@yemi33/minions 0.1.2382 → 0.1.2384

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 (51) hide show
  1. package/bin/minions.js +1 -0
  2. package/dashboard/js/refresh.js +2 -1
  3. package/dashboard/js/settings.js +1 -1
  4. package/dashboard.js +37 -19
  5. package/docs/completion-reports.md +20 -1
  6. package/docs/keep-processes.md +7 -2
  7. package/docs/live-checkout-mode.md +7 -7
  8. package/docs/managed-spawn.md +14 -1
  9. package/docs/runtime-adapters.md +61 -26
  10. package/docs/skills.md +2 -0
  11. package/docs/workspace-manifests.md +1 -1
  12. package/docs/worktree-lifecycle.md +36 -0
  13. package/engine/acp-transport.js +273 -58
  14. package/engine/ado-comment.js +3 -1
  15. package/engine/agent-worker-pool.js +278 -54
  16. package/engine/cc-worker-pool.js +12 -3
  17. package/engine/cleanup.js +15 -11
  18. package/engine/cli.js +56 -28
  19. package/engine/comment-format.js +51 -14
  20. package/engine/create-pr-worktree.js +57 -79
  21. package/engine/dispatch.js +7 -2
  22. package/engine/execution-model.js +68 -0
  23. package/engine/gh-comment.js +6 -3
  24. package/engine/github.js +6 -7
  25. package/engine/keep-process-sweep.js +131 -9
  26. package/engine/lifecycle.js +126 -45
  27. package/engine/live-checkout.js +4 -2
  28. package/engine/llm.js +59 -83
  29. package/engine/managed-spawn.js +112 -5
  30. package/engine/memory-store.js +3 -1
  31. package/engine/playbook.js +4 -6
  32. package/engine/pooled-agent-process.js +512 -45
  33. package/engine/pr-action.js +6 -8
  34. package/engine/process-utils.js +154 -18
  35. package/engine/runtimes/claude.js +94 -25
  36. package/engine/runtimes/codex.js +1 -0
  37. package/engine/runtimes/contract.js +239 -0
  38. package/engine/runtimes/copilot.js +97 -9
  39. package/engine/runtimes/index.js +37 -9
  40. package/engine/shared.js +349 -82
  41. package/engine/spawn-agent.js +85 -116
  42. package/engine/spawn-phase-watchdog.js +8 -37
  43. package/engine/timeout.js +14 -10
  44. package/engine/worktree-gc.js +55 -46
  45. package/engine.js +418 -241
  46. package/package.json +1 -1
  47. package/playbooks/fix.md +20 -0
  48. package/playbooks/review.md +2 -0
  49. package/playbooks/shared-rules.md +2 -2
  50. package/prompts/cc-system.md +11 -11
  51. package/skills/check-self-authored-review-comment/SKILL.md +34 -0
@@ -32,12 +32,18 @@
32
32
  */
33
33
 
34
34
  const fs = require('fs');
35
- const os = require('os');
36
35
  const path = require('path');
37
36
  const { runFile, cleanChildEnv, killGracefully, killImmediate, killByPidsImmediate, listProcessDescendants, ts, readFileNoFollow } = require('./shared');
38
- const { resolveRuntime } = require('./runtimes');
37
+ const {
38
+ resolveRuntime,
39
+ RUNTIME_OPTIONS_FLAG,
40
+ RUNTIME_IMAGES_FILE_OPTION,
41
+ decodeRuntimeOptions,
42
+ prepareWorkspace: prepareRuntimeWorkspace,
43
+ } = require('./runtimes');
39
44
  const { acquireAdoTokenSync, isLikelyAdoToken } = require('./ado-token');
40
45
  const keepProcessSweep = require('./keep-process-sweep');
46
+ const MAX_RUNTIME_IMAGES_FILE_BYTES = 32 * 1024 * 1024;
41
47
 
42
48
  // ─── Pure helpers (exported for tests) ──────────────────────────────────────
43
49
 
@@ -76,17 +82,7 @@ function parseSpawnArgs(argv) {
76
82
  case '--disable-builtin-mcps': opts.disableBuiltinMcps = true; break;
77
83
  case '--no-custom-instructions': opts.suppressAgentsMd = true; break;
78
84
  case '--enable-reasoning-summaries': opts.reasoningSummaries = true; break;
79
- case '--project-harness-dir': {
80
- const v = peek();
81
- if (typeof v === 'string' && v) projectHarnessDirs.push(v);
82
- break;
83
- }
84
- // P-49e1c8b7 — hermetic harness opt-out. When set, computeAddDirs in
85
- // main() returns [minionsDir] only (user-scope asset dirs from
86
- // runtime.getUserAssetDirs AND any forwarded --project-harness-dir
87
- // entries are dropped). Engine.js gates this flag via
88
- // shared.resolveAgentHermeticHarness(agent, engine).
89
- case '--hermetic-harness': hermetic = true; break;
85
+ case RUNTIME_OPTIONS_FLAG: Object.assign(opts, decodeRuntimeOptions(peek())); break;
90
86
  // LEGACY: dropped — the runtime adapter emits its own permission flag.
91
87
  // Pre-P-2a6d9c4f engine.js still passes `--permission-mode bypassPermissions`;
92
88
  // letting it through would duplicate the permission flag for Claude.
@@ -168,6 +164,41 @@ function injectAdoTokenEnvForRepoHost(env, opts) {
168
164
  return injectAdoTokenEnv(env, opts);
169
165
  }
170
166
 
167
+ function hydrateRuntimeOptionFiles(opts, {
168
+ promptFile,
169
+ readFile = readFileNoFollow,
170
+ lstatSync = fs.lstatSync,
171
+ unlinkSync = fs.unlinkSync,
172
+ } = {}) {
173
+ const imagesFile = opts?.[RUNTIME_IMAGES_FILE_OPTION];
174
+ if (!imagesFile) return opts;
175
+ if (typeof imagesFile !== 'string' || !promptFile) {
176
+ throw new Error('Invalid runtime images sidecar path');
177
+ }
178
+ const resolvedFile = path.resolve(imagesFile);
179
+ const optionsDir = path.resolve(path.dirname(promptFile));
180
+ if (path.dirname(resolvedFile) !== optionsDir) {
181
+ throw new Error('Runtime images sidecar must be beside the prompt file');
182
+ }
183
+ const stat = lstatSync(resolvedFile);
184
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_RUNTIME_IMAGES_FILE_BYTES) {
185
+ throw new Error('Invalid runtime images sidecar file');
186
+ }
187
+ let images;
188
+ try {
189
+ images = JSON.parse(readFile(resolvedFile, 'utf8'));
190
+ } catch (err) {
191
+ throw new Error(`Invalid runtime images sidecar payload: ${err.message}`);
192
+ }
193
+ if (!Array.isArray(images)) {
194
+ throw new Error('Invalid runtime images sidecar payload: expected an array');
195
+ }
196
+ opts.images = images;
197
+ delete opts[RUNTIME_IMAGES_FILE_OPTION];
198
+ try { unlinkSync(resolvedFile); } catch {}
199
+ return opts;
200
+ }
201
+
171
202
  function formatProcessExitSentinel(exitCode, signal) {
172
203
  return `\n[process-exit] code=${exitCode}${signal ? ` signal=${signal}` : ''}\n`;
173
204
  }
@@ -307,105 +338,16 @@ function createParentPipeForwarder(stream, outputPath, prefix = '') {
307
338
  }
308
339
 
309
340
  /**
310
- * P-7d31a06b Pre-approve a worktree's `.mcp.json` servers in `~/.claude.json`
311
- * so Claude Code's first invocation in a fresh worktree cwd doesn't show the
312
- * project-scoped "trust this MCP server?" prompt (which `--dangerously-skip-permissions`
313
- * silently suppresses → the agent runs without the workspace MCPs).
314
- *
315
- * Writes to `projects[worktreePath].enabledMcpjsonServers` (the field Claude
316
- * Code actually reads — confirmed against the live `~/.claude.json` schema:
317
- * `enabledMcpjsonServers` / `disabledMcpjsonServers` are siblings of
318
- * `mcpServers`, indexed by absolute project path). Notes on the PRD wording
319
- * "mcpServers.approved": that's the intent label; `enabledMcpjsonServers` is
320
- * the on-disk reality.
321
- *
322
- * Pure-ish: all I/O (fs reads, mutator) is injected for tests. Best-effort —
323
- * callers MUST swallow errors and never block dispatch on this.
324
- *
325
- * Returns `{ wrote: boolean, reason: string, servers: string[] }`:
326
- * - reason ∈ { 'ok', 'flag-off', 'not-claude', 'no-worktree',
327
- * 'no-workspace-mcp', 'invalid-workspace-mcp', 'no-servers' }
328
- * - servers: keys discovered in the workspace `.mcp.json` (regardless of write)
329
- *
330
- * Args:
331
- * runtimeName — `runtime.name` from `resolveRuntime(...)`. Helper no-ops if !== 'claude'.
332
- * worktreePath — absolute path to the agent's cwd (the worktree). Used as both the file location
333
- * (`<worktreePath>/.mcp.json`) and the `projects[...]` key in `~/.claude.json`.
334
- * homeDir — `os.homedir()`. Injected for test isolation; helper does NOT call os.homedir directly.
335
- * engineConfig — config.engine object; reads `claudePreApproveWorkspaceMcps` (default true via ENGINE_DEFAULTS).
336
- * mutateJsonFileLocked — injected file-locked mutator (engine/shared.js#mutateJsonFileLocked).
337
- * existsSync, readFileSync — injectable fs primitives (default to node:fs).
341
+ * Backward-compatible wrapper for the old helper name. Runtime-owned workspace
342
+ * preparation now lives behind the adapter contract; the spawn wrapper does
343
+ * not know which harness needs which config file mutation.
338
344
  */
339
- function preApproveWorkspaceMcps({
340
- runtimeName,
341
- worktreePath,
342
- homeDir,
343
- engineConfig,
344
- mutateJsonFileLocked,
345
- existsSync = fs.existsSync,
346
- readFileSync = fs.readFileSync,
347
- } = {}) {
348
- if (engineConfig && engineConfig.claudePreApproveWorkspaceMcps === false) {
349
- return { wrote: false, reason: 'flag-off', servers: [] };
350
- }
351
- if (runtimeName !== 'claude') {
352
- return { wrote: false, reason: 'not-claude', servers: [] };
353
- }
354
- if (!worktreePath || typeof worktreePath !== 'string') {
355
- return { wrote: false, reason: 'no-worktree', servers: [] };
345
+ function preApproveWorkspaceMcps({ runtimeName, ...context } = {}) {
346
+ const result = prepareRuntimeWorkspace(resolveRuntime(runtimeName), context);
347
+ if (result?.reason === 'unsupported-runtime') {
348
+ return { ...result, reason: 'not-claude' };
356
349
  }
357
- const workspaceMcpPath = path.join(worktreePath, '.mcp.json');
358
- if (!existsSync(workspaceMcpPath)) {
359
- return { wrote: false, reason: 'no-workspace-mcp', servers: [] };
360
- }
361
- let workspaceMcp;
362
- try {
363
- workspaceMcp = JSON.parse(readFileSync(workspaceMcpPath, 'utf8'));
364
- } catch {
365
- return { wrote: false, reason: 'invalid-workspace-mcp', servers: [] };
366
- }
367
- const serversObj = workspaceMcp && typeof workspaceMcp === 'object' ? workspaceMcp.mcpServers : null;
368
- const servers = serversObj && typeof serversObj === 'object' && !Array.isArray(serversObj)
369
- ? Object.keys(serversObj).filter((k) => typeof k === 'string' && k.length > 0)
370
- : [];
371
- if (servers.length === 0) {
372
- return { wrote: false, reason: 'no-servers', servers: [] };
373
- }
374
- if (!homeDir || typeof mutateJsonFileLocked !== 'function') {
375
- return { wrote: false, reason: 'no-home-or-mutator', servers };
376
- }
377
- const claudeJsonPath = path.join(homeDir, '.claude.json');
378
- let didWrite = false;
379
- mutateJsonFileLocked(claudeJsonPath, (data) => {
380
- const root = data && typeof data === 'object' ? data : {};
381
- if (!root.projects || typeof root.projects !== 'object') root.projects = {};
382
- const projectKey = worktreePath;
383
- const proj = root.projects[projectKey] && typeof root.projects[projectKey] === 'object'
384
- ? root.projects[projectKey]
385
- : {};
386
- // enabledMcpjsonServers is Claude's actual on-disk approval field
387
- const enabled = Array.isArray(proj.enabledMcpjsonServers) ? proj.enabledMcpjsonServers.slice() : [];
388
- const disabled = Array.isArray(proj.disabledMcpjsonServers) ? proj.disabledMcpjsonServers.slice() : [];
389
- const enabledSet = new Set(enabled.filter((s) => typeof s === 'string'));
390
- let mutated = false;
391
- for (const name of servers) {
392
- if (!enabledSet.has(name)) {
393
- enabledSet.add(name);
394
- mutated = true;
395
- }
396
- }
397
- // If a server was previously explicitly disabled, lift the disabled mark when we
398
- // re-approve it — otherwise Claude treats the disabled entry as authoritative.
399
- const nextDisabled = disabled.filter((s) => typeof s === 'string' && !servers.includes(s));
400
- if (nextDisabled.length !== disabled.length) mutated = true;
401
- if (!mutated) return data;
402
- proj.enabledMcpjsonServers = [...enabledSet];
403
- proj.disabledMcpjsonServers = nextDisabled;
404
- root.projects[projectKey] = proj;
405
- didWrite = true;
406
- return root;
407
- }, { defaultValue: {}, skipWriteIfUnchanged: true });
408
- return { wrote: didWrite, reason: 'ok', servers };
350
+ return result;
409
351
  }
410
352
 
411
353
  // ─── Main script execution ──────────────────────────────────────────────────
@@ -421,7 +363,13 @@ function _installHint(name, runtime) {
421
363
  }
422
364
 
423
365
  function main() {
424
- const parsed = parseSpawnArgs(process.argv);
366
+ let parsed;
367
+ try {
368
+ parsed = parseSpawnArgs(process.argv);
369
+ } catch (err) {
370
+ console.error(`FATAL: ${err.message}`);
371
+ process.exit(78);
372
+ }
425
373
  if (!parsed) {
426
374
  console.error('Usage: node spawn-agent.js <prompt-file> <sysprompt-file> [--runtime <name>] [args...]');
427
375
  process.exit(1);
@@ -445,12 +393,18 @@ function main() {
445
393
  // (Windows non-junction reads don't follow symlinks the same way POSIX does).
446
394
  const promptText = readFileNoFollow(promptFile, 'utf8');
447
395
  const sysPromptText = readFileNoFollow(sysPromptFile, 'utf8');
396
+ try {
397
+ hydrateRuntimeOptionFiles(opts, { promptFile });
398
+ } catch (err) {
399
+ console.error(`FATAL: ${err.message}`);
400
+ process.exit(78);
401
+ }
448
402
 
449
403
  // Sys prompt tmp file — only when (a) NOT resuming and (b) the adapter
450
404
  // accepts a system prompt as a separate file. For runtimes that bake the
451
405
  // system prompt into the user prompt (e.g. Copilot), sysPromptText is
452
406
  // already merged inside `runtime.buildPrompt(prompt, sys)`.
453
- const isResume = opts.sessionId != null;
407
+ const isResume = typeof opts.sessionId === 'string' && opts.sessionId.trim() !== '';
454
408
  const sysTmpPath = sysPromptFile + '.tmp';
455
409
  const wantsSystemPromptFile = typeof runtime.usesSystemPromptFile === 'function'
456
410
  ? runtime.usesSystemPromptFile({ isResume, opts })
@@ -672,9 +626,12 @@ function main() {
672
626
  // meta.keep_processes_skip_workdir_check is true) bypasses the
673
627
  // requireGitWorkdir check so legitimate non-git keep_processes
674
628
  // use cases (e.g., a daemon under /tmp) still anchor.
675
- const reapOpts = process.env.MINIONS_KEEP_PROCESSES_SKIP_WORKDIR_CHECK === '1'
676
- ? { requireGitWorkdir: false }
677
- : {};
629
+ const reapOpts = {
630
+ requireGitWorkdir: process.env.MINIONS_KEEP_PROCESSES_SKIP_WORKDIR_CHECK !== '1',
631
+ };
632
+ if (process.env.MINIONS_KEEP_PROCESSES_ALLOWED_CWD_ROOT) {
633
+ reapOpts.allowedCwdRoot = process.env.MINIONS_KEEP_PROCESSES_ALLOWED_CWD_ROOT;
634
+ }
678
635
  const plan = keepProcessSweep.computeReapPlan(toKillPids, agentId, reapOpts);
679
636
  toKillPids = plan.toKill;
680
637
  kept = plan.kept;
@@ -714,6 +671,18 @@ function main() {
714
671
  });
715
672
  }
716
673
 
717
- module.exports = { parseSpawnArgs, buildSpawnInvocation, normalizeRuntimeExit, shouldInjectAdoTokenEnv, injectAdoTokenEnv, injectAdoTokenEnvForRepoHost, writeProcessExitSentinel, preApproveWorkspaceMcps, createParentPipeForwarder, assertStaleHeadOk };
674
+ module.exports = {
675
+ parseSpawnArgs,
676
+ buildSpawnInvocation,
677
+ normalizeRuntimeExit,
678
+ shouldInjectAdoTokenEnv,
679
+ injectAdoTokenEnv,
680
+ injectAdoTokenEnvForRepoHost,
681
+ writeProcessExitSentinel,
682
+ preApproveWorkspaceMcps,
683
+ createParentPipeForwarder,
684
+ assertStaleHeadOk,
685
+ hydrateRuntimeOptionFiles, // exported for testing
686
+ };
718
687
 
719
688
  if (require.main === module) main();
@@ -36,6 +36,7 @@ const fs = require('fs');
36
36
  const path = require('path');
37
37
  const shared = require('./shared');
38
38
  const queries = require('./queries');
39
+ const { resolveRuntime, isSpawnStartupEvent } = require('./runtimes');
39
40
  const { ENGINE_DEFAULTS, FAILURE_CLASS } = shared;
40
41
  const AGENTS_DIR = queries.AGENTS_DIR;
41
42
  const log = (level, msg) => shared.log ? shared.log(level, msg) : console.log(`[${level}] ${msg}`);
@@ -43,24 +44,6 @@ const log = (level, msg) => shared.log ? shared.log(level, msg) : console.log(`[
43
44
  // Lazy require to break circular dep with dispatch.js (which lazy-requires engine.js).
44
45
  function dispatch() { return require('./dispatch'); }
45
46
 
46
- // ── Per-runtime startup-only event filters ──────────────────────────────────
47
- //
48
- // Anything NOT in these sets is treated as "real task activity" and the
49
- // watchdog stands down. These match the runtimes/*.js KNOWN_EVENT_TYPES
50
- // startup section — keep in sync.
51
-
52
- const COPILOT_STARTUP_TYPES = new Set([
53
- 'session.mcp_server_status_changed',
54
- 'session.mcp_servers_loaded',
55
- 'session.skills_loaded',
56
- 'session.tools_updated',
57
- 'session.info',
58
- ]);
59
-
60
- // Claude stream-json prints {"type":"system","subtype":"init|hook_started|hook_response"}
61
- // during startup before any assistant/user/tool_use/result events fire.
62
- const CLAUDE_STARTUP_SUBTYPES = new Set(['init', 'hook_started', 'hook_response']);
63
-
64
47
  // Header line that engine.js writes at spawn: "[<iso>] pid: <n>"
65
48
  const HEADER_LINE_RE = /^\[[^\]]+\]\s+(?:pid:|spawn-failed)/;
66
49
 
@@ -79,6 +62,12 @@ const HEADER_LINE_RE = /^\[[^\]]+\]\s+(?:pid:|spawn-failed)/;
79
62
  */
80
63
  function hasRealTaskActivity(tailText, runtimeName) {
81
64
  if (!tailText) return false;
65
+ let runtime;
66
+ try {
67
+ runtime = resolveRuntime(runtimeName);
68
+ } catch {
69
+ return true;
70
+ }
82
71
  const lines = String(tailText).split(/\r?\n/);
83
72
  for (const rawLine of lines) {
84
73
  const line = rawLine.trim();
@@ -100,23 +89,7 @@ function hasRealTaskActivity(tailText, runtimeName) {
100
89
  // Object with no `type` — unusual; treat as activity to stay safe.
101
90
  return true;
102
91
  }
103
- if (runtimeName === 'copilot') {
104
- if (COPILOT_STARTUP_TYPES.has(type)) continue;
105
- // Anything else from Copilot is real (assistant.*, tool.*, result,
106
- // session.task_complete, user.message, function).
107
- return true;
108
- }
109
- if (runtimeName === 'claude' || !runtimeName) {
110
- // Claude stream-json: only {type:'system', subtype:'init|hook_*'} is startup.
111
- if (type === 'system') {
112
- const sub = typeof evt.subtype === 'string' ? evt.subtype : '';
113
- if (CLAUDE_STARTUP_SUBTYPES.has(sub)) continue;
114
- // Other `system` subtypes (e.g. errors, banners) — treat as activity.
115
- return true;
116
- }
117
- return true;
118
- }
119
- // Unknown runtime — be conservative.
92
+ if (isSpawnStartupEvent(runtime, evt)) continue;
120
93
  return true;
121
94
  }
122
95
  return false;
@@ -266,6 +239,4 @@ function _defaultTailReader(filePath) {
266
239
  module.exports = {
267
240
  checkSpawnPhaseStalls,
268
241
  hasRealTaskActivity, // exported for testing
269
- COPILOT_STARTUP_TYPES, // exported for testing
270
- CLAUDE_STARTUP_SUBTYPES, // exported for testing
271
242
  };
package/engine/timeout.js CHANGED
@@ -25,14 +25,16 @@ let _engine = null;
25
25
  function engine() { if (!_engine) _engine = require('../engine'); return _engine; }
26
26
  const _pendingBranchPreservations = new Set();
27
27
 
28
- function resolveDispatchBranchPath(item) {
28
+ function resolveDispatchBranchPath(item, config) {
29
29
  if (item?.worktreePath) return item.worktreePath;
30
- if (item?.originalRef && item.meta?.project?.localPath) return item.meta.project.localPath;
30
+ if (shared.isLiveCheckoutDispatchRecord(item, config) && item.meta?.project?.localPath) {
31
+ return item.meta.project.localPath;
32
+ }
31
33
  return null;
32
34
  }
33
35
 
34
36
  function preserveDispatchBranch(item, config) {
35
- const branchPath = resolveDispatchBranchPath(item);
37
+ const branchPath = resolveDispatchBranchPath(item, config);
36
38
  if (!branchPath || !item.meta?.branch) {
37
39
  return Promise.resolve({ pushed: false, reason: 'invalid-worktree' });
38
40
  }
@@ -55,17 +57,18 @@ async function _finalizeDeadDispatch(item, reason, failureClass, config, deps =
55
57
  require('./live-checkout').maybeRestoreLiveCheckoutFromRecord;
56
58
  const complete = deps.complete || dispatch().completeDispatch;
57
59
 
58
- if (resolveDispatchBranchPath(item) && item.meta?.branch) {
60
+ if (resolveDispatchBranchPath(item, config) && item.meta?.branch) {
59
61
  try {
60
62
  await preserveBranch(item, config);
61
63
  } catch (err) {
62
64
  log('warn', `Orphan branch preservation failed for ${item.id}: ${err.message}`);
63
65
  }
64
66
  }
65
- if (item.originalRef && item.meta?.branch && item.meta?.project?.localPath) {
67
+ if (shared.isLiveCheckoutDispatchRecord(item, config)) {
66
68
  try {
67
69
  await restoreLiveCheckout({
68
70
  item,
71
+ config,
69
72
  isTerminalFailure: true,
70
73
  resultLabel: 'orphaned',
71
74
  log: (lvl, msg) => log(lvl, msg),
@@ -719,7 +722,7 @@ function checkTimeouts(config) {
719
722
  const legacyAnnotationClears = new Set();
720
723
 
721
724
  function completeFromOutput(item, liveLogPath, processExitCode, detectedLogText, hasProcess, preservationDone = false, expectedProcInfo = activeProcesses.get(item.id)) {
722
- if (!preservationDone && resolveDispatchBranchPath(item) && item.meta?.branch) {
725
+ if (!preservationDone && resolveDispatchBranchPath(item, config) && item.meta?.branch) {
723
726
  if (_pendingBranchPreservations.has(item.id)) return;
724
727
  _pendingBranchPreservations.add(item.id);
725
728
  preserveDispatchBranch(item, config)
@@ -776,12 +779,13 @@ function checkTimeouts(config) {
776
779
  // for a live-mode dispatch that finished AFTER an engine restart. This path
777
780
  // (output-detected completion of a re-attached process) has no in-memory
778
781
  // onAgentClose closure, so without this the tree is stranded on the agent
779
- // branch. originalRef's presence is the live-mode signal; the helper no-ops
780
- // for non-live records. Fire-and-forget + best-effort.
781
- if (item.originalRef && item.meta?.branch && item.meta?.project?.localPath) {
782
+ // branch. Explicit dispatch provenance plus current config are the live-mode
783
+ // signal; worktree-mode records fail closed.
784
+ if (shared.isLiveCheckoutDispatchRecord(item, config)) {
782
785
  try {
783
786
  require('./live-checkout').maybeRestoreLiveCheckoutFromRecord({
784
787
  item,
788
+ config,
785
789
  isTerminalFailure: _completedAsError,
786
790
  resultLabel: _completedAsError ? 'error' : 'success',
787
791
  log: (lvl, msg) => log(lvl, msg),
@@ -1033,7 +1037,7 @@ function checkTimeouts(config) {
1033
1037
  // #853 — stale orphans have no process close event, so preserve their
1034
1038
  // clean committed branch explicitly before completeDispatch makes the
1035
1039
  // worktree eligible for teardown/quarantine.
1036
- if (resolveDispatchBranchPath(item) && item.meta?.branch) {
1040
+ if (resolveDispatchBranchPath(item, config) && item.meta?.branch) {
1037
1041
  if (_pendingBranchPreservations.has(item.id)) continue;
1038
1042
  _pendingBranchPreservations.add(item.id);
1039
1043
  _finalizeDeadDispatch(item, reason, failureClass, config, { complete: completeDispatch })
@@ -41,6 +41,32 @@ function _managedSpawn() {
41
41
 
42
42
  const _noopLog = () => {};
43
43
 
44
+ function _collectAnchoredCwds(listManagedSpecs, listKeepProcessCwds) {
45
+ const out = [];
46
+ try {
47
+ for (const rec of (listManagedSpecs() || [])) {
48
+ if (!rec || typeof rec.cwd !== 'string' || !rec.cwd) continue;
49
+ try { out.push(shared.realPathForComparison(rec.cwd)); } catch {}
50
+ }
51
+ } catch {}
52
+ try {
53
+ for (const cwd of (listKeepProcessCwds() || [])) {
54
+ if (typeof cwd !== 'string' || !cwd) continue;
55
+ try { out.push(shared.realPathForComparison(cwd)); } catch {}
56
+ }
57
+ } catch {}
58
+ return out;
59
+ }
60
+
61
+ function _isWorktreeCwdAnchored(worktreePath, anchoredCwds) {
62
+ for (const cwd of anchoredCwds) {
63
+ try {
64
+ if (shared.isPathInsideOrEqual(cwd, worktreePath)) return true;
65
+ } catch {}
66
+ }
67
+ return false;
68
+ }
69
+
44
70
  // ── Stuck-dir escalation (Layer 4 of W-mq5o6bvy000x7191) ─────────────────────
45
71
  // In-memory ring of paths that have failed removal N consecutive times. After
46
72
  // escalation we (a) write a dedup'd inbox note, (b) suppress repeat per-tick
@@ -608,13 +634,11 @@ function shouldGcDispatchWorktree(opts) {
608
634
  ? listManagedSpecs
609
635
  : _managedSpawn().listManagedSpecs;
610
636
  const specs = fn() || [];
611
- const wtAbs = path.resolve(worktreePath);
612
- const wtPrefix = wtAbs + path.sep;
613
637
  for (const rec of specs) {
614
638
  if (!rec || typeof rec.cwd !== 'string' || rec.cwd.length === 0) continue;
615
- let cwdAbs;
616
- try { cwdAbs = path.resolve(rec.cwd); } catch { continue; }
617
- if (cwdAbs === wtAbs || cwdAbs.startsWith(wtPrefix)) {
639
+ let anchored = false;
640
+ try { anchored = shared.isPathInsideOrEqual(rec.cwd, worktreePath); } catch {}
641
+ if (anchored) {
618
642
  return { gc: false, reason: 'managed-spawn-anchored' };
619
643
  }
620
644
  }
@@ -744,6 +768,12 @@ function pruneOrphanWorktrees(opts) {
744
768
  try { return _managedSpawn().listManagedSpecs(); }
745
769
  catch (_e) { return []; }
746
770
  });
771
+ const _listKeepProcessCwds = typeof opts.listKeepProcessCwds === 'function'
772
+ ? opts.listKeepProcessCwds
773
+ : (() => {
774
+ try { return _keepProcessSweep().getActiveKeepProcessCwds(); }
775
+ catch (_e) { return []; }
776
+ });
747
777
 
748
778
  // Pool-known paths (idle + borrowed + any stale entry still on disk). The
749
779
  // worktree-pool's own pruneStale() runs FIRST in cli boot so this snapshot
@@ -768,7 +798,11 @@ function pruneOrphanWorktrees(opts) {
768
798
  // review — Issue 2)
769
799
  const globalLiveDirNames = new Set();
770
800
  for (const d of [...(dispatchSnap.active || []), ...(dispatchSnap.pending || [])]) {
771
- if (!d || !d.id || !d.meta || !d.meta.branch) continue;
801
+ if (!d || !d.id) continue;
802
+ if (d.worktreePath) {
803
+ try { globalLiveDirNames.add(path.basename(path.resolve(d.worktreePath))); } catch {}
804
+ }
805
+ if (!d.meta || !d.meta.branch) continue;
772
806
  const dispatchProject = typeof d.meta.project === 'string'
773
807
  ? d.meta.project
774
808
  : (d.meta.project?.name || 'default');
@@ -781,17 +815,7 @@ function pruneOrphanWorktrees(opts) {
781
815
  } catch (_e) { /* defensive */ }
782
816
  }
783
817
 
784
- // Resolved managed-spawn cwds (per-cwd → resolved abs path). Used to
785
- // reject GC of any dir that contains (or equals) a live service's cwd.
786
- // (PR #2627 review — Issue 1)
787
- const managedSpawnCwds = [];
788
- try {
789
- for (const rec of (_listManagedSpecs() || [])) {
790
- if (!rec || typeof rec.cwd !== 'string' || rec.cwd.length === 0) continue;
791
- try { managedSpawnCwds.push(path.resolve(rec.cwd)); }
792
- catch (_e) { /* malformed cwd — skip */ }
793
- }
794
- } catch (_e) { /* optional */ }
818
+ const anchoredCwds = _collectAnchoredCwds(_listManagedSpecs, _listKeepProcessCwds);
795
819
 
796
820
  const result = { scanned: 0, kept: 0, evicted: 0, failed: 0, perProject: {} };
797
821
 
@@ -836,18 +860,10 @@ function pruneOrphanWorktrees(opts) {
836
860
  projStats.kept++; result.kept++;
837
861
  continue;
838
862
  }
839
- // 3. managed_spawn cwd anchor protection.
840
- if (managedSpawnCwds.length > 0) {
841
- const wtPathNorm = path.resolve(wtPath);
842
- const wtPrefix = wtPathNorm + path.sep;
843
- let anchored = false;
844
- for (const cwd of managedSpawnCwds) {
845
- if (cwd === wtPathNorm || cwd.startsWith(wtPrefix)) { anchored = true; break; }
846
- }
847
- if (anchored) {
848
- projStats.kept++; result.kept++;
849
- continue;
850
- }
863
+ // 3. managed_spawn / keep_processes cwd anchor protection.
864
+ if (_isWorktreeCwdAnchored(wtPath, anchoredCwds)) {
865
+ projStats.kept++; result.kept++;
866
+ continue;
851
867
  }
852
868
 
853
869
  // P-c7e2b405 — route the slow-retry gate, ownership-marker gate (now
@@ -938,6 +954,12 @@ function pruneOrphanWorktreesFromGitRegistry(opts) {
938
954
  try { return _managedSpawn().listManagedSpecs(); }
939
955
  catch (_e) { return []; }
940
956
  });
957
+ const _listKeepProcessCwds = typeof opts.listKeepProcessCwds === 'function'
958
+ ? opts.listKeepProcessCwds
959
+ : (() => {
960
+ try { return _keepProcessSweep().getActiveKeepProcessCwds(); }
961
+ catch (_e) { return []; }
962
+ });
941
963
  const _execSilent = typeof opts.execSilent === 'function'
942
964
  ? opts.execSilent
943
965
  : shared.execSilent;
@@ -986,15 +1008,7 @@ function pruneOrphanWorktreesFromGitRegistry(opts) {
986
1008
  for (const p of opts.extraPoolPaths) poolPaths.add(worktreePool._normalizePath(p));
987
1009
  }
988
1010
 
989
- // managed_spawn cwds.
990
- const managedSpawnCwds = [];
991
- try {
992
- for (const rec of (_listManagedSpecs() || [])) {
993
- if (!rec || typeof rec.cwd !== 'string' || rec.cwd.length === 0) continue;
994
- try { managedSpawnCwds.push(path.resolve(rec.cwd)); }
995
- catch (_e) { /* malformed cwd — skip */ }
996
- }
997
- } catch (_e) { /* optional */ }
1011
+ const anchoredCwds = _collectAnchoredCwds(_listManagedSpecs, _listKeepProcessCwds);
998
1012
 
999
1013
  const result = { scanned: 0, kept: 0, evicted: 0, failed: 0, prunedRegistry: 0, perProject: {} };
1000
1014
  const _seenAbs = new Set(); // dedup across projects sharing a parent
@@ -1058,14 +1072,9 @@ function pruneOrphanWorktreesFromGitRegistry(opts) {
1058
1072
  if (poolPaths.has(normPath)) {
1059
1073
  projStats.kept++; result.kept++; continue;
1060
1074
  }
1061
- // managed_spawn anchor protection
1062
- if (managedSpawnCwds.length > 0) {
1063
- const wtPrefix = wtAbs + path.sep;
1064
- let anchored = false;
1065
- for (const cwd of managedSpawnCwds) {
1066
- if (cwd === wtAbs || cwd.startsWith(wtPrefix)) { anchored = true; break; }
1067
- }
1068
- if (anchored) { projStats.kept++; result.kept++; continue; }
1075
+ // managed_spawn / keep_processes cwd anchor protection.
1076
+ if (_isWorktreeCwdAnchored(wtAbs, anchoredCwds)) {
1077
+ projStats.kept++; result.kept++; continue;
1069
1078
  }
1070
1079
 
1071
1080
  // P-c7e2b405 — ownership-marker gate, slow-retry gate, remove, escalate,