mixdog 0.9.83 → 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.
Files changed (30) hide show
  1. package/package.json +1 -1
  2. package/scripts/channel-daemon-stub.mjs +2 -1
  3. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +30 -3
  4. package/src/runtime/agent/orchestrator/session/loop/steering.mjs +38 -4
  5. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +48 -32
  6. package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +25 -14
  7. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +8 -2
  8. package/src/runtime/agent/orchestrator/session/store/listing.mjs +38 -1
  9. package/src/runtime/agent/orchestrator/session/store.mjs +1 -0
  10. package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +44 -73
  11. package/src/runtime/channels/lib/owned-runtime.mjs +6 -8
  12. package/src/runtime/channels/lib/parent-bridge.mjs +27 -15
  13. package/src/runtime/channels/lib/worker-ipc.mjs +17 -4
  14. package/src/runtime/memory/index.mjs +14 -5
  15. package/src/runtime/memory/lib/agent-ipc.mjs +11 -12
  16. package/src/runtime/shared/atomic-file.mjs +51 -24
  17. package/src/runtime/shared/err-text.mjs +1 -0
  18. package/src/runtime/shared/safe-ipc-send.mjs +34 -0
  19. package/src/session-runtime/lifecycle-api.mjs +9 -3
  20. package/src/session-runtime/runtime-core.mjs +5 -1
  21. package/src/standalone/channel-daemon.mjs +2 -1
  22. package/src/standalone/channel-worker.mjs +21 -19
  23. package/src/standalone/memory-runtime-proxy.mjs +98 -23
  24. package/src/tui/app/slash-dispatch.mjs +23 -13
  25. package/src/tui/app/theme-effort-pickers.mjs +3 -1
  26. package/src/tui/dist/index.mjs +115 -57
  27. package/src/tui/engine/live-share.mjs +2 -2
  28. package/src/tui/engine/session-flow.mjs +20 -10
  29. package/src/tui/engine/turn.mjs +28 -16
  30. package/src/tui/engine.mjs +17 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mixdog",
3
- "version": "0.9.83",
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) {
@@ -2,7 +2,7 @@
2
2
  // Periodic idle-session + tombstone sweep extracted verbatim from manager.mjs.
3
3
  // Drives sweepStaleSessions on an unref'd interval; closeSession is imported
4
4
  // from session-close.mjs (one-way dependency, no cycle).
5
- import { sweepStaleSessions, evictIdleLiveSessions } from '../store.mjs';
5
+ import { sweepStaleSessions, sweepStaleSessionsCooperative, evictIdleLiveSessions } from '../store.mjs';
6
6
  import { sweepOrphanedPendingMessages } from './pending-messages.mjs';
7
7
  import {
8
8
  _getRuntimeEntry,
@@ -23,6 +23,7 @@ const CLEANUP_SLOW_LOG_MS = nonNegativeIntEnv('MIXDOG_SESSION_CLEANUP_SLOW_LOG_M
23
23
  const TOMBSTONE_MAX_AGE_MS = 60 * 60 * 1000; // 1h
24
24
  let _cleanupTimer = null;
25
25
  let _cleanupInitialTimer = null;
26
+ let _cleanupRun = null;
26
27
 
27
28
  // A session is "live" when it still owns a non-closed runtime entry. Passed to
28
29
  // the retention cap so the active/current and any in-flight session is never
@@ -65,10 +66,10 @@ const _sweepLog = (line) => {
65
66
  if (process.env.MIXDOG_DEBUG_SESSION_LOG) process.stderr.write(line);
66
67
  };
67
68
 
68
- function sweepIdleSessions({ includeTombstones = true, sweepIdle = true } = {}) {
69
+ async function sweepIdleSessions({ includeTombstones = true, sweepIdle = true } = {}) {
69
70
  const startedAt = Date.now();
70
71
  try {
71
- const result = sweepStaleSessions({
72
+ const result = await sweepStaleSessionsCooperative({
72
73
  sweepIdle,
73
74
  tombstoneMaxAgeMs: includeTombstones ? TOMBSTONE_MAX_AGE_MS : 0,
74
75
  isSessionLive: _isSessionLive,
@@ -144,33 +145,43 @@ export function sweepTombstones() {
144
145
  }
145
146
 
146
147
  export function _runCleanupCycle() {
147
- // Drain every settled runtime entry on each pass, not just the one or two
148
- // sessions whose on-disk idle TTL happened to expire in this interval.
149
- _sweepTerminalSessionRuntimes();
150
- sweepOrphanedPendingMessages();
151
- sweepIdleSessions({ includeTombstones: true });
152
- // Reclaim same-process session snapshots whose state is durable on disk
153
- // (memory-leak guard: _liveSessions used to grow for process lifetime).
154
- try { evictIdleLiveSessions({ isSessionLive: _isSessionLive }); } catch { /* best-effort */ }
148
+ if (_cleanupRun) return _cleanupRun;
149
+ const run = (async () => {
150
+ // Drain every settled runtime entry on each pass, not just the one or two
151
+ // sessions whose on-disk idle TTL happened to expire in this interval.
152
+ _sweepTerminalSessionRuntimes();
153
+ sweepOrphanedPendingMessages();
154
+ await sweepIdleSessions({ includeTombstones: true });
155
+ // Reclaim same-process session snapshots whose state is durable on disk
156
+ // (memory-leak guard: _liveSessions used to grow for process lifetime).
157
+ try { evictIdleLiveSessions({ isSessionLive: _isSessionLive }); } catch { /* best-effort */ }
158
+ })().catch((error) => {
159
+ try { process.stderr.write(`[agent-session] cleanup cycle failed: ${error?.message || error}\n`); } catch {}
160
+ });
161
+ const tracked = run.finally(() => {
162
+ if (_cleanupRun === tracked) _cleanupRun = null;
163
+ });
164
+ _cleanupRun = tracked;
165
+ return tracked;
155
166
  }
156
167
 
157
168
  function _startCleanupInterval() {
158
169
  if (_cleanupTimer) return;
159
170
  if (CLEANUP_INTERVAL_MS <= 0) return;
160
- _cleanupTimer = setInterval(_runCleanupCycle, CLEANUP_INTERVAL_MS);
171
+ _cleanupTimer = setInterval(() => { void _runCleanupCycle(); }, CLEANUP_INTERVAL_MS);
161
172
  if (_cleanupTimer.unref) _cleanupTimer.unref(); // don't block process exit
162
173
  }
163
174
 
164
175
  export function startIdleCleanup() {
165
176
  if (_cleanupTimer || _cleanupInitialTimer) return;
166
177
  if (CLEANUP_INITIAL_DELAY_MS <= 0) {
167
- _runCleanupCycle();
178
+ void _runCleanupCycle();
168
179
  _startCleanupInterval();
169
180
  return;
170
181
  }
171
182
  _cleanupInitialTimer = setTimeout(() => {
172
183
  _cleanupInitialTimer = null;
173
- _runCleanupCycle();
184
+ void _runCleanupCycle();
174
185
  _startCleanupInterval();
175
186
  }, CLEANUP_INITIAL_DELAY_MS);
176
187
  if (_cleanupInitialTimer.unref) _cleanupInitialTimer.unref();
@@ -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;
@@ -244,7 +244,7 @@ export function getStoredSessionsRaw() {
244
244
  * Background sweep: delete session files idle longer than ttlMs.
245
245
  * Returns { cleaned, remaining, details } for logging.
246
246
  */
247
- export function sweepStaleSessions(ttlMs, options = {}) {
247
+ function* sweepStaleSessionSteps(ttlMs, options = {}) {
248
248
  if (ttlMs && typeof ttlMs === 'object') {
249
249
  options = ttlMs;
250
250
  ttlMs = options.ttlMs;
@@ -302,6 +302,9 @@ export function sweepStaleSessions(ttlMs, options = {}) {
302
302
  let openPruned = 0;
303
303
  const openPrunedDetails = [];
304
304
  for (const row of summaries) {
305
+ // Cooperative callers pause between records so large stores never hold
306
+ // an interactive host's event loop for the full directory scan.
307
+ yield undefined;
305
308
  try {
306
309
  if (!row?.id) continue;
307
310
  const jsonPath = sessionPath(row.id);
@@ -593,6 +596,7 @@ export function sweepStaleSessions(ttlMs, options = {}) {
593
596
  // session mid-create whose .json write has not landed yet.
594
597
  try {
595
598
  for (const h of readdirSync(dir).filter(f => f.endsWith('.hb') || f.endsWith('.own'))) {
599
+ yield undefined;
596
600
  if (existsSync(join(dir, h.replace(/\.(hb|own)$/, '.json')))) continue;
597
601
  let hbMtime = 0;
598
602
  try { hbMtime = statSync(join(dir, h)).mtimeMs; } catch { continue; }
@@ -613,3 +617,36 @@ export function sweepStaleSessions(ttlMs, options = {}) {
613
617
  }
614
618
  return { cleaned, remaining, details, tombstonesCleaned, tombstoneDetails, tombstoneErrors, openPruned, openPrunedDetails };
615
619
  }
620
+
621
+ /** Synchronous compatibility surface for explicit maintenance commands/tests. */
622
+ export function sweepStaleSessions(ttlMs, options = {}) {
623
+ const steps = sweepStaleSessionSteps(ttlMs, options);
624
+ let next = steps.next();
625
+ while (!next.done) next = steps.next();
626
+ return next.value;
627
+ }
628
+
629
+ /**
630
+ * Interactive-host sweep: preserve the exact synchronous lifecycle decisions
631
+ * while yielding between records. A single large session remains atomic, but a
632
+ * directory worth of reads/parses can no longer become one multi-second task.
633
+ */
634
+ export async function sweepStaleSessionsCooperative(ttlMs, options = {}) {
635
+ const cooperativeOptions = ttlMs && typeof ttlMs === 'object' ? ttlMs : options;
636
+ const configuredSliceMs = Number(cooperativeOptions?.cooperativeSliceMs);
637
+ const sliceMs = Number.isFinite(configuredSliceMs)
638
+ ? Math.min(50, Math.max(0, configuredSliceMs))
639
+ : 8;
640
+ const steps = sweepStaleSessionSteps(ttlMs, options);
641
+ let next = steps.next();
642
+ while (!next.done) {
643
+ const sliceStartedAt = performance.now();
644
+ do {
645
+ next = steps.next();
646
+ } while (!next.done && performance.now() - sliceStartedAt < sliceMs);
647
+ if (!next.done) {
648
+ await new Promise((resolve) => setImmediate(resolve));
649
+ }
650
+ }
651
+ return next.value;
652
+ }
@@ -757,5 +757,6 @@ export {
757
757
  listStoredSessionSummaries,
758
758
  getStoredSessionsRaw,
759
759
  sweepStaleSessions,
760
+ sweepStaleSessionsCooperative,
760
761
  } from './store/listing.mjs';
761
762
  export { _savePending };
@@ -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
  }