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