@shumkov/orchestra 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,388 @@
1
+ # AUTH_DISABLED detection — spec
2
+
3
+ ## Problem
4
+
5
+ When Anthropic disables Claude Code / subscription access on an account
6
+ (non-payment, org policy, an admin toggling a Team/Enterprise setting), the
7
+ `claude` CLI does not exit or surface an HTTP error orchestra's process
8
+ wrapper can see. It renders the notice **inside the TUI**, as if it were a
9
+ turn's output, then sits there — no reply-tool call, no hook `Stop` event.
10
+ orchestra's `CliProcess` has no detector for this text, so the turn just
11
+ waits. It is eventually killed by the existing idle ceiling
12
+ (`DEFAULT_TURN_TIMEOUT_MS` = 10 min) or the hard wall-clock backstop
13
+ (`DEFAULT_TURN_HARD_MAX_MS` = 90 min), rejecting with the generic
14
+ `err.code = 'TURN_TIMEOUT'` / `'TURN_MAX_EXCEEDED'` (cli-process.js:2458).
15
+ Both consumer bots (water, polygram) then classify that generic code into a
16
+ canned "went quiet" reply, 10+ minutes late, with no indication that the
17
+ real cause is an account-level auth problem an operator needs to act on.
18
+
19
+ Because CliProcess is shared, water and polygram can both go dark at the
20
+ same minute on the same account — which is what happened in production.
21
+
22
+ This is a **different failure mode** from the OAuth-refresh-token-expiry
23
+ case fixed in 0.3.0 (`checkClaudeAuthHealth` / `claude-bin.js`) — that one
24
+ is a free, local, credentials-file check for an *expired* token, done
25
+ *before* spawn. This one is Anthropic *actively disabling* access
26
+ mid-conversation; it has no file-readable precondition and can only be
27
+ observed in the live CLI output.
28
+
29
+ ## What the message actually looks like (verified)
30
+
31
+ Contrary to the initial hand-off note ("NOT an HTTP 401/403"), the
32
+ underlying cause genuinely is an HTTP 403 (`permission_error`,
33
+ `"OAuth authentication is currently not allowed for this organization"`)
34
+ — but that response is consumed *inside* the `claude` binary. orchestra
35
+ runs `claude` inside tmux over the channels bridge; it never sees raw HTTP.
36
+ What it sees is the CLI's own rendering of the error, in the pane, as if it
37
+ were conversational output. Per Anthropic's own error reference
38
+ (`code.claude.com/docs/en/errors`, confirmed against multiple
39
+ `anthropics/claude-code` GitHub issues, e.g. #63886, #62722, #68212), the
40
+ literal, stable text is:
41
+
42
+ ```
43
+ Your organization has disabled Claude subscription access for Claude Code · Use an Anthropic API key instead, or ask your admin to enable access
44
+ ```
45
+
46
+ This is a fixed product string (not a template with variable account
47
+ details), and it is what the CLI prints regardless of surface (`/login`,
48
+ mid-session, `-p` mode surfaces it as the structured code
49
+ `oauth_org_not_allowed`, which orchestra also never sees — that's an SDK
50
+ path, not the CLI/tmux path CliProcess drives).
51
+
52
+ ## Where in cli-process.js to hook this
53
+
54
+ CliProcess has three distinct places that inspect Claude's output, and only
55
+ one of them is a good fit:
56
+
57
+ 1. **Bridge MCP protocol** (`_handleBridgeMessage`, `_dispatchToolCall`) —
58
+ structured tool calls (`reply`, `ask`, permission requests) delivered
59
+ over the channels socket. Not applicable: this failure produces no tool
60
+ call. Claude never gets far enough to call anything.
61
+ 2. **Hook ndjson stream** (`_handleHookEvent`, `Stop`'s
62
+ `lastAssistantMessage`) — this *would* be the natural place if the CLI
63
+ fired a `Stop` hook after emitting the notice. It does not: the turn
64
+ diagnosis confirms it hangs to the idle ceiling rather than resolving via
65
+ the existing zero-reply Stop-fallback (`_computeTurnDelivery`,
66
+ cli-process.js:2153), which only fires once `Stop` lands. No `Stop`
67
+ event here means no hook payload carries this text.
68
+ 3. **tmux pane-capture watchdog** (`_pollMidTurnDialogs`, cli-process.js
69
+ ~4008) — the *only* place that regex-matches Claude's literal rendered
70
+ output. It already runs every `PONG_CHECK_INTERVAL_MS` (5s) while a turn
71
+ is pending (`pendingTurns.size > 0`), captures the pane with `-J`
72
+ (line-rewrap-safe), and matches a catalog of known dialog strings
73
+ (`MID_TURN_PROMPTS`, `SESSION_AGE_PROMPT_RE`, `UNKNOWN_PROMPT_HEURISTIC_RE`).
74
+ This is the existing "live scan of streamed output" the task referred
75
+ to — it is the mechanism that already recovers session-age dialogs and
76
+ other TUI-only text. It is the correct, minimal hook point.
77
+
78
+ **Chosen approach:** add an `AUTH_DISABLED_RE` check inside
79
+ `_pollMidTurnDialogs`, run once per poll, BEFORE the `STREAMING_HINT_RE` /
80
+ `MID_TURN_PROMPTS` / unknown-prompt checks (most severe signal first; on
81
+ confirmed match the rest of the method is skipped via an explicit early
82
+ `return` — see "same-poll interference" below). On confirmed match: reject
83
+ every currently-pending turn with `err.code = 'AUTH_DISABLED'`, mirroring
84
+ the existing drain idiom already used three times in this file
85
+ (`_handleBridgeDisconnected`, `_doKill`, `resetSession`). Detection latency
86
+ is bounded by two poll intervals (≤10s, see debounce below) — effectively
87
+ immediate next to the current 10-minute wait.
88
+
89
+ **Bounded tail, not the full pane.** `_pollMidTurnDialogs` reuses the
90
+ pane already captured for the other detectors, which defaults to
91
+ `capturePane`'s `lines: 1000` (tmux-runner.js:345). That's the right depth
92
+ for dialogs that must survive scrollback, but wrong for this detector: once
93
+ printed, the notice would stay inside a 1000-line window for a long time
94
+ (review finding — a legitimate assistant reply that happens to quote this
95
+ exact error text, e.g. a user asking Claude to explain the error, would
96
+ then falsely arm detection for every subsequent turn in the session until
97
+ ~1000 lines of new output rolled it out of view). Mitigation: only test
98
+ `AUTH_DISABLED_RE` against the **last ~40 lines** of the already-captured
99
+ pane (`pane.split('\n').slice(-40).join('\n')` — same pattern already used
100
+ for the unknown-prompt excerpt at cli-process.js:4119-4120, no extra tmux
101
+ call). A wrapped single-sentence notice is 1-3 logical lines after `-J`
102
+ join; 40 lines comfortably covers TUI chrome around it while aging the
103
+ match out within roughly one screen of subsequent output — versus ~1000
104
+ lines for the untrimmed pane.
105
+
106
+ **Two-consecutive-poll debounce.** Even bounded to 40 lines, a single-poll
107
+ match isn't enough: a legitimate reply that quotes the string could still
108
+ be visible in the tail for one poll tick while the turn is still
109
+ in-flight (not yet `Stop`-resolved). Require the match on two consecutive
110
+ polls (`this._authDisabledArmed`, reset to `false` on any poll where the
111
+ tail does *not* match) before rejecting. Adds ≤1 poll interval (~5s) of
112
+ latency to the worst case; still trivial next to the 10-minute status quo.
113
+
114
+ **Implementation correction found in code review (must-fix, applied):** the
115
+ first draft only reset `_authDisabledArmed` inside the tail-check block
116
+ itself — every EARLY-RETURN guard above it (`this.closed`,
117
+ `pendingTurns.size === 0`, `_openQuestions.size > 0`, no `tmuxSession`, no
118
+ `captureWide`, a `captureWide` throw, an empty `pane`) left the flag
119
+ untouched. Concretely: turn A arms the flag, then resolves normally via
120
+ `Stop` — `pendingTurns` empties, so every subsequent poll returns early at
121
+ the `pendingTurns.size === 0` guard *without* touching the flag. It stays
122
+ `true` indefinitely. When an unrelated turn B starts later and its pane
123
+ still shows the same old text within the last 40 lines (plausible — nothing
124
+ scrolled it away during the idle gap), turn B's very FIRST poll sees the
125
+ stale `true` and "confirms" against it, rejecting a turn that had nothing
126
+ to do with the original sighting. This defeated the debounce's actual
127
+ purpose (protecting the *next* turn, not just the one that produced the
128
+ quote). Fixed by resetting `_authDisabledArmed = false` on every one of
129
+ those early-return paths, so "armed" can only ever mean "the immediately
130
+ preceding poll actually re-observed a match against a still-pending turn."
131
+ Caught by an independent correctness-review agent, confirmed by direct
132
+ reproduction, and pinned by a dedicated regression test (test plan below)
133
+ that fails against the first-draft code and passes against the fix.
134
+
135
+ **Same-poll interference with other detectors (review finding).** Once
136
+ `AUTH_DISABLED_RE` confirms, `_pollMidTurnDialogs` must `return`
137
+ immediately after the drain, rather than falling through to the
138
+ `STREAMING_HINT_RE` heartbeat, `MID_TURN_PROMPTS` loop, or
139
+ `UNKNOWN_PROMPT_HEURISTIC_RE` check. Reason: the drain empties
140
+ `pendingTurns`, and the TUI's idle input cursor (`❯ `) below a static
141
+ notice is exactly what `UNKNOWN_PROMPT_HEURISTIC_RE` matches — without the
142
+ early return, the same poll tick would also emit a confusing
143
+ `cli-mid-turn-unknown-prompt` telemetry row with `pending_count: 0`, or
144
+ worse, send stray dismissal keystrokes (`MID_TURN_PROMPTS` actions) into an
145
+ already-terminated turn.
146
+
147
+ **Drain must match-and-reject `pendingQueue`, not blunt-truncate it, and
148
+ must reset `inFlight` (review finding).** The three existing drain sites
149
+ are NOT identical: `_handleBridgeDisconnected` (cli-process.js:3436)
150
+ truncates `pendingQueue` with `this.pendingQueue.length = 0` and never
151
+ rejects its items; `_doKill` (3473) and `resetSession` (3697) both walk
152
+ `pendingQueue`, skip entries already covered by `pendingTurns` (matched by
153
+ `turnId`), and explicitly `reject()` the rest (`resetSession`'s own
154
+ comment: entries can be "pushed by callers other than this.send"). This
155
+ fix follows the `_doKill`/`resetSession` shape, not
156
+ `_handleBridgeDisconnected`'s — truncating without rejecting would leak
157
+ orphaned promises, reproducing the exact silent-hang bug class this fix
158
+ exists to close. All three existing sites also set `this.inFlight = false`
159
+ after draining; this fix does the same (missing it would leave
160
+ `inFlight` stuck `true` on a session that can never produce another
161
+ reply).
162
+
163
+ Not in scope: startup-time detection (before the first `send()`). If
164
+ access is disabled before any turn starts, the existing startup-gate /
165
+ dialog-timeout paths apply and are a different symptom with an existing
166
+ (if generic) error code. This spec only closes the mid-turn gap the
167
+ diagnosis describes.
168
+
169
+ ## The regex
170
+
171
+ ```js
172
+ const AUTH_DISABLED_RE =
173
+ /organization\s+has\s+disabled\s+Claude\s+subscription\s+access\s+for\s+Claude\s+Code/i;
174
+ ```
175
+
176
+ Anchored on the *exact*, distinctive middle clause of Anthropic's fixed
177
+ product string, not the full sentence (avoids the `·` bullet/separator,
178
+ which could be rendered inconsistently) and not the "use an Anthropic API
179
+ key instead" clause (which is generic advice someone could plausibly type
180
+ in unrelated conversation — the seed regex from the hand-off note used it
181
+ as a standalone alternative, which is the false-positive risk called out
182
+ in the task; this spec drops that alternative). `\s+` (not literal spaces)
183
+ tolerates any pane rewrap artifacts even though `captureWide` already
184
+ passes `-J` (join-wrapped) to tmux.
185
+
186
+ This phrase is Anthropic product copy naming both "Claude subscription
187
+ access" and "Claude Code" together in a disablement sentence — not
188
+ something a legitimate assistant reply would casually construct while
189
+ answering a user's question about API keys. Verified against real
190
+ production reports (GitHub issues #63886, #62722, #68212) — same string in
191
+ every report, not account-specific interpolation.
192
+
193
+ **False-positive check performed:** a legitimate reply like *"you can use
194
+ an Anthropic API key instead of your Claude subscription for that"* does
195
+ not match — it doesn't contain "disabled ... Claude Code" in that shape.
196
+ Covered by a negative test case (below).
197
+
198
+ **Residual false-positive: verbatim quoting (review finding, mitigated
199
+ above, not eliminated by regex alone).** A user debugging *this exact
200
+ issue* could paste the real string and ask Claude to explain it, and a
201
+ healthy Claude reply might echo it back. The regex cannot distinguish
202
+ "this is happening to me" from "here's what that message means" — no
203
+ regex can, the distinguishing signal is behavioral (does the turn go on to
204
+ resolve normally?), not textual. That's what the bounded-tail +
205
+ two-consecutive-poll debounce above defends against, not the regex shape.
206
+ Residual risk after mitigation: a session would have to (a) have this
207
+ exact phrase land in the last ~40 captured lines, (b) on two consecutive
208
+ 5s-apart polls, (c) while the turn genuinely has not yet `Stop`-resolved
209
+ both times — a narrow enough window that it's an acceptable trade for
210
+ closing a 10-minute silent production hang. Blast radius if it ever does
211
+ mis-fire: self-scoped to that one chat's one turn (own pane, own
212
+ `pendingTurns`) — not a cross-tenant or cross-session amplification.
213
+
214
+ ## The AUTH_DISABLED contract
215
+
216
+ - `err.code = 'AUTH_DISABLED'` on the rejection every currently-pending
217
+ `send()` promise receives.
218
+ - `err.message` — human-readable, includes the detected pane excerpt
219
+ is *not* included (see security note below) — just a static description.
220
+ - Emitted alongside the reject: `this.emit('auth-disabled-detected', {
221
+ sessionId, backend })` (telemetry parity with `mid-turn-dialog-detected`)
222
+ and `this._logEvent('cli-auth-disabled-detected', { pending_count })`
223
+ (parity with the existing `cli-mid-turn-dialog-detected` telemetry row).
224
+ - `emit('idle')` fires before the rejections resolve, so a wired
225
+ `HeartbeatReactor` stops cycling (Step E contract, pinned by existing
226
+ tests for the other drain paths).
227
+ - The underlying `claude` process and tmux session are **not** killed and
228
+ the session is **not** reset (`claudeSessionId` untouched) — mirrors
229
+ `TURN_TIMEOUT`'s behavior, not `resetSession`'s. Killing/resetting is a
230
+ policy decision (notify admin? pause the chat? wait for the org to
231
+ re-enable?) that belongs to the consumer, not to the shared engine.
232
+ - water and polygram add `AUTH_DISABLED` as a `CODES` short-circuit in
233
+ their own `classify()` (out of scope for this repo/PR — happening in
234
+ parallel per the task).
235
+ - **Consumer backoff requirement (review finding, documented here since
236
+ it's a cross-repo coordination point, not enforceable from this repo):**
237
+ `AUTH_DISABLED` means the *account* is blocked, not the request — an
238
+ immediate automatic retry will hit the same wall. Because
239
+ `_pollMidTurnDialogs`'s only gate is `pendingTurns.size > 0`, a consumer
240
+ that retries into the same session on catch will get rejected again on
241
+ the next poll (≤5s), not instantly — this is not a busy-loop on
242
+ orchestra's side (poll cadence is fixed, independent of `pendingTurns`
243
+ churn) — but it is repeated wasted `send()` calls against a
244
+ known-disabled account. water/polygram should treat `AUTH_DISABLED` as
245
+ non-retryable (surface to the operator / admin, don't auto-resend) the
246
+ same way they'd treat `AUTH_EXPIRED` today.
247
+ - The rejection `err.message` is a static, generic description — it does
248
+ **not** include the captured pane excerpt. This mirrors the L13
249
+ incident precedent already in this file (cli-process.js:1206-1210):
250
+ logging raw pane/reply content at default log levels previously leaked
251
+ private chat content into the log sink unconditionally. The matched
252
+ string here is fixed Anthropic product copy with no user content, so
253
+ there's nothing account-specific to lose by omitting it — but the
254
+ precedent is why the omission is deliberate, not an oversight.
255
+
256
+ ## orchestra's own `lib/error/classify.js` — decision
257
+
258
+ **Decision: no entry added.** Investigated and confirmed:
259
+ `lib/error/classify.js` is `require`d by exactly one file in this repo,
260
+ `lib/process/sdk-process.js` (only `isTransientHttpError`, for the SDK
261
+ backend's own retry-once decision on `SDKResultMessage`/`SDKAssistantMessage`
262
+ errors). It is not `require`d by `cli-process.js`, not called anywhere on a
263
+ `CliProcess`-thrown error, and not re-exported from `index.js` (confirmed:
264
+ `index.js`'s `module.exports` has no `classify` key). Its existing
265
+ `TURN_TIMEOUT`/`TURN_MAX_EXCEEDED`/`BRIDGE_DISCONNECTED` entries are
266
+ already dead weight for any CliProcess consumer of *this* package — nothing
267
+ in-repo classifies a CliProcess error through this file today (those
268
+ entries are there for internal consistency / a hypothetical future shared
269
+ consumer, not because something calls them). Adding `AUTH_DISABLED` here
270
+ would look like it wires the contract up when it does not — actively
271
+ misleading. This is purely a downstream-consumer concern: water and
272
+ polygram each already maintain their own `classify()` and add the
273
+ `AUTH_DISABLED` case there, per the task's parallel work.
274
+
275
+ ## Failure modes / edge cases considered
276
+
277
+ - **`_openQuestions.size > 0` gate**: `_pollMidTurnDialogs` returns early
278
+ while an interactive `ask` is outstanding (pane shows the question text,
279
+ not activity). If Anthropic disables access while a question is
280
+ mid-flight, detection is delayed until the question resolves or times
281
+ out. Pre-existing limitation shared with every other entry in
282
+ `MID_TURN_PROMPTS` — not introduced by this change, not fixed by it.
283
+ Documented, not addressed (out of scope; the question-timeout backstop
284
+ already bounds this).
285
+ - **Multiple pending turns**: pane detection is process-wide (one pane, not
286
+ attributable to a specific turn), same limitation the `Stop`-hook
287
+ attribution comment (cli-process.js:2074) already documents for that
288
+ path. All currently-pending turns are rejected — correct, since a
289
+ disabled account can serve none of them.
290
+ - **Re-detection after reject**: no time-windowed dedup is used (unlike
291
+ `MID_TURN_DEDUP_WINDOW_MS` for `MID_TURN_PROMPTS`). Once rejected,
292
+ `pendingTurns` is empty, so `_pollMidTurnDialogs`'s
293
+ `pendingTurns.size === 0` guard means the next poll no-ops. If the
294
+ consumer resends into the same wedged session, the pane still shows the
295
+ notice (nothing consumes it) and the new turn is rejected again on the
296
+ next poll (≤5s) — correct, not a busy-loop (poll cadence is fixed at
297
+ `PONG_CHECK_INTERVAL_MS`, unrelated to pendingTurns state).
298
+ - **Startup-time disablement** (before first `send()`): out of scope, see
299
+ above.
300
+ - **Leaking the raw pane excerpt**: existing telemetry
301
+ (`cli-mid-turn-unknown-prompt`) logs a pane excerpt for *unknown*
302
+ prompts, by design, for operator triage. This detector's payload is
303
+ intentionally minimal (no excerpt) — see the L13-precedent note in the
304
+ contract section above.
305
+ - **Not a new abstraction**: this is the *fourth* near-identical
306
+ reject-all-pending drain block in this file
307
+ (`_handleBridgeDisconnected`, `_doKill`, `resetSession`, now this).
308
+ Extracting a shared `_rejectAllPending(code, opts)` helper was
309
+ considered and deliberately deferred — the four sites differ enough
310
+ (queue-truncate-vs-reject, `emit('idle')` conditionality,
311
+ `claudeSessionId` reset, tmux/bridge teardown) that a parameterized
312
+ helper risks more complexity than the duplication it removes, and
313
+ refactoring three already-working shutdown paths is out of scope for a
314
+ bug fix whose stated purpose is adding one detector. Flagged here as a
315
+ legitimate follow-up, not silently skipped.
316
+
317
+ ## Test plan
318
+
319
+ New test in `tests/resume-dialog-fix.test.js`-style direct-invocation form
320
+ (mirrors the existing `B2-midturn` test, which already drives
321
+ `_pollMidTurnDialogs()` directly against a manually-set `pendingTurns`
322
+ entry — no fake-bridge harness needed):
323
+
324
+ 1. **Red**: construct a `CliProcess` with a fake runner whose
325
+ `captureWide` returns a pane string containing the exact Anthropic
326
+ notice. Seed `pendingTurns` with one entry (`resolve`/`reject` stubs
327
+ that record calls). Call `_pollMidTurnDialogs()` **twice** (mirroring
328
+ the two-consecutive-poll debounce) against the *current, unfixed* code.
329
+ Assert the pending turn is **not** rejected (`reject` never called,
330
+ entry still in `pendingTurns`) — pins the current bug (silent hang
331
+ toward the 10-min ceiling).
332
+ 2. **Green**: same test, run again after the fix lands. Assert `reject`
333
+ is NOT yet called after the first poll (armed, not confirmed), then IS
334
+ called once after the second poll with `err.code === 'AUTH_DISABLED'`,
335
+ `pendingTurns` is empty, `'idle'` was emitted, and both
336
+ `auth-disabled-detected` was emitted and `cli-auth-disabled-detected`
337
+ was passed to `_logEvent` (via a fake `db.logEvent` collector, same
338
+ pattern as `resume-dialog-fix.test.js`'s `session-age-dialog-fallback`
339
+ assertion).
340
+ 3. **Single-poll match does not reject**: one poll with a match must not
341
+ drain `pendingTurns` by itself — asserts the debounce is load-bearing,
342
+ not a no-op.
343
+ 4. **Debounce resets on a clean poll**: match on poll 1, no match on poll
344
+ 2 (pane changed / cleared), match again on poll 3 → still requires two
345
+ *consecutive* matching polls from that point; poll 3 alone doesn't
346
+ reject. Guards against an accumulating counter instead of a reset-on-miss
347
+ flag.
348
+ 5. **Negative / false-positive guard**: pane text containing a *legitimate*
349
+ assistant reply mentioning API keys (e.g. "you can use an Anthropic API
350
+ key instead of your Claude subscription for that automation") must
351
+ **not** trigger detection across repeated polls — assert `reject` is
352
+ never called and the pending turn survives.
353
+ 6. **Bounded tail**: a pane where the notice sits *before* the last ~40
354
+ lines (padded with ≥40 lines of unrelated trailing output) must **not**
355
+ trigger — pins the tail-bounding behavior, not just "some substring
356
+ match somewhere in 1000 lines."
357
+ 7. **Multi-turn drain**: two pending turns, two consecutive confirming
358
+ polls → both rejected with `AUTH_DISABLED`, both removed from
359
+ `pendingTurns` and any matching `pendingQueue` rows.
360
+ 8. **No same-poll interference**: on the confirming poll, assert no
361
+ `sendControl` keystrokes were sent and no `mid-turn-unknown-prompt` /
362
+ `mid-turn-dialog-detected` events fired — pins the early-`return` after
363
+ the drain.
364
+ 9. Existing `resume-dialog-fix.test.js` / `cli-process-integration.test.js`
365
+ suites re-run green (no regression to the existing `MID_TURN_PROMPTS`
366
+ catalog or `STREAMING_HINT_RE` heartbeat).
367
+ 10. **`pendingQueue` drain** (added after test-coverage review flagged it as
368
+ uncovered): a `pendingQueue` entry matching a rejected `turnId` must not
369
+ be double-rejected; a `pendingQueue`-only entry (no matching
370
+ `pendingTurns` row) must still be rejected with `AUTH_DISABLED` and
371
+ removed. Guards against silently regressing to
372
+ `_handleBridgeDisconnected`'s blunt `pendingQueue.length = 0`
373
+ truncation, which would leak orphaned promises — every existing test
374
+ up to that point would still pass under that regression; this is the
375
+ one that would catch it.
376
+ 11. **Stale-arm regression** (added after correctness review found the bug
377
+ described above): turn A arms the debounce, is then manually cleared
378
+ from `pendingTurns` (simulating a normal `Stop` resolution before the
379
+ second poll), and an unrelated turn B is added. Turn B's first poll
380
+ must NOT be rejected. Verified this test fails against the pre-fix
381
+ code (which had the detector but not the early-return resets) while
382
+ every other test in the file still passes — confirms it's the specific
383
+ pin for this bug, not a duplicate of the existing debounce tests.
384
+
385
+ Per the repo owner's TDD discipline, the whole suite was run red (detector
386
+ absent) then green (detector added) for the initial cut, and the two
387
+ review-driven additions (10, 11) were each independently confirmed red
388
+ against the version of the code that predated their respective fix.
package/lib/claude-bin.js CHANGED
@@ -238,10 +238,51 @@ function ensureVendoredClaudeBin(version, { logger = console } = {}) {
238
238
  return { ok: true, path: vendored, vendored: true };
239
239
  }
240
240
 
241
+ /**
242
+ * Check Claude CLI OAuth health from the credentials file — FREE (no API call,
243
+ * no model spawn). The CLI stores its login in ~/.claude/.credentials.json under
244
+ * `claudeAiOauth`, with two expiries: the short-lived `expiresAt` (access token,
245
+ * which the CLI silently auto-refreshes) and `refreshTokenExpiresAt` (the HARD
246
+ * limit — once the refresh token itself ages out, auto-refresh 401s and every
247
+ * channels-backend turn wedges INVISIBLY: claude fires UserPromptSubmit then
248
+ * dies with "OAuth access token has expired", which never reaches polygram's
249
+ * classifier, so it degrades to a silent "⏱ went quiet"). We watch the
250
+ * refresh-token expiry so the daemon can refuse turns with a clear message
251
+ * instead of wedging.
252
+ *
253
+ * Pure + injectable (`home`/`now`) for tests.
254
+ *
255
+ * @param {{home?:string, now?:number, warnWithinMs?:number}} [opts]
256
+ * @returns {{state:'healthy'|'expiring'|'expired'|'unknown', reason?:string,
257
+ * refreshTokenExpiresAt:?number, msLeft:?number, daysLeft:?number}}
258
+ */
259
+ function checkClaudeAuthHealth({ home = os.homedir(), now = Date.now(), warnWithinMs = 3 * 86_400_000 } = {}) {
260
+ const credPath = path.join(home, '.claude', '.credentials.json');
261
+ let creds;
262
+ try {
263
+ creds = JSON.parse(fs.readFileSync(credPath, 'utf8'));
264
+ } catch (err) {
265
+ // Missing/unreadable → can't prove expiry. Report 'unknown' (caller logs it)
266
+ // rather than 'expired', so a transient read error never hard-refuses traffic.
267
+ return { state: 'unknown', reason: `credentials unreadable: ${err.code || err.message}`, refreshTokenExpiresAt: null, msLeft: null, daysLeft: null };
268
+ }
269
+ const exp = creds && creds.claudeAiOauth && creds.claudeAiOauth.refreshTokenExpiresAt;
270
+ if (typeof exp !== 'number') {
271
+ return { state: 'unknown', reason: 'no claudeAiOauth.refreshTokenExpiresAt', refreshTokenExpiresAt: null, msLeft: null, daysLeft: null };
272
+ }
273
+ const msLeft = exp - now;
274
+ const daysLeft = Math.round((msLeft / 86_400_000) * 10) / 10;
275
+ let state = 'healthy';
276
+ if (msLeft <= 0) state = 'expired';
277
+ else if (msLeft <= warnWithinMs) state = 'expiring';
278
+ return { state, refreshTokenExpiresAt: exp, msLeft, daysLeft };
279
+ }
280
+
241
281
  module.exports = {
242
282
  resolvePinnedClaudeBin,
243
283
  verifyPinnedClaudeBin,
244
284
  ensureVendoredClaudeBin,
245
285
  vendorDir,
246
286
  CLAUDE_CLI_PINNED_VERSION,
287
+ checkClaudeAuthHealth,
247
288
  };
@@ -187,6 +187,38 @@ const UNKNOWN_PROMPT_HEURISTIC_RE = /(\?\s*$|\(y\/N\)|Yes\/No|❯\s|^\s*[12345]\
187
187
  // doesn't spam sendControl/event emissions. Aligned with the 5s poll cadence.
188
188
  const MID_TURN_DEDUP_WINDOW_MS = 30_000;
189
189
 
190
+ // docs/AUTH_DISABLED_DETECTION_SPEC.md: when Anthropic disables Claude
191
+ // subscription access for an account (non-payment, org policy), the `claude`
192
+ // CLI renders a fixed notice INSIDE the TUI instead of exiting or surfacing
193
+ // an HTTP error orchestra's tmux wrapper can see — no reply-tool call, no
194
+ // Stop hook. Pre-fix, the turn just hangs to the 10-min idle ceiling
195
+ // (TURN_TIMEOUT). This is the only literal-text-match detector strong
196
+ // enough to warrant terminating pending turns outright (the others
197
+ // dismiss-and-continue or emit telemetry only), so unlike them it is
198
+ // bounded to a recent tail (not the full ~1000-line pane capture) and
199
+ // requires two consecutive polls before acting — see the spec for the
200
+ // false-positive analysis this defends against.
201
+ //
202
+ // Anchored on the distinctive middle clause of Anthropic's fixed product
203
+ // string (code.claude.com/docs/en/errors; confirmed against
204
+ // anthropics/claude-code issues #63886, #62722, #68212): "Your organization
205
+ // has disabled Claude subscription access for Claude Code · Use an
206
+ // Anthropic API key instead, or ask your admin to enable access". Deliberately
207
+ // drops the "use an Anthropic API key instead" clause — that's generic
208
+ // advice a legitimate reply could construct on its own; the disablement
209
+ // clause is not. \s+ (not literal spaces) tolerates any pane-rewrap
210
+ // artifact even though captureWide already passes tmux `-J` (join-wrapped).
211
+ const AUTH_DISABLED_RE = /organization\s+has\s+disabled\s+Claude\s+subscription\s+access\s+for\s+Claude\s+Code/i;
212
+ // Only the last N lines of the captured pane are tested against
213
+ // AUTH_DISABLED_RE. The shared pane capture defaults to 1000 lines of
214
+ // scrollback (right for dialogs that must survive scrollback); wrong here —
215
+ // a legitimate reply that ever quotes this exact error text (e.g. a user
216
+ // asking Claude to explain it) would otherwise stay "visible" to the
217
+ // detector for the rest of the session. 40 lines comfortably covers the
218
+ // wrapped notice + TUI chrome while aging the match out within roughly one
219
+ // screen of subsequent output.
220
+ const AUTH_DISABLED_TAIL_LINES = 40;
221
+
190
222
  // Parity with TmuxProcess (R2-F1 / G5b) and SdkProcess: strip C0 control
191
223
  // chars + DEL before injecting. Allows \t (0x09) and \n (0x0a) through.
192
224
  // Same regex as `lib/tmux/tmux-runner.js` CONTROL_CHAR_RE — keep in sync.
@@ -357,6 +389,11 @@ class CliProcess extends Process {
357
389
  // watchdog. Dedups within MID_TURN_DEDUP_WINDOW_MS so a lingering dialog
358
390
  // doesn't trigger sendControl/emit on every 5s poll.
359
391
  this.midTurnDialogLastFiredAt = new Map(); // patternName → ts
392
+ // AUTH_DISABLED two-consecutive-poll debounce (docs/AUTH_DISABLED_DETECTION_
393
+ // SPEC.md): true once AUTH_DISABLED_RE matched on the immediately-prior poll.
394
+ // Reset to false on any poll where it doesn't match — an accumulating
395
+ // counter would let non-consecutive false-positive sightings add up.
396
+ this._authDisabledArmed = false;
360
397
  // Review F#16: secondary content-hash dedup. The tool_call_id cache above
361
398
  // catches retries that reuse the same id, but Claude's bridge generates a
362
399
  // NEW tool_call_id per retry (channels-bridge.mjs:230). If the daemon's
@@ -4005,17 +4042,88 @@ class CliProcess extends Process {
4005
4042
  }
4006
4043
  }
4007
4044
 
4045
+ /**
4046
+ * docs/AUTH_DISABLED_DETECTION_SPEC.md: reject every currently-pending
4047
+ * turn with err.code = 'AUTH_DISABLED' once the pane watchdog confirms
4048
+ * Anthropic has disabled Claude subscription access for this account.
4049
+ * Mirrors resetSession's drain shape (cli-process.js resetSession,
4050
+ * ~line 3697) — match-and-reject pendingQueue entries by turnId rather
4051
+ * than _handleBridgeDisconnected's blunt `pendingQueue.length = 0`
4052
+ * truncation, which would leak orphaned promises. Does NOT kill the tmux
4053
+ * session or clear claudeSessionId (mirrors TURN_TIMEOUT, not
4054
+ * resetSession) — whether to notify an operator, pause the chat, or wait
4055
+ * for the org to re-enable access is a downstream-consumer policy
4056
+ * decision, not this shared engine's to make.
4057
+ */
4058
+ _rejectPendingTurnsAuthDisabled() {
4059
+ const makeErr = () => {
4060
+ const err = new Error(
4061
+ 'Claude subscription access has been disabled for this account — ' +
4062
+ 'the operator needs to re-enable Claude Code access or switch to an API key',
4063
+ );
4064
+ err.code = 'AUTH_DISABLED';
4065
+ return err;
4066
+ };
4067
+ const channelsTurnIds = new Set();
4068
+ let drained = 0;
4069
+ for (const [turnId, pending] of this.pendingTurns) {
4070
+ channelsTurnIds.add(turnId);
4071
+ drained++;
4072
+ if (pending.quietTimer) clearTimeout(pending.quietTimer);
4073
+ if (pending.hardTimer) clearTimeout(pending.hardTimer);
4074
+ if (pending.absoluteTimer) clearTimeout(pending.absoluteTimer);
4075
+ if (pending._stopGraceTimer) clearTimeout(pending._stopGraceTimer);
4076
+ if (pending._activityQuietTimer) clearTimeout(pending._activityQuietTimer);
4077
+ if (pending._onStop) this.off('stop-hook', pending._onStop);
4078
+ try { pending.reject(makeErr()); } catch {}
4079
+ }
4080
+ this.pendingTurns.clear();
4081
+
4082
+ const remaining = [];
4083
+ for (const item of this.pendingQueue) {
4084
+ if (item.turnId && channelsTurnIds.has(item.turnId)) continue;
4085
+ remaining.push(item);
4086
+ }
4087
+ this.pendingQueue.length = 0;
4088
+ for (const item of remaining) {
4089
+ drained++;
4090
+ try { item.clearTimers?.(); } catch {}
4091
+ try { item.reject?.(makeErr()); } catch {}
4092
+ }
4093
+
4094
+ this.inFlight = false;
4095
+ // Step E parity (resetSession/fireTimeout/_finalizeTurn): emit 'idle'
4096
+ // BEFORE the detection event so a wired HeartbeatReactor stops cycling.
4097
+ if (drained > 0) this.emit('idle');
4098
+ this.emit('auth-disabled-detected', {
4099
+ sessionId: this.claudeSessionId,
4100
+ backend: this.backend,
4101
+ drainedPendings: drained,
4102
+ });
4103
+ this._logEvent('cli-auth-disabled-detected', { drained_pendings: drained });
4104
+ return drained;
4105
+ }
4106
+
4008
4107
  async _pollMidTurnDialogs() {
4009
- if (this.closed) return;
4010
- if (this.pendingTurns.size === 0) return; // no work to do when idle
4108
+ // AUTH_DISABLED_TAIL_LINES/_authDisabledArmed note: every early-return
4109
+ // guard below resets the arm flag. The two-consecutive-poll debounce
4110
+ // (docs/AUTH_DISABLED_DETECTION_SPEC.md) only means anything if "armed"
4111
+ // reflects the IMMEDIATELY PRECEDING poll actually re-observing the same
4112
+ // still-pending turn's pane. Without these resets, a turn that armed the
4113
+ // flag and then resolved normally (pendingTurns.size hits 0, this method
4114
+ // starts early-returning) would leave the flag stuck true — a later,
4115
+ // unrelated turn's very first poll could then falsely "confirm" against
4116
+ // that stale arm instead of its own second observation.
4117
+ if (this.closed) { this._authDisabledArmed = false; return; }
4118
+ if (this.pendingTurns.size === 0) { this._authDisabledArmed = false; return; } // no work to do when idle
4011
4119
  // 0.12 interactive questions: while an `ask` is open claude sits idle at the
4012
4120
  // prompt waiting on the tool result — so the pane shows no "esc to interrupt"
4013
4121
  // and the question's own echoed text (a "?"/numbered list/"Yes/No") would
4014
4122
  // false-trip the unknown-prompt heuristic + starve the STALL heartbeat. The
4015
4123
  // keyboard lives on Telegram; suppress the pane watchdog while a question is open.
4016
- if (this._openQuestions.size > 0) return;
4017
- if (!this.tmuxSession) return; // pre-spawn / post-kill
4018
- if (typeof this.runner?.captureWide !== 'function') return;
4124
+ if (this._openQuestions.size > 0) { this._authDisabledArmed = false; return; }
4125
+ if (!this.tmuxSession) { this._authDisabledArmed = false; return; } // pre-spawn / post-kill
4126
+ if (typeof this.runner?.captureWide !== 'function') { this._authDisabledArmed = false; return; }
4019
4127
 
4020
4128
  let pane;
4021
4129
  try {
@@ -4024,10 +4132,33 @@ class CliProcess extends Process {
4024
4132
  // captureWide can fail if tmux died, session got renamed, etc. Log
4025
4133
  // once at warn (rate-limited by the outer pong loop's cadence) and
4026
4134
  // return — pong watchdog will eventually trip on the real symptom.
4135
+ // A failed capture didn't actually re-observe the pane, so it can't
4136
+ // count as a consecutive confirmation either way.
4027
4137
  this.logger.warn?.(`[${this.label}] channels: mid-turn captureWide failed: ${err.message}`);
4138
+ this._authDisabledArmed = false;
4028
4139
  return;
4029
4140
  }
4030
- if (!pane) return;
4141
+ if (!pane) { this._authDisabledArmed = false; return; }
4142
+
4143
+ // docs/AUTH_DISABLED_DETECTION_SPEC.md: checked first (most severe
4144
+ // signal) and against a BOUNDED TAIL, not the full ~1000-line pane —
4145
+ // see AUTH_DISABLED_TAIL_LINES for why. Two consecutive matching polls
4146
+ // required before acting; a single match only arms.
4147
+ const authDisabledTail = pane.split('\n').slice(-AUTH_DISABLED_TAIL_LINES).join('\n');
4148
+ if (AUTH_DISABLED_RE.test(authDisabledTail)) {
4149
+ if (this._authDisabledArmed) {
4150
+ this._authDisabledArmed = false;
4151
+ this.logger.warn?.(
4152
+ `[${this.label}] cli: AUTH_DISABLED detected (confirmed on 2 consecutive polls) — ` +
4153
+ `rejecting ${this.pendingTurns.size} pending turn(s)`,
4154
+ );
4155
+ this._rejectPendingTurnsAuthDisabled();
4156
+ return; // skip the rest of this poll — pendingTurns is now empty
4157
+ }
4158
+ this._authDisabledArmed = true;
4159
+ } else if (this._authDisabledArmed) {
4160
+ this._authDisabledArmed = false;
4161
+ }
4031
4162
 
4032
4163
  // rc.14: removed the rc.11 pane-based "dead bridge" detection here. It
4033
4164
  // matched the BENIGN banner "server:water-bridge no MCP server
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shumkov/orchestra",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Drive interactive Claude Code CLI sessions: spawn, inject messages via the MCP channels bridge, observe turns, recover. The transport-agnostic session engine extracted from polygram, shared by polygram (Telegram) and water (WhatsApp).",
5
5
  "type": "commonjs",
6
6
  "main": "index.js",