mixdog 0.9.84 → 0.9.85
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/package.json +1 -1
- package/scripts/channel-daemon-stub.mjs +2 -1
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +30 -3
- package/src/runtime/agent/orchestrator/session/loop/steering.mjs +38 -4
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +48 -32
- package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +8 -2
- package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +44 -73
- package/src/runtime/channels/lib/owned-runtime.mjs +6 -8
- package/src/runtime/channels/lib/parent-bridge.mjs +27 -15
- package/src/runtime/channels/lib/worker-ipc.mjs +17 -4
- package/src/runtime/memory/index.mjs +14 -5
- package/src/runtime/memory/lib/agent-ipc.mjs +11 -12
- package/src/runtime/shared/atomic-file.mjs +51 -24
- package/src/runtime/shared/err-text.mjs +1 -0
- package/src/runtime/shared/safe-ipc-send.mjs +34 -0
- package/src/session-runtime/lifecycle-api.mjs +9 -3
- package/src/session-runtime/runtime-core.mjs +5 -1
- package/src/standalone/channel-daemon.mjs +2 -1
- package/src/standalone/channel-worker.mjs +21 -19
- package/src/standalone/memory-runtime-proxy.mjs +98 -23
- package/src/tui/app/slash-dispatch.mjs +23 -13
- package/src/tui/app/theme-effort-pickers.mjs +3 -1
- package/src/tui/dist/index.mjs +115 -57
- package/src/tui/engine/live-share.mjs +2 -2
- package/src/tui/engine/session-flow.mjs +20 -10
- package/src/tui/engine/turn.mjs +28 -16
- package/src/tui/engine.mjs +17 -1
package/package.json
CHANGED
|
@@ -10,6 +10,7 @@ import os from 'node:os';
|
|
|
10
10
|
import path from 'node:path';
|
|
11
11
|
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
12
12
|
import { claimSingletonOwner, releaseSingletonOwner } from '../src/runtime/shared/singleton-owner.mjs';
|
|
13
|
+
import { safeIpcSend } from '../src/runtime/shared/safe-ipc-send.mjs';
|
|
13
14
|
import { createChannelDaemonTransport } from '../src/standalone/channel-daemon-transport.mjs';
|
|
14
15
|
|
|
15
16
|
function runtimeRoot() {
|
|
@@ -69,7 +70,7 @@ async function main() {
|
|
|
69
70
|
onClientsEmpty: () => { void shutdown('no live clients'); },
|
|
70
71
|
});
|
|
71
72
|
const { port, token } = await transport.start();
|
|
72
|
-
|
|
73
|
+
safeIpcSend(process, { type: 'ready', port, token });
|
|
73
74
|
log(`ready port=${port} pid=${process.pid}`);
|
|
74
75
|
}
|
|
75
76
|
|
|
@@ -230,19 +230,46 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
230
230
|
}
|
|
231
231
|
let totalCount = 0;
|
|
232
232
|
let totalTextLen = 0;
|
|
233
|
+
let maxQueueWaitMs = 0;
|
|
233
234
|
for (const merged of mergedMessages) {
|
|
235
|
+
const submissionIds = Array.isArray(merged.ids) ? merged.ids : [];
|
|
236
|
+
const submittedAt = Number(merged.submittedAt);
|
|
237
|
+
const injectedAt = Date.now();
|
|
238
|
+
if (Number.isFinite(submittedAt) && submittedAt > 0) {
|
|
239
|
+
maxQueueWaitMs = Math.max(maxQueueWaitMs, injectedAt - submittedAt);
|
|
240
|
+
}
|
|
234
241
|
// Tag steering-origin user messages so provider lowering keeps them
|
|
235
242
|
// distinct from preceding tool results. Keep each queued command as
|
|
236
243
|
// its own user turn, matching Claude Code queued_command attachment
|
|
237
244
|
// semantics instead of collapsing priority/mode buckets together.
|
|
238
|
-
messages.push({
|
|
245
|
+
messages.push({
|
|
246
|
+
role: 'user',
|
|
247
|
+
content: merged.content,
|
|
248
|
+
meta: {
|
|
249
|
+
source: 'steering',
|
|
250
|
+
...(submissionIds.length ? { submissionIds } : {}),
|
|
251
|
+
},
|
|
252
|
+
});
|
|
239
253
|
const text = merged.text || steeringContentText(merged.content);
|
|
240
254
|
totalCount += Number(merged.count) || 1;
|
|
241
255
|
totalTextLen += String(text || '').length;
|
|
242
|
-
try {
|
|
256
|
+
try {
|
|
257
|
+
opts.onSteerMessage?.(text, {
|
|
258
|
+
ids: submissionIds,
|
|
259
|
+
submittedAt: Number.isFinite(submittedAt) && submittedAt > 0 ? submittedAt : undefined,
|
|
260
|
+
injectedAt,
|
|
261
|
+
stage,
|
|
262
|
+
...(Array.isArray(merged.images) && merged.images.length ? { images: merged.images } : {}),
|
|
263
|
+
});
|
|
264
|
+
} catch {}
|
|
243
265
|
}
|
|
244
266
|
if (sessionId) {
|
|
245
|
-
try {
|
|
267
|
+
try {
|
|
268
|
+
process.stderr.write(
|
|
269
|
+
`[steer] sess=${sessionId} injected ${stage} user message(s)`
|
|
270
|
+
+ ` (merged=${totalCount} len=${totalTextLen} waitMs=${Math.max(0, maxQueueWaitMs)})\n`,
|
|
271
|
+
);
|
|
272
|
+
} catch {}
|
|
246
273
|
}
|
|
247
274
|
return true;
|
|
248
275
|
};
|
|
@@ -14,21 +14,48 @@ export function steeringContentText(content) {
|
|
|
14
14
|
return String(content ?? '');
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
function steeringEntryMetadata(entry) {
|
|
18
|
+
if (!entry || typeof entry !== 'object') return {};
|
|
19
|
+
const sourceIds = Array.isArray(entry.ids)
|
|
20
|
+
? entry.ids
|
|
21
|
+
: (entry.id !== undefined && entry.id !== null ? [entry.id] : []);
|
|
22
|
+
const ids = [...new Set(sourceIds.filter((id) => id !== undefined && id !== null))];
|
|
23
|
+
const submittedAt = Number(entry.submittedAt);
|
|
24
|
+
return {
|
|
25
|
+
...(ids.length ? { ids } : {}),
|
|
26
|
+
...(Number.isFinite(submittedAt) && submittedAt > 0 ? { submittedAt } : {}),
|
|
27
|
+
...(Array.isArray(entry.images) && entry.images.length ? { images: entry.images } : {}),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
17
31
|
function normalizeSteeringEntry(entry) {
|
|
18
32
|
if (typeof entry === 'string') {
|
|
19
33
|
const text = entry.trim();
|
|
20
34
|
return text ? { content: text, text } : null;
|
|
21
35
|
}
|
|
22
36
|
if (!entry || typeof entry !== 'object') return null;
|
|
37
|
+
const metadata = steeringEntryMetadata(entry);
|
|
23
38
|
const content = Object.prototype.hasOwnProperty.call(entry, 'content') ? entry.content : entry;
|
|
24
39
|
const text = typeof entry.text === 'string' ? entry.text.trim() : steeringContentText(content).trim();
|
|
25
|
-
if (Array.isArray(content)) return content.length > 0 ? { content, text } : null;
|
|
40
|
+
if (Array.isArray(content)) return content.length > 0 ? { content, text, ...metadata } : null;
|
|
26
41
|
if (typeof content === 'string') {
|
|
27
42
|
const value = content.trim();
|
|
28
|
-
return value ? { content: value, text: text || value } : null;
|
|
43
|
+
return value ? { content: value, text: text || value, ...metadata } : null;
|
|
29
44
|
}
|
|
30
45
|
const fallback = steeringContentText(content).trim();
|
|
31
|
-
return fallback ? { content: fallback, text: text || fallback } : null;
|
|
46
|
+
return fallback ? { content: fallback, text: text || fallback, ...metadata } : null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function mergeSteeringMetadata(entries) {
|
|
50
|
+
const ids = [...new Set(entries.flatMap((entry) => Array.isArray(entry.ids) ? entry.ids : []))];
|
|
51
|
+
const submittedTimes = entries.map((entry) => Number(entry.submittedAt))
|
|
52
|
+
.filter((value) => Number.isFinite(value) && value > 0);
|
|
53
|
+
const images = entries.flatMap((entry) => Array.isArray(entry.images) ? entry.images : []);
|
|
54
|
+
return {
|
|
55
|
+
...(ids.length ? { ids } : {}),
|
|
56
|
+
...(submittedTimes.length ? { submittedAt: Math.min(...submittedTimes) } : {}),
|
|
57
|
+
...(images.length ? { images } : {}),
|
|
58
|
+
};
|
|
32
59
|
}
|
|
33
60
|
|
|
34
61
|
export function mergeSteeringEntries(entries) {
|
|
@@ -36,6 +63,7 @@ export function mergeSteeringEntries(entries) {
|
|
|
36
63
|
.map(normalizeSteeringEntry)
|
|
37
64
|
.filter(Boolean);
|
|
38
65
|
if (normalized.length === 0) return null;
|
|
66
|
+
const metadata = mergeSteeringMetadata(normalized);
|
|
39
67
|
const displayText = normalized.map((entry) => entry.text || steeringContentText(entry.content))
|
|
40
68
|
.filter((text) => String(text || '').trim())
|
|
41
69
|
.join('\n');
|
|
@@ -44,6 +72,7 @@ export function mergeSteeringEntries(entries) {
|
|
|
44
72
|
content: normalized.map((entry) => entry.content).filter(Boolean).join('\n'),
|
|
45
73
|
text: displayText,
|
|
46
74
|
count: normalized.length,
|
|
75
|
+
...metadata,
|
|
47
76
|
};
|
|
48
77
|
}
|
|
49
78
|
const parts = [];
|
|
@@ -59,5 +88,10 @@ export function mergeSteeringEntries(entries) {
|
|
|
59
88
|
parts.push({ type: 'text', text: '\n' });
|
|
60
89
|
}
|
|
61
90
|
while (parts.length && parts[parts.length - 1]?.type === 'text' && parts[parts.length - 1]?.text === '\n') parts.pop();
|
|
62
|
-
return {
|
|
91
|
+
return {
|
|
92
|
+
content: parts,
|
|
93
|
+
text: displayText || steeringContentText(parts),
|
|
94
|
+
count: normalized.length,
|
|
95
|
+
...metadata,
|
|
96
|
+
};
|
|
63
97
|
}
|
|
@@ -307,6 +307,40 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
307
307
|
let _turnOutgoing = null;
|
|
308
308
|
const _turnInterruption = createTurnInterruptionTracker();
|
|
309
309
|
const _sessionStartMetaInjectedBeforeTurn = preSession.sessionStartMetaInjected === true;
|
|
310
|
+
let _interruptionSnapshot = null;
|
|
311
|
+
const _prepareCloseSnapshot = (abortReason) => {
|
|
312
|
+
if (_interruptionSnapshot) return _interruptionSnapshot;
|
|
313
|
+
if (!activeSession) return null;
|
|
314
|
+
activeSession.liveTurnMessages = null;
|
|
315
|
+
_turnInterruption.restoreTombstonedText();
|
|
316
|
+
const finalized = _turnInterruption.finalize({
|
|
317
|
+
turnOutgoing: _turnOutgoing || activeSession.messages,
|
|
318
|
+
currentUserContent: cancelledUserTurnContent,
|
|
319
|
+
abortReason,
|
|
320
|
+
});
|
|
321
|
+
activeSession.messages = finalized.messages;
|
|
322
|
+
if (!finalized.responsePreserved) {
|
|
323
|
+
if (finalized.userTurnPreserved) {
|
|
324
|
+
// A non-user detach keeps the provisional prompt but makes
|
|
325
|
+
// the opaque provider continuation unsafe to reuse.
|
|
326
|
+
activeSession.providerState = undefined;
|
|
327
|
+
} else {
|
|
328
|
+
activeSession.sessionStartMetaInjected = _sessionStartMetaInjectedBeforeTurn;
|
|
329
|
+
}
|
|
330
|
+
} else {
|
|
331
|
+
activeSession.providerState = undefined;
|
|
332
|
+
}
|
|
333
|
+
activeSession.updatedAt = Date.now();
|
|
334
|
+
activeSession.lastUsedAt = Date.now();
|
|
335
|
+
runtime.session = activeSession;
|
|
336
|
+
_interruptionSnapshot = finalized;
|
|
337
|
+
return finalized;
|
|
338
|
+
};
|
|
339
|
+
// closeSession is synchronous and generation-first by design. Expose a
|
|
340
|
+
// turn-local hook so it can canonicalize the in-flight transcript
|
|
341
|
+
// before bumpSessionGeneration()/markSessionClosed() writes the disk
|
|
342
|
+
// snapshot and invalidates the ordinary cancellation cleanup save.
|
|
343
|
+
runtime.prepareCloseSnapshot = _prepareCloseSnapshot;
|
|
310
344
|
try {
|
|
311
345
|
const session = activeSession;
|
|
312
346
|
const provider = getProvider(session.provider);
|
|
@@ -672,6 +706,12 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
672
706
|
process.stderr.write(`[session] empty-final persisted sessionId=${sessionId} stopReason=${_emptyStop ?? 'unknown'} iterations=${result?.iterations ?? 0} toolCallsTotal=${result?.toolCallsTotal ?? 0} outTokens=${_emptyUsage?.outputTokens ?? 0} hasThinking=${_thinkingStr} blockTypes=${_blockTypesStr}\n`);
|
|
673
707
|
} catch {}
|
|
674
708
|
}
|
|
709
|
+
// The terminal assistant message is now canonical. A close racing
|
|
710
|
+
// any later await should persist this committed session as-is,
|
|
711
|
+
// never re-finalize the pre-terminal outgoing array.
|
|
712
|
+
if (runtime.prepareCloseSnapshot === _prepareCloseSnapshot) {
|
|
713
|
+
runtime.prepareCloseSnapshot = null;
|
|
714
|
+
}
|
|
675
715
|
session.updatedAt = Date.now();
|
|
676
716
|
session.lastUsedAt = Date.now();
|
|
677
717
|
applyAskTerminalUsageTotals(session, result, {
|
|
@@ -840,34 +880,15 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
840
880
|
const currentRuntime = _getRuntimeEntry(sessionId);
|
|
841
881
|
if (!currentRuntime?.closed) {
|
|
842
882
|
if (activeSession) {
|
|
843
|
-
const finalized =
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
});
|
|
848
|
-
activeSession.messages = finalized.messages;
|
|
883
|
+
const finalized = _prepareCloseSnapshot(err.reason);
|
|
884
|
+
if (currentRuntime?.prepareCloseSnapshot === _prepareCloseSnapshot) {
|
|
885
|
+
currentRuntime.prepareCloseSnapshot = null;
|
|
886
|
+
}
|
|
849
887
|
if (!finalized.responsePreserved) {
|
|
850
888
|
releasePendingMessages(sessionId, _turnPendingEntries);
|
|
851
|
-
if (finalized.userTurnPreserved) {
|
|
852
|
-
// Non-user abort (app quit / engine dispose /
|
|
853
|
-
// watchdog): the just-sent user turn stays in
|
|
854
|
-
// history, so the session-start meta it carries
|
|
855
|
-
// remains consumed, and the opaque provider
|
|
856
|
-
// continuation no longer matches — force full
|
|
857
|
-
// transcript replay on the next send.
|
|
858
|
-
activeSession.providerState = undefined;
|
|
859
|
-
} else {
|
|
860
|
-
activeSession.sessionStartMetaInjected = _sessionStartMetaInjectedBeforeTurn;
|
|
861
|
-
}
|
|
862
889
|
} else {
|
|
863
890
|
recordPendingMessageDelivery(activeSession, _turnPendingEntries);
|
|
864
|
-
// The opaque provider continuation now points at a
|
|
865
|
-
// request that ended mid-turn. Force full transcript
|
|
866
|
-
// replay on the next send instead of reusing it.
|
|
867
|
-
activeSession.providerState = undefined;
|
|
868
891
|
}
|
|
869
|
-
activeSession.updatedAt = Date.now();
|
|
870
|
-
activeSession.lastUsedAt = Date.now();
|
|
871
892
|
try {
|
|
872
893
|
const durableSave = saveSessionAsync(activeSession, { expectedGeneration: askGeneration });
|
|
873
894
|
if (finalized.responsePreserved) {
|
|
@@ -887,6 +908,9 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
887
908
|
// can render it as "cancelled" rather than a red failure.
|
|
888
909
|
throw err;
|
|
889
910
|
}
|
|
911
|
+
if (runtime.prepareCloseSnapshot === _prepareCloseSnapshot) {
|
|
912
|
+
runtime.prepareCloseSnapshot = null;
|
|
913
|
+
}
|
|
890
914
|
// A reset acknowledgement removes the live partial before the
|
|
891
915
|
// non-streaming request starts. If that restart fails, restore the
|
|
892
916
|
// tombstone and persist the one exposed partial as interruption
|
|
@@ -895,17 +919,9 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
895
919
|
|| err?.liveTextEmitted === true
|
|
896
920
|
|| err?.unsafeToRetry === true;
|
|
897
921
|
if (preserveProviderPartial && activeSession && _turnInterruption.hasResponseStarted()) {
|
|
898
|
-
const finalized =
|
|
899
|
-
turnOutgoing: _turnOutgoing || activeSession.messages,
|
|
900
|
-
currentUserContent: cancelledUserTurnContent,
|
|
901
|
-
abortReason: 'provider-error',
|
|
902
|
-
});
|
|
903
|
-
activeSession.messages = finalized.messages;
|
|
922
|
+
const finalized = _prepareCloseSnapshot('provider-error');
|
|
904
923
|
if (finalized.responsePreserved) recordPendingMessageDelivery(activeSession, _turnPendingEntries);
|
|
905
924
|
else releasePendingMessages(sessionId, _turnPendingEntries);
|
|
906
|
-
activeSession.providerState = undefined;
|
|
907
|
-
activeSession.updatedAt = Date.now();
|
|
908
|
-
activeSession.lastUsedAt = Date.now();
|
|
909
925
|
try {
|
|
910
926
|
const durableSave = saveSessionAsync(activeSession, { expectedGeneration: askGeneration });
|
|
911
927
|
if (finalized.responsePreserved) {
|
|
@@ -37,10 +37,17 @@ export function closeSession(id, reason = 'manual', opts = {}) {
|
|
|
37
37
|
// resuming. Only truly-empty scratch sessions should still tombstone.
|
|
38
38
|
const tombstone = opts.tombstone !== false;
|
|
39
39
|
if (!id) return false;
|
|
40
|
+
const entry = _getRuntimeEntry(id);
|
|
41
|
+
// askSession owns the interruption tracker that can merge committed
|
|
42
|
+
// iterations, the current partial response, and observed tool results into
|
|
43
|
+
// one canonical transcript. Finalize that in-memory snapshot synchronously
|
|
44
|
+
// BEFORE the lifecycle generation changes; once generation is bumped, the
|
|
45
|
+
// normal cancellation cleanup save is intentionally rejected as stale.
|
|
46
|
+
try { entry?.prepareCloseSnapshot?.(reason); } catch { /* best-effort */ }
|
|
40
47
|
_stopToolActivityHeartbeat(id);
|
|
41
48
|
// Prefer in-memory runtime session — allBashSessionIds may not be persisted
|
|
42
49
|
// yet for shells opened in the current turn (BL-bash-disk-sync).
|
|
43
|
-
const inMemory =
|
|
50
|
+
const inMemory = entry?.session;
|
|
44
51
|
const persisted = inMemory || loadSession(id);
|
|
45
52
|
const bashSessionId = persisted?.implicitBashSessionId || null;
|
|
46
53
|
// Collect all persistent bash shells created during this session.
|
|
@@ -63,7 +70,6 @@ export function closeSession(id, reason = 'manual', opts = {}) {
|
|
|
63
70
|
// it back (BL: burned-session late-save clobber).
|
|
64
71
|
const newGen = tombstone ? markSessionClosed(id, reason) : bumpSessionGeneration(id, reason);
|
|
65
72
|
// 2. Mark runtime as closed so post-await validation in askSession fires.
|
|
66
|
-
const entry = _getRuntimeEntry(id);
|
|
67
73
|
if (entry) {
|
|
68
74
|
entry.closed = true;
|
|
69
75
|
entry.closedReason = reason;
|
|
@@ -1,15 +1,14 @@
|
|
|
1
|
-
import { spawnSync } from 'child_process';
|
|
2
1
|
import { existsSync } from 'fs';
|
|
3
|
-
import { basename, dirname, join } from 'path';
|
|
2
|
+
import { basename, delimiter, dirname, join } from 'path';
|
|
4
3
|
|
|
5
4
|
let _resolvedShell = null;
|
|
6
5
|
let _configuredShell = null;
|
|
7
6
|
// Per-kind cache for resolveShellFor(). 'default' aliases resolveShell()'s
|
|
8
|
-
// singleton; 'bash'/'powershell' get their own memoized slots.
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
// be installed mid-session, and PATH can change for a long-lived host).
|
|
7
|
+
// singleton; 'bash'/'powershell' get their own memoized slots. Misses receive
|
|
8
|
+
// a short TTL so a missing optional shell cannot rescan PATH for every tool
|
|
9
|
+
// call while a long-lived host can still discover a later installation.
|
|
12
10
|
const _resolvedShellByKind = new Map();
|
|
11
|
+
const SHELL_RESOLUTION_MISS_TTL_MS = 30_000;
|
|
13
12
|
|
|
14
13
|
export function setConfiguredShell(value = '') {
|
|
15
14
|
const next = String(value || '').trim();
|
|
@@ -43,30 +42,39 @@ function shellSpec(shell, shellType = shellTypeFor(shell)) {
|
|
|
43
42
|
return { shell, shellArg: '-c', shellArgs: ['-c'], shellType };
|
|
44
43
|
}
|
|
45
44
|
|
|
46
|
-
function
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
45
|
+
function allExistingPathsFromPath(commandName) {
|
|
46
|
+
const rawPath = String(process.env.PATH || '');
|
|
47
|
+
let cwd = '';
|
|
48
|
+
try { cwd = process.cwd(); } catch {}
|
|
49
|
+
const entries = [cwd, ...rawPath.split(delimiter)]
|
|
50
|
+
.map((entry) => entry.trim().replace(/^"(.*)"$/, '$1'))
|
|
51
|
+
.filter(Boolean);
|
|
52
|
+
const seen = new Set();
|
|
53
|
+
const matches = [];
|
|
54
|
+
for (const entry of entries) {
|
|
55
|
+
const candidate = join(entry, commandName);
|
|
56
|
+
const key = process.platform === 'win32' ? candidate.toLowerCase() : candidate;
|
|
57
|
+
if (seen.has(key)) continue;
|
|
58
|
+
seen.add(key);
|
|
59
|
+
if (existsSync(candidate)) matches.push(candidate);
|
|
58
60
|
}
|
|
61
|
+
return matches;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function firstExistingPathFromPath(commandName, excludeRe = null) {
|
|
65
|
+
return allExistingPathsFromPath(commandName)
|
|
66
|
+
.find((candidate) => !excludeRe || !excludeRe.test(candidate)) || null;
|
|
59
67
|
}
|
|
60
68
|
|
|
61
69
|
function resolveWindowsPowerShell() {
|
|
62
|
-
const pwsh =
|
|
70
|
+
const pwsh = firstExistingPathFromPath('pwsh.exe');
|
|
63
71
|
if (pwsh) return shellSpec(pwsh, 'powershell');
|
|
64
72
|
|
|
65
73
|
const systemRoot = process.env.SystemRoot || process.env.WINDIR || 'C:\\Windows';
|
|
66
74
|
const bundled = join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
|
|
67
75
|
if (existsSync(bundled)) return shellSpec(bundled, 'powershell');
|
|
68
76
|
|
|
69
|
-
const powershell =
|
|
77
|
+
const powershell = firstExistingPathFromPath('powershell.exe');
|
|
70
78
|
if (powershell) return shellSpec(powershell, 'powershell');
|
|
71
79
|
|
|
72
80
|
return shellSpec('powershell.exe', 'powershell');
|
|
@@ -109,12 +117,12 @@ function _isWindows() {
|
|
|
109
117
|
// not Git-for-Windows bash).
|
|
110
118
|
// Returns a posix shellSpec, or null when Git Bash is genuinely not installed.
|
|
111
119
|
function resolveWindowsGitBash() {
|
|
112
|
-
for (const git of
|
|
120
|
+
for (const git of allExistingPathsFromPath('git.exe')) {
|
|
113
121
|
const bash = probeGitBashFromGitExe(git);
|
|
114
122
|
if (bash) return shellSpec(bash, 'posix');
|
|
115
123
|
}
|
|
116
124
|
// Fallback: a bare `bash.exe` on PATH, but never the System32 WSL launcher.
|
|
117
|
-
const bash =
|
|
125
|
+
const bash = firstExistingPathFromPath('bash.exe', /\\system32\\/i);
|
|
118
126
|
if (bash) return shellSpec(bash, 'posix');
|
|
119
127
|
// Final fallback: probe well-known Git-for-Windows install roots on the
|
|
120
128
|
// filesystem directly. PATH-independent and spawn-free, so it still resolves
|
|
@@ -164,36 +172,6 @@ function probeGitBashFromGitExe(gitExe) {
|
|
|
164
172
|
return null;
|
|
165
173
|
}
|
|
166
174
|
|
|
167
|
-
function allExistingPathsFromWhere(commandName) {
|
|
168
|
-
try {
|
|
169
|
-
const r = spawnSync('cmd.exe', ['/d', '/s', '/c', `where ${commandName}`], {
|
|
170
|
-
stdio: ['ignore', 'pipe', 'ignore'],
|
|
171
|
-
windowsHide: true,
|
|
172
|
-
timeout: 1000,
|
|
173
|
-
});
|
|
174
|
-
if (r.status !== 0 || !r.stdout) return [];
|
|
175
|
-
return r.stdout.toString('utf8').split(/\r?\n/).map(s => s.trim())
|
|
176
|
-
.filter(Boolean).filter(p => existsSync(p));
|
|
177
|
-
} catch {
|
|
178
|
-
return [];
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
function firstExistingPathFromWhereExcluding(commandName, excludeRe) {
|
|
183
|
-
try {
|
|
184
|
-
const r = spawnSync('cmd.exe', ['/d', '/s', '/c', `where ${commandName}`], {
|
|
185
|
-
stdio: ['ignore', 'pipe', 'ignore'],
|
|
186
|
-
windowsHide: true,
|
|
187
|
-
timeout: 1000,
|
|
188
|
-
});
|
|
189
|
-
if (r.status !== 0 || !r.stdout) return null;
|
|
190
|
-
const lines = r.stdout.toString('utf8').split(/\r?\n/).map(s => s.trim()).filter(Boolean);
|
|
191
|
-
return lines.find(p => existsSync(p) && !excludeRe.test(p)) || null;
|
|
192
|
-
} catch {
|
|
193
|
-
return null;
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
|
|
197
175
|
// Kind-aware shell resolution. kind:
|
|
198
176
|
//
|
|
199
177
|
// Resolve a real bash on macOS/Linux. When 'bash' is explicitly requested we
|
|
@@ -204,13 +182,8 @@ function resolvePosixBash() {
|
|
|
204
182
|
for (const p of ['/bin/bash', '/usr/bin/bash', '/usr/local/bin/bash', '/opt/homebrew/bin/bash']) {
|
|
205
183
|
if (existsSync(p)) return shellSpec(p, 'posix');
|
|
206
184
|
}
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
if (r.status === 0 && r.stdout) {
|
|
210
|
-
const p = r.stdout.toString('utf8').split(/\r?\n/).map(s => s.trim()).find(Boolean);
|
|
211
|
-
if (p && existsSync(p)) return shellSpec(p, 'posix');
|
|
212
|
-
}
|
|
213
|
-
} catch { /* fall through to /bin/sh */ }
|
|
185
|
+
const fromPath = firstExistingPathFromPath('bash');
|
|
186
|
+
if (fromPath) return shellSpec(fromPath, 'posix');
|
|
214
187
|
return shellSpec('/bin/sh', 'posix');
|
|
215
188
|
}
|
|
216
189
|
|
|
@@ -220,12 +193,15 @@ function resolvePosixBash() {
|
|
|
220
193
|
// binary (/bin/bash, /usr/bin/bash, or `bash` on PATH), falling back to
|
|
221
194
|
// /bin/sh only when no bash exists (dash/ash distros break on bash syntax).
|
|
222
195
|
// 'powershell' → on Windows, resolveShell(); elsewhere pwsh if present, else null.
|
|
223
|
-
// Each kind is memoized independently
|
|
224
|
-
//
|
|
225
|
-
// next call rather than pinned for the life of the process.
|
|
196
|
+
// Each kind is memoized independently. Successes remain stable for the process;
|
|
197
|
+
// misses expire quickly so installs/PATH changes remain discoverable.
|
|
226
198
|
export function resolveShellFor(kind = 'default') {
|
|
227
199
|
if (kind == null || kind === 'default') return resolveShell();
|
|
228
|
-
|
|
200
|
+
const cached = _resolvedShellByKind.get(kind);
|
|
201
|
+
if (cached) {
|
|
202
|
+
if (cached.spec || cached.expiresAt > Date.now()) return cached.spec;
|
|
203
|
+
_resolvedShellByKind.delete(kind);
|
|
204
|
+
}
|
|
229
205
|
|
|
230
206
|
let spec = null;
|
|
231
207
|
if (kind === 'bash') {
|
|
@@ -234,21 +210,16 @@ export function resolveShellFor(kind = 'default') {
|
|
|
234
210
|
if (_isWindows()) {
|
|
235
211
|
spec = resolveShell();
|
|
236
212
|
} else {
|
|
237
|
-
const pwsh = (
|
|
238
|
-
try {
|
|
239
|
-
const r = spawnSync('which', ['pwsh'], { stdio: ['ignore', 'pipe', 'ignore'], timeout: 1000 });
|
|
240
|
-
if (r.status !== 0 || !r.stdout) return null;
|
|
241
|
-
const p = r.stdout.toString('utf8').split(/\r?\n/).map(s => s.trim()).find(Boolean);
|
|
242
|
-
return p && existsSync(p) ? p : null;
|
|
243
|
-
} catch { return null; }
|
|
244
|
-
})();
|
|
213
|
+
const pwsh = firstExistingPathFromPath('pwsh');
|
|
245
214
|
spec = pwsh ? shellSpec(pwsh, 'powershell') : null;
|
|
246
215
|
}
|
|
247
216
|
} else {
|
|
248
217
|
spec = resolveShell();
|
|
249
218
|
}
|
|
250
219
|
|
|
251
|
-
|
|
252
|
-
|
|
220
|
+
_resolvedShellByKind.set(kind, {
|
|
221
|
+
spec,
|
|
222
|
+
expiresAt: spec ? Number.POSITIVE_INFINITY : Date.now() + SHELL_RESOLUTION_MISS_TTL_MS,
|
|
223
|
+
});
|
|
253
224
|
return spec;
|
|
254
225
|
}
|
|
@@ -3,6 +3,7 @@ import { loadConfig, createBackend } from "./config.mjs";
|
|
|
3
3
|
import { WebhookServer } from "./webhook.mjs";
|
|
4
4
|
import { EventPipeline } from "./event-pipeline.mjs";
|
|
5
5
|
import { startSnapshotWriter, stopSnapshotWriter } from "./status-snapshot.mjs";
|
|
6
|
+
import { safeIpcSend } from "../../shared/safe-ipc-send.mjs";
|
|
6
7
|
import { initProviders } from "../../agent/orchestrator/providers/registry.mjs";
|
|
7
8
|
import { loadConfig as loadAgentConfig } from "../../agent/orchestrator/config.mjs";
|
|
8
9
|
import {
|
|
@@ -423,14 +424,11 @@ function armBridgeOwnershipTimer() {}
|
|
|
423
424
|
// error-callback path of ERR_IPC_CHANNEL_CLOSED (channel closing between the
|
|
424
425
|
// connected check and delivery). Log-and-continue — never crash the worker.
|
|
425
426
|
function sendToParent(message) {
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
} catch (err) {
|
|
432
|
-
process.stderr.write(`[channels] parent IPC send threw: ${err?.message || err}\n`);
|
|
433
|
-
}
|
|
427
|
+
safeIpcSend(process, message, {
|
|
428
|
+
onError: (err) => {
|
|
429
|
+
try { process.stderr.write(`[channels] parent IPC send failed: ${err?.message || err}\n`); } catch {}
|
|
430
|
+
},
|
|
431
|
+
});
|
|
434
432
|
}
|
|
435
433
|
// Tell the parent session this worker ACQUIRED the bridge so it flips remote
|
|
436
434
|
// mode ON (badge/transcript writer). Callers fire this only on a genuine
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
// (behavior-preserving). Groups the notify-to-parent path and the
|
|
3
3
|
// worker → parent → memory call bridge. Bound to live getters
|
|
4
4
|
// (getInstanceId) so runtime identity stays consistent.
|
|
5
|
+
import { safeIpcSend } from '../../shared/safe-ipc-send.mjs';
|
|
6
|
+
|
|
5
7
|
function normalizeChannelNotifyParams(method, params) {
|
|
6
8
|
if (method === 'notifications/claude/channel' && params && params.meta) {
|
|
7
9
|
const m = {};
|
|
@@ -24,7 +26,7 @@ function setChannelNotifySink(fn) {
|
|
|
24
26
|
_notifySink = typeof fn === 'function' ? fn : null;
|
|
25
27
|
}
|
|
26
28
|
|
|
27
|
-
function createParentBridge({ getInstanceId }) {
|
|
29
|
+
function createParentBridge({ getInstanceId, ipcProcess = process }) {
|
|
28
30
|
function sendNotifyToParent(method, params) {
|
|
29
31
|
// CC channel schema requires meta: Record<string,string> (channelNotification.ts).
|
|
30
32
|
// Coerce every meta value to string so a non-string (e.g. a Discord
|
|
@@ -37,15 +39,15 @@ function createParentBridge({ getInstanceId }) {
|
|
|
37
39
|
catch (err) { try { process.stderr.write(`mixdog channels: notify sink failed: ${err && err.message || err}\n`); } catch {} }
|
|
38
40
|
return;
|
|
39
41
|
}
|
|
40
|
-
if (!
|
|
42
|
+
if (!ipcProcess?.send || ipcProcess.connected !== true) {
|
|
41
43
|
try { process.stderr.write(`mixdog channels: notify dropped (no IPC channel): ${method}\n`); } catch {}
|
|
42
44
|
return;
|
|
43
45
|
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
}
|
|
46
|
+
safeIpcSend(ipcProcess, { type: 'notify', method, params: outParams }, {
|
|
47
|
+
onError: (err) => {
|
|
48
|
+
try { process.stderr.write(`mixdog channels: notify IPC send failed: ${err && err.message || err}\n`); } catch {}
|
|
49
|
+
},
|
|
50
|
+
});
|
|
49
51
|
}
|
|
50
52
|
|
|
51
53
|
// ── Memory worker bridge (worker → parent → memory) ─────────────────
|
|
@@ -56,10 +58,24 @@ function createParentBridge({ getInstanceId }) {
|
|
|
56
58
|
// integrated into the main IPC handler below (not a second listener).
|
|
57
59
|
const _memoryCallPending = new Map();
|
|
58
60
|
let _memoryCallSeq = 0;
|
|
61
|
+
const failMemoryCall = (callId, error) => {
|
|
62
|
+
const pending = _memoryCallPending.get(callId);
|
|
63
|
+
if (!pending) return;
|
|
64
|
+
_memoryCallPending.delete(callId);
|
|
65
|
+
pending.reject(error instanceof Error ? error : new Error(String(error || 'memory_call IPC failed')));
|
|
66
|
+
};
|
|
67
|
+
const rejectAllMemoryCalls = (error) => {
|
|
68
|
+
for (const callId of Array.from(_memoryCallPending.keys())) failMemoryCall(callId, error);
|
|
69
|
+
};
|
|
70
|
+
ipcProcess?.once?.('disconnect', () => {
|
|
71
|
+
rejectAllMemoryCalls(new Error('memory_call parent IPC disconnected'));
|
|
72
|
+
});
|
|
59
73
|
|
|
60
74
|
function callMemoryAction(action, args, timeoutMs) {
|
|
61
75
|
return new Promise((resolve, reject) => {
|
|
62
|
-
if (!
|
|
76
|
+
if (!ipcProcess?.send || ipcProcess.connected !== true) {
|
|
77
|
+
return reject(new Error('not a connected worker process'));
|
|
78
|
+
}
|
|
63
79
|
const callId = `mc_${getInstanceId()}_${++_memoryCallSeq}_${Math.random().toString(36).slice(2, 8)}`;
|
|
64
80
|
const timer = setTimeout(() => {
|
|
65
81
|
_memoryCallPending.delete(callId);
|
|
@@ -69,13 +85,9 @@ function createParentBridge({ getInstanceId }) {
|
|
|
69
85
|
resolve: (v) => { clearTimeout(timer); resolve(v); },
|
|
70
86
|
reject: (e) => { clearTimeout(timer); reject(e); },
|
|
71
87
|
});
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
}
|
|
75
|
-
_memoryCallPending.delete(callId);
|
|
76
|
-
clearTimeout(timer);
|
|
77
|
-
reject(e);
|
|
78
|
-
}
|
|
88
|
+
safeIpcSend(ipcProcess, { type: 'memory_call_request', callId, action, args: args || {} }, {
|
|
89
|
+
onError: (error) => failMemoryCall(callId, error),
|
|
90
|
+
});
|
|
79
91
|
});
|
|
80
92
|
}
|
|
81
93
|
|