autodev-cli 1.4.66 → 1.4.68

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.
@@ -1,25 +1,40 @@
1
1
  "use strict";
2
2
  // ---------------------------------------------------------------------------
3
- // grokProvider -- Grok tasks via the `grok` headless CLI. Backs BOTH provider
4
- // variants from one shared runner:
3
+ // grokProvider -- Grok tasks via the `grok` CLI. Backs BOTH provider variants:
5
4
  //
6
- // grok-cli (stateless): fresh process each task, no session flags — no
7
- // context accumulation across tasks.
8
- // grok-tui (persistent): resumes ONE session per workspace so context
9
- // carries across tasks, like an interactive session.
10
- // First task: `--session-id <uuid>` (created + saved
11
- // to session-state.json); later tasks: `--resume <id>`.
5
+ // grok-cli (stateless): fresh HEADLESS process each task (--prompt-file),
6
+ // no context accumulation across tasks.
7
+ // grok-tui (persistent): ONE long-lived interactive `grok` process kept alive
8
+ // inside a per-workspace tmux session (a real PTY), so
9
+ // grok's in-process context accumulates across tasks —
10
+ // exactly like a human-driven interactive session.
12
11
  //
13
- // Command:
14
- // grok -m <model> --always-approve --cwd <root>
15
- // [--session-id <uuid> | --resume <uuid>] (grok-tui only)
16
- // --prompt-file <file> --output-format streaming-json
12
+ // -------------------------------------------------------------------------
13
+ // Why tmux for grok-tui?
14
+ // -------------------------------------------------------------------------
15
+ // grok is an interactive terminal UI. The OLD grok-tui ran it HEADLESS with
16
+ // piped, non-TTY stdio (`--prompt-file --output-format streaming-json`, stdin
17
+ // ignored). Under load it would finish producing output but never terminate the
18
+ // process (no controlling TTY to close on, an internal render/input wait, or a
19
+ // tool it retried forever). `close` never fired → the per-message exit file was
20
+ // never written → the task loop blocked until its 30 s sentinel / the 10 min
21
+ // watchdog, and the turn surfaced as a StopFailure. The wedge root-cause was the
22
+ // no-TTY non-exit.
17
23
  //
18
- // streaming-json lines are parsed; assistant text chunks are appended to
19
- // stdoutFile. exitFile is written when the process exits.
24
+ // The tmux reimplementation gives grok a real PTY. One detached tmux session per
25
+ // workspace runs `grok --no-alt-screen --always-approve` interactively; each
26
+ // task PASTES its prompt into the live pane and detects turn-end by output
27
+ // quiescence (recipe below). Context lives in the one long-running grok process
28
+ // — the live session IS the resumed context, so we no longer `--resume` per task.
20
29
  //
21
- // Model: none is forced grok uses the account's own default. Set an explicit,
22
- // VALID model via settings.grokModel to override (`grok models` lists them).
30
+ // Contract preserved for taskLoop (unchanged): stream assistant/pane text to the
31
+ // SAME per-message stdoutFile; write the exit code to the SAME per-message
32
+ // exitFile exactly ONCE, only when the turn genuinely ends. Session id is still
33
+ // minted-once and persisted under getSessionId/saveSessionId('grok-tui') so the
34
+ // office/loop see continuity and a dead session can be relaunched with --resume.
35
+ //
36
+ // If tmux is not installed, grok-tui transparently FALLS BACK to the old
37
+ // headless spawn so nothing regresses on boxes without tmux.
23
38
  // ---------------------------------------------------------------------------
24
39
  var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
25
40
  if (k2 === undefined) k2 = k;
@@ -56,14 +71,20 @@ var __importStar = (this && this.__importStar) || (function () {
56
71
  })();
57
72
  Object.defineProperty(exports, "__esModule", { value: true });
58
73
  exports.isGrokTuiBusy = isGrokTuiBusy;
74
+ exports.getGrokTuiLastActivity = getGrokTuiLastActivity;
75
+ exports.forceIdleGrokTui = forceIdleGrokTui;
76
+ exports.tmuxAvailable = tmuxAvailable;
59
77
  exports.sendGrokPrompt = sendGrokPrompt;
60
78
  exports.sendGrokCliPrompt = sendGrokCliPrompt;
61
79
  exports.sendGrokTuiPrompt = sendGrokTuiPrompt;
80
+ exports.steerGrokTui = steerGrokTui;
62
81
  exports.closeGrokTuiSession = closeGrokTuiSession;
63
82
  exports.closeAllGrokTuiSessions = closeAllGrokTuiSessions;
64
83
  exports.detectGrokTuiRateLimit = detectGrokTuiRateLimit;
65
84
  const path = __importStar(require("path"));
66
85
  const fs = __importStar(require("fs"));
86
+ const os = __importStar(require("os"));
87
+ const crypto = __importStar(require("crypto"));
67
88
  const readline = __importStar(require("readline"));
68
89
  const child_process = __importStar(require("child_process"));
69
90
  const crypto_1 = require("crypto");
@@ -78,33 +99,47 @@ const GROK_BIN = process.env['GROK_BIN'] ?? (() => {
78
99
  // Prefer a grok on PATH; otherwise fall back to the default install location
79
100
  // (~/.grok/bin/grok) so the provider works without PATH changes.
80
101
  try {
81
- const home = require('os').homedir();
82
- const local = require('path').join(home, '.grok', 'bin', 'grok');
83
- if (require('fs').existsSync(local)) {
102
+ const home = os.homedir();
103
+ const local = path.join(home, '.grok', 'bin', 'grok');
104
+ if (fs.existsSync(local)) {
84
105
  return local;
85
106
  }
86
107
  }
87
108
  catch { /* ignore */ }
88
109
  return 'grok';
89
110
  })();
111
+ /** tmux binary — overridable for non-standard installs. */
112
+ const TMUX_BIN = process.env['TMUX_BIN'] ?? 'tmux';
113
+ // Env-tunable knobs. `envNum` accepts any finite >= 0; 0 disables that guard.
114
+ const envNum = (key, def) => {
115
+ const n = Number(process.env[key]);
116
+ return Number.isFinite(n) && n >= 0 ? n : def;
117
+ };
90
118
  // ---------------------------------------------------------------------------
91
119
  // Per-workspace state
92
120
  // ---------------------------------------------------------------------------
93
- /** Roots with an actively-running grok turn. */
121
+ /** Roots with an actively-running grok turn (headless OR tmux). */
94
122
  const _busyRoots = new Set();
95
- /** Active child processes by root — used to kill them on extension deactivate. */
123
+ /** Active HEADLESS child processes by root — grok-cli + tmux-unavailable fallback. */
96
124
  const _activeChildren = new Map();
125
+ const _tmuxSessions = new Map();
126
+ /** Epoch-ms of the last streamed pane growth per root (activity heartbeat). */
127
+ const _lastActivityMs = new Map();
97
128
  /** True while a grok turn is running for the given workspace root. */
98
129
  function isGrokTuiBusy(root) {
99
130
  return _busyRoots.has(root);
100
131
  }
132
+ /** Epoch-ms of the most recent streamed pane activity, or 0 if none. */
133
+ function getGrokTuiLastActivity(root) {
134
+ return _lastActivityMs.get(root) ?? 0;
135
+ }
136
+ /** Force-clear the busy flag for a root whose turn appears hung. */
137
+ function forceIdleGrokTui(root) {
138
+ _busyRoots.delete(root);
139
+ }
101
140
  /**
102
141
  * Append a REAL grok activity event to `.autodev/hooks-events.jsonl` in the
103
- * native Claude-Code hook schema (pixel-office reads `hook_event_name`). Grok
104
- * has no hooks mechanism, but its `--output-format streaming-json` stream
105
- * carries tool_use / tool_result events — we translate those into PreToolUse /
106
- * PostToolUse so pixel-office shows real per-tool activity (no synthetic
107
- * SessionStart/End padding beyond the genuine turn boundaries).
142
+ * native Claude-Code hook schema (pixel-office reads `hook_event_name`).
108
143
  */
109
144
  function _emitGrokHook(root, hookEventName, extra = {}) {
110
145
  try {
@@ -112,29 +147,522 @@ function _emitGrokHook(root, hookEventName, extra = {}) {
112
147
  if (!fs.existsSync(dir)) {
113
148
  fs.mkdirSync(dir, { recursive: true });
114
149
  }
115
- // Emit the canonical event_type too (grok writes hooks directly, bypassing
116
- // normalizeEvent) so pixel-office can trust it without a provider map.
117
150
  const ev = { hook_event_name: hookEventName, event_type: (0, hookEventNormalizer_1.eventTypeFor)(hookEventName), provider: 'grok-tui', cwd: root, timestamp: new Date().toISOString(), ...extra };
118
151
  fs.appendFileSync(path.join(dir, 'hooks-events.jsonl'), JSON.stringify(ev) + '\n', 'utf8');
119
152
  }
120
153
  catch { /* non-critical */ }
121
154
  }
155
+ // ---------------------------------------------------------------------------
156
+ // tmux helpers — all synchronous, best-effort (swallow + report).
157
+ // ---------------------------------------------------------------------------
158
+ let _tmuxAvailable = null;
159
+ /** True if tmux is installed and runnable. Cached after first probe. */
160
+ function tmuxAvailable() {
161
+ if (_tmuxAvailable !== null) {
162
+ return _tmuxAvailable;
163
+ }
164
+ try {
165
+ child_process.execFileSync(TMUX_BIN, ['-V'], { stdio: 'ignore', timeout: 5_000 });
166
+ _tmuxAvailable = true;
167
+ }
168
+ catch {
169
+ _tmuxAvailable = false;
170
+ }
171
+ return _tmuxAvailable;
172
+ }
173
+ /** Run a tmux subcommand; returns stdout string, or null on any failure. */
174
+ function tmux(args, input) {
175
+ try {
176
+ const out = child_process.execFileSync(TMUX_BIN, args, {
177
+ encoding: 'utf8', timeout: 15_000, input, stdio: ['pipe', 'pipe', 'ignore'],
178
+ });
179
+ return out ?? '';
180
+ }
181
+ catch {
182
+ return null;
183
+ }
184
+ }
185
+ /** Deterministic, tmux-safe session name from the absolute workspace path. */
186
+ function sessionName(root) {
187
+ const h = crypto.createHash('sha1').update(root).digest('hex').slice(0, 10);
188
+ return `grok-${h}`;
189
+ }
190
+ /** True if the tmux session exists (a dead grok collapses the session). */
191
+ function hasSession(name) {
192
+ try {
193
+ child_process.execFileSync(TMUX_BIN, ['has-session', '-t', name], { stdio: 'ignore', timeout: 5_000 });
194
+ return true;
195
+ }
196
+ catch {
197
+ return false;
198
+ }
199
+ }
200
+ /** Kill a tmux session (best-effort). */
201
+ function killSession(name) {
202
+ tmux(['kill-session', '-t', name]);
203
+ }
204
+ /** Escape-stripped visible grid + scrollback of a pane (for detection). */
205
+ function capturePane(name, scrollback = 200) {
206
+ const out = tmux(['capture-pane', '-p', '-t', name, '-S', `-${scrollback}`]);
207
+ return out === null ? '' : stripAnsi(out);
208
+ }
209
+ /** Shell-quote a single argument for a `send-keys "exec …"` command string. */
210
+ function shq(s) {
211
+ return `'${s.replace(/'/g, `'\\''`)}'`;
212
+ }
213
+ /**
214
+ * Strip ANSI/OSC/DCS escapes and carriage returns from raw pane bytes.
215
+ * `pipe-pane` captures the full TUI byte stream (SGR colour, mouse tracking,
216
+ * bracketed-paste markers, OSC titles, `\r`), which must be removed before the
217
+ * text is written to stdoutFile or scanned for banners.
218
+ */
219
+ function stripAnsi(s) {
220
+ return s
221
+ // CSI sequences: ESC [ ... final-byte
222
+ .replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '')
223
+ // OSC sequences: ESC ] ... (BEL | ST)
224
+ .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '')
225
+ // DCS/PM/APC: ESC (P|X|^|_) ... ST
226
+ .replace(/\x1b[PX^_][^\x1b]*\x1b\\/g, '')
227
+ // Any other single-char escape
228
+ .replace(/\x1b[@-Z\\-_]/g, '')
229
+ .replace(/\r/g, '');
230
+ }
231
+ // ---------------------------------------------------------------------------
232
+ // Turn-end / auth detectors (calibrate against a live authed grok — see risks).
233
+ // ---------------------------------------------------------------------------
234
+ /**
235
+ * Markers that mean grok hit the OAuth/expired-token gate instead of a prompt.
236
+ * The loop must treat this as reauth, NOT a hung turn.
237
+ */
238
+ const REAUTH_MARKERS = /waiting for approval|approve in your browser|sign(?:ing)? in to grok|finish signing in|device code|please (?:log ?in|sign ?in)/i;
239
+ /**
240
+ * "grok finished this turn" affordance — CALIBRATED against grok 4.5 build 0.2.93:
241
+ * a completed turn prints "Worked for 4.1s." (a summary line) and the footer drops
242
+ * the cancel affordance. Used as a fast, positive turn-end confirmation.
243
+ */
244
+ const DONE_MARKER = /worked for\s+[\d.]+\s*s\b/i;
245
+ /**
246
+ * "grok is actively working" affordance. grok renders a live spinner + an
247
+ * "esc to interrupt"-style footer while a turn runs. If present we never
248
+ * declare idle. This is a SOFT guard: the primary idle signal is output
249
+ * quiescence + a byte-identical pane snapshot across polls (a running spinner
250
+ * animates, so the snapshot is never stable while grok works), which fires
251
+ * cleanly even if this pattern never matches on a given grok build.
252
+ */
253
+ // CALIBRATED against real grok 4.5 (build 0.2.93): while a turn runs the footer
254
+ // shows "Ctrl+c:cancel" and a "⠧ Waiting for response… 1.5s … [stop]" status line;
255
+ // when idle the footer is only "Shift+Tab:mode │ Ctrl+x:shortcuts" (no Ctrl+c).
256
+ // So the presence of Ctrl+c / "waiting for response" / "[stop]" / a braille spinner
257
+ // = working. This is the primary running signal (backed by output-quiescence).
258
+ const RUNNING_INDICATOR = /ctrl\+c\s*[: ]|waiting for response|\[stop\]|esc to (?:interrupt|cancel)|⠋|⠙|⠹|⠸|⠼|⠴|⠦|⠧|⠇|⠏/i;
259
+ // ---------------------------------------------------------------------------
260
+ // grok-tui: persistent interactive session driven through tmux.
261
+ // ---------------------------------------------------------------------------
262
+ /** Launch a fresh grok process in a new detached tmux session for this root. */
263
+ function launchSession(root, sessionId, resume, model, log) {
264
+ const name = sessionName(root);
265
+ // A stale/dead session for this name must be cleared first.
266
+ if (hasSession(name)) {
267
+ killSession(name);
268
+ }
269
+ const dir = path.join(root, '.autodev', 'grok-tui');
270
+ try {
271
+ if (!fs.existsSync(dir)) {
272
+ fs.mkdirSync(dir, { recursive: true });
273
+ }
274
+ }
275
+ catch { /* ignore */ }
276
+ const rawLog = path.join(dir, 'pane.raw');
277
+ // Start each launch with a fresh raw log so readOffset math is simple.
278
+ try {
279
+ fs.writeFileSync(rawLog, '', 'utf8');
280
+ }
281
+ catch { /* ignore */ }
282
+ // Fixed geometry so wrapping/relayout never shifts detection rows; manual
283
+ // window-size so a human attaching can't reflow the pane mid-turn.
284
+ tmux(['new-session', '-d', '-s', name, '-x', '200', '-y', '50', '-c', root]);
285
+ tmux(['set-option', '-t', name, 'window-size', 'manual']);
286
+ // pipe-pane BEFORE launching grok — it only captures bytes emitted after it
287
+ // attaches (confirmed). Append mode.
288
+ tmux(['pipe-pane', '-o', '-t', name, `cat >> ${shq(rawLog)}`]);
289
+ // Build the interactive grok command. `exec` replaces the shell so a dead grok
290
+ // collapses the pane/session → cheap liveness signal via has-session.
291
+ const parts = [
292
+ 'exec', shq(GROK_BIN),
293
+ '--no-alt-screen', '--always-approve',
294
+ '--cwd', shq(root),
295
+ ];
296
+ if (model) {
297
+ parts.push('-m', shq(model));
298
+ }
299
+ if (resume) {
300
+ // Restore the accumulated conversation on relaunch.
301
+ parts.push('--resume', shq(sessionId));
302
+ }
303
+ else {
304
+ // Name a brand-new conversation with our UUID so it is addressable later.
305
+ parts.push('-s', shq(sessionId));
306
+ }
307
+ tmux(['send-keys', '-t', name, parts.join(' '), 'Enter']);
308
+ const sess = { name, rawLog, sessionId, readOffset: 0 };
309
+ _tmuxSessions.set(root, sess);
310
+ log(`Grok TUI: launched tmux session ${name} (${resume ? 'resume' : 'new'} ${sessionId.slice(0, 8)}…, model=${model || 'account default'})`);
311
+ return sess;
312
+ }
122
313
  /**
123
- * Shared runner behind both grok providers. `persist:false` (grok-cli) spawns
124
- * a stateless process; `persist:true` (grok-tui) resumes the workspace session
125
- * (or creates + saves a new one on the first task).
314
+ * Ensure a live tmux grok session exists for this root. Returns the session and
315
+ * whether it was just launched (caller applies a startup grace before pasting).
126
316
  */
127
- function sendGrokPrompt(root,
128
- /** Absolute path to the combined agent-profile + message file. */
129
- promptFilePath, stdoutFile, exitFile, log, opts = {}) {
317
+ function ensureSession(root, resolvedSessionId, model, log) {
318
+ const name = sessionName(root);
319
+ const cached = _tmuxSessions.get(root);
320
+ if (cached && cached.name === name && hasSession(name)) {
321
+ return { sess: cached, justLaunched: false }; // reuse — context is live in-process
322
+ }
323
+ // No live session. If we have a stored session id, relaunch with --resume so
324
+ // grok restores the accumulated history; otherwise mint + persist a new UUID.
325
+ let sid = resolvedSessionId || (0, sessionState_1.getSessionId)(root, 'grok-tui');
326
+ let resume = false;
327
+ if (sid) {
328
+ resume = true; // a stored id means grok already has a persisted session
329
+ }
330
+ else {
331
+ sid = (0, crypto_1.randomUUID)();
332
+ try {
333
+ (0, sessionState_1.saveSessionId)(root, 'grok-tui', sid);
334
+ }
335
+ catch { /* best effort */ }
336
+ }
337
+ const sess = launchSession(root, sid, resume, model, log);
338
+ return { sess, justLaunched: true };
339
+ }
340
+ /** Read new bytes of rawLog past `offset`; returns decoded text + new offset. */
341
+ function readFrom(file, offset) {
342
+ try {
343
+ const size = fs.statSync(file).size;
344
+ if (size <= offset) {
345
+ return { text: '', offset };
346
+ }
347
+ const len = size - offset;
348
+ const buf = Buffer.alloc(len);
349
+ const fd = fs.openSync(file, 'r');
350
+ try {
351
+ fs.readSync(fd, buf, 0, len, offset);
352
+ }
353
+ finally {
354
+ fs.closeSync(fd);
355
+ }
356
+ return { text: buf.toString('utf8'), offset: size };
357
+ }
358
+ catch {
359
+ return { text: '', offset };
360
+ }
361
+ }
362
+ /** Type a prompt into the live pane and submit it. CALIBRATED against real grok:
363
+ * a tmux bracketed paste (paste-buffer) is misread by grok's TUI as a
364
+ * worktree/directory PICKER, not chat input — only literal keystrokes land in
365
+ * the input box. grok also has no reliable "newline without submit" key over
366
+ * send-keys (Enter submits), so flatten the prompt to one logical line. */
367
+ function pastePrompt(sess, promptFilePath) {
368
+ // A full agent prompt is 10KB+ (system prompt + task + context). Typing that
369
+ // via send-keys -l is UNRELIABLE (tmux drops/garbles keys at that size —
370
+ // observed live), and a bracketed paste is misread by grok's TUI as a directory
371
+ // picker. So DON'T type the prompt: hand grok a SHORT literal instruction to
372
+ // READ the prompt file, and its file tool ingests the whole thing losslessly.
373
+ const instr = `Read the file ${promptFilePath} in full right now — it is your current task with all its context and instructions. Do exactly what it says: do the real work, then mark the task done in TODO.md. Do not ask questions; do not skip the file.`;
374
+ tmux(['send-keys', '-t', sess.name, '-l', instr]);
375
+ tmux(['send-keys', '-t', sess.name, 'Enter']);
376
+ }
377
+ // ---------------------------------------------------------------------------
378
+ // runGrokTmuxTurn — one turn against the persistent tmux session.
379
+ // Fire-and-forget: streams pane text to stdoutFile, writes exitFile at turn end.
380
+ // ---------------------------------------------------------------------------
381
+ function runGrokTmuxTurn(root, promptFilePath, resolvedSessionId, stdoutFile, exitFile, log, model, showOutput) {
382
+ showOutput?.();
383
+ try {
384
+ fs.writeFileSync(stdoutFile, '', 'utf8');
385
+ }
386
+ catch { /* ignore */ }
387
+ try {
388
+ fs.writeFileSync(exitFile, '', 'utf8');
389
+ }
390
+ catch { /* ignore */ }
391
+ _busyRoots.add(root);
392
+ const POLL_MS = envNum('AUTODEV_GROK_TUI_POLL_MS', 700);
393
+ const DEBOUNCE_MS = envNum('AUTODEV_GROK_TUI_DEBOUNCE_MS', 5_000);
394
+ // Quiescence-only fallback (no "Worked for" marker seen) needs a much longer
395
+ // quiet window so grok's early "Starting session…"/"Reading file…" pauses aren't
396
+ // misread as turn-end (observed a false ~4s finish). The "Worked for" marker is
397
+ // the fast, positive done-signal for the normal case.
398
+ const QUIET_FALLBACK_MS = envNum('AUTODEV_GROK_TUI_QUIET_FALLBACK_MS', 45_000);
399
+ const STARTUP_MS = envNum('AUTODEV_GROK_TUI_STARTUP_MS', 8_000);
400
+ const NOOUTPUT_MS = envNum('AUTODEV_GROK_TUI_NOOUTPUT_MS', 25_000);
401
+ const MAX_RUN_MS = envNum('AUTODEV_GROK_MAX_RUN_MS', 10 * 60_000);
402
+ const STABLE_POLLS = Math.max(2, envNum('AUTODEV_GROK_TUI_STABLE_POLLS', 4));
403
+ const sleep = (ms) => new Promise(r => setTimeout(r, ms));
404
+ // Line-buffer for clean console logging (grok streams mid-word).
405
+ let lineBuf = '';
406
+ const emitLines = (text) => {
407
+ lineBuf += text;
408
+ let nl;
409
+ while ((nl = lineBuf.indexOf('\n')) >= 0) {
410
+ const line = lineBuf.slice(0, nl).replace(/\s+$/, '');
411
+ lineBuf = lineBuf.slice(nl + 1);
412
+ if (line.trim()) {
413
+ log(` ${line}`);
414
+ }
415
+ }
416
+ };
417
+ const flushLine = () => {
418
+ const line = lineBuf.replace(/\s+$/, '');
419
+ lineBuf = '';
420
+ if (line.trim()) {
421
+ log(` ${line}`);
422
+ }
423
+ };
424
+ // Coalesced office activity feed (grok exposes no per-tool events here).
425
+ let activityBuf = '';
426
+ let activityTimer = null;
427
+ const flushActivity = () => {
428
+ if (activityTimer) {
429
+ clearTimeout(activityTimer);
430
+ activityTimer = null;
431
+ }
432
+ const t = activityBuf.trim();
433
+ activityBuf = '';
434
+ if (!t) {
435
+ return;
436
+ }
437
+ const preview = t.length > 280 ? t.slice(0, 277) + '…' : t;
438
+ _emitGrokHook(root, 'Notification', { message: preview, title: 'grok', tool_name: 'grok' });
439
+ };
440
+ const scheduleActivity = () => {
441
+ if (activityBuf.length >= 400) {
442
+ flushActivity();
443
+ return;
444
+ }
445
+ if (!activityTimer) {
446
+ activityTimer = setTimeout(flushActivity, 1200);
447
+ }
448
+ };
449
+ const finish = (code, reason) => {
450
+ flushLine();
451
+ flushActivity();
452
+ _busyRoots.delete(root);
453
+ log(`Grok TUI: turn ${code === 0 ? 'complete' : `ended (code=${code}, ${reason})`}`);
454
+ _emitGrokHook(root, code === 0 ? 'Stop' : 'StopFailure', { exit_code: code });
455
+ _emitGrokHook(root, 'SessionEnd', { reason: code === 0 ? 'completed' : reason });
456
+ try {
457
+ fs.writeFileSync(exitFile, `${code}\n`, 'utf8');
458
+ }
459
+ catch { /* ignore */ }
460
+ };
461
+ void (async () => {
462
+ let sess;
463
+ let justLaunched;
464
+ try {
465
+ const r = ensureSession(root, resolvedSessionId, model, log);
466
+ sess = r.sess;
467
+ justLaunched = r.justLaunched;
468
+ }
469
+ catch (err) {
470
+ const msg = err?.message ?? String(err);
471
+ log(`Grok TUI: session launch failed: ${msg}`);
472
+ try {
473
+ fs.appendFileSync(stdoutFile, `\n[Grok TUI launch error: ${msg}]\n`, 'utf8');
474
+ }
475
+ catch { /* ignore */ }
476
+ finish(1, 'launch-failed');
477
+ return;
478
+ }
479
+ // Stream only THIS turn's output: start reading at the current rawLog size.
480
+ let readOffset = (() => { try {
481
+ return fs.statSync(sess.rawLog).size;
482
+ }
483
+ catch {
484
+ return 0;
485
+ } })();
486
+ if (justLaunched) {
487
+ _emitGrokHook(root, 'SessionStart', { source: 'startup' });
488
+ // Startup grace — grok's splash takes a few seconds before it accepts input.
489
+ // Scan for the reauth gate while we wait; bail early if the token is dead.
490
+ const deadline = Date.now() + STARTUP_MS;
491
+ while (Date.now() < deadline) {
492
+ await sleep(500);
493
+ if (!hasSession(sess.name)) {
494
+ log('Grok TUI: session died during startup');
495
+ try {
496
+ fs.appendFileSync(stdoutFile, `\n[Grok TUI: session exited during startup]\n`, 'utf8');
497
+ }
498
+ catch { /* ignore */ }
499
+ finish(1, 'startup-exit');
500
+ return;
501
+ }
502
+ const snap = capturePane(sess.name);
503
+ if (REAUTH_MARKERS.test(snap)) {
504
+ log('Grok TUI: reauth required (OAuth gate at startup)');
505
+ try {
506
+ fs.appendFileSync(stdoutFile, `\n[Grok TUI: reauthentication required — please login]\n`, 'utf8');
507
+ }
508
+ catch { /* ignore */ }
509
+ finish(1, 'reauth_required');
510
+ return;
511
+ }
512
+ }
513
+ // CALIBRATED: grok opens on a welcome menu (New worktree / Resume session /
514
+ // Changelog / Quit) sitting over the input box; a single Enter dismisses it
515
+ // to the ready "❯" prompt. Without this the first real prompt lands on the
516
+ // menu instead of the chat input. Fresh launch only (a resumed session is
517
+ // already at the prompt).
518
+ tmux(['send-keys', '-t', sess.name, 'Enter']);
519
+ await sleep(1500);
520
+ // re-read the offset after startup so the splash/menu text isn't streamed
521
+ // as "turn output".
522
+ readOffset = (() => { try {
523
+ return fs.statSync(sess.rawLog).size;
524
+ }
525
+ catch {
526
+ return readOffset;
527
+ } })();
528
+ }
529
+ else {
530
+ _emitGrokHook(root, 'SessionStart', { source: 'resume' });
531
+ }
532
+ // Submit the prompt.
533
+ log(`Grok TUI: sending turn (${(() => { try {
534
+ return fs.statSync(promptFilePath).size;
535
+ }
536
+ catch {
537
+ return 0;
538
+ } })()} bytes)`);
539
+ pastePrompt(sess, promptFilePath);
540
+ // -----------------------------------------------------------------------
541
+ // Poll: stream new pane bytes, detect turn-end by output quiescence + a
542
+ // byte-stable pane snapshot with the running-indicator absent.
543
+ // -----------------------------------------------------------------------
544
+ const turnStart = Date.now();
545
+ let lastGrowthMs = Date.now();
546
+ let sawOutput = false;
547
+ let lastSnap = '';
548
+ let stable = 0;
549
+ let sawDoneMarker = false;
550
+ for (;;) {
551
+ await sleep(POLL_MS);
552
+ // Session died mid-turn (grok crashed / was killed).
553
+ if (!hasSession(sess.name)) {
554
+ try {
555
+ fs.appendFileSync(stdoutFile, `\n[Grok TUI: session exited]\n`, 'utf8');
556
+ }
557
+ catch { /* ignore */ }
558
+ _tmuxSessions.delete(root);
559
+ finish(1, 'session-exit');
560
+ return;
561
+ }
562
+ // 1) Drain new pane bytes → stdoutFile (+ console + office feed).
563
+ const { text, offset } = readFrom(sess.rawLog, readOffset);
564
+ if (text) {
565
+ readOffset = offset;
566
+ sess.readOffset = offset;
567
+ const clean = stripAnsi(text);
568
+ if (clean) {
569
+ try {
570
+ fs.appendFileSync(stdoutFile, clean, 'utf8');
571
+ }
572
+ catch { /* ignore */ }
573
+ emitLines(clean);
574
+ activityBuf += clean;
575
+ scheduleActivity();
576
+ _lastActivityMs.set(root, Date.now());
577
+ lastGrowthMs = Date.now();
578
+ sawOutput = true;
579
+ }
580
+ }
581
+ // 2) Snapshot for detection (also catches a mid-turn reauth prompt).
582
+ const snap = capturePane(sess.name);
583
+ if (REAUTH_MARKERS.test(snap)) {
584
+ try {
585
+ fs.appendFileSync(stdoutFile, `\n[Grok TUI: reauthentication required — please login]\n`, 'utf8');
586
+ }
587
+ catch { /* ignore */ }
588
+ finish(1, 'reauth_required');
589
+ return;
590
+ }
591
+ // 3) Idle test. grok prints "Worked for N.Ns." when a turn genuinely ends —
592
+ // that's the fast, positive done-signal. Absent it (an error turn, or an
593
+ // unusual grok build), fall back to a MUCH longer quiescence so early
594
+ // "Starting session…"/"Reading file…" pauses aren't misread as turn-end.
595
+ if (DONE_MARKER.test(snap)) {
596
+ sawDoneMarker = true;
597
+ }
598
+ if (snap === lastSnap) {
599
+ stable++;
600
+ }
601
+ else {
602
+ stable = 0;
603
+ lastSnap = snap;
604
+ }
605
+ const running = RUNNING_INDICATOR.test(snap);
606
+ const quietMs = Date.now() - lastGrowthMs;
607
+ const readyToJudge = sawOutput || (Date.now() - turnStart >= NOOUTPUT_MS);
608
+ const doneFast = sawDoneMarker && quietMs >= DEBOUNCE_MS;
609
+ const doneSlow = readyToJudge && quietMs >= QUIET_FALLBACK_MS && stable >= STABLE_POLLS;
610
+ if (!running && (doneFast || doneSlow)) {
611
+ // Final drain to catch anything written between the last read and idle.
612
+ const tail = readFrom(sess.rawLog, readOffset);
613
+ if (tail.text) {
614
+ readOffset = tail.offset;
615
+ sess.readOffset = tail.offset;
616
+ const clean = stripAnsi(tail.text);
617
+ if (clean) {
618
+ try {
619
+ fs.appendFileSync(stdoutFile, clean, 'utf8');
620
+ }
621
+ catch { /* ignore */ }
622
+ emitLines(clean);
623
+ }
624
+ }
625
+ finish(0, 'idle');
626
+ return;
627
+ }
628
+ // 4) Watchdog — a turn that overruns the hard budget. Interrupt with Esc
629
+ // (grok's cancel) to preserve the session/context; if it won't settle,
630
+ // kill the session so the NEXT turn relaunches with --resume.
631
+ if (MAX_RUN_MS > 0 && Date.now() - turnStart >= MAX_RUN_MS) {
632
+ log(`Grok TUI watchdog: exceeded ${Math.round(MAX_RUN_MS / 60_000)}min budget — interrupting`);
633
+ try {
634
+ fs.appendFileSync(stdoutFile, `\n[Grok TUI watchdog: turn exceeded time budget — interrupted]\n`, 'utf8');
635
+ }
636
+ catch { /* ignore */ }
637
+ tmux(['send-keys', '-t', sess.name, 'Escape']);
638
+ // Give grok a moment to settle after the interrupt.
639
+ let settled = false;
640
+ for (let i = 0; i < 8; i++) {
641
+ await sleep(500);
642
+ if (!hasSession(sess.name)) {
643
+ break;
644
+ }
645
+ const s2 = capturePane(sess.name);
646
+ if (!RUNNING_INDICATOR.test(s2)) {
647
+ settled = true;
648
+ break;
649
+ }
650
+ }
651
+ if (!settled && hasSession(sess.name)) {
652
+ // Still wedged after Esc → kill so ensureSession relaunches next turn.
653
+ killSession(sess.name);
654
+ _tmuxSessions.delete(root);
655
+ }
656
+ finish(124, 'watchdog');
657
+ return;
658
+ }
659
+ }
660
+ })();
661
+ }
662
+ function sendGrokPrompt(root, promptFilePath, stdoutFile, exitFile, log, opts = {}) {
130
663
  opts.showOutput?.();
131
- // Only force a model when one is explicitly configured. With no model, grok
132
- // uses the account's own default — more robust than hardcoding a model id
133
- // that may not exist for every account/plan.
134
664
  const modelArgs = opts.model ? ['-m', opts.model] : [];
135
665
  const modelLabel = opts.model || 'account default';
136
- // Session flags — grok-tui only. Resume the stored session, or mint a new id
137
- // and persist it immediately so the next task resumes the same conversation.
138
666
  const sessionArgs = [];
139
667
  if (opts.persist) {
140
668
  const providerId = opts.providerId ?? 'grok-tui';
@@ -163,9 +691,6 @@ promptFilePath, stdoutFile, exitFile, log, opts = {}) {
163
691
  fs.writeFileSync(exitFile, '', 'utf8');
164
692
  }
165
693
  catch { /* ignore */ }
166
- // Defensive: if a previous run for this root is somehow still tracked, kill it
167
- // first so `_activeChildren.set` below can't orphan it (callers gate on
168
- // isGrokTuiBusy, but don't rely on that alone).
169
694
  const prior = _activeChildren.get(root);
170
695
  if (prior) {
171
696
  try {
@@ -175,17 +700,12 @@ promptFilePath, stdoutFile, exitFile, log, opts = {}) {
175
700
  _activeChildren.delete(root);
176
701
  }
177
702
  _busyRoots.add(root);
178
- /**
179
- * Fail the turn the way the (previously unreachable) catch below intended.
180
- * Shared so the sync and async paths can't drift into disagreeing.
181
- */
182
703
  const failSpawn = (msg) => {
183
704
  log(`Grok spawn error: ${msg}`);
184
705
  try {
185
706
  fs.appendFileSync(stdoutFile, `\n[Grok spawn error: ${msg}]\n`, 'utf8');
186
707
  }
187
708
  catch { /* ignore */ }
188
- // The exit file is what the loop waits on — without it the turn never ends.
189
709
  try {
190
710
  fs.writeFileSync(exitFile, '1\n', 'utf8');
191
711
  }
@@ -202,28 +722,12 @@ promptFilePath, stdoutFile, exitFile, log, opts = {}) {
202
722
  ...sessionArgs,
203
723
  '--prompt-file', promptFilePath,
204
724
  '--output-format', 'streaming-json',
205
- ], {
206
- cwd: root,
207
- stdio: ['ignore', 'pipe', 'pipe'],
208
- env: { ...process.env },
209
- });
725
+ ], { cwd: root, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env } });
210
726
  }
211
727
  catch (spawnErr) {
212
- // Kept for the genuinely synchronous failures (bad options/EACCES on some
213
- // platforms). A MISSING BINARY does NOT land here — see the 'error' handler.
214
728
  failSpawn(spawnErr?.message ?? String(spawnErr));
215
729
  return;
216
730
  }
217
- // The one that actually fires when grok isn't installed.
218
- //
219
- // `spawn()` does NOT throw on ENOENT — it emits 'error' asynchronously — so the
220
- // catch above was dead code for the most common failure by far. With no handler
221
- // the error escaped to start.ts's uncaughtException backstop, which keeps the
222
- // process alive: 'close' never fired, the exit file stayed empty, _busyRoots was
223
- // never cleared, and SessionStart had ALREADY been emitted — so the office showed
224
- // the agent WORKING, forever, on a grok that was never running. Observed live:
225
- // an agent "online" and busy for hours whose only trace was `spawn grok ENOENT`
226
- // buried in agent.log. (opencodeCliProvider always did this correctly.)
227
731
  child.on('error', (err) => {
228
732
  const missing = err.code === 'ENOENT';
229
733
  failSpawn(missing
@@ -231,23 +735,7 @@ promptFilePath, stdoutFile, exitFile, log, opts = {}) {
231
735
  : err.message);
232
736
  });
233
737
  _activeChildren.set(root, child);
234
- // Emit SessionStart only AFTER the child is confirmed running. Emitting it
235
- // before spawn resolves is what let a failed turn masquerade as an active one.
236
738
  child.once('spawn', () => { _emitGrokHook(root, 'SessionStart', { source: 'startup' }); });
237
- // -------------------------------------------------------------------------
238
- // Watchdog — grok can wedge internally (e.g. retrying a failing tool call
239
- // forever) WITHOUT ever exiting, which leaves the process alive, the exit
240
- // file unwritten, and the task loop blocked indefinitely on this one task
241
- // while new work piles up. Kill a run that overruns a hard time budget or
242
- // floods tool-output errors, so `close` fires, the exit file is written, and
243
- // the loop fails this task cleanly and moves on.
244
- // -------------------------------------------------------------------------
245
- // Env overrides: accept any finite >= 0; 0 explicitly DISABLES that guard
246
- // (the old `Number(x) || DEFAULT` silently reverted 0 back to the default).
247
- const envNum = (key, def) => {
248
- const n = Number(process.env[key]);
249
- return Number.isFinite(n) && n >= 0 ? n : def;
250
- };
251
739
  const GROK_MAX_RUN_MS = envNum('AUTODEV_GROK_MAX_RUN_MS', 10 * 60_000);
252
740
  const GROK_MAX_TOOL_ERRORS = envNum('AUTODEV_GROK_MAX_TOOL_ERRORS', 25);
253
741
  let toolErrorCount = 0;
@@ -255,7 +743,7 @@ promptFilePath, stdoutFile, exitFile, log, opts = {}) {
255
743
  const killGrok = (why) => {
256
744
  if (killed) {
257
745
  return;
258
- } // never double-kill / double-log
746
+ }
259
747
  killed = true;
260
748
  log(`Grok watchdog: ${why} — killing run`);
261
749
  try {
@@ -270,21 +758,8 @@ promptFilePath, stdoutFile, exitFile, log, opts = {}) {
270
758
  const watchdog = GROK_MAX_RUN_MS > 0
271
759
  ? setTimeout(() => killGrok(`exceeded ${Math.round(GROK_MAX_RUN_MS / 60_000)}min time budget`), GROK_MAX_RUN_MS)
272
760
  : null;
273
- // -------------------------------------------------------------------------
274
- // Stream stdout — each line is a streaming-json object.
275
- // -------------------------------------------------------------------------
276
761
  const rl = readline.createInterface({ input: child.stdout });
277
- // Grok's streaming-json exposes `thought`, `text`, `end` — but NOT tool
278
- // events (tool use is hidden under --always-approve). So per-tool activity
279
- // isn't available; we surface the real turn boundaries instead: SessionStart
280
- // at spawn and Stop/SessionEnd on the `end` event (a genuine grok signal, not
281
- // synthetic padding). `_endSeen` avoids a duplicate SessionEnd on close.
282
762
  let _endSeen = false;
283
- // Grok exposes NO tool events in streaming-json (tool use is hidden under
284
- // --always-approve), so pixel-office's activity feed would otherwise be empty
285
- // apart from the SessionStart/End boundaries. Surface the assistant's streamed
286
- // TEXT as periodic, coalesced `Notification` hook events so the office shows
287
- // what grok is actually producing (not raw per-chunk spam).
288
763
  let _activityBuf = '';
289
764
  let _activityTimer = null;
290
765
  const flushActivity = () => {
@@ -309,10 +784,6 @@ promptFilePath, stdoutFile, exitFile, log, opts = {}) {
309
784
  _activityTimer = setTimeout(flushActivity, 1200);
310
785
  }
311
786
  };
312
- // Console output: grok streams assistant text token-by-token (often mid-word,
313
- // e.g. "aut"/"ode"/"v"). Logging each delta puts one timestamped line per token
314
- // and shreds the output. Buffer deltas and only log COMPLETE lines (on \n),
315
- // flushing whatever remains when the turn ends.
316
787
  let _lineBuf = '';
317
788
  const emitOutputLines = (text) => {
318
789
  _lineBuf += text;
@@ -341,7 +812,6 @@ promptFilePath, stdoutFile, exitFile, log, opts = {}) {
341
812
  msg = JSON.parse(line);
342
813
  }
343
814
  catch {
344
- // Plain text fallback (e.g. progress lines before JSON kicks in).
345
815
  try {
346
816
  fs.appendFileSync(stdoutFile, line + '\n', 'utf8');
347
817
  }
@@ -350,16 +820,13 @@ promptFilePath, stdoutFile, exitFile, log, opts = {}) {
350
820
  }
351
821
  const type = msg.type ?? '';
352
822
  if (type === 'assistant' || type === 'text') {
353
- // Assistant response text.
354
823
  const text = msg.content ?? msg.data ?? msg.text ?? msg.message ?? '';
355
824
  if (text) {
356
825
  try {
357
826
  fs.appendFileSync(stdoutFile, text, 'utf8');
358
827
  }
359
828
  catch { /* ignore */ }
360
- // Log full lines only (buffer partial tokens until a newline arrives).
361
829
  emitOutputLines(text);
362
- // Feed the office activity stream (coalesced).
363
830
  _activityBuf += text;
364
831
  scheduleActivity();
365
832
  }
@@ -373,17 +840,13 @@ promptFilePath, stdoutFile, exitFile, log, opts = {}) {
373
840
  catch { /* ignore */ }
374
841
  }
375
842
  else if (type === 'end') {
376
- // Genuine turn-end from grok's stream → real session boundary.
377
843
  _endSeen = true;
378
- flushOutputLine(); // print any half-line still buffered
379
- flushActivity(); // emit any buffered assistant text first
844
+ flushOutputLine();
845
+ flushActivity();
380
846
  _emitGrokHook(root, 'Stop', {});
381
847
  _emitGrokHook(root, 'SessionEnd', { reason: 'completed' });
382
848
  }
383
- // `thought` and other event types stay out of the output file.
384
849
  });
385
- // Read stderr LINE by line (not raw chunks) so a `tool_output_error` marker
386
- // split across two data chunks is still counted exactly once.
387
850
  const errRl = child.stderr ? readline.createInterface({ input: child.stderr }) : null;
388
851
  errRl?.on('line', (line) => {
389
852
  const text = line.trim();
@@ -391,8 +854,6 @@ promptFilePath, stdoutFile, exitFile, log, opts = {}) {
391
854
  return;
392
855
  }
393
856
  log(`Grok stderr: ${text}`);
394
- // A flood of tool-output errors means grok is stuck retrying a tool it
395
- // can't complete — kill it now rather than waiting out the time budget.
396
857
  if (GROK_MAX_TOOL_ERRORS > 0 && text.includes('tool_output_error')) {
397
858
  toolErrorCount++;
398
859
  if (toolErrorCount >= GROK_MAX_TOOL_ERRORS) {
@@ -404,16 +865,14 @@ promptFilePath, stdoutFile, exitFile, log, opts = {}) {
404
865
  if (watchdog) {
405
866
  clearTimeout(watchdog);
406
867
  }
407
- flushOutputLine(); // print any half-line still buffered
408
- flushActivity(); // flush any trailing assistant text
868
+ flushOutputLine();
869
+ flushActivity();
409
870
  errRl?.close();
410
871
  rl.close();
411
872
  _activeChildren.delete(root);
412
873
  _busyRoots.delete(root);
413
874
  const exitCode = code ?? 1;
414
875
  log(`Grok: exited (code=${exitCode})`);
415
- // Fallback SessionEnd only if grok didn't already emit an `end` event
416
- // (abnormal exit / killed) — avoids a duplicate from the normal path.
417
876
  if (!_endSeen) {
418
877
  _emitGrokHook(root, exitCode === 0 ? 'Stop' : 'StopFailure', { exit_code: exitCode });
419
878
  _emitGrokHook(root, 'SessionEnd', { reason: exitCode === 0 ? 'completed' : 'error' });
@@ -424,28 +883,78 @@ promptFilePath, stdoutFile, exitFile, log, opts = {}) {
424
883
  catch { /* ignore */ }
425
884
  });
426
885
  }
427
- /** grok-cli: stateless — a fresh grok process every task (no session flags). */
886
+ /** grok-cli: stateless — a fresh HEADLESS grok process every task. */
428
887
  function sendGrokCliPrompt(root, promptFilePath, stdoutFile, exitFile, log, model, showOutput) {
429
888
  sendGrokPrompt(root, promptFilePath, stdoutFile, exitFile, log, {
430
889
  model, showOutput, persist: false, providerId: 'grok-cli',
431
890
  });
432
891
  }
433
- /** grok-tui: persistent — resume the workspace session so context accumulates. */
434
- function sendGrokTuiPrompt(root, promptFilePath,
435
- /** Session id from resolveSession (undefined on the first task). */
436
- resolvedSessionId, stdoutFile, exitFile, log, model, showOutput) {
892
+ /**
893
+ * grok-tui: persistent — run the turn in the per-workspace tmux session so
894
+ * grok's in-process context accumulates across tasks. Falls back to the old
895
+ * headless spawn (with --resume) when tmux is unavailable so nothing regresses.
896
+ */
897
+ function sendGrokTuiPrompt(root, promptFilePath, resolvedSessionId, stdoutFile, exitFile, log, model, showOutput) {
898
+ if (tmuxAvailable()) {
899
+ runGrokTmuxTurn(root, promptFilePath, resolvedSessionId, stdoutFile, exitFile, log, model, showOutput);
900
+ return;
901
+ }
902
+ log('Grok TUI: tmux not available — falling back to headless spawn');
437
903
  sendGrokPrompt(root, promptFilePath, stdoutFile, exitFile, log, {
438
904
  model, showOutput, persist: true, sessionId: resolvedSessionId, providerId: 'grok-tui',
439
905
  });
440
906
  }
441
907
  // ---------------------------------------------------------------------------
442
- // closeGrokTuiSession
443
- // Called by taskLoop reset interval. grok-tui persists its session id in
444
- // session-state.json BY DESIGN (so context survives), so we only ensure no
445
- // child is left running the session id is intentionally preserved.
908
+ // steerGrokTui — inject a message into the RUNNING grok turn via the live pane.
909
+ //
910
+ // Because the tmux session is a real TTY, keys sent to a mid-turn pane are
911
+ // delivered to grok's input and folded into the current turn (confirmed with a
912
+ // live REPL stand-in). Mirrors steerClaudeTui: only injects when a turn is
913
+ // actually running; returns false when there is no live pane so the caller
914
+ // keeps the durable TODO fallback (at-least-once delivery).
915
+ // ---------------------------------------------------------------------------
916
+ async function steerGrokTui(root, text, log) {
917
+ const sess = _tmuxSessions.get(root);
918
+ if (!sess) {
919
+ return false;
920
+ } // no live tmux session (headless / not started)
921
+ if (!_busyRoots.has(root)) {
922
+ return false;
923
+ } // only steer a turn in flight
924
+ if (!hasSession(sess.name)) {
925
+ _tmuxSessions.delete(root);
926
+ return false;
927
+ }
928
+ try {
929
+ // Literal keystrokes (send-keys -l), NOT a bracketed paste — grok's TUI
930
+ // misreads a paste as a directory picker. Flatten to one line (no reliable
931
+ // newline-without-submit key), then Enter to inject into the running turn.
932
+ const flat = text.replace(/\r?\n+/g, ' ').replace(/[ \t]+/g, ' ').trim();
933
+ for (let i = 0; i < flat.length; i += 3000) {
934
+ if (tmux(['send-keys', '-t', sess.name, '-l', flat.slice(i, i + 3000)]) === null) {
935
+ return false;
936
+ }
937
+ }
938
+ if (tmux(['send-keys', '-t', sess.name, 'Enter']) === null) {
939
+ return false;
940
+ }
941
+ log(`Grok TUI: steered live pane (${text.length} chars) — injected into current turn`);
942
+ return true;
943
+ }
944
+ catch (err) {
945
+ log(`Grok TUI: steer failed: ${err?.message ?? String(err)}`);
946
+ return false;
947
+ }
948
+ }
949
+ // ---------------------------------------------------------------------------
950
+ // Teardown
446
951
  // ---------------------------------------------------------------------------
952
+ /**
953
+ * Called by taskLoop reset interval / provider close. Kills any live grok for
954
+ * this root — the tmux session AND any headless child — while PRESERVING the
955
+ * persisted session id so a later task relaunches with --resume (context intact).
956
+ */
447
957
  function closeGrokTuiSession(root, _log) {
448
- // Kill any active child for this root; keep the persisted session id.
449
958
  const child = _activeChildren.get(root);
450
959
  if (child) {
451
960
  try {
@@ -454,9 +963,21 @@ function closeGrokTuiSession(root, _log) {
454
963
  catch { /* ignore */ }
455
964
  _activeChildren.delete(root);
456
965
  }
966
+ const sess = _tmuxSessions.get(root);
967
+ if (sess) {
968
+ killSession(sess.name);
969
+ _tmuxSessions.delete(root);
970
+ }
971
+ else {
972
+ // No cached handle but a session may still exist under the deterministic name.
973
+ const name = sessionName(root);
974
+ if (hasSession(name)) {
975
+ killSession(name);
976
+ }
977
+ }
457
978
  _busyRoots.delete(root);
458
979
  }
459
- /** Kill all active grok processes — called on extension deactivate. */
980
+ /** Kill all live grok sessions — called on SDK/extension shutdown. */
460
981
  function closeAllGrokTuiSessions() {
461
982
  for (const child of _activeChildren.values()) {
462
983
  try {
@@ -465,6 +986,10 @@ function closeAllGrokTuiSessions() {
465
986
  catch { /* ignore */ }
466
987
  }
467
988
  _activeChildren.clear();
989
+ for (const sess of _tmuxSessions.values()) {
990
+ killSession(sess.name);
991
+ }
992
+ _tmuxSessions.clear();
468
993
  _busyRoots.clear();
469
994
  }
470
995
  // ---------------------------------------------------------------------------