triflux 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/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 "../bridge.mjs";
24
24
  import { escapePwshSingleQuoted } from "../cli-adapter-base.mjs";
@@ -26,6 +26,7 @@ import { getMaxSpawnPerSec } from "../lib/spawn-trace.mjs";
26
26
  import { IS_WINDOWS } from "../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
  };