polygram 0.9.0 → 0.10.0-rc.2

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