mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-21 21:15:09 +00:00
feat(tui): per-path concurrency limit + /health observability (PR-B) (#139)
Two P1 audit fixes for the TUI subscription-pool bridge (ADR 0007). The
default path (CLAUDE_TUI_MODE unset) is unchanged except the additive
/health `tui` block (enabled:false when off).
C-4 — TUI path had NO concurrency bound. The global MAX_CONCURRENT gate
lives in spawnClaudeProcess (the -p/stream-json path); callClaudeTui never
calls it — it calls runTuiTurn, which cold-boots a full interactive claude
in tmux. So N concurrent TUI requests spawned N simultaneous cold boots (a
family burst of 5 on a Pi 4 = OOM risk + subscription rate-limit pressure).
Adds an independent in-process limiter (lib/tui/semaphore.mjs, TuiSemaphore)
gating callClaudeTui: OCP_TUI_MAX_CONCURRENT (default 2 — a TUI turn is heavy:
per-request cold-boot + up to 120s wallclock). Queues rather than rejects
(mirrors MAX_CONCURRENT intent), with a bounded wait queue (default 32x the
limit) → tui_queue_full (503) on overflow rather than unbounded growth. The
slot releases in a finally, so PR-A's honesty-gate throws / timeouts / paste
failures never leak a slot.
C-5 — no operator-visible TUI drift surface. The tui_entrypoint_mismatch
warning only reached journald; after the 6/15 flip a silent sdk-cli drift
(the documented top risk) would drain metered credits invisibly. Adds an
additive `tui` block to /health: { enabled, entrypointMode, lastEntrypoint,
entrypointMismatches, inflight, queued, maxConcurrent }. lastEntrypoint /
entrypointMismatches are recorded in callClaudeTui (same mismatch branch the
journald warning covers); inflight/queued come from the C-4 semaphore.
ALIGNMENT (Class B): cli.js does NOT perform this operation — both the TUI
path and /health are OCP-owned, so no cli.js citation applies. /health is a
grandfathered B.2 endpoint (ADR 0006, frozen at v3.16.4). The response-shape
change is authorized by the ADR 0007 PR-B amendment added in this commit and
is behaviour-preserving: the `tui` block is NEW fields only — no existing
/health field is changed, renamed, removed, or re-typed, and no existing
semantics change, so existing consumers (dashboard, ocp-connect, monitoring)
are unaffected. Per ALIGNMENT.md's grandfather provision, an additive
behaviour-preserving change to a grandfathered B.2 endpoint is authorized by
an ADR; ADR 0007 is the authority for the TUI observability surface. No
Class A forwarding path, no alignment.yml, no models.json touched — alignment
blacklist is unaffected (zero new network tokens).
Tests: 11 new (lib/tui/semaphore.mjs is importable, so the semaphore + the
two pure /health helpers are tested directly): limit=1 serializes two
overlapping calls; limit=2 runs two + queues the third; slot released on
throw; bounded queue → tui_queue_full; mismatch counter increments on
cli→other drift; auto mode never counts a mismatch; /health tui block shape
+ live inflight/queued. npm test: 235 passed, 0 failed (was 224).
Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude <claude-opus> <noreply@anthropic.com>
This commit is contained in:
@@ -725,7 +725,7 @@ The canonical list lives in [`models.json`](./models.json) — the single source
|
|||||||
|----------|--------|-------------|
|
|----------|--------|-------------|
|
||||||
| `/v1/models` | GET | List available models |
|
| `/v1/models` | GET | List available models |
|
||||||
| `/v1/chat/completions` | POST | Chat completion (streaming + non-streaming) |
|
| `/v1/chat/completions` | POST | Chat completion (streaming + non-streaming) |
|
||||||
| `/health` | GET | Comprehensive health check |
|
| `/health` | GET | Comprehensive health check (includes a `tui` block for TUI-mode drift/concurrency monitoring) |
|
||||||
| `/usage` | GET | Plan usage limits + per-model stats |
|
| `/usage` | GET | Plan usage limits + per-model stats |
|
||||||
| `/status` | GET | Combined overview (usage + health) |
|
| `/status` | GET | Combined overview (usage + health) |
|
||||||
| `/settings` | GET/PATCH | View or update settings at runtime |
|
| `/settings` | GET/PATCH | View or update settings at runtime |
|
||||||
@@ -898,6 +898,7 @@ Future `ocp update` invocations sync automatically.
|
|||||||
| `OCP_TUI_CWD` | `$HOME/.ocp-tui/work` | (TUI-mode) Scratch working directory where interactive claude sessions run. Transcripts land under `<HOME>/.claude/projects/<encoded-cwd>/`. Created automatically. |
|
| `OCP_TUI_CWD` | `$HOME/.ocp-tui/work` | (TUI-mode) Scratch working directory where interactive claude sessions run. Transcripts land under `<HOME>/.claude/projects/<encoded-cwd>/`. Created automatically. |
|
||||||
| `OCP_TUI_HOME` | `$HOME` (real home) | (TUI-mode) `HOME` claude runs under. Default is the operator's real home (shared credentials, existing onboarding). Set to a separate path for scratch-home isolation — see ADR 0007 for the credential-fork caveat. |
|
| `OCP_TUI_HOME` | `$HOME` (real home) | (TUI-mode) `HOME` claude runs under. Default is the operator's real home (shared credentials, existing onboarding). Set to a separate path for scratch-home isolation — see ADR 0007 for the credential-fork caveat. |
|
||||||
| `OCP_TUI_ENTRYPOINT` | `cli` | (TUI-mode) Billing-classifier labeling: `cli` (default) pins `cc_entrypoint=cli` deterministically; `auto` lets claude self-classify via TTY detection; `off` leaves the inherited env untouched. Honest only when the spawn is a genuine interactive PTY — see ADR 0007. |
|
| `OCP_TUI_ENTRYPOINT` | `cli` | (TUI-mode) Billing-classifier labeling: `cli` (default) pins `cc_entrypoint=cli` deterministically; `auto` lets claude self-classify via TTY detection; `off` leaves the inherited env untouched. Honest only when the spawn is a genuine interactive PTY — see ADR 0007. |
|
||||||
|
| `OCP_TUI_MAX_CONCURRENT` | `2` | (TUI-mode) Max concurrent interactive TUI turns. **Independent** of `CLAUDE_MAX_CONCURRENT` (which bounds the `-p`/stream-json path; TUI never uses it). A TUI turn is heavy (per-request cold-boot of tmux+claude + up to `CLAUDE_TUI_WALLCLOCK_MS` wallclock), so the default is low to keep small hosts (e.g. a Pi 4) alive under a burst. Excess turns **queue** (bounded); a full queue yields a 503. See ADR 0007 PR-B amendment. |
|
||||||
| `OCP_SKIP_AUTH_TEST` | *(unset)* | When `=1`, skip the `claude -p` auth probe during `setup.mjs`. After 2026-06-15 this probe draws from the Agent SDK credit pool; set this to avoid burning a metered credit on re-installs or `ocp update` runs. Auth is validated at the first real request. |
|
| `OCP_SKIP_AUTH_TEST` | *(unset)* | When `=1`, skip the `claude -p` auth probe during `setup.mjs`. After 2026-06-15 this probe draws from the Agent SDK credit pool; set this to avoid burning a metered credit on re-installs or `ocp update` runs. Auth is validated at the first real request. |
|
||||||
| `OCP_TUI_FULL_TOOLS` | *(unset)* | (TUI-mode, **single-user only**) When `=1`, grant the interactive session the **same tool surface as the `-p` path** — `--allowedTools` (+ optional `--mcp-config` / `--dangerously-skip-permissions`, read from `CLAUDE_ALLOWED_TOOLS` / `CLAUDE_MCP_CONFIG` / `CLAUDE_SKIP_PERMISSIONS`) — instead of the default MCP-walled, built-in-tools-only set. Lets a trusted single-operator TUI deployment run a **tool-using / MCP agent** (e.g. an OpenClaw assistant) on the subscription pool. Safe because TUI **refuses to boot under `AUTH_MODE=multi`** (hard exit) — no guest key can ever reach the TUI path, so this gate cannot expose tools to an untrusted caller. (Under `AUTH_MODE=shared` + `OCP_TUI_ALLOW_LAN=1`, anyone holding the single shared key reaches it — that is the existing TUI trust model, unchanged.) See [Subscription-pool (TUI) mode](#subscription-pool-tui-mode) and ADR 0007. |
|
| `OCP_TUI_FULL_TOOLS` | *(unset)* | (TUI-mode, **single-user only**) When `=1`, grant the interactive session the **same tool surface as the `-p` path** — `--allowedTools` (+ optional `--mcp-config` / `--dangerously-skip-permissions`, read from `CLAUDE_ALLOWED_TOOLS` / `CLAUDE_MCP_CONFIG` / `CLAUDE_SKIP_PERMISSIONS`) — instead of the default MCP-walled, built-in-tools-only set. Lets a trusted single-operator TUI deployment run a **tool-using / MCP agent** (e.g. an OpenClaw assistant) on the subscription pool. Safe because TUI **refuses to boot under `AUTH_MODE=multi`** (hard exit) — no guest key can ever reach the TUI path, so this gate cannot expose tools to an untrusted caller. (Under `AUTH_MODE=shared` + `OCP_TUI_ALLOW_LAN=1`, anyone holding the single shared key reaches it — that is the existing TUI trust model, unchanged.) See [Subscription-pool (TUI) mode](#subscription-pool-tui-mode) and ADR 0007. |
|
||||||
|
|
||||||
@@ -965,6 +966,25 @@ Then restart OCP. At boot you will see:
|
|||||||
- **Cache and singleflight work normally.** TUI-mode writes the buffered response to the cache on success; cache-hits skip the interactive turn entirely.
|
- **Cache and singleflight work normally.** TUI-mode writes the buffered response to the cache on success; cache-hits skip the interactive turn entirely.
|
||||||
- **The host's `CLAUDE.md` / auto-memory is never injected.** OCP is a proxy — the proxied client (OpenClaw / your IDE) owns its own context and memory. TUI-mode always runs `claude` with `CLAUDE_CODE_DISABLE_CLAUDE_MDS` + `CLAUDE_CODE_DISABLE_AUTO_MEMORY`, so a `CLAUDE.md` on the OCP host can never leak into proxied turns (verified live; see #4). Built-in tool schemas + the interactive system prompt remain (the inherent ~20–35K context floor of interactive mode); MCP is hard-disabled.
|
- **The host's `CLAUDE.md` / auto-memory is never injected.** OCP is a proxy — the proxied client (OpenClaw / your IDE) owns its own context and memory. TUI-mode always runs `claude` with `CLAUDE_CODE_DISABLE_CLAUDE_MDS` + `CLAUDE_CODE_DISABLE_AUTO_MEMORY`, so a `CLAUDE.md` on the OCP host can never leak into proxied turns (verified live; see #4). Built-in tool schemas + the interactive system prompt remain (the inherent ~20–35K context floor of interactive mode); MCP is hard-disabled.
|
||||||
- **Default path unchanged.** Unset `CLAUDE_TUI_MODE` and restart → `callClaude` / `callClaudeStreaming` are used again, byte-for-byte identical to today.
|
- **Default path unchanged.** Unset `CLAUDE_TUI_MODE` and restart → `callClaude` / `callClaudeStreaming` are used again, byte-for-byte identical to today.
|
||||||
|
- **Concurrency is bounded separately.** TUI turns are heavy (per-request cold-boot + long wallclock), so the TUI path has its own limiter — `OCP_TUI_MAX_CONCURRENT` (default `2`), independent of `CLAUDE_MAX_CONCURRENT`. Excess turns queue; a full queue returns a 503. Tune it up only on a host that can run more interactive `claude` sessions at once.
|
||||||
|
|
||||||
|
### Monitoring drift via `/health`
|
||||||
|
|
||||||
|
`GET /health` includes a `tui` block so you can poll for a silent billing-pool drift (the top risk after the 6/15 flip — a lost TTY flipping `cc_entrypoint` from `cli` to the metered `sdk-cli` pool would still return answers but burn metered credits). The block is **always present** (with `enabled:false` when TUI-mode is off):
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
"tui": {
|
||||||
|
"enabled": true, // CLAUDE_TUI_MODE === "true"
|
||||||
|
"entrypointMode": "cli", // OCP_TUI_ENTRYPOINT (cli | auto | off)
|
||||||
|
"lastEntrypoint": "cli", // last cc_entrypoint observed in a transcript, or null
|
||||||
|
"entrypointMismatches": 0, // count of cli-expected-but-got-other turns — ALERT if this climbs
|
||||||
|
"inflight": 1, // TUI turns running right now
|
||||||
|
"queued": 0, // TUI turns waiting for a concurrency slot
|
||||||
|
"maxConcurrent": 2 // OCP_TUI_MAX_CONCURRENT
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Alert on `entrypointMismatches > 0` (or `lastEntrypoint !== "cli"`): it means a turn drew from the metered Agent SDK pool instead of the subscription. `inflight` / `queued` show how close the TUI path is to its concurrency cap.
|
||||||
|
|
||||||
### Kill-switch
|
### Kill-switch
|
||||||
|
|
||||||
|
|||||||
@@ -139,6 +139,101 @@ B-path is **deferred** and is not implemented in this ADR. Until B-path lands, T
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Observability and concurrency (PR-B amendment)
|
||||||
|
|
||||||
|
**Date:** 2026-06-10
|
||||||
|
**Status:** Accepted — amends ADR 0007.
|
||||||
|
**Motivation:** the post-PR-A code audit, findings C-4 (P1) and C-5 (P1).
|
||||||
|
|
||||||
|
### C-4 — independent concurrency bound for the TUI path
|
||||||
|
|
||||||
|
The global `MAX_CONCURRENT` gate lives in `spawnClaudeProcess()` (the `-p` / stream-json
|
||||||
|
path). `callClaudeTui()` never calls `spawnClaudeProcess` — it calls `runTuiTurn()`, which
|
||||||
|
cold-boots a full interactive `claude` inside a fresh tmux session. So the TUI path had **no**
|
||||||
|
concurrency bound: N concurrent TUI requests spawned N simultaneous cold-boot tmux+claude
|
||||||
|
processes. On a small host (e.g. a Pi 4 serving a family) a burst of ~5 is an OOM risk and
|
||||||
|
also multiplies subscription rate-limit pressure.
|
||||||
|
|
||||||
|
PR-B adds an **independent** limiter for the TUI path (`lib/tui/semaphore.mjs`,
|
||||||
|
`TuiSemaphore`):
|
||||||
|
|
||||||
|
- **`OCP_TUI_MAX_CONCURRENT`, default `2`.** Rationale: a TUI turn is heavy — a per-request
|
||||||
|
cold-boot of tmux+claude plus up to `CLAUDE_TUI_WALLCLOCK_MS` (120 s) of wallclock — so a
|
||||||
|
small host cannot run many at once. `2` is the conservative default that keeps a Pi-class
|
||||||
|
host alive under a family burst while still allowing some overlap. It is deliberately **not**
|
||||||
|
the same knob as `MAX_CONCURRENT` (default 8): the two pools have different shapes (a
|
||||||
|
stream-json spawn is cheap and fast; a TUI turn is a heavy cold-boot + long wallclock), so
|
||||||
|
coupling them would mis-size one of the two paths.
|
||||||
|
- **Queue, don't reject.** The limiter **queues** (awaits a slot), mirroring the spirit of
|
||||||
|
`MAX_CONCURRENT` — requests are not dropped on contention. To bound memory against a runaway
|
||||||
|
client, the wait queue itself is capped (`maxQueue`, default 32× the limit); when the queue
|
||||||
|
is full `run()` rejects with `tui_queue_full`, surfaced as a 503 — deterministic backpressure
|
||||||
|
rather than silent OOM.
|
||||||
|
- **Slot released in a `finally`.** `TuiSemaphore.run(fn)` releases the slot in a `finally`, so
|
||||||
|
any throw — PR-A's honesty gates (`tui_wallclock_truncated`, `tui_upstream_error`), a
|
||||||
|
`tui_paste_not_landed`, or a `tui_spawn_failed` — can never leak a slot.
|
||||||
|
|
||||||
|
This limiter has **zero effect when `TUI_MODE` is off**: `callClaudeTui` is never reached, so
|
||||||
|
the semaphore is never entered. The default stream-json path is untouched.
|
||||||
|
|
||||||
|
### C-5 — operator-visible drift surface on `/health` (additive)
|
||||||
|
|
||||||
|
The `tui_entrypoint_mismatch` warning only reached journald. After the 2026-06-15 flip, a
|
||||||
|
silent `sdk-cli` drift (the documented top risk in this ADR — a lost TTY flipping the
|
||||||
|
self-classification to the metered Agent SDK pool) would drain metered credits **invisibly**.
|
||||||
|
PR-B adds a `tui` block to the `/health` JSON response so an operator can poll it:
|
||||||
|
|
||||||
|
```
|
||||||
|
tui: {
|
||||||
|
enabled: <TUI_MODE>,
|
||||||
|
entrypointMode: <OCP_TUI_ENTRYPOINT>, // cli | auto | off
|
||||||
|
lastEntrypoint: <last observed cc_entrypoint, e.g. "cli", or null>,
|
||||||
|
entrypointMismatches: <count of cli-expected-but-got-other turns>,
|
||||||
|
inflight: <current concurrent TUI turns>,
|
||||||
|
queued: <turns waiting for a slot>,
|
||||||
|
maxConcurrent: <OCP_TUI_MAX_CONCURRENT>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`lastEntrypoint` is recorded and `entrypointMismatches` incremented inside `callClaudeTui` in
|
||||||
|
the same mismatch branch that already emits the journald warning (via `recordTuiEntrypoint`).
|
||||||
|
`inflight` / `queued` / `maxConcurrent` come from the C-4 semaphore. When `TUI_MODE` is off the
|
||||||
|
block still appears with `enabled:false` (cheap, harmless) so the response shape is stable for
|
||||||
|
consumers regardless of mode.
|
||||||
|
|
||||||
|
### ALIGNMENT authorization for the `/health` change
|
||||||
|
|
||||||
|
`/health` is a **grandfathered B.2 endpoint** under ADR 0006, frozen at its v3.16.4 behaviour.
|
||||||
|
`ALIGNMENT.md`'s grandfather provision states: *"Any change to the contract (request shape,
|
||||||
|
response shape, semantics) of a grandfathered B.2 endpoint is treated as a new authorization
|
||||||
|
request and requires either a behaviour-preserving refactor PR or its own ADR."*
|
||||||
|
|
||||||
|
This amendment **is** that authorization. The argument:
|
||||||
|
|
||||||
|
- The change is **additive**: it adds one new top-level field (`tui`) containing only new
|
||||||
|
sub-fields. **No existing `/health` field is changed, renamed, removed, or re-typed**, and no
|
||||||
|
existing semantics change. Existing `/health` consumers (the dashboard, `ocp-connect`,
|
||||||
|
monitoring) read the fields they already read and are unaffected — the change is
|
||||||
|
**behaviour-preserving** for them, which is exactly the bar the grandfather provision sets for
|
||||||
|
a non-ADR contract change.
|
||||||
|
- The TUI observability surface is an **intrinsic part of the TUI feature** whose authorizing
|
||||||
|
authority is **this ADR (0007)**, not a brand-new B.2 endpoint. We are not adding a new B.2
|
||||||
|
endpoint or a new method (which would each require their own fresh ADR under the New Class B
|
||||||
|
endpoint procedure) — we are extending the response of an existing grandfathered endpoint with
|
||||||
|
fields that report state owned by an ADR-0007 feature. ADR 0007 is the natural home for that
|
||||||
|
authority, and this amendment records it explicitly.
|
||||||
|
- `cli.js` does not perform this operation — `/health` is OCP-owned (Class B), so no `cli.js`
|
||||||
|
citation applies; the citation is this ADR + ADR 0006 (grandfathered B.2) per
|
||||||
|
`ALIGNMENT.md`'s Class B citation requirement.
|
||||||
|
|
||||||
|
### `OCP_TUI_MAX_CONCURRENT` summary
|
||||||
|
|
||||||
|
| Env var | Default | Meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| `OCP_TUI_MAX_CONCURRENT` | `2` | Max concurrent interactive TUI turns. Independent of `CLAUDE_MAX_CONCURRENT` (the stream-json path). Excess turns queue (bounded); a full queue yields a 503. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Consequences
|
## Consequences
|
||||||
|
|
||||||
### Positive
|
### Positive
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
// TUI-path concurrency limiter (audit finding C-4).
|
||||||
|
//
|
||||||
|
// WHY THIS EXISTS, SEPARATE FROM server.mjs's MAX_CONCURRENT:
|
||||||
|
// The global MAX_CONCURRENT gate lives in spawnClaudeProcess() (the -p / stream-json
|
||||||
|
// path). callClaudeTui() NEVER calls spawnClaudeProcess — it calls runTuiTurn(), which
|
||||||
|
// boots a full interactive `claude` inside a fresh tmux session. So nothing bounded the
|
||||||
|
// TUI path: N concurrent TUI requests spawned N simultaneous cold-boot tmux+claude
|
||||||
|
// processes. On a small host (a Pi 4 serving a family) a burst of ~5 is an OOM risk, and
|
||||||
|
// it also multiplies subscription rate-limit pressure. This is an INDEPENDENT limiter for
|
||||||
|
// the TUI path that mirrors MAX_CONCURRENT's intent without coupling to it (the two pools
|
||||||
|
// are different shapes: a stream-json spawn is cheap and fast; a TUI turn is a heavy
|
||||||
|
// cold-boot + up to 120s wallclock).
|
||||||
|
//
|
||||||
|
// QUEUE vs REJECT: we QUEUE (await a slot), mirroring the spirit of MAX_CONCURRENT's
|
||||||
|
// intent not to drop requests, rather than rejecting immediately. To avoid unbounded
|
||||||
|
// memory growth from a runaway client, the wait queue itself is bounded by maxQueue
|
||||||
|
// (default: a generous multiple of the concurrency limit). When the queue is full, run()
|
||||||
|
// rejects with a tui_queue_full error (the caller surfaces it as a 503) — a deterministic
|
||||||
|
// backpressure signal rather than silent OOM.
|
||||||
|
//
|
||||||
|
// Pure + importable so test-features.mjs can assert the bound directly (no server boot).
|
||||||
|
|
||||||
|
export class TuiSemaphore {
|
||||||
|
// limit: max concurrent slots. maxQueue: max waiters before run() rejects with backpressure.
|
||||||
|
constructor(limit, { maxQueue } = {}) {
|
||||||
|
this.limit = Math.max(1, parseInt(limit, 10) || 1);
|
||||||
|
// Default queue cap: 32× the limit. Large enough that real family-burst traffic never
|
||||||
|
// hits it, small enough that a pathological flood can't grow the queue without bound.
|
||||||
|
this.maxQueue = Number.isFinite(maxQueue) ? maxQueue : this.limit * 32;
|
||||||
|
this._inflight = 0;
|
||||||
|
this._waiters = []; // FIFO queue of resolve callbacks waiting for a slot
|
||||||
|
}
|
||||||
|
|
||||||
|
get inflight() { return this._inflight; }
|
||||||
|
get queued() { return this._waiters.length; }
|
||||||
|
|
||||||
|
// Acquire a slot. Resolves once a slot is free (immediately if under the limit, otherwise
|
||||||
|
// when an in-flight task releases). Rejects synchronously-ish if the wait queue is full.
|
||||||
|
acquire() {
|
||||||
|
if (this._inflight < this.limit) {
|
||||||
|
this._inflight++;
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
if (this._waiters.length >= this.maxQueue) {
|
||||||
|
return Promise.reject(new Error(
|
||||||
|
`tui_queue_full: TUI concurrency limit (${this.limit}) reached and wait queue ` +
|
||||||
|
`(${this.maxQueue}) is full`));
|
||||||
|
}
|
||||||
|
return new Promise((resolve) => { this._waiters.push(resolve); });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Release a slot. If a waiter is queued, hand the slot directly to it (inflight stays
|
||||||
|
// constant across the handoff); otherwise decrement.
|
||||||
|
release() {
|
||||||
|
const next = this._waiters.shift();
|
||||||
|
if (next) {
|
||||||
|
next(); // the woken waiter already "owns" the slot — inflight unchanged
|
||||||
|
} else if (this._inflight > 0) {
|
||||||
|
this._inflight--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run fn() under one slot. Releases in a finally so a throw (PR-A's honesty gates,
|
||||||
|
// wallclock truncation, paste-not-landed, tmux spawn failure) NEVER leaks a slot.
|
||||||
|
async run(fn) {
|
||||||
|
await this.acquire();
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} finally {
|
||||||
|
this.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TUI drift observability (audit C-5) — pure helpers, importable for testing ──
|
||||||
|
|
||||||
|
// Record an observed cc_entrypoint into the (mutable) tuiStats counter. Sets lastEntrypoint
|
||||||
|
// unconditionally and increments entrypointMismatches when the spawn was supposed to be
|
||||||
|
// subscription-pool ("cli") but the transcript reported something else (a silent drift to
|
||||||
|
// the metered Agent SDK pool — the audit's top risk after the 6/15 billing flip).
|
||||||
|
// Returns true iff this observation was a mismatch (so the caller can also emit a log).
|
||||||
|
export function recordTuiEntrypoint(tuiStats, observed, expectedMode = "cli") {
|
||||||
|
tuiStats.lastEntrypoint = observed ?? null;
|
||||||
|
const mismatch = expectedMode === "cli" && observed !== "cli";
|
||||||
|
if (mismatch) tuiStats.entrypointMismatches++;
|
||||||
|
return mismatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the additive /health `tui` block (ADR 0007 PR-B amendment). Pure: given the
|
||||||
|
// config + live counters, returns the exact object embedded in /health. New fields only —
|
||||||
|
// behaviour-preserving for existing /health consumers (grandfathered B.2 under ADR 0006).
|
||||||
|
export function buildTuiHealthBlock({ enabled, entrypointMode, maxConcurrent }, tuiStats, semaphore) {
|
||||||
|
return {
|
||||||
|
enabled,
|
||||||
|
entrypointMode, // cli | auto | off
|
||||||
|
lastEntrypoint: tuiStats.lastEntrypoint, // last observed cc_entrypoint, or null
|
||||||
|
entrypointMismatches: tuiStats.entrypointMismatches,
|
||||||
|
inflight: semaphore.inflight, // current concurrent TUI turns
|
||||||
|
queued: semaphore.queued, // turns waiting for a slot
|
||||||
|
maxConcurrent,
|
||||||
|
};
|
||||||
|
}
|
||||||
+43
-5
@@ -19,7 +19,8 @@
|
|||||||
* CLAUDE_SYSTEM_PROMPT — system prompt appended to all requests
|
* CLAUDE_SYSTEM_PROMPT — system prompt appended to all requests
|
||||||
* CLAUDE_MCP_CONFIG — path to MCP server config JSON file
|
* CLAUDE_MCP_CONFIG — path to MCP server config JSON file
|
||||||
* CLAUDE_SESSION_TTL — session TTL in ms (default: 3600000 = 1h)
|
* CLAUDE_SESSION_TTL — session TTL in ms (default: 3600000 = 1h)
|
||||||
* CLAUDE_MAX_CONCURRENT — max concurrent claude processes (default: 8)
|
* CLAUDE_MAX_CONCURRENT — max concurrent claude processes, -p/stream-json path (default: 8)
|
||||||
|
* OCP_TUI_MAX_CONCURRENT — max concurrent interactive TUI turns, TUI-mode path (default: 2)
|
||||||
* CLAUDE_BREAKER_THRESHOLD — failures in window before circuit opens (default: 6)
|
* CLAUDE_BREAKER_THRESHOLD — failures in window before circuit opens (default: 6)
|
||||||
* CLAUDE_BREAKER_COOLDOWN — base ms to wait before retrying after circuit opens (default: 120000)
|
* CLAUDE_BREAKER_COOLDOWN — base ms to wait before retrying after circuit opens (default: 120000)
|
||||||
* CLAUDE_BREAKER_WINDOW — sliding window duration in ms (default: 300000 = 5min)
|
* CLAUDE_BREAKER_WINDOW — sliding window duration in ms (default: 300000 = 5min)
|
||||||
@@ -39,6 +40,7 @@ import { DEFAULT_PORT } from "./lib/constants.mjs";
|
|||||||
import { isLoopbackBind } from "./lib/net.mjs";
|
import { isLoopbackBind } from "./lib/net.mjs";
|
||||||
import { runTuiTurn, reapStaleTuiSessions } from "./lib/tui/session.mjs";
|
import { runTuiTurn, reapStaleTuiSessions } from "./lib/tui/session.mjs";
|
||||||
import { detectTuiUpstreamError } from "./lib/tui/transcript.mjs";
|
import { detectTuiUpstreamError } from "./lib/tui/transcript.mjs";
|
||||||
|
import { TuiSemaphore, recordTuiEntrypoint, buildTuiHealthBlock } from "./lib/tui/semaphore.mjs";
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
const _pkg = JSON.parse(readFileSync(join(__dirname, "package.json"), "utf8"));
|
const _pkg = JSON.parse(readFileSync(join(__dirname, "package.json"), "utf8"));
|
||||||
@@ -300,6 +302,22 @@ const TUI_WALLCLOCK_MS = parseInt(process.env.CLAUDE_TUI_WALLCLOCK_MS || "120000
|
|||||||
const TUI_CWD = process.env.OCP_TUI_CWD || `${process.env.HOME}/.ocp-tui/work`;
|
const TUI_CWD = process.env.OCP_TUI_CWD || `${process.env.HOME}/.ocp-tui/work`;
|
||||||
const TUI_HOME = process.env.OCP_TUI_HOME || process.env.HOME;
|
const TUI_HOME = process.env.OCP_TUI_HOME || process.env.HOME;
|
||||||
const TUI_ENTRYPOINT = process.env.OCP_TUI_ENTRYPOINT || "cli"; // cli|auto|off — see ADR 0007
|
const TUI_ENTRYPOINT = process.env.OCP_TUI_ENTRYPOINT || "cli"; // cli|auto|off — see ADR 0007
|
||||||
|
// Independent concurrency bound for the TUI path (audit C-4). Default 2: a TUI turn is
|
||||||
|
// HEAVY (per-request cold-boot of a tmux+claude session + up to TUI_WALLCLOCK_MS=120s of
|
||||||
|
// wallclock), so a small host (e.g. a Pi 4 serving a family) cannot run many at once
|
||||||
|
// without OOM + multiplied subscription rate-limit pressure. This is NOT the global
|
||||||
|
// MAX_CONCURRENT gate (that lives in spawnClaudeProcess, the -p/stream-json path, which
|
||||||
|
// callClaudeTui never reaches). See ADR 0007 PR-B amendment + lib/tui/semaphore.mjs.
|
||||||
|
const TUI_MAX_CONCURRENT = parseInt(process.env.OCP_TUI_MAX_CONCURRENT || "2", 10);
|
||||||
|
const tuiSemaphore = new TuiSemaphore(TUI_MAX_CONCURRENT);
|
||||||
|
// Operator-visible TUI drift surface (audit C-5). lastEntrypoint + entrypointMismatches
|
||||||
|
// let the operator poll /health to catch a silent metered-pool drift (the audit's top
|
||||||
|
// risk: after the 6/15 flip a TTY-loss could flip cc_entrypoint cli→sdk-cli and drain
|
||||||
|
// metered credits invisibly — the warning currently only reaches journald).
|
||||||
|
const tuiStats = {
|
||||||
|
lastEntrypoint: null, // last observed cc_entrypoint from the transcript ("cli" | "sdk-cli" | null)
|
||||||
|
entrypointMismatches: 0, // count of cli-expected-but-got-other turns
|
||||||
|
};
|
||||||
|
|
||||||
// SECURITY fail-loud: TUI-mode is incompatible with any configuration that allows
|
// SECURITY fail-loud: TUI-mode is incompatible with any configuration that allows
|
||||||
// non-operator prompts to reach the interactive claude session. Three cases:
|
// non-operator prompts to reach the interactive claude session. Three cases:
|
||||||
@@ -918,7 +936,11 @@ function callClaudeTui(model, messages, _conversationId, _keyName) {
|
|||||||
const cliModel = MODEL_MAP[model] || model;
|
const cliModel = MODEL_MAP[model] || model;
|
||||||
const prompt = messagesToPrompt(messages); // includes system as [System] inline
|
const prompt = messagesToPrompt(messages); // includes system as [System] inline
|
||||||
recordModelRequest(cliModel, prompt.length);
|
recordModelRequest(cliModel, prompt.length);
|
||||||
return runTuiTurn({
|
// C-4: gate the heavy interactive boot behind the TUI semaphore. run() acquires a slot
|
||||||
|
// (queuing if all are busy, up to maxQueue), then releases in a finally so any throw from
|
||||||
|
// runTuiTurn (tmux spawn failure, paste-not-landed) OR from the honesty gates below
|
||||||
|
// (truncation / error banner) can NEVER leak a slot. tuiSemaphore.inflight feeds /health.
|
||||||
|
return tuiSemaphore.run(() => runTuiTurn({
|
||||||
prompt,
|
prompt,
|
||||||
model: cliModel,
|
model: cliModel,
|
||||||
claudeBin: CLAUDE,
|
claudeBin: CLAUDE,
|
||||||
@@ -955,14 +977,19 @@ function callClaudeTui(model, messages, _conversationId, _keyName) {
|
|||||||
// Assert the subscription-pool classification. TUI exists to keep cc_entrypoint=cli
|
// Assert the subscription-pool classification. TUI exists to keep cc_entrypoint=cli
|
||||||
// (subscription pool); a silent degrade to sdk-cli (metered Agent SDK pool) would still
|
// (subscription pool); a silent degrade to sdk-cli (metered Agent SDK pool) would still
|
||||||
// return text but cost money — warn loudly so it's visible. (issue #115)
|
// return text but cost money — warn loudly so it's visible. (issue #115)
|
||||||
if (TUI_ENTRYPOINT === "cli" && entrypoint !== "cli") {
|
// C-5: also surface the observation on /health. recordTuiEntrypoint sets lastEntrypoint
|
||||||
|
// unconditionally (operators can poll it to confirm cli) and increments
|
||||||
|
// entrypointMismatches when expected=cli but observed≠cli — the same condition the
|
||||||
|
// journald warning already covers — so a silent metered-pool drift is visible on /health
|
||||||
|
// without tailing logs.
|
||||||
|
if (recordTuiEntrypoint(tuiStats, entrypoint, TUI_ENTRYPOINT)) {
|
||||||
logEvent("warn", "tui_entrypoint_mismatch", { expected: "cli", got: entrypoint, model: cliModel });
|
logEvent("warn", "tui_entrypoint_mismatch", { expected: "cli", got: entrypoint, model: cliModel });
|
||||||
}
|
}
|
||||||
return text;
|
return text;
|
||||||
}).catch((err) => {
|
}).catch((err) => {
|
||||||
recordModelError(cliModel, false);
|
recordModelError(cliModel, false);
|
||||||
throw err;
|
throw err;
|
||||||
});
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── SSE heartbeat (opt-in idle watchdog) ────────────────────────────────
|
// ── SSE heartbeat (opt-in idle watchdog) ────────────────────────────────
|
||||||
@@ -2014,6 +2041,17 @@ const server = createServer(async (req, res) => {
|
|||||||
circuitBreaker: "disabled",
|
circuitBreaker: "disabled",
|
||||||
sessions: sessionList,
|
sessions: sessionList,
|
||||||
recentErrors: recentErrors.slice(-5),
|
recentErrors: recentErrors.slice(-5),
|
||||||
|
// ── TUI observability (audit C-5) — ADDITIVE block (ADR 0007 PR-B amendment) ──
|
||||||
|
// /health is a grandfathered B.2 endpoint (ADR 0006). This block is NEW fields only;
|
||||||
|
// every existing field above is byte-identical → behaviour-preserving for existing
|
||||||
|
// consumers per ALIGNMENT.md's grandfather provision. When TUI_MODE is off the block
|
||||||
|
// still appears with enabled:false (cheap, harmless) so the shape is stable.
|
||||||
|
// entrypointMismatches/lastEntrypoint exist so an operator can poll /health to catch a
|
||||||
|
// silent metered-pool drift (the audit's top risk after the 6/15 billing flip).
|
||||||
|
tui: buildTuiHealthBlock(
|
||||||
|
{ enabled: TUI_MODE, entrypointMode: TUI_ENTRYPOINT, maxConcurrent: TUI_MAX_CONCURRENT },
|
||||||
|
tuiStats, tuiSemaphore,
|
||||||
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2304,7 +2342,7 @@ server.listen(PORT, BIND_ADDRESS, () => {
|
|||||||
else console.log(`Cache: disabled (set CLAUDE_CACHE_TTL to enable)`);
|
else console.log(`Cache: disabled (set CLAUDE_CACHE_TTL to enable)`);
|
||||||
if (TUI_MODE) {
|
if (TUI_MODE) {
|
||||||
console.warn(`⚠️ TUI-mode ON — single-user only; do NOT enable on a multi-user OCP (guest prompts would run claude with operator filesystem access). See ADR 0007.`);
|
console.warn(`⚠️ TUI-mode ON — single-user only; do NOT enable on a multi-user OCP (guest prompts would run claude with operator filesystem access). See ADR 0007.`);
|
||||||
console.log(` TUI-mode: ON home=${TUI_HOME} cwd=${TUI_CWD} wallclock=${TUI_WALLCLOCK_MS}ms`);
|
console.log(` TUI-mode: ON home=${TUI_HOME} cwd=${TUI_CWD} wallclock=${TUI_WALLCLOCK_MS}ms maxConcurrent=${TUI_MAX_CONCURRENT}`);
|
||||||
try {
|
try {
|
||||||
const n = reapStaleTuiSessions();
|
const n = reapStaleTuiSessions();
|
||||||
if (n) logEvent("info", "tui_reaped_stale_sessions", { count: n });
|
if (n) logEvent("info", "tui_reaped_stale_sessions", { count: n });
|
||||||
|
|||||||
@@ -1851,6 +1851,147 @@ test("default mode (no second arg) behaves like 'cli'", () => {
|
|||||||
assert.equal(env.CLAUDE_CODE_ENTRYPOINT, "cli");
|
assert.equal(env.CLAUDE_CODE_ENTRYPOINT, "cli");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── TUI concurrency limiter + drift observability (PR-B: audit C-4 / C-5) ──
|
||||||
|
import { TuiSemaphore, recordTuiEntrypoint, buildTuiHealthBlock } from "./lib/tui/semaphore.mjs";
|
||||||
|
|
||||||
|
console.log("\nTUI concurrency limiter (C-4):");
|
||||||
|
|
||||||
|
const deferred = () => { let resolve, reject; const p = new Promise((res, rej) => { resolve = res; reject = rej; }); return { p, resolve, reject }; };
|
||||||
|
|
||||||
|
await asyncTest("limit=1 serializes two overlapping calls (second waits for the first)", async () => {
|
||||||
|
const sem = new TuiSemaphore(1);
|
||||||
|
const order = [];
|
||||||
|
const g1 = deferred();
|
||||||
|
// First task acquires the only slot and blocks on g1.
|
||||||
|
const t1 = sem.run(async () => { order.push("t1-start"); await g1.p; order.push("t1-end"); });
|
||||||
|
await new Promise((r) => setImmediate(r)); // let t1 acquire
|
||||||
|
assert.equal(sem.inflight, 1, "t1 holds the only slot");
|
||||||
|
// Second task must QUEUE — it has not started yet.
|
||||||
|
const t2 = sem.run(async () => { order.push("t2-start"); });
|
||||||
|
await new Promise((r) => setImmediate(r));
|
||||||
|
assert.equal(sem.queued, 1, "t2 is queued, not running");
|
||||||
|
assert.deepEqual(order, ["t1-start"], "t2 has not started while t1 holds the slot");
|
||||||
|
// Release t1 → t2 runs.
|
||||||
|
g1.resolve();
|
||||||
|
await t1; await t2;
|
||||||
|
assert.deepEqual(order, ["t1-start", "t1-end", "t2-start"], "t2 ran only after t1 finished");
|
||||||
|
assert.equal(sem.inflight, 0, "all slots released");
|
||||||
|
assert.equal(sem.queued, 0, "queue drained");
|
||||||
|
});
|
||||||
|
|
||||||
|
await asyncTest("limit=2 allows two concurrent, queues the third", async () => {
|
||||||
|
const sem = new TuiSemaphore(2);
|
||||||
|
const g = [deferred(), deferred(), deferred()];
|
||||||
|
const started = [];
|
||||||
|
const tasks = g.map((d, i) => sem.run(async () => { started.push(i); await d.p; }));
|
||||||
|
await new Promise((r) => setImmediate(r));
|
||||||
|
assert.equal(sem.inflight, 2, "exactly 2 run concurrently");
|
||||||
|
assert.equal(sem.queued, 1, "the third is queued");
|
||||||
|
assert.deepEqual(started.sort(), [0, 1], "only the first two started");
|
||||||
|
g.forEach((d) => d.resolve());
|
||||||
|
await Promise.all(tasks);
|
||||||
|
assert.equal(sem.inflight, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
await asyncTest("slot is RELEASED on throw (finally) — a rejecting task never leaks its slot", async () => {
|
||||||
|
const sem = new TuiSemaphore(1);
|
||||||
|
await assert.rejects(sem.run(async () => { throw new Error("boom"); }), /boom/);
|
||||||
|
assert.equal(sem.inflight, 0, "throwing task released its slot");
|
||||||
|
// Prove the slot is reusable: a subsequent task acquires immediately.
|
||||||
|
let ran = false;
|
||||||
|
await sem.run(async () => { ran = true; });
|
||||||
|
assert.equal(ran, true);
|
||||||
|
assert.equal(sem.inflight, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
await asyncTest("wait queue is bounded — run() rejects with tui_queue_full when full (backpressure, not OOM)", async () => {
|
||||||
|
const sem = new TuiSemaphore(1, { maxQueue: 1 });
|
||||||
|
const g1 = deferred();
|
||||||
|
const t1 = sem.run(async () => { await g1.p; }); // holds the slot
|
||||||
|
await new Promise((r) => setImmediate(r));
|
||||||
|
const t2 = sem.run(async () => {}); // fills the 1-deep queue
|
||||||
|
await new Promise((r) => setImmediate(r));
|
||||||
|
assert.equal(sem.queued, 1, "queue is full");
|
||||||
|
await assert.rejects(sem.run(async () => {}), /tui_queue_full/, "third request rejects");
|
||||||
|
g1.resolve();
|
||||||
|
await t1; await t2;
|
||||||
|
assert.equal(sem.inflight, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("\nTUI drift observability (C-5):");
|
||||||
|
|
||||||
|
test("recordTuiEntrypoint: observed 'cli' is NOT a mismatch and sets lastEntrypoint", () => {
|
||||||
|
const ts = { lastEntrypoint: null, entrypointMismatches: 0 };
|
||||||
|
const mism = recordTuiEntrypoint(ts, "cli", "cli");
|
||||||
|
assert.equal(mism, false);
|
||||||
|
assert.equal(ts.lastEntrypoint, "cli");
|
||||||
|
assert.equal(ts.entrypointMismatches, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("recordTuiEntrypoint: expected cli but observed 'sdk-cli' increments the mismatch counter (drift)", () => {
|
||||||
|
const ts = { lastEntrypoint: null, entrypointMismatches: 0 };
|
||||||
|
assert.equal(recordTuiEntrypoint(ts, "sdk-cli", "cli"), true);
|
||||||
|
assert.equal(ts.lastEntrypoint, "sdk-cli");
|
||||||
|
assert.equal(ts.entrypointMismatches, 1);
|
||||||
|
// A second drift increments again (counter accumulates across turns).
|
||||||
|
assert.equal(recordTuiEntrypoint(ts, "sdk-cli", "cli"), true);
|
||||||
|
assert.equal(ts.entrypointMismatches, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("recordTuiEntrypoint: null observation → lastEntrypoint null, counts as mismatch when expected cli", () => {
|
||||||
|
const ts = { lastEntrypoint: "cli", entrypointMismatches: 0 };
|
||||||
|
assert.equal(recordTuiEntrypoint(ts, null, "cli"), true);
|
||||||
|
assert.equal(ts.lastEntrypoint, null);
|
||||||
|
assert.equal(ts.entrypointMismatches, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("recordTuiEntrypoint: non-cli expected mode (auto) never counts a mismatch", () => {
|
||||||
|
const ts = { lastEntrypoint: null, entrypointMismatches: 0 };
|
||||||
|
assert.equal(recordTuiEntrypoint(ts, "sdk-cli", "auto"), false);
|
||||||
|
assert.equal(ts.lastEntrypoint, "sdk-cli");
|
||||||
|
assert.equal(ts.entrypointMismatches, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildTuiHealthBlock: shape + live counters (the additive /health tui block)", () => {
|
||||||
|
const sem = new TuiSemaphore(2);
|
||||||
|
const ts = { lastEntrypoint: "cli", entrypointMismatches: 3 };
|
||||||
|
const block = buildTuiHealthBlock(
|
||||||
|
{ enabled: true, entrypointMode: "cli", maxConcurrent: 2 }, ts, sem);
|
||||||
|
assert.deepEqual(Object.keys(block).sort(),
|
||||||
|
["enabled", "entrypointMismatches", "entrypointMode", "inflight", "lastEntrypoint", "maxConcurrent", "queued"]);
|
||||||
|
assert.equal(block.enabled, true);
|
||||||
|
assert.equal(block.entrypointMode, "cli");
|
||||||
|
assert.equal(block.lastEntrypoint, "cli");
|
||||||
|
assert.equal(block.entrypointMismatches, 3);
|
||||||
|
assert.equal(block.inflight, 0);
|
||||||
|
assert.equal(block.queued, 0);
|
||||||
|
assert.equal(block.maxConcurrent, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildTuiHealthBlock: TUI off → enabled:false but block still present (stable shape)", () => {
|
||||||
|
const sem = new TuiSemaphore(2);
|
||||||
|
const ts = { lastEntrypoint: null, entrypointMismatches: 0 };
|
||||||
|
const block = buildTuiHealthBlock(
|
||||||
|
{ enabled: false, entrypointMode: "cli", maxConcurrent: 2 }, ts, sem);
|
||||||
|
assert.equal(block.enabled, false);
|
||||||
|
assert.equal(block.lastEntrypoint, null);
|
||||||
|
assert.equal(block.entrypointMismatches, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
await asyncTest("buildTuiHealthBlock reflects live inflight/queued while turns are in flight", async () => {
|
||||||
|
const sem = new TuiSemaphore(1);
|
||||||
|
const ts = { lastEntrypoint: null, entrypointMismatches: 0 };
|
||||||
|
const g1 = deferred();
|
||||||
|
const t1 = sem.run(async () => { await g1.p; });
|
||||||
|
const t2 = sem.run(async () => {}); // queued behind t1
|
||||||
|
await new Promise((r) => setImmediate(r));
|
||||||
|
const block = buildTuiHealthBlock({ enabled: true, entrypointMode: "cli", maxConcurrent: 1 }, ts, sem);
|
||||||
|
assert.equal(block.inflight, 1, "one turn in flight");
|
||||||
|
assert.equal(block.queued, 1, "one turn queued");
|
||||||
|
g1.resolve();
|
||||||
|
await t1; await t2;
|
||||||
|
});
|
||||||
|
|
||||||
// ── TUI session driver: runTuiTurn (live-only, guarded) ──────────────────
|
// ── TUI session driver: runTuiTurn (live-only, guarded) ──────────────────
|
||||||
console.log("\nTUI session driver:");
|
console.log("\nTUI session driver:");
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user