@triflux/remote 10.0.1 → 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 (155) hide show
  1. package/hub/pipe.mjs +239 -121
  2. package/hub/public/dashboard.html +68 -7
  3. package/hub/server.mjs +2181 -701
  4. package/hub/store-adapter.mjs +101 -37
  5. package/hub/store.mjs +356 -153
  6. package/hub/team/agent-map.json +26 -7
  7. package/hub/team/ansi.mjs +245 -146
  8. package/hub/team/backend.mjs +84 -17
  9. package/hub/team/build-worker-prompt.mjs +42 -0
  10. package/hub/team/check-mcp-hub.mjs +49 -0
  11. package/hub/team/claude-daemon-control.mjs +219 -0
  12. package/hub/team/claude-native-bridge.mjs +784 -0
  13. package/hub/team/claude-session-projection.mjs +65 -0
  14. package/hub/team/cli/commands/attach.mjs +57 -37
  15. package/hub/team/cli/commands/control.mjs +29 -8
  16. package/hub/team/cli/commands/debug.mjs +95 -74
  17. package/hub/team/cli/commands/focus.mjs +80 -53
  18. package/hub/team/cli/commands/interrupt.mjs +18 -6
  19. package/hub/team/cli/commands/kill.mjs +16 -5
  20. package/hub/team/cli/commands/list.mjs +31 -24
  21. package/hub/team/cli/commands/send.mjs +19 -6
  22. package/hub/team/cli/commands/start/index.mjs +154 -33
  23. package/hub/team/cli/commands/start/parse-args.mjs +89 -12
  24. package/hub/team/cli/commands/start/start-headless.mjs +127 -37
  25. package/hub/team/cli/commands/start/start-in-process.mjs +50 -40
  26. package/hub/team/cli/commands/start/start-mux.mjs +122 -73
  27. package/hub/team/cli/commands/start/start-wt.mjs +86 -69
  28. package/hub/team/cli/commands/status.mjs +43 -14
  29. package/hub/team/cli/commands/stop.mjs +11 -4
  30. package/hub/team/cli/commands/task.mjs +8 -3
  31. package/hub/team/cli/commands/tasks.mjs +13 -13
  32. package/hub/team/cli/index.mjs +2 -2
  33. package/hub/team/cli/manifest.mjs +38 -8
  34. package/hub/team/cli/render.mjs +52 -30
  35. package/hub/team/cli/services/attach-fallback.mjs +74 -54
  36. package/hub/team/cli/services/hub-client.mjs +45 -17
  37. package/hub/team/cli/services/member-selector.mjs +37 -30
  38. package/hub/team/cli/services/native-control.mjs +144 -116
  39. package/hub/team/cli/services/runtime-mode.mjs +55 -1
  40. package/hub/team/cli/services/state-store.mjs +66 -9
  41. package/hub/team/cli/services/task-model.mjs +40 -30
  42. package/hub/team/codex-review.mjs +521 -0
  43. package/hub/team/conductor-mesh-bridge.mjs +25 -24
  44. package/hub/team/conductor-registry.mjs +121 -0
  45. package/hub/team/conductor.mjs +1379 -671
  46. package/hub/team/consensus-meta.mjs +176 -0
  47. package/hub/team/dashboard-anchor.mjs +4 -5
  48. package/hub/team/dashboard-layout.mjs +3 -1
  49. package/hub/team/dashboard-open.mjs +21 -119
  50. package/hub/team/dashboard.mjs +76 -28
  51. package/hub/team/event-log.mjs +18 -10
  52. package/hub/team/execution-mode.mjs +241 -0
  53. package/hub/team/extract-completion-payload.mjs +110 -0
  54. package/hub/team/git-preflight.mjs +286 -0
  55. package/hub/team/handoff.mjs +49 -18
  56. package/hub/team/headless-bridge-session.mjs +17 -0
  57. package/hub/team/headless.mjs +2065 -1156
  58. package/hub/team/health-probe.mjs +232 -69
  59. package/hub/team/launcher-template.mjs +23 -13
  60. package/hub/team/lead-control.mjs +91 -4
  61. package/hub/team/mcp-selector.mjs +145 -0
  62. package/hub/team/native-supervisor.mjs +90 -26
  63. package/hub/team/native.mjs +74 -35
  64. package/hub/team/nativeProxy.mjs +184 -113
  65. package/hub/team/notify.mjs +147 -78
  66. package/hub/team/orchestrator.mjs +173 -161
  67. package/hub/team/pane.mjs +43 -15
  68. package/hub/team/process-cleanup.mjs +69 -25
  69. package/hub/team/psmux.mjs +1067 -264
  70. package/hub/team/recovery-store.mjs +149 -0
  71. package/hub/team/remote-probe.mjs +69 -53
  72. package/hub/team/remote-session.mjs +131 -59
  73. package/hub/team/remote-watcher.mjs +62 -34
  74. package/hub/team/retry-state-machine.mjs +267 -0
  75. package/hub/team/routing.mjs +51 -25
  76. package/hub/team/runtime-strategy.mjs +200 -0
  77. package/hub/team/schemas/worker-completion.json +39 -0
  78. package/hub/team/sentinel-capture.mjs +127 -0
  79. package/hub/team/session-sync.mjs +153 -6
  80. package/hub/team/session.mjs +605 -611
  81. package/hub/team/shared.mjs +13 -13
  82. package/hub/team/staleState.mjs +72 -30
  83. package/hub/team/swarm-cli.mjs +390 -0
  84. package/hub/team/swarm-hypervisor.mjs +1526 -198
  85. package/hub/team/swarm-intent.mjs +216 -0
  86. package/hub/team/swarm-locks.mjs +140 -25
  87. package/hub/team/swarm-planner.mjs +121 -21
  88. package/hub/team/swarm-preflight.mjs +317 -0
  89. package/hub/team/swarm-reconciler.mjs +128 -23
  90. package/hub/team/synapse-cli.mjs +458 -0
  91. package/hub/team/synapse-http.mjs +60 -0
  92. package/hub/team/synapse-registry.mjs +329 -0
  93. package/hub/team/terminal-opener.mjs +216 -0
  94. package/hub/team/tui-core.mjs +288 -0
  95. package/hub/team/tui-lite.mjs +234 -146
  96. package/hub/team/tui-remote-adapter.mjs +21 -37
  97. package/hub/team/tui-synapse.mjs +223 -0
  98. package/hub/team/tui-viewer.mjs +104 -45
  99. package/hub/team/tui-widgets.mjs +262 -0
  100. package/hub/team/tui.mjs +936 -449
  101. package/hub/team/worker-completion-validator.mjs +84 -0
  102. package/hub/team/worker-sandbox.mjs +93 -0
  103. package/hub/team/worker-signal.mjs +263 -0
  104. package/hub/team/worker-signal.types.d.ts +52 -0
  105. package/hub/team/worktree-lifecycle.mjs +518 -64
  106. package/hub/team/wt-manager.mjs +297 -26
  107. package/hub/tools.mjs +474 -252
  108. package/hub/tray.mjs +411 -368
  109. package/hub/workers/claude-worker.mjs +162 -118
  110. package/hub/workers/codex-app-server-worker.mjs +1015 -0
  111. package/hub/workers/codex-mcp.mjs +724 -507
  112. package/hub/workers/delegator-mcp.mjs +663 -337
  113. package/hub/workers/factory.mjs +100 -21
  114. package/hub/workers/gemini-worker.mjs +179 -86
  115. package/hub/workers/interface.mjs +23 -2
  116. package/hub/workers/lib/jsonrpc-stdio.mjs +463 -0
  117. package/hub/workers/worker-utils.mjs +22 -16
  118. package/package.json +4 -3
  119. package/scripts/lib/agent-json.mjs +27 -0
  120. package/scripts/lib/async.mjs +174 -0
  121. package/scripts/lib/claudemd-scanner.mjs +223 -0
  122. package/scripts/lib/cli-agy.mjs +62 -0
  123. package/scripts/lib/cli-claude.mjs +78 -0
  124. package/scripts/lib/cli-codex.mjs +199 -0
  125. package/scripts/lib/cli-gemini.mjs +67 -0
  126. package/scripts/lib/codex-recovery.sh +50 -0
  127. package/scripts/lib/context.mjs +67 -0
  128. package/scripts/lib/cross-review-utils.mjs +59 -0
  129. package/scripts/lib/doctor-env-checks.mjs +121 -0
  130. package/scripts/lib/dynamic-route-cli.mjs +107 -0
  131. package/scripts/lib/env-probe.mjs +269 -0
  132. package/scripts/lib/env.mjs +14 -0
  133. package/scripts/lib/gemini-profiles.mjs +119 -0
  134. package/scripts/lib/handoff.mjs +187 -0
  135. package/scripts/lib/hook-utils.mjs +16 -0
  136. package/scripts/lib/hub.mjs +48 -0
  137. package/scripts/lib/keyword-rules.mjs +195 -0
  138. package/scripts/lib/logger.mjs +105 -0
  139. package/scripts/lib/mcp-filter.mjs +883 -0
  140. package/scripts/lib/mcp-gateway-servers.mjs +70 -0
  141. package/scripts/lib/mcp-guard-engine.mjs +2120 -0
  142. package/scripts/lib/mcp-health.mjs +773 -0
  143. package/scripts/lib/mcp-manifest.mjs +89 -0
  144. package/scripts/lib/mcp-server-catalog.mjs +355 -0
  145. package/scripts/lib/pid.mjs +63 -0
  146. package/scripts/lib/process-utils.mjs +26 -0
  147. package/scripts/lib/psmux-info.mjs +179 -0
  148. package/scripts/lib/quota.mjs +47 -0
  149. package/scripts/lib/remote-spawn-transfer.mjs +226 -0
  150. package/scripts/lib/skill-state.mjs +220 -0
  151. package/scripts/lib/skill-template.mjs +375 -0
  152. package/scripts/lib/team.mjs +75 -0
  153. package/scripts/lib/timeout.mjs +66 -0
  154. package/scripts/lib/tmp.mjs +26 -0
  155. package/scripts/lib/toml.mjs +113 -0
@@ -1,10 +1,20 @@
1
- import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
2
- import { join } from "node:path";
3
1
  import { spawn } from "node:child_process";
2
+ import {
3
+ existsSync,
4
+ mkdirSync,
5
+ readFileSync,
6
+ unlinkSync,
7
+ writeFileSync,
8
+ } from "node:fs";
9
+ import { join } from "node:path";
4
10
 
5
11
  import { publishLeadControl as publishLeadControlBridge } from "../../lead-control.mjs";
6
- import { getTeamStatus as fetchTeamStatus, subscribeToLeadCommands as pullLeadCommands } from "../../session-sync.mjs";
12
+ import {
13
+ getTeamStatus as fetchTeamStatus,
14
+ subscribeToLeadCommands as pullLeadCommands,
15
+ } from "../../session-sync.mjs";
7
16
  import { HUB_PID_DIR, PKG_ROOT } from "./state-store.mjs";
17
+
8
18
  export { nativeGetStatus } from "./native-control.mjs";
9
19
 
10
20
  const HUB_PID_FILE = join(HUB_PID_DIR, "hub.pid");
@@ -56,18 +66,20 @@ export async function getHubInfo() {
56
66
  if (!Number.isFinite(pid) || pid <= 0) throw new Error("invalid pid");
57
67
  process.kill(pid, 0);
58
68
  const host = normalizeLoopbackHost(raw?.host);
59
- const port = Number(raw?.port) || 27888;
69
+ const port = Number(raw?.port) || probePort;
60
70
  const status = await probeHubStatus(host, port, 1200);
71
+ if (!status) throw new Error("pid file process is not a healthy hub");
61
72
  return {
62
73
  ...raw,
63
74
  pid,
64
75
  host,
65
76
  port,
66
77
  url: `${buildHubBaseUrl(host, port)}/mcp`,
67
- ...(status ? {} : { degraded: true }),
68
78
  };
69
79
  } catch {
70
- try { unlinkSync(HUB_PID_FILE); } catch {}
80
+ try {
81
+ unlinkSync(HUB_PID_FILE);
82
+ } catch {}
71
83
  }
72
84
  }
73
85
 
@@ -86,7 +98,10 @@ export async function getHubInfo() {
86
98
  if (Number.isFinite(recovered.pid) && recovered.pid > 0) {
87
99
  try {
88
100
  mkdirSync(HUB_PID_DIR, { recursive: true });
89
- writeFileSync(HUB_PID_FILE, JSON.stringify({ ...recovered, started: Date.now() }));
101
+ writeFileSync(
102
+ HUB_PID_FILE,
103
+ JSON.stringify({ ...recovered, started: Date.now() }),
104
+ );
90
105
  } catch {}
91
106
  }
92
107
  return recovered;
@@ -154,12 +169,14 @@ export async function ensureHubAlive(maxRetries = 3) {
154
169
  }
155
170
  // 다음 재시도 전 대기: 1초, 2초, 4초 (마지막 시도 후에는 대기 없음)
156
171
  if (i < maxRetries - 1) {
157
- const backoffMs = Math.pow(2, i) * 1000; // i=0: 1초, i=1: 2초, i=2: 4초
172
+ const backoffMs = 2 ** i * 1000; // i=0: 1초, i=1: 2초, i=2: 4초
158
173
  await new Promise((resolve) => setTimeout(resolve, backoffMs));
159
174
  }
160
175
  }
161
176
 
162
- const error = new Error(`Hub 재시작 ${maxRetries}회 모두 실패${lastError ? `: ${lastError.message}` : ""}`);
177
+ const error = new Error(
178
+ `Hub 재시작 ${maxRetries}회 모두 실패${lastError ? `: ${lastError.message}` : ""}`,
179
+ );
163
180
  error.code = "HUB_RESTART_FAILED";
164
181
  error.cause = lastError;
165
182
  throw error;
@@ -178,16 +195,24 @@ export async function fetchHubTaskList(state) {
178
195
  signal: AbortSignal.timeout(2000),
179
196
  });
180
197
  const data = await res.json();
181
- return data?.ok ? (data.data?.tasks || []) : [];
198
+ return data?.ok ? data.data?.tasks || [] : [];
182
199
  } catch {
183
200
  return [];
184
201
  }
185
202
  }
186
203
 
187
- export async function publishLeadControl(state, targetMember, command, reason = "") {
204
+ export async function publishLeadControl(
205
+ state,
206
+ targetMember,
207
+ command,
208
+ reason = "",
209
+ ) {
188
210
  const hubBase = (state?.hubUrl || getDefaultHubUrl()).replace(/\/mcp$/, "");
189
- const leadAgent = (state?.members || []).find((member) => member.role === "lead")?.agentId || "lead";
190
- const targetAgent = typeof targetMember === "string" ? targetMember : targetMember?.agentId;
211
+ const leadAgent =
212
+ (state?.members || []).find((member) => member.role === "lead")?.agentId ||
213
+ "lead";
214
+ const targetAgent =
215
+ typeof targetMember === "string" ? targetMember : targetMember?.agentId;
191
216
 
192
217
  const result = await publishLeadControlBridge({
193
218
  hubUrl: hubBase,
@@ -206,10 +231,13 @@ export async function publishLeadControl(state, targetMember, command, reason =
206
231
 
207
232
  export async function subscribeToLeadCommands(state, member, options = {}) {
208
233
  const hubBase = (state?.hubUrl || getDefaultHubUrl()).replace(/\/mcp$/, "");
209
- const fallbackAgentId = (state?.members || []).find((candidate) => candidate.role === "lead")?.agentId || null;
210
- const agentId = typeof member === "string"
211
- ? member
212
- : member?.agentId || options?.agentId || fallbackAgentId;
234
+ const fallbackAgentId =
235
+ (state?.members || []).find((candidate) => candidate.role === "lead")
236
+ ?.agentId || null;
237
+ const agentId =
238
+ typeof member === "string"
239
+ ? member
240
+ : member?.agentId || options?.agentId || fallbackAgentId;
213
241
 
214
242
  return await pullLeadCommands({
215
243
  hubUrl: hubBase,
@@ -1,30 +1,37 @@
1
- export function resolveMember(state, selector) {
2
- const members = state?.members || [];
3
- if (!selector) return null;
4
-
5
- const direct = members.find((member) => (
6
- member.name === selector || member.role === selector || member.agentId === selector
7
- ));
8
- if (direct) return direct;
9
-
10
- const workerAlias = /^worker-(\d+)$/i.exec(selector);
11
- if (workerAlias) {
12
- const index = parseInt(workerAlias[1], 10) - 1;
13
- const workers = members.filter((member) => member.role === "worker");
14
- if (index >= 0 && index < workers.length) return workers[index];
15
- }
16
-
17
- const numeric = parseInt(selector, 10);
18
- if (!Number.isNaN(numeric)) {
19
- const byPane = members.find((member) => member.pane?.endsWith(`.${numeric}`) || member.pane?.endsWith(`:${numeric}`));
20
- if (byPane) return byPane;
21
- if (numeric >= 1 && numeric <= members.length) return members[numeric - 1];
22
- }
23
-
24
- return null;
25
- }
26
-
27
- export function toAgentId(cli, target) {
28
- const suffix = String(target).split(/[:.]/).pop();
29
- return `${cli}-${suffix}`;
30
- }
1
+ export function resolveMember(state, selector) {
2
+ const members = state?.members || [];
3
+ if (!selector) return null;
4
+
5
+ const direct = members.find(
6
+ (member) =>
7
+ member.name === selector ||
8
+ member.role === selector ||
9
+ member.agentId === selector,
10
+ );
11
+ if (direct) return direct;
12
+
13
+ const workerAlias = /^worker-(\d+)$/i.exec(selector);
14
+ if (workerAlias) {
15
+ const index = parseInt(workerAlias[1], 10) - 1;
16
+ const workers = members.filter((member) => member.role === "worker");
17
+ if (index >= 0 && index < workers.length) return workers[index];
18
+ }
19
+
20
+ const numeric = parseInt(selector, 10);
21
+ if (!Number.isNaN(numeric)) {
22
+ const byPane = members.find(
23
+ (member) =>
24
+ member.pane?.endsWith(`.${numeric}`) ||
25
+ member.pane?.endsWith(`:${numeric}`),
26
+ );
27
+ if (byPane) return byPane;
28
+ if (numeric >= 1 && numeric <= members.length) return members[numeric - 1];
29
+ }
30
+
31
+ return null;
32
+ }
33
+
34
+ export function toAgentId(cli, target) {
35
+ const suffix = String(target).split(/[:.]/).pop();
36
+ return `${cli}-${suffix}`;
37
+ }
@@ -1,117 +1,145 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
- import { join } from "node:path";
3
- import { spawn } from "node:child_process";
4
-
5
- import { buildLeadPrompt, buildPrompt } from "../../orchestrator.mjs";
6
- import { HUB_PID_DIR, PKG_ROOT } from "./state-store.mjs";
7
-
8
- import { buildExecArgs } from "@triflux/core/hub/codex-adapter.mjs";
9
-
10
- export function buildNativeCliCommand(cli) {
11
- switch (cli) {
12
- case "codex":
13
- return buildExecArgs({});
14
- case "gemini":
15
- return "gemini";
16
- case "claude":
17
- return "claude";
18
- default:
19
- return cli;
20
- }
21
- }
22
-
23
- export async function startNativeSupervisor({ sessionId, task, lead, agents, subtasks, hubUrl }) {
24
- const configPath = join(HUB_PID_DIR, `team-native-${sessionId}.config.json`);
25
- const runtimePath = join(HUB_PID_DIR, `team-native-${sessionId}.runtime.json`);
26
- const logsDir = join(HUB_PID_DIR, "team-logs", sessionId);
27
- mkdirSync(logsDir, { recursive: true });
28
-
29
- const leadMember = {
30
- role: "lead",
31
- name: "lead",
32
- cli: lead,
33
- agentId: `${lead}-lead`,
34
- command: buildNativeCliCommand(lead),
35
- };
36
- const workers = agents.map((cli, index) => ({
37
- role: "worker",
38
- name: `${cli}-${index + 1}`,
39
- cli,
40
- agentId: `${cli}-w${index + 1}`,
41
- command: buildNativeCliCommand(cli),
42
- subtask: subtasks[index],
43
- }));
44
- const members = [
45
- {
46
- ...leadMember,
47
- prompt: buildLeadPrompt(task, {
48
- agentId: leadMember.agentId,
49
- hubUrl,
50
- teammateMode: "in-process",
51
- workers: workers.map((worker) => ({
52
- agentId: worker.agentId,
53
- cli: worker.cli,
54
- subtask: worker.subtask,
55
- })),
56
- }),
57
- },
58
- ...workers.map((worker) => ({
59
- ...worker,
60
- prompt: buildPrompt(worker.subtask, { cli: worker.cli, agentId: worker.agentId, hubUrl }),
61
- })),
62
- ];
63
-
64
- writeFileSync(configPath, JSON.stringify({
65
- sessionName: sessionId,
66
- hubUrl,
67
- startupDelayMs: 3000,
68
- logsDir,
69
- runtimeFile: runtimePath,
70
- members,
71
- }, null, 2) + "\n");
72
-
73
- const child = spawn(process.execPath, [join(PKG_ROOT, "hub", "team", "native-supervisor.mjs"), "--config", configPath], {
74
- detached: true,
75
- stdio: "ignore",
76
- env: { ...process.env },
77
- windowsHide: true,
78
- });
79
- child.unref();
80
-
81
- const deadline = Date.now() + 5000;
82
- while (Date.now() < deadline) {
83
- if (existsSync(runtimePath)) {
84
- try {
85
- const runtime = JSON.parse(readFileSync(runtimePath, "utf8"));
86
- return { runtime, members };
87
- } catch {}
88
- }
89
- await new Promise((resolve) => setTimeout(resolve, 100));
90
- }
91
-
92
- return { runtime: null, members };
93
- }
94
-
95
- export async function nativeRequest(state, path, body = {}) {
96
- if (!state?.native?.controlUrl) return null;
97
- try {
98
- const res = await fetch(`${state.native.controlUrl}${path}`, {
99
- method: "POST",
100
- headers: { "Content-Type": "application/json" },
101
- body: JSON.stringify(body),
102
- });
103
- return await res.json();
104
- } catch {
105
- return null;
106
- }
107
- }
108
-
109
- export async function nativeGetStatus(state) {
110
- if (!state?.native?.controlUrl) return null;
111
- try {
112
- const res = await fetch(`${state.native.controlUrl}/status`);
113
- return await res.json();
114
- } catch {
115
- return null;
116
- }
1
+ import { spawn } from "node:child_process";
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { buildExecArgs } from "@triflux/core/hub/codex-adapter.mjs";
5
+ import { buildLeadPrompt, buildPrompt } from "../../orchestrator.mjs";
6
+ import { HUB_PID_DIR, PKG_ROOT } from "./state-store.mjs";
7
+
8
+ export function buildNativeCliCommand(cli) {
9
+ switch (cli) {
10
+ case "codex":
11
+ return buildExecArgs({});
12
+ case "gemini":
13
+ return "gemini";
14
+ case "claude":
15
+ return "claude";
16
+ default:
17
+ return cli;
18
+ }
19
+ }
20
+
21
+ export async function startNativeSupervisor({
22
+ sessionId,
23
+ task,
24
+ lead,
25
+ agents,
26
+ subtasks,
27
+ hubUrl,
28
+ }) {
29
+ const configPath = join(HUB_PID_DIR, `team-native-${sessionId}.config.json`);
30
+ const runtimePath = join(
31
+ HUB_PID_DIR,
32
+ `team-native-${sessionId}.runtime.json`,
33
+ );
34
+ const logsDir = join(HUB_PID_DIR, "team-logs", sessionId);
35
+ mkdirSync(logsDir, { recursive: true });
36
+
37
+ const leadMember = {
38
+ role: "lead",
39
+ name: "lead",
40
+ cli: lead,
41
+ agentId: `${lead}-lead`,
42
+ command: buildNativeCliCommand(lead),
43
+ };
44
+ const workers = agents.map((cli, index) => ({
45
+ role: "worker",
46
+ name: `${cli}-${index + 1}`,
47
+ cli,
48
+ agentId: `${cli}-w${index + 1}`,
49
+ command: buildNativeCliCommand(cli),
50
+ subtask: subtasks[index],
51
+ }));
52
+ const members = [
53
+ {
54
+ ...leadMember,
55
+ prompt: buildLeadPrompt(task, {
56
+ agentId: leadMember.agentId,
57
+ hubUrl,
58
+ teammateMode: "in-process",
59
+ workers: workers.map((worker) => ({
60
+ agentId: worker.agentId,
61
+ cli: worker.cli,
62
+ subtask: worker.subtask,
63
+ })),
64
+ }),
65
+ },
66
+ ...workers.map((worker) => ({
67
+ ...worker,
68
+ prompt: buildPrompt(worker.subtask, {
69
+ cli: worker.cli,
70
+ agentId: worker.agentId,
71
+ hubUrl,
72
+ }),
73
+ })),
74
+ ];
75
+
76
+ writeFileSync(
77
+ configPath,
78
+ JSON.stringify(
79
+ {
80
+ sessionName: sessionId,
81
+ hubUrl,
82
+ workdir: process.cwd(),
83
+ startupDelayMs: 3000,
84
+ logsDir,
85
+ runtimeFile: runtimePath,
86
+ members,
87
+ },
88
+ null,
89
+ 2,
90
+ ) + "\n",
91
+ );
92
+
93
+ const child = spawn(
94
+ process.execPath,
95
+ [
96
+ join(PKG_ROOT, "hub", "team", "native-supervisor.mjs"),
97
+ "--config",
98
+ configPath,
99
+ ],
100
+ {
101
+ detached: true,
102
+ stdio: "ignore",
103
+ env: { ...process.env },
104
+ windowsHide: true,
105
+ },
106
+ );
107
+ child.unref();
108
+
109
+ const deadline = Date.now() + 5000;
110
+ while (Date.now() < deadline) {
111
+ if (existsSync(runtimePath)) {
112
+ try {
113
+ const runtime = JSON.parse(readFileSync(runtimePath, "utf8"));
114
+ return { runtime, members };
115
+ } catch {}
116
+ }
117
+ await new Promise((resolve) => setTimeout(resolve, 100));
118
+ }
119
+
120
+ return { runtime: null, members };
121
+ }
122
+
123
+ export async function nativeRequest(state, path, body = {}) {
124
+ if (!state?.native?.controlUrl) return null;
125
+ try {
126
+ const res = await fetch(`${state.native.controlUrl}${path}`, {
127
+ method: "POST",
128
+ headers: { "Content-Type": "application/json" },
129
+ body: JSON.stringify(body),
130
+ });
131
+ return await res.json();
132
+ } catch {
133
+ return null;
134
+ }
135
+ }
136
+
137
+ export async function nativeGetStatus(state) {
138
+ if (!state?.native?.controlUrl) return null;
139
+ try {
140
+ const res = await fetch(`${state.native.controlUrl}/status`);
141
+ return await res.json();
142
+ } catch {
143
+ return null;
144
+ }
117
145
  }
@@ -22,7 +22,8 @@ export function normalizeTeammateMode(mode = "auto") {
22
22
  export function normalizeLayout(layout = "2x2") {
23
23
  const raw = String(layout).toLowerCase();
24
24
  if (raw === "2x2" || raw === "grid") return "2x2";
25
- if (raw === "1xn" || raw === "1x3" || raw === "vertical" || raw === "columns") return "1xN";
25
+ if (raw === "1xn" || raw === "1x3" || raw === "vertical" || raw === "columns")
26
+ return "1xN";
26
27
  if (raw === "nx1" || raw === "horizontal" || raw === "rows") return "Nx1";
27
28
  return "2x2";
28
29
  }
@@ -60,3 +61,56 @@ export function ensureTmuxOrExit() {
60
61
  error.code = "TMUX_REQUIRED";
61
62
  throw error;
62
63
  }
64
+
65
+ /**
66
+ * teammateMode + 환경 기반 effective mode 결정 (pure function).
67
+ *
68
+ * - wt → wt.exe 없으면 in-process
69
+ * - wt → WT_SESSION 없으면 in-process
70
+ * - in-process → non-TTY 감지 시 headless (#114: run_in_background 부모 종료 시 자식 SIGTERM 방지)
71
+ * - override: TFX_FORCE_IN_PROCESS=1 로 in-process 유지
72
+ *
73
+ * @param {string} teammateMode
74
+ * @param {{
75
+ * hasWt?: () => boolean,
76
+ * hasWtSession?: () => boolean,
77
+ * isTTY?: boolean,
78
+ * env?: Record<string,string|undefined>,
79
+ * }} [deps]
80
+ * @returns {{ mode: string, warnings: string[] }}
81
+ */
82
+ export function resolveEffectiveMode(teammateMode, deps = {}) {
83
+ const hasWt = deps.hasWt || hasWindowsTerminal;
84
+ const hasWtSession = deps.hasWtSession || hasWindowsTerminalSession;
85
+ const isTTY =
86
+ typeof deps.isTTY === "boolean"
87
+ ? deps.isTTY
88
+ : Boolean(process.stdout.isTTY);
89
+ const env = deps.env || process.env;
90
+
91
+ const warnings = [];
92
+ let effective = teammateMode;
93
+
94
+ if (effective === "wt" && !hasWt()) {
95
+ warnings.push("wt.exe 미발견 — in-process 모드로 자동 fallback");
96
+ effective = "in-process";
97
+ }
98
+ if (effective === "wt" && !hasWtSession()) {
99
+ warnings.push(
100
+ "WT_SESSION 미감지(Windows Terminal 외부) — in-process 모드로 자동 fallback",
101
+ );
102
+ effective = "in-process";
103
+ }
104
+ if (
105
+ effective === "in-process" &&
106
+ !isTTY &&
107
+ env.TFX_FORCE_IN_PROCESS !== "1"
108
+ ) {
109
+ warnings.push(
110
+ "non-TTY 환경 감지 (run_in_background 등) — headless 모드로 자동 fallback (#114). 강제 유지: TFX_FORCE_IN_PROCESS=1",
111
+ );
112
+ effective = "headless";
113
+ }
114
+
115
+ return { mode: effective, warnings };
116
+ }
@@ -1,13 +1,23 @@
1
- import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
2
- import { mkdirSync } from "node:fs";
3
- import { dirname, join } from "node:path";
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ readdirSync,
5
+ readFileSync,
6
+ statSync,
7
+ unlinkSync,
8
+ writeFileSync,
9
+ } from "node:fs";
4
10
  import { homedir } from "node:os";
11
+ import { dirname, join } from "node:path";
5
12
  import { fileURLToPath } from "node:url";
6
13
 
7
14
  export const PKG_ROOT = fileURLToPath(new URL("../../../../", import.meta.url));
8
- export const HUB_PID_DIR = join(homedir(), ".claude", "cache", "tfx-hub");
15
+ export const HUB_PID_DIR =
16
+ process.env.TFX_HUB_PID_DIR || join(homedir(), ".claude", "cache", "tfx-hub");
9
17
  export const TEAM_PROFILE = (() => {
10
- const raw = String(process.env.TFX_TEAM_PROFILE || "team").trim().toLowerCase();
18
+ const raw = String(process.env.TFX_TEAM_PROFILE || "team")
19
+ .trim()
20
+ .toLowerCase();
11
21
  return raw === "codex-team" ? "codex-team" : "team";
12
22
  })();
13
23
 
@@ -15,31 +25,78 @@ export const SESSION_ID = process.env.CLAUDE_SESSION_ID || `s${Date.now()}`;
15
25
 
16
26
  function getStatePath(sessionId) {
17
27
  if (sessionId) return join(HUB_PID_DIR, `team-state-${sessionId}.json`);
18
- return join(HUB_PID_DIR, TEAM_PROFILE === "codex-team" ? "team-state-codex-team.json" : "team-state.json");
28
+ return join(
29
+ HUB_PID_DIR,
30
+ TEAM_PROFILE === "codex-team"
31
+ ? "team-state-codex-team.json"
32
+ : "team-state.json",
33
+ );
34
+ }
35
+
36
+ function findLatestMatchingTeamState() {
37
+ try {
38
+ if (!existsSync(HUB_PID_DIR)) return null;
39
+ const entries = readdirSync(HUB_PID_DIR)
40
+ .filter((name) => /^team-state-.+\.json$/.test(name))
41
+ .map((name) => {
42
+ const path = join(HUB_PID_DIR, name);
43
+ try {
44
+ return { path, mtime: statSync(path).mtimeMs };
45
+ } catch {
46
+ return null;
47
+ }
48
+ })
49
+ .filter(Boolean)
50
+ .sort((a, b) => b.mtime - a.mtime);
51
+ for (const { path } of entries) {
52
+ try {
53
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
54
+ if ((parsed.profile || "team") === TEAM_PROFILE) return parsed;
55
+ } catch {
56
+ // corrupt entry — skip
57
+ }
58
+ }
59
+ } catch {
60
+ // directory walk failure
61
+ }
62
+ return null;
19
63
  }
20
64
 
21
65
  export function loadTeamState(sessionId) {
66
+ const explicit = Boolean(sessionId);
22
67
  const resolvedId = sessionId || SESSION_ID;
23
68
  const sessionPath = getStatePath(resolvedId);
24
69
  try {
25
- if (existsSync(sessionPath)) return JSON.parse(readFileSync(sessionPath, "utf8"));
70
+ if (existsSync(sessionPath))
71
+ return JSON.parse(readFileSync(sessionPath, "utf8"));
26
72
  } catch {
27
73
  return null;
28
74
  }
29
75
  // 세션별 파일 없으면 기존 team-state.json fallback
30
76
  const legacyPath = getStatePath(null);
31
77
  try {
32
- if (existsSync(legacyPath)) return JSON.parse(readFileSync(legacyPath, "utf8"));
78
+ if (existsSync(legacyPath))
79
+ return JSON.parse(readFileSync(legacyPath, "utf8"));
33
80
  } catch {
34
81
  return null;
35
82
  }
83
+ // Issue #116-B: CLAUDE_SESSION_ID 미설정이거나 bg shell 의 다른 세션이 저장한
84
+ // team-state-*.json 을 tfx multi status 가 못 찾던 false-negative 우회.
85
+ // 명시 sessionId lookup 은 의도적 조회이므로 auto-discover 하지 않는다.
86
+ if (!explicit) {
87
+ const latest = findLatestMatchingTeamState();
88
+ if (latest) return latest;
89
+ }
36
90
  return null;
37
91
  }
38
92
 
39
93
  export function saveTeamState(state, sessionId) {
40
94
  const path = getStatePath(sessionId || state.sessionId || SESSION_ID);
41
95
  mkdirSync(dirname(path), { recursive: true });
42
- writeFileSync(path, JSON.stringify({ ...state, profile: TEAM_PROFILE }, null, 2) + "\n");
96
+ writeFileSync(
97
+ path,
98
+ JSON.stringify({ ...state, profile: TEAM_PROFILE }, null, 2) + "\n",
99
+ );
43
100
  }
44
101
 
45
102
  export function clearTeamState(sessionId) {