@triflux/remote 10.34.0 → 10.35.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/cto/status.mjs CHANGED
@@ -29,7 +29,61 @@ function toIsoTime(value) {
29
29
  return null;
30
30
  }
31
31
 
32
+ function shortHash(value) {
33
+ const str = String(value ?? "");
34
+ let h = 5381;
35
+ for (let i = 0; i < str.length; i++) {
36
+ h = (h * 33) ^ str.charCodeAt(i);
37
+ }
38
+ return (h >>> 0).toString(36);
39
+ }
40
+
41
+ function pathLabel(value) {
42
+ const str = String(value ?? "").replace(/[/\\]+$/u, "");
43
+ if (!str) return "";
44
+ const segments = str.split(/[/\\]+/u);
45
+ return segments[segments.length - 1] || "";
46
+ }
47
+
48
+ export function deriveRepoRootFromCwd(cwd) {
49
+ if (typeof cwd !== "string" || !cwd) return "";
50
+ if (cwd.includes("\\") || /^[A-Za-z]:/u.test(cwd)) return cwd;
51
+
52
+ const normalized = cwd.replace(/\/+$/u, "");
53
+ const claudeMarker = "/.claude/worktrees/";
54
+ const claudeIndex = normalized.indexOf(claudeMarker);
55
+ if (claudeIndex > 0) {
56
+ const suffix = normalized.slice(claudeIndex + claudeMarker.length);
57
+ if (/^[^/]+(?:\/|$)/u.test(suffix)) {
58
+ return normalized.slice(0, claudeIndex);
59
+ }
60
+ }
61
+
62
+ const codexMarker = "/.codex-swarm/";
63
+ const codexIndex = normalized.indexOf(codexMarker);
64
+ if (codexIndex > 0) {
65
+ const suffix = normalized.slice(codexIndex + codexMarker.length);
66
+ if (/^wt-[^/]+(?:\/|$)/u.test(suffix)) {
67
+ return normalized.slice(0, codexIndex);
68
+ }
69
+ }
70
+
71
+ return cwd;
72
+ }
73
+
74
+ function redactedCwdFields(cwd) {
75
+ if (typeof cwd !== "string" || !cwd) return {};
76
+ const repoRoot = deriveRepoRootFromCwd(cwd);
77
+ return {
78
+ cwdLabel: pathLabel(cwd),
79
+ cwdHash: shortHash(cwd),
80
+ repoRootLabel: pathLabel(repoRoot),
81
+ repoRootHash: shortHash(repoRoot),
82
+ };
83
+ }
84
+
32
85
  function normalizeLiveSession(session) {
86
+ const cwd = typeof session?.cwd === "string" ? session.cwd : "";
33
87
  return {
34
88
  sessionId: String(session?.sessionId || ""),
35
89
  host: typeof session?.host === "string" ? session.host : "local",
@@ -48,6 +102,7 @@ function normalizeLiveSession(session) {
48
102
  started_at: toIsoTime(
49
103
  session?.started_at ?? session?.startedAt ?? session?.lastHeartbeat,
50
104
  ),
105
+ ...redactedCwdFields(cwd),
51
106
  };
52
107
  }
53
108
 
@@ -164,7 +219,27 @@ function deriveActiveShards(current, overlay) {
164
219
  return shards.map(normalizeActiveShard).filter((shard) => shard.shard_name);
165
220
  }
166
221
 
222
+ function deriveLiveSessionGroups(liveSessions) {
223
+ const groups = new Map();
224
+ for (const session of liveSessions || []) {
225
+ if (!session?.repoRootHash) continue;
226
+ if (!groups.has(session.repoRootHash)) {
227
+ groups.set(session.repoRootHash, {
228
+ repoRootLabel: session.repoRootLabel || "",
229
+ repoRootHash: session.repoRootHash,
230
+ session_count: 0,
231
+ sessions: [],
232
+ });
233
+ }
234
+ const group = groups.get(session.repoRootHash);
235
+ group.session_count += 1;
236
+ group.sessions.push(session.sessionId);
237
+ }
238
+ return [...groups.values()];
239
+ }
240
+
167
241
  function projectStatus(current, overlay) {
242
+ const liveSessions = overlay.live_sessions;
168
243
  return {
169
244
  schema_version: current?.schema_version || SCHEMA_VERSION,
170
245
  generated_at: current?.generated_at || null,
@@ -172,7 +247,8 @@ function projectStatus(current, overlay) {
172
247
  sources: current?.sources || {},
173
248
  summary: current?.summary || {},
174
249
  ledger_tail: Array.isArray(current?.ledger_tail) ? current.ledger_tail : [],
175
- live_sessions: overlay.live_sessions,
250
+ live_sessions: liveSessions,
251
+ live_session_groups: deriveLiveSessionGroups(liveSessions),
176
252
  active_shards: deriveActiveShards(current, overlay),
177
253
  };
178
254
  }
package/hub/server.mjs CHANGED
@@ -246,7 +246,9 @@ async function parseBody(req) {
246
246
  return JSON.parse(Buffer.concat(chunks).toString());
247
247
  }
248
248
 
249
- const PID_DIR = join(homedir(), ".claude", "cache", "tfx-hub");
249
+ const PID_DIR =
250
+ process.env.TFX_HUB_PID_DIR?.trim() ||
251
+ join(homedir(), ".claude", "cache", "tfx-hub");
250
252
  const PID_FILE = join(PID_DIR, "hub.pid");
251
253
  const TOKEN_FILE = join(homedir(), ".claude", ".tfx-hub-token");
252
254
 
@@ -1004,7 +1006,7 @@ export async function startHub({
1004
1006
  // DB를 npm 패키지 밖에 저장하여 npm update 시 EBUSY 방지
1005
1007
  // 기존: PROJECT_ROOT/.tfx/state/state.db (패키지 내부 → 락 충돌)
1006
1008
  // 변경: ~/.claude/cache/tfx-hub/state.db (패키지 외부 → 안전)
1007
- const hubCacheDir = join(homedir(), ".claude", "cache", "tfx-hub");
1009
+ const hubCacheDir = PID_DIR;
1008
1010
  mkdirSync(hubCacheDir, { recursive: true });
1009
1011
  dbPath = join(hubCacheDir, "state.db");
1010
1012
  }
@@ -22,10 +22,23 @@ function formatLeaseFiles(leaseFiles) {
22
22
  return normalized.map((file) => `- ${file}`).join("\n");
23
23
  }
24
24
 
25
- export function buildLeaseScopedAcceptanceAppendix({ leaseFiles = [] } = {}) {
25
+ function normalizeWorktreePath(worktreePath) {
26
+ return typeof worktreePath === "string" ? worktreePath.trim() : "";
27
+ }
28
+
29
+ export function buildLeaseScopedAcceptanceAppendix({
30
+ leaseFiles = [],
31
+ worktreePath,
32
+ } = {}) {
33
+ const normalizedWorktreePath = normalizeWorktreePath(worktreePath);
34
+ const rootLine = normalizedWorktreePath
35
+ ? `작업 루트(절대경로): ${normalizedWorktreePath} — 아래 lease 경로와 모든 상대경로는 이 루트 기준으로만 해석한다.\n`
36
+ : "";
37
+
26
38
  return `
27
39
 
28
40
  ## Lease-scoped Acceptance / Lint Guard (자동 삽입됨)
41
+ ${rootLine}- 위 PRD 본문의 모든 제약과 acceptance 기준은 이 appendix 이후에도 그대로 유효하다.
29
42
  - 이 shard 의 파일 lease:
30
43
  ${formatLeaseFiles(leaseFiles)}
31
44
  - Acceptance, lint, and format checks are scoped to files changed by this shard; if that set is unclear, use only the lease list above.
@@ -51,7 +64,10 @@ ${SENTINEL_END}
51
64
  규약:
52
65
  - 두 sentinel 마커는 각자 자기 줄에 단독으로 출력 (앞뒤 newline)
53
66
  - 마커 사이 본문은 단일 JSON object (배열/primitive 금지)
54
- - commits_made 비어 있어도 (no-op shard)
67
+ - status 값은 ok | failed | blocked 중 하나
68
+ - payload 출력 직전 \`git log -1 --format=%H\` 로 보고할 sha 가 실재하는지 검증하라
69
+ - 코드 변경이 기대되는 shard 에서 변경/커밋을 못 했으면 status:failed 와 함께 reason 필드로 사유를 보고하라 — 그 경우 빈 commits_made 에 status:ok 는 금지
70
+ - no-op shard 는 status:ok + 빈 commits_made 배열 허용
55
71
  - 마커 쌍은 stdout 에 정확히 한 번만 출력해야 함. 재emit 시 conductor 는 첫 BEGIN..END 한 쌍만 채택하며, 이후 stdout 은 무시한다.
56
72
  - ${SENTINEL_BEGIN} 만 출력하고 ${SENTINEL_END} 누락 시 conductor 가 truncation 으로 명확히 reject
57
73
  `;
@@ -60,14 +76,17 @@ ${SENTINEL_END}
60
76
  * Append the Completion Protocol section to a PRD prompt.
61
77
  *
62
78
  * @param {string|null|undefined} prdPrompt — original PRD body
63
- * @param {{ leaseFiles?: string[] }} [opts] — shard file lease for scoped acceptance
79
+ * @param {{ leaseFiles?: string[], worktreePath?: string }} [opts] — shard file lease and absolute worktree root for scoped acceptance
64
80
  * @returns {string} prompt with appendix
65
81
  */
66
82
  export function buildWorkerPrompt(prdPrompt, opts = {}) {
67
83
  const body = typeof prdPrompt === "string" ? prdPrompt : "";
68
84
  return (
69
85
  body +
70
- buildLeaseScopedAcceptanceAppendix({ leaseFiles: opts.leaseFiles }) +
86
+ buildLeaseScopedAcceptanceAppendix({
87
+ leaseFiles: opts.leaseFiles,
88
+ worktreePath: opts.worktreePath,
89
+ }) +
71
90
  COMPLETION_PROTOCOL_APPENDIX
72
91
  );
73
92
  }
@@ -16,7 +16,24 @@ import {
16
16
 
17
17
  export function resolveClaudeConfigDir(env = process.env) {
18
18
  if (env.CLAUDE_CONFIG_DIR) return path.resolve(env.CLAUDE_CONFIG_DIR);
19
- return path.join(os.homedir(), ".claude");
19
+ return path.join(resolveClaudeHomeDir(env), ".claude");
20
+ }
21
+
22
+ export function resolveClaudeHomeDir(
23
+ env = process.env,
24
+ platform = process.platform,
25
+ ) {
26
+ // win32: claude daemon launcher 의 os.homedir() 는 USERPROFILE 기반이고
27
+ // HOME 을 보지 않는다. git-bash 가 HOME 을 따로 잡아도 launcher 폴백을
28
+ // 그대로 미러해야 socket hash 가 daemon 과 일치한다 (HOME 수용 금지).
29
+ if (platform === "win32") {
30
+ const userProfile = nullableEnv(env.USERPROFILE);
31
+ return userProfile ? path.resolve(userProfile) : os.homedir();
32
+ }
33
+ // POSIX 는 HOME 우선 — sandbox HOME 오버라이드를 추적하는 provenance
34
+ // 해석(#382)과 일치 (os.homedir() 도 POSIX 에서는 HOME 을 우선한다).
35
+ const home = nullableEnv(env.HOME);
36
+ return home ? path.resolve(home) : os.homedir();
20
37
  }
21
38
 
22
39
  export function deriveClaudeDaemonPaths({
@@ -44,6 +61,316 @@ export function deriveClaudeDaemonPaths({
44
61
  };
45
62
  }
46
63
 
64
+ function nullableEnv(value) {
65
+ const text = typeof value === "string" ? value.trim() : "";
66
+ return text.length > 0 ? text : null;
67
+ }
68
+
69
+ function defaultClaudeConfigDir(env = process.env) {
70
+ return path.join(resolveClaudeHomeDir(env), ".claude");
71
+ }
72
+
73
+ export function detectCallerProvenance(env = process.env) {
74
+ const claudeConfigDir = nullableEnv(env.CLAUDE_CONFIG_DIR);
75
+ const omxSessionId = nullableEnv(env.OMX_SESSION_ID);
76
+ const omxEntryPath = nullableEnv(env.OMX_ENTRY_PATH);
77
+ const codexThreadId = nullableEnv(env.CODEX_THREAD_ID);
78
+ const codexLauncher =
79
+ omxSessionId || omxEntryPath ? "omx" : codexThreadId ? "codex" : "unknown";
80
+ return {
81
+ claudeLauncher: "unknown",
82
+ codexLauncher,
83
+ signals: {
84
+ claudeConfigDir,
85
+ omxSessionId,
86
+ omxEntryPath,
87
+ codexThreadId,
88
+ },
89
+ };
90
+ }
91
+
92
+ export async function readOmcLaunchProfile(configDir) {
93
+ if (!configDir) return null;
94
+ try {
95
+ // Provenance hint only: OMC writes this profile in its runtime config dir.
96
+ // It is not an auth/security boundary and must not override explicit/env
97
+ // config-dir authority.
98
+ const parsed = JSON.parse(
99
+ await fs.readFile(
100
+ path.join(path.resolve(configDir), ".omc-launch-profile.json"),
101
+ "utf8",
102
+ ),
103
+ );
104
+ const sourceConfigDir = nullableEnv(parsed?.sourceConfigDir);
105
+ if (!sourceConfigDir) return null;
106
+ const sourceClaudeMd = nullableEnv(parsed?.sourceClaudeMd);
107
+ return {
108
+ sourceConfigDir: path.resolve(sourceConfigDir),
109
+ sourceClaudeMd: sourceClaudeMd ? path.resolve(sourceClaudeMd) : null,
110
+ };
111
+ } catch (error) {
112
+ if (error?.code === "ENOENT" || error instanceof SyntaxError) return null;
113
+ return null;
114
+ }
115
+ }
116
+
117
+ async function buildClaudeDaemonCandidate({
118
+ configDir,
119
+ configDirSource,
120
+ selectionMode,
121
+ env,
122
+ uid,
123
+ tmpRoot,
124
+ }) {
125
+ const paths = deriveClaudeDaemonPaths({ configDir, uid, tmpRoot });
126
+ const defaultConfig = path.resolve(defaultClaudeConfigDir(env));
127
+ const omcProfile = await readOmcLaunchProfile(paths.configDir);
128
+ const claudeLauncher = omcProfile
129
+ ? "omc"
130
+ : paths.configDir === defaultConfig
131
+ ? "claude"
132
+ : "unknown";
133
+ return {
134
+ ...paths,
135
+ configDirSource,
136
+ selectionMode,
137
+ claudeLauncher,
138
+ sourceConfigDir: omcProfile?.sourceConfigDir,
139
+ sourceClaudeMd: omcProfile?.sourceClaudeMd ?? null,
140
+ callerProvenance: detectCallerProvenance(env),
141
+ };
142
+ }
143
+
144
+ export async function buildClaudeDaemonDiscoveryCandidates({
145
+ configDir,
146
+ env = process.env,
147
+ uid = typeof process.getuid === "function" ? process.getuid() : 0,
148
+ tmpRoot = "/tmp",
149
+ } = {}) {
150
+ const explicitConfigDir = nullableEnv(configDir);
151
+ if (explicitConfigDir) {
152
+ return [
153
+ await buildClaudeDaemonCandidate({
154
+ configDir: explicitConfigDir,
155
+ configDirSource: "explicit",
156
+ selectionMode: "exact",
157
+ env,
158
+ uid,
159
+ tmpRoot,
160
+ }),
161
+ ];
162
+ }
163
+
164
+ const envConfigDir = nullableEnv(env.CLAUDE_CONFIG_DIR);
165
+ if (envConfigDir) {
166
+ return [
167
+ await buildClaudeDaemonCandidate({
168
+ configDir: envConfigDir,
169
+ configDirSource: "env",
170
+ selectionMode: "env-strict",
171
+ env,
172
+ uid,
173
+ tmpRoot,
174
+ }),
175
+ ];
176
+ }
177
+
178
+ const candidates = [];
179
+ const defaultConfig = defaultClaudeConfigDir(env);
180
+ const omcRuntime = path.join(defaultConfig, ".omc-launch");
181
+ if (await readOmcLaunchProfile(omcRuntime)) {
182
+ candidates.push(
183
+ await buildClaudeDaemonCandidate({
184
+ configDir: omcRuntime,
185
+ configDirSource: "omc-runtime",
186
+ selectionMode: "ambient",
187
+ env,
188
+ uid,
189
+ tmpRoot,
190
+ }),
191
+ );
192
+ }
193
+ candidates.push(
194
+ await buildClaudeDaemonCandidate({
195
+ configDir: defaultConfig,
196
+ configDirSource: "default",
197
+ selectionMode: "ambient",
198
+ env,
199
+ uid,
200
+ tmpRoot,
201
+ }),
202
+ );
203
+ return candidates;
204
+ }
205
+
206
+ export function publicClaudeDaemonCandidate(candidate) {
207
+ if (!candidate) return null;
208
+ return {
209
+ configDirSource: candidate.configDirSource,
210
+ selectionMode: candidate.selectionMode,
211
+ claudeLauncher: candidate.claudeLauncher,
212
+ configDir: candidate.configDir,
213
+ sourceConfigDir: candidate.sourceConfigDir ?? null,
214
+ sourceClaudeMd: candidate.sourceClaudeMd ?? null,
215
+ hash: candidate.hash,
216
+ controlSock: candidate.controlSock,
217
+ };
218
+ }
219
+
220
+ function sessionsFromDaemonList(listResponse) {
221
+ // probe 경로도 list/find 경로와 같은 정규화 row 를 내보내야 한다
222
+ // (jobs/sessions, snake_case, dispatch 중첩 변형 흡수 — 8016b724).
223
+ return extractClaudeAgentSessions(listResponse);
224
+ }
225
+
226
+ function errorMessage(error) {
227
+ return error?.message || String(error);
228
+ }
229
+
230
+ function findDaemonTargetInSessions(sessions, { short, sessionId } = {}) {
231
+ if (sessionId) {
232
+ return findDaemonJobBySessionId({ jobs: sessions }, sessionId);
233
+ }
234
+ if (short) {
235
+ return findDaemonJobByShort({ jobs: sessions }, short);
236
+ }
237
+ return null;
238
+ }
239
+
240
+ function candidateResultSummary(result) {
241
+ const summary = publicClaudeDaemonCandidate(result.candidate);
242
+ return {
243
+ ...summary,
244
+ ok: result.ok === true,
245
+ error: result.error ?? null,
246
+ sessionCount: Array.isArray(result.sessions) ? result.sessions.length : 0,
247
+ };
248
+ }
249
+
250
+ function buildProbeResponse({
251
+ candidates,
252
+ results,
253
+ selected,
254
+ matches,
255
+ targetRequested,
256
+ callerProvenance,
257
+ }) {
258
+ const reachable = results.filter((result) => result.ok === true);
259
+ let ok = false;
260
+ let reason = "daemon-unavailable";
261
+ let error = null;
262
+
263
+ if (targetRequested && matches.length > 1) {
264
+ reason = "ambiguous-target";
265
+ error = `Claude daemon target ambiguous across ${matches.length} candidates`;
266
+ } else if (selected) {
267
+ ok = true;
268
+ reason = selected.target ? "target-found" : "daemon-available";
269
+ } else if (targetRequested && reachable.length > 0) {
270
+ reason = "target-not-found";
271
+ error = "Claude daemon target not found in reachable candidates";
272
+ } else if (results.length > 0) {
273
+ error =
274
+ results
275
+ .map((result) => result.error)
276
+ .filter(Boolean)
277
+ .join("; ") || "Claude daemon unavailable";
278
+ }
279
+
280
+ const selectedSessions = selected
281
+ ? selected.sessions
282
+ : targetRequested
283
+ ? []
284
+ : reachable.flatMap((result) => result.sessions);
285
+ const selectedDaemon = publicClaudeDaemonCandidate(selected?.candidate);
286
+ const reachableDaemons = reachable.map((result) =>
287
+ publicClaudeDaemonCandidate(result.candidate),
288
+ );
289
+
290
+ return {
291
+ ok,
292
+ reason,
293
+ controlSock: selected?.candidate?.controlSock,
294
+ daemon: selectedDaemon,
295
+ daemons: reachableDaemons,
296
+ sessions: selectedSessions,
297
+ target: selected?.target || undefined,
298
+ matches: matches.map((match) => ({
299
+ daemon: publicClaudeDaemonCandidate(match.candidate),
300
+ target: match.target,
301
+ })),
302
+ candidateResults: results.map(candidateResultSummary),
303
+ callerProvenance,
304
+ candidates: candidates.map(publicClaudeDaemonCandidate),
305
+ error: ok ? undefined : error,
306
+ };
307
+ }
308
+
309
+ export async function probeClaudeDaemonCandidates({
310
+ configDir,
311
+ env = process.env,
312
+ short,
313
+ sessionId,
314
+ timeoutMs = 6000,
315
+ } = {}) {
316
+ const candidates = await buildClaudeDaemonDiscoveryCandidates({
317
+ configDir,
318
+ env,
319
+ });
320
+ const callerProvenance = detectCallerProvenance(env);
321
+ const targetRequested = Boolean(short || sessionId);
322
+ const results = [];
323
+
324
+ for (const candidate of candidates) {
325
+ try {
326
+ const list = await sendClaudeControlRequest(
327
+ candidate.controlSock,
328
+ { proto: 1, op: "list" },
329
+ { timeoutMs },
330
+ );
331
+ const sessions = sessionsFromDaemonList(list);
332
+ const ok = list?.ok !== false;
333
+ results.push({
334
+ ok,
335
+ candidate,
336
+ list,
337
+ sessions: ok ? sessions : [],
338
+ target: ok
339
+ ? findDaemonTargetInSessions(sessions, { short, sessionId })
340
+ : null,
341
+ error: ok ? null : list?.error || "Claude daemon list failed",
342
+ });
343
+ } catch (error) {
344
+ results.push({
345
+ ok: false,
346
+ candidate,
347
+ list: null,
348
+ sessions: [],
349
+ target: null,
350
+ error: errorMessage(error),
351
+ });
352
+ }
353
+ }
354
+
355
+ const matches = targetRequested
356
+ ? results.filter((result) => result.ok === true && result.target)
357
+ : [];
358
+ const selected = targetRequested
359
+ ? matches.length === 1
360
+ ? matches[0]
361
+ : null
362
+ : results.find((result) => result.ok === true) || null;
363
+
364
+ return buildProbeResponse({
365
+ candidates,
366
+ results,
367
+ selected,
368
+ matches,
369
+ targetRequested,
370
+ callerProvenance,
371
+ });
372
+ }
373
+
47
374
  export function getProcStart(pid = process.pid) {
48
375
  if (!Number.isInteger(pid) || pid <= 0)
49
376
  throw new Error(`invalid pid: ${pid}`);
@@ -141,6 +468,7 @@ export function buildDaemonAttachRequest({
141
468
  cols = DEFAULT_DAEMON_ATTACH_COLS,
142
469
  rows = 40,
143
470
  caps = { terminal: null, mux: null, ssh: false },
471
+ auth,
144
472
  } = {}) {
145
473
  if (!short) throw new Error("short is required");
146
474
  return {
@@ -150,6 +478,7 @@ export function buildDaemonAttachRequest({
150
478
  cols,
151
479
  rows,
152
480
  caps,
481
+ ...(auth ? { auth } : {}),
153
482
  };
154
483
  }
155
484
 
@@ -683,6 +1012,7 @@ export function attachClaudeDaemonSession({
683
1012
  controlSock,
684
1013
  short,
685
1014
  input,
1015
+ auth,
686
1016
  cols = DEFAULT_DAEMON_ATTACH_COLS,
687
1017
  rows = 40,
688
1018
  caps,
@@ -790,7 +1120,9 @@ export function attachClaudeDaemonSession({
790
1120
  });
791
1121
  socket.on("connect", () => {
792
1122
  socket.write(
793
- `${JSON.stringify(buildDaemonAttachRequest({ short, cols, rows, caps }))}\n`,
1123
+ `${JSON.stringify(
1124
+ buildDaemonAttachRequest({ short, cols, rows, caps, auth }),
1125
+ )}\n`,
794
1126
  );
795
1127
  });
796
1128
  socket.on("data", (chunk) => {
@@ -906,6 +1238,7 @@ export function attachClaudeDaemonSession({
906
1238
  export function interruptClaudeDaemonSession({
907
1239
  controlSock,
908
1240
  short,
1241
+ auth,
909
1242
  cols = DEFAULT_DAEMON_ATTACH_COLS,
910
1243
  rows = 40,
911
1244
  caps,
@@ -956,7 +1289,9 @@ export function interruptClaudeDaemonSession({
956
1289
  socket.on("error", fail);
957
1290
  socket.on("connect", () => {
958
1291
  socket.write(
959
- `${JSON.stringify(buildDaemonAttachRequest({ short, cols, rows, caps }))}\n`,
1292
+ `${JSON.stringify(
1293
+ buildDaemonAttachRequest({ short, cols, rows, caps, auth }),
1294
+ )}\n`,
960
1295
  );
961
1296
  });
962
1297
  socket.on("data", (chunk) => {
@@ -1182,6 +1517,7 @@ export async function sendKillBySessionId({
1182
1517
  daemonPaths,
1183
1518
  sessionId,
1184
1519
  timeoutMs = 6000,
1520
+ auth,
1185
1521
  } = {}) {
1186
1522
  const controlSock = daemonPaths?.controlSock;
1187
1523
  if (!controlSock || !sessionId) {
@@ -1201,12 +1537,16 @@ export async function sendKillBySessionId({
1201
1537
  if (!job?.short) {
1202
1538
  return { ok: true, killed: false, reason: "not_found" };
1203
1539
  }
1540
+ const controlAuth = auth
1541
+ ? { auth }
1542
+ : await buildDaemonControlAuth(daemonPaths?.configDir);
1204
1543
  return await sendClaudeControlRequest(
1205
1544
  controlSock,
1206
1545
  {
1207
1546
  proto: 1,
1208
1547
  op: "kill",
1209
1548
  short: job.short,
1549
+ ...controlAuth,
1210
1550
  },
1211
1551
  { timeoutMs },
1212
1552
  );
@@ -1356,6 +1696,7 @@ export async function teardownClaudeDaemonJob({
1356
1696
  _deps.removeClaudeSessionProjection || removeClaudeSessionProjection;
1357
1697
  const killJob = _deps.killDaemonJob || killDaemonJob;
1358
1698
  const removeJobStateImpl = _deps.removeClaudeJobState;
1699
+ const buildAuth = _deps.buildDaemonControlAuth || buildDaemonControlAuth;
1359
1700
  const resolvedControlSock = controlSock || paths?.controlSock;
1360
1701
  const resolvedJobsDir =
1361
1702
  jobsDir ||
@@ -1371,7 +1712,10 @@ export async function teardownClaudeDaemonJob({
1371
1712
  steps.push(sendKillBySessionId({ daemonPaths, sessionId }).catch(() => {}));
1372
1713
  }
1373
1714
  if (resolvedControlSock && short) {
1374
- steps.push(killJob(resolvedControlSock, short).catch(() => {}));
1715
+ const controlAuth = await buildAuth(paths?.configDir);
1716
+ steps.push(
1717
+ killJob(resolvedControlSock, short, controlAuth).catch(() => {}),
1718
+ );
1375
1719
  }
1376
1720
  if (removeJobState && removeJobStateImpl && resolvedJobsDir && short) {
1377
1721
  steps.push(removeJobStateImpl(resolvedJobsDir, short).catch(() => {}));
@@ -10,6 +10,7 @@ import {
10
10
  dispatchClaudeDaemonJob,
11
11
  getProcStart,
12
12
  killDaemonJob,
13
+ resolveClaudeConfigDir,
13
14
  resolveDaemonBridgeSessionId,
14
15
  sendClaudeControlRequest,
15
16
  teardownClaudeDaemonJob,
@@ -22,9 +23,11 @@ import {
22
23
  } from "./claude-session-projection.mjs";
23
24
  import { createInteractiveTuiTransport } from "./interactive-tui-transport.mjs";
24
25
 
25
- // daemon-control 이 deriveClaudeDaemonPaths / getProcStart 의 단일 owner 다.
26
- // 기존 native-bridge import 경로 (headless 포함) 호환을 위해 re-export 한다.
27
- export { deriveClaudeDaemonPaths, getProcStart };
26
+ // daemon-control 이 deriveClaudeDaemonPaths / getProcStart /
27
+ // resolveClaudeConfigDir 단일 owner 다. configDir 해석이 갈리면 양쪽이
28
+ // 서로 다른 control.sock hash 를 보게 되므로 (HOME 오버라이드 split-brain)
29
+ // 로컬 복제본을 두지 않는다. 기존 import 경로 호환을 위해 re-export 한다.
30
+ export { deriveClaudeDaemonPaths, getProcStart, resolveClaudeConfigDir };
28
31
 
29
32
  const DEFAULT_ROWS = 40;
30
33
  const DEFAULT_COLS = 120;
@@ -33,11 +36,6 @@ const ROSTER_LOCK_STALE_MS = 30_000;
33
36
  const ROSTER_LOCK_TIMEOUT_MS = 5_000;
34
37
  const ROSTER_LOCK_RETRY_MS = 10;
35
38
 
36
- export function resolveClaudeConfigDir(env = process.env) {
37
- if (env.CLAUDE_CONFIG_DIR) return path.resolve(env.CLAUDE_CONFIG_DIR);
38
- return path.join(os.homedir(), ".claude");
39
- }
40
-
41
39
  export function buildPtyDataFrame(value) {
42
40
  const payload = Buffer.isBuffer(value) ? value : Buffer.from(value, "utf8");
43
41
  const frame = Buffer.allocUnsafe(5 + payload.length);
@@ -1140,6 +1140,7 @@ export function createConductor(opts = {}) {
1140
1140
  const sandbox = buildWorkerSandboxEnv({
1141
1141
  cwd: resolvedConfig.workdir || process.cwd(),
1142
1142
  sessionId: resolvedConfig.id,
1143
+ agent: resolvedConfig.agent,
1143
1144
  env: { ...process.env, ...(resolvedConfig.env || {}) },
1144
1145
  });
1145
1146
  resolvedConfig = {
@@ -23,7 +23,7 @@ After completing the task, you MUST output a HANDOFF block in exactly this forma
23
23
  status: ok | partial | failed
24
24
  lead_action: accept | needs_read | retry | reassign
25
25
  task: <1-3 word task type>
26
- files_changed: <comma-separated file paths, or "none">
26
+ files_changed: <comma-separated repo-root relative file paths, or "none">
27
27
  verdict: <one sentence conclusion>
28
28
  confidence: high | medium | low
29
29
  risk: low | med | high
@@ -36,21 +36,25 @@ partial_output: yes | no
36
36
 
37
37
  Rules:
38
38
  - The HANDOFF block must start with exactly "--- HANDOFF ---"
39
+ - Set status: ok only after you have verified the work (file/diff/test evidence) — unverified claims must use partial or failed.
39
40
  - Each field must be on its own line as "key: value"
41
+ - Report files_changed as repo-root relative paths
40
42
  - verdict must be a single concise sentence
41
43
  - Do not skip any required field
44
+ - This block owns the final output position; this block must be the last thing you output
42
45
  `.trim();
43
46
 
44
47
  /**
45
48
  * CLI 프롬프트 길이 제한을 고려한 축약 HANDOFF 지시
46
49
  */
47
- export const HANDOFF_INSTRUCTION_SHORT = `After completing, output this block at the end:
50
+ export const HANDOFF_INSTRUCTION_SHORT = `After completing, output this block at the end; this block must be the last thing you output:
48
51
  --- HANDOFF ---
49
52
  status: ok | partial | failed
50
53
  lead_action: accept | needs_read | retry | reassign
51
54
  verdict: <one sentence>
52
- files_changed: <comma-separated paths or "none">
53
- confidence: high | medium | low`;
55
+ files_changed: <comma-separated repo-root relative paths or "none">
56
+ confidence: high | medium | low
57
+ Set status: ok only after you have verified the work (file/diff/test evidence) — unverified claims must use partial or failed.`;
54
58
 
55
59
  /**
56
60
  * raw 텍스트에서 HANDOFF 블록을 파싱한다.
@@ -18,7 +18,7 @@ import {
18
18
  import { createRequire } from "node:module";
19
19
  import net from "node:net";
20
20
  import { tmpdir } from "node:os";
21
- import { dirname, join } from "node:path";
21
+ import { dirname, join, resolve } from "node:path";
22
22
  import { fileURLToPath } from "node:url";
23
23
  import { requestJson } from "@triflux/core/hub/bridge.mjs";
24
24
  import { escapePwshSingleQuoted } from "@triflux/core/hub/cli-adapter-base.mjs";
@@ -26,6 +26,7 @@ import { getMaxSpawnPerSec } from "@triflux/core/hub/lib/spawn-trace.mjs";
26
26
  import { IS_WINDOWS } from "@triflux/core/hub/platform.mjs";
27
27
  import { getBackend } from "./backend.mjs";
28
28
  import {
29
+ buildDaemonControlAuth,
29
30
  buildDaemonExecDispatchPayload,
30
31
  deriveClaudeDaemonPaths as deriveClaudeControlPaths,
31
32
  dispatchClaudeDaemonJob,
@@ -255,7 +256,7 @@ function unregisterHeadlessSynapseWorker(workerId) {
255
256
  /** MCP 프로필별 프롬프트 힌트 (tfx-route.sh resolve_mcp_policy의 경량 미러) */
256
257
  const MCP_PROFILE_HINTS = {
257
258
  implement:
258
- "You have full filesystem read/write access. Implement changes directly.",
259
+ "You have full filesystem read/write access. Implement changes directly. After changes, run the narrowest relevant test/lint for the files you touched; if none can run, state why and the next-best check.",
259
260
  analyze:
260
261
  "Focus on reading and analyzing the codebase. Prefer analysis over modification.",
261
262
  review: "Review the code for quality, security, and correctness.",
@@ -301,22 +302,31 @@ export function buildHeadlessCommand(cli, prompt, resultFile, opts = {}) {
301
302
  // contextFile 처리: 32KB(32768 bytes) 초과 시 UTF-8 안전 절단
302
303
  let contextPrefix = "";
303
304
  if (contextFile && existsSync(contextFile)) {
305
+ const contextSource = resolve(contextFile);
304
306
  let ctx = readFileSync(contextFile, "utf8");
307
+ let truncated = false;
305
308
  if (Buffer.byteLength(ctx, "utf8") > 32768) {
306
309
  ctx = Buffer.from(ctx).subarray(0, 32768).toString("utf8");
310
+ truncated = true;
311
+ }
312
+ if (truncated) {
313
+ ctx = `${ctx}\n[... truncated at 32KB]`;
307
314
  }
308
315
  if (ctx.length > 0) {
309
- contextPrefix = `<prior_context>\n${ctx}\n</prior_context>\n\n`;
316
+ contextPrefix = `<prior_context source="${contextSource}" truncated="${truncated ? "true" : "false"}">\n${ctx}\n</prior_context>\nBase your work on the prior_context above; if it conflicts with the task below, the task wins.\n\n`;
310
317
  }
311
318
  }
312
319
 
313
320
  const mcpHint =
314
321
  mcp && MCP_PROFILE_HINTS[mcp]
315
- ? ` [MCP: ${mcp}] ${MCP_PROFILE_HINTS[mcp]}`
322
+ ? `\n\n[MCP: ${mcp}]\n${MCP_PROFILE_HINTS[mcp]}`
316
323
  : "";
324
+ const workspaceHint = cwd
325
+ ? `\n\n[workspace] root(절대경로): ${resolve(cwd)}. 모든 상대경로는 이 루트 기준. 파일 쓰기/커밋 전 \`git rev-parse --show-toplevel\` 이 이 root 와 일치하는지 확인하고 불일치 시 중단·보고.`
326
+ : "";
317
327
  // P2: HANDOFF 지시를 프롬프트에 삽입 (워커가 구조화된 handoff 블록을 출력하도록)
318
328
  const handoffHint = handoff ? `\n\n${HANDOFF_INSTRUCTION_SHORT}` : "";
319
- const fullPrompt = `${contextPrefix}${prompt}${mcpHint}${handoffHint}`;
329
+ const fullPrompt = `${contextPrefix}${prompt}${mcpHint}${workspaceHint}${handoffHint}`;
320
330
 
321
331
  // 보안: 프롬프트를 임시 파일에 쓰고 파일 참조로 전달 (셸 주입 방지).
322
332
  // 이전 구현은 `"$(cat 'PATH')"` shell-expansion expression 을 만들어
@@ -1064,9 +1074,14 @@ export async function cleanupDaemonDispatches(dispatches) {
1064
1074
  }).catch(() => {});
1065
1075
  }
1066
1076
  if (dispatch.controlSock && dispatch.daemonShort) {
1067
- await killDaemonJob(dispatch.controlSock, dispatch.daemonShort).catch(
1068
- () => {},
1069
- );
1077
+ const controlAuth = await buildDaemonControlAuth(
1078
+ dispatch.daemonPaths?.configDir,
1079
+ ).catch(() => ({}));
1080
+ await killDaemonJob(
1081
+ dispatch.controlSock,
1082
+ dispatch.daemonShort,
1083
+ controlAuth,
1084
+ ).catch(() => {});
1070
1085
  }
1071
1086
  }),
1072
1087
  );
@@ -1244,9 +1259,16 @@ async function waitForDaemonCompletion(
1244
1259
  dispatch.daemonCompletionMatched = true;
1245
1260
  cleanupDaemonDispatches([dispatch]).catch(() => {});
1246
1261
  } else {
1247
- killDaemonJob(dispatch.controlSock, dispatch.daemonShort).catch(
1248
- () => {},
1249
- );
1262
+ buildDaemonControlAuth(dispatch.daemonPaths?.configDir)
1263
+ .catch(() => ({}))
1264
+ .then((controlAuth) =>
1265
+ killDaemonJob(
1266
+ dispatch.controlSock,
1267
+ dispatch.daemonShort,
1268
+ controlAuth,
1269
+ ),
1270
+ )
1271
+ .catch(() => {});
1250
1272
  }
1251
1273
  resolve(finalCompletion);
1252
1274
  };
@@ -173,11 +173,15 @@ function spawnMember(member) {
173
173
  ? process.env.TERM
174
174
  : "xterm-256color",
175
175
  };
176
+ // Worker members get HOME-isolated sandboxes, but antigravity-family agents
177
+ // (agy/gemini) are carved out inside buildWorkerSandboxEnv so their keyring/
178
+ // OAuth auth resolves — pass member.cli so the carve-out applies in-process too.
176
179
  const sandbox =
177
180
  member.role === "worker"
178
181
  ? buildWorkerSandboxEnv({
179
182
  cwd: config.workdir || process.cwd(),
180
183
  sessionId: `${sessionName}-${member.name}`,
184
+ agent: member.cli,
181
185
  env: baseEnv,
182
186
  })
183
187
  : { env: {}, root: null };
@@ -50,7 +50,7 @@ export function decomposeTask(taskDescription, agentCount) {
50
50
  * @returns {string}
51
51
  */
52
52
  export function buildLeadPrompt(taskDescription, config) {
53
- const { agentId, teammateMode = "tmux", workers = [] } = config;
53
+ const { agentId, repoRoot, teammateMode = "tmux", workers = [] } = config;
54
54
 
55
55
  const roster =
56
56
  workers
@@ -59,7 +59,10 @@ export function buildLeadPrompt(taskDescription, config) {
59
59
 
60
60
  const workerIds = workers.map((w) => w.agentId).join(", ");
61
61
 
62
- const bridgePath = "node hub/bridge.mjs";
62
+ // TODO: Require repoRoot once all callers pass absolute repository roots.
63
+ const bridgePath = repoRoot
64
+ ? `node ${String(repoRoot).replace(/\/+$/, "")}/hub/bridge.mjs`
65
+ : "node hub/bridge.mjs";
63
66
 
64
67
  return `리드 에이전트: ${agentId}
65
68
 
@@ -76,6 +79,8 @@ ${roster}
76
79
  - 워커 결과 수집:
77
80
  ${bridgePath} context --agent ${agentId} --max 20
78
81
  - 최종 결과는 topic="task.result"를 모아 통합
82
+ - 모든 워커의 task.result 수신 후 결과를 통합하고 종료하라. 워커가 무응답이면 상태를 보고하고 중단하라.
83
+ - 증거(커밋/테스트/파일) 없는 완료 주장은 통합하지 말고 해당 워커에 redo 를 지시하라.
79
84
 
80
85
  워커 ID: ${workerIds || "(없음)"}
81
86
  지금 즉시 워커를 배정하고 병렬 진행을 관리하라.`;
@@ -435,6 +435,11 @@ export function createSwarmHypervisor(opts) {
435
435
  });
436
436
  }
437
437
 
438
+ // Injectable git seam (#394). Defaults to the execFile closure above; tests
439
+ // override via `_deps.git` to drive the redundant-win commit-evidence gate
440
+ // deterministically without provisioning a real worktree.
441
+ const gitRun = _deps.git || git;
442
+
438
443
  function computeAuthoritativeStatus(shardName, workerEntry, sessions) {
439
444
  const failureInfo = failures.get(shardName) || null;
440
445
  if (failureInfo) {
@@ -508,7 +513,7 @@ export function createSwarmHypervisor(opts) {
508
513
  try {
509
514
  evidence.commitsAhead =
510
515
  Number.parseInt(
511
- await git([
516
+ await gitRun([
512
517
  "rev-list",
513
518
  "--count",
514
519
  `${integrationBranch}..${branchName}`,
@@ -521,14 +526,17 @@ export function createSwarmHypervisor(opts) {
521
526
  }
522
527
 
523
528
  try {
524
- evidence.headCommit = await git(["rev-parse", branchName]);
529
+ evidence.headCommit = await gitRun(["rev-parse", branchName]);
525
530
  } catch {
526
531
  /* best-effort */
527
532
  }
528
533
 
529
534
  if (worker?.worktreePath && !worker?.shardConfig?.host) {
530
535
  try {
531
- const rawStatus = await git(["status", "--short"], worker.worktreePath);
536
+ const rawStatus = await gitRun(
537
+ ["status", "--short"],
538
+ worker.worktreePath,
539
+ );
532
540
  // Only explicitly expected lifecycle deletions are filtered. Plugin
533
541
  // metadata deletions remain dirty because Codex workers need them.
534
542
  evidence.dirtyFiles = extractDirtyFiles(
@@ -663,7 +671,10 @@ export function createSwarmHypervisor(opts) {
663
671
  agent: shard.agent,
664
672
  // #125: append Completion Protocol appendix so workers emit a
665
673
  // sentinel-framed JSON payload that conductor can reliably capture.
666
- prompt: buildWorkerPrompt(shard.prompt, { leaseFiles: shard.files }),
674
+ prompt: buildWorkerPrompt(shard.prompt, {
675
+ leaseFiles: shard.files,
676
+ worktreePath: shard.worktreePath,
677
+ }),
667
678
  workdir: shard.worktreePath || workdir,
668
679
  mcpServers: shard.mcp,
669
680
  worktreePath: shard.worktreePath || null,
@@ -987,6 +998,84 @@ export function createSwarmHypervisor(opts) {
987
998
  checkAllShardsCompleted();
988
999
  return;
989
1000
  }
1001
+
1002
+ if (
1003
+ completionPayload?.status === "failed" ||
1004
+ completionPayload?.status === "blocked"
1005
+ ) {
1006
+ const detail =
1007
+ typeof completionPayload.reason === "string" &&
1008
+ completionPayload.reason.length > 0
1009
+ ? completionPayload.reason
1010
+ : "unspecified";
1011
+ const reason = `worker_reported_${completionPayload.status}:${detail}`;
1012
+
1013
+ if (isRedundant) {
1014
+ eventLog.append("redundant_completion_rejected", {
1015
+ shard: shardName,
1016
+ sessionId,
1017
+ reason,
1018
+ });
1019
+ redundantWorkers.delete(shardName);
1020
+ if (completedWorker) {
1021
+ void closeNativeBridgeRegistration(
1022
+ completedWorker,
1023
+ shardName,
1024
+ "failed",
1025
+ );
1026
+ }
1027
+ checkAllShardsCompleted();
1028
+ return;
1029
+ }
1030
+
1031
+ eventLog.append("worker_reported_non_ok_completion", {
1032
+ shard: shardName,
1033
+ sessionId,
1034
+ status: completionPayload.status,
1035
+ reason,
1036
+ });
1037
+ if (completedWorker) {
1038
+ void closeNativeBridgeRegistration(
1039
+ completedWorker,
1040
+ shardName,
1041
+ "failed",
1042
+ );
1043
+ }
1044
+ failures.set(shardName, {
1045
+ mode: classifyFailure(reason),
1046
+ reason,
1047
+ sessionId,
1048
+ completionPayload,
1049
+ });
1050
+ lockManager.release(shardName);
1051
+
1052
+ const redundant = redundantWorkers.get(shardName);
1053
+ if (redundant) {
1054
+ void redundant.conductor.shutdown("primary_reported_non_ok");
1055
+ }
1056
+
1057
+ emitter.emit("shardFailed", {
1058
+ shardName,
1059
+ failureMode: classifyFailure(reason),
1060
+ reason,
1061
+ });
1062
+
1063
+ cascadeDependencyFailure(shardName);
1064
+ checkAllShardsCompleted();
1065
+ return;
1066
+ }
1067
+ }
1068
+
1069
+ if (isRedundant) {
1070
+ // #394: a redundant worker may only "win" (and SIGKILL the still-running
1071
+ // primary) once it has produced real, integratable commits. The earlier
1072
+ // F7 payload check trusts a self-report; agy/gemini redundant workers are
1073
+ // known to signal completion with zero commits (and legacy workers emit
1074
+ // no payload at all, bypassing F7). Gate the win on authoritative git
1075
+ // evidence collected now — before any kill — so a phantom completion can
1076
+ // never kill the worker that is doing the actual work.
1077
+ void finalizeRedundantWin(shardName, sessionId, completedWorker);
1078
+ return;
990
1079
  }
991
1080
 
992
1081
  if (completedWorker) {
@@ -999,30 +1088,125 @@ export function createSwarmHypervisor(opts) {
999
1088
 
1000
1089
  completedShards.add(shardName);
1001
1090
 
1002
- if (isRedundant) {
1003
- // Redundant worker completed first — kill primary if still running
1004
- const primary = workers.get(shardName);
1005
- const redundant = redundantWorkers.get(shardName);
1006
- if (redundant) {
1007
- workers.set(shardName, redundant);
1008
- redundantWorkers.delete(shardName);
1009
- }
1010
- if (primary && !isTerminal(primary)) {
1011
- eventLog.append("redundant_wins", { shard: shardName });
1012
- void primary.conductor.shutdown("redundant_completed_first");
1013
- }
1014
- } else {
1015
- // Primary completed — kill redundant if exists
1016
- const redundant = redundantWorkers.get(shardName);
1017
- if (redundant) {
1018
- void redundant.conductor.shutdown("primary_completed_first");
1019
- }
1091
+ // Primary completed — kill redundant if exists
1092
+ const redundant = redundantWorkers.get(shardName);
1093
+ if (redundant) {
1094
+ void redundant.conductor.shutdown("primary_completed_first");
1020
1095
  }
1021
1096
 
1022
1097
  emitter.emit("shardCompleted", { shardName, sessionId, isRedundant });
1023
1098
  checkAllShardsCompleted();
1024
1099
  }
1025
1100
 
1101
+ /**
1102
+ * Gate a redundant worker's completion on authoritative git commit evidence
1103
+ * before it is allowed to win the shard and SIGKILL the primary (#394).
1104
+ *
1105
+ * A redundant worker with no commits ahead of the base branch is NOT a valid
1106
+ * winner: it is rejected, the primary is left running, and the shard is not
1107
+ * marked complete. Only a worker with `commitsAhead > 0` promotes itself and
1108
+ * terminates the primary.
1109
+ *
1110
+ * @param {string} shardName
1111
+ * @param {string} sessionId
1112
+ * @param {object} redundantWorker — entry fetched before this async hop
1113
+ */
1114
+ async function finalizeRedundantWin(shardName, sessionId, redundantWorker) {
1115
+ const redundant = redundantWorker || redundantWorkers.get(shardName);
1116
+ if (!redundant) {
1117
+ // The primary already completed/failed and cleared the redundant entry
1118
+ // (primary_completed_first / primary_failed_f7). Nothing to promote.
1119
+ checkAllShardsCompleted();
1120
+ return;
1121
+ }
1122
+
1123
+ // A redundant worker may win only while the shard is still undecided. This
1124
+ // is checked TWICE — once up front (a cheap early-out before the git
1125
+ // round-trip) and again AFTER the async evidence hop. The second check is
1126
+ // the load-bearing one: the primary can complete/fail while
1127
+ // collectCommitEvidence() is in flight, and without re-checking, a slow git
1128
+ // probe would resume and flip an already-decided winner via
1129
+ // `workers.set(shardName, redundant)` (#394 TOCTOU race).
1130
+ const retireSupersededRedundant = (phase) => {
1131
+ eventLog.append("redundant_win_superseded", {
1132
+ shard: shardName,
1133
+ sessionId,
1134
+ phase,
1135
+ });
1136
+ redundantWorkers.delete(shardName);
1137
+ void closeNativeBridgeRegistration(redundant, shardName, "failed");
1138
+ checkAllShardsCompleted();
1139
+ };
1140
+
1141
+ if (completedShards.has(shardName) || failures.has(shardName)) {
1142
+ retireSupersededRedundant("before_evidence");
1143
+ return;
1144
+ }
1145
+
1146
+ let evidence;
1147
+ try {
1148
+ evidence = await collectCommitEvidence(redundant, baseBranch);
1149
+ } catch (err) {
1150
+ evidence = { commitsAhead: 0, dirty: false, error: err.message };
1151
+ }
1152
+
1153
+ // Re-check after the await: the primary may have won while we waited.
1154
+ if (completedShards.has(shardName) || failures.has(shardName)) {
1155
+ retireSupersededRedundant("after_evidence");
1156
+ return;
1157
+ }
1158
+
1159
+ // A redundant worker may win only with CLEAN, integratable commits — the
1160
+ // exact bar integration applies (collectCommitEvidence.ok =
1161
+ // commitsAhead > 0 && !dirty). A weaker gate (commits-only) would promote a
1162
+ // dirty-but-committed redundant, kill the primary, then fail integration on
1163
+ // that same dirty evidence (F6) — losing the shard the primary could have
1164
+ // delivered (#394 re-review). Reject anything integration would not accept.
1165
+ if (!evidence?.ok) {
1166
+ const reason = evidence?.error
1167
+ ? "evidence_error"
1168
+ : evidence?.dirty
1169
+ ? "dirty_worktree"
1170
+ : "no_commit";
1171
+ eventLog.append("redundant_win_rejected", {
1172
+ shard: shardName,
1173
+ sessionId,
1174
+ reason,
1175
+ commitsAhead: evidence?.commitsAhead || 0,
1176
+ dirty: Boolean(evidence?.dirty),
1177
+ error: evidence?.error || null,
1178
+ });
1179
+ redundantWorkers.delete(shardName);
1180
+ void closeNativeBridgeRegistration(redundant, shardName, "failed");
1181
+ // Re-check in case the primary already reached a terminal state while we
1182
+ // were collecting evidence.
1183
+ checkAllShardsCompleted();
1184
+ return;
1185
+ }
1186
+
1187
+ // Clean, integratable commit evidence — promote the redundant worker and
1188
+ // kill the primary if it is still running.
1189
+ const primary = workers.get(shardName);
1190
+ workers.set(shardName, redundant);
1191
+ redundantWorkers.delete(shardName);
1192
+ completedShards.add(shardName);
1193
+ void closeNativeBridgeRegistration(redundant, shardName, "completed");
1194
+ if (primary && !isTerminal(primary)) {
1195
+ eventLog.append("redundant_wins", {
1196
+ shard: shardName,
1197
+ commitsAhead: evidence.commitsAhead,
1198
+ });
1199
+ void primary.conductor.shutdown("redundant_completed_first");
1200
+ }
1201
+
1202
+ emitter.emit("shardCompleted", {
1203
+ shardName,
1204
+ sessionId,
1205
+ isRedundant: true,
1206
+ });
1207
+ checkAllShardsCompleted();
1208
+ }
1209
+
1026
1210
  function handleShardFailed(shardName, sessionId, reason, isRedundant) {
1027
1211
  const failureMode = classifyFailure(reason);
1028
1212
 
@@ -1502,31 +1686,33 @@ export function createSwarmHypervisor(opts) {
1502
1686
  "before_worktree_cleanup",
1503
1687
  );
1504
1688
 
1505
- // Best-effort: preserve any uncommitted worker changes before removing
1506
- // the worktree. Silent on failure must not block cleanup or mask the
1507
- // original failure reason.
1508
- if (failureReason) {
1509
- try {
1510
- const preservation = await preserveWorktreePatchImpl({
1689
+ // Best-effort: preserve any uncommitted worker changes before removing the
1690
+ // worktree, on BOTH the failure and success teardown paths (#394). A worker
1691
+ // can exit 0 yet leave dirty, uncommitted work (the hub-observability case:
1692
+ // exit 0, commits_made []), and the success cleanup carries no
1693
+ // failureReason — without this it would silently delete that work.
1694
+ // preserveWorktreePatchImpl self-skips when the worktree is clean, so a
1695
+ // properly-committed shard saves nothing here.
1696
+ try {
1697
+ const preservation = await preserveWorktreePatchImpl({
1698
+ worktreePath: worker.worktreePath,
1699
+ shardId: shardName,
1700
+ recoveryDir: join(workdir, ".codex-swarm", "recovery"),
1701
+ });
1702
+ if (preservation?.ok && !preservation.skipped) {
1703
+ outcome.recoveryPatch = {
1704
+ patchPath: preservation.patchPath,
1705
+ manifestPath: preservation.manifestPath || null,
1706
+ };
1707
+ eventLog.append("recovery_patch_saved", {
1708
+ shard: shardName,
1511
1709
  worktreePath: worker.worktreePath,
1512
- shardId: shardName,
1513
- recoveryDir: join(workdir, ".codex-swarm", "recovery"),
1710
+ patchPath: preservation.patchPath,
1711
+ reason: failureReason || "dirty_on_cleanup",
1514
1712
  });
1515
- if (preservation?.ok && !preservation.skipped) {
1516
- outcome.recoveryPatch = {
1517
- patchPath: preservation.patchPath,
1518
- manifestPath: preservation.manifestPath || null,
1519
- };
1520
- eventLog.append("recovery_patch_saved", {
1521
- shard: shardName,
1522
- worktreePath: worker.worktreePath,
1523
- patchPath: preservation.patchPath,
1524
- reason: failureReason,
1525
- });
1526
- }
1527
- } catch {
1528
- /* silent: preservation failures must not block cleanup */
1529
1713
  }
1714
+ } catch {
1715
+ /* silent: preservation failures must not block cleanup */
1530
1716
  }
1531
1717
 
1532
1718
  try {
@@ -1,9 +1,9 @@
1
1
  // hub/team/worker-completion-validator.mjs — Enforce worker completion schema.
2
2
  // Issue #115 Lane 1 / F7_worker_did_not_commit.
3
3
  //
4
- // A shard worker MUST emit a completion JSON with `status: "ok"` + a non-empty
5
- // `commits_made` array. Reporting without committing is the #1 cause of lost
6
- // swarm work guarded here before integration trusts the worker.
4
+ // A shard worker that reports `status: "ok"` MUST include a non-empty
5
+ // `commits_made` array. Non-ok statuses are valid terminal reports, but they
6
+ // are not allowed to masquerade as successful completion.
7
7
 
8
8
  import { readFileSync } from "node:fs";
9
9
  import { dirname, join } from "node:path";
@@ -34,25 +34,20 @@ export function validateWorkerCompletion(payload) {
34
34
  return { ok: false, reason: "payload_not_object" };
35
35
  }
36
36
 
37
- const valid = compiled(payload);
37
+ const status = payload.status;
38
+ if (!["ok", "failed", "blocked"].includes(status)) {
39
+ return { ok: false, reason: `invalid_status:${status ?? "undefined"}` };
40
+ }
41
+
42
+ const schemaPayload =
43
+ status === "blocked" ? { ...payload, status: "failed" } : payload;
44
+ const valid = compiled(schemaPayload);
38
45
  if (!valid) {
39
46
  const firstError = compiled.errors?.[0];
40
47
  const reason = formatError(firstError, payload);
41
48
  return { ok: false, reason };
42
49
  }
43
50
 
44
- // BUG-G (#130): the schema's if-then only constrains commits_made when
45
- // status='ok', so status='failed' payloads pass AJV vacuously. Without
46
- // this guard the hypervisor F7 path treats a worker self-reported failure
47
- // as a successful completion and integrates phantom shards.
48
- if (payload.status === "failed") {
49
- const detail =
50
- typeof payload.reason === "string" && payload.reason.length > 0
51
- ? payload.reason
52
- : "unspecified";
53
- return { ok: false, reason: `worker_self_reported_failure:${detail}` };
54
- }
55
-
56
51
  return { ok: true };
57
52
  }
58
53
 
@@ -19,6 +19,23 @@ function isDisabled(env = {}) {
19
19
  return /^(0|false|no|off)$/i.test(String(env.TFX_WORKER_SANDBOX ?? ""));
20
20
  }
21
21
 
22
+ // Antigravity-family CLIs (agy/gemini) authenticate only via interactive OAuth and
23
+ // store credentials in the OS keyring plus HOME-relative ~/.gemini state bound to the
24
+ // logged-in user. They expose no headless-auth flag and no config-home env
25
+ // (antigravity-cli issues #223/#155/#316), so overriding HOME forces a re-auth that
26
+ // never completes in a headless swarm/team worker — the worker times out at the OAuth
27
+ // wait and does no work (the agy "no_commit" symptom). Keep the host HOME for these
28
+ // agents so the keyring and ~/.gemini resolve. Codex stays isolated via CODEX_HOME.
29
+ const AUTH_HOME_BOUND_AGENTS = new Set(["antigravity", "agy", "gemini"]);
30
+
31
+ function isAuthHomeBoundAgent(agent) {
32
+ return AUTH_HOME_BOUND_AGENTS.has(
33
+ String(agent ?? "")
34
+ .trim()
35
+ .toLowerCase(),
36
+ );
37
+ }
38
+
22
39
  function windowsHomeParts(home) {
23
40
  const normalized = String(home || "").replace(/\//g, "\\");
24
41
  const match = normalized.match(/^([a-zA-Z]:)(\\.*)$/);
@@ -36,15 +53,26 @@ function mkdirAll(paths) {
36
53
  * @param {object} opts
37
54
  * @param {string} [opts.cwd] Worktree/current working directory for the worker.
38
55
  * @param {string} [opts.sessionId] Stable session/member id.
56
+ * @param {string} [opts.agent] Worker CLI/agent; antigravity-family agents skip the
57
+ * HOME override so their keyring/OAuth credentials resolve (see note above).
39
58
  * @param {object} [opts.env] Existing env used for opt-out and explicit root.
40
59
  * @param {boolean} [opts.create=true] Create directories eagerly.
41
- * @returns {{env: object, root: string|null, home: string|null, disabled: boolean}}
60
+ * @returns {{env: object, root: string|null, home: string|null, disabled: boolean, reason?: string}}
42
61
  */
43
62
  export function buildWorkerSandboxEnv(opts = {}) {
44
63
  const env = opts.env && typeof opts.env === "object" ? opts.env : {};
45
64
  if (isDisabled(env)) {
46
65
  return { env: {}, root: null, home: null, disabled: true };
47
66
  }
67
+ if (isAuthHomeBoundAgent(opts.agent)) {
68
+ return {
69
+ env: {},
70
+ root: null,
71
+ home: null,
72
+ disabled: true,
73
+ reason: "auth-home-bound-agent",
74
+ };
75
+ }
48
76
 
49
77
  const cwd = resolve(opts.cwd || process.cwd());
50
78
  const sessionId = safeSegment(opts.sessionId, "worker");
@@ -117,10 +117,11 @@ export function spawnTrayForHub({
117
117
  trayPath,
118
118
  nodePath = process.execPath,
119
119
  env = process.env,
120
+ cwd = process.cwd(),
120
121
  spawnFn = spawn,
121
122
  } = {}) {
122
123
  if (env?.TFX_HUB_AUTO_TRAY === "0") return { status: "disabled" };
123
- if (isWorktreeOrEphemeralHubContext({ env })) {
124
+ if (isWorktreeOrEphemeralHubContext({ cwd, env })) {
124
125
  return { status: "disabled", reason: "ephemeral-or-worktree-context" };
125
126
  }
126
127
  if (platform !== "darwin") return { status: "unsupported-platform" };
@@ -971,6 +971,7 @@ export class DelegatorMcpWorker {
971
971
  args.workerIndex ||
972
972
  args.agentType ||
973
973
  "route",
974
+ agent: args.provider,
974
975
  env: baseEnv,
975
976
  });
976
977
  const env = { ...baseEnv, ...sandbox.env };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@triflux/remote",
3
- "version": "10.34.0",
3
+ "version": "10.35.0",
4
4
  "description": "triflux remote — team mode, psmux, MCP workers, SQLite store.",
5
5
  "type": "module",
6
6
  "main": "hub/index.mjs",
@@ -13,8 +13,8 @@ export const MANIFEST_PATH = join(
13
13
  "mcp-enabled.json",
14
14
  );
15
15
 
16
- /** API 키 불필요 — 항상 활성화 for gateway-managed MCP servers */
17
- export const CORE_SERVERS = Object.freeze(["serena"]);
16
+ /** API 키 불필요 — 항상 활성화 for gateway-managed MCP servers (현재 없음 — serena는 2026-06-10 core에서 제거) */
17
+ export const CORE_SERVERS = Object.freeze([]);
18
18
 
19
19
  /** 검색 MCP — API 키 필요 */
20
20
  export const SEARCH_SERVERS = Object.freeze([