@triflux/remote 10.45.0 → 10.47.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.
package/hub/server.mjs CHANGED
@@ -72,9 +72,7 @@ import {
72
72
  } from "@triflux/core/hub/state.mjs";
73
73
  import { createStoreAdapter } from "./store-adapter.mjs";
74
74
  import { createCtoAutoCollector } from "./team/cto-auto-collect.mjs";
75
- import { createGitPreflight } from "./team/git-preflight.mjs";
76
75
  import { nativeProxy } from "./team/nativeProxy.mjs";
77
- import { createSwarmLocks } from "./team/swarm-locks.mjs";
78
76
  import {
79
77
  createSynapseRegistry,
80
78
  projectPeer,
@@ -119,14 +117,6 @@ const AIMD_WINDOW_MS = 30 * 60 * 1000;
119
117
  const AIMD_INITIAL_BATCH_SIZE = 3;
120
118
  const AIMD_MIN_BATCH_SIZE = 1;
121
119
  const AIMD_MAX_BATCH_SIZE = 10;
122
- const SYNAPSE_VALID_OPS = new Set([
123
- "checkout",
124
- "rebase",
125
- "cherry-pick",
126
- "reset",
127
- "stash-pop",
128
- "worktree-remove",
129
- ]);
130
120
  const HUB_IDLE_TIMEOUT_DEFAULT_MS = 30 * 60 * 1000;
131
121
  const HUB_IDLE_SWEEP_DEFAULT_MS = 60 * 1000;
132
122
  const STATIC_CONTENT_TYPES = Object.freeze({
@@ -1124,21 +1114,13 @@ export async function startHub({
1124
1114
  }
1125
1115
  const delegatorService = new DelegatorService({ worker: delegatorWorker });
1126
1116
 
1127
- // Synapse Layer 4: session registry + git preflight + swarm locks
1117
+ // Synapse Layer 4: session registry
1128
1118
  const synapseEmitter = new EventEmitter();
1129
1119
  synapseEmitter.setMaxListeners(50);
1130
1120
  const synapseRegistry = createSynapseRegistry({
1131
1121
  persistPath: join(CACHE_DIR, "tfx-hub", "synapse-sessions.json"),
1132
1122
  emitter: synapseEmitter,
1133
1123
  });
1134
- const swarmLocks = createSwarmLocks({
1135
- repoRoot: PROJECT_ROOT,
1136
- persistPath: join(CACHE_DIR, "tfx-hub", "swarm-locks.json"),
1137
- });
1138
- const gitPreflight = createGitPreflight({
1139
- registry: synapseRegistry,
1140
- locks: swarmLocks,
1141
- });
1142
1124
  const ctoAutoCollector = createCtoAutoCollector({
1143
1125
  registry: synapseRegistry,
1144
1126
  runCollect,
@@ -1437,7 +1419,7 @@ export async function startHub({
1437
1419
  });
1438
1420
  }
1439
1421
 
1440
- // ── Synapse Layer 5: session registry + locks + preflight routes ──
1422
+ // ── Synapse Layer 5: session registry routes ──
1441
1423
  // Admin/raw snapshot (loopback-only). Returns raw cwd/pid for local
1442
1424
  // admin/HUD use; the redacted peer surface is GET /synapse/peers.
1443
1425
  if (path === "/synapse/sessions" && req.method === "GET") {
@@ -1642,37 +1624,6 @@ export async function startHub({
1642
1624
  }
1643
1625
  }
1644
1626
 
1645
- if (path === "/synapse/locks" && req.method === "GET") {
1646
- return writeJson(res, 200, {
1647
- ok: true,
1648
- locks: swarmLocks.snapshot(),
1649
- ts: Date.now(),
1650
- });
1651
- }
1652
-
1653
- if (path === "/synapse/preflight" && req.method === "POST") {
1654
- try {
1655
- const body = await parseBody(req);
1656
- const { op, args = {}, sessionContext = {} } = body;
1657
- if (!op || typeof op !== "string") {
1658
- return writeJson(res, 400, { ok: false, error: "op 필수" });
1659
- }
1660
- if (!SYNAPSE_VALID_OPS.has(op)) {
1661
- return writeJson(res, 400, {
1662
- ok: false,
1663
- error: `invalid op: ${op}`,
1664
- });
1665
- }
1666
- const result = gitPreflight.check(op, args, sessionContext);
1667
- return writeJson(res, 200, { ok: true, ...result });
1668
- } catch (err) {
1669
- return writeJson(res, 400, {
1670
- ok: false,
1671
- error: String(err?.message || err),
1672
- });
1673
- }
1674
- }
1675
-
1676
1627
  if (path.startsWith("/bridge")) {
1677
1628
  const isBridgeStatusGet =
1678
1629
  path === "/bridge/status" && req.method === "GET";
@@ -1,6 +1,6 @@
1
1
  // hub/team/recovery-store.mjs — Best-effort preservation of worker changes
2
2
  // before a dirty worktree is removed. Uses file-based `.patch` + JSON manifest
3
- // instead of git stash (git-preflight flags stash-pop/worktree-remove as risky).
3
+ // instead of git stash, which is risky to pop under concurrent worktree use.
4
4
 
5
5
  import { execFile } from "node:child_process";
6
6
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
@@ -23,7 +23,10 @@ import {
23
23
  } from "node:fs";
24
24
  import { dirname, join } from "node:path";
25
25
 
26
- import { normalizeCodexProfileName } from "../../scripts/lib/codex-profile-config.mjs";
26
+ import {
27
+ canonicalCodexProfileModel,
28
+ normalizeCodexProfileName,
29
+ } from "../../scripts/lib/codex-profile-config.mjs";
27
30
 
28
31
  export const STATES = Object.freeze({
29
32
  PLANNING: "PLANNING",
@@ -318,6 +321,11 @@ function normalizeEscalationChainEntry(entry, index, source) {
318
321
  const profile = entry.profile.trim();
319
322
  normalized.profile =
320
323
  entry.cli === "codex" ? normalizeEscalationProfile(profile) : profile;
324
+ // A remapped legacy profile must not keep reporting its old model.
325
+ if (normalized.profile !== profile) {
326
+ normalized.model =
327
+ canonicalCodexProfileModel(normalized.profile) ?? normalized.model;
328
+ }
321
329
  }
322
330
  return normalized;
323
331
  }
@@ -48,9 +48,9 @@ function hasDirtyFiles(session) {
48
48
  // A session is "live" while active OR idle. Idle is an interactive session that
49
49
  // missed its heartbeat interval but is still under the TTL — alive but inactive,
50
50
  // not presumed dead. getActive() and querySessions() share this single predicate
51
- // so the liveness contract can't drift: getActive() feeds git-preflight's
52
- // dirty-file conflict guard, so dropping idle there would hide a still-live
53
- // interactive session's claimed paths from that safety check.
51
+ // so the liveness contract can't drift: getActive() feeds peer discovery,
52
+ // CTO status/hygiene and log retention, so dropping idle there would hide a
53
+ // still-live interactive session from every one of those consumers.
54
54
  function isLiveStatus(status) {
55
55
  return status === "active" || status === "idle";
56
56
  }
@@ -337,7 +337,7 @@ export function createSynapseRegistry(opts = {}) {
337
337
  // session resuming: Claude Code re-fires SessionStart with the same
338
338
  // session_id on resume/clear/compact, so fall through and re-register
339
339
  // (revive) it. Otherwise the resumed-but-live session stays stale forever
340
- // and vanishes from peer-discovery AND git-preflight's dirty-file guard.
340
+ // and vanishes from peer-discovery.
341
341
  const existing = sessions.get(sessionId);
342
342
  if (existing && isLiveStatus(existing.status)) {
343
343
  console.warn(
@@ -381,10 +381,13 @@ export function createSynapseRegistry(opts = {}) {
381
381
  }
382
382
 
383
383
  // stale/expired 세션이 cutoff 넘게 누적되면 Map에서 제거.
384
- // live(active/idle)는 lastHeartbeat가 오래돼도 보존 — git-preflight dirty-file 가드가 의존.
385
- // Dirty stale rows keep the historical 24h window because same-id resume
386
- // revives their dirty-file guard; clean stale rows expire quickly to avoid
387
- // daily dummy rows after overnight operator gaps.
384
+ // live(active/idle) rows survive a long-stale lastHeartbeat because peer
385
+ // discovery and CTO status/hygiene still read them.
386
+ // Dirty stale rows keep the historical 24h window. Its original consumer,
387
+ // the git-preflight dirty-file guard, is gone and nothing populates
388
+ // dirtyFiles today, so this split is a candidate for collapsing into one
389
+ // window. Clean stale rows expire quickly to avoid daily dummy rows after
390
+ // overnight operator gaps.
388
391
  function pruneExpired(opts2 = {}) {
389
392
  if (destroyed) return { removed: [], count: 0 };
390
393
 
@@ -510,9 +513,9 @@ export function createSynapseRegistry(opts = {}) {
510
513
  }
511
514
 
512
515
  function getActive() {
513
- // Live = active or idle. git-preflight uses this to detect other live
514
- // sessions whose dirty files would conflict; an idle interactive session is
515
- // still live (process alive, dirty files on disk) and must stay visible.
516
+ // Live = active or idle. Peer discovery, CTO status/hygiene and log
517
+ // retention read this list; an idle interactive session is still live
518
+ // (process alive) and must stay visible to them.
516
519
  return [...sessions.values()]
517
520
  .filter((session) => isLiveStatus(session.status))
518
521
  .map((session) => cloneSession(session));
@@ -1,9 +1,9 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import crypto from "node:crypto";
3
- import { mkdtemp, rm, stat } from "node:fs/promises";
3
+ import { mkdtemp, realpath, rm, stat } from "node:fs/promises";
4
4
  import net from "node:net";
5
5
  import { tmpdir } from "node:os";
6
- import { join as pathJoin } from "node:path";
6
+ import { join as pathJoin, resolve as pathResolve } from "node:path";
7
7
 
8
8
  import { execute as defaultExecuteCodex } from "@triflux/core/hub/codex-adapter.mjs";
9
9
  import {
@@ -23,6 +23,378 @@ const DEFAULT_TIMEOUT_MS = 90_000;
23
23
  const DEFAULT_CODEX_APP_SERVER_UDS_TIMEOUT_MS = 120_000;
24
24
  const DEFAULT_CODEX_APP_SERVER_UDS_BOOTSTRAP_MS = 12_000;
25
25
 
26
+ function codexClient(socketPath, timeoutMs, clientFactory) {
27
+ return clientFactory({ socketPath, connectTimeoutMs: timeoutMs });
28
+ }
29
+
30
+ async function initializeCodexClient(client, timeoutMs) {
31
+ await client.connect();
32
+ await client.request(
33
+ "initialize",
34
+ { clientInfo: { name: "triflux-tfx-live", version: "1.0.0" } },
35
+ timeoutMs,
36
+ );
37
+ client.notify("initialized", {});
38
+ }
39
+
40
+ async function runCodexAppServerTurn({
41
+ client,
42
+ threadId,
43
+ prompt,
44
+ timeoutMs,
45
+ maxTurnMs,
46
+ expectedTurnId,
47
+ }) {
48
+ const messages = new Map();
49
+ const pending = [];
50
+ let turnId = null;
51
+ let startedTurnId = null;
52
+ let retries = 0;
53
+ let settled = false;
54
+ let timer = null;
55
+ let resolveCompletion;
56
+ const completion = new Promise((resolve) => {
57
+ resolveCompletion = resolve;
58
+ });
59
+ const lifecycle = createActivityLifecycle({
60
+ enabled: isActivityLifecycleEnabled(),
61
+ interventionMs: timeoutMs,
62
+ hardCeilingMs: maxTurnMs,
63
+ });
64
+ const finish = (result) => {
65
+ if (settled) return;
66
+ settled = true;
67
+ resolveCompletion(result);
68
+ };
69
+ const matching = (params) =>
70
+ (!params?.threadId || params.threadId === threadId) &&
71
+ (!params?.turnId || params.turnId === turnId) &&
72
+ (!params?.turn?.id || params.turn.id === turnId);
73
+ const receive = (kind, params) => {
74
+ if (!turnId) {
75
+ pending.push([kind, params]);
76
+ return;
77
+ }
78
+ if (!matching(params)) return;
79
+ if (kind === "started" && params?.turn?.id === turnId) {
80
+ startedTurnId = turnId;
81
+ } else if (kind === "delta" && typeof params?.delta === "string") {
82
+ const itemId = params?.itemId || "__unidentified_agent_message__";
83
+ if (!messages.has(itemId))
84
+ messages.set(itemId, { text: "", phase: null });
85
+ messages.get(itemId).text += params.delta;
86
+ lifecycle.observe();
87
+ } else if (
88
+ kind === "itemCompleted" &&
89
+ params?.item?.type === "agentMessage"
90
+ ) {
91
+ const itemId = params.item.id;
92
+ if (itemId) {
93
+ if (!messages.has(itemId))
94
+ messages.set(itemId, { text: "", phase: null });
95
+ const message = messages.get(itemId);
96
+ if (typeof params.item.text === "string")
97
+ message.text = params.item.text;
98
+ message.phase = params.item.phase || null;
99
+ }
100
+ } else if (kind === "completed" && params?.turn?.id === turnId) {
101
+ finish({
102
+ status: params?.turn?.status || "unknown",
103
+ matchedCompletion: true,
104
+ });
105
+ } else if (kind === "error") {
106
+ if (params?.willRetry === true) {
107
+ retries++;
108
+ lifecycle.observe();
109
+ return;
110
+ }
111
+ finish({
112
+ status: "failed",
113
+ message:
114
+ params?.error?.message || params?.message || "codex app-server error",
115
+ matchedCompletion: false,
116
+ });
117
+ }
118
+ };
119
+ const offDelta = client.onNotification("item/agentMessage/delta", (p) =>
120
+ receive("delta", p),
121
+ );
122
+ const offItemCompleted = client.onNotification("item/completed", (p) =>
123
+ receive("itemCompleted", p),
124
+ );
125
+ const offStarted = client.onNotification("turn/started", (p) =>
126
+ receive("started", p),
127
+ );
128
+ const offDone = client.onNotification("turn/completed", (p) =>
129
+ receive("completed", p),
130
+ );
131
+ const offError = client.onNotification("error", (p) => receive("error", p));
132
+ const offActivity = client.onNotification("*", (params) => {
133
+ if (matching(params)) lifecycle.observe();
134
+ });
135
+ const armTimer = () => {
136
+ timer = setTimeout(async () => {
137
+ if (settled) return;
138
+ if (!isActivityLifecycleEnabled()) {
139
+ finish({ status: "timeout", matchedCompletion: false });
140
+ return;
141
+ }
142
+ const reason = await lifecycle.check();
143
+ if (settled) return;
144
+ if (reason)
145
+ finish({ status: "timeout", reason, matchedCompletion: false });
146
+ else armTimer();
147
+ }, timeoutMs);
148
+ };
149
+ armTimer();
150
+ try {
151
+ const input = [{ type: "text", text: prompt }];
152
+ const started = expectedTurnId
153
+ ? await client.request(
154
+ "turn/steer",
155
+ { threadId, input, expectedTurnId },
156
+ timeoutMs,
157
+ )
158
+ : await client.request("turn/start", { threadId, input }, timeoutMs);
159
+ turnId = started?.turn?.id || started?.turnId;
160
+ if (!turnId)
161
+ throw new Error("codex app-server turn/start returned no turn id");
162
+ for (const [kind, params] of pending) receive(kind, params);
163
+ const result = await completion;
164
+ const entries = [...messages.values()].map((item) => ({
165
+ ...item,
166
+ text: item.text.trim(),
167
+ }));
168
+ const finals = entries.filter((item) => item.phase === "final_answer");
169
+ const answer = finals.length ? finals : entries.slice(-1);
170
+ const commentary = entries.filter(
171
+ (item) => item.phase === "commentary" && item.text,
172
+ );
173
+ return {
174
+ ...result,
175
+ turnId,
176
+ response: answer
177
+ .map((item) => item.text)
178
+ .filter(Boolean)
179
+ .join("\n\n"),
180
+ commentary: commentary.map((item) => item.text),
181
+ retries,
182
+ steered:
183
+ result.matchedCompletion &&
184
+ (expectedTurnId === turnId || startedTurnId !== turnId),
185
+ timedOut: result.status === "timeout",
186
+ };
187
+ } finally {
188
+ clearTimeout(timer);
189
+ offDelta();
190
+ offItemCompleted();
191
+ offStarted();
192
+ offDone();
193
+ offError();
194
+ offActivity();
195
+ }
196
+ }
197
+
198
+ async function readCodexThread(client, threadId, timeoutMs) {
199
+ const result = await client.request("thread/read", { threadId }, timeoutMs);
200
+ if (!result?.thread)
201
+ throw new Error(`codex thread/read returned no thread: ${threadId}`);
202
+ return result.thread;
203
+ }
204
+
205
+ async function normalizedCwd(cwd) {
206
+ if (!cwd) return null;
207
+ try {
208
+ return await realpath(cwd);
209
+ } catch (error) {
210
+ if (error?.code === "ENOENT") return pathResolve(cwd);
211
+ throw error;
212
+ }
213
+ }
214
+
215
+ async function loadedCodexThreads(client, cwd, timeoutMs) {
216
+ const wantedCwd = await normalizedCwd(cwd);
217
+ const threads = [];
218
+ let cursor;
219
+ do {
220
+ const result = await client.request(
221
+ "thread/loaded/list",
222
+ cursor ? { cursor } : {},
223
+ timeoutMs,
224
+ );
225
+ for (const threadId of result?.data || []) {
226
+ const thread = await readCodexThread(client, threadId, timeoutMs);
227
+ if (wantedCwd && (await normalizedCwd(thread.cwd)) !== wantedCwd)
228
+ continue;
229
+ threads.push({
230
+ threadId,
231
+ cwd: thread.cwd,
232
+ name: thread.name,
233
+ status: thread.status,
234
+ source: thread.source,
235
+ });
236
+ }
237
+ cursor = result?.nextCursor;
238
+ } while (cursor);
239
+ return threads;
240
+ }
241
+
242
+ export async function listCodexAppServerThreads({
243
+ socketPath,
244
+ cwd,
245
+ clientFactory = (opts) => new JsonRpcWsUdsClient(opts),
246
+ } = {}) {
247
+ const client = codexClient(
248
+ socketPath,
249
+ DEFAULT_CODEX_APP_SERVER_UDS_BOOTSTRAP_MS,
250
+ clientFactory,
251
+ );
252
+ try {
253
+ await initializeCodexClient(
254
+ client,
255
+ DEFAULT_CODEX_APP_SERVER_UDS_BOOTSTRAP_MS,
256
+ );
257
+ return await loadedCodexThreads(
258
+ client,
259
+ cwd,
260
+ DEFAULT_CODEX_APP_SERVER_UDS_BOOTSTRAP_MS,
261
+ );
262
+ } finally {
263
+ client.close();
264
+ }
265
+ }
266
+
267
+ export async function askCodexAppServerThread({
268
+ socketPath,
269
+ threadId,
270
+ cwd,
271
+ prompt,
272
+ timeoutMs = DEFAULT_CODEX_APP_SERVER_UDS_TIMEOUT_MS,
273
+ maxTurnMs = Math.min(resolveHardCeilingMs(), 15 * 60_000),
274
+ ifBusy = "wait",
275
+ busyTimeoutMs = timeoutMs,
276
+ pollIntervalMs = 200,
277
+ clientFactory = (opts) => new JsonRpcWsUdsClient(opts),
278
+ } = {}) {
279
+ if (typeof prompt !== "string" || !prompt.trim())
280
+ throw new Error("codex UDS ask requires a non-empty prompt");
281
+ if (!threadId) throw new Error("codex UDS ask requires --thread <id|auto>");
282
+ if (!["wait", "fail", "steer"].includes(ifBusy))
283
+ throw new Error(`invalid --if-busy: ${ifBusy}`);
284
+ const client = codexClient(
285
+ socketPath,
286
+ DEFAULT_CODEX_APP_SERVER_UDS_BOOTSTRAP_MS,
287
+ clientFactory,
288
+ );
289
+ try {
290
+ await initializeCodexClient(
291
+ client,
292
+ DEFAULT_CODEX_APP_SERVER_UDS_BOOTSTRAP_MS,
293
+ );
294
+ let selectedId = threadId;
295
+ if (threadId === "auto") {
296
+ const candidates = await loadedCodexThreads(
297
+ client,
298
+ cwd,
299
+ DEFAULT_CODEX_APP_SERVER_UDS_BOOTSTRAP_MS,
300
+ );
301
+ if (!candidates.length)
302
+ throw new Error(
303
+ "no loaded Codex threads; open the TUI and send its first message before using --thread auto",
304
+ );
305
+ if (candidates.length !== 1)
306
+ throw new Error(
307
+ `multiple loaded Codex threads; pass --thread ID: ${JSON.stringify(candidates)}`,
308
+ );
309
+ selectedId = candidates[0].threadId;
310
+ }
311
+ const busyStarted = Date.now();
312
+ let thread = await readCodexThread(
313
+ client,
314
+ selectedId,
315
+ DEFAULT_CODEX_APP_SERVER_UDS_BOOTSTRAP_MS,
316
+ );
317
+ const initiallyBusy = thread.status?.type === "active";
318
+ if (thread.status?.type === "active" && ifBusy === "fail")
319
+ throw new Error(`target busy (--if-busy fail): ${selectedId}`);
320
+ const busyDeadline = busyStarted + busyTimeoutMs;
321
+ const busyError = () =>
322
+ new Error(
323
+ `target busy after ${Math.ceil(busyTimeoutMs / 1000)}s (--if-busy wait)`,
324
+ );
325
+ while (thread.status?.type === "active" && ifBusy === "wait") {
326
+ const waitMs = Math.min(
327
+ pollIntervalMs,
328
+ Math.max(0, busyDeadline - Date.now()),
329
+ );
330
+ if (waitMs > 0)
331
+ await new Promise((resolve) => setTimeout(resolve, waitMs));
332
+ const remainingMs = busyDeadline - Date.now();
333
+ try {
334
+ thread = await readCodexThread(
335
+ client,
336
+ selectedId,
337
+ Math.min(
338
+ DEFAULT_CODEX_APP_SERVER_UDS_BOOTSTRAP_MS,
339
+ Math.max(1, remainingMs),
340
+ ),
341
+ );
342
+ } catch (error) {
343
+ if (Date.now() >= busyDeadline) throw busyError();
344
+ throw error;
345
+ }
346
+ if (Date.now() >= busyDeadline && thread.status?.type === "active")
347
+ throw busyError();
348
+ }
349
+ const busyWaitedMs =
350
+ ifBusy === "wait" && initiallyBusy ? Date.now() - busyStarted : 0;
351
+ const resumed = await client.request(
352
+ "thread/resume",
353
+ { threadId: selectedId, excludeTurns: true },
354
+ DEFAULT_CODEX_APP_SERVER_UDS_BOOTSTRAP_MS,
355
+ );
356
+ let activeTurns = null;
357
+ if (resumed?.thread?.status?.type === "active")
358
+ activeTurns = resumed.thread.turns?.length
359
+ ? resumed.thread.turns
360
+ : thread.turns;
361
+ else if (!resumed?.thread && thread.status?.type === "active")
362
+ activeTurns = thread.turns;
363
+ const expectedTurnId =
364
+ ifBusy === "steer"
365
+ ? activeTurns?.findLast((turn) => turn.status === "inProgress")?.id
366
+ : undefined;
367
+ const turn = await runCodexAppServerTurn({
368
+ client,
369
+ threadId: selectedId,
370
+ prompt,
371
+ timeoutMs,
372
+ maxTurnMs,
373
+ expectedTurnId,
374
+ });
375
+ if (turn.message) throw new Error(turn.message);
376
+ return {
377
+ cli: "codex",
378
+ transport: "uds",
379
+ threadId: selectedId,
380
+ turnId: turn.turnId,
381
+ response: turn.response,
382
+ done: turn.status === "completed",
383
+ matchedCompletion: turn.matchedCompletion,
384
+ timedOut: turn.timedOut,
385
+ status: turn.status,
386
+ commentary: turn.commentary,
387
+ meta: { retries: turn.retries },
388
+ ifBusy,
389
+ busyWaitedMs,
390
+ steered: turn.steered,
391
+ socketPath,
392
+ };
393
+ } finally {
394
+ client.close();
395
+ }
396
+ }
397
+
26
398
  function requireEndpoint(endpoint, name) {
27
399
  if (!endpoint || typeof endpoint.ask !== "function") {
28
400
  throw new Error(`${name} endpoint with ask(prompt) is required`);
@@ -532,17 +904,8 @@ export function createCodexAppServerUdsEndpoint({
532
904
  );
533
905
  }
534
906
 
535
- client = clientFactory({
536
- socketPath: resolvedSock,
537
- connectTimeoutMs: bootstrapTimeoutMs,
538
- });
539
- await client.connect();
540
- await client.request(
541
- "initialize",
542
- { clientInfo: { name: "triflux-tfx-live", version: "1.0.0" } },
543
- bootstrapTimeoutMs,
544
- );
545
- client.notify("initialized", {});
907
+ client = codexClient(resolvedSock, bootstrapTimeoutMs, clientFactory);
908
+ await initializeCodexClient(client, bootstrapTimeoutMs);
546
909
 
547
910
  const threadParams = {
548
911
  sandbox: "read-only",
@@ -563,85 +926,23 @@ export function createCodexAppServerUdsEndpoint({
563
926
  );
564
927
  }
565
928
 
566
- const parts = [];
567
- let offDelta = () => {};
568
- let offError = () => {};
569
- let offDone = () => {};
570
- let offActivity = () => {};
571
- const cleanup = () => {
572
- offDelta();
573
- offError();
574
- offDone();
575
- offActivity();
576
- };
577
- const completion = new Promise((resolve) => {
578
- offDelta = client.onNotification(
579
- "item/agentMessage/delta",
580
- (params) => {
581
- if (typeof params?.delta === "string") parts.push(params.delta);
582
- },
583
- );
584
- offError = client.onNotification("error", (params) => {
585
- cleanup();
586
- resolve({
587
- status: "failed",
588
- message: params?.message || "codex app-server error",
589
- });
590
- });
591
- offDone = client.onNotification("turn/completed", (params) => {
592
- cleanup();
593
- resolve({ status: params?.turn?.status || "unknown" });
594
- });
595
- });
596
-
597
- await client.request(
598
- "turn/start",
599
- { threadId, input: [{ type: "text", text: prompt }] },
929
+ const settled = await runCodexAppServerTurn({
930
+ client,
931
+ threadId,
932
+ prompt,
600
933
  timeoutMs,
601
- );
602
- const activityEnabled = isActivityLifecycleEnabled();
603
- const lifecycle = createActivityLifecycle({
604
- enabled: activityEnabled,
605
- interventionMs: timeoutMs,
606
- hardCeilingMs: maxTurnMs,
607
- });
608
- offActivity = client.onNotification("*", () => lifecycle.observe());
609
- let timeoutHandle = null;
610
- const timeoutPromise = new Promise((resolve) => {
611
- const arm = () => {
612
- timeoutHandle = setTimeout(async () => {
613
- if (!activityEnabled) {
614
- cleanup();
615
- resolve({ status: "timeout" });
616
- return;
617
- }
618
- const reason = await lifecycle.check();
619
- if (reason) {
620
- cleanup();
621
- resolve({ status: "timeout", reason });
622
- return;
623
- }
624
- arm();
625
- }, timeoutMs);
626
- };
627
- arm();
934
+ maxTurnMs,
628
935
  });
629
- let settled;
630
- try {
631
- settled = await Promise.race([completion, timeoutPromise]);
632
- } finally {
633
- // Clear the timer whichever side won, so a completed turn does not
634
- // keep the event loop alive until timeoutMs.
635
- if (timeoutHandle) clearTimeout(timeoutHandle);
636
- }
637
936
 
638
937
  return {
639
938
  endpoint: "codex-app-server-uds",
640
- text: parts.join("").trim(),
939
+ text: settled.response,
641
940
  done: settled.status === "completed",
642
941
  meta: {
643
942
  threadId,
644
943
  status: settled.status,
944
+ retries: settled.retries,
945
+ commentary: settled.commentary,
645
946
  socketPath: resolvedSock,
646
947
  spawned: Boolean(child),
647
948
  ...(settled.message ? { error: settled.message } : {}),
@@ -273,6 +273,8 @@ export async function ensureWorktree({
273
273
  * @param {string} opts.baseBranch
274
274
  * @param {string} [opts.rootDir=process.cwd()]
275
275
  * @returns {Promise<{ integrationBranch: string, baseCommit: string }>}
276
+ * @throws {Error} when the integration branch is checked out by another
277
+ * worktree; this is an intentional NO-GO and leaves that worktree untouched.
276
278
  */
277
279
  export async function prepareIntegrationBranch({
278
280
  runId,
@@ -299,42 +301,17 @@ export async function prepareIntegrationBranch({
299
301
  * Rebase a shard branch onto the integration branch.
300
302
  * Uses rebase + fast-forward only (no merge commits).
301
303
  *
302
- * Synapse v1: when a `preflight` checker + `sessionContext` are provided,
303
- * the rebase is pre-flighted against active sessions' dirty files/leases.
304
- * Blocked rebases return `{ ok: false, preflight: decision }` without touching
305
- * any branch. If preflight is omitted, behavior is unchanged.
306
- *
307
304
  * @param {object} opts
308
305
  * @param {string} opts.shardBranch — the shard's branch name
309
306
  * @param {string} opts.integrationBranch — target integration branch
310
307
  * @param {string} [opts.rootDir=process.cwd()]
311
- * @param {{ checkRebase: Function } | null} [opts.preflight=null] — git-preflight instance
312
- * @param {{ sessionId: string, workerId?: string } | null} [opts.sessionContext=null]
313
- * @returns {Promise<{ ok: boolean, headCommit?: string, strategy?: string, commits?: number, notes?: string, fallbackReason?: string, error?: string, preflight?: object }>}
308
+ * @returns {Promise<{ ok: boolean, headCommit?: string, strategy?: string, commits?: number, notes?: string, fallbackReason?: string, error?: string }>}
314
309
  */
315
310
  export async function rebaseShardOntoIntegration({
316
311
  shardBranch,
317
312
  integrationBranch,
318
313
  rootDir = process.cwd(),
319
- preflight = null,
320
- sessionContext = null,
321
314
  }) {
322
- // Synapse v1 pre-flight: bail out before mutating branches when another
323
- // session claims overlapping files.
324
- if (preflight && sessionContext) {
325
- const decision = preflight.checkRebase(
326
- { branch: integrationBranch },
327
- sessionContext,
328
- );
329
- if (!decision.allowed) {
330
- return {
331
- ok: false,
332
- error: `git-preflight blocked rebase: ${decision.reason || "overlap"}`,
333
- preflight: decision,
334
- };
335
- }
336
- }
337
-
338
315
  // #127 BUG-E accident history: this function used to checkout the
339
316
  // integration branch in rootDir, leak rootDir HEAD, and redirect later user
340
317
  // edits/commits into swarm/.../merge. Integration now happens exclusively in
@@ -458,6 +435,11 @@ export async function rebaseShardOntoIntegration({
458
435
  }
459
436
  } else {
460
437
  await rm(integrationWorktree, { recursive: true, force: true });
438
+ try {
439
+ await git(["worktree", "prune"], rootDir);
440
+ } catch {
441
+ /* best-effort metadata cleanup after a partial worktree add */
442
+ }
461
443
  }
462
444
  }
463
445
  }
@@ -524,40 +506,18 @@ export async function cleanupWorktree({
524
506
  * Remove a worktree and its branch.
525
507
  * Follows WT race-guard: sleep between operations.
526
508
  *
527
- * Synapse v1: when `preflight` + `sessionContext` are provided, the removal
528
- * is pre-flighted against active sessions — blocked if another session is
529
- * still registered to this worktree path.
530
- *
531
509
  * @param {object} opts
532
510
  * @param {string} opts.worktreePath
533
511
  * @param {string} [opts.branchName] — optional branch to delete
534
512
  * @param {string} [opts.rootDir=process.cwd()]
535
513
  * @param {boolean} [opts.force=false]
536
- * @param {{ checkWorktreeRemove: Function } | null} [opts.preflight=null]
537
- * @param {{ sessionId: string, workerId?: string } | null} [opts.sessionContext=null]
538
514
  */
539
515
  export async function pruneWorktree({
540
516
  worktreePath,
541
517
  branchName,
542
518
  rootDir = process.cwd(),
543
519
  force = false,
544
- preflight = null,
545
- sessionContext = null,
546
520
  }) {
547
- if (preflight && sessionContext) {
548
- const decision = preflight.checkWorktreeRemove(
549
- { worktreePath },
550
- sessionContext,
551
- );
552
- if (!decision.allowed) {
553
- return {
554
- ok: false,
555
- error: `git-preflight blocked worktree-remove: ${decision.reason || "active_worktree"}`,
556
- preflight: decision,
557
- };
558
- }
559
- }
560
-
561
521
  const cleanupResult = await cleanupWorktree({
562
522
  worktreePath,
563
523
  branchName,
@@ -120,7 +120,7 @@ export function spawnTrayForHub({
120
120
  cwd = process.cwd(),
121
121
  spawnFn = spawn,
122
122
  } = {}) {
123
- if (env?.TFX_HUB_AUTO_TRAY === "0") return { status: "disabled" };
123
+ if (env?.TFX_HUB_AUTO_TRAY !== "1") return { status: "disabled" };
124
124
  if (isWorktreeOrEphemeralHubContext({ cwd, env })) {
125
125
  return { status: "disabled", reason: "ephemeral-or-worktree-context" };
126
126
  }
@@ -120,7 +120,7 @@ function readTomlScalar(raw, key) {
120
120
  }
121
121
 
122
122
  /**
123
- * Resolve a Codex effort profile name (e.g. "gpt56_terra_high") to its concrete
123
+ * Resolve a Codex effort profile name (e.g. "gpt6_sol_high") to its concrete
124
124
  * `model` + reasoning effort. SSOT = the per-profile file written by
125
125
  * `tfx setup` at `~/.codex/<profile>.config.toml`. Falls back to deriving the
126
126
  * effort from the `<...>_<effort>` naming convention when the file is absent.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@triflux/remote",
3
- "version": "10.45.0",
3
+ "version": "10.47.0",
4
4
  "description": "triflux remote — team mode, psmux, MCP workers, SQLite store.",
5
5
  "type": "module",
6
6
  "main": "hub/index.mjs",
@@ -7,7 +7,7 @@ import {
7
7
 
8
8
  const policy = {
9
9
  executor: {
10
- profile: "gpt56_terra_high",
10
+ profile: "gpt6_sol_high",
11
11
  timeoutSec: 1080,
12
12
  runMode: "fg",
13
13
  opusOversight: false,
@@ -15,7 +15,7 @@ const policy = {
15
15
  subcommand: "exec",
16
16
  },
17
17
  codex: {
18
- profile: "gpt56_terra_high",
18
+ profile: "gpt6_sol_high",
19
19
  timeoutSec: 1080,
20
20
  runMode: "fg",
21
21
  opusOversight: false,
@@ -23,7 +23,7 @@ const policy = {
23
23
  subcommand: "exec",
24
24
  },
25
25
  "build-fixer": {
26
- profile: "gpt56_luna_low",
26
+ profile: "gpt6_luna_high",
27
27
  timeoutSec: 540,
28
28
  runMode: "fg",
29
29
  opusOversight: false,
@@ -31,7 +31,7 @@ const policy = {
31
31
  subcommand: "exec",
32
32
  },
33
33
  cleanup: {
34
- profile: "gpt56_terra_med",
34
+ profile: "gpt6_sol_med",
35
35
  timeoutSec: 540,
36
36
  runMode: "fg",
37
37
  opusOversight: false,
@@ -39,7 +39,7 @@ const policy = {
39
39
  subcommand: "exec",
40
40
  },
41
41
  deslop: {
42
- profile: "gpt56_terra_med",
42
+ profile: "gpt6_sol_med",
43
43
  timeoutSec: 540,
44
44
  runMode: "fg",
45
45
  opusOversight: false,
@@ -95,7 +95,7 @@ const policy = {
95
95
  subcommand: "exec",
96
96
  },
97
97
  "code-reviewer": {
98
- profile: "gpt56_terra_high",
98
+ profile: "gpt6_sol_high",
99
99
  timeoutSec: 1800,
100
100
  runMode: "bg",
101
101
  opusOversight: false,
@@ -103,7 +103,7 @@ const policy = {
103
103
  subcommand: "review",
104
104
  },
105
105
  "quality-reviewer": {
106
- profile: "gpt56_terra_high",
106
+ profile: "gpt6_sol_high",
107
107
  timeoutSec: 1800,
108
108
  runMode: "bg",
109
109
  opusOversight: false,
@@ -119,7 +119,7 @@ const policy = {
119
119
  subcommand: "review",
120
120
  },
121
121
  scientist: {
122
- profile: "gpt56_terra_high",
122
+ profile: "gpt6_sol_high",
123
123
  timeoutSec: 1440,
124
124
  runMode: "bg",
125
125
  opusOversight: false,
@@ -127,7 +127,7 @@ const policy = {
127
127
  subcommand: "exec",
128
128
  },
129
129
  "document-specialist": {
130
- profile: "gpt56_terra_high",
130
+ profile: "gpt6_sol_high",
131
131
  timeoutSec: 1440,
132
132
  runMode: "bg",
133
133
  opusOversight: false,
@@ -143,7 +143,7 @@ const policy = {
143
143
  subcommand: "exec",
144
144
  },
145
145
  verifier: {
146
- profile: "gpt56_terra_high",
146
+ profile: "gpt6_sol_high",
147
147
  timeoutSec: 1200,
148
148
  runMode: "fg",
149
149
  opusOversight: false,
@@ -151,7 +151,7 @@ const policy = {
151
151
  subcommand: "review",
152
152
  },
153
153
  "test-engineer": {
154
- profile: "gpt56_terra_high",
154
+ profile: "gpt6_sol_high",
155
155
  timeoutSec: 1200,
156
156
  runMode: "bg",
157
157
  opusOversight: false,
@@ -159,7 +159,7 @@ const policy = {
159
159
  subcommand: "exec",
160
160
  },
161
161
  "qa-tester": {
162
- profile: "gpt56_terra_high",
162
+ profile: "gpt6_sol_high",
163
163
  timeoutSec: 1200,
164
164
  runMode: "bg",
165
165
  opusOversight: false,
@@ -167,7 +167,7 @@ const policy = {
167
167
  subcommand: "review",
168
168
  },
169
169
  spark: {
170
- profile: "gpt56_luna_low",
170
+ profile: "gpt6_luna_low",
171
171
  timeoutSec: 180,
172
172
  runMode: "fg",
173
173
  opusOversight: false,
@@ -185,7 +185,7 @@ const policy = {
185
185
  subcommand: "exec",
186
186
  },
187
187
  writer: {
188
- profile: "gpt56_luna_low",
188
+ profile: "gpt6_luna_high",
189
189
  timeoutSec: 900,
190
190
  runMode: "bg",
191
191
  opusOversight: false,
@@ -210,9 +210,10 @@ export const DEFAULT_CODEX_AGENT = "executor";
210
210
 
211
211
  export const CANONICAL_CODEX_PROFILE_OVERRIDES = Object.freeze(
212
212
  new Set([
213
- "gpt56_luna_low",
214
- "gpt56_terra_med",
215
- "gpt56_terra_high",
213
+ "gpt6_luna_low",
214
+ "gpt6_luna_high",
215
+ "gpt6_sol_med",
216
+ "gpt6_sol_high",
216
217
  "gpt6_astra_xhigh",
217
218
  "gpt6_astra_max",
218
219
  "gpt6_astra_ultra",
@@ -19,18 +19,24 @@ const EFFORT_BY_SUFFIX = {
19
19
  };
20
20
 
21
21
  const CANONICAL_PROFILE_VALUES = Object.freeze({
22
- gpt56_luna_low: { model: "gpt-5.6-luna", effort: "low" },
23
- gpt56_terra_med: { model: "gpt-5.6-terra", effort: "medium" },
24
- gpt56_terra_high: { model: "gpt-5.6-terra", effort: "high" },
22
+ gpt6_luna_low: { model: "gpt-6-luna", effort: "low" },
23
+ gpt6_luna_high: { model: "gpt-6-luna", effort: "high" },
24
+ gpt6_sol_med: { model: "gpt-6-sol", effort: "medium" },
25
+ gpt6_sol_high: { model: "gpt-6-sol", effort: "high" },
25
26
  gpt6_astra_xhigh: { model: "gpt-6-astra", effort: "xhigh" },
26
27
  gpt6_astra_max: { model: "gpt-6-astra", effort: "max" },
27
28
  gpt6_astra_ultra: { model: "gpt-6-astra", effort: "ultra" },
28
29
  });
29
30
 
31
+ // GPT-5.6 lane names map to the GPT-6 lane with the same effort. GPT-6 has no
32
+ // Terra tier, so Terra lanes move to Sol.
30
33
  const LEGACY_CANONICAL_PROFILE_ALIASES = Object.freeze({
31
34
  gpt56_sol_xhigh: "gpt6_astra_xhigh",
32
35
  gpt56_sol_max: "gpt6_astra_max",
33
36
  gpt56_sol_ultra: "gpt6_astra_ultra",
37
+ gpt56_terra_high: "gpt6_sol_high",
38
+ gpt56_terra_med: "gpt6_sol_med",
39
+ gpt56_luna_low: "gpt6_luna_low",
34
40
  });
35
41
 
36
42
  export function normalizeCodexProfileName(profileName) {
@@ -38,6 +44,14 @@ export function normalizeCodexProfileName(profileName) {
38
44
  return LEGACY_CANONICAL_PROFILE_ALIASES[normalized] || normalized;
39
45
  }
40
46
 
47
+ /** Model ID of a canonical profile (after legacy normalization), or null. */
48
+ export function canonicalCodexProfileModel(profileName) {
49
+ return (
50
+ CANONICAL_PROFILE_VALUES[normalizeCodexProfileName(profileName)]?.model ??
51
+ null
52
+ );
53
+ }
54
+
41
55
  function readProfileScalar(raw, key) {
42
56
  const match = String(raw).match(
43
57
  new RegExp(
@@ -51,7 +65,7 @@ function readProfileScalar(raw, key) {
51
65
  }
52
66
 
53
67
  /**
54
- * Resolve a Codex effort profile name (e.g. "gpt56_terra_high") to the `-c key=value`
68
+ * Resolve a Codex effort profile name (e.g. "gpt6_sol_high") to the `-c key=value`
55
69
  * config overrides that `codex exec` accepts, so the headless CLI lanes can
56
70
  * select a profile WITHOUT `codex exec --profile <name>`. codex 0.134+ rejects
57
71
  * `--profile X` whenever config.toml still contains an inline `[profiles.X]`
@@ -69,7 +83,7 @@ function readProfileScalar(raw, key) {
69
83
  *
70
84
  * @param {string} profileName
71
85
  * @param {{ codexHome?: string }} [opts]
72
- * @returns {string[]} e.g. ['model="gpt-5.6-terra"', 'model_reasoning_effort="high"']
86
+ * @returns {string[]} e.g. ['model="gpt-6-sol"', 'model_reasoning_effort="high"']
73
87
  */
74
88
  export function resolveCodexProfileConfigValues(profileName, opts = {}) {
75
89
  if (typeof profileName !== "string" || !profileName) {
@@ -1,286 +0,0 @@
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
- }