mixdog 0.9.14 → 0.9.16

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 (125) hide show
  1. package/package.json +1 -1
  2. package/scripts/bench/cache-probe-tasks.json +1 -1
  3. package/scripts/bench/lead-review-tasks-r3.json +1 -1
  4. package/scripts/bench/r4-mixed-tasks.json +1 -1
  5. package/scripts/bench/review-tasks.json +1 -1
  6. package/scripts/build-runtime-windows.ps1 +242 -242
  7. package/scripts/explore-bench.mjs +5 -4
  8. package/scripts/ingest-pure-conversation-smoke.mjs +27 -0
  9. package/scripts/output-style-smoke.mjs +3 -3
  10. package/scripts/recall-usecase-cases.json +1 -1
  11. package/scripts/smoke-runtime-negative.ps1 +106 -106
  12. package/scripts/termio-input-smoke.mjs +199 -0
  13. package/scripts/tool-efficiency-diag.mjs +88 -0
  14. package/scripts/tool-smoke.mjs +40 -24
  15. package/scripts/tui-frame-harness-shim.mjs +32 -0
  16. package/scripts/tui-frame-harness.mjs +306 -0
  17. package/src/agents/heavy-worker/AGENT.md +6 -7
  18. package/src/agents/worker/AGENT.md +6 -7
  19. package/src/lib/keychain-cjs.cjs +28 -11
  20. package/src/lib/rules-builder.cjs +6 -10
  21. package/src/mixdog-session-runtime.mjs +90 -20
  22. package/src/output-styles/simple.md +22 -24
  23. package/src/rules/agent/00-core.md +12 -11
  24. package/src/rules/agent/30-explorer.md +22 -21
  25. package/src/rules/lead/01-general.md +8 -8
  26. package/src/rules/lead/02-channels.md +3 -3
  27. package/src/rules/lead/lead-brief.md +11 -12
  28. package/src/rules/shared/01-tool.md +25 -14
  29. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +4 -3
  30. package/src/runtime/agent/orchestrator/config.mjs +1 -1
  31. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +22 -1
  32. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +21 -1
  33. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +29 -8
  34. package/src/runtime/agent/orchestrator/providers/gemini.mjs +13 -5
  35. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +1 -1
  36. package/src/runtime/agent/orchestrator/providers/model-list-sanitize.mjs +11 -0
  37. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +18 -0
  38. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +1 -1
  39. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +30 -2
  40. package/src/runtime/agent/orchestrator/session/cache/scoped-cache.mjs +19 -0
  41. package/src/runtime/agent/orchestrator/session/compact/constants.mjs +2 -2
  42. package/src/runtime/agent/orchestrator/session/compact/engine.mjs +1 -1
  43. package/src/runtime/agent/orchestrator/session/compact/summary.mjs +37 -15
  44. package/src/runtime/agent/orchestrator/session/context-utils.mjs +55 -0
  45. package/src/runtime/agent/orchestrator/session/lifecycle-scan.mjs +177 -0
  46. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +12 -19
  47. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +10 -0
  48. package/src/runtime/agent/orchestrator/session/loop.mjs +22 -1
  49. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +8 -0
  50. package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +13 -18
  51. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +5 -1
  52. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +4 -1
  53. package/src/runtime/agent/orchestrator/session/manager.mjs +59 -2
  54. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +18 -0
  55. package/src/runtime/agent/orchestrator/session/store.mjs +33 -25
  56. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +1 -1
  57. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +7 -5
  58. package/src/runtime/agent/orchestrator/tools/builtin/path-diagnostics.mjs +38 -1
  59. package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +24 -0
  60. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +2 -1
  61. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +14 -1
  62. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +25 -1
  63. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +73 -3
  64. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +23 -10
  65. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +55 -0
  66. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +21 -13
  67. package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +13 -2
  68. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +44 -6
  69. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +5 -0
  70. package/src/runtime/channels/backends/telegram.mjs +29 -9
  71. package/src/runtime/channels/lib/event-queue.mjs +6 -2
  72. package/src/runtime/channels/lib/memory-client.mjs +66 -1
  73. package/src/runtime/channels/lib/webhook/deliveries.mjs +22 -1
  74. package/src/runtime/memory/index.mjs +95 -8
  75. package/src/runtime/memory/lib/cycle-scheduler.mjs +24 -5
  76. package/src/runtime/memory/lib/memory-cycle1.mjs +45 -3
  77. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +7 -3
  78. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +54 -10
  79. package/src/runtime/memory/lib/memory-cycle2.mjs +21 -8
  80. package/src/runtime/memory/lib/memory-embed.mjs +48 -0
  81. package/src/runtime/memory/lib/memory-recall-store.mjs +57 -13
  82. package/src/runtime/memory/lib/memory.mjs +88 -1
  83. package/src/runtime/memory/lib/session-ingest.mjs +34 -3
  84. package/src/runtime/memory/lib/trace-store.mjs +52 -3
  85. package/src/runtime/memory/lib/transcript-ingest.mjs +27 -4
  86. package/src/runtime/shared/child-guardian.mjs +4 -2
  87. package/src/runtime/shared/open-url.mjs +2 -1
  88. package/src/runtime/shared/singleton-owner.mjs +26 -0
  89. package/src/runtime/shared/tool-execution-contract.mjs +25 -0
  90. package/src/runtime/shared/transcript-writer.mjs +3 -1
  91. package/src/session-runtime/config-helpers.mjs +7 -2
  92. package/src/session-runtime/cwd-plugins.mjs +6 -1
  93. package/src/session-runtime/effort.mjs +6 -1
  94. package/src/session-runtime/plugin-mcp.mjs +116 -12
  95. package/src/session-runtime/settings-api.mjs +7 -1
  96. package/src/standalone/channel-worker.mjs +25 -3
  97. package/src/standalone/explore-tool.mjs +5 -5
  98. package/src/standalone/hook-bus/config.mjs +38 -2
  99. package/src/standalone/hook-bus/handlers.mjs +103 -11
  100. package/src/standalone/hook-bus.mjs +20 -6
  101. package/src/standalone/memory-runtime-proxy.mjs +40 -5
  102. package/src/tui/App.jsx +214 -30
  103. package/src/tui/app/core-memory-picker.mjs +6 -6
  104. package/src/tui/app/model-options.mjs +10 -4
  105. package/src/tui/app/settings-picker.mjs +5 -30
  106. package/src/tui/app/slash-commands.mjs +0 -1
  107. package/src/tui/app/slash-dispatch.mjs +0 -16
  108. package/src/tui/app/text-layout.mjs +57 -0
  109. package/src/tui/app/transcript-window.mjs +53 -2
  110. package/src/tui/app/use-mouse-input.mjs +60 -47
  111. package/src/tui/app/use-transcript-window.mjs +38 -10
  112. package/src/tui/components/PromptInput.jsx +18 -1
  113. package/src/tui/components/TextEntryPanel.jsx +97 -33
  114. package/src/tui/dist/index.mjs +567 -229
  115. package/src/tui/engine/tool-card-results.mjs +22 -5
  116. package/src/tui/engine.mjs +126 -7
  117. package/src/tui/index.jsx +4 -1
  118. package/src/workflows/default/WORKFLOW.md +27 -31
  119. package/vendor/ink/build/components/App.js +62 -17
  120. package/vendor/ink/build/ink.js +78 -3
  121. package/vendor/ink/build/input-parser.d.ts +9 -4
  122. package/vendor/ink/build/input-parser.js +45 -176
  123. package/vendor/ink/build/log-update.js +47 -2
  124. package/vendor/ink/build/termio-keypress.js +240 -0
  125. package/vendor/ink/build/termio-tokenize.js +253 -0
@@ -15,4 +15,4 @@
15
15
  { "id": "uc5-yesterday", "label": "UC5 calendar yesterday", "args": { "period": "yesterday", "limit": 10 }, "expect": "browse" },
16
16
  { "id": "uc5-thisweek", "label": "UC5 calendar this_week", "args": { "period": "this_week", "limit": 10 }, "expect": "browse" },
17
17
  { "id": "uc6-category", "label": "UC6 category decision 7d", "args": { "period": "7d", "category": "decision", "limit": 10 }, "expect": "browse" }
18
- ]
18
+ ]
@@ -1,106 +1,106 @@
1
- # smoke-runtime-negative.ps1 — Negative-path tests against released win32-x64
2
- # runtime tarball. Validates: download, sha256, corrupt-tarball, fresh-extract
3
- # boot, double-init refusal, hostile-env survival.
4
- #
5
- # Env: TAG / OS / ARCH / RUNTIME_RELEASE_REPOSITORY (default tribgames/mixdog)
6
-
7
- $ErrorActionPreference = 'Stop'
8
-
9
- $Tag = if ($env:TAG) { $env:TAG } else { 'runtime-v0.4.0' }
10
- $Os = if ($env:OS) { $env:OS } else { 'win32' }
11
- $Arch = if ($env:ARCH) { $env:ARCH } else { 'x64' }
12
- $ReleaseRepo = if ($env:RUNTIME_RELEASE_REPOSITORY) { $env:RUNTIME_RELEASE_REPOSITORY } else { 'tribgames/mixdog' }
13
- $PgVer = '16.4'
14
- $PgvectorVer = '0.8.2'
15
-
16
- $Asset = "mixdog-runtime-${Os}-${Arch}-pg${PgVer}-pgvector${PgvectorVer}.tar.gz"
17
- $Url = "https://github.com/${ReleaseRepo}/releases/download/${Tag}/${Asset}"
18
-
19
- $Work = New-Item -ItemType Directory -Force -Path "$env:TEMP\smoke-$([guid]::NewGuid().ToString('N'))" | Select-Object -ExpandProperty FullName
20
-
21
- try {
22
- Write-Host "==> Negative-path smoke for ${Os}-${Arch} from $Url"
23
-
24
- Write-Host "==> Test 1: HEAD reaches release asset"
25
- $resp = Invoke-WebRequest -Uri $Url -Method Head -UseBasicParsing -ErrorAction Stop
26
- Write-Host " HTTP $($resp.StatusCode)"
27
-
28
- Write-Host "==> Test 2: full download + sha256"
29
- $TarPath = Join-Path $Work $Asset
30
- Invoke-WebRequest -Uri $Url -OutFile $TarPath -UseBasicParsing
31
- $sha = (Get-FileHash -Algorithm SHA256 $TarPath).Hash.ToLower()
32
- Write-Host " sha256=$sha"
33
-
34
- Write-Host "==> Test 3: corrupt tarball — flip a byte and ensure tar errors"
35
- $CorruptPath = "$Work\corrupt.tar.gz"
36
- Copy-Item $TarPath $CorruptPath
37
- $bytes = [byte[]]::new(1); $rand = [System.Random]::new(); $rand.NextBytes($bytes)
38
- $stream = [System.IO.File]::OpenWrite($CorruptPath)
39
- $stream.Position = 102400
40
- $stream.Write($bytes, 0, 1)
41
- $stream.Close()
42
- $CorruptDir = "$Work\corrupt"
43
- New-Item -ItemType Directory -Force -Path $CorruptDir | Out-Null
44
- $TarProc = Start-Process -FilePath tar -ArgumentList @('-xzf', $CorruptPath, '-C', $CorruptDir.Replace('\','/')) -Wait:$false -PassThru -WindowStyle Hidden
45
- if (-not $TarProc.WaitForExit(15000)) {
46
- Stop-Process -Id $TarProc.Id -Force -ErrorAction SilentlyContinue
47
- Write-Host " PASS: corrupted tarball extraction timed out and was stopped"
48
- } elseif ($TarProc.ExitCode -eq 0) {
49
- Write-Host " WARN: corrupted tarball extracted without error (tar gzip recovery)"
50
- } else {
51
- Write-Host " PASS: corrupted tarball rejected by tar (exit $($TarProc.ExitCode))"
52
- }
53
-
54
- Write-Host "==> Test 4: fresh-extract boot (extension load + distance query)"
55
- $FreshDir = "$Work\fresh"
56
- New-Item -ItemType Directory -Force -Path $FreshDir | Out-Null
57
- & tar -xzf $TarPath -C ($FreshDir.Replace('\','/'))
58
- if ($LASTEXITCODE -ne 0) { throw "fresh extract failed" }
59
-
60
- $PgBin = "$FreshDir\bin"
61
- $Data = "$FreshDir\pgdata"
62
- $Log = "$FreshDir\pg.log"
63
- $Port = 55897
64
-
65
- # Hostile env: minimal PATH (System32 only), no PGROOT/PGDATA
66
- $SavedPath = $env:PATH
67
- $SavedPgRoot = $env:PGROOT
68
- $env:PATH = "$env:SystemRoot\System32;$env:SystemRoot"
69
- $env:PGROOT = $null
70
- $env:PGDATA = $null
71
-
72
- try {
73
- & "$PgBin\postgres.exe" --version
74
- if ($LASTEXITCODE -ne 0) { throw "FAIL: postgres --version" }
75
- & "$PgBin\initdb.exe" -D $Data --username=postgres --auth-local=trust --no-locale -E UTF8 | Out-Null
76
- if ($LASTEXITCODE -ne 0) { throw "FAIL: initdb" }
77
- & "$PgBin\pg_ctl.exe" -D $Data -o "-p $Port -h 127.0.0.1" -l $Log -w start
78
- if ($LASTEXITCODE -ne 0) { Get-Content $Log | Select-Object -Last 30; throw "FAIL: pg_ctl start" }
79
-
80
- try {
81
- & "$PgBin\psql.exe" -h 127.0.0.1 -p $Port -U postgres -d postgres -c "CREATE EXTENSION vector;" | Out-Null
82
- if ($LASTEXITCODE -ne 0) { throw "FAIL: CREATE EXTENSION" }
83
- $ExtV = & "$PgBin\psql.exe" -h 127.0.0.1 -p $Port -U postgres -d postgres -tAc "SELECT extversion FROM pg_extension WHERE extname='vector';"
84
- if ($ExtV.Trim() -ne $PgvectorVer) { throw "FAIL: extversion='$ExtV' expected='$PgvectorVer'" }
85
- Write-Host " PASS: fresh-extract boot + vector extension"
86
- } finally {
87
- & "$PgBin\pg_ctl.exe" -D $Data -m fast stop 2>$null | Out-Null
88
- }
89
-
90
- Write-Host "==> Test 5: second initdb on initialized dir refused"
91
- $rc = (Start-Process -FilePath "$PgBin\initdb.exe" -ArgumentList "-D",$Data,"-U","postgres" -Wait -PassThru -NoNewWindow).ExitCode
92
- if ($rc -eq 0) {
93
- Write-Host " WARN: second initdb succeeded (unexpected)"
94
- } else {
95
- Write-Host " PASS: second initdb refused (exit $rc)"
96
- }
97
- } finally {
98
- $env:PATH = $SavedPath
99
- $env:PGROOT = $SavedPgRoot
100
- }
101
-
102
- Write-Host "==> All negative-path smokes: PASS"
103
- }
104
- finally {
105
- Remove-Item -Recurse -Force $Work -ErrorAction SilentlyContinue
106
- }
1
+ # smoke-runtime-negative.ps1 — Negative-path tests against released win32-x64
2
+ # runtime tarball. Validates: download, sha256, corrupt-tarball, fresh-extract
3
+ # boot, double-init refusal, hostile-env survival.
4
+ #
5
+ # Env: TAG / OS / ARCH / RUNTIME_RELEASE_REPOSITORY (default tribgames/mixdog)
6
+
7
+ $ErrorActionPreference = 'Stop'
8
+
9
+ $Tag = if ($env:TAG) { $env:TAG } else { 'runtime-v0.4.0' }
10
+ $Os = if ($env:OS) { $env:OS } else { 'win32' }
11
+ $Arch = if ($env:ARCH) { $env:ARCH } else { 'x64' }
12
+ $ReleaseRepo = if ($env:RUNTIME_RELEASE_REPOSITORY) { $env:RUNTIME_RELEASE_REPOSITORY } else { 'tribgames/mixdog' }
13
+ $PgVer = '16.4'
14
+ $PgvectorVer = '0.8.2'
15
+
16
+ $Asset = "mixdog-runtime-${Os}-${Arch}-pg${PgVer}-pgvector${PgvectorVer}.tar.gz"
17
+ $Url = "https://github.com/${ReleaseRepo}/releases/download/${Tag}/${Asset}"
18
+
19
+ $Work = New-Item -ItemType Directory -Force -Path "$env:TEMP\smoke-$([guid]::NewGuid().ToString('N'))" | Select-Object -ExpandProperty FullName
20
+
21
+ try {
22
+ Write-Host "==> Negative-path smoke for ${Os}-${Arch} from $Url"
23
+
24
+ Write-Host "==> Test 1: HEAD reaches release asset"
25
+ $resp = Invoke-WebRequest -Uri $Url -Method Head -UseBasicParsing -ErrorAction Stop
26
+ Write-Host " HTTP $($resp.StatusCode)"
27
+
28
+ Write-Host "==> Test 2: full download + sha256"
29
+ $TarPath = Join-Path $Work $Asset
30
+ Invoke-WebRequest -Uri $Url -OutFile $TarPath -UseBasicParsing
31
+ $sha = (Get-FileHash -Algorithm SHA256 $TarPath).Hash.ToLower()
32
+ Write-Host " sha256=$sha"
33
+
34
+ Write-Host "==> Test 3: corrupt tarball — flip a byte and ensure tar errors"
35
+ $CorruptPath = "$Work\corrupt.tar.gz"
36
+ Copy-Item $TarPath $CorruptPath
37
+ $bytes = [byte[]]::new(1); $rand = [System.Random]::new(); $rand.NextBytes($bytes)
38
+ $stream = [System.IO.File]::OpenWrite($CorruptPath)
39
+ $stream.Position = 102400
40
+ $stream.Write($bytes, 0, 1)
41
+ $stream.Close()
42
+ $CorruptDir = "$Work\corrupt"
43
+ New-Item -ItemType Directory -Force -Path $CorruptDir | Out-Null
44
+ $TarProc = Start-Process -FilePath tar -ArgumentList @('-xzf', $CorruptPath, '-C', $CorruptDir.Replace('\','/')) -Wait:$false -PassThru -WindowStyle Hidden
45
+ if (-not $TarProc.WaitForExit(15000)) {
46
+ Stop-Process -Id $TarProc.Id -Force -ErrorAction SilentlyContinue
47
+ Write-Host " PASS: corrupted tarball extraction timed out and was stopped"
48
+ } elseif ($TarProc.ExitCode -eq 0) {
49
+ Write-Host " WARN: corrupted tarball extracted without error (tar gzip recovery)"
50
+ } else {
51
+ Write-Host " PASS: corrupted tarball rejected by tar (exit $($TarProc.ExitCode))"
52
+ }
53
+
54
+ Write-Host "==> Test 4: fresh-extract boot (extension load + distance query)"
55
+ $FreshDir = "$Work\fresh"
56
+ New-Item -ItemType Directory -Force -Path $FreshDir | Out-Null
57
+ & tar -xzf $TarPath -C ($FreshDir.Replace('\','/'))
58
+ if ($LASTEXITCODE -ne 0) { throw "fresh extract failed" }
59
+
60
+ $PgBin = "$FreshDir\bin"
61
+ $Data = "$FreshDir\pgdata"
62
+ $Log = "$FreshDir\pg.log"
63
+ $Port = 55897
64
+
65
+ # Hostile env: minimal PATH (System32 only), no PGROOT/PGDATA
66
+ $SavedPath = $env:PATH
67
+ $SavedPgRoot = $env:PGROOT
68
+ $env:PATH = "$env:SystemRoot\System32;$env:SystemRoot"
69
+ $env:PGROOT = $null
70
+ $env:PGDATA = $null
71
+
72
+ try {
73
+ & "$PgBin\postgres.exe" --version
74
+ if ($LASTEXITCODE -ne 0) { throw "FAIL: postgres --version" }
75
+ & "$PgBin\initdb.exe" -D $Data --username=postgres --auth-local=trust --no-locale -E UTF8 | Out-Null
76
+ if ($LASTEXITCODE -ne 0) { throw "FAIL: initdb" }
77
+ & "$PgBin\pg_ctl.exe" -D $Data -o "-p $Port -h 127.0.0.1" -l $Log -w start
78
+ if ($LASTEXITCODE -ne 0) { Get-Content $Log | Select-Object -Last 30; throw "FAIL: pg_ctl start" }
79
+
80
+ try {
81
+ & "$PgBin\psql.exe" -h 127.0.0.1 -p $Port -U postgres -d postgres -c "CREATE EXTENSION vector;" | Out-Null
82
+ if ($LASTEXITCODE -ne 0) { throw "FAIL: CREATE EXTENSION" }
83
+ $ExtV = & "$PgBin\psql.exe" -h 127.0.0.1 -p $Port -U postgres -d postgres -tAc "SELECT extversion FROM pg_extension WHERE extname='vector';"
84
+ if ($ExtV.Trim() -ne $PgvectorVer) { throw "FAIL: extversion='$ExtV' expected='$PgvectorVer'" }
85
+ Write-Host " PASS: fresh-extract boot + vector extension"
86
+ } finally {
87
+ & "$PgBin\pg_ctl.exe" -D $Data -m fast stop 2>$null | Out-Null
88
+ }
89
+
90
+ Write-Host "==> Test 5: second initdb on initialized dir refused"
91
+ $rc = (Start-Process -FilePath "$PgBin\initdb.exe" -ArgumentList "-D",$Data,"-U","postgres" -Wait -PassThru -NoNewWindow).ExitCode
92
+ if ($rc -eq 0) {
93
+ Write-Host " WARN: second initdb succeeded (unexpected)"
94
+ } else {
95
+ Write-Host " PASS: second initdb refused (exit $rc)"
96
+ }
97
+ } finally {
98
+ $env:PATH = $SavedPath
99
+ $env:PGROOT = $SavedPgRoot
100
+ }
101
+
102
+ Write-Host "==> All negative-path smokes: PASS"
103
+ }
104
+ finally {
105
+ Remove-Item -Recurse -Force $Work -ErrorAction SilentlyContinue
106
+ }
@@ -0,0 +1,199 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * termio-input-smoke.mjs — throwaway smoke for the vendored termio input
4
+ * pipeline. Feeds synthetic chunk sequences through createInputParser() and
5
+ * asserts the typed event kinds / channel routing decisions.
6
+ *
7
+ * Run: node scripts/termio-input-smoke.mjs
8
+ */
9
+ import { createInputParser } from '../vendor/ink/build/input-parser.js';
10
+
11
+ let failures = 0;
12
+ function check(name, cond, detail) {
13
+ if (cond) {
14
+ console.log(`ok - ${name}`);
15
+ } else {
16
+ failures++;
17
+ console.log(`FAIL - ${name}${detail ? ' :: ' + detail : ''}`);
18
+ }
19
+ }
20
+
21
+ // Helper: push a list of chunks, collect all emitted events.
22
+ function run(chunks) {
23
+ const p = createInputParser();
24
+ const events = [];
25
+ for (const c of chunks) events.push(...p.push(c));
26
+ return { p, events };
27
+ }
28
+
29
+ // 1. ESC split across two chunks -> single arrow-up key, no lone escape.
30
+ {
31
+ const { events } = run(['\x1b', '[A']);
32
+ check('split ESC -> one up key',
33
+ events.length === 1 && events[0].kind === 'key' && events[0].name === 'up',
34
+ JSON.stringify(events));
35
+ }
36
+
37
+ // 2. SGR mouse click (button 0 press) -> ParsedMouse.
38
+ {
39
+ const { events } = run(['\x1b[<0;12;34M']);
40
+ const m = events[0];
41
+ check('SGR click -> mouse press',
42
+ events.length === 1 && m.kind === 'mouse' && m.action === 'press' &&
43
+ m.col === 12 && m.row === 34 && m.button === 0,
44
+ JSON.stringify(events));
45
+ }
46
+
47
+ // 3. SGR wheel-up (button 64) -> key wheelup (stays a key, routes to mouse chan).
48
+ {
49
+ const { events } = run(['\x1b[<64;5;5M']);
50
+ const k = events[0];
51
+ check('SGR wheel -> wheelup key',
52
+ events.length === 1 && k.kind === 'key' && k.name === 'wheelup',
53
+ JSON.stringify(events));
54
+ }
55
+
56
+ // 4. X10 mouse: CSI M + 3 payload bytes (wheel-up: Cb = 0x40+32 = 96 = '`').
57
+ {
58
+ const { events } = run(['\x1b[M\x60\x30\x30']);
59
+ const k = events[0];
60
+ check('X10 wheel -> wheelup key',
61
+ events.length === 1 && k.kind === 'key' && k.name === 'wheelup',
62
+ JSON.stringify(events));
63
+ }
64
+
65
+ // 5. Bracketed paste split across 3 chunks -> one pasted key with full body.
66
+ {
67
+ const { events } = run(['\x1b[200~hel', 'lo wor', 'ld\x1b[201~']);
68
+ const paste = events.find((e) => e.isPasted);
69
+ check('split paste -> one pasted key "hello world"',
70
+ events.length === 1 && paste && paste.sequence === 'hello world',
71
+ JSON.stringify(events));
72
+ }
73
+
74
+ // 6. Paste whose body ends with an X10 mouse tail `\x1b[M` — the ESC in the
75
+ // payload slot must NOT be consumed as X10 mouse; it stays paste content and
76
+ // PASTE_END still terminates the paste.
77
+ {
78
+ const { events } = run(['\x1b[200~abc\x1b[M12\x1b[201~']);
79
+ const paste = events.find((e) => e.isPasted);
80
+ check('paste with \\x1b[M tail stays paste',
81
+ events.length === 1 && paste && paste.sequence === 'abc\x1b[M12',
82
+ JSON.stringify(events));
83
+ }
84
+
85
+ // 7. Terminal response (DA1) -> ParsedResponse.
86
+ {
87
+ const { events } = run(['\x1b[?62;1c']);
88
+ const r = events[0];
89
+ check('DA1 -> response',
90
+ events.length === 1 && r.kind === 'response' && r.response.type === 'da1',
91
+ JSON.stringify(events));
92
+ }
93
+
94
+ // 8. Lone ESC buffered -> hasPendingEscape true; flushPendingEscape -> escape key.
95
+ {
96
+ const p = createInputParser();
97
+ const first = p.push('\x1b');
98
+ check('lone ESC buffers (no immediate events)', first.length === 0, JSON.stringify(first));
99
+ check('hasPendingEscape true for lone ESC', p.hasPendingEscape() === true);
100
+ const flushed = p.flushPendingEscape();
101
+ check('flushPendingEscape -> escape key',
102
+ Array.isArray(flushed) && flushed.length === 1 && flushed[0].name === 'escape',
103
+ JSON.stringify(flushed));
104
+ }
105
+
106
+ // 9. Incomplete paste-start marker must NOT arm the escape flush timer.
107
+ {
108
+ const p = createInputParser();
109
+ p.push('\x1b[200');
110
+ check('partial paste-start does not arm flush', p.hasPendingEscape() === false);
111
+ }
112
+
113
+ // 9b. Generic partial CSI (`\x1b[`, `\x1b[2`, `\x1b[20`) — none of these are
114
+ // paste-start prefixes/penultimate, so hasPendingEscape MUST arm to avoid
115
+ // buffering indefinitely.
116
+ {
117
+ for (const partial of ['\x1b[', '\x1b[2', '\x1b[20']) {
118
+ const p = createInputParser();
119
+ p.push(partial);
120
+ check(`generic partial CSI ${JSON.stringify(partial)} arms flush`,
121
+ p.hasPendingEscape() === true);
122
+ }
123
+ }
124
+
125
+ // 9c. `\x1b[200` and `\x1b[200~`-prefixed pending must NOT arm the flush
126
+ // (paste-start marker / its penultimate prefix complete on their own).
127
+ {
128
+ const p1 = createInputParser();
129
+ p1.push('\x1b[200');
130
+ check('"\\x1b[200" does not arm flush', p1.hasPendingEscape() === false);
131
+
132
+ const p2 = createInputParser();
133
+ p2.push('\x1b[200~hel'); // still inside paste body once PASTE_START token completes
134
+ check('paste-start-prefixed pending does not arm flush', p2.hasPendingEscape() === false);
135
+ }
136
+
137
+ // 9d. Held-backspace chunk splits into individual key events; `\r`/`\t` in
138
+ // text are NOT split.
139
+ {
140
+ const { events } = run(['abc\x7f\x7f\x7f']);
141
+ const names = events.map((e) => e.name || e.sequence);
142
+ check('abc + 3x backspace -> text key(s) + 3 separate backspace keys',
143
+ events.length === 4 &&
144
+ events[0].sequence === 'abc' &&
145
+ events.slice(1).every((e) => e.name === 'backspace' || e.sequence === '\x7f'),
146
+ JSON.stringify(names));
147
+ }
148
+ {
149
+ const { events } = run(['a\rb\tc']);
150
+ check('\\r and \\t inside text are not split (single key for whole run)',
151
+ events.length === 1 && events[0].sequence === 'a\rb\tc',
152
+ JSON.stringify(events));
153
+ }
154
+
155
+ // 10. Plain text -> key events, sequence carries the character.
156
+ {
157
+ const { events } = run(['a']);
158
+ check('plain char -> key "a"',
159
+ events.length === 1 && events[0].kind === 'key' && events[0].sequence === 'a',
160
+ JSON.stringify(events));
161
+ }
162
+
163
+ // 11. Consumer-level routing: synthetic 'mouse' channel events reach the
164
+ // use-mouse-input dispatch decisions exactly once each. We replicate the
165
+ // hook's branch keys (kind:'key' wheel with ctrl-mask from sequence vs.
166
+ // kind:'mouse' press) to guard against double-handling / mis-routing.
167
+ // MOUSE_CTRL_MASK = 16; wheel ctrl bit is read from the SGR button in
168
+ // the ParsedKey.sequence via /\x1b\[<(\d+);/ (no button field on wheel).
169
+ {
170
+ const MOUSE_CTRL_MASK = 16;
171
+ const WHEEL_SGR = /\x1b\[<(\d+);/;
172
+ // Feed real parser output so field shapes match production.
173
+ const wheelPlain = run(['\x1b[<64;5;5M']).events[0]; // wheelup, no ctrl
174
+ const wheelCtrl = run(['\x1b[<80;5;5M']).events[0]; // 64|16 -> ctrl wheelup
175
+ const click = run(['\x1b[<0;12;34M']).events[0]; // left press
176
+
177
+ const routes = { zoom: 0, scroll: 0, press: 0, ignored: 0 };
178
+ function route(event) {
179
+ if (!event || typeof event !== 'object') return;
180
+ if (event.kind === 'key') {
181
+ if (event.name !== 'wheelup' && event.name !== 'wheeldown') { routes.ignored++; return; }
182
+ const wm = WHEEL_SGR.exec(typeof event.sequence === 'string' ? event.sequence : '');
183
+ const ctrl = wm ? ((Number(wm[1]) & MOUSE_CTRL_MASK) !== 0) : false;
184
+ if (ctrl) routes.zoom++; else routes.scroll++;
185
+ return;
186
+ }
187
+ if (event.kind !== 'mouse') { routes.ignored++; return; }
188
+ const button = Number(event.button);
189
+ if ((button & 3) === 0 && event.action === 'press' && (button & 32) === 0) routes.press++;
190
+ }
191
+ for (const e of [wheelPlain, wheelCtrl, click]) route(e);
192
+ check('mouse routing: plain wheel -> scroll x1', routes.scroll === 1, JSON.stringify(routes));
193
+ check('mouse routing: ctrl wheel -> zoom x1', routes.zoom === 1, JSON.stringify(routes));
194
+ check('mouse routing: left press -> press x1 (no double)', routes.press === 1, JSON.stringify(routes));
195
+ check('mouse routing: nothing mis-routed', routes.ignored === 0, JSON.stringify(routes));
196
+ }
197
+
198
+ console.log(failures === 0 ? '\nALL PASS' : `\n${failures} FAILURE(S)`);
199
+ process.exit(failures === 0 ? 0 : 1);
@@ -0,0 +1,88 @@
1
+ #!/usr/bin/env node
2
+ // Tool-usage efficiency diag for worker/heavy-worker (before/after rule changes).
3
+ // Usage: node scripts/tool-efficiency-diag.mjs [--since 24h|<epoch-ms>|ISO]
4
+ import { readFileSync, existsSync } from 'node:fs';
5
+ import { homedir } from 'node:os';
6
+ import { resolve } from 'node:path';
7
+
8
+ function sinceArg() {
9
+ const i = process.argv.indexOf('--since');
10
+ const raw = i >= 0 ? process.argv[i + 1] : '24h';
11
+ const rel = String(raw).match(/^(\d+(?:\.\d+)?)(h|m|d)$/i);
12
+ if (rel) {
13
+ const mult = { m: 60_000, h: 3_600_000, d: 86_400_000 }[rel[2].toLowerCase()];
14
+ return Date.now() - Number(rel[1]) * mult;
15
+ }
16
+ if (/^\d+$/.test(raw)) return Number(raw);
17
+ const p = Date.parse(raw);
18
+ return Number.isFinite(p) ? p : Date.now() - 86_400_000;
19
+ }
20
+
21
+ const dataDir = process.env.MIXDOG_DATA_DIR || resolve(homedir(), '.mixdog', 'data');
22
+ const files = [resolve(dataDir, 'history', 'agent-trace.jsonl.1'), resolve(dataDir, 'history', 'agent-trace.jsonl')];
23
+ const since = sinceArg();
24
+ const rows = [];
25
+ for (const f of files) {
26
+ if (!existsSync(f)) continue;
27
+ for (const line of readFileSync(f, 'utf8').split(/\r?\n/)) {
28
+ if (!line) continue;
29
+ try { const r = JSON.parse(line); if ((r.ts || 0) >= since) rows.push(r); } catch {}
30
+ }
31
+ }
32
+ const F = (r, n) => (r[n] != null ? r[n] : (r.payload && r.payload[n] != null ? r.payload[n] : null));
33
+ const bySess = new Map();
34
+ for (const r of rows) {
35
+ const k = r.sessionId || r.session_id || '?';
36
+ if (!bySess.has(k)) bySess.set(k, []);
37
+ bySess.get(k).push(r);
38
+ }
39
+ console.log(`window since ${new Date(since).toISOString()}`);
40
+ for (const target of ['heavy-worker', 'worker']) {
41
+ let batch1 = 0, batchTot = 0, llmCalls = 0, sessions = 0, toolCalls = 0;
42
+ let grepContent = 0, grepContentWithCtx = 0, grepThenRead = 0, rereads = 0, outTok = 0;
43
+ for (const [, rs] of bySess) {
44
+ let agent = null;
45
+ for (const r of rs) { const x = F(r, 'agent'); if (x) { agent = x; break; } }
46
+ if (agent !== target) continue;
47
+ sessions++;
48
+ rs.sort((a, b) => (a.ts || 0) - (b.ts || 0));
49
+ for (const b of rs.filter((r) => r.kind === 'batch')) {
50
+ const c = Number(F(b, 'tool_call_count')) || 0;
51
+ batchTot++; if (c === 1) batch1++;
52
+ }
53
+ const usage = rs.filter((r) => r.kind === 'usage_raw');
54
+ llmCalls += usage.length;
55
+ outTok += usage.reduce((s, r) => s + (Number(F(r, 'output_tokens')) || 0), 0);
56
+ const tools = rs.filter((r) => r.kind === 'tool');
57
+ toolCalls += tools.length;
58
+ const seenRead = new Map();
59
+ for (let i = 0; i < tools.length; i++) {
60
+ const t = tools[i];
61
+ const n = String(F(t, 'tool_name'));
62
+ const a = F(t, 'tool_args_summary') || {};
63
+ if (n === 'read') {
64
+ const p = typeof a.path === 'string' ? a.path : JSON.stringify(a.path);
65
+ seenRead.set(p, (seenRead.get(p) || 0) + 1);
66
+ }
67
+ if (n === 'grep' && ['content', 'content_with_context'].includes(String(a.output_mode || ''))) {
68
+ grepContent++;
69
+ if (a['-C'] != null || a['-A'] != null || a['-B'] != null) grepContentWithCtx++;
70
+ const gpath = typeof a.path === 'string' ? a.path : JSON.stringify(a.path || '');
71
+ for (let j = i + 1; j < Math.min(i + 4, tools.length); j++) {
72
+ const u = tools[j];
73
+ if (String(F(u, 'tool_name')) !== 'read') continue;
74
+ const ra = F(u, 'tool_args_summary') || {};
75
+ const rpath = typeof ra.path === 'string' ? ra.path : JSON.stringify(ra.path || '');
76
+ if (rpath && gpath && (rpath.includes(gpath) || gpath.includes(rpath))) { grepThenRead++; break; }
77
+ }
78
+ }
79
+ }
80
+ for (const [, c] of seenRead) if (c >= 3) rereads++;
81
+ }
82
+ const pct = (a, b) => (b ? Math.round((a / b) * 100) : 0);
83
+ console.log(`\n${target}: sessions=${sessions} llmCalls=${llmCalls} toolCalls=${toolCalls} outTok=${outTok}`);
84
+ console.log(` llmCalls/session=${sessions ? Math.round(llmCalls / sessions) : 0} tools/llmCall=${llmCalls ? (toolCalls / llmCalls).toFixed(2) : '-'}`);
85
+ console.log(` single-call batches: ${batch1}/${batchTot} (${pct(batch1, batchTot)}%)`);
86
+ console.log(` content greps with -C/-A/-B: ${grepContentWithCtx}/${grepContent} (${pct(grepContentWithCtx, grepContent)}%) grep->read follow-ups: ${grepThenRead}`);
87
+ console.log(` same-path re-reads >=3x: ${rereads}`);
88
+ }
@@ -42,6 +42,7 @@ import { mergeSessionRowsIntoGlobal } from '../src/runtime/memory/lib/memory-ses
42
42
  import { TOOL_DEFS as SEARCH_TOOL_DEFS } from '../src/runtime/search/tool-defs.mjs';
43
43
  import { TOOL_DEFS as CHANNEL_TOOL_DEFS } from '../src/runtime/channels/tool-defs.mjs';
44
44
  import { AGENT_OWNER } from '../src/runtime/agent/orchestrator/agent-owner.mjs';
45
+ import { recursiveWrapperToolNameForPublicAgent } from '../src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs';
45
46
  import { composeSystemPrompt } from '../src/runtime/agent/orchestrator/context/collect.mjs';
46
47
  import { setInternalToolsProvider } from '../src/runtime/agent/orchestrator/internal-tools.mjs';
47
48
  import { prepareAgentSession } from '../src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs';
@@ -386,7 +387,7 @@ assertOk('glob explicit src', explicitSrcGlobOut, /src[\\/].*engine\.mjs/i);
386
387
 
387
388
  const globPathOnlyOut = await executeBuiltinTool('glob', {
388
389
  path: 'scripts',
389
- head_limit: 8,
390
+ head_limit: 200,
390
391
  }, root);
391
392
  assertOk('glob path-only default *', globPathOnlyOut, /tool-smoke\.mjs/i);
392
393
 
@@ -398,8 +399,8 @@ const grepNoPatternGlobOut = await executeBuiltinTool('grep', {
398
399
  assertOk('grep without pattern routes to glob', grepNoPatternGlobOut, /tool-smoke\.mjs/i);
399
400
 
400
401
  const grepManyPatterns = [
401
- ...Array.from({ length: 20 }, (_, i) => `__tool_smoke_miss_${i}__`),
402
402
  'tool-smoke',
403
+ ...Array.from({ length: 20 }, (_, i) => `__tool_smoke_miss_${i}__`),
403
404
  ];
404
405
  const grepManyPatternsOut = await executeBuiltinTool('grep', {
405
406
  pattern: grepManyPatterns,
@@ -407,12 +408,12 @@ const grepManyPatternsOut = await executeBuiltinTool('grep', {
407
408
  glob: '*.mjs',
408
409
  head_limit: 5,
409
410
  }, root);
410
- if (/exceeds the 20-pattern cap/i.test(String(grepManyPatternsOut))) {
411
- throw new Error(`grep should chunk >20 patterns instead of error:\n${grepManyPatternsOut.slice(0, 400)}`);
411
+ if (/exceeds the \d+-pattern cap/i.test(String(grepManyPatternsOut))) {
412
+ throw new Error(`grep should truncate oversized pattern[] instead of error:\n${grepManyPatternsOut.slice(0, 400)}`);
412
413
  }
413
- assertOk('grep >20 pattern chunk merge', grepManyPatternsOut, /tool-smoke\.mjs/i);
414
- if (!/\[pattern set split into 2 chunks\]/.test(String(grepManyPatternsOut))) {
415
- throw new Error(`grep >20 patterns should emit chunk advisory:\n${grepManyPatternsOut.slice(0, 400)}`);
414
+ assertOk('grep >10 pattern cap keeps first patterns', grepManyPatternsOut, /tool-smoke\.mjs/i);
415
+ if (!/\[capped at 10 of 21 patterns\]/.test(String(grepManyPatternsOut))) {
416
+ throw new Error(`grep >10 patterns should emit cap note:\n${grepManyPatternsOut.slice(0, 400)}`);
416
417
  }
417
418
 
418
419
  function grepCountTotalMatches(body) {
@@ -431,9 +432,8 @@ if (singleCountTotal == null || singleCountTotal < 1) {
431
432
  }
432
433
 
433
434
  const grepCountOverlapPatterns = [
434
- ...Array.from({ length: 19 }, (_, i) => `__count_overlap_a_${i}__`),
435
435
  'assertOk',
436
- ...Array.from({ length: 19 }, (_, i) => `__count_overlap_b_${i}__`),
436
+ ...Array.from({ length: 8 }, (_, i) => `__count_overlap_a_${i}__`),
437
437
  'assertOk',
438
438
  ];
439
439
  const grepCountOverlapOut = await executeBuiltinTool('grep', {
@@ -444,36 +444,37 @@ const grepCountOverlapOut = await executeBuiltinTool('grep', {
444
444
  const overlapCountTotal = grepCountTotalMatches(grepCountOverlapOut);
445
445
  if (overlapCountTotal !== singleCountTotal) {
446
446
  throw new Error(
447
- `chunked count must not double-count overlapping lines (single=${singleCountTotal} overlap=${overlapCountTotal}):\n${grepCountOverlapOut.slice(0, 600)}`,
447
+ `multi-pattern count must not double-count overlapping lines (single=${singleCountTotal} overlap=${overlapCountTotal}):\n${grepCountOverlapOut.slice(0, 600)}`,
448
448
  );
449
449
  }
450
450
 
451
451
  const grepChunkContextPatterns = [
452
- ...Array.from({ length: 20 }, (_, i) => `__ctx_chunk_miss_${i}__`),
453
452
  'grepCountTotalMatches',
453
+ ...Array.from({ length: 20 }, (_, i) => `__ctx_chunk_miss_${i}__`),
454
454
  ];
455
455
  const grepChunkContextOut = await executeBuiltinTool('grep', {
456
456
  pattern: grepChunkContextPatterns,
457
- path: 'scripts/tool-smoke.mjs',
457
+ path: 'scripts',
458
+ glob: 'tool-smoke.mjs',
458
459
  '-C': 1,
459
460
  head_limit: 30,
460
461
  }, root);
461
- if (!/\[pattern set split into 2 chunks\]/.test(String(grepChunkContextOut))) {
462
- throw new Error(`chunked -C grep should split patterns:\n${grepChunkContextOut.slice(0, 500)}`);
462
+ if (!/\[capped at 10 of 21 patterns\]/.test(String(grepChunkContextOut))) {
463
+ throw new Error(`oversized -C pattern[] should emit cap note:\n${grepChunkContextOut.slice(0, 500)}`);
463
464
  }
464
465
  if (!/tool-smoke\.mjs:\d+:/.test(String(grepChunkContextOut))) {
465
- throw new Error(`chunked -C must emit path-prefixed match lines:\n${grepChunkContextOut.slice(0, 800)}`);
466
+ throw new Error(`capped -C must emit path-prefixed match lines:\n${grepChunkContextOut.slice(0, 800)}`);
466
467
  }
467
468
  if (!/tool-smoke\.mjs-\d+-/.test(String(grepChunkContextOut))) {
468
- throw new Error(`chunked -C must keep path-prefixed context lines:\n${grepChunkContextOut.slice(0, 800)}`);
469
+ throw new Error(`capped -C must keep path-prefixed context lines:\n${grepChunkContextOut.slice(0, 800)}`);
469
470
  }
470
471
  const ctxBodyLines = String(grepChunkContextOut).split('\n').filter((l) => l && !/^\[/.test(l) && !/^\(no matches\)/.test(l));
471
472
  const orphanLineOnlyContext = ctxBodyLines.some((l) => /^\d+-/.test(l));
472
473
  if (orphanLineOnlyContext) {
473
- throw new Error(`chunked -C must not leave line-only context orphans:\n${grepChunkContextOut.slice(0, 800)}`);
474
+ throw new Error(`capped -C must not leave line-only context orphans:\n${grepChunkContextOut.slice(0, 800)}`);
474
475
  }
475
476
  if (!/function grepCountTotalMatches/.test(String(grepChunkContextOut))) {
476
- throw new Error(`chunked -C should include match span:\n${grepChunkContextOut.slice(0, 800)}`);
477
+ throw new Error(`capped -C should include match span:\n${grepChunkContextOut.slice(0, 800)}`);
477
478
  }
478
479
 
479
480
  const findOut = await executeBuiltinTool('find', {
@@ -1165,12 +1166,19 @@ setInternalToolsProvider({
1165
1166
  if (!/Read-only retrieval role/i.test(visible) || /# environment/i.test(visible) || /git operations deferred to Lead/i.test(visible)) {
1166
1167
  throw new Error(`explorer hidden retrieval context should stay slim: ${visible.slice(0, 1200)}`);
1167
1168
  }
1168
- if (!/# Role: explorer/i.test(systemVisible) || /# Role: explorer/i.test(userReminderVisible) || !/one-shot locator/i.test(systemVisible)) {
1169
+ if (!/# Role: explorer/i.test(systemVisible) || /# Role: explorer/i.test(userReminderVisible) || !/Coordinate locator/i.test(systemVisible)) {
1169
1170
  throw new Error(`explorer role md must ride BP2 system, not BP3 user reminder: system=${systemVisible.slice(0, 600)} user=${userReminderVisible.slice(0, 600)}`);
1170
1171
  }
1171
- const visibleBytes = Buffer.byteLength(visible, 'utf8');
1172
- if (visibleBytes > 1800) {
1173
- throw new Error(`explorer hidden retrieval context too large: ${visibleBytes} bytes`);
1172
+ // System layers (BP1 tool policy + BP2 role md) are shared/frozen and sized
1173
+ // elsewhere; the slimness cap guards only the per-session injected layers
1174
+ // (BP3 user reminder etc.), so measure non-system messages only.
1175
+ const injectedVisible = (explorerSession.messages || [])
1176
+ .filter((m) => m?.role !== 'system')
1177
+ .map((m) => String(m.content || ''))
1178
+ .join('\n');
1179
+ const injectedBytes = Buffer.byteLength(injectedVisible, 'utf8');
1180
+ if (injectedBytes > 1800) {
1181
+ throw new Error(`explorer hidden retrieval context too large: ${injectedBytes} bytes (injected layers only)`);
1174
1182
  }
1175
1183
  } finally {
1176
1184
  closeSession(explorerSession.id, 'tool-smoke');
@@ -1298,8 +1306,16 @@ setInternalToolsProvider({
1298
1306
  if (!fullTools.includes('explore')) {
1299
1307
  throw new Error(`full agent schema must expose explore: full=${fullTools.join(', ')}`);
1300
1308
  }
1301
- if (publicExploreTools.includes('explore')) {
1302
- throw new Error(`public explore role must not expose recursive explore tool: ${publicExploreTools.join(', ')}`);
1309
+ // Unified-shard policy (v0.9.13): the explore wrapper stays IN the schema
1310
+ // for every read role including the explore agent itself — so the
1311
+ // read-only bundle is bit-identical across roles (one cache group).
1312
+ // Recursion is broken at call time in pre-dispatch-deny.mjs via
1313
+ // recursiveWrapperToolNameForPublicAgent, not by schema stripping.
1314
+ if (JSON.stringify(publicExploreTools) !== JSON.stringify(expectedReadTools)) {
1315
+ throw new Error(`public explore role must ship the shared read-only bundle (incl. explore): expected=${expectedReadTools.join(', ')} actual=${publicExploreTools.join(', ')}`);
1316
+ }
1317
+ if (recursiveWrapperToolNameForPublicAgent('explore') !== 'explore') {
1318
+ throw new Error('call-time anti-recursion must map public explore agent to its own wrapper tool');
1303
1319
  }
1304
1320
  } finally {
1305
1321
  closeSession(readAgentSession.id, 'tool-smoke');