@triflux/remote 10.1.0 → 10.26.0

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 (162) hide show
  1. package/hub/pipe.mjs +239 -121
  2. package/hub/public/dashboard.html +61 -0
  3. package/hub/server.mjs +2179 -709
  4. package/hub/store-adapter.mjs +59 -26
  5. package/hub/store.mjs +356 -153
  6. package/hub/team/agent-map.json +26 -7
  7. package/hub/team/ansi.mjs +245 -146
  8. package/hub/team/backend.mjs +78 -17
  9. package/hub/team/build-worker-prompt.mjs +73 -0
  10. package/hub/team/check-mcp-hub.mjs +49 -0
  11. package/hub/team/claude-daemon-control.mjs +1130 -0
  12. package/hub/team/claude-native-bridge.mjs +1011 -0
  13. package/hub/team/claude-session-projection.mjs +74 -0
  14. package/hub/team/cli/commands/attach.mjs +57 -37
  15. package/hub/team/cli/commands/control.mjs +29 -8
  16. package/hub/team/cli/commands/debug.mjs +95 -74
  17. package/hub/team/cli/commands/focus.mjs +80 -53
  18. package/hub/team/cli/commands/interrupt.mjs +18 -6
  19. package/hub/team/cli/commands/kill.mjs +16 -5
  20. package/hub/team/cli/commands/list.mjs +31 -24
  21. package/hub/team/cli/commands/send.mjs +19 -6
  22. package/hub/team/cli/commands/start/index.mjs +151 -33
  23. package/hub/team/cli/commands/start/parse-args.mjs +97 -13
  24. package/hub/team/cli/commands/start/start-headless.mjs +155 -37
  25. package/hub/team/cli/commands/start/start-in-process.mjs +50 -40
  26. package/hub/team/cli/commands/start/start-mux.mjs +122 -73
  27. package/hub/team/cli/commands/start/start-wt.mjs +86 -69
  28. package/hub/team/cli/commands/status.mjs +43 -14
  29. package/hub/team/cli/commands/stop.mjs +11 -4
  30. package/hub/team/cli/commands/task.mjs +8 -3
  31. package/hub/team/cli/commands/tasks.mjs +13 -13
  32. package/hub/team/cli/help.mjs +1 -1
  33. package/hub/team/cli/index.mjs +2 -2
  34. package/hub/team/cli/manifest.mjs +38 -8
  35. package/hub/team/cli/render.mjs +52 -30
  36. package/hub/team/cli/services/attach-fallback.mjs +74 -54
  37. package/hub/team/cli/services/hub-client.mjs +44 -16
  38. package/hub/team/cli/services/member-selector.mjs +37 -30
  39. package/hub/team/cli/services/native-control.mjs +144 -116
  40. package/hub/team/cli/services/runtime-mode.mjs +61 -4
  41. package/hub/team/cli/services/state-store.mjs +66 -9
  42. package/hub/team/cli/services/task-model.mjs +40 -30
  43. package/hub/team/codex-review.mjs +521 -0
  44. package/hub/team/conductor-mesh-bridge.mjs +25 -24
  45. package/hub/team/conductor-registry.mjs +121 -0
  46. package/hub/team/conductor.mjs +1379 -671
  47. package/hub/team/consensus-meta.mjs +176 -0
  48. package/hub/team/dashboard-anchor.mjs +4 -5
  49. package/hub/team/dashboard-layout.mjs +3 -1
  50. package/hub/team/dashboard-open.mjs +20 -118
  51. package/hub/team/dashboard.mjs +76 -28
  52. package/hub/team/event-log.mjs +18 -10
  53. package/hub/team/execution-mode.mjs +256 -0
  54. package/hub/team/extract-completion-payload.mjs +110 -0
  55. package/hub/team/git-preflight.mjs +286 -0
  56. package/hub/team/handoff.mjs +49 -18
  57. package/hub/team/headless-bridge-session.mjs +17 -0
  58. package/hub/team/headless.mjs +2213 -1156
  59. package/hub/team/health-probe.mjs +232 -69
  60. package/hub/team/interactive-native-launcher.mjs +106 -0
  61. package/hub/team/interactive-native-worker.mjs +80 -0
  62. package/hub/team/interactive-tui-transport.mjs +465 -0
  63. package/hub/team/launcher-template.mjs +49 -17
  64. package/hub/team/lead-control.mjs +91 -4
  65. package/hub/team/mcp-selector.mjs +145 -0
  66. package/hub/team/native-supervisor.mjs +90 -26
  67. package/hub/team/native.mjs +120 -42
  68. package/hub/team/nativeProxy.mjs +184 -113
  69. package/hub/team/notify.mjs +147 -78
  70. package/hub/team/orchestrator.mjs +173 -161
  71. package/hub/team/pane.mjs +43 -20
  72. package/hub/team/process-cleanup.mjs +69 -25
  73. package/hub/team/psmux.mjs +1022 -281
  74. package/hub/team/recovery-store.mjs +149 -0
  75. package/hub/team/remote-probe.mjs +68 -52
  76. package/hub/team/remote-session.mjs +131 -59
  77. package/hub/team/remote-watcher.mjs +61 -33
  78. package/hub/team/retry-state-machine.mjs +336 -0
  79. package/hub/team/routing.mjs +51 -25
  80. package/hub/team/runtime-strategy.mjs +200 -0
  81. package/hub/team/schemas/worker-completion.json +39 -0
  82. package/hub/team/sentinel-capture.mjs +127 -0
  83. package/hub/team/session-sync.mjs +153 -6
  84. package/hub/team/session.mjs +574 -611
  85. package/hub/team/shared.mjs +13 -13
  86. package/hub/team/staleState.mjs +72 -30
  87. package/hub/team/swarm-cli.mjs +390 -0
  88. package/hub/team/swarm-hypervisor.mjs +1544 -193
  89. package/hub/team/swarm-intent.mjs +216 -0
  90. package/hub/team/swarm-locks.mjs +140 -25
  91. package/hub/team/swarm-planner.mjs +121 -21
  92. package/hub/team/swarm-preflight.mjs +319 -0
  93. package/hub/team/swarm-reconciler.mjs +128 -23
  94. package/hub/team/synapse-cli.mjs +458 -0
  95. package/hub/team/synapse-http.mjs +60 -0
  96. package/hub/team/synapse-registry.mjs +329 -0
  97. package/hub/team/terminal-opener.mjs +216 -0
  98. package/hub/team/tui-core.mjs +292 -0
  99. package/hub/team/tui-lite.mjs +237 -148
  100. package/hub/team/tui-remote-adapter.mjs +18 -34
  101. package/hub/team/tui-synapse.mjs +223 -0
  102. package/hub/team/tui-viewer.mjs +108 -48
  103. package/hub/team/tui-widgets.mjs +262 -0
  104. package/hub/team/tui.mjs +931 -439
  105. package/hub/team/worker-completion-validator.mjs +84 -0
  106. package/hub/team/worker-sandbox.mjs +97 -0
  107. package/hub/team/worker-signal.mjs +263 -0
  108. package/hub/team/worker-signal.types.d.ts +52 -0
  109. package/hub/team/worktree-lifecycle.mjs +517 -63
  110. package/hub/team/wt-manager.mjs +297 -26
  111. package/hub/tools.mjs +477 -252
  112. package/hub/tray.mjs +411 -368
  113. package/hub/workers/antigravity-route-worker.mjs +169 -0
  114. package/hub/workers/claude-worker.mjs +162 -118
  115. package/hub/workers/codex-app-server-worker.mjs +1015 -0
  116. package/hub/workers/codex-mcp.mjs +724 -507
  117. package/hub/workers/delegator-mcp.mjs +570 -499
  118. package/hub/workers/factory.mjs +103 -21
  119. package/hub/workers/gemini-worker.mjs +126 -284
  120. package/hub/workers/interface.mjs +24 -2
  121. package/hub/workers/lib/jsonrpc-stdio.mjs +463 -0
  122. package/hub/workers/worker-utils.mjs +23 -17
  123. package/package.json +4 -3
  124. package/scripts/lib/agent-json.mjs +27 -0
  125. package/scripts/lib/async.mjs +174 -0
  126. package/scripts/lib/claudemd-scanner.mjs +223 -0
  127. package/scripts/lib/cli-agy.mjs +62 -0
  128. package/scripts/lib/cli-claude.mjs +78 -0
  129. package/scripts/lib/cli-codex.mjs +199 -0
  130. package/scripts/lib/cli-gemini.mjs +37 -0
  131. package/scripts/lib/codex-recovery.sh +50 -0
  132. package/scripts/lib/context.mjs +67 -0
  133. package/scripts/lib/cross-review-utils.mjs +59 -0
  134. package/scripts/lib/doctor-env-checks.mjs +121 -0
  135. package/scripts/lib/dynamic-route-cli.mjs +107 -0
  136. package/scripts/lib/env-probe.mjs +269 -0
  137. package/scripts/lib/env.mjs +14 -0
  138. package/scripts/lib/gemini-profiles.mjs +119 -0
  139. package/scripts/lib/handoff.mjs +187 -0
  140. package/scripts/lib/hook-utils.mjs +16 -0
  141. package/scripts/lib/hub.mjs +48 -0
  142. package/scripts/lib/keyword-rules.mjs +195 -0
  143. package/scripts/lib/logger.mjs +105 -0
  144. package/scripts/lib/mcp-filter.mjs +883 -0
  145. package/scripts/lib/mcp-gateway-health-check.mjs +159 -0
  146. package/scripts/lib/mcp-gateway-servers.mjs +70 -0
  147. package/scripts/lib/mcp-gateway-wrapper-check.mjs +35 -0
  148. package/scripts/lib/mcp-guard-engine.mjs +2120 -0
  149. package/scripts/lib/mcp-health.mjs +773 -0
  150. package/scripts/lib/mcp-manifest.mjs +89 -0
  151. package/scripts/lib/mcp-server-catalog.mjs +355 -0
  152. package/scripts/lib/pid.mjs +63 -0
  153. package/scripts/lib/process-utils.mjs +26 -0
  154. package/scripts/lib/psmux-info.mjs +224 -0
  155. package/scripts/lib/quota.mjs +47 -0
  156. package/scripts/lib/remote-spawn-transfer.mjs +226 -0
  157. package/scripts/lib/skill-state.mjs +220 -0
  158. package/scripts/lib/skill-template.mjs +375 -0
  159. package/scripts/lib/team.mjs +75 -0
  160. package/scripts/lib/timeout.mjs +66 -0
  161. package/scripts/lib/tmp.mjs +26 -0
  162. package/scripts/lib/toml.mjs +113 -0
@@ -3,34 +3,168 @@
3
3
  // Convention: .codex-swarm/wt-{slug} paths, swarm/{runId}/{slug} branches.
4
4
  // Remote support: host option → SSH-based git operations via remote-session.mjs.
5
5
 
6
- import { execFile } from 'node:child_process';
7
- import { resolve, normalize } from 'node:path';
8
- import { mkdir, rm, access } from 'node:fs/promises';
9
- import { remoteGit, validateHost } from './remote-session.mjs';
6
+ import { execFile } from "node:child_process";
7
+ import { access, mkdir, readdir, realpath, rm } from "node:fs/promises";
8
+ import { normalize, relative, resolve } from "node:path";
9
+ import { remoteGit, validateHost } from "./remote-session.mjs";
10
10
 
11
- const SWARM_ROOT = '.codex-swarm';
11
+ const SWARM_ROOT = ".codex-swarm";
12
12
  const SLEEP_MS = 2000; // WT race-guard (MEMORY.md: wt-attach-spacing)
13
13
 
14
+ // Tracked files intentionally removed from swarm worktrees. Keep this list
15
+ // narrow: .claude-plugin metadata must be preserved for Codex worker startup.
16
+ export const EXPECTED_WORKTREE_DELETIONS = Object.freeze([]);
17
+
18
+ /**
19
+ * Parse `git status --short` output into a dirty-file list. Filters out paths
20
+ * in `expectedDeletions` only when the XY status code indicates a deletion
21
+ * (X='D' or Y='D'). Modifications / additions / untracked of the same paths
22
+ * remain dirty — otherwise a worker could silently corrupt those files and
23
+ * bypass F6 no_commit_guard (Codex review #134 round 2).
24
+ *
25
+ * git status --short XY codes reference:
26
+ * ' D' unstaged delete, 'D ' staged delete, 'DD' both — filtered if path
27
+ * is in expectedDeletions.
28
+ * ' M'/'M '/'MM' modify, '??' untracked, 'A ' add, 'R ' rename — kept.
29
+ *
30
+ * @param {string} rawStatus — stdout of `git status --short`
31
+ * @param {string[]} [expectedDeletions=EXPECTED_WORKTREE_DELETIONS] — paths eligible for deletion-only skip
32
+ * @returns {string[]} — remaining dirty paths after filtering
33
+ */
34
+ export function extractDirtyFiles(
35
+ rawStatus,
36
+ expectedDeletions = EXPECTED_WORKTREE_DELETIONS,
37
+ ) {
38
+ const skip = new Set(expectedDeletions);
39
+ const out = [];
40
+ for (const raw of String(rawStatus ?? "").split(/\r?\n/)) {
41
+ if (raw.length < 3) continue;
42
+ const xy = raw.slice(0, 2);
43
+ const path = raw.slice(2).trim();
44
+ if (!path) continue;
45
+ const isDeletion = xy.includes("D");
46
+ if (isDeletion && skip.has(path)) continue;
47
+ out.push(path);
48
+ }
49
+ return out;
50
+ }
51
+
14
52
  function git(args, cwd) {
15
53
  return new Promise((res, rej) => {
16
- execFile('git', args, { cwd, windowsHide: true, timeout: 30_000 }, (err, stdout, stderr) => {
17
- if (err) {
18
- const msg = `git ${args[0]} failed: ${stderr?.trim() || err.message}`;
19
- rej(new Error(msg));
20
- } else {
21
- res(stdout.trim());
22
- }
23
- });
54
+ execFile(
55
+ "git",
56
+ args,
57
+ {
58
+ cwd,
59
+ windowsHide: true,
60
+ timeout: 30_000,
61
+ env: {
62
+ ...process.env,
63
+ GIT_AUTHOR_NAME: process.env.GIT_AUTHOR_NAME || "Triflux",
64
+ GIT_AUTHOR_EMAIL:
65
+ process.env.GIT_AUTHOR_EMAIL || "triflux@example.invalid",
66
+ GIT_COMMITTER_NAME: process.env.GIT_COMMITTER_NAME || "Triflux",
67
+ GIT_COMMITTER_EMAIL:
68
+ process.env.GIT_COMMITTER_EMAIL || "triflux@example.invalid",
69
+ },
70
+ },
71
+ (err, stdout, stderr) => {
72
+ if (err) {
73
+ const msg = `git ${args[0]} failed: ${stderr?.trim() || err.message}`;
74
+ rej(new Error(msg));
75
+ } else {
76
+ res(stdout.trim());
77
+ }
78
+ },
79
+ );
24
80
  });
25
81
  }
26
82
 
83
+ /**
84
+ * Best-effort shutdown for the Git fsmonitor daemon attached to a worktree.
85
+ * Missing or already-stopped daemons are treated as successful no-ops because
86
+ * cleanup should not be blocked by watcher state.
87
+ *
88
+ * @param {string} worktreePath
89
+ * @param {(args: string[], cwd: string) => Promise<string>} [gitImpl=git]
90
+ * @returns {Promise<{ ok: boolean, stopped: boolean, reason?: 'not_running', error?: string }>}
91
+ */
92
+ async function stopFsmonitorDaemon(worktreePath, gitImpl = git) {
93
+ try {
94
+ await gitImpl(["fsmonitor--daemon", "stop"], worktreePath);
95
+ return { ok: true, stopped: true };
96
+ } catch (err) {
97
+ const msg = String(err?.message || err || "");
98
+ if (
99
+ /not running|not watching|fsmonitor--daemon is not running/iu.test(msg)
100
+ ) {
101
+ return { ok: true, stopped: false, reason: "not_running" };
102
+ }
103
+ return { ok: false, stopped: false, error: msg };
104
+ }
105
+ }
106
+
27
107
  function sleep(ms) {
28
108
  return new Promise((r) => setTimeout(r, ms));
29
109
  }
30
110
 
31
111
  /** Normalize path for Windows compatibility. */
32
112
  function normPath(p) {
33
- return normalize(p).replace(/\\/g, '/');
113
+ return normalize(p).replace(/\\/g, "/");
114
+ }
115
+
116
+ async function normExistingPath(p) {
117
+ try {
118
+ return normPath(await realpath(p));
119
+ } catch {
120
+ return normPath(p);
121
+ }
122
+ }
123
+
124
+ function resolveCleanupTarget(worktreePath, rootDir) {
125
+ const resolvedRoot = resolve(rootDir);
126
+ const resolvedWorktree = resolve(worktreePath);
127
+ const normalizedRoot = normPath(resolvedRoot).replace(/\/+$/u, "");
128
+ const normalizedWorktree = normPath(resolvedWorktree).replace(/\/+$/u, "");
129
+
130
+ if (normalizedWorktree === normalizedRoot) {
131
+ throw new Error("refusing to cleanup the main working tree");
132
+ }
133
+
134
+ const rel = normPath(relative(resolvedRoot, resolvedWorktree));
135
+ if (
136
+ !rel ||
137
+ rel === "." ||
138
+ rel === ".." ||
139
+ rel.startsWith("../") ||
140
+ rel.includes("/../")
141
+ ) {
142
+ throw new Error(
143
+ `refusing to cleanup path outside rootDir: ${worktreePath}`,
144
+ );
145
+ }
146
+
147
+ const allowed =
148
+ rel.startsWith(".codex-swarm/wt-") || rel.startsWith(".triflux/");
149
+ if (!allowed) {
150
+ throw new Error(`refusing to cleanup unsafe path: ${worktreePath}`);
151
+ }
152
+
153
+ return {
154
+ resolvedRoot,
155
+ resolvedWorktree,
156
+ normalizedWorktree,
157
+ };
158
+ }
159
+
160
+ async function branchExists(branchName, rootDir, gitImpl = git) {
161
+ if (!branchName) return false;
162
+ try {
163
+ const listed = await gitImpl(["branch", "--list", branchName], rootDir);
164
+ return listed.trim().length > 0;
165
+ } catch {
166
+ return false;
167
+ }
34
168
  }
35
169
 
36
170
  /**
@@ -45,22 +179,45 @@ function normPath(p) {
45
179
  * @param {object} [opts.remoteEnv] — remote environment from probeRemoteEnv()
46
180
  * @returns {Promise<{ worktreePath: string, branchName: string, remote: boolean }>}
47
181
  */
48
- export async function ensureWorktree({ slug, runId, rootDir = process.cwd(), baseBranch = 'main', host, remoteEnv }) {
182
+ export async function ensureWorktree({
183
+ slug,
184
+ runId,
185
+ rootDir = process.cwd(),
186
+ baseBranch = "main",
187
+ host,
188
+ remoteEnv,
189
+ }) {
49
190
  const branchName = `swarm/${runId}/${slug}`;
50
191
 
51
192
  // ── Remote path: SSH-based worktree creation ──
52
193
  if (host && remoteEnv) {
53
- const remoteRoot = rootDir.replace(/\\/g, '/');
194
+ const remoteRoot = rootDir.replace(/\\/g, "/");
54
195
  const remoteWtDir = `${remoteRoot}/${SWARM_ROOT}/wt-${slug}`;
55
196
 
56
- try { remoteGit(host, remoteEnv, ['worktree', 'prune'], remoteRoot); } catch { /* best-effort */ }
197
+ try {
198
+ remoteGit(host, remoteEnv, ["worktree", "prune"], remoteRoot);
199
+ } catch {
200
+ /* best-effort */
201
+ }
57
202
 
58
203
  try {
59
- remoteGit(host, remoteEnv, ['worktree', 'add', remoteWtDir, '-b', branchName, baseBranch], remoteRoot);
204
+ remoteGit(
205
+ host,
206
+ remoteEnv,
207
+ ["worktree", "add", remoteWtDir, "-b", branchName, baseBranch],
208
+ remoteRoot,
209
+ );
60
210
  } catch {
61
211
  try {
62
- remoteGit(host, remoteEnv, ['worktree', 'add', remoteWtDir, branchName], remoteRoot);
63
- } catch { /* already exists — acceptable */ }
212
+ remoteGit(
213
+ host,
214
+ remoteEnv,
215
+ ["worktree", "add", remoteWtDir, branchName],
216
+ remoteRoot,
217
+ );
218
+ } catch {
219
+ /* already exists — acceptable */
220
+ }
64
221
  }
65
222
 
66
223
  return { worktreePath: remoteWtDir, branchName, remote: true };
@@ -74,22 +231,27 @@ export async function ensureWorktree({ slug, runId, rootDir = process.cwd(), bas
74
231
  // Check if worktree already exists
75
232
  try {
76
233
  await access(wtDir);
77
- await git(['rev-parse', '--is-inside-work-tree'], wtDir);
234
+ await git(["rev-parse", "--is-inside-work-tree"], wtDir);
78
235
  return { worktreePath: normPath(wtDir), branchName, remote: false };
79
236
  } catch {
80
237
  // Doesn't exist or invalid — create fresh
81
238
  }
82
239
 
83
240
  try {
84
- await git(['worktree', 'prune'], rootDir);
85
- } catch { /* best-effort */ }
241
+ await git(["worktree", "prune"], rootDir);
242
+ } catch {
243
+ /* best-effort */
244
+ }
86
245
 
87
246
  await sleep(SLEEP_MS);
88
247
 
89
248
  try {
90
- await git(['worktree', 'add', wtDir, '-b', branchName, baseBranch], rootDir);
249
+ await git(
250
+ ["worktree", "add", wtDir, "-b", branchName, baseBranch],
251
+ rootDir,
252
+ );
91
253
  } catch {
92
- await git(['worktree', 'add', wtDir, branchName], rootDir);
254
+ await git(["worktree", "add", wtDir, branchName], rootDir);
93
255
  }
94
256
 
95
257
  return { worktreePath: normPath(wtDir), branchName, remote: false };
@@ -104,18 +266,22 @@ export async function ensureWorktree({ slug, runId, rootDir = process.cwd(), bas
104
266
  * @param {string} [opts.rootDir=process.cwd()]
105
267
  * @returns {Promise<{ integrationBranch: string, baseCommit: string }>}
106
268
  */
107
- export async function prepareIntegrationBranch({ runId, baseBranch, rootDir = process.cwd() }) {
269
+ export async function prepareIntegrationBranch({
270
+ runId,
271
+ baseBranch,
272
+ rootDir = process.cwd(),
273
+ }) {
108
274
  const integrationBranch = `swarm/${runId}/merge`;
109
275
 
110
276
  // Record base commit for rollback
111
- const baseCommit = await git(['rev-parse', baseBranch], rootDir);
277
+ const baseCommit = await git(["rev-parse", baseBranch], rootDir);
112
278
 
113
279
  // Create integration branch from base
114
280
  try {
115
- await git(['branch', integrationBranch, baseBranch], rootDir);
281
+ await git(["branch", integrationBranch, baseBranch], rootDir);
116
282
  } catch {
117
283
  // Branch may already exist — reset to base
118
- await git(['branch', '-f', integrationBranch, baseBranch], rootDir);
284
+ await git(["branch", "-f", integrationBranch, baseBranch], rootDir);
119
285
  }
120
286
 
121
287
  return { integrationBranch, baseCommit };
@@ -125,71 +291,349 @@ export async function prepareIntegrationBranch({ runId, baseBranch, rootDir = pr
125
291
  * Rebase a shard branch onto the integration branch.
126
292
  * Uses rebase + fast-forward only (no merge commits).
127
293
  *
294
+ * Synapse v1: when a `preflight` checker + `sessionContext` are provided,
295
+ * the rebase is pre-flighted against active sessions' dirty files/leases.
296
+ * Blocked rebases return `{ ok: false, preflight: decision }` without touching
297
+ * any branch. If preflight is omitted, behavior is unchanged.
298
+ *
128
299
  * @param {object} opts
129
300
  * @param {string} opts.shardBranch — the shard's branch name
130
301
  * @param {string} opts.integrationBranch — target integration branch
131
302
  * @param {string} [opts.rootDir=process.cwd()]
132
- * @returns {Promise<{ ok: boolean, headCommit?: string, error?: string }>}
303
+ * @param {{ checkRebase: Function } | null} [opts.preflight=null] — git-preflight instance
304
+ * @param {{ sessionId: string, workerId?: string } | null} [opts.sessionContext=null]
305
+ * @returns {Promise<{ ok: boolean, headCommit?: string, strategy?: string, commits?: number, notes?: string, fallbackReason?: string, error?: string, preflight?: object }>}
133
306
  */
134
- export async function rebaseShardOntoIntegration({ shardBranch, integrationBranch, rootDir = process.cwd() }) {
307
+ export async function rebaseShardOntoIntegration({
308
+ shardBranch,
309
+ integrationBranch,
310
+ rootDir = process.cwd(),
311
+ preflight = null,
312
+ sessionContext = null,
313
+ }) {
314
+ // Synapse v1 pre-flight: bail out before mutating branches when another
315
+ // session claims overlapping files.
316
+ if (preflight && sessionContext) {
317
+ const decision = preflight.checkRebase(
318
+ { branch: integrationBranch },
319
+ sessionContext,
320
+ );
321
+ if (!decision.allowed) {
322
+ return {
323
+ ok: false,
324
+ error: `git-preflight blocked rebase: ${decision.reason || "overlap"}`,
325
+ preflight: decision,
326
+ };
327
+ }
328
+ }
329
+
330
+ // #127 BUG-E: capture caller's branch BEFORE any checkout. Without
331
+ // restoring it in finally, this function leaks main repo HEAD onto
332
+ // integrationBranch and every subsequent Edit/commit lands on the swarm
333
+ // temp branch silently (observed 2026-04-20: probe + edits + commits all
334
+ // ended up on swarm/.../merge while user thought they were on main).
335
+ let originalBranch = null;
336
+ try {
337
+ const head = await git(["rev-parse", "--abbrev-ref", "HEAD"], rootDir);
338
+ if (head && head !== "HEAD") originalBranch = head;
339
+ } catch {
340
+ /* detached HEAD or unknown — skip restore */
341
+ }
342
+
135
343
  // Backup integration HEAD for rollback
136
- const backupCommit = await git(['rev-parse', integrationBranch], rootDir);
344
+ const backupCommit = await git(["rev-parse", integrationBranch], rootDir);
345
+
346
+ let shaList = [];
347
+ let ffError = null;
137
348
 
138
349
  try {
139
- // Rebase shard onto integration
140
- await git(['rebase', integrationBranch, shardBranch], rootDir);
350
+ const log = await git(
351
+ [
352
+ "log",
353
+ "--reverse",
354
+ "--format=%H",
355
+ `${integrationBranch}..${shardBranch}`,
356
+ ],
357
+ rootDir,
358
+ );
359
+ shaList = log
360
+ .split("\n")
361
+ .map((s) => s.trim())
362
+ .filter(Boolean);
363
+
364
+ await git(["checkout", integrationBranch], rootDir);
141
365
 
142
- // Fast-forward integration to include shard changes
143
- await git(['checkout', integrationBranch], rootDir);
144
- await git(['merge', '--ff-only', shardBranch], rootDir);
366
+ try {
367
+ await git(["merge", "--ff-only", shardBranch], rootDir);
368
+ const headCommit = await git(["rev-parse", "HEAD"], rootDir);
369
+ return {
370
+ ok: true,
371
+ headCommit,
372
+ strategy: "auto-ff",
373
+ commits: shaList.length,
374
+ notes: "linear (merge --ff-only)",
375
+ };
376
+ } catch (err) {
377
+ ffError = err;
378
+ try {
379
+ await git(["merge", "--abort"], rootDir);
380
+ } catch {
381
+ /* ff-only usually leaves no merge state */
382
+ }
383
+ try {
384
+ await git(["reset", "--hard", backupCommit], rootDir);
385
+ } catch {
386
+ /* fallback below will report if cherry-pick also fails */
387
+ }
388
+ }
389
+
390
+ // #127: cherry-pick fallback instead of branch merge. Rebase/ff paths can
391
+ // fail when histories diverge or a shard branch is checked out by a swarm
392
+ // worktree. Cherry-pick reads commits without moving the shard branch ref.
393
+ for (const sha of shaList) {
394
+ await git(["cherry-pick", sha], rootDir);
395
+ }
145
396
 
146
- const headCommit = await git(['rev-parse', 'HEAD'], rootDir);
147
- return { ok: true, headCommit };
397
+ const headCommit = await git(["rev-parse", "HEAD"], rootDir);
398
+ return {
399
+ ok: true,
400
+ headCommit,
401
+ strategy: "cherry-pick",
402
+ commits: shaList.length,
403
+ fallbackReason: ffError?.message || null,
404
+ notes: ffError
405
+ ? `auto-ff failed: ${ffError.message}`
406
+ : "cherry-pick fallback",
407
+ };
148
408
  } catch (err) {
149
- // Abort rebase and restore integration branch
150
- try { await git(['rebase', '--abort'], rootDir); } catch { /* already clean */ }
151
- try { await git(['checkout', integrationBranch], rootDir); } catch { /* best-effort */ }
152
- try { await git(['reset', '--hard', backupCommit], rootDir); } catch { /* best-effort */ }
409
+ try {
410
+ await git(["cherry-pick", "--abort"], rootDir);
411
+ } catch {
412
+ /* already clean */
413
+ }
153
414
 
154
- return { ok: false, error: err.message };
415
+ // #129 BUG-J: Roll back integrationBranch without mutating whatever
416
+ // branch HEAD currently points at. The previous implementation did a
417
+ // blind `reset --hard backupCommit` on current HEAD — if the earlier
418
+ // `git checkout integrationBranch` inside the try block failed (e.g.
419
+ // integrationBranch was already checked out by a swarm worktree) the
420
+ // caller's branch silently rewound to integrationBranch's backup commit.
421
+ // Observed 2026-04-20: fix branch tree lost to main HEAD during swarm
422
+ // probe, requiring `git merge --ff-only origin/<branch>` recovery.
423
+ let currentBranch = null;
424
+ try {
425
+ const head = await git(["rev-parse", "--abbrev-ref", "HEAD"], rootDir);
426
+ if (head && head !== "HEAD") currentBranch = head;
427
+ } catch {
428
+ /* detached — fall through to ref-only update */
429
+ }
430
+
431
+ if (currentBranch === integrationBranch) {
432
+ // HEAD is on integrationBranch — reset advances this branch only.
433
+ try {
434
+ await git(["reset", "--hard", backupCommit], rootDir);
435
+ } catch {
436
+ /* best-effort */
437
+ }
438
+ } else {
439
+ // HEAD is elsewhere (originalBranch, detached, or another branch).
440
+ // Update the integrationBranch ref directly; never touch current HEAD.
441
+ try {
442
+ await git(["branch", "-f", integrationBranch, backupCommit], rootDir);
443
+ } catch {
444
+ /* best-effort: branch may be checked out in another worktree */
445
+ }
446
+ }
447
+
448
+ return {
449
+ ok: false,
450
+ error: err.message,
451
+ strategy: "failed",
452
+ commits: shaList.length,
453
+ fallbackReason: ffError?.message || null,
454
+ };
455
+ } finally {
456
+ // Always restore caller's branch. Skip if originalBranch is null
457
+ // (detached HEAD case) or already on target.
458
+ if (originalBranch) {
459
+ try {
460
+ await git(["checkout", originalBranch], rootDir);
461
+ } catch {
462
+ /* best-effort — caller will see HEAD on integrationBranch */
463
+ }
464
+ }
155
465
  }
156
466
  }
157
467
 
158
468
  /**
159
- * Remove a worktree and its branch.
160
- * Follows WT race-guard: sleep between operations.
469
+ * Safely remove a worktree directory and delete its branch.
470
+ * Refuses to touch the main working tree or paths outside known swarm state.
161
471
  *
162
472
  * @param {object} opts
163
473
  * @param {string} opts.worktreePath
164
- * @param {string} [opts.branchName] — optional branch to delete
474
+ * @param {string} [opts.branchName]
165
475
  * @param {string} [opts.rootDir=process.cwd()]
166
476
  * @param {boolean} [opts.force=false]
167
477
  */
168
- export async function pruneWorktree({ worktreePath, branchName, rootDir = process.cwd(), force = false }) {
169
- const forceFlag = force ? '--force' : '';
478
+ export async function cleanupWorktree({
479
+ worktreePath,
480
+ branchName,
481
+ rootDir = process.cwd(),
482
+ force = false,
483
+ _git = git,
484
+ }) {
485
+ const { resolvedWorktree } = resolveCleanupTarget(worktreePath, rootDir);
486
+ const fsmonitorStop = await stopFsmonitorDaemon(resolvedWorktree, _git);
487
+ const forceArgs = force ? ["--force"] : [];
170
488
 
171
- // Remove worktree (with retry for Windows file handle issues — E5)
172
489
  for (let attempt = 0; attempt < 3; attempt++) {
173
490
  try {
174
- await git(['worktree', 'remove', worktreePath, ...(forceFlag ? [forceFlag] : [])], rootDir);
491
+ await _git(
492
+ ["worktree", "remove", ...forceArgs, resolvedWorktree],
493
+ rootDir,
494
+ );
175
495
  break;
176
496
  } catch (_err) {
177
497
  if (attempt === 2) {
178
- // Last resort: rm the directory and prune
179
- try { await rm(worktreePath, { recursive: true, force: true }); } catch { /* ignore */ }
498
+ try {
499
+ await rm(resolvedWorktree, { recursive: true, force: true });
500
+ } catch {
501
+ /* ignore */
502
+ }
180
503
  }
181
504
  await sleep(SLEEP_MS);
182
505
  }
183
506
  }
184
507
 
185
- // Prune stale worktree references
186
508
  await sleep(SLEEP_MS);
187
- try { await git(['worktree', 'prune'], rootDir); } catch { /* best-effort */ }
509
+ try {
510
+ await _git(["worktree", "prune"], rootDir);
511
+ } catch {
512
+ /* best-effort */
513
+ }
188
514
 
189
- // Delete branch if specified
190
- if (branchName) {
191
- try { await git(['branch', '-D', branchName], rootDir); } catch { /* may not exist */ }
515
+ if (branchName && (await branchExists(branchName, rootDir, _git))) {
516
+ try {
517
+ await _git(["branch", "-D", branchName], rootDir);
518
+ } catch {
519
+ /* branch may already be gone */
520
+ }
192
521
  }
522
+
523
+ return { ok: true, fsmonitorStop };
524
+ }
525
+
526
+ /**
527
+ * Remove a worktree and its branch.
528
+ * Follows WT race-guard: sleep between operations.
529
+ *
530
+ * Synapse v1: when `preflight` + `sessionContext` are provided, the removal
531
+ * is pre-flighted against active sessions — blocked if another session is
532
+ * still registered to this worktree path.
533
+ *
534
+ * @param {object} opts
535
+ * @param {string} opts.worktreePath
536
+ * @param {string} [opts.branchName] — optional branch to delete
537
+ * @param {string} [opts.rootDir=process.cwd()]
538
+ * @param {boolean} [opts.force=false]
539
+ * @param {{ checkWorktreeRemove: Function } | null} [opts.preflight=null]
540
+ * @param {{ sessionId: string, workerId?: string } | null} [opts.sessionContext=null]
541
+ */
542
+ export async function pruneWorktree({
543
+ worktreePath,
544
+ branchName,
545
+ rootDir = process.cwd(),
546
+ force = false,
547
+ preflight = null,
548
+ sessionContext = null,
549
+ }) {
550
+ if (preflight && sessionContext) {
551
+ const decision = preflight.checkWorktreeRemove(
552
+ { worktreePath },
553
+ sessionContext,
554
+ );
555
+ if (!decision.allowed) {
556
+ return {
557
+ ok: false,
558
+ error: `git-preflight blocked worktree-remove: ${decision.reason || "active_worktree"}`,
559
+ preflight: decision,
560
+ };
561
+ }
562
+ }
563
+
564
+ const cleanupResult = await cleanupWorktree({
565
+ worktreePath,
566
+ branchName,
567
+ rootDir,
568
+ force,
569
+ });
570
+
571
+ return { ok: true, fsmonitorStop: cleanupResult.fsmonitorStop };
572
+ }
573
+
574
+ /**
575
+ * #34 L3: Detect and remove orphan worktree directories.
576
+ * Compares .codex-swarm/wt-* directories against `git worktree list`.
577
+ * Directories not registered as worktrees are removed.
578
+ *
579
+ * @param {object} [opts]
580
+ * @param {string} [opts.rootDir=process.cwd()]
581
+ * @returns {Promise<string[]>} removed directory names
582
+ */
583
+ export async function pruneOrphanWorktrees({ rootDir = process.cwd() } = {}) {
584
+ const swarmDir = resolve(rootDir, SWARM_ROOT);
585
+ const removed = [];
586
+
587
+ let entries;
588
+ try {
589
+ entries = await readdir(swarmDir);
590
+ } catch {
591
+ return removed; // .codex-swarm/ doesn't exist → nothing to clean
592
+ }
593
+
594
+ const wtDirs = entries.filter((e) => e.startsWith("wt-"));
595
+ if (wtDirs.length === 0) return removed;
596
+
597
+ // Get registered worktree paths from git
598
+ let registeredPaths;
599
+ try {
600
+ const raw = await git(["worktree", "list", "--porcelain"], rootDir);
601
+ registeredPaths = new Set(
602
+ await Promise.all(
603
+ raw
604
+ .split("\n")
605
+ .filter((l) => l.startsWith("worktree "))
606
+ .map((l) => normExistingPath(l.slice("worktree ".length))),
607
+ ),
608
+ );
609
+ } catch {
610
+ return removed; // git worktree list failed → don't remove anything
611
+ }
612
+
613
+ for (const dir of wtDirs) {
614
+ const fullPath = resolve(swarmDir, dir);
615
+ const normalized = await normExistingPath(fullPath);
616
+ if (!registeredPaths.has(normalized)) {
617
+ try {
618
+ await stopFsmonitorDaemon(fullPath).catch(() => null);
619
+ await rm(fullPath, { recursive: true, force: true });
620
+ removed.push(dir);
621
+ } catch {
622
+ /* best-effort */
623
+ }
624
+ }
625
+ }
626
+
627
+ // Prune stale git references
628
+ if (removed.length > 0) {
629
+ try {
630
+ await git(["worktree", "prune"], rootDir);
631
+ } catch {
632
+ /* best-effort */
633
+ }
634
+ }
635
+
636
+ return removed;
193
637
  }
194
638
 
195
639
  /**
@@ -206,24 +650,34 @@ export async function pruneWorktree({ worktreePath, branchName, rootDir = proces
206
650
  * @param {string} [opts.rootDir=process.cwd()] — local repo root
207
651
  * @returns {Promise<{ ok: boolean, localRef?: string, error?: string }>}
208
652
  */
209
- export async function fetchRemoteShard({ host, sshUser, remoteRepoPath, branchName, rootDir = process.cwd() }) {
653
+ export async function fetchRemoteShard({
654
+ host,
655
+ sshUser,
656
+ remoteRepoPath,
657
+ branchName,
658
+ rootDir = process.cwd(),
659
+ }) {
210
660
  validateHost(host);
211
661
 
212
662
  const remoteName = `_swarm-${host}-${Date.now()}`;
213
663
  const sshUrl = `ssh://${sshUser}@${host}${remoteRepoPath}`;
214
664
 
215
665
  try {
216
- await git(['remote', 'add', remoteName, sshUrl], rootDir);
666
+ await git(["remote", "add", remoteName, sshUrl], rootDir);
217
667
 
218
- await git(['fetch', remoteName, branchName, '--no-tags'], rootDir);
668
+ await git(["fetch", remoteName, branchName, "--no-tags"], rootDir);
219
669
 
220
670
  const localRef = `${remoteName}/${branchName}`;
221
- const headCommit = await git(['rev-parse', `FETCH_HEAD`], rootDir);
671
+ const headCommit = await git(["rev-parse", `FETCH_HEAD`], rootDir);
222
672
 
223
673
  return { ok: true, localRef, headCommit };
224
674
  } catch (err) {
225
675
  return { ok: false, error: err.message };
226
676
  } finally {
227
- try { await git(['remote', 'remove', remoteName], rootDir); } catch { /* cleanup */ }
677
+ try {
678
+ await git(["remote", "remove", remoteName], rootDir);
679
+ } catch {
680
+ /* cleanup */
681
+ }
228
682
  }
229
683
  }