mixdog 0.9.125 → 0.9.126

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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mixdog",
3
- "version": "0.9.125",
3
+ "version": "0.9.126",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Standalone mixdog coding-agent CLI/TUI workspace.",
package/src/app.mjs CHANGED
@@ -86,6 +86,7 @@ export async function run(argv = [], classifiedInvocation = null) {
86
86
  model: opts.model,
87
87
  effort: opts.effort,
88
88
  fast: opts.fast,
89
+ json: opts.json,
89
90
  cwd: process.cwd(),
90
91
  });
91
92
  }
@@ -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
 
@@ -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: 0,
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: 0,
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
- const { result } = await runtime.ask(prompt, {
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) => applyUsageDelta(stats, 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
- const text = String(result?.content ?? result?.text ?? '');
144
- if (text) write(text.endsWith('\n') ? text : `${text}\n`);
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
- writeUsageDocument(usageLogPath, stats, runtime);
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: !!call?.id && eager.pending.has(call.id),
641
+ eagerStarted: false,
643
642
  });
644
643
  } catch {}
645
- return result;
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: Date.now(),
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();
@@ -29,7 +29,14 @@ function _setServerReferenced(server, referenced) {
29
29
 
30
30
  function _resolveBinary() {
31
31
  if (_binaryPath !== undefined) return _binaryPath;
32
- const explicit = String(process.env.MIXDOG_SEARCH_SERVER_BIN || '').trim();
32
+ // The graph and resident-search protocols ship in the same executable.
33
+ // Honor either override synchronously so a first grep never races the lazy
34
+ // graph module import while an explicitly injected binary already exists.
35
+ const explicit = String(
36
+ process.env.MIXDOG_SEARCH_SERVER_BIN
37
+ || process.env.MIXDOG_GRAPH_BIN
38
+ || '',
39
+ ).trim();
33
40
  if (explicit && existsSync(explicit)) {
34
41
  _binaryPath = explicit;
35
42
  return _binaryPath;
@@ -305,7 +312,12 @@ export async function tryServeSearch(argsList, execOptions = {}, opts = {}) {
305
312
  ? Math.floor(Number(opts.limit))
306
313
  : 0,
307
314
  }, execOptions, requestDeadlineMs);
308
- if (!response || response.unsupported || response.error) return null;
315
+ if (!response) return null;
316
+ if (response.unsupported || response.error) {
317
+ const rejected = new Error(String(response.error || response.unsupported));
318
+ rejected.code = response.error ? 'NATIVE_SEARCH_ERROR' : 'NATIVE_SEARCH_UNSUPPORTED';
319
+ throw rejected;
320
+ }
309
321
  if (!Array.isArray(response.lines)) return null;
310
322
  return {
311
323
  lines: response.lines,
@@ -1,26 +1,26 @@
1
1
  {
2
- "version": "0.1.8",
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.8/mixdog-graph-darwin-arm64",
7
- "sha256": "3b0f1607394a542df99ac1a226fa8c0cc413cb2ea2714fe8e72184f4f1dc99ff"
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.8/mixdog-graph-darwin-x64",
11
- "sha256": "bac512530c83f3cf45b542ae45fb50cb4d1c5fa5de4cfdede9865208d23fa4a4"
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.8/mixdog-graph-linux-arm64",
15
- "sha256": "10df41dd7c0a68cdc2f6430b83e15d928a4f446b9f7b34504aef3ab05a32f514"
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.8/mixdog-graph-linux-x64",
19
- "sha256": "8a3b77fc830e169f0b2f553c67d3a2ecb4e3a91aa27706185749f38dcebe1482"
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.8/mixdog-graph-win32-x64.exe",
23
- "sha256": "d748a4f9b0deb39fbcb3387f54a6773eba76bbfa7749ed8376a1d3e5c0cddcdd"
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
  }
@@ -34,12 +34,24 @@ export function createPrewarmSchedulers({
34
34
  }
35
35
  if (isCloseRequested()) return;
36
36
  state.codeGraphPrewarmQueuedCwd = getCurrentCwd();
37
- if (timers.codeGraphPrewarmTimer) return;
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
- if (getActiveTurnCount() > 0 || getSessionCreatePromise()) {
42
- bootProfile('code-graph:prewarm-deferred', { reason: getActiveTurnCount() > 0 ? 'turn-active' : 'session-create' });
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 {