@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
@@ -0,0 +1,256 @@
1
+ // hub/team/execution-mode.mjs — headless vs interactive execution mode selection
2
+
3
+ import { existsSync, readFileSync } from "node:fs";
4
+ import { dirname, resolve as pathResolve } from "node:path";
5
+
6
+ import { whichCommand } from "@triflux/core/hub/platform.mjs";
7
+
8
+ const WIN32_EXT_PRECEDENCE = [".cmd", ".exe", ".bat", ".ps1"];
9
+
10
+ // #128: parse npm-cmd-shim wrapper to extract the underlying .js entry point.
11
+ // npm shim wrappers end with: "%_prog%" "%dp0%\node_modules\...\<cli>.js" %*
12
+ // %* is cmd batch arg pass-through which mangles multi-line / fenced prompts.
13
+ // Bypass cmd entirely by spawning node + the .js directly.
14
+ export function unwrapCmdToJsScript(cmdPath, opts = {}) {
15
+ const readFile = opts.readFile || readFileSync;
16
+ const existsFn = opts.existsSyncFn || existsSync;
17
+ try {
18
+ const content = readFile(cmdPath, "utf8");
19
+ const match = content.match(/"%dp0%[\\/]([^"]+\.[cm]?js)"/i);
20
+ if (!match) return null;
21
+ const dir = dirname(cmdPath);
22
+ const jsPath = pathResolve(dir, match[1].replace(/\\/g, "/"));
23
+ return existsFn(jsPath) ? jsPath : null;
24
+ } catch {
25
+ return null;
26
+ }
27
+ }
28
+
29
+ export const MODES = Object.freeze({
30
+ HEADLESS: "headless",
31
+ INTERACTIVE: "interactive",
32
+ AUTO: "auto",
33
+ });
34
+
35
+ function asPrompt(prompt) {
36
+ return typeof prompt === "string" ? prompt : "";
37
+ }
38
+
39
+ function pushFlag(args, flag, value) {
40
+ if (typeof value === "string" && value.length > 0) {
41
+ args.push(flag, value);
42
+ }
43
+ }
44
+
45
+ // #116-C: codex headless 의 argv-inline prompt 가 macOS oh-my-codex hook
46
+ // SessionStart Failed 회귀를 유발 (codex CLI v0.130.0). cli-adapter-base 의
47
+ // buildExecCommand 가 shell command 형태에서 동일 fix 를 적용했고
48
+ // (TFX_CODEX_STDIN_PROMPT=0 opt-out), 본 함수의 spawn-args 형태에서도 같은
49
+ // 정신으로 prompt 를 stdin transport 로 분리한다. 동일 env var 를 재사용해
50
+ // 사용자가 한 곳만 끄면 양쪽 path 모두 비활성.
51
+ export function resolveStdinPromptMode(explicit, envSource) {
52
+ if (typeof explicit === "boolean") return explicit;
53
+ const env = (envSource || process.env).TFX_CODEX_STDIN_PROMPT;
54
+ if (env === "0" || env === "false") return false;
55
+ return true;
56
+ }
57
+
58
+ function normalizeCli(cli) {
59
+ const value = String(cli || "codex").toLowerCase();
60
+ if (value === "gemini" || value === "antigravity" || value === "agy") {
61
+ return "antigravity";
62
+ }
63
+ return value;
64
+ }
65
+
66
+ export function resolveCliExecutable(cli, opts = {}) {
67
+ const name = String(cli || "codex");
68
+ const resolveCommand = opts.resolveCommand || whichCommand;
69
+ const resolved = resolveCommand(name) || name;
70
+
71
+ // Windows: Node spawn({ shell: false }) calls CreateProcess directly and does NOT
72
+ // search PATHEXT. npm-installed CLIs (codex, gemini) live at `<npm>/codex` (Git Bash
73
+ // shell script) alongside `<npm>/codex.cmd` (Windows batch wrapper). whichCommand
74
+ // returns the extensionless path, which Windows cannot execute → ENOENT "The system
75
+ // cannot find the file specified." Append the correct extension when the resolved
76
+ // path has none.
77
+ const platform = opts.platform || process.platform;
78
+ if (platform === "win32" && resolved) {
79
+ const hasExt = /\.[^\\/.]+$/.test(resolved);
80
+ if (!hasExt) {
81
+ const existsFn = opts.existsSyncFn || existsSync;
82
+ for (const ext of WIN32_EXT_PRECEDENCE) {
83
+ const candidate = `${resolved}${ext}`;
84
+ try {
85
+ if (existsFn(candidate)) return candidate;
86
+ } catch {
87
+ // ignore stat failures, try next extension
88
+ }
89
+ }
90
+ }
91
+ }
92
+
93
+ return resolved;
94
+ }
95
+
96
+ export function buildSpawnSpecForMode(mode, opts = {}) {
97
+ const cli = normalizeCli(opts.cli || "codex");
98
+ const prompt = asPrompt(opts.prompt);
99
+ const commandName = cli === "antigravity" ? "agy" : cli;
100
+ const resolvedCommand = resolveCliExecutable(commandName, opts);
101
+ const platform = opts.platform || process.platform;
102
+
103
+ // Node v20.12+ (CVE-2024-27980) rejects spawn of .cmd/.bat files with shell:false
104
+ // (EINVAL). npm-installed Windows wrappers (e.g. codex.cmd) hit this.
105
+ //
106
+ // #128 (BUG-A): the cmd /c wrapper used to be the workaround, but cmd batch
107
+ // %* arg pass-through mangles multi-line and fenced (```bash) prompts before
108
+ // they reach the underlying .js. We now unwrap the .cmd to the node script
109
+ // it launches and spawn `node <script>` directly, bypassing cmd entirely.
110
+ // Falls back to cmd /c if the .cmd cannot be parsed.
111
+ const needsCmdWrap =
112
+ platform === "win32" && /\.(cmd|bat)$/i.test(resolvedCommand);
113
+ const unwrappedJs = needsCmdWrap
114
+ ? (opts.unwrapCmdFn || unwrapCmdToJsScript)(resolvedCommand)
115
+ : null;
116
+ const wrap = (args) => {
117
+ if (unwrappedJs) {
118
+ const nodeBin = opts.nodeExecPath || process.execPath;
119
+ return { command: nodeBin, args: [unwrappedJs, ...args] };
120
+ }
121
+ if (needsCmdWrap) {
122
+ return { command: "cmd", args: ["/c", resolvedCommand, ...args] };
123
+ }
124
+ return { command: resolvedCommand, args };
125
+ };
126
+
127
+ if (cli === "antigravity") {
128
+ const args = ["--print", "--dangerously-skip-permissions"];
129
+ return {
130
+ ...wrap(args),
131
+ useExec: true,
132
+ shell: false,
133
+ stdinPrompt: prompt.length > 0,
134
+ prompt,
135
+ };
136
+ }
137
+
138
+ if (mode === MODES.INTERACTIVE || mode === MODES.AUTO) {
139
+ return { ...wrap([]), useExec: false, shell: false };
140
+ }
141
+
142
+ if (cli === "claude") {
143
+ const args = [];
144
+ pushFlag(args, "--model", opts.model);
145
+ args.push("-p", prompt);
146
+ return { ...wrap(args), useExec: true, shell: false };
147
+ }
148
+
149
+ const args = [];
150
+ pushFlag(args, "--profile", opts.profile);
151
+ args.push(
152
+ "exec",
153
+ "--dangerously-bypass-approvals-and-sandbox",
154
+ "--skip-git-repo-check",
155
+ "--color",
156
+ "never",
157
+ );
158
+ if (Array.isArray(opts.mcpServers)) {
159
+ for (const server of opts.mcpServers) {
160
+ args.push("-c", `mcp_servers.${server}.enabled=true`);
161
+ }
162
+ }
163
+
164
+ // #116-C: codex headless 의 argv-inline prompt → macOS oh-my-codex hook
165
+ // SessionStart Failed 회귀 (codex v0.130.0). default 가 stdin transport.
166
+ // conductor.respawnSession 이 spec.prompt 를 child.stdin.write 로 주입한다.
167
+ const useStdin =
168
+ resolveStdinPromptMode(opts.stdinPrompt, opts.env) && prompt.length > 0;
169
+ if (useStdin) {
170
+ return {
171
+ ...wrap(args),
172
+ useExec: true,
173
+ shell: false,
174
+ stdinPrompt: true,
175
+ prompt,
176
+ };
177
+ }
178
+
179
+ args.push(prompt);
180
+ return { ...wrap(args), useExec: true, shell: false };
181
+ }
182
+
183
+ /**
184
+ * @param {{
185
+ * cli: "codex"|"gemini"|"antigravity"|"agy"|"claude",
186
+ * taskType?: "implement"|"review"|"research"|"test"|"analyze",
187
+ * needsInput?: boolean,
188
+ * estimatedDuration?: number,
189
+ * hasHub?: boolean,
190
+ * }} opts
191
+ * @returns {{ mode: string, reason: string }}
192
+ */
193
+ export function selectExecutionMode(opts = {}) {
194
+ const {
195
+ cli: rawCli = "codex",
196
+ taskType = "research",
197
+ needsInput = false,
198
+ estimatedDuration = 0,
199
+ hasHub = false,
200
+ } = opts;
201
+
202
+ const cli = normalizeCli(rawCli);
203
+
204
+ if (!hasHub) {
205
+ return {
206
+ mode: MODES.HEADLESS,
207
+ reason: "interactive mode requires hub; falling back to headless",
208
+ };
209
+ }
210
+
211
+ if (cli === "antigravity") {
212
+ return {
213
+ mode: MODES.HEADLESS,
214
+ reason: "antigravity CLI uses headless stdin print mode",
215
+ };
216
+ }
217
+
218
+ if (taskType === "implement" && !needsInput) {
219
+ return {
220
+ mode: MODES.HEADLESS,
221
+ reason: "implementation without expected input fits headless execution",
222
+ };
223
+ }
224
+
225
+ if (taskType === "review" || taskType === "analyze") {
226
+ return {
227
+ mode: MODES.HEADLESS,
228
+ reason: "review and analyze tasks default to headless execution",
229
+ };
230
+ }
231
+
232
+ if (needsInput === true) {
233
+ return {
234
+ mode: MODES.INTERACTIVE,
235
+ reason: "task may require operator input during execution",
236
+ };
237
+ }
238
+
239
+ if (estimatedDuration > 300) {
240
+ return {
241
+ mode: MODES.INTERACTIVE,
242
+ reason: "long-running work benefits from monitored interactive mode",
243
+ };
244
+ }
245
+
246
+ return {
247
+ mode: MODES.HEADLESS,
248
+ reason: "defaulting to headless execution",
249
+ };
250
+ }
251
+
252
+ // buildCommandForMode 는 caller 가 없어 제거됨 (v10.20.x). argv-inline 프롬프트와
253
+ // `-s danger-full-access` flag 가 PR #252 의 stdin redirect 패턴과 정합하지
254
+ // 않아 회귀 위험이 있었다. 실제 헤드리스 spawn 은 buildSpawnSpecForMode
255
+ // (conductor.mjs:544) 가 담당하며, 셸 명령 형태가 필요한 경우는
256
+ // hub/cli-adapter-base.mjs:buildExecCommand 를 사용한다.
@@ -0,0 +1,110 @@
1
+ // hub/team/extract-completion-payload.mjs — worker stdout tail → completion JSON
2
+ //
3
+ // Two-tier extraction:
4
+ // 1) Sentinel-framed (preferred, #125): worker emits the payload between
5
+ // `<<<TFX_COMPLETION_BEGIN>>>` and `<<<TFX_COMPLETION_END>>>` markers
6
+ // that each occupy their own line. If a standalone-line BEGIN is present
7
+ // but END is missing, this is a deterministic truncation signal and we
8
+ // return null instead of falling back. Substring matches inside log
9
+ // lines (e.g. `[debug] saw <<<TFX_COMPLETION_BEGIN>>> earlier`) do NOT
10
+ // trigger Tier 1 (Codex R1 LOW finding).
11
+ // 2) Brace-scan fallback (legacy, #115): for workers that haven't adopted
12
+ // the sentinel protocol yet (or for unit fixtures), scan the tail for
13
+ // the latest `{...}` pair that parses as a JSON object.
14
+ //
15
+ // The hypervisor's F7 guard (validateWorkerCompletion) decides whether the
16
+ // parsed payload is semantically valid — this layer only handles extraction.
17
+
18
+ import { SENTINEL_BEGIN, SENTINEL_END } from "./sentinel-capture.mjs";
19
+
20
+ function isLineEdgeChar(ch) {
21
+ return ch === "\n" || ch === "\r";
22
+ }
23
+
24
+ function findStandaloneMarkerLast(text, marker) {
25
+ let from = 0;
26
+ let last = -1;
27
+ while (from <= text.length - marker.length) {
28
+ const idx = text.indexOf(marker, from);
29
+ if (idx === -1) return last;
30
+ const before = idx === 0 || isLineEdgeChar(text[idx - 1]);
31
+ const afterIdx = idx + marker.length;
32
+ const after = afterIdx === text.length || isLineEdgeChar(text[afterIdx]);
33
+ if (before && after) last = idx;
34
+ from = idx + 1;
35
+ }
36
+ return last;
37
+ }
38
+
39
+ function findStandaloneMarkerFrom(text, marker, fromIdx) {
40
+ let from = fromIdx;
41
+ while (from <= text.length - marker.length) {
42
+ const idx = text.indexOf(marker, from);
43
+ if (idx === -1) return -1;
44
+ const before = idx === 0 || isLineEdgeChar(text[idx - 1]);
45
+ const afterIdx = idx + marker.length;
46
+ const after = afterIdx === text.length || isLineEdgeChar(text[afterIdx]);
47
+ if (before && after) return idx;
48
+ from = idx + 1;
49
+ }
50
+ return -1;
51
+ }
52
+
53
+ /**
54
+ * @param {string} tail raw stdout (sentinel snapshot or 16 KiB sliding tail)
55
+ * @returns {{payload: object} | null}
56
+ */
57
+ export function extractCompletionPayload(tail) {
58
+ if (typeof tail !== "string" || tail.length === 0) return null;
59
+
60
+ // Tier 1: sentinel-framed. Use last standalone-line BEGIN so a worker that
61
+ // emitted multiple payloads (retry, debug log) yields the most recent one.
62
+ const beginIdx = findStandaloneMarkerLast(tail, SENTINEL_BEGIN);
63
+ if (beginIdx !== -1) {
64
+ const innerStart = beginIdx + SENTINEL_BEGIN.length;
65
+ const endIdx = findStandaloneMarkerFrom(tail, SENTINEL_END, innerStart);
66
+ if (endIdx === -1) return null; // BEGIN without END → truncation/incomplete
67
+ const inner = tail.slice(innerStart, endIdx).trim();
68
+ try {
69
+ const parsed = JSON.parse(inner);
70
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
71
+ return { payload: parsed };
72
+ }
73
+ } catch {
74
+ // Sentinel-framed but invalid JSON — do not fall back to brace scan,
75
+ // because the worker explicitly tried (and failed) to use the protocol.
76
+ }
77
+ return null;
78
+ }
79
+
80
+ // Tier 2: brace-scan fallback. Iterate from the latest `}` backward; for
81
+ // each close, try every `{` that sits before it (nearest first, widening
82
+ // outward). Tolerates trailing noise containing `}` (`noise } more`) and
83
+ // dangling `{` (`{ broken`) after the real payload.
84
+ const closes = [];
85
+ const opens = [];
86
+ for (let i = 0; i < tail.length; i += 1) {
87
+ const ch = tail[i];
88
+ if (ch === "}") closes.push(i);
89
+ else if (ch === "{") opens.push(i);
90
+ }
91
+ if (closes.length === 0 || opens.length === 0) return null;
92
+
93
+ for (let c = closes.length - 1; c >= 0; c -= 1) {
94
+ const closeIdx = closes[c];
95
+ for (let o = opens.length - 1; o >= 0; o -= 1) {
96
+ const openIdx = opens[o];
97
+ if (openIdx >= closeIdx) continue;
98
+ const candidate = tail.slice(openIdx, closeIdx + 1);
99
+ try {
100
+ const parsed = JSON.parse(candidate);
101
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
102
+ return { payload: parsed };
103
+ }
104
+ } catch {
105
+ // try an earlier `{` for this close, then an earlier `}`.
106
+ }
107
+ }
108
+ }
109
+ return null;
110
+ }
@@ -0,0 +1,286 @@
1
+ // hub/team/git-preflight.mjs — Pre-flight safety check for dangerous git ops.
2
+ // Blocks checkout/rebase/cherry-pick/reset/stash-pop/worktree-remove when they
3
+ // would conflict with other active Synapse sessions' dirty files or claimed
4
+ // leases. Fail-open by default: if registry/locks are unavailable, allow the op
5
+ // with a warning (worktree isolation is the baseline safety net).
6
+
7
+ /**
8
+ * Factory for git pre-flight check.
9
+ *
10
+ * @param {object} opts
11
+ * @param {{ getActive: () => Array<object> }} opts.registry — synapse-registry duck type
12
+ * @param {{ snapshot: () => Array<object> }} opts.locks — swarm-locks duck type
13
+ * @param {boolean} [opts.failOpen=true] — allow op when registry/locks throw or are missing
14
+ * @param {(level: string, msg: string, data?: object) => void} [opts.logger] — optional logger
15
+ * @returns {{
16
+ * check: Function,
17
+ * checkRebase: Function,
18
+ * checkCheckout: Function,
19
+ * checkCherryPick: Function,
20
+ * checkReset: Function,
21
+ * checkStashPop: Function,
22
+ * checkWorktreeRemove: Function,
23
+ * }}
24
+ */
25
+ export function createGitPreflight(opts = {}) {
26
+ const { registry, locks, failOpen = true, logger = null } = opts;
27
+
28
+ function log(level, msg, data) {
29
+ if (logger) {
30
+ try {
31
+ logger(level, msg, data);
32
+ } catch {
33
+ /* logger failure is non-fatal */
34
+ }
35
+ return;
36
+ }
37
+ if (level === "warn") {
38
+ console.warn(`[git-preflight] ${msg}`, data ?? "");
39
+ } else if (level === "error") {
40
+ console.error(`[git-preflight] ${msg}`, data ?? "");
41
+ }
42
+ }
43
+
44
+ // ── helpers ──────────────────────────────────────────────
45
+
46
+ function safeGetActive() {
47
+ try {
48
+ const value = registry?.getActive?.();
49
+ if (!Array.isArray(value)) return null;
50
+ return value;
51
+ } catch (err) {
52
+ log("warn", "registry.getActive threw", { error: err?.message });
53
+ return null;
54
+ }
55
+ }
56
+
57
+ function safeSnapshot() {
58
+ try {
59
+ const value = locks?.snapshot?.();
60
+ if (!Array.isArray(value)) return null;
61
+ return value;
62
+ } catch (err) {
63
+ log("warn", "locks.snapshot threw", { error: err?.message });
64
+ return null;
65
+ }
66
+ }
67
+
68
+ function failOpenDecision(reason) {
69
+ log("warn", "hub unavailable, failing open", { reason });
70
+ return { allowed: true, reason: "hub_unavailable_fail_open" };
71
+ }
72
+
73
+ function normalizeFiles(list) {
74
+ if (!Array.isArray(list)) return new Set();
75
+ return new Set(
76
+ list
77
+ .filter((f) => typeof f === "string" && f.length > 0)
78
+ .map((f) => f.replace(/\\/g, "/")),
79
+ );
80
+ }
81
+
82
+ function otherActiveSessions(active, sessionId) {
83
+ return active.filter((s) => s?.sessionId && s.sessionId !== sessionId);
84
+ }
85
+
86
+ function otherLeases(snapshot, workerId) {
87
+ return snapshot.filter(
88
+ (entry) => entry?.workerId && entry.workerId !== workerId,
89
+ );
90
+ }
91
+
92
+ function buildConflict(file, session, holder) {
93
+ return {
94
+ file,
95
+ activeSession: session?.sessionId ?? holder ?? "unknown",
96
+ activeTask: session?.taskSummary ?? "",
97
+ ...(holder ? { leaseHolder: holder } : {}),
98
+ };
99
+ }
100
+
101
+ function recommend(conflicts) {
102
+ if (!conflicts.length) return "";
103
+ const first = conflicts[0];
104
+ const owner = first.leaseHolder || first.activeSession;
105
+ const task = first.activeTask ? ` Active task: '${first.activeTask}'` : "";
106
+ return `Wait for '${owner}' to finish, or coordinate via HITL.${task}`;
107
+ }
108
+
109
+ function blockedDecision(op, conflicts, extraReason) {
110
+ return {
111
+ allowed: false,
112
+ reason: extraReason || "overlap_with_active_session",
113
+ conflicts,
114
+ recommendation: recommend(conflicts),
115
+ op,
116
+ };
117
+ }
118
+
119
+ function allowedDecision() {
120
+ return { allowed: true };
121
+ }
122
+
123
+ // ── shared scanners ─────────────────────────────────────
124
+
125
+ function findDirtyFileConflicts(sessionId, workerId) {
126
+ const active = safeGetActive();
127
+ const snapshot = safeSnapshot();
128
+
129
+ if (active == null || snapshot == null) {
130
+ return failOpen ? { fallOpen: true } : { fallOpen: false, conflicts: [] };
131
+ }
132
+
133
+ const conflicts = [];
134
+ const seen = new Set();
135
+
136
+ for (const session of otherActiveSessions(active, sessionId)) {
137
+ const dirty = normalizeFiles(session.dirtyFiles);
138
+ for (const file of dirty) {
139
+ if (seen.has(file)) continue;
140
+ seen.add(file);
141
+ conflicts.push(buildConflict(file, session));
142
+ }
143
+ }
144
+
145
+ for (const lease of otherLeases(snapshot, workerId)) {
146
+ if (lease.leaseType && lease.leaseType !== "exclusive") continue;
147
+ const file = typeof lease.file === "string" ? lease.file : null;
148
+ if (!file || seen.has(file)) continue;
149
+ seen.add(file);
150
+ const sessionMeta = lease.sessionMeta || null;
151
+ conflicts.push(
152
+ buildConflict(
153
+ file,
154
+ sessionMeta
155
+ ? {
156
+ sessionId: sessionMeta.sessionId || lease.workerId,
157
+ taskSummary: sessionMeta.taskSummary || "",
158
+ }
159
+ : null,
160
+ lease.workerId,
161
+ ),
162
+ );
163
+ }
164
+
165
+ return { conflicts };
166
+ }
167
+
168
+ function findTargetFileConflicts(targetFiles, sessionId, workerId) {
169
+ const active = safeGetActive();
170
+ const snapshot = safeSnapshot();
171
+
172
+ if (active == null || snapshot == null) {
173
+ return failOpen ? { fallOpen: true } : { fallOpen: false, conflicts: [] };
174
+ }
175
+
176
+ const targets = normalizeFiles(targetFiles);
177
+ if (targets.size === 0) return { conflicts: [] };
178
+
179
+ const conflicts = [];
180
+ const seen = new Set();
181
+
182
+ for (const session of otherActiveSessions(active, sessionId)) {
183
+ const dirty = normalizeFiles(session.dirtyFiles);
184
+ for (const file of dirty) {
185
+ if (!targets.has(file) || seen.has(file)) continue;
186
+ seen.add(file);
187
+ conflicts.push(buildConflict(file, session));
188
+ }
189
+ }
190
+
191
+ for (const lease of otherLeases(snapshot, workerId)) {
192
+ if (lease.leaseType && lease.leaseType !== "exclusive") continue;
193
+ const file = typeof lease.file === "string" ? lease.file : null;
194
+ if (!file || !targets.has(file) || seen.has(file)) continue;
195
+ seen.add(file);
196
+ const sessionMeta = lease.sessionMeta || null;
197
+ conflicts.push(
198
+ buildConflict(
199
+ file,
200
+ sessionMeta
201
+ ? {
202
+ sessionId: sessionMeta.sessionId || lease.workerId,
203
+ taskSummary: sessionMeta.taskSummary || "",
204
+ }
205
+ : null,
206
+ lease.workerId,
207
+ ),
208
+ );
209
+ }
210
+
211
+ return { conflicts };
212
+ }
213
+
214
+ // ── public API ──────────────────────────────────────────
215
+
216
+ /**
217
+ * Core pre-flight check.
218
+ *
219
+ * @param {"checkout"|"rebase"|"cherry-pick"|"reset"|"stash-pop"|"worktree-remove"} op
220
+ * @param {{ targetFiles?: string[], branch?: string, ref?: string, worktreePath?: string }} args
221
+ * @param {{ sessionId: string, workerId?: string }} sessionContext
222
+ * @returns {{ allowed: boolean, reason?: string, conflicts?: object[], recommendation?: string, op?: string }}
223
+ */
224
+ function check(op, args = {}, sessionContext = {}) {
225
+ const sessionId = sessionContext.sessionId || "";
226
+ const workerId = sessionContext.workerId || sessionId;
227
+
228
+ switch (op) {
229
+ case "checkout":
230
+ case "rebase":
231
+ case "reset":
232
+ case "stash-pop": {
233
+ const res = findDirtyFileConflicts(sessionId, workerId);
234
+ if (res.fallOpen) return failOpenDecision(op);
235
+ if (res.conflicts.length > 0) return blockedDecision(op, res.conflicts);
236
+ return allowedDecision();
237
+ }
238
+ case "cherry-pick": {
239
+ const targets = Array.isArray(args.targetFiles) ? args.targetFiles : [];
240
+ const res = findTargetFileConflicts(targets, sessionId, workerId);
241
+ if (res.fallOpen) return failOpenDecision(op);
242
+ if (res.conflicts.length > 0) return blockedDecision(op, res.conflicts);
243
+ return allowedDecision();
244
+ }
245
+ case "worktree-remove": {
246
+ const active = safeGetActive();
247
+ if (active == null) return failOpenDecision(op);
248
+ const target =
249
+ typeof args.worktreePath === "string"
250
+ ? args.worktreePath.replace(/\\/g, "/")
251
+ : "";
252
+ if (!target) return allowedDecision();
253
+ const conflicts = [];
254
+ for (const session of otherActiveSessions(active, sessionId)) {
255
+ const sessionPath =
256
+ typeof session.worktreePath === "string"
257
+ ? session.worktreePath.replace(/\\/g, "/")
258
+ : "";
259
+ if (sessionPath && sessionPath === target) {
260
+ conflicts.push({
261
+ file: target,
262
+ activeSession: session.sessionId,
263
+ activeTask: session.taskSummary || "",
264
+ });
265
+ }
266
+ }
267
+ if (conflicts.length > 0) {
268
+ return blockedDecision(op, conflicts, "active_worktree");
269
+ }
270
+ return allowedDecision();
271
+ }
272
+ default:
273
+ return allowedDecision();
274
+ }
275
+ }
276
+
277
+ return Object.freeze({
278
+ check,
279
+ checkRebase: (args, ctx) => check("rebase", args, ctx),
280
+ checkCheckout: (args, ctx) => check("checkout", args, ctx),
281
+ checkCherryPick: (args, ctx) => check("cherry-pick", args, ctx),
282
+ checkReset: (args, ctx) => check("reset", args, ctx),
283
+ checkStashPop: (args, ctx) => check("stash-pop", args, ctx),
284
+ checkWorktreeRemove: (args, ctx) => check("worktree-remove", args, ctx),
285
+ });
286
+ }