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.
- package/out/providers/grokTuiProvider.d.ts +20 -16
- package/out/providers/grokTuiProvider.js +846 -125
- package/out/providers/grokTuiProvider.js.map +1 -1
- package/out/taskLoop.js +22 -7
- package/out/taskLoop.js.map +1 -1
- package/out/webSocketPoller.js +10 -3
- package/out/webSocketPoller.js.map +1 -1
- package/package.json +1 -1
|
@@ -1,25 +1,40 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
// ---------------------------------------------------------------------------
|
|
3
|
-
// grokProvider -- Grok tasks via the `grok`
|
|
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,
|
|
7
|
-
// context accumulation across tasks.
|
|
8
|
-
// grok-tui (persistent):
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
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
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
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
|
-
//
|
|
19
|
-
//
|
|
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
|
-
//
|
|
22
|
-
//
|
|
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 =
|
|
82
|
-
const local =
|
|
83
|
-
if (
|
|
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 —
|
|
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`).
|
|
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
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
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
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
}
|
|
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();
|
|
379
|
-
flushActivity();
|
|
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();
|
|
408
|
-
flushActivity();
|
|
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
|
|
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
|
-
/**
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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
|
-
//
|
|
443
|
-
//
|
|
444
|
-
// session
|
|
445
|
-
//
|
|
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
|
|
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
|
// ---------------------------------------------------------------------------
|