better-dsh 0.3.3 → 0.3.4-a
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/adr/0015-independent-bundles-agent-remote.md +4 -1
- package/docs/remote-tools.md +6 -6
- package/docs/specs/remote-tool-enhancement/spec.md +1 -0
- package/lib/remote/plugin.d.ts +93 -20
- package/lib/remote/plugin.js +921 -452
- package/lib/web-trust2.d.ts +11 -11
- package/lib/welcome-notice.d.ts +3 -3
- package/package.json +64 -64
package/lib/remote/plugin.js
CHANGED
|
@@ -2,7 +2,7 @@ import { t as Schema } from "../lib.js";
|
|
|
2
2
|
import { createRequire } from "node:module";
|
|
3
3
|
import { Context } from "@deepseek-ai/cordis";
|
|
4
4
|
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
5
|
-
import { constants, createReadStream, createWriteStream, existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
|
|
5
|
+
import { constants, createReadStream, createWriteStream, existsSync, mkdirSync, readFileSync, rmSync, statSync } from "node:fs";
|
|
6
6
|
import { dirname, isAbsolute, join, posix, resolve } from "node:path";
|
|
7
7
|
import { spawn } from "node:child_process";
|
|
8
8
|
import { fileURLToPath } from "node:url";
|
|
@@ -574,6 +574,15 @@ function startBinaryProcess(argv, options = {}) {
|
|
|
574
574
|
let diagnostic;
|
|
575
575
|
let timedOut = false, finished = false, cancelled = false;
|
|
576
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
|
+
};
|
|
577
586
|
let timer;
|
|
578
587
|
const signal = (sig) => {
|
|
579
588
|
if (child.pid === void 0) return false;
|
|
@@ -613,6 +622,7 @@ function startBinaryProcess(argv, options = {}) {
|
|
|
613
622
|
const cancel = () => {
|
|
614
623
|
if (!finished) {
|
|
615
624
|
cancelled = true;
|
|
625
|
+
notifyCancel();
|
|
616
626
|
reap();
|
|
617
627
|
}
|
|
618
628
|
};
|
|
@@ -1611,150 +1621,6 @@ function sshArgv(connection, options = {}) {
|
|
|
1611
1621
|
], options.extraArgs ?? [], options.command);
|
|
1612
1622
|
}
|
|
1613
1623
|
|
|
1614
|
-
//#endregion
|
|
1615
|
-
//#region src/remote/input.ts
|
|
1616
|
-
/** Local carrier validation precedes any target discovery, connection or spawn. */
|
|
1617
|
-
async function prepareRemoteInput(input) {
|
|
1618
|
-
if (input.mode !== void 0 && input.mode !== "oneshot" && input.mode !== "pty") throw new Error("[E_BAD_MODE] remote: mode must be oneshot or pty");
|
|
1619
|
-
if (input.scriptPath !== void 0) {
|
|
1620
|
-
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");
|
|
1621
|
-
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");
|
|
1622
|
-
}
|
|
1623
|
-
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)");
|
|
1624
|
-
if (input.scriptPath !== void 0) {
|
|
1625
|
-
let stdin;
|
|
1626
|
-
try {
|
|
1627
|
-
stdin = await readFile(input.scriptPath, "utf8");
|
|
1628
|
-
} catch (error) {
|
|
1629
|
-
throw new Error(`[E_SCRIPT_READ] remote: cannot read local script ${JSON.stringify(input.scriptPath)} — ${error instanceof Error ? error.message : String(error)}`);
|
|
1630
|
-
}
|
|
1631
|
-
return {
|
|
1632
|
-
target: input.target,
|
|
1633
|
-
ssh: input.ssh,
|
|
1634
|
-
cmd: "bash -s",
|
|
1635
|
-
stdin,
|
|
1636
|
-
mode: "oneshot",
|
|
1637
|
-
timeout: input.timeout
|
|
1638
|
-
};
|
|
1639
|
-
}
|
|
1640
|
-
return {
|
|
1641
|
-
target: input.target,
|
|
1642
|
-
spawn: input.spawn,
|
|
1643
|
-
ssh: input.ssh,
|
|
1644
|
-
cmd: input.cmd ?? "",
|
|
1645
|
-
stdin: input.stdin,
|
|
1646
|
-
mode: input.mode,
|
|
1647
|
-
timeout: input.timeout
|
|
1648
|
-
};
|
|
1649
|
-
}
|
|
1650
|
-
|
|
1651
|
-
//#endregion
|
|
1652
|
-
//#region src/remote/oneshot.ts
|
|
1653
|
-
async function runOneShot(argv, opts = {}) {
|
|
1654
|
-
const process$1 = startOneShot(argv, opts);
|
|
1655
|
-
const result = await process$1.done;
|
|
1656
|
-
const stdout = process$1.read("stdout", 0), stderr = process$1.read("stderr", 0);
|
|
1657
|
-
return {
|
|
1658
|
-
...result,
|
|
1659
|
-
stdout: stdout.text,
|
|
1660
|
-
stderr: [stderr.text, stderr.diagnostic].filter(Boolean).join("\n"),
|
|
1661
|
-
outputStats: {
|
|
1662
|
-
stdout: stdout.stats,
|
|
1663
|
-
stderr: stderr.stats
|
|
1664
|
-
}
|
|
1665
|
-
};
|
|
1666
|
-
}
|
|
1667
|
-
|
|
1668
|
-
//#endregion
|
|
1669
|
-
//#region src/remote/helper.ts
|
|
1670
|
-
/** Reads helper-bin/MANIFEST.json next to the compiled module (lib/remote in the npm
|
|
1671
|
-
* package, super-dsh/remote in the Super closure — same relative layout). Missing or
|
|
1672
|
-
* malformed assets ⇒ null ⇒ shim-only mode; never throws. */
|
|
1673
|
-
function helperAssets(baseDir) {
|
|
1674
|
-
const dir = baseDir ?? join(dirname(fileURLToPath(import.meta.url)), "helper-bin");
|
|
1675
|
-
try {
|
|
1676
|
-
const manifest = JSON.parse(readFileSync(join(dir, "MANIFEST.json"), "utf8"));
|
|
1677
|
-
if (manifest?.format !== 1 || typeof manifest.ver !== "string" || typeof manifest.protocol !== "number" || !manifest.targets || typeof manifest.targets !== "object") return null;
|
|
1678
|
-
return {
|
|
1679
|
-
dir,
|
|
1680
|
-
manifest
|
|
1681
|
-
};
|
|
1682
|
-
} catch {
|
|
1683
|
-
return null;
|
|
1684
|
-
}
|
|
1685
|
-
}
|
|
1686
|
-
/** `uname -s`/`uname -m` → shipped target triple. Unknown combos ⇒ null (shim). */
|
|
1687
|
-
function archTriple(sys, machine, shipped) {
|
|
1688
|
-
const s = sys.trim().toLowerCase(), m = machine.trim().toLowerCase();
|
|
1689
|
-
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;
|
|
1690
|
-
if (candidates === null) return null;
|
|
1691
|
-
return shipped.has(candidates) ? candidates : null;
|
|
1692
|
-
}
|
|
1693
|
-
function planKey(plan) {
|
|
1694
|
-
switch (plan.kind) {
|
|
1695
|
-
case "ssh": {
|
|
1696
|
-
const c = plan.connection;
|
|
1697
|
-
const host = c?.host ?? plan.host;
|
|
1698
|
-
return `ssh|${c?.user ? c.user + "@" : ""}${host}${c?.port ? ":" + c.port : ""}${c?.proxyJump ? "|" + c.proxyJump : ""}`;
|
|
1699
|
-
}
|
|
1700
|
-
case "docker": return `docker|${plan.container}`;
|
|
1701
|
-
case "incus": return `incus|${plan.container}`;
|
|
1702
|
-
}
|
|
1703
|
-
}
|
|
1704
|
-
const PROBE_RE = /^SUPERD-PROBE (\S+) (\S+)$/m;
|
|
1705
|
-
const READY_RE = /^SUPERD-READY (\S+)$/m;
|
|
1706
|
-
var HelperRegistry = class {
|
|
1707
|
-
states = /* @__PURE__ */ new Map();
|
|
1708
|
-
constructor(assets, exec, probeArgv, uploadArgv) {
|
|
1709
|
-
this.assets = assets;
|
|
1710
|
-
this.exec = exec;
|
|
1711
|
-
this.probeArgv = probeArgv;
|
|
1712
|
-
this.uploadArgv = uploadArgv;
|
|
1713
|
-
}
|
|
1714
|
-
/** Never throws: any bootstrap failure resolves null (sticky) and the caller stays on the shim. */
|
|
1715
|
-
ensure(plan) {
|
|
1716
|
-
if (plan.kind !== "ssh" && plan.kind !== "docker" && plan.kind !== "incus") return Promise.resolve(null);
|
|
1717
|
-
const key = planKey(plan);
|
|
1718
|
-
let entry = this.states.get(key);
|
|
1719
|
-
if (entry === void 0) {
|
|
1720
|
-
entry = this.bootstrap(plan).catch(() => null);
|
|
1721
|
-
entry.catch(() => {});
|
|
1722
|
-
this.states.set(key, entry);
|
|
1723
|
-
}
|
|
1724
|
-
return entry;
|
|
1725
|
-
}
|
|
1726
|
-
async bootstrap(plan) {
|
|
1727
|
-
const { manifest } = this.assets;
|
|
1728
|
-
const first = await this.exec(this.probeArgv(plan, this.assets), { timeoutSec: 15 });
|
|
1729
|
-
const ready = this.parseReady(first.stdout, manifest.ver, manifest.protocol);
|
|
1730
|
-
if (ready !== null) return ready;
|
|
1731
|
-
const probe = PROBE_RE.exec(first.stdout);
|
|
1732
|
-
if (probe === null || first.exit !== 90 && first.exit !== 0) return null;
|
|
1733
|
-
const triple = archTriple(probe[1], probe[2], new Set(Object.keys(manifest.targets)));
|
|
1734
|
-
if (triple === null) return null;
|
|
1735
|
-
const target = manifest.targets[triple];
|
|
1736
|
-
const binary = await readFile(join(this.assets.dir, triple, target.file));
|
|
1737
|
-
if ((await this.exec(this.uploadArgv(plan, triple, target.file, this.assets), {
|
|
1738
|
-
stdin: binary,
|
|
1739
|
-
timeoutSec: 60
|
|
1740
|
-
})).exit !== 0) return null;
|
|
1741
|
-
const second = await this.exec(this.probeArgv(plan, this.assets), { timeoutSec: 15 });
|
|
1742
|
-
return this.parseReady(second.stdout, manifest.ver, manifest.protocol);
|
|
1743
|
-
}
|
|
1744
|
-
parseReady(stdout, ver, protocol) {
|
|
1745
|
-
const ready = READY_RE.exec(stdout);
|
|
1746
|
-
const banner = /^SUPERD-RUN proto=(\d+) name=superd-run ver=(\S+) target=(\S+)/m.exec(stdout);
|
|
1747
|
-
if (ready === null || banner === null) return null;
|
|
1748
|
-
if (banner[1] !== String(protocol) || banner[2] !== ver) return null;
|
|
1749
|
-
return {
|
|
1750
|
-
binPath: ready[1],
|
|
1751
|
-
triple: banner[3],
|
|
1752
|
-
ver,
|
|
1753
|
-
protocol
|
|
1754
|
-
};
|
|
1755
|
-
}
|
|
1756
|
-
};
|
|
1757
|
-
|
|
1758
1624
|
//#endregion
|
|
1759
1625
|
//#region src/remote/transports.ts
|
|
1760
1626
|
/** POSIX 单引号机械转义(唯一一层转义,spec §三"一层机械转义")。 */
|
|
@@ -1783,7 +1649,10 @@ bash -lc ${shQuote(cmd)}`;
|
|
|
1783
1649
|
wrapped
|
|
1784
1650
|
];
|
|
1785
1651
|
switch (plan.kind) {
|
|
1786
|
-
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
|
+
});
|
|
1787
1656
|
case "docker": return [
|
|
1788
1657
|
"docker",
|
|
1789
1658
|
"exec",
|
|
@@ -1857,18 +1726,21 @@ function buildHubPtyArgv(endpoint, assets) {
|
|
|
1857
1726
|
/** superd-run helper argv (spec: docs/specs/remote-rescope §5). Probe and upload are single
|
|
1858
1727
|
* `sh -c` hops (busybox/alpine/darwin-safe); run/killgroup over ssh go through exactly one
|
|
1859
1728
|
* remote-shell parse, same discipline as buildOneShotArgv. */
|
|
1860
|
-
function buildHelperProbeArgv(plan, assets) {
|
|
1729
|
+
function buildHelperProbeArgv(plan, assets, muxArgv) {
|
|
1861
1730
|
const { ver, targets } = assets.manifest;
|
|
1862
|
-
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);
|
|
1863
1732
|
}
|
|
1864
|
-
function buildHelperUploadArgv(plan, triple, file, assets) {
|
|
1733
|
+
function buildHelperUploadArgv(plan, triple, file, assets, muxArgv) {
|
|
1865
1734
|
const dir = `$HOME/.cache/superd/run/${triple}-${assets.manifest.ver}`;
|
|
1866
|
-
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);
|
|
1867
1736
|
}
|
|
1868
1737
|
function buildHelperOneShotArgv(plan, binPath, options) {
|
|
1869
1738
|
const { tag, cmd } = options;
|
|
1870
1739
|
switch (plan.kind) {
|
|
1871
|
-
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
|
+
});
|
|
1872
1744
|
case "docker": return [
|
|
1873
1745
|
"docker",
|
|
1874
1746
|
"exec",
|
|
@@ -1901,9 +1773,12 @@ function buildHelperOneShotArgv(plan, binPath, options) {
|
|
|
1901
1773
|
];
|
|
1902
1774
|
}
|
|
1903
1775
|
}
|
|
1904
|
-
function buildHelperKillgroupArgv(plan, binPath, tag) {
|
|
1776
|
+
function buildHelperKillgroupArgv(plan, binPath, tag, muxArgv) {
|
|
1905
1777
|
switch (plan.kind) {
|
|
1906
|
-
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
|
+
});
|
|
1907
1782
|
case "docker": return [
|
|
1908
1783
|
"docker",
|
|
1909
1784
|
"exec",
|
|
@@ -1927,9 +1802,12 @@ function buildHelperKillgroupArgv(plan, binPath, tag) {
|
|
|
1927
1802
|
];
|
|
1928
1803
|
}
|
|
1929
1804
|
}
|
|
1930
|
-
function helperShellArgv(plan, script) {
|
|
1805
|
+
function helperShellArgv(plan, script, muxArgv) {
|
|
1931
1806
|
switch (plan.kind) {
|
|
1932
|
-
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
|
+
});
|
|
1933
1811
|
case "docker": return [
|
|
1934
1812
|
"docker",
|
|
1935
1813
|
"exec",
|
|
@@ -1951,7 +1829,243 @@ function helperShellArgv(plan, script) {
|
|
|
1951
1829
|
script
|
|
1952
1830
|
];
|
|
1953
1831
|
}
|
|
1954
|
-
}
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1834
|
+
//#endregion
|
|
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
|
+
}
|
|
1861
|
+
return {
|
|
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
|
|
1869
|
+
};
|
|
1870
|
+
}
|
|
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
|
|
1885
|
+
}
|
|
1886
|
+
};
|
|
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
|
+
};
|
|
1955
2069
|
|
|
1956
2070
|
//#endregion
|
|
1957
2071
|
//#region src/remote/nonce-framing.ts
|
|
@@ -2007,7 +2121,7 @@ function createNonceFrameParser(nonce, events) {
|
|
|
2007
2121
|
const prefix = `\x1b]133;D;${nonce};`;
|
|
2008
2122
|
const prefixE = `\x1b]133;E;${nonce};`;
|
|
2009
2123
|
const prefixes = [prefix, prefixE];
|
|
2010
|
-
let buf = "";
|
|
2124
|
+
let buf$1 = "";
|
|
2011
2125
|
let done = false;
|
|
2012
2126
|
const emit = (raw) => {
|
|
2013
2127
|
const text = normalizePtyText(raw);
|
|
@@ -2017,8 +2131,8 @@ function createNonceFrameParser(nonce, events) {
|
|
|
2017
2131
|
const holdback = () => {
|
|
2018
2132
|
let best = 0;
|
|
2019
2133
|
for (const p of prefixes) {
|
|
2020
|
-
const max = Math.min(buf.length, p.length - 1);
|
|
2021
|
-
for (let k = max; k > best; k--) if (buf.endsWith(p.slice(0, k))) {
|
|
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))) {
|
|
2022
2136
|
best = k;
|
|
2023
2137
|
break;
|
|
2024
2138
|
}
|
|
@@ -2031,7 +2145,7 @@ function createNonceFrameParser(nonce, events) {
|
|
|
2031
2145
|
let idx = -1;
|
|
2032
2146
|
let isEnv = false;
|
|
2033
2147
|
for (const p of prefixes) {
|
|
2034
|
-
const i = buf.indexOf(p);
|
|
2148
|
+
const i = buf$1.indexOf(p);
|
|
2035
2149
|
if (i >= 0 && (idx === -1 || i < idx)) {
|
|
2036
2150
|
idx = i;
|
|
2037
2151
|
isEnv = p === prefixE;
|
|
@@ -2039,26 +2153,26 @@ function createNonceFrameParser(nonce, events) {
|
|
|
2039
2153
|
}
|
|
2040
2154
|
if (idx < 0) {
|
|
2041
2155
|
const keep = holdback();
|
|
2042
|
-
emit(buf.slice(0, buf.length - keep));
|
|
2043
|
-
buf = buf.slice(buf.length - keep);
|
|
2156
|
+
emit(buf$1.slice(0, buf$1.length - keep));
|
|
2157
|
+
buf$1 = buf$1.slice(buf$1.length - keep);
|
|
2044
2158
|
return;
|
|
2045
2159
|
}
|
|
2046
|
-
emit(buf.slice(0, idx));
|
|
2047
|
-
const rest = buf.slice(idx + (isEnv ? prefixE.length : prefix.length));
|
|
2160
|
+
emit(buf$1.slice(0, idx));
|
|
2161
|
+
const rest = buf$1.slice(idx + (isEnv ? prefixE.length : prefix.length));
|
|
2048
2162
|
if (isEnv) {
|
|
2049
2163
|
const m$1 = ENV_BODY_RE.exec(rest);
|
|
2050
2164
|
if (m$1 === null) {
|
|
2051
|
-
buf = buf.slice(idx);
|
|
2165
|
+
buf$1 = buf$1.slice(idx);
|
|
2052
2166
|
return;
|
|
2053
2167
|
}
|
|
2054
2168
|
if (m$1[1].length > 0) events.onEnv?.(m$1[1]);
|
|
2055
|
-
buf = rest.slice(m$1[0].length);
|
|
2169
|
+
buf$1 = rest.slice(m$1[0].length);
|
|
2056
2170
|
consume();
|
|
2057
2171
|
return;
|
|
2058
2172
|
}
|
|
2059
2173
|
const m = FRAME_BODY_RE.exec(rest);
|
|
2060
2174
|
if (m === null) {
|
|
2061
|
-
buf = buf.slice(idx);
|
|
2175
|
+
buf$1 = buf$1.slice(idx);
|
|
2062
2176
|
return;
|
|
2063
2177
|
}
|
|
2064
2178
|
events.onFrame({
|
|
@@ -2066,16 +2180,16 @@ function createNonceFrameParser(nonce, events) {
|
|
|
2066
2180
|
cwd: m[2].length > 0 ? m[2] : null
|
|
2067
2181
|
});
|
|
2068
2182
|
done = true;
|
|
2069
|
-
buf = "";
|
|
2183
|
+
buf$1 = "";
|
|
2070
2184
|
};
|
|
2071
2185
|
return {
|
|
2072
2186
|
feed(chunk) {
|
|
2073
|
-
buf += chunk;
|
|
2187
|
+
buf$1 += chunk;
|
|
2074
2188
|
consume();
|
|
2075
2189
|
},
|
|
2076
2190
|
flush() {
|
|
2077
|
-
if (buf.length > 0) events.onOutput?.(buf);
|
|
2078
|
-
buf = "";
|
|
2191
|
+
if (buf$1.length > 0) events.onOutput?.(buf$1);
|
|
2192
|
+
buf$1 = "";
|
|
2079
2193
|
}
|
|
2080
2194
|
};
|
|
2081
2195
|
}
|
|
@@ -2118,6 +2232,67 @@ function diffEnv(prev, next) {
|
|
|
2118
2232
|
};
|
|
2119
2233
|
}
|
|
2120
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
|
+
|
|
2121
2296
|
//#endregion
|
|
2122
2297
|
//#region src/remote/pty-session.ts
|
|
2123
2298
|
/** spec §五.2 逐字。 */
|
|
@@ -2129,11 +2304,6 @@ function displayKey(key) {
|
|
|
2129
2304
|
const raw = key.includes("|") ? key.slice(key.indexOf("|") + 1) : key;
|
|
2130
2305
|
return raw.startsWith("p:") ? `pty:${raw.slice(2)}` : raw;
|
|
2131
2306
|
}
|
|
2132
|
-
const LEDGER_MAX = 20;
|
|
2133
|
-
const summaryOf = (cmd) => {
|
|
2134
|
-
const first = cmd.trim().split(/\r?\n/, 1)[0] ?? "";
|
|
2135
|
-
return first.length > 72 ? `${first.slice(0, 71)}…` : first;
|
|
2136
|
-
};
|
|
2137
2307
|
const DEFAULT_INIT_TIMEOUT_SEC = 30;
|
|
2138
2308
|
const INTERRUPT_GRACE_MS = 3e3;
|
|
2139
2309
|
/** F2(0.3.2-d):\x03 触发 ISIG 时内核 flush 输入队列——130-marker 必须等 flush 完成
|
|
@@ -2150,12 +2320,11 @@ var PtySession = class {
|
|
|
2150
2320
|
deathWaiters = [];
|
|
2151
2321
|
diag = "";
|
|
2152
2322
|
inFlight = 0;
|
|
2323
|
+
cmdSeq = 0;
|
|
2153
2324
|
/** P4/P5:当前占用前台的 detached job(background/超时 handoff)。非 null 时新命令拒绝(Q6)。 */
|
|
2154
2325
|
detachedJob = null;
|
|
2155
2326
|
/** §3.2(2026-10-25):当前占用会话的命令——dispatch 同步占位,settle/cleanup 释放;input 的唯一写入对象。 */
|
|
2156
2327
|
activeCommand = null;
|
|
2157
|
-
/** 账本(§5):本会话最近 20 条命令(FIFO,超出丢最旧)。 */
|
|
2158
|
-
ledger = [];
|
|
2159
2328
|
/** env 侧信道基线(init E 帧);undefined = 侧信道不可用(不报假 delta)。 */
|
|
2160
2329
|
envBaseline;
|
|
2161
2330
|
/** idle 起点(ready / settle 时重置);null = 尚无 idle 时钟(cold 或已 dispose)。 */
|
|
@@ -2186,10 +2355,12 @@ var PtySession = class {
|
|
|
2186
2355
|
get detachedCommandId() {
|
|
2187
2356
|
return this.detachedJob?.commandId ?? null;
|
|
2188
2357
|
}
|
|
2189
|
-
/**
|
|
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 写入回执用)。 */
|
|
2190
2363
|
attachJob(commandId, jobId) {
|
|
2191
|
-
const entry = [...this.ledger].reverse().find((e) => e.commandId === commandId);
|
|
2192
|
-
if (entry !== void 0) entry.jobId = jobId;
|
|
2193
2364
|
if (this.activeCommand !== null && this.activeCommand.commandId === commandId) this.activeCommand.jobId = jobId;
|
|
2194
2365
|
}
|
|
2195
2366
|
/** Workbench 身份(§3):id + 可选 label + connection + byo 标记。 */
|
|
@@ -2202,10 +2373,6 @@ var PtySession = class {
|
|
|
2202
2373
|
builtOn: this.opts.builtOn === true
|
|
2203
2374
|
};
|
|
2204
2375
|
}
|
|
2205
|
-
/** 账本快照(session card 面,只读)。 */
|
|
2206
|
-
ledgerSnapshot() {
|
|
2207
|
-
return this.ledger;
|
|
2208
|
-
}
|
|
2209
2376
|
/** spawn 创建原文(仅 BYO 会话;v2 起仅存档,不参与复用判定)。 */
|
|
2210
2377
|
get origin() {
|
|
2211
2378
|
return this.opts.origin;
|
|
@@ -2234,24 +2401,15 @@ var PtySession = class {
|
|
|
2234
2401
|
if (this.state === "disposed") return Promise.reject(/* @__PURE__ */ new Error(`[E_SESSION_DISPOSED] remote pty session '${this.opts.id}' was disposed before dispatch`));
|
|
2235
2402
|
if (this.activeCommand !== null) {
|
|
2236
2403
|
const handle = this.activeCommand.jobId ?? this.activeCommand.commandId;
|
|
2237
|
-
return Promise.reject(/* @__PURE__ */ new Error(`[E_SESSION_BUSY] remote: pty session '${this.opts.id}' still runs a command (${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`));
|
|
2238
2405
|
}
|
|
2239
2406
|
const commandId = dOpts.commandId;
|
|
2240
|
-
const entry = {
|
|
2241
|
-
commandId,
|
|
2242
|
-
summary: summaryOf(cmd),
|
|
2243
|
-
exit: null,
|
|
2244
|
-
durationMs: 0,
|
|
2245
|
-
running: true,
|
|
2246
|
-
...dOpts.jobId !== void 0 ? { jobId: dOpts.jobId } : {}
|
|
2247
|
-
};
|
|
2248
|
-
this.ledger.push(entry);
|
|
2249
|
-
if (this.ledger.length > LEDGER_MAX) this.ledger.shift();
|
|
2250
2407
|
this.activeCommand = {
|
|
2251
2408
|
commandId,
|
|
2252
2409
|
...dOpts.jobId !== void 0 ? { jobId: dOpts.jobId } : {}
|
|
2253
2410
|
};
|
|
2254
2411
|
this.inFlight++;
|
|
2412
|
+
this.cmdSeq++;
|
|
2255
2413
|
const attempt = async () => {
|
|
2256
2414
|
const started = Date.now();
|
|
2257
2415
|
const reconnected = this.state === "dead";
|
|
@@ -2260,8 +2418,6 @@ var PtySession = class {
|
|
|
2260
2418
|
const abandon = () => {
|
|
2261
2419
|
this.activeCommand = null;
|
|
2262
2420
|
this.inFlight--;
|
|
2263
|
-
const index = this.ledger.indexOf(entry);
|
|
2264
|
-
if (index >= 0) this.ledger.splice(index, 1);
|
|
2265
2421
|
};
|
|
2266
2422
|
if (this.state !== "ready") try {
|
|
2267
2423
|
await this.start();
|
|
@@ -2276,6 +2432,20 @@ var PtySession = class {
|
|
|
2276
2432
|
return await new Promise((resolve$1) => {
|
|
2277
2433
|
const nonce = genNonce();
|
|
2278
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
|
+
});
|
|
2279
2449
|
let interrupted = false;
|
|
2280
2450
|
let settled = false;
|
|
2281
2451
|
let jobSettled = false;
|
|
@@ -2298,6 +2468,13 @@ var PtySession = class {
|
|
|
2298
2468
|
};
|
|
2299
2469
|
this.detachedJob = job;
|
|
2300
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();
|
|
2301
2478
|
resolve$1({
|
|
2302
2479
|
output: ring.text,
|
|
2303
2480
|
outputStats: { stdout: ring.stats() },
|
|
@@ -2324,6 +2501,16 @@ var PtySession = class {
|
|
|
2324
2501
|
const settle = (r) => {
|
|
2325
2502
|
if (jobSettled) return;
|
|
2326
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();
|
|
2327
2514
|
cleanup();
|
|
2328
2515
|
this.inFlight--;
|
|
2329
2516
|
this.idleSince = Date.now();
|
|
@@ -2331,9 +2518,6 @@ var PtySession = class {
|
|
|
2331
2518
|
if (r.cwd !== null) this.lastCwdSeen = r.cwd;
|
|
2332
2519
|
const envDelta = envNext !== void 0 ? diffEnv(this.envBaseline, envNext) : void 0;
|
|
2333
2520
|
if (envNext !== void 0) this.envBaseline = envNext;
|
|
2334
|
-
entry.exit = r.exit;
|
|
2335
|
-
entry.durationMs = Date.now() - started;
|
|
2336
|
-
entry.running = false;
|
|
2337
2521
|
const full = {
|
|
2338
2522
|
...r,
|
|
2339
2523
|
outputStats: { stdout: ring.stats() },
|
|
@@ -2359,6 +2543,13 @@ var PtySession = class {
|
|
|
2359
2543
|
const parser = createNonceFrameParser(nonce, {
|
|
2360
2544
|
onOutput: (t) => {
|
|
2361
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
|
+
});
|
|
2362
2553
|
},
|
|
2363
2554
|
onEnv: (b64) => {
|
|
2364
2555
|
const m = decodeEnvSnapshot(b64);
|
|
@@ -2389,8 +2580,41 @@ var PtySession = class {
|
|
|
2389
2580
|
}, INTERRUPT_GRACE_MS);
|
|
2390
2581
|
};
|
|
2391
2582
|
this.onChunk = (c) => parser.feed(c);
|
|
2392
|
-
|
|
2393
|
-
|
|
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
|
+
});
|
|
2394
2618
|
this.deathWaiters.push(deathWaiter);
|
|
2395
2619
|
if (dOpts.background === true) {
|
|
2396
2620
|
detach();
|
|
@@ -2417,6 +2641,12 @@ var PtySession = class {
|
|
|
2417
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`);
|
|
2418
2642
|
try {
|
|
2419
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
|
+
});
|
|
2420
2650
|
} catch (error) {
|
|
2421
2651
|
return fail("[E_SESSION_WRITE_FAILED]", `writing stdin to pty session '${displayKey(this.opts.key)}' failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
2422
2652
|
}
|
|
@@ -2433,6 +2663,11 @@ var PtySession = class {
|
|
|
2433
2663
|
this.clearIdleTimer();
|
|
2434
2664
|
this.clearDeathTimer();
|
|
2435
2665
|
this.state = "disposed";
|
|
2666
|
+
trace("dispose", {
|
|
2667
|
+
s: this.opts.id,
|
|
2668
|
+
key: this.opts.key
|
|
2669
|
+
});
|
|
2670
|
+
traceFlush();
|
|
2436
2671
|
this.startPromise = null;
|
|
2437
2672
|
this.detachedJob?.interrupt();
|
|
2438
2673
|
const proc = this.proc;
|
|
@@ -2479,6 +2714,12 @@ var PtySession = class {
|
|
|
2479
2714
|
detached: true
|
|
2480
2715
|
});
|
|
2481
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
|
+
});
|
|
2482
2723
|
proc.stdout.setEncoding("utf8");
|
|
2483
2724
|
proc.stdout.on("data", (d) => {
|
|
2484
2725
|
this.onChunk?.(d);
|
|
@@ -2540,6 +2781,11 @@ var PtySession = class {
|
|
|
2540
2781
|
return;
|
|
2541
2782
|
}
|
|
2542
2783
|
this.state = "dead";
|
|
2784
|
+
trace("dead", {
|
|
2785
|
+
s: this.opts.id,
|
|
2786
|
+
diagTail: this.diag.slice(-500)
|
|
2787
|
+
});
|
|
2788
|
+
traceFlush();
|
|
2543
2789
|
this.startPromise = null;
|
|
2544
2790
|
this.clearIdleTimer();
|
|
2545
2791
|
this.armDeathReaper();
|
|
@@ -2752,50 +2998,20 @@ function parseTargetForm(target) {
|
|
|
2752
2998
|
form: "hub",
|
|
2753
2999
|
machine: segs[0]
|
|
2754
3000
|
};
|
|
2755
|
-
if (segs.length
|
|
2756
|
-
form: "hub-pty",
|
|
2757
|
-
machine: segs[0]
|
|
2758
|
-
};
|
|
2759
|
-
if (segs.length === 3 && segs[1] === "pty") {
|
|
2760
|
-
if (segs[2] === void 0 || segs[2].length === 0) throw new TargetFormatError(`[E_TARGET_FORMAT] remote: ${JSON.stringify(target)} — empty label after ":pty:"`);
|
|
2761
|
-
return {
|
|
2762
|
-
form: "hub-pty",
|
|
2763
|
-
machine: segs[0],
|
|
2764
|
-
label: segs[2]
|
|
2765
|
-
};
|
|
2766
|
-
}
|
|
2767
|
-
throw new TargetFormatError(`[E_TARGET_FORMAT] remote: ${JSON.stringify(target)} — hub PTY is hub:<m4>:pty[:label]`);
|
|
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>`);
|
|
2768
3002
|
}
|
|
2769
3003
|
for (const transport of TRANSPORTS) {
|
|
2770
3004
|
if (!target.startsWith(`${transport}:`)) continue;
|
|
2771
3005
|
const rest = target.slice(transport.length + 1);
|
|
2772
3006
|
if (rest.length === 0) throw new TargetFormatError(`[E_TARGET_FORMAT] remote: ${JSON.stringify(target)} — ${transport} takes a name`);
|
|
2773
|
-
|
|
2774
|
-
|
|
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 {
|
|
2775
3009
|
form: "direct",
|
|
2776
3010
|
transport,
|
|
2777
3011
|
name: rest
|
|
2778
3012
|
};
|
|
2779
|
-
const base = rest.slice(0, ptyAt);
|
|
2780
|
-
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)`);
|
|
2781
|
-
const tail = rest.slice(ptyAt + 4);
|
|
2782
|
-
if (tail.startsWith(":")) {
|
|
2783
|
-
if (tail.length === 1) throw new TargetFormatError(`[E_TARGET_FORMAT] remote: ${JSON.stringify(target)} — empty label after ":pty:"`);
|
|
2784
|
-
return {
|
|
2785
|
-
form: "create-pty",
|
|
2786
|
-
transport,
|
|
2787
|
-
name: base,
|
|
2788
|
-
label: tail.slice(1)
|
|
2789
|
-
};
|
|
2790
|
-
}
|
|
2791
|
-
if (tail.length > 0) throw new TargetFormatError(`[E_TARGET_FORMAT] remote: ${JSON.stringify(target)} — unexpected ${JSON.stringify(tail)} after ":pty"`);
|
|
2792
|
-
return {
|
|
2793
|
-
form: "create-pty",
|
|
2794
|
-
transport,
|
|
2795
|
-
name: base
|
|
2796
|
-
};
|
|
2797
3013
|
}
|
|
2798
|
-
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)`);
|
|
2799
3015
|
return {
|
|
2800
3016
|
form: "bare",
|
|
2801
3017
|
name: target
|
|
@@ -2939,7 +3155,11 @@ async function probeTarget(plan, opts = {}) {
|
|
|
2939
3155
|
const runner = opts.runner ?? runOneShot;
|
|
2940
3156
|
const started = Date.now();
|
|
2941
3157
|
const r = await runner(plan.kind === "ssh" ? sshArgv(plan.connection ?? { host: plan.host }, {
|
|
2942
|
-
extraArgs: [
|
|
3158
|
+
extraArgs: [
|
|
3159
|
+
...opts.muxArgvFor?.(plan) ?? [],
|
|
3160
|
+
"-o",
|
|
3161
|
+
`ConnectTimeout=${ct}`
|
|
3162
|
+
],
|
|
2943
3163
|
command: "true"
|
|
2944
3164
|
}) : plan.kind === "docker" ? [
|
|
2945
3165
|
"docker",
|
|
@@ -2998,12 +3218,6 @@ function renderProbe(p) {
|
|
|
2998
3218
|
case "container": return `probe: container ${p.status} (just now, on demand)`;
|
|
2999
3219
|
}
|
|
3000
3220
|
}
|
|
3001
|
-
/** session card(§5.5,`pty:<id|label>` 无 cmd):connection/state/cwd/ttl 由调用方拼头行,这里渲染账本。 */
|
|
3002
|
-
function renderLedger(ledger) {
|
|
3003
|
-
if (ledger.length === 0) return "ledger: empty — no command has run in this session yet";
|
|
3004
|
-
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}`);
|
|
3005
|
-
return `ledger (last ${ledger.length}):\n${rows.join("\n")}`;
|
|
3006
|
-
}
|
|
3007
3221
|
|
|
3008
3222
|
//#endregion
|
|
3009
3223
|
//#region src/remote/ssh-config.ts
|
|
@@ -3154,7 +3368,7 @@ async function scanLocal(opts = {}) {
|
|
|
3154
3368
|
};
|
|
3155
3369
|
}
|
|
3156
3370
|
/** 统一池视图的 roster 呈现:机器三段 + BYO ssh 同位(built on)+ pty 会话段(caller-scoped)。 */
|
|
3157
|
-
function renderRoster(scan, entries, sessions, hubTargets) {
|
|
3371
|
+
function renderRoster(scan, entries, sessions, hubTargets, defaults) {
|
|
3158
3372
|
const lines = [];
|
|
3159
3373
|
if (hubTargets !== null) lines.push(`hub machines: ${hubTargets.map((t) => `hub:${t.id} (${t.label}${t.execEnabled ? "" : "; exec disabled"})`).join(", ") || "none"}`);
|
|
3160
3374
|
if (scan.ssh.error !== void 0) lines.push(`ssh hosts: no readable ~/.ssh/config (${scan.ssh.error})`);
|
|
@@ -3176,7 +3390,7 @@ function renderRoster(scan, entries, sessions, hubTargets) {
|
|
|
3176
3390
|
const id = s.identity;
|
|
3177
3391
|
const snap = s.snapshot;
|
|
3178
3392
|
const state = snap.busy ? "busy" : snap.state === "dead" ? "died" : `idle ${Math.round((snap.idleMs ?? 0) / 1e3)}s`;
|
|
3179
|
-
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)}` : ""}]`;
|
|
3180
3394
|
}).join(" · ")}` : "pty sessions: none");
|
|
3181
3395
|
return lines.join("\n");
|
|
3182
3396
|
}
|
|
@@ -3186,7 +3400,9 @@ function renderRoster(scan, entries, sessions, hubTargets) {
|
|
|
3186
3400
|
/** R1:idle TTL 15 min——idle 随活动重置,在用会话永不超时(PtySession 双条件)。 */
|
|
3187
3401
|
const REMOTE_PTY_IDLE_TTL_SEC = 900;
|
|
3188
3402
|
/** P3(user 2026-10-19):每 agent 10 个 PTY 会话,跨所有 connection 统一计;仅 error message 披露。 */
|
|
3189
|
-
|
|
3403
|
+
/** 二/三轮裁决(2026-11-01):每 Agent session 全局 30 个 active PTY(默认/handoff/BYO 全计入);
|
|
3404
|
+
* 仅触达上限时的错误回执与 roster 可见性披露,任何模型可见 surface 不预先公布。 */
|
|
3405
|
+
const REMOTE_PTY_SESSION_LIMIT = 30;
|
|
3190
3406
|
/** hub 线 one-shot 监督披露(0.3.2-c):对端 = 我们自己的 hub server(bash -lc 于独立进程组,
|
|
3191
3407
|
* Node 原生 TERM→2s→KILL + 输出环 + 租约生命周期);superd-run helper 是 dumb-pipe 专用物,
|
|
3192
3408
|
* 从不经 hub 上传(non-goal,docs/10_plans/2026-10-25-hub-remote-parity.md §3 O1/O8)。 */
|
|
@@ -3202,6 +3418,10 @@ const genCommandId = () => `remote-${randomUUID().replace(/-/g, "").slice(0, 16)
|
|
|
3202
3418
|
/** 双轨调度器:参数校验 → 目标语法(parseTargetForm)→ 统一 label 池 → oneshot 子进程 / pty 会话池。 */
|
|
3203
3419
|
var RemoteDriver = class {
|
|
3204
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();
|
|
3205
3425
|
labels = new LabelPool();
|
|
3206
3426
|
hubOwner = randomUUID();
|
|
3207
3427
|
hubProviders = /* @__PURE__ */ new Set();
|
|
@@ -3214,8 +3434,10 @@ var RemoteDriver = class {
|
|
|
3214
3434
|
runnerOf = /* @__PURE__ */ new WeakMap();
|
|
3215
3435
|
disposal;
|
|
3216
3436
|
ptyIdleTtlSec;
|
|
3437
|
+
mux;
|
|
3217
3438
|
constructor(opts = {}) {
|
|
3218
3439
|
this.opts = opts;
|
|
3440
|
+
this.mux = opts.sshMux ?? new SshMux();
|
|
3219
3441
|
this.ptyIdleTtlSec = opts.idleTtlSec ?? REMOTE_PTY_IDLE_TTL_SEC;
|
|
3220
3442
|
this.pool = new PtyPool({ idleTtlSec: this.ptyIdleTtlSec });
|
|
3221
3443
|
}
|
|
@@ -3254,15 +3476,21 @@ var RemoteDriver = class {
|
|
|
3254
3476
|
const hasSsh = params.ssh !== void 0;
|
|
3255
3477
|
if (hasSpawn && hasSsh) throw new Error("[E_PARAMS] remote: spawn and ssh are two different BYO entry points — pass one, not both");
|
|
3256
3478
|
const ptyForm = form.form === "pty-selector" || form.form === "empty" || form.form === "bare";
|
|
3257
|
-
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";
|
|
3258
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");
|
|
3259
|
-
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)");
|
|
3260
3482
|
}
|
|
3261
3483
|
async execute(params, form, callCtx) {
|
|
3262
3484
|
const timeoutSec = params.timeout ?? this.opts.execTimeoutSec ?? 120;
|
|
3263
3485
|
const sessionKey = callCtx.sessionKey ?? "no-session";
|
|
3264
3486
|
const hasCmd = params.cmd !== void 0 && params.cmd.length > 0;
|
|
3265
|
-
params.spawn !== void 0 && params.spawn.length;
|
|
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
|
+
}
|
|
3266
3494
|
if (form.form === "hub") {
|
|
3267
3495
|
const hub = this.requireHub();
|
|
3268
3496
|
this.hubProviders.add(hub);
|
|
@@ -3459,7 +3687,7 @@ var RemoteDriver = class {
|
|
|
3459
3687
|
const parsed = parseFileEndpoint(value);
|
|
3460
3688
|
if (!parsed.target) return { path: parsed.path };
|
|
3461
3689
|
const form = parseTargetForm(parsed.target);
|
|
3462
|
-
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");
|
|
3463
3691
|
const plan = await this.resolveConnectionTarget(parsed.target, callCtx);
|
|
3464
3692
|
this.assertOpen();
|
|
3465
3693
|
combined.throwIfAborted();
|
|
@@ -3501,49 +3729,68 @@ var RemoteDriver = class {
|
|
|
3501
3729
|
const plan = form.transport === "ssh" ? this.sshPlanFor(form.name, callCtx.sessionKey ?? "no-session") : resolveTarget(`${form.transport}:${form.name}`);
|
|
3502
3730
|
this.assertOpen();
|
|
3503
3731
|
signal.throwIfAborted();
|
|
3504
|
-
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);
|
|
3505
3733
|
} else if (form.form === "bare") {
|
|
3506
3734
|
const entry = await this.resolvePoolEntry(form.name, callCtx.sessionKey ?? "no-session");
|
|
3507
3735
|
this.assertOpen();
|
|
3508
3736
|
signal.throwIfAborted();
|
|
3509
3737
|
if (entry.kind === "pty" || entry.kind === "hub") throw new Error("remote: background requires an ssh/docker/incus target");
|
|
3510
|
-
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);
|
|
3511
3739
|
} else throw new Error("remote: background oneshot takes a machine target");
|
|
3512
3740
|
this.own(process$1);
|
|
3513
3741
|
if (signal.aborted || this.disposed) process$1.cancel("owner closed during start");
|
|
3514
3742
|
return process$1;
|
|
3515
3743
|
}
|
|
3516
|
-
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);
|
|
3517
3746
|
const cleanupTag = randomUUID().slice(0, 8);
|
|
3518
|
-
const helper = await this.ensureHelper(plan);
|
|
3747
|
+
const helper = await this.ensureHelper(plan, muxArgv);
|
|
3519
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
|
+
};
|
|
3520
3755
|
const process$2 = startOneShot(buildHelperOneShotArgv(plan, helper.binPath, {
|
|
3521
3756
|
tag: cleanupTag,
|
|
3522
|
-
cmd: params.cmd
|
|
3757
|
+
cmd: params.cmd,
|
|
3758
|
+
...muxArgv !== void 0 ? { muxArgv } : {}
|
|
3523
3759
|
}), {
|
|
3524
3760
|
stdin: params.stdin,
|
|
3525
|
-
timeoutSec
|
|
3761
|
+
timeoutSec,
|
|
3762
|
+
onCancel: kill$1
|
|
3526
3763
|
});
|
|
3527
3764
|
this.runnerOf.set(process$2, `superd-run ${helper.ver} (${helper.triple})`);
|
|
3528
3765
|
process$2.done.then((result) => {
|
|
3529
3766
|
if (!result.cancelled && !result.timedOut) return;
|
|
3530
|
-
|
|
3767
|
+
kill$1();
|
|
3531
3768
|
}).catch(() => {});
|
|
3532
3769
|
const tracked = Promise.resolve(process$2);
|
|
3533
3770
|
this.starting.add(tracked);
|
|
3534
3771
|
tracked.then(() => this.starting.delete(tracked), () => this.starting.delete(tracked));
|
|
3535
3772
|
return tracked;
|
|
3536
3773
|
}
|
|
3537
|
-
|
|
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
|
+
}), {
|
|
3538
3786
|
stdin: params.stdin,
|
|
3539
|
-
timeoutSec
|
|
3787
|
+
timeoutSec,
|
|
3788
|
+
onCancel: kill
|
|
3540
3789
|
});
|
|
3541
3790
|
this.runnerOf.set(process$1, "posix-shim");
|
|
3542
3791
|
process$1.done.then((result) => {
|
|
3543
3792
|
if (!result.cancelled && !result.timedOut) return;
|
|
3544
|
-
|
|
3545
|
-
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}`;
|
|
3546
|
-
startOneShot((this.opts.oneshotArgvFor ?? buildOneShotArgv)(plan, cleanup, {}), { timeoutSec: 15 }).done.catch(() => {});
|
|
3793
|
+
kill();
|
|
3547
3794
|
}).catch(() => {});
|
|
3548
3795
|
const work = Promise.resolve(process$1);
|
|
3549
3796
|
this.starting.add(work);
|
|
@@ -3552,14 +3799,14 @@ var RemoteDriver = class {
|
|
|
3552
3799
|
}
|
|
3553
3800
|
/** Helper bootstrap is best-effort and sticky: any failure parks null for this driver
|
|
3554
3801
|
* lifetime and the shim stays the path. First call to a target pays probe (+ upload). */
|
|
3555
|
-
async ensureHelper(plan) {
|
|
3802
|
+
async ensureHelper(plan, muxArgv) {
|
|
3556
3803
|
if (this.helpers === void 0) {
|
|
3557
3804
|
const assets = this.opts.helperAssetsFor?.() ?? helperAssets();
|
|
3558
3805
|
this.helpers = assets === null ? null : new HelperRegistry(assets, (argv, options) => this.helperExec(argv, options), buildHelperProbeArgv, buildHelperUploadArgv);
|
|
3559
3806
|
}
|
|
3560
3807
|
if (this.helpers === null || this.disposed) return null;
|
|
3561
3808
|
try {
|
|
3562
|
-
return await this.helpers.ensure(plan);
|
|
3809
|
+
return await this.helpers.ensure(plan, muxArgv ?? []);
|
|
3563
3810
|
} catch {
|
|
3564
3811
|
return null;
|
|
3565
3812
|
}
|
|
@@ -3672,6 +3919,16 @@ var RemoteDriver = class {
|
|
|
3672
3919
|
host: name$1
|
|
3673
3920
|
};
|
|
3674
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
|
+
}
|
|
3675
3932
|
/** BYO ssh 注册(v2:无 spec 对比——label 占用即拒;注册成功才认领)。返回实际名(label 或 s-id)。 */
|
|
3676
3933
|
registerByoSsh(label, connection, sessionKey) {
|
|
3677
3934
|
this.assertOpen();
|
|
@@ -3701,7 +3958,8 @@ var RemoteDriver = class {
|
|
|
3701
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");
|
|
3702
3959
|
if (live.length >= REMOTE_PTY_SESSION_LIMIT) {
|
|
3703
3960
|
const listing = live.map(sessionLine).join(" · ");
|
|
3704
|
-
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}`);
|
|
3705
3963
|
}
|
|
3706
3964
|
const { label } = opts;
|
|
3707
3965
|
if (label !== void 0) validateLabel(label);
|
|
@@ -3750,7 +4008,7 @@ var RemoteDriver = class {
|
|
|
3750
4008
|
isBuiltOn(connection, sessionKey) {
|
|
3751
4009
|
return connection.startsWith("ssh:") && this.labels.resolve(connection.slice(4), sessionKey)?.kind === "ssh-byo";
|
|
3752
4010
|
}
|
|
3753
|
-
/** PTY 创建形唯一实现(execute 与 background 预建共用):spawn BYO
|
|
4011
|
+
/** PTY 创建形唯一实现(execute 与 background 预建共用):spawn BYO(spec 2026-11-01:native `:pty`/hub-pty 创建语法已删,machine 会话经默认槽 + handoff)。 */
|
|
3754
4012
|
createPtyLane(params, form, sessionKey) {
|
|
3755
4013
|
if (params.spawn !== void 0 && params.spawn.length > 0) {
|
|
3756
4014
|
const label = form.form === "pty-selector" && form.idOrLabel !== void 0 ? form.idOrLabel : form.form === "bare" ? form.name : void 0;
|
|
@@ -3763,42 +4021,246 @@ var RemoteDriver = class {
|
|
|
3763
4021
|
origin: params.spawn
|
|
3764
4022
|
}, sessionKey) };
|
|
3765
4023
|
}
|
|
3766
|
-
|
|
3767
|
-
|
|
3768
|
-
|
|
3769
|
-
|
|
3770
|
-
|
|
3771
|
-
|
|
3772
|
-
|
|
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}`
|
|
3773
4046
|
};
|
|
3774
|
-
|
|
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}`,
|
|
3775
4076
|
argv: (this.opts.ptyArgvFor ?? buildPtyArgv)(plan),
|
|
3776
|
-
connection:
|
|
3777
|
-
|
|
3778
|
-
...form.label !== void 0 ? { label: form.label } : {}
|
|
3779
|
-
}, sessionKey) };
|
|
4077
|
+
connection: `${form.transport}:${form.name}`
|
|
4078
|
+
};
|
|
3780
4079
|
}
|
|
3781
|
-
if (form.form === "
|
|
3782
|
-
const
|
|
3783
|
-
|
|
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,
|
|
3784
4110
|
argv: (this.opts.ptyArgvFor ?? buildPtyArgv)(plan),
|
|
3785
|
-
connection: `${
|
|
3786
|
-
|
|
3787
|
-
...form.label !== void 0 ? { label: form.label } : {}
|
|
3788
|
-
}, sessionKey) };
|
|
4111
|
+
connection: `${entry.kind === "docker" ? "docker" : "incus"}:${entry.name}`
|
|
4112
|
+
};
|
|
3789
4113
|
}
|
|
3790
|
-
|
|
3791
|
-
|
|
3792
|
-
|
|
3793
|
-
|
|
3794
|
-
|
|
3795
|
-
|
|
3796
|
-
|
|
3797
|
-
|
|
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,
|
|
3798
4187
|
byo: false,
|
|
3799
|
-
...
|
|
3800
|
-
}, sessionKey)
|
|
4188
|
+
...spec.builtOn === true ? { builtOn: true } : {}
|
|
4189
|
+
}, sessionKey);
|
|
4190
|
+
created = true;
|
|
4191
|
+
await session.warm();
|
|
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
|
+
};
|
|
3801
4260
|
}
|
|
4261
|
+
const r = this.closePty(session.identity.id, sessionKey, force);
|
|
4262
|
+
if (r.closed) this.releaseDefaultSlot(spec.key, sessionKey);
|
|
4263
|
+
return r;
|
|
3802
4264
|
}
|
|
3803
4265
|
/** background PTY lane:会话先占位(不拨号)再进官方 jobs registry——首回执必须能寻址;
|
|
3804
4266
|
* registry 拒绝时把新建的会话拆掉(复用形的会话绝不拆)。返回 undefined = 走普通 oneshot job。 */
|
|
@@ -3810,9 +4272,9 @@ var RemoteDriver = class {
|
|
|
3810
4272
|
const form = parseTargetForm(prepared.target);
|
|
3811
4273
|
const target = (id$1) => ({
|
|
3812
4274
|
target: `pty:${id$1}`,
|
|
3813
|
-
cmd:
|
|
3814
|
-
stdin:
|
|
3815
|
-
timeout:
|
|
4275
|
+
cmd: prepared.cmd,
|
|
4276
|
+
stdin: prepared.stdin,
|
|
4277
|
+
timeout: prepared.timeout,
|
|
3816
4278
|
mode: "pty"
|
|
3817
4279
|
});
|
|
3818
4280
|
const lane = this.createPtyLane(prepared, form, sessionKey);
|
|
@@ -3827,6 +4289,20 @@ var RemoteDriver = class {
|
|
|
3827
4289
|
cleanup: () => void 0
|
|
3828
4290
|
};
|
|
3829
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
|
+
}
|
|
3830
4306
|
if (lane === void 0) return void 0;
|
|
3831
4307
|
const id = lane.session.identity;
|
|
3832
4308
|
return {
|
|
@@ -3886,24 +4362,26 @@ var RemoteDriver = class {
|
|
|
3886
4362
|
this.pool.get(key)?.attachJob(commandId, jobId);
|
|
3887
4363
|
}
|
|
3888
4364
|
}
|
|
3889
|
-
/** §
|
|
3890
|
-
* 追加 stdin
|
|
3891
|
-
*
|
|
3892
|
-
* 一律明确拒绝并给出可恢复指引。回执附 connection/label 供工具层渲染。 */
|
|
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 供工具层渲染。 */
|
|
3893
4368
|
async input(target, options, callCtx = {}) {
|
|
3894
4369
|
this.assertOpen();
|
|
3895
4370
|
const sessionKey = callCtx.sessionKey ?? "no-session";
|
|
3896
4371
|
const form = parseTargetForm(target);
|
|
3897
|
-
let
|
|
4372
|
+
let session;
|
|
3898
4373
|
if (form.form === "pty-selector") {
|
|
3899
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)");
|
|
3900
|
-
|
|
3901
|
-
} else if (form.form === "bare")
|
|
3902
|
-
|
|
3903
|
-
|
|
3904
|
-
|
|
3905
|
-
|
|
3906
|
-
|
|
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)}`);
|
|
3907
4385
|
const receipt = await session.input(options);
|
|
3908
4386
|
const id = session.identity;
|
|
3909
4387
|
return {
|
|
@@ -3929,6 +4407,7 @@ var RemoteDriver = class {
|
|
|
3929
4407
|
jobId: commandId
|
|
3930
4408
|
} : {}
|
|
3931
4409
|
});
|
|
4410
|
+
if (callCtx.ptyBackground === true && r.running === true) this.releaseSlotByPoolKey(session.key, callCtx.sessionKey ?? "no-session");
|
|
3932
4411
|
const out = disclose(r.output, r.outputStats?.stdout);
|
|
3933
4412
|
const id = session.identity;
|
|
3934
4413
|
return {
|
|
@@ -3992,14 +4471,14 @@ var RemoteDriver = class {
|
|
|
3992
4471
|
cwd: session.lastCwd,
|
|
3993
4472
|
timedOut: false,
|
|
3994
4473
|
reconnected: false,
|
|
3995
|
-
stdout: [head,
|
|
4474
|
+
stdout: [head, activeLine(session)].join("\n"),
|
|
3996
4475
|
durationMs: 0,
|
|
3997
4476
|
...this.ptyTtl(session.key)
|
|
3998
4477
|
};
|
|
3999
4478
|
}
|
|
4000
4479
|
async oneshot(plan, display, params, timeoutSec) {
|
|
4001
4480
|
this.assertOpen();
|
|
4002
|
-
const process$1 = this.own(await this.nativeProcess(plan, params, timeoutSec));
|
|
4481
|
+
const process$1 = this.own(await this.nativeProcess(plan, display, params, timeoutSec));
|
|
4003
4482
|
if (this.disposed) process$1.cancel("owner disposed");
|
|
4004
4483
|
const result = await process$1.done;
|
|
4005
4484
|
const stdout = process$1.read("stdout", 0), stderr = process$1.read("stderr", 0);
|
|
@@ -4085,17 +4564,20 @@ var RemoteDriver = class {
|
|
|
4085
4564
|
return s === void 0 ? null : s;
|
|
4086
4565
|
}).filter((s) => s !== null);
|
|
4087
4566
|
const hub = this.opts.getHub?.();
|
|
4088
|
-
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"));
|
|
4089
4568
|
}
|
|
4090
4569
|
/** status probe(target 无 cmd):机器面 = probe + 该 connection 上的会话;pty selector = session card。 */
|
|
4091
4570
|
async status(target, callCtx = {}) {
|
|
4092
4571
|
this.assertOpen();
|
|
4093
4572
|
const sessionKey = callCtx.sessionKey ?? "no-session";
|
|
4094
4573
|
const form = parseTargetForm(target);
|
|
4095
|
-
if (form.form === "hub"
|
|
4574
|
+
if (form.form === "hub") {
|
|
4096
4575
|
const row = this.requireHub().listTargets().find((t) => t.id === form.machine);
|
|
4097
4576
|
if (!row) throw new Error(`unknown remote machine ${form.machine} — select a remote machine from the Hub roster; local execution is not available`);
|
|
4098
|
-
|
|
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");
|
|
4099
4581
|
}
|
|
4100
4582
|
if (form.form === "pty-selector") {
|
|
4101
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)");
|
|
@@ -4113,15 +4595,23 @@ var RemoteDriver = class {
|
|
|
4113
4595
|
const plan = this.planOf(entry);
|
|
4114
4596
|
const head = `${target} — ${plan.kind === "ssh" ? "ssh host" : `${plan.kind} container`}${entry.kind === "ssh-byo" ? " (built on)" : ""}`;
|
|
4115
4597
|
const runner = this.opts.probeRunner ?? runOneShot;
|
|
4116
|
-
const probe = await probeTarget(plan, {
|
|
4117
|
-
|
|
4118
|
-
|
|
4119
|
-
|
|
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
|
+
});
|
|
4120
4605
|
const prefix = `${sessionKey}|`;
|
|
4121
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)}`;
|
|
4122
4611
|
return [
|
|
4123
4612
|
head,
|
|
4124
4613
|
renderProbe(probe),
|
|
4614
|
+
slotLine,
|
|
4125
4615
|
onConnection.length > 0 ? `pty sessions on this connection: ${onConnection.map(sessionLine).join(" · ")}` : "pty sessions on this connection: none"
|
|
4126
4616
|
].join("\n");
|
|
4127
4617
|
}
|
|
@@ -4136,8 +4626,7 @@ var RemoteDriver = class {
|
|
|
4136
4626
|
this.assertOpen();
|
|
4137
4627
|
const form = parseTargetForm(target);
|
|
4138
4628
|
if (form.form === "pty-selector") throw new Error("remote: a PTY session is not a filesystem connection");
|
|
4139
|
-
if (form.form === "hub"
|
|
4140
|
-
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");
|
|
4141
4630
|
if (form.form === "direct") return form.transport === "ssh" ? this.sshPlanFor(form.name, callCtx.sessionKey ?? "no-session") : resolveTarget(`${form.transport}:${form.name}`);
|
|
4142
4631
|
if (form.form === "ssh-prefix" || form.form === "empty") throw new Error("[E_TARGET_FORMAT] remote: connection targets need a name");
|
|
4143
4632
|
const entry = await this.resolvePoolEntry(form.name, callCtx.sessionKey ?? "no-session");
|
|
@@ -4167,6 +4656,7 @@ var RemoteDriver = class {
|
|
|
4167
4656
|
if (this.disposal) return this.disposal;
|
|
4168
4657
|
this.disposed = true;
|
|
4169
4658
|
this.labels.clear();
|
|
4659
|
+
this.defaultSlots.clear();
|
|
4170
4660
|
this.fileAbort.abort(/* @__PURE__ */ new Error("remote owner disposed"));
|
|
4171
4661
|
for (const process$1 of this.processes) process$1.cancel("remote owner disposed");
|
|
4172
4662
|
return this.disposal = (async () => {
|
|
@@ -4177,12 +4667,18 @@ var RemoteDriver = class {
|
|
|
4177
4667
|
...[...this.hubProviders].map((hub) => hub.releaseOwner(this.hubOwner))
|
|
4178
4668
|
]);
|
|
4179
4669
|
this.hubProviders.clear();
|
|
4670
|
+
await this.mux.dispose();
|
|
4180
4671
|
})();
|
|
4181
4672
|
}
|
|
4182
4673
|
};
|
|
4183
4674
|
/** direct 形态回退到 resolveTarget 可解析的名字(ssh:dev4 → dev4)。 */
|
|
4184
4675
|
function directName(form) {
|
|
4185
|
-
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`;
|
|
4186
4682
|
}
|
|
4187
4683
|
/** roster/status 一行的会话描述:`p-ab12 (label build) ssh:dev4 idle 12s`。 */
|
|
4188
4684
|
function sessionLine(s) {
|
|
@@ -4217,7 +4713,8 @@ const ptyOutcome = (aborted, result) => {
|
|
|
4217
4713
|
function startRemoteJob(registry, input, exec, driver) {
|
|
4218
4714
|
if (!exec.agent) throw new Error("remote: background jobs require a live Agent owner");
|
|
4219
4715
|
const form = parseTargetForm(input.target);
|
|
4220
|
-
|
|
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") {
|
|
4221
4718
|
const holder = {};
|
|
4222
4719
|
return registry.start({
|
|
4223
4720
|
kind: "remote",
|
|
@@ -4354,83 +4851,32 @@ const NO_SESSION_KEY$1 = "remote:no-session";
|
|
|
4354
4851
|
* interact-able stdin / managed output / persistent env——回执形状与 oneshot 相同、只增状态,
|
|
4355
4852
|
* 运行中的命令可分步观察后再补输(input 动作);多步与交互任务默认进 PTY。
|
|
4356
4853
|
*/
|
|
4357
|
-
const REMOTE_TOOL_DESCRIPTION = `Remote shell
|
|
4358
|
-
|
|
4359
|
-
Actions (action is the only dispatch dimension; unknown fields are rejected):
|
|
4360
|
-
- exec {target, cmd | scriptPath, stdin?, timeout?, run_in_background?}: run a command. Machine target → oneshot; pty:<id|label> → run inside that live session; <machine>:pty[:label] → create a NEW session and run there.
|
|
4361
|
-
- add {ssh:{...} | spawn:"<cmd>", target?}: register a BYO endpoint (identity or interactive-shell command); no command runs. Returns the addressable label; a taken label answers [E_LABEL_IN_USE] — address existing names directly, never add again.
|
|
4362
|
-
- status {}: roster — the one unified pool (machines, identities, live PTY sessions).
|
|
4363
|
-
- status {target}: machine probe, or a session card (connection, cwd, ledger of the last 20 commands).
|
|
4364
|
-
- input {target:"pty:<id|label>", stdin, commandId?}: write stdin into the command currently running in that session — passwords, OTP, tokens, menu choices, REPL lines. Never creates a session, never dials.
|
|
4365
|
-
- close {target, force?}: close a PTY session; a running command refuses it (job_kill first) unless force:true kills it in the same step.
|
|
4366
|
-
|
|
4367
|
-
Targets:
|
|
4368
|
-
- ssh:<name> / docker:<name> / incus:<name> / bare pool name → stateless oneshot.
|
|
4369
|
-
- <machine>:pty[:label] → NEW PTY every call — creation is a target suffix, not a different tool.
|
|
4370
|
-
- pty:<id|label> (or its bare label) → REUSE the live session.
|
|
4371
|
-
After creation, reuse the returned session; repeating :pty opens another. Prefixes force the channel; bare names resolve through the roster.
|
|
4372
|
-
|
|
4373
|
-
PTY:
|
|
4374
|
-
- stateful oneshot, interact-able stdin, managed output, persistent env
|
|
4375
|
-
- timeout (default 120s) → command keeps running; returns job handle.
|
|
4376
|
-
- run_in_background → same job lane. jobId = commandId.
|
|
4377
|
-
- Read: job_output(jobId). Cancel: job_kill; try Ctrl-C first.
|
|
4378
|
-
- Busy session: input / poll / cancel / use another session.
|
|
4379
|
-
- exec.stdin = startup input; input = subsequent input.
|
|
4380
|
-
- input is verbatim: "\\n" submits; "\\u0003" Ctrl-C; "\\u0004" Ctrl-D; "" writes nothing. Ctrl-D follows terminal EOF rules.
|
|
4381
|
-
- commandId, if supplied, must match running command.
|
|
4382
|
-
- Input receipt confirms write only; read result via SAME job.
|
|
4383
|
-
- Output returns in full up to retention (4M chars per stream; Hub oneshot 4MiB bytes) — beyond it the retained tail plus outputStats. Redirect bulk output to files.
|
|
4384
|
-
- Idle expiry: 15 min; running commands exempt.
|
|
4385
|
-
|
|
4386
|
-
Oneshot:
|
|
4387
|
-
- stdout/stderr separate, exit code verbatim; no retained shell state. stdin is startup-only: there is no input after start — a prompt on a oneshot means cancel (job_kill) and switch to PTY. Heredocs and input redirection remain valid bulk-input alternatives.
|
|
4388
|
-
- Supervision prefers a pinned superd-run helper cached on the endpoint (~/.cache/superd/run), falls back to a POSIX shim — the receipt field "runner" says which ran. Either way children are reaped on completion and the whole group is killed on timeout/cancel. The endpoint needs bash only — no Python.
|
|
4389
|
-
- scriptPath (oneshot only): a LOCAL absolute script read before any dialing, sent literally over stdin to a fixed bash -s — the file carrier for multi-line scripts; pairs with neither cmd nor stdin.
|
|
4390
|
-
|
|
4391
|
-
Examples
|
|
4854
|
+
const REMOTE_TOOL_DESCRIPTION = `Remote shell (SSH, Docker, Incus, Hub): 1 default interactive PTY per machine (retains cwd/env). \`mode:"oneshot"\` = isolated, stateless.
|
|
4392
4855
|
|
|
4393
|
-
|
|
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\`.
|
|
4394
4862
|
|
|
4395
|
-
|
|
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).
|
|
4396
4869
|
|
|
4397
|
-
|
|
4398
|
-
|
|
4399
|
-
|
|
4400
|
-
|
|
4401
|
-
|
|
4402
|
-
remote {
|
|
4403
|
-
|
|
4404
|
-
|
|
4405
|
-
|
|
4406
|
-
|
|
4407
|
-
LISTEN 0 4096 127.0.0.1:2019 0.0.0.0:* users:(("caddy",pid=5600,fd=4))
|
|
4408
|
-
LISTEN 0 4096 127.0.0.1:19514 0.0.0.0:* users:(("multica",pid=27030,fd=6))
|
|
4409
|
-
LISTEN 0 4096 127.0.0.54:53 0.0.0.0:* users:(("systemd-resolve",pid=26758,fd=17))
|
|
4410
|
-
[pty:pty-irsq (dev4via) (byo-pty) · conn BYOP · cmd remote-0d89215d1cc64cdc · cwd /home/u1 · idle-left ~15m · exit 0 · 0.3s]
|
|
4411
|
-
|
|
4412
|
-
Reuse the same session for a long task — returns a jobId immediately; collect with job_output, cancel with job_kill:
|
|
4413
|
-
|
|
4414
|
-
remote { "action": "exec", "target": "pty:dev4via", "cmd": "for i in 1 2 3; do sleep 1; echo \\"tick $i\\"; done; echo done", "run_in_background": true }
|
|
4415
|
-
|
|
4416
|
-
Started remote-47676dff2c6443e8 on pty:pty-irsq (dev4via) (BYOP). Use job_output to read and job_kill to cancel; completion is reported by the host.
|
|
4417
|
-
|
|
4418
|
-
job_output { "job_id": "remote-47676dff2c6443e8", "wait": true }
|
|
4419
|
-
|
|
4420
|
-
tick 1
|
|
4421
|
-
tick 2
|
|
4422
|
-
tick 3
|
|
4423
|
-
done
|
|
4424
|
-
[status: completed, exit code: 0]
|
|
4425
|
-
|
|
4426
|
-
- Bring Your Own PTY fits when the endpoint has no direct route, or the connection/transport layer must be composed yourself (jump chains, docker exec, console servers).
|
|
4427
|
-
- It also strips one or two shell-escaping layers — commands go straight to the innermost bash, quotes behave naturally.
|
|
4428
|
-
|
|
4429
|
-
Register once — the label enters the pool and works everywhere: exec, pty, copy, rsync. Worth it whenever more than one operation type is involved. Keys stay local-file references; nothing persisted.
|
|
4430
|
-
|
|
4431
|
-
remote { "action": "add", "ssh": { "host": "10.0.0.8", "user": "u1", "port": 22, "key": "/home/u1/.ssh/id_key4" }, "target": "ssh:lab" }
|
|
4432
|
-
remote { "action": "exec", "target": "ssh:lab", "cmd": "uname -a" }
|
|
4433
|
-
remote_transfer { "mode": "rsync", "src": "ssh:lab:/var/log/app/", "dst": "/home/u1/app-logs/", "argv": ["-a"] }`;
|
|
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"]}`;
|
|
4434
4880
|
/** action 是唯一派发维度:每个动词声明自己的字段集,超出即报错(不做在场推断、不留别名)。 */
|
|
4435
4881
|
const ACTION_PARAMS = {
|
|
4436
4882
|
exec: new Set([
|
|
@@ -4440,8 +4886,11 @@ const ACTION_PARAMS = {
|
|
|
4440
4886
|
"stdin",
|
|
4441
4887
|
"scriptPath",
|
|
4442
4888
|
"timeout",
|
|
4443
|
-
"run_in_background"
|
|
4889
|
+
"run_in_background",
|
|
4890
|
+
"mode",
|
|
4891
|
+
"commandId"
|
|
4444
4892
|
]),
|
|
4893
|
+
handoff: new Set(["action", "target"]),
|
|
4445
4894
|
add: new Set([
|
|
4446
4895
|
"action",
|
|
4447
4896
|
"target",
|
|
@@ -4453,26 +4902,20 @@ const ACTION_PARAMS = {
|
|
|
4453
4902
|
"action",
|
|
4454
4903
|
"target",
|
|
4455
4904
|
"force"
|
|
4456
|
-
]),
|
|
4457
|
-
input: new Set([
|
|
4458
|
-
"action",
|
|
4459
|
-
"target",
|
|
4460
|
-
"stdin",
|
|
4461
|
-
"commandId"
|
|
4462
4905
|
])
|
|
4463
4906
|
};
|
|
4464
4907
|
const ACTION_HINT = {
|
|
4465
|
-
exec: "exec takes target plus cmd (or scriptPath) and the execution options; ssh/spawn belong to add",
|
|
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>",
|
|
4466
4910
|
add: "add takes exactly one of ssh or spawn, plus an optional target label",
|
|
4467
4911
|
status: "status takes only an optional target — omit it for the roster",
|
|
4468
|
-
close: "close takes target pty:<id|label> and optional force"
|
|
4469
|
-
input: "input takes target (a live PTY session), stdin, and an optional commandId guard — no cmd, no timeout, no creation"
|
|
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"
|
|
4470
4913
|
};
|
|
4471
4914
|
async function remoteExecute(ENV, args, exec) {
|
|
4472
4915
|
const action = args.action;
|
|
4473
|
-
if (action === void 0) throw new Error("[E_PARAMS] remote: action is required — \"exec\" (run a command), \"add\" (register a BYO endpoint), \"status\" (roster / probe / session card), \"close\" (close a PTY
|
|
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)");
|
|
4474
4917
|
const allowed = ACTION_PARAMS[action];
|
|
4475
|
-
if (allowed === void 0) throw new Error(`[E_PARAMS] remote: unknown action ${JSON.stringify(String(action))} — one of exec / add / close / status
|
|
4918
|
+
if (allowed === void 0) throw new Error(`[E_PARAMS] remote: unknown action ${JSON.stringify(String(action))} — one of exec / handoff / add / close / status`);
|
|
4476
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]}`);
|
|
4477
4920
|
const sessionKey = ENV.getSessionKey(exec);
|
|
4478
4921
|
const hasTarget = typeof args.target === "string" && args.target.length > 0;
|
|
@@ -4491,40 +4934,38 @@ async function remoteExecute(ENV, args, exec) {
|
|
|
4491
4934
|
durationMs: 0
|
|
4492
4935
|
};
|
|
4493
4936
|
}
|
|
4494
|
-
if (action === "
|
|
4495
|
-
if (!hasTarget) throw new Error("[E_PARAMS]
|
|
4496
|
-
const r$1 = ENV.driver.
|
|
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 });
|
|
4497
4940
|
return {
|
|
4498
|
-
kind: "
|
|
4499
|
-
text: r$1.
|
|
4941
|
+
kind: "handoff",
|
|
4942
|
+
text: r$1.stdout,
|
|
4500
4943
|
exit: null,
|
|
4501
4944
|
durationMs: 0,
|
|
4502
4945
|
session: r$1.session,
|
|
4503
|
-
|
|
4504
|
-
...r$1.
|
|
4505
|
-
|
|
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 } : {}
|
|
4506
4955
|
};
|
|
4507
4956
|
}
|
|
4508
|
-
if (action === "
|
|
4509
|
-
if (!hasTarget) throw new Error("[E_PARAMS]
|
|
4510
|
-
|
|
4511
|
-
if (typeof args.commandId === "string" && args.commandId.length === 0) throw new Error("[E_PARAMS] remote: input: commandId, when given, must be the non-empty id from the exec receipt / job_output");
|
|
4512
|
-
const r$1 = await ENV.driver.input(args.target, {
|
|
4513
|
-
stdin: args.stdin,
|
|
4514
|
-
...args.commandId !== void 0 ? { commandId: args.commandId } : {}
|
|
4515
|
-
}, { sessionKey });
|
|
4516
|
-
const handle = r$1.jobId ?? r$1.commandId;
|
|
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);
|
|
4517
4960
|
return {
|
|
4518
|
-
kind: "
|
|
4519
|
-
text: `
|
|
4961
|
+
kind: "close",
|
|
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"}`,
|
|
4520
4963
|
exit: null,
|
|
4521
4964
|
durationMs: 0,
|
|
4522
4965
|
session: r$1.session,
|
|
4523
|
-
|
|
4524
|
-
...r$1.
|
|
4525
|
-
|
|
4526
|
-
...r$1.jobId !== void 0 ? { jobId: r$1.jobId } : {},
|
|
4527
|
-
writtenBytes: r$1.writtenBytes
|
|
4966
|
+
closed: r$1.closed,
|
|
4967
|
+
...r$1.reason !== void 0 ? { reason: r$1.reason } : {},
|
|
4968
|
+
killed: r$1.killed
|
|
4528
4969
|
};
|
|
4529
4970
|
}
|
|
4530
4971
|
if (action === "add") {
|
|
@@ -4545,15 +4986,34 @@ async function remoteExecute(ENV, args, exec) {
|
|
|
4545
4986
|
}
|
|
4546
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\".");
|
|
4547
4988
|
const target = args.target;
|
|
4548
|
-
|
|
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");
|
|
4549
4990
|
if (args.scriptPath !== void 0) {
|
|
4550
4991
|
if (args.scriptPath.length === 0) throw new Error("[E_PARAMS] remote: scriptPath must be a non-empty absolute path");
|
|
4551
|
-
if (ptyTarget) throw new Error("[E_SCRIPT_PARAMS] remote: scriptPath is oneshot-only — PTY lanes take cmd");
|
|
4552
4992
|
if (hasCmd) throw new Error("[E_SCRIPT_PARAMS] remote: scriptPath and cmd are alternative carriers — pass one");
|
|
4553
|
-
if (args.stdin !== void 0) throw new Error("[E_SCRIPT_PARAMS] remote: scriptPath already occupies stdin — do not pass stdin with it");
|
|
4554
4993
|
}
|
|
4555
|
-
if (args.
|
|
4556
|
-
if (!hasCmd && args.scriptPath === void 0)
|
|
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");
|
|
4557
5017
|
if (args.run_in_background) {
|
|
4558
5018
|
const jobs = exec.agent?.ctx.get("jobs");
|
|
4559
5019
|
if (!jobs) throw new Error("remote: background requires the official DSH jobs service and job_output/job_kill controller; foreground execution remains available");
|
|
@@ -4562,7 +5022,8 @@ async function remoteExecute(ENV, args, exec) {
|
|
|
4562
5022
|
cmd: args.cmd,
|
|
4563
5023
|
scriptPath: args.scriptPath,
|
|
4564
5024
|
stdin: args.stdin,
|
|
4565
|
-
timeout: args.timeout
|
|
5025
|
+
timeout: args.timeout,
|
|
5026
|
+
...args.mode !== void 0 ? { mode: args.mode } : {}
|
|
4566
5027
|
}, { sessionKey });
|
|
4567
5028
|
let jobId;
|
|
4568
5029
|
try {
|
|
@@ -4571,7 +5032,8 @@ async function remoteExecute(ENV, args, exec) {
|
|
|
4571
5032
|
cmd: args.cmd,
|
|
4572
5033
|
scriptPath: args.scriptPath,
|
|
4573
5034
|
stdin: args.stdin,
|
|
4574
|
-
timeout: args.timeout
|
|
5035
|
+
timeout: args.timeout,
|
|
5036
|
+
...args.mode !== void 0 ? { mode: args.mode } : {}
|
|
4575
5037
|
}, exec, ENV.driver);
|
|
4576
5038
|
} catch (error) {
|
|
4577
5039
|
lane?.cleanup();
|
|
@@ -4597,7 +5059,8 @@ async function remoteExecute(ENV, args, exec) {
|
|
|
4597
5059
|
cmd: args.cmd,
|
|
4598
5060
|
scriptPath: args.scriptPath,
|
|
4599
5061
|
stdin: args.stdin,
|
|
4600
|
-
timeout: args.timeout
|
|
5062
|
+
timeout: args.timeout,
|
|
5063
|
+
...args.mode !== void 0 ? { mode: args.mode } : {}
|
|
4601
5064
|
}, { sessionKey });
|
|
4602
5065
|
const v = workbenchValue(r);
|
|
4603
5066
|
if (r.running === true && r.ptyJob !== void 0) {
|
|
@@ -4624,17 +5087,17 @@ function createRemoteTool(driver, opts = {}) {
|
|
|
4624
5087
|
type: "string",
|
|
4625
5088
|
enum: [
|
|
4626
5089
|
"exec",
|
|
5090
|
+
"handoff",
|
|
4627
5091
|
"add",
|
|
4628
5092
|
"status",
|
|
4629
|
-
"close"
|
|
4630
|
-
"input"
|
|
5093
|
+
"close"
|
|
4631
5094
|
],
|
|
4632
5095
|
required: true,
|
|
4633
|
-
description: "Required. exec = run a command
|
|
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)."
|
|
4634
5097
|
},
|
|
4635
5098
|
target: {
|
|
4636
5099
|
type: "string",
|
|
4637
|
-
description: "exec: required
|
|
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>."
|
|
4638
5101
|
},
|
|
4639
5102
|
cmd: {
|
|
4640
5103
|
type: "string",
|
|
@@ -4642,19 +5105,24 @@ function createRemoteTool(driver, opts = {}) {
|
|
|
4642
5105
|
},
|
|
4643
5106
|
stdin: {
|
|
4644
5107
|
type: "string",
|
|
4645
|
-
description: "exec:
|
|
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."
|
|
4646
5109
|
},
|
|
4647
5110
|
commandId: {
|
|
4648
5111
|
type: "string",
|
|
4649
|
-
description: "
|
|
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."
|
|
4650
5113
|
},
|
|
4651
5114
|
timeout: {
|
|
4652
5115
|
type: "number",
|
|
4653
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)."
|
|
4654
5117
|
},
|
|
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."
|
|
5122
|
+
},
|
|
4655
5123
|
run_in_background: {
|
|
4656
5124
|
type: "boolean",
|
|
4657
|
-
description: "exec:
|
|
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."
|
|
4658
5126
|
},
|
|
4659
5127
|
ssh: {
|
|
4660
5128
|
type: "object",
|
|
@@ -4686,7 +5154,7 @@ function createRemoteTool(driver, opts = {}) {
|
|
|
4686
5154
|
},
|
|
4687
5155
|
spawn: {
|
|
4688
5156
|
type: "string",
|
|
4689
|
-
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
|
|
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)."
|
|
4690
5158
|
},
|
|
4691
5159
|
force: {
|
|
4692
5160
|
type: "boolean",
|
|
@@ -4703,6 +5171,7 @@ function createRemoteTool(driver, opts = {}) {
|
|
|
4703
5171
|
required: true,
|
|
4704
5172
|
enum: [
|
|
4705
5173
|
"exec",
|
|
5174
|
+
"handoff",
|
|
4706
5175
|
"status",
|
|
4707
5176
|
"roster",
|
|
4708
5177
|
"job",
|
|
@@ -4785,7 +5254,7 @@ function createRemoteTool(driver, opts = {}) {
|
|
|
4785
5254
|
const description = tool.description;
|
|
4786
5255
|
Object.defineProperty(tool, "description", {
|
|
4787
5256
|
enumerable: true,
|
|
4788
|
-
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).
|
|
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." : "")
|
|
4789
5258
|
});
|
|
4790
5259
|
return tool;
|
|
4791
5260
|
}
|
|
@@ -4824,7 +5293,7 @@ function renderEnvDelta(d) {
|
|
|
4824
5293
|
return bits.length > 0 ? bits.join(" ") : void 0;
|
|
4825
5294
|
}
|
|
4826
5295
|
function renderValue(v) {
|
|
4827
|
-
if (v.kind === "status" || v.kind === "roster" || v.kind === "job" || v.kind === "close" || v.kind === "input") return v.text;
|
|
5296
|
+
if (v.kind === "status" || v.kind === "roster" || v.kind === "job" || v.kind === "close" || v.kind === "input" || v.kind === "handoff") return v.text;
|
|
4828
5297
|
const parts = [];
|
|
4829
5298
|
if (v.reconnected === true) parts.push(RECONNECT_NOTICE);
|
|
4830
5299
|
if (typeof v.notice === "string" && v.notice.length > 0) parts.push(v.notice);
|