mixdog 0.9.22 → 0.9.24

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 (132) hide show
  1. package/README.md +1 -4
  2. package/package.json +1 -1
  3. package/scripts/boot-smoke.mjs +1 -1
  4. package/scripts/channel-daemon-smoke.mjs +483 -0
  5. package/scripts/channel-daemon-stub.mjs +80 -0
  6. package/scripts/debounced-skills-async-save-test.mjs +57 -0
  7. package/scripts/explore-bench-tmp.mjs +17 -0
  8. package/scripts/find-fuzzy-hidden-test.mjs +145 -0
  9. package/scripts/mcp-grace-deferred-test.mjs +149 -0
  10. package/scripts/statusline-quota-hysteresis-test.mjs +37 -0
  11. package/scripts/tool-smoke.mjs +68 -47
  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 +226 -0
  18. package/src/runtime/agent/orchestrator/mcp/client.mjs +184 -33
  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/providers/openai-compat-stream.mjs +1 -1
  22. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +5 -3
  23. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +2 -2
  24. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +2 -2
  25. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +1 -1
  26. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +24 -7
  27. package/src/runtime/agent/orchestrator/session/loop/completion-guards.mjs +3 -2
  28. package/src/runtime/agent/orchestrator/session/loop/deferred-call-through.mjs +6 -3
  29. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +1 -1
  30. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +37 -4
  31. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +11 -1
  32. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +14 -3
  33. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +40 -6
  34. package/src/runtime/agent/orchestrator/session/store.mjs +30 -14
  35. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +2 -1
  36. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +13 -5
  37. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +62 -24
  38. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +7 -6
  39. package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +20 -0
  40. package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +34 -3
  41. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +220 -27
  42. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +61 -0
  43. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +1 -1
  44. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +67 -27
  45. package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +6 -5
  46. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +54 -5
  47. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +97 -54
  48. package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +49 -31
  49. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +35 -2
  50. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +126 -22
  51. package/src/runtime/channels/lib/crash-log.mjs +4 -2
  52. package/src/runtime/channels/lib/output-forwarder.mjs +2 -1
  53. package/src/runtime/channels/lib/owned-runtime.mjs +67 -223
  54. package/src/runtime/channels/lib/owner-heartbeat.mjs +9 -62
  55. package/src/runtime/channels/lib/parent-bridge.mjs +16 -1
  56. package/src/runtime/channels/lib/runtime-paths.mjs +32 -113
  57. package/src/runtime/channels/lib/session-discovery.mjs +2 -1
  58. package/src/runtime/channels/lib/tool-dispatch.mjs +22 -14
  59. package/src/runtime/channels/lib/tool-format.mjs +7 -2
  60. package/src/runtime/channels/lib/worker-main.mjs +42 -30
  61. package/src/runtime/memory/index.mjs +54 -7
  62. package/src/runtime/memory/lib/embedding-warmup.mjs +7 -0
  63. package/src/runtime/memory/lib/pg/process.mjs +85 -40
  64. package/src/runtime/memory/lib/pg/supervisor.mjs +82 -17
  65. package/src/runtime/memory/lib/query-handlers.mjs +4 -1
  66. package/src/runtime/memory/lib/recall-format.mjs +7 -3
  67. package/src/runtime/memory/tool-defs.mjs +1 -1
  68. package/src/runtime/shared/atomic-file.mjs +150 -6
  69. package/src/runtime/shared/background-tasks.mjs +1 -1
  70. package/src/runtime/shared/config.mjs +53 -1
  71. package/src/runtime/shared/tool-execution-contract.mjs +1 -1
  72. package/src/runtime/shared/tool-primitives.mjs +31 -1
  73. package/src/runtime/shared/tool-surface.mjs +42 -13
  74. package/src/runtime/shared/update-checker.mjs +3 -0
  75. package/src/runtime/shared/user-data-guard.mjs +66 -0
  76. package/src/session-runtime/config-lifecycle.mjs +221 -20
  77. package/src/session-runtime/lifecycle-api.mjs +9 -0
  78. package/src/session-runtime/mcp-glue.mjs +93 -1
  79. package/src/session-runtime/resource-api.mjs +62 -8
  80. package/src/session-runtime/runtime-core.mjs +118 -4
  81. package/src/session-runtime/session-text.mjs +41 -0
  82. package/src/session-runtime/session-turn-api.mjs +50 -0
  83. package/src/session-runtime/settings-api.mjs +8 -1
  84. package/src/session-runtime/tool-catalog.mjs +350 -38
  85. package/src/session-runtime/tool-defs.mjs +7 -7
  86. package/src/session-runtime/workflow.mjs +2 -1
  87. package/src/standalone/channel-admin.mjs +32 -3
  88. package/src/standalone/channel-daemon-client.mjs +226 -0
  89. package/src/standalone/channel-daemon-transport.mjs +545 -0
  90. package/src/standalone/channel-daemon.mjs +176 -0
  91. package/src/standalone/channel-worker.mjs +224 -4
  92. package/src/standalone/explore-tool.mjs +87 -15
  93. package/src/standalone/hook-bus.mjs +71 -3
  94. package/src/tui/App.jsx +107 -19
  95. package/src/tui/app/clipboard.mjs +39 -19
  96. package/src/tui/app/doctor.mjs +57 -0
  97. package/src/tui/app/extension-pickers.mjs +53 -9
  98. package/src/tui/app/maintenance-pickers.mjs +0 -5
  99. package/src/tui/app/slash-dispatch.mjs +4 -4
  100. package/src/tui/app/text-layout.mjs +11 -0
  101. package/src/tui/app/use-mouse-input.mjs +235 -51
  102. package/src/tui/app/use-prompt-handlers.mjs +49 -30
  103. package/src/tui/app/use-transcript-scroll.mjs +124 -27
  104. package/src/tui/app/use-transcript-window.mjs +55 -1
  105. package/src/tui/components/Message.jsx +1 -1
  106. package/src/tui/components/PromptInput.jsx +3 -1
  107. package/src/tui/components/QueuedCommands.jsx +21 -10
  108. package/src/tui/components/StatusLine.jsx +3 -3
  109. package/src/tui/components/ToolExecution.jsx +16 -4
  110. package/src/tui/components/TranscriptItem.jsx +1 -1
  111. package/src/tui/dist/index.mjs +820 -326
  112. package/src/tui/engine/agent-job-feed.mjs +5 -0
  113. package/src/tui/engine/notification-plan.mjs +5 -0
  114. package/src/tui/engine/session-api.mjs +23 -8
  115. package/src/tui/engine/session-flow.mjs +6 -0
  116. package/src/tui/engine/tool-card-results.mjs +14 -5
  117. package/src/tui/engine/turn.mjs +9 -2
  118. package/src/tui/engine.mjs +32 -5
  119. package/src/tui/index.jsx +62 -18
  120. package/src/tui/paste-attachments.mjs +26 -0
  121. package/src/ui/statusline-agents.mjs +36 -0
  122. package/src/ui/statusline.mjs +60 -10
  123. package/src/ui/tool-card.mjs +8 -1
  124. package/src/vendor/statusline/bin/statusline-route.mjs +23 -7
  125. package/src/workflows/bench/WORKFLOW.md +46 -0
  126. package/src/workflows/default/WORKFLOW.md +6 -0
  127. package/src/workflows/solo/WORKFLOW.md +5 -0
  128. package/vendor/ink/build/ink.js +23 -1
  129. package/vendor/ink/build/output.js +154 -71
  130. package/vendor/ink/build/render-node-to-output.js +44 -2
  131. package/vendor/ink/build/render.js +4 -0
  132. package/vendor/ink/build/renderer.js +4 -1
package/README.md CHANGED
@@ -23,7 +23,7 @@ mixdog
23
23
  ```
24
24
 
25
25
  First run walks you through onboarding: provider auth, model pick, and
26
- workflow setup. Re-run it anytime with `mixdog --onboarding`.
26
+ workflow setup.
27
27
 
28
28
  ## Terminal-Bench 2.1 — 89.9% (self-reported)
29
29
 
@@ -117,9 +117,6 @@ mixdog --readonly
117
117
 
118
118
  # Enable remote/channel mode for this session
119
119
  mixdog --remote
120
-
121
- # Re-run first-run setup
122
- mixdog --onboarding
123
120
  ```
124
121
 
125
122
  Headless role mode is also supported:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mixdog",
3
- "version": "0.9.22",
3
+ "version": "0.9.24",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Standalone mixdog coding-agent CLI/TUI workspace.",
@@ -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']) {
@@ -0,0 +1,483 @@
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 http from 'node:http';
15
+ import { spawn } from 'node:child_process';
16
+ import { mkdtempSync, rmSync } from 'node:fs';
17
+ import { fileURLToPath } from 'node:url';
18
+ import { createChannelDaemonTransport } from '../src/standalone/channel-daemon-transport.mjs';
19
+ import { attachToDaemon } from '../src/standalone/channel-daemon-client.mjs';
20
+ import { createParentBridge, setChannelNotifySink } from '../src/runtime/channels/lib/parent-bridge.mjs';
21
+
22
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
23
+ const STUB_ENTRY = path.join(HERE, 'channel-daemon-stub.mjs');
24
+
25
+ const delay = (ms) => new Promise((r) => setTimeout(r, ms));
26
+ const waitFor = async (fn, ms = 1500) => {
27
+ const end = Date.now() + ms;
28
+ while (Date.now() < end) { if (fn()) return true; await delay(20); }
29
+ return fn();
30
+ };
31
+ let failures = 0;
32
+ function check(label, cond) {
33
+ const ok = !!cond;
34
+ if (!ok) failures++;
35
+ console.log(`${ok ? 'PASS' : 'FAIL'} ${label}`);
36
+ }
37
+
38
+ async function main() {
39
+ const tmp = mkdtempSync(path.join(os.tmpdir(), 'mixdog-daemon-smoke-'));
40
+ const discoveryPath = path.join(tmp, 'channel-daemon.json');
41
+ let clientsEmptyFired = false;
42
+ let sideEffects = 0; // counts non-idempotent 'reply' dispatches
43
+
44
+ // Stub channels runtime: echoes the call + the caller identity the transport
45
+ // resolved, so we can assert per-client leadPid threading.
46
+ const handleCall = async (name, args, ctx) => {
47
+ if (name === 'reply') { sideEffects++; await delay(40); } // simulate a side effect
48
+ return { ok: true, name, args, leadPid: ctx.leadPid };
49
+ };
50
+
51
+ const transport = createChannelDaemonTransport({
52
+ handleCall,
53
+ discoveryPath,
54
+ clientGraceMs: 250,
55
+ sweepMs: 1000,
56
+ onClientsEmpty: () => { clientsEmptyFired = true; },
57
+ log: (m) => process.env.DAEMON_SMOKE_VERBOSE && console.log(`[daemon] ${m}`),
58
+ });
59
+ const { port, token } = await transport.start();
60
+ const discovery = { port, token, pid: process.pid };
61
+
62
+ const notA = [];
63
+ const notB = [];
64
+ const clientA = await attachToDaemon({ discovery, leadPid: process.pid, cwd: 'A', onNotify: (m) => notA.push(m) });
65
+ const clientB = await attachToDaemon({ discovery, leadPid: process.pid, cwd: 'B', onNotify: (m) => notB.push(m) });
66
+ await delay(150); // let both SSE streams attach
67
+
68
+ check('two clients registered', transport._clientsForTest.size === 2);
69
+
70
+ // (2) calls round-trip per client.
71
+ const rA = await clientA.call('reload_config', { k: 1 });
72
+ check('client A call round-trips', rA?.ok === true && rA?.name === 'reload_config');
73
+ const rB = await clientB.call('fetch', { q: 2 });
74
+ check('client B call round-trips', rB?.ok === true && rB?.name === 'fetch');
75
+
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' });
80
+ await delay(120);
81
+ check('notify #1 targets newest registrant (B) only',
82
+ notB.length === 1 && notB[0]?.params?.content === 'to-B' && notA.length === 0);
83
+
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' });
87
+ await delay(120);
88
+ check('notify #2 delivered to A only after rebind',
89
+ notA.length === 1 && notA[0]?.params?.content === 'to-A' && notB.length === 1);
90
+
91
+ // (fix 1) idempotent replay: two /call with the SAME callId (a retried
92
+ // transport failure) must run the non-idempotent side-effect exactly once.
93
+ const dupId = 'dup-call-1';
94
+ const [d1, d2] = await Promise.all([
95
+ clientA.call('reply', { n: 1 }, { callId: dupId }),
96
+ clientA.call('reply', { n: 1 }, { callId: dupId }),
97
+ ]);
98
+ check('idempotent replay: same callId → exactly one side-effect',
99
+ sideEffects === 1 && d1?.ok === true && d2?.ok === true);
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
+
149
+ // (5) deregister both → self-shutdown fires after the client grace window.
150
+ await clientA.close();
151
+ await clientB.close();
152
+ await delay(600);
153
+ check('daemon self-shutdown fired after last client left', clientsEmptyFired === true);
154
+
155
+ await transport.stop();
156
+ try { rmSync(tmp, { recursive: true, force: true }); } catch {}
157
+
158
+ await flipTest();
159
+ await pointerStealTest();
160
+ await reattachBufferTest();
161
+ await pointerFailoverTest();
162
+ await pointerReconnectFollowsTest();
163
+
164
+ console.log(failures === 0 ? '\nALL PASS' : `\n${failures} FAILURE(S)`);
165
+ process.exit(failures === 0 ? 0 : 1);
166
+ }
167
+
168
+ // Flip coverage: two TUIs attach to ONE daemon via the REAL channel-worker.mjs
169
+ // spawn-or-attach path (against the stub daemon entry — no Discord token). The
170
+ // first worker spawns the daemon; the second attaches to it.
171
+ async function flipTest() {
172
+ const tmp = mkdtempSync(path.join(os.tmpdir(), 'mixdog-flip-smoke-'));
173
+ // channel-worker.mjs resolves DAEMON_ENTRY + runtimeRoot at import/call time,
174
+ // so the env must be set BEFORE importing it.
175
+ process.env.MIXDOG_RUNTIME_ROOT = tmp;
176
+ process.env.MIXDOG_DATA_DIR = tmp;
177
+ process.env.MIXDOG_CHANNEL_DAEMON_ENTRY = STUB_ENTRY;
178
+ const { createStandaloneChannelWorker } = await import('../src/standalone/channel-worker.mjs');
179
+ const { readDaemonDiscovery } = await import('../src/standalone/channel-daemon-client.mjs');
180
+ const discFile = path.join(tmp, 'channel-daemon.json');
181
+
182
+ const notA = [];
183
+ const notB = [];
184
+ const mk = (onNotify) => createStandaloneChannelWorker({ entry: STUB_ENTRY, rootDir: tmp, dataDir: tmp, cwd: tmp, onNotify });
185
+ const wA = mk((m) => notA.push(m));
186
+ const wB = mk((m) => notB.push(m));
187
+
188
+ await wA.start(); // spawns the (stub) daemon then attaches
189
+ await wB.start(); // attaches to the SAME daemon (no second spawn)
190
+ check('flip: worker A call round-trips', (await wA.execute('reply', { x: 1 }))?.ok === true);
191
+ check('flip: worker B call round-trips', (await wB.execute('reply', { y: 2 }))?.ok === true);
192
+
193
+ // B claims the transcript → routing pointer moves to B; a notify emitted by
194
+ // the daemon (on A's 'fetch') must reach B only, never both TUIs.
195
+ await wB.execute('rebind_current_transcript', { transcriptPath: path.join(tmp, 'b.jsonl') });
196
+ await wA.execute('fetch', { probe: true });
197
+ await waitFor(() => notB.some((m) => m?.params?.content === 'ping-from-stub'), 1500);
198
+ check('flip: notify targets the pointer TUI (B) only',
199
+ notB.some((m) => m?.params?.content === 'ping-from-stub') &&
200
+ !notA.some((m) => m?.params?.content === 'ping-from-stub'));
201
+
202
+ // (fix 1) daemon death mid-session: SIGKILL it, then the surviving client's
203
+ // next call must transparently respawn + re-attach (bounded retry inside
204
+ // execute) — no TUI process restart.
205
+ const beforeKill = readDaemonDiscovery(discFile);
206
+ if (beforeKill?.pid) { try { process.kill(beforeKill.pid, 'SIGKILL'); } catch {} }
207
+ await delay(400);
208
+ let recovered = false;
209
+ try { recovered = (await wA.execute('reply', { after: 'kill' }))?.ok === true; } catch {}
210
+ const afterKill = readDaemonDiscovery(discFile);
211
+ check('flip: call transparently respawns+reattaches after daemon death',
212
+ recovered === true && !!afterKill?.pid && afterKill.pid !== beforeKill?.pid);
213
+
214
+ await wA.stop();
215
+ await wB.stop();
216
+ await delay(700);
217
+ const disc = readDaemonDiscovery(discFile);
218
+ check('flip: daemon self-shutdown after last TUI detaches', disc === null);
219
+ try { rmSync(tmp, { recursive: true, force: true }); } catch {}
220
+ }
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
+
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
+ }
@@ -0,0 +1,80 @@
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, writeFileSync } 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
+ // 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
+
54
+ // Stub runtime: echo the call + caller identity, and (for 'fetch') emit a
55
+ // notify AFTER responding so the smoke can assert targeted routing.
56
+ const handleCall = async (name, args, ctx) => {
57
+ if (name === 'fetch') {
58
+ setTimeout(() => { try { transport.notify('notifications/claude/channel', { content: 'ping-from-stub' }); } catch {} }, 20);
59
+ }
60
+ return { ok: true, name, args, leadPid: ctx.leadPid };
61
+ };
62
+
63
+ transport = createChannelDaemonTransport({
64
+ handleCall,
65
+ discoveryPath: DISCOVERY_PATH,
66
+ clientGraceMs: 250,
67
+ sweepMs: 1000,
68
+ log,
69
+ onClientsEmpty: () => { void shutdown('no live clients'); },
70
+ });
71
+ const { port, token } = await transport.start();
72
+ if (process.send) { try { process.send({ type: 'ready', port, token }); } catch {} }
73
+ log(`ready port=${port} pid=${process.pid}`);
74
+ }
75
+
76
+ process.on('SIGTERM', () => { void shutdown('SIGTERM'); });
77
+ process.on('SIGINT', () => { void shutdown('SIGINT'); });
78
+ process.on('message', (msg) => { if (msg && msg.type === 'shutdown') void shutdown('IPC shutdown'); });
79
+
80
+ main().catch((err) => { log(`fatal: ${err?.stack || err}`); void shutdown('fatal', 2); });
@@ -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
+ });