better-dsh 0.2.4-a → 0.2.4-c

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/cordis.patch.yml +7 -0
  2. package/docs/10_plans/2026-09-23-remote-execution-framework.md +236 -0
  3. package/docs/10_plans/2026-09-23-remote-framework-impl.md +2083 -0
  4. package/docs/10_plans/2026-09-26-lsp-ast-reminder.md +64 -0
  5. package/docs/20_specs/agent/spec.md +54 -0
  6. package/docs/20_specs/ast/spec.md +34 -0
  7. package/docs/20_specs/compaction-recall/spec.md +46 -0
  8. package/docs/20_specs/ctx/spec.md +107 -0
  9. package/docs/20_specs/dsh/spec.md +47 -0
  10. package/docs/20_specs/dvc/spec.md +87 -0
  11. package/docs/20_specs/escalation-guidance/spec.md +44 -0
  12. package/docs/20_specs/fs-scheme-resolution/spec.md +37 -0
  13. package/docs/20_specs/hash-edit/spec.md +41 -0
  14. package/docs/20_specs/http-read/spec.md +73 -0
  15. package/docs/20_specs/kernel-provisioning/spec.md +53 -0
  16. package/docs/20_specs/lsp/spec.md +121 -0
  17. package/docs/20_specs/mobile-layout/spec.md +108 -0
  18. package/docs/20_specs/model-failover/spec.md +20 -0
  19. package/docs/20_specs/plugins-page-components/spec.md +79 -0
  20. package/docs/20_specs/preact-ui-shell/spec.md +22 -0
  21. package/docs/20_specs/repl-dispatch-resilience/spec.md +21 -0
  22. package/docs/20_specs/skill/spec.md +58 -0
  23. package/docs/20_specs/tool-surface/spec.md +222 -0
  24. package/docs/20_specs/url-schema/spec.md +148 -0
  25. package/docs/20_specs/web-trust-fence/spec.md +43 -0
  26. package/docs/50_test-reports/2026-09-22-better-dsh/347/273/204/344/273/266/347/203/255/346/217/222/346/213/224/344/270/216/344/276/235/350/265/226/347/272/247/350/201/224/345/256/236/346/265/213/346/212/245/345/221/212.md +18 -1
  27. package/docs/50_test-reports/2026-09-23-remote-framework/345/256/236/346/265/213/346/212/245/345/221/212.md +92 -0
  28. package/docs/50_test-reports/2026-09-23-remote-framework/347/254/254/344/270/200/344/272/272/347/247/260/345/244/215/346/265/213/346/212/245/345/221/212.md +125 -0
  29. package/docs/50_test-reports/2026-09-23-ssh/345/267/245/345/205/267assisted-pty/344/274/232/350/257/235framing/345/256/236/346/265/213/346/212/245/345/221/212.md +207 -0
  30. package/docs/50_test-reports/2026-09-24-v0.1.7-rc.1/345/257/271/351/275/220/350/275/256-compaction/344/270/216failover/350/256/276/347/275/256/351/235/242/344/277/256/345/244/215/345/256/236/346/265/213/346/212/245/345/221/212.md +65 -0
  31. package/docs/50_test-reports/2026-09-27-lsp-ast-reminder-4999/345/256/236/346/265/213/346/212/245/345/221/212.md +53 -0
  32. package/lib/client/index.js +18 -18
  33. package/lib/compaction/index.d.ts +20 -10
  34. package/lib/compaction/index.js +42 -56
  35. package/lib/failover/index.d.ts +30 -6
  36. package/lib/failover/index.js +32 -26
  37. package/lib/fs-aware/sandbox-plugin.js +1 -1
  38. package/lib/index.js +4 -7
  39. package/lib/mobile/plugin.d.ts +1 -1
  40. package/lib/remote/plugin.d.ts +24 -0
  41. package/lib/remote/plugin.js +1095 -0
  42. package/lib/url-schemes/index.d.ts +14 -14
  43. package/lib/url-schemes/index.js +119 -15
  44. package/lib/{web-trust-CgpBoFv3.d.ts → web-trust-BihHCelF.d.ts} +10 -10
  45. package/lib/web-trust.d.ts +1 -1
  46. package/package.json +12 -4
  47. package/docs/10_plans/dashr-remote-tool-design.md +0 -337
  48. package/docs/10_plans/self-exo-cot-draft.md +0 -322
  49. package/docs/10_plans/self-scheme-and-monolog-draft.md +0 -357
@@ -0,0 +1,1095 @@
1
+ import { Context } from "@deepseek-ai/cordis";
2
+ import { randomBytes } from "node:crypto";
3
+ import { join } from "node:path";
4
+ import z from "@deepseek-ai/schemastery";
5
+ import { spawn } from "node:child_process";
6
+ import { homedir } from "node:os";
7
+ import { defineTool } from "@deepseek-ai/dsh-tools";
8
+ import { readFile } from "node:fs/promises";
9
+
10
+ //#region src/remote/oneshot.ts
11
+ /** 单流硬收集帽(4M UTF-16 code units,ASCII≈4MB):防失控输出吃内存;模型面截断由上层 tailWindow 负责。 */
12
+ const HARD_CAP = 4e6;
13
+ /** One-shot:子进程退出码即收尾(spec §五.1),stdout/stderr 分离保留。 */
14
+ async function runOneShot(argv, opts = {}) {
15
+ const started = Date.now();
16
+ const child = spawn(argv[0], argv.slice(1), {
17
+ stdio: [
18
+ "pipe",
19
+ "pipe",
20
+ "pipe"
21
+ ],
22
+ detached: true
23
+ });
24
+ let stdout = "";
25
+ let stderr = "";
26
+ let timedOut = false;
27
+ child.stdout.setEncoding("utf8").on("data", (d) => {
28
+ if (stdout.length < HARD_CAP) stdout = (stdout + d).slice(0, HARD_CAP);
29
+ });
30
+ child.stderr.setEncoding("utf8").on("data", (d) => {
31
+ if (stderr.length < HARD_CAP) stderr = (stderr + d).slice(0, HARD_CAP);
32
+ });
33
+ child.stdin.on("error", () => {});
34
+ if (opts.stdin !== void 0) child.stdin.write(opts.stdin);
35
+ child.stdin.end();
36
+ let termTimer;
37
+ let killTimer;
38
+ const clearTimers = () => {
39
+ if (termTimer !== void 0) clearTimeout(termTimer);
40
+ if (killTimer !== void 0) clearTimeout(killTimer);
41
+ };
42
+ if (opts.timeoutSec !== void 0) {
43
+ const killGroup = (sig) => {
44
+ if (child.pid === void 0) {
45
+ child.kill(sig);
46
+ return;
47
+ }
48
+ try {
49
+ process.kill(-child.pid, sig);
50
+ } catch {}
51
+ };
52
+ termTimer = setTimeout(() => {
53
+ timedOut = true;
54
+ killGroup("SIGTERM");
55
+ killTimer = setTimeout(() => killGroup("SIGKILL"), 2e3);
56
+ }, opts.timeoutSec * 1e3);
57
+ }
58
+ const exit = await new Promise((resolve$1) => {
59
+ child.once("close", (code) => resolve$1(code));
60
+ child.once("error", (err) => {
61
+ stderr += `\n[remote: spawn failed: ${err.message}]`;
62
+ resolve$1(null);
63
+ });
64
+ });
65
+ clearTimers();
66
+ return {
67
+ exit,
68
+ timedOut,
69
+ stdout,
70
+ stderr,
71
+ durationMs: Date.now() - started
72
+ };
73
+ }
74
+
75
+ //#endregion
76
+ //#region src/remote/nonce-framing.ts
77
+ /** 8 字节随机串的 hex 形(spec §五.2:每次下发前生成)。 */
78
+ function genNonce(bytes = 8) {
79
+ return randomBytes(bytes).toString("hex");
80
+ }
81
+ /** 命令整行:花括号复合体 + Nonce 收帧尾(spec §五.2 的 printf 逐字落地 + cwd 第三字段)。 */
82
+ function wrapPtyCommand(cmd, nonce) {
83
+ return `{\n${cmd}\n} ; printf '\\033]133;D;${nonce};%s;%s\\007' "$?" "$PWD"\n`;
84
+ }
85
+ /** init 整行:哑终端(echo/PS1/PS2/TERM)+ 固定 40×120(Non-Goal ③)+ INIT 帧。 */
86
+ function buildInitCommand(nonce) {
87
+ return `stty -echo 2>/dev/null ; stty rows 40 cols 120 2>/dev/null ; PS1='' ; PS2='' ; export TERM=dumb ; printf '\\033]133;D;${nonce};0;%s\\007' "$PWD"\n`;
88
+ }
89
+ const ANSI_RE = /\x1b(?:\][^\x07\x1b]*(?:\x07|\x1b\\)?|\[[0-9;?]*[ -/]*[@-~]|[@-Z\\-_])/g;
90
+ function stripAnsi(s) {
91
+ return s.replace(ANSI_RE, "");
92
+ }
93
+ function normalizePtyText(s) {
94
+ return stripAnsi(s).replace(/\r\n/g, "\n").replace(/\r/g, "");
95
+ }
96
+ function tailWindow(text, cap) {
97
+ if (text.length <= cap) return { text };
98
+ return {
99
+ text: `[truncated: showing last ${cap} of ${text.length} chars]\n${text.slice(text.length - cap)}`,
100
+ truncated: text.length
101
+ };
102
+ }
103
+ const FRAME_BODY_RE = /^(\d+);([^\x07\x1b]*)(?:\x07|\x1b\\)/;
104
+ /**
105
+ * 单 nonce、单帧解析器:一轮 dispatch 一个实例。只认 `\x1b]133;D;<nonce>;`,
106
+ * 错误 nonce 的静态/伪造 marker 永不匹配,按普通输出流过(再经 ANSI 剥洗)。
107
+ * 缓冲尾若可能是 marker 前缀则扣留,flush() 时作为输出放出。
108
+ */
109
+ function createNonceFrameParser(nonce, events) {
110
+ const prefix = `\x1b]133;D;${nonce};`;
111
+ let buf = "";
112
+ let done = false;
113
+ const emit = (raw) => {
114
+ const text = normalizePtyText(raw);
115
+ if (text.length > 0) events.onOutput?.(text);
116
+ };
117
+ const holdback = () => {
118
+ const max = Math.min(buf.length, prefix.length - 1);
119
+ for (let k = max; k > 0; k--) if (buf.endsWith(prefix.slice(0, k))) return k;
120
+ return 0;
121
+ };
122
+ const consume = () => {
123
+ if (done) return;
124
+ const i = buf.indexOf(prefix);
125
+ if (i < 0) {
126
+ const keep = holdback();
127
+ emit(buf.slice(0, buf.length - keep));
128
+ buf = buf.slice(buf.length - keep);
129
+ return;
130
+ }
131
+ emit(buf.slice(0, i));
132
+ const rest = buf.slice(i + prefix.length);
133
+ const m = FRAME_BODY_RE.exec(rest);
134
+ if (m === null) {
135
+ buf = buf.slice(i);
136
+ return;
137
+ }
138
+ events.onFrame({
139
+ exit: Number(m[1]),
140
+ cwd: m[2].length > 0 ? m[2] : null
141
+ });
142
+ done = true;
143
+ buf = "";
144
+ };
145
+ return {
146
+ feed(chunk) {
147
+ buf += chunk;
148
+ consume();
149
+ },
150
+ flush() {
151
+ if (buf.length > 0) events.onOutput?.(buf);
152
+ buf = "";
153
+ }
154
+ };
155
+ }
156
+
157
+ //#endregion
158
+ //#region src/remote/pty-session.ts
159
+ /** spec §五.2 逐字。 */
160
+ const RECONNECT_NOTICE = "[remote: session reconnected to fresh shell; cwd reset to default]";
161
+ const DEFAULT_INIT_TIMEOUT_SEC = 30;
162
+ const INTERRUPT_GRACE_MS = 3e3;
163
+ /** pty 收集内存安全帽(Ruling P15,与 oneshot HARD_CAP 对齐):模型面只见尾窗,保留尾部。 */
164
+ const OUTPUT_HARD_CAP = 4e6;
165
+ var PtySession = class {
166
+ proc = null;
167
+ state = "cold";
168
+ startPromise = null;
169
+ queue = Promise.resolve();
170
+ idleTimer = null;
171
+ onChunk = null;
172
+ deathWaiters = [];
173
+ diag = "";
174
+ inFlight = 0;
175
+ lastSettleAt = 0;
176
+ constructor(opts) {
177
+ this.opts = opts;
178
+ }
179
+ get sessionState() {
180
+ return this.state;
181
+ }
182
+ /** status 面快照:state + busy(有命令在跑)+ idleMs(上次 settle 至今)+ pid(进程组头,kill 兜底可观测)。 */
183
+ get snapshot() {
184
+ return {
185
+ state: this.state,
186
+ busy: this.inFlight > 0,
187
+ idleMs: this.lastSettleAt > 0 ? Date.now() - this.lastSettleAt : null,
188
+ pid: this.proc?.pid ?? null
189
+ };
190
+ }
191
+ /** 同一会话命令严格串行(FIFO);死亡/冷态先重启。 */
192
+ dispatch(cmd, dOpts = {}) {
193
+ const attempt = async () => {
194
+ const started = Date.now();
195
+ const reconnected = this.state === "dead";
196
+ if (this.state !== "ready") await this.start();
197
+ this.inFlight++;
198
+ return await new Promise((resolve$1) => {
199
+ const nonce = genNonce();
200
+ let output = "";
201
+ let interrupted = false;
202
+ let settled = false;
203
+ let interruptTimer;
204
+ let killTimer;
205
+ const deathWaiter = () => finish({
206
+ output,
207
+ exit: null,
208
+ cwd: null,
209
+ timedOut: interrupted
210
+ });
211
+ const cleanup = () => {
212
+ this.onChunk = null;
213
+ this.deathWaiters = this.deathWaiters.filter((w) => w !== deathWaiter);
214
+ if (interruptTimer !== void 0) clearTimeout(interruptTimer);
215
+ if (killTimer !== void 0) clearTimeout(killTimer);
216
+ };
217
+ const finish = (r) => {
218
+ if (settled) return;
219
+ settled = true;
220
+ cleanup();
221
+ this.inFlight--;
222
+ this.lastSettleAt = Date.now();
223
+ this.resetIdleTimer();
224
+ resolve$1({
225
+ ...r,
226
+ reconnected,
227
+ durationMs: Date.now() - started
228
+ });
229
+ };
230
+ const parser = createNonceFrameParser(nonce, {
231
+ onOutput: (t) => {
232
+ output += t;
233
+ if (output.length > OUTPUT_HARD_CAP) {
234
+ output = output.slice(output.length - OUTPUT_HARD_CAP);
235
+ if (output.charCodeAt(0) >= 56320 && output.charCodeAt(0) <= 57343) output = output.slice(1);
236
+ }
237
+ },
238
+ onFrame: ({ exit, cwd }) => finish({
239
+ output,
240
+ exit,
241
+ cwd,
242
+ timedOut: interrupted
243
+ })
244
+ });
245
+ this.onChunk = (c) => parser.feed(c);
246
+ this.write(wrapPtyCommand(cmd, nonce));
247
+ if (dOpts.stdin !== void 0) this.write(dOpts.stdin);
248
+ interruptTimer = setTimeout(() => {
249
+ interrupted = true;
250
+ this.write("");
251
+ this.write(`printf '\\033]133;D;${nonce};130;%s\\007' "$PWD"\n`);
252
+ killTimer = setTimeout(() => {
253
+ this.killTree();
254
+ this.markDead();
255
+ finish({
256
+ output,
257
+ exit: null,
258
+ cwd: null,
259
+ timedOut: true
260
+ });
261
+ }, INTERRUPT_GRACE_MS);
262
+ }, dOpts.timeoutSec === void 0 ? 12e4 : dOpts.timeoutSec * 1e3);
263
+ this.deathWaiters.push(deathWaiter);
264
+ });
265
+ };
266
+ const run = this.queue.then(attempt, attempt);
267
+ this.queue = run.then(() => void 0, () => void 0);
268
+ return run;
269
+ }
270
+ /** TTL / row teardown:尽力 exit,1s 后杀组,出池。 */
271
+ async dispose() {
272
+ if (this.state === "disposed") return;
273
+ this.clearIdleTimer();
274
+ this.state = "disposed";
275
+ this.startPromise = null;
276
+ this.write("exit\n");
277
+ setTimeout(() => this.killTree(), 1e3).unref();
278
+ this.opts.onDead?.();
279
+ }
280
+ write(s) {
281
+ if (this.proc?.stdin?.writable !== true) return;
282
+ try {
283
+ this.proc.stdin.write(s);
284
+ } catch {}
285
+ }
286
+ async start() {
287
+ if (this.state === "disposed") throw new Error(`[E_SESSION_DISPOSED] remote pty session '${this.opts.key}' was disposed — the pool replaces disposed sessions; this reference is stale`);
288
+ if (this.startPromise === null) {
289
+ this.state = "starting";
290
+ this.startPromise = this.doStart().catch((err) => {
291
+ this.startPromise = null;
292
+ this.markDead();
293
+ throw err;
294
+ });
295
+ }
296
+ return this.startPromise;
297
+ }
298
+ async doStart() {
299
+ const proc = spawn(this.opts.argv[0], this.opts.argv.slice(1), {
300
+ stdio: [
301
+ "pipe",
302
+ "pipe",
303
+ "pipe"
304
+ ],
305
+ detached: true
306
+ });
307
+ this.proc = proc;
308
+ proc.stdout.setEncoding("utf8");
309
+ proc.stdout.on("data", (d) => {
310
+ this.onChunk?.(d);
311
+ });
312
+ proc.stderr.setEncoding("utf8").on("data", (d) => {
313
+ this.diag = (this.diag + d).slice(-2e3);
314
+ });
315
+ proc.once("close", () => this.markDead());
316
+ proc.once("error", (err) => {
317
+ this.diag = (this.diag + `\n[remote: spawn failed: ${err.message}]`).slice(-2e3);
318
+ });
319
+ const initTimeoutSec = this.opts.initTimeoutSec ?? DEFAULT_INIT_TIMEOUT_SEC;
320
+ const initNonce = genNonce();
321
+ await new Promise((resolve$1, reject) => {
322
+ const timer = setTimeout(() => {
323
+ this.killTree();
324
+ reject(/* @__PURE__ */ new Error(`[E_SESSION_START] remote pty session '${this.opts.key}' did not initialize in ${initTimeoutSec}s` + (this.diag.length > 0 ? `; transport stderr tail: ${this.diag}` : "")));
325
+ }, initTimeoutSec * 1e3);
326
+ const waiter = () => {
327
+ clearTimeout(timer);
328
+ reject(/* @__PURE__ */ new Error(`[E_SESSION_DIED] remote pty session '${this.opts.key}' died before initializing` + (this.diag.length > 0 ? `; transport stderr tail: ${this.diag}` : "")));
329
+ };
330
+ this.deathWaiters.push(waiter);
331
+ const parser = createNonceFrameParser(initNonce, { onFrame: () => {
332
+ clearTimeout(timer);
333
+ this.deathWaiters = this.deathWaiters.filter((w) => w !== waiter);
334
+ if (this.state === "disposed") {
335
+ reject(/* @__PURE__ */ new Error(`[E_SESSION_DISPOSED] remote pty session '${this.opts.key}' disposed during init`));
336
+ return;
337
+ }
338
+ this.state = "ready";
339
+ this.resetIdleTimer();
340
+ resolve$1();
341
+ } });
342
+ this.onChunk = (c) => parser.feed(c);
343
+ this.write(buildInitCommand(initNonce));
344
+ });
345
+ }
346
+ markDead() {
347
+ if (this.state === "disposed") {
348
+ const waiters$1 = this.deathWaiters;
349
+ this.deathWaiters = [];
350
+ for (const w of waiters$1) w();
351
+ return;
352
+ }
353
+ this.state = "dead";
354
+ this.startPromise = null;
355
+ this.clearIdleTimer();
356
+ const waiters = this.deathWaiters;
357
+ this.deathWaiters = [];
358
+ for (const w of waiters) w();
359
+ }
360
+ resetIdleTimer() {
361
+ this.clearIdleTimer();
362
+ this.idleTimer = setTimeout(() => void this.dispose(), this.opts.idleTtlSec * 1e3);
363
+ this.idleTimer.unref();
364
+ }
365
+ clearIdleTimer() {
366
+ if (this.idleTimer !== null) {
367
+ clearTimeout(this.idleTimer);
368
+ this.idleTimer = null;
369
+ }
370
+ }
371
+ killTree() {
372
+ const pid = this.proc?.pid;
373
+ this.proc = null;
374
+ if (pid === void 0) return;
375
+ try {
376
+ process.kill(-pid, "SIGTERM");
377
+ } catch {}
378
+ setTimeout(() => {
379
+ try {
380
+ process.kill(-pid, "SIGKILL");
381
+ } catch {}
382
+ }, 2e3).unref();
383
+ }
384
+ };
385
+ /** 会话池:键 → 会话。意外死亡的会话留在池内(下次 dispatch 带 reconnect 提示)。 */
386
+ var PtyPool = class {
387
+ sessions = /* @__PURE__ */ new Map();
388
+ constructor(defaults) {
389
+ this.defaults = defaults;
390
+ }
391
+ get size() {
392
+ return this.sessions.size;
393
+ }
394
+ /** status 面:键上的会话快照(无会话 = undefined → "none — dials on first exec")。 */
395
+ inspect(key) {
396
+ return this.sessions.get(key)?.snapshot;
397
+ }
398
+ /** roster 面:池内全部会话(key 含 agent 前缀 `${sessionKey}|t:<target>` / `|s:<spawn>`)。 */
399
+ list() {
400
+ return [...this.sessions.entries()].map(([key, s]) => ({
401
+ key,
402
+ snapshot: s.snapshot
403
+ }));
404
+ }
405
+ getOrCreate(key, argv) {
406
+ let s = this.sessions.get(key);
407
+ if (s === void 0 || s.sessionState === "disposed") {
408
+ s = new PtySession({
409
+ key,
410
+ argv,
411
+ ...this.defaults,
412
+ onDead: () => this.sessions.delete(key)
413
+ });
414
+ this.sessions.set(key, s);
415
+ }
416
+ return s;
417
+ }
418
+ async disposeAll() {
419
+ await Promise.all([...this.sessions.values()].map((s) => s.dispose()));
420
+ this.sessions.clear();
421
+ }
422
+ };
423
+
424
+ //#endregion
425
+ //#region src/remote/target.ts
426
+ var TargetFormatError = class extends Error {
427
+ code = "E_TARGET_FORMAT";
428
+ constructor(message) {
429
+ super(message);
430
+ this.name = "TargetFormatError";
431
+ }
432
+ };
433
+ /** 显式协议选择器(Ruling P16):可扩展新协议——加一个词即一条新传输腿。 */
434
+ const PREFIX_RE = /^(docker|incus|ssh):(.+)$/;
435
+ /** spec §二.2 智能路由(Ruling P18):显式选择器 > ssh 缺省;无别名表面——容器一律显式前缀,发现走 on-demand probe。 */
436
+ function resolveTarget(target) {
437
+ const raw = target.trim();
438
+ if (raw.length === 0) throw new TargetFormatError("[E_TARGET_FORMAT] remote: empty target");
439
+ const direct = PREFIX_RE.exec(raw);
440
+ if (direct !== null) {
441
+ if (direct[1] === "ssh") return {
442
+ kind: "ssh",
443
+ host: direct[2]
444
+ };
445
+ return {
446
+ kind: direct[1],
447
+ container: direct[2]
448
+ };
449
+ }
450
+ return {
451
+ kind: "ssh",
452
+ host: raw
453
+ };
454
+ }
455
+
456
+ //#endregion
457
+ //#region src/remote/status.ts
458
+ function oneLine$1(s) {
459
+ return s.trim().split(/\r?\n/).filter(Boolean).at(-1) ?? "";
460
+ }
461
+ /**
462
+ * On-demand 探测(Ruling 17):调用瞬间拨一次,双重超时(ssh ConnectTimeout
463
+ * 5s + 外层 hard 10s),结果即当时事实。绝不缓存、绝不在 boot 时扫。
464
+ */
465
+ async function probeTarget(plan, opts = {}) {
466
+ const ct = opts.connectTimeoutSec ?? 5;
467
+ const hard = opts.hardTimeoutSec ?? 10;
468
+ const runner = opts.runner ?? runOneShot;
469
+ const started = Date.now();
470
+ const r = await runner(plan.kind === "ssh" ? [
471
+ "ssh",
472
+ "-o",
473
+ "BatchMode=yes",
474
+ "-o",
475
+ `ConnectTimeout=${ct}`,
476
+ "-T",
477
+ "--",
478
+ plan.host,
479
+ "true"
480
+ ] : plan.kind === "docker" ? [
481
+ "docker",
482
+ "inspect",
483
+ "--format",
484
+ "{{.State.Status}}",
485
+ plan.container
486
+ ] : [
487
+ "incus",
488
+ "list",
489
+ plan.container,
490
+ "--format",
491
+ "csv",
492
+ "--columns",
493
+ "ns"
494
+ ], { timeoutSec: hard });
495
+ if (plan.kind === "ssh") {
496
+ if (r.exit === 0 && !r.timedOut) return {
497
+ state: "reachable",
498
+ rttMs: Date.now() - started
499
+ };
500
+ return {
501
+ state: "unreachable",
502
+ detail: r.timedOut ? `probe timed out after ${hard}s` : oneLine$1(r.stderr) || `ssh exited ${r.exit}`
503
+ };
504
+ }
505
+ if (plan.kind === "docker") {
506
+ if (r.exit === 0 && !r.timedOut) return {
507
+ state: "container",
508
+ status: r.stdout.trim()
509
+ };
510
+ return {
511
+ state: "error",
512
+ detail: oneLine$1(r.stderr) || `docker inspect exited ${r.exit}`
513
+ };
514
+ }
515
+ const row = r.stdout.trim().split(/\r?\n/).find((l) => l.split(",")[0] === plan.container);
516
+ if (r.exit === 0 && !r.timedOut && row !== void 0) return {
517
+ state: "container",
518
+ status: row.split(",")[1]?.trim() ?? "unknown"
519
+ };
520
+ if (r.exit === 0 && !r.timedOut) return {
521
+ state: "error",
522
+ detail: `incus list has no container named '${plan.container}'`
523
+ };
524
+ return {
525
+ state: "error",
526
+ detail: oneLine$1(r.stderr) || `incus exited ${r.exit}`
527
+ };
528
+ }
529
+ function renderProbe(p) {
530
+ switch (p.state) {
531
+ case "reachable": return `probe: reachable in ${p.rttMs}ms (just now, on demand)`;
532
+ case "unreachable": return `probe: unreachable — ${p.detail}`;
533
+ case "error": return `probe: error — ${p.detail}`;
534
+ case "container": return `probe: container ${p.status} (just now, on demand)`;
535
+ }
536
+ }
537
+ /**
538
+ * 唯一偏离 brief 处:brief 的 renderSession 测试以不含 `pid` 的三字段字面量直呼本函数,而
539
+ * SessionSnapshot.pid 为必填 → 按测试意图(会话层词汇只由 state/busy/idleMs 决定)收窄为
540
+ * 结构子集;传完整 SessionSnapshot 仍然成立,运行时语义零变化。
541
+ */
542
+ function renderSession(s) {
543
+ if (s === void 0 || s.state === "cold" || s.state === "starting" || s.state === "disposed") return "session: none — dials on first exec";
544
+ if (s.state === "dead") return "session: died — next exec cold-starts a fresh shell";
545
+ if (s.busy) return "session: busy (a command is running)";
546
+ return `session: idle ${Math.round((s.idleMs ?? 0) / 1e3)}s (connected)`;
547
+ }
548
+
549
+ //#endregion
550
+ //#region src/remote/ssh-config.ts
551
+ /** Per-entry Host-line pattern lists — module-private, so the public entry shape stays exactly as declared. */
552
+ const PATTERNS = /* @__PURE__ */ new WeakMap();
553
+ /** `~` / `~/` prefix → real home directory (IdentityFile only). */
554
+ function expandHome(path) {
555
+ if (path === "~") return homedir();
556
+ if (path.startsWith("~/")) return homedir() + path.slice(1);
557
+ return path;
558
+ }
559
+ function parseSshConfig(text) {
560
+ const entries = [];
561
+ const skipped = [];
562
+ const skippedSeen = /* @__PURE__ */ new Set();
563
+ let current;
564
+ const skip = (key) => {
565
+ const lower = key.toLowerCase();
566
+ if (!skippedSeen.has(lower)) {
567
+ skippedSeen.add(lower);
568
+ skipped.push(key);
569
+ }
570
+ };
571
+ for (const rawLine of text.split(/\r?\n/)) {
572
+ const line = rawLine.trim();
573
+ if (line === "" || line.startsWith("#")) continue;
574
+ const match = /^([^\s=]+)\s*(?:=\s*)?(.*)$/.exec(line);
575
+ if (match === null) continue;
576
+ const key = match[1];
577
+ const value = (match[2] ?? "").trim();
578
+ if (key.toLowerCase() === "host") {
579
+ const patterns = value.split(/[\s,]+/).filter((p) => p !== "");
580
+ if (patterns.length === 0) {
581
+ skip(key);
582
+ continue;
583
+ }
584
+ current = {
585
+ host: patterns[0],
586
+ identityFiles: []
587
+ };
588
+ PATTERNS.set(current, patterns);
589
+ entries.push(current);
590
+ continue;
591
+ }
592
+ switch (key.toLowerCase()) {
593
+ case "hostname":
594
+ if (current !== void 0 && current.hostName === void 0) current.hostName = value;
595
+ break;
596
+ case "user":
597
+ if (current !== void 0 && current.user === void 0) current.user = value;
598
+ break;
599
+ case "port": {
600
+ if (current === void 0) break;
601
+ const port = /^\d+$/.test(value) ? Number.parseInt(value, 10) : void 0;
602
+ if (current.port === void 0) if (port === void 0) skip(key);
603
+ else current.port = port;
604
+ break;
605
+ }
606
+ case "identityfile":
607
+ if (current !== void 0) current.identityFiles.push(expandHome(value));
608
+ break;
609
+ case "proxyjump":
610
+ if (current !== void 0 && current.proxyJump === void 0) current.proxyJump = value;
611
+ break;
612
+ default: skip(key);
613
+ }
614
+ }
615
+ return {
616
+ entries,
617
+ skipped
618
+ };
619
+ }
620
+
621
+ //#endregion
622
+ //#region src/remote/roster.ts
623
+ function oneLine(s) {
624
+ return s.trim().split(/\r?\n/).filter(Boolean).at(-1) ?? "";
625
+ }
626
+ /** ssh config 里的字面别名(无通配、非否定的首个模式);通配块是默认值来源不是目标。 */
627
+ function literalHosts(text) {
628
+ const { entries } = parseSshConfig(text);
629
+ const hosts = [];
630
+ let wildcardBlocks = 0;
631
+ for (const entry of entries) if (/[*?]/.test(entry.host) || entry.host.startsWith("!")) wildcardBlocks++;
632
+ else hosts.push(entry.host);
633
+ return {
634
+ hosts,
635
+ wildcardBlocks
636
+ };
637
+ }
638
+ /**
639
+ * Build the roster text. Sections render independently; a failing section
640
+ * renders its native error (honest vocabulary: never invent, never hide).
641
+ */
642
+ async function renderRoster(sessions, opts = {}) {
643
+ const runner = opts.runner ?? runOneShot;
644
+ const readSshConfig = opts.readSshConfig ?? (async () => await readFile(join(homedir(), ".ssh", "config"), "utf8"));
645
+ const hard = opts.hardTimeoutSec ?? 10;
646
+ const lines = [];
647
+ try {
648
+ const { hosts, wildcardBlocks } = literalHosts(await readSshConfig());
649
+ lines.push(`ssh hosts (~/.ssh/config): ${hosts.length > 0 ? hosts.join(", ") : "(none)"}${wildcardBlocks > 0 ? ` (+${wildcardBlocks} wildcard default blocks, not listed)` : ""}`);
650
+ } catch (error) {
651
+ lines.push(`ssh hosts: no readable ~/.ssh/config (${error instanceof Error ? error.message : String(error)})`);
652
+ }
653
+ const docker = await runner([
654
+ "docker",
655
+ "ps",
656
+ "-a",
657
+ "--format",
658
+ "{{.Names}} {{.State}}"
659
+ ], { timeoutSec: hard });
660
+ if (docker.exit === 0) {
661
+ const rows = docker.stdout.trim().split(/\r?\n/).filter(Boolean).map((l) => {
662
+ const [name$1, state] = l.split(" ");
663
+ return `${name$1}${state !== void 0 && state !== "" ? ` (${state})` : ""}`;
664
+ });
665
+ lines.push(`docker containers: ${rows.length > 0 ? rows.join(", ") : "(none)"}`);
666
+ } else lines.push(`docker containers: unavailable — ${docker.timedOut ? `scan timed out after ${hard}s` : oneLine(docker.stderr) || `docker exited ${docker.exit}`}`);
667
+ const incus = await runner([
668
+ "incus",
669
+ "list",
670
+ "--format",
671
+ "csv",
672
+ "--columns",
673
+ "ns"
674
+ ], { timeoutSec: hard });
675
+ if (incus.exit === 0) {
676
+ const rows = incus.stdout.trim().split(/\r?\n/).filter(Boolean).map((l) => {
677
+ const [name$1, state] = l.split(",");
678
+ return `${name$1}${state !== void 0 && state !== "" ? ` (${state.trim()})` : ""}`;
679
+ });
680
+ lines.push(`incus containers: ${rows.length > 0 ? rows.join(", ") : "(none)"}`);
681
+ } else lines.push(`incus containers: unavailable — ${incus.timedOut ? `scan timed out after ${hard}s` : oneLine(incus.stderr) || `incus exited ${incus.exit}`}`);
682
+ lines.push(sessions.length > 0 ? `live pty sessions: ${sessions.map((s) => `${s.target} [${s.state}${s.idleSec !== null ? `, idle ${s.idleSec}s` : ""}]`).join(", ")}` : "live pty sessions: none");
683
+ return lines.join("\n");
684
+ }
685
+
686
+ //#endregion
687
+ //#region src/remote/transports.ts
688
+ /** POSIX 单引号机械转义(唯一一层转义,spec §三"一层机械转义")。 */
689
+ function shQuote(s) {
690
+ return `'${s.replaceAll("'", `'\\''`)}'`;
691
+ }
692
+ /** One-shot(§五.1):不分配 tty,收尾 = 子进程退出码,零字符串扫描。 */
693
+ function buildOneShotArgv(plan, cmd) {
694
+ switch (plan.kind) {
695
+ case "ssh": return [
696
+ "ssh",
697
+ "-o",
698
+ "BatchMode=yes",
699
+ "-T",
700
+ "--",
701
+ plan.host,
702
+ `bash -lc ${shQuote(cmd)}`
703
+ ];
704
+ case "docker": return [
705
+ "docker",
706
+ "exec",
707
+ "-i",
708
+ "--",
709
+ plan.container,
710
+ "bash",
711
+ "-lc",
712
+ cmd
713
+ ];
714
+ case "incus": return [
715
+ "incus",
716
+ "exec",
717
+ "--force-noninteractive",
718
+ plan.container,
719
+ "--",
720
+ "bash",
721
+ "-lc",
722
+ cmd
723
+ ];
724
+ }
725
+ }
726
+ /** Persistent pty(Ruling 2):ssh/incus 走宿主管道,docker 经 script(1) 托管宿主 PTY。 */
727
+ function buildPtyArgv(plan) {
728
+ switch (plan.kind) {
729
+ case "ssh": return [
730
+ "ssh",
731
+ "-tt",
732
+ "-o",
733
+ "BatchMode=yes",
734
+ "--",
735
+ plan.host,
736
+ "bash"
737
+ ];
738
+ case "docker": return [
739
+ "script",
740
+ "-qfec",
741
+ `stty -echo 2>/dev/null ; exec docker exec -it -- ${shQuote(plan.container)} bash`,
742
+ "/dev/null"
743
+ ];
744
+ case "incus": return [
745
+ "incus",
746
+ "exec",
747
+ "--force-interactive",
748
+ plan.container,
749
+ "--",
750
+ "bash"
751
+ ];
752
+ }
753
+ }
754
+ /** BYO-PTY(§三 spawn):工具只提供 PTY 托管 + Nonce 注入 + 收帧。 */
755
+ function buildSpawnArgv(spawnCommand) {
756
+ return [
757
+ "script",
758
+ "-qfec",
759
+ `stty -echo 2>/dev/null ; exec ${spawnCommand}`,
760
+ "/dev/null"
761
+ ];
762
+ }
763
+
764
+ //#endregion
765
+ //#region src/remote/driver.ts
766
+ /** 双轨调度器(spec §七 Phase 2):参数校验 → 路由 → oneshot 子进程 / pty 会话池。 */
767
+ var RemoteDriver = class {
768
+ pool;
769
+ constructor(opts = {}) {
770
+ this.opts = opts;
771
+ this.pool = new PtyPool({ idleTtlSec: opts.idleTtlSec ?? 600 });
772
+ }
773
+ async call(params, callCtx = {}) {
774
+ const started = Date.now();
775
+ try {
776
+ const routing = this.route(params);
777
+ const result = await this.execute(params, routing, callCtx);
778
+ this.audit({
779
+ target: routing.display,
780
+ cmd: params.cmd,
781
+ cwd: result.cwd ?? void 0,
782
+ exit: result.exit,
783
+ durationMs: Date.now() - started
784
+ });
785
+ return result;
786
+ } catch (error) {
787
+ this.audit({
788
+ target: params.target ?? params.spawn ?? "?",
789
+ cmd: params.cmd,
790
+ error: error instanceof Error ? error.message : String(error),
791
+ durationMs: Date.now() - started
792
+ });
793
+ throw error;
794
+ }
795
+ }
796
+ /** Ruling 16/17:on-demand 探测 + 会话层,两行如实陈述。 */
797
+ /**
798
+ * Ruling P20:裸名先按名匹配——ssh config 字面 host → docker → incus(本地廉价扫描,
799
+ * 撞名 ssh 优先,查无 → 默认 ssh 交原生错误)。选择器前缀的 target 不经此路径。
800
+ */
801
+ async resolveBareName(name$1) {
802
+ const runner = this.opts.rosterRunner ?? runOneShot;
803
+ const readCfg = this.opts.sshConfigReader ?? (async () => await readFile(join(homedir(), ".ssh", "config"), "utf8"));
804
+ try {
805
+ const { hosts } = literalHosts(await readCfg());
806
+ if (hosts.some((h) => h.toLowerCase() === name$1.toLowerCase())) return {
807
+ kind: "ssh",
808
+ host: name$1
809
+ };
810
+ } catch {}
811
+ const docker = await runner([
812
+ "docker",
813
+ "ps",
814
+ "-a",
815
+ "--format",
816
+ "{{.Names}}"
817
+ ], { timeoutSec: 10 });
818
+ if (docker.exit === 0 && docker.stdout.trim().split(/\r?\n/).some((n) => n.trim().toLowerCase() === name$1.toLowerCase())) return {
819
+ kind: "docker",
820
+ container: name$1
821
+ };
822
+ const incus = await runner([
823
+ "incus",
824
+ "list",
825
+ "--format",
826
+ "csv",
827
+ "--columns",
828
+ "n"
829
+ ], { timeoutSec: 10 });
830
+ if (incus.exit === 0 && incus.stdout.trim().split(/\r?\n/).some((n) => n.split(",")[0]?.trim().toLowerCase() === name$1.toLowerCase())) return {
831
+ kind: "incus",
832
+ container: name$1
833
+ };
834
+ return {
835
+ kind: "ssh",
836
+ host: name$1
837
+ };
838
+ }
839
+ /** Ruling P19:roster = 注意力入口——本地廉价扫描(ssh config/docker ps/incus list + 池内活会话),零拨号。 */
840
+ async roster() {
841
+ return await renderRoster(this.pool.list().map(({ key, snapshot }) => {
842
+ const raw = key.includes("|") ? key.slice(key.indexOf("|") + 1) : key;
843
+ return {
844
+ target: raw.startsWith("t:") ? raw.slice(2) : raw.startsWith("s:") ? `spawn:${raw.slice(2)}` : raw,
845
+ state: snapshot.state,
846
+ idleSec: snapshot.idleMs !== null ? Math.round(snapshot.idleMs / 1e3) : null
847
+ };
848
+ }), this.opts.rosterRunner !== void 0 ? { runner: this.opts.rosterRunner } : {});
849
+ }
850
+ async status(target, callCtx = {}) {
851
+ const plan = /^(docker|incus|ssh):/.test(target) ? resolveTarget(target) : await this.resolveBareName(target);
852
+ const head = `${target} — ${plan.kind === "ssh" ? "ssh host" : `${plan.kind} container`}`;
853
+ const probe = await probeTarget(plan, { runner: this.opts.probeRunner });
854
+ const key = `${callCtx.sessionKey ?? "no-session"}|t:${target}`;
855
+ return [
856
+ head,
857
+ renderProbe(probe),
858
+ renderSession(this.pool.inspect(key))
859
+ ].join("\n");
860
+ }
861
+ /** Ruling 13:审计 best-effort——钩子抛错不得影响命令路径(成功被毒化成失败+双重审计)。 */
862
+ audit(r) {
863
+ try {
864
+ this.opts.onAudit?.(r);
865
+ } catch {}
866
+ }
867
+ route(params) {
868
+ const hasTarget = params.target !== void 0 && params.target.length > 0;
869
+ const hasSpawn = params.spawn !== void 0 && params.spawn.length > 0;
870
+ if (hasTarget && hasSpawn) throw new Error("[E_PARAMS] remote: pass exactly one of target or spawn, not both");
871
+ if (!hasTarget && !hasSpawn) throw new Error("[E_PARAMS] remote: target or spawn is required (e.g. { target: \"dev4\", cmd: \"git status\" })");
872
+ if (params.timeout !== void 0 && (!Number.isFinite(params.timeout) || params.timeout <= 0)) throw new Error(`[E_BAD_TIMEOUT] remote: timeout must be a positive number of seconds (got ${JSON.stringify(params.timeout)})`);
873
+ if (params.stdin !== void 0 && (params.cmd === void 0 || params.cmd.length === 0)) throw new Error("[E_STDIN_WITHOUT_CMD] remote: stdin requires cmd");
874
+ const mode = hasSpawn ? "pty" : params.mode ?? "oneshot";
875
+ if (hasSpawn) return {
876
+ mode,
877
+ display: params.spawn,
878
+ spawn: params.spawn
879
+ };
880
+ const direct = /^(docker|incus|ssh):/.test(params.target);
881
+ return {
882
+ mode,
883
+ display: params.target,
884
+ ...direct ? { plan: resolveTarget(params.target) } : { bare: params.target }
885
+ };
886
+ }
887
+ async execute(params, routing, callCtx) {
888
+ if (routing.bare !== void 0) routing.plan = await this.resolveBareName(routing.bare);
889
+ const timeoutSec = params.timeout ?? this.opts.execTimeoutSec ?? 120;
890
+ const cap = this.opts.maxOutputChars ?? 3e4;
891
+ if (routing.mode === "oneshot") {
892
+ const r$1 = await runOneShot((this.opts.oneshotArgvFor ?? buildOneShotArgv)(routing.plan, params.cmd), {
893
+ stdin: params.stdin,
894
+ timeoutSec
895
+ });
896
+ const out$1 = tailWindow(r$1.stdout, cap);
897
+ const err = r$1.stderr.length > 0 ? tailWindow(r$1.stderr, cap) : void 0;
898
+ return {
899
+ mode: "oneshot",
900
+ target: routing.display,
901
+ exit: r$1.exit,
902
+ cwd: null,
903
+ timedOut: r$1.timedOut,
904
+ reconnected: false,
905
+ stdout: out$1.text,
906
+ ...err !== void 0 ? { stderr: err.text } : {},
907
+ durationMs: r$1.durationMs,
908
+ ...out$1.truncated !== void 0 ? { truncated: out$1.truncated } : {}
909
+ };
910
+ }
911
+ const argv = routing.spawn !== void 0 ? (this.opts.spawnArgvFor ?? buildSpawnArgv)(routing.spawn) : (this.opts.ptyArgvFor ?? buildPtyArgv)(routing.plan);
912
+ const key = `${callCtx.sessionKey ?? "no-session"}|${routing.spawn !== void 0 ? `s:${routing.spawn}` : `t:${routing.display}`}`;
913
+ const r = await this.pool.getOrCreate(key, argv).dispatch(params.cmd, {
914
+ stdin: params.stdin,
915
+ timeoutSec
916
+ });
917
+ const out = tailWindow(r.output, cap);
918
+ return {
919
+ mode: "pty",
920
+ target: routing.display,
921
+ exit: r.exit,
922
+ cwd: r.cwd,
923
+ timedOut: r.timedOut,
924
+ reconnected: r.reconnected,
925
+ stdout: out.text,
926
+ durationMs: r.durationMs,
927
+ ...out.truncated !== void 0 ? { truncated: out.truncated } : {}
928
+ };
929
+ }
930
+ async dispose() {
931
+ await this.pool.disposeAll();
932
+ }
933
+ };
934
+
935
+ //#endregion
936
+ //#region src/remote/tool.ts
937
+ const NO_SESSION_KEY = "remote:no-session";
938
+ function createRemoteTool(driver, opts = {}) {
939
+ const getSessionKey = opts.getSessionKey ?? ((exec) => exec.agent?.id ?? NO_SESSION_KEY);
940
+ return defineTool({
941
+ name: "remote",
942
+ description: "Remote execution over three transports (ssh / docker / incus). Pick the call shape first:\n {} → roster: known target names from a local scan (no dialing)\n {target} → on-demand status probe of that one target\n {target, cmd} → run cmd — oneshot by default; mode \"pty\" for a persistent shell\n {spawn, cmd} → BYO-PTY: you supply the interactive-shell command\nBare names match by name first (ssh config, then docker, then incus; on a collision ssh wins; unknown names go to ssh and its native error surfaces). \"docker:<name>\" / \"incus:<name>\" / \"ssh:<name>\" force a transport when you must be explicit. oneshot: stateless `bash -lc`, stdout/stderr separate, the exit code is the remote process's own. pty: one persistent shell per (agent, target) — cwd/env survive across calls, streams merge, Ctrl-C interrupts without killing the session, idle sessions are reaped after 600s (a reconnect notice precedes the fresh shell). Output is tail-truncated at ~30k chars with a \"[truncated: …]\" header — redirect bulk output to a file yourself. `stdin` feeds the command; `timeout` (seconds) interrupts then kills. Native transport errors pass through verbatim — self-correct from them. Background processes must redirect their own output (`cmd > log 2>&1 &`). Status probes report \"reachable\"/\"unreachable — <native error>\" or the factual container state plus the session layer (\"idle Ns\" / \"busy\" / \"none — dials on first exec\"), always probed at call time.",
943
+ parameters: {
944
+ target: {
945
+ type: "string",
946
+ description: "Bare name / IP / domain matches by name (ssh config, then docker, then incus; collision → ssh). \"docker:<name>\" / \"incus:<name>\" / \"ssh:<name>\" force a transport. Exactly one of target / spawn."
947
+ },
948
+ spawn: {
949
+ type: "string",
950
+ description: "BYO-PTY escape hatch: the full command that starts an interactive shell (e.g. \"docker exec -it img bash\"). Requires cmd, excludes target; mode is locked to pty; nested hops are dumb pipes (only the innermost bash frames)."
951
+ },
952
+ cmd: {
953
+ type: "string",
954
+ description: "One raw shell string parsed by the remote bash (multi-line = one compound; exit = last command's). Omit with target → status probe; omit everything → roster."
955
+ },
956
+ mode: {
957
+ type: "string",
958
+ enum: ["oneshot", "pty"],
959
+ description: "'oneshot' (default): stateless `bash -lc`, stdout/stderr separate, exit code is the process's own. 'pty': one persistent shell per (agent, target), cwd/env survive, streams merge, idle TTL 600s."
960
+ },
961
+ stdin: {
962
+ type: "string",
963
+ description: "Input fed to the command. oneshot: piped to stdin. pty: written right after the command line (REPL-style) — only helps commands that read stdin there. Requires cmd."
964
+ },
965
+ timeout: {
966
+ type: "number",
967
+ description: "Per-command timeout in seconds (default 120, row-configurable). pty: Ctrl-C first, then kill; an interrupt does not kill the session."
968
+ }
969
+ },
970
+ output: {
971
+ schema: {
972
+ type: "object",
973
+ additionalProperties: false,
974
+ properties: {
975
+ kind: {
976
+ type: "string",
977
+ required: true,
978
+ enum: [
979
+ "exec",
980
+ "status",
981
+ "roster"
982
+ ]
983
+ },
984
+ text: {
985
+ type: "string",
986
+ required: true
987
+ },
988
+ exit: { oneOf: [{ type: "integer" }, { type: "null" }] },
989
+ durationMs: { type: "integer" },
990
+ cwd: { type: "string" },
991
+ stderr: { type: "string" },
992
+ timedOut: { type: "boolean" },
993
+ reconnected: { type: "boolean" },
994
+ truncated: { type: "integer" }
995
+ }
996
+ },
997
+ render: (_args, value) => [{
998
+ type: "text",
999
+ text: renderValue(value)
1000
+ }]
1001
+ },
1002
+ async execute(args, exec) {
1003
+ const hasCmd = typeof args.cmd === "string" && args.cmd.length > 0;
1004
+ const hasTarget = typeof args.target === "string" && args.target.length > 0;
1005
+ const hasSpawn = typeof args.spawn === "string" && args.spawn.length > 0;
1006
+ if (args.mode !== void 0 && args.mode !== "oneshot" && args.mode !== "pty") throw new Error(`[E_BAD_MODE] remote: mode must be 'oneshot' or 'pty' (got ${JSON.stringify(args.mode)})`);
1007
+ if (!hasCmd) {
1008
+ if (args.stdin !== void 0) throw new Error("[E_STDIN_WITHOUT_CMD] remote: stdin requires cmd — status probes take no input");
1009
+ if (hasSpawn) throw new Error("[E_PARAMS] remote: spawn is an exec channel — pass cmd (there is nothing to probe for BYO-PTY)");
1010
+ if (!hasTarget) return {
1011
+ kind: "roster",
1012
+ text: await driver.roster(),
1013
+ exit: null,
1014
+ durationMs: 0
1015
+ };
1016
+ return {
1017
+ kind: "status",
1018
+ text: await driver.status(args.target, { sessionKey: getSessionKey(exec) }),
1019
+ exit: null,
1020
+ durationMs: 0
1021
+ };
1022
+ }
1023
+ const r = await driver.call({
1024
+ target: args.target,
1025
+ spawn: args.spawn,
1026
+ cmd: args.cmd,
1027
+ mode: args.mode === "oneshot" || args.mode === "pty" ? args.mode : void 0,
1028
+ stdin: args.stdin,
1029
+ timeout: args.timeout
1030
+ }, { sessionKey: getSessionKey(exec) });
1031
+ const v = {
1032
+ kind: "exec",
1033
+ text: r.stdout,
1034
+ exit: r.exit,
1035
+ durationMs: r.durationMs
1036
+ };
1037
+ if (r.cwd !== null) v.cwd = r.cwd;
1038
+ if (r.stderr !== void 0) v.stderr = r.stderr;
1039
+ if (r.timedOut) v.timedOut = true;
1040
+ if (r.reconnected) v.reconnected = true;
1041
+ if (r.truncated !== void 0) v.truncated = r.truncated;
1042
+ return v;
1043
+ }
1044
+ });
1045
+ }
1046
+ function renderValue(v) {
1047
+ if (v.kind === "status" || v.kind === "roster") return v.text;
1048
+ const parts = [];
1049
+ if (v.reconnected === true) parts.push(RECONNECT_NOTICE);
1050
+ parts.push(v.text);
1051
+ const secs = ((v.durationMs ?? 0) / 1e3).toFixed(1);
1052
+ const markers = [];
1053
+ if (v.timedOut === true) markers.push("interrupted");
1054
+ if (v.truncated !== void 0) markers.push(`truncated, original ${v.truncated} chars`);
1055
+ const suffix = markers.length > 0 ? ` · ${markers.join(" · ")}` : "";
1056
+ const cwdTag = typeof v.cwd === "string" && v.cwd.length > 0 ? ` · cwd ${v.cwd}` : "";
1057
+ parts.push(`[exit ${v.exit} · ${secs}s${suffix}${cwdTag}]`);
1058
+ if (v.stderr !== void 0 && v.stderr.length > 0) parts.push("[stderr]", v.stderr);
1059
+ return parts.join("\n");
1060
+ }
1061
+
1062
+ //#endregion
1063
+ //#region src/remote/plugin.ts
1064
+ const name = "dashr-remote";
1065
+ const inject = [];
1066
+ const Config = z.object({
1067
+ execTimeoutSec: z.natural().min(1).default(120),
1068
+ idleTtlSec: z.natural().min(1).default(600),
1069
+ maxOutputChars: z.natural().min(1).default(3e4)
1070
+ }).default({
1071
+ execTimeoutSec: 120,
1072
+ idleTtlSec: 600,
1073
+ maxOutputChars: 3e4
1074
+ });
1075
+ /** MUST stay an arrow(RM0 plugin.ts 同注:function 声明会被 cordis 当构造器 new 掉,返回的 disposer 丢失)。 */
1076
+ const apply = (ctx, config) => {
1077
+ const driver = new RemoteDriver({
1078
+ execTimeoutSec: config.execTimeoutSec,
1079
+ idleTtlSec: config.idleTtlSec,
1080
+ maxOutputChars: config.maxOutputChars
1081
+ });
1082
+ ctx.inject(["tools"], (toolsCtx) => {
1083
+ toolsCtx.tools.register(createRemoteTool(driver, { getSessionKey: (exec) => exec.agent?.id ?? "remote:no-session" }));
1084
+ });
1085
+ return () => void driver.dispose();
1086
+ };
1087
+ var plugin_default = {
1088
+ name,
1089
+ inject,
1090
+ Config,
1091
+ apply
1092
+ };
1093
+
1094
+ //#endregion
1095
+ export { Config, apply, plugin_default as default, inject, name };