mixdog 0.9.19 → 0.9.21

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 (120) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +100 -34
  3. package/package.json +3 -2
  4. package/scripts/build-tui.mjs +6 -0
  5. package/scripts/hook-bus-test.mjs +8 -0
  6. package/scripts/log-writer-guard-smoke.mjs +131 -0
  7. package/scripts/reactive-compact-persist-smoke.mjs +22 -6
  8. package/scripts/recall-bench-cases.json +0 -1
  9. package/scripts/recall-quality-cases.json +1 -2
  10. package/scripts/session-ingest-compaction-smoke.mjs +241 -0
  11. package/scripts/tool-smoke.mjs +150 -45
  12. package/src/defaults/skills/setup/SKILL.md +327 -0
  13. package/src/help.mjs +2 -5
  14. package/src/lib/mixdog-debug.cjs +13 -0
  15. package/src/mixdog-session-runtime.mjs +7 -3328
  16. package/src/runtime/agent/orchestrator/context/collect.mjs +33 -9
  17. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +34 -2
  18. package/src/runtime/agent/orchestrator/providers/gemini.mjs +3 -0
  19. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +67 -10
  20. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +3 -0
  21. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +40 -3
  22. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +73 -3
  23. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +663 -0
  24. package/src/runtime/agent/orchestrator/session/compact/engine.mjs +6 -0
  25. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +153 -0
  26. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +49 -0
  27. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +9 -1
  28. package/src/runtime/agent/orchestrator/session/loop.mjs +8 -1860
  29. package/src/runtime/agent/orchestrator/session/manager/agent-runtime-singleton.mjs +29 -0
  30. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +686 -0
  31. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +60 -12
  32. package/src/runtime/agent/orchestrator/session/manager/env-utils.mjs +8 -0
  33. package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +159 -0
  34. package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +143 -0
  35. package/src/runtime/agent/orchestrator/session/manager/prefetch-bridge.mjs +268 -0
  36. package/src/runtime/agent/orchestrator/session/manager/provider-cache-key.mjs +22 -0
  37. package/src/runtime/agent/orchestrator/session/manager/runtime-loaders.mjs +26 -0
  38. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +124 -0
  39. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +258 -0
  40. package/src/runtime/agent/orchestrator/session/manager/session-errors.mjs +20 -0
  41. package/src/runtime/agent/orchestrator/session/manager/session-id.mjs +9 -0
  42. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +475 -0
  43. package/src/runtime/agent/orchestrator/session/manager/session-lock.mjs +23 -0
  44. package/src/runtime/agent/orchestrator/session/manager.mjs +88 -2285
  45. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +440 -0
  46. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +153 -0
  47. package/src/runtime/agent/orchestrator/session/store.mjs +4 -1
  48. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +642 -0
  49. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +23 -3
  50. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +108 -2
  51. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +15 -3
  52. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +7 -0
  53. package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +24 -2
  54. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +2 -2
  55. package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +72 -8
  56. package/src/runtime/agent/orchestrator/tools/shell-policy-danger-target.mjs +5 -1
  57. package/src/runtime/channels/index.mjs +6 -2183
  58. package/src/runtime/channels/lib/inbound-handler.mjs +328 -0
  59. package/src/runtime/channels/lib/interaction-handlers.mjs +260 -0
  60. package/src/runtime/channels/lib/network-retry.mjs +23 -0
  61. package/src/runtime/channels/lib/owned-runtime.mjs +627 -0
  62. package/src/runtime/channels/lib/runtime-paths.mjs +20 -9
  63. package/src/runtime/channels/lib/transcript-binding.mjs +336 -0
  64. package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +39 -2
  65. package/src/runtime/channels/lib/worker-bootstrap.mjs +97 -0
  66. package/src/runtime/channels/lib/worker-ipc.mjs +201 -0
  67. package/src/runtime/channels/lib/worker-main.mjs +777 -0
  68. package/src/runtime/memory/index.mjs +163 -1725
  69. package/src/runtime/memory/lib/embedding-provider.mjs +4 -2
  70. package/src/runtime/memory/lib/embedding-worker.mjs +47 -6
  71. package/src/runtime/memory/lib/http-router.mjs +811 -0
  72. package/src/runtime/memory/lib/memory-action-handlers.mjs +901 -0
  73. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +6 -0
  74. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +46 -9
  75. package/src/runtime/memory/lib/memory-cycle2.mjs +87 -11
  76. package/src/runtime/memory/lib/memory-embed.mjs +28 -7
  77. package/src/runtime/memory/lib/memory-recall-store.mjs +4 -4
  78. package/src/runtime/memory/lib/memory.mjs +39 -0
  79. package/src/runtime/memory/lib/query-handlers.mjs +2 -2
  80. package/src/runtime/memory/lib/session-ingest-runtime.mjs +401 -0
  81. package/src/runtime/shared/atomic-file.mjs +138 -80
  82. package/src/runtime/shared/child-guardian.mjs +61 -3
  83. package/src/session-runtime/boot-profile.mjs +36 -0
  84. package/src/session-runtime/channel-config-api.mjs +70 -0
  85. package/src/session-runtime/context-status.mjs +181 -0
  86. package/src/session-runtime/env.mjs +17 -0
  87. package/src/session-runtime/lifecycle-api.mjs +242 -0
  88. package/src/session-runtime/model-route-api.mjs +198 -0
  89. package/src/session-runtime/provider-auth-api.mjs +135 -0
  90. package/src/session-runtime/resource-api.mjs +282 -0
  91. package/src/session-runtime/runtime-core.mjs +2104 -0
  92. package/src/session-runtime/session-turn-api.mjs +274 -0
  93. package/src/session-runtime/tool-catalog.mjs +18 -264
  94. package/src/session-runtime/tool-defs.mjs +2 -2
  95. package/src/session-runtime/workflow-agents-api.mjs +238 -0
  96. package/src/standalone/agent-tool.mjs +2 -2
  97. package/src/standalone/channel-worker.mjs +67 -7
  98. package/src/standalone/folder-dialog.mjs +4 -1
  99. package/src/standalone/memory-runtime-proxy.mjs +154 -17
  100. package/src/standalone/seeds.mjs +28 -1
  101. package/src/tui/App.jsx +40 -28
  102. package/src/tui/app/core-memory-picker.mjs +1 -1
  103. package/src/tui/app/doctor.mjs +175 -0
  104. package/src/tui/app/slash-commands.mjs +1 -0
  105. package/src/tui/app/slash-dispatch.mjs +9 -0
  106. package/src/tui/app/use-mouse-input.mjs +6 -0
  107. package/src/tui/app/use-transcript-scroll.mjs +77 -3
  108. package/src/tui/dist/index.mjs +2851 -2162
  109. package/src/tui/engine/context-state.mjs +145 -0
  110. package/src/tui/engine/prompt-history.mjs +27 -0
  111. package/src/tui/engine/session-api-ext.mjs +478 -0
  112. package/src/tui/engine/session-api.mjs +564 -0
  113. package/src/tui/engine/session-flow.mjs +485 -0
  114. package/src/tui/engine/turn.mjs +1078 -0
  115. package/src/tui/engine.mjs +68 -2620
  116. package/src/tui/index.jsx +7 -0
  117. package/vendor/ink/build/ink.js +16 -1
  118. package/vendor/ink/build/output.js +30 -4
  119. package/vendor/ink/build/render.js +5 -0
  120. package/src/workflows/sequential/WORKFLOW.md +0 -51
@@ -0,0 +1,336 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ // Transcript binding / rebind / pending-rearm cluster extracted from
4
+ // channels/index.mjs (behavior-preserving). Bound to live runtime getters and
5
+ // shared primitives so file-level reference semantics are unchanged.
6
+ export function createTranscriptBinding({
7
+ forwarder,
8
+ statusState,
9
+ readActiveInstance,
10
+ refreshActiveInstance,
11
+ instanceId,
12
+ memoryIngestTranscript,
13
+ memoryDrainBuffer,
14
+ dropTrace,
15
+ discoverSessionBoundTranscript,
16
+ sameResolvedPath,
17
+ getConfig,
18
+ getChannelBridgeActive,
19
+ getBridgeRuntimeConnected,
20
+ RUNTIME_ROOT,
21
+ }) {
22
+ function sessionIdFromTranscriptPath(transcriptPath) {
23
+ const base = path.basename(transcriptPath);
24
+ return base.endsWith(".jsonl") ? base.slice(0, -6) : "";
25
+ }
26
+ function getPersistedTranscriptPath() {
27
+ const state = statusState.read();
28
+ if (typeof state.transcriptPath === "string" && state.transcriptPath) return state.transcriptPath;
29
+ return readActiveInstance()?.transcriptPath ?? "";
30
+ }
31
+ function pickUsableTranscriptPath(bound, previousPath) {
32
+ if (bound?.exists) return bound.transcriptPath;
33
+ if (!previousPath) return "";
34
+ if (!bound?.sessionId) return previousPath;
35
+ return sessionIdFromTranscriptPath(previousPath) === bound.sessionId ? previousPath : "";
36
+ }
37
+ function applyTranscriptBinding(channelId, transcriptPath, options = {}) {
38
+ if (!transcriptPath) return;
39
+ forwarder.setContext(channelId, transcriptPath, {
40
+ replayFromStart: options.replayFromStart,
41
+ catchUpFromPersisted: options.catchUpFromPersisted,
42
+ recoverUnsyncedTail: options.recoverUnsyncedTail,
43
+ });
44
+ const boundTranscriptPath = forwarder.transcriptPath || transcriptPath;
45
+ forwarder.startWatch();
46
+ void memoryIngestTranscript(boundTranscriptPath, { cwd: options.cwd });
47
+ // Opportunistic drain: an ingest that had to buffer (memory port not yet
48
+ // published) leaves entry-/ingest- files behind; kick the drainer so they
49
+ // replay as soon as the port appears, without waiting for the periodic tick.
50
+ void memoryDrainBuffer().catch(() => {});
51
+ // onlyIfOwned: binds happen on the owned path, but discovery/poll loops
52
+ // above can outlast an ownership handoff — never overwrite a newer owner.
53
+ refreshActiveInstance(instanceId, { channelId, transcriptPath: boundTranscriptPath }, { onlyIfOwned: true });
54
+ if (options.persistStatus !== false) {
55
+ statusState.update((state) => {
56
+ const wasSameTranscript = typeof state.transcriptPath === "string"
57
+ && state.transcriptPath
58
+ && sameResolvedPath(state.transcriptPath, boundTranscriptPath);
59
+ const prevSentCount = Number(state.sentCount ?? 0);
60
+ const nextSentCount = Number(forwarder.sentCount ?? 0);
61
+ state.channelId = channelId;
62
+ state.transcriptPath = boundTranscriptPath;
63
+ state.lastFileSize = forwarder.lastFileSize;
64
+ if (wasSameTranscript) {
65
+ state.sentCount = Math.max(
66
+ Number.isFinite(prevSentCount) ? prevSentCount : 0,
67
+ Number.isFinite(nextSentCount) ? nextSentCount : 0,
68
+ );
69
+ if (forwarder.lastHash) state.lastSentHash = forwarder.lastHash;
70
+ else if (typeof state.lastSentHash !== "string") state.lastSentHash = "";
71
+ if (!Number.isFinite(Number(state.lastSentTime))) state.lastSentTime = 0;
72
+ if (typeof state.sessionIdle !== "boolean") state.sessionIdle = false;
73
+ } else {
74
+ state.sentCount = forwarder.sentCount;
75
+ state.lastSentHash = forwarder.lastHash;
76
+ state.lastSentTime = 0;
77
+ state.sessionIdle = false;
78
+ }
79
+ if (options.cwd !== undefined) state.sessionCwd = options.cwd ?? null;
80
+ else if (!wasSameTranscript) state.sessionCwd = null;
81
+ });
82
+ }
83
+ }
84
+ // ── Pending-transcript re-arm ────────────────────────────────────────
85
+ // fs.watch cannot watch a file that does not exist yet, so when we bind a
86
+ // session's known-but-not-yet-written transcript path, OutputForwarder.
87
+ // startWatch() silently fails (watch.start.catch) and the file's later
88
+ // creation is never observed. This bounded poll bridges that gap: once the
89
+ // transcript-writer creates the file, we install the watch and forward the
90
+ // backlog. It self-cancels on success, on timeout, and whenever a fresh
91
+ // (re)bind supersedes it — so no timers leak and no double-forward occurs.
92
+ let _pendingRearmTimer = null;
93
+ const PENDING_REARM_INTERVAL_MS = 250;
94
+ const PENDING_REARM_MAX_MS = 60_000;
95
+ function cancelPendingTranscriptRearm() {
96
+ if (_pendingRearmTimer) {
97
+ clearTimeout(_pendingRearmTimer);
98
+ _pendingRearmTimer = null;
99
+ dropTrace("rebind.rearm.cancel");
100
+ }
101
+ }
102
+ function schedulePendingTranscriptRearm(channelId, boundPath) {
103
+ cancelPendingTranscriptRearm();
104
+ if (!boundPath) return;
105
+ const deadline = Date.now() + PENDING_REARM_MAX_MS;
106
+ dropTrace("rebind.rearm.schedule", { channelId, path: boundPath });
107
+ const tick = () => {
108
+ _pendingRearmTimer = null;
109
+ // A different transcript got bound in the meantime — abandon this poll.
110
+ if (forwarder.transcriptPath !== boundPath) {
111
+ dropTrace("rebind.rearm.superseded", { path: boundPath, now: forwarder.transcriptPath || "(none)" });
112
+ return;
113
+ }
114
+ // Ownership may have been lost (bridge deactivated / superseded owner)
115
+ // while this poll was pending. Do not reinstall the fs.watch handle after
116
+ // teardown; startWatch() is not owner-gated so guard it here.
117
+ if (!getBridgeRuntimeConnected()) {
118
+ dropTrace("rebind.rearm.not-owner", { path: boundPath });
119
+ return;
120
+ }
121
+ if (fs.existsSync(boundPath)) {
122
+ dropTrace("rebind.rearm.fire", { channelId, path: boundPath });
123
+ forwarder.startWatch();
124
+ void forwarder.forwardNewText().catch((err) => {
125
+ try { process.stderr.write(`mixdog: rearm forwardNewText rejection: ${err?.stack || err}\n`); } catch {}
126
+ });
127
+ return;
128
+ }
129
+ if (Date.now() >= deadline) {
130
+ dropTrace("rebind.rearm.timeout", { channelId, path: boundPath });
131
+ return;
132
+ }
133
+ _pendingRearmTimer = setTimeout(tick, PENDING_REARM_INTERVAL_MS);
134
+ _pendingRearmTimer?.unref?.();
135
+ };
136
+ _pendingRearmTimer = setTimeout(tick, PENDING_REARM_INTERVAL_MS);
137
+ _pendingRearmTimer?.unref?.();
138
+ }
139
+ async function rebindTranscriptContext(channelId, options = {}) {
140
+ const previousPath = options.previousPath ?? "";
141
+ const mode = options.mode ?? "same";
142
+ // A new (re)bind supersedes any pending re-arm poll left over from a prior
143
+ // bind of a not-yet-existing transcript, so we never leak timers or
144
+ // double-forward once the fresh bind takes over.
145
+ cancelPendingTranscriptRearm();
146
+ const explicitTranscriptPath = typeof options.transcriptPath === "string" ? options.transcriptPath.trim() : "";
147
+ if (explicitTranscriptPath) {
148
+ let explicitExists = false;
149
+ try {
150
+ explicitExists = fs.statSync(explicitTranscriptPath).isFile();
151
+ } catch {
152
+ explicitExists = false;
153
+ }
154
+ if (explicitExists) {
155
+ applyTranscriptBinding(channelId, explicitTranscriptPath, {
156
+ replayFromStart: Boolean(options.catchUp),
157
+ catchUpFromPersisted: options.catchUpFromPersisted,
158
+ persistStatus: options.persistStatus,
159
+ recoverUnsyncedTail: options.recoverUnsyncedTail,
160
+ });
161
+ if (options.catchUp || options.catchUpFromPersisted) {
162
+ await forwarder.forwardNewText();
163
+ }
164
+ return explicitTranscriptPath;
165
+ }
166
+ }
167
+ let sawPendingTranscript = false;
168
+ let pendingSessionId = "";
169
+ // Distinct from sawPendingTranscript/pendingSessionId (which only track the
170
+ // sessionId): remember the FULL not-yet-on-disk candidate — its concrete
171
+ // transcriptPath (from the session record) + cwd — so we can bind it after
172
+ // the loop even though the `.jsonl` does not exist yet. This breaks the
173
+ // chicken-and-egg deadlock where the first assistant reply is only written
174
+ // seconds after inbound time.
175
+ let pendingTranscriptPath = "";
176
+ let pendingTranscriptCwd = null;
177
+ const maxAttempts = Number.isFinite(options.maxAttempts) ? Math.max(1, Math.floor(options.maxAttempts)) : 30;
178
+ const pollMs = Number.isFinite(options.pollMs) ? Math.max(25, Math.floor(options.pollMs)) : 150;
179
+ for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
180
+ const bound = discoverSessionBoundTranscript();
181
+ if (bound?.exists) {
182
+ const acceptable = mode === "same" || !previousPath || bound.transcriptPath !== previousPath;
183
+ if (acceptable) {
184
+ const replayFromStart = Boolean(
185
+ options.catchUp && !previousPath && sawPendingTranscript && pendingSessionId === bound.sessionId
186
+ );
187
+ applyTranscriptBinding(channelId, bound.transcriptPath, {
188
+ replayFromStart,
189
+ catchUpFromPersisted: options.catchUpFromPersisted,
190
+ recoverUnsyncedTail: options.recoverUnsyncedTail,
191
+ persistStatus: options.persistStatus,
192
+ cwd: bound.sessionCwd,
193
+ });
194
+ if (replayFromStart || options.catchUpFromPersisted) {
195
+ await forwarder.forwardNewText();
196
+ }
197
+ return bound.transcriptPath;
198
+ }
199
+ } else if (bound?.sessionId) {
200
+ sawPendingTranscript = true;
201
+ pendingSessionId = bound.sessionId;
202
+ if (bound.transcriptPath) {
203
+ pendingTranscriptPath = bound.transcriptPath;
204
+ pendingTranscriptCwd = bound.sessionCwd ?? null;
205
+ }
206
+ }
207
+ await new Promise((resolve) => setTimeout(resolve, pollMs));
208
+ }
209
+ // No existing transcript surfaced during the loop, but the session record
210
+ // named a concrete transcript path that simply is not on disk yet. Bind it
211
+ // now: applyTranscriptBinding persists state.transcriptPath (so the
212
+ // getPersistedTranscriptPath() fallback works for future rebinds) and calls
213
+ // forwarder.startWatch(). Because fs.watch cannot watch a missing file, we
214
+ // also schedule a bounded re-arm poll that installs the watch + catches up
215
+ // once the transcript-writer creates the file.
216
+ if (pendingTranscriptPath) {
217
+ // Same wrong-session guard as the exists:true path: refuse to bind a
218
+ // candidate that conflicts with an explicit previousPath when this is a
219
+ // switch (mode!=="same").
220
+ const acceptable = mode === "same" || !previousPath || pendingTranscriptPath !== previousPath;
221
+ if (acceptable) {
222
+ dropTrace("rebind.pending.bind", { channelId, path: pendingTranscriptPath, sessionId: pendingSessionId });
223
+ // If the persisted cursor belongs to THIS transcript, resume from it;
224
+ // otherwise this is a freshly-discovered session transcript that was
225
+ // never bound before (the chicken-and-egg case), so forward from the
226
+ // start of file. Binding with catchUpFromPersisted against a
227
+ // non-matching persisted path would set the read cursor to EOF and
228
+ // silently skip the first reply (output-forwarder setContext).
229
+ // Resume from the persisted cursor only when it belongs to THIS
230
+ // transcript; otherwise forward from the start of file (see comment
231
+ // above). sameResolvedPath handles Windows case-insensitive paths.
232
+ const samePersisted = sameResolvedPath(getPersistedTranscriptPath(), pendingTranscriptPath);
233
+ applyTranscriptBinding(channelId, pendingTranscriptPath, {
234
+ replayFromStart: !samePersisted,
235
+ catchUpFromPersisted: samePersisted,
236
+ recoverUnsyncedTail: options.recoverUnsyncedTail,
237
+ cwd: pendingTranscriptCwd,
238
+ persistStatus: options.persistStatus,
239
+ });
240
+ const boundPath = forwarder.transcriptPath || pendingTranscriptPath;
241
+ if (fs.existsSync(boundPath)) {
242
+ // Raced: file appeared between discovery and bind — forward immediately.
243
+ await forwarder.forwardNewText();
244
+ } else {
245
+ schedulePendingTranscriptRearm(channelId, boundPath);
246
+ }
247
+ process.stderr.write(`mixdog: rebind pending: bound not-yet-existing transcript ${boundPath}\n`);
248
+ return pendingTranscriptPath;
249
+ }
250
+ }
251
+ if (previousPath && options.fallbackPrevious !== false) {
252
+ applyTranscriptBinding(channelId, previousPath, {
253
+ catchUpFromPersisted: true,
254
+ recoverUnsyncedTail: options.recoverUnsyncedTail,
255
+ cwd: statusState.read().sessionCwd
256
+ });
257
+ if (fs.existsSync(previousPath)) {
258
+ await forwarder.forwardNewText();
259
+ } else {
260
+ // Same not-yet-on-disk situation as the pending branch: arm a poll so
261
+ // forwarding starts when the file is created.
262
+ schedulePendingTranscriptRearm(channelId, forwarder.transcriptPath || previousPath);
263
+ }
264
+ process.stderr.write(`mixdog: rebind fallback: bound previous transcript ${previousPath}\n`);
265
+ return previousPath;
266
+ }
267
+ process.stderr.write(`mixdog: rebind failed: no transcript found and no previous path to fall back to\n`);
268
+ return "";
269
+ }
270
+ async function bindPersistedTranscriptIfAny() {
271
+ // Main-channel fallback requires getChannelBridgeActive() (set in start() before
272
+ // refreshBridgeOwnership → startOwnedRuntime, including pre-connect binds).
273
+ // Resolve channelId first from persisted status; fall back to the most
274
+ // recent status-*.json snapshot, then to the configured main channel when
275
+ // the bridge is active. No exists-gate here — once we have a channelId,
276
+ // hand off to rebindTranscriptContext(), which owns the 30-attempt retry
277
+ // for transcripts that are not yet on disk at boot/activate time.
278
+ let currentStatus = statusState.read();
279
+ if (!currentStatus.channelId) {
280
+ try {
281
+ const files = fs.readdirSync(RUNTIME_ROOT).filter((f) => f.startsWith("status-") && f.endsWith(".json")).map((f) => {
282
+ const full = path.join(RUNTIME_ROOT, f);
283
+ return { path: full, mtime: fs.statSync(full).mtimeMs };
284
+ }).sort((a, b) => b.mtime - a.mtime);
285
+ for (const { path: fp } of files) {
286
+ try {
287
+ const data = JSON.parse(fs.readFileSync(fp, "utf8"));
288
+ if (data.channelId) {
289
+ statusState.update((state) => {
290
+ Object.assign(state, data);
291
+ });
292
+ currentStatus = statusState.read();
293
+ process.stderr.write(`mixdog: restored status from ${fp}
294
+ `);
295
+ break;
296
+ }
297
+ } catch {
298
+ }
299
+ }
300
+ } catch {
301
+ }
302
+ }
303
+ if (!currentStatus.channelId && getChannelBridgeActive()) {
304
+ const mainId = getConfig().channelId;
305
+ if (mainId) {
306
+ statusState.update((state) => {
307
+ state.channelId = mainId;
308
+ });
309
+ currentStatus = statusState.read();
310
+ process.stderr.write(`mixdog: auto-bound to main channel ${mainId}
311
+ `);
312
+ }
313
+ }
314
+ if (!currentStatus.channelId) return;
315
+ const bound = await rebindTranscriptContext(currentStatus.channelId, {
316
+ previousPath: getPersistedTranscriptPath(),
317
+ persistStatus: true,
318
+ catchUpFromPersisted: true,
319
+ recoverUnsyncedTail: true,
320
+ });
321
+ if (bound) {
322
+ process.stderr.write(`mixdog: initial transcript bind: ${bound}
323
+ `);
324
+ }
325
+ }
326
+ return {
327
+ sessionIdFromTranscriptPath,
328
+ getPersistedTranscriptPath,
329
+ pickUsableTranscriptPath,
330
+ applyTranscriptBinding,
331
+ cancelPendingTranscriptRearm,
332
+ schedulePendingTranscriptRearm,
333
+ rebindTranscriptContext,
334
+ bindPersistedTranscriptIfAny,
335
+ };
336
+ }
@@ -78,6 +78,40 @@ function platformKey() {
78
78
  return `${os}-${process.arch}`
79
79
  }
80
80
 
81
+ // Raised when the running OS×arch has no manifest coverage (e.g. linux-arm64).
82
+ // Carries a clean, user-facing message so callers surface a single "not
83
+ // supported" notice instead of a developer-oriented "add a manifest entry"
84
+ // throw. `unsupportedPlatform` lets callers special-case it if desired.
85
+ class VoiceRuntimeUnsupportedError extends Error {
86
+ constructor(key) {
87
+ super(`voice runtime not supported on ${key} — voice transcription is unavailable on this platform`)
88
+ this.name = 'VoiceRuntimeUnsupportedError'
89
+ this.code = 'VOICE_RUNTIME_UNSUPPORTED'
90
+ this.unsupportedPlatform = key
91
+ }
92
+ }
93
+ export { VoiceRuntimeUnsupportedError }
94
+
95
+ // POSIX zip archives do not reliably carry the executable bit through every
96
+ // extractor (unzip/bsdtar), so freshly extracted whisper-cli / whisper-server
97
+ // (and a co-located ffmpeg, when packed the same way) are not spawnable until
98
+ // chmod'd. No-op on Windows. Best-effort per file — a missing sibling is fine.
99
+ function chmodExtractedBinariesPosix(rootDir, executable) {
100
+ if (process.platform === 'win32') return
101
+ const execDir = join(rootDir, dirname(executable))
102
+ const targets = new Set([
103
+ join(rootDir, executable),
104
+ join(execDir, 'whisper-cli'),
105
+ join(execDir, 'whisper-server'),
106
+ join(execDir, 'ffmpeg'),
107
+ ])
108
+ for (const p of targets) {
109
+ if (existsSync(p)) {
110
+ try { chmodSync(p, 0o755) } catch {}
111
+ }
112
+ }
113
+ }
114
+
81
115
  async function _withInstallLock(rootDir, lockName, fn, { pollMs = 250 } = {}) {
82
116
  mkdirSync(rootDir, { recursive: true })
83
117
  const lockPath = join(rootDir, `.${lockName}.lock`)
@@ -451,7 +485,7 @@ export async function ensureWhisperRuntime(dataDir, onProgress = null) {
451
485
  const key = platformKey()
452
486
  const platformEntry = manifest.platforms?.[key]
453
487
  if (!platformEntry) {
454
- throw new Error(`[voice-runtime] no manifest entry for ${key} — disable voice in mixdog-config.json or add a manifest entry for this platform`)
488
+ throw new VoiceRuntimeUnsupportedError(key)
455
489
  }
456
490
 
457
491
  const variants = platformEntry.variants
@@ -497,6 +531,9 @@ export async function ensureWhisperRuntime(dataDir, onProgress = null) {
497
531
  await verifySha256(archivePath, variant.sha256)
498
532
  extractZip(archivePath, stagingDir)
499
533
  rmSync(archivePath, { force: true })
534
+ // POSIX zips can drop the exec bit — make whisper-cli/whisper-server
535
+ // spawnable before the existence check and eventual spawn.
536
+ chmodExtractedBinariesPosix(stagingDir, variant.executable)
500
537
  const stagedExec = join(stagingDir, variant.executable)
501
538
  if (!existsSync(stagedExec)) {
502
539
  throw new Error(`[voice-runtime] expected executable ${variant.executable} not present after extract`)
@@ -579,7 +616,7 @@ export async function ensureFfmpegRuntime(dataDir, onProgress = null) {
579
616
  const key = platformKey()
580
617
  const platformEntry = manifest.ffmpeg.platforms?.[key]
581
618
  if (!platformEntry) {
582
- throw new Error(`[voice-runtime] no ffmpeg manifest entry for ${key} — disable voice in mixdog-config.json or add a manifest entry for this platform`)
619
+ throw new VoiceRuntimeUnsupportedError(key)
583
620
  }
584
621
 
585
622
  const ver = manifest.ffmpeg.version
@@ -0,0 +1,97 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import { DATA_DIR } from "./config.mjs";
4
+ import { _dtIdxFlush } from "./index-drop-trace.mjs";
5
+ import {
6
+ ensureRuntimeDirs,
7
+ cleanupStaleRuntimeFiles,
8
+ notePreviousServerIfAny,
9
+ writeServerPid,
10
+ refreshActiveInstance,
11
+ } from "./runtime-paths.mjs";
12
+ import { startCliWorker } from "./cli-worker-host.mjs";
13
+ // Worker boot maintenance extracted from channels/index.mjs (behavior-
14
+ // preserving): worker-log rotation + stale worker-log/session GC + plugin-data
15
+ // sibling prune, the SIGTERM drop-trace flush handler, runtime-dir init, and the
16
+ // non-worker-mode owner-identity publish + CLI worker start.
17
+ export function runWorkerBootstrap({
18
+ instanceId,
19
+ isWorkerMode,
20
+ pruneStalePluginDataLogSiblings,
21
+ DEFAULT_STALE_LOG_SIBLING_MAX,
22
+ }) {
23
+ // Rotate additional worker logs (10 MB threshold).
24
+ for (const _rotLog of ["channels-worker.log", "schedule.log", "event.log", "memory-worker.log", "mcp-debug.log", "webhook.log", "pg.log", "session-start.log"]) {
25
+ const _rotPath = path.join(DATA_DIR, _rotLog);
26
+ try { if (fs.statSync(_rotPath).size > 10 * 1024 * 1024) fs.renameSync(_rotPath, _rotPath + ".1") } catch {}
27
+ }
28
+ // GC per-worker scoped sibling logs (`<name>-worker.<leadPid>.<workerPid>.log`).
29
+ // Master logs above rotate live; scoped siblings are opened once per worker
30
+ // process and never reopened, so age-based removal is the only reliable
31
+ // cleanup signal. 7-day TTL keeps recent crash forensics while bounding leak.
32
+ const _STALE_WORKER_LOG_TTL_MS = 7 * 24 * 60 * 60 * 1000;
33
+ try {
34
+ const _now = Date.now();
35
+ for (const _f of fs.readdirSync(DATA_DIR)) {
36
+ if (!/^(channels|memory)-worker\.\d+\.\d+\.log$/.test(_f)
37
+ && !/^mcp-debug\.\d+\.\d+\.log$/.test(_f)
38
+ && !/^supervisor\.\d+\.log$/.test(_f)) continue;
39
+ const _p = path.join(DATA_DIR, _f);
40
+ try { if (_now - fs.statSync(_p).mtimeMs > _STALE_WORKER_LOG_TTL_MS) fs.unlinkSync(_p); } catch {}
41
+ }
42
+ } catch {}
43
+ // GC stale ephemeral session files. closeSession plants a closed=true
44
+ // tombstone, but bench / smoke / probe drivers historically created sessions
45
+ // without ever calling closeSession, leaving 175-byte placeholders behind.
46
+ // 7-day TTL is safe because live agent sessions touch their JSON file on
47
+ // every ask iteration, so any file older than 7 days is provably abandoned.
48
+ const _SESSIONS_DIR = path.join(DATA_DIR, 'sessions');
49
+ const _STALE_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
50
+ try {
51
+ const _now = Date.now();
52
+ for (const _f of fs.readdirSync(_SESSIONS_DIR)) {
53
+ if (!_f.endsWith('.json')) continue;
54
+ const _p = path.join(_SESSIONS_DIR, _f);
55
+ try { if (_now - fs.statSync(_p).mtimeMs > _STALE_SESSION_TTL_MS) fs.unlinkSync(_p); } catch {}
56
+ }
57
+ } catch {}
58
+ // Count-based cap: drop oldest *.log siblings when plugin-data accumulates
59
+ // hundreds of per-process files (doctor warns above 300).
60
+ try {
61
+ pruneStalePluginDataLogSiblings(DATA_DIR, DEFAULT_STALE_LOG_SIBLING_MAX);
62
+ } catch {}
63
+ // SIGTERM: flush the drop-trace buffer, but do NOT exit here. In worker
64
+ // mode the graceful `_channelsShutdownHandler` below owns shutdown
65
+ // (stop() → cleanup → process.exit). In non-worker mode no SIGTERM
66
+ // handler was previously installed beyond this one; defer to default
67
+ // termination so process.on('exit') hooks still run.
68
+ process.on("SIGTERM", () => {
69
+ void _dtIdxFlush();
70
+ if (!isWorkerMode) process.exit(0);
71
+ });
72
+ // ────────────────────────────────────────────────────────────────────────────
73
+ ensureRuntimeDirs();
74
+ cleanupStaleRuntimeFiles();
75
+ if (!isWorkerMode) {
76
+ notePreviousServerIfAny();
77
+ writeServerPid();
78
+ // Publish owner identity immediately so the SessionStart shim's
79
+ // owner_lead_alive() sees a live owner and uses the full connect budget
80
+ // instead of the 5s no-owner grace (fixes missing recap/core on restart).
81
+ // backendReady intentionally omitted — readiness stays gated until connect.
82
+ try {
83
+ refreshActiveInstance(instanceId);
84
+ } catch (e) {
85
+ const code = e?.code;
86
+ const transient =
87
+ code === "EPERM" || code === "EBUSY" || code === "EACCES" || code === "ENOENT";
88
+ if (!transient) throw e;
89
+ try {
90
+ process.stderr.write(
91
+ `mixdog channels: refreshActiveInstance at startup failed (non-fatal, ${code}): ${e instanceof Error ? e.message : String(e)}\n`,
92
+ );
93
+ } catch {}
94
+ }
95
+ startCliWorker();
96
+ }
97
+ }