@triflux/remote 10.88.2 → 10.89.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.
@@ -112,7 +112,7 @@ export function leadDisplayName(role, now = new Date()) {
112
112
  /** Claude 는 mod 가 /tfx-lead 를 처리한다. Codex 와 agy 는 mod 가 없어 스킬 파일을 읽게 한다. */
113
113
  export function leadEntryPrompt(cli, role) {
114
114
  if (cli === "claude") return `/tfx-lead ${role}`;
115
- return `${LEAD_SKILL} 를 읽고 이 세션이 ${role} 리드를 맡는다. 그 스킬의 절차를 따른다.`;
115
+ return `${LEAD_SKILL} 를 읽고 이 세션이 ${role} 리드를 맡는다. 처음 활성화할 때 "보유 자산과 선택" 절로 tfx-harness, tfx-auto, tfx-live 등 설치 자산을 인지하고 요청에 맞는 경로를 선택한다. 그 스킬의 절차를 따른다.`;
116
116
  }
117
117
 
118
118
  async function runLive(args, run) {
@@ -14,15 +14,47 @@ import { JsonRpcWsUdsClient } from "../workers/lib/jsonrpc-ws-uds.mjs";
14
14
  const WAKE =
15
15
  "A peer agent_message was delivered immediately before this tool output. Read it and respond within this session.";
16
16
 
17
+ export function parsePeerSpec(value) {
18
+ if (
19
+ typeof value !== "string" ||
20
+ /[\x00-\x1f\x7f-\x9f\u2028\u2029]/u.test(value) ||
21
+ !/^(claude|codex|agy|antigravity):[^:]+$/i.test(value)
22
+ )
23
+ throw new Error("Use exact claude:NAME, codex:NAME, or agy:NAME target");
24
+ const [rawCli, name] = value.split(":");
25
+ if (
26
+ !name.trim() ||
27
+ name !== name.trim() ||
28
+ name.startsWith("-") ||
29
+ name.length > 512
30
+ )
31
+ throw new Error("Invalid peer name");
32
+ const cli =
33
+ rawCli.toLowerCase() === "antigravity" ? "agy" : rawCli.toLowerCase();
34
+ return `${cli}:${name}`;
35
+ }
36
+
17
37
  export function parsePeerArgs(args) {
18
- if (args[0] !== "send" && args[0] !== "self")
19
- throw new Error("Only send and self are supported");
38
+ if (!["send", "self", "remote-send"].includes(args[0]))
39
+ throw new Error("Only send, remote-send and self are supported");
40
+ const remote = args[0] === "remote-send";
20
41
  const target = args[0] === "self" ? "--session" : "--to";
42
+ const allowed =
43
+ target === "--session"
44
+ ? [target, "--message"]
45
+ : remote
46
+ ? [target, "--message", "--from", "--host", "--reply-host"]
47
+ : [target, "--message", "--from"];
21
48
  const parsed = {};
22
49
  for (let i = 1; i < args.length; i += 2) {
23
50
  const flag = args[i],
24
51
  value = args[i + 1];
25
- if (![target, "--message"].includes(flag) || !value || parsed[flag])
52
+ if (
53
+ !allowed.includes(flag) ||
54
+ typeof value !== "string" ||
55
+ !value ||
56
+ Object.hasOwn(parsed, flag)
57
+ )
26
58
  throw new Error(`Invalid peer argument: ${flag}`);
27
59
  parsed[flag] = value;
28
60
  }
@@ -36,14 +68,19 @@ export function parsePeerArgs(args) {
36
68
  );
37
69
  if (target === "--session")
38
70
  return { sessionId: parsed["--session"], message: parsed["--message"] };
39
- if (!/^(claude|codex|agy|antigravity):[^:]+$/i.test(parsed["--to"]))
40
- throw new Error("Use exact claude:NAME, codex:NAME, or agy:NAME target");
41
- const sep = parsed["--to"].indexOf(":");
42
- const rawCli = parsed["--to"].slice(0, sep).toLowerCase();
43
- const cli = rawCli === "antigravity" ? "agy" : rawCli;
71
+ if (remote && (!parsed["--host"] || !parsed["--reply-host"]))
72
+ throw new Error("remote-send requires --host and --reply-host");
73
+ for (const flag of ["--host", "--reply-host"]) {
74
+ if (parsed[flag] && !/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/.test(parsed[flag]))
75
+ throw new Error(`Invalid host alias: ${flag}`);
76
+ }
44
77
  return {
45
- to: `${cli}:${parsed["--to"].slice(sep + 1)}`,
78
+ to: parsePeerSpec(parsed["--to"]),
46
79
  message: parsed["--message"],
80
+ ...(parsed["--from"] ? { from: parsePeerSpec(parsed["--from"]) } : {}),
81
+ ...(remote
82
+ ? { host: parsed["--host"], replyHost: parsed["--reply-host"] }
83
+ : {}),
47
84
  };
48
85
  }
49
86
 
@@ -58,7 +95,12 @@ export function validateCodexTarget(thread, id) {
58
95
  throw new Error("Codex thread is not an eligible loaded root user thread");
59
96
  }
60
97
 
61
- export async function sendCodexPeer({ client, threadId, message }) {
98
+ export async function sendCodexPeer({
99
+ client,
100
+ threadId,
101
+ message,
102
+ onWriteStart,
103
+ }) {
62
104
  let cursor;
63
105
  let loaded = false;
64
106
  do {
@@ -81,6 +123,7 @@ export async function sendCodexPeer({ client, threadId, message }) {
81
123
  });
82
124
  validateCodexTarget(read.thread, threadId);
83
125
  try {
126
+ onWriteStart?.();
84
127
  await client.request("thread/inject_items", {
85
128
  threadId,
86
129
  items: [
@@ -162,6 +205,7 @@ export function resolveCodexSocket(home = os.homedir(), env = process.env) {
162
205
  export async function deliverCodex({
163
206
  threadId,
164
207
  message,
208
+ onWriteStart,
165
209
  home = os.homedir(),
166
210
  env = process.env,
167
211
  platform = process.platform,
@@ -177,7 +221,7 @@ export async function deliverCodex({
177
221
  capabilities: { experimentalApi: true },
178
222
  });
179
223
  client.notify("initialized", {});
180
- return await sendCodexPeer({ client, threadId, message });
224
+ return await sendCodexPeer({ client, threadId, message, onWriteStart });
181
225
  } finally {
182
226
  client.close();
183
227
  }
@@ -400,6 +444,7 @@ export function resolveClaudeRegistryTarget(
400
444
  export async function deliverClaude({
401
445
  sessionId,
402
446
  message,
447
+ onWriteStart,
403
448
  home = os.homedir(),
404
449
  platform = process.platform,
405
450
  receiver = resolveClaudeReceiver({ sessionId, home, platform }),
@@ -444,6 +489,7 @@ export async function deliverClaude({
444
489
  );
445
490
  conn.once("error", reject);
446
491
  conn.once("connect", () => {
492
+ onWriteStart?.();
447
493
  conn.write(
448
494
  `${JSON.stringify({ type: "auth", token: receiver.peerToken })}\n${JSON.stringify(frame)}\n`,
449
495
  );
@@ -480,6 +526,7 @@ export function deliverAgy({
480
526
  appDataDir: targetAppDataDir = null,
481
527
  requireExisting = false,
482
528
  message,
529
+ onWriteStart,
483
530
  sender = "unknown",
484
531
  env = process.env,
485
532
  home = null,
@@ -534,6 +581,7 @@ export function deliverAgy({
534
581
  const jsonPath = path.join(messagesDir, `${msgId}.json`);
535
582
  const markerPath = path.join(undeliveredDir, msgId);
536
583
  try {
584
+ onWriteStart?.();
537
585
  io.writeFileSync(jsonPath, JSON.stringify(payload, null, 2), {
538
586
  mode: 0o600,
539
587
  });
@@ -0,0 +1,255 @@
1
+ import { spawn } from "node:child_process";
2
+ import { readFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import path from "node:path";
5
+ import { normalizeHost, userStateHostsPath } from "@triflux/core/hub/lib/hosts-compat.mjs";
6
+ import { parsePeerSpec } from "./native-peer.mjs";
7
+
8
+ export const PEER_WIRE_LIMIT = 65536;
9
+ export const PEER_RECEIVER_COMMAND =
10
+ 'exec "$HOME/.claude/scripts/tfx-peer" _receive';
11
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
12
+ const ALIAS = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/;
13
+
14
+ // 사용자 설정만 읽는다. legacy migration과 source-tree 폴백은 호출하지 않는다.
15
+ export function resolveRemoteHost(
16
+ alias,
17
+ { hostsPath = userStateHostsPath() } = {},
18
+ ) {
19
+ if (typeof alias !== "string" || !ALIAS.test(alias))
20
+ throw new Error("Invalid registered host alias");
21
+ if (!hostsPath) throw new Error("User-state hosts.json is required");
22
+ const registry = JSON.parse(readFileSync(hostsPath, "utf8"));
23
+ const matches = Object.entries(registry.hosts || {}).filter(
24
+ ([name, host]) =>
25
+ name === alias ||
26
+ (Array.isArray(host?.aliases) && host.aliases.includes(alias)),
27
+ );
28
+ if (matches.length !== 1)
29
+ throw new Error("Host alias is unregistered or ambiguous");
30
+ const [name, raw] = matches[0];
31
+ if (
32
+ !["darwin", "macos", "linux"].includes(
33
+ typeof raw.os === "string" ? raw.os.toLowerCase() : "",
34
+ )
35
+ )
36
+ throw new Error("Remote peer supports only Darwin and POSIX Linux hosts");
37
+ const host = normalizeHost(raw, name);
38
+ const address =
39
+ host.ssh?.host || host.tailscale?.dns || host.tailscale?.ip || host.name;
40
+ const user = host.ssh_user;
41
+ if (
42
+ typeof address !== "string" ||
43
+ !/^[a-zA-Z0-9][a-zA-Z0-9._:-]*$/.test(address) ||
44
+ (user != null &&
45
+ (typeof user !== "string" || !/^[a-zA-Z0-9_][a-zA-Z0-9_.-]*$/.test(user)))
46
+ )
47
+ throw new Error("Invalid registered SSH address/user");
48
+ if (
49
+ host.ssh?.port !== undefined &&
50
+ (!Number.isInteger(host.ssh.port) ||
51
+ host.ssh.port < 1 ||
52
+ host.ssh.port > 65535)
53
+ )
54
+ throw new Error("Invalid registered SSH port");
55
+ return host;
56
+ }
57
+
58
+ export function validatePeerEnvelope(value) {
59
+ if (
60
+ !value ||
61
+ typeof value !== "object" ||
62
+ Array.isArray(value) ||
63
+ value.version !== 1
64
+ )
65
+ throw new Error("Unsupported peer protocol version");
66
+ if (
67
+ typeof value.correlationId !== "string" ||
68
+ !UUID.test(value.correlationId)
69
+ )
70
+ throw new Error("Invalid correlation ID");
71
+ for (const key of ["originHost", "replyHost"])
72
+ if (typeof value[key] !== "string" || !ALIAS.test(value[key]))
73
+ throw new Error(`Invalid ${key}`);
74
+ if (
75
+ typeof value.message !== "string" ||
76
+ !value.message ||
77
+ value.message.length > 8000
78
+ )
79
+ throw new Error("Peer message requires 1..8000 chars");
80
+ if (parsePeerSpec(value.to) !== value.to)
81
+ throw new Error("Noncanonical peer target");
82
+ const sender = value.sender;
83
+ if (!sender || !["claude", "codex", "agy"].includes(sender.cli))
84
+ throw new Error("Unknown sender; use --from CLI:NAME");
85
+ if (typeof sender.name !== "string") throw new Error("Invalid sender name");
86
+ parsePeerSpec(`${sender.cli}:${sender.name}`);
87
+ if (sender.name === "unknown")
88
+ throw new Error("Unknown sender; use --from CLI:NAME");
89
+ if (
90
+ sender.id !== undefined &&
91
+ (typeof sender.id !== "string" || !UUID.test(sender.id))
92
+ )
93
+ throw new Error("Invalid sender conversation ID");
94
+ const keys = [
95
+ "version",
96
+ "correlationId",
97
+ "to",
98
+ "message",
99
+ "originHost",
100
+ "replyHost",
101
+ "sender",
102
+ ];
103
+ if (
104
+ Object.keys(value).some((k) => !keys.includes(k)) ||
105
+ Object.keys(sender).some((k) => !["cli", "name", "id"].includes(k))
106
+ )
107
+ throw new Error("Unexpected peer metadata");
108
+ if (Buffer.byteLength(JSON.stringify(value)) > PEER_WIRE_LIMIT)
109
+ throw new Error("Peer envelope too large");
110
+ return value;
111
+ }
112
+
113
+ export async function readPeerInput(input) {
114
+ const chunks = [];
115
+ let size = 0;
116
+ for await (const chunk of input) {
117
+ const bytes = Buffer.from(chunk);
118
+ size += bytes.length;
119
+ if (size > PEER_WIRE_LIMIT) throw new Error("Peer envelope too large");
120
+ chunks.push(bytes);
121
+ }
122
+ return validatePeerEnvelope(
123
+ JSON.parse(Buffer.concat(chunks).toString("utf8")),
124
+ );
125
+ }
126
+
127
+ export function formatRemotePeerMessage(envelope) {
128
+ const { sender, correlationId, originHost, replyHost, message } = envelope;
129
+ const quote = (s) => `'${s.replaceAll("'", `'"'"'`)}'`;
130
+ const command = quote(path.join(homedir(), ".claude", "scripts", "tfx-peer"));
131
+ return [
132
+ `[tfx-peer from=${sender.cli}:${sender.name} msg=${correlationId} origin=${originHost}${sender.id ? ` fromId=${sender.id}` : ""}]`,
133
+ message,
134
+ `회신: ${command} remote-send --host ${quote(originHost)} --reply-host ${quote(replyHost)} --to ${quote(`${sender.cli}:${sender.id || sender.name}`)} --message '...'`,
135
+ ].join("\n");
136
+ }
137
+
138
+ export function validatePeerResponse(response, correlationId) {
139
+ const result = response?.result;
140
+ if (
141
+ response?.version !== 1 ||
142
+ response.correlationId !== correlationId ||
143
+ !["preflight", "delivery"].includes(response.stage) ||
144
+ !["sent", "written", "unknown", "partial", "rejected"].includes(
145
+ result?.status,
146
+ ) ||
147
+ result.retrySafe !== false ||
148
+ result.modelReceipt !== false ||
149
+ (response.stage === "preflight") !== (result.status === "rejected") ||
150
+ (result.msgId !== undefined &&
151
+ (typeof result.msgId !== "string" ||
152
+ !result.msgId ||
153
+ result.msgId.length > 512)) ||
154
+ (result.error !== undefined && typeof result.error !== "string")
155
+ )
156
+ throw new Error("Invalid remote peer response version/ID/status");
157
+ return result;
158
+ }
159
+
160
+ export async function sendRemotePeer(envelope, deps = {}) {
161
+ validatePeerEnvelope(envelope);
162
+ const platform = deps.platform || process.platform;
163
+ if (!["darwin", "linux"].includes(platform))
164
+ throw new Error("Remote peer supports only Darwin and POSIX Linux senders");
165
+ const lookup = deps.resolveRemoteHost || resolveRemoteHost;
166
+ const host = lookup(envelope.replyHost, deps);
167
+ const address =
168
+ host.ssh?.host || host.tailscale?.dns || host.tailscale?.ip || host.name;
169
+ const user = host.ssh_user;
170
+ const args = [
171
+ "-T",
172
+ "-o",
173
+ "BatchMode=yes",
174
+ "-o",
175
+ "ConnectTimeout=10",
176
+ "-o",
177
+ "ConnectionAttempts=1",
178
+ ];
179
+ if (host.ssh?.port !== undefined) args.push("-p", String(host.ssh.port));
180
+ args.push("--", user ? `${user}@${address}` : address, PEER_RECEIVER_COMMAND);
181
+ const uncertain = (error) => ({
182
+ status: "unknown",
183
+ correlationId: envelope.correlationId,
184
+ retrySafe: false,
185
+ modelReceipt: false,
186
+ error,
187
+ });
188
+ const wire = await new Promise((resolve) => {
189
+ let child;
190
+ try {
191
+ child = (deps.spawn || spawn)("ssh", args, {
192
+ shell: false,
193
+ stdio: ["pipe", "pipe", "pipe"],
194
+ env: deps.env || process.env,
195
+ });
196
+ } catch (error) {
197
+ resolve({ error: error.message });
198
+ return;
199
+ }
200
+ const stdout = [];
201
+ let size = 0,
202
+ error = null;
203
+ const stop = (reason) => {
204
+ error = reason;
205
+ child.kill("SIGKILL");
206
+ };
207
+ const timer = setTimeout(
208
+ () => stop("SSH peer timeout; delivery outcome unknown"),
209
+ deps.timeoutMs ?? 15000,
210
+ );
211
+ child.stdout.on("data", (chunk) => {
212
+ size += chunk.length;
213
+ if (size > PEER_WIRE_LIMIT) stop("Remote peer response too large");
214
+ else stdout.push(Buffer.from(chunk));
215
+ });
216
+ child.stderr.on("data", (chunk) => {
217
+ size += chunk.length;
218
+ if (size > PEER_WIRE_LIMIT) stop("SSH peer output too large");
219
+ });
220
+ child.stdin.on("error", (err) => {
221
+ error ||= `SSH stdin: ${err.code || err.message}`;
222
+ });
223
+ child.once("error", (err) => {
224
+ error ||= `SSH execution: ${err.message}`;
225
+ });
226
+ child.once("close", (code) => {
227
+ clearTimeout(timer);
228
+ resolve({ stdout: Buffer.concat(stdout).toString("utf8"), code, error });
229
+ });
230
+ child.stdin.end(JSON.stringify(envelope));
231
+ });
232
+ if (wire.error) return uncertain(wire.error);
233
+ try {
234
+ if (!wire.stdout?.trim())
235
+ throw new Error(
236
+ "Empty remote peer response; protocol unavailable or response lost",
237
+ );
238
+ const response = JSON.parse(wire.stdout);
239
+ const result = validatePeerResponse(response, envelope.correlationId);
240
+ const expectedCode =
241
+ result.status === "rejected"
242
+ ? 1
243
+ : ["unknown", "partial"].includes(result.status)
244
+ ? 2
245
+ : 0;
246
+ if (wire.code !== expectedCode)
247
+ return {
248
+ ...result,
249
+ ...uncertain("SSH exit/status mismatch; delivery outcome unknown"),
250
+ };
251
+ return { ...result, correlationId: envelope.correlationId };
252
+ } catch (error) {
253
+ return uncertain(error.message);
254
+ }
255
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@triflux/remote",
3
- "version": "10.88.2",
3
+ "version": "10.89.0",
4
4
  "description": "triflux remote — team mode, psmux, MCP workers.",
5
5
  "type": "module",
6
6
  "main": "hub/index.mjs",