mixdog 0.9.22 → 0.9.23

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 (91) hide show
  1. package/README.md +1 -4
  2. package/package.json +1 -1
  3. package/scripts/build-runtime-windows.ps1 +242 -242
  4. package/scripts/channel-daemon-smoke.mjs +165 -0
  5. package/scripts/channel-daemon-stub.mjs +69 -0
  6. package/scripts/recall-usecase-cases.json +1 -1
  7. package/scripts/smoke-runtime-negative.ps1 +106 -106
  8. package/scripts/statusline-quota-hysteresis-test.mjs +37 -0
  9. package/scripts/tool-efficiency-diag.mjs +1 -1
  10. package/scripts/tool-smoke.mjs +30 -17
  11. package/src/rules/lead/02-channels.md +3 -3
  12. package/src/runtime/agent/orchestrator/mcp/child-tree.mjs +187 -0
  13. package/src/runtime/agent/orchestrator/mcp/client.mjs +40 -3
  14. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +1 -1
  15. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +5 -3
  16. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +2 -2
  17. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +2 -2
  18. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +1 -1
  19. package/src/runtime/agent/orchestrator/session/loop/completion-guards.mjs +3 -2
  20. package/src/runtime/agent/orchestrator/session/loop/deferred-call-through.mjs +6 -3
  21. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +1 -1
  22. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +37 -4
  23. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +7 -0
  24. package/src/runtime/agent/orchestrator/session/store.mjs +30 -14
  25. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +1 -1
  26. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +24 -11
  27. package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +6 -5
  28. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +56 -1
  29. package/src/runtime/channels/lib/owned-runtime.mjs +126 -167
  30. package/src/runtime/channels/lib/owner-heartbeat.mjs +11 -60
  31. package/src/runtime/channels/lib/parent-bridge.mjs +16 -1
  32. package/src/runtime/channels/lib/runtime-paths.mjs +32 -113
  33. package/src/runtime/channels/lib/seat-lock.mjs +196 -0
  34. package/src/runtime/channels/lib/session-discovery.mjs +2 -1
  35. package/src/runtime/channels/lib/tool-dispatch.mjs +24 -8
  36. package/src/runtime/channels/lib/worker-main.mjs +42 -11
  37. package/src/runtime/memory/index.mjs +54 -7
  38. package/src/runtime/memory/lib/embedding-warmup.mjs +7 -0
  39. package/src/runtime/memory/lib/pg/process.mjs +85 -40
  40. package/src/runtime/memory/lib/pg/supervisor.mjs +82 -17
  41. package/src/runtime/shared/atomic-file.mjs +20 -4
  42. package/src/runtime/shared/tool-primitives.mjs +31 -1
  43. package/src/runtime/shared/tool-surface.mjs +23 -13
  44. package/src/session-runtime/config-lifecycle.mjs +48 -7
  45. package/src/session-runtime/lifecycle-api.mjs +9 -0
  46. package/src/session-runtime/mcp-glue.mjs +63 -1
  47. package/src/session-runtime/resource-api.mjs +62 -8
  48. package/src/session-runtime/runtime-core.mjs +32 -2
  49. package/src/session-runtime/session-text.mjs +41 -0
  50. package/src/session-runtime/session-turn-api.mjs +24 -0
  51. package/src/session-runtime/settings-api.mjs +8 -1
  52. package/src/session-runtime/tool-catalog.mjs +306 -38
  53. package/src/session-runtime/tool-defs.mjs +7 -7
  54. package/src/session-runtime/workflow.mjs +2 -1
  55. package/src/standalone/channel-daemon-client.mjs +224 -0
  56. package/src/standalone/channel-daemon-transport.mjs +351 -0
  57. package/src/standalone/channel-daemon.mjs +139 -0
  58. package/src/standalone/channel-worker.mjs +213 -4
  59. package/src/standalone/hook-bus.mjs +71 -3
  60. package/src/tui/App.jsx +105 -17
  61. package/src/tui/app/clipboard.mjs +39 -19
  62. package/src/tui/app/doctor.mjs +57 -0
  63. package/src/tui/app/extension-pickers.mjs +53 -9
  64. package/src/tui/app/maintenance-pickers.mjs +0 -5
  65. package/src/tui/app/slash-dispatch.mjs +4 -4
  66. package/src/tui/app/text-layout.mjs +11 -0
  67. package/src/tui/app/use-mouse-input.mjs +235 -51
  68. package/src/tui/app/use-prompt-handlers.mjs +49 -30
  69. package/src/tui/app/use-transcript-scroll.mjs +124 -27
  70. package/src/tui/app/use-transcript-window.mjs +55 -1
  71. package/src/tui/components/Message.jsx +1 -1
  72. package/src/tui/components/PromptInput.jsx +3 -1
  73. package/src/tui/components/QueuedCommands.jsx +21 -10
  74. package/src/tui/components/ToolExecution.jsx +2 -2
  75. package/src/tui/dist/index.mjs +635 -306
  76. package/src/tui/engine/session-api.mjs +17 -7
  77. package/src/tui/engine/session-flow.mjs +6 -0
  78. package/src/tui/engine.mjs +8 -0
  79. package/src/tui/index.jsx +62 -18
  80. package/src/tui/paste-attachments.mjs +26 -0
  81. package/src/ui/statusline.mjs +45 -5
  82. package/src/ui/tool-card.mjs +8 -1
  83. package/src/vendor/statusline/bin/statusline-route.mjs +23 -7
  84. package/src/workflows/bench/WORKFLOW.md +46 -0
  85. package/src/workflows/default/WORKFLOW.md +6 -0
  86. package/src/workflows/solo/WORKFLOW.md +5 -0
  87. package/vendor/ink/build/ink.js +23 -1
  88. package/vendor/ink/build/output.js +154 -71
  89. package/vendor/ink/build/render-node-to-output.js +44 -2
  90. package/vendor/ink/build/render.js +4 -0
  91. package/vendor/ink/build/renderer.js +4 -1
@@ -0,0 +1,165 @@
1
+ // Scripted two-client simulation for the machine-global channels daemon
2
+ // transport (no Discord token needed — the channels runtime is stubbed).
3
+ //
4
+ // Verifies the transport + attach client contracts that later slices depend on:
5
+ // 1. two TUIs attach to ONE daemon and each get an independent SSE stream;
6
+ // 2. tool calls round-trip per client and carry the caller's leadPid;
7
+ // 3. notifies are TARGETED (never broadcast) to the routing-pointer client;
8
+ // 4. a bind-intent call (rebind_current_transcript) moves the pointer;
9
+ // 5. once every client deregisters, the daemon self-shutdown fires.
10
+ //
11
+ // Run: node scripts/channel-daemon-smoke.mjs
12
+ import os from 'node:os';
13
+ import path from 'node:path';
14
+ import { mkdtempSync, rmSync } from 'node:fs';
15
+ import { fileURLToPath } from 'node:url';
16
+ import { createChannelDaemonTransport } from '../src/standalone/channel-daemon-transport.mjs';
17
+ import { attachToDaemon } from '../src/standalone/channel-daemon-client.mjs';
18
+
19
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
20
+ const STUB_ENTRY = path.join(HERE, 'channel-daemon-stub.mjs');
21
+
22
+ const delay = (ms) => new Promise((r) => setTimeout(r, ms));
23
+ const waitFor = async (fn, ms = 1500) => {
24
+ const end = Date.now() + ms;
25
+ while (Date.now() < end) { if (fn()) return true; await delay(20); }
26
+ return fn();
27
+ };
28
+ let failures = 0;
29
+ function check(label, cond) {
30
+ const ok = !!cond;
31
+ if (!ok) failures++;
32
+ console.log(`${ok ? 'PASS' : 'FAIL'} ${label}`);
33
+ }
34
+
35
+ async function main() {
36
+ const tmp = mkdtempSync(path.join(os.tmpdir(), 'mixdog-daemon-smoke-'));
37
+ const discoveryPath = path.join(tmp, 'channel-daemon.json');
38
+ let clientsEmptyFired = false;
39
+ let sideEffects = 0; // counts non-idempotent 'reply' dispatches
40
+
41
+ // Stub channels runtime: echoes the call + the caller identity the transport
42
+ // resolved, so we can assert per-client leadPid threading.
43
+ const handleCall = async (name, args, ctx) => {
44
+ if (name === 'reply') { sideEffects++; await delay(40); } // simulate a side effect
45
+ return { ok: true, name, args, leadPid: ctx.leadPid };
46
+ };
47
+
48
+ const transport = createChannelDaemonTransport({
49
+ handleCall,
50
+ discoveryPath,
51
+ clientGraceMs: 250,
52
+ sweepMs: 1000,
53
+ onClientsEmpty: () => { clientsEmptyFired = true; },
54
+ log: (m) => process.env.DAEMON_SMOKE_VERBOSE && console.log(`[daemon] ${m}`),
55
+ });
56
+ const { port, token } = await transport.start();
57
+ const discovery = { port, token, pid: process.pid };
58
+
59
+ const notA = [];
60
+ const notB = [];
61
+ const clientA = await attachToDaemon({ discovery, leadPid: process.pid, cwd: 'A', onNotify: (m) => notA.push(m) });
62
+ const clientB = await attachToDaemon({ discovery, leadPid: process.pid, cwd: 'B', onNotify: (m) => notB.push(m) });
63
+ await delay(150); // let both SSE streams attach
64
+
65
+ check('two clients registered', transport._clientsForTest.size === 2);
66
+
67
+ // (2) calls round-trip per client.
68
+ const rA = await clientA.call('reload_config', { k: 1 });
69
+ check('client A call round-trips', rA?.ok === true && rA?.name === 'reload_config');
70
+ const rB = await clientB.call('fetch', { q: 2 });
71
+ check('client B call round-trips', rB?.ok === true && rB?.name === 'fetch');
72
+
73
+ // (3) notify targets the pointer client. First registrant (A) is the pointer.
74
+ transport.notify('notifications/claude/channel', { content: 'to-A' });
75
+ await delay(120);
76
+ check('notify #1 delivered to A only',
77
+ notA.length === 1 && notA[0]?.params?.content === 'to-A' && notB.length === 0);
78
+
79
+ // (4) bind-intent call moves the pointer to B.
80
+ await clientB.call('rebind_current_transcript', { transcriptPath: '/tmp/b.jsonl' });
81
+ transport.notify('notifications/claude/channel', { content: 'to-B' });
82
+ await delay(120);
83
+ check('notify #2 delivered to B only after rebind',
84
+ notB.length === 1 && notB[0]?.params?.content === 'to-B' && notA.length === 1);
85
+
86
+ // (fix 1) idempotent replay: two /call with the SAME callId (a retried
87
+ // transport failure) must run the non-idempotent side-effect exactly once.
88
+ const dupId = 'dup-call-1';
89
+ const [d1, d2] = await Promise.all([
90
+ clientA.call('reply', { n: 1 }, { callId: dupId }),
91
+ clientA.call('reply', { n: 1 }, { callId: dupId }),
92
+ ]);
93
+ check('idempotent replay: same callId → exactly one side-effect',
94
+ sideEffects === 1 && d1?.ok === true && d2?.ok === true);
95
+
96
+ // (5) deregister both → self-shutdown fires after the client grace window.
97
+ await clientA.close();
98
+ await clientB.close();
99
+ await delay(600);
100
+ check('daemon self-shutdown fired after last client left', clientsEmptyFired === true);
101
+
102
+ await transport.stop();
103
+ try { rmSync(tmp, { recursive: true, force: true }); } catch {}
104
+
105
+ await flipTest();
106
+
107
+ console.log(failures === 0 ? '\nALL PASS' : `\n${failures} FAILURE(S)`);
108
+ process.exit(failures === 0 ? 0 : 1);
109
+ }
110
+
111
+ // Flip coverage: two TUIs attach to ONE daemon via the REAL channel-worker.mjs
112
+ // spawn-or-attach path (against the stub daemon entry — no Discord token). The
113
+ // first worker spawns the daemon; the second attaches to it.
114
+ async function flipTest() {
115
+ const tmp = mkdtempSync(path.join(os.tmpdir(), 'mixdog-flip-smoke-'));
116
+ // channel-worker.mjs resolves DAEMON_ENTRY + runtimeRoot at import/call time,
117
+ // so the env must be set BEFORE importing it.
118
+ process.env.MIXDOG_RUNTIME_ROOT = tmp;
119
+ process.env.MIXDOG_DATA_DIR = tmp;
120
+ process.env.MIXDOG_CHANNEL_DAEMON_ENTRY = STUB_ENTRY;
121
+ const { createStandaloneChannelWorker } = await import('../src/standalone/channel-worker.mjs');
122
+ const { readDaemonDiscovery } = await import('../src/standalone/channel-daemon-client.mjs');
123
+ const discFile = path.join(tmp, 'channel-daemon.json');
124
+
125
+ const notA = [];
126
+ const notB = [];
127
+ const mk = (onNotify) => createStandaloneChannelWorker({ entry: STUB_ENTRY, rootDir: tmp, dataDir: tmp, cwd: tmp, onNotify });
128
+ const wA = mk((m) => notA.push(m));
129
+ const wB = mk((m) => notB.push(m));
130
+
131
+ await wA.start(); // spawns the (stub) daemon then attaches
132
+ await wB.start(); // attaches to the SAME daemon (no second spawn)
133
+ check('flip: worker A call round-trips', (await wA.execute('reply', { x: 1 }))?.ok === true);
134
+ check('flip: worker B call round-trips', (await wB.execute('reply', { y: 2 }))?.ok === true);
135
+
136
+ // B claims the transcript → routing pointer moves to B; a notify emitted by
137
+ // the daemon (on A's 'fetch') must reach B only, never both TUIs.
138
+ await wB.execute('rebind_current_transcript', { transcriptPath: path.join(tmp, 'b.jsonl') });
139
+ await wA.execute('fetch', { probe: true });
140
+ await waitFor(() => notB.some((m) => m?.params?.content === 'ping-from-stub'), 1500);
141
+ check('flip: notify targets the pointer TUI (B) only',
142
+ notB.some((m) => m?.params?.content === 'ping-from-stub') &&
143
+ !notA.some((m) => m?.params?.content === 'ping-from-stub'));
144
+
145
+ // (fix 1) daemon death mid-session: SIGKILL it, then the surviving client's
146
+ // next call must transparently respawn + re-attach (bounded retry inside
147
+ // execute) — no TUI process restart.
148
+ const beforeKill = readDaemonDiscovery(discFile);
149
+ if (beforeKill?.pid) { try { process.kill(beforeKill.pid, 'SIGKILL'); } catch {} }
150
+ await delay(400);
151
+ let recovered = false;
152
+ try { recovered = (await wA.execute('reply', { after: 'kill' }))?.ok === true; } catch {}
153
+ const afterKill = readDaemonDiscovery(discFile);
154
+ check('flip: call transparently respawns+reattaches after daemon death',
155
+ recovered === true && !!afterKill?.pid && afterKill.pid !== beforeKill?.pid);
156
+
157
+ await wA.stop();
158
+ await wB.stop();
159
+ await delay(700);
160
+ const disc = readDaemonDiscovery(discFile);
161
+ check('flip: daemon self-shutdown after last TUI detaches', disc === null);
162
+ try { rmSync(tmp, { recursive: true, force: true }); } catch {}
163
+ }
164
+
165
+ main().catch((err) => { console.error(err); process.exit(1); });
@@ -0,0 +1,69 @@
1
+ // Stub channels daemon for the flip smoke: same lifecycle contract as
2
+ // src/standalone/channel-daemon.mjs (pid-verified singleton claim, HTTP+SSE
3
+ // transport, discovery file, ready handshake, client-grace self-shutdown) but
4
+ // with a STUB runtime instead of worker-main — no Discord token needed. The
5
+ // real channel-worker.mjs spawn-or-attach path forks THIS via
6
+ // MIXDOG_CHANNEL_DAEMON_ENTRY, so the flip is exercised end to end.
7
+ process.env.MIXDOG_WORKER_MODE = process.env.MIXDOG_WORKER_MODE || '1';
8
+
9
+ import os from 'node:os';
10
+ import path from 'node:path';
11
+ import { mkdirSync } from 'node:fs';
12
+ import { claimSingletonOwner, releaseSingletonOwner } from '../src/runtime/shared/singleton-owner.mjs';
13
+ import { createChannelDaemonTransport } from '../src/standalone/channel-daemon-transport.mjs';
14
+
15
+ function runtimeRoot() {
16
+ return process.env.MIXDOG_RUNTIME_ROOT
17
+ ? path.resolve(process.env.MIXDOG_RUNTIME_ROOT)
18
+ : path.join(os.tmpdir(), 'mixdog');
19
+ }
20
+ const RUNTIME_ROOT = runtimeRoot();
21
+ const DATA_DIR = process.env.MIXDOG_DATA_DIR ? path.resolve(process.env.MIXDOG_DATA_DIR) : RUNTIME_ROOT;
22
+ const DISCOVERY_PATH = path.join(RUNTIME_ROOT, 'channel-daemon.json');
23
+ const OWNER_PATH = path.join(DATA_DIR, 'channel-daemon-owner.json');
24
+
25
+ function log(line) { if (process.env.DAEMON_SMOKE_VERBOSE) process.stderr.write(`[stub-daemon] ${line}\n`); }
26
+
27
+ let transport = null;
28
+ let shuttingDown = false;
29
+ async function shutdown(reason, code = 0) {
30
+ if (shuttingDown) return;
31
+ shuttingDown = true;
32
+ try { await transport?.stop?.(); } catch {}
33
+ try { releaseSingletonOwner(OWNER_PATH, process.pid); } catch {}
34
+ process.exit(code);
35
+ }
36
+
37
+ async function main() {
38
+ try { mkdirSync(RUNTIME_ROOT, { recursive: true }); } catch {}
39
+ const claim = claimSingletonOwner(OWNER_PATH, { kind: 'channel-runtime-daemon', pid: process.pid, meta: { cwd: process.cwd() } });
40
+ if (!claim.owned) { process.exit(0); } // race loser → spawner attaches to winner
41
+ process.on('exit', () => { try { releaseSingletonOwner(OWNER_PATH, process.pid); } catch {} });
42
+
43
+ // Stub runtime: echo the call + caller identity, and (for 'fetch') emit a
44
+ // notify AFTER responding so the smoke can assert targeted routing.
45
+ const handleCall = async (name, args, ctx) => {
46
+ if (name === 'fetch') {
47
+ setTimeout(() => { try { transport.notify('notifications/claude/channel', { content: 'ping-from-stub' }); } catch {} }, 20);
48
+ }
49
+ return { ok: true, name, args, leadPid: ctx.leadPid };
50
+ };
51
+
52
+ transport = createChannelDaemonTransport({
53
+ handleCall,
54
+ discoveryPath: DISCOVERY_PATH,
55
+ clientGraceMs: 250,
56
+ sweepMs: 1000,
57
+ log,
58
+ onClientsEmpty: () => { void shutdown('no live clients'); },
59
+ });
60
+ const { port, token } = await transport.start();
61
+ if (process.send) { try { process.send({ type: 'ready', port, token }); } catch {} }
62
+ log(`ready port=${port} pid=${process.pid}`);
63
+ }
64
+
65
+ process.on('SIGTERM', () => { void shutdown('SIGTERM'); });
66
+ process.on('SIGINT', () => { void shutdown('SIGINT'); });
67
+ process.on('message', (msg) => { if (msg && msg.type === 'shutdown') void shutdown('IPC shutdown'); });
68
+
69
+ main().catch((err) => { log(`fatal: ${err?.stack || err}`); void shutdown('fatal', 2); });
@@ -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,37 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { acceptQuotaSnapshot } from '../src/ui/statusline.mjs';
4
+
5
+ // Monotonic hysteresis for the 5H/7D usage segment: once a value has rendered,
6
+ // an OLDER shared-cache snapshot (written by another mixdog instance) must not
7
+ // displace it, but a NEWER one — or confirmed own-instance live data — must.
8
+ test('older shared-cache snapshot is rejected, newer is accepted', () => {
9
+ const displayedOwnLive = { segments: ['5H 12%'], asOf: 2000, owned: true };
10
+
11
+ // Another instance overwrites the shared cache with an OLDER snapshot →
12
+ // metricsMatch flips false, source alternates to that unowned older snapshot.
13
+ assert.equal(
14
+ acceptQuotaSnapshot(displayedOwnLive, { asOf: 1000, owned: false }),
15
+ false,
16
+ 'older unowned snapshot must not displace displayed own-live value',
17
+ );
18
+
19
+ // A strictly newer shared snapshot is allowed to advance the value.
20
+ assert.equal(
21
+ acceptQuotaSnapshot(displayedOwnLive, { asOf: 3000, owned: false }),
22
+ true,
23
+ 'newer snapshot must replace the displayed value',
24
+ );
25
+ });
26
+
27
+ test('own-instance live data always wins; empty timestamps preserve prior behavior', () => {
28
+ const displayedShared = { segments: ['5H 12%'], asOf: 5000, owned: false };
29
+ // Own-instance live data replaces even an apparently newer shared snapshot.
30
+ assert.equal(acceptQuotaSnapshot(displayedShared, { asOf: 1, owned: true }), true);
31
+ // Nothing displayed yet → accept.
32
+ assert.equal(acceptQuotaSnapshot(undefined, { asOf: 1000, owned: false }), true);
33
+ // No comparable timestamps → accept (byte-for-byte prior behavior).
34
+ assert.equal(acceptQuotaSnapshot({ asOf: 0, owned: true }, { asOf: 0, owned: false }), true);
35
+ // Shared→shared same asOf → accept (idempotent refresh).
36
+ assert.equal(acceptQuotaSnapshot({ asOf: 2000, owned: false }, { asOf: 2000, owned: false }), true);
37
+ });
@@ -88,4 +88,4 @@ for (const target of ['heavy-worker', 'worker']) {
88
88
  console.log(` single-call batches: ${batch1}/${batchTot} (${pct(batch1, batchTot)}%)`);
89
89
  console.log(` content greps with -C/-A/-B: ${grepContentWithCtx}/${grepContent} (${pct(grepContentWithCtx, grepContent)}%) grep->read follow-ups: ${grepThenRead}`);
90
90
  console.log(` same-path re-reads >=3x: ${rereads}`);
91
- }
91
+ }
@@ -906,7 +906,7 @@ const fullDefaults = defaultDeferredToolNames(smokeCatalog, 'full');
906
906
  if (fullDefaults.size !== 10) {
907
907
  throw new Error(`full default surface should stay 10 tools, got ${fullDefaults.size}: ${[...fullDefaults].join(', ')}`);
908
908
  }
909
- for (const name of ['read', 'code_graph', 'grep', 'find', 'glob', 'list', 'apply_patch', 'explore', 'Skill', 'tool_search']) {
909
+ for (const name of ['read', 'code_graph', 'grep', 'find', 'glob', 'list', 'apply_patch', 'explore', 'Skill', 'load_tool']) {
910
910
  assertHas(fullDefaults, name);
911
911
  }
912
912
  for (const name of ['shell', 'task', 'agent', 'recall', 'search', 'web_fetch', 'cwd']) {
@@ -917,7 +917,7 @@ const leadDefaults = defaultDeferredToolNames(smokeCatalog, 'lead');
917
917
  if (leadDefaults.size !== 16) {
918
918
  throw new Error(`lead default surface should stay 16 tools for this static catalog, got ${leadDefaults.size}: ${[...leadDefaults].join(', ')}`);
919
919
  }
920
- for (const name of ['read', 'code_graph', 'grep', 'find', 'glob', 'list', 'shell', 'task', 'apply_patch', 'explore', 'agent', 'recall', 'search', 'web_fetch', 'Skill', 'tool_search']) {
920
+ for (const name of ['read', 'code_graph', 'grep', 'find', 'glob', 'list', 'shell', 'task', 'apply_patch', 'explore', 'agent', 'recall', 'search', 'web_fetch', 'Skill', 'load_tool']) {
921
921
  assertHas(leadDefaults, name);
922
922
  }
923
923
  if (TOOL_SEARCH_TOOL.annotations?.agentHidden !== true) {
@@ -943,7 +943,7 @@ for (const [name, cap] of [
943
943
  ['recall', 2400],
944
944
  ['search', 3200],
945
945
  ['web_fetch', 900],
946
- ['tool_search', 900],
946
+ ['load_tool', 900],
947
947
  ]) {
948
948
  const tool = smokeCatalog.find((item) => item?.name === name);
949
949
  const size = toolSchemaSize(tool);
@@ -954,7 +954,7 @@ const readonlyDefaults = defaultDeferredToolNames(smokeCatalog, 'readonly');
954
954
  if (readonlyDefaults.size !== 9) {
955
955
  throw new Error(`readonly default surface should stay 9 tools, got ${readonlyDefaults.size}: ${[...readonlyDefaults].join(', ')}`);
956
956
  }
957
- for (const name of ['read', 'code_graph', 'grep', 'find', 'glob', 'list', 'explore', 'Skill', 'tool_search']) {
957
+ for (const name of ['read', 'code_graph', 'grep', 'find', 'glob', 'list', 'explore', 'Skill', 'load_tool']) {
958
958
  assertHas(readonlyDefaults, name);
959
959
  }
960
960
  for (const name of ['apply_patch', 'agent', 'shell']) {
@@ -1359,8 +1359,8 @@ setInternalToolsProvider({
1359
1359
  throw new Error(`agent context must not inject environment reminder: ${visible.slice(0, 1200)}`);
1360
1360
  }
1361
1361
  const workerToolNames = (workerSession.tools || []).map((tool) => tool?.name).filter(Boolean);
1362
- if (workerToolNames.includes('tool_search')) {
1363
- throw new Error(`agent session schema must not expose deferred tool_search: ${workerToolNames.join(', ')}`);
1362
+ if (workerToolNames.includes('load_tool')) {
1363
+ throw new Error(`agent session schema must not expose deferred load_tool: ${workerToolNames.join(', ')}`);
1364
1364
  }
1365
1365
  for (const name of ['shell', 'task']) {
1366
1366
  if (!workerToolNames.includes(name)) {
@@ -1420,8 +1420,8 @@ setInternalToolsProvider({
1420
1420
  if (JSON.stringify(writeTools) !== JSON.stringify(expectedWriteTools)) {
1421
1421
  throw new Error(`read-write agent schema must be fixed allow-list: expected=${expectedWriteTools.join(', ')} actual=${writeTools.join(', ')}`);
1422
1422
  }
1423
- if (readTools.includes('tool_search') || writeTools.includes('tool_search')) {
1424
- throw new Error(`agent session fixed schemas must omit tool_search: read=${readTools.join(', ')} write=${writeTools.join(', ')}`);
1423
+ if (readTools.includes('load_tool') || writeTools.includes('load_tool')) {
1424
+ throw new Error(`agent session fixed schemas must omit load_tool: read=${readTools.join(', ')} write=${writeTools.join(', ')}`);
1425
1425
  }
1426
1426
  if (readTools.includes('shell')) {
1427
1427
  throw new Error(`read agent schema must omit shell: read=${readTools.join(', ')}`);
@@ -1846,25 +1846,38 @@ if (!/Use after search/i.test(webFetchTool?.description || '') || !webFetchProps
1846
1846
  if (!/offset/i.test(webFetchProps.startIndex?.description || '') || !/Maximum characters/i.test(webFetchProps.maxLength?.description || '')) {
1847
1847
  throw new Error('web_fetch schema must describe paging window fields');
1848
1848
  }
1849
- if (!/deferred tools/i.test(TOOL_SEARCH_TOOL.description || '') || !TOOL_SEARCH_TOOL.inputSchema?.properties?.select) {
1850
- throw new Error('tool_search schema must preserve selection guidance and select field');
1849
+ if (!/deferred tools/i.test(TOOL_SEARCH_TOOL.description || '')
1850
+ || !TOOL_SEARCH_TOOL.inputSchema?.properties?.names
1851
+ || !TOOL_SEARCH_TOOL.inputSchema?.properties?.select) {
1852
+ throw new Error('load_tool schema must preserve loader guidance plus names + legacy select fields');
1851
1853
  }
1852
1854
  const toolSearchSession = {
1853
1855
  tools: smokeCatalog.filter((tool) => fullDefaults.has(tool?.name)),
1854
1856
  deferredToolCatalog: smokeCatalog.slice(),
1855
1857
  deferredSelectedTools: [...fullDefaults],
1856
1858
  };
1857
- // A plain query is a case-insensitive substring filter over name+description.
1858
- // It lists matches only it never auto-loads or ranks.
1859
+ // load_tool is a pure loader: a free-text query is NOT a search. It loads
1860
+ // nothing, returns an error steering to names[], and never activates tools.
1859
1861
  const listQueryResult = JSON.parse(__renderToolSearchForTest({ query: 'shell' }, toolSearchSession, 'full'));
1860
- if (listQueryResult.selected) {
1861
- throw new Error(`tool_search plain query must not auto-load: ${JSON.stringify(listQueryResult.selected)}`);
1862
+ if (listQueryResult.selected || (Array.isArray(listQueryResult.loaded) && listQueryResult.loaded.length)) {
1863
+ throw new Error(`load_tool free-text query must not load: ${JSON.stringify(listQueryResult)}`);
1862
1864
  }
1863
- if (!listQueryResult.matches.some((row) => row.name === 'shell')) {
1864
- throw new Error(`tool_search plain query should list substring matches: ${JSON.stringify(listQueryResult.matches)}`);
1865
+ if (!listQueryResult.error || !/names/i.test(listQueryResult.error)) {
1866
+ throw new Error(`load_tool free-text query must steer to names[]: ${JSON.stringify(listQueryResult)}`);
1865
1867
  }
1866
1868
  if (listQueryResult.activeTools.includes('shell') || (Array.isArray(listQueryResult.discoveredTools) && listQueryResult.discoveredTools.includes('shell'))) {
1867
- throw new Error(`tool_search plain query must not activate/discover tools: ${JSON.stringify(listQueryResult)}`);
1869
+ throw new Error(`load_tool free-text query must not activate/discover tools: ${JSON.stringify(listQueryResult)}`);
1870
+ }
1871
+ // names[] is the primary loader input (aliases expand, tools activate).
1872
+ const namesLoadResult = JSON.parse(__renderToolSearchForTest({ names: ['shell', 'recall'] }, {
1873
+ tools: smokeCatalog.filter((tool) => fullDefaults.has(tool?.name)),
1874
+ deferredToolCatalog: smokeCatalog.slice(),
1875
+ deferredSelectedTools: [...fullDefaults],
1876
+ }, 'full'));
1877
+ for (const name of ['shell', 'recall']) {
1878
+ if (!namesLoadResult.activeTools.includes(name) || !namesLoadResult.loaded.includes(name)) {
1879
+ throw new Error(`load_tool names[] must load ${name}: ${JSON.stringify(namesLoadResult)}`);
1880
+ }
1868
1881
  }
1869
1882
  // query "select:a,b" is the explicit query-side loader (aliases expand).
1870
1883
  const bulkSelectResult = JSON.parse(__renderToolSearchForTest({ query: 'select:shell,recall' }, toolSearchSession, 'full'));
@@ -1,3 +1,3 @@
1
- # Channels
2
-
3
- - Channel features are handled by the runtime.
1
+ # Channels
2
+
3
+ - Channel features are handled by the runtime.