autodev-cli 1.4.131 → 1.4.133
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/LICENSE.md +21 -0
- package/media/profile/00b-self-verifiable-work.md +62 -0
- package/media/profile/00c-verifiable-threads.md +78 -0
- package/media/profile/00d-turn-end-retrospective.md +61 -0
- package/media/profile/00e-long-horizon-planning.md +83 -0
- package/out/commands/office.js +4 -0
- package/out/commands/office.js.map +1 -1
- package/out/commands/start.js +1 -1
- package/out/commands/start.js.map +1 -1
- package/out/core/claudeUsage.d.ts +1 -26
- package/out/core/claudeUsage.js +15 -91
- package/out/core/claudeUsage.js.map +1 -1
- package/out/core/modelInfo.d.ts +1 -28
- package/out/core/modelInfo.js +15 -94
- package/out/core/modelInfo.js.map +1 -1
- package/out/core/provider/BaseProvider.d.ts +1 -14
- package/out/core/provider/BaseProvider.js +15 -22
- package/out/core/provider/BaseProvider.js.map +1 -1
- package/out/core/provider/ProviderRegistry.d.ts +1 -12
- package/out/core/provider/ProviderRegistry.js +15 -38
- package/out/core/provider/ProviderRegistry.js.map +1 -1
- package/out/core/provider/contract.d.ts +1 -62
- package/out/core/provider/contract.js +15 -6
- package/out/core/provider/contract.js.map +1 -1
- package/out/core/provider/implementations.d.ts +1 -65
- package/out/core/provider/implementations.js +4 -169
- package/out/core/provider/implementations.js.map +1 -1
- package/out/dispatcher.js +7 -0
- package/out/dispatcher.js.map +1 -1
- package/out/hookEventNormalizer.d.ts +1 -33
- package/out/hookEventNormalizer.js +15 -424
- package/out/hookEventNormalizer.js.map +1 -1
- package/out/profileBuilder.js +32 -0
- package/out/profileBuilder.js.map +1 -1
- package/out/providers/base/BaseProvider.d.ts +14 -0
- package/out/providers/base/BaseProvider.js +25 -0
- package/out/providers/base/BaseProvider.js.map +1 -0
- package/out/providers/base/ProviderEvents.d.ts +12 -0
- package/out/providers/base/ProviderEvents.js +81 -0
- package/out/providers/base/ProviderEvents.js.map +1 -0
- package/out/providers/base/ProviderManager.d.ts +25 -0
- package/out/providers/base/ProviderManager.js +22 -0
- package/out/providers/base/ProviderManager.js.map +1 -0
- package/out/providers/base/ProviderModels.d.ts +28 -0
- package/out/providers/base/ProviderModels.js +89 -0
- package/out/providers/base/ProviderModels.js.map +1 -0
- package/out/providers/base/ProviderNormalizer.d.ts +42 -0
- package/out/providers/base/ProviderNormalizer.js +439 -0
- package/out/providers/base/ProviderNormalizer.js.map +1 -0
- package/out/providers/base/ProviderRegistry.d.ts +12 -0
- package/out/providers/base/ProviderRegistry.js +42 -0
- package/out/providers/base/ProviderRegistry.js.map +1 -0
- package/out/providers/base/ProviderSessions.d.ts +15 -0
- package/out/providers/base/ProviderSessions.js +15 -0
- package/out/providers/base/ProviderSessions.js.map +1 -0
- package/out/providers/base/ProviderState.d.ts +9 -0
- package/out/providers/base/ProviderState.js +16 -0
- package/out/providers/base/ProviderState.js.map +1 -0
- package/out/providers/base/ProviderUsage.d.ts +12 -0
- package/out/providers/base/ProviderUsage.js +33 -0
- package/out/providers/base/ProviderUsage.js.map +1 -0
- package/out/providers/base/contract.d.ts +62 -0
- package/out/providers/base/contract.js +9 -0
- package/out/providers/base/contract.js.map +1 -0
- package/out/providers/base/factories.d.ts +64 -0
- package/out/providers/base/factories.js +265 -0
- package/out/providers/base/factories.js.map +1 -0
- package/out/providers/base/implementations.d.ts +5 -0
- package/out/providers/base/implementations.js +23 -0
- package/out/providers/base/implementations.js.map +1 -0
- package/out/providers/base/index.d.ts +13 -0
- package/out/providers/base/index.js +29 -0
- package/out/providers/base/index.js.map +1 -0
- package/out/providers/base/types.d.ts +30 -0
- package/out/providers/base/types.js +8 -0
- package/out/providers/base/types.js.map +1 -0
- package/out/providers/claude/ProviderEvents.d.ts +5 -0
- package/out/providers/claude/ProviderEvents.js +18 -0
- package/out/providers/claude/ProviderEvents.js.map +1 -0
- package/out/providers/claude/ProviderManager.d.ts +39 -0
- package/out/providers/claude/ProviderManager.js +50 -0
- package/out/providers/claude/ProviderManager.js.map +1 -0
- package/out/providers/claude/ProviderModels.d.ts +8 -0
- package/out/providers/claude/ProviderModels.js +23 -0
- package/out/providers/claude/ProviderModels.js.map +1 -0
- package/out/providers/claude/ProviderNormalizer.d.ts +5 -0
- package/out/providers/claude/ProviderNormalizer.js +10 -0
- package/out/providers/claude/ProviderNormalizer.js.map +1 -0
- package/out/providers/claude/ProviderSessions.d.ts +14 -0
- package/out/providers/claude/ProviderSessions.js +35 -0
- package/out/providers/claude/ProviderSessions.js.map +1 -0
- package/out/providers/claude/ProviderState.d.ts +13 -0
- package/out/providers/claude/ProviderState.js +19 -0
- package/out/providers/claude/ProviderState.js.map +1 -0
- package/out/providers/claude/ProviderUsage.d.ts +13 -0
- package/out/providers/claude/ProviderUsage.js +59 -0
- package/out/providers/claude/ProviderUsage.js.map +1 -0
- package/out/providers/claude/cli.d.ts +95 -0
- package/out/providers/claude/cli.js +674 -0
- package/out/providers/claude/cli.js.map +1 -0
- package/out/providers/claude/index.d.ts +9 -0
- package/out/providers/claude/index.js +26 -0
- package/out/providers/claude/index.js.map +1 -0
- package/out/providers/claude/tui.d.ts +38 -0
- package/out/providers/claude/tui.js +346 -0
- package/out/providers/claude/tui.js.map +1 -0
- package/out/providers/claudeCliProvider.d.ts +1 -95
- package/out/providers/claudeCliProvider.js +4 -660
- package/out/providers/claudeCliProvider.js.map +1 -1
- package/out/providers/claudeTuiProvider.d.ts +1 -38
- package/out/providers/claudeTuiProvider.js +4 -332
- package/out/providers/claudeTuiProvider.js.map +1 -1
- package/out/providers/copilot/ProviderEvents.d.ts +6 -0
- package/out/providers/copilot/ProviderEvents.js +18 -0
- package/out/providers/copilot/ProviderEvents.js.map +1 -0
- package/out/providers/copilot/ProviderManager.d.ts +39 -0
- package/out/providers/copilot/ProviderManager.js +98 -0
- package/out/providers/copilot/ProviderManager.js.map +1 -0
- package/out/providers/copilot/ProviderModels.d.ts +7 -0
- package/out/providers/copilot/ProviderModels.js +18 -0
- package/out/providers/copilot/ProviderModels.js.map +1 -0
- package/out/providers/copilot/ProviderNormalizer.d.ts +5 -0
- package/out/providers/copilot/ProviderNormalizer.js +10 -0
- package/out/providers/copilot/ProviderNormalizer.js.map +1 -0
- package/out/providers/copilot/ProviderSessions.d.ts +12 -0
- package/out/providers/copilot/ProviderSessions.js +39 -0
- package/out/providers/copilot/ProviderSessions.js.map +1 -0
- package/out/providers/copilot/ProviderState.d.ts +11 -0
- package/out/providers/copilot/ProviderState.js +17 -0
- package/out/providers/copilot/ProviderState.js.map +1 -0
- package/out/providers/copilot/ProviderUsage.d.ts +12 -0
- package/out/providers/copilot/ProviderUsage.js +41 -0
- package/out/providers/copilot/ProviderUsage.js.map +1 -0
- package/out/providers/copilot/cli.d.ts +16 -0
- package/out/providers/copilot/cli.js +52 -0
- package/out/providers/copilot/cli.js.map +1 -0
- package/out/providers/copilot/index.d.ts +10 -0
- package/out/providers/copilot/index.js +27 -0
- package/out/providers/copilot/index.js.map +1 -0
- package/out/providers/copilot/sdk.d.ts +22 -0
- package/out/providers/copilot/sdk.js +489 -0
- package/out/providers/copilot/sdk.js.map +1 -0
- package/out/providers/copilot/sessions.d.ts +25 -0
- package/out/providers/copilot/sessions.js +214 -0
- package/out/providers/copilot/sessions.js.map +1 -0
- package/out/providers/copilotCliProvider.d.ts +1 -16
- package/out/providers/copilotCliProvider.js +15 -49
- package/out/providers/copilotCliProvider.js.map +1 -1
- package/out/providers/copilotSdkProvider.d.ts +1 -22
- package/out/providers/copilotSdkProvider.js +4 -475
- package/out/providers/copilotSdkProvider.js.map +1 -1
- package/out/providers/copilotSessions.d.ts +1 -25
- package/out/providers/copilotSessions.js +4 -200
- package/out/providers/copilotSessions.js.map +1 -1
- package/out/providers/dummy/ProviderEvents.d.ts +5 -0
- package/out/providers/dummy/ProviderEvents.js +16 -0
- package/out/providers/dummy/ProviderEvents.js.map +1 -0
- package/out/providers/dummy/ProviderManager.d.ts +31 -0
- package/out/providers/dummy/ProviderManager.js +115 -0
- package/out/providers/dummy/ProviderManager.js.map +1 -0
- package/out/providers/dummy/ProviderModels.d.ts +7 -0
- package/out/providers/dummy/ProviderModels.js +15 -0
- package/out/providers/dummy/ProviderModels.js.map +1 -0
- package/out/providers/dummy/ProviderNormalizer.d.ts +4 -0
- package/out/providers/dummy/ProviderNormalizer.js +9 -0
- package/out/providers/dummy/ProviderNormalizer.js.map +1 -0
- package/out/providers/dummy/ProviderSessions.d.ts +12 -0
- package/out/providers/dummy/ProviderSessions.js +105 -0
- package/out/providers/dummy/ProviderSessions.js.map +1 -0
- package/out/providers/dummy/ProviderState.d.ts +12 -0
- package/out/providers/dummy/ProviderState.js +23 -0
- package/out/providers/dummy/ProviderState.js.map +1 -0
- package/out/providers/dummy/ProviderUsage.d.ts +9 -0
- package/out/providers/dummy/ProviderUsage.js +27 -0
- package/out/providers/dummy/ProviderUsage.js.map +1 -0
- package/out/providers/dummy/commands.d.ts +9 -0
- package/out/providers/dummy/commands.js +48 -0
- package/out/providers/dummy/commands.js.map +1 -0
- package/out/providers/dummy/index.d.ts +10 -0
- package/out/providers/dummy/index.js +27 -0
- package/out/providers/dummy/index.js.map +1 -0
- package/out/providers/dummy/office.d.ts +23 -0
- package/out/providers/dummy/office.js +66 -0
- package/out/providers/dummy/office.js.map +1 -0
- package/out/providers/dummy/traces.d.ts +8 -0
- package/out/providers/dummy/traces.js +174 -0
- package/out/providers/dummy/traces.js.map +1 -0
- package/out/providers/grok/ProviderEvents.d.ts +6 -0
- package/out/providers/grok/ProviderEvents.js +17 -0
- package/out/providers/grok/ProviderEvents.js.map +1 -0
- package/out/providers/grok/ProviderManager.d.ts +42 -0
- package/out/providers/grok/ProviderManager.js +48 -0
- package/out/providers/grok/ProviderManager.js.map +1 -0
- package/out/providers/grok/ProviderModels.d.ts +7 -0
- package/out/providers/grok/ProviderModels.js +16 -0
- package/out/providers/grok/ProviderModels.js.map +1 -0
- package/out/providers/grok/ProviderNormalizer.d.ts +5 -0
- package/out/providers/grok/ProviderNormalizer.js +10 -0
- package/out/providers/grok/ProviderNormalizer.js.map +1 -0
- package/out/providers/grok/ProviderSessions.d.ts +12 -0
- package/out/providers/grok/ProviderSessions.js +27 -0
- package/out/providers/grok/ProviderSessions.js.map +1 -0
- package/out/providers/grok/ProviderState.d.ts +10 -0
- package/out/providers/grok/ProviderState.js +15 -0
- package/out/providers/grok/ProviderState.js.map +1 -0
- package/out/providers/grok/ProviderUsage.d.ts +7 -0
- package/out/providers/grok/ProviderUsage.js +20 -0
- package/out/providers/grok/ProviderUsage.js.map +1 -0
- package/out/providers/grok/index.d.ts +9 -0
- package/out/providers/grok/index.js +26 -0
- package/out/providers/grok/index.js.map +1 -0
- package/out/providers/grok/sessions.d.ts +27 -0
- package/out/providers/grok/sessions.js +246 -0
- package/out/providers/grok/sessions.js.map +1 -0
- package/out/providers/grok/tui.d.ts +38 -0
- package/out/providers/grok/tui.js +1630 -0
- package/out/providers/grok/tui.js.map +1 -0
- package/out/providers/grokSessions.d.ts +1 -27
- package/out/providers/grokSessions.js +4 -232
- package/out/providers/grokSessions.js.map +1 -1
- package/out/providers/grokTuiProvider.d.ts +1 -38
- package/out/providers/grokTuiProvider.js +4 -1616
- package/out/providers/grokTuiProvider.js.map +1 -1
- package/out/providers/opencode/ProviderEvents.d.ts +6 -0
- package/out/providers/opencode/ProviderEvents.js +18 -0
- package/out/providers/opencode/ProviderEvents.js.map +1 -0
- package/out/providers/opencode/ProviderManager.d.ts +39 -0
- package/out/providers/opencode/ProviderManager.js +90 -0
- package/out/providers/opencode/ProviderManager.js.map +1 -0
- package/out/providers/opencode/ProviderModels.d.ts +7 -0
- package/out/providers/opencode/ProviderModels.js +17 -0
- package/out/providers/opencode/ProviderModels.js.map +1 -0
- package/out/providers/opencode/ProviderNormalizer.d.ts +5 -0
- package/out/providers/opencode/ProviderNormalizer.js +10 -0
- package/out/providers/opencode/ProviderNormalizer.js.map +1 -0
- package/out/providers/opencode/ProviderSessions.d.ts +11 -0
- package/out/providers/opencode/ProviderSessions.js +28 -0
- package/out/providers/opencode/ProviderSessions.js.map +1 -0
- package/out/providers/opencode/ProviderState.d.ts +14 -0
- package/out/providers/opencode/ProviderState.js +20 -0
- package/out/providers/opencode/ProviderState.js.map +1 -0
- package/out/providers/opencode/ProviderUsage.d.ts +7 -0
- package/out/providers/opencode/ProviderUsage.js +20 -0
- package/out/providers/opencode/ProviderUsage.js.map +1 -0
- package/out/providers/opencode/cli.d.ts +29 -0
- package/out/providers/opencode/cli.js +199 -0
- package/out/providers/opencode/cli.js.map +1 -0
- package/out/providers/opencode/index.d.ts +9 -0
- package/out/providers/opencode/index.js +26 -0
- package/out/providers/opencode/index.js.map +1 -0
- package/out/providers/opencode/sdk.d.ts +32 -0
- package/out/providers/opencode/sdk.js +610 -0
- package/out/providers/opencode/sdk.js.map +1 -0
- package/out/providers/opencodeCliProvider.d.ts +1 -29
- package/out/providers/opencodeCliProvider.js +15 -196
- package/out/providers/opencodeCliProvider.js.map +1 -1
- package/out/providers/opencodeSdkProvider.d.ts +1 -32
- package/out/providers/opencodeSdkProvider.js +4 -596
- package/out/providers/opencodeSdkProvider.js.map +1 -1
- package/out/providers.d.ts +1 -1
- package/out/providers.js +5 -0
- package/out/providers.js.map +1 -1
- package/out/sdk/index.js +5 -0
- package/out/sdk/index.js.map +1 -1
- package/package.json +2 -2
|
@@ -1,41 +1,4 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
-
// ---------------------------------------------------------------------------
|
|
3
|
-
// grokProvider -- Grok tasks via the `grok` CLI. Backs BOTH provider variants:
|
|
4
|
-
//
|
|
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.
|
|
11
|
-
//
|
|
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.
|
|
23
|
-
//
|
|
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.
|
|
29
|
-
//
|
|
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.
|
|
38
|
-
// ---------------------------------------------------------------------------
|
|
39
2
|
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
40
3
|
if (k2 === undefined) k2 = k;
|
|
41
4
|
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
@@ -47,1584 +10,9 @@ var __createBinding = (this && this.__createBinding) || (Object.create ? (functi
|
|
|
47
10
|
if (k2 === undefined) k2 = k;
|
|
48
11
|
o[k2] = m[k];
|
|
49
12
|
}));
|
|
50
|
-
var
|
|
51
|
-
|
|
52
|
-
}) : function(o, v) {
|
|
53
|
-
o["default"] = v;
|
|
54
|
-
});
|
|
55
|
-
var __importStar = (this && this.__importStar) || (function () {
|
|
56
|
-
var ownKeys = function(o) {
|
|
57
|
-
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
58
|
-
var ar = [];
|
|
59
|
-
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
60
|
-
return ar;
|
|
61
|
-
};
|
|
62
|
-
return ownKeys(o);
|
|
63
|
-
};
|
|
64
|
-
return function (mod) {
|
|
65
|
-
if (mod && mod.__esModule) return mod;
|
|
66
|
-
var result = {};
|
|
67
|
-
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
68
|
-
__setModuleDefault(result, mod);
|
|
69
|
-
return result;
|
|
70
|
-
};
|
|
71
|
-
})();
|
|
72
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
73
|
-
exports.isGrokTuiBusy = isGrokTuiBusy;
|
|
74
|
-
exports.getGrokTuiLastActivity = getGrokTuiLastActivity;
|
|
75
|
-
exports.forceIdleGrokTui = forceIdleGrokTui;
|
|
76
|
-
exports.tmuxAvailable = tmuxAvailable;
|
|
77
|
-
exports.sendGrokPrompt = sendGrokPrompt;
|
|
78
|
-
exports.sendGrokCliPrompt = sendGrokCliPrompt;
|
|
79
|
-
exports.sendGrokTuiPrompt = sendGrokTuiPrompt;
|
|
80
|
-
exports.steerGrokTui = steerGrokTui;
|
|
81
|
-
exports.closeGrokTuiSession = closeGrokTuiSession;
|
|
82
|
-
exports.closeAllGrokTuiSessions = closeAllGrokTuiSessions;
|
|
83
|
-
exports.detectGrokTuiRateLimit = detectGrokTuiRateLimit;
|
|
84
|
-
const path = __importStar(require("path"));
|
|
85
|
-
const fs = __importStar(require("fs"));
|
|
86
|
-
const os = __importStar(require("os"));
|
|
87
|
-
const crypto = __importStar(require("crypto"));
|
|
88
|
-
const readline = __importStar(require("readline"));
|
|
89
|
-
const child_process = __importStar(require("child_process"));
|
|
90
|
-
const crypto_1 = require("crypto");
|
|
91
|
-
const hookEventNormalizer_1 = require("../hookEventNormalizer");
|
|
92
|
-
const rateLimit_1 = require("../rateLimit");
|
|
93
|
-
const sessionState_1 = require("../sessionState");
|
|
94
|
-
// ---------------------------------------------------------------------------
|
|
95
|
-
// Constants
|
|
96
|
-
// ---------------------------------------------------------------------------
|
|
97
|
-
/** Path to the grok binary — use env override for non-default installs. */
|
|
98
|
-
const GROK_BIN = process.env['GROK_BIN'] ?? (() => {
|
|
99
|
-
// Prefer a grok on PATH; otherwise fall back to the default install location
|
|
100
|
-
// (~/.grok/bin/grok) so the provider works without PATH changes.
|
|
101
|
-
try {
|
|
102
|
-
const home = os.homedir();
|
|
103
|
-
const local = path.join(home, '.grok', 'bin', 'grok');
|
|
104
|
-
if (fs.existsSync(local)) {
|
|
105
|
-
return local;
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
catch { /* ignore */ }
|
|
109
|
-
return 'grok';
|
|
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;
|
|
13
|
+
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
14
|
+
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
117
15
|
};
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
// ---------------------------------------------------------------------------
|
|
121
|
-
/** Roots with an actively-running grok turn (headless OR tmux). */
|
|
122
|
-
const _busyRoots = new Set();
|
|
123
|
-
/** Active HEADLESS child processes by root — grok-cli + tmux-unavailable fallback. */
|
|
124
|
-
const _activeChildren = new Map();
|
|
125
|
-
const _tmuxSessions = new Map();
|
|
126
|
-
// Per-root set of grok tool_call ids already emitted as PreToolUse, so a re-scan of
|
|
127
|
-
// the transcript can't emit the SAME tool call twice → duplicate rows in the office
|
|
128
|
-
// hook feed (observed ~2.4× per call). PostToolUse is already dedup'd by pending.delete;
|
|
129
|
-
// PreToolUse had no guard. tool_call ids are unique per call, so this is exact. Bounded
|
|
130
|
-
// so an overnight run can't grow it unboundedly; cleared when the session is closed.
|
|
131
|
-
const _emittedToolIds = new Map();
|
|
132
|
-
const EMITTED_TOOL_IDS_CAP = 8000;
|
|
133
|
-
// Reap grok on process shutdown. grok runs in a DETACHED tmux session that outlives
|
|
134
|
-
// the loop, so killing the loop (app Stop, `autodev stop`, SIGTERM) would otherwise
|
|
135
|
-
// ORPHAN grok — it keeps running/burning tokens, its stale office presence makes the
|
|
136
|
-
// agent look stuck ("Stopping…" forever), and orphans accumulate across restarts.
|
|
137
|
-
// closeAllGrokTuiSessions() (defined below, hoisted) reaps every session this process
|
|
138
|
-
// launched; it was previously only wired to SDK/extension shutdown, not the standalone
|
|
139
|
-
// `autodev start` loop. A signal handler suppresses Node's default terminate, so we
|
|
140
|
-
// re-exit after the sync cleanup; 'exit' is a backstop for other exit paths. No other
|
|
141
|
-
// SIGTERM/SIGINT handler exists in the CLI, so taking the signal here is safe.
|
|
142
|
-
let _grokShuttingDown = false;
|
|
143
|
-
function _grokReapOnSignal(sig) {
|
|
144
|
-
if (_grokShuttingDown) {
|
|
145
|
-
return;
|
|
146
|
-
}
|
|
147
|
-
_grokShuttingDown = true;
|
|
148
|
-
try {
|
|
149
|
-
closeAllGrokTuiSessions();
|
|
150
|
-
}
|
|
151
|
-
catch { /* best effort */ }
|
|
152
|
-
process.exit(sig === 'SIGINT' ? 130 : 143);
|
|
153
|
-
}
|
|
154
|
-
try {
|
|
155
|
-
process.on('SIGTERM', () => _grokReapOnSignal('SIGTERM'));
|
|
156
|
-
process.on('SIGINT', () => _grokReapOnSignal('SIGINT'));
|
|
157
|
-
process.on('exit', () => { try {
|
|
158
|
-
closeAllGrokTuiSessions();
|
|
159
|
-
}
|
|
160
|
-
catch { /* best effort */ } });
|
|
161
|
-
}
|
|
162
|
-
catch { /* handlers restricted in this env */ }
|
|
163
|
-
/** Epoch-ms of the last streamed pane growth per root (activity heartbeat). */
|
|
164
|
-
const _lastActivityMs = new Map();
|
|
165
|
-
/** True while a grok turn is running for the given workspace root. */
|
|
166
|
-
function isGrokTuiBusy(root) {
|
|
167
|
-
return _busyRoots.has(root);
|
|
168
|
-
}
|
|
169
|
-
/** Epoch-ms of the most recent streamed pane activity, or 0 if none. */
|
|
170
|
-
function getGrokTuiLastActivity(root) {
|
|
171
|
-
return _lastActivityMs.get(root) ?? 0;
|
|
172
|
-
}
|
|
173
|
-
/** Force-clear the busy flag for a root whose turn appears hung. */
|
|
174
|
-
function forceIdleGrokTui(root) {
|
|
175
|
-
_busyRoots.delete(root);
|
|
176
|
-
}
|
|
177
|
-
/**
|
|
178
|
-
* Append a REAL grok activity event to `.autodev/hooks-events.jsonl` in the
|
|
179
|
-
* native Claude-Code hook schema (pixel-office reads `hook_event_name`).
|
|
180
|
-
*/
|
|
181
|
-
function _emitGrokHook(root, hookEventName, extra = {}) {
|
|
182
|
-
try {
|
|
183
|
-
const dir = path.join(root, '.autodev');
|
|
184
|
-
if (!fs.existsSync(dir)) {
|
|
185
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
186
|
-
}
|
|
187
|
-
const ev = { hook_event_name: hookEventName, event_type: (0, hookEventNormalizer_1.eventTypeFor)(hookEventName), provider: 'grok-tui', cwd: root, timestamp: new Date().toISOString(), ...extra };
|
|
188
|
-
fs.appendFileSync(path.join(dir, 'hooks-events.jsonl'), JSON.stringify(ev) + '\n', 'utf8');
|
|
189
|
-
}
|
|
190
|
-
catch { /* non-critical */ }
|
|
191
|
-
}
|
|
192
|
-
// ---------------------------------------------------------------------------
|
|
193
|
-
// tmux helpers — all synchronous, best-effort (swallow + report).
|
|
194
|
-
// ---------------------------------------------------------------------------
|
|
195
|
-
let _tmuxAvailable = null;
|
|
196
|
-
/** True if tmux is installed and runnable. Cached after first probe. */
|
|
197
|
-
function tmuxAvailable() {
|
|
198
|
-
if (_tmuxAvailable !== null) {
|
|
199
|
-
return _tmuxAvailable;
|
|
200
|
-
}
|
|
201
|
-
try {
|
|
202
|
-
child_process.execFileSync(TMUX_BIN, ['-V'], { stdio: 'ignore', timeout: 5_000 });
|
|
203
|
-
_tmuxAvailable = true;
|
|
204
|
-
}
|
|
205
|
-
catch {
|
|
206
|
-
_tmuxAvailable = false;
|
|
207
|
-
}
|
|
208
|
-
return _tmuxAvailable;
|
|
209
|
-
}
|
|
210
|
-
/** Run a tmux subcommand; returns stdout string, or null on any failure. */
|
|
211
|
-
function tmux(args, input) {
|
|
212
|
-
try {
|
|
213
|
-
const out = child_process.execFileSync(TMUX_BIN, args, {
|
|
214
|
-
encoding: 'utf8', timeout: 15_000, input, stdio: ['pipe', 'pipe', 'ignore'],
|
|
215
|
-
});
|
|
216
|
-
return out ?? '';
|
|
217
|
-
}
|
|
218
|
-
catch {
|
|
219
|
-
return null;
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
/** Deterministic, tmux-safe session name from the absolute workspace path. */
|
|
223
|
-
function sessionName(root) {
|
|
224
|
-
const h = crypto.createHash('sha1').update(root).digest('hex').slice(0, 10);
|
|
225
|
-
return `grok-${h}`;
|
|
226
|
-
}
|
|
227
|
-
/** True if the tmux session exists (a dead grok collapses the session). */
|
|
228
|
-
function hasSession(name) {
|
|
229
|
-
try {
|
|
230
|
-
child_process.execFileSync(TMUX_BIN, ['has-session', '-t', name], { stdio: 'ignore', timeout: 5_000 });
|
|
231
|
-
return true;
|
|
232
|
-
}
|
|
233
|
-
catch {
|
|
234
|
-
return false;
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
/** Kill a tmux session (best-effort). */
|
|
238
|
-
function killSession(name) {
|
|
239
|
-
tmux(['kill-session', '-t', name]);
|
|
240
|
-
}
|
|
241
|
-
/** Escape-stripped visible grid + scrollback of a pane (for detection). */
|
|
242
|
-
function capturePane(name, scrollback = 200) {
|
|
243
|
-
const out = tmux(['capture-pane', '-p', '-t', name, '-S', `-${scrollback}`]);
|
|
244
|
-
return out === null ? '' : stripAnsi(out);
|
|
245
|
-
}
|
|
246
|
-
/** Shell-quote a single argument for a `send-keys "exec …"` command string. */
|
|
247
|
-
function shq(s) {
|
|
248
|
-
return `'${s.replace(/'/g, `'\\''`)}'`;
|
|
249
|
-
}
|
|
250
|
-
/**
|
|
251
|
-
* Strip ANSI/OSC/DCS escapes and carriage returns from raw pane bytes.
|
|
252
|
-
* `pipe-pane` captures the full TUI byte stream (SGR colour, mouse tracking,
|
|
253
|
-
* bracketed-paste markers, OSC titles, `\r`), which must be removed before the
|
|
254
|
-
* text is written to stdoutFile or scanned for banners.
|
|
255
|
-
*/
|
|
256
|
-
function stripAnsi(s) {
|
|
257
|
-
return s
|
|
258
|
-
// CSI sequences: ESC [ ... final-byte
|
|
259
|
-
.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '')
|
|
260
|
-
// OSC sequences: ESC ] ... (BEL | ST)
|
|
261
|
-
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '')
|
|
262
|
-
// DCS/PM/APC: ESC (P|X|^|_) ... ST
|
|
263
|
-
.replace(/\x1b[PX^_][^\x1b]*\x1b\\/g, '')
|
|
264
|
-
// Any other single-char escape
|
|
265
|
-
.replace(/\x1b[@-Z\\-_]/g, '')
|
|
266
|
-
.replace(/\r/g, '');
|
|
267
|
-
}
|
|
268
|
-
// ---------------------------------------------------------------------------
|
|
269
|
-
// Turn-end / auth detectors (calibrate against a live authed grok — see risks).
|
|
270
|
-
// ---------------------------------------------------------------------------
|
|
271
|
-
/**
|
|
272
|
-
* Markers that mean grok hit the OAuth/expired-token gate instead of a prompt.
|
|
273
|
-
* The loop must treat this as reauth, NOT a hung turn.
|
|
274
|
-
*/
|
|
275
|
-
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;
|
|
276
|
-
/**
|
|
277
|
-
* "grok finished this turn" affordance — CALIBRATED against grok 4.5 build 0.2.93:
|
|
278
|
-
* a completed turn prints "Worked for 4.1s." (a summary line) and the footer drops
|
|
279
|
-
* the cancel affordance. Used as a fast, positive turn-end confirmation.
|
|
280
|
-
*/
|
|
281
|
-
const DONE_MARKER = /worked for\s+[\d.]+\s*s\b/i;
|
|
282
|
-
/**
|
|
283
|
-
* "grok is actively working" affordance. grok renders a live spinner + an
|
|
284
|
-
* "esc to interrupt"-style footer while a turn runs. If present we never
|
|
285
|
-
* declare idle. This is a SOFT guard: the primary idle signal is output
|
|
286
|
-
* quiescence + a byte-identical pane snapshot across polls (a running spinner
|
|
287
|
-
* animates, so the snapshot is never stable while grok works), which fires
|
|
288
|
-
* cleanly even if this pattern never matches on a given grok build.
|
|
289
|
-
*/
|
|
290
|
-
// CALIBRATED against real grok 4.5 (build 0.2.93): while a turn runs the footer
|
|
291
|
-
// shows "Ctrl+c:cancel" and a "⠧ Waiting for response… 1.5s … [stop]" status line;
|
|
292
|
-
// when idle the footer is only "Shift+Tab:mode │ Ctrl+x:shortcuts" (no Ctrl+c).
|
|
293
|
-
// So the presence of Ctrl+c / "waiting for response" / "[stop]" / a braille spinner
|
|
294
|
-
// = working. This is the primary running signal (backed by output-quiescence).
|
|
295
|
-
const RUNNING_INDICATOR = /ctrl\+c\s*[: ]|waiting for response|\[stop\]|esc to (?:interrupt|cancel)|⠋|⠙|⠹|⠸|⠼|⠴|⠦|⠧|⠇|⠏/i;
|
|
296
|
-
// ---------------------------------------------------------------------------
|
|
297
|
-
// grok-tui: persistent interactive session driven through tmux.
|
|
298
|
-
// ---------------------------------------------------------------------------
|
|
299
|
-
/** Launch a fresh grok process in a new detached tmux session for this root. */
|
|
300
|
-
/** Block the calling thread for `ms` — launchSession is synchronous, so the
|
|
301
|
-
* cross-process launch serialization below needs a synchronous wait. */
|
|
302
|
-
function sleepSyncMs(ms) {
|
|
303
|
-
if (!(ms > 0))
|
|
304
|
-
return;
|
|
305
|
-
try {
|
|
306
|
-
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
307
|
-
}
|
|
308
|
-
catch {
|
|
309
|
-
const end = Date.now() + ms;
|
|
310
|
-
while (Date.now() < end) { /* spin fallback */ }
|
|
311
|
-
}
|
|
312
|
-
}
|
|
313
|
-
// ── Cross-process grok-tui launch serialization ─────────────────────────────
|
|
314
|
-
// Every office agent runs in its OWN `autodev start` process, so an in-process
|
|
315
|
-
// mutex can't help: when many agents fire a turn at the same instant they all
|
|
316
|
-
// hit `tmux new-session` (and the shared ~/.grok config) simultaneously, the
|
|
317
|
-
// tmux server races on startup, and grok dies during startup ("session died
|
|
318
|
-
// during startup"). A lockfile + a minimum spacing between launches turns that
|
|
319
|
-
// stampede into an orderly ~1-launch-per-second queue. Tunable/disable via
|
|
320
|
-
// GROK_TMUX_LAUNCH_SPACING_MS (0 disables the spacing; the lock still serializes).
|
|
321
|
-
const TMUX_LAUNCH_LOCK = path.join(os.homedir(), '.grok', '.autodev-tmux-launch.lock');
|
|
322
|
-
const TMUX_LAUNCH_STAMP = path.join(os.homedir(), '.grok', '.autodev-tmux-launch.stamp');
|
|
323
|
-
// 10s default: 1s only spaced the tmux new-session call, but grok's ~9s startup
|
|
324
|
-
// (where it initializes its session + writes the shared session_search.sqlite
|
|
325
|
-
// index) still overlapped across agents and raced. Holding the slot ~10s lets
|
|
326
|
-
// each grok fully initialize its session before the next one launches.
|
|
327
|
-
const TMUX_LAUNCH_SPACING_MS = envNum('GROK_TMUX_LAUNCH_SPACING_MS', 10000);
|
|
328
|
-
const TMUX_LOCK_STALE_MS = 15000;
|
|
329
|
-
/** Acquire the shared launch slot (atomic-create lockfile, steal if stale), then
|
|
330
|
-
* wait out the minimum spacing since the previous launch. Best-effort: never
|
|
331
|
-
* blocks a turn forever. */
|
|
332
|
-
function acquireTmuxLaunchSlot(log) {
|
|
333
|
-
try {
|
|
334
|
-
fs.mkdirSync(path.dirname(TMUX_LAUNCH_LOCK), { recursive: true });
|
|
335
|
-
}
|
|
336
|
-
catch { /* ignore */ }
|
|
337
|
-
const deadline = Date.now() + 45000;
|
|
338
|
-
for (;;) {
|
|
339
|
-
try {
|
|
340
|
-
fs.closeSync(fs.openSync(TMUX_LAUNCH_LOCK, 'wx'));
|
|
341
|
-
break;
|
|
342
|
-
} // atomic exclusive create = acquired
|
|
343
|
-
catch {
|
|
344
|
-
try {
|
|
345
|
-
const st = fs.statSync(TMUX_LAUNCH_LOCK);
|
|
346
|
-
if (Date.now() - st.mtimeMs > TMUX_LOCK_STALE_MS) {
|
|
347
|
-
try {
|
|
348
|
-
fs.unlinkSync(TMUX_LAUNCH_LOCK);
|
|
349
|
-
}
|
|
350
|
-
catch { /* race */ }
|
|
351
|
-
continue;
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
catch {
|
|
355
|
-
continue;
|
|
356
|
-
} // lock vanished mid-check → retry create
|
|
357
|
-
if (Date.now() > deadline) {
|
|
358
|
-
log('Grok TUI: tmux-launch lock wait timed out — launching anyway');
|
|
359
|
-
return;
|
|
360
|
-
}
|
|
361
|
-
sleepSyncMs(120);
|
|
362
|
-
}
|
|
363
|
-
}
|
|
364
|
-
try {
|
|
365
|
-
const last = Number(fs.readFileSync(TMUX_LAUNCH_STAMP, 'utf8')) || 0;
|
|
366
|
-
sleepSyncMs(TMUX_LAUNCH_SPACING_MS - (Date.now() - last));
|
|
367
|
-
}
|
|
368
|
-
catch { /* no prior stamp */ }
|
|
369
|
-
}
|
|
370
|
-
/** Stamp the launch time (for the next launcher's spacing) and release the lock. */
|
|
371
|
-
function releaseTmuxLaunchSlot() {
|
|
372
|
-
try {
|
|
373
|
-
fs.writeFileSync(TMUX_LAUNCH_STAMP, String(Date.now()));
|
|
374
|
-
}
|
|
375
|
-
catch { /* ignore */ }
|
|
376
|
-
try {
|
|
377
|
-
fs.unlinkSync(TMUX_LAUNCH_LOCK);
|
|
378
|
-
}
|
|
379
|
-
catch { /* ignore */ }
|
|
380
|
-
}
|
|
381
|
-
// grok keeps a SHARED session index at ~/.grok/sessions/session_search.sqlite.
|
|
382
|
-
// In the default rollback journal mode a writer takes an exclusive lock, so when
|
|
383
|
-
// many agents initialize a session at once they block/fail on it and grok dies
|
|
384
|
-
// during startup. Enabling WAL (persistent — stored in the db header, so once is
|
|
385
|
-
// enough) lets concurrent readers/writers proceed without that exclusive-lock
|
|
386
|
-
// stampede. Best-effort via node:sqlite (Node ≥22), falling back to the sqlite3
|
|
387
|
-
// CLI; if neither is available the launch spacing still serializes access.
|
|
388
|
-
let _grokWalEnsured = false;
|
|
389
|
-
function ensureGrokSessionDbWal(log) {
|
|
390
|
-
if (_grokWalEnsured)
|
|
391
|
-
return;
|
|
392
|
-
const db = path.join(os.homedir(), '.grok', 'sessions', 'session_search.sqlite');
|
|
393
|
-
if (!fs.existsSync(db))
|
|
394
|
-
return; // grok hasn't created it yet — retry on a later launch
|
|
395
|
-
const PRAGMAS = 'PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000;';
|
|
396
|
-
try {
|
|
397
|
-
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
398
|
-
const { DatabaseSync } = require('node:sqlite');
|
|
399
|
-
const d = new DatabaseSync(db);
|
|
400
|
-
d.exec(PRAGMAS);
|
|
401
|
-
d.close();
|
|
402
|
-
_grokWalEnsured = true;
|
|
403
|
-
log('Grok TUI: enabled WAL on session_search.sqlite');
|
|
404
|
-
return;
|
|
405
|
-
}
|
|
406
|
-
catch { /* node:sqlite unavailable → try the CLI */ }
|
|
407
|
-
try {
|
|
408
|
-
child_process.execSync(`sqlite3 ${shq(db)} ${shq(PRAGMAS)}`, { stdio: 'ignore', timeout: 5000 });
|
|
409
|
-
_grokWalEnsured = true;
|
|
410
|
-
log('Grok TUI: enabled WAL on session_search.sqlite (sqlite3)');
|
|
411
|
-
}
|
|
412
|
-
catch { /* best effort — spacing still serializes */ }
|
|
413
|
-
}
|
|
414
|
-
function launchSession(root, sessionId, resume, model, log) {
|
|
415
|
-
const name = sessionName(root);
|
|
416
|
-
// A stale/dead session for this name must be cleared first.
|
|
417
|
-
if (hasSession(name)) {
|
|
418
|
-
killSession(name);
|
|
419
|
-
}
|
|
420
|
-
// Per-agent pane log (distinct file per root → no cross-agent race).
|
|
421
|
-
const dir = path.join(root, '.autodev', 'grok-tui');
|
|
422
|
-
try {
|
|
423
|
-
if (!fs.existsSync(dir)) {
|
|
424
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
425
|
-
}
|
|
426
|
-
}
|
|
427
|
-
catch { /* ignore */ }
|
|
428
|
-
const rawLog = path.join(dir, 'pane.raw');
|
|
429
|
-
// Start each launch with a fresh raw log so readOffset math is simple.
|
|
430
|
-
try {
|
|
431
|
-
fs.writeFileSync(rawLog, '', 'utf8');
|
|
432
|
-
}
|
|
433
|
-
catch { /* ignore */ }
|
|
434
|
-
// Build the interactive grok command. `exec` replaces the shell so a dead grok
|
|
435
|
-
// collapses the pane/session → cheap liveness signal via has-session. (Pure
|
|
436
|
-
// string work — no shared state — so it stays outside the launch lock.)
|
|
437
|
-
// Launch grok via `env -u TMUX -u TMUX_PANE TERM=xterm-256color`: although grok
|
|
438
|
-
// physically runs inside our tmux pane (so we can capture it), inheriting the
|
|
439
|
-
// pane's TMUX/TMUX_PANE + TERM=screen makes grok take a NESTED-tmux terminal
|
|
440
|
-
// path — under load its /dev/tty window-size handshake times out after ~11s and
|
|
441
|
-
// grok exits CLEANLY (exit_group(0)), which we misread as "session died during
|
|
442
|
-
// startup". Hiding the nesting (proper TERM, no TMUX) makes grok use the normal
|
|
443
|
-
// terminal path and it stays alive. (strace-confirmed 2026-08-02.)
|
|
444
|
-
const parts = [
|
|
445
|
-
'exec', 'env', '-u', 'TMUX', '-u', 'TMUX_PANE', 'TERM=xterm-256color',
|
|
446
|
-
shq(GROK_BIN),
|
|
447
|
-
'--no-alt-screen', '--always-approve',
|
|
448
|
-
'--cwd', shq(root),
|
|
449
|
-
];
|
|
450
|
-
if (model) {
|
|
451
|
-
parts.push('-m', shq(model));
|
|
452
|
-
}
|
|
453
|
-
if (resume) {
|
|
454
|
-
// Restore the accumulated conversation on relaunch.
|
|
455
|
-
parts.push('--resume', shq(sessionId));
|
|
456
|
-
}
|
|
457
|
-
else {
|
|
458
|
-
// Name a brand-new conversation with our UUID so it is addressable later.
|
|
459
|
-
parts.push('-s', shq(sessionId));
|
|
460
|
-
}
|
|
461
|
-
// Serialize the race-prone work — the shared ~/.grok config write and the tmux
|
|
462
|
-
// server interactions — across the per-agent loop processes, spaced ~1s apart,
|
|
463
|
-
// so a burst of simultaneous turns can't race the tmux server startup and kill
|
|
464
|
-
// grok ("session died during startup").
|
|
465
|
-
acquireTmuxLaunchSlot(log);
|
|
466
|
-
try {
|
|
467
|
-
// Put grok's shared session index into WAL so concurrent session inits don't
|
|
468
|
-
// block on an exclusive rollback-journal lock (a cause of startup crashes).
|
|
469
|
-
ensureGrokSessionDbWal(log);
|
|
470
|
-
// Kill the one-time project-directory picker before it can block the turn.
|
|
471
|
-
ensureGrokPickerDisabled(log);
|
|
472
|
-
// Trust the workspace dir so grok skips its "Do you trust this directory?"
|
|
473
|
-
// gate (a first-run-only prompt that otherwise kills the turn — see fn doc).
|
|
474
|
-
ensureGrokDirTrusted(root, log);
|
|
475
|
-
// Pre-create grok's per-session dir + an empty transcript for a NEW session so
|
|
476
|
-
// grok doesn't have to mkdir it (and register it in the shared
|
|
477
|
-
// ~/.grok/sessions/session_search.sqlite index) during startup — that
|
|
478
|
-
// filesystem/sqlite contention across a burst of concurrent launches is a
|
|
479
|
-
// suspected cause of the immediate "session died during startup". A resumed
|
|
480
|
-
// session already has its dir, so skip it there.
|
|
481
|
-
if (!resume) {
|
|
482
|
-
try {
|
|
483
|
-
const sPath = chatHistoryPath(root, sessionId);
|
|
484
|
-
fs.mkdirSync(path.dirname(sPath), { recursive: true });
|
|
485
|
-
if (!fs.existsSync(sPath)) {
|
|
486
|
-
fs.writeFileSync(sPath, '', 'utf8');
|
|
487
|
-
}
|
|
488
|
-
}
|
|
489
|
-
catch { /* best effort */ }
|
|
490
|
-
}
|
|
491
|
-
// Kill any stale session with this name first. launchSession only runs when
|
|
492
|
-
// there's no live in-process session, so an existing tmux session by this name
|
|
493
|
-
// is an ORPHAN from a previous loop instance (the tmux session outlives a
|
|
494
|
-
// killed loop). Without this, `new-session` fails "duplicate session" on
|
|
495
|
-
// restart, and orphans accumulate. The shutdown handler (closeAllGrokTuiSessions)
|
|
496
|
-
// normally reaps them, but a SIGKILL'd loop can't run it — so reap here too.
|
|
497
|
-
try {
|
|
498
|
-
killSession(name);
|
|
499
|
-
}
|
|
500
|
-
catch { /* none to kill */ }
|
|
501
|
-
// Fixed geometry so wrapping/relayout never shifts detection rows; manual
|
|
502
|
-
// window-size so a human attaching can't reflow the pane mid-turn.
|
|
503
|
-
tmux(['new-session', '-d', '-s', name, '-x', '200', '-y', '50', '-c', root]);
|
|
504
|
-
tmux(['set-option', '-t', name, 'window-size', 'manual']);
|
|
505
|
-
// pipe-pane BEFORE launching grok — it only captures bytes emitted after it
|
|
506
|
-
// attaches (confirmed). Append mode.
|
|
507
|
-
tmux(['pipe-pane', '-o', '-t', name, `cat >> ${shq(rawLog)}`]);
|
|
508
|
-
tmux(['send-keys', '-t', name, parts.join(' '), 'Enter']);
|
|
509
|
-
}
|
|
510
|
-
finally {
|
|
511
|
-
releaseTmuxLaunchSlot();
|
|
512
|
-
}
|
|
513
|
-
const sess = { name, rawLog, sessionId, readOffset: 0 };
|
|
514
|
-
_tmuxSessions.set(root, sess);
|
|
515
|
-
log(`Grok TUI: launched tmux session ${name} (${resume ? 'resume' : 'new'} ${sessionId.slice(0, 8)}…, model=${model || 'account default'})`);
|
|
516
|
-
return sess;
|
|
517
|
-
}
|
|
518
|
-
/**
|
|
519
|
-
* Ensure a live tmux grok session exists for this root. Returns the session and
|
|
520
|
-
* whether it was just launched (caller applies a startup grace before pasting).
|
|
521
|
-
*/
|
|
522
|
-
function ensureSession(root, resolvedSessionId, model, log) {
|
|
523
|
-
const name = sessionName(root);
|
|
524
|
-
const cached = _tmuxSessions.get(root);
|
|
525
|
-
if (cached && cached.name === name && hasSession(name)) {
|
|
526
|
-
return { sess: cached, justLaunched: false }; // reuse — context is live in-process
|
|
527
|
-
}
|
|
528
|
-
// No live session. If we have a stored session id, relaunch with --resume so
|
|
529
|
-
// grok restores the accumulated history; otherwise mint + persist a new UUID.
|
|
530
|
-
let sid = resolvedSessionId || (0, sessionState_1.getSessionId)(root, 'grok-tui');
|
|
531
|
-
let resume = false;
|
|
532
|
-
if (sid) {
|
|
533
|
-
resume = true; // a stored id means grok already has a persisted session
|
|
534
|
-
}
|
|
535
|
-
else {
|
|
536
|
-
sid = (0, crypto_1.randomUUID)();
|
|
537
|
-
try {
|
|
538
|
-
(0, sessionState_1.saveSessionId)(root, 'grok-tui', sid);
|
|
539
|
-
}
|
|
540
|
-
catch { /* best effort */ }
|
|
541
|
-
}
|
|
542
|
-
const sess = launchSession(root, sid, resume, model, log);
|
|
543
|
-
return { sess, justLaunched: true };
|
|
544
|
-
}
|
|
545
|
-
/** Read new bytes of rawLog past `offset`; returns decoded text + new offset. */
|
|
546
|
-
function readFrom(file, offset) {
|
|
547
|
-
try {
|
|
548
|
-
const size = fs.statSync(file).size;
|
|
549
|
-
if (size <= offset) {
|
|
550
|
-
return { text: '', offset };
|
|
551
|
-
}
|
|
552
|
-
const len = size - offset;
|
|
553
|
-
const buf = Buffer.alloc(len);
|
|
554
|
-
const fd = fs.openSync(file, 'r');
|
|
555
|
-
try {
|
|
556
|
-
fs.readSync(fd, buf, 0, len, offset);
|
|
557
|
-
}
|
|
558
|
-
finally {
|
|
559
|
-
fs.closeSync(fd);
|
|
560
|
-
}
|
|
561
|
-
return { text: buf.toString('utf8'), offset: size };
|
|
562
|
-
}
|
|
563
|
-
catch {
|
|
564
|
-
return { text: '', offset };
|
|
565
|
-
}
|
|
566
|
-
}
|
|
567
|
-
/**
|
|
568
|
-
* grok persists a CLEAN structured transcript per workspace session at
|
|
569
|
-
* ~/.grok/sessions/<encodeURIComponent(cwd)>/<sessionId>/chat_history.jsonl
|
|
570
|
-
* one JSON object per line: {type:'system'|'user'|'reasoning'|'assistant'|
|
|
571
|
-
* 'tool_result', content, tool_calls?}. The assistant `content` is the real
|
|
572
|
-
* message text (markdown), with none of the TUI chrome — spinners, "Waiting for
|
|
573
|
-
* response…", token counters, "Ctrl+x:shortcuts", load-bar glyphs — that scraping
|
|
574
|
-
* the live tmux pane drags in. We read the agent's OUTPUT from here; the pane is
|
|
575
|
-
* used only for turn-end/reauth detection.
|
|
576
|
-
*/
|
|
577
|
-
function chatHistoryPath(root, sessionId) {
|
|
578
|
-
return path.join(os.homedir(), '.grok', 'sessions', encodeURIComponent(root), sessionId, 'chat_history.jsonl');
|
|
579
|
-
}
|
|
580
|
-
/**
|
|
581
|
-
* A NEW grok session shows a one-time "Run Grok Build in a project directory?"
|
|
582
|
-
* picker before the first turn's work, which blocks headless automation. grok
|
|
583
|
-
* exposes a persistent opt-out — `hints = { project_picker_disabled = true }` in
|
|
584
|
-
* ~/.grok/config.toml (what its "Don't ask me again" option writes). Ensure it is
|
|
585
|
-
* present before launching so the session goes straight to the prompt. Idempotent;
|
|
586
|
-
* the poll-loop picker backstop covers any grok build that ignores this.
|
|
587
|
-
*/
|
|
588
|
-
function ensureGrokPickerDisabled(log) {
|
|
589
|
-
try {
|
|
590
|
-
const cfg = path.join(os.homedir(), '.grok', 'config.toml');
|
|
591
|
-
let text = '';
|
|
592
|
-
try {
|
|
593
|
-
text = fs.readFileSync(cfg, 'utf8');
|
|
594
|
-
}
|
|
595
|
-
catch { /* no config yet */ }
|
|
596
|
-
if (/project_picker_disabled\s*=\s*true/.test(text)) {
|
|
597
|
-
return;
|
|
598
|
-
}
|
|
599
|
-
if (/^hints\s*=\s*\{/m.test(text)) {
|
|
600
|
-
// Inject the key into the existing inline `hints` table.
|
|
601
|
-
text = text.replace(/^hints\s*=\s*\{/m, 'hints = { project_picker_disabled = true,');
|
|
602
|
-
}
|
|
603
|
-
else {
|
|
604
|
-
text = 'hints = { project_picker_disabled = true }\n' + text;
|
|
605
|
-
}
|
|
606
|
-
fs.mkdirSync(path.dirname(cfg), { recursive: true });
|
|
607
|
-
fs.writeFileSync(cfg, text, 'utf8');
|
|
608
|
-
log('Grok TUI: disabled grok project-directory picker in config.toml');
|
|
609
|
-
}
|
|
610
|
-
catch { /* best effort — the poll-loop dismissal is the backstop */ }
|
|
611
|
-
}
|
|
612
|
-
/**
|
|
613
|
-
* Pre-trust the workspace directory in ~/.grok/trusted_folders.toml so grok skips
|
|
614
|
-
* its "Do you trust the contents of this directory?" startup gate. That gate is
|
|
615
|
-
* NOT the project picker (ensureGrokPickerDisabled) and is not covered by the
|
|
616
|
-
* startup picker loop — if it shows, the loop mistakes the blank body for the
|
|
617
|
-
* ready prompt, pastes the turn into the trust dialog, grok reads it as "not y"
|
|
618
|
-
* and QUITS (observed as "session-exit" right after "sending turn"). Untrusted
|
|
619
|
-
* dirs are the ones an agent has never run in before, so this is the durable fix
|
|
620
|
-
* for the "some agents crash on first launch" class. Idempotent: skip if already
|
|
621
|
-
* present.
|
|
622
|
-
*/
|
|
623
|
-
function ensureGrokDirTrusted(root, log) {
|
|
624
|
-
try {
|
|
625
|
-
const tf = path.join(os.homedir(), '.grok', 'trusted_folders.toml');
|
|
626
|
-
const abs = path.resolve(root);
|
|
627
|
-
let text = '';
|
|
628
|
-
try {
|
|
629
|
-
text = fs.readFileSync(tf, 'utf8');
|
|
630
|
-
}
|
|
631
|
-
catch { /* no file yet */ }
|
|
632
|
-
// Match the exact folder header so a substring path can't yield a false skip.
|
|
633
|
-
const header = `[folders."${abs}"]`;
|
|
634
|
-
if (text.includes(header)) {
|
|
635
|
-
return;
|
|
636
|
-
}
|
|
637
|
-
const nowSec = Math.floor(Date.now() / 1000);
|
|
638
|
-
const entry = `${text.endsWith('\n') || text === '' ? '' : '\n'}\n${header}\ntrusted = true\ndecided_at = ${nowSec}\n`;
|
|
639
|
-
fs.mkdirSync(path.dirname(tf), { recursive: true });
|
|
640
|
-
fs.appendFileSync(tf, entry, 'utf8');
|
|
641
|
-
log(`Grok TUI: trusted workspace dir in trusted_folders.toml (skips grok's trust gate)`);
|
|
642
|
-
}
|
|
643
|
-
catch { /* best effort — the picker loop's trust-prompt handling is the backstop */ }
|
|
644
|
-
}
|
|
645
|
-
/** Cap a string field to `max` chars, appending an ellipsis when truncated. */
|
|
646
|
-
function _truncStr(s, max) {
|
|
647
|
-
return s.length > max ? s.slice(0, max) + '…' : s;
|
|
648
|
-
}
|
|
649
|
-
/**
|
|
650
|
-
* Parse a grok tool_call `arguments` JSON string into a plain OBJECT suitable
|
|
651
|
-
* for the office `tool_input` field, which the pixel-office normalizer requires
|
|
652
|
-
* to be an array/object (a raw string is dropped). Long string values are
|
|
653
|
-
* truncated so a huge write/patch payload can't bloat the hook line. Non-JSON
|
|
654
|
-
* arguments fall back to `{ _raw: <truncated> }` so tool_input is still an object.
|
|
655
|
-
*/
|
|
656
|
-
function _parseToolInput(argsStr) {
|
|
657
|
-
const raw = (argsStr ?? '').trim();
|
|
658
|
-
if (!raw) {
|
|
659
|
-
return {};
|
|
660
|
-
}
|
|
661
|
-
try {
|
|
662
|
-
const parsed = JSON.parse(raw);
|
|
663
|
-
if (parsed && typeof parsed === 'object') {
|
|
664
|
-
const obj = parsed;
|
|
665
|
-
for (const k of Object.keys(obj)) {
|
|
666
|
-
if (typeof obj[k] === 'string') {
|
|
667
|
-
obj[k] = _truncStr(obj[k], 2000);
|
|
668
|
-
}
|
|
669
|
-
}
|
|
670
|
-
return obj;
|
|
671
|
-
}
|
|
672
|
-
// JSON that isn't an object (number/array/string) — wrap it.
|
|
673
|
-
return { value: parsed };
|
|
674
|
-
}
|
|
675
|
-
catch {
|
|
676
|
-
return { _raw: _truncStr(raw, 2000) };
|
|
677
|
-
}
|
|
678
|
-
}
|
|
679
|
-
/**
|
|
680
|
-
* Scan NEW chat_history.jsonl records past `offset` and emit ONE rich hook event
|
|
681
|
-
* per grok tool call: `PreToolUse` when the assistant record's `tool_calls[]`
|
|
682
|
-
* entry first appears (carrying tool_name + parsed tool_input), then `PostToolUse`
|
|
683
|
-
* when its matching `tool_result` (by tool_call_id) lands (carrying tool_name +
|
|
684
|
-
* tool_input + tool_output.text). `pending` (keyed by tool_call id) survives
|
|
685
|
-
* across polls so a Pre/Post pair split over two reads is matched. Advances the
|
|
686
|
-
* offset only past COMPLETE lines so a half-written trailing record is re-read.
|
|
687
|
-
*
|
|
688
|
-
* grok's OWN native tools (Read/Edit/Bash/Glob/…) are what surface here. Office
|
|
689
|
-
* MCP tools (`mcp__…`) are skipped: the office already records those server-side
|
|
690
|
-
* (OfficeMcpController → provider 'mcp-operator'), so emitting them again would
|
|
691
|
-
* double-render the same call.
|
|
692
|
-
*/
|
|
693
|
-
function scanGrokToolEvents(root, file, offset, pending) {
|
|
694
|
-
let size = 0;
|
|
695
|
-
try {
|
|
696
|
-
size = fs.statSync(file).size;
|
|
697
|
-
}
|
|
698
|
-
catch {
|
|
699
|
-
return offset;
|
|
700
|
-
}
|
|
701
|
-
if (size <= offset) {
|
|
702
|
-
return offset;
|
|
703
|
-
}
|
|
704
|
-
let raw = '';
|
|
705
|
-
try {
|
|
706
|
-
const len = size - offset;
|
|
707
|
-
const buf = Buffer.alloc(len);
|
|
708
|
-
const fd = fs.openSync(file, 'r');
|
|
709
|
-
try {
|
|
710
|
-
fs.readSync(fd, buf, 0, len, offset);
|
|
711
|
-
}
|
|
712
|
-
finally {
|
|
713
|
-
fs.closeSync(fd);
|
|
714
|
-
}
|
|
715
|
-
raw = buf.toString('utf8');
|
|
716
|
-
}
|
|
717
|
-
catch {
|
|
718
|
-
return offset;
|
|
719
|
-
}
|
|
720
|
-
const lastNl = raw.lastIndexOf('\n');
|
|
721
|
-
if (lastNl < 0) {
|
|
722
|
-
return offset;
|
|
723
|
-
} // no complete line yet
|
|
724
|
-
const complete = raw.slice(0, lastNl);
|
|
725
|
-
const newOffset = offset + Buffer.byteLength(complete, 'utf8') + 1; // +1 for the \n
|
|
726
|
-
for (const line of complete.split('\n')) {
|
|
727
|
-
const s = line.trim();
|
|
728
|
-
if (!s) {
|
|
729
|
-
continue;
|
|
730
|
-
}
|
|
731
|
-
let o;
|
|
732
|
-
try {
|
|
733
|
-
o = JSON.parse(s);
|
|
734
|
-
}
|
|
735
|
-
catch {
|
|
736
|
-
continue;
|
|
737
|
-
}
|
|
738
|
-
const kind = o.type || o.role;
|
|
739
|
-
if (kind === 'assistant' && Array.isArray(o.tool_calls)) {
|
|
740
|
-
const emitted = _emittedToolIds.get(root) ?? (() => { const s = new Set(); _emittedToolIds.set(root, s); return s; })();
|
|
741
|
-
for (const tc of o.tool_calls) {
|
|
742
|
-
const name = (tc?.name || '').trim();
|
|
743
|
-
if (!name) {
|
|
744
|
-
continue;
|
|
745
|
-
}
|
|
746
|
-
if (name.startsWith('mcp__')) {
|
|
747
|
-
continue;
|
|
748
|
-
} // office logs MCP tools server-side
|
|
749
|
-
// Dedup: a re-scan of the transcript would otherwise re-emit this tool call's
|
|
750
|
-
// PreToolUse → duplicate rows. tool_call ids are unique per call, so an id we
|
|
751
|
-
// already emitted is a re-scan, not a real second call — skip it.
|
|
752
|
-
const id = tc?.id;
|
|
753
|
-
if (id && emitted.has(id)) {
|
|
754
|
-
continue;
|
|
755
|
-
}
|
|
756
|
-
const input = _parseToolInput(tc?.arguments);
|
|
757
|
-
// tool_use_id lets the office pair this PreToolUse with its PostToolUse
|
|
758
|
-
// into ONE step card (useToolPairing keys on rawPayload.tool_use_id).
|
|
759
|
-
// Without it, Pre and Post render as two separate cards = duplicates.
|
|
760
|
-
_emitGrokHook(root, 'PreToolUse', { tool_name: name, tool_input: input, tool_use_id: id });
|
|
761
|
-
if (id) {
|
|
762
|
-
pending.set(id, { name, input });
|
|
763
|
-
emitted.add(id);
|
|
764
|
-
if (emitted.size > EMITTED_TOOL_IDS_CAP) {
|
|
765
|
-
emitted.clear();
|
|
766
|
-
} // bound memory; old ids won't re-scan
|
|
767
|
-
}
|
|
768
|
-
}
|
|
769
|
-
continue;
|
|
770
|
-
}
|
|
771
|
-
if (kind === 'tool_result') {
|
|
772
|
-
const id = o.tool_call_id;
|
|
773
|
-
if (!id) {
|
|
774
|
-
continue;
|
|
775
|
-
}
|
|
776
|
-
const p = pending.get(id);
|
|
777
|
-
if (!p) {
|
|
778
|
-
continue;
|
|
779
|
-
} // no matching Pre (e.g. resumed history / skipped mcp tool) — don't emit an empty event
|
|
780
|
-
pending.delete(id);
|
|
781
|
-
const outText = typeof o.content === 'string' ? o.content : (o.content == null ? '' : JSON.stringify(o.content));
|
|
782
|
-
_emitGrokHook(root, 'PostToolUse', {
|
|
783
|
-
tool_name: p.name,
|
|
784
|
-
tool_input: p.input,
|
|
785
|
-
tool_output: { text: _truncStr(outText, 2000) },
|
|
786
|
-
tool_use_id: id, // pair with the PreToolUse above (see note there)
|
|
787
|
-
});
|
|
788
|
-
}
|
|
789
|
-
}
|
|
790
|
-
return newOffset;
|
|
791
|
-
}
|
|
792
|
-
/**
|
|
793
|
-
* Read new chat_history.jsonl records past `offset` and render only the
|
|
794
|
-
* user-facing parts: assistant prose + compact tool-call markers. Skips
|
|
795
|
-
* reasoning/user/system/tool_result. Advances the offset only past COMPLETE
|
|
796
|
-
* lines so a half-written trailing record is re-read next poll.
|
|
797
|
-
*/
|
|
798
|
-
function drainChatHistory(file, offset) {
|
|
799
|
-
let size = 0;
|
|
800
|
-
try {
|
|
801
|
-
size = fs.statSync(file).size;
|
|
802
|
-
}
|
|
803
|
-
catch {
|
|
804
|
-
return { text: '', offset };
|
|
805
|
-
}
|
|
806
|
-
if (size <= offset) {
|
|
807
|
-
return { text: '', offset };
|
|
808
|
-
}
|
|
809
|
-
let raw = '';
|
|
810
|
-
try {
|
|
811
|
-
const len = size - offset;
|
|
812
|
-
const buf = Buffer.alloc(len);
|
|
813
|
-
const fd = fs.openSync(file, 'r');
|
|
814
|
-
try {
|
|
815
|
-
fs.readSync(fd, buf, 0, len, offset);
|
|
816
|
-
}
|
|
817
|
-
finally {
|
|
818
|
-
fs.closeSync(fd);
|
|
819
|
-
}
|
|
820
|
-
raw = buf.toString('utf8');
|
|
821
|
-
}
|
|
822
|
-
catch {
|
|
823
|
-
return { text: '', offset };
|
|
824
|
-
}
|
|
825
|
-
const lastNl = raw.lastIndexOf('\n');
|
|
826
|
-
if (lastNl < 0) {
|
|
827
|
-
return { text: '', offset };
|
|
828
|
-
} // no complete line yet
|
|
829
|
-
const complete = raw.slice(0, lastNl);
|
|
830
|
-
const newOffset = offset + Buffer.byteLength(complete, 'utf8') + 1; // +1 for the \n
|
|
831
|
-
let out = '';
|
|
832
|
-
for (const line of complete.split('\n')) {
|
|
833
|
-
const s = line.trim();
|
|
834
|
-
if (!s) {
|
|
835
|
-
continue;
|
|
836
|
-
}
|
|
837
|
-
let o;
|
|
838
|
-
try {
|
|
839
|
-
o = JSON.parse(s);
|
|
840
|
-
}
|
|
841
|
-
catch {
|
|
842
|
-
continue;
|
|
843
|
-
}
|
|
844
|
-
if ((o.type || o.role) !== 'assistant') {
|
|
845
|
-
continue;
|
|
846
|
-
}
|
|
847
|
-
const c = (o.content || '').trim();
|
|
848
|
-
if (c) {
|
|
849
|
-
out += c + '\n';
|
|
850
|
-
}
|
|
851
|
-
// NOTE: tool calls are NOT appended as "» tool" text markers here — they are
|
|
852
|
-
// emitted as proper PreToolUse/PostToolUse hook events by scanGrokToolEvents,
|
|
853
|
-
// which the office renders as (richer, paired) step cards. Emitting them here
|
|
854
|
-
// too made every grok tool call appear TWICE (a card AND a text line).
|
|
855
|
-
}
|
|
856
|
-
return { text: out, offset: newOffset };
|
|
857
|
-
}
|
|
858
|
-
/** Type a prompt into the live pane and submit it. CALIBRATED against real grok:
|
|
859
|
-
* a tmux bracketed paste (paste-buffer) is misread by grok's TUI as a
|
|
860
|
-
* worktree/directory PICKER, not chat input — only literal keystrokes land in
|
|
861
|
-
* the input box. grok also has no reliable "newline without submit" key over
|
|
862
|
-
* send-keys (Enter submits), so flatten the prompt to one logical line. */
|
|
863
|
-
function pastePrompt(sess, promptFilePath) {
|
|
864
|
-
// A full agent prompt is 10KB+ (system prompt + task + context). Typing that
|
|
865
|
-
// via send-keys -l is UNRELIABLE (tmux drops/garbles keys at that size —
|
|
866
|
-
// observed live), and a bracketed paste is misread by grok's TUI as a directory
|
|
867
|
-
// picker. So DON'T type the prompt: hand grok a SHORT literal instruction to
|
|
868
|
-
// READ the prompt file, and its file tool ingests the whole thing losslessly.
|
|
869
|
-
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.`;
|
|
870
|
-
tmux(['send-keys', '-t', sess.name, '-l', instr]);
|
|
871
|
-
tmux(['send-keys', '-t', sess.name, 'Enter']);
|
|
872
|
-
}
|
|
873
|
-
// ---------------------------------------------------------------------------
|
|
874
|
-
// runGrokTmuxTurn — one turn against the persistent tmux session.
|
|
875
|
-
// Fire-and-forget: streams pane text to stdoutFile, writes exitFile at turn end.
|
|
876
|
-
// ---------------------------------------------------------------------------
|
|
877
|
-
function runGrokTmuxTurn(root, promptFilePath, resolvedSessionId, stdoutFile, exitFile, log, model, showOutput) {
|
|
878
|
-
showOutput?.();
|
|
879
|
-
try {
|
|
880
|
-
fs.writeFileSync(stdoutFile, '', 'utf8');
|
|
881
|
-
}
|
|
882
|
-
catch { /* ignore */ }
|
|
883
|
-
try {
|
|
884
|
-
fs.writeFileSync(exitFile, '', 'utf8');
|
|
885
|
-
}
|
|
886
|
-
catch { /* ignore */ }
|
|
887
|
-
_busyRoots.add(root);
|
|
888
|
-
const POLL_MS = envNum('AUTODEV_GROK_TUI_POLL_MS', 700);
|
|
889
|
-
const DEBOUNCE_MS = envNum('AUTODEV_GROK_TUI_DEBOUNCE_MS', 5_000);
|
|
890
|
-
// Quiescence-only fallback (no "Worked for" marker seen) needs a much longer
|
|
891
|
-
// quiet window so grok's early "Starting session…"/"Reading file…" pauses aren't
|
|
892
|
-
// misread as turn-end (observed a false ~4s finish). The "Worked for" marker is
|
|
893
|
-
// the fast, positive done-signal for the normal case.
|
|
894
|
-
const QUIET_FALLBACK_MS = envNum('AUTODEV_GROK_TUI_QUIET_FALLBACK_MS', 45_000);
|
|
895
|
-
// 18s default (was 8s): only paid on a fresh launch, never on a reused live
|
|
896
|
-
// session. Under a launch storm (many agents starting at once → dozens of MCP
|
|
897
|
-
// servers spinning up together) grok's startup can exceed 8s; the picker loop
|
|
898
|
-
// then sees a blank splash, assumes the ready prompt, and pastes the turn into a
|
|
899
|
-
// not-yet-ready grok, which dies ("session-exit" right after "sending turn").
|
|
900
|
-
// A longer grace lets grok reach its input prompt before we type. Override with
|
|
901
|
-
// AUTODEV_GROK_TUI_STARTUP_MS for faster single-agent launches. Default 30s: 18s
|
|
902
|
-
// was still too tight under a many-agents-at-once spawn (the "turn failed (provider
|
|
903
|
-
// stopped or timed out)" then recover-on-retry pattern), so give grok more headroom.
|
|
904
|
-
const STARTUP_MS = envNum('AUTODEV_GROK_TUI_STARTUP_MS', 30_000);
|
|
905
|
-
const NOOUTPUT_MS = envNum('AUTODEV_GROK_TUI_NOOUTPUT_MS', 25_000);
|
|
906
|
-
const MAX_RUN_MS = envNum('AUTODEV_GROK_MAX_RUN_MS', 10 * 60_000);
|
|
907
|
-
const STABLE_POLLS = Math.max(2, envNum('AUTODEV_GROK_TUI_STABLE_POLLS', 4));
|
|
908
|
-
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
|
909
|
-
// Line-buffer for clean console logging (grok streams mid-word).
|
|
910
|
-
let lineBuf = '';
|
|
911
|
-
const emitLines = (text) => {
|
|
912
|
-
lineBuf += text;
|
|
913
|
-
let nl;
|
|
914
|
-
while ((nl = lineBuf.indexOf('\n')) >= 0) {
|
|
915
|
-
const line = lineBuf.slice(0, nl).replace(/\s+$/, '');
|
|
916
|
-
lineBuf = lineBuf.slice(nl + 1);
|
|
917
|
-
if (line.trim()) {
|
|
918
|
-
log(` ${line}`);
|
|
919
|
-
}
|
|
920
|
-
}
|
|
921
|
-
};
|
|
922
|
-
const flushLine = () => {
|
|
923
|
-
const line = lineBuf.replace(/\s+$/, '');
|
|
924
|
-
lineBuf = '';
|
|
925
|
-
if (line.trim()) {
|
|
926
|
-
log(` ${line}`);
|
|
927
|
-
}
|
|
928
|
-
};
|
|
929
|
-
// Coalesced office activity feed (grok exposes no per-tool events here).
|
|
930
|
-
let activityBuf = '';
|
|
931
|
-
let activityTimer = null;
|
|
932
|
-
const flushActivity = () => {
|
|
933
|
-
if (activityTimer) {
|
|
934
|
-
clearTimeout(activityTimer);
|
|
935
|
-
activityTimer = null;
|
|
936
|
-
}
|
|
937
|
-
const t = activityBuf.trim();
|
|
938
|
-
activityBuf = '';
|
|
939
|
-
if (!t) {
|
|
940
|
-
return;
|
|
941
|
-
}
|
|
942
|
-
const preview = t.length > 280 ? t.slice(0, 277) + '…' : t;
|
|
943
|
-
_emitGrokHook(root, 'Notification', { message: preview, title: 'grok', tool_name: 'grok' });
|
|
944
|
-
};
|
|
945
|
-
const scheduleActivity = () => {
|
|
946
|
-
if (activityBuf.length >= 400) {
|
|
947
|
-
flushActivity();
|
|
948
|
-
return;
|
|
949
|
-
}
|
|
950
|
-
if (!activityTimer) {
|
|
951
|
-
activityTimer = setTimeout(flushActivity, 1200);
|
|
952
|
-
}
|
|
953
|
-
};
|
|
954
|
-
const finish = (code, reason) => {
|
|
955
|
-
flushLine();
|
|
956
|
-
flushActivity();
|
|
957
|
-
_busyRoots.delete(root);
|
|
958
|
-
log(`Grok TUI: turn ${code === 0 ? 'complete' : `ended (code=${code}, ${reason})`}`);
|
|
959
|
-
_emitGrokHook(root, code === 0 ? 'Stop' : 'StopFailure', { exit_code: code });
|
|
960
|
-
_emitGrokHook(root, 'SessionEnd', { reason: code === 0 ? 'completed' : reason });
|
|
961
|
-
try {
|
|
962
|
-
fs.writeFileSync(exitFile, `${code}\n`, 'utf8');
|
|
963
|
-
}
|
|
964
|
-
catch { /* ignore */ }
|
|
965
|
-
};
|
|
966
|
-
void (async () => {
|
|
967
|
-
let sess;
|
|
968
|
-
let justLaunched = false;
|
|
969
|
-
let readOffset = 0;
|
|
970
|
-
// Poison-resume self-heal. grok-tui always resumes its stored session; if that
|
|
971
|
-
// resume dies on startup the session is poisoned and resuming it again fails
|
|
972
|
-
// forever. Rather than emit a hard-failure (which the desktop app flags as
|
|
973
|
-
// "Crashed" and STOPS the agent, forcing a manual Restart), clear the poisoned
|
|
974
|
-
// id and relaunch FRESH in place, retrying THIS turn. `sidForLaunch` goes
|
|
975
|
-
// undefined after a clear so ensureSession mints a new UUID instead of resuming
|
|
976
|
-
// the poison again. Only after MAX_STARTUP_TRIES do we surface startup-exit.
|
|
977
|
-
let sidForLaunch = resolvedSessionId;
|
|
978
|
-
const MAX_STARTUP_TRIES = 3;
|
|
979
|
-
let startupOk = false;
|
|
980
|
-
for (let attempt = 1; attempt <= MAX_STARTUP_TRIES; attempt++) {
|
|
981
|
-
try {
|
|
982
|
-
const r = ensureSession(root, sidForLaunch, model, log);
|
|
983
|
-
sess = r.sess;
|
|
984
|
-
justLaunched = r.justLaunched;
|
|
985
|
-
}
|
|
986
|
-
catch (err) {
|
|
987
|
-
const msg = err?.message ?? String(err);
|
|
988
|
-
log(`Grok TUI: session launch failed: ${msg}`);
|
|
989
|
-
try {
|
|
990
|
-
fs.appendFileSync(stdoutFile, `\n[Grok TUI launch error: ${msg}]\n`, 'utf8');
|
|
991
|
-
}
|
|
992
|
-
catch { /* ignore */ }
|
|
993
|
-
finish(1, 'launch-failed');
|
|
994
|
-
return;
|
|
995
|
-
}
|
|
996
|
-
// Stream only THIS turn's output: start reading at the current rawLog size.
|
|
997
|
-
readOffset = (() => { try {
|
|
998
|
-
return fs.statSync(sess.rawLog).size;
|
|
999
|
-
}
|
|
1000
|
-
catch {
|
|
1001
|
-
return 0;
|
|
1002
|
-
} })();
|
|
1003
|
-
if (!justLaunched) {
|
|
1004
|
-
startupOk = true;
|
|
1005
|
-
break;
|
|
1006
|
-
} // reused live session — no grace
|
|
1007
|
-
_emitGrokHook(root, 'SessionStart', { source: 'startup' });
|
|
1008
|
-
let died = false;
|
|
1009
|
-
let reauth = false;
|
|
1010
|
-
// Startup grace — grok's splash takes a few seconds before it accepts input.
|
|
1011
|
-
const deadline = Date.now() + STARTUP_MS;
|
|
1012
|
-
while (Date.now() < deadline) {
|
|
1013
|
-
await sleep(500);
|
|
1014
|
-
if (!hasSession(sess.name)) {
|
|
1015
|
-
died = true;
|
|
1016
|
-
break;
|
|
1017
|
-
}
|
|
1018
|
-
if (REAUTH_MARKERS.test(capturePane(sess.name))) {
|
|
1019
|
-
reauth = true;
|
|
1020
|
-
break;
|
|
1021
|
-
}
|
|
1022
|
-
}
|
|
1023
|
-
if (!died && !reauth) {
|
|
1024
|
-
// Past the grace with a live session — dismiss any welcome/project picker
|
|
1025
|
-
// (Enter through it) or the directory-trust gate (accept with 'y'), until
|
|
1026
|
-
// the ready "❯" prompt is clear. CALIBRATED: a NEW grok session opens on a
|
|
1027
|
-
// picker over the input box; a single fixed Enter races grok's splash, so
|
|
1028
|
-
// poll + press each pass. The trust gate ("Do you trust this directory?")
|
|
1029
|
-
// is normally prevented by ensureGrokDirTrusted; this is the backstop.
|
|
1030
|
-
const PICKER_MARKERS = /project directory|New worktree|Resume session|Changelog|\(current\)|Run Grok Build|[◯○◉●]|Select|↑\/↓/i;
|
|
1031
|
-
const TRUST_MARKERS = /Do you trust|Yes, proceed|posing security risks/i;
|
|
1032
|
-
for (let i = 0; i < 8; i++) {
|
|
1033
|
-
if (!hasSession(sess.name)) {
|
|
1034
|
-
died = true;
|
|
1035
|
-
break;
|
|
1036
|
-
}
|
|
1037
|
-
const snap = capturePane(sess.name);
|
|
1038
|
-
if (REAUTH_MARKERS.test(snap)) {
|
|
1039
|
-
reauth = true;
|
|
1040
|
-
break;
|
|
1041
|
-
}
|
|
1042
|
-
if (TRUST_MARKERS.test(snap)) {
|
|
1043
|
-
// Accept explicitly with 'y' (Enter alone is unreliable depending on
|
|
1044
|
-
// which option is highlighted), then keep polling.
|
|
1045
|
-
log('Grok TUI: accepting grok directory-trust gate (y)');
|
|
1046
|
-
tmux(['send-keys', '-t', sess.name, 'y']);
|
|
1047
|
-
await sleep(1200);
|
|
1048
|
-
continue;
|
|
1049
|
-
}
|
|
1050
|
-
if (!PICKER_MARKERS.test(snap)) {
|
|
1051
|
-
break;
|
|
1052
|
-
} // at the ready prompt
|
|
1053
|
-
tmux(['send-keys', '-t', sess.name, 'Enter']);
|
|
1054
|
-
await sleep(1500);
|
|
1055
|
-
}
|
|
1056
|
-
}
|
|
1057
|
-
if (reauth) {
|
|
1058
|
-
log('Grok TUI: reauth required (OAuth gate at startup)');
|
|
1059
|
-
try {
|
|
1060
|
-
fs.appendFileSync(stdoutFile, `\n[Grok TUI: reauthentication required — please login]\n`, 'utf8');
|
|
1061
|
-
}
|
|
1062
|
-
catch { /* ignore */ }
|
|
1063
|
-
finish(1, 'reauth_required');
|
|
1064
|
-
return;
|
|
1065
|
-
}
|
|
1066
|
-
if (!died) {
|
|
1067
|
-
// re-read the offset after startup so the splash/menu text isn't streamed
|
|
1068
|
-
// as "turn output".
|
|
1069
|
-
readOffset = (() => { try {
|
|
1070
|
-
return fs.statSync(sess.rawLog).size;
|
|
1071
|
-
}
|
|
1072
|
-
catch {
|
|
1073
|
-
return readOffset;
|
|
1074
|
-
} })();
|
|
1075
|
-
startupOk = true;
|
|
1076
|
-
break;
|
|
1077
|
-
}
|
|
1078
|
-
// Session died during startup — a poisoned resume (or a storm-starved launch).
|
|
1079
|
-
// Clear the stored id, tear down the dead pane, and relaunch FRESH next pass.
|
|
1080
|
-
log(`Grok TUI: session died during startup — ${attempt < MAX_STARTUP_TRIES ? `relaunching fresh (attempt ${attempt + 1}/${MAX_STARTUP_TRIES})` : 'giving up this turn'} (poisoned-resume self-heal)`);
|
|
1081
|
-
try {
|
|
1082
|
-
(0, sessionState_1.clearSessionId)(root, 'grok-tui');
|
|
1083
|
-
}
|
|
1084
|
-
catch { /* best effort */ }
|
|
1085
|
-
_tmuxSessions.delete(root);
|
|
1086
|
-
try {
|
|
1087
|
-
tmux(['kill-session', '-t', sess.name]);
|
|
1088
|
-
}
|
|
1089
|
-
catch { /* already gone */ }
|
|
1090
|
-
sidForLaunch = undefined; // force a fresh mint on the retry
|
|
1091
|
-
}
|
|
1092
|
-
if (!startupOk) {
|
|
1093
|
-
try {
|
|
1094
|
-
fs.appendFileSync(stdoutFile, `\n[Grok TUI: session kept exiting during startup — a fresh session will be tried next turn]\n`, 'utf8');
|
|
1095
|
-
}
|
|
1096
|
-
catch { /* ignore */ }
|
|
1097
|
-
finish(1, 'startup-exit');
|
|
1098
|
-
return;
|
|
1099
|
-
}
|
|
1100
|
-
// NOTE: when the session is REUSED (not justLaunched) we deliberately emit NO
|
|
1101
|
-
// SessionStart. The grok process is persistent across turns — one tmux launch
|
|
1102
|
-
// serves many turns — so a per-turn "startup"/SessionStart is semantically
|
|
1103
|
-
// false and made the office Events feed show a phantom "startup" every turn,
|
|
1104
|
-
// reading as "the grok agent restarts each time" when it does not. A turn
|
|
1105
|
-
// boundary is already conveyed by the prompt/task and the tool events.
|
|
1106
|
-
// Clean-output source: grok's structured transcript. Seed the offset at the
|
|
1107
|
-
// current size so we emit only THIS turn's assistant records, not the
|
|
1108
|
-
// resumed history. (File may not exist yet on a fresh session → offset 0.)
|
|
1109
|
-
const chatPath = chatHistoryPath(root, sess.sessionId);
|
|
1110
|
-
let chatOffset = (() => { try {
|
|
1111
|
-
return fs.statSync(chatPath).size;
|
|
1112
|
-
}
|
|
1113
|
-
catch {
|
|
1114
|
-
return 0;
|
|
1115
|
-
} })();
|
|
1116
|
-
let chatEmitted = false;
|
|
1117
|
-
// Rich tool-event stream: a SEPARATE offset over the same transcript so each
|
|
1118
|
-
// grok tool call becomes one PreToolUse + one PostToolUse (payload-carrying)
|
|
1119
|
-
// hook event in the office feed. `pendingTools` matches a Post to its Pre by
|
|
1120
|
-
// tool_call id across polls. Seeded at the same size so only THIS turn's tools
|
|
1121
|
-
// are emitted, not the resumed history.
|
|
1122
|
-
let toolOffset = chatOffset;
|
|
1123
|
-
const pendingTools = new Map();
|
|
1124
|
-
// Submit the prompt.
|
|
1125
|
-
log(`Grok TUI: sending turn (${(() => { try {
|
|
1126
|
-
return fs.statSync(promptFilePath).size;
|
|
1127
|
-
}
|
|
1128
|
-
catch {
|
|
1129
|
-
return 0;
|
|
1130
|
-
} })()} bytes)`);
|
|
1131
|
-
pastePrompt(sess, promptFilePath);
|
|
1132
|
-
// -----------------------------------------------------------------------
|
|
1133
|
-
// Poll: stream new pane bytes, detect turn-end by output quiescence + a
|
|
1134
|
-
// byte-stable pane snapshot with the running-indicator absent.
|
|
1135
|
-
// -----------------------------------------------------------------------
|
|
1136
|
-
const turnStart = Date.now();
|
|
1137
|
-
let lastGrowthMs = Date.now();
|
|
1138
|
-
let sawOutput = false;
|
|
1139
|
-
let lastSnap = '';
|
|
1140
|
-
let stable = 0;
|
|
1141
|
-
let sawDoneMarker = false;
|
|
1142
|
-
for (;;) {
|
|
1143
|
-
await sleep(POLL_MS);
|
|
1144
|
-
// Session died mid-turn (grok crashed / was killed).
|
|
1145
|
-
if (!hasSession(sess.name)) {
|
|
1146
|
-
try {
|
|
1147
|
-
fs.appendFileSync(stdoutFile, `\n[Grok TUI: session exited]\n`, 'utf8');
|
|
1148
|
-
}
|
|
1149
|
-
catch { /* ignore */ }
|
|
1150
|
-
_tmuxSessions.delete(root);
|
|
1151
|
-
finish(1, 'session-exit');
|
|
1152
|
-
return;
|
|
1153
|
-
}
|
|
1154
|
-
// 1a) Read the pane ONLY for activity/quiescence timing — its bytes are
|
|
1155
|
-
// TUI chrome (spinners, "Waiting for response…", token counters) and
|
|
1156
|
-
// must NOT be emitted as the agent's output.
|
|
1157
|
-
const { text: paneText, offset: paneOff } = readFrom(sess.rawLog, readOffset);
|
|
1158
|
-
if (paneText) {
|
|
1159
|
-
readOffset = paneOff;
|
|
1160
|
-
sess.readOffset = paneOff;
|
|
1161
|
-
if (stripAnsi(paneText).trim()) {
|
|
1162
|
-
_lastActivityMs.set(root, Date.now());
|
|
1163
|
-
lastGrowthMs = Date.now();
|
|
1164
|
-
sawOutput = true;
|
|
1165
|
-
}
|
|
1166
|
-
}
|
|
1167
|
-
// 1b) Emit CLEAN output from grok's structured transcript (assistant prose
|
|
1168
|
-
// + compact tool markers), never the pane scrape.
|
|
1169
|
-
const ch = drainChatHistory(chatPath, chatOffset);
|
|
1170
|
-
if (ch.text) {
|
|
1171
|
-
chatOffset = ch.offset;
|
|
1172
|
-
chatEmitted = true;
|
|
1173
|
-
try {
|
|
1174
|
-
fs.appendFileSync(stdoutFile, ch.text, 'utf8');
|
|
1175
|
-
}
|
|
1176
|
-
catch { /* ignore */ }
|
|
1177
|
-
emitLines(ch.text);
|
|
1178
|
-
activityBuf += ch.text;
|
|
1179
|
-
scheduleActivity();
|
|
1180
|
-
_lastActivityMs.set(root, Date.now());
|
|
1181
|
-
}
|
|
1182
|
-
// 1c) Emit rich per-tool hook events (Pre/PostToolUse with payload) from the
|
|
1183
|
-
// SAME transcript, on an independent offset.
|
|
1184
|
-
toolOffset = scanGrokToolEvents(root, chatPath, toolOffset, pendingTools);
|
|
1185
|
-
// 2) Snapshot for detection (also catches a mid-turn reauth prompt).
|
|
1186
|
-
const snap = capturePane(sess.name);
|
|
1187
|
-
if (REAUTH_MARKERS.test(snap)) {
|
|
1188
|
-
try {
|
|
1189
|
-
fs.appendFileSync(stdoutFile, `\n[Grok TUI: reauthentication required — please login]\n`, 'utf8');
|
|
1190
|
-
}
|
|
1191
|
-
catch { /* ignore */ }
|
|
1192
|
-
finish(1, 'reauth_required');
|
|
1193
|
-
return;
|
|
1194
|
-
}
|
|
1195
|
-
// Backstop: the project-directory picker appears AFTER the first prompt is
|
|
1196
|
-
// submitted (grok's one-time confirm), so it lands mid-turn. The config
|
|
1197
|
-
// opt-out normally prevents it; if a grok build ignores that, select the
|
|
1198
|
-
// current dir (option "1", always present) to unblock the turn.
|
|
1199
|
-
if (/Run Grok Build in a project directory|Don't ask me again|\(current\)/i.test(snap)) {
|
|
1200
|
-
tmux(['send-keys', '-t', sess.name, '1']);
|
|
1201
|
-
await sleep(800);
|
|
1202
|
-
lastGrowthMs = Date.now();
|
|
1203
|
-
continue;
|
|
1204
|
-
}
|
|
1205
|
-
// 3) Idle test. grok prints "Worked for N.Ns." when a turn genuinely ends —
|
|
1206
|
-
// that's the fast, positive done-signal. Absent it (an error turn, or an
|
|
1207
|
-
// unusual grok build), fall back to a MUCH longer quiescence so early
|
|
1208
|
-
// "Starting session…"/"Reading file…" pauses aren't misread as turn-end.
|
|
1209
|
-
if (DONE_MARKER.test(snap)) {
|
|
1210
|
-
sawDoneMarker = true;
|
|
1211
|
-
}
|
|
1212
|
-
if (snap === lastSnap) {
|
|
1213
|
-
stable++;
|
|
1214
|
-
}
|
|
1215
|
-
else {
|
|
1216
|
-
stable = 0;
|
|
1217
|
-
lastSnap = snap;
|
|
1218
|
-
}
|
|
1219
|
-
const running = RUNNING_INDICATOR.test(snap);
|
|
1220
|
-
const quietMs = Date.now() - lastGrowthMs;
|
|
1221
|
-
const readyToJudge = sawOutput || (Date.now() - turnStart >= NOOUTPUT_MS);
|
|
1222
|
-
const doneFast = sawDoneMarker && quietMs >= DEBOUNCE_MS;
|
|
1223
|
-
const doneSlow = readyToJudge && quietMs >= QUIET_FALLBACK_MS && stable >= STABLE_POLLS;
|
|
1224
|
-
if (!running && (doneFast || doneSlow)) {
|
|
1225
|
-
// Final drain of the transcript to catch records written between the last
|
|
1226
|
-
// poll and turn-end.
|
|
1227
|
-
const tail = drainChatHistory(chatPath, chatOffset);
|
|
1228
|
-
if (tail.text) {
|
|
1229
|
-
chatOffset = tail.offset;
|
|
1230
|
-
chatEmitted = true;
|
|
1231
|
-
try {
|
|
1232
|
-
fs.appendFileSync(stdoutFile, tail.text, 'utf8');
|
|
1233
|
-
}
|
|
1234
|
-
catch { /* ignore */ }
|
|
1235
|
-
emitLines(tail.text);
|
|
1236
|
-
}
|
|
1237
|
-
// Flush any tool events written between the last poll and turn-end.
|
|
1238
|
-
toolOffset = scanGrokToolEvents(root, chatPath, toolOffset, pendingTools);
|
|
1239
|
-
// Safety net: if the transcript path never resolved (grok layout change /
|
|
1240
|
-
// encoding drift) yet the pane clearly produced output, fall back to a
|
|
1241
|
-
// cleaned pane tail so the turn is never silently empty.
|
|
1242
|
-
if (!chatEmitted && sawOutput) {
|
|
1243
|
-
const pane = stripAnsi(capturePane(sess.name, 400)).replace(/[ \t]+\n/g, '\n').trim();
|
|
1244
|
-
if (pane) {
|
|
1245
|
-
try {
|
|
1246
|
-
fs.appendFileSync(stdoutFile, pane + '\n', 'utf8');
|
|
1247
|
-
}
|
|
1248
|
-
catch { /* ignore */ }
|
|
1249
|
-
}
|
|
1250
|
-
log('Grok TUI: transcript yielded no output — fell back to pane capture (check chat_history path)');
|
|
1251
|
-
}
|
|
1252
|
-
finish(0, 'idle');
|
|
1253
|
-
return;
|
|
1254
|
-
}
|
|
1255
|
-
// 4) Watchdog — a turn that overruns the hard budget. Interrupt with Esc
|
|
1256
|
-
// (grok's cancel) to preserve the session/context; if it won't settle,
|
|
1257
|
-
// kill the session so the NEXT turn relaunches with --resume.
|
|
1258
|
-
if (MAX_RUN_MS > 0 && Date.now() - turnStart >= MAX_RUN_MS) {
|
|
1259
|
-
log(`Grok TUI watchdog: exceeded ${Math.round(MAX_RUN_MS / 60_000)}min budget — interrupting`);
|
|
1260
|
-
try {
|
|
1261
|
-
fs.appendFileSync(stdoutFile, `\n[Grok TUI watchdog: turn exceeded time budget — interrupted]\n`, 'utf8');
|
|
1262
|
-
}
|
|
1263
|
-
catch { /* ignore */ }
|
|
1264
|
-
tmux(['send-keys', '-t', sess.name, 'Escape']);
|
|
1265
|
-
// Give grok a moment to settle after the interrupt.
|
|
1266
|
-
let settled = false;
|
|
1267
|
-
for (let i = 0; i < 8; i++) {
|
|
1268
|
-
await sleep(500);
|
|
1269
|
-
if (!hasSession(sess.name)) {
|
|
1270
|
-
break;
|
|
1271
|
-
}
|
|
1272
|
-
const s2 = capturePane(sess.name);
|
|
1273
|
-
if (!RUNNING_INDICATOR.test(s2)) {
|
|
1274
|
-
settled = true;
|
|
1275
|
-
break;
|
|
1276
|
-
}
|
|
1277
|
-
}
|
|
1278
|
-
if (!settled && hasSession(sess.name)) {
|
|
1279
|
-
// Still wedged after Esc → kill so ensureSession relaunches next turn.
|
|
1280
|
-
killSession(sess.name);
|
|
1281
|
-
_tmuxSessions.delete(root);
|
|
1282
|
-
}
|
|
1283
|
-
finish(124, 'watchdog');
|
|
1284
|
-
return;
|
|
1285
|
-
}
|
|
1286
|
-
}
|
|
1287
|
-
})();
|
|
1288
|
-
}
|
|
1289
|
-
function sendGrokPrompt(root, promptFilePath, stdoutFile, exitFile, log, opts = {}) {
|
|
1290
|
-
opts.showOutput?.();
|
|
1291
|
-
const modelArgs = opts.model ? ['-m', opts.model] : [];
|
|
1292
|
-
const modelLabel = opts.model || 'account default';
|
|
1293
|
-
const sessionArgs = [];
|
|
1294
|
-
if (opts.persist) {
|
|
1295
|
-
const providerId = opts.providerId ?? 'grok-tui';
|
|
1296
|
-
if (opts.sessionId) {
|
|
1297
|
-
sessionArgs.push('--resume', opts.sessionId);
|
|
1298
|
-
log(`Grok: resuming session ${opts.sessionId.slice(0, 8)}… (model=${modelLabel})`);
|
|
1299
|
-
}
|
|
1300
|
-
else {
|
|
1301
|
-
const sid = (0, crypto_1.randomUUID)();
|
|
1302
|
-
sessionArgs.push('--session-id', sid);
|
|
1303
|
-
try {
|
|
1304
|
-
(0, sessionState_1.saveSessionId)(root, providerId, sid);
|
|
1305
|
-
}
|
|
1306
|
-
catch { /* best effort */ }
|
|
1307
|
-
log(`Grok: new persistent session ${sid.slice(0, 8)}… (model=${modelLabel})`);
|
|
1308
|
-
}
|
|
1309
|
-
}
|
|
1310
|
-
else {
|
|
1311
|
-
log(`Grok: spawning (model=${modelLabel})`);
|
|
1312
|
-
}
|
|
1313
|
-
try {
|
|
1314
|
-
fs.writeFileSync(stdoutFile, '', 'utf8');
|
|
1315
|
-
}
|
|
1316
|
-
catch { /* ignore */ }
|
|
1317
|
-
try {
|
|
1318
|
-
fs.writeFileSync(exitFile, '', 'utf8');
|
|
1319
|
-
}
|
|
1320
|
-
catch { /* ignore */ }
|
|
1321
|
-
const prior = _activeChildren.get(root);
|
|
1322
|
-
if (prior) {
|
|
1323
|
-
try {
|
|
1324
|
-
prior.kill('SIGKILL');
|
|
1325
|
-
}
|
|
1326
|
-
catch { /* ignore */ }
|
|
1327
|
-
_activeChildren.delete(root);
|
|
1328
|
-
}
|
|
1329
|
-
_busyRoots.add(root);
|
|
1330
|
-
const failSpawn = (msg) => {
|
|
1331
|
-
log(`Grok spawn error: ${msg}`);
|
|
1332
|
-
try {
|
|
1333
|
-
fs.appendFileSync(stdoutFile, `\n[Grok spawn error: ${msg}]\n`, 'utf8');
|
|
1334
|
-
}
|
|
1335
|
-
catch { /* ignore */ }
|
|
1336
|
-
try {
|
|
1337
|
-
fs.writeFileSync(exitFile, '1\n', 'utf8');
|
|
1338
|
-
}
|
|
1339
|
-
catch { /* ignore */ }
|
|
1340
|
-
_activeChildren.delete(root);
|
|
1341
|
-
_busyRoots.delete(root);
|
|
1342
|
-
};
|
|
1343
|
-
let child;
|
|
1344
|
-
try {
|
|
1345
|
-
child = child_process.spawn(GROK_BIN, [
|
|
1346
|
-
...modelArgs,
|
|
1347
|
-
'--always-approve',
|
|
1348
|
-
'--cwd', root,
|
|
1349
|
-
...sessionArgs,
|
|
1350
|
-
'--prompt-file', promptFilePath,
|
|
1351
|
-
'--output-format', 'streaming-json',
|
|
1352
|
-
], { cwd: root, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env } });
|
|
1353
|
-
}
|
|
1354
|
-
catch (spawnErr) {
|
|
1355
|
-
failSpawn(spawnErr?.message ?? String(spawnErr));
|
|
1356
|
-
return;
|
|
1357
|
-
}
|
|
1358
|
-
child.on('error', (err) => {
|
|
1359
|
-
const missing = err.code === 'ENOENT';
|
|
1360
|
-
failSpawn(missing
|
|
1361
|
-
? `${GROK_BIN} is not installed or not on PATH (${err.message})`
|
|
1362
|
-
: err.message);
|
|
1363
|
-
});
|
|
1364
|
-
_activeChildren.set(root, child);
|
|
1365
|
-
child.once('spawn', () => { _emitGrokHook(root, 'SessionStart', { source: 'startup' }); });
|
|
1366
|
-
const GROK_MAX_RUN_MS = envNum('AUTODEV_GROK_MAX_RUN_MS', 10 * 60_000);
|
|
1367
|
-
const GROK_MAX_TOOL_ERRORS = envNum('AUTODEV_GROK_MAX_TOOL_ERRORS', 25);
|
|
1368
|
-
let toolErrorCount = 0;
|
|
1369
|
-
let killed = false;
|
|
1370
|
-
const killGrok = (why) => {
|
|
1371
|
-
if (killed) {
|
|
1372
|
-
return;
|
|
1373
|
-
}
|
|
1374
|
-
killed = true;
|
|
1375
|
-
log(`Grok watchdog: ${why} — killing run`);
|
|
1376
|
-
try {
|
|
1377
|
-
fs.appendFileSync(stdoutFile, `\n[Grok watchdog: ${why}]\n`, 'utf8');
|
|
1378
|
-
}
|
|
1379
|
-
catch { /* ignore */ }
|
|
1380
|
-
try {
|
|
1381
|
-
child.kill('SIGKILL');
|
|
1382
|
-
}
|
|
1383
|
-
catch { /* ignore */ }
|
|
1384
|
-
};
|
|
1385
|
-
const watchdog = GROK_MAX_RUN_MS > 0
|
|
1386
|
-
? setTimeout(() => killGrok(`exceeded ${Math.round(GROK_MAX_RUN_MS / 60_000)}min time budget`), GROK_MAX_RUN_MS)
|
|
1387
|
-
: null;
|
|
1388
|
-
const rl = readline.createInterface({ input: child.stdout });
|
|
1389
|
-
let _endSeen = false;
|
|
1390
|
-
let _activityBuf = '';
|
|
1391
|
-
let _activityTimer = null;
|
|
1392
|
-
const flushActivity = () => {
|
|
1393
|
-
if (_activityTimer) {
|
|
1394
|
-
clearTimeout(_activityTimer);
|
|
1395
|
-
_activityTimer = null;
|
|
1396
|
-
}
|
|
1397
|
-
const t = _activityBuf.trim();
|
|
1398
|
-
_activityBuf = '';
|
|
1399
|
-
if (!t) {
|
|
1400
|
-
return;
|
|
1401
|
-
}
|
|
1402
|
-
const preview = t.length > 280 ? t.slice(0, 277) + '…' : t;
|
|
1403
|
-
_emitGrokHook(root, 'Notification', { message: preview, title: 'grok', tool_name: 'grok' });
|
|
1404
|
-
};
|
|
1405
|
-
const scheduleActivity = () => {
|
|
1406
|
-
if (_activityBuf.length >= 400) {
|
|
1407
|
-
flushActivity();
|
|
1408
|
-
return;
|
|
1409
|
-
}
|
|
1410
|
-
if (!_activityTimer) {
|
|
1411
|
-
_activityTimer = setTimeout(flushActivity, 1200);
|
|
1412
|
-
}
|
|
1413
|
-
};
|
|
1414
|
-
let _lineBuf = '';
|
|
1415
|
-
const emitOutputLines = (text) => {
|
|
1416
|
-
_lineBuf += text;
|
|
1417
|
-
let nl;
|
|
1418
|
-
while ((nl = _lineBuf.indexOf('\n')) >= 0) {
|
|
1419
|
-
const line = _lineBuf.slice(0, nl).replace(/\s+$/, '');
|
|
1420
|
-
_lineBuf = _lineBuf.slice(nl + 1);
|
|
1421
|
-
if (line.trim()) {
|
|
1422
|
-
log(` ${line}`);
|
|
1423
|
-
}
|
|
1424
|
-
}
|
|
1425
|
-
};
|
|
1426
|
-
const flushOutputLine = () => {
|
|
1427
|
-
const line = _lineBuf.replace(/\s+$/, '');
|
|
1428
|
-
_lineBuf = '';
|
|
1429
|
-
if (line.trim()) {
|
|
1430
|
-
log(` ${line}`);
|
|
1431
|
-
}
|
|
1432
|
-
};
|
|
1433
|
-
rl.on('line', (line) => {
|
|
1434
|
-
if (!line.trim()) {
|
|
1435
|
-
return;
|
|
1436
|
-
}
|
|
1437
|
-
let msg;
|
|
1438
|
-
try {
|
|
1439
|
-
msg = JSON.parse(line);
|
|
1440
|
-
}
|
|
1441
|
-
catch {
|
|
1442
|
-
try {
|
|
1443
|
-
fs.appendFileSync(stdoutFile, line + '\n', 'utf8');
|
|
1444
|
-
}
|
|
1445
|
-
catch { /* ignore */ }
|
|
1446
|
-
return;
|
|
1447
|
-
}
|
|
1448
|
-
const type = msg.type ?? '';
|
|
1449
|
-
if (type === 'assistant' || type === 'text') {
|
|
1450
|
-
const text = msg.content ?? msg.data ?? msg.text ?? msg.message ?? '';
|
|
1451
|
-
if (text) {
|
|
1452
|
-
try {
|
|
1453
|
-
fs.appendFileSync(stdoutFile, text, 'utf8');
|
|
1454
|
-
}
|
|
1455
|
-
catch { /* ignore */ }
|
|
1456
|
-
emitOutputLines(text);
|
|
1457
|
-
_activityBuf += text;
|
|
1458
|
-
scheduleActivity();
|
|
1459
|
-
}
|
|
1460
|
-
}
|
|
1461
|
-
else if (type === 'error') {
|
|
1462
|
-
const errMsg = msg.message ?? JSON.stringify(msg);
|
|
1463
|
-
log(`Grok error: ${errMsg}`);
|
|
1464
|
-
try {
|
|
1465
|
-
fs.appendFileSync(stdoutFile, `\n[Grok error: ${errMsg}]\n`, 'utf8');
|
|
1466
|
-
}
|
|
1467
|
-
catch { /* ignore */ }
|
|
1468
|
-
}
|
|
1469
|
-
else if (type === 'end') {
|
|
1470
|
-
_endSeen = true;
|
|
1471
|
-
flushOutputLine();
|
|
1472
|
-
flushActivity();
|
|
1473
|
-
_emitGrokHook(root, 'Stop', {});
|
|
1474
|
-
_emitGrokHook(root, 'SessionEnd', { reason: 'completed' });
|
|
1475
|
-
}
|
|
1476
|
-
});
|
|
1477
|
-
const errRl = child.stderr ? readline.createInterface({ input: child.stderr }) : null;
|
|
1478
|
-
errRl?.on('line', (line) => {
|
|
1479
|
-
const text = line.trim();
|
|
1480
|
-
if (!text) {
|
|
1481
|
-
return;
|
|
1482
|
-
}
|
|
1483
|
-
log(`Grok stderr: ${text}`);
|
|
1484
|
-
if (GROK_MAX_TOOL_ERRORS > 0 && text.includes('tool_output_error')) {
|
|
1485
|
-
toolErrorCount++;
|
|
1486
|
-
if (toolErrorCount >= GROK_MAX_TOOL_ERRORS) {
|
|
1487
|
-
killGrok(`${toolErrorCount} tool-output errors (stuck retry loop)`);
|
|
1488
|
-
}
|
|
1489
|
-
}
|
|
1490
|
-
});
|
|
1491
|
-
child.on('close', (code) => {
|
|
1492
|
-
if (watchdog) {
|
|
1493
|
-
clearTimeout(watchdog);
|
|
1494
|
-
}
|
|
1495
|
-
flushOutputLine();
|
|
1496
|
-
flushActivity();
|
|
1497
|
-
errRl?.close();
|
|
1498
|
-
rl.close();
|
|
1499
|
-
_activeChildren.delete(root);
|
|
1500
|
-
_busyRoots.delete(root);
|
|
1501
|
-
const exitCode = code ?? 1;
|
|
1502
|
-
log(`Grok: exited (code=${exitCode})`);
|
|
1503
|
-
if (!_endSeen) {
|
|
1504
|
-
_emitGrokHook(root, exitCode === 0 ? 'Stop' : 'StopFailure', { exit_code: exitCode });
|
|
1505
|
-
_emitGrokHook(root, 'SessionEnd', { reason: exitCode === 0 ? 'completed' : 'error' });
|
|
1506
|
-
}
|
|
1507
|
-
try {
|
|
1508
|
-
fs.writeFileSync(exitFile, `${exitCode}\n`, 'utf8');
|
|
1509
|
-
}
|
|
1510
|
-
catch { /* ignore */ }
|
|
1511
|
-
});
|
|
1512
|
-
}
|
|
1513
|
-
/** grok-cli: stateless — a fresh HEADLESS grok process every task. */
|
|
1514
|
-
function sendGrokCliPrompt(root, promptFilePath, stdoutFile, exitFile, log, model, showOutput) {
|
|
1515
|
-
sendGrokPrompt(root, promptFilePath, stdoutFile, exitFile, log, {
|
|
1516
|
-
model, showOutput, persist: false, providerId: 'grok-cli',
|
|
1517
|
-
});
|
|
1518
|
-
}
|
|
1519
|
-
/**
|
|
1520
|
-
* grok-tui: persistent — run the turn in the per-workspace tmux session so
|
|
1521
|
-
* grok's in-process context accumulates across tasks. Falls back to the old
|
|
1522
|
-
* headless spawn (with --resume) when tmux is unavailable so nothing regresses.
|
|
1523
|
-
*/
|
|
1524
|
-
function sendGrokTuiPrompt(root, promptFilePath, resolvedSessionId, stdoutFile, exitFile, log, model, showOutput) {
|
|
1525
|
-
if (tmuxAvailable()) {
|
|
1526
|
-
runGrokTmuxTurn(root, promptFilePath, resolvedSessionId, stdoutFile, exitFile, log, model, showOutput);
|
|
1527
|
-
return;
|
|
1528
|
-
}
|
|
1529
|
-
log('Grok TUI: tmux not available — falling back to headless spawn');
|
|
1530
|
-
sendGrokPrompt(root, promptFilePath, stdoutFile, exitFile, log, {
|
|
1531
|
-
model, showOutput, persist: true, sessionId: resolvedSessionId, providerId: 'grok-tui',
|
|
1532
|
-
});
|
|
1533
|
-
}
|
|
1534
|
-
// ---------------------------------------------------------------------------
|
|
1535
|
-
// steerGrokTui — inject a message into the RUNNING grok turn via the live pane.
|
|
1536
|
-
//
|
|
1537
|
-
// Because the tmux session is a real TTY, keys sent to a mid-turn pane are
|
|
1538
|
-
// delivered to grok's input and folded into the current turn (confirmed with a
|
|
1539
|
-
// live REPL stand-in). Mirrors steerClaudeTui: only injects when a turn is
|
|
1540
|
-
// actually running; returns false when there is no live pane so the caller
|
|
1541
|
-
// keeps the durable TODO fallback (at-least-once delivery).
|
|
1542
|
-
// ---------------------------------------------------------------------------
|
|
1543
|
-
async function steerGrokTui(root, text, log) {
|
|
1544
|
-
const sess = _tmuxSessions.get(root);
|
|
1545
|
-
if (!sess) {
|
|
1546
|
-
return false;
|
|
1547
|
-
} // no live tmux session (headless / not started)
|
|
1548
|
-
if (!_busyRoots.has(root)) {
|
|
1549
|
-
return false;
|
|
1550
|
-
} // only steer a turn in flight
|
|
1551
|
-
if (!hasSession(sess.name)) {
|
|
1552
|
-
_tmuxSessions.delete(root);
|
|
1553
|
-
return false;
|
|
1554
|
-
}
|
|
1555
|
-
try {
|
|
1556
|
-
// Literal keystrokes (send-keys -l), NOT a bracketed paste — grok's TUI
|
|
1557
|
-
// misreads a paste as a directory picker. Flatten to one line (no reliable
|
|
1558
|
-
// newline-without-submit key), then Enter to inject into the running turn.
|
|
1559
|
-
const flat = text.replace(/\r?\n+/g, ' ').replace(/[ \t]+/g, ' ').trim();
|
|
1560
|
-
for (let i = 0; i < flat.length; i += 3000) {
|
|
1561
|
-
if (tmux(['send-keys', '-t', sess.name, '-l', flat.slice(i, i + 3000)]) === null) {
|
|
1562
|
-
return false;
|
|
1563
|
-
}
|
|
1564
|
-
}
|
|
1565
|
-
if (tmux(['send-keys', '-t', sess.name, 'Enter']) === null) {
|
|
1566
|
-
return false;
|
|
1567
|
-
}
|
|
1568
|
-
log(`Grok TUI: steered live pane (${text.length} chars) — injected into current turn`);
|
|
1569
|
-
return true;
|
|
1570
|
-
}
|
|
1571
|
-
catch (err) {
|
|
1572
|
-
log(`Grok TUI: steer failed: ${err?.message ?? String(err)}`);
|
|
1573
|
-
return false;
|
|
1574
|
-
}
|
|
1575
|
-
}
|
|
1576
|
-
// ---------------------------------------------------------------------------
|
|
1577
|
-
// Teardown
|
|
1578
|
-
// ---------------------------------------------------------------------------
|
|
1579
|
-
/**
|
|
1580
|
-
* Called by taskLoop reset interval / provider close. Kills any live grok for
|
|
1581
|
-
* this root — the tmux session AND any headless child — while PRESERVING the
|
|
1582
|
-
* persisted session id so a later task relaunches with --resume (context intact).
|
|
1583
|
-
*/
|
|
1584
|
-
function closeGrokTuiSession(root, _log) {
|
|
1585
|
-
const child = _activeChildren.get(root);
|
|
1586
|
-
if (child) {
|
|
1587
|
-
try {
|
|
1588
|
-
child.kill('SIGKILL');
|
|
1589
|
-
}
|
|
1590
|
-
catch { /* ignore */ }
|
|
1591
|
-
_activeChildren.delete(root);
|
|
1592
|
-
}
|
|
1593
|
-
const sess = _tmuxSessions.get(root);
|
|
1594
|
-
if (sess) {
|
|
1595
|
-
killSession(sess.name);
|
|
1596
|
-
_tmuxSessions.delete(root);
|
|
1597
|
-
}
|
|
1598
|
-
else {
|
|
1599
|
-
// No cached handle but a session may still exist under the deterministic name.
|
|
1600
|
-
const name = sessionName(root);
|
|
1601
|
-
if (hasSession(name)) {
|
|
1602
|
-
killSession(name);
|
|
1603
|
-
}
|
|
1604
|
-
}
|
|
1605
|
-
_busyRoots.delete(root);
|
|
1606
|
-
_emittedToolIds.delete(root);
|
|
1607
|
-
}
|
|
1608
|
-
/** Kill all live grok sessions — called on SDK/extension shutdown. */
|
|
1609
|
-
function closeAllGrokTuiSessions() {
|
|
1610
|
-
for (const child of _activeChildren.values()) {
|
|
1611
|
-
try {
|
|
1612
|
-
child.kill('SIGKILL');
|
|
1613
|
-
}
|
|
1614
|
-
catch { /* ignore */ }
|
|
1615
|
-
}
|
|
1616
|
-
_activeChildren.clear();
|
|
1617
|
-
for (const sess of _tmuxSessions.values()) {
|
|
1618
|
-
killSession(sess.name);
|
|
1619
|
-
}
|
|
1620
|
-
_tmuxSessions.clear();
|
|
1621
|
-
_busyRoots.clear();
|
|
1622
|
-
_emittedToolIds.clear();
|
|
1623
|
-
}
|
|
1624
|
-
// ---------------------------------------------------------------------------
|
|
1625
|
-
// detectGrokTuiRateLimit
|
|
1626
|
-
// ---------------------------------------------------------------------------
|
|
1627
|
-
function detectGrokTuiRateLimit(stdoutContent) {
|
|
1628
|
-
return rateLimit_1.RateLimitDetector.detect(stdoutContent);
|
|
1629
|
-
}
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
__exportStar(require("./grok/tui"), exports);
|
|
1630
18
|
//# sourceMappingURL=grokTuiProvider.js.map
|