autodev-cli 1.4.186 → 1.4.188

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.
@@ -70,12 +70,18 @@ var __importStar = (this && this.__importStar) || (function () {
70
70
  };
71
71
  })();
72
72
  Object.defineProperty(exports, "__esModule", { value: true });
73
+ exports.grokAgentStdioSupported = grokAgentStdioSupported;
74
+ exports.grokAcpEnabled = grokAcpEnabled;
73
75
  exports.isGrokTuiBusy = isGrokTuiBusy;
74
76
  exports.getGrokTuiLastActivity = getGrokTuiLastActivity;
75
77
  exports.forceIdleGrokTui = forceIdleGrokTui;
76
78
  exports.tmuxAvailable = tmuxAvailable;
79
+ exports.grokSessionArgsFor = grokSessionArgsFor;
77
80
  exports.sendGrokPrompt = sendGrokPrompt;
78
81
  exports.sendGrokCliPrompt = sendGrokCliPrompt;
82
+ exports.sendGrokAcpPrompt = sendGrokAcpPrompt;
83
+ exports.steerGrokAcp = steerGrokAcp;
84
+ exports.isGrokAcpBusy = isGrokAcpBusy;
79
85
  exports.decideGrokRunMode = decideGrokRunMode;
80
86
  exports.decideHeadlessGrokMode = decideHeadlessGrokMode;
81
87
  exports.resumeProbeVerdict = resumeProbeVerdict;
@@ -101,6 +107,7 @@ const authClassify_1 = require("./authClassify");
101
107
  const grokEvents_1 = require("../../core/grokEvents");
102
108
  const sessions_1 = require("./sessions");
103
109
  const redactSecrets_1 = require("../../core/redactSecrets");
110
+ const GrokAcpSession_1 = require("./GrokAcpSession");
104
111
  // ---------------------------------------------------------------------------
105
112
  // Constants
106
113
  // ---------------------------------------------------------------------------
@@ -155,6 +162,39 @@ const _resumeFailCount = (root) => _resumeFailures.get(root) ?? 0;
155
162
  const _noteResumeFailure = (root) => { _resumeFailures.set(root, _resumeFailCount(root) + 1); };
156
163
  const _noteResumeSuccess = (root) => { _resumeFailures.delete(root); };
157
164
  const _tmuxSessions = new Map();
165
+ /**
166
+ * Live PERSISTENT `grok agent stdio` ACP sessions by root (the persistent-process
167
+ * model). ONE long-lived grok per workspace serves many turns without exiting;
168
+ * resume happens only at (re)start via session/load. See GrokAcpSession.
169
+ */
170
+ const _acpSessions = new Map();
171
+ /** Cached probe: does this grok build expose `agent stdio` (the ACP transport)? */
172
+ let _grokAgentStdioSupported = null;
173
+ function grokAgentStdioSupported() {
174
+ if (_grokAgentStdioSupported !== null) {
175
+ return _grokAgentStdioSupported;
176
+ }
177
+ try {
178
+ const out = child_process.execFileSync(GROK_BIN, ['agent', '--help'], { encoding: 'utf8', timeout: 8_000, stdio: ['ignore', 'pipe', 'ignore'] });
179
+ _grokAgentStdioSupported = /\bstdio\b/.test(out);
180
+ }
181
+ catch {
182
+ _grokAgentStdioSupported = false;
183
+ }
184
+ return _grokAgentStdioSupported;
185
+ }
186
+ /**
187
+ * Whether the persistent ACP path is active. Gated on grok supporting `agent
188
+ * stdio` AND the AUTODEV_GROK_PERSISTENT env override (default ON when supported;
189
+ * set to 'false'/'0'/'off' to force the legacy one-shot headless path).
190
+ */
191
+ function grokAcpEnabled() {
192
+ const raw = String(process.env['AUTODEV_GROK_PERSISTENT'] ?? '').toLowerCase();
193
+ if (raw === 'false' || raw === '0' || raw === 'off' || raw === 'no') {
194
+ return false;
195
+ }
196
+ return grokAgentStdioSupported();
197
+ }
158
198
  // Per-root set of grok tool_call ids already emitted as PreToolUse, so a re-scan of
159
199
  // the transcript can't emit the SAME tool call twice → duplicate rows in the office
160
200
  // hook feed (observed ~2.4× per call). PostToolUse is already dedup'd by pending.delete;
@@ -1510,6 +1550,29 @@ function runGrokTmuxTurn(root, promptFilePath, resolvedSessionId, stdoutFile, ex
1510
1550
  }
1511
1551
  })();
1512
1552
  }
1553
+ /**
1554
+ * Pure builder for the grok session CLI flags, extracted so the mutually-exclusive
1555
+ * invariants are unit-testable without spawning grok:
1556
+ * - continueSession → `['--continue']` ONLY. NEVER with `--session-id` (grok rejects
1557
+ * that combo unless `--fork-session`, which we never pass) and never an explicit id.
1558
+ * - sessionId → `['--resume', <id>]` (legacy explicit-id resume; not used by the
1559
+ * headless path anymore, kept for callers that pass a concrete id).
1560
+ * - newSessionId → `['--session-id', <uuid>]` (bootstrap a brand-new named session).
1561
+ * - none → `[]` (stateless: grok mints its own throwaway id).
1562
+ * continueSession takes precedence, so `--continue` can never be emitted alongside an id.
1563
+ */
1564
+ function grokSessionArgsFor(args) {
1565
+ if (args.continueSession) {
1566
+ return ['--continue'];
1567
+ }
1568
+ if (args.sessionId) {
1569
+ return ['--resume', args.sessionId];
1570
+ }
1571
+ if (args.newSessionId) {
1572
+ return ['--session-id', args.newSessionId];
1573
+ }
1574
+ return [];
1575
+ }
1513
1576
  function sendGrokPrompt(root, promptFilePath, stdoutFile, exitFile, log, opts = {}) {
1514
1577
  opts.showOutput?.();
1515
1578
  const modelArgs = opts.model ? ['-m', opts.model] : [];
@@ -1525,10 +1588,34 @@ function sendGrokPrompt(root, promptFilePath, stdoutFile, exitFile, log, opts =
1525
1588
  // read from the start = an instant false failure). This is the primary fix for
1526
1589
  // the 1.4.181 forced-resume regression.
1527
1590
  let _launchSid;
1591
+ // For the `--continue` path the concrete id is not known up front — grok appends to
1592
+ // the cwd's most-recent session dir — so we pin the tailer to that EXISTING newest
1593
+ // dir (resolved at launch, before grok appends). Kept separate from _launchSid (which
1594
+ // means "we minted/passed this exact id"), since --continue passes no id at all.
1595
+ let _continueDir;
1528
1596
  if (opts.persist) {
1529
1597
  const providerId = opts.providerId ?? 'grok-tui';
1530
- if (opts.sessionId) {
1531
- sessionArgs.push('--resume', opts.sessionId);
1598
+ if (opts.continueSession) {
1599
+ // `grok --continue`: resume the folder's most-recent session. No id, and NO
1600
+ // --session-id (grok rejects that combo unless --fork-session, which we never
1601
+ // want). Robust to the stale-id / cwd-tracking-drift that made `--resume <id>`
1602
+ // error in ~1s on Windows and forced every turn stateless.
1603
+ sessionArgs.push(...grokSessionArgsFor({ continueSession: true }));
1604
+ _continueDir = (0, sessions_1.newestGrokSessionDir)(path.resolve(root));
1605
+ // Best-effort: stamp the resolved id into session-state.json so the sessions UI
1606
+ // / resume-aware profile reminder can still name the session. The continue
1607
+ // DECISION no longer depends on a stored id (it keys off folder-session presence).
1608
+ try {
1609
+ const resumedId = _continueDir ? path.basename(_continueDir) : undefined;
1610
+ if (resumedId) {
1611
+ (0, sessionState_1.saveSessionId)(root, providerId, resumedId);
1612
+ }
1613
+ }
1614
+ catch { /* best effort */ }
1615
+ log(`Grok: continuing most-recent session for cwd (--continue) (model=${modelLabel})`);
1616
+ }
1617
+ else if (opts.sessionId) {
1618
+ sessionArgs.push(...grokSessionArgsFor({ sessionId: opts.sessionId }));
1532
1619
  _launchSid = opts.sessionId;
1533
1620
  // Persist the id we're resuming so the NEXT turn reads it from
1534
1621
  // session-state.json directly — this also stamps a session that was only
@@ -1542,7 +1629,7 @@ function sendGrokPrompt(root, promptFilePath, stdoutFile, exitFile, log, opts =
1542
1629
  }
1543
1630
  else {
1544
1631
  const sid = (0, crypto_1.randomUUID)();
1545
- sessionArgs.push('--session-id', sid);
1632
+ sessionArgs.push(...grokSessionArgsFor({ newSessionId: sid }));
1546
1633
  _launchSid = sid;
1547
1634
  try {
1548
1635
  (0, sessionState_1.saveSessionId)(root, providerId, sid);
@@ -1666,6 +1753,22 @@ function sendGrokPrompt(root, promptFilePath, stdoutFile, exitFile, log, opts =
1666
1753
  let _endSeen = false;
1667
1754
  let _forcedExit = null; // set by a session-log turn_ended finish
1668
1755
  let _grokFilesActive = false; // once true, session logs own narration
1756
+ // Best diagnostic grok gave us THIS turn, so a fast-failing resume/continue is no
1757
+ // longer a blind guess (the user's Windows `--resume <id>` errors in ~1s but our log
1758
+ // never showed grok's real reason). Populated from three channels: an authoritative
1759
+ // streaming-json {type:"error"} record or an error turn_ended detail (_lastGrokErr),
1760
+ // and, as a fallback, the most recent stderr line (_lastGrokStderr). Surfaced right
1761
+ // before the stateless fallback in finishFromTurnEnd, redacted + truncated.
1762
+ let _lastGrokErr = ''; // authoritative: json error record / error turn_ended detail
1763
+ let _lastGrokStderr = ''; // fallback: latest non-empty stderr line
1764
+ const grokFailDiag = () => {
1765
+ const raw = _lastGrokErr || _lastGrokStderr;
1766
+ if (!raw) {
1767
+ return '';
1768
+ }
1769
+ const t = (0, redactSecrets_1.redactSecrets)(String(raw)).replace(/\s+/g, ' ').trim();
1770
+ return t.length > 300 ? t.slice(0, 297) + '…' : t;
1771
+ };
1669
1772
  // Headless resume hang-probe state (see resumeProbeMs / onResumeHang below). The
1670
1773
  // probe window is the caller-supplied value (sendGrokTuiPrompt reads the env);
1671
1774
  // 0 (all stateless / non-probed calls) leaves the probe disarmed.
@@ -1700,7 +1803,10 @@ function sendGrokPrompt(root, promptFilePath, stdoutFile, exitFile, log, opts =
1700
1803
  // the tailer pins THIS dir and never "newest-by-mtime" (which a newer unrelated
1701
1804
  // session dir could hijack). undefined only in the stateless mode, where grok
1702
1805
  // mints an unknown id and newest-by-mtime is the only way to find it.
1703
- const _fixedDir = _launchSid ? (0, sessions_1.grokSessionDirFor)(path.resolve(root), _launchSid) : undefined;
1806
+ // --continue → the existing newest dir grok appends to (resolved above); resume /
1807
+ // new-persistent → the fixed dir for the known id; stateless → undefined (unknown id).
1808
+ const _fixedDir = _continueDir
1809
+ ?? (_launchSid ? (0, sessions_1.grokSessionDirFor)(path.resolve(root), _launchSid) : undefined);
1704
1810
  // Snapshot the pre-turn session so the tailer forwards ONLY this turn's records:
1705
1811
  // a RESUMED session's dir already holds backlog → seed at its current end; a
1706
1812
  // FRESH session mints a NEW dir that appears later → read that dir from the start
@@ -1787,8 +1893,16 @@ function sendGrokPrompt(root, promptFilePath, stdoutFile, exitFile, log, opts =
1787
1893
  // re-attempts `--resume <storedId>` — continuity resumes as soon as the transient
1788
1894
  // condition clears. Only for a launched RESUME (opts.sessionId present, probe
1789
1895
  // armed); a new-persistent/stateless error falls through to the normal report.
1790
- if (!clean && !_sawFirstToken && opts.sessionId && opts.onResumeHang && !_resumeAbandoned) {
1896
+ if (!clean && !_sawFirstToken && (opts.sessionId || opts.continueSession) && opts.onResumeHang && !_resumeAbandoned) {
1791
1897
  _resumeAbandoned = true; // close skips the exit file — the relaunch owns the turn
1898
+ const how = opts.continueSession ? '--continue' : '--resume';
1899
+ const diag = grokFailDiag();
1900
+ if (diag) {
1901
+ log(`Grok: ${how} failed fast — grok said: "${diag}"`);
1902
+ }
1903
+ else {
1904
+ log(`Grok: ${how} failed fast — grok emitted no diagnostic (no stderr / error record captured this turn)`);
1905
+ }
1792
1906
  log('Grok: resume errored before any output (no first_token) — marking session non-resumable this run, falling back to stateless for this turn');
1793
1907
  if (grokFilePoll) {
1794
1908
  clearInterval(grokFilePoll);
@@ -1889,7 +2003,7 @@ function sendGrokPrompt(root, promptFilePath, stdoutFile, exitFile, log, opts =
1889
2003
  // prior transient hiccup stops biasing later turns.
1890
2004
  if (!_sawFirstToken && (0, grokEvents_1.scanGrokFirstToken)(ev.lines)) {
1891
2005
  _sawFirstToken = true;
1892
- if (opts.sessionId && opts.onResumeOk) {
2006
+ if ((opts.sessionId || opts.continueSession) && opts.onResumeOk) {
1893
2007
  try {
1894
2008
  opts.onResumeOk();
1895
2009
  }
@@ -1927,6 +2041,28 @@ function sendGrokPrompt(root, promptFilePath, stdoutFile, exitFile, log, opts =
1927
2041
  // instead of hanging to GROK_MAX_RUN_MS. An error/aborted outcome ends it too
1928
2042
  // (retryable), honoring scanGrokTurnEnd's classification.
1929
2043
  const te = (0, grokEvents_1.scanGrokTurnEnd)(ev.lines);
2044
+ // Capture grok's own error detail from an error turn_ended so the fast-fail
2045
+ // fallback can name WHY (session-not-found, in-use, cwd mismatch, auth, …). Only
2046
+ // when we don't already have an authoritative streaming-json error record.
2047
+ if (te === 'error' && !_lastGrokErr) {
2048
+ for (const l of ev.lines) {
2049
+ let o;
2050
+ try {
2051
+ o = typeof l === 'string' ? JSON.parse(l) : l;
2052
+ }
2053
+ catch {
2054
+ continue;
2055
+ }
2056
+ if (!o || o.type !== 'turn_ended') {
2057
+ continue;
2058
+ }
2059
+ const d = o.error?.message ?? o.error ?? o.detail ?? o.message ?? o.reason ?? o.outcome_detail;
2060
+ if (d) {
2061
+ _lastGrokErr = typeof d === 'string' ? d : JSON.stringify(d);
2062
+ break;
2063
+ }
2064
+ }
2065
+ }
1930
2066
  // Safety net against any residual offset skew: only honor a turn_ended once
1931
2067
  // THIS turn has demonstrably started in the tailed window. A turn_ended with no
1932
2068
  // preceding current-turn start is backlog (a prior errored turn read from a
@@ -2004,7 +2140,12 @@ function sendGrokPrompt(root, promptFilePath, stdoutFile, exitFile, log, opts =
2004
2140
  // counts a TRANSIENT failure (bounded counter); it does NOT permanently disable
2005
2141
  // resume — the next turn re-attempts `--resume`.
2006
2142
  _resumeAbandoned = true;
2007
- log(`Grok: headless --resume produced no output in ${Math.round(_probeWaitedMs / 1000)}s (${_probeRound} extension${_probeRound === 1 ? '' : 's'}) — stateless for this turn only (stored session id kept; resume retried next turn)`);
2143
+ const how = opts.continueSession ? '--continue' : '--resume';
2144
+ const diag = grokFailDiag();
2145
+ if (diag) {
2146
+ log(`Grok: ${how} failed — grok said: "${diag}"`);
2147
+ }
2148
+ log(`Grok: headless ${how} produced no output in ${Math.round(_probeWaitedMs / 1000)}s (${_probeRound} extension${_probeRound === 1 ? '' : 's'}) — stateless for this turn only (stored session id kept; resume retried next turn)`);
2008
2149
  if (grokFilePoll) {
2009
2150
  clearInterval(grokFilePoll);
2010
2151
  }
@@ -2060,6 +2201,7 @@ function sendGrokPrompt(root, promptFilePath, stdoutFile, exitFile, log, opts =
2060
2201
  }
2061
2202
  else if (type === 'error') {
2062
2203
  const errMsg = msg.message ?? JSON.stringify(msg);
2204
+ _lastGrokErr = errMsg; // authoritative diagnostic for a fast resume/continue failure
2063
2205
  log(`Grok error: ${errMsg}`);
2064
2206
  try {
2065
2207
  fs.appendFileSync(stdoutFile, `\n[Grok error: ${errMsg}]\n`, 'utf8');
@@ -2080,6 +2222,7 @@ function sendGrokPrompt(root, promptFilePath, stdoutFile, exitFile, log, opts =
2080
2222
  if (!text) {
2081
2223
  return;
2082
2224
  }
2225
+ _lastGrokStderr = text; // fallback diagnostic for a fast resume/continue failure
2083
2226
  log(`Grok stderr: ${text}`);
2084
2227
  if (GROK_MAX_TOOL_ERRORS > 0 && text.includes('tool_output_error')) {
2085
2228
  toolErrorCount++;
@@ -2122,6 +2265,17 @@ function sendGrokPrompt(root, promptFilePath, stdoutFile, exitFile, log, opts =
2122
2265
  // A session-log turn_ended already reported the true outcome via _forcedExit;
2123
2266
  // don't let the SIGKILL exit code (1) override a clean finish.
2124
2267
  const exitCode = _forcedExit ?? code ?? 1;
2268
+ // A resume/continue that FAILED FAST by exiting non-zero directly (grok printed to
2269
+ // stderr and quit, e.g. "Session <id> not found locally … 404 Not Found") never
2270
+ // wrote a session-log turn_ended, so it bypassed finishFromTurnEnd's diagnostic.
2271
+ // Surface grok's captured reason here too so this failure shape is not a blind guess.
2272
+ if (exitCode !== 0 && !_sawFirstToken && (opts.sessionId || opts.continueSession)) {
2273
+ const how = opts.continueSession ? '--continue' : '--resume';
2274
+ const diag = grokFailDiag();
2275
+ if (diag) {
2276
+ log(`Grok: ${how} failed fast (exit ${exitCode}) — grok said: "${diag}"`);
2277
+ }
2278
+ }
2125
2279
  log(`Grok: exited (code=${exitCode})`);
2126
2280
  if (!_endSeen) {
2127
2281
  _emitGrokHook(root, exitCode === 0 ? 'Stop' : 'StopFailure', { exit_code: exitCode });
@@ -2142,8 +2296,154 @@ function sendGrokPrompt(root, promptFilePath, stdoutFile, exitFile, log, opts =
2142
2296
  * to a stateless relaunch instead of hanging the loop.
2143
2297
  */
2144
2298
  function sendGrokCliPrompt(root, promptFilePath, resolvedSessionId, stdoutFile, exitFile, log, model, showOutput) {
2299
+ if (grokAcpEnabled()) {
2300
+ sendGrokAcpPrompt(root, promptFilePath, resolvedSessionId, stdoutFile, exitFile, log, 'grok-cli', model, showOutput);
2301
+ return;
2302
+ }
2145
2303
  sendGrokPersistentHeadless(root, promptFilePath, resolvedSessionId, stdoutFile, exitFile, log, 'grok-cli', model, showOutput);
2146
2304
  }
2305
+ // ---------------------------------------------------------------------------
2306
+ // PERSISTENT-PROCESS (ACP) path — one long-lived `grok agent stdio` per root.
2307
+ // ---------------------------------------------------------------------------
2308
+ /** Get (or lazily create) the persistent ACP session for a root. */
2309
+ function getOrCreateAcpSession(root, providerId, model, log, resolvedSessionId) {
2310
+ let sess = _acpSessions.get(root);
2311
+ if (sess) {
2312
+ return sess;
2313
+ }
2314
+ // Resume-on-(re)start: seed the id to session/load from the persisted id, else
2315
+ // the newest grok session on disk for this cwd (works whether that session was
2316
+ // created by a prior ACP run or the legacy headless `--continue`).
2317
+ const seedId = resolvedSessionId || (0, sessionState_1.getSessionId)(root, providerId) || (0, sessions_1.getGrokLatestSessionId)(root) || undefined;
2318
+ const hooks = {
2319
+ emitHook: (name, extra) => {
2320
+ if (name === 'Notification' && typeof extra['message'] === 'string') {
2321
+ const msg = (0, redactSecrets_1.redactSecrets)(String(extra['message'])).slice(0, 1200);
2322
+ if (!msg.trim()) {
2323
+ return;
2324
+ }
2325
+ _emitGrokHook(root, 'Notification', { ...extra, message: msg });
2326
+ }
2327
+ else {
2328
+ _emitGrokHook(root, name, (0, redactSecrets_1.redactDeep)(extra));
2329
+ }
2330
+ },
2331
+ log,
2332
+ };
2333
+ sess = new GrokAcpSession_1.GrokAcpSession({ root, model, bin: GROK_BIN, hooks, initialSessionId: seedId });
2334
+ _acpSessions.set(root, sess);
2335
+ return sess;
2336
+ }
2337
+ /**
2338
+ * Fire-and-forget: run ONE turn through the persistent ACP session. Streams
2339
+ * assistant text to stdoutFile and writes the exit code to exitFile exactly once
2340
+ * at turn end — preserving the taskLoop contract byte-for-byte. The grok process
2341
+ * STAYS ALIVE between turns; a crash/exit is detected and the NEXT turn respawns
2342
+ * + session/load restores context. If the ACP handshake can't be established
2343
+ * (older grok, or a transient spawn failure) it transparently FALLS BACK to the
2344
+ * legacy one-shot headless path so nothing regresses.
2345
+ */
2346
+ function sendGrokAcpPrompt(root, promptFilePath, resolvedSessionId, stdoutFile, exitFile, log, providerId, model, showOutput) {
2347
+ try {
2348
+ fs.writeFileSync(stdoutFile, '', 'utf8');
2349
+ }
2350
+ catch { /* ignore */ }
2351
+ try {
2352
+ fs.writeFileSync(exitFile, '', 'utf8');
2353
+ }
2354
+ catch { /* ignore */ }
2355
+ let promptText = '';
2356
+ try {
2357
+ promptText = fs.readFileSync(promptFilePath, 'utf8');
2358
+ }
2359
+ catch (err) {
2360
+ try {
2361
+ fs.appendFileSync(stdoutFile, `\n[Grok ACP: cannot read prompt file: ${err?.message ?? err}]\n`, 'utf8');
2362
+ }
2363
+ catch { /* ignore */ }
2364
+ try {
2365
+ fs.writeFileSync(exitFile, '1\n', 'utf8');
2366
+ }
2367
+ catch { /* ignore */ }
2368
+ return;
2369
+ }
2370
+ const sess = getOrCreateAcpSession(root, providerId, model, log, resolvedSessionId);
2371
+ _busyRoots.add(root);
2372
+ if (showOutput) {
2373
+ try {
2374
+ showOutput();
2375
+ }
2376
+ catch { /* ignore */ }
2377
+ }
2378
+ const sink = (text) => { try {
2379
+ fs.appendFileSync(stdoutFile, text, 'utf8');
2380
+ }
2381
+ catch { /* ignore */ } };
2382
+ void (async () => {
2383
+ try {
2384
+ await sess.ensureReady();
2385
+ }
2386
+ catch (err) {
2387
+ // ACP unavailable / handshake failed → clean fallback to the one-shot path
2388
+ // (which writes its own stdout/exit + hooks). Nothing regresses.
2389
+ log(`Grok ACP: startup failed (${err?.message ?? err}); stderr="${sess.stderrSnapshot.slice(0, 200)}" — falling back to headless one-shot`);
2390
+ try {
2391
+ sess.close();
2392
+ }
2393
+ catch { /* ignore */ }
2394
+ _acpSessions.delete(root);
2395
+ _busyRoots.delete(root);
2396
+ sendGrokPersistentHeadless(root, promptFilePath, resolvedSessionId, stdoutFile, exitFile, log, providerId, model, showOutput);
2397
+ return;
2398
+ }
2399
+ // Persist the session id so a restart can session/load it (resume-on-restart).
2400
+ if (sess.sessionId) {
2401
+ try {
2402
+ (0, sessionState_1.saveSessionId)(root, providerId, sess.sessionId);
2403
+ }
2404
+ catch { /* ignore */ }
2405
+ }
2406
+ let res;
2407
+ try {
2408
+ res = await sess.runTurn(promptText, sink);
2409
+ }
2410
+ catch (err) {
2411
+ res = { clean: false, stopReason: 'exception', error: err?.message ?? String(err) };
2412
+ }
2413
+ if (!res.clean && res.error) {
2414
+ try {
2415
+ fs.appendFileSync(stdoutFile, `\n[Grok ACP: turn ended ${res.stopReason} — ${res.error}]\n`, 'utf8');
2416
+ }
2417
+ catch { /* ignore */ }
2418
+ }
2419
+ try {
2420
+ fs.writeFileSync(exitFile, `${res.clean ? 0 : 1}\n`, 'utf8');
2421
+ }
2422
+ catch { /* ignore */ }
2423
+ _busyRoots.delete(root);
2424
+ })();
2425
+ }
2426
+ /**
2427
+ * Deliver a mid-run steer to the LIVE persistent grok process (a queued
2428
+ * session/prompt). Returns true only when a live ACP session accepted it; false
2429
+ * lets the caller fall back to the durable TODO buffer (at-least-once).
2430
+ */
2431
+ function steerGrokAcp(root, text, log) {
2432
+ const sess = _acpSessions.get(root);
2433
+ if (!sess || !sess.isAlive()) {
2434
+ return false;
2435
+ }
2436
+ const ok = sess.steer(text);
2437
+ if (ok) {
2438
+ log(`Grok ACP: steered live process (${text.length} chars) — queued to the running session`);
2439
+ }
2440
+ return ok;
2441
+ }
2442
+ /** True if a persistent ACP session for this root is mid-turn. */
2443
+ function isGrokAcpBusy(root) {
2444
+ const sess = _acpSessions.get(root);
2445
+ return !!sess && sess.isBusy();
2446
+ }
2147
2447
  /**
2148
2448
  * grok-tui: persistent — run the turn in the per-workspace tmux session so
2149
2449
  * grok's in-process context accumulates across tasks. Falls back to the old
@@ -2156,7 +2456,7 @@ function sendGrokCliPrompt(root, promptFilePath, resolvedSessionId, stdoutFile,
2156
2456
  *
2157
2457
  * - grok-tui + tmux → useTmux: true (persistent live pane; context accretes)
2158
2458
  * - grok-tui + no tmux → useTmux: false (HEADLESS path — persistence handled there
2159
- * via `grok --resume` behind the first_token hang-probe; see decideHeadlessGrokMode)
2459
+ * via `grok --continue` behind the first_token hang-probe; see decideHeadlessGrokMode)
2160
2460
  * - grok-cli → useTmux: false always (never uses tmux)
2161
2461
  */
2162
2462
  function decideGrokRunMode(opts) {
@@ -2168,27 +2468,32 @@ function decideGrokRunMode(opts) {
2168
2468
  * unit-testable. The `maxConsecutiveFailures` guard REPLACES the old permanent
2169
2469
  * "resumeBroken" flag that doomed a whole run to statelessness after one hiccup.
2170
2470
  *
2171
- * - no stored session id → 'new-persistent': there is
2172
- * no id to reuse, so bootstrap persistence with a fresh named session
2173
- * (`--session-id`, mints + persists a UUID; NOT `--resume`, so it can't hit a
2174
- * resume-hang), behind the probe as a safety net.
2175
- * - a stored id, failures < max → 'resume': attempt persistent
2176
- * `grok --resume <id>` (the reliable, near-instant path on grok 1.0.13), gated by
2177
- * the first_token hang-probe. This is the STEADY STATE — every turn resumes.
2178
- * - a stored id, failures >= max (sustained resume hang) → 'stateless' for exactly ONE
2471
+ * - no folder session yet → 'new-session': there is no
2472
+ * session on disk for this cwd to continue, so bootstrap persistence with a fresh
2473
+ * named session (`--session-id`, mints + persists a UUID; NOT `--continue`, which
2474
+ * would have nothing to continue), behind the probe as a safety net.
2475
+ * - a folder session exists, failures < max → 'continue': resume the cwd's
2476
+ * most-recent session with `grok --continue` — robust to the stale-id / cwd-tracking
2477
+ * drift that made an explicit `--resume <id>` error in ~1s on Windows. Gated by the
2478
+ * first_token hang-probe. This is the STEADY STATE — every turn continues.
2479
+ * - a folder session, failures >= max (sustained hang) → 'stateless' for exactly ONE
2179
2480
  * breather turn (skip the probe cost once); the caller resets the counter so the
2180
- * NEXT turn returns to 'resume'. The stored id is preserved throughout, so resume
2181
- * is NEVER permanently abandoned — persistence self-heals.
2481
+ * NEXT turn returns to 'continue'. The folder session is untouched, so continuity is
2482
+ * NEVER permanently abandoned — persistence self-heals.
2483
+ *
2484
+ * NOTE: the decision keys off FOLDER-SESSION PRESENCE (newestGrokSessionDir exists),
2485
+ * NOT a stored id — because `--continue` needs no id, and keying off a stored id is
2486
+ * exactly what let a stale/mismatched id drive a doomed `--resume`.
2182
2487
  */
2183
2488
  function decideHeadlessGrokMode(args) {
2184
- if (!args.hasSessionId) {
2185
- return 'new-persistent';
2489
+ if (!args.hasFolderSession) {
2490
+ return 'new-session';
2186
2491
  }
2187
2492
  const max = args.maxConsecutiveFailures ?? RESUME_MAX_CONSEC_FAILURES;
2188
2493
  if (args.consecutiveResumeFailures >= max) {
2189
2494
  return 'stateless';
2190
2495
  }
2191
- return 'resume';
2496
+ return 'continue';
2192
2497
  }
2193
2498
  /**
2194
2499
  * Pure verdict for the headless resume hang-probe (multi-round exponential, 1.4.185).
@@ -2237,6 +2542,12 @@ function resumeProbeVerdict(args) {
2237
2542
  return 'wait';
2238
2543
  }
2239
2544
  function sendGrokTuiPrompt(root, promptFilePath, resolvedSessionId, stdoutFile, exitFile, log, model, showOutput) {
2545
+ // Preferred persistent model: ONE long-lived `grok agent stdio` (ACP). Cleaner
2546
+ // than the tmux PTY (no pane-scraping, real steering, unified with grok-cli).
2547
+ if (grokAcpEnabled()) {
2548
+ sendGrokAcpPrompt(root, promptFilePath, resolvedSessionId, stdoutFile, exitFile, log, 'grok-tui', model, showOutput);
2549
+ return;
2550
+ }
2240
2551
  const mode = decideGrokRunMode({ tmuxAvailable: tmuxAvailable(), providerId: 'grok-tui' });
2241
2552
  if (mode.useTmux) {
2242
2553
  runGrokTmuxTurn(root, promptFilePath, resolvedSessionId, stdoutFile, exitFile, log, model, showOutput);
@@ -2247,14 +2558,16 @@ function sendGrokTuiPrompt(root, promptFilePath, resolvedSessionId, stdoutFile,
2247
2558
  }
2248
2559
  /**
2249
2560
  * The persistent HEADLESS grok launch, shared by grok-cli and headless grok-tui.
2250
- * We want PERSISTENCE across turns, so attempt grok's persistent session
2251
- * (`--resume` when a session id exists). grok 1.0.41's headless `--resume` can hang
2252
- * the resumed turn (loaded-but-never-answers) → the 10-min watchdog, so every
2253
- * persistent launch is gated behind a FAST first_token hang-probe: if grok begins
2254
- * answering within AUTODEV_GROK_RESUME_PROBE_MS the turn runs normally (persistent,
2255
- * context intact); if not, sendGrokPrompt kills it and calls onResumeHang to relaunch
2256
- * THIS turn stateless. A resume failure is COUNTED (bounded, NOT a permanent poison):
2257
- * the STORED session id is preserved and the NEXT turn re-attempts `--resume`, so a
2561
+ * We want PERSISTENCE across turns, so when the cwd already has a grok session on disk
2562
+ * we resume the MOST RECENT one with `grok --continue` (robust to the stale-id /
2563
+ * cwd-tracking drift that made an explicit `--resume <id>` error in ~1s on Windows and
2564
+ * forced every turn stateless). grok's headless resume can also hang a resumed turn
2565
+ * (loaded-but-never-answers) → the watchdog, so every persistent launch is gated behind
2566
+ * a FAST first_token hang-probe: if grok begins answering within
2567
+ * AUTODEV_GROK_RESUME_PROBE_MS the turn runs normally (persistent, context intact); if
2568
+ * not, sendGrokPrompt kills it and calls onResumeHang to relaunch THIS turn stateless. A
2569
+ * failure is COUNTED (bounded, NOT a permanent poison): the folder session is preserved
2570
+ * and the NEXT turn re-attempts `--continue`, so a
2258
2571
  * transient hiccup can't strand the run in per-turn statelessness (the pre-1.4.184 bug).
2259
2572
  * Only after RESUME_MAX_CONSEC_FAILURES straight failures does a single breather turn
2260
2573
  * skip resume, after which resume is tried again. The stateless path stays intact as the
@@ -2264,6 +2577,11 @@ function sendGrokTuiPrompt(root, promptFilePath, resolvedSessionId, stdoutFile,
2264
2577
  function sendGrokPersistentHeadless(root, promptFilePath, resolvedSessionId, stdoutFile, exitFile, log, providerId, model, showOutput) {
2265
2578
  const label = providerId === 'grok-cli' ? 'Grok CLI' : 'Grok TUI';
2266
2579
  const PROBE_MS = envNum('AUTODEV_GROK_RESUME_PROBE_MS', 90_000);
2580
+ // Continuity is decided by whether grok has a session on disk for THIS cwd — the same
2581
+ // signal `grok --continue` itself uses — NOT a stored id (a stale/mismatched id was the
2582
+ // root cause of the instant `--resume <id>` error). resolvedSessionId/getSessionId are
2583
+ // kept only for logging + best-effort id display.
2584
+ const hasFolderSession = !!(0, sessions_1.newestGrokSessionDir)(path.resolve(root));
2267
2585
  const persistedSid = resolvedSessionId || (0, sessionState_1.getSessionId)(root, providerId);
2268
2586
  // Stateless relaunch: grok mints a throwaway id for THIS turn. persist:false means
2269
2587
  // it never overwrites the STORED session id in session-state.json → the next turn
@@ -2285,27 +2603,28 @@ function sendGrokPersistentHeadless(root, promptFilePath, resolvedSessionId, std
2285
2603
  // Clear the failure counter the instant a resumed turn starts answering.
2286
2604
  const onResumeOk = () => { _noteResumeSuccess(root); };
2287
2605
  const decision = decideHeadlessGrokMode({
2288
- hasSessionId: !!persistedSid, consecutiveResumeFailures: _resumeFailCount(root),
2606
+ hasFolderSession, consecutiveResumeFailures: _resumeFailCount(root),
2289
2607
  });
2290
2608
  if (decision === 'stateless') {
2291
- // Sustained resume failures → ONE breather turn (skip the probe cost), then the
2292
- // counter resets so the NEXT turn resumes again. Persistence is never abandoned.
2293
- log(`${label}: --resume failed ${_resumeFailCount(root)}× in a row — one stateless breather turn, will retry --resume next turn`);
2294
- _noteResumeSuccess(root); // reset so the next turn returns to 'resume'
2609
+ // Sustained continue failures → ONE breather turn (skip the probe cost), then the
2610
+ // counter resets so the NEXT turn continues again. Persistence is never abandoned.
2611
+ log(`${label}: --continue failed ${_resumeFailCount(root)}× in a row — one stateless breather turn, will retry --continue next turn`);
2612
+ _noteResumeSuccess(root); // reset so the next turn returns to 'continue'
2295
2613
  runStateless();
2296
2614
  return;
2297
2615
  }
2298
- if (decision === 'resume') {
2299
- log(`${label}: resuming session ${persistedSid.slice(0, 8)}… (headless)`);
2616
+ if (decision === 'continue') {
2617
+ const idNote = persistedSid ? ` (${persistedSid.slice(0, 8)}…)` : '';
2618
+ log(`${label}: continuing most-recent session for cwd${idNote} (headless --continue)`);
2300
2619
  sendGrokPrompt(root, promptFilePath, stdoutFile, exitFile, log, {
2301
- model, showOutput, persist: true, sessionId: persistedSid, providerId,
2620
+ model, showOutput, persist: true, continueSession: true, providerId,
2302
2621
  resumeProbeMs: PROBE_MS, onResumeHang, onResumeOk,
2303
2622
  });
2304
2623
  return;
2305
2624
  }
2306
- // new-persistent — no stored id yet: bootstrap a fresh named session so later turns
2307
- // have an id to resume. A fresh `--session-id` isn't a `--resume`, but keep the
2308
- // probe as a belt-and-braces safety net (and it can never exceed the watchdog).
2625
+ // new-session — no folder session yet: bootstrap a fresh named session so later turns
2626
+ // have one to `--continue`. A fresh `--session-id` isn't a resume, but keep the probe
2627
+ // as a belt-and-braces safety net (and it can never exceed the watchdog).
2309
2628
  log(`${label}: starting a new persistent session (headless)`);
2310
2629
  sendGrokPrompt(root, promptFilePath, stdoutFile, exitFile, log, {
2311
2630
  model, showOutput, persist: true, providerId,
@@ -2363,6 +2682,22 @@ async function steerGrokTui(root, text, log) {
2363
2682
  * persisted session id so a later task relaunches with --resume (context intact).
2364
2683
  */
2365
2684
  function closeGrokTuiSession(root, _log) {
2685
+ const acp = _acpSessions.get(root);
2686
+ if (acp) {
2687
+ // Reap grok's whole worker subtree (grok forks a per-turn worker child), then
2688
+ // close — SIGKILL of just the parent would orphan the worker to init.
2689
+ if (acp.pid) {
2690
+ try {
2691
+ (0, killProcessTree_1.killProcessTree)(acp.pid);
2692
+ }
2693
+ catch { /* ignore */ }
2694
+ }
2695
+ try {
2696
+ acp.close();
2697
+ }
2698
+ catch { /* ignore */ }
2699
+ _acpSessions.delete(root);
2700
+ }
2366
2701
  const child = _activeChildren.get(root);
2367
2702
  if (child) {
2368
2703
  if (child.pid) {
@@ -2394,6 +2729,19 @@ function closeGrokTuiSession(root, _log) {
2394
2729
  }
2395
2730
  /** Kill all live grok sessions — called on SDK/extension shutdown. */
2396
2731
  function closeAllGrokTuiSessions() {
2732
+ for (const acp of _acpSessions.values()) {
2733
+ if (acp.pid) {
2734
+ try {
2735
+ (0, killProcessTree_1.killProcessTree)(acp.pid);
2736
+ }
2737
+ catch { /* ignore */ }
2738
+ }
2739
+ try {
2740
+ acp.close();
2741
+ }
2742
+ catch { /* ignore */ }
2743
+ }
2744
+ _acpSessions.clear();
2397
2745
  for (const child of _activeChildren.values()) {
2398
2746
  if (child.pid) {
2399
2747
  (0, killProcessTree_1.killProcessTree)(child.pid);