@shumkov/orchestra 0.2.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,469 @@
1
+ // provenance: polygram lib/error/classify.js — error classification (isTransient used by sdk-process; message strings are consumer defaults)
2
+ /**
3
+ * Error classifier — maps any error from any source to a stable shape.
4
+ *
5
+ * Sources covered: SDK iterator throws (`AbortError` named class plus
6
+ * plain `Error`s), `SDKResultMessage` with subtypes
7
+ * `error_during_execution` / `error_max_turns` / `error_max_budget_usd`
8
+ * / `error_max_structured_output_retries`, per-message
9
+ * `SDKAssistantMessage.error` subtypes (`authentication_failed` /
10
+ * `billing_error` / `rate_limit` / `invalid_request` / `server_error`
11
+ * / `unknown` / `max_output_tokens`), 5xx HTTP errors that bubble
12
+ * through the SDK transport, idle timer fires, polygram-internal
13
+ * Errors with `err.code` set (`INTERRUPTED`, `RESET_SESSION`, etc).
14
+ *
15
+ * Returning the same shape regardless of source means
16
+ * `errorReplyText` in polygram.js doesn't grow N branches every time
17
+ * a new error class shows up — we just add a row to PATTERNS or a
18
+ * `code:` short-circuit at the top.
19
+ *
20
+ * Returned shape:
21
+ * { kind, userMessage, isTransient, autoRecover, shouldResetSession }
22
+ *
23
+ * AUTO_RECOVER actions ('reset_session' etc) let pm self-heal stuck
24
+ * sessions without waiting for the user to type /new.
25
+ */
26
+
27
+ 'use strict';
28
+
29
+ // Substring/regex patterns matched against the error string. Order
30
+ // is significant only when patterns overlap — `transient5xx` is last
31
+ // because the others (auth/billing/format) carry their own status
32
+ // codes too. First match wins.
33
+ const PATTERNS = {
34
+ // Anthropic API rate limit (429) — "rate-limited", "Too Many
35
+ // Requests", token-bucket exhaustion text.
36
+ rateLimit: /\b429\b|rate[_ ]?limit|too[_ ]many[_ ]requests|tokens? per minute/i,
37
+
38
+ // Billing / quota (402, "insufficient credit"). Fires before any
39
+ // model call when the workspace is out of funds.
40
+ billing: /\b402\b|payment[_ ]required|billing|insufficient[_ ]credit/i,
41
+
42
+ // Auth: 401/403, OAuth token expiry, refresh failure. The 0.8.0
43
+ // plan ships an explicit auth-expired UX (admin-chat notify +
44
+ // pause); 0.7.7 just maps to a friendlier user message.
45
+ authExpired: /\b401\b|\b403\b|unauthor(ized|ised)|forbidden|token[_ ]expired|oauth[_ ]token[_ ]refresh[_ ]failed/i,
46
+
47
+ // Context window exceeded — too many tokens for the model. Usually
48
+ // surfaces as `prompt is too long` from Anthropic; sometimes as
49
+ // generic "exceeds maximum context" depending on SDK version.
50
+ contextOverflow: /context[_ ](window|length)|prompt[_ ]too[_ ]large|exceeds[_ ]maximum[_ ]context|prompt is too long/i,
51
+
52
+ // Role alternation / message ordering — fires when transcript has
53
+ // consecutive same-role messages or a tool_use without matching
54
+ // tool_result. Polygram doesn't generate these directly, but they
55
+ // can surface after an interrupted turn.
56
+ roleOrdering: /role.*alternat|message[_ ]ordering|consecutive (user|assistant)/i,
57
+
58
+ // Tool call missing required `input` field. Indicates corrupted
59
+ // history; user-facing message tells them to /new. Word order
60
+ // varies across Anthropic SDK versions — accept either
61
+ // "input...missing" or "missing...input" within a tool_use mention.
62
+ missingToolInput: /tool[_ ]use.*(input.*missing|missing.*input)|missing tool call input|tool input required/i,
63
+
64
+ // Anthropic API rejected an image content block in the conversation.
65
+ // 2026-05-13: shumabit Dina DM hit this after accumulating 53 images
66
+ // over 2 weeks — every new turn 400'd with raw API JSON dumped to
67
+ // the user. Most common cause: a persisted image in the resumed
68
+ // transcript that the API now considers invalid (model snapshot
69
+ // drift, expired URL, bad base64, dimension/size cap, format the
70
+ // API stopped accepting). Recovery: reset_session — /compact has
71
+ // to load the same history so it usually fails too.
72
+ //
73
+ // Anchor on the literal Anthropic phrase to avoid false positives
74
+ // on routine "image" mentions. Tightened by requiring an
75
+ // image-failure verb co-located with "image" or "photo".
76
+ imageProcess: /(could not process|cannot process|failed to (process|load|decode)|unsupported|invalid|corrupt(?:ed)?)[^\n]{0,80}\b(image|photo)\b|\b(image|photo)\b[^\n]{0,80}(could not process|failed to (process|load|decode)|is (invalid|corrupted|unsupported))/i,
77
+
78
+ // Idle/wall-clock timeout from polygram's pm timers, OR
79
+ // model-side timeout. Mapped to a single class; user message is
80
+ // identical either way.
81
+ timeout: /timed[_ ]out|deadline|idle with no Claude activity|wall-clock ceiling/i,
82
+
83
+ // Generic format/validation errors (400 with no other class
84
+ // matching). Rare in practice; included so we don't fall through
85
+ // to "unknown".
86
+ format: /invalid[_ ]request|invalid[_ ]json|malformed|bad request/i,
87
+
88
+ // CLI/channels backend: the claude process exited unexpectedly mid-life
89
+ // ("Claude Code process exited with code N" — e.g. 129/SIGHUP seen on
90
+ // shumabit). A respawn fixes it, so it's transient; the user gets a calm
91
+ // line, never the raw exit code. (known-issue #2.1)
92
+ processExit: /process exited with code|claude code process exited|exited with (signal|code)/i,
93
+
94
+ // Transient HTTP (5xx upstream Anthropic outage / overload). Only
95
+ // these get retried by pm. 521-524/529 are Cloudflare codes seen
96
+ // when Anthropic's edge is degraded.
97
+ transient5xx: /\b5(00|02|03|2[1-4]|29)\b|temporarily overloaded|server[_ ]error|service unavailable/i,
98
+ };
99
+
100
+ // User-facing message per kind. Polygram-style emoji + concise
101
+ // action hint. `null` means "suppress the user-facing reply" (used
102
+ // for INTERRUPTED inside the abort-grace window — the user already
103
+ // saw their /stop ack).
104
+ const USER_MESSAGES = {
105
+ rateLimit: '⚠️ Rate-limited by Anthropic. Try again in a minute.',
106
+ billing: '💳 Billing issue on Anthropic — operator needs to top up credits.',
107
+ authExpired: '🔑 Claude auth expired. Operator has been notified.',
108
+ contextOverflow: '📚 Conversation got too long. Send /new to start fresh.',
109
+ roleOrdering: '⚠️ Conversation got into a tangled state. Try /new.',
110
+ missingToolInput: '⚠️ Session history looks corrupted. Try /new.',
111
+ imageProcess: '🖼 One of the images in this conversation can\'t be re-processed by Claude — likely an older one in the history. Starting a fresh session for this chat.',
112
+ timeout: '⏳ I went quiet too long without finishing. Try resending or simplifying.',
113
+ format: '⚠️ Invalid request format. Try rephrasing or /new.',
114
+ processExit: '🔄 My Claude process stopped unexpectedly — resend in a moment and I\'ll restart it.',
115
+ // Used both for in-flight retry attempts AND for the post-retry-failed
116
+ // bubble-up message. Avoid promising "retrying once" since by the
117
+ // time the user reads it pm has already retried and given up.
118
+ transient5xx: '☁️ Server hiccup — please try again in a moment.',
119
+ };
120
+
121
+ // Auto-recovery actions for kinds where the session is irrecoverable
122
+ // without a reset. polygram.js consults this when result.error fires
123
+ // and dispatches `pm.resetSession()` accordingly.
124
+ //
125
+ // Values map to action names that pm understands:
126
+ // 'reset_session' — close current Query, clear sessionId, fresh start
127
+ // (future) 'compact' — manual compact request, if SDK exposes it
128
+ const AUTO_RECOVER = {
129
+ roleOrdering: 'reset_session',
130
+ contextOverflow: 'reset_session',
131
+ missingToolInput: 'reset_session',
132
+ imageProcess: 'reset_session',
133
+ };
134
+
135
+ // Typed-code short-circuits — set on errors polygram throws itself
136
+ // (see lib/process-manager-sdk.js), not pattern-matched. Keep these
137
+ // in sync with the codes pm emits.
138
+ const CODES = {
139
+ // 0.7.6 (item H): queue cap drop. Pre-empts pattern matching so
140
+ // the queue-overflow message is exact, not classified.
141
+ QUEUE_OVERFLOW: {
142
+ kind: 'queueOverflow',
143
+ userMessage: '⏭ Couldn\'t keep up — this message was skipped while I was processing newer ones. Resend if it still matters.',
144
+ isTransient: false,
145
+ autoRecover: null,
146
+ },
147
+ // Set on pendings rejected via pm.interrupt() (e.g. /stop). Matched
148
+ // here so the abort-grace silence works — user already saw the
149
+ // /stop ack, no need to surface another error.
150
+ INTERRUPTED: {
151
+ kind: 'interrupted',
152
+ userMessage: null,
153
+ isTransient: false,
154
+ autoRecover: null,
155
+ },
156
+ // Set when pm.resetSession() drains the queue for any reason
157
+ // (auto-recovery, /new, /reset, auth-expired).
158
+ RESET_SESSION: {
159
+ kind: 'resetSession',
160
+ userMessage: '✨ Started a fresh session.',
161
+ isTransient: false,
162
+ autoRecover: null,
163
+ },
164
+ // 0.8.0 auth-expired path — set on every pending the daemon
165
+ // rejects after a 401 surface. Distinct from authExpired pattern
166
+ // because it's polygram saying "I already noticed and paused"
167
+ // rather than "I just hit a 401 and am about to handle it".
168
+ AUTH_EXPIRED: {
169
+ kind: 'authExpired',
170
+ userMessage: '🔑 The bot needs re-auth. The operator has been notified. Try again in a few minutes.',
171
+ isTransient: false,
172
+ autoRecover: null,
173
+ },
174
+ // Review F#5: channels-specific error codes. Pre-fix these fell through
175
+ // to the generic 'unknown' kind (errorReplyText: "Hit a snag. Try
176
+ // resending.") which lies about what happened. Mirrors the rc.46→rc.47
177
+ // tmuxToolWedge fix where backend-specific codes needed their own kinds.
178
+ //
179
+ // BRIDGE_DISCONNECTED: thrown by CliProcess when the mcp-bridge
180
+ // socket drops mid-turn (claude crashed, bridge process died, etc).
181
+ // isTransient: true because the daemon retries spawning the backend.
182
+ BRIDGE_DISCONNECTED: {
183
+ kind: 'bridgeDisconnected',
184
+ userMessage: '🔌 Lost the bridge to Claude mid-turn. Retrying — please resend if I don\'t reply in 30s.',
185
+ isTransient: true,
186
+ autoRecover: null,
187
+ },
188
+ // CHANNELS_HANDSHAKE_TIMEOUT: bridge process never sent session_init
189
+ // within the handshake window during start(). Usually means the bridge
190
+ // crashed pre-init or the socket file is stale.
191
+ CHANNELS_HANDSHAKE_TIMEOUT: {
192
+ kind: 'channelsHandshakeTimeout',
193
+ userMessage: '⏳ Couldn\'t start a Claude session — the bridge didn\'t respond in time. Try again in a moment.',
194
+ isTransient: true,
195
+ autoRecover: null,
196
+ },
197
+ // CHANNELS_DIALOG_TIMEOUT: a permission / usage-limit / context-overflow
198
+ // dialog opened mid-turn and we couldn't auto-respond within the dialog
199
+ // window. The turn is dead; user needs to retry.
200
+ CHANNELS_DIALOG_TIMEOUT: {
201
+ kind: 'channelsDialogTimeout',
202
+ userMessage: '🚧 Claude hit a dialog (permission/usage-limit) mid-turn and I couldn\'t auto-respond in time. Please resend.',
203
+ isTransient: false,
204
+ autoRecover: null,
205
+ },
206
+ // TMUX_SESSION_GONE: claude exited during spawn so the tmux session vanished
207
+ // before the channel went live (the startup-gate's captureWide hit "can't
208
+ // find pane"). Usual cause: an unresumable aged session whose "Resume from
209
+ // summary?" /compact exits code 0. The dispatcher poison-clears the session
210
+ // on this code, so a resend genuinely starts fresh and works — hence the
211
+ // calm "send it again" copy instead of the old raw "[startup-gate]…" leak.
212
+ TMUX_SESSION_GONE: {
213
+ kind: 'tmuxSessionGone',
214
+ userMessage: '🔄 That chat got stuck starting up, so I reset it. Send your message again and I\'ll pick it up fresh.',
215
+ isTransient: false,
216
+ autoRecover: null,
217
+ },
218
+ // TURN_TIMEOUT: per-turn time cap fired because the turn went QUIET with no
219
+ // detectable progress (0.16: the busy-aware checkpoint extends a turn that's
220
+ // provably working, so reaching this code means the probe saw no streaming /
221
+ // no active shell — a genuine stall/wedge, not a long-but-working turn). Not
222
+ // transient. Copy must not name a number (the 2026-06-11 UMI false-⏱ rendered
223
+ // "10-minute" under a 60-minute cap).
224
+ TURN_TIMEOUT: {
225
+ kind: 'turnTimeout',
226
+ userMessage: '⏱ This one went quiet with no progress, so I stopped waiting — send /stop to clear it, or resend if you still need it.',
227
+ isTransient: false,
228
+ autoRecover: null,
229
+ },
230
+ // TURN_MAX_EXCEEDED (0.16): the busy-aware checkpoint kept extending a turn
231
+ // that WAS still working, until it hit the hard wall-clock backstop
232
+ // (turnHardMaxMs, default 90 min). Distinct from TURN_TIMEOUT (which means
233
+ // "went quiet") — here it ran genuinely long and we capped it for safety.
234
+ TURN_MAX_EXCEEDED: {
235
+ kind: 'turnMaxExceeded',
236
+ userMessage: '⏱ This ran past the max time and I had to stop it. Resend if you still need it — or break it into smaller steps.',
237
+ isTransient: false,
238
+ autoRecover: null,
239
+ },
240
+ };
241
+
242
+ /**
243
+ * Classify an error from any source.
244
+ *
245
+ * Accepts:
246
+ * - Error / object with `code` / `message`
247
+ * - SDKResultMessage with `subtype` and optional `error`
248
+ * - SDKAssistantMessage.error (string subtype like 'rate_limit')
249
+ * - plain string
250
+ * - null/undefined (returns the 'unknown' shape)
251
+ *
252
+ * Returns an object with stable shape:
253
+ * {
254
+ * kind: 'rateLimit' | 'billing' | ... | 'unknown' | code-keyed kind,
255
+ * userMessage: string | null, // null means suppress reply
256
+ * isTransient: boolean, // true → pm should retry once
257
+ * autoRecover: 'reset_session' | null,
258
+ * }
259
+ */
260
+ function classify(err) {
261
+ // Typed-code short-circuit takes priority over pattern matching.
262
+ // Errors polygram constructs internally (QUEUE_OVERFLOW etc.) set
263
+ // `err.code` so we don't depend on string content.
264
+ const code = err?.code;
265
+ if (code && CODES[code]) {
266
+ return { ...CODES[code] };
267
+ }
268
+
269
+ // SDKAssistantMessage.error is a short string code from a fixed
270
+ // union — match those directly, not via regex. Result subtypes
271
+ // are checked LATER (after pattern matching) so a more-specific
272
+ // pattern in the message text (e.g. 'HTTP 401' inside an
273
+ // error_during_execution subtype) wins over the generic subtype
274
+ // mapping that defaults the entire error_during_execution class
275
+ // to transient.
276
+ if (typeof err === 'string') {
277
+ const sdkMessageError = matchSdkMessageError(err);
278
+ if (sdkMessageError) return sdkMessageError;
279
+ }
280
+
281
+ const msg = extractMessage(err);
282
+ for (const [kind, re] of Object.entries(PATTERNS)) {
283
+ if (re.test(msg)) {
284
+ return {
285
+ kind,
286
+ userMessage: USER_MESSAGES[kind],
287
+ isTransient: kind === 'transient5xx' || kind === 'rateLimit' || kind === 'processExit',
288
+ autoRecover: AUTO_RECOVER[kind] ?? null,
289
+ };
290
+ }
291
+ }
292
+
293
+ // After pattern matching: try SDK result subtypes. A bare string
294
+ // like 'error_during_execution' (no message context) lands here
295
+ // and gets the friendly transient5xx kind. Object inputs with a
296
+ // subtype field also land here when their message text didn't
297
+ // match a more specific pattern.
298
+ if (typeof err === 'string') {
299
+ const sdkResultSubtype = matchSdkResultSubtype(err);
300
+ if (sdkResultSubtype) return sdkResultSubtype;
301
+ }
302
+ if (err?.subtype && typeof err.subtype === 'string') {
303
+ const sdkResultSubtype = matchSdkResultSubtype(err.subtype);
304
+ if (sdkResultSubtype) return sdkResultSubtype;
305
+ }
306
+
307
+ // Fall-through: an error we couldn't classify. Do NOT echo the raw text
308
+ // to the user (known-issue #2.2) — it leaks internal identifiers (tmux
309
+ // names, gate vocabulary, pane dumps; the 2026-06-03 incident). The full
310
+ // raw string is preserved by callers in the events log
311
+ // (handler-error.detail_json) for forensics; the user gets a calm,
312
+ // generic, actionable line.
313
+ return {
314
+ kind: 'unknown',
315
+ userMessage: '⚠️ Something went wrong on my end — try resending. If it keeps happening, send /new.',
316
+ isTransient: false,
317
+ autoRecover: null,
318
+ };
319
+ }
320
+
321
+ // Pull a string out of whatever shape the caller passed.
322
+ function extractMessage(err) {
323
+ if (err == null) return '';
324
+ if (typeof err === 'string') return err;
325
+ if (err.message) return String(err.message);
326
+ if (err.error) return String(err.error);
327
+ return String(err);
328
+ }
329
+
330
+ // SDKAssistantMessage.error fields are a small fixed union
331
+ // (sdk.d.ts:2343). Map directly so we don't depend on transport-
332
+ // specific error text.
333
+ const SDK_MESSAGE_ERROR_MAP = {
334
+ authentication_failed: 'authExpired',
335
+ billing_error: 'billing',
336
+ rate_limit: 'rateLimit',
337
+ invalid_request: 'format',
338
+ server_error: 'transient5xx',
339
+ unknown: 'unknown',
340
+ max_output_tokens: 'format', // closest match — model gave up
341
+ };
342
+ function matchSdkMessageError(s) {
343
+ const kind = SDK_MESSAGE_ERROR_MAP[s];
344
+ if (!kind) return null;
345
+ if (kind === 'unknown') return null; // fall through to pattern match
346
+ return {
347
+ kind,
348
+ userMessage: USER_MESSAGES[kind] ?? null,
349
+ isTransient: kind === 'transient5xx' || kind === 'rateLimit',
350
+ autoRecover: AUTO_RECOVER[kind] ?? null,
351
+ };
352
+ }
353
+
354
+ // SDKResultMessage.subtype values (sdk.d.ts:3121). Most are
355
+ // terminal-error indicators that don't have a clean pattern equivalent.
356
+ //
357
+ // `error_during_execution` is the SDK's catch-all for "something went
358
+ // wrong mid-turn" — could be a transient stream/network blip OR a
359
+ // systemic model issue. We treat it as transient (1 retry is cheap;
360
+ // if it's systemic the second attempt fails fast). Pre-rc.5 this was
361
+ // mapped to 'unknown' which fell through to the default "Hit a snag:
362
+ // error_during_execution" template — leaking the SDK enum to users.
363
+ const SDK_RESULT_SUBTYPE_MAP = {
364
+ error_during_execution: 'transient5xx',
365
+ error_max_turns: 'format',
366
+ error_max_budget_usd: 'billing',
367
+ error_max_structured_output_retries: 'format',
368
+ };
369
+ function matchSdkResultSubtype(s) {
370
+ if (s === 'success') return null;
371
+ const kind = SDK_RESULT_SUBTYPE_MAP[s];
372
+ if (!kind || kind === 'unknown') return null;
373
+ return {
374
+ kind,
375
+ userMessage: USER_MESSAGES[kind] ?? null,
376
+ // Derive transience from the kind so error_during_execution →
377
+ // transient5xx → isTransient=true, matching the pattern-match
378
+ // branch's behaviour. pm guards retry with firstAssistantSeen=
379
+ // false, which prevents budget waste when the turn already had
380
+ // billable assistant output.
381
+ isTransient: kind === 'transient5xx' || kind === 'rateLimit',
382
+ autoRecover: AUTO_RECOVER[kind] ?? null,
383
+ };
384
+ }
385
+
386
+ // True if pm's iteration loop should sleep and retry the user
387
+ // message ONCE before giving up. Currently only transient5xx and
388
+ // rateLimit. Per v4 plan §6.6 H1/M2, retry only fires when the
389
+ // turn produced ZERO assistant messages (idempotency); pm checks
390
+ // that flag, not this function.
391
+ function isTransientHttpError(err) {
392
+ return classify(err).isTransient;
393
+ }
394
+
395
+ // 2026-05-13: detect the case where SDK reports result_subtype=success
396
+ // BUT the assistant text is actually an API error JSON the SDK wrapped
397
+ // as if it were the model's reply. Happens when the resumed session's
398
+ // transcript has data the API can't reload — most commonly an image
399
+ // content block. Pattern is distinctive ("API Error: <code> {...
400
+ // type:error ...}") so false positives are unlikely.
401
+ //
402
+ // When this matches:
403
+ // - The text we'd deliver to Telegram is garbage (raw JSON).
404
+ // - The Claude session is wedged — every future turn will fail the
405
+ // same way until reset.
406
+ // Returns the same shape as classify() so callers can use it uniformly,
407
+ // or null when the text looks like a legitimate assistant reply.
408
+ const WEDGED_SESSION_RE = /^API Error: \d{3}\s+\{[^}]*"type"\s*:\s*"error"/;
409
+
410
+ function detectWedgedSessionError(text) {
411
+ if (typeof text !== 'string' || text.length === 0) return null;
412
+ if (!WEDGED_SESSION_RE.test(text)) return null;
413
+ // Once we've confirmed it's a wrapped API error, run classify on the
414
+ // text — it picks up imageProcess / rateLimit / billing / etc. from
415
+ // the JSON body. classify is robust to JSON-ish input.
416
+ const cls = classify(new Error(text));
417
+ // If classify fell through to 'unknown', return a safe imageProcess
418
+ // shape — wedged sessions are most commonly image-driven and the
419
+ // recovery action (reset_session) is correct for all wedge classes.
420
+ if (cls.kind === 'unknown') {
421
+ return {
422
+ kind: 'imageProcess',
423
+ userMessage: USER_MESSAGES.imageProcess,
424
+ isTransient: false,
425
+ autoRecover: 'reset_session',
426
+ };
427
+ }
428
+ return cls;
429
+ }
430
+
431
+ /**
432
+ * 0.16: decide how the streamed-reply catch (polygram.js handleMessage) should
433
+ * cap the bubble + set the reactor when a turn ends in error. Extracted as a
434
+ * pure fn so the decision is unit-testable (the catch itself isn't unit-reachable).
435
+ *
436
+ * Returns { errorSuffix, reactorState }:
437
+ * - errorSuffix: appended to streamer.finalize('') (null = no suffix)
438
+ * - reactorState: reactor.setState(...) value
439
+ *
440
+ * Turn-end timeouts (TURN_TIMEOUT = went quiet, TURN_MAX_EXCEEDED = hit hard cap)
441
+ * are real stops → the "stream interrupted" suffix is honest here. Note: the cli
442
+ * backend's TURN_TIMEOUT err.message is `turn timeout (...)` which does NOT match
443
+ * the legacy /wall-clock ceiling|idle.../ regex, so we branch on err.code, not
444
+ * the message text (a v1-review correction).
445
+ */
446
+ function classifyTurnEndError(err) {
447
+ const code = err?.code;
448
+ // The cli backend sets err.code (TURN_TIMEOUT / TURN_MAX_EXCEEDED). The SDK +
449
+ // tmux backends reject with a MESSAGE and NO code (e.g. "Turn exceeded 1800s
450
+ // wall-clock ceiling" / "Timeout: 600s idle with no Claude activity"), so we
451
+ // MUST keep the legacy message regex as a fallback — without it those
452
+ // backends' timeouts flip from the calm ⏱ TIMEOUT reactor to the scary ERROR
453
+ // one (regression caught in the 0.16 code review).
454
+ const isTimeout = code === 'TURN_TIMEOUT'
455
+ || code === 'TURN_MAX_EXCEEDED'
456
+ || /wall-clock ceiling|idle with no Claude activity/i.test(err?.message || '');
457
+ return { errorSuffix: 'stream interrupted', reactorState: isTimeout ? 'TIMEOUT' : 'ERROR' };
458
+ }
459
+
460
+ module.exports = {
461
+ classify,
462
+ classifyTurnEndError,
463
+ detectWedgedSessionError,
464
+ isTransientHttpError,
465
+ PATTERNS,
466
+ USER_MESSAGES,
467
+ AUTO_RECOVER,
468
+ CODES,
469
+ };
@@ -0,0 +1,41 @@
1
+ // provenance: extracted from polygram/water channels-tool-dispatcher.js (git 746bca6).
2
+ // The staging-dir base + attachment-path validators are engine-level (they bound where
3
+ // a Claude session may read files from for outbound sends), independent of transport.
4
+
5
+ 'use strict';
6
+
7
+ const fs = require('node:fs');
8
+ const path = require('node:path');
9
+ const os = require('node:os');
10
+
11
+ // Per-session staging dir for agent file sends. Consumers may override the base.
12
+ const DEFAULT_ATTACHMENT_BASE = path.join(os.tmpdir(), 'orchestra-attachments');
13
+
14
+ function isPathUnder(child, parent) {
15
+ const rel = path.relative(parent, child);
16
+ return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
17
+ }
18
+
19
+ function validateAttachmentPath(filePath, allowedRoots) {
20
+ if (typeof filePath !== 'string' || filePath.length === 0) return { ok: false, error: 'empty path' };
21
+ let real;
22
+ try { real = fs.realpathSync(filePath); } catch (e) { return { ok: false, error: `not found: ${e.code || e.message}` }; }
23
+ const allowed = allowedRoots.some((root) => {
24
+ try { return isPathUnder(real, fs.realpathSync(root)); } catch { return false; }
25
+ });
26
+ if (!allowed) return { ok: false, error: 'path outside the allowed staging/cwd roots' };
27
+ return { ok: true, real };
28
+ }
29
+
30
+ function buildAllowedRoots({ sessionKey, sessionCwd = null, extraRoots = [], base = DEFAULT_ATTACHMENT_BASE }) {
31
+ // RAW sessionKey — must byte-match the per-session staging dir cli-process creates
32
+ // (path.join(base, String(sessionKey))). Sanitizing here (but not there) diverges the
33
+ // allowlist from the real dir for keys with chars outside [\w.-] (e.g. WhatsApp JIDs
34
+ // '…@g.us'), so realpath(root) misses and every reply(files) is rejected.
35
+ const roots = [path.join(base, String(sessionKey))];
36
+ if (sessionCwd) roots.push(sessionCwd);
37
+ for (const r of extraRoots) if (r) roots.push(r);
38
+ return roots;
39
+ }
40
+
41
+ module.exports = { DEFAULT_ATTACHMENT_BASE, isPathUnder, validateAttachmentPath, buildAllowedRoots };