triflux 10.25.0 → 10.25.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/bin/triflux.mjs +62 -0
  2. package/config/mcp-registry.json +85 -0
  3. package/config/routing-policy.json +124 -0
  4. package/hooks/safety-guard.mjs +8 -1
  5. package/hub/team/claude-daemon-control.mjs +59 -0
  6. package/hub/team/claude-native-bridge.mjs +211 -0
  7. package/hub/team/cli/commands/start/index.mjs +4 -1
  8. package/hub/team/cli/commands/start/parse-args.mjs +32 -2
  9. package/hub/team/headless.mjs +20 -2
  10. package/hub/team/swarm-cli.mjs +15 -0
  11. package/hub/team/swarm-hypervisor.mjs +171 -7
  12. package/hub/team/worktree-lifecycle.mjs +4 -16
  13. package/package.json +4 -3
  14. package/scripts/__tests__/install-mcp-gateway-startup.test.mjs +24 -0
  15. package/scripts/__tests__/mcp-gateway-health-check.test.mjs +208 -0
  16. package/scripts/__tests__/mcp-gateway-wrapper-check.test.mjs +54 -0
  17. package/scripts/__tests__/release-governance.test.mjs +161 -1
  18. package/scripts/__tests__/tfx-route-bash-node-parity.test.mjs +35 -0
  19. package/scripts/doctor-diagnose.mjs +14 -8
  20. package/scripts/install-mcp-gateway-startup.mjs +3 -1
  21. package/scripts/lib/mcp-gateway-health-check.mjs +159 -0
  22. package/scripts/lib/mcp-gateway-wrapper-check.mjs +35 -0
  23. package/scripts/pack.mjs +23 -18
  24. package/scripts/release/check-packages-mirror.mjs +20 -4
  25. package/scripts/release/prepare.mjs +2 -1
  26. package/scripts/release/publish.mjs +100 -7
  27. package/scripts/release/verify.mjs +39 -13
  28. package/scripts/release/version-manifest.json +8 -0
  29. package/scripts/tfx-route.mjs +13 -0
  30. package/scripts/tfx-route.sh +44 -12
  31. package/skills/tfx-analysis/SKILL.md +12 -12
  32. package/skills/tfx-auto/SKILL.md +14 -12
  33. package/skills/tfx-auto/SKILL.md.tmpl +5 -5
  34. package/skills/tfx-consensus/SKILL.md.tmpl +8 -8
  35. package/skills/tfx-debate/SKILL.md +1 -1
  36. package/skills/tfx-debate/SKILL.md.tmpl +5 -5
  37. package/skills/tfx-doctor/SKILL.md +5 -5
  38. package/skills/tfx-doctor/SKILL.md.tmpl +5 -5
  39. package/skills/tfx-fullcycle/SKILL.md.tmpl +8 -8
  40. package/skills/tfx-hub/SKILL.md +2 -2
  41. package/skills/tfx-hub/SKILL.md.tmpl +2 -2
  42. package/skills/tfx-index/SKILL.md +9 -9
  43. package/skills/tfx-index/SKILL.md.tmpl +9 -9
  44. package/skills/tfx-interview/SKILL.md +29 -29
  45. package/skills/tfx-interview/SKILL.md.tmpl +24 -24
  46. package/skills/tfx-multi/SKILL.md.tmpl +4 -4
  47. package/skills/tfx-multi/references/agent-wrapper-rules.md +3 -3
  48. package/skills/tfx-panel/SKILL.md.tmpl +5 -5
  49. package/skills/tfx-persist/SKILL.md.tmpl +15 -15
  50. package/skills/tfx-plan/SKILL.md +14 -14
  51. package/skills/tfx-plan/SKILL.md.tmpl +6 -6
  52. package/skills/tfx-plan/skill.json +1 -1
  53. package/skills/tfx-profile/SKILL.md +10 -10
  54. package/skills/tfx-profile/SKILL.md.tmpl +10 -10
  55. package/skills/tfx-profile/skill.json +1 -1
  56. package/skills/tfx-prune/SKILL.md +4 -4
  57. package/skills/tfx-prune/SKILL.md.tmpl +4 -4
  58. package/skills/tfx-qa/SKILL.md +4 -4
  59. package/skills/tfx-research/SKILL.md +9 -9
  60. package/skills/tfx-research/SKILL.md.tmpl +12 -12
  61. package/skills/tfx-research/skill.json +1 -1
  62. package/skills/tfx-review/SKILL.md +13 -13
  63. package/skills/tfx-setup/SKILL.md +25 -8
  64. package/skills/tfx-setup/SKILL.md.tmpl +8 -8
  65. package/skills/tfx-ship/SKILL.md +4 -5
  66. package/skills/tfx-swarm/SKILL.md.tmpl +4 -4
package/bin/triflux.mjs CHANGED
@@ -4670,6 +4670,68 @@ async function cmdDoctor(options = {}) {
4670
4670
  }
4671
4671
  }
4672
4672
 
4673
+ // ── MCP Gateway Health ──
4674
+ // install-mcp-gateway-startup 으로 띄운 LaunchAgent/systemd daemon 의 stdout
4675
+ // (~/.local/state/triflux/mcp-gateway.out.log) 를 파싱해 missing-env 등으로
4676
+ // skip 된 server 를 잡는다. 로그가 없으면 gateway 미설치/미실행으로 침묵.
4677
+ section("MCP Gateway Health");
4678
+ {
4679
+ const { checkMcpGatewayHealth, summarizeMcpGatewayHealth } = await import(
4680
+ "../scripts/lib/mcp-gateway-health-check.mjs"
4681
+ );
4682
+ const gatewayHealth = checkMcpGatewayHealth();
4683
+ const summary = summarizeMcpGatewayHealth(gatewayHealth);
4684
+ addDoctorCheck(report, {
4685
+ name: "mcp-gateway-health",
4686
+ status: summary.level === "warn" ? "warning" : "ok",
4687
+ log_path: gatewayHealth.logPath,
4688
+ findings: gatewayHealth.findings,
4689
+ started: gatewayHealth.started,
4690
+ skipped: gatewayHealth.skipped,
4691
+ ...(summary.fix ? { fix: summary.fix } : {}),
4692
+ });
4693
+ if (summary.level === "skip") {
4694
+ info(summary.message);
4695
+ } else if (summary.level === "ok") {
4696
+ ok(summary.message);
4697
+ } else {
4698
+ warn(summary.message);
4699
+ if (summary.fix) info(`수정: ${summary.fix}`);
4700
+ issues++;
4701
+ }
4702
+ }
4703
+
4704
+ // ── MCP Gateway Wrapper ──
4705
+ // 로그가 생기기 전 단계에서 wrapper 자체가 secrets.env 를 source 하는지 확인한다.
4706
+ section("MCP Gateway Wrapper");
4707
+ {
4708
+ const { checkWrapperSourcing } = await import(
4709
+ "../scripts/lib/mcp-gateway-wrapper-check.mjs"
4710
+ );
4711
+ const wrapperCheck = await checkWrapperSourcing();
4712
+ addDoctorCheck(report, {
4713
+ name: "mcp-gateway-wrapper-sourcing",
4714
+ status:
4715
+ wrapperCheck.status === "warn" ? "warning" : wrapperCheck.status,
4716
+ path: wrapperCheck.wrapperPath,
4717
+ ...(wrapperCheck.message ? { message: wrapperCheck.message } : {}),
4718
+ ...(wrapperCheck.suggestedFix
4719
+ ? { fix: wrapperCheck.suggestedFix }
4720
+ : {}),
4721
+ });
4722
+
4723
+ if (wrapperCheck.status === "ok") {
4724
+ ok("wrapper sources secrets.env");
4725
+ } else if (wrapperCheck.status === "warn") {
4726
+ warn(wrapperCheck.message);
4727
+ info(`수정: ${wrapperCheck.suggestedFix}`);
4728
+ issues++;
4729
+ } else {
4730
+ warn("mcp-gateway wrapper not installed");
4731
+ if (wrapperCheck.message) info(wrapperCheck.message);
4732
+ }
4733
+ }
4734
+
4673
4735
  // ── Codex Config Health (BUG-H #132) ──
4674
4736
  // _codex_config_swap 의 restore 가 Windows lock/ACL 로 실패하면
4675
4737
  // ~/.codex/config.toml.pre-exec 가 남아 [mcp_servers.*] 섹션이 영구 손실된다.
@@ -0,0 +1,85 @@
1
+ {
2
+ "$schema": "mcp-registry-schema",
3
+ "version": 1,
4
+ "description": "MCP 서버 중앙 레지스트리 — 진실의 원천",
5
+ "defaults": {
6
+ "transport": "hub-url",
7
+ "hub_base": "http://127.0.0.1:27888"
8
+ },
9
+ "policy_notes": {
10
+ "policy": "Server policy is the SSOT for client sync shape: hosted writes url+headers, gateway-sse writes http://127.0.0.1:81XX/sse with SSE metadata, and stdio writes command/args/env.",
11
+ "transport": "Server transport accepts \"hub-url\" for triflux Hub URL flow, \"http\" for direct Streamable HTTP MCP endpoints, or \"stdio\" for upstream-stdio-only MCP servers. Gateway-backed stdio upstreams use policy:\"gateway-sse\" plus gateway_port so clients receive URL/SSE config.",
12
+ "headers": "Optional headers are allowed only for HTTP-compatible transports. Each header value must be a descriptor: {\"value\":\"literal\"} for non-secret static values, {\"env\":\"ENV_VAR_NAME\"} for secrets resolved at sync/runtime, or {\"env\":\"ENV_VAR_NAME\",\"prefix\":\"Bearer \"} for common authorization formats.",
13
+ "secret_safety": "Resolved secret values must not be written back to this registry file. Missing env vars warn during sync and do not emit empty secret headers.",
14
+ "sync_denylist": "Array of client:server strings skipped by proactive registry sync, for example gemini:tfx-hub."
15
+ },
16
+ "servers": {
17
+ "tfx-hub": {
18
+ "policy": "hosted",
19
+ "transport": "hub-url",
20
+ "url": "http://127.0.0.1:27888/mcp",
21
+ "safe": true,
22
+ "targets": ["claude", "gemini", "codex", "antigravity"],
23
+ "description": "triflux Hub MCP 서버"
24
+ },
25
+ "context7": {
26
+ "policy": "hosted",
27
+ "transport": "http",
28
+ "url": "https://mcp.context7.com/mcp",
29
+ "safe": true,
30
+ "targets": ["claude", "gemini", "codex", "antigravity"],
31
+ "description": "Upstash Context7 — 라이브러리 문서/코드 컨텍스트 (HTTP MCP, API key 불필요)"
32
+ },
33
+ "exa": {
34
+ "policy": "hosted",
35
+ "transport": "http",
36
+ "url": "https://mcp.exa.ai/mcp",
37
+ "headers": {
38
+ "Authorization": { "env": "EXA_API_KEY", "prefix": "Bearer " }
39
+ },
40
+ "safe": true,
41
+ "targets": ["claude", "gemini", "codex", "antigravity"],
42
+ "description": "Exa neural/semantic web search — 학술/기술 깊이. Key 발급: https://exa.ai/dashboard → secrets.env의 EXA_API_KEY"
43
+ },
44
+ "serena": {
45
+ "policy": "gateway-sse",
46
+ "gateway_port": 8105,
47
+ "safe": true,
48
+ "targets": ["claude", "gemini", "codex", "antigravity"],
49
+ "description": "Serena MCP — supergateway singleton SSE endpoint (:8105)"
50
+ },
51
+ "brave-search": {
52
+ "policy": "gateway-sse",
53
+ "gateway_port": 8101,
54
+ "safe": true,
55
+ "targets": ["claude", "gemini", "codex", "antigravity"],
56
+ "description": "Brave Search MCP — supergateway singleton SSE endpoint (:8101). BRAVE_API_KEY 환경변수 필요 (https://brave.com/search/api/). secrets.env 의 BRAVE_API_KEY 참조."
57
+ },
58
+ "tavily": {
59
+ "policy": "hosted",
60
+ "transport": "http",
61
+ "url": "https://mcp.tavily.com/mcp",
62
+ "headers": {
63
+ "Authorization": { "env": "TAVILY_API_KEY", "prefix": "Bearer " }
64
+ },
65
+ "safe": true,
66
+ "targets": ["claude", "gemini", "codex", "antigravity"],
67
+ "description": "Tavily research — 비용/운영/DX/일반 웹. Key 발급: https://app.tavily.com/home → secrets.env의 TAVILY_API_KEY"
68
+ }
69
+ },
70
+ "policies": {
71
+ "stdio_action": "replace-with-hub",
72
+ "unknown_server_action": "warn",
73
+ "sync_denylist": [],
74
+ "watched_paths": [
75
+ "~/.gemini/settings.json",
76
+ "~/.codex/config.toml",
77
+ "~/.claude.json",
78
+ "~/.claude/settings.json",
79
+ "~/.claude/settings.local.json",
80
+ ".claude/mcp.json",
81
+ ".mcp.json",
82
+ "~/.gemini/config/mcp_config.json"
83
+ ]
84
+ }
85
+ }
@@ -0,0 +1,124 @@
1
+ {
2
+ "version": 1,
3
+ "freshness": {
4
+ "max_snapshot_age_ms": 900000,
5
+ "max_cross_provider_skew_ms": 300000,
6
+ "local_clock_skew_guard_ms": 60000
7
+ },
8
+ "thresholds": {
9
+ "reset_imminent_ms": 86400000,
10
+ "exhaustion_high_percent": 80,
11
+ "exhaustion_low_percent": 20,
12
+ "expiry_drain_window_ms": 604800000,
13
+ "drain_usage_ceiling_percent": 50
14
+ },
15
+ "scenarios": [
16
+ {
17
+ "id": "S1",
18
+ "name": "claude-reset-imminent-drain",
19
+ "when": {
20
+ "provider": "claude",
21
+ "weeklyResetsAtLteMs": 86400000,
22
+ "weeklyPercentLt": 100,
23
+ "hasMultipleAccounts": true
24
+ },
25
+ "then": {
26
+ "lane": "multi",
27
+ "primary_cli": "claude",
28
+ "reserve_codex_share": 0.1,
29
+ "reason": "claude-weekly-reset-imminent-drain-fleet"
30
+ }
31
+ },
32
+ {
33
+ "id": "S2",
34
+ "name": "codex-reset-imminent-drain",
35
+ "when": {
36
+ "provider": "codex",
37
+ "weeklyResetsAtLteMs": 86400000,
38
+ "weeklyPercentLt": 100
39
+ },
40
+ "then": {
41
+ "lane": "swarm",
42
+ "primary_cli": "codex",
43
+ "reserve_claude_share": 0.1,
44
+ "reason": "codex-weekly-reset-imminent-drain-fleet"
45
+ }
46
+ },
47
+ {
48
+ "id": "S3",
49
+ "name": "claude-burned-codex-fresh",
50
+ "when": {
51
+ "claude_weekly_percent_gte": 80,
52
+ "codex_weekly_percent_lt": 20,
53
+ "claude_weekly_resets_at_gt_ms": 172800000
54
+ },
55
+ "then": {
56
+ "lane": "single",
57
+ "primary_cli": "codex",
58
+ "claude_lanes_allowed": ["explore", "final-challenge"],
59
+ "reason": "claude-burned-codex-fresh-rebalance"
60
+ }
61
+ },
62
+ {
63
+ "id": "S4",
64
+ "name": "claude-multi-account-staggered",
65
+ "when": {
66
+ "provider": "claude",
67
+ "accountCountGte": 2,
68
+ "staggered": true
69
+ },
70
+ "then": {
71
+ "lane": "multi",
72
+ "primary_cli": "claude",
73
+ "rank_by": "expiring_capacity_score",
74
+ "reason": "claude-multi-account-staggered-reset"
75
+ }
76
+ },
77
+ {
78
+ "id": "S5",
79
+ "name": "claude-parallel-leasemany",
80
+ "when": {
81
+ "provider": "claude",
82
+ "request_team_size_gte": 2,
83
+ "accountCountGte": 2
84
+ },
85
+ "then": {
86
+ "lane": "swarm",
87
+ "primary_cli": "claude",
88
+ "atomic": "leaseMany",
89
+ "atomic_policy": "balanced",
90
+ "reason": "claude-parallel-multi-account"
91
+ }
92
+ },
93
+ {
94
+ "id": "S6",
95
+ "name": "account-expiry-drain",
96
+ "when": {
97
+ "expiresAtLteMs": 604800000,
98
+ "weeklyPercentLt": 50,
99
+ "circuitStateNotOpen": true
100
+ },
101
+ "then": {
102
+ "lane": "single",
103
+ "riskTier": "drain",
104
+ "elevated_weight": 2.0,
105
+ "reason": "account-expiry-drain-before-loss"
106
+ }
107
+ }
108
+ ],
109
+ "fallback": {
110
+ "default_mode": "codex-default",
111
+ "default_cli": "codex",
112
+ "default_model": "gpt-5.5",
113
+ "triggers": [
114
+ "hud_null",
115
+ "hud_stale_gte_15min",
116
+ "weekly_reset_in_past",
117
+ "cross_provider_skew_exceeded",
118
+ "broker_empty",
119
+ "all_circuits_open",
120
+ "mirror_drift",
121
+ "flag_disabled"
122
+ ]
123
+ }
124
+ }
@@ -340,7 +340,14 @@ function main() {
340
340
  return hasSegmentInvocation(cmd, WT_DIRECT_PATTERNS);
341
341
  }
342
342
 
343
- if (isWtDirectInvocation(command)) {
343
+ // wt.exe는 Windows Terminal 전용. macOS/Linux에서는 PATH에 존재하지 않아
344
+ // 차단해도 dormant이며, 문서/로그 텍스트의 wt 단어가 false positive로 잡힐
345
+ // 위험만 있다. win32에서만 차단 적용.
346
+ // SAFETY_GUARD_FORCE_PLATFORM env로 테스트에서 platform 시뮬레이션 가능
347
+ // (mac CI에서 win32 차단 동작을 검증하기 위함).
348
+ const guardPlatform =
349
+ process.env.SAFETY_GUARD_FORCE_PLATFORM || process.platform;
350
+ if (guardPlatform === "win32" && isWtDirectInvocation(command)) {
344
351
  blockCommand(WT_DIRECT_BLOCK_MESSAGE, command);
345
352
  }
346
353
 
@@ -133,6 +133,23 @@ export function findDaemonJobByShort(listResponse, short) {
133
133
  return listResponse.jobs.find((job) => job?.short === short) || null;
134
134
  }
135
135
 
136
+ export function findDaemonJobBySessionId(listResponse, sessionId) {
137
+ if (!Array.isArray(listResponse?.jobs)) return null;
138
+ const expected = String(sessionId || "");
139
+ if (!expected) return null;
140
+ return (
141
+ listResponse.jobs.find((job) => {
142
+ const candidate =
143
+ job?.sessionId ??
144
+ job?.session_id ??
145
+ job?.dispatch?.sessionId ??
146
+ job?.d?.sessionId ??
147
+ "";
148
+ return String(candidate) === expected;
149
+ }) || null
150
+ );
151
+ }
152
+
136
153
  export async function waitForDaemonJobPid(
137
154
  controlSock,
138
155
  short,
@@ -158,3 +175,45 @@ export async function killDaemonJob(controlSock, short) {
158
175
  short,
159
176
  });
160
177
  }
178
+
179
+ export async function sendKillBySessionId({
180
+ daemonPaths,
181
+ sessionId,
182
+ timeoutMs = 6000,
183
+ } = {}) {
184
+ const controlSock = daemonPaths?.controlSock;
185
+ if (!controlSock || !sessionId) {
186
+ return { ok: true, killed: false, reason: "missing_target" };
187
+ }
188
+
189
+ try {
190
+ const list = await sendClaudeControlRequest(
191
+ controlSock,
192
+ {
193
+ proto: 1,
194
+ op: "list",
195
+ },
196
+ { timeoutMs },
197
+ );
198
+ const job = findDaemonJobBySessionId(list, sessionId);
199
+ if (!job?.short) {
200
+ return { ok: true, killed: false, reason: "not_found" };
201
+ }
202
+ return await sendClaudeControlRequest(
203
+ controlSock,
204
+ {
205
+ proto: 1,
206
+ op: "kill",
207
+ short: job.short,
208
+ },
209
+ { timeoutMs },
210
+ );
211
+ } catch (error) {
212
+ return {
213
+ ok: false,
214
+ killed: false,
215
+ reason: "control_unavailable",
216
+ error: error?.message || String(error),
217
+ };
218
+ }
219
+ }
@@ -4,6 +4,17 @@ import fs from "node:fs/promises";
4
4
  import net from "node:net";
5
5
  import os from "node:os";
6
6
  import path from "node:path";
7
+ import {
8
+ buildDaemonExecDispatchPayload,
9
+ killDaemonJob,
10
+ sendClaudeControlRequest,
11
+ waitForDaemonJobPid,
12
+ } from "./claude-daemon-control.mjs";
13
+ import {
14
+ buildClaudeSessionProjection,
15
+ removeClaudeSessionProjection,
16
+ writeClaudeSessionProjection,
17
+ } from "./claude-session-projection.mjs";
7
18
 
8
19
  const DEFAULT_ROWS = 40;
9
20
  const DEFAULT_COLS = 120;
@@ -36,6 +47,8 @@ export function deriveClaudeDaemonPaths({
36
47
  rendezvousDir: path.join(daemonDir, "rv"),
37
48
  ptyDir: path.join(daemonDir, "pty"),
38
49
  rosterPath: path.join(resolvedConfigDir, "daemon", "roster.json"),
50
+ sessionsDir: path.join(resolvedConfigDir, "sessions"),
51
+ jobsDir: path.join(resolvedConfigDir, "jobs"),
39
52
  };
40
53
  }
41
54
 
@@ -203,6 +216,204 @@ export async function removeRosterWorkers(rosterPath, shorts) {
203
216
  });
204
217
  }
205
218
 
219
+ export async function removeClaudeJobState(jobsDir, short) {
220
+ if (!short) return;
221
+ await fs.rm(path.join(jobsDir, short), { recursive: true, force: true });
222
+ }
223
+
224
+ function shellSingleQuote(value) {
225
+ return `'${String(value).replaceAll("'", "'\\''")}'`;
226
+ }
227
+
228
+ function buildSwarmShardBridgeCommand({ displayName, sessionId }) {
229
+ const banner = `${displayName} native bridge active (${sessionId})`;
230
+ return `printf '%s\\n' ${shellSingleQuote(banner)}; while true; do sleep 3600; done`;
231
+ }
232
+
233
+ function compactDisplayPart(value, fallback = "") {
234
+ const text = String(value ?? "")
235
+ .replace(/[\u0000-\u001f\u007f]+/g, " ")
236
+ .replace(/\s+/g, " ")
237
+ .trim();
238
+ return text || fallback;
239
+ }
240
+
241
+ function toPositiveInteger(value) {
242
+ const number = Number(value);
243
+ return Number.isInteger(number) && number > 0 ? number : null;
244
+ }
245
+
246
+ function formatShardOrdinal({ shardIndex, shardCount }) {
247
+ const index = toPositiveInteger(shardIndex);
248
+ const count = toPositiveInteger(shardCount);
249
+ if (!index || !count) return null;
250
+ const width = Math.max(String(count).length, 2);
251
+ return `${String(index).padStart(width, "0")}/${String(count).padStart(width, "0")}`;
252
+ }
253
+
254
+ function buildSwarmShardDisplayName({
255
+ sessionId,
256
+ cli,
257
+ swarmName,
258
+ shardIndex,
259
+ shardCount,
260
+ shardName,
261
+ }) {
262
+ const groupName = compactDisplayPart(swarmName || sessionId, "swarm");
263
+ const agentName = compactDisplayPart(cli, "agent");
264
+ const workerName = compactDisplayPart(shardName, "shard");
265
+ return [
266
+ "Triflux swarm",
267
+ groupName,
268
+ formatShardOrdinal({ shardIndex, shardCount }),
269
+ agentName,
270
+ workerName,
271
+ ]
272
+ .filter(Boolean)
273
+ .join(" ");
274
+ }
275
+
276
+ function isLocalHost(host) {
277
+ const value = String(host || "local").toLowerCase();
278
+ return value === "local" || value === "localhost" || value === "127.0.0.1";
279
+ }
280
+
281
+ function deriveSwarmShort({ sessionId, shardName, host }) {
282
+ return crypto
283
+ .createHash("sha256")
284
+ .update(`${sessionId}:${shardName}:${host || "local"}`)
285
+ .digest("hex")
286
+ .slice(0, 8);
287
+ }
288
+
289
+ export async function registerSwarmShard({
290
+ sessionId,
291
+ cli = "codex",
292
+ role = "worker",
293
+ swarmName,
294
+ shardIndex,
295
+ shardCount,
296
+ shardName,
297
+ cwd = process.cwd(),
298
+ host = "local",
299
+ configDir = resolveClaudeConfigDir(),
300
+ tmpRoot = "/tmp",
301
+ _deps = {},
302
+ } = {}) {
303
+ if (!sessionId) throw new Error("sessionId is required");
304
+ if (!shardName) throw new Error("shardName is required");
305
+
306
+ const displayName = buildSwarmShardDisplayName({
307
+ sessionId,
308
+ cli,
309
+ swarmName,
310
+ shardIndex,
311
+ shardCount,
312
+ shardName,
313
+ });
314
+ const warn = _deps.warn || ((message) => console.warn(message));
315
+ if (!isLocalHost(host)) {
316
+ warn(
317
+ `[native-bridge] remote shard ${shardName}@${host}: skipping local registration; remote launcher must register on that host`,
318
+ );
319
+ return {
320
+ ok: true,
321
+ skipped: true,
322
+ host,
323
+ sessionId,
324
+ shardName,
325
+ displayName,
326
+ close() {},
327
+ };
328
+ }
329
+
330
+ const derivePaths = _deps.deriveClaudeDaemonPaths || deriveClaudeDaemonPaths;
331
+ const buildPayload =
332
+ _deps.buildDaemonExecDispatchPayload || buildDaemonExecDispatchPayload;
333
+ const sendControl =
334
+ _deps.sendClaudeControlRequest || sendClaudeControlRequest;
335
+ const waitForPid = _deps.waitForDaemonJobPid || waitForDaemonJobPid;
336
+ const readProcStart = _deps.getProcStart || getProcStart;
337
+ const buildProjection =
338
+ _deps.buildClaudeSessionProjection || buildClaudeSessionProjection;
339
+ const writeProjection =
340
+ _deps.writeClaudeSessionProjection || writeClaudeSessionProjection;
341
+ const removeProjection =
342
+ _deps.removeClaudeSessionProjection || removeClaudeSessionProjection;
343
+ const removeJobStateImpl = _deps.removeClaudeJobState || removeClaudeJobState;
344
+ const killJob = _deps.killDaemonJob || killDaemonJob;
345
+ const accessControlSock = _deps.accessControlSock || fs.access;
346
+
347
+ const paths = derivePaths({ configDir, tmpRoot });
348
+ const sessionsDir =
349
+ paths.sessionsDir || path.join(paths.configDir || configDir, "sessions");
350
+ const jobsDir =
351
+ paths.jobsDir || path.join(paths.configDir || configDir, "jobs");
352
+ const short = deriveSwarmShort({ sessionId, shardName, host });
353
+ const command = buildSwarmShardBridgeCommand({ displayName, sessionId });
354
+ const payload = buildPayload({
355
+ short,
356
+ cwd,
357
+ command,
358
+ name: displayName,
359
+ });
360
+
361
+ await accessControlSock(paths.controlSock);
362
+ const dispatch = await sendControl(
363
+ paths.controlSock,
364
+ {
365
+ proto: 1,
366
+ op: "dispatch",
367
+ d: payload,
368
+ timeoutMs: 1000,
369
+ },
370
+ { timeoutMs: 1000 },
371
+ );
372
+ if (dispatch?.ok !== true) {
373
+ throw new Error(
374
+ `Claude daemon dispatch failed for swarm shard ${shardName}`,
375
+ );
376
+ }
377
+
378
+ const job = await waitForPid(paths.controlSock, short, { timeoutMs: 1000 });
379
+ const pid = job.pid;
380
+ const projection = buildProjection({
381
+ pid,
382
+ procStart: readProcStart(pid),
383
+ sessionId: payload.sessionId,
384
+ short,
385
+ cwd,
386
+ name: displayName,
387
+ agent: cli,
388
+ startedAt: job.startedAt || Date.now(),
389
+ updatedAt: Date.now(),
390
+ });
391
+ const sessionProjectionPath = await writeProjection(sessionsDir, projection);
392
+ let closed = false;
393
+
394
+ return {
395
+ ok: true,
396
+ skipped: false,
397
+ host: "local",
398
+ sessionId: payload.sessionId,
399
+ swarmSessionId: sessionId,
400
+ shardName,
401
+ cli,
402
+ role,
403
+ short,
404
+ displayName,
405
+ controlSock: paths.controlSock,
406
+ sessionProjectionPath,
407
+ async close() {
408
+ if (closed) return;
409
+ closed = true;
410
+ await removeProjection(sessionProjectionPath).catch(() => {});
411
+ await killJob(paths.controlSock, short).catch(() => {});
412
+ await removeJobStateImpl(jobsDir, short).catch(() => {});
413
+ },
414
+ };
415
+ }
416
+
206
417
  export function buildNativeWorkerRosterEntry({
207
418
  short,
208
419
  pid = process.pid,
@@ -96,6 +96,7 @@ export async function teamStart(args = []) {
96
96
  cwd,
97
97
  nativeBridge,
98
98
  nativeBridgeMode,
99
+ nativeBridgeUiOptOut,
99
100
  } = parseTeamArgs(args);
100
101
  // --assign 사용 시 task를 자동 생성
101
102
  const task =
@@ -133,6 +134,8 @@ export async function teamStart(args = []) {
133
134
  const { mode: effectiveMode, warnings: modeWarnings } =
134
135
  resolveEffectiveMode(teammateMode);
135
136
  for (const message of modeWarnings) warn(message);
137
+ const effectiveNativeBridge =
138
+ effectiveMode === "headless" && !nativeBridgeUiOptOut ? true : nativeBridge;
136
139
 
137
140
  console.log(` 세션: ${WHITE}${sessionId}${RESET}`);
138
141
  console.log(` 모드: ${effectiveMode}`);
@@ -180,7 +183,7 @@ export async function teamStart(args = []) {
180
183
  mcpProfile,
181
184
  model,
182
185
  cwd,
183
- nativeBridge,
186
+ nativeBridge: effectiveNativeBridge,
184
187
  nativeBridgeMode,
185
188
  })
186
189
  : effectiveMode === "wt"
@@ -77,7 +77,10 @@ export function parseTeamArgs(args = []) {
77
77
  let model = "";
78
78
  let cwd = "";
79
79
  let nativeBridge = false;
80
- let nativeBridgeMode = "roster";
80
+ let nativeBridgeMode = "agents";
81
+ let nativeBridgeExplicit = false;
82
+ let nativeBridgeUiRequested = false;
83
+ let nativeBridgeUiOptOut = false;
81
84
 
82
85
  for (let index = 0; index < args.length; index += 1) {
83
86
  const current = args[index];
@@ -127,9 +130,25 @@ export function parseTeamArgs(args = []) {
127
130
  model = args[++index].trim();
128
131
  } else if (current === "--native-bridge" || current === "-nb") {
129
132
  nativeBridge = true;
133
+ nativeBridgeExplicit = true;
130
134
  } else if (current === "--native-bridge-ui") {
135
+ if (nativeBridgeUiOptOut) {
136
+ throw new Error(
137
+ "cannot combine --native-bridge-ui and --no-native-bridge-ui",
138
+ );
139
+ }
131
140
  nativeBridge = true;
132
141
  nativeBridgeMode = "agents";
142
+ nativeBridgeExplicit = true;
143
+ nativeBridgeUiRequested = true;
144
+ } else if (current === "--no-native-bridge-ui") {
145
+ if (nativeBridgeUiRequested) {
146
+ throw new Error(
147
+ "cannot combine --native-bridge-ui and --no-native-bridge-ui",
148
+ );
149
+ }
150
+ nativeBridge = false;
151
+ nativeBridgeUiOptOut = true;
133
152
  } else if (current === "--native-bridge-mode") {
134
153
  const mode = args[++index];
135
154
  if (!NATIVE_BRIDGE_MODES.has(mode)) {
@@ -139,6 +158,7 @@ export function parseTeamArgs(args = []) {
139
158
  }
140
159
  nativeBridge = true;
141
160
  nativeBridgeMode = mode;
161
+ nativeBridgeExplicit = true;
142
162
  } else if (current === "--cwd" && args[index + 1]) {
143
163
  let p = args[++index].trim();
144
164
  // MSYS/Git Bash 드라이브 문자 변환: /c/... → C:/...
@@ -153,11 +173,20 @@ export function parseTeamArgs(args = []) {
153
173
  }
154
174
  }
155
175
 
176
+ const normalizedTeammateMode = normalizeTeammateMode(teammateMode);
177
+ if (
178
+ !nativeBridgeExplicit &&
179
+ !nativeBridgeUiOptOut &&
180
+ normalizedTeammateMode === "headless"
181
+ ) {
182
+ nativeBridge = true;
183
+ }
184
+
156
185
  return {
157
186
  agents,
158
187
  lead,
159
188
  layout: normalizeLayout(layout),
160
- teammateMode: normalizeTeammateMode(teammateMode),
189
+ teammateMode: normalizedTeammateMode,
161
190
  task: taskParts.join(" ").trim(),
162
191
  assigns,
163
192
  autoAttach,
@@ -173,5 +202,6 @@ export function parseTeamArgs(args = []) {
173
202
  cwd,
174
203
  nativeBridge,
175
204
  nativeBridgeMode,
205
+ nativeBridgeUiOptOut,
176
206
  };
177
207
  }