mixdog 0.9.134 → 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 (133) 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/headless-exec.mjs +26 -9
  6. package/src/headless-exec.test.mjs +55 -2
  7. package/src/help.mjs +0 -2
  8. package/src/rules/lead/01-general.md +0 -4
  9. package/src/rules/shared/01-tool.md +12 -4
  10. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +30 -4
  11. package/src/runtime/agent/orchestrator/agent-trace-timing.test.mjs +57 -0
  12. package/src/runtime/agent/orchestrator/session/cache/prefetch-cache.mjs +3 -3
  13. package/src/runtime/agent/orchestrator/session/cache/prefetch-cache.test.mjs +40 -0
  14. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +7 -0
  15. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +5 -2
  16. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +5 -0
  17. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +15 -6
  18. package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +25 -4
  19. package/src/runtime/agent/orchestrator/tools/builtin/edit-sequential-occupation.test.mjs +40 -0
  20. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-pattern-fanout.mjs +30 -7
  21. package/src/runtime/agent/orchestrator/tools/builtin/lib/shell-job-records.mjs +1 -1
  22. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +150 -41
  23. package/src/runtime/agent/orchestrator/tools/builtin/local-search-telemetry.mjs +4 -0
  24. package/src/runtime/agent/orchestrator/tools/builtin/native-search-client.mjs +60 -0
  25. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +18 -1
  26. package/src/runtime/agent/orchestrator/tools/builtin/read-streaming.mjs +10 -9
  27. package/src/runtime/agent/orchestrator/tools/builtin/search-glob-tool.mjs +138 -19
  28. package/src/runtime/agent/orchestrator/tools/builtin/search-glob-top-k.test.mjs +39 -0
  29. package/src/runtime/agent/orchestrator/tools/builtin/search-grep-tool.mjs +7 -1
  30. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +4 -1
  31. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +80 -3
  32. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +82 -5
  33. package/src/runtime/agent/orchestrator/tools/code-graph/graph-binary.mjs +2 -2
  34. package/src/runtime/agent/orchestrator/tools/env-scrub.mjs +3 -19
  35. package/src/runtime/agent/orchestrator/tools/env-scrub.test.mjs +25 -0
  36. package/src/runtime/agent/orchestrator/tools/graph-manifest.json +11 -11
  37. package/src/runtime/agent/orchestrator/tools/lib/native-spawn-client.mjs +165 -17
  38. package/src/runtime/agent/orchestrator/tools/lib/shell-warm-standby.mjs +162 -0
  39. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +36 -8
  40. package/src/runtime/agent/orchestrator/tools/shell-exec-output.mjs +87 -0
  41. package/src/runtime/agent/orchestrator/tools/spawn-manifest.json +11 -11
  42. package/src/runtime/channels/lib/config.mjs +14 -101
  43. package/src/runtime/channels/lib/index-drop-trace.mjs +2 -5
  44. package/src/runtime/channels/lib/owned-runtime.mjs +4 -80
  45. package/src/runtime/channels/lib/scheduler.mjs +75 -18
  46. package/src/runtime/channels/lib/status-snapshot.mjs +5 -68
  47. package/src/runtime/channels/lib/tool-dispatch.mjs +12 -55
  48. package/src/runtime/channels/lib/worker-ipc.mjs +1 -16
  49. package/src/runtime/channels/lib/worker-main.mjs +22 -307
  50. package/src/runtime/media/renditions.mjs +68 -5
  51. package/src/runtime/media/renditions.test.mjs +45 -0
  52. package/src/runtime/media/store.mjs +1 -0
  53. package/src/runtime/memory/index.mjs +4 -3
  54. package/src/runtime/memory/lib/cycle-scheduler.mjs +17 -6
  55. package/src/runtime/memory/lib/memory-cycle-packets.test.mjs +7 -1
  56. package/src/runtime/memory/lib/memory-cycle-requests.mjs +13 -0
  57. package/src/runtime/memory/lib/query-handlers.mjs +1 -0
  58. package/src/runtime/shared/config.mjs +8 -49
  59. package/src/runtime/shared/schedule-time.mjs +25 -0
  60. package/src/runtime/shared/schedule-time.test.mjs +29 -0
  61. package/src/runtime/shared/schedules-db.mjs +73 -1
  62. package/src/runtime/shared/tool-card-model.mjs +1 -1
  63. package/src/runtime/shared/transcript-writer.mjs +10 -17
  64. package/src/runtime/shared/update-checker.mjs +7 -2
  65. package/src/runtime/shared/update-checker.test.mjs +33 -0
  66. package/src/session-runtime/channel-config-api.mjs +3 -20
  67. package/src/session-runtime/config-lifecycle.mjs +1 -42
  68. package/src/session-runtime/lifecycle-api.mjs +10 -21
  69. package/src/session-runtime/model-route-api.mjs +0 -2
  70. package/src/session-runtime/model-settings-persist.test.mjs +0 -1
  71. package/src/session-runtime/prewarm.mjs +11 -7
  72. package/src/session-runtime/prewarm.test.mjs +0 -1
  73. package/src/session-runtime/runtime-core.mjs +20 -186
  74. package/src/session-runtime/session-lifecycle.mjs +4 -17
  75. package/src/session-runtime/session-transcript.mjs +54 -0
  76. package/src/session-runtime/session-turn-api.mjs +4 -44
  77. package/src/session-runtime/settings-api.mjs +0 -45
  78. package/src/session-runtime/tool-catalog-schema.mjs +1 -1
  79. package/src/session-runtime/tool-policy-surface.test.mjs +4 -0
  80. package/src/standalone/channel-admin.mjs +8 -150
  81. package/src/standalone/channel-transport.mjs +13 -0
  82. package/src/standalone/daemon.mjs +19 -49
  83. package/src/standalone/seeds.mjs +18 -1
  84. package/src/standalone/session-protocol.mjs +0 -8
  85. package/src/standalone/session-service.mjs +1 -3
  86. package/src/tui/App.jsx +0 -5
  87. package/src/tui/app/app-view.jsx +1 -2
  88. package/src/tui/app/create-app-pickers.mjs +0 -20
  89. package/src/tui/app/doctor.mjs +2 -6
  90. package/src/tui/app/prompt-submit.mjs +2 -49
  91. package/src/tui/app/settings-picker.mjs +22 -96
  92. package/src/tui/app/slash-commands.mjs +0 -2
  93. package/src/tui/app/slash-dispatch.mjs +0 -17
  94. package/src/tui/app/use-prompt-handlers.mjs +4 -1
  95. package/src/tui/components/StatusLine.jsx +4 -4
  96. package/src/tui/dist/index.mjs +1003 -1461
  97. package/src/tui/session/context-state.mjs +0 -1
  98. package/src/tui/session/queue-helpers.mjs +1 -1
  99. package/src/tui/session/queue-helpers.test.mjs +10 -1
  100. package/src/tui/session/session-api-ext.mjs +0 -89
  101. package/src/tui/session/session-api.mjs +0 -2
  102. package/src/tui/session/tool-card-results.mjs +1 -1
  103. package/src/tui/session/tool-result-status.mjs +3 -3
  104. package/src/tui/session/tool-result-status.test.mjs +3 -3
  105. package/src/tui/session-local.mjs +0 -26
  106. package/src/workflows/default/WORKFLOW.md +13 -15
  107. package/src/workflows/solo/WORKFLOW.md +6 -7
  108. package/src/runtime/channels/lib/config-lock.mjs +0 -13
  109. package/src/runtime/channels/lib/drop-trace.mjs +0 -71
  110. package/src/runtime/channels/lib/format.mjs +0 -190
  111. package/src/runtime/channels/lib/inbound-handler.mjs +0 -387
  112. package/src/runtime/channels/lib/inbound-routing.mjs +0 -81
  113. package/src/runtime/channels/lib/interaction-handlers.mjs +0 -70
  114. package/src/runtime/channels/lib/interaction-workflows.mjs +0 -76
  115. package/src/runtime/channels/lib/memory-client.mjs +0 -289
  116. package/src/runtime/channels/lib/output-forwarder.mjs +0 -934
  117. package/src/runtime/channels/lib/session-control.mjs +0 -36
  118. package/src/runtime/channels/lib/session-discovery.mjs +0 -217
  119. package/src/runtime/channels/lib/session-discovery.test.mjs +0 -32
  120. package/src/runtime/channels/lib/telegram-format.mjs +0 -280
  121. package/src/runtime/channels/lib/tool-format.mjs +0 -125
  122. package/src/runtime/channels/lib/transcript-binding.mjs +0 -336
  123. package/src/runtime/channels/lib/transcript-discovery.mjs +0 -208
  124. package/src/runtime/channels/providers/discord-access.mjs +0 -30
  125. package/src/runtime/channels/providers/discord-attachments.mjs +0 -69
  126. package/src/runtime/channels/providers/discord-gateway.mjs +0 -230
  127. package/src/runtime/channels/providers/discord.mjs +0 -730
  128. package/src/runtime/channels/providers/telegram.mjs +0 -598
  129. package/src/runtime/channels/providers/telegram.test.mjs +0 -75
  130. package/src/session-runtime/remote-control.mjs +0 -126
  131. package/src/session-runtime/remote-transcript.mjs +0 -134
  132. package/src/session-runtime/remote-transition-queue.mjs +0 -14
  133. 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.134",
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
  ```
@@ -583,6 +583,7 @@ export async function runHeadlessExec({
583
583
  let runtime = null;
584
584
  let signalCleanup = null;
585
585
  let unsubscribeNotification = null;
586
+ let completionPending = false;
586
587
  let cleanupPromise = null;
587
588
  let result = null;
588
589
  let resultText = '';
@@ -644,18 +645,25 @@ export async function runHeadlessExec({
644
645
  toolMode: 'full',
645
646
  approvalMode: 'implicit',
646
647
  disallowDelegation: true,
648
+ autoWakeCompletions: false,
647
649
  initialConfig: boundary.loadConfig(),
648
650
  });
649
651
  if (lifecycle && !clean(runtime?.id) && typeof runtime?.reserveSessionId === 'function') {
650
652
  runtime.reserveSessionId(lifecycle.threadId);
651
653
  }
652
654
  lifecycle?.start(runtime);
653
- if (lifecycle && typeof runtime?.onNotification === 'function') {
655
+ if (typeof runtime?.onNotification === 'function') {
654
656
  unsubscribeNotification = runtime.onNotification(
655
- (event) => lifecycle.onNotification(event),
657
+ (event) => {
658
+ lifecycle?.onNotification(event);
659
+ const status = clean(event?.meta?.status).toLowerCase();
660
+ if (['completed', 'failed', 'cancelled', 'canceled', 'timed_out'].includes(status)) {
661
+ completionPending = true;
662
+ }
663
+ },
656
664
  );
657
665
  }
658
- ({ result } = await runtime.ask(prompt, {
666
+ const askOptions = {
659
667
  onTextReset: () => true,
660
668
  onUsageDelta: (delta) => {
661
669
  applyUsageDelta(stats, delta);
@@ -672,13 +680,22 @@ export async function runHeadlessExec({
672
680
  onToolPhaseCompleted: (detail) => lifecycle.onToolBatchCompleted(detail),
673
681
  onStageChange: (stage, detail) => lifecycle.onStageChange(stage, detail),
674
682
  } : {}),
675
- }));
676
- await waitForTrackedTasks({
677
- sessionId: runtime.id,
683
+ };
684
+ ({ result } = await runtime.ask(prompt, askOptions));
685
+ const taskScope = {
686
+ ...(clean(runtime.id) ? { callerSessionId: clean(runtime.id) } : {}),
678
687
  clientHostPid: runtime.clientHostPid,
679
- hasActiveTasks,
680
- pollMs: idlePollMs,
681
- });
688
+ };
689
+ while (completionPending || hasActiveTasks(taskScope)) {
690
+ await waitForTrackedTasks({
691
+ sessionId: runtime.id,
692
+ clientHostPid: runtime.clientHostPid,
693
+ hasActiveTasks,
694
+ pollMs: idlePollMs,
695
+ });
696
+ completionPending = false;
697
+ ({ result } = await runtime.ask('', askOptions));
698
+ }
682
699
  resultText = String(result?.content ?? result?.text ?? '');
683
700
  if (!json && resultText) {
684
701
  write(resultText.endsWith('\n') ? resultText : `${resultText}\n`);
@@ -90,7 +90,7 @@ test('headless exec runs one implicit-approval session and waits for tracked tas
90
90
  hasActiveTasks: (scope) => {
91
91
  activeScopes.push(scope);
92
92
  activeChecks += 1;
93
- return activeChecks === 1;
93
+ return false;
94
94
  },
95
95
  installSignalCleanupFn: () => ({ uninstall() {} }),
96
96
  });
@@ -100,6 +100,7 @@ test('headless exec runs one implicit-approval session and waits for tracked tas
100
100
  assert.deepEqual(errors, []);
101
101
  assert.equal(runtimeOptions[0].approvalMode, 'implicit');
102
102
  assert.equal(runtimeOptions[0].disallowDelegation, true);
103
+ assert.equal(runtimeOptions[0].autoWakeCompletions, false);
103
104
  assert.equal(runtimeOptions[0].toolMode, 'full');
104
105
  assert.deepEqual(activeScopes[0], {
105
106
  callerSessionId: 'sess_exec_test',
@@ -121,6 +122,58 @@ test('headless exec runs one implicit-approval session and waits for tracked tas
121
122
  }
122
123
  });
123
124
 
125
+ test('headless exec drains tracked work and returns the completion follow-up answer', async () => {
126
+ const output = [];
127
+ const errors = [];
128
+ const prompts = [];
129
+ let active = true;
130
+ let notificationListener = null;
131
+ const code = await runHeadlessExec({
132
+ message: 'start the long task',
133
+ provider: 'openai-oauth',
134
+ model: 'gpt-test',
135
+ usageLogPath: '',
136
+ idlePollMs: 1,
137
+ write: (text) => output.push(text),
138
+ writeErr: (text) => errors.push(text),
139
+ boundaryFactory: () => ({
140
+ loadConfig: () => ({ providers: { 'openai-oauth': { enabled: true } } }),
141
+ cleanup() {},
142
+ }),
143
+ runtimeFactory: async () => ({
144
+ id: 'sess_headless_drain',
145
+ model: 'gpt-test',
146
+ clientHostPid: 123,
147
+ onNotification(listener) {
148
+ notificationListener = listener;
149
+ return () => { notificationListener = null; };
150
+ },
151
+ async ask(prompt) {
152
+ prompts.push(prompt);
153
+ if (prompts.length === 1) {
154
+ setTimeout(() => {
155
+ active = false;
156
+ notificationListener?.({
157
+ content: 'background task completed',
158
+ meta: { status: 'completed', execution_id: 'job_drain' },
159
+ });
160
+ }, 10);
161
+ return { result: { content: 'task started' } };
162
+ }
163
+ return { result: { content: 'final result' } };
164
+ },
165
+ async close() {},
166
+ }),
167
+ hasActiveTasks: () => active,
168
+ installSignalCleanupFn: () => ({ uninstall() {} }),
169
+ });
170
+
171
+ assert.equal(code, 0);
172
+ assert.deepEqual(errors, []);
173
+ assert.deepEqual(prompts, ['start the long task', '']);
174
+ assert.deepEqual(output, ['final result\n']);
175
+ });
176
+
124
177
  test('headless exec emits a timestamped JSONL lifecycle and exact tool count', async () => {
125
178
  const root = mkdtempSync(join(tmpdir(), 'mixdog-headless-json-test-'));
126
179
  const usageLogPath = join(root, 'usage.json');
@@ -199,7 +252,7 @@ test('headless exec emits a timestamped JSONL lifecycle and exact tool count', a
199
252
  options.onToolPhaseCompleted({ iteration: 1, calls: 1, elapsedMs: 2 });
200
253
  notificationListener?.({
201
254
  content: 'background task completed',
202
- meta: { status: 'completed' },
255
+ meta: { status: 'progress' },
203
256
  });
204
257
  options.onProviderSendStarted();
205
258
  options.onAssistantText('done');
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.
@@ -1,5 +1,7 @@
1
1
  # Tool Use
2
2
 
3
+ - When an internal Mixdog rule conflicts with the user's latest explicit
4
+ request, follow the user's request.
3
5
  - Baseline routing assigns each facet directly by the evidence needed to
4
6
  determine the complete answer or edit:
5
7
  path/name only→`find`; wildcard/recursive paths→`glob` (including known-root
@@ -39,7 +41,12 @@
39
41
  a file to rebuild context or confirm an edit, no rerun of a passed check.
40
42
  A hole (needed content absent and not reconstructable) is fetched once;
41
43
  a change re-opens only that hole. Returned output is fully mined before the
42
- 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
43
50
  usages and ends that facet; values/locations end at the context grep
44
51
  returns; `read` covers only what returned spans cannot, as an anchored
45
52
  offset/limit window. Already obtained hunk text is any visible span —
@@ -71,9 +78,10 @@
71
78
  and buildability invariants. Treat mutable behavior, UX, exact text,
72
79
  snapshots, and implementation shape as advisory specifications; update them
73
80
  when the requested behavior changes instead of preserving obsolete behavior.
74
- - A successful verification closes the task unless later changes affect it.
75
- Rerun a failed action only after its inputs or subject changes; otherwise
76
- 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.
77
85
  - If inspection can change evidence or durable state, use read-only means;
78
86
  mutate only when the deliverable requires it, first preserving evidence
79
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;
@@ -655,10 +655,10 @@ function guardRead(a) {
655
655
  }
656
656
 
657
657
  function guardShell(a) {
658
- const allowed = new Set(['command', 'timeout_ms']);
658
+ const allowed = new Set(['command', 'timeout_ms', 'run_in_background']);
659
659
  const unsupported = Object.keys(a).find((key) => !allowed.has(key));
660
660
  if (unsupported) {
661
- return `Error: shell arg "${unsupported}" is unsupported; use only command and timeout_ms`;
661
+ return `Error: shell arg "${unsupported}" is unsupported; use only command, timeout_ms, and run_in_background`;
662
662
  }
663
663
  if (!hasOwn(a, 'command')) {
664
664
  return 'Error: shell requires "command"';
@@ -672,6 +672,9 @@ function guardShell(a) {
672
672
  if (hasOwn(a, 'timeout_ms') && (typeof a.timeout_ms !== 'number' || !Number.isFinite(a.timeout_ms) || a.timeout_ms < 0)) {
673
673
  return `Error: shell arg "timeout_ms" must be a non-negative number (got ${describeType(a.timeout_ms)})`;
674
674
  }
675
+ if (hasOwn(a, 'run_in_background') && typeof a.run_in_background !== 'boolean') {
676
+ return `Error: shell arg "run_in_background" must be a boolean (got ${describeType(a.run_in_background)})`;
677
+ }
675
678
  return null;
676
679
  }
677
680
 
@@ -469,6 +469,10 @@ export async function executeBashTool(args, workDir, options = {}) {
469
469
  const _bgTasksDisabled = /^(1|true|yes|on)$/i.test(
470
470
  String(process.env.MIXDOG_SHELL_DISABLE_BACKGROUND_TASKS || '').trim(),
471
471
  );
472
+ const runInBackground = args.run_in_background === true;
473
+ if (runInBackground && _bgTasksDisabled) {
474
+ return formatShellToolFailure('background tasks are disabled for this process');
475
+ }
472
476
 
473
477
  let shellEffects;
474
478
  let combinedBashAbort = null;
@@ -603,6 +607,7 @@ export async function executeBashTool(args, workDir, options = {}) {
603
607
  timeoutMs: timeout,
604
608
  abortSignal: combinedBashAbort.signal,
605
609
  autoBackgroundMs,
610
+ startInBackground: runInBackground,
606
611
  // On a foreground timeout, promote the still-running child to a
607
612
  // tracked background job only when an explicit deadline has
608
613
  // remaining budget; omitted deadlines may stay unlimited.