@triflux/remote 10.25.1 → 10.27.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.
Files changed (45) hide show
  1. package/hub/server.mjs +21 -8
  2. package/hub/team/agent-map.json +3 -3
  3. package/hub/team/backend.mjs +3 -9
  4. package/hub/team/build-worker-prompt.mjs +33 -2
  5. package/hub/team/claude-daemon-control.mjs +911 -0
  6. package/hub/team/claude-native-bridge.mjs +244 -11
  7. package/hub/team/claude-session-projection.mjs +10 -1
  8. package/hub/team/cli/commands/start/index.mjs +3 -6
  9. package/hub/team/cli/commands/start/parse-args.mjs +10 -3
  10. package/hub/team/cli/commands/start/start-headless.mjs +50 -20
  11. package/hub/team/cli/help.mjs +1 -1
  12. package/hub/team/cli/services/runtime-mode.mjs +6 -3
  13. package/hub/team/execution-mode.mjs +27 -12
  14. package/hub/team/headless.mjs +191 -43
  15. package/hub/team/interactive-native-launcher.mjs +106 -0
  16. package/hub/team/interactive-native-worker.mjs +80 -0
  17. package/hub/team/interactive-tui-transport.mjs +465 -0
  18. package/hub/team/launcher-template.mjs +28 -6
  19. package/hub/team/native.mjs +46 -7
  20. package/hub/team/pane.mjs +0 -5
  21. package/hub/team/psmux.mjs +3 -3
  22. package/hub/team/retry-state-machine.mjs +83 -14
  23. package/hub/team/session.mjs +5 -36
  24. package/hub/team/swarm-hypervisor.mjs +63 -14
  25. package/hub/team/swarm-preflight.mjs +3 -1
  26. package/hub/team/terminal-opener.mjs +1 -1
  27. package/hub/team/tui-core.mjs +4 -0
  28. package/hub/team/tui-lite.mjs +3 -2
  29. package/hub/team/tui-viewer.mjs +4 -4
  30. package/hub/team/tui.mjs +4 -2
  31. package/hub/team/worker-sandbox.mjs +4 -0
  32. package/hub/tools.mjs +4 -1
  33. package/hub/workers/antigravity-route-worker.mjs +169 -0
  34. package/hub/workers/delegator-mcp.mjs +58 -314
  35. package/hub/workers/factory.mjs +7 -4
  36. package/hub/workers/gemini-worker.mjs +55 -306
  37. package/hub/workers/interface.mjs +2 -1
  38. package/hub/workers/worker-utils.mjs +1 -1
  39. package/package.json +1 -1
  40. package/scripts/lib/cli-codex.mjs +3 -3
  41. package/scripts/lib/cli-gemini.mjs +20 -50
  42. package/scripts/lib/keyword-rules.mjs +1 -1
  43. package/scripts/lib/mcp-gateway-health-check.mjs +159 -0
  44. package/scripts/lib/mcp-gateway-wrapper-check.mjs +35 -0
  45. package/scripts/lib/psmux-info.mjs +50 -5
@@ -8,7 +8,7 @@ import { whichCommand } from "@triflux/core/hub/platform.mjs";
8
8
  const WIN32_EXT_PRECEDENCE = [".cmd", ".exe", ".bat", ".ps1"];
9
9
 
10
10
  // #128: parse npm-cmd-shim wrapper to extract the underlying .js entry point.
11
- // codex.cmd / gemini.cmd end with: "%_prog%" "%dp0%\node_modules\...\<cli>.js" %*
11
+ // npm shim wrappers end with: "%_prog%" "%dp0%\node_modules\...\<cli>.js" %*
12
12
  // %* is cmd batch arg pass-through which mangles multi-line / fenced prompts.
13
13
  // Bypass cmd entirely by spawning node + the .js directly.
14
14
  export function unwrapCmdToJsScript(cmdPath, opts = {}) {
@@ -55,6 +55,14 @@ export function resolveStdinPromptMode(explicit, envSource) {
55
55
  return true;
56
56
  }
57
57
 
58
+ function normalizeCli(cli) {
59
+ const value = String(cli || "codex").toLowerCase();
60
+ if (value === "gemini" || value === "antigravity" || value === "agy") {
61
+ return "antigravity";
62
+ }
63
+ return value;
64
+ }
65
+
58
66
  export function resolveCliExecutable(cli, opts = {}) {
59
67
  const name = String(cli || "codex");
60
68
  const resolveCommand = opts.resolveCommand || whichCommand;
@@ -86,9 +94,10 @@ export function resolveCliExecutable(cli, opts = {}) {
86
94
  }
87
95
 
88
96
  export function buildSpawnSpecForMode(mode, opts = {}) {
89
- const cli = opts.cli || "codex";
97
+ const cli = normalizeCli(opts.cli || "codex");
90
98
  const prompt = asPrompt(opts.prompt);
91
- const resolvedCommand = resolveCliExecutable(cli, opts);
99
+ const commandName = cli === "antigravity" ? "agy" : cli;
100
+ const resolvedCommand = resolveCliExecutable(commandName, opts);
92
101
  const platform = opts.platform || process.platform;
93
102
 
94
103
  // Node v20.12+ (CVE-2024-27980) rejects spawn of .cmd/.bat files with shell:false
@@ -115,11 +124,15 @@ export function buildSpawnSpecForMode(mode, opts = {}) {
115
124
  return { command: resolvedCommand, args };
116
125
  };
117
126
 
118
- if (cli === "gemini") {
119
- const args = [];
120
- pushFlag(args, "--model", opts.model);
121
- args.push("--yolo", "--prompt", prompt, "--output-format", "text");
122
- return { ...wrap(args), useExec: true, shell: false };
127
+ if (cli === "antigravity") {
128
+ const args = ["--print", "--dangerously-skip-permissions"];
129
+ return {
130
+ ...wrap(args),
131
+ useExec: true,
132
+ shell: false,
133
+ stdinPrompt: prompt.length > 0,
134
+ prompt,
135
+ };
123
136
  }
124
137
 
125
138
  if (mode === MODES.INTERACTIVE || mode === MODES.AUTO) {
@@ -169,7 +182,7 @@ export function buildSpawnSpecForMode(mode, opts = {}) {
169
182
 
170
183
  /**
171
184
  * @param {{
172
- * cli: "codex"|"gemini"|"claude",
185
+ * cli: "codex"|"gemini"|"antigravity"|"agy"|"claude",
173
186
  * taskType?: "implement"|"review"|"research"|"test"|"analyze",
174
187
  * needsInput?: boolean,
175
188
  * estimatedDuration?: number,
@@ -179,13 +192,15 @@ export function buildSpawnSpecForMode(mode, opts = {}) {
179
192
  */
180
193
  export function selectExecutionMode(opts = {}) {
181
194
  const {
182
- cli = "codex",
195
+ cli: rawCli = "codex",
183
196
  taskType = "research",
184
197
  needsInput = false,
185
198
  estimatedDuration = 0,
186
199
  hasHub = false,
187
200
  } = opts;
188
201
 
202
+ const cli = normalizeCli(rawCli);
203
+
189
204
  if (!hasHub) {
190
205
  return {
191
206
  mode: MODES.HEADLESS,
@@ -193,10 +208,10 @@ export function selectExecutionMode(opts = {}) {
193
208
  };
194
209
  }
195
210
 
196
- if (cli === "gemini") {
211
+ if (cli === "antigravity") {
197
212
  return {
198
213
  mode: MODES.HEADLESS,
199
- reason: "gemini CLI only supports headless prompt mode",
214
+ reason: "antigravity CLI uses headless stdin print mode",
200
215
  };
201
216
  }
202
217
 
@@ -18,7 +18,8 @@ import {
18
18
  import { createRequire } from "node:module";
19
19
  import net from "node:net";
20
20
  import { tmpdir } from "node:os";
21
- import { join } from "node:path";
21
+ import { dirname, join } from "node:path";
22
+ import { fileURLToPath } from "node:url";
22
23
  import { requestJson } from "@triflux/core/hub/bridge.mjs";
23
24
  import { escapePwshSingleQuoted } from "@triflux/core/hub/cli-adapter-base.mjs";
24
25
  import { getMaxSpawnPerSec } from "@triflux/core/hub/lib/spawn-trace.mjs";
@@ -28,8 +29,9 @@ import {
28
29
  buildDaemonExecDispatchPayload,
29
30
  deriveClaudeDaemonPaths as deriveClaudeControlPaths,
30
31
  killDaemonJob,
31
- sendKillBySessionId,
32
+ resolveDaemonBridgeSessionId,
32
33
  sendClaudeControlRequest,
34
+ sendKillBySessionId,
33
35
  waitForDaemonJobPid,
34
36
  } from "./claude-daemon-control.mjs";
35
37
  import {
@@ -67,11 +69,17 @@ import { createWtManager } from "./wt-manager.mjs";
67
69
 
68
70
  const RESULT_DIR = join(tmpdir(), "tfx-headless");
69
71
  const DAEMON_COMPLETION_PREFIX = "__TFX_HEADLESS_DONE__:";
72
+ const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
70
73
 
71
74
  /** CLI별 브랜드 — 이모지 + 공식 색상 (HUD와 통일) */
72
75
  const CLI_BRAND = {
73
76
  codex: { emoji: "\u{26AA}", label: "Codex", ansi: "\x1b[97m" }, // ⚪ bright white (codexWhite)
74
77
  gemini: { emoji: "\u{1F535}", label: "Gemini", ansi: "\x1b[38;5;39m" }, // 🔵 geminiBlue
78
+ antigravity: {
79
+ emoji: "\u{1F535}",
80
+ label: "Antigravity",
81
+ ansi: "\x1b[38;5;39m",
82
+ },
75
83
  claude: {
76
84
  emoji: "\u{1F7E0}",
77
85
  label: "Claude",
@@ -84,17 +92,109 @@ const _ANSI_DIM = "\x1b[2m";
84
92
  /** 에이전트 역할명 → CLI 타입 매핑 (단일 소스: agent-map.json) */
85
93
  const _require = createRequire(import.meta.url);
86
94
  const AGENT_TO_CLI = _require("./agent-map.json");
95
+ // scripts/tfx-route.sh also validates route agents from agent-map.json.
96
+ const VALID_ROUTE_AGENTS = new Set(Object.keys(AGENT_TO_CLI));
87
97
 
88
98
  /**
89
- * 에이전트 역할명 또는 CLI 이름을 CLI 타입("codex"|"gemini"|"claude")으로 해석한다.
99
+ * 에이전트 역할명 또는 CLI 이름을 CLI 타입("codex"|"antigravity"|"claude")으로 해석한다.
90
100
  * route_agent()가 적용되지 않는 headless 경로에서 사용.
91
101
  * @param {string} agentOrCli — "executor", "codex", "designer" 등
92
- * @returns {'codex'|'gemini'|'claude'} CLI 타입
102
+ * @returns {'codex'|'antigravity'|'claude'} CLI 타입
93
103
  */
94
104
  export function resolveCliType(agentOrCli) {
95
105
  return AGENT_TO_CLI[agentOrCli] || agentOrCli;
96
106
  }
97
107
 
108
+ function resolveHeadlessCliType(agentOrCli) {
109
+ const resolved = resolveCliType(agentOrCli);
110
+ return resolved === "gemini" ? "antigravity" : resolved;
111
+ }
112
+
113
+ function shellQuote(value) {
114
+ return `'${String(value).replace(/'/g, "'\\''")}'`;
115
+ }
116
+
117
+ function resolveHeadlessRouteScript(opts = {}) {
118
+ const candidates = [
119
+ opts.routeScript,
120
+ process.env.TFX_ROUTE_SCRIPT,
121
+ process.env.TFX_DELEGATOR_ROUTE_SCRIPT,
122
+ join(process.cwd(), "scripts", "tfx-route.sh"),
123
+ join(SCRIPT_DIR, "..", "..", "scripts", "tfx-route.sh"),
124
+ process.env.HOME
125
+ ? join(process.env.HOME, ".claude", "scripts", "tfx-route.sh")
126
+ : "",
127
+ "tfx-route.sh",
128
+ ];
129
+
130
+ for (const candidate of candidates) {
131
+ if (!candidate) continue;
132
+ if (candidate === "tfx-route.sh" || existsSync(candidate)) {
133
+ return candidate;
134
+ }
135
+ }
136
+
137
+ return "tfx-route.sh";
138
+ }
139
+
140
+ function resolveRouteAgentForHeadless(resolvedCli, opts = {}) {
141
+ const role = String(opts.role || "").trim();
142
+ if (
143
+ role &&
144
+ VALID_ROUTE_AGENTS.has(role) &&
145
+ !["gemini", "antigravity", "agy"].includes(role)
146
+ ) {
147
+ return role;
148
+ }
149
+ return resolvedCli === "antigravity" ? "antigravity" : resolvedCli;
150
+ }
151
+
152
+ export function normalizeHeadlessRole(role) {
153
+ const normalized = String(role || "")
154
+ .trim()
155
+ .toLowerCase();
156
+ return normalized && VALID_ROUTE_AGENTS.has(normalized) ? normalized : "";
157
+ }
158
+
159
+ export function resolveHeadlessDisplayName(assignment = {}, paneName = "") {
160
+ const normalized = String(assignment.displayName || "")
161
+ .trim()
162
+ .replace(/[\r\n\x00-\x1f]/g, " ");
163
+ return normalized || paneName || "";
164
+ }
165
+
166
+ export function normalizeHeadlessAssignment(assignment = {}, index = 0) {
167
+ const paneName = `worker-${index + 1}`;
168
+ return {
169
+ ...assignment,
170
+ role: normalizeHeadlessRole(assignment.role),
171
+ displayName: resolveHeadlessDisplayName(assignment, paneName),
172
+ };
173
+ }
174
+
175
+ function buildRouteBackedHeadlessCommand(
176
+ resolvedCli,
177
+ promptFile,
178
+ resultFile,
179
+ opts = {},
180
+ ) {
181
+ const routeScript = resolveHeadlessRouteScript(opts);
182
+ const routeAgent = resolveRouteAgentForHeadless(resolvedCli, opts);
183
+ const mcp = String(opts.mcp || "auto");
184
+ const timeout =
185
+ Number.isFinite(Number(opts.timeoutSec)) && Number(opts.timeoutSec) > 0
186
+ ? String(Math.trunc(Number(opts.timeoutSec)))
187
+ : "";
188
+ const timeoutArg = timeout ? ` ${shellQuote(timeout)}` : "";
189
+ const routeMode = resolvedCli === "antigravity" ? "antigravity" : resolvedCli;
190
+ const script =
191
+ `__tfx_prompt=$(cat ${shellQuote(promptFile)}); ` +
192
+ `TFX_CLI_MODE=${shellQuote(routeMode)} ` +
193
+ `bash ${shellQuote(routeScript)} ${shellQuote(routeAgent)} "$__tfx_prompt" ${shellQuote(mcp)}${timeoutArg} ` +
194
+ `> ${shellQuote(resultFile)} 2>${shellQuote(`${resultFile}.err`)} < /dev/null`;
195
+ return `bash -lc ${shellQuote(script)}`;
196
+ }
197
+
98
198
  export function getHeadlessWorkerAgentId(sessionName, index) {
99
199
  return `headless-${sessionName}-${index}`;
100
200
  }
@@ -173,7 +273,7 @@ const MCP_PROFILE_HINTS = {
173
273
 
174
274
  /**
175
275
  * CLI별 헤드리스 명령 빌더
176
- * @param {'codex'|'gemini'|'claude'} cli
276
+ * @param {'codex'|'antigravity'|'claude'} cli
177
277
  * @param {string} prompt — 실행할 프롬프트
178
278
  * @param {string} resultFile — 결과 저장 파일 경로
179
279
  * @param {object} [opts]
@@ -205,7 +305,7 @@ export function buildDashboardAttachArgs(
205
305
 
206
306
  export function buildHeadlessCommand(cli, prompt, resultFile, opts = {}) {
207
307
  const { handoff = true, mcp, contextFile, model, cwd } = opts;
208
- const resolvedCli = resolveCliType(cli);
308
+ const resolvedCli = resolveHeadlessCliType(cli);
209
309
 
210
310
  // contextFile 처리: 32KB(32768 bytes) 초과 시 UTF-8 안전 절단
211
311
  let contextPrefix = "";
@@ -241,12 +341,17 @@ export function buildHeadlessCommand(cli, prompt, resultFile, opts = {}) {
241
341
  writeFileSync(promptFile, fullPrompt, "utf8");
242
342
  void IS_WINDOWS; // referenced for diagnostic guard chain below
243
343
 
244
- const backend = getBackend(resolvedCli);
245
- const backendCommand = backend.buildArgs(fullPrompt, resultFile, {
246
- ...opts,
247
- model,
248
- promptFile,
249
- });
344
+ const backendCommand =
345
+ resolvedCli === "antigravity"
346
+ ? buildRouteBackedHeadlessCommand(resolvedCli, promptFile, resultFile, {
347
+ ...opts,
348
+ mcp,
349
+ })
350
+ : getBackend(resolvedCli).buildArgs(fullPrompt, resultFile, {
351
+ ...opts,
352
+ model,
353
+ promptFile,
354
+ });
250
355
  const safeCwd =
251
356
  typeof cwd === "string" ? cwd.trim().replace(/[\r\n\x00-\x1f]/g, "") : "";
252
357
  if (safeCwd && (safeCwd.startsWith("\\\\") || safeCwd.startsWith("//"))) {
@@ -679,16 +784,15 @@ async function dispatchProgressive(sessionName, assignments, opts = {}) {
679
784
  for (let i = 0; i < assignments.length; i++) {
680
785
  const assignment = assignments[i];
681
786
  const paneName = `worker-${i + 1}`;
787
+ const displayName = resolveHeadlessDisplayName(assignment, paneName);
682
788
  const workerId = getHeadlessWorkerAgentId(sessionName, i);
683
- const resolvedCli = resolveCliType(assignment.cli);
789
+ const resolvedCli = resolveHeadlessCliType(assignment.cli);
684
790
  const brand = CLI_BRAND[resolvedCli] || {
685
791
  emoji: "\u{25CF}",
686
792
  label: resolvedCli,
687
793
  ansi: "",
688
794
  };
689
- const paneTitle = assignment.role
690
- ? `${brand.emoji} ${resolvedCli} (${assignment.role})`
691
- : `${brand.emoji} ${resolvedCli}-${i + 1}`;
795
+ const paneTitle = `${brand.emoji} ${resolvedCli} (${displayName})`;
692
796
 
693
797
  let newPaneId;
694
798
  // 모든 워커를 split-window로 생성 (lead pane index 0은 비워둠)
@@ -708,13 +812,14 @@ async function dispatchProgressive(sessionName, assignments, opts = {}) {
708
812
  } catch {
709
813
  /* 무시 */
710
814
  }
711
- await registerHeadlessWorker(sessionName, i, assignment.cli);
815
+ await registerHeadlessWorker(sessionName, i, resolvedCli);
712
816
 
713
817
  if (safeProgress)
714
818
  safeProgress({
715
819
  type: "worker_added",
716
820
  paneName,
717
- cli: assignment.cli,
821
+ displayName,
822
+ cli: resolvedCli,
718
823
  paneTitle,
719
824
  });
720
825
 
@@ -731,6 +836,7 @@ async function dispatchProgressive(sessionName, assignments, opts = {}) {
731
836
  resultFile,
732
837
  {
733
838
  mcp: assignment.mcp,
839
+ role: assignment.role,
734
840
  model: assignment.model,
735
841
  cwd: assignment.cwd || assignment.workdir,
736
842
  },
@@ -742,14 +848,15 @@ async function dispatchProgressive(sessionName, assignments, opts = {}) {
742
848
  registerHeadlessSynapseWorker(workerId, assignment.prompt);
743
849
 
744
850
  if (safeProgress)
745
- safeProgress({ type: "dispatched", paneName, cli: assignment.cli });
851
+ safeProgress({ type: "dispatched", paneName, cli: resolvedCli });
746
852
 
747
853
  dispatches.push({
748
854
  ...dispatch,
749
855
  paneId: newPaneId,
750
856
  paneName,
857
+ displayName,
751
858
  resultFile,
752
- cli: assignment.cli,
859
+ cli: resolvedCli,
753
860
  role: assignment.role,
754
861
  command: cmd,
755
862
  workerId,
@@ -796,7 +903,9 @@ async function dispatchBatch(sessionName, assignments, opts = {}) {
796
903
  return await Promise.all(
797
904
  assignments.map(async (assignment, i) => {
798
905
  const paneName = `worker-${i + 1}`;
906
+ const displayName = resolveHeadlessDisplayName(assignment, paneName);
799
907
  const workerId = getHeadlessWorkerAgentId(sessionName, i);
908
+ const resolvedCli = resolveHeadlessCliType(assignment.cli);
800
909
  const resultFile = join(
801
910
  RESULT_DIR,
802
911
  `${sessionName}-${paneName}.txt`,
@@ -809,12 +918,13 @@ async function dispatchBatch(sessionName, assignments, opts = {}) {
809
918
  resultFile,
810
919
  {
811
920
  mcp: assignment.mcp,
921
+ role: assignment.role,
812
922
  model: assignment.model,
813
923
  cwd: assignment.cwd || assignment.workdir,
814
924
  },
815
925
  );
816
926
  const scriptDir = join(RESULT_DIR, sessionName);
817
- await registerHeadlessWorker(sessionName, i, assignment.cli);
927
+ await registerHeadlessWorker(sessionName, i, resolvedCli);
818
928
  const dispatch = dispatchCommand(sessionName, paneName, cmd, {
819
929
  scriptDir,
820
930
  scriptName: paneName,
@@ -826,13 +936,19 @@ async function dispatchBatch(sessionName, assignments, opts = {}) {
826
936
  // progressive 모드에서는 split-window 시 새 pane에 바로 타이틀이 설정되므로 문제없음
827
937
 
828
938
  if (safeProgress)
829
- safeProgress({ type: "dispatched", paneName, cli: assignment.cli });
939
+ safeProgress({
940
+ type: "dispatched",
941
+ paneName,
942
+ displayName,
943
+ cli: resolvedCli,
944
+ });
830
945
 
831
946
  return {
832
947
  ...dispatch,
833
948
  paneName,
949
+ displayName,
834
950
  resultFile,
835
- cli: assignment.cli,
951
+ cli: resolvedCli,
836
952
  role: assignment.role,
837
953
  command: cmd,
838
954
  workerId,
@@ -851,7 +967,9 @@ async function dispatchDaemonBatch(sessionName, assignments, opts = {}) {
851
967
  for (let i = 0; i < assignments.length; i += 1) {
852
968
  const assignment = assignments[i];
853
969
  const paneName = `worker-${i + 1}`;
970
+ const displayName = resolveHeadlessDisplayName(assignment, paneName);
854
971
  const workerId = getHeadlessWorkerAgentId(sessionName, i);
972
+ const resolvedCli = resolveHeadlessCliType(assignment.cli);
855
973
  const resultFile = join(
856
974
  RESULT_DIR,
857
975
  `${sessionName}-${paneName}.txt`,
@@ -863,18 +981,20 @@ async function dispatchDaemonBatch(sessionName, assignments, opts = {}) {
863
981
  resultFile,
864
982
  {
865
983
  mcp: assignment.mcp,
984
+ role: assignment.role,
866
985
  model: assignment.model,
867
986
  cwd: assignment.cwd || assignment.workdir,
868
987
  },
869
988
  );
870
989
  const token = randomUUID().slice(0, 10);
871
990
  const short = randomUUID().replace(/-/g, "").slice(0, 8);
872
- const name = `Triflux ${assignment.cli || "worker"} ${assignment.role || paneName}`;
991
+ const name = `Triflux ${resolvedCli || "worker"} ${displayName}`;
873
992
  const record = {
874
993
  paneId: `daemon:${short}`,
875
994
  paneName,
995
+ displayName,
876
996
  resultFile,
877
- cli: assignment.cli,
997
+ cli: resolvedCli,
878
998
  role: assignment.role,
879
999
  command,
880
1000
  token,
@@ -909,6 +1029,11 @@ async function dispatchDaemonBatch(sessionName, assignments, opts = {}) {
909
1029
  }
910
1030
 
911
1031
  const job = await waitForDaemonJobPid(paths.controlSock, short);
1032
+ const bridgeSessionId = await resolveDaemonBridgeSessionId({
1033
+ daemonPaths: paths,
1034
+ short,
1035
+ job,
1036
+ });
912
1037
  const projection = buildClaudeSessionProjection({
913
1038
  pid: job.pid,
914
1039
  procStart: getProcStart(job.pid),
@@ -916,18 +1041,19 @@ async function dispatchDaemonBatch(sessionName, assignments, opts = {}) {
916
1041
  short,
917
1042
  cwd: assignment.cwd || assignment.workdir || process.cwd(),
918
1043
  name,
919
- agent: assignment.cli || "codex",
1044
+ agent: resolvedCli || "codex",
920
1045
  startedAt: job.startedAt || Date.now(),
921
1046
  updatedAt: Date.now(),
1047
+ bridgeSessionId,
922
1048
  });
923
1049
  record.sessionProjectionPath = await writeClaudeSessionProjection(
924
1050
  paths.sessionsDir,
925
1051
  projection,
926
1052
  );
927
- await registerHeadlessWorker(sessionName, i, assignment.cli);
1053
+ await registerHeadlessWorker(sessionName, i, resolvedCli);
928
1054
  registerHeadlessSynapseWorker(workerId, assignment.prompt);
929
1055
  if (safeProgress) {
930
- safeProgress({ type: "dispatched", paneName, cli: assignment.cli });
1056
+ safeProgress({ type: "dispatched", paneName, cli: resolvedCli });
931
1057
  }
932
1058
  }
933
1059
  } catch (error) {
@@ -997,6 +1123,7 @@ async function awaitAll(
997
1123
  safeProgress({
998
1124
  type: "progress",
999
1125
  paneName: d.paneName,
1126
+ displayName: d.displayName,
1000
1127
  cli: d.cli,
1001
1128
  snapshot: content.split("\n").slice(-15).join("\n"), // 마지막 15줄
1002
1129
  });
@@ -1015,6 +1142,7 @@ async function awaitAll(
1015
1142
  safeProgress({
1016
1143
  type: "progress",
1017
1144
  paneName: d.paneName,
1145
+ displayName: d.displayName,
1018
1146
  cli: d.cli,
1019
1147
  snapshot: snapshot.split("\n").slice(-15).join("\n"),
1020
1148
  });
@@ -1092,6 +1220,7 @@ async function awaitAll(
1092
1220
  safeProgress({
1093
1221
  type: "completed",
1094
1222
  paneName: d.paneName,
1223
+ displayName: d.displayName,
1095
1224
  cli: d.cli,
1096
1225
  matched: completion.matched,
1097
1226
  exitCode: completion.exitCode,
@@ -1184,6 +1313,7 @@ async function waitForDaemonCompletion(
1184
1313
  safeProgress({
1185
1314
  type: "progress",
1186
1315
  paneName: dispatch.paneName,
1316
+ displayName: dispatch.displayName,
1187
1317
  cli: dispatch.cli,
1188
1318
  snapshot: message.line.split("\n").slice(-15).join("\n"),
1189
1319
  });
@@ -1220,6 +1350,7 @@ async function awaitAllDaemon(
1220
1350
  safeProgress({
1221
1351
  type: "completed",
1222
1352
  paneName: d.paneName,
1353
+ displayName: d.displayName,
1223
1354
  cli: d.cli,
1224
1355
  matched: completion.matched,
1225
1356
  exitCode: completion.exitCode,
@@ -1276,6 +1407,7 @@ async function collectResults(sessionName, results) {
1276
1407
  return {
1277
1408
  cli: d.cli,
1278
1409
  paneName: d.paneName,
1410
+ displayName: d.displayName,
1279
1411
  paneId: d.paneId,
1280
1412
  workerId: d.workerId,
1281
1413
  role: d.role,
@@ -1337,8 +1469,11 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
1337
1469
  } = opts;
1338
1470
 
1339
1471
  mkdirSync(RESULT_DIR, { recursive: true });
1472
+ const normalizedAssignments = (assignments || []).map((assignment, index) =>
1473
+ normalizeHeadlessAssignment(assignment, index),
1474
+ );
1340
1475
 
1341
- if (assignments.length === 0) {
1476
+ if (normalizedAssignments.length === 0) {
1342
1477
  return { sessionName, results: [] };
1343
1478
  }
1344
1479
 
@@ -1349,11 +1484,16 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
1349
1484
  "[headless] --native-bridge-mode claude-wrapper is reserved and not implemented yet",
1350
1485
  );
1351
1486
  }
1352
- if (nativeBridge && nativeBridgeMode === "roster") {
1487
+ if (
1488
+ nativeBridge &&
1489
+ (nativeBridgeMode === "roster" || nativeBridgeMode === "interactive-attach")
1490
+ ) {
1353
1491
  nativeBridgeHandle = await startClaudeNativeBridge({
1354
1492
  sessionName,
1355
- assignments,
1493
+ assignments: normalizedAssignments,
1356
1494
  cwd: process.cwd(),
1495
+ workerType:
1496
+ nativeBridgeMode === "interactive-attach" ? "interactive" : "headless",
1357
1497
  onKill() {
1358
1498
  try {
1359
1499
  killPsmuxSession(sessionName);
@@ -1381,11 +1521,11 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
1381
1521
  .catch(() => {});
1382
1522
 
1383
1523
  // Synapse: 세션 registration (fire-and-forget, hub 미응답 시 무시)
1384
- const synapseIds = assignments.map(
1524
+ const synapseIds = normalizedAssignments.map(
1385
1525
  (_, i) => `${sessionName}-worker-${i + 1}`,
1386
1526
  );
1387
- for (let i = 0; i < assignments.length; i++) {
1388
- const a = assignments[i];
1527
+ for (let i = 0; i < normalizedAssignments.length; i++) {
1528
+ const a = normalizedAssignments[i];
1389
1529
  requestJson("/synapse/register", {
1390
1530
  method: "POST",
1391
1531
  body: {
@@ -1402,7 +1542,7 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
1402
1542
  let tui = null;
1403
1543
  const resolvedLayout = resolveDashboardLayout(
1404
1544
  dashboardLayout,
1405
- assignments.length,
1545
+ normalizedAssignments.length,
1406
1546
  );
1407
1547
  if (dashboard && process.stdout.isTTY) {
1408
1548
  tui = createLogDashboard({
@@ -1413,10 +1553,12 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
1413
1553
  });
1414
1554
  tui.setStartTime(Date.now());
1415
1555
  // 초기 워커 상태 등록
1416
- for (let i = 0; i < assignments.length; i++) {
1417
- const a = assignments[i];
1418
- tui.updateWorker(`worker-${i + 1}`, {
1419
- cli: a.cli || "codex",
1556
+ for (let i = 0; i < normalizedAssignments.length; i++) {
1557
+ const a = normalizedAssignments[i];
1558
+ const paneName = `worker-${i + 1}`;
1559
+ tui.updateWorker(paneName, {
1560
+ cli: resolveHeadlessCliType(a.cli || "codex"),
1561
+ displayName: resolveHeadlessDisplayName(a, paneName),
1420
1562
  role: a.role || "",
1421
1563
  status: "pending",
1422
1564
  progress: 0,
@@ -1437,12 +1579,14 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
1437
1579
  // per-worker state feed: onProgress 이벤트 → tui.updateWorker()
1438
1580
  function feedTui(event) {
1439
1581
  if (!tui) return;
1440
- const { type, paneName, cli, snapshot, matched, exitCode } = event;
1582
+ const { type, paneName, displayName, cli, snapshot, matched, exitCode } =
1583
+ event;
1441
1584
  if (!paneName) return;
1442
1585
 
1443
1586
  if (type === "progress" && snapshot) {
1444
1587
  tui.updateWorker(paneName, {
1445
1588
  cli: cli || "codex",
1589
+ displayName,
1446
1590
  status: "running",
1447
1591
  snapshot: snapshot.split("\n").at(-1) || "",
1448
1592
  summary: snapshot.split("\n").at(-1) || "",
@@ -1453,12 +1597,14 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
1453
1597
  const status = matched && exitCode === 0 ? "completed" : "failed";
1454
1598
  tui.updateWorker(paneName, {
1455
1599
  cli: cli || "codex",
1600
+ displayName,
1456
1601
  status,
1457
1602
  progress: 1,
1458
1603
  });
1459
1604
  } else if (type === "worker_added") {
1460
1605
  tui.updateWorker(paneName, {
1461
1606
  cli: cli || "codex",
1607
+ displayName,
1462
1608
  status: "running",
1463
1609
  progress: 0.05,
1464
1610
  });
@@ -1507,16 +1653,16 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
1507
1653
  try {
1508
1654
  const dispatches =
1509
1655
  nativeBridge && nativeBridgeMode === "agents"
1510
- ? await dispatchDaemonBatch(sessionName, assignments, {
1656
+ ? await dispatchDaemonBatch(sessionName, normalizedAssignments, {
1511
1657
  safeProgress,
1512
1658
  })
1513
1659
  : progressive
1514
- ? await dispatchProgressive(sessionName, assignments, {
1660
+ ? await dispatchProgressive(sessionName, normalizedAssignments, {
1515
1661
  layout,
1516
1662
  safeProgress,
1517
1663
  dashboardLayout,
1518
1664
  })
1519
- : await dispatchBatch(sessionName, assignments, {
1665
+ : await dispatchBatch(sessionName, normalizedAssignments, {
1520
1666
  layout,
1521
1667
  safeProgress,
1522
1668
  dashboardLayout,
@@ -1549,6 +1695,7 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
1549
1695
  for (const r of collected) {
1550
1696
  tui.updateWorker(r.paneName, {
1551
1697
  cli: r.cli,
1698
+ displayName: r.displayName,
1552
1699
  role: r.role || "",
1553
1700
  status: r.handoff?.status === "failed" ? "failed" : "completed",
1554
1701
  handoff: r.handoff,
@@ -1950,6 +2097,7 @@ export async function runHeadlessInteractive(
1950
2097
  // Fix P2: paneId를 dispatches에 포함 (snapshots에서 필요)
1951
2098
  const dispatches = results.map((r, i) => ({
1952
2099
  paneName: r.paneName,
2100
+ displayName: r.displayName,
1953
2101
  paneId: r.paneId || "",
1954
2102
  cli: r.cli,
1955
2103
  role: r.role,