@linxin666/dsh-ssh 0.4.4 → 0.4.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -1016,7 +1016,7 @@ function sweepPool(engine) {
1016
1016
  * Run `fn` with a live client for `alias`, reconnecting (up to the
1017
1017
  * attempt budget) when the connection broke mid-flight.
1018
1018
  */
1019
- async function withClient(engine, alias, fn, attempts = 3) {
1019
+ async function withClient(engine, alias, fn, attempts = 3, signal) {
1020
1020
  let lastError;
1021
1021
  for (let attempt = 1; attempt <= attempts; attempt += 1) {
1022
1022
  let record = engine.pool.get(alias);
@@ -1032,6 +1032,7 @@ async function withClient(engine, alias, fn, attempts = 3) {
1032
1032
  return result;
1033
1033
  } catch (error) {
1034
1034
  lastError = error;
1035
+ if (signal?.aborted === true) throw error;
1035
1036
  if (!record.broken) throw error;
1036
1037
  disposeRecord(engine, alias, record);
1037
1038
  } finally {
@@ -1041,16 +1042,33 @@ async function withClient(engine, alias, fn, attempts = 3) {
1041
1042
  throw lastError instanceof Error ? lastError : new Error(String(lastError));
1042
1043
  }
1043
1044
  /** Run one command on `alias` (reusing the pooled connection). */
1044
- async function execCommand(engine, alias, command, timeoutMs) {
1045
+ async function execCommand(engine, alias, command, timeoutMs, signal) {
1045
1046
  const started = Date.now();
1046
1047
  const budget = timeoutMs !== void 0 && timeoutMs > 0 ? timeoutMs : engine.opts.defaultExecTimeoutMs;
1047
1048
  return withClient(engine, alias, async (client) => {
1048
1049
  return await new Promise((resolve, reject) => {
1050
+ let aborted = signal !== void 0 && signal.aborted;
1051
+ let kill = () => {};
1052
+ const onAbort = () => {
1053
+ aborted = true;
1054
+ kill();
1055
+ };
1056
+ if (signal !== void 0 && !signal.aborted) signal.addEventListener("abort", onAbort, { once: true });
1049
1057
  client.exec(command, (error, stream) => {
1050
1058
  if (error !== void 0) {
1059
+ signal?.removeEventListener("abort", onAbort);
1051
1060
  reject(error);
1052
1061
  return;
1053
1062
  }
1063
+ kill = () => {
1064
+ try {
1065
+ stream.signal("KILL");
1066
+ } catch {}
1067
+ try {
1068
+ stream.close();
1069
+ } catch {}
1070
+ };
1071
+ if (aborted) kill();
1054
1072
  const stdout = {
1055
1073
  text: "",
1056
1074
  truncated: false
@@ -1061,10 +1079,21 @@ async function execCommand(engine, alias, command, timeoutMs) {
1061
1079
  };
1062
1080
  let timedOut = false;
1063
1081
  let settled = false;
1082
+ /** One cancelled outcome: the remote channel was killed on caller request. */
1083
+ const cancelledResult = () => ({
1084
+ success: false,
1085
+ exitCode: null,
1086
+ timedOut: false,
1087
+ stdout: stdout.text,
1088
+ stderr: stderr.text,
1089
+ durationMs: Date.now() - started,
1090
+ error: "command cancelled by the caller"
1091
+ });
1064
1092
  const finish = () => {
1065
1093
  if (settled) return;
1066
1094
  settled = true;
1067
1095
  clearTimeout(timer);
1096
+ signal?.removeEventListener("abort", onAbort);
1068
1097
  resolve({
1069
1098
  success: false,
1070
1099
  exitCode: null,
@@ -1091,6 +1120,11 @@ async function execCommand(engine, alias, command, timeoutMs) {
1091
1120
  if (settled) return;
1092
1121
  settled = true;
1093
1122
  clearTimeout(timer);
1123
+ signal?.removeEventListener("abort", onAbort);
1124
+ if (aborted) {
1125
+ resolve(cancelledResult());
1126
+ return;
1127
+ }
1094
1128
  if (typeof code !== "number" && !timedOut) {
1095
1129
  reject(/* @__PURE__ */ new Error("ssh: connection lost mid-flight (channel closed without an exit status)"));
1096
1130
  return;
@@ -1108,6 +1142,11 @@ async function execCommand(engine, alias, command, timeoutMs) {
1108
1142
  if (settled) return;
1109
1143
  settled = true;
1110
1144
  clearTimeout(timer);
1145
+ signal?.removeEventListener("abort", onAbort);
1146
+ if (aborted) {
1147
+ resolve(cancelledResult());
1148
+ return;
1149
+ }
1111
1150
  reject(streamError);
1112
1151
  });
1113
1152
  });
@@ -1413,6 +1452,24 @@ function fastGet(sftp, src, dst, concurrency, onProgress) {
1413
1452
  * to a pooled connection so an idle sweep never closes it, with per-tunnel
1414
1453
  * socket tracking and stop (single / alias-scoped / all).
1415
1454
  */
1455
+ /** Destroy one socket or channel stream, tolerating an already-dead peer. */
1456
+ function destroyQuietly(target) {
1457
+ try {
1458
+ target.destroy();
1459
+ } catch {}
1460
+ }
1461
+ /**
1462
+ * Tear down one tunnel because the SSH link behind it is gone. Idempotent: the
1463
+ * tunnel registry is the guard, so a socket callback or a link-death hook that
1464
+ * arrives after a manual stop does nothing instead of tearing down twice, and
1465
+ * no teardown error can escape into the net connection handler — an exception
1466
+ * there terminates the whole host process.
1467
+ */
1468
+ function abortTunnel(engine, id) {
1469
+ try {
1470
+ stopTunnel(engine, id);
1471
+ } catch {}
1472
+ }
1416
1473
  /** Start a local port-forward tunnel (listens on 127.0.0.1 only). */
1417
1474
  async function startTunnel(engine, alias, options) {
1418
1475
  if (!Number.isInteger(options.remotePort) || options.remotePort < 1 || options.remotePort > 65535) throw new Error("remotePort must be an integer in 1..65535");
@@ -1435,30 +1492,44 @@ async function startTunnel(engine, alias, options) {
1435
1492
  const record = engine.pool.get(alias) ?? await acquire(engine, alias);
1436
1493
  const client = record.client;
1437
1494
  const sockets = /* @__PURE__ */ new Set();
1495
+ const onLinkClosed = () => {
1496
+ abortTunnel(engine, id);
1497
+ };
1498
+ const detachLink = () => {
1499
+ client.off("close", onLinkClosed);
1500
+ };
1438
1501
  const server = createServer((socket) => {
1439
1502
  sockets.add(socket);
1440
1503
  socket.on("close", () => {
1441
1504
  sockets.delete(socket);
1442
1505
  });
1443
- client.forwardOut("127.0.0.1", 0, remoteHost, options.remotePort, (error, stream) => {
1444
- if (error !== void 0) {
1445
- socket.destroy();
1446
- return;
1447
- }
1448
- const destroy = () => {
1449
- try {
1450
- socket.destroy();
1451
- } catch {}
1452
- try {
1453
- stream.close();
1454
- } catch {}
1455
- };
1456
- stream.on("error", destroy);
1457
- socket.on("error", destroy);
1458
- stream.on("close", destroy);
1459
- socket.on("close", destroy);
1460
- stream.pipe(socket).pipe(stream);
1461
- });
1506
+ if (record.broken) {
1507
+ destroyQuietly(socket);
1508
+ abortTunnel(engine, id);
1509
+ return;
1510
+ }
1511
+ try {
1512
+ client.forwardOut("127.0.0.1", 0, remoteHost, options.remotePort, (error, stream) => {
1513
+ if (error !== void 0) {
1514
+ destroyQuietly(socket);
1515
+ return;
1516
+ }
1517
+ const destroy = () => {
1518
+ destroyQuietly(socket);
1519
+ try {
1520
+ stream.close();
1521
+ } catch {}
1522
+ };
1523
+ stream.on("error", destroy);
1524
+ socket.on("error", destroy);
1525
+ stream.on("close", destroy);
1526
+ socket.on("close", destroy);
1527
+ stream.pipe(socket).pipe(stream);
1528
+ });
1529
+ } catch {
1530
+ destroyQuietly(socket);
1531
+ abortTunnel(engine, id);
1532
+ }
1462
1533
  });
1463
1534
  try {
1464
1535
  await new Promise((resolve, reject) => {
@@ -1481,8 +1552,15 @@ async function startTunnel(engine, alias, options) {
1481
1552
  server,
1482
1553
  alias,
1483
1554
  record,
1484
- sockets
1555
+ sockets,
1556
+ detachLink
1485
1557
  });
1558
+ client.once("close", onLinkClosed);
1559
+ if (record.broken) {
1560
+ info.state = "failed";
1561
+ info.error = "ssh link closed while the tunnel was starting";
1562
+ abortTunnel(engine, id);
1563
+ }
1486
1564
  return info;
1487
1565
  }
1488
1566
  /** All active tunnels. */
@@ -1495,11 +1573,12 @@ function stopTunnel(engine, id) {
1495
1573
  if (tunnel === void 0) return false;
1496
1574
  engine.tunnels.delete(id);
1497
1575
  try {
1498
- tunnel.server.close();
1576
+ tunnel.detachLink?.();
1499
1577
  } catch {}
1500
- for (const socket of tunnel.sockets) try {
1501
- socket.destroy();
1578
+ try {
1579
+ tunnel.server.close();
1502
1580
  } catch {}
1581
+ for (const socket of tunnel.sockets) destroyQuietly(socket);
1503
1582
  tunnel.sockets.clear();
1504
1583
  if (![...engine.tunnels.values()].some((candidate) => candidate.record === tunnel.record)) if (engine.pool.get(tunnel.alias) === tunnel.record) disposeRecord(engine, tunnel.alias, tunnel.record);
1505
1584
  else endRecordChain(tunnel.record);
@@ -1535,7 +1614,7 @@ async function cluster(engine, options) {
1535
1614
  while (queue.length > 0) {
1536
1615
  const entry = queue.shift();
1537
1616
  try {
1538
- const result = await execCommand(engine, entry.alias, options.command, options.timeoutMs);
1617
+ const result = await execCommand(engine, entry.alias, options.command, options.timeoutMs, options.signal);
1539
1618
  results.push({
1540
1619
  alias: entry.alias,
1541
1620
  ok: result.success,
@@ -1595,8 +1674,8 @@ var SshEngine = class {
1595
1674
  return entry === void 0 ? void 0 : this.store.summarize(entry);
1596
1675
  }
1597
1676
  /** Run one command on `alias` (reusing the pooled connection). */
1598
- async exec(alias, command, timeoutMs) {
1599
- return execCommand(this, alias, command, timeoutMs);
1677
+ async exec(alias, command, timeoutMs, signal) {
1678
+ return execCommand(this, alias, command, timeoutMs, signal);
1600
1679
  }
1601
1680
  /** Run one command against many hosts concurrently. */
1602
1681
  async cluster(options) {
@@ -2594,7 +2673,7 @@ function renderTunnel(tunnel) {
2594
2673
  function sshListTool(engine) {
2595
2674
  return defineTool({
2596
2675
  name: "ssh_list",
2597
- description: "List configured SSH hosts (alias, host, user, auth, environment, tags, description). Use ssh_exec etc. with the alias. Triggers: SSH, remote server, server IP/hostname, connect/login, check server/status, deploy, upload/download, jump host, tunnel, port forward.",
2676
+ description: "List configured SSH hosts (alias, host, user, auth, environment, tags, description). Use ssh_exec etc. with the alias.",
2598
2677
  parameters: { query: {
2599
2678
  type: "string",
2600
2679
  description: "Optional fuzzy match against alias, description, host, and tags."
@@ -2670,6 +2749,7 @@ function sshListTool(engine) {
2670
2749
  },
2671
2750
  render: (_args, value) => text(renderHosts(value.hosts ?? []))
2672
2751
  },
2752
+ isConcurrencySafe: () => true,
2673
2753
  async execute(args) {
2674
2754
  return { hosts: engine.list(args.query).map(toAgentHostRow) };
2675
2755
  }
@@ -2679,7 +2759,7 @@ function sshListTool(engine) {
2679
2759
  function sshExecTool(engine) {
2680
2760
  return defineTool({
2681
2761
  name: "ssh_exec",
2682
- description: "Execute a shell command on a REMOTE SSH host by alias; the command runs on the remote host, never on this machine. For commands on this machine, use the local bash tool. Prefer combining independent read-only queries into one command. Triggers: run command on server, deploy, check server/status, service control, view logs, any remote operation.",
2762
+ description: "Execute a shell command on a REMOTE SSH host by alias; the command runs on the remote host, never on this machine. For commands on this machine, use the local bash tool. Prefer combining independent read-only queries into one command.",
2683
2763
  parameters: {
2684
2764
  alias: {
2685
2765
  type: "string",
@@ -2730,9 +2810,9 @@ function sshExecTool(engine) {
2730
2810
  },
2731
2811
  render: (_args, value) => text(renderExec(value))
2732
2812
  },
2733
- async execute(args) {
2813
+ async execute(args, exec) {
2734
2814
  try {
2735
- return await engine.exec(args.alias, args.command, args.timeoutMs);
2815
+ return await engine.exec(args.alias, args.command, args.timeoutMs, exec.signal);
2736
2816
  } catch (error) {
2737
2817
  return {
2738
2818
  success: false,
@@ -2751,7 +2831,7 @@ function sshExecTool(engine) {
2751
2831
  function sshUploadTool(engine) {
2752
2832
  return defineTool({
2753
2833
  name: "ssh_upload",
2754
- description: "Transfer a file FROM this machine (the dsh host) TO a remote SSH host. Use this only when the file must be copied to the remote host. Files that stay on this machine are handled with the local file tools (read / write / edit), not ssh_upload. Triggers: upload file to server, deploy artifact, copy config to server.",
2834
+ description: "Transfer a file FROM this machine (the dsh host) TO a remote SSH host. Use this only when the file must be copied to the remote host. Files that stay on this machine are handled with the local file tools (read / write / edit), not ssh_upload.",
2755
2835
  parameters: {
2756
2836
  alias: {
2757
2837
  type: "string",
@@ -2806,7 +2886,7 @@ function sshUploadTool(engine) {
2806
2886
  function sshDownloadTool(engine) {
2807
2887
  return defineTool({
2808
2888
  name: "ssh_download",
2809
- description: "Copy a remote FILE from a configured SSH host to this machine (the dsh host). Use this only when the source is on the remote host; files already on this machine are read with the local file tools (read / write / edit), not ssh_download. Directory download is not supported — download files individually. Triggers: download file from server, fetch remote log/artifact.",
2889
+ description: "Copy a remote FILE from a configured SSH host to this machine (the dsh host). Use this only when the source is on the remote host; files already on this machine are read with the local file tools (read / write / edit), not ssh_download. Directory download is not supported — download files individually.",
2810
2890
  parameters: {
2811
2891
  alias: {
2812
2892
  type: "string",
@@ -2858,7 +2938,7 @@ function sshDownloadTool(engine) {
2858
2938
  function sshTunnelTool(engine) {
2859
2939
  return defineTool({
2860
2940
  name: "ssh_tunnel",
2861
- description: "Manage local port-forward tunnels to a configured SSH host. Start a tunnel to reach a remote internal service (database, web UI, API) through 127.0.0.1 on this machine. Triggers: tunnel, port forward, connect database, access internal service.",
2941
+ description: "Manage local port-forward tunnels to a configured SSH host. Start a tunnel to reach a remote internal service (database, web UI, API) through 127.0.0.1 on this machine.",
2862
2942
  parameters: {
2863
2943
  action: {
2864
2944
  type: "string",
@@ -3053,7 +3133,7 @@ function sshTunnelTool(engine) {
3053
3133
  function sshClusterTool(engine) {
3054
3134
  return defineTool({
3055
3135
  name: "ssh_cluster",
3056
- description: "Run one command concurrently across selected SSH hosts; at least one aliases, environment, or tags filter is required. Triggers: run on selected servers, batch operation, production servers, cluster command.",
3136
+ description: "Run one command concurrently across selected SSH hosts; at least one aliases, environment, or tags filter is required.",
3057
3137
  parameters: {
3058
3138
  command: {
3059
3139
  type: "string",
@@ -3114,9 +3194,12 @@ function sshClusterTool(engine) {
3114
3194
  },
3115
3195
  render: (_args, value) => text(renderCluster(value.results ?? []))
3116
3196
  },
3117
- async execute(args) {
3197
+ async execute(args, exec) {
3118
3198
  if (!(Array.isArray(args.aliases) && args.aliases.some((alias) => typeof alias === "string" && alias.trim() !== "") || typeof args.environment === "string" && args.environment.trim() !== "" || Array.isArray(args.tags) && args.tags.some((tag) => typeof tag === "string" && tag.trim() !== ""))) throw new Error("ssh_cluster requires aliases, environment, or tags to limit the target set");
3119
- return { results: await engine.cluster(args) };
3199
+ return { results: await engine.cluster({
3200
+ ...args,
3201
+ signal: exec.signal
3202
+ }) };
3120
3203
  }
3121
3204
  });
3122
3205
  }
@@ -3235,6 +3318,66 @@ function mountOnce(packageName, fn) {
3235
3318
  return mount;
3236
3319
  }
3237
3320
  //#endregion
3321
+ //#region src/tool-surface.ts
3322
+ /**
3323
+ * Family-shared tool-surface conventions for the packages that register
3324
+ * model-facing tools.
3325
+ *
3326
+ * One home for the prompt-section placement and the visibility gate this
3327
+ * family previously restated per package.
3328
+ *
3329
+ * Modelled on the official tool packages:
3330
+ * - `@deepseek-ai/dsh-tool-subagent` renders its section through
3331
+ * `tools.get(name, context.scope) === undefined ? '' : text`, so guidance
3332
+ * disappears together with a restricted tool.
3333
+ * - 21 of the 22 official tool packages register their tools GLOBALLY and gate
3334
+ * them with a setting, not per agent. Only the Agent Teams tool package —
3335
+ * whose tools belong to one membership — installs into each `agent.ctx`.
3336
+ * This family follows the majority: registration stays global so the
3337
+ * registry's per-scope `restrict()` mask (which filters inherited global
3338
+ * tools and REJECTS scope-local names) can still withhold these tools.
3339
+ *
3340
+ * Structural types only: this copy lands in packages that do not all depend on
3341
+ * the same SDK service packages.
3342
+ */
3343
+ /**
3344
+ * Prompt-section order for each package's tool guidance.
3345
+ *
3346
+ * These sit in the family band between the deployment persona and the SDK's
3347
+ * `PLAN_POLICY` (500). Values are owned here rather than written per package so
3348
+ * the host plugins whose guidance shares one prompt cannot drift into an
3349
+ * accidental tie.
3350
+ */
3351
+ const PLUGIN_TOOL_SECTION_ORDERS = {
3352
+ ssh: 150,
3353
+ "task-board": 200
3354
+ };
3355
+ /**
3356
+ * Build a section provider that renders \`text\` only while at least one of
3357
+ * \`toolNames\` is visible to the rendering scope.
3358
+ *
3359
+ * The native convention: a tool that a restriction, a preset, or a missing
3360
+ * registry removed must not leave guidance behind telling the model to call it.
3361
+ * An unreadable registry renders the text unchanged — guidance that outlives a
3362
+ * momentary read failure is recoverable; guidance for a tool that is gone is
3363
+ * not.
3364
+ * @param tools - the scoped tool registry, when the deployment serves one.
3365
+ * @param toolNames - registered names the guidance describes.
3366
+ * @param text - the guidance rendered while they are reachable.
3367
+ * @returns the section text provider.
3368
+ */
3369
+ function visibleToolText(tools, toolNames, text) {
3370
+ if (tools === void 0) return () => text;
3371
+ return (context) => {
3372
+ for (const name of toolNames) try {
3373
+ if (tools.get(name, context.scope) !== void 0) return text;
3374
+ } catch {
3375
+ return text;
3376
+ }
3377
+ return "";
3378
+ };
3379
+ }
3380
+ //#endregion
3238
3381
  //#region src/index.ts
3239
3382
  /** Stable cordis plugin name. */
3240
3383
  const name = "ssh";
@@ -3275,8 +3418,17 @@ function profileConfig(ctx) {
3275
3418
  if (typeof config !== "object" || config === null || Array.isArray(config)) return void 0;
3276
3419
  return config;
3277
3420
  }
3278
- /** Order of the announcement section within the tool-guidance band. */
3279
- const SECTION_ORDER = 150;
3421
+ /** Order of the announcement section within the shared tool-guidance band. */
3422
+ const SECTION_ORDER = PLUGIN_TOOL_SECTION_ORDERS.ssh;
3423
+ /** The six agent-tool names this plugin's announcement describes. */
3424
+ const SSH_TOOL_NAMES = [
3425
+ "ssh_list",
3426
+ "ssh_exec",
3427
+ "ssh_upload",
3428
+ "ssh_download",
3429
+ "ssh_tunnel",
3430
+ "ssh_cluster"
3431
+ ];
3280
3432
  /** Model-facing announcement: plugin presence, capabilities, and limits. */
3281
3433
  const SSH_GUIDANCE = "本机已安装 dsh-ssh 插件(DSH 远程 SSH 运维):侧边栏「SSH」入口;在 dsh-web 插件全家桶仓库(packages/dsh-ssh)统一维护。能力:主机配置存 $DSH_HOME/dsh-ssh.json(默认 ~/.dsh)(可从 ~/.ssh/config 导入);持久连接池复用长连接(空闲 30 分钟自动断开);ssh_list 列出主机、ssh_exec 执行远程命令、ssh_upload/ssh_download 传输文件、ssh_tunnel 本地端口转发(访问远程数据库/内网服务)、ssh_cluster 按 aliases/environment/tags 至少一种非空 selector 筛选后集群并发执行;支持密钥/密码/ssh-agent 认证、passphrase 密钥、ProxyJump 跳板机(别名或 [user@]host[:port] 地址)与 OpenSSH 语义的 ProxyCommand(跳过堡垒机客户端场景,只能由用户在 GUI 配置,agent 不可写);Web 终端走 WebSocket。限制:主机操作由用户在 GUI 中配置后 agent 方可使用;密码以明文存在用户主目录私有文件(权限 0600);命令输出原样返回、可能含敏感信息;断线重连可能重放非幂等命令;传输/执行消耗真实远程资源,先确认再操作。路径区分:本机(dsh host)上的文件与命令一律用本地工具(read / write / edit / bash),ssh_* 工具只针对远程主机上的路径。用户提到「SSH / 远程服务器 / 服务器操作 / 跳板机 / 隧道 / 部署 / 上传下载」时即指本插件,请据此协作。";
3282
3434
  /**
@@ -3332,7 +3484,7 @@ function applyImpl(ctx, config) {
3332
3484
  if (value.announceToAgent) disposeSection = ctx.systemPrompt.section({
3333
3485
  name: "plugin:dsh-ssh",
3334
3486
  order: SECTION_ORDER,
3335
- text: SSH_GUIDANCE
3487
+ text: visibleToolText(ctx.tools, SSH_TOOL_NAMES, SSH_GUIDANCE)
3336
3488
  });
3337
3489
  disposeRoutes = ctx.effect(() => {
3338
3490
  const disposers = routes.map((route) => ctx.webServer.register(route));
@@ -18,22 +18,33 @@ interface PageLocation {
18
18
  * The WebSocket URL the terminal route is reached at, or undefined when this
19
19
  * page cannot carry one.
20
20
  *
21
- * A page delivered by an application on this machine (the official DSH
22
- * Desktop shell serves its Web GUI from `dsh-app://app/`) has no WebSocket
23
- * transport: its scheme handler forwards HTTP requests to the local host but
24
- * cannot upgrade a socket, so `ws://app/...` never connects and the terminal
25
- * reports "connection error" (issue #1744). Every other page is a web page and
26
- * resolves the socket against its own origin, exactly as before.
21
+ * A web page resolves the socket against its own origin, exactly as before.
27
22
  *
28
- * A blank authority is treated the same as an application scheme: there is
29
- * nothing to dial, and the caller gets the actionable reason instead of a
30
- * socket that can only fail.
23
+ * A page delivered by an application on this machine (the official DSH Desktop
24
+ * shell serves its Web GUI from `dsh-app://app/`) cannot carry a socket on its
25
+ * own scheme: the protocol handler forwards HTTP to the local Host but has no
26
+ * upgrade to forward. That is exactly the case the official transport hook
27
+ * covers - the shell publishes `__DSH_TRANSPORT__.streamBaseUrl`, the loopback
28
+ * authority of the Host it owns and already forwards every other request to
29
+ * (issue #1744). The socket is therefore dialed there instead, with the `ws`/
30
+ * `wss` scheme derived from that base, which is what the shell's
31
+ * `onBeforeSendHeaders` hook expects to see before it attaches its
32
+ * authority-bound credential.
33
+ *
34
+ * The base is used only on an application-delivered page. A web page that
35
+ * somehow carries the hook (the remote channel grants `ownsHost` to a paired
36
+ * LAN page, and that page is fenced behind a pairing channel) keeps resolving
37
+ * against its own origin, so this cannot reroute gated traffic onto a host the
38
+ * page is not entitled to reach. A blank authority is treated the same as an
39
+ * application scheme with no base: there is nothing to dial, and the caller
40
+ * gets the actionable reason instead of a socket that can only fail.
31
41
  *
32
42
  * @param location - the page location to read.
33
43
  * @param search - the query string carrying `alias` or `session`.
44
+ * @param streamBaseUrl - `__DSH_TRANSPORT__.streamBaseUrl`, when the shell published one.
34
45
  * @returns the absolute `ws:`/`wss:` URL, or undefined when the page cannot carry one.
35
46
  */
36
- export declare function terminalSocketUrl(location: PageLocation, search: string): string | undefined;
47
+ export declare function terminalSocketUrl(location: PageLocation, search: string, streamBaseUrl?: string | undefined): string | undefined;
37
48
  /** One open terminal connection (WebSocket JSON frames). */
38
49
  export interface TerminalConnection {
39
50
  /** Fired on the ready frame (shell is up); carries the host session id and alias. */
@@ -89,7 +89,7 @@ export declare const zh: {
89
89
  readonly 'terminal.ready': "终端已连接({alias})";
90
90
  readonly 'terminal.exited': "终端已退出({alias})";
91
91
  readonly 'terminal.error': "终端错误:{error}";
92
- readonly 'terminal.noWebSocket': "当前页面不支持 WebSocket(应用外壳只转发 HTTP),请改用浏览器打开的 Web 界面使用终端。";
92
+ readonly 'terminal.noWebSocket': "当前页面不支持 WebSocket(应用外壳没有可连接的本地主机地址),请改用浏览器打开的 Web 界面使用终端。";
93
93
  readonly 'terminal.auth.title': "二次身份验证 (2FA / 交互式认证)";
94
94
  readonly 'terminal.auth.submit': "提交验证码";
95
95
  readonly 'terminal.auth.cancel': "取消";
@@ -11,6 +11,8 @@ interface ClusterOptions {
11
11
  tags?: string[];
12
12
  timeoutMs?: number;
13
13
  maxWorkers?: number;
14
+ /** Caller cancellation, forwarded to every host's command. */
15
+ signal?: AbortSignal;
14
16
  }
15
17
  /** Run one command against many hosts concurrently. */
16
18
  export declare function cluster(engine: PoolEngine, options: ClusterOptions): Promise<ClusterResult[]>;
@@ -107,6 +107,6 @@ export declare function sweepPool(engine: PoolEngine): void;
107
107
  * Run `fn` with a live client for `alias`, reconnecting (up to the
108
108
  * attempt budget) when the connection broke mid-flight.
109
109
  */
110
- export declare function withClient<T>(engine: PoolEngine, alias: string, fn: (client: Client) => Promise<T>, attempts?: number): Promise<T>;
110
+ export declare function withClient<T>(engine: PoolEngine, alias: string, fn: (client: Client) => Promise<T>, attempts?: number, signal?: AbortSignal): Promise<T>;
111
111
  /** Run one command on `alias` (reusing the pooled connection). */
112
- export declare function execCommand(engine: PoolEngine, alias: string, command: string, timeoutMs?: number): Promise<ExecResult>;
112
+ export declare function execCommand(engine: PoolEngine, alias: string, command: string, timeoutMs?: number, signal?: AbortSignal): Promise<ExecResult>;
@@ -14,6 +14,8 @@ export interface TunnelRecord {
14
14
  /** The pooled connection this tunnel pins; siblings on one alias may share it. */
15
15
  record: PoolRecord;
16
16
  sockets: Set<Socket>;
17
+ /** Detach the link-death hook startTunnel installs on the pooled client. */
18
+ detachLink?: () => void;
17
19
  }
18
20
  /** The engine slice the tunnel module needs (adds the tunnel registry). */
19
21
  export interface TunnelEngine extends PoolEngine {
@@ -36,7 +36,7 @@ export declare class SshEngine {
36
36
  /** One host summary by alias. */
37
37
  find(alias: string): SshHostSummary | undefined;
38
38
  /** Run one command on `alias` (reusing the pooled connection). */
39
- exec(alias: string, command: string, timeoutMs?: number): Promise<ExecResult>;
39
+ exec(alias: string, command: string, timeoutMs?: number, signal?: AbortSignal): Promise<ExecResult>;
40
40
  /** Run one command against many hosts concurrently. */
41
41
  cluster(options: {
42
42
  command: string;
@@ -45,6 +45,7 @@ export declare class SshEngine {
45
45
  tags?: string[];
46
46
  timeoutMs?: number;
47
47
  maxWorkers?: number;
48
+ signal?: AbortSignal;
48
49
  }): Promise<ClusterResult[]>;
49
50
  /** Open a PTY shell session for the web terminal (standalone connection). */
50
51
  openShell(alias: string, size: {
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Family-shared tool-surface conventions for the packages that register
3
+ * model-facing tools.
4
+ *
5
+ * One home for the prompt-section placement and the visibility gate this
6
+ * family previously restated per package.
7
+ *
8
+ * Modelled on the official tool packages:
9
+ * - `@deepseek-ai/dsh-tool-subagent` renders its section through
10
+ * `tools.get(name, context.scope) === undefined ? '' : text`, so guidance
11
+ * disappears together with a restricted tool.
12
+ * - 21 of the 22 official tool packages register their tools GLOBALLY and gate
13
+ * them with a setting, not per agent. Only the Agent Teams tool package —
14
+ * whose tools belong to one membership — installs into each `agent.ctx`.
15
+ * This family follows the majority: registration stays global so the
16
+ * registry's per-scope `restrict()` mask (which filters inherited global
17
+ * tools and REJECTS scope-local names) can still withhold these tools.
18
+ *
19
+ * Structural types only: this copy lands in packages that do not all depend on
20
+ * the same SDK service packages.
21
+ */
22
+ /**
23
+ * Prompt-section order for each package's tool guidance.
24
+ *
25
+ * These sit in the family band between the deployment persona and the SDK's
26
+ * `PLAN_POLICY` (500). Values are owned here rather than written per package so
27
+ * the host plugins whose guidance shares one prompt cannot drift into an
28
+ * accidental tie.
29
+ */
30
+ export declare const PLUGIN_TOOL_SECTION_ORDERS: {
31
+ readonly ssh: 150;
32
+ readonly 'task-board': 200;
33
+ };
34
+ /** One plugin id keyed by {@link PLUGIN_TOOL_SECTION_ORDERS}. */
35
+ export type PluginToolSectionId = keyof typeof PLUGIN_TOOL_SECTION_ORDERS;
36
+ /**
37
+ * Order of an external provider extension's own guidance section, right after
38
+ * the band of the plugin it extends.
39
+ *
40
+ * An extension names itself, not the plugin it plugs into, so its order stays
41
+ * out of {@link PLUGIN_TOOL_SECTION_ORDERS}: every package's copy of this module
42
+ * then carries no provider id. The slot still has one owner and cannot collide
43
+ * with a host plugin's band.
44
+ */
45
+ export declare const EXTENSION_TOOL_SECTION_ORDER = 210;
46
+ /**
47
+ * The scoped tool lookup a section provider needs: the registry read for one
48
+ * assembly's scope. Matches `ctx.tools.get(name, scope)`.
49
+ */
50
+ export interface ScopedToolLookup {
51
+ get(name: string, scope?: unknown): unknown;
52
+ }
53
+ /** One assembly context carrying the scope the section is rendered for. */
54
+ export interface SectionScopeContext {
55
+ scope?: unknown;
56
+ }
57
+ /**
58
+ * Build a section provider that renders \`text\` only while at least one of
59
+ * \`toolNames\` is visible to the rendering scope.
60
+ *
61
+ * The native convention: a tool that a restriction, a preset, or a missing
62
+ * registry removed must not leave guidance behind telling the model to call it.
63
+ * An unreadable registry renders the text unchanged — guidance that outlives a
64
+ * momentary read failure is recoverable; guidance for a tool that is gone is
65
+ * not.
66
+ * @param tools - the scoped tool registry, when the deployment serves one.
67
+ * @param toolNames - registered names the guidance describes.
68
+ * @param text - the guidance rendered while they are reachable.
69
+ * @returns the section text provider.
70
+ */
71
+ export declare function visibleToolText(tools: ScopedToolLookup | undefined, toolNames: readonly string[], text: string): (context: SectionScopeContext) => string;