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.
- package/LICENSE +21 -0
- package/README.md +100 -34
- package/package.json +3 -2
- package/scripts/build-tui.mjs +6 -0
- package/scripts/hook-bus-test.mjs +8 -0
- package/scripts/log-writer-guard-smoke.mjs +131 -0
- package/scripts/reactive-compact-persist-smoke.mjs +22 -6
- package/scripts/recall-bench-cases.json +0 -1
- package/scripts/recall-quality-cases.json +1 -2
- package/scripts/session-ingest-compaction-smoke.mjs +241 -0
- package/scripts/tool-smoke.mjs +150 -45
- package/src/defaults/skills/setup/SKILL.md +327 -0
- package/src/help.mjs +2 -5
- package/src/lib/mixdog-debug.cjs +13 -0
- package/src/mixdog-session-runtime.mjs +7 -3328
- package/src/runtime/agent/orchestrator/context/collect.mjs +33 -9
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +34 -2
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +3 -0
- package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +67 -10
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +3 -0
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +40 -3
- package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +73 -3
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +663 -0
- package/src/runtime/agent/orchestrator/session/compact/engine.mjs +6 -0
- package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +153 -0
- package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +49 -0
- package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +9 -1
- package/src/runtime/agent/orchestrator/session/loop.mjs +8 -1860
- package/src/runtime/agent/orchestrator/session/manager/agent-runtime-singleton.mjs +29 -0
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +686 -0
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +60 -12
- package/src/runtime/agent/orchestrator/session/manager/env-utils.mjs +8 -0
- package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +159 -0
- package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +143 -0
- package/src/runtime/agent/orchestrator/session/manager/prefetch-bridge.mjs +268 -0
- package/src/runtime/agent/orchestrator/session/manager/provider-cache-key.mjs +22 -0
- package/src/runtime/agent/orchestrator/session/manager/runtime-loaders.mjs +26 -0
- package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +124 -0
- package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +258 -0
- package/src/runtime/agent/orchestrator/session/manager/session-errors.mjs +20 -0
- package/src/runtime/agent/orchestrator/session/manager/session-id.mjs +9 -0
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +475 -0
- package/src/runtime/agent/orchestrator/session/manager/session-lock.mjs +23 -0
- package/src/runtime/agent/orchestrator/session/manager.mjs +88 -2285
- package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +440 -0
- package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +153 -0
- package/src/runtime/agent/orchestrator/session/store.mjs +4 -1
- package/src/runtime/agent/orchestrator/session/tool-batch.mjs +642 -0
- package/src/runtime/agent/orchestrator/tools/bash-session.mjs +23 -3
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +108 -2
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +15 -3
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +7 -0
- package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +24 -2
- package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +72 -8
- package/src/runtime/agent/orchestrator/tools/shell-policy-danger-target.mjs +5 -1
- package/src/runtime/channels/index.mjs +6 -2183
- package/src/runtime/channels/lib/inbound-handler.mjs +328 -0
- package/src/runtime/channels/lib/interaction-handlers.mjs +260 -0
- package/src/runtime/channels/lib/network-retry.mjs +23 -0
- package/src/runtime/channels/lib/owned-runtime.mjs +627 -0
- package/src/runtime/channels/lib/runtime-paths.mjs +20 -9
- package/src/runtime/channels/lib/transcript-binding.mjs +336 -0
- package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +39 -2
- package/src/runtime/channels/lib/worker-bootstrap.mjs +97 -0
- package/src/runtime/channels/lib/worker-ipc.mjs +201 -0
- package/src/runtime/channels/lib/worker-main.mjs +777 -0
- package/src/runtime/memory/index.mjs +163 -1725
- package/src/runtime/memory/lib/embedding-provider.mjs +4 -2
- package/src/runtime/memory/lib/embedding-worker.mjs +47 -6
- package/src/runtime/memory/lib/http-router.mjs +811 -0
- package/src/runtime/memory/lib/memory-action-handlers.mjs +901 -0
- package/src/runtime/memory/lib/memory-cycle2-gate.mjs +6 -0
- package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +46 -9
- package/src/runtime/memory/lib/memory-cycle2.mjs +87 -11
- package/src/runtime/memory/lib/memory-embed.mjs +28 -7
- package/src/runtime/memory/lib/memory-recall-store.mjs +4 -4
- package/src/runtime/memory/lib/memory.mjs +39 -0
- package/src/runtime/memory/lib/query-handlers.mjs +2 -2
- package/src/runtime/memory/lib/session-ingest-runtime.mjs +401 -0
- package/src/runtime/shared/atomic-file.mjs +138 -80
- package/src/runtime/shared/child-guardian.mjs +61 -3
- package/src/session-runtime/boot-profile.mjs +36 -0
- package/src/session-runtime/channel-config-api.mjs +70 -0
- package/src/session-runtime/context-status.mjs +181 -0
- package/src/session-runtime/env.mjs +17 -0
- package/src/session-runtime/lifecycle-api.mjs +242 -0
- package/src/session-runtime/model-route-api.mjs +198 -0
- package/src/session-runtime/provider-auth-api.mjs +135 -0
- package/src/session-runtime/resource-api.mjs +282 -0
- package/src/session-runtime/runtime-core.mjs +2104 -0
- package/src/session-runtime/session-turn-api.mjs +274 -0
- package/src/session-runtime/tool-catalog.mjs +18 -264
- package/src/session-runtime/tool-defs.mjs +2 -2
- package/src/session-runtime/workflow-agents-api.mjs +238 -0
- package/src/standalone/agent-tool.mjs +2 -2
- package/src/standalone/channel-worker.mjs +67 -7
- package/src/standalone/folder-dialog.mjs +4 -1
- package/src/standalone/memory-runtime-proxy.mjs +154 -17
- package/src/standalone/seeds.mjs +28 -1
- package/src/tui/App.jsx +40 -28
- package/src/tui/app/core-memory-picker.mjs +1 -1
- package/src/tui/app/doctor.mjs +175 -0
- package/src/tui/app/slash-commands.mjs +1 -0
- package/src/tui/app/slash-dispatch.mjs +9 -0
- package/src/tui/app/use-mouse-input.mjs +6 -0
- package/src/tui/app/use-transcript-scroll.mjs +77 -3
- package/src/tui/dist/index.mjs +2851 -2162
- package/src/tui/engine/context-state.mjs +145 -0
- package/src/tui/engine/prompt-history.mjs +27 -0
- package/src/tui/engine/session-api-ext.mjs +478 -0
- package/src/tui/engine/session-api.mjs +564 -0
- package/src/tui/engine/session-flow.mjs +485 -0
- package/src/tui/engine/turn.mjs +1078 -0
- package/src/tui/engine.mjs +68 -2620
- package/src/tui/index.jsx +7 -0
- package/vendor/ink/build/ink.js +16 -1
- package/vendor/ink/build/output.js +30 -4
- package/vendor/ink/build/render.js +5 -0
- 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
|
|
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
|
|
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
|
+
}
|