mixdog 0.9.135 → 0.9.136

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.
Files changed (121) hide show
  1. package/package.json +3 -1
  2. package/scripts/tool-stress.mjs +8 -1
  3. package/src/agents/maintainer/agent.json +1 -1
  4. package/src/defaults/skills/setup/SKILL.md +8 -43
  5. package/src/help.mjs +0 -2
  6. package/src/rules/lead/01-general.md +0 -4
  7. package/src/rules/shared/01-tool.md +10 -4
  8. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +30 -4
  9. package/src/runtime/agent/orchestrator/agent-trace-timing.test.mjs +57 -0
  10. package/src/runtime/agent/orchestrator/session/cache/prefetch-cache.mjs +3 -3
  11. package/src/runtime/agent/orchestrator/session/cache/prefetch-cache.test.mjs +40 -0
  12. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +7 -0
  13. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +0 -5
  14. package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +17 -4
  15. package/src/runtime/agent/orchestrator/tools/builtin/edit-sequential-occupation.test.mjs +40 -0
  16. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +150 -41
  17. package/src/runtime/agent/orchestrator/tools/builtin/local-search-telemetry.mjs +4 -0
  18. package/src/runtime/agent/orchestrator/tools/builtin/native-search-client.mjs +34 -0
  19. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +18 -1
  20. package/src/runtime/agent/orchestrator/tools/builtin/read-streaming.mjs +10 -9
  21. package/src/runtime/agent/orchestrator/tools/builtin/search-glob-tool.mjs +124 -18
  22. package/src/runtime/agent/orchestrator/tools/builtin/search-glob-top-k.test.mjs +39 -0
  23. package/src/runtime/agent/orchestrator/tools/builtin/search-grep-tool.mjs +7 -1
  24. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +4 -1
  25. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +82 -5
  26. package/src/runtime/agent/orchestrator/tools/code-graph/graph-binary.mjs +2 -2
  27. package/src/runtime/agent/orchestrator/tools/graph-manifest.json +11 -11
  28. package/src/runtime/agent/orchestrator/tools/lib/native-spawn-client.mjs +78 -12
  29. package/src/runtime/agent/orchestrator/tools/lib/shell-warm-standby.mjs +34 -3
  30. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +12 -5
  31. package/src/runtime/agent/orchestrator/tools/shell-exec-output.mjs +87 -0
  32. package/src/runtime/agent/orchestrator/tools/spawn-manifest.json +11 -11
  33. package/src/runtime/channels/lib/config.mjs +14 -101
  34. package/src/runtime/channels/lib/index-drop-trace.mjs +2 -5
  35. package/src/runtime/channels/lib/owned-runtime.mjs +4 -80
  36. package/src/runtime/channels/lib/scheduler.mjs +75 -18
  37. package/src/runtime/channels/lib/status-snapshot.mjs +5 -68
  38. package/src/runtime/channels/lib/tool-dispatch.mjs +12 -55
  39. package/src/runtime/channels/lib/worker-ipc.mjs +1 -16
  40. package/src/runtime/channels/lib/worker-main.mjs +22 -307
  41. package/src/runtime/media/renditions.mjs +68 -5
  42. package/src/runtime/media/renditions.test.mjs +45 -0
  43. package/src/runtime/media/store.mjs +1 -0
  44. package/src/runtime/memory/index.mjs +4 -3
  45. package/src/runtime/memory/lib/cycle-scheduler.mjs +17 -6
  46. package/src/runtime/memory/lib/memory-cycle-packets.test.mjs +7 -1
  47. package/src/runtime/memory/lib/memory-cycle-requests.mjs +13 -0
  48. package/src/runtime/shared/config.mjs +8 -49
  49. package/src/runtime/shared/schedule-time.mjs +25 -0
  50. package/src/runtime/shared/schedule-time.test.mjs +29 -0
  51. package/src/runtime/shared/schedules-db.mjs +73 -1
  52. package/src/runtime/shared/tool-card-model.mjs +1 -1
  53. package/src/runtime/shared/transcript-writer.mjs +10 -17
  54. package/src/runtime/shared/update-checker.mjs +7 -2
  55. package/src/runtime/shared/update-checker.test.mjs +33 -0
  56. package/src/session-runtime/channel-config-api.mjs +3 -20
  57. package/src/session-runtime/config-lifecycle.mjs +1 -42
  58. package/src/session-runtime/lifecycle-api.mjs +1 -19
  59. package/src/session-runtime/model-route-api.mjs +0 -2
  60. package/src/session-runtime/model-settings-persist.test.mjs +0 -1
  61. package/src/session-runtime/prewarm.mjs +2 -7
  62. package/src/session-runtime/prewarm.test.mjs +0 -1
  63. package/src/session-runtime/runtime-core.mjs +6 -185
  64. package/src/session-runtime/session-lifecycle.mjs +4 -17
  65. package/src/session-runtime/session-transcript.mjs +54 -0
  66. package/src/session-runtime/session-turn-api.mjs +4 -44
  67. package/src/session-runtime/settings-api.mjs +0 -45
  68. package/src/session-runtime/tool-catalog-schema.mjs +1 -1
  69. package/src/standalone/channel-admin.mjs +8 -150
  70. package/src/standalone/daemon.mjs +5 -48
  71. package/src/standalone/seeds.mjs +18 -1
  72. package/src/standalone/session-protocol.mjs +0 -8
  73. package/src/standalone/session-service.mjs +1 -3
  74. package/src/tui/App.jsx +0 -5
  75. package/src/tui/app/app-view.jsx +1 -2
  76. package/src/tui/app/create-app-pickers.mjs +0 -20
  77. package/src/tui/app/doctor.mjs +2 -6
  78. package/src/tui/app/prompt-submit.mjs +2 -49
  79. package/src/tui/app/settings-picker.mjs +22 -96
  80. package/src/tui/app/slash-commands.mjs +0 -2
  81. package/src/tui/app/slash-dispatch.mjs +0 -17
  82. package/src/tui/app/use-prompt-handlers.mjs +4 -1
  83. package/src/tui/components/StatusLine.jsx +4 -4
  84. package/src/tui/dist/index.mjs +1003 -1461
  85. package/src/tui/session/context-state.mjs +0 -1
  86. package/src/tui/session/queue-helpers.mjs +1 -1
  87. package/src/tui/session/queue-helpers.test.mjs +10 -1
  88. package/src/tui/session/session-api-ext.mjs +0 -89
  89. package/src/tui/session/session-api.mjs +0 -2
  90. package/src/tui/session/tool-card-results.mjs +1 -1
  91. package/src/tui/session/tool-result-status.mjs +3 -3
  92. package/src/tui/session/tool-result-status.test.mjs +3 -3
  93. package/src/tui/session-local.mjs +0 -26
  94. package/src/workflows/default/WORKFLOW.md +13 -15
  95. package/src/workflows/solo/WORKFLOW.md +6 -7
  96. package/src/runtime/channels/lib/config-lock.mjs +0 -13
  97. package/src/runtime/channels/lib/drop-trace.mjs +0 -71
  98. package/src/runtime/channels/lib/format.mjs +0 -190
  99. package/src/runtime/channels/lib/inbound-handler.mjs +0 -387
  100. package/src/runtime/channels/lib/inbound-routing.mjs +0 -81
  101. package/src/runtime/channels/lib/interaction-handlers.mjs +0 -70
  102. package/src/runtime/channels/lib/interaction-workflows.mjs +0 -76
  103. package/src/runtime/channels/lib/memory-client.mjs +0 -289
  104. package/src/runtime/channels/lib/output-forwarder.mjs +0 -934
  105. package/src/runtime/channels/lib/session-control.mjs +0 -36
  106. package/src/runtime/channels/lib/session-discovery.mjs +0 -217
  107. package/src/runtime/channels/lib/session-discovery.test.mjs +0 -32
  108. package/src/runtime/channels/lib/telegram-format.mjs +0 -280
  109. package/src/runtime/channels/lib/tool-format.mjs +0 -125
  110. package/src/runtime/channels/lib/transcript-binding.mjs +0 -336
  111. package/src/runtime/channels/lib/transcript-discovery.mjs +0 -208
  112. package/src/runtime/channels/providers/discord-access.mjs +0 -30
  113. package/src/runtime/channels/providers/discord-attachments.mjs +0 -69
  114. package/src/runtime/channels/providers/discord-gateway.mjs +0 -230
  115. package/src/runtime/channels/providers/discord.mjs +0 -730
  116. package/src/runtime/channels/providers/telegram.mjs +0 -598
  117. package/src/runtime/channels/providers/telegram.test.mjs +0 -75
  118. package/src/session-runtime/remote-control.mjs +0 -126
  119. package/src/session-runtime/remote-transcript.mjs +0 -134
  120. package/src/session-runtime/remote-transition-queue.mjs +0 -14
  121. package/src/tui/app/channel-pickers.mjs +0 -384
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mixdog",
3
- "version": "0.9.135",
3
+ "version": "0.9.136",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Standalone mixdog coding-agent CLI/TUI workspace.",
@@ -86,6 +86,8 @@
86
86
  "bench:session-context": "node scripts/session-context-bench.mjs",
87
87
  "bench:session-transport": "node scripts/session-transport-bench.mjs",
88
88
  "bench:tool-overhead": "node scripts/tool-overhead-microbench.mjs",
89
+ "bench:search-tools": "node scripts/tool-search-bench.mjs",
90
+ "bench:io-tools": "node scripts/tool-io-bench.mjs",
89
91
  "bench:internal-comms": "node scripts/internal-comms-bench.mjs",
90
92
  "audit:models": "node scripts/model-catalog-audit.mjs",
91
93
  "patch:replay": "node scripts/patch-replay.mjs",
@@ -2,7 +2,9 @@
2
2
  // concurrent multi-session waves and checks that results stay correct and
3
3
  // bounded under load (no unhandled rejections, no resource-pressure failures,
4
4
  // patch/read integrity preserved, cancellation clean). One-shot script:
5
- // `node scripts/tool-stress.mjs`. Exit 0 = stable, 1 = instability found.
5
+ // `node scripts/tool-stress.mjs --unsafe-live`. Exit 0 = stable, 1 = instability found.
6
+ // This intentionally exercises shell cancellation and concurrent mutations.
7
+ // Use tool-search-bench.mjs for routine, read-only exploration diagnostics.
6
8
  import '../src/runtime/shared/uv-threadpool-boot.mjs';
7
9
  import './native-spawn-test-runtime.mjs';
8
10
  import { mkdtempSync, rmSync } from 'node:fs';
@@ -17,6 +19,11 @@ import { warmNativeSpawnServer } from '../src/runtime/agent/orchestrator/tools/l
17
19
  import { warmNativeSearchServer } from '../src/runtime/agent/orchestrator/tools/builtin/native-search-client.mjs';
18
20
  import { prewarmFindEnumeration } from '../src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs';
19
21
 
22
+ if (!process.argv.includes('--unsafe-live')) {
23
+ console.error('Refusing high-impact tool stress without --unsafe-live; run `node scripts/tool-search-bench.mjs` for safe exploration diagnostics.');
24
+ process.exit(2);
25
+ }
26
+
20
27
  const root = join(fileURLToPath(new URL('.', import.meta.url)), '..');
21
28
  const SESSIONS = 8;
22
29
  const WAVES = 5;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "maintainer",
3
3
  "name": "Maintainer",
4
- "description": "Use for memory upkeep and long-running maintenance work.",
4
+ "description": "Use for internal Git operations, memory upkeep, session naming, and other Mixdog background operations.",
5
5
  "entry": "AGENT.md"
6
6
  }
@@ -18,7 +18,7 @@ description: Use this skill only to inspect or modify a Mixdog user's persisted
18
18
  - 프로젝트 config: `<cwd>/.mcp.json`, `<cwd>/.mixdog/skills/`, `<cwd>/.mixdog/hooks.json`
19
19
  - TUI 명령 목록: `src/tui/app/slash-commands.mjs`
20
20
  - TUI 설정 허브: `/setting` (별칭 `/settings`, `/config`) → `src/tui/app/settings-picker.mjs`
21
- - Desktop 설정: `Ctrl+,` → General / Context / Output style / Providers / Git / Skills / MCP / Plugins / Hooks / Channels / Connection / System / Shortcuts / About
21
+ - Desktop 설정: `Ctrl+,` → General / Context / Output style / Providers / Git / Skills / MCP / Plugins / Hooks / Connection / System / Shortcuts / About
22
22
  - Desktop rail: **Projects / Workflows / Schedules / Webhooks / Utilities**
23
23
 
24
24
  사용자 경로와 지원 여부는 **현재 Desktop 구현을 최신 기준**으로 판정한다. TUI는 Desktop에 없는 고급 경로를 보완할 때만 사용하며, 저장만 왕복되고 실제 소비되지 않는 key는 사용자 옵션으로 설명하지 않는다.
@@ -34,10 +34,9 @@ description: Use this skill only to inspect or modify a Mixdog user's persisted
34
34
  | Search 모델 | `agent.searchRoute` |
35
35
  | Provider·MCP·프로필·스킬·업데이트·셸·autoClear·compaction | `agent.*` |
36
36
  | Web search 도구 | `agent.modules.search.enabled` |
37
- | Channels messaging | `agent.modules.channels.enabled` |
38
37
  | Recap(배경 사이클) | `agent.recap.enabled` |
39
38
  | 출력 스타일 | 루트 `outputStyle` |
40
- | Discord/Telegram provider·target·webhook server | `channels.*` (`channels.provider`) |
39
+ | Webhook server | `channels.webhook.*` |
41
40
  | 메모리 cycle interval | `memory.cycle1.interval`, `memory.cycle2.interval` |
42
41
  | 음성 전사 | `voice.enabled` |
43
42
  | TUI 테마 | `ui.theme` |
@@ -51,7 +50,7 @@ API 키·토큰·OAuth 자격은 config에 쓰지 않는다.
51
50
  - MCP: `/mcp`, Desktop **Settings → MCP**, 또는 `mcpStatus()`
52
51
  - Skills: `/skills`, Desktop **Settings → Skills**, 또는 `skillsStatus()`
53
52
  - Providers: `/providers`, Desktop **Settings → Providers**, 또는 `getProviderSetup()`
54
- - Channels: `/channels`, Desktop **Settings Channels**, 또는 `getChannelSetup()`
53
+ - Webhooks: Desktop **Webhooks** 뷰, 또는 `getChannelSetup()` (webhook enabled/port)
55
54
  - 비밀 존재 여부: `hasStoredSecret(account)`만 사용하고 값은 출력하지 않는다.
56
55
 
57
56
  ---
@@ -175,8 +174,6 @@ API 키·토큰·OAuth 자격은 config에 쓰지 않는다.
175
174
  | `agent.<provider>.apiKey` | Provider API key |
176
175
  | `agent.openai.usageSessionKey` | OpenAI usage |
177
176
  | `agent.opencode-go.authCookie` | OpenCode Go usage |
178
- | `discord.token` | Discord bot |
179
- | `telegram.token` | Telegram bot |
180
177
 
181
178
  값은 출력하지 않고 `hasStoredSecret()`으로 존재만 확인한다. MCP `env`는 keychain이 아니라 자식 프로세스 환경이다.
182
179
 
@@ -335,21 +332,9 @@ Plugins 화면에는 일반적인 “plugin 활성/비활성” toggle이 없다
335
332
 
336
333
  ---
337
334
 
338
- ## 5. Channels·Voice·Automation·Remote
335
+ ## 5. Voice·Automation
339
336
 
340
- ### Discord / Telegram
341
-
342
- 1. **확인**: `/channels`, `/setting → Channel / Setting`, Desktop **Settings → Channels**, `getChannelSetup()`.
343
- 2. **변경**:
344
- - provider: Discord/Telegram 선택 → `setChannelProvider()`
345
- - token: Bot token → OS keychain
346
- - main target: Discord channel ID / Telegram chat ID → `setChannel({ provider, channelId })`
347
- 3. **저장**: provider·target은 `channels.provider`, `channels.channel.discordChannelId` / `telegramChatId`. token은 `discord.token` / `telegram.token`.
348
- 4. **검증**: active provider, authenticated, main target의 Ready 상태.
349
-
350
- `Channels enabled`는 messaging만 제어하며 디스크 경로는 `agent.modules.channels.enabled`다. schedules/webhooks는 이 toggle과 독립적으로 계속 실행된다.
351
-
352
- `channels.backend`와 `channels.channel.channelId`는 쓰지 않는다. canonicalize가 제거하고 `provider` / per-provider id만 남긴다.
337
+ Discord/Telegram messaging은 제거됐다. schedules/webhooks 자동화와 voice transcription만 남는다.
353
338
 
354
339
  ### Voice transcription
355
340
 
@@ -383,16 +368,7 @@ Plugins 화면에는 일반적인 “plugin 활성/비활성” toggle이 없다
383
368
 
384
369
  기존 파일형 schedules는 최초 DB init 때 import 후 `schedules.migrated`로 보존한다. 기존 webhook 디렉터리는 성공적으로 모두 import된 경우 삭제한다.
385
370
 
386
- 활성 schedule/webhook이 있으면 automation worker가 messaging·remote와 독립적으로 자동 시작한다.
387
-
388
- ### Remote session
389
-
390
- 1. **강제 claim**: TUI `/remote`는 항상 현재 세션을 ON으로 만들고 기존 owner를 넘겨받는다.
391
- 2. **TUI toggle**: `/channels → Remote Runtime` 또는 `/setting → Remote Runtime`.
392
- 3. **Desktop**: Settings에 Remote 행이 없다. session header의 remote toggle만 사용한다. draft의 New-task remote는 일회성이며 설정 기본값이 아니다.
393
- 4. **시작 시 ON**: CLI `mixdog --remote`.
394
- 5. **중요**: config `remote.autoStart`는 사용하지 않는다. 자동화 worker의 자동 시작과 remote session claim은 별개다.
395
- 6. **검증**: `isRemoteEnabled()`, owner session ID, channel worker status.
371
+ 활성 schedule/webhook이 있으면 automation worker가 자동 시작한다.
396
372
 
397
373
  ---
398
374
 
@@ -436,13 +412,12 @@ Desktop과 현재 runtime에서 소비되지 않는 아래 key는 사용자 옵
436
412
  | `agent.agentMaintenance`, `agent.runtime` | 제거 |
437
413
  | `remote.autoStart` | 제거; Remote는 session header에서 수동 claim |
438
414
  | `ui.mouseMode` | 제거; `ui.theme`은 TUI 현행 설정 |
439
- | `channels.backend` | 제거 (이관 없음; `channels.provider`만 유효) |
440
- | `channels.channel.channelId` | 제거; `discordChannelId` / `telegramChatId`만 유지 |
415
+ | `channels.backend`, `channels.provider`, `channels.channel.*` | 제거; Discord/Telegram messaging 은퇴로 전부 데드 key |
441
416
  | `channels.quiet`, `channels.schedules` | 제거; schedule은 PG가 단일 저장소 |
442
417
  | `channels.webhook.ngrokDomain`, `channels.webhook.respectQuiet` | 제거; endpoint URL은 Desktop Webhooks가 발급 |
443
418
  | `agent.outputStyle` | 제거 (이관 없음; 루트 `outputStyle`만 유효) |
444
419
 
445
- 다음 항목은 레거시처럼 보여도 현행이므로 유지한다: 루트 `outputStyle`, `agent.shell`, `agent.modules`, `channels.channel.discordChannelId/telegramChatId`, compaction의 `type/compactType`과 recall tuning fields.
420
+ 다음 항목은 레거시처럼 보여도 현행이므로 유지한다: 루트 `outputStyle`, `agent.shell`, `agent.modules`, compaction의 `type/compactType`과 recall tuning fields.
446
421
 
447
422
  ---
448
423
 
@@ -478,21 +453,11 @@ URL transport는 `type` + `url` + 선택 `headers`를 사용한다.
478
453
  ```json
479
454
  {
480
455
  "channels": {
481
- "provider": "discord",
482
- "channel": {
483
- "discordChannelId": "",
484
- "telegramChatId": ""
485
- },
486
456
  "webhook": {
487
457
  "enabled": true,
488
458
  "port": 3333
489
459
  }
490
460
  },
491
- "agent": {
492
- "modules": {
493
- "channels": { "enabled": true }
494
- }
495
- },
496
461
  "voice": { "enabled": false }
497
462
  }
498
463
  ```
package/src/help.mjs CHANGED
@@ -46,8 +46,6 @@ export const HELP_LINES = [
46
46
  ' /plugins manage local plugin integrations',
47
47
  ' /hooks manage before-tool hook rules and events',
48
48
  ' /providers manage auth, API keys, OAuth, and local endpoints',
49
- ' /channels manage Discord, Telegram, and voice',
50
- ' /remote claim remote for this session',
51
49
  ' /setting open runtime settings (aliases: /settings, /config)',
52
50
  ' /profile set your title and response language',
53
51
  ' /update check version and update mixdog',
@@ -7,10 +7,6 @@
7
7
  - Confirm destructive/hard-to-reverse actions against explicit validated paths;
8
8
  never `~`, a root, or unresolved variables/globs; report material deletion
9
9
  recoverability.
10
- - After required work, run final verification only when the outcome needs
11
- evidence the successful tool result does not already give, and run only
12
- affected blocking invariants. Combine commands when dependency or
13
- atomicity requires it.
14
10
  - Mid-task: replacement supersedes; addition folds in; status gets a brief
15
11
  answer while work continues. After compaction, resume the summary.
16
12
  - Final text ends the turn only when done.
@@ -41,7 +41,12 @@
41
41
  a file to rebuild context or confirm an edit, no rerun of a passed check.
42
42
  A hole (needed content absent and not reconstructable) is fetched once;
43
43
  a change re-opens only that hole. Returned output is fully mined before the
44
- next round. `code_graph references` supplies the declaration and scoped
44
+ next round. A successful edit establishes the resulting source as known
45
+ state: do not inspect or query changed source with `read`, `grep`,
46
+ `code_graph`, `glob`, or `list`; proceed directly to final verification.
47
+ Reopen only a specific hole reported by a failed or partial edit, or by a
48
+ failed verification.
49
+ `code_graph references` supplies the declaration and scoped
45
50
  usages and ends that facet; values/locations end at the context grep
46
51
  returns; `read` covers only what returned spans cannot, as an anchored
47
52
  offset/limit window. Already obtained hunk text is any visible span —
@@ -73,9 +78,10 @@
73
78
  and buildability invariants. Treat mutable behavior, UX, exact text,
74
79
  snapshots, and implementation shape as advisory specifications; update them
75
80
  when the requested behavior changes instead of preserving obsolete behavior.
76
- - A successful verification closes the task unless later changes affect it.
77
- Rerun a failed action only after its inputs or subject changes; otherwise
78
- report it unresolved.
81
+ - When the applied changes require verification, run all applicable checks as
82
+ one final batch only after every planned edit is complete — never after
83
+ individual edits. If that verification fails, fully collect the failures,
84
+ batch all determinable fixes, then rerun only the affected failed checks once.
79
85
  - If inspection can change evidence or durable state, use read-only means;
80
86
  mutate only when the deliverable requires it, first preserving evidence
81
87
  at risk. Never mutate merely to clear an obstacle or unexpected state;
@@ -423,11 +423,36 @@ function traceAgentToolFailure({ sessionId, iteration, toolName, toolKind, toolM
423
423
  }
424
424
  }
425
425
 
426
- function traceAgentTool({ sessionId, iteration, toolName, toolKind, toolMs, toolArgs, agent, resultKind, model, resultText, localSearchTelemetry = null, resultTelemetry = null, cwd }) {
426
+ function summarizeToolTiming(toolTiming) {
427
+ if (!toolTiming || typeof toolTiming !== 'object') return null;
428
+ const number = (key) => {
429
+ const value = Number(toolTiming[key]);
430
+ return Number.isFinite(value) ? value : null;
431
+ };
432
+ const dispatchStartedAt = number('dispatchStartedAt');
433
+ const executionStartedAt = number('executionStartedAt');
434
+ const executionCompletedAt = number('executionCompletedAt');
435
+ const postprocessStartedAt = number('postprocessStartedAt');
436
+ const resultCompletedAt = number('resultCompletedAt');
437
+ const timing = {};
438
+ const duration = (key, start, end) => {
439
+ if (start === null || end === null) return;
440
+ timing[key] = Math.max(0, Math.round(end - start));
441
+ };
442
+ duration('dispatch_wait_ms', dispatchStartedAt, executionStartedAt);
443
+ duration('execution_ms', executionStartedAt, executionCompletedAt);
444
+ duration('result_collection_wait_ms', executionCompletedAt, postprocessStartedAt);
445
+ duration('postprocess_ms', postprocessStartedAt, resultCompletedAt);
446
+ duration('total_ms', dispatchStartedAt, resultCompletedAt);
447
+ return Object.keys(timing).length > 0 ? timing : null;
448
+ }
449
+
450
+ function traceAgentTool({ sessionId, iteration, toolName, toolKind, toolMs, toolArgs, agent, resultKind, model, resultText, localSearchTelemetry = null, resultTelemetry = null, toolTiming = null, cwd }) {
427
451
  const nextCallCount = countJsonNextCalls(resultText);
428
452
  const resultBytesEst = typeof resultText === 'string' ? Buffer.byteLength(resultText, 'utf8') : 0;
429
453
  const resultLinesEst = typeof resultText === 'string' && resultText.length > 0 ? resultText.split('\n').length : 0;
430
454
  const numericToolMs = Number(toolMs);
455
+ const summarizedTiming = summarizeToolTiming(toolTiming);
431
456
  const summarizedArgs = summarizeToolArgs(toolName, toolArgs);
432
457
  const grepCoverage = parseGrepCoverage(resultText, toolName, toolArgs, resultKind);
433
458
  // Hash the FULL args, not the summary: summaries drop payload fields
@@ -474,9 +499,10 @@ function traceAgentTool({ sessionId, iteration, toolName, toolKind, toolMs, tool
474
499
  local_search: localSearchTelemetry && Object.keys(localSearchTelemetry).length > 0
475
500
  ? { ...localSearchTelemetry }
476
501
  : null,
477
- payload: resultTelemetry?.integrity
478
- ? { integrity: { ...resultTelemetry.integrity } }
479
- : {},
502
+ payload: {
503
+ ...(summarizedTiming ? { timing: summarizedTiming } : {}),
504
+ ...(resultTelemetry?.integrity ? { integrity: { ...resultTelemetry.integrity } } : {}),
505
+ },
480
506
  cwd: cwd || null,
481
507
  });
482
508
  if (
@@ -0,0 +1,57 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { spawnSync } from 'node:child_process';
7
+
8
+ test('tool traces expose dispatch, execution, collection, and postprocess timing', () => {
9
+ const dir = mkdtempSync(join(tmpdir(), 'mixdog-tool-timing-'));
10
+ try {
11
+ const tracePath = join(dir, 'agent-trace.jsonl');
12
+ const child = spawnSync(process.execPath, ['--input-type=module', '-e', `
13
+ import { readFileSync } from 'node:fs';
14
+ import { traceAgentTool } from './src/runtime/agent/orchestrator/agent-trace-format.mjs';
15
+ import { drainAgentTrace } from './src/runtime/agent/orchestrator/agent-trace-io.mjs';
16
+ traceAgentTool({
17
+ sessionId: 'timing-test',
18
+ iteration: 1,
19
+ toolName: 'grep',
20
+ toolKind: 'builtin',
21
+ toolMs: 50,
22
+ resultKind: 'normal',
23
+ resultText: 'ok',
24
+ toolTiming: {
25
+ dispatchStartedAt: 100,
26
+ executionStartedAt: 110,
27
+ executionCompletedAt: 160,
28
+ postprocessStartedAt: 175,
29
+ resultCompletedAt: 190,
30
+ },
31
+ });
32
+ await drainAgentTrace();
33
+ const row = JSON.parse(readFileSync(process.env.MIXDOG_AGENT_TRACE_PATH, 'utf8').trim());
34
+ process.stdout.write(JSON.stringify(row.payload.timing));
35
+ `], {
36
+ cwd: process.cwd(),
37
+ encoding: 'utf8',
38
+ env: {
39
+ ...process.env,
40
+ MIXDOG_AGENT_TRACE_PATH: tracePath,
41
+ MIXDOG_AGENT_TRACE_DISABLE: '',
42
+ MIXDOG_AGENT_TRACE_LOCAL_DISABLE: '',
43
+ MIXDOG_RUNTIME_ROOT: join(dir, 'no-service'),
44
+ },
45
+ });
46
+ assert.equal(child.status, 0, child.stderr);
47
+ assert.deepEqual(JSON.parse(child.stdout), {
48
+ dispatch_wait_ms: 10,
49
+ execution_ms: 50,
50
+ result_collection_wait_ms: 15,
51
+ postprocess_ms: 15,
52
+ total_ms: 90,
53
+ });
54
+ } finally {
55
+ rmSync(dir, { recursive: true, force: true });
56
+ }
57
+ });
@@ -50,7 +50,7 @@ function _maybeSweepDiskCache() {
50
50
  void _sweepDiskCache();
51
51
  }
52
52
  function _diskPath(absPath) {
53
- const hash = createHash('sha256').update(absPath).digest('hex').slice(0, 16);
53
+ const hash = createHash('sha256').update(absPath).digest('hex');
54
54
  return join(DISK_CACHE_DIR, `${hash}.json`);
55
55
  }
56
56
  function _readDiskEntry(absPath) {
@@ -58,7 +58,7 @@ function _readDiskEntry(absPath) {
58
58
  if (!existsSync(p)) return null;
59
59
  try {
60
60
  const parsed = JSON.parse(readFileSync(p, 'utf8'));
61
- if (!parsed || typeof parsed.content !== 'string' || !parsed.stat) return null;
61
+ if (!parsed || parsed.absPath !== absPath || typeof parsed.content !== 'string' || !parsed.stat) return null;
62
62
  return { content: parsed.content, stat: parsed.stat, ts: parsed.ts };
63
63
  } catch { return null; }
64
64
  }
@@ -157,7 +157,7 @@ export function invalidatePrefetchCache(path, cwd) {
157
157
  _deleteDiskEntry(abs);
158
158
  }
159
159
 
160
- function drainPrefetchDiskWrites() {
160
+ export function drainPrefetchDiskWrites() {
161
161
  if (_diskWriteTimer !== null) {
162
162
  clearTimeout(_diskWriteTimer);
163
163
  _diskWriteTimer = null;
@@ -0,0 +1,40 @@
1
+ import assert from 'node:assert/strict';
2
+ import {
3
+ mkdtempSync,
4
+ mkdirSync,
5
+ readFileSync,
6
+ readdirSync,
7
+ rmSync,
8
+ writeFileSync,
9
+ } from 'node:fs';
10
+ import { tmpdir } from 'node:os';
11
+ import { join } from 'node:path';
12
+ import { test } from 'node:test';
13
+
14
+ test('prefetch disk cache uses a full hash and rejects a mismatched path identity', async () => {
15
+ const root = mkdtempSync(join(tmpdir(), 'mixdog-prefetch-cache-'));
16
+ const previousDataDir = process.env.MIXDOG_DATA_DIR;
17
+ process.env.MIXDOG_DATA_DIR = root;
18
+ const source = join(root, 'source.txt');
19
+ writeFileSync(source, 'source');
20
+ try {
21
+ const first = await import(`./prefetch-cache.mjs?writer=${Date.now()}`);
22
+ first.setPrefetchCached(source, 'cached output');
23
+ first.drainPrefetchDiskWrites();
24
+ const cacheDir = join(root, 'cache', 'prefetch');
25
+ const [cacheFile] = readdirSync(cacheDir);
26
+ assert.match(cacheFile, /^[0-9a-f]{64}\.json$/);
27
+
28
+ const path = join(cacheDir, cacheFile);
29
+ const payload = JSON.parse(readFileSync(path, 'utf8'));
30
+ mkdirSync(cacheDir, { recursive: true });
31
+ writeFileSync(path, JSON.stringify({ ...payload, absPath: `${source}.other` }));
32
+
33
+ const second = await import(`./prefetch-cache.mjs?reader=${Date.now()}`);
34
+ assert.equal(second.tryPrefetchCached(source), null);
35
+ } finally {
36
+ if (previousDataDir === undefined) delete process.env.MIXDOG_DATA_DIR;
37
+ else process.env.MIXDOG_DATA_DIR = previousDataDir;
38
+ rmSync(root, { recursive: true, force: true });
39
+ }
40
+ });
@@ -726,6 +726,13 @@ export async function processToolBatch(ctx) {
726
726
  resultText: result,
727
727
  localSearchTelemetry: completed.localSearchTelemetry,
728
728
  resultTelemetry: completed.resultTelemetry,
729
+ toolTiming: {
730
+ dispatchStartedAt,
731
+ executionStartedAt: executionStartedAt ?? toolStartedAt,
732
+ executionCompletedAt: toolEndedAt,
733
+ postprocessStartedAt,
734
+ resultCompletedAt: Date.now(),
735
+ },
729
736
  cwd,
730
737
  });
731
738
  // Deferred writes that predate a later mutation are skipped;
@@ -89,11 +89,6 @@ export const BUILTIN_TOOLS = [
89
89
  type: 'object',
90
90
  properties: {
91
91
  command: { type: 'string', description: `Command.${_shellSyntaxCheat}` },
92
- timeout_ms: {
93
- type: 'integer',
94
- minimum: 0,
95
- description: 'Hard total deadline in milliseconds; omit or use 0 to allow unlimited runtime after task promotion.',
96
- },
97
92
  ...(_shellBackgroundDisabled ? {} : {
98
93
  run_in_background: {
99
94
  type: 'boolean',
@@ -359,9 +359,8 @@ export function getCachedReadOnlyStat(fullPath, loader = statSync, now = Date.no
359
359
  return stat;
360
360
  }
361
361
 
362
- export async function statPathsForMtime(paths, workDir, concurrency = Infinity, opts = {}) {
362
+ async function visitPathStats(paths, workDir, concurrency, opts, visitor) {
363
363
  const items = Array.isArray(paths) ? paths : [];
364
- const out = new Array(items.length);
365
364
  const now = Date.now();
366
365
  const inflight = new Map();
367
366
  let next = 0;
@@ -406,17 +405,31 @@ export async function statPathsForMtime(paths, workDir, concurrency = Infinity,
406
405
  if (index >= items.length) return;
407
406
  const p = items[index];
408
407
  const full = isAbsolute(p) ? p : resolveAgainstCwd(p, workDir);
408
+ let entry;
409
409
  try {
410
410
  const stat = await resolveStat(full);
411
411
  if (!stat) throw new Error('stat failed');
412
- out[index] = { path: p, full, stat, size: stat.size, mtime: stat.mtimeMs, mtimeMs: stat.mtimeMs };
412
+ entry = { path: p, full, stat, size: stat.size, mtime: stat.mtimeMs, mtimeMs: stat.mtimeMs };
413
413
  } catch {
414
- out[index] = { path: p, full, stat: null, size: 0, mtime: 0, mtimeMs: 0 };
414
+ entry = { path: p, full, stat: null, size: 0, mtime: 0, mtimeMs: 0 };
415
415
  }
416
+ visitor(entry, index);
416
417
  }
417
418
  }
418
419
  const workerCount = Math.min(Math.max(1, concurrency), Math.max(1, items.length));
419
420
  await Promise.all(Array.from({ length: workerCount }, worker));
421
+ }
422
+
423
+ export async function visitPathsForMtime(paths, workDir, concurrency = Infinity, opts = {}, visitor = () => {}) {
424
+ await visitPathStats(paths, workDir, concurrency, opts, visitor);
425
+ }
426
+
427
+ export async function statPathsForMtime(paths, workDir, concurrency = Infinity, opts = {}) {
428
+ const items = Array.isArray(paths) ? paths : [];
429
+ const out = new Array(items.length);
430
+ await visitPathStats(items, workDir, concurrency, opts, (entry, index) => {
431
+ out[index] = entry;
432
+ });
420
433
  return out;
421
434
  }
422
435
 
@@ -8,7 +8,9 @@ import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node
8
8
  import { tmpdir } from 'node:os';
9
9
  import { join } from 'node:path';
10
10
  import { tryExecuteExternalToolAdapter } from './external-tool-adapters.mjs';
11
+ import { executeBuiltinTool } from '../builtin.mjs';
11
12
  import { recordReadSnapshot } from './read-snapshot-runtime.mjs';
13
+ import { executePatchTool } from '../patch.mjs';
12
14
  import { closeNativePatchServerForTests } from '../patch/native-server.mjs';
13
15
 
14
16
  const ANCHOR = '#if 0\n"""\n#endif';
@@ -57,6 +59,44 @@ test('count drift falls through to the strict ambiguity reject', async (t) => {
57
59
  assert.match(String(result), /current file excerpt lines/);
58
60
  });
59
61
 
62
+ test('successful edit snapshot makes a follow-up read return unchanged', async (t) => {
63
+ const { dir, file } = makeTempFile('alpha\nkeep\n');
64
+ const sessionId = `edit-known-current-${process.pid}`;
65
+ t.after(() => { rmSync(dir, { recursive: true, force: true }); void closeNativePatchServerForTests?.(); });
66
+
67
+ const result = await tryExecuteExternalToolAdapter('edit', {
68
+ file_path: file,
69
+ old_string: 'alpha',
70
+ new_string: 'omega',
71
+ }, dir, { sessionId });
72
+ assert.match(String(result), /^Updated /);
73
+
74
+ const reread = await executeBuiltinTool('read', { path: file }, dir, { sessionId });
75
+ assert.equal(reread, `[file unchanged: ${file.replaceAll('\\', '/')}]`);
76
+ });
77
+
78
+ test('successful apply_patch snapshot makes a follow-up read return unchanged', async (t) => {
79
+ const { dir, file } = makeTempFile('alpha\nkeep\n');
80
+ const sessionId = `patch-known-current-${process.pid}`;
81
+ t.after(() => { rmSync(dir, { recursive: true, force: true }); void closeNativePatchServerForTests?.(); });
82
+
83
+ const result = await executePatchTool('apply_patch', {
84
+ base_path: dir,
85
+ patch: `*** Begin Patch
86
+ *** Update File: poly.c
87
+ @@
88
+ -alpha
89
+ +omega
90
+ keep
91
+ *** End Patch
92
+ `,
93
+ }, dir, { sessionId });
94
+ assert.match(String(result), /^Applied 1 File \(Native\)/);
95
+
96
+ const reread = await executeBuiltinTool('read', { path: file }, dir, { sessionId });
97
+ assert.equal(reread, `[file unchanged: ${file.replaceAll('\\', '/')}]`);
98
+ });
99
+
60
100
  test('stale read snapshot does not block a still-unique current old_string', async (t) => {
61
101
  const { dir, file } = makeTempFile('alpha\nkeep\n');
62
102
  const sessionId = `edit-stale-safe-${process.pid}`;