@triflux/remote 10.27.0 → 10.28.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.
@@ -1,8 +1,60 @@
1
+ import { execFileSync } from "node:child_process";
1
2
  import crypto from "node:crypto";
2
3
  import fs from "node:fs/promises";
3
4
  import net from "node:net";
4
5
  import os from "node:os";
5
6
  import path from "node:path";
7
+ import {
8
+ buildClaudeSessionProjection,
9
+ removeClaudeSessionProjection,
10
+ writeClaudeSessionProjection,
11
+ } from "./claude-session-projection.mjs";
12
+
13
+ export function buildClaudePromptDispatchPayload({
14
+ short = crypto.randomBytes(4).toString("hex"),
15
+ sessionId,
16
+ cwd = process.cwd(),
17
+ prompt,
18
+ name = "tfx uds claude prompt",
19
+ createdAt = Date.now(),
20
+ cols = 120,
21
+ rows = 40,
22
+ } = {}) {
23
+ if (!prompt) throw new Error("prompt is required");
24
+ const uuid = crypto.randomUUID();
25
+ const resolvedSessionId = sessionId || `${short}${uuid.slice(8)}`;
26
+ return {
27
+ proto: 1,
28
+ short,
29
+ sessionId: resolvedSessionId,
30
+ createdAt,
31
+ source: "shell",
32
+ cwd,
33
+ agent: "claude",
34
+ launch: {
35
+ mode: "prompt",
36
+ args: [
37
+ "--session-id",
38
+ resolvedSessionId,
39
+ "--agent",
40
+ "claude",
41
+ "--permission-mode",
42
+ "auto",
43
+ "--",
44
+ prompt,
45
+ ],
46
+ },
47
+ env: {},
48
+ isolation: "none",
49
+ respawnFlags: [],
50
+ seed: {
51
+ intent: "[redacted uds orchestration prompt]",
52
+ name,
53
+ },
54
+ cols,
55
+ rows,
56
+ };
57
+ }
6
58
 
7
59
  export function resolveClaudeConfigDir(env = process.env) {
8
60
  if (env.CLAUDE_CONFIG_DIR) return path.resolve(env.CLAUDE_CONFIG_DIR);
@@ -26,12 +78,23 @@ export function deriveClaudeDaemonPaths({
26
78
  hash,
27
79
  daemonDir,
28
80
  controlSock: path.join(daemonDir, "control.sock"),
81
+ rendezvousDir: path.join(daemonDir, "rv"),
82
+ ptyDir: path.join(daemonDir, "pty"),
29
83
  rosterPath: path.join(resolvedConfigDir, "daemon", "roster.json"),
30
84
  sessionsDir: path.join(resolvedConfigDir, "sessions"),
31
85
  jobsDir: path.join(resolvedConfigDir, "jobs"),
32
86
  };
33
87
  }
34
88
 
89
+ export function getProcStart(pid = process.pid) {
90
+ if (!Number.isInteger(pid) || pid <= 0)
91
+ throw new Error(`invalid pid: ${pid}`);
92
+ return execFileSync("ps", ["-o", "lstart=", "-p", String(pid)], {
93
+ encoding: "utf8",
94
+ env: { ...process.env, LC_ALL: "C", TZ: "UTC" },
95
+ }).trim();
96
+ }
97
+
35
98
  export function readFirstJsonLine(text) {
36
99
  const line = String(text)
37
100
  .split("\n")
@@ -1128,3 +1191,154 @@ export async function sendKillBySessionId({
1128
1191
  };
1129
1192
  }
1130
1193
  }
1194
+
1195
+ /**
1196
+ * Claude 데몬 dispatch 한 건의 공통 시퀀스를 캡슐화한다.
1197
+ * dispatch → waitForDaemonJobPid → resolveDaemonBridgeSessionId →
1198
+ * buildClaudeSessionProjection → writeClaudeSessionProjection.
1199
+ *
1200
+ * 호출 측이 reference 로 들고 있는 record 를 헬퍼가 새로 만들지 않도록
1201
+ * plain 필드만 반환한다. caller 는 이 결과를 자기 record 에 Object.assign 한다.
1202
+ *
1203
+ * @returns {Promise<{ok:boolean, short:string, sessionId:string, job:object,
1204
+ * pid:number, bridgeSessionId:string, sessionProjectionPath:string,
1205
+ * controlSock:string, paths:object}>}
1206
+ */
1207
+ export async function dispatchClaudeDaemonJob({
1208
+ paths,
1209
+ controlSock,
1210
+ payload,
1211
+ agent,
1212
+ name,
1213
+ cwd,
1214
+ projection = "write",
1215
+ dispatchTimeoutMs = 5000,
1216
+ pidTimeoutMs,
1217
+ bridgeTimeoutMs,
1218
+ accessControlSock,
1219
+ _deps = {},
1220
+ } = {}) {
1221
+ if (!payload) throw new Error("payload is required");
1222
+ if (!payload.short) throw new Error("payload.short is required");
1223
+ const resolvedControlSock = controlSock || paths?.controlSock;
1224
+ if (!resolvedControlSock) throw new Error("controlSock is required");
1225
+
1226
+ const sendControl =
1227
+ _deps.sendClaudeControlRequest || sendClaudeControlRequest;
1228
+ const waitForPid = _deps.waitForDaemonJobPid || waitForDaemonJobPid;
1229
+ const resolveBridgeSessionId =
1230
+ _deps.resolveDaemonBridgeSessionId || resolveDaemonBridgeSessionId;
1231
+ const buildProjection =
1232
+ _deps.buildClaudeSessionProjection || buildClaudeSessionProjection;
1233
+ const writeProjection =
1234
+ _deps.writeClaudeSessionProjection || writeClaudeSessionProjection;
1235
+ const readProcStart = _deps.getProcStart || getProcStart;
1236
+
1237
+ const short = payload.short;
1238
+ const sessionsDir =
1239
+ paths?.sessionsDir ||
1240
+ (paths?.configDir ? path.join(paths.configDir, "sessions") : "");
1241
+
1242
+ // native-bridge 는 control.sock 존재를 미리 점검한다 (없으면 fast-fail).
1243
+ if (accessControlSock) await accessControlSock(resolvedControlSock);
1244
+
1245
+ const dispatch = await sendControl(
1246
+ resolvedControlSock,
1247
+ {
1248
+ proto: 1,
1249
+ op: "dispatch",
1250
+ d: payload,
1251
+ timeoutMs: dispatchTimeoutMs,
1252
+ },
1253
+ { timeoutMs: dispatchTimeoutMs },
1254
+ );
1255
+ if (dispatch?.ok !== true) {
1256
+ throw new Error(`Claude daemon dispatch failed for ${name || short}`);
1257
+ }
1258
+
1259
+ const pidOpts =
1260
+ pidTimeoutMs === undefined ? undefined : { timeoutMs: pidTimeoutMs };
1261
+ const job = await waitForPid(resolvedControlSock, short, pidOpts);
1262
+ const pid = job.pid;
1263
+ const bridgeSessionId = await resolveBridgeSessionId({
1264
+ daemonPaths: paths,
1265
+ short,
1266
+ job,
1267
+ ...(bridgeTimeoutMs === undefined ? {} : { timeoutMs: bridgeTimeoutMs }),
1268
+ });
1269
+
1270
+ let sessionProjectionPath = "";
1271
+ if (projection !== "skip") {
1272
+ const projectionRecord = buildProjection({
1273
+ pid,
1274
+ procStart: readProcStart(pid),
1275
+ sessionId: payload.sessionId,
1276
+ short,
1277
+ cwd,
1278
+ name,
1279
+ agent,
1280
+ startedAt: job.startedAt || Date.now(),
1281
+ updatedAt: Date.now(),
1282
+ bridgeSessionId,
1283
+ });
1284
+ sessionProjectionPath = await writeProjection(
1285
+ sessionsDir,
1286
+ projectionRecord,
1287
+ );
1288
+ }
1289
+
1290
+ return {
1291
+ ok: true,
1292
+ short,
1293
+ sessionId: payload.sessionId,
1294
+ job,
1295
+ pid,
1296
+ bridgeSessionId,
1297
+ sessionProjectionPath,
1298
+ controlSock: resolvedControlSock,
1299
+ paths,
1300
+ };
1301
+ }
1302
+
1303
+ /**
1304
+ * dispatchClaudeDaemonJob 의 대칭 정리 경로.
1305
+ * removeProjection + killDaemonJob (+ optional removeClaudeJobState) 를
1306
+ * 모두 시도하며, 각 스텝 오류는 기존 close()/cleanupDaemonDispatches 와
1307
+ * 동일하게 .catch 로 삼킨다.
1308
+ */
1309
+ export async function teardownClaudeDaemonJob({
1310
+ controlSock,
1311
+ paths,
1312
+ short,
1313
+ sessionProjectionPath,
1314
+ sessionId,
1315
+ jobsDir,
1316
+ removeJobState = false,
1317
+ _deps = {},
1318
+ } = {}) {
1319
+ const removeProjection =
1320
+ _deps.removeClaudeSessionProjection || removeClaudeSessionProjection;
1321
+ const killJob = _deps.killDaemonJob || killDaemonJob;
1322
+ const removeJobStateImpl = _deps.removeClaudeJobState;
1323
+ const resolvedControlSock = controlSock || paths?.controlSock;
1324
+ const resolvedJobsDir =
1325
+ jobsDir ||
1326
+ paths?.jobsDir ||
1327
+ (paths?.configDir ? path.join(paths.configDir, "jobs") : "");
1328
+
1329
+ const steps = [];
1330
+ if (sessionProjectionPath) {
1331
+ steps.push(removeProjection(sessionProjectionPath).catch(() => {}));
1332
+ }
1333
+ if (sessionId && (paths?.controlSock || resolvedControlSock)) {
1334
+ const daemonPaths = paths || { controlSock: resolvedControlSock };
1335
+ steps.push(sendKillBySessionId({ daemonPaths, sessionId }).catch(() => {}));
1336
+ }
1337
+ if (resolvedControlSock && short) {
1338
+ steps.push(killJob(resolvedControlSock, short).catch(() => {}));
1339
+ }
1340
+ if (removeJobState && removeJobStateImpl && resolvedJobsDir && short) {
1341
+ steps.push(removeJobStateImpl(resolvedJobsDir, short).catch(() => {}));
1342
+ }
1343
+ await Promise.all(steps);
1344
+ }
@@ -1,4 +1,4 @@
1
- import { execFileSync, spawn } from "node:child_process";
1
+ import { spawn } from "node:child_process";
2
2
  import crypto from "node:crypto";
3
3
  import fs from "node:fs/promises";
4
4
  import net from "node:net";
@@ -6,9 +6,13 @@ import os from "node:os";
6
6
  import path from "node:path";
7
7
  import {
8
8
  buildDaemonExecDispatchPayload,
9
+ deriveClaudeDaemonPaths,
10
+ dispatchClaudeDaemonJob,
11
+ getProcStart,
9
12
  killDaemonJob,
10
13
  resolveDaemonBridgeSessionId,
11
14
  sendClaudeControlRequest,
15
+ teardownClaudeDaemonJob,
12
16
  waitForDaemonJobPid,
13
17
  } from "./claude-daemon-control.mjs";
14
18
  import {
@@ -18,6 +22,10 @@ import {
18
22
  } from "./claude-session-projection.mjs";
19
23
  import { createInteractiveTuiTransport } from "./interactive-tui-transport.mjs";
20
24
 
25
+ // daemon-control 이 deriveClaudeDaemonPaths / getProcStart 의 단일 owner 다.
26
+ // 기존 native-bridge import 경로 (headless 포함) 호환을 위해 re-export 한다.
27
+ export { deriveClaudeDaemonPaths, getProcStart };
28
+
21
29
  const DEFAULT_ROWS = 40;
22
30
  const DEFAULT_COLS = 120;
23
31
  const MAX_TRANSCRIPT_BYTES = 64 * 1024;
@@ -30,39 +38,6 @@ export function resolveClaudeConfigDir(env = process.env) {
30
38
  return path.join(os.homedir(), ".claude");
31
39
  }
32
40
 
33
- export function deriveClaudeDaemonPaths({
34
- configDir = resolveClaudeConfigDir(),
35
- uid = typeof process.getuid === "function" ? process.getuid() : 0,
36
- tmpRoot = "/tmp",
37
- } = {}) {
38
- const resolvedConfigDir = path.resolve(configDir);
39
- const hash = crypto
40
- .createHash("sha256")
41
- .update(resolvedConfigDir)
42
- .digest("hex")
43
- .slice(0, 8);
44
- const daemonDir = path.join(tmpRoot, `cc-daemon-${uid}`, hash);
45
- return {
46
- configDir: resolvedConfigDir,
47
- daemonDir,
48
- controlSock: path.join(daemonDir, "control.sock"),
49
- rendezvousDir: path.join(daemonDir, "rv"),
50
- ptyDir: path.join(daemonDir, "pty"),
51
- rosterPath: path.join(resolvedConfigDir, "daemon", "roster.json"),
52
- sessionsDir: path.join(resolvedConfigDir, "sessions"),
53
- jobsDir: path.join(resolvedConfigDir, "jobs"),
54
- };
55
- }
56
-
57
- export function getProcStart(pid = process.pid) {
58
- if (!Number.isInteger(pid) || pid <= 0)
59
- throw new Error(`invalid pid: ${pid}`);
60
- return execFileSync("ps", ["-o", "lstart=", "-p", String(pid)], {
61
- encoding: "utf8",
62
- env: { ...process.env, LC_ALL: "C", TZ: "UTC" },
63
- }).trim();
64
- }
65
-
66
41
  export function buildPtyDataFrame(value) {
67
42
  const payload = Buffer.isBuffer(value) ? value : Buffer.from(value, "utf8");
68
43
  const frame = Buffer.allocUnsafe(5 + payload.length);
@@ -379,6 +354,8 @@ export async function registerSwarmShard({
379
354
  const removeJobStateImpl = _deps.removeClaudeJobState || removeClaudeJobState;
380
355
  const killJob = _deps.killDaemonJob || killDaemonJob;
381
356
  const accessControlSock = _deps.accessControlSock || fs.access;
357
+ const dispatchJob = _deps.dispatchClaudeDaemonJob || dispatchClaudeDaemonJob;
358
+ const teardownJob = _deps.teardownClaudeDaemonJob || teardownClaudeDaemonJob;
382
359
 
383
360
  const paths = derivePaths({ configDir, tmpRoot });
384
361
  const sessionsDir =
@@ -487,44 +464,30 @@ export async function registerSwarmShard({
487
464
  name: displayName,
488
465
  });
489
466
 
490
- await accessControlSock(paths.controlSock);
491
- const dispatch = await sendControl(
492
- paths.controlSock,
493
- {
494
- proto: 1,
495
- op: "dispatch",
496
- d: payload,
497
- timeoutMs: 1000,
498
- },
499
- { timeoutMs: 1000 },
500
- );
501
- if (dispatch?.ok !== true) {
502
- throw new Error(
503
- `Claude daemon dispatch failed for swarm shard ${shardName}`,
504
- );
505
- }
506
-
507
- const job = await waitForPid(paths.controlSock, short, { timeoutMs: 1000 });
508
- const pid = job.pid;
509
- const bridgeSessionId = await resolveBridgeSessionId({
510
- daemonPaths: paths,
511
- short,
512
- job,
513
- timeoutMs: 1000,
514
- });
515
- const projection = buildProjection({
516
- pid,
517
- procStart: readProcStart(pid),
518
- sessionId: payload.sessionId,
519
- short,
520
- cwd,
521
- name: displayName,
467
+ // native-bridge 는 데몬이 없으면 빠르게 실패해야 하므로 1000ms 로 고정한다
468
+ // (headless 의 5000ms 와 다르므로 명시 전달).
469
+ const dispatched = await dispatchJob({
470
+ paths,
471
+ controlSock: paths.controlSock,
472
+ payload,
522
473
  agent: cli,
523
- startedAt: job.startedAt || Date.now(),
524
- updatedAt: Date.now(),
525
- bridgeSessionId,
474
+ name: displayName,
475
+ cwd,
476
+ dispatchTimeoutMs: 1000,
477
+ pidTimeoutMs: 1000,
478
+ bridgeTimeoutMs: 1000,
479
+ accessControlSock,
480
+ _deps: {
481
+ sendClaudeControlRequest: sendControl,
482
+ waitForDaemonJobPid: waitForPid,
483
+ resolveDaemonBridgeSessionId: resolveBridgeSessionId,
484
+ buildClaudeSessionProjection: buildProjection,
485
+ writeClaudeSessionProjection: writeProjection,
486
+ getProcStart: readProcStart,
487
+ },
526
488
  });
527
- const sessionProjectionPath = await writeProjection(sessionsDir, projection);
489
+ const sessionProjectionPath = dispatched.sessionProjectionPath;
490
+ void sessionsDir;
528
491
  let closed = false;
529
492
 
530
493
  return {
@@ -543,9 +506,18 @@ export async function registerSwarmShard({
543
506
  async close() {
544
507
  if (closed) return;
545
508
  closed = true;
546
- await removeProjection(sessionProjectionPath).catch(() => {});
547
- await killJob(paths.controlSock, short).catch(() => {});
548
- await removeJobStateImpl(jobsDir, short).catch(() => {});
509
+ await teardownJob({
510
+ controlSock: paths.controlSock,
511
+ short,
512
+ sessionProjectionPath,
513
+ jobsDir,
514
+ removeJobState: true,
515
+ _deps: {
516
+ removeClaudeSessionProjection: removeProjection,
517
+ killDaemonJob: killJob,
518
+ removeClaudeJobState: removeJobStateImpl,
519
+ },
520
+ });
549
521
  },
550
522
  };
551
523
  }
@@ -0,0 +1,146 @@
1
+ import { pathToFileURL } from "node:url";
2
+
3
+ import { createInteractiveTuiTransport } from "./interactive-tui-transport.mjs";
4
+
5
+ const DEFAULT_COLS = 120;
6
+ const DEFAULT_ROWS = 40;
7
+ const SIGNAL_EXIT_CODES = new Map([
8
+ ["SIGHUP", 129],
9
+ ["SIGINT", 130],
10
+ ["SIGTERM", 143],
11
+ ]);
12
+
13
+ export function encodeBridgeConfig(config) {
14
+ return Buffer.from(JSON.stringify(config), "utf8").toString("base64");
15
+ }
16
+
17
+ export function parseBridgeConfig(argv = []) {
18
+ const index = argv.indexOf("--config");
19
+ if (index < 0 || !argv[index + 1]) {
20
+ throw new Error("--config <base64-json> is required");
21
+ }
22
+ return JSON.parse(Buffer.from(argv[index + 1], "base64").toString("utf8"));
23
+ }
24
+
25
+ export async function runBridge({
26
+ config,
27
+ stdin,
28
+ stdout,
29
+ signals,
30
+ createTransport = createInteractiveTuiTransport,
31
+ } = {}) {
32
+ if (!config?.sessionName) throw new Error("config.sessionName is required");
33
+ if (!stdin) throw new Error("stdin is required");
34
+ if (!stdout) throw new Error("stdout is required");
35
+ if (!signals) throw new Error("signals is required");
36
+
37
+ const transport = createTransport({
38
+ sessionName: config.sessionName,
39
+ cwd: config.cwd,
40
+ launchCmd: config.launchCmd,
41
+ env: config.env || {},
42
+ onData: (chunk) => stdout.write(chunk),
43
+ });
44
+ let shutdownPromise = null;
45
+
46
+ const currentSize = () => ({
47
+ cols: stdout.columns || config.cols || DEFAULT_COLS,
48
+ rows: stdout.rows || config.rows || DEFAULT_ROWS,
49
+ });
50
+
51
+ const shutdown = async ({ exitCode, exitProcess = false } = {}) => {
52
+ if (!shutdownPromise) {
53
+ shutdownPromise = (async () => {
54
+ unregister();
55
+ await transport.stop();
56
+ })();
57
+ }
58
+ await shutdownPromise;
59
+ if (exitProcess && typeof signals.exit === "function") {
60
+ signals.exit(exitCode ?? 0);
61
+ }
62
+ };
63
+
64
+ const writeInput = (chunk) => {
65
+ void transport.writeInput(chunk).catch(() => {
66
+ void shutdown({ exitCode: 1, exitProcess: true });
67
+ });
68
+ };
69
+ const resize = () => {
70
+ void transport.resize(currentSize()).catch(() => {
71
+ void shutdown({ exitCode: 1, exitProcess: true });
72
+ });
73
+ };
74
+ const stdinEnd = () => {
75
+ void shutdown({ exitCode: 0, exitProcess: true });
76
+ };
77
+ const signalHandlers = new Map(
78
+ [...SIGNAL_EXIT_CODES].map(([signal, exitCode]) => [
79
+ signal,
80
+ () => {
81
+ void shutdown({ exitCode, exitProcess: true });
82
+ },
83
+ ]),
84
+ );
85
+ const uncaughtException = () => {
86
+ void shutdown({ exitCode: 1, exitProcess: true });
87
+ };
88
+ const beforeExit = () => {
89
+ void shutdown();
90
+ };
91
+ const exit = () => {
92
+ void shutdown();
93
+ };
94
+
95
+ function unregister() {
96
+ stdin.off?.("data", writeInput);
97
+ stdin.off?.("end", stdinEnd);
98
+ stdout.off?.("resize", resize);
99
+ signals.off?.("uncaughtException", uncaughtException);
100
+ signals.off?.("beforeExit", beforeExit);
101
+ signals.off?.("exit", exit);
102
+ for (const [signal, handler] of signalHandlers) {
103
+ signals.off?.(signal, handler);
104
+ }
105
+ }
106
+
107
+ stdin.on("data", writeInput);
108
+ stdin.on("end", stdinEnd);
109
+ stdout.on?.("resize", resize);
110
+ signals.on?.("uncaughtException", uncaughtException);
111
+ signals.on?.("beforeExit", beforeExit);
112
+ signals.on?.("exit", exit);
113
+ for (const [signal, handler] of signalHandlers) {
114
+ signals.on?.(signal, handler);
115
+ }
116
+
117
+ try {
118
+ await transport.start();
119
+ await transport.resize(currentSize());
120
+ } catch (error) {
121
+ await shutdown().catch(() => {});
122
+ throw error;
123
+ }
124
+
125
+ return { shutdown };
126
+ }
127
+
128
+ async function main() {
129
+ const config = parseBridgeConfig(process.argv.slice(2));
130
+ await runBridge({
131
+ config,
132
+ stdin: process.stdin,
133
+ stdout: process.stdout,
134
+ signals: process,
135
+ });
136
+ }
137
+
138
+ if (
139
+ process.argv[1] &&
140
+ import.meta.url === pathToFileURL(process.argv[1]).href
141
+ ) {
142
+ main().catch((error) => {
143
+ console.error(error?.stack || error?.message || String(error));
144
+ process.exit(1);
145
+ });
146
+ }
@@ -28,21 +28,12 @@ import { getBackend } from "./backend.mjs";
28
28
  import {
29
29
  buildDaemonExecDispatchPayload,
30
30
  deriveClaudeDaemonPaths as deriveClaudeControlPaths,
31
+ dispatchClaudeDaemonJob,
31
32
  killDaemonJob,
32
- resolveDaemonBridgeSessionId,
33
- sendClaudeControlRequest,
34
33
  sendKillBySessionId,
35
- waitForDaemonJobPid,
36
34
  } from "./claude-daemon-control.mjs";
37
- import {
38
- getProcStart,
39
- startClaudeNativeBridge,
40
- } from "./claude-native-bridge.mjs";
41
- import {
42
- buildClaudeSessionProjection,
43
- removeClaudeSessionProjection,
44
- writeClaudeSessionProjection,
45
- } from "./claude-session-projection.mjs";
35
+ import { startClaudeNativeBridge } from "./claude-native-bridge.mjs";
36
+ import { removeClaudeSessionProjection } from "./claude-session-projection.mjs";
46
37
  import { resolveDashboardLayout } from "./dashboard-layout.mjs";
47
38
  import {
48
39
  formatHandoffForLead,
@@ -1016,40 +1007,30 @@ async function dispatchDaemonBatch(sessionName, assignments, opts = {}) {
1016
1007
  name,
1017
1008
  });
1018
1009
  record.sessionId = payload.sessionId;
1019
- const dispatch = await sendClaudeControlRequest(paths.controlSock, {
1020
- proto: 1,
1021
- op: "dispatch",
1022
- d: payload,
1023
- timeoutMs: 5000,
1024
- });
1025
- if (dispatch?.ok !== true) {
1010
+ // buildDaemonWrappedCommand + token completion 시맨틱은 헬퍼 밖(headless 전용)에
1011
+ // 남기고, dispatch→pid→bridge→projection 시퀀스만 공유 헬퍼로 위임한다.
1012
+ // CRITICAL: waitForDaemonCompletion 이 나중에 record.daemonCompletionMatched 를
1013
+ // 변경하고 cleanupDaemonDispatches 가 같은 record 를 읽으므로, 헬퍼가 새 객체를
1014
+ // 반환해도 record 를 교체하지 말고 plain 필드를 Object.assign 한다.
1015
+ const dispatched = await dispatchClaudeDaemonJob({
1016
+ paths,
1017
+ controlSock: paths.controlSock,
1018
+ payload,
1019
+ agent: resolvedCli || "codex",
1020
+ name,
1021
+ cwd: assignment.cwd || assignment.workdir || process.cwd(),
1022
+ dispatchTimeoutMs: 5000,
1023
+ }).catch((error) => {
1026
1024
  throw new Error(
1027
- `[headless] Claude daemon dispatch failed for ${paneName}`,
1025
+ `[headless] Claude daemon dispatch failed for ${paneName}: ${
1026
+ error?.message || error
1027
+ }`,
1028
1028
  );
1029
- }
1030
-
1031
- const job = await waitForDaemonJobPid(paths.controlSock, short);
1032
- const bridgeSessionId = await resolveDaemonBridgeSessionId({
1033
- daemonPaths: paths,
1034
- short,
1035
- job,
1036
1029
  });
1037
- const projection = buildClaudeSessionProjection({
1038
- pid: job.pid,
1039
- procStart: getProcStart(job.pid),
1040
- sessionId: payload.sessionId,
1041
- short,
1042
- cwd: assignment.cwd || assignment.workdir || process.cwd(),
1043
- name,
1044
- agent: resolvedCli || "codex",
1045
- startedAt: job.startedAt || Date.now(),
1046
- updatedAt: Date.now(),
1047
- bridgeSessionId,
1030
+ Object.assign(record, {
1031
+ sessionId: dispatched.sessionId,
1032
+ sessionProjectionPath: dispatched.sessionProjectionPath,
1048
1033
  });
1049
- record.sessionProjectionPath = await writeClaudeSessionProjection(
1050
- paths.sessionsDir,
1051
- projection,
1052
- );
1053
1034
  await registerHeadlessWorker(sessionName, i, resolvedCli);
1054
1035
  registerHeadlessSynapseWorker(workerId, assignment.prompt);
1055
1036
  if (safeProgress) {
@@ -1,27 +1,17 @@
1
1
  import crypto from "node:crypto";
2
- import os from "node:os";
3
2
  import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
4
 
5
5
  import {
6
- buildNativeWorkerRosterEntry,
6
+ buildDaemonExecDispatchPayload,
7
7
  deriveClaudeDaemonPaths,
8
- mergeRosterWorkers,
9
- removeRosterWorkers,
10
- } from "./claude-native-bridge.mjs";
11
- import { startInteractiveNativeWorker } from "./interactive-native-worker.mjs";
8
+ dispatchClaudeDaemonJob,
9
+ teardownClaudeDaemonJob,
10
+ } from "./claude-daemon-control.mjs";
11
+ import { encodeBridgeConfig } from "./daemon-pty-tmux-bridge.mjs";
12
12
 
13
- function buildWorkerSocketPaths({ configDir, sessionName, pid, short }) {
14
- const hash = crypto
15
- .createHash("sha256")
16
- .update(`${configDir || ""}:${sessionName}:${pid}:${crypto.randomUUID()}`)
17
- .digest("hex")
18
- .slice(0, 10);
19
- const socketRoot = path.join(os.tmpdir(), `tfx-in-${hash}`);
20
- return {
21
- rvSock: path.join(socketRoot, `${short}.rv.sock`),
22
- ptySock: path.join(socketRoot, `${short}.pty.sock`),
23
- };
24
- }
13
+ const DEFAULT_COLS = 120;
14
+ const DEFAULT_ROWS = 40;
25
15
 
26
16
  export async function launchAdoptedInteractiveWorker({
27
17
  cwd = process.cwd(),
@@ -31,76 +21,99 @@ export async function launchAdoptedInteractiveWorker({
31
21
  role = "interactive",
32
22
  configDir,
33
23
  pid = process.pid,
24
+ env = {},
25
+ cols = DEFAULT_COLS,
26
+ rows = DEFAULT_ROWS,
34
27
  _deps = {},
35
28
  } = {}) {
36
- const startWorker =
37
- _deps.startInteractiveNativeWorker || startInteractiveNativeWorker;
38
- const buildRosterEntry =
39
- _deps.buildNativeWorkerRosterEntry || buildNativeWorkerRosterEntry;
40
- const mergeWorkers = _deps.mergeRosterWorkers || mergeRosterWorkers;
41
29
  const derivePaths = _deps.deriveClaudeDaemonPaths || deriveClaudeDaemonPaths;
30
+ const encodeConfig = _deps.encodeBridgeConfig || encodeBridgeConfig;
31
+ const buildPayload =
32
+ _deps.buildDaemonExecDispatchPayload || buildDaemonExecDispatchPayload;
33
+ const dispatchJob = _deps.dispatchClaudeDaemonJob || dispatchClaudeDaemonJob;
34
+ const teardownJob = _deps.teardownClaudeDaemonJob || teardownClaudeDaemonJob;
42
35
 
43
36
  const resolvedCwd = path.resolve(cwd);
44
- const effectiveSessionName =
45
- sessionName || `tfx-${cli}-${role}-${process.pid}`;
37
+ const effectiveSessionName = sessionName || `tfx-${cli}-${role}-${pid}`;
46
38
  const paths = derivePaths({ configDir });
47
- const short = crypto.randomUUID().replaceAll("-", "").slice(0, 8);
48
- const { rvSock, ptySock } = buildWorkerSocketPaths({
49
- configDir,
39
+ const short = crypto.randomBytes(4).toString("hex");
40
+ const displayName = `Triflux ${cli} ${role}`;
41
+ const bridgePath = fileURLToPath(
42
+ new URL("./daemon-pty-tmux-bridge.mjs", import.meta.url),
43
+ );
44
+ const bridgeConfig = encodeConfig({
50
45
  sessionName: effectiveSessionName,
51
- pid,
46
+ launchCmd,
47
+ cwd: resolvedCwd,
48
+ env,
49
+ cols,
50
+ rows,
51
+ });
52
+ const command = `${shellQuote(process.execPath)} ${shellQuote(
53
+ bridgePath,
54
+ )} --config ${bridgeConfig}`;
55
+ const payload = buildPayload({
52
56
  short,
57
+ cwd: resolvedCwd,
58
+ command,
59
+ name: displayName,
60
+ cols,
61
+ rows,
53
62
  });
54
- const displayName = `Triflux ${cli} ${role}`;
55
-
56
- let worker;
57
- let closed = false;
58
- let closePromise = null;
59
-
63
+ let dispatched;
60
64
  try {
61
- worker = await startWorker({
62
- short,
63
- rvSock,
64
- ptySock,
65
- sessionName: effectiveSessionName,
66
- cwd: resolvedCwd,
67
- launchCmd,
68
- pid,
69
- });
70
- const workerShort = worker.short || short;
71
- const workerPid = worker.pid || pid;
72
- const entry = buildRosterEntry({
73
- short: workerShort,
74
- pid: workerPid,
75
- cwd: resolvedCwd,
76
- rendezvousSock: worker.rvSock,
77
- ptySock: worker.ptySock,
65
+ dispatched = await dispatchJob({
66
+ paths,
67
+ controlSock: paths.controlSock,
68
+ payload,
69
+ agent: cli,
78
70
  name: displayName,
79
- prompt: displayName,
71
+ cwd: resolvedCwd,
72
+ dispatchTimeoutMs: 5000,
80
73
  });
81
- await mergeWorkers(paths.rosterPath, { [workerShort]: entry });
82
-
83
- return {
84
- short: workerShort,
85
- displayName,
86
- rosterPath: paths.rosterPath,
87
- close() {
88
- if (!closePromise) {
89
- closePromise = (async () => {
90
- if (closed) return;
91
- closed = true;
92
- try {
93
- await worker.close();
94
- } finally {
95
- await removeRosterWorkers(paths.rosterPath, [workerShort]);
96
- }
97
- })();
98
- }
99
- return closePromise;
100
- },
101
- };
102
74
  } catch (error) {
103
- if (worker) await worker.close().catch(() => {});
75
+ // dispatch may have started a daemon job (pid assigned) before a later
76
+ // step (bridge-session resolve / projection write) threw. Tear down by
77
+ // short + sessionId so a failed launch never leaks a running pty/tmux job.
78
+ await teardownJob({
79
+ controlSock: paths.controlSock,
80
+ paths,
81
+ short,
82
+ sessionId: payload.sessionId,
83
+ }).catch(() => {});
104
84
  throw error;
105
85
  }
86
+
87
+ let closed = false;
88
+ let closePromise = null;
89
+
90
+ return {
91
+ short,
92
+ displayName,
93
+ sessionId: dispatched.sessionId,
94
+ pid: dispatched.pid,
95
+ bridgeSessionId: dispatched.bridgeSessionId,
96
+ sessionProjectionPath: dispatched.sessionProjectionPath,
97
+ controlSock: paths.controlSock,
98
+ close() {
99
+ if (!closePromise) {
100
+ closePromise = (async () => {
101
+ if (closed) return;
102
+ closed = true;
103
+ await teardownJob({
104
+ controlSock: paths.controlSock,
105
+ paths,
106
+ short,
107
+ sessionProjectionPath: dispatched.sessionProjectionPath,
108
+ sessionId: dispatched.sessionId,
109
+ });
110
+ })();
111
+ }
112
+ return closePromise;
113
+ },
114
+ };
115
+ }
116
+
117
+ function shellQuote(value) {
118
+ return `'${String(value).replaceAll("'", "'\\''")}'`;
106
119
  }
@@ -42,10 +42,10 @@ const ESCALATION_CHAIN_CONFIG_PATH = ".triflux/config/escalation-chain.json";
42
42
 
43
43
  // Escalation chain (2026-05-27 정책):
44
44
  // 1. codex gpt-5.5 — 코드/추론/비용에서 mini/5.3-codex 중간 단계를 대체
45
- // 2. claude opus-4-7 — 최종 수단
45
+ // 2. claude opus-4-8 — 최종 수단
46
46
  const DEFAULT_ESCALATION_CHAIN = Object.freeze([
47
47
  Object.freeze({ cli: "codex", model: "gpt-5.5" }),
48
- Object.freeze({ cli: "claude", model: "opus-4-7" }),
48
+ Object.freeze({ cli: "claude", model: "opus-4-8" }),
49
49
  ]);
50
50
 
51
51
  const STUCK_THRESHOLD = 3;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@triflux/remote",
3
- "version": "10.27.0",
3
+ "version": "10.28.0",
4
4
  "description": "triflux remote — team mode, psmux, MCP workers, SQLite store.",
5
5
  "type": "module",
6
6
  "main": "hub/index.mjs",
@@ -14,6 +14,7 @@
14
14
  // 침묵한다 (false-positive 방지).
15
15
 
16
16
  import { readFileSync, statSync } from "node:fs";
17
+ import { createConnection } from "node:net";
17
18
  import { homedir } from "node:os";
18
19
  import { join } from "node:path";
19
20
 
@@ -33,6 +34,27 @@ const START_RE = /^\[START\]\s+(\S+)\s+on\s+:(\d+)$/;
33
34
  const SKIP_RUNNING_RE = /^\[SKIP\]\s+(\S+)\s+already running on\s+:(\d+)$/;
34
35
  const SKIP_DISABLED_RE = /^\[SKIP\]\s+(\S+)\s+—\s+manifest에서 비활성$/;
35
36
 
37
+ function probePortListening(
38
+ port,
39
+ { host = "127.0.0.1", timeoutMs = 800 } = {},
40
+ ) {
41
+ return new Promise((resolve) => {
42
+ const socket = createConnection({ host, port });
43
+ let settled = false;
44
+
45
+ function finish(value) {
46
+ if (settled) return;
47
+ settled = true;
48
+ socket.destroy();
49
+ resolve(value);
50
+ }
51
+
52
+ socket.once("connect", () => finish(true));
53
+ socket.once("error", () => finish(false));
54
+ socket.setTimeout(timeoutMs, () => finish(false));
55
+ });
56
+ }
57
+
36
58
  export function checkMcpGatewayHealth({
37
59
  fs = { readFileSync, statSync },
38
60
  logPath = DEFAULT_LOG_PATH,
@@ -130,6 +152,40 @@ export function checkMcpGatewayHealth({
130
152
  };
131
153
  }
132
154
 
155
+ export async function checkMcpGatewayHealthLive({
156
+ probePort = probePortListening,
157
+ ...options
158
+ } = {}) {
159
+ const result = checkMcpGatewayHealth(options);
160
+ if (!result.available || result.started.length === 0) {
161
+ return {
162
+ ...result,
163
+ live: [],
164
+ };
165
+ }
166
+
167
+ const live = [];
168
+ const findings = [...result.findings];
169
+ for (const entry of result.started) {
170
+ const listening = await probePort(entry.port);
171
+ live.push({ ...entry, listening });
172
+ if (!listening) {
173
+ findings.push({
174
+ server: entry.server,
175
+ reason: "port-down",
176
+ detail: `expected listener on :${entry.port}`,
177
+ port: entry.port,
178
+ });
179
+ }
180
+ }
181
+
182
+ return {
183
+ ...result,
184
+ findings,
185
+ live,
186
+ };
187
+ }
188
+
133
189
  export function summarizeMcpGatewayHealth(result) {
134
190
  if (!result?.available) {
135
191
  return {
@@ -152,6 +208,15 @@ export function summarizeMcpGatewayHealth(result) {
152
208
  fix: "secrets 를 ~/.config/triflux/secrets.env 에 추가하고 `launchctl kickstart -k gui/$(id -u)/com.tellang.mcp-gateway` (macOS) 또는 `systemctl --user restart mcp-gateway` (linux)",
153
209
  };
154
210
  }
211
+ const portDown = result.findings.filter((f) => f.reason === "port-down");
212
+ if (portDown.length > 0) {
213
+ const list = portDown.map((f) => `${f.server} (:${f.port})`).join(", ");
214
+ return {
215
+ level: "warn",
216
+ message: `${portDown.length}개 MCP gateway port down: ${list}`,
217
+ fix: "gateway 를 재기동하세요: `node scripts/mcp-gateway-start.mjs` 또는 macOS `launchctl kickstart -k gui/$(id -u)/com.tellang.mcp-gateway`",
218
+ };
219
+ }
155
220
  return {
156
221
  level: "warn",
157
222
  message: `${result.findings.length}개 gateway 이슈`,
@@ -22,7 +22,12 @@ const PROJECT_ROOT = fileURLToPath(new URL("../../", import.meta.url));
22
22
  const DEFAULT_REGISTRY_PATH = join(PROJECT_ROOT, "config", "mcp-registry.json");
23
23
  const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1"]);
24
24
  const DEFAULT_HUB_PATH = "/mcp";
25
- const SERVER_POLICIES = new Set(["hosted", "gateway-sse", "stdio"]);
25
+ const SERVER_POLICIES = new Set([
26
+ "hosted",
27
+ "gateway-sse",
28
+ "gateway-http",
29
+ "stdio",
30
+ ]);
26
31
  const GATEWAY_PORTS = Object.freeze({
27
32
  context7: 8100,
28
33
  "brave-search": 8101,
@@ -792,6 +797,23 @@ function gatewaySseUrl(name, serverConfig = {}) {
792
797
  return `http://127.0.0.1:${port}/sse`;
793
798
  }
794
799
 
800
+ function gatewayHttpUrl(name, serverConfig = {}) {
801
+ if (typeof serverConfig.url === "string" && serverConfig.url.trim()) {
802
+ return normalizeUrl(serverConfig.url);
803
+ }
804
+ const port =
805
+ Number(serverConfig.gateway_port || serverConfig.port || 0) ||
806
+ GATEWAY_PORTS[name];
807
+ if (!Number.isFinite(port) || port <= 0) return "";
808
+ const path =
809
+ typeof serverConfig.gateway_path === "string" &&
810
+ serverConfig.gateway_path.trim()
811
+ ? serverConfig.gateway_path.trim()
812
+ : "/mcp";
813
+ const normalizedPath = path.startsWith("/") ? path : `/${path}`;
814
+ return `http://127.0.0.1:${port}${normalizedPath}`;
815
+ }
816
+
795
817
  function syncDenylistEntries(policy = {}) {
796
818
  if (!Array.isArray(policy?.sync_denylist)) return new Set();
797
819
  return new Set(
@@ -836,9 +858,11 @@ export function buildDesiredServerRecord(name, serverConfig, filePath) {
836
858
  const url =
837
859
  policy === "gateway-sse"
838
860
  ? gatewaySseUrl(name, serverConfig)
839
- : serverConfig?.transport === "hub-url"
840
- ? resolveHubUrl()
841
- : normalizeUrl(serverConfig?.url || "");
861
+ : policy === "gateway-http"
862
+ ? gatewayHttpUrl(name, serverConfig)
863
+ : serverConfig?.transport === "hub-url"
864
+ ? resolveHubUrl()
865
+ : normalizeUrl(serverConfig?.url || "");
842
866
  const basenameValue = pathBasename(filePath);
843
867
  const resolvedHeaders = resolveHeaderDescriptors(
844
868
  name,
@@ -849,6 +873,26 @@ export function buildDesiredServerRecord(name, serverConfig, filePath) {
849
873
  ? { headers: resolvedHeaders.headers }
850
874
  : {};
851
875
 
876
+ if (policy === "gateway-http") {
877
+ if (isCodexConfig(filePath)) {
878
+ return {
879
+ name,
880
+ config: { url },
881
+ headerDescriptors: {},
882
+ codex: {},
883
+ warnings: [],
884
+ };
885
+ }
886
+
887
+ return {
888
+ name,
889
+ config: { type: "http", url },
890
+ headerDescriptors: {},
891
+ codex: {},
892
+ warnings: [],
893
+ };
894
+ }
895
+
852
896
  if (policy === "gateway-sse") {
853
897
  if (isCodexConfig(filePath)) {
854
898
  return {
@@ -1237,7 +1281,7 @@ export function validateRegistry(registry) {
1237
1281
  !SERVER_POLICIES.has(server.policy)
1238
1282
  ) {
1239
1283
  errors.push(
1240
- `registry.servers.${name}.policy must be hosted, gateway-sse, or stdio`,
1284
+ `registry.servers.${name}.policy must be hosted, gateway-sse, gateway-http, or stdio`,
1241
1285
  );
1242
1286
  }
1243
1287
  const transport = server.transport || registry.defaults?.transport;
@@ -1252,7 +1296,7 @@ export function validateRegistry(registry) {
1252
1296
  ) {
1253
1297
  errors.push(`registry.servers.${name}.url must be a non-empty string`);
1254
1298
  }
1255
- if (policy === "gateway-sse") {
1299
+ if (policy === "gateway-sse" || policy === "gateway-http") {
1256
1300
  const port = Number(server.gateway_port || server.port || 0);
1257
1301
  const hasGatewayUrl =
1258
1302
  typeof server.url === "string" && server.url.trim().length > 0;
@@ -1261,7 +1305,7 @@ export function validateRegistry(registry) {
1261
1305
  (!Number.isInteger(port) || port <= 0 || port > 65535)
1262
1306
  ) {
1263
1307
  errors.push(
1264
- `registry.servers.${name}.gateway_port must be a valid TCP port for gateway-sse`,
1308
+ `registry.servers.${name}.gateway_port must be a valid TCP port for ${policy}`,
1265
1309
  );
1266
1310
  }
1267
1311
  }
@@ -1913,7 +1957,7 @@ export function addRegistryServer(name, url, options = {}) {
1913
1957
  (transport === "stdio"
1914
1958
  ? "stdio"
1915
1959
  : options.gateway_port || options.port
1916
- ? "gateway-sse"
1960
+ ? "gateway-http"
1917
1961
  : "hosted");
1918
1962
 
1919
1963
  registry.servers[trimmedName] = {