super-dsh 0.3.2-d → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/remote/plugin.d.ts +201 -402
- package/remote/plugin.js +286 -598
- package/remote/provenance.json +11 -14
- package/superd-hub/dist/exec-pty-channel.d.ts +58 -0
- package/superd-hub/dist/exec-pty-channel.js +162 -0
- package/superd-hub/dist/exec-pty-framing.d.ts +34 -0
- package/superd-hub/dist/exec-pty-framing.js +89 -0
- package/superd-hub/dist/exec-pty-session.d.ts +78 -0
- package/superd-hub/dist/exec-pty-session.js +228 -0
- package/superd-hub/dist/hub-tool.d.ts +1 -1
- package/superd-hub/dist/hub-tool.js +1 -1
- package/superd-hub/dist/remote-provider.js +36 -70
- package/remote/hub-pty-pump.mjs +0 -97
package/remote/plugin.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import "node:module";
|
|
2
1
|
import { Context } from "@deepseek-ai/cordis";
|
|
3
2
|
import { chmod, lstat, mkdir, mkdtemp, open, readFile, readdir, readlink, rename, rm, stat, symlink, writeFile } from "node:fs/promises";
|
|
4
3
|
import { constants, createReadStream, createWriteStream, existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
|
|
@@ -10,7 +9,6 @@ import { homedir, tmpdir } from "node:os";
|
|
|
10
9
|
import { spawn } from "node:child_process";
|
|
11
10
|
import { createConnection, createServer } from "node:net";
|
|
12
11
|
import { fileURLToPath } from "node:url";
|
|
13
|
-
import { Buffer as Buffer$1 } from "node:buffer";
|
|
14
12
|
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
15
13
|
|
|
16
14
|
//#region third_party/schemastery/node_modules/@deepseek-ai/cosmokit/lib/index.js
|
|
@@ -2729,23 +2727,6 @@ function buildSpawnArgv(spawnCommand) {
|
|
|
2729
2727
|
"/dev/null"
|
|
2730
2728
|
];
|
|
2731
2729
|
}
|
|
2732
|
-
/** Hub PTY(0.3.2-c;0.3.2-d F4 修正):会话子进程 = 本地 `script` PTY 托管的 WS 字节泵
|
|
2733
|
-
* (hub-pty-pump.mjs)。与 docker exec 的嵌套 PTY 同形,但泵是哑 node 子进程、不像
|
|
2734
|
-
* `ssh -tt` / `docker exec -it` 那样作为前台子进程自己把本地 tty 置 raw——所以本地包装必须
|
|
2735
|
-
* `stty raw`:canonical+ISIG 下 0x03 在本地变 SIGINT 杀泵、0x04 变 EOF 结束泵 stdin
|
|
2736
|
-
* (F4:input ^C/^D 与 job_kill 会打死整条会话)。raw 只作用于本地半段;对端 script PTY
|
|
2737
|
-
* 保持 canonical,^C/^D 在对端产生 SIGINT/EOF 正是我们要的语义(字节原样过线,解释权在
|
|
2738
|
-
* 对端)。kill 链 session 组杀 → 泵死 → WS 关 → 对端组杀,双向自然闭合。连接事实走 wrapper
|
|
2739
|
-
* 内 env 赋值(cookie 不进 argv、不落 ps)。 */
|
|
2740
|
-
function buildHubPtyArgv(endpoint, assets) {
|
|
2741
|
-
const node = assets.nodePath ?? process.execPath;
|
|
2742
|
-
return [
|
|
2743
|
-
"script",
|
|
2744
|
-
"-qfec",
|
|
2745
|
-
`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)}`,
|
|
2746
|
-
"/dev/null"
|
|
2747
|
-
];
|
|
2748
|
-
}
|
|
2749
2730
|
/** superd-run helper argv (spec: docs/specs/remote-rescope §5). Probe and upload are single
|
|
2750
2731
|
* `sh -c` hops (busybox/alpine/darwin-safe); run/killgroup over ssh go through exactly one
|
|
2751
2732
|
* remote-shell parse, same discipline as buildOneShotArgv. */
|
|
@@ -3025,15 +3006,13 @@ const summaryOf = (cmd) => {
|
|
|
3025
3006
|
};
|
|
3026
3007
|
const DEFAULT_INIT_TIMEOUT_SEC = 30;
|
|
3027
3008
|
const INTERRUPT_GRACE_MS = 3e3;
|
|
3028
|
-
/** F2(0.3.2-d):\x03 触发 ISIG 时内核 flush 输入队列——130-marker 必须等 flush 完成
|
|
3029
|
-
* 再入队,否则首字节被吞(实测 'rintf' not found → 会话死)。 */
|
|
3030
|
-
const MARKER_SETTLE_MS = 80;
|
|
3031
3009
|
/** pty 收集内存安全帽(Ruling P15,与 oneshot HARD_CAP 对齐):模型面只见尾窗,保留尾部。 */
|
|
3032
3010
|
const OUTPUT_HARD_CAP = 4e6;
|
|
3033
3011
|
var PtySession = class {
|
|
3034
3012
|
proc = null;
|
|
3035
3013
|
state = "cold";
|
|
3036
3014
|
startPromise = null;
|
|
3015
|
+
queue = Promise.resolve();
|
|
3037
3016
|
idleTimer = null;
|
|
3038
3017
|
onChunk = null;
|
|
3039
3018
|
deathWaiters = [];
|
|
@@ -3041,8 +3020,6 @@ var PtySession = class {
|
|
|
3041
3020
|
inFlight = 0;
|
|
3042
3021
|
/** P4/P5:当前占用前台的 detached job(background/超时 handoff)。非 null 时新命令拒绝(Q6)。 */
|
|
3043
3022
|
detachedJob = null;
|
|
3044
|
-
/** §3.2(2026-10-25):当前占用会话的命令——dispatch 同步占位,settle/cleanup 释放;input 的唯一写入对象。 */
|
|
3045
|
-
activeCommand = null;
|
|
3046
3023
|
/** 账本(§5):本会话最近 20 条命令(FIFO,超出丢最旧)。 */
|
|
3047
3024
|
ledger = [];
|
|
3048
3025
|
/** env 侧信道基线(init E 帧);undefined = 侧信道不可用(不报假 delta)。 */
|
|
@@ -3075,11 +3052,10 @@ var PtySession = class {
|
|
|
3075
3052
|
get detachedCommandId() {
|
|
3076
3053
|
return this.detachedJob?.commandId ?? null;
|
|
3077
3054
|
}
|
|
3078
|
-
/** job 通道收养后回填 jobId(timeout handoff 时 registry id ≠ commandId
|
|
3055
|
+
/** job 通道收养后回填 jobId(timeout handoff 时 registry id ≠ commandId,账本如实记录两者)。 */
|
|
3079
3056
|
attachJob(commandId, jobId) {
|
|
3080
3057
|
const entry = [...this.ledger].reverse().find((e) => e.commandId === commandId);
|
|
3081
3058
|
if (entry !== void 0) entry.jobId = jobId;
|
|
3082
|
-
if (this.activeCommand !== null && this.activeCommand.commandId === commandId) this.activeCommand.jobId = jobId;
|
|
3083
3059
|
}
|
|
3084
3060
|
/** Workbench 身份(§3):id + 可选 label + connection + byo 标记。 */
|
|
3085
3061
|
get identity() {
|
|
@@ -3113,55 +3089,28 @@ var PtySession = class {
|
|
|
3113
3089
|
async warm() {
|
|
3114
3090
|
await this.start();
|
|
3115
3091
|
}
|
|
3116
|
-
/**
|
|
3117
|
-
*
|
|
3118
|
-
*
|
|
3119
|
-
*
|
|
3120
|
-
* P4/P5:超时不杀命令而是 handoff 成 running job;background 立即回执;P8 显式
|
|
3121
|
-
* interrupt 保留在 job.interrupt;死亡/冷态先重启(reconnect 语义不变)。 */
|
|
3092
|
+
/** 同一会话命令严格串行(FIFO);死亡/冷态先重启。P4/P5(2026-10-19):超时不再自动
|
|
3093
|
+
* 打断——handoff 成 running job(byte-cursor 轮询 + 显式 interrupt,P8 机制原样保留在
|
|
3094
|
+
* job.interrupt);background 选项让 dispatch 立即回执。占用者是 detached job 时新命令
|
|
3095
|
+
* 拒绝(Q6:诚实回状态优先于排队),纯同步并发 FIFO 语义不变。 */
|
|
3122
3096
|
dispatch(cmd, dOpts) {
|
|
3123
|
-
if (this.state === "disposed") return Promise.reject(/* @__PURE__ */ new Error(`[E_SESSION_DISPOSED] remote pty session '${this.opts.id}' was disposed before dispatch`));
|
|
3124
|
-
if (this.activeCommand !== null) {
|
|
3125
|
-
const handle = this.activeCommand.jobId ?? this.activeCommand.commandId;
|
|
3126
|
-
return Promise.reject(/* @__PURE__ */ new Error(`[E_SESSION_BUSY] remote: pty session '${this.opts.id}' still runs a command (${this.activeCommand.commandId}) — send it more stdin with the input action, 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`));
|
|
3127
|
-
}
|
|
3128
|
-
const commandId = dOpts.commandId;
|
|
3129
|
-
const entry = {
|
|
3130
|
-
commandId,
|
|
3131
|
-
summary: summaryOf(cmd),
|
|
3132
|
-
exit: null,
|
|
3133
|
-
durationMs: 0,
|
|
3134
|
-
running: true,
|
|
3135
|
-
...dOpts.jobId !== void 0 ? { jobId: dOpts.jobId } : {}
|
|
3136
|
-
};
|
|
3137
|
-
this.ledger.push(entry);
|
|
3138
|
-
if (this.ledger.length > LEDGER_MAX) this.ledger.shift();
|
|
3139
|
-
this.activeCommand = {
|
|
3140
|
-
commandId,
|
|
3141
|
-
...dOpts.jobId !== void 0 ? { jobId: dOpts.jobId } : {}
|
|
3142
|
-
};
|
|
3143
|
-
this.inFlight++;
|
|
3144
3097
|
const attempt = async () => {
|
|
3145
3098
|
const started = Date.now();
|
|
3146
3099
|
const reconnected = this.state === "dead";
|
|
3147
|
-
|
|
3148
|
-
|
|
3149
|
-
|
|
3150
|
-
|
|
3151
|
-
|
|
3152
|
-
|
|
3153
|
-
|
|
3100
|
+
if (this.state !== "ready") await this.start();
|
|
3101
|
+
if (this.state === "disposed") throw new Error(`[E_SESSION_DISPOSED] remote pty session '${this.opts.id}' was disposed before dispatch`);
|
|
3102
|
+
if (this.detachedJob !== null) throw new Error(`[E_SESSION_BUSY] remote: pty session '${this.opts.id}' still runs a detached command (${this.detachedJob.commandId}) — poll job_output ${this.detachedJob.commandId}, cancel with job_kill ${this.detachedJob.commandId}, or address other work to another session / oneshot`);
|
|
3103
|
+
const commandId = dOpts.commandId;
|
|
3104
|
+
const entry = {
|
|
3105
|
+
commandId,
|
|
3106
|
+
summary: summaryOf(cmd),
|
|
3107
|
+
exit: null,
|
|
3108
|
+
durationMs: 0,
|
|
3109
|
+
running: true
|
|
3154
3110
|
};
|
|
3155
|
-
|
|
3156
|
-
|
|
3157
|
-
|
|
3158
|
-
abandon();
|
|
3159
|
-
throw error;
|
|
3160
|
-
}
|
|
3161
|
-
if (this.state === "disposed") {
|
|
3162
|
-
abandon();
|
|
3163
|
-
throw new Error(`[E_SESSION_DISPOSED] remote pty session '${this.opts.id}' was disposed before dispatch`);
|
|
3164
|
-
}
|
|
3111
|
+
this.ledger.push(entry);
|
|
3112
|
+
if (this.ledger.length > LEDGER_MAX) this.ledger.shift();
|
|
3113
|
+
this.inFlight++;
|
|
3165
3114
|
return await new Promise((resolve$1) => {
|
|
3166
3115
|
const nonce = genNonce();
|
|
3167
3116
|
const ring = new Utf16TailRing(OUTPUT_HARD_CAP);
|
|
@@ -3170,7 +3119,6 @@ var PtySession = class {
|
|
|
3170
3119
|
let jobSettled = false;
|
|
3171
3120
|
let interruptTimer;
|
|
3172
3121
|
let killTimer;
|
|
3173
|
-
let markerTimer;
|
|
3174
3122
|
let jobDone = null;
|
|
3175
3123
|
let envNext;
|
|
3176
3124
|
const detach = () => {
|
|
@@ -3204,11 +3152,9 @@ var PtySession = class {
|
|
|
3204
3152
|
const cleanup = () => {
|
|
3205
3153
|
this.onChunk = null;
|
|
3206
3154
|
this.detachedJob = null;
|
|
3207
|
-
this.activeCommand = null;
|
|
3208
3155
|
this.deathWaiters = this.deathWaiters.filter((w) => w !== deathWaiter);
|
|
3209
3156
|
if (interruptTimer !== void 0) clearTimeout(interruptTimer);
|
|
3210
3157
|
if (killTimer !== void 0) clearTimeout(killTimer);
|
|
3211
|
-
if (markerTimer !== void 0) clearTimeout(markerTimer);
|
|
3212
3158
|
};
|
|
3213
3159
|
const settle = (r) => {
|
|
3214
3160
|
if (jobSettled) return;
|
|
@@ -3263,9 +3209,7 @@ var PtySession = class {
|
|
|
3263
3209
|
const twoStage = () => {
|
|
3264
3210
|
interrupted = true;
|
|
3265
3211
|
this.write("");
|
|
3266
|
-
|
|
3267
|
-
this.write(`printf '\\033]133;D;${nonce};130;%s\\007' "$PWD"\n`);
|
|
3268
|
-
}, MARKER_SETTLE_MS);
|
|
3212
|
+
this.write(`printf '\\033]133;D;${nonce};130;%s\\007' "$PWD"\n`);
|
|
3269
3213
|
killTimer = setTimeout(() => {
|
|
3270
3214
|
this.killTree();
|
|
3271
3215
|
this.markDead();
|
|
@@ -3288,33 +3232,9 @@ var PtySession = class {
|
|
|
3288
3232
|
interruptTimer = setTimeout(() => detach(), dOpts.timeoutSec === void 0 ? 12e4 : dOpts.timeoutSec * 1e3);
|
|
3289
3233
|
});
|
|
3290
3234
|
};
|
|
3291
|
-
|
|
3292
|
-
|
|
3293
|
-
|
|
3294
|
-
* 与身份,不新建命令、账本条目或输出游标;不重连、不重放(dead/disposed 明确拒绝)。
|
|
3295
|
-
* 校验与写入全同步:多次 input 按调用顺序整段写入不交错;写入失败如实抛错(私有
|
|
3296
|
-
* write() 的静默忽略不适用于回执语义)。stdin:"" 是零字节写入,不是 EOF。 */
|
|
3297
|
-
input(options) {
|
|
3298
|
-
const fail = (code, hint) => Promise.reject(/* @__PURE__ */ new Error(`${code} remote: ${hint}`));
|
|
3299
|
-
if (this.state === "disposed") return fail("[E_SESSION_DISPOSED]", `pty session '${displayKey(this.opts.key)}' was disposed — input never reaches a disposed session`);
|
|
3300
|
-
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`);
|
|
3301
|
-
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`);
|
|
3302
|
-
const active = this.activeCommand;
|
|
3303
|
-
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`);
|
|
3304
|
-
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`);
|
|
3305
|
-
const stdin = this.proc?.stdin;
|
|
3306
|
-
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`);
|
|
3307
|
-
try {
|
|
3308
|
-
stdin.write(options.stdin);
|
|
3309
|
-
} catch (error) {
|
|
3310
|
-
return fail("[E_SESSION_WRITE_FAILED]", `writing stdin to pty session '${displayKey(this.opts.key)}' failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
3311
|
-
}
|
|
3312
|
-
return Promise.resolve({
|
|
3313
|
-
session: displayKey(this.opts.key),
|
|
3314
|
-
commandId: active.commandId,
|
|
3315
|
-
...active.jobId !== void 0 ? { jobId: active.jobId } : {},
|
|
3316
|
-
writtenBytes: Buffer$1.byteLength(options.stdin, "utf8")
|
|
3317
|
-
});
|
|
3235
|
+
const run = this.queue.then(attempt, attempt);
|
|
3236
|
+
this.queue = run.then(() => void 0, () => void 0);
|
|
3237
|
+
return run;
|
|
3318
3238
|
}
|
|
3319
3239
|
/** TTL / row teardown:尽力 exit,1s 后杀组,出池。 */
|
|
3320
3240
|
dispose() {
|
|
@@ -3323,7 +3243,6 @@ var PtySession = class {
|
|
|
3323
3243
|
this.clearDeathTimer();
|
|
3324
3244
|
this.state = "disposed";
|
|
3325
3245
|
this.startPromise = null;
|
|
3326
|
-
this.detachedJob?.interrupt();
|
|
3327
3246
|
const proc = this.proc;
|
|
3328
3247
|
const close = this.procClose;
|
|
3329
3248
|
const group = this.reapGroup(proc?.pid, 1e3, close);
|
|
@@ -3334,7 +3253,7 @@ var PtySession = class {
|
|
|
3334
3253
|
]).then(() => {
|
|
3335
3254
|
if (this.proc === proc) this.proc = null;
|
|
3336
3255
|
});
|
|
3337
|
-
|
|
3256
|
+
this.write("exit\n");
|
|
3338
3257
|
this.opts.onDead?.();
|
|
3339
3258
|
return this.disposal;
|
|
3340
3259
|
}
|
|
@@ -3375,9 +3294,6 @@ var PtySession = class {
|
|
|
3375
3294
|
proc.stderr.setEncoding("utf8").on("data", (d) => {
|
|
3376
3295
|
this.diag = (this.diag + d).slice(-2e3);
|
|
3377
3296
|
});
|
|
3378
|
-
proc.stdin.on("error", (err) => {
|
|
3379
|
-
this.diag = (this.diag + `\n[remote: stdin write failed: ${err.message}]`).slice(-2e3);
|
|
3380
|
-
});
|
|
3381
3297
|
this.procClose = new Promise((resolve$1) => {
|
|
3382
3298
|
proc.once("close", () => {
|
|
3383
3299
|
this.markDead();
|
|
@@ -3645,15 +3561,7 @@ function parseTargetForm(target) {
|
|
|
3645
3561
|
form: "hub-pty",
|
|
3646
3562
|
machine: segs[0]
|
|
3647
3563
|
};
|
|
3648
|
-
|
|
3649
|
-
if (segs[2] === void 0 || segs[2].length === 0) throw new TargetFormatError(`[E_TARGET_FORMAT] remote: ${JSON.stringify(target)} — empty label after ":pty:"`);
|
|
3650
|
-
return {
|
|
3651
|
-
form: "hub-pty",
|
|
3652
|
-
machine: segs[0],
|
|
3653
|
-
label: segs[2]
|
|
3654
|
-
};
|
|
3655
|
-
}
|
|
3656
|
-
throw new TargetFormatError(`[E_TARGET_FORMAT] remote: ${JSON.stringify(target)} — hub PTY is hub:<m4>:pty[:label]`);
|
|
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)`);
|
|
3657
3565
|
}
|
|
3658
3566
|
for (const transport of TRANSPORTS) {
|
|
3659
3567
|
if (!target.startsWith(`${transport}:`)) continue;
|
|
@@ -4076,16 +3984,6 @@ function renderRoster(scan, entries, sessions, hubTargets) {
|
|
|
4076
3984
|
const REMOTE_PTY_IDLE_TTL_SEC = 900;
|
|
4077
3985
|
/** P3(user 2026-10-19):每 agent 10 个 PTY 会话,跨所有 connection 统一计;仅 error message 披露。 */
|
|
4078
3986
|
const REMOTE_PTY_SESSION_LIMIT = 10;
|
|
4079
|
-
/** hub 线 one-shot 监督披露(0.3.2-c):对端 = 我们自己的 hub server(bash -lc 于独立进程组,
|
|
4080
|
-
* Node 原生 TERM→2s→KILL + 输出环 + 租约生命周期);superd-run helper 是 dumb-pipe 专用物,
|
|
4081
|
-
* 从不经 hub 上传(non-goal,docs/10_plans/2026-10-25-hub-remote-parity.md §3 O1/O8)。 */
|
|
4082
|
-
const HUB_RUNNER = "hub-lease (peer bash -lc, peer Node supervision)";
|
|
4083
|
-
/** hub PTY 泵资产:pump 与本模块同目录(npm 包与 Super 闭包同构,helper.ts 同款纪律)。
|
|
4084
|
-
* ws 模块路径由 transport 侧(superd-hub 上下文,分叉的 proven 解析链)解析后经
|
|
4085
|
-
* ptyEndpoint 传入——闭包自身子树无可解析的 ws,按名解析不在闭包里做(0.3.2-c 定稿)。 */
|
|
4086
|
-
function hubPtyAssets() {
|
|
4087
|
-
return { pumpPath: join(dirname(fileURLToPath(import.meta.url)), "hub-pty-pump.mjs") };
|
|
4088
|
-
}
|
|
4089
3987
|
/** 账本键与 jobId 同构(同格式 `<kind>-<16hex>`);background 通道预传入 registry id 时两者字面相等。 */
|
|
4090
3988
|
const genCommandId = () => `remote-${randomUUID().replace(/-/g, "").slice(0, 16)}`;
|
|
4091
3989
|
/** 双轨调度器:参数校验 → 目标语法(parseTargetForm)→ 统一 label 池 → oneshot 子进程 / pty 会话池。 */
|
|
@@ -4153,9 +4051,28 @@ var RemoteDriver = class {
|
|
|
4153
4051
|
const sessionKey = callCtx.sessionKey ?? "no-session";
|
|
4154
4052
|
const hasCmd = params.cmd !== void 0 && params.cmd.length > 0;
|
|
4155
4053
|
params.spawn !== void 0 && params.spawn.length;
|
|
4156
|
-
if (form.form === "hub") {
|
|
4054
|
+
if (form.form === "hub" || form.form === "hub-pty") {
|
|
4157
4055
|
const hub = this.requireHub();
|
|
4158
4056
|
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
|
+
}
|
|
4159
4076
|
const process$1 = this.own(await hub.start(form.machine, {
|
|
4160
4077
|
...params,
|
|
4161
4078
|
target: `hub:${form.machine}`,
|
|
@@ -4174,7 +4091,6 @@ var RemoteDriver = class {
|
|
|
4174
4091
|
connection: `hub:${form.machine}`,
|
|
4175
4092
|
cwd: null,
|
|
4176
4093
|
reconnected: false,
|
|
4177
|
-
runner: HUB_RUNNER,
|
|
4178
4094
|
stdout: (!stdout.stats && stdout.lossy ? "[truncated: peer returned only a tail; exact total/retained/dropped unavailable]\n" : "") + out.text,
|
|
4179
4095
|
stderr: [err.text, stderr.diagnostic].filter(Boolean).join("\n"),
|
|
4180
4096
|
...out.stats ? { outputStats: {
|
|
@@ -4677,18 +4593,6 @@ var RemoteDriver = class {
|
|
|
4677
4593
|
...form.label !== void 0 ? { label: form.label } : {}
|
|
4678
4594
|
}, sessionKey) };
|
|
4679
4595
|
}
|
|
4680
|
-
if (form.form === "hub-pty") {
|
|
4681
|
-
const hub = this.requireHub();
|
|
4682
|
-
this.hubProviders.add(hub);
|
|
4683
|
-
const endpoint = hub.ptyEndpoint(form.machine);
|
|
4684
|
-
const argv = this.opts.hubPtyArgvFor !== void 0 ? this.opts.hubPtyArgvFor(endpoint) : buildHubPtyArgv(endpoint, hubPtyAssets());
|
|
4685
|
-
return { session: this.createSession({
|
|
4686
|
-
argv,
|
|
4687
|
-
connection: `hub:${form.machine}`,
|
|
4688
|
-
byo: false,
|
|
4689
|
-
...form.label !== void 0 ? { label: form.label } : {}
|
|
4690
|
-
}, sessionKey) };
|
|
4691
|
-
}
|
|
4692
4596
|
}
|
|
4693
4597
|
/** background PTY lane:会话先占位(不拨号)再进官方 jobs registry——首回执必须能寻址;
|
|
4694
4598
|
* registry 拒绝时把新建的会话拆掉(复用形的会话绝不拆)。返回 undefined = 走普通 oneshot job。 */
|
|
@@ -4776,48 +4680,13 @@ var RemoteDriver = class {
|
|
|
4776
4680
|
this.pool.get(key)?.attachJob(commandId, jobId);
|
|
4777
4681
|
}
|
|
4778
4682
|
}
|
|
4779
|
-
/** §3.1(2026-10-25)interactive-stdin:input 动作——向已有 live PTY 的当前运行命令
|
|
4780
|
-
* 追加 stdin。只解析池内已存在的会话(pty:<id|label> / 裸 label 命中 pty 项),
|
|
4781
|
-
* 不触发发现、拨号、创建或重连;machine target 与创建形(:pty 后缀,含 hub:<m4>:pty)
|
|
4782
|
-
* 一律明确拒绝并给出可恢复指引。回执附 connection/label 供工具层渲染。 */
|
|
4783
|
-
async input(target, options, callCtx = {}) {
|
|
4784
|
-
this.assertOpen();
|
|
4785
|
-
const sessionKey = callCtx.sessionKey ?? "no-session";
|
|
4786
|
-
const form = parseTargetForm(target);
|
|
4787
|
-
let selector;
|
|
4788
|
-
if (form.form === "pty-selector") {
|
|
4789
|
-
if (form.idOrLabel === void 0) throw new Error("[E_PARAMS] remote: \"pty\" alone selects nothing — append an id/label (pty:pty-1a2b, pty:build)");
|
|
4790
|
-
selector = form.idOrLabel;
|
|
4791
|
-
} else if (form.form === "bare") {
|
|
4792
|
-
if (this.labels.resolve(form.name, sessionKey)?.kind !== "pty") throw new Error(`[E_NO_SESSION] remote: no live PTY session under ${JSON.stringify(form.name)} in this Agent's pool — input never dials, discovers or creates; run {action:"status"} to list sessions, then address one as pty:<id|label>`);
|
|
4793
|
-
selector = form.name;
|
|
4794
|
-
} else if (form.form === "create-pty" || form.form === "hub-pty") throw new Error(`[E_TARGET_FORMAT] remote: input writes into a live PTY session — "${target}" is a creation form; input never creates a session. Start the interactive program with {action:"exec", target:"${target}", cmd:…} first, then input into it`);
|
|
4795
|
-
else throw new Error(`[E_TARGET_FORMAT] remote: input writes into a live PTY session — "${target}" is a machine target and machines are dialed per call. Run the interactive program with exec on a :pty session (e.g. {action:"exec", target:"ssh:dev4:pty" or "hub:<m4>:pty", cmd:…}), then input into that session`);
|
|
4796
|
-
const session = this.resolvePty(selector, sessionKey);
|
|
4797
|
-
const receipt = await session.input(options);
|
|
4798
|
-
const id = session.identity;
|
|
4799
|
-
return {
|
|
4800
|
-
...receipt,
|
|
4801
|
-
connection: id.connection,
|
|
4802
|
-
...id.label !== void 0 ? { label: id.label } : {}
|
|
4803
|
-
};
|
|
4804
|
-
}
|
|
4805
4683
|
async dispatchPty(session, params, callCtx, timeoutSec, cap) {
|
|
4806
|
-
const hubConn = session.identity.connection;
|
|
4807
|
-
if (hubConn.startsWith("hub:")) {
|
|
4808
|
-
const hub = this.requireHub();
|
|
4809
|
-
this.hubProviders.add(hub);
|
|
4810
|
-
hub.ptyEndpoint(hubConn.slice(4));
|
|
4811
|
-
}
|
|
4812
4684
|
const commandId = callCtx.commandId ?? genCommandId();
|
|
4813
4685
|
const r = await session.dispatch(params.cmd, {
|
|
4814
4686
|
stdin: params.stdin,
|
|
4815
4687
|
timeoutSec,
|
|
4816
4688
|
commandId,
|
|
4817
|
-
...callCtx.ptyBackground === true ? {
|
|
4818
|
-
background: true,
|
|
4819
|
-
jobId: commandId
|
|
4820
|
-
} : {}
|
|
4689
|
+
...callCtx.ptyBackground === true ? { background: true } : {}
|
|
4821
4690
|
});
|
|
4822
4691
|
const out = tailWindow(r.output, cap, r.outputStats?.stdout);
|
|
4823
4692
|
const id = session.identity;
|
|
@@ -4936,7 +4805,6 @@ var RemoteDriver = class {
|
|
|
4936
4805
|
connection: display,
|
|
4937
4806
|
cwd: null,
|
|
4938
4807
|
reconnected: false,
|
|
4939
|
-
runner: HUB_RUNNER,
|
|
4940
4808
|
stdout: (!stdout.stats && stdout.lossy ? "[truncated: peer returned only a tail; exact total/retained/dropped unavailable]\n" : "") + out.text,
|
|
4941
4809
|
stderr: [err.text, stderr.diagnostic].filter(Boolean).join("\n"),
|
|
4942
4810
|
...out.stats ? { outputStats: {
|
|
@@ -5094,20 +4962,16 @@ const ptyOutputSources = (holder) => [{
|
|
|
5094
4962
|
lossy: false
|
|
5095
4963
|
}
|
|
5096
4964
|
}];
|
|
5097
|
-
|
|
5098
|
-
|
|
5099
|
-
|
|
5100
|
-
|
|
5101
|
-
return {
|
|
5102
|
-
status: interrupted ? "killed" : result.exit === null ? "failed" : "completed",
|
|
5103
|
-
detail: interrupted ? `interrupted: ${result.exit === null ? aborted ? "session died" : "session closed" : `exit ${result.exit}`}` : result.exit === null ? "session died" : `exit code: ${result.exit}`
|
|
5104
|
-
};
|
|
5105
|
-
};
|
|
4965
|
+
const ptyOutcome = (aborted, result) => ({
|
|
4966
|
+
status: aborted ? "killed" : result.exit === null ? "failed" : "completed",
|
|
4967
|
+
detail: aborted ? `interrupted: ${result.exit === null ? "session died" : `exit ${result.exit}`}` : result.exit === null ? "session died" : `exit code: ${result.exit}`
|
|
4968
|
+
});
|
|
5106
4969
|
/** No preparation or discovery before the official registry's admission. */
|
|
5107
4970
|
function startRemoteJob(registry, input, exec, driver) {
|
|
5108
4971
|
if (!exec.agent) throw new Error("remote: background jobs require a live Agent owner");
|
|
5109
4972
|
const form = parseTargetForm(input.target);
|
|
5110
4973
|
if (form.form === "pty-selector" || form.form === "create-pty" || form.form === "hub-pty" || input.spawn !== void 0 || input.mode === "pty") {
|
|
4974
|
+
if (form.form === "hub-pty") throw new Error("remote: hub-machine PTY has no background lane; use a direct ssh/docker/incus target or a pty session");
|
|
5111
4975
|
const holder = {};
|
|
5112
4976
|
return registry.start({
|
|
5113
4977
|
kind: "remote",
|
|
@@ -5233,157 +5097,116 @@ function adoptRunningPtyJob(registry, exec, label, job) {
|
|
|
5233
5097
|
|
|
5234
5098
|
//#endregion
|
|
5235
5099
|
//#region src/remote/tool.ts
|
|
5236
|
-
const NO_SESSION_KEY
|
|
5100
|
+
const NO_SESSION_KEY = "remote:no-session";
|
|
5237
5101
|
/**
|
|
5238
|
-
* The model-visible surface of the `remote` tool (
|
|
5239
|
-
*
|
|
5240
|
-
* final draft; Examples receipts match renderValue byte-for-byte).
|
|
5241
|
-
* ONE template literal — no backticks, no "${".
|
|
5242
|
-
*
|
|
5243
|
-
* 语义目标(surface 就是产品):动作是唯一的派发维度;PTY 四词定位——stateful oneshot /
|
|
5244
|
-
* interact-able stdin / managed output / persistent env——回执形状与 oneshot 相同、只增状态,
|
|
5245
|
-
* 运行中的命令可分步观察后再补输(input 动作);多步与交互任务默认进 PTY。
|
|
5102
|
+
* The model-visible surface of the `remote` tool (workbench round, plan
|
|
5103
|
+
* 2026-10-19-pty-workbench). ONE template literal — no backticks, no "${".
|
|
5246
5104
|
*/
|
|
5247
|
-
const REMOTE_TOOL_DESCRIPTION = `Remote
|
|
5248
|
-
|
|
5249
|
-
|
|
5250
|
-
|
|
5251
|
-
|
|
5252
|
-
|
|
5253
|
-
|
|
5254
|
-
|
|
5255
|
-
|
|
5256
|
-
|
|
5257
|
-
|
|
5258
|
-
- ssh
|
|
5259
|
-
- <machine>:pty[:label] → NEW PTY every call — creation is a target suffix, not a different tool.
|
|
5260
|
-
- pty:<id|label> (or its bare label) → REUSE the live session.
|
|
5261
|
-
After creation, reuse the returned session; repeating :pty opens another. Prefixes force the channel; bare names resolve through the roster.
|
|
5262
|
-
|
|
5263
|
-
PTY:
|
|
5264
|
-
- stateful oneshot, interact-able stdin, managed output, persistent env
|
|
5265
|
-
- timeout (default 120s) → command keeps running; returns job handle.
|
|
5266
|
-
- run_in_background → same job lane. jobId = commandId.
|
|
5267
|
-
- Read: job_output(jobId). Cancel: job_kill; try Ctrl-C first.
|
|
5268
|
-
- Busy session: input / poll / cancel / use another session.
|
|
5269
|
-
- exec.stdin = startup input; input = subsequent input.
|
|
5270
|
-
- input is verbatim: "\\n" submits; "\\u0003" Ctrl-C; "\\u0004" Ctrl-D; "" writes nothing. Ctrl-D follows terminal EOF rules.
|
|
5271
|
-
- commandId, if supplied, must match running command.
|
|
5272
|
-
- Input receipt confirms write only; read result via SAME job.
|
|
5273
|
-
- Output may truncate (~30k chars per read); redirect bulk output to files.
|
|
5274
|
-
- Idle expiry: 15 min; running commands exempt.
|
|
5275
|
-
|
|
5276
|
-
Oneshot:
|
|
5277
|
-
- stdout/stderr separate, exit code verbatim; no retained shell state. stdin is startup-only: there is no input after start — a prompt on a oneshot means cancel (job_kill) and switch to PTY. Heredocs and input redirection remain valid bulk-input alternatives.
|
|
5278
|
-
- Supervision prefers a pinned superd-run helper cached on the endpoint (~/.cache/superd/run), falls back to a POSIX shim — the receipt field "runner" says which ran. Either way children are reaped on completion and the whole group is killed on timeout/cancel. The endpoint needs bash only — no Python.
|
|
5279
|
-
- scriptPath (oneshot only): a LOCAL absolute script read before any dialing, sent literally over stdin to a fixed bash -s — the file carrier for multi-line scripts; pairs with neither cmd nor stdin.
|
|
5280
|
-
|
|
5281
|
-
Examples
|
|
5282
|
-
|
|
5283
|
-
Manage the persistent PTY session. One-shot style output, but stable: every command returns synchronously with exit, cwd, and an env delta.
|
|
5284
|
-
|
|
5285
|
-
No direct route to the endpoint? Compose the transport and land the session on the innermost machine — bring your own PTY. Here: borrow pc as a jump host to reach dev4.
|
|
5286
|
-
|
|
5287
|
-
remote { "action": "add", "spawn": "ssh -t pc ssh -t dev4", "target": "pty:dev4via" }
|
|
5105
|
+
const REMOTE_TOOL_DESCRIPTION = `Remote execution and file transfer over ssh / docker / incus, plus stateful PTY workbenches. Pick the call shape:
|
|
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).
|
|
5288
5117
|
|
|
5289
|
-
|
|
5290
|
-
[pty:pty-irsq (dev4via) (byo-pty) · conn BYOP · idle-left ~15m · exit null · 0.0s]
|
|
5118
|
+
oneshot: stdout/stderr separate, exit code verbatim. Supervision prefers a pinned superd-run helper cached on the endpoint (~/.cache/superd/run; first call uploads it over the same exec lane) and falls back to a POSIX wrapper everywhere else — the receipt field "runner" says which ran. Helper or wrapper, backgrounded children are reaped on completion and the whole group is killed on timeout/cancel; the endpoint needs bash only (no Python).
|
|
5291
5119
|
|
|
5292
|
-
|
|
5120
|
+
PTY is the stateful superset of oneshot: every command returns synchronously with exit + cwd + an env delta (NAMES only — added/changed/removed; values never leave the endpoint; fetch a value yourself if you need it). A command that outlives its timeout is NOT killed — it hands off as a running job: poll job_output <commandId>, cancel with job_kill (Ctrl-C first; the session survives). run_in_background uses the same lane and its jobId IS the commandId. A busy session rejects new commands ([E_SESSION_BUSY] names the running id and the ways out: job_output / job_kill / another session or oneshot).
|
|
5293
5121
|
|
|
5294
|
-
|
|
5295
|
-
State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
|
|
5296
|
-
LISTEN 0 4096 127.0.0.1:45207 0.0.0.0:* users:(("containerd",pid=1070,fd=14))
|
|
5297
|
-
LISTEN 0 4096 127.0.0.1:2019 0.0.0.0:* users:(("caddy",pid=5600,fd=4))
|
|
5298
|
-
LISTEN 0 4096 127.0.0.1:19514 0.0.0.0:* users:(("multica",pid=27030,fd=6))
|
|
5299
|
-
LISTEN 0 4096 127.0.0.54:53 0.0.0.0:* users:(("systemd-resolve",pid=26758,fd=17))
|
|
5300
|
-
[pty:pty-irsq (dev4via) (byo-pty) · conn BYOP · cmd remote-0d89215d1cc64cdc · cwd /home/u1 · idle-left ~15m · exit 0 · 0.3s]
|
|
5301
|
-
|
|
5302
|
-
Reuse the same session for a long task — returns a jobId immediately; collect with job_output, cancel with job_kill:
|
|
5303
|
-
|
|
5304
|
-
remote { "action": "exec", "target": "pty:dev4via", "cmd": "for i in 1 2 3; do sleep 1; echo \\"tick $i\\"; done; echo done", "run_in_background": true }
|
|
5305
|
-
|
|
5306
|
-
Started remote-47676dff2c6443e8 on pty:pty-irsq (dev4via) (BYOP). Use job_output to read and job_kill to cancel; completion is reported by the host.
|
|
5307
|
-
|
|
5308
|
-
job_output { "job_id": "remote-47676dff2c6443e8", "wait": true }
|
|
5309
|
-
|
|
5310
|
-
tick 1
|
|
5311
|
-
tick 2
|
|
5312
|
-
tick 3
|
|
5313
|
-
done
|
|
5314
|
-
[status: completed, exit code: 0]
|
|
5315
|
-
|
|
5316
|
-
- Bring Your Own PTY fits when the endpoint has no direct route, or the connection/transport layer must be composed yourself (jump chains, docker exec, console servers).
|
|
5317
|
-
- It also strips one or two shell-escaping layers — commands go straight to the innermost bash, quotes behave naturally.
|
|
5318
|
-
|
|
5319
|
-
Register once — the label enters the pool and works everywhere: exec, pty, copy, rsync. Worth it whenever more than one operation type is involved. Keys stay local-file references; nothing persisted.
|
|
5320
|
-
|
|
5321
|
-
remote { "action": "add", "ssh": { "host": "10.0.0.8", "user": "u1", "port": 22, "key": "/home/u1/.ssh/id_key4" }, "target": "ssh:lab" }
|
|
5322
|
-
remote { "action": "exec", "target": "ssh:lab", "cmd": "uname -a" }
|
|
5323
|
-
remote_transfer { "mode": "rsync", "src": "ssh:lab:/var/log/app/", "dst": "/home/u1/app-logs/", "argv": ["-a"] }`;
|
|
5324
|
-
/** action 是唯一派发维度:每个动词声明自己的字段集,超出即报错(不做在场推断、不留别名)。 */
|
|
5325
|
-
const ACTION_PARAMS = {
|
|
5326
|
-
exec: new Set([
|
|
5327
|
-
"action",
|
|
5328
|
-
"target",
|
|
5329
|
-
"cmd",
|
|
5330
|
-
"stdin",
|
|
5331
|
-
"scriptPath",
|
|
5332
|
-
"timeout",
|
|
5333
|
-
"run_in_background"
|
|
5334
|
-
]),
|
|
5335
|
-
add: new Set([
|
|
5336
|
-
"action",
|
|
5337
|
-
"target",
|
|
5338
|
-
"ssh",
|
|
5339
|
-
"spawn"
|
|
5340
|
-
]),
|
|
5341
|
-
status: new Set(["action", "target"]),
|
|
5342
|
-
close: new Set([
|
|
5343
|
-
"action",
|
|
5344
|
-
"target",
|
|
5345
|
-
"force"
|
|
5346
|
-
]),
|
|
5347
|
-
input: new Set([
|
|
5348
|
-
"action",
|
|
5349
|
-
"target",
|
|
5350
|
-
"stdin",
|
|
5351
|
-
"commandId"
|
|
5352
|
-
])
|
|
5353
|
-
};
|
|
5354
|
-
const ACTION_HINT = {
|
|
5355
|
-
exec: "exec takes target plus cmd (or scriptPath) and the execution options; ssh/spawn belong to add",
|
|
5356
|
-
add: "add takes exactly one of ssh or spawn, plus an optional target label",
|
|
5357
|
-
status: "status takes only an optional target — omit it for the roster",
|
|
5358
|
-
close: "close takes target pty:<id|label> and optional force",
|
|
5359
|
-
input: "input takes target (a live PTY session), stdin, and an optional commandId guard — no cmd, no timeout, no creation"
|
|
5360
|
-
};
|
|
5122
|
+
Output: pty tails at ~30k chars ("[truncated: …]" header) — redirect bulk output to a file. Hard retention 4,000,000 UTF-16 units per stream (Hub 4MiB bytes); outputStats reports the rest; restart cannot recover discarded output. stdin feeds the command (pty: REPL-style right after the command line — only helps commands that read stdin there). timeout (seconds, default 120): oneshot interrupts then kills the whole group; pty hands off. Native transport errors pass through verbatim — self-correct from them.`;
|
|
5361
5123
|
async function remoteExecute(ENV, args, exec) {
|
|
5362
|
-
|
|
5363
|
-
|
|
5364
|
-
|
|
5365
|
-
|
|
5366
|
-
|
|
5367
|
-
|
|
5368
|
-
|
|
5369
|
-
|
|
5370
|
-
|
|
5371
|
-
|
|
5372
|
-
|
|
5373
|
-
|
|
5374
|
-
|
|
5375
|
-
|
|
5376
|
-
|
|
5124
|
+
if (args.action === "copy" || args.action === "rsync") {
|
|
5125
|
+
if (!args.src || !args.dst) throw new Error(`${args.action} requires src and dst absolute endpoints`);
|
|
5126
|
+
if ([
|
|
5127
|
+
args.cmd,
|
|
5128
|
+
args.scriptPath,
|
|
5129
|
+
args.stdin,
|
|
5130
|
+
args.spawn,
|
|
5131
|
+
args.ssh,
|
|
5132
|
+
args.target,
|
|
5133
|
+
args.timeout,
|
|
5134
|
+
args.run_in_background,
|
|
5135
|
+
args.force
|
|
5136
|
+
].some((value) => value !== void 0)) throw new Error(`${args.action} cannot be combined with execution/PTY/job arguments`);
|
|
5137
|
+
const KNOWN_PARAMS = new Set([
|
|
5138
|
+
"action",
|
|
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;
|
|
5377
5190
|
return {
|
|
5378
|
-
kind: "
|
|
5379
|
-
text:
|
|
5380
|
-
|
|
5381
|
-
|
|
5191
|
+
kind: "copy",
|
|
5192
|
+
text: formatTransferLine("copy", receipt, copyDuration),
|
|
5193
|
+
receipt,
|
|
5194
|
+
exit: exits.length > 0 ? 0 : null,
|
|
5195
|
+
durationMs: copyDuration
|
|
5382
5196
|
};
|
|
5383
5197
|
}
|
|
5384
|
-
if (action === "close") {
|
|
5385
|
-
if (
|
|
5386
|
-
|
|
5198
|
+
if (args.action === "close") {
|
|
5199
|
+
if (typeof args.target !== "string" || args.target.length === 0) throw new Error("close requires target pty:<id|label>");
|
|
5200
|
+
if ([
|
|
5201
|
+
args.cmd,
|
|
5202
|
+
args.scriptPath,
|
|
5203
|
+
args.stdin,
|
|
5204
|
+
args.spawn,
|
|
5205
|
+
args.ssh,
|
|
5206
|
+
args.timeout,
|
|
5207
|
+
args.run_in_background
|
|
5208
|
+
].some((value) => value !== void 0)) throw new Error("[E_PARAMS] close cannot be combined with execution/PTY/job arguments");
|
|
5209
|
+
const r$1 = ENV.driver.closePty(args.target.startsWith("pty:") ? args.target.slice(4) : args.target, ENV.getSessionKey(exec), args.force === true);
|
|
5387
5210
|
return {
|
|
5388
5211
|
kind: "close",
|
|
5389
5212
|
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"}`,
|
|
@@ -5395,71 +5218,42 @@ async function remoteExecute(ENV, args, exec) {
|
|
|
5395
5218
|
killed: r$1.killed
|
|
5396
5219
|
};
|
|
5397
5220
|
}
|
|
5398
|
-
if (
|
|
5399
|
-
|
|
5400
|
-
|
|
5401
|
-
|
|
5402
|
-
|
|
5403
|
-
|
|
5404
|
-
|
|
5405
|
-
|
|
5406
|
-
|
|
5407
|
-
|
|
5408
|
-
|
|
5409
|
-
|
|
5410
|
-
|
|
5411
|
-
|
|
5412
|
-
|
|
5413
|
-
connection: r$1.connection,
|
|
5414
|
-
...r$1.label !== void 0 ? { label: r$1.label } : {},
|
|
5415
|
-
commandId: r$1.commandId,
|
|
5416
|
-
...r$1.jobId !== void 0 ? { jobId: r$1.jobId } : {},
|
|
5417
|
-
writtenBytes: r$1.writtenBytes
|
|
5418
|
-
};
|
|
5419
|
-
}
|
|
5420
|
-
if (action === "add") {
|
|
5421
|
-
const hasSpawn = typeof args.spawn === "string" && args.spawn.length > 0;
|
|
5422
|
-
if (args.spawn !== void 0 && !hasSpawn) throw new Error("[E_PARAMS] remote: add: spawn must be a non-empty command string");
|
|
5423
|
-
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");
|
|
5424
|
-
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");
|
|
5425
|
-
const r$1 = await ENV.driver.call({
|
|
5426
|
-
...hasTarget ? { target: args.target } : {},
|
|
5427
|
-
...args.ssh === void 0 ? {} : { ssh: args.ssh },
|
|
5428
|
-
...hasSpawn ? { spawn: args.spawn } : {},
|
|
5429
|
-
cmd: ""
|
|
5430
|
-
}, { sessionKey });
|
|
5431
|
-
const v$1 = workbenchValue(r$1);
|
|
5432
|
-
v$1.created = true;
|
|
5433
|
-
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)`;
|
|
5434
|
-
return v$1;
|
|
5435
|
-
}
|
|
5436
|
-
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\".");
|
|
5437
|
-
const target = args.target;
|
|
5438
|
-
const ptyTarget = /^pty:/.test(target) || /:pty(:|$)/.test(target);
|
|
5439
|
-
if (args.scriptPath !== void 0) {
|
|
5440
|
-
if (args.scriptPath.length === 0) throw new Error("[E_PARAMS] remote: scriptPath must be a non-empty absolute path");
|
|
5441
|
-
if (ptyTarget) throw new Error("[E_SCRIPT_PARAMS] remote: scriptPath is oneshot-only — PTY lanes take cmd");
|
|
5442
|
-
if (hasCmd) throw new Error("[E_SCRIPT_PARAMS] remote: scriptPath and cmd are alternative carriers — pass one");
|
|
5443
|
-
if (args.stdin !== void 0) throw new Error("[E_SCRIPT_PARAMS] remote: scriptPath already occupies stdin — do not pass stdin with it");
|
|
5444
|
-
}
|
|
5445
|
-
if (args.stdin !== void 0 && !hasCmd) throw new Error("[E_STDIN_WITHOUT_CMD] remote: exec stdin is startup input and requires cmd — to feed a command that is already running in a PTY, use {action:\"input\", target:\"pty:<id|label>\", stdin}");
|
|
5446
|
-
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");
|
|
5221
|
+
if ([
|
|
5222
|
+
args.src,
|
|
5223
|
+
args.dst,
|
|
5224
|
+
args.argv,
|
|
5225
|
+
args.pullArgv,
|
|
5226
|
+
args.pushArgv,
|
|
5227
|
+
args.verify,
|
|
5228
|
+
args.mkdir,
|
|
5229
|
+
args.force
|
|
5230
|
+
].some((value) => value !== void 0)) throw new Error("file/force fields require an action (copy / rsync / close)");
|
|
5231
|
+
const hasCmd = typeof args.cmd === "string" && args.cmd.length > 0;
|
|
5232
|
+
const hasTarget = typeof args.target === "string" && args.target.length > 0;
|
|
5233
|
+
const hasSpawn = typeof args.spawn === "string" && args.spawn.length > 0;
|
|
5234
|
+
if (args.spawn !== void 0 && !hasSpawn) throw new Error("[E_PARAMS] remote: spawn must be a non-empty command string");
|
|
5235
|
+
if (args.scriptPath !== void 0 && (hasSpawn || /^pty:/.test(args.target ?? "") || /:pty/.test(args.target ?? ""))) throw new Error("[E_SCRIPT_PARAMS] remote: scriptPath is oneshot-only — PTY lanes take cmd");
|
|
5447
5236
|
if (args.run_in_background) {
|
|
5237
|
+
if (!hasCmd && args.scriptPath === void 0) throw new Error("remote: background requires cmd or scriptPath");
|
|
5448
5238
|
const jobs = exec.agent?.ctx.get("jobs");
|
|
5449
5239
|
if (!jobs) throw new Error("remote: background requires the official DSH jobs service and job_output/job_kill controller; foreground execution remains available");
|
|
5450
5240
|
const lane = await ENV.driver.preparePtyLane({
|
|
5451
5241
|
target: args.target,
|
|
5242
|
+
spawn: args.spawn,
|
|
5452
5243
|
cmd: args.cmd,
|
|
5453
5244
|
scriptPath: args.scriptPath,
|
|
5245
|
+
ssh: args.ssh,
|
|
5454
5246
|
stdin: args.stdin,
|
|
5455
5247
|
timeout: args.timeout
|
|
5456
|
-
}, { sessionKey });
|
|
5248
|
+
}, { sessionKey: ENV.getSessionKey(exec) });
|
|
5457
5249
|
let jobId;
|
|
5458
5250
|
try {
|
|
5459
5251
|
jobId = startRemoteJob(jobs, lane !== void 0 ? lane.input : {
|
|
5460
5252
|
target: args.target,
|
|
5253
|
+
spawn: args.spawn,
|
|
5461
5254
|
cmd: args.cmd,
|
|
5462
5255
|
scriptPath: args.scriptPath,
|
|
5256
|
+
ssh: args.ssh,
|
|
5463
5257
|
stdin: args.stdin,
|
|
5464
5258
|
timeout: args.timeout
|
|
5465
5259
|
}, exec, ENV.driver);
|
|
@@ -5475,20 +5269,44 @@ async function remoteExecute(ENV, args, exec) {
|
|
|
5475
5269
|
durationMs: 0,
|
|
5476
5270
|
text: `Started ${jobId}${where}. Use job_output to read and job_kill to cancel; completion is reported by the host.`,
|
|
5477
5271
|
...lane !== void 0 ? {
|
|
5478
|
-
commandId: jobId,
|
|
5479
5272
|
session: lane.session,
|
|
5480
5273
|
connection: lane.connection,
|
|
5481
5274
|
...lane.label !== void 0 ? { label: lane.label } : {}
|
|
5482
5275
|
} : {}
|
|
5483
5276
|
};
|
|
5484
5277
|
}
|
|
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
|
+
}
|
|
5485
5301
|
const r = await ENV.driver.call({
|
|
5486
5302
|
target: args.target,
|
|
5303
|
+
spawn: args.spawn,
|
|
5304
|
+
ssh: args.ssh,
|
|
5487
5305
|
cmd: args.cmd,
|
|
5488
5306
|
scriptPath: args.scriptPath,
|
|
5489
5307
|
stdin: args.stdin,
|
|
5490
5308
|
timeout: args.timeout
|
|
5491
|
-
}, { sessionKey });
|
|
5309
|
+
}, { sessionKey: ENV.getSessionKey(exec) });
|
|
5492
5310
|
const v = workbenchValue(r);
|
|
5493
5311
|
if (r.running === true && r.ptyJob !== void 0) {
|
|
5494
5312
|
const jobs = exec.agent?.ctx.get("jobs");
|
|
@@ -5496,7 +5314,7 @@ async function remoteExecute(ENV, args, exec) {
|
|
|
5496
5314
|
const jobId = adoptRunningPtyJob(jobs, exec, `${r.target}: ${args.cmd}`, r.ptyJob);
|
|
5497
5315
|
v.jobId = jobId;
|
|
5498
5316
|
v.running = true;
|
|
5499
|
-
if (r.commandId !== void 0 && jobId !== r.commandId) ENV.driver.attachPtyJob(r.commandId, jobId,
|
|
5317
|
+
if (r.commandId !== void 0 && jobId !== r.commandId) ENV.driver.attachPtyJob(r.commandId, jobId, ENV.getSessionKey(exec));
|
|
5500
5318
|
} else {
|
|
5501
5319
|
r.ptyJob.interrupt();
|
|
5502
5320
|
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.";
|
|
@@ -5505,7 +5323,7 @@ async function remoteExecute(ENV, args, exec) {
|
|
|
5505
5323
|
return v;
|
|
5506
5324
|
}
|
|
5507
5325
|
function createRemoteTool(driver, opts = {}) {
|
|
5508
|
-
const getSessionKey = opts.getSessionKey ?? ((exec) => exec.agent?.id ?? NO_SESSION_KEY
|
|
5326
|
+
const getSessionKey = opts.getSessionKey ?? ((exec) => exec.agent?.id ?? NO_SESSION_KEY);
|
|
5509
5327
|
const tool = defineTool({
|
|
5510
5328
|
name: "remote",
|
|
5511
5329
|
description: REMOTE_TOOL_DESCRIPTION,
|
|
@@ -5513,42 +5331,58 @@ function createRemoteTool(driver, opts = {}) {
|
|
|
5513
5331
|
action: {
|
|
5514
5332
|
type: "string",
|
|
5515
5333
|
enum: [
|
|
5516
|
-
"
|
|
5517
|
-
"
|
|
5518
|
-
"
|
|
5519
|
-
"close",
|
|
5520
|
-
"input"
|
|
5334
|
+
"copy",
|
|
5335
|
+
"rsync",
|
|
5336
|
+
"close"
|
|
5521
5337
|
],
|
|
5522
|
-
|
|
5523
|
-
description: "Required. exec = run a command (oneshot on a machine, or inside a PTY session); 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 session; input = append stdin to the command currently running in a live PTY session."
|
|
5338
|
+
description: "Optional action; omit for execution/status/roster. close takes target + optional force."
|
|
5524
5339
|
},
|
|
5525
|
-
|
|
5526
|
-
type: "
|
|
5527
|
-
description: "
|
|
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."
|
|
5528
5343
|
},
|
|
5529
|
-
|
|
5344
|
+
src: {
|
|
5530
5345
|
type: "string",
|
|
5531
|
-
description: "
|
|
5346
|
+
description: "copy/rsync source [target:]absolute path; rsync retains trailing-slash semantics"
|
|
5532
5347
|
},
|
|
5533
|
-
|
|
5348
|
+
dst: {
|
|
5534
5349
|
type: "string",
|
|
5535
|
-
description: "
|
|
5350
|
+
description: "copy/rsync destination [target:]absolute path"
|
|
5536
5351
|
},
|
|
5537
|
-
|
|
5538
|
-
type: "
|
|
5539
|
-
|
|
5352
|
+
argv: {
|
|
5353
|
+
type: "array",
|
|
5354
|
+
items: { type: "string" },
|
|
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."
|
|
5540
5356
|
},
|
|
5541
|
-
|
|
5542
|
-
type: "
|
|
5543
|
-
|
|
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."
|
|
5361
|
+
},
|
|
5362
|
+
pushArgv: {
|
|
5363
|
+
type: "array",
|
|
5364
|
+
items: { type: "string" },
|
|
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."
|
|
5366
|
+
},
|
|
5367
|
+
verify: {
|
|
5368
|
+
type: "boolean",
|
|
5369
|
+
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."
|
|
5370
|
+
},
|
|
5371
|
+
mkdir: {
|
|
5372
|
+
type: "boolean",
|
|
5373
|
+
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."
|
|
5544
5374
|
},
|
|
5545
5375
|
run_in_background: {
|
|
5546
5376
|
type: "boolean",
|
|
5547
|
-
description: "
|
|
5377
|
+
description: "oneshot AND pty: return a native jobId immediately, read with job_output, cancel with job_kill; output is available while running. On pty lanes the jobId IS the commandId — it tracks the running command inside a persistent session; job_kill sends Ctrl-C and the session survives. Hub-machine PTY has no background lane. Requires the official jobs service/controller."
|
|
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)."
|
|
5548
5382
|
},
|
|
5549
5383
|
ssh: {
|
|
5550
5384
|
type: "object",
|
|
5551
|
-
description: "
|
|
5385
|
+
description: "BYO ssh registration — the field present IS the entry point. First call registers (no cmd = register only); later calls reuse the label with ssh omitted. No inline keys or arbitrary SSH options.",
|
|
5552
5386
|
additionalProperties: false,
|
|
5553
5387
|
properties: {
|
|
5554
5388
|
host: {
|
|
@@ -5576,11 +5410,23 @@ function createRemoteTool(driver, opts = {}) {
|
|
|
5576
5410
|
},
|
|
5577
5411
|
spawn: {
|
|
5578
5412
|
type: "string",
|
|
5579
|
-
description: "
|
|
5413
|
+
description: "BYO pty creation — the field present IS the entry point: the full command that starts an interactive shell (e.g. \"docker exec -it img bash\"). Pairs with target pty[:<label>] / a bare <label> / nothing (auto id). Nested hops are dumb pipes (only the innermost bash frames)."
|
|
5580
5414
|
},
|
|
5581
|
-
|
|
5582
|
-
type: "
|
|
5583
|
-
description: "
|
|
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."
|
|
5422
|
+
},
|
|
5423
|
+
stdin: {
|
|
5424
|
+
type: "string",
|
|
5425
|
+
description: "Input fed to the command. oneshot: piped to stdin. pty: written right after the command line (REPL-style). Requires cmd."
|
|
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)."
|
|
5584
5430
|
}
|
|
5585
5431
|
},
|
|
5586
5432
|
output: {
|
|
@@ -5596,8 +5442,9 @@ function createRemoteTool(driver, opts = {}) {
|
|
|
5596
5442
|
"status",
|
|
5597
5443
|
"roster",
|
|
5598
5444
|
"job",
|
|
5599
|
-
"
|
|
5600
|
-
"
|
|
5445
|
+
"copy",
|
|
5446
|
+
"rsync",
|
|
5447
|
+
"close"
|
|
5601
5448
|
]
|
|
5602
5449
|
},
|
|
5603
5450
|
text: {
|
|
@@ -5629,10 +5476,6 @@ function createRemoteTool(driver, opts = {}) {
|
|
|
5629
5476
|
type: "string",
|
|
5630
5477
|
description: "ledger key; equals the jobId whenever a job exists"
|
|
5631
5478
|
},
|
|
5632
|
-
writtenBytes: {
|
|
5633
|
-
type: "integer",
|
|
5634
|
-
description: "input: UTF-8 bytes actually written; confirms the write, not the program response"
|
|
5635
|
-
},
|
|
5636
5479
|
envDelta: {
|
|
5637
5480
|
type: "json",
|
|
5638
5481
|
description: "names only: {added,changed,removed}"
|
|
@@ -5641,7 +5484,7 @@ function createRemoteTool(driver, opts = {}) {
|
|
|
5641
5484
|
notice: { type: "string" },
|
|
5642
5485
|
runner: {
|
|
5643
5486
|
type: "string",
|
|
5644
|
-
description: "oneshot supervision path: superd-run <ver> (<triple>)
|
|
5487
|
+
description: "oneshot supervision path: superd-run <ver> (<triple>) or posix-shim"
|
|
5645
5488
|
},
|
|
5646
5489
|
running: { type: "boolean" },
|
|
5647
5490
|
ttlSec: { type: "integer" },
|
|
@@ -5675,7 +5518,7 @@ function createRemoteTool(driver, opts = {}) {
|
|
|
5675
5518
|
const description = tool.description;
|
|
5676
5519
|
Object.defineProperty(tool, "description", {
|
|
5677
5520
|
enumerable: true,
|
|
5678
|
-
get: () => description + (driver.hasHub() ? "\nHub transport available: hub:<m4> selects a paired remote machine (never self
|
|
5521
|
+
get: () => description + (driver.hasHub() ? "\nHub transport available: hub:<m4> selects a paired remote machine (never self). {} includes the Hub directory even when empty. Uses authenticated Hub HTTP oneshot / persistent WS PTY (hub:<m4>:pty selects the PTY lane); no SSH alias needed. Hub failures never fall back to SSH." : "")
|
|
5679
5522
|
});
|
|
5680
5523
|
return tool;
|
|
5681
5524
|
}
|
|
@@ -5706,6 +5549,26 @@ function workbenchValue(r) {
|
|
|
5706
5549
|
if (r.ttlSec !== void 0) v.ttlSec = r.ttlSec;
|
|
5707
5550
|
return v;
|
|
5708
5551
|
}
|
|
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
|
+
}
|
|
5709
5572
|
function renderEnvDelta(d) {
|
|
5710
5573
|
const bits = [];
|
|
5711
5574
|
if (d.added.length > 0) bits.push(`+${d.added.join(",+")}`);
|
|
@@ -5714,7 +5577,7 @@ function renderEnvDelta(d) {
|
|
|
5714
5577
|
return bits.length > 0 ? bits.join(" ") : void 0;
|
|
5715
5578
|
}
|
|
5716
5579
|
function renderValue(v) {
|
|
5717
|
-
if (v.kind === "status" || v.kind === "roster" || v.kind === "job" || v.kind === "
|
|
5580
|
+
if (v.kind === "status" || v.kind === "roster" || v.kind === "job" || v.kind === "copy" || v.kind === "rsync" || v.kind === "close") return v.text;
|
|
5718
5581
|
const parts = [];
|
|
5719
5582
|
if (v.reconnected === true) parts.push(RECONNECT_NOTICE);
|
|
5720
5583
|
if (typeof v.notice === "string" && v.notice.length > 0) parts.push(v.notice);
|
|
@@ -5742,181 +5605,8 @@ function renderValue(v) {
|
|
|
5742
5605
|
return parts.join("\n");
|
|
5743
5606
|
}
|
|
5744
5607
|
|
|
5745
|
-
//#endregion
|
|
5746
|
-
//#region src/remote/transfer-tool.ts
|
|
5747
|
-
const NO_SESSION_KEY = "remote:no-session";
|
|
5748
|
-
/**
|
|
5749
|
-
* The model-visible surface of the `remote_transfer` tool (split out of
|
|
5750
|
-
* `remote`, 2026-10-25). ONE template literal — no backticks, no "${".
|
|
5751
|
-
*/
|
|
5752
|
-
const TRANSFER_TOOL_DESCRIPTION = `File transfer between any two supported endpoints. Every call declares its mode:
|
|
5753
|
-
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.
|
|
5754
|
-
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.
|
|
5755
|
-
|
|
5756
|
-
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.
|
|
5757
|
-
|
|
5758
|
-
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.
|
|
5759
|
-
|
|
5760
|
-
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.
|
|
5761
|
-
|
|
5762
|
-
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.`;
|
|
5763
|
-
const KNOWN_PARAMS = new Set([
|
|
5764
|
-
"mode",
|
|
5765
|
-
"src",
|
|
5766
|
-
"dst",
|
|
5767
|
-
"argv",
|
|
5768
|
-
"pullArgv",
|
|
5769
|
-
"pushArgv",
|
|
5770
|
-
"verify",
|
|
5771
|
-
"mkdir"
|
|
5772
|
-
]);
|
|
5773
|
-
async function transferExecute(ENV, args, exec) {
|
|
5774
|
-
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`);
|
|
5775
|
-
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)`);
|
|
5776
|
-
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`);
|
|
5777
|
-
const sessionKey = ENV.getSessionKey(exec);
|
|
5778
|
-
if (args.mode === "rsync") {
|
|
5779
|
-
if (args.pullArgv !== void 0 || args.pushArgv !== void 0) throw new Error("remote_transfer: rsync takes argv only; pullArgv/pushArgv are copy-only fields");
|
|
5780
|
-
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");
|
|
5781
|
-
const start$1 = Date.now();
|
|
5782
|
-
const receipt$1 = await ENV.driver.rsync({
|
|
5783
|
-
src: args.src,
|
|
5784
|
-
dst: args.dst,
|
|
5785
|
-
...args.argv === void 0 ? {} : { argv: args.argv }
|
|
5786
|
-
}, exec.signal, { sessionKey });
|
|
5787
|
-
if (receipt$1.ok === false || receipt$1.exit !== 0) throw new Error(JSON.stringify(receipt$1));
|
|
5788
|
-
const durationMs$1 = Date.now() - start$1;
|
|
5789
|
-
return {
|
|
5790
|
-
kind: "rsync",
|
|
5791
|
-
text: formatTransferLine("rsync", receipt$1, durationMs$1),
|
|
5792
|
-
receipt: receipt$1,
|
|
5793
|
-
exit: receipt$1.exit,
|
|
5794
|
-
durationMs: durationMs$1
|
|
5795
|
-
};
|
|
5796
|
-
}
|
|
5797
|
-
const start = Date.now();
|
|
5798
|
-
const receipt = await ENV.driver.copy({
|
|
5799
|
-
src: args.src,
|
|
5800
|
-
dst: args.dst,
|
|
5801
|
-
...args.verify === void 0 ? {} : { verify: args.verify },
|
|
5802
|
-
...args.mkdir === void 0 ? {} : { mkdir: args.mkdir },
|
|
5803
|
-
...args.argv === void 0 ? {} : { argv: args.argv },
|
|
5804
|
-
...args.pullArgv === void 0 ? {} : { pullArgv: args.pullArgv },
|
|
5805
|
-
...args.pushArgv === void 0 ? {} : { pushArgv: args.pushArgv }
|
|
5806
|
-
}, exec.signal, { sessionKey });
|
|
5807
|
-
const asReceipt = receipt;
|
|
5808
|
-
const exits = asReceipt.exits ?? [];
|
|
5809
|
-
if (asReceipt.ok === false || exits.some((code) => code !== 0)) throw new Error(JSON.stringify(receipt));
|
|
5810
|
-
const durationMs = Date.now() - start;
|
|
5811
|
-
return {
|
|
5812
|
-
kind: "copy",
|
|
5813
|
-
text: formatTransferLine("copy", receipt, durationMs),
|
|
5814
|
-
receipt,
|
|
5815
|
-
exit: exits.length > 0 ? 0 : null,
|
|
5816
|
-
durationMs
|
|
5817
|
-
};
|
|
5818
|
-
}
|
|
5819
|
-
function createRemoteTransferTool(driver, opts = {}) {
|
|
5820
|
-
const getSessionKey = opts.getSessionKey ?? ((exec) => exec.agent?.id ?? NO_SESSION_KEY);
|
|
5821
|
-
return defineTool({
|
|
5822
|
-
name: "remote_transfer",
|
|
5823
|
-
description: TRANSFER_TOOL_DESCRIPTION,
|
|
5824
|
-
parameters: {
|
|
5825
|
-
mode: {
|
|
5826
|
-
type: "string",
|
|
5827
|
-
enum: ["copy", "rsync"],
|
|
5828
|
-
required: true,
|
|
5829
|
-
description: "Required. copy = tree copy through each endpoint's native CLI, or the Hub binary lane; rsync = SSH only (argv only, no verify/mkdir)."
|
|
5830
|
-
},
|
|
5831
|
-
src: {
|
|
5832
|
-
type: "string",
|
|
5833
|
-
description: "Source [target:]absolute path. No target = the machine running this Agent."
|
|
5834
|
-
},
|
|
5835
|
-
dst: {
|
|
5836
|
-
type: "string",
|
|
5837
|
-
description: "Destination [target:]absolute path. No target = the machine running this Agent."
|
|
5838
|
-
},
|
|
5839
|
-
argv: {
|
|
5840
|
-
type: "array",
|
|
5841
|
-
items: { type: "string" },
|
|
5842
|
-
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."
|
|
5843
|
-
},
|
|
5844
|
-
pullArgv: {
|
|
5845
|
-
type: "array",
|
|
5846
|
-
items: { type: "string" },
|
|
5847
|
-
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."
|
|
5848
|
-
},
|
|
5849
|
-
pushArgv: {
|
|
5850
|
-
type: "array",
|
|
5851
|
-
items: { type: "string" },
|
|
5852
|
-
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."
|
|
5853
|
-
},
|
|
5854
|
-
verify: {
|
|
5855
|
-
type: "boolean",
|
|
5856
|
-
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."
|
|
5857
|
-
},
|
|
5858
|
-
mkdir: {
|
|
5859
|
-
type: "boolean",
|
|
5860
|
-
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."
|
|
5861
|
-
}
|
|
5862
|
-
},
|
|
5863
|
-
output: {
|
|
5864
|
-
schema: {
|
|
5865
|
-
type: "object",
|
|
5866
|
-
additionalProperties: false,
|
|
5867
|
-
properties: {
|
|
5868
|
-
kind: {
|
|
5869
|
-
type: "string",
|
|
5870
|
-
required: true,
|
|
5871
|
-
enum: ["copy", "rsync"]
|
|
5872
|
-
},
|
|
5873
|
-
text: {
|
|
5874
|
-
type: "string",
|
|
5875
|
-
required: true
|
|
5876
|
-
},
|
|
5877
|
-
receipt: { type: "json" },
|
|
5878
|
-
exit: { oneOf: [{ type: "integer" }, { type: "null" }] },
|
|
5879
|
-
durationMs: { type: "integer" }
|
|
5880
|
-
}
|
|
5881
|
-
},
|
|
5882
|
-
render: (_args, value) => [{
|
|
5883
|
-
type: "text",
|
|
5884
|
-
text: value.text
|
|
5885
|
-
}]
|
|
5886
|
-
},
|
|
5887
|
-
async execute(args, exec) {
|
|
5888
|
-
return transferExecute({
|
|
5889
|
-
driver,
|
|
5890
|
-
getSessionKey
|
|
5891
|
-
}, args, exec);
|
|
5892
|
-
}
|
|
5893
|
-
});
|
|
5894
|
-
}
|
|
5895
|
-
/** S8 (2026-10-17): copy/rsync render as one formatted line; the full receipt
|
|
5896
|
-
* stays in the receipt field — raw-JSON dumps are gone from text. */
|
|
5897
|
-
function formatTransferLine(mode, receipt, durationMs) {
|
|
5898
|
-
const r = receipt;
|
|
5899
|
-
const parts = [];
|
|
5900
|
-
if (r.ok === false) parts.push(`✗ ${r.reason ?? "failed"}`);
|
|
5901
|
-
parts.push(`${mode} ${r.src} → ${r.dst}`);
|
|
5902
|
-
if (r.route !== void 0 && r.route !== "") parts.push(`route ${r.route}`);
|
|
5903
|
-
if (r.exits !== void 0) parts.push(`exits [${r.exits.join(",")}]`);
|
|
5904
|
-
else if (r.exit !== void 0) parts.push(`exit ${r.exit}`);
|
|
5905
|
-
if (r.verification !== void 0) parts.push(`verification ${r.verification}`);
|
|
5906
|
-
if (r.created === true) parts.push("created yes");
|
|
5907
|
-
else if (r.created === false) parts.push("created pre-existing");
|
|
5908
|
-
else if (r.created === "n-a") parts.push("created n-a");
|
|
5909
|
-
if (r.cleanup !== void 0 && r.cleanup !== "none-required") parts.push(`cleanup ${r.cleanup}`);
|
|
5910
|
-
if (typeof r.bytes === "number") parts.push(`${r.bytes} bytes / ${r.entries} entries`);
|
|
5911
|
-
parts.push(`${(durationMs / 1e3).toFixed(1)}s`);
|
|
5912
|
-
if (r.note !== void 0 && r.note.length > 0) parts.push(`(${r.note})`);
|
|
5913
|
-
return parts.join(" · ");
|
|
5914
|
-
}
|
|
5915
|
-
|
|
5916
5608
|
//#endregion
|
|
5917
5609
|
//#region src/remote/agent-registration.ts
|
|
5918
|
-
/** The pair a row contributes: execution/PTY and file transfer are separate tools. */
|
|
5919
|
-
const TOOL_NAMES = ["remote", "remote_transfer"];
|
|
5920
5610
|
/** Host listeners attempt once per official creation edge, never on vacancy. */
|
|
5921
5611
|
function installAgentRemote(ctx, config) {
|
|
5922
5612
|
return ctx.on("agent/created", ({ agent }) => {
|
|
@@ -5926,14 +5616,12 @@ function installAgentRemote(ctx, config) {
|
|
|
5926
5616
|
...config,
|
|
5927
5617
|
getHub: () => agent.ctx.get("superdRemoteTransport")
|
|
5928
5618
|
});
|
|
5929
|
-
|
|
5619
|
+
let unregister;
|
|
5930
5620
|
try {
|
|
5931
|
-
|
|
5932
|
-
registered.push(tools.register(createRemoteTransferTool(driver, { getSessionKey: () => agent.id })));
|
|
5621
|
+
unregister = tools.register(createRemoteTool(driver, { getSessionKey: () => agent.id }));
|
|
5933
5622
|
} catch (error) {
|
|
5934
|
-
for (const undo of registered.splice(0)) undo();
|
|
5935
5623
|
driver.dispose();
|
|
5936
|
-
if (
|
|
5624
|
+
if (error instanceof Error && error.message === "tool \"remote\" is already registered in this scope" && tools.get("remote", agent) !== void 0) return;
|
|
5937
5625
|
throw error;
|
|
5938
5626
|
}
|
|
5939
5627
|
const stopService = agent.ctx.on("internal/service", (name$1) => {
|
|
@@ -5945,7 +5633,7 @@ function installAgentRemote(ctx, config) {
|
|
|
5945
5633
|
const cleanup = () => {
|
|
5946
5634
|
if (disposal) return disposal;
|
|
5947
5635
|
disposal = driver.dispose();
|
|
5948
|
-
|
|
5636
|
+
unregister();
|
|
5949
5637
|
stopService();
|
|
5950
5638
|
ownerUndo?.();
|
|
5951
5639
|
agentUndo?.();
|
|
@@ -6020,4 +5708,4 @@ var plugin_default = {
|
|
|
6020
5708
|
};
|
|
6021
5709
|
|
|
6022
5710
|
//#endregion
|
|
6023
|
-
export { ByteTailRing, Config, FILE_FRAME_BYTES, FILE_MAX_ENTRIES, FILE_MAX_METADATA, PROCESS_CLEANUP_BOUND_MS,
|
|
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 };
|