super-dsh 0.3.2 → 0.3.3-c
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -14
- package/agent-agy/dist/agent.d.ts +4 -1
- package/agent-agy/dist/agent.js +15 -1
- package/agent-agy/dist/index.js +5 -8
- package/agent-agy/package.json +6 -6
- package/agent-agy-sdk/dist/agent.d.ts +4 -1
- package/agent-agy-sdk/dist/agent.js +15 -1
- package/agent-agy-sdk/dist/index.js +5 -8
- package/agent-agy-sdk/package.json +5 -5
- package/agent-claude/dist/agent.js +15 -1
- package/agent-claude/package.json +8 -8
- package/agent-codex/dist/agent.js +15 -1
- package/agent-codex/package.json +7 -7
- package/agent-codex-sdk/dist/agent.js +15 -1
- package/agent-codex-sdk/dist/index.js +5 -11
- package/agent-codex-sdk/package.json +7 -7
- package/agent-hermes/dist/agent.js +15 -1
- package/agent-hermes/dist/index.js +5 -8
- package/agent-hermes/package.json +7 -7
- package/agent-omp/dist/agent.js +26 -2
- package/agent-omp/package.json +8 -8
- package/agent-omp-sdk/dist/agent.d.ts +4 -1
- package/agent-omp-sdk/dist/agent.js +15 -1
- package/agent-omp-sdk/package.json +8 -8
- package/agent-pi/dist/agent.js +20 -2
- package/agent-pi/dist/index.js +5 -11
- package/agent-pi/package.json +7 -7
- package/client/index.js +106 -249
- package/cordis.patch.yml +0 -6
- package/package.json +22 -27
- package/remote/hub-pty-pump.mjs +97 -0
- package/remote/plugin.d.ts +476 -200
- package/remote/plugin.js +1448 -667
- package/remote/provenance.json +22 -17
- package/superd-hub/dist/agent-model-face.d.ts +93 -0
- package/superd-hub/dist/agent-model-face.js +113 -0
- package/superd-hub/dist/exec.d.ts +3 -2
- package/superd-hub/dist/exec.js +6 -5
- package/superd-hub/dist/fusion-transport.js +0 -1
- package/superd-hub/dist/hub-tool.d.ts +1 -1
- package/superd-hub/dist/hub-tool.js +6 -6
- package/superd-hub/dist/index.d.ts +1 -0
- package/superd-hub/dist/index.js +1 -0
- package/superd-hub/dist/remote-provider.js +70 -36
- package/superd-hub/dist/world-entry.js +30 -0
- package/superd-hub/lib/client/index.js +106 -249
- package/superd-hub/package.json +14 -10
- package/superd-hub/dist/bundle-setup.d.ts +0 -27
- package/superd-hub/dist/bundle-setup.js +0 -94
- package/superd-hub/dist/client/companion-api.d.ts +0 -5
- package/superd-hub/dist/client/companion-api.js +0 -30
- package/superd-hub/dist/companion-contract.d.ts +0 -9
- package/superd-hub/dist/companion-contract.js +0 -1
- package/superd-hub/dist/companions.d.ts +0 -30
- package/superd-hub/dist/companions.js +0 -211
- package/superd-hub/dist/exec-pty-channel.d.ts +0 -58
- package/superd-hub/dist/exec-pty-channel.js +0 -162
- package/superd-hub/dist/exec-pty-framing.d.ts +0 -34
- package/superd-hub/dist/exec-pty-framing.js +0 -89
- package/superd-hub/dist/exec-pty-session.d.ts +0 -78
- package/superd-hub/dist/exec-pty-session.js +0 -228
package/remote/plugin.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import "node:module";
|
|
1
2
|
import { Context } from "@deepseek-ai/cordis";
|
|
2
3
|
import { chmod, lstat, mkdir, mkdtemp, open, readFile, readdir, readlink, rename, rm, stat, symlink, writeFile } from "node:fs/promises";
|
|
3
|
-
import { constants, createReadStream, createWriteStream, existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
|
|
4
|
+
import { constants, createReadStream, createWriteStream, existsSync, mkdirSync, readFileSync, rmSync, statSync } from "node:fs";
|
|
4
5
|
import { pipeline } from "node:stream/promises";
|
|
5
6
|
import { dirname, isAbsolute, join, posix, resolve } from "node:path";
|
|
6
7
|
import { Readable } from "node:stream";
|
|
@@ -9,6 +10,7 @@ import { homedir, tmpdir } from "node:os";
|
|
|
9
10
|
import { spawn } from "node:child_process";
|
|
10
11
|
import { createConnection, createServer } from "node:net";
|
|
11
12
|
import { fileURLToPath } from "node:url";
|
|
13
|
+
import { Buffer as Buffer$1 } from "node:buffer";
|
|
12
14
|
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
13
15
|
|
|
14
16
|
//#region third_party/schemastery/node_modules/@deepseek-ai/cosmokit/lib/index.js
|
|
@@ -1464,6 +1466,15 @@ function startBinaryProcess(argv, options = {}) {
|
|
|
1464
1466
|
let diagnostic;
|
|
1465
1467
|
let timedOut = false, finished = false, cancelled = false;
|
|
1466
1468
|
let cleanup;
|
|
1469
|
+
let cancelledNotified = false;
|
|
1470
|
+
const notifyCancel = () => {
|
|
1471
|
+
if (!cancelledNotified) {
|
|
1472
|
+
cancelledNotified = true;
|
|
1473
|
+
try {
|
|
1474
|
+
options.onCancel?.();
|
|
1475
|
+
} catch {}
|
|
1476
|
+
}
|
|
1477
|
+
};
|
|
1467
1478
|
let timer;
|
|
1468
1479
|
const signal = (sig) => {
|
|
1469
1480
|
if (child.pid === void 0) return false;
|
|
@@ -1503,6 +1514,7 @@ function startBinaryProcess(argv, options = {}) {
|
|
|
1503
1514
|
const cancel = () => {
|
|
1504
1515
|
if (!finished) {
|
|
1505
1516
|
cancelled = true;
|
|
1517
|
+
notifyCancel();
|
|
1506
1518
|
reap();
|
|
1507
1519
|
}
|
|
1508
1520
|
};
|
|
@@ -2501,150 +2513,6 @@ function sshArgv(connection, options = {}) {
|
|
|
2501
2513
|
], options.extraArgs ?? [], options.command);
|
|
2502
2514
|
}
|
|
2503
2515
|
|
|
2504
|
-
//#endregion
|
|
2505
|
-
//#region src/remote/input.ts
|
|
2506
|
-
/** Local carrier validation precedes any target discovery, connection or spawn. */
|
|
2507
|
-
async function prepareRemoteInput(input) {
|
|
2508
|
-
if (input.mode !== void 0 && input.mode !== "oneshot" && input.mode !== "pty") throw new Error("[E_BAD_MODE] remote: mode must be oneshot or pty");
|
|
2509
|
-
if (input.scriptPath !== void 0) {
|
|
2510
|
-
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");
|
|
2511
|
-
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");
|
|
2512
|
-
}
|
|
2513
|
-
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)");
|
|
2514
|
-
if (input.scriptPath !== void 0) {
|
|
2515
|
-
let stdin;
|
|
2516
|
-
try {
|
|
2517
|
-
stdin = await readFile(input.scriptPath, "utf8");
|
|
2518
|
-
} catch (error) {
|
|
2519
|
-
throw new Error(`[E_SCRIPT_READ] remote: cannot read local script ${JSON.stringify(input.scriptPath)} — ${error instanceof Error ? error.message : String(error)}`);
|
|
2520
|
-
}
|
|
2521
|
-
return {
|
|
2522
|
-
target: input.target,
|
|
2523
|
-
ssh: input.ssh,
|
|
2524
|
-
cmd: "bash -s",
|
|
2525
|
-
stdin,
|
|
2526
|
-
mode: "oneshot",
|
|
2527
|
-
timeout: input.timeout
|
|
2528
|
-
};
|
|
2529
|
-
}
|
|
2530
|
-
return {
|
|
2531
|
-
target: input.target,
|
|
2532
|
-
spawn: input.spawn,
|
|
2533
|
-
ssh: input.ssh,
|
|
2534
|
-
cmd: input.cmd ?? "",
|
|
2535
|
-
stdin: input.stdin,
|
|
2536
|
-
mode: input.mode,
|
|
2537
|
-
timeout: input.timeout
|
|
2538
|
-
};
|
|
2539
|
-
}
|
|
2540
|
-
|
|
2541
|
-
//#endregion
|
|
2542
|
-
//#region src/remote/oneshot.ts
|
|
2543
|
-
async function runOneShot(argv, opts = {}) {
|
|
2544
|
-
const process$1 = startOneShot(argv, opts);
|
|
2545
|
-
const result = await process$1.done;
|
|
2546
|
-
const stdout = process$1.read("stdout", 0), stderr = process$1.read("stderr", 0);
|
|
2547
|
-
return {
|
|
2548
|
-
...result,
|
|
2549
|
-
stdout: stdout.text,
|
|
2550
|
-
stderr: [stderr.text, stderr.diagnostic].filter(Boolean).join("\n"),
|
|
2551
|
-
outputStats: {
|
|
2552
|
-
stdout: stdout.stats,
|
|
2553
|
-
stderr: stderr.stats
|
|
2554
|
-
}
|
|
2555
|
-
};
|
|
2556
|
-
}
|
|
2557
|
-
|
|
2558
|
-
//#endregion
|
|
2559
|
-
//#region src/remote/helper.ts
|
|
2560
|
-
/** Reads helper-bin/MANIFEST.json next to the compiled module (lib/remote in the npm
|
|
2561
|
-
* package, super-dsh/remote in the Super closure — same relative layout). Missing or
|
|
2562
|
-
* malformed assets ⇒ null ⇒ shim-only mode; never throws. */
|
|
2563
|
-
function helperAssets(baseDir) {
|
|
2564
|
-
const dir = baseDir ?? join(dirname(fileURLToPath(import.meta.url)), "helper-bin");
|
|
2565
|
-
try {
|
|
2566
|
-
const manifest = JSON.parse(readFileSync(join(dir, "MANIFEST.json"), "utf8"));
|
|
2567
|
-
if (manifest?.format !== 1 || typeof manifest.ver !== "string" || typeof manifest.protocol !== "number" || !manifest.targets || typeof manifest.targets !== "object") return null;
|
|
2568
|
-
return {
|
|
2569
|
-
dir,
|
|
2570
|
-
manifest
|
|
2571
|
-
};
|
|
2572
|
-
} catch {
|
|
2573
|
-
return null;
|
|
2574
|
-
}
|
|
2575
|
-
}
|
|
2576
|
-
/** `uname -s`/`uname -m` → shipped target triple. Unknown combos ⇒ null (shim). */
|
|
2577
|
-
function archTriple(sys, machine, shipped) {
|
|
2578
|
-
const s = sys.trim().toLowerCase(), m = machine.trim().toLowerCase();
|
|
2579
|
-
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;
|
|
2580
|
-
if (candidates === null) return null;
|
|
2581
|
-
return shipped.has(candidates) ? candidates : null;
|
|
2582
|
-
}
|
|
2583
|
-
function planKey(plan) {
|
|
2584
|
-
switch (plan.kind) {
|
|
2585
|
-
case "ssh": {
|
|
2586
|
-
const c = plan.connection;
|
|
2587
|
-
const host = c?.host ?? plan.host;
|
|
2588
|
-
return `ssh|${c?.user ? c.user + "@" : ""}${host}${c?.port ? ":" + c.port : ""}${c?.proxyJump ? "|" + c.proxyJump : ""}`;
|
|
2589
|
-
}
|
|
2590
|
-
case "docker": return `docker|${plan.container}`;
|
|
2591
|
-
case "incus": return `incus|${plan.container}`;
|
|
2592
|
-
}
|
|
2593
|
-
}
|
|
2594
|
-
const PROBE_RE = /^SUPERD-PROBE (\S+) (\S+)$/m;
|
|
2595
|
-
const READY_RE = /^SUPERD-READY (\S+)$/m;
|
|
2596
|
-
var HelperRegistry = class {
|
|
2597
|
-
states = /* @__PURE__ */ new Map();
|
|
2598
|
-
constructor(assets, exec, probeArgv, uploadArgv) {
|
|
2599
|
-
this.assets = assets;
|
|
2600
|
-
this.exec = exec;
|
|
2601
|
-
this.probeArgv = probeArgv;
|
|
2602
|
-
this.uploadArgv = uploadArgv;
|
|
2603
|
-
}
|
|
2604
|
-
/** Never throws: any bootstrap failure resolves null (sticky) and the caller stays on the shim. */
|
|
2605
|
-
ensure(plan) {
|
|
2606
|
-
if (plan.kind !== "ssh" && plan.kind !== "docker" && plan.kind !== "incus") return Promise.resolve(null);
|
|
2607
|
-
const key = planKey(plan);
|
|
2608
|
-
let entry = this.states.get(key);
|
|
2609
|
-
if (entry === void 0) {
|
|
2610
|
-
entry = this.bootstrap(plan).catch(() => null);
|
|
2611
|
-
entry.catch(() => {});
|
|
2612
|
-
this.states.set(key, entry);
|
|
2613
|
-
}
|
|
2614
|
-
return entry;
|
|
2615
|
-
}
|
|
2616
|
-
async bootstrap(plan) {
|
|
2617
|
-
const { manifest } = this.assets;
|
|
2618
|
-
const first = await this.exec(this.probeArgv(plan, this.assets), { timeoutSec: 15 });
|
|
2619
|
-
const ready = this.parseReady(first.stdout, manifest.ver, manifest.protocol);
|
|
2620
|
-
if (ready !== null) return ready;
|
|
2621
|
-
const probe = PROBE_RE.exec(first.stdout);
|
|
2622
|
-
if (probe === null || first.exit !== 90 && first.exit !== 0) return null;
|
|
2623
|
-
const triple = archTriple(probe[1], probe[2], new Set(Object.keys(manifest.targets)));
|
|
2624
|
-
if (triple === null) return null;
|
|
2625
|
-
const target = manifest.targets[triple];
|
|
2626
|
-
const binary = await readFile(join(this.assets.dir, triple, target.file));
|
|
2627
|
-
if ((await this.exec(this.uploadArgv(plan, triple, target.file, this.assets), {
|
|
2628
|
-
stdin: binary,
|
|
2629
|
-
timeoutSec: 60
|
|
2630
|
-
})).exit !== 0) return null;
|
|
2631
|
-
const second = await this.exec(this.probeArgv(plan, this.assets), { timeoutSec: 15 });
|
|
2632
|
-
return this.parseReady(second.stdout, manifest.ver, manifest.protocol);
|
|
2633
|
-
}
|
|
2634
|
-
parseReady(stdout, ver, protocol) {
|
|
2635
|
-
const ready = READY_RE.exec(stdout);
|
|
2636
|
-
const banner = /^SUPERD-RUN proto=(\d+) name=superd-run ver=(\S+) target=(\S+)/m.exec(stdout);
|
|
2637
|
-
if (ready === null || banner === null) return null;
|
|
2638
|
-
if (banner[1] !== String(protocol) || banner[2] !== ver) return null;
|
|
2639
|
-
return {
|
|
2640
|
-
binPath: ready[1],
|
|
2641
|
-
triple: banner[3],
|
|
2642
|
-
ver,
|
|
2643
|
-
protocol
|
|
2644
|
-
};
|
|
2645
|
-
}
|
|
2646
|
-
};
|
|
2647
|
-
|
|
2648
2516
|
//#endregion
|
|
2649
2517
|
//#region src/remote/transports.ts
|
|
2650
2518
|
/** POSIX 单引号机械转义(唯一一层转义,spec §三"一层机械转义")。 */
|
|
@@ -2673,7 +2541,10 @@ bash -lc ${shQuote(cmd)}`;
|
|
|
2673
2541
|
wrapped
|
|
2674
2542
|
];
|
|
2675
2543
|
switch (plan.kind) {
|
|
2676
|
-
case "ssh": return sshArgv(plan.connection ?? { host: plan.host }, {
|
|
2544
|
+
case "ssh": return sshArgv(plan.connection ?? { host: plan.host }, {
|
|
2545
|
+
command: `setsid --wait bash -c ${shQuote(wrapped)}`,
|
|
2546
|
+
extraArgs: options.muxArgv
|
|
2547
|
+
});
|
|
2677
2548
|
case "docker": return [
|
|
2678
2549
|
"docker",
|
|
2679
2550
|
"exec",
|
|
@@ -2727,21 +2598,41 @@ function buildSpawnArgv(spawnCommand) {
|
|
|
2727
2598
|
"/dev/null"
|
|
2728
2599
|
];
|
|
2729
2600
|
}
|
|
2601
|
+
/** Hub PTY(0.3.2-c;0.3.2-d F4 修正):会话子进程 = 本地 `script` PTY 托管的 WS 字节泵
|
|
2602
|
+
* (hub-pty-pump.mjs)。与 docker exec 的嵌套 PTY 同形,但泵是哑 node 子进程、不像
|
|
2603
|
+
* `ssh -tt` / `docker exec -it` 那样作为前台子进程自己把本地 tty 置 raw——所以本地包装必须
|
|
2604
|
+
* `stty raw`:canonical+ISIG 下 0x03 在本地变 SIGINT 杀泵、0x04 变 EOF 结束泵 stdin
|
|
2605
|
+
* (F4:input ^C/^D 与 job_kill 会打死整条会话)。raw 只作用于本地半段;对端 script PTY
|
|
2606
|
+
* 保持 canonical,^C/^D 在对端产生 SIGINT/EOF 正是我们要的语义(字节原样过线,解释权在
|
|
2607
|
+
* 对端)。kill 链 session 组杀 → 泵死 → WS 关 → 对端组杀,双向自然闭合。连接事实走 wrapper
|
|
2608
|
+
* 内 env 赋值(cookie 不进 argv、不落 ps)。 */
|
|
2609
|
+
function buildHubPtyArgv(endpoint, assets) {
|
|
2610
|
+
const node = assets.nodePath ?? process.execPath;
|
|
2611
|
+
return [
|
|
2612
|
+
"script",
|
|
2613
|
+
"-qfec",
|
|
2614
|
+
`stty raw -echo 2>/dev/null ; ${`export SUPERD_HUB_PTY_URL=${shQuote(endpoint.url)} SUPERD_HUB_PTY_COOKIE=${shQuote(endpoint.cookie)} SUPERD_HUB_PTY_WS=${shQuote(endpoint.wsPath)}`} ; exec ${shQuote(node)} ${shQuote(assets.pumpPath)}`,
|
|
2615
|
+
"/dev/null"
|
|
2616
|
+
];
|
|
2617
|
+
}
|
|
2730
2618
|
/** superd-run helper argv (spec: docs/specs/remote-rescope §5). Probe and upload are single
|
|
2731
2619
|
* `sh -c` hops (busybox/alpine/darwin-safe); run/killgroup over ssh go through exactly one
|
|
2732
2620
|
* remote-shell parse, same discipline as buildOneShotArgv. */
|
|
2733
|
-
function buildHelperProbeArgv(plan, assets) {
|
|
2621
|
+
function buildHelperProbeArgv(plan, assets, muxArgv) {
|
|
2734
2622
|
const { ver, targets } = assets.manifest;
|
|
2735
|
-
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
|
|
2623
|
+
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);
|
|
2736
2624
|
}
|
|
2737
|
-
function buildHelperUploadArgv(plan, triple, file, assets) {
|
|
2625
|
+
function buildHelperUploadArgv(plan, triple, file, assets, muxArgv) {
|
|
2738
2626
|
const dir = `$HOME/.cache/superd/run/${triple}-${assets.manifest.ver}`;
|
|
2739
|
-
return helperShellArgv(plan, `mkdir -p "${dir}" && cat > "${dir}/${file}.tmp" && chmod 0755 "${dir}/${file}.tmp" && mv -f "${dir}/${file}.tmp" "${dir}/${file}"
|
|
2627
|
+
return helperShellArgv(plan, `mkdir -p "${dir}" && cat > "${dir}/${file}.tmp" && chmod 0755 "${dir}/${file}.tmp" && mv -f "${dir}/${file}.tmp" "${dir}/${file}"`, muxArgv);
|
|
2740
2628
|
}
|
|
2741
2629
|
function buildHelperOneShotArgv(plan, binPath, options) {
|
|
2742
2630
|
const { tag, cmd } = options;
|
|
2743
2631
|
switch (plan.kind) {
|
|
2744
|
-
case "ssh": return sshArgv(plan.connection ?? { host: plan.host }, {
|
|
2632
|
+
case "ssh": return sshArgv(plan.connection ?? { host: plan.host }, {
|
|
2633
|
+
command: `exec ${shQuote(binPath)} run --tag ${shQuote(tag)} -- bash -lc ${shQuote(cmd)}`,
|
|
2634
|
+
extraArgs: options.muxArgv
|
|
2635
|
+
});
|
|
2745
2636
|
case "docker": return [
|
|
2746
2637
|
"docker",
|
|
2747
2638
|
"exec",
|
|
@@ -2774,9 +2665,12 @@ function buildHelperOneShotArgv(plan, binPath, options) {
|
|
|
2774
2665
|
];
|
|
2775
2666
|
}
|
|
2776
2667
|
}
|
|
2777
|
-
function buildHelperKillgroupArgv(plan, binPath, tag) {
|
|
2668
|
+
function buildHelperKillgroupArgv(plan, binPath, tag, muxArgv) {
|
|
2778
2669
|
switch (plan.kind) {
|
|
2779
|
-
case "ssh": return sshArgv(plan.connection ?? { host: plan.host }, {
|
|
2670
|
+
case "ssh": return sshArgv(plan.connection ?? { host: plan.host }, {
|
|
2671
|
+
command: `${shQuote(binPath)} killgroup --tag ${shQuote(tag)}`,
|
|
2672
|
+
extraArgs: muxArgv
|
|
2673
|
+
});
|
|
2780
2674
|
case "docker": return [
|
|
2781
2675
|
"docker",
|
|
2782
2676
|
"exec",
|
|
@@ -2800,9 +2694,12 @@ function buildHelperKillgroupArgv(plan, binPath, tag) {
|
|
|
2800
2694
|
];
|
|
2801
2695
|
}
|
|
2802
2696
|
}
|
|
2803
|
-
function helperShellArgv(plan, script) {
|
|
2697
|
+
function helperShellArgv(plan, script, muxArgv) {
|
|
2804
2698
|
switch (plan.kind) {
|
|
2805
|
-
case "ssh": return sshArgv(plan.connection ?? { host: plan.host }, {
|
|
2699
|
+
case "ssh": return sshArgv(plan.connection ?? { host: plan.host }, {
|
|
2700
|
+
command: script,
|
|
2701
|
+
extraArgs: muxArgv
|
|
2702
|
+
});
|
|
2806
2703
|
case "docker": return [
|
|
2807
2704
|
"docker",
|
|
2808
2705
|
"exec",
|
|
@@ -2827,108 +2724,347 @@ function helperShellArgv(plan, script) {
|
|
|
2827
2724
|
}
|
|
2828
2725
|
|
|
2829
2726
|
//#endregion
|
|
2830
|
-
//#region src/remote/
|
|
2831
|
-
/**
|
|
2832
|
-
function
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
|
|
2842
|
-
|
|
2843
|
-
|
|
2844
|
-
|
|
2845
|
-
|
|
2846
|
-
|
|
2847
|
-
|
|
2848
|
-
|
|
2849
|
-
|
|
2850
|
-
|
|
2851
|
-
|
|
2852
|
-
|
|
2853
|
-
|
|
2854
|
-
|
|
2855
|
-
|
|
2856
|
-
...stats,
|
|
2857
|
-
returned
|
|
2858
|
-
} };
|
|
2859
|
-
if (total <= returned) return {
|
|
2860
|
-
text: tail,
|
|
2861
|
-
...updated
|
|
2862
|
-
};
|
|
2727
|
+
//#region src/remote/input.ts
|
|
2728
|
+
/** Local carrier validation precedes any target discovery, connection or spawn. */
|
|
2729
|
+
async function prepareRemoteInput(input) {
|
|
2730
|
+
if (input.mode !== void 0 && input.mode !== "oneshot" && input.mode !== "pty") throw new Error("[E_BAD_MODE] remote: mode must be oneshot or pty");
|
|
2731
|
+
if (input.scriptPath !== void 0) {
|
|
2732
|
+
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");
|
|
2733
|
+
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");
|
|
2734
|
+
}
|
|
2735
|
+
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)");
|
|
2736
|
+
if (input.scriptPath !== void 0) {
|
|
2737
|
+
let script;
|
|
2738
|
+
try {
|
|
2739
|
+
script = await readFile(input.scriptPath, "utf8");
|
|
2740
|
+
} catch (error) {
|
|
2741
|
+
throw new Error(`[E_SCRIPT_READ] remote: cannot read local script ${JSON.stringify(input.scriptPath)} — ${error instanceof Error ? error.message : String(error)}`);
|
|
2742
|
+
}
|
|
2743
|
+
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");
|
|
2744
|
+
return {
|
|
2745
|
+
target: input.target,
|
|
2746
|
+
ssh: input.ssh,
|
|
2747
|
+
cmd: `bash -c ${shQuote(script)}`,
|
|
2748
|
+
stdin: input.stdin,
|
|
2749
|
+
mode: input.mode,
|
|
2750
|
+
timeout: input.timeout
|
|
2751
|
+
};
|
|
2752
|
+
}
|
|
2863
2753
|
return {
|
|
2864
|
-
|
|
2865
|
-
|
|
2866
|
-
|
|
2754
|
+
target: input.target,
|
|
2755
|
+
spawn: input.spawn,
|
|
2756
|
+
ssh: input.ssh,
|
|
2757
|
+
cmd: input.cmd ?? "",
|
|
2758
|
+
stdin: input.stdin,
|
|
2759
|
+
mode: input.mode,
|
|
2760
|
+
timeout: input.timeout
|
|
2867
2761
|
};
|
|
2868
2762
|
}
|
|
2869
|
-
|
|
2870
|
-
|
|
2871
|
-
|
|
2872
|
-
|
|
2873
|
-
|
|
2874
|
-
|
|
2875
|
-
|
|
2876
|
-
|
|
2877
|
-
|
|
2878
|
-
|
|
2879
|
-
|
|
2880
|
-
|
|
2881
|
-
|
|
2882
|
-
|
|
2883
|
-
const text = normalizePtyText(raw);
|
|
2884
|
-
if (text.length > 0) events.onOutput?.(text);
|
|
2885
|
-
};
|
|
2886
|
-
/** 缓冲尾若是任一候选前缀的真前缀则扣留(D/E 共用);flush() 放出残余。 */
|
|
2887
|
-
const holdback = () => {
|
|
2888
|
-
let best = 0;
|
|
2889
|
-
for (const p of prefixes) {
|
|
2890
|
-
const max = Math.min(buf.length, p.length - 1);
|
|
2891
|
-
for (let k = max; k > best; k--) if (buf.endsWith(p.slice(0, k))) {
|
|
2892
|
-
best = k;
|
|
2893
|
-
break;
|
|
2894
|
-
}
|
|
2763
|
+
|
|
2764
|
+
//#endregion
|
|
2765
|
+
//#region src/remote/oneshot.ts
|
|
2766
|
+
async function runOneShot(argv, opts = {}) {
|
|
2767
|
+
const process$1 = startOneShot(argv, opts);
|
|
2768
|
+
const result = await process$1.done;
|
|
2769
|
+
const stdout = process$1.read("stdout", 0), stderr = process$1.read("stderr", 0);
|
|
2770
|
+
return {
|
|
2771
|
+
...result,
|
|
2772
|
+
stdout: stdout.text,
|
|
2773
|
+
stderr: [stderr.text, stderr.diagnostic].filter(Boolean).join("\n"),
|
|
2774
|
+
outputStats: {
|
|
2775
|
+
stdout: stdout.stats,
|
|
2776
|
+
stderr: stderr.stats
|
|
2895
2777
|
}
|
|
2896
|
-
return best;
|
|
2897
2778
|
};
|
|
2898
|
-
|
|
2899
|
-
|
|
2900
|
-
|
|
2901
|
-
|
|
2902
|
-
|
|
2903
|
-
|
|
2904
|
-
|
|
2905
|
-
|
|
2906
|
-
|
|
2907
|
-
|
|
2908
|
-
|
|
2909
|
-
|
|
2910
|
-
|
|
2911
|
-
|
|
2912
|
-
|
|
2913
|
-
|
|
2914
|
-
|
|
2915
|
-
|
|
2916
|
-
|
|
2917
|
-
|
|
2918
|
-
|
|
2919
|
-
|
|
2920
|
-
|
|
2921
|
-
|
|
2922
|
-
|
|
2779
|
+
}
|
|
2780
|
+
|
|
2781
|
+
//#endregion
|
|
2782
|
+
//#region src/remote/helper.ts
|
|
2783
|
+
/** Reads helper-bin/MANIFEST.json next to the compiled module (lib/remote in
|
|
2784
|
+
* the npm package). Missing or malformed assets ⇒ null ⇒ shim-only mode;
|
|
2785
|
+
* never throws. */
|
|
2786
|
+
function helperAssets(baseDir) {
|
|
2787
|
+
const dir = baseDir ?? join(dirname(fileURLToPath(import.meta.url)), "helper-bin");
|
|
2788
|
+
try {
|
|
2789
|
+
const manifest = JSON.parse(readFileSync(join(dir, "MANIFEST.json"), "utf8"));
|
|
2790
|
+
if (manifest?.format !== 1 || typeof manifest.ver !== "string" || typeof manifest.protocol !== "number" || !manifest.targets || typeof manifest.targets !== "object") return null;
|
|
2791
|
+
return {
|
|
2792
|
+
dir,
|
|
2793
|
+
manifest
|
|
2794
|
+
};
|
|
2795
|
+
} catch {
|
|
2796
|
+
return null;
|
|
2797
|
+
}
|
|
2798
|
+
}
|
|
2799
|
+
/** `uname -s`/`uname -m` → shipped target triple. Unknown combos ⇒ null (shim). */
|
|
2800
|
+
function archTriple(sys, machine, shipped) {
|
|
2801
|
+
const s = sys.trim().toLowerCase(), m = machine.trim().toLowerCase();
|
|
2802
|
+
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;
|
|
2803
|
+
if (candidates === null) return null;
|
|
2804
|
+
return shipped.has(candidates) ? candidates : null;
|
|
2805
|
+
}
|
|
2806
|
+
function planKey(plan) {
|
|
2807
|
+
switch (plan.kind) {
|
|
2808
|
+
case "ssh": {
|
|
2809
|
+
const c = plan.connection;
|
|
2810
|
+
const host = c?.host ?? plan.host;
|
|
2811
|
+
return `ssh|${c?.user ? c.user + "@" : ""}${host}${c?.port ? ":" + c.port : ""}${c?.proxyJump ? "|" + c.proxyJump : ""}`;
|
|
2812
|
+
}
|
|
2813
|
+
case "docker": return `docker|${plan.container}`;
|
|
2814
|
+
case "incus": return `incus|${plan.container}`;
|
|
2815
|
+
}
|
|
2816
|
+
}
|
|
2817
|
+
const PROBE_RE = /^SUPERD-PROBE (\S+) (\S+)$/m;
|
|
2818
|
+
const READY_RE = /^SUPERD-READY (\S+)$/m;
|
|
2819
|
+
var HelperRegistry = class {
|
|
2820
|
+
states = /* @__PURE__ */ new Map();
|
|
2821
|
+
constructor(assets, exec, probeArgv, uploadArgv) {
|
|
2822
|
+
this.assets = assets;
|
|
2823
|
+
this.exec = exec;
|
|
2824
|
+
this.probeArgv = probeArgv;
|
|
2825
|
+
this.uploadArgv = uploadArgv;
|
|
2826
|
+
}
|
|
2827
|
+
/** Never throws: any bootstrap failure resolves null (sticky) and the caller stays on the shim. */
|
|
2828
|
+
ensure(plan, muxArgv = []) {
|
|
2829
|
+
if (plan.kind !== "ssh" && plan.kind !== "docker" && plan.kind !== "incus") return Promise.resolve(null);
|
|
2830
|
+
const key = planKey(plan);
|
|
2831
|
+
let entry = this.states.get(key);
|
|
2832
|
+
if (entry === void 0) {
|
|
2833
|
+
entry = this.bootstrap(plan, muxArgv).catch(() => null);
|
|
2834
|
+
entry.catch(() => {});
|
|
2835
|
+
this.states.set(key, entry);
|
|
2836
|
+
}
|
|
2837
|
+
return entry;
|
|
2838
|
+
}
|
|
2839
|
+
async bootstrap(plan, muxArgv) {
|
|
2840
|
+
const { manifest } = this.assets;
|
|
2841
|
+
const first = await this.exec(this.probeArgv(plan, this.assets, muxArgv), { timeoutSec: 15 });
|
|
2842
|
+
const ready = this.parseReady(first.stdout, manifest.ver, manifest.protocol);
|
|
2843
|
+
if (ready !== null) return ready;
|
|
2844
|
+
const probe = PROBE_RE.exec(first.stdout);
|
|
2845
|
+
if (probe === null || first.exit !== 90 && first.exit !== 0) return null;
|
|
2846
|
+
const triple = archTriple(probe[1], probe[2], new Set(Object.keys(manifest.targets)));
|
|
2847
|
+
if (triple === null) return null;
|
|
2848
|
+
const target = manifest.targets[triple];
|
|
2849
|
+
const binary = await readFile(join(this.assets.dir, triple, target.file));
|
|
2850
|
+
if ((await this.exec(this.uploadArgv(plan, triple, target.file, this.assets, muxArgv), {
|
|
2851
|
+
stdin: binary,
|
|
2852
|
+
timeoutSec: 60
|
|
2853
|
+
})).exit !== 0) return null;
|
|
2854
|
+
const second = await this.exec(this.probeArgv(plan, this.assets, muxArgv), { timeoutSec: 15 });
|
|
2855
|
+
return this.parseReady(second.stdout, manifest.ver, manifest.protocol);
|
|
2856
|
+
}
|
|
2857
|
+
parseReady(stdout, ver, protocol) {
|
|
2858
|
+
const ready = READY_RE.exec(stdout);
|
|
2859
|
+
const banner = /^SUPERD-RUN proto=(\d+) name=superd-run ver=(\S+) target=(\S+)/m.exec(stdout);
|
|
2860
|
+
if (ready === null || banner === null) return null;
|
|
2861
|
+
if (banner[1] !== String(protocol) || banner[2] !== ver) return null;
|
|
2862
|
+
return {
|
|
2863
|
+
binPath: ready[1],
|
|
2864
|
+
triple: banner[3],
|
|
2865
|
+
ver,
|
|
2866
|
+
protocol
|
|
2867
|
+
};
|
|
2868
|
+
}
|
|
2869
|
+
};
|
|
2870
|
+
|
|
2871
|
+
//#endregion
|
|
2872
|
+
//#region src/remote/ssh-mux.ts
|
|
2873
|
+
/** SSH oneshot 连接复用(plan 2026-11-01 Task E;探针基线 = 发起端 OpenSSH 10.2p1,2026-11-01 实测):
|
|
2874
|
+
* - master 与客户端完全脱钩(fork 后 PPID=init、自有 pgid/session)→ 本地 killpg 取消/超时永不波及
|
|
2875
|
+
* master,后续 oneshot 继续复用;这正好落在 startBinaryProcess 的组杀清理语义之内。
|
|
2876
|
+
* - ControlPersist 到期 master 自行退出并 unlink socket;`ssh -O exit` 主动拆除亦同。
|
|
2877
|
+
* - master 被 SIGKILL 会留下残 socket 文件:ControlMaster=auto 的下一个客户端会自愈重建
|
|
2878
|
+
* (实测 exit 0),失败语义按 ssh 真实回退,不冒充复用。
|
|
2879
|
+
* - 只注入 SSH oneshot 执行链(POSIX wrapper / helper probe·upload·run·killgroup / cleanup /
|
|
2880
|
+
* status 探测);PTY、scp、rsync、relay 与 copy 的 mkdir pre-hop 一律不注入。
|
|
2881
|
+
* - CLI `-o` 覆盖用户 config 同名项是 OpenSSH 语义(last-option-wins),其余身份选项照常读 config。 */
|
|
2882
|
+
const SSH_MUX_CONTROL_PERSIST_SEC = 600;
|
|
2883
|
+
/** Linux sun_path 上限 108;留余量并在超限时诚实退化为不复用(MUX 是优化,不是前提)。 */
|
|
2884
|
+
const MAX_SOCKET_PATH = 104;
|
|
2885
|
+
function identityKey(identity) {
|
|
2886
|
+
const c = identity.connection;
|
|
2887
|
+
const raw = JSON.stringify([
|
|
2888
|
+
identity.target,
|
|
2889
|
+
c.host,
|
|
2890
|
+
c.user ?? null,
|
|
2891
|
+
c.port ?? null,
|
|
2892
|
+
c.key ?? null,
|
|
2893
|
+
c.proxyJump ?? null
|
|
2894
|
+
]);
|
|
2895
|
+
return createHash("sha256").update(raw).digest("hex").slice(0, 16);
|
|
2896
|
+
}
|
|
2897
|
+
/** 每 Agent 一个(driver 私有):tool-owned 0700 socket 目录 + 按连接身份的 socket 名。
|
|
2898
|
+
* 目录名含随机 owner 后缀,跨 Agent 天然不共享;dispose 逐 socket `-O exit` 后整目录删除。 */
|
|
2899
|
+
var SshMux = class {
|
|
2900
|
+
persistSec;
|
|
2901
|
+
rootDir;
|
|
2902
|
+
runner;
|
|
2903
|
+
owner = randomUUID().slice(0, 8);
|
|
2904
|
+
sockets = /* @__PURE__ */ new Map();
|
|
2905
|
+
dir;
|
|
2906
|
+
disposal;
|
|
2907
|
+
constructor(options = {}) {
|
|
2908
|
+
this.persistSec = options.persistSec ?? SSH_MUX_CONTROL_PERSIST_SEC;
|
|
2909
|
+
this.rootDir = options.rootDir ?? tmpdir();
|
|
2910
|
+
this.runner = options.runner ?? runOneShot;
|
|
2911
|
+
}
|
|
2912
|
+
/** 注入 SSH argv 的 MUX 选项(trusted internal argv,非模型输入)。目录懒建(纯 docker/hub
|
|
2913
|
+
* Agent 永不触盘);超限/已 dispose 返回 [] = 诚实退化为普通连接。 */
|
|
2914
|
+
argvFor(identity) {
|
|
2915
|
+
if (this.disposal !== void 0) return [];
|
|
2916
|
+
const socket = join(this.dir ?? (this.dir = join(this.rootDir, `superd-mux-${this.owner}`)), identityKey(identity));
|
|
2917
|
+
if (socket.length + 1 > MAX_SOCKET_PATH) return [];
|
|
2918
|
+
try {
|
|
2919
|
+
mkdirSync(this.dir, {
|
|
2920
|
+
recursive: true,
|
|
2921
|
+
mode: 448
|
|
2922
|
+
});
|
|
2923
|
+
} catch {
|
|
2924
|
+
return [];
|
|
2925
|
+
}
|
|
2926
|
+
this.sockets.set(socket, identity.connection.host);
|
|
2927
|
+
return [
|
|
2928
|
+
"-o",
|
|
2929
|
+
`ControlPath=${socket}`,
|
|
2930
|
+
"-o",
|
|
2931
|
+
"ControlMaster=auto",
|
|
2932
|
+
"-o",
|
|
2933
|
+
`ControlPersist=${this.persistSec}`
|
|
2934
|
+
];
|
|
2935
|
+
}
|
|
2936
|
+
/** Teardown:先 `-O exit` 逐 socket 礼貌拆除 master(不拨号、5s 上限、错误全吞——
|
|
2937
|
+
* master 已死/残 socket 属正常),再删目录。单飞(重复调用同一 Promise)。 */
|
|
2938
|
+
dispose() {
|
|
2939
|
+
if (this.disposal !== void 0) return this.disposal;
|
|
2940
|
+
const sockets = [...this.sockets.entries()];
|
|
2941
|
+
this.sockets.clear();
|
|
2942
|
+
return this.disposal = (async () => {
|
|
2943
|
+
await Promise.allSettled(sockets.map(([socket, host]) => this.runner([
|
|
2944
|
+
"ssh",
|
|
2945
|
+
"-o",
|
|
2946
|
+
`ControlPath=${socket}`,
|
|
2947
|
+
"-O",
|
|
2948
|
+
"exit",
|
|
2949
|
+
"--",
|
|
2950
|
+
host
|
|
2951
|
+
], { timeoutSec: 5 })));
|
|
2952
|
+
if (this.dir !== void 0) try {
|
|
2953
|
+
rmSync(this.dir, {
|
|
2954
|
+
recursive: true,
|
|
2955
|
+
force: true
|
|
2956
|
+
});
|
|
2957
|
+
} catch {}
|
|
2958
|
+
})();
|
|
2959
|
+
}
|
|
2960
|
+
};
|
|
2961
|
+
|
|
2962
|
+
//#endregion
|
|
2963
|
+
//#region src/remote/nonce-framing.ts
|
|
2964
|
+
/** 8 字节随机串的 hex 形(spec §五.2:每次下发前生成)。 */
|
|
2965
|
+
function genNonce(bytes = 8) {
|
|
2966
|
+
return randomBytes(bytes).toString("hex");
|
|
2967
|
+
}
|
|
2968
|
+
/** 命令整行:花括号复合体 + Nonce 收帧尾(spec §五.2 的 printf 逐字落地 + cwd 第三字段)。 */
|
|
2969
|
+
function wrapPtyCommand(cmd, nonce) {
|
|
2970
|
+
return `{\n${cmd}\n} ; __superd_e=$? ; printf '\\033]133;E;${nonce};%s\\007' "$(env -0 | base64 | tr -d '\\n\\r')" ; printf '\\033]133;D;${nonce};%s;%s\\007' "$__superd_e" "$PWD" ; unset __superd_e\n`;
|
|
2971
|
+
}
|
|
2972
|
+
/** init 整行:哑终端(echo/PS1/PS2/TERM)+ 固定 40×120(Non-Goal ③)+ INIT 帧。 */
|
|
2973
|
+
function buildInitCommand(nonce) {
|
|
2974
|
+
return `stty -echo 2>/dev/null ; stty rows 40 cols 120 2>/dev/null ; PS1='' ; PS2='' ; export TERM=dumb ; printf '\\033]133;E;${nonce};%s\\007' "$(env -0 | base64 | tr -d '\\n\\r')" ; printf '\\033]133;D;${nonce};0;%s\\007' "$PWD"\n`;
|
|
2975
|
+
}
|
|
2976
|
+
const ANSI_RE = /\x1b(?:\][^\x07\x1b]*(?:\x07|\x1b\\)?|\[[0-9;?]*[ -/]*[@-~]|[@-Z\\-_])/g;
|
|
2977
|
+
function stripAnsi(s) {
|
|
2978
|
+
return s.replace(ANSI_RE, "");
|
|
2979
|
+
}
|
|
2980
|
+
function normalizePtyText(s) {
|
|
2981
|
+
return stripAnsi(s).replace(/\r\n/g, "\n").replace(/\r/g, "");
|
|
2982
|
+
}
|
|
2983
|
+
/** 0.3.2-e:自有 30K 返回窗口已删(原生 agent runtime 的 spill/输出保护接管模型上下文
|
|
2984
|
+
* 尺寸)。本 helper 只做 retention 披露:ring 留存(4M UTF-16 / 4MiB bytes)未驱逐时
|
|
2985
|
+
* 全量透传(returned=retained,无 banner);驱逐(dropped>0,仅 >4M 输出发生)时打一行
|
|
2986
|
+
* banner 且 truncated=原始总量。text 已是 ring 尾部,不再二次裁剪。 */
|
|
2987
|
+
function disclose(text, stats) {
|
|
2988
|
+
if (stats === void 0) return { text };
|
|
2989
|
+
const returned = stats.retained;
|
|
2990
|
+
const updated = { stats: {
|
|
2991
|
+
...stats,
|
|
2992
|
+
returned
|
|
2993
|
+
} };
|
|
2994
|
+
if (stats.dropped <= 0) return {
|
|
2995
|
+
text,
|
|
2996
|
+
...updated
|
|
2997
|
+
};
|
|
2998
|
+
const unit = stats.unit === "bytes" ? "bytes" : "chars";
|
|
2999
|
+
return {
|
|
3000
|
+
text: `[retention tail: showing last ${stats.retained} of ${stats.total} ${unit}; dropped head ${stats.dropped}, retention limit ${stats.retentionLimit} ${stats.unit === "bytes" ? "bytes" : "UTF-16 code units"}]\n${text}`,
|
|
3001
|
+
truncated: stats.total,
|
|
3002
|
+
...updated
|
|
3003
|
+
};
|
|
3004
|
+
}
|
|
3005
|
+
const FRAME_BODY_RE = /^(\d+);([^\x07\x1b]*)(?:\x07|\x1b\\)/;
|
|
3006
|
+
const ENV_BODY_RE = /^([A-Za-z0-9+/=]*)\x07/;
|
|
3007
|
+
/**
|
|
3008
|
+
* 单 nonce、单帧解析器:一轮 dispatch 一个实例。只认 `\x1b]133;D;<nonce>;`,
|
|
3009
|
+
* 错误 nonce 的静态/伪造 marker 永不匹配,按普通输出流过(再经 ANSI 剥洗)。
|
|
3010
|
+
* 缓冲尾若可能是 marker 前缀则扣留,flush() 时作为输出放出。
|
|
3011
|
+
*/
|
|
3012
|
+
function createNonceFrameParser(nonce, events) {
|
|
3013
|
+
const prefix = `\x1b]133;D;${nonce};`;
|
|
3014
|
+
const prefixE = `\x1b]133;E;${nonce};`;
|
|
3015
|
+
const prefixes = [prefix, prefixE];
|
|
3016
|
+
let buf$1 = "";
|
|
3017
|
+
let done = false;
|
|
3018
|
+
const emit = (raw) => {
|
|
3019
|
+
const text = normalizePtyText(raw);
|
|
3020
|
+
if (text.length > 0) events.onOutput?.(text);
|
|
3021
|
+
};
|
|
3022
|
+
/** 缓冲尾若是任一候选前缀的真前缀则扣留(D/E 共用);flush() 放出残余。 */
|
|
3023
|
+
const holdback = () => {
|
|
3024
|
+
let best = 0;
|
|
3025
|
+
for (const p of prefixes) {
|
|
3026
|
+
const max = Math.min(buf$1.length, p.length - 1);
|
|
3027
|
+
for (let k = max; k > best; k--) if (buf$1.endsWith(p.slice(0, k))) {
|
|
3028
|
+
best = k;
|
|
3029
|
+
break;
|
|
3030
|
+
}
|
|
3031
|
+
}
|
|
3032
|
+
return best;
|
|
3033
|
+
};
|
|
3034
|
+
/** 扫描最早出现的帧:E(env 侧信道)先于 D 下发;D = settle,帧后字节丢弃。 */
|
|
3035
|
+
const consume = () => {
|
|
3036
|
+
if (done) return;
|
|
3037
|
+
let idx = -1;
|
|
3038
|
+
let isEnv = false;
|
|
3039
|
+
for (const p of prefixes) {
|
|
3040
|
+
const i = buf$1.indexOf(p);
|
|
3041
|
+
if (i >= 0 && (idx === -1 || i < idx)) {
|
|
3042
|
+
idx = i;
|
|
3043
|
+
isEnv = p === prefixE;
|
|
3044
|
+
}
|
|
3045
|
+
}
|
|
3046
|
+
if (idx < 0) {
|
|
3047
|
+
const keep = holdback();
|
|
3048
|
+
emit(buf$1.slice(0, buf$1.length - keep));
|
|
3049
|
+
buf$1 = buf$1.slice(buf$1.length - keep);
|
|
3050
|
+
return;
|
|
3051
|
+
}
|
|
3052
|
+
emit(buf$1.slice(0, idx));
|
|
3053
|
+
const rest = buf$1.slice(idx + (isEnv ? prefixE.length : prefix.length));
|
|
3054
|
+
if (isEnv) {
|
|
3055
|
+
const m$1 = ENV_BODY_RE.exec(rest);
|
|
3056
|
+
if (m$1 === null) {
|
|
3057
|
+
buf$1 = buf$1.slice(idx);
|
|
3058
|
+
return;
|
|
2923
3059
|
}
|
|
2924
3060
|
if (m$1[1].length > 0) events.onEnv?.(m$1[1]);
|
|
2925
|
-
buf = rest.slice(m$1[0].length);
|
|
3061
|
+
buf$1 = rest.slice(m$1[0].length);
|
|
2926
3062
|
consume();
|
|
2927
3063
|
return;
|
|
2928
3064
|
}
|
|
2929
3065
|
const m = FRAME_BODY_RE.exec(rest);
|
|
2930
3066
|
if (m === null) {
|
|
2931
|
-
buf = buf.slice(idx);
|
|
3067
|
+
buf$1 = buf$1.slice(idx);
|
|
2932
3068
|
return;
|
|
2933
3069
|
}
|
|
2934
3070
|
events.onFrame({
|
|
@@ -2936,16 +3072,16 @@ function createNonceFrameParser(nonce, events) {
|
|
|
2936
3072
|
cwd: m[2].length > 0 ? m[2] : null
|
|
2937
3073
|
});
|
|
2938
3074
|
done = true;
|
|
2939
|
-
buf = "";
|
|
3075
|
+
buf$1 = "";
|
|
2940
3076
|
};
|
|
2941
3077
|
return {
|
|
2942
3078
|
feed(chunk) {
|
|
2943
|
-
buf += chunk;
|
|
3079
|
+
buf$1 += chunk;
|
|
2944
3080
|
consume();
|
|
2945
3081
|
},
|
|
2946
3082
|
flush() {
|
|
2947
|
-
if (buf.length > 0) events.onOutput?.(buf);
|
|
2948
|
-
buf = "";
|
|
3083
|
+
if (buf$1.length > 0) events.onOutput?.(buf$1);
|
|
3084
|
+
buf$1 = "";
|
|
2949
3085
|
}
|
|
2950
3086
|
};
|
|
2951
3087
|
}
|
|
@@ -2988,6 +3124,67 @@ function diffEnv(prev, next) {
|
|
|
2988
3124
|
};
|
|
2989
3125
|
}
|
|
2990
3126
|
|
|
3127
|
+
//#endregion
|
|
3128
|
+
//#region src/remote/trace.ts
|
|
3129
|
+
/**
|
|
3130
|
+
* R1 定位轮插桩(2026-11-01):`SUPERD_REMOTE_TRACE=<file>` 时输出 JSONL 事件,否则全 no-op。
|
|
3131
|
+
* 动机:rig-only「PTY background + startup stdin」丢失只在 rig 长驻进程复现(bg-read 形状 13/13),
|
|
3132
|
+
* 本地全组合 0 复现——必须在实际 rig 宿主进程内拿到第一手事件流。
|
|
3133
|
+
*
|
|
3134
|
+
* 设计约束:
|
|
3135
|
+
* - 热路径(write/onOutput)零同步 IO:事件先进内存缓冲,异步 flush(settle/dispose/满 200 条)。
|
|
3136
|
+
* - env 未设置时每个入口都是一次函数调用 + 立即 return,不改变交付语义。
|
|
3137
|
+
* - 失败静默(trace 永不破坏工具行为);缓冲上限 5000 条防失控。
|
|
3138
|
+
*/
|
|
3139
|
+
const TRACE_PATH = process.env.SUPERD_REMOTE_TRACE ?? "";
|
|
3140
|
+
const ENABLED = TRACE_PATH !== "";
|
|
3141
|
+
let buf = [];
|
|
3142
|
+
let flushing = false;
|
|
3143
|
+
function serialize(ev, fields) {
|
|
3144
|
+
let s;
|
|
3145
|
+
try {
|
|
3146
|
+
s = JSON.stringify({
|
|
3147
|
+
t: Date.now(),
|
|
3148
|
+
mono: Number(process.hrtime.bigint() / 1000000n),
|
|
3149
|
+
ev,
|
|
3150
|
+
...fields
|
|
3151
|
+
});
|
|
3152
|
+
} catch {
|
|
3153
|
+
s = JSON.stringify({
|
|
3154
|
+
t: Date.now(),
|
|
3155
|
+
ev,
|
|
3156
|
+
err: "unserializable-fields"
|
|
3157
|
+
});
|
|
3158
|
+
}
|
|
3159
|
+
return s ?? `{\"t\":${Date.now()},\"ev\":${JSON.stringify(ev)},\"err\":\"null\"}`;
|
|
3160
|
+
}
|
|
3161
|
+
function trace(ev, fields = {}) {
|
|
3162
|
+
if (!ENABLED) return;
|
|
3163
|
+
buf.push(serialize(ev, fields));
|
|
3164
|
+
if (buf.length > 5e3) buf.splice(0, buf.length - 5e3);
|
|
3165
|
+
if (buf.length >= 200) traceFlush();
|
|
3166
|
+
}
|
|
3167
|
+
async function traceFlush() {
|
|
3168
|
+
if (!ENABLED || flushing || buf.length === 0) return;
|
|
3169
|
+
flushing = true;
|
|
3170
|
+
const out = buf.splice(0).join("\n") + "\n";
|
|
3171
|
+
try {
|
|
3172
|
+
const [{ appendFile }, { dirname: dirname$1 }] = await Promise.all([import("node:fs/promises"), import("node:path")]);
|
|
3173
|
+
const dir = dirname$1(TRACE_PATH);
|
|
3174
|
+
try {
|
|
3175
|
+
await (await import("node:fs/promises")).mkdir(dir, { recursive: true });
|
|
3176
|
+
} catch {}
|
|
3177
|
+
await appendFile(TRACE_PATH, out);
|
|
3178
|
+
} catch {}
|
|
3179
|
+
flushing = false;
|
|
3180
|
+
if (buf.length > 0) traceFlush();
|
|
3181
|
+
}
|
|
3182
|
+
/** 事件里的字节安全截断表示(JSON 转义 + 长度上限)。 */
|
|
3183
|
+
function traceRepr(s, max) {
|
|
3184
|
+
const j = JSON.stringify(s) ?? "null";
|
|
3185
|
+
return j.length > max ? `${j.slice(0, max)}…` : j;
|
|
3186
|
+
}
|
|
3187
|
+
|
|
2991
3188
|
//#endregion
|
|
2992
3189
|
//#region src/remote/pty-session.ts
|
|
2993
3190
|
/** spec §五.2 逐字。 */
|
|
@@ -2999,29 +3196,27 @@ function displayKey(key) {
|
|
|
2999
3196
|
const raw = key.includes("|") ? key.slice(key.indexOf("|") + 1) : key;
|
|
3000
3197
|
return raw.startsWith("p:") ? `pty:${raw.slice(2)}` : raw;
|
|
3001
3198
|
}
|
|
3002
|
-
const LEDGER_MAX = 20;
|
|
3003
|
-
const summaryOf = (cmd) => {
|
|
3004
|
-
const first = cmd.trim().split(/\r?\n/, 1)[0] ?? "";
|
|
3005
|
-
return first.length > 72 ? `${first.slice(0, 71)}…` : first;
|
|
3006
|
-
};
|
|
3007
3199
|
const DEFAULT_INIT_TIMEOUT_SEC = 30;
|
|
3008
3200
|
const INTERRUPT_GRACE_MS = 3e3;
|
|
3201
|
+
/** F2(0.3.2-d):\x03 触发 ISIG 时内核 flush 输入队列——130-marker 必须等 flush 完成
|
|
3202
|
+
* 再入队,否则首字节被吞(实测 'rintf' not found → 会话死)。 */
|
|
3203
|
+
const MARKER_SETTLE_MS = 80;
|
|
3009
3204
|
/** pty 收集内存安全帽(Ruling P15,与 oneshot HARD_CAP 对齐):模型面只见尾窗,保留尾部。 */
|
|
3010
3205
|
const OUTPUT_HARD_CAP = 4e6;
|
|
3011
3206
|
var PtySession = class {
|
|
3012
3207
|
proc = null;
|
|
3013
3208
|
state = "cold";
|
|
3014
3209
|
startPromise = null;
|
|
3015
|
-
queue = Promise.resolve();
|
|
3016
3210
|
idleTimer = null;
|
|
3017
3211
|
onChunk = null;
|
|
3018
3212
|
deathWaiters = [];
|
|
3019
3213
|
diag = "";
|
|
3020
3214
|
inFlight = 0;
|
|
3215
|
+
cmdSeq = 0;
|
|
3021
3216
|
/** P4/P5:当前占用前台的 detached job(background/超时 handoff)。非 null 时新命令拒绝(Q6)。 */
|
|
3022
3217
|
detachedJob = null;
|
|
3023
|
-
/**
|
|
3024
|
-
|
|
3218
|
+
/** §3.2(2026-10-25):当前占用会话的命令——dispatch 同步占位,settle/cleanup 释放;input 的唯一写入对象。 */
|
|
3219
|
+
activeCommand = null;
|
|
3025
3220
|
/** env 侧信道基线(init E 帧);undefined = 侧信道不可用(不报假 delta)。 */
|
|
3026
3221
|
envBaseline;
|
|
3027
3222
|
/** idle 起点(ready / settle 时重置);null = 尚无 idle 时钟(cold 或已 dispose)。 */
|
|
@@ -3052,10 +3247,13 @@ var PtySession = class {
|
|
|
3052
3247
|
get detachedCommandId() {
|
|
3053
3248
|
return this.detachedJob?.commandId ?? null;
|
|
3054
3249
|
}
|
|
3055
|
-
/**
|
|
3250
|
+
/** handoff/card 面(2026-11-01 默认 PTY 轮):当前占用会话的命令与可选 job 句柄;无 = null。 */
|
|
3251
|
+
get active() {
|
|
3252
|
+
return this.activeCommand === null ? null : { ...this.activeCommand };
|
|
3253
|
+
}
|
|
3254
|
+
/** job 通道收养后回填 jobId(timeout handoff 时 registry id ≠ commandId);运行中的命令同步携带(stdin-only 写入回执用)。 */
|
|
3056
3255
|
attachJob(commandId, jobId) {
|
|
3057
|
-
|
|
3058
|
-
if (entry !== void 0) entry.jobId = jobId;
|
|
3256
|
+
if (this.activeCommand !== null && this.activeCommand.commandId === commandId) this.activeCommand.jobId = jobId;
|
|
3059
3257
|
}
|
|
3060
3258
|
/** Workbench 身份(§3):id + 可选 label + connection + byo 标记。 */
|
|
3061
3259
|
get identity() {
|
|
@@ -3067,10 +3265,6 @@ var PtySession = class {
|
|
|
3067
3265
|
builtOn: this.opts.builtOn === true
|
|
3068
3266
|
};
|
|
3069
3267
|
}
|
|
3070
|
-
/** 账本快照(session card 面,只读)。 */
|
|
3071
|
-
ledgerSnapshot() {
|
|
3072
|
-
return this.ledger;
|
|
3073
|
-
}
|
|
3074
3268
|
/** spawn 创建原文(仅 BYO 会话;v2 起仅存档,不参与复用判定)。 */
|
|
3075
3269
|
get origin() {
|
|
3076
3270
|
return this.opts.origin;
|
|
@@ -3089,36 +3283,67 @@ var PtySession = class {
|
|
|
3089
3283
|
async warm() {
|
|
3090
3284
|
await this.start();
|
|
3091
3285
|
}
|
|
3092
|
-
/**
|
|
3093
|
-
*
|
|
3094
|
-
*
|
|
3095
|
-
*
|
|
3286
|
+
/** 同一会话命令占位制(2026-10-25 interactive-stdin 轮):dispatch 入口**同步**建立
|
|
3287
|
+
* 命令占位(activeCommand + inFlight + 账本条目)——上一条命令仍在运行(同步等待 /
|
|
3288
|
+
* 等输入 / detached job)时新 cmd 立即 E_SESSION_BUSY;原「纯同步并发排 FIFO」语义
|
|
3289
|
+
* 废除(排队会让等输入的命令与补输入动作互相卡死,也给 busy 留下绕行窗口)。
|
|
3290
|
+
* P4/P5:超时不杀命令而是 handoff 成 running job;background 立即回执;P8 显式
|
|
3291
|
+
* interrupt 保留在 job.interrupt;死亡/冷态先重启(reconnect 语义不变)。 */
|
|
3096
3292
|
dispatch(cmd, dOpts) {
|
|
3293
|
+
if (this.state === "disposed") return Promise.reject(/* @__PURE__ */ new Error(`[E_SESSION_DISPOSED] remote pty session '${this.opts.id}' was disposed before dispatch`));
|
|
3294
|
+
if (this.activeCommand !== null) {
|
|
3295
|
+
const handle = this.activeCommand.jobId ?? this.activeCommand.commandId;
|
|
3296
|
+
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`));
|
|
3297
|
+
}
|
|
3298
|
+
const commandId = dOpts.commandId;
|
|
3299
|
+
this.activeCommand = {
|
|
3300
|
+
commandId,
|
|
3301
|
+
...dOpts.jobId !== void 0 ? { jobId: dOpts.jobId } : {}
|
|
3302
|
+
};
|
|
3303
|
+
this.inFlight++;
|
|
3304
|
+
this.cmdSeq++;
|
|
3097
3305
|
const attempt = async () => {
|
|
3098
3306
|
const started = Date.now();
|
|
3099
3307
|
const reconnected = this.state === "dead";
|
|
3100
|
-
|
|
3101
|
-
|
|
3102
|
-
|
|
3103
|
-
|
|
3104
|
-
|
|
3105
|
-
commandId,
|
|
3106
|
-
summary: summaryOf(cmd),
|
|
3107
|
-
exit: null,
|
|
3108
|
-
durationMs: 0,
|
|
3109
|
-
running: true
|
|
3308
|
+
/** 占位撤销:命令从未真正启动(建连失败 / 建连中被 dispose)——释放 busy 与计数,
|
|
3309
|
+
* 撤掉账本条目(与旧时序等价:条目原本在 start 成功之后才建立)。 */
|
|
3310
|
+
const abandon = () => {
|
|
3311
|
+
this.activeCommand = null;
|
|
3312
|
+
this.inFlight--;
|
|
3110
3313
|
};
|
|
3111
|
-
this.
|
|
3112
|
-
|
|
3113
|
-
|
|
3314
|
+
if (this.state !== "ready") try {
|
|
3315
|
+
await this.start();
|
|
3316
|
+
} catch (error) {
|
|
3317
|
+
abandon();
|
|
3318
|
+
throw error;
|
|
3319
|
+
}
|
|
3320
|
+
if (this.state === "disposed") {
|
|
3321
|
+
abandon();
|
|
3322
|
+
throw new Error(`[E_SESSION_DISPOSED] remote pty session '${this.opts.id}' was disposed before dispatch`);
|
|
3323
|
+
}
|
|
3114
3324
|
return await new Promise((resolve$1) => {
|
|
3115
3325
|
const nonce = genNonce();
|
|
3116
3326
|
const ring = new Utf16TailRing(OUTPUT_HARD_CAP);
|
|
3327
|
+
let traceChunkSeq = 0;
|
|
3328
|
+
trace("disp", {
|
|
3329
|
+
s: this.opts.id,
|
|
3330
|
+
key: this.opts.key,
|
|
3331
|
+
commandId,
|
|
3332
|
+
bg: dOpts.background === true,
|
|
3333
|
+
stdinGiven: dOpts.stdin !== void 0,
|
|
3334
|
+
cmdLen: cmd.length,
|
|
3335
|
+
stdinLen: dOpts.stdin?.length ?? 0,
|
|
3336
|
+
cmdRepr: traceRepr(cmd, 140),
|
|
3337
|
+
stdinRepr: traceRepr(dOpts.stdin ?? "", 60),
|
|
3338
|
+
state: this.state,
|
|
3339
|
+
priorCmds: this.cmdSeq
|
|
3340
|
+
});
|
|
3117
3341
|
let interrupted = false;
|
|
3118
3342
|
let settled = false;
|
|
3119
3343
|
let jobSettled = false;
|
|
3120
3344
|
let interruptTimer;
|
|
3121
3345
|
let killTimer;
|
|
3346
|
+
let markerTimer;
|
|
3122
3347
|
let jobDone = null;
|
|
3123
3348
|
let envNext;
|
|
3124
3349
|
const detach = () => {
|
|
@@ -3135,6 +3360,13 @@ var PtySession = class {
|
|
|
3135
3360
|
};
|
|
3136
3361
|
this.detachedJob = job;
|
|
3137
3362
|
settled = true;
|
|
3363
|
+
trace("detach", {
|
|
3364
|
+
s: this.opts.id,
|
|
3365
|
+
commandId,
|
|
3366
|
+
outLen: ring.text.length,
|
|
3367
|
+
tail: traceRepr(ring.text, 220)
|
|
3368
|
+
});
|
|
3369
|
+
traceFlush();
|
|
3138
3370
|
resolve$1({
|
|
3139
3371
|
output: ring.text,
|
|
3140
3372
|
outputStats: { stdout: ring.stats() },
|
|
@@ -3152,13 +3384,25 @@ var PtySession = class {
|
|
|
3152
3384
|
const cleanup = () => {
|
|
3153
3385
|
this.onChunk = null;
|
|
3154
3386
|
this.detachedJob = null;
|
|
3387
|
+
this.activeCommand = null;
|
|
3155
3388
|
this.deathWaiters = this.deathWaiters.filter((w) => w !== deathWaiter);
|
|
3156
3389
|
if (interruptTimer !== void 0) clearTimeout(interruptTimer);
|
|
3157
3390
|
if (killTimer !== void 0) clearTimeout(killTimer);
|
|
3391
|
+
if (markerTimer !== void 0) clearTimeout(markerTimer);
|
|
3158
3392
|
};
|
|
3159
3393
|
const settle = (r) => {
|
|
3160
3394
|
if (jobSettled) return;
|
|
3161
3395
|
jobSettled = true;
|
|
3396
|
+
trace("settle", {
|
|
3397
|
+
s: this.opts.id,
|
|
3398
|
+
commandId,
|
|
3399
|
+
exit: r.exit,
|
|
3400
|
+
timedOut: r.timedOut,
|
|
3401
|
+
cwd: r.cwd,
|
|
3402
|
+
outLen: r.output.length,
|
|
3403
|
+
tail: traceRepr(r.output, 260)
|
|
3404
|
+
});
|
|
3405
|
+
traceFlush();
|
|
3162
3406
|
cleanup();
|
|
3163
3407
|
this.inFlight--;
|
|
3164
3408
|
this.idleSince = Date.now();
|
|
@@ -3166,9 +3410,6 @@ var PtySession = class {
|
|
|
3166
3410
|
if (r.cwd !== null) this.lastCwdSeen = r.cwd;
|
|
3167
3411
|
const envDelta = envNext !== void 0 ? diffEnv(this.envBaseline, envNext) : void 0;
|
|
3168
3412
|
if (envNext !== void 0) this.envBaseline = envNext;
|
|
3169
|
-
entry.exit = r.exit;
|
|
3170
|
-
entry.durationMs = Date.now() - started;
|
|
3171
|
-
entry.running = false;
|
|
3172
3413
|
const full = {
|
|
3173
3414
|
...r,
|
|
3174
3415
|
outputStats: { stdout: ring.stats() },
|
|
@@ -3194,6 +3435,13 @@ var PtySession = class {
|
|
|
3194
3435
|
const parser = createNonceFrameParser(nonce, {
|
|
3195
3436
|
onOutput: (t) => {
|
|
3196
3437
|
ring.push(t);
|
|
3438
|
+
if (traceChunkSeq < 60) trace("out", {
|
|
3439
|
+
s: this.opts.id,
|
|
3440
|
+
commandId,
|
|
3441
|
+
n: traceChunkSeq++,
|
|
3442
|
+
len: t.length,
|
|
3443
|
+
text: traceRepr(t, 180)
|
|
3444
|
+
});
|
|
3197
3445
|
},
|
|
3198
3446
|
onEnv: (b64) => {
|
|
3199
3447
|
const m = decodeEnvSnapshot(b64);
|
|
@@ -3209,7 +3457,9 @@ var PtySession = class {
|
|
|
3209
3457
|
const twoStage = () => {
|
|
3210
3458
|
interrupted = true;
|
|
3211
3459
|
this.write("");
|
|
3212
|
-
|
|
3460
|
+
markerTimer = setTimeout(() => {
|
|
3461
|
+
this.write(`printf '\\033]133;D;${nonce};130;%s\\007' "$PWD"\n`);
|
|
3462
|
+
}, MARKER_SETTLE_MS);
|
|
3213
3463
|
killTimer = setTimeout(() => {
|
|
3214
3464
|
this.killTree();
|
|
3215
3465
|
this.markDead();
|
|
@@ -3222,8 +3472,41 @@ var PtySession = class {
|
|
|
3222
3472
|
}, INTERRUPT_GRACE_MS);
|
|
3223
3473
|
};
|
|
3224
3474
|
this.onChunk = (c) => parser.feed(c);
|
|
3225
|
-
|
|
3226
|
-
|
|
3475
|
+
const startup = dOpts.stdin === void 0 ? wrapPtyCommand(cmd, nonce) : wrapPtyCommand(cmd, nonce) + dOpts.stdin;
|
|
3476
|
+
const wStdin = this.proc?.stdin;
|
|
3477
|
+
if (wStdin?.writable === true) {
|
|
3478
|
+
const wl0 = wStdin.writableLength;
|
|
3479
|
+
let wret;
|
|
3480
|
+
try {
|
|
3481
|
+
wret = wStdin.write(startup);
|
|
3482
|
+
} catch (error) {
|
|
3483
|
+
trace("werr", {
|
|
3484
|
+
s: this.opts.id,
|
|
3485
|
+
commandId,
|
|
3486
|
+
msg: error instanceof Error ? error.message : String(error)
|
|
3487
|
+
});
|
|
3488
|
+
}
|
|
3489
|
+
if (wret !== void 0) {
|
|
3490
|
+
trace("w1", {
|
|
3491
|
+
s: this.opts.id,
|
|
3492
|
+
commandId,
|
|
3493
|
+
bytes: Buffer$1.byteLength(startup),
|
|
3494
|
+
ret: wret,
|
|
3495
|
+
wl0,
|
|
3496
|
+
wl1: wStdin.writableLength,
|
|
3497
|
+
payloadTail: traceRepr(startup.slice(-90), 120)
|
|
3498
|
+
});
|
|
3499
|
+
if (wret === false) wStdin.once("drain", () => {
|
|
3500
|
+
trace("drain", {
|
|
3501
|
+
s: this.opts.id,
|
|
3502
|
+
commandId
|
|
3503
|
+
});
|
|
3504
|
+
});
|
|
3505
|
+
}
|
|
3506
|
+
} else trace("wskip", {
|
|
3507
|
+
s: this.opts.id,
|
|
3508
|
+
commandId
|
|
3509
|
+
});
|
|
3227
3510
|
this.deathWaiters.push(deathWaiter);
|
|
3228
3511
|
if (dOpts.background === true) {
|
|
3229
3512
|
detach();
|
|
@@ -3232,9 +3515,39 @@ var PtySession = class {
|
|
|
3232
3515
|
interruptTimer = setTimeout(() => detach(), dOpts.timeoutSec === void 0 ? 12e4 : dOpts.timeoutSec * 1e3);
|
|
3233
3516
|
});
|
|
3234
3517
|
};
|
|
3235
|
-
|
|
3236
|
-
|
|
3237
|
-
|
|
3518
|
+
return attempt();
|
|
3519
|
+
}
|
|
3520
|
+
/** §3.1(2026-10-25)interactive-stdin:向当前运行中的命令追加 stdin——只写既有通道
|
|
3521
|
+
* 与身份,不新建命令、账本条目或输出游标;不重连、不重放(dead/disposed 明确拒绝)。
|
|
3522
|
+
* 校验与写入全同步:多次 input 按调用顺序整段写入不交错;写入失败如实抛错(私有
|
|
3523
|
+
* write() 的静默忽略不适用于回执语义)。stdin:"" 是零字节写入,不是 EOF。 */
|
|
3524
|
+
input(options) {
|
|
3525
|
+
const fail = (code, hint) => Promise.reject(/* @__PURE__ */ new Error(`${code} remote: ${hint}`));
|
|
3526
|
+
if (this.state === "disposed") return fail("[E_SESSION_DISPOSED]", `pty session '${displayKey(this.opts.key)}' was disposed — input never reaches a disposed session`);
|
|
3527
|
+
if (this.state === "dead") return fail("[E_SESSION_NOT_RUNNING]", `pty session '${displayKey(this.opts.key)}' died — input never reconnects or replays; start a new command (exec reconnects a fresh shell) and re-send only what is still needed`);
|
|
3528
|
+
if (this.state === "starting") return fail("[E_SESSION_NOT_READY]", `pty session '${displayKey(this.opts.key)}' is still initializing — wait for the exec receipt, then input`);
|
|
3529
|
+
const active = this.activeCommand;
|
|
3530
|
+
if (active === null) return fail("[E_SESSION_NOT_RUNNING]", `pty session '${displayKey(this.opts.key)}' has no running command (state ${this.state}) — input writes into a command started by exec; it is never an implicit shell command`);
|
|
3531
|
+
if (options.commandId !== void 0 && options.commandId !== active.commandId) return fail("[E_COMMAND_MISMATCH]", `commandId ${JSON.stringify(options.commandId)} does not match the running command ${active.commandId} — input aimed at an earlier prompt is never written to the next command`);
|
|
3532
|
+
const stdin = this.proc?.stdin;
|
|
3533
|
+
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`);
|
|
3534
|
+
try {
|
|
3535
|
+
stdin.write(options.stdin);
|
|
3536
|
+
trace("in", {
|
|
3537
|
+
s: this.opts.id,
|
|
3538
|
+
commandId: active.commandId,
|
|
3539
|
+
bytes: Buffer$1.byteLength(options.stdin, "utf8"),
|
|
3540
|
+
repr: traceRepr(options.stdin, 60)
|
|
3541
|
+
});
|
|
3542
|
+
} catch (error) {
|
|
3543
|
+
return fail("[E_SESSION_WRITE_FAILED]", `writing stdin to pty session '${displayKey(this.opts.key)}' failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
3544
|
+
}
|
|
3545
|
+
return Promise.resolve({
|
|
3546
|
+
session: displayKey(this.opts.key),
|
|
3547
|
+
commandId: active.commandId,
|
|
3548
|
+
...active.jobId !== void 0 ? { jobId: active.jobId } : {},
|
|
3549
|
+
writtenBytes: Buffer$1.byteLength(options.stdin, "utf8")
|
|
3550
|
+
});
|
|
3238
3551
|
}
|
|
3239
3552
|
/** TTL / row teardown:尽力 exit,1s 后杀组,出池。 */
|
|
3240
3553
|
dispose() {
|
|
@@ -3242,7 +3555,13 @@ var PtySession = class {
|
|
|
3242
3555
|
this.clearIdleTimer();
|
|
3243
3556
|
this.clearDeathTimer();
|
|
3244
3557
|
this.state = "disposed";
|
|
3558
|
+
trace("dispose", {
|
|
3559
|
+
s: this.opts.id,
|
|
3560
|
+
key: this.opts.key
|
|
3561
|
+
});
|
|
3562
|
+
traceFlush();
|
|
3245
3563
|
this.startPromise = null;
|
|
3564
|
+
this.detachedJob?.interrupt();
|
|
3246
3565
|
const proc = this.proc;
|
|
3247
3566
|
const close = this.procClose;
|
|
3248
3567
|
const group = this.reapGroup(proc?.pid, 1e3, close);
|
|
@@ -3253,7 +3572,7 @@ var PtySession = class {
|
|
|
3253
3572
|
]).then(() => {
|
|
3254
3573
|
if (this.proc === proc) this.proc = null;
|
|
3255
3574
|
});
|
|
3256
|
-
this.write("exit\n");
|
|
3575
|
+
if (this.detachedJob === null) this.write("exit\n");
|
|
3257
3576
|
this.opts.onDead?.();
|
|
3258
3577
|
return this.disposal;
|
|
3259
3578
|
}
|
|
@@ -3287,6 +3606,12 @@ var PtySession = class {
|
|
|
3287
3606
|
detached: true
|
|
3288
3607
|
});
|
|
3289
3608
|
this.proc = proc;
|
|
3609
|
+
trace("start", {
|
|
3610
|
+
s: this.opts.id,
|
|
3611
|
+
key: this.opts.key,
|
|
3612
|
+
argv: this.opts.argv,
|
|
3613
|
+
pid: proc.pid
|
|
3614
|
+
});
|
|
3290
3615
|
proc.stdout.setEncoding("utf8");
|
|
3291
3616
|
proc.stdout.on("data", (d) => {
|
|
3292
3617
|
this.onChunk?.(d);
|
|
@@ -3294,6 +3619,9 @@ var PtySession = class {
|
|
|
3294
3619
|
proc.stderr.setEncoding("utf8").on("data", (d) => {
|
|
3295
3620
|
this.diag = (this.diag + d).slice(-2e3);
|
|
3296
3621
|
});
|
|
3622
|
+
proc.stdin.on("error", (err) => {
|
|
3623
|
+
this.diag = (this.diag + `\n[remote: stdin write failed: ${err.message}]`).slice(-2e3);
|
|
3624
|
+
});
|
|
3297
3625
|
this.procClose = new Promise((resolve$1) => {
|
|
3298
3626
|
proc.once("close", () => {
|
|
3299
3627
|
this.markDead();
|
|
@@ -3345,6 +3673,11 @@ var PtySession = class {
|
|
|
3345
3673
|
return;
|
|
3346
3674
|
}
|
|
3347
3675
|
this.state = "dead";
|
|
3676
|
+
trace("dead", {
|
|
3677
|
+
s: this.opts.id,
|
|
3678
|
+
diagTail: this.diag.slice(-500)
|
|
3679
|
+
});
|
|
3680
|
+
traceFlush();
|
|
3348
3681
|
this.startPromise = null;
|
|
3349
3682
|
this.clearIdleTimer();
|
|
3350
3683
|
this.armDeathReaper();
|
|
@@ -3557,42 +3890,20 @@ function parseTargetForm(target) {
|
|
|
3557
3890
|
form: "hub",
|
|
3558
3891
|
machine: segs[0]
|
|
3559
3892
|
};
|
|
3560
|
-
if (segs.length
|
|
3561
|
-
form: "hub-pty",
|
|
3562
|
-
machine: segs[0]
|
|
3563
|
-
};
|
|
3564
|
-
throw new TargetFormatError(`[E_TARGET_FORMAT] remote: ${JSON.stringify(target)} — hub PTY takes no label (hub lane has no session id / parallelism this round)`);
|
|
3893
|
+
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>`);
|
|
3565
3894
|
}
|
|
3566
3895
|
for (const transport of TRANSPORTS) {
|
|
3567
3896
|
if (!target.startsWith(`${transport}:`)) continue;
|
|
3568
3897
|
const rest = target.slice(transport.length + 1);
|
|
3569
3898
|
if (rest.length === 0) throw new TargetFormatError(`[E_TARGET_FORMAT] remote: ${JSON.stringify(target)} — ${transport} takes a name`);
|
|
3570
|
-
|
|
3571
|
-
|
|
3899
|
+
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>`);
|
|
3900
|
+
return {
|
|
3572
3901
|
form: "direct",
|
|
3573
3902
|
transport,
|
|
3574
3903
|
name: rest
|
|
3575
3904
|
};
|
|
3576
|
-
const base = rest.slice(0, ptyAt);
|
|
3577
|
-
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)`);
|
|
3578
|
-
const tail = rest.slice(ptyAt + 4);
|
|
3579
|
-
if (tail.startsWith(":")) {
|
|
3580
|
-
if (tail.length === 1) throw new TargetFormatError(`[E_TARGET_FORMAT] remote: ${JSON.stringify(target)} — empty label after ":pty:"`);
|
|
3581
|
-
return {
|
|
3582
|
-
form: "create-pty",
|
|
3583
|
-
transport,
|
|
3584
|
-
name: base,
|
|
3585
|
-
label: tail.slice(1)
|
|
3586
|
-
};
|
|
3587
|
-
}
|
|
3588
|
-
if (tail.length > 0) throw new TargetFormatError(`[E_TARGET_FORMAT] remote: ${JSON.stringify(target)} — unexpected ${JSON.stringify(tail)} after ":pty"`);
|
|
3589
|
-
return {
|
|
3590
|
-
form: "create-pty",
|
|
3591
|
-
transport,
|
|
3592
|
-
name: base
|
|
3593
|
-
};
|
|
3594
3905
|
}
|
|
3595
|
-
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
|
|
3906
|
+
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)`);
|
|
3596
3907
|
return {
|
|
3597
3908
|
form: "bare",
|
|
3598
3909
|
name: target
|
|
@@ -3736,7 +4047,11 @@ async function probeTarget(plan, opts = {}) {
|
|
|
3736
4047
|
const runner = opts.runner ?? runOneShot;
|
|
3737
4048
|
const started = Date.now();
|
|
3738
4049
|
const r = await runner(plan.kind === "ssh" ? sshArgv(plan.connection ?? { host: plan.host }, {
|
|
3739
|
-
extraArgs: [
|
|
4050
|
+
extraArgs: [
|
|
4051
|
+
...opts.muxArgvFor?.(plan) ?? [],
|
|
4052
|
+
"-o",
|
|
4053
|
+
`ConnectTimeout=${ct}`
|
|
4054
|
+
],
|
|
3740
4055
|
command: "true"
|
|
3741
4056
|
}) : plan.kind === "docker" ? [
|
|
3742
4057
|
"docker",
|
|
@@ -3795,12 +4110,6 @@ function renderProbe(p) {
|
|
|
3795
4110
|
case "container": return `probe: container ${p.status} (just now, on demand)`;
|
|
3796
4111
|
}
|
|
3797
4112
|
}
|
|
3798
|
-
/** session card(§5.5,`pty:<id|label>` 无 cmd):connection/state/cwd/ttl 由调用方拼头行,这里渲染账本。 */
|
|
3799
|
-
function renderLedger(ledger) {
|
|
3800
|
-
if (ledger.length === 0) return "ledger: empty — no command has run in this session yet";
|
|
3801
|
-
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}`);
|
|
3802
|
-
return `ledger (last ${ledger.length}):\n${rows.join("\n")}`;
|
|
3803
|
-
}
|
|
3804
4113
|
|
|
3805
4114
|
//#endregion
|
|
3806
4115
|
//#region src/remote/ssh-config.ts
|
|
@@ -3951,7 +4260,7 @@ async function scanLocal(opts = {}) {
|
|
|
3951
4260
|
};
|
|
3952
4261
|
}
|
|
3953
4262
|
/** 统一池视图的 roster 呈现:机器三段 + BYO ssh 同位(built on)+ pty 会话段(caller-scoped)。 */
|
|
3954
|
-
function renderRoster(scan, entries, sessions, hubTargets) {
|
|
4263
|
+
function renderRoster(scan, entries, sessions, hubTargets, defaults) {
|
|
3955
4264
|
const lines = [];
|
|
3956
4265
|
if (hubTargets !== null) lines.push(`hub machines: ${hubTargets.map((t) => `hub:${t.id} (${t.label}${t.execEnabled ? "" : "; exec disabled"})`).join(", ") || "none"}`);
|
|
3957
4266
|
if (scan.ssh.error !== void 0) lines.push(`ssh hosts: no readable ~/.ssh/config (${scan.ssh.error})`);
|
|
@@ -3973,7 +4282,7 @@ function renderRoster(scan, entries, sessions, hubTargets) {
|
|
|
3973
4282
|
const id = s.identity;
|
|
3974
4283
|
const snap = s.snapshot;
|
|
3975
4284
|
const state = snap.busy ? "busy" : snap.state === "dead" ? "died" : `idle ${Math.round((snap.idleMs ?? 0) / 1e3)}s`;
|
|
3976
|
-
return `pty:${id.id}${id.label !== void 0 ? ` (label ${id.label})` : ""} ${id.connection}${id.byo ? " (byo-pty)" : ""} [${state}]`;
|
|
4285
|
+
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)}` : ""}]`;
|
|
3977
4286
|
}).join(" · ")}` : "pty sessions: none");
|
|
3978
4287
|
return lines.join("\n");
|
|
3979
4288
|
}
|
|
@@ -3983,12 +4292,28 @@ function renderRoster(scan, entries, sessions, hubTargets) {
|
|
|
3983
4292
|
/** R1:idle TTL 15 min——idle 随活动重置,在用会话永不超时(PtySession 双条件)。 */
|
|
3984
4293
|
const REMOTE_PTY_IDLE_TTL_SEC = 900;
|
|
3985
4294
|
/** P3(user 2026-10-19):每 agent 10 个 PTY 会话,跨所有 connection 统一计;仅 error message 披露。 */
|
|
3986
|
-
|
|
4295
|
+
/** 二/三轮裁决(2026-11-01):每 Agent session 全局 30 个 active PTY(默认/handoff/BYO 全计入);
|
|
4296
|
+
* 仅触达上限时的错误回执与 roster 可见性披露,任何模型可见 surface 不预先公布。 */
|
|
4297
|
+
const REMOTE_PTY_SESSION_LIMIT = 30;
|
|
4298
|
+
/** hub 线 one-shot 监督披露(0.3.2-c):对端 = 我们自己的 hub server(bash -lc 于独立进程组,
|
|
4299
|
+
* Node 原生 TERM→2s→KILL + 输出环 + 租约生命周期);superd-run helper 是 dumb-pipe 专用物,
|
|
4300
|
+
* 从不经 hub 上传(non-goal,docs/10_plans/2026-10-25-hub-remote-parity.md §3 O1/O8)。 */
|
|
4301
|
+
const HUB_RUNNER = "hub-lease (peer bash -lc, peer Node supervision)";
|
|
4302
|
+
/** hub PTY 泵资产:pump 与本模块同目录(npm 包与 Super 闭包同构,helper.ts 同款纪律)。
|
|
4303
|
+
* ws 模块路径由 transport 侧(superd-hub 上下文,分叉的 proven 解析链)解析后经
|
|
4304
|
+
* ptyEndpoint 传入——闭包自身子树无可解析的 ws,按名解析不在闭包里做(0.3.2-c 定稿)。 */
|
|
4305
|
+
function hubPtyAssets() {
|
|
4306
|
+
return { pumpPath: join(dirname(fileURLToPath(import.meta.url)), "hub-pty-pump.mjs") };
|
|
4307
|
+
}
|
|
3987
4308
|
/** 账本键与 jobId 同构(同格式 `<kind>-<16hex>`);background 通道预传入 registry id 时两者字面相等。 */
|
|
3988
4309
|
const genCommandId = () => `remote-${randomUUID().replace(/-/g, "").slice(0, 16)}`;
|
|
3989
4310
|
/** 双轨调度器:参数校验 → 目标语法(parseTargetForm)→ 统一 label 池 → oneshot 子进程 / pty 会话池。 */
|
|
3990
4311
|
var RemoteDriver = class {
|
|
3991
4312
|
pool;
|
|
4313
|
+
/** 默认 PTY 槽(spec remote-default-pty §1):sessionKey → 归一 machineKey → pool key。
|
|
4314
|
+
* 会话本体是普通 PtySession(id 创建即认领,roster 全可见);槽登记在 dispose/TTL 后悬空,
|
|
4315
|
+
* 取槽时经 pool.get(disposed 视同出池)惰性清理。 */
|
|
4316
|
+
defaultSlots = /* @__PURE__ */ new Map();
|
|
3992
4317
|
labels = new LabelPool();
|
|
3993
4318
|
hubOwner = randomUUID();
|
|
3994
4319
|
hubProviders = /* @__PURE__ */ new Set();
|
|
@@ -4001,8 +4326,10 @@ var RemoteDriver = class {
|
|
|
4001
4326
|
runnerOf = /* @__PURE__ */ new WeakMap();
|
|
4002
4327
|
disposal;
|
|
4003
4328
|
ptyIdleTtlSec;
|
|
4329
|
+
mux;
|
|
4004
4330
|
constructor(opts = {}) {
|
|
4005
4331
|
this.opts = opts;
|
|
4332
|
+
this.mux = opts.sshMux ?? new SshMux();
|
|
4006
4333
|
this.ptyIdleTtlSec = opts.idleTtlSec ?? REMOTE_PTY_IDLE_TTL_SEC;
|
|
4007
4334
|
this.pool = new PtyPool({ idleTtlSec: this.ptyIdleTtlSec });
|
|
4008
4335
|
}
|
|
@@ -4041,38 +4368,24 @@ var RemoteDriver = class {
|
|
|
4041
4368
|
const hasSsh = params.ssh !== void 0;
|
|
4042
4369
|
if (hasSpawn && hasSsh) throw new Error("[E_PARAMS] remote: spawn and ssh are two different BYO entry points — pass one, not both");
|
|
4043
4370
|
const ptyForm = form.form === "pty-selector" || form.form === "empty" || form.form === "bare";
|
|
4044
|
-
const sshForm = form.form === "ssh-prefix" || form.form === "empty" || form.form === "bare" || form.form === "direct" && form.transport === "ssh"
|
|
4371
|
+
const sshForm = form.form === "ssh-prefix" || form.form === "empty" || form.form === "bare" || form.form === "direct" && form.transport === "ssh";
|
|
4045
4372
|
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");
|
|
4046
|
-
if (hasSsh && !sshForm) throw new Error("[E_PARAMS] remote: ssh registration pairs with ssh-form targets only (ssh, ssh:<label>,
|
|
4373
|
+
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)");
|
|
4047
4374
|
}
|
|
4048
4375
|
async execute(params, form, callCtx) {
|
|
4049
4376
|
const timeoutSec = params.timeout ?? this.opts.execTimeoutSec ?? 120;
|
|
4050
|
-
const cap = this.opts.maxOutputChars ?? 3e4;
|
|
4051
4377
|
const sessionKey = callCtx.sessionKey ?? "no-session";
|
|
4052
4378
|
const hasCmd = params.cmd !== void 0 && params.cmd.length > 0;
|
|
4053
|
-
params.spawn !== void 0 && params.spawn.length;
|
|
4054
|
-
|
|
4379
|
+
const hasSpawn = params.spawn !== void 0 && params.spawn.length > 0;
|
|
4380
|
+
const bareHitsPty = form.form === "bare" && this.labels.resolve(form.name, sessionKey)?.kind === "pty";
|
|
4381
|
+
if ((form.form === "direct" || form.form === "hub" || form.form === "bare" && !bareHitsPty) && params.mode !== "oneshot" && params.ssh === void 0 && !hasSpawn) {
|
|
4382
|
+
const { session } = await this.defaultSlot(form, sessionKey);
|
|
4383
|
+
if (!hasCmd) return this.cardReceipt(session);
|
|
4384
|
+
return await this.dispatchPty(session, params, callCtx, timeoutSec);
|
|
4385
|
+
}
|
|
4386
|
+
if (form.form === "hub") {
|
|
4055
4387
|
const hub = this.requireHub();
|
|
4056
4388
|
this.hubProviders.add(hub);
|
|
4057
|
-
if (form.form === "hub-pty") {
|
|
4058
|
-
if (!hasCmd) throw new Error("[E_PARAMS] remote: hub PTY takes cmd");
|
|
4059
|
-
const result$1 = await hub.pty(form.machine, params.cmd, this.hubOwner, {
|
|
4060
|
-
stdin: params.stdin,
|
|
4061
|
-
timeout: timeoutSec
|
|
4062
|
-
});
|
|
4063
|
-
const out$1 = tailWindow(result$1.stdout, cap, result$1.outputStats?.stdout);
|
|
4064
|
-
return {
|
|
4065
|
-
...result$1,
|
|
4066
|
-
target: `hub:${form.machine}`,
|
|
4067
|
-
connection: `hub:${form.machine}`,
|
|
4068
|
-
stdout: `[hub:${form.machine} pty — no session id, no parallel, no background]\n${out$1.text}`,
|
|
4069
|
-
...out$1.stats ? { outputStats: {
|
|
4070
|
-
...result$1.outputStats,
|
|
4071
|
-
stdout: out$1.stats
|
|
4072
|
-
} } : {},
|
|
4073
|
-
...out$1.truncated === void 0 ? {} : { truncated: out$1.truncated }
|
|
4074
|
-
};
|
|
4075
|
-
}
|
|
4076
4389
|
const process$1 = this.own(await hub.start(form.machine, {
|
|
4077
4390
|
...params,
|
|
4078
4391
|
target: `hub:${form.machine}`,
|
|
@@ -4082,8 +4395,8 @@ var RemoteDriver = class {
|
|
|
4082
4395
|
if (this.disposed) process$1.cancel("owner disposed");
|
|
4083
4396
|
const result = await process$1.done;
|
|
4084
4397
|
const stdout = process$1.read("stdout", 0), stderr = process$1.read("stderr", 0);
|
|
4085
|
-
const out =
|
|
4086
|
-
const err =
|
|
4398
|
+
const out = disclose(stdout.text, stdout.stats);
|
|
4399
|
+
const err = disclose(stderr.text, stderr.stats);
|
|
4087
4400
|
return {
|
|
4088
4401
|
...result,
|
|
4089
4402
|
mode: "oneshot",
|
|
@@ -4091,6 +4404,7 @@ var RemoteDriver = class {
|
|
|
4091
4404
|
connection: `hub:${form.machine}`,
|
|
4092
4405
|
cwd: null,
|
|
4093
4406
|
reconnected: false,
|
|
4407
|
+
runner: HUB_RUNNER,
|
|
4094
4408
|
stdout: (!stdout.stats && stdout.lossy ? "[truncated: peer returned only a tail; exact total/retained/dropped unavailable]\n" : "") + out.text,
|
|
4095
4409
|
stderr: [err.text, stderr.diagnostic].filter(Boolean).join("\n"),
|
|
4096
4410
|
...out.stats ? { outputStats: {
|
|
@@ -4107,7 +4421,7 @@ var RemoteDriver = class {
|
|
|
4107
4421
|
await session.warm();
|
|
4108
4422
|
return this.warmReceipt(session);
|
|
4109
4423
|
}
|
|
4110
|
-
return await this.dispatchPty(session, params, callCtx, timeoutSec
|
|
4424
|
+
return await this.dispatchPty(session, params, callCtx, timeoutSec);
|
|
4111
4425
|
}
|
|
4112
4426
|
if (params.ssh !== void 0) {
|
|
4113
4427
|
const label = form.form === "direct" || form.form === "bare" ? form.name : void 0;
|
|
@@ -4128,28 +4442,28 @@ var RemoteDriver = class {
|
|
|
4128
4442
|
kind: "ssh",
|
|
4129
4443
|
host: normalizeSshConnection(params.ssh).host,
|
|
4130
4444
|
connection: normalizeSshConnection(params.ssh)
|
|
4131
|
-
}, `ssh:${name$1}`, params, timeoutSec
|
|
4445
|
+
}, `ssh:${name$1}`, params, timeoutSec);
|
|
4132
4446
|
}
|
|
4133
4447
|
if (form.form === "pty-selector" && form.idOrLabel === void 0) throw new Error("[E_PARAMS] remote: \"pty\" alone selects nothing — append an id/label (pty:pty-1a2b, pty:build) or pass spawn to create one");
|
|
4134
4448
|
if (form.form === "pty-selector") {
|
|
4135
4449
|
const session = this.resolvePty(form.idOrLabel, sessionKey);
|
|
4136
4450
|
if (!hasCmd) return this.cardReceipt(session);
|
|
4137
|
-
return await this.dispatchPty(session, params, callCtx, timeoutSec
|
|
4451
|
+
return await this.dispatchPty(session, params, callCtx, timeoutSec);
|
|
4138
4452
|
}
|
|
4139
4453
|
if (form.form === "direct") {
|
|
4140
4454
|
const plan$1 = form.transport === "ssh" ? this.sshPlanFor(form.name, sessionKey) : resolveTarget(`${form.transport}:${form.name}`);
|
|
4141
|
-
return await this.oneshot(plan$1, `${form.transport}:${form.name}`, params, timeoutSec
|
|
4455
|
+
return await this.oneshot(plan$1, `${form.transport}:${form.name}`, params, timeoutSec);
|
|
4142
4456
|
}
|
|
4143
4457
|
if (form.form !== "bare") throw new Error("[E_INTERNAL] remote: unreachable target form after routing");
|
|
4144
4458
|
const entry = await this.resolvePoolEntry(form.name, sessionKey);
|
|
4145
4459
|
if (entry.kind === "pty") {
|
|
4146
4460
|
const session = this.resolvePty(form.name, sessionKey);
|
|
4147
4461
|
if (!hasCmd) return this.cardReceipt(session);
|
|
4148
|
-
return await this.dispatchPty(session, params, callCtx, timeoutSec
|
|
4462
|
+
return await this.dispatchPty(session, params, callCtx, timeoutSec);
|
|
4149
4463
|
}
|
|
4150
|
-
if (entry.kind === "hub") return await this.hubOneshot(entry.machine, `hub:${entry.machine}`, params, timeoutSec
|
|
4464
|
+
if (entry.kind === "hub") return await this.hubOneshot(entry.machine, `hub:${entry.machine}`, params, timeoutSec);
|
|
4151
4465
|
const plan = this.planOf(entry);
|
|
4152
|
-
return await this.oneshot(plan, form.name, params, timeoutSec
|
|
4466
|
+
return await this.oneshot(plan, form.name, params, timeoutSec);
|
|
4153
4467
|
}
|
|
4154
4468
|
/** Native-first copy routing (spec: remote-native-shortcut) — unchanged by the workbench round. */
|
|
4155
4469
|
copy(request, signal, callCtx = {}) {
|
|
@@ -4265,7 +4579,7 @@ var RemoteDriver = class {
|
|
|
4265
4579
|
const parsed = parseFileEndpoint(value);
|
|
4266
4580
|
if (!parsed.target) return { path: parsed.path };
|
|
4267
4581
|
const form = parseTargetForm(parsed.target);
|
|
4268
|
-
if (form.form === "hub" || form.form === "
|
|
4582
|
+
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");
|
|
4269
4583
|
const plan = await this.resolveConnectionTarget(parsed.target, callCtx);
|
|
4270
4584
|
this.assertOpen();
|
|
4271
4585
|
combined.throwIfAborted();
|
|
@@ -4307,49 +4621,68 @@ var RemoteDriver = class {
|
|
|
4307
4621
|
const plan = form.transport === "ssh" ? this.sshPlanFor(form.name, callCtx.sessionKey ?? "no-session") : resolveTarget(`${form.transport}:${form.name}`);
|
|
4308
4622
|
this.assertOpen();
|
|
4309
4623
|
signal.throwIfAborted();
|
|
4310
|
-
process$1 = await this.nativeProcess(plan, params, params.timeout, signal);
|
|
4624
|
+
process$1 = await this.nativeProcess(plan, `${form.transport}:${form.name}`, params, params.timeout, signal);
|
|
4311
4625
|
} else if (form.form === "bare") {
|
|
4312
4626
|
const entry = await this.resolvePoolEntry(form.name, callCtx.sessionKey ?? "no-session");
|
|
4313
4627
|
this.assertOpen();
|
|
4314
4628
|
signal.throwIfAborted();
|
|
4315
4629
|
if (entry.kind === "pty" || entry.kind === "hub") throw new Error("remote: background requires an ssh/docker/incus target");
|
|
4316
|
-
process$1 = await this.nativeProcess(this.planOf(entry), params, params.timeout, signal);
|
|
4630
|
+
process$1 = await this.nativeProcess(this.planOf(entry), form.name, params, params.timeout, signal);
|
|
4317
4631
|
} else throw new Error("remote: background oneshot takes a machine target");
|
|
4318
4632
|
this.own(process$1);
|
|
4319
4633
|
if (signal.aborted || this.disposed) process$1.cancel("owner closed during start");
|
|
4320
4634
|
return process$1;
|
|
4321
4635
|
}
|
|
4322
|
-
async nativeProcess(plan, params, timeoutSec, signal = this.fileAbort.signal) {
|
|
4636
|
+
async nativeProcess(plan, display, params, timeoutSec, signal = this.fileAbort.signal) {
|
|
4637
|
+
const muxArgv = this.muxArgvFor(plan, display);
|
|
4323
4638
|
const cleanupTag = randomUUID().slice(0, 8);
|
|
4324
|
-
const helper = await this.ensureHelper(plan);
|
|
4639
|
+
const helper = await this.ensureHelper(plan, muxArgv);
|
|
4325
4640
|
if (helper !== null && !this.disposed) {
|
|
4641
|
+
let killed$1 = false;
|
|
4642
|
+
const kill$1 = () => {
|
|
4643
|
+
if (killed$1) return;
|
|
4644
|
+
killed$1 = true;
|
|
4645
|
+
startOneShot(buildHelperKillgroupArgv(plan, helper.binPath, cleanupTag, muxArgv), { timeoutSec: 15 }).done.catch(() => {});
|
|
4646
|
+
};
|
|
4326
4647
|
const process$2 = startOneShot(buildHelperOneShotArgv(plan, helper.binPath, {
|
|
4327
4648
|
tag: cleanupTag,
|
|
4328
|
-
cmd: params.cmd
|
|
4649
|
+
cmd: params.cmd,
|
|
4650
|
+
...muxArgv !== void 0 ? { muxArgv } : {}
|
|
4329
4651
|
}), {
|
|
4330
4652
|
stdin: params.stdin,
|
|
4331
|
-
timeoutSec
|
|
4653
|
+
timeoutSec,
|
|
4654
|
+
onCancel: kill$1
|
|
4332
4655
|
});
|
|
4333
4656
|
this.runnerOf.set(process$2, `superd-run ${helper.ver} (${helper.triple})`);
|
|
4334
4657
|
process$2.done.then((result) => {
|
|
4335
4658
|
if (!result.cancelled && !result.timedOut) return;
|
|
4336
|
-
|
|
4659
|
+
kill$1();
|
|
4337
4660
|
}).catch(() => {});
|
|
4338
4661
|
const tracked = Promise.resolve(process$2);
|
|
4339
4662
|
this.starting.add(tracked);
|
|
4340
4663
|
tracked.then(() => this.starting.delete(tracked), () => this.starting.delete(tracked));
|
|
4341
4664
|
return tracked;
|
|
4342
4665
|
}
|
|
4343
|
-
|
|
4344
|
-
|
|
4345
|
-
|
|
4666
|
+
let killed = false;
|
|
4667
|
+
const kill = () => {
|
|
4668
|
+
if (killed) return;
|
|
4669
|
+
killed = true;
|
|
4670
|
+
const pidfile = `"\${TMPDIR:-/tmp}/superd-oneshot-${cleanupTag}.pgid"`;
|
|
4671
|
+
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}`;
|
|
4672
|
+
startOneShot((this.opts.oneshotArgvFor ?? buildOneShotArgv)(plan, cleanup, { ...muxArgv !== void 0 ? { muxArgv } : {} }), { timeoutSec: 15 }).done.catch(() => {});
|
|
4673
|
+
};
|
|
4674
|
+
const process$1 = startOneShot((this.opts.oneshotArgvFor ?? buildOneShotArgv)(plan, params.cmd, {
|
|
4675
|
+
cleanupTag,
|
|
4676
|
+
...muxArgv !== void 0 ? { muxArgv } : {}
|
|
4677
|
+
}), {
|
|
4678
|
+
stdin: params.stdin,
|
|
4679
|
+
timeoutSec,
|
|
4680
|
+
onCancel: kill
|
|
4346
4681
|
});
|
|
4347
4682
|
this.runnerOf.set(process$1, "posix-shim");
|
|
4348
4683
|
process$1.done.then((result) => {
|
|
4349
4684
|
if (!result.cancelled && !result.timedOut) return;
|
|
4350
|
-
|
|
4351
|
-
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}`;
|
|
4352
|
-
startOneShot((this.opts.oneshotArgvFor ?? buildOneShotArgv)(plan, cleanup, {}), { timeoutSec: 15 }).done.catch(() => {});
|
|
4685
|
+
kill();
|
|
4353
4686
|
}).catch(() => {});
|
|
4354
4687
|
const work = Promise.resolve(process$1);
|
|
4355
4688
|
this.starting.add(work);
|
|
@@ -4358,14 +4691,14 @@ var RemoteDriver = class {
|
|
|
4358
4691
|
}
|
|
4359
4692
|
/** Helper bootstrap is best-effort and sticky: any failure parks null for this driver
|
|
4360
4693
|
* lifetime and the shim stays the path. First call to a target pays probe (+ upload). */
|
|
4361
|
-
async ensureHelper(plan) {
|
|
4694
|
+
async ensureHelper(plan, muxArgv) {
|
|
4362
4695
|
if (this.helpers === void 0) {
|
|
4363
4696
|
const assets = this.opts.helperAssetsFor?.() ?? helperAssets();
|
|
4364
4697
|
this.helpers = assets === null ? null : new HelperRegistry(assets, (argv, options) => this.helperExec(argv, options), buildHelperProbeArgv, buildHelperUploadArgv);
|
|
4365
4698
|
}
|
|
4366
4699
|
if (this.helpers === null || this.disposed) return null;
|
|
4367
4700
|
try {
|
|
4368
|
-
return await this.helpers.ensure(plan);
|
|
4701
|
+
return await this.helpers.ensure(plan, muxArgv ?? []);
|
|
4369
4702
|
} catch {
|
|
4370
4703
|
return null;
|
|
4371
4704
|
}
|
|
@@ -4478,6 +4811,16 @@ var RemoteDriver = class {
|
|
|
4478
4811
|
host: name$1
|
|
4479
4812
|
};
|
|
4480
4813
|
}
|
|
4814
|
+
/** Task E MUX 身份:仅 SSH plan 注入(docker/incus/hub 返回 undefined)。身份键 = 原始
|
|
4815
|
+
* target 串 + 有效连接字段(host/user/port/key/ProxyJump)——同身份共享 master,跨身份
|
|
4816
|
+
* 不串 socket;目录/寿命/拆除全部由 SshMux 自持。 */
|
|
4817
|
+
muxArgvFor(plan, target) {
|
|
4818
|
+
if (plan.kind !== "ssh") return void 0;
|
|
4819
|
+
return this.mux.argvFor({
|
|
4820
|
+
target,
|
|
4821
|
+
connection: plan.connection ?? { host: plan.host }
|
|
4822
|
+
});
|
|
4823
|
+
}
|
|
4481
4824
|
/** BYO ssh 注册(v2:无 spec 对比——label 占用即拒;注册成功才认领)。返回实际名(label 或 s-id)。 */
|
|
4482
4825
|
registerByoSsh(label, connection, sessionKey) {
|
|
4483
4826
|
this.assertOpen();
|
|
@@ -4507,7 +4850,8 @@ var RemoteDriver = class {
|
|
|
4507
4850
|
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");
|
|
4508
4851
|
if (live.length >= REMOTE_PTY_SESSION_LIMIT) {
|
|
4509
4852
|
const listing = live.map(sessionLine).join(" · ");
|
|
4510
|
-
throw new Error(`[E_SESSION_LIMIT] remote: this Agent already holds ${live.length} live PTY sessions
|
|
4853
|
+
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.
|
|
4854
|
+
live: ${listing}`);
|
|
4511
4855
|
}
|
|
4512
4856
|
const { label } = opts;
|
|
4513
4857
|
if (label !== void 0) validateLabel(label);
|
|
@@ -4556,7 +4900,7 @@ var RemoteDriver = class {
|
|
|
4556
4900
|
isBuiltOn(connection, sessionKey) {
|
|
4557
4901
|
return connection.startsWith("ssh:") && this.labels.resolve(connection.slice(4), sessionKey)?.kind === "ssh-byo";
|
|
4558
4902
|
}
|
|
4559
|
-
/** PTY 创建形唯一实现(execute 与 background 预建共用):spawn BYO
|
|
4903
|
+
/** PTY 创建形唯一实现(execute 与 background 预建共用):spawn BYO(spec 2026-11-01:native `:pty`/hub-pty 创建语法已删,machine 会话经默认槽 + handoff)。 */
|
|
4560
4904
|
createPtyLane(params, form, sessionKey) {
|
|
4561
4905
|
if (params.spawn !== void 0 && params.spawn.length > 0) {
|
|
4562
4906
|
const label = form.form === "pty-selector" && form.idOrLabel !== void 0 ? form.idOrLabel : form.form === "bare" ? form.name : void 0;
|
|
@@ -4569,30 +4913,246 @@ var RemoteDriver = class {
|
|
|
4569
4913
|
origin: params.spawn
|
|
4570
4914
|
}, sessionKey) };
|
|
4571
4915
|
}
|
|
4572
|
-
|
|
4573
|
-
|
|
4574
|
-
|
|
4575
|
-
|
|
4576
|
-
|
|
4577
|
-
|
|
4578
|
-
|
|
4916
|
+
}
|
|
4917
|
+
/** 池条目的槽键归一:同一实体(host/container/machine 身份)跨裸名/前缀/撞名前缀共享同一默认槽。 */
|
|
4918
|
+
machineKeyOfEntry(entry) {
|
|
4919
|
+
switch (entry.kind) {
|
|
4920
|
+
case "ssh": return `ssh:${entry.host}`;
|
|
4921
|
+
case "ssh-byo": return `byo:${entry.name}`;
|
|
4922
|
+
case "docker": return `docker:${entry.container}`;
|
|
4923
|
+
case "incus": return `incus:${entry.container}`;
|
|
4924
|
+
case "hub": return `hub:${entry.machine}`;
|
|
4925
|
+
case "pty": throw new Error("remote: a PTY session is not a machine slot");
|
|
4926
|
+
}
|
|
4927
|
+
}
|
|
4928
|
+
/** 默认槽的 machine 规格:归一 key + 创建 argv + 展示 connection(hub 每次经 ptyEndpoint 重验 pairing,P10)。 */
|
|
4929
|
+
async machineSlotSpec(form, sessionKey) {
|
|
4930
|
+
if (form.form === "hub") {
|
|
4931
|
+
const hub = this.requireHub();
|
|
4932
|
+
this.hubProviders.add(hub);
|
|
4933
|
+
const endpoint = hub.ptyEndpoint(form.machine);
|
|
4934
|
+
return {
|
|
4935
|
+
key: `hub:${form.machine}`,
|
|
4936
|
+
argv: this.opts.hubPtyArgvFor !== void 0 ? this.opts.hubPtyArgvFor(endpoint) : buildHubPtyArgv(endpoint, hubPtyAssets()),
|
|
4937
|
+
connection: `hub:${form.machine}`
|
|
4579
4938
|
};
|
|
4580
|
-
|
|
4939
|
+
}
|
|
4940
|
+
if (form.form === "direct") {
|
|
4941
|
+
if (form.transport === "ssh") {
|
|
4942
|
+
const entry = this.labels.resolve(form.name, sessionKey);
|
|
4943
|
+
if (entry?.kind === "ssh-byo") {
|
|
4944
|
+
const connection = entry.connection;
|
|
4945
|
+
return {
|
|
4946
|
+
key: `byo:${entry.name}`,
|
|
4947
|
+
argv: (this.opts.ptyArgvFor ?? buildPtyArgv)({
|
|
4948
|
+
kind: "ssh",
|
|
4949
|
+
host: connection.host,
|
|
4950
|
+
connection
|
|
4951
|
+
}),
|
|
4952
|
+
connection: `ssh:${entry.name}`,
|
|
4953
|
+
builtOn: true
|
|
4954
|
+
};
|
|
4955
|
+
}
|
|
4956
|
+
return {
|
|
4957
|
+
key: `ssh:${form.name}`,
|
|
4958
|
+
argv: (this.opts.ptyArgvFor ?? buildPtyArgv)({
|
|
4959
|
+
kind: "ssh",
|
|
4960
|
+
host: form.name
|
|
4961
|
+
}),
|
|
4962
|
+
connection: `ssh:${form.name}`
|
|
4963
|
+
};
|
|
4964
|
+
}
|
|
4965
|
+
const plan = resolveTarget(`${form.transport}:${form.name}`);
|
|
4966
|
+
return {
|
|
4967
|
+
key: `${form.transport}:${form.name}`,
|
|
4581
4968
|
argv: (this.opts.ptyArgvFor ?? buildPtyArgv)(plan),
|
|
4582
|
-
connection:
|
|
4583
|
-
|
|
4584
|
-
...form.label !== void 0 ? { label: form.label } : {}
|
|
4585
|
-
}, sessionKey) };
|
|
4969
|
+
connection: `${form.transport}:${form.name}`
|
|
4970
|
+
};
|
|
4586
4971
|
}
|
|
4587
|
-
if (form.form === "
|
|
4588
|
-
const
|
|
4589
|
-
|
|
4972
|
+
if (form.form === "bare") {
|
|
4973
|
+
const entry = await this.resolvePoolEntry(form.name, sessionKey);
|
|
4974
|
+
this.assertOpen();
|
|
4975
|
+
if (entry.kind === "hub") return this.machineSlotSpec({
|
|
4976
|
+
form: "hub",
|
|
4977
|
+
machine: entry.machine
|
|
4978
|
+
}, sessionKey);
|
|
4979
|
+
if (entry.kind === "pty") throw new Error("[E_INTERNAL] remote: bare pty entries route before machine slots");
|
|
4980
|
+
const key = this.machineKeyOfEntry(entry);
|
|
4981
|
+
if (entry.kind === "ssh") return {
|
|
4982
|
+
key,
|
|
4983
|
+
argv: (this.opts.ptyArgvFor ?? buildPtyArgv)({
|
|
4984
|
+
kind: "ssh",
|
|
4985
|
+
host: entry.host
|
|
4986
|
+
}),
|
|
4987
|
+
connection: `ssh:${entry.name}`
|
|
4988
|
+
};
|
|
4989
|
+
if (entry.kind === "ssh-byo") return {
|
|
4990
|
+
key,
|
|
4991
|
+
argv: (this.opts.ptyArgvFor ?? buildPtyArgv)({
|
|
4992
|
+
kind: "ssh",
|
|
4993
|
+
host: entry.connection.host,
|
|
4994
|
+
connection: entry.connection
|
|
4995
|
+
}),
|
|
4996
|
+
connection: `ssh:${entry.name}`,
|
|
4997
|
+
builtOn: true
|
|
4998
|
+
};
|
|
4999
|
+
const plan = entry.kind === "docker" ? resolveTarget(`docker:${entry.container}`) : resolveTarget(`incus:${entry.container}`);
|
|
5000
|
+
return {
|
|
5001
|
+
key,
|
|
4590
5002
|
argv: (this.opts.ptyArgvFor ?? buildPtyArgv)(plan),
|
|
4591
|
-
connection: `${
|
|
5003
|
+
connection: `${entry.kind === "docker" ? "docker" : "incus"}:${entry.name}`
|
|
5004
|
+
};
|
|
5005
|
+
}
|
|
5006
|
+
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)}`);
|
|
5007
|
+
}
|
|
5008
|
+
/** 取/懒建 machine 的默认 PTY 槽:dead/disposed 槽惰性重建;id 创建即认领(roster 全可见 + 防撞)。 */
|
|
5009
|
+
async defaultSlot(form, sessionKey) {
|
|
5010
|
+
const spec = await this.machineSlotSpec(form, sessionKey);
|
|
5011
|
+
this.assertOpen();
|
|
5012
|
+
const byKey = this.defaultSlots.get(sessionKey) ?? /* @__PURE__ */ new Map();
|
|
5013
|
+
this.defaultSlots.set(sessionKey, byKey);
|
|
5014
|
+
const existingKey = byKey.get(spec.key);
|
|
5015
|
+
if (existingKey !== void 0) {
|
|
5016
|
+
const existing = this.pool.get(existingKey);
|
|
5017
|
+
if (existing !== void 0) return {
|
|
5018
|
+
session: existing,
|
|
5019
|
+
machineKey: spec.key,
|
|
5020
|
+
isNew: false
|
|
5021
|
+
};
|
|
5022
|
+
byKey.delete(spec.key);
|
|
5023
|
+
}
|
|
5024
|
+
const session = this.createSession({
|
|
5025
|
+
argv: spec.argv,
|
|
5026
|
+
connection: spec.connection,
|
|
5027
|
+
byo: false,
|
|
5028
|
+
...spec.builtOn === true ? { builtOn: true } : {}
|
|
5029
|
+
}, sessionKey);
|
|
5030
|
+
byKey.set(spec.key, session.key);
|
|
5031
|
+
return {
|
|
5032
|
+
session,
|
|
5033
|
+
machineKey: spec.key,
|
|
5034
|
+
isNew: true
|
|
5035
|
+
};
|
|
5036
|
+
}
|
|
5037
|
+
releaseDefaultSlot(machineKey, sessionKey) {
|
|
5038
|
+
this.defaultSlots.get(sessionKey)?.delete(machineKey);
|
|
5039
|
+
}
|
|
5040
|
+
/** 按池键反查并解绑默认槽(background 提交即 handoff 用);非槽会话无操作。 */
|
|
5041
|
+
releaseSlotByPoolKey(poolKey, sessionKey) {
|
|
5042
|
+
const byKey = this.defaultSlots.get(sessionKey);
|
|
5043
|
+
if (byKey === void 0) return;
|
|
5044
|
+
for (const [machineKey, key] of byKey) if (key === poolKey) {
|
|
5045
|
+
byKey.delete(machineKey);
|
|
5046
|
+
return;
|
|
5047
|
+
}
|
|
5048
|
+
}
|
|
5049
|
+
/** roster/status 面:本 session 的默认槽标注(session id → machineKey;悬空条目过滤)。 */
|
|
5050
|
+
defaultMarks(sessionKey) {
|
|
5051
|
+
const marks = /* @__PURE__ */ new Map();
|
|
5052
|
+
const byKey = this.defaultSlots.get(sessionKey);
|
|
5053
|
+
if (byKey === void 0) return marks;
|
|
5054
|
+
for (const [machineKey, poolKey] of byKey) {
|
|
5055
|
+
const s = this.pool.get(poolKey);
|
|
5056
|
+
if (s !== void 0) marks.set(s.identity.id, machineKey);
|
|
5057
|
+
}
|
|
5058
|
+
return marks;
|
|
5059
|
+
}
|
|
5060
|
+
/** spec §1 handoff(2026-11-01 二/三轮裁决):把 machine 默认 PTY 解绑成可寻址 pty:<id>——同引用移动
|
|
5061
|
+
* (不重启、不复制输出流);fresh(尚无默认槽)= 后台创建空闲 shell 再隔离(默认 PTY 思维模型恒
|
|
5062
|
+
* ready,创建时机是实现细节);busy = 附当前命令与 job 句柄。machine 下次 exec 按需新建默认 PTY。 */
|
|
5063
|
+
async handoff(target, callCtx = {}) {
|
|
5064
|
+
this.assertOpen();
|
|
5065
|
+
const sessionKey = callCtx.sessionKey ?? "no-session";
|
|
5066
|
+
const form = parseTargetForm(target);
|
|
5067
|
+
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`);
|
|
5068
|
+
const spec = await this.machineSlotSpec(form, sessionKey);
|
|
5069
|
+
this.assertOpen();
|
|
5070
|
+
let session;
|
|
5071
|
+
const poolKey = this.defaultSlots.get(sessionKey)?.get(spec.key);
|
|
5072
|
+
if (poolKey !== void 0) session = this.pool.get(poolKey);
|
|
5073
|
+
let created = false;
|
|
5074
|
+
if (session === void 0) {
|
|
5075
|
+
this.releaseDefaultSlot(spec.key, sessionKey);
|
|
5076
|
+
session = this.createSession({
|
|
5077
|
+
argv: spec.argv,
|
|
5078
|
+
connection: spec.connection,
|
|
4592
5079
|
byo: false,
|
|
4593
|
-
...
|
|
4594
|
-
}, sessionKey)
|
|
5080
|
+
...spec.builtOn === true ? { builtOn: true } : {}
|
|
5081
|
+
}, sessionKey);
|
|
5082
|
+
created = true;
|
|
5083
|
+
await session.warm();
|
|
5084
|
+
}
|
|
5085
|
+
this.releaseDefaultSlot(spec.key, sessionKey);
|
|
5086
|
+
const id = session.identity;
|
|
5087
|
+
const snap = session.snapshot;
|
|
5088
|
+
const activeCmd = session.active;
|
|
5089
|
+
const lines = [
|
|
5090
|
+
`handed off pty:${id.id}${id.label !== void 0 ? ` (label ${id.label})` : ""} — ${id.connection}${id.builtOn ? " (built on)" : ""}`,
|
|
5091
|
+
created ? "fresh idle shell created and detached" : snap.state === "dead" ? "session died — its next dispatch reconnects a fresh shell" : "detached",
|
|
5092
|
+
`this machine's next exec opens a fresh default PTY`
|
|
5093
|
+
];
|
|
5094
|
+
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"}`);
|
|
5095
|
+
return {
|
|
5096
|
+
mode: "pty",
|
|
5097
|
+
target: `pty:${id.id}`,
|
|
5098
|
+
session: `pty:${id.id}`,
|
|
5099
|
+
...id.label !== void 0 ? { label: id.label } : {},
|
|
5100
|
+
connection: id.connection,
|
|
5101
|
+
exit: null,
|
|
5102
|
+
cwd: session.lastCwd,
|
|
5103
|
+
timedOut: false,
|
|
5104
|
+
reconnected: false,
|
|
5105
|
+
stdout: lines.join("\n"),
|
|
5106
|
+
durationMs: 0,
|
|
5107
|
+
...created ? { created: true } : {},
|
|
5108
|
+
...activeCmd !== null ? {
|
|
5109
|
+
running: true,
|
|
5110
|
+
commandId: activeCmd.commandId
|
|
5111
|
+
} : {},
|
|
5112
|
+
...this.ptyTtl(session.key)
|
|
5113
|
+
};
|
|
5114
|
+
}
|
|
5115
|
+
/** close 统一入口(spec §1 + 三轮补充裁决):machine target 只关默认槽;pty selector 关该会话;
|
|
5116
|
+
* 不存在 job 定位——取消运行中命令的唯一入口是官方 job_kill。 */
|
|
5117
|
+
async close(target, sessionKey, force = false) {
|
|
5118
|
+
this.assertOpen();
|
|
5119
|
+
const form = parseTargetForm(target);
|
|
5120
|
+
const bareIsPty = form.form === "bare" && this.labels.resolve(form.name, sessionKey)?.kind === "pty";
|
|
5121
|
+
if (form.form === "direct" || form.form === "hub" || form.form === "bare" && !bareIsPty) return this.closeDefault(form, sessionKey, force);
|
|
5122
|
+
if (form.form === "pty-selector") {
|
|
5123
|
+
if (form.idOrLabel === void 0) throw new Error("[E_PARAMS] remote: \"pty\" alone selects nothing — append an id/label (pty:pty-1a2b, pty:build)");
|
|
5124
|
+
return this.closePty(form.idOrLabel, sessionKey, force);
|
|
5125
|
+
}
|
|
5126
|
+
if (form.form === "bare") return this.closePty(form.name, sessionKey, force);
|
|
5127
|
+
throw new Error(`[E_TARGET_FORMAT] remote: close takes a machine target (closes its default PTY) or pty:<id|label> — got ${JSON.stringify(target)}`);
|
|
5128
|
+
}
|
|
5129
|
+
async closeDefault(form, sessionKey, force) {
|
|
5130
|
+
const spec = await this.machineSlotSpec(form, sessionKey);
|
|
5131
|
+
this.assertOpen();
|
|
5132
|
+
const poolKey = this.defaultSlots.get(sessionKey)?.get(spec.key);
|
|
5133
|
+
if (poolKey === void 0) return {
|
|
5134
|
+
session: spec.key,
|
|
5135
|
+
connection: spec.connection,
|
|
5136
|
+
closed: false,
|
|
5137
|
+
reason: "not-found",
|
|
5138
|
+
killed: [],
|
|
5139
|
+
message: `no default PTY on ${spec.key} — a machine's first exec opens one`
|
|
5140
|
+
};
|
|
5141
|
+
const session = this.pool.get(poolKey);
|
|
5142
|
+
if (session === void 0) {
|
|
5143
|
+
this.releaseDefaultSlot(spec.key, sessionKey);
|
|
5144
|
+
return {
|
|
5145
|
+
session: spec.key,
|
|
5146
|
+
connection: spec.connection,
|
|
5147
|
+
closed: false,
|
|
5148
|
+
reason: "already-closed-or-reaped",
|
|
5149
|
+
killed: [],
|
|
5150
|
+
message: `the default PTY on ${spec.key} was already closed or reaped after ${this.ttlMinutes()} min idle`
|
|
5151
|
+
};
|
|
4595
5152
|
}
|
|
5153
|
+
const r = this.closePty(session.identity.id, sessionKey, force);
|
|
5154
|
+
if (r.closed) this.releaseDefaultSlot(spec.key, sessionKey);
|
|
5155
|
+
return r;
|
|
4596
5156
|
}
|
|
4597
5157
|
/** background PTY lane:会话先占位(不拨号)再进官方 jobs registry——首回执必须能寻址;
|
|
4598
5158
|
* registry 拒绝时把新建的会话拆掉(复用形的会话绝不拆)。返回 undefined = 走普通 oneshot job。 */
|
|
@@ -4604,9 +5164,9 @@ var RemoteDriver = class {
|
|
|
4604
5164
|
const form = parseTargetForm(prepared.target);
|
|
4605
5165
|
const target = (id$1) => ({
|
|
4606
5166
|
target: `pty:${id$1}`,
|
|
4607
|
-
cmd:
|
|
4608
|
-
stdin:
|
|
4609
|
-
timeout:
|
|
5167
|
+
cmd: prepared.cmd,
|
|
5168
|
+
stdin: prepared.stdin,
|
|
5169
|
+
timeout: prepared.timeout,
|
|
4610
5170
|
mode: "pty"
|
|
4611
5171
|
});
|
|
4612
5172
|
const lane = this.createPtyLane(prepared, form, sessionKey);
|
|
@@ -4621,6 +5181,20 @@ var RemoteDriver = class {
|
|
|
4621
5181
|
cleanup: () => void 0
|
|
4622
5182
|
};
|
|
4623
5183
|
}
|
|
5184
|
+
if (lane === void 0 && (form.form === "direct" || form.form === "hub" || form.form === "bare" && this.labels.resolve(form.name, sessionKey)?.kind !== "pty") && prepared.mode !== "oneshot") {
|
|
5185
|
+
const slot = await this.defaultSlot(form, sessionKey);
|
|
5186
|
+
this.assertOpen();
|
|
5187
|
+
const id$1 = slot.session.identity;
|
|
5188
|
+
return {
|
|
5189
|
+
input: target(id$1.id),
|
|
5190
|
+
session: `pty:${id$1.id}`,
|
|
5191
|
+
connection: id$1.connection,
|
|
5192
|
+
...id$1.label !== void 0 ? { label: id$1.label } : {},
|
|
5193
|
+
cleanup: () => {
|
|
5194
|
+
if (slot.isNew) slot.session.dispose();
|
|
5195
|
+
}
|
|
5196
|
+
};
|
|
5197
|
+
}
|
|
4624
5198
|
if (lane === void 0) return void 0;
|
|
4625
5199
|
const id = lane.session.identity;
|
|
4626
5200
|
return {
|
|
@@ -4680,15 +5254,53 @@ var RemoteDriver = class {
|
|
|
4680
5254
|
this.pool.get(key)?.attachJob(commandId, jobId);
|
|
4681
5255
|
}
|
|
4682
5256
|
}
|
|
4683
|
-
|
|
5257
|
+
/** spec remote-default-pty §1(2026-11-01):exec stdin-only 写入核心——向已有 live PTY 的当前运行命令
|
|
5258
|
+
* 追加 exact-byte stdin。寻址:pty:<id|label> / 裸 label 命中 pty 项 / machine target(其默认槽的
|
|
5259
|
+
* 当前命令,无槽不懒建、如实报错)。不触发拨号、创建或重连;回执附 connection/label 供工具层渲染。 */
|
|
5260
|
+
async input(target, options, callCtx = {}) {
|
|
5261
|
+
this.assertOpen();
|
|
5262
|
+
const sessionKey = callCtx.sessionKey ?? "no-session";
|
|
5263
|
+
const form = parseTargetForm(target);
|
|
5264
|
+
let session;
|
|
5265
|
+
if (form.form === "pty-selector") {
|
|
5266
|
+
if (form.idOrLabel === void 0) throw new Error("[E_PARAMS] remote: \"pty\" alone selects nothing — append an id/label (pty:pty-1a2b, pty:build)");
|
|
5267
|
+
session = this.resolvePty(form.idOrLabel, sessionKey);
|
|
5268
|
+
} else if (form.form === "bare" && this.labels.resolve(form.name, sessionKey)?.kind === "pty") session = this.resolvePty(form.name, sessionKey);
|
|
5269
|
+
else if (form.form === "direct" || form.form === "hub" || form.form === "bare") {
|
|
5270
|
+
const spec = await this.machineSlotSpec(form, sessionKey);
|
|
5271
|
+
this.assertOpen();
|
|
5272
|
+
const poolKey = this.defaultSlots.get(sessionKey)?.get(spec.key);
|
|
5273
|
+
const slot = poolKey !== void 0 ? this.pool.get(poolKey) : void 0;
|
|
5274
|
+
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`);
|
|
5275
|
+
session = slot;
|
|
5276
|
+
} 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)}`);
|
|
5277
|
+
const receipt = await session.input(options);
|
|
5278
|
+
const id = session.identity;
|
|
5279
|
+
return {
|
|
5280
|
+
...receipt,
|
|
5281
|
+
connection: id.connection,
|
|
5282
|
+
...id.label !== void 0 ? { label: id.label } : {}
|
|
5283
|
+
};
|
|
5284
|
+
}
|
|
5285
|
+
async dispatchPty(session, params, callCtx, timeoutSec) {
|
|
5286
|
+
const hubConn = session.identity.connection;
|
|
5287
|
+
if (hubConn.startsWith("hub:")) {
|
|
5288
|
+
const hub = this.requireHub();
|
|
5289
|
+
this.hubProviders.add(hub);
|
|
5290
|
+
hub.ptyEndpoint(hubConn.slice(4));
|
|
5291
|
+
}
|
|
4684
5292
|
const commandId = callCtx.commandId ?? genCommandId();
|
|
4685
5293
|
const r = await session.dispatch(params.cmd, {
|
|
4686
5294
|
stdin: params.stdin,
|
|
4687
5295
|
timeoutSec,
|
|
4688
5296
|
commandId,
|
|
4689
|
-
...callCtx.ptyBackground === true ? {
|
|
5297
|
+
...callCtx.ptyBackground === true ? {
|
|
5298
|
+
background: true,
|
|
5299
|
+
jobId: commandId
|
|
5300
|
+
} : {}
|
|
4690
5301
|
});
|
|
4691
|
-
|
|
5302
|
+
if (callCtx.ptyBackground === true && r.running === true) this.releaseSlotByPoolKey(session.key, callCtx.sessionKey ?? "no-session");
|
|
5303
|
+
const out = disclose(r.output, r.outputStats?.stdout);
|
|
4692
5304
|
const id = session.identity;
|
|
4693
5305
|
return {
|
|
4694
5306
|
mode: "pty",
|
|
@@ -4751,14 +5363,14 @@ var RemoteDriver = class {
|
|
|
4751
5363
|
cwd: session.lastCwd,
|
|
4752
5364
|
timedOut: false,
|
|
4753
5365
|
reconnected: false,
|
|
4754
|
-
stdout: [head,
|
|
5366
|
+
stdout: [head, activeLine(session)].join("\n"),
|
|
4755
5367
|
durationMs: 0,
|
|
4756
5368
|
...this.ptyTtl(session.key)
|
|
4757
5369
|
};
|
|
4758
5370
|
}
|
|
4759
|
-
async oneshot(plan, display, params, timeoutSec
|
|
5371
|
+
async oneshot(plan, display, params, timeoutSec) {
|
|
4760
5372
|
this.assertOpen();
|
|
4761
|
-
const process$1 = this.own(await this.nativeProcess(plan, params, timeoutSec));
|
|
5373
|
+
const process$1 = this.own(await this.nativeProcess(plan, display, params, timeoutSec));
|
|
4762
5374
|
if (this.disposed) process$1.cancel("owner disposed");
|
|
4763
5375
|
const result = await process$1.done;
|
|
4764
5376
|
const stdout = process$1.read("stdout", 0), stderr = process$1.read("stderr", 0);
|
|
@@ -4771,8 +5383,8 @@ var RemoteDriver = class {
|
|
|
4771
5383
|
stderr: stderr.stats
|
|
4772
5384
|
}
|
|
4773
5385
|
};
|
|
4774
|
-
const out =
|
|
4775
|
-
const err = r.stderr.length > 0 ?
|
|
5386
|
+
const out = disclose(r.stdout, r.outputStats?.stdout);
|
|
5387
|
+
const err = r.stderr.length > 0 ? disclose(r.stderr, r.outputStats?.stderr) : void 0;
|
|
4776
5388
|
return {
|
|
4777
5389
|
mode: "oneshot",
|
|
4778
5390
|
target: display,
|
|
@@ -4791,13 +5403,13 @@ var RemoteDriver = class {
|
|
|
4791
5403
|
...this.runnerOf.get(process$1) === void 0 ? {} : { runner: this.runnerOf.get(process$1) }
|
|
4792
5404
|
};
|
|
4793
5405
|
}
|
|
4794
|
-
async hubOneshot(machine, display, params, timeoutSec
|
|
5406
|
+
async hubOneshot(machine, display, params, timeoutSec) {
|
|
4795
5407
|
const process$1 = this.own(await this.hubOneshotProcess(machine, params));
|
|
4796
5408
|
if (this.disposed) process$1.cancel("owner disposed");
|
|
4797
5409
|
const result = await process$1.done;
|
|
4798
5410
|
const stdout = process$1.read("stdout", 0), stderr = process$1.read("stderr", 0);
|
|
4799
|
-
const out =
|
|
4800
|
-
const err =
|
|
5411
|
+
const out = disclose(stdout.text, stdout.stats);
|
|
5412
|
+
const err = disclose(stderr.text, stderr.stats);
|
|
4801
5413
|
return {
|
|
4802
5414
|
...result,
|
|
4803
5415
|
mode: "oneshot",
|
|
@@ -4805,6 +5417,7 @@ var RemoteDriver = class {
|
|
|
4805
5417
|
connection: display,
|
|
4806
5418
|
cwd: null,
|
|
4807
5419
|
reconnected: false,
|
|
5420
|
+
runner: HUB_RUNNER,
|
|
4808
5421
|
stdout: (!stdout.stats && stdout.lossy ? "[truncated: peer returned only a tail; exact total/retained/dropped unavailable]\n" : "") + out.text,
|
|
4809
5422
|
stderr: [err.text, stderr.diagnostic].filter(Boolean).join("\n"),
|
|
4810
5423
|
...out.stats ? { outputStats: {
|
|
@@ -4843,17 +5456,20 @@ var RemoteDriver = class {
|
|
|
4843
5456
|
return s === void 0 ? null : s;
|
|
4844
5457
|
}).filter((s) => s !== null);
|
|
4845
5458
|
const hub = this.opts.getHub?.();
|
|
4846
|
-
return renderRoster(scan, this.labels.list(callCtx.sessionKey ?? "no-session"), sessions, hub === void 0 ? null : hub.listTargets());
|
|
5459
|
+
return renderRoster(scan, this.labels.list(callCtx.sessionKey ?? "no-session"), sessions, hub === void 0 ? null : hub.listTargets(), this.defaultMarks(callCtx.sessionKey ?? "no-session"));
|
|
4847
5460
|
}
|
|
4848
5461
|
/** status probe(target 无 cmd):机器面 = probe + 该 connection 上的会话;pty selector = session card。 */
|
|
4849
5462
|
async status(target, callCtx = {}) {
|
|
4850
5463
|
this.assertOpen();
|
|
4851
5464
|
const sessionKey = callCtx.sessionKey ?? "no-session";
|
|
4852
5465
|
const form = parseTargetForm(target);
|
|
4853
|
-
if (form.form === "hub"
|
|
5466
|
+
if (form.form === "hub") {
|
|
4854
5467
|
const row = this.requireHub().listTargets().find((t) => t.id === form.machine);
|
|
4855
5468
|
if (!row) throw new Error(`unknown remote machine ${form.machine} — select a remote machine from the Hub roster; local execution is not available`);
|
|
4856
|
-
|
|
5469
|
+
const hubSlotKey = `hub:${form.machine}`;
|
|
5470
|
+
const hubSlotPoolKey = this.defaultSlots.get(sessionKey)?.get(hubSlotKey);
|
|
5471
|
+
const hubSlot = hubSlotPoolKey !== void 0 ? this.pool.get(hubSlotPoolKey) : void 0;
|
|
5472
|
+
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");
|
|
4857
5473
|
}
|
|
4858
5474
|
if (form.form === "pty-selector") {
|
|
4859
5475
|
if (form.idOrLabel === void 0) throw new Error("[E_PARAMS] remote: \"pty\" alone selects nothing — append an id/label (pty:pty-1a2b, pty:build)");
|
|
@@ -4871,15 +5487,23 @@ var RemoteDriver = class {
|
|
|
4871
5487
|
const plan = this.planOf(entry);
|
|
4872
5488
|
const head = `${target} — ${plan.kind === "ssh" ? "ssh host" : `${plan.kind} container`}${entry.kind === "ssh-byo" ? " (built on)" : ""}`;
|
|
4873
5489
|
const runner = this.opts.probeRunner ?? runOneShot;
|
|
4874
|
-
const probe = await probeTarget(plan, {
|
|
4875
|
-
|
|
4876
|
-
|
|
4877
|
-
|
|
5490
|
+
const probe = await probeTarget(plan, {
|
|
5491
|
+
runner: (argv, options) => {
|
|
5492
|
+
this.assertOpen();
|
|
5493
|
+
return runner(argv, options);
|
|
5494
|
+
},
|
|
5495
|
+
muxArgvFor: (p) => this.muxArgvFor(p, target) ?? []
|
|
5496
|
+
});
|
|
4878
5497
|
const prefix = `${sessionKey}|`;
|
|
4879
5498
|
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}`);
|
|
5499
|
+
const slotKey = this.machineKeyOfEntry(entry);
|
|
5500
|
+
const slotPoolKey = this.defaultSlots.get(sessionKey)?.get(slotKey);
|
|
5501
|
+
const slotSession = slotPoolKey !== void 0 ? this.pool.get(slotPoolKey) : void 0;
|
|
5502
|
+
const slotLine = slotSession === void 0 ? `default PTY: none — this machine's first exec opens one` : `default PTY: ${sessionLine(slotSession)}`;
|
|
4880
5503
|
return [
|
|
4881
5504
|
head,
|
|
4882
5505
|
renderProbe(probe),
|
|
5506
|
+
slotLine,
|
|
4883
5507
|
onConnection.length > 0 ? `pty sessions on this connection: ${onConnection.map(sessionLine).join(" · ")}` : "pty sessions on this connection: none"
|
|
4884
5508
|
].join("\n");
|
|
4885
5509
|
}
|
|
@@ -4894,8 +5518,7 @@ var RemoteDriver = class {
|
|
|
4894
5518
|
this.assertOpen();
|
|
4895
5519
|
const form = parseTargetForm(target);
|
|
4896
5520
|
if (form.form === "pty-selector") throw new Error("remote: a PTY session is not a filesystem connection");
|
|
4897
|
-
if (form.form === "hub"
|
|
4898
|
-
if (form.form === "create-pty") throw new Error("remote: a PTY session is not a filesystem connection");
|
|
5521
|
+
if (form.form === "hub") throw new Error("remote: hub endpoints resolve through the Hub file lane, not a CLI connection");
|
|
4899
5522
|
if (form.form === "direct") return form.transport === "ssh" ? this.sshPlanFor(form.name, callCtx.sessionKey ?? "no-session") : resolveTarget(`${form.transport}:${form.name}`);
|
|
4900
5523
|
if (form.form === "ssh-prefix" || form.form === "empty") throw new Error("[E_TARGET_FORMAT] remote: connection targets need a name");
|
|
4901
5524
|
const entry = await this.resolvePoolEntry(form.name, callCtx.sessionKey ?? "no-session");
|
|
@@ -4925,6 +5548,7 @@ var RemoteDriver = class {
|
|
|
4925
5548
|
if (this.disposal) return this.disposal;
|
|
4926
5549
|
this.disposed = true;
|
|
4927
5550
|
this.labels.clear();
|
|
5551
|
+
this.defaultSlots.clear();
|
|
4928
5552
|
this.fileAbort.abort(/* @__PURE__ */ new Error("remote owner disposed"));
|
|
4929
5553
|
for (const process$1 of this.processes) process$1.cancel("remote owner disposed");
|
|
4930
5554
|
return this.disposal = (async () => {
|
|
@@ -4935,12 +5559,18 @@ var RemoteDriver = class {
|
|
|
4935
5559
|
...[...this.hubProviders].map((hub) => hub.releaseOwner(this.hubOwner))
|
|
4936
5560
|
]);
|
|
4937
5561
|
this.hubProviders.clear();
|
|
5562
|
+
await this.mux.dispose();
|
|
4938
5563
|
})();
|
|
4939
5564
|
}
|
|
4940
5565
|
};
|
|
4941
5566
|
/** direct 形态回退到 resolveTarget 可解析的名字(ssh:dev4 → dev4)。 */
|
|
4942
5567
|
function directName(form) {
|
|
4943
|
-
return form.form === "direct" ? form.name :
|
|
5568
|
+
return form.form === "direct" ? form.name : "";
|
|
5569
|
+
}
|
|
5570
|
+
/** session card 的当前命令行(spec 2026-11-01:历史 ledger 已删,只报活动命令)。 */
|
|
5571
|
+
function activeLine(s) {
|
|
5572
|
+
const a = s.active;
|
|
5573
|
+
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`;
|
|
4944
5574
|
}
|
|
4945
5575
|
/** roster/status 一行的会话描述:`p-ab12 (label build) ssh:dev4 idle 12s`。 */
|
|
4946
5576
|
function sessionLine(s) {
|
|
@@ -4962,16 +5592,21 @@ const ptyOutputSources = (holder) => [{
|
|
|
4962
5592
|
lossy: false
|
|
4963
5593
|
}
|
|
4964
5594
|
}];
|
|
4965
|
-
|
|
4966
|
-
|
|
4967
|
-
|
|
4968
|
-
|
|
5595
|
+
/** 0.3.2-d CF:timedOut=true 且 aborted=false = 会话侧主动收束(close force / 行回收走的
|
|
5596
|
+
* dispose→interrupt 两段路)——同为 killed;exit 到手用 exit,只剩会话死亡时写 session closed。 */
|
|
5597
|
+
const ptyOutcome = (aborted, result) => {
|
|
5598
|
+
const interrupted = aborted || result.timedOut === true;
|
|
5599
|
+
return {
|
|
5600
|
+
status: interrupted ? "killed" : result.exit === null ? "failed" : "completed",
|
|
5601
|
+
detail: interrupted ? `interrupted: ${result.exit === null ? aborted ? "session died" : "session closed" : `exit ${result.exit}`}` : result.exit === null ? "session died" : `exit code: ${result.exit}`
|
|
5602
|
+
};
|
|
5603
|
+
};
|
|
4969
5604
|
/** No preparation or discovery before the official registry's admission. */
|
|
4970
5605
|
function startRemoteJob(registry, input, exec, driver) {
|
|
4971
5606
|
if (!exec.agent) throw new Error("remote: background jobs require a live Agent owner");
|
|
4972
5607
|
const form = parseTargetForm(input.target);
|
|
4973
|
-
|
|
4974
|
-
|
|
5608
|
+
const machineForm = form.form === "direct" || form.form === "bare" || form.form === "hub";
|
|
5609
|
+
if (form.form === "pty-selector" || input.spawn !== void 0 || input.mode === "pty" || machineForm && input.mode !== "oneshot") {
|
|
4975
5610
|
const holder = {};
|
|
4976
5611
|
return registry.start({
|
|
4977
5612
|
kind: "remote",
|
|
@@ -5097,116 +5732,123 @@ function adoptRunningPtyJob(registry, exec, label, job) {
|
|
|
5097
5732
|
|
|
5098
5733
|
//#endregion
|
|
5099
5734
|
//#region src/remote/tool.ts
|
|
5100
|
-
const NO_SESSION_KEY = "remote:no-session";
|
|
5735
|
+
const NO_SESSION_KEY$1 = "remote:no-session";
|
|
5101
5736
|
/**
|
|
5102
|
-
* The model-visible surface of the `remote` tool (
|
|
5103
|
-
*
|
|
5737
|
+
* The model-visible surface of the `remote` tool (0.3.2-d user-ruled restructure:
|
|
5738
|
+
* Actions / Targets / PTY / Oneshot / Examples — the PTY block is the user's verbatim
|
|
5739
|
+
* final draft; Examples receipts match renderValue byte-for-byte).
|
|
5740
|
+
* ONE template literal — no backticks, no "${".
|
|
5741
|
+
*
|
|
5742
|
+
* 语义目标(surface 就是产品):动作是唯一的派发维度;PTY 四词定位——stateful oneshot /
|
|
5743
|
+
* interact-able stdin / managed output / persistent env——回执形状与 oneshot 相同、只增状态,
|
|
5744
|
+
* 运行中的命令可分步观察后再补输(input 动作);多步与交互任务默认进 PTY。
|
|
5104
5745
|
*/
|
|
5105
|
-
const REMOTE_TOOL_DESCRIPTION = `Remote
|
|
5106
|
-
{} → roster (local scan; refreshes the name pool: ssh aliases, BYO identities, docker/incus containers, hub machines, live pty sessions)
|
|
5107
|
-
{target} → status probe: machine reachability + its pty sessions, or a session card (connection, cwd, ledger of the last 20 commands) for pty:<id|label>
|
|
5108
|
-
{target, cmd} → run cmd — oneshot (stateless bash -lc) on any machine target
|
|
5109
|
-
{target, scriptPath:"/abs"} → LOCAL script over stdin to a fixed bash -s (oneshot only; no cmd/stdin/spawn mix)
|
|
5110
|
-
{target:"ssh:dev4:pty[:label]", cmd?} → create a PTY session on that machine and run cmd in it; no cmd = warm creation returning the id. Tab semantics: every creation call opens a NEW session — parallel is first-class (max 10 per Agent, ssh:dev4 and docker:corti alike).
|
|
5111
|
-
{target:"pty:<id|label>", cmd} → run cmd in a live session (cwd/env survive; the receipt carries session, connection, commandId, cwd, env delta, idle-left)
|
|
5112
|
-
{spawn, target?:"pty[:<label>]"|"<label>"|"", cmd?} → create a BYO pty from a spawn command (e.g. docker exec -it img bash; nested hops are dumb pipes). spawn present IS the BYO entry point; no target = auto id.
|
|
5113
|
-
{ssh:{...}, target?:"ssh[:<label>]"|"<label>"|"", cmd?} → register a BYO ssh identity (ssh present IS the entry point); the label then works everywhere ssh: aliases do (roster marks it "built on"); ssh:<label>:pty opens a session on it. key is an existing origin file, never inline material; nothing is persisted; a new Agent registers again.
|
|
5114
|
-
{action:"close", target:"pty:<id|label>", force?} → close a session. Idempotent (unknown id → closed:false). Default rejects while a job is running and names it (job_kill it first, or wait); force:true closes the tab and kills the group in one step.
|
|
5115
|
-
{action:"copy"|"rsync", src, dst, argv?} → file transfer (see the action/src/dst fields)
|
|
5116
|
-
Bare names address ONE unified pool (machines + identities + sessions — roster lists it; a machine losing its bare name to an earlier claim is shown prefixed docker-foo / incus-foo). Prefixes ssh: / docker: / incus: / hub: force a channel. Creation always claims a fresh name: a taken label answers [E_LABEL_IN_USE] — address it without creation params, or pick another (specs are never compared). PTY sessions idle out after 15 min (running commands exempt; reads do not reset the clock).
|
|
5746
|
+
const REMOTE_TOOL_DESCRIPTION = `Remote shell (SSH, Docker, Incus, Hub): 1 default interactive PTY per machine (retains cwd/env). \`mode:"oneshot"\` = isolated, stateless.
|
|
5117
5747
|
|
|
5118
|
-
|
|
5748
|
+
Actions:
|
|
5749
|
+
- 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.
|
|
5750
|
+
- handoff {target:<machine>}: detach machine's default PTY -> \`pty:<id>\`. Next exec spawns fresh default.
|
|
5751
|
+
- add {ssh:{...} | spawn:"<cmd>", target?}: register BYO identity/PTY. Returns label. Taken label answers \`[E_LABEL_IN_USE]\`.
|
|
5752
|
+
- status {target?}: roster pool (no target) or machine probe / session card.
|
|
5753
|
+
- close {target, force?:boolean}: close PTY shell. Machine target closes only default PTY. Running command blocks close unless \`force:true\`.
|
|
5119
5754
|
|
|
5120
|
-
|
|
5755
|
+
Targets & Semantics:
|
|
5756
|
+
- 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).
|
|
5757
|
+
- 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\`).
|
|
5758
|
+
- Oneshot: Separate stdout/stderr, verbatim exit code, stateless. \`stdin\` = startup only. Cached \`superd-run\` runner preferred over POSIX shim.
|
|
5759
|
+
- 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.
|
|
5760
|
+
- 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).
|
|
5121
5761
|
|
|
5122
|
-
|
|
5762
|
+
Examples:
|
|
5763
|
+
remote {"action":"exec","target":"ssh:dev4","cmd":"cd /srv/app && pwd"}
|
|
5764
|
+
remote {"action":"exec","target":"ssh:dev4","cmd":"make test","run_in_background":true}
|
|
5765
|
+
remote {"action":"handoff","target":"ssh:dev4"}
|
|
5766
|
+
remote {"action":"exec","target":"pty:pty-irsq","stdin":"yes\\n","commandId":"remote-47676dff2c6443e8"}
|
|
5767
|
+
remote {"action":"exec","target":"ssh:dev4","mode":"oneshot","cmd":"uname -a"}
|
|
5768
|
+
remote {"action":"add","spawn":"ssh -t pc ssh -t dev4","target":"pty:dev4via"}
|
|
5769
|
+
remote {"action":"exec","target":"pty:dev4via","cmd":"hostname"}
|
|
5770
|
+
remote {"action":"add","ssh":{"host":"10.0.0.8","user":"u1","port":22,"key":"~/.ssh/id_key4"},"target":"ssh:lab"}
|
|
5771
|
+
remote_transfer {"mode":"rsync","src":"ssh:lab:/var/log/app/","dst":"~/app-logs/","argv":["-a"]}`;
|
|
5772
|
+
/** action 是唯一派发维度:每个动词声明自己的字段集,超出即报错(不做在场推断、不留别名)。 */
|
|
5773
|
+
const ACTION_PARAMS = {
|
|
5774
|
+
exec: new Set([
|
|
5775
|
+
"action",
|
|
5776
|
+
"target",
|
|
5777
|
+
"cmd",
|
|
5778
|
+
"stdin",
|
|
5779
|
+
"scriptPath",
|
|
5780
|
+
"timeout",
|
|
5781
|
+
"run_in_background",
|
|
5782
|
+
"mode",
|
|
5783
|
+
"commandId"
|
|
5784
|
+
]),
|
|
5785
|
+
handoff: new Set(["action", "target"]),
|
|
5786
|
+
add: new Set([
|
|
5787
|
+
"action",
|
|
5788
|
+
"target",
|
|
5789
|
+
"ssh",
|
|
5790
|
+
"spawn"
|
|
5791
|
+
]),
|
|
5792
|
+
status: new Set(["action", "target"]),
|
|
5793
|
+
close: new Set([
|
|
5794
|
+
"action",
|
|
5795
|
+
"target",
|
|
5796
|
+
"force"
|
|
5797
|
+
])
|
|
5798
|
+
};
|
|
5799
|
+
const ACTION_HINT = {
|
|
5800
|
+
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",
|
|
5801
|
+
handoff: "handoff takes exactly one field besides action: target — the machine whose default PTY becomes pty:<id>",
|
|
5802
|
+
add: "add takes exactly one of ssh or spawn, plus an optional target label",
|
|
5803
|
+
status: "status takes only an optional target — omit it for the roster",
|
|
5804
|
+
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"
|
|
5805
|
+
};
|
|
5123
5806
|
async function remoteExecute(ENV, args, exec) {
|
|
5124
|
-
|
|
5125
|
-
|
|
5126
|
-
|
|
5127
|
-
|
|
5128
|
-
|
|
5129
|
-
|
|
5130
|
-
|
|
5131
|
-
|
|
5132
|
-
|
|
5133
|
-
|
|
5134
|
-
|
|
5135
|
-
|
|
5136
|
-
|
|
5137
|
-
|
|
5138
|
-
|
|
5139
|
-
"force",
|
|
5140
|
-
"src",
|
|
5141
|
-
"dst",
|
|
5142
|
-
"argv",
|
|
5143
|
-
"pullArgv",
|
|
5144
|
-
"pushArgv",
|
|
5145
|
-
"verify",
|
|
5146
|
-
"mkdir",
|
|
5147
|
-
"run_in_background",
|
|
5148
|
-
"target",
|
|
5149
|
-
"ssh",
|
|
5150
|
-
"spawn",
|
|
5151
|
-
"cmd",
|
|
5152
|
-
"scriptPath",
|
|
5153
|
-
"stdin",
|
|
5154
|
-
"timeout"
|
|
5155
|
-
]);
|
|
5156
|
-
for (const key of Object.keys(args)) if (!KNOWN_PARAMS.has(key)) throw new Error(`remote: unknown parameter "${key}" — this surface has no legacy aliases; pass native CLI options through argv instead`);
|
|
5157
|
-
if (args.action === "rsync") {
|
|
5158
|
-
if (args.pullArgv !== void 0 || args.pushArgv !== void 0) throw new Error("rsync takes argv only; pullArgv/pushArgv are copy-only fields");
|
|
5159
|
-
if ([args.verify, args.mkdir].some((value) => value !== void 0)) throw new Error("rsync cannot be combined with copy verify/mkdir options; the Hub/local staged lane is copy-only");
|
|
5160
|
-
const start = Date.now();
|
|
5161
|
-
const receipt$1 = await ENV.driver.rsync({
|
|
5162
|
-
src: args.src,
|
|
5163
|
-
dst: args.dst,
|
|
5164
|
-
...args.argv === void 0 ? {} : { argv: args.argv }
|
|
5165
|
-
}, exec.signal, { sessionKey: ENV.getSessionKey(exec) });
|
|
5166
|
-
if (receipt$1.ok === false || receipt$1.exit !== 0) throw new Error(JSON.stringify(receipt$1));
|
|
5167
|
-
const rsyncDuration = Date.now() - start;
|
|
5168
|
-
return {
|
|
5169
|
-
kind: "rsync",
|
|
5170
|
-
text: formatTransferLine("rsync", receipt$1, rsyncDuration),
|
|
5171
|
-
receipt: receipt$1,
|
|
5172
|
-
exit: receipt$1.exit,
|
|
5173
|
-
durationMs: rsyncDuration
|
|
5174
|
-
};
|
|
5175
|
-
}
|
|
5176
|
-
const copyStart = Date.now();
|
|
5177
|
-
const receipt = await ENV.driver.copy({
|
|
5178
|
-
src: args.src,
|
|
5179
|
-
dst: args.dst,
|
|
5180
|
-
...args.verify === void 0 ? {} : { verify: args.verify },
|
|
5181
|
-
...args.mkdir === void 0 ? {} : { mkdir: args.mkdir },
|
|
5182
|
-
...args.argv === void 0 ? {} : { argv: args.argv },
|
|
5183
|
-
...args.pullArgv === void 0 ? {} : { pullArgv: args.pullArgv },
|
|
5184
|
-
...args.pushArgv === void 0 ? {} : { pushArgv: args.pushArgv }
|
|
5185
|
-
}, exec.signal, { sessionKey: ENV.getSessionKey(exec) });
|
|
5186
|
-
const asReceipt = receipt;
|
|
5187
|
-
const exits = asReceipt.exits ?? [];
|
|
5188
|
-
if (asReceipt.ok === false || exits.some((code) => code !== 0)) throw new Error(JSON.stringify(receipt));
|
|
5189
|
-
const copyDuration = Date.now() - copyStart;
|
|
5807
|
+
const action = args.action;
|
|
5808
|
+
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)");
|
|
5809
|
+
const allowed = ACTION_PARAMS[action];
|
|
5810
|
+
if (allowed === void 0) throw new Error(`[E_PARAMS] remote: unknown action ${JSON.stringify(String(action))} — one of exec / handoff / add / close / status`);
|
|
5811
|
+
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]}`);
|
|
5812
|
+
const sessionKey = ENV.getSessionKey(exec);
|
|
5813
|
+
const hasTarget = typeof args.target === "string" && args.target.length > 0;
|
|
5814
|
+
const hasCmd = typeof args.cmd === "string" && args.cmd.length > 0;
|
|
5815
|
+
if (action === "status") {
|
|
5816
|
+
if (!hasTarget) return {
|
|
5817
|
+
kind: "roster",
|
|
5818
|
+
text: await ENV.driver.roster({ sessionKey }),
|
|
5819
|
+
exit: null,
|
|
5820
|
+
durationMs: 0
|
|
5821
|
+
};
|
|
5190
5822
|
return {
|
|
5191
|
-
kind: "
|
|
5192
|
-
text:
|
|
5193
|
-
|
|
5194
|
-
|
|
5195
|
-
durationMs: copyDuration
|
|
5823
|
+
kind: "status",
|
|
5824
|
+
text: await ENV.driver.status(args.target, { sessionKey }),
|
|
5825
|
+
exit: null,
|
|
5826
|
+
durationMs: 0
|
|
5196
5827
|
};
|
|
5197
5828
|
}
|
|
5198
|
-
if (
|
|
5199
|
-
if (
|
|
5200
|
-
|
|
5201
|
-
|
|
5202
|
-
|
|
5203
|
-
|
|
5204
|
-
|
|
5205
|
-
|
|
5206
|
-
|
|
5207
|
-
|
|
5208
|
-
|
|
5209
|
-
|
|
5829
|
+
if (action === "handoff") {
|
|
5830
|
+
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)");
|
|
5831
|
+
const r$1 = await ENV.driver.handoff(args.target, { sessionKey });
|
|
5832
|
+
return {
|
|
5833
|
+
kind: "handoff",
|
|
5834
|
+
text: r$1.stdout,
|
|
5835
|
+
exit: null,
|
|
5836
|
+
durationMs: 0,
|
|
5837
|
+
session: r$1.session,
|
|
5838
|
+
connection: r$1.connection,
|
|
5839
|
+
...r$1.label !== void 0 ? { label: r$1.label } : {},
|
|
5840
|
+
...r$1.cwd !== null && r$1.cwd !== void 0 ? { cwd: r$1.cwd } : {},
|
|
5841
|
+
...r$1.created === true ? { created: true } : {},
|
|
5842
|
+
...r$1.running === true && r$1.commandId !== void 0 ? {
|
|
5843
|
+
running: true,
|
|
5844
|
+
commandId: r$1.commandId
|
|
5845
|
+
} : {},
|
|
5846
|
+
...r$1.ttlSec !== void 0 ? { ttlSec: r$1.ttlSec } : {}
|
|
5847
|
+
};
|
|
5848
|
+
}
|
|
5849
|
+
if (action === "close") {
|
|
5850
|
+
if (!hasTarget) throw new Error("[E_PARAMS] close requires target — a machine (closes its default PTY) or pty:<id|label>");
|
|
5851
|
+
const r$1 = await ENV.driver.close(args.target, sessionKey, args.force === true);
|
|
5210
5852
|
return {
|
|
5211
5853
|
kind: "close",
|
|
5212
5854
|
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"}`,
|
|
@@ -5218,44 +5860,72 @@ async function remoteExecute(ENV, args, exec) {
|
|
|
5218
5860
|
killed: r$1.killed
|
|
5219
5861
|
};
|
|
5220
5862
|
}
|
|
5221
|
-
if (
|
|
5222
|
-
args.
|
|
5223
|
-
args.
|
|
5224
|
-
args.
|
|
5225
|
-
args.
|
|
5226
|
-
|
|
5227
|
-
|
|
5228
|
-
|
|
5229
|
-
|
|
5230
|
-
|
|
5231
|
-
|
|
5232
|
-
|
|
5233
|
-
|
|
5234
|
-
|
|
5235
|
-
|
|
5863
|
+
if (action === "add") {
|
|
5864
|
+
const hasSpawn = typeof args.spawn === "string" && args.spawn.length > 0;
|
|
5865
|
+
if (args.spawn !== void 0 && !hasSpawn) throw new Error("[E_PARAMS] remote: add: spawn must be a non-empty command string");
|
|
5866
|
+
if (args.ssh === void 0 && !hasSpawn) throw new Error("[E_PARAMS] remote: action \"add\" registers a BYO endpoint — pass ssh:{host,...} (identity) or spawn:\"<command>\" (PTY), exactly one");
|
|
5867
|
+
if (args.ssh !== void 0 && hasSpawn) throw new Error("[E_PARAMS] remote: add: ssh and spawn are two different BYO entry points — pass one, not both");
|
|
5868
|
+
const r$1 = await ENV.driver.call({
|
|
5869
|
+
...hasTarget ? { target: args.target } : {},
|
|
5870
|
+
...args.ssh === void 0 ? {} : { ssh: args.ssh },
|
|
5871
|
+
...hasSpawn ? { spawn: args.spawn } : {},
|
|
5872
|
+
cmd: ""
|
|
5873
|
+
}, { sessionKey });
|
|
5874
|
+
const v$1 = workbenchValue(r$1);
|
|
5875
|
+
v$1.created = true;
|
|
5876
|
+
if (!hasSpawn) v$1.notice = `registered — address it as ${r$1.session ?? r$1.target} from now on (the label, id and ssh alias all work)`;
|
|
5877
|
+
return v$1;
|
|
5878
|
+
}
|
|
5879
|
+
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\".");
|
|
5880
|
+
const target = args.target;
|
|
5881
|
+
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");
|
|
5882
|
+
if (args.scriptPath !== void 0) {
|
|
5883
|
+
if (args.scriptPath.length === 0) throw new Error("[E_PARAMS] remote: scriptPath must be a non-empty absolute path");
|
|
5884
|
+
if (hasCmd) throw new Error("[E_SCRIPT_PARAMS] remote: scriptPath and cmd are alternative carriers — pass one");
|
|
5885
|
+
}
|
|
5886
|
+
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");
|
|
5887
|
+
if (args.stdin !== void 0 && !hasCmd && args.scriptPath === void 0) {
|
|
5888
|
+
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)");
|
|
5889
|
+
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");
|
|
5890
|
+
const r$1 = await ENV.driver.input(target, {
|
|
5891
|
+
stdin: args.stdin,
|
|
5892
|
+
...args.commandId !== void 0 ? { commandId: args.commandId } : {}
|
|
5893
|
+
}, { sessionKey });
|
|
5894
|
+
const handle = r$1.jobId ?? r$1.commandId;
|
|
5895
|
+
return {
|
|
5896
|
+
kind: "input",
|
|
5897
|
+
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}`,
|
|
5898
|
+
exit: null,
|
|
5899
|
+
durationMs: 0,
|
|
5900
|
+
session: r$1.session,
|
|
5901
|
+
connection: r$1.connection,
|
|
5902
|
+
...r$1.label !== void 0 ? { label: r$1.label } : {},
|
|
5903
|
+
commandId: r$1.commandId,
|
|
5904
|
+
...r$1.jobId !== void 0 ? { jobId: r$1.jobId } : {},
|
|
5905
|
+
writtenBytes: r$1.writtenBytes
|
|
5906
|
+
};
|
|
5907
|
+
}
|
|
5908
|
+
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");
|
|
5236
5909
|
if (args.run_in_background) {
|
|
5237
|
-
if (!hasCmd && args.scriptPath === void 0) throw new Error("remote: background requires cmd or scriptPath");
|
|
5238
5910
|
const jobs = exec.agent?.ctx.get("jobs");
|
|
5239
5911
|
if (!jobs) throw new Error("remote: background requires the official DSH jobs service and job_output/job_kill controller; foreground execution remains available");
|
|
5240
5912
|
const lane = await ENV.driver.preparePtyLane({
|
|
5241
5913
|
target: args.target,
|
|
5242
|
-
spawn: args.spawn,
|
|
5243
5914
|
cmd: args.cmd,
|
|
5244
5915
|
scriptPath: args.scriptPath,
|
|
5245
|
-
ssh: args.ssh,
|
|
5246
5916
|
stdin: args.stdin,
|
|
5247
|
-
timeout: args.timeout
|
|
5248
|
-
|
|
5917
|
+
timeout: args.timeout,
|
|
5918
|
+
...args.mode !== void 0 ? { mode: args.mode } : {}
|
|
5919
|
+
}, { sessionKey });
|
|
5249
5920
|
let jobId;
|
|
5250
5921
|
try {
|
|
5251
5922
|
jobId = startRemoteJob(jobs, lane !== void 0 ? lane.input : {
|
|
5252
5923
|
target: args.target,
|
|
5253
|
-
spawn: args.spawn,
|
|
5254
5924
|
cmd: args.cmd,
|
|
5255
5925
|
scriptPath: args.scriptPath,
|
|
5256
|
-
ssh: args.ssh,
|
|
5257
5926
|
stdin: args.stdin,
|
|
5258
|
-
timeout: args.timeout
|
|
5927
|
+
timeout: args.timeout,
|
|
5928
|
+
...args.mode !== void 0 ? { mode: args.mode } : {}
|
|
5259
5929
|
}, exec, ENV.driver);
|
|
5260
5930
|
} catch (error) {
|
|
5261
5931
|
lane?.cleanup();
|
|
@@ -5269,44 +5939,21 @@ async function remoteExecute(ENV, args, exec) {
|
|
|
5269
5939
|
durationMs: 0,
|
|
5270
5940
|
text: `Started ${jobId}${where}. Use job_output to read and job_kill to cancel; completion is reported by the host.`,
|
|
5271
5941
|
...lane !== void 0 ? {
|
|
5942
|
+
commandId: jobId,
|
|
5272
5943
|
session: lane.session,
|
|
5273
5944
|
connection: lane.connection,
|
|
5274
5945
|
...lane.label !== void 0 ? { label: lane.label } : {}
|
|
5275
5946
|
} : {}
|
|
5276
5947
|
};
|
|
5277
5948
|
}
|
|
5278
|
-
if (!hasCmd && args.scriptPath === void 0) {
|
|
5279
|
-
if (args.stdin !== void 0) throw new Error("[E_STDIN_WITHOUT_CMD] remote: stdin requires cmd — status probes take no input");
|
|
5280
|
-
const t = args.target ?? "";
|
|
5281
|
-
if (hasSpawn || args.ssh !== void 0 || /^pty:/.test(t) || /:pty(:|$)/.test(t) && !t.startsWith("hub:")) return workbenchValue(await ENV.driver.call({
|
|
5282
|
-
target: args.target,
|
|
5283
|
-
spawn: args.spawn,
|
|
5284
|
-
ssh: args.ssh,
|
|
5285
|
-
cmd: "",
|
|
5286
|
-
stdin: void 0
|
|
5287
|
-
}, { sessionKey: ENV.getSessionKey(exec) }));
|
|
5288
|
-
if (!hasTarget) return {
|
|
5289
|
-
kind: "roster",
|
|
5290
|
-
text: await ENV.driver.roster({ sessionKey: ENV.getSessionKey(exec) }),
|
|
5291
|
-
exit: null,
|
|
5292
|
-
durationMs: 0
|
|
5293
|
-
};
|
|
5294
|
-
return {
|
|
5295
|
-
kind: "status",
|
|
5296
|
-
text: await ENV.driver.status(args.target, { sessionKey: ENV.getSessionKey(exec) }),
|
|
5297
|
-
exit: null,
|
|
5298
|
-
durationMs: 0
|
|
5299
|
-
};
|
|
5300
|
-
}
|
|
5301
5949
|
const r = await ENV.driver.call({
|
|
5302
5950
|
target: args.target,
|
|
5303
|
-
spawn: args.spawn,
|
|
5304
|
-
ssh: args.ssh,
|
|
5305
5951
|
cmd: args.cmd,
|
|
5306
5952
|
scriptPath: args.scriptPath,
|
|
5307
5953
|
stdin: args.stdin,
|
|
5308
|
-
timeout: args.timeout
|
|
5309
|
-
|
|
5954
|
+
timeout: args.timeout,
|
|
5955
|
+
...args.mode !== void 0 ? { mode: args.mode } : {}
|
|
5956
|
+
}, { sessionKey });
|
|
5310
5957
|
const v = workbenchValue(r);
|
|
5311
5958
|
if (r.running === true && r.ptyJob !== void 0) {
|
|
5312
5959
|
const jobs = exec.agent?.ctx.get("jobs");
|
|
@@ -5314,7 +5961,7 @@ async function remoteExecute(ENV, args, exec) {
|
|
|
5314
5961
|
const jobId = adoptRunningPtyJob(jobs, exec, `${r.target}: ${args.cmd}`, r.ptyJob);
|
|
5315
5962
|
v.jobId = jobId;
|
|
5316
5963
|
v.running = true;
|
|
5317
|
-
if (r.commandId !== void 0 && jobId !== r.commandId) ENV.driver.attachPtyJob(r.commandId, jobId,
|
|
5964
|
+
if (r.commandId !== void 0 && jobId !== r.commandId) ENV.driver.attachPtyJob(r.commandId, jobId, sessionKey);
|
|
5318
5965
|
} else {
|
|
5319
5966
|
r.ptyJob.interrupt();
|
|
5320
5967
|
v.notice = "The command outlived its synchronous wait and no jobs service is available to poll it, so it was interrupted. Use run_in_background:true for long commands.";
|
|
@@ -5323,7 +5970,7 @@ async function remoteExecute(ENV, args, exec) {
|
|
|
5323
5970
|
return v;
|
|
5324
5971
|
}
|
|
5325
5972
|
function createRemoteTool(driver, opts = {}) {
|
|
5326
|
-
const getSessionKey = opts.getSessionKey ?? ((exec) => exec.agent?.id ?? NO_SESSION_KEY);
|
|
5973
|
+
const getSessionKey = opts.getSessionKey ?? ((exec) => exec.agent?.id ?? NO_SESSION_KEY$1);
|
|
5327
5974
|
const tool = defineTool({
|
|
5328
5975
|
name: "remote",
|
|
5329
5976
|
description: REMOTE_TOOL_DESCRIPTION,
|
|
@@ -5331,58 +5978,47 @@ function createRemoteTool(driver, opts = {}) {
|
|
|
5331
5978
|
action: {
|
|
5332
5979
|
type: "string",
|
|
5333
5980
|
enum: [
|
|
5334
|
-
"
|
|
5335
|
-
"
|
|
5981
|
+
"exec",
|
|
5982
|
+
"handoff",
|
|
5983
|
+
"add",
|
|
5984
|
+
"status",
|
|
5336
5985
|
"close"
|
|
5337
5986
|
],
|
|
5338
|
-
|
|
5339
|
-
|
|
5340
|
-
force: {
|
|
5341
|
-
type: "boolean",
|
|
5342
|
-
description: "close only: default false — a session with a running job is refused (job_kill it first). true = kill the group and close in one step; the receipt lists what was killed."
|
|
5987
|
+
required: true,
|
|
5988
|
+
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)."
|
|
5343
5989
|
},
|
|
5344
|
-
|
|
5990
|
+
target: {
|
|
5345
5991
|
type: "string",
|
|
5346
|
-
description: "
|
|
5992
|
+
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>."
|
|
5347
5993
|
},
|
|
5348
|
-
|
|
5994
|
+
cmd: {
|
|
5349
5995
|
type: "string",
|
|
5350
|
-
description: "
|
|
5996
|
+
description: "exec: one raw shell string parsed by the remote bash (multi-line = one compound; exit = last command's). Required unless scriptPath is given."
|
|
5351
5997
|
},
|
|
5352
|
-
|
|
5353
|
-
type: "
|
|
5354
|
-
|
|
5355
|
-
description: "Ordered native CLI options and values for one-command routes (e.g. rsync [\"-a\",\"--partial\",\"--dry-run\"], scp [\"-p\",\"-r\"], docker cp [\"-a\"], incus file push/pull). One element = one argv token, appended verbatim before src/dst; no shell parsing, no whitelist, no forced defaults — unknown options fail in the native CLI itself. rsync only (SSH endpoints): explicit -e/--rsh overrides the tool's SSH wiring. docker cp: pass -L to follow symlinks (a symlink source like /etc/os-release fails without it). Two-segment cross-endpoint copy uses pullArgv/pushArgv instead."
|
|
5356
|
-
},
|
|
5357
|
-
pullArgv: {
|
|
5358
|
-
type: "array",
|
|
5359
|
-
items: { type: "string" },
|
|
5360
|
-
description: "Options for the PULL half (source endpoint → origin staging) of a two-command cross-endpoint copy (e.g. SSH↔Docker, SSH↔Incus, Docker↔Incus). Mutually exclusive with argv; must be paired with pushArgv. Only options valid for the pull half's CLI."
|
|
5998
|
+
stdin: {
|
|
5999
|
+
type: "string",
|
|
6000
|
+
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."
|
|
5361
6001
|
},
|
|
5362
|
-
|
|
5363
|
-
type: "
|
|
5364
|
-
|
|
5365
|
-
description: "Options for the PUSH half (origin staging → destination endpoint) of a two-command cross-endpoint copy. Mutually exclusive with argv; must be paired with pullArgv."
|
|
6002
|
+
commandId: {
|
|
6003
|
+
type: "string",
|
|
6004
|
+
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."
|
|
5366
6005
|
},
|
|
5367
|
-
|
|
5368
|
-
type: "
|
|
5369
|
-
description: "
|
|
6006
|
+
timeout: {
|
|
6007
|
+
type: "number",
|
|
6008
|
+
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)."
|
|
5370
6009
|
},
|
|
5371
|
-
|
|
5372
|
-
type: "
|
|
5373
|
-
|
|
6010
|
+
mode: {
|
|
6011
|
+
type: "string",
|
|
6012
|
+
enum: ["pty", "oneshot"],
|
|
6013
|
+
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."
|
|
5374
6014
|
},
|
|
5375
6015
|
run_in_background: {
|
|
5376
6016
|
type: "boolean",
|
|
5377
|
-
description: "
|
|
5378
|
-
},
|
|
5379
|
-
target: {
|
|
5380
|
-
type: "string",
|
|
5381
|
-
description: "Single entry point. Machine targets: bare pool name, ssh:<name>, docker:<name>, incus:<name>, hub:<m4>. PTY: append :pty to create (ssh:dev4:pty, ssh:dev4:pty:build — label optional, every creation opens a new session) or address a live one with pty:<id|label>. BYO: pass spawn (pty) or ssh (identity) and the label lives in the same pool. Omit target → roster (or BYO auto-id when spawn/ssh present)."
|
|
6017
|
+
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."
|
|
5382
6018
|
},
|
|
5383
6019
|
ssh: {
|
|
5384
6020
|
type: "object",
|
|
5385
|
-
description: "BYO ssh
|
|
6021
|
+
description: "add: BYO ssh identity to register into the pool (no dialing). Later exec calls address the returned label. No inline keys or arbitrary SSH options; nothing is persisted — a new Agent registers again.",
|
|
5386
6022
|
additionalProperties: false,
|
|
5387
6023
|
properties: {
|
|
5388
6024
|
host: {
|
|
@@ -5410,23 +6046,11 @@ function createRemoteTool(driver, opts = {}) {
|
|
|
5410
6046
|
},
|
|
5411
6047
|
spawn: {
|
|
5412
6048
|
type: "string",
|
|
5413
|
-
description: "BYO
|
|
5414
|
-
},
|
|
5415
|
-
cmd: {
|
|
5416
|
-
type: "string",
|
|
5417
|
-
description: "One raw shell string parsed by the remote bash (multi-line = one compound; exit = last command's). Omit with target → status/card; omit everything → roster."
|
|
5418
|
-
},
|
|
5419
|
-
scriptPath: {
|
|
5420
|
-
type: "string",
|
|
5421
|
-
description: "LOCAL absolute script path. Read before any dialing, sent literally over stdin to fixed bash -s. Oneshot only (no PTY lanes); cannot combine with cmd, stdin or spawn. Hub inline cmd remains limited to 4096; script body is not argv."
|
|
6049
|
+
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)."
|
|
5422
6050
|
},
|
|
5423
|
-
|
|
5424
|
-
type: "
|
|
5425
|
-
description: "
|
|
5426
|
-
},
|
|
5427
|
-
timeout: {
|
|
5428
|
-
type: "number",
|
|
5429
|
-
description: "Per-command timeout in seconds (default 120). oneshot: interrupts then kills the whole group. pty: the command is NOT killed on timeout — it hands off to a running job you can poll (job_output) and cancel (job_kill = Ctrl-C first; the session survives an interrupt)."
|
|
6051
|
+
force: {
|
|
6052
|
+
type: "boolean",
|
|
6053
|
+
description: "close only: default false — a session with a running job is refused (job_kill it first). true = kill the group and close in one step; the receipt lists what was killed."
|
|
5430
6054
|
}
|
|
5431
6055
|
},
|
|
5432
6056
|
output: {
|
|
@@ -5439,12 +6063,12 @@ function createRemoteTool(driver, opts = {}) {
|
|
|
5439
6063
|
required: true,
|
|
5440
6064
|
enum: [
|
|
5441
6065
|
"exec",
|
|
6066
|
+
"handoff",
|
|
5442
6067
|
"status",
|
|
5443
6068
|
"roster",
|
|
5444
6069
|
"job",
|
|
5445
|
-
"
|
|
5446
|
-
"
|
|
5447
|
-
"close"
|
|
6070
|
+
"close",
|
|
6071
|
+
"input"
|
|
5448
6072
|
]
|
|
5449
6073
|
},
|
|
5450
6074
|
text: {
|
|
@@ -5476,6 +6100,10 @@ function createRemoteTool(driver, opts = {}) {
|
|
|
5476
6100
|
type: "string",
|
|
5477
6101
|
description: "ledger key; equals the jobId whenever a job exists"
|
|
5478
6102
|
},
|
|
6103
|
+
writtenBytes: {
|
|
6104
|
+
type: "integer",
|
|
6105
|
+
description: "input: UTF-8 bytes actually written; confirms the write, not the program response"
|
|
6106
|
+
},
|
|
5479
6107
|
envDelta: {
|
|
5480
6108
|
type: "json",
|
|
5481
6109
|
description: "names only: {added,changed,removed}"
|
|
@@ -5484,7 +6112,7 @@ function createRemoteTool(driver, opts = {}) {
|
|
|
5484
6112
|
notice: { type: "string" },
|
|
5485
6113
|
runner: {
|
|
5486
6114
|
type: "string",
|
|
5487
|
-
description: "oneshot supervision path: superd-run <ver> (<triple>) or
|
|
6115
|
+
description: "oneshot supervision path: superd-run <ver> (<triple>), posix-shim, or hub-lease (peer hub server supervises natively)"
|
|
5488
6116
|
},
|
|
5489
6117
|
running: { type: "boolean" },
|
|
5490
6118
|
ttlSec: { type: "integer" },
|
|
@@ -5518,7 +6146,7 @@ function createRemoteTool(driver, opts = {}) {
|
|
|
5518
6146
|
const description = tool.description;
|
|
5519
6147
|
Object.defineProperty(tool, "description", {
|
|
5520
6148
|
enumerable: true,
|
|
5521
|
-
get: () => description + (driver.hasHub() ? "\nHub transport available: hub:<m4> selects a paired remote machine (never self).
|
|
6149
|
+
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." : "")
|
|
5522
6150
|
});
|
|
5523
6151
|
return tool;
|
|
5524
6152
|
}
|
|
@@ -5549,26 +6177,6 @@ function workbenchValue(r) {
|
|
|
5549
6177
|
if (r.ttlSec !== void 0) v.ttlSec = r.ttlSec;
|
|
5550
6178
|
return v;
|
|
5551
6179
|
}
|
|
5552
|
-
/** S8 (2026-10-17): copy/rsync render as one formatted line; the full receipt
|
|
5553
|
-
* stays in the receipt field — raw-JSON dumps are gone from text. */
|
|
5554
|
-
function formatTransferLine(action, receipt, durationMs) {
|
|
5555
|
-
const r = receipt;
|
|
5556
|
-
const parts = [];
|
|
5557
|
-
if (r.ok === false) parts.push(`✗ ${r.reason ?? "failed"}`);
|
|
5558
|
-
parts.push(`${action} ${r.src} → ${r.dst}`);
|
|
5559
|
-
if (r.route !== void 0 && r.route !== "") parts.push(`route ${r.route}`);
|
|
5560
|
-
if (r.exits !== void 0) parts.push(`exits [${r.exits.join(",")}]`);
|
|
5561
|
-
else if (r.exit !== void 0) parts.push(`exit ${r.exit}`);
|
|
5562
|
-
if (r.verification !== void 0) parts.push(`verification ${r.verification}`);
|
|
5563
|
-
if (r.created === true) parts.push("created yes");
|
|
5564
|
-
else if (r.created === false) parts.push("created pre-existing");
|
|
5565
|
-
else if (r.created === "n-a") parts.push("created n-a");
|
|
5566
|
-
if (r.cleanup !== void 0 && r.cleanup !== "none-required") parts.push(`cleanup ${r.cleanup}`);
|
|
5567
|
-
if (typeof r.bytes === "number") parts.push(`${r.bytes} bytes / ${r.entries} entries`);
|
|
5568
|
-
parts.push(`${(durationMs / 1e3).toFixed(1)}s`);
|
|
5569
|
-
if (r.note !== void 0 && r.note.length > 0) parts.push(`(${r.note})`);
|
|
5570
|
-
return parts.join(" · ");
|
|
5571
|
-
}
|
|
5572
6180
|
function renderEnvDelta(d) {
|
|
5573
6181
|
const bits = [];
|
|
5574
6182
|
if (d.added.length > 0) bits.push(`+${d.added.join(",+")}`);
|
|
@@ -5577,7 +6185,7 @@ function renderEnvDelta(d) {
|
|
|
5577
6185
|
return bits.length > 0 ? bits.join(" ") : void 0;
|
|
5578
6186
|
}
|
|
5579
6187
|
function renderValue(v) {
|
|
5580
|
-
if (v.kind === "status" || v.kind === "roster" || v.kind === "job" || v.kind === "
|
|
6188
|
+
if (v.kind === "status" || v.kind === "roster" || v.kind === "job" || v.kind === "close" || v.kind === "input" || v.kind === "handoff") return v.text;
|
|
5581
6189
|
const parts = [];
|
|
5582
6190
|
if (v.reconnected === true) parts.push(RECONNECT_NOTICE);
|
|
5583
6191
|
if (typeof v.notice === "string" && v.notice.length > 0) parts.push(v.notice);
|
|
@@ -5598,15 +6206,188 @@ function renderValue(v) {
|
|
|
5598
6206
|
markers.push(`${secs}s`);
|
|
5599
6207
|
if (v.running === true) markers.push(`still running — poll job_output${v.jobId !== void 0 ? ` ${v.jobId}` : ""}`);
|
|
5600
6208
|
if (v.truncated !== void 0) markers.push(`truncated, ${v.outputStatsUnavailable ? "returned peer tail" : "original"} ${v.truncated} ${v.outputStats?.stdout.unit === "bytes" ? "bytes" : "chars"}`);
|
|
5601
|
-
if (v.outputStatsUnavailable) markers.push("peer output totals/retention unavailable
|
|
6209
|
+
if (v.outputStatsUnavailable) markers.push("peer output totals/retention unavailable");
|
|
5602
6210
|
parts.push(`[${markers.join(" · ")}]`);
|
|
5603
6211
|
if (v.runner === "posix-shim" && (v.exit !== null && v.exit !== 0 || v.timedOut === true)) parts.push("note: this oneshot ran through the POSIX fallback on this target; PTY lanes use an independent lane and may still work here");
|
|
5604
6212
|
if (v.stderr !== void 0 && v.stderr.length > 0) parts.push("[stderr]", v.stderr);
|
|
5605
6213
|
return parts.join("\n");
|
|
5606
6214
|
}
|
|
5607
6215
|
|
|
6216
|
+
//#endregion
|
|
6217
|
+
//#region src/remote/transfer-tool.ts
|
|
6218
|
+
const NO_SESSION_KEY = "remote:no-session";
|
|
6219
|
+
/**
|
|
6220
|
+
* The model-visible surface of the `remote_transfer` tool (split out of
|
|
6221
|
+
* `remote`, 2026-10-25). ONE template literal — no backticks, no "${".
|
|
6222
|
+
*/
|
|
6223
|
+
const TRANSFER_TOOL_DESCRIPTION = `File transfer between any two supported endpoints. Every call declares its mode:
|
|
6224
|
+
mode:"copy" → tree copy. Native CLI per endpoint (scp for ssh, docker cp, incus file push/pull), a plain tree copy when both ends are local, and the Hub binary lane when a Hub machine is involved. Directories copy recursively; exit codes are authoritative.
|
|
6225
|
+
mode:"rsync" → rsync over SSH only: at least one endpoint remote SSH, the other local or SSH (both remote legs tunnel through this machine; identities never leave it). Hub and container endpoints are rejected — no silent copy fallback. Trailing slashes keep rsync semantics.
|
|
6226
|
+
|
|
6227
|
+
Endpoints are "[target:]absolute path". No target = the machine running this Agent. Targets are the same unified pool remote uses: a bare pool name, ssh:<name>, docker:<name>, incus:<name>, hub:<m4>. Examples: "/srv/app", "ssh:dev4:/srv/app", "docker:corti:/var/lib/x", "hub:m4:/srv". A path is never guessed: both operands must be absolute.
|
|
6228
|
+
|
|
6229
|
+
Native CLI options travel through argv verbatim — one element = one token, appended before src/dst, no shell parsing and no whitelist: rsync ["-a","--partial"], scp ["-p","-r"], docker cp ["-a","-L"], incus file push ["--recursive"]. Cross-endpoint copies that need two commands (SSH↔Docker, SSH↔Incus, Docker↔Incus) take pullArgv plus pushArgv instead of argv. rsync is a single SSH command, so it takes argv only; an explicit -e/--rsh overrides the wiring.
|
|
6230
|
+
|
|
6231
|
+
Options: verify (copy only) — the Hub↔local staged lane computes independent SHA-256 digests by default and verify:false skips them; native CLI routes have no independent hash tier, so verify is rejected there (self-verify through a oneshot digest command). mkdir (copy only, default true) — one best-effort pre-hop creates the destination PARENT directory; the operand itself is never pre-created, so each CLI's own layout semantics stay byte-identical.
|
|
6232
|
+
|
|
6233
|
+
Failures are loud: a non-zero native exit throws with the receipt instead of returning a normal-looking result. The receipt stays in the receipt field (route, exits, verification, bytes/entries, cleanup); text is one formatted line. For checksum-grade verification of a native route, run a digest command through remote oneshot or PTY instead of trusting the copy receipt.`;
|
|
6234
|
+
const KNOWN_PARAMS = new Set([
|
|
6235
|
+
"mode",
|
|
6236
|
+
"src",
|
|
6237
|
+
"dst",
|
|
6238
|
+
"argv",
|
|
6239
|
+
"pullArgv",
|
|
6240
|
+
"pushArgv",
|
|
6241
|
+
"verify",
|
|
6242
|
+
"mkdir"
|
|
6243
|
+
]);
|
|
6244
|
+
async function transferExecute(ENV, args, exec) {
|
|
6245
|
+
for (const key of Object.keys(args)) if (!KNOWN_PARAMS.has(key)) throw new Error(`remote_transfer: unknown parameter "${key}" — this surface has no legacy aliases; pass native CLI options through argv instead`);
|
|
6246
|
+
if (args.mode !== "copy" && args.mode !== "rsync") throw new Error(`[E_PARAMS] remote_transfer: mode is required — "copy" (tree copy through each endpoint's native CLI or the Hub lane) or "rsync" (SSH only)`);
|
|
6247
|
+
if (typeof args.src !== "string" || args.src.length === 0 || typeof args.dst !== "string" || args.dst.length === 0) throw new Error(`[E_PARAMS] remote_transfer: ${args.mode} requires src and dst absolute endpoints`);
|
|
6248
|
+
const sessionKey = ENV.getSessionKey(exec);
|
|
6249
|
+
if (args.mode === "rsync") {
|
|
6250
|
+
if (args.pullArgv !== void 0 || args.pushArgv !== void 0) throw new Error("remote_transfer: rsync takes argv only; pullArgv/pushArgv are copy-only fields");
|
|
6251
|
+
if (args.verify !== void 0 || args.mkdir !== void 0) throw new Error("remote_transfer: rsync cannot be combined with copy verify/mkdir options; the Hub/local staged lane is copy-only");
|
|
6252
|
+
const start$1 = Date.now();
|
|
6253
|
+
const receipt$1 = await ENV.driver.rsync({
|
|
6254
|
+
src: args.src,
|
|
6255
|
+
dst: args.dst,
|
|
6256
|
+
...args.argv === void 0 ? {} : { argv: args.argv }
|
|
6257
|
+
}, exec.signal, { sessionKey });
|
|
6258
|
+
if (receipt$1.ok === false || receipt$1.exit !== 0) throw new Error(JSON.stringify(receipt$1));
|
|
6259
|
+
const durationMs$1 = Date.now() - start$1;
|
|
6260
|
+
return {
|
|
6261
|
+
kind: "rsync",
|
|
6262
|
+
text: formatTransferLine("rsync", receipt$1, durationMs$1),
|
|
6263
|
+
receipt: receipt$1,
|
|
6264
|
+
exit: receipt$1.exit,
|
|
6265
|
+
durationMs: durationMs$1
|
|
6266
|
+
};
|
|
6267
|
+
}
|
|
6268
|
+
const start = Date.now();
|
|
6269
|
+
const receipt = await ENV.driver.copy({
|
|
6270
|
+
src: args.src,
|
|
6271
|
+
dst: args.dst,
|
|
6272
|
+
...args.verify === void 0 ? {} : { verify: args.verify },
|
|
6273
|
+
...args.mkdir === void 0 ? {} : { mkdir: args.mkdir },
|
|
6274
|
+
...args.argv === void 0 ? {} : { argv: args.argv },
|
|
6275
|
+
...args.pullArgv === void 0 ? {} : { pullArgv: args.pullArgv },
|
|
6276
|
+
...args.pushArgv === void 0 ? {} : { pushArgv: args.pushArgv }
|
|
6277
|
+
}, exec.signal, { sessionKey });
|
|
6278
|
+
const asReceipt = receipt;
|
|
6279
|
+
const exits = asReceipt.exits ?? [];
|
|
6280
|
+
if (asReceipt.ok === false || exits.some((code) => code !== 0)) throw new Error(JSON.stringify(receipt));
|
|
6281
|
+
const durationMs = Date.now() - start;
|
|
6282
|
+
return {
|
|
6283
|
+
kind: "copy",
|
|
6284
|
+
text: formatTransferLine("copy", receipt, durationMs),
|
|
6285
|
+
receipt,
|
|
6286
|
+
exit: exits.length > 0 ? 0 : null,
|
|
6287
|
+
durationMs
|
|
6288
|
+
};
|
|
6289
|
+
}
|
|
6290
|
+
function createRemoteTransferTool(driver, opts = {}) {
|
|
6291
|
+
const getSessionKey = opts.getSessionKey ?? ((exec) => exec.agent?.id ?? NO_SESSION_KEY);
|
|
6292
|
+
return defineTool({
|
|
6293
|
+
name: "remote_transfer",
|
|
6294
|
+
description: TRANSFER_TOOL_DESCRIPTION,
|
|
6295
|
+
parameters: {
|
|
6296
|
+
mode: {
|
|
6297
|
+
type: "string",
|
|
6298
|
+
enum: ["copy", "rsync"],
|
|
6299
|
+
required: true,
|
|
6300
|
+
description: "Required. copy = tree copy through each endpoint's native CLI, or the Hub binary lane; rsync = SSH only (argv only, no verify/mkdir)."
|
|
6301
|
+
},
|
|
6302
|
+
src: {
|
|
6303
|
+
type: "string",
|
|
6304
|
+
description: "Source [target:]absolute path. No target = the machine running this Agent."
|
|
6305
|
+
},
|
|
6306
|
+
dst: {
|
|
6307
|
+
type: "string",
|
|
6308
|
+
description: "Destination [target:]absolute path. No target = the machine running this Agent."
|
|
6309
|
+
},
|
|
6310
|
+
argv: {
|
|
6311
|
+
type: "array",
|
|
6312
|
+
items: { type: "string" },
|
|
6313
|
+
description: "Ordered native CLI options and values for one-command routes (e.g. rsync [\"-a\",\"--partial\",\"--dry-run\"], scp [\"-p\",\"-r\"], docker cp [\"-a\",\"-L\"], incus file push/pull). One element = one argv token, appended verbatim before src/dst; no shell parsing, no whitelist, no forced defaults — unknown options fail in the native CLI itself. rsync only (SSH endpoints): explicit -e/--rsh overrides the tool's SSH wiring. docker cp: pass -L to follow symlinks (a symlink source like /etc/os-release fails without it). Two-segment cross-endpoint copy uses pullArgv/pushArgv instead."
|
|
6314
|
+
},
|
|
6315
|
+
pullArgv: {
|
|
6316
|
+
type: "array",
|
|
6317
|
+
items: { type: "string" },
|
|
6318
|
+
description: "Options for the PULL half (source endpoint → origin staging) of a two-command cross-endpoint copy (e.g. SSH↔Docker, SSH↔Incus, Docker↔Incus). Mutually exclusive with argv; must be paired with pushArgv. Only options valid for the pull half's CLI."
|
|
6319
|
+
},
|
|
6320
|
+
pushArgv: {
|
|
6321
|
+
type: "array",
|
|
6322
|
+
items: { type: "string" },
|
|
6323
|
+
description: "Options for the PUSH half (origin staging → destination endpoint) of a two-command cross-endpoint copy. Mutually exclusive with argv; must be paired with pullArgv."
|
|
6324
|
+
},
|
|
6325
|
+
verify: {
|
|
6326
|
+
type: "boolean",
|
|
6327
|
+
description: "copy only. Receipts name the verification tier actually performed (D1=A): native CLI routes are \"native-exit-only\" (no independent check — self-verify via oneshot digest commands when needed); the Hub↔local staged lane computes independent SHA-256 digests by default and verify:false skips them (\"not-applicable\"). No hash tier exists on native routes."
|
|
6328
|
+
},
|
|
6329
|
+
mkdir: {
|
|
6330
|
+
type: "boolean",
|
|
6331
|
+
description: "copy only, all routes. Default true: before transfer, one best-effort exec pre-hop ensures the destination's PARENT directory exists (the operand itself is never pre-created — each CLI's own layout semantics stay byte-identical). Receipt created field: true (parent created this call) / false (already existed) / \"n-a\" (skipped or pre-hop unavailable, e.g. docker exec on a stopped container — the native CLI then reports the real error itself). false skips the pre-hop."
|
|
6332
|
+
}
|
|
6333
|
+
},
|
|
6334
|
+
output: {
|
|
6335
|
+
schema: {
|
|
6336
|
+
type: "object",
|
|
6337
|
+
additionalProperties: false,
|
|
6338
|
+
properties: {
|
|
6339
|
+
kind: {
|
|
6340
|
+
type: "string",
|
|
6341
|
+
required: true,
|
|
6342
|
+
enum: ["copy", "rsync"]
|
|
6343
|
+
},
|
|
6344
|
+
text: {
|
|
6345
|
+
type: "string",
|
|
6346
|
+
required: true
|
|
6347
|
+
},
|
|
6348
|
+
receipt: { type: "json" },
|
|
6349
|
+
exit: { oneOf: [{ type: "integer" }, { type: "null" }] },
|
|
6350
|
+
durationMs: { type: "integer" }
|
|
6351
|
+
}
|
|
6352
|
+
},
|
|
6353
|
+
render: (_args, value) => [{
|
|
6354
|
+
type: "text",
|
|
6355
|
+
text: value.text
|
|
6356
|
+
}]
|
|
6357
|
+
},
|
|
6358
|
+
async execute(args, exec) {
|
|
6359
|
+
return transferExecute({
|
|
6360
|
+
driver,
|
|
6361
|
+
getSessionKey
|
|
6362
|
+
}, args, exec);
|
|
6363
|
+
}
|
|
6364
|
+
});
|
|
6365
|
+
}
|
|
6366
|
+
/** S8 (2026-10-17): copy/rsync render as one formatted line; the full receipt
|
|
6367
|
+
* stays in the receipt field — raw-JSON dumps are gone from text. */
|
|
6368
|
+
function formatTransferLine(mode, receipt, durationMs) {
|
|
6369
|
+
const r = receipt;
|
|
6370
|
+
const parts = [];
|
|
6371
|
+
if (r.ok === false) parts.push(`✗ ${r.reason ?? "failed"}`);
|
|
6372
|
+
parts.push(`${mode} ${r.src} → ${r.dst}`);
|
|
6373
|
+
if (r.route !== void 0 && r.route !== "") parts.push(`route ${r.route}`);
|
|
6374
|
+
if (r.exits !== void 0) parts.push(`exits [${r.exits.join(",")}]`);
|
|
6375
|
+
else if (r.exit !== void 0) parts.push(`exit ${r.exit}`);
|
|
6376
|
+
if (r.verification !== void 0) parts.push(`verification ${r.verification}`);
|
|
6377
|
+
if (r.created === true) parts.push("created yes");
|
|
6378
|
+
else if (r.created === false) parts.push("created pre-existing");
|
|
6379
|
+
else if (r.created === "n-a") parts.push("created n-a");
|
|
6380
|
+
if (r.cleanup !== void 0 && r.cleanup !== "none-required") parts.push(`cleanup ${r.cleanup}`);
|
|
6381
|
+
if (typeof r.bytes === "number") parts.push(`${r.bytes} bytes / ${r.entries} entries`);
|
|
6382
|
+
parts.push(`${(durationMs / 1e3).toFixed(1)}s`);
|
|
6383
|
+
if (r.note !== void 0 && r.note.length > 0) parts.push(`(${r.note})`);
|
|
6384
|
+
return parts.join(" · ");
|
|
6385
|
+
}
|
|
6386
|
+
|
|
5608
6387
|
//#endregion
|
|
5609
6388
|
//#region src/remote/agent-registration.ts
|
|
6389
|
+
/** The pair a row contributes: execution/PTY and file transfer are separate tools. */
|
|
6390
|
+
const TOOL_NAMES = ["remote", "remote_transfer"];
|
|
5610
6391
|
/** Host listeners attempt once per official creation edge, never on vacancy. */
|
|
5611
6392
|
function installAgentRemote(ctx, config) {
|
|
5612
6393
|
return ctx.on("agent/created", ({ agent }) => {
|
|
@@ -5616,12 +6397,14 @@ function installAgentRemote(ctx, config) {
|
|
|
5616
6397
|
...config,
|
|
5617
6398
|
getHub: () => agent.ctx.get("superdRemoteTransport")
|
|
5618
6399
|
});
|
|
5619
|
-
|
|
6400
|
+
const registered = [];
|
|
5620
6401
|
try {
|
|
5621
|
-
|
|
6402
|
+
registered.push(tools.register(createRemoteTool(driver, { getSessionKey: () => agent.id })));
|
|
6403
|
+
registered.push(tools.register(createRemoteTransferTool(driver, { getSessionKey: () => agent.id })));
|
|
5622
6404
|
} catch (error) {
|
|
6405
|
+
for (const undo of registered.splice(0)) undo();
|
|
5623
6406
|
driver.dispose();
|
|
5624
|
-
if (error instanceof Error && error.message ===
|
|
6407
|
+
if (TOOL_NAMES.find((name$1) => error instanceof Error && error.message === `tool "${name$1}" is already registered in this scope` && tools.get(name$1, agent) !== void 0) !== void 0) return;
|
|
5625
6408
|
throw error;
|
|
5626
6409
|
}
|
|
5627
6410
|
const stopService = agent.ctx.on("internal/service", (name$1) => {
|
|
@@ -5633,7 +6416,7 @@ function installAgentRemote(ctx, config) {
|
|
|
5633
6416
|
const cleanup = () => {
|
|
5634
6417
|
if (disposal) return disposal;
|
|
5635
6418
|
disposal = driver.dispose();
|
|
5636
|
-
|
|
6419
|
+
for (const undo of registered.splice(0)) undo();
|
|
5637
6420
|
stopService();
|
|
5638
6421
|
ownerUndo?.();
|
|
5639
6422
|
agentUndo?.();
|
|
@@ -5689,12 +6472,10 @@ const name = "dashr-remote";
|
|
|
5689
6472
|
const inject = [];
|
|
5690
6473
|
const Config = Schema.object({
|
|
5691
6474
|
execTimeoutSec: Schema.natural().min(1).default(120),
|
|
5692
|
-
idleTtlSec: Schema.natural().min(1).default(REMOTE_PTY_IDLE_TTL_SEC)
|
|
5693
|
-
maxOutputChars: Schema.natural().min(1).default(3e4)
|
|
6475
|
+
idleTtlSec: Schema.natural().min(1).default(REMOTE_PTY_IDLE_TTL_SEC)
|
|
5694
6476
|
}).default({
|
|
5695
6477
|
execTimeoutSec: 120,
|
|
5696
|
-
idleTtlSec: REMOTE_PTY_IDLE_TTL_SEC
|
|
5697
|
-
maxOutputChars: 3e4
|
|
6478
|
+
idleTtlSec: REMOTE_PTY_IDLE_TTL_SEC
|
|
5698
6479
|
});
|
|
5699
6480
|
/** MUST stay an arrow(RM0 plugin.ts 同注:function 声明会被 cordis 当构造器 new 掉,返回的 disposer 丢失)。 */
|
|
5700
6481
|
const apply = (ctx, config) => {
|
|
@@ -5708,4 +6489,4 @@ var plugin_default = {
|
|
|
5708
6489
|
};
|
|
5709
6490
|
|
|
5710
6491
|
//#endregion
|
|
5711
|
-
export { ByteTailRing, Config, FILE_FRAME_BYTES, FILE_MAX_ENTRIES, FILE_MAX_METADATA, PROCESS_CLEANUP_BOUND_MS, Utf16TailRing, apply, checkAbsolute, checkSelection, plugin_default as default, inject, localFileEndpoint, name, readUtf8Tail, runFileWorker, runRsync, sshArgv, startBinaryProcess, startOneShot, startRemoteJob };
|
|
6492
|
+
export { ByteTailRing, Config, FILE_FRAME_BYTES, FILE_MAX_ENTRIES, FILE_MAX_METADATA, PROCESS_CLEANUP_BOUND_MS, PtySession, Utf16TailRing, apply, buildHubPtyArgv, checkAbsolute, checkSelection, plugin_default as default, disclose, hubPtyAssets, inject, localFileEndpoint, name, readUtf8Tail, runFileWorker, runRsync, sshArgv, startBinaryProcess, startOneShot, startRemoteJob };
|