autodev-cli 1.4.67 → 1.4.69

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,718 @@ 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
+ // Kill the one-time project-directory picker before it can block the turn.
270
+ ensureGrokPickerDisabled(log);
271
+ const dir = path.join(root, '.autodev', 'grok-tui');
272
+ try {
273
+ if (!fs.existsSync(dir)) {
274
+ fs.mkdirSync(dir, { recursive: true });
275
+ }
276
+ }
277
+ catch { /* ignore */ }
278
+ const rawLog = path.join(dir, 'pane.raw');
279
+ // Start each launch with a fresh raw log so readOffset math is simple.
280
+ try {
281
+ fs.writeFileSync(rawLog, '', 'utf8');
282
+ }
283
+ catch { /* ignore */ }
284
+ // Fixed geometry so wrapping/relayout never shifts detection rows; manual
285
+ // window-size so a human attaching can't reflow the pane mid-turn.
286
+ tmux(['new-session', '-d', '-s', name, '-x', '200', '-y', '50', '-c', root]);
287
+ tmux(['set-option', '-t', name, 'window-size', 'manual']);
288
+ // pipe-pane BEFORE launching grok — it only captures bytes emitted after it
289
+ // attaches (confirmed). Append mode.
290
+ tmux(['pipe-pane', '-o', '-t', name, `cat >> ${shq(rawLog)}`]);
291
+ // Build the interactive grok command. `exec` replaces the shell so a dead grok
292
+ // collapses the pane/session → cheap liveness signal via has-session.
293
+ const parts = [
294
+ 'exec', shq(GROK_BIN),
295
+ '--no-alt-screen', '--always-approve',
296
+ '--cwd', shq(root),
297
+ ];
298
+ if (model) {
299
+ parts.push('-m', shq(model));
300
+ }
301
+ if (resume) {
302
+ // Restore the accumulated conversation on relaunch.
303
+ parts.push('--resume', shq(sessionId));
304
+ }
305
+ else {
306
+ // Name a brand-new conversation with our UUID so it is addressable later.
307
+ parts.push('-s', shq(sessionId));
308
+ }
309
+ tmux(['send-keys', '-t', name, parts.join(' '), 'Enter']);
310
+ const sess = { name, rawLog, sessionId, readOffset: 0 };
311
+ _tmuxSessions.set(root, sess);
312
+ log(`Grok TUI: launched tmux session ${name} (${resume ? 'resume' : 'new'} ${sessionId.slice(0, 8)}…, model=${model || 'account default'})`);
313
+ return sess;
314
+ }
315
+ /**
316
+ * Ensure a live tmux grok session exists for this root. Returns the session and
317
+ * whether it was just launched (caller applies a startup grace before pasting).
318
+ */
319
+ function ensureSession(root, resolvedSessionId, model, log) {
320
+ const name = sessionName(root);
321
+ const cached = _tmuxSessions.get(root);
322
+ if (cached && cached.name === name && hasSession(name)) {
323
+ return { sess: cached, justLaunched: false }; // reuse — context is live in-process
324
+ }
325
+ // No live session. If we have a stored session id, relaunch with --resume so
326
+ // grok restores the accumulated history; otherwise mint + persist a new UUID.
327
+ let sid = resolvedSessionId || (0, sessionState_1.getSessionId)(root, 'grok-tui');
328
+ let resume = false;
329
+ if (sid) {
330
+ resume = true; // a stored id means grok already has a persisted session
331
+ }
332
+ else {
333
+ sid = (0, crypto_1.randomUUID)();
334
+ try {
335
+ (0, sessionState_1.saveSessionId)(root, 'grok-tui', sid);
336
+ }
337
+ catch { /* best effort */ }
338
+ }
339
+ const sess = launchSession(root, sid, resume, model, log);
340
+ return { sess, justLaunched: true };
341
+ }
342
+ /** Read new bytes of rawLog past `offset`; returns decoded text + new offset. */
343
+ function readFrom(file, offset) {
344
+ try {
345
+ const size = fs.statSync(file).size;
346
+ if (size <= offset) {
347
+ return { text: '', offset };
348
+ }
349
+ const len = size - offset;
350
+ const buf = Buffer.alloc(len);
351
+ const fd = fs.openSync(file, 'r');
352
+ try {
353
+ fs.readSync(fd, buf, 0, len, offset);
354
+ }
355
+ finally {
356
+ fs.closeSync(fd);
357
+ }
358
+ return { text: buf.toString('utf8'), offset: size };
359
+ }
360
+ catch {
361
+ return { text: '', offset };
362
+ }
363
+ }
364
+ /**
365
+ * grok persists a CLEAN structured transcript per workspace session at
366
+ * ~/.grok/sessions/<encodeURIComponent(cwd)>/<sessionId>/chat_history.jsonl
367
+ * one JSON object per line: {type:'system'|'user'|'reasoning'|'assistant'|
368
+ * 'tool_result', content, tool_calls?}. The assistant `content` is the real
369
+ * message text (markdown), with none of the TUI chrome — spinners, "Waiting for
370
+ * response…", token counters, "Ctrl+x:shortcuts", load-bar glyphs — that scraping
371
+ * the live tmux pane drags in. We read the agent's OUTPUT from here; the pane is
372
+ * used only for turn-end/reauth detection.
373
+ */
374
+ function chatHistoryPath(root, sessionId) {
375
+ return path.join(os.homedir(), '.grok', 'sessions', encodeURIComponent(root), sessionId, 'chat_history.jsonl');
376
+ }
122
377
  /**
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).
378
+ * A NEW grok session shows a one-time "Run Grok Build in a project directory?"
379
+ * picker before the first turn's work, which blocks headless automation. grok
380
+ * exposes a persistent opt-out `hints = { project_picker_disabled = true }` in
381
+ * ~/.grok/config.toml (what its "Don't ask me again" option writes). Ensure it is
382
+ * present before launching so the session goes straight to the prompt. Idempotent;
383
+ * the poll-loop picker backstop covers any grok build that ignores this.
126
384
  */
127
- function sendGrokPrompt(root,
128
- /** Absolute path to the combined agent-profile + message file. */
129
- promptFilePath, stdoutFile, exitFile, log, opts = {}) {
385
+ function ensureGrokPickerDisabled(log) {
386
+ try {
387
+ const cfg = path.join(os.homedir(), '.grok', 'config.toml');
388
+ let text = '';
389
+ try {
390
+ text = fs.readFileSync(cfg, 'utf8');
391
+ }
392
+ catch { /* no config yet */ }
393
+ if (/project_picker_disabled\s*=\s*true/.test(text)) {
394
+ return;
395
+ }
396
+ if (/^hints\s*=\s*\{/m.test(text)) {
397
+ // Inject the key into the existing inline `hints` table.
398
+ text = text.replace(/^hints\s*=\s*\{/m, 'hints = { project_picker_disabled = true,');
399
+ }
400
+ else {
401
+ text = 'hints = { project_picker_disabled = true }\n' + text;
402
+ }
403
+ fs.mkdirSync(path.dirname(cfg), { recursive: true });
404
+ fs.writeFileSync(cfg, text, 'utf8');
405
+ log('Grok TUI: disabled grok project-directory picker in config.toml');
406
+ }
407
+ catch { /* best effort — the poll-loop dismissal is the backstop */ }
408
+ }
409
+ /** Compact one-line marker for a grok tool call (for the live activity feed). */
410
+ function formatToolCall(tc) {
411
+ const name = tc?.name || 'tool';
412
+ let arg = '';
413
+ try {
414
+ const a = JSON.parse(tc?.arguments || '{}');
415
+ const cand = a.target_file ?? a.file_path ?? a.path ?? a.command ?? a.query ?? a.message ?? a.to ?? '';
416
+ if (typeof cand === 'string') {
417
+ arg = cand;
418
+ }
419
+ }
420
+ catch { /* non-JSON args — just show the name */ }
421
+ arg = arg.replace(/\s+/g, ' ').trim().slice(0, 80);
422
+ return `» ${name}${arg ? ` ${arg}` : ''}`;
423
+ }
424
+ /**
425
+ * Read new chat_history.jsonl records past `offset` and render only the
426
+ * user-facing parts: assistant prose + compact tool-call markers. Skips
427
+ * reasoning/user/system/tool_result. Advances the offset only past COMPLETE
428
+ * lines so a half-written trailing record is re-read next poll.
429
+ */
430
+ function drainChatHistory(file, offset) {
431
+ let size = 0;
432
+ try {
433
+ size = fs.statSync(file).size;
434
+ }
435
+ catch {
436
+ return { text: '', offset };
437
+ }
438
+ if (size <= offset) {
439
+ return { text: '', offset };
440
+ }
441
+ let raw = '';
442
+ try {
443
+ const len = size - offset;
444
+ const buf = Buffer.alloc(len);
445
+ const fd = fs.openSync(file, 'r');
446
+ try {
447
+ fs.readSync(fd, buf, 0, len, offset);
448
+ }
449
+ finally {
450
+ fs.closeSync(fd);
451
+ }
452
+ raw = buf.toString('utf8');
453
+ }
454
+ catch {
455
+ return { text: '', offset };
456
+ }
457
+ const lastNl = raw.lastIndexOf('\n');
458
+ if (lastNl < 0) {
459
+ return { text: '', offset };
460
+ } // no complete line yet
461
+ const complete = raw.slice(0, lastNl);
462
+ const newOffset = offset + Buffer.byteLength(complete, 'utf8') + 1; // +1 for the \n
463
+ let out = '';
464
+ for (const line of complete.split('\n')) {
465
+ const s = line.trim();
466
+ if (!s) {
467
+ continue;
468
+ }
469
+ let o;
470
+ try {
471
+ o = JSON.parse(s);
472
+ }
473
+ catch {
474
+ continue;
475
+ }
476
+ if ((o.type || o.role) !== 'assistant') {
477
+ continue;
478
+ }
479
+ const c = (o.content || '').trim();
480
+ if (c) {
481
+ out += c + '\n';
482
+ }
483
+ for (const tc of o.tool_calls || []) {
484
+ out += formatToolCall(tc) + '\n';
485
+ }
486
+ }
487
+ return { text: out, offset: newOffset };
488
+ }
489
+ /** Type a prompt into the live pane and submit it. CALIBRATED against real grok:
490
+ * a tmux bracketed paste (paste-buffer) is misread by grok's TUI as a
491
+ * worktree/directory PICKER, not chat input — only literal keystrokes land in
492
+ * the input box. grok also has no reliable "newline without submit" key over
493
+ * send-keys (Enter submits), so flatten the prompt to one logical line. */
494
+ function pastePrompt(sess, promptFilePath) {
495
+ // A full agent prompt is 10KB+ (system prompt + task + context). Typing that
496
+ // via send-keys -l is UNRELIABLE (tmux drops/garbles keys at that size —
497
+ // observed live), and a bracketed paste is misread by grok's TUI as a directory
498
+ // picker. So DON'T type the prompt: hand grok a SHORT literal instruction to
499
+ // READ the prompt file, and its file tool ingests the whole thing losslessly.
500
+ 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.`;
501
+ tmux(['send-keys', '-t', sess.name, '-l', instr]);
502
+ tmux(['send-keys', '-t', sess.name, 'Enter']);
503
+ }
504
+ // ---------------------------------------------------------------------------
505
+ // runGrokTmuxTurn — one turn against the persistent tmux session.
506
+ // Fire-and-forget: streams pane text to stdoutFile, writes exitFile at turn end.
507
+ // ---------------------------------------------------------------------------
508
+ function runGrokTmuxTurn(root, promptFilePath, resolvedSessionId, stdoutFile, exitFile, log, model, showOutput) {
509
+ showOutput?.();
510
+ try {
511
+ fs.writeFileSync(stdoutFile, '', 'utf8');
512
+ }
513
+ catch { /* ignore */ }
514
+ try {
515
+ fs.writeFileSync(exitFile, '', 'utf8');
516
+ }
517
+ catch { /* ignore */ }
518
+ _busyRoots.add(root);
519
+ const POLL_MS = envNum('AUTODEV_GROK_TUI_POLL_MS', 700);
520
+ const DEBOUNCE_MS = envNum('AUTODEV_GROK_TUI_DEBOUNCE_MS', 5_000);
521
+ // Quiescence-only fallback (no "Worked for" marker seen) needs a much longer
522
+ // quiet window so grok's early "Starting session…"/"Reading file…" pauses aren't
523
+ // misread as turn-end (observed a false ~4s finish). The "Worked for" marker is
524
+ // the fast, positive done-signal for the normal case.
525
+ const QUIET_FALLBACK_MS = envNum('AUTODEV_GROK_TUI_QUIET_FALLBACK_MS', 45_000);
526
+ const STARTUP_MS = envNum('AUTODEV_GROK_TUI_STARTUP_MS', 8_000);
527
+ const NOOUTPUT_MS = envNum('AUTODEV_GROK_TUI_NOOUTPUT_MS', 25_000);
528
+ const MAX_RUN_MS = envNum('AUTODEV_GROK_MAX_RUN_MS', 10 * 60_000);
529
+ const STABLE_POLLS = Math.max(2, envNum('AUTODEV_GROK_TUI_STABLE_POLLS', 4));
530
+ const sleep = (ms) => new Promise(r => setTimeout(r, ms));
531
+ // Line-buffer for clean console logging (grok streams mid-word).
532
+ let lineBuf = '';
533
+ const emitLines = (text) => {
534
+ lineBuf += text;
535
+ let nl;
536
+ while ((nl = lineBuf.indexOf('\n')) >= 0) {
537
+ const line = lineBuf.slice(0, nl).replace(/\s+$/, '');
538
+ lineBuf = lineBuf.slice(nl + 1);
539
+ if (line.trim()) {
540
+ log(` ${line}`);
541
+ }
542
+ }
543
+ };
544
+ const flushLine = () => {
545
+ const line = lineBuf.replace(/\s+$/, '');
546
+ lineBuf = '';
547
+ if (line.trim()) {
548
+ log(` ${line}`);
549
+ }
550
+ };
551
+ // Coalesced office activity feed (grok exposes no per-tool events here).
552
+ let activityBuf = '';
553
+ let activityTimer = null;
554
+ const flushActivity = () => {
555
+ if (activityTimer) {
556
+ clearTimeout(activityTimer);
557
+ activityTimer = null;
558
+ }
559
+ const t = activityBuf.trim();
560
+ activityBuf = '';
561
+ if (!t) {
562
+ return;
563
+ }
564
+ const preview = t.length > 280 ? t.slice(0, 277) + '…' : t;
565
+ _emitGrokHook(root, 'Notification', { message: preview, title: 'grok', tool_name: 'grok' });
566
+ };
567
+ const scheduleActivity = () => {
568
+ if (activityBuf.length >= 400) {
569
+ flushActivity();
570
+ return;
571
+ }
572
+ if (!activityTimer) {
573
+ activityTimer = setTimeout(flushActivity, 1200);
574
+ }
575
+ };
576
+ const finish = (code, reason) => {
577
+ flushLine();
578
+ flushActivity();
579
+ _busyRoots.delete(root);
580
+ log(`Grok TUI: turn ${code === 0 ? 'complete' : `ended (code=${code}, ${reason})`}`);
581
+ _emitGrokHook(root, code === 0 ? 'Stop' : 'StopFailure', { exit_code: code });
582
+ _emitGrokHook(root, 'SessionEnd', { reason: code === 0 ? 'completed' : reason });
583
+ try {
584
+ fs.writeFileSync(exitFile, `${code}\n`, 'utf8');
585
+ }
586
+ catch { /* ignore */ }
587
+ };
588
+ void (async () => {
589
+ let sess;
590
+ let justLaunched;
591
+ try {
592
+ const r = ensureSession(root, resolvedSessionId, model, log);
593
+ sess = r.sess;
594
+ justLaunched = r.justLaunched;
595
+ }
596
+ catch (err) {
597
+ const msg = err?.message ?? String(err);
598
+ log(`Grok TUI: session launch failed: ${msg}`);
599
+ try {
600
+ fs.appendFileSync(stdoutFile, `\n[Grok TUI launch error: ${msg}]\n`, 'utf8');
601
+ }
602
+ catch { /* ignore */ }
603
+ finish(1, 'launch-failed');
604
+ return;
605
+ }
606
+ // Stream only THIS turn's output: start reading at the current rawLog size.
607
+ let readOffset = (() => { try {
608
+ return fs.statSync(sess.rawLog).size;
609
+ }
610
+ catch {
611
+ return 0;
612
+ } })();
613
+ if (justLaunched) {
614
+ _emitGrokHook(root, 'SessionStart', { source: 'startup' });
615
+ // Startup grace — grok's splash takes a few seconds before it accepts input.
616
+ // Scan for the reauth gate while we wait; bail early if the token is dead.
617
+ const deadline = Date.now() + STARTUP_MS;
618
+ while (Date.now() < deadline) {
619
+ await sleep(500);
620
+ if (!hasSession(sess.name)) {
621
+ log('Grok TUI: session died during startup');
622
+ try {
623
+ fs.appendFileSync(stdoutFile, `\n[Grok TUI: session exited during startup]\n`, 'utf8');
624
+ }
625
+ catch { /* ignore */ }
626
+ finish(1, 'startup-exit');
627
+ return;
628
+ }
629
+ const snap = capturePane(sess.name);
630
+ if (REAUTH_MARKERS.test(snap)) {
631
+ log('Grok TUI: reauth required (OAuth gate at startup)');
632
+ try {
633
+ fs.appendFileSync(stdoutFile, `\n[Grok TUI: reauthentication required — please login]\n`, 'utf8');
634
+ }
635
+ catch { /* ignore */ }
636
+ finish(1, 'reauth_required');
637
+ return;
638
+ }
639
+ }
640
+ // CALIBRATED: a NEW grok session opens on a picker sitting over the input
641
+ // box — either the welcome menu (New worktree / Resume session / Changelog /
642
+ // Quit) or the project chooser ("Run Grok Build in a project directory?"
643
+ // with radio options, the current dir highlighted). A RESUMED session skips
644
+ // it. Pressing Enter confirms the highlighted default (current dir / first
645
+ // item) and proceeds to the ready "❯" prompt. A single fixed Enter races the
646
+ // picker's render (grok's splash can outlast the grace), so poll and press
647
+ // Enter each time a picker is still showing, until the prompt is clear.
648
+ const PICKER_MARKERS = /project directory|New worktree|Resume session|Changelog|\(current\)|Run Grok Build|[◯○◉●]|Select|↑\/↓/i;
649
+ for (let i = 0; i < 8; i++) {
650
+ if (!hasSession(sess.name)) {
651
+ log('Grok TUI: session died during startup');
652
+ try {
653
+ fs.appendFileSync(stdoutFile, `\n[Grok TUI: session exited during startup]\n`, 'utf8');
654
+ }
655
+ catch { /* ignore */ }
656
+ finish(1, 'startup-exit');
657
+ return;
658
+ }
659
+ const snap = capturePane(sess.name);
660
+ if (REAUTH_MARKERS.test(snap)) {
661
+ log('Grok TUI: reauth required (OAuth gate at startup)');
662
+ try {
663
+ fs.appendFileSync(stdoutFile, `\n[Grok TUI: reauthentication required — please login]\n`, 'utf8');
664
+ }
665
+ catch { /* ignore */ }
666
+ finish(1, 'reauth_required');
667
+ return;
668
+ }
669
+ if (!PICKER_MARKERS.test(snap)) {
670
+ break;
671
+ } // at the ready prompt
672
+ tmux(['send-keys', '-t', sess.name, 'Enter']);
673
+ await sleep(1500);
674
+ }
675
+ // re-read the offset after startup so the splash/menu text isn't streamed
676
+ // as "turn output".
677
+ readOffset = (() => { try {
678
+ return fs.statSync(sess.rawLog).size;
679
+ }
680
+ catch {
681
+ return readOffset;
682
+ } })();
683
+ }
684
+ else {
685
+ _emitGrokHook(root, 'SessionStart', { source: 'resume' });
686
+ }
687
+ // Clean-output source: grok's structured transcript. Seed the offset at the
688
+ // current size so we emit only THIS turn's assistant records, not the
689
+ // resumed history. (File may not exist yet on a fresh session → offset 0.)
690
+ const chatPath = chatHistoryPath(root, sess.sessionId);
691
+ let chatOffset = (() => { try {
692
+ return fs.statSync(chatPath).size;
693
+ }
694
+ catch {
695
+ return 0;
696
+ } })();
697
+ let chatEmitted = false;
698
+ // Submit the prompt.
699
+ log(`Grok TUI: sending turn (${(() => { try {
700
+ return fs.statSync(promptFilePath).size;
701
+ }
702
+ catch {
703
+ return 0;
704
+ } })()} bytes)`);
705
+ pastePrompt(sess, promptFilePath);
706
+ // -----------------------------------------------------------------------
707
+ // Poll: stream new pane bytes, detect turn-end by output quiescence + a
708
+ // byte-stable pane snapshot with the running-indicator absent.
709
+ // -----------------------------------------------------------------------
710
+ const turnStart = Date.now();
711
+ let lastGrowthMs = Date.now();
712
+ let sawOutput = false;
713
+ let lastSnap = '';
714
+ let stable = 0;
715
+ let sawDoneMarker = false;
716
+ for (;;) {
717
+ await sleep(POLL_MS);
718
+ // Session died mid-turn (grok crashed / was killed).
719
+ if (!hasSession(sess.name)) {
720
+ try {
721
+ fs.appendFileSync(stdoutFile, `\n[Grok TUI: session exited]\n`, 'utf8');
722
+ }
723
+ catch { /* ignore */ }
724
+ _tmuxSessions.delete(root);
725
+ finish(1, 'session-exit');
726
+ return;
727
+ }
728
+ // 1a) Read the pane ONLY for activity/quiescence timing — its bytes are
729
+ // TUI chrome (spinners, "Waiting for response…", token counters) and
730
+ // must NOT be emitted as the agent's output.
731
+ const { text: paneText, offset: paneOff } = readFrom(sess.rawLog, readOffset);
732
+ if (paneText) {
733
+ readOffset = paneOff;
734
+ sess.readOffset = paneOff;
735
+ if (stripAnsi(paneText).trim()) {
736
+ _lastActivityMs.set(root, Date.now());
737
+ lastGrowthMs = Date.now();
738
+ sawOutput = true;
739
+ }
740
+ }
741
+ // 1b) Emit CLEAN output from grok's structured transcript (assistant prose
742
+ // + compact tool markers), never the pane scrape.
743
+ const ch = drainChatHistory(chatPath, chatOffset);
744
+ if (ch.text) {
745
+ chatOffset = ch.offset;
746
+ chatEmitted = true;
747
+ try {
748
+ fs.appendFileSync(stdoutFile, ch.text, 'utf8');
749
+ }
750
+ catch { /* ignore */ }
751
+ emitLines(ch.text);
752
+ activityBuf += ch.text;
753
+ scheduleActivity();
754
+ _lastActivityMs.set(root, Date.now());
755
+ }
756
+ // 2) Snapshot for detection (also catches a mid-turn reauth prompt).
757
+ const snap = capturePane(sess.name);
758
+ if (REAUTH_MARKERS.test(snap)) {
759
+ try {
760
+ fs.appendFileSync(stdoutFile, `\n[Grok TUI: reauthentication required — please login]\n`, 'utf8');
761
+ }
762
+ catch { /* ignore */ }
763
+ finish(1, 'reauth_required');
764
+ return;
765
+ }
766
+ // Backstop: the project-directory picker appears AFTER the first prompt is
767
+ // submitted (grok's one-time confirm), so it lands mid-turn. The config
768
+ // opt-out normally prevents it; if a grok build ignores that, select the
769
+ // current dir (option "1", always present) to unblock the turn.
770
+ if (/Run Grok Build in a project directory|Don't ask me again|\(current\)/i.test(snap)) {
771
+ tmux(['send-keys', '-t', sess.name, '1']);
772
+ await sleep(800);
773
+ lastGrowthMs = Date.now();
774
+ continue;
775
+ }
776
+ // 3) Idle test. grok prints "Worked for N.Ns." when a turn genuinely ends —
777
+ // that's the fast, positive done-signal. Absent it (an error turn, or an
778
+ // unusual grok build), fall back to a MUCH longer quiescence so early
779
+ // "Starting session…"/"Reading file…" pauses aren't misread as turn-end.
780
+ if (DONE_MARKER.test(snap)) {
781
+ sawDoneMarker = true;
782
+ }
783
+ if (snap === lastSnap) {
784
+ stable++;
785
+ }
786
+ else {
787
+ stable = 0;
788
+ lastSnap = snap;
789
+ }
790
+ const running = RUNNING_INDICATOR.test(snap);
791
+ const quietMs = Date.now() - lastGrowthMs;
792
+ const readyToJudge = sawOutput || (Date.now() - turnStart >= NOOUTPUT_MS);
793
+ const doneFast = sawDoneMarker && quietMs >= DEBOUNCE_MS;
794
+ const doneSlow = readyToJudge && quietMs >= QUIET_FALLBACK_MS && stable >= STABLE_POLLS;
795
+ if (!running && (doneFast || doneSlow)) {
796
+ // Final drain of the transcript to catch records written between the last
797
+ // poll and turn-end.
798
+ const tail = drainChatHistory(chatPath, chatOffset);
799
+ if (tail.text) {
800
+ chatOffset = tail.offset;
801
+ chatEmitted = true;
802
+ try {
803
+ fs.appendFileSync(stdoutFile, tail.text, 'utf8');
804
+ }
805
+ catch { /* ignore */ }
806
+ emitLines(tail.text);
807
+ }
808
+ // Safety net: if the transcript path never resolved (grok layout change /
809
+ // encoding drift) yet the pane clearly produced output, fall back to a
810
+ // cleaned pane tail so the turn is never silently empty.
811
+ if (!chatEmitted && sawOutput) {
812
+ const pane = stripAnsi(capturePane(sess.name, 400)).replace(/[ \t]+\n/g, '\n').trim();
813
+ if (pane) {
814
+ try {
815
+ fs.appendFileSync(stdoutFile, pane + '\n', 'utf8');
816
+ }
817
+ catch { /* ignore */ }
818
+ }
819
+ log('Grok TUI: transcript yielded no output — fell back to pane capture (check chat_history path)');
820
+ }
821
+ finish(0, 'idle');
822
+ return;
823
+ }
824
+ // 4) Watchdog — a turn that overruns the hard budget. Interrupt with Esc
825
+ // (grok's cancel) to preserve the session/context; if it won't settle,
826
+ // kill the session so the NEXT turn relaunches with --resume.
827
+ if (MAX_RUN_MS > 0 && Date.now() - turnStart >= MAX_RUN_MS) {
828
+ log(`Grok TUI watchdog: exceeded ${Math.round(MAX_RUN_MS / 60_000)}min budget — interrupting`);
829
+ try {
830
+ fs.appendFileSync(stdoutFile, `\n[Grok TUI watchdog: turn exceeded time budget — interrupted]\n`, 'utf8');
831
+ }
832
+ catch { /* ignore */ }
833
+ tmux(['send-keys', '-t', sess.name, 'Escape']);
834
+ // Give grok a moment to settle after the interrupt.
835
+ let settled = false;
836
+ for (let i = 0; i < 8; i++) {
837
+ await sleep(500);
838
+ if (!hasSession(sess.name)) {
839
+ break;
840
+ }
841
+ const s2 = capturePane(sess.name);
842
+ if (!RUNNING_INDICATOR.test(s2)) {
843
+ settled = true;
844
+ break;
845
+ }
846
+ }
847
+ if (!settled && hasSession(sess.name)) {
848
+ // Still wedged after Esc → kill so ensureSession relaunches next turn.
849
+ killSession(sess.name);
850
+ _tmuxSessions.delete(root);
851
+ }
852
+ finish(124, 'watchdog');
853
+ return;
854
+ }
855
+ }
856
+ })();
857
+ }
858
+ function sendGrokPrompt(root, promptFilePath, stdoutFile, exitFile, log, opts = {}) {
130
859
  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
860
  const modelArgs = opts.model ? ['-m', opts.model] : [];
135
861
  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
862
  const sessionArgs = [];
139
863
  if (opts.persist) {
140
864
  const providerId = opts.providerId ?? 'grok-tui';
@@ -163,9 +887,6 @@ promptFilePath, stdoutFile, exitFile, log, opts = {}) {
163
887
  fs.writeFileSync(exitFile, '', 'utf8');
164
888
  }
165
889
  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
890
  const prior = _activeChildren.get(root);
170
891
  if (prior) {
171
892
  try {
@@ -175,17 +896,12 @@ promptFilePath, stdoutFile, exitFile, log, opts = {}) {
175
896
  _activeChildren.delete(root);
176
897
  }
177
898
  _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
899
  const failSpawn = (msg) => {
183
900
  log(`Grok spawn error: ${msg}`);
184
901
  try {
185
902
  fs.appendFileSync(stdoutFile, `\n[Grok spawn error: ${msg}]\n`, 'utf8');
186
903
  }
187
904
  catch { /* ignore */ }
188
- // The exit file is what the loop waits on — without it the turn never ends.
189
905
  try {
190
906
  fs.writeFileSync(exitFile, '1\n', 'utf8');
191
907
  }
@@ -202,28 +918,12 @@ promptFilePath, stdoutFile, exitFile, log, opts = {}) {
202
918
  ...sessionArgs,
203
919
  '--prompt-file', promptFilePath,
204
920
  '--output-format', 'streaming-json',
205
- ], {
206
- cwd: root,
207
- stdio: ['ignore', 'pipe', 'pipe'],
208
- env: { ...process.env },
209
- });
921
+ ], { cwd: root, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env } });
210
922
  }
211
923
  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
924
  failSpawn(spawnErr?.message ?? String(spawnErr));
215
925
  return;
216
926
  }
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
927
  child.on('error', (err) => {
228
928
  const missing = err.code === 'ENOENT';
229
929
  failSpawn(missing
@@ -231,23 +931,7 @@ promptFilePath, stdoutFile, exitFile, log, opts = {}) {
231
931
  : err.message);
232
932
  });
233
933
  _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
934
  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
935
  const GROK_MAX_RUN_MS = envNum('AUTODEV_GROK_MAX_RUN_MS', 10 * 60_000);
252
936
  const GROK_MAX_TOOL_ERRORS = envNum('AUTODEV_GROK_MAX_TOOL_ERRORS', 25);
253
937
  let toolErrorCount = 0;
@@ -255,7 +939,7 @@ promptFilePath, stdoutFile, exitFile, log, opts = {}) {
255
939
  const killGrok = (why) => {
256
940
  if (killed) {
257
941
  return;
258
- } // never double-kill / double-log
942
+ }
259
943
  killed = true;
260
944
  log(`Grok watchdog: ${why} — killing run`);
261
945
  try {
@@ -270,21 +954,8 @@ promptFilePath, stdoutFile, exitFile, log, opts = {}) {
270
954
  const watchdog = GROK_MAX_RUN_MS > 0
271
955
  ? setTimeout(() => killGrok(`exceeded ${Math.round(GROK_MAX_RUN_MS / 60_000)}min time budget`), GROK_MAX_RUN_MS)
272
956
  : null;
273
- // -------------------------------------------------------------------------
274
- // Stream stdout — each line is a streaming-json object.
275
- // -------------------------------------------------------------------------
276
957
  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
958
  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
959
  let _activityBuf = '';
289
960
  let _activityTimer = null;
290
961
  const flushActivity = () => {
@@ -309,10 +980,6 @@ promptFilePath, stdoutFile, exitFile, log, opts = {}) {
309
980
  _activityTimer = setTimeout(flushActivity, 1200);
310
981
  }
311
982
  };
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
983
  let _lineBuf = '';
317
984
  const emitOutputLines = (text) => {
318
985
  _lineBuf += text;
@@ -341,7 +1008,6 @@ promptFilePath, stdoutFile, exitFile, log, opts = {}) {
341
1008
  msg = JSON.parse(line);
342
1009
  }
343
1010
  catch {
344
- // Plain text fallback (e.g. progress lines before JSON kicks in).
345
1011
  try {
346
1012
  fs.appendFileSync(stdoutFile, line + '\n', 'utf8');
347
1013
  }
@@ -350,16 +1016,13 @@ promptFilePath, stdoutFile, exitFile, log, opts = {}) {
350
1016
  }
351
1017
  const type = msg.type ?? '';
352
1018
  if (type === 'assistant' || type === 'text') {
353
- // Assistant response text.
354
1019
  const text = msg.content ?? msg.data ?? msg.text ?? msg.message ?? '';
355
1020
  if (text) {
356
1021
  try {
357
1022
  fs.appendFileSync(stdoutFile, text, 'utf8');
358
1023
  }
359
1024
  catch { /* ignore */ }
360
- // Log full lines only (buffer partial tokens until a newline arrives).
361
1025
  emitOutputLines(text);
362
- // Feed the office activity stream (coalesced).
363
1026
  _activityBuf += text;
364
1027
  scheduleActivity();
365
1028
  }
@@ -373,17 +1036,13 @@ promptFilePath, stdoutFile, exitFile, log, opts = {}) {
373
1036
  catch { /* ignore */ }
374
1037
  }
375
1038
  else if (type === 'end') {
376
- // Genuine turn-end from grok's stream → real session boundary.
377
1039
  _endSeen = true;
378
- flushOutputLine(); // print any half-line still buffered
379
- flushActivity(); // emit any buffered assistant text first
1040
+ flushOutputLine();
1041
+ flushActivity();
380
1042
  _emitGrokHook(root, 'Stop', {});
381
1043
  _emitGrokHook(root, 'SessionEnd', { reason: 'completed' });
382
1044
  }
383
- // `thought` and other event types stay out of the output file.
384
1045
  });
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
1046
  const errRl = child.stderr ? readline.createInterface({ input: child.stderr }) : null;
388
1047
  errRl?.on('line', (line) => {
389
1048
  const text = line.trim();
@@ -391,8 +1050,6 @@ promptFilePath, stdoutFile, exitFile, log, opts = {}) {
391
1050
  return;
392
1051
  }
393
1052
  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
1053
  if (GROK_MAX_TOOL_ERRORS > 0 && text.includes('tool_output_error')) {
397
1054
  toolErrorCount++;
398
1055
  if (toolErrorCount >= GROK_MAX_TOOL_ERRORS) {
@@ -404,16 +1061,14 @@ promptFilePath, stdoutFile, exitFile, log, opts = {}) {
404
1061
  if (watchdog) {
405
1062
  clearTimeout(watchdog);
406
1063
  }
407
- flushOutputLine(); // print any half-line still buffered
408
- flushActivity(); // flush any trailing assistant text
1064
+ flushOutputLine();
1065
+ flushActivity();
409
1066
  errRl?.close();
410
1067
  rl.close();
411
1068
  _activeChildren.delete(root);
412
1069
  _busyRoots.delete(root);
413
1070
  const exitCode = code ?? 1;
414
1071
  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
1072
  if (!_endSeen) {
418
1073
  _emitGrokHook(root, exitCode === 0 ? 'Stop' : 'StopFailure', { exit_code: exitCode });
419
1074
  _emitGrokHook(root, 'SessionEnd', { reason: exitCode === 0 ? 'completed' : 'error' });
@@ -424,28 +1079,78 @@ promptFilePath, stdoutFile, exitFile, log, opts = {}) {
424
1079
  catch { /* ignore */ }
425
1080
  });
426
1081
  }
427
- /** grok-cli: stateless — a fresh grok process every task (no session flags). */
1082
+ /** grok-cli: stateless — a fresh HEADLESS grok process every task. */
428
1083
  function sendGrokCliPrompt(root, promptFilePath, stdoutFile, exitFile, log, model, showOutput) {
429
1084
  sendGrokPrompt(root, promptFilePath, stdoutFile, exitFile, log, {
430
1085
  model, showOutput, persist: false, providerId: 'grok-cli',
431
1086
  });
432
1087
  }
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) {
1088
+ /**
1089
+ * grok-tui: persistent — run the turn in the per-workspace tmux session so
1090
+ * grok's in-process context accumulates across tasks. Falls back to the old
1091
+ * headless spawn (with --resume) when tmux is unavailable so nothing regresses.
1092
+ */
1093
+ function sendGrokTuiPrompt(root, promptFilePath, resolvedSessionId, stdoutFile, exitFile, log, model, showOutput) {
1094
+ if (tmuxAvailable()) {
1095
+ runGrokTmuxTurn(root, promptFilePath, resolvedSessionId, stdoutFile, exitFile, log, model, showOutput);
1096
+ return;
1097
+ }
1098
+ log('Grok TUI: tmux not available — falling back to headless spawn');
437
1099
  sendGrokPrompt(root, promptFilePath, stdoutFile, exitFile, log, {
438
1100
  model, showOutput, persist: true, sessionId: resolvedSessionId, providerId: 'grok-tui',
439
1101
  });
440
1102
  }
441
1103
  // ---------------------------------------------------------------------------
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.
1104
+ // steerGrokTui — inject a message into the RUNNING grok turn via the live pane.
1105
+ //
1106
+ // Because the tmux session is a real TTY, keys sent to a mid-turn pane are
1107
+ // delivered to grok's input and folded into the current turn (confirmed with a
1108
+ // live REPL stand-in). Mirrors steerClaudeTui: only injects when a turn is
1109
+ // actually running; returns false when there is no live pane so the caller
1110
+ // keeps the durable TODO fallback (at-least-once delivery).
1111
+ // ---------------------------------------------------------------------------
1112
+ async function steerGrokTui(root, text, log) {
1113
+ const sess = _tmuxSessions.get(root);
1114
+ if (!sess) {
1115
+ return false;
1116
+ } // no live tmux session (headless / not started)
1117
+ if (!_busyRoots.has(root)) {
1118
+ return false;
1119
+ } // only steer a turn in flight
1120
+ if (!hasSession(sess.name)) {
1121
+ _tmuxSessions.delete(root);
1122
+ return false;
1123
+ }
1124
+ try {
1125
+ // Literal keystrokes (send-keys -l), NOT a bracketed paste — grok's TUI
1126
+ // misreads a paste as a directory picker. Flatten to one line (no reliable
1127
+ // newline-without-submit key), then Enter to inject into the running turn.
1128
+ const flat = text.replace(/\r?\n+/g, ' ').replace(/[ \t]+/g, ' ').trim();
1129
+ for (let i = 0; i < flat.length; i += 3000) {
1130
+ if (tmux(['send-keys', '-t', sess.name, '-l', flat.slice(i, i + 3000)]) === null) {
1131
+ return false;
1132
+ }
1133
+ }
1134
+ if (tmux(['send-keys', '-t', sess.name, 'Enter']) === null) {
1135
+ return false;
1136
+ }
1137
+ log(`Grok TUI: steered live pane (${text.length} chars) — injected into current turn`);
1138
+ return true;
1139
+ }
1140
+ catch (err) {
1141
+ log(`Grok TUI: steer failed: ${err?.message ?? String(err)}`);
1142
+ return false;
1143
+ }
1144
+ }
1145
+ // ---------------------------------------------------------------------------
1146
+ // Teardown
446
1147
  // ---------------------------------------------------------------------------
1148
+ /**
1149
+ * Called by taskLoop reset interval / provider close. Kills any live grok for
1150
+ * this root — the tmux session AND any headless child — while PRESERVING the
1151
+ * persisted session id so a later task relaunches with --resume (context intact).
1152
+ */
447
1153
  function closeGrokTuiSession(root, _log) {
448
- // Kill any active child for this root; keep the persisted session id.
449
1154
  const child = _activeChildren.get(root);
450
1155
  if (child) {
451
1156
  try {
@@ -454,9 +1159,21 @@ function closeGrokTuiSession(root, _log) {
454
1159
  catch { /* ignore */ }
455
1160
  _activeChildren.delete(root);
456
1161
  }
1162
+ const sess = _tmuxSessions.get(root);
1163
+ if (sess) {
1164
+ killSession(sess.name);
1165
+ _tmuxSessions.delete(root);
1166
+ }
1167
+ else {
1168
+ // No cached handle but a session may still exist under the deterministic name.
1169
+ const name = sessionName(root);
1170
+ if (hasSession(name)) {
1171
+ killSession(name);
1172
+ }
1173
+ }
457
1174
  _busyRoots.delete(root);
458
1175
  }
459
- /** Kill all active grok processes — called on extension deactivate. */
1176
+ /** Kill all live grok sessions — called on SDK/extension shutdown. */
460
1177
  function closeAllGrokTuiSessions() {
461
1178
  for (const child of _activeChildren.values()) {
462
1179
  try {
@@ -465,6 +1182,10 @@ function closeAllGrokTuiSessions() {
465
1182
  catch { /* ignore */ }
466
1183
  }
467
1184
  _activeChildren.clear();
1185
+ for (const sess of _tmuxSessions.values()) {
1186
+ killSession(sess.name);
1187
+ }
1188
+ _tmuxSessions.clear();
468
1189
  _busyRoots.clear();
469
1190
  }
470
1191
  // ---------------------------------------------------------------------------