@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 +77 -1
- package/hub/server.mjs +4 -2
- package/hub/team/build-worker-prompt.mjs +23 -4
- package/hub/team/claude-daemon-control.mjs +348 -4
- package/hub/team/claude-native-bridge.mjs +6 -8
- package/hub/team/conductor.mjs +1 -0
- package/hub/team/handoff.mjs +8 -4
- package/hub/team/headless.mjs +33 -11
- package/hub/team/native-supervisor.mjs +4 -0
- package/hub/team/orchestrator.mjs +7 -2
- package/hub/team/swarm-hypervisor.mjs +230 -44
- package/hub/team/worker-completion-validator.mjs +11 -16
- package/hub/team/worker-sandbox.mjs +29 -1
- package/hub/tray-lifecycle.mjs +2 -1
- package/hub/workers/delegator-mcp.mjs +1 -0
- package/package.json +1 -1
- package/scripts/lib/mcp-manifest.mjs +2 -2
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:
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
-
|
|
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({
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
|
26
|
-
//
|
|
27
|
-
|
|
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);
|
package/hub/team/conductor.mjs
CHANGED
|
@@ -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 = {
|
package/hub/team/handoff.mjs
CHANGED
|
@@ -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 블록을 파싱한다.
|
package/hub/team/headless.mjs
CHANGED
|
@@ -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
|
-
?
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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, {
|
|
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
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
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
|
-
//
|
|
1507
|
-
//
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
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
|
-
|
|
1513
|
-
|
|
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
|
|
5
|
-
// `commits_made` array.
|
|
6
|
-
//
|
|
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
|
|
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");
|
package/hub/tray-lifecycle.mjs
CHANGED
|
@@ -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" };
|
package/package.json
CHANGED
|
@@ -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([
|
|
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([
|