better-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.
@@ -1,4 +1,5 @@
1
1
  import { t as Schema } from "../lib.js";
2
+ import { createRequire } from "node:module";
2
3
  import { Context } from "@deepseek-ai/cordis";
3
4
  import { createHash, randomBytes, randomUUID } from "node:crypto";
4
5
  import { constants, createReadStream, createWriteStream, existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
@@ -11,6 +12,7 @@ import { chmod, lstat, mkdir, mkdtemp, open, readFile, readdir, readlink, rename
11
12
  import { pipeline } from "node:stream/promises";
12
13
  import { Readable } from "node:stream";
13
14
  import { createConnection, createServer } from "node:net";
15
+ import { Buffer as Buffer$1 } from "node:buffer";
14
16
 
15
17
  //#region src/remote/archive.ts
16
18
  const FILE_FRAME_BYTES = 64 * 1024;
@@ -1835,6 +1837,23 @@ function buildSpawnArgv(spawnCommand) {
1835
1837
  "/dev/null"
1836
1838
  ];
1837
1839
  }
1840
+ /** Hub PTY(0.3.2-c;0.3.2-d F4 修正):会话子进程 = 本地 `script` PTY 托管的 WS 字节泵
1841
+ * (hub-pty-pump.mjs)。与 docker exec 的嵌套 PTY 同形,但泵是哑 node 子进程、不像
1842
+ * `ssh -tt` / `docker exec -it` 那样作为前台子进程自己把本地 tty 置 raw——所以本地包装必须
1843
+ * `stty raw`:canonical+ISIG 下 0x03 在本地变 SIGINT 杀泵、0x04 变 EOF 结束泵 stdin
1844
+ * (F4:input ^C/^D 与 job_kill 会打死整条会话)。raw 只作用于本地半段;对端 script PTY
1845
+ * 保持 canonical,^C/^D 在对端产生 SIGINT/EOF 正是我们要的语义(字节原样过线,解释权在
1846
+ * 对端)。kill 链 session 组杀 → 泵死 → WS 关 → 对端组杀,双向自然闭合。连接事实走 wrapper
1847
+ * 内 env 赋值(cookie 不进 argv、不落 ps)。 */
1848
+ function buildHubPtyArgv(endpoint, assets) {
1849
+ const node = assets.nodePath ?? process.execPath;
1850
+ return [
1851
+ "script",
1852
+ "-qfec",
1853
+ `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)}`,
1854
+ "/dev/null"
1855
+ ];
1856
+ }
1838
1857
  /** superd-run helper argv (spec: docs/specs/remote-rescope §5). Probe and upload are single
1839
1858
  * `sh -c` hops (busybox/alpine/darwin-safe); run/killgroup over ssh go through exactly one
1840
1859
  * remote-shell parse, same discipline as buildOneShotArgv. */
@@ -1955,22 +1974,25 @@ function stripAnsi(s) {
1955
1974
  function normalizePtyText(s) {
1956
1975
  return stripAnsi(s).replace(/\r\n/g, "\n").replace(/\r/g, "");
1957
1976
  }
1958
- function tailWindow(text, cap, stats) {
1959
- let tail = text.slice(-cap);
1960
- if (tail.charCodeAt(0) >= 56320 && tail.charCodeAt(0) <= 57343) tail = tail.slice(1);
1961
- const returned = stats?.unit === "bytes" ? Buffer.byteLength(tail) : tail.length;
1962
- const total = stats?.total ?? text.length;
1963
- const updated = stats === void 0 ? {} : { stats: {
1977
+ /** 0.3.2-e:自有 30K 返回窗口已删(原生 agent runtime 的 spill/输出保护接管模型上下文
1978
+ * 尺寸)。本 helper 只做 retention 披露:ring 留存(4M UTF-16 / 4MiB bytes)未驱逐时
1979
+ * 全量透传(returned=retained,无 banner);驱逐(dropped>0,仅 >4M 输出发生)时打一行
1980
+ * banner 且 truncated=原始总量。text 已是 ring 尾部,不再二次裁剪。 */
1981
+ function disclose(text, stats) {
1982
+ if (stats === void 0) return { text };
1983
+ const returned = stats.retained;
1984
+ const updated = { stats: {
1964
1985
  ...stats,
1965
1986
  returned
1966
1987
  } };
1967
- if (total <= returned) return {
1968
- text: tail,
1988
+ if (stats.dropped <= 0) return {
1989
+ text,
1969
1990
  ...updated
1970
1991
  };
1992
+ const unit = stats.unit === "bytes" ? "bytes" : "chars";
1971
1993
  return {
1972
- 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}`,
1973
- truncated: total,
1994
+ 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}`,
1995
+ truncated: stats.total,
1974
1996
  ...updated
1975
1997
  };
1976
1998
  }
@@ -2114,13 +2136,15 @@ const summaryOf = (cmd) => {
2114
2136
  };
2115
2137
  const DEFAULT_INIT_TIMEOUT_SEC = 30;
2116
2138
  const INTERRUPT_GRACE_MS = 3e3;
2139
+ /** F2(0.3.2-d):\x03 触发 ISIG 时内核 flush 输入队列——130-marker 必须等 flush 完成
2140
+ * 再入队,否则首字节被吞(实测 'rintf' not found → 会话死)。 */
2141
+ const MARKER_SETTLE_MS = 80;
2117
2142
  /** pty 收集内存安全帽(Ruling P15,与 oneshot HARD_CAP 对齐):模型面只见尾窗,保留尾部。 */
2118
2143
  const OUTPUT_HARD_CAP = 4e6;
2119
2144
  var PtySession = class {
2120
2145
  proc = null;
2121
2146
  state = "cold";
2122
2147
  startPromise = null;
2123
- queue = Promise.resolve();
2124
2148
  idleTimer = null;
2125
2149
  onChunk = null;
2126
2150
  deathWaiters = [];
@@ -2128,6 +2152,8 @@ var PtySession = class {
2128
2152
  inFlight = 0;
2129
2153
  /** P4/P5:当前占用前台的 detached job(background/超时 handoff)。非 null 时新命令拒绝(Q6)。 */
2130
2154
  detachedJob = null;
2155
+ /** §3.2(2026-10-25):当前占用会话的命令——dispatch 同步占位,settle/cleanup 释放;input 的唯一写入对象。 */
2156
+ activeCommand = null;
2131
2157
  /** 账本(§5):本会话最近 20 条命令(FIFO,超出丢最旧)。 */
2132
2158
  ledger = [];
2133
2159
  /** env 侧信道基线(init E 帧);undefined = 侧信道不可用(不报假 delta)。 */
@@ -2160,10 +2186,11 @@ var PtySession = class {
2160
2186
  get detachedCommandId() {
2161
2187
  return this.detachedJob?.commandId ?? null;
2162
2188
  }
2163
- /** job 通道收养后回填 jobId(timeout handoff 时 registry id ≠ commandId,账本如实记录两者)。 */
2189
+ /** job 通道收养后回填 jobId(timeout handoff 时 registry id ≠ commandId,账本如实记录两者);运行中的命令同步携带(input 回执用)。 */
2164
2190
  attachJob(commandId, jobId) {
2165
2191
  const entry = [...this.ledger].reverse().find((e) => e.commandId === commandId);
2166
2192
  if (entry !== void 0) entry.jobId = jobId;
2193
+ if (this.activeCommand !== null && this.activeCommand.commandId === commandId) this.activeCommand.jobId = jobId;
2167
2194
  }
2168
2195
  /** Workbench 身份(§3):id + 可选 label + connection + byo 标记。 */
2169
2196
  get identity() {
@@ -2197,28 +2224,55 @@ var PtySession = class {
2197
2224
  async warm() {
2198
2225
  await this.start();
2199
2226
  }
2200
- /** 同一会话命令严格串行(FIFO);死亡/冷态先重启。P4/P5(2026-10-19):超时不再自动
2201
- * 打断——handoff 成 running job(byte-cursor 轮询 + 显式 interrupt,P8 机制原样保留在
2202
- * job.interrupt);background 选项让 dispatch 立即回执。占用者是 detached job 时新命令
2203
- * 拒绝(Q6:诚实回状态优先于排队),纯同步并发 FIFO 语义不变。 */
2227
+ /** 同一会话命令占位制(2026-10-25 interactive-stdin 轮):dispatch 入口**同步**建立
2228
+ * 命令占位(activeCommand + inFlight + 账本条目)——上一条命令仍在运行(同步等待 /
2229
+ * 等输入 / detached job)时新 cmd 立即 E_SESSION_BUSY;原「纯同步并发排 FIFO」语义
2230
+ * 废除(排队会让等输入的命令与补输入动作互相卡死,也给 busy 留下绕行窗口)。
2231
+ * P4/P5:超时不杀命令而是 handoff 成 running job;background 立即回执;P8 显式
2232
+ * interrupt 保留在 job.interrupt;死亡/冷态先重启(reconnect 语义不变)。 */
2204
2233
  dispatch(cmd, dOpts) {
2234
+ if (this.state === "disposed") return Promise.reject(/* @__PURE__ */ new Error(`[E_SESSION_DISPOSED] remote pty session '${this.opts.id}' was disposed before dispatch`));
2235
+ if (this.activeCommand !== null) {
2236
+ const handle = this.activeCommand.jobId ?? this.activeCommand.commandId;
2237
+ return Promise.reject(/* @__PURE__ */ new Error(`[E_SESSION_BUSY] remote: pty session '${this.opts.id}' still runs a command (${this.activeCommand.commandId}) — 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`));
2238
+ }
2239
+ const commandId = dOpts.commandId;
2240
+ const entry = {
2241
+ commandId,
2242
+ summary: summaryOf(cmd),
2243
+ exit: null,
2244
+ durationMs: 0,
2245
+ running: true,
2246
+ ...dOpts.jobId !== void 0 ? { jobId: dOpts.jobId } : {}
2247
+ };
2248
+ this.ledger.push(entry);
2249
+ if (this.ledger.length > LEDGER_MAX) this.ledger.shift();
2250
+ this.activeCommand = {
2251
+ commandId,
2252
+ ...dOpts.jobId !== void 0 ? { jobId: dOpts.jobId } : {}
2253
+ };
2254
+ this.inFlight++;
2205
2255
  const attempt = async () => {
2206
2256
  const started = Date.now();
2207
2257
  const reconnected = this.state === "dead";
2208
- if (this.state !== "ready") await this.start();
2209
- if (this.state === "disposed") throw new Error(`[E_SESSION_DISPOSED] remote pty session '${this.opts.id}' was disposed before dispatch`);
2210
- 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`);
2211
- const commandId = dOpts.commandId;
2212
- const entry = {
2213
- commandId,
2214
- summary: summaryOf(cmd),
2215
- exit: null,
2216
- durationMs: 0,
2217
- running: true
2258
+ /** 占位撤销:命令从未真正启动(建连失败 / 建连中被 dispose)——释放 busy 与计数,
2259
+ * 撤掉账本条目(与旧时序等价:条目原本在 start 成功之后才建立)。 */
2260
+ const abandon = () => {
2261
+ this.activeCommand = null;
2262
+ this.inFlight--;
2263
+ const index = this.ledger.indexOf(entry);
2264
+ if (index >= 0) this.ledger.splice(index, 1);
2218
2265
  };
2219
- this.ledger.push(entry);
2220
- if (this.ledger.length > LEDGER_MAX) this.ledger.shift();
2221
- this.inFlight++;
2266
+ if (this.state !== "ready") try {
2267
+ await this.start();
2268
+ } catch (error) {
2269
+ abandon();
2270
+ throw error;
2271
+ }
2272
+ if (this.state === "disposed") {
2273
+ abandon();
2274
+ throw new Error(`[E_SESSION_DISPOSED] remote pty session '${this.opts.id}' was disposed before dispatch`);
2275
+ }
2222
2276
  return await new Promise((resolve$1) => {
2223
2277
  const nonce = genNonce();
2224
2278
  const ring = new Utf16TailRing(OUTPUT_HARD_CAP);
@@ -2227,6 +2281,7 @@ var PtySession = class {
2227
2281
  let jobSettled = false;
2228
2282
  let interruptTimer;
2229
2283
  let killTimer;
2284
+ let markerTimer;
2230
2285
  let jobDone = null;
2231
2286
  let envNext;
2232
2287
  const detach = () => {
@@ -2260,9 +2315,11 @@ var PtySession = class {
2260
2315
  const cleanup = () => {
2261
2316
  this.onChunk = null;
2262
2317
  this.detachedJob = null;
2318
+ this.activeCommand = null;
2263
2319
  this.deathWaiters = this.deathWaiters.filter((w) => w !== deathWaiter);
2264
2320
  if (interruptTimer !== void 0) clearTimeout(interruptTimer);
2265
2321
  if (killTimer !== void 0) clearTimeout(killTimer);
2322
+ if (markerTimer !== void 0) clearTimeout(markerTimer);
2266
2323
  };
2267
2324
  const settle = (r) => {
2268
2325
  if (jobSettled) return;
@@ -2317,7 +2374,9 @@ var PtySession = class {
2317
2374
  const twoStage = () => {
2318
2375
  interrupted = true;
2319
2376
  this.write("");
2320
- this.write(`printf '\\033]133;D;${nonce};130;%s\\007' "$PWD"\n`);
2377
+ markerTimer = setTimeout(() => {
2378
+ this.write(`printf '\\033]133;D;${nonce};130;%s\\007' "$PWD"\n`);
2379
+ }, MARKER_SETTLE_MS);
2321
2380
  killTimer = setTimeout(() => {
2322
2381
  this.killTree();
2323
2382
  this.markDead();
@@ -2340,9 +2399,33 @@ var PtySession = class {
2340
2399
  interruptTimer = setTimeout(() => detach(), dOpts.timeoutSec === void 0 ? 12e4 : dOpts.timeoutSec * 1e3);
2341
2400
  });
2342
2401
  };
2343
- const run = this.queue.then(attempt, attempt);
2344
- this.queue = run.then(() => void 0, () => void 0);
2345
- return run;
2402
+ return attempt();
2403
+ }
2404
+ /** §3.1(2026-10-25)interactive-stdin:向当前运行中的命令追加 stdin——只写既有通道
2405
+ * 与身份,不新建命令、账本条目或输出游标;不重连、不重放(dead/disposed 明确拒绝)。
2406
+ * 校验与写入全同步:多次 input 按调用顺序整段写入不交错;写入失败如实抛错(私有
2407
+ * write() 的静默忽略不适用于回执语义)。stdin:"" 是零字节写入,不是 EOF。 */
2408
+ input(options) {
2409
+ const fail = (code, hint) => Promise.reject(/* @__PURE__ */ new Error(`${code} remote: ${hint}`));
2410
+ if (this.state === "disposed") return fail("[E_SESSION_DISPOSED]", `pty session '${displayKey(this.opts.key)}' was disposed — input never reaches a disposed session`);
2411
+ 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`);
2412
+ 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`);
2413
+ const active = this.activeCommand;
2414
+ 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`);
2415
+ 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`);
2416
+ const stdin = this.proc?.stdin;
2417
+ if (stdin?.writable !== true) return fail("[E_SESSION_WRITE_FAILED]", `pty session '${displayKey(this.opts.key)}' has no writable stdin channel (state ${this.state}) — follow the running command via job_output for its settled result`);
2418
+ try {
2419
+ stdin.write(options.stdin);
2420
+ } catch (error) {
2421
+ return fail("[E_SESSION_WRITE_FAILED]", `writing stdin to pty session '${displayKey(this.opts.key)}' failed: ${error instanceof Error ? error.message : String(error)}`);
2422
+ }
2423
+ return Promise.resolve({
2424
+ session: displayKey(this.opts.key),
2425
+ commandId: active.commandId,
2426
+ ...active.jobId !== void 0 ? { jobId: active.jobId } : {},
2427
+ writtenBytes: Buffer$1.byteLength(options.stdin, "utf8")
2428
+ });
2346
2429
  }
2347
2430
  /** TTL / row teardown:尽力 exit,1s 后杀组,出池。 */
2348
2431
  dispose() {
@@ -2351,6 +2434,7 @@ var PtySession = class {
2351
2434
  this.clearDeathTimer();
2352
2435
  this.state = "disposed";
2353
2436
  this.startPromise = null;
2437
+ this.detachedJob?.interrupt();
2354
2438
  const proc = this.proc;
2355
2439
  const close = this.procClose;
2356
2440
  const group = this.reapGroup(proc?.pid, 1e3, close);
@@ -2361,7 +2445,7 @@ var PtySession = class {
2361
2445
  ]).then(() => {
2362
2446
  if (this.proc === proc) this.proc = null;
2363
2447
  });
2364
- this.write("exit\n");
2448
+ if (this.detachedJob === null) this.write("exit\n");
2365
2449
  this.opts.onDead?.();
2366
2450
  return this.disposal;
2367
2451
  }
@@ -2402,6 +2486,9 @@ var PtySession = class {
2402
2486
  proc.stderr.setEncoding("utf8").on("data", (d) => {
2403
2487
  this.diag = (this.diag + d).slice(-2e3);
2404
2488
  });
2489
+ proc.stdin.on("error", (err) => {
2490
+ this.diag = (this.diag + `\n[remote: stdin write failed: ${err.message}]`).slice(-2e3);
2491
+ });
2405
2492
  this.procClose = new Promise((resolve$1) => {
2406
2493
  proc.once("close", () => {
2407
2494
  this.markDead();
@@ -2669,7 +2756,15 @@ function parseTargetForm(target) {
2669
2756
  form: "hub-pty",
2670
2757
  machine: segs[0]
2671
2758
  };
2672
- throw new TargetFormatError(`[E_TARGET_FORMAT] remote: ${JSON.stringify(target)} — hub PTY takes no label (hub lane has no session id / parallelism this round)`);
2759
+ if (segs.length === 3 && segs[1] === "pty") {
2760
+ if (segs[2] === void 0 || segs[2].length === 0) throw new TargetFormatError(`[E_TARGET_FORMAT] remote: ${JSON.stringify(target)} — empty label after ":pty:"`);
2761
+ return {
2762
+ form: "hub-pty",
2763
+ machine: segs[0],
2764
+ label: segs[2]
2765
+ };
2766
+ }
2767
+ throw new TargetFormatError(`[E_TARGET_FORMAT] remote: ${JSON.stringify(target)} — hub PTY is hub:<m4>:pty[:label]`);
2673
2768
  }
2674
2769
  for (const transport of TRANSPORTS) {
2675
2770
  if (!target.startsWith(`${transport}:`)) continue;
@@ -3092,6 +3187,16 @@ function renderRoster(scan, entries, sessions, hubTargets) {
3092
3187
  const REMOTE_PTY_IDLE_TTL_SEC = 900;
3093
3188
  /** P3(user 2026-10-19):每 agent 10 个 PTY 会话,跨所有 connection 统一计;仅 error message 披露。 */
3094
3189
  const REMOTE_PTY_SESSION_LIMIT = 10;
3190
+ /** hub 线 one-shot 监督披露(0.3.2-c):对端 = 我们自己的 hub server(bash -lc 于独立进程组,
3191
+ * Node 原生 TERM→2s→KILL + 输出环 + 租约生命周期);superd-run helper 是 dumb-pipe 专用物,
3192
+ * 从不经 hub 上传(non-goal,docs/10_plans/2026-10-25-hub-remote-parity.md §3 O1/O8)。 */
3193
+ const HUB_RUNNER = "hub-lease (peer bash -lc, peer Node supervision)";
3194
+ /** hub PTY 泵资产:pump 与本模块同目录(npm 包与 Super 闭包同构,helper.ts 同款纪律)。
3195
+ * ws 模块路径由 transport 侧(superd-hub 上下文,分叉的 proven 解析链)解析后经
3196
+ * ptyEndpoint 传入——闭包自身子树无可解析的 ws,按名解析不在闭包里做(0.3.2-c 定稿)。 */
3197
+ function hubPtyAssets() {
3198
+ return { pumpPath: join(dirname(fileURLToPath(import.meta.url)), "hub-pty-pump.mjs") };
3199
+ }
3095
3200
  /** 账本键与 jobId 同构(同格式 `<kind>-<16hex>`);background 通道预传入 registry id 时两者字面相等。 */
3096
3201
  const genCommandId = () => `remote-${randomUUID().replace(/-/g, "").slice(0, 16)}`;
3097
3202
  /** 双轨调度器:参数校验 → 目标语法(parseTargetForm)→ 统一 label 池 → oneshot 子进程 / pty 会话池。 */
@@ -3155,32 +3260,12 @@ var RemoteDriver = class {
3155
3260
  }
3156
3261
  async execute(params, form, callCtx) {
3157
3262
  const timeoutSec = params.timeout ?? this.opts.execTimeoutSec ?? 120;
3158
- const cap = this.opts.maxOutputChars ?? 3e4;
3159
3263
  const sessionKey = callCtx.sessionKey ?? "no-session";
3160
3264
  const hasCmd = params.cmd !== void 0 && params.cmd.length > 0;
3161
3265
  params.spawn !== void 0 && params.spawn.length;
3162
- if (form.form === "hub" || form.form === "hub-pty") {
3266
+ if (form.form === "hub") {
3163
3267
  const hub = this.requireHub();
3164
3268
  this.hubProviders.add(hub);
3165
- if (form.form === "hub-pty") {
3166
- if (!hasCmd) throw new Error("[E_PARAMS] remote: hub PTY takes cmd");
3167
- const result$1 = await hub.pty(form.machine, params.cmd, this.hubOwner, {
3168
- stdin: params.stdin,
3169
- timeout: timeoutSec
3170
- });
3171
- const out$1 = tailWindow(result$1.stdout, cap, result$1.outputStats?.stdout);
3172
- return {
3173
- ...result$1,
3174
- target: `hub:${form.machine}`,
3175
- connection: `hub:${form.machine}`,
3176
- stdout: `[hub:${form.machine} pty — no session id, no parallel, no background]\n${out$1.text}`,
3177
- ...out$1.stats ? { outputStats: {
3178
- ...result$1.outputStats,
3179
- stdout: out$1.stats
3180
- } } : {},
3181
- ...out$1.truncated === void 0 ? {} : { truncated: out$1.truncated }
3182
- };
3183
- }
3184
3269
  const process$1 = this.own(await hub.start(form.machine, {
3185
3270
  ...params,
3186
3271
  target: `hub:${form.machine}`,
@@ -3190,8 +3275,8 @@ var RemoteDriver = class {
3190
3275
  if (this.disposed) process$1.cancel("owner disposed");
3191
3276
  const result = await process$1.done;
3192
3277
  const stdout = process$1.read("stdout", 0), stderr = process$1.read("stderr", 0);
3193
- const out = tailWindow(stdout.text, cap, stdout.stats);
3194
- const err = tailWindow(stderr.text, cap, stderr.stats);
3278
+ const out = disclose(stdout.text, stdout.stats);
3279
+ const err = disclose(stderr.text, stderr.stats);
3195
3280
  return {
3196
3281
  ...result,
3197
3282
  mode: "oneshot",
@@ -3199,6 +3284,7 @@ var RemoteDriver = class {
3199
3284
  connection: `hub:${form.machine}`,
3200
3285
  cwd: null,
3201
3286
  reconnected: false,
3287
+ runner: HUB_RUNNER,
3202
3288
  stdout: (!stdout.stats && stdout.lossy ? "[truncated: peer returned only a tail; exact total/retained/dropped unavailable]\n" : "") + out.text,
3203
3289
  stderr: [err.text, stderr.diagnostic].filter(Boolean).join("\n"),
3204
3290
  ...out.stats ? { outputStats: {
@@ -3215,7 +3301,7 @@ var RemoteDriver = class {
3215
3301
  await session.warm();
3216
3302
  return this.warmReceipt(session);
3217
3303
  }
3218
- return await this.dispatchPty(session, params, callCtx, timeoutSec, cap);
3304
+ return await this.dispatchPty(session, params, callCtx, timeoutSec);
3219
3305
  }
3220
3306
  if (params.ssh !== void 0) {
3221
3307
  const label = form.form === "direct" || form.form === "bare" ? form.name : void 0;
@@ -3236,28 +3322,28 @@ var RemoteDriver = class {
3236
3322
  kind: "ssh",
3237
3323
  host: normalizeSshConnection(params.ssh).host,
3238
3324
  connection: normalizeSshConnection(params.ssh)
3239
- }, `ssh:${name$1}`, params, timeoutSec, cap);
3325
+ }, `ssh:${name$1}`, params, timeoutSec);
3240
3326
  }
3241
3327
  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");
3242
3328
  if (form.form === "pty-selector") {
3243
3329
  const session = this.resolvePty(form.idOrLabel, sessionKey);
3244
3330
  if (!hasCmd) return this.cardReceipt(session);
3245
- return await this.dispatchPty(session, params, callCtx, timeoutSec, cap);
3331
+ return await this.dispatchPty(session, params, callCtx, timeoutSec);
3246
3332
  }
3247
3333
  if (form.form === "direct") {
3248
3334
  const plan$1 = form.transport === "ssh" ? this.sshPlanFor(form.name, sessionKey) : resolveTarget(`${form.transport}:${form.name}`);
3249
- return await this.oneshot(plan$1, `${form.transport}:${form.name}`, params, timeoutSec, cap);
3335
+ return await this.oneshot(plan$1, `${form.transport}:${form.name}`, params, timeoutSec);
3250
3336
  }
3251
3337
  if (form.form !== "bare") throw new Error("[E_INTERNAL] remote: unreachable target form after routing");
3252
3338
  const entry = await this.resolvePoolEntry(form.name, sessionKey);
3253
3339
  if (entry.kind === "pty") {
3254
3340
  const session = this.resolvePty(form.name, sessionKey);
3255
3341
  if (!hasCmd) return this.cardReceipt(session);
3256
- return await this.dispatchPty(session, params, callCtx, timeoutSec, cap);
3342
+ return await this.dispatchPty(session, params, callCtx, timeoutSec);
3257
3343
  }
3258
- if (entry.kind === "hub") return await this.hubOneshot(entry.machine, `hub:${entry.machine}`, params, timeoutSec, cap);
3344
+ if (entry.kind === "hub") return await this.hubOneshot(entry.machine, `hub:${entry.machine}`, params, timeoutSec);
3259
3345
  const plan = this.planOf(entry);
3260
- return await this.oneshot(plan, form.name, params, timeoutSec, cap);
3346
+ return await this.oneshot(plan, form.name, params, timeoutSec);
3261
3347
  }
3262
3348
  /** Native-first copy routing (spec: remote-native-shortcut) — unchanged by the workbench round. */
3263
3349
  copy(request, signal, callCtx = {}) {
@@ -3701,6 +3787,18 @@ var RemoteDriver = class {
3701
3787
  ...form.label !== void 0 ? { label: form.label } : {}
3702
3788
  }, sessionKey) };
3703
3789
  }
3790
+ if (form.form === "hub-pty") {
3791
+ const hub = this.requireHub();
3792
+ this.hubProviders.add(hub);
3793
+ const endpoint = hub.ptyEndpoint(form.machine);
3794
+ const argv = this.opts.hubPtyArgvFor !== void 0 ? this.opts.hubPtyArgvFor(endpoint) : buildHubPtyArgv(endpoint, hubPtyAssets());
3795
+ return { session: this.createSession({
3796
+ argv,
3797
+ connection: `hub:${form.machine}`,
3798
+ byo: false,
3799
+ ...form.label !== void 0 ? { label: form.label } : {}
3800
+ }, sessionKey) };
3801
+ }
3704
3802
  }
3705
3803
  /** background PTY lane:会话先占位(不拨号)再进官方 jobs registry——首回执必须能寻址;
3706
3804
  * registry 拒绝时把新建的会话拆掉(复用形的会话绝不拆)。返回 undefined = 走普通 oneshot job。 */
@@ -3788,15 +3886,50 @@ var RemoteDriver = class {
3788
3886
  this.pool.get(key)?.attachJob(commandId, jobId);
3789
3887
  }
3790
3888
  }
3791
- async dispatchPty(session, params, callCtx, timeoutSec, cap) {
3889
+ /** §3.1(2026-10-25)interactive-stdin:input 动作——向已有 live PTY 的当前运行命令
3890
+ * 追加 stdin。只解析池内已存在的会话(pty:<id|label> / 裸 label 命中 pty 项),
3891
+ * 不触发发现、拨号、创建或重连;machine target 与创建形(:pty 后缀,含 hub:<m4>:pty)
3892
+ * 一律明确拒绝并给出可恢复指引。回执附 connection/label 供工具层渲染。 */
3893
+ async input(target, options, callCtx = {}) {
3894
+ this.assertOpen();
3895
+ const sessionKey = callCtx.sessionKey ?? "no-session";
3896
+ const form = parseTargetForm(target);
3897
+ let selector;
3898
+ if (form.form === "pty-selector") {
3899
+ if (form.idOrLabel === void 0) throw new Error("[E_PARAMS] remote: \"pty\" alone selects nothing — append an id/label (pty:pty-1a2b, pty:build)");
3900
+ selector = form.idOrLabel;
3901
+ } else if (form.form === "bare") {
3902
+ 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>`);
3903
+ selector = form.name;
3904
+ } 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`);
3905
+ 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`);
3906
+ const session = this.resolvePty(selector, sessionKey);
3907
+ const receipt = await session.input(options);
3908
+ const id = session.identity;
3909
+ return {
3910
+ ...receipt,
3911
+ connection: id.connection,
3912
+ ...id.label !== void 0 ? { label: id.label } : {}
3913
+ };
3914
+ }
3915
+ async dispatchPty(session, params, callCtx, timeoutSec) {
3916
+ const hubConn = session.identity.connection;
3917
+ if (hubConn.startsWith("hub:")) {
3918
+ const hub = this.requireHub();
3919
+ this.hubProviders.add(hub);
3920
+ hub.ptyEndpoint(hubConn.slice(4));
3921
+ }
3792
3922
  const commandId = callCtx.commandId ?? genCommandId();
3793
3923
  const r = await session.dispatch(params.cmd, {
3794
3924
  stdin: params.stdin,
3795
3925
  timeoutSec,
3796
3926
  commandId,
3797
- ...callCtx.ptyBackground === true ? { background: true } : {}
3927
+ ...callCtx.ptyBackground === true ? {
3928
+ background: true,
3929
+ jobId: commandId
3930
+ } : {}
3798
3931
  });
3799
- const out = tailWindow(r.output, cap, r.outputStats?.stdout);
3932
+ const out = disclose(r.output, r.outputStats?.stdout);
3800
3933
  const id = session.identity;
3801
3934
  return {
3802
3935
  mode: "pty",
@@ -3864,7 +3997,7 @@ var RemoteDriver = class {
3864
3997
  ...this.ptyTtl(session.key)
3865
3998
  };
3866
3999
  }
3867
- async oneshot(plan, display, params, timeoutSec, cap) {
4000
+ async oneshot(plan, display, params, timeoutSec) {
3868
4001
  this.assertOpen();
3869
4002
  const process$1 = this.own(await this.nativeProcess(plan, params, timeoutSec));
3870
4003
  if (this.disposed) process$1.cancel("owner disposed");
@@ -3879,8 +4012,8 @@ var RemoteDriver = class {
3879
4012
  stderr: stderr.stats
3880
4013
  }
3881
4014
  };
3882
- const out = tailWindow(r.stdout, cap, r.outputStats?.stdout);
3883
- const err = r.stderr.length > 0 ? tailWindow(r.stderr, cap, r.outputStats?.stderr) : void 0;
4015
+ const out = disclose(r.stdout, r.outputStats?.stdout);
4016
+ const err = r.stderr.length > 0 ? disclose(r.stderr, r.outputStats?.stderr) : void 0;
3884
4017
  return {
3885
4018
  mode: "oneshot",
3886
4019
  target: display,
@@ -3899,13 +4032,13 @@ var RemoteDriver = class {
3899
4032
  ...this.runnerOf.get(process$1) === void 0 ? {} : { runner: this.runnerOf.get(process$1) }
3900
4033
  };
3901
4034
  }
3902
- async hubOneshot(machine, display, params, timeoutSec, cap) {
4035
+ async hubOneshot(machine, display, params, timeoutSec) {
3903
4036
  const process$1 = this.own(await this.hubOneshotProcess(machine, params));
3904
4037
  if (this.disposed) process$1.cancel("owner disposed");
3905
4038
  const result = await process$1.done;
3906
4039
  const stdout = process$1.read("stdout", 0), stderr = process$1.read("stderr", 0);
3907
- const out = tailWindow(stdout.text, cap, stdout.stats);
3908
- const err = tailWindow(stderr.text, cap, stderr.stats);
4040
+ const out = disclose(stdout.text, stdout.stats);
4041
+ const err = disclose(stderr.text, stderr.stats);
3909
4042
  return {
3910
4043
  ...result,
3911
4044
  mode: "oneshot",
@@ -3913,6 +4046,7 @@ var RemoteDriver = class {
3913
4046
  connection: display,
3914
4047
  cwd: null,
3915
4048
  reconnected: false,
4049
+ runner: HUB_RUNNER,
3916
4050
  stdout: (!stdout.stats && stdout.lossy ? "[truncated: peer returned only a tail; exact total/retained/dropped unavailable]\n" : "") + out.text,
3917
4051
  stderr: [err.text, stderr.diagnostic].filter(Boolean).join("\n"),
3918
4052
  ...out.stats ? { outputStats: {
@@ -4070,16 +4204,20 @@ const ptyOutputSources = (holder) => [{
4070
4204
  lossy: false
4071
4205
  }
4072
4206
  }];
4073
- const ptyOutcome = (aborted, result) => ({
4074
- status: aborted ? "killed" : result.exit === null ? "failed" : "completed",
4075
- detail: aborted ? `interrupted: ${result.exit === null ? "session died" : `exit ${result.exit}`}` : result.exit === null ? "session died" : `exit code: ${result.exit}`
4076
- });
4207
+ /** 0.3.2-d CF:timedOut=true 且 aborted=false = 会话侧主动收束(close force / 行回收走的
4208
+ * dispose→interrupt 两段路)——同为 killed;exit 到手用 exit,只剩会话死亡时写 session closed。 */
4209
+ const ptyOutcome = (aborted, result) => {
4210
+ const interrupted = aborted || result.timedOut === true;
4211
+ return {
4212
+ status: interrupted ? "killed" : result.exit === null ? "failed" : "completed",
4213
+ detail: interrupted ? `interrupted: ${result.exit === null ? aborted ? "session died" : "session closed" : `exit ${result.exit}`}` : result.exit === null ? "session died" : `exit code: ${result.exit}`
4214
+ };
4215
+ };
4077
4216
  /** No preparation or discovery before the official registry's admission. */
4078
4217
  function startRemoteJob(registry, input, exec, driver) {
4079
4218
  if (!exec.agent) throw new Error("remote: background jobs require a live Agent owner");
4080
4219
  const form = parseTargetForm(input.target);
4081
4220
  if (form.form === "pty-selector" || form.form === "create-pty" || form.form === "hub-pty" || input.spawn !== void 0 || input.mode === "pty") {
4082
- 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");
4083
4221
  const holder = {};
4084
4222
  return registry.start({
4085
4223
  kind: "remote",
@@ -4205,116 +4343,157 @@ function adoptRunningPtyJob(registry, exec, label, job) {
4205
4343
 
4206
4344
  //#endregion
4207
4345
  //#region src/remote/tool.ts
4208
- const NO_SESSION_KEY = "remote:no-session";
4346
+ const NO_SESSION_KEY$1 = "remote:no-session";
4209
4347
  /**
4210
- * The model-visible surface of the `remote` tool (workbench round, plan
4211
- * 2026-10-19-pty-workbench). ONE template literal — no backticks, no "${".
4348
+ * The model-visible surface of the `remote` tool (0.3.2-d user-ruled restructure:
4349
+ * Actions / Targets / PTY / Oneshot / Examples — the PTY block is the user's verbatim
4350
+ * final draft; Examples receipts match renderValue byte-for-byte).
4351
+ * ONE template literal — no backticks, no "${".
4352
+ *
4353
+ * 语义目标(surface 就是产品):动作是唯一的派发维度;PTY 四词定位——stateful oneshot /
4354
+ * interact-able stdin / managed output / persistent env——回执形状与 oneshot 相同、只增状态,
4355
+ * 运行中的命令可分步观察后再补输(input 动作);多步与交互任务默认进 PTY。
4212
4356
  */
4213
- const REMOTE_TOOL_DESCRIPTION = `Remote execution and file transfer over ssh / docker / incus, plus stateful PTY workbenches. Pick the call shape:
4214
- {} → roster (local scan; refreshes the name pool: ssh aliases, BYO identities, docker/incus containers, hub machines, live pty sessions)
4215
- {target} → status probe: machine reachability + its pty sessions, or a session card (connection, cwd, ledger of the last 20 commands) for pty:<id|label>
4216
- {target, cmd} → run cmd — oneshot (stateless bash -lc) on any machine target
4217
- {target, scriptPath:"/abs"} → LOCAL script over stdin to a fixed bash -s (oneshot only; no cmd/stdin/spawn mix)
4218
- {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).
4219
- {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)
4220
- {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.
4221
- {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.
4222
- {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.
4223
- {action:"copy"|"rsync", src, dst, argv?} → file transfer (see the action/src/dst fields)
4224
- 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).
4357
+ 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.
4358
+
4359
+ Actions (action is the only dispatch dimension; unknown fields are rejected):
4360
+ - exec {target, cmd | scriptPath, stdin?, timeout?, run_in_background?}: run a command. Machine target → oneshot; pty:<id|label> → run inside that live session; <machine>:pty[:label] → create a NEW session and run there.
4361
+ - add {ssh:{...} | spawn:"<cmd>", target?}: register a BYO endpoint (identity or interactive-shell command); no command runs. Returns the addressable label; a taken label answers [E_LABEL_IN_USE] — address existing names directly, never add again.
4362
+ - status {}: roster — the one unified pool (machines, identities, live PTY sessions).
4363
+ - status {target}: machine probe, or a session card (connection, cwd, ledger of the last 20 commands).
4364
+ - input {target:"pty:<id|label>", stdin, commandId?}: write stdin into the command currently running in that session — passwords, OTP, tokens, menu choices, REPL lines. Never creates a session, never dials.
4365
+ - close {target, force?}: close a PTY session; a running command refuses it (job_kill first) unless force:true kills it in the same step.
4366
+
4367
+ Targets:
4368
+ - ssh:<name> / docker:<name> / incus:<name> / bare pool name → stateless oneshot.
4369
+ - <machine>:pty[:label] → NEW PTY every call — creation is a target suffix, not a different tool.
4370
+ - pty:<id|label> (or its bare label) → REUSE the live session.
4371
+ After creation, reuse the returned session; repeating :pty opens another. Prefixes force the channel; bare names resolve through the roster.
4372
+
4373
+ PTY:
4374
+ - stateful oneshot, interact-able stdin, managed output, persistent env
4375
+ - timeout (default 120s) → command keeps running; returns job handle.
4376
+ - run_in_background → same job lane. jobId = commandId.
4377
+ - Read: job_output(jobId). Cancel: job_kill; try Ctrl-C first.
4378
+ - Busy session: input / poll / cancel / use another session.
4379
+ - exec.stdin = startup input; input = subsequent input.
4380
+ - input is verbatim: "\\n" submits; "\\u0003" Ctrl-C; "\\u0004" Ctrl-D; "" writes nothing. Ctrl-D follows terminal EOF rules.
4381
+ - commandId, if supplied, must match running command.
4382
+ - Input receipt confirms write only; read result via SAME job.
4383
+ - Output returns in full up to retention (4M chars per stream; Hub oneshot 4MiB bytes) — beyond it the retained tail plus outputStats. Redirect bulk output to files.
4384
+ - Idle expiry: 15 min; running commands exempt.
4385
+
4386
+ Oneshot:
4387
+ - stdout/stderr separate, exit code verbatim; no retained shell state. stdin is startup-only: there is no input after start — a prompt on a oneshot means cancel (job_kill) and switch to PTY. Heredocs and input redirection remain valid bulk-input alternatives.
4388
+ - Supervision prefers a pinned superd-run helper cached on the endpoint (~/.cache/superd/run), falls back to a POSIX shim — the receipt field "runner" says which ran. Either way children are reaped on completion and the whole group is killed on timeout/cancel. The endpoint needs bash only — no Python.
4389
+ - scriptPath (oneshot only): a LOCAL absolute script read before any dialing, sent literally over stdin to a fixed bash -s — the file carrier for multi-line scripts; pairs with neither cmd nor stdin.
4390
+
4391
+ Examples
4392
+
4393
+ Manage the persistent PTY session. One-shot style output, but stable: every command returns synchronously with exit, cwd, and an env delta.
4394
+
4395
+ 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.
4396
+
4397
+ remote { "action": "add", "spawn": "ssh -t pc ssh -t dev4", "target": "pty:dev4via" }
4225
4398
 
4226
- 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).
4399
+ [session created — address it as pty:pty-irsq]
4400
+ [pty:pty-irsq (dev4via) (byo-pty) · conn BYOP · idle-left ~15m · exit null · 0.0s]
4227
4401
 
4228
- 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).
4402
+ remote { "action": "exec", "target": "pty:dev4via", "cmd": "hostname && sudo -n ss -tlnp | awk 'NR==1 || /LISTEN/' | head -5" }
4229
4403
 
4230
- 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.`;
4404
+ dev4
4405
+ State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
4406
+ LISTEN 0 4096 127.0.0.1:45207 0.0.0.0:* users:(("containerd",pid=1070,fd=14))
4407
+ LISTEN 0 4096 127.0.0.1:2019 0.0.0.0:* users:(("caddy",pid=5600,fd=4))
4408
+ LISTEN 0 4096 127.0.0.1:19514 0.0.0.0:* users:(("multica",pid=27030,fd=6))
4409
+ LISTEN 0 4096 127.0.0.54:53 0.0.0.0:* users:(("systemd-resolve",pid=26758,fd=17))
4410
+ [pty:pty-irsq (dev4via) (byo-pty) · conn BYOP · cmd remote-0d89215d1cc64cdc · cwd /home/u1 · idle-left ~15m · exit 0 · 0.3s]
4411
+
4412
+ Reuse the same session for a long task — returns a jobId immediately; collect with job_output, cancel with job_kill:
4413
+
4414
+ remote { "action": "exec", "target": "pty:dev4via", "cmd": "for i in 1 2 3; do sleep 1; echo \\"tick $i\\"; done; echo done", "run_in_background": true }
4415
+
4416
+ Started remote-47676dff2c6443e8 on pty:pty-irsq (dev4via) (BYOP). Use job_output to read and job_kill to cancel; completion is reported by the host.
4417
+
4418
+ job_output { "job_id": "remote-47676dff2c6443e8", "wait": true }
4419
+
4420
+ tick 1
4421
+ tick 2
4422
+ tick 3
4423
+ done
4424
+ [status: completed, exit code: 0]
4425
+
4426
+ - Bring Your Own PTY fits when the endpoint has no direct route, or the connection/transport layer must be composed yourself (jump chains, docker exec, console servers).
4427
+ - It also strips one or two shell-escaping layers — commands go straight to the innermost bash, quotes behave naturally.
4428
+
4429
+ Register once — the label enters the pool and works everywhere: exec, pty, copy, rsync. Worth it whenever more than one operation type is involved. Keys stay local-file references; nothing persisted.
4430
+
4431
+ remote { "action": "add", "ssh": { "host": "10.0.0.8", "user": "u1", "port": 22, "key": "/home/u1/.ssh/id_key4" }, "target": "ssh:lab" }
4432
+ remote { "action": "exec", "target": "ssh:lab", "cmd": "uname -a" }
4433
+ remote_transfer { "mode": "rsync", "src": "ssh:lab:/var/log/app/", "dst": "/home/u1/app-logs/", "argv": ["-a"] }`;
4434
+ /** action 是唯一派发维度:每个动词声明自己的字段集,超出即报错(不做在场推断、不留别名)。 */
4435
+ const ACTION_PARAMS = {
4436
+ exec: new Set([
4437
+ "action",
4438
+ "target",
4439
+ "cmd",
4440
+ "stdin",
4441
+ "scriptPath",
4442
+ "timeout",
4443
+ "run_in_background"
4444
+ ]),
4445
+ add: new Set([
4446
+ "action",
4447
+ "target",
4448
+ "ssh",
4449
+ "spawn"
4450
+ ]),
4451
+ status: new Set(["action", "target"]),
4452
+ close: new Set([
4453
+ "action",
4454
+ "target",
4455
+ "force"
4456
+ ]),
4457
+ input: new Set([
4458
+ "action",
4459
+ "target",
4460
+ "stdin",
4461
+ "commandId"
4462
+ ])
4463
+ };
4464
+ const ACTION_HINT = {
4465
+ exec: "exec takes target plus cmd (or scriptPath) and the execution options; ssh/spawn belong to add",
4466
+ add: "add takes exactly one of ssh or spawn, plus an optional target label",
4467
+ status: "status takes only an optional target — omit it for the roster",
4468
+ close: "close takes target pty:<id|label> and optional force",
4469
+ input: "input takes target (a live PTY session), stdin, and an optional commandId guard — no cmd, no timeout, no creation"
4470
+ };
4231
4471
  async function remoteExecute(ENV, args, exec) {
4232
- if (args.action === "copy" || args.action === "rsync") {
4233
- if (!args.src || !args.dst) throw new Error(`${args.action} requires src and dst absolute endpoints`);
4234
- if ([
4235
- args.cmd,
4236
- args.scriptPath,
4237
- args.stdin,
4238
- args.spawn,
4239
- args.ssh,
4240
- args.target,
4241
- args.timeout,
4242
- args.run_in_background,
4243
- args.force
4244
- ].some((value) => value !== void 0)) throw new Error(`${args.action} cannot be combined with execution/PTY/job arguments`);
4245
- const KNOWN_PARAMS = new Set([
4246
- "action",
4247
- "force",
4248
- "src",
4249
- "dst",
4250
- "argv",
4251
- "pullArgv",
4252
- "pushArgv",
4253
- "verify",
4254
- "mkdir",
4255
- "run_in_background",
4256
- "target",
4257
- "ssh",
4258
- "spawn",
4259
- "cmd",
4260
- "scriptPath",
4261
- "stdin",
4262
- "timeout"
4263
- ]);
4264
- 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`);
4265
- if (args.action === "rsync") {
4266
- if (args.pullArgv !== void 0 || args.pushArgv !== void 0) throw new Error("rsync takes argv only; pullArgv/pushArgv are copy-only fields");
4267
- 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");
4268
- const start = Date.now();
4269
- const receipt$1 = await ENV.driver.rsync({
4270
- src: args.src,
4271
- dst: args.dst,
4272
- ...args.argv === void 0 ? {} : { argv: args.argv }
4273
- }, exec.signal, { sessionKey: ENV.getSessionKey(exec) });
4274
- if (receipt$1.ok === false || receipt$1.exit !== 0) throw new Error(JSON.stringify(receipt$1));
4275
- const rsyncDuration = Date.now() - start;
4276
- return {
4277
- kind: "rsync",
4278
- text: formatTransferLine("rsync", receipt$1, rsyncDuration),
4279
- receipt: receipt$1,
4280
- exit: receipt$1.exit,
4281
- durationMs: rsyncDuration
4282
- };
4283
- }
4284
- const copyStart = Date.now();
4285
- const receipt = await ENV.driver.copy({
4286
- src: args.src,
4287
- dst: args.dst,
4288
- ...args.verify === void 0 ? {} : { verify: args.verify },
4289
- ...args.mkdir === void 0 ? {} : { mkdir: args.mkdir },
4290
- ...args.argv === void 0 ? {} : { argv: args.argv },
4291
- ...args.pullArgv === void 0 ? {} : { pullArgv: args.pullArgv },
4292
- ...args.pushArgv === void 0 ? {} : { pushArgv: args.pushArgv }
4293
- }, exec.signal, { sessionKey: ENV.getSessionKey(exec) });
4294
- const asReceipt = receipt;
4295
- const exits = asReceipt.exits ?? [];
4296
- if (asReceipt.ok === false || exits.some((code) => code !== 0)) throw new Error(JSON.stringify(receipt));
4297
- const copyDuration = Date.now() - copyStart;
4472
+ const action = args.action;
4473
+ if (action === void 0) throw new Error("[E_PARAMS] remote: action is required — \"exec\" (run a command), \"add\" (register a BYO endpoint), \"status\" (roster / probe / session card), \"close\" (close a PTY session), \"input\" (append stdin to a running PTY command)");
4474
+ const allowed = ACTION_PARAMS[action];
4475
+ if (allowed === void 0) throw new Error(`[E_PARAMS] remote: unknown action ${JSON.stringify(String(action))} — one of exec / add / close / status / input`);
4476
+ for (const key of Object.keys(args)) if (!allowed.has(key)) throw new Error(`[E_PARAMS] remote: "${key}" does not belong to action "${action}" — ${ACTION_HINT[action]}`);
4477
+ const sessionKey = ENV.getSessionKey(exec);
4478
+ const hasTarget = typeof args.target === "string" && args.target.length > 0;
4479
+ const hasCmd = typeof args.cmd === "string" && args.cmd.length > 0;
4480
+ if (action === "status") {
4481
+ if (!hasTarget) return {
4482
+ kind: "roster",
4483
+ text: await ENV.driver.roster({ sessionKey }),
4484
+ exit: null,
4485
+ durationMs: 0
4486
+ };
4298
4487
  return {
4299
- kind: "copy",
4300
- text: formatTransferLine("copy", receipt, copyDuration),
4301
- receipt,
4302
- exit: exits.length > 0 ? 0 : null,
4303
- durationMs: copyDuration
4488
+ kind: "status",
4489
+ text: await ENV.driver.status(args.target, { sessionKey }),
4490
+ exit: null,
4491
+ durationMs: 0
4304
4492
  };
4305
4493
  }
4306
- if (args.action === "close") {
4307
- if (typeof args.target !== "string" || args.target.length === 0) throw new Error("close requires target pty:<id|label>");
4308
- if ([
4309
- args.cmd,
4310
- args.scriptPath,
4311
- args.stdin,
4312
- args.spawn,
4313
- args.ssh,
4314
- args.timeout,
4315
- args.run_in_background
4316
- ].some((value) => value !== void 0)) throw new Error("[E_PARAMS] close cannot be combined with execution/PTY/job arguments");
4317
- const r$1 = ENV.driver.closePty(args.target.startsWith("pty:") ? args.target.slice(4) : args.target, ENV.getSessionKey(exec), args.force === true);
4494
+ if (action === "close") {
4495
+ if (!hasTarget) throw new Error("[E_PARAMS] close requires target pty:<id|label>");
4496
+ const r$1 = ENV.driver.closePty(args.target.startsWith("pty:") ? args.target.slice(4) : args.target, sessionKey, args.force === true);
4318
4497
  return {
4319
4498
  kind: "close",
4320
4499
  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"}`,
@@ -4326,42 +4505,71 @@ async function remoteExecute(ENV, args, exec) {
4326
4505
  killed: r$1.killed
4327
4506
  };
4328
4507
  }
4329
- if ([
4330
- args.src,
4331
- args.dst,
4332
- args.argv,
4333
- args.pullArgv,
4334
- args.pushArgv,
4335
- args.verify,
4336
- args.mkdir,
4337
- args.force
4338
- ].some((value) => value !== void 0)) throw new Error("file/force fields require an action (copy / rsync / close)");
4339
- const hasCmd = typeof args.cmd === "string" && args.cmd.length > 0;
4340
- const hasTarget = typeof args.target === "string" && args.target.length > 0;
4341
- const hasSpawn = typeof args.spawn === "string" && args.spawn.length > 0;
4342
- if (args.spawn !== void 0 && !hasSpawn) throw new Error("[E_PARAMS] remote: spawn must be a non-empty command string");
4343
- 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");
4508
+ if (action === "input") {
4509
+ 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)");
4510
+ 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)");
4511
+ if (typeof args.commandId === "string" && args.commandId.length === 0) throw new Error("[E_PARAMS] remote: input: commandId, when given, must be the non-empty id from the exec receipt / job_output");
4512
+ const r$1 = await ENV.driver.input(args.target, {
4513
+ stdin: args.stdin,
4514
+ ...args.commandId !== void 0 ? { commandId: args.commandId } : {}
4515
+ }, { sessionKey });
4516
+ const handle = r$1.jobId ?? r$1.commandId;
4517
+ return {
4518
+ kind: "input",
4519
+ 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}`,
4520
+ exit: null,
4521
+ durationMs: 0,
4522
+ session: r$1.session,
4523
+ connection: r$1.connection,
4524
+ ...r$1.label !== void 0 ? { label: r$1.label } : {},
4525
+ commandId: r$1.commandId,
4526
+ ...r$1.jobId !== void 0 ? { jobId: r$1.jobId } : {},
4527
+ writtenBytes: r$1.writtenBytes
4528
+ };
4529
+ }
4530
+ if (action === "add") {
4531
+ const hasSpawn = typeof args.spawn === "string" && args.spawn.length > 0;
4532
+ if (args.spawn !== void 0 && !hasSpawn) throw new Error("[E_PARAMS] remote: add: spawn must be a non-empty command string");
4533
+ 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");
4534
+ 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");
4535
+ const r$1 = await ENV.driver.call({
4536
+ ...hasTarget ? { target: args.target } : {},
4537
+ ...args.ssh === void 0 ? {} : { ssh: args.ssh },
4538
+ ...hasSpawn ? { spawn: args.spawn } : {},
4539
+ cmd: ""
4540
+ }, { sessionKey });
4541
+ const v$1 = workbenchValue(r$1);
4542
+ v$1.created = true;
4543
+ 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)`;
4544
+ return v$1;
4545
+ }
4546
+ if (!hasTarget) throw new Error("[E_PARAMS] remote: action \"exec\" requires target — a machine (ssh:dev4, docker:corti, hub:<m4>, a pool name) or a PTY session (pty:<id|label>, a bare label, or <machine>:pty[:label] to create one). Status probes and the roster live under action \"status\".");
4547
+ const target = args.target;
4548
+ const ptyTarget = /^pty:/.test(target) || /:pty(:|$)/.test(target);
4549
+ if (args.scriptPath !== void 0) {
4550
+ if (args.scriptPath.length === 0) throw new Error("[E_PARAMS] remote: scriptPath must be a non-empty absolute path");
4551
+ if (ptyTarget) throw new Error("[E_SCRIPT_PARAMS] remote: scriptPath is oneshot-only — PTY lanes take cmd");
4552
+ if (hasCmd) throw new Error("[E_SCRIPT_PARAMS] remote: scriptPath and cmd are alternative carriers — pass one");
4553
+ if (args.stdin !== void 0) throw new Error("[E_SCRIPT_PARAMS] remote: scriptPath already occupies stdin — do not pass stdin with it");
4554
+ }
4555
+ 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}");
4556
+ 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");
4344
4557
  if (args.run_in_background) {
4345
- if (!hasCmd && args.scriptPath === void 0) throw new Error("remote: background requires cmd or scriptPath");
4346
4558
  const jobs = exec.agent?.ctx.get("jobs");
4347
4559
  if (!jobs) throw new Error("remote: background requires the official DSH jobs service and job_output/job_kill controller; foreground execution remains available");
4348
4560
  const lane = await ENV.driver.preparePtyLane({
4349
4561
  target: args.target,
4350
- spawn: args.spawn,
4351
4562
  cmd: args.cmd,
4352
4563
  scriptPath: args.scriptPath,
4353
- ssh: args.ssh,
4354
4564
  stdin: args.stdin,
4355
4565
  timeout: args.timeout
4356
- }, { sessionKey: ENV.getSessionKey(exec) });
4566
+ }, { sessionKey });
4357
4567
  let jobId;
4358
4568
  try {
4359
4569
  jobId = startRemoteJob(jobs, lane !== void 0 ? lane.input : {
4360
4570
  target: args.target,
4361
- spawn: args.spawn,
4362
4571
  cmd: args.cmd,
4363
4572
  scriptPath: args.scriptPath,
4364
- ssh: args.ssh,
4365
4573
  stdin: args.stdin,
4366
4574
  timeout: args.timeout
4367
4575
  }, exec, ENV.driver);
@@ -4377,44 +4585,20 @@ async function remoteExecute(ENV, args, exec) {
4377
4585
  durationMs: 0,
4378
4586
  text: `Started ${jobId}${where}. Use job_output to read and job_kill to cancel; completion is reported by the host.`,
4379
4587
  ...lane !== void 0 ? {
4588
+ commandId: jobId,
4380
4589
  session: lane.session,
4381
4590
  connection: lane.connection,
4382
4591
  ...lane.label !== void 0 ? { label: lane.label } : {}
4383
4592
  } : {}
4384
4593
  };
4385
4594
  }
4386
- if (!hasCmd && args.scriptPath === void 0) {
4387
- if (args.stdin !== void 0) throw new Error("[E_STDIN_WITHOUT_CMD] remote: stdin requires cmd — status probes take no input");
4388
- const t = args.target ?? "";
4389
- if (hasSpawn || args.ssh !== void 0 || /^pty:/.test(t) || /:pty(:|$)/.test(t) && !t.startsWith("hub:")) return workbenchValue(await ENV.driver.call({
4390
- target: args.target,
4391
- spawn: args.spawn,
4392
- ssh: args.ssh,
4393
- cmd: "",
4394
- stdin: void 0
4395
- }, { sessionKey: ENV.getSessionKey(exec) }));
4396
- if (!hasTarget) return {
4397
- kind: "roster",
4398
- text: await ENV.driver.roster({ sessionKey: ENV.getSessionKey(exec) }),
4399
- exit: null,
4400
- durationMs: 0
4401
- };
4402
- return {
4403
- kind: "status",
4404
- text: await ENV.driver.status(args.target, { sessionKey: ENV.getSessionKey(exec) }),
4405
- exit: null,
4406
- durationMs: 0
4407
- };
4408
- }
4409
4595
  const r = await ENV.driver.call({
4410
4596
  target: args.target,
4411
- spawn: args.spawn,
4412
- ssh: args.ssh,
4413
4597
  cmd: args.cmd,
4414
4598
  scriptPath: args.scriptPath,
4415
4599
  stdin: args.stdin,
4416
4600
  timeout: args.timeout
4417
- }, { sessionKey: ENV.getSessionKey(exec) });
4601
+ }, { sessionKey });
4418
4602
  const v = workbenchValue(r);
4419
4603
  if (r.running === true && r.ptyJob !== void 0) {
4420
4604
  const jobs = exec.agent?.ctx.get("jobs");
@@ -4422,7 +4606,7 @@ async function remoteExecute(ENV, args, exec) {
4422
4606
  const jobId = adoptRunningPtyJob(jobs, exec, `${r.target}: ${args.cmd}`, r.ptyJob);
4423
4607
  v.jobId = jobId;
4424
4608
  v.running = true;
4425
- if (r.commandId !== void 0 && jobId !== r.commandId) ENV.driver.attachPtyJob(r.commandId, jobId, ENV.getSessionKey(exec));
4609
+ if (r.commandId !== void 0 && jobId !== r.commandId) ENV.driver.attachPtyJob(r.commandId, jobId, sessionKey);
4426
4610
  } else {
4427
4611
  r.ptyJob.interrupt();
4428
4612
  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.";
@@ -4431,7 +4615,7 @@ async function remoteExecute(ENV, args, exec) {
4431
4615
  return v;
4432
4616
  }
4433
4617
  function createRemoteTool(driver, opts = {}) {
4434
- const getSessionKey = opts.getSessionKey ?? ((exec) => exec.agent?.id ?? NO_SESSION_KEY);
4618
+ const getSessionKey = opts.getSessionKey ?? ((exec) => exec.agent?.id ?? NO_SESSION_KEY$1);
4435
4619
  const tool = defineTool({
4436
4620
  name: "remote",
4437
4621
  description: REMOTE_TOOL_DESCRIPTION,
@@ -4439,58 +4623,42 @@ function createRemoteTool(driver, opts = {}) {
4439
4623
  action: {
4440
4624
  type: "string",
4441
4625
  enum: [
4442
- "copy",
4443
- "rsync",
4444
- "close"
4626
+ "exec",
4627
+ "add",
4628
+ "status",
4629
+ "close",
4630
+ "input"
4445
4631
  ],
4446
- description: "Optional action; omit for execution/status/roster. close takes target + optional force."
4632
+ required: true,
4633
+ 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."
4447
4634
  },
4448
- force: {
4449
- type: "boolean",
4450
- 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."
4451
- },
4452
- src: {
4635
+ target: {
4453
4636
  type: "string",
4454
- description: "copy/rsync source [target:]absolute path; rsync retains trailing-slash semantics"
4637
+ 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)."
4455
4638
  },
4456
- dst: {
4639
+ cmd: {
4457
4640
  type: "string",
4458
- description: "copy/rsync destination [target:]absolute path"
4641
+ description: "exec: one raw shell string parsed by the remote bash (multi-line = one compound; exit = last command's). Required unless scriptPath is given."
4459
4642
  },
4460
- argv: {
4461
- type: "array",
4462
- items: { type: "string" },
4463
- 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."
4464
- },
4465
- pullArgv: {
4466
- type: "array",
4467
- items: { type: "string" },
4468
- 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."
4469
- },
4470
- pushArgv: {
4471
- type: "array",
4472
- items: { type: "string" },
4473
- 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."
4643
+ stdin: {
4644
+ type: "string",
4645
+ 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."
4474
4646
  },
4475
- verify: {
4476
- type: "boolean",
4477
- 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."
4647
+ commandId: {
4648
+ type: "string",
4649
+ 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."
4478
4650
  },
4479
- mkdir: {
4480
- type: "boolean",
4481
- 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."
4651
+ timeout: {
4652
+ type: "number",
4653
+ 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)."
4482
4654
  },
4483
4655
  run_in_background: {
4484
4656
  type: "boolean",
4485
- 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."
4486
- },
4487
- target: {
4488
- type: "string",
4489
- 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)."
4657
+ 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."
4490
4658
  },
4491
4659
  ssh: {
4492
4660
  type: "object",
4493
- 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.",
4661
+ 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.",
4494
4662
  additionalProperties: false,
4495
4663
  properties: {
4496
4664
  host: {
@@ -4518,23 +4686,11 @@ function createRemoteTool(driver, opts = {}) {
4518
4686
  },
4519
4687
  spawn: {
4520
4688
  type: "string",
4521
- 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)."
4522
- },
4523
- cmd: {
4524
- type: "string",
4525
- 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."
4526
- },
4527
- scriptPath: {
4528
- type: "string",
4529
- 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."
4689
+ description: "add: BYO PTY entry point — the full command that starts an interactive shell (e.g. \"docker exec -it img bash\"). Pairs with target pty[:<label>] / a bare <label> / nothing (auto id). Nested hops are dumb pipes (only the innermost bash frames)."
4530
4690
  },
4531
- stdin: {
4532
- type: "string",
4533
- description: "Input fed to the command. oneshot: piped to stdin. pty: written right after the command line (REPL-style). Requires cmd."
4534
- },
4535
- timeout: {
4536
- type: "number",
4537
- 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)."
4691
+ force: {
4692
+ type: "boolean",
4693
+ 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."
4538
4694
  }
4539
4695
  },
4540
4696
  output: {
@@ -4550,9 +4706,8 @@ function createRemoteTool(driver, opts = {}) {
4550
4706
  "status",
4551
4707
  "roster",
4552
4708
  "job",
4553
- "copy",
4554
- "rsync",
4555
- "close"
4709
+ "close",
4710
+ "input"
4556
4711
  ]
4557
4712
  },
4558
4713
  text: {
@@ -4584,6 +4739,10 @@ function createRemoteTool(driver, opts = {}) {
4584
4739
  type: "string",
4585
4740
  description: "ledger key; equals the jobId whenever a job exists"
4586
4741
  },
4742
+ writtenBytes: {
4743
+ type: "integer",
4744
+ description: "input: UTF-8 bytes actually written; confirms the write, not the program response"
4745
+ },
4587
4746
  envDelta: {
4588
4747
  type: "json",
4589
4748
  description: "names only: {added,changed,removed}"
@@ -4592,7 +4751,7 @@ function createRemoteTool(driver, opts = {}) {
4592
4751
  notice: { type: "string" },
4593
4752
  runner: {
4594
4753
  type: "string",
4595
- description: "oneshot supervision path: superd-run <ver> (<triple>) or posix-shim"
4754
+ description: "oneshot supervision path: superd-run <ver> (<triple>), posix-shim, or hub-lease (peer hub server supervises natively)"
4596
4755
  },
4597
4756
  running: { type: "boolean" },
4598
4757
  ttlSec: { type: "integer" },
@@ -4626,7 +4785,7 @@ function createRemoteTool(driver, opts = {}) {
4626
4785
  const description = tool.description;
4627
4786
  Object.defineProperty(tool, "description", {
4628
4787
  enumerable: true,
4629
- 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." : "")
4788
+ get: () => description + (driver.hasHub() ? "\nHub transport available: hub:<m4> selects a paired remote machine (never self; the roster lists them). Hub failures never fall back to SSH.\n- hub:<m4> oneshot: the peer hub server is the runner itself — bash -lc in a detached group, TERM→2s→KILL cleanup; the receipt runner field says \"hub-lease\" (the superd-run helper is a dumb-pipe artifact, never uploaded over the Hub). 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." : "")
4630
4789
  });
4631
4790
  return tool;
4632
4791
  }
@@ -4657,26 +4816,6 @@ function workbenchValue(r) {
4657
4816
  if (r.ttlSec !== void 0) v.ttlSec = r.ttlSec;
4658
4817
  return v;
4659
4818
  }
4660
- /** S8 (2026-10-17): copy/rsync render as one formatted line; the full receipt
4661
- * stays in the receipt field — raw-JSON dumps are gone from text. */
4662
- function formatTransferLine(action, receipt, durationMs) {
4663
- const r = receipt;
4664
- const parts = [];
4665
- if (r.ok === false) parts.push(`✗ ${r.reason ?? "failed"}`);
4666
- parts.push(`${action} ${r.src} → ${r.dst}`);
4667
- if (r.route !== void 0 && r.route !== "") parts.push(`route ${r.route}`);
4668
- if (r.exits !== void 0) parts.push(`exits [${r.exits.join(",")}]`);
4669
- else if (r.exit !== void 0) parts.push(`exit ${r.exit}`);
4670
- if (r.verification !== void 0) parts.push(`verification ${r.verification}`);
4671
- if (r.created === true) parts.push("created yes");
4672
- else if (r.created === false) parts.push("created pre-existing");
4673
- else if (r.created === "n-a") parts.push("created n-a");
4674
- if (r.cleanup !== void 0 && r.cleanup !== "none-required") parts.push(`cleanup ${r.cleanup}`);
4675
- if (typeof r.bytes === "number") parts.push(`${r.bytes} bytes / ${r.entries} entries`);
4676
- parts.push(`${(durationMs / 1e3).toFixed(1)}s`);
4677
- if (r.note !== void 0 && r.note.length > 0) parts.push(`(${r.note})`);
4678
- return parts.join(" · ");
4679
- }
4680
4819
  function renderEnvDelta(d) {
4681
4820
  const bits = [];
4682
4821
  if (d.added.length > 0) bits.push(`+${d.added.join(",+")}`);
@@ -4685,7 +4824,7 @@ function renderEnvDelta(d) {
4685
4824
  return bits.length > 0 ? bits.join(" ") : void 0;
4686
4825
  }
4687
4826
  function renderValue(v) {
4688
- if (v.kind === "status" || v.kind === "roster" || v.kind === "job" || v.kind === "copy" || v.kind === "rsync" || v.kind === "close") return v.text;
4827
+ if (v.kind === "status" || v.kind === "roster" || v.kind === "job" || v.kind === "close" || v.kind === "input") return v.text;
4689
4828
  const parts = [];
4690
4829
  if (v.reconnected === true) parts.push(RECONNECT_NOTICE);
4691
4830
  if (typeof v.notice === "string" && v.notice.length > 0) parts.push(v.notice);
@@ -4706,15 +4845,188 @@ function renderValue(v) {
4706
4845
  markers.push(`${secs}s`);
4707
4846
  if (v.running === true) markers.push(`still running — poll job_output${v.jobId !== void 0 ? ` ${v.jobId}` : ""}`);
4708
4847
  if (v.truncated !== void 0) markers.push(`truncated, ${v.outputStatsUnavailable ? "returned peer tail" : "original"} ${v.truncated} ${v.outputStats?.stdout.unit === "bytes" ? "bytes" : "chars"}`);
4709
- if (v.outputStatsUnavailable) markers.push("peer output totals/retention unavailable; returned tail only");
4848
+ if (v.outputStatsUnavailable) markers.push("peer output totals/retention unavailable");
4710
4849
  parts.push(`[${markers.join(" · ")}]`);
4711
4850
  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");
4712
4851
  if (v.stderr !== void 0 && v.stderr.length > 0) parts.push("[stderr]", v.stderr);
4713
4852
  return parts.join("\n");
4714
4853
  }
4715
4854
 
4855
+ //#endregion
4856
+ //#region src/remote/transfer-tool.ts
4857
+ const NO_SESSION_KEY = "remote:no-session";
4858
+ /**
4859
+ * The model-visible surface of the `remote_transfer` tool (split out of
4860
+ * `remote`, 2026-10-25). ONE template literal — no backticks, no "${".
4861
+ */
4862
+ const TRANSFER_TOOL_DESCRIPTION = `File transfer between any two supported endpoints. Every call declares its mode:
4863
+ 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.
4864
+ 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.
4865
+
4866
+ 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.
4867
+
4868
+ 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.
4869
+
4870
+ 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.
4871
+
4872
+ 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.`;
4873
+ const KNOWN_PARAMS = new Set([
4874
+ "mode",
4875
+ "src",
4876
+ "dst",
4877
+ "argv",
4878
+ "pullArgv",
4879
+ "pushArgv",
4880
+ "verify",
4881
+ "mkdir"
4882
+ ]);
4883
+ async function transferExecute(ENV, args, exec) {
4884
+ 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`);
4885
+ 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)`);
4886
+ 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`);
4887
+ const sessionKey = ENV.getSessionKey(exec);
4888
+ if (args.mode === "rsync") {
4889
+ if (args.pullArgv !== void 0 || args.pushArgv !== void 0) throw new Error("remote_transfer: rsync takes argv only; pullArgv/pushArgv are copy-only fields");
4890
+ 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");
4891
+ const start$1 = Date.now();
4892
+ const receipt$1 = await ENV.driver.rsync({
4893
+ src: args.src,
4894
+ dst: args.dst,
4895
+ ...args.argv === void 0 ? {} : { argv: args.argv }
4896
+ }, exec.signal, { sessionKey });
4897
+ if (receipt$1.ok === false || receipt$1.exit !== 0) throw new Error(JSON.stringify(receipt$1));
4898
+ const durationMs$1 = Date.now() - start$1;
4899
+ return {
4900
+ kind: "rsync",
4901
+ text: formatTransferLine("rsync", receipt$1, durationMs$1),
4902
+ receipt: receipt$1,
4903
+ exit: receipt$1.exit,
4904
+ durationMs: durationMs$1
4905
+ };
4906
+ }
4907
+ const start = Date.now();
4908
+ const receipt = await ENV.driver.copy({
4909
+ src: args.src,
4910
+ dst: args.dst,
4911
+ ...args.verify === void 0 ? {} : { verify: args.verify },
4912
+ ...args.mkdir === void 0 ? {} : { mkdir: args.mkdir },
4913
+ ...args.argv === void 0 ? {} : { argv: args.argv },
4914
+ ...args.pullArgv === void 0 ? {} : { pullArgv: args.pullArgv },
4915
+ ...args.pushArgv === void 0 ? {} : { pushArgv: args.pushArgv }
4916
+ }, exec.signal, { sessionKey });
4917
+ const asReceipt = receipt;
4918
+ const exits = asReceipt.exits ?? [];
4919
+ if (asReceipt.ok === false || exits.some((code) => code !== 0)) throw new Error(JSON.stringify(receipt));
4920
+ const durationMs = Date.now() - start;
4921
+ return {
4922
+ kind: "copy",
4923
+ text: formatTransferLine("copy", receipt, durationMs),
4924
+ receipt,
4925
+ exit: exits.length > 0 ? 0 : null,
4926
+ durationMs
4927
+ };
4928
+ }
4929
+ function createRemoteTransferTool(driver, opts = {}) {
4930
+ const getSessionKey = opts.getSessionKey ?? ((exec) => exec.agent?.id ?? NO_SESSION_KEY);
4931
+ return defineTool({
4932
+ name: "remote_transfer",
4933
+ description: TRANSFER_TOOL_DESCRIPTION,
4934
+ parameters: {
4935
+ mode: {
4936
+ type: "string",
4937
+ enum: ["copy", "rsync"],
4938
+ required: true,
4939
+ description: "Required. copy = tree copy through each endpoint's native CLI, or the Hub binary lane; rsync = SSH only (argv only, no verify/mkdir)."
4940
+ },
4941
+ src: {
4942
+ type: "string",
4943
+ description: "Source [target:]absolute path. No target = the machine running this Agent."
4944
+ },
4945
+ dst: {
4946
+ type: "string",
4947
+ description: "Destination [target:]absolute path. No target = the machine running this Agent."
4948
+ },
4949
+ argv: {
4950
+ type: "array",
4951
+ items: { type: "string" },
4952
+ 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."
4953
+ },
4954
+ pullArgv: {
4955
+ type: "array",
4956
+ items: { type: "string" },
4957
+ 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."
4958
+ },
4959
+ pushArgv: {
4960
+ type: "array",
4961
+ items: { type: "string" },
4962
+ 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."
4963
+ },
4964
+ verify: {
4965
+ type: "boolean",
4966
+ 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."
4967
+ },
4968
+ mkdir: {
4969
+ type: "boolean",
4970
+ 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."
4971
+ }
4972
+ },
4973
+ output: {
4974
+ schema: {
4975
+ type: "object",
4976
+ additionalProperties: false,
4977
+ properties: {
4978
+ kind: {
4979
+ type: "string",
4980
+ required: true,
4981
+ enum: ["copy", "rsync"]
4982
+ },
4983
+ text: {
4984
+ type: "string",
4985
+ required: true
4986
+ },
4987
+ receipt: { type: "json" },
4988
+ exit: { oneOf: [{ type: "integer" }, { type: "null" }] },
4989
+ durationMs: { type: "integer" }
4990
+ }
4991
+ },
4992
+ render: (_args, value) => [{
4993
+ type: "text",
4994
+ text: value.text
4995
+ }]
4996
+ },
4997
+ async execute(args, exec) {
4998
+ return transferExecute({
4999
+ driver,
5000
+ getSessionKey
5001
+ }, args, exec);
5002
+ }
5003
+ });
5004
+ }
5005
+ /** S8 (2026-10-17): copy/rsync render as one formatted line; the full receipt
5006
+ * stays in the receipt field — raw-JSON dumps are gone from text. */
5007
+ function formatTransferLine(mode, receipt, durationMs) {
5008
+ const r = receipt;
5009
+ const parts = [];
5010
+ if (r.ok === false) parts.push(`✗ ${r.reason ?? "failed"}`);
5011
+ parts.push(`${mode} ${r.src} → ${r.dst}`);
5012
+ if (r.route !== void 0 && r.route !== "") parts.push(`route ${r.route}`);
5013
+ if (r.exits !== void 0) parts.push(`exits [${r.exits.join(",")}]`);
5014
+ else if (r.exit !== void 0) parts.push(`exit ${r.exit}`);
5015
+ if (r.verification !== void 0) parts.push(`verification ${r.verification}`);
5016
+ if (r.created === true) parts.push("created yes");
5017
+ else if (r.created === false) parts.push("created pre-existing");
5018
+ else if (r.created === "n-a") parts.push("created n-a");
5019
+ if (r.cleanup !== void 0 && r.cleanup !== "none-required") parts.push(`cleanup ${r.cleanup}`);
5020
+ if (typeof r.bytes === "number") parts.push(`${r.bytes} bytes / ${r.entries} entries`);
5021
+ parts.push(`${(durationMs / 1e3).toFixed(1)}s`);
5022
+ if (r.note !== void 0 && r.note.length > 0) parts.push(`(${r.note})`);
5023
+ return parts.join(" · ");
5024
+ }
5025
+
4716
5026
  //#endregion
4717
5027
  //#region src/remote/agent-registration.ts
5028
+ /** The pair a row contributes: execution/PTY and file transfer are separate tools. */
5029
+ const TOOL_NAMES = ["remote", "remote_transfer"];
4718
5030
  /** Host listeners attempt once per official creation edge, never on vacancy. */
4719
5031
  function installAgentRemote(ctx, config) {
4720
5032
  return ctx.on("agent/created", ({ agent }) => {
@@ -4724,12 +5036,14 @@ function installAgentRemote(ctx, config) {
4724
5036
  ...config,
4725
5037
  getHub: () => agent.ctx.get("superdRemoteTransport")
4726
5038
  });
4727
- let unregister;
5039
+ const registered = [];
4728
5040
  try {
4729
- unregister = tools.register(createRemoteTool(driver, { getSessionKey: () => agent.id }));
5041
+ registered.push(tools.register(createRemoteTool(driver, { getSessionKey: () => agent.id })));
5042
+ registered.push(tools.register(createRemoteTransferTool(driver, { getSessionKey: () => agent.id })));
4730
5043
  } catch (error) {
5044
+ for (const undo of registered.splice(0)) undo();
4731
5045
  driver.dispose();
4732
- if (error instanceof Error && error.message === "tool \"remote\" is already registered in this scope" && tools.get("remote", agent) !== void 0) return;
5046
+ 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;
4733
5047
  throw error;
4734
5048
  }
4735
5049
  const stopService = agent.ctx.on("internal/service", (name$1) => {
@@ -4741,7 +5055,7 @@ function installAgentRemote(ctx, config) {
4741
5055
  const cleanup = () => {
4742
5056
  if (disposal) return disposal;
4743
5057
  disposal = driver.dispose();
4744
- unregister();
5058
+ for (const undo of registered.splice(0)) undo();
4745
5059
  stopService();
4746
5060
  ownerUndo?.();
4747
5061
  agentUndo?.();
@@ -4797,12 +5111,10 @@ const name = "dashr-remote";
4797
5111
  const inject = [];
4798
5112
  const Config = Schema.object({
4799
5113
  execTimeoutSec: Schema.natural().min(1).default(120),
4800
- idleTtlSec: Schema.natural().min(1).default(REMOTE_PTY_IDLE_TTL_SEC),
4801
- maxOutputChars: Schema.natural().min(1).default(3e4)
5114
+ idleTtlSec: Schema.natural().min(1).default(REMOTE_PTY_IDLE_TTL_SEC)
4802
5115
  }).default({
4803
5116
  execTimeoutSec: 120,
4804
- idleTtlSec: REMOTE_PTY_IDLE_TTL_SEC,
4805
- maxOutputChars: 3e4
5117
+ idleTtlSec: REMOTE_PTY_IDLE_TTL_SEC
4806
5118
  });
4807
5119
  /** MUST stay an arrow(RM0 plugin.ts 同注:function 声明会被 cordis 当构造器 new 掉,返回的 disposer 丢失)。 */
4808
5120
  const apply = (ctx, config) => {
@@ -4816,4 +5128,4 @@ var plugin_default = {
4816
5128
  };
4817
5129
 
4818
5130
  //#endregion
4819
- 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 };
5131
+ 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 };