@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,891 @@
1
+ // provenance: polygram lib/process/sdk-process.js — the SDK-Query backend; @anthropic-ai/claude-agent-sdk is an OPTIONAL dep, lazy-required only when a pm:sdk session actually starts
2
+ /**
3
+ * SdkProcess — one @anthropic-ai/claude-agent-sdk Query, wrapped as a
4
+ * Process for the generic ProcessManager.
5
+ *
6
+ * Direct extraction of the per-entry guts from the pre-0.10.0
7
+ * `lib/sdk/process-manager.js` ProcessManagerSdk class. What was
8
+ * `entry.X` is now `this.X`; what was `pm.onInit(sessionKey, ...)`
9
+ * is now `this.emit('init', ...)`.
10
+ *
11
+ * Architecture invariants (unchanged from the previous pm impl):
12
+ * D1 stream subscription: SDKAssistantMessage cumulative
13
+ * D2 long-lived Query per chat
14
+ * D3 /effort via applyFlagSettings (no respawn)
15
+ * D5 Options.env SHADOW — buildSdkOptions enumerates everything
16
+ * D6 Query.close() is fast — close timeout safe
17
+ * D7 killChat Promise.allSettled with timeout per Query
18
+ * D8 drainQueue(code) owns drain logic
19
+ * D11 stdinLock dropped — SDK preserves FIFO at Query level
20
+ *
21
+ * Phase 0 spike + audit findings preserved:
22
+ * R1-F1 hot-path: drainQueue / injectUserMessage / steer NEVER throw
23
+ * R1-F2 query.close() is synchronous void; await iteratePromise
24
+ * F-spike-1 — TmuxProcess uses --permission-mode acceptEdits (separate
25
+ * file, Phase 2); SdkProcess mirrors via Options.permissionMode
26
+ *
27
+ * cost = 1 (default SDK weight; tmux backend will override to 3).
28
+ */
29
+
30
+ 'use strict';
31
+
32
+ // Lazy: the Anthropic SDK is an OPTIONAL dependency. Requiring this module must NOT
33
+ // load it — only constructing an actual pm:sdk query does. cli-only consumers (water)
34
+ // never take this path, so they never need the dep installed.
35
+ let _sdkQuery = null;
36
+ function sdkQuery(...args) {
37
+ if (!_sdkQuery) {
38
+ try { _sdkQuery = require('@anthropic-ai/claude-agent-sdk').query; }
39
+ catch (e) { throw new Error('orchestra: pm:sdk needs the optional @anthropic-ai/claude-agent-sdk dependency installed (' + e.message + ')'); }
40
+ }
41
+ return _sdkQuery(...args);
42
+ }
43
+ const { Process, UnsupportedOperationError } = require('./process');
44
+ const { isTransientHttpError } = require('../error/classify');
45
+
46
+ // ─── Constants ────────────────────────────────────────────────────
47
+
48
+ const DEFAULT_QUEUE_CAP = 50;
49
+ const DEFAULT_QUERY_CLOSE_TIMEOUT_MS = 5000;
50
+ const DEFAULT_TRANSIENT_RETRY_DELAY_MS = 2500;
51
+ const MAX_TRANSIENT_RETRIES = 1;
52
+ const DEFAULT_IDLE_MS = 600_000;
53
+ const DEFAULT_MAX_TURN_MS = 30 * 60_000;
54
+ const VISIBILITY_HEARTBEAT_MS = 30 * 1000;
55
+
56
+ // Parity with TmuxProcess (R2-F1 / G5b): strip C0 control chars + DEL
57
+ // before sending to the SDK. Allows \t (0x09) and \n (0x0a) through.
58
+ // Same regex as `lib/tmux/tmux-runner.js` CONTROL_CHAR_RE — keep in sync.
59
+ const CONTROL_CHAR_RE = /[\x00-\x08\x0b-\x1f\x7f]/g;
60
+ function sanitizeControlChars(text) {
61
+ return typeof text === 'string' ? text.replace(CONTROL_CHAR_RE, '') : text;
62
+ }
63
+
64
+ // ─── Helpers ──────────────────────────────────────────────────────
65
+
66
+ /**
67
+ * Pull cumulative user-visible text from an SDKAssistantMessage.
68
+ * Same shape as today's stream-json assistant events (per D1):
69
+ * `event.message.content[]` with text blocks. Colon-suffix
70
+ * normalisation matches the CLI pm — "Listing deps:" → "Listing deps…"
71
+ * so a trailing assistant message doesn't read as half-formed.
72
+ */
73
+ function extractAssistantText(event) {
74
+ const blocks = event?.message?.content;
75
+ if (!Array.isArray(blocks)) return '';
76
+ const parts = [];
77
+ for (const b of blocks) {
78
+ if (!b) continue;
79
+ if (b.type === 'text' && typeof b.text === 'string') {
80
+ parts.push(b.text);
81
+ }
82
+ }
83
+ return parts.join('\n\n').trim().replace(/([^:]):\s*$/, '$1…');
84
+ }
85
+
86
+ /**
87
+ * Sum usage across distinct assistant message ids.
88
+ */
89
+ function sumUsage(usageByMessage) {
90
+ const out = {
91
+ input_tokens: 0,
92
+ output_tokens: 0,
93
+ cache_creation_input_tokens: 0,
94
+ cache_read_input_tokens: 0,
95
+ };
96
+ for (const u of usageByMessage.values()) {
97
+ if (!u) continue;
98
+ if (Number.isFinite(u.input_tokens)) out.input_tokens += u.input_tokens;
99
+ if (Number.isFinite(u.output_tokens)) out.output_tokens += u.output_tokens;
100
+ if (Number.isFinite(u.cache_creation_input_tokens)) {
101
+ out.cache_creation_input_tokens += u.cache_creation_input_tokens;
102
+ }
103
+ if (Number.isFinite(u.cache_read_input_tokens)) {
104
+ out.cache_read_input_tokens += u.cache_read_input_tokens;
105
+ }
106
+ }
107
+ return out;
108
+ }
109
+
110
+ /**
111
+ * Create the writable-end-of-AsyncIterable that send() / steer() /
112
+ * injectUserMessage() push user messages onto. SDK's `query({ prompt:
113
+ * <this> })` consumes from the read end via `for await`.
114
+ *
115
+ * Bounded by queueCap (D5). Push beyond cap drops OLDEST queued
116
+ * (non-yielded) message; caller's onDrop handler rejects the
117
+ * corresponding pending.
118
+ */
119
+ function makeInputController({ queueCap = DEFAULT_QUEUE_CAP } = {}) {
120
+ const queue = [];
121
+ const waiters = [];
122
+ let closed = false;
123
+ let dropCallback = null;
124
+
125
+ const iter = {
126
+ [Symbol.asyncIterator]() { return iter; },
127
+ next() {
128
+ if (queue.length) {
129
+ return Promise.resolve({ value: queue.shift(), done: false });
130
+ }
131
+ if (closed) {
132
+ return Promise.resolve({ value: undefined, done: true });
133
+ }
134
+ return new Promise((resolve) => waiters.push(resolve));
135
+ },
136
+ async return() {
137
+ closed = true;
138
+ while (waiters.length) waiters.shift()({ value: undefined, done: true });
139
+ return { value: undefined, done: true };
140
+ },
141
+ };
142
+
143
+ function push(msg) {
144
+ if (closed) {
145
+ throw Object.assign(new Error('input controller closed'),
146
+ { code: 'INPUT_CLOSED' });
147
+ }
148
+ if (waiters.length) {
149
+ waiters.shift()({ value: msg, done: false });
150
+ return;
151
+ }
152
+ queue.push(msg);
153
+ while (queue.length > queueCap) {
154
+ const dropped = queue.shift();
155
+ if (dropCallback) {
156
+ try { dropCallback(dropped); } catch { /* swallow */ }
157
+ }
158
+ }
159
+ }
160
+
161
+ function close() {
162
+ if (closed) return;
163
+ closed = true;
164
+ while (waiters.length) waiters.shift()({ value: undefined, done: true });
165
+ }
166
+
167
+ function onDrop(cb) { dropCallback = cb; }
168
+
169
+ return { iter, push, close, onDrop, get size() { return queue.length; } };
170
+ }
171
+
172
+ // ─── SdkProcess ────────────────────────────────────────────────────
173
+
174
+ class SdkProcess extends Process {
175
+ /**
176
+ * @param {object} opts
177
+ * @param {string} opts.sessionKey
178
+ * @param {string|null} opts.chatId
179
+ * @param {string|null} opts.threadId
180
+ * @param {string} opts.label
181
+ * @param {Function} opts.spawnFn — (sessionKey, ctx) → SdkOptions OR { query, inputController } for test paths
182
+ * @param {object} [opts.db] — used for _logEvent + clearSessionId on resetSession
183
+ * @param {object} [opts.logger=console]
184
+ * @param {number} [opts.queueCap]
185
+ * @param {number} [opts.queryCloseTimeoutMs]
186
+ */
187
+ constructor({
188
+ sessionKey, chatId, threadId, label,
189
+ spawnFn,
190
+ db = null,
191
+ logger = console,
192
+ queueCap = DEFAULT_QUEUE_CAP,
193
+ queryCloseTimeoutMs = DEFAULT_QUERY_CLOSE_TIMEOUT_MS,
194
+ } = {}) {
195
+ super({ sessionKey, chatId, threadId, label });
196
+ if (typeof spawnFn !== 'function') throw new TypeError('SdkProcess: spawnFn required');
197
+ this.backend = 'sdk';
198
+ this.spawnFn = spawnFn;
199
+ this.db = db;
200
+ this.logger = logger;
201
+ this.queueCap = queueCap;
202
+ this.queryCloseTimeoutMs = queryCloseTimeoutMs;
203
+
204
+ // Underlying Query state
205
+ this.query = null;
206
+ this.inputController = null;
207
+ this.iteratePromise = null;
208
+ this.lastUsedTs = Date.now();
209
+
210
+ // pendingQueue is inherited as [] from the abstract Process base.
211
+ // claudeSessionId is inherited as null.
212
+ }
213
+
214
+ get cost() { return 1; }
215
+
216
+ // ─── Lifecycle ──────────────────────────────────────────────────
217
+
218
+ async start(ctx) {
219
+ const spawnResult = this.spawnFn(this.sessionKey, ctx);
220
+ // spawnFn may return either SdkOptions (production) or
221
+ // { query, inputController } (test fakeQuery shortcut), or a
222
+ // ready Query instance directly.
223
+ if (spawnResult && typeof spawnResult.next === 'function') {
224
+ // Already a Query instance (test path).
225
+ this.query = spawnResult;
226
+ this.inputController = makeInputController({ queueCap: this.queueCap });
227
+ this.query.streamInput?.(this.inputController.iter).catch(() => {});
228
+ } else if (spawnResult && spawnResult.query && spawnResult.inputController) {
229
+ this.query = spawnResult.query;
230
+ this.inputController = spawnResult.inputController;
231
+ } else {
232
+ this.inputController = makeInputController({ queueCap: this.queueCap });
233
+ this.query = sdkQuery({
234
+ prompt: this.inputController.iter,
235
+ options: spawnResult || {},
236
+ });
237
+ }
238
+
239
+ this.inputController.onDrop((dropped) => this._handleQueueDrop(dropped));
240
+
241
+ // Run iteration in the background. When the SDK loop exits, we
242
+ // mark closed, drain remaining pendings with err, fire 'close',
243
+ // and `emit('idle')` so the pm can signal any parked LRU waiter.
244
+ this.iteratePromise = this._runIteration().catch((err) => {
245
+ this.logger.error?.(`[${this.label}] iteration crashed: ${err?.message || err}`);
246
+ this._failAllPendings(err);
247
+ });
248
+ }
249
+
250
+ async _runIteration() {
251
+ try {
252
+ for await (const msg of this.query) {
253
+ await this._handleEvent(msg);
254
+ if (this.closed) break;
255
+ }
256
+ } catch (err) {
257
+ this._failAllPendings(err);
258
+ this.emit('close', err.code === 'AbortError' ? 0 : 1);
259
+ } finally {
260
+ this.closed = true;
261
+ this.inFlight = false;
262
+ this.emit('idle');
263
+ }
264
+ }
265
+
266
+ // ─── Event handler — the heart of the per-Process state machine ──
267
+
268
+ async _handleEvent(msg) {
269
+ const head = this.pendingQueue[0];
270
+
271
+ if (head && this._isActivityEvent(msg)) {
272
+ head.resetIdleTimer?.();
273
+ }
274
+
275
+ if (msg.type === 'system' && msg.subtype === 'init') {
276
+ this.claudeSessionId = msg.session_id || null;
277
+ this.emit('init', msg);
278
+ return;
279
+ }
280
+
281
+ // rc.29: stream_event with content_block_start of type='thinking'.
282
+ if (msg.type === 'stream_event' && head && !head.thinkingFired) {
283
+ const ev = msg.event;
284
+ const isThinkingStart = ev?.type === 'content_block_start'
285
+ && ev?.content_block?.type === 'thinking';
286
+ if (isThinkingStart) {
287
+ head.thinkingFired = true;
288
+ this.emit('thinking');
289
+ }
290
+ return;
291
+ }
292
+
293
+ if (msg.type === 'system' && msg.subtype === 'compact_boundary') {
294
+ // Sequence: await listeners before processing next event so a
295
+ // fresh assistant message after boundary routes to new bubble.
296
+ const listeners = this.listeners('compact-boundary');
297
+ for (const fn of listeners) {
298
+ try { await fn(msg); }
299
+ catch (err) { this.logger.error?.(`[${this.label}] compact-boundary listener: ${err.message}`); }
300
+ }
301
+ this._logEvent('compact-boundary', {
302
+ session_key: this.sessionKey,
303
+ trigger: msg.compact_metadata?.trigger ?? null,
304
+ pre_tokens: msg.compact_metadata?.pre_tokens ?? null,
305
+ post_tokens: msg.compact_metadata?.post_tokens ?? null,
306
+ });
307
+ return;
308
+ }
309
+
310
+ if (msg.type === 'assistant' && !head) {
311
+ // rc.47: autonomous assistant message — no pm.send in flight.
312
+ if (msg.parent_tool_use_id != null) return;
313
+ const text = extractAssistantText(msg);
314
+ if (!text) return;
315
+ this.emit('autonomous-assistant-message', msg);
316
+ return;
317
+ }
318
+
319
+ if (msg.type === 'assistant' && head) {
320
+ // Subagent filter: top-level only.
321
+ if (msg.parent_tool_use_id != null) return;
322
+
323
+ const messageId = msg.message?.id;
324
+ const added = extractAssistantText(msg);
325
+ const hasToolUse = Array.isArray(msg.message?.content)
326
+ && msg.message.content.some((b) => b?.type === 'tool_use');
327
+
328
+ if (added || hasToolUse) {
329
+ head.fireFirstStream?.();
330
+ head.firstAssistantSeen = true;
331
+ }
332
+
333
+ if (messageId != null && msg.message?.usage) {
334
+ head.usageByMessage.set(messageId, msg.message.usage);
335
+ }
336
+
337
+ if (hasToolUse) {
338
+ for (const b of msg.message.content) {
339
+ if (b?.type === 'tool_use') {
340
+ head.toolUseCount++;
341
+ if (b.name) this.emit('tool-use', b.name);
342
+ }
343
+ }
344
+ }
345
+
346
+ // rc.45: multi-segment same-bubble streaming.
347
+ if (added) {
348
+ const isNewMessage = head.lastAssistantMessageId != null
349
+ && messageId != null
350
+ && head.lastAssistantMessageId !== messageId
351
+ && head.streamText
352
+ && head.streamText.length > 0;
353
+ if (isNewMessage) {
354
+ if (head.pendingSteerCausesNewBubble) {
355
+ // Steered: fire assistant-message-start so streamer
356
+ // forceNewMessage's.
357
+ const listeners = this.listeners('assistant-message-start');
358
+ for (const fn of listeners) {
359
+ try { await fn(); }
360
+ catch (err) { this.logger.error?.(`[${this.label}] assistant-message-start: ${err.message}`); }
361
+ }
362
+ head.priorMessagesText = '';
363
+ head.pendingSteerCausesNewBubble = false;
364
+ } else {
365
+ head.priorMessagesText = head.streamText;
366
+ }
367
+ }
368
+ if (messageId != null) head.lastAssistantMessageId = messageId;
369
+ head.streamText = head.priorMessagesText
370
+ ? head.priorMessagesText + '\n\n' + added
371
+ : added;
372
+ this.emit('stream-chunk', head.streamText);
373
+ }
374
+ return;
375
+ }
376
+
377
+ if (msg.type === 'result' && head) {
378
+ // Transient retry: retry once if turn hit 5xx/429 BEFORE any
379
+ // assistant content arrived.
380
+ const errSignal = msg.error || msg.subtype;
381
+ const isError = msg.subtype !== 'success';
382
+ const shouldRetry = isError
383
+ && !head.firstAssistantSeen
384
+ && head.transientRetries < MAX_TRANSIENT_RETRIES
385
+ && head.prompt != null
386
+ && isTransientHttpError({ message: errSignal, subtype: msg.subtype });
387
+ if (shouldRetry) {
388
+ head.transientRetries++;
389
+ this._logEvent('transient-retry', {
390
+ session_key: this.sessionKey,
391
+ chat_id: this.chatId,
392
+ attempt: head.transientRetries,
393
+ subtype: msg.subtype,
394
+ error: typeof errSignal === 'string' ? errSignal.slice(0, 200) : null,
395
+ });
396
+ head.usageByMessage = new Map();
397
+ head.toolUseCount = 0;
398
+ head.streamText = '';
399
+ head.lastAssistantMessageId = null;
400
+ head.resetIdleTimer?.();
401
+ setTimeout(() => {
402
+ if (this.pendingQueue[0] !== head || this.closed) return;
403
+ try {
404
+ this.inputController.push({
405
+ type: 'user',
406
+ message: { role: 'user', content: head.prompt },
407
+ parent_tool_use_id: null,
408
+ });
409
+ } catch (err) {
410
+ this.pendingQueue.shift();
411
+ head.clearTimers();
412
+ head.reject(err);
413
+ }
414
+ }, DEFAULT_TRANSIENT_RETRY_DELAY_MS);
415
+ return;
416
+ }
417
+
418
+ // Normal resolution.
419
+ this.pendingQueue.shift();
420
+ head.clearTimers();
421
+ this.emit('result', msg, head);
422
+ const usageTotals = sumUsage(head.usageByMessage);
423
+ head.resolve({
424
+ text: msg.result || '',
425
+ sessionId: msg.session_id,
426
+ cost: msg.total_cost_usd,
427
+ duration: msg.duration_ms,
428
+ error: msg.subtype === 'success' ? null : (msg.error || msg.subtype),
429
+ metrics: {
430
+ inputTokens: usageTotals.input_tokens,
431
+ outputTokens: usageTotals.output_tokens,
432
+ cacheCreationTokens: usageTotals.cache_creation_input_tokens,
433
+ cacheReadTokens: usageTotals.cache_read_input_tokens,
434
+ numAssistantMessages: head.usageByMessage.size,
435
+ numToolUses: head.toolUseCount,
436
+ resultSubtype: msg.subtype || null,
437
+ },
438
+ });
439
+
440
+ if (this.pendingQueue.length > 0) {
441
+ this.pendingQueue[0].activate();
442
+ } else {
443
+ this.inFlight = false;
444
+ this.emit('idle');
445
+ }
446
+ return;
447
+ }
448
+ }
449
+
450
+ _isActivityEvent(msg) {
451
+ if (!msg?.type) return false;
452
+ if (msg.type === 'assistant') return true;
453
+ if (msg.type === 'partial_assistant') return true;
454
+ if (msg.type === 'stream_event') return true;
455
+ if (msg.type === 'tool_progress') return true;
456
+ if (msg.type === 'user') return true;
457
+ return false;
458
+ }
459
+
460
+ // ─── send ──────────────────────────────────────────────────────
461
+
462
+ send(prompt, {
463
+ timeoutMs = DEFAULT_IDLE_MS,
464
+ maxTurnMs = DEFAULT_MAX_TURN_MS,
465
+ context = {},
466
+ } = {}) {
467
+ // Parity with TmuxProcess: strip C0/DEL control chars before any
468
+ // queue work. Same regex (G5b). Emit 'prompt-sanitized' when we
469
+ // actually changed something so observability matches tmux.
470
+ const safePrompt = sanitizeControlChars(prompt);
471
+ if (typeof prompt === 'string' && safePrompt.length !== prompt.length) {
472
+ const stripped = prompt.length - safePrompt.length;
473
+ this.logger.warn?.(
474
+ `[${this.label}] stripped ${stripped} control chars from prompt`,
475
+ );
476
+ this.emit('prompt-sanitized', { stripped, source: 'send' });
477
+ }
478
+ prompt = safePrompt;
479
+
480
+ return new Promise((resolve, reject) => {
481
+ if (this.closed) return reject(new Error('No process for session'));
482
+
483
+ this.lastUsedTs = Date.now();
484
+
485
+ let idleTimer = null;
486
+ let maxTimer = null;
487
+ let visibilityTimer = null;
488
+ let activated = false;
489
+
490
+ const armVisibilityTimer = () => {
491
+ if (visibilityTimer) clearInterval(visibilityTimer);
492
+ visibilityTimer = setInterval(() => {
493
+ if (!this.pendingQueue.includes(pending)) {
494
+ if (visibilityTimer) { clearInterval(visibilityTimer); visibilityTimer = null; }
495
+ return;
496
+ }
497
+ const r = pending.context?.reactor;
498
+ if (r && typeof r.heartbeat === 'function') {
499
+ try { r.heartbeat(); } catch { /* defensive */ }
500
+ }
501
+ }, VISIBILITY_HEARTBEAT_MS);
502
+ visibilityTimer.unref?.();
503
+ };
504
+
505
+ const clearTimers = () => {
506
+ if (idleTimer) { clearTimeout(idleTimer); idleTimer = null; }
507
+ if (maxTimer) { clearTimeout(maxTimer); maxTimer = null; }
508
+ if (visibilityTimer) { clearInterval(visibilityTimer); visibilityTimer = null; }
509
+ };
510
+
511
+ const pending = {
512
+ resolve: (r) => { clearTimers(); resolve(r); },
513
+ reject: (e) => { clearTimers(); reject(e); },
514
+ clearTimers,
515
+ startedAt: null,
516
+ streamText: '',
517
+ context,
518
+ idleTimer: null,
519
+ maxTimer: null,
520
+ activated: false,
521
+ usageByMessage: new Map(),
522
+ lastUsageMessageId: null,
523
+ toolUseCount: 0,
524
+ firstStreamFired: false,
525
+ prompt,
526
+ transientRetries: 0,
527
+ firstAssistantSeen: false,
528
+ thinkingFired: false,
529
+ priorMessagesText: '',
530
+ pendingSteerCausesNewBubble: false,
531
+ lastAssistantMessageId: null,
532
+ };
533
+
534
+ pending.fireFirstStream = () => {
535
+ if (pending.firstStreamFired) return;
536
+ pending.firstStreamFired = true;
537
+ try { context?.onFirstStream?.(); }
538
+ catch (err) { this.logger.error?.(`[${this.label}] onFirstStream: ${err.message}`); }
539
+ };
540
+
541
+ const fireTimeout = (reason) => {
542
+ if (this.pendingQueue[0] !== pending) return;
543
+ this._logEvent('turn-timeout', {
544
+ session_key: this.sessionKey,
545
+ chat_id: this.chatId,
546
+ reason,
547
+ });
548
+ this.pendingQueue.shift();
549
+ this.query.interrupt?.().catch(() => {});
550
+ pending.reject(new Error(reason));
551
+ if (this.pendingQueue.length > 0) {
552
+ this.pendingQueue[0].activate();
553
+ } else {
554
+ this.inFlight = false;
555
+ this.emit('idle');
556
+ }
557
+ };
558
+
559
+ const armIdle = () => setTimeout(
560
+ () => fireTimeout(`Timeout: ${timeoutMs / 1000}s idle with no Claude activity`),
561
+ timeoutMs,
562
+ );
563
+
564
+ pending.activate = () => {
565
+ if (activated) return;
566
+ activated = true;
567
+ pending.activated = true;
568
+ pending.startedAt = Date.now();
569
+ idleTimer = armIdle();
570
+ pending.idleTimer = idleTimer;
571
+ maxTimer = setTimeout(
572
+ () => fireTimeout(`Turn exceeded ${maxTurnMs / 1000}s wall-clock ceiling`),
573
+ maxTurnMs,
574
+ );
575
+ pending.maxTimer = maxTimer;
576
+ armVisibilityTimer();
577
+ try { context?.onActivate?.(); }
578
+ catch (err) { this.logger.error?.(`[${this.label}] onActivate: ${err.message}`); }
579
+ };
580
+
581
+ pending.resetIdleTimer = () => {
582
+ if (!activated) return;
583
+ if (idleTimer) clearTimeout(idleTimer);
584
+ idleTimer = armIdle();
585
+ pending.idleTimer = idleTimer;
586
+ };
587
+
588
+ // Push into queue, enforce queueCap.
589
+ this.pendingQueue.push(pending);
590
+ this.inFlight = true;
591
+ while (this.pendingQueue.length > this.queueCap) {
592
+ const dropped = this.pendingQueue.splice(1, 1)[0];
593
+ if (!dropped) break;
594
+ dropped.clearTimers?.();
595
+ const dropErr = Object.assign(
596
+ new Error(`queue overflow: dropped (queue cap ${this.queueCap})`),
597
+ { code: 'QUEUE_OVERFLOW' },
598
+ );
599
+ this._logEvent('queue-overflow-drop', {
600
+ session_key: this.sessionKey,
601
+ chat_id: this.chatId,
602
+ queue_len: this.pendingQueue.length,
603
+ source_msg_id: dropped.context?.sourceMsgId ?? null,
604
+ });
605
+ this.emit('queue-drop', dropped);
606
+ dropped.reject(dropErr);
607
+ }
608
+
609
+ if (this.pendingQueue.length === 1) pending.activate();
610
+
611
+ try {
612
+ this.inputController.push({
613
+ type: 'user',
614
+ message: { role: 'user', content: prompt },
615
+ parent_tool_use_id: null,
616
+ });
617
+ } catch (err) {
618
+ const idx = this.pendingQueue.indexOf(pending);
619
+ if (idx !== -1) this.pendingQueue.splice(idx, 1);
620
+ if (this.pendingQueue.length === 0) this.inFlight = false;
621
+ pending.reject(err);
622
+ }
623
+ });
624
+ }
625
+
626
+ // ─── Per-session control surface ────────────────────────────────
627
+
628
+ async interrupt() {
629
+ if (this.closed) return false;
630
+ try { await this.query.interrupt?.(); }
631
+ catch (err) {
632
+ this.logger.error?.(`[${this.label}] interrupt: ${err.message}`);
633
+ return false;
634
+ }
635
+ this._logEvent('interrupt-applied', { session_key: this.sessionKey });
636
+ // Parity with TmuxProcess: emit as event so cross-backend consumers
637
+ // can observe interrupts without subscribing to backend-specific channels.
638
+ this.emit('interrupt-applied', { backend: this.backend });
639
+ return true;
640
+ }
641
+
642
+ drainQueue(errCode = 'INTERRUPTED') {
643
+ let count = 0;
644
+ while (this.pendingQueue.length > 0) {
645
+ const p = this.pendingQueue.shift();
646
+ p.clearTimers?.();
647
+ const err = Object.assign(new Error(`drained:${errCode}`), { code: errCode });
648
+ try { p.reject(err); } catch { /* swallow */ }
649
+ count++;
650
+ }
651
+ this.inFlight = false;
652
+ this._logEvent('drain-queue', { session_key: this.sessionKey, code: errCode, count });
653
+ return count;
654
+ }
655
+
656
+ async setModel(model) {
657
+ if (this.closed) return false;
658
+ try { await this.query.setModel?.(model); return true; }
659
+ catch (err) {
660
+ this.logger.error?.(`[${this.label}] setModel: ${err.message}`);
661
+ return false;
662
+ }
663
+ }
664
+
665
+ async setPermissionMode(mode) {
666
+ if (this.closed) return false;
667
+ try { await this.query.setPermissionMode?.(mode); return true; }
668
+ catch (err) {
669
+ this.logger.error?.(`[${this.label}] setPermissionMode: ${err.message}`);
670
+ return false;
671
+ }
672
+ }
673
+
674
+ async applyFlagSettings(settings) {
675
+ if (this.closed) return false;
676
+ try { await this.query.applyFlagSettings?.(settings); return true; }
677
+ catch (err) {
678
+ this.logger.error?.(`[${this.label}] applyFlagSettings: ${err.message}`);
679
+ return false;
680
+ }
681
+ }
682
+
683
+ steer(text, { shouldQuery = false } = {}) {
684
+ if (this.closed) return false;
685
+ try {
686
+ this.inputController.push({
687
+ type: 'user',
688
+ message: { role: 'user', content: text },
689
+ parent_tool_use_id: null,
690
+ priority: 'now',
691
+ shouldQuery,
692
+ });
693
+ this._logEvent('steer', {
694
+ session_key: this.sessionKey,
695
+ chat_id: this.chatId,
696
+ should_query: shouldQuery,
697
+ text_len: text?.length ?? 0,
698
+ });
699
+ return true;
700
+ } catch (err) {
701
+ this.logger.error?.(`[${this.label}] steer: ${err.message}`);
702
+ return false;
703
+ }
704
+ }
705
+
706
+ injectUserMessage({ content, priority = 'next', shouldQuery, parent_tool_use_id = null } = {}) {
707
+ if (this.closed) return false;
708
+ if (typeof content !== 'string' || !content) {
709
+ // R1-F1: hot path — never throw. Just refuse.
710
+ return false;
711
+ }
712
+ // Parity with TmuxProcess (G5b): strip C0/DEL before push. Refuse
713
+ // if the result is empty so caller falls through to pm.send path.
714
+ const safeContent = sanitizeControlChars(content);
715
+ if (!safeContent) return false;
716
+ if (safeContent.length !== content.length) {
717
+ this.emit('prompt-sanitized', {
718
+ stripped: content.length - safeContent.length,
719
+ source: 'inject',
720
+ });
721
+ }
722
+ content = safeContent;
723
+ try {
724
+ const msg = {
725
+ type: 'user',
726
+ message: { role: 'user', content },
727
+ parent_tool_use_id,
728
+ };
729
+ if (priority !== undefined) msg.priority = priority;
730
+ if (shouldQuery !== undefined) msg.shouldQuery = shouldQuery;
731
+ this.inputController.push(msg);
732
+ const head = this.pendingQueue?.[0];
733
+ if (head) head.pendingSteerCausesNewBubble = true;
734
+ this._logEvent('inject-user-message', {
735
+ session_key: this.sessionKey,
736
+ chat_id: this.chatId,
737
+ priority: priority ?? null,
738
+ should_query: shouldQuery ?? null,
739
+ text_len: content.length,
740
+ });
741
+ // Parity with TmuxProcess: emit a hot-path event so EventEmitter
742
+ // consumers (and the cross-backend contract suite) can observe
743
+ // injection consistently across backends.
744
+ this.emit('inject-user-message', {
745
+ text_len: content.length,
746
+ priority: priority ?? null,
747
+ shouldQuery: shouldQuery ?? null,
748
+ });
749
+ return true;
750
+ } catch (err) {
751
+ this.logger.error?.(`[${this.label}] injectUserMessage: ${err.message}`);
752
+ // Parity with TmuxProcess: surface transport failure as an event
753
+ // so cross-backend consumers can observe it consistently.
754
+ this.emit('inject-fail', { err: err.message, source: 'inject' });
755
+ return false;
756
+ }
757
+ }
758
+
759
+ /**
760
+ * Fire-and-forget user-message push. Used by polygram's slash-command
761
+ * paths (/compact). SDK's inputController accepts pushes anytime;
762
+ * tmux pastes into the TUI. Returns boolean.
763
+ */
764
+ fireUserMessage(text) {
765
+ if (this.closed) return false;
766
+ if (typeof text !== 'string' || !text) return false;
767
+ try {
768
+ this.inputController.push({
769
+ type: 'user',
770
+ message: { role: 'user', content: text },
771
+ parent_tool_use_id: null,
772
+ });
773
+ return true;
774
+ } catch (err) {
775
+ this.logger.error?.(`[${this.label}] fireUserMessage: ${err.message}`);
776
+ return false;
777
+ }
778
+ }
779
+
780
+ async resetSession({ reason = 'user-requested' } = {}) {
781
+ const drainedPendings = this.drainQueue('RESET_SESSION');
782
+ const closed = await this._closeQuery(reason);
783
+ if (this.db?.clearSessionId) {
784
+ try { this.db.clearSessionId(this.sessionKey); }
785
+ catch (err) { this.logger.error?.(`[${this.label}] clearSessionId: ${err.message}`); }
786
+ }
787
+ this._logEvent('session-reset', {
788
+ session_key: this.sessionKey, reason, drained_pendings: drainedPendings, closed,
789
+ });
790
+ return { closed, drainedPendings };
791
+ }
792
+
793
+ async getContextUsage() {
794
+ if (this.closed) throw new UnsupportedOperationError('getContextUsage', this.backend);
795
+ if (typeof this.query?.getContextUsage !== 'function') {
796
+ throw new UnsupportedOperationError('getContextUsage', this.backend);
797
+ }
798
+ return this.query.getContextUsage();
799
+ }
800
+
801
+ // ─── kill ──────────────────────────────────────────────────────
802
+
803
+ async kill(reason = 'kill') {
804
+ this.drainQueue('KILLED');
805
+ await this._closeQuery(reason);
806
+ }
807
+
808
+ /**
809
+ * Race Query.close() against the close timeout. Returns true if
810
+ * close resolved cleanly; false if it timed out. Per D7.
811
+ */
812
+ async _closeQuery(reason) {
813
+ if (this.closed) return true;
814
+ this.closed = true;
815
+ try { this.inputController?.close(); } catch { /* swallow */ }
816
+ let timedOut = false;
817
+ const closeP = (async () => {
818
+ try { await this.query?.close?.(); }
819
+ catch (err) {
820
+ this.logger.error?.(`[${this.label}] query.close: ${err.message}`);
821
+ }
822
+ })();
823
+ const timerP = new Promise((resolve) => setTimeout(() => {
824
+ timedOut = true;
825
+ resolve();
826
+ }, this.queryCloseTimeoutMs));
827
+ await Promise.race([closeP, timerP]);
828
+ if (timedOut) {
829
+ this._logEvent('evict-close-timeout', {
830
+ session_key: this.sessionKey, reason, timeout_ms: this.queryCloseTimeoutMs,
831
+ });
832
+ }
833
+ this.emit('close', timedOut ? 1 : 0);
834
+ return !timedOut;
835
+ }
836
+
837
+ // ─── Helpers ────────────────────────────────────────────────────
838
+
839
+ _failAllPendings(err) {
840
+ while (this.pendingQueue.length > 0) {
841
+ const p = this.pendingQueue.shift();
842
+ p.clearTimers?.();
843
+ try { p.reject(err); } catch { /* swallow */ }
844
+ }
845
+ this.inFlight = false;
846
+ }
847
+
848
+ _handleQueueDrop(droppedMsg) {
849
+ // The dropped message was a queued user message not yet consumed
850
+ // by SDK. Find the corresponding pending and reject it.
851
+ // (Pendings and pushed messages are 1:1 in order; we dropped
852
+ // from the FRONT, which corresponds to pendingQueue[1] —
853
+ // head=in-flight is index 0.)
854
+ if (this.pendingQueue.length < 2) return;
855
+ const dropped = this.pendingQueue.splice(1, 1)[0];
856
+ if (!dropped) return;
857
+ dropped.clearTimers?.();
858
+ const err = Object.assign(
859
+ new Error(`queue overflow: dropped (queue cap ${this.queueCap})`),
860
+ { code: 'QUEUE_OVERFLOW' },
861
+ );
862
+ this._logEvent('queue-overflow-drop', {
863
+ session_key: this.sessionKey,
864
+ chat_id: this.chatId,
865
+ queue_len: this.pendingQueue.length,
866
+ source_msg_id: dropped.context?.sourceMsgId ?? null,
867
+ });
868
+ this.emit('queue-drop', dropped);
869
+ dropped.reject(err);
870
+ }
871
+
872
+ _logEvent(kind, detail) {
873
+ if (!this.db?.logEvent) return;
874
+ try { this.db.logEvent(kind, detail); }
875
+ catch (err) { this.logger.error?.(`[sdk-process] logEvent ${kind} failed: ${err.message}`); }
876
+ }
877
+ }
878
+
879
+ module.exports = {
880
+ SdkProcess,
881
+ extractAssistantText,
882
+ sumUsage,
883
+ makeInputController,
884
+ // Constants exposed for tests + the pm
885
+ DEFAULT_QUEUE_CAP,
886
+ DEFAULT_QUERY_CLOSE_TIMEOUT_MS,
887
+ DEFAULT_TRANSIENT_RETRY_DELAY_MS,
888
+ MAX_TRANSIENT_RETRIES,
889
+ DEFAULT_IDLE_MS,
890
+ DEFAULT_MAX_TURN_MS,
891
+ };