autodev-cli 1.4.189 → 1.4.191

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,683 @@
1
+ "use strict";
2
+ // ---------------------------------------------------------------------------
3
+ // AcpSession — a PROVIDER-AGNOSTIC persistent Agent Client Protocol process.
4
+ //
5
+ // This is the generalization of the grok-only GrokAcpSession (shipped 1.4.188)
6
+ // into a single client that can drive ANY agent that speaks ACP over stdio —
7
+ // grok (`grok agent … stdio`), opencode (`opencode acp`), and later copilot
8
+ // (`copilot --acp`) / claude (via a third-party adapter). One long-lived process
9
+ // per workspace root, fed turn-by-turn, kept alive between turns:
10
+ //
11
+ // spawn once → `initialize` → `session/new` (or `session/load` to RESTORE a
12
+ // prior session's context at (re)start) → per turn `session/prompt`, awaiting
13
+ // the turn's completion, KEEPING THE PROCESS ALIVE between turns.
14
+ //
15
+ // Wire protocol (identical across grok + opencode; captured live against
16
+ // grok 1.0.13 and opencode 1.14.22):
17
+ // - Framing: line-delimited JSON-RPC 2.0 (NOT LSP Content-Length).
18
+ // - initialize {protocolVersion:1, clientCapabilities:{}} ->
19
+ // {protocolVersion, agentCapabilities:{loadSession, sessionCapabilities:{resume,…}}}
20
+ // - session/new {cwd, mcpServers:[]} -> {sessionId, …}
21
+ // (the agent STILL loads its OWN config MCP servers even with mcpServers:[],
22
+ // so office/operate MCP keeps working — verified for grok AND opencode.)
23
+ // - session/load {sessionId, cwd, mcpServers:[]} -> replays the conversation as
24
+ // session/update notifications, then resolves — restores in-process context.
25
+ // - session/prompt {sessionId, prompt:[{type:'text',text}]} ->
26
+ // {stopReason:'end_turn'|'cancelled'|…} (resolves at turn end).
27
+ // - session/update NOTIFICATIONS stream during a turn (agent_message_chunk,
28
+ // agent_thought_chunk, tool_call, tool_call_update, …).
29
+ // - session/cancel {sessionId} (notification) cancels the running turn; the
30
+ // process STAYS ALIVE. A second session/prompt mid-turn is QUEUED (FIFO) by
31
+ // the agent and run after the current turn — this is how a mid-run STEER
32
+ // reaches the live process without exiting it.
33
+ //
34
+ // With EMPTY client capabilities the agent performs its OWN fs/terminal I/O
35
+ // (verified for grok + opencode) — so the client stays tiny. We still answer any
36
+ // agent→client request defensively (auto-allow permission) so nothing wedges.
37
+ //
38
+ // The ONLY provider-specific bits are captured in `AcpLaunchSpec`:
39
+ // - buildArgs(model, extra): the argv after the binary
40
+ // (grok: `agent --always-approve [-m M] --no-leader … stdio`;
41
+ // opencode: `[-m M] acp`)
42
+ // - decodeUpdate: how this provider's session/update maps to AcpUpdate
43
+ // (grok reads the real tool name from `_meta['x.ai/tool']`; opencode uses the
44
+ // standard ACP `title`) — defaults to the standard-ACP decoder below.
45
+ // - narrationTitle: the label attached to narration hooks ('grok' / 'opencode').
46
+ // - narrateReasoning: whether agent_thought_chunk is forwarded as narration
47
+ // (default false — keeps reasoning out of the office chat).
48
+ // ---------------------------------------------------------------------------
49
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
50
+ if (k2 === undefined) k2 = k;
51
+ var desc = Object.getOwnPropertyDescriptor(m, k);
52
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
53
+ desc = { enumerable: true, get: function() { return m[k]; } };
54
+ }
55
+ Object.defineProperty(o, k2, desc);
56
+ }) : (function(o, m, k, k2) {
57
+ if (k2 === undefined) k2 = k;
58
+ o[k2] = m[k];
59
+ }));
60
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
61
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
62
+ }) : function(o, v) {
63
+ o["default"] = v;
64
+ });
65
+ var __importStar = (this && this.__importStar) || (function () {
66
+ var ownKeys = function(o) {
67
+ ownKeys = Object.getOwnPropertyNames || function (o) {
68
+ var ar = [];
69
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
70
+ return ar;
71
+ };
72
+ return ownKeys(o);
73
+ };
74
+ return function (mod) {
75
+ if (mod && mod.__esModule) return mod;
76
+ var result = {};
77
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
78
+ __setModuleDefault(result, mod);
79
+ return result;
80
+ };
81
+ })();
82
+ Object.defineProperty(exports, "__esModule", { value: true });
83
+ exports.AcpSession = void 0;
84
+ exports.decodeAcpUpdateStandard = decodeAcpUpdateStandard;
85
+ exports.extractToolOutput = extractToolOutput;
86
+ exports.classifyAcpResult = classifyAcpResult;
87
+ const child_process = __importStar(require("child_process"));
88
+ /**
89
+ * STANDARD-ACP decoder (opencode, and the generic default). Handles the ACP-spec
90
+ * session/update shapes:
91
+ * agent_message_chunk {content:{type:'text',text}} -> assistant
92
+ * agent_thought_chunk {content:{type:'text',text}} -> reasoning
93
+ * tool_call {toolCallId,title,rawInput} -> tool_call (name=title)
94
+ * tool_call_update {toolCallId,status,content|rawOutput} -> tool_result
95
+ * Anything else (available_commands_update, usage_update, plan, …) -> other. PURE.
96
+ */
97
+ function decodeAcpUpdateStandard(msg) {
98
+ const m = msg;
99
+ if (!m || m.method !== 'session/update' || !m.params || !m.params.update) {
100
+ return { kind: 'other' };
101
+ }
102
+ const u = m.params.update;
103
+ const su = u['sessionUpdate'];
104
+ if (su === 'agent_message_chunk') {
105
+ const content = u['content'];
106
+ if (content && content.type === 'text' && typeof content.text === 'string') {
107
+ return { kind: 'assistant', text: content.text };
108
+ }
109
+ return { kind: 'other' };
110
+ }
111
+ if (su === 'agent_thought_chunk') {
112
+ const content = u['content'];
113
+ if (content && content.type === 'text' && typeof content.text === 'string') {
114
+ return { kind: 'reasoning', text: content.text };
115
+ }
116
+ return { kind: 'other' };
117
+ }
118
+ if (su === 'tool_call') {
119
+ const id = String(u['toolCallId'] ?? '');
120
+ // Standard ACP puts a human title on the tool call; some agents also carry a
121
+ // machine tool id under _meta — prefer an explicit name, fall back to title.
122
+ const meta = u['_meta'];
123
+ const metaName = _metaToolName(meta);
124
+ const name = String(metaName || u['title'] || u['kind'] || '').trim();
125
+ return { kind: 'tool_call', id, name, input: u['rawInput'] };
126
+ }
127
+ if (su === 'tool_call_update') {
128
+ const status = String(u['status'] ?? '');
129
+ // Only TERMINAL statuses close a tool call; in_progress/pending are interim.
130
+ if (status !== 'completed' && status !== 'failed' && status !== 'cancelled') {
131
+ return { kind: 'other' };
132
+ }
133
+ const id = String(u['toolCallId'] ?? '');
134
+ return { kind: 'tool_result', id, status, outputText: extractToolOutput(u) };
135
+ }
136
+ return { kind: 'other' };
137
+ }
138
+ /** Pull a machine tool name out of a provider `_meta` blob if present. PURE. */
139
+ function _metaToolName(meta) {
140
+ if (!meta || typeof meta !== 'object') {
141
+ return '';
142
+ }
143
+ // grok: _meta['x.ai/tool'].name ; opencode: _meta['opencode']?.tool (defensive)
144
+ const xai = meta['x.ai/tool'];
145
+ if (xai?.name) {
146
+ return String(xai.name);
147
+ }
148
+ const oc = meta['opencode'];
149
+ if (oc?.tool || oc?.toolID) {
150
+ return String(oc.tool || oc.toolID);
151
+ }
152
+ return '';
153
+ }
154
+ /** Best-effort flatten of a tool_call_update's output into a short string. PURE. */
155
+ function extractToolOutput(u) {
156
+ const raw = u['rawOutput'];
157
+ if (typeof raw === 'string') {
158
+ return raw;
159
+ }
160
+ if (raw && typeof raw === 'object') {
161
+ try {
162
+ return JSON.stringify(raw);
163
+ }
164
+ catch { /* ignore */ }
165
+ }
166
+ const content = u['content'];
167
+ if (Array.isArray(content)) {
168
+ const parts = [];
169
+ for (const c of content) {
170
+ const cc = c;
171
+ if (cc?.type === 'text' && typeof cc.text === 'string') {
172
+ parts.push(cc.text);
173
+ }
174
+ else if (cc?.type === 'diff' && typeof cc.newText === 'string') {
175
+ parts.push(cc.newText);
176
+ }
177
+ else if (cc?.content && typeof cc.content.text === 'string') {
178
+ parts.push(cc.content.text);
179
+ }
180
+ }
181
+ if (parts.length) {
182
+ return parts.join('\n');
183
+ }
184
+ }
185
+ return '';
186
+ }
187
+ /**
188
+ * Classify a session/prompt RESULT into clean-vs-error for the exit file. PURE.
189
+ * - end_turn / max_tokens / max_turn_requests → clean (exit 0)
190
+ * - cancelled → clean (an intentional interrupt, not a failure)
191
+ * - a JSON-RPC error, or any other stopReason → error (exit 1)
192
+ */
193
+ function classifyAcpResult(result) {
194
+ if (result.error) {
195
+ return { clean: false, stopReason: 'error', error: String(result.error.message || 'jsonrpc error') };
196
+ }
197
+ const sr = String(result.result?.stopReason ?? '');
198
+ const CLEAN = new Set(['end_turn', 'max_tokens', 'max_turn_requests', 'cancelled']);
199
+ return { clean: CLEAN.has(sr), stopReason: sr || 'unknown' };
200
+ }
201
+ /**
202
+ * One persistent ACP process for a workspace root. Not a singleton — the host
203
+ * keeps a Map<root, AcpSession> and reuses it. Provider behaviour is injected via
204
+ * the `spec`.
205
+ */
206
+ class AcpSession {
207
+ root;
208
+ model;
209
+ bin;
210
+ hooks;
211
+ extraArgs;
212
+ spec;
213
+ /** Extra env vars merged into the child's environment (e.g. OPENCODE_CONFIG). */
214
+ extraEnv;
215
+ _decode;
216
+ child = null;
217
+ buf = '';
218
+ nextId = 1;
219
+ pending = new Map();
220
+ _sessionId;
221
+ _wantSessionId; // id to session/load on (re)start
222
+ _ready = null;
223
+ _busy = false;
224
+ _startCount = 0;
225
+ earlyStderr = '';
226
+ /** True WHILE session/load is replaying prior history — every session/update
227
+ * during the replay is dropped (emits ZERO hooks) so a (re)start never
228
+ * re-spams historical narration + tool_calls to the office feed (Layer A.1). */
229
+ _loading = false;
230
+ /** The active PRIMARY turn's stdout sink (assistant text). Null between turns. */
231
+ _sink = null;
232
+ /** Tool-call names by id, so a tool_result can carry the originating name. */
233
+ _toolNames = new Map();
234
+ /** Accumulates assistant chunks so Notification hooks are line-granular. */
235
+ _narrationBuf = '';
236
+ /** The current tool activity label (set on tool_call, cleared on result). */
237
+ _activity;
238
+ /** Did the active turn produce ANY assistant text or tool call? (empty-turn guard) */
239
+ _turnHadOutput = false;
240
+ /** ALL outstanding session/prompt promises — the primary turn AND any steered
241
+ * (queued) continuation prompts. The turn is not "done" until this drains, so
242
+ * the loop keeps the agent WORKING through steers instead of going idle while
243
+ * the agent runs an untracked queued turn (Bug A). */
244
+ _inflight = new Set();
245
+ constructor(opts) {
246
+ this.root = opts.root;
247
+ this.model = opts.model;
248
+ this.bin = opts.bin;
249
+ this.hooks = opts.hooks;
250
+ this.spec = opts.spec;
251
+ this._decode = opts.spec.decodeUpdate ?? decodeAcpUpdateStandard;
252
+ this._wantSessionId = opts.initialSessionId;
253
+ this.extraArgs = opts.extraArgs ?? [];
254
+ this.extraEnv = opts.extraEnv ?? {};
255
+ }
256
+ /** Per-turn watchdog budget (ms). Env: AUTODEV_ACP_MAX_RUN_MS, else GROK_MAX_RUN_MS
257
+ * (parity with the headless path), else 20 minutes. A 0/negative value disables. */
258
+ static maxRunMs() {
259
+ const raw = process.env['AUTODEV_ACP_MAX_RUN_MS'] ?? process.env['GROK_MAX_RUN_MS'] ?? '';
260
+ const n = Number.parseInt(String(raw), 10);
261
+ if (Number.isFinite(n) && n > 0) {
262
+ return n;
263
+ }
264
+ if (Number.isFinite(n) && n <= 0 && String(raw).trim() !== '') {
265
+ return 2147483647;
266
+ } // disabled
267
+ return 20 * 60 * 1000;
268
+ }
269
+ get pid() { return this.child?.pid; }
270
+ get sessionId() { return this._sessionId; }
271
+ get activity() { return this._activity; }
272
+ /** Busy while the primary turn is running OR any steered/queued prompt is still
273
+ * in-flight — so the loop never treats the agent as idle mid-queue (Bug A). */
274
+ isBusy() { return this._busy || this._inflight.size > 0; }
275
+ isAlive() { return !!this.child && this.child.exitCode === null && !this.child.killed; }
276
+ /** Spawn + initialize + session/new|load, once. Respawns transparently if dead. */
277
+ async ensureReady() {
278
+ if (this.isAlive() && this._sessionId) {
279
+ return;
280
+ }
281
+ if (this._ready) {
282
+ return this._ready;
283
+ }
284
+ this._ready = this._startup().catch((err) => { this._ready = null; throw err; });
285
+ return this._ready;
286
+ }
287
+ async _startup() {
288
+ this._spawnChild();
289
+ await this._request('initialize', { protocolVersion: 1, clientCapabilities: {} });
290
+ const want = this._wantSessionId;
291
+ if (want) {
292
+ // Layer A.1: suppress hook emission for the whole session/load replay so a
293
+ // reload restores context QUIETLY (no re-spammed historical narration/tools).
294
+ this._loading = true;
295
+ try {
296
+ const r = await this._request('session/load', { sessionId: want, cwd: this.root, mcpServers: [] });
297
+ if (!r.error) {
298
+ this._sessionId = want;
299
+ this.hooks.log(`ACP: restored session ${want.slice(0, 8)}… (session/load, quiet) — context intact`);
300
+ }
301
+ }
302
+ catch (err) {
303
+ this.hooks.log(`ACP: session/load ${want.slice(0, 8)}… failed (${err?.message ?? err}); starting fresh`);
304
+ }
305
+ finally {
306
+ this._loading = false;
307
+ this._narrationBuf = ''; // drop any partial replayed narration
308
+ this._toolNames.clear(); // drop replayed open tool ids (never emitted)
309
+ }
310
+ }
311
+ if (!this._sessionId) {
312
+ const sn = await this._request('session/new', { cwd: this.root, mcpServers: [] });
313
+ const sid = sn.result?.sessionId;
314
+ if (!sid) {
315
+ const em = sn.error?.message ? ` (${sn.error.message})` : '';
316
+ throw new Error(`session/new returned no sessionId${em}`);
317
+ }
318
+ this._sessionId = sid;
319
+ this.hooks.log(`ACP: new persistent session ${sid.slice(0, 8)}…`);
320
+ }
321
+ this._wantSessionId = this._sessionId;
322
+ this.hooks.emitHook('SessionStart', { source: this._startCount > 1 ? 'resume' : 'startup' });
323
+ }
324
+ _spawnChild() {
325
+ this._startCount++;
326
+ const args = this.spec.buildArgs(this.model, this.extraArgs);
327
+ const child = child_process.spawn(this.bin, args, {
328
+ cwd: this.root,
329
+ stdio: ['pipe', 'pipe', 'pipe'],
330
+ env: { ...process.env, TERM: process.env['TERM'] || 'xterm-256color', ...this.extraEnv },
331
+ });
332
+ this.child = child;
333
+ this.buf = '';
334
+ this.earlyStderr = '';
335
+ child.stdout?.setEncoding('utf8');
336
+ child.stdout?.on('data', (chunk) => this._onStdout(chunk));
337
+ child.stderr?.on('data', (chunk) => {
338
+ const s = chunk.toString();
339
+ if (this.earlyStderr.length < 4000) {
340
+ this.earlyStderr += s;
341
+ }
342
+ });
343
+ child.on('exit', (code, sig) => this._onExit(code, sig));
344
+ child.on('error', (err) => this.hooks.log(`ACP: child error: ${err.message}`));
345
+ }
346
+ _onExit(code, sig) {
347
+ this.hooks.log(`ACP: agent process exited (code=${code} sig=${sig})`);
348
+ this.child = null;
349
+ this._ready = null;
350
+ this._sink = null;
351
+ this._busy = false;
352
+ this._activity = undefined;
353
+ this._flushOpenTools('interrupted'); // Bug B: close any tools open when the agent dies
354
+ const err = { error: { message: `acp agent exited (code=${code} sig=${sig})` } };
355
+ for (const [, p] of this.pending) {
356
+ try {
357
+ p.resolve(err);
358
+ }
359
+ catch { /* ignore */ }
360
+ }
361
+ this.pending.clear();
362
+ this._inflight.clear();
363
+ }
364
+ _onStdout(chunk) {
365
+ this.buf += chunk;
366
+ let nl;
367
+ while ((nl = this.buf.indexOf('\n')) >= 0) {
368
+ const line = this.buf.slice(0, nl).trim();
369
+ this.buf = this.buf.slice(nl + 1);
370
+ if (!line) {
371
+ continue;
372
+ }
373
+ let msg;
374
+ try {
375
+ msg = JSON.parse(line);
376
+ }
377
+ catch {
378
+ continue;
379
+ }
380
+ this._handle(msg);
381
+ }
382
+ }
383
+ _handle(msg) {
384
+ const id = msg['id'];
385
+ if (typeof id === 'number' && (msg['result'] !== undefined || msg['error'] !== undefined) && this.pending.has(id)) {
386
+ const p = this.pending.get(id);
387
+ this.pending.delete(id);
388
+ p.resolve(msg);
389
+ return;
390
+ }
391
+ // Request FROM the agent (we are the client). With empty caps the agent does
392
+ // its own fs/terminal, but answer defensively so nothing ever wedges.
393
+ if (msg['method'] && id !== undefined) {
394
+ const method = String(msg['method']);
395
+ if (method === 'session/request_permission') {
396
+ const params = msg['params'];
397
+ const opts = params?.options ?? [];
398
+ const allow = opts.find((o) => /allow|once|always/i.test(o.optionId || o.kind || '')) || opts[0];
399
+ this._send({ jsonrpc: '2.0', id, result: { outcome: { outcome: 'selected', optionId: allow?.optionId } } });
400
+ }
401
+ else {
402
+ this._send({ jsonrpc: '2.0', id, error: { code: -32601, message: 'client capability not provided' } });
403
+ }
404
+ return;
405
+ }
406
+ if (msg['method'] === 'session/update') {
407
+ this._onUpdate(this._decode(msg));
408
+ }
409
+ }
410
+ _onUpdate(u) {
411
+ if (this._loading) {
412
+ return;
413
+ } // Layer A.1: drop session/load replay silently
414
+ if (u.kind === 'assistant') {
415
+ this._turnHadOutput = true;
416
+ if (this._sink) {
417
+ try {
418
+ this._sink(u.text);
419
+ }
420
+ catch { /* ignore */ }
421
+ }
422
+ this._narrationBuf += u.text;
423
+ this._flushNarration(false);
424
+ return;
425
+ }
426
+ if (u.kind === 'reasoning') {
427
+ if (!this.spec.narrateReasoning) {
428
+ return;
429
+ } // reasoning kept out of the feed by default
430
+ this._narrationBuf += u.text;
431
+ this._flushNarration(false);
432
+ return;
433
+ }
434
+ if (u.kind === 'tool_call') {
435
+ if (!u.name || u.name.startsWith('mcp__')) {
436
+ return;
437
+ } // office logs MCP tools server-side
438
+ this._turnHadOutput = true;
439
+ this._activity = u.name;
440
+ this._toolNames.set(u.id, u.name);
441
+ this.hooks.emitHook('PreToolUse', { tool_name: u.name, tool_input: u.input, tool_use_id: u.id });
442
+ return;
443
+ }
444
+ if (u.kind === 'tool_result') {
445
+ const name = this._toolNames.get(u.id);
446
+ if (!name) {
447
+ return;
448
+ } // no matching Pre (skipped mcp tool / replay) — don't emit orphan
449
+ this._toolNames.delete(u.id);
450
+ this._activity = undefined;
451
+ if (u.status === 'failed' || u.status === 'cancelled') {
452
+ this.hooks.emitHook('PostToolUseFailure', { tool_name: name, tool_use_id: u.id, outcome: u.status === 'cancelled' ? 'cancelled' : 'error', tool_output: { text: u.outputText.slice(0, 2000) } });
453
+ }
454
+ else {
455
+ this.hooks.emitHook('PostToolUse', { tool_name: name, tool_use_id: u.id, tool_output: { text: u.outputText.slice(0, 2000) } });
456
+ }
457
+ }
458
+ }
459
+ /**
460
+ * Bug B invariant: every forwarded tool-START must get a matching tool-END. On
461
+ * turn end / cancel / teardown, FLUSH any tool_call ids still open (never got a
462
+ * terminal tool_call_update) as ended, so pixel-office (which pairs start→end)
463
+ * never shows a tool stuck "running" after the agent is idle.
464
+ */
465
+ _flushOpenTools(outcome) {
466
+ if (this._toolNames.size === 0) {
467
+ return;
468
+ }
469
+ for (const [id, name] of this._toolNames) {
470
+ try {
471
+ this.hooks.emitHook('PostToolUseFailure', { tool_name: name, tool_use_id: id, outcome, tool_output: { text: `[${outcome}]` } });
472
+ }
473
+ catch { /* ignore */ }
474
+ }
475
+ this._toolNames.clear();
476
+ this._activity = undefined;
477
+ }
478
+ /** Emit completed narration lines as Notification hooks. `final` flushes the
479
+ * trailing partial line (turn end). Keeps the office feed line-granular. */
480
+ _flushNarration(final) {
481
+ let nl;
482
+ while ((nl = this._narrationBuf.indexOf('\n')) >= 0) {
483
+ const line = this._narrationBuf.slice(0, nl);
484
+ this._narrationBuf = this._narrationBuf.slice(nl + 1);
485
+ if (line.trim()) {
486
+ this.hooks.emitHook('Notification', { message: line, title: this.spec.narrationTitle, tool_name: this.spec.narrationTitle });
487
+ }
488
+ }
489
+ if (final && this._narrationBuf.trim()) {
490
+ this.hooks.emitHook('Notification', { message: this._narrationBuf, title: this.spec.narrationTitle, tool_name: this.spec.narrationTitle });
491
+ }
492
+ if (final) {
493
+ this._narrationBuf = '';
494
+ }
495
+ }
496
+ /**
497
+ * Run ONE primary turn: send the prompt and await turn end. Streams assistant
498
+ * text to `sink`. Emits Stop/StopFailure + SessionEnd. The process stays alive.
499
+ */
500
+ async runTurn(promptText, sink) {
501
+ await this.ensureReady();
502
+ this._busy = true;
503
+ this._sink = sink;
504
+ this._turnHadOutput = false;
505
+ // Layer A.3: per-turn watchdog — a wedged always-open turn must never hang the
506
+ // loop forever. If the turn (primary + drained steers) blows the budget we
507
+ // cancel + reconnect and surface it as a FAILURE (never a false success).
508
+ const budget = AcpSession.maxRunMs();
509
+ let timedOut = false;
510
+ let timer;
511
+ const watchdog = new Promise((resolve) => { timer = setTimeout(() => { timedOut = true; resolve('timeout'); }, budget); });
512
+ let res;
513
+ try {
514
+ const outcome = await Promise.race([
515
+ (async () => {
516
+ const r = await this._promptRequest(promptText);
517
+ const c = classifyAcpResult(r);
518
+ // Bug A: drain any STEERED (queued) continuation prompts before declaring
519
+ // the turn done — the agent stays WORKING until the ACP queue is empty.
520
+ try {
521
+ await this._drainInflight();
522
+ }
523
+ catch { /* resolve on exit */ }
524
+ return c;
525
+ })(),
526
+ watchdog,
527
+ ]);
528
+ if (outcome === 'timeout') {
529
+ this.hooks.log(`ACP: turn exceeded ${budget}ms watchdog — cancelling + reconnecting`);
530
+ try {
531
+ this.cancel();
532
+ }
533
+ catch { /* ignore */ }
534
+ try {
535
+ this.close();
536
+ }
537
+ catch { /* ignore */ } // next turn respawns + session/load restores context
538
+ res = { clean: false, stopReason: 'timeout', error: `turn exceeded ${budget}ms watchdog` };
539
+ }
540
+ else {
541
+ res = outcome;
542
+ }
543
+ }
544
+ catch (err) {
545
+ res = { clean: false, stopReason: 'exception', error: err?.message ?? String(err) };
546
+ }
547
+ finally {
548
+ if (timer) {
549
+ clearTimeout(timer);
550
+ }
551
+ }
552
+ this._flushNarration(true);
553
+ if (!timedOut) {
554
+ this._flushOpenTools('interrupted');
555
+ } // Bug B (close() already flushed on timeout)
556
+ this._sink = null;
557
+ this._busy = false;
558
+ this._activity = undefined;
559
+ res.emptyOutput = res.clean && !this._turnHadOutput;
560
+ if (res.clean) {
561
+ this.hooks.emitHook('Stop', {});
562
+ this.hooks.emitHook('SessionEnd', { reason: 'completed' });
563
+ }
564
+ else {
565
+ this.hooks.emitHook('StopFailure', { exit_code: 1 });
566
+ this.hooks.emitHook('SessionEnd', { reason: 'error' });
567
+ }
568
+ return res;
569
+ }
570
+ /**
571
+ * Deliver a mid-run STEER: send an additional session/prompt the agent QUEUES
572
+ * (FIFO) and runs next — without exiting. The prompt is TRACKED in `_inflight`
573
+ * so the in-progress runTurn drains it before finishing (Bug A) — the steered
574
+ * turn's session/update hooks are forwarded like a normal turn and the loop
575
+ * stays WORKING until it completes. Returns true if handed to the live agent.
576
+ */
577
+ steer(text) {
578
+ if (!this.isAlive() || !this._sessionId) {
579
+ return false;
580
+ }
581
+ const t = text.trim();
582
+ if (!t) {
583
+ return false;
584
+ }
585
+ void this._promptRequest(t); // tracked; drained by the active runTurn
586
+ return true;
587
+ }
588
+ /** Send a tracked session/prompt (primary or steer) and register it in
589
+ * `_inflight` until it resolves. */
590
+ _promptRequest(text) {
591
+ const p = this._request('session/prompt', { sessionId: this._sessionId, prompt: [{ type: 'text', text }] });
592
+ this._inflight.add(p);
593
+ void p.then(() => { this._inflight.delete(p); }, () => { this._inflight.delete(p); });
594
+ return p;
595
+ }
596
+ /** Wait until every in-flight/queued prompt has resolved. */
597
+ async _drainInflight() {
598
+ let guard = 0;
599
+ while (this._inflight.size > 0 && guard++ < 10000) {
600
+ try {
601
+ await Promise.race([...this._inflight]);
602
+ }
603
+ catch { /* ignore */ }
604
+ }
605
+ }
606
+ /**
607
+ * Send a QUIET prompt (e.g. `/compact`) and await it WITHOUT emitting the
608
+ * Stop/SessionEnd turn-boundary hooks — used for maintenance commands so they
609
+ * don't register as a task turn. Best-effort.
610
+ */
611
+ async sendQuiet(text) {
612
+ if (!this.isAlive() || !this._sessionId) {
613
+ return;
614
+ }
615
+ try {
616
+ await this._request('session/prompt', { sessionId: this._sessionId, prompt: [{ type: 'text', text }] });
617
+ }
618
+ catch { /* ignore */ }
619
+ }
620
+ /** Cancel the running turn (process stays alive). */
621
+ cancel() {
622
+ if (!this.isAlive() || !this._sessionId) {
623
+ return;
624
+ }
625
+ this._send({ jsonrpc: '2.0', method: 'session/cancel', params: { sessionId: this._sessionId } });
626
+ this._flushOpenTools('cancelled'); // Bug B: close any tools open at cancel
627
+ }
628
+ /** Kill the persistent process. Preserves the known session id for a later load. */
629
+ close() {
630
+ const c = this.child;
631
+ this.child = null;
632
+ this._ready = null;
633
+ this._sink = null;
634
+ this._busy = false;
635
+ this._activity = undefined;
636
+ this._flushOpenTools('interrupted'); // Bug B: close any tools open at teardown
637
+ for (const [, p] of this.pending) {
638
+ try {
639
+ p.resolve({ error: { message: 'closed' } });
640
+ }
641
+ catch { /* ignore */ }
642
+ }
643
+ this.pending.clear();
644
+ this._inflight.clear();
645
+ if (c) {
646
+ try {
647
+ c.stdin?.end();
648
+ }
649
+ catch { /* ignore */ }
650
+ try {
651
+ c.kill('SIGKILL');
652
+ }
653
+ catch { /* ignore */ }
654
+ }
655
+ }
656
+ _request(method, params) {
657
+ const id = this.nextId++;
658
+ return new Promise((resolve) => {
659
+ this.pending.set(id, { resolve });
660
+ const ok = this._send({ jsonrpc: '2.0', id, method, params });
661
+ if (!ok) {
662
+ this.pending.delete(id);
663
+ resolve({ error: { message: 'stdin write failed (agent not running)' } });
664
+ }
665
+ });
666
+ }
667
+ _send(obj) {
668
+ const c = this.child;
669
+ if (!c || !c.stdin || c.stdin.destroyed) {
670
+ return false;
671
+ }
672
+ try {
673
+ c.stdin.write(JSON.stringify(obj) + '\n');
674
+ return true;
675
+ }
676
+ catch {
677
+ return false;
678
+ }
679
+ }
680
+ get stderrSnapshot() { return this.earlyStderr; }
681
+ }
682
+ exports.AcpSession = AcpSession;
683
+ //# sourceMappingURL=AcpSession.js.map