@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.
- package/README.md +24 -0
- package/docs/EXTRACTION.md +83 -0
- package/index.js +57 -0
- package/lib/approvals/store.js +251 -0
- package/lib/async-lock.js +50 -0
- package/lib/canonical-json.js +63 -0
- package/lib/claude-bin.js +247 -0
- package/lib/compaction-warn.js +60 -0
- package/lib/context-usage.js +94 -0
- package/lib/error/classify.js +469 -0
- package/lib/process/attachment-base.js +41 -0
- package/lib/process/channels-bridge-protocol.js +200 -0
- package/lib/process/channels-bridge-server.js +281 -0
- package/lib/process/channels-bridge.mjs +482 -0
- package/lib/process/cli-process.js +4135 -0
- package/lib/process/factory.js +236 -0
- package/lib/process/hook-append.js +72 -0
- package/lib/process/hook-event-tail.js +163 -0
- package/lib/process/hook-settings.js +182 -0
- package/lib/process/process-manager.js +643 -0
- package/lib/process/process.js +216 -0
- package/lib/process/sdk-process.js +891 -0
- package/lib/process-guard.js +297 -0
- package/lib/questions/store.js +107 -0
- package/lib/tmux/log-tail.js +339 -0
- package/lib/tmux/orphan-sweep.js +80 -0
- package/lib/tmux/poll-scheduler.js +111 -0
- package/lib/tmux/startup-gate.js +251 -0
- package/lib/tmux/tmux-runner.js +415 -0
- package/package.json +46 -0
|
@@ -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 };
|