oh-my-codex 0.12.3 → 0.12.4

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 (176) hide show
  1. package/Cargo.lock +5 -5
  2. package/Cargo.toml +1 -1
  3. package/README.md +2 -0
  4. package/dist/cli/__tests__/index.test.js +73 -12
  5. package/dist/cli/__tests__/index.test.js.map +1 -1
  6. package/dist/cli/__tests__/launch-fallback.test.js +8 -27
  7. package/dist/cli/__tests__/launch-fallback.test.js.map +1 -1
  8. package/dist/cli/__tests__/mcp-parity.test.d.ts +2 -0
  9. package/dist/cli/__tests__/mcp-parity.test.d.ts.map +1 -0
  10. package/dist/cli/__tests__/mcp-parity.test.js +111 -0
  11. package/dist/cli/__tests__/mcp-parity.test.js.map +1 -0
  12. package/dist/cli/__tests__/nested-help-routing.test.js +13 -0
  13. package/dist/cli/__tests__/nested-help-routing.test.js.map +1 -1
  14. package/dist/cli/__tests__/package-bin-contract.test.js +6 -1
  15. package/dist/cli/__tests__/package-bin-contract.test.js.map +1 -1
  16. package/dist/cli/__tests__/setup-hooks-shared-ownership.test.d.ts +2 -0
  17. package/dist/cli/__tests__/setup-hooks-shared-ownership.test.d.ts.map +1 -0
  18. package/dist/cli/__tests__/setup-hooks-shared-ownership.test.js +189 -0
  19. package/dist/cli/__tests__/setup-hooks-shared-ownership.test.js.map +1 -0
  20. package/dist/cli/__tests__/setup-scope.test.js +48 -0
  21. package/dist/cli/__tests__/setup-scope.test.js.map +1 -1
  22. package/dist/cli/__tests__/state.test.d.ts +2 -0
  23. package/dist/cli/__tests__/state.test.d.ts.map +1 -0
  24. package/dist/cli/__tests__/state.test.js +46 -0
  25. package/dist/cli/__tests__/state.test.js.map +1 -0
  26. package/dist/cli/__tests__/team.test.js +238 -2
  27. package/dist/cli/__tests__/team.test.js.map +1 -1
  28. package/dist/cli/__tests__/uninstall.test.js +37 -2
  29. package/dist/cli/__tests__/uninstall.test.js.map +1 -1
  30. package/dist/cli/index.d.ts +6 -13
  31. package/dist/cli/index.d.ts.map +1 -1
  32. package/dist/cli/index.js +47 -60
  33. package/dist/cli/index.js.map +1 -1
  34. package/dist/cli/mcp-parity.d.ts +22 -0
  35. package/dist/cli/mcp-parity.d.ts.map +1 -0
  36. package/dist/cli/mcp-parity.js +227 -0
  37. package/dist/cli/mcp-parity.js.map +1 -0
  38. package/dist/cli/setup.d.ts.map +1 -1
  39. package/dist/cli/setup.js +5 -2
  40. package/dist/cli/setup.js.map +1 -1
  41. package/dist/cli/state.d.ts +8 -0
  42. package/dist/cli/state.d.ts.map +1 -0
  43. package/dist/cli/state.js +71 -0
  44. package/dist/cli/state.js.map +1 -0
  45. package/dist/cli/team.d.ts.map +1 -1
  46. package/dist/cli/team.js +6 -5
  47. package/dist/cli/team.js.map +1 -1
  48. package/dist/cli/uninstall.d.ts.map +1 -1
  49. package/dist/cli/uninstall.js +18 -4
  50. package/dist/cli/uninstall.js.map +1 -1
  51. package/dist/config/__tests__/codex-hooks.test.d.ts +2 -0
  52. package/dist/config/__tests__/codex-hooks.test.d.ts.map +1 -0
  53. package/dist/config/__tests__/codex-hooks.test.js +53 -0
  54. package/dist/config/__tests__/codex-hooks.test.js.map +1 -0
  55. package/dist/config/codex-hooks.d.ts +16 -7
  56. package/dist/config/codex-hooks.d.ts.map +1 -1
  57. package/dist/config/codex-hooks.js +134 -2
  58. package/dist/config/codex-hooks.js.map +1 -1
  59. package/dist/hooks/__tests__/keyword-detector.test.js +6 -0
  60. package/dist/hooks/__tests__/keyword-detector.test.js.map +1 -1
  61. package/dist/hooks/keyword-detector.d.ts.map +1 -1
  62. package/dist/hooks/keyword-detector.js +6 -0
  63. package/dist/hooks/keyword-detector.js.map +1 -1
  64. package/dist/hud/__tests__/reconcile.test.d.ts +2 -0
  65. package/dist/hud/__tests__/reconcile.test.d.ts.map +1 -0
  66. package/dist/hud/__tests__/reconcile.test.js +83 -0
  67. package/dist/hud/__tests__/reconcile.test.js.map +1 -0
  68. package/dist/hud/__tests__/render.test.js +43 -0
  69. package/dist/hud/__tests__/render.test.js.map +1 -1
  70. package/dist/hud/constants.d.ts +2 -1
  71. package/dist/hud/constants.d.ts.map +1 -1
  72. package/dist/hud/constants.js +2 -1
  73. package/dist/hud/constants.js.map +1 -1
  74. package/dist/hud/index.d.ts +4 -1
  75. package/dist/hud/index.d.ts.map +1 -1
  76. package/dist/hud/index.js +11 -5
  77. package/dist/hud/index.js.map +1 -1
  78. package/dist/hud/reconcile.d.ts +23 -0
  79. package/dist/hud/reconcile.d.ts.map +1 -0
  80. package/dist/hud/reconcile.js +71 -0
  81. package/dist/hud/reconcile.js.map +1 -0
  82. package/dist/hud/render.d.ts +6 -1
  83. package/dist/hud/render.d.ts.map +1 -1
  84. package/dist/hud/render.js +77 -3
  85. package/dist/hud/render.js.map +1 -1
  86. package/dist/hud/tmux.d.ts +26 -0
  87. package/dist/hud/tmux.d.ts.map +1 -0
  88. package/dist/hud/tmux.js +126 -0
  89. package/dist/hud/tmux.js.map +1 -0
  90. package/dist/mcp/bootstrap.d.ts.map +1 -1
  91. package/dist/mcp/bootstrap.js +16 -6
  92. package/dist/mcp/bootstrap.js.map +1 -1
  93. package/dist/mcp/code-intel-server.d.ts +298 -0
  94. package/dist/mcp/code-intel-server.d.ts.map +1 -1
  95. package/dist/mcp/code-intel-server.js +9 -5
  96. package/dist/mcp/code-intel-server.js.map +1 -1
  97. package/dist/mcp/memory-server.d.ts +195 -1
  98. package/dist/mcp/memory-server.d.ts.map +1 -1
  99. package/dist/mcp/memory-server.js +9 -5
  100. package/dist/mcp/memory-server.js.map +1 -1
  101. package/dist/mcp/trace-server.d.ts +51 -0
  102. package/dist/mcp/trace-server.d.ts.map +1 -1
  103. package/dist/mcp/trace-server.js +9 -5
  104. package/dist/mcp/trace-server.js.map +1 -1
  105. package/dist/scripts/__tests__/codex-native-hook.test.js +455 -8
  106. package/dist/scripts/__tests__/codex-native-hook.test.js.map +1 -1
  107. package/dist/scripts/codex-native-hook.d.ts.map +1 -1
  108. package/dist/scripts/codex-native-hook.js +159 -52
  109. package/dist/scripts/codex-native-hook.js.map +1 -1
  110. package/dist/scripts/codex-native-pre-post.d.ts +5 -0
  111. package/dist/scripts/codex-native-pre-post.d.ts.map +1 -1
  112. package/dist/scripts/codex-native-pre-post.js +86 -0
  113. package/dist/scripts/codex-native-pre-post.js.map +1 -1
  114. package/dist/scripts/notify-hook/operational-events.d.ts.map +1 -1
  115. package/dist/scripts/notify-hook/operational-events.js +7 -2
  116. package/dist/scripts/notify-hook/operational-events.js.map +1 -1
  117. package/dist/state/__tests__/operations-ralph-phase.test.d.ts +2 -0
  118. package/dist/state/__tests__/operations-ralph-phase.test.d.ts.map +1 -0
  119. package/dist/state/__tests__/operations-ralph-phase.test.js +82 -0
  120. package/dist/state/__tests__/operations-ralph-phase.test.js.map +1 -0
  121. package/dist/state/__tests__/operations.test.d.ts +2 -0
  122. package/dist/state/__tests__/operations.test.d.ts.map +1 -0
  123. package/dist/state/__tests__/operations.test.js +200 -0
  124. package/dist/state/__tests__/operations.test.js.map +1 -0
  125. package/dist/state/__tests__/path-traversal.test.d.ts +2 -0
  126. package/dist/state/__tests__/path-traversal.test.d.ts.map +1 -0
  127. package/dist/state/__tests__/path-traversal.test.js +49 -0
  128. package/dist/state/__tests__/path-traversal.test.js.map +1 -0
  129. package/dist/state/operations.d.ts +11 -0
  130. package/dist/state/operations.d.ts.map +1 -0
  131. package/dist/state/operations.js +233 -0
  132. package/dist/state/operations.js.map +1 -0
  133. package/dist/team/__tests__/api-interop.test.js +24 -2
  134. package/dist/team/__tests__/api-interop.test.js.map +1 -1
  135. package/dist/team/__tests__/delivery-e2e-smoke.test.js +9 -1
  136. package/dist/team/__tests__/delivery-e2e-smoke.test.js.map +1 -1
  137. package/dist/team/__tests__/runtime-cli.test.js +45 -0
  138. package/dist/team/__tests__/runtime-cli.test.js.map +1 -1
  139. package/dist/team/__tests__/runtime.test.js +191 -66
  140. package/dist/team/__tests__/runtime.test.js.map +1 -1
  141. package/dist/team/__tests__/tmux-session.test.js +33 -0
  142. package/dist/team/__tests__/tmux-session.test.js.map +1 -1
  143. package/dist/team/api-interop.d.ts.map +1 -1
  144. package/dist/team/api-interop.js +2 -1
  145. package/dist/team/api-interop.js.map +1 -1
  146. package/dist/team/runtime-cli.d.ts.map +1 -1
  147. package/dist/team/runtime-cli.js +21 -2
  148. package/dist/team/runtime-cli.js.map +1 -1
  149. package/dist/team/runtime.d.ts +8 -0
  150. package/dist/team/runtime.d.ts.map +1 -1
  151. package/dist/team/runtime.js +179 -78
  152. package/dist/team/runtime.js.map +1 -1
  153. package/dist/team/state/dispatch.d.ts.map +1 -1
  154. package/dist/team/state/dispatch.js +9 -0
  155. package/dist/team/state/dispatch.js.map +1 -1
  156. package/dist/team/tmux-session.js +3 -3
  157. package/dist/team/tmux-session.js.map +1 -1
  158. package/dist/team/worktree.d.ts +2 -0
  159. package/dist/team/worktree.d.ts.map +1 -1
  160. package/dist/team/worktree.js +7 -1
  161. package/dist/team/worktree.js.map +1 -1
  162. package/dist/utils/__tests__/paths.test.js +76 -1
  163. package/dist/utils/__tests__/paths.test.js.map +1 -1
  164. package/dist/utils/paths.d.ts +6 -0
  165. package/dist/utils/paths.d.ts.map +1 -1
  166. package/dist/utils/paths.js +14 -0
  167. package/dist/utils/paths.js.map +1 -1
  168. package/dist/verification/__tests__/ci-rust-gates.test.js +59 -11
  169. package/dist/verification/__tests__/ci-rust-gates.test.js.map +1 -1
  170. package/dist/verification/__tests__/ralph-persistence-gate.test.js +1 -4
  171. package/dist/verification/__tests__/ralph-persistence-gate.test.js.map +1 -1
  172. package/package.json +6 -1
  173. package/src/scripts/__tests__/codex-native-hook.test.ts +600 -8
  174. package/src/scripts/codex-native-hook.ts +236 -60
  175. package/src/scripts/codex-native-pre-post.ts +104 -0
  176. package/src/scripts/notify-hook/operational-events.ts +6 -2
@@ -3,7 +3,7 @@ import { existsSync, appendFileSync, mkdirSync } from 'fs';
3
3
  import { mkdir, readdir, readFile, writeFile } from 'fs/promises';
4
4
  import { performance } from 'perf_hooks';
5
5
  import { spawn, spawnSync } from 'child_process';
6
- import { sanitizeTeamName, isTmuxAvailable, hasCurrentTmuxClientContext, createTeamSession, buildWorkerProcessLaunchSpec, resolveTeamWorkerCli, resolveTeamWorkerCliPlan, resolveTeamWorkerLaunchMode, waitForWorkerReady, dismissTrustPromptIfPresent, isNativeWindows, sleepFractionalSeconds, sendToWorker, sendToWorkerStdin, isWorkerAlive, getWorkerPanePid, killWorkerByPaneIdAsync, restoreStandaloneHudPane, teardownWorkerPanes, unregisterResizeHook, destroyTeamSession, listPaneIds, listTeamSessions, } from './tmux-session.js';
6
+ import { sanitizeTeamName, isTmuxAvailable, hasCurrentTmuxClientContext, createTeamSession, buildWorkerProcessLaunchSpec, resolveTeamWorkerCli, resolveTeamWorkerCliPlan, resolveTeamWorkerLaunchMode, waitForWorkerReady, dismissTrustPromptIfPresent, sleepFractionalSeconds, sendToWorker, sendToWorkerStdin, isWorkerAlive, getWorkerPanePid, killWorkerByPaneIdAsync, restoreStandaloneHudPane, teardownWorkerPanes, unregisterResizeHook, destroyTeamSession, listPaneIds, listTeamSessions, } from './tmux-session.js';
7
7
  import { teamInit as initTeamState, DEFAULT_MAX_WORKERS, teamReadConfig as readTeamConfig, teamWriteWorkerIdentity as writeWorkerIdentity, teamReadWorkerHeartbeat as readWorkerHeartbeat, teamReadWorkerStatus as readWorkerStatus, teamWriteWorkerInbox as writeWorkerInbox, teamCreateTask as createStateTask, teamReadTask as readTask, teamListTasks as listTasks, teamReadManifest as readTeamManifestV2, teamNormalizeGovernance as normalizeTeamGovernance, teamNormalizePolicy as normalizeTeamPolicy, teamClaimTask as claimTask, teamReleaseTaskClaim as releaseTaskClaim, teamReclaimExpiredTaskClaim as reclaimExpiredTaskClaim, teamAppendEvent as appendTeamEvent, teamReadTaskApproval as readTaskApproval, teamListMailbox as listMailboxMessages, teamMarkMessageDelivered as markMessageDelivered, teamMarkMessageNotified as markMessageNotified, teamEnqueueDispatchRequest as enqueueDispatchRequest, teamMarkDispatchRequestNotified as markDispatchRequestNotified, teamTransitionDispatchRequest as transitionDispatchRequest, teamReadDispatchRequest as readDispatchRequest, teamCleanup as cleanupTeamState, teamSaveConfig as saveTeamConfig, teamWriteShutdownRequest as writeShutdownRequest, teamReadShutdownAck as readShutdownAck, teamReadMonitorSnapshot as readMonitorSnapshot, teamWriteMonitorSnapshot as writeMonitorSnapshot, teamReadPhase as readTeamPhaseState, teamWritePhase as writeTeamPhaseState, } from './team-ops.js';
8
8
  import { queueInboxInstruction, queueDirectMailboxMessage, queueBroadcastMailboxMessage, waitForDispatchReceipt, } from './mcp-comm.js';
9
9
  import { appendTeamDeliveryLogForCwd } from './delivery-log.js';
@@ -20,7 +20,7 @@ import { hasStructuredVerificationEvidence } from '../verification/verifier.js';
20
20
  import { buildRebalanceDecisions } from './rebalance-policy.js';
21
21
  import { readModeState, updateModeState } from '../modes/base.js';
22
22
  import { appendTeamCommitHygieneEntries, buildTeamCommitHygieneContext, writeTeamCommitHygieneContext, } from './commit-hygiene.js';
23
- import { assertCleanLeaderWorkspaceForWorkerWorktrees, ensureWorktree, isGitRepository, planWorktreeTarget, rollbackProvisionedWorktrees, } from './worktree.js';
23
+ import { assertCleanLeaderWorkspaceForWorkerWorktrees, ensureWorktree, isGitRepository, isWorktreeDirty, planWorktreeTarget, removeWorktreeForce, rollbackProvisionedWorktrees, } from './worktree.js';
24
24
  async function syncRootTeamModeStateOnTerminalPhase(teamName, phase, cwd) {
25
25
  if (phase !== 'complete' && phase !== 'failed' && phase !== 'cancelled')
26
26
  return;
@@ -109,9 +109,15 @@ function collectShutdownPaneIds(params) {
109
109
  return [...paneIds];
110
110
  }
111
111
  export function shouldPrekillInteractiveShutdownProcessTrees(sessionName) {
112
- // Native Windows + split-pane psmux sessions can expose overlapping
113
- // ancestry around the leader client; rely on pane-targeted teardown there.
114
- return !(isNativeWindows() && sessionName.includes(':'));
112
+ // Shared-window tmux sessions can expose overlapping ancestry around the
113
+ // invoking leader client. Rely on pane-targeted teardown there so shutdown
114
+ // does not signal the leader while tearing down worker panes.
115
+ if (sessionName.includes(':'))
116
+ return false;
117
+ // Detached session teardown still benefits from process-tree prekill,
118
+ // including native Windows prompt-worker ancestry where pane-targeted
119
+ // teardown alone is insufficient.
120
+ return true;
115
121
  }
116
122
  async function logRuntimeDispatchOutcome(params) {
117
123
  const { cwd, teamName, workerName, requestId, messageId, intent, outcome, source = 'team.runtime' } = params;
@@ -789,6 +795,44 @@ async function prepareWorkerWorktreeShutdownReports(config, leaderCwd) {
789
795
  }
790
796
  return reports;
791
797
  }
798
+ function listDirtyShutdownWorkers(config) {
799
+ const dirtyWorkers = [];
800
+ for (const worker of config.workers) {
801
+ if (!worker.worktree_repo_root || !worker.worktree_path || !existsSync(worker.worktree_path))
802
+ continue;
803
+ const worktreePath = resolve(worker.worktree_path);
804
+ const repoRoot = resolve(worker.worktree_repo_root);
805
+ const status = runGitCommand(repoRoot, ['status', '--porcelain'], worktreePath);
806
+ if (!status.ok || status.stdout.trim().length > 0) {
807
+ dirtyWorkers.push(worker.name);
808
+ }
809
+ }
810
+ return dirtyWorkers;
811
+ }
812
+ async function classifyShutdown(params) {
813
+ const { teamName, cwd, config, governance, confirmIssues } = params;
814
+ const allTasks = await listTasks(teamName, cwd);
815
+ const gate = {
816
+ total: allTasks.length,
817
+ pending: allTasks.filter((t) => t.status === 'pending').length,
818
+ blocked: allTasks.filter((t) => t.status === 'blocked').length,
819
+ in_progress: allTasks.filter((t) => t.status === 'in_progress').length,
820
+ completed: allTasks.filter((t) => t.status === 'completed').length,
821
+ failed: allTasks.filter((t) => t.status === 'failed').length,
822
+ allowed: false,
823
+ };
824
+ const dirtyWorkers = listDirtyShutdownWorkers(config);
825
+ const hasBlockingBacklog = gate.pending > 0 || gate.blocked > 0 || gate.in_progress > 0;
826
+ const requiresIssueConfirmation = gate.failed > 0 && dirtyWorkers.length === 0 && !confirmIssues;
827
+ gate.allowed = governance.cleanup_requires_all_workers_inactive !== true
828
+ || (!hasBlockingBacklog && !requiresIssueConfirmation);
829
+ return {
830
+ gate,
831
+ dirtyWorkers,
832
+ requiresIssueConfirmation,
833
+ useCleanFastPath: dirtyWorkers.length === 0 && !hasBlockingBacklog && (gate.failed === 0 || confirmIssues),
834
+ };
835
+ }
792
836
  function resolveEffectiveTeamWorktreeMode(leaderCwd, requestedMode) {
793
837
  if (!isGitRepository(leaderCwd)) {
794
838
  return { enabled: false };
@@ -1319,6 +1363,7 @@ export async function startTeam(teamName, task, agentType, workerCount, tasks, c
1319
1363
  for (let i = 1; i <= workerCount; i++) {
1320
1364
  workerWorkspaceByName.set(`worker-${i}`, { cwd: leaderCwd });
1321
1365
  }
1366
+ await detectAndCleanStaleTeam(sanitized, leaderCwd, workerCount, options.confirmStaleCleanup);
1322
1367
  if (activeWorktreeMode) {
1323
1368
  assertCleanLeaderWorkspaceForWorkerWorktrees(leaderCwd);
1324
1369
  for (let i = 1; i <= workerCount; i++) {
@@ -2060,6 +2105,8 @@ export async function reassignTask(teamName, taskId, _fromWorker, toWorker, cwd)
2060
2105
  */
2061
2106
  export async function shutdownTeam(teamName, cwd, options = {}) {
2062
2107
  const force = options.force === true;
2108
+ const confirmIssues = options.confirmIssues === true;
2109
+ let skipWorkerAcks = false;
2063
2110
  const sanitized = sanitizeTeamName(teamName);
2064
2111
  const config = await readTeamConfig(sanitized, cwd);
2065
2112
  if (!config) {
@@ -2077,26 +2124,26 @@ export async function shutdownTeam(teamName, cwd, options = {}) {
2077
2124
  const manifest = await readTeamManifestV2(sanitized, cwd);
2078
2125
  const governance = resolveGovernancePolicy(manifest?.governance, manifest?.policy);
2079
2126
  if (!force) {
2080
- const allTasks = await listTasks(sanitized, cwd);
2081
- const gate = {
2082
- total: allTasks.length,
2083
- pending: allTasks.filter((t) => t.status === 'pending').length,
2084
- blocked: allTasks.filter((t) => t.status === 'blocked').length,
2085
- in_progress: allTasks.filter((t) => t.status === 'in_progress').length,
2086
- completed: allTasks.filter((t) => t.status === 'completed').length,
2087
- failed: allTasks.filter((t) => t.status === 'failed').length,
2088
- allowed: false,
2089
- };
2090
- gate.allowed = governance.cleanup_requires_all_workers_inactive !== true
2091
- || (gate.pending === 0 && gate.blocked === 0 && gate.in_progress === 0 && gate.failed === 0);
2127
+ const classification = await classifyShutdown({
2128
+ teamName: sanitized,
2129
+ cwd,
2130
+ config,
2131
+ governance,
2132
+ confirmIssues,
2133
+ });
2134
+ const { gate, dirtyWorkers, requiresIssueConfirmation, useCleanFastPath } = classification;
2092
2135
  await appendTeamEvent(sanitized, {
2093
2136
  type: 'shutdown_gate',
2094
2137
  worker: 'leader-fixed',
2095
- reason: `allowed=${gate.allowed} total=${gate.total} pending=${gate.pending} blocked=${gate.blocked} in_progress=${gate.in_progress} completed=${gate.completed} failed=${gate.failed} cleanup_requires_all_workers_inactive=${governance.cleanup_requires_all_workers_inactive}`,
2138
+ reason: `allowed=${gate.allowed} total=${gate.total} pending=${gate.pending} blocked=${gate.blocked} in_progress=${gate.in_progress} completed=${gate.completed} failed=${gate.failed} cleanup_requires_all_workers_inactive=${governance.cleanup_requires_all_workers_inactive} dirty_workers=${dirtyWorkers.join('|') || 'none'} confirm_issues=${confirmIssues} clean_fast_path=${useCleanFastPath}`,
2096
2139
  }, cwd).catch(() => { });
2097
2140
  if (!gate.allowed) {
2141
+ if (requiresIssueConfirmation) {
2142
+ throw new Error(`shutdown_confirm_issues_required:failed=${gate.failed}:rerun=omx team shutdown ${sanitized} --confirm-issues`);
2143
+ }
2098
2144
  throw new Error(`shutdown_gate_blocked:pending=${gate.pending},blocked=${gate.blocked},in_progress=${gate.in_progress},failed=${gate.failed}`);
2099
2145
  }
2146
+ skipWorkerAcks = useCleanFastPath;
2100
2147
  }
2101
2148
  if (force) {
2102
2149
  await appendTeamEvent(sanitized, {
@@ -2105,73 +2152,81 @@ export async function shutdownTeam(teamName, cwd, options = {}) {
2105
2152
  reason: 'force_bypass',
2106
2153
  }, cwd).catch(() => { });
2107
2154
  }
2155
+ if (force && config.worker_launch_mode === 'prompt') {
2156
+ // Prompt-mode workers are raw CLI children, not team-runtime workers that
2157
+ // participate in the shutdown-ack handshake. Waiting the full ack window
2158
+ // before force-killing them only adds deterministic suite slowness.
2159
+ skipWorkerAcks = true;
2160
+ }
2108
2161
  const sessionName = config.tmux_session;
2109
2162
  const dispatchPolicy = resolveDispatchPolicy(manifest?.policy, config.worker_launch_mode);
2110
2163
  const shutdownRequestTimes = new Map();
2111
- // 1. Send shutdown inbox to each worker
2112
- for (const w of config.workers) {
2113
- try {
2114
- const requestedAt = new Date().toISOString();
2115
- await writeShutdownRequest(sanitized, w.name, 'leader-fixed', cwd);
2116
- shutdownRequestTimes.set(w.name, requestedAt);
2117
- const triggerDirective = buildTriggerDirective(w.name, sanitized, resolveInstructionStateRoot(w.worktree_path));
2118
- await dispatchCriticalInboxInstruction({
2119
- teamName: sanitized,
2120
- config,
2121
- workerName: w.name,
2122
- workerIndex: w.index,
2123
- paneId: w.pane_id,
2124
- inbox: generateShutdownInbox(sanitized, w.name),
2125
- triggerMessage: triggerDirective.text,
2126
- intent: triggerDirective.intent,
2127
- cwd,
2128
- dispatchPolicy,
2129
- inboxCorrelationKey: `shutdown:${w.name}`,
2130
- });
2131
- }
2132
- catch (err) {
2133
- process.stderr.write(`[team/runtime] operation failed: ${err}\n`);
2134
- }
2135
- }
2136
- // 2. Wait up to 15s for workers to exit and collect acks
2137
- const deadline = Date.now() + 15_000;
2138
- const rejected = [];
2139
- const ackedWorkers = new Set();
2140
- while (Date.now() < deadline) {
2164
+ if (!skipWorkerAcks) {
2165
+ // 1. Send shutdown inbox to each worker
2141
2166
  for (const w of config.workers) {
2142
- const ack = await readShutdownAck(sanitized, w.name, cwd, shutdownRequestTimes.get(w.name));
2143
- if (ack && !ackedWorkers.has(w.name)) {
2144
- ackedWorkers.add(w.name);
2145
- await appendTeamEvent(sanitized, {
2146
- type: 'shutdown_ack',
2147
- worker: w.name,
2148
- reason: ack.status === 'reject' ? `reject:${ack.reason || 'no_reason'}` : 'accept',
2149
- }, cwd);
2167
+ try {
2168
+ const requestedAt = new Date().toISOString();
2169
+ await writeShutdownRequest(sanitized, w.name, 'leader-fixed', cwd);
2170
+ shutdownRequestTimes.set(w.name, requestedAt);
2171
+ const triggerDirective = buildTriggerDirective(w.name, sanitized, resolveInstructionStateRoot(w.worktree_path));
2172
+ await dispatchCriticalInboxInstruction({
2173
+ teamName: sanitized,
2174
+ config,
2175
+ workerName: w.name,
2176
+ workerIndex: w.index,
2177
+ paneId: w.pane_id,
2178
+ inbox: generateShutdownInbox(sanitized, w.name),
2179
+ triggerMessage: triggerDirective.text,
2180
+ intent: triggerDirective.intent,
2181
+ cwd,
2182
+ dispatchPolicy,
2183
+ inboxCorrelationKey: `shutdown:${w.name}`,
2184
+ });
2150
2185
  }
2151
- if (ack?.status === 'reject') {
2152
- if (!rejected.some((r) => r.worker === w.name)) {
2153
- rejected.push({ worker: w.name, reason: ack.reason || 'no_reason' });
2154
- }
2186
+ catch (err) {
2187
+ process.stderr.write(`[team/runtime] operation failed: ${err}\n`);
2155
2188
  }
2156
2189
  }
2157
- if (rejected.length > 0 && !force) {
2158
- const detail = rejected.map(r => `${r.worker}:${r.reason}`).join(',');
2159
- throw new Error(`shutdown_rejected:${detail}`);
2190
+ // 2. Wait up to 15s for workers to exit and collect acks
2191
+ const deadline = Date.now() + 15_000;
2192
+ const rejected = [];
2193
+ const ackedWorkers = new Set();
2194
+ while (Date.now() < deadline) {
2195
+ for (const w of config.workers) {
2196
+ const ack = await readShutdownAck(sanitized, w.name, cwd, shutdownRequestTimes.get(w.name));
2197
+ if (ack && !ackedWorkers.has(w.name)) {
2198
+ ackedWorkers.add(w.name);
2199
+ await appendTeamEvent(sanitized, {
2200
+ type: 'shutdown_ack',
2201
+ worker: w.name,
2202
+ reason: ack.status === 'reject' ? `reject:${ack.reason || 'no_reason'}` : 'accept',
2203
+ }, cwd);
2204
+ }
2205
+ if (ack?.status === 'reject') {
2206
+ if (!rejected.some((r) => r.worker === w.name)) {
2207
+ rejected.push({ worker: w.name, reason: ack.reason || 'no_reason' });
2208
+ }
2209
+ }
2210
+ }
2211
+ if (rejected.length > 0 && !force) {
2212
+ const detail = rejected.map(r => `${r.worker}:${r.reason}`).join(',');
2213
+ throw new Error(`shutdown_rejected:${detail}`);
2214
+ }
2215
+ const anyAlive = config.workers.some((w) => (config.worker_launch_mode === 'prompt'
2216
+ ? isPromptWorkerAlive(config, w)
2217
+ : isWorkerAlive(sessionName, w.index, w.pane_id)));
2218
+ if (!anyAlive)
2219
+ break;
2220
+ // Sleep 2s
2221
+ await new Promise(resolve => setTimeout(resolve, 2000));
2160
2222
  }
2161
- const anyAlive = config.workers.some((w) => (config.worker_launch_mode === 'prompt'
2223
+ const anyAliveAfterWait = config.workers.some((w) => (config.worker_launch_mode === 'prompt'
2162
2224
  ? isPromptWorkerAlive(config, w)
2163
2225
  : isWorkerAlive(sessionName, w.index, w.pane_id)));
2164
- if (!anyAlive)
2165
- break;
2166
- // Sleep 2s
2167
- await new Promise(resolve => setTimeout(resolve, 2000));
2168
- }
2169
- const anyAliveAfterWait = config.workers.some((w) => (config.worker_launch_mode === 'prompt'
2170
- ? isPromptWorkerAlive(config, w)
2171
- : isWorkerAlive(sessionName, w.index, w.pane_id)));
2172
- if (anyAliveAfterWait && !force) {
2173
- // Workers may have accepted shutdown but not exited (Codex TUI requires explicit exit).
2174
- // In this case, proceed to force kill panes (next step) rather than failing and leaving state around.
2226
+ if (anyAliveAfterWait && !force) {
2227
+ // Workers may have accepted shutdown but not exited (Codex TUI requires explicit exit).
2228
+ // In this case, proceed to force kill panes (next step) rather than failing and leaving state around.
2229
+ }
2175
2230
  }
2176
2231
  // 3. Force kill remaining workers
2177
2232
  const leaderPaneId = config.leader_pane_id;
@@ -2413,6 +2468,52 @@ async function findActiveTeams(cwd, leaderSessionId) {
2413
2468
  }
2414
2469
  return active;
2415
2470
  }
2471
+ async function detectAndCleanStaleTeam(teamName, leaderCwd, workerCount, confirmFn) {
2472
+ const stateDir = join(leaderCwd, '.omx', 'state', 'team', teamName);
2473
+ if (!existsSync(stateDir))
2474
+ return;
2475
+ const sessions = new Set(listTeamSessions());
2476
+ if (sessions.has(`omx-team-${teamName}`))
2477
+ return;
2478
+ const repoRootResult = spawnSync('git', ['rev-parse', '--show-toplevel'], {
2479
+ cwd: leaderCwd, encoding: 'utf-8', windowsHide: true,
2480
+ });
2481
+ if (repoRootResult.status !== 0)
2482
+ return;
2483
+ const repoRoot = repoRootResult.stdout.trim();
2484
+ const worktreePaths = [];
2485
+ for (let i = 1; i <= workerCount; i++) {
2486
+ const wtPath = join(repoRoot, '.omx', 'team', teamName, 'worktrees', `worker-${i}`);
2487
+ if (existsSync(wtPath))
2488
+ worktreePaths.push(wtPath);
2489
+ }
2490
+ if (worktreePaths.length === 0) {
2491
+ await cleanupTeamState(teamName, leaderCwd);
2492
+ return;
2493
+ }
2494
+ const hasDirtyWorktrees = worktreePaths.some((p) => {
2495
+ try {
2496
+ return isWorktreeDirty(p);
2497
+ }
2498
+ catch {
2499
+ return false;
2500
+ }
2501
+ });
2502
+ const summary = { teamName, worktreePaths, statePath: stateDir, hasDirtyWorktrees };
2503
+ if (!confirmFn) {
2504
+ throw new Error(`stale_team_artifacts:${teamName}:${worktreePaths.length}_worktrees:` +
2505
+ 'pass_confirmStaleCleanup_or_manually_remove');
2506
+ }
2507
+ const confirmed = await confirmFn(summary);
2508
+ if (!confirmed) {
2509
+ throw new Error(`stale_team_cleanup_declined:${teamName}:` +
2510
+ 'manually_remove_worktrees_and_state_before_retrying');
2511
+ }
2512
+ for (const wtPath of worktreePaths) {
2513
+ await removeWorktreeForce(repoRoot, wtPath);
2514
+ }
2515
+ await cleanupTeamState(teamName, leaderCwd);
2516
+ }
2416
2517
  async function resolveLeaderSessionId(cwd) {
2417
2518
  const fromEnv = process.env.OMX_SESSION_ID || process.env.CODEX_SESSION_ID || process.env.SESSION_ID;
2418
2519
  if (fromEnv && fromEnv.trim() !== '')
@@ -2632,7 +2733,7 @@ async function dispatchCriticalInboxInstruction(params) {
2632
2733
  if (receipt?.status === 'failed') {
2633
2734
  const fallback = await notifyWorkerOutcome(config, workerIndex, triggerMessage, paneId);
2634
2735
  if (fallback.ok) {
2635
- await transitionDispatchRequest(teamName, queued.request_id, 'failed', 'failed', { last_reason: `fallback_confirmed_after_failed_receipt:${fallback.reason}` }, cwd).catch(() => { });
2736
+ await transitionDispatchRequest(teamName, queued.request_id, 'pending', 'failed', { last_reason: `fallback_confirmed_after_failed_receipt:${fallback.reason}` }, cwd).catch(() => { });
2636
2737
  return {
2637
2738
  ok: true,
2638
2739
  transport: fallback.transport,
@@ -2658,7 +2759,7 @@ async function dispatchCriticalInboxInstruction(params) {
2658
2759
  if (fallback.ok) {
2659
2760
  const marked = await markDispatchRequestNotified(teamName, queued.request_id, { last_reason: `fallback_confirmed:${fallback.reason}` }, cwd);
2660
2761
  if (!marked) {
2661
- await transitionDispatchRequest(teamName, queued.request_id, 'failed', 'failed', { last_reason: `fallback_confirmed_after_failed_receipt:${fallback.reason}` }, cwd).catch(() => { });
2762
+ await transitionDispatchRequest(teamName, queued.request_id, 'pending', 'failed', { last_reason: `fallback_confirmed_after_failed_receipt:${fallback.reason}` }, cwd).catch(() => { });
2662
2763
  }
2663
2764
  return {
2664
2765
  ok: true,
@@ -2700,7 +2801,7 @@ async function finalizeHookPreferredMailboxDispatch(params) {
2700
2801
  if (receipt?.status === 'failed') {
2701
2802
  if (fallback.ok) {
2702
2803
  await markMessageNotified(teamName, workerName, messageId, cwd).catch(() => false);
2703
- await transitionDispatchRequest(teamName, requestId, 'failed', 'failed', { message_id: messageId, last_reason: `fallback_confirmed_after_failed_receipt:${fallback.reason}` }, cwd).catch(() => { });
2804
+ await transitionDispatchRequest(teamName, requestId, 'failed', 'failed', { message_id: messageId, last_reason: `fallback_confirmed_after_failed_receipt:${fallback.reason}` }, cwd).catch(() => null);
2704
2805
  const outcome = {
2705
2806
  ok: true,
2706
2807
  transport: fallback.transport,