better-dsh 0.3.1 → 0.3.2

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 (72) hide show
  1. package/README.md +6 -5
  2. package/cordis.patch.yml +14 -9
  3. package/docs/20_specs/kernel-provisioning/spec.md +2 -0
  4. package/docs/remote-tools.md +15 -9
  5. package/docs/specs/kernel-provisioning/spec.md +12 -40
  6. package/docs/specs/omp-python-repl-port/runtime-guide.md +3 -1
  7. package/docs/specs/omp-python-repl-port/spec.md +7 -0
  8. package/docs/specs/remote-pty-label/spec.md +2 -0
  9. package/docs/specs/remote-tool-enhancement/spec.md +1 -0
  10. package/lib/BrowserWebSocketTransport.js +4 -0
  11. package/lib/{BrowserWebSocketTransport-wm99h94r.js → BrowserWebSocketTransport2.js} +1 -1
  12. package/lib/LaunchOptions.js +4 -0
  13. package/lib/{LaunchOptions-5eg7E0ye.js → LaunchOptions2.js} +1 -1
  14. package/lib/{NodeWebSocketTransport-D9ysYGLM.js → NodeWebSocketTransport.js} +2 -2
  15. package/lib/NodeWebSocketTransport2.js +4 -0
  16. package/lib/{bidi-CwabFH07.js → bidi.js} +4 -4
  17. package/lib/client/index.js +59 -6
  18. package/lib/compaction/index.d.ts +1 -1
  19. package/lib/compaction/index.js +1 -1
  20. package/lib/failover/index.d.ts +1 -1
  21. package/lib/failover/index.js +1 -1
  22. package/lib/fs-aware/sandbox-plugin.js +1 -1
  23. package/lib/{index-browser-B-9jm12n.js → index-browser.js} +6 -6
  24. package/lib/index.d.ts +2 -12
  25. package/lib/index.js +58 -62
  26. package/lib/kernel-env.d.ts +5 -15
  27. package/lib/kernel-env.js +2 -2
  28. package/lib/kernel-env2.js +76 -0
  29. package/lib/{lsp-server-registry-BcdkHROD.js → lsp-server-registry2.js} +1 -1
  30. package/lib/{main-BeCgUd20.js → main.js} +1 -1
  31. package/lib/{main-Dv_UQqG2.js → main2.js} +1 -1
  32. package/lib/mobile/plugin.d.ts +2 -2
  33. package/lib/mobile/plugin.js +2 -2
  34. package/lib/{puppeteer-core-Dnd7eqel.js → puppeteer-core.js} +9 -9
  35. package/lib/py-sdk.js +1 -1
  36. package/lib/remote/helper-bin/MANIFEST.json +35 -0
  37. package/lib/remote/helper-bin/aarch64-apple-darwin/BUILT-VER +1 -0
  38. package/lib/remote/helper-bin/aarch64-apple-darwin/superd-run +0 -0
  39. package/lib/remote/helper-bin/aarch64-unknown-linux-musl/BUILT-VER +1 -0
  40. package/lib/remote/helper-bin/aarch64-unknown-linux-musl/superd-run +0 -0
  41. package/lib/remote/helper-bin/x86_64-pc-windows-gnu/BUILT-VER +1 -0
  42. package/lib/remote/helper-bin/x86_64-pc-windows-gnu/superd-run.exe +0 -0
  43. package/lib/remote/helper-bin/x86_64-unknown-linux-musl/BUILT-VER +1 -0
  44. package/lib/remote/helper-bin/x86_64-unknown-linux-musl/superd-run +0 -0
  45. package/lib/remote/plugin.d.ts +172 -51
  46. package/lib/remote/plugin.js +2168 -718
  47. package/lib/url-schemes/index.d.ts +11 -11
  48. package/lib/url-schemes/index.js +12 -12
  49. package/lib/{wasm_bg-CNXhHjVg.js → wasm_bg.js} +1 -1
  50. package/lib/web-password.d.ts +7 -7
  51. package/lib/web-password.js +1 -1
  52. package/lib/web-trust.d.ts +1 -1
  53. package/lib/web-trust.js +2 -2
  54. package/lib/{web-trust-D6gJpUMf.js → web-trust2.js} +20 -8
  55. package/lib/welcome-notice.js +1 -1
  56. package/package.json +4 -4
  57. package/scripts/kernel-check.mjs +5 -0
  58. package/lib/BrowserWebSocketTransport-D1aFReu_.js +0 -4
  59. package/lib/LaunchOptions-DxQQ0s-A.js +0 -4
  60. package/lib/NodeWebSocketTransport-BRLjmu2R.js +0 -4
  61. package/lib/kernel-env-BOF0fNHn.js +0 -91
  62. package/scripts/kernel-provision.mjs +0 -5
  63. /package/lib/{chunk-DQxbwPpZ.js → chunk.js} +0 -0
  64. /package/lib/{index-DTDXUe6m.d.ts → index2.d.ts} +0 -0
  65. /package/lib/{lib-C8_jXvwA.js → lib.js} +0 -0
  66. /package/lib/{lsp-server-registry-D4VLs1Sm.js → lsp-server-registry.js} +0 -0
  67. /package/lib/{native-capture-BEByDXYM.js → native-capture.js} +0 -0
  68. /package/lib/{py-sdk-D7klhpWb.js → py-sdk2.js} +0 -0
  69. /package/lib/{util-BHL_dfV_.js → util.js} +0 -0
  70. /package/lib/{web-tree-sitter-CPrPD4J2.js → web-tree-sitter.js} +0 -0
  71. /package/lib/{web-trust-CwZrTBcl.d.ts → web-trust2.d.ts} +0 -0
  72. /package/lib/{wrap-0VA0uFx8.js → wrap.js} +0 -0
@@ -1,14 +1,15 @@
1
- import { t as Schema } from "../lib-C8_jXvwA.js";
1
+ import { t as Schema } from "../lib.js";
2
2
  import { Context } from "@deepseek-ai/cordis";
3
3
  import { createHash, randomBytes, randomUUID } from "node:crypto";
4
- import { constants, createReadStream, createWriteStream, mkdirSync, statSync } from "node:fs";
4
+ import { constants, createReadStream, createWriteStream, existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
5
5
  import { dirname, isAbsolute, join, posix, resolve } from "node:path";
6
6
  import { spawn } from "node:child_process";
7
+ import { fileURLToPath } from "node:url";
8
+ import { homedir, tmpdir } from "node:os";
7
9
  import { defineTool } from "@deepseek-ai/dsh-tools";
8
- import { chmod, lstat, mkdir, mkdtemp, open, readFile, readdir, readlink, rename, rm, symlink, writeFile } from "node:fs/promises";
10
+ import { chmod, lstat, mkdir, mkdtemp, open, readFile, readdir, readlink, rename, rm, stat, symlink, writeFile } from "node:fs/promises";
9
11
  import { pipeline } from "node:stream/promises";
10
12
  import { Readable } from "node:stream";
11
- import { homedir, tmpdir } from "node:os";
12
13
  import { createConnection, createServer } from "node:net";
13
14
 
14
15
  //#region src/remote/archive.ts
@@ -317,17 +318,18 @@ async function copyTree(request, source, destination, signal, scratchRoot = join
317
318
  signal.throwIfAborted();
318
319
  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
320
  await mkdir(scratchRoot, { recursive: true });
320
- signal.throwIfAborted();
321
- const scratch = await mkdtemp(join(scratchRoot, "remote-copy-")), payload = join(scratch, "payload.bin");
322
321
  const receipt = {
323
322
  action: "copy",
323
+ route: "hub-staged",
324
324
  src: request.src,
325
325
  dst: request.dst,
326
- verified: false,
326
+ ok: false,
327
+ verification: "not-applicable",
327
328
  bytes: 0,
328
329
  entries: 0,
329
330
  scope: "transferred entries"
330
331
  };
332
+ const scratch = await mkdtemp(join(scratchRoot, "remote-copy-")), payload = join(scratch, "payload.bin");
331
333
  try {
332
334
  await pipeline(await source.endpoint.exportTree(source.path, signal), createWriteStream(payload, {
333
335
  flags: "wx",
@@ -351,24 +353,91 @@ async function copyTree(request, source, destination, signal, scratchRoot = join
351
353
  const dest = await destination.endpoint.digest(destination.path, signal, selection);
352
354
  receipt.destinationDigest = dest.sha256;
353
355
  if (dest.sha256 !== receipt.sourceDigest) throw new Error("destination digest mismatch");
354
- receipt.verified = true;
356
+ receipt.verification = "sha256";
355
357
  }
356
358
  signal.throwIfAborted();
357
359
  await rm(scratch, {
358
360
  recursive: true,
359
361
  force: true
360
362
  });
363
+ receipt.ok = true;
361
364
  return receipt;
362
365
  } catch (error) {
366
+ const message = error instanceof Error ? error.message : String(error);
367
+ const reason = signal.aborted ? "aborted" : /mismatch/i.test(message) ? "digest-mismatch" : "staging-error";
363
368
  throw new CopyError({
364
369
  ...receipt,
365
- verified: false,
370
+ ok: false,
371
+ reason,
366
372
  scratch,
367
- error: error instanceof Error ? error.message : String(error)
373
+ error: message
368
374
  });
369
375
  }
370
376
  }
371
377
 
378
+ //#endregion
379
+ //#region src/remote/ssh-argv.ts
380
+ /**
381
+ * ONE identity-assembly policy for every SSH-speaking lane (user close-out
382
+ * ruling 2026-10-17: "four builders necessarily mean four identity
383
+ * semantics" — the structural root of F1).
384
+ *
385
+ * Assembly: explicit connection fields (byossh registrations; caller options
386
+ * may override later by the CLI's own last-option-wins) in ONE order —
387
+ * user, port, key, proxyJump — spoken in the target CLI's dialect.
388
+ *
389
+ * Resolution: config-side identity (aliases, config-only IdentityFile paths)
390
+ * is read from the ORIGIN's own ssh config natively by every lane. The single
391
+ * exception is the scp -3 temp config (ssh -F sees no user config), where
392
+ * tempSshConfig inlines the `ssh -G`-resolved fields — paths only, never key
393
+ * material (F1, 2026-10-14). That policy lives beside this builder in
394
+ * native-copy.ts and must stay the only such inline site.
395
+ *
396
+ * Former duplicate builders now reduced here: sshArgv (execution/probes),
397
+ * identityArgv (scp routes), rsyncRsh (RSYNC_RSH wiring), relaySsh
398
+ * (dual-SSH relay). Behavior is byte-identical to each former site.
399
+ */
400
+ /** Identity options ONLY for one connection, in the target CLI's dialect:
401
+ * user, port, key, proxyJump — conditional, one order, NO base flags (each
402
+ * lane's dialect base — BatchMode, -T, hardening — stays at its call site).
403
+ * scp carries the user inside its user@host operand, so the scp dialect
404
+ * never emits -l; its port flag is -P. */
405
+ function sshIdentityArgv(connection, dialect) {
406
+ const args = [];
407
+ if (dialect === "ssh" && connection.user !== void 0) args.push("-l", connection.user);
408
+ if (connection.port !== void 0) args.push(dialect === "scp" ? "-P" : "-p", String(connection.port));
409
+ if (connection.key !== void 0) args.push("-i", connection.key);
410
+ if (connection.proxyJump !== void 0) args.push("-J", connection.proxyJump);
411
+ return args;
412
+ }
413
+ /** Full ssh invocation around the shared identity core. base and extra are
414
+ * trusted internal argv (dialect flags incl. BatchMode, forwarding options)
415
+ * — never a model-provided shell string. */
416
+ /** SSH keepalive for PERSISTENT lanes (pty ruling 2026-10-19): client-owned,
417
+ * fixed cadence — OpenSSH has NO protocol-default keepalive unit (default is
418
+ * disabled), so we set our own in our own argv. Control-channel probes never
419
+ * touch the PTY byte stream; NAT/WiFi idle expiry (~5min) would otherwise
420
+ * silently kill an idle-but-wanted session inside the 15min TTL. CountMax=2
421
+ * gives ~6min honest dead-detection instead of a hang on next write. */
422
+ const SSH_PERSISTENT_KEEPALIVE = [
423
+ "-o",
424
+ "ServerAliveInterval=180",
425
+ "-o",
426
+ "ServerAliveCountMax=2"
427
+ ];
428
+ function sshCommandArgv(connection, base, extra, command) {
429
+ const args = [
430
+ "ssh",
431
+ ...base,
432
+ ...sshIdentityArgv(connection, "ssh"),
433
+ ...extra,
434
+ "--",
435
+ connection.host
436
+ ];
437
+ if (command !== void 0) args.push(command);
438
+ return args;
439
+ }
440
+
372
441
  //#endregion
373
442
  //#region src/remote/output-ring.ts
374
443
  const continuation = (byte) => (byte & 192) === 128;
@@ -674,15 +743,17 @@ function scpSpec(connection, path$1) {
674
743
  const host = connection.host.includes(":") ? `[${connection.host}]` : connection.host;
675
744
  return `${connection.user !== void 0 ? `${connection.user}@${host}` : host}:${path$1}`;
676
745
  }
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). */
746
+ /** Connection identity as scp options, placed BEFORE caller argv so an
747
+ * explicit caller -F/-i/-P/-J overrides the registered identity by scp's own
748
+ * precedence (last option wins). Assembly itself lives in ssh-argv.ts
749
+ * (the ONE identity policy — 2026-10-17 ruling); only the scp dialect
750
+ * placement rule is local. */
680
751
  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;
752
+ return [
753
+ "-o",
754
+ "BatchMode=yes",
755
+ ...sshIdentityArgv(connection, "scp")
756
+ ];
686
757
  }
687
758
  function sshEndpoint(plan) {
688
759
  if (plan.kind !== "ssh") throw new Error("remote: native copy endpoint is not an SSH target");
@@ -721,24 +792,30 @@ async function nativeCopy(request, endpoints, signal, options = {}) {
721
792
  stdout: [],
722
793
  stderr: [],
723
794
  outputTruncated: false,
724
- verified: false,
795
+ verification: "native-exit-only",
796
+ ok: true,
725
797
  cleanup: "none-required"
726
798
  };
727
- const finish = (route, argvs, results) => {
799
+ const finish = (route, argvs, results, internal = []) => {
728
800
  receipt.route = route;
729
- receipt.argv = argvs;
801
+ receipt.argv = argvs.map((argv) => argv.map((token) => internal.find(([secret]) => secret === token)?.[1] ?? token));
730
802
  receipt.exits = results.map((result) => result.exit);
731
803
  receipt.stdout = results.map((result) => result.stdout);
732
804
  receipt.stderr = results.map((result) => result.stderr);
733
805
  receipt.outputTruncated = results.some((result) => result.outputTruncated);
806
+ receipt.ok = !signal.aborted && receipt.exits.every((exit) => exit === 0);
807
+ if (!receipt.ok) receipt.reason = signal.aborted ? "aborted" : "non-zero-exit";
734
808
  return receipt;
735
809
  };
810
+ if (request.mkdir !== false) await ensureDestinationDir(endpoints.dst, signal, options.execArgvFor, wrap, receipt);
811
+ else receipt.created = "n-a";
736
812
  if (s === "local" && t === "ssh") {
737
813
  rejectRemoteToRemoteScp(oneShot);
738
814
  const connection = sshEndpoint(endpoints.dst.plan).connection;
739
815
  const argv = [
740
816
  "scp",
741
817
  ...identityArgv(connection),
818
+ ...await recursiveFlag(endpoints.src.path, oneShot, "-r"),
742
819
  ...oneShot,
743
820
  endpoints.src.path,
744
821
  scpSpec(connection, endpoints.dst.path)
@@ -771,7 +848,7 @@ async function nativeCopy(request, endpoints, signal, options = {}) {
771
848
  dstSpec = scpSpec(destination, endpoints.dst.path);
772
849
  extra = identityArgv(source);
773
850
  } else {
774
- config = await tempSshConfig(source, destination);
851
+ config = await tempSshConfig(source, destination, runNative);
775
852
  extra = ["-F", config.path];
776
853
  srcSpec = `superd-scp3-source:${endpoints.src.path}`;
777
854
  dstSpec = `superd-scp3-destination:${endpoints.dst.path}`;
@@ -784,7 +861,7 @@ async function nativeCopy(request, endpoints, signal, options = {}) {
784
861
  srcSpec,
785
862
  dstSpec
786
863
  ];
787
- return finish("scp -3 (data through origin)", [argv], [await runNative(wrap(argv), signal)]);
864
+ return finish("scp-3", [argv], [await runNative(wrap(argv), signal)], config !== void 0 ? [[config.path, "<temp-ssh-config>"]] : []);
788
865
  } catch (error) {
789
866
  if (signal.aborted) unknown = true;
790
867
  throw error;
@@ -803,7 +880,9 @@ async function nativeCopy(request, endpoints, signal, options = {}) {
803
880
  endpoints.src.path,
804
881
  `${containerOf(endpoints.dst.plan)}:${endpoints.dst.path}`
805
882
  ];
806
- return finish("docker cp", [argv], [await runNative(wrap(argv), signal)]);
883
+ const result = await runNative(wrap(argv), signal);
884
+ withSymlinkHint(result);
885
+ return finish("docker-cp", [argv], [result]);
807
886
  }
808
887
  if (s === "docker" && t === "local") {
809
888
  const argv = [
@@ -813,18 +892,21 @@ async function nativeCopy(request, endpoints, signal, options = {}) {
813
892
  `${containerOf(endpoints.src.plan)}:${endpoints.src.path}`,
814
893
  endpoints.dst.path
815
894
  ];
816
- return finish("docker cp", [argv], [await runNative(wrap(argv), signal)]);
895
+ const result = await runNative(wrap(argv), signal);
896
+ withSymlinkHint(result);
897
+ return finish("docker-cp", [argv], [result]);
817
898
  }
818
899
  if (s === "local" && t === "incus") {
819
900
  const argv = [
820
901
  "incus",
821
902
  "file",
822
903
  "push",
904
+ ...await recursiveFlag(endpoints.src.path, oneShot, "-r"),
823
905
  ...oneShot,
824
906
  endpoints.src.path,
825
907
  `${containerOf(endpoints.dst.plan)}${endpoints.dst.path}`
826
908
  ];
827
- return finish("incus file push", [argv], [await runNative(wrap(argv), signal)]);
909
+ return finish("incus-file-push", [argv], [await runNative(wrap(argv), signal)]);
828
910
  }
829
911
  if (s === "incus" && t === "local") {
830
912
  const argv = [
@@ -835,7 +917,7 @@ async function nativeCopy(request, endpoints, signal, options = {}) {
835
917
  `${containerOf(endpoints.src.plan)}${endpoints.src.path}`,
836
918
  endpoints.dst.path
837
919
  ];
838
- return finish("incus file pull", [argv], [await runNative(wrap(argv), signal)]);
920
+ return finish("incus-file-pull", [argv], [await runNative(wrap(argv), signal)]);
839
921
  }
840
922
  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
923
  const pullOptions = request.pullArgv !== void 0 ? pull : oneShot;
@@ -848,9 +930,12 @@ async function nativeCopy(request, endpoints, signal, options = {}) {
848
930
  const pullArgv = segmentArgv(endpoints.src.plan, endpoints.src.path, staged, pullOptions, "pull");
849
931
  const pushArgv = segmentArgv(endpoints.dst.plan, endpoints.dst.path, staged, pushOptions, "push");
850
932
  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]);
933
+ if (first.exit !== 0) {
934
+ receipt.note = "push not started: pull failed";
935
+ return finish("staged-pull-push", [pullArgv], [first], [[staged, "<staging>"]]);
936
+ }
852
937
  const second = await runNative(wrap(pushArgv), signal);
853
- return finish("two native commands with origin staging", [pullArgv, pushArgv], [first, second]);
938
+ return finish("staged-pull-push", [pullArgv, pushArgv], [first, second], [[staged, "<staging>"]]);
854
939
  } finally {
855
940
  receipt.cleanup = await rm(scratch, {
856
941
  recursive: true,
@@ -911,40 +996,162 @@ function segmentArgv(plan, remote, staged, opts, leg) {
911
996
  spec
912
997
  ];
913
998
  }
999
+ /** D3 mkdir pre-hop: ensure the destination's PARENT directory exists before
1000
+ * the transfer (never the operand itself — see parent-only note below).
1001
+ * transfer. Local dst is node-side; remote dst runs ONE exec `mkdir -p` probe
1002
+ * whose stdout discloses created-vs-existed. Best-effort by contract: any
1003
+ * failure records created:'n-a' + note and lets the copy proceed so the native
1004
+ * CLI surfaces the real error (and stopped-container docker cp keeps working). */
1005
+ async function ensureDestinationDir(dst, signal, execArgvFor, wrap, receipt) {
1006
+ const dir = dirname(dst.path);
1007
+ if (dir === "/" || dir === ".") {
1008
+ receipt.created = false;
1009
+ return;
1010
+ }
1011
+ if (dst.plan.kind === "local") {
1012
+ try {
1013
+ if (existsSync(dir)) {
1014
+ receipt.created = false;
1015
+ return;
1016
+ }
1017
+ mkdirSync(dir, { recursive: true });
1018
+ receipt.created = true;
1019
+ } catch (error) {
1020
+ receipt.created = "n-a";
1021
+ receipt.note = `mkdir pre-hop failed: ${error instanceof Error ? error.message : String(error)}`;
1022
+ }
1023
+ return;
1024
+ }
1025
+ if (execArgvFor === void 0) {
1026
+ receipt.created = "n-a";
1027
+ return;
1028
+ }
1029
+ if (/[\r\n]/.test(dir)) {
1030
+ receipt.created = "n-a";
1031
+ receipt.note = "mkdir pre-hop skipped: newline-bearing destination path";
1032
+ return;
1033
+ }
1034
+ const quoted = `'${dir.replaceAll("'", "'\\\\''")}'`;
1035
+ const script = `if [ -d ${quoted} ]; then echo existed; else mkdir -p ${quoted} && echo created; fi`;
1036
+ try {
1037
+ const result = await runNative(wrap(execArgvFor(dst.plan, script)), signal);
1038
+ const word = result.stdout.trim().split("\n").pop();
1039
+ if (result.exit === 0 && word === "created") receipt.created = true;
1040
+ else if (result.exit === 0 && word === "existed") receipt.created = false;
1041
+ else {
1042
+ receipt.created = "n-a";
1043
+ const tail = result.stderr.trim().split("\n").filter((line) => line.length > 0).pop();
1044
+ receipt.note = `mkdir pre-hop unavailable (exit ${result.exit})${tail !== void 0 ? `: ${tail}` : ""}`;
1045
+ }
1046
+ } catch (error) {
1047
+ receipt.created = "n-a";
1048
+ receipt.note = `mkdir pre-hop failed: ${error instanceof Error ? error.message : String(error)}`;
1049
+ }
1050
+ }
914
1051
  /** Distinguishing identities for scp -3: an origin-only temp config maps two
915
1052
  * 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)`);
1053
+ /** D8 (2026-10-14 user adoption, third acceptance round): a LOCAL directory source gets
1054
+ * the route's recursive flag appended automatically — scp / incus file push refuse
1055
+ * directories without it. The appended flag lands in the executed argv, which the
1056
+ * receipt shows verbatim: disclosure by receipt, never silent. Remote sources stay
1057
+ * caller-explicit (we do not dial just to stat); an explicit caller flag wins. */
1058
+ async function recursiveFlag(localPath, callerArgv, flag) {
1059
+ if (callerArgv.includes(flag) || callerArgv.includes("--recursive")) return [];
1060
+ try {
1061
+ if ((await stat(localPath)).isDirectory()) return [flag];
1062
+ } catch {}
1063
+ return [];
1064
+ }
1065
+ /** docker cp follows symlinks only with -L; its native error ("invalid symlink")
1066
+ * names no remedy. Append the one-line hint on that failure path (F5, 2026-10-14). */
1067
+ function withSymlinkHint(result) {
1068
+ if (result.exit !== 0 && /invalid symlink/i.test(result.stderr)) result.stderr = `${result.stderr}\nhint: docker cp follows symlinks only with -L — pass argv:["-L"] (e.g. /etc/os-release is a symlink)`;
1069
+ }
1070
+ async function tempSshConfig(source, destination, runNative$1) {
1071
+ const resolvedSource = await resolveThroughOriginConfig(source, runNative$1);
1072
+ const resolvedDestination = await resolveThroughOriginConfig(destination, runNative$1);
1073
+ for (const { connection: c, identityFiles } of [resolvedSource, resolvedDestination]) {
1074
+ for (const [field, value] of [
1075
+ ["host", c.host],
1076
+ ["user", c.user],
1077
+ ["key", c.key],
1078
+ ["proxyJump", c.proxyJump]
1079
+ ]) if (value !== void 0 && /[\r\n]/.test(value)) throw new Error(`remote: scp -3 temp config rejects newline in SSH ${field} (no config injection)`);
1080
+ for (const idFile of identityFiles) if (/[\r\n]/.test(idFile)) throw new Error("remote: scp -3 temp config rejects newline in resolved IdentityFile (no config injection)");
1081
+ }
923
1082
  const dir = await mkdtemp(join(tmpdir(), "superd-scp3-"));
924
1083
  const path$1 = join(dir, "config");
925
- const block = (name$1, c) => {
1084
+ const block = (name$1, c, identityFiles) => {
926
1085
  const lines = [`Host ${name$1}`, ` HostName ${c.host.replace(/^.*@/, "")}`];
927
1086
  if (c.user !== void 0) lines.push(` User ${c.user}`);
928
1087
  if (c.port !== void 0) lines.push(` Port ${String(c.port)}`);
929
- if (c.key !== void 0) lines.push(` IdentityFile ${c.key}`);
1088
+ for (const idFile of [c.key !== void 0 ? [c.key] : [], identityFiles].flat()) lines.push(` IdentityFile ${idFile}`);
930
1089
  if (c.proxyJump !== void 0) lines.push(` ProxyJump ${c.proxyJump}`);
931
1090
  return lines.join("\n");
932
1091
  };
933
- await writeFile(path$1, `${block("superd-scp3-source", source)}\n${block("superd-scp3-destination", destination)}\n`, { mode: 384 });
1092
+ await writeFile(path$1, `${block("superd-scp3-source", resolvedSource.connection, resolvedSource.identityFiles)}\n${block("superd-scp3-destination", resolvedDestination.connection, resolvedDestination.identityFiles)}\n`, { mode: 384 });
934
1093
  await chmod(path$1, 384);
935
1094
  return {
936
1095
  path: path$1,
937
1096
  dir
938
1097
  };
939
1098
  }
1099
+ /** Effective fields for one connection per the origin's own ssh config (ssh -G,
1100
+ * prints resolved settings without dialing). Explicit fields win; empty/none
1101
+ * proxyjump is dropped. IdentityFile PATHS from the config are returned separately
1102
+ * for inlining into the temp config — a path is not key material and stays on the
1103
+ * origin (F1, 2026-10-14: dev4's only authorized key lived in config, not the agent;
1104
+ * dropping it made scp -3 fail with a bare Permission denied). */
1105
+ async function resolveThroughOriginConfig(c, runNative$1) {
1106
+ let probe;
1107
+ try {
1108
+ probe = await runNative$1([
1109
+ "ssh",
1110
+ "-G",
1111
+ c.host
1112
+ ], new AbortController().signal);
1113
+ } catch {
1114
+ probe = void 0;
1115
+ }
1116
+ if (probe === void 0 || probe.exit !== 0) return {
1117
+ connection: c,
1118
+ identityFiles: []
1119
+ };
1120
+ const lines = probe.stdout.split("\n").map((line) => line.trim()).filter((line) => line.length > 0);
1121
+ const pick = (key) => lines.find((line) => line.startsWith(`${key} `))?.slice(key.length + 1).trim() || void 0;
1122
+ const home = process.env.HOME ?? "";
1123
+ const compiledDefault = (path$1) => path$1 === "none" || [
1124
+ "id_rsa",
1125
+ "id_ecdsa",
1126
+ "id_ecdsa_sk",
1127
+ "id_ed25519",
1128
+ "id_ed25519_sk"
1129
+ ].some((name$1) => path$1 === `~/.ssh/${name$1}` || home !== "" && path$1 === `${home}/.ssh/${name$1}`);
1130
+ const identityFiles = [...new Set(lines.filter((line) => line.startsWith("identityfile ")).map((line) => line.slice(13).trim()).filter((path$1) => path$1 !== "" && !compiledDefault(path$1)))];
1131
+ const host = (pick("hostname") ?? c.host).replace(/^.*@/, "");
1132
+ const user = c.user ?? pick("user");
1133
+ const portText = pick("port");
1134
+ const port = c.port ?? (portText !== void 0 && /^\d+$/.test(portText) ? Number(portText) : void 0);
1135
+ const proxyJumpRaw = c.proxyJump ?? pick("proxyjump");
1136
+ return {
1137
+ connection: {
1138
+ host,
1139
+ ...user !== void 0 && user !== "" ? { user } : {},
1140
+ ...port !== void 0 && Number.isInteger(port) && port > 0 ? { port } : {},
1141
+ ...c.key !== void 0 ? { key: c.key } : {},
1142
+ ...proxyJumpRaw !== void 0 && proxyJumpRaw !== "" && proxyJumpRaw.toLowerCase() !== "none" ? { proxyJump: proxyJumpRaw } : {}
1143
+ },
1144
+ identityFiles
1145
+ };
1146
+ }
940
1147
 
941
1148
  //#endregion
942
1149
  //#region src/remote/ssh-rsync-native-relay.ts
943
1150
  /** ssh argv for the relay: batch, no agent, no multiplexing. `command` is the
944
- * remote command; forwarding options go before `--`, the host after it. */
1151
+ * remote command; forwarding options go before `--`, the host after it.
1152
+ * Identity core comes from ssh-argv.ts (the ONE policy — 2026-10-17 ruling). */
945
1153
  function relaySsh(connection, extra, command) {
946
- const args = [
947
- "ssh",
1154
+ return sshCommandArgv(connection, [
948
1155
  "-o",
949
1156
  "BatchMode=yes",
950
1157
  "-T",
@@ -961,14 +1168,7 @@ function relaySsh(connection, extra, command) {
961
1168
  "ServerAliveInterval=5",
962
1169
  "-o",
963
1170
  "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;
1171
+ ], extra, command);
972
1172
  }
973
1173
  async function freePorts(count) {
974
1174
  const servers = [];
@@ -1123,12 +1323,16 @@ async function forwardRoute(argv, source, destination, signal, options) {
1123
1323
  return await removeRemoteTemp(destination.connection, temp, wrap) ? "released" : "unknown";
1124
1324
  };
1125
1325
  try {
1326
+ signal.throwIfAborted();
1327
+ const daemonState = await waitForDaemonReady(destinationChild, 5e3);
1328
+ if (daemonState === "exited") throw new Error(`dual-SSH rsync destination daemon exited before serving${relayDetail(destinationChild, sourceHold)}`);
1329
+ if (daemonState === "timeout") throw new Error(`dual-SSH rsync destination daemon never announced readiness${relayDetail(destinationChild, sourceHold)}`);
1126
1330
  signal.throwIfAborted();
1127
1331
  if (!await originPortOpen(originPort, 15e3)) throw new Error(`dual-SSH rsync destination daemon never became reachable${relayDetail(destinationChild, sourceHold)}`);
1128
1332
  signal.throwIfAborted();
1129
1333
  const runArgv = relaySsh(source.connection, [], rsyncClientCommand(argv, source.path, sourcePort));
1130
1334
  const result = await runNative(wrap(runArgv), signal);
1131
- if (result.exit === 255) result.stderr = (result.stderr + relayDetail(destinationChild, sourceHold)).slice(-OUTPUT_LIMIT);
1335
+ if (result.exit !== 0) result.stderr = (result.stderr + relayDetail(destinationChild, sourceHold)).slice(-OUTPUT_LIMIT);
1132
1336
  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
1337
  return {
1134
1338
  ...result,
@@ -1153,6 +1357,19 @@ async function forwardRoute(argv, source, destination, signal, options) {
1153
1357
  await Promise.allSettled([destinationChild.stop(), sourceHold.stop()]);
1154
1358
  }
1155
1359
  }
1360
+ /** Wait until the destination daemon script announces itself (stderr marker,
1361
+ * printed after its directory check and right before the daemon exec) or exits.
1362
+ * 'ready' only when the daemon is actually about to serve — closes the 0.3.1-e
1363
+ * race where -L opens at session start but the script dies ~150ms later. */
1364
+ async function waitForDaemonReady(held, budgetMs) {
1365
+ const deadline = Date.now() + budgetMs;
1366
+ for (;;) {
1367
+ if (/^SUPERD-RSYNC-TMP \S+$/m.test(held.stderrSink.text)) return "ready";
1368
+ if (held.child.exitCode !== null) return "exited";
1369
+ if (Date.now() >= deadline) return "timeout";
1370
+ await new Promise((resolve$1) => setTimeout(resolve$1, 50));
1371
+ }
1372
+ }
1156
1373
  function relayDetail(...held) {
1157
1374
  const detail = held.map((child) => child.stderrSink.text).filter(Boolean).join("\n");
1158
1375
  return detail ? `\n[relay stderr]\n${detail}` : "";
@@ -1234,14 +1451,7 @@ async function runRsync(request, source, destination, signal, options = {}) {
1234
1451
  checkAbsolute(destination.path);
1235
1452
  const argv = checkArgv("argv", request.argv);
1236
1453
  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)`);
1454
+ for (const key of Object.keys(request)) if (key !== "src" && key !== "dst" && key !== "argv") throw new Error(`remote: rsync rejects unknown field "${key}" — native options go through argv (verify is a copy Hub-lane option; mkdir is copy-only; for destination parents pass --mkpath yourself)`);
1245
1455
  if (!source.connection && !destination.connection) throw new Error("rsync requires at least one SSH endpoint; local-only is unsupported");
1246
1456
  let result;
1247
1457
  let route;
@@ -1289,6 +1499,9 @@ async function runRsync(request, source, destination, signal, options = {}) {
1289
1499
  dst: request.dst,
1290
1500
  argv,
1291
1501
  route,
1502
+ verification: "native-exit-only",
1503
+ ok: result.exit === 0,
1504
+ ...result.exit === 0 ? {} : { reason: signal.aborted ? "aborted" : "non-zero-exit" },
1292
1505
  command: `rsync ${argv.join(" ")}`,
1293
1506
  ...itemized.length > 0 || argv.includes("--itemize-changes") || argv.includes("-i") ? { itemizedChanges: itemized } : {},
1294
1507
  ...literalBytes === void 0 ? {} : { literalBytes },
@@ -1303,21 +1516,14 @@ async function runRsync(request, source, destination, signal, options = {}) {
1303
1516
  * placeholder host rsync appends and execs the canonical SSH argv. All tokens
1304
1517
  * are tool-built argv (identity options/aliases), never model text. */
1305
1518
  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
1519
  return [
1318
1520
  "bash",
1319
1521
  "-c",
1320
- `shift; exec ${wrap(args).map(rshQuote).join(" ")} "$@"`,
1522
+ `shift; exec ${wrap(sshCommandArgv(connection, [
1523
+ "-o",
1524
+ "BatchMode=yes",
1525
+ "-T"
1526
+ ], [])).map(rshQuote).join(" ")} "$@"`,
1321
1527
  "rsync-rsh"
1322
1528
  ].map(rshQuote).join(" ");
1323
1529
  }
@@ -1336,7 +1542,7 @@ function parseFileEndpoint(value) {
1336
1542
  if (split < 1) throw new Error("file endpoint must be [target:]absolute/path");
1337
1543
  const target = value.slice(0, split), path$1 = value.slice(split + 1);
1338
1544
  checkAbsolute(path$1);
1339
- if (target.startsWith("byop:")) throw new Error("BYO-PTY has no binary file lane; select SSH/Docker/Incus/Hub");
1545
+ if (target.startsWith("pty:")) throw new Error("a PTY session is not a filesystem endpoint; select SSH/Docker/Incus/Hub");
1340
1546
  return {
1341
1547
  target,
1342
1548
  path: path$1
@@ -1345,13 +1551,9 @@ function parseFileEndpoint(value) {
1345
1551
 
1346
1552
  //#endregion
1347
1553
  //#region src/remote/ssh-targets.ts
1348
- const LABEL = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
1349
1554
  const USER = /^[A-Za-z0-9_][A-Za-z0-9_.-]*$/;
1350
1555
  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
1556
  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
1557
  function bad(field) {
1356
1558
  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
1559
  }
@@ -1391,48 +1593,20 @@ function normalizeSshConnection(input) {
1391
1593
  ...input.proxyJump === void 0 ? {} : { proxyJump: input.proxyJump }
1392
1594
  });
1393
1595
  }
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
1596
  /** Shared by execution, probes and file/relay consumers. extraArgs is trusted
1416
- * internal SSH argv (e.g. forwarding), never a model-provided shell string. */
1597
+ * internal SSH argv (e.g. forwarding), never a model-provided shell string.
1598
+ * Identity assembly lives in ssh-argv.ts (the ONE policy — 2026-10-17 ruling);
1599
+ * this wrapper only picks the tty/batch dialect base. */
1417
1600
  function sshArgv(connection, options = {}) {
1418
- const args = options.tty ? [
1419
- "ssh",
1601
+ return sshCommandArgv(connection, options.tty ? [
1420
1602
  "-tt",
1421
1603
  "-o",
1422
1604
  "BatchMode=yes"
1423
1605
  ] : [
1424
- "ssh",
1425
1606
  "-o",
1426
1607
  "BatchMode=yes",
1427
1608
  "-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;
1609
+ ], options.extraArgs ?? [], options.command);
1436
1610
  }
1437
1611
 
1438
1612
  //#endregion
@@ -1440,17 +1614,17 @@ function sshArgv(connection, options = {}) {
1440
1614
  /** Local carrier validation precedes any target discovery, connection or spawn. */
1441
1615
  async function prepareRemoteInput(input) {
1442
1616
  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");
1617
+ if (input.scriptPath !== void 0) {
1618
+ if (input.cmd !== void 0 || input.stdin !== void 0 || input.mode === "pty" || input.spawn !== void 0) throw new Error("[E_SCRIPT_PARAMS] remote: script is oneshot-only and cannot be combined with cmd, stdin, spawn or PTY lanes");
1619
+ if (typeof input.scriptPath !== "string" || !isAbsolute(input.scriptPath) || input.scriptPath.includes("\0")) throw new Error("[E_SCRIPT_PATH] remote: script must be a local absolute path without NUL");
1446
1620
  }
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) {
1621
+ if (!input.target && input.spawn === void 0 && input.ssh === void 0) throw new Error("[E_PARAMS] remote: target is required (spawn / ssh are BYO entry points and may stand alone)");
1622
+ if (input.scriptPath !== void 0) {
1449
1623
  let stdin;
1450
1624
  try {
1451
- stdin = await readFile(input.script, "utf8");
1625
+ stdin = await readFile(input.scriptPath, "utf8");
1452
1626
  } 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)}`);
1627
+ throw new Error(`[E_SCRIPT_READ] remote: cannot read local script ${JSON.stringify(input.scriptPath)} — ${error instanceof Error ? error.message : String(error)}`);
1454
1628
  }
1455
1629
  return {
1456
1630
  target: input.target,
@@ -1489,6 +1663,96 @@ async function runOneShot(argv, opts = {}) {
1489
1663
  };
1490
1664
  }
1491
1665
 
1666
+ //#endregion
1667
+ //#region src/remote/helper.ts
1668
+ /** Reads helper-bin/MANIFEST.json next to the compiled module (lib/remote in the npm
1669
+ * package, super-dsh/remote in the Super closure — same relative layout). Missing or
1670
+ * malformed assets ⇒ null ⇒ shim-only mode; never throws. */
1671
+ function helperAssets(baseDir) {
1672
+ const dir = baseDir ?? join(dirname(fileURLToPath(import.meta.url)), "helper-bin");
1673
+ try {
1674
+ const manifest = JSON.parse(readFileSync(join(dir, "MANIFEST.json"), "utf8"));
1675
+ if (manifest?.format !== 1 || typeof manifest.ver !== "string" || typeof manifest.protocol !== "number" || !manifest.targets || typeof manifest.targets !== "object") return null;
1676
+ return {
1677
+ dir,
1678
+ manifest
1679
+ };
1680
+ } catch {
1681
+ return null;
1682
+ }
1683
+ }
1684
+ /** `uname -s`/`uname -m` → shipped target triple. Unknown combos ⇒ null (shim). */
1685
+ function archTriple(sys, machine, shipped) {
1686
+ const s = sys.trim().toLowerCase(), m = machine.trim().toLowerCase();
1687
+ const candidates = s === "linux" ? m === "x86_64" || m === "amd64" ? "x86_64-unknown-linux-musl" : m === "aarch64" || m === "arm64" ? "aarch64-unknown-linux-musl" : null : s === "darwin" ? m === "arm64" || m === "aarch64" ? "aarch64-apple-darwin" : null : null;
1688
+ if (candidates === null) return null;
1689
+ return shipped.has(candidates) ? candidates : null;
1690
+ }
1691
+ function planKey(plan) {
1692
+ switch (plan.kind) {
1693
+ case "ssh": {
1694
+ const c = plan.connection;
1695
+ const host = c?.host ?? plan.host;
1696
+ return `ssh|${c?.user ? c.user + "@" : ""}${host}${c?.port ? ":" + c.port : ""}${c?.proxyJump ? "|" + c.proxyJump : ""}`;
1697
+ }
1698
+ case "docker": return `docker|${plan.container}`;
1699
+ case "incus": return `incus|${plan.container}`;
1700
+ }
1701
+ }
1702
+ const PROBE_RE = /^SUPERD-PROBE (\S+) (\S+)$/m;
1703
+ const READY_RE = /^SUPERD-READY (\S+)$/m;
1704
+ var HelperRegistry = class {
1705
+ states = /* @__PURE__ */ new Map();
1706
+ constructor(assets, exec, probeArgv, uploadArgv) {
1707
+ this.assets = assets;
1708
+ this.exec = exec;
1709
+ this.probeArgv = probeArgv;
1710
+ this.uploadArgv = uploadArgv;
1711
+ }
1712
+ /** Never throws: any bootstrap failure resolves null (sticky) and the caller stays on the shim. */
1713
+ ensure(plan) {
1714
+ if (plan.kind !== "ssh" && plan.kind !== "docker" && plan.kind !== "incus") return Promise.resolve(null);
1715
+ const key = planKey(plan);
1716
+ let entry = this.states.get(key);
1717
+ if (entry === void 0) {
1718
+ entry = this.bootstrap(plan).catch(() => null);
1719
+ entry.catch(() => {});
1720
+ this.states.set(key, entry);
1721
+ }
1722
+ return entry;
1723
+ }
1724
+ async bootstrap(plan) {
1725
+ const { manifest } = this.assets;
1726
+ const first = await this.exec(this.probeArgv(plan, this.assets), { timeoutSec: 15 });
1727
+ const ready = this.parseReady(first.stdout, manifest.ver, manifest.protocol);
1728
+ if (ready !== null) return ready;
1729
+ const probe = PROBE_RE.exec(first.stdout);
1730
+ if (probe === null || first.exit !== 90 && first.exit !== 0) return null;
1731
+ const triple = archTriple(probe[1], probe[2], new Set(Object.keys(manifest.targets)));
1732
+ if (triple === null) return null;
1733
+ const target = manifest.targets[triple];
1734
+ const binary = await readFile(join(this.assets.dir, triple, target.file));
1735
+ if ((await this.exec(this.uploadArgv(plan, triple, target.file, this.assets), {
1736
+ stdin: binary,
1737
+ timeoutSec: 60
1738
+ })).exit !== 0) return null;
1739
+ const second = await this.exec(this.probeArgv(plan, this.assets), { timeoutSec: 15 });
1740
+ return this.parseReady(second.stdout, manifest.ver, manifest.protocol);
1741
+ }
1742
+ parseReady(stdout, ver, protocol) {
1743
+ const ready = READY_RE.exec(stdout);
1744
+ const banner = /^SUPERD-RUN proto=(\d+) name=superd-run ver=(\S+) target=(\S+)/m.exec(stdout);
1745
+ if (ready === null || banner === null) return null;
1746
+ if (banner[1] !== String(protocol) || banner[2] !== ver) return null;
1747
+ return {
1748
+ binPath: ready[1],
1749
+ triple: banner[3],
1750
+ ver,
1751
+ protocol
1752
+ };
1753
+ }
1754
+ };
1755
+
1492
1756
  //#endregion
1493
1757
  //#region src/remote/transports.ts
1494
1758
  /** POSIX 单引号机械转义(唯一一层转义,spec §三"一层机械转义")。 */
@@ -1536,12 +1800,15 @@ bash -lc ${shQuote(cmd)}`;
1536
1800
  ];
1537
1801
  }
1538
1802
  }
1539
- /** Persistent pty(Ruling 2):ssh/incus 走宿主管道,docker 经 script(1) 托管宿主 PTY。 */
1803
+ /** Persistent pty(Ruling 2):ssh/incus 走宿主管道,docker 经 script(1) 托管宿主 PTY。
1804
+ * ssh 基座常驻固定 keepalive(2026-10-19 裁决):客户端自有、零对端依赖,控制消息
1805
+ * 不触碰 PTY 字节流;oneshot 短命连接不带。 */
1540
1806
  function buildPtyArgv(plan) {
1541
1807
  switch (plan.kind) {
1542
1808
  case "ssh": return sshArgv(plan.connection ?? { host: plan.host }, {
1543
1809
  tty: true,
1544
- command: "bash"
1810
+ command: "bash",
1811
+ extraArgs: SSH_PERSISTENT_KEEPALIVE
1545
1812
  });
1546
1813
  case "docker": return [
1547
1814
  "script",
@@ -1568,6 +1835,104 @@ function buildSpawnArgv(spawnCommand) {
1568
1835
  "/dev/null"
1569
1836
  ];
1570
1837
  }
1838
+ /** superd-run helper argv (spec: docs/specs/remote-rescope §5). Probe and upload are single
1839
+ * `sh -c` hops (busybox/alpine/darwin-safe); run/killgroup over ssh go through exactly one
1840
+ * remote-shell parse, same discipline as buildOneShotArgv. */
1841
+ function buildHelperProbeArgv(plan, assets) {
1842
+ const { ver, targets } = assets.manifest;
1843
+ return helperShellArgv(plan, `${Object.entries(targets).map(([triple, t]) => `d="$HOME/.cache/superd/run/${triple}-${ver}"; [ -x "$d/${t.file}" ] && { echo "SUPERD-READY $d/${t.file}"; "$d/${t.file}" banner; exit 0; };`).join(" ")} echo "SUPERD-PROBE $(uname -s 2>/dev/null || echo unknown) $(uname -m 2>/dev/null || echo unknown)"; exit 90`);
1844
+ }
1845
+ function buildHelperUploadArgv(plan, triple, file, assets) {
1846
+ const dir = `$HOME/.cache/superd/run/${triple}-${assets.manifest.ver}`;
1847
+ return helperShellArgv(plan, `mkdir -p "${dir}" && cat > "${dir}/${file}.tmp" && chmod 0755 "${dir}/${file}.tmp" && mv -f "${dir}/${file}.tmp" "${dir}/${file}"`);
1848
+ }
1849
+ function buildHelperOneShotArgv(plan, binPath, options) {
1850
+ const { tag, cmd } = options;
1851
+ switch (plan.kind) {
1852
+ case "ssh": return sshArgv(plan.connection ?? { host: plan.host }, { command: `exec ${shQuote(binPath)} run --tag ${shQuote(tag)} -- bash -lc ${shQuote(cmd)}` });
1853
+ case "docker": return [
1854
+ "docker",
1855
+ "exec",
1856
+ "-i",
1857
+ "--",
1858
+ plan.container,
1859
+ binPath,
1860
+ "run",
1861
+ "--tag",
1862
+ tag,
1863
+ "--",
1864
+ "bash",
1865
+ "-lc",
1866
+ cmd
1867
+ ];
1868
+ case "incus": return [
1869
+ "incus",
1870
+ "exec",
1871
+ "--force-noninteractive",
1872
+ plan.container,
1873
+ "--",
1874
+ binPath,
1875
+ "run",
1876
+ "--tag",
1877
+ tag,
1878
+ "--",
1879
+ "bash",
1880
+ "-lc",
1881
+ cmd
1882
+ ];
1883
+ }
1884
+ }
1885
+ function buildHelperKillgroupArgv(plan, binPath, tag) {
1886
+ switch (plan.kind) {
1887
+ case "ssh": return sshArgv(plan.connection ?? { host: plan.host }, { command: `${shQuote(binPath)} killgroup --tag ${shQuote(tag)}` });
1888
+ case "docker": return [
1889
+ "docker",
1890
+ "exec",
1891
+ "--",
1892
+ plan.container,
1893
+ binPath,
1894
+ "killgroup",
1895
+ "--tag",
1896
+ tag
1897
+ ];
1898
+ case "incus": return [
1899
+ "incus",
1900
+ "exec",
1901
+ "--force-noninteractive",
1902
+ plan.container,
1903
+ "--",
1904
+ binPath,
1905
+ "killgroup",
1906
+ "--tag",
1907
+ tag
1908
+ ];
1909
+ }
1910
+ }
1911
+ function helperShellArgv(plan, script) {
1912
+ switch (plan.kind) {
1913
+ case "ssh": return sshArgv(plan.connection ?? { host: plan.host }, { command: script });
1914
+ case "docker": return [
1915
+ "docker",
1916
+ "exec",
1917
+ "-i",
1918
+ "--",
1919
+ plan.container,
1920
+ "sh",
1921
+ "-c",
1922
+ script
1923
+ ];
1924
+ case "incus": return [
1925
+ "incus",
1926
+ "exec",
1927
+ "--force-noninteractive",
1928
+ plan.container,
1929
+ "--",
1930
+ "sh",
1931
+ "-c",
1932
+ script
1933
+ ];
1934
+ }
1935
+ }
1571
1936
 
1572
1937
  //#endregion
1573
1938
  //#region src/remote/nonce-framing.ts
@@ -1577,11 +1942,11 @@ function genNonce(bytes = 8) {
1577
1942
  }
1578
1943
  /** 命令整行:花括号复合体 + Nonce 收帧尾(spec §五.2 的 printf 逐字落地 + cwd 第三字段)。 */
1579
1944
  function wrapPtyCommand(cmd, nonce) {
1580
- return `{\n${cmd}\n} ; printf '\\033]133;D;${nonce};%s;%s\\007' "$?" "$PWD"\n`;
1945
+ return `{\n${cmd}\n} ; __superd_e=$? ; printf '\\033]133;E;${nonce};%s\\007' "$(env -0 | base64 | tr -d '\\n\\r')" ; printf '\\033]133;D;${nonce};%s;%s\\007' "$__superd_e" "$PWD" ; unset __superd_e\n`;
1581
1946
  }
1582
1947
  /** init 整行:哑终端(echo/PS1/PS2/TERM)+ 固定 40×120(Non-Goal ③)+ INIT 帧。 */
1583
1948
  function buildInitCommand(nonce) {
1584
- return `stty -echo 2>/dev/null ; stty rows 40 cols 120 2>/dev/null ; PS1='' ; PS2='' ; export TERM=dumb ; printf '\\033]133;D;${nonce};0;%s\\007' "$PWD"\n`;
1949
+ return `stty -echo 2>/dev/null ; stty rows 40 cols 120 2>/dev/null ; PS1='' ; PS2='' ; export TERM=dumb ; printf '\\033]133;E;${nonce};%s\\007' "$(env -0 | base64 | tr -d '\\n\\r')" ; printf '\\033]133;D;${nonce};0;%s\\007' "$PWD"\n`;
1585
1950
  }
1586
1951
  const ANSI_RE = /\x1b(?:\][^\x07\x1b]*(?:\x07|\x1b\\)?|\[[0-9;?]*[ -/]*[@-~]|[@-Z\\-_])/g;
1587
1952
  function stripAnsi(s) {
@@ -1610,6 +1975,7 @@ function tailWindow(text, cap, stats) {
1610
1975
  };
1611
1976
  }
1612
1977
  const FRAME_BODY_RE = /^(\d+);([^\x07\x1b]*)(?:\x07|\x1b\\)/;
1978
+ const ENV_BODY_RE = /^([A-Za-z0-9+/=]*)\x07/;
1613
1979
  /**
1614
1980
  * 单 nonce、单帧解析器:一轮 dispatch 一个实例。只认 `\x1b]133;D;<nonce>;`,
1615
1981
  * 错误 nonce 的静态/伪造 marker 永不匹配,按普通输出流过(再经 ANSI 剥洗)。
@@ -1617,31 +1983,60 @@ const FRAME_BODY_RE = /^(\d+);([^\x07\x1b]*)(?:\x07|\x1b\\)/;
1617
1983
  */
1618
1984
  function createNonceFrameParser(nonce, events) {
1619
1985
  const prefix = `\x1b]133;D;${nonce};`;
1986
+ const prefixE = `\x1b]133;E;${nonce};`;
1987
+ const prefixes = [prefix, prefixE];
1620
1988
  let buf = "";
1621
1989
  let done = false;
1622
1990
  const emit = (raw) => {
1623
1991
  const text = normalizePtyText(raw);
1624
1992
  if (text.length > 0) events.onOutput?.(text);
1625
1993
  };
1994
+ /** 缓冲尾若是任一候选前缀的真前缀则扣留(D/E 共用);flush() 放出残余。 */
1626
1995
  const holdback = () => {
1627
- const max = Math.min(buf.length, prefix.length - 1);
1628
- for (let k = max; k > 0; k--) if (buf.endsWith(prefix.slice(0, k))) return k;
1629
- return 0;
1996
+ let best = 0;
1997
+ for (const p of prefixes) {
1998
+ const max = Math.min(buf.length, p.length - 1);
1999
+ for (let k = max; k > best; k--) if (buf.endsWith(p.slice(0, k))) {
2000
+ best = k;
2001
+ break;
2002
+ }
2003
+ }
2004
+ return best;
1630
2005
  };
2006
+ /** 扫描最早出现的帧:E(env 侧信道)先于 D 下发;D = settle,帧后字节丢弃。 */
1631
2007
  const consume = () => {
1632
2008
  if (done) return;
1633
- const i = buf.indexOf(prefix);
1634
- if (i < 0) {
2009
+ let idx = -1;
2010
+ let isEnv = false;
2011
+ for (const p of prefixes) {
2012
+ const i = buf.indexOf(p);
2013
+ if (i >= 0 && (idx === -1 || i < idx)) {
2014
+ idx = i;
2015
+ isEnv = p === prefixE;
2016
+ }
2017
+ }
2018
+ if (idx < 0) {
1635
2019
  const keep = holdback();
1636
2020
  emit(buf.slice(0, buf.length - keep));
1637
2021
  buf = buf.slice(buf.length - keep);
1638
2022
  return;
1639
2023
  }
1640
- emit(buf.slice(0, i));
1641
- const rest = buf.slice(i + prefix.length);
2024
+ emit(buf.slice(0, idx));
2025
+ const rest = buf.slice(idx + (isEnv ? prefixE.length : prefix.length));
2026
+ if (isEnv) {
2027
+ const m$1 = ENV_BODY_RE.exec(rest);
2028
+ if (m$1 === null) {
2029
+ buf = buf.slice(idx);
2030
+ return;
2031
+ }
2032
+ if (m$1[1].length > 0) events.onEnv?.(m$1[1]);
2033
+ buf = rest.slice(m$1[0].length);
2034
+ consume();
2035
+ return;
2036
+ }
1642
2037
  const m = FRAME_BODY_RE.exec(rest);
1643
2038
  if (m === null) {
1644
- buf = buf.slice(i);
2039
+ buf = buf.slice(idx);
1645
2040
  return;
1646
2041
  }
1647
2042
  events.onFrame({
@@ -1662,18 +2057,61 @@ function createNonceFrameParser(nonce, events) {
1662
2057
  }
1663
2058
  };
1664
2059
  }
2060
+ /** E 帧 payload → name→value-hash(值不离开端点;hash 只为 changed 判定)。空/坏 payload → undefined。 */
2061
+ function decodeEnvSnapshot(b64) {
2062
+ if (b64.length === 0) return void 0;
2063
+ let text;
2064
+ try {
2065
+ text = Buffer.from(b64, "base64").toString("utf8");
2066
+ } catch {
2067
+ return;
2068
+ }
2069
+ const map = /* @__PURE__ */ new Map();
2070
+ for (const entry of text.split("\0")) {
2071
+ if (entry.length === 0) continue;
2072
+ const eq = entry.indexOf("=");
2073
+ const name$1 = eq === -1 ? entry : entry.slice(0, eq);
2074
+ if (name$1.startsWith("__superd")) continue;
2075
+ let h = 5381;
2076
+ const value = eq === -1 ? "" : entry.slice(eq + 1);
2077
+ for (let i = 0; i < value.length; i++) h = (h * 33 ^ value.charCodeAt(i)) >>> 0;
2078
+ map.set(name$1, h);
2079
+ }
2080
+ return map.size > 0 ? map : void 0;
2081
+ }
2082
+ /** prev 未知(init 侧信道失败)时返回 undefined——不报告假 delta。 */
2083
+ function diffEnv(prev, next) {
2084
+ if (prev === void 0) return void 0;
2085
+ const added = [], changed = [], removed = [];
2086
+ for (const [name$1, h] of next) if (!prev.has(name$1)) added.push(name$1);
2087
+ else if (prev.get(name$1) !== h) changed.push(name$1);
2088
+ for (const name$1 of prev.keys()) if (!next.has(name$1)) removed.push(name$1);
2089
+ added.sort();
2090
+ changed.sort();
2091
+ removed.sort();
2092
+ return {
2093
+ added,
2094
+ changed,
2095
+ removed
2096
+ };
2097
+ }
1665
2098
 
1666
2099
  //#endregion
1667
2100
  //#region src/remote/pty-session.ts
1668
2101
  /** spec §五.2 逐字。 */
1669
2102
  const RECONNECT_NOTICE = "[remote: session reconnected to fresh shell; cwd reset to default]";
1670
- /** 池键 → 模型面名字:剥掉 agent 前缀与内部标记(`t:` → 裸 target;`p:` → `byop:<label>`)。
1671
- * 这是**唯一**的 key→名字映射规则,roster 与错误面共用——错误面不得回吐内部键:
1672
- * 2026-10-03 实测报告 F1,`E_SESSION_*` 曾把 `session-<uuid>|p:<label>` 整条吐给模型。 */
2103
+ /** 池键 → 模型面名字:剥掉 agent 前缀与内部标记(`p:` → `pty:<id>`)。
2104
+ * 这是**唯一**的 key→名字映射规则,roster 与错误面共用——错误面不得回吐内部键:
2105
+ * 2026-10-03 实测报告 F1,`E_SESSION_*` 曾把 `session-<uuid>|p:<label>` 整条吐给模型。 */
1673
2106
  function displayKey(key) {
1674
2107
  const raw = key.includes("|") ? key.slice(key.indexOf("|") + 1) : key;
1675
- return raw.startsWith("t:") ? raw.slice(2) : raw.startsWith("p:") ? `byop:${raw.slice(2)}` : raw;
2108
+ return raw.startsWith("p:") ? `pty:${raw.slice(2)}` : raw;
1676
2109
  }
2110
+ const LEDGER_MAX = 20;
2111
+ const summaryOf = (cmd) => {
2112
+ const first = cmd.trim().split(/\r?\n/, 1)[0] ?? "";
2113
+ return first.length > 72 ? `${first.slice(0, 71)}…` : first;
2114
+ };
1677
2115
  const DEFAULT_INIT_TIMEOUT_SEC = 30;
1678
2116
  const INTERRUPT_GRACE_MS = 3e3;
1679
2117
  /** pty 收集内存安全帽(Ruling P15,与 oneshot HARD_CAP 对齐):模型面只见尾窗,保留尾部。 */
@@ -1688,7 +2126,17 @@ var PtySession = class {
1688
2126
  deathWaiters = [];
1689
2127
  diag = "";
1690
2128
  inFlight = 0;
1691
- lastSettleAt = 0;
2129
+ /** P4/P5:当前占用前台的 detached job(background/超时 handoff)。非 null 时新命令拒绝(Q6)。 */
2130
+ detachedJob = null;
2131
+ /** 账本(§5):本会话最近 20 条命令(FIFO,超出丢最旧)。 */
2132
+ ledger = [];
2133
+ /** env 侧信道基线(init E 帧);undefined = 侧信道不可用(不报假 delta)。 */
2134
+ envBaseline;
2135
+ /** idle 起点(ready / settle 时重置);null = 尚无 idle 时钟(cold 或已 dispose)。 */
2136
+ idleSince = null;
2137
+ /** 意外死亡后的回收定时器(R2):ready 过的会话先留池供 reconnect,超时仍无人寻址则出池。 */
2138
+ deathTimer = null;
2139
+ lastCwdSeen = null;
1692
2140
  /** 是否曾成功初始化(ready 过)。决定死亡语义:ready 过 → reconnect(留池);从未 ready → 出池(见 start())。 */
1693
2141
  everReady = false;
1694
2142
  disposal;
@@ -1700,7 +2148,38 @@ var PtySession = class {
1700
2148
  get sessionState() {
1701
2149
  return this.state;
1702
2150
  }
1703
- /** 创建来源(spawn 原文);仅 `byop:<label>` 会话有。 */
2151
+ /** 池键(driver 的 ttl/inspect 用)。 */
2152
+ get key() {
2153
+ return this.opts.key;
2154
+ }
2155
+ /** 最近帧 cwd(card 面);从未执行过 = null。 */
2156
+ get lastCwd() {
2157
+ return this.lastCwdSeen;
2158
+ }
2159
+ /** 占用前台的 detached job 的 commandId(无 = null;close 拒绝与 busy 文案共用)。 */
2160
+ get detachedCommandId() {
2161
+ return this.detachedJob?.commandId ?? null;
2162
+ }
2163
+ /** job 通道收养后回填 jobId(timeout handoff 时 registry id ≠ commandId,账本如实记录两者)。 */
2164
+ attachJob(commandId, jobId) {
2165
+ const entry = [...this.ledger].reverse().find((e) => e.commandId === commandId);
2166
+ if (entry !== void 0) entry.jobId = jobId;
2167
+ }
2168
+ /** Workbench 身份(§3):id + 可选 label + connection + byo 标记。 */
2169
+ get identity() {
2170
+ return {
2171
+ id: this.opts.id,
2172
+ ...this.opts.label !== void 0 ? { label: this.opts.label } : {},
2173
+ connection: this.opts.connection,
2174
+ byo: this.opts.byo === true,
2175
+ builtOn: this.opts.builtOn === true
2176
+ };
2177
+ }
2178
+ /** 账本快照(session card 面,只读)。 */
2179
+ ledgerSnapshot() {
2180
+ return this.ledger;
2181
+ }
2182
+ /** spawn 创建原文(仅 BYO 会话;v2 起仅存档,不参与复用判定)。 */
1704
2183
  get origin() {
1705
2184
  return this.opts.origin;
1706
2185
  }
@@ -1709,81 +2188,156 @@ var PtySession = class {
1709
2188
  return {
1710
2189
  state: this.state,
1711
2190
  busy: this.inFlight > 0,
1712
- idleMs: this.lastSettleAt > 0 ? Date.now() - this.lastSettleAt : null,
2191
+ idleMs: this.idleSince === null ? null : Date.now() - this.idleSince,
1713
2192
  pid: this.proc?.pid ?? null
1714
2193
  };
1715
2194
  }
1716
- /** 同一会话命令严格串行(FIFO);死亡/冷态先重启。 */
1717
- dispatch(cmd, dOpts = {}) {
2195
+ /** 预热创建(创建形无 cmd):真正拨号到 init 帧——「会话已就绪」必须是真的,
2196
+ * 而且只有 ready 过才挂 idle 回收(cold 会话没有 timer,会永远占配额)。 */
2197
+ async warm() {
2198
+ await this.start();
2199
+ }
2200
+ /** 同一会话命令严格串行(FIFO);死亡/冷态先重启。P4/P5(2026-10-19):超时不再自动
2201
+ * 打断——handoff 成 running job(byte-cursor 轮询 + 显式 interrupt,P8 机制原样保留在
2202
+ * job.interrupt);background 选项让 dispatch 立即回执。占用者是 detached job 时新命令
2203
+ * 拒绝(Q6:诚实回状态优先于排队),纯同步并发 FIFO 语义不变。 */
2204
+ dispatch(cmd, dOpts) {
1718
2205
  const attempt = async () => {
1719
2206
  const started = Date.now();
1720
2207
  const reconnected = this.state === "dead";
1721
2208
  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`);
2209
+ if (this.state === "disposed") throw new Error(`[E_SESSION_DISPOSED] remote pty session '${this.opts.id}' was disposed before dispatch`);
2210
+ if (this.detachedJob !== null) throw new Error(`[E_SESSION_BUSY] remote: pty session '${this.opts.id}' still runs a detached command (${this.detachedJob.commandId}) — poll job_output ${this.detachedJob.commandId}, cancel with job_kill ${this.detachedJob.commandId}, or address other work to another session / oneshot`);
2211
+ const commandId = dOpts.commandId;
2212
+ const entry = {
2213
+ commandId,
2214
+ summary: summaryOf(cmd),
2215
+ exit: null,
2216
+ durationMs: 0,
2217
+ running: true
2218
+ };
2219
+ this.ledger.push(entry);
2220
+ if (this.ledger.length > LEDGER_MAX) this.ledger.shift();
1723
2221
  this.inFlight++;
1724
2222
  return await new Promise((resolve$1) => {
1725
2223
  const nonce = genNonce();
1726
2224
  const ring = new Utf16TailRing(OUTPUT_HARD_CAP);
1727
2225
  let interrupted = false;
1728
2226
  let settled = false;
2227
+ let jobSettled = false;
1729
2228
  let interruptTimer;
1730
2229
  let killTimer;
1731
- const deathWaiter = () => finish({
1732
- output: ring.text,
1733
- exit: null,
1734
- cwd: null,
1735
- timedOut: interrupted
1736
- });
2230
+ let jobDone = null;
2231
+ let envNext;
2232
+ const detach = () => {
2233
+ const job = {
2234
+ commandId,
2235
+ read: (fromByte) => ring.read(fromByte),
2236
+ stats: () => ring.stats(),
2237
+ interrupt: () => {
2238
+ if (!jobSettled) twoStage();
2239
+ },
2240
+ done: new Promise((res) => {
2241
+ jobDone = res;
2242
+ })
2243
+ };
2244
+ this.detachedJob = job;
2245
+ settled = true;
2246
+ resolve$1({
2247
+ output: ring.text,
2248
+ outputStats: { stdout: ring.stats() },
2249
+ exit: null,
2250
+ cwd: null,
2251
+ timedOut: false,
2252
+ running: true,
2253
+ job,
2254
+ commandId,
2255
+ reconnected,
2256
+ durationMs: Date.now() - started
2257
+ });
2258
+ return job;
2259
+ };
1737
2260
  const cleanup = () => {
1738
2261
  this.onChunk = null;
2262
+ this.detachedJob = null;
1739
2263
  this.deathWaiters = this.deathWaiters.filter((w) => w !== deathWaiter);
1740
2264
  if (interruptTimer !== void 0) clearTimeout(interruptTimer);
1741
2265
  if (killTimer !== void 0) clearTimeout(killTimer);
1742
2266
  };
1743
- const finish = (r) => {
1744
- if (settled) return;
1745
- settled = true;
2267
+ const settle = (r) => {
2268
+ if (jobSettled) return;
2269
+ jobSettled = true;
1746
2270
  cleanup();
1747
2271
  this.inFlight--;
1748
- this.lastSettleAt = Date.now();
2272
+ this.idleSince = Date.now();
1749
2273
  this.resetIdleTimer();
1750
- resolve$1({
2274
+ if (r.cwd !== null) this.lastCwdSeen = r.cwd;
2275
+ const envDelta = envNext !== void 0 ? diffEnv(this.envBaseline, envNext) : void 0;
2276
+ if (envNext !== void 0) this.envBaseline = envNext;
2277
+ entry.exit = r.exit;
2278
+ entry.durationMs = Date.now() - started;
2279
+ entry.running = false;
2280
+ const full = {
1751
2281
  ...r,
1752
2282
  outputStats: { stdout: ring.stats() },
1753
- reconnected,
1754
- durationMs: Date.now() - started
1755
- });
2283
+ commandId,
2284
+ ...envDelta !== void 0 ? { envDelta } : {}
2285
+ };
2286
+ jobDone?.(full);
2287
+ if (!settled) {
2288
+ settled = true;
2289
+ resolve$1({
2290
+ ...full,
2291
+ reconnected,
2292
+ durationMs: Date.now() - started
2293
+ });
2294
+ }
1756
2295
  };
2296
+ const deathWaiter = () => settle({
2297
+ output: ring.text,
2298
+ exit: null,
2299
+ cwd: null,
2300
+ timedOut: interrupted
2301
+ });
1757
2302
  const parser = createNonceFrameParser(nonce, {
1758
2303
  onOutput: (t) => {
1759
2304
  ring.push(t);
1760
2305
  },
1761
- onFrame: ({ exit, cwd }) => finish({
2306
+ onEnv: (b64) => {
2307
+ const m = decodeEnvSnapshot(b64);
2308
+ if (m !== void 0) envNext = m;
2309
+ },
2310
+ onFrame: ({ exit, cwd }) => settle({
1762
2311
  output: ring.text,
1763
2312
  exit,
1764
2313
  cwd,
1765
2314
  timedOut: interrupted
1766
2315
  })
1767
2316
  });
1768
- this.onChunk = (c) => parser.feed(c);
1769
- this.write(wrapPtyCommand(cmd, nonce));
1770
- if (dOpts.stdin !== void 0) this.write(dOpts.stdin);
1771
- interruptTimer = setTimeout(() => {
2317
+ const twoStage = () => {
1772
2318
  interrupted = true;
1773
2319
  this.write("");
1774
2320
  this.write(`printf '\\033]133;D;${nonce};130;%s\\007' "$PWD"\n`);
1775
2321
  killTimer = setTimeout(() => {
1776
2322
  this.killTree();
1777
2323
  this.markDead();
1778
- finish({
2324
+ settle({
1779
2325
  output: ring.text,
1780
2326
  exit: null,
1781
2327
  cwd: null,
1782
2328
  timedOut: true
1783
2329
  });
1784
2330
  }, INTERRUPT_GRACE_MS);
1785
- }, dOpts.timeoutSec === void 0 ? 12e4 : dOpts.timeoutSec * 1e3);
2331
+ };
2332
+ this.onChunk = (c) => parser.feed(c);
2333
+ this.write(wrapPtyCommand(cmd, nonce));
2334
+ if (dOpts.stdin !== void 0) this.write(dOpts.stdin);
1786
2335
  this.deathWaiters.push(deathWaiter);
2336
+ if (dOpts.background === true) {
2337
+ detach();
2338
+ return;
2339
+ }
2340
+ interruptTimer = setTimeout(() => detach(), dOpts.timeoutSec === void 0 ? 12e4 : dOpts.timeoutSec * 1e3);
1787
2341
  });
1788
2342
  };
1789
2343
  const run = this.queue.then(attempt, attempt);
@@ -1794,6 +2348,7 @@ var PtySession = class {
1794
2348
  dispose() {
1795
2349
  if (this.disposal) return this.disposal;
1796
2350
  this.clearIdleTimer();
2351
+ this.clearDeathTimer();
1797
2352
  this.state = "disposed";
1798
2353
  this.startPromise = null;
1799
2354
  const proc = this.proc;
@@ -1818,6 +2373,7 @@ var PtySession = class {
1818
2373
  }
1819
2374
  async start() {
1820
2375
  if (this.state === "disposed") throw new Error(`[E_SESSION_DISPOSED] remote pty session '${displayKey(this.opts.key)}' was disposed — the pool replaces disposed sessions; this reference is stale`);
2376
+ this.clearDeathTimer();
1821
2377
  if (this.startPromise === null) {
1822
2378
  this.state = "starting";
1823
2379
  this.startPromise = this.doStart().catch((err) => {
@@ -1867,18 +2423,24 @@ var PtySession = class {
1867
2423
  reject(/* @__PURE__ */ new Error(`[E_SESSION_DIED] remote pty session '${displayKey(this.opts.key)}' died before initializing` + (this.diag.length > 0 ? `; transport stderr tail: ${this.diag}` : "")));
1868
2424
  };
1869
2425
  this.deathWaiters.push(waiter);
1870
- const parser = createNonceFrameParser(initNonce, { onFrame: () => {
1871
- clearTimeout(timer);
1872
- this.deathWaiters = this.deathWaiters.filter((w) => w !== waiter);
1873
- if (this.state === "disposed") {
1874
- reject(/* @__PURE__ */ new Error(`[E_SESSION_DISPOSED] remote pty session '${displayKey(this.opts.key)}' disposed during init`));
1875
- return;
2426
+ const parser = createNonceFrameParser(initNonce, {
2427
+ onEnv: (b64) => {
2428
+ const m = decodeEnvSnapshot(b64);
2429
+ if (m !== void 0) this.envBaseline = m;
2430
+ },
2431
+ onFrame: () => {
2432
+ clearTimeout(timer);
2433
+ this.deathWaiters = this.deathWaiters.filter((w) => w !== waiter);
2434
+ if (this.state === "disposed") {
2435
+ reject(/* @__PURE__ */ new Error(`[E_SESSION_DISPOSED] remote pty session '${displayKey(this.opts.key)}' disposed during init`));
2436
+ return;
2437
+ }
2438
+ this.everReady = true;
2439
+ this.state = "ready";
2440
+ this.resetIdleTimer();
2441
+ resolve$1();
1876
2442
  }
1877
- this.everReady = true;
1878
- this.state = "ready";
1879
- this.resetIdleTimer();
1880
- resolve$1();
1881
- } });
2443
+ });
1882
2444
  this.onChunk = (c) => parser.feed(c);
1883
2445
  this.write(buildInitCommand(initNonce));
1884
2446
  });
@@ -1893,6 +2455,7 @@ var PtySession = class {
1893
2455
  this.state = "dead";
1894
2456
  this.startPromise = null;
1895
2457
  this.clearIdleTimer();
2458
+ this.armDeathReaper();
1896
2459
  const waiters = this.deathWaiters;
1897
2460
  this.deathWaiters = [];
1898
2461
  for (const w of waiters) w();
@@ -1900,7 +2463,14 @@ var PtySession = class {
1900
2463
  resetIdleTimer() {
1901
2464
  this.clearIdleTimer();
1902
2465
  if (this.state === "disposed") return;
1903
- this.idleTimer = setTimeout(() => void this.dispose(), this.opts.idleTtlSec * 1e3);
2466
+ this.idleSince = Date.now();
2467
+ this.idleTimer = setTimeout(() => {
2468
+ if (this.inFlight > 0) {
2469
+ this.resetIdleTimer();
2470
+ return;
2471
+ }
2472
+ this.dispose();
2473
+ }, this.opts.idleTtlSec * 1e3);
1904
2474
  this.idleTimer.unref();
1905
2475
  }
1906
2476
  clearIdleTimer() {
@@ -1909,6 +2479,22 @@ var PtySession = class {
1909
2479
  this.idleTimer = null;
1910
2480
  }
1911
2481
  }
2482
+ /** 意外死亡后的回收:留池只换取一次 reconnect 机会,不得超过一段 idle TTL(R2 复核 P0)。
2483
+ * 否则死会话永久占 label + 永久计入 10 配额,Agent 再也开不出新会话。 */
2484
+ armDeathReaper() {
2485
+ this.clearDeathTimer();
2486
+ if (!this.everReady || this.state !== "dead") return;
2487
+ this.deathTimer = setTimeout(() => {
2488
+ this.dispose();
2489
+ }, this.opts.idleTtlSec * 1e3);
2490
+ this.deathTimer.unref();
2491
+ }
2492
+ clearDeathTimer() {
2493
+ if (this.deathTimer !== null) {
2494
+ clearTimeout(this.deathTimer);
2495
+ this.deathTimer = null;
2496
+ }
2497
+ }
1912
2498
  killTree() {
1913
2499
  const pid = this.proc?.pid;
1914
2500
  this.proc = null;
@@ -1998,19 +2584,20 @@ var PtyPool = class {
1998
2584
  const s = this.sessions.get(key);
1999
2585
  return s === void 0 || s.sessionState === "disposed" ? void 0 : s;
2000
2586
  }
2001
- getOrCreate(key, argv, origin) {
2587
+ /** v2:创建一律新建(「同 label 同来源复用」废除——复用只走 get/get by id);disposed 残留由这里替换。 */
2588
+ create(opts) {
2002
2589
  if (this.disposed) throw new Error("[E_REMOTE_DISPOSED] remote: the owning Agent or plugin PTY pool was disposed");
2003
- let s = this.sessions.get(key);
2004
- if (s === void 0 || s.sessionState === "disposed") {
2005
- s = new PtySession({
2006
- key,
2007
- argv,
2008
- origin,
2009
- ...this.defaults,
2010
- onDead: () => this.sessions.delete(key)
2011
- });
2012
- this.sessions.set(key, s);
2013
- }
2590
+ const existing = this.sessions.get(opts.key);
2591
+ if (existing !== void 0 && existing.sessionState !== "disposed") throw new Error(`[E_INTERNAL] remote: pty pool key collision for ${opts.id}`);
2592
+ const s = new PtySession({
2593
+ ...opts,
2594
+ ...this.defaults,
2595
+ onDead: () => {
2596
+ this.sessions.delete(opts.key);
2597
+ opts.onDead?.();
2598
+ }
2599
+ });
2600
+ this.sessions.set(opts.key, s);
2014
2601
  return s;
2015
2602
  }
2016
2603
  disposeAll() {
@@ -2053,19 +2640,207 @@ function resolveTarget(target) {
2053
2640
  host: raw
2054
2641
  };
2055
2642
  }
2056
-
2057
- //#endregion
2058
- //#region src/remote/status.ts
2059
- function oneLine$1(s) {
2060
- return s.trim().split(/\r?\n/).filter(Boolean).at(-1) ?? "";
2061
- }
2062
- /**
2063
- * On-demand 探测(Ruling 17):调用瞬间拨一次,双重超时(ssh ConnectTimeout
2064
- * 5s + 外层 hard 10s),结果即当时事实。绝不缓存、绝不在 boot 时扫。
2065
- */
2066
- async function probeTarget(plan, opts = {}) {
2067
- const ct = opts.connectTimeoutSec ?? 5;
2068
- const hard = opts.hardTimeoutSec ?? 10;
2643
+ const TRANSPORTS = [
2644
+ "ssh",
2645
+ "docker",
2646
+ "incus"
2647
+ ];
2648
+ function parseTargetForm(target) {
2649
+ if (target === void 0 || target.length === 0) return { form: "empty" };
2650
+ if (target === "pty") return { form: "pty-selector" };
2651
+ if (target.startsWith("pty:")) {
2652
+ const rest = target.slice(4);
2653
+ if (rest.length === 0 || rest.includes(":")) throw new TargetFormatError(`[E_TARGET_FORMAT] remote: ${JSON.stringify(target)} — the pty selector takes exactly one id or label ("pty:p-1a2b", "pty:build")`);
2654
+ return {
2655
+ form: "pty-selector",
2656
+ idOrLabel: rest
2657
+ };
2658
+ }
2659
+ if (target === "ssh") return { form: "ssh-prefix" };
2660
+ if (target === "docker" || target === "incus" || target === "hub") throw new TargetFormatError(`[E_TARGET_FORMAT] remote: ${JSON.stringify(target)} is a transport prefix — pass ${target === "hub" ? "hub:<machine>" : `${target}:<name>`}`);
2661
+ if (target.startsWith("hub:")) {
2662
+ const segs = target.slice(4).split(":");
2663
+ if (segs[0] === void 0 || segs[0].length === 0) throw new TargetFormatError(`[E_TARGET_FORMAT] remote: ${JSON.stringify(target)} — hub takes a machine id`);
2664
+ if (segs.length === 1) return {
2665
+ form: "hub",
2666
+ machine: segs[0]
2667
+ };
2668
+ if (segs.length === 2 && segs[1] === "pty") return {
2669
+ form: "hub-pty",
2670
+ machine: segs[0]
2671
+ };
2672
+ throw new TargetFormatError(`[E_TARGET_FORMAT] remote: ${JSON.stringify(target)} — hub PTY takes no label (hub lane has no session id / parallelism this round)`);
2673
+ }
2674
+ for (const transport of TRANSPORTS) {
2675
+ if (!target.startsWith(`${transport}:`)) continue;
2676
+ const rest = target.slice(transport.length + 1);
2677
+ if (rest.length === 0) throw new TargetFormatError(`[E_TARGET_FORMAT] remote: ${JSON.stringify(target)} — ${transport} takes a name`);
2678
+ const ptyAt = rest.lastIndexOf(":pty");
2679
+ if (ptyAt === -1) return {
2680
+ form: "direct",
2681
+ transport,
2682
+ name: rest
2683
+ };
2684
+ const base = rest.slice(0, ptyAt);
2685
+ if (base.length === 0) throw new TargetFormatError(`[E_TARGET_FORMAT] remote: ${JSON.stringify(target)} — ":pty" needs a transport target before it ("ssh:pty" is the host named pty)`);
2686
+ const tail = rest.slice(ptyAt + 4);
2687
+ if (tail.startsWith(":")) {
2688
+ if (tail.length === 1) throw new TargetFormatError(`[E_TARGET_FORMAT] remote: ${JSON.stringify(target)} — empty label after ":pty:"`);
2689
+ return {
2690
+ form: "create-pty",
2691
+ transport,
2692
+ name: base,
2693
+ label: tail.slice(1)
2694
+ };
2695
+ }
2696
+ if (tail.length > 0) throw new TargetFormatError(`[E_TARGET_FORMAT] remote: ${JSON.stringify(target)} — unexpected ${JSON.stringify(tail)} after ":pty"`);
2697
+ return {
2698
+ form: "create-pty",
2699
+ transport,
2700
+ name: base
2701
+ };
2702
+ }
2703
+ if (target.includes(":")) throw new TargetFormatError(`[E_TARGET_FORMAT] remote: unknown target ${JSON.stringify(target)} — transports are ssh: / docker: / incus: / hub:, the PTY selector is pty:<id|label>, creation appends :pty (roster lists names)`);
2704
+ return {
2705
+ form: "bare",
2706
+ name: target
2707
+ };
2708
+ }
2709
+
2710
+ //#endregion
2711
+ //#region src/remote/labels.ts
2712
+ /** label 字符集(与 BYO ssh label 同源);禁 `pty` 与会话 id 形态(防 selector 歧义)。 */
2713
+ const LABEL_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
2714
+ const PTY_ID_RE = /^pty-[a-z0-9]{4}$/;
2715
+ const RESERVED_LABELS = new Set(["pty"]);
2716
+ function validateLabel(label) {
2717
+ if (!LABEL_RE.test(label)) throw new Error(`[E_BAD_LABEL] remote: label must match ${LABEL_RE.source} (got ${JSON.stringify(label)})`);
2718
+ if (RESERVED_LABELS.has(label) || PTY_ID_RE.test(label)) throw new Error(`[E_BAD_LABEL] remote: ${JSON.stringify(label)} is reserved (session ids are auto-generated; pick a different label)`);
2719
+ }
2720
+ function describeEntry(e) {
2721
+ switch (e.kind) {
2722
+ case "ssh": return `ssh host ${e.name}`;
2723
+ case "ssh-byo": return `a BYO ssh identity (${e.name})`;
2724
+ case "docker": return `docker container ${e.container}`;
2725
+ case "incus": return `incus container ${e.container}`;
2726
+ case "hub": return `hub machine ${e.machine}`;
2727
+ case "pty": return `pty session ${e.id}${e.label !== void 0 ? ` (label ${e.label})` : ""}`;
2728
+ }
2729
+ }
2730
+ /** 机器身份键:同一实体重扫后名字保持稳定(按身份匹配,不按名字)。 */
2731
+ const machineId = (e) => e.kind === "ssh" ? `ssh:${e.host}` : e.kind === "docker" ? `docker:${e.container}` : e.kind === "incus" ? `incus:${e.container}` : e.kind === "hub" ? `hub:${e.machine}` : void 0;
2732
+ const ID_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
2733
+ var LabelPool = class {
2734
+ /** 每 agent session 一个注册表:name → entry(机器 + 认领,到达序)。 */
2735
+ sessions = /* @__PURE__ */ new Map();
2736
+ registry(agent) {
2737
+ let reg = this.sessions.get(agent);
2738
+ if (reg === void 0) {
2739
+ reg = /* @__PURE__ */ new Map();
2740
+ this.sessions.set(agent, reg);
2741
+ }
2742
+ return reg;
2743
+ }
2744
+ resolve(name$1, agent) {
2745
+ return this.sessions.get(agent)?.get(name$1);
2746
+ }
2747
+ /** 显式认领(BYO ssh / PTY label):任何已立名字占用即拒——名字是模型选的,不自动改名。 */
2748
+ claim(name$1, agent, claim) {
2749
+ const reg = this.registry(agent);
2750
+ const existing = reg.get(name$1);
2751
+ if (existing !== void 0) throw new Error(`[E_LABEL_IN_USE] remote: label ${JSON.stringify(name$1)} is already taken by ${describeEntry(existing)} — nothing was run.\n To address it: use the name directly (creation always claims a fresh name — spawn/ssh never adopt)\n` + (existing.kind === "pty" ? ` To free it: close ${existing.id} ({action:"close", target:"pty:${existing.id}"}) — a session that died still holds its label until it is closed or reaped\n` : "") + ` To create new: pick another label`);
2752
+ reg.set(name$1, claim);
2753
+ }
2754
+ /** 生成不撞本 session 的 id(`pty-` PTY 会话 / `ssh-` BYO ssh 身份)。 */
2755
+ freshId(prefix, agent) {
2756
+ const reg = this.registry(agent);
2757
+ for (;;) {
2758
+ let id = prefix;
2759
+ for (let i = 0; i < 4; i++) id += ID_ALPHABET[Math.floor(Math.random() * 36)];
2760
+ if (!reg.has(id)) return id;
2761
+ }
2762
+ }
2763
+ /**
2764
+ * 现场采集 merge(roster / 裸名 miss 触发):按 ssh → docker → incus → hub 顺序并入。
2765
+ * 已立名字永不挪动:同一实体(身份键相同)保持原槽;新实体撞已立名字 = latecomer,
2766
+ * 自动加 `<transport>-<name>`(再冲突 -2/-3)——无论对方是认领还是更早的机器。
2767
+ * 本轮扫描里消失的机器出表(名字释放);认领永远保留。
2768
+ */
2769
+ mergeScan(agent, scan) {
2770
+ const reg = this.registry(agent);
2771
+ const renamed = [];
2772
+ const present = /* @__PURE__ */ new Map();
2773
+ for (const [name$1, e] of [...reg]) {
2774
+ const id = machineId(e);
2775
+ if (id === void 0) continue;
2776
+ reg.delete(name$1);
2777
+ present.set(id, e);
2778
+ }
2779
+ const place = (transport, raw, make) => {
2780
+ const id = `${transport}:${raw}`;
2781
+ const known = present.get(id);
2782
+ if (known !== void 0) {
2783
+ reg.set(known.name, known);
2784
+ return;
2785
+ }
2786
+ const fresh = make(raw);
2787
+ if (!reg.has(raw)) {
2788
+ reg.set(raw, fresh);
2789
+ return;
2790
+ }
2791
+ let candidate = `${transport}-${raw}`, n = 2;
2792
+ while (reg.has(candidate)) candidate = `${transport}-${raw}-${n++}`;
2793
+ reg.set(candidate, make(candidate));
2794
+ renamed.push([fresh, candidate]);
2795
+ };
2796
+ for (const host of scan.sshHosts) place("ssh", host, (name$1) => ({
2797
+ kind: "ssh",
2798
+ name: name$1,
2799
+ host
2800
+ }));
2801
+ for (const c of scan.docker) place("docker", c, (name$1) => ({
2802
+ kind: "docker",
2803
+ name: name$1,
2804
+ container: c
2805
+ }));
2806
+ for (const c of scan.incus) place("incus", c, (name$1) => ({
2807
+ kind: "incus",
2808
+ name: name$1,
2809
+ container: c
2810
+ }));
2811
+ for (const m of scan.hub) place("hub", m, (name$1) => ({
2812
+ kind: "hub",
2813
+ name: name$1,
2814
+ machine: m
2815
+ }));
2816
+ return { renamed };
2817
+ }
2818
+ /** 认领释放(会话 dispose/TTL;BYO 身份随 session 存亡);幂等。 */
2819
+ release(name$1, agent) {
2820
+ this.sessions.get(agent)?.delete(name$1);
2821
+ }
2822
+ /** roster 面:本 session 的全部条目(机器 + 认领,插入序)。 */
2823
+ list(agent) {
2824
+ return [...this.sessions.get(agent)?.values() ?? []];
2825
+ }
2826
+ /** driver dispose:全部 session 注册表清空。 */
2827
+ clear() {
2828
+ this.sessions.clear();
2829
+ }
2830
+ };
2831
+
2832
+ //#endregion
2833
+ //#region src/remote/status.ts
2834
+ function oneLine$1(s) {
2835
+ return s.trim().split(/\r?\n/).filter(Boolean).at(-1) ?? "";
2836
+ }
2837
+ /**
2838
+ * On-demand 探测(Ruling 17):调用瞬间拨一次,双重超时(ssh ConnectTimeout
2839
+ * 5s + 外层 hard 10s),结果即当时事实。绝不缓存、绝不在 boot 时扫。
2840
+ */
2841
+ async function probeTarget(plan, opts = {}) {
2842
+ const ct = opts.connectTimeoutSec ?? 5;
2843
+ const hard = opts.hardTimeoutSec ?? 10;
2069
2844
  const runner = opts.runner ?? runOneShot;
2070
2845
  const started = Date.now();
2071
2846
  const r = await runner(plan.kind === "ssh" ? sshArgv(plan.connection ?? { host: plan.host }, {
@@ -2128,21 +2903,11 @@ function renderProbe(p) {
2128
2903
  case "container": return `probe: container ${p.status} (just now, on demand)`;
2129
2904
  }
2130
2905
  }
2131
- /**
2132
- * 唯一偏离 brief 处:brief 的 renderSession 测试以不含 `pid` 的三字段字面量直呼本函数,而
2133
- * SessionSnapshot.pid 为必填 → 按测试意图(会话层词汇只由 state/busy/idleMs 决定)收窄为
2134
- * 结构子集;传完整 SessionSnapshot 仍然成立,运行时语义零变化。
2135
- */
2136
- function renderSession(s) {
2137
- if (s === void 0 || s.state === "cold" || s.state === "starting" || s.state === "disposed") return "session: none — dials on first exec";
2138
- if (s.state === "dead") return "session: died — next exec cold-starts a fresh shell";
2139
- if (s.busy) return "session: busy (a command is running)";
2140
- return `session: idle ${Math.round((s.idleMs ?? 0) / 1e3)}s (connected)`;
2141
- }
2142
- /** PTY 会话状态行(BYO-PTY 面):无会话 = 这个 label 下没有活会话,且**不会自建**——
2143
- * 与 transport target 的 "dials on first exec" 是两回事,故不复用那句。 */
2144
- function renderPtySession(s) {
2145
- return s === void 0 ? "session: none — no live PTY under this label" : renderSession(s);
2906
+ /** session card(§5.5,`pty:<id|label>` 无 cmd):connection/state/cwd/ttl 由调用方拼头行,这里渲染账本。 */
2907
+ function renderLedger(ledger) {
2908
+ if (ledger.length === 0) return "ledger: empty — no command has run in this session yet";
2909
+ const rows = [...ledger].reverse().map((e) => ` ${e.commandId}${e.jobId !== void 0 && e.jobId !== e.commandId ? ` (job ${e.jobId})` : ""} · ${e.running ? "running" : `exit ${e.exit}`}${e.running ? "" : ` · ${(e.durationMs / 1e3).toFixed(1)}s`} · ${e.summary}`);
2910
+ return `ledger (last ${ledger.length}):\n${rows.join("\n")}`;
2146
2911
  }
2147
2912
 
2148
2913
  //#endregion
@@ -2234,22 +2999,24 @@ function literalHosts(text) {
2234
2999
  wildcardBlocks
2235
3000
  };
2236
3001
  }
2237
- /**
2238
- * Build the roster text. Sections render independently; a failing section
2239
- * renders its native error (honest vocabulary: never invent, never hide).
2240
- */
2241
- async function renderRoster(sessions, opts = {}) {
3002
+ /** 三段本地扫描(各自独立降级,错误如实带回给 roster 呈现)。 */
3003
+ async function scanLocal(opts = {}) {
2242
3004
  const runner = opts.runner ?? runOneShot;
2243
3005
  const readSshConfig = opts.readSshConfig ?? (async () => await readFile(join(homedir(), ".ssh", "config"), "utf8"));
2244
3006
  const hard = opts.hardTimeoutSec ?? 10;
2245
- const lines = [];
3007
+ let ssh = {
3008
+ hosts: [],
3009
+ wildcardBlocks: 0
3010
+ };
2246
3011
  try {
2247
- const { hosts, wildcardBlocks } = literalHosts(await readSshConfig());
2248
- lines.push(`ssh hosts (~/.ssh/config): ${hosts.length > 0 ? hosts.join(", ") : "(none)"}${wildcardBlocks > 0 ? ` (+${wildcardBlocks} wildcard default blocks, not listed)` : ""}`);
3012
+ ssh = literalHosts(await readSshConfig());
2249
3013
  } catch (error) {
2250
- lines.push(`ssh hosts: no readable ~/.ssh/config (${error instanceof Error ? error.message : String(error)})`);
3014
+ ssh = {
3015
+ hosts: [],
3016
+ wildcardBlocks: 0,
3017
+ error: error instanceof Error ? error.message : String(error)
3018
+ };
2251
3019
  }
2252
- lines.push(`byossh labels (this Agent, in memory): ${opts.byosshLabels?.map((label) => `byossh:${label}`).join(", ") || "(none)"}`);
2253
3020
  const docker = await runner([
2254
3021
  "docker",
2255
3022
  "ps",
@@ -2257,13 +3024,6 @@ async function renderRoster(sessions, opts = {}) {
2257
3024
  "--format",
2258
3025
  "{{.Names}} {{.State}}"
2259
3026
  ], { timeoutSec: hard });
2260
- if (docker.exit === 0) {
2261
- const rows = docker.stdout.trim().split(/\r?\n/).filter(Boolean).map((l) => {
2262
- const [name$1, state] = l.split(" ");
2263
- return `${name$1}${state !== void 0 && state !== "" ? ` (${state})` : ""}`;
2264
- });
2265
- lines.push(`docker containers: ${rows.length > 0 ? rows.join(", ") : "(none)"}`);
2266
- } else lines.push(`docker containers: unavailable — ${docker.timedOut ? `scan timed out after ${hard}s` : oneLine(docker.stderr) || `docker exited ${docker.exit}`}`);
2267
3027
  const incus = await runner([
2268
3028
  "incus",
2269
3029
  "list",
@@ -2272,35 +3032,87 @@ async function renderRoster(sessions, opts = {}) {
2272
3032
  "--columns",
2273
3033
  "ns"
2274
3034
  ], { timeoutSec: hard });
2275
- if (incus.exit === 0) {
2276
- const rows = incus.stdout.trim().split(/\r?\n/).filter(Boolean).map((l) => {
3035
+ return {
3036
+ ssh,
3037
+ docker: docker.exit === 0 ? { rows: docker.stdout.trim().split(/\r?\n/).filter(Boolean).map((l) => {
3038
+ const [name$1, state] = l.split(" ");
3039
+ return {
3040
+ name: (name$1 ?? "").trim(),
3041
+ state: (state ?? "").trim()
3042
+ };
3043
+ }).filter((r) => r.name.length > 0) } : {
3044
+ rows: [],
3045
+ error: oneLine(docker.stderr) || `docker exited ${docker.exit}`,
3046
+ ...docker.timedOut ? { timedOut: true } : {}
3047
+ },
3048
+ incus: incus.exit === 0 ? { rows: incus.stdout.trim().split(/\r?\n/).filter(Boolean).map((l) => {
2277
3049
  const [name$1, state] = l.split(",");
2278
- return `${name$1}${state !== void 0 && state !== "" ? ` (${state.trim()})` : ""}`;
2279
- });
2280
- lines.push(`incus containers: ${rows.length > 0 ? rows.join(", ") : "(none)"}`);
2281
- } else lines.push(`incus containers: unavailable — ${incus.timedOut ? `scan timed out after ${hard}s` : oneLine(incus.stderr) || `incus exited ${incus.exit}`}`);
2282
- lines.push(sessions.length > 0 ? `live pty sessions: ${sessions.map((s) => `${s.target} [${s.state}${s.idleSec !== null ? `, idle ${s.idleSec}s` : ""}]`).join(", ")}` : "live pty sessions: none");
3050
+ return {
3051
+ name: (name$1 ?? "").trim(),
3052
+ state: (state ?? "").trim()
3053
+ };
3054
+ }).filter((r) => r.name.length > 0) } : {
3055
+ rows: [],
3056
+ error: oneLine(incus.stderr) || `incus exited ${incus.exit}`,
3057
+ ...incus.timedOut ? { timedOut: true } : {}
3058
+ }
3059
+ };
3060
+ }
3061
+ /** 统一池视图的 roster 呈现:机器三段 + BYO ssh 同位(built on)+ pty 会话段(caller-scoped)。 */
3062
+ function renderRoster(scan, entries, sessions, hubTargets) {
3063
+ const lines = [];
3064
+ if (hubTargets !== null) lines.push(`hub machines: ${hubTargets.map((t) => `hub:${t.id} (${t.label}${t.execEnabled ? "" : "; exec disabled"})`).join(", ") || "none"}`);
3065
+ if (scan.ssh.error !== void 0) lines.push(`ssh hosts: no readable ~/.ssh/config (${scan.ssh.error})`);
3066
+ else {
3067
+ const sshNames = entries.filter((e) => e.kind === "ssh").map((e) => e.name);
3068
+ const byoNames = entries.filter((e) => e.kind === "ssh-byo").map((e) => `${e.name} (built on)`);
3069
+ const all = [...sshNames, ...byoNames];
3070
+ lines.push(`ssh hosts (~/.ssh/config + registered): ${all.length > 0 ? all.join(", ") : "(none)"}${scan.ssh.wildcardBlocks > 0 ? ` (+${scan.ssh.wildcardBlocks} wildcard default blocks, not listed)` : ""}`);
3071
+ }
3072
+ const dockerStates = new Map(scan.docker.rows.map((r) => [r.name, r.state]));
3073
+ const dockerEntries = entries.filter((e) => e.kind === "docker");
3074
+ if (scan.docker.error !== void 0) lines.push(`docker containers: unavailable — ${scan.docker.timedOut ? `scan timed out` : scan.docker.error}`);
3075
+ else lines.push(`docker containers: ${dockerEntries.length > 0 ? dockerEntries.map((e) => e.kind === "docker" && `${e.name}${e.name !== e.container ? ` (= docker:${e.container}${dockerStates.get(e.container) ? `, ${dockerStates.get(e.container)}` : ""})` : dockerStates.get(e.container) !== void 0 && dockerStates.get(e.container) !== "" ? ` (${dockerStates.get(e.container)})` : ""}`).join(", ") : "(none)"}`);
3076
+ const incusStates = new Map(scan.incus.rows.map((r) => [r.name, r.state]));
3077
+ const incusEntries = entries.filter((e) => e.kind === "incus");
3078
+ if (scan.incus.error !== void 0) lines.push(`incus containers: unavailable — ${scan.incus.timedOut ? `scan timed out` : scan.incus.error}`);
3079
+ else lines.push(`incus containers: ${incusEntries.length > 0 ? incusEntries.map((e) => e.kind === "incus" && `${e.name}${e.name !== e.container ? ` (= incus:${e.container}${incusStates.get(e.container) ? `, ${incusStates.get(e.container)}` : ""})` : incusStates.get(e.container) !== void 0 && incusStates.get(e.container) !== "" ? ` (${incusStates.get(e.container)})` : ""}`).join(", ") : "(none)"}`);
3080
+ lines.push(sessions.length > 0 ? `pty sessions: ${sessions.map((s) => {
3081
+ const id = s.identity;
3082
+ const snap = s.snapshot;
3083
+ const state = snap.busy ? "busy" : snap.state === "dead" ? "died" : `idle ${Math.round((snap.idleMs ?? 0) / 1e3)}s`;
3084
+ return `pty:${id.id}${id.label !== void 0 ? ` (label ${id.label})` : ""} ${id.connection}${id.byo ? " (byo-pty)" : ""} [${state}]`;
3085
+ }).join(" · ")}` : "pty sessions: none");
2283
3086
  return lines.join("\n");
2284
3087
  }
2285
3088
 
2286
3089
  //#endregion
2287
3090
  //#region src/remote/driver.ts
2288
- /** PTY label 字符集:只禁空白 / `:` / 控制字符(roster 一行一 label,带换行的 label 渲染即烂)。 */
2289
- const PTY_LABEL_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
2290
- /** 双轨调度器(spec §七 Phase 2):参数校验 → 路由 → oneshot 子进程 / pty 会话池。 */
3091
+ /** R1:idle TTL 15 min——idle 随活动重置,在用会话永不超时(PtySession 双条件)。 */
3092
+ const REMOTE_PTY_IDLE_TTL_SEC = 900;
3093
+ /** P3(user 2026-10-19):每 agent 10 个 PTY 会话,跨所有 connection 统一计;仅 error message 披露。 */
3094
+ const REMOTE_PTY_SESSION_LIMIT = 10;
3095
+ /** 账本键与 jobId 同构(同格式 `<kind>-<16hex>`);background 通道预传入 registry id 时两者字面相等。 */
3096
+ const genCommandId = () => `remote-${randomUUID().replace(/-/g, "").slice(0, 16)}`;
3097
+ /** 双轨调度器:参数校验 → 目标语法(parseTargetForm)→ 统一 label 池 → oneshot 子进程 / pty 会话池。 */
2291
3098
  var RemoteDriver = class {
2292
- sshTargets = new SshTargets();
2293
3099
  pool;
3100
+ labels = new LabelPool();
2294
3101
  hubOwner = randomUUID();
2295
3102
  hubProviders = /* @__PURE__ */ new Set();
2296
3103
  processes = /* @__PURE__ */ new Set();
2297
3104
  starting = /* @__PURE__ */ new Set();
2298
3105
  fileAbort = new AbortController();
2299
3106
  disposed = false;
3107
+ /** undefined = 未尝试;null = 资产缺失(shim-only);registry = 每 target sticky 状态机。 */
3108
+ helpers;
3109
+ runnerOf = /* @__PURE__ */ new WeakMap();
2300
3110
  disposal;
3111
+ ptyIdleTtlSec;
2301
3112
  constructor(opts = {}) {
2302
3113
  this.opts = opts;
2303
- this.pool = new PtyPool({ idleTtlSec: opts.idleTtlSec ?? 600 });
3114
+ this.ptyIdleTtlSec = opts.idleTtlSec ?? REMOTE_PTY_IDLE_TTL_SEC;
3115
+ this.pool = new PtyPool({ idleTtlSec: this.ptyIdleTtlSec });
2304
3116
  }
2305
3117
  async call(params, callCtx = {}) {
2306
3118
  const started = Date.now();
@@ -2308,10 +3120,11 @@ var RemoteDriver = class {
2308
3120
  this.assertOpen();
2309
3121
  const prepared = await prepareRemoteInput(params);
2310
3122
  this.assertOpen();
2311
- const routing = this.route(prepared);
2312
- const result = await this.execute(prepared, routing, callCtx);
3123
+ const form = parseTargetForm(prepared.target);
3124
+ this.validate(form, prepared);
3125
+ const result = await this.execute(prepared, form, callCtx);
2313
3126
  this.audit({
2314
- target: routing.display,
3127
+ target: result.target,
2315
3128
  cmd: prepared.cmd,
2316
3129
  cwd: result.cwd ?? void 0,
2317
3130
  exit: result.exit,
@@ -2328,10 +3141,125 @@ var RemoteDriver = class {
2328
3141
  throw error;
2329
3142
  }
2330
3143
  }
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. */
3144
+ /** 参数互斥矩阵(§2):spawn/ssh 是 BYO 入口(参数在场即 BYO),只与对应形态的 target 配对。 */
3145
+ validate(form, params) {
3146
+ 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)})`);
3147
+ if (params.stdin !== void 0 && (params.cmd === void 0 || params.cmd.length === 0)) throw new Error("[E_STDIN_WITHOUT_CMD] remote: stdin requires cmd");
3148
+ const hasSpawn = params.spawn !== void 0 && params.spawn.length > 0;
3149
+ const hasSsh = params.ssh !== void 0;
3150
+ if (hasSpawn && hasSsh) throw new Error("[E_PARAMS] remote: spawn and ssh are two different BYO entry points — pass one, not both");
3151
+ const ptyForm = form.form === "pty-selector" || form.form === "empty" || form.form === "bare";
3152
+ const sshForm = form.form === "ssh-prefix" || form.form === "empty" || form.form === "bare" || form.form === "direct" && form.transport === "ssh" || form.form === "create-pty" && form.transport === "ssh";
3153
+ if (hasSpawn && !ptyForm) throw new Error("[E_PARAMS] remote: spawn pairs only with a pty target (pty, pty:<label>, or a bare label) — machines are dialed, not spawned");
3154
+ if (hasSsh && !sshForm) throw new Error("[E_PARAMS] remote: ssh registration pairs with ssh-form targets only (ssh, ssh:<label>, ssh:<label>:pty, a bare label, or no target)");
3155
+ }
3156
+ async execute(params, form, callCtx) {
3157
+ const timeoutSec = params.timeout ?? this.opts.execTimeoutSec ?? 120;
3158
+ const cap = this.opts.maxOutputChars ?? 3e4;
3159
+ const sessionKey = callCtx.sessionKey ?? "no-session";
3160
+ const hasCmd = params.cmd !== void 0 && params.cmd.length > 0;
3161
+ params.spawn !== void 0 && params.spawn.length;
3162
+ if (form.form === "hub" || form.form === "hub-pty") {
3163
+ const hub = this.requireHub();
3164
+ this.hubProviders.add(hub);
3165
+ if (form.form === "hub-pty") {
3166
+ if (!hasCmd) throw new Error("[E_PARAMS] remote: hub PTY takes cmd");
3167
+ const result$1 = await hub.pty(form.machine, params.cmd, this.hubOwner, {
3168
+ stdin: params.stdin,
3169
+ timeout: timeoutSec
3170
+ });
3171
+ const out$1 = tailWindow(result$1.stdout, cap, result$1.outputStats?.stdout);
3172
+ return {
3173
+ ...result$1,
3174
+ target: `hub:${form.machine}`,
3175
+ connection: `hub:${form.machine}`,
3176
+ stdout: `[hub:${form.machine} pty — no session id, no parallel, no background]\n${out$1.text}`,
3177
+ ...out$1.stats ? { outputStats: {
3178
+ ...result$1.outputStats,
3179
+ stdout: out$1.stats
3180
+ } } : {},
3181
+ ...out$1.truncated === void 0 ? {} : { truncated: out$1.truncated }
3182
+ };
3183
+ }
3184
+ const process$1 = this.own(await hub.start(form.machine, {
3185
+ ...params,
3186
+ target: `hub:${form.machine}`,
3187
+ mode: "oneshot",
3188
+ timeout: timeoutSec
3189
+ }, this.hubOwner));
3190
+ if (this.disposed) process$1.cancel("owner disposed");
3191
+ const result = await process$1.done;
3192
+ const stdout = process$1.read("stdout", 0), stderr = process$1.read("stderr", 0);
3193
+ const out = tailWindow(stdout.text, cap, stdout.stats);
3194
+ const err = tailWindow(stderr.text, cap, stderr.stats);
3195
+ return {
3196
+ ...result,
3197
+ mode: "oneshot",
3198
+ target: `hub:${form.machine}`,
3199
+ connection: `hub:${form.machine}`,
3200
+ cwd: null,
3201
+ reconnected: false,
3202
+ stdout: (!stdout.stats && stdout.lossy ? "[truncated: peer returned only a tail; exact total/retained/dropped unavailable]\n" : "") + out.text,
3203
+ stderr: [err.text, stderr.diagnostic].filter(Boolean).join("\n"),
3204
+ ...out.stats ? { outputStats: {
3205
+ stdout: out.stats,
3206
+ stderr: err.stats
3207
+ } } : { outputStatsUnavailable: true },
3208
+ ...out.truncated === void 0 ? {} : { truncated: out.truncated }
3209
+ };
3210
+ }
3211
+ const lane = this.createPtyLane(params, form, sessionKey);
3212
+ if (lane !== void 0) {
3213
+ const session = lane.session;
3214
+ if (!hasCmd) {
3215
+ await session.warm();
3216
+ return this.warmReceipt(session);
3217
+ }
3218
+ return await this.dispatchPty(session, params, callCtx, timeoutSec, cap);
3219
+ }
3220
+ if (params.ssh !== void 0) {
3221
+ const label = form.form === "direct" || form.form === "bare" ? form.name : void 0;
3222
+ const name$1 = this.registerByoSsh(label, params.ssh, sessionKey);
3223
+ if (!hasCmd) return {
3224
+ mode: "oneshot",
3225
+ target: `ssh:${name$1}`,
3226
+ connection: `ssh:${name$1}`,
3227
+ byoTag: "byo-ssh",
3228
+ exit: null,
3229
+ cwd: null,
3230
+ timedOut: false,
3231
+ reconnected: false,
3232
+ stdout: `ssh:${name$1} registered (byo-ssh, no dialing). Reuse the label anywhere ssh: aliases work; a new Agent registers again.`,
3233
+ durationMs: 0
3234
+ };
3235
+ return await this.oneshot({
3236
+ kind: "ssh",
3237
+ host: normalizeSshConnection(params.ssh).host,
3238
+ connection: normalizeSshConnection(params.ssh)
3239
+ }, `ssh:${name$1}`, params, timeoutSec, cap);
3240
+ }
3241
+ if (form.form === "pty-selector" && form.idOrLabel === void 0) throw new Error("[E_PARAMS] remote: \"pty\" alone selects nothing — append an id/label (pty:pty-1a2b, pty:build) or pass spawn to create one");
3242
+ if (form.form === "pty-selector") {
3243
+ const session = this.resolvePty(form.idOrLabel, sessionKey);
3244
+ if (!hasCmd) return this.cardReceipt(session);
3245
+ return await this.dispatchPty(session, params, callCtx, timeoutSec, cap);
3246
+ }
3247
+ if (form.form === "direct") {
3248
+ const plan$1 = form.transport === "ssh" ? this.sshPlanFor(form.name, sessionKey) : resolveTarget(`${form.transport}:${form.name}`);
3249
+ return await this.oneshot(plan$1, `${form.transport}:${form.name}`, params, timeoutSec, cap);
3250
+ }
3251
+ if (form.form !== "bare") throw new Error("[E_INTERNAL] remote: unreachable target form after routing");
3252
+ const entry = await this.resolvePoolEntry(form.name, sessionKey);
3253
+ if (entry.kind === "pty") {
3254
+ const session = this.resolvePty(form.name, sessionKey);
3255
+ if (!hasCmd) return this.cardReceipt(session);
3256
+ return await this.dispatchPty(session, params, callCtx, timeoutSec, cap);
3257
+ }
3258
+ if (entry.kind === "hub") return await this.hubOneshot(entry.machine, `hub:${entry.machine}`, params, timeoutSec, cap);
3259
+ const plan = this.planOf(entry);
3260
+ return await this.oneshot(plan, form.name, params, timeoutSec, cap);
3261
+ }
3262
+ /** Native-first copy routing (spec: remote-native-shortcut) — unchanged by the workbench round. */
2335
3263
  copy(request, signal, callCtx = {}) {
2336
3264
  const combined = AbortSignal.any([signal, this.fileAbort.signal]);
2337
3265
  const work = (async () => {
@@ -2403,13 +3331,18 @@ var RemoteDriver = class {
2403
3331
  path: dst.path,
2404
3332
  endpoint: localFileEndpoint()
2405
3333
  }, 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)");
3334
+ if (request.verify !== void 0) throw new Error("copy: verify is a Hub/local staged lane option (independent SHA-256); native routes report exit-code-only verification — self-verify via oneshot digest commands when needed");
2407
3335
  const planOf = (end) => end.kind === "cli" ? end.plan : end.kind === "local" ? { kind: "local" } : void 0;
2408
3336
  const srcPlan = planOf(src), dstPlan = planOf(dst);
2409
3337
  if (srcPlan === void 0 || dstPlan === void 0) throw new Error("copy: unreachable route resolution");
3338
+ const execArgvFor = (plan, script) => {
3339
+ const argv = helperShellArgv(plan, script);
3340
+ return this.opts.nativeArgvFor ? this.opts.nativeArgvFor(argv) : argv;
3341
+ };
2410
3342
  return nativeCopy({
2411
3343
  src: request.src,
2412
3344
  dst: request.dst,
3345
+ ...request.mkdir === void 0 ? {} : { mkdir: request.mkdir },
2413
3346
  ...request.argv === void 0 ? {} : { argv: request.argv },
2414
3347
  ...request.pullArgv === void 0 ? {} : { pullArgv: request.pullArgv },
2415
3348
  ...request.pushArgv === void 0 ? {} : { pushArgv: request.pushArgv }
@@ -2422,7 +3355,10 @@ var RemoteDriver = class {
2422
3355
  plan: dstPlan,
2423
3356
  path: dst.path
2424
3357
  }
2425
- }, combined, { wrapArgv: this.opts.nativeArgvFor });
3358
+ }, combined, {
3359
+ wrapArgv: this.opts.nativeArgvFor,
3360
+ execArgvFor
3361
+ });
2426
3362
  })();
2427
3363
  this.starting.add(work);
2428
3364
  work.then(() => this.starting.delete(work), () => this.starting.delete(work));
@@ -2436,7 +3372,8 @@ var RemoteDriver = class {
2436
3372
  const endpoint = async (value) => {
2437
3373
  const parsed = parseFileEndpoint(value);
2438
3374
  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");
3375
+ const form = parseTargetForm(parsed.target);
3376
+ if (form.form === "hub" || form.form === "hub-pty" || form.form === "pty-selector") throw new Error("rsync supports SSH endpoints only (plus one local endpoint); no mixed-transport or copy fallback");
2440
3377
  const plan = await this.resolveConnectionTarget(parsed.target, callCtx);
2441
3378
  this.assertOpen();
2442
3379
  combined.throwIfAborted();
@@ -2456,45 +3393,66 @@ var RemoteDriver = class {
2456
3393
  return work;
2457
3394
  }
2458
3395
  /** 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]));
3396
+ start(input, signal, callCtx = {}) {
3397
+ const work = this.startPrepared(input, AbortSignal.any([signal, this.fileAbort.signal]), callCtx);
2461
3398
  this.starting.add(work);
2462
3399
  work.then(() => this.starting.delete(work), () => this.starting.delete(work));
2463
3400
  return work;
2464
3401
  }
2465
- async startPrepared(input, signal) {
3402
+ async startPrepared(input, signal, callCtx = {}) {
2466
3403
  this.assertOpen();
2467
3404
  signal.throwIfAborted();
2468
3405
  const params = await prepareRemoteInput(input);
2469
3406
  this.assertOpen();
2470
3407
  signal.throwIfAborted();
2471
- const route = this.route(params);
2472
- if (route.mode !== "oneshot" || route.pty) throw new Error("remote: background requires oneshot");
3408
+ const form = parseTargetForm(params.target);
3409
+ this.validate(form, params);
3410
+ if (form.form !== "direct" && form.form !== "bare" && form.form !== "hub") throw new Error("remote: background oneshot takes a machine target; PTY lanes go through the pty job channel");
3411
+ if (params.ssh !== void 0 && (form.form === "direct" || form.form === "bare") && form.transport !== "docker" && form.transport !== "incus") this.registerByoSsh(form.name, params.ssh, callCtx.sessionKey ?? "no-session");
2473
3412
  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);
3413
+ if (form.form === "hub") process$1 = await this.hubOneshotProcess(form.machine, params);
3414
+ else if (form.form === "direct") {
3415
+ const plan = form.transport === "ssh" ? this.sshPlanFor(form.name, callCtx.sessionKey ?? "no-session") : resolveTarget(`${form.transport}:${form.name}`);
2483
3416
  this.assertOpen();
2484
3417
  signal.throwIfAborted();
2485
- if (plan.kind === "pty") throw new Error("remote: background requires oneshot");
2486
3418
  process$1 = await this.nativeProcess(plan, params, params.timeout, signal);
2487
- }
3419
+ } else if (form.form === "bare") {
3420
+ const entry = await this.resolvePoolEntry(form.name, callCtx.sessionKey ?? "no-session");
3421
+ this.assertOpen();
3422
+ signal.throwIfAborted();
3423
+ if (entry.kind === "pty" || entry.kind === "hub") throw new Error("remote: background requires an ssh/docker/incus target");
3424
+ process$1 = await this.nativeProcess(this.planOf(entry), params, params.timeout, signal);
3425
+ } else throw new Error("remote: background oneshot takes a machine target");
2488
3426
  this.own(process$1);
2489
3427
  if (signal.aborted || this.disposed) process$1.cancel("owner closed during start");
2490
3428
  return process$1;
2491
3429
  }
2492
- nativeProcess(plan, params, timeoutSec, signal = this.fileAbort.signal) {
3430
+ async nativeProcess(plan, params, timeoutSec, signal = this.fileAbort.signal) {
2493
3431
  const cleanupTag = randomUUID().slice(0, 8);
3432
+ const helper = await this.ensureHelper(plan);
3433
+ if (helper !== null && !this.disposed) {
3434
+ const process$2 = startOneShot(buildHelperOneShotArgv(plan, helper.binPath, {
3435
+ tag: cleanupTag,
3436
+ cmd: params.cmd
3437
+ }), {
3438
+ stdin: params.stdin,
3439
+ timeoutSec
3440
+ });
3441
+ this.runnerOf.set(process$2, `superd-run ${helper.ver} (${helper.triple})`);
3442
+ process$2.done.then((result) => {
3443
+ if (!result.cancelled && !result.timedOut) return;
3444
+ startOneShot(buildHelperKillgroupArgv(plan, helper.binPath, cleanupTag), { timeoutSec: 15 }).done.catch(() => {});
3445
+ }).catch(() => {});
3446
+ const tracked = Promise.resolve(process$2);
3447
+ this.starting.add(tracked);
3448
+ tracked.then(() => this.starting.delete(tracked), () => this.starting.delete(tracked));
3449
+ return tracked;
3450
+ }
2494
3451
  const process$1 = startOneShot((this.opts.oneshotArgvFor ?? buildOneShotArgv)(plan, params.cmd, { cleanupTag }), {
2495
3452
  stdin: params.stdin,
2496
3453
  timeoutSec
2497
3454
  });
3455
+ this.runnerOf.set(process$1, "posix-shim");
2498
3456
  process$1.done.then((result) => {
2499
3457
  if (!result.cancelled && !result.timedOut) return;
2500
3458
  const pidfile = `"\${TMPDIR:-/tmp}/superd-oneshot-${cleanupTag}.pgid"`;
@@ -2506,296 +3464,348 @@ var RemoteDriver = class {
2506
3464
  work.then(() => this.starting.delete(work), () => this.starting.delete(work));
2507
3465
  return work;
2508
3466
  }
3467
+ /** Helper bootstrap is best-effort and sticky: any failure parks null for this driver
3468
+ * lifetime and the shim stays the path. First call to a target pays probe (+ upload). */
3469
+ async ensureHelper(plan) {
3470
+ if (this.helpers === void 0) {
3471
+ const assets = this.opts.helperAssetsFor?.() ?? helperAssets();
3472
+ this.helpers = assets === null ? null : new HelperRegistry(assets, (argv, options) => this.helperExec(argv, options), buildHelperProbeArgv, buildHelperUploadArgv);
3473
+ }
3474
+ if (this.helpers === null || this.disposed) return null;
3475
+ try {
3476
+ return await this.helpers.ensure(plan);
3477
+ } catch {
3478
+ return null;
3479
+ }
3480
+ }
3481
+ async helperExec(argv, options) {
3482
+ this.assertOpen();
3483
+ const stdin = options?.stdin;
3484
+ if (stdin !== void 0) {
3485
+ const proc$1 = startBinaryProcess(argv, { timeoutSec: options?.timeoutSec });
3486
+ proc$1.stdin.on("error", () => {});
3487
+ proc$1.stdin.end(stdin);
3488
+ return {
3489
+ exit: (await proc$1.done).exit,
3490
+ stdout: ""
3491
+ };
3492
+ }
3493
+ const proc = startOneShot(argv, { timeoutSec: options?.timeoutSec });
3494
+ return {
3495
+ exit: (await proc.done).exit,
3496
+ stdout: proc.read("stdout", 0).text
3497
+ };
3498
+ }
2509
3499
  own(process$1) {
2510
3500
  this.processes.add(process$1);
2511
3501
  process$1.done.then(() => this.processes.delete(process$1));
2512
3502
  return process$1;
2513
3503
  }
2514
- /** Ruling 16/17:on-demand 探测 + 会话层,两行如实陈述。 */
2515
- /**
2516
- * Ruling P20 + PTY 候选表末位:裸名按名匹配——ssh config 字面 host → docker → incus(本地廉价扫描,
2517
- * 撞名 ssh 优先),三段全不中再看池内活 PTY label,最后才是默认 ssh(交原生错误)。
2518
- * 位置即优先级:现有三段一位不挪。选择器前缀的 target 不经此路径。
2519
- */
2520
- async resolveBareName(name$1, sessionKey, usePtyLabels = true) {
3504
+ /** 现场采集机器面并 merge 进本 session 注册表(ssh → docker → incus → hub,迟到者加前缀)。错误逐段吞掉(roster 另行如实呈现)。 */
3505
+ async refreshMachines(sessionKey) {
2521
3506
  const runner = this.opts.rosterRunner ?? runOneShot;
2522
3507
  const readCfg = this.opts.sshConfigReader ?? (async () => await readFile(join(homedir(), ".ssh", "config"), "utf8"));
2523
- let hosts = [];
3508
+ let sshHosts = [];
2524
3509
  try {
2525
- hosts = literalHosts(await readCfg()).hosts;
3510
+ sshHosts = literalHosts(await readCfg()).hosts;
2526
3511
  } catch {}
2527
- this.assertOpen();
2528
- if (hosts.some((h) => h.toLowerCase() === name$1.toLowerCase())) return {
2529
- kind: "ssh",
2530
- host: name$1
2531
- };
2532
- const docker = await runner([
3512
+ const names = (argv) => (async () => {
3513
+ const r = await runner(argv, { timeoutSec: 10 });
3514
+ this.assertOpen();
3515
+ return r.exit === 0 ? r.stdout.trim().split(/\r?\n/).map((l) => l.split(/[\t,]/)[0].trim()).filter((n) => n.length > 0) : [];
3516
+ })().catch(() => []);
3517
+ const [docker, incus] = await Promise.all([names([
2533
3518
  "docker",
2534
3519
  "ps",
2535
3520
  "-a",
2536
3521
  "--format",
2537
3522
  "{{.Names}}"
2538
- ], { timeoutSec: 10 });
2539
- this.assertOpen();
2540
- if (docker.exit === 0 && docker.stdout.trim().split(/\r?\n/).some((n) => n.trim().toLowerCase() === name$1.toLowerCase())) return {
2541
- kind: "docker",
2542
- container: name$1
2543
- };
2544
- const incus = await runner([
3523
+ ]), names([
2545
3524
  "incus",
2546
3525
  "list",
2547
3526
  "--format",
2548
3527
  "csv",
2549
3528
  "--columns",
2550
3529
  "n"
2551
- ], { timeoutSec: 10 });
3530
+ ])]);
3531
+ const hub = this.opts.getHub?.()?.listTargets().map((t) => t.id) ?? [];
3532
+ this.labels.mergeScan(sessionKey, {
3533
+ sshHosts,
3534
+ docker,
3535
+ incus,
3536
+ hub
3537
+ });
3538
+ }
3539
+ /** 池寻址:命中即回;miss 现场采集一次再寻址(docker 容器名不经 roster 也可达)。 */
3540
+ async resolvePoolEntry(name$1, sessionKey) {
3541
+ const hit = this.labels.resolve(name$1, sessionKey);
3542
+ if (hit !== void 0) return hit;
3543
+ await this.refreshMachines(sessionKey);
2552
3544
  this.assertOpen();
2553
- if (incus.exit === 0 && incus.stdout.trim().split(/\r?\n/).some((n) => n.split(",")[0]?.trim().toLowerCase() === name$1.toLowerCase())) return {
2554
- kind: "incus",
2555
- container: name$1
3545
+ const after = this.labels.resolve(name$1, sessionKey);
3546
+ if (after !== void 0) return after;
3547
+ return {
3548
+ kind: "ssh",
3549
+ name: name$1,
3550
+ host: name$1
2556
3551
  };
2557
- if (usePtyLabels && this.pool.get(`${sessionKey}|p:${name$1}`) !== void 0) return {
2558
- kind: "pty",
2559
- label: name$1
3552
+ }
3553
+ planOf(entry) {
3554
+ switch (entry.kind) {
3555
+ case "ssh": return {
3556
+ kind: "ssh",
3557
+ host: entry.host
3558
+ };
3559
+ case "ssh-byo": return {
3560
+ kind: "ssh",
3561
+ host: entry.connection.host,
3562
+ connection: entry.connection
3563
+ };
3564
+ case "docker": return {
3565
+ kind: "docker",
3566
+ container: entry.container
3567
+ };
3568
+ case "incus": return {
3569
+ kind: "incus",
3570
+ container: entry.container
3571
+ };
3572
+ case "pty": throw new Error("remote: a PTY session is not a connection target");
3573
+ case "hub": throw new Error("remote: hub machines are not connection targets");
3574
+ }
3575
+ }
3576
+ /** ssh:<name> 的池感知解析:BYO 身份 → 其 connection;否则按 config 别名/主机名。 */
3577
+ sshPlanFor(name$1, sessionKey) {
3578
+ const entry = this.labels.resolve(name$1, sessionKey);
3579
+ if (entry?.kind === "ssh-byo") return {
3580
+ kind: "ssh",
3581
+ host: entry.connection.host,
3582
+ connection: entry.connection
2560
3583
  };
2561
3584
  return {
2562
3585
  kind: "ssh",
2563
3586
  host: name$1
2564
3587
  };
2565
3588
  }
2566
- /** Ruling P19:roster = 注意力入口——本地廉价扫描(ssh config/docker ps/incus list + 池内活会话),零拨号。
2567
- * 活的 pty 会话按调用方 agent session 过滤(2026-10-03 裁决):ssh/docker/incus 是**公共静态配置**,
2568
- * 而活会话是**操作环境**——复用别的 agent 留下的 shell 等于继承未知状态,且两个 agent 各开一条线
2569
- * 的代价可忽略。BYO-PTY 天然是活的,于是自然落进这个 scope,无需另立规则。 */
2570
- async roster(callCtx = {}) {
3589
+ /** BYO ssh 注册(v2:无 spec 对比——label 占用即拒;注册成功才认领)。返回实际名(label 或 s-id)。 */
3590
+ registerByoSsh(label, connection, sessionKey) {
2571
3591
  this.assertOpen();
2572
- const prefix = `${callCtx.sessionKey ?? "no-session"}|`;
2573
- const sessions = this.pool.list().filter(({ key }) => key.startsWith(prefix)).map(({ key, snapshot }) => ({
2574
- target: displayKey(key),
2575
- state: snapshot.state,
2576
- idleSec: snapshot.idleMs !== null ? Math.round(snapshot.idleMs / 1e3) : null
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");
3592
+ const normalized = normalizeSshConnection(connection);
3593
+ if (label !== void 0) validateLabel(label);
3594
+ const name$1 = label ?? this.labels.freshId("ssh-", sessionKey);
3595
+ if (label !== void 0) {
3596
+ const existing = this.labels.resolve(label, sessionKey);
3597
+ if (existing !== void 0) throw new Error(`[E_LABEL_IN_USE] remote: label ${JSON.stringify(label)} is already taken by ${describeEntry(existing)} — nothing was run.\n To reuse it: address ssh:${label} without the ssh field\n To register new: pick another label`);
3598
+ this.labels.claim(label, sessionKey, {
3599
+ kind: "ssh-byo",
3600
+ name: label,
3601
+ connection: normalized
3602
+ });
3603
+ return label;
3604
+ }
3605
+ this.labels.claim(name$1, sessionKey, {
3606
+ kind: "ssh-byo",
3607
+ name: name$1,
3608
+ connection: normalized
3609
+ });
3610
+ return name$1;
2588
3611
  }
2589
- async status(target, callCtx = {}) {
3612
+ /** PTY 会话创建(native/spawn 同一形状):上限 → label 认领 → 惰性会话(失败由 onDead 释放 label)。 */
3613
+ createSession(opts, sessionKey) {
2590
3614
  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`;
3615
+ const live = this.pool.list().filter(({ key: key$1 }) => key$1.startsWith(`${sessionKey}|`)).map(({ key: key$1 }) => this.pool.get(key$1)).filter((s) => s !== void 0 && s.sessionState !== "dead");
3616
+ if (live.length >= REMOTE_PTY_SESSION_LIMIT) {
3617
+ const listing = live.map(sessionLine).join(" · ");
3618
+ throw new Error(`[E_SESSION_LIMIT] remote: this Agent already holds ${live.length} live PTY sessions (limit ${REMOTE_PTY_SESSION_LIMIT}) — close one ({action:"close", target:"pty:<id>"}); idle sessions are reaped after ${this.ttlMinutes()} min.\n live: ${listing}`);
2596
3619
  }
2597
- const sessionKey = callCtx.sessionKey ?? "no-session";
2598
- const label = target.startsWith("byop:") ? target.slice(5) : void 0;
2599
- if (label !== void 0) return this.ptyStatus(target, label, 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();
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;
2606
- return [
2607
- head,
2608
- renderProbe(await probeTarget(resolved, { runner: (argv, options) => {
2609
- this.assertOpen();
2610
- return runner(argv, options);
2611
- } })),
2612
- renderSession(this.pool.inspect(`${sessionKey}|t:${target}`))
2613
- ].join("\n");
3620
+ const { label } = opts;
3621
+ if (label !== void 0) validateLabel(label);
3622
+ const id = this.labels.freshId("pty-", sessionKey);
3623
+ const entry = {
3624
+ kind: "pty",
3625
+ name: id,
3626
+ id,
3627
+ ...label !== void 0 ? { label } : {},
3628
+ connection: opts.connection,
3629
+ byo: opts.byo
3630
+ };
3631
+ if (label !== void 0) this.labels.claim(label, sessionKey, {
3632
+ ...entry,
3633
+ name: label
3634
+ });
3635
+ const key = `${sessionKey}|p:${id}`;
3636
+ let session;
3637
+ try {
3638
+ session = this.pool.create({
3639
+ key,
3640
+ argv: opts.argv,
3641
+ id,
3642
+ ...label !== void 0 ? { label } : {},
3643
+ connection: opts.connection,
3644
+ byo: opts.byo,
3645
+ builtOn: this.isBuiltOn(opts.connection, sessionKey),
3646
+ ...opts.origin !== void 0 ? { origin: opts.origin } : {},
3647
+ onDead: () => {
3648
+ this.labels.release(id, sessionKey);
3649
+ if (label !== void 0) this.labels.release(label, sessionKey);
3650
+ }
3651
+ });
3652
+ } catch (error) {
3653
+ this.labels.release(id, sessionKey);
3654
+ if (label !== void 0) this.labels.release(label, sessionKey);
3655
+ throw error;
3656
+ }
3657
+ this.labels.claim(id, sessionKey, entry);
3658
+ return session;
2614
3659
  }
2615
- /** PTY 状态行:纯池内读取,零拨号(label 从来不是可拨目标)。 */
2616
- ptyStatus(display, label, sessionKey) {
2617
- return [`${display} — BYO pty session`, renderPtySession(this.pool.inspect(`${sessionKey}|p:${label}`))].join("\n");
3660
+ ttlMinutes() {
3661
+ return Math.max(1, Math.round(this.ptyIdleTtlSec / 60));
2618
3662
  }
2619
- /** Ruling 13:审计 best-effort——钩子抛错不得影响命令路径(成功被毒化成失败+双重审计)。 */
2620
- audit(r) {
2621
- try {
2622
- this.opts.onAudit?.(r);
2623
- } catch {}
3663
+ /** connection 由 BYO ssh 身份建立 → card 面标 `(built on)`(与 roster 同词,R2 复核 P2-10)。 */
3664
+ isBuiltOn(connection, sessionKey) {
3665
+ return connection.startsWith("ssh:") && this.labels.resolve(connection.slice(4), sessionKey)?.kind === "ssh-byo";
2624
3666
  }
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
- };
3667
+ /** PTY 创建形唯一实现(execute 与 background 预建共用):spawn BYO / ssh BYO / native `<transport>:pty`。 */
3668
+ createPtyLane(params, form, sessionKey) {
3669
+ if (params.spawn !== void 0 && params.spawn.length > 0) {
3670
+ const label = form.form === "pty-selector" && form.idOrLabel !== void 0 ? form.idOrLabel : form.form === "bare" ? form.name : void 0;
3671
+ if (label !== void 0 && PTY_ID_RE.test(label)) throw new Error(`[E_BAD_LABEL] remote: ${JSON.stringify(label)} looks like an auto id — ids are generated, labels are yours to choose`);
3672
+ return { session: this.createSession({
3673
+ argv: (this.opts.spawnArgvFor ?? buildSpawnArgv)(params.spawn),
3674
+ connection: "BYOP",
3675
+ byo: true,
3676
+ ...label !== void 0 ? { label } : {},
3677
+ origin: params.spawn
3678
+ }, sessionKey) };
3679
+ }
3680
+ if (params.ssh !== void 0 && form.form === "create-pty") {
3681
+ const name$1 = this.registerByoSsh(form.name, params.ssh, sessionKey);
3682
+ const connection = normalizeSshConnection(params.ssh);
3683
+ const plan = {
3684
+ kind: "ssh",
3685
+ host: connection.host,
3686
+ connection
3687
+ };
3688
+ return { session: this.createSession({
3689
+ argv: (this.opts.ptyArgvFor ?? buildPtyArgv)(plan),
3690
+ connection: `ssh:${name$1}`,
3691
+ byo: false,
3692
+ ...form.label !== void 0 ? { label: form.label } : {}
3693
+ }, sessionKey) };
3694
+ }
3695
+ if (form.form === "create-pty") {
3696
+ const plan = form.transport === "ssh" ? this.sshPlanFor(form.name, sessionKey) : resolveTarget(`${form.transport}:${form.name}`);
3697
+ return { session: this.createSession({
3698
+ argv: (this.opts.ptyArgvFor ?? buildPtyArgv)(plan),
3699
+ connection: `${form.transport}:${form.name}`,
3700
+ byo: false,
3701
+ ...form.label !== void 0 ? { label: form.label } : {}
3702
+ }, sessionKey) };
3703
+ }
2641
3704
  }
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 = {}) {
3705
+ /** background PTY lane:会话先占位(不拨号)再进官方 jobs registry——首回执必须能寻址;
3706
+ * registry 拒绝时把新建的会话拆掉(复用形的会话绝不拆)。返回 undefined = 走普通 oneshot job。 */
3707
+ async preparePtyLane(params, callCtx = {}) {
2645
3708
  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);
3709
+ const prepared = await prepareRemoteInput(params);
2650
3710
  this.assertOpen();
2651
- if (plan.kind === "pty") throw new Error("remote: BYO-PTY is not a filesystem connection");
2652
- return plan;
2653
- }
2654
- route(params) {
2655
- const hasTarget = params.target !== void 0 && params.target.length > 0;
2656
- const hasSpawn = params.spawn !== void 0 && params.spawn.length > 0;
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");
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)})`);
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");
3711
+ const sessionKey = callCtx.sessionKey ?? "no-session";
3712
+ const form = parseTargetForm(prepared.target);
3713
+ const target = (id$1) => ({
3714
+ target: `pty:${id$1}`,
3715
+ cmd: params.cmd,
3716
+ stdin: params.stdin,
3717
+ timeout: params.timeout,
3718
+ mode: "pty"
3719
+ });
3720
+ const lane = this.createPtyLane(prepared, form, sessionKey);
3721
+ if (lane === void 0 && form.form === "pty-selector") {
3722
+ if (form.idOrLabel === void 0) throw new Error("[E_PARAMS] remote: \"pty\" alone selects nothing — append an id/label (pty:pty-1a2b, pty:build) or pass spawn to create one");
3723
+ const id$1 = this.resolvePty(form.idOrLabel, sessionKey).identity;
2663
3724
  return {
2664
- mode: params.mode ?? "oneshot",
2665
- display: params.target,
2666
- plan: this.byosshPlan(params.target, params.ssh)
3725
+ input: target(id$1.id),
3726
+ session: `pty:${id$1.id}`,
3727
+ connection: id$1.connection,
3728
+ ...id$1.label !== void 0 ? { label: id$1.label } : {},
3729
+ cleanup: () => void 0
2667
3730
  };
2668
3731
  }
2669
- if (hasTarget && params.target.startsWith("byop:")) {
2670
- const label = params.target.slice(5);
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"`);
2672
- if (params.mode !== void 0 && params.mode !== "pty") throw new Error(`[E_BAD_MODE] remote: "${params.target}" is a PTY selector — mode is locked to 'pty'`);
2673
- return {
2674
- mode: "pty",
2675
- display: params.target,
2676
- pty: {
2677
- label,
2678
- ...hasSpawn ? { spawn: params.spawn } : {}
2679
- }
2680
- };
3732
+ if (lane === void 0) return void 0;
3733
+ const id = lane.session.identity;
3734
+ return {
3735
+ input: target(id.id),
3736
+ session: `pty:${id.id}`,
3737
+ connection: id.connection,
3738
+ ...id.label !== void 0 ? { label: id.label } : {},
3739
+ cleanup: () => {
3740
+ lane.session.dispose();
3741
+ }
3742
+ };
3743
+ }
3744
+ /** `pty:<id|label>` 寻址:先 id 后 label;miss 给重建形式。 */
3745
+ resolvePty(selector, sessionKey) {
3746
+ const entry = this.labels.resolve(selector, sessionKey);
3747
+ if (entry?.kind === "pty") {
3748
+ const s = this.pool.get(`${sessionKey}|p:${entry.id}`);
3749
+ if (s !== void 0) return s;
2681
3750
  }
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");
2683
- const mode = params.mode ?? "oneshot";
2684
- if (params.target.startsWith("hub:")) return {
2685
- mode,
2686
- display: params.target
3751
+ throw new Error(`[E_NO_SESSION] remote: no live PTY under ${PTY_ID_RE.test(selector) ? `id ${selector}` : `label ${JSON.stringify(selector)}`} — sessions are created by appending :pty to a transport target ({target:"ssh:dev4:pty", cmd:"…"}) or by spawn; idle sessions are reaped after ${this.ttlMinutes()} min`);
3752
+ }
3753
+ /** §6 close:默认撞 detached job 报错(列杀法);force = 杀组+关闭(披露 killed)。幂等 not-found。 */
3754
+ closePty(selector, sessionKey, force = false) {
3755
+ this.assertOpen();
3756
+ const entry = this.labels.resolve(selector, sessionKey);
3757
+ if (entry?.kind !== "pty") return {
3758
+ session: selector,
3759
+ connection: "",
3760
+ closed: false,
3761
+ reason: "not-found",
3762
+ killed: [],
3763
+ message: `no pty session is registered under ${JSON.stringify(selector)}`
2687
3764
  };
2688
- const direct = /^(docker|incus|ssh):/.test(params.target);
3765
+ const session = this.pool.get(`${sessionKey}|p:${entry.id}`);
3766
+ if (session === void 0) return {
3767
+ session: `pty:${entry.id}`,
3768
+ connection: entry.connection,
3769
+ closed: false,
3770
+ reason: "already-closed-or-reaped",
3771
+ killed: [],
3772
+ message: `pty:${entry.id} was already closed or reaped after ${this.ttlMinutes()} min idle`
3773
+ };
3774
+ const pending = session.detachedCommandId;
3775
+ if (pending !== null && !force) throw new Error(`[E_SESSION_BUSY] remote: pty session ${entry.id} still runs a detached command (${pending}) — job_kill ${pending} first (or wait for it), then close again; force:true closes the tab and kills the group in one step`);
3776
+ session.dispose();
2689
3777
  return {
2690
- mode,
2691
- display: params.target,
2692
- ...direct ? { plan: resolveTarget(params.target) } : { bare: params.target }
3778
+ session: `pty:${entry.id}`,
3779
+ connection: entry.connection,
3780
+ closed: true,
3781
+ ...pending !== null ? { killed: [pending] } : { killed: [] }
2693
3782
  };
2694
3783
  }
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
- }
2744
- const sessionKey = callCtx.sessionKey ?? "no-session";
2745
- if (routing.bare !== void 0) {
2746
- const resolved = await this.resolveBareName(routing.bare, sessionKey, params.mode !== "oneshot");
2747
- this.assertOpen();
2748
- if (resolved.kind === "pty") {
2749
- routing.pty = { label: resolved.label };
2750
- routing.mode = "pty";
2751
- routing.display = `byop:${resolved.label}`;
2752
- } else routing.plan = resolved;
2753
- }
2754
- if (routing.pty !== void 0) return await this.ptyCall(routing.pty, params, sessionKey, timeoutSec, cap);
2755
- if (routing.mode === "oneshot") {
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;
2772
- return {
2773
- mode: "oneshot",
2774
- target: routing.display,
2775
- exit: r$1.exit,
2776
- cwd: null,
2777
- timedOut: r$1.timedOut,
2778
- reconnected: false,
2779
- stdout: out$1.text,
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
- } } : {},
2785
- durationMs: r$1.durationMs,
2786
- ...out$1.truncated !== void 0 ? { truncated: out$1.truncated } : {}
2787
- };
3784
+ /** job 通道收养回填(timeout handoff 时 registry id ≠ commandId,账本记录两者)。 */
3785
+ attachPtyJob(commandId, jobId, sessionKey) {
3786
+ for (const { key } of this.pool.list()) {
3787
+ if (!key.startsWith(`${sessionKey ?? "no-session"}|`)) continue;
3788
+ this.pool.get(key)?.attachJob(commandId, jobId);
2788
3789
  }
2789
- const argv = (this.opts.ptyArgvFor ?? buildPtyArgv)(routing.plan);
2790
- const key = `${sessionKey}|t:${routing.display}`;
2791
- const r = await this.pool.getOrCreate(key, argv).dispatch(params.cmd, {
3790
+ }
3791
+ async dispatchPty(session, params, callCtx, timeoutSec, cap) {
3792
+ const commandId = callCtx.commandId ?? genCommandId();
3793
+ const r = await session.dispatch(params.cmd, {
2792
3794
  stdin: params.stdin,
2793
- timeoutSec
3795
+ timeoutSec,
3796
+ commandId,
3797
+ ...callCtx.ptyBackground === true ? { background: true } : {}
2794
3798
  });
2795
3799
  const out = tailWindow(r.output, cap, r.outputStats?.stdout);
3800
+ const id = session.identity;
2796
3801
  return {
2797
3802
  mode: "pty",
2798
- target: routing.display,
3803
+ target: `pty:${id.id}`,
3804
+ session: `pty:${id.id}`,
3805
+ ...id.label !== void 0 ? { label: id.label } : {},
3806
+ connection: id.connection,
3807
+ ...id.byo ? { byoTag: "byo-pty" } : {},
3808
+ commandId: r.commandId,
2799
3809
  exit: r.exit,
2800
3810
  cwd: r.cwd,
2801
3811
  timedOut: r.timedOut,
@@ -2803,42 +3813,210 @@ var RemoteDriver = class {
2803
3813
  stdout: out.text,
2804
3814
  outputStats: out.stats ? { stdout: out.stats } : void 0,
2805
3815
  durationMs: r.durationMs,
2806
- ...out.truncated !== void 0 ? { truncated: out.truncated } : {}
3816
+ ...out.truncated !== void 0 ? { truncated: out.truncated } : {},
3817
+ ...r.envDelta !== void 0 ? { envDelta: r.envDelta } : {},
3818
+ ...r.running === true && r.job !== void 0 ? {
3819
+ running: true,
3820
+ ptyJob: r.job
3821
+ } : {},
3822
+ ...this.ptyTtl(session.key)
2807
3823
  };
2808
3824
  }
2809
- /** BYO-PTY 通道:label 即池键(agent 前缀之外),spawn 只在 label 尚不存在时被接受。
2810
- * 三分支 —— 建(无会话)/ 复用(无 spawn,或有 spawn 且与创建来源相同)/ **冲突(有 spawn 且来源不同:不执行任何命令)**。 */
2811
- async ptyCall(pty, params, sessionKey, timeoutSec, cap) {
2812
- const display = `byop:${pty.label}`;
2813
- const key = `${sessionKey}|p:${pty.label}`;
2814
- const existing = this.pool.get(key);
2815
- let session = existing;
2816
- let notice;
2817
- if (existing !== void 0 && pty.spawn !== void 0 && existing.origin !== pty.spawn) throw new Error(`[E_LABEL_CONFLICT] remote: ${display} already exists and was created by a different command — nothing was run.\n existing spawn: ${JSON.stringify(existing.origin)}\n given spawn: ${JSON.stringify(pty.spawn)}\n To reuse it: { target: "${display}", cmd: "…" } (drop spawn — spawn only ever creates)\n To start new: pick another label, e.g. { target: "byop:${pty.label}2", spawn: ${JSON.stringify(pty.spawn)}, cmd: "…" }`);
2818
- if (session === void 0) {
2819
- if (pty.spawn === void 0) throw new Error(`[E_NO_SESSION] remote: no live PTY under ${display} — one exists only after a call that supplies spawn, and it is reaped after the idle TTL (600s).\n Create it: { target: "${display}", spawn: "…", cmd: "…" } (the spawn command is not retained; the label is yours to choose)`);
2820
- session = this.pool.getOrCreate(key, (this.opts.spawnArgvFor ?? buildSpawnArgv)(pty.spawn), pty.spawn);
2821
- } else if (pty.spawn !== void 0) notice = `[remote: ${display} already runs that exact command and is persistent — reuse it with { target: "${display}", cmd: "…" }; spawn is only needed to create.]`;
2822
- const r = await session.dispatch(params.cmd, {
2823
- stdin: params.stdin,
2824
- timeoutSec
2825
- });
2826
- const out = tailWindow(r.output, cap, r.outputStats?.stdout);
3825
+ /** 预热(创建形无 cmd)回执:§3——无 commandId/envDelta;timeout 忽略。 */
3826
+ warmReceipt(session) {
3827
+ const id = session.identity;
2827
3828
  return {
2828
3829
  mode: "pty",
3830
+ target: `pty:${id.id}`,
3831
+ session: `pty:${id.id}`,
3832
+ ...id.label !== void 0 ? { label: id.label } : {},
3833
+ connection: id.connection,
3834
+ ...id.byo ? { byoTag: "byo-pty" } : {},
3835
+ created: true,
3836
+ exit: null,
3837
+ cwd: null,
3838
+ timedOut: false,
3839
+ reconnected: false,
3840
+ stdout: "",
3841
+ durationMs: 0,
3842
+ ...this.ptyTtl(session.key)
3843
+ };
3844
+ }
3845
+ /** session card 回执(`pty:<id|label>` 无 cmd):connection/state/cwd + 最近 20 条账本(不带输出尾,§9 Q7)。 */
3846
+ cardReceipt(session) {
3847
+ const id = session.identity;
3848
+ const snap = session.snapshot;
3849
+ const state = snap.busy ? "busy (a command is running)" : snap.state === "dead" ? "died — next dispatch reconnects a fresh shell" : `idle ${Math.round((snap.idleMs ?? 0) / 1e3)}s (connected)`;
3850
+ const head = `pty:${id.id}${id.label !== void 0 ? ` (label ${id.label})` : ""} — ${id.connection}${id.byo ? " (byo-pty)" : id.builtOn ? " (built on)" : ""} · ${state} · cwd ${session.lastCwd ?? "(none yet)"}`;
3851
+ return {
3852
+ mode: "pty",
3853
+ target: `pty:${id.id}`,
3854
+ session: `pty:${id.id}`,
3855
+ ...id.label !== void 0 ? { label: id.label } : {},
3856
+ connection: id.connection,
3857
+ ...id.byo ? { byoTag: "byo-pty" } : {},
3858
+ exit: null,
3859
+ cwd: session.lastCwd,
3860
+ timedOut: false,
3861
+ reconnected: false,
3862
+ stdout: [head, renderLedger(session.ledgerSnapshot())].join("\n"),
3863
+ durationMs: 0,
3864
+ ...this.ptyTtl(session.key)
3865
+ };
3866
+ }
3867
+ async oneshot(plan, display, params, timeoutSec, cap) {
3868
+ this.assertOpen();
3869
+ const process$1 = this.own(await this.nativeProcess(plan, params, timeoutSec));
3870
+ if (this.disposed) process$1.cancel("owner disposed");
3871
+ const result = await process$1.done;
3872
+ const stdout = process$1.read("stdout", 0), stderr = process$1.read("stderr", 0);
3873
+ const r = {
3874
+ ...result,
3875
+ stdout: stdout.text,
3876
+ stderr: [stderr.text, stderr.diagnostic].filter(Boolean).join("\n"),
3877
+ outputStats: {
3878
+ stdout: stdout.stats,
3879
+ stderr: stderr.stats
3880
+ }
3881
+ };
3882
+ const out = tailWindow(r.stdout, cap, r.outputStats?.stdout);
3883
+ const err = r.stderr.length > 0 ? tailWindow(r.stderr, cap, r.outputStats?.stderr) : void 0;
3884
+ return {
3885
+ mode: "oneshot",
2829
3886
  target: display,
2830
- session: display,
2831
3887
  exit: r.exit,
2832
- cwd: r.cwd,
3888
+ cwd: null,
2833
3889
  timedOut: r.timedOut,
2834
- reconnected: r.reconnected,
3890
+ reconnected: false,
2835
3891
  stdout: out.text,
2836
- ...notice !== void 0 ? { notice } : {},
2837
- outputStats: out.stats ? { stdout: out.stats } : void 0,
3892
+ ...err !== void 0 ? { stderr: err.text } : {},
3893
+ ...out.stats ? { outputStats: {
3894
+ stdout: out.stats,
3895
+ stderr: err?.stats ?? r.outputStats?.stderr
3896
+ } } : {},
2838
3897
  durationMs: r.durationMs,
2839
- ...out.truncated !== void 0 ? { truncated: out.truncated } : {}
3898
+ ...out.truncated !== void 0 ? { truncated: out.truncated } : {},
3899
+ ...this.runnerOf.get(process$1) === void 0 ? {} : { runner: this.runnerOf.get(process$1) }
3900
+ };
3901
+ }
3902
+ async hubOneshot(machine, display, params, timeoutSec, cap) {
3903
+ const process$1 = this.own(await this.hubOneshotProcess(machine, params));
3904
+ if (this.disposed) process$1.cancel("owner disposed");
3905
+ const result = await process$1.done;
3906
+ const stdout = process$1.read("stdout", 0), stderr = process$1.read("stderr", 0);
3907
+ const out = tailWindow(stdout.text, cap, stdout.stats);
3908
+ const err = tailWindow(stderr.text, cap, stderr.stats);
3909
+ return {
3910
+ ...result,
3911
+ mode: "oneshot",
3912
+ target: display,
3913
+ connection: display,
3914
+ cwd: null,
3915
+ reconnected: false,
3916
+ stdout: (!stdout.stats && stdout.lossy ? "[truncated: peer returned only a tail; exact total/retained/dropped unavailable]\n" : "") + out.text,
3917
+ stderr: [err.text, stderr.diagnostic].filter(Boolean).join("\n"),
3918
+ ...out.stats ? { outputStats: {
3919
+ stdout: out.stats,
3920
+ stderr: err.stats
3921
+ } } : { outputStatsUnavailable: true },
3922
+ ...out.truncated === void 0 ? {} : { truncated: out.truncated }
2840
3923
  };
2841
3924
  }
3925
+ async hubOneshotProcess(machine, params) {
3926
+ const hub = this.requireHub();
3927
+ this.hubProviders.add(hub);
3928
+ return await hub.start(machine, {
3929
+ ...params,
3930
+ target: `hub:${machine}`,
3931
+ mode: "oneshot"
3932
+ }, this.hubOwner);
3933
+ }
3934
+ /** Ruling P19 + §2.5:roster = 注意力入口——本地扫描刷新统一池,零拨号;机器面公共,会话面 caller-scoped。 */
3935
+ async roster(callCtx = {}) {
3936
+ this.assertOpen();
3937
+ const scan = await scanLocal({
3938
+ runner: this.opts.rosterRunner,
3939
+ readSshConfig: this.opts.sshConfigReader
3940
+ });
3941
+ this.assertOpen();
3942
+ this.labels.mergeScan(callCtx.sessionKey ?? "no-session", {
3943
+ sshHosts: scan.ssh.hosts,
3944
+ docker: scan.docker.rows.map((r) => r.name),
3945
+ incus: scan.incus.rows.map((r) => r.name),
3946
+ hub: this.opts.getHub?.()?.listTargets().map((t) => t.id) ?? []
3947
+ });
3948
+ const prefix = `${callCtx.sessionKey ?? "no-session"}|`;
3949
+ const sessions = this.pool.list().filter(({ key }) => key.startsWith(prefix)).map(({ key }) => {
3950
+ const s = this.pool.get(key);
3951
+ return s === void 0 ? null : s;
3952
+ }).filter((s) => s !== null);
3953
+ const hub = this.opts.getHub?.();
3954
+ return renderRoster(scan, this.labels.list(callCtx.sessionKey ?? "no-session"), sessions, hub === void 0 ? null : hub.listTargets());
3955
+ }
3956
+ /** status probe(target 无 cmd):机器面 = probe + 该 connection 上的会话;pty selector = session card。 */
3957
+ async status(target, callCtx = {}) {
3958
+ this.assertOpen();
3959
+ const sessionKey = callCtx.sessionKey ?? "no-session";
3960
+ const form = parseTargetForm(target);
3961
+ if (form.form === "hub" || form.form === "hub-pty") {
3962
+ const row = this.requireHub().listTargets().find((t) => t.id === form.machine);
3963
+ if (!row) throw new Error(`unknown remote machine ${form.machine} — select a remote machine from the Hub roster; local execution is not available`);
3964
+ return `hub:${form.machine} — Hub remote machine (${row.label}); exec ${row.execEnabled ? "enabled" : "disabled"}; not probed`;
3965
+ }
3966
+ if (form.form === "pty-selector") {
3967
+ if (form.idOrLabel === void 0) throw new Error("[E_PARAMS] remote: \"pty\" alone selects nothing — append an id/label (pty:pty-1a2b, pty:build)");
3968
+ return this.cardReceipt(this.resolvePty(form.idOrLabel, sessionKey)).stdout;
3969
+ }
3970
+ if (form.form !== "direct" && form.form !== "bare") throw new Error("[E_PARAMS] remote: status takes a machine target (bare name or ssh:/docker:/incus:/hub:) or a pty:<id|label> selector");
3971
+ const name$1 = directName(form) || (form.form === "bare" ? form.name : "");
3972
+ const entry = await this.resolvePoolEntry(name$1, sessionKey);
3973
+ this.assertOpen();
3974
+ if (entry.kind === "pty") {
3975
+ if (form.form !== "bare") throw new Error("[E_TARGET_FORMAT] remote: ssh:/docker:/incus: force a machine channel — the pty selector is pty:<id|label>");
3976
+ return this.cardReceipt(this.resolvePty(form.name, sessionKey)).stdout;
3977
+ }
3978
+ if (entry.kind === "hub") return this.status(`hub:${entry.machine}`, callCtx);
3979
+ const plan = this.planOf(entry);
3980
+ const head = `${target} — ${plan.kind === "ssh" ? "ssh host" : `${plan.kind} container`}${entry.kind === "ssh-byo" ? " (built on)" : ""}`;
3981
+ const runner = this.opts.probeRunner ?? runOneShot;
3982
+ const probe = await probeTarget(plan, { runner: (argv, options) => {
3983
+ this.assertOpen();
3984
+ return runner(argv, options);
3985
+ } });
3986
+ const prefix = `${sessionKey}|`;
3987
+ const onConnection = this.pool.list().filter(({ key }) => key.startsWith(prefix)).map(({ key }) => this.pool.get(key)).filter((s) => s !== void 0 && s.identity.connection === `${plan.kind === "ssh" ? "ssh" : plan.kind}:${name$1}`);
3988
+ return [
3989
+ head,
3990
+ renderProbe(probe),
3991
+ onConnection.length > 0 ? `pty sessions on this connection: ${onConnection.map(sessionLine).join(" · ")}` : "pty sessions on this connection: none"
3992
+ ].join("\n");
3993
+ }
3994
+ /** Ruling 13:审计 best-effort——钩子抛错不得影响命令路径。 */
3995
+ audit(r) {
3996
+ try {
3997
+ this.opts.onAudit?.(r);
3998
+ } catch {}
3999
+ }
4000
+ /** Single target resolver for later copy/rsync; PTY sessions are not filesystem connections. */
4001
+ async resolveConnectionTarget(target, callCtx = {}) {
4002
+ this.assertOpen();
4003
+ const form = parseTargetForm(target);
4004
+ if (form.form === "pty-selector") throw new Error("remote: a PTY session is not a filesystem connection");
4005
+ if (form.form === "hub" || form.form === "hub-pty") throw new Error("remote: hub endpoints resolve through the Hub file lane, not a CLI connection");
4006
+ if (form.form === "create-pty") throw new Error("remote: a PTY session is not a filesystem connection");
4007
+ if (form.form === "direct") return form.transport === "ssh" ? this.sshPlanFor(form.name, callCtx.sessionKey ?? "no-session") : resolveTarget(`${form.transport}:${form.name}`);
4008
+ if (form.form === "ssh-prefix" || form.form === "empty") throw new Error("[E_TARGET_FORMAT] remote: connection targets need a name");
4009
+ const entry = await this.resolvePoolEntry(form.name, callCtx.sessionKey ?? "no-session");
4010
+ this.assertOpen();
4011
+ return this.planOf(entry);
4012
+ }
4013
+ /** P2/P3:回执即状态快照——idle TTL 剩余随每次 pty 回执带回。 */
4014
+ ptyTtl(key) {
4015
+ const snap = this.pool.inspect(key);
4016
+ if (snap === void 0 || snap.state === "dead" || snap.state === "disposed") return {};
4017
+ if (snap.idleMs === null) return { ttlSec: this.ptyIdleTtlSec };
4018
+ return { ttlSec: Math.max(0, Math.round(this.ptyIdleTtlSec - snap.idleMs / 1e3)) };
4019
+ }
2842
4020
  hasHub() {
2843
4021
  return this.opts.getHub?.() !== void 0;
2844
4022
  }
@@ -2853,8 +4031,8 @@ var RemoteDriver = class {
2853
4031
  }
2854
4032
  dispose() {
2855
4033
  if (this.disposal) return this.disposal;
2856
- this.sshTargets.clear();
2857
4034
  this.disposed = true;
4035
+ this.labels.clear();
2858
4036
  this.fileAbort.abort(/* @__PURE__ */ new Error("remote owner disposed"));
2859
4037
  for (const process$1 of this.processes) process$1.cancel("remote owner disposed");
2860
4038
  return this.disposal = (async () => {
@@ -2868,13 +4046,83 @@ var RemoteDriver = class {
2868
4046
  })();
2869
4047
  }
2870
4048
  };
4049
+ /** direct 形态回退到 resolveTarget 可解析的名字(ssh:dev4 → dev4)。 */
4050
+ function directName(form) {
4051
+ return form.form === "direct" ? form.name : form.form === "create-pty" ? form.name : "";
4052
+ }
4053
+ /** roster/status 一行的会话描述:`p-ab12 (label build) ssh:dev4 idle 12s`。 */
4054
+ function sessionLine(s) {
4055
+ const id = s.identity;
4056
+ const snap = s.snapshot;
4057
+ const state = snap.busy ? "busy" : snap.state === "dead" ? "died" : `idle ${Math.round((snap.idleMs ?? 0) / 1e3)}s`;
4058
+ return `${id.id}${id.label !== void 0 ? ` (label ${id.label})` : ""} ${id.connection}${id.byo ? " (byo-pty)" : ""} ${state}`;
4059
+ }
2871
4060
 
2872
4061
  //#endregion
2873
4062
  //#region src/remote/jobs.ts
4063
+ /** PTY job 的单通道输出:流在 PTY 里天然合并,无独立 stderr 通道(P5 如实披露)。
4064
+ * read 是 byte-cursor 增量(Utf16TailRing.read 语义:逐字节游标 + lossy 报尾环逐出)。 */
4065
+ const ptyOutputSources = (holder) => [{
4066
+ channel: "stdout",
4067
+ read: (fromByte) => holder.job?.read(fromByte) ?? {
4068
+ text: "",
4069
+ nextOffset: fromByte,
4070
+ lossy: false
4071
+ }
4072
+ }];
4073
+ const ptyOutcome = (aborted, result) => ({
4074
+ status: aborted ? "killed" : result.exit === null ? "failed" : "completed",
4075
+ detail: aborted ? `interrupted: ${result.exit === null ? "session died" : `exit ${result.exit}`}` : result.exit === null ? "session died" : `exit code: ${result.exit}`
4076
+ });
2874
4077
  /** No preparation or discovery before the official registry's admission. */
2875
4078
  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
4079
  if (!exec.agent) throw new Error("remote: background jobs require a live Agent owner");
4080
+ const form = parseTargetForm(input.target);
4081
+ if (form.form === "pty-selector" || form.form === "create-pty" || form.form === "hub-pty" || input.spawn !== void 0 || input.mode === "pty") {
4082
+ if (form.form === "hub-pty") throw new Error("remote: hub-machine PTY has no background lane; use a direct ssh/docker/incus target or a pty session");
4083
+ const holder = {};
4084
+ return registry.start({
4085
+ kind: "remote",
4086
+ owner: exec.agent.id,
4087
+ label: `${input.target}: ${input.cmd}`,
4088
+ output: ptyOutputSources(holder),
4089
+ run(job) {
4090
+ const controller = new AbortController();
4091
+ return {
4092
+ done: (async () => {
4093
+ try {
4094
+ const r = await driver.call({
4095
+ ...input,
4096
+ mode: "pty"
4097
+ }, {
4098
+ ptyBackground: true,
4099
+ commandId: job.id,
4100
+ sessionKey: exec.agent?.id ?? "no-session"
4101
+ });
4102
+ if (r.running !== true || r.ptyJob === void 0) return {
4103
+ status: "failed",
4104
+ detail: "pty dispatch did not hand off a running job"
4105
+ };
4106
+ holder.job = r.ptyJob;
4107
+ if (controller.signal.aborted) r.ptyJob.interrupt();
4108
+ const outcome = await r.ptyJob.done;
4109
+ return ptyOutcome(controller.signal.aborted, outcome);
4110
+ } catch (error) {
4111
+ return {
4112
+ status: controller.signal.aborted ? "killed" : "failed",
4113
+ detail: error instanceof Error ? error.message : String(error)
4114
+ };
4115
+ }
4116
+ })(),
4117
+ cancel(reason) {
4118
+ if (controller.signal.aborted) return;
4119
+ controller.abort(reason);
4120
+ holder.job?.interrupt();
4121
+ }
4122
+ };
4123
+ }
4124
+ });
4125
+ }
2878
4126
  let process$1;
2879
4127
  const output = ["stdout", "stderr"].map((channel) => ({
2880
4128
  channel,
@@ -2894,7 +4142,7 @@ function startRemoteJob(registry, input, exec, driver) {
2894
4142
  return registry.start({
2895
4143
  kind: "remote",
2896
4144
  owner: exec.agent.id,
2897
- label: `${input.target}: ${input.cmd ?? input.script}`,
4145
+ label: `${input.target}: ${input.cmd}`,
2898
4146
  output,
2899
4147
  run() {
2900
4148
  const controller = new AbortController();
@@ -2904,7 +4152,7 @@ function startRemoteJob(registry, input, exec, driver) {
2904
4152
  process$1 = await driver.start({
2905
4153
  ...input,
2906
4154
  mode: "oneshot"
2907
- }, controller.signal);
4155
+ }, controller.signal, { sessionKey: exec.agent?.id ?? "no-session" });
2908
4156
  if (controller.signal.aborted) process$1.cancel(controller.signal.reason);
2909
4157
  const result = await process$1.done;
2910
4158
  const diagnostic = process$1.read("stderr", 0).diagnostic;
@@ -2928,29 +4176,260 @@ function startRemoteJob(registry, input, exec, driver) {
2928
4176
  }
2929
4177
  });
2930
4178
  }
4179
+ /** P4(2026-10-19)handoff 收养:同步 pty dispatch 超时后命令仍在跑——注册成 job,
4180
+ * job_output 即增量轮询、job_kill 即显式中断(P8 ^C+130-marker,会话存活)。 */
4181
+ function adoptRunningPtyJob(registry, exec, label, job) {
4182
+ if (!exec.agent) throw new Error("remote: background jobs require a live Agent owner");
4183
+ return registry.start({
4184
+ kind: "remote",
4185
+ owner: exec.agent.id,
4186
+ label,
4187
+ output: ptyOutputSources({ job }),
4188
+ run() {
4189
+ const controller = new AbortController();
4190
+ return {
4191
+ done: (async () => {
4192
+ if (controller.signal.aborted) job.interrupt();
4193
+ const outcome = await job.done;
4194
+ return ptyOutcome(controller.signal.aborted, outcome);
4195
+ })(),
4196
+ cancel(reason) {
4197
+ if (controller.signal.aborted) return;
4198
+ controller.abort(reason);
4199
+ job.interrupt();
4200
+ }
4201
+ };
4202
+ }
4203
+ });
4204
+ }
2931
4205
 
2932
4206
  //#endregion
2933
4207
  //#region src/remote/tool.ts
2934
4208
  const NO_SESSION_KEY = "remote:no-session";
2935
4209
  /**
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.
4210
+ * The model-visible surface of the `remote` tool (workbench round, plan
4211
+ * 2026-10-19-pty-workbench). ONE template literal — no backticks, no "${".
2939
4212
  */
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.
4213
+ const REMOTE_TOOL_DESCRIPTION = `Remote execution and file transfer over ssh / docker / incus, plus stateful PTY workbenches. Pick the call shape:
4214
+ {} → roster (local scan; refreshes the name pool: ssh aliases, BYO identities, docker/incus containers, hub machines, live pty sessions)
4215
+ {target} → status probe: machine reachability + its pty sessions, or a session card (connection, cwd, ledger of the last 20 commands) for pty:<id|label>
4216
+ {target, cmd} → run cmd — oneshot (stateless bash -lc) on any machine target
4217
+ {target, scriptPath:"/abs"} → LOCAL script over stdin to a fixed bash -s (oneshot only; no cmd/stdin/spawn mix)
4218
+ {target:"ssh:dev4:pty[:label]", cmd?} → create a PTY session on that machine and run cmd in it; no cmd = warm creation returning the id. Tab semantics: every creation call opens a NEW session — parallel is first-class (max 10 per Agent, ssh:dev4 and docker:corti alike).
4219
+ {target:"pty:<id|label>", cmd} → run cmd in a live session (cwd/env survive; the receipt carries session, connection, commandId, cwd, env delta, idle-left)
4220
+ {spawn, target?:"pty[:<label>]"|"<label>"|"", cmd?} → create a BYO pty from a spawn command (e.g. docker exec -it img bash; nested hops are dumb pipes). spawn present IS the BYO entry point; no target = auto id.
4221
+ {ssh:{...}, target?:"ssh[:<label>]"|"<label>"|"", cmd?} → register a BYO ssh identity (ssh present IS the entry point); the label then works everywhere ssh: aliases do (roster marks it "built on"); ssh:<label>:pty opens a session on it. key is an existing origin file, never inline material; nothing is persisted; a new Agent registers again.
4222
+ {action:"close", target:"pty:<id|label>", force?} → close a session. Idempotent (unknown id → closed:false). Default rejects while a job is running and names it (job_kill it first, or wait); force:true closes the tab and kills the group in one step.
4223
+ {action:"copy"|"rsync", src, dst, argv?} → file transfer (see the action/src/dst fields)
4224
+ Bare names address ONE unified pool (machines + identities + sessions — roster lists it; a machine losing its bare name to an earlier claim is shown prefixed docker-foo / incus-foo). Prefixes ssh: / docker: / incus: / hub: force a channel. Creation always claims a fresh name: a taken label answers [E_LABEL_IN_USE] — address it without creation params, or pick another (specs are never compared). PTY sessions idle out after 15 min (running commands exempt; reads do not reset the clock).
2948
4225
 
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.
4226
+ oneshot: stdout/stderr separate, exit code verbatim. Supervision prefers a pinned superd-run helper cached on the endpoint (~/.cache/superd/run; first call uploads it over the same exec lane) and falls back to a POSIX wrapper everywhere else — the receipt field "runner" says which ran. Helper or wrapper, backgrounded children are reaped on completion and the whole group is killed on timeout/cancel; the endpoint needs bash only (no Python).
2950
4227
 
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.
4228
+ PTY is the stateful superset of oneshot: every command returns synchronously with exit + cwd + an env delta (NAMES only — added/changed/removed; values never leave the endpoint; fetch a value yourself if you need it). A command that outlives its timeout is NOT killed — it hands off as a running job: poll job_output <commandId>, cancel with job_kill (Ctrl-C first; the session survives). run_in_background uses the same lane and its jobId IS the commandId. A busy session rejects new commands ([E_SESSION_BUSY] names the running id and the ways out: job_output / job_kill / another session or oneshot).
2952
4229
 
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.`;
4230
+ 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 (pty: REPL-style right after the command line — only helps commands that read stdin there). timeout (seconds, default 120): oneshot interrupts then kills the whole group; pty hands off. Native transport errors pass through verbatim — self-correct from them.`;
4231
+ async function remoteExecute(ENV, args, exec) {
4232
+ if (args.action === "copy" || args.action === "rsync") {
4233
+ if (!args.src || !args.dst) throw new Error(`${args.action} requires src and dst absolute endpoints`);
4234
+ if ([
4235
+ args.cmd,
4236
+ args.scriptPath,
4237
+ args.stdin,
4238
+ args.spawn,
4239
+ args.ssh,
4240
+ args.target,
4241
+ args.timeout,
4242
+ args.run_in_background,
4243
+ args.force
4244
+ ].some((value) => value !== void 0)) throw new Error(`${args.action} cannot be combined with execution/PTY/job arguments`);
4245
+ const KNOWN_PARAMS = new Set([
4246
+ "action",
4247
+ "force",
4248
+ "src",
4249
+ "dst",
4250
+ "argv",
4251
+ "pullArgv",
4252
+ "pushArgv",
4253
+ "verify",
4254
+ "mkdir",
4255
+ "run_in_background",
4256
+ "target",
4257
+ "ssh",
4258
+ "spawn",
4259
+ "cmd",
4260
+ "scriptPath",
4261
+ "stdin",
4262
+ "timeout"
4263
+ ]);
4264
+ for (const key of Object.keys(args)) if (!KNOWN_PARAMS.has(key)) throw new Error(`remote: unknown parameter "${key}" — this surface has no legacy aliases; pass native CLI options through argv instead`);
4265
+ if (args.action === "rsync") {
4266
+ if (args.pullArgv !== void 0 || args.pushArgv !== void 0) throw new Error("rsync takes argv only; pullArgv/pushArgv are copy-only fields");
4267
+ 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");
4268
+ const start = Date.now();
4269
+ const receipt$1 = await ENV.driver.rsync({
4270
+ src: args.src,
4271
+ dst: args.dst,
4272
+ ...args.argv === void 0 ? {} : { argv: args.argv }
4273
+ }, exec.signal, { sessionKey: ENV.getSessionKey(exec) });
4274
+ if (receipt$1.ok === false || receipt$1.exit !== 0) throw new Error(JSON.stringify(receipt$1));
4275
+ const rsyncDuration = Date.now() - start;
4276
+ return {
4277
+ kind: "rsync",
4278
+ text: formatTransferLine("rsync", receipt$1, rsyncDuration),
4279
+ receipt: receipt$1,
4280
+ exit: receipt$1.exit,
4281
+ durationMs: rsyncDuration
4282
+ };
4283
+ }
4284
+ const copyStart = Date.now();
4285
+ const receipt = await ENV.driver.copy({
4286
+ src: args.src,
4287
+ dst: args.dst,
4288
+ ...args.verify === void 0 ? {} : { verify: args.verify },
4289
+ ...args.mkdir === void 0 ? {} : { mkdir: args.mkdir },
4290
+ ...args.argv === void 0 ? {} : { argv: args.argv },
4291
+ ...args.pullArgv === void 0 ? {} : { pullArgv: args.pullArgv },
4292
+ ...args.pushArgv === void 0 ? {} : { pushArgv: args.pushArgv }
4293
+ }, exec.signal, { sessionKey: ENV.getSessionKey(exec) });
4294
+ const asReceipt = receipt;
4295
+ const exits = asReceipt.exits ?? [];
4296
+ if (asReceipt.ok === false || exits.some((code) => code !== 0)) throw new Error(JSON.stringify(receipt));
4297
+ const copyDuration = Date.now() - copyStart;
4298
+ return {
4299
+ kind: "copy",
4300
+ text: formatTransferLine("copy", receipt, copyDuration),
4301
+ receipt,
4302
+ exit: exits.length > 0 ? 0 : null,
4303
+ durationMs: copyDuration
4304
+ };
4305
+ }
4306
+ if (args.action === "close") {
4307
+ if (typeof args.target !== "string" || args.target.length === 0) throw new Error("close requires target pty:<id|label>");
4308
+ if ([
4309
+ args.cmd,
4310
+ args.scriptPath,
4311
+ args.stdin,
4312
+ args.spawn,
4313
+ args.ssh,
4314
+ args.timeout,
4315
+ args.run_in_background
4316
+ ].some((value) => value !== void 0)) throw new Error("[E_PARAMS] close cannot be combined with execution/PTY/job arguments");
4317
+ const r$1 = ENV.driver.closePty(args.target.startsWith("pty:") ? args.target.slice(4) : args.target, ENV.getSessionKey(exec), args.force === true);
4318
+ return {
4319
+ kind: "close",
4320
+ text: r$1.closed ? `closed ${r$1.session} (${r$1.connection})${r$1.killed.length > 0 ? ` — killed running job ${r$1.killed.join(", ")}` : ""}` : `${r$1.session}: not closed (${r$1.reason}) — ${r$1.message ?? "no live session"}`,
4321
+ exit: null,
4322
+ durationMs: 0,
4323
+ session: r$1.session,
4324
+ closed: r$1.closed,
4325
+ ...r$1.reason !== void 0 ? { reason: r$1.reason } : {},
4326
+ killed: r$1.killed
4327
+ };
4328
+ }
4329
+ if ([
4330
+ args.src,
4331
+ args.dst,
4332
+ args.argv,
4333
+ args.pullArgv,
4334
+ args.pushArgv,
4335
+ args.verify,
4336
+ args.mkdir,
4337
+ args.force
4338
+ ].some((value) => value !== void 0)) throw new Error("file/force fields require an action (copy / rsync / close)");
4339
+ const hasCmd = typeof args.cmd === "string" && args.cmd.length > 0;
4340
+ const hasTarget = typeof args.target === "string" && args.target.length > 0;
4341
+ const hasSpawn = typeof args.spawn === "string" && args.spawn.length > 0;
4342
+ if (args.spawn !== void 0 && !hasSpawn) throw new Error("[E_PARAMS] remote: spawn must be a non-empty command string");
4343
+ if (args.scriptPath !== void 0 && (hasSpawn || /^pty:/.test(args.target ?? "") || /:pty/.test(args.target ?? ""))) throw new Error("[E_SCRIPT_PARAMS] remote: scriptPath is oneshot-only — PTY lanes take cmd");
4344
+ if (args.run_in_background) {
4345
+ if (!hasCmd && args.scriptPath === void 0) throw new Error("remote: background requires cmd or scriptPath");
4346
+ const jobs = exec.agent?.ctx.get("jobs");
4347
+ if (!jobs) throw new Error("remote: background requires the official DSH jobs service and job_output/job_kill controller; foreground execution remains available");
4348
+ const lane = await ENV.driver.preparePtyLane({
4349
+ target: args.target,
4350
+ spawn: args.spawn,
4351
+ cmd: args.cmd,
4352
+ scriptPath: args.scriptPath,
4353
+ ssh: args.ssh,
4354
+ stdin: args.stdin,
4355
+ timeout: args.timeout
4356
+ }, { sessionKey: ENV.getSessionKey(exec) });
4357
+ let jobId;
4358
+ try {
4359
+ jobId = startRemoteJob(jobs, lane !== void 0 ? lane.input : {
4360
+ target: args.target,
4361
+ spawn: args.spawn,
4362
+ cmd: args.cmd,
4363
+ scriptPath: args.scriptPath,
4364
+ ssh: args.ssh,
4365
+ stdin: args.stdin,
4366
+ timeout: args.timeout
4367
+ }, exec, ENV.driver);
4368
+ } catch (error) {
4369
+ lane?.cleanup();
4370
+ throw error;
4371
+ }
4372
+ const where = lane === void 0 ? "" : ` on ${lane.session}${lane.label !== void 0 ? ` (${lane.label})` : ""} (${lane.connection})`;
4373
+ return {
4374
+ kind: "job",
4375
+ jobId,
4376
+ exit: null,
4377
+ durationMs: 0,
4378
+ text: `Started ${jobId}${where}. Use job_output to read and job_kill to cancel; completion is reported by the host.`,
4379
+ ...lane !== void 0 ? {
4380
+ session: lane.session,
4381
+ connection: lane.connection,
4382
+ ...lane.label !== void 0 ? { label: lane.label } : {}
4383
+ } : {}
4384
+ };
4385
+ }
4386
+ if (!hasCmd && args.scriptPath === void 0) {
4387
+ if (args.stdin !== void 0) throw new Error("[E_STDIN_WITHOUT_CMD] remote: stdin requires cmd — status probes take no input");
4388
+ const t = args.target ?? "";
4389
+ if (hasSpawn || args.ssh !== void 0 || /^pty:/.test(t) || /:pty(:|$)/.test(t) && !t.startsWith("hub:")) return workbenchValue(await ENV.driver.call({
4390
+ target: args.target,
4391
+ spawn: args.spawn,
4392
+ ssh: args.ssh,
4393
+ cmd: "",
4394
+ stdin: void 0
4395
+ }, { sessionKey: ENV.getSessionKey(exec) }));
4396
+ if (!hasTarget) return {
4397
+ kind: "roster",
4398
+ text: await ENV.driver.roster({ sessionKey: ENV.getSessionKey(exec) }),
4399
+ exit: null,
4400
+ durationMs: 0
4401
+ };
4402
+ return {
4403
+ kind: "status",
4404
+ text: await ENV.driver.status(args.target, { sessionKey: ENV.getSessionKey(exec) }),
4405
+ exit: null,
4406
+ durationMs: 0
4407
+ };
4408
+ }
4409
+ const r = await ENV.driver.call({
4410
+ target: args.target,
4411
+ spawn: args.spawn,
4412
+ ssh: args.ssh,
4413
+ cmd: args.cmd,
4414
+ scriptPath: args.scriptPath,
4415
+ stdin: args.stdin,
4416
+ timeout: args.timeout
4417
+ }, { sessionKey: ENV.getSessionKey(exec) });
4418
+ const v = workbenchValue(r);
4419
+ if (r.running === true && r.ptyJob !== void 0) {
4420
+ const jobs = exec.agent?.ctx.get("jobs");
4421
+ if (jobs) {
4422
+ const jobId = adoptRunningPtyJob(jobs, exec, `${r.target}: ${args.cmd}`, r.ptyJob);
4423
+ v.jobId = jobId;
4424
+ v.running = true;
4425
+ if (r.commandId !== void 0 && jobId !== r.commandId) ENV.driver.attachPtyJob(r.commandId, jobId, ENV.getSessionKey(exec));
4426
+ } else {
4427
+ r.ptyJob.interrupt();
4428
+ v.notice = "The command outlived its synchronous wait and no jobs service is available to poll it, so it was interrupted. Use run_in_background:true for long commands.";
4429
+ }
4430
+ }
4431
+ return v;
4432
+ }
2954
4433
  function createRemoteTool(driver, opts = {}) {
2955
4434
  const getSessionKey = opts.getSessionKey ?? ((exec) => exec.agent?.id ?? NO_SESSION_KEY);
2956
4435
  const tool = defineTool({
@@ -2959,8 +4438,16 @@ function createRemoteTool(driver, opts = {}) {
2959
4438
  parameters: {
2960
4439
  action: {
2961
4440
  type: "string",
2962
- enum: ["copy", "rsync"],
2963
- description: "Optional file action; omit for execution/status/roster."
4441
+ enum: [
4442
+ "copy",
4443
+ "rsync",
4444
+ "close"
4445
+ ],
4446
+ description: "Optional action; omit for execution/status/roster. close takes target + optional force."
4447
+ },
4448
+ force: {
4449
+ type: "boolean",
4450
+ description: "close only: default false — a session with a running job is refused (job_kill it first). true = kill the group and close in one step; the receipt lists what was killed."
2964
4451
  },
2965
4452
  src: {
2966
4453
  type: "string",
@@ -2973,7 +4460,7 @@ function createRemoteTool(driver, opts = {}) {
2973
4460
  argv: {
2974
4461
  type: "array",
2975
4462
  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."
4463
+ description: "Ordered native CLI options and values for one-command routes (e.g. rsync [\"-a\",\"--partial\",\"--dry-run\"], scp [\"-p\",\"-r\"], 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. docker cp: pass -L to follow symlinks (a symlink source like /etc/os-release fails without it). Two-segment cross-endpoint copy uses pullArgv/pushArgv instead."
2977
4464
  },
2978
4465
  pullArgv: {
2979
4466
  type: "array",
@@ -2983,27 +4470,27 @@ function createRemoteTool(driver, opts = {}) {
2983
4470
  pushArgv: {
2984
4471
  type: "array",
2985
4472
  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."
4473
+ 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."
2987
4474
  },
2988
4475
  verify: {
2989
4476
  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."
4477
+ description: "copy only. Receipts name the verification tier actually performed (D1=A): native CLI routes are \"native-exit-only\" (no independent check — self-verify via oneshot digest commands when needed); the Hub↔local staged lane computes independent SHA-256 digests by default and verify:false skips them (\"not-applicable\"). No hash tier exists on native routes."
2991
4478
  },
2992
4479
  mkdir: {
2993
4480
  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."
4481
+ description: "copy only, all routes. Default true: before transfer, one best-effort exec pre-hop ensures the destination's PARENT directory exists (the operand itself is never pre-created — each CLI's own layout semantics stay byte-identical). Receipt created field: true (parent created this call) / false (already existed) / \"n-a\" (skipped or pre-hop unavailable, e.g. docker exec on a stopped container — the native CLI then reports the real error itself). false skips the pre-hop."
2995
4482
  },
2996
4483
  run_in_background: {
2997
4484
  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."
4485
+ description: "oneshot AND pty: return a native jobId immediately, read with job_output, cancel with job_kill; output is available while running. On pty lanes the jobId IS the commandId — it tracks the running command inside a persistent session; job_kill sends Ctrl-C and the session survives. Hub-machine PTY has no background lane. Requires the official jobs service/controller."
2999
4486
  },
3000
4487
  target: {
3001
4488
  type: "string",
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."
4489
+ description: "Single entry point. Machine targets: bare pool name, ssh:<name>, docker:<name>, incus:<name>, hub:<m4>. PTY: append :pty to create (ssh:dev4:pty, ssh:dev4:pty:build — label optional, every creation opens a new session) or address a live one with pty:<id|label>. BYO: pass spawn (pty) or ssh (identity) and the label lives in the same pool. Omit target → roster (or BYO auto-id when spawn/ssh present)."
3003
4490
  },
3004
4491
  ssh: {
3005
4492
  type: "object",
3006
- description: "Only for byossh:<label>: register origin-side connection; omit on reuse. No inline keys or arbitrary SSH options.",
4493
+ description: "BYO ssh registration — the field present IS the entry point. First call registers (no cmd = register only); later calls reuse the label with ssh omitted. No inline keys or arbitrary SSH options.",
3007
4494
  additionalProperties: false,
3008
4495
  properties: {
3009
4496
  host: {
@@ -3031,28 +4518,23 @@ function createRemoteTool(driver, opts = {}) {
3031
4518
  },
3032
4519
  spawn: {
3033
4520
  type: "string",
3034
- description: "Only valid together with a \"byop:<label>\" target, and required the first time that label is created: the full command that starts an interactive shell (e.g. \"docker exec -it img bash\"). It is not retained — pass it again if the label answers [E_NO_SESSION]. Requires cmd; mode is locked to pty; nested hops are dumb pipes (only the innermost bash frames)."
4521
+ description: "BYO pty creation — the field present IS the entry point: the full command that starts an interactive shell (e.g. \"docker exec -it img bash\"). Pairs with target pty[:<label>] / a bare <label> / nothing (auto id). Nested hops are dumb pipes (only the innermost bash frames)."
3035
4522
  },
3036
4523
  cmd: {
3037
4524
  type: "string",
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."
4525
+ description: "One raw shell string parsed by the remote bash (multi-line = one compound; exit = last command's). Omit with target → status/card; omit everything → roster."
3039
4526
  },
3040
- script: {
4527
+ scriptPath: {
3041
4528
  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
- },
3044
- mode: {
3045
- type: "string",
3046
- enum: ["oneshot", "pty"],
3047
- description: "'oneshot' (default): stateless `bash -lc`, stdout/stderr separate, exit code is the process's own. 'pty': one persistent shell per (agent, target), cwd/env survive, streams merge, idle TTL 600s. A \"byop:<label>\" target locks mode to 'pty'."
4529
+ description: "LOCAL absolute script path. Read before any dialing, sent literally over stdin to fixed bash -s. Oneshot only (no PTY lanes); cannot combine with cmd, stdin or spawn. Hub inline cmd remains limited to 4096; script body is not argv."
3048
4530
  },
3049
4531
  stdin: {
3050
4532
  type: "string",
3051
- description: "Input fed to the command. oneshot: piped to stdin. pty: written right after the command line (REPL-style) — only helps commands that read stdin there. Requires cmd."
4533
+ description: "Input fed to the command. oneshot: piped to stdin. pty: written right after the command line (REPL-style). Requires cmd."
3052
4534
  },
3053
4535
  timeout: {
3054
4536
  type: "number",
3055
- description: "Per-command timeout in seconds (default 120, row-configurable). pty: Ctrl-C first, then kill; an interrupt does not kill the session."
4537
+ description: "Per-command timeout in seconds (default 120). oneshot: interrupts then kills the whole group. pty: the command is NOT killed on timeout — it hands off to a running job you can poll (job_output) and cancel (job_kill = Ctrl-C first; the session survives an interrupt)."
3056
4538
  }
3057
4539
  },
3058
4540
  output: {
@@ -3069,7 +4551,8 @@ function createRemoteTool(driver, opts = {}) {
3069
4551
  "roster",
3070
4552
  "job",
3071
4553
  "copy",
3072
- "rsync"
4554
+ "rsync",
4555
+ "close"
3073
4556
  ]
3074
4557
  },
3075
4558
  text: {
@@ -3087,8 +4570,45 @@ function createRemoteTool(driver, opts = {}) {
3087
4570
  truncated: { type: "integer" },
3088
4571
  outputStats: { type: "json" },
3089
4572
  outputStatsUnavailable: { type: "boolean" },
3090
- session: { type: "string" },
3091
- notice: { type: "string" }
4573
+ session: {
4574
+ type: "string",
4575
+ description: "pty session id, e.g. pty:pty-7k2m"
4576
+ },
4577
+ label: { type: "string" },
4578
+ connection: {
4579
+ type: "string",
4580
+ description: "underlying connection: ssh:dev4 / docker:c / incus:x / hub:m4 / BYOP"
4581
+ },
4582
+ byoTag: { type: "string" },
4583
+ commandId: {
4584
+ type: "string",
4585
+ description: "ledger key; equals the jobId whenever a job exists"
4586
+ },
4587
+ envDelta: {
4588
+ type: "json",
4589
+ description: "names only: {added,changed,removed}"
4590
+ },
4591
+ created: { type: "boolean" },
4592
+ notice: { type: "string" },
4593
+ runner: {
4594
+ type: "string",
4595
+ description: "oneshot supervision path: superd-run <ver> (<triple>) or posix-shim"
4596
+ },
4597
+ running: { type: "boolean" },
4598
+ ttlSec: { type: "integer" },
4599
+ closed: {
4600
+ type: "boolean",
4601
+ description: "close: true = the session is gone now"
4602
+ },
4603
+ reason: {
4604
+ type: "string",
4605
+ description: "close: not-found / already-closed-or-reaped"
4606
+ },
4607
+ killed: {
4608
+ type: "array",
4609
+ items: { type: "string" },
4610
+ description: "close force: the running commandIds that were killed"
4611
+ }
3092
4612
  }
3093
4613
  },
3094
4614
  render: (_args, value) => [{
@@ -3097,168 +4617,98 @@ function createRemoteTool(driver, opts = {}) {
3097
4617
  }]
3098
4618
  },
3099
4619
  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");
3163
- const hasCmd = typeof args.cmd === "string" && args.cmd.length > 0;
3164
- const hasTarget = typeof args.target === "string" && args.target.length > 0;
3165
- const hasSpawn = typeof args.spawn === "string" && args.spawn.length > 0;
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)})`);
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) {
3191
- if (args.stdin !== void 0) throw new Error("[E_STDIN_WITHOUT_CMD] remote: stdin requires cmd — status probes take no input");
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
- };
3199
- if (!hasTarget) return {
3200
- kind: "roster",
3201
- text: await driver.roster({ sessionKey: getSessionKey(exec) }),
3202
- exit: null,
3203
- durationMs: 0
3204
- };
3205
- return {
3206
- kind: "status",
3207
- text: await driver.status(args.target, { sessionKey: getSessionKey(exec) }),
3208
- exit: null,
3209
- durationMs: 0
3210
- };
3211
- }
3212
- const r = await driver.call({
3213
- target: args.target,
3214
- spawn: args.spawn,
3215
- ssh: args.ssh,
3216
- cmd: args.cmd,
3217
- script: args.script,
3218
- mode: args.mode === "oneshot" || args.mode === "pty" ? args.mode : void 0,
3219
- stdin: args.stdin,
3220
- timeout: args.timeout
3221
- }, { sessionKey: getSessionKey(exec) });
3222
- const v = {
3223
- kind: "exec",
3224
- text: r.stdout,
3225
- exit: r.exit,
3226
- durationMs: r.durationMs
3227
- };
3228
- if (r.cwd !== null) v.cwd = r.cwd;
3229
- if (r.stderr !== void 0) v.stderr = r.stderr;
3230
- if (r.timedOut) v.timedOut = true;
3231
- if (r.reconnected) v.reconnected = true;
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;
3235
- if (r.session !== void 0) v.session = r.session;
3236
- if (r.notice !== void 0) v.notice = r.notice;
3237
- return v;
4620
+ return remoteExecute({
4621
+ driver,
4622
+ getSessionKey
4623
+ }, args, exec);
3238
4624
  }
3239
4625
  });
3240
4626
  const description = tool.description;
3241
4627
  Object.defineProperty(tool, "description", {
3242
4628
  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." : "")
4629
+ 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 (hub:<m4>:pty selects the PTY lane); no SSH alias needed. Hub failures never fall back to SSH." : "")
3244
4630
  });
3245
4631
  return tool;
3246
4632
  }
4633
+ /** RemoteCallResult → RemoteToolValue(pty 工作台字段 + 既有字段)。 */
4634
+ function workbenchValue(r) {
4635
+ const v = {
4636
+ kind: "exec",
4637
+ text: r.stdout,
4638
+ exit: r.exit,
4639
+ durationMs: r.durationMs
4640
+ };
4641
+ if (r.cwd !== null && r.cwd !== void 0) v.cwd = r.cwd;
4642
+ if (r.stderr !== void 0) v.stderr = r.stderr;
4643
+ if (r.timedOut) v.timedOut = true;
4644
+ if (r.reconnected) v.reconnected = true;
4645
+ if (r.truncated !== void 0) v.truncated = r.truncated;
4646
+ if (r.outputStats !== void 0) v.outputStats = r.outputStats;
4647
+ if (r.outputStatsUnavailable) v.outputStatsUnavailable = true;
4648
+ if (r.session !== void 0) v.session = r.session;
4649
+ if (r.label !== void 0) v.label = r.label;
4650
+ if (r.connection !== void 0) v.connection = r.connection;
4651
+ if (r.byoTag !== void 0) v.byoTag = r.byoTag;
4652
+ if (r.commandId !== void 0) v.commandId = r.commandId;
4653
+ if (r.envDelta !== void 0) v.envDelta = r.envDelta;
4654
+ if (r.created === true) v.created = true;
4655
+ if (r.notice !== void 0) v.notice = r.notice;
4656
+ if (r.runner !== void 0) v.runner = r.runner;
4657
+ if (r.ttlSec !== void 0) v.ttlSec = r.ttlSec;
4658
+ return v;
4659
+ }
4660
+ /** S8 (2026-10-17): copy/rsync render as one formatted line; the full receipt
4661
+ * stays in the receipt field — raw-JSON dumps are gone from text. */
4662
+ function formatTransferLine(action, receipt, durationMs) {
4663
+ const r = receipt;
4664
+ const parts = [];
4665
+ if (r.ok === false) parts.push(`✗ ${r.reason ?? "failed"}`);
4666
+ parts.push(`${action} ${r.src} → ${r.dst}`);
4667
+ if (r.route !== void 0 && r.route !== "") parts.push(`route ${r.route}`);
4668
+ if (r.exits !== void 0) parts.push(`exits [${r.exits.join(",")}]`);
4669
+ else if (r.exit !== void 0) parts.push(`exit ${r.exit}`);
4670
+ if (r.verification !== void 0) parts.push(`verification ${r.verification}`);
4671
+ if (r.created === true) parts.push("created yes");
4672
+ else if (r.created === false) parts.push("created pre-existing");
4673
+ else if (r.created === "n-a") parts.push("created n-a");
4674
+ if (r.cleanup !== void 0 && r.cleanup !== "none-required") parts.push(`cleanup ${r.cleanup}`);
4675
+ if (typeof r.bytes === "number") parts.push(`${r.bytes} bytes / ${r.entries} entries`);
4676
+ parts.push(`${(durationMs / 1e3).toFixed(1)}s`);
4677
+ if (r.note !== void 0 && r.note.length > 0) parts.push(`(${r.note})`);
4678
+ return parts.join(" · ");
4679
+ }
4680
+ function renderEnvDelta(d) {
4681
+ const bits = [];
4682
+ if (d.added.length > 0) bits.push(`+${d.added.join(",+")}`);
4683
+ if (d.changed.length > 0) bits.push(`~${d.changed.join(",~")}`);
4684
+ if (d.removed.length > 0) bits.push(`-${d.removed.join(",-")}`);
4685
+ return bits.length > 0 ? bits.join(" ") : void 0;
4686
+ }
3247
4687
  function renderValue(v) {
3248
- if (v.kind === "status" || v.kind === "roster" || v.kind === "job" || v.kind === "copy" || v.kind === "rsync") return v.text;
4688
+ if (v.kind === "status" || v.kind === "roster" || v.kind === "job" || v.kind === "copy" || v.kind === "rsync" || v.kind === "close") return v.text;
3249
4689
  const parts = [];
3250
4690
  if (v.reconnected === true) parts.push(RECONNECT_NOTICE);
3251
4691
  if (typeof v.notice === "string" && v.notice.length > 0) parts.push(v.notice);
4692
+ if (v.created === true && v.session !== void 0) parts.push(`[session created — address it as ${v.session}]`);
3252
4693
  parts.push(v.text);
3253
4694
  const secs = ((v.durationMs ?? 0) / 1e3).toFixed(1);
3254
4695
  const markers = [];
4696
+ if (typeof v.session === "string" && v.session.length > 0) markers.push(`${v.session}${typeof v.label === "string" && v.label.length > 0 ? ` (${v.label})` : ""}${v.byoTag !== void 0 ? ` (${v.byoTag})` : ""}`);
4697
+ if (typeof v.connection === "string" && v.connection.length > 0 && v.session !== void 0) markers.push(`conn ${v.connection}`);
4698
+ if (typeof v.commandId === "string" && v.commandId.length > 0) markers.push(`cmd ${v.commandId}`);
4699
+ if (typeof v.cwd === "string" && v.cwd.length > 0) markers.push(`cwd ${v.cwd}`);
4700
+ const envTag = v.envDelta !== void 0 ? renderEnvDelta(v.envDelta) : void 0;
4701
+ if (envTag !== void 0) markers.push(`env ${envTag}`);
4702
+ if (typeof v.ttlSec === "number") markers.push(`idle-left ~${Math.max(1, Math.round(v.ttlSec / 60))}m`);
4703
+ if (typeof v.runner === "string" && v.runner.length > 0) markers.push(`runner ${v.runner}`);
3255
4704
  if (v.timedOut === true) markers.push("interrupted");
4705
+ markers.push(`exit ${v.exit}`);
4706
+ markers.push(`${secs}s`);
4707
+ if (v.running === true) markers.push(`still running — poll job_output${v.jobId !== void 0 ? ` ${v.jobId}` : ""}`);
3256
4708
  if (v.truncated !== void 0) markers.push(`truncated, ${v.outputStatsUnavailable ? "returned peer tail" : "original"} ${v.truncated} ${v.outputStats?.stdout.unit === "bytes" ? "bytes" : "chars"}`);
3257
4709
  if (v.outputStatsUnavailable) markers.push("peer output totals/retention unavailable; returned tail only");
3258
- const suffix = markers.length > 0 ? ` · ${markers.join(" · ")}` : "";
3259
- const cwdTag = typeof v.cwd === "string" && v.cwd.length > 0 ? ` · cwd ${v.cwd}` : "";
3260
- const sessionTag = typeof v.session === "string" && v.session.length > 0 ? ` · ${v.session}` : "";
3261
- parts.push(`[exit ${v.exit} · ${secs}s${suffix}${cwdTag}${sessionTag}]`);
4710
+ parts.push(`[${markers.join(" · ")}]`);
4711
+ if (v.runner === "posix-shim" && (v.exit !== null && v.exit !== 0 || v.timedOut === true)) parts.push("note: this oneshot ran through the POSIX fallback on this target; PTY lanes use an independent lane and may still work here");
3262
4712
  if (v.stderr !== void 0 && v.stderr.length > 0) parts.push("[stderr]", v.stderr);
3263
4713
  return parts.join("\n");
3264
4714
  }
@@ -3347,11 +4797,11 @@ const name = "dashr-remote";
3347
4797
  const inject = [];
3348
4798
  const Config = Schema.object({
3349
4799
  execTimeoutSec: Schema.natural().min(1).default(120),
3350
- idleTtlSec: Schema.natural().min(1).default(600),
4800
+ idleTtlSec: Schema.natural().min(1).default(REMOTE_PTY_IDLE_TTL_SEC),
3351
4801
  maxOutputChars: Schema.natural().min(1).default(3e4)
3352
4802
  }).default({
3353
4803
  execTimeoutSec: 120,
3354
- idleTtlSec: 600,
4804
+ idleTtlSec: REMOTE_PTY_IDLE_TTL_SEC,
3355
4805
  maxOutputChars: 3e4
3356
4806
  });
3357
4807
  /** MUST stay an arrow(RM0 plugin.ts 同注:function 声明会被 cordis 当构造器 new 掉,返回的 disposer 丢失)。 */
@@ -3366,4 +4816,4 @@ var plugin_default = {
3366
4816
  };
3367
4817
 
3368
4818
  //#endregion
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 };
4819
+ export { ByteTailRing, Config, FILE_FRAME_BYTES, FILE_MAX_ENTRIES, FILE_MAX_METADATA, PROCESS_CLEANUP_BOUND_MS, Utf16TailRing, apply, checkAbsolute, checkSelection, plugin_default as default, inject, localFileEndpoint, name, readUtf8Tail, runFileWorker, runRsync, sshArgv, startBinaryProcess, startOneShot, startRemoteJob };