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.
Files changed (48) hide show
  1. package/agent-agy/dist/agent.d.ts +4 -1
  2. package/agent-agy/dist/agent.js +15 -1
  3. package/agent-agy/dist/index.js +5 -8
  4. package/agent-agy/package.json +6 -6
  5. package/agent-agy-sdk/dist/agent.d.ts +4 -1
  6. package/agent-agy-sdk/dist/agent.js +15 -1
  7. package/agent-agy-sdk/dist/index.js +5 -8
  8. package/agent-agy-sdk/package.json +5 -5
  9. package/agent-claude/dist/agent.js +15 -1
  10. package/agent-claude/package.json +8 -8
  11. package/agent-codex/dist/agent.js +15 -1
  12. package/agent-codex/package.json +7 -7
  13. package/agent-codex-sdk/dist/agent.js +15 -1
  14. package/agent-codex-sdk/dist/index.js +5 -11
  15. package/agent-codex-sdk/package.json +7 -7
  16. package/agent-hermes/dist/agent.js +15 -1
  17. package/agent-hermes/dist/index.js +5 -8
  18. package/agent-hermes/package.json +7 -7
  19. package/agent-omp/dist/agent.js +26 -2
  20. package/agent-omp/package.json +8 -8
  21. package/agent-omp-sdk/dist/agent.d.ts +4 -1
  22. package/agent-omp-sdk/dist/agent.js +15 -1
  23. package/agent-omp-sdk/package.json +8 -8
  24. package/agent-pi/dist/agent.js +20 -2
  25. package/agent-pi/dist/index.js +5 -11
  26. package/agent-pi/package.json +7 -7
  27. package/package.json +21 -21
  28. package/remote/hub-pty-pump.mjs +97 -0
  29. package/remote/plugin.d.ts +406 -203
  30. package/remote/plugin.js +631 -319
  31. package/remote/provenance.json +15 -12
  32. package/superd-hub/dist/agent-model-face.d.ts +93 -0
  33. package/superd-hub/dist/agent-model-face.js +113 -0
  34. package/superd-hub/dist/exec.d.ts +3 -2
  35. package/superd-hub/dist/exec.js +6 -5
  36. package/superd-hub/dist/hub-tool.d.ts +1 -1
  37. package/superd-hub/dist/hub-tool.js +6 -6
  38. package/superd-hub/dist/index.d.ts +1 -0
  39. package/superd-hub/dist/index.js +1 -0
  40. package/superd-hub/dist/remote-provider.js +70 -36
  41. package/superd-hub/dist/world-entry.js +30 -0
  42. package/superd-hub/package.json +14 -10
  43. package/superd-hub/dist/exec-pty-channel.d.ts +0 -58
  44. package/superd-hub/dist/exec-pty-channel.js +0 -162
  45. package/superd-hub/dist/exec-pty-framing.d.ts +0 -34
  46. package/superd-hub/dist/exec-pty-framing.js +0 -89
  47. package/superd-hub/dist/exec-pty-session.d.ts +0 -78
  48. 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
- function tailWindow(text, cap, stats) {
2851
- let tail = text.slice(-cap);
2852
- if (tail.charCodeAt(0) >= 56320 && tail.charCodeAt(0) <= 57343) tail = tail.slice(1);
2853
- const returned = stats?.unit === "bytes" ? Buffer.byteLength(tail) : tail.length;
2854
- const total = stats?.total ?? text.length;
2855
- const updated = stats === void 0 ? {} : { stats: {
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 (total <= returned) return {
2860
- text: tail,
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: `[truncated: showing last ${returned} of ${total} ${stats?.unit === "bytes" ? "bytes" : "chars"}${stats !== void 0 && stats.dropped > 0 ? `; retained ${stats.retained}, dropped head ${stats.dropped}, retention limit ${stats.retentionLimit} ${stats.unit === "bytes" ? "bytes" : "UTF-16 code units"}` : ""}]\n${tail}`,
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
- /** 同一会话命令严格串行(FIFO);死亡/冷态先重启。P4/P5(2026-10-19):超时不再自动
3093
- * 打断——handoff 成 running job(byte-cursor 轮询 + 显式 interrupt,P8 机制原样保留在
3094
- * job.interrupt);background 选项让 dispatch 立即回执。占用者是 detached job 时新命令
3095
- * 拒绝(Q6:诚实回状态优先于排队),纯同步并发 FIFO 语义不变。 */
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
- 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
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.ledger.push(entry);
3112
- if (this.ledger.length > LEDGER_MAX) this.ledger.shift();
3113
- this.inFlight++;
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
- this.write(`printf '\\033]133;D;${nonce};130;%s\\007' "$PWD"\n`);
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
- const run = this.queue.then(attempt, attempt);
3236
- this.queue = run.then(() => void 0, () => void 0);
3237
- return run;
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
- throw new TargetFormatError(`[E_TARGET_FORMAT] remote: ${JSON.stringify(target)} — hub PTY takes no label (hub lane has no session id / parallelism this round)`);
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" || form.form === "hub-pty") {
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 = tailWindow(stdout.text, cap, stdout.stats);
4086
- const err = tailWindow(stderr.text, cap, stderr.stats);
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, cap);
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, cap);
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, cap);
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, cap);
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, cap);
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, cap);
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, cap);
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
- async dispatchPty(session, params, callCtx, timeoutSec, cap) {
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 ? { background: true } : {}
4819
+ ...callCtx.ptyBackground === true ? {
4820
+ background: true,
4821
+ jobId: commandId
4822
+ } : {}
4690
4823
  });
4691
- const out = tailWindow(r.output, cap, r.outputStats?.stdout);
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, cap) {
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 = tailWindow(r.stdout, cap, r.outputStats?.stdout);
4775
- const err = r.stderr.length > 0 ? tailWindow(r.stderr, cap, r.outputStats?.stderr) : void 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, cap) {
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 = tailWindow(stdout.text, cap, stdout.stats);
4800
- const err = tailWindow(stderr.text, cap, stderr.stats);
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
- 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
- });
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 (workbench round, plan
5103
- * 2026-10-19-pty-workbench). ONE template literal — no backticks, no "${".
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 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).
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
- 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
+ [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
- 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).
5294
+ remote { "action": "exec", "target": "pty:dev4via", "cmd": "hostname && sudo -n ss -tlnp | awk 'NR==1 || /LISTEN/' | head -5" }
5121
5295
 
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.`;
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
- 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;
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: "copy",
5192
- text: formatTransferLine("copy", receipt, copyDuration),
5193
- receipt,
5194
- exit: exits.length > 0 ? 0 : null,
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 (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);
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
- 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");
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: ENV.getSessionKey(exec) });
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: ENV.getSessionKey(exec) });
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, ENV.getSessionKey(exec));
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
- "copy",
5335
- "rsync",
5336
- "close"
5518
+ "exec",
5519
+ "add",
5520
+ "status",
5521
+ "close",
5522
+ "input"
5337
5523
  ],
5338
- description: "Optional action; omit for execution/status/roster. close takes target + optional force."
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
- 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."
5343
- },
5344
- src: {
5527
+ target: {
5345
5528
  type: "string",
5346
- description: "copy/rsync source [target:]absolute path; rsync retains trailing-slash semantics"
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
- dst: {
5531
+ cmd: {
5349
5532
  type: "string",
5350
- description: "copy/rsync destination [target:]absolute path"
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
- 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."
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
- 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."
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
- 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."
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: 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)."
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 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.",
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 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)."
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
- 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)."
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
- "copy",
5446
- "rsync",
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 posix-shim"
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). {} 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." : "")
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 === "copy" || v.kind === "rsync" || v.kind === "close") return v.text;
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; returned tail only");
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
- let unregister;
5931
+ const registered = [];
5620
5932
  try {
5621
- unregister = tools.register(createRemoteTool(driver, { getSessionKey: () => agent.id }));
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 === "tool \"remote\" is already registered in this scope" && tools.get("remote", agent) !== void 0) return;
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
- unregister();
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 };