@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.
- package/docs/AUTH_DISABLED_DETECTION_SPEC.md +388 -0
- package/lib/claude-bin.js +41 -0
- package/lib/process/cli-process.js +137 -6
- package/package.json +1 -1
|
@@ -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
|
-
|
|
4010
|
-
|
|
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.
|
|
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",
|