better-dsh 0.3.0 → 0.3.1-a

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 (76) hide show
  1. package/README.md +49 -177
  2. package/cordis.patch.yml +5 -36
  3. package/docs/20_specs/kernel-provisioning/spec.md +2 -0
  4. package/docs/adr/0015-independent-bundles-agent-remote.md +40 -0
  5. package/docs/remote-tools.md +56 -0
  6. package/docs/specs/kernel-provisioning/spec.md +12 -40
  7. package/docs/specs/omp-python-repl-port/runtime-guide.md +35 -0
  8. package/docs/specs/omp-python-repl-port/spec.md +68 -0
  9. package/docs/specs/remote-tool-enhancement/spec.md +203 -0
  10. package/dsh-docs/baseline.md +13 -0
  11. package/dsh-docs/subsystems/invariants.md +3 -0
  12. package/dsh-docs/subsystems/invariants.zh.md +3 -0
  13. package/eval-description.md +7 -3
  14. package/lib/LaunchOptions-0Mr6meJc.js +4 -0
  15. package/lib/{LaunchOptions-kGXcZOe0.js → LaunchOptions-iE9M-STS.js} +1 -1
  16. package/lib/{bidi-BalpKaJc.js → bidi-CaQeBZHY.js} +1 -1
  17. package/lib/client/index.js +30 -6
  18. package/lib/failover/index.js +1 -1
  19. package/lib/fs-aware/sandbox-plugin.js +3 -3
  20. package/lib/{index-browser-CysrjMyM.js → index-browser-CJzxT9Vi.js} +4 -4
  21. package/lib/index.d.ts +16 -50
  22. package/lib/index.js +482 -1720
  23. package/lib/kernel-env-x_WkG6Fr.js +76 -0
  24. package/lib/kernel-env.d.ts +5 -69
  25. package/lib/kernel-env.js +2 -2
  26. package/lib/{lsp-server-registry-BXvUTBY7.js → lsp-server-registry-BcdkHROD.js} +1 -1
  27. package/lib/{lsp-server-registry-CH9WNFFt.js → lsp-server-registry-D4VLs1Sm.js} +1 -1
  28. package/lib/{main-BQeFrBM1.js → main-taSMz1KX.js} +1 -1
  29. package/lib/{main-CupOhMp5.js → main-uqGqYYnA.js} +7 -5
  30. package/lib/mobile/plugin.d.ts +1 -1
  31. package/lib/omp-python/DILL-LICENSE +35 -0
  32. package/lib/omp-python/OMP-LICENSE +23 -0
  33. package/lib/omp-python/better_snapshot.py +391 -0
  34. package/lib/omp-python/better_support.py +425 -0
  35. package/lib/omp-python/dill-provenance.json +55 -0
  36. package/lib/omp-python/provenance.json +13 -0
  37. package/lib/omp-python/runner.py +2499 -0
  38. package/lib/omp-python/vendor/dill/__diff.py +234 -0
  39. package/lib/omp-python/vendor/dill/__info__.py +291 -0
  40. package/lib/omp-python/vendor/dill/__init__.py +119 -0
  41. package/lib/omp-python/vendor/dill/_dill.py +2265 -0
  42. package/lib/omp-python/vendor/dill/_objects.py +545 -0
  43. package/lib/omp-python/vendor/dill/_shims.py +193 -0
  44. package/lib/omp-python/vendor/dill/detect.py +287 -0
  45. package/lib/omp-python/vendor/dill/logger.py +285 -0
  46. package/lib/omp-python/vendor/dill/objtypes.py +24 -0
  47. package/lib/omp-python/vendor/dill/pointers.py +122 -0
  48. package/lib/omp-python/vendor/dill/session.py +612 -0
  49. package/lib/omp-python/vendor/dill/settings.py +25 -0
  50. package/lib/omp-python/vendor/dill/source.py +1025 -0
  51. package/lib/omp-python/vendor/dill/temp.py +252 -0
  52. package/lib/{puppeteer-core-CSk2PtSm.js → puppeteer-core-C-4NyOpY.js} +8 -8
  53. package/lib/remote/plugin.d.ts +459 -5
  54. package/lib/remote/plugin.js +2389 -210
  55. package/lib/url-schemes/index.d.ts +11 -11
  56. package/lib/url-schemes/index.js +11 -11
  57. package/lib/web-password.d.ts +8 -8
  58. package/lib/web-password.js +5 -1
  59. package/lib/{web-trust-Dej_RI2B.d.ts → web-trust-C_Y6o8Z2.d.ts} +11 -11
  60. package/lib/web-trust.d.ts +1 -2
  61. package/lib/welcome-notice.d.ts +5 -7
  62. package/lib/welcome-notice.js +9 -48
  63. package/lib/{wrap-T4I0nfPM.js → wrap-0VA0uFx8.js} +1 -1
  64. package/package.json +7 -24
  65. package/scripts/kernel-check.mjs +5 -0
  66. package/lib/LaunchOptions-B874eBu2.js +0 -4
  67. package/lib/bundle-delivery.d.ts +0 -20
  68. package/lib/bundle-delivery.js +0 -209
  69. package/lib/hmr.d.ts +0 -32
  70. package/lib/hmr.js +0 -49
  71. package/lib/kernel-bridge.py +0 -172
  72. package/lib/kernel-env-D8UOWeji.js +0 -195
  73. package/lib/webserver.d.ts +0 -10
  74. package/lib/webserver.js +0 -19
  75. package/scripts/kernel-provision.mjs +0 -35
  76. package/scripts/register-bundle.mjs +0 -80
@@ -1,19 +1,498 @@
1
1
  import { t as Schema } from "../lib-C8_jXvwA.js";
2
- import { randomBytes } from "node:crypto";
3
- import { spawn } from "node:child_process";
4
- import { readFile } from "node:fs/promises";
5
- import { join } from "node:path";
6
2
  import { Context } from "@deepseek-ai/cordis";
7
- import { homedir } from "node:os";
3
+ import { createHash, randomBytes, randomUUID } from "node:crypto";
4
+ import { constants, createReadStream, createWriteStream, mkdirSync, statSync } from "node:fs";
5
+ import { dirname, isAbsolute, join, posix, resolve } from "node:path";
6
+ import { spawn } from "node:child_process";
7
+ import { homedir, tmpdir } from "node:os";
8
8
  import { defineTool } from "@deepseek-ai/dsh-tools";
9
+ import { chmod, lstat, mkdir, mkdtemp, open, readFile, readdir, readlink, rename, rm, symlink, writeFile } from "node:fs/promises";
10
+ import { pipeline } from "node:stream/promises";
11
+ import { Readable } from "node:stream";
12
+ import { createConnection, createServer } from "node:net";
9
13
 
10
- //#region src/remote/oneshot.ts
11
- /** 单流硬收集帽(4M UTF-16 code units,ASCII≈4MB):防失控输出吃内存;模型面截断由上层 tailWindow 负责。 */
12
- const HARD_CAP = 4e6;
13
- /** One-shot:子进程退出码即收尾(spec §五.1),stdout/stderr 分离保留。 */
14
- async function runOneShot(argv, opts = {}) {
14
+ //#region src/remote/archive.ts
15
+ const FILE_FRAME_BYTES = 64 * 1024;
16
+ const FILE_MAX_ENTRIES = 1e4;
17
+ const FILE_MAX_METADATA = 8 * 1024 * 1024;
18
+ const order = (a, b) => Buffer.compare(Buffer.from(a), Buffer.from(b));
19
+ function checkRelative(p) {
20
+ if (typeof p !== "string" || Buffer.byteLength(p) > 4096 || p.includes("\0") || p !== "" && (p.startsWith("/") || p.split("/").some((s) => !s || s === "." || s === ".."))) throw new Error("unsafe archive relative path");
21
+ }
22
+ function checkAbsolute(p) {
23
+ if (typeof p !== "string" || !p.startsWith("/") || p.includes("\0") || p.split("/").includes("..")) throw new Error("file endpoint requires an absolute path without traversal or NUL");
24
+ }
25
+ function checkSelection(selection) {
26
+ if (selection.length > FILE_MAX_ENTRIES || Buffer.byteLength(JSON.stringify(selection)) > FILE_MAX_METADATA) throw new Error("file selection metadata limit exceeded");
27
+ const seen = /* @__PURE__ */ new Set();
28
+ for (const p of selection) {
29
+ checkRelative(p);
30
+ if (seen.has(p)) throw new Error("duplicate selection path");
31
+ seen.add(p);
32
+ }
33
+ }
34
+ function checkLink(e) {
35
+ const link = e.link;
36
+ if (e.p === "" || typeof link !== "string" || !link || link.includes("\0") || link.startsWith("/") || Buffer.byteLength(link) > 4096) throw new Error("unsafe archive symlink");
37
+ const target = posix.normalize(posix.join(posix.dirname(e.p), link));
38
+ if (target === ".." || target.startsWith("../")) throw new Error("unsafe archive symlink escape");
39
+ }
40
+ /** Conservative effective resolution: links may traverse only members of this incoming tree.
41
+ * Preserved destination extras cannot supply a symlink target or any of its ancestors.
42
+ */
43
+ function checkTreeLinks(entries) {
44
+ const incoming = new Map(entries.map((e) => [e.p, e]));
45
+ for (const link of entries.filter((e) => e.t === "l")) {
46
+ const stack = posix.dirname(link.p) === "." ? [] : posix.dirname(link.p).split("/");
47
+ let pending = link.link.split("/"), hops = 0;
48
+ while (pending.length) {
49
+ const part = pending.shift();
50
+ if (!part || part === ".") continue;
51
+ if (part === "..") {
52
+ if (!stack.length) throw new Error("unsafe symlink chain escape");
53
+ stack.pop();
54
+ continue;
55
+ }
56
+ const entry = incoming.get([...stack, part].join("/"));
57
+ if (!entry) throw new Error("unsafe symlink target outside incoming tree (destination aliases are not followed)");
58
+ if (entry.t === "l") {
59
+ if (++hops > 40) throw new Error("unsafe symlink cycle or chain limit");
60
+ pending = [...entry.link.split("/"), ...pending];
61
+ } else {
62
+ if (pending.length && entry.t !== "d") throw new Error("unsafe symlink non-directory target ancestor");
63
+ stack.push(part);
64
+ }
65
+ }
66
+ }
67
+ }
68
+ function header(e) {
69
+ const b = Buffer.from(JSON.stringify(e)), n = Buffer.alloc(4);
70
+ n.writeUInt32BE(b.length);
71
+ return Buffer.concat([n, b]);
72
+ }
73
+ function manifestDigest(entries) {
74
+ const hash = createHash("sha256");
75
+ let bytes = 0;
76
+ for (const e of [...entries].sort((a, b) => order(a.p, b.p))) {
77
+ hash.update(header([
78
+ e.p,
79
+ e.t,
80
+ e.n,
81
+ e.t === "f" ? e.hash : e.t === "l" ? e.link : ""
82
+ ]));
83
+ if (e.t === "f") bytes += e.n;
84
+ }
85
+ return {
86
+ sha256: hash.digest("hex"),
87
+ bytes,
88
+ entries: entries.length
89
+ };
90
+ }
91
+ /** Reject even existing parent symlinks; leaf may be an explicitly copied symlink. */
92
+ async function ancestors(path$1, includeLeaf = false) {
93
+ checkAbsolute(path$1);
94
+ const parts = resolve(path$1).split("/").filter(Boolean);
95
+ for (let i = 1; i <= parts.length - (includeLeaf ? 0 : 1); i++) try {
96
+ const st = await lstat("/" + parts.slice(0, i).join("/"));
97
+ if (st.isSymbolicLink() || !st.isDirectory()) throw new Error("unsafe destination/source ancestor: symlink or non-directory");
98
+ } catch (e) {
99
+ if (e.code !== "ENOENT") throw e;
100
+ }
101
+ }
102
+ async function safeRead(path$1, signal) {
103
+ return (await open(path$1, constants.O_RDONLY | constants.O_NOFOLLOW)).createReadStream({
104
+ autoClose: true,
105
+ highWaterMark: FILE_FRAME_BYTES,
106
+ signal
107
+ });
108
+ }
109
+ async function entryAt(root, p, signal) {
110
+ signal.throwIfAborted();
111
+ const path$1 = p ? join(root, p) : root;
112
+ await ancestors(path$1);
113
+ const st = await lstat(path$1);
114
+ if (st.isDirectory()) return {
115
+ p,
116
+ t: "d",
117
+ n: 0
118
+ };
119
+ if (st.isSymbolicLink()) {
120
+ const e = {
121
+ p,
122
+ t: "l",
123
+ n: 0,
124
+ link: await readlink(path$1)
125
+ };
126
+ checkLink(e);
127
+ return e;
128
+ }
129
+ if (!st.isFile()) throw new Error("unsupported file type (only files, directories and safe relative symlinks)");
130
+ const hash = createHash("sha256");
131
+ for await (const chunk of await safeRead(path$1, signal)) hash.update(chunk);
132
+ return {
133
+ p,
134
+ t: "f",
135
+ n: st.size,
136
+ hash: hash.digest("hex")
137
+ };
138
+ }
139
+ async function tree(root, signal, selection) {
140
+ checkAbsolute(root);
141
+ const entries = [];
142
+ const visit = async (p) => {
143
+ const e = await entryAt(root, p, signal);
144
+ entries.push(e);
145
+ if (entries.length > FILE_MAX_ENTRIES) throw new Error("archive entry limit exceeded");
146
+ if (e.t === "d") for (const name$1 of (await readdir(p ? join(root, p) : root)).sort(order)) await visit(p ? `${p}/${name$1}` : name$1);
147
+ };
148
+ if (selection) {
149
+ checkSelection(selection);
150
+ for (const p of selection) entries.push(await entryAt(root, p, signal));
151
+ } else await visit("");
152
+ if (Buffer.byteLength(JSON.stringify(entries)) > FILE_MAX_METADATA) throw new Error("archive metadata limit exceeded");
153
+ return entries;
154
+ }
155
+ async function inspectArchive(path$1, signal) {
156
+ const file = await open(path$1, "r");
157
+ let offset = 0, metadata = 0;
158
+ const entries = [];
159
+ const seen = /* @__PURE__ */ new Map();
160
+ const exact = async (n) => {
161
+ const b = Buffer.alloc(n);
162
+ let at = 0;
163
+ while (at < n) {
164
+ signal.throwIfAborted();
165
+ const r = await file.read(b, at, n - at, offset);
166
+ if (!r.bytesRead) throw new Error("truncated binary archive");
167
+ at += r.bytesRead;
168
+ offset += r.bytesRead;
169
+ }
170
+ return b;
171
+ };
172
+ try {
173
+ while (true) {
174
+ const size = (await exact(4)).readUInt32BE();
175
+ if (!size || size > FILE_FRAME_BYTES) throw new Error("archive header limit exceeded");
176
+ metadata += size;
177
+ if (metadata > FILE_MAX_METADATA) throw new Error("archive metadata limit exceeded");
178
+ const e = JSON.parse((await exact(size)).toString("utf8"));
179
+ if (e.end === true) {
180
+ if ((await file.stat()).size !== offset) throw new Error("trailing archive bytes");
181
+ break;
182
+ }
183
+ checkRelative(e.p);
184
+ if (![
185
+ "f",
186
+ "d",
187
+ "l"
188
+ ].includes(e.t) || !Number.isSafeInteger(e.n) || e.n < 0 || e.t !== "f" && e.n !== 0) throw new Error("unsafe archive type/size");
189
+ if (entries.length >= FILE_MAX_ENTRIES || seen.has(e.p)) throw new Error("archive entry limit or duplicate path");
190
+ if (e.p !== "" && seen.get(posix.dirname(e.p) === "." ? "" : posix.dirname(e.p))?.t !== "d") throw new Error("unsafe archive parent ordering/type");
191
+ if (!entries.length && e.p !== "") throw new Error("archive root missing");
192
+ if (e.t === "l") checkLink(e);
193
+ e.offset = offset;
194
+ if (e.t === "f") {
195
+ const hash = createHash("sha256");
196
+ let left = e.n;
197
+ while (left) {
198
+ const chunk = await exact(Math.min(FILE_FRAME_BYTES, left));
199
+ hash.update(chunk);
200
+ left -= chunk.length;
201
+ }
202
+ e.hash = hash.digest("hex");
203
+ }
204
+ entries.push(e);
205
+ seen.set(e.p, e);
206
+ }
207
+ if (!entries.length) throw new Error("archive root missing");
208
+ return entries;
209
+ } finally {
210
+ await file.close();
211
+ }
212
+ }
213
+ async function importArchive(destination, payload, mkdirParents, signal, resources) {
214
+ checkAbsolute(destination);
215
+ const entries = await inspectArchive(payload, signal);
216
+ checkTreeLinks(entries);
217
+ for (const e of entries) {
218
+ const path$1 = e.p ? join(destination, e.p) : destination;
219
+ await ancestors(path$1);
220
+ try {
221
+ const st = await lstat(path$1);
222
+ const type = st.isSymbolicLink() ? "l" : st.isDirectory() ? "d" : st.isFile() ? "f" : "?";
223
+ if (type !== e.t || type === "l") throw new Error("destination path/type conflict or existing symlink");
224
+ } catch (error) {
225
+ if (error.code !== "ENOENT") throw error;
226
+ }
227
+ }
228
+ await ancestors(destination);
229
+ if (mkdirParents) await mkdir(dirname(destination), { recursive: true });
230
+ for (const e of entries) {
231
+ signal.throwIfAborted();
232
+ const path$1 = e.p ? join(destination, e.p) : destination;
233
+ await ancestors(path$1);
234
+ if (e.t === "d") {
235
+ await mkdir(path$1, { recursive: false }).catch((error) => {
236
+ if (error.code !== "EEXIST") throw error;
237
+ });
238
+ continue;
239
+ }
240
+ if (e.t === "l") {
241
+ await symlink(e.link, path$1);
242
+ continue;
243
+ }
244
+ const temporary = join(dirname(path$1), `.remote-copy-${randomUUID()}`);
245
+ await resources.temporary?.(temporary, true);
246
+ try {
247
+ const output = createWriteStream(temporary, {
248
+ flags: "wx",
249
+ mode: 384
250
+ });
251
+ await pipeline(e.n ? createReadStream(payload, {
252
+ start: e.offset,
253
+ end: e.offset + e.n - 1
254
+ }) : Readable.from([]), output, { signal });
255
+ await ancestors(path$1);
256
+ signal.throwIfAborted();
257
+ await rename(temporary, path$1);
258
+ } finally {
259
+ await rm(temporary, { force: true });
260
+ await resources.temporary?.(temporary, false);
261
+ }
262
+ }
263
+ }
264
+ function localFileEndpoint(resources = {}) {
265
+ return {
266
+ async exportTree(path$1, signal) {
267
+ const entries = await tree(path$1, signal);
268
+ return Readable.from((async function* () {
269
+ for (const e of entries) {
270
+ signal.throwIfAborted();
271
+ yield header({
272
+ p: e.p,
273
+ t: e.t,
274
+ n: e.n,
275
+ ...e.link === void 0 ? {} : { link: e.link }
276
+ });
277
+ if (e.t === "f" && e.n) for await (const chunk of await safeRead(e.p ? join(path$1, e.p) : path$1, signal)) yield chunk;
278
+ }
279
+ yield header({ end: true });
280
+ })());
281
+ },
282
+ async importTree(path$1, source, { mkdir: parents, signal }) {
283
+ checkAbsolute(path$1);
284
+ const scratch = resources.receiveRoot ?? await mkdtemp(join(tmpdir(), "remote-file-receive-"));
285
+ try {
286
+ const payload = join(scratch, "payload");
287
+ await pipeline(source, createWriteStream(payload, {
288
+ flags: "wx",
289
+ mode: 384
290
+ }), { signal });
291
+ await importArchive(path$1, payload, parents, signal, resources);
292
+ } finally {
293
+ source.destroy?.();
294
+ await rm(scratch, {
295
+ recursive: true,
296
+ force: true
297
+ });
298
+ }
299
+ },
300
+ async digest(path$1, signal, selection) {
301
+ return manifestDigest(await tree(path$1, signal, selection));
302
+ }
303
+ };
304
+ }
305
+
306
+ //#endregion
307
+ //#region src/remote/copy.ts
308
+ var CopyError = class extends Error {
309
+ constructor(receipt) {
310
+ super(`copy failed: ${JSON.stringify(receipt)}`);
311
+ this.receipt = receipt;
312
+ }
313
+ };
314
+ /** Tree lane (Hub/local binary endpoints) with staged archive and digest
315
+ * verification. CLI-option fields are rejected: this lane has no native CLI. */
316
+ async function copyTree(request, source, destination, signal, scratchRoot = join(process.cwd(), ".scratch")) {
317
+ signal.throwIfAborted();
318
+ if (request.argv !== void 0 || request.pullArgv !== void 0 || request.pushArgv !== void 0) throw new Error("copy: argv/pullArgv/pushArgv are native CLI options; the Hub/local staged lane does not accept CLI options");
319
+ await mkdir(scratchRoot, { recursive: true });
320
+ signal.throwIfAborted();
321
+ const scratch = await mkdtemp(join(scratchRoot, "remote-copy-")), payload = join(scratch, "payload.bin");
322
+ const receipt = {
323
+ action: "copy",
324
+ src: request.src,
325
+ dst: request.dst,
326
+ verified: false,
327
+ bytes: 0,
328
+ entries: 0,
329
+ scope: "transferred entries"
330
+ };
331
+ try {
332
+ await pipeline(await source.endpoint.exportTree(source.path, signal), createWriteStream(payload, {
333
+ flags: "wx",
334
+ mode: 384
335
+ }), { signal });
336
+ const manifest = await inspectArchive(payload, signal), staged = manifestDigest(manifest);
337
+ receipt.bytes = staged.bytes;
338
+ receipt.entries = staged.entries;
339
+ const selection = manifest.map((e) => e.p);
340
+ if (request.verify !== false) {
341
+ const original = await source.endpoint.digest(source.path, signal, selection);
342
+ receipt.sourceDigest = original.sha256;
343
+ if (original.sha256 !== staged.sha256) throw new Error("source changed during export; staged digest mismatch");
344
+ }
345
+ signal.throwIfAborted();
346
+ await destination.endpoint.importTree(destination.path, createReadStream(payload), {
347
+ mkdir: request.mkdir ?? true,
348
+ signal
349
+ });
350
+ if (request.verify !== false) {
351
+ const dest = await destination.endpoint.digest(destination.path, signal, selection);
352
+ receipt.destinationDigest = dest.sha256;
353
+ if (dest.sha256 !== receipt.sourceDigest) throw new Error("destination digest mismatch");
354
+ receipt.verified = true;
355
+ }
356
+ signal.throwIfAborted();
357
+ await rm(scratch, {
358
+ recursive: true,
359
+ force: true
360
+ });
361
+ return receipt;
362
+ } catch (error) {
363
+ throw new CopyError({
364
+ ...receipt,
365
+ verified: false,
366
+ scratch,
367
+ error: error instanceof Error ? error.message : String(error)
368
+ });
369
+ }
370
+ }
371
+
372
+ //#endregion
373
+ //#region src/remote/output-ring.ts
374
+ const continuation = (byte) => (byte & 192) === 128;
375
+ function completeUtf8End(bytes) {
376
+ if (!bytes.length) return 0;
377
+ let start = bytes.length - 1;
378
+ while (start > 0 && continuation(bytes[start]) && bytes.length - start < 4) start--;
379
+ const lead = bytes[start];
380
+ const width = lead >= 240 && lead <= 244 ? 4 : lead >= 224 && lead <= 239 ? 3 : lead >= 194 && lead <= 223 ? 2 : 1;
381
+ return bytes.length - start < width ? start : bytes.length;
382
+ }
383
+ /** Byte-addressed decoded output: mid-codepoint cursors skip forward and explicitly report loss. */
384
+ function readUtf8Tail(bytes, totalBytes, fromByte) {
385
+ if (!Number.isSafeInteger(fromByte) || fromByte < 0) throw new Error("Output cursor must be a non-negative safe byte offset");
386
+ const start = totalBytes - bytes.length;
387
+ let offset = Math.max(0, Math.min(bytes.length, fromByte - start));
388
+ let lossy = fromByte < start;
389
+ while (offset < bytes.length && continuation(bytes[offset])) {
390
+ offset++;
391
+ lossy = true;
392
+ }
393
+ const end = completeUtf8End(bytes);
394
+ return {
395
+ text: bytes.subarray(Math.min(offset, end), end).toString("utf8"),
396
+ nextOffset: start + end,
397
+ lossy
398
+ };
399
+ }
400
+ /** Native/PTY retention is UTF16 units; cursor offsets remain real UTF8 bytes. Input is decoded text. */
401
+ var Utf16TailRing = class {
402
+ text = "";
403
+ total = 0;
404
+ totalBytes = 0;
405
+ constructor(capacity = 4e6) {
406
+ this.capacity = capacity;
407
+ }
408
+ push(text) {
409
+ this.total += text.length;
410
+ this.totalBytes += Buffer.byteLength(text);
411
+ this.text = (this.text + text).slice(-this.capacity);
412
+ if (this.text.charCodeAt(0) >= 56320 && this.text.charCodeAt(0) <= 57343) this.text = this.text.slice(1);
413
+ }
414
+ stats(returned = this.text.length) {
415
+ return {
416
+ unit: "utf16",
417
+ total: this.total,
418
+ retained: this.text.length,
419
+ dropped: this.total - this.text.length,
420
+ returned,
421
+ retentionLimit: this.capacity
422
+ };
423
+ }
424
+ read(fromByte) {
425
+ return readUtf8Tail(Buffer.from(this.text), this.totalBytes, fromByte);
426
+ }
427
+ };
428
+ /** Raw byte retention for Hub oneshot. Tail/read starts skip partial UTF8 prefixes, never invent replacement characters. */
429
+ var ByteTailRing = class {
430
+ total = 0;
431
+ chunks = [];
432
+ retained = 0;
433
+ constructor(capacity = 4 * 1024 * 1024) {
434
+ this.capacity = capacity;
435
+ }
436
+ push(chunk) {
437
+ if (chunk.length === 0) return;
438
+ this.total += chunk.length;
439
+ this.chunks.push(chunk);
440
+ this.retained += chunk.length;
441
+ let drop = this.retained - this.capacity;
442
+ while (drop > 0) {
443
+ const head = this.chunks[0];
444
+ const count = Math.min(drop, head.length);
445
+ if (count === head.length) this.chunks.shift();
446
+ else this.chunks[0] = head.subarray(count);
447
+ this.retained -= count;
448
+ drop -= count;
449
+ }
450
+ }
451
+ tail(maxBytes = this.retained) {
452
+ let skip = Math.max(0, this.retained - maxBytes);
453
+ const out = [];
454
+ for (const chunk of this.chunks) {
455
+ if (skip >= chunk.length) {
456
+ skip -= chunk.length;
457
+ continue;
458
+ }
459
+ out.push(chunk.subarray(skip));
460
+ skip = 0;
461
+ }
462
+ const bytes = Buffer.concat(out);
463
+ let offset = 0;
464
+ while (offset < bytes.length && continuation(bytes[offset])) offset++;
465
+ return bytes.subarray(offset, completeUtf8End(bytes));
466
+ }
467
+ stats(returned = this.tail().length) {
468
+ return {
469
+ unit: "bytes",
470
+ total: this.total,
471
+ retained: this.retained,
472
+ dropped: this.total - this.retained,
473
+ returned,
474
+ retentionLimit: this.capacity
475
+ };
476
+ }
477
+ read(fromByte) {
478
+ return readUtf8Tail(Buffer.concat(this.chunks), this.total, fromByte);
479
+ }
480
+ };
481
+
482
+ //#endregion
483
+ //#region src/remote/process.ts
484
+ const TERM_GRACE_MS = 2e3;
485
+ const KILL_SETTLE_MS = 100;
486
+ const PROCESS_CLEANUP_BOUND_MS = TERM_GRACE_MS + KILL_SETTLE_MS;
487
+ /** Real detached process. Completion includes group cleanup, even after its leader closes. */
488
+ function startBinaryProcess(argv, options = {}) {
15
489
  const started = Date.now();
16
490
  const child = spawn(argv[0], argv.slice(1), {
491
+ cwd: options.cwd,
492
+ env: options.env === void 0 ? process.env : {
493
+ ...process.env,
494
+ ...options.env
495
+ },
17
496
  stdio: [
18
497
  "pipe",
19
498
  "pipe",
@@ -21,57 +500,1075 @@ async function runOneShot(argv, opts = {}) {
21
500
  ],
22
501
  detached: true
23
502
  });
24
- let stdout = "";
25
- let stderr = "";
26
- let timedOut = false;
27
- child.stdout.setEncoding("utf8").on("data", (d) => {
28
- if (stdout.length < HARD_CAP) stdout = (stdout + d).slice(0, HARD_CAP);
503
+ let diagnostic;
504
+ let timedOut = false, finished = false, cancelled = false;
505
+ let cleanup;
506
+ let timer;
507
+ const signal = (sig) => {
508
+ if (child.pid === void 0) return false;
509
+ try {
510
+ process.kill(-child.pid, sig);
511
+ return true;
512
+ } catch (error) {
513
+ if (error.code !== "ESRCH") diagnostic = `remote: process cleanup failed: ${String(error)}`;
514
+ return false;
515
+ }
516
+ };
517
+ let closed;
518
+ const close = new Promise((resolve$1) => {
519
+ closed = resolve$1;
29
520
  });
30
- child.stderr.setEncoding("utf8").on("data", (d) => {
31
- if (stderr.length < HARD_CAP) stderr = (stderr + d).slice(0, HARD_CAP);
521
+ const reap = () => cleanup ??= new Promise((resolve$1) => {
522
+ if (!signal("SIGTERM")) {
523
+ resolve$1();
524
+ return;
525
+ }
526
+ let settled = false;
527
+ const finish = () => {
528
+ if (!settled) {
529
+ settled = true;
530
+ clearTimeout(grace);
531
+ resolve$1();
532
+ }
533
+ };
534
+ const grace = setTimeout(() => {
535
+ signal("SIGKILL");
536
+ setTimeout(finish, KILL_SETTLE_MS);
537
+ }, TERM_GRACE_MS);
538
+ close.then(() => {
539
+ if (!signal(0)) finish();
540
+ });
32
541
  });
542
+ const cancel = () => {
543
+ if (!finished) {
544
+ cancelled = true;
545
+ reap();
546
+ }
547
+ };
33
548
  child.stdin.on("error", () => {});
34
- if (opts.stdin !== void 0) child.stdin.write(opts.stdin);
35
- child.stdin.end();
36
- let termTimer;
37
- let killTimer;
38
- const clearTimers = () => {
39
- if (termTimer !== void 0) clearTimeout(termTimer);
40
- if (killTimer !== void 0) clearTimeout(killTimer);
549
+ child.once("error", (error) => {
550
+ diagnostic = `exec: spawn failed — ${error.message}${options.cwd === void 0 ? "" : ` (cwd: ${options.cwd})`}`;
551
+ });
552
+ const done = new Promise((resolve$1) => {
553
+ child.once("close", async (exit) => {
554
+ closed();
555
+ if (timer) clearTimeout(timer);
556
+ await reap();
557
+ finished = true;
558
+ resolve$1({
559
+ exit: diagnostic ? null : exit,
560
+ timedOut,
561
+ cancelled,
562
+ durationMs: Date.now() - started
563
+ });
564
+ });
565
+ });
566
+ if (options.timeoutSec !== void 0) timer = setTimeout(() => {
567
+ timedOut = true;
568
+ cancel();
569
+ }, options.timeoutSec * 1e3);
570
+ return {
571
+ cancel,
572
+ done,
573
+ stdout: child.stdout,
574
+ stderr: child.stderr,
575
+ stdin: child.stdin,
576
+ diagnostic: () => diagnostic
41
577
  };
42
- if (opts.timeoutSec !== void 0) {
43
- const killGroup = (sig) => {
44
- if (child.pid === void 0) {
45
- child.kill(sig);
46
- return;
47
- }
48
- try {
49
- process.kill(-child.pid, sig);
50
- } catch {}
578
+ }
579
+ /** Text adapter over the same owned process-group lifecycle as binary pipelines. */
580
+ function startOneShot(argv, options = {}) {
581
+ const child = startBinaryProcess(argv, options);
582
+ const byteMode = options.unit === "bytes";
583
+ const stdout = byteMode ? new ByteTailRing() : new Utf16TailRing();
584
+ const stderr = byteMode ? new ByteTailRing() : new Utf16TailRing();
585
+ for (const [stream, ring] of [[child.stdout, stdout], [child.stderr, stderr]]) {
586
+ if (!byteMode) stream.setEncoding("utf8");
587
+ stream.on("data", (chunk) => {
588
+ if (ring instanceof ByteTailRing) ring.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
589
+ else ring.push(String(chunk));
590
+ options.onOutput?.();
591
+ });
592
+ }
593
+ child.stdin.end(options.stdin);
594
+ return {
595
+ cancel: child.cancel,
596
+ done: child.done,
597
+ read(channel, fromByte) {
598
+ const ring = channel === "stdout" ? stdout : stderr;
599
+ const read = ring.read(fromByte), diagnostic = child.diagnostic();
600
+ return {
601
+ ...read,
602
+ stats: ring.stats(byteMode ? Buffer.byteLength(read.text) : read.text.length),
603
+ ...channel === "stderr" && diagnostic ? { diagnostic } : {}
604
+ };
605
+ }
606
+ };
607
+ }
608
+
609
+ //#endregion
610
+ //#region src/remote/native-copy.ts
611
+ const OUTPUT_LIMIT$1 = 512 * 1024;
612
+ var ArgvError = class extends Error {
613
+ constructor(field, reason) {
614
+ super(`remote: ${field} must be an array of non-empty string tokens without NUL (${reason})`);
615
+ }
616
+ };
617
+ /** Ordered native option tokens; one element = one argv token, never shell-parsed. */
618
+ function checkArgv(field, value) {
619
+ if (value === void 0) return [];
620
+ if (!Array.isArray(value)) throw new ArgvError(field, "got a non-array");
621
+ for (const token of value) {
622
+ if (typeof token !== "string" || token.length === 0) throw new ArgvError(field, "empty or non-string element");
623
+ if (token.includes("\0")) throw new ArgvError(field, "NUL byte in element");
624
+ if (token === "--") throw new ArgvError(field, "a bare -- would terminate option parsing and let later tokens act as extra source operands; src/dst cannot be overridden via argv");
625
+ }
626
+ return [...value];
627
+ }
628
+ /** scp -R re-authenticates to the destination FROM the source host, breaking the identities-stay-on-origin boundary. */
629
+ function rejectRemoteToRemoteScp(argv) {
630
+ const boundary = "remote: scp -R would authenticate to the destination from the source host; identities must stay on the origin (scp -3 through-origin is the supported route)";
631
+ for (const token of argv) {
632
+ if (token === "-R" || token === "--remote-to-remote" || token === "--r") throw new Error(boundary);
633
+ if (token.startsWith("-") && !token.startsWith("--") && token.slice(1).includes("R")) throw new Error(boundary);
634
+ }
635
+ }
636
+ /** Run one native CLI with group-owned lifecycle; output bounded, exit verbatim.
637
+ * `env` overlays process env (RSYNC_RSH wiring); values are tool-built argv, never model text. */
638
+ async function runNative(argv, signal, env = {}) {
639
+ signal.throwIfAborted();
640
+ const child = startBinaryProcess(argv, { env: {
641
+ ...process.env,
642
+ ...env
643
+ } });
644
+ const cancel = () => child.cancel();
645
+ signal.addEventListener("abort", cancel, { once: true });
646
+ let stdout = "", stderr = "", truncated = false;
647
+ child.stdout.setEncoding("utf8");
648
+ child.stderr.setEncoding("utf8");
649
+ child.stdout.on("data", (text) => {
650
+ const value = stdout + text;
651
+ if (value.length > OUTPUT_LIMIT$1) truncated = true;
652
+ stdout = value.slice(-OUTPUT_LIMIT$1);
653
+ });
654
+ child.stderr.on("data", (text) => {
655
+ const value = stderr + text;
656
+ if (value.length > OUTPUT_LIMIT$1) truncated = true;
657
+ stderr = value.slice(-OUTPUT_LIMIT$1);
658
+ });
659
+ try {
660
+ const result = await child.done;
661
+ const diagnostic = child.diagnostic();
662
+ return {
663
+ exit: result.exit,
664
+ stdout,
665
+ stderr: diagnostic !== void 0 ? (stderr + "\n" + diagnostic).slice(-OUTPUT_LIMIT$1) : stderr,
666
+ outputTruncated: truncated || diagnostic !== void 0
51
667
  };
52
- termTimer = setTimeout(() => {
53
- timedOut = true;
54
- killGroup("SIGTERM");
55
- killTimer = setTimeout(() => killGroup("SIGKILL"), 2e3);
56
- }, opts.timeoutSec * 1e3);
57
- }
58
- const exit = await new Promise((resolve$1) => {
59
- child.once("close", (code) => resolve$1(code));
60
- child.once("error", (err) => {
61
- stderr += `\n[remote: spawn failed: ${err.message}]`;
62
- resolve$1(null);
668
+ } finally {
669
+ signal.removeEventListener("abort", cancel);
670
+ }
671
+ }
672
+ /** scp spec keeps identity in argv/config; caller -F/-i/-P override by scp's own precedence. */
673
+ function scpSpec(connection, path$1) {
674
+ const host = connection.host.includes(":") ? `[${connection.host}]` : connection.host;
675
+ return `${connection.user !== void 0 ? `${connection.user}@${host}` : host}:${path$1}`;
676
+ }
677
+ /** Connection identity as scp options (port, key), placed BEFORE caller argv so
678
+ * an explicit caller -F/-i/-P/-J overrides the registered identity by scp's own
679
+ * precedence (last option wins). */
680
+ function identityArgv(connection) {
681
+ const options = ["-o", "BatchMode=yes"];
682
+ if (connection.port !== void 0) options.push("-P", String(connection.port));
683
+ if (connection.key !== void 0) options.push("-i", connection.key);
684
+ if (connection.proxyJump !== void 0) options.push("-J", connection.proxyJump);
685
+ return options;
686
+ }
687
+ function sshEndpoint(plan) {
688
+ if (plan.kind !== "ssh") throw new Error("remote: native copy endpoint is not an SSH target");
689
+ return { connection: plan.connection ?? { host: plan.host } };
690
+ }
691
+ /** Container name for docker/incus endpoints (narrows without casts). */
692
+ function containerOf(plan) {
693
+ if (plan.kind !== "docker" && plan.kind !== "incus") throw new Error("remote: native copy endpoint is not a container target");
694
+ return plan.container;
695
+ }
696
+ /**
697
+ * One native copy.
698
+ * - local↔SSH: scp; SSH↔SSH: scp -3 through origin (both identities here; when
699
+ * they differ a temp origin-only config maps two synthetic aliases)
700
+ * - local↔docker: docker cp; local↔incus: incus file push/pull
701
+ * - every other cross-endpoint pair: two native commands with origin staging,
702
+ * each segment with its own options (pullArgv/pushArgv)
703
+ */
704
+ async function nativeCopy(request, endpoints, signal, options = {}) {
705
+ signal.throwIfAborted();
706
+ const oneShot = checkArgv("argv", request.argv);
707
+ const pull = checkArgv("pullArgv", request.pullArgv);
708
+ const push = checkArgv("pushArgv", request.pushArgv);
709
+ if (oneShot.length > 0 && (pull.length > 0 || push.length > 0)) throw new Error("remote: argv cannot be combined with pullArgv/pushArgv (argv is for single-command routes; pullArgv/pushArgv for two-segment routes)");
710
+ if (request.pullArgv !== void 0 && request.pushArgv === void 0) throw new Error("remote: pullArgv requires pushArgv (a two-segment copy has both halves)");
711
+ if (request.pushArgv !== void 0 && request.pullArgv === void 0) throw new Error("remote: pushArgv requires pullArgv (a two-segment copy has both halves)");
712
+ const wrap = options.wrapArgv ?? ((argv) => argv);
713
+ const s = endpoints.src.plan.kind, t = endpoints.dst.plan.kind;
714
+ const receipt = {
715
+ action: "copy",
716
+ src: request.src,
717
+ dst: request.dst,
718
+ route: "",
719
+ argv: [],
720
+ exits: [],
721
+ stdout: [],
722
+ stderr: [],
723
+ outputTruncated: false,
724
+ verified: false,
725
+ cleanup: "none-required"
726
+ };
727
+ const finish = (route, argvs, results) => {
728
+ receipt.route = route;
729
+ receipt.argv = argvs;
730
+ receipt.exits = results.map((result) => result.exit);
731
+ receipt.stdout = results.map((result) => result.stdout);
732
+ receipt.stderr = results.map((result) => result.stderr);
733
+ receipt.outputTruncated = results.some((result) => result.outputTruncated);
734
+ return receipt;
735
+ };
736
+ if (s === "local" && t === "ssh") {
737
+ rejectRemoteToRemoteScp(oneShot);
738
+ const connection = sshEndpoint(endpoints.dst.plan).connection;
739
+ const argv = [
740
+ "scp",
741
+ ...identityArgv(connection),
742
+ ...oneShot,
743
+ endpoints.src.path,
744
+ scpSpec(connection, endpoints.dst.path)
745
+ ];
746
+ return finish("scp", [argv], [await runNative(wrap(argv), signal)]);
747
+ }
748
+ if (s === "ssh" && t === "local") {
749
+ rejectRemoteToRemoteScp(oneShot);
750
+ const connection = sshEndpoint(endpoints.src.plan).connection;
751
+ const argv = [
752
+ "scp",
753
+ ...identityArgv(connection),
754
+ ...oneShot,
755
+ scpSpec(connection, endpoints.src.path),
756
+ endpoints.dst.path
757
+ ];
758
+ return finish("scp", [argv], [await runNative(wrap(argv), signal)]);
759
+ }
760
+ if (s === "ssh" && t === "ssh") {
761
+ rejectRemoteToRemoteScp(oneShot);
762
+ const source = sshEndpoint(endpoints.src.plan).connection, destination = sshEndpoint(endpoints.dst.plan).connection;
763
+ const sameIdentity = JSON.stringify(source) === JSON.stringify(destination);
764
+ let config;
765
+ let unknown = false;
766
+ try {
767
+ let srcSpec, dstSpec;
768
+ let extra = [];
769
+ if (sameIdentity) {
770
+ srcSpec = scpSpec(source, endpoints.src.path);
771
+ dstSpec = scpSpec(destination, endpoints.dst.path);
772
+ extra = identityArgv(source);
773
+ } else {
774
+ config = await tempSshConfig(source, destination);
775
+ extra = ["-F", config.path];
776
+ srcSpec = `superd-scp3-source:${endpoints.src.path}`;
777
+ dstSpec = `superd-scp3-destination:${endpoints.dst.path}`;
778
+ }
779
+ const argv = [
780
+ "scp",
781
+ "-3",
782
+ ...extra,
783
+ ...oneShot,
784
+ srcSpec,
785
+ dstSpec
786
+ ];
787
+ return finish("scp -3 (data through origin)", [argv], [await runNative(wrap(argv), signal)]);
788
+ } catch (error) {
789
+ if (signal.aborted) unknown = true;
790
+ throw error;
791
+ } finally {
792
+ if (config !== void 0) receipt.cleanup = !await rm(config.dir, {
793
+ recursive: true,
794
+ force: true
795
+ }).then(() => true, () => false) || unknown ? "unknown" : "released";
796
+ }
797
+ }
798
+ if (s === "local" && t === "docker") {
799
+ const argv = [
800
+ "docker",
801
+ "cp",
802
+ ...oneShot,
803
+ endpoints.src.path,
804
+ `${containerOf(endpoints.dst.plan)}:${endpoints.dst.path}`
805
+ ];
806
+ return finish("docker cp", [argv], [await runNative(wrap(argv), signal)]);
807
+ }
808
+ if (s === "docker" && t === "local") {
809
+ const argv = [
810
+ "docker",
811
+ "cp",
812
+ ...oneShot,
813
+ `${containerOf(endpoints.src.plan)}:${endpoints.src.path}`,
814
+ endpoints.dst.path
815
+ ];
816
+ return finish("docker cp", [argv], [await runNative(wrap(argv), signal)]);
817
+ }
818
+ if (s === "local" && t === "incus") {
819
+ const argv = [
820
+ "incus",
821
+ "file",
822
+ "push",
823
+ ...oneShot,
824
+ endpoints.src.path,
825
+ `${containerOf(endpoints.dst.plan)}${endpoints.dst.path}`
826
+ ];
827
+ return finish("incus file push", [argv], [await runNative(wrap(argv), signal)]);
828
+ }
829
+ if (s === "incus" && t === "local") {
830
+ const argv = [
831
+ "incus",
832
+ "file",
833
+ "pull",
834
+ ...oneShot,
835
+ `${containerOf(endpoints.src.plan)}${endpoints.src.path}`,
836
+ endpoints.dst.path
837
+ ];
838
+ return finish("incus file pull", [argv], [await runNative(wrap(argv), signal)]);
839
+ }
840
+ if (s === "local" || t === "local") throw new Error("remote: this endpoint pair is a single native command route; pullArgv/pushArgv apply to cross-endpoint pairs only");
841
+ const pullOptions = request.pullArgv !== void 0 ? pull : oneShot;
842
+ const pushOptions = request.pushArgv !== void 0 ? push : oneShot;
843
+ const scratch = await mkdtemp(join(options.scratchRoot ?? join(process.cwd(), ".scratch"), "remote-native-copy-"));
844
+ const staged = join(scratch, "staged");
845
+ try {
846
+ rejectRemoteToRemoteScp(pullOptions);
847
+ rejectRemoteToRemoteScp(pushOptions);
848
+ const pullArgv = segmentArgv(endpoints.src.plan, endpoints.src.path, staged, pullOptions, "pull");
849
+ const pushArgv = segmentArgv(endpoints.dst.plan, endpoints.dst.path, staged, pushOptions, "push");
850
+ const first = await runNative(wrap(pullArgv), signal);
851
+ if (first.exit !== 0) return finish("two native commands with origin staging (push not started: pull failed)", [pullArgv], [first]);
852
+ const second = await runNative(wrap(pushArgv), signal);
853
+ return finish("two native commands with origin staging", [pullArgv, pushArgv], [first, second]);
854
+ } finally {
855
+ receipt.cleanup = await rm(scratch, {
856
+ recursive: true,
857
+ force: true
858
+ }).then(() => true, () => false) && !signal.aborted ? "released" : "unknown";
859
+ }
860
+ }
861
+ /** One segment of a two-segment route: pull remote→staging, or push staging→remote. */
862
+ function segmentArgv(plan, remote, staged, opts, leg) {
863
+ if (plan.kind === "local") throw new Error("remote: two-segment staging connects two CLI peers, not a local path");
864
+ if (plan.kind === "ssh") {
865
+ const connection = sshEndpoint(plan).connection;
866
+ const spec$1 = scpSpec(connection, remote);
867
+ return leg === "pull" ? [
868
+ "scp",
869
+ ...opts,
870
+ ...identityArgv(connection),
871
+ spec$1,
872
+ staged
873
+ ] : [
874
+ "scp",
875
+ ...opts,
876
+ ...identityArgv(connection),
877
+ staged,
878
+ spec$1
879
+ ];
880
+ }
881
+ if (plan.kind === "docker") {
882
+ const spec$1 = `${plan.container}:${remote}`;
883
+ return leg === "pull" ? [
884
+ "docker",
885
+ "cp",
886
+ ...opts,
887
+ spec$1,
888
+ staged
889
+ ] : [
890
+ "docker",
891
+ "cp",
892
+ ...opts,
893
+ staged,
894
+ spec$1
895
+ ];
896
+ }
897
+ const spec = `${plan.container}${remote}`;
898
+ return leg === "pull" ? [
899
+ "incus",
900
+ "file",
901
+ "pull",
902
+ ...opts,
903
+ spec,
904
+ staged
905
+ ] : [
906
+ "incus",
907
+ "file",
908
+ "push",
909
+ ...opts,
910
+ staged,
911
+ spec
912
+ ];
913
+ }
914
+ /** Distinguishing identities for scp -3: an origin-only temp config maps two
915
+ * synthetic aliases; keys are referenced in place, never copied to any remote. */
916
+ async function tempSshConfig(source, destination) {
917
+ for (const c of [source, destination]) for (const [field, value] of [
918
+ ["host", c.host],
919
+ ["user", c.user],
920
+ ["key", c.key],
921
+ ["proxyJump", c.proxyJump]
922
+ ]) if (value !== void 0 && /[\r\n]/.test(value)) throw new Error(`remote: scp -3 temp config rejects newline in SSH ${field} (no config injection)`);
923
+ const dir = await mkdtemp(join(tmpdir(), "superd-scp3-"));
924
+ const path$1 = join(dir, "config");
925
+ const block = (name$1, c) => {
926
+ const lines = [`Host ${name$1}`, ` HostName ${c.host.replace(/^.*@/, "")}`];
927
+ if (c.user !== void 0) lines.push(` User ${c.user}`);
928
+ if (c.port !== void 0) lines.push(` Port ${String(c.port)}`);
929
+ if (c.key !== void 0) lines.push(` IdentityFile ${c.key}`);
930
+ if (c.proxyJump !== void 0) lines.push(` ProxyJump ${c.proxyJump}`);
931
+ return lines.join("\n");
932
+ };
933
+ await writeFile(path$1, `${block("superd-scp3-source", source)}\n${block("superd-scp3-destination", destination)}\n`, { mode: 384 });
934
+ await chmod(path$1, 384);
935
+ return {
936
+ path: path$1,
937
+ dir
938
+ };
939
+ }
940
+
941
+ //#endregion
942
+ //#region src/remote/ssh-rsync-native-relay.ts
943
+ /** ssh argv for the relay: batch, no agent, no multiplexing. `command` is the
944
+ * remote command; forwarding options go before `--`, the host after it. */
945
+ function relaySsh(connection, extra, command) {
946
+ const args = [
947
+ "ssh",
948
+ "-o",
949
+ "BatchMode=yes",
950
+ "-T",
951
+ "-a",
952
+ "-o",
953
+ "ForwardAgent=no",
954
+ "-o",
955
+ "ControlMaster=no",
956
+ "-o",
957
+ "ControlPath=none",
958
+ "-o",
959
+ "ConnectTimeout=10",
960
+ "-o",
961
+ "ServerAliveInterval=5",
962
+ "-o",
963
+ "ServerAliveCountMax=2"
964
+ ];
965
+ if (connection.user !== void 0) args.push("-l", connection.user);
966
+ if (connection.port !== void 0) args.push("-p", String(connection.port));
967
+ if (connection.key !== void 0) args.push("-i", connection.key);
968
+ if (connection.proxyJump !== void 0) args.push("-J", connection.proxyJump);
969
+ args.push(...extra, "--", connection.host);
970
+ if (command !== void 0) args.push(command);
971
+ return args;
972
+ }
973
+ async function freePorts(count) {
974
+ const servers = [];
975
+ try {
976
+ for (let i = 0; i < count; i++) {
977
+ const server = createServer();
978
+ const { promise, resolve: resolve$1, reject } = Promise.withResolvers();
979
+ server.once("error", reject);
980
+ server.listen(0, "127.0.0.1", resolve$1);
981
+ await promise;
982
+ servers.push(server);
983
+ }
984
+ const ports = servers.map((server) => server.address().port);
985
+ await Promise.all(servers.map((server) => new Promise((resolve$1) => server.close(() => resolve$1()))));
986
+ return ports;
987
+ } finally {
988
+ await Promise.all(servers.map((server) => new Promise((resolve$1) => server.close(() => resolve$1()))));
989
+ }
990
+ }
991
+ async function originPortOpen(port, timeoutMs) {
992
+ const until = Date.now() + timeoutMs;
993
+ for (;;) {
994
+ const { promise, resolve: resolve$1 } = Promise.withResolvers();
995
+ const socket = createConnection({
996
+ host: "127.0.0.1",
997
+ port
998
+ });
999
+ socket.once("connect", () => {
1000
+ socket.destroy();
1001
+ resolve$1(true);
1002
+ });
1003
+ socket.once("error", () => {
1004
+ socket.destroy();
1005
+ resolve$1(false);
1006
+ });
1007
+ if (await promise) return true;
1008
+ if (Date.now() >= until) return false;
1009
+ await new Promise((resolve$2) => setTimeout(resolve$2, 50));
1010
+ }
1011
+ }
1012
+ /**
1013
+ * Dual-SSH rsync. Forwarding route first; on forwarding denial the stdio
1014
+ * route; if both are unavailable the combined facts are reported — never a
1015
+ * full-copy fallback.
1016
+ */
1017
+ async function relayRsync(argv, source, destination, signal, options = {}) {
1018
+ if (options.forceStdioRoute !== true) try {
1019
+ return await forwardRoute(argv, source, destination, signal, options);
1020
+ } catch (error) {
1021
+ if (signal.aborted) throw signal.reason;
1022
+ if (!forwardingDenied(error)) throw error;
1023
+ try {
1024
+ return await stdioRoute(argv, source, destination, signal, options);
1025
+ } catch (stdioError) {
1026
+ if (signal.aborted) throw signal.reason;
1027
+ throw new Error(`dual-SSH rsync unavailable: forwarding route denied (${error instanceof Error ? error.message : String(error)}); stdio route failed (${stdioError instanceof Error ? stdioError.message : String(stdioError)}); no full-copy fallback is performed`, { cause: stdioError });
1028
+ }
1029
+ }
1030
+ return stdioRoute(argv, source, destination, signal, options);
1031
+ }
1032
+ /** Forwarding was actually refused by an endpoint (admin policy), not any other failure. */
1033
+ function forwardingDenied(error) {
1034
+ const text = error instanceof Error ? `${error.message}\n${String(error.cause ?? "")}` : String(error);
1035
+ return /administratively prohibited|forwarding failed|could not request (local|remote) forwarding|AllowTcpForwarding/i.test(text);
1036
+ }
1037
+ const OUTPUT_LIMIT = 512 * 1024;
1038
+ /** Bounded stdout/stderr capture for a child stream. */
1039
+ function capture(stream, sink) {
1040
+ const onText = (text) => {
1041
+ const value = sink.text + text;
1042
+ if (value.length > OUTPUT_LIMIT) sink.truncated = true;
1043
+ sink.text = value.slice(-OUTPUT_LIMIT);
1044
+ };
1045
+ stream?.setEncoding("utf8");
1046
+ stream?.on("data", onText);
1047
+ return onText;
1048
+ }
1049
+ /** A held SSH child (forward/daemon carrier) with group shutdown and stderr. */
1050
+ var Held = class {
1051
+ child;
1052
+ done;
1053
+ stderrSink = {
1054
+ text: "",
1055
+ truncated: false
1056
+ };
1057
+ stopped = false;
1058
+ constructor(argv, signal) {
1059
+ this.signal = signal;
1060
+ signal.throwIfAborted();
1061
+ this.child = spawn(argv[0], argv.slice(1), {
1062
+ stdio: [
1063
+ "ignore",
1064
+ "pipe",
1065
+ "pipe"
1066
+ ],
1067
+ detached: true
1068
+ });
1069
+ capture(this.child.stderr, this.stderrSink);
1070
+ this.done = new Promise((resolve$1) => {
1071
+ this.child.once("close", () => resolve$1());
63
1072
  });
1073
+ }
1074
+ async stop() {
1075
+ if (this.stopped) {
1076
+ await this.done;
1077
+ return;
1078
+ }
1079
+ this.stopped = true;
1080
+ if (this.child.pid !== void 0) try {
1081
+ process.kill(-this.child.pid, "SIGTERM");
1082
+ } catch {}
1083
+ await Promise.race([this.done, new Promise((resolve$1) => setTimeout(resolve$1, 3e3))]);
1084
+ if (this.child.exitCode === null && this.child.signalCode === null && this.child.pid !== void 0) try {
1085
+ process.kill(-this.child.pid, "SIGKILL");
1086
+ } catch {}
1087
+ await this.done;
1088
+ }
1089
+ };
1090
+ /**
1091
+ * Route A. Three SSH sessions, all origin-held:
1092
+ * 1. destination: -L + daemon script (daemon binds destination loopback only)
1093
+ * 2. source hold: -N -R maps origin daemon port onto source loopback
1094
+ * 3. source run: executes the native rsync client against its own loopback
1095
+ * rsync protocol bytes traverse source → origin → destination; nothing is
1096
+ * staged on the origin.
1097
+ */
1098
+ async function forwardRoute(argv, source, destination, signal, options) {
1099
+ signal.throwIfAborted();
1100
+ const wrap = options.wrapArgv ?? ((value) => value);
1101
+ const ports = options.ports?.daemon !== void 0 ? [options.ports.daemon, ...await freePorts(2)] : await freePorts(3);
1102
+ const daemonPort = ports[0], originPort = ports[1], sourcePort = ports[2];
1103
+ const destinationArgv = relaySsh(destination.connection, [
1104
+ "-o",
1105
+ "ExitOnForwardFailure=yes",
1106
+ "-L",
1107
+ `127.0.0.1:${originPort}:127.0.0.1:${daemonPort}`
1108
+ ], daemonCommand(destination.path, daemonPort));
1109
+ const sourceHoldArgv = relaySsh(source.connection, [
1110
+ "-o",
1111
+ "ExitOnForwardFailure=yes",
1112
+ "-N",
1113
+ "-R",
1114
+ `127.0.0.1:${sourcePort}:127.0.0.1:${originPort}`
1115
+ ]);
1116
+ const destinationChild = new Held(wrap(destinationArgv), signal);
1117
+ const sourceHold = new Held(wrap(sourceHoldArgv), signal);
1118
+ const remoteTempOf = (held) => /^SUPERD-RSYNC-TMP (\S+)$/m.exec(held.stderrSink.text)?.[1];
1119
+ const settle = async () => {
1120
+ await Promise.all([destinationChild.stop(), sourceHold.stop()]);
1121
+ const temp = remoteTempOf(destinationChild);
1122
+ if (temp === void 0) return "unknown";
1123
+ return await removeRemoteTemp(destination.connection, temp, wrap) ? "released" : "unknown";
1124
+ };
1125
+ try {
1126
+ signal.throwIfAborted();
1127
+ if (!await originPortOpen(originPort, 15e3)) throw new Error(`dual-SSH rsync destination daemon never became reachable${relayDetail(destinationChild, sourceHold)}`);
1128
+ signal.throwIfAborted();
1129
+ const runArgv = relaySsh(source.connection, [], rsyncClientCommand(argv, source.path, sourcePort));
1130
+ const result = await runNative(wrap(runArgv), signal);
1131
+ if (result.exit === 255) result.stderr = (result.stderr + relayDetail(destinationChild, sourceHold)).slice(-OUTPUT_LIMIT);
1132
+ if (result.exit !== 0 && /remote port forwarding failed|administratively prohibited|could not request (local|remote) forwarding/.test(relayDetail(destinationChild, sourceHold))) throw new Error(`dual-SSH rsync forwarding route refused by an endpoint: ${relayDetail(destinationChild, sourceHold)}`);
1133
+ return {
1134
+ ...result,
1135
+ route: "ssh-ssh-forward",
1136
+ cleanup: await settle(),
1137
+ sshArgv: [
1138
+ destinationArgv,
1139
+ sourceHoldArgv,
1140
+ runArgv
1141
+ ]
1142
+ };
1143
+ } catch (error) {
1144
+ await settle().catch(() => "unknown");
1145
+ if (signal.aborted) throw signal.reason;
1146
+ const detail = relayDetail(destinationChild, sourceHold);
1147
+ if (detail) {
1148
+ const message = error instanceof Error ? error.message : String(error);
1149
+ throw new Error(`${message}${message.includes("[relay stderr]") ? "" : detail}`, { cause: error });
1150
+ }
1151
+ throw error;
1152
+ } finally {
1153
+ await Promise.allSettled([destinationChild.stop(), sourceHold.stop()]);
1154
+ }
1155
+ }
1156
+ function relayDetail(...held) {
1157
+ const detail = held.map((child) => child.stderrSink.text).filter(Boolean).join("\n");
1158
+ return detail ? `\n[relay stderr]\n${detail}` : "";
1159
+ }
1160
+ function daemonCommand(targetPath, port) {
1161
+ if (/[\r\n]/.test(targetPath)) throw new Error("dual-SSH rsync refuses newline-bearing destination paths (no daemon config injection)");
1162
+ return `set -eu
1163
+ [ -d ${shellQuote(targetPath)} ] || { echo 'dual-SSH rsync destination directory does not exist' >&2; exit 64; }
1164
+ umask 077
1165
+ tmp=$(mktemp -d "\${TMPDIR:-/tmp}/superd-rsync.XXXXXXXX")
1166
+ # The origin reads this marker from held stderr and stops the daemon + removes
1167
+ # the directory over a dedicated cleanup session: killing the local ssh cannot
1168
+ # rely on remote traps (a dropped connection terminates the remote session
1169
+ # without HUP), and an unreaped daemon keeps its loopback port open.
1170
+ printf 'SUPERD-RSYNC-TMP %s\\n' "$tmp" >&2
1171
+ printf '%s\\n' 'use chroot = no' 'read only = no' 'munge symlinks = yes' 'hosts allow = 127.0.0.1' 'hosts deny = *' 'pid file = '"$tmp"'/rsyncd.pid' '[task]' ${shellQuote("path = " + targetPath)} > "$tmp/rsyncd.conf"
1172
+ exec rsync --daemon --no-detach --address=127.0.0.1 --port=${port} --config="$tmp/rsyncd.conf"
1173
+ `;
1174
+ }
1175
+ /** Best-effort teardown of the remote daemon (pidfile kill) and its temp dir
1176
+ * over its own SSH session; identities stay origin-held. Returns whether the
1177
+ * whole teardown is confirmed. */
1178
+ async function removeRemoteTemp(connection, path$1, wrap) {
1179
+ const controller = new AbortController();
1180
+ const timer = setTimeout(() => controller.abort(), 1e4);
1181
+ const script = `if [ -f ${shellQuote(path$1 + "/rsyncd.pid")} ]; then
1182
+ p=$(cat ${shellQuote(path$1 + "/rsyncd.pid")})
1183
+ kill "$p" 2>/dev/null || :
1184
+ i=0; while kill -0 "$p" 2>/dev/null && [ "$i" -lt 20 ]; do sleep 0.1; i=$((i+1)); done
1185
+ kill -9 "$p" 2>/dev/null || :
1186
+ fi
1187
+ rm -rf ${shellQuote(path$1)}`;
1188
+ try {
1189
+ return (await runNative(wrap(relaySsh(connection, [], script)), controller.signal)).exit === 0;
1190
+ } catch {
1191
+ return false;
1192
+ } finally {
1193
+ clearTimeout(timer);
1194
+ }
1195
+ }
1196
+ function rsyncClientCommand(argv, sourcePath, port) {
1197
+ return `exec rsync ${argv.map(shellQuote).join(" ")} -- ${shellQuote(sourcePath)} rsync://127.0.0.1:${port}/task/`;
1198
+ }
1199
+ function shellQuote(value) {
1200
+ return /[^A-Za-z0-9_@%+=:,./-]/.test(value) ? `'${value.replaceAll("'", `'\\''`)}'` : value;
1201
+ }
1202
+ /**
1203
+ * Route B (empirical verdict): a Node bidirectional bridge of two SSH exec
1204
+ * standard streams carrying one rsync protocol conversation. The design was
1205
+ * validated with local processes (source rsync client + fd-dup adapter +
1206
+ * `rsync --server --daemon .` receiver, full transfer with real delta), but
1207
+ * under real OpenSSH sessions the source-side combination (rsync client in an
1208
+ * exec session + fd-dup adapter) makes ssh tear down the session's stdin
1209
+ * channel ("channel 0: read failed ... Broken pipe" then EOF), starving the
1210
+ * protocol's return path before any byte of rsync protocol flows. Shell-level
1211
+ * adjustments (closing fd 0 for the client, dropping -T/keepalives) did not
1212
+ * change this on OpenSSH 10.2 + rsync 3.4.1. Per the spec this route reports
1213
+ * itself unavailable on such combinations instead of degrading into two
1214
+ * ordinary rsyncs or a full copy: the origin would stage the whole source and
1215
+ * the destination's existing data could not feed delta matching.
1216
+ */
1217
+ async function stdioRoute(argv, source, destination, signal, options) {
1218
+ signal.throwIfAborted();
1219
+ throw new Error("dual-SSH rsync stdio route unavailable on this SSH/rsync combination: the source-side rsync client with a stdio adapter causes the SSH session stdin channel to be torn down before the rsync protocol starts (validated locally without SSH; fails under OpenSSH 10.2 + rsync 3.4.1). No full-copy fallback is performed");
1220
+ }
1221
+
1222
+ //#endregion
1223
+ //#region src/remote/rsync.ts
1224
+ /**
1225
+ * Native rsync with ordered caller options. Local↔SSH runs rsync directly with
1226
+ * the endpoint's SSH identity wired as RSYNC_RSH; explicit -e/--rsh in argv
1227
+ * overrides that wiring per rsync's own rules (the receipt shows the argv).
1228
+ * SSH↔SSH delegates to the native relay routes (forwarding first, stdio second).
1229
+ * No option is injected: no archive, no timeout, no dry-run, no remote owner.
1230
+ */
1231
+ async function runRsync(request, source, destination, signal, options = {}) {
1232
+ signal.throwIfAborted();
1233
+ checkAbsolute(source.path);
1234
+ checkAbsolute(destination.path);
1235
+ const argv = checkArgv("argv", request.argv);
1236
+ if ("pullArgv" in request || "pushArgv" in request) throw new Error("remote: rsync takes argv only; pullArgv/pushArgv are copy-only fields");
1237
+ for (const legacy of [
1238
+ "dryRun",
1239
+ "delete",
1240
+ "excludes",
1241
+ "preserve",
1242
+ "verify",
1243
+ "mkdir"
1244
+ ]) if (legacy in request) throw new Error(`remote: rsync field "${legacy}" was removed — pass native options through argv instead (e.g. "--dry-run", "--delete", "--exclude=<pattern>", "-a"/"-r"; verify/mkdir belong to the copy Hub/local lane)`);
1245
+ if (!source.connection && !destination.connection) throw new Error("rsync requires at least one SSH endpoint; local-only is unsupported");
1246
+ let result;
1247
+ let route;
1248
+ if (source.connection && destination.connection) {
1249
+ const relayed = await relayRsync(argv, {
1250
+ connection: source.connection,
1251
+ path: source.path
1252
+ }, {
1253
+ connection: destination.connection,
1254
+ path: destination.path
1255
+ }, signal, options);
1256
+ result = relayed;
1257
+ route = relayed.route;
1258
+ } else {
1259
+ route = "local-ssh";
1260
+ const remote = source.connection ?? destination.connection;
1261
+ const wrap = options.wrapArgv ?? ((value) => value);
1262
+ const callerRsh = argv.some((token) => token === "-e" || token === "--rsh" || token.startsWith("--rsh=") || /^-[a-zA-Z]+e$/.test(token) && !token.startsWith("--"));
1263
+ const hostSpec = (connection) => {
1264
+ const host = connection.host.includes(":") && !connection.host.startsWith("[") ? `[${connection.host}]` : connection.host;
1265
+ return connection.user !== void 0 ? `${connection.user}@${host}` : host;
1266
+ };
1267
+ const env = callerRsh ? {} : { RSYNC_RSH: rsyncRsh(remote, wrap) };
1268
+ const src = source.connection ? callerRsh ? `${hostSpec(source.connection)}:${source.path}` : `rsync-native:${source.path}` : source.path;
1269
+ const dst = destination.connection ? callerRsh ? `${hostSpec(destination.connection)}:${destination.path}` : `rsync-native:${destination.path}` : destination.path;
1270
+ result = await runNative(wrap([
1271
+ "rsync",
1272
+ ...argv,
1273
+ "--",
1274
+ src,
1275
+ dst
1276
+ ]), signal, env);
1277
+ }
1278
+ const number = (label) => {
1279
+ const match = new RegExp(`^${label}: ([\\d,]+)(?: bytes)?$`, "m").exec(result.stdout);
1280
+ return match ? Number(match[1].replaceAll(",", "")) : void 0;
1281
+ };
1282
+ const sentBytes = number("Total bytes sent"), receivedBytes = number("Total bytes received");
1283
+ const literalBytes = number("Literal data"), matchedBytes = number("Matched data");
1284
+ const itemized = result.stdout.split("\n").filter((line) => /^[<>ch.*][fdLDS.]/.test(line) || line.startsWith("*deleting"));
1285
+ return {
1286
+ ...result,
1287
+ action: "rsync",
1288
+ src: request.src,
1289
+ dst: request.dst,
1290
+ argv,
1291
+ route,
1292
+ command: `rsync ${argv.join(" ")}`,
1293
+ ...itemized.length > 0 || argv.includes("--itemize-changes") || argv.includes("-i") ? { itemizedChanges: itemized } : {},
1294
+ ...literalBytes === void 0 ? {} : { literalBytes },
1295
+ ...matchedBytes === void 0 ? {} : { matchedBytes },
1296
+ ...sentBytes === void 0 ? {} : { sentBytes },
1297
+ ...receivedBytes === void 0 ? {} : { receivedBytes },
1298
+ ...sentBytes !== void 0 && receivedBytes !== void 0 ? { transferredBytes: sentBytes + receivedBytes } : {}
1299
+ };
1300
+ }
1301
+ /** RSYNC_RSH value: rsync word-splits this string with its own quote
1302
+ * convention (single quotes, internal quotes doubled); the adapter drops the
1303
+ * placeholder host rsync appends and execs the canonical SSH argv. All tokens
1304
+ * are tool-built argv (identity options/aliases), never model text. */
1305
+ function rsyncRsh(connection, wrap) {
1306
+ const args = [
1307
+ "ssh",
1308
+ "-o",
1309
+ "BatchMode=yes",
1310
+ "-T"
1311
+ ];
1312
+ if (connection.user !== void 0) args.push("-l", connection.user);
1313
+ if (connection.port !== void 0) args.push("-p", String(connection.port));
1314
+ if (connection.key !== void 0) args.push("-i", connection.key);
1315
+ if (connection.proxyJump !== void 0) args.push("-J", connection.proxyJump);
1316
+ args.push("--", connection.host);
1317
+ return [
1318
+ "bash",
1319
+ "-c",
1320
+ `shift; exec ${wrap(args).map(rshQuote).join(" ")} "$@"`,
1321
+ "rsync-rsh"
1322
+ ].map(rshQuote).join(" ");
1323
+ }
1324
+ /** rsync -e/RSYNC_RSH grammar: doubled quotes, not shell backslashes (rsync(1) --rsh). */
1325
+ const rshQuote = (value) => "'" + value.replaceAll("'", "''") + "'";
1326
+
1327
+ //#endregion
1328
+ //#region src/remote/endpoints.ts
1329
+ function parseFileEndpoint(value) {
1330
+ if (typeof value !== "string" || !value) throw new Error("file endpoint required");
1331
+ if (value.startsWith("/")) {
1332
+ checkAbsolute(value);
1333
+ return { path: value };
1334
+ }
1335
+ const split = value.lastIndexOf(":/");
1336
+ if (split < 1) throw new Error("file endpoint must be [target:]absolute/path");
1337
+ const target = value.slice(0, split), path$1 = value.slice(split + 1);
1338
+ checkAbsolute(path$1);
1339
+ if (target.startsWith("byop:")) throw new Error("BYO-PTY has no binary file lane; select SSH/Docker/Incus/Hub");
1340
+ return {
1341
+ target,
1342
+ path: path$1
1343
+ };
1344
+ }
1345
+
1346
+ //#endregion
1347
+ //#region src/remote/ssh-targets.ts
1348
+ const LABEL = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
1349
+ const USER = /^[A-Za-z0-9_][A-Za-z0-9_.-]*$/;
1350
+ const HOST = /^(?:[A-Za-z0-9_][A-Za-z0-9_.-]*@)?(?:[A-Za-z0-9_][A-Za-z0-9_.-]*|\[[A-Fa-f0-9:.]+\]|[A-Fa-f0-9:]+)$/;
1351
+ const JUMP = /^(?:[A-Za-z0-9_][A-Za-z0-9_.-]*@)?(?:[A-Za-z0-9_][A-Za-z0-9_.-]*|\[[A-Fa-f0-9:.]+\])(?::([0-9]+))?$/;
1352
+ function validateSshLabel(label) {
1353
+ if (!LABEL.test(label)) throw new Error("[E_BAD_LABEL] remote: BYO-SSH label must use letters, digits, dot, underscore or hyphen and start with a letter/digit");
1354
+ }
1355
+ function bad(field) {
1356
+ throw new Error(`[E_SSH_PARAMS] remote: invalid SSH ${field} — use host/user/port, an existing origin key file path, and optional ProxyJump aliases or [user@]host[:port] hops; no inline keys or shell options`);
1357
+ }
1358
+ function normalizeSshConnection(input) {
1359
+ if (!input || typeof input !== "object" || Array.isArray(input)) bad("connection");
1360
+ if (Object.keys(input).some((key$1) => ![
1361
+ "host",
1362
+ "user",
1363
+ "port",
1364
+ "key",
1365
+ "proxyJump"
1366
+ ].includes(key$1))) bad("field");
1367
+ if (typeof input.host !== "string" || !HOST.test(input.host)) bad("host");
1368
+ if (input.user !== void 0 && (typeof input.user !== "string" || !USER.test(input.user))) bad("user");
1369
+ if (input.port !== void 0 && (!Number.isInteger(input.port) || input.port < 1 || input.port > 65535)) bad("port");
1370
+ if (input.proxyJump !== void 0) {
1371
+ if (typeof input.proxyJump !== "string" || !input.proxyJump || input.proxyJump.split(",").some((hop) => {
1372
+ const match = JUMP.exec(hop);
1373
+ return !match || match[1] !== void 0 && (Number(match[1]) < 1 || Number(match[1]) > 65535);
1374
+ })) bad("proxyJump");
1375
+ }
1376
+ let key;
1377
+ if (input.key !== void 0) {
1378
+ if (typeof input.key !== "string" || !input.key || /[\0\r\n]/.test(input.key)) bad("key");
1379
+ key = input.key.startsWith("~/") ? join(homedir(), input.key.slice(2)) : resolve(input.key);
1380
+ try {
1381
+ if (!statSync(key).isFile()) bad("key");
1382
+ } catch {
1383
+ bad("key file (must exist on the origin)");
1384
+ }
1385
+ }
1386
+ return Object.freeze({
1387
+ host: input.host,
1388
+ ...input.user === void 0 ? {} : { user: input.user },
1389
+ ...input.port === void 0 ? {} : { port: input.port },
1390
+ ...key === void 0 ? {} : { key },
1391
+ ...input.proxyJump === void 0 ? {} : { proxyJump: input.proxyJump }
64
1392
  });
65
- clearTimers();
1393
+ }
1394
+ /** One instance per Agent-owned driver; destroyed with its Agent/plugin owners. */
1395
+ var SshTargets = class {
1396
+ targets = /* @__PURE__ */ new Map();
1397
+ register(label, connection) {
1398
+ validateSshLabel(label);
1399
+ const normalized = normalizeSshConnection(connection), existing = this.targets.get(label);
1400
+ if (existing && JSON.stringify(existing) !== JSON.stringify(normalized)) throw new Error(`[E_LABEL_CONFLICT] remote: byossh:${label} SSH identity conflict — nothing was run. Reuse the label without ssh, or choose another label; existing credentials were not replaced`);
1401
+ if (!existing) this.targets.set(label, normalized);
1402
+ return existing ?? normalized;
1403
+ }
1404
+ get(label) {
1405
+ validateSshLabel(label);
1406
+ return this.targets.get(label);
1407
+ }
1408
+ entries() {
1409
+ return [...this.targets.entries()];
1410
+ }
1411
+ clear() {
1412
+ this.targets.clear();
1413
+ }
1414
+ };
1415
+ /** Shared by execution, probes and file/relay consumers. extraArgs is trusted
1416
+ * internal SSH argv (e.g. forwarding), never a model-provided shell string. */
1417
+ function sshArgv(connection, options = {}) {
1418
+ const args = options.tty ? [
1419
+ "ssh",
1420
+ "-tt",
1421
+ "-o",
1422
+ "BatchMode=yes"
1423
+ ] : [
1424
+ "ssh",
1425
+ "-o",
1426
+ "BatchMode=yes",
1427
+ "-T"
1428
+ ];
1429
+ if (connection.user !== void 0) args.push("-l", connection.user);
1430
+ if (connection.port !== void 0) args.push("-p", String(connection.port));
1431
+ if (connection.key !== void 0) args.push("-i", connection.key);
1432
+ if (connection.proxyJump !== void 0) args.push("-J", connection.proxyJump);
1433
+ args.push(...options.extraArgs ?? [], "--", connection.host);
1434
+ if (options.command !== void 0) args.push(options.command);
1435
+ return args;
1436
+ }
1437
+
1438
+ //#endregion
1439
+ //#region src/remote/input.ts
1440
+ /** Local carrier validation precedes any target discovery, connection or spawn. */
1441
+ async function prepareRemoteInput(input) {
1442
+ if (input.mode !== void 0 && input.mode !== "oneshot" && input.mode !== "pty") throw new Error("[E_BAD_MODE] remote: mode must be oneshot or pty");
1443
+ if (input.script !== void 0) {
1444
+ if (input.cmd !== void 0 || input.stdin !== void 0 || input.mode === "pty" || input.spawn !== void 0 || input.target?.startsWith("byop:")) throw new Error("[E_SCRIPT_PARAMS] remote: script is oneshot-only and cannot be combined with cmd, stdin, spawn or BYO-PTY");
1445
+ if (typeof input.script !== "string" || !isAbsolute(input.script) || input.script.includes("\0")) throw new Error("[E_SCRIPT_PATH] remote: script must be a local absolute path without NUL");
1446
+ }
1447
+ if (!input.target) throw new Error("[E_PARAMS] remote: target is required; spawn requires a byop:<label> target");
1448
+ if (input.script !== void 0) {
1449
+ let stdin;
1450
+ try {
1451
+ stdin = await readFile(input.script, "utf8");
1452
+ } catch (error) {
1453
+ throw new Error(`[E_SCRIPT_READ] remote: cannot read local script ${JSON.stringify(input.script)} — ${error instanceof Error ? error.message : String(error)}`);
1454
+ }
1455
+ return {
1456
+ target: input.target,
1457
+ ssh: input.ssh,
1458
+ cmd: "bash -s",
1459
+ stdin,
1460
+ mode: "oneshot",
1461
+ timeout: input.timeout
1462
+ };
1463
+ }
66
1464
  return {
67
- exit,
68
- timedOut,
69
- stdout,
70
- stderr,
71
- durationMs: Date.now() - started
1465
+ target: input.target,
1466
+ spawn: input.spawn,
1467
+ ssh: input.ssh,
1468
+ cmd: input.cmd ?? "",
1469
+ stdin: input.stdin,
1470
+ mode: input.mode,
1471
+ timeout: input.timeout
72
1472
  };
73
1473
  }
74
1474
 
1475
+ //#endregion
1476
+ //#region src/remote/oneshot.ts
1477
+ async function runOneShot(argv, opts = {}) {
1478
+ const process$1 = startOneShot(argv, opts);
1479
+ const result = await process$1.done;
1480
+ const stdout = process$1.read("stdout", 0), stderr = process$1.read("stderr", 0);
1481
+ return {
1482
+ ...result,
1483
+ stdout: stdout.text,
1484
+ stderr: [stderr.text, stderr.diagnostic].filter(Boolean).join("\n"),
1485
+ outputStats: {
1486
+ stdout: stdout.stats,
1487
+ stderr: stderr.stats
1488
+ }
1489
+ };
1490
+ }
1491
+
1492
+ //#endregion
1493
+ //#region src/remote/transports.ts
1494
+ /** POSIX 单引号机械转义(唯一一层转义,spec §三"一层机械转义")。 */
1495
+ function shQuote(s) {
1496
+ return `'${s.replaceAll("'", `'\\''`)}'`;
1497
+ }
1498
+ /** One-shot(§五.1):不分配 tty,收尾 = 子进程退出码,零字符串扫描。
1499
+ * POSIX wrapper(无远端 Python/owner/lease):
1500
+ * - wrapper 跑在 setsid 的私有会话里:sshd 会话进程与命令共享进程组,
1501
+ * 不隔离的话 kill 0 会把连接一起带走。
1502
+ * - EXIT trap(自免疫后杀组):命令正常结束时回收后台化子孙——它们不
1503
+ * 越过调用存活,也不会攥住 stdout 拖住收尾;退出码原样透传。
1504
+ * - cleanupTag:远端把 wrapper 的 pgid 写进 pidfile;本地取消/超时后经
1505
+ * 独立清理会话按组 kill(与 rsync daemon 的远端清理同构——实证 sshd
1506
+ * 对无 PTY 会话断连不发信号,本地 killpg 杀不到远端)。 */
1507
+ function buildOneShotArgv(plan, cmd, options = {}) {
1508
+ const tag = options.cleanupTag;
1509
+ const wrapped = `${tag === void 0 ? "" : `printf '%s' "$(ps -o pgid= -p $$ | tr -d ' ')" > "\${TMPDIR:-/tmp}/superd-oneshot-${tag}.pgid"\n`}trap 'trap "" TERM HUP INT; kill -TERM 0 2>/dev/null; exit 143' TERM HUP INT
1510
+ trap 'trap "" TERM HUP INT; kill -TERM 0 2>/dev/null${tag === void 0 ? "" : `; rm -f "\${TMPDIR:-/tmp}/superd-oneshot-${tag}.pgid"`}' EXIT
1511
+ bash -lc ${shQuote(cmd)}`;
1512
+ const sessioned = [
1513
+ "setsid",
1514
+ "--wait",
1515
+ "bash",
1516
+ "-c",
1517
+ wrapped
1518
+ ];
1519
+ switch (plan.kind) {
1520
+ case "ssh": return sshArgv(plan.connection ?? { host: plan.host }, { command: `setsid --wait bash -c ${shQuote(wrapped)}` });
1521
+ case "docker": return [
1522
+ "docker",
1523
+ "exec",
1524
+ "-i",
1525
+ "--",
1526
+ plan.container,
1527
+ ...sessioned
1528
+ ];
1529
+ case "incus": return [
1530
+ "incus",
1531
+ "exec",
1532
+ "--force-noninteractive",
1533
+ plan.container,
1534
+ "--",
1535
+ ...sessioned
1536
+ ];
1537
+ }
1538
+ }
1539
+ /** Persistent pty(Ruling 2):ssh/incus 走宿主管道,docker 经 script(1) 托管宿主 PTY。 */
1540
+ function buildPtyArgv(plan) {
1541
+ switch (plan.kind) {
1542
+ case "ssh": return sshArgv(plan.connection ?? { host: plan.host }, {
1543
+ tty: true,
1544
+ command: "bash"
1545
+ });
1546
+ case "docker": return [
1547
+ "script",
1548
+ "-qfec",
1549
+ `stty -echo 2>/dev/null ; exec docker exec -it -- ${shQuote(plan.container)} bash`,
1550
+ "/dev/null"
1551
+ ];
1552
+ case "incus": return [
1553
+ "incus",
1554
+ "exec",
1555
+ "--force-interactive",
1556
+ plan.container,
1557
+ "--",
1558
+ "bash"
1559
+ ];
1560
+ }
1561
+ }
1562
+ /** BYO-PTY(§三 spawn):工具只提供 PTY 托管 + Nonce 注入 + 收帧。 */
1563
+ function buildSpawnArgv(spawnCommand) {
1564
+ return [
1565
+ "script",
1566
+ "-qfec",
1567
+ `stty -echo 2>/dev/null ; exec ${spawnCommand}`,
1568
+ "/dev/null"
1569
+ ];
1570
+ }
1571
+
75
1572
  //#endregion
76
1573
  //#region src/remote/nonce-framing.ts
77
1574
  /** 8 字节随机串的 hex 形(spec §五.2:每次下发前生成)。 */
@@ -93,11 +1590,23 @@ function stripAnsi(s) {
93
1590
  function normalizePtyText(s) {
94
1591
  return stripAnsi(s).replace(/\r\n/g, "\n").replace(/\r/g, "");
95
1592
  }
96
- function tailWindow(text, cap) {
97
- if (text.length <= cap) return { text };
1593
+ function tailWindow(text, cap, stats) {
1594
+ let tail = text.slice(-cap);
1595
+ if (tail.charCodeAt(0) >= 56320 && tail.charCodeAt(0) <= 57343) tail = tail.slice(1);
1596
+ const returned = stats?.unit === "bytes" ? Buffer.byteLength(tail) : tail.length;
1597
+ const total = stats?.total ?? text.length;
1598
+ const updated = stats === void 0 ? {} : { stats: {
1599
+ ...stats,
1600
+ returned
1601
+ } };
1602
+ if (total <= returned) return {
1603
+ text: tail,
1604
+ ...updated
1605
+ };
98
1606
  return {
99
- text: `[truncated: showing last ${cap} of ${text.length} chars]\n${text.slice(text.length - cap)}`,
100
- truncated: text.length
1607
+ text: `[truncated: showing last ${returned} of ${total} ${stats?.unit === "bytes" ? "bytes" : "chars"}${stats !== void 0 && stats.dropped > 0 ? `; retained ${stats.retained}, dropped head ${stats.dropped}, retention limit ${stats.retentionLimit} ${stats.unit === "bytes" ? "bytes" : "UTF-16 code units"}` : ""}]\n${tail}`,
1608
+ truncated: total,
1609
+ ...updated
101
1610
  };
102
1611
  }
103
1612
  const FRAME_BODY_RE = /^(\d+);([^\x07\x1b]*)(?:\x07|\x1b\\)/;
@@ -182,6 +1691,9 @@ var PtySession = class {
182
1691
  lastSettleAt = 0;
183
1692
  /** 是否曾成功初始化(ready 过)。决定死亡语义:ready 过 → reconnect(留池);从未 ready → 出池(见 start())。 */
184
1693
  everReady = false;
1694
+ disposal;
1695
+ procClose = Promise.resolve();
1696
+ groupCleanup = Promise.resolve();
185
1697
  constructor(opts) {
186
1698
  this.opts = opts;
187
1699
  }
@@ -207,16 +1719,17 @@ var PtySession = class {
207
1719
  const started = Date.now();
208
1720
  const reconnected = this.state === "dead";
209
1721
  if (this.state !== "ready") await this.start();
1722
+ if (this.state === "disposed") throw new Error(`[E_SESSION_DISPOSED] remote pty session '${displayKey(this.opts.key)}' was disposed before dispatch`);
210
1723
  this.inFlight++;
211
1724
  return await new Promise((resolve$1) => {
212
1725
  const nonce = genNonce();
213
- let output = "";
1726
+ const ring = new Utf16TailRing(OUTPUT_HARD_CAP);
214
1727
  let interrupted = false;
215
1728
  let settled = false;
216
1729
  let interruptTimer;
217
1730
  let killTimer;
218
1731
  const deathWaiter = () => finish({
219
- output,
1732
+ output: ring.text,
220
1733
  exit: null,
221
1734
  cwd: null,
222
1735
  timedOut: interrupted
@@ -236,20 +1749,17 @@ var PtySession = class {
236
1749
  this.resetIdleTimer();
237
1750
  resolve$1({
238
1751
  ...r,
1752
+ outputStats: { stdout: ring.stats() },
239
1753
  reconnected,
240
1754
  durationMs: Date.now() - started
241
1755
  });
242
1756
  };
243
1757
  const parser = createNonceFrameParser(nonce, {
244
1758
  onOutput: (t) => {
245
- output += t;
246
- if (output.length > OUTPUT_HARD_CAP) {
247
- output = output.slice(output.length - OUTPUT_HARD_CAP);
248
- if (output.charCodeAt(0) >= 56320 && output.charCodeAt(0) <= 57343) output = output.slice(1);
249
- }
1759
+ ring.push(t);
250
1760
  },
251
1761
  onFrame: ({ exit, cwd }) => finish({
252
- output,
1762
+ output: ring.text,
253
1763
  exit,
254
1764
  cwd,
255
1765
  timedOut: interrupted
@@ -266,7 +1776,7 @@ var PtySession = class {
266
1776
  this.killTree();
267
1777
  this.markDead();
268
1778
  finish({
269
- output,
1779
+ output: ring.text,
270
1780
  exit: null,
271
1781
  cwd: null,
272
1782
  timedOut: true
@@ -281,14 +1791,24 @@ var PtySession = class {
281
1791
  return run;
282
1792
  }
283
1793
  /** TTL / row teardown:尽力 exit,1s 后杀组,出池。 */
284
- async dispose() {
285
- if (this.state === "disposed") return;
1794
+ dispose() {
1795
+ if (this.disposal) return this.disposal;
286
1796
  this.clearIdleTimer();
287
1797
  this.state = "disposed";
288
1798
  this.startPromise = null;
1799
+ const proc = this.proc;
1800
+ const close = this.procClose;
1801
+ const group = this.reapGroup(proc?.pid, 1e3, close);
1802
+ this.disposal = Promise.all([
1803
+ close,
1804
+ this.groupCleanup,
1805
+ group
1806
+ ]).then(() => {
1807
+ if (this.proc === proc) this.proc = null;
1808
+ });
289
1809
  this.write("exit\n");
290
- setTimeout(() => this.killTree(), 1e3).unref();
291
1810
  this.opts.onDead?.();
1811
+ return this.disposal;
292
1812
  }
293
1813
  write(s) {
294
1814
  if (this.proc?.stdin?.writable !== true) return;
@@ -326,7 +1846,12 @@ var PtySession = class {
326
1846
  proc.stderr.setEncoding("utf8").on("data", (d) => {
327
1847
  this.diag = (this.diag + d).slice(-2e3);
328
1848
  });
329
- proc.once("close", () => this.markDead());
1849
+ this.procClose = new Promise((resolve$1) => {
1850
+ proc.once("close", () => {
1851
+ this.markDead();
1852
+ resolve$1();
1853
+ });
1854
+ });
330
1855
  proc.once("error", (err) => {
331
1856
  this.diag = (this.diag + `\n[remote: spawn failed: ${err.message}]`).slice(-2e3);
332
1857
  });
@@ -374,6 +1899,7 @@ var PtySession = class {
374
1899
  }
375
1900
  resetIdleTimer() {
376
1901
  this.clearIdleTimer();
1902
+ if (this.state === "disposed") return;
377
1903
  this.idleTimer = setTimeout(() => void this.dispose(), this.opts.idleTtlSec * 1e3);
378
1904
  this.idleTimer.unref();
379
1905
  }
@@ -386,20 +1912,69 @@ var PtySession = class {
386
1912
  killTree() {
387
1913
  const pid = this.proc?.pid;
388
1914
  this.proc = null;
389
- if (pid === void 0) return;
390
- try {
391
- process.kill(-pid, "SIGTERM");
392
- } catch {}
393
- setTimeout(() => {
1915
+ const cleanup = this.reapGroup(pid, 0, this.procClose);
1916
+ this.groupCleanup = Promise.all([this.groupCleanup, cleanup]).then(() => void 0);
1917
+ }
1918
+ /** A direct child's close is not proof that its detached process group is gone. */
1919
+ reapGroup(pid, graceMs, close) {
1920
+ if (pid === void 0) return Promise.resolve();
1921
+ const signal = (value) => {
394
1922
  try {
395
- process.kill(-pid, "SIGKILL");
396
- } catch {}
397
- }, 2e3).unref();
1923
+ process.kill(-pid, value);
1924
+ return true;
1925
+ } catch (error) {
1926
+ if (error.code === "ESRCH") return false;
1927
+ throw error;
1928
+ }
1929
+ };
1930
+ return new Promise((resolve$1, reject) => {
1931
+ let termTimer;
1932
+ let killTimer;
1933
+ let settled = false;
1934
+ const finish = (error) => {
1935
+ if (settled) return;
1936
+ settled = true;
1937
+ if (termTimer !== void 0) clearTimeout(termTimer);
1938
+ if (killTimer !== void 0) clearTimeout(killTimer);
1939
+ if (error !== void 0) reject(error);
1940
+ else resolve$1();
1941
+ };
1942
+ const checkGone = () => {
1943
+ try {
1944
+ if (!signal(0)) finish();
1945
+ } catch (error) {
1946
+ finish(error);
1947
+ }
1948
+ };
1949
+ checkGone();
1950
+ if (settled) return;
1951
+ close.then(checkGone);
1952
+ termTimer = setTimeout(() => {
1953
+ try {
1954
+ if (!signal("SIGTERM")) {
1955
+ finish();
1956
+ return;
1957
+ }
1958
+ killTimer = setTimeout(() => {
1959
+ try {
1960
+ signal("SIGKILL");
1961
+ killTimer = setTimeout(() => finish(), 100);
1962
+ } catch (error) {
1963
+ finish(error);
1964
+ }
1965
+ }, 2e3);
1966
+ } catch (error) {
1967
+ finish(error);
1968
+ }
1969
+ }, graceMs);
1970
+ });
398
1971
  }
399
1972
  };
400
1973
  /** 会话池:键 → 会话。意外死亡的会话留在池内(下次 dispatch 带 reconnect 提示)。 */
401
1974
  var PtyPool = class {
402
1975
  sessions = /* @__PURE__ */ new Map();
1976
+ disposed = false;
1977
+ disposal;
403
1978
  constructor(defaults) {
404
1979
  this.defaults = defaults;
405
1980
  }
@@ -424,6 +1999,7 @@ var PtyPool = class {
424
1999
  return s === void 0 || s.sessionState === "disposed" ? void 0 : s;
425
2000
  }
426
2001
  getOrCreate(key, argv, origin) {
2002
+ if (this.disposed) throw new Error("[E_REMOTE_DISPOSED] remote: the owning Agent or plugin PTY pool was disposed");
427
2003
  let s = this.sessions.get(key);
428
2004
  if (s === void 0 || s.sessionState === "disposed") {
429
2005
  s = new PtySession({
@@ -437,9 +2013,12 @@ var PtyPool = class {
437
2013
  }
438
2014
  return s;
439
2015
  }
440
- async disposeAll() {
441
- await Promise.all([...this.sessions.values()].map((s) => s.dispose()));
442
- this.sessions.clear();
2016
+ disposeAll() {
2017
+ if (this.disposal) return this.disposal;
2018
+ this.disposed = true;
2019
+ return this.disposal = Promise.all([...this.sessions.values()].map((s) => s.dispose())).then(() => {
2020
+ this.sessions.clear();
2021
+ });
443
2022
  }
444
2023
  };
445
2024
 
@@ -489,17 +2068,10 @@ async function probeTarget(plan, opts = {}) {
489
2068
  const hard = opts.hardTimeoutSec ?? 10;
490
2069
  const runner = opts.runner ?? runOneShot;
491
2070
  const started = Date.now();
492
- const r = await runner(plan.kind === "ssh" ? [
493
- "ssh",
494
- "-o",
495
- "BatchMode=yes",
496
- "-o",
497
- `ConnectTimeout=${ct}`,
498
- "-T",
499
- "--",
500
- plan.host,
501
- "true"
502
- ] : plan.kind === "docker" ? [
2071
+ const r = await runner(plan.kind === "ssh" ? sshArgv(plan.connection ?? { host: plan.host }, {
2072
+ extraArgs: ["-o", `ConnectTimeout=${ct}`],
2073
+ command: "true"
2074
+ }) : plan.kind === "docker" ? [
503
2075
  "docker",
504
2076
  "inspect",
505
2077
  "--format",
@@ -677,6 +2249,7 @@ async function renderRoster(sessions, opts = {}) {
677
2249
  } catch (error) {
678
2250
  lines.push(`ssh hosts: no readable ~/.ssh/config (${error instanceof Error ? error.message : String(error)})`);
679
2251
  }
2252
+ lines.push(`byossh labels (this Agent, in memory): ${opts.byosshLabels?.map((label) => `byossh:${label}`).join(", ") || "(none)"}`);
680
2253
  const docker = await runner([
681
2254
  "docker",
682
2255
  "ps",
@@ -710,91 +2283,21 @@ async function renderRoster(sessions, opts = {}) {
710
2283
  return lines.join("\n");
711
2284
  }
712
2285
 
713
- //#endregion
714
- //#region src/remote/transports.ts
715
- /** POSIX 单引号机械转义(唯一一层转义,spec §三"一层机械转义")。 */
716
- function shQuote(s) {
717
- return `'${s.replaceAll("'", `'\\''`)}'`;
718
- }
719
- /** One-shot(§五.1):不分配 tty,收尾 = 子进程退出码,零字符串扫描。 */
720
- function buildOneShotArgv(plan, cmd) {
721
- switch (plan.kind) {
722
- case "ssh": return [
723
- "ssh",
724
- "-o",
725
- "BatchMode=yes",
726
- "-T",
727
- "--",
728
- plan.host,
729
- `bash -lc ${shQuote(cmd)}`
730
- ];
731
- case "docker": return [
732
- "docker",
733
- "exec",
734
- "-i",
735
- "--",
736
- plan.container,
737
- "bash",
738
- "-lc",
739
- cmd
740
- ];
741
- case "incus": return [
742
- "incus",
743
- "exec",
744
- "--force-noninteractive",
745
- plan.container,
746
- "--",
747
- "bash",
748
- "-lc",
749
- cmd
750
- ];
751
- }
752
- }
753
- /** Persistent pty(Ruling 2):ssh/incus 走宿主管道,docker 经 script(1) 托管宿主 PTY。 */
754
- function buildPtyArgv(plan) {
755
- switch (plan.kind) {
756
- case "ssh": return [
757
- "ssh",
758
- "-tt",
759
- "-o",
760
- "BatchMode=yes",
761
- "--",
762
- plan.host,
763
- "bash"
764
- ];
765
- case "docker": return [
766
- "script",
767
- "-qfec",
768
- `stty -echo 2>/dev/null ; exec docker exec -it -- ${shQuote(plan.container)} bash`,
769
- "/dev/null"
770
- ];
771
- case "incus": return [
772
- "incus",
773
- "exec",
774
- "--force-interactive",
775
- plan.container,
776
- "--",
777
- "bash"
778
- ];
779
- }
780
- }
781
- /** BYO-PTY(§三 spawn):工具只提供 PTY 托管 + Nonce 注入 + 收帧。 */
782
- function buildSpawnArgv(spawnCommand) {
783
- return [
784
- "script",
785
- "-qfec",
786
- `stty -echo 2>/dev/null ; exec ${spawnCommand}`,
787
- "/dev/null"
788
- ];
789
- }
790
-
791
2286
  //#endregion
792
2287
  //#region src/remote/driver.ts
793
2288
  /** PTY label 字符集:只禁空白 / `:` / 控制字符(roster 一行一 label,带换行的 label 渲染即烂)。 */
794
2289
  const PTY_LABEL_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
795
2290
  /** 双轨调度器(spec §七 Phase 2):参数校验 → 路由 → oneshot 子进程 / pty 会话池。 */
796
2291
  var RemoteDriver = class {
2292
+ sshTargets = new SshTargets();
797
2293
  pool;
2294
+ hubOwner = randomUUID();
2295
+ hubProviders = /* @__PURE__ */ new Set();
2296
+ processes = /* @__PURE__ */ new Set();
2297
+ starting = /* @__PURE__ */ new Set();
2298
+ fileAbort = new AbortController();
2299
+ disposed = false;
2300
+ disposal;
798
2301
  constructor(opts = {}) {
799
2302
  this.opts = opts;
800
2303
  this.pool = new PtyPool({ idleTtlSec: opts.idleTtlSec ?? 600 });
@@ -802,11 +2305,14 @@ var RemoteDriver = class {
802
2305
  async call(params, callCtx = {}) {
803
2306
  const started = Date.now();
804
2307
  try {
805
- const routing = this.route(params);
806
- const result = await this.execute(params, routing, callCtx);
2308
+ this.assertOpen();
2309
+ const prepared = await prepareRemoteInput(params);
2310
+ this.assertOpen();
2311
+ const routing = this.route(prepared);
2312
+ const result = await this.execute(prepared, routing, callCtx);
807
2313
  this.audit({
808
2314
  target: routing.display,
809
- cmd: params.cmd,
2315
+ cmd: prepared.cmd,
810
2316
  cwd: result.cwd ?? void 0,
811
2317
  exit: result.exit,
812
2318
  durationMs: Date.now() - started
@@ -815,13 +2321,196 @@ var RemoteDriver = class {
815
2321
  } catch (error) {
816
2322
  this.audit({
817
2323
  target: params.target ?? params.spawn ?? "?",
818
- cmd: params.cmd,
2324
+ cmd: params.cmd ?? "[local script]",
819
2325
  error: error instanceof Error ? error.message : String(error),
820
2326
  durationMs: Date.now() - started
821
2327
  });
822
2328
  throw error;
823
2329
  }
824
2330
  }
2331
+ /** Native-first copy routing (spec: remote-native-shortcut): CLI peers run
2332
+ * their native CLI verbatim; only Hub↔local↔local stays on the staged tree
2333
+ * lane with verify/mkdir. Mixed pairs (Hub↔CLI peer) are refused: Hub has no
2334
+ * native CLI and the tree lane has no CLI options. */
2335
+ copy(request, signal, callCtx = {}) {
2336
+ const combined = AbortSignal.any([signal, this.fileAbort.signal]);
2337
+ const work = (async () => {
2338
+ this.assertOpen();
2339
+ combined.throwIfAborted();
2340
+ const resolve$1 = async (value) => {
2341
+ const parsed = parseFileEndpoint(value);
2342
+ if (!parsed.target) return {
2343
+ kind: "local",
2344
+ path: parsed.path
2345
+ };
2346
+ if (parsed.target.startsWith("hub:")) return {
2347
+ kind: "hub",
2348
+ path: parsed.path,
2349
+ machine: parsed.target.slice(4)
2350
+ };
2351
+ const plan = await this.resolveConnectionTarget(parsed.target, callCtx);
2352
+ this.assertOpen();
2353
+ combined.throwIfAborted();
2354
+ return {
2355
+ kind: "cli",
2356
+ path: parsed.path,
2357
+ plan
2358
+ };
2359
+ };
2360
+ const src = await resolve$1(request.src), dst = await resolve$1(request.dst);
2361
+ this.assertOpen();
2362
+ combined.throwIfAborted();
2363
+ if (src.kind === "hub" || dst.kind === "hub") {
2364
+ if (src.kind === "hub" && dst.kind === "local") {
2365
+ const hub = this.requireHub();
2366
+ this.hubProviders.add(hub);
2367
+ return copyTree(request, {
2368
+ path: src.path,
2369
+ endpoint: hub.fileEndpoint(src.machine)
2370
+ }, {
2371
+ path: dst.path,
2372
+ endpoint: localFileEndpoint()
2373
+ }, combined);
2374
+ }
2375
+ if (src.kind === "local" && dst.kind === "hub") {
2376
+ const hub = this.requireHub();
2377
+ this.hubProviders.add(hub);
2378
+ return copyTree(request, {
2379
+ path: src.path,
2380
+ endpoint: localFileEndpoint()
2381
+ }, {
2382
+ path: dst.path,
2383
+ endpoint: hub.fileEndpoint(dst.machine)
2384
+ }, combined);
2385
+ }
2386
+ if (src.kind === "hub" && dst.kind === "hub") {
2387
+ const hub = this.requireHub();
2388
+ this.hubProviders.add(hub);
2389
+ return copyTree(request, {
2390
+ path: src.path,
2391
+ endpoint: hub.fileEndpoint(src.machine)
2392
+ }, {
2393
+ path: dst.path,
2394
+ endpoint: hub.fileEndpoint(dst.machine)
2395
+ }, combined);
2396
+ }
2397
+ throw new Error("copy: Hub endpoints pair only with local paths or another Hub machine (native CLI peers have no Hub lane and the Hub has no CLI options)");
2398
+ }
2399
+ if (src.kind === "local" && dst.kind === "local") return copyTree(request, {
2400
+ path: src.path,
2401
+ endpoint: localFileEndpoint()
2402
+ }, {
2403
+ path: dst.path,
2404
+ endpoint: localFileEndpoint()
2405
+ }, combined);
2406
+ if (request.verify !== void 0 || request.mkdir !== void 0) throw new Error("copy: verify/mkdir apply to the Hub/local staged lane only; native CLI routes do not verify or create parents (mirror the CLI's own behavior with argv like -r)");
2407
+ const planOf = (end) => end.kind === "cli" ? end.plan : end.kind === "local" ? { kind: "local" } : void 0;
2408
+ const srcPlan = planOf(src), dstPlan = planOf(dst);
2409
+ if (srcPlan === void 0 || dstPlan === void 0) throw new Error("copy: unreachable route resolution");
2410
+ return nativeCopy({
2411
+ src: request.src,
2412
+ dst: request.dst,
2413
+ ...request.argv === void 0 ? {} : { argv: request.argv },
2414
+ ...request.pullArgv === void 0 ? {} : { pullArgv: request.pullArgv },
2415
+ ...request.pushArgv === void 0 ? {} : { pushArgv: request.pushArgv }
2416
+ }, {
2417
+ src: {
2418
+ plan: srcPlan,
2419
+ path: src.path
2420
+ },
2421
+ dst: {
2422
+ plan: dstPlan,
2423
+ path: dst.path
2424
+ }
2425
+ }, combined, { wrapArgv: this.opts.nativeArgvFor });
2426
+ })();
2427
+ this.starting.add(work);
2428
+ work.then(() => this.starting.delete(work), () => this.starting.delete(work));
2429
+ return work;
2430
+ }
2431
+ rsync(request, signal, callCtx = {}) {
2432
+ const combined = AbortSignal.any([signal, this.fileAbort.signal]);
2433
+ const work = (async () => {
2434
+ this.assertOpen();
2435
+ combined.throwIfAborted();
2436
+ const endpoint = async (value) => {
2437
+ const parsed = parseFileEndpoint(value);
2438
+ if (!parsed.target) return { path: parsed.path };
2439
+ if (/^(hub|docker|incus|byop):/.test(parsed.target)) throw new Error("rsync supports SSH/byossh only (plus one local endpoint); no mixed-transport or copy fallback");
2440
+ const plan = await this.resolveConnectionTarget(parsed.target, callCtx);
2441
+ this.assertOpen();
2442
+ combined.throwIfAborted();
2443
+ if (plan.kind !== "ssh") throw new Error("rsync requires an SSH target; no copy fallback");
2444
+ return {
2445
+ path: parsed.path,
2446
+ connection: plan.connection ?? { host: plan.host }
2447
+ };
2448
+ };
2449
+ const source = await endpoint(request.src), destination = await endpoint(request.dst);
2450
+ this.assertOpen();
2451
+ combined.throwIfAborted();
2452
+ return runRsync(request, source, destination, combined, { wrapArgv: this.opts.nativeArgvFor });
2453
+ })();
2454
+ this.starting.add(work);
2455
+ work.then(() => this.starting.delete(work), () => this.starting.delete(work));
2456
+ return work;
2457
+ }
2458
+ /** Only called from JobSpec.run: admission precedes local file reads and discovery. */
2459
+ start(input, signal) {
2460
+ const work = this.startPrepared(input, AbortSignal.any([signal, this.fileAbort.signal]));
2461
+ this.starting.add(work);
2462
+ work.then(() => this.starting.delete(work), () => this.starting.delete(work));
2463
+ return work;
2464
+ }
2465
+ async startPrepared(input, signal) {
2466
+ this.assertOpen();
2467
+ signal.throwIfAborted();
2468
+ const params = await prepareRemoteInput(input);
2469
+ this.assertOpen();
2470
+ signal.throwIfAborted();
2471
+ const route = this.route(params);
2472
+ if (route.mode !== "oneshot" || route.pty) throw new Error("remote: background requires oneshot");
2473
+ let process$1;
2474
+ if (route.display.startsWith("hub:")) {
2475
+ const hub = this.requireHub();
2476
+ this.hubProviders.add(hub);
2477
+ process$1 = await hub.start(route.display.slice(4), {
2478
+ ...params,
2479
+ mode: "oneshot"
2480
+ }, this.hubOwner);
2481
+ } else {
2482
+ const plan = route.plan ?? await this.resolveBareName(route.bare, "no-session", false);
2483
+ this.assertOpen();
2484
+ signal.throwIfAborted();
2485
+ if (plan.kind === "pty") throw new Error("remote: background requires oneshot");
2486
+ process$1 = await this.nativeProcess(plan, params, params.timeout, signal);
2487
+ }
2488
+ this.own(process$1);
2489
+ if (signal.aborted || this.disposed) process$1.cancel("owner closed during start");
2490
+ return process$1;
2491
+ }
2492
+ nativeProcess(plan, params, timeoutSec, signal = this.fileAbort.signal) {
2493
+ const cleanupTag = randomUUID().slice(0, 8);
2494
+ const process$1 = startOneShot((this.opts.oneshotArgvFor ?? buildOneShotArgv)(plan, params.cmd, { cleanupTag }), {
2495
+ stdin: params.stdin,
2496
+ timeoutSec
2497
+ });
2498
+ process$1.done.then((result) => {
2499
+ if (!result.cancelled && !result.timedOut) return;
2500
+ const pidfile = `"\${TMPDIR:-/tmp}/superd-oneshot-${cleanupTag}.pgid"`;
2501
+ const cleanup = `p=$(cat ${pidfile} 2>/dev/null) && { kill -TERM -- "-$p" 2>/dev/null; sleep 0.3; kill -KILL -- "-$p" 2>/dev/null; }; rm -f ${pidfile}`;
2502
+ startOneShot((this.opts.oneshotArgvFor ?? buildOneShotArgv)(plan, cleanup, {}), { timeoutSec: 15 }).done.catch(() => {});
2503
+ }).catch(() => {});
2504
+ const work = Promise.resolve(process$1);
2505
+ this.starting.add(work);
2506
+ work.then(() => this.starting.delete(work), () => this.starting.delete(work));
2507
+ return work;
2508
+ }
2509
+ own(process$1) {
2510
+ this.processes.add(process$1);
2511
+ process$1.done.then(() => this.processes.delete(process$1));
2512
+ return process$1;
2513
+ }
825
2514
  /** Ruling 16/17:on-demand 探测 + 会话层,两行如实陈述。 */
826
2515
  /**
827
2516
  * Ruling P20 + PTY 候选表末位:裸名按名匹配——ssh config 字面 host → docker → incus(本地廉价扫描,
@@ -831,13 +2520,15 @@ var RemoteDriver = class {
831
2520
  async resolveBareName(name$1, sessionKey, usePtyLabels = true) {
832
2521
  const runner = this.opts.rosterRunner ?? runOneShot;
833
2522
  const readCfg = this.opts.sshConfigReader ?? (async () => await readFile(join(homedir(), ".ssh", "config"), "utf8"));
2523
+ let hosts = [];
834
2524
  try {
835
- const { hosts } = literalHosts(await readCfg());
836
- if (hosts.some((h) => h.toLowerCase() === name$1.toLowerCase())) return {
837
- kind: "ssh",
838
- host: name$1
839
- };
2525
+ hosts = literalHosts(await readCfg()).hosts;
840
2526
  } catch {}
2527
+ this.assertOpen();
2528
+ if (hosts.some((h) => h.toLowerCase() === name$1.toLowerCase())) return {
2529
+ kind: "ssh",
2530
+ host: name$1
2531
+ };
841
2532
  const docker = await runner([
842
2533
  "docker",
843
2534
  "ps",
@@ -845,6 +2536,7 @@ var RemoteDriver = class {
845
2536
  "--format",
846
2537
  "{{.Names}}"
847
2538
  ], { timeoutSec: 10 });
2539
+ this.assertOpen();
848
2540
  if (docker.exit === 0 && docker.stdout.trim().split(/\r?\n/).some((n) => n.trim().toLowerCase() === name$1.toLowerCase())) return {
849
2541
  kind: "docker",
850
2542
  container: name$1
@@ -857,6 +2549,7 @@ var RemoteDriver = class {
857
2549
  "--columns",
858
2550
  "n"
859
2551
  ], { timeoutSec: 10 });
2552
+ this.assertOpen();
860
2553
  if (incus.exit === 0 && incus.stdout.trim().split(/\r?\n/).some((n) => n.split(",")[0]?.trim().toLowerCase() === name$1.toLowerCase())) return {
861
2554
  kind: "incus",
862
2555
  container: name$1
@@ -875,22 +2568,47 @@ var RemoteDriver = class {
875
2568
  * 而活会话是**操作环境**——复用别的 agent 留下的 shell 等于继承未知状态,且两个 agent 各开一条线
876
2569
  * 的代价可忽略。BYO-PTY 天然是活的,于是自然落进这个 scope,无需另立规则。 */
877
2570
  async roster(callCtx = {}) {
2571
+ this.assertOpen();
878
2572
  const prefix = `${callCtx.sessionKey ?? "no-session"}|`;
879
- return await renderRoster(this.pool.list().filter(({ key }) => key.startsWith(prefix)).map(({ key, snapshot }) => ({
2573
+ const sessions = this.pool.list().filter(({ key }) => key.startsWith(prefix)).map(({ key, snapshot }) => ({
880
2574
  target: displayKey(key),
881
2575
  state: snapshot.state,
882
2576
  idleSec: snapshot.idleMs !== null ? Math.round(snapshot.idleMs / 1e3) : null
883
- })), this.opts.rosterRunner !== void 0 ? { runner: this.opts.rosterRunner } : {});
2577
+ }));
2578
+ const runner = this.opts.rosterRunner ?? runOneShot;
2579
+ const hub = this.opts.getHub?.();
2580
+ return [...hub ? ["hub machines: " + (hub.listTargets().map((t) => `hub:${t.id} (${t.label}${t.execEnabled ? "" : "; exec disabled"})`).join(", ") || "none")] : [], await renderRoster(sessions, {
2581
+ byosshLabels: this.sshTargets.entries().map(([label]) => label),
2582
+ readSshConfig: this.opts.sshConfigReader,
2583
+ runner: (argv, options) => {
2584
+ this.assertOpen();
2585
+ return runner(argv, options);
2586
+ }
2587
+ })].join("\n");
884
2588
  }
885
2589
  async status(target, callCtx = {}) {
2590
+ this.assertOpen();
2591
+ if (target.startsWith("hub:")) {
2592
+ const machine = target.slice(4);
2593
+ const row = this.requireHub().listTargets().find((t) => t.id === machine);
2594
+ if (!row) throw new Error(`unknown remote machine ${machine} — select a remote machine from the Hub roster; local execution is not available`);
2595
+ return `${target} — Hub remote machine (${row.label}); exec ${row.execEnabled ? "enabled" : "disabled"}; not probed`;
2596
+ }
886
2597
  const sessionKey = callCtx.sessionKey ?? "no-session";
887
2598
  const label = target.startsWith("byop:") ? target.slice(5) : void 0;
888
2599
  if (label !== void 0) return this.ptyStatus(target, label, sessionKey);
889
- const resolved = /^(docker|incus|ssh):/.test(target) ? resolveTarget(target) : await this.resolveBareName(target, sessionKey);
2600
+ const direct = /^(docker|incus|ssh):/.test(target);
2601
+ const resolved = target.startsWith("byossh:") ? this.byosshPlan(target) : direct ? resolveTarget(target) : await this.resolveBareName(target, sessionKey);
2602
+ this.assertOpen();
890
2603
  if (resolved.kind === "pty") return this.ptyStatus(`byop:${resolved.label}`, resolved.label, sessionKey);
2604
+ const head = `${target} — ${resolved.kind === "ssh" ? "ssh host" : `${resolved.kind} container`}`;
2605
+ const runner = this.opts.probeRunner ?? runOneShot;
891
2606
  return [
892
- `${target} — ${resolved.kind === "ssh" ? "ssh host" : `${resolved.kind} container`}`,
893
- renderProbe(await probeTarget(resolved, { runner: this.opts.probeRunner })),
2607
+ head,
2608
+ renderProbe(await probeTarget(resolved, { runner: (argv, options) => {
2609
+ this.assertOpen();
2610
+ return runner(argv, options);
2611
+ } })),
894
2612
  renderSession(this.pool.inspect(`${sessionKey}|t:${target}`))
895
2613
  ].join("\n");
896
2614
  }
@@ -904,12 +2622,50 @@ var RemoteDriver = class {
904
2622
  this.opts.onAudit?.(r);
905
2623
  } catch {}
906
2624
  }
2625
+ /** Registration never dials; response reveals labels only, not credentials. */
2626
+ registerSsh(target, connection) {
2627
+ this.assertOpen();
2628
+ if (!target.startsWith("byossh:")) throw new Error("[E_SSH_PARAMS] remote: ssh requires a byossh:<label> target");
2629
+ this.sshTargets.register(target.slice(7), connection);
2630
+ return `${target} registered (no dialing). Reuse this Agent's label without ssh; a new Agent/cold resume must register again.`;
2631
+ }
2632
+ byosshPlan(target, connection) {
2633
+ if (connection !== void 0) this.registerSsh(target, connection);
2634
+ const spec = this.sshTargets.get(target.slice(7));
2635
+ if (!spec) throw new Error(`[E_NO_SSH_TARGET] remote: ${target} is not registered in this Agent — supply ssh:{host,user?,port?,key?,proxyJump?} first; key must be an existing origin file path`);
2636
+ return {
2637
+ kind: "ssh",
2638
+ host: spec.host,
2639
+ connection: spec
2640
+ };
2641
+ }
2642
+ /** Single target resolver for later copy/rsync; BYO identities cannot be
2643
+ * independently re-resolved through the origin's alias table. */
2644
+ async resolveConnectionTarget(target, callCtx = {}) {
2645
+ this.assertOpen();
2646
+ if (target.startsWith("byossh:")) return this.byosshPlan(target);
2647
+ if (/^(docker|incus|ssh):/.test(target)) return resolveTarget(target);
2648
+ if (/^(byop|hub):/.test(target)) throw new Error("remote: select an SSH/Docker/Incus target for this connection resolver");
2649
+ const plan = await this.resolveBareName(target, callCtx.sessionKey ?? "no-session", false);
2650
+ this.assertOpen();
2651
+ if (plan.kind === "pty") throw new Error("remote: BYO-PTY is not a filesystem connection");
2652
+ return plan;
2653
+ }
907
2654
  route(params) {
908
2655
  const hasTarget = params.target !== void 0 && params.target.length > 0;
909
2656
  const hasSpawn = params.spawn !== void 0 && params.spawn.length > 0;
910
2657
  if (!hasTarget && !hasSpawn) throw new Error("[E_PARAMS] remote: target is required (e.g. { target: \"dev4\", cmd: \"git status\" }); spawn is a modifier of a \"byop:<label>\" target and cannot stand alone");
911
2658
  if (params.timeout !== void 0 && (!Number.isFinite(params.timeout) || params.timeout <= 0)) throw new Error(`[E_BAD_TIMEOUT] remote: timeout must be a positive number of seconds (got ${JSON.stringify(params.timeout)})`);
912
2659
  if (params.stdin !== void 0 && (params.cmd === void 0 || params.cmd.length === 0)) throw new Error("[E_STDIN_WITHOUT_CMD] remote: stdin requires cmd");
2660
+ if (params.ssh !== void 0 && !params.target.startsWith("byossh:")) throw new Error("[E_SSH_PARAMS] remote: ssh requires a byossh:<label> target");
2661
+ if (params.target.startsWith("byossh:")) {
2662
+ if (hasSpawn) throw new Error("[E_PARAMS] remote: spawn is only for byop:<label>, not BYO-SSH");
2663
+ return {
2664
+ mode: params.mode ?? "oneshot",
2665
+ display: params.target,
2666
+ plan: this.byosshPlan(params.target, params.ssh)
2667
+ };
2668
+ }
913
2669
  if (hasTarget && params.target.startsWith("byop:")) {
914
2670
  const label = params.target.slice(5);
915
2671
  if (!PTY_LABEL_RE.test(label)) throw new Error(`[E_BAD_LABEL] remote: PTY label must match ${PTY_LABEL_RE.source} (got ${JSON.stringify(label)}) — e.g. target: "byop:dev3"`);
@@ -925,6 +2681,10 @@ var RemoteDriver = class {
925
2681
  }
926
2682
  if (hasSpawn) throw new Error("[E_PARAMS] remote: spawn requires a \"byop:<label>\" target — e.g. { target: \"byop:dev3\", spawn: \"ssh dev3\", cmd: \"…\" }; it is not a standalone entry point");
927
2683
  const mode = params.mode ?? "oneshot";
2684
+ if (params.target.startsWith("hub:")) return {
2685
+ mode,
2686
+ display: params.target
2687
+ };
928
2688
  const direct = /^(docker|incus|ssh):/.test(params.target);
929
2689
  return {
930
2690
  mode,
@@ -933,25 +2693,82 @@ var RemoteDriver = class {
933
2693
  };
934
2694
  }
935
2695
  async execute(params, routing, callCtx) {
2696
+ const timeoutSec = params.timeout ?? this.opts.execTimeoutSec ?? 120;
2697
+ const cap = this.opts.maxOutputChars ?? 3e4;
2698
+ if (routing.display.startsWith("hub:")) {
2699
+ const hub = this.requireHub();
2700
+ this.hubProviders.add(hub);
2701
+ const machine = routing.display.slice(4);
2702
+ if (routing.mode === "pty") {
2703
+ const result$1 = await hub.pty(machine, params.cmd, this.hubOwner, {
2704
+ stdin: params.stdin,
2705
+ timeout: timeoutSec
2706
+ });
2707
+ const out$2 = tailWindow(result$1.stdout, cap, result$1.outputStats?.stdout);
2708
+ return {
2709
+ ...result$1,
2710
+ stdout: out$2.text,
2711
+ ...out$2.stats ? { outputStats: {
2712
+ ...result$1.outputStats,
2713
+ stdout: out$2.stats
2714
+ } } : {},
2715
+ ...out$2.truncated === void 0 ? {} : { truncated: out$2.truncated }
2716
+ };
2717
+ }
2718
+ const process$1 = this.own(await hub.start(machine, {
2719
+ ...params,
2720
+ target: routing.display,
2721
+ mode: "oneshot",
2722
+ timeout: timeoutSec
2723
+ }, this.hubOwner));
2724
+ if (this.disposed) process$1.cancel("owner disposed");
2725
+ const result = await process$1.done;
2726
+ const stdout = process$1.read("stdout", 0), stderr = process$1.read("stderr", 0);
2727
+ const out$1 = tailWindow(stdout.text, cap, stdout.stats);
2728
+ const err = tailWindow(stderr.text, cap, stderr.stats);
2729
+ return {
2730
+ ...result,
2731
+ mode: "oneshot",
2732
+ target: routing.display,
2733
+ cwd: null,
2734
+ reconnected: false,
2735
+ stdout: (!stdout.stats && stdout.lossy ? "[truncated: peer returned only a tail; exact total/retained/dropped unavailable]\n" : "") + out$1.text,
2736
+ stderr: [err.text, stderr.diagnostic].filter(Boolean).join("\n"),
2737
+ ...out$1.stats ? { outputStats: {
2738
+ stdout: out$1.stats,
2739
+ stderr: err.stats
2740
+ } } : { outputStatsUnavailable: true },
2741
+ ...out$1.truncated === void 0 ? {} : { truncated: out$1.truncated }
2742
+ };
2743
+ }
936
2744
  const sessionKey = callCtx.sessionKey ?? "no-session";
937
2745
  if (routing.bare !== void 0) {
938
2746
  const resolved = await this.resolveBareName(routing.bare, sessionKey, params.mode !== "oneshot");
2747
+ this.assertOpen();
939
2748
  if (resolved.kind === "pty") {
940
2749
  routing.pty = { label: resolved.label };
941
2750
  routing.mode = "pty";
942
2751
  routing.display = `byop:${resolved.label}`;
943
2752
  } else routing.plan = resolved;
944
2753
  }
945
- const timeoutSec = params.timeout ?? this.opts.execTimeoutSec ?? 120;
946
- const cap = this.opts.maxOutputChars ?? 3e4;
947
2754
  if (routing.pty !== void 0) return await this.ptyCall(routing.pty, params, sessionKey, timeoutSec, cap);
948
2755
  if (routing.mode === "oneshot") {
949
- const r$1 = await runOneShot((this.opts.oneshotArgvFor ?? buildOneShotArgv)(routing.plan, params.cmd), {
950
- stdin: params.stdin,
951
- timeoutSec
952
- });
953
- const out$1 = tailWindow(r$1.stdout, cap);
954
- const err = r$1.stderr.length > 0 ? tailWindow(r$1.stderr, cap) : void 0;
2756
+ this.assertOpen();
2757
+ const process$1 = this.own(await this.nativeProcess(routing.plan, params, timeoutSec));
2758
+ if (this.disposed) process$1.cancel("owner disposed");
2759
+ const result = await process$1.done;
2760
+ const stdout = process$1.read("stdout", 0), stderr = process$1.read("stderr", 0);
2761
+ const r$1 = {
2762
+ ...result,
2763
+ stdout: stdout.text,
2764
+ stderr: [stderr.text, stderr.diagnostic].filter(Boolean).join("\n"),
2765
+ outputStats: {
2766
+ stdout: stdout.stats,
2767
+ stderr: stderr.stats
2768
+ }
2769
+ };
2770
+ const out$1 = tailWindow(r$1.stdout, cap, r$1.outputStats?.stdout);
2771
+ const err = r$1.stderr.length > 0 ? tailWindow(r$1.stderr, cap, r$1.outputStats?.stderr) : void 0;
955
2772
  return {
956
2773
  mode: "oneshot",
957
2774
  target: routing.display,
@@ -961,6 +2778,10 @@ var RemoteDriver = class {
961
2778
  reconnected: false,
962
2779
  stdout: out$1.text,
963
2780
  ...err !== void 0 ? { stderr: err.text } : {},
2781
+ ...out$1.stats ? { outputStats: {
2782
+ stdout: out$1.stats,
2783
+ stderr: err?.stats ?? r$1.outputStats?.stderr
2784
+ } } : {},
964
2785
  durationMs: r$1.durationMs,
965
2786
  ...out$1.truncated !== void 0 ? { truncated: out$1.truncated } : {}
966
2787
  };
@@ -971,7 +2792,7 @@ var RemoteDriver = class {
971
2792
  stdin: params.stdin,
972
2793
  timeoutSec
973
2794
  });
974
- const out = tailWindow(r.output, cap);
2795
+ const out = tailWindow(r.output, cap, r.outputStats?.stdout);
975
2796
  return {
976
2797
  mode: "pty",
977
2798
  target: routing.display,
@@ -980,6 +2801,7 @@ var RemoteDriver = class {
980
2801
  timedOut: r.timedOut,
981
2802
  reconnected: r.reconnected,
982
2803
  stdout: out.text,
2804
+ outputStats: out.stats ? { stdout: out.stats } : void 0,
983
2805
  durationMs: r.durationMs,
984
2806
  ...out.truncated !== void 0 ? { truncated: out.truncated } : {}
985
2807
  };
@@ -1001,7 +2823,7 @@ var RemoteDriver = class {
1001
2823
  stdin: params.stdin,
1002
2824
  timeoutSec
1003
2825
  });
1004
- const out = tailWindow(r.output, cap);
2826
+ const out = tailWindow(r.output, cap, r.outputStats?.stdout);
1005
2827
  return {
1006
2828
  mode: "pty",
1007
2829
  target: display,
@@ -1012,27 +2834,200 @@ var RemoteDriver = class {
1012
2834
  reconnected: r.reconnected,
1013
2835
  stdout: out.text,
1014
2836
  ...notice !== void 0 ? { notice } : {},
2837
+ outputStats: out.stats ? { stdout: out.stats } : void 0,
1015
2838
  durationMs: r.durationMs,
1016
2839
  ...out.truncated !== void 0 ? { truncated: out.truncated } : {}
1017
2840
  };
1018
2841
  }
1019
- async dispose() {
1020
- await this.pool.disposeAll();
2842
+ hasHub() {
2843
+ return this.opts.getHub?.() !== void 0;
2844
+ }
2845
+ requireHub() {
2846
+ this.assertOpen();
2847
+ const hub = this.opts.getHub?.();
2848
+ if (!hub || hub.protocol !== 1) throw new Error("[E_HUB_UNAVAILABLE] remote: Hub transport unavailable — enable the Super D Hub body, or select an explicit SSH/Docker/Incus/BYO target");
2849
+ return hub;
2850
+ }
2851
+ assertOpen() {
2852
+ if (this.disposed) throw new Error("[E_REMOTE_DISPOSED] remote: this Agent or plugin owner has exited — create a new session or cold resume");
2853
+ }
2854
+ dispose() {
2855
+ if (this.disposal) return this.disposal;
2856
+ this.sshTargets.clear();
2857
+ this.disposed = true;
2858
+ this.fileAbort.abort(/* @__PURE__ */ new Error("remote owner disposed"));
2859
+ for (const process$1 of this.processes) process$1.cancel("remote owner disposed");
2860
+ return this.disposal = (async () => {
2861
+ await Promise.allSettled([...this.starting]);
2862
+ await Promise.all([
2863
+ this.pool.disposeAll(),
2864
+ ...[...this.processes].map((p) => p.done),
2865
+ ...[...this.hubProviders].map((hub) => hub.releaseOwner(this.hubOwner))
2866
+ ]);
2867
+ this.hubProviders.clear();
2868
+ })();
1021
2869
  }
1022
2870
  };
1023
2871
 
2872
+ //#endregion
2873
+ //#region src/remote/jobs.ts
2874
+ /** No preparation or discovery before the official registry's admission. */
2875
+ function startRemoteJob(registry, input, exec, driver) {
2876
+ if (input.mode === "pty" || input.spawn !== void 0 || input.target?.startsWith("byop:")) throw new Error("remote: background execution requires oneshot; PTY and BYO-PTY are unsupported");
2877
+ if (!exec.agent) throw new Error("remote: background jobs require a live Agent owner");
2878
+ let process$1;
2879
+ const output = ["stdout", "stderr"].map((channel) => ({
2880
+ channel,
2881
+ read: (fromByte) => {
2882
+ const read = process$1?.read(channel, fromByte);
2883
+ return read ? {
2884
+ text: read.text,
2885
+ nextOffset: read.nextOffset,
2886
+ lossy: read.lossy
2887
+ } : {
2888
+ text: "",
2889
+ nextOffset: fromByte,
2890
+ lossy: false
2891
+ };
2892
+ }
2893
+ }));
2894
+ return registry.start({
2895
+ kind: "remote",
2896
+ owner: exec.agent.id,
2897
+ label: `${input.target}: ${input.cmd ?? input.script}`,
2898
+ output,
2899
+ run() {
2900
+ const controller = new AbortController();
2901
+ return {
2902
+ done: (async () => {
2903
+ try {
2904
+ process$1 = await driver.start({
2905
+ ...input,
2906
+ mode: "oneshot"
2907
+ }, controller.signal);
2908
+ if (controller.signal.aborted) process$1.cancel(controller.signal.reason);
2909
+ const result = await process$1.done;
2910
+ const diagnostic = process$1.read("stderr", 0).diagnostic;
2911
+ return {
2912
+ status: controller.signal.aborted || result.cancelled ? "killed" : diagnostic ? "failed" : result.timedOut ? "killed" : "completed",
2913
+ detail: diagnostic ?? (result.timedOut ? "timeout" : `exit code: ${result.exit}`)
2914
+ };
2915
+ } catch (error) {
2916
+ return {
2917
+ status: controller.signal.aborted ? "killed" : "failed",
2918
+ detail: error instanceof Error ? error.message : String(error)
2919
+ };
2920
+ }
2921
+ })(),
2922
+ cancel(reason) {
2923
+ if (controller.signal.aborted) return;
2924
+ controller.abort(reason);
2925
+ process$1?.cancel(reason);
2926
+ }
2927
+ };
2928
+ }
2929
+ });
2930
+ }
2931
+
1024
2932
  //#endregion
1025
2933
  //#region src/remote/tool.ts
1026
2934
  const NO_SESSION_KEY = "remote:no-session";
2935
+ /**
2936
+ * The model-visible surface of the `remote` tool. Kept as ONE template
2937
+ * literal so it can be edited here directly — no string concatenation and no
2938
+ * apostrophe escaping. Plain text only: no backticks and no "${" inside.
2939
+ */
2940
+ const REMOTE_TOOL_DESCRIPTION = `Remote execution and file transfer over ssh / docker / incus, plus persistent named shells. Pick the call shape:
2941
+ {} → roster (local scan, no dialing)
2942
+ {target} → status probe: reachable / unreachable — <native error>, or container state + session layer
2943
+ {target, cmd} → run cmd — oneshot by default; mode "pty" for a persistent shell
2944
+ {target, script:"/abs"} → LOCAL script over stdin to a fixed bash -s (oneshot only; no cmd/stdin/spawn/mode mix)
2945
+ {target:"byossh:lbl", ssh:{host,user?,port?,key?,proxyJump?}} → register this Agent's SSH identity (no cmd = register only); reuse the target later with ssh omitted. Same spec idempotent; different spec refused — use another label. key is an existing origin file, never inline material; nothing written or persisted; a new Agent registers again.
2946
+ {target:"byop:lbl", spawn, cmd} → create the named shell once; reuse as {target:"byop:lbl", cmd} (no spawn)
2947
+ Bare names resolve ssh config → docker → incus → live pty; unmatched goes to ssh and its native error surfaces. Prefixes ssh: / docker: / incus: / byop: force a channel. byop: same spawn repeated is reused; a different spawn is refused ([E_LABEL_CONFLICT], nothing runs); unknown or idle-expired → [E_NO_SESSION] (spawn is not retained — pass it again); a failed spawn frees the label.
2948
+
2949
+ oneshot: stateless bash -lc, stdout/stderr separate, exit code verbatim; the endpoint needs bash only (no Python). A POSIX wrapper reaps backgrounded children on completion and kills the whole group on timeout/cancel — they never outlive the call or hold the channel. pty: one shell per (agent, target); cwd/env survive, streams merge, Ctrl-C interrupts without killing, idle reap after 600s.
2950
+
2951
+ Files — {action:"copy" | "rsync", src:"[target:]abs", dst:"[target:]abs", argv:["-a"]}: argv tokens pass verbatim before src/dst — no whitelist, no forced defaults (dry run = pass "--dry-run" yourself); the CLI's own exit code and stderr come back in the receipt. copy: local↔SSH scp; SSH↔SSH scp -3 through the origin (identities stay local); docker cp; incus file push/pull; any other pair = two commands with origin staging (per-half options via pullArgv/pushArgv, exclusive with argv); scp -R refused; success is NOT independently verified; verify/mkdir only on the Hub↔local lane. rsync: local↔SSH, or SSH↔SSH with one protocol conversation between the peers (real delta; forwarding denied → honest error, never full copy). rsync src trailing slash copies contents, no slash the directory itself.
2952
+
2953
+ Output: pty tails at ~30k chars ("[truncated: …]" header) — redirect bulk output to a file. Hard retention 4,000,000 UTF-16 units per stream (Hub 4MiB bytes); outputStats reports the rest; restart cannot recover discarded output. stdin feeds the command; timeout (seconds) interrupts then kills. run_in_background:true (oneshot) → jobId for job_output/job_kill. Native transport errors pass through verbatim — self-correct from them.`;
1027
2954
  function createRemoteTool(driver, opts = {}) {
1028
2955
  const getSessionKey = opts.getSessionKey ?? ((exec) => exec.agent?.id ?? NO_SESSION_KEY);
1029
- return defineTool({
2956
+ const tool = defineTool({
1030
2957
  name: "remote",
1031
- description: "Remote execution over three transports (ssh / docker / incus) plus named persistent BYO shells. Pick the call shape first:\n {} → roster: known target names from a local scan (no dialing)\n {target} → on-demand status probe of that one target (for \"byop:<label>\": its session state)\n {target, cmd} → run cmd — oneshot by default; mode \"pty\" for a persistent shell\n {target:\"byop:<label>\", spawn, cmd} → BYO-PTY: create the persistent shell you named <label>\n {target:\"byop:<label>\", cmd} → reuse that shell (spawn is only needed to create)\nBYO-PTY labels are yours to choose, and the label alone is the whole reuse mechanism: \"byop:dev3\" is created once with `spawn` (the command that starts an interactive shell, e.g. \"docker exec -it img bash\"), and from then on you address it as {target:\"byop:dev3\", cmd} with no spawn. Repeating the same spawn for a live label is reused and the result tells you to drop it; repeating a *different* spawn is refused with [E_LABEL_CONFLICT] and nothing runs — reuse the label, or pick a new one. A label that was never created, or was reaped after the idle TTL, answers [E_NO_SESSION]: spawn is not retained, so pass it again to re-create. A spawn that fails to come up (e.g. a bad host) leaves nothing behind either — the label stays free, so just retry with a corrected spawn. Bare names match in order — ssh config, then docker, then incus, then a live pty label (on a collision the earlier source wins; a name matching none of them goes to ssh and its native error surfaces). \"byop:<label>\" / \"docker:<name>\" / \"incus:<name>\" / \"ssh:<name>\" force a channel when you must be explicit. oneshot: stateless `bash -lc`, stdout/stderr separate, the exit code is the remote process's own. pty: one persistent shell per (agent, target) — cwd/env survive across calls, streams merge, Ctrl-C interrupts without killing the session, idle sessions are reaped after 600s (a reconnect notice precedes the fresh shell). Output is tail-truncated at ~30k chars with a \"[truncated: …]\" header — redirect bulk output to a file yourself. `stdin` feeds the command; `timeout` (seconds) interrupts then kills. Native transport errors pass through verbatim — self-correct from them. Background processes must redirect their own output (`cmd > log 2>&1 &`). Status probes report \"reachable\"/\"unreachable — <native error>\" or the factual container state plus the session layer (\"idle Ns\" / \"busy\" / \"none — dials on first exec\"), always probed at call time.",
2958
+ description: REMOTE_TOOL_DESCRIPTION,
1032
2959
  parameters: {
2960
+ action: {
2961
+ type: "string",
2962
+ enum: ["copy", "rsync"],
2963
+ description: "Optional file action; omit for execution/status/roster."
2964
+ },
2965
+ src: {
2966
+ type: "string",
2967
+ description: "copy/rsync source [target:]absolute path; rsync retains trailing-slash semantics"
2968
+ },
2969
+ dst: {
2970
+ type: "string",
2971
+ description: "copy/rsync destination [target:]absolute path"
2972
+ },
2973
+ argv: {
2974
+ type: "array",
2975
+ items: { type: "string" },
2976
+ description: "Ordered native CLI options and values for one-command routes (e.g. rsync [\"-a\",\"--partial\",\"--dry-run\"], scp [\"-p\"], docker cp [\"-a\"], incus file push/pull). One element = one argv token, appended verbatim before src/dst; no shell parsing, no whitelist, no forced defaults — unknown options fail in the native CLI itself. rsync only (SSH endpoints): explicit -e/--rsh overrides the tool's SSH wiring. Two-segment cross-endpoint copy uses pullArgv/pushArgv instead."
2977
+ },
2978
+ pullArgv: {
2979
+ type: "array",
2980
+ items: { type: "string" },
2981
+ description: "Options for the PULL half (source endpoint → origin staging) of a two-command cross-endpoint copy (e.g. SSH↔Docker, SSH↔Incus, Docker↔Incus). Mutually exclusive with argv; must be paired with pushArgv. Only options valid for the pull half's CLI."
2982
+ },
2983
+ pushArgv: {
2984
+ type: "array",
2985
+ items: { type: "string" },
2986
+ description: "Options for the PUSH half (origin staging → destination endpoint) of a two-command cross-endpoint copy. Mutually exclusive with argv; must be paired with pullArgv. Only options valid for the push half's CLI."
2987
+ },
2988
+ verify: {
2989
+ type: "boolean",
2990
+ description: "copy only, Hub↔local↔local staged lane only: independent SHA-256 verification (default true). Native CLI routes do not verify and report that fact."
2991
+ },
2992
+ mkdir: {
2993
+ type: "boolean",
2994
+ description: "copy only, Hub↔local↔local staged lane only: create missing destination parents (default true). Native CLI routes let the CLI decide."
2995
+ },
2996
+ run_in_background: {
2997
+ type: "boolean",
2998
+ description: "Oneshot only: return a native jobId immediately, read with job_output, cancel with job_kill; output is available while running. Requires official jobs service/controller."
2999
+ },
1033
3000
  target: {
1034
3001
  type: "string",
1035
- description: "Single entry point. Bare name / IP / domain matches in order: ssh config, then docker, then incus, then a live pty label (a collision goes to the earlier source). \"byop:<label>\" / \"docker:<name>\" / \"incus:<name>\" / \"ssh:<name>\" force a channel. For \"byop:<label>\", pass spawn the first time to create it and omit spawn afterwards to reuse it. Omit cmd → status probe."
3002
+ description: "Single entry point. byossh:<label> reuses this Agent-scoped registered SSH identity (pass ssh on first call; no cmd registers only). Bare name / IP / domain matches in order: ssh config, then docker, then incus, then a live pty label (a collision goes to the earlier source). \"byop:<label>\" / \"docker:<name>\" / \"incus:<name>\" / \"ssh:<name>\" force a channel. For \"byop:<label>\", pass spawn the first time to create it and omit spawn afterwards to reuse it. Omit cmd → status probe."
3003
+ },
3004
+ ssh: {
3005
+ type: "object",
3006
+ description: "Only for byossh:<label>: register origin-side connection; omit on reuse. No inline keys or arbitrary SSH options.",
3007
+ additionalProperties: false,
3008
+ properties: {
3009
+ host: {
3010
+ type: "string",
3011
+ required: true,
3012
+ description: "SSH hostname, IP or native config alias."
3013
+ },
3014
+ user: {
3015
+ type: "string",
3016
+ description: "Login user; omitted preserves native SSH config."
3017
+ },
3018
+ port: {
3019
+ type: "integer",
3020
+ description: "SSH port 1–65535; omitted preserves native SSH config."
3021
+ },
3022
+ key: {
3023
+ type: "string",
3024
+ description: "Existing origin private-key file path, not key contents; never copied or persisted."
3025
+ },
3026
+ proxyJump: {
3027
+ type: "string",
3028
+ description: "Official ProxyJump: comma-separated [user@]host[:port] or native aliases; jump identity remains in origin SSH config."
3029
+ }
3030
+ }
1036
3031
  },
1037
3032
  spawn: {
1038
3033
  type: "string",
@@ -1042,6 +3037,10 @@ function createRemoteTool(driver, opts = {}) {
1042
3037
  type: "string",
1043
3038
  description: "One raw shell string parsed by the remote bash (multi-line = one compound; exit = last command's). Omit with target → status probe; omit everything → roster."
1044
3039
  },
3040
+ script: {
3041
+ type: "string",
3042
+ description: "LOCAL absolute script path. Read before any dialing, sent literally over stdin to fixed bash -s. Oneshot only; cannot combine with cmd, stdin, spawn, pty mode or byop target. Hub inline cmd remains limited to 4096; script body is not argv."
3043
+ },
1045
3044
  mode: {
1046
3045
  type: "string",
1047
3046
  enum: ["oneshot", "pty"],
@@ -1067,13 +3066,18 @@ function createRemoteTool(driver, opts = {}) {
1067
3066
  enum: [
1068
3067
  "exec",
1069
3068
  "status",
1070
- "roster"
3069
+ "roster",
3070
+ "job",
3071
+ "copy",
3072
+ "rsync"
1071
3073
  ]
1072
3074
  },
1073
3075
  text: {
1074
3076
  type: "string",
1075
3077
  required: true
1076
3078
  },
3079
+ jobId: { type: "string" },
3080
+ receipt: { type: "json" },
1077
3081
  exit: { oneOf: [{ type: "integer" }, { type: "null" }] },
1078
3082
  durationMs: { type: "integer" },
1079
3083
  cwd: { type: "string" },
@@ -1081,6 +3085,8 @@ function createRemoteTool(driver, opts = {}) {
1081
3085
  timedOut: { type: "boolean" },
1082
3086
  reconnected: { type: "boolean" },
1083
3087
  truncated: { type: "integer" },
3088
+ outputStats: { type: "json" },
3089
+ outputStatsUnavailable: { type: "boolean" },
1084
3090
  session: { type: "string" },
1085
3091
  notice: { type: "string" }
1086
3092
  }
@@ -1091,13 +3097,105 @@ function createRemoteTool(driver, opts = {}) {
1091
3097
  }]
1092
3098
  },
1093
3099
  async execute(args, exec) {
3100
+ if (args.action === "copy" || args.action === "rsync") {
3101
+ if (!args.src || !args.dst) throw new Error(`${args.action} requires src and dst absolute endpoints`);
3102
+ if ([
3103
+ args.cmd,
3104
+ args.script,
3105
+ args.stdin,
3106
+ args.spawn,
3107
+ args.ssh,
3108
+ args.target,
3109
+ args.mode,
3110
+ args.timeout,
3111
+ args.run_in_background
3112
+ ].some((value) => value !== void 0)) throw new Error(`${args.action} cannot be combined with execution/PTY/job arguments; register byossh separately first`);
3113
+ for (const legacy of [
3114
+ "dryRun",
3115
+ "delete",
3116
+ "excludes",
3117
+ "preserve"
3118
+ ]) if (args[legacy] !== void 0) throw new Error(`${legacy} was removed: pass native options through argv instead (e.g. ${legacy === "dryRun" ? "\"--dry-run\"" : legacy === "delete" ? "\"--delete\"" : legacy === "excludes" ? "\"--exclude=<pattern>\"" : "\"-a\" / \"-r\""})`);
3119
+ if (args.action === "rsync") {
3120
+ if (args.pullArgv !== void 0 || args.pushArgv !== void 0) throw new Error("rsync takes argv only; pullArgv/pushArgv are copy-only fields");
3121
+ if ([args.verify, args.mkdir].some((value) => value !== void 0)) throw new Error("rsync cannot be combined with copy verify/mkdir options; the Hub/local staged lane is copy-only");
3122
+ const start = Date.now();
3123
+ const receipt$1 = await driver.rsync({
3124
+ src: args.src,
3125
+ dst: args.dst,
3126
+ ...args.argv === void 0 ? {} : { argv: args.argv }
3127
+ }, exec.signal, { sessionKey: getSessionKey(exec) });
3128
+ if (receipt$1.exit !== 0) throw new Error(JSON.stringify(receipt$1));
3129
+ return {
3130
+ kind: "rsync",
3131
+ text: JSON.stringify(receipt$1),
3132
+ receipt: receipt$1,
3133
+ exit: receipt$1.exit,
3134
+ durationMs: Date.now() - start
3135
+ };
3136
+ }
3137
+ const receipt = await driver.copy({
3138
+ src: args.src,
3139
+ dst: args.dst,
3140
+ ...args.verify === void 0 ? {} : { verify: args.verify },
3141
+ ...args.mkdir === void 0 ? {} : { mkdir: args.mkdir },
3142
+ ...args.argv === void 0 ? {} : { argv: args.argv },
3143
+ ...args.pullArgv === void 0 ? {} : { pullArgv: args.pullArgv },
3144
+ ...args.pushArgv === void 0 ? {} : { pushArgv: args.pushArgv }
3145
+ }, exec.signal, { sessionKey: getSessionKey(exec) });
3146
+ return {
3147
+ kind: "copy",
3148
+ text: JSON.stringify(receipt),
3149
+ receipt,
3150
+ exit: null,
3151
+ durationMs: 0
3152
+ };
3153
+ }
3154
+ if ([
3155
+ args.src,
3156
+ args.dst,
3157
+ args.argv,
3158
+ args.pullArgv,
3159
+ args.pushArgv,
3160
+ args.verify,
3161
+ args.mkdir
3162
+ ].some((value) => value !== void 0)) throw new Error("file fields require action copy or rsync");
1094
3163
  const hasCmd = typeof args.cmd === "string" && args.cmd.length > 0;
1095
3164
  const hasTarget = typeof args.target === "string" && args.target.length > 0;
1096
3165
  const hasSpawn = typeof args.spawn === "string" && args.spawn.length > 0;
1097
3166
  if (args.mode !== void 0 && args.mode !== "oneshot" && args.mode !== "pty") throw new Error(`[E_BAD_MODE] remote: mode must be 'oneshot' or 'pty' (got ${JSON.stringify(args.mode)})`);
1098
- if (!hasCmd) {
3167
+ if (args.ssh !== void 0 && (!args.target?.startsWith("byossh:") || hasSpawn)) throw new Error("[E_SSH_PARAMS] remote: ssh requires a byossh:<label> target; spawn is only for byop:<label>");
3168
+ if (args.run_in_background) {
3169
+ if (!hasCmd && args.script === void 0) throw new Error("remote: background requires cmd or script");
3170
+ if (args.mode === "pty" || hasSpawn || args.target?.startsWith("byop:")) throw new Error("remote: background requires oneshot; PTY and BYO-PTY are unsupported");
3171
+ const jobs = exec.agent?.ctx.get("jobs");
3172
+ if (!jobs) throw new Error("remote: background requires the official DSH jobs service and job_output/job_kill controller; foreground execution remains available");
3173
+ const jobId = startRemoteJob(jobs, {
3174
+ target: args.target,
3175
+ cmd: args.cmd,
3176
+ script: args.script,
3177
+ ssh: args.ssh,
3178
+ stdin: args.stdin,
3179
+ mode: "oneshot",
3180
+ timeout: args.timeout
3181
+ }, exec, driver);
3182
+ return {
3183
+ kind: "job",
3184
+ jobId,
3185
+ text: `Started ${jobId}. Use job_output to read and job_kill to cancel; completion is reported by the host.`,
3186
+ exit: null,
3187
+ durationMs: 0
3188
+ };
3189
+ }
3190
+ if (!hasCmd && args.script === void 0) {
1099
3191
  if (args.stdin !== void 0) throw new Error("[E_STDIN_WITHOUT_CMD] remote: stdin requires cmd — status probes take no input");
1100
3192
  if (hasSpawn) throw new Error("[E_PARAMS] remote: spawn is an exec channel — pass cmd (there is nothing to probe for BYO-PTY)");
3193
+ if (args.ssh !== void 0) return {
3194
+ kind: "status",
3195
+ text: driver.registerSsh(args.target, args.ssh),
3196
+ exit: null,
3197
+ durationMs: 0
3198
+ };
1101
3199
  if (!hasTarget) return {
1102
3200
  kind: "roster",
1103
3201
  text: await driver.roster({ sessionKey: getSessionKey(exec) }),
@@ -1114,7 +3212,9 @@ function createRemoteTool(driver, opts = {}) {
1114
3212
  const r = await driver.call({
1115
3213
  target: args.target,
1116
3214
  spawn: args.spawn,
3215
+ ssh: args.ssh,
1117
3216
  cmd: args.cmd,
3217
+ script: args.script,
1118
3218
  mode: args.mode === "oneshot" || args.mode === "pty" ? args.mode : void 0,
1119
3219
  stdin: args.stdin,
1120
3220
  timeout: args.timeout
@@ -1130,14 +3230,22 @@ function createRemoteTool(driver, opts = {}) {
1130
3230
  if (r.timedOut) v.timedOut = true;
1131
3231
  if (r.reconnected) v.reconnected = true;
1132
3232
  if (r.truncated !== void 0) v.truncated = r.truncated;
3233
+ if (r.outputStats !== void 0) v.outputStats = r.outputStats;
3234
+ if (r.outputStatsUnavailable) v.outputStatsUnavailable = true;
1133
3235
  if (r.session !== void 0) v.session = r.session;
1134
3236
  if (r.notice !== void 0) v.notice = r.notice;
1135
3237
  return v;
1136
3238
  }
1137
3239
  });
3240
+ const description = tool.description;
3241
+ Object.defineProperty(tool, "description", {
3242
+ enumerable: true,
3243
+ get: () => description + (driver.hasHub() ? "\nHub transport available: hub:<m4> selects a paired remote machine (never self). {} includes the Hub directory even when empty. Uses authenticated Hub HTTP oneshot / persistent WS PTY; no SSH alias needed. Hub failures never fall back to SSH." : "")
3244
+ });
3245
+ return tool;
1138
3246
  }
1139
3247
  function renderValue(v) {
1140
- if (v.kind === "status" || v.kind === "roster") return v.text;
3248
+ if (v.kind === "status" || v.kind === "roster" || v.kind === "job" || v.kind === "copy" || v.kind === "rsync") return v.text;
1141
3249
  const parts = [];
1142
3250
  if (v.reconnected === true) parts.push(RECONNECT_NOTICE);
1143
3251
  if (typeof v.notice === "string" && v.notice.length > 0) parts.push(v.notice);
@@ -1145,7 +3253,8 @@ function renderValue(v) {
1145
3253
  const secs = ((v.durationMs ?? 0) / 1e3).toFixed(1);
1146
3254
  const markers = [];
1147
3255
  if (v.timedOut === true) markers.push("interrupted");
1148
- if (v.truncated !== void 0) markers.push(`truncated, original ${v.truncated} chars`);
3256
+ if (v.truncated !== void 0) markers.push(`truncated, ${v.outputStatsUnavailable ? "returned peer tail" : "original"} ${v.truncated} ${v.outputStats?.stdout.unit === "bytes" ? "bytes" : "chars"}`);
3257
+ if (v.outputStatsUnavailable) markers.push("peer output totals/retention unavailable; returned tail only");
1149
3258
  const suffix = markers.length > 0 ? ` · ${markers.join(" · ")}` : "";
1150
3259
  const cwdTag = typeof v.cwd === "string" && v.cwd.length > 0 ? ` · cwd ${v.cwd}` : "";
1151
3260
  const sessionTag = typeof v.session === "string" && v.session.length > 0 ? ` · ${v.session}` : "";
@@ -1154,6 +3263,84 @@ function renderValue(v) {
1154
3263
  return parts.join("\n");
1155
3264
  }
1156
3265
 
3266
+ //#endregion
3267
+ //#region src/remote/agent-registration.ts
3268
+ /** Host listeners attempt once per official creation edge, never on vacancy. */
3269
+ function installAgentRemote(ctx, config) {
3270
+ return ctx.on("agent/created", ({ agent }) => {
3271
+ const tools = agent.ctx.get("tools");
3272
+ if (!tools) return;
3273
+ const driver = new RemoteDriver({
3274
+ ...config,
3275
+ getHub: () => agent.ctx.get("superdRemoteTransport")
3276
+ });
3277
+ let unregister;
3278
+ try {
3279
+ unregister = tools.register(createRemoteTool(driver, { getSessionKey: () => agent.id }));
3280
+ } catch (error) {
3281
+ driver.dispose();
3282
+ if (error instanceof Error && error.message === "tool \"remote\" is already registered in this scope" && tools.get("remote", agent) !== void 0) return;
3283
+ throw error;
3284
+ }
3285
+ const stopService = agent.ctx.on("internal/service", (name$1) => {
3286
+ if (name$1 === "superdRemoteTransport") agent.ctx.emit("tools/change");
3287
+ });
3288
+ let disposal;
3289
+ let ownerUndo;
3290
+ let agentUndo;
3291
+ const cleanup = () => {
3292
+ if (disposal) return disposal;
3293
+ disposal = driver.dispose();
3294
+ unregister();
3295
+ stopService();
3296
+ ownerUndo?.();
3297
+ agentUndo?.();
3298
+ return disposal;
3299
+ };
3300
+ agentUndo = agent.ctx.effect(() => cleanup, "remote.agentOwner()");
3301
+ ownerUndo = ctx.effect(() => cleanup, "remote.rowOwner()");
3302
+ });
3303
+ }
3304
+
3305
+ //#endregion
3306
+ //#region src/remote/file-worker.ts
3307
+ async function runFileWorker(request) {
3308
+ const abort = new AbortController(), cancel = () => abort.abort(/* @__PURE__ */ new Error("binary file worker cancelled"));
3309
+ process.on("SIGTERM", cancel);
3310
+ process.on("SIGHUP", cancel);
3311
+ const report = (value) => new Promise((resolve$1, reject) => process.stderr.write(JSON.stringify(value) + "\n", (error) => error ? reject(error) : resolve$1()));
3312
+ try {
3313
+ mkdirSync(request.root, { mode: 448 });
3314
+ const endpoint = localFileEndpoint({
3315
+ receiveRoot: request.root,
3316
+ temporary: (path$1, active) => report({
3317
+ temporary: path$1,
3318
+ active
3319
+ })
3320
+ });
3321
+ if (request.action === "export") await pipeline(await endpoint.exportTree(request.path, abort.signal), process.stdout, { signal: abort.signal });
3322
+ else if (request.action === "import") await endpoint.importTree(request.path, process.stdin, {
3323
+ mkdir: request.mkdir,
3324
+ signal: abort.signal
3325
+ });
3326
+ else {
3327
+ let metadata = "";
3328
+ process.stdin.setEncoding("utf8");
3329
+ for await (const chunk of process.stdin) {
3330
+ metadata += String(chunk);
3331
+ if (Buffer.byteLength(metadata) > FILE_MAX_METADATA) throw new Error("file selection metadata limit exceeded");
3332
+ }
3333
+ process.stdout.write(JSON.stringify(await endpoint.digest(request.path, abort.signal, request.selected ? JSON.parse(metadata) : void 0)));
3334
+ }
3335
+ } catch (error) {
3336
+ await report({ error: error instanceof Error ? error.message : String(error) }).catch(() => {});
3337
+ process.exitCode = 1;
3338
+ } finally {
3339
+ process.removeListener("SIGTERM", cancel);
3340
+ process.removeListener("SIGHUP", cancel);
3341
+ }
3342
+ }
3343
+
1157
3344
  //#endregion
1158
3345
  //#region src/remote/plugin.ts
1159
3346
  const name = "dashr-remote";
@@ -1169,15 +3356,7 @@ const Config = Schema.object({
1169
3356
  });
1170
3357
  /** MUST stay an arrow(RM0 plugin.ts 同注:function 声明会被 cordis 当构造器 new 掉,返回的 disposer 丢失)。 */
1171
3358
  const apply = (ctx, config) => {
1172
- const driver = new RemoteDriver({
1173
- execTimeoutSec: config.execTimeoutSec,
1174
- idleTtlSec: config.idleTtlSec,
1175
- maxOutputChars: config.maxOutputChars
1176
- });
1177
- ctx.inject(["tools"], (toolsCtx) => {
1178
- toolsCtx.tools.register(createRemoteTool(driver, { getSessionKey: (exec) => exec.agent?.id ?? "remote:no-session" }));
1179
- });
1180
- return () => void driver.dispose();
3359
+ return installAgentRemote(ctx, config);
1181
3360
  };
1182
3361
  var plugin_default = {
1183
3362
  name,
@@ -1187,4 +3366,4 @@ var plugin_default = {
1187
3366
  };
1188
3367
 
1189
3368
  //#endregion
1190
- export { Config, apply, plugin_default as default, inject, name };
3369
+ export { ByteTailRing, Config, FILE_FRAME_BYTES, FILE_MAX_ENTRIES, FILE_MAX_METADATA, PROCESS_CLEANUP_BOUND_MS, SshTargets, Utf16TailRing, apply, checkAbsolute, checkSelection, plugin_default as default, inject, localFileEndpoint, name, readUtf8Tail, runFileWorker, runRsync, sshArgv, startBinaryProcess, startOneShot, startRemoteJob };