@the-open-engine/zeroshot 6.10.0 → 6.10.2

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 (33) hide show
  1. package/cli/index.js +16 -0
  2. package/cluster-hooks/block-ask-user-question.py +2 -3
  3. package/cluster-hooks/block-dangerous-git.py +2 -3
  4. package/lib/agent-cli-provider/adapters/claude.d.ts.map +1 -1
  5. package/lib/agent-cli-provider/adapters/claude.js +42 -1
  6. package/lib/agent-cli-provider/adapters/claude.js.map +1 -1
  7. package/lib/agent-cli-provider/contract-options.d.ts.map +1 -1
  8. package/lib/agent-cli-provider/contract-options.js +2 -0
  9. package/lib/agent-cli-provider/contract-options.js.map +1 -1
  10. package/lib/agent-cli-provider/types.d.ts +6 -2
  11. package/lib/agent-cli-provider/types.d.ts.map +1 -1
  12. package/lib/agent-cli-provider/types.js.map +1 -1
  13. package/package.json +1 -1
  14. package/src/agent/agent-lifecycle.js +13 -6
  15. package/src/agent/agent-task-executor.js +527 -313
  16. package/src/agent-cli-provider/adapters/claude.ts +46 -1
  17. package/src/agent-cli-provider/contract-options.ts +6 -0
  18. package/src/agent-cli-provider/types.ts +9 -6
  19. package/src/claude-task-runner.js +168 -44
  20. package/src/darwin-keychain-boundary.js +174 -0
  21. package/src/task-spawn-cleanup-ownership.js +82 -0
  22. package/src/worktree-claude-config.js +193 -85
  23. package/src/worktree-tooling-env.js +3 -0
  24. package/task-lib/attachable-watcher.js +93 -267
  25. package/task-lib/command-spec-cleanup.js +219 -0
  26. package/task-lib/commands/clean.js +35 -5
  27. package/task-lib/commands/get-task-id-by-spawn-token.js +10 -0
  28. package/task-lib/commands/kill.js +117 -4
  29. package/task-lib/commands/status.js +1 -0
  30. package/task-lib/runner.js +61 -4
  31. package/task-lib/store.js +68 -6
  32. package/task-lib/watcher-output-runtime.js +284 -0
  33. package/task-lib/watcher.js +124 -257
@@ -15,12 +15,18 @@ const path = require('path');
15
15
  const fs = require('fs');
16
16
  const os = require('os');
17
17
  const { parseProviderChunk, getProvider } = require('../providers');
18
- const { getTask } = require('../../task-lib/store.js');
18
+ const { getTask, getTaskBySpawnOwnershipToken } = require('../../task-lib/store.js');
19
19
  const { loadSettings } = require('../../lib/settings.js');
20
20
  const { resolveClaudeAuth } = require('../../lib/settings/claude-auth.js');
21
21
  const { prependWorktreeToolBinToEnv } = require('../worktree-tooling-env.js');
22
+ const { applyDarwinKeychainBoundaryToEnv } = require('../darwin-keychain-boundary.js');
22
23
  const {
23
- prepareClaudeConfigDir,
24
+ CLAUDE_SETTINGS_ENV,
25
+ cleanupClaudeSettingsOverlay,
26
+ ensureAskUserQuestionHook,
27
+ ensureDangerousGitHook,
28
+ prepareClaudeSettingsOverlay,
29
+ resolveContainerMcpConfigPath,
24
30
  resolveRepoMcpConfigPath,
25
31
  } = require('../worktree-claude-config.js');
26
32
  const {
@@ -28,6 +34,14 @@ const {
28
34
  wrapTaskRunWithIsolatedSettings,
29
35
  } = require('../task-run-model-args.js');
30
36
  const { buildRawLogOnlyMetadata } = require('./context-replay-policy');
37
+ const {
38
+ TASK_SPAWN_OWNERSHIP_TOKEN_ENV,
39
+ cleanupCallerOwnedCommand,
40
+ callerOwnsCommandCleanup,
41
+ createTaskSpawnOwnershipToken,
42
+ requireTaskIdFromWrapperResult,
43
+ trackTaskWrapperCleanupOwnership,
44
+ } = require('../task-spawn-cleanup-ownership');
31
45
  const {
32
46
  providerSessionFromCompletedTask,
33
47
  resolveAgentResumeSessionId,
@@ -378,10 +392,6 @@ function extractErrorContext({ output, statusOutput, taskId, isNotFound = false,
378
392
  return sanitizeErrorMessage(`Task failed. Output: ${trimmedOutput}`);
379
393
  }
380
394
 
381
- // Track which config dirs already have zeroshot-installed hooks.
382
- const askUserQuestionHookInstalledDirs = new Set();
383
- const dangerousGitHookInstalledDirs = new Set();
384
-
385
395
  /**
386
396
  * Extract token usage from NDJSON output.
387
397
  * Looks for the 'result' event line which contains usage data.
@@ -435,162 +445,6 @@ function extractTokenUsage(output, providerName = 'claude') {
435
445
  };
436
446
  }
437
447
 
438
- /**
439
- * Ensure the AskUserQuestion blocking hook is installed in user's Claude config.
440
- * This adds defense-in-depth by blocking the tool at the Claude CLI level.
441
- * Modifies ~/.claude/settings.json and copies hook script to ~/.claude/hooks/
442
- *
443
- * Safe to call multiple times - only modifies config once per process.
444
- */
445
- function ensureAskUserQuestionHook(targetClaudeDir = null) {
446
- const userClaudeDir =
447
- targetClaudeDir || process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
448
- if (askUserQuestionHookInstalledDirs.has(userClaudeDir)) {
449
- return;
450
- }
451
- const hooksDir = path.join(userClaudeDir, 'hooks');
452
- const settingsPath = path.join(userClaudeDir, 'settings.json');
453
- const hookScriptName = 'block-ask-user-question.py';
454
- const hookScriptDst = path.join(hooksDir, hookScriptName);
455
-
456
- // Ensure hooks directory exists
457
- if (!fs.existsSync(hooksDir)) {
458
- fs.mkdirSync(hooksDir, { recursive: true });
459
- }
460
-
461
- // Copy hook script if not present or outdated
462
- const hookScriptSrc = path.join(__dirname, '..', '..', 'cluster-hooks', hookScriptName);
463
- if (fs.existsSync(hookScriptSrc)) {
464
- // Always copy to ensure latest version
465
- fs.copyFileSync(hookScriptSrc, hookScriptDst);
466
- fs.chmodSync(hookScriptDst, 0o755);
467
- }
468
-
469
- // Read existing settings or create new
470
- let settings = {};
471
- if (fs.existsSync(settingsPath)) {
472
- try {
473
- settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
474
- } catch (e) {
475
- console.warn(`[AgentTaskExecutor] Could not parse settings.json, creating new: ${e.message}`);
476
- settings = {};
477
- }
478
- }
479
-
480
- // Ensure hooks structure exists
481
- if (!settings.hooks) {
482
- settings.hooks = {};
483
- }
484
- if (!settings.hooks.PreToolUse) {
485
- settings.hooks.PreToolUse = [];
486
- }
487
-
488
- // Check if AskUserQuestion hook already exists
489
- const hasHook = settings.hooks.PreToolUse.some(
490
- (entry) =>
491
- entry.matcher === 'AskUserQuestion' ||
492
- (entry.hooks && entry.hooks.some((h) => h.command && h.command.includes(hookScriptName)))
493
- );
494
-
495
- if (!hasHook) {
496
- // Add the hook
497
- settings.hooks.PreToolUse.push({
498
- matcher: 'AskUserQuestion',
499
- hooks: [
500
- {
501
- type: 'command',
502
- command: hookScriptDst,
503
- },
504
- ],
505
- });
506
-
507
- // Write updated settings
508
- fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
509
- console.log(`[AgentTaskExecutor] Installed AskUserQuestion blocking hook in ${settingsPath}`);
510
- }
511
-
512
- askUserQuestionHookInstalledDirs.add(userClaudeDir);
513
- }
514
-
515
- /**
516
- * Ensure the dangerous git blocking hook is installed in user's Claude config.
517
- * This blocks dangerous git commands like stash, checkout --, reset --hard, etc.
518
- * Modifies ~/.claude/settings.json and copies hook script to ~/.claude/hooks/
519
- *
520
- * Only used in worktree mode - Docker isolation mode has its own git-safe.sh wrapper.
521
- * Safe to call multiple times - only modifies config once per process.
522
- */
523
- function ensureDangerousGitHook(targetClaudeDir = null) {
524
- const userClaudeDir =
525
- targetClaudeDir || process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
526
- if (dangerousGitHookInstalledDirs.has(userClaudeDir)) {
527
- return;
528
- }
529
- const hooksDir = path.join(userClaudeDir, 'hooks');
530
- const settingsPath = path.join(userClaudeDir, 'settings.json');
531
- const hookScriptName = 'block-dangerous-git.py';
532
- const hookScriptDst = path.join(hooksDir, hookScriptName);
533
-
534
- // Ensure hooks directory exists
535
- if (!fs.existsSync(hooksDir)) {
536
- fs.mkdirSync(hooksDir, { recursive: true });
537
- }
538
-
539
- // Copy hook script if not present or outdated
540
- const hookScriptSrc = path.join(__dirname, '..', '..', 'cluster-hooks', hookScriptName);
541
- if (fs.existsSync(hookScriptSrc)) {
542
- // Always copy to ensure latest version
543
- fs.copyFileSync(hookScriptSrc, hookScriptDst);
544
- fs.chmodSync(hookScriptDst, 0o755);
545
- }
546
-
547
- // Read existing settings or create new
548
- let settings = {};
549
- if (fs.existsSync(settingsPath)) {
550
- try {
551
- settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
552
- } catch (e) {
553
- console.warn(`[AgentTaskExecutor] Could not parse settings.json, creating new: ${e.message}`);
554
- settings = {};
555
- }
556
- }
557
-
558
- // Ensure hooks structure exists
559
- if (!settings.hooks) {
560
- settings.hooks = {};
561
- }
562
- if (!settings.hooks.PreToolUse) {
563
- settings.hooks.PreToolUse = [];
564
- }
565
-
566
- // Check if dangerous git hook already exists
567
- const hasHook = settings.hooks.PreToolUse.some(
568
- (entry) =>
569
- entry.matcher === 'Bash' &&
570
- entry.hooks &&
571
- entry.hooks.some((h) => h.command && h.command.includes(hookScriptName))
572
- );
573
-
574
- if (!hasHook) {
575
- // Add the hook - matches Bash tool to check for dangerous git commands
576
- settings.hooks.PreToolUse.push({
577
- matcher: 'Bash',
578
- hooks: [
579
- {
580
- type: 'command',
581
- command: hookScriptDst,
582
- },
583
- ],
584
- });
585
-
586
- // Write updated settings
587
- fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
588
- console.log(`[AgentTaskExecutor] Installed dangerous git blocking hook in ${settingsPath}`);
589
- }
590
-
591
- dangerousGitHookInstalledDirs.add(userClaudeDir);
592
- }
593
-
594
448
  /**
595
449
  * Spawn claude-zeroshots process and stream output via message bus
596
450
  * @param {Object} agent - Agent instance
@@ -650,37 +504,43 @@ async function spawnClaudeTask(agent, context) {
650
504
  return spawnClaudeTaskIsolated(agent, context);
651
505
  }
652
506
 
653
- // NON-ISOLATION MODE: For Claude, use user's existing Claude config
507
+ // NON-ISOLATION MODE: Load safety hooks through an additional per-run settings file. Claude
508
+ // still reads the user's normal config and the repository's project/local config.
654
509
  // AskUserQuestion blocking handled via:
655
510
  // 1. Prompt injection (see agent-context-builder)
656
511
  // 2. PreToolUse hook (defense-in-depth) - activated by ZEROSHOT_BLOCK_ASK_USER env var
657
- const claudeConfigDir =
512
+ const claudeSettingsPath =
658
513
  providerName === 'claude'
659
- ? prepareClaudeConfigDir({
660
- cwd,
661
- worktreePath: agent.worktree?.path || null,
514
+ ? prepareClaudeSettingsOverlay({
515
+ includeDangerousGit: Boolean(agent.worktree?.enabled),
662
516
  })
663
517
  : null;
664
518
 
665
- ensureProviderHooks(agent, providerName, claudeConfigDir);
666
- const spawnEnv = buildSpawnEnv(agent, providerName, modelSpec, { claudeConfigDir });
667
- const taskId = await spawnTaskProcess({
668
- agent,
669
- ctPath,
670
- args,
671
- cwd,
672
- spawnEnv,
673
- });
519
+ let taskId;
520
+ let pendingLaunch;
521
+ try {
522
+ const spawnEnv = buildSpawnEnv(agent, providerName, modelSpec, { claudeSettingsPath });
523
+ taskId = await spawnTaskProcess({
524
+ agent,
525
+ ctPath,
526
+ args,
527
+ cwd,
528
+ spawnEnv,
529
+ });
530
+ pendingLaunch = agent.currentTask;
531
+ } catch (error) {
532
+ cleanupCallerOwnedCommand(error, () => cleanupClaudeSettingsOverlay(claudeSettingsPath));
533
+ throw error;
534
+ }
674
535
 
536
+ // The task ID transfers provider and cleanup ownership to the detached
537
+ // watcher. From this point, follower/PID observation failures must not remove
538
+ // files that a live provider still reads.
675
539
  agent._log(`📋 Agent ${agent.id}: Following zeroshot logs for ${taskId}`);
676
-
677
- // Wait for task to be registered in zeroshot storage (race condition fix)
678
540
  await waitForTaskReady(agent, taskId);
541
+ if (pendingLaunch?.cancelled) throw new Error(`Task launch cancelled: ${taskId}`);
679
542
 
680
- // CRITICAL: Poll for REAL process PID from task store
681
- // The watcher spawns the actual CLI and writes PID to SQLite asynchronously.
682
- // We must poll because the watcher runs in a forked process.
683
- const MAX_PID_POLLS = 30; // 3 seconds max
543
+ const MAX_PID_POLLS = 30;
684
544
  const PID_POLL_DELAY = 100;
685
545
  let realPid = null;
686
546
  let terminalBeforePidObservation = false;
@@ -698,6 +558,8 @@ async function spawnClaudeTask(agent, context) {
698
558
  await new Promise((r) => setTimeout(r, PID_POLL_DELAY));
699
559
  }
700
560
 
561
+ if (pendingLaunch?.cancelled) throw new Error(`Task launch cancelled: ${taskId}`);
562
+
701
563
  if (realPid) {
702
564
  agent.processPid = realPid;
703
565
  agent._publishLifecycle('PROCESS_SPAWNED', { pid: realPid });
@@ -708,7 +570,6 @@ async function spawnClaudeTask(agent, context) {
708
570
  agent._log(`⚠️ Agent ${agent.id}: PID not available (task may use non-standard watcher)`);
709
571
  }
710
572
 
711
- // Now follow the logs and stream output
712
573
  return followClaudeTaskLogs(agent, taskId);
713
574
  }
714
575
 
@@ -749,9 +610,9 @@ function buildTaskRunArgs({ agent, providerName, modelSpec, runOutputFormat }) {
749
610
  args.push('--json-schema', schema);
750
611
  }
751
612
 
752
- // MCP servers: providers whose CLI accepts an MCP config flag (e.g. Copilot's
753
- // --additional-mcp-config) cannot use the Claude config-dir overlay, so forward the repo's
754
- // `.mcp.json` (the same MCP source Claude consumes) inline via `--mcp-config`.
613
+ // MCP servers: explicitly forward the repo config through the task CLI.
614
+ // Claude receives the path; providers such as Copilot receive inlined JSON
615
+ // so it survives container path translation.
755
616
  for (const mcpArg of resolveMcpConfigArgs(agent, providerName)) {
756
617
  args.push(mcpArg);
757
618
  }
@@ -767,20 +628,28 @@ function buildTaskRunArgs({ agent, providerName, modelSpec, runOutputFormat }) {
767
628
  /**
768
629
  * Build the `--mcp-config` args for a task-run invocation, or [] when they don't apply.
769
630
  *
770
- * Only providers whose adapter models an MCP config CLI flag receive it — Claude consumes MCP via
771
- * the config-dir `.mcp.json` overlay (see prepareClaudeConfigDir) and needs no flag. The repo
772
- * `.mcp.json` content is inlined (not passed as an @<path> reference) so the identical value works
773
- * under local, worktree, and Docker isolation without host/container path translation.
631
+ * Claude receives the config path because its CLI supports `--mcp-config`.
632
+ * Other providers whose adapter models an MCP config flag receive inlined
633
+ * content so the identical value works under Docker isolation.
774
634
  */
775
635
  function resolveMcpConfigArgs(agent, providerName) {
776
- if (!providerModelsMcpConfigFlag(providerName)) return [];
777
-
778
636
  const mcpPath = resolveRepoMcpConfigPath({
779
637
  cwd: agent.config?.cwd || process.cwd(),
780
638
  worktreePath: agent.worktree?.path || null,
781
639
  });
782
640
  if (!mcpPath) return [];
783
641
 
642
+ if (providerName === 'claude') {
643
+ const forwardedPath = agent.isolation?.enabled
644
+ ? resolveContainerMcpConfigPath({
645
+ cwd: agent.config?.cwd || process.cwd(),
646
+ worktreePath: agent.worktree?.path || null,
647
+ })
648
+ : mcpPath;
649
+ return forwardedPath ? ['--mcp-config', forwardedPath] : [];
650
+ }
651
+ if (!providerModelsMcpConfigFlag(providerName)) return [];
652
+
784
653
  const content = fs.readFileSync(mcpPath, 'utf8').trim();
785
654
  if (content.length === 0) return [];
786
655
 
@@ -820,21 +689,11 @@ function buildFinalContext({ agent, context, desiredOutputFormat, runOutputForma
820
689
  return context;
821
690
  }
822
691
 
823
- function ensureProviderHooks(agent, providerName, claudeConfigDir = null) {
824
- if (providerName !== 'claude') {
825
- return;
826
- }
827
-
828
- ensureAskUserQuestionHook(claudeConfigDir);
829
-
830
- // WORKTREE MODE: Install git safety hook (blocks dangerous git commands)
831
- if (agent.worktree?.enabled) {
832
- ensureDangerousGitHook(claudeConfigDir);
833
- }
834
- }
835
-
836
692
  function buildSpawnEnv(agent, providerName, modelSpec, options = {}) {
837
- const { claudeConfigDir = null } = options;
693
+ const {
694
+ claudeSettingsPath = null,
695
+ applyDarwinKeychainBoundary = applyDarwinKeychainBoundaryToEnv,
696
+ } = options;
838
697
  const spawnEnv = { ...process.env };
839
698
  const agentCwd = agent.config?.cwd || agent.worktree?.path || process.cwd();
840
699
  const clusterId = agent.cluster?.id || agent.cluster_id || process.env.ZEROSHOT_CLUSTER_ID;
@@ -859,8 +718,8 @@ function buildSpawnEnv(agent, providerName, modelSpec, options = {}) {
859
718
 
860
719
  if (providerName === 'claude') {
861
720
  Object.assign(spawnEnv, buildClaudeEnv(modelSpec));
862
- if (claudeConfigDir) {
863
- spawnEnv.CLAUDE_CONFIG_DIR = claudeConfigDir;
721
+ if (claudeSettingsPath) {
722
+ spawnEnv[CLAUDE_SETTINGS_ENV] = claudeSettingsPath;
864
723
  }
865
724
 
866
725
  // WORKTREE MODE: Activate git safety hook via environment variable
@@ -869,6 +728,13 @@ function buildSpawnEnv(agent, providerName, modelSpec, options = {}) {
869
728
  }
870
729
  }
871
730
 
731
+ // KEYCHAIN BOUNDARY (darwin only): non-interactive local/worktree worker
732
+ // descendants must not reach the user's GUI Keychain session (issue #704).
733
+ // Docker isolation never reaches buildSpawnEnv (see spawnClaudeTaskIsolated).
734
+ // Applied before the worktree tool bins so repo-managed tool substitutes
735
+ // stay first on PATH.
736
+ applyDarwinKeychainBoundary(spawnEnv);
737
+
872
738
  prependWorktreeToolBinToEnv(spawnEnv, {
873
739
  cwd: agentCwd,
874
740
  worktreePath: agent.worktree?.path || null,
@@ -882,21 +748,129 @@ function parseTaskIdFromOutput(stdout) {
882
748
  return match ? match[1] : null;
883
749
  }
884
750
 
885
- function spawnTaskProcess({ agent, ctPath, args, cwd, spawnEnv }) {
886
- // Timeout for spawn phase - if CLI hangs during init (e.g., opencode 429 bug), kill it
887
- const SPAWN_TIMEOUT_MS = 30000; // 30 seconds to spawn task
751
+ const TASK_TERMINAL_STATUSES = new Set(['completed', 'failed', 'killed', 'stale']);
752
+
753
+ function assignDurableTaskId(agent, taskId) {
754
+ if (!taskId || agent.currentTaskId === taskId) return;
755
+ agent.currentTaskId = taskId;
756
+ agent._publishLifecycle('TASK_ID_ASSIGNED', {
757
+ pid: agent.processPid,
758
+ taskId,
759
+ });
760
+ }
888
761
 
762
+ function createPendingTaskLaunchHandle({
763
+ agent,
764
+ proc,
765
+ ctPath,
766
+ findPersistedTaskId,
767
+ waitForWrapperClose,
768
+ isWrapperClosed,
769
+ }) {
770
+ let cancellation = null;
771
+ const handle = {
772
+ pendingLaunch: true,
773
+ cancelled: false,
774
+ kill(reason = 'Task killed') {
775
+ handle.cancelled = true;
776
+ if (cancellation) return cancellation;
777
+ const cancellationAttempt = (async () => {
778
+ let taskId = findPersistedTaskId();
779
+ let commandError = null;
780
+ let commandAttempted = false;
781
+ const cancelTask = async (persistedTaskId) => {
782
+ commandAttempted = true;
783
+ assignDurableTaskId(agent, persistedTaskId);
784
+ try {
785
+ await runCommandWithTimeout(ctPath, ['kill', persistedTaskId], { timeout: 10000 });
786
+ } catch (error) {
787
+ commandError = error;
788
+ }
789
+ };
790
+ if (taskId) await cancelTask(taskId);
791
+
792
+ if (!isWrapperClosed()) {
793
+ proc.kill('SIGKILL');
794
+ await waitForWrapperClose;
795
+ }
796
+
797
+ taskId = taskId || findPersistedTaskId();
798
+ if (taskId && !commandAttempted) await cancelTask(taskId);
799
+ if (taskId && commandError === null) {
800
+ const task = getTask(taskId);
801
+ if (!task || !TASK_TERMINAL_STATUSES.has(task.status) || task.commandCleanup) {
802
+ commandError = new Error(
803
+ `Task ${taskId} termination and command cleanup were not confirmed`
804
+ );
805
+ }
806
+ } else if (taskId) {
807
+ const task = getTask(taskId);
808
+ if (task && TASK_TERMINAL_STATUSES.has(task.status) && !task.commandCleanup) {
809
+ commandError = null;
810
+ }
811
+ }
812
+
813
+ if (commandError) {
814
+ return { forced: false, reason: commandError.message };
815
+ }
816
+ agent._log?.(`Cancelled pending task launch${taskId ? ` ${taskId}` : ''}: ${reason}`);
817
+ return { forced: true, taskId: taskId || null };
818
+ })();
819
+ cancellation = cancellationAttempt;
820
+ cancellationAttempt.then(
821
+ (termination) => {
822
+ if (termination?.forced === false && cancellation === cancellationAttempt) {
823
+ cancellation = null;
824
+ }
825
+ },
826
+ () => {
827
+ if (cancellation === cancellationAttempt) cancellation = null;
828
+ }
829
+ );
830
+ return cancellationAttempt;
831
+ },
832
+ };
833
+ return handle;
834
+ }
835
+
836
+ function spawnTaskProcess({ agent, ctPath, args, cwd, spawnEnv, spawnTimeoutMs = 30000 }) {
837
+ // Timeout for spawn phase - if CLI hangs during init (e.g., opencode 429 bug), kill it.
838
+ const SPAWN_TIMEOUT_MS = spawnTimeoutMs;
889
839
  // spawn() throws on null bytes in argv; strip them before they get there.
890
840
  const safeArgs = args.map((arg) => (typeof arg === 'string' ? arg.replace(/\0/g, '') : arg));
841
+ const ownershipToken = createTaskSpawnOwnershipToken();
842
+ const findPersistedTaskId = () => getTaskBySpawnOwnershipToken(ownershipToken)?.id || null;
891
843
 
892
844
  return new Promise((resolve, reject) => {
893
845
  const proc = spawn(ctPath, safeArgs, {
894
846
  cwd,
895
847
  stdio: ['ignore', 'pipe', 'pipe'],
896
- env: spawnEnv,
848
+ env: { ...spawnEnv, [TASK_SPAWN_OWNERSHIP_TOKEN_ENV]: ownershipToken },
897
849
  windowsHide: true,
898
850
  });
899
851
 
852
+ let wrapperClosed = false;
853
+ let resolveWrapperClose;
854
+ const waitForWrapperClose = new Promise((resolveClose) => {
855
+ resolveWrapperClose = resolveClose;
856
+ });
857
+ const markWrapperClosed = () => {
858
+ if (wrapperClosed) return;
859
+ wrapperClosed = true;
860
+ resolveWrapperClose();
861
+ };
862
+ proc.once('close', markWrapperClosed);
863
+ proc.once('error', markWrapperClosed);
864
+ const pendingLaunch = createPendingTaskLaunchHandle({
865
+ agent,
866
+ proc,
867
+ ctPath,
868
+ findPersistedTaskId,
869
+ waitForWrapperClose,
870
+ isWrapperClosed: () => wrapperClosed,
871
+ });
872
+ agent.currentTask = pendingLaunch;
873
+
900
874
  // NOTE: Don't emit PROCESS_SPAWNED here - proc.pid is a wrapper that exits immediately.
901
875
  // Real PID comes from task store after watcher spawns the actual CLI process.
902
876
  // PROCESS_SPAWNED is emitted in spawnClaudeTask after waitForTaskReady + PID polling.
@@ -904,18 +878,46 @@ function spawnTaskProcess({ agent, ctPath, args, cwd, spawnEnv }) {
904
878
  let stdout = '';
905
879
  let stderr = '';
906
880
  let resolved = false;
881
+ let timeoutError = null;
882
+ const classifyCleanupOwnership = trackTaskWrapperCleanupOwnership(findPersistedTaskId);
883
+ const rejectWithOwnership = async (error) => {
884
+ const classifiedError = classifyCleanupOwnership(error);
885
+ if (!callerOwnsCommandCleanup(classifiedError) && agent.currentTask === pendingLaunch) {
886
+ let termination;
887
+ try {
888
+ termination = await pendingLaunch.kill(classifiedError.message);
889
+ } catch (cleanupError) {
890
+ termination = { forced: false, reason: cleanupError.message };
891
+ }
892
+ if (termination?.forced === false) {
893
+ classifiedError.message += ` Task cleanup was not confirmed: ${termination.reason}`;
894
+ classifiedError.retainTaskHandle = true;
895
+ classifiedError.permanent = true;
896
+ classifiedError.restartExhausted = true;
897
+ classifiedError.terminationExhausted = true;
898
+ classifiedError.terminationAttempts = 1;
899
+ classifiedError.taskId = agent.currentTaskId || null;
900
+ } else if (agent.currentTask === pendingLaunch) {
901
+ agent.currentTask = null;
902
+ agent.currentTaskId = null;
903
+ agent.processPid = null;
904
+ agent.lastOutputTime = null;
905
+ agent.taskStartedAt = null;
906
+ }
907
+ } else if (wrapperClosed && agent.currentTask === pendingLaunch) {
908
+ agent.currentTask = null;
909
+ }
910
+ reject(classifiedError);
911
+ };
907
912
 
908
913
  // CRITICAL: Timeout to prevent infinite hang if provider CLI hangs
909
914
  const spawnTimeout = setTimeout(() => {
910
915
  if (resolved) return;
911
- resolved = true;
912
- proc.kill('SIGKILL');
913
- reject(
914
- new Error(
915
- `Spawn timeout after ${SPAWN_TIMEOUT_MS / 1000}s - provider CLI hung. ` +
916
- `stdout: ${stdout.slice(-500)}, stderr: ${stderr.slice(-500)}`
917
- )
916
+ timeoutError = new Error(
917
+ `Spawn timeout after ${SPAWN_TIMEOUT_MS / 1000}s - provider CLI hung. ` +
918
+ `stdout: ${stdout.slice(-500)}, stderr: ${stderr.slice(-500)}`
918
919
  );
920
+ proc.kill('SIGKILL');
919
921
  }, SPAWN_TIMEOUT_MS);
920
922
 
921
923
  proc.stdout.on('data', (data) => {
@@ -926,48 +928,53 @@ function spawnTaskProcess({ agent, ctPath, args, cwd, spawnEnv }) {
926
928
  stderr += data.toString();
927
929
  });
928
930
 
929
- proc.on('close', (code, signal) => {
931
+ proc.on('close', async (code, signal) => {
930
932
  clearTimeout(spawnTimeout);
931
933
  if (resolved) return;
932
934
  resolved = true;
935
+ if (timeoutError) {
936
+ await rejectWithOwnership(timeoutError);
937
+ return;
938
+ }
933
939
  // Handle process killed by signal (e.g., SIGTERM, SIGKILL, SIGSTOP)
934
940
  if (signal) {
935
- reject(new Error(`Process killed by signal ${signal}${stderr ? `: ${stderr}` : ''}`));
941
+ await rejectWithOwnership(
942
+ new Error(`Process killed by signal ${signal}${stderr ? `: ${stderr}` : ''}`)
943
+ );
936
944
  return;
937
945
  }
938
946
 
939
- if (code === 0) {
940
- // Parse task ID from output: "✓ Task spawned: xxx-yyy-nn"
941
- // Format: <adjective>-<noun>-<digits> (may or may not have task- prefix)
942
- const spawnedTaskId = parseTaskIdFromOutput(stdout);
943
- if (spawnedTaskId) {
944
- agent.currentTaskId = spawnedTaskId; // Track for resume capability
945
- agent._publishLifecycle('TASK_ID_ASSIGNED', {
946
- pid: agent.processPid,
947
- taskId: spawnedTaskId,
948
- });
947
+ let spawnedTaskId;
948
+ try {
949
+ spawnedTaskId = requireTaskIdFromWrapperResult({
950
+ code,
951
+ stdout,
952
+ stderr,
953
+ parseTaskId: parseTaskIdFromOutput,
954
+ persistedTaskId: findPersistedTaskId(),
955
+ });
956
+ } catch (error) {
957
+ await rejectWithOwnership(error);
958
+ return;
959
+ }
949
960
 
950
- // Start liveness monitoring
951
- if (agent.enableLivenessCheck) {
952
- agent.taskStartedAt = Date.now();
953
- agent.lastOutputTime = agent.taskStartedAt;
954
- agent._startLivenessCheck();
955
- }
961
+ assignDurableTaskId(agent, spawnedTaskId);
956
962
 
957
- resolve(spawnedTaskId);
958
- } else {
959
- reject(new Error(`Could not parse task ID from output: ${stdout}`));
960
- }
961
- } else {
962
- reject(new Error(`zeroshot task run failed with code ${code}: ${stderr}`));
963
+ // Start liveness monitoring
964
+ if (agent.enableLivenessCheck) {
965
+ agent.taskStartedAt = Date.now();
966
+ agent.lastOutputTime = agent.taskStartedAt;
967
+ agent._startLivenessCheck();
963
968
  }
969
+
970
+ resolve(spawnedTaskId);
964
971
  });
965
972
 
966
973
  proc.on('error', (error) => {
967
974
  clearTimeout(spawnTimeout);
968
975
  if (resolved) return;
969
976
  resolved = true;
970
- reject(error);
977
+ rejectWithOwnership(error);
971
978
  });
972
979
  });
973
980
  }
@@ -1371,10 +1378,26 @@ function handleStatusExecError({ agent, state, ctPath, taskId, error, stderr, re
1371
1378
  return true;
1372
1379
  }
1373
1380
 
1381
+ function hasPendingCommandCleanup(statusOutput) {
1382
+ return /Cleanup:\s+pending/i.test(stripAnsiCodes(statusOutput));
1383
+ }
1384
+
1385
+ function retryHostTerminalCleanup({ agent, taskId, state, ctPath }) {
1386
+ if (state.commandCleanupRecoveryPending) return;
1387
+ state.commandCleanupRecoveryPending = true;
1388
+ runCommandWithTimeout(ctPath, ['kill', taskId], { timeout: 10000 }, (error) => {
1389
+ state.commandCleanupRecoveryPending = false;
1390
+ if (error) {
1391
+ agent._log(`[${agent.id}] Terminal command cleanup recovery will retry: ${error.message}`);
1392
+ }
1393
+ });
1394
+ }
1395
+
1374
1396
  function handleStatusCompletion({
1375
1397
  agent,
1376
1398
  taskId,
1377
1399
  providerName,
1400
+ ctPath,
1378
1401
  state,
1379
1402
  stdout,
1380
1403
  pollLogFile,
@@ -1387,6 +1410,11 @@ function handleStatusCompletion({
1387
1410
  return false;
1388
1411
  }
1389
1412
 
1413
+ if (hasPendingCommandCleanup(cleanStdout)) {
1414
+ retryHostTerminalCleanup({ agent, taskId, state, ctPath });
1415
+ return true;
1416
+ }
1417
+
1390
1418
  pollLogFile();
1391
1419
 
1392
1420
  let success = isCompleted;
@@ -1481,6 +1509,7 @@ function createLogFollower({ agent, taskId, fsModule, ctPath, providerName }) {
1481
1509
  state.consecutiveExecFailures = 0;
1482
1510
  handleStatusCompletion({
1483
1511
  agent,
1512
+ ctPath,
1484
1513
  taskId,
1485
1514
  providerName,
1486
1515
  state,
@@ -1505,7 +1534,7 @@ function createLogFollower({ agent, taskId, fsModule, ctPath, providerName }) {
1505
1534
  */
1506
1535
  function followClaudeTaskLogs(agent, taskId) {
1507
1536
  const fsModule = require('fs');
1508
- const ctPath = getClaudeTasksPath();
1537
+ const ctPath = agent.taskCliPath || getClaudeTasksPath();
1509
1538
  const providerName = agent._resolveProvider ? agent._resolveProvider() : 'claude';
1510
1539
 
1511
1540
  return createLogFollower({ agent, taskId, fsModule, ctPath, providerName });
@@ -1586,35 +1615,145 @@ async function spawnClaudeTaskIsolated(agent, context) {
1586
1615
 
1587
1616
  // STEP 1: Spawn task and extract task ID (same as non-isolated mode)
1588
1617
  // Timeout for spawn phase - if CLI hangs during init (e.g., opencode 429 bug), kill it
1589
- const SPAWN_TIMEOUT_MS = 30000; // 30 seconds to spawn task
1590
- // Note: Auth env vars are injected by IsolationManager, we only need model mapping here
1591
- const isolatedEnv =
1592
- providerName === 'claude' ? buildClaudeEnv(modelSpec, { includeAuth: false }) : {};
1618
+ const SPAWN_TIMEOUT_MS = agent.spawnTimeoutMs ?? 30000;
1619
+ const ownershipToken = createTaskSpawnOwnershipToken();
1620
+ // Auth env vars are injected by IsolationManager; the launch token is the
1621
+ // only authoritative bridge back to the detached task row in the container.
1622
+ const isolatedEnv = {
1623
+ ...(providerName === 'claude' ? buildClaudeEnv(modelSpec, { includeAuth: false }) : {}),
1624
+ [TASK_SPAWN_OWNERSHIP_TOKEN_ENV]: ownershipToken,
1625
+ };
1593
1626
 
1627
+ let isolatedPendingLaunch = null;
1594
1628
  const taskId = await new Promise((resolve, reject) => {
1595
1629
  const proc = manager.spawnInContainer(clusterId, command, {
1596
1630
  env: isolatedEnv,
1597
1631
  });
1598
1632
 
1599
- // Track PID for resource monitoring
1600
- agent.processPid = proc.pid;
1601
- agent._publishLifecycle('PROCESS_SPAWNED', { pid: proc.pid });
1602
-
1633
+ let isolatedTaskId = null;
1634
+ let wrapperClosed = false;
1635
+ let resolveWrapperClose;
1603
1636
  let stdout = '';
1604
1637
  let stderr = '';
1605
1638
  let resolved = false;
1606
-
1607
- // CRITICAL: Timeout to prevent infinite hang if provider CLI hangs
1608
- const spawnTimeout = setTimeout(() => {
1639
+ let timeoutError = null;
1640
+ let spawnTimeout = null;
1641
+ let cancellation = null;
1642
+ const waitForWrapperClose = new Promise((resolveClose) => {
1643
+ resolveWrapperClose = resolveClose;
1644
+ });
1645
+ const markWrapperClosed = () => {
1646
+ if (!wrapperClosed) {
1647
+ wrapperClosed = true;
1648
+ resolveWrapperClose();
1649
+ }
1650
+ };
1651
+ const findPersistedTaskId = async () => {
1652
+ const persistedTaskId = await resolveIsolatedTaskIdBySpawnToken(
1653
+ manager,
1654
+ clusterId,
1655
+ ownershipToken
1656
+ );
1657
+ if (persistedTaskId) {
1658
+ isolatedTaskId = persistedTaskId;
1659
+ assignDurableTaskId(agent, persistedTaskId);
1660
+ }
1661
+ return persistedTaskId;
1662
+ };
1663
+ const rejectLaunch = (error, { retainHandle = false } = {}) => {
1609
1664
  if (resolved) return;
1610
1665
  resolved = true;
1611
- proc.kill('SIGKILL');
1612
- reject(
1613
- new Error(
1614
- `Spawn timeout after ${SPAWN_TIMEOUT_MS / 1000}s - provider CLI hung. ` +
1615
- `stdout: ${stdout.slice(-500)}, stderr: ${stderr.slice(-500)}`
1616
- )
1666
+ clearTimeout(spawnTimeout);
1667
+ const rejection = error instanceof Error ? error : new Error(String(error));
1668
+ if (retainHandle) {
1669
+ rejection.retainTaskHandle = true;
1670
+ rejection.permanent = true;
1671
+ rejection.restartExhausted = true;
1672
+ rejection.terminationExhausted = true;
1673
+ rejection.terminationAttempts = 1;
1674
+ rejection.taskId = isolatedTaskId || agent.currentTaskId || null;
1675
+ } else if (agent.currentTask === isolatedPendingLaunch) {
1676
+ agent.currentTask = null;
1677
+ }
1678
+ reject(rejection);
1679
+ };
1680
+
1681
+ proc.once('close', markWrapperClosed);
1682
+ proc.once('error', markWrapperClosed);
1683
+ isolatedPendingLaunch = {
1684
+ pendingLaunch: true,
1685
+ cancelled: false,
1686
+ async kill(reason = 'Task killed') {
1687
+ isolatedPendingLaunch.cancelled = true;
1688
+ if (cancellation) return cancellation;
1689
+ cancellation = (async () => {
1690
+ let termination = null;
1691
+ let commandError = null;
1692
+ try {
1693
+ const persistedTaskId = isolatedTaskId || (await findPersistedTaskId());
1694
+ if (persistedTaskId) {
1695
+ termination = await terminateIsolatedTask(manager, clusterId, persistedTaskId);
1696
+ }
1697
+ } catch (error) {
1698
+ commandError = error;
1699
+ }
1700
+
1701
+ if (!wrapperClosed) {
1702
+ proc.kill('SIGKILL');
1703
+ await waitForWrapperClose;
1704
+ }
1705
+
1706
+ try {
1707
+ const persistedTaskId = isolatedTaskId || (await findPersistedTaskId());
1708
+ if (persistedTaskId && !termination) {
1709
+ termination = await terminateIsolatedTask(manager, clusterId, persistedTaskId);
1710
+ commandError = null;
1711
+ }
1712
+ } catch (error) {
1713
+ commandError ||= error;
1714
+ }
1715
+
1716
+ if (commandError) {
1717
+ return { forced: false, reason: commandError.message };
1718
+ }
1719
+ agent._log?.(`Cancelled pending isolated task launch: ${reason}`);
1720
+ return termination
1721
+ ? { ...termination, forced: true, taskId: isolatedTaskId }
1722
+ : { forced: true, taskId: null };
1723
+ })();
1724
+
1725
+ const termination = await cancellation;
1726
+ if (termination?.forced === false) cancellation = null;
1727
+ if (!resolved) {
1728
+ const error =
1729
+ timeoutError ||
1730
+ new Error(
1731
+ termination?.forced === false
1732
+ ? `Task launch cancellation was not confirmed: ${termination.reason}`
1733
+ : `Task launch cancelled: ${isolatedTaskId || 'before persistence'}`
1734
+ );
1735
+ rejectLaunch(error, { retainHandle: termination?.forced === false });
1736
+ }
1737
+ return termination;
1738
+ },
1739
+ };
1740
+ agent.currentTask = isolatedPendingLaunch;
1741
+
1742
+ // Track PID for resource monitoring
1743
+ agent.processPid = proc.pid;
1744
+ agent._publishLifecycle('PROCESS_SPAWNED', { pid: proc.pid });
1745
+
1746
+ // CRITICAL: Timeout to prevent infinite hang if provider CLI hangs. Timeout
1747
+ // uses the same cancellation path so a durable child cannot outlive it.
1748
+ spawnTimeout = setTimeout(() => {
1749
+ if (resolved) return;
1750
+ timeoutError = new Error(
1751
+ `Spawn timeout after ${SPAWN_TIMEOUT_MS / 1000}s - provider CLI hung. ` +
1752
+ `stdout: ${stdout.slice(-500)}, stderr: ${stderr.slice(-500)}`
1617
1753
  );
1754
+ isolatedPendingLaunch.kill(timeoutError.message).catch((error) => {
1755
+ rejectLaunch(error, { retainHandle: true });
1756
+ });
1618
1757
  }, SPAWN_TIMEOUT_MS);
1619
1758
 
1620
1759
  proc.stdout.on('data', (data) => {
@@ -1625,42 +1764,47 @@ async function spawnClaudeTaskIsolated(agent, context) {
1625
1764
  stderr += data.toString();
1626
1765
  });
1627
1766
 
1628
- proc.on('close', (code, signal) => {
1767
+ proc.on('close', async (code, signal) => {
1629
1768
  clearTimeout(spawnTimeout);
1630
- if (resolved) return;
1631
- resolved = true;
1632
- // Handle process killed by signal
1769
+ if (resolved || isolatedPendingLaunch.cancelled) return;
1633
1770
  if (signal) {
1634
- reject(new Error(`Process killed by signal ${signal}${stderr ? `: ${stderr}` : ''}`));
1771
+ await isolatedPendingLaunch.kill(
1772
+ `Process killed by signal ${signal}${stderr ? `: ${stderr}` : ''}`
1773
+ );
1635
1774
  return;
1636
1775
  }
1637
1776
 
1638
- if (code === 0) {
1639
- // Parse task ID from output: "✓ Task spawned: xxx-yyy-nn"
1640
- const spawnedTaskId = parseTaskIdFromOutput(stdout);
1641
- if (spawnedTaskId) {
1642
- agent.currentTaskId = spawnedTaskId; // Track for resume capability
1643
- agent._publishLifecycle('TASK_ID_ASSIGNED', {
1644
- pid: agent.processPid,
1645
- taskId: spawnedTaskId,
1646
- });
1647
-
1648
- resolve(spawnedTaskId);
1649
- } else {
1650
- reject(new Error(`Could not parse task ID from output: ${stdout}`));
1777
+ try {
1778
+ const persistedTaskId = await findPersistedTaskId();
1779
+ const spawnedTaskId = requireTaskIdFromWrapperResult({
1780
+ code,
1781
+ stdout,
1782
+ stderr,
1783
+ parseTaskId: parseTaskIdFromOutput,
1784
+ persistedTaskId,
1785
+ });
1786
+ isolatedTaskId = spawnedTaskId;
1787
+ assignDurableTaskId(agent, spawnedTaskId);
1788
+ resolved = true;
1789
+ resolve(spawnedTaskId);
1790
+ } catch (error) {
1791
+ const termination = await isolatedPendingLaunch.kill(error.message);
1792
+ if (!resolved) {
1793
+ rejectLaunch(error, { retainHandle: termination?.forced === false });
1651
1794
  }
1652
- } else {
1653
- reject(new Error(`zeroshot task run failed with code ${code}: ${stderr}`));
1654
1795
  }
1655
1796
  });
1656
1797
 
1657
- proc.on('error', (error) => {
1798
+ proc.on('error', async (error) => {
1658
1799
  clearTimeout(spawnTimeout);
1659
- if (resolved) return;
1660
- resolved = true;
1661
- reject(error);
1800
+ if (resolved || isolatedPendingLaunch.cancelled) return;
1801
+ const termination = await isolatedPendingLaunch.kill(error.message);
1802
+ if (termination?.forced === false && !resolved) {
1803
+ rejectLaunch(error, { retainHandle: true });
1804
+ }
1662
1805
  });
1663
1806
  });
1807
+ if (isolatedPendingLaunch?.cancelled) throw new Error(`Task launch cancelled: ${taskId}`);
1664
1808
 
1665
1809
  agent._log(`📋 Agent ${agent.id}: Following zeroshot logs for ${taskId} in container...`);
1666
1810
 
@@ -1759,6 +1903,32 @@ function rejectIsolatedFollower({ agent, state, cleanup, reject, error }) {
1759
1903
  reject(error);
1760
1904
  }
1761
1905
 
1906
+ function rejectIsolatedFollowerRetainingHandle({ state, cleanup, reject, error }) {
1907
+ if (state.resolved || state.failureSettled) return;
1908
+ state.failureSettled = true;
1909
+ cleanup();
1910
+ reject(error);
1911
+ }
1912
+
1913
+ async function resolveIsolatedTaskIdBySpawnToken(manager, clusterId, ownershipToken) {
1914
+ const result = await manager.execInContainer(clusterId, [
1915
+ 'zeroshot',
1916
+ 'get-task-id-by-spawn-token',
1917
+ ownershipToken,
1918
+ ]);
1919
+ if (result.code === 2) return null;
1920
+ if (result.code !== 0) {
1921
+ throw new Error(
1922
+ `Failed to resolve isolated task ownership: ${result.stderr || result.stdout || `exit ${result.code}`}`
1923
+ );
1924
+ }
1925
+ const taskId = result.stdout.trim();
1926
+ if (!taskId) {
1927
+ throw new Error('Isolated task ownership lookup returned an empty task ID');
1928
+ }
1929
+ return taskId;
1930
+ }
1931
+
1762
1932
  function parseIsolatedStatus(output) {
1763
1933
  return output.match(/Status:\s+(completed|failed|killed|stale|cancelled)/i)?.[1].toLowerCase();
1764
1934
  }
@@ -1766,23 +1936,27 @@ function parseIsolatedStatus(output) {
1766
1936
  async function terminateIsolatedTask(manager, clusterId, taskId) {
1767
1937
  const before = await manager.execInContainer(clusterId, ['zeroshot', 'status', taskId]);
1768
1938
  const beforeStatus = before.code === 0 ? parseIsolatedStatus(before.stdout) : null;
1769
- if (beforeStatus) {
1770
- return { alreadyTerminal: true, forced: false, status: beforeStatus };
1939
+ const result = await manager.execInContainer(clusterId, ['zeroshot', 'kill', taskId]);
1940
+ if (result.code !== 0) {
1941
+ throw new Error(
1942
+ `Failed to terminate isolated task ${taskId}: ${result.stderr || result.stdout || `exit ${result.code}`}`
1943
+ );
1771
1944
  }
1772
1945
 
1773
- const result = await manager.execInContainer(clusterId, ['zeroshot', 'kill', taskId]);
1774
1946
  const status = await manager.execInContainer(clusterId, ['zeroshot', 'status', taskId]);
1775
1947
  const afterStatus = status.code === 0 ? parseIsolatedStatus(status.stdout) : null;
1776
1948
  if (!afterStatus) {
1777
1949
  throw new Error(
1778
- `Failed to terminate isolated task ${taskId}: ${result.stderr || result.stdout || `exit ${result.code}`}`
1950
+ `Failed to confirm isolated task ${taskId} after cleanup recovery: ${
1951
+ status.stderr || status.stdout || `exit ${status.code}`
1952
+ }`
1779
1953
  );
1780
1954
  }
1781
1955
 
1782
1956
  return {
1783
- alreadyTerminal: false,
1784
- forced: afterStatus === 'killed',
1785
- status: afterStatus,
1957
+ alreadyTerminal: Boolean(beforeStatus),
1958
+ forced: !beforeStatus && afterStatus === 'killed',
1959
+ status: beforeStatus || afterStatus,
1786
1960
  };
1787
1961
  }
1788
1962
 
@@ -1952,7 +2126,7 @@ function buildIsolatedLifecycleHandle({
1952
2126
  terminate,
1953
2127
  kill: terminate,
1954
2128
  failClosed(error) {
1955
- rejectIsolatedFollower({ agent, state, cleanup, reject, error });
2129
+ rejectIsolatedFollowerRetainingHandle({ state, cleanup, reject, error });
1956
2130
  },
1957
2131
  };
1958
2132
  }
@@ -2054,6 +2228,28 @@ async function checkIsolatedStatus({
2054
2228
  return;
2055
2229
  }
2056
2230
 
2231
+ if (status && hasPendingCommandCleanup(statusOutput)) {
2232
+ if (!state.commandCleanupRecoveryPromise) {
2233
+ const recovery = manager.execInContainer(clusterId, ['zeroshot', 'kill', taskId]);
2234
+ state.commandCleanupRecoveryPromise = recovery;
2235
+ try {
2236
+ const result = await recovery;
2237
+ if (result.code !== 0) {
2238
+ agent._log(
2239
+ `[${agent.id}] Isolated terminal command cleanup recovery will retry: ${
2240
+ result.stderr || result.stdout || `exit ${result.code}`
2241
+ }`
2242
+ );
2243
+ }
2244
+ } finally {
2245
+ if (state.commandCleanupRecoveryPromise === recovery) {
2246
+ state.commandCleanupRecoveryPromise = null;
2247
+ }
2248
+ }
2249
+ }
2250
+ return;
2251
+ }
2252
+
2057
2253
  state.logFilePath = logFilePath;
2058
2254
  await settleIsolatedTerminalStatus({
2059
2255
  agent,
@@ -2353,29 +2549,40 @@ async function killTask(agent, termination = 'Task killed') {
2353
2549
  const currentTask = agent.currentTask;
2354
2550
  const taskId = agent.currentTaskId;
2355
2551
 
2552
+ if (currentTask?.pendingLaunch && typeof currentTask.kill === 'function') {
2553
+ const pendingTermination = await currentTask.kill(reason, { code });
2554
+ if (pendingTermination?.forced === false) return pendingTermination;
2555
+ agent._stopLivenessCheck?.();
2556
+ agent.currentTask = null;
2557
+ agent.currentTaskId = null;
2558
+ agent.processPid = null;
2559
+ agent.lastOutputTime = null;
2560
+ agent.taskStartedAt = null;
2561
+ return pendingTermination;
2562
+ }
2563
+
2356
2564
  if (agent.isolation?.enabled && taskId) {
2357
2565
  return killIsolatedTask(agent, currentTask, taskId, reason, code);
2358
2566
  }
2359
2567
 
2360
- agent._stopLivenessCheck?.();
2361
-
2362
2568
  // Kill the underlying task before resolving the local follower. This keeps
2363
2569
  // retries from racing a provider process that is still shutting down.
2364
2570
  if (taskId) {
2365
- const ctPath = getClaudeTasksPath();
2571
+ const ctPath = agent.taskCommandPath || getClaudeTasksPath();
2366
2572
  try {
2367
2573
  // `kill` is a top-level smart command. `task kill` has never existed.
2368
2574
  await runCommandWithTimeout(ctPath, ['kill', taskId], { timeout: 10000 });
2369
2575
  agent._log?.(`Killed task ${taskId}`);
2370
2576
  } catch (error) {
2371
- // Resolve the local follower even if the task is already terminal or the
2372
- // management CLI is unavailable; shutdown state must still reconcile.
2373
- agent._log?.(`Note: Could not kill task ${taskId}: ${error.message}`);
2577
+ agent._log?.(`Note: Could not confirm termination for task ${taskId}: ${error.message}`);
2578
+ return { forced: false, reason: error.message };
2374
2579
  }
2375
2580
  }
2376
2581
 
2582
+ agent._stopLivenessCheck?.();
2583
+
2377
2584
  if (currentTask && typeof currentTask.kill === 'function') {
2378
- currentTask.kill(reason, { code });
2585
+ await currentTask.kill(reason, { code });
2379
2586
  }
2380
2587
 
2381
2588
  agent.currentTask = null;
@@ -2387,24 +2594,28 @@ async function killTask(agent, termination = 'Task killed') {
2387
2594
 
2388
2595
  async function killIsolatedTask(agent, currentTask, taskId, reason, code) {
2389
2596
  let termination;
2390
- if (currentTask && typeof currentTask.terminate === 'function') {
2391
- termination = await currentTask.terminate(reason, { code });
2392
- } else {
2393
- termination = await terminateIsolatedTask(
2394
- agent.isolation.manager,
2395
- agent.isolation.clusterId,
2396
- taskId
2397
- );
2398
- if (currentTask && typeof currentTask.kill === 'function') {
2399
- currentTask.kill(reason, { code });
2597
+ try {
2598
+ if (currentTask && typeof currentTask.terminate === 'function') {
2599
+ termination = await currentTask.terminate(reason, { code });
2600
+ } else {
2601
+ termination = await terminateIsolatedTask(
2602
+ agent.isolation.manager,
2603
+ agent.isolation.clusterId,
2604
+ taskId
2605
+ );
2606
+ if (currentTask && typeof currentTask.kill === 'function') {
2607
+ currentTask.kill(reason, { code });
2608
+ }
2400
2609
  }
2610
+ } catch (error) {
2611
+ return { forced: false, reason: error.message };
2401
2612
  }
2402
2613
 
2403
- agent._stopLivenessCheck?.();
2404
- if (termination?.forced === false) {
2614
+ if (termination?.forced === false && !termination.alreadyTerminal) {
2405
2615
  return termination;
2406
2616
  }
2407
2617
 
2618
+ agent._stopLivenessCheck?.();
2408
2619
  agent.currentTask = null;
2409
2620
  agent.currentTaskId = null;
2410
2621
  agent.processPid = null;
@@ -2415,6 +2626,9 @@ async function killIsolatedTask(agent, currentTask, taskId, reason, code) {
2415
2626
 
2416
2627
  module.exports = {
2417
2628
  ensureAskUserQuestionHook,
2629
+ ensureDangerousGitHook,
2630
+ resolveMcpConfigArgs,
2631
+ buildSpawnEnv,
2418
2632
  spawnClaudeTask,
2419
2633
  spawnTaskProcess,
2420
2634
  followClaudeTaskLogs,