mixdog 0.9.23 → 0.9.25

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 (89) hide show
  1. package/package.json +2 -1
  2. package/scripts/boot-smoke.mjs +1 -1
  3. package/scripts/channel-daemon-smoke.mjs +327 -9
  4. package/scripts/channel-daemon-stub.mjs +12 -1
  5. package/scripts/debounced-skills-async-save-test.mjs +57 -0
  6. package/scripts/explore-bench-tmp.mjs +17 -0
  7. package/scripts/find-fuzzy-hidden-test.mjs +145 -0
  8. package/scripts/mcp-grace-deferred-test.mjs +149 -0
  9. package/scripts/tool-smoke.mjs +38 -30
  10. package/src/defaults/cycle3-review-prompt.md +11 -4
  11. package/src/defaults/memory-promote-prompt.md +9 -0
  12. package/src/rules/agent/30-explorer.md +6 -0
  13. package/src/rules/shared/01-tool.md +11 -4
  14. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +76 -1
  15. package/src/runtime/agent/orchestrator/config.mjs +33 -7
  16. package/src/runtime/agent/orchestrator/context/collect.mjs +43 -8
  17. package/src/runtime/agent/orchestrator/mcp/child-tree.mjs +39 -0
  18. package/src/runtime/agent/orchestrator/mcp/client.mjs +145 -31
  19. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +38 -1
  20. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +39 -1
  21. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +24 -7
  22. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +11 -1
  23. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +7 -3
  24. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +40 -6
  25. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +2 -1
  26. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +13 -5
  27. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +62 -24
  28. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +7 -6
  29. package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +20 -0
  30. package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +34 -3
  31. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +220 -27
  32. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +61 -0
  33. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +43 -16
  34. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +54 -5
  35. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +97 -54
  36. package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +49 -31
  37. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +35 -2
  38. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +70 -21
  39. package/src/runtime/channels/backends/discord.mjs +10 -3
  40. package/src/runtime/channels/lib/crash-log.mjs +4 -2
  41. package/src/runtime/channels/lib/inbound-handler.mjs +45 -1
  42. package/src/runtime/channels/lib/output-forwarder.mjs +2 -1
  43. package/src/runtime/channels/lib/owned-runtime.mjs +65 -180
  44. package/src/runtime/channels/lib/owner-heartbeat.mjs +9 -13
  45. package/src/runtime/channels/lib/runtime-paths.mjs +6 -6
  46. package/src/runtime/channels/lib/tool-dispatch.mjs +9 -17
  47. package/src/runtime/channels/lib/tool-format.mjs +7 -2
  48. package/src/runtime/channels/lib/worker-main.mjs +9 -28
  49. package/src/runtime/memory/lib/cycle-scheduler.mjs +17 -1
  50. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +59 -0
  51. package/src/runtime/memory/lib/memory-cycle2.mjs +10 -3
  52. package/src/runtime/memory/lib/memory-cycle3.mjs +79 -9
  53. package/src/runtime/memory/lib/query-handlers.mjs +4 -1
  54. package/src/runtime/memory/lib/recall-format.mjs +7 -3
  55. package/src/runtime/memory/tool-defs.mjs +1 -1
  56. package/src/runtime/shared/atomic-file.mjs +130 -2
  57. package/src/runtime/shared/background-tasks.mjs +1 -1
  58. package/src/runtime/shared/config.mjs +53 -1
  59. package/src/runtime/shared/tool-execution-contract.mjs +1 -1
  60. package/src/runtime/shared/tool-surface.mjs +19 -0
  61. package/src/runtime/shared/update-checker.mjs +3 -0
  62. package/src/runtime/shared/user-data-guard.mjs +66 -0
  63. package/src/session-runtime/config-lifecycle.mjs +175 -15
  64. package/src/session-runtime/mcp-glue.mjs +30 -0
  65. package/src/session-runtime/runtime-core.mjs +91 -7
  66. package/src/session-runtime/session-turn-api.mjs +42 -16
  67. package/src/session-runtime/tool-catalog.mjs +44 -0
  68. package/src/standalone/channel-admin.mjs +32 -3
  69. package/src/standalone/channel-daemon-client.mjs +3 -1
  70. package/src/standalone/channel-daemon-transport.mjs +202 -8
  71. package/src/standalone/channel-daemon.mjs +54 -17
  72. package/src/standalone/channel-worker.mjs +18 -7
  73. package/src/standalone/explore-tool.mjs +87 -15
  74. package/src/tui/App.jsx +2 -2
  75. package/src/tui/components/StatusLine.jsx +3 -3
  76. package/src/tui/components/ToolExecution.jsx +14 -2
  77. package/src/tui/components/TranscriptItem.jsx +1 -1
  78. package/src/tui/dist/index.mjs +246 -47
  79. package/src/tui/engine/agent-job-feed.mjs +47 -3
  80. package/src/tui/engine/notification-plan.mjs +5 -0
  81. package/src/tui/engine/session-api.mjs +6 -1
  82. package/src/tui/engine/tool-card-results.mjs +14 -5
  83. package/src/tui/engine/turn.mjs +9 -2
  84. package/src/tui/engine.mjs +31 -12
  85. package/src/ui/statusline-agents.mjs +36 -0
  86. package/src/ui/statusline.mjs +15 -5
  87. package/src/workflows/default/WORKFLOW.md +29 -38
  88. package/src/workflows/solo/WORKFLOW.md +15 -20
  89. package/src/runtime/channels/lib/seat-lock.mjs +0 -196
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mixdog",
3
- "version": "0.9.23",
3
+ "version": "0.9.25",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Standalone mixdog coding-agent CLI/TUI workspace.",
@@ -33,6 +33,7 @@
33
33
  ],
34
34
  "scripts": {
35
35
  "prepack": "npm run build:tui",
36
+ "prepublishOnly": "node -e \"if(!process.env.CI){console.error('local npm publish is disabled — run the Release workflow: gh workflow run release -f bump=patch');process.exit(1)}\"",
36
37
  "start": "node src/cli.mjs",
37
38
  "smoke": "node scripts/smoke.mjs",
38
39
  "smoke:all": "npm run smoke && npm run smoke:boot && npm run smoke:tools && npm run smoke:output && npm run smoke:tui && npm run smoke:freevars && npm run smoke:logguard",
@@ -64,7 +64,7 @@ const rows = [
64
64
  try {
65
65
  const status = runtime.toolsStatus();
66
66
  const active = new Set(status.activeTools || []);
67
- for (const name of ['read','code_graph','grep','find','glob','list','apply_patch','explore','agent','shell','task','cwd','recall','search','web_fetch','Skill','tool_search']) {
67
+ for (const name of ['read','code_graph','grep','find','glob','list','apply_patch','explore','agent','shell','task','cwd','recall','search','web_fetch','Skill','load_tool']) {
68
68
  if (!active.has(name)) throw new Error('missing ' + name + ' in ' + [...active].join(','));
69
69
  }
70
70
  for (const name of ['bash']) {
@@ -11,10 +11,13 @@
11
11
  // Run: node scripts/channel-daemon-smoke.mjs
12
12
  import os from 'node:os';
13
13
  import path from 'node:path';
14
+ import http from 'node:http';
15
+ import { spawn } from 'node:child_process';
14
16
  import { mkdtempSync, rmSync } from 'node:fs';
15
17
  import { fileURLToPath } from 'node:url';
16
18
  import { createChannelDaemonTransport } from '../src/standalone/channel-daemon-transport.mjs';
17
19
  import { attachToDaemon } from '../src/standalone/channel-daemon-client.mjs';
20
+ import { createParentBridge, setChannelNotifySink } from '../src/runtime/channels/lib/parent-bridge.mjs';
18
21
 
19
22
  const HERE = path.dirname(fileURLToPath(import.meta.url));
20
23
  const STUB_ENTRY = path.join(HERE, 'channel-daemon-stub.mjs');
@@ -70,18 +73,20 @@ async function main() {
70
73
  const rB = await clientB.call('fetch', { q: 2 });
71
74
  check('client B call round-trips', rB?.ok === true && rB?.name === 'fetch');
72
75
 
73
- // (3) notify targets the pointer client. First registrant (A) is the pointer.
74
- transport.notify('notifications/claude/channel', { content: 'to-A' });
76
+ // (3) LAST-WINS register: the NEWEST registrant (B) steals the ownership seat,
77
+ // so an inbound notify targets B NOT the first registrant. (Under the old
78
+ // first-wins model this went to A.)
79
+ transport.notify('notifications/claude/channel', { content: 'to-B' });
75
80
  await delay(120);
76
- check('notify #1 delivered to A only',
77
- notA.length === 1 && notA[0]?.params?.content === 'to-A' && notB.length === 0);
81
+ check('notify #1 targets newest registrant (B) only',
82
+ notB.length === 1 && notB[0]?.params?.content === 'to-B' && notA.length === 0);
78
83
 
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' });
84
+ // (4) bind-intent call moves the pointer back to A.
85
+ await clientA.call('rebind_current_transcript', { transcriptPath: '/tmp/a.jsonl' });
86
+ transport.notify('notifications/claude/channel', { content: 'to-A' });
82
87
  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);
88
+ check('notify #2 delivered to A only after rebind',
89
+ notA.length === 1 && notA[0]?.params?.content === 'to-A' && notB.length === 1);
85
90
 
86
91
  // (fix 1) idempotent replay: two /call with the SAME callId (a retried
87
92
  // transport failure) must run the non-idempotent side-effect exactly once.
@@ -93,6 +98,54 @@ async function main() {
93
98
  check('idempotent replay: same callId → exactly one side-effect',
94
99
  sideEffects === 1 && d1?.ok === true && d2?.ok === true);
95
100
 
101
+ // (targeted) the 'acquired' badge goes ONLY to the current owner (pointer=A
102
+ // after the rebind), never broadcast — a displaced/non-owner TUI (B) must not
103
+ // light its remote badge. Still sticky for a late owner attach (below).
104
+ const remoteBefore = { A: notA.length, B: notB.length };
105
+ transport.notify('notifications/mixdog/remote', { state: 'acquired' });
106
+ await delay(120);
107
+ const gotA = notA.slice(remoteBefore.A).filter((m) => m?.method === 'notifications/mixdog/remote');
108
+ const gotB = notB.slice(remoteBefore.B).filter((m) => m?.method === 'notifications/mixdog/remote');
109
+ check('remote-state acquired targets owner (A) only, not broadcast',
110
+ gotA.length === 1 && gotB.length === 0);
111
+
112
+ // A late client that registers becomes the NEWEST owner (steals the pointer),
113
+ // so on attach it replays the sticky 'acquired' badge for itself.
114
+ const notC = [];
115
+ const clientC = await attachToDaemon({ discovery, leadPid: process.pid, cwd: 'C', onNotify: (m) => notC.push(m) });
116
+ await waitFor(() => notC.some((m) => m?.method === 'notifications/mixdog/remote'), 1000);
117
+ check('late owner receives replayed remote-state on attach',
118
+ notC.filter((m) => m?.method === 'notifications/mixdog/remote' && m?.params?.state === 'acquired').length === 1);
119
+ await clientC.close();
120
+
121
+ // (replay) 'superseded' CLEARS the sticky: a client attaching after it must
122
+ // receive NO remote-state replay (else it would wrongly stop a fresh client).
123
+ transport.notify('notifications/mixdog/remote', { state: 'superseded' });
124
+ await delay(60);
125
+ const notD = [];
126
+ const clientD = await attachToDaemon({ discovery, leadPid: process.pid, cwd: 'D', onNotify: (m) => notD.push(m) });
127
+ await delay(150);
128
+ check('attach after superseded receives no remote-state replay',
129
+ notD.filter((m) => m?.method === 'notifications/mixdog/remote').length === 0);
130
+ await clientD.close();
131
+
132
+ // (sink) daemon-mode wiring: owned-runtime routes remote-state through the
133
+ // parent bridge's sendNotifyToParent (NOT raw process.send). The daemon
134
+ // installs a sink -> transport.notify (channel-daemon.mjs:106). Assert an
135
+ // 'acquired' emitted via that sink reaches the transport and replays to a
136
+ // late client — the exact daemon-mode path a raw process.send would drop.
137
+ const { sendNotifyToParent } = createParentBridge({ getInstanceId: () => 'smoke' });
138
+ setChannelNotifySink((method, params) => transport.notify(method, params));
139
+ sendNotifyToParent('notifications/mixdog/remote', { state: 'acquired' });
140
+ await delay(60);
141
+ const notE = [];
142
+ const clientE = await attachToDaemon({ discovery, leadPid: process.pid, cwd: 'E', onNotify: (m) => notE.push(m) });
143
+ await waitFor(() => notE.some((m) => m?.method === 'notifications/mixdog/remote'), 1000);
144
+ check('daemon-mode acquire via notify sink reaches transport + replays',
145
+ notE.filter((m) => m?.method === 'notifications/mixdog/remote' && m?.params?.state === 'acquired').length === 1);
146
+ await clientE.close();
147
+ setChannelNotifySink(null);
148
+
96
149
  // (5) deregister both → self-shutdown fires after the client grace window.
97
150
  await clientA.close();
98
151
  await clientB.close();
@@ -103,6 +156,10 @@ async function main() {
103
156
  try { rmSync(tmp, { recursive: true, force: true }); } catch {}
104
157
 
105
158
  await flipTest();
159
+ await pointerStealTest();
160
+ await reattachBufferTest();
161
+ await pointerFailoverTest();
162
+ await pointerReconnectFollowsTest();
106
163
 
107
164
  console.log(failures === 0 ? '\nALL PASS' : `\n${failures} FAILURE(S)`);
108
165
  process.exit(failures === 0 ? 0 : 1);
@@ -162,4 +219,265 @@ async function flipTest() {
162
219
  try { rmSync(tmp, { recursive: true, force: true }); } catch {}
163
220
  }
164
221
 
222
+ // Last-wins ownership steal coverage with DISTINCT alive leadPids (two sleeper
223
+ // child processes) — the only way to observe the targeted 'superseded' frame,
224
+ // which is skipped when old/new client share a leadPid (same-TUI rebind).
225
+ async function pointerStealTest() {
226
+ const tmp = mkdtempSync(path.join(os.tmpdir(), 'mixdog-steal-smoke-'));
227
+ const discoveryPath = path.join(tmp, 'channel-daemon.json');
228
+ const transport = createChannelDaemonTransport({
229
+ handleCall: async (name, args, ctx) => ({ ok: true, name, leadPid: ctx.leadPid }),
230
+ discoveryPath,
231
+ clientGraceMs: 2000,
232
+ sweepMs: 5000,
233
+ onClientsEmpty: () => {},
234
+ log: (m) => process.env.DAEMON_SMOKE_VERBOSE && console.log(`[steal] ${m}`),
235
+ });
236
+ const { port, token } = await transport.start();
237
+ const discovery = { port, token, pid: process.pid };
238
+ const REMOTE = 'notifications/mixdog/remote';
239
+
240
+ // Two long-lived children give us two DISTINCT alive lead pids.
241
+ const sleeperA = spawn(process.execPath, ['-e', 'setTimeout(()=>{}, 60000)'], { stdio: 'ignore' });
242
+ const sleeperB = spawn(process.execPath, ['-e', 'setTimeout(()=>{}, 60000)'], { stdio: 'ignore' });
243
+ await delay(80);
244
+
245
+ const notA = [];
246
+ const notB = [];
247
+ const clientA = await attachToDaemon({ discovery, leadPid: sleeperA.pid, cwd: 'A', onNotify: (m) => notA.push(m) });
248
+ await delay(100); // A registers → A is the owner
249
+ const clientB = await attachToDaemon({ discovery, leadPid: sleeperB.pid, cwd: 'B', onNotify: (m) => notB.push(m) });
250
+ await delay(150); // B registers → steals the seat, A gets targeted superseded
251
+
252
+ check('steal: newest registrant (B) becomes the owner pointer',
253
+ transport._resolveTargetForTest()?.leadPid === sleeperB.pid);
254
+ check('steal: displaced owner (A) got a TARGETED superseded on register',
255
+ notA.filter((m) => m?.method === REMOTE && m?.params?.state === 'superseded').length === 1);
256
+ check('steal: new owner (B) got no superseded',
257
+ notB.filter((m) => m?.method === REMOTE).length === 0);
258
+
259
+ // 'acquired' now targets the owner (B) only — never broadcast to A.
260
+ transport.notify(REMOTE, { state: 'acquired' });
261
+ await delay(120);
262
+ check('steal: acquired targets owner (B) only, not broadcast',
263
+ notB.filter((m) => m?.params?.state === 'acquired').length === 1 &&
264
+ notA.filter((m) => m?.params?.state === 'acquired').length === 0);
265
+
266
+ // A bind-intent /call from A steals the seat back → B (displaced) superseded.
267
+ await clientA.call('activate_channel_bridge', {});
268
+ await delay(120);
269
+ check('steal: bind /call moves pointer back to A',
270
+ transport._resolveTargetForTest()?.leadPid === sleeperA.pid);
271
+ check('steal: displaced owner (B) got targeted superseded on bind /call',
272
+ notB.filter((m) => m?.method === REMOTE && m?.params?.state === 'superseded').length === 1);
273
+
274
+ // superseded via notify() (seat lost to another daemon) still BROADCASTS to
275
+ // every live client, regardless of pointer.
276
+ const aBefore = notA.length;
277
+ const bBefore = notB.length;
278
+ transport.notify(REMOTE, { state: 'superseded' });
279
+ await delay(120);
280
+ check('steal: superseded via notify broadcasts to ALL live clients',
281
+ notA.slice(aBefore).some((m) => m?.params?.state === 'superseded') &&
282
+ notB.slice(bBefore).some((m) => m?.params?.state === 'superseded'));
283
+
284
+ await clientA.close();
285
+ await clientB.close();
286
+ try { sleeperA.kill(); } catch {}
287
+ try { sleeperB.kill(); } catch {}
288
+ await transport.stop();
289
+ try { rmSync(tmp, { recursive: true, force: true }); } catch {}
290
+ }
291
+
292
+ // Low-level helpers: register/attach WITHOUT the auto-attach client, so a
293
+ // client can be registered but leave its SSE stream closed (to exercise the
294
+ // buffered-superseded + reattach paths that attachToDaemon hides).
295
+ function rawPost(port, serverToken, p, body) {
296
+ return new Promise((resolve, reject) => {
297
+ const payload = JSON.stringify(body);
298
+ const req = http.request({
299
+ hostname: '127.0.0.1', port, path: p, method: 'POST',
300
+ headers: { 'X-Mixdog-Daemon-Token': serverToken, 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) },
301
+ }, (res) => { let d = ''; res.setEncoding('utf8'); res.on('data', (c) => { d += c; }); res.on('end', () => { try { resolve(JSON.parse(d)); } catch { resolve({}); } }); });
302
+ req.on('error', reject); req.write(payload); req.end();
303
+ });
304
+ }
305
+ function rawSse(port, serverToken, clientToken, onNotify) {
306
+ const req = http.request({
307
+ hostname: '127.0.0.1', port,
308
+ path: `/events?token=${encodeURIComponent(clientToken)}&server_token=${encodeURIComponent(serverToken)}`,
309
+ method: 'GET', headers: { Accept: 'text/event-stream', 'X-Mixdog-Daemon-Token': serverToken },
310
+ }, (res) => {
311
+ res.setEncoding('utf8'); let buf = '';
312
+ res.on('data', (chunk) => {
313
+ buf += chunk; let idx;
314
+ while ((idx = buf.indexOf('\n\n')) >= 0) {
315
+ const raw = buf.slice(0, idx); buf = buf.slice(idx + 2);
316
+ for (const line of raw.split('\n')) {
317
+ if (!line.startsWith('data:')) continue;
318
+ const j = line.slice(5).trim(); if (!j) continue;
319
+ let m = null; try { m = JSON.parse(j); } catch { continue; }
320
+ if (m?.type === 'notify') onNotify(m);
321
+ }
322
+ }
323
+ });
324
+ });
325
+ req.end(); return req;
326
+ }
327
+
328
+ // Fix coverage: (1) an SSE-reconnect re-register must NOT steal ownership;
329
+ // (2) a targeted 'superseded' emitted while the displaced client has no live
330
+ // stream is buffered and flushed when its stream (re)attaches.
331
+ async function reattachBufferTest() {
332
+ const tmp = mkdtempSync(path.join(os.tmpdir(), 'mixdog-reattach-smoke-'));
333
+ const discoveryPath = path.join(tmp, 'channel-daemon.json');
334
+ const transport = createChannelDaemonTransport({
335
+ handleCall: async () => ({ ok: true }),
336
+ discoveryPath, clientGraceMs: 2000, sweepMs: 5000, onClientsEmpty: () => {},
337
+ log: (m) => process.env.DAEMON_SMOKE_VERBOSE && console.log(`[reattach] ${m}`),
338
+ });
339
+ const { port, token } = await transport.start();
340
+ const REMOTE = 'notifications/mixdog/remote';
341
+ const s1 = spawn(process.execPath, ['-e', 'setTimeout(()=>{}, 60000)'], { stdio: 'ignore' });
342
+ const s2 = spawn(process.execPath, ['-e', 'setTimeout(()=>{}, 60000)'], { stdio: 'ignore' });
343
+ await delay(80);
344
+
345
+ // (1) reconnect with the SAME leadPid: the pointer FOLLOWS to the fresh token
346
+ // (its old dead-stream entry is dropped), never stealing from a DIFFERENT
347
+ // owner. Register X (fresh) → owner; re-register reattach:true → pointer moves
348
+ // to the new token and the old entry is gone (no dead-stream blackhole).
349
+ const regX = await rawPost(port, token, '/client/register', { leadPid: s1.pid, cwd: 'X' });
350
+ await delay(40);
351
+ check('reattach: fresh register X becomes owner',
352
+ transport._resolveTargetForTest()?.token === regX.token);
353
+ const regReconnect = await rawPost(port, token, '/client/register', { leadPid: s1.pid, cwd: 'X', reattach: true });
354
+ await delay(40);
355
+ check('reattach: reconnect (same pid) pointer follows fresh token, old dropped',
356
+ transport._resolveTargetForTest()?.token === regReconnect.token &&
357
+ !transport._clientsForTest.has(regX.token));
358
+
359
+ // (2) X's current entry never opened a stream; a fresh register Y steals the
360
+ // seat → X's targeted 'superseded' is BUFFERED, then delivered on late attach.
361
+ const regY = await rawPost(port, token, '/client/register', { leadPid: s2.pid, cwd: 'Y' });
362
+ await delay(40);
363
+ check('reattach: fresh Y steals the seat from X',
364
+ transport._resolveTargetForTest()?.token === regY.token);
365
+ const notX = [];
366
+ const sseX = rawSse(port, token, regReconnect.token, (m) => notX.push(m));
367
+ await waitFor(() => notX.some((m) => m?.method === REMOTE && m?.params?.state === 'superseded'), 1000);
368
+ check('reattach: buffered superseded flushed to X after late SSE attach',
369
+ notX.filter((m) => m?.method === REMOTE && m?.params?.state === 'superseded').length === 1);
370
+
371
+ try { sseX.destroy(); } catch {}
372
+ try { s1.kill(); } catch {}
373
+ try { s2.kill(); } catch {}
374
+ await transport.stop();
375
+ try { rmSync(tmp, { recursive: true, force: true }); } catch {}
376
+ }
377
+
165
378
  main().catch((err) => { console.error(err); process.exit(1); });
379
+
380
+ // Pointer-failover coverage: the pointer client dies (deregister) while a
381
+ // live client remains. The transport must move the pointer to the survivor
382
+ // (reason 'failover'), deliver the sticky 'acquired' badge to it, and
383
+ // re-dispatch the survivor's stored bind intent via the injected dispatchBind
384
+ // so the output forwarder rebinds to the survivor's transcript.
385
+ async function pointerFailoverTest() {
386
+ const tmp = mkdtempSync(path.join(os.tmpdir(), 'mixdog-failover-smoke-'));
387
+ const discoveryPath = path.join(tmp, 'channel-daemon.json');
388
+ const REMOTE = 'notifications/mixdog/remote';
389
+ const rebinds = []; // failover re-dispatches routed through dispatchBind
390
+ const handleCall = async (name, args, ctx) => ({ ok: true, name, args, leadPid: ctx.leadPid });
391
+ const dispatchBind = (name, args, ctx) => {
392
+ rebinds.push({ name, args, leadPid: ctx.leadPid });
393
+ return handleCall(name, args, ctx);
394
+ };
395
+ const transport = createChannelDaemonTransport({
396
+ handleCall,
397
+ dispatchBind,
398
+ discoveryPath, clientGraceMs: 2000, sweepMs: 5000, onClientsEmpty: () => {},
399
+ log: (m) => process.env.DAEMON_SMOKE_VERBOSE && console.log(`[failover] ${m}`),
400
+ });
401
+ const { port, token } = await transport.start();
402
+ const discovery = { port, token, pid: process.pid };
403
+
404
+ const sleeperA = spawn(process.execPath, ['-e', 'setTimeout(()=>{}, 60000)'], { stdio: 'ignore' });
405
+ const sleeperB = spawn(process.execPath, ['-e', 'setTimeout(()=>{}, 60000)'], { stdio: 'ignore' });
406
+ await delay(80);
407
+
408
+ const notA = [];
409
+ const notB = [];
410
+ const clientA = await attachToDaemon({ discovery, leadPid: sleeperA.pid, cwd: 'A', onNotify: (m) => notA.push(m) });
411
+ await clientA.call('rebind_current_transcript', { transcriptPath: '/tmp/a.jsonl' }); // A stores bind intent
412
+ const clientB = await attachToDaemon({ discovery, leadPid: sleeperB.pid, cwd: 'B', onNotify: (m) => notB.push(m) });
413
+ await clientB.call('rebind_current_transcript', { transcriptPath: '/tmp/b.jsonl' }); // B owner + bind intent
414
+ await delay(120);
415
+ check('failover: newest binder (B) owns the pointer', transport._resolveTargetForTest()?.leadPid === sleeperB.pid);
416
+
417
+ const aBefore = notA.length;
418
+ await clientB.close(); // pointer client dies → failover to survivor A
419
+ await waitFor(() => transport._resolveTargetForTest()?.leadPid === sleeperA.pid, 1000);
420
+ check('failover: pointer moves to surviving client (A)',
421
+ transport._resolveTargetForTest()?.leadPid === sleeperA.pid);
422
+ await waitFor(() => notA.slice(aBefore).some((m) => m?.method === REMOTE && m?.params?.state === 'acquired'), 1000);
423
+ check('failover: survivor (A) receives the sticky acquired badge',
424
+ notA.slice(aBefore).filter((m) => m?.method === REMOTE && m?.params?.state === 'acquired').length === 1);
425
+ await waitFor(() => rebinds.some((r) => r.leadPid === sleeperA.pid), 1000);
426
+ check('failover: survivor bind intent re-dispatched to its transcript',
427
+ rebinds.some((r) => r.name === 'rebind_current_transcript'
428
+ && r.args?.transcriptPath === '/tmp/a.jsonl' && r.leadPid === sleeperA.pid));
429
+
430
+ await clientA.close();
431
+ try { sleeperA.kill(); } catch {}
432
+ try { sleeperB.kill(); } catch {}
433
+ await transport.stop();
434
+ try { rmSync(tmp, { recursive: true, force: true }); } catch {}
435
+ }
436
+
437
+ // Reattach-after-pointer-loss: the pointer client's SSE drops and it
438
+ // re-registers (reattach:true, SAME leadPid) with a FRESH token. The pointer
439
+ // must FOLLOW to the new token (old dead-stream entry dropped, no failover/
440
+ // self-superseded), and a notify must reach the NEW stream — not blackhole on
441
+ // the old entry.
442
+ async function pointerReconnectFollowsTest() {
443
+ const tmp = mkdtempSync(path.join(os.tmpdir(), 'mixdog-reconnect-smoke-'));
444
+ const discoveryPath = path.join(tmp, 'channel-daemon.json');
445
+ const transport = createChannelDaemonTransport({
446
+ handleCall: async () => ({ ok: true }),
447
+ discoveryPath, clientGraceMs: 2000, sweepMs: 5000, onClientsEmpty: () => {},
448
+ log: (m) => process.env.DAEMON_SMOKE_VERBOSE && console.log(`[reconnect] ${m}`),
449
+ });
450
+ const { port, token } = await transport.start();
451
+ const s1 = spawn(process.execPath, ['-e', 'setTimeout(()=>{}, 60000)'], { stdio: 'ignore' });
452
+ await delay(80);
453
+
454
+ // Register X (fresh) → owner; open its SSE, then let the stream drop.
455
+ const regX = await rawPost(port, token, '/client/register', { leadPid: s1.pid, cwd: 'X' });
456
+ const notOld = [];
457
+ const sseOld = rawSse(port, token, regX.token, (m) => notOld.push(m));
458
+ await delay(60);
459
+ try { sseOld.destroy(); } catch {}
460
+ await delay(40);
461
+
462
+ // Reconnect re-register (same leadPid) → fresh token; pointer must FOLLOW it.
463
+ const regNew = await rawPost(port, token, '/client/register', { leadPid: s1.pid, cwd: 'X', reattach: true });
464
+ await delay(40);
465
+ check('reconnect: pointer follows the fresh reconnect token',
466
+ transport._resolveTargetForTest()?.token === regNew.token && regNew.token !== regX.token);
467
+ check('reconnect: dead old entry was dropped', !transport._clientsForTest.has(regX.token));
468
+
469
+ // A notify must reach the NEW stream (no blackhole on the old dead entry).
470
+ const notNew = [];
471
+ const sseNew = rawSse(port, token, regNew.token, (m) => notNew.push(m));
472
+ await delay(60);
473
+ transport.notify('notifications/claude/channel', { content: 'to-reconnect' });
474
+ await waitFor(() => notNew.some((m) => m?.params?.content === 'to-reconnect'), 1000);
475
+ check('reconnect: notify reaches the new stream, not the dead old entry',
476
+ notNew.some((m) => m?.params?.content === 'to-reconnect') &&
477
+ !notOld.some((m) => m?.params?.content === 'to-reconnect'));
478
+
479
+ try { sseNew.destroy(); } catch {}
480
+ try { s1.kill(); } catch {}
481
+ await transport.stop();
482
+ try { rmSync(tmp, { recursive: true, force: true }); } catch {}
483
+ }
@@ -8,7 +8,7 @@ process.env.MIXDOG_WORKER_MODE = process.env.MIXDOG_WORKER_MODE || '1';
8
8
 
9
9
  import os from 'node:os';
10
10
  import path from 'node:path';
11
- import { mkdirSync } from 'node:fs';
11
+ import { mkdirSync, writeFileSync } from 'node:fs';
12
12
  import { claimSingletonOwner, releaseSingletonOwner } from '../src/runtime/shared/singleton-owner.mjs';
13
13
  import { createChannelDaemonTransport } from '../src/standalone/channel-daemon-transport.mjs';
14
14
 
@@ -40,6 +40,17 @@ async function main() {
40
40
  if (!claim.owned) { process.exit(0); } // race loser → spawner attaches to winner
41
41
  process.on('exit', () => { try { releaseSingletonOwner(OWNER_PATH, process.pid); } catch {} });
42
42
 
43
+ // Optional env dump for the tool smoke: proves daemonEnv() spawned us with
44
+ // the daemon-mode flags (MIXDOG_CHANNEL_DAEMON=1, MIXDOG_CLI_OWNED=0).
45
+ if (process.env.SMOKE_CHANNEL_ENV_OUT) {
46
+ try {
47
+ writeFileSync(process.env.SMOKE_CHANNEL_ENV_OUT, JSON.stringify({
48
+ cliOwned: process.env.MIXDOG_CLI_OWNED,
49
+ daemon: process.env.MIXDOG_CHANNEL_DAEMON,
50
+ }));
51
+ } catch { /* smoke-only, best-effort */ }
52
+ }
53
+
43
54
  // Stub runtime: echo the call + caller identity, and (for 'fetch') emit a
44
55
  // notify AFTER responding so the smoke can assert targeted routing.
45
56
  const handleCall = async (name, args, ctx) => {
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env node
2
+ // Proves the debounced skills save persists through the ASYNC config path and
3
+ // that the debounce-timer flush routes through async I/O (async icacls), never
4
+ // the sync icacls, on the timer path.
5
+ import test from 'node:test';
6
+ import assert from 'node:assert/strict';
7
+ import { readFileSync, mkdtempSync, rmSync } from 'node:fs';
8
+ import { tmpdir } from 'node:os';
9
+ import { join, resolve, dirname } from 'node:path';
10
+ import { fileURLToPath, pathToFileURL } from 'node:url';
11
+
12
+ const __dirname = dirname(fileURLToPath(import.meta.url));
13
+ const SRC = resolve(__dirname, '../src');
14
+
15
+ test('patchSkillsDisabledAsync persists agent.skills.disabled to disk (async path)', async () => {
16
+ const dataDir = mkdtempSync(join(tmpdir(), 'mixdog-skills-async-'));
17
+ process.env.MIXDOG_DATA_DIR = dataDir;
18
+ // Keep the test hermetic: skip the user-data backup copy tree.
19
+ process.env.MIXDOG_SKIP_USER_DATA_BACKUP = '1';
20
+ try {
21
+ // Import AFTER env is set: shared/config.mjs resolves DATA_DIR at load.
22
+ const cfg = await import(pathToFileURL(join(SRC, 'runtime/agent/orchestrator/config.mjs')).href);
23
+ const out = await cfg.patchSkillsDisabledAsync(['zeta', 'alpha']);
24
+ assert.deepEqual(out.disabled, ['alpha', 'zeta']); // normalized + sorted
25
+ const onDisk = JSON.parse(readFileSync(join(dataDir, 'mixdog-config.json'), 'utf8'));
26
+ assert.deepEqual(onDisk.agent.skills.disabled, ['alpha', 'zeta']);
27
+ // A second async patch must last-writer-win, not append.
28
+ await cfg.patchSkillsDisabledAsync(['beta']);
29
+ const onDisk2 = JSON.parse(readFileSync(join(dataDir, 'mixdog-config.json'), 'utf8'));
30
+ assert.deepEqual(onDisk2.agent.skills.disabled, ['beta']);
31
+ } finally {
32
+ try { rmSync(dataDir, { recursive: true, force: true }); } catch {}
33
+ delete process.env.MIXDOG_DATA_DIR;
34
+ delete process.env.MIXDOG_SKIP_USER_DATA_BACKUP;
35
+ }
36
+ });
37
+
38
+ test('debounce timer path routes through the async flush (async icacls), sync flush retained', () => {
39
+ const lifecycle = readFileSync(`${SRC}/session-runtime/config-lifecycle.mjs`, 'utf8');
40
+ // Skills timer schedules the ASYNC flush, and the async runner uses the async
41
+ // persist twin — so the timer path never hits the sync icacls RMW.
42
+ assert.match(lifecycle, /scheduleSkillsSave\(names\)[\s\S]*?setTimeout\(\(\) => \{ flushSkillsSaveAsync\(\); \}/);
43
+ assert.match(lifecycle, /runSkillsFlushAsync[\s\S]*?cfgMod\.patchSkillsDisabledAsync\(names\)/);
44
+ // Config/backend/outputStyle timers likewise fire the async flush.
45
+ assert.match(lifecycle, /setTimeout\(\(\) => \{ flushConfigSaveAsync\(\); \}/);
46
+ assert.match(lifecycle, /setTimeout\(\(\) => \{ flushBackendSaveAsync\(\); \}/);
47
+ assert.match(lifecycle, /setTimeout\(\(\) => \{ flushOutputStyleSaveAsync\(\); \}/);
48
+ // Sync flushes stay for reloadFullConfig/teardown durability.
49
+ assert.match(lifecycle, /function flushConfigSave\(\)/);
50
+ assert.match(lifecycle, /function flushSkillsSave\(\)/);
51
+ assert.match(lifecycle, /reloadFullConfig[\s\S]*?flushConfigSave\(\);/);
52
+
53
+ const atomic = readFileSync(`${SRC}/runtime/shared/atomic-file.mjs`, 'utf8');
54
+ // The async writer enforces the owner-only ACL via the async icacls variant.
55
+ assert.match(atomic, /export async function writeFileAtomicAsync[\s\S]*?await _enforceOwnerOnlyAclWin32Async\(tmp\)/);
56
+ assert.match(atomic, /_enforceOwnerOnlyAclWin32Async[\s\S]*?_execFileAsync\(icacls/);
57
+ });
@@ -0,0 +1,17 @@
1
+ import { runExplore } from '../src/standalone/explore-tool.mjs';
2
+ const queries = [
3
+ 'standalone explore tool implementation entry point',
4
+ 'V4A patch format parsing/conversion implementation',
5
+ 'recall memory entries persistent store (sqlite) read/query implementation',
6
+ 'output styles: how style definitions are loaded and applied at session runtime',
7
+ 'Discord backend outbound message send implementation',
8
+ 'shell command destructive/danger policy scan before execution',
9
+ 'where does mixdog store per-user data and sessions on this machine (out of repo)',
10
+ 'channel runtime crash logging write path',
11
+ ];
12
+ const t0 = Date.now();
13
+ const res = await runExplore({ query: queries, cwd: 'C:/Project/mixdog' }, { callerCwd: 'C:/Project/mixdog' });
14
+ console.log('BATCH_ELAPSED_MS', Date.now() - t0);
15
+ const txt = res.content?.[0]?.text || '';
16
+ console.log('FAILED_COUNT', (txt.match(/EXPLORATION_FAILED/g) || []).length);
17
+ process.exit(0);
@@ -0,0 +1,145 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { fuzzyRank } from '../src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs';
7
+ import { executeFuzzyFindTool } from '../src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs';
8
+ import { runRg } from '../src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs';
9
+
10
+ // ── fuzzyRank score floor ────────────────────────────────────────────────
11
+
12
+ test('fuzzyRank keeps an exact basename/substring hit and drops scattered noise', () => {
13
+ const items = [
14
+ { path: '.mixdog/data/tool-events.log' },
15
+ // subsequence-only junk: the query chars appear in order but scattered
16
+ // mid-word across dirs, no contiguity or word boundaries.
17
+ { path: 'pgadmin/xtx/oxoxl/exvxexnxtx/sxlxoxg/records.dat' },
18
+ ];
19
+ const ranked = fuzzyRank('tool-events.log', items);
20
+ assert.ok(ranked.length >= 1, 'the real file must survive the floor');
21
+ assert.equal(ranked[0].item.path, '.mixdog/data/tool-events.log');
22
+ assert.ok(
23
+ !ranked.some((r) => r.item.path.startsWith('pgadmin/')),
24
+ 'scattered subsequence junk must be filtered out',
25
+ );
26
+ });
27
+
28
+ test('fuzzyRank floor drops a purely scattered subsequence but keeps a substring', () => {
29
+ const strong = { path: 'src/abcdef.txt' };
30
+ const junk = { path: 'xax/xbxcx/xdxexfx/z.bin' };
31
+ const ranked = fuzzyRank('abcdef', [strong, junk]);
32
+ assert.deepEqual(ranked.map((r) => r.item.path), ['src/abcdef.txt']);
33
+ });
34
+
35
+ // ── hidden-directory discovery via the find tool ─────────────────────────
36
+
37
+ function makeRepo() {
38
+ const root = mkdtempSync(join(tmpdir(), 'mixdog-find-hidden-'));
39
+ mkdirSync(join(root, '.mixdog', 'data'), { recursive: true });
40
+ writeFileSync(join(root, '.mixdog', 'data', 'tool-events.log'), 'x\n');
41
+ mkdirSync(join(root, 'src'), { recursive: true });
42
+ writeFileSync(join(root, 'src', 'unrelated.txt'), 'x\n');
43
+ // .git noise must stay pruned even with hidden default true.
44
+ mkdirSync(join(root, '.git'), { recursive: true });
45
+ writeFileSync(join(root, '.git', 'tool-events.log'), 'x\n');
46
+ return root;
47
+ }
48
+
49
+ test('find surfaces a file under a dot-directory top-ranked (hidden default true)', async () => {
50
+ const root = makeRepo();
51
+ try {
52
+ const out = await executeFuzzyFindTool({ query: 'tool-events.log' }, root);
53
+ const lines = out.split('\n').filter(Boolean);
54
+ assert.ok(lines.length >= 1, `expected a hit, got: ${out}`);
55
+ assert.equal(lines[0], '.mixdog/data/tool-events.log');
56
+ assert.ok(!out.includes('.git/'), '.git must stay pruned as noise');
57
+ } finally {
58
+ rmSync(root, { recursive: true, force: true });
59
+ }
60
+ });
61
+
62
+ test('find hidden:false skips dot-directories', async () => {
63
+ const root = makeRepo();
64
+ try {
65
+ const out = await executeFuzzyFindTool({ query: 'tool-events.log', hidden: false }, root);
66
+ assert.ok(!out.includes('.mixdog'), `hidden:false must not descend dot-dirs: ${out}`);
67
+ } finally {
68
+ rmSync(root, { recursive: true, force: true });
69
+ }
70
+ });
71
+
72
+ // ── narrowed-pass merge / dedup backstop ─────────────────────────────────
73
+
74
+ test('exact-name hit survives among many decoys and is not duplicated', async () => {
75
+ const root = mkdtempSync(join(tmpdir(), 'mixdog-find-merge-'));
76
+ try {
77
+ // A deep exact-name target plus a pile of unrelated decoys. The target
78
+ // matches both the broad enumeration and the query-narrowed pass, so it
79
+ // exercises the merge+dedup path.
80
+ mkdirSync(join(root, 'deep', 'nested', 'here'), { recursive: true });
81
+ writeFileSync(join(root, 'deep', 'nested', 'here', 'tool-events.log'), 'x\n');
82
+ mkdirSync(join(root, 'noise'), { recursive: true });
83
+ for (let i = 0; i < 200; i++) {
84
+ writeFileSync(join(root, 'noise', `decoy-${i}.bin`), 'x\n');
85
+ }
86
+ const out = await executeFuzzyFindTool({ query: 'tool-events.log' }, root);
87
+ const lines = out.split('\n').filter(Boolean);
88
+ assert.equal(lines[0], 'deep/nested/here/tool-events.log');
89
+ const hits = lines.filter((l) => l === 'deep/nested/here/tool-events.log');
90
+ assert.equal(hits.length, 1, `merge must dedup, got ${hits.length}: ${out}`);
91
+ } finally {
92
+ rmSync(root, { recursive: true, force: true });
93
+ }
94
+ });
95
+
96
+ // ── truncation warning surfaces when the broad pass reports .truncated ────
97
+
98
+ test('truncated broad enumeration emits a visible warning, still ranks the exact hit, and is not cached', async () => {
99
+ const root = makeRepo();
100
+ try {
101
+ // Test-only seam: wrap the real runRg so the BROAD pass (the one without
102
+ // `--iglob`) reports truncation via the boxed-String contract the tool
103
+ // relies on (.truncated=true), while the narrowed pass is left intact.
104
+ // Production options never carry __runRg, so the real path is unchanged.
105
+ const truncatingRunRg = async (argsList, execOptions) => {
106
+ const out = await runRg(argsList, execOptions);
107
+ if (argsList.includes('--iglob')) return out; // narrowed backstop unchanged
108
+ const boxed = new String(String(out));
109
+ boxed.truncated = true;
110
+ return boxed;
111
+ };
112
+ const out = await executeFuzzyFindTool(
113
+ { query: 'tool-events.log' }, root, { __runRg: truncatingRunRg },
114
+ );
115
+ assert.ok(out.includes('[warning]'), `truncated broad pass must warn: ${out}`);
116
+ assert.ok(out.includes('truncated at 20MB cap'), `warning text must surface: ${out}`);
117
+ const lines = out.split('\n').filter(Boolean);
118
+ assert.equal(lines[0], '.mixdog/data/tool-events.log', 'exact hit must still rank first');
119
+ // Not cached: a normal follow-up run (no injected truncation) must NOT
120
+ // return the warning — proving the truncated result was never cached.
121
+ const out2 = await executeFuzzyFindTool({ query: 'tool-events.log' }, root);
122
+ assert.ok(!out2.includes('[warning]'), `truncated result must not be cached/served: ${out2}`);
123
+ } finally {
124
+ rmSync(root, { recursive: true, force: true });
125
+ }
126
+ });
127
+
128
+ // ── narrowed backstop treats the query as a literal (glob metachars) ──────
129
+
130
+ test('a query with glob metachars still gets the narrowed backstop', async () => {
131
+ const root = mkdtempSync(join(tmpdir(), 'mixdog-find-glob-'));
132
+ try {
133
+ // "[slug].tsx" contains a character-class metachar; a raw *[slug].tsx*
134
+ // iglob would match one of s/l/u/g, not the literal filename. The tool
135
+ // must escape it so the exact-name file is still surfaced.
136
+ mkdirSync(join(root, 'app'), { recursive: true });
137
+ writeFileSync(join(root, 'app', '[slug].tsx'), 'x\n');
138
+ writeFileSync(join(root, 'app', 'slug.tsx'), 'x\n'); // class-match decoy
139
+ const out = await executeFuzzyFindTool({ query: '[slug].tsx' }, root);
140
+ const lines = out.split('\n').filter(Boolean);
141
+ assert.ok(lines.includes('app/[slug].tsx'), `literal metachar hit must surface: ${out}`);
142
+ } finally {
143
+ rmSync(root, { recursive: true, force: true });
144
+ }
145
+ });