mixdog 0.9.125 → 0.9.127
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/README.md +8 -0
- package/package.json +1 -1
- package/src/app.mjs +1 -0
- package/src/headless-command.mjs +9 -1
- package/src/headless-exec.mjs +531 -9
- package/src/headless-exec.test.mjs +194 -0
- package/src/help.mjs +2 -1
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +2 -3
- package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +10 -1
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +1 -0
- package/src/runtime/agent/orchestrator/session/tool-batch.mjs +30 -1
- package/src/runtime/agent/orchestrator/tools/builtin/native-search-client.mjs +18 -3
- package/src/runtime/agent/orchestrator/tools/graph-manifest.json +11 -11
- package/src/runtime/agent/orchestrator/tools/lib/native-spawn-client.mjs +5 -0
- package/src/session-runtime/prewarm.mjs +15 -3
- package/src/session-runtime/prewarm.test.mjs +53 -0
- package/src/session-runtime/session-turn-api.mjs +1 -0
package/README.md
CHANGED
|
@@ -163,8 +163,16 @@ host behavioral config and personal state out of the run:
|
|
|
163
163
|
```bash
|
|
164
164
|
mixdog exec --provider anthropic-oauth --model claude-opus-5 "fix the failing test"
|
|
165
165
|
mixdog exec --provider openai-oauth --model gpt-5.6-sol --effort xhigh --fast "review the current diff"
|
|
166
|
+
mixdog exec --provider openai-oauth --model gpt-5.6-sol --json "fix the failing test"
|
|
166
167
|
```
|
|
167
168
|
|
|
169
|
+
`--json` emits timestamped JSONL. The stream contains thread/turn lifecycle,
|
|
170
|
+
provider request timing, reasoning and assistant messages, tool start/completion
|
|
171
|
+
with arguments/output and queue/dispatch/execution/batch-wait/postprocess timing,
|
|
172
|
+
background notifications, final usage, and a
|
|
173
|
+
terminal success/error result. Without `--json`, stdout remains final text only.
|
|
174
|
+
In JSON mode stdout is JSONL-only; runtime diagnostics remain on stderr.
|
|
175
|
+
|
|
168
176
|
## TUI basics
|
|
169
177
|
|
|
170
178
|
Common slash commands:
|
package/package.json
CHANGED
package/src/app.mjs
CHANGED
package/src/headless-command.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
const VALUE_OPTIONS = new Set(['--provider', '--model', '--effort', '--workflow']);
|
|
2
2
|
const FLAG_OPTIONS = new Set([
|
|
3
3
|
'--readonly', '--help', '-h', '--plain', '--react', '--remote', '--onboarding', '--fast',
|
|
4
|
-
'--web-search', '--memory',
|
|
4
|
+
'--web-search', '--memory', '--json',
|
|
5
5
|
]);
|
|
6
6
|
const EXEC_UNSUPPORTED_FLAGS = new Set([
|
|
7
7
|
'--readonly', '--remote', '--onboarding', '--web-search', '--memory',
|
|
@@ -101,6 +101,7 @@ export function classifyCliInvocation(argv = []) {
|
|
|
101
101
|
fast: argv.includes('--fast'),
|
|
102
102
|
webSearch: argv.includes('--web-search'),
|
|
103
103
|
memory: argv.includes('--memory'),
|
|
104
|
+
json: argv.includes('--json'),
|
|
104
105
|
toolMode: argv.includes('--readonly') ? 'readonly' : 'full',
|
|
105
106
|
remote: argv.includes('--remote'),
|
|
106
107
|
forceOnboarding: argv.includes('--onboarding'),
|
|
@@ -130,6 +131,13 @@ export function classifyCliInvocation(argv = []) {
|
|
|
130
131
|
}
|
|
131
132
|
return { kind: 'exec', exec, options, skipHostPrelude: true };
|
|
132
133
|
}
|
|
134
|
+
if (argv.includes('--json')) {
|
|
135
|
+
return {
|
|
136
|
+
kind: 'error',
|
|
137
|
+
error: 'option --json is only supported for mixdog exec',
|
|
138
|
+
skipHostPrelude: true,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
133
141
|
return { kind: 'general', options };
|
|
134
142
|
}
|
|
135
143
|
|
package/src/headless-exec.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
1
2
|
import {
|
|
2
3
|
mkdirSync,
|
|
3
4
|
renameSync,
|
|
@@ -22,6 +23,472 @@ function sleep(ms) {
|
|
|
22
23
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
23
24
|
}
|
|
24
25
|
|
|
26
|
+
function nonNegativeNumber(value) {
|
|
27
|
+
const number = Number(value);
|
|
28
|
+
return Number.isFinite(number) ? Math.max(0, number) : 0;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function jsonValue(value) {
|
|
32
|
+
if (value === undefined) return null;
|
|
33
|
+
try {
|
|
34
|
+
return JSON.parse(JSON.stringify(value));
|
|
35
|
+
} catch {
|
|
36
|
+
return String(value);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function toolCallName(call) {
|
|
41
|
+
return clean(
|
|
42
|
+
call?.name
|
|
43
|
+
?? call?.toolName
|
|
44
|
+
?? call?.function?.name
|
|
45
|
+
?? call?.tool?.name,
|
|
46
|
+
) || 'tool';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function toolCallArguments(call) {
|
|
50
|
+
const raw = call?.arguments
|
|
51
|
+
?? call?.input
|
|
52
|
+
?? call?.function?.arguments
|
|
53
|
+
?? call?.tool?.arguments
|
|
54
|
+
?? call?.args
|
|
55
|
+
?? {};
|
|
56
|
+
if (typeof raw !== 'string') return jsonValue(raw);
|
|
57
|
+
const text = raw.trim();
|
|
58
|
+
if (!text) return {};
|
|
59
|
+
try {
|
|
60
|
+
return JSON.parse(text);
|
|
61
|
+
} catch {
|
|
62
|
+
return { input: raw };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function usageSummary(stats, toolCallCount = 0) {
|
|
67
|
+
return {
|
|
68
|
+
input_tokens: nonNegativeNumber(stats.inputTokens),
|
|
69
|
+
cached_input_tokens: nonNegativeNumber(stats.cachedTokens),
|
|
70
|
+
cache_write_input_tokens: nonNegativeNumber(stats.cacheWriteTokens),
|
|
71
|
+
output_tokens: nonNegativeNumber(stats.outputTokens),
|
|
72
|
+
tool_calls: nonNegativeNumber(toolCallCount),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function usageDeltaSummary(delta = {}) {
|
|
77
|
+
return {
|
|
78
|
+
input_tokens: nonNegativeNumber(delta.deltaInput),
|
|
79
|
+
cached_input_tokens: nonNegativeNumber(delta.deltaCachedRead),
|
|
80
|
+
cache_write_input_tokens: nonNegativeNumber(delta.deltaCacheWrite),
|
|
81
|
+
output_tokens: nonNegativeNumber(delta.deltaOutput),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function createJsonLifecycle({
|
|
86
|
+
write,
|
|
87
|
+
stats,
|
|
88
|
+
provider,
|
|
89
|
+
model,
|
|
90
|
+
effort,
|
|
91
|
+
fast,
|
|
92
|
+
cwd,
|
|
93
|
+
}) {
|
|
94
|
+
let threadId = `exec_${randomUUID().replace(/-/g, '')}`;
|
|
95
|
+
const turnId = 'turn_1';
|
|
96
|
+
let resolvedProvider = clean(provider);
|
|
97
|
+
let resolvedModel = clean(model);
|
|
98
|
+
let resolvedEffort = clean(effort) || null;
|
|
99
|
+
let resolvedFast = fast === true;
|
|
100
|
+
let resolvedCwd = clean(cwd);
|
|
101
|
+
let started = false;
|
|
102
|
+
let turnStartedAt = 0;
|
|
103
|
+
let itemSequence = 0;
|
|
104
|
+
let toolCallCount = 0;
|
|
105
|
+
let providerRequestCount = 0;
|
|
106
|
+
let providerDurationMs = 0;
|
|
107
|
+
let activeProviderRequest = null;
|
|
108
|
+
let activeToolBatch = null;
|
|
109
|
+
let reasoningText = '';
|
|
110
|
+
let lastAssistantText = '';
|
|
111
|
+
const pendingTools = new Map();
|
|
112
|
+
const completedTools = new Set();
|
|
113
|
+
|
|
114
|
+
const nowIso = (value = Date.now()) => new Date(value).toISOString();
|
|
115
|
+
const emit = (event, at = Date.now()) => {
|
|
116
|
+
write(`${JSON.stringify({
|
|
117
|
+
schema_version: 1,
|
|
118
|
+
timestamp: nowIso(at),
|
|
119
|
+
...event,
|
|
120
|
+
})}\n`);
|
|
121
|
+
};
|
|
122
|
+
const nextItemId = (prefix = 'item') => `${prefix}_${++itemSequence}`;
|
|
123
|
+
|
|
124
|
+
function start(runtime = null) {
|
|
125
|
+
if (started) return;
|
|
126
|
+
threadId = clean(runtime?.id) || threadId;
|
|
127
|
+
resolvedProvider = clean(runtime?.provider) || resolvedProvider;
|
|
128
|
+
resolvedModel = clean(runtime?.model) || resolvedModel;
|
|
129
|
+
resolvedEffort = clean(runtime?.effort) || resolvedEffort;
|
|
130
|
+
resolvedFast = runtime?.fast === true || resolvedFast;
|
|
131
|
+
resolvedCwd = clean(runtime?.cwd) || resolvedCwd;
|
|
132
|
+
turnStartedAt = Date.now();
|
|
133
|
+
started = true;
|
|
134
|
+
emit({
|
|
135
|
+
type: 'thread.started',
|
|
136
|
+
thread_id: threadId,
|
|
137
|
+
session: {
|
|
138
|
+
provider: resolvedProvider,
|
|
139
|
+
model: resolvedModel,
|
|
140
|
+
effort: resolvedEffort,
|
|
141
|
+
fast: resolvedFast,
|
|
142
|
+
cwd: resolvedCwd,
|
|
143
|
+
tool_mode: 'full',
|
|
144
|
+
approval_mode: 'implicit',
|
|
145
|
+
delegation: false,
|
|
146
|
+
},
|
|
147
|
+
}, turnStartedAt);
|
|
148
|
+
emit({
|
|
149
|
+
type: 'turn.started',
|
|
150
|
+
thread_id: threadId,
|
|
151
|
+
turn_id: turnId,
|
|
152
|
+
}, turnStartedAt);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function completeProviderRequest(status, delta = null, at = Date.now()) {
|
|
156
|
+
if (!activeProviderRequest) return;
|
|
157
|
+
const request = activeProviderRequest;
|
|
158
|
+
activeProviderRequest = null;
|
|
159
|
+
const durationMs = Math.max(0, at - request.startedAt);
|
|
160
|
+
providerDurationMs += durationMs;
|
|
161
|
+
emit({
|
|
162
|
+
type: `model.request.${status}`,
|
|
163
|
+
thread_id: threadId,
|
|
164
|
+
turn_id: turnId,
|
|
165
|
+
request_id: request.id,
|
|
166
|
+
request_index: request.index,
|
|
167
|
+
duration_ms: durationMs,
|
|
168
|
+
...(delta ? { usage: usageDeltaSummary(delta) } : {}),
|
|
169
|
+
}, at);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function flushReasoning(at = Date.now()) {
|
|
173
|
+
const text = reasoningText;
|
|
174
|
+
reasoningText = '';
|
|
175
|
+
if (!text.trim()) return;
|
|
176
|
+
emit({
|
|
177
|
+
type: 'item.completed',
|
|
178
|
+
thread_id: threadId,
|
|
179
|
+
turn_id: turnId,
|
|
180
|
+
item: {
|
|
181
|
+
id: nextItemId('reasoning'),
|
|
182
|
+
type: 'reasoning',
|
|
183
|
+
text,
|
|
184
|
+
status: 'completed',
|
|
185
|
+
},
|
|
186
|
+
}, at);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function emitAssistant(text, at = Date.now()) {
|
|
190
|
+
const value = String(text ?? '');
|
|
191
|
+
if (!value.trim()) return;
|
|
192
|
+
flushReasoning(at);
|
|
193
|
+
lastAssistantText = value;
|
|
194
|
+
emit({
|
|
195
|
+
type: 'item.completed',
|
|
196
|
+
thread_id: threadId,
|
|
197
|
+
turn_id: turnId,
|
|
198
|
+
item: {
|
|
199
|
+
id: nextItemId('message'),
|
|
200
|
+
type: 'agent_message',
|
|
201
|
+
text: value,
|
|
202
|
+
status: 'completed',
|
|
203
|
+
},
|
|
204
|
+
}, at);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function startTool(call, at = Date.now()) {
|
|
208
|
+
const callId = clean(call?.id) || nextItemId('tool');
|
|
209
|
+
if (pendingTools.has(callId) || completedTools.has(callId)) return;
|
|
210
|
+
const entry = {
|
|
211
|
+
id: callId,
|
|
212
|
+
name: toolCallName(call),
|
|
213
|
+
arguments: toolCallArguments(call),
|
|
214
|
+
startedAt: at,
|
|
215
|
+
startedAtIso: nowIso(at),
|
|
216
|
+
};
|
|
217
|
+
pendingTools.set(callId, entry);
|
|
218
|
+
toolCallCount += 1;
|
|
219
|
+
emit({
|
|
220
|
+
type: 'item.started',
|
|
221
|
+
thread_id: threadId,
|
|
222
|
+
turn_id: turnId,
|
|
223
|
+
item: {
|
|
224
|
+
id: entry.id,
|
|
225
|
+
type: 'tool_call',
|
|
226
|
+
name: entry.name,
|
|
227
|
+
arguments: entry.arguments,
|
|
228
|
+
status: 'in_progress',
|
|
229
|
+
started_at: entry.startedAtIso,
|
|
230
|
+
},
|
|
231
|
+
}, at);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function completeTool(message, at = Date.now()) {
|
|
235
|
+
const callId = clean(message?.toolCallId);
|
|
236
|
+
if (!callId || completedTools.has(callId)) return;
|
|
237
|
+
let entry = pendingTools.get(callId);
|
|
238
|
+
if (!entry) {
|
|
239
|
+
startTool({ id: callId, name: message?.toolName || 'tool', arguments: {} }, at);
|
|
240
|
+
entry = pendingTools.get(callId);
|
|
241
|
+
}
|
|
242
|
+
if (!entry) return;
|
|
243
|
+
if (message?.__earlyNotify === true) {
|
|
244
|
+
entry.earlyCompletedAt = at;
|
|
245
|
+
entry.earlyTiming = message?.toolTiming || null;
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
pendingTools.delete(callId);
|
|
249
|
+
completedTools.add(callId);
|
|
250
|
+
const failed = message?.isError === true || message?.toolKind === 'error';
|
|
251
|
+
const skipped = message?.toolKind === 'skipped';
|
|
252
|
+
const rawTiming = message?.toolTiming || entry.earlyTiming || {};
|
|
253
|
+
const dispatchStartedAt = nonNegativeNumber(rawTiming.dispatchStartedAt || entry.startedAt);
|
|
254
|
+
const executionStartedAt = nonNegativeNumber(
|
|
255
|
+
rawTiming.executionStartedAt || dispatchStartedAt,
|
|
256
|
+
);
|
|
257
|
+
const executionCompletedAt = nonNegativeNumber(
|
|
258
|
+
rawTiming.executionCompletedAt || entry.earlyCompletedAt || at,
|
|
259
|
+
);
|
|
260
|
+
const postprocessStartedAt = nonNegativeNumber(
|
|
261
|
+
rawTiming.postprocessStartedAt || executionCompletedAt,
|
|
262
|
+
);
|
|
263
|
+
const resultCompletedAt = nonNegativeNumber(rawTiming.resultCompletedAt || at);
|
|
264
|
+
const timing = {
|
|
265
|
+
queue_ms: Math.max(0, dispatchStartedAt - entry.startedAt),
|
|
266
|
+
dispatch_ms: Math.max(0, executionStartedAt - dispatchStartedAt),
|
|
267
|
+
execution_ms: Math.max(0, executionCompletedAt - executionStartedAt),
|
|
268
|
+
batch_wait_ms: Math.max(0, postprocessStartedAt - executionCompletedAt),
|
|
269
|
+
postprocess_ms: Math.max(0, resultCompletedAt - postprocessStartedAt),
|
|
270
|
+
total_ms: Math.max(0, resultCompletedAt - entry.startedAt),
|
|
271
|
+
};
|
|
272
|
+
emit({
|
|
273
|
+
type: 'item.completed',
|
|
274
|
+
thread_id: threadId,
|
|
275
|
+
turn_id: turnId,
|
|
276
|
+
item: {
|
|
277
|
+
id: entry.id,
|
|
278
|
+
type: 'tool_call',
|
|
279
|
+
name: entry.name,
|
|
280
|
+
arguments: entry.arguments,
|
|
281
|
+
output: jsonValue(message?.content),
|
|
282
|
+
status: failed ? 'failed' : (skipped ? 'skipped' : 'completed'),
|
|
283
|
+
started_at: entry.startedAtIso,
|
|
284
|
+
completed_at: nowIso(at),
|
|
285
|
+
duration_ms: timing.total_ms,
|
|
286
|
+
timing,
|
|
287
|
+
},
|
|
288
|
+
}, at);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function closePendingTools(status, output, at = Date.now()) {
|
|
292
|
+
for (const entry of pendingTools.values()) {
|
|
293
|
+
completedTools.add(entry.id);
|
|
294
|
+
emit({
|
|
295
|
+
type: 'item.completed',
|
|
296
|
+
thread_id: threadId,
|
|
297
|
+
turn_id: turnId,
|
|
298
|
+
item: {
|
|
299
|
+
id: entry.id,
|
|
300
|
+
type: 'tool_call',
|
|
301
|
+
name: entry.name,
|
|
302
|
+
arguments: entry.arguments,
|
|
303
|
+
output,
|
|
304
|
+
status,
|
|
305
|
+
started_at: entry.startedAtIso,
|
|
306
|
+
completed_at: nowIso(at),
|
|
307
|
+
duration_ms: Math.max(0, at - entry.startedAt),
|
|
308
|
+
},
|
|
309
|
+
}, at);
|
|
310
|
+
}
|
|
311
|
+
pendingTools.clear();
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return {
|
|
315
|
+
get threadId() {
|
|
316
|
+
return threadId;
|
|
317
|
+
},
|
|
318
|
+
get toolCallCount() {
|
|
319
|
+
return toolCallCount;
|
|
320
|
+
},
|
|
321
|
+
start,
|
|
322
|
+
onProviderSendStarted() {
|
|
323
|
+
start();
|
|
324
|
+
if (activeProviderRequest) completeProviderRequest('failed');
|
|
325
|
+
const startedAt = Date.now();
|
|
326
|
+
providerRequestCount += 1;
|
|
327
|
+
activeProviderRequest = {
|
|
328
|
+
id: `model_request_${providerRequestCount}`,
|
|
329
|
+
index: providerRequestCount,
|
|
330
|
+
startedAt,
|
|
331
|
+
};
|
|
332
|
+
emit({
|
|
333
|
+
type: 'model.request.started',
|
|
334
|
+
thread_id: threadId,
|
|
335
|
+
turn_id: turnId,
|
|
336
|
+
request_id: activeProviderRequest.id,
|
|
337
|
+
request_index: activeProviderRequest.index,
|
|
338
|
+
}, startedAt);
|
|
339
|
+
},
|
|
340
|
+
onUsageDelta(delta) {
|
|
341
|
+
completeProviderRequest('completed', delta);
|
|
342
|
+
},
|
|
343
|
+
onReasoningDelta(chunk) {
|
|
344
|
+
reasoningText += String(chunk ?? '');
|
|
345
|
+
},
|
|
346
|
+
onAssistantText(text) {
|
|
347
|
+
emitAssistant(text);
|
|
348
|
+
},
|
|
349
|
+
onAssistantToolCallObserved(call) {
|
|
350
|
+
start();
|
|
351
|
+
flushReasoning();
|
|
352
|
+
startTool(call);
|
|
353
|
+
},
|
|
354
|
+
onToolCall(_iteration, calls) {
|
|
355
|
+
start();
|
|
356
|
+
flushReasoning();
|
|
357
|
+
for (const call of calls || []) startTool(call);
|
|
358
|
+
},
|
|
359
|
+
onToolResult(message) {
|
|
360
|
+
completeTool(message);
|
|
361
|
+
},
|
|
362
|
+
onToolBatchStarted() {
|
|
363
|
+
const startedAt = Date.now();
|
|
364
|
+
activeToolBatch = {
|
|
365
|
+
id: `tool_batch_${providerRequestCount || 1}`,
|
|
366
|
+
startedAt,
|
|
367
|
+
};
|
|
368
|
+
emit({
|
|
369
|
+
type: 'tool.batch.started',
|
|
370
|
+
thread_id: threadId,
|
|
371
|
+
turn_id: turnId,
|
|
372
|
+
batch_id: activeToolBatch.id,
|
|
373
|
+
}, startedAt);
|
|
374
|
+
},
|
|
375
|
+
onToolBatchCompleted(detail = {}) {
|
|
376
|
+
const completedAt = Date.now();
|
|
377
|
+
const batch = activeToolBatch || {
|
|
378
|
+
id: `tool_batch_${providerRequestCount || 1}`,
|
|
379
|
+
startedAt: completedAt,
|
|
380
|
+
};
|
|
381
|
+
activeToolBatch = null;
|
|
382
|
+
emit({
|
|
383
|
+
type: 'tool.batch.completed',
|
|
384
|
+
thread_id: threadId,
|
|
385
|
+
turn_id: turnId,
|
|
386
|
+
batch_id: batch.id,
|
|
387
|
+
iteration: nonNegativeNumber(detail.iteration),
|
|
388
|
+
calls: nonNegativeNumber(detail.calls),
|
|
389
|
+
duration_ms: nonNegativeNumber(
|
|
390
|
+
detail.elapsedMs ?? (completedAt - batch.startedAt),
|
|
391
|
+
),
|
|
392
|
+
}, completedAt);
|
|
393
|
+
},
|
|
394
|
+
onStageChange(stage, detail = null) {
|
|
395
|
+
emit({
|
|
396
|
+
type: 'turn.status',
|
|
397
|
+
thread_id: threadId,
|
|
398
|
+
turn_id: turnId,
|
|
399
|
+
stage: clean(stage) || 'unknown',
|
|
400
|
+
...(detail == null ? {} : { detail: jsonValue(detail) }),
|
|
401
|
+
});
|
|
402
|
+
},
|
|
403
|
+
onNotification(event = {}) {
|
|
404
|
+
emit({
|
|
405
|
+
type: 'notification',
|
|
406
|
+
thread_id: threadId,
|
|
407
|
+
turn_id: turnId,
|
|
408
|
+
content: String(event?.content ?? ''),
|
|
409
|
+
meta: jsonValue(event?.meta ?? {}),
|
|
410
|
+
});
|
|
411
|
+
return false;
|
|
412
|
+
},
|
|
413
|
+
succeed(text, result = null) {
|
|
414
|
+
start();
|
|
415
|
+
const completedAt = Date.now();
|
|
416
|
+
flushReasoning(completedAt);
|
|
417
|
+
completeProviderRequest('completed', null, completedAt);
|
|
418
|
+
closePendingTools('incomplete', null, completedAt);
|
|
419
|
+
const finalText = String(text ?? '');
|
|
420
|
+
if (finalText.trim() && finalText !== lastAssistantText) {
|
|
421
|
+
emitAssistant(finalText, completedAt);
|
|
422
|
+
}
|
|
423
|
+
const durationMs = Math.max(0, completedAt - turnStartedAt);
|
|
424
|
+
const usage = usageSummary(stats, toolCallCount);
|
|
425
|
+
emit({
|
|
426
|
+
type: 'turn.completed',
|
|
427
|
+
thread_id: threadId,
|
|
428
|
+
turn_id: turnId,
|
|
429
|
+
duration_ms: durationMs,
|
|
430
|
+
duration_api_ms: providerDurationMs,
|
|
431
|
+
provider_requests: providerRequestCount,
|
|
432
|
+
tool_calls: toolCallCount,
|
|
433
|
+
usage,
|
|
434
|
+
}, completedAt);
|
|
435
|
+
emit({
|
|
436
|
+
type: 'result',
|
|
437
|
+
subtype: 'success',
|
|
438
|
+
thread_id: threadId,
|
|
439
|
+
turn_id: turnId,
|
|
440
|
+
session_id: threadId,
|
|
441
|
+
model: resolvedModel,
|
|
442
|
+
is_error: false,
|
|
443
|
+
duration_ms: durationMs,
|
|
444
|
+
duration_api_ms: providerDurationMs,
|
|
445
|
+
num_turns: 1,
|
|
446
|
+
provider_requests: providerRequestCount,
|
|
447
|
+
tool_calls: toolCallCount,
|
|
448
|
+
result: finalText,
|
|
449
|
+
stop_reason: result?.stopReason ?? result?.stop_reason ?? null,
|
|
450
|
+
usage,
|
|
451
|
+
}, completedAt);
|
|
452
|
+
},
|
|
453
|
+
fail(error) {
|
|
454
|
+
start();
|
|
455
|
+
const completedAt = Date.now();
|
|
456
|
+
const message = error?.message || String(error || 'execution failed');
|
|
457
|
+
flushReasoning(completedAt);
|
|
458
|
+
completeProviderRequest('failed', null, completedAt);
|
|
459
|
+
closePendingTools('failed', message, completedAt);
|
|
460
|
+
const durationMs = Math.max(0, completedAt - turnStartedAt);
|
|
461
|
+
const usage = usageSummary(stats, toolCallCount);
|
|
462
|
+
emit({
|
|
463
|
+
type: 'turn.failed',
|
|
464
|
+
thread_id: threadId,
|
|
465
|
+
turn_id: turnId,
|
|
466
|
+
duration_ms: durationMs,
|
|
467
|
+
duration_api_ms: providerDurationMs,
|
|
468
|
+
error: { message },
|
|
469
|
+
usage,
|
|
470
|
+
}, completedAt);
|
|
471
|
+
emit({
|
|
472
|
+
type: 'result',
|
|
473
|
+
subtype: 'error_during_execution',
|
|
474
|
+
thread_id: threadId,
|
|
475
|
+
turn_id: turnId,
|
|
476
|
+
session_id: threadId,
|
|
477
|
+
model: resolvedModel,
|
|
478
|
+
is_error: true,
|
|
479
|
+
duration_ms: durationMs,
|
|
480
|
+
duration_api_ms: providerDurationMs,
|
|
481
|
+
num_turns: 1,
|
|
482
|
+
provider_requests: providerRequestCount,
|
|
483
|
+
tool_calls: toolCallCount,
|
|
484
|
+
stop_reason: null,
|
|
485
|
+
errors: [message],
|
|
486
|
+
usage,
|
|
487
|
+
}, completedAt);
|
|
488
|
+
},
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
|
|
25
492
|
export async function waitForTrackedTasks({
|
|
26
493
|
sessionId,
|
|
27
494
|
clientHostPid = process.pid,
|
|
@@ -37,7 +504,7 @@ export async function waitForTrackedTasks({
|
|
|
37
504
|
}
|
|
38
505
|
}
|
|
39
506
|
|
|
40
|
-
function writeUsageDocument(path, stats, runtime) {
|
|
507
|
+
function writeUsageDocument(path, stats, runtime, toolCallCount = 0) {
|
|
41
508
|
const target = clean(path);
|
|
42
509
|
if (!target) return;
|
|
43
510
|
const session = {
|
|
@@ -48,7 +515,7 @@ function writeUsageDocument(path, stats, runtime) {
|
|
|
48
515
|
cacheTokens: stats.cachedTokens,
|
|
49
516
|
cacheWriteTokens: stats.cacheWriteTokens,
|
|
50
517
|
outputTokens: stats.outputTokens,
|
|
51
|
-
toolCallCountApprox:
|
|
518
|
+
toolCallCountApprox: nonNegativeNumber(toolCallCount),
|
|
52
519
|
};
|
|
53
520
|
const document = {
|
|
54
521
|
schemaVersion: 1,
|
|
@@ -58,7 +525,7 @@ function writeUsageDocument(path, stats, runtime) {
|
|
|
58
525
|
cacheTokens: stats.cachedTokens,
|
|
59
526
|
cacheWriteTokens: stats.cacheWriteTokens,
|
|
60
527
|
outputTokens: stats.outputTokens,
|
|
61
|
-
toolCallCountApprox:
|
|
528
|
+
toolCallCountApprox: nonNegativeNumber(toolCallCount),
|
|
62
529
|
},
|
|
63
530
|
};
|
|
64
531
|
const temp = `${target}.tmp-${process.pid}`;
|
|
@@ -73,6 +540,7 @@ export async function runHeadlessExec({
|
|
|
73
540
|
model,
|
|
74
541
|
effort,
|
|
75
542
|
fast,
|
|
543
|
+
json = false,
|
|
76
544
|
cwd = process.cwd(),
|
|
77
545
|
write = (text) => stdout.write(text),
|
|
78
546
|
writeErr = (text) => stderr.write(text),
|
|
@@ -95,10 +563,23 @@ export async function runHeadlessExec({
|
|
|
95
563
|
}
|
|
96
564
|
|
|
97
565
|
const stats = createSessionStats();
|
|
566
|
+
const lifecycle = json ? createJsonLifecycle({
|
|
567
|
+
write,
|
|
568
|
+
stats,
|
|
569
|
+
provider,
|
|
570
|
+
model,
|
|
571
|
+
effort,
|
|
572
|
+
fast,
|
|
573
|
+
cwd,
|
|
574
|
+
}) : null;
|
|
98
575
|
let boundary = null;
|
|
99
576
|
let runtime = null;
|
|
100
577
|
let signalCleanup = null;
|
|
578
|
+
let unsubscribeNotification = null;
|
|
101
579
|
let cleanupPromise = null;
|
|
580
|
+
let result = null;
|
|
581
|
+
let resultText = '';
|
|
582
|
+
let executionError = null;
|
|
102
583
|
let code = 1;
|
|
103
584
|
const cleanup = (reason = 'exec-exit') => {
|
|
104
585
|
cleanupPromise ??= (async () => {
|
|
@@ -130,35 +611,76 @@ export async function runHeadlessExec({
|
|
|
130
611
|
disallowDelegation: true,
|
|
131
612
|
initialConfig: boundary.loadConfig(),
|
|
132
613
|
});
|
|
133
|
-
|
|
614
|
+
if (lifecycle && !clean(runtime?.id) && typeof runtime?.reserveSessionId === 'function') {
|
|
615
|
+
runtime.reserveSessionId(lifecycle.threadId);
|
|
616
|
+
}
|
|
617
|
+
lifecycle?.start(runtime);
|
|
618
|
+
if (lifecycle && typeof runtime?.onNotification === 'function') {
|
|
619
|
+
unsubscribeNotification = runtime.onNotification(
|
|
620
|
+
(event) => lifecycle.onNotification(event),
|
|
621
|
+
);
|
|
622
|
+
}
|
|
623
|
+
({ result } = await runtime.ask(prompt, {
|
|
134
624
|
onTextReset: () => true,
|
|
135
|
-
onUsageDelta: (delta) =>
|
|
136
|
-
|
|
625
|
+
onUsageDelta: (delta) => {
|
|
626
|
+
applyUsageDelta(stats, delta);
|
|
627
|
+
lifecycle?.onUsageDelta(delta);
|
|
628
|
+
},
|
|
629
|
+
...(lifecycle ? {
|
|
630
|
+
onProviderSendStarted: () => lifecycle.onProviderSendStarted(),
|
|
631
|
+
onReasoningDelta: (chunk) => lifecycle.onReasoningDelta(chunk),
|
|
632
|
+
onAssistantText: (text) => lifecycle.onAssistantText(text),
|
|
633
|
+
onAssistantToolCallObserved: (call) => lifecycle.onAssistantToolCallObserved(call),
|
|
634
|
+
onToolCall: (iteration, calls) => lifecycle.onToolCall(iteration, calls),
|
|
635
|
+
onToolResult: (message) => lifecycle.onToolResult(message),
|
|
636
|
+
onToolPhaseStarted: () => lifecycle.onToolBatchStarted(),
|
|
637
|
+
onToolPhaseCompleted: (detail) => lifecycle.onToolBatchCompleted(detail),
|
|
638
|
+
onStageChange: (stage, detail) => lifecycle.onStageChange(stage, detail),
|
|
639
|
+
} : {}),
|
|
640
|
+
}));
|
|
137
641
|
await waitForTrackedTasks({
|
|
138
642
|
sessionId: runtime.id,
|
|
139
643
|
clientHostPid: runtime.clientHostPid,
|
|
140
644
|
hasActiveTasks,
|
|
141
645
|
pollMs: idlePollMs,
|
|
142
646
|
});
|
|
143
|
-
|
|
144
|
-
if (
|
|
647
|
+
resultText = String(result?.content ?? result?.text ?? '');
|
|
648
|
+
if (!json && resultText) {
|
|
649
|
+
write(resultText.endsWith('\n') ? resultText : `${resultText}\n`);
|
|
650
|
+
}
|
|
145
651
|
code = 0;
|
|
146
652
|
} catch (error) {
|
|
653
|
+
executionError = error;
|
|
147
654
|
writeErr(`mixdog: ${error?.message || error}\n`);
|
|
148
655
|
} finally {
|
|
149
656
|
try {
|
|
150
|
-
|
|
657
|
+
unsubscribeNotification?.();
|
|
658
|
+
} catch {
|
|
659
|
+
// Listener cleanup is best-effort.
|
|
660
|
+
}
|
|
661
|
+
try {
|
|
662
|
+
writeUsageDocument(
|
|
663
|
+
usageLogPath,
|
|
664
|
+
stats,
|
|
665
|
+
runtime,
|
|
666
|
+
lifecycle?.toolCallCount || 0,
|
|
667
|
+
);
|
|
151
668
|
} catch (error) {
|
|
152
669
|
writeErr(`mixdog: usage log write failed: ${error?.message || error}\n`);
|
|
153
670
|
}
|
|
154
671
|
try {
|
|
155
672
|
await cleanup('exec-exit');
|
|
156
673
|
} catch (error) {
|
|
674
|
+
executionError ??= error;
|
|
157
675
|
writeErr(`mixdog: shutdown failed: ${error?.message || error}\n`);
|
|
158
676
|
code = 1;
|
|
159
677
|
} finally {
|
|
160
678
|
signalCleanup?.uninstall();
|
|
161
679
|
}
|
|
162
680
|
}
|
|
681
|
+
if (lifecycle) {
|
|
682
|
+
if (code === 0) lifecycle.succeed(resultText, result);
|
|
683
|
+
else lifecycle.fail(executionError || new Error('execution failed'));
|
|
684
|
+
}
|
|
163
685
|
return code;
|
|
164
686
|
}
|
|
@@ -4,6 +4,7 @@ import { tmpdir } from 'node:os';
|
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import test from 'node:test';
|
|
6
6
|
|
|
7
|
+
import { classifyCliInvocation } from './headless-command.mjs';
|
|
7
8
|
import { runHeadlessExec } from './headless-exec.mjs';
|
|
8
9
|
|
|
9
10
|
test('headless exec runs one implicit-approval session and waits for tracked tasks', async () => {
|
|
@@ -81,3 +82,196 @@ test('headless exec runs one implicit-approval session and waits for tracked tas
|
|
|
81
82
|
rmSync(root, { recursive: true, force: true });
|
|
82
83
|
}
|
|
83
84
|
});
|
|
85
|
+
|
|
86
|
+
test('headless exec emits a timestamped JSONL lifecycle and exact tool count', async () => {
|
|
87
|
+
const root = mkdtempSync(join(tmpdir(), 'mixdog-headless-json-test-'));
|
|
88
|
+
const usageLogPath = join(root, 'usage.json');
|
|
89
|
+
const output = [];
|
|
90
|
+
const errors = [];
|
|
91
|
+
let notificationListener = null;
|
|
92
|
+
try {
|
|
93
|
+
const code = await runHeadlessExec({
|
|
94
|
+
message: 'fix it',
|
|
95
|
+
provider: 'openai-oauth',
|
|
96
|
+
model: 'gpt-test',
|
|
97
|
+
effort: 'high',
|
|
98
|
+
fast: true,
|
|
99
|
+
json: true,
|
|
100
|
+
usageLogPath,
|
|
101
|
+
write: (text) => output.push(text),
|
|
102
|
+
writeErr: (text) => errors.push(text),
|
|
103
|
+
boundaryFactory: () => ({
|
|
104
|
+
loadConfig: () => ({ providers: { 'openai-oauth': { enabled: true } } }),
|
|
105
|
+
cleanup() {},
|
|
106
|
+
}),
|
|
107
|
+
runtimeFactory: async () => ({
|
|
108
|
+
id: 'sess_json_test',
|
|
109
|
+
provider: 'openai-oauth',
|
|
110
|
+
model: 'gpt-test',
|
|
111
|
+
effort: 'high',
|
|
112
|
+
fast: true,
|
|
113
|
+
cwd: '/app',
|
|
114
|
+
clientHostPid: 123,
|
|
115
|
+
onNotification(listener) {
|
|
116
|
+
notificationListener = listener;
|
|
117
|
+
return () => { notificationListener = null; };
|
|
118
|
+
},
|
|
119
|
+
async ask(_prompt, options) {
|
|
120
|
+
options.onProviderSendStarted();
|
|
121
|
+
options.onReasoningDelta('inspect first');
|
|
122
|
+
options.onUsageDelta({
|
|
123
|
+
deltaInput: 11,
|
|
124
|
+
deltaCachedRead: 7,
|
|
125
|
+
deltaCacheWrite: 3,
|
|
126
|
+
deltaOutput: 5,
|
|
127
|
+
});
|
|
128
|
+
const call = {
|
|
129
|
+
id: 'call_1',
|
|
130
|
+
name: 'shell',
|
|
131
|
+
arguments: { command: 'echo ok' },
|
|
132
|
+
};
|
|
133
|
+
options.onAssistantToolCallObserved(call);
|
|
134
|
+
await options.onToolCall(1, [call]);
|
|
135
|
+
options.onToolPhaseStarted();
|
|
136
|
+
const toolClock = Date.now();
|
|
137
|
+
options.onToolResult({
|
|
138
|
+
role: 'tool',
|
|
139
|
+
toolCallId: 'call_1',
|
|
140
|
+
content: 'ok\n',
|
|
141
|
+
__earlyNotify: true,
|
|
142
|
+
toolTiming: {
|
|
143
|
+
dispatchStartedAt: toolClock,
|
|
144
|
+
executionStartedAt: toolClock + 2,
|
|
145
|
+
executionCompletedAt: toolClock + 5,
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
options.onToolResult({
|
|
149
|
+
role: 'tool',
|
|
150
|
+
toolCallId: 'call_1',
|
|
151
|
+
content: 'ok\n',
|
|
152
|
+
toolKind: 'normal',
|
|
153
|
+
toolTiming: {
|
|
154
|
+
dispatchStartedAt: toolClock,
|
|
155
|
+
executionStartedAt: toolClock + 2,
|
|
156
|
+
executionCompletedAt: toolClock + 5,
|
|
157
|
+
postprocessStartedAt: toolClock + 6,
|
|
158
|
+
resultCompletedAt: toolClock + 8,
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
options.onToolPhaseCompleted({ iteration: 1, calls: 1, elapsedMs: 2 });
|
|
162
|
+
notificationListener?.({
|
|
163
|
+
content: 'background task completed',
|
|
164
|
+
meta: { status: 'completed' },
|
|
165
|
+
});
|
|
166
|
+
options.onProviderSendStarted();
|
|
167
|
+
options.onAssistantText('done');
|
|
168
|
+
options.onUsageDelta({
|
|
169
|
+
deltaInput: 5,
|
|
170
|
+
deltaCachedRead: 0,
|
|
171
|
+
deltaCacheWrite: 0,
|
|
172
|
+
deltaOutput: 2,
|
|
173
|
+
});
|
|
174
|
+
return { result: { content: 'done', stopReason: 'end_turn' } };
|
|
175
|
+
},
|
|
176
|
+
async close() {},
|
|
177
|
+
}),
|
|
178
|
+
hasActiveTasks: () => false,
|
|
179
|
+
installSignalCleanupFn: () => ({ uninstall() {} }),
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
assert.equal(code, 0);
|
|
183
|
+
assert.deepEqual(errors, []);
|
|
184
|
+
const events = output.join('').trim().split('\n').map((line) => JSON.parse(line));
|
|
185
|
+
assert.equal(events[0].type, 'thread.started');
|
|
186
|
+
assert.equal(events[1].type, 'turn.started');
|
|
187
|
+
assert.ok(events.every((event) => event.schema_version === 1 && event.timestamp));
|
|
188
|
+
const toolStarted = events.find(
|
|
189
|
+
(event) => event.type === 'item.started' && event.item?.type === 'tool_call',
|
|
190
|
+
);
|
|
191
|
+
const toolCompleted = events.find(
|
|
192
|
+
(event) => event.type === 'item.completed' && event.item?.id === 'call_1',
|
|
193
|
+
);
|
|
194
|
+
assert.equal(toolStarted.item.name, 'shell');
|
|
195
|
+
assert.equal(toolCompleted.item.output, 'ok\n');
|
|
196
|
+
assert.equal(toolCompleted.item.status, 'completed');
|
|
197
|
+
assert.ok(toolCompleted.item.duration_ms >= 0);
|
|
198
|
+
assert.equal(toolCompleted.item.timing.dispatch_ms, 2);
|
|
199
|
+
assert.equal(toolCompleted.item.timing.execution_ms, 3);
|
|
200
|
+
assert.equal(toolCompleted.item.timing.batch_wait_ms, 1);
|
|
201
|
+
assert.equal(toolCompleted.item.timing.postprocess_ms, 2);
|
|
202
|
+
assert.equal(events.filter(
|
|
203
|
+
(event) => event.type === 'item.completed' && event.item?.id === 'call_1',
|
|
204
|
+
).length, 1);
|
|
205
|
+
assert.ok(events.some((event) => event.type === 'notification'));
|
|
206
|
+
assert.equal(events.filter((event) => event.type === 'model.request.completed').length, 2);
|
|
207
|
+
const terminal = events.at(-1);
|
|
208
|
+
assert.equal(terminal.type, 'result');
|
|
209
|
+
assert.equal(terminal.subtype, 'success');
|
|
210
|
+
assert.equal(terminal.session_id, 'sess_json_test');
|
|
211
|
+
assert.equal(terminal.provider_requests, 2);
|
|
212
|
+
assert.equal(terminal.tool_calls, 1);
|
|
213
|
+
assert.deepEqual(terminal.usage, {
|
|
214
|
+
input_tokens: 16,
|
|
215
|
+
cached_input_tokens: 7,
|
|
216
|
+
cache_write_input_tokens: 3,
|
|
217
|
+
output_tokens: 7,
|
|
218
|
+
tool_calls: 1,
|
|
219
|
+
});
|
|
220
|
+
const usage = JSON.parse(readFileSync(usageLogPath, 'utf8'));
|
|
221
|
+
assert.equal(usage.totals.toolCallCountApprox, 1);
|
|
222
|
+
} finally {
|
|
223
|
+
rmSync(root, { recursive: true, force: true });
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test('headless exec emits structured JSONL failure before returning exit 1', async () => {
|
|
228
|
+
const output = [];
|
|
229
|
+
const errors = [];
|
|
230
|
+
const code = await runHeadlessExec({
|
|
231
|
+
message: 'fail',
|
|
232
|
+
provider: 'openai-oauth',
|
|
233
|
+
model: 'gpt-test',
|
|
234
|
+
json: true,
|
|
235
|
+
usageLogPath: '',
|
|
236
|
+
write: (text) => output.push(text),
|
|
237
|
+
writeErr: (text) => errors.push(text),
|
|
238
|
+
boundaryFactory: () => ({
|
|
239
|
+
loadConfig: () => ({ providers: { 'openai-oauth': { enabled: true } } }),
|
|
240
|
+
cleanup() {},
|
|
241
|
+
}),
|
|
242
|
+
runtimeFactory: async () => ({
|
|
243
|
+
id: 'sess_json_failure',
|
|
244
|
+
model: 'gpt-test',
|
|
245
|
+
clientHostPid: 123,
|
|
246
|
+
async ask() {
|
|
247
|
+
throw new Error('boom');
|
|
248
|
+
},
|
|
249
|
+
async close() {},
|
|
250
|
+
}),
|
|
251
|
+
installSignalCleanupFn: () => ({ uninstall() {} }),
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
const events = output.join('').trim().split('\n').map((line) => JSON.parse(line));
|
|
255
|
+
assert.equal(code, 1);
|
|
256
|
+
assert.ok(events.some((event) => event.type === 'turn.failed'));
|
|
257
|
+
assert.equal(events.at(-1).type, 'result');
|
|
258
|
+
assert.equal(events.at(-1).subtype, 'error_during_execution');
|
|
259
|
+
assert.deepEqual(events.at(-1).errors, ['boom']);
|
|
260
|
+
assert.deepEqual(errors, ['mixdog: boom\n']);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test('--json is accepted for exec and rejected for the interactive command', () => {
|
|
264
|
+
const exec = classifyCliInvocation([
|
|
265
|
+
'exec',
|
|
266
|
+
'--provider', 'openai-oauth',
|
|
267
|
+
'--model', 'gpt-test',
|
|
268
|
+
'--json',
|
|
269
|
+
'fix it',
|
|
270
|
+
]);
|
|
271
|
+
assert.equal(exec.kind, 'exec');
|
|
272
|
+
assert.equal(exec.options.json, true);
|
|
273
|
+
|
|
274
|
+
const interactive = classifyCliInvocation(['--json']);
|
|
275
|
+
assert.equal(interactive.kind, 'error');
|
|
276
|
+
assert.equal(interactive.error, 'option --json is only supported for mixdog exec');
|
|
277
|
+
});
|
package/src/help.mjs
CHANGED
|
@@ -6,7 +6,7 @@ export const HELP_LINES = [
|
|
|
6
6
|
'',
|
|
7
7
|
'Usage:',
|
|
8
8
|
' mixdog [options] start the TUI in the current project',
|
|
9
|
-
' mixdog exec --provider <name> --model <name> [--effort <level>] [--fast] <message...>',
|
|
9
|
+
' mixdog exec --provider <name> --model <name> [--effort <level>] [--fast] [--json] <message...>',
|
|
10
10
|
' mixdog --help',
|
|
11
11
|
'',
|
|
12
12
|
'Options:',
|
|
@@ -14,6 +14,7 @@ export const HELP_LINES = [
|
|
|
14
14
|
' --model <name> model route for this session',
|
|
15
15
|
' --effort <level> reasoning effort for the selected model',
|
|
16
16
|
' --fast enable Fast mode for the selected model',
|
|
17
|
+
' --json emit headless exec events as JSONL',
|
|
17
18
|
' --workflow <name> start with the given workflow active',
|
|
18
19
|
' --readonly read-only tool surface',
|
|
19
20
|
' --remote enable remote/channel mode for this session',
|
|
@@ -636,13 +636,12 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
636
636
|
opts.onToolCall = _capFinalToolsDisabled
|
|
637
637
|
? undefined
|
|
638
638
|
: (call) => {
|
|
639
|
-
const result = eager.onToolCall(call);
|
|
640
639
|
try {
|
|
641
640
|
opts.onAssistantToolCallObserved?.(call, {
|
|
642
|
-
eagerStarted:
|
|
641
|
+
eagerStarted: false,
|
|
643
642
|
});
|
|
644
643
|
} catch {}
|
|
645
|
-
return
|
|
644
|
+
return eager.onToolCall(call);
|
|
646
645
|
};
|
|
647
646
|
const sendStartedAt = Date.now();
|
|
648
647
|
const preSendMs = sendStartedAt - _iterT0;
|
|
@@ -112,8 +112,11 @@ export function createEagerDispatcher({
|
|
|
112
112
|
// serial path below. If any role/permission guard would reject
|
|
113
113
|
// this call there, never start it eagerly here.
|
|
114
114
|
if (preDispatchDenyForSession(sessionRef, call, toolKind) !== null) return null;
|
|
115
|
+
const dispatchedAt = Date.now();
|
|
115
116
|
const entry = {
|
|
116
|
-
startedAt:
|
|
117
|
+
startedAt: dispatchedAt,
|
|
118
|
+
dispatchStartedAt: dispatchedAt,
|
|
119
|
+
executionStartedAt: null,
|
|
117
120
|
endedAt: null,
|
|
118
121
|
mutationEpoch: epoch.mutation,
|
|
119
122
|
localSearchTelemetry: {},
|
|
@@ -134,6 +137,7 @@ export function createEagerDispatcher({
|
|
|
134
137
|
}
|
|
135
138
|
}
|
|
136
139
|
await opts.beforeToolExecution?.();
|
|
140
|
+
entry.executionStartedAt = Date.now();
|
|
137
141
|
return { ok: true, value: await executeToolFn(call.name, call.arguments, cwd, sessionId, sessionRef, { toolCallId: call.id, signal, notifyFn: opts.notifyFn, toolApprovalHook: opts.onToolApproval, iteration: getNextIteration(), localSearchTelemetry: entry.localSearchTelemetry, resultTelemetry: entry.resultTelemetry }) };
|
|
138
142
|
} catch (error) {
|
|
139
143
|
return { ok: false, error };
|
|
@@ -182,6 +186,11 @@ export function createEagerDispatcher({
|
|
|
182
186
|
content: _earlyContent,
|
|
183
187
|
isError: !(settled && settled.ok),
|
|
184
188
|
__earlyNotify: true,
|
|
189
|
+
toolTiming: {
|
|
190
|
+
dispatchStartedAt: entry.dispatchStartedAt,
|
|
191
|
+
executionStartedAt: entry.executionStartedAt ?? entry.endedAt,
|
|
192
|
+
executionCompletedAt: entry.endedAt,
|
|
193
|
+
},
|
|
185
194
|
});
|
|
186
195
|
} catch { /* best-effort — UI notify must never break the eager path */ }
|
|
187
196
|
// Intentionally do NOT delete _sig here — see the block
|
|
@@ -640,6 +640,7 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
640
640
|
onAssistantToolCallObserved: (call, detail) => {
|
|
641
641
|
_turnInterruption.recordToolCalls([call], detail);
|
|
642
642
|
_scheduleTurnCheckpoint(true);
|
|
643
|
+
try { askOpts?.onAssistantToolCallObserved?.(call, detail); } catch {}
|
|
643
644
|
},
|
|
644
645
|
onProviderSendStarted: () => {
|
|
645
646
|
_turnInterruption.markProviderSendStarted();
|
|
@@ -196,6 +196,8 @@ export async function processToolBatch(ctx) {
|
|
|
196
196
|
}
|
|
197
197
|
}
|
|
198
198
|
if (sessionId) markSessionToolCall(sessionId, call.name, resolveToolSelfDeadlineMs(call.name, call.arguments));
|
|
199
|
+
let dispatchStartedAt = Date.now();
|
|
200
|
+
let executionStartedAt;
|
|
199
201
|
let result;
|
|
200
202
|
let toolStartedAt;
|
|
201
203
|
let toolEndedAt;
|
|
@@ -234,12 +236,14 @@ export async function processToolBatch(ctx) {
|
|
|
234
236
|
try {
|
|
235
237
|
if (_invalidArgs) {
|
|
236
238
|
toolStartedAt = Date.now();
|
|
239
|
+
executionStartedAt = toolStartedAt;
|
|
237
240
|
toolEndedAt = toolStartedAt;
|
|
238
241
|
result = formatInvalidToolArgsResult(call);
|
|
239
242
|
_resultKind = 'error';
|
|
240
243
|
_executeOk = false;
|
|
241
244
|
} else if (_readCacheHit !== null) {
|
|
242
245
|
toolStartedAt = Date.now();
|
|
246
|
+
executionStartedAt = toolStartedAt;
|
|
243
247
|
toolEndedAt = toolStartedAt;
|
|
244
248
|
const _body = _readCacheHit.content;
|
|
245
249
|
// Return the cached body byte-for-byte instead of a
|
|
@@ -251,6 +255,7 @@ export async function processToolBatch(ctx) {
|
|
|
251
255
|
_executeOk = true;
|
|
252
256
|
} else if (_scopedCacheHit !== null) {
|
|
253
257
|
toolStartedAt = Date.now();
|
|
258
|
+
executionStartedAt = toolStartedAt;
|
|
254
259
|
toolEndedAt = toolStartedAt;
|
|
255
260
|
const _body = _scopedCacheHit.content;
|
|
256
261
|
result = _body;
|
|
@@ -276,6 +281,8 @@ export async function processToolBatch(ctx) {
|
|
|
276
281
|
}
|
|
277
282
|
if (eager !== undefined) {
|
|
278
283
|
toolStartedAt = eager.startedAt;
|
|
284
|
+
dispatchStartedAt = eager.dispatchStartedAt ?? eager.startedAt;
|
|
285
|
+
executionStartedAt = eager.executionStartedAt ?? eager.endedAt;
|
|
279
286
|
_localSearchTelemetry = eager.localSearchTelemetry || null;
|
|
280
287
|
_resultTelemetry = eager.resultTelemetry || {};
|
|
281
288
|
const settled = await eager.promise;
|
|
@@ -306,11 +313,13 @@ export async function processToolBatch(ctx) {
|
|
|
306
313
|
// both paths.
|
|
307
314
|
const _denyMsg = preDispatchDenyForSession(sessionRef, call, toolKind);
|
|
308
315
|
if (_denyMsg !== null) {
|
|
316
|
+
executionStartedAt = toolStartedAt;
|
|
309
317
|
result = _denyMsg;
|
|
310
318
|
toolEndedAt = Date.now();
|
|
311
319
|
_resultKind = 'error';
|
|
312
320
|
} else {
|
|
313
321
|
await opts.beforeToolExecution?.();
|
|
322
|
+
executionStartedAt = Date.now();
|
|
314
323
|
_localSearchTelemetry = {};
|
|
315
324
|
result = await executeToolFn(call.name, call.arguments, cwd, sessionId, sessionRef, { toolCallId: call.id, signal, notifyFn: opts.notifyFn, toolApprovalHook: opts.onToolApproval, iteration: iterations, localSearchTelemetry: _localSearchTelemetry, resultTelemetry: _resultTelemetry });
|
|
316
325
|
toolEndedAt = Date.now();
|
|
@@ -330,6 +339,7 @@ export async function processToolBatch(ctx) {
|
|
|
330
339
|
}
|
|
331
340
|
catch (err) {
|
|
332
341
|
if (toolStartedAt === undefined) toolStartedAt = Date.now();
|
|
342
|
+
if (executionStartedAt === undefined) executionStartedAt = toolStartedAt;
|
|
333
343
|
toolEndedAt = Date.now();
|
|
334
344
|
result = `Error: ${err instanceof Error ? err.message : String(err)}`;
|
|
335
345
|
_resultKind = 'error';
|
|
@@ -484,6 +494,8 @@ export async function processToolBatch(ctx) {
|
|
|
484
494
|
_batchCompleted.push({
|
|
485
495
|
call,
|
|
486
496
|
result,
|
|
497
|
+
dispatchStartedAt,
|
|
498
|
+
executionStartedAt,
|
|
487
499
|
toolStartedAt,
|
|
488
500
|
toolEndedAt,
|
|
489
501
|
toolKind,
|
|
@@ -528,10 +540,11 @@ export async function processToolBatch(ctx) {
|
|
|
528
540
|
for (let completedIndex = 0; completedIndex < _batchCompleted.length; completedIndex += 1) {
|
|
529
541
|
const completed = _batchCompleted[completedIndex];
|
|
530
542
|
const {
|
|
531
|
-
call, toolStartedAt, toolEndedAt, toolKind,
|
|
543
|
+
call, dispatchStartedAt, executionStartedAt, toolStartedAt, toolEndedAt, toolKind,
|
|
532
544
|
executeOk: _executeOk, resultKind: _resultKind,
|
|
533
545
|
readCacheHit: _readCacheHit, scopedCacheHit: _scopedCacheHit,
|
|
534
546
|
} = completed;
|
|
547
|
+
const postprocessStartedAt = Date.now();
|
|
535
548
|
let _ctSig = completed.crossTurnSig;
|
|
536
549
|
let result = completed.result;
|
|
537
550
|
const _nativeToolSearch = completed.nativeToolSearch;
|
|
@@ -604,11 +617,19 @@ export async function processToolBatch(ctx) {
|
|
|
604
617
|
const _applyPatchUiDiff = _stripMcpPrefix(call.name) === 'apply_patch'
|
|
605
618
|
? takeApplyPatchUiDiff(call.id)
|
|
606
619
|
: null;
|
|
620
|
+
const resultCompletedAt = Date.now();
|
|
607
621
|
_stageToolResultMessage({
|
|
608
622
|
role: 'tool',
|
|
609
623
|
content: result,
|
|
610
624
|
toolCallId: call.id,
|
|
611
625
|
toolKind: _resultKind,
|
|
626
|
+
toolTiming: {
|
|
627
|
+
dispatchStartedAt,
|
|
628
|
+
executionStartedAt: executionStartedAt ?? toolStartedAt,
|
|
629
|
+
executionCompletedAt: toolEndedAt,
|
|
630
|
+
postprocessStartedAt,
|
|
631
|
+
resultCompletedAt,
|
|
632
|
+
},
|
|
612
633
|
...(_nativeToolSearch ? { nativeToolSearch: _nativeToolSearch } : {}),
|
|
613
634
|
...(_applyPatchUiDiff !== null ? { uiDiff: _applyPatchUiDiff } : {}),
|
|
614
635
|
});
|
|
@@ -651,11 +672,19 @@ export async function processToolBatch(ctx) {
|
|
|
651
672
|
resultText: _postMsg,
|
|
652
673
|
resultKind: 'error',
|
|
653
674
|
});
|
|
675
|
+
const resultCompletedAt = Date.now();
|
|
654
676
|
_stageToolResultMessage({
|
|
655
677
|
role: 'tool',
|
|
656
678
|
content: _postMsg,
|
|
657
679
|
toolCallId: call.id,
|
|
658
680
|
toolKind: 'error',
|
|
681
|
+
toolTiming: {
|
|
682
|
+
dispatchStartedAt,
|
|
683
|
+
executionStartedAt: executionStartedAt ?? toolStartedAt,
|
|
684
|
+
executionCompletedAt: toolEndedAt,
|
|
685
|
+
postprocessStartedAt,
|
|
686
|
+
resultCompletedAt,
|
|
687
|
+
},
|
|
659
688
|
});
|
|
660
689
|
}
|
|
661
690
|
throwIfAborted();
|
|
@@ -9,6 +9,8 @@ import { existsSync } from 'node:fs';
|
|
|
9
9
|
import { createInterface } from 'node:readline';
|
|
10
10
|
import { hiddenSpawnOpts } from '../../../../shared/spawn-flags.mjs';
|
|
11
11
|
import { invalidateBuiltinResultCache } from './cache-layers.mjs';
|
|
12
|
+
import { getPluginData } from '../../config.mjs';
|
|
13
|
+
import { ensureGraphBinary } from '../graph-binary-fetcher.mjs';
|
|
12
14
|
|
|
13
15
|
const RESTART_BACKOFF_MS = 30_000;
|
|
14
16
|
const REQUEST_TIMEOUT_MS = 20_000;
|
|
@@ -29,7 +31,14 @@ function _setServerReferenced(server, referenced) {
|
|
|
29
31
|
|
|
30
32
|
function _resolveBinary() {
|
|
31
33
|
if (_binaryPath !== undefined) return _binaryPath;
|
|
32
|
-
|
|
34
|
+
// The graph and resident-search protocols ship in the same executable.
|
|
35
|
+
// Honor either override synchronously so a first grep never races the lazy
|
|
36
|
+
// graph module import while an explicitly injected binary already exists.
|
|
37
|
+
const explicit = String(
|
|
38
|
+
process.env.MIXDOG_SEARCH_SERVER_BIN
|
|
39
|
+
|| process.env.MIXDOG_GRAPH_BIN
|
|
40
|
+
|| '',
|
|
41
|
+
).trim();
|
|
33
42
|
if (explicit && existsSync(explicit)) {
|
|
34
43
|
_binaryPath = explicit;
|
|
35
44
|
return _binaryPath;
|
|
@@ -172,7 +181,8 @@ export async function warmNativeSearchServer(timeoutMs = 5_000) {
|
|
|
172
181
|
try {
|
|
173
182
|
if (!_resolveBinary()) {
|
|
174
183
|
const mod = await import('../code-graph/graph-binary.mjs');
|
|
175
|
-
|
|
184
|
+
let candidate = mod.graphBinaryPath?.() || mod.resolveGraphBinaryPath?.() || null;
|
|
185
|
+
if (!candidate) candidate = await ensureGraphBinary(getPluginData());
|
|
176
186
|
if (candidate && existsSync(candidate)) _binaryPath = candidate;
|
|
177
187
|
else if (_binaryPath === undefined) _binaryPath = null;
|
|
178
188
|
}
|
|
@@ -305,7 +315,12 @@ export async function tryServeSearch(argsList, execOptions = {}, opts = {}) {
|
|
|
305
315
|
? Math.floor(Number(opts.limit))
|
|
306
316
|
: 0,
|
|
307
317
|
}, execOptions, requestDeadlineMs);
|
|
308
|
-
if (!response
|
|
318
|
+
if (!response) return null;
|
|
319
|
+
if (response.unsupported || response.error) {
|
|
320
|
+
const rejected = new Error(String(response.error || response.unsupported));
|
|
321
|
+
rejected.code = response.error ? 'NATIVE_SEARCH_ERROR' : 'NATIVE_SEARCH_UNSUPPORTED';
|
|
322
|
+
throw rejected;
|
|
323
|
+
}
|
|
309
324
|
if (!Array.isArray(response.lines)) return null;
|
|
310
325
|
return {
|
|
311
326
|
lines: response.lines,
|
|
@@ -1,26 +1,26 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.1.
|
|
2
|
+
"version": "0.1.9",
|
|
3
3
|
"_comment": "Synced from immutable graph-v release assets.",
|
|
4
4
|
"assets": {
|
|
5
5
|
"darwin-arm64": {
|
|
6
|
-
"url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.
|
|
7
|
-
"sha256": "
|
|
6
|
+
"url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.9/mixdog-graph-darwin-arm64",
|
|
7
|
+
"sha256": "1533e65d40a9392833dee78f771771c176d376a6001f1905aa4b25d3fda49041"
|
|
8
8
|
},
|
|
9
9
|
"darwin-x64": {
|
|
10
|
-
"url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.
|
|
11
|
-
"sha256": "
|
|
10
|
+
"url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.9/mixdog-graph-darwin-x64",
|
|
11
|
+
"sha256": "9fb70b2109683945d26fc3ba49d71d991aaa26d3513cb50048bf9539ec01fd31"
|
|
12
12
|
},
|
|
13
13
|
"linux-arm64": {
|
|
14
|
-
"url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.
|
|
15
|
-
"sha256": "
|
|
14
|
+
"url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.9/mixdog-graph-linux-arm64",
|
|
15
|
+
"sha256": "e41aad50f20a22d170ca9850988cc327d77eea264fbbabed6ff98cc840fc2c0f"
|
|
16
16
|
},
|
|
17
17
|
"linux-x64": {
|
|
18
|
-
"url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.
|
|
19
|
-
"sha256": "
|
|
18
|
+
"url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.9/mixdog-graph-linux-x64",
|
|
19
|
+
"sha256": "750c8ed55ab077b052ae76e222a3ebefe6a137a18f8790948e2b33a4ce4aa915"
|
|
20
20
|
},
|
|
21
21
|
"win32-x64": {
|
|
22
|
-
"url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.
|
|
23
|
-
"sha256": "
|
|
22
|
+
"url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.9/mixdog-graph-win32-x64.exe",
|
|
23
|
+
"sha256": "99b12dd8f10245e0aabc2e5ffc5ae25299d01127a51ebc433dbca9bd36814c75"
|
|
24
24
|
}
|
|
25
25
|
}
|
|
26
26
|
}
|
|
@@ -118,6 +118,11 @@ export class NativeSpawnChild extends EventEmitter {
|
|
|
118
118
|
|
|
119
119
|
function _resolveBinary() {
|
|
120
120
|
if (_binaryPath !== undefined) return _binaryPath;
|
|
121
|
+
const explicit = String(process.env.MIXDOG_SPAWN_SERVER_BIN || '').trim();
|
|
122
|
+
if (explicit && existsSync(explicit)) {
|
|
123
|
+
_binaryPath = explicit;
|
|
124
|
+
return _binaryPath;
|
|
125
|
+
}
|
|
121
126
|
_binaryPath = findCachedSpawnBinary(getPluginData());
|
|
122
127
|
return _binaryPath;
|
|
123
128
|
}
|
|
@@ -34,12 +34,24 @@ export function createPrewarmSchedulers({
|
|
|
34
34
|
}
|
|
35
35
|
if (isCloseRequested()) return;
|
|
36
36
|
state.codeGraphPrewarmQueuedCwd = getCurrentCwd();
|
|
37
|
-
if (timers.codeGraphPrewarmTimer)
|
|
37
|
+
if (timers.codeGraphPrewarmTimer) {
|
|
38
|
+
// Upgrade a pending idle/cwd warm when the first visible provider token
|
|
39
|
+
// arrives. Keeping the older timer would preserve its non-overlap reason
|
|
40
|
+
// and defer the graph until after the active turn.
|
|
41
|
+
if (reason !== 'first-visible') return;
|
|
42
|
+
clearTimeout(timers.codeGraphPrewarmTimer);
|
|
43
|
+
timers.codeGraphPrewarmTimer = null;
|
|
44
|
+
}
|
|
38
45
|
timers.codeGraphPrewarmTimer = setTimeout(() => {
|
|
39
46
|
timers.codeGraphPrewarmTimer = null;
|
|
40
47
|
if (isCloseRequested()) return;
|
|
41
|
-
|
|
42
|
-
|
|
48
|
+
const activeTurn = getActiveTurnCount() > 0;
|
|
49
|
+
// first-visible is armed only after TTFT. Let that warm overlap the
|
|
50
|
+
// provider's remaining generation instead of retrying until the turn
|
|
51
|
+
// ends, which made first-turn code_graph calls pay the full cold build.
|
|
52
|
+
const canOverlapActiveTurn = reason === 'first-visible';
|
|
53
|
+
if ((activeTurn && !canOverlapActiveTurn) || getSessionCreatePromise()) {
|
|
54
|
+
bootProfile('code-graph:prewarm-deferred', { reason: activeTurn ? 'turn-active' : 'session-create' });
|
|
43
55
|
scheduleCodeGraphPrewarm(backgroundBusyRetryMs, 'busy');
|
|
44
56
|
return;
|
|
45
57
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
|
|
4
|
+
import { createPrewarmSchedulers } from './prewarm.mjs';
|
|
5
|
+
|
|
6
|
+
test('first-visible code graph prewarm overlaps an active turn', async () => {
|
|
7
|
+
const timers = {};
|
|
8
|
+
const calls = [];
|
|
9
|
+
const profiles = [];
|
|
10
|
+
const cwd = process.cwd();
|
|
11
|
+
const schedulers = createPrewarmSchedulers({
|
|
12
|
+
timers,
|
|
13
|
+
bootProfile: (event, detail) => profiles.push([event, detail]),
|
|
14
|
+
getCurrentCwd: () => cwd,
|
|
15
|
+
isCloseRequested: () => false,
|
|
16
|
+
getActiveTurnCount: () => 1,
|
|
17
|
+
getSessionCreatePromise: () => null,
|
|
18
|
+
getSession: () => null,
|
|
19
|
+
isRemoteEnabled: () => false,
|
|
20
|
+
channelsEnabled: () => false,
|
|
21
|
+
hasActiveAutomation: () => false,
|
|
22
|
+
getCodeGraphModule: async () => ({
|
|
23
|
+
prewarmCodeGraphIfProject(root) {
|
|
24
|
+
calls.push(root);
|
|
25
|
+
return true;
|
|
26
|
+
},
|
|
27
|
+
}),
|
|
28
|
+
createCurrentSession: async () => null,
|
|
29
|
+
channels: {},
|
|
30
|
+
envFlag: () => false,
|
|
31
|
+
delays: {
|
|
32
|
+
codeGraphPrewarmDelayMs: 0,
|
|
33
|
+
channelStartDelayMs: 0,
|
|
34
|
+
backgroundBusyRetryMs: 50,
|
|
35
|
+
},
|
|
36
|
+
flags: { codeGraphPrewarmEnabled: true },
|
|
37
|
+
state: {
|
|
38
|
+
codeGraphPrewarmQueuedCwd: '',
|
|
39
|
+
codeGraphPrewarmInFlight: false,
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
schedulers.scheduleCodeGraphPrewarm(100, 'cwd');
|
|
44
|
+
schedulers.scheduleCodeGraphPrewarm(0, 'first-visible');
|
|
45
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
46
|
+
|
|
47
|
+
assert.deepEqual(calls, [cwd]);
|
|
48
|
+
assert.equal(
|
|
49
|
+
profiles.some(([event, detail]) =>
|
|
50
|
+
event === 'code-graph:prewarm-deferred' && detail?.reason === 'turn-active'),
|
|
51
|
+
false,
|
|
52
|
+
);
|
|
53
|
+
});
|
|
@@ -339,6 +339,7 @@ export function createSessionTurnApi(deps) {
|
|
|
339
339
|
return options.onAssistantText?.(text);
|
|
340
340
|
},
|
|
341
341
|
onUsageDelta: options.onUsageDelta,
|
|
342
|
+
onAssistantToolCallObserved: options.onAssistantToolCallObserved,
|
|
342
343
|
onToolResult: (message) => {
|
|
343
344
|
if (getRemoteEnabled() && getTranscriptWriter()) {
|
|
344
345
|
try {
|