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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mixdog",
3
- "version": "0.9.84",
3
+ "version": "0.9.85",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Standalone mixdog coding-agent CLI/TUI workspace.",
@@ -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
- if (process.send) { try { process.send({ type: 'ready', port, token }); } catch {} }
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({ role: 'user', content: merged.content, meta: { source: 'steering' } });
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 { opts.onSteerMessage?.(text); } catch {}
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 { process.stderr.write(`[steer] sess=${sessionId} injected ${stage} user message(s) (merged=${totalCount} len=${totalTextLen})\n`); } catch {}
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 { content: parts, text: displayText || steeringContentText(parts), count: normalized.length };
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 = _turnInterruption.finalize({
844
- turnOutgoing: _turnOutgoing || activeSession.messages,
845
- currentUserContent: cancelledUserTurnContent,
846
- abortReason: err.reason,
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 = _turnInterruption.finalize({
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 = _getRuntimeEntry(id)?.session;
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. ONLY a
9
- // successful (non-null) resolution is cached a resolution MISS is
10
- // deliberately not memoized so the next call re-probes (Git Bash / pwsh may
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 firstExistingPathFromWhere(commandName) {
47
- try {
48
- const r = spawnSync('cmd.exe', ['/d', '/s', '/c', `where ${commandName}`], {
49
- stdio: ['ignore', 'pipe', 'ignore'],
50
- windowsHide: true,
51
- timeout: 1000,
52
- });
53
- if (r.status !== 0 || !r.stdout) return null;
54
- const lines = r.stdout.toString('utf8').split(/\r?\n/).map(s => s.trim()).filter(Boolean);
55
- return lines.find(p => existsSync(p)) || null;
56
- } catch {
57
- return null;
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 = firstExistingPathFromWhere('pwsh.exe');
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 = firstExistingPathFromWhere('powershell.exe');
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 allExistingPathsFromWhere('git.exe')) {
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 = firstExistingPathFromWhereExcluding('bash.exe', /\\system32\\/i);
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
- try {
208
- const r = spawnSync('which', ['bash'], { stdio: ['ignore', 'pipe', 'ignore'], timeout: 1000 });
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, but ONLY on success: a resolution miss
224
- // (null) is not cached, so the caller's clear-error path is re-probed on the
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
- if (_resolvedShellByKind.has(kind)) return _resolvedShellByKind.get(kind);
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
- // Cache positively only — see the _resolvedShellByKind / doc comments above.
252
- if (spec) _resolvedShellByKind.set(kind, spec);
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
- if (!process.send || process.connected === false) return;
427
- try {
428
- process.send(message, undefined, undefined, err => {
429
- if (err) process.stderr.write(`[channels] parent IPC send failed: ${err?.message || err}\n`);
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 (!process.send) {
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
- try {
45
- process.send({ type: 'notify', method, params: outParams });
46
- } catch (err) {
47
- try { process.stderr.write(`mixdog channels: notify IPC send failed: ${err && err.message || err}\n`); } catch {}
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 (!process.send) return reject(new Error('not a worker process'));
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
- try {
73
- process.send({ type: 'memory_call_request', callId, action, args: args || {} });
74
- } catch (e) {
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