tide-commander 1.148.0 → 1.149.0
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/CHANGELOG.md +17 -0
- package/README.md +6 -3
- package/dist/assets/{BossLogsModal-CZMHgMBE.js → BossLogsModal-Cs4kWugD.js} +1 -1
- package/dist/assets/BossSpawnModal-BudMgJqj.js +1 -0
- package/dist/assets/{ControlsModal-DJ8Scm16.js → ControlsModal-Bh6_3qtH.js} +1 -1
- package/dist/assets/{DockerLogsModal-Vb4gqdFz.js → DockerLogsModal-Nf4wQFfz.js} +1 -1
- package/dist/assets/{EmbeddedEditor-D4sHERAj.js → EmbeddedEditor-BfRXRX50.js} +1 -1
- package/dist/assets/{GmailOAuthSetup-COIYIbJS.js → GmailOAuthSetup-CXUtjA0r.js} +1 -1
- package/dist/assets/{GoogleOAuthSetup-DJS59Soi.js → GoogleOAuthSetup-D-_rwicD.js} +1 -1
- package/dist/assets/{IframeModal-CFysTkn9.js → IframeModal-CdaXsQK0.js} +1 -1
- package/dist/assets/{IntegrationsPanel-BK8g9ceY.js → IntegrationsPanel-ZsSVxcWr.js} +2 -2
- package/dist/assets/{LogViewerModal-DRxR0HYq.js → LogViewerModal-BAf-klB2.js} +1 -1
- package/dist/assets/{MonitoringModal-CL7UMOdX.js → MonitoringModal-BjRbh5z4.js} +1 -1
- package/dist/assets/{PM2LogsModal-I3aouUlw.js → PM2LogsModal-SaywDZVL.js} +1 -1
- package/dist/assets/{RestoreArchivedAreaModal-PWAg6w6b.js → RestoreArchivedAreaModal-jru2Ez9a.js} +1 -1
- package/dist/assets/Scene2DCanvas-NJHoe2Qg.js +1 -0
- package/dist/assets/SceneManager-i9wVMa_V.js +104 -0
- package/dist/assets/{SkillsPanel-EQ74sIa6.js → SkillsPanel-BHfKLzWe.js} +3 -3
- package/dist/assets/{SlackMultiInstanceSetup-CLMUteI0.js → SlackMultiInstanceSetup-BfVevCSo.js} +1 -1
- package/dist/assets/SpawnModal-29x5z53B.js +1 -0
- package/dist/assets/{StatisticsModal-D6MgA_o0.js → StatisticsModal-MbIByjWD.js} +1 -1
- package/dist/assets/{SubordinateAssignmentModal-C6n0pMn3.js → SubordinateAssignmentModal-DF0vYaM6.js} +1 -1
- package/dist/assets/{TriggerManagerPanel-Umd8iUg1.js → TriggerManagerPanel-EGcKS1Pt.js} +1 -1
- package/dist/assets/{WorkflowEditorPanel-CxjBxs9O.js → WorkflowEditorPanel-BLmejfqe.js} +1 -1
- package/dist/assets/{area-logos-CThPeqIM.js → area-logos-C4fgpLgO.js} +1 -1
- package/dist/assets/grok.ico +0 -0
- package/dist/assets/grok.png +0 -0
- package/dist/assets/{index-h3Cuh2Zn.js → index-B-2H7O_Y.js} +2 -2
- package/dist/assets/{index-DelocTaH.js → index-B9xpLAcH.js} +1 -1
- package/dist/assets/{index-BUN7MVf5.js → index-BZ4wZwRu.js} +3 -3
- package/dist/assets/index-BiiLWTXJ.js +1 -0
- package/dist/assets/{index-maZSt2YW.js → index-C6Kf4ewu.js} +5 -5
- package/dist/assets/{index-Cz-ypmob.js → index-C8CGfmXP.js} +1 -1
- package/dist/assets/index-DThK5_yS.js +5 -0
- package/dist/assets/index-DYgVP0G3.js +2 -0
- package/dist/assets/{index-DOzFBkSH.js → index-DhD3m2Qx.js} +1 -1
- package/dist/assets/main-B0UlZnfk.js +260 -0
- package/dist/assets/main-DeiAIdzy.css +1 -0
- package/dist/assets/{web-CfvEypdU.js → web-CXC8P25z.js} +1 -1
- package/dist/assets/{web-CNjTPilF.js → web-LGEDa25c.js} +1 -1
- package/dist/assets/{web-bcYaAwP6.js → web-NM4dUqWg.js} +1 -1
- package/dist/index.html +2 -2
- package/dist/locales/en/tools.json +4 -2
- package/dist/src/packages/server/claude/runner/process-lifecycle.js +53 -0
- package/dist/src/packages/server/claude/runner/stdout-pipeline.js +125 -4
- package/dist/src/packages/server/claude/runner/watchdog.js +12 -0
- package/dist/src/packages/server/claude/session-loader.js +280 -0
- package/dist/src/packages/server/data/index.js +1 -0
- package/dist/src/packages/server/grok/backend.js +231 -0
- package/dist/src/packages/server/grok/index.js +4 -0
- package/dist/src/packages/server/grok/json-event-parser.js +138 -0
- package/dist/src/packages/server/grok/session-watcher.js +460 -0
- package/dist/src/packages/server/routes/agents.js +7 -2
- package/dist/src/packages/server/runtime/grok-runtime-provider.js +11 -0
- package/dist/src/packages/server/runtime/index.js +1 -0
- package/dist/src/packages/server/services/agent-service.js +26 -5
- package/dist/src/packages/server/services/runtime-command-execution.js +4 -2
- package/dist/src/packages/server/services/runtime-events.js +46 -7
- package/dist/src/packages/server/services/runtime-service.js +19 -3
- package/dist/src/packages/server/utils/tool-formatting.js +40 -12
- package/dist/src/packages/server/websocket/handlers/agent-handler.js +70 -17
- package/dist/src/packages/server/websocket/listeners/runtime-listeners.js +63 -7
- package/dist/src/packages/shared/agent-types.js +10 -0
- package/package.json +1 -1
- package/dist/assets/BossSpawnModal-C6DxqtKP.js +0 -1
- package/dist/assets/Scene2DCanvas-DYPHkU3i.js +0 -1
- package/dist/assets/SceneManager-QqKygyaw.js +0 -104
- package/dist/assets/SpawnModal-BOeStihN.js +0 -1
- package/dist/assets/index-DP7kd91h.js +0 -1
- package/dist/assets/index-DRyMARFk.js +0 -5
- package/dist/assets/index-DuWmOZdY.js +0 -2
- package/dist/assets/main-C2zgPci-.js +0 -256
- package/dist/assets/main-C3JO2TRI.css +0 -1
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Grok headless streaming-json event parser.
|
|
3
|
+
* Maps NDJSON events from `grok -p … --output-format streaming-json` to StandardEvent.
|
|
4
|
+
*
|
|
5
|
+
* Documented event types: text, thought, end, error.
|
|
6
|
+
* Also handles max_turns_reached / auto_compact_* defensively.
|
|
7
|
+
*
|
|
8
|
+
* Grok emits one tiny token per NDJSON line. The client only merges streaming
|
|
9
|
+
* chunks when they share a `uuid`, so we mint a stable stream id per block
|
|
10
|
+
* (text / thinking) for the turn and finalize with isStreaming=false on end.
|
|
11
|
+
*/
|
|
12
|
+
import { randomUUID } from 'crypto';
|
|
13
|
+
import { createLogger } from '../utils/logger.js';
|
|
14
|
+
const log = createLogger('GrokParser');
|
|
15
|
+
export class GrokJsonEventParser {
|
|
16
|
+
lastTextContent = '';
|
|
17
|
+
lastThinkingContent = '';
|
|
18
|
+
textEventEmittedInTurn = false;
|
|
19
|
+
/** Stable uuid so token-sized deltas merge into one terminal row. */
|
|
20
|
+
textStreamUuid;
|
|
21
|
+
thinkingStreamUuid;
|
|
22
|
+
parseEvent(rawEvent) {
|
|
23
|
+
const event = rawEvent;
|
|
24
|
+
if (!event || typeof event !== 'object' || !event.type) {
|
|
25
|
+
return [];
|
|
26
|
+
}
|
|
27
|
+
log.log(`parseEvent: type=${event.type}, sessionId=${event.sessionId || 'none'}`);
|
|
28
|
+
switch (event.type) {
|
|
29
|
+
case 'text':
|
|
30
|
+
return this.parseText(event);
|
|
31
|
+
case 'thought':
|
|
32
|
+
return this.parseThought(event);
|
|
33
|
+
case 'end':
|
|
34
|
+
return this.parseEnd(event);
|
|
35
|
+
case 'error':
|
|
36
|
+
return this.parseError(event);
|
|
37
|
+
case 'max_turns_reached':
|
|
38
|
+
return [{
|
|
39
|
+
type: 'error',
|
|
40
|
+
errorMessage: event.message || 'Max turns reached',
|
|
41
|
+
sessionId: event.sessionId,
|
|
42
|
+
}];
|
|
43
|
+
default:
|
|
44
|
+
// Unknown / telemetry events — ignore for UI, but keep the stream alive
|
|
45
|
+
log.log(`parseEvent: unknown event type '${event.type}' - skipping`);
|
|
46
|
+
return [];
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
ensureTextStreamUuid() {
|
|
50
|
+
if (!this.textStreamUuid) {
|
|
51
|
+
this.textStreamUuid = `grok-text-${randomUUID()}`;
|
|
52
|
+
}
|
|
53
|
+
return this.textStreamUuid;
|
|
54
|
+
}
|
|
55
|
+
ensureThinkingStreamUuid() {
|
|
56
|
+
if (!this.thinkingStreamUuid) {
|
|
57
|
+
this.thinkingStreamUuid = `grok-thinking-${randomUUID()}`;
|
|
58
|
+
}
|
|
59
|
+
return this.thinkingStreamUuid;
|
|
60
|
+
}
|
|
61
|
+
parseText(event) {
|
|
62
|
+
const chunk = event.data ?? event.text;
|
|
63
|
+
if (!chunk)
|
|
64
|
+
return [];
|
|
65
|
+
this.lastTextContent += chunk;
|
|
66
|
+
this.textEventEmittedInTurn = true;
|
|
67
|
+
return [{
|
|
68
|
+
type: 'text',
|
|
69
|
+
text: chunk,
|
|
70
|
+
isStreaming: true,
|
|
71
|
+
uuid: this.ensureTextStreamUuid(),
|
|
72
|
+
sessionId: event.sessionId,
|
|
73
|
+
}];
|
|
74
|
+
}
|
|
75
|
+
parseThought(event) {
|
|
76
|
+
const chunk = event.data ?? event.text;
|
|
77
|
+
if (!chunk)
|
|
78
|
+
return [];
|
|
79
|
+
this.lastThinkingContent += chunk;
|
|
80
|
+
return [{
|
|
81
|
+
type: 'thinking',
|
|
82
|
+
text: chunk,
|
|
83
|
+
isStreaming: true,
|
|
84
|
+
uuid: this.ensureThinkingStreamUuid(),
|
|
85
|
+
sessionId: event.sessionId,
|
|
86
|
+
}];
|
|
87
|
+
}
|
|
88
|
+
parseEnd(event) {
|
|
89
|
+
const events = [];
|
|
90
|
+
// Finalize open streams so the client stops treating the row as "live"
|
|
91
|
+
// and stops re-mounting/blinking. Final payload replaces the merged row
|
|
92
|
+
// (same uuid, isStreaming=false) with the full accumulated text.
|
|
93
|
+
if (this.thinkingStreamUuid && this.lastThinkingContent) {
|
|
94
|
+
events.push({
|
|
95
|
+
type: 'thinking',
|
|
96
|
+
text: this.lastThinkingContent,
|
|
97
|
+
isStreaming: false,
|
|
98
|
+
uuid: this.thinkingStreamUuid,
|
|
99
|
+
sessionId: event.sessionId,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
if (this.textStreamUuid && this.lastTextContent) {
|
|
103
|
+
events.push({
|
|
104
|
+
type: 'text',
|
|
105
|
+
text: this.lastTextContent,
|
|
106
|
+
isStreaming: false,
|
|
107
|
+
uuid: this.textStreamUuid,
|
|
108
|
+
sessionId: event.sessionId,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
const stepComplete = {
|
|
112
|
+
type: 'step_complete',
|
|
113
|
+
sessionId: event.sessionId,
|
|
114
|
+
};
|
|
115
|
+
if (this.lastTextContent) {
|
|
116
|
+
stepComplete.resultText = this.lastTextContent;
|
|
117
|
+
}
|
|
118
|
+
else if (this.lastThinkingContent && !this.textEventEmittedInTurn) {
|
|
119
|
+
// Thinking-only turn — surface a marker so the terminal isn't blank
|
|
120
|
+
stepComplete.resultText = `(Empty response: thinking only, stopReason=${event.stopReason || 'EndTurn'})`;
|
|
121
|
+
}
|
|
122
|
+
events.push(stepComplete);
|
|
123
|
+
// Reset per-turn accumulators / stream ids
|
|
124
|
+
this.lastTextContent = '';
|
|
125
|
+
this.lastThinkingContent = '';
|
|
126
|
+
this.textEventEmittedInTurn = false;
|
|
127
|
+
this.textStreamUuid = undefined;
|
|
128
|
+
this.thinkingStreamUuid = undefined;
|
|
129
|
+
return events;
|
|
130
|
+
}
|
|
131
|
+
parseError(event) {
|
|
132
|
+
return [{
|
|
133
|
+
type: 'error',
|
|
134
|
+
errorMessage: event.message || event.data || 'Grok CLI error',
|
|
135
|
+
sessionId: event.sessionId,
|
|
136
|
+
}];
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Grok session side-channel watcher.
|
|
3
|
+
*
|
|
4
|
+
* Headless `streaming-json` only emits thought/text/end on stdout. Tool use is
|
|
5
|
+
* recorded under ~/.grok/sessions/<encoded-cwd>/<sessionId>/. This watcher
|
|
6
|
+
* tails those files and emits StandardEvents so Tide can show tool cards live.
|
|
7
|
+
*
|
|
8
|
+
* Sources:
|
|
9
|
+
* - events.jsonl → early tool_started/tool_completed (name only, fast)
|
|
10
|
+
* - chat_history.jsonl → tool_calls with args + tool_result with full output
|
|
11
|
+
*/
|
|
12
|
+
import * as fs from 'fs';
|
|
13
|
+
import * as os from 'os';
|
|
14
|
+
import * as path from 'path';
|
|
15
|
+
import { createLogger } from '../utils/logger.js';
|
|
16
|
+
const log = createLogger('GrokSessionWatcher');
|
|
17
|
+
const MAX_TOOL_OUTPUT_CHARS = 50_000;
|
|
18
|
+
const DISCOVER_INTERVAL_MS = 250;
|
|
19
|
+
const TAIL_INTERVAL_MS = 200;
|
|
20
|
+
/** Accept sessions modified up to this long before process start (slow disk / clock skew). */
|
|
21
|
+
const DISCOVER_GRACE_MS = 5_000;
|
|
22
|
+
/**
|
|
23
|
+
* Read context usage from a Grok session's signals.json.
|
|
24
|
+
* Streaming-json only emits thought/text/end — no token counts — so this file
|
|
25
|
+
* is the authoritative live source for the Tide context bar.
|
|
26
|
+
*/
|
|
27
|
+
export function readGrokSignalsUsage(sessionDir) {
|
|
28
|
+
const signalsPath = path.join(sessionDir, 'signals.json');
|
|
29
|
+
if (!fs.existsSync(signalsPath))
|
|
30
|
+
return null;
|
|
31
|
+
try {
|
|
32
|
+
const raw = JSON.parse(fs.readFileSync(signalsPath, 'utf8'));
|
|
33
|
+
const used = Number(raw.contextTokensUsed);
|
|
34
|
+
const limit = Number(raw.contextWindowTokens);
|
|
35
|
+
if (!Number.isFinite(used) || !Number.isFinite(limit) || limit <= 0)
|
|
36
|
+
return null;
|
|
37
|
+
const usagePct = Number(raw.contextWindowUsage);
|
|
38
|
+
return {
|
|
39
|
+
contextTokensUsed: Math.max(0, Math.round(used)),
|
|
40
|
+
contextWindowTokens: Math.max(1, Math.round(limit)),
|
|
41
|
+
contextWindowUsage: Number.isFinite(usagePct) ? usagePct : undefined,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/** Grok stores sessions under encodeURIComponent(absolute cwd). */
|
|
49
|
+
export function encodeGrokProjectKey(cwd) {
|
|
50
|
+
return encodeURIComponent(path.resolve(cwd));
|
|
51
|
+
}
|
|
52
|
+
export function getGrokSessionsRoot(cwd) {
|
|
53
|
+
return path.join(os.homedir(), '.grok', 'sessions', encodeGrokProjectKey(cwd));
|
|
54
|
+
}
|
|
55
|
+
export function getGrokSessionDir(cwd, sessionId) {
|
|
56
|
+
return path.join(getGrokSessionsRoot(cwd), sessionId);
|
|
57
|
+
}
|
|
58
|
+
const TOOL_NAME_MAP = {
|
|
59
|
+
list_dir: 'ListFiles',
|
|
60
|
+
read_file: 'Read',
|
|
61
|
+
search_replace: 'Edit',
|
|
62
|
+
write: 'Write',
|
|
63
|
+
run_terminal_cmd: 'Bash',
|
|
64
|
+
run_terminal_command: 'Bash',
|
|
65
|
+
grep: 'Grep',
|
|
66
|
+
web_search: 'WebSearch',
|
|
67
|
+
web_fetch: 'WebFetch',
|
|
68
|
+
open_page: 'WebFetch',
|
|
69
|
+
open_page_with_find: 'WebFetch',
|
|
70
|
+
spawn_subagent: 'Task',
|
|
71
|
+
todo_write: 'TodoWrite',
|
|
72
|
+
image_gen: 'ImageGen',
|
|
73
|
+
image_edit: 'ImageEdit',
|
|
74
|
+
};
|
|
75
|
+
export function normalizeGrokToolName(raw) {
|
|
76
|
+
if (!raw)
|
|
77
|
+
return 'unknown';
|
|
78
|
+
return TOOL_NAME_MAP[raw.toLowerCase()] || raw;
|
|
79
|
+
}
|
|
80
|
+
function parseToolInput(args) {
|
|
81
|
+
if (!args)
|
|
82
|
+
return {};
|
|
83
|
+
if (typeof args === 'object' && args !== null && !Array.isArray(args)) {
|
|
84
|
+
return args;
|
|
85
|
+
}
|
|
86
|
+
if (typeof args === 'string') {
|
|
87
|
+
try {
|
|
88
|
+
const parsed = JSON.parse(args);
|
|
89
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
90
|
+
return parsed;
|
|
91
|
+
}
|
|
92
|
+
return { raw: args };
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return { raw: args };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return {};
|
|
99
|
+
}
|
|
100
|
+
function truncateOutput(text) {
|
|
101
|
+
if (text.length <= MAX_TOOL_OUTPUT_CHARS)
|
|
102
|
+
return text;
|
|
103
|
+
return text.slice(0, MAX_TOOL_OUTPUT_CHARS) + `\n… [truncated ${text.length - MAX_TOOL_OUTPUT_CHARS} chars]`;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Append-only JSONL tailer. Starts at EOF so prior-turn tools are not re-broadcast.
|
|
107
|
+
*/
|
|
108
|
+
export class JsonlTailer {
|
|
109
|
+
position = 0;
|
|
110
|
+
buffer = '';
|
|
111
|
+
initialized = false;
|
|
112
|
+
filePath;
|
|
113
|
+
onLine;
|
|
114
|
+
constructor(filePath, onLine) {
|
|
115
|
+
this.filePath = filePath;
|
|
116
|
+
this.onLine = onLine;
|
|
117
|
+
}
|
|
118
|
+
initAtEof() {
|
|
119
|
+
try {
|
|
120
|
+
if (fs.existsSync(this.filePath)) {
|
|
121
|
+
this.position = fs.statSync(this.filePath).size;
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
this.position = 0;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
this.position = 0;
|
|
129
|
+
}
|
|
130
|
+
this.buffer = '';
|
|
131
|
+
this.initialized = true;
|
|
132
|
+
}
|
|
133
|
+
poll() {
|
|
134
|
+
if (!this.initialized)
|
|
135
|
+
this.initAtEof();
|
|
136
|
+
try {
|
|
137
|
+
if (!fs.existsSync(this.filePath))
|
|
138
|
+
return;
|
|
139
|
+
const stat = fs.statSync(this.filePath);
|
|
140
|
+
if (stat.size < this.position) {
|
|
141
|
+
this.position = 0;
|
|
142
|
+
this.buffer = '';
|
|
143
|
+
}
|
|
144
|
+
if (stat.size === this.position)
|
|
145
|
+
return;
|
|
146
|
+
const fd = fs.openSync(this.filePath, 'r');
|
|
147
|
+
try {
|
|
148
|
+
const toRead = stat.size - this.position;
|
|
149
|
+
const buf = Buffer.alloc(toRead);
|
|
150
|
+
fs.readSync(fd, buf, 0, toRead, this.position);
|
|
151
|
+
this.position = stat.size;
|
|
152
|
+
this.buffer += buf.toString('utf8');
|
|
153
|
+
}
|
|
154
|
+
finally {
|
|
155
|
+
fs.closeSync(fd);
|
|
156
|
+
}
|
|
157
|
+
const lines = this.buffer.split('\n');
|
|
158
|
+
this.buffer = lines.pop() || '';
|
|
159
|
+
for (const line of lines) {
|
|
160
|
+
const trimmed = line.trim();
|
|
161
|
+
if (!trimmed)
|
|
162
|
+
continue;
|
|
163
|
+
try {
|
|
164
|
+
this.onLine(JSON.parse(trimmed));
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
// skip malformed
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
catch (err) {
|
|
172
|
+
log.log(`tail poll error for ${this.filePath}: ${String(err)}`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
export function startGrokSessionWatcher(opts) {
|
|
177
|
+
const startedAt = opts.startedAt ?? Date.now();
|
|
178
|
+
let stopped = false;
|
|
179
|
+
let sessionId = opts.sessionId;
|
|
180
|
+
let sessionDirPath = null;
|
|
181
|
+
let chatTailer = null;
|
|
182
|
+
let eventsTailer = null;
|
|
183
|
+
let discoverTimer = null;
|
|
184
|
+
let tailTimer = null;
|
|
185
|
+
/** Dedupe key for last-emitted signals usage snapshot. */
|
|
186
|
+
let lastSignalsUsageKey = '';
|
|
187
|
+
const emittedToolStarts = new Set();
|
|
188
|
+
const emittedToolResults = new Set();
|
|
189
|
+
/** raw tool_name → queue of early synthetic ids (events.jsonl, no call id) */
|
|
190
|
+
const pendingEarlyByName = new Map();
|
|
191
|
+
const earlyToReal = new Map();
|
|
192
|
+
const realToEarly = new Map();
|
|
193
|
+
const emit = (event) => {
|
|
194
|
+
if (stopped)
|
|
195
|
+
return;
|
|
196
|
+
opts.onEvent(event);
|
|
197
|
+
};
|
|
198
|
+
const pollSignalsUsage = () => {
|
|
199
|
+
if (!sessionDirPath)
|
|
200
|
+
return;
|
|
201
|
+
const usage = readGrokSignalsUsage(sessionDirPath);
|
|
202
|
+
if (!usage)
|
|
203
|
+
return;
|
|
204
|
+
const key = `${usage.contextTokensUsed}:${usage.contextWindowTokens}`;
|
|
205
|
+
if (key === lastSignalsUsageKey)
|
|
206
|
+
return;
|
|
207
|
+
lastSignalsUsageKey = key;
|
|
208
|
+
// Encode fill as input tokens so usage_snapshot handlers treat it as
|
|
209
|
+
// current context size (cache fields left 0).
|
|
210
|
+
emit({
|
|
211
|
+
type: 'usage_snapshot',
|
|
212
|
+
tokens: {
|
|
213
|
+
input: usage.contextTokensUsed,
|
|
214
|
+
output: 0,
|
|
215
|
+
cacheRead: 0,
|
|
216
|
+
cacheCreation: 0,
|
|
217
|
+
},
|
|
218
|
+
modelUsage: {
|
|
219
|
+
contextWindow: usage.contextWindowTokens,
|
|
220
|
+
inputTokens: usage.contextTokensUsed,
|
|
221
|
+
outputTokens: 0,
|
|
222
|
+
},
|
|
223
|
+
sessionId: sessionId,
|
|
224
|
+
});
|
|
225
|
+
log.log(`📊 Grok signals usage for ${opts.agentId.slice(0, 8)}: ${usage.contextTokensUsed}/${usage.contextWindowTokens}`
|
|
226
|
+
+ (usage.contextWindowUsage != null ? ` (${usage.contextWindowUsage}%)` : ''));
|
|
227
|
+
};
|
|
228
|
+
const handleEventsLine = (raw) => {
|
|
229
|
+
const line = raw;
|
|
230
|
+
if (!line || typeof line !== 'object' || !line.type)
|
|
231
|
+
return;
|
|
232
|
+
if (line.type === 'turn_started' && typeof line.session_id === 'string') {
|
|
233
|
+
if (sessionId !== line.session_id) {
|
|
234
|
+
sessionId = line.session_id;
|
|
235
|
+
opts.onSessionId?.(sessionId);
|
|
236
|
+
}
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
if (line.type === 'tool_started' && line.tool_name) {
|
|
240
|
+
const rawName = line.tool_name;
|
|
241
|
+
const toolName = normalizeGrokToolName(rawName);
|
|
242
|
+
const synthId = `grok-early-${rawName}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
243
|
+
const queue = pendingEarlyByName.get(rawName) || [];
|
|
244
|
+
queue.push(synthId);
|
|
245
|
+
pendingEarlyByName.set(rawName, queue);
|
|
246
|
+
emittedToolStarts.add(synthId);
|
|
247
|
+
emit({
|
|
248
|
+
type: 'tool_start',
|
|
249
|
+
toolName,
|
|
250
|
+
toolInput: {},
|
|
251
|
+
toolUseId: synthId,
|
|
252
|
+
uuid: synthId,
|
|
253
|
+
});
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
if (line.type === 'tool_completed' && line.tool_name) {
|
|
257
|
+
const rawName = line.tool_name;
|
|
258
|
+
const queue = pendingEarlyByName.get(rawName);
|
|
259
|
+
if (!queue || queue.length === 0)
|
|
260
|
+
return;
|
|
261
|
+
const synthId = queue.shift();
|
|
262
|
+
if (queue.length === 0)
|
|
263
|
+
pendingEarlyByName.delete(rawName);
|
|
264
|
+
else
|
|
265
|
+
pendingEarlyByName.set(rawName, queue);
|
|
266
|
+
// If chat_history already completed the real call, skip empty early result.
|
|
267
|
+
if (emittedToolResults.has(synthId))
|
|
268
|
+
return;
|
|
269
|
+
// If this early was upgraded to a real call id that already has a result, skip.
|
|
270
|
+
const realId = earlyToReal.get(synthId);
|
|
271
|
+
if (realId && emittedToolResults.has(realId)) {
|
|
272
|
+
emittedToolResults.add(synthId);
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
emittedToolResults.add(synthId);
|
|
276
|
+
emit({
|
|
277
|
+
type: 'tool_result',
|
|
278
|
+
toolName: normalizeGrokToolName(rawName),
|
|
279
|
+
toolUseId: synthId,
|
|
280
|
+
uuid: synthId,
|
|
281
|
+
toolOutput: line.outcome === 'success' ? '(ok)' : `(${line.outcome || 'done'})`,
|
|
282
|
+
});
|
|
283
|
+
// Tools change context fill — re-read signals after completions.
|
|
284
|
+
pollSignalsUsage();
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
// End of model turn is a good moment to refresh context stats.
|
|
288
|
+
if (line.type === 'turn_ended') {
|
|
289
|
+
pollSignalsUsage();
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
const handleChatLine = (raw) => {
|
|
293
|
+
const line = raw;
|
|
294
|
+
if (!line || typeof line !== 'object')
|
|
295
|
+
return;
|
|
296
|
+
if (line.type === 'assistant' && Array.isArray(line.tool_calls)) {
|
|
297
|
+
for (const call of line.tool_calls) {
|
|
298
|
+
const callId = call.id || `anon-${call.name}-${Date.now()}`;
|
|
299
|
+
if (emittedToolStarts.has(callId))
|
|
300
|
+
continue;
|
|
301
|
+
const rawName = call.name || 'unknown';
|
|
302
|
+
const toolName = normalizeGrokToolName(rawName);
|
|
303
|
+
const toolInput = parseToolInput(call.arguments);
|
|
304
|
+
// Link to a pending early card for this tool name (if any)
|
|
305
|
+
const earlyQueue = pendingEarlyByName.get(rawName);
|
|
306
|
+
if (earlyQueue && earlyQueue.length > 0) {
|
|
307
|
+
const earlyId = earlyQueue.shift();
|
|
308
|
+
if (earlyQueue.length === 0)
|
|
309
|
+
pendingEarlyByName.delete(rawName);
|
|
310
|
+
earlyToReal.set(earlyId, callId);
|
|
311
|
+
realToEarly.set(callId, earlyId);
|
|
312
|
+
}
|
|
313
|
+
emittedToolStarts.add(callId);
|
|
314
|
+
// Prefer upgrading the early card (same uuid) with full args so the UI
|
|
315
|
+
// does not keep an empty "Using tool: Read" plus a second full row.
|
|
316
|
+
// tool_result dual-emits to earlyId + callId for pairing.
|
|
317
|
+
const earlyId = realToEarly.get(callId);
|
|
318
|
+
if (earlyId) {
|
|
319
|
+
emit({
|
|
320
|
+
type: 'tool_start',
|
|
321
|
+
toolName,
|
|
322
|
+
toolInput,
|
|
323
|
+
toolUseId: earlyId,
|
|
324
|
+
uuid: earlyId,
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
else {
|
|
328
|
+
emit({
|
|
329
|
+
type: 'tool_start',
|
|
330
|
+
toolName,
|
|
331
|
+
toolInput,
|
|
332
|
+
toolUseId: callId,
|
|
333
|
+
uuid: callId,
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
if (line.type === 'tool_result' && line.tool_call_id) {
|
|
340
|
+
const callId = line.tool_call_id;
|
|
341
|
+
if (emittedToolResults.has(callId))
|
|
342
|
+
return;
|
|
343
|
+
emittedToolResults.add(callId);
|
|
344
|
+
const content = typeof line.content === 'string'
|
|
345
|
+
? line.content
|
|
346
|
+
: line.content != null
|
|
347
|
+
? JSON.stringify(line.content)
|
|
348
|
+
: '';
|
|
349
|
+
const out = truncateOutput(content);
|
|
350
|
+
emit({
|
|
351
|
+
type: 'tool_result',
|
|
352
|
+
toolUseId: callId,
|
|
353
|
+
uuid: callId,
|
|
354
|
+
toolOutput: out,
|
|
355
|
+
});
|
|
356
|
+
const earlyId = realToEarly.get(callId);
|
|
357
|
+
if (earlyId && !emittedToolResults.has(earlyId)) {
|
|
358
|
+
emittedToolResults.add(earlyId);
|
|
359
|
+
emit({
|
|
360
|
+
type: 'tool_result',
|
|
361
|
+
toolUseId: earlyId,
|
|
362
|
+
uuid: earlyId,
|
|
363
|
+
toolOutput: out,
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
};
|
|
368
|
+
const attachToSession = (id) => {
|
|
369
|
+
sessionId = id;
|
|
370
|
+
opts.onSessionId?.(id);
|
|
371
|
+
const sessionDir = getGrokSessionDir(opts.workingDir, id);
|
|
372
|
+
sessionDirPath = sessionDir;
|
|
373
|
+
const chatPath = path.join(sessionDir, 'chat_history.jsonl');
|
|
374
|
+
const eventsPath = path.join(sessionDir, 'events.jsonl');
|
|
375
|
+
log.log(`📎 Watching Grok session ${id.slice(0, 8)}… for agent ${opts.agentId.slice(0, 8)}`);
|
|
376
|
+
chatTailer = new JsonlTailer(chatPath, handleChatLine);
|
|
377
|
+
eventsTailer = new JsonlTailer(eventsPath, handleEventsLine);
|
|
378
|
+
chatTailer.initAtEof();
|
|
379
|
+
eventsTailer.initAtEof();
|
|
380
|
+
// Seed context bar from existing signals (resume / mid-session attach).
|
|
381
|
+
lastSignalsUsageKey = '';
|
|
382
|
+
pollSignalsUsage();
|
|
383
|
+
if (discoverTimer) {
|
|
384
|
+
clearInterval(discoverTimer);
|
|
385
|
+
discoverTimer = null;
|
|
386
|
+
}
|
|
387
|
+
if (!tailTimer) {
|
|
388
|
+
tailTimer = setInterval(() => {
|
|
389
|
+
if (stopped)
|
|
390
|
+
return;
|
|
391
|
+
eventsTailer?.poll();
|
|
392
|
+
chatTailer?.poll();
|
|
393
|
+
// signals.json is rewritten in place (not append-only) — poll each tick.
|
|
394
|
+
pollSignalsUsage();
|
|
395
|
+
}, TAIL_INTERVAL_MS);
|
|
396
|
+
}
|
|
397
|
+
};
|
|
398
|
+
const tryDiscover = () => {
|
|
399
|
+
const root = getGrokSessionsRoot(opts.workingDir);
|
|
400
|
+
if (!fs.existsSync(root))
|
|
401
|
+
return false;
|
|
402
|
+
let newest = null;
|
|
403
|
+
try {
|
|
404
|
+
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
|
|
405
|
+
if (!entry.isDirectory())
|
|
406
|
+
continue;
|
|
407
|
+
const dir = path.join(root, entry.name);
|
|
408
|
+
try {
|
|
409
|
+
const st = fs.statSync(dir);
|
|
410
|
+
if (st.mtimeMs < startedAt - DISCOVER_GRACE_MS)
|
|
411
|
+
continue;
|
|
412
|
+
if (!newest || st.mtimeMs > newest.mtime) {
|
|
413
|
+
newest = { id: entry.name, mtime: st.mtimeMs };
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
catch {
|
|
417
|
+
// skip
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
catch {
|
|
422
|
+
return false;
|
|
423
|
+
}
|
|
424
|
+
if (!newest)
|
|
425
|
+
return false;
|
|
426
|
+
const sessionDir = getGrokSessionDir(opts.workingDir, newest.id);
|
|
427
|
+
const hasChat = fs.existsSync(path.join(sessionDir, 'chat_history.jsonl'));
|
|
428
|
+
const hasEvents = fs.existsSync(path.join(sessionDir, 'events.jsonl'));
|
|
429
|
+
if (!hasChat && !hasEvents)
|
|
430
|
+
return false;
|
|
431
|
+
attachToSession(newest.id);
|
|
432
|
+
return true;
|
|
433
|
+
};
|
|
434
|
+
if (sessionId) {
|
|
435
|
+
attachToSession(sessionId);
|
|
436
|
+
}
|
|
437
|
+
else if (!tryDiscover()) {
|
|
438
|
+
discoverTimer = setInterval(() => {
|
|
439
|
+
if (stopped)
|
|
440
|
+
return;
|
|
441
|
+
tryDiscover();
|
|
442
|
+
}, DISCOVER_INTERVAL_MS);
|
|
443
|
+
}
|
|
444
|
+
return {
|
|
445
|
+
stop: () => {
|
|
446
|
+
if (stopped)
|
|
447
|
+
return;
|
|
448
|
+
stopped = true;
|
|
449
|
+
if (discoverTimer)
|
|
450
|
+
clearInterval(discoverTimer);
|
|
451
|
+
if (tailTimer)
|
|
452
|
+
clearInterval(tailTimer);
|
|
453
|
+
discoverTimer = null;
|
|
454
|
+
tailTimer = null;
|
|
455
|
+
chatTailer = null;
|
|
456
|
+
eventsTailer = null;
|
|
457
|
+
log.log(`🛑 Stopped Grok session watcher for agent ${opts.agentId.slice(0, 8)}`);
|
|
458
|
+
},
|
|
459
|
+
};
|
|
460
|
+
}
|
|
@@ -503,14 +503,17 @@ router.post('/bulk/change-model', async (req, res) => {
|
|
|
503
503
|
else if (provider === 'opencode') {
|
|
504
504
|
sanitized = agentService.sanitizeOpencodeModel(model);
|
|
505
505
|
}
|
|
506
|
+
else if (provider === 'grok') {
|
|
507
|
+
sanitized = agentService.sanitizeGrokModel(model);
|
|
508
|
+
}
|
|
506
509
|
if (!sanitized) {
|
|
507
510
|
res.status(400).json({ error: `Invalid model "${model}" for provider "${provider}"` });
|
|
508
511
|
return;
|
|
509
512
|
}
|
|
510
|
-
// Effort is Claude
|
|
513
|
+
// Effort is Claude/Grok. `null` means "clear back to default"; undefined means "leave unchanged".
|
|
511
514
|
const VALID_EFFORTS = new Set(['low', 'medium', 'high', 'xHigh', 'max']);
|
|
512
515
|
let effortUpdate = { set: false };
|
|
513
|
-
if (effort !== undefined && provider === 'claude') {
|
|
516
|
+
if (effort !== undefined && (provider === 'claude' || provider === 'grok')) {
|
|
514
517
|
if (effort === null) {
|
|
515
518
|
effortUpdate = { set: true, value: undefined };
|
|
516
519
|
}
|
|
@@ -547,6 +550,8 @@ router.post('/bulk/change-model', async (req, res) => {
|
|
|
547
550
|
modelUpdates.codexModel = sanitized;
|
|
548
551
|
else if (provider === 'opencode')
|
|
549
552
|
modelUpdates.opencodeModel = sanitized;
|
|
553
|
+
else if (provider === 'grok')
|
|
554
|
+
modelUpdates.grokModel = sanitized;
|
|
550
555
|
if (effortUpdate.set)
|
|
551
556
|
modelUpdates.effort = effortUpdate.value;
|
|
552
557
|
agentService.updateAgent(agentId, modelUpdates);
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { ClaudeRunner } from '../claude/runner.js';
|
|
2
|
+
import { GrokBackend } from '../grok/backend.js';
|
|
3
|
+
class GrokRuntimeProvider {
|
|
4
|
+
name = 'grok';
|
|
5
|
+
createRunner(callbacks) {
|
|
6
|
+
return new ClaudeRunner(callbacks, new GrokBackend());
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
export function createGrokRuntimeProvider() {
|
|
10
|
+
return new GrokRuntimeProvider();
|
|
11
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
export { createClaudeRuntimeProvider } from './claude-runtime-provider.js';
|
|
2
2
|
export { createCodexRuntimeProvider } from './codex-runtime-provider.js';
|
|
3
3
|
export { createOpencodeRuntimeProvider } from './opencode-runtime-provider.js';
|
|
4
|
+
export { createGrokRuntimeProvider } from './grok-runtime-provider.js';
|