@triflux/remote 10.88.3 → 10.89.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.
@@ -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
 
@@ -51,14 +88,25 @@ export function validateCodexTarget(thread, id) {
51
88
  if (
52
89
  thread?.id !== id ||
53
90
  thread.canAcceptDirectInput !== true ||
54
- thread.threadSource !== "user" ||
91
+ !(
92
+ thread.threadSource === "user" ||
93
+ (thread.threadSource === null &&
94
+ thread.parentThreadId === null &&
95
+ thread.agentRole === null &&
96
+ (thread.source === "cli" || thread.source === "vscode"))
97
+ ) ||
55
98
  thread.parentThreadId ||
56
99
  thread.agentRole
57
100
  )
58
101
  throw new Error("Codex thread is not an eligible loaded root user thread");
59
102
  }
60
103
 
61
- export async function sendCodexPeer({ client, threadId, message }) {
104
+ export async function sendCodexPeer({
105
+ client,
106
+ threadId,
107
+ message,
108
+ onWriteStart,
109
+ }) {
62
110
  let cursor;
63
111
  let loaded = false;
64
112
  do {
@@ -81,6 +129,7 @@ export async function sendCodexPeer({ client, threadId, message }) {
81
129
  });
82
130
  validateCodexTarget(read.thread, threadId);
83
131
  try {
132
+ onWriteStart?.();
84
133
  await client.request("thread/inject_items", {
85
134
  threadId,
86
135
  items: [
@@ -162,6 +211,7 @@ export function resolveCodexSocket(home = os.homedir(), env = process.env) {
162
211
  export async function deliverCodex({
163
212
  threadId,
164
213
  message,
214
+ onWriteStart,
165
215
  home = os.homedir(),
166
216
  env = process.env,
167
217
  platform = process.platform,
@@ -177,7 +227,7 @@ export async function deliverCodex({
177
227
  capabilities: { experimentalApi: true },
178
228
  });
179
229
  client.notify("initialized", {});
180
- return await sendCodexPeer({ client, threadId, message });
230
+ return await sendCodexPeer({ client, threadId, message, onWriteStart });
181
231
  } finally {
182
232
  client.close();
183
233
  }
@@ -400,6 +450,7 @@ export function resolveClaudeRegistryTarget(
400
450
  export async function deliverClaude({
401
451
  sessionId,
402
452
  message,
453
+ onWriteStart,
403
454
  home = os.homedir(),
404
455
  platform = process.platform,
405
456
  receiver = resolveClaudeReceiver({ sessionId, home, platform }),
@@ -444,6 +495,7 @@ export async function deliverClaude({
444
495
  );
445
496
  conn.once("error", reject);
446
497
  conn.once("connect", () => {
498
+ onWriteStart?.();
447
499
  conn.write(
448
500
  `${JSON.stringify({ type: "auth", token: receiver.peerToken })}\n${JSON.stringify(frame)}\n`,
449
501
  );
@@ -480,6 +532,7 @@ export function deliverAgy({
480
532
  appDataDir: targetAppDataDir = null,
481
533
  requireExisting = false,
482
534
  message,
535
+ onWriteStart,
483
536
  sender = "unknown",
484
537
  env = process.env,
485
538
  home = null,
@@ -534,6 +587,7 @@ export function deliverAgy({
534
587
  const jsonPath = path.join(messagesDir, `${msgId}.json`);
535
588
  const markerPath = path.join(undeliveredDir, msgId);
536
589
  try {
590
+ onWriteStart?.();
537
591
  io.writeFileSync(jsonPath, JSON.stringify(payload, null, 2), {
538
592
  mode: 0o600,
539
593
  });
@@ -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.3",
3
+ "version": "10.89.1",
4
4
  "description": "triflux remote — team mode, psmux, MCP workers.",
5
5
  "type": "module",
6
6
  "main": "hub/index.mjs",