polygram 0.17.10 → 0.17.12

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.
Files changed (39) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/lib/config.js +21 -0
  3. package/lib/error/classify.js +14 -0
  4. package/lib/handlers/dispatcher.js +54 -1
  5. package/lib/handlers/drop-redeliver.js +19 -0
  6. package/lib/handlers/should-handle.js +52 -0
  7. package/lib/media-group-buffer.js +29 -0
  8. package/lib/ops/auth-disabled-gate.js +43 -0
  9. package/lib/ops/heartbeat.js +56 -0
  10. package/lib/process/channels-tool-dispatcher.js +12 -1
  11. package/lib/prompt.js +22 -7
  12. package/lib/sdk/callbacks.js +2 -1
  13. package/lib/secret-detect.js +13 -1
  14. package/lib/telegram/chunk.js +20 -6
  15. package/lib/telegram/process-agent-reply.js +5 -3
  16. package/lib/telegram/streamer.js +6 -1
  17. package/package.json +2 -1
  18. package/polygram.js +202 -45
  19. package/lib/async-lock.js +0 -49
  20. package/lib/claude-bin.js +0 -246
  21. package/lib/compaction-warn.js +0 -59
  22. package/lib/context-usage.js +0 -93
  23. package/lib/process/channels-bridge-protocol.js +0 -199
  24. package/lib/process/channels-bridge-server.js +0 -274
  25. package/lib/process/channels-bridge.mjs +0 -477
  26. package/lib/process/cli-process.js +0 -4029
  27. package/lib/process/factory.js +0 -215
  28. package/lib/process/hook-event-tail.js +0 -162
  29. package/lib/process/hook-settings.js +0 -181
  30. package/lib/process/polygram-hook-append.js +0 -71
  31. package/lib/process/process.js +0 -215
  32. package/lib/process/sdk-process.js +0 -880
  33. package/lib/process-guard.js +0 -296
  34. package/lib/process-manager.js +0 -628
  35. package/lib/tmux/log-tail.js +0 -334
  36. package/lib/tmux/orphan-sweep.js +0 -79
  37. package/lib/tmux/poll-scheduler.js +0 -110
  38. package/lib/tmux/startup-gate.js +0 -250
  39. package/lib/tmux/tmux-runner.js +0 -412
@@ -1,628 +0,0 @@
1
- /**
2
- * ProcessManager — generic collection of `Process` instances.
3
- *
4
- * Holds Map<sessionKey, Process>. Doesn't know or care which concrete
5
- * Process subclass it's holding. SdkProcess + TmuxProcess both
6
- * implement the same `lib/process/process.js` interface.
7
- *
8
- * Per-session dispatch (send, kill, interrupt, etc.) just delegates
9
- * to the Process. Collection logic (LRU eviction, killChat, shutdown)
10
- * lives here.
11
- *
12
- * Weighted LRU per Phase 0 F-spike-2: tmux backend is ~10× SDK pm's
13
- * RSS (545MB vs 50MB). We evict to keep Σ Process.cost ≤ budget
14
- * rather than count ≤ cap. Default: SDK cost=1, tmux cost=3,
15
- * budget=10 → "10 SDK | 3 tmux | mixed in between."
16
- *
17
- * Lifecycle callbacks (onInit, onClose, onStreamChunk, etc.) get wired
18
- * to each Process's EventEmitter at spawn. Process emits, pm forwards
19
- * to operator's callback.
20
- *
21
- * Phase 1 only (this file): SDK-only factory; ProcessManager behaviour
22
- * matches the current `lib/sdk/process-manager.js` API exactly. After
23
- * Phase 1 lands and tests pass, the old per-bot pm class is deleted.
24
- *
25
- * See `docs/0.10.0-process-manager-abstraction-plan.md` for the full
26
- * design.
27
- */
28
-
29
- 'use strict';
30
-
31
- const DEFAULT_BUDGET = 10; // total Σ cost (SDK cost=1, tmux cost=3)
32
- const DEFAULT_LRU_WAIT_MS = 300_000;
33
-
34
- // callback name → event name
35
- const CALLBACK_TO_EVENT = {
36
- onInit: 'init',
37
- onClose: 'close',
38
- onResult: 'result',
39
- onStreamChunk: 'stream-chunk',
40
- onToolUse: 'tool-use',
41
- onAssistantMessageStart: 'assistant-message-start',
42
- onAutonomousAssistantMessage: 'autonomous-assistant-message',
43
- onCompactBoundary: 'compact-boundary',
44
- // 0.12.0-rc.13: per-chat compaction warning. CliProcess emits
45
- // 'compaction-warn' {kind:'proactive'|'reactive', pct?} when (proactive)
46
- // context crosses the chat's threshold at turn-end, or (reactive) claude is
47
- // auto-compacting now. The callback posts a chat message proposing /compact
48
- // — opt-in per chat. See docs/0.12.0-file-send.md / lib/compaction-warn.js.
49
- onCompactionWarn: 'compaction-warn',
50
- // 0.12.0 background-work visibility (Use 3). CliProcess emits 'bg-work-status'
51
- // {state:'running'|'cleared', count?} when a detached background shell is first
52
- // observed running idle past its turn, and again when it clears. The callback
53
- // posts/edits a "⏳ working in background" status message so a long job reads as
54
- // working, not stuck. See docs/0.12.0-background-work-lifecycle-plan.md.
55
- onBgWorkStatus: 'bg-work-status',
56
- // 0.16 busy-aware ceiling: CliProcess emits 'turn-extended' the FIRST time a
57
- // turn passes the 30-min checkpoint while still provably working. The callback
58
- // posts a one-time "⏳ still working — /stop to cancel" message so a long turn
59
- // reads as alive (not the old false "stream interrupted"). See
60
- // docs/0.16-turn-ceiling-busy-aware-spec.md.
61
- onTurnExtended: 'turn-extended',
62
- // 0.12 interactive questions: CliProcess emits 'question-asked'
63
- // {sessionKey, chatId, threadId, turnId, toolCallId, questions} when claude calls
64
- // the `ask` tool. The callback (polygram) renders the Telegram inline keyboard;
65
- // the user's tap/typed answer routes back via pm.answerQuestion → writeQuestionAnswer.
66
- onQuestionAsked: 'question-asked',
67
- // 0.12.0 question-progress-resume: CliProcess emits 'question-resumed' (no payload) when a
68
- // blocking `ask` resolves with a real answer and the turn resumes working. The callback
69
- // re-arms the per-turn reactor (it cleared during the wait, no hooks re-lit it). See
70
- // docs/0.12.0-question-resume-progress-spec.md.
71
- onQuestionResumed: 'question-resumed',
72
- // 0.13 D2: CliProcess emits 'input-dropped' {turnId, msgId, chatId, source}
73
- // when a ledgered input was confirmed dropped (never seen/acked by cycle-end
74
- // + confirm window, contract observed). polygram redelivers ONCE via the D4
75
- // tail (lib/handlers/drop-redeliver.js).
76
- onInputDropped: 'input-dropped',
77
- onQueueDrop: 'queue-drop',
78
- onThinking: 'thinking',
79
- // Tmux backend: TUI shows in-pane approval prompt. SDK backend
80
- // uses canUseTool callback directly (no event). Polygram wires
81
- // onApprovalRequired to route tmux prompts through the SAME
82
- // approval card UI used by SDK's canUseTool flow.
83
- onApprovalRequired: 'approval-required',
84
- // 0.13 P4: the tmux-era rows (onExtraTurnReply/-Started, onAutosteerResolution/
85
- // -MatchMiss) were removed — zero emitters on any backend since the 0.12 tmux
86
- // deletion; the 'autosteer-resolution' audit trail returns as D2 ledger events.
87
- // 0.13 D3: 'turn-start' (UserPromptSubmit; payload {hasPending, anchorMsgId})
88
- // and 'idle' get polygram-side consumers — the session feedback controller's
89
- // start/stop edges for cycles with no pending turn. ('idle' is ALSO wired
90
- // internally for LRU waiters in _wireCallbacks — both fire.)
91
- onTurnStart: 'turn-start',
92
- onIdle: 'idle',
93
- // R8: tmux backend autosteer paste failure. TmuxProcess.injectUserMessage
94
- // fires `inject-fail` when its fire-and-forget paste rejects. Before
95
- // this was wired the event had no consumer — a failed autosteer was
96
- // silent until the stale-turn sweep caught it turnTimeoutMs later.
97
- // The handler logs the failure and clears the ✍ on the failed msgId.
98
- onInjectFail: 'inject-fail',
99
- // 0.10.0: tmux backend turn-phase predicate (observer-only Commit 1
100
- // of the patience-model unification — see docs/0.10.0-tmux-patience-
101
- // model-solution.md). TmuxProcess emits `phase-change` on every
102
- // TurnPhase transition; polygram persists it as `turn-phase-change`
103
- // in the events DB so the soak can verify the predicate's
104
- // trajectory against real workloads before Commits 2-3 start
105
- // consuming turn.phase for control flow. SDK backend never emits
106
- // this — predicate is tmux-specific.
107
- onPhaseChange: 'phase-change',
108
- // 0.10.0 H1: tmux backend hook-based turn observability. TmuxProcess
109
- // tails a per-session ndjson that claude appends to via
110
- // `--settings`-injected command hooks (PreToolUse/PostToolUse/
111
- // UserPromptSubmit/Stop/SubagentStop/Notification). Each event is
112
- // forwarded here so polygram persists it as `hook-event` in the
113
- // events DB for the H1 soak. OBSERVER-ONLY — no control flow
114
- // consumes the events yet (mirrors Commit 1 of the patience-model
115
- // unification). SDK backend never emits — hooks are tmux-specific.
116
- // See docs/0.10.0-tmux-hook-observability.md.
117
- onHookEvent: 'hook-event',
118
- // 0.10.0 rc.42 (review-driven #1): tmux backend turn-timeout event.
119
- // Mirrors sdk-process.js's `_logEvent('turn-timeout', ...)` so both
120
- // backends emit the same diagnostic. Payload distinguishes
121
- // `idle-ceiling` vs `hard-backstop` (the H3 racers) so operators can
122
- // tell a wedged-silent subagent from a runaway tool loop.
123
- onTurnTimeout: 'turn-timeout',
124
- // 0.10.0 rc.42 (review-driven #8): tmux backend hook-tail
125
- // degradation event. The hook ndjson is load-bearing for H3 idle
126
- // heartbeats; a persistently broken tail silently resurrects
127
- // msg-884-class kills. Emitting the event surfaces the degradation
128
- // in the events DB so it's visible in forensics, not just
129
- // logger.warn.
130
- onHookTailError: 'hook-tail-error',
131
- // 0.10.0 rc.42 (review-driven #15): tmux backend stop-hook-resolved
132
- // event. Fires when a turn settled via the H4 Stop-hook synth path
133
- // instead of the canonical JSONL `result` (i.e. JSONL was broken or
134
- // stuck and Stop rescued the turn). The synth's `via: 'stop-hook'`
135
- // field was previously dead — only the tests read it. Persisting
136
- // the event lets the soak count how often H4 actually fires its
137
- // rescue contract.
138
- onStopHookResolved: 'stop-hook-resolved',
139
- // 0.10.0 rc.43: claude TUI's "This session is N old…" interactive
140
- // menu auto-dismissed by `_waitForReady`. Surfacing the event so
141
- // soak can count how often aged-session resumes hit this path.
142
- onSessionAgePromptDismissed: 'session-age-prompt-dismissed',
143
- // 0.12 CliProcess observability — typed hook events from cli-process.js
144
- // _handleHookEvent. Each gets its own callback so polygram can persist
145
- // structured rows to the events DB for soak-time aggregate queries.
146
- // - hook-lag-sample: Phase 1.8 — per-event lag_ms (target: median<2s, p99<5s)
147
- // - tool-result: Phase 1.3 — PostToolUse durationMs per tool
148
- // - subagent-start / subagent-done: Phase 1.3 — typed subagent lifecycle
149
- // (we DO get tool-use='Agent' via onToolUse, but agent_type + durationMs
150
- // only fire on these typed events). SDK backend never emits — hooks
151
- // are CliProcess-specific (and were tmux-specific in 0.10–0.11).
152
- onHookLagSample: 'hook-lag-sample',
153
- onToolResult: 'tool-result',
154
- onSubagentStart: 'subagent-start',
155
- onSubagentDone: 'subagent-done',
156
- };
157
-
158
- class ProcessManager {
159
- /**
160
- * @param {object} opts
161
- * @param {(sessionKey: string, ctx: object) => Process} opts.processFactory
162
- * — required. Returns a Process instance (not yet started).
163
- * @param {number} [opts.budget=10] — weighted LRU budget
164
- * @param {object} [opts.db] — used for _logEvent (matches today's pm)
165
- * @param {object} [opts.logger=console]
166
- * @param {object} [opts.callbacks={}] — keys: onInit, onClose, ...
167
- * @param {number} [opts.lruWaitMs] — how long getOrSpawn parks
168
- * when all entries are in-flight
169
- */
170
- constructor({
171
- processFactory,
172
- budget = DEFAULT_BUDGET,
173
- db,
174
- logger = console,
175
- callbacks = {},
176
- lruWaitMs = DEFAULT_LRU_WAIT_MS,
177
- } = {}) {
178
- if (typeof processFactory !== 'function') {
179
- throw new TypeError('ProcessManager: processFactory function required');
180
- }
181
- this.processFactory = processFactory;
182
- this.budget = budget;
183
- this.db = db;
184
- this.logger = logger;
185
- this.callbacks = { ...callbacks };
186
- this.lruWaitMs = lruWaitMs;
187
- this.procs = new Map(); // sessionKey → Process
188
- this._lruWaiters = []; // [{ resolve, reject, timer }]
189
- this._shuttingDown = false;
190
- // sessionKey → in-flight start() Promise. Lets a concurrent
191
- // getOrSpawn for the same key await the spawn instead of
192
- // returning a proc whose start() hasn't resolved (see getOrSpawn).
193
- this._starting = new Map();
194
- }
195
-
196
- // ─── Introspection ───────────────────────────────────────────────
197
-
198
- has(sessionKey) { return this.procs.has(sessionKey); }
199
- get(sessionKey) { return this.procs.get(sessionKey) || null; }
200
- keys() { return [...this.procs.keys()]; }
201
- get size() { return this.procs.size; }
202
-
203
- /**
204
- * Current total cost across all live processes.
205
- */
206
- get totalCost() {
207
- let sum = 0;
208
- for (const p of this.procs.values()) {
209
- if (!p.closed) sum += p.cost;
210
- }
211
- return sum;
212
- }
213
-
214
- // ─── Spawn + LRU ─────────────────────────────────────────────────
215
-
216
- /**
217
- * Returns the Process for sessionKey, spawning if absent.
218
- * Evicts other processes (oldest non-in-flight first) to make room
219
- * when adding a new Process would exceed budget.
220
- *
221
- * @param {string} sessionKey
222
- * @param {object} spawnContext — passed through to processFactory + start()
223
- */
224
- async getOrSpawn(sessionKey, spawnContext) {
225
- if (this._shuttingDown) throw new Error('shutdown');
226
-
227
- const existing = this.procs.get(sessionKey);
228
- if (existing && !existing.closed) {
229
- // getOrSpawn registers the proc in this.procs BEFORE awaiting
230
- // start(). A concurrent getOrSpawn for the same key (a second
231
- // Telegram message landing during the ~11s tmux spawn) would
232
- // otherwise get this still-spawning proc and call send() on it
233
- // — pasting a turn into a TUI that is not ready, which silently
234
- // drops the paste and returns an empty turn (shumorobot
235
- // production 2026-05-16: msg 2 of a 3-message burst returned
236
- // "No response generated"). Await the in-flight spawn so every
237
- // caller receives a proc whose start() has fully resolved.
238
- const pendingStart = this._starting.get(sessionKey);
239
- if (pendingStart) await pendingStart;
240
- // Reload-on-drift (cli): a warm cli proc can't hot-swap model/effort
241
- // (spawn-time flags). If the resolved config has drifted and the proc is
242
- // idle, kill it (preserves session_id) and fall through to a cold respawn
243
- // → --resume keeps the conversation, the new --model/--effort takes
244
- // effect. In-flight cli procs and SDK procs (no wouldReloadFor — they
245
- // apply model live) are reused unchanged.
246
- if (typeof existing.wouldReloadFor === 'function' && existing.wouldReloadFor(spawnContext)) {
247
- this._logEvent('cli-config-reload', {
248
- sessionKey,
249
- from_model: existing.model,
250
- from_effort: existing.effort,
251
- });
252
- await this.kill(sessionKey, 'config-reload');
253
- // fall through to the cold-spawn path below — respawns with --resume
254
- } else {
255
- return existing;
256
- }
257
- }
258
-
259
- // Provisional new-process cost — ask the factory but don't start yet.
260
- const newProc = this.processFactory(sessionKey, spawnContext);
261
- const newCost = newProc.cost;
262
-
263
- while (this.totalCost + newCost > this.budget) {
264
- const evicted = this._evictLRU(); // skips inFlight + background-job-pinned
265
- if (evicted) continue;
266
- // _evictLRU freed nothing. Policy C — split by WHY:
267
- if (this._hasPinnedSession()) {
268
- // A DURABLE blocker (live background job) holds a slot. Don't park on it (could be
269
- // ~an hour) and don't kill it. The budget caps RSS, not correctness — so treat it as
270
- // SOFT: spawn over budget + warn; the operator reclaims by /reset-ing a chat.
271
- const pinned = this._pinnedSessionKeys();
272
- this._logEvent('lru-overflow-pinned', {
273
- active: this.procs.size,
274
- totalCost: this.totalCost,
275
- budget: this.budget,
276
- newCost,
277
- pinned,
278
- });
279
- this.logger.warn?.(
280
- `[pm] budget ${this.budget} exceeded (~${this.totalCost + newCost}): all free slots hold ` +
281
- `live background jobs [${pinned.join(', ')}]. Spawning over limit — /reset one of those ` +
282
- `chats to reclaim memory.`,
283
- );
284
- break; // soft overflow — spawn anyway
285
- }
286
- // No pin — the blockers are all in-flight TURNS (transient, finish in seconds). Keep the
287
- // existing behavior: park briefly for a slot rather than needlessly overflow.
288
- await this._awaitLruSlot();
289
- if (this._shuttingDown) {
290
- try { await newProc.kill('shutdown'); } catch {}
291
- throw new Error('shutdown');
292
- }
293
- // Loop again — budget may have freed up.
294
- }
295
-
296
- this._wireCallbacks(newProc);
297
- this.procs.set(sessionKey, newProc);
298
- newProc.lastUsedTs = Date.now();
299
- // Publish the in-flight start() Promise so concurrent getOrSpawn
300
- // callers (above) can await it instead of racing the spawn.
301
- const startP = newProc.start(spawnContext);
302
- this._starting.set(sessionKey, startP);
303
- try {
304
- await startP;
305
- } catch (err) {
306
- this.procs.delete(sessionKey);
307
- throw err;
308
- } finally {
309
- this._starting.delete(sessionKey);
310
- }
311
- return newProc;
312
- }
313
-
314
- _evictLRU() {
315
- let oldest = null;
316
- let oldestKey = null;
317
- let pinnedSkipped = 0;
318
- for (const [k, p] of this.procs.entries()) {
319
- if (p.inFlight) continue;
320
- // PIN: a session with a live detached background job is NOT evictable — killing it
321
- // would silently drop the job (and its report-back wakeup). Skip like inFlight.
322
- if (p.hasActiveBackgroundWork()) { pinnedSkipped++; continue; }
323
- // PIN (0.13 D1, S9): a session blocked on an open interactive question is
324
- // NOT evictable — the keyboard is live and claude is blocked on the ask;
325
- // killing it silently strands both. Skip like inFlight.
326
- if (typeof p.hasOpenQuestions === 'function' && p.hasOpenQuestions()) { pinnedSkipped++; continue; }
327
- if (!oldest || (p.lastUsedTs || 0) < (oldest.lastUsedTs || 0)) {
328
- oldest = p;
329
- oldestKey = k;
330
- }
331
- }
332
- if (!oldest) {
333
- this._logEvent('lru-full', {
334
- active: this.procs.size,
335
- totalCost: this.totalCost,
336
- budget: this.budget,
337
- pinnedSkipped,
338
- });
339
- return false;
340
- }
341
- this._logEvent('evict', {
342
- session_key: oldestKey,
343
- cost: oldest.cost,
344
- backend: oldest.backend,
345
- pinnedSkipped,
346
- });
347
- oldest.kill('evict').catch(() => {});
348
- this.procs.delete(oldestKey);
349
- return true;
350
- }
351
-
352
- /**
353
- * A DURABLE eviction blocker: a non-inFlight session holding a slot because it has a live
354
- * background job (vs an inFlight TURN, which is transient and frees in seconds). Used to
355
- * split park-vs-overflow when _evictLRU can free nothing.
356
- */
357
- _hasPinnedSession() {
358
- for (const p of this.procs.values()) {
359
- if (!p.inFlight && p.hasActiveBackgroundWork()) return true;
360
- // 0.16 (MF-B): an extended busy-aware-ceiling turn is a DURABLE blocker —
361
- // it can hold its slot up to the hard backstop (90min), not "seconds" like
362
- // a normal in-flight turn. Treat it as a pin so getOrSpawn SOFT-overflows
363
- // (spawn over budget + warn) instead of park-then-reject, which would deny
364
- // service to other chats for the full 5-min LRU wait.
365
- if (p.inFlight && typeof p.hasExtendedTurn === 'function' && p.hasExtendedTurn()) return true;
366
- }
367
- return false;
368
- }
369
-
370
- _pinnedSessionKeys() {
371
- const keys = [];
372
- for (const [k, p] of this.procs.entries()) {
373
- if (!p.inFlight && p.hasActiveBackgroundWork()) keys.push(k);
374
- else if (p.inFlight && typeof p.hasExtendedTurn === 'function' && p.hasExtendedTurn()) keys.push(k);
375
- }
376
- return keys;
377
- }
378
-
379
- async _awaitLruSlot() {
380
- return new Promise((resolve, reject) => {
381
- const timer = setTimeout(() => {
382
- const idx = this._lruWaiters.findIndex((w) => w.resolve === resolve);
383
- if (idx !== -1) this._lruWaiters.splice(idx, 1);
384
- this._logEvent('lru-wait-timeout', { wait_ms: this.lruWaitMs });
385
- reject(new Error(`lru wait timed out after ${this.lruWaitMs}ms`));
386
- }, this.lruWaitMs);
387
- this._lruWaiters.push({ resolve, reject, timer });
388
- this._logEvent('lru-wait', {
389
- active: this.procs.size,
390
- totalCost: this.totalCost,
391
- budget: this.budget,
392
- });
393
- });
394
- }
395
-
396
- _maybeSignalLruWaiter() {
397
- const w = this._lruWaiters.shift();
398
- if (w) { clearTimeout(w.timer); w.resolve(); }
399
- }
400
-
401
- // ─── Per-session dispatch ────────────────────────────────────────
402
-
403
- async send(sessionKey, prompt, opts) {
404
- const proc = this.procs.get(sessionKey);
405
- if (!proc) throw new Error(`no process for sessionKey ${sessionKey}`);
406
- proc.lastUsedTs = Date.now();
407
- return proc.send(prompt, opts);
408
- }
409
-
410
- async kill(sessionKey, reason = 'kill') {
411
- const proc = this.procs.get(sessionKey);
412
- if (!proc) return false;
413
- this.procs.delete(sessionKey);
414
- try { await proc.kill(reason); } catch {}
415
- this._maybeSignalLruWaiter();
416
- return true;
417
- }
418
-
419
- async killChat(chatId) {
420
- const targets = [];
421
- const idStr = String(chatId);
422
- for (const [sk, p] of this.procs.entries()) {
423
- if (p.chatId === idStr) targets.push([sk, p]);
424
- }
425
- for (const [sk] of targets) this.procs.delete(sk);
426
- const results = await Promise.allSettled(
427
- targets.map(([_, p]) => p.kill('killChat')),
428
- );
429
- for (let i = 0; i < targets.length; i++) {
430
- this._maybeSignalLruWaiter();
431
- }
432
- return results;
433
- }
434
-
435
- async shutdown() {
436
- this._shuttingDown = true;
437
- // Reject parked lru waiters.
438
- for (const w of this._lruWaiters) {
439
- clearTimeout(w.timer);
440
- w.reject(new Error('shutdown'));
441
- }
442
- this._lruWaiters.length = 0;
443
-
444
- const all = [...this.procs.values()];
445
- this.procs.clear();
446
- await Promise.allSettled(all.map((p) => p.kill('shutdown')));
447
- }
448
-
449
- // ─── Optional async — feature-detect at call site if needed ──────
450
-
451
- /**
452
- * Shared dispatch for the five optional async methods. Returns the
453
- * Process method's value on success, `unsupportedDefault` when the
454
- * Process is missing/closed OR throws UNSUPPORTED_OPERATION /
455
- * NOT_IMPLEMENTED_YET. Other errors propagate.
456
- */
457
- async _invokeOptional(sessionKey, methodName, args, unsupportedDefault) {
458
- const p = this.procs.get(sessionKey);
459
- if (!p || p.closed) return unsupportedDefault;
460
- try { return await p[methodName](...args); }
461
- catch (err) {
462
- if (err && (err.code === 'UNSUPPORTED_OPERATION' || err.code === 'NOT_IMPLEMENTED_YET')) {
463
- return unsupportedDefault;
464
- }
465
- throw err;
466
- }
467
- }
468
-
469
- async interrupt(sessionKey) {
470
- return this._invokeOptional(sessionKey, 'interrupt', [], false);
471
- }
472
-
473
- async setModel(sessionKey, model) {
474
- return this._invokeOptional(sessionKey, 'setModel', [model], false);
475
- }
476
-
477
- /**
478
- * Review F#10: return the backend name for a live process so callers
479
- * (slash-commands) can word their UX accurately. Returns null if no
480
- * live process exists.
481
- */
482
- getBackend(sessionKey) {
483
- const p = this.procs.get(sessionKey);
484
- return (p && !p.closed) ? p.backend : null;
485
- }
486
-
487
- async applyFlagSettings(sessionKey, settings) {
488
- return this._invokeOptional(sessionKey, 'applyFlagSettings', [settings], false);
489
- }
490
-
491
- async setPermissionMode(sessionKey, mode) {
492
- return this._invokeOptional(sessionKey, 'setPermissionMode', [mode], false);
493
- }
494
-
495
- async resetSession(sessionKey, opts) {
496
- const p = this.procs.get(sessionKey);
497
- // No active process for this key — return no-op. Matches the
498
- // pre-0.10.0 SDK pm semantic (`closed: false` = "we did not close
499
- // anything"). Caller can distinguish "session was already gone"
500
- // from "we just closed an active session."
501
- if (!p) return { closed: false, drainedPendings: 0 };
502
- try {
503
- const result = await p.resetSession(opts);
504
- // The Process's resetSession closes itself; remove from Map
505
- // and signal LRU.
506
- if (this.procs.get(sessionKey) === p) {
507
- this.procs.delete(sessionKey);
508
- }
509
- this._maybeSignalLruWaiter();
510
- return result;
511
- } catch (err) {
512
- if (err.code === 'UNSUPPORTED_OPERATION' || err.code === 'NOT_IMPLEMENTED_YET') {
513
- const drained = p.drainQueue('RESET_SESSION');
514
- await this.kill(sessionKey, 'reset');
515
- return { closed: true, drainedPendings: drained };
516
- }
517
- throw err;
518
- }
519
- }
520
-
521
- async getContextUsage(sessionKey) {
522
- return this._invokeOptional(sessionKey, 'getContextUsage', [], null);
523
- }
524
-
525
- // ─── Optional sync hot-path — never throws (R1-F1) ───────────────
526
-
527
- drainQueue(sessionKey, code = 'INTERRUPTED') {
528
- const p = this.procs.get(sessionKey);
529
- if (!p) return 0;
530
- return p.drainQueue(code);
531
- }
532
-
533
- injectUserMessage(sessionKey, opts) {
534
- const p = this.procs.get(sessionKey);
535
- if (!p || p.closed) return false;
536
- return p.injectUserMessage(opts);
537
- }
538
-
539
- // 0.12 interactive questions: hand an answer back to a blocking `ask` tool call.
540
- // Returns false if the session is gone (claude is dead → nothing to answer).
541
- answerQuestion(sessionKey, toolCallId, result) {
542
- const p = this.procs.get(sessionKey);
543
- if (!p || p.closed || typeof p.writeQuestionAnswer !== 'function') return false;
544
- return p.writeQuestionAnswer(toolCallId, result);
545
- }
546
-
547
- steer(sessionKey, text, opts) {
548
- const p = this.procs.get(sessionKey);
549
- if (!p || p.closed) return false;
550
- return p.steer(text, opts);
551
- }
552
-
553
- // ─── Internal helpers ────────────────────────────────────────────
554
-
555
- /**
556
- * For each callback in this.callbacks, register a listener on the
557
- * Process that forwards the event payload to the callback. Wire
558
- * the standard event names; Process subclasses are free to emit
559
- * additional events that pm doesn't forward.
560
- *
561
- * Also subscribes to 'idle' (Process became inFlight=false) and
562
- * 'close' (Process closed itself) so the pm can signal parked
563
- * LRU waiters + remove from the Map.
564
- */
565
- _wireCallbacks(proc) {
566
- for (const [cbName, eventName] of Object.entries(CALLBACK_TO_EVENT)) {
567
- const fn = this.callbacks[cbName];
568
- if (typeof fn !== 'function') continue;
569
- proc.on(eventName, (...args) => {
570
- try { fn(proc.sessionKey, ...args, proc); }
571
- catch (err) {
572
- this.logger.error?.(`[pm:${proc.label}] callback ${cbName} threw: ${err.message}`);
573
- }
574
- });
575
- }
576
- // Generic 'error' channel — log + forward via onError if provided.
577
- proc.on('error', (err) => {
578
- this.logger.error?.(`[pm:${proc.label}] process error: ${err.message}`);
579
- if (typeof this.callbacks.onError === 'function') {
580
- try { this.callbacks.onError(proc.sessionKey, err, proc); }
581
- catch (e) { this.logger.error?.(`[pm:${proc.label}] onError threw: ${e.message}`); }
582
- }
583
- });
584
- // 'idle': a turn completed and pendingQueue is empty. Signal any
585
- // parked LRU waiter that a non-in-flight slot is available.
586
- proc.on('idle', () => this._maybeSignalLruWaiter());
587
- // 'close': process closed itself (iteration loop exited or
588
- // _closeQuery returned). Remove from the Map + signal LRU.
589
- proc.on('close', () => {
590
- if (this.procs.get(proc.sessionKey) === proc) {
591
- this.procs.delete(proc.sessionKey);
592
- }
593
- this._maybeSignalLruWaiter();
594
- });
595
- // P0 #3: channels backend emits 'bridge-disconnected' when its socket to
596
- // the spawned bridge dies (claude crash, bridge crash, EOF). The disconnect
597
- // handler in CliProcess already drained pendingTurns; here we kill
598
- // the dead Process so it leaves the Map and frees its LRU slot. Next
599
- // user-msg on the same sessionKey triggers a fresh getOrSpawn — which
600
- // calls Process.start with the persisted claudeSessionId, recovering the
601
- // conversation via `claude --resume`.
602
- //
603
- // We don't re-spawn proactively: an idle disconnected session shouldn't
604
- // burn LRU budget. Lazy respawn on next message is the right shape.
605
- proc.on('bridge-disconnected', () => {
606
- this.logger.warn?.(`[pm:${proc.label}] channels bridge disconnected — killing dead instance for lazy respawn`);
607
- // Kill is idempotent and removes the proc from the Map via the 'close'
608
- // listener wired just above.
609
- proc.kill('bridge-disconnected').catch(err => {
610
- this.logger.warn?.(`[pm:${proc.label}] kill on bridge-disconnect failed: ${err.message}`);
611
- });
612
- });
613
- }
614
-
615
- _logEvent(kind, detail) {
616
- try {
617
- this.db?.logEvent?.(kind, detail || {});
618
- } catch (err) {
619
- this.logger.error?.(`[pm] logEvent ${kind} failed: ${err.message}`);
620
- }
621
- }
622
- }
623
-
624
- module.exports = {
625
- ProcessManager,
626
- DEFAULT_BUDGET,
627
- CALLBACK_TO_EVENT,
628
- };