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.
- package/.claude-plugin/plugin.json +1 -1
- package/lib/db.js +14 -3
- package/lib/handlers/slash-commands.js +22 -12
- package/lib/model-costs.js +60 -0
- package/lib/process/factory.js +102 -0
- package/lib/process/process.js +193 -0
- package/lib/process/sdk-process.js +880 -0
- package/lib/process/tmux-process.js +1022 -0
- package/lib/process-manager.js +391 -0
- package/lib/sdk/callbacks.js +13 -5
- package/lib/tmux/log-tail.js +324 -0
- package/lib/tmux/orphan-sweep.js +79 -0
- package/lib/tmux/poll-scheduler.js +110 -0
- package/lib/tmux/session-log-parser.js +173 -0
- package/lib/tmux/tmux-runner.js +303 -0
- package/lib/tmux/tui-tool-input.js +62 -0
- package/migrations/011-pm-backend.sql +17 -0
- package/package.json +1 -1
- package/polygram.js +122 -33
- package/lib/sdk/process-manager.js +0 -1178
|
@@ -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
|
+
};
|