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