better-dsh 0.3.2 → 0.3.3-c
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.
- package/docs/adr/0015-independent-bundles-agent-remote.md +4 -1
- package/docs/remote-tools.md +36 -20
- package/docs/specs/remote-tool-enhancement/spec.md +2 -0
- package/lib/fs-aware/sandbox-plugin.js +3 -3
- package/lib/index.d.ts +16 -12
- package/lib/index.js +19 -25
- package/lib/remote/hub-pty-pump.mjs +97 -0
- package/lib/remote/plugin.d.ts +476 -200
- package/lib/remote/plugin.js +1447 -666
- package/lib/web-password.js +1 -1
- package/lib/web-trust2.d.ts +11 -11
- package/lib/welcome-notice.d.ts +3 -3
- package/package.json +77 -61
package/lib/remote/plugin.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { t as Schema } from "../lib.js";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
2
3
|
import { Context } from "@deepseek-ai/cordis";
|
|
3
4
|
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
4
|
-
import { constants, createReadStream, createWriteStream, existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
|
|
5
|
+
import { constants, createReadStream, createWriteStream, existsSync, mkdirSync, readFileSync, rmSync, statSync } from "node:fs";
|
|
5
6
|
import { dirname, isAbsolute, join, posix, resolve } from "node:path";
|
|
6
7
|
import { spawn } from "node:child_process";
|
|
7
8
|
import { fileURLToPath } from "node:url";
|
|
@@ -11,6 +12,7 @@ import { chmod, lstat, mkdir, mkdtemp, open, readFile, readdir, readlink, rename
|
|
|
11
12
|
import { pipeline } from "node:stream/promises";
|
|
12
13
|
import { Readable } from "node:stream";
|
|
13
14
|
import { createConnection, createServer } from "node:net";
|
|
15
|
+
import { Buffer as Buffer$1 } from "node:buffer";
|
|
14
16
|
|
|
15
17
|
//#region src/remote/archive.ts
|
|
16
18
|
const FILE_FRAME_BYTES = 64 * 1024;
|
|
@@ -572,6 +574,15 @@ function startBinaryProcess(argv, options = {}) {
|
|
|
572
574
|
let diagnostic;
|
|
573
575
|
let timedOut = false, finished = false, cancelled = false;
|
|
574
576
|
let cleanup;
|
|
577
|
+
let cancelledNotified = false;
|
|
578
|
+
const notifyCancel = () => {
|
|
579
|
+
if (!cancelledNotified) {
|
|
580
|
+
cancelledNotified = true;
|
|
581
|
+
try {
|
|
582
|
+
options.onCancel?.();
|
|
583
|
+
} catch {}
|
|
584
|
+
}
|
|
585
|
+
};
|
|
575
586
|
let timer;
|
|
576
587
|
const signal = (sig) => {
|
|
577
588
|
if (child.pid === void 0) return false;
|
|
@@ -611,6 +622,7 @@ function startBinaryProcess(argv, options = {}) {
|
|
|
611
622
|
const cancel = () => {
|
|
612
623
|
if (!finished) {
|
|
613
624
|
cancelled = true;
|
|
625
|
+
notifyCancel();
|
|
614
626
|
reap();
|
|
615
627
|
}
|
|
616
628
|
};
|
|
@@ -1609,150 +1621,6 @@ function sshArgv(connection, options = {}) {
|
|
|
1609
1621
|
], options.extraArgs ?? [], options.command);
|
|
1610
1622
|
}
|
|
1611
1623
|
|
|
1612
|
-
//#endregion
|
|
1613
|
-
//#region src/remote/input.ts
|
|
1614
|
-
/** Local carrier validation precedes any target discovery, connection or spawn. */
|
|
1615
|
-
async function prepareRemoteInput(input) {
|
|
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");
|
|
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");
|
|
1620
|
-
}
|
|
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) {
|
|
1623
|
-
let stdin;
|
|
1624
|
-
try {
|
|
1625
|
-
stdin = await readFile(input.scriptPath, "utf8");
|
|
1626
|
-
} catch (error) {
|
|
1627
|
-
throw new Error(`[E_SCRIPT_READ] remote: cannot read local script ${JSON.stringify(input.scriptPath)} — ${error instanceof Error ? error.message : String(error)}`);
|
|
1628
|
-
}
|
|
1629
|
-
return {
|
|
1630
|
-
target: input.target,
|
|
1631
|
-
ssh: input.ssh,
|
|
1632
|
-
cmd: "bash -s",
|
|
1633
|
-
stdin,
|
|
1634
|
-
mode: "oneshot",
|
|
1635
|
-
timeout: input.timeout
|
|
1636
|
-
};
|
|
1637
|
-
}
|
|
1638
|
-
return {
|
|
1639
|
-
target: input.target,
|
|
1640
|
-
spawn: input.spawn,
|
|
1641
|
-
ssh: input.ssh,
|
|
1642
|
-
cmd: input.cmd ?? "",
|
|
1643
|
-
stdin: input.stdin,
|
|
1644
|
-
mode: input.mode,
|
|
1645
|
-
timeout: input.timeout
|
|
1646
|
-
};
|
|
1647
|
-
}
|
|
1648
|
-
|
|
1649
|
-
//#endregion
|
|
1650
|
-
//#region src/remote/oneshot.ts
|
|
1651
|
-
async function runOneShot(argv, opts = {}) {
|
|
1652
|
-
const process$1 = startOneShot(argv, opts);
|
|
1653
|
-
const result = await process$1.done;
|
|
1654
|
-
const stdout = process$1.read("stdout", 0), stderr = process$1.read("stderr", 0);
|
|
1655
|
-
return {
|
|
1656
|
-
...result,
|
|
1657
|
-
stdout: stdout.text,
|
|
1658
|
-
stderr: [stderr.text, stderr.diagnostic].filter(Boolean).join("\n"),
|
|
1659
|
-
outputStats: {
|
|
1660
|
-
stdout: stdout.stats,
|
|
1661
|
-
stderr: stderr.stats
|
|
1662
|
-
}
|
|
1663
|
-
};
|
|
1664
|
-
}
|
|
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
|
-
|
|
1756
1624
|
//#endregion
|
|
1757
1625
|
//#region src/remote/transports.ts
|
|
1758
1626
|
/** POSIX 单引号机械转义(唯一一层转义,spec §三"一层机械转义")。 */
|
|
@@ -1781,7 +1649,10 @@ bash -lc ${shQuote(cmd)}`;
|
|
|
1781
1649
|
wrapped
|
|
1782
1650
|
];
|
|
1783
1651
|
switch (plan.kind) {
|
|
1784
|
-
case "ssh": return sshArgv(plan.connection ?? { host: plan.host }, {
|
|
1652
|
+
case "ssh": return sshArgv(plan.connection ?? { host: plan.host }, {
|
|
1653
|
+
command: `setsid --wait bash -c ${shQuote(wrapped)}`,
|
|
1654
|
+
extraArgs: options.muxArgv
|
|
1655
|
+
});
|
|
1785
1656
|
case "docker": return [
|
|
1786
1657
|
"docker",
|
|
1787
1658
|
"exec",
|
|
@@ -1835,21 +1706,41 @@ function buildSpawnArgv(spawnCommand) {
|
|
|
1835
1706
|
"/dev/null"
|
|
1836
1707
|
];
|
|
1837
1708
|
}
|
|
1709
|
+
/** Hub PTY(0.3.2-c;0.3.2-d F4 修正):会话子进程 = 本地 `script` PTY 托管的 WS 字节泵
|
|
1710
|
+
* (hub-pty-pump.mjs)。与 docker exec 的嵌套 PTY 同形,但泵是哑 node 子进程、不像
|
|
1711
|
+
* `ssh -tt` / `docker exec -it` 那样作为前台子进程自己把本地 tty 置 raw——所以本地包装必须
|
|
1712
|
+
* `stty raw`:canonical+ISIG 下 0x03 在本地变 SIGINT 杀泵、0x04 变 EOF 结束泵 stdin
|
|
1713
|
+
* (F4:input ^C/^D 与 job_kill 会打死整条会话)。raw 只作用于本地半段;对端 script PTY
|
|
1714
|
+
* 保持 canonical,^C/^D 在对端产生 SIGINT/EOF 正是我们要的语义(字节原样过线,解释权在
|
|
1715
|
+
* 对端)。kill 链 session 组杀 → 泵死 → WS 关 → 对端组杀,双向自然闭合。连接事实走 wrapper
|
|
1716
|
+
* 内 env 赋值(cookie 不进 argv、不落 ps)。 */
|
|
1717
|
+
function buildHubPtyArgv(endpoint, assets) {
|
|
1718
|
+
const node = assets.nodePath ?? process.execPath;
|
|
1719
|
+
return [
|
|
1720
|
+
"script",
|
|
1721
|
+
"-qfec",
|
|
1722
|
+
`stty raw -echo 2>/dev/null ; ${`export SUPERD_HUB_PTY_URL=${shQuote(endpoint.url)} SUPERD_HUB_PTY_COOKIE=${shQuote(endpoint.cookie)} SUPERD_HUB_PTY_WS=${shQuote(endpoint.wsPath)}`} ; exec ${shQuote(node)} ${shQuote(assets.pumpPath)}`,
|
|
1723
|
+
"/dev/null"
|
|
1724
|
+
];
|
|
1725
|
+
}
|
|
1838
1726
|
/** superd-run helper argv (spec: docs/specs/remote-rescope §5). Probe and upload are single
|
|
1839
1727
|
* `sh -c` hops (busybox/alpine/darwin-safe); run/killgroup over ssh go through exactly one
|
|
1840
1728
|
* remote-shell parse, same discipline as buildOneShotArgv. */
|
|
1841
|
-
function buildHelperProbeArgv(plan, assets) {
|
|
1729
|
+
function buildHelperProbeArgv(plan, assets, muxArgv) {
|
|
1842
1730
|
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
|
|
1731
|
+
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`, muxArgv);
|
|
1844
1732
|
}
|
|
1845
|
-
function buildHelperUploadArgv(plan, triple, file, assets) {
|
|
1733
|
+
function buildHelperUploadArgv(plan, triple, file, assets, muxArgv) {
|
|
1846
1734
|
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}"
|
|
1735
|
+
return helperShellArgv(plan, `mkdir -p "${dir}" && cat > "${dir}/${file}.tmp" && chmod 0755 "${dir}/${file}.tmp" && mv -f "${dir}/${file}.tmp" "${dir}/${file}"`, muxArgv);
|
|
1848
1736
|
}
|
|
1849
1737
|
function buildHelperOneShotArgv(plan, binPath, options) {
|
|
1850
1738
|
const { tag, cmd } = options;
|
|
1851
1739
|
switch (plan.kind) {
|
|
1852
|
-
case "ssh": return sshArgv(plan.connection ?? { host: plan.host }, {
|
|
1740
|
+
case "ssh": return sshArgv(plan.connection ?? { host: plan.host }, {
|
|
1741
|
+
command: `exec ${shQuote(binPath)} run --tag ${shQuote(tag)} -- bash -lc ${shQuote(cmd)}`,
|
|
1742
|
+
extraArgs: options.muxArgv
|
|
1743
|
+
});
|
|
1853
1744
|
case "docker": return [
|
|
1854
1745
|
"docker",
|
|
1855
1746
|
"exec",
|
|
@@ -1882,9 +1773,12 @@ function buildHelperOneShotArgv(plan, binPath, options) {
|
|
|
1882
1773
|
];
|
|
1883
1774
|
}
|
|
1884
1775
|
}
|
|
1885
|
-
function buildHelperKillgroupArgv(plan, binPath, tag) {
|
|
1776
|
+
function buildHelperKillgroupArgv(plan, binPath, tag, muxArgv) {
|
|
1886
1777
|
switch (plan.kind) {
|
|
1887
|
-
case "ssh": return sshArgv(plan.connection ?? { host: plan.host }, {
|
|
1778
|
+
case "ssh": return sshArgv(plan.connection ?? { host: plan.host }, {
|
|
1779
|
+
command: `${shQuote(binPath)} killgroup --tag ${shQuote(tag)}`,
|
|
1780
|
+
extraArgs: muxArgv
|
|
1781
|
+
});
|
|
1888
1782
|
case "docker": return [
|
|
1889
1783
|
"docker",
|
|
1890
1784
|
"exec",
|
|
@@ -1908,9 +1802,12 @@ function buildHelperKillgroupArgv(plan, binPath, tag) {
|
|
|
1908
1802
|
];
|
|
1909
1803
|
}
|
|
1910
1804
|
}
|
|
1911
|
-
function helperShellArgv(plan, script) {
|
|
1805
|
+
function helperShellArgv(plan, script, muxArgv) {
|
|
1912
1806
|
switch (plan.kind) {
|
|
1913
|
-
case "ssh": return sshArgv(plan.connection ?? { host: plan.host }, {
|
|
1807
|
+
case "ssh": return sshArgv(plan.connection ?? { host: plan.host }, {
|
|
1808
|
+
command: script,
|
|
1809
|
+
extraArgs: muxArgv
|
|
1810
|
+
});
|
|
1914
1811
|
case "docker": return [
|
|
1915
1812
|
"docker",
|
|
1916
1813
|
"exec",
|
|
@@ -1935,108 +1832,347 @@ function helperShellArgv(plan, script) {
|
|
|
1935
1832
|
}
|
|
1936
1833
|
|
|
1937
1834
|
//#endregion
|
|
1938
|
-
//#region src/remote/
|
|
1939
|
-
/**
|
|
1940
|
-
function
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
...stats,
|
|
1965
|
-
returned
|
|
1966
|
-
} };
|
|
1967
|
-
if (total <= returned) return {
|
|
1968
|
-
text: tail,
|
|
1969
|
-
...updated
|
|
1970
|
-
};
|
|
1835
|
+
//#region src/remote/input.ts
|
|
1836
|
+
/** Local carrier validation precedes any target discovery, connection or spawn. */
|
|
1837
|
+
async function prepareRemoteInput(input) {
|
|
1838
|
+
if (input.mode !== void 0 && input.mode !== "oneshot" && input.mode !== "pty") throw new Error("[E_BAD_MODE] remote: mode must be oneshot or pty");
|
|
1839
|
+
if (input.scriptPath !== void 0) {
|
|
1840
|
+
if (input.cmd !== void 0 || input.spawn !== void 0) throw new Error("[E_SCRIPT_PARAMS] remote: script and cmd are alternative carriers — pass one; spawn belongs to BYO PTY creation, not scripts");
|
|
1841
|
+
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");
|
|
1842
|
+
}
|
|
1843
|
+
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)");
|
|
1844
|
+
if (input.scriptPath !== void 0) {
|
|
1845
|
+
let script;
|
|
1846
|
+
try {
|
|
1847
|
+
script = await readFile(input.scriptPath, "utf8");
|
|
1848
|
+
} catch (error) {
|
|
1849
|
+
throw new Error(`[E_SCRIPT_READ] remote: cannot read local script ${JSON.stringify(input.scriptPath)} — ${error instanceof Error ? error.message : String(error)}`);
|
|
1850
|
+
}
|
|
1851
|
+
if (script.includes("\0")) throw new Error("[E_SCRIPT_NUL] remote: script contains NUL bytes — the command channel cannot carry it; transfer the file and run it with cmd instead");
|
|
1852
|
+
return {
|
|
1853
|
+
target: input.target,
|
|
1854
|
+
ssh: input.ssh,
|
|
1855
|
+
cmd: `bash -c ${shQuote(script)}`,
|
|
1856
|
+
stdin: input.stdin,
|
|
1857
|
+
mode: input.mode,
|
|
1858
|
+
timeout: input.timeout
|
|
1859
|
+
};
|
|
1860
|
+
}
|
|
1971
1861
|
return {
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1862
|
+
target: input.target,
|
|
1863
|
+
spawn: input.spawn,
|
|
1864
|
+
ssh: input.ssh,
|
|
1865
|
+
cmd: input.cmd ?? "",
|
|
1866
|
+
stdin: input.stdin,
|
|
1867
|
+
mode: input.mode,
|
|
1868
|
+
timeout: input.timeout
|
|
1975
1869
|
};
|
|
1976
1870
|
}
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
const text = normalizePtyText(raw);
|
|
1992
|
-
if (text.length > 0) events.onOutput?.(text);
|
|
1993
|
-
};
|
|
1994
|
-
/** 缓冲尾若是任一候选前缀的真前缀则扣留(D/E 共用);flush() 放出残余。 */
|
|
1995
|
-
const holdback = () => {
|
|
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
|
-
}
|
|
1871
|
+
|
|
1872
|
+
//#endregion
|
|
1873
|
+
//#region src/remote/oneshot.ts
|
|
1874
|
+
async function runOneShot(argv, opts = {}) {
|
|
1875
|
+
const process$1 = startOneShot(argv, opts);
|
|
1876
|
+
const result = await process$1.done;
|
|
1877
|
+
const stdout = process$1.read("stdout", 0), stderr = process$1.read("stderr", 0);
|
|
1878
|
+
return {
|
|
1879
|
+
...result,
|
|
1880
|
+
stdout: stdout.text,
|
|
1881
|
+
stderr: [stderr.text, stderr.diagnostic].filter(Boolean).join("\n"),
|
|
1882
|
+
outputStats: {
|
|
1883
|
+
stdout: stdout.stats,
|
|
1884
|
+
stderr: stderr.stats
|
|
2003
1885
|
}
|
|
2004
|
-
return best;
|
|
2005
1886
|
};
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
//#endregion
|
|
1890
|
+
//#region src/remote/helper.ts
|
|
1891
|
+
/** Reads helper-bin/MANIFEST.json next to the compiled module (lib/remote in
|
|
1892
|
+
* the npm package). Missing or malformed assets ⇒ null ⇒ shim-only mode;
|
|
1893
|
+
* never throws. */
|
|
1894
|
+
function helperAssets(baseDir) {
|
|
1895
|
+
const dir = baseDir ?? join(dirname(fileURLToPath(import.meta.url)), "helper-bin");
|
|
1896
|
+
try {
|
|
1897
|
+
const manifest = JSON.parse(readFileSync(join(dir, "MANIFEST.json"), "utf8"));
|
|
1898
|
+
if (manifest?.format !== 1 || typeof manifest.ver !== "string" || typeof manifest.protocol !== "number" || !manifest.targets || typeof manifest.targets !== "object") return null;
|
|
1899
|
+
return {
|
|
1900
|
+
dir,
|
|
1901
|
+
manifest
|
|
1902
|
+
};
|
|
1903
|
+
} catch {
|
|
1904
|
+
return null;
|
|
1905
|
+
}
|
|
1906
|
+
}
|
|
1907
|
+
/** `uname -s`/`uname -m` → shipped target triple. Unknown combos ⇒ null (shim). */
|
|
1908
|
+
function archTriple(sys, machine, shipped) {
|
|
1909
|
+
const s = sys.trim().toLowerCase(), m = machine.trim().toLowerCase();
|
|
1910
|
+
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;
|
|
1911
|
+
if (candidates === null) return null;
|
|
1912
|
+
return shipped.has(candidates) ? candidates : null;
|
|
1913
|
+
}
|
|
1914
|
+
function planKey(plan) {
|
|
1915
|
+
switch (plan.kind) {
|
|
1916
|
+
case "ssh": {
|
|
1917
|
+
const c = plan.connection;
|
|
1918
|
+
const host = c?.host ?? plan.host;
|
|
1919
|
+
return `ssh|${c?.user ? c.user + "@" : ""}${host}${c?.port ? ":" + c.port : ""}${c?.proxyJump ? "|" + c.proxyJump : ""}`;
|
|
1920
|
+
}
|
|
1921
|
+
case "docker": return `docker|${plan.container}`;
|
|
1922
|
+
case "incus": return `incus|${plan.container}`;
|
|
1923
|
+
}
|
|
1924
|
+
}
|
|
1925
|
+
const PROBE_RE = /^SUPERD-PROBE (\S+) (\S+)$/m;
|
|
1926
|
+
const READY_RE = /^SUPERD-READY (\S+)$/m;
|
|
1927
|
+
var HelperRegistry = class {
|
|
1928
|
+
states = /* @__PURE__ */ new Map();
|
|
1929
|
+
constructor(assets, exec, probeArgv, uploadArgv) {
|
|
1930
|
+
this.assets = assets;
|
|
1931
|
+
this.exec = exec;
|
|
1932
|
+
this.probeArgv = probeArgv;
|
|
1933
|
+
this.uploadArgv = uploadArgv;
|
|
1934
|
+
}
|
|
1935
|
+
/** Never throws: any bootstrap failure resolves null (sticky) and the caller stays on the shim. */
|
|
1936
|
+
ensure(plan, muxArgv = []) {
|
|
1937
|
+
if (plan.kind !== "ssh" && plan.kind !== "docker" && plan.kind !== "incus") return Promise.resolve(null);
|
|
1938
|
+
const key = planKey(plan);
|
|
1939
|
+
let entry = this.states.get(key);
|
|
1940
|
+
if (entry === void 0) {
|
|
1941
|
+
entry = this.bootstrap(plan, muxArgv).catch(() => null);
|
|
1942
|
+
entry.catch(() => {});
|
|
1943
|
+
this.states.set(key, entry);
|
|
1944
|
+
}
|
|
1945
|
+
return entry;
|
|
1946
|
+
}
|
|
1947
|
+
async bootstrap(plan, muxArgv) {
|
|
1948
|
+
const { manifest } = this.assets;
|
|
1949
|
+
const first = await this.exec(this.probeArgv(plan, this.assets, muxArgv), { timeoutSec: 15 });
|
|
1950
|
+
const ready = this.parseReady(first.stdout, manifest.ver, manifest.protocol);
|
|
1951
|
+
if (ready !== null) return ready;
|
|
1952
|
+
const probe = PROBE_RE.exec(first.stdout);
|
|
1953
|
+
if (probe === null || first.exit !== 90 && first.exit !== 0) return null;
|
|
1954
|
+
const triple = archTriple(probe[1], probe[2], new Set(Object.keys(manifest.targets)));
|
|
1955
|
+
if (triple === null) return null;
|
|
1956
|
+
const target = manifest.targets[triple];
|
|
1957
|
+
const binary = await readFile(join(this.assets.dir, triple, target.file));
|
|
1958
|
+
if ((await this.exec(this.uploadArgv(plan, triple, target.file, this.assets, muxArgv), {
|
|
1959
|
+
stdin: binary,
|
|
1960
|
+
timeoutSec: 60
|
|
1961
|
+
})).exit !== 0) return null;
|
|
1962
|
+
const second = await this.exec(this.probeArgv(plan, this.assets, muxArgv), { timeoutSec: 15 });
|
|
1963
|
+
return this.parseReady(second.stdout, manifest.ver, manifest.protocol);
|
|
1964
|
+
}
|
|
1965
|
+
parseReady(stdout, ver, protocol) {
|
|
1966
|
+
const ready = READY_RE.exec(stdout);
|
|
1967
|
+
const banner = /^SUPERD-RUN proto=(\d+) name=superd-run ver=(\S+) target=(\S+)/m.exec(stdout);
|
|
1968
|
+
if (ready === null || banner === null) return null;
|
|
1969
|
+
if (banner[1] !== String(protocol) || banner[2] !== ver) return null;
|
|
1970
|
+
return {
|
|
1971
|
+
binPath: ready[1],
|
|
1972
|
+
triple: banner[3],
|
|
1973
|
+
ver,
|
|
1974
|
+
protocol
|
|
1975
|
+
};
|
|
1976
|
+
}
|
|
1977
|
+
};
|
|
1978
|
+
|
|
1979
|
+
//#endregion
|
|
1980
|
+
//#region src/remote/ssh-mux.ts
|
|
1981
|
+
/** SSH oneshot 连接复用(plan 2026-11-01 Task E;探针基线 = 发起端 OpenSSH 10.2p1,2026-11-01 实测):
|
|
1982
|
+
* - master 与客户端完全脱钩(fork 后 PPID=init、自有 pgid/session)→ 本地 killpg 取消/超时永不波及
|
|
1983
|
+
* master,后续 oneshot 继续复用;这正好落在 startBinaryProcess 的组杀清理语义之内。
|
|
1984
|
+
* - ControlPersist 到期 master 自行退出并 unlink socket;`ssh -O exit` 主动拆除亦同。
|
|
1985
|
+
* - master 被 SIGKILL 会留下残 socket 文件:ControlMaster=auto 的下一个客户端会自愈重建
|
|
1986
|
+
* (实测 exit 0),失败语义按 ssh 真实回退,不冒充复用。
|
|
1987
|
+
* - 只注入 SSH oneshot 执行链(POSIX wrapper / helper probe·upload·run·killgroup / cleanup /
|
|
1988
|
+
* status 探测);PTY、scp、rsync、relay 与 copy 的 mkdir pre-hop 一律不注入。
|
|
1989
|
+
* - CLI `-o` 覆盖用户 config 同名项是 OpenSSH 语义(last-option-wins),其余身份选项照常读 config。 */
|
|
1990
|
+
const SSH_MUX_CONTROL_PERSIST_SEC = 600;
|
|
1991
|
+
/** Linux sun_path 上限 108;留余量并在超限时诚实退化为不复用(MUX 是优化,不是前提)。 */
|
|
1992
|
+
const MAX_SOCKET_PATH = 104;
|
|
1993
|
+
function identityKey(identity) {
|
|
1994
|
+
const c = identity.connection;
|
|
1995
|
+
const raw = JSON.stringify([
|
|
1996
|
+
identity.target,
|
|
1997
|
+
c.host,
|
|
1998
|
+
c.user ?? null,
|
|
1999
|
+
c.port ?? null,
|
|
2000
|
+
c.key ?? null,
|
|
2001
|
+
c.proxyJump ?? null
|
|
2002
|
+
]);
|
|
2003
|
+
return createHash("sha256").update(raw).digest("hex").slice(0, 16);
|
|
2004
|
+
}
|
|
2005
|
+
/** 每 Agent 一个(driver 私有):tool-owned 0700 socket 目录 + 按连接身份的 socket 名。
|
|
2006
|
+
* 目录名含随机 owner 后缀,跨 Agent 天然不共享;dispose 逐 socket `-O exit` 后整目录删除。 */
|
|
2007
|
+
var SshMux = class {
|
|
2008
|
+
persistSec;
|
|
2009
|
+
rootDir;
|
|
2010
|
+
runner;
|
|
2011
|
+
owner = randomUUID().slice(0, 8);
|
|
2012
|
+
sockets = /* @__PURE__ */ new Map();
|
|
2013
|
+
dir;
|
|
2014
|
+
disposal;
|
|
2015
|
+
constructor(options = {}) {
|
|
2016
|
+
this.persistSec = options.persistSec ?? SSH_MUX_CONTROL_PERSIST_SEC;
|
|
2017
|
+
this.rootDir = options.rootDir ?? tmpdir();
|
|
2018
|
+
this.runner = options.runner ?? runOneShot;
|
|
2019
|
+
}
|
|
2020
|
+
/** 注入 SSH argv 的 MUX 选项(trusted internal argv,非模型输入)。目录懒建(纯 docker/hub
|
|
2021
|
+
* Agent 永不触盘);超限/已 dispose 返回 [] = 诚实退化为普通连接。 */
|
|
2022
|
+
argvFor(identity) {
|
|
2023
|
+
if (this.disposal !== void 0) return [];
|
|
2024
|
+
const socket = join(this.dir ?? (this.dir = join(this.rootDir, `superd-mux-${this.owner}`)), identityKey(identity));
|
|
2025
|
+
if (socket.length + 1 > MAX_SOCKET_PATH) return [];
|
|
2026
|
+
try {
|
|
2027
|
+
mkdirSync(this.dir, {
|
|
2028
|
+
recursive: true,
|
|
2029
|
+
mode: 448
|
|
2030
|
+
});
|
|
2031
|
+
} catch {
|
|
2032
|
+
return [];
|
|
2033
|
+
}
|
|
2034
|
+
this.sockets.set(socket, identity.connection.host);
|
|
2035
|
+
return [
|
|
2036
|
+
"-o",
|
|
2037
|
+
`ControlPath=${socket}`,
|
|
2038
|
+
"-o",
|
|
2039
|
+
"ControlMaster=auto",
|
|
2040
|
+
"-o",
|
|
2041
|
+
`ControlPersist=${this.persistSec}`
|
|
2042
|
+
];
|
|
2043
|
+
}
|
|
2044
|
+
/** Teardown:先 `-O exit` 逐 socket 礼貌拆除 master(不拨号、5s 上限、错误全吞——
|
|
2045
|
+
* master 已死/残 socket 属正常),再删目录。单飞(重复调用同一 Promise)。 */
|
|
2046
|
+
dispose() {
|
|
2047
|
+
if (this.disposal !== void 0) return this.disposal;
|
|
2048
|
+
const sockets = [...this.sockets.entries()];
|
|
2049
|
+
this.sockets.clear();
|
|
2050
|
+
return this.disposal = (async () => {
|
|
2051
|
+
await Promise.allSettled(sockets.map(([socket, host]) => this.runner([
|
|
2052
|
+
"ssh",
|
|
2053
|
+
"-o",
|
|
2054
|
+
`ControlPath=${socket}`,
|
|
2055
|
+
"-O",
|
|
2056
|
+
"exit",
|
|
2057
|
+
"--",
|
|
2058
|
+
host
|
|
2059
|
+
], { timeoutSec: 5 })));
|
|
2060
|
+
if (this.dir !== void 0) try {
|
|
2061
|
+
rmSync(this.dir, {
|
|
2062
|
+
recursive: true,
|
|
2063
|
+
force: true
|
|
2064
|
+
});
|
|
2065
|
+
} catch {}
|
|
2066
|
+
})();
|
|
2067
|
+
}
|
|
2068
|
+
};
|
|
2069
|
+
|
|
2070
|
+
//#endregion
|
|
2071
|
+
//#region src/remote/nonce-framing.ts
|
|
2072
|
+
/** 8 字节随机串的 hex 形(spec §五.2:每次下发前生成)。 */
|
|
2073
|
+
function genNonce(bytes = 8) {
|
|
2074
|
+
return randomBytes(bytes).toString("hex");
|
|
2075
|
+
}
|
|
2076
|
+
/** 命令整行:花括号复合体 + Nonce 收帧尾(spec §五.2 的 printf 逐字落地 + cwd 第三字段)。 */
|
|
2077
|
+
function wrapPtyCommand(cmd, nonce) {
|
|
2078
|
+
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`;
|
|
2079
|
+
}
|
|
2080
|
+
/** init 整行:哑终端(echo/PS1/PS2/TERM)+ 固定 40×120(Non-Goal ③)+ INIT 帧。 */
|
|
2081
|
+
function buildInitCommand(nonce) {
|
|
2082
|
+
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`;
|
|
2083
|
+
}
|
|
2084
|
+
const ANSI_RE = /\x1b(?:\][^\x07\x1b]*(?:\x07|\x1b\\)?|\[[0-9;?]*[ -/]*[@-~]|[@-Z\\-_])/g;
|
|
2085
|
+
function stripAnsi(s) {
|
|
2086
|
+
return s.replace(ANSI_RE, "");
|
|
2087
|
+
}
|
|
2088
|
+
function normalizePtyText(s) {
|
|
2089
|
+
return stripAnsi(s).replace(/\r\n/g, "\n").replace(/\r/g, "");
|
|
2090
|
+
}
|
|
2091
|
+
/** 0.3.2-e:自有 30K 返回窗口已删(原生 agent runtime 的 spill/输出保护接管模型上下文
|
|
2092
|
+
* 尺寸)。本 helper 只做 retention 披露:ring 留存(4M UTF-16 / 4MiB bytes)未驱逐时
|
|
2093
|
+
* 全量透传(returned=retained,无 banner);驱逐(dropped>0,仅 >4M 输出发生)时打一行
|
|
2094
|
+
* banner 且 truncated=原始总量。text 已是 ring 尾部,不再二次裁剪。 */
|
|
2095
|
+
function disclose(text, stats) {
|
|
2096
|
+
if (stats === void 0) return { text };
|
|
2097
|
+
const returned = stats.retained;
|
|
2098
|
+
const updated = { stats: {
|
|
2099
|
+
...stats,
|
|
2100
|
+
returned
|
|
2101
|
+
} };
|
|
2102
|
+
if (stats.dropped <= 0) return {
|
|
2103
|
+
text,
|
|
2104
|
+
...updated
|
|
2105
|
+
};
|
|
2106
|
+
const unit = stats.unit === "bytes" ? "bytes" : "chars";
|
|
2107
|
+
return {
|
|
2108
|
+
text: `[retention tail: showing last ${stats.retained} of ${stats.total} ${unit}; dropped head ${stats.dropped}, retention limit ${stats.retentionLimit} ${stats.unit === "bytes" ? "bytes" : "UTF-16 code units"}]\n${text}`,
|
|
2109
|
+
truncated: stats.total,
|
|
2110
|
+
...updated
|
|
2111
|
+
};
|
|
2112
|
+
}
|
|
2113
|
+
const FRAME_BODY_RE = /^(\d+);([^\x07\x1b]*)(?:\x07|\x1b\\)/;
|
|
2114
|
+
const ENV_BODY_RE = /^([A-Za-z0-9+/=]*)\x07/;
|
|
2115
|
+
/**
|
|
2116
|
+
* 单 nonce、单帧解析器:一轮 dispatch 一个实例。只认 `\x1b]133;D;<nonce>;`,
|
|
2117
|
+
* 错误 nonce 的静态/伪造 marker 永不匹配,按普通输出流过(再经 ANSI 剥洗)。
|
|
2118
|
+
* 缓冲尾若可能是 marker 前缀则扣留,flush() 时作为输出放出。
|
|
2119
|
+
*/
|
|
2120
|
+
function createNonceFrameParser(nonce, events) {
|
|
2121
|
+
const prefix = `\x1b]133;D;${nonce};`;
|
|
2122
|
+
const prefixE = `\x1b]133;E;${nonce};`;
|
|
2123
|
+
const prefixes = [prefix, prefixE];
|
|
2124
|
+
let buf$1 = "";
|
|
2125
|
+
let done = false;
|
|
2126
|
+
const emit = (raw) => {
|
|
2127
|
+
const text = normalizePtyText(raw);
|
|
2128
|
+
if (text.length > 0) events.onOutput?.(text);
|
|
2129
|
+
};
|
|
2130
|
+
/** 缓冲尾若是任一候选前缀的真前缀则扣留(D/E 共用);flush() 放出残余。 */
|
|
2131
|
+
const holdback = () => {
|
|
2132
|
+
let best = 0;
|
|
2133
|
+
for (const p of prefixes) {
|
|
2134
|
+
const max = Math.min(buf$1.length, p.length - 1);
|
|
2135
|
+
for (let k = max; k > best; k--) if (buf$1.endsWith(p.slice(0, k))) {
|
|
2136
|
+
best = k;
|
|
2137
|
+
break;
|
|
2138
|
+
}
|
|
2139
|
+
}
|
|
2140
|
+
return best;
|
|
2141
|
+
};
|
|
2142
|
+
/** 扫描最早出现的帧:E(env 侧信道)先于 D 下发;D = settle,帧后字节丢弃。 */
|
|
2143
|
+
const consume = () => {
|
|
2144
|
+
if (done) return;
|
|
2145
|
+
let idx = -1;
|
|
2146
|
+
let isEnv = false;
|
|
2147
|
+
for (const p of prefixes) {
|
|
2148
|
+
const i = buf$1.indexOf(p);
|
|
2149
|
+
if (i >= 0 && (idx === -1 || i < idx)) {
|
|
2150
|
+
idx = i;
|
|
2151
|
+
isEnv = p === prefixE;
|
|
2152
|
+
}
|
|
2153
|
+
}
|
|
2154
|
+
if (idx < 0) {
|
|
2155
|
+
const keep = holdback();
|
|
2156
|
+
emit(buf$1.slice(0, buf$1.length - keep));
|
|
2157
|
+
buf$1 = buf$1.slice(buf$1.length - keep);
|
|
2158
|
+
return;
|
|
2159
|
+
}
|
|
2160
|
+
emit(buf$1.slice(0, idx));
|
|
2161
|
+
const rest = buf$1.slice(idx + (isEnv ? prefixE.length : prefix.length));
|
|
2162
|
+
if (isEnv) {
|
|
2163
|
+
const m$1 = ENV_BODY_RE.exec(rest);
|
|
2164
|
+
if (m$1 === null) {
|
|
2165
|
+
buf$1 = buf$1.slice(idx);
|
|
2166
|
+
return;
|
|
2031
2167
|
}
|
|
2032
2168
|
if (m$1[1].length > 0) events.onEnv?.(m$1[1]);
|
|
2033
|
-
buf = rest.slice(m$1[0].length);
|
|
2169
|
+
buf$1 = rest.slice(m$1[0].length);
|
|
2034
2170
|
consume();
|
|
2035
2171
|
return;
|
|
2036
2172
|
}
|
|
2037
2173
|
const m = FRAME_BODY_RE.exec(rest);
|
|
2038
2174
|
if (m === null) {
|
|
2039
|
-
buf = buf.slice(idx);
|
|
2175
|
+
buf$1 = buf$1.slice(idx);
|
|
2040
2176
|
return;
|
|
2041
2177
|
}
|
|
2042
2178
|
events.onFrame({
|
|
@@ -2044,16 +2180,16 @@ function createNonceFrameParser(nonce, events) {
|
|
|
2044
2180
|
cwd: m[2].length > 0 ? m[2] : null
|
|
2045
2181
|
});
|
|
2046
2182
|
done = true;
|
|
2047
|
-
buf = "";
|
|
2183
|
+
buf$1 = "";
|
|
2048
2184
|
};
|
|
2049
2185
|
return {
|
|
2050
2186
|
feed(chunk) {
|
|
2051
|
-
buf += chunk;
|
|
2187
|
+
buf$1 += chunk;
|
|
2052
2188
|
consume();
|
|
2053
2189
|
},
|
|
2054
2190
|
flush() {
|
|
2055
|
-
if (buf.length > 0) events.onOutput?.(buf);
|
|
2056
|
-
buf = "";
|
|
2191
|
+
if (buf$1.length > 0) events.onOutput?.(buf$1);
|
|
2192
|
+
buf$1 = "";
|
|
2057
2193
|
}
|
|
2058
2194
|
};
|
|
2059
2195
|
}
|
|
@@ -2096,6 +2232,67 @@ function diffEnv(prev, next) {
|
|
|
2096
2232
|
};
|
|
2097
2233
|
}
|
|
2098
2234
|
|
|
2235
|
+
//#endregion
|
|
2236
|
+
//#region src/remote/trace.ts
|
|
2237
|
+
/**
|
|
2238
|
+
* R1 定位轮插桩(2026-11-01):`SUPERD_REMOTE_TRACE=<file>` 时输出 JSONL 事件,否则全 no-op。
|
|
2239
|
+
* 动机:rig-only「PTY background + startup stdin」丢失只在 rig 长驻进程复现(bg-read 形状 13/13),
|
|
2240
|
+
* 本地全组合 0 复现——必须在实际 rig 宿主进程内拿到第一手事件流。
|
|
2241
|
+
*
|
|
2242
|
+
* 设计约束:
|
|
2243
|
+
* - 热路径(write/onOutput)零同步 IO:事件先进内存缓冲,异步 flush(settle/dispose/满 200 条)。
|
|
2244
|
+
* - env 未设置时每个入口都是一次函数调用 + 立即 return,不改变交付语义。
|
|
2245
|
+
* - 失败静默(trace 永不破坏工具行为);缓冲上限 5000 条防失控。
|
|
2246
|
+
*/
|
|
2247
|
+
const TRACE_PATH = process.env.SUPERD_REMOTE_TRACE ?? "";
|
|
2248
|
+
const ENABLED = TRACE_PATH !== "";
|
|
2249
|
+
let buf = [];
|
|
2250
|
+
let flushing = false;
|
|
2251
|
+
function serialize(ev, fields) {
|
|
2252
|
+
let s;
|
|
2253
|
+
try {
|
|
2254
|
+
s = JSON.stringify({
|
|
2255
|
+
t: Date.now(),
|
|
2256
|
+
mono: Number(process.hrtime.bigint() / 1000000n),
|
|
2257
|
+
ev,
|
|
2258
|
+
...fields
|
|
2259
|
+
});
|
|
2260
|
+
} catch {
|
|
2261
|
+
s = JSON.stringify({
|
|
2262
|
+
t: Date.now(),
|
|
2263
|
+
ev,
|
|
2264
|
+
err: "unserializable-fields"
|
|
2265
|
+
});
|
|
2266
|
+
}
|
|
2267
|
+
return s ?? `{\"t\":${Date.now()},\"ev\":${JSON.stringify(ev)},\"err\":\"null\"}`;
|
|
2268
|
+
}
|
|
2269
|
+
function trace(ev, fields = {}) {
|
|
2270
|
+
if (!ENABLED) return;
|
|
2271
|
+
buf.push(serialize(ev, fields));
|
|
2272
|
+
if (buf.length > 5e3) buf.splice(0, buf.length - 5e3);
|
|
2273
|
+
if (buf.length >= 200) traceFlush();
|
|
2274
|
+
}
|
|
2275
|
+
async function traceFlush() {
|
|
2276
|
+
if (!ENABLED || flushing || buf.length === 0) return;
|
|
2277
|
+
flushing = true;
|
|
2278
|
+
const out = buf.splice(0).join("\n") + "\n";
|
|
2279
|
+
try {
|
|
2280
|
+
const [{ appendFile }, { dirname: dirname$1 }] = await Promise.all([import("node:fs/promises"), import("node:path")]);
|
|
2281
|
+
const dir = dirname$1(TRACE_PATH);
|
|
2282
|
+
try {
|
|
2283
|
+
await (await import("node:fs/promises")).mkdir(dir, { recursive: true });
|
|
2284
|
+
} catch {}
|
|
2285
|
+
await appendFile(TRACE_PATH, out);
|
|
2286
|
+
} catch {}
|
|
2287
|
+
flushing = false;
|
|
2288
|
+
if (buf.length > 0) traceFlush();
|
|
2289
|
+
}
|
|
2290
|
+
/** 事件里的字节安全截断表示(JSON 转义 + 长度上限)。 */
|
|
2291
|
+
function traceRepr(s, max) {
|
|
2292
|
+
const j = JSON.stringify(s) ?? "null";
|
|
2293
|
+
return j.length > max ? `${j.slice(0, max)}…` : j;
|
|
2294
|
+
}
|
|
2295
|
+
|
|
2099
2296
|
//#endregion
|
|
2100
2297
|
//#region src/remote/pty-session.ts
|
|
2101
2298
|
/** spec §五.2 逐字。 */
|
|
@@ -2107,29 +2304,27 @@ function displayKey(key) {
|
|
|
2107
2304
|
const raw = key.includes("|") ? key.slice(key.indexOf("|") + 1) : key;
|
|
2108
2305
|
return raw.startsWith("p:") ? `pty:${raw.slice(2)}` : raw;
|
|
2109
2306
|
}
|
|
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
|
-
};
|
|
2115
2307
|
const DEFAULT_INIT_TIMEOUT_SEC = 30;
|
|
2116
2308
|
const INTERRUPT_GRACE_MS = 3e3;
|
|
2309
|
+
/** F2(0.3.2-d):\x03 触发 ISIG 时内核 flush 输入队列——130-marker 必须等 flush 完成
|
|
2310
|
+
* 再入队,否则首字节被吞(实测 'rintf' not found → 会话死)。 */
|
|
2311
|
+
const MARKER_SETTLE_MS = 80;
|
|
2117
2312
|
/** pty 收集内存安全帽(Ruling P15,与 oneshot HARD_CAP 对齐):模型面只见尾窗,保留尾部。 */
|
|
2118
2313
|
const OUTPUT_HARD_CAP = 4e6;
|
|
2119
2314
|
var PtySession = class {
|
|
2120
2315
|
proc = null;
|
|
2121
2316
|
state = "cold";
|
|
2122
2317
|
startPromise = null;
|
|
2123
|
-
queue = Promise.resolve();
|
|
2124
2318
|
idleTimer = null;
|
|
2125
2319
|
onChunk = null;
|
|
2126
2320
|
deathWaiters = [];
|
|
2127
2321
|
diag = "";
|
|
2128
2322
|
inFlight = 0;
|
|
2323
|
+
cmdSeq = 0;
|
|
2129
2324
|
/** P4/P5:当前占用前台的 detached job(background/超时 handoff)。非 null 时新命令拒绝(Q6)。 */
|
|
2130
2325
|
detachedJob = null;
|
|
2131
|
-
/**
|
|
2132
|
-
|
|
2326
|
+
/** §3.2(2026-10-25):当前占用会话的命令——dispatch 同步占位,settle/cleanup 释放;input 的唯一写入对象。 */
|
|
2327
|
+
activeCommand = null;
|
|
2133
2328
|
/** env 侧信道基线(init E 帧);undefined = 侧信道不可用(不报假 delta)。 */
|
|
2134
2329
|
envBaseline;
|
|
2135
2330
|
/** idle 起点(ready / settle 时重置);null = 尚无 idle 时钟(cold 或已 dispose)。 */
|
|
@@ -2160,10 +2355,13 @@ var PtySession = class {
|
|
|
2160
2355
|
get detachedCommandId() {
|
|
2161
2356
|
return this.detachedJob?.commandId ?? null;
|
|
2162
2357
|
}
|
|
2163
|
-
/**
|
|
2358
|
+
/** handoff/card 面(2026-11-01 默认 PTY 轮):当前占用会话的命令与可选 job 句柄;无 = null。 */
|
|
2359
|
+
get active() {
|
|
2360
|
+
return this.activeCommand === null ? null : { ...this.activeCommand };
|
|
2361
|
+
}
|
|
2362
|
+
/** job 通道收养后回填 jobId(timeout handoff 时 registry id ≠ commandId);运行中的命令同步携带(stdin-only 写入回执用)。 */
|
|
2164
2363
|
attachJob(commandId, jobId) {
|
|
2165
|
-
|
|
2166
|
-
if (entry !== void 0) entry.jobId = jobId;
|
|
2364
|
+
if (this.activeCommand !== null && this.activeCommand.commandId === commandId) this.activeCommand.jobId = jobId;
|
|
2167
2365
|
}
|
|
2168
2366
|
/** Workbench 身份(§3):id + 可选 label + connection + byo 标记。 */
|
|
2169
2367
|
get identity() {
|
|
@@ -2175,10 +2373,6 @@ var PtySession = class {
|
|
|
2175
2373
|
builtOn: this.opts.builtOn === true
|
|
2176
2374
|
};
|
|
2177
2375
|
}
|
|
2178
|
-
/** 账本快照(session card 面,只读)。 */
|
|
2179
|
-
ledgerSnapshot() {
|
|
2180
|
-
return this.ledger;
|
|
2181
|
-
}
|
|
2182
2376
|
/** spawn 创建原文(仅 BYO 会话;v2 起仅存档,不参与复用判定)。 */
|
|
2183
2377
|
get origin() {
|
|
2184
2378
|
return this.opts.origin;
|
|
@@ -2197,36 +2391,67 @@ var PtySession = class {
|
|
|
2197
2391
|
async warm() {
|
|
2198
2392
|
await this.start();
|
|
2199
2393
|
}
|
|
2200
|
-
/**
|
|
2201
|
-
*
|
|
2202
|
-
*
|
|
2203
|
-
*
|
|
2394
|
+
/** 同一会话命令占位制(2026-10-25 interactive-stdin 轮):dispatch 入口**同步**建立
|
|
2395
|
+
* 命令占位(activeCommand + inFlight + 账本条目)——上一条命令仍在运行(同步等待 /
|
|
2396
|
+
* 等输入 / detached job)时新 cmd 立即 E_SESSION_BUSY;原「纯同步并发排 FIFO」语义
|
|
2397
|
+
* 废除(排队会让等输入的命令与补输入动作互相卡死,也给 busy 留下绕行窗口)。
|
|
2398
|
+
* P4/P5:超时不杀命令而是 handoff 成 running job;background 立即回执;P8 显式
|
|
2399
|
+
* interrupt 保留在 job.interrupt;死亡/冷态先重启(reconnect 语义不变)。 */
|
|
2204
2400
|
dispatch(cmd, dOpts) {
|
|
2401
|
+
if (this.state === "disposed") return Promise.reject(/* @__PURE__ */ new Error(`[E_SESSION_DISPOSED] remote pty session '${this.opts.id}' was disposed before dispatch`));
|
|
2402
|
+
if (this.activeCommand !== null) {
|
|
2403
|
+
const handle = this.activeCommand.jobId ?? this.activeCommand.commandId;
|
|
2404
|
+
return Promise.reject(/* @__PURE__ */ new Error(`[E_SESSION_BUSY] remote: pty session '${this.opts.id}' still runs a command (${this.activeCommand.commandId}) — feed it more stdin with {action:"exec", target:..., stdin:...} (stdin-only write), follow its output via job_output ${handle} (a synchronous exec hands its job off there on timeout), cancel with job_kill ${handle}, or address other work to another session / oneshot`));
|
|
2405
|
+
}
|
|
2406
|
+
const commandId = dOpts.commandId;
|
|
2407
|
+
this.activeCommand = {
|
|
2408
|
+
commandId,
|
|
2409
|
+
...dOpts.jobId !== void 0 ? { jobId: dOpts.jobId } : {}
|
|
2410
|
+
};
|
|
2411
|
+
this.inFlight++;
|
|
2412
|
+
this.cmdSeq++;
|
|
2205
2413
|
const attempt = async () => {
|
|
2206
2414
|
const started = Date.now();
|
|
2207
2415
|
const reconnected = this.state === "dead";
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
commandId,
|
|
2214
|
-
summary: summaryOf(cmd),
|
|
2215
|
-
exit: null,
|
|
2216
|
-
durationMs: 0,
|
|
2217
|
-
running: true
|
|
2416
|
+
/** 占位撤销:命令从未真正启动(建连失败 / 建连中被 dispose)——释放 busy 与计数,
|
|
2417
|
+
* 撤掉账本条目(与旧时序等价:条目原本在 start 成功之后才建立)。 */
|
|
2418
|
+
const abandon = () => {
|
|
2419
|
+
this.activeCommand = null;
|
|
2420
|
+
this.inFlight--;
|
|
2218
2421
|
};
|
|
2219
|
-
this.
|
|
2220
|
-
|
|
2221
|
-
|
|
2422
|
+
if (this.state !== "ready") try {
|
|
2423
|
+
await this.start();
|
|
2424
|
+
} catch (error) {
|
|
2425
|
+
abandon();
|
|
2426
|
+
throw error;
|
|
2427
|
+
}
|
|
2428
|
+
if (this.state === "disposed") {
|
|
2429
|
+
abandon();
|
|
2430
|
+
throw new Error(`[E_SESSION_DISPOSED] remote pty session '${this.opts.id}' was disposed before dispatch`);
|
|
2431
|
+
}
|
|
2222
2432
|
return await new Promise((resolve$1) => {
|
|
2223
2433
|
const nonce = genNonce();
|
|
2224
2434
|
const ring = new Utf16TailRing(OUTPUT_HARD_CAP);
|
|
2435
|
+
let traceChunkSeq = 0;
|
|
2436
|
+
trace("disp", {
|
|
2437
|
+
s: this.opts.id,
|
|
2438
|
+
key: this.opts.key,
|
|
2439
|
+
commandId,
|
|
2440
|
+
bg: dOpts.background === true,
|
|
2441
|
+
stdinGiven: dOpts.stdin !== void 0,
|
|
2442
|
+
cmdLen: cmd.length,
|
|
2443
|
+
stdinLen: dOpts.stdin?.length ?? 0,
|
|
2444
|
+
cmdRepr: traceRepr(cmd, 140),
|
|
2445
|
+
stdinRepr: traceRepr(dOpts.stdin ?? "", 60),
|
|
2446
|
+
state: this.state,
|
|
2447
|
+
priorCmds: this.cmdSeq
|
|
2448
|
+
});
|
|
2225
2449
|
let interrupted = false;
|
|
2226
2450
|
let settled = false;
|
|
2227
2451
|
let jobSettled = false;
|
|
2228
2452
|
let interruptTimer;
|
|
2229
2453
|
let killTimer;
|
|
2454
|
+
let markerTimer;
|
|
2230
2455
|
let jobDone = null;
|
|
2231
2456
|
let envNext;
|
|
2232
2457
|
const detach = () => {
|
|
@@ -2243,6 +2468,13 @@ var PtySession = class {
|
|
|
2243
2468
|
};
|
|
2244
2469
|
this.detachedJob = job;
|
|
2245
2470
|
settled = true;
|
|
2471
|
+
trace("detach", {
|
|
2472
|
+
s: this.opts.id,
|
|
2473
|
+
commandId,
|
|
2474
|
+
outLen: ring.text.length,
|
|
2475
|
+
tail: traceRepr(ring.text, 220)
|
|
2476
|
+
});
|
|
2477
|
+
traceFlush();
|
|
2246
2478
|
resolve$1({
|
|
2247
2479
|
output: ring.text,
|
|
2248
2480
|
outputStats: { stdout: ring.stats() },
|
|
@@ -2260,13 +2492,25 @@ var PtySession = class {
|
|
|
2260
2492
|
const cleanup = () => {
|
|
2261
2493
|
this.onChunk = null;
|
|
2262
2494
|
this.detachedJob = null;
|
|
2495
|
+
this.activeCommand = null;
|
|
2263
2496
|
this.deathWaiters = this.deathWaiters.filter((w) => w !== deathWaiter);
|
|
2264
2497
|
if (interruptTimer !== void 0) clearTimeout(interruptTimer);
|
|
2265
2498
|
if (killTimer !== void 0) clearTimeout(killTimer);
|
|
2499
|
+
if (markerTimer !== void 0) clearTimeout(markerTimer);
|
|
2266
2500
|
};
|
|
2267
2501
|
const settle = (r) => {
|
|
2268
2502
|
if (jobSettled) return;
|
|
2269
2503
|
jobSettled = true;
|
|
2504
|
+
trace("settle", {
|
|
2505
|
+
s: this.opts.id,
|
|
2506
|
+
commandId,
|
|
2507
|
+
exit: r.exit,
|
|
2508
|
+
timedOut: r.timedOut,
|
|
2509
|
+
cwd: r.cwd,
|
|
2510
|
+
outLen: r.output.length,
|
|
2511
|
+
tail: traceRepr(r.output, 260)
|
|
2512
|
+
});
|
|
2513
|
+
traceFlush();
|
|
2270
2514
|
cleanup();
|
|
2271
2515
|
this.inFlight--;
|
|
2272
2516
|
this.idleSince = Date.now();
|
|
@@ -2274,9 +2518,6 @@ var PtySession = class {
|
|
|
2274
2518
|
if (r.cwd !== null) this.lastCwdSeen = r.cwd;
|
|
2275
2519
|
const envDelta = envNext !== void 0 ? diffEnv(this.envBaseline, envNext) : void 0;
|
|
2276
2520
|
if (envNext !== void 0) this.envBaseline = envNext;
|
|
2277
|
-
entry.exit = r.exit;
|
|
2278
|
-
entry.durationMs = Date.now() - started;
|
|
2279
|
-
entry.running = false;
|
|
2280
2521
|
const full = {
|
|
2281
2522
|
...r,
|
|
2282
2523
|
outputStats: { stdout: ring.stats() },
|
|
@@ -2302,6 +2543,13 @@ var PtySession = class {
|
|
|
2302
2543
|
const parser = createNonceFrameParser(nonce, {
|
|
2303
2544
|
onOutput: (t) => {
|
|
2304
2545
|
ring.push(t);
|
|
2546
|
+
if (traceChunkSeq < 60) trace("out", {
|
|
2547
|
+
s: this.opts.id,
|
|
2548
|
+
commandId,
|
|
2549
|
+
n: traceChunkSeq++,
|
|
2550
|
+
len: t.length,
|
|
2551
|
+
text: traceRepr(t, 180)
|
|
2552
|
+
});
|
|
2305
2553
|
},
|
|
2306
2554
|
onEnv: (b64) => {
|
|
2307
2555
|
const m = decodeEnvSnapshot(b64);
|
|
@@ -2317,7 +2565,9 @@ var PtySession = class {
|
|
|
2317
2565
|
const twoStage = () => {
|
|
2318
2566
|
interrupted = true;
|
|
2319
2567
|
this.write("");
|
|
2320
|
-
|
|
2568
|
+
markerTimer = setTimeout(() => {
|
|
2569
|
+
this.write(`printf '\\033]133;D;${nonce};130;%s\\007' "$PWD"\n`);
|
|
2570
|
+
}, MARKER_SETTLE_MS);
|
|
2321
2571
|
killTimer = setTimeout(() => {
|
|
2322
2572
|
this.killTree();
|
|
2323
2573
|
this.markDead();
|
|
@@ -2330,8 +2580,41 @@ var PtySession = class {
|
|
|
2330
2580
|
}, INTERRUPT_GRACE_MS);
|
|
2331
2581
|
};
|
|
2332
2582
|
this.onChunk = (c) => parser.feed(c);
|
|
2333
|
-
|
|
2334
|
-
|
|
2583
|
+
const startup = dOpts.stdin === void 0 ? wrapPtyCommand(cmd, nonce) : wrapPtyCommand(cmd, nonce) + dOpts.stdin;
|
|
2584
|
+
const wStdin = this.proc?.stdin;
|
|
2585
|
+
if (wStdin?.writable === true) {
|
|
2586
|
+
const wl0 = wStdin.writableLength;
|
|
2587
|
+
let wret;
|
|
2588
|
+
try {
|
|
2589
|
+
wret = wStdin.write(startup);
|
|
2590
|
+
} catch (error) {
|
|
2591
|
+
trace("werr", {
|
|
2592
|
+
s: this.opts.id,
|
|
2593
|
+
commandId,
|
|
2594
|
+
msg: error instanceof Error ? error.message : String(error)
|
|
2595
|
+
});
|
|
2596
|
+
}
|
|
2597
|
+
if (wret !== void 0) {
|
|
2598
|
+
trace("w1", {
|
|
2599
|
+
s: this.opts.id,
|
|
2600
|
+
commandId,
|
|
2601
|
+
bytes: Buffer$1.byteLength(startup),
|
|
2602
|
+
ret: wret,
|
|
2603
|
+
wl0,
|
|
2604
|
+
wl1: wStdin.writableLength,
|
|
2605
|
+
payloadTail: traceRepr(startup.slice(-90), 120)
|
|
2606
|
+
});
|
|
2607
|
+
if (wret === false) wStdin.once("drain", () => {
|
|
2608
|
+
trace("drain", {
|
|
2609
|
+
s: this.opts.id,
|
|
2610
|
+
commandId
|
|
2611
|
+
});
|
|
2612
|
+
});
|
|
2613
|
+
}
|
|
2614
|
+
} else trace("wskip", {
|
|
2615
|
+
s: this.opts.id,
|
|
2616
|
+
commandId
|
|
2617
|
+
});
|
|
2335
2618
|
this.deathWaiters.push(deathWaiter);
|
|
2336
2619
|
if (dOpts.background === true) {
|
|
2337
2620
|
detach();
|
|
@@ -2340,9 +2623,39 @@ var PtySession = class {
|
|
|
2340
2623
|
interruptTimer = setTimeout(() => detach(), dOpts.timeoutSec === void 0 ? 12e4 : dOpts.timeoutSec * 1e3);
|
|
2341
2624
|
});
|
|
2342
2625
|
};
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
2626
|
+
return attempt();
|
|
2627
|
+
}
|
|
2628
|
+
/** §3.1(2026-10-25)interactive-stdin:向当前运行中的命令追加 stdin——只写既有通道
|
|
2629
|
+
* 与身份,不新建命令、账本条目或输出游标;不重连、不重放(dead/disposed 明确拒绝)。
|
|
2630
|
+
* 校验与写入全同步:多次 input 按调用顺序整段写入不交错;写入失败如实抛错(私有
|
|
2631
|
+
* write() 的静默忽略不适用于回执语义)。stdin:"" 是零字节写入,不是 EOF。 */
|
|
2632
|
+
input(options) {
|
|
2633
|
+
const fail = (code, hint) => Promise.reject(/* @__PURE__ */ new Error(`${code} remote: ${hint}`));
|
|
2634
|
+
if (this.state === "disposed") return fail("[E_SESSION_DISPOSED]", `pty session '${displayKey(this.opts.key)}' was disposed — input never reaches a disposed session`);
|
|
2635
|
+
if (this.state === "dead") return fail("[E_SESSION_NOT_RUNNING]", `pty session '${displayKey(this.opts.key)}' died — input never reconnects or replays; start a new command (exec reconnects a fresh shell) and re-send only what is still needed`);
|
|
2636
|
+
if (this.state === "starting") return fail("[E_SESSION_NOT_READY]", `pty session '${displayKey(this.opts.key)}' is still initializing — wait for the exec receipt, then input`);
|
|
2637
|
+
const active = this.activeCommand;
|
|
2638
|
+
if (active === null) return fail("[E_SESSION_NOT_RUNNING]", `pty session '${displayKey(this.opts.key)}' has no running command (state ${this.state}) — input writes into a command started by exec; it is never an implicit shell command`);
|
|
2639
|
+
if (options.commandId !== void 0 && options.commandId !== active.commandId) return fail("[E_COMMAND_MISMATCH]", `commandId ${JSON.stringify(options.commandId)} does not match the running command ${active.commandId} — input aimed at an earlier prompt is never written to the next command`);
|
|
2640
|
+
const stdin = this.proc?.stdin;
|
|
2641
|
+
if (stdin?.writable !== true) return fail("[E_SESSION_WRITE_FAILED]", `pty session '${displayKey(this.opts.key)}' has no writable stdin channel (state ${this.state}) — follow the running command via job_output for its settled result`);
|
|
2642
|
+
try {
|
|
2643
|
+
stdin.write(options.stdin);
|
|
2644
|
+
trace("in", {
|
|
2645
|
+
s: this.opts.id,
|
|
2646
|
+
commandId: active.commandId,
|
|
2647
|
+
bytes: Buffer$1.byteLength(options.stdin, "utf8"),
|
|
2648
|
+
repr: traceRepr(options.stdin, 60)
|
|
2649
|
+
});
|
|
2650
|
+
} catch (error) {
|
|
2651
|
+
return fail("[E_SESSION_WRITE_FAILED]", `writing stdin to pty session '${displayKey(this.opts.key)}' failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
2652
|
+
}
|
|
2653
|
+
return Promise.resolve({
|
|
2654
|
+
session: displayKey(this.opts.key),
|
|
2655
|
+
commandId: active.commandId,
|
|
2656
|
+
...active.jobId !== void 0 ? { jobId: active.jobId } : {},
|
|
2657
|
+
writtenBytes: Buffer$1.byteLength(options.stdin, "utf8")
|
|
2658
|
+
});
|
|
2346
2659
|
}
|
|
2347
2660
|
/** TTL / row teardown:尽力 exit,1s 后杀组,出池。 */
|
|
2348
2661
|
dispose() {
|
|
@@ -2350,7 +2663,13 @@ var PtySession = class {
|
|
|
2350
2663
|
this.clearIdleTimer();
|
|
2351
2664
|
this.clearDeathTimer();
|
|
2352
2665
|
this.state = "disposed";
|
|
2666
|
+
trace("dispose", {
|
|
2667
|
+
s: this.opts.id,
|
|
2668
|
+
key: this.opts.key
|
|
2669
|
+
});
|
|
2670
|
+
traceFlush();
|
|
2353
2671
|
this.startPromise = null;
|
|
2672
|
+
this.detachedJob?.interrupt();
|
|
2354
2673
|
const proc = this.proc;
|
|
2355
2674
|
const close = this.procClose;
|
|
2356
2675
|
const group = this.reapGroup(proc?.pid, 1e3, close);
|
|
@@ -2361,7 +2680,7 @@ var PtySession = class {
|
|
|
2361
2680
|
]).then(() => {
|
|
2362
2681
|
if (this.proc === proc) this.proc = null;
|
|
2363
2682
|
});
|
|
2364
|
-
this.write("exit\n");
|
|
2683
|
+
if (this.detachedJob === null) this.write("exit\n");
|
|
2365
2684
|
this.opts.onDead?.();
|
|
2366
2685
|
return this.disposal;
|
|
2367
2686
|
}
|
|
@@ -2395,6 +2714,12 @@ var PtySession = class {
|
|
|
2395
2714
|
detached: true
|
|
2396
2715
|
});
|
|
2397
2716
|
this.proc = proc;
|
|
2717
|
+
trace("start", {
|
|
2718
|
+
s: this.opts.id,
|
|
2719
|
+
key: this.opts.key,
|
|
2720
|
+
argv: this.opts.argv,
|
|
2721
|
+
pid: proc.pid
|
|
2722
|
+
});
|
|
2398
2723
|
proc.stdout.setEncoding("utf8");
|
|
2399
2724
|
proc.stdout.on("data", (d) => {
|
|
2400
2725
|
this.onChunk?.(d);
|
|
@@ -2402,6 +2727,9 @@ var PtySession = class {
|
|
|
2402
2727
|
proc.stderr.setEncoding("utf8").on("data", (d) => {
|
|
2403
2728
|
this.diag = (this.diag + d).slice(-2e3);
|
|
2404
2729
|
});
|
|
2730
|
+
proc.stdin.on("error", (err) => {
|
|
2731
|
+
this.diag = (this.diag + `\n[remote: stdin write failed: ${err.message}]`).slice(-2e3);
|
|
2732
|
+
});
|
|
2405
2733
|
this.procClose = new Promise((resolve$1) => {
|
|
2406
2734
|
proc.once("close", () => {
|
|
2407
2735
|
this.markDead();
|
|
@@ -2453,6 +2781,11 @@ var PtySession = class {
|
|
|
2453
2781
|
return;
|
|
2454
2782
|
}
|
|
2455
2783
|
this.state = "dead";
|
|
2784
|
+
trace("dead", {
|
|
2785
|
+
s: this.opts.id,
|
|
2786
|
+
diagTail: this.diag.slice(-500)
|
|
2787
|
+
});
|
|
2788
|
+
traceFlush();
|
|
2456
2789
|
this.startPromise = null;
|
|
2457
2790
|
this.clearIdleTimer();
|
|
2458
2791
|
this.armDeathReaper();
|
|
@@ -2665,42 +2998,20 @@ function parseTargetForm(target) {
|
|
|
2665
2998
|
form: "hub",
|
|
2666
2999
|
machine: segs[0]
|
|
2667
3000
|
};
|
|
2668
|
-
if (segs.length
|
|
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)`);
|
|
3001
|
+
if (segs.length > 1) throw new TargetFormatError(`[E_TARGET_FORMAT] remote: ${JSON.stringify(target)} — hub takes a machine id (hub:<m4>); a hub machine's exec opens its default PTY, and handoff detaches it as pty:<id>`);
|
|
2673
3002
|
}
|
|
2674
3003
|
for (const transport of TRANSPORTS) {
|
|
2675
3004
|
if (!target.startsWith(`${transport}:`)) continue;
|
|
2676
3005
|
const rest = target.slice(transport.length + 1);
|
|
2677
3006
|
if (rest.length === 0) throw new TargetFormatError(`[E_TARGET_FORMAT] remote: ${JSON.stringify(target)} — ${transport} takes a name`);
|
|
2678
|
-
|
|
2679
|
-
|
|
3007
|
+
if (/(^|:)pty(:|$)/.test(rest)) throw new TargetFormatError(`[E_TARGET_FORMAT] remote: ${JSON.stringify(target)} — the :pty creation suffix is gone; a machine's exec opens its default PTY and handoff detaches it as pty:<id>`);
|
|
3008
|
+
return {
|
|
2680
3009
|
form: "direct",
|
|
2681
3010
|
transport,
|
|
2682
3011
|
name: rest
|
|
2683
3012
|
};
|
|
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
3013
|
}
|
|
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
|
|
3014
|
+
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>; a machine's exec opens its default PTY and handoff detaches it (the old :pty creation suffix is gone)`);
|
|
2704
3015
|
return {
|
|
2705
3016
|
form: "bare",
|
|
2706
3017
|
name: target
|
|
@@ -2844,7 +3155,11 @@ async function probeTarget(plan, opts = {}) {
|
|
|
2844
3155
|
const runner = opts.runner ?? runOneShot;
|
|
2845
3156
|
const started = Date.now();
|
|
2846
3157
|
const r = await runner(plan.kind === "ssh" ? sshArgv(plan.connection ?? { host: plan.host }, {
|
|
2847
|
-
extraArgs: [
|
|
3158
|
+
extraArgs: [
|
|
3159
|
+
...opts.muxArgvFor?.(plan) ?? [],
|
|
3160
|
+
"-o",
|
|
3161
|
+
`ConnectTimeout=${ct}`
|
|
3162
|
+
],
|
|
2848
3163
|
command: "true"
|
|
2849
3164
|
}) : plan.kind === "docker" ? [
|
|
2850
3165
|
"docker",
|
|
@@ -2903,12 +3218,6 @@ function renderProbe(p) {
|
|
|
2903
3218
|
case "container": return `probe: container ${p.status} (just now, on demand)`;
|
|
2904
3219
|
}
|
|
2905
3220
|
}
|
|
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")}`;
|
|
2911
|
-
}
|
|
2912
3221
|
|
|
2913
3222
|
//#endregion
|
|
2914
3223
|
//#region src/remote/ssh-config.ts
|
|
@@ -3059,7 +3368,7 @@ async function scanLocal(opts = {}) {
|
|
|
3059
3368
|
};
|
|
3060
3369
|
}
|
|
3061
3370
|
/** 统一池视图的 roster 呈现:机器三段 + BYO ssh 同位(built on)+ pty 会话段(caller-scoped)。 */
|
|
3062
|
-
function renderRoster(scan, entries, sessions, hubTargets) {
|
|
3371
|
+
function renderRoster(scan, entries, sessions, hubTargets, defaults) {
|
|
3063
3372
|
const lines = [];
|
|
3064
3373
|
if (hubTargets !== null) lines.push(`hub machines: ${hubTargets.map((t) => `hub:${t.id} (${t.label}${t.execEnabled ? "" : "; exec disabled"})`).join(", ") || "none"}`);
|
|
3065
3374
|
if (scan.ssh.error !== void 0) lines.push(`ssh hosts: no readable ~/.ssh/config (${scan.ssh.error})`);
|
|
@@ -3081,7 +3390,7 @@ function renderRoster(scan, entries, sessions, hubTargets) {
|
|
|
3081
3390
|
const id = s.identity;
|
|
3082
3391
|
const snap = s.snapshot;
|
|
3083
3392
|
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}]`;
|
|
3393
|
+
return `pty:${id.id}${id.label !== void 0 ? ` (label ${id.label})` : ""} ${id.connection}${id.byo ? " (byo-pty)" : ""} [${state}${defaults?.get(id.id) !== void 0 ? `; default on ${defaults.get(id.id)}` : ""}]`;
|
|
3085
3394
|
}).join(" · ")}` : "pty sessions: none");
|
|
3086
3395
|
return lines.join("\n");
|
|
3087
3396
|
}
|
|
@@ -3091,12 +3400,28 @@ function renderRoster(scan, entries, sessions, hubTargets) {
|
|
|
3091
3400
|
/** R1:idle TTL 15 min——idle 随活动重置,在用会话永不超时(PtySession 双条件)。 */
|
|
3092
3401
|
const REMOTE_PTY_IDLE_TTL_SEC = 900;
|
|
3093
3402
|
/** P3(user 2026-10-19):每 agent 10 个 PTY 会话,跨所有 connection 统一计;仅 error message 披露。 */
|
|
3094
|
-
|
|
3403
|
+
/** 二/三轮裁决(2026-11-01):每 Agent session 全局 30 个 active PTY(默认/handoff/BYO 全计入);
|
|
3404
|
+
* 仅触达上限时的错误回执与 roster 可见性披露,任何模型可见 surface 不预先公布。 */
|
|
3405
|
+
const REMOTE_PTY_SESSION_LIMIT = 30;
|
|
3406
|
+
/** hub 线 one-shot 监督披露(0.3.2-c):对端 = 我们自己的 hub server(bash -lc 于独立进程组,
|
|
3407
|
+
* Node 原生 TERM→2s→KILL + 输出环 + 租约生命周期);superd-run helper 是 dumb-pipe 专用物,
|
|
3408
|
+
* 从不经 hub 上传(non-goal,docs/10_plans/2026-10-25-hub-remote-parity.md §3 O1/O8)。 */
|
|
3409
|
+
const HUB_RUNNER = "hub-lease (peer bash -lc, peer Node supervision)";
|
|
3410
|
+
/** hub PTY 泵资产:pump 与本模块同目录(npm 包与 Super 闭包同构,helper.ts 同款纪律)。
|
|
3411
|
+
* ws 模块路径由 transport 侧(superd-hub 上下文,分叉的 proven 解析链)解析后经
|
|
3412
|
+
* ptyEndpoint 传入——闭包自身子树无可解析的 ws,按名解析不在闭包里做(0.3.2-c 定稿)。 */
|
|
3413
|
+
function hubPtyAssets() {
|
|
3414
|
+
return { pumpPath: join(dirname(fileURLToPath(import.meta.url)), "hub-pty-pump.mjs") };
|
|
3415
|
+
}
|
|
3095
3416
|
/** 账本键与 jobId 同构(同格式 `<kind>-<16hex>`);background 通道预传入 registry id 时两者字面相等。 */
|
|
3096
3417
|
const genCommandId = () => `remote-${randomUUID().replace(/-/g, "").slice(0, 16)}`;
|
|
3097
3418
|
/** 双轨调度器:参数校验 → 目标语法(parseTargetForm)→ 统一 label 池 → oneshot 子进程 / pty 会话池。 */
|
|
3098
3419
|
var RemoteDriver = class {
|
|
3099
3420
|
pool;
|
|
3421
|
+
/** 默认 PTY 槽(spec remote-default-pty §1):sessionKey → 归一 machineKey → pool key。
|
|
3422
|
+
* 会话本体是普通 PtySession(id 创建即认领,roster 全可见);槽登记在 dispose/TTL 后悬空,
|
|
3423
|
+
* 取槽时经 pool.get(disposed 视同出池)惰性清理。 */
|
|
3424
|
+
defaultSlots = /* @__PURE__ */ new Map();
|
|
3100
3425
|
labels = new LabelPool();
|
|
3101
3426
|
hubOwner = randomUUID();
|
|
3102
3427
|
hubProviders = /* @__PURE__ */ new Set();
|
|
@@ -3109,8 +3434,10 @@ var RemoteDriver = class {
|
|
|
3109
3434
|
runnerOf = /* @__PURE__ */ new WeakMap();
|
|
3110
3435
|
disposal;
|
|
3111
3436
|
ptyIdleTtlSec;
|
|
3437
|
+
mux;
|
|
3112
3438
|
constructor(opts = {}) {
|
|
3113
3439
|
this.opts = opts;
|
|
3440
|
+
this.mux = opts.sshMux ?? new SshMux();
|
|
3114
3441
|
this.ptyIdleTtlSec = opts.idleTtlSec ?? REMOTE_PTY_IDLE_TTL_SEC;
|
|
3115
3442
|
this.pool = new PtyPool({ idleTtlSec: this.ptyIdleTtlSec });
|
|
3116
3443
|
}
|
|
@@ -3149,38 +3476,24 @@ var RemoteDriver = class {
|
|
|
3149
3476
|
const hasSsh = params.ssh !== void 0;
|
|
3150
3477
|
if (hasSpawn && hasSsh) throw new Error("[E_PARAMS] remote: spawn and ssh are two different BYO entry points — pass one, not both");
|
|
3151
3478
|
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"
|
|
3479
|
+
const sshForm = form.form === "ssh-prefix" || form.form === "empty" || form.form === "bare" || form.form === "direct" && form.transport === "ssh";
|
|
3153
3480
|
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>,
|
|
3481
|
+
if (hasSsh && !sshForm) throw new Error("[E_PARAMS] remote: ssh registration pairs with ssh-form targets only (ssh, ssh:<label>, a bare label, or no target)");
|
|
3155
3482
|
}
|
|
3156
3483
|
async execute(params, form, callCtx) {
|
|
3157
3484
|
const timeoutSec = params.timeout ?? this.opts.execTimeoutSec ?? 120;
|
|
3158
|
-
const cap = this.opts.maxOutputChars ?? 3e4;
|
|
3159
3485
|
const sessionKey = callCtx.sessionKey ?? "no-session";
|
|
3160
3486
|
const hasCmd = params.cmd !== void 0 && params.cmd.length > 0;
|
|
3161
|
-
params.spawn !== void 0 && params.spawn.length;
|
|
3162
|
-
|
|
3487
|
+
const hasSpawn = params.spawn !== void 0 && params.spawn.length > 0;
|
|
3488
|
+
const bareHitsPty = form.form === "bare" && this.labels.resolve(form.name, sessionKey)?.kind === "pty";
|
|
3489
|
+
if ((form.form === "direct" || form.form === "hub" || form.form === "bare" && !bareHitsPty) && params.mode !== "oneshot" && params.ssh === void 0 && !hasSpawn) {
|
|
3490
|
+
const { session } = await this.defaultSlot(form, sessionKey);
|
|
3491
|
+
if (!hasCmd) return this.cardReceipt(session);
|
|
3492
|
+
return await this.dispatchPty(session, params, callCtx, timeoutSec);
|
|
3493
|
+
}
|
|
3494
|
+
if (form.form === "hub") {
|
|
3163
3495
|
const hub = this.requireHub();
|
|
3164
3496
|
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
3497
|
const process$1 = this.own(await hub.start(form.machine, {
|
|
3185
3498
|
...params,
|
|
3186
3499
|
target: `hub:${form.machine}`,
|
|
@@ -3190,8 +3503,8 @@ var RemoteDriver = class {
|
|
|
3190
3503
|
if (this.disposed) process$1.cancel("owner disposed");
|
|
3191
3504
|
const result = await process$1.done;
|
|
3192
3505
|
const stdout = process$1.read("stdout", 0), stderr = process$1.read("stderr", 0);
|
|
3193
|
-
const out =
|
|
3194
|
-
const err =
|
|
3506
|
+
const out = disclose(stdout.text, stdout.stats);
|
|
3507
|
+
const err = disclose(stderr.text, stderr.stats);
|
|
3195
3508
|
return {
|
|
3196
3509
|
...result,
|
|
3197
3510
|
mode: "oneshot",
|
|
@@ -3199,6 +3512,7 @@ var RemoteDriver = class {
|
|
|
3199
3512
|
connection: `hub:${form.machine}`,
|
|
3200
3513
|
cwd: null,
|
|
3201
3514
|
reconnected: false,
|
|
3515
|
+
runner: HUB_RUNNER,
|
|
3202
3516
|
stdout: (!stdout.stats && stdout.lossy ? "[truncated: peer returned only a tail; exact total/retained/dropped unavailable]\n" : "") + out.text,
|
|
3203
3517
|
stderr: [err.text, stderr.diagnostic].filter(Boolean).join("\n"),
|
|
3204
3518
|
...out.stats ? { outputStats: {
|
|
@@ -3215,7 +3529,7 @@ var RemoteDriver = class {
|
|
|
3215
3529
|
await session.warm();
|
|
3216
3530
|
return this.warmReceipt(session);
|
|
3217
3531
|
}
|
|
3218
|
-
return await this.dispatchPty(session, params, callCtx, timeoutSec
|
|
3532
|
+
return await this.dispatchPty(session, params, callCtx, timeoutSec);
|
|
3219
3533
|
}
|
|
3220
3534
|
if (params.ssh !== void 0) {
|
|
3221
3535
|
const label = form.form === "direct" || form.form === "bare" ? form.name : void 0;
|
|
@@ -3236,28 +3550,28 @@ var RemoteDriver = class {
|
|
|
3236
3550
|
kind: "ssh",
|
|
3237
3551
|
host: normalizeSshConnection(params.ssh).host,
|
|
3238
3552
|
connection: normalizeSshConnection(params.ssh)
|
|
3239
|
-
}, `ssh:${name$1}`, params, timeoutSec
|
|
3553
|
+
}, `ssh:${name$1}`, params, timeoutSec);
|
|
3240
3554
|
}
|
|
3241
3555
|
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
3556
|
if (form.form === "pty-selector") {
|
|
3243
3557
|
const session = this.resolvePty(form.idOrLabel, sessionKey);
|
|
3244
3558
|
if (!hasCmd) return this.cardReceipt(session);
|
|
3245
|
-
return await this.dispatchPty(session, params, callCtx, timeoutSec
|
|
3559
|
+
return await this.dispatchPty(session, params, callCtx, timeoutSec);
|
|
3246
3560
|
}
|
|
3247
3561
|
if (form.form === "direct") {
|
|
3248
3562
|
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
|
|
3563
|
+
return await this.oneshot(plan$1, `${form.transport}:${form.name}`, params, timeoutSec);
|
|
3250
3564
|
}
|
|
3251
3565
|
if (form.form !== "bare") throw new Error("[E_INTERNAL] remote: unreachable target form after routing");
|
|
3252
3566
|
const entry = await this.resolvePoolEntry(form.name, sessionKey);
|
|
3253
3567
|
if (entry.kind === "pty") {
|
|
3254
3568
|
const session = this.resolvePty(form.name, sessionKey);
|
|
3255
3569
|
if (!hasCmd) return this.cardReceipt(session);
|
|
3256
|
-
return await this.dispatchPty(session, params, callCtx, timeoutSec
|
|
3570
|
+
return await this.dispatchPty(session, params, callCtx, timeoutSec);
|
|
3257
3571
|
}
|
|
3258
|
-
if (entry.kind === "hub") return await this.hubOneshot(entry.machine, `hub:${entry.machine}`, params, timeoutSec
|
|
3572
|
+
if (entry.kind === "hub") return await this.hubOneshot(entry.machine, `hub:${entry.machine}`, params, timeoutSec);
|
|
3259
3573
|
const plan = this.planOf(entry);
|
|
3260
|
-
return await this.oneshot(plan, form.name, params, timeoutSec
|
|
3574
|
+
return await this.oneshot(plan, form.name, params, timeoutSec);
|
|
3261
3575
|
}
|
|
3262
3576
|
/** Native-first copy routing (spec: remote-native-shortcut) — unchanged by the workbench round. */
|
|
3263
3577
|
copy(request, signal, callCtx = {}) {
|
|
@@ -3373,7 +3687,7 @@ var RemoteDriver = class {
|
|
|
3373
3687
|
const parsed = parseFileEndpoint(value);
|
|
3374
3688
|
if (!parsed.target) return { path: parsed.path };
|
|
3375
3689
|
const form = parseTargetForm(parsed.target);
|
|
3376
|
-
if (form.form === "hub" || form.form === "
|
|
3690
|
+
if (form.form === "hub" || form.form === "pty-selector") throw new Error("rsync supports SSH endpoints only (plus one local endpoint); no mixed-transport or copy fallback");
|
|
3377
3691
|
const plan = await this.resolveConnectionTarget(parsed.target, callCtx);
|
|
3378
3692
|
this.assertOpen();
|
|
3379
3693
|
combined.throwIfAborted();
|
|
@@ -3415,49 +3729,68 @@ var RemoteDriver = class {
|
|
|
3415
3729
|
const plan = form.transport === "ssh" ? this.sshPlanFor(form.name, callCtx.sessionKey ?? "no-session") : resolveTarget(`${form.transport}:${form.name}`);
|
|
3416
3730
|
this.assertOpen();
|
|
3417
3731
|
signal.throwIfAborted();
|
|
3418
|
-
process$1 = await this.nativeProcess(plan, params, params.timeout, signal);
|
|
3732
|
+
process$1 = await this.nativeProcess(plan, `${form.transport}:${form.name}`, params, params.timeout, signal);
|
|
3419
3733
|
} else if (form.form === "bare") {
|
|
3420
3734
|
const entry = await this.resolvePoolEntry(form.name, callCtx.sessionKey ?? "no-session");
|
|
3421
3735
|
this.assertOpen();
|
|
3422
3736
|
signal.throwIfAborted();
|
|
3423
3737
|
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);
|
|
3738
|
+
process$1 = await this.nativeProcess(this.planOf(entry), form.name, params, params.timeout, signal);
|
|
3425
3739
|
} else throw new Error("remote: background oneshot takes a machine target");
|
|
3426
3740
|
this.own(process$1);
|
|
3427
3741
|
if (signal.aborted || this.disposed) process$1.cancel("owner closed during start");
|
|
3428
3742
|
return process$1;
|
|
3429
3743
|
}
|
|
3430
|
-
async nativeProcess(plan, params, timeoutSec, signal = this.fileAbort.signal) {
|
|
3744
|
+
async nativeProcess(plan, display, params, timeoutSec, signal = this.fileAbort.signal) {
|
|
3745
|
+
const muxArgv = this.muxArgvFor(plan, display);
|
|
3431
3746
|
const cleanupTag = randomUUID().slice(0, 8);
|
|
3432
|
-
const helper = await this.ensureHelper(plan);
|
|
3747
|
+
const helper = await this.ensureHelper(plan, muxArgv);
|
|
3433
3748
|
if (helper !== null && !this.disposed) {
|
|
3749
|
+
let killed$1 = false;
|
|
3750
|
+
const kill$1 = () => {
|
|
3751
|
+
if (killed$1) return;
|
|
3752
|
+
killed$1 = true;
|
|
3753
|
+
startOneShot(buildHelperKillgroupArgv(plan, helper.binPath, cleanupTag, muxArgv), { timeoutSec: 15 }).done.catch(() => {});
|
|
3754
|
+
};
|
|
3434
3755
|
const process$2 = startOneShot(buildHelperOneShotArgv(plan, helper.binPath, {
|
|
3435
3756
|
tag: cleanupTag,
|
|
3436
|
-
cmd: params.cmd
|
|
3757
|
+
cmd: params.cmd,
|
|
3758
|
+
...muxArgv !== void 0 ? { muxArgv } : {}
|
|
3437
3759
|
}), {
|
|
3438
3760
|
stdin: params.stdin,
|
|
3439
|
-
timeoutSec
|
|
3761
|
+
timeoutSec,
|
|
3762
|
+
onCancel: kill$1
|
|
3440
3763
|
});
|
|
3441
3764
|
this.runnerOf.set(process$2, `superd-run ${helper.ver} (${helper.triple})`);
|
|
3442
3765
|
process$2.done.then((result) => {
|
|
3443
3766
|
if (!result.cancelled && !result.timedOut) return;
|
|
3444
|
-
|
|
3767
|
+
kill$1();
|
|
3445
3768
|
}).catch(() => {});
|
|
3446
3769
|
const tracked = Promise.resolve(process$2);
|
|
3447
3770
|
this.starting.add(tracked);
|
|
3448
3771
|
tracked.then(() => this.starting.delete(tracked), () => this.starting.delete(tracked));
|
|
3449
3772
|
return tracked;
|
|
3450
3773
|
}
|
|
3451
|
-
|
|
3774
|
+
let killed = false;
|
|
3775
|
+
const kill = () => {
|
|
3776
|
+
if (killed) return;
|
|
3777
|
+
killed = true;
|
|
3778
|
+
const pidfile = `"\${TMPDIR:-/tmp}/superd-oneshot-${cleanupTag}.pgid"`;
|
|
3779
|
+
const cleanup = `p=$(cat ${pidfile} 2>/dev/null) && { kill -TERM -- "-$p" 2>/dev/null; sleep 0.3; kill -KILL -- "-$p" 2>/dev/null; }; rm -f ${pidfile}`;
|
|
3780
|
+
startOneShot((this.opts.oneshotArgvFor ?? buildOneShotArgv)(plan, cleanup, { ...muxArgv !== void 0 ? { muxArgv } : {} }), { timeoutSec: 15 }).done.catch(() => {});
|
|
3781
|
+
};
|
|
3782
|
+
const process$1 = startOneShot((this.opts.oneshotArgvFor ?? buildOneShotArgv)(plan, params.cmd, {
|
|
3783
|
+
cleanupTag,
|
|
3784
|
+
...muxArgv !== void 0 ? { muxArgv } : {}
|
|
3785
|
+
}), {
|
|
3452
3786
|
stdin: params.stdin,
|
|
3453
|
-
timeoutSec
|
|
3787
|
+
timeoutSec,
|
|
3788
|
+
onCancel: kill
|
|
3454
3789
|
});
|
|
3455
3790
|
this.runnerOf.set(process$1, "posix-shim");
|
|
3456
3791
|
process$1.done.then((result) => {
|
|
3457
3792
|
if (!result.cancelled && !result.timedOut) return;
|
|
3458
|
-
|
|
3459
|
-
const cleanup = `p=$(cat ${pidfile} 2>/dev/null) && { kill -TERM -- "-$p" 2>/dev/null; sleep 0.3; kill -KILL -- "-$p" 2>/dev/null; }; rm -f ${pidfile}`;
|
|
3460
|
-
startOneShot((this.opts.oneshotArgvFor ?? buildOneShotArgv)(plan, cleanup, {}), { timeoutSec: 15 }).done.catch(() => {});
|
|
3793
|
+
kill();
|
|
3461
3794
|
}).catch(() => {});
|
|
3462
3795
|
const work = Promise.resolve(process$1);
|
|
3463
3796
|
this.starting.add(work);
|
|
@@ -3466,14 +3799,14 @@ var RemoteDriver = class {
|
|
|
3466
3799
|
}
|
|
3467
3800
|
/** Helper bootstrap is best-effort and sticky: any failure parks null for this driver
|
|
3468
3801
|
* lifetime and the shim stays the path. First call to a target pays probe (+ upload). */
|
|
3469
|
-
async ensureHelper(plan) {
|
|
3802
|
+
async ensureHelper(plan, muxArgv) {
|
|
3470
3803
|
if (this.helpers === void 0) {
|
|
3471
3804
|
const assets = this.opts.helperAssetsFor?.() ?? helperAssets();
|
|
3472
3805
|
this.helpers = assets === null ? null : new HelperRegistry(assets, (argv, options) => this.helperExec(argv, options), buildHelperProbeArgv, buildHelperUploadArgv);
|
|
3473
3806
|
}
|
|
3474
3807
|
if (this.helpers === null || this.disposed) return null;
|
|
3475
3808
|
try {
|
|
3476
|
-
return await this.helpers.ensure(plan);
|
|
3809
|
+
return await this.helpers.ensure(plan, muxArgv ?? []);
|
|
3477
3810
|
} catch {
|
|
3478
3811
|
return null;
|
|
3479
3812
|
}
|
|
@@ -3586,6 +3919,16 @@ var RemoteDriver = class {
|
|
|
3586
3919
|
host: name$1
|
|
3587
3920
|
};
|
|
3588
3921
|
}
|
|
3922
|
+
/** Task E MUX 身份:仅 SSH plan 注入(docker/incus/hub 返回 undefined)。身份键 = 原始
|
|
3923
|
+
* target 串 + 有效连接字段(host/user/port/key/ProxyJump)——同身份共享 master,跨身份
|
|
3924
|
+
* 不串 socket;目录/寿命/拆除全部由 SshMux 自持。 */
|
|
3925
|
+
muxArgvFor(plan, target) {
|
|
3926
|
+
if (plan.kind !== "ssh") return void 0;
|
|
3927
|
+
return this.mux.argvFor({
|
|
3928
|
+
target,
|
|
3929
|
+
connection: plan.connection ?? { host: plan.host }
|
|
3930
|
+
});
|
|
3931
|
+
}
|
|
3589
3932
|
/** BYO ssh 注册(v2:无 spec 对比——label 占用即拒;注册成功才认领)。返回实际名(label 或 s-id)。 */
|
|
3590
3933
|
registerByoSsh(label, connection, sessionKey) {
|
|
3591
3934
|
this.assertOpen();
|
|
@@ -3615,7 +3958,8 @@ var RemoteDriver = class {
|
|
|
3615
3958
|
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
3959
|
if (live.length >= REMOTE_PTY_SESSION_LIMIT) {
|
|
3617
3960
|
const listing = live.map(sessionLine).join(" · ");
|
|
3618
|
-
throw new Error(`[E_SESSION_LIMIT] remote: this Agent already holds ${live.length} live PTY sessions
|
|
3961
|
+
throw new Error(`[E_SESSION_LIMIT] remote: this Agent already holds ${live.length} live PTY sessions — the per-Agent session global limit (${REMOTE_PTY_SESSION_LIMIT}) is reached. Check the live sessions below (or {action:"status"} for the roster), then close one ({action:"close", target:"pty:<id>" or the machine name}), kill its running job (job_kill), reuse a session, or run {mode:"oneshot"} instead.
|
|
3962
|
+
live: ${listing}`);
|
|
3619
3963
|
}
|
|
3620
3964
|
const { label } = opts;
|
|
3621
3965
|
if (label !== void 0) validateLabel(label);
|
|
@@ -3664,7 +4008,7 @@ var RemoteDriver = class {
|
|
|
3664
4008
|
isBuiltOn(connection, sessionKey) {
|
|
3665
4009
|
return connection.startsWith("ssh:") && this.labels.resolve(connection.slice(4), sessionKey)?.kind === "ssh-byo";
|
|
3666
4010
|
}
|
|
3667
|
-
/** PTY 创建形唯一实现(execute 与 background 预建共用):spawn BYO
|
|
4011
|
+
/** PTY 创建形唯一实现(execute 与 background 预建共用):spawn BYO(spec 2026-11-01:native `:pty`/hub-pty 创建语法已删,machine 会话经默认槽 + handoff)。 */
|
|
3668
4012
|
createPtyLane(params, form, sessionKey) {
|
|
3669
4013
|
if (params.spawn !== void 0 && params.spawn.length > 0) {
|
|
3670
4014
|
const label = form.form === "pty-selector" && form.idOrLabel !== void 0 ? form.idOrLabel : form.form === "bare" ? form.name : void 0;
|
|
@@ -3677,30 +4021,246 @@ var RemoteDriver = class {
|
|
|
3677
4021
|
origin: params.spawn
|
|
3678
4022
|
}, sessionKey) };
|
|
3679
4023
|
}
|
|
3680
|
-
|
|
3681
|
-
|
|
3682
|
-
|
|
3683
|
-
|
|
3684
|
-
|
|
3685
|
-
|
|
3686
|
-
|
|
4024
|
+
}
|
|
4025
|
+
/** 池条目的槽键归一:同一实体(host/container/machine 身份)跨裸名/前缀/撞名前缀共享同一默认槽。 */
|
|
4026
|
+
machineKeyOfEntry(entry) {
|
|
4027
|
+
switch (entry.kind) {
|
|
4028
|
+
case "ssh": return `ssh:${entry.host}`;
|
|
4029
|
+
case "ssh-byo": return `byo:${entry.name}`;
|
|
4030
|
+
case "docker": return `docker:${entry.container}`;
|
|
4031
|
+
case "incus": return `incus:${entry.container}`;
|
|
4032
|
+
case "hub": return `hub:${entry.machine}`;
|
|
4033
|
+
case "pty": throw new Error("remote: a PTY session is not a machine slot");
|
|
4034
|
+
}
|
|
4035
|
+
}
|
|
4036
|
+
/** 默认槽的 machine 规格:归一 key + 创建 argv + 展示 connection(hub 每次经 ptyEndpoint 重验 pairing,P10)。 */
|
|
4037
|
+
async machineSlotSpec(form, sessionKey) {
|
|
4038
|
+
if (form.form === "hub") {
|
|
4039
|
+
const hub = this.requireHub();
|
|
4040
|
+
this.hubProviders.add(hub);
|
|
4041
|
+
const endpoint = hub.ptyEndpoint(form.machine);
|
|
4042
|
+
return {
|
|
4043
|
+
key: `hub:${form.machine}`,
|
|
4044
|
+
argv: this.opts.hubPtyArgvFor !== void 0 ? this.opts.hubPtyArgvFor(endpoint) : buildHubPtyArgv(endpoint, hubPtyAssets()),
|
|
4045
|
+
connection: `hub:${form.machine}`
|
|
3687
4046
|
};
|
|
3688
|
-
|
|
4047
|
+
}
|
|
4048
|
+
if (form.form === "direct") {
|
|
4049
|
+
if (form.transport === "ssh") {
|
|
4050
|
+
const entry = this.labels.resolve(form.name, sessionKey);
|
|
4051
|
+
if (entry?.kind === "ssh-byo") {
|
|
4052
|
+
const connection = entry.connection;
|
|
4053
|
+
return {
|
|
4054
|
+
key: `byo:${entry.name}`,
|
|
4055
|
+
argv: (this.opts.ptyArgvFor ?? buildPtyArgv)({
|
|
4056
|
+
kind: "ssh",
|
|
4057
|
+
host: connection.host,
|
|
4058
|
+
connection
|
|
4059
|
+
}),
|
|
4060
|
+
connection: `ssh:${entry.name}`,
|
|
4061
|
+
builtOn: true
|
|
4062
|
+
};
|
|
4063
|
+
}
|
|
4064
|
+
return {
|
|
4065
|
+
key: `ssh:${form.name}`,
|
|
4066
|
+
argv: (this.opts.ptyArgvFor ?? buildPtyArgv)({
|
|
4067
|
+
kind: "ssh",
|
|
4068
|
+
host: form.name
|
|
4069
|
+
}),
|
|
4070
|
+
connection: `ssh:${form.name}`
|
|
4071
|
+
};
|
|
4072
|
+
}
|
|
4073
|
+
const plan = resolveTarget(`${form.transport}:${form.name}`);
|
|
4074
|
+
return {
|
|
4075
|
+
key: `${form.transport}:${form.name}`,
|
|
3689
4076
|
argv: (this.opts.ptyArgvFor ?? buildPtyArgv)(plan),
|
|
3690
|
-
connection:
|
|
3691
|
-
|
|
3692
|
-
...form.label !== void 0 ? { label: form.label } : {}
|
|
3693
|
-
}, sessionKey) };
|
|
4077
|
+
connection: `${form.transport}:${form.name}`
|
|
4078
|
+
};
|
|
3694
4079
|
}
|
|
3695
|
-
if (form.form === "
|
|
3696
|
-
const
|
|
3697
|
-
|
|
4080
|
+
if (form.form === "bare") {
|
|
4081
|
+
const entry = await this.resolvePoolEntry(form.name, sessionKey);
|
|
4082
|
+
this.assertOpen();
|
|
4083
|
+
if (entry.kind === "hub") return this.machineSlotSpec({
|
|
4084
|
+
form: "hub",
|
|
4085
|
+
machine: entry.machine
|
|
4086
|
+
}, sessionKey);
|
|
4087
|
+
if (entry.kind === "pty") throw new Error("[E_INTERNAL] remote: bare pty entries route before machine slots");
|
|
4088
|
+
const key = this.machineKeyOfEntry(entry);
|
|
4089
|
+
if (entry.kind === "ssh") return {
|
|
4090
|
+
key,
|
|
4091
|
+
argv: (this.opts.ptyArgvFor ?? buildPtyArgv)({
|
|
4092
|
+
kind: "ssh",
|
|
4093
|
+
host: entry.host
|
|
4094
|
+
}),
|
|
4095
|
+
connection: `ssh:${entry.name}`
|
|
4096
|
+
};
|
|
4097
|
+
if (entry.kind === "ssh-byo") return {
|
|
4098
|
+
key,
|
|
4099
|
+
argv: (this.opts.ptyArgvFor ?? buildPtyArgv)({
|
|
4100
|
+
kind: "ssh",
|
|
4101
|
+
host: entry.connection.host,
|
|
4102
|
+
connection: entry.connection
|
|
4103
|
+
}),
|
|
4104
|
+
connection: `ssh:${entry.name}`,
|
|
4105
|
+
builtOn: true
|
|
4106
|
+
};
|
|
4107
|
+
const plan = entry.kind === "docker" ? resolveTarget(`docker:${entry.container}`) : resolveTarget(`incus:${entry.container}`);
|
|
4108
|
+
return {
|
|
4109
|
+
key,
|
|
3698
4110
|
argv: (this.opts.ptyArgvFor ?? buildPtyArgv)(plan),
|
|
3699
|
-
connection: `${
|
|
4111
|
+
connection: `${entry.kind === "docker" ? "docker" : "incus"}:${entry.name}`
|
|
4112
|
+
};
|
|
4113
|
+
}
|
|
4114
|
+
throw new Error(`[E_TARGET_FORMAT] remote: a machine target is required (ssh:<name>, docker:<name>, incus:<name>, hub:<m4>, or a bare pool name) — got form ${JSON.stringify(form.form)}`);
|
|
4115
|
+
}
|
|
4116
|
+
/** 取/懒建 machine 的默认 PTY 槽:dead/disposed 槽惰性重建;id 创建即认领(roster 全可见 + 防撞)。 */
|
|
4117
|
+
async defaultSlot(form, sessionKey) {
|
|
4118
|
+
const spec = await this.machineSlotSpec(form, sessionKey);
|
|
4119
|
+
this.assertOpen();
|
|
4120
|
+
const byKey = this.defaultSlots.get(sessionKey) ?? /* @__PURE__ */ new Map();
|
|
4121
|
+
this.defaultSlots.set(sessionKey, byKey);
|
|
4122
|
+
const existingKey = byKey.get(spec.key);
|
|
4123
|
+
if (existingKey !== void 0) {
|
|
4124
|
+
const existing = this.pool.get(existingKey);
|
|
4125
|
+
if (existing !== void 0) return {
|
|
4126
|
+
session: existing,
|
|
4127
|
+
machineKey: spec.key,
|
|
4128
|
+
isNew: false
|
|
4129
|
+
};
|
|
4130
|
+
byKey.delete(spec.key);
|
|
4131
|
+
}
|
|
4132
|
+
const session = this.createSession({
|
|
4133
|
+
argv: spec.argv,
|
|
4134
|
+
connection: spec.connection,
|
|
4135
|
+
byo: false,
|
|
4136
|
+
...spec.builtOn === true ? { builtOn: true } : {}
|
|
4137
|
+
}, sessionKey);
|
|
4138
|
+
byKey.set(spec.key, session.key);
|
|
4139
|
+
return {
|
|
4140
|
+
session,
|
|
4141
|
+
machineKey: spec.key,
|
|
4142
|
+
isNew: true
|
|
4143
|
+
};
|
|
4144
|
+
}
|
|
4145
|
+
releaseDefaultSlot(machineKey, sessionKey) {
|
|
4146
|
+
this.defaultSlots.get(sessionKey)?.delete(machineKey);
|
|
4147
|
+
}
|
|
4148
|
+
/** 按池键反查并解绑默认槽(background 提交即 handoff 用);非槽会话无操作。 */
|
|
4149
|
+
releaseSlotByPoolKey(poolKey, sessionKey) {
|
|
4150
|
+
const byKey = this.defaultSlots.get(sessionKey);
|
|
4151
|
+
if (byKey === void 0) return;
|
|
4152
|
+
for (const [machineKey, key] of byKey) if (key === poolKey) {
|
|
4153
|
+
byKey.delete(machineKey);
|
|
4154
|
+
return;
|
|
4155
|
+
}
|
|
4156
|
+
}
|
|
4157
|
+
/** roster/status 面:本 session 的默认槽标注(session id → machineKey;悬空条目过滤)。 */
|
|
4158
|
+
defaultMarks(sessionKey) {
|
|
4159
|
+
const marks = /* @__PURE__ */ new Map();
|
|
4160
|
+
const byKey = this.defaultSlots.get(sessionKey);
|
|
4161
|
+
if (byKey === void 0) return marks;
|
|
4162
|
+
for (const [machineKey, poolKey] of byKey) {
|
|
4163
|
+
const s = this.pool.get(poolKey);
|
|
4164
|
+
if (s !== void 0) marks.set(s.identity.id, machineKey);
|
|
4165
|
+
}
|
|
4166
|
+
return marks;
|
|
4167
|
+
}
|
|
4168
|
+
/** spec §1 handoff(2026-11-01 二/三轮裁决):把 machine 默认 PTY 解绑成可寻址 pty:<id>——同引用移动
|
|
4169
|
+
* (不重启、不复制输出流);fresh(尚无默认槽)= 后台创建空闲 shell 再隔离(默认 PTY 思维模型恒
|
|
4170
|
+
* ready,创建时机是实现细节);busy = 附当前命令与 job 句柄。machine 下次 exec 按需新建默认 PTY。 */
|
|
4171
|
+
async handoff(target, callCtx = {}) {
|
|
4172
|
+
this.assertOpen();
|
|
4173
|
+
const sessionKey = callCtx.sessionKey ?? "no-session";
|
|
4174
|
+
const form = parseTargetForm(target);
|
|
4175
|
+
if (form.form !== "direct" && form.form !== "bare" && form.form !== "hub") throw new Error(`[E_TARGET_FORMAT] remote: handoff detaches a machine's default PTY — pass a machine target (ssh:dev4, docker:c, hub:<m4>, or a bare pool name); addressable sessions are already detached, and ${JSON.stringify(target)} is not a machine`);
|
|
4176
|
+
const spec = await this.machineSlotSpec(form, sessionKey);
|
|
4177
|
+
this.assertOpen();
|
|
4178
|
+
let session;
|
|
4179
|
+
const poolKey = this.defaultSlots.get(sessionKey)?.get(spec.key);
|
|
4180
|
+
if (poolKey !== void 0) session = this.pool.get(poolKey);
|
|
4181
|
+
let created = false;
|
|
4182
|
+
if (session === void 0) {
|
|
4183
|
+
this.releaseDefaultSlot(spec.key, sessionKey);
|
|
4184
|
+
session = this.createSession({
|
|
4185
|
+
argv: spec.argv,
|
|
4186
|
+
connection: spec.connection,
|
|
3700
4187
|
byo: false,
|
|
3701
|
-
...
|
|
3702
|
-
}, sessionKey)
|
|
4188
|
+
...spec.builtOn === true ? { builtOn: true } : {}
|
|
4189
|
+
}, sessionKey);
|
|
4190
|
+
created = true;
|
|
4191
|
+
await session.warm();
|
|
3703
4192
|
}
|
|
4193
|
+
this.releaseDefaultSlot(spec.key, sessionKey);
|
|
4194
|
+
const id = session.identity;
|
|
4195
|
+
const snap = session.snapshot;
|
|
4196
|
+
const activeCmd = session.active;
|
|
4197
|
+
const lines = [
|
|
4198
|
+
`handed off pty:${id.id}${id.label !== void 0 ? ` (label ${id.label})` : ""} — ${id.connection}${id.builtOn ? " (built on)" : ""}`,
|
|
4199
|
+
created ? "fresh idle shell created and detached" : snap.state === "dead" ? "session died — its next dispatch reconnects a fresh shell" : "detached",
|
|
4200
|
+
`this machine's next exec opens a fresh default PTY`
|
|
4201
|
+
];
|
|
4202
|
+
if (activeCmd !== null) lines.push(`still running: command ${activeCmd.commandId}${activeCmd.jobId !== void 0 ? ` — follow with job_output ${activeCmd.jobId}, cancel with job_kill` : " — a synchronous exec settles into its receipt; a timeout handoff is adoptable via job_output"}`);
|
|
4203
|
+
return {
|
|
4204
|
+
mode: "pty",
|
|
4205
|
+
target: `pty:${id.id}`,
|
|
4206
|
+
session: `pty:${id.id}`,
|
|
4207
|
+
...id.label !== void 0 ? { label: id.label } : {},
|
|
4208
|
+
connection: id.connection,
|
|
4209
|
+
exit: null,
|
|
4210
|
+
cwd: session.lastCwd,
|
|
4211
|
+
timedOut: false,
|
|
4212
|
+
reconnected: false,
|
|
4213
|
+
stdout: lines.join("\n"),
|
|
4214
|
+
durationMs: 0,
|
|
4215
|
+
...created ? { created: true } : {},
|
|
4216
|
+
...activeCmd !== null ? {
|
|
4217
|
+
running: true,
|
|
4218
|
+
commandId: activeCmd.commandId
|
|
4219
|
+
} : {},
|
|
4220
|
+
...this.ptyTtl(session.key)
|
|
4221
|
+
};
|
|
4222
|
+
}
|
|
4223
|
+
/** close 统一入口(spec §1 + 三轮补充裁决):machine target 只关默认槽;pty selector 关该会话;
|
|
4224
|
+
* 不存在 job 定位——取消运行中命令的唯一入口是官方 job_kill。 */
|
|
4225
|
+
async close(target, sessionKey, force = false) {
|
|
4226
|
+
this.assertOpen();
|
|
4227
|
+
const form = parseTargetForm(target);
|
|
4228
|
+
const bareIsPty = form.form === "bare" && this.labels.resolve(form.name, sessionKey)?.kind === "pty";
|
|
4229
|
+
if (form.form === "direct" || form.form === "hub" || form.form === "bare" && !bareIsPty) return this.closeDefault(form, sessionKey, force);
|
|
4230
|
+
if (form.form === "pty-selector") {
|
|
4231
|
+
if (form.idOrLabel === void 0) throw new Error("[E_PARAMS] remote: \"pty\" alone selects nothing — append an id/label (pty:pty-1a2b, pty:build)");
|
|
4232
|
+
return this.closePty(form.idOrLabel, sessionKey, force);
|
|
4233
|
+
}
|
|
4234
|
+
if (form.form === "bare") return this.closePty(form.name, sessionKey, force);
|
|
4235
|
+
throw new Error(`[E_TARGET_FORMAT] remote: close takes a machine target (closes its default PTY) or pty:<id|label> — got ${JSON.stringify(target)}`);
|
|
4236
|
+
}
|
|
4237
|
+
async closeDefault(form, sessionKey, force) {
|
|
4238
|
+
const spec = await this.machineSlotSpec(form, sessionKey);
|
|
4239
|
+
this.assertOpen();
|
|
4240
|
+
const poolKey = this.defaultSlots.get(sessionKey)?.get(spec.key);
|
|
4241
|
+
if (poolKey === void 0) return {
|
|
4242
|
+
session: spec.key,
|
|
4243
|
+
connection: spec.connection,
|
|
4244
|
+
closed: false,
|
|
4245
|
+
reason: "not-found",
|
|
4246
|
+
killed: [],
|
|
4247
|
+
message: `no default PTY on ${spec.key} — a machine's first exec opens one`
|
|
4248
|
+
};
|
|
4249
|
+
const session = this.pool.get(poolKey);
|
|
4250
|
+
if (session === void 0) {
|
|
4251
|
+
this.releaseDefaultSlot(spec.key, sessionKey);
|
|
4252
|
+
return {
|
|
4253
|
+
session: spec.key,
|
|
4254
|
+
connection: spec.connection,
|
|
4255
|
+
closed: false,
|
|
4256
|
+
reason: "already-closed-or-reaped",
|
|
4257
|
+
killed: [],
|
|
4258
|
+
message: `the default PTY on ${spec.key} was already closed or reaped after ${this.ttlMinutes()} min idle`
|
|
4259
|
+
};
|
|
4260
|
+
}
|
|
4261
|
+
const r = this.closePty(session.identity.id, sessionKey, force);
|
|
4262
|
+
if (r.closed) this.releaseDefaultSlot(spec.key, sessionKey);
|
|
4263
|
+
return r;
|
|
3704
4264
|
}
|
|
3705
4265
|
/** background PTY lane:会话先占位(不拨号)再进官方 jobs registry——首回执必须能寻址;
|
|
3706
4266
|
* registry 拒绝时把新建的会话拆掉(复用形的会话绝不拆)。返回 undefined = 走普通 oneshot job。 */
|
|
@@ -3712,9 +4272,9 @@ var RemoteDriver = class {
|
|
|
3712
4272
|
const form = parseTargetForm(prepared.target);
|
|
3713
4273
|
const target = (id$1) => ({
|
|
3714
4274
|
target: `pty:${id$1}`,
|
|
3715
|
-
cmd:
|
|
3716
|
-
stdin:
|
|
3717
|
-
timeout:
|
|
4275
|
+
cmd: prepared.cmd,
|
|
4276
|
+
stdin: prepared.stdin,
|
|
4277
|
+
timeout: prepared.timeout,
|
|
3718
4278
|
mode: "pty"
|
|
3719
4279
|
});
|
|
3720
4280
|
const lane = this.createPtyLane(prepared, form, sessionKey);
|
|
@@ -3729,6 +4289,20 @@ var RemoteDriver = class {
|
|
|
3729
4289
|
cleanup: () => void 0
|
|
3730
4290
|
};
|
|
3731
4291
|
}
|
|
4292
|
+
if (lane === void 0 && (form.form === "direct" || form.form === "hub" || form.form === "bare" && this.labels.resolve(form.name, sessionKey)?.kind !== "pty") && prepared.mode !== "oneshot") {
|
|
4293
|
+
const slot = await this.defaultSlot(form, sessionKey);
|
|
4294
|
+
this.assertOpen();
|
|
4295
|
+
const id$1 = slot.session.identity;
|
|
4296
|
+
return {
|
|
4297
|
+
input: target(id$1.id),
|
|
4298
|
+
session: `pty:${id$1.id}`,
|
|
4299
|
+
connection: id$1.connection,
|
|
4300
|
+
...id$1.label !== void 0 ? { label: id$1.label } : {},
|
|
4301
|
+
cleanup: () => {
|
|
4302
|
+
if (slot.isNew) slot.session.dispose();
|
|
4303
|
+
}
|
|
4304
|
+
};
|
|
4305
|
+
}
|
|
3732
4306
|
if (lane === void 0) return void 0;
|
|
3733
4307
|
const id = lane.session.identity;
|
|
3734
4308
|
return {
|
|
@@ -3788,15 +4362,53 @@ var RemoteDriver = class {
|
|
|
3788
4362
|
this.pool.get(key)?.attachJob(commandId, jobId);
|
|
3789
4363
|
}
|
|
3790
4364
|
}
|
|
3791
|
-
|
|
4365
|
+
/** spec remote-default-pty §1(2026-11-01):exec stdin-only 写入核心——向已有 live PTY 的当前运行命令
|
|
4366
|
+
* 追加 exact-byte stdin。寻址:pty:<id|label> / 裸 label 命中 pty 项 / machine target(其默认槽的
|
|
4367
|
+
* 当前命令,无槽不懒建、如实报错)。不触发拨号、创建或重连;回执附 connection/label 供工具层渲染。 */
|
|
4368
|
+
async input(target, options, callCtx = {}) {
|
|
4369
|
+
this.assertOpen();
|
|
4370
|
+
const sessionKey = callCtx.sessionKey ?? "no-session";
|
|
4371
|
+
const form = parseTargetForm(target);
|
|
4372
|
+
let session;
|
|
4373
|
+
if (form.form === "pty-selector") {
|
|
4374
|
+
if (form.idOrLabel === void 0) throw new Error("[E_PARAMS] remote: \"pty\" alone selects nothing — append an id/label (pty:pty-1a2b, pty:build)");
|
|
4375
|
+
session = this.resolvePty(form.idOrLabel, sessionKey);
|
|
4376
|
+
} else if (form.form === "bare" && this.labels.resolve(form.name, sessionKey)?.kind === "pty") session = this.resolvePty(form.name, sessionKey);
|
|
4377
|
+
else if (form.form === "direct" || form.form === "hub" || form.form === "bare") {
|
|
4378
|
+
const spec = await this.machineSlotSpec(form, sessionKey);
|
|
4379
|
+
this.assertOpen();
|
|
4380
|
+
const poolKey = this.defaultSlots.get(sessionKey)?.get(spec.key);
|
|
4381
|
+
const slot = poolKey !== void 0 ? this.pool.get(poolKey) : void 0;
|
|
4382
|
+
if (slot === void 0) throw new Error(`[E_SESSION_NOT_RUNNING] remote: no default PTY on ${spec.key} yet — start a command first ({action:"exec", target:"${spec.key}", cmd:…}); a raw stdin write goes to the command running there`);
|
|
4383
|
+
session = slot;
|
|
4384
|
+
} else throw new Error(`[E_TARGET_FORMAT] remote: a raw stdin write goes into a live PTY session — pass pty:<id|label>, a bare session label, or a machine target (its default PTY); got ${JSON.stringify(target)}`);
|
|
4385
|
+
const receipt = await session.input(options);
|
|
4386
|
+
const id = session.identity;
|
|
4387
|
+
return {
|
|
4388
|
+
...receipt,
|
|
4389
|
+
connection: id.connection,
|
|
4390
|
+
...id.label !== void 0 ? { label: id.label } : {}
|
|
4391
|
+
};
|
|
4392
|
+
}
|
|
4393
|
+
async dispatchPty(session, params, callCtx, timeoutSec) {
|
|
4394
|
+
const hubConn = session.identity.connection;
|
|
4395
|
+
if (hubConn.startsWith("hub:")) {
|
|
4396
|
+
const hub = this.requireHub();
|
|
4397
|
+
this.hubProviders.add(hub);
|
|
4398
|
+
hub.ptyEndpoint(hubConn.slice(4));
|
|
4399
|
+
}
|
|
3792
4400
|
const commandId = callCtx.commandId ?? genCommandId();
|
|
3793
4401
|
const r = await session.dispatch(params.cmd, {
|
|
3794
4402
|
stdin: params.stdin,
|
|
3795
4403
|
timeoutSec,
|
|
3796
4404
|
commandId,
|
|
3797
|
-
...callCtx.ptyBackground === true ? {
|
|
4405
|
+
...callCtx.ptyBackground === true ? {
|
|
4406
|
+
background: true,
|
|
4407
|
+
jobId: commandId
|
|
4408
|
+
} : {}
|
|
3798
4409
|
});
|
|
3799
|
-
|
|
4410
|
+
if (callCtx.ptyBackground === true && r.running === true) this.releaseSlotByPoolKey(session.key, callCtx.sessionKey ?? "no-session");
|
|
4411
|
+
const out = disclose(r.output, r.outputStats?.stdout);
|
|
3800
4412
|
const id = session.identity;
|
|
3801
4413
|
return {
|
|
3802
4414
|
mode: "pty",
|
|
@@ -3859,14 +4471,14 @@ var RemoteDriver = class {
|
|
|
3859
4471
|
cwd: session.lastCwd,
|
|
3860
4472
|
timedOut: false,
|
|
3861
4473
|
reconnected: false,
|
|
3862
|
-
stdout: [head,
|
|
4474
|
+
stdout: [head, activeLine(session)].join("\n"),
|
|
3863
4475
|
durationMs: 0,
|
|
3864
4476
|
...this.ptyTtl(session.key)
|
|
3865
4477
|
};
|
|
3866
4478
|
}
|
|
3867
|
-
async oneshot(plan, display, params, timeoutSec
|
|
4479
|
+
async oneshot(plan, display, params, timeoutSec) {
|
|
3868
4480
|
this.assertOpen();
|
|
3869
|
-
const process$1 = this.own(await this.nativeProcess(plan, params, timeoutSec));
|
|
4481
|
+
const process$1 = this.own(await this.nativeProcess(plan, display, params, timeoutSec));
|
|
3870
4482
|
if (this.disposed) process$1.cancel("owner disposed");
|
|
3871
4483
|
const result = await process$1.done;
|
|
3872
4484
|
const stdout = process$1.read("stdout", 0), stderr = process$1.read("stderr", 0);
|
|
@@ -3879,8 +4491,8 @@ var RemoteDriver = class {
|
|
|
3879
4491
|
stderr: stderr.stats
|
|
3880
4492
|
}
|
|
3881
4493
|
};
|
|
3882
|
-
const out =
|
|
3883
|
-
const err = r.stderr.length > 0 ?
|
|
4494
|
+
const out = disclose(r.stdout, r.outputStats?.stdout);
|
|
4495
|
+
const err = r.stderr.length > 0 ? disclose(r.stderr, r.outputStats?.stderr) : void 0;
|
|
3884
4496
|
return {
|
|
3885
4497
|
mode: "oneshot",
|
|
3886
4498
|
target: display,
|
|
@@ -3899,13 +4511,13 @@ var RemoteDriver = class {
|
|
|
3899
4511
|
...this.runnerOf.get(process$1) === void 0 ? {} : { runner: this.runnerOf.get(process$1) }
|
|
3900
4512
|
};
|
|
3901
4513
|
}
|
|
3902
|
-
async hubOneshot(machine, display, params, timeoutSec
|
|
4514
|
+
async hubOneshot(machine, display, params, timeoutSec) {
|
|
3903
4515
|
const process$1 = this.own(await this.hubOneshotProcess(machine, params));
|
|
3904
4516
|
if (this.disposed) process$1.cancel("owner disposed");
|
|
3905
4517
|
const result = await process$1.done;
|
|
3906
4518
|
const stdout = process$1.read("stdout", 0), stderr = process$1.read("stderr", 0);
|
|
3907
|
-
const out =
|
|
3908
|
-
const err =
|
|
4519
|
+
const out = disclose(stdout.text, stdout.stats);
|
|
4520
|
+
const err = disclose(stderr.text, stderr.stats);
|
|
3909
4521
|
return {
|
|
3910
4522
|
...result,
|
|
3911
4523
|
mode: "oneshot",
|
|
@@ -3913,6 +4525,7 @@ var RemoteDriver = class {
|
|
|
3913
4525
|
connection: display,
|
|
3914
4526
|
cwd: null,
|
|
3915
4527
|
reconnected: false,
|
|
4528
|
+
runner: HUB_RUNNER,
|
|
3916
4529
|
stdout: (!stdout.stats && stdout.lossy ? "[truncated: peer returned only a tail; exact total/retained/dropped unavailable]\n" : "") + out.text,
|
|
3917
4530
|
stderr: [err.text, stderr.diagnostic].filter(Boolean).join("\n"),
|
|
3918
4531
|
...out.stats ? { outputStats: {
|
|
@@ -3951,17 +4564,20 @@ var RemoteDriver = class {
|
|
|
3951
4564
|
return s === void 0 ? null : s;
|
|
3952
4565
|
}).filter((s) => s !== null);
|
|
3953
4566
|
const hub = this.opts.getHub?.();
|
|
3954
|
-
return renderRoster(scan, this.labels.list(callCtx.sessionKey ?? "no-session"), sessions, hub === void 0 ? null : hub.listTargets());
|
|
4567
|
+
return renderRoster(scan, this.labels.list(callCtx.sessionKey ?? "no-session"), sessions, hub === void 0 ? null : hub.listTargets(), this.defaultMarks(callCtx.sessionKey ?? "no-session"));
|
|
3955
4568
|
}
|
|
3956
4569
|
/** status probe(target 无 cmd):机器面 = probe + 该 connection 上的会话;pty selector = session card。 */
|
|
3957
4570
|
async status(target, callCtx = {}) {
|
|
3958
4571
|
this.assertOpen();
|
|
3959
4572
|
const sessionKey = callCtx.sessionKey ?? "no-session";
|
|
3960
4573
|
const form = parseTargetForm(target);
|
|
3961
|
-
if (form.form === "hub"
|
|
4574
|
+
if (form.form === "hub") {
|
|
3962
4575
|
const row = this.requireHub().listTargets().find((t) => t.id === form.machine);
|
|
3963
4576
|
if (!row) throw new Error(`unknown remote machine ${form.machine} — select a remote machine from the Hub roster; local execution is not available`);
|
|
3964
|
-
|
|
4577
|
+
const hubSlotKey = `hub:${form.machine}`;
|
|
4578
|
+
const hubSlotPoolKey = this.defaultSlots.get(sessionKey)?.get(hubSlotKey);
|
|
4579
|
+
const hubSlot = hubSlotPoolKey !== void 0 ? this.pool.get(hubSlotPoolKey) : void 0;
|
|
4580
|
+
return [`hub:${form.machine} — Hub remote machine (${row.label}); exec ${row.execEnabled ? "enabled" : "disabled"}; not probed`, hubSlot === void 0 ? `default PTY: none — this machine's first exec opens one` : `default PTY: ${sessionLine(hubSlot)}`].join("\n");
|
|
3965
4581
|
}
|
|
3966
4582
|
if (form.form === "pty-selector") {
|
|
3967
4583
|
if (form.idOrLabel === void 0) throw new Error("[E_PARAMS] remote: \"pty\" alone selects nothing — append an id/label (pty:pty-1a2b, pty:build)");
|
|
@@ -3979,15 +4595,23 @@ var RemoteDriver = class {
|
|
|
3979
4595
|
const plan = this.planOf(entry);
|
|
3980
4596
|
const head = `${target} — ${plan.kind === "ssh" ? "ssh host" : `${plan.kind} container`}${entry.kind === "ssh-byo" ? " (built on)" : ""}`;
|
|
3981
4597
|
const runner = this.opts.probeRunner ?? runOneShot;
|
|
3982
|
-
const probe = await probeTarget(plan, {
|
|
3983
|
-
|
|
3984
|
-
|
|
3985
|
-
|
|
4598
|
+
const probe = await probeTarget(plan, {
|
|
4599
|
+
runner: (argv, options) => {
|
|
4600
|
+
this.assertOpen();
|
|
4601
|
+
return runner(argv, options);
|
|
4602
|
+
},
|
|
4603
|
+
muxArgvFor: (p) => this.muxArgvFor(p, target) ?? []
|
|
4604
|
+
});
|
|
3986
4605
|
const prefix = `${sessionKey}|`;
|
|
3987
4606
|
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}`);
|
|
4607
|
+
const slotKey = this.machineKeyOfEntry(entry);
|
|
4608
|
+
const slotPoolKey = this.defaultSlots.get(sessionKey)?.get(slotKey);
|
|
4609
|
+
const slotSession = slotPoolKey !== void 0 ? this.pool.get(slotPoolKey) : void 0;
|
|
4610
|
+
const slotLine = slotSession === void 0 ? `default PTY: none — this machine's first exec opens one` : `default PTY: ${sessionLine(slotSession)}`;
|
|
3988
4611
|
return [
|
|
3989
4612
|
head,
|
|
3990
4613
|
renderProbe(probe),
|
|
4614
|
+
slotLine,
|
|
3991
4615
|
onConnection.length > 0 ? `pty sessions on this connection: ${onConnection.map(sessionLine).join(" · ")}` : "pty sessions on this connection: none"
|
|
3992
4616
|
].join("\n");
|
|
3993
4617
|
}
|
|
@@ -4002,8 +4626,7 @@ var RemoteDriver = class {
|
|
|
4002
4626
|
this.assertOpen();
|
|
4003
4627
|
const form = parseTargetForm(target);
|
|
4004
4628
|
if (form.form === "pty-selector") throw new Error("remote: a PTY session is not a filesystem connection");
|
|
4005
|
-
if (form.form === "hub"
|
|
4006
|
-
if (form.form === "create-pty") throw new Error("remote: a PTY session is not a filesystem connection");
|
|
4629
|
+
if (form.form === "hub") throw new Error("remote: hub endpoints resolve through the Hub file lane, not a CLI connection");
|
|
4007
4630
|
if (form.form === "direct") return form.transport === "ssh" ? this.sshPlanFor(form.name, callCtx.sessionKey ?? "no-session") : resolveTarget(`${form.transport}:${form.name}`);
|
|
4008
4631
|
if (form.form === "ssh-prefix" || form.form === "empty") throw new Error("[E_TARGET_FORMAT] remote: connection targets need a name");
|
|
4009
4632
|
const entry = await this.resolvePoolEntry(form.name, callCtx.sessionKey ?? "no-session");
|
|
@@ -4033,6 +4656,7 @@ var RemoteDriver = class {
|
|
|
4033
4656
|
if (this.disposal) return this.disposal;
|
|
4034
4657
|
this.disposed = true;
|
|
4035
4658
|
this.labels.clear();
|
|
4659
|
+
this.defaultSlots.clear();
|
|
4036
4660
|
this.fileAbort.abort(/* @__PURE__ */ new Error("remote owner disposed"));
|
|
4037
4661
|
for (const process$1 of this.processes) process$1.cancel("remote owner disposed");
|
|
4038
4662
|
return this.disposal = (async () => {
|
|
@@ -4043,12 +4667,18 @@ var RemoteDriver = class {
|
|
|
4043
4667
|
...[...this.hubProviders].map((hub) => hub.releaseOwner(this.hubOwner))
|
|
4044
4668
|
]);
|
|
4045
4669
|
this.hubProviders.clear();
|
|
4670
|
+
await this.mux.dispose();
|
|
4046
4671
|
})();
|
|
4047
4672
|
}
|
|
4048
4673
|
};
|
|
4049
4674
|
/** direct 形态回退到 resolveTarget 可解析的名字(ssh:dev4 → dev4)。 */
|
|
4050
4675
|
function directName(form) {
|
|
4051
|
-
return form.form === "direct" ? form.name :
|
|
4676
|
+
return form.form === "direct" ? form.name : "";
|
|
4677
|
+
}
|
|
4678
|
+
/** session card 的当前命令行(spec 2026-11-01:历史 ledger 已删,只报活动命令)。 */
|
|
4679
|
+
function activeLine(s) {
|
|
4680
|
+
const a = s.active;
|
|
4681
|
+
return a === null ? "no command running" : `running: ${a.commandId}${a.jobId !== void 0 ? ` (job ${a.jobId})` : ""} — feed it stdin with exec stdin-only, follow job_output, cancel job_kill`;
|
|
4052
4682
|
}
|
|
4053
4683
|
/** roster/status 一行的会话描述:`p-ab12 (label build) ssh:dev4 idle 12s`。 */
|
|
4054
4684
|
function sessionLine(s) {
|
|
@@ -4070,16 +4700,21 @@ const ptyOutputSources = (holder) => [{
|
|
|
4070
4700
|
lossy: false
|
|
4071
4701
|
}
|
|
4072
4702
|
}];
|
|
4073
|
-
|
|
4074
|
-
|
|
4075
|
-
|
|
4076
|
-
|
|
4703
|
+
/** 0.3.2-d CF:timedOut=true 且 aborted=false = 会话侧主动收束(close force / 行回收走的
|
|
4704
|
+
* dispose→interrupt 两段路)——同为 killed;exit 到手用 exit,只剩会话死亡时写 session closed。 */
|
|
4705
|
+
const ptyOutcome = (aborted, result) => {
|
|
4706
|
+
const interrupted = aborted || result.timedOut === true;
|
|
4707
|
+
return {
|
|
4708
|
+
status: interrupted ? "killed" : result.exit === null ? "failed" : "completed",
|
|
4709
|
+
detail: interrupted ? `interrupted: ${result.exit === null ? aborted ? "session died" : "session closed" : `exit ${result.exit}`}` : result.exit === null ? "session died" : `exit code: ${result.exit}`
|
|
4710
|
+
};
|
|
4711
|
+
};
|
|
4077
4712
|
/** No preparation or discovery before the official registry's admission. */
|
|
4078
4713
|
function startRemoteJob(registry, input, exec, driver) {
|
|
4079
4714
|
if (!exec.agent) throw new Error("remote: background jobs require a live Agent owner");
|
|
4080
4715
|
const form = parseTargetForm(input.target);
|
|
4081
|
-
|
|
4082
|
-
|
|
4716
|
+
const machineForm = form.form === "direct" || form.form === "bare" || form.form === "hub";
|
|
4717
|
+
if (form.form === "pty-selector" || input.spawn !== void 0 || input.mode === "pty" || machineForm && input.mode !== "oneshot") {
|
|
4083
4718
|
const holder = {};
|
|
4084
4719
|
return registry.start({
|
|
4085
4720
|
kind: "remote",
|
|
@@ -4205,116 +4840,123 @@ function adoptRunningPtyJob(registry, exec, label, job) {
|
|
|
4205
4840
|
|
|
4206
4841
|
//#endregion
|
|
4207
4842
|
//#region src/remote/tool.ts
|
|
4208
|
-
const NO_SESSION_KEY = "remote:no-session";
|
|
4843
|
+
const NO_SESSION_KEY$1 = "remote:no-session";
|
|
4209
4844
|
/**
|
|
4210
|
-
* The model-visible surface of the `remote` tool (
|
|
4211
|
-
*
|
|
4845
|
+
* The model-visible surface of the `remote` tool (0.3.2-d user-ruled restructure:
|
|
4846
|
+
* Actions / Targets / PTY / Oneshot / Examples — the PTY block is the user's verbatim
|
|
4847
|
+
* final draft; Examples receipts match renderValue byte-for-byte).
|
|
4848
|
+
* ONE template literal — no backticks, no "${".
|
|
4849
|
+
*
|
|
4850
|
+
* 语义目标(surface 就是产品):动作是唯一的派发维度;PTY 四词定位——stateful oneshot /
|
|
4851
|
+
* interact-able stdin / managed output / persistent env——回执形状与 oneshot 相同、只增状态,
|
|
4852
|
+
* 运行中的命令可分步观察后再补输(input 动作);多步与交互任务默认进 PTY。
|
|
4212
4853
|
*/
|
|
4213
|
-
const REMOTE_TOOL_DESCRIPTION = `Remote
|
|
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).
|
|
4854
|
+
const REMOTE_TOOL_DESCRIPTION = `Remote shell (SSH, Docker, Incus, Hub): 1 default interactive PTY per machine (retains cwd/env). \`mode:"oneshot"\` = isolated, stateless.
|
|
4225
4855
|
|
|
4226
|
-
|
|
4856
|
+
Actions:
|
|
4857
|
+
- exec {target, cmd? | scriptPath?, stdin?, mode?:"pty"|"oneshot"="pty", timeout?, run_in_background?:boolean, commandId?:string}: run command / script / raw stdin write. Machine target = default PTY. PTY \`stdin\` alone = raw write into running command (optional \`commandId\` guard). \`run_in_background:true\` = async job, returns handle instantly, PTY handed off automatically.
|
|
4858
|
+
- handoff {target:<machine>}: detach machine's default PTY -> \`pty:<id>\`. Next exec spawns fresh default.
|
|
4859
|
+
- add {ssh:{...} | spawn:"<cmd>", target?}: register BYO identity/PTY. Returns label. Taken label answers \`[E_LABEL_IN_USE]\`.
|
|
4860
|
+
- status {target?}: roster pool (no target) or machine probe / session card.
|
|
4861
|
+
- close {target, force?:boolean}: close PTY shell. Machine target closes only default PTY. Running command blocks close unless \`force:true\`.
|
|
4227
4862
|
|
|
4228
|
-
|
|
4863
|
+
Targets & Semantics:
|
|
4864
|
+
- Targets: \`ssh:<n>\`, \`docker:<n>\`, \`incus:<n>\`, \`hub:<m4>\`, \`pty:<id|label>\` (or bare label). Busy PTY rejects new cmds (use input/poll/cancel/handoff/oneshot).
|
|
4865
|
+
- PTY: Stream includes echo/stderr. \`envDelta\` lists var names (no values). Expires after 15m idle. Timeout (default 120s) = sync wait limit (command keeps running as job: \`job_output\` / \`job_kill\`).
|
|
4866
|
+
- Oneshot: Separate stdout/stderr, verbatim exit code, stateless. \`stdin\` = startup only. Cached \`superd-run\` runner preferred over POSIX shim.
|
|
4867
|
+
- Script: \`scriptPath\` = local absolute file read before dial, runs as single \`bash -c\` cmd (same size limit). In PTY, runs as child process (\`cd\`/\`export\` do not affect parent shell). Hub inline cmd limit: 4096 chars.
|
|
4868
|
+
- Stdin: oneshot = \`fd0 → pipe\`; PTY = \`fd0 → pty slave\`, line disciplines. Literal bytes (\`\\n\`, \`\\u0003\` = Ctrl-C, \`\\u0004\` = Ctrl-D). Retention: 4M chars/stream (Hub oneshot 4MiB).
|
|
4229
4869
|
|
|
4230
|
-
|
|
4870
|
+
Examples:
|
|
4871
|
+
remote {"action":"exec","target":"ssh:dev4","cmd":"cd /srv/app && pwd"}
|
|
4872
|
+
remote {"action":"exec","target":"ssh:dev4","cmd":"make test","run_in_background":true}
|
|
4873
|
+
remote {"action":"handoff","target":"ssh:dev4"}
|
|
4874
|
+
remote {"action":"exec","target":"pty:pty-irsq","stdin":"yes\\n","commandId":"remote-47676dff2c6443e8"}
|
|
4875
|
+
remote {"action":"exec","target":"ssh:dev4","mode":"oneshot","cmd":"uname -a"}
|
|
4876
|
+
remote {"action":"add","spawn":"ssh -t pc ssh -t dev4","target":"pty:dev4via"}
|
|
4877
|
+
remote {"action":"exec","target":"pty:dev4via","cmd":"hostname"}
|
|
4878
|
+
remote {"action":"add","ssh":{"host":"10.0.0.8","user":"u1","port":22,"key":"~/.ssh/id_key4"},"target":"ssh:lab"}
|
|
4879
|
+
remote_transfer {"mode":"rsync","src":"ssh:lab:/var/log/app/","dst":"~/app-logs/","argv":["-a"]}`;
|
|
4880
|
+
/** action 是唯一派发维度:每个动词声明自己的字段集,超出即报错(不做在场推断、不留别名)。 */
|
|
4881
|
+
const ACTION_PARAMS = {
|
|
4882
|
+
exec: new Set([
|
|
4883
|
+
"action",
|
|
4884
|
+
"target",
|
|
4885
|
+
"cmd",
|
|
4886
|
+
"stdin",
|
|
4887
|
+
"scriptPath",
|
|
4888
|
+
"timeout",
|
|
4889
|
+
"run_in_background",
|
|
4890
|
+
"mode",
|
|
4891
|
+
"commandId"
|
|
4892
|
+
]),
|
|
4893
|
+
handoff: new Set(["action", "target"]),
|
|
4894
|
+
add: new Set([
|
|
4895
|
+
"action",
|
|
4896
|
+
"target",
|
|
4897
|
+
"ssh",
|
|
4898
|
+
"spawn"
|
|
4899
|
+
]),
|
|
4900
|
+
status: new Set(["action", "target"]),
|
|
4901
|
+
close: new Set([
|
|
4902
|
+
"action",
|
|
4903
|
+
"target",
|
|
4904
|
+
"force"
|
|
4905
|
+
])
|
|
4906
|
+
};
|
|
4907
|
+
const ACTION_HINT = {
|
|
4908
|
+
exec: "exec takes target plus cmd (or scriptPath, or stdin alone as a raw write into the running command) and the execution options; ssh/spawn belong to add",
|
|
4909
|
+
handoff: "handoff takes exactly one field besides action: target — the machine whose default PTY becomes pty:<id>",
|
|
4910
|
+
add: "add takes exactly one of ssh or spawn, plus an optional target label",
|
|
4911
|
+
status: "status takes only an optional target — omit it for the roster",
|
|
4912
|
+
close: "close takes a target (machine = its default PTY, or pty:<id|label>) and optional force — never a job id; cancel jobs with job_kill"
|
|
4913
|
+
};
|
|
4231
4914
|
async function remoteExecute(ENV, args, exec) {
|
|
4232
|
-
|
|
4233
|
-
|
|
4234
|
-
|
|
4235
|
-
|
|
4236
|
-
|
|
4237
|
-
|
|
4238
|
-
|
|
4239
|
-
|
|
4240
|
-
|
|
4241
|
-
|
|
4242
|
-
|
|
4243
|
-
|
|
4244
|
-
|
|
4245
|
-
|
|
4246
|
-
|
|
4247
|
-
|
|
4248
|
-
"
|
|
4249
|
-
|
|
4250
|
-
|
|
4251
|
-
|
|
4252
|
-
|
|
4253
|
-
|
|
4254
|
-
|
|
4255
|
-
|
|
4256
|
-
|
|
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;
|
|
4915
|
+
const action = args.action;
|
|
4916
|
+
if (action === void 0) throw new Error("[E_PARAMS] remote: action is required — \"exec\" (run a command or raw stdin write), \"handoff\" (detach a machine default PTY), \"add\" (register a BYO endpoint), \"status\" (roster / probe / session card), \"close\" (close a PTY shell)");
|
|
4917
|
+
const allowed = ACTION_PARAMS[action];
|
|
4918
|
+
if (allowed === void 0) throw new Error(`[E_PARAMS] remote: unknown action ${JSON.stringify(String(action))} — one of exec / handoff / add / close / status`);
|
|
4919
|
+
for (const key of Object.keys(args)) if (!allowed.has(key)) throw new Error(`[E_PARAMS] remote: "${key}" does not belong to action "${action}" — ${ACTION_HINT[action]}`);
|
|
4920
|
+
const sessionKey = ENV.getSessionKey(exec);
|
|
4921
|
+
const hasTarget = typeof args.target === "string" && args.target.length > 0;
|
|
4922
|
+
const hasCmd = typeof args.cmd === "string" && args.cmd.length > 0;
|
|
4923
|
+
if (action === "status") {
|
|
4924
|
+
if (!hasTarget) return {
|
|
4925
|
+
kind: "roster",
|
|
4926
|
+
text: await ENV.driver.roster({ sessionKey }),
|
|
4927
|
+
exit: null,
|
|
4928
|
+
durationMs: 0
|
|
4929
|
+
};
|
|
4930
|
+
return {
|
|
4931
|
+
kind: "status",
|
|
4932
|
+
text: await ENV.driver.status(args.target, { sessionKey }),
|
|
4933
|
+
exit: null,
|
|
4934
|
+
durationMs: 0
|
|
4935
|
+
};
|
|
4936
|
+
}
|
|
4937
|
+
if (action === "handoff") {
|
|
4938
|
+
if (!hasTarget) throw new Error("[E_PARAMS] remote: action \"handoff\" requires target — the machine whose default PTY becomes an addressable pty:<id> (ssh:dev4, docker:c, hub:<m4>, or a bare pool name)");
|
|
4939
|
+
const r$1 = await ENV.driver.handoff(args.target, { sessionKey });
|
|
4298
4940
|
return {
|
|
4299
|
-
kind: "
|
|
4300
|
-
text:
|
|
4301
|
-
|
|
4302
|
-
|
|
4303
|
-
|
|
4941
|
+
kind: "handoff",
|
|
4942
|
+
text: r$1.stdout,
|
|
4943
|
+
exit: null,
|
|
4944
|
+
durationMs: 0,
|
|
4945
|
+
session: r$1.session,
|
|
4946
|
+
connection: r$1.connection,
|
|
4947
|
+
...r$1.label !== void 0 ? { label: r$1.label } : {},
|
|
4948
|
+
...r$1.cwd !== null && r$1.cwd !== void 0 ? { cwd: r$1.cwd } : {},
|
|
4949
|
+
...r$1.created === true ? { created: true } : {},
|
|
4950
|
+
...r$1.running === true && r$1.commandId !== void 0 ? {
|
|
4951
|
+
running: true,
|
|
4952
|
+
commandId: r$1.commandId
|
|
4953
|
+
} : {},
|
|
4954
|
+
...r$1.ttlSec !== void 0 ? { ttlSec: r$1.ttlSec } : {}
|
|
4304
4955
|
};
|
|
4305
4956
|
}
|
|
4306
|
-
if (
|
|
4307
|
-
if (
|
|
4308
|
-
|
|
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);
|
|
4957
|
+
if (action === "close") {
|
|
4958
|
+
if (!hasTarget) throw new Error("[E_PARAMS] close requires target — a machine (closes its default PTY) or pty:<id|label>");
|
|
4959
|
+
const r$1 = await ENV.driver.close(args.target, sessionKey, args.force === true);
|
|
4318
4960
|
return {
|
|
4319
4961
|
kind: "close",
|
|
4320
4962
|
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"}`,
|
|
@@ -4326,44 +4968,72 @@ async function remoteExecute(ENV, args, exec) {
|
|
|
4326
4968
|
killed: r$1.killed
|
|
4327
4969
|
};
|
|
4328
4970
|
}
|
|
4329
|
-
if (
|
|
4330
|
-
args.
|
|
4331
|
-
args.
|
|
4332
|
-
args.
|
|
4333
|
-
args.
|
|
4334
|
-
|
|
4335
|
-
|
|
4336
|
-
|
|
4337
|
-
|
|
4338
|
-
|
|
4339
|
-
|
|
4340
|
-
|
|
4341
|
-
|
|
4342
|
-
|
|
4343
|
-
|
|
4971
|
+
if (action === "add") {
|
|
4972
|
+
const hasSpawn = typeof args.spawn === "string" && args.spawn.length > 0;
|
|
4973
|
+
if (args.spawn !== void 0 && !hasSpawn) throw new Error("[E_PARAMS] remote: add: spawn must be a non-empty command string");
|
|
4974
|
+
if (args.ssh === void 0 && !hasSpawn) throw new Error("[E_PARAMS] remote: action \"add\" registers a BYO endpoint — pass ssh:{host,...} (identity) or spawn:\"<command>\" (PTY), exactly one");
|
|
4975
|
+
if (args.ssh !== void 0 && hasSpawn) throw new Error("[E_PARAMS] remote: add: ssh and spawn are two different BYO entry points — pass one, not both");
|
|
4976
|
+
const r$1 = await ENV.driver.call({
|
|
4977
|
+
...hasTarget ? { target: args.target } : {},
|
|
4978
|
+
...args.ssh === void 0 ? {} : { ssh: args.ssh },
|
|
4979
|
+
...hasSpawn ? { spawn: args.spawn } : {},
|
|
4980
|
+
cmd: ""
|
|
4981
|
+
}, { sessionKey });
|
|
4982
|
+
const v$1 = workbenchValue(r$1);
|
|
4983
|
+
v$1.created = true;
|
|
4984
|
+
if (!hasSpawn) v$1.notice = `registered — address it as ${r$1.session ?? r$1.target} from now on (the label, id and ssh alias all work)`;
|
|
4985
|
+
return v$1;
|
|
4986
|
+
}
|
|
4987
|
+
if (!hasTarget) throw new Error("[E_PARAMS] remote: action \"exec\" requires target — a machine (ssh:dev4, docker:corti, hub:<m4>, a pool name) or a PTY session (pty:<id|label>, a bare label, or <machine>:pty[:label] to create one). Status probes and the roster live under action \"status\".");
|
|
4988
|
+
const target = args.target;
|
|
4989
|
+
if (args.commandId !== void 0 && args.commandId.length === 0) throw new Error("[E_PARAMS] remote: commandId, when given, must be the non-empty id from the exec receipt / job_output");
|
|
4990
|
+
if (args.scriptPath !== void 0) {
|
|
4991
|
+
if (args.scriptPath.length === 0) throw new Error("[E_PARAMS] remote: scriptPath must be a non-empty absolute path");
|
|
4992
|
+
if (hasCmd) throw new Error("[E_SCRIPT_PARAMS] remote: scriptPath and cmd are alternative carriers — pass one");
|
|
4993
|
+
}
|
|
4994
|
+
if (args.commandId !== void 0 && (hasCmd || args.scriptPath !== void 0)) throw new Error("[E_PARAMS] remote: commandId guards a raw stdin write (stdin alone, no cmd/scriptPath) — drop it for a normal command");
|
|
4995
|
+
if (args.stdin !== void 0 && !hasCmd && args.scriptPath === void 0) {
|
|
4996
|
+
if (args.mode === "oneshot") throw new Error("[E_STDIN_WITHOUT_CMD] remote: oneshot has no interactive lane — stdin without cmd/scriptPath is a raw write into a PTY running command; drop mode:\"oneshot\" (or pass cmd)");
|
|
4997
|
+
if (args.run_in_background === true) throw new Error("[E_PARAMS] remote: a raw stdin write cannot go background — it writes the command currently running in that PTY; follow it with job_output instead");
|
|
4998
|
+
const r$1 = await ENV.driver.input(target, {
|
|
4999
|
+
stdin: args.stdin,
|
|
5000
|
+
...args.commandId !== void 0 ? { commandId: args.commandId } : {}
|
|
5001
|
+
}, { sessionKey });
|
|
5002
|
+
const handle = r$1.jobId ?? r$1.commandId;
|
|
5003
|
+
return {
|
|
5004
|
+
kind: "input",
|
|
5005
|
+
text: `wrote ${r$1.writtenBytes} byte${r$1.writtenBytes === 1 ? "" : "s"} into ${r$1.session} for command ${r$1.commandId} — this confirms the write, not the program's response; read the response with job_output ${handle}`,
|
|
5006
|
+
exit: null,
|
|
5007
|
+
durationMs: 0,
|
|
5008
|
+
session: r$1.session,
|
|
5009
|
+
connection: r$1.connection,
|
|
5010
|
+
...r$1.label !== void 0 ? { label: r$1.label } : {},
|
|
5011
|
+
commandId: r$1.commandId,
|
|
5012
|
+
...r$1.jobId !== void 0 ? { jobId: r$1.jobId } : {},
|
|
5013
|
+
writtenBytes: r$1.writtenBytes
|
|
5014
|
+
};
|
|
5015
|
+
}
|
|
5016
|
+
if (!hasCmd && args.scriptPath === void 0) throw new Error("[E_PARAMS] remote: action \"exec\" must execute something — pass cmd, or scriptPath for a local script file, or stdin alone for a raw write into a PTY running command");
|
|
4344
5017
|
if (args.run_in_background) {
|
|
4345
|
-
if (!hasCmd && args.scriptPath === void 0) throw new Error("remote: background requires cmd or scriptPath");
|
|
4346
5018
|
const jobs = exec.agent?.ctx.get("jobs");
|
|
4347
5019
|
if (!jobs) throw new Error("remote: background requires the official DSH jobs service and job_output/job_kill controller; foreground execution remains available");
|
|
4348
5020
|
const lane = await ENV.driver.preparePtyLane({
|
|
4349
5021
|
target: args.target,
|
|
4350
|
-
spawn: args.spawn,
|
|
4351
5022
|
cmd: args.cmd,
|
|
4352
5023
|
scriptPath: args.scriptPath,
|
|
4353
|
-
ssh: args.ssh,
|
|
4354
5024
|
stdin: args.stdin,
|
|
4355
|
-
timeout: args.timeout
|
|
4356
|
-
|
|
5025
|
+
timeout: args.timeout,
|
|
5026
|
+
...args.mode !== void 0 ? { mode: args.mode } : {}
|
|
5027
|
+
}, { sessionKey });
|
|
4357
5028
|
let jobId;
|
|
4358
5029
|
try {
|
|
4359
5030
|
jobId = startRemoteJob(jobs, lane !== void 0 ? lane.input : {
|
|
4360
5031
|
target: args.target,
|
|
4361
|
-
spawn: args.spawn,
|
|
4362
5032
|
cmd: args.cmd,
|
|
4363
5033
|
scriptPath: args.scriptPath,
|
|
4364
|
-
ssh: args.ssh,
|
|
4365
5034
|
stdin: args.stdin,
|
|
4366
|
-
timeout: args.timeout
|
|
5035
|
+
timeout: args.timeout,
|
|
5036
|
+
...args.mode !== void 0 ? { mode: args.mode } : {}
|
|
4367
5037
|
}, exec, ENV.driver);
|
|
4368
5038
|
} catch (error) {
|
|
4369
5039
|
lane?.cleanup();
|
|
@@ -4377,44 +5047,21 @@ async function remoteExecute(ENV, args, exec) {
|
|
|
4377
5047
|
durationMs: 0,
|
|
4378
5048
|
text: `Started ${jobId}${where}. Use job_output to read and job_kill to cancel; completion is reported by the host.`,
|
|
4379
5049
|
...lane !== void 0 ? {
|
|
5050
|
+
commandId: jobId,
|
|
4380
5051
|
session: lane.session,
|
|
4381
5052
|
connection: lane.connection,
|
|
4382
5053
|
...lane.label !== void 0 ? { label: lane.label } : {}
|
|
4383
5054
|
} : {}
|
|
4384
5055
|
};
|
|
4385
5056
|
}
|
|
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
5057
|
const r = await ENV.driver.call({
|
|
4410
5058
|
target: args.target,
|
|
4411
|
-
spawn: args.spawn,
|
|
4412
|
-
ssh: args.ssh,
|
|
4413
5059
|
cmd: args.cmd,
|
|
4414
5060
|
scriptPath: args.scriptPath,
|
|
4415
5061
|
stdin: args.stdin,
|
|
4416
|
-
timeout: args.timeout
|
|
4417
|
-
|
|
5062
|
+
timeout: args.timeout,
|
|
5063
|
+
...args.mode !== void 0 ? { mode: args.mode } : {}
|
|
5064
|
+
}, { sessionKey });
|
|
4418
5065
|
const v = workbenchValue(r);
|
|
4419
5066
|
if (r.running === true && r.ptyJob !== void 0) {
|
|
4420
5067
|
const jobs = exec.agent?.ctx.get("jobs");
|
|
@@ -4422,7 +5069,7 @@ async function remoteExecute(ENV, args, exec) {
|
|
|
4422
5069
|
const jobId = adoptRunningPtyJob(jobs, exec, `${r.target}: ${args.cmd}`, r.ptyJob);
|
|
4423
5070
|
v.jobId = jobId;
|
|
4424
5071
|
v.running = true;
|
|
4425
|
-
if (r.commandId !== void 0 && jobId !== r.commandId) ENV.driver.attachPtyJob(r.commandId, jobId,
|
|
5072
|
+
if (r.commandId !== void 0 && jobId !== r.commandId) ENV.driver.attachPtyJob(r.commandId, jobId, sessionKey);
|
|
4426
5073
|
} else {
|
|
4427
5074
|
r.ptyJob.interrupt();
|
|
4428
5075
|
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.";
|
|
@@ -4431,7 +5078,7 @@ async function remoteExecute(ENV, args, exec) {
|
|
|
4431
5078
|
return v;
|
|
4432
5079
|
}
|
|
4433
5080
|
function createRemoteTool(driver, opts = {}) {
|
|
4434
|
-
const getSessionKey = opts.getSessionKey ?? ((exec) => exec.agent?.id ?? NO_SESSION_KEY);
|
|
5081
|
+
const getSessionKey = opts.getSessionKey ?? ((exec) => exec.agent?.id ?? NO_SESSION_KEY$1);
|
|
4435
5082
|
const tool = defineTool({
|
|
4436
5083
|
name: "remote",
|
|
4437
5084
|
description: REMOTE_TOOL_DESCRIPTION,
|
|
@@ -4439,58 +5086,47 @@ function createRemoteTool(driver, opts = {}) {
|
|
|
4439
5086
|
action: {
|
|
4440
5087
|
type: "string",
|
|
4441
5088
|
enum: [
|
|
4442
|
-
"
|
|
4443
|
-
"
|
|
5089
|
+
"exec",
|
|
5090
|
+
"handoff",
|
|
5091
|
+
"add",
|
|
5092
|
+
"status",
|
|
4444
5093
|
"close"
|
|
4445
5094
|
],
|
|
4446
|
-
|
|
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."
|
|
5095
|
+
required: true,
|
|
5096
|
+
description: "Required. exec = run a command / script / raw stdin write (machine target defaults to its reusable PTY); handoff = detach a machine's default PTY as pty:<id>; add = register a BYO ssh identity or BYO PTY into the pool; status = roster (no target) or probe/session card (target); close = close a PTY shell (no job id — cancel with job_kill)."
|
|
4451
5097
|
},
|
|
4452
|
-
|
|
5098
|
+
target: {
|
|
4453
5099
|
type: "string",
|
|
4454
|
-
description: "
|
|
5100
|
+
description: "exec: required — a machine (bare pool name, ssh:<name>, docker:<name>, incus:<name>, hub:<m4>; its default PTY unless mode is oneshot) or a live PTY session (pty:<id|label> or its bare label). handoff: the machine whose default PTY detaches. add: optional label for the BYO entry. status: optional — omit for the roster. close: a machine (its default PTY) or pty:<id|label>."
|
|
4455
5101
|
},
|
|
4456
|
-
|
|
5102
|
+
cmd: {
|
|
4457
5103
|
type: "string",
|
|
4458
|
-
description: "
|
|
5104
|
+
description: "exec: one raw shell string parsed by the remote bash (multi-line = one compound; exit = last command's). Required unless scriptPath is given."
|
|
4459
5105
|
},
|
|
4460
|
-
|
|
4461
|
-
type: "
|
|
4462
|
-
|
|
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."
|
|
4464
|
-
},
|
|
4465
|
-
pullArgv: {
|
|
4466
|
-
type: "array",
|
|
4467
|
-
items: { type: "string" },
|
|
4468
|
-
description: "Options for the PULL half (source endpoint → origin staging) of a two-command cross-endpoint copy (e.g. SSH↔Docker, SSH↔Incus, Docker↔Incus). Mutually exclusive with argv; must be paired with pushArgv. Only options valid for the pull half's CLI."
|
|
5106
|
+
stdin: {
|
|
5107
|
+
type: "string",
|
|
5108
|
+
description: "exec: with cmd/scriptPath = startup input (oneshot: piped; pty: REPL-style right after the command line; with scriptPath it is the script process's own stdin). Alone (no cmd/scriptPath) = a raw write into the PTY's RUNNING command — exact bytes; \"\" writes zero bytes (not EOF); \"\\n\" submits a line; \"\\u0003\" = Ctrl-C, \"\\u0004\" = Ctrl-D as raw characters; after a raw \"\\u0003\" settle the job with job_kill."
|
|
4469
5109
|
},
|
|
4470
|
-
|
|
4471
|
-
type: "
|
|
4472
|
-
|
|
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."
|
|
5110
|
+
commandId: {
|
|
5111
|
+
type: "string",
|
|
5112
|
+
description: "exec stdin-only: optional guard — when given it must equal the running command's id (from the exec receipt or job_output); a stale id refuses the write instead of feeding the next command. Not valid with cmd/scriptPath."
|
|
4474
5113
|
},
|
|
4475
|
-
|
|
4476
|
-
type: "
|
|
4477
|
-
description: "
|
|
5114
|
+
timeout: {
|
|
5115
|
+
type: "number",
|
|
5116
|
+
description: "exec: 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)."
|
|
4478
5117
|
},
|
|
4479
|
-
|
|
4480
|
-
type: "
|
|
4481
|
-
|
|
5118
|
+
mode: {
|
|
5119
|
+
type: "string",
|
|
5120
|
+
enum: ["pty", "oneshot"],
|
|
5121
|
+
description: "exec: pty (default) — a machine target reuses its default interactive PTY (cwd/env persist); oneshot — isolated, stateless, stdout/stderr split, verbatim exit. Redundant on pty:<id|label> targets; \"oneshot\" on an addressable PTY is an error."
|
|
4482
5122
|
},
|
|
4483
5123
|
run_in_background: {
|
|
4484
5124
|
type: "boolean",
|
|
4485
|
-
description: "
|
|
4486
|
-
},
|
|
4487
|
-
target: {
|
|
4488
|
-
type: "string",
|
|
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)."
|
|
5125
|
+
description: "exec with cmd/scriptPath only: the command went async (not bash &) — a running job returns immediately and a completion notification will follow, so no polling is required. Oneshot runs async; on a machine's default PTY the framed command starts and that PTY is handed off in the same operation; on an addressable PTY it keeps its identity. job_output reads the command's full stream from byte 0 (overlap with the pre-handoff receipt is expected); job_kill cancels. Requires the official jobs service/controller."
|
|
4490
5126
|
},
|
|
4491
5127
|
ssh: {
|
|
4492
5128
|
type: "object",
|
|
4493
|
-
description: "BYO ssh
|
|
5129
|
+
description: "add: BYO ssh identity to register into the pool (no dialing). Later exec calls address the returned label. No inline keys or arbitrary SSH options; nothing is persisted — a new Agent registers again.",
|
|
4494
5130
|
additionalProperties: false,
|
|
4495
5131
|
properties: {
|
|
4496
5132
|
host: {
|
|
@@ -4518,23 +5154,11 @@ function createRemoteTool(driver, opts = {}) {
|
|
|
4518
5154
|
},
|
|
4519
5155
|
spawn: {
|
|
4520
5156
|
type: "string",
|
|
4521
|
-
description: "BYO
|
|
4522
|
-
},
|
|
4523
|
-
cmd: {
|
|
4524
|
-
type: "string",
|
|
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."
|
|
4526
|
-
},
|
|
4527
|
-
scriptPath: {
|
|
4528
|
-
type: "string",
|
|
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."
|
|
5157
|
+
description: "add: BYO PTY 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)."
|
|
4530
5158
|
},
|
|
4531
|
-
|
|
4532
|
-
type: "
|
|
4533
|
-
description: "
|
|
4534
|
-
},
|
|
4535
|
-
timeout: {
|
|
4536
|
-
type: "number",
|
|
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)."
|
|
5159
|
+
force: {
|
|
5160
|
+
type: "boolean",
|
|
5161
|
+
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."
|
|
4538
5162
|
}
|
|
4539
5163
|
},
|
|
4540
5164
|
output: {
|
|
@@ -4547,12 +5171,12 @@ function createRemoteTool(driver, opts = {}) {
|
|
|
4547
5171
|
required: true,
|
|
4548
5172
|
enum: [
|
|
4549
5173
|
"exec",
|
|
5174
|
+
"handoff",
|
|
4550
5175
|
"status",
|
|
4551
5176
|
"roster",
|
|
4552
5177
|
"job",
|
|
4553
|
-
"
|
|
4554
|
-
"
|
|
4555
|
-
"close"
|
|
5178
|
+
"close",
|
|
5179
|
+
"input"
|
|
4556
5180
|
]
|
|
4557
5181
|
},
|
|
4558
5182
|
text: {
|
|
@@ -4584,6 +5208,10 @@ function createRemoteTool(driver, opts = {}) {
|
|
|
4584
5208
|
type: "string",
|
|
4585
5209
|
description: "ledger key; equals the jobId whenever a job exists"
|
|
4586
5210
|
},
|
|
5211
|
+
writtenBytes: {
|
|
5212
|
+
type: "integer",
|
|
5213
|
+
description: "input: UTF-8 bytes actually written; confirms the write, not the program response"
|
|
5214
|
+
},
|
|
4587
5215
|
envDelta: {
|
|
4588
5216
|
type: "json",
|
|
4589
5217
|
description: "names only: {added,changed,removed}"
|
|
@@ -4592,7 +5220,7 @@ function createRemoteTool(driver, opts = {}) {
|
|
|
4592
5220
|
notice: { type: "string" },
|
|
4593
5221
|
runner: {
|
|
4594
5222
|
type: "string",
|
|
4595
|
-
description: "oneshot supervision path: superd-run <ver> (<triple>) or
|
|
5223
|
+
description: "oneshot supervision path: superd-run <ver> (<triple>), posix-shim, or hub-lease (peer hub server supervises natively)"
|
|
4596
5224
|
},
|
|
4597
5225
|
running: { type: "boolean" },
|
|
4598
5226
|
ttlSec: { type: "integer" },
|
|
@@ -4626,7 +5254,7 @@ function createRemoteTool(driver, opts = {}) {
|
|
|
4626
5254
|
const description = tool.description;
|
|
4627
5255
|
Object.defineProperty(tool, "description", {
|
|
4628
5256
|
enumerable: true,
|
|
4629
|
-
get: () => description + (driver.hasHub() ? "\nHub transport available: hub:<m4> selects a paired remote machine (never self).
|
|
5257
|
+
get: () => description + (driver.hasHub() ? "\nHub transport available: hub:<m4> selects a paired remote machine (never self; the roster lists them). Hub failures never fall back to SSH.\n- hub:<m4> oneshot: the peer hub server is the runner itself — bash -lc in a detached group, TERM→2s→KILL cleanup; the receipt runner field says \"hub-lease\" (the superd-run helper is a dumb-pipe artifact, never uploaded over the Hub). The peer caps inline commands at 4096 chars — and a scriptPath IS carried as one bash -c command, so hub-oneshot scripts share that cap (split large work or transfer the file and run it with cmd).\n- hub:<m4> as a machine target has the SAME default-PTY semantics as every other transport — exec opens the default session (a local pump child over one authenticated WebSocket to the peer terminal), handoff / background / stdin-only / close / idle TTL all work identically." : "")
|
|
4630
5258
|
});
|
|
4631
5259
|
return tool;
|
|
4632
5260
|
}
|
|
@@ -4657,26 +5285,6 @@ function workbenchValue(r) {
|
|
|
4657
5285
|
if (r.ttlSec !== void 0) v.ttlSec = r.ttlSec;
|
|
4658
5286
|
return v;
|
|
4659
5287
|
}
|
|
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
5288
|
function renderEnvDelta(d) {
|
|
4681
5289
|
const bits = [];
|
|
4682
5290
|
if (d.added.length > 0) bits.push(`+${d.added.join(",+")}`);
|
|
@@ -4685,7 +5293,7 @@ function renderEnvDelta(d) {
|
|
|
4685
5293
|
return bits.length > 0 ? bits.join(" ") : void 0;
|
|
4686
5294
|
}
|
|
4687
5295
|
function renderValue(v) {
|
|
4688
|
-
if (v.kind === "status" || v.kind === "roster" || v.kind === "job" || v.kind === "
|
|
5296
|
+
if (v.kind === "status" || v.kind === "roster" || v.kind === "job" || v.kind === "close" || v.kind === "input" || v.kind === "handoff") return v.text;
|
|
4689
5297
|
const parts = [];
|
|
4690
5298
|
if (v.reconnected === true) parts.push(RECONNECT_NOTICE);
|
|
4691
5299
|
if (typeof v.notice === "string" && v.notice.length > 0) parts.push(v.notice);
|
|
@@ -4706,15 +5314,188 @@ function renderValue(v) {
|
|
|
4706
5314
|
markers.push(`${secs}s`);
|
|
4707
5315
|
if (v.running === true) markers.push(`still running — poll job_output${v.jobId !== void 0 ? ` ${v.jobId}` : ""}`);
|
|
4708
5316
|
if (v.truncated !== void 0) markers.push(`truncated, ${v.outputStatsUnavailable ? "returned peer tail" : "original"} ${v.truncated} ${v.outputStats?.stdout.unit === "bytes" ? "bytes" : "chars"}`);
|
|
4709
|
-
if (v.outputStatsUnavailable) markers.push("peer output totals/retention unavailable
|
|
5317
|
+
if (v.outputStatsUnavailable) markers.push("peer output totals/retention unavailable");
|
|
4710
5318
|
parts.push(`[${markers.join(" · ")}]`);
|
|
4711
5319
|
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");
|
|
4712
5320
|
if (v.stderr !== void 0 && v.stderr.length > 0) parts.push("[stderr]", v.stderr);
|
|
4713
5321
|
return parts.join("\n");
|
|
4714
5322
|
}
|
|
4715
5323
|
|
|
5324
|
+
//#endregion
|
|
5325
|
+
//#region src/remote/transfer-tool.ts
|
|
5326
|
+
const NO_SESSION_KEY = "remote:no-session";
|
|
5327
|
+
/**
|
|
5328
|
+
* The model-visible surface of the `remote_transfer` tool (split out of
|
|
5329
|
+
* `remote`, 2026-10-25). ONE template literal — no backticks, no "${".
|
|
5330
|
+
*/
|
|
5331
|
+
const TRANSFER_TOOL_DESCRIPTION = `File transfer between any two supported endpoints. Every call declares its mode:
|
|
5332
|
+
mode:"copy" → tree copy. Native CLI per endpoint (scp for ssh, docker cp, incus file push/pull), a plain tree copy when both ends are local, and the Hub binary lane when a Hub machine is involved. Directories copy recursively; exit codes are authoritative.
|
|
5333
|
+
mode:"rsync" → rsync over SSH only: at least one endpoint remote SSH, the other local or SSH (both remote legs tunnel through this machine; identities never leave it). Hub and container endpoints are rejected — no silent copy fallback. Trailing slashes keep rsync semantics.
|
|
5334
|
+
|
|
5335
|
+
Endpoints are "[target:]absolute path". No target = the machine running this Agent. Targets are the same unified pool remote uses: a bare pool name, ssh:<name>, docker:<name>, incus:<name>, hub:<m4>. Examples: "/srv/app", "ssh:dev4:/srv/app", "docker:corti:/var/lib/x", "hub:m4:/srv". A path is never guessed: both operands must be absolute.
|
|
5336
|
+
|
|
5337
|
+
Native CLI options travel through argv verbatim — one element = one token, appended before src/dst, no shell parsing and no whitelist: rsync ["-a","--partial"], scp ["-p","-r"], docker cp ["-a","-L"], incus file push ["--recursive"]. Cross-endpoint copies that need two commands (SSH↔Docker, SSH↔Incus, Docker↔Incus) take pullArgv plus pushArgv instead of argv. rsync is a single SSH command, so it takes argv only; an explicit -e/--rsh overrides the wiring.
|
|
5338
|
+
|
|
5339
|
+
Options: verify (copy only) — the Hub↔local staged lane computes independent SHA-256 digests by default and verify:false skips them; native CLI routes have no independent hash tier, so verify is rejected there (self-verify through a oneshot digest command). mkdir (copy only, default true) — one best-effort pre-hop creates the destination PARENT directory; the operand itself is never pre-created, so each CLI's own layout semantics stay byte-identical.
|
|
5340
|
+
|
|
5341
|
+
Failures are loud: a non-zero native exit throws with the receipt instead of returning a normal-looking result. The receipt stays in the receipt field (route, exits, verification, bytes/entries, cleanup); text is one formatted line. For checksum-grade verification of a native route, run a digest command through remote oneshot or PTY instead of trusting the copy receipt.`;
|
|
5342
|
+
const KNOWN_PARAMS = new Set([
|
|
5343
|
+
"mode",
|
|
5344
|
+
"src",
|
|
5345
|
+
"dst",
|
|
5346
|
+
"argv",
|
|
5347
|
+
"pullArgv",
|
|
5348
|
+
"pushArgv",
|
|
5349
|
+
"verify",
|
|
5350
|
+
"mkdir"
|
|
5351
|
+
]);
|
|
5352
|
+
async function transferExecute(ENV, args, exec) {
|
|
5353
|
+
for (const key of Object.keys(args)) if (!KNOWN_PARAMS.has(key)) throw new Error(`remote_transfer: unknown parameter "${key}" — this surface has no legacy aliases; pass native CLI options through argv instead`);
|
|
5354
|
+
if (args.mode !== "copy" && args.mode !== "rsync") throw new Error(`[E_PARAMS] remote_transfer: mode is required — "copy" (tree copy through each endpoint's native CLI or the Hub lane) or "rsync" (SSH only)`);
|
|
5355
|
+
if (typeof args.src !== "string" || args.src.length === 0 || typeof args.dst !== "string" || args.dst.length === 0) throw new Error(`[E_PARAMS] remote_transfer: ${args.mode} requires src and dst absolute endpoints`);
|
|
5356
|
+
const sessionKey = ENV.getSessionKey(exec);
|
|
5357
|
+
if (args.mode === "rsync") {
|
|
5358
|
+
if (args.pullArgv !== void 0 || args.pushArgv !== void 0) throw new Error("remote_transfer: rsync takes argv only; pullArgv/pushArgv are copy-only fields");
|
|
5359
|
+
if (args.verify !== void 0 || args.mkdir !== void 0) throw new Error("remote_transfer: rsync cannot be combined with copy verify/mkdir options; the Hub/local staged lane is copy-only");
|
|
5360
|
+
const start$1 = Date.now();
|
|
5361
|
+
const receipt$1 = await ENV.driver.rsync({
|
|
5362
|
+
src: args.src,
|
|
5363
|
+
dst: args.dst,
|
|
5364
|
+
...args.argv === void 0 ? {} : { argv: args.argv }
|
|
5365
|
+
}, exec.signal, { sessionKey });
|
|
5366
|
+
if (receipt$1.ok === false || receipt$1.exit !== 0) throw new Error(JSON.stringify(receipt$1));
|
|
5367
|
+
const durationMs$1 = Date.now() - start$1;
|
|
5368
|
+
return {
|
|
5369
|
+
kind: "rsync",
|
|
5370
|
+
text: formatTransferLine("rsync", receipt$1, durationMs$1),
|
|
5371
|
+
receipt: receipt$1,
|
|
5372
|
+
exit: receipt$1.exit,
|
|
5373
|
+
durationMs: durationMs$1
|
|
5374
|
+
};
|
|
5375
|
+
}
|
|
5376
|
+
const start = Date.now();
|
|
5377
|
+
const receipt = await ENV.driver.copy({
|
|
5378
|
+
src: args.src,
|
|
5379
|
+
dst: args.dst,
|
|
5380
|
+
...args.verify === void 0 ? {} : { verify: args.verify },
|
|
5381
|
+
...args.mkdir === void 0 ? {} : { mkdir: args.mkdir },
|
|
5382
|
+
...args.argv === void 0 ? {} : { argv: args.argv },
|
|
5383
|
+
...args.pullArgv === void 0 ? {} : { pullArgv: args.pullArgv },
|
|
5384
|
+
...args.pushArgv === void 0 ? {} : { pushArgv: args.pushArgv }
|
|
5385
|
+
}, exec.signal, { sessionKey });
|
|
5386
|
+
const asReceipt = receipt;
|
|
5387
|
+
const exits = asReceipt.exits ?? [];
|
|
5388
|
+
if (asReceipt.ok === false || exits.some((code) => code !== 0)) throw new Error(JSON.stringify(receipt));
|
|
5389
|
+
const durationMs = Date.now() - start;
|
|
5390
|
+
return {
|
|
5391
|
+
kind: "copy",
|
|
5392
|
+
text: formatTransferLine("copy", receipt, durationMs),
|
|
5393
|
+
receipt,
|
|
5394
|
+
exit: exits.length > 0 ? 0 : null,
|
|
5395
|
+
durationMs
|
|
5396
|
+
};
|
|
5397
|
+
}
|
|
5398
|
+
function createRemoteTransferTool(driver, opts = {}) {
|
|
5399
|
+
const getSessionKey = opts.getSessionKey ?? ((exec) => exec.agent?.id ?? NO_SESSION_KEY);
|
|
5400
|
+
return defineTool({
|
|
5401
|
+
name: "remote_transfer",
|
|
5402
|
+
description: TRANSFER_TOOL_DESCRIPTION,
|
|
5403
|
+
parameters: {
|
|
5404
|
+
mode: {
|
|
5405
|
+
type: "string",
|
|
5406
|
+
enum: ["copy", "rsync"],
|
|
5407
|
+
required: true,
|
|
5408
|
+
description: "Required. copy = tree copy through each endpoint's native CLI, or the Hub binary lane; rsync = SSH only (argv only, no verify/mkdir)."
|
|
5409
|
+
},
|
|
5410
|
+
src: {
|
|
5411
|
+
type: "string",
|
|
5412
|
+
description: "Source [target:]absolute path. No target = the machine running this Agent."
|
|
5413
|
+
},
|
|
5414
|
+
dst: {
|
|
5415
|
+
type: "string",
|
|
5416
|
+
description: "Destination [target:]absolute path. No target = the machine running this Agent."
|
|
5417
|
+
},
|
|
5418
|
+
argv: {
|
|
5419
|
+
type: "array",
|
|
5420
|
+
items: { type: "string" },
|
|
5421
|
+
description: "Ordered native CLI options and values for one-command routes (e.g. rsync [\"-a\",\"--partial\",\"--dry-run\"], scp [\"-p\",\"-r\"], docker cp [\"-a\",\"-L\"], 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."
|
|
5422
|
+
},
|
|
5423
|
+
pullArgv: {
|
|
5424
|
+
type: "array",
|
|
5425
|
+
items: { type: "string" },
|
|
5426
|
+
description: "Options for the PULL half (source endpoint → origin staging) of a two-command cross-endpoint copy (e.g. SSH↔Docker, SSH↔Incus, Docker↔Incus). Mutually exclusive with argv; must be paired with pushArgv. Only options valid for the pull half's CLI."
|
|
5427
|
+
},
|
|
5428
|
+
pushArgv: {
|
|
5429
|
+
type: "array",
|
|
5430
|
+
items: { type: "string" },
|
|
5431
|
+
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."
|
|
5432
|
+
},
|
|
5433
|
+
verify: {
|
|
5434
|
+
type: "boolean",
|
|
5435
|
+
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."
|
|
5436
|
+
},
|
|
5437
|
+
mkdir: {
|
|
5438
|
+
type: "boolean",
|
|
5439
|
+
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."
|
|
5440
|
+
}
|
|
5441
|
+
},
|
|
5442
|
+
output: {
|
|
5443
|
+
schema: {
|
|
5444
|
+
type: "object",
|
|
5445
|
+
additionalProperties: false,
|
|
5446
|
+
properties: {
|
|
5447
|
+
kind: {
|
|
5448
|
+
type: "string",
|
|
5449
|
+
required: true,
|
|
5450
|
+
enum: ["copy", "rsync"]
|
|
5451
|
+
},
|
|
5452
|
+
text: {
|
|
5453
|
+
type: "string",
|
|
5454
|
+
required: true
|
|
5455
|
+
},
|
|
5456
|
+
receipt: { type: "json" },
|
|
5457
|
+
exit: { oneOf: [{ type: "integer" }, { type: "null" }] },
|
|
5458
|
+
durationMs: { type: "integer" }
|
|
5459
|
+
}
|
|
5460
|
+
},
|
|
5461
|
+
render: (_args, value) => [{
|
|
5462
|
+
type: "text",
|
|
5463
|
+
text: value.text
|
|
5464
|
+
}]
|
|
5465
|
+
},
|
|
5466
|
+
async execute(args, exec) {
|
|
5467
|
+
return transferExecute({
|
|
5468
|
+
driver,
|
|
5469
|
+
getSessionKey
|
|
5470
|
+
}, args, exec);
|
|
5471
|
+
}
|
|
5472
|
+
});
|
|
5473
|
+
}
|
|
5474
|
+
/** S8 (2026-10-17): copy/rsync render as one formatted line; the full receipt
|
|
5475
|
+
* stays in the receipt field — raw-JSON dumps are gone from text. */
|
|
5476
|
+
function formatTransferLine(mode, receipt, durationMs) {
|
|
5477
|
+
const r = receipt;
|
|
5478
|
+
const parts = [];
|
|
5479
|
+
if (r.ok === false) parts.push(`✗ ${r.reason ?? "failed"}`);
|
|
5480
|
+
parts.push(`${mode} ${r.src} → ${r.dst}`);
|
|
5481
|
+
if (r.route !== void 0 && r.route !== "") parts.push(`route ${r.route}`);
|
|
5482
|
+
if (r.exits !== void 0) parts.push(`exits [${r.exits.join(",")}]`);
|
|
5483
|
+
else if (r.exit !== void 0) parts.push(`exit ${r.exit}`);
|
|
5484
|
+
if (r.verification !== void 0) parts.push(`verification ${r.verification}`);
|
|
5485
|
+
if (r.created === true) parts.push("created yes");
|
|
5486
|
+
else if (r.created === false) parts.push("created pre-existing");
|
|
5487
|
+
else if (r.created === "n-a") parts.push("created n-a");
|
|
5488
|
+
if (r.cleanup !== void 0 && r.cleanup !== "none-required") parts.push(`cleanup ${r.cleanup}`);
|
|
5489
|
+
if (typeof r.bytes === "number") parts.push(`${r.bytes} bytes / ${r.entries} entries`);
|
|
5490
|
+
parts.push(`${(durationMs / 1e3).toFixed(1)}s`);
|
|
5491
|
+
if (r.note !== void 0 && r.note.length > 0) parts.push(`(${r.note})`);
|
|
5492
|
+
return parts.join(" · ");
|
|
5493
|
+
}
|
|
5494
|
+
|
|
4716
5495
|
//#endregion
|
|
4717
5496
|
//#region src/remote/agent-registration.ts
|
|
5497
|
+
/** The pair a row contributes: execution/PTY and file transfer are separate tools. */
|
|
5498
|
+
const TOOL_NAMES = ["remote", "remote_transfer"];
|
|
4718
5499
|
/** Host listeners attempt once per official creation edge, never on vacancy. */
|
|
4719
5500
|
function installAgentRemote(ctx, config) {
|
|
4720
5501
|
return ctx.on("agent/created", ({ agent }) => {
|
|
@@ -4724,12 +5505,14 @@ function installAgentRemote(ctx, config) {
|
|
|
4724
5505
|
...config,
|
|
4725
5506
|
getHub: () => agent.ctx.get("superdRemoteTransport")
|
|
4726
5507
|
});
|
|
4727
|
-
|
|
5508
|
+
const registered = [];
|
|
4728
5509
|
try {
|
|
4729
|
-
|
|
5510
|
+
registered.push(tools.register(createRemoteTool(driver, { getSessionKey: () => agent.id })));
|
|
5511
|
+
registered.push(tools.register(createRemoteTransferTool(driver, { getSessionKey: () => agent.id })));
|
|
4730
5512
|
} catch (error) {
|
|
5513
|
+
for (const undo of registered.splice(0)) undo();
|
|
4731
5514
|
driver.dispose();
|
|
4732
|
-
if (error instanceof Error && error.message ===
|
|
5515
|
+
if (TOOL_NAMES.find((name$1) => error instanceof Error && error.message === `tool "${name$1}" is already registered in this scope` && tools.get(name$1, agent) !== void 0) !== void 0) return;
|
|
4733
5516
|
throw error;
|
|
4734
5517
|
}
|
|
4735
5518
|
const stopService = agent.ctx.on("internal/service", (name$1) => {
|
|
@@ -4741,7 +5524,7 @@ function installAgentRemote(ctx, config) {
|
|
|
4741
5524
|
const cleanup = () => {
|
|
4742
5525
|
if (disposal) return disposal;
|
|
4743
5526
|
disposal = driver.dispose();
|
|
4744
|
-
|
|
5527
|
+
for (const undo of registered.splice(0)) undo();
|
|
4745
5528
|
stopService();
|
|
4746
5529
|
ownerUndo?.();
|
|
4747
5530
|
agentUndo?.();
|
|
@@ -4797,12 +5580,10 @@ const name = "dashr-remote";
|
|
|
4797
5580
|
const inject = [];
|
|
4798
5581
|
const Config = Schema.object({
|
|
4799
5582
|
execTimeoutSec: Schema.natural().min(1).default(120),
|
|
4800
|
-
idleTtlSec: Schema.natural().min(1).default(REMOTE_PTY_IDLE_TTL_SEC)
|
|
4801
|
-
maxOutputChars: Schema.natural().min(1).default(3e4)
|
|
5583
|
+
idleTtlSec: Schema.natural().min(1).default(REMOTE_PTY_IDLE_TTL_SEC)
|
|
4802
5584
|
}).default({
|
|
4803
5585
|
execTimeoutSec: 120,
|
|
4804
|
-
idleTtlSec: REMOTE_PTY_IDLE_TTL_SEC
|
|
4805
|
-
maxOutputChars: 3e4
|
|
5586
|
+
idleTtlSec: REMOTE_PTY_IDLE_TTL_SEC
|
|
4806
5587
|
});
|
|
4807
5588
|
/** MUST stay an arrow(RM0 plugin.ts 同注:function 声明会被 cordis 当构造器 new 掉,返回的 disposer 丢失)。 */
|
|
4808
5589
|
const apply = (ctx, config) => {
|
|
@@ -4816,4 +5597,4 @@ var plugin_default = {
|
|
|
4816
5597
|
};
|
|
4817
5598
|
|
|
4818
5599
|
//#endregion
|
|
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 };
|
|
5600
|
+
export { ByteTailRing, Config, FILE_FRAME_BYTES, FILE_MAX_ENTRIES, FILE_MAX_METADATA, PROCESS_CLEANUP_BOUND_MS, PtySession, Utf16TailRing, apply, buildHubPtyArgv, checkAbsolute, checkSelection, plugin_default as default, disclose, hubPtyAssets, inject, localFileEndpoint, name, readUtf8Tail, runFileWorker, runRsync, sshArgv, startBinaryProcess, startOneShot, startRemoteJob };
|