super-dsh 0.3.2 → 0.3.3
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/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/package.json +21 -21
- package/remote/hub-pty-pump.mjs +97 -0
- package/remote/plugin.d.ts +406 -203
- package/remote/plugin.js +631 -319
- package/remote/provenance.json +15 -12
- 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/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/package.json +14 -10
- 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,3 +1,4 @@
|
|
|
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
4
|
import { constants, createReadStream, createWriteStream, existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
|
|
@@ -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
|
|
@@ -2727,6 +2729,23 @@ function buildSpawnArgv(spawnCommand) {
|
|
|
2727
2729
|
"/dev/null"
|
|
2728
2730
|
];
|
|
2729
2731
|
}
|
|
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
|
+
}
|
|
2730
2749
|
/** superd-run helper argv (spec: docs/specs/remote-rescope §5). Probe and upload are single
|
|
2731
2750
|
* `sh -c` hops (busybox/alpine/darwin-safe); run/killgroup over ssh go through exactly one
|
|
2732
2751
|
* remote-shell parse, same discipline as buildOneShotArgv. */
|
|
@@ -2847,22 +2866,25 @@ function stripAnsi(s) {
|
|
|
2847
2866
|
function normalizePtyText(s) {
|
|
2848
2867
|
return stripAnsi(s).replace(/\r\n/g, "\n").replace(/\r/g, "");
|
|
2849
2868
|
}
|
|
2850
|
-
|
|
2851
|
-
|
|
2852
|
-
|
|
2853
|
-
|
|
2854
|
-
|
|
2855
|
-
|
|
2869
|
+
/** 0.3.2-e:自有 30K 返回窗口已删(原生 agent runtime 的 spill/输出保护接管模型上下文
|
|
2870
|
+
* 尺寸)。本 helper 只做 retention 披露:ring 留存(4M UTF-16 / 4MiB bytes)未驱逐时
|
|
2871
|
+
* 全量透传(returned=retained,无 banner);驱逐(dropped>0,仅 >4M 输出发生)时打一行
|
|
2872
|
+
* banner 且 truncated=原始总量。text 已是 ring 尾部,不再二次裁剪。 */
|
|
2873
|
+
function disclose(text, stats) {
|
|
2874
|
+
if (stats === void 0) return { text };
|
|
2875
|
+
const returned = stats.retained;
|
|
2876
|
+
const updated = { stats: {
|
|
2856
2877
|
...stats,
|
|
2857
2878
|
returned
|
|
2858
2879
|
} };
|
|
2859
|
-
if (
|
|
2860
|
-
text
|
|
2880
|
+
if (stats.dropped <= 0) return {
|
|
2881
|
+
text,
|
|
2861
2882
|
...updated
|
|
2862
2883
|
};
|
|
2884
|
+
const unit = stats.unit === "bytes" ? "bytes" : "chars";
|
|
2863
2885
|
return {
|
|
2864
|
-
text: `[
|
|
2865
|
-
truncated: total,
|
|
2886
|
+
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}`,
|
|
2887
|
+
truncated: stats.total,
|
|
2866
2888
|
...updated
|
|
2867
2889
|
};
|
|
2868
2890
|
}
|
|
@@ -3006,13 +3028,15 @@ const summaryOf = (cmd) => {
|
|
|
3006
3028
|
};
|
|
3007
3029
|
const DEFAULT_INIT_TIMEOUT_SEC = 30;
|
|
3008
3030
|
const INTERRUPT_GRACE_MS = 3e3;
|
|
3031
|
+
/** F2(0.3.2-d):\x03 触发 ISIG 时内核 flush 输入队列——130-marker 必须等 flush 完成
|
|
3032
|
+
* 再入队,否则首字节被吞(实测 'rintf' not found → 会话死)。 */
|
|
3033
|
+
const MARKER_SETTLE_MS = 80;
|
|
3009
3034
|
/** pty 收集内存安全帽(Ruling P15,与 oneshot HARD_CAP 对齐):模型面只见尾窗,保留尾部。 */
|
|
3010
3035
|
const OUTPUT_HARD_CAP = 4e6;
|
|
3011
3036
|
var PtySession = class {
|
|
3012
3037
|
proc = null;
|
|
3013
3038
|
state = "cold";
|
|
3014
3039
|
startPromise = null;
|
|
3015
|
-
queue = Promise.resolve();
|
|
3016
3040
|
idleTimer = null;
|
|
3017
3041
|
onChunk = null;
|
|
3018
3042
|
deathWaiters = [];
|
|
@@ -3020,6 +3044,8 @@ var PtySession = class {
|
|
|
3020
3044
|
inFlight = 0;
|
|
3021
3045
|
/** P4/P5:当前占用前台的 detached job(background/超时 handoff)。非 null 时新命令拒绝(Q6)。 */
|
|
3022
3046
|
detachedJob = null;
|
|
3047
|
+
/** §3.2(2026-10-25):当前占用会话的命令——dispatch 同步占位,settle/cleanup 释放;input 的唯一写入对象。 */
|
|
3048
|
+
activeCommand = null;
|
|
3023
3049
|
/** 账本(§5):本会话最近 20 条命令(FIFO,超出丢最旧)。 */
|
|
3024
3050
|
ledger = [];
|
|
3025
3051
|
/** env 侧信道基线(init E 帧);undefined = 侧信道不可用(不报假 delta)。 */
|
|
@@ -3052,10 +3078,11 @@ var PtySession = class {
|
|
|
3052
3078
|
get detachedCommandId() {
|
|
3053
3079
|
return this.detachedJob?.commandId ?? null;
|
|
3054
3080
|
}
|
|
3055
|
-
/** job 通道收养后回填 jobId(timeout handoff 时 registry id ≠ commandId
|
|
3081
|
+
/** job 通道收养后回填 jobId(timeout handoff 时 registry id ≠ commandId,账本如实记录两者);运行中的命令同步携带(input 回执用)。 */
|
|
3056
3082
|
attachJob(commandId, jobId) {
|
|
3057
3083
|
const entry = [...this.ledger].reverse().find((e) => e.commandId === commandId);
|
|
3058
3084
|
if (entry !== void 0) entry.jobId = jobId;
|
|
3085
|
+
if (this.activeCommand !== null && this.activeCommand.commandId === commandId) this.activeCommand.jobId = jobId;
|
|
3059
3086
|
}
|
|
3060
3087
|
/** Workbench 身份(§3):id + 可选 label + connection + byo 标记。 */
|
|
3061
3088
|
get identity() {
|
|
@@ -3089,28 +3116,55 @@ var PtySession = class {
|
|
|
3089
3116
|
async warm() {
|
|
3090
3117
|
await this.start();
|
|
3091
3118
|
}
|
|
3092
|
-
/**
|
|
3093
|
-
*
|
|
3094
|
-
*
|
|
3095
|
-
*
|
|
3119
|
+
/** 同一会话命令占位制(2026-10-25 interactive-stdin 轮):dispatch 入口**同步**建立
|
|
3120
|
+
* 命令占位(activeCommand + inFlight + 账本条目)——上一条命令仍在运行(同步等待 /
|
|
3121
|
+
* 等输入 / detached job)时新 cmd 立即 E_SESSION_BUSY;原「纯同步并发排 FIFO」语义
|
|
3122
|
+
* 废除(排队会让等输入的命令与补输入动作互相卡死,也给 busy 留下绕行窗口)。
|
|
3123
|
+
* P4/P5:超时不杀命令而是 handoff 成 running job;background 立即回执;P8 显式
|
|
3124
|
+
* interrupt 保留在 job.interrupt;死亡/冷态先重启(reconnect 语义不变)。 */
|
|
3096
3125
|
dispatch(cmd, dOpts) {
|
|
3126
|
+
if (this.state === "disposed") return Promise.reject(/* @__PURE__ */ new Error(`[E_SESSION_DISPOSED] remote pty session '${this.opts.id}' was disposed before dispatch`));
|
|
3127
|
+
if (this.activeCommand !== null) {
|
|
3128
|
+
const handle = this.activeCommand.jobId ?? this.activeCommand.commandId;
|
|
3129
|
+
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`));
|
|
3130
|
+
}
|
|
3131
|
+
const commandId = dOpts.commandId;
|
|
3132
|
+
const entry = {
|
|
3133
|
+
commandId,
|
|
3134
|
+
summary: summaryOf(cmd),
|
|
3135
|
+
exit: null,
|
|
3136
|
+
durationMs: 0,
|
|
3137
|
+
running: true,
|
|
3138
|
+
...dOpts.jobId !== void 0 ? { jobId: dOpts.jobId } : {}
|
|
3139
|
+
};
|
|
3140
|
+
this.ledger.push(entry);
|
|
3141
|
+
if (this.ledger.length > LEDGER_MAX) this.ledger.shift();
|
|
3142
|
+
this.activeCommand = {
|
|
3143
|
+
commandId,
|
|
3144
|
+
...dOpts.jobId !== void 0 ? { jobId: dOpts.jobId } : {}
|
|
3145
|
+
};
|
|
3146
|
+
this.inFlight++;
|
|
3097
3147
|
const attempt = async () => {
|
|
3098
3148
|
const started = Date.now();
|
|
3099
3149
|
const reconnected = this.state === "dead";
|
|
3100
|
-
|
|
3101
|
-
|
|
3102
|
-
|
|
3103
|
-
|
|
3104
|
-
|
|
3105
|
-
|
|
3106
|
-
|
|
3107
|
-
exit: null,
|
|
3108
|
-
durationMs: 0,
|
|
3109
|
-
running: true
|
|
3150
|
+
/** 占位撤销:命令从未真正启动(建连失败 / 建连中被 dispose)——释放 busy 与计数,
|
|
3151
|
+
* 撤掉账本条目(与旧时序等价:条目原本在 start 成功之后才建立)。 */
|
|
3152
|
+
const abandon = () => {
|
|
3153
|
+
this.activeCommand = null;
|
|
3154
|
+
this.inFlight--;
|
|
3155
|
+
const index = this.ledger.indexOf(entry);
|
|
3156
|
+
if (index >= 0) this.ledger.splice(index, 1);
|
|
3110
3157
|
};
|
|
3111
|
-
this.
|
|
3112
|
-
|
|
3113
|
-
|
|
3158
|
+
if (this.state !== "ready") try {
|
|
3159
|
+
await this.start();
|
|
3160
|
+
} catch (error) {
|
|
3161
|
+
abandon();
|
|
3162
|
+
throw error;
|
|
3163
|
+
}
|
|
3164
|
+
if (this.state === "disposed") {
|
|
3165
|
+
abandon();
|
|
3166
|
+
throw new Error(`[E_SESSION_DISPOSED] remote pty session '${this.opts.id}' was disposed before dispatch`);
|
|
3167
|
+
}
|
|
3114
3168
|
return await new Promise((resolve$1) => {
|
|
3115
3169
|
const nonce = genNonce();
|
|
3116
3170
|
const ring = new Utf16TailRing(OUTPUT_HARD_CAP);
|
|
@@ -3119,6 +3173,7 @@ var PtySession = class {
|
|
|
3119
3173
|
let jobSettled = false;
|
|
3120
3174
|
let interruptTimer;
|
|
3121
3175
|
let killTimer;
|
|
3176
|
+
let markerTimer;
|
|
3122
3177
|
let jobDone = null;
|
|
3123
3178
|
let envNext;
|
|
3124
3179
|
const detach = () => {
|
|
@@ -3152,9 +3207,11 @@ var PtySession = class {
|
|
|
3152
3207
|
const cleanup = () => {
|
|
3153
3208
|
this.onChunk = null;
|
|
3154
3209
|
this.detachedJob = null;
|
|
3210
|
+
this.activeCommand = null;
|
|
3155
3211
|
this.deathWaiters = this.deathWaiters.filter((w) => w !== deathWaiter);
|
|
3156
3212
|
if (interruptTimer !== void 0) clearTimeout(interruptTimer);
|
|
3157
3213
|
if (killTimer !== void 0) clearTimeout(killTimer);
|
|
3214
|
+
if (markerTimer !== void 0) clearTimeout(markerTimer);
|
|
3158
3215
|
};
|
|
3159
3216
|
const settle = (r) => {
|
|
3160
3217
|
if (jobSettled) return;
|
|
@@ -3209,7 +3266,9 @@ var PtySession = class {
|
|
|
3209
3266
|
const twoStage = () => {
|
|
3210
3267
|
interrupted = true;
|
|
3211
3268
|
this.write("");
|
|
3212
|
-
|
|
3269
|
+
markerTimer = setTimeout(() => {
|
|
3270
|
+
this.write(`printf '\\033]133;D;${nonce};130;%s\\007' "$PWD"\n`);
|
|
3271
|
+
}, MARKER_SETTLE_MS);
|
|
3213
3272
|
killTimer = setTimeout(() => {
|
|
3214
3273
|
this.killTree();
|
|
3215
3274
|
this.markDead();
|
|
@@ -3232,9 +3291,33 @@ var PtySession = class {
|
|
|
3232
3291
|
interruptTimer = setTimeout(() => detach(), dOpts.timeoutSec === void 0 ? 12e4 : dOpts.timeoutSec * 1e3);
|
|
3233
3292
|
});
|
|
3234
3293
|
};
|
|
3235
|
-
|
|
3236
|
-
|
|
3237
|
-
|
|
3294
|
+
return attempt();
|
|
3295
|
+
}
|
|
3296
|
+
/** §3.1(2026-10-25)interactive-stdin:向当前运行中的命令追加 stdin——只写既有通道
|
|
3297
|
+
* 与身份,不新建命令、账本条目或输出游标;不重连、不重放(dead/disposed 明确拒绝)。
|
|
3298
|
+
* 校验与写入全同步:多次 input 按调用顺序整段写入不交错;写入失败如实抛错(私有
|
|
3299
|
+
* write() 的静默忽略不适用于回执语义)。stdin:"" 是零字节写入,不是 EOF。 */
|
|
3300
|
+
input(options) {
|
|
3301
|
+
const fail = (code, hint) => Promise.reject(/* @__PURE__ */ new Error(`${code} remote: ${hint}`));
|
|
3302
|
+
if (this.state === "disposed") return fail("[E_SESSION_DISPOSED]", `pty session '${displayKey(this.opts.key)}' was disposed — input never reaches a disposed session`);
|
|
3303
|
+
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`);
|
|
3304
|
+
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`);
|
|
3305
|
+
const active = this.activeCommand;
|
|
3306
|
+
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`);
|
|
3307
|
+
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`);
|
|
3308
|
+
const stdin = this.proc?.stdin;
|
|
3309
|
+
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`);
|
|
3310
|
+
try {
|
|
3311
|
+
stdin.write(options.stdin);
|
|
3312
|
+
} catch (error) {
|
|
3313
|
+
return fail("[E_SESSION_WRITE_FAILED]", `writing stdin to pty session '${displayKey(this.opts.key)}' failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
3314
|
+
}
|
|
3315
|
+
return Promise.resolve({
|
|
3316
|
+
session: displayKey(this.opts.key),
|
|
3317
|
+
commandId: active.commandId,
|
|
3318
|
+
...active.jobId !== void 0 ? { jobId: active.jobId } : {},
|
|
3319
|
+
writtenBytes: Buffer$1.byteLength(options.stdin, "utf8")
|
|
3320
|
+
});
|
|
3238
3321
|
}
|
|
3239
3322
|
/** TTL / row teardown:尽力 exit,1s 后杀组,出池。 */
|
|
3240
3323
|
dispose() {
|
|
@@ -3243,6 +3326,7 @@ var PtySession = class {
|
|
|
3243
3326
|
this.clearDeathTimer();
|
|
3244
3327
|
this.state = "disposed";
|
|
3245
3328
|
this.startPromise = null;
|
|
3329
|
+
this.detachedJob?.interrupt();
|
|
3246
3330
|
const proc = this.proc;
|
|
3247
3331
|
const close = this.procClose;
|
|
3248
3332
|
const group = this.reapGroup(proc?.pid, 1e3, close);
|
|
@@ -3253,7 +3337,7 @@ var PtySession = class {
|
|
|
3253
3337
|
]).then(() => {
|
|
3254
3338
|
if (this.proc === proc) this.proc = null;
|
|
3255
3339
|
});
|
|
3256
|
-
this.write("exit\n");
|
|
3340
|
+
if (this.detachedJob === null) this.write("exit\n");
|
|
3257
3341
|
this.opts.onDead?.();
|
|
3258
3342
|
return this.disposal;
|
|
3259
3343
|
}
|
|
@@ -3294,6 +3378,9 @@ var PtySession = class {
|
|
|
3294
3378
|
proc.stderr.setEncoding("utf8").on("data", (d) => {
|
|
3295
3379
|
this.diag = (this.diag + d).slice(-2e3);
|
|
3296
3380
|
});
|
|
3381
|
+
proc.stdin.on("error", (err) => {
|
|
3382
|
+
this.diag = (this.diag + `\n[remote: stdin write failed: ${err.message}]`).slice(-2e3);
|
|
3383
|
+
});
|
|
3297
3384
|
this.procClose = new Promise((resolve$1) => {
|
|
3298
3385
|
proc.once("close", () => {
|
|
3299
3386
|
this.markDead();
|
|
@@ -3561,7 +3648,15 @@ function parseTargetForm(target) {
|
|
|
3561
3648
|
form: "hub-pty",
|
|
3562
3649
|
machine: segs[0]
|
|
3563
3650
|
};
|
|
3564
|
-
|
|
3651
|
+
if (segs.length === 3 && segs[1] === "pty") {
|
|
3652
|
+
if (segs[2] === void 0 || segs[2].length === 0) throw new TargetFormatError(`[E_TARGET_FORMAT] remote: ${JSON.stringify(target)} — empty label after ":pty:"`);
|
|
3653
|
+
return {
|
|
3654
|
+
form: "hub-pty",
|
|
3655
|
+
machine: segs[0],
|
|
3656
|
+
label: segs[2]
|
|
3657
|
+
};
|
|
3658
|
+
}
|
|
3659
|
+
throw new TargetFormatError(`[E_TARGET_FORMAT] remote: ${JSON.stringify(target)} — hub PTY is hub:<m4>:pty[:label]`);
|
|
3565
3660
|
}
|
|
3566
3661
|
for (const transport of TRANSPORTS) {
|
|
3567
3662
|
if (!target.startsWith(`${transport}:`)) continue;
|
|
@@ -3984,6 +4079,16 @@ function renderRoster(scan, entries, sessions, hubTargets) {
|
|
|
3984
4079
|
const REMOTE_PTY_IDLE_TTL_SEC = 900;
|
|
3985
4080
|
/** P3(user 2026-10-19):每 agent 10 个 PTY 会话,跨所有 connection 统一计;仅 error message 披露。 */
|
|
3986
4081
|
const REMOTE_PTY_SESSION_LIMIT = 10;
|
|
4082
|
+
/** hub 线 one-shot 监督披露(0.3.2-c):对端 = 我们自己的 hub server(bash -lc 于独立进程组,
|
|
4083
|
+
* Node 原生 TERM→2s→KILL + 输出环 + 租约生命周期);superd-run helper 是 dumb-pipe 专用物,
|
|
4084
|
+
* 从不经 hub 上传(non-goal,docs/10_plans/2026-10-25-hub-remote-parity.md §3 O1/O8)。 */
|
|
4085
|
+
const HUB_RUNNER = "hub-lease (peer bash -lc, peer Node supervision)";
|
|
4086
|
+
/** hub PTY 泵资产:pump 与本模块同目录(npm 包与 Super 闭包同构,helper.ts 同款纪律)。
|
|
4087
|
+
* ws 模块路径由 transport 侧(superd-hub 上下文,分叉的 proven 解析链)解析后经
|
|
4088
|
+
* ptyEndpoint 传入——闭包自身子树无可解析的 ws,按名解析不在闭包里做(0.3.2-c 定稿)。 */
|
|
4089
|
+
function hubPtyAssets() {
|
|
4090
|
+
return { pumpPath: join(dirname(fileURLToPath(import.meta.url)), "hub-pty-pump.mjs") };
|
|
4091
|
+
}
|
|
3987
4092
|
/** 账本键与 jobId 同构(同格式 `<kind>-<16hex>`);background 通道预传入 registry id 时两者字面相等。 */
|
|
3988
4093
|
const genCommandId = () => `remote-${randomUUID().replace(/-/g, "").slice(0, 16)}`;
|
|
3989
4094
|
/** 双轨调度器:参数校验 → 目标语法(parseTargetForm)→ 统一 label 池 → oneshot 子进程 / pty 会话池。 */
|
|
@@ -4047,32 +4152,12 @@ var RemoteDriver = class {
|
|
|
4047
4152
|
}
|
|
4048
4153
|
async execute(params, form, callCtx) {
|
|
4049
4154
|
const timeoutSec = params.timeout ?? this.opts.execTimeoutSec ?? 120;
|
|
4050
|
-
const cap = this.opts.maxOutputChars ?? 3e4;
|
|
4051
4155
|
const sessionKey = callCtx.sessionKey ?? "no-session";
|
|
4052
4156
|
const hasCmd = params.cmd !== void 0 && params.cmd.length > 0;
|
|
4053
4157
|
params.spawn !== void 0 && params.spawn.length;
|
|
4054
|
-
if (form.form === "hub"
|
|
4158
|
+
if (form.form === "hub") {
|
|
4055
4159
|
const hub = this.requireHub();
|
|
4056
4160
|
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
4161
|
const process$1 = this.own(await hub.start(form.machine, {
|
|
4077
4162
|
...params,
|
|
4078
4163
|
target: `hub:${form.machine}`,
|
|
@@ -4082,8 +4167,8 @@ var RemoteDriver = class {
|
|
|
4082
4167
|
if (this.disposed) process$1.cancel("owner disposed");
|
|
4083
4168
|
const result = await process$1.done;
|
|
4084
4169
|
const stdout = process$1.read("stdout", 0), stderr = process$1.read("stderr", 0);
|
|
4085
|
-
const out =
|
|
4086
|
-
const err =
|
|
4170
|
+
const out = disclose(stdout.text, stdout.stats);
|
|
4171
|
+
const err = disclose(stderr.text, stderr.stats);
|
|
4087
4172
|
return {
|
|
4088
4173
|
...result,
|
|
4089
4174
|
mode: "oneshot",
|
|
@@ -4091,6 +4176,7 @@ var RemoteDriver = class {
|
|
|
4091
4176
|
connection: `hub:${form.machine}`,
|
|
4092
4177
|
cwd: null,
|
|
4093
4178
|
reconnected: false,
|
|
4179
|
+
runner: HUB_RUNNER,
|
|
4094
4180
|
stdout: (!stdout.stats && stdout.lossy ? "[truncated: peer returned only a tail; exact total/retained/dropped unavailable]\n" : "") + out.text,
|
|
4095
4181
|
stderr: [err.text, stderr.diagnostic].filter(Boolean).join("\n"),
|
|
4096
4182
|
...out.stats ? { outputStats: {
|
|
@@ -4107,7 +4193,7 @@ var RemoteDriver = class {
|
|
|
4107
4193
|
await session.warm();
|
|
4108
4194
|
return this.warmReceipt(session);
|
|
4109
4195
|
}
|
|
4110
|
-
return await this.dispatchPty(session, params, callCtx, timeoutSec
|
|
4196
|
+
return await this.dispatchPty(session, params, callCtx, timeoutSec);
|
|
4111
4197
|
}
|
|
4112
4198
|
if (params.ssh !== void 0) {
|
|
4113
4199
|
const label = form.form === "direct" || form.form === "bare" ? form.name : void 0;
|
|
@@ -4128,28 +4214,28 @@ var RemoteDriver = class {
|
|
|
4128
4214
|
kind: "ssh",
|
|
4129
4215
|
host: normalizeSshConnection(params.ssh).host,
|
|
4130
4216
|
connection: normalizeSshConnection(params.ssh)
|
|
4131
|
-
}, `ssh:${name$1}`, params, timeoutSec
|
|
4217
|
+
}, `ssh:${name$1}`, params, timeoutSec);
|
|
4132
4218
|
}
|
|
4133
4219
|
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
4220
|
if (form.form === "pty-selector") {
|
|
4135
4221
|
const session = this.resolvePty(form.idOrLabel, sessionKey);
|
|
4136
4222
|
if (!hasCmd) return this.cardReceipt(session);
|
|
4137
|
-
return await this.dispatchPty(session, params, callCtx, timeoutSec
|
|
4223
|
+
return await this.dispatchPty(session, params, callCtx, timeoutSec);
|
|
4138
4224
|
}
|
|
4139
4225
|
if (form.form === "direct") {
|
|
4140
4226
|
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
|
|
4227
|
+
return await this.oneshot(plan$1, `${form.transport}:${form.name}`, params, timeoutSec);
|
|
4142
4228
|
}
|
|
4143
4229
|
if (form.form !== "bare") throw new Error("[E_INTERNAL] remote: unreachable target form after routing");
|
|
4144
4230
|
const entry = await this.resolvePoolEntry(form.name, sessionKey);
|
|
4145
4231
|
if (entry.kind === "pty") {
|
|
4146
4232
|
const session = this.resolvePty(form.name, sessionKey);
|
|
4147
4233
|
if (!hasCmd) return this.cardReceipt(session);
|
|
4148
|
-
return await this.dispatchPty(session, params, callCtx, timeoutSec
|
|
4234
|
+
return await this.dispatchPty(session, params, callCtx, timeoutSec);
|
|
4149
4235
|
}
|
|
4150
|
-
if (entry.kind === "hub") return await this.hubOneshot(entry.machine, `hub:${entry.machine}`, params, timeoutSec
|
|
4236
|
+
if (entry.kind === "hub") return await this.hubOneshot(entry.machine, `hub:${entry.machine}`, params, timeoutSec);
|
|
4151
4237
|
const plan = this.planOf(entry);
|
|
4152
|
-
return await this.oneshot(plan, form.name, params, timeoutSec
|
|
4238
|
+
return await this.oneshot(plan, form.name, params, timeoutSec);
|
|
4153
4239
|
}
|
|
4154
4240
|
/** Native-first copy routing (spec: remote-native-shortcut) — unchanged by the workbench round. */
|
|
4155
4241
|
copy(request, signal, callCtx = {}) {
|
|
@@ -4593,6 +4679,18 @@ var RemoteDriver = class {
|
|
|
4593
4679
|
...form.label !== void 0 ? { label: form.label } : {}
|
|
4594
4680
|
}, sessionKey) };
|
|
4595
4681
|
}
|
|
4682
|
+
if (form.form === "hub-pty") {
|
|
4683
|
+
const hub = this.requireHub();
|
|
4684
|
+
this.hubProviders.add(hub);
|
|
4685
|
+
const endpoint = hub.ptyEndpoint(form.machine);
|
|
4686
|
+
const argv = this.opts.hubPtyArgvFor !== void 0 ? this.opts.hubPtyArgvFor(endpoint) : buildHubPtyArgv(endpoint, hubPtyAssets());
|
|
4687
|
+
return { session: this.createSession({
|
|
4688
|
+
argv,
|
|
4689
|
+
connection: `hub:${form.machine}`,
|
|
4690
|
+
byo: false,
|
|
4691
|
+
...form.label !== void 0 ? { label: form.label } : {}
|
|
4692
|
+
}, sessionKey) };
|
|
4693
|
+
}
|
|
4596
4694
|
}
|
|
4597
4695
|
/** background PTY lane:会话先占位(不拨号)再进官方 jobs registry——首回执必须能寻址;
|
|
4598
4696
|
* registry 拒绝时把新建的会话拆掉(复用形的会话绝不拆)。返回 undefined = 走普通 oneshot job。 */
|
|
@@ -4680,15 +4778,50 @@ var RemoteDriver = class {
|
|
|
4680
4778
|
this.pool.get(key)?.attachJob(commandId, jobId);
|
|
4681
4779
|
}
|
|
4682
4780
|
}
|
|
4683
|
-
|
|
4781
|
+
/** §3.1(2026-10-25)interactive-stdin:input 动作——向已有 live PTY 的当前运行命令
|
|
4782
|
+
* 追加 stdin。只解析池内已存在的会话(pty:<id|label> / 裸 label 命中 pty 项),
|
|
4783
|
+
* 不触发发现、拨号、创建或重连;machine target 与创建形(:pty 后缀,含 hub:<m4>:pty)
|
|
4784
|
+
* 一律明确拒绝并给出可恢复指引。回执附 connection/label 供工具层渲染。 */
|
|
4785
|
+
async input(target, options, callCtx = {}) {
|
|
4786
|
+
this.assertOpen();
|
|
4787
|
+
const sessionKey = callCtx.sessionKey ?? "no-session";
|
|
4788
|
+
const form = parseTargetForm(target);
|
|
4789
|
+
let selector;
|
|
4790
|
+
if (form.form === "pty-selector") {
|
|
4791
|
+
if (form.idOrLabel === void 0) throw new Error("[E_PARAMS] remote: \"pty\" alone selects nothing — append an id/label (pty:pty-1a2b, pty:build)");
|
|
4792
|
+
selector = form.idOrLabel;
|
|
4793
|
+
} else if (form.form === "bare") {
|
|
4794
|
+
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>`);
|
|
4795
|
+
selector = form.name;
|
|
4796
|
+
} 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`);
|
|
4797
|
+
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`);
|
|
4798
|
+
const session = this.resolvePty(selector, sessionKey);
|
|
4799
|
+
const receipt = await session.input(options);
|
|
4800
|
+
const id = session.identity;
|
|
4801
|
+
return {
|
|
4802
|
+
...receipt,
|
|
4803
|
+
connection: id.connection,
|
|
4804
|
+
...id.label !== void 0 ? { label: id.label } : {}
|
|
4805
|
+
};
|
|
4806
|
+
}
|
|
4807
|
+
async dispatchPty(session, params, callCtx, timeoutSec) {
|
|
4808
|
+
const hubConn = session.identity.connection;
|
|
4809
|
+
if (hubConn.startsWith("hub:")) {
|
|
4810
|
+
const hub = this.requireHub();
|
|
4811
|
+
this.hubProviders.add(hub);
|
|
4812
|
+
hub.ptyEndpoint(hubConn.slice(4));
|
|
4813
|
+
}
|
|
4684
4814
|
const commandId = callCtx.commandId ?? genCommandId();
|
|
4685
4815
|
const r = await session.dispatch(params.cmd, {
|
|
4686
4816
|
stdin: params.stdin,
|
|
4687
4817
|
timeoutSec,
|
|
4688
4818
|
commandId,
|
|
4689
|
-
...callCtx.ptyBackground === true ? {
|
|
4819
|
+
...callCtx.ptyBackground === true ? {
|
|
4820
|
+
background: true,
|
|
4821
|
+
jobId: commandId
|
|
4822
|
+
} : {}
|
|
4690
4823
|
});
|
|
4691
|
-
const out =
|
|
4824
|
+
const out = disclose(r.output, r.outputStats?.stdout);
|
|
4692
4825
|
const id = session.identity;
|
|
4693
4826
|
return {
|
|
4694
4827
|
mode: "pty",
|
|
@@ -4756,7 +4889,7 @@ var RemoteDriver = class {
|
|
|
4756
4889
|
...this.ptyTtl(session.key)
|
|
4757
4890
|
};
|
|
4758
4891
|
}
|
|
4759
|
-
async oneshot(plan, display, params, timeoutSec
|
|
4892
|
+
async oneshot(plan, display, params, timeoutSec) {
|
|
4760
4893
|
this.assertOpen();
|
|
4761
4894
|
const process$1 = this.own(await this.nativeProcess(plan, params, timeoutSec));
|
|
4762
4895
|
if (this.disposed) process$1.cancel("owner disposed");
|
|
@@ -4771,8 +4904,8 @@ var RemoteDriver = class {
|
|
|
4771
4904
|
stderr: stderr.stats
|
|
4772
4905
|
}
|
|
4773
4906
|
};
|
|
4774
|
-
const out =
|
|
4775
|
-
const err = r.stderr.length > 0 ?
|
|
4907
|
+
const out = disclose(r.stdout, r.outputStats?.stdout);
|
|
4908
|
+
const err = r.stderr.length > 0 ? disclose(r.stderr, r.outputStats?.stderr) : void 0;
|
|
4776
4909
|
return {
|
|
4777
4910
|
mode: "oneshot",
|
|
4778
4911
|
target: display,
|
|
@@ -4791,13 +4924,13 @@ var RemoteDriver = class {
|
|
|
4791
4924
|
...this.runnerOf.get(process$1) === void 0 ? {} : { runner: this.runnerOf.get(process$1) }
|
|
4792
4925
|
};
|
|
4793
4926
|
}
|
|
4794
|
-
async hubOneshot(machine, display, params, timeoutSec
|
|
4927
|
+
async hubOneshot(machine, display, params, timeoutSec) {
|
|
4795
4928
|
const process$1 = this.own(await this.hubOneshotProcess(machine, params));
|
|
4796
4929
|
if (this.disposed) process$1.cancel("owner disposed");
|
|
4797
4930
|
const result = await process$1.done;
|
|
4798
4931
|
const stdout = process$1.read("stdout", 0), stderr = process$1.read("stderr", 0);
|
|
4799
|
-
const out =
|
|
4800
|
-
const err =
|
|
4932
|
+
const out = disclose(stdout.text, stdout.stats);
|
|
4933
|
+
const err = disclose(stderr.text, stderr.stats);
|
|
4801
4934
|
return {
|
|
4802
4935
|
...result,
|
|
4803
4936
|
mode: "oneshot",
|
|
@@ -4805,6 +4938,7 @@ var RemoteDriver = class {
|
|
|
4805
4938
|
connection: display,
|
|
4806
4939
|
cwd: null,
|
|
4807
4940
|
reconnected: false,
|
|
4941
|
+
runner: HUB_RUNNER,
|
|
4808
4942
|
stdout: (!stdout.stats && stdout.lossy ? "[truncated: peer returned only a tail; exact total/retained/dropped unavailable]\n" : "") + out.text,
|
|
4809
4943
|
stderr: [err.text, stderr.diagnostic].filter(Boolean).join("\n"),
|
|
4810
4944
|
...out.stats ? { outputStats: {
|
|
@@ -4962,16 +5096,20 @@ const ptyOutputSources = (holder) => [{
|
|
|
4962
5096
|
lossy: false
|
|
4963
5097
|
}
|
|
4964
5098
|
}];
|
|
4965
|
-
|
|
4966
|
-
|
|
4967
|
-
|
|
4968
|
-
|
|
5099
|
+
/** 0.3.2-d CF:timedOut=true 且 aborted=false = 会话侧主动收束(close force / 行回收走的
|
|
5100
|
+
* dispose→interrupt 两段路)——同为 killed;exit 到手用 exit,只剩会话死亡时写 session closed。 */
|
|
5101
|
+
const ptyOutcome = (aborted, result) => {
|
|
5102
|
+
const interrupted = aborted || result.timedOut === true;
|
|
5103
|
+
return {
|
|
5104
|
+
status: interrupted ? "killed" : result.exit === null ? "failed" : "completed",
|
|
5105
|
+
detail: interrupted ? `interrupted: ${result.exit === null ? aborted ? "session died" : "session closed" : `exit ${result.exit}`}` : result.exit === null ? "session died" : `exit code: ${result.exit}`
|
|
5106
|
+
};
|
|
5107
|
+
};
|
|
4969
5108
|
/** No preparation or discovery before the official registry's admission. */
|
|
4970
5109
|
function startRemoteJob(registry, input, exec, driver) {
|
|
4971
5110
|
if (!exec.agent) throw new Error("remote: background jobs require a live Agent owner");
|
|
4972
5111
|
const form = parseTargetForm(input.target);
|
|
4973
5112
|
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");
|
|
4975
5113
|
const holder = {};
|
|
4976
5114
|
return registry.start({
|
|
4977
5115
|
kind: "remote",
|
|
@@ -5097,116 +5235,157 @@ function adoptRunningPtyJob(registry, exec, label, job) {
|
|
|
5097
5235
|
|
|
5098
5236
|
//#endregion
|
|
5099
5237
|
//#region src/remote/tool.ts
|
|
5100
|
-
const NO_SESSION_KEY = "remote:no-session";
|
|
5238
|
+
const NO_SESSION_KEY$1 = "remote:no-session";
|
|
5101
5239
|
/**
|
|
5102
|
-
* The model-visible surface of the `remote` tool (
|
|
5103
|
-
*
|
|
5240
|
+
* The model-visible surface of the `remote` tool (0.3.2-d user-ruled restructure:
|
|
5241
|
+
* Actions / Targets / PTY / Oneshot / Examples — the PTY block is the user's verbatim
|
|
5242
|
+
* final draft; Examples receipts match renderValue byte-for-byte).
|
|
5243
|
+
* ONE template literal — no backticks, no "${".
|
|
5244
|
+
*
|
|
5245
|
+
* 语义目标(surface 就是产品):动作是唯一的派发维度;PTY 四词定位——stateful oneshot /
|
|
5246
|
+
* interact-able stdin / managed output / persistent env——回执形状与 oneshot 相同、只增状态,
|
|
5247
|
+
* 运行中的命令可分步观察后再补输(input 动作);多步与交互任务默认进 PTY。
|
|
5104
5248
|
*/
|
|
5105
|
-
const REMOTE_TOOL_DESCRIPTION = `Remote
|
|
5106
|
-
|
|
5107
|
-
|
|
5108
|
-
|
|
5109
|
-
|
|
5110
|
-
|
|
5111
|
-
|
|
5112
|
-
|
|
5113
|
-
|
|
5114
|
-
|
|
5115
|
-
|
|
5116
|
-
|
|
5249
|
+
const REMOTE_TOOL_DESCRIPTION = `Remote shell over ssh / docker / incus (Hub when configured): stateless oneshots + persistent interactive PTY sessions. Default to PTY for multi-step or interactive work; oneshot for isolated commands.
|
|
5250
|
+
|
|
5251
|
+
Actions (action is the only dispatch dimension; unknown fields are rejected):
|
|
5252
|
+
- exec {target, cmd | scriptPath, stdin?, timeout?, run_in_background?}: run a command. Machine target → oneshot; pty:<id|label> → run inside that live session; <machine>:pty[:label] → create a NEW session and run there.
|
|
5253
|
+
- add {ssh:{...} | spawn:"<cmd>", target?}: register a BYO endpoint (identity or interactive-shell command); no command runs. Returns the addressable label; a taken label answers [E_LABEL_IN_USE] — address existing names directly, never add again.
|
|
5254
|
+
- status {}: roster — the one unified pool (machines, identities, live PTY sessions).
|
|
5255
|
+
- status {target}: machine probe, or a session card (connection, cwd, ledger of the last 20 commands).
|
|
5256
|
+
- input {target:"pty:<id|label>", stdin, commandId?}: write stdin into the command currently running in that session — passwords, OTP, tokens, menu choices, REPL lines. Never creates a session, never dials.
|
|
5257
|
+
- close {target, force?}: close a PTY session; a running command refuses it (job_kill first) unless force:true kills it in the same step.
|
|
5258
|
+
|
|
5259
|
+
Targets:
|
|
5260
|
+
- ssh:<name> / docker:<name> / incus:<name> / bare pool name → stateless oneshot.
|
|
5261
|
+
- <machine>:pty[:label] → NEW PTY every call — creation is a target suffix, not a different tool.
|
|
5262
|
+
- pty:<id|label> (or its bare label) → REUSE the live session.
|
|
5263
|
+
After creation, reuse the returned session; repeating :pty opens another. Prefixes force the channel; bare names resolve through the roster.
|
|
5264
|
+
|
|
5265
|
+
PTY:
|
|
5266
|
+
- stateful oneshot, interact-able stdin, managed output, persistent env
|
|
5267
|
+
- timeout (default 120s) → command keeps running; returns job handle.
|
|
5268
|
+
- run_in_background → same job lane. jobId = commandId.
|
|
5269
|
+
- Read: job_output(jobId). Cancel: job_kill; try Ctrl-C first.
|
|
5270
|
+
- Busy session: input / poll / cancel / use another session.
|
|
5271
|
+
- exec.stdin = startup input; input = subsequent input.
|
|
5272
|
+
- input is verbatim: "\\n" submits; "\\u0003" Ctrl-C; "\\u0004" Ctrl-D; "" writes nothing. Ctrl-D follows terminal EOF rules.
|
|
5273
|
+
- commandId, if supplied, must match running command.
|
|
5274
|
+
- Input receipt confirms write only; read result via SAME job.
|
|
5275
|
+
- Output returns in full up to retention (4M chars per stream; Hub oneshot 4MiB bytes) — beyond it the retained tail plus outputStats. Redirect bulk output to files.
|
|
5276
|
+
- Idle expiry: 15 min; running commands exempt.
|
|
5277
|
+
|
|
5278
|
+
Oneshot:
|
|
5279
|
+
- 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.
|
|
5280
|
+
- 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.
|
|
5281
|
+
- 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.
|
|
5282
|
+
|
|
5283
|
+
Examples
|
|
5284
|
+
|
|
5285
|
+
Manage the persistent PTY session. One-shot style output, but stable: every command returns synchronously with exit, cwd, and an env delta.
|
|
5286
|
+
|
|
5287
|
+
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.
|
|
5288
|
+
|
|
5289
|
+
remote { "action": "add", "spawn": "ssh -t pc ssh -t dev4", "target": "pty:dev4via" }
|
|
5117
5290
|
|
|
5118
|
-
|
|
5291
|
+
[session created — address it as pty:pty-irsq]
|
|
5292
|
+
[pty:pty-irsq (dev4via) (byo-pty) · conn BYOP · idle-left ~15m · exit null · 0.0s]
|
|
5119
5293
|
|
|
5120
|
-
|
|
5294
|
+
remote { "action": "exec", "target": "pty:dev4via", "cmd": "hostname && sudo -n ss -tlnp | awk 'NR==1 || /LISTEN/' | head -5" }
|
|
5121
5295
|
|
|
5122
|
-
|
|
5296
|
+
dev4
|
|
5297
|
+
State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
|
|
5298
|
+
LISTEN 0 4096 127.0.0.1:45207 0.0.0.0:* users:(("containerd",pid=1070,fd=14))
|
|
5299
|
+
LISTEN 0 4096 127.0.0.1:2019 0.0.0.0:* users:(("caddy",pid=5600,fd=4))
|
|
5300
|
+
LISTEN 0 4096 127.0.0.1:19514 0.0.0.0:* users:(("multica",pid=27030,fd=6))
|
|
5301
|
+
LISTEN 0 4096 127.0.0.54:53 0.0.0.0:* users:(("systemd-resolve",pid=26758,fd=17))
|
|
5302
|
+
[pty:pty-irsq (dev4via) (byo-pty) · conn BYOP · cmd remote-0d89215d1cc64cdc · cwd /home/u1 · idle-left ~15m · exit 0 · 0.3s]
|
|
5303
|
+
|
|
5304
|
+
Reuse the same session for a long task — returns a jobId immediately; collect with job_output, cancel with job_kill:
|
|
5305
|
+
|
|
5306
|
+
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 }
|
|
5307
|
+
|
|
5308
|
+
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.
|
|
5309
|
+
|
|
5310
|
+
job_output { "job_id": "remote-47676dff2c6443e8", "wait": true }
|
|
5311
|
+
|
|
5312
|
+
tick 1
|
|
5313
|
+
tick 2
|
|
5314
|
+
tick 3
|
|
5315
|
+
done
|
|
5316
|
+
[status: completed, exit code: 0]
|
|
5317
|
+
|
|
5318
|
+
- 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).
|
|
5319
|
+
- It also strips one or two shell-escaping layers — commands go straight to the innermost bash, quotes behave naturally.
|
|
5320
|
+
|
|
5321
|
+
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.
|
|
5322
|
+
|
|
5323
|
+
remote { "action": "add", "ssh": { "host": "10.0.0.8", "user": "u1", "port": 22, "key": "/home/u1/.ssh/id_key4" }, "target": "ssh:lab" }
|
|
5324
|
+
remote { "action": "exec", "target": "ssh:lab", "cmd": "uname -a" }
|
|
5325
|
+
remote_transfer { "mode": "rsync", "src": "ssh:lab:/var/log/app/", "dst": "/home/u1/app-logs/", "argv": ["-a"] }`;
|
|
5326
|
+
/** action 是唯一派发维度:每个动词声明自己的字段集,超出即报错(不做在场推断、不留别名)。 */
|
|
5327
|
+
const ACTION_PARAMS = {
|
|
5328
|
+
exec: new Set([
|
|
5329
|
+
"action",
|
|
5330
|
+
"target",
|
|
5331
|
+
"cmd",
|
|
5332
|
+
"stdin",
|
|
5333
|
+
"scriptPath",
|
|
5334
|
+
"timeout",
|
|
5335
|
+
"run_in_background"
|
|
5336
|
+
]),
|
|
5337
|
+
add: new Set([
|
|
5338
|
+
"action",
|
|
5339
|
+
"target",
|
|
5340
|
+
"ssh",
|
|
5341
|
+
"spawn"
|
|
5342
|
+
]),
|
|
5343
|
+
status: new Set(["action", "target"]),
|
|
5344
|
+
close: new Set([
|
|
5345
|
+
"action",
|
|
5346
|
+
"target",
|
|
5347
|
+
"force"
|
|
5348
|
+
]),
|
|
5349
|
+
input: new Set([
|
|
5350
|
+
"action",
|
|
5351
|
+
"target",
|
|
5352
|
+
"stdin",
|
|
5353
|
+
"commandId"
|
|
5354
|
+
])
|
|
5355
|
+
};
|
|
5356
|
+
const ACTION_HINT = {
|
|
5357
|
+
exec: "exec takes target plus cmd (or scriptPath) and the execution options; ssh/spawn belong to add",
|
|
5358
|
+
add: "add takes exactly one of ssh or spawn, plus an optional target label",
|
|
5359
|
+
status: "status takes only an optional target — omit it for the roster",
|
|
5360
|
+
close: "close takes target pty:<id|label> and optional force",
|
|
5361
|
+
input: "input takes target (a live PTY session), stdin, and an optional commandId guard — no cmd, no timeout, no creation"
|
|
5362
|
+
};
|
|
5123
5363
|
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;
|
|
5364
|
+
const action = args.action;
|
|
5365
|
+
if (action === void 0) throw new Error("[E_PARAMS] remote: action is required — \"exec\" (run a command), \"add\" (register a BYO endpoint), \"status\" (roster / probe / session card), \"close\" (close a PTY session), \"input\" (append stdin to a running PTY command)");
|
|
5366
|
+
const allowed = ACTION_PARAMS[action];
|
|
5367
|
+
if (allowed === void 0) throw new Error(`[E_PARAMS] remote: unknown action ${JSON.stringify(String(action))} — one of exec / add / close / status / input`);
|
|
5368
|
+
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]}`);
|
|
5369
|
+
const sessionKey = ENV.getSessionKey(exec);
|
|
5370
|
+
const hasTarget = typeof args.target === "string" && args.target.length > 0;
|
|
5371
|
+
const hasCmd = typeof args.cmd === "string" && args.cmd.length > 0;
|
|
5372
|
+
if (action === "status") {
|
|
5373
|
+
if (!hasTarget) return {
|
|
5374
|
+
kind: "roster",
|
|
5375
|
+
text: await ENV.driver.roster({ sessionKey }),
|
|
5376
|
+
exit: null,
|
|
5377
|
+
durationMs: 0
|
|
5378
|
+
};
|
|
5190
5379
|
return {
|
|
5191
|
-
kind: "
|
|
5192
|
-
text:
|
|
5193
|
-
|
|
5194
|
-
|
|
5195
|
-
durationMs: copyDuration
|
|
5380
|
+
kind: "status",
|
|
5381
|
+
text: await ENV.driver.status(args.target, { sessionKey }),
|
|
5382
|
+
exit: null,
|
|
5383
|
+
durationMs: 0
|
|
5196
5384
|
};
|
|
5197
5385
|
}
|
|
5198
|
-
if (
|
|
5199
|
-
if (
|
|
5200
|
-
|
|
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);
|
|
5386
|
+
if (action === "close") {
|
|
5387
|
+
if (!hasTarget) throw new Error("[E_PARAMS] close requires target pty:<id|label>");
|
|
5388
|
+
const r$1 = ENV.driver.closePty(args.target.startsWith("pty:") ? args.target.slice(4) : args.target, sessionKey, args.force === true);
|
|
5210
5389
|
return {
|
|
5211
5390
|
kind: "close",
|
|
5212
5391
|
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,42 +5397,71 @@ async function remoteExecute(ENV, args, exec) {
|
|
|
5218
5397
|
killed: r$1.killed
|
|
5219
5398
|
};
|
|
5220
5399
|
}
|
|
5221
|
-
if (
|
|
5222
|
-
|
|
5223
|
-
args.
|
|
5224
|
-
args.
|
|
5225
|
-
args.
|
|
5226
|
-
|
|
5227
|
-
|
|
5228
|
-
|
|
5229
|
-
|
|
5230
|
-
|
|
5231
|
-
|
|
5232
|
-
|
|
5233
|
-
|
|
5234
|
-
|
|
5235
|
-
|
|
5400
|
+
if (action === "input") {
|
|
5401
|
+
if (!hasTarget) throw new Error("[E_PARAMS] remote: action \"input\" requires target — the live PTY session whose command is waiting (pty:<id|label> or its bare label)");
|
|
5402
|
+
if (args.stdin === void 0) throw new Error("[E_PARAMS] remote: action \"input\" requires stdin — the exact text to append; include a trailing \\n to submit a line, \"\\u0003\" for Ctrl-C, \"\\u0004\" for Ctrl-D (\"\" = zero bytes, not EOF)");
|
|
5403
|
+
if (typeof args.commandId === "string" && args.commandId.length === 0) throw new Error("[E_PARAMS] remote: input: commandId, when given, must be the non-empty id from the exec receipt / job_output");
|
|
5404
|
+
const r$1 = await ENV.driver.input(args.target, {
|
|
5405
|
+
stdin: args.stdin,
|
|
5406
|
+
...args.commandId !== void 0 ? { commandId: args.commandId } : {}
|
|
5407
|
+
}, { sessionKey });
|
|
5408
|
+
const handle = r$1.jobId ?? r$1.commandId;
|
|
5409
|
+
return {
|
|
5410
|
+
kind: "input",
|
|
5411
|
+
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}`,
|
|
5412
|
+
exit: null,
|
|
5413
|
+
durationMs: 0,
|
|
5414
|
+
session: r$1.session,
|
|
5415
|
+
connection: r$1.connection,
|
|
5416
|
+
...r$1.label !== void 0 ? { label: r$1.label } : {},
|
|
5417
|
+
commandId: r$1.commandId,
|
|
5418
|
+
...r$1.jobId !== void 0 ? { jobId: r$1.jobId } : {},
|
|
5419
|
+
writtenBytes: r$1.writtenBytes
|
|
5420
|
+
};
|
|
5421
|
+
}
|
|
5422
|
+
if (action === "add") {
|
|
5423
|
+
const hasSpawn = typeof args.spawn === "string" && args.spawn.length > 0;
|
|
5424
|
+
if (args.spawn !== void 0 && !hasSpawn) throw new Error("[E_PARAMS] remote: add: spawn must be a non-empty command string");
|
|
5425
|
+
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");
|
|
5426
|
+
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");
|
|
5427
|
+
const r$1 = await ENV.driver.call({
|
|
5428
|
+
...hasTarget ? { target: args.target } : {},
|
|
5429
|
+
...args.ssh === void 0 ? {} : { ssh: args.ssh },
|
|
5430
|
+
...hasSpawn ? { spawn: args.spawn } : {},
|
|
5431
|
+
cmd: ""
|
|
5432
|
+
}, { sessionKey });
|
|
5433
|
+
const v$1 = workbenchValue(r$1);
|
|
5434
|
+
v$1.created = true;
|
|
5435
|
+
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)`;
|
|
5436
|
+
return v$1;
|
|
5437
|
+
}
|
|
5438
|
+
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\".");
|
|
5439
|
+
const target = args.target;
|
|
5440
|
+
const ptyTarget = /^pty:/.test(target) || /:pty(:|$)/.test(target);
|
|
5441
|
+
if (args.scriptPath !== void 0) {
|
|
5442
|
+
if (args.scriptPath.length === 0) throw new Error("[E_PARAMS] remote: scriptPath must be a non-empty absolute path");
|
|
5443
|
+
if (ptyTarget) throw new Error("[E_SCRIPT_PARAMS] remote: scriptPath is oneshot-only — PTY lanes take cmd");
|
|
5444
|
+
if (hasCmd) throw new Error("[E_SCRIPT_PARAMS] remote: scriptPath and cmd are alternative carriers — pass one");
|
|
5445
|
+
if (args.stdin !== void 0) throw new Error("[E_SCRIPT_PARAMS] remote: scriptPath already occupies stdin — do not pass stdin with it");
|
|
5446
|
+
}
|
|
5447
|
+
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}");
|
|
5448
|
+
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");
|
|
5236
5449
|
if (args.run_in_background) {
|
|
5237
|
-
if (!hasCmd && args.scriptPath === void 0) throw new Error("remote: background requires cmd or scriptPath");
|
|
5238
5450
|
const jobs = exec.agent?.ctx.get("jobs");
|
|
5239
5451
|
if (!jobs) throw new Error("remote: background requires the official DSH jobs service and job_output/job_kill controller; foreground execution remains available");
|
|
5240
5452
|
const lane = await ENV.driver.preparePtyLane({
|
|
5241
5453
|
target: args.target,
|
|
5242
|
-
spawn: args.spawn,
|
|
5243
5454
|
cmd: args.cmd,
|
|
5244
5455
|
scriptPath: args.scriptPath,
|
|
5245
|
-
ssh: args.ssh,
|
|
5246
5456
|
stdin: args.stdin,
|
|
5247
5457
|
timeout: args.timeout
|
|
5248
|
-
}, { sessionKey
|
|
5458
|
+
}, { sessionKey });
|
|
5249
5459
|
let jobId;
|
|
5250
5460
|
try {
|
|
5251
5461
|
jobId = startRemoteJob(jobs, lane !== void 0 ? lane.input : {
|
|
5252
5462
|
target: args.target,
|
|
5253
|
-
spawn: args.spawn,
|
|
5254
5463
|
cmd: args.cmd,
|
|
5255
5464
|
scriptPath: args.scriptPath,
|
|
5256
|
-
ssh: args.ssh,
|
|
5257
5465
|
stdin: args.stdin,
|
|
5258
5466
|
timeout: args.timeout
|
|
5259
5467
|
}, exec, ENV.driver);
|
|
@@ -5269,44 +5477,20 @@ async function remoteExecute(ENV, args, exec) {
|
|
|
5269
5477
|
durationMs: 0,
|
|
5270
5478
|
text: `Started ${jobId}${where}. Use job_output to read and job_kill to cancel; completion is reported by the host.`,
|
|
5271
5479
|
...lane !== void 0 ? {
|
|
5480
|
+
commandId: jobId,
|
|
5272
5481
|
session: lane.session,
|
|
5273
5482
|
connection: lane.connection,
|
|
5274
5483
|
...lane.label !== void 0 ? { label: lane.label } : {}
|
|
5275
5484
|
} : {}
|
|
5276
5485
|
};
|
|
5277
5486
|
}
|
|
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
5487
|
const r = await ENV.driver.call({
|
|
5302
5488
|
target: args.target,
|
|
5303
|
-
spawn: args.spawn,
|
|
5304
|
-
ssh: args.ssh,
|
|
5305
5489
|
cmd: args.cmd,
|
|
5306
5490
|
scriptPath: args.scriptPath,
|
|
5307
5491
|
stdin: args.stdin,
|
|
5308
5492
|
timeout: args.timeout
|
|
5309
|
-
}, { sessionKey
|
|
5493
|
+
}, { sessionKey });
|
|
5310
5494
|
const v = workbenchValue(r);
|
|
5311
5495
|
if (r.running === true && r.ptyJob !== void 0) {
|
|
5312
5496
|
const jobs = exec.agent?.ctx.get("jobs");
|
|
@@ -5314,7 +5498,7 @@ async function remoteExecute(ENV, args, exec) {
|
|
|
5314
5498
|
const jobId = adoptRunningPtyJob(jobs, exec, `${r.target}: ${args.cmd}`, r.ptyJob);
|
|
5315
5499
|
v.jobId = jobId;
|
|
5316
5500
|
v.running = true;
|
|
5317
|
-
if (r.commandId !== void 0 && jobId !== r.commandId) ENV.driver.attachPtyJob(r.commandId, jobId,
|
|
5501
|
+
if (r.commandId !== void 0 && jobId !== r.commandId) ENV.driver.attachPtyJob(r.commandId, jobId, sessionKey);
|
|
5318
5502
|
} else {
|
|
5319
5503
|
r.ptyJob.interrupt();
|
|
5320
5504
|
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 +5507,7 @@ async function remoteExecute(ENV, args, exec) {
|
|
|
5323
5507
|
return v;
|
|
5324
5508
|
}
|
|
5325
5509
|
function createRemoteTool(driver, opts = {}) {
|
|
5326
|
-
const getSessionKey = opts.getSessionKey ?? ((exec) => exec.agent?.id ?? NO_SESSION_KEY);
|
|
5510
|
+
const getSessionKey = opts.getSessionKey ?? ((exec) => exec.agent?.id ?? NO_SESSION_KEY$1);
|
|
5327
5511
|
const tool = defineTool({
|
|
5328
5512
|
name: "remote",
|
|
5329
5513
|
description: REMOTE_TOOL_DESCRIPTION,
|
|
@@ -5331,58 +5515,42 @@ function createRemoteTool(driver, opts = {}) {
|
|
|
5331
5515
|
action: {
|
|
5332
5516
|
type: "string",
|
|
5333
5517
|
enum: [
|
|
5334
|
-
"
|
|
5335
|
-
"
|
|
5336
|
-
"
|
|
5518
|
+
"exec",
|
|
5519
|
+
"add",
|
|
5520
|
+
"status",
|
|
5521
|
+
"close",
|
|
5522
|
+
"input"
|
|
5337
5523
|
],
|
|
5338
|
-
|
|
5524
|
+
required: true,
|
|
5525
|
+
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."
|
|
5339
5526
|
},
|
|
5340
|
-
|
|
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."
|
|
5343
|
-
},
|
|
5344
|
-
src: {
|
|
5527
|
+
target: {
|
|
5345
5528
|
type: "string",
|
|
5346
|
-
description: "
|
|
5529
|
+
description: "exec: required. A machine (bare pool name, ssh:<name>, docker:<name>, incus:<name>, hub:<m4>), a live PTY session (pty:<id|label> or its bare label), or a machine with :pty[:label] to create a new session (ssh:dev4:pty, hub:<m4>:pty, ssh:dev4:pty:build). add: optional label for the BYO entry. status: optional — omit for the roster. close / input: pty:<id|label> (input never creates or dials — the session must already run the command)."
|
|
5347
5530
|
},
|
|
5348
|
-
|
|
5531
|
+
cmd: {
|
|
5349
5532
|
type: "string",
|
|
5350
|
-
description: "
|
|
5533
|
+
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
5534
|
},
|
|
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."
|
|
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."
|
|
5535
|
+
stdin: {
|
|
5536
|
+
type: "string",
|
|
5537
|
+
description: "exec: startup input fed to cmd — oneshot: piped to stdin; pty: written right after the command line (REPL-style); requires cmd. input: the exact text to append to the RUNNING command — \"\" writes zero bytes (not EOF); include \"\\n\" to submit a line; \"\\u0003\" = Ctrl-C, \"\\u0004\" = Ctrl-D as raw characters."
|
|
5366
5538
|
},
|
|
5367
|
-
|
|
5368
|
-
type: "
|
|
5369
|
-
description: "
|
|
5539
|
+
commandId: {
|
|
5540
|
+
type: "string",
|
|
5541
|
+
description: "input 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. Ignored by other actions."
|
|
5370
5542
|
},
|
|
5371
|
-
|
|
5372
|
-
type: "
|
|
5373
|
-
description: "
|
|
5543
|
+
timeout: {
|
|
5544
|
+
type: "number",
|
|
5545
|
+
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)."
|
|
5374
5546
|
},
|
|
5375
5547
|
run_in_background: {
|
|
5376
5548
|
type: "boolean",
|
|
5377
|
-
description: "oneshot AND pty
|
|
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)."
|
|
5549
|
+
description: "exec: 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. Requires the official jobs service/controller."
|
|
5382
5550
|
},
|
|
5383
5551
|
ssh: {
|
|
5384
5552
|
type: "object",
|
|
5385
|
-
description: "BYO ssh
|
|
5553
|
+
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
5554
|
additionalProperties: false,
|
|
5387
5555
|
properties: {
|
|
5388
5556
|
host: {
|
|
@@ -5410,23 +5578,11 @@ function createRemoteTool(driver, opts = {}) {
|
|
|
5410
5578
|
},
|
|
5411
5579
|
spawn: {
|
|
5412
5580
|
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."
|
|
5581
|
+
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
5582
|
},
|
|
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)."
|
|
5583
|
+
force: {
|
|
5584
|
+
type: "boolean",
|
|
5585
|
+
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
5586
|
}
|
|
5431
5587
|
},
|
|
5432
5588
|
output: {
|
|
@@ -5442,9 +5598,8 @@ function createRemoteTool(driver, opts = {}) {
|
|
|
5442
5598
|
"status",
|
|
5443
5599
|
"roster",
|
|
5444
5600
|
"job",
|
|
5445
|
-
"
|
|
5446
|
-
"
|
|
5447
|
-
"close"
|
|
5601
|
+
"close",
|
|
5602
|
+
"input"
|
|
5448
5603
|
]
|
|
5449
5604
|
},
|
|
5450
5605
|
text: {
|
|
@@ -5476,6 +5631,10 @@ function createRemoteTool(driver, opts = {}) {
|
|
|
5476
5631
|
type: "string",
|
|
5477
5632
|
description: "ledger key; equals the jobId whenever a job exists"
|
|
5478
5633
|
},
|
|
5634
|
+
writtenBytes: {
|
|
5635
|
+
type: "integer",
|
|
5636
|
+
description: "input: UTF-8 bytes actually written; confirms the write, not the program response"
|
|
5637
|
+
},
|
|
5479
5638
|
envDelta: {
|
|
5480
5639
|
type: "json",
|
|
5481
5640
|
description: "names only: {added,changed,removed}"
|
|
@@ -5484,7 +5643,7 @@ function createRemoteTool(driver, opts = {}) {
|
|
|
5484
5643
|
notice: { type: "string" },
|
|
5485
5644
|
runner: {
|
|
5486
5645
|
type: "string",
|
|
5487
|
-
description: "oneshot supervision path: superd-run <ver> (<triple>) or
|
|
5646
|
+
description: "oneshot supervision path: superd-run <ver> (<triple>), posix-shim, or hub-lease (peer hub server supervises natively)"
|
|
5488
5647
|
},
|
|
5489
5648
|
running: { type: "boolean" },
|
|
5490
5649
|
ttlSec: { type: "integer" },
|
|
@@ -5518,7 +5677,7 @@ function createRemoteTool(driver, opts = {}) {
|
|
|
5518
5677
|
const description = tool.description;
|
|
5519
5678
|
Object.defineProperty(tool, "description", {
|
|
5520
5679
|
enumerable: true,
|
|
5521
|
-
get: () => description + (driver.hasHub() ? "\nHub transport available: hub:<m4> selects a paired remote machine (never self).
|
|
5680
|
+
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). Inline cmd is limited to 4096 chars; scriptPath is the file carrier and is not argv.\n- hub:<m4>:pty[:label] opens the SAME persistent interactive session as every other transport — id, label, input, busy, timeout handoff, background, close, session card, ledger, idle TTL, quota — carried by a local pump child over one authenticated WebSocket to the peer terminal." : "")
|
|
5522
5681
|
});
|
|
5523
5682
|
return tool;
|
|
5524
5683
|
}
|
|
@@ -5549,26 +5708,6 @@ function workbenchValue(r) {
|
|
|
5549
5708
|
if (r.ttlSec !== void 0) v.ttlSec = r.ttlSec;
|
|
5550
5709
|
return v;
|
|
5551
5710
|
}
|
|
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
5711
|
function renderEnvDelta(d) {
|
|
5573
5712
|
const bits = [];
|
|
5574
5713
|
if (d.added.length > 0) bits.push(`+${d.added.join(",+")}`);
|
|
@@ -5577,7 +5716,7 @@ function renderEnvDelta(d) {
|
|
|
5577
5716
|
return bits.length > 0 ? bits.join(" ") : void 0;
|
|
5578
5717
|
}
|
|
5579
5718
|
function renderValue(v) {
|
|
5580
|
-
if (v.kind === "status" || v.kind === "roster" || v.kind === "job" || v.kind === "
|
|
5719
|
+
if (v.kind === "status" || v.kind === "roster" || v.kind === "job" || v.kind === "close" || v.kind === "input") return v.text;
|
|
5581
5720
|
const parts = [];
|
|
5582
5721
|
if (v.reconnected === true) parts.push(RECONNECT_NOTICE);
|
|
5583
5722
|
if (typeof v.notice === "string" && v.notice.length > 0) parts.push(v.notice);
|
|
@@ -5598,15 +5737,188 @@ function renderValue(v) {
|
|
|
5598
5737
|
markers.push(`${secs}s`);
|
|
5599
5738
|
if (v.running === true) markers.push(`still running — poll job_output${v.jobId !== void 0 ? ` ${v.jobId}` : ""}`);
|
|
5600
5739
|
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
|
|
5740
|
+
if (v.outputStatsUnavailable) markers.push("peer output totals/retention unavailable");
|
|
5602
5741
|
parts.push(`[${markers.join(" · ")}]`);
|
|
5603
5742
|
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
5743
|
if (v.stderr !== void 0 && v.stderr.length > 0) parts.push("[stderr]", v.stderr);
|
|
5605
5744
|
return parts.join("\n");
|
|
5606
5745
|
}
|
|
5607
5746
|
|
|
5747
|
+
//#endregion
|
|
5748
|
+
//#region src/remote/transfer-tool.ts
|
|
5749
|
+
const NO_SESSION_KEY = "remote:no-session";
|
|
5750
|
+
/**
|
|
5751
|
+
* The model-visible surface of the `remote_transfer` tool (split out of
|
|
5752
|
+
* `remote`, 2026-10-25). ONE template literal — no backticks, no "${".
|
|
5753
|
+
*/
|
|
5754
|
+
const TRANSFER_TOOL_DESCRIPTION = `File transfer between any two supported endpoints. Every call declares its mode:
|
|
5755
|
+
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.
|
|
5756
|
+
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.
|
|
5757
|
+
|
|
5758
|
+
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.
|
|
5759
|
+
|
|
5760
|
+
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.
|
|
5761
|
+
|
|
5762
|
+
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.
|
|
5763
|
+
|
|
5764
|
+
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.`;
|
|
5765
|
+
const KNOWN_PARAMS = new Set([
|
|
5766
|
+
"mode",
|
|
5767
|
+
"src",
|
|
5768
|
+
"dst",
|
|
5769
|
+
"argv",
|
|
5770
|
+
"pullArgv",
|
|
5771
|
+
"pushArgv",
|
|
5772
|
+
"verify",
|
|
5773
|
+
"mkdir"
|
|
5774
|
+
]);
|
|
5775
|
+
async function transferExecute(ENV, args, exec) {
|
|
5776
|
+
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`);
|
|
5777
|
+
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)`);
|
|
5778
|
+
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`);
|
|
5779
|
+
const sessionKey = ENV.getSessionKey(exec);
|
|
5780
|
+
if (args.mode === "rsync") {
|
|
5781
|
+
if (args.pullArgv !== void 0 || args.pushArgv !== void 0) throw new Error("remote_transfer: rsync takes argv only; pullArgv/pushArgv are copy-only fields");
|
|
5782
|
+
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");
|
|
5783
|
+
const start$1 = Date.now();
|
|
5784
|
+
const receipt$1 = await ENV.driver.rsync({
|
|
5785
|
+
src: args.src,
|
|
5786
|
+
dst: args.dst,
|
|
5787
|
+
...args.argv === void 0 ? {} : { argv: args.argv }
|
|
5788
|
+
}, exec.signal, { sessionKey });
|
|
5789
|
+
if (receipt$1.ok === false || receipt$1.exit !== 0) throw new Error(JSON.stringify(receipt$1));
|
|
5790
|
+
const durationMs$1 = Date.now() - start$1;
|
|
5791
|
+
return {
|
|
5792
|
+
kind: "rsync",
|
|
5793
|
+
text: formatTransferLine("rsync", receipt$1, durationMs$1),
|
|
5794
|
+
receipt: receipt$1,
|
|
5795
|
+
exit: receipt$1.exit,
|
|
5796
|
+
durationMs: durationMs$1
|
|
5797
|
+
};
|
|
5798
|
+
}
|
|
5799
|
+
const start = Date.now();
|
|
5800
|
+
const receipt = await ENV.driver.copy({
|
|
5801
|
+
src: args.src,
|
|
5802
|
+
dst: args.dst,
|
|
5803
|
+
...args.verify === void 0 ? {} : { verify: args.verify },
|
|
5804
|
+
...args.mkdir === void 0 ? {} : { mkdir: args.mkdir },
|
|
5805
|
+
...args.argv === void 0 ? {} : { argv: args.argv },
|
|
5806
|
+
...args.pullArgv === void 0 ? {} : { pullArgv: args.pullArgv },
|
|
5807
|
+
...args.pushArgv === void 0 ? {} : { pushArgv: args.pushArgv }
|
|
5808
|
+
}, exec.signal, { sessionKey });
|
|
5809
|
+
const asReceipt = receipt;
|
|
5810
|
+
const exits = asReceipt.exits ?? [];
|
|
5811
|
+
if (asReceipt.ok === false || exits.some((code) => code !== 0)) throw new Error(JSON.stringify(receipt));
|
|
5812
|
+
const durationMs = Date.now() - start;
|
|
5813
|
+
return {
|
|
5814
|
+
kind: "copy",
|
|
5815
|
+
text: formatTransferLine("copy", receipt, durationMs),
|
|
5816
|
+
receipt,
|
|
5817
|
+
exit: exits.length > 0 ? 0 : null,
|
|
5818
|
+
durationMs
|
|
5819
|
+
};
|
|
5820
|
+
}
|
|
5821
|
+
function createRemoteTransferTool(driver, opts = {}) {
|
|
5822
|
+
const getSessionKey = opts.getSessionKey ?? ((exec) => exec.agent?.id ?? NO_SESSION_KEY);
|
|
5823
|
+
return defineTool({
|
|
5824
|
+
name: "remote_transfer",
|
|
5825
|
+
description: TRANSFER_TOOL_DESCRIPTION,
|
|
5826
|
+
parameters: {
|
|
5827
|
+
mode: {
|
|
5828
|
+
type: "string",
|
|
5829
|
+
enum: ["copy", "rsync"],
|
|
5830
|
+
required: true,
|
|
5831
|
+
description: "Required. copy = tree copy through each endpoint's native CLI, or the Hub binary lane; rsync = SSH only (argv only, no verify/mkdir)."
|
|
5832
|
+
},
|
|
5833
|
+
src: {
|
|
5834
|
+
type: "string",
|
|
5835
|
+
description: "Source [target:]absolute path. No target = the machine running this Agent."
|
|
5836
|
+
},
|
|
5837
|
+
dst: {
|
|
5838
|
+
type: "string",
|
|
5839
|
+
description: "Destination [target:]absolute path. No target = the machine running this Agent."
|
|
5840
|
+
},
|
|
5841
|
+
argv: {
|
|
5842
|
+
type: "array",
|
|
5843
|
+
items: { type: "string" },
|
|
5844
|
+
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."
|
|
5845
|
+
},
|
|
5846
|
+
pullArgv: {
|
|
5847
|
+
type: "array",
|
|
5848
|
+
items: { type: "string" },
|
|
5849
|
+
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."
|
|
5850
|
+
},
|
|
5851
|
+
pushArgv: {
|
|
5852
|
+
type: "array",
|
|
5853
|
+
items: { type: "string" },
|
|
5854
|
+
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."
|
|
5855
|
+
},
|
|
5856
|
+
verify: {
|
|
5857
|
+
type: "boolean",
|
|
5858
|
+
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."
|
|
5859
|
+
},
|
|
5860
|
+
mkdir: {
|
|
5861
|
+
type: "boolean",
|
|
5862
|
+
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."
|
|
5863
|
+
}
|
|
5864
|
+
},
|
|
5865
|
+
output: {
|
|
5866
|
+
schema: {
|
|
5867
|
+
type: "object",
|
|
5868
|
+
additionalProperties: false,
|
|
5869
|
+
properties: {
|
|
5870
|
+
kind: {
|
|
5871
|
+
type: "string",
|
|
5872
|
+
required: true,
|
|
5873
|
+
enum: ["copy", "rsync"]
|
|
5874
|
+
},
|
|
5875
|
+
text: {
|
|
5876
|
+
type: "string",
|
|
5877
|
+
required: true
|
|
5878
|
+
},
|
|
5879
|
+
receipt: { type: "json" },
|
|
5880
|
+
exit: { oneOf: [{ type: "integer" }, { type: "null" }] },
|
|
5881
|
+
durationMs: { type: "integer" }
|
|
5882
|
+
}
|
|
5883
|
+
},
|
|
5884
|
+
render: (_args, value) => [{
|
|
5885
|
+
type: "text",
|
|
5886
|
+
text: value.text
|
|
5887
|
+
}]
|
|
5888
|
+
},
|
|
5889
|
+
async execute(args, exec) {
|
|
5890
|
+
return transferExecute({
|
|
5891
|
+
driver,
|
|
5892
|
+
getSessionKey
|
|
5893
|
+
}, args, exec);
|
|
5894
|
+
}
|
|
5895
|
+
});
|
|
5896
|
+
}
|
|
5897
|
+
/** S8 (2026-10-17): copy/rsync render as one formatted line; the full receipt
|
|
5898
|
+
* stays in the receipt field — raw-JSON dumps are gone from text. */
|
|
5899
|
+
function formatTransferLine(mode, receipt, durationMs) {
|
|
5900
|
+
const r = receipt;
|
|
5901
|
+
const parts = [];
|
|
5902
|
+
if (r.ok === false) parts.push(`✗ ${r.reason ?? "failed"}`);
|
|
5903
|
+
parts.push(`${mode} ${r.src} → ${r.dst}`);
|
|
5904
|
+
if (r.route !== void 0 && r.route !== "") parts.push(`route ${r.route}`);
|
|
5905
|
+
if (r.exits !== void 0) parts.push(`exits [${r.exits.join(",")}]`);
|
|
5906
|
+
else if (r.exit !== void 0) parts.push(`exit ${r.exit}`);
|
|
5907
|
+
if (r.verification !== void 0) parts.push(`verification ${r.verification}`);
|
|
5908
|
+
if (r.created === true) parts.push("created yes");
|
|
5909
|
+
else if (r.created === false) parts.push("created pre-existing");
|
|
5910
|
+
else if (r.created === "n-a") parts.push("created n-a");
|
|
5911
|
+
if (r.cleanup !== void 0 && r.cleanup !== "none-required") parts.push(`cleanup ${r.cleanup}`);
|
|
5912
|
+
if (typeof r.bytes === "number") parts.push(`${r.bytes} bytes / ${r.entries} entries`);
|
|
5913
|
+
parts.push(`${(durationMs / 1e3).toFixed(1)}s`);
|
|
5914
|
+
if (r.note !== void 0 && r.note.length > 0) parts.push(`(${r.note})`);
|
|
5915
|
+
return parts.join(" · ");
|
|
5916
|
+
}
|
|
5917
|
+
|
|
5608
5918
|
//#endregion
|
|
5609
5919
|
//#region src/remote/agent-registration.ts
|
|
5920
|
+
/** The pair a row contributes: execution/PTY and file transfer are separate tools. */
|
|
5921
|
+
const TOOL_NAMES = ["remote", "remote_transfer"];
|
|
5610
5922
|
/** Host listeners attempt once per official creation edge, never on vacancy. */
|
|
5611
5923
|
function installAgentRemote(ctx, config) {
|
|
5612
5924
|
return ctx.on("agent/created", ({ agent }) => {
|
|
@@ -5616,12 +5928,14 @@ function installAgentRemote(ctx, config) {
|
|
|
5616
5928
|
...config,
|
|
5617
5929
|
getHub: () => agent.ctx.get("superdRemoteTransport")
|
|
5618
5930
|
});
|
|
5619
|
-
|
|
5931
|
+
const registered = [];
|
|
5620
5932
|
try {
|
|
5621
|
-
|
|
5933
|
+
registered.push(tools.register(createRemoteTool(driver, { getSessionKey: () => agent.id })));
|
|
5934
|
+
registered.push(tools.register(createRemoteTransferTool(driver, { getSessionKey: () => agent.id })));
|
|
5622
5935
|
} catch (error) {
|
|
5936
|
+
for (const undo of registered.splice(0)) undo();
|
|
5623
5937
|
driver.dispose();
|
|
5624
|
-
if (error instanceof Error && error.message ===
|
|
5938
|
+
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
5939
|
throw error;
|
|
5626
5940
|
}
|
|
5627
5941
|
const stopService = agent.ctx.on("internal/service", (name$1) => {
|
|
@@ -5633,7 +5947,7 @@ function installAgentRemote(ctx, config) {
|
|
|
5633
5947
|
const cleanup = () => {
|
|
5634
5948
|
if (disposal) return disposal;
|
|
5635
5949
|
disposal = driver.dispose();
|
|
5636
|
-
|
|
5950
|
+
for (const undo of registered.splice(0)) undo();
|
|
5637
5951
|
stopService();
|
|
5638
5952
|
ownerUndo?.();
|
|
5639
5953
|
agentUndo?.();
|
|
@@ -5689,12 +6003,10 @@ const name = "dashr-remote";
|
|
|
5689
6003
|
const inject = [];
|
|
5690
6004
|
const Config = Schema.object({
|
|
5691
6005
|
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)
|
|
6006
|
+
idleTtlSec: Schema.natural().min(1).default(REMOTE_PTY_IDLE_TTL_SEC)
|
|
5694
6007
|
}).default({
|
|
5695
6008
|
execTimeoutSec: 120,
|
|
5696
|
-
idleTtlSec: REMOTE_PTY_IDLE_TTL_SEC
|
|
5697
|
-
maxOutputChars: 3e4
|
|
6009
|
+
idleTtlSec: REMOTE_PTY_IDLE_TTL_SEC
|
|
5698
6010
|
});
|
|
5699
6011
|
/** MUST stay an arrow(RM0 plugin.ts 同注:function 声明会被 cordis 当构造器 new 掉,返回的 disposer 丢失)。 */
|
|
5700
6012
|
const apply = (ctx, config) => {
|
|
@@ -5708,4 +6020,4 @@ var plugin_default = {
|
|
|
5708
6020
|
};
|
|
5709
6021
|
|
|
5710
6022
|
//#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 };
|
|
6023
|
+
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 };
|