@hyzyn/dsh-tty 0.15.0 → 0.16.1

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
@@ -772,6 +772,8 @@ class TtyServer {
772
772
  wss = new WebSocketServer({ noServer: true });
773
773
  /** 在途的持久会话创建(tmuxName → 创建 promise):dsh 重启后多页面并发恢复时收敛竞态。 */
774
774
  pendingTmux = new Map();
775
+ /** WS 闸门(插件禁用时关闭):拒绝新升级 + 断开存量连接。 */
776
+ wsGateOpen = true;
775
777
  constructor(ctx, sessions, options, hostKeyStore,
776
778
  /** SSH 持久会话名留存回调(apply 闭包实现,settings 落盘)。 */
777
779
  trackPersist) {
@@ -782,12 +784,36 @@ class TtyServer {
782
784
  this.trackPersist = trackPersist;
783
785
  this.wss.on('connection', (ws) => this.onConnection(ws));
784
786
  }
787
+ /**
788
+ * 按启用状态对齐 WS 闸门(幂等)。关闭时对存量连接发正常关闭帧:客户端走
789
+ * 既有重连循环,禁用期间升级被拒,重新启用后自动重连并 attach 孤儿会话。
790
+ * PTY 进程不受影响(转孤儿保活),不因禁用杀用户进程。
791
+ */
792
+ setWsGate(open) {
793
+ if (open === this.wsGateOpen)
794
+ return;
795
+ this.wsGateOpen = open;
796
+ if (open)
797
+ return;
798
+ for (const ws of this.wss.clients) {
799
+ try {
800
+ ws.close(1001, 'dsh-tty disabled');
801
+ }
802
+ catch {
803
+ /* 已关闭 */
804
+ }
805
+ }
806
+ }
785
807
  /** registerUpgrade 的 handler(loopback 围栏 + ws 握手)。 */
786
808
  handleUpgrade(req, socket, head) {
787
809
  if (!isLoopbackUpgrade(req)) {
788
810
  socket.destroy();
789
811
  return;
790
812
  }
813
+ if (!this.wsGateOpen) {
814
+ socket.destroy();
815
+ return;
816
+ }
791
817
  this.wss.handleUpgrade(req, socket, head, (ws) => {
792
818
  this.wss.emit('connection', ws, req);
793
819
  });
@@ -1404,14 +1430,19 @@ class TtyServer {
1404
1430
  * env 插件以 yaml 数组渲染(`- key: NAME`),逐行宽容提取即可,不引 YAML 依赖。
1405
1431
  */
1406
1432
  function readManagedEnvKeys() {
1433
+ // 托管区块标记(与 env 插件 MARK_START/MARK_END 逐字符一致)
1434
+ const MARK_START = '# --- dsh-env-manager managed (auto-generated; do not edit) ---';
1435
+ const MARK_END = '# --- end dsh-env-manager managed ---';
1407
1436
  const dshHome = process.env.DSH_HOME?.trim() || join(homedir(), '.dsh');
1408
1437
  const file = process.env.DSH_ENV_FILE?.trim() || join(dshHome, 'env.yml');
1409
1438
  try {
1410
1439
  const lines = readFileSync(file, 'utf8').split('\n');
1411
- const start = lines.findIndex((line) => line.includes('dsh-env-manager managed'));
1440
+ // 标记必须整行精确匹配(trimEnd 仅容忍 \r 与尾部空格):值经 yaml literal block
1441
+ // 缩进渲染,子串匹配会把值内的标记文本误判为区块边界,导致键名提取落空。
1442
+ const start = lines.findIndex((line) => line.trimEnd() === MARK_START);
1412
1443
  if (start === -1)
1413
1444
  return [];
1414
- const end = lines.findIndex((line, index) => index > start && line.includes('end dsh-env-manager managed'));
1445
+ const end = lines.findIndex((line, index) => index > start && line.trimEnd() === MARK_END);
1415
1446
  if (end === -1)
1416
1447
  return [];
1417
1448
  const keys = [];
@@ -1659,6 +1690,12 @@ const plugin = definePlugin({
1659
1690
  const server = new TtyServer(ctx, sessions, live, hostKeyStore, trackPersistSession);
1660
1691
  const stateRef = { enabled: true, announceToAgent: config?.announceToAgent !== false, toolsRegistered: false, sftpStyle: config?.sftpStyle === 'dual' ? 'dual' : 'dialog' };
1661
1692
  let settingsScope;
1693
+ // 工具/公告的重注册钩子:真正的实现由各自的注入 effect 挂载时回填。
1694
+ // applyPatch 定义在注入之前,只能先拿 noop——启动顺序无论是 settings 先行
1695
+ // (registerAll 读 stateRef 自行短路)还是 tools/announcement 先行(applyPatch
1696
+ // 再触发一次重注册,幂等),两种顺序最终状态一致。
1697
+ let refreshToolsHook = () => { };
1698
+ let refreshAnnouncementHook = () => { };
1662
1699
  const snapshot = () => ({
1663
1700
  enabled: stateRef.enabled,
1664
1701
  announceToAgent: stateRef.announceToAgent,
@@ -1695,18 +1732,25 @@ const plugin = definePlugin({
1695
1732
  sftpLimits: typeof section.sftpLimits === 'object' && section.sftpLimits !== null ? section.sftpLimits : undefined,
1696
1733
  persistSessions: sanitizePersistSessions(section.persistSessions),
1697
1734
  });
1698
- // 隧道按最新规格对齐(幂等;sshHosts 变更也会触发,让重连取到新凭证)
1699
- tunnelManager.reconcile(live.tunnels);
1700
- if (typeof section.maxSessions === 'number' && Number.isInteger(section.maxSessions) && section.maxSessions >= 1 && section.maxSessions <= 16) {
1701
- sessions.setLimit(section.maxSessions);
1702
- }
1703
1735
  if (typeof section.enabled === 'boolean')
1704
1736
  stateRef.enabled = section.enabled;
1705
1737
  if (typeof section.announceToAgent === 'boolean')
1706
1738
  stateRef.announceToAgent = section.announceToAgent;
1707
1739
  if (section.sftpStyle === 'dialog' || section.sftpStyle === 'dual')
1708
1740
  stateRef.sftpStyle = section.sftpStyle;
1709
- console.log(`[dsh-tty] config applied (shell=${live.shell}, term=${live.term}, cwd=${live.cwd}, maxSessions=${sessions.limitValue}, sshHosts=${live.sshHosts.length})`);
1741
+ // 隧道按最新规格对齐(幂等;sshHosts 变更也会触发,让重连取到新凭证)。
1742
+ // 禁用态清空目标列表(拆掉活跃转发),重新启用后的下一次对齐自动恢复
1743
+ tunnelManager.reconcile(stateRef.enabled ? live.tunnels : []);
1744
+ if (typeof section.maxSessions === 'number' && Number.isInteger(section.maxSessions) && section.maxSessions >= 1 && section.maxSessions <= 16) {
1745
+ sessions.setLimit(section.maxSessions);
1746
+ }
1747
+ // 禁用/启用热生效(enabled 不再只是记账):工具与公告重注册(幂等)、
1748
+ // WS 闸门与隧道随开关对齐。PTY 进程不杀——会话转孤儿保活,重新启用后
1749
+ // 客户端重连即 attach 回来
1750
+ refreshToolsHook();
1751
+ refreshAnnouncementHook();
1752
+ server.setWsGate(stateRef.enabled);
1753
+ console.log(`[dsh-tty] config applied (shell=${live.shell}, term=${live.term}, cwd=${live.cwd}, maxSessions=${sessions.limitValue}, sshHosts=${live.sshHosts.length}, enabled=${String(stateRef.enabled)})`);
1710
1754
  };
1711
1755
  /** 校验 HTTP POST 的配置体;返回规范化补丁或错误信息。 */
1712
1756
  const normalizePatch = (input) => {
@@ -1820,6 +1864,19 @@ const plugin = definePlugin({
1820
1864
  webCtx.effect(() => {
1821
1865
  const webServer = webCtx.webServer;
1822
1866
  const disposers = [];
1867
+ // 数据路由的禁用守卫:enabled=false 时一律 403。/config 不走它——设置
1868
+ // 卡片靠它渲染,也是重新启用插件的唯一 UI 入口(不能一并关掉)。
1869
+ // never 参数做签名擦除:原样适配 ReqLike / IncomingMessage 等各种 handler。
1870
+ const guardDisabled = (handler) => (req, res) => {
1871
+ if (!stateRef.enabled) {
1872
+ writeJson(res, 403, { error: '插件已禁用(设置 → 插件 → 终端面板 → 启用插件)' });
1873
+ return;
1874
+ }
1875
+ return handler(req, res);
1876
+ };
1877
+ const registerGated = (route) => {
1878
+ disposers.push(webServer.register({ ...route, handler: guardDisabled(route.handler) }));
1879
+ };
1823
1880
  disposers.push(webServer.registerUpgrade({
1824
1881
  path: WS_PATH,
1825
1882
  handler: (req, socket, head) => server.handleUpgrade(req, socket, head),
@@ -1869,7 +1926,7 @@ const plugin = definePlugin({
1869
1926
  },
1870
1927
  }));
1871
1928
  // ~/.ssh/config 导入候选(连接簿):loopback 围栏,只回解析结果不落盘
1872
- disposers.push(webServer.register({
1929
+ registerGated({
1873
1930
  kind: 'exact',
1874
1931
  path: '/api/dsh-tty/ssh-config',
1875
1932
  handler: async (req, res) => {
@@ -1889,9 +1946,9 @@ const plugin = definePlugin({
1889
1946
  writeJson(res, 200, { ok: false, error: '无法读取 ~/.ssh/config: ' + (error instanceof Error ? error.message : String(error)) });
1890
1947
  }
1891
1948
  },
1892
- }));
1949
+ });
1893
1950
  // env:VAR 下拉数据源(SSH 对话框):只回 env 插件托管变量名,绝不含值
1894
- disposers.push(webServer.register({
1951
+ registerGated({
1895
1952
  kind: 'exact',
1896
1953
  path: '/api/dsh-tty/env-vars',
1897
1954
  handler: async (req, res) => {
@@ -1905,9 +1962,9 @@ const plugin = definePlugin({
1905
1962
  }
1906
1963
  writeJson(res, 200, { ok: true, names: readManagedEnvKeys() });
1907
1964
  },
1908
- }));
1965
+ });
1909
1966
  // known_hosts 指纹导入候选(TOFU 预填充):hashed 条目用连接簿主机名还原
1910
- disposers.push(webServer.register({
1967
+ registerGated({
1911
1968
  kind: 'exact',
1912
1969
  path: '/api/dsh-tty/known-hosts',
1913
1970
  handler: async (req, res) => {
@@ -1928,13 +1985,13 @@ const plugin = definePlugin({
1928
1985
  writeJson(res, 200, { ok: false, error: '无法读取 ~/.ssh/known_hosts: ' + (error instanceof Error ? error.message : String(error)) });
1929
1986
  }
1930
1987
  },
1931
- }));
1988
+ });
1932
1989
  // SSH 连接测试(0.11.0,src/probe.ts):连接簿行「测试」与 SSH 对话框
1933
1990
  // 「试连」共用。body 携带完整内联 SSH 规格(不引用连接簿——卡片测试
1934
1991
  // 由客户端先行展开条目),免去服务端按 name 解析;只诊断不建会话。
1935
1992
  // 连接簿条目测试传 store(新指纹当场 TOFU record);对话框试连不带
1936
1993
  // store(只比对不落盘,避免给未保存草稿建立钉扎)。
1937
- disposers.push(webServer.register({
1994
+ registerGated({
1938
1995
  kind: 'exact',
1939
1996
  path: '/api/dsh-tty/probe',
1940
1997
  handler: async (req, res) => {
@@ -1994,9 +2051,9 @@ const plugin = definePlugin({
1994
2051
  const result = await probeSsh(spec, body.bookRecord === true ? hostKeyStore : undefined);
1995
2052
  writeJson(res, 200, { ok: result.auth.ok, result });
1996
2053
  },
1997
- }));
2054
+ });
1998
2055
  // 已安装 shell 候选(设置卡片「Shell 路径」可选可输入):loopback 围栏,只回路径不执行
1999
- disposers.push(webServer.register({
2056
+ registerGated({
2000
2057
  kind: 'exact',
2001
2058
  path: '/api/dsh-tty/shells',
2002
2059
  handler: async (req, res) => {
@@ -2010,9 +2067,9 @@ const plugin = definePlugin({
2010
2067
  }
2011
2068
  writeJson(res, 200, { ok: true, shells: listCandidateShells(), current: process.env.SHELL ?? '' });
2012
2069
  },
2013
- }));
2070
+ });
2014
2071
  // 端口转发隧道实时状态(设置卡片轮询徽标 + tunnel_list 工具数据源)
2015
- disposers.push(webServer.register({
2072
+ registerGated({
2016
2073
  kind: 'exact',
2017
2074
  path: '/api/dsh-tty/tunnels',
2018
2075
  handler: async (req, res) => {
@@ -2026,14 +2083,14 @@ const plugin = definePlugin({
2026
2083
  }
2027
2084
  writeJson(res, 200, { ok: true, tunnels: tunnelManager.list() });
2028
2085
  },
2029
- }));
2086
+ });
2030
2087
  // SFTP 文件传输(0.7.0,src/sftp.ts):loopback 围栏;spec 解析与 WS ssh
2031
2088
  // 帧同款(mergeSshSpec:连接簿条目作基底 + 内联字段覆盖)。list/mkdir/
2032
2089
  // rename/remove/download 走 JSON 体(凭证不进 URL/查询串);download 响应
2033
2090
  // 为文件字节流(stat 成功时带 content-length);upload 以 x-dsh-sftp-meta
2034
2091
  // 头携带 base64url(JSON)(spec + path + append),请求体即原始文件字节,
2035
2092
  // pipeline 直灌 SFTP 写流——上传下载都不整文件进内存。
2036
- disposers.push(webServer.register({
2093
+ registerGated({
2037
2094
  kind: 'prefix',
2038
2095
  path: '/api/dsh-tty/sftp',
2039
2096
  handler: async (req, res) => {
@@ -2179,12 +2236,12 @@ const plugin = definePlugin({
2179
2236
  }
2180
2237
  writeJson(res, 404, { error: 'not found: ' + sub });
2181
2238
  },
2182
- }));
2239
+ });
2183
2240
  // 本机文件浏览(0.9.0,双栏 SFTP 的本机一侧):loopback 围栏。信任模型
2184
2241
  // 与终端/SFTP 一致——浏览器仅同源可访问,且本机能做的 SSH 会话也能做;
2185
2242
  // list/mkdir/rename/remove 操作本机路径;transfer 在服务端把本机路径与
2186
2243
  // 远程路径流式对拷(凭证不落浏览器,字节不经过浏览器)。
2187
- disposers.push(webServer.register({
2244
+ registerGated({
2188
2245
  kind: 'prefix',
2189
2246
  path: '/api/dsh-tty/local-fs',
2190
2247
  handler: async (req, res) => {
@@ -2286,7 +2343,7 @@ const plugin = definePlugin({
2286
2343
  writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
2287
2344
  }
2288
2345
  },
2289
- }));
2346
+ });
2290
2347
  return () => {
2291
2348
  server.close();
2292
2349
  for (const dispose of disposers) {
@@ -2373,636 +2430,656 @@ const plugin = definePlugin({
2373
2430
  const parts = session.buffer.split('\n');
2374
2431
  return parts.slice(-(lines + 1)).join('\n').replace(/^\n+/, '');
2375
2432
  };
2376
- const disposers = [];
2377
- disposers.push(tools.register(defineTool({
2378
- name: 'tty_list',
2379
- description: '列出当前活跃的终端面板会话(sid / kind(local|ssh) / target / pid / cwd / 创建与最后活动时间)。用户开了终端面板后,用 tty_capture 读取某个 sid 的终端输出,用 tty_send 向该终端发送按键。',
2380
- parameters: {},
2381
- output: {
2382
- schema: {
2383
- type: 'object',
2384
- additionalProperties: false,
2385
- properties: {
2386
- sessions: {
2387
- type: 'array',
2388
- required: true,
2389
- items: {
2390
- type: 'object',
2391
- additionalProperties: false,
2392
- properties: {
2393
- sid: { type: 'string', required: true },
2394
- kind: { type: 'string', required: true },
2395
- target: { type: 'string', required: true },
2396
- pid: { type: 'number' },
2397
- cwd: { type: 'string', required: true },
2398
- startedAt: { type: 'number', required: true },
2399
- lastOutputAt: { type: 'number', required: true },
2400
- persist: { type: 'boolean' },
2433
+ let activeDisposers = [];
2434
+ /** 幂等重注册:撤下现有工具后按 enabled 决定是否重挂(禁用热生效的入口;由 applyPatch 经 refreshToolsHook 触发)。 */
2435
+ const registerAll = () => {
2436
+ for (const dispose of activeDisposers) {
2437
+ try {
2438
+ dispose();
2439
+ }
2440
+ catch {
2441
+ /* 工具已注销 */
2442
+ }
2443
+ }
2444
+ activeDisposers = [];
2445
+ if (!stateRef.enabled) {
2446
+ stateRef.toolsRegistered = false;
2447
+ console.log('[dsh-tty] agent tools skipped (disabled)');
2448
+ return;
2449
+ }
2450
+ activeDisposers.push(tools.register(defineTool({
2451
+ name: 'tty_list',
2452
+ description: '列出当前活跃的终端面板会话(sid / kind(local|ssh) / target / pid / cwd / 创建与最后活动时间)。用户开了终端面板后,用 tty_capture 读取某个 sid 的终端输出,用 tty_send 向该终端发送按键。',
2453
+ parameters: {},
2454
+ output: {
2455
+ schema: {
2456
+ type: 'object',
2457
+ additionalProperties: false,
2458
+ properties: {
2459
+ sessions: {
2460
+ type: 'array',
2461
+ required: true,
2462
+ items: {
2463
+ type: 'object',
2464
+ additionalProperties: false,
2465
+ properties: {
2466
+ sid: { type: 'string', required: true },
2467
+ kind: { type: 'string', required: true },
2468
+ target: { type: 'string', required: true },
2469
+ pid: { type: 'number' },
2470
+ cwd: { type: 'string', required: true },
2471
+ startedAt: { type: 'number', required: true },
2472
+ lastOutputAt: { type: 'number', required: true },
2473
+ persist: { type: 'boolean' },
2474
+ },
2401
2475
  },
2402
2476
  },
2403
2477
  },
2404
2478
  },
2479
+ render: (_args, value) => {
2480
+ const sessions = value?.sessions ?? [];
2481
+ const text = sessions.length === 0
2482
+ ? '当前没有活跃的终端面板会话(请引导用户先打开终端面板,或用户尚未打开)'
2483
+ : '终端面板会话:' + sessions.map((s) => {
2484
+ const where = s.kind === 'ssh' ? `ssh ${s.target}` : `pid=${String(s.pid ?? '?')} cwd=${s.cwd}`;
2485
+ const persist = s.persist === true ? ' [tmux 持久]' : '';
2486
+ return `\n- sid=${s.sid} [${s.kind}]${persist} ${where} (启动于 ${new Date(s.startedAt).toLocaleString()})`;
2487
+ }).join('');
2488
+ return [{ type: 'text', text }];
2489
+ },
2405
2490
  },
2406
- render: (_args, value) => {
2407
- const sessions = value?.sessions ?? [];
2408
- const text = sessions.length === 0
2409
- ? '当前没有活跃的终端面板会话(请引导用户先打开终端面板,或用户尚未打开)'
2410
- : '终端面板会话:' + sessions.map((s) => {
2411
- const where = s.kind === 'ssh' ? `ssh ${s.target}` : `pid=${String(s.pid ?? '?')} cwd=${s.cwd}`;
2412
- const persist = s.persist === true ? ' [tmux 持久]' : '';
2413
- return `\n- sid=${s.sid} [${s.kind}]${persist} ${where} (启动于 ${new Date(s.startedAt).toLocaleString()})`;
2414
- }).join('');
2415
- return [{ type: 'text', text }];
2491
+ async execute() {
2492
+ return { sessions: sessions.list() };
2416
2493
  },
2417
- },
2418
- async execute() {
2419
- return { sessions: sessions.list() };
2420
- },
2421
- })));
2422
- disposers.push(tools.register(defineTool({
2423
- name: 'tty_capture',
2424
- description: '读取某个终端面板会话(tty_list 提供 sid)的近期输出。默认读取尾部 N 行(60,最多 500,已剥离 ANSI 转义序列并收敛同行覆盖);last:true 时只返回「上一条已完成命令」的输出与退出码(依赖 shell 集成标记,更适合拿单条命令的结果)。',
2425
- parameters: {
2426
- sid: { type: 'string', required: true, description: '会话 id(来自 tty_list)' },
2427
- lines: { type: 'number', description: '读取尾部行数(1~500,默认 60);last:true 时忽略' },
2428
- last: { type: 'boolean', description: 'true 只返回上一条命令的输出+退出码(默认 false 读尾部)' },
2429
- raw: { type: 'boolean', description: 'true 返回含 ANSI 转义序列的原始输出(默认 false 清洗为纯文本)' },
2430
- },
2431
- output: {
2432
- schema: {
2433
- type: 'object',
2434
- additionalProperties: false,
2435
- properties: {
2436
- sid: { type: 'string', required: true },
2437
- tail: { type: 'string', required: true },
2438
- source: { type: 'string' },
2439
- exitCode: { type: 'number' },
2440
- },
2494
+ })));
2495
+ activeDisposers.push(tools.register(defineTool({
2496
+ name: 'tty_capture',
2497
+ description: '读取某个终端面板会话(tty_list 提供 sid)的近期输出。默认读取尾部 N 行(60,最多 500,已剥离 ANSI 转义序列并收敛同行覆盖);last:true 时只返回「上一条已完成命令」的输出与退出码(依赖 shell 集成标记,更适合拿单条命令的结果)。',
2498
+ parameters: {
2499
+ sid: { type: 'string', required: true, description: '会话 id(来自 tty_list)' },
2500
+ lines: { type: 'number', description: '读取尾部行数(1~500,默认 60);last:true 时忽略' },
2501
+ last: { type: 'boolean', description: 'true 只返回上一条命令的输出+退出码(默认 false 读尾部)' },
2502
+ raw: { type: 'boolean', description: 'true 返回含 ANSI 转义序列的原始输出(默认 false 清洗为纯文本)' },
2441
2503
  },
2442
- render: (_args, value) => {
2443
- const v = value;
2444
- const head = v.source === 'last'
2445
- ? `终端会话 ${v.sid ?? '?'} 上一条命令的输出(exitCode=${String(v.exitCode ?? '?')}):\n\n`
2446
- : `终端会话 ${v.sid ?? '?'} 尾部输出:\n\n`;
2447
- return [{ type: 'text', text: head + (v.tail ?? '') }];
2504
+ output: {
2505
+ schema: {
2506
+ type: 'object',
2507
+ additionalProperties: false,
2508
+ properties: {
2509
+ sid: { type: 'string', required: true },
2510
+ tail: { type: 'string', required: true },
2511
+ source: { type: 'string' },
2512
+ exitCode: { type: 'number' },
2513
+ },
2514
+ },
2515
+ render: (_args, value) => {
2516
+ const v = value;
2517
+ const head = v.source === 'last'
2518
+ ? `终端会话 ${v.sid ?? '?'} 上一条命令的输出(exitCode=${String(v.exitCode ?? '?')}):\n\n`
2519
+ : `终端会话 ${v.sid ?? '?'} 尾部输出:\n\n`;
2520
+ return [{ type: 'text', text: head + (v.tail ?? '') }];
2521
+ },
2448
2522
  },
2449
- },
2450
- async execute(args) {
2451
- const input = args;
2452
- if (typeof input.sid !== 'string' || input.sid === '')
2453
- throw new Error('sid 必须是非空字符串');
2454
- const session = sessions.get(input.sid);
2455
- if (session === undefined || session.closed)
2456
- throw new Error(`会话不存在或已退出: ${input.sid}`);
2457
- const useRaw = input.raw === true;
2458
- if (input.last === true) {
2459
- const last = session.shellState.lastCommand;
2460
- if (last === null) {
2461
- throw new Error('暂无「上一条命令」记录(shell 集成未生效——shell 不受支持或被配置关闭——或尚未执行过命令);可改用 lines 读尾部');
2523
+ async execute(args) {
2524
+ const input = args;
2525
+ if (typeof input.sid !== 'string' || input.sid === '')
2526
+ throw new Error('sid 必须是非空字符串');
2527
+ const session = sessions.get(input.sid);
2528
+ if (session === undefined || session.closed)
2529
+ throw new Error(`会话不存在或已退出: ${input.sid}`);
2530
+ const useRaw = input.raw === true;
2531
+ if (input.last === true) {
2532
+ const last = session.shellState.lastCommand;
2533
+ if (last === null) {
2534
+ throw new Error('暂无「上一条命令」记录(shell 集成未生效——shell 不受支持或被配置关闭——或尚未执行过命令);可改用 lines 读尾部');
2535
+ }
2536
+ return { sid: input.sid, source: 'last', exitCode: last.exitCode ?? undefined, tail: (useRaw ? last.output : cleanAnsiTail(last.output)).slice(0, 128 * 1024) };
2462
2537
  }
2463
- return { sid: input.sid, source: 'last', exitCode: last.exitCode ?? undefined, tail: (useRaw ? last.output : cleanAnsiTail(last.output)).slice(0, 128 * 1024) };
2464
- }
2465
- const lines = Math.max(1, Math.min(500, typeof input.lines === 'number' && Number.isInteger(input.lines) && input.lines >= 1 ? input.lines : 60));
2466
- const rawTail = tailLines(session, lines);
2467
- return { sid: input.sid, source: 'tail', tail: useRaw ? rawTail : cleanAnsiTail(rawTail) };
2468
- },
2469
- })));
2470
- disposers.push(tools.register(defineTool({
2471
- name: 'tty_screen',
2472
- description: '读取某个终端面板会话(tty_list 提供 sid)当前可见屏幕的渲染结果(纯文本,等价于用户此刻看到的画面)。适合查看全屏交互程序(vim / htop / 菜单选择)的当前界面状态;要历史滚动输出用 tty_capture。',
2473
- parameters: {
2474
- sid: { type: 'string', required: true, description: '会话 id(来自 tty_list)' },
2475
- },
2476
- output: {
2477
- schema: {
2478
- type: 'object',
2479
- additionalProperties: false,
2480
- properties: {
2481
- sid: { type: 'string', required: true },
2482
- cols: { type: 'number', required: true },
2483
- rows: { type: 'number', required: true },
2484
- text: { type: 'string', required: true },
2485
- },
2538
+ const lines = Math.max(1, Math.min(500, typeof input.lines === 'number' && Number.isInteger(input.lines) && input.lines >= 1 ? input.lines : 60));
2539
+ const rawTail = tailLines(session, lines);
2540
+ return { sid: input.sid, source: 'tail', tail: useRaw ? rawTail : cleanAnsiTail(rawTail) };
2486
2541
  },
2487
- render: (_args, value) => {
2488
- const v = value;
2489
- return [{ type: 'text', text: `终端会话 ${v.sid ?? '?'} 当前屏幕(${String(v.cols ?? '?')}×${String(v.rows ?? '?')}):\n\n${v.text ?? ''}` }];
2542
+ })));
2543
+ activeDisposers.push(tools.register(defineTool({
2544
+ name: 'tty_screen',
2545
+ description: '读取某个终端面板会话(tty_list 提供 sid)当前可见屏幕的渲染结果(纯文本,等价于用户此刻看到的画面)。适合查看全屏交互程序(vim / htop / 菜单选择)的当前界面状态;要历史滚动输出用 tty_capture。',
2546
+ parameters: {
2547
+ sid: { type: 'string', required: true, description: '会话 id(来自 tty_list)' },
2490
2548
  },
2491
- },
2492
- async execute(args) {
2493
- const input = args;
2494
- if (typeof input.sid !== 'string' || input.sid === '')
2495
- throw new Error('sid 必须是非空字符串');
2496
- const session = sessions.get(input.sid);
2497
- if (session === undefined || session.closed)
2498
- throw new Error(`会话不存在或已退出: ${input.sid}`);
2499
- const screen = session.screen;
2500
- if (screen === null)
2501
- throw new Error(`虚拟屏不可用: ${input.sid}`);
2502
- const buffer = screen.buffer.active;
2503
- const lines = [];
2504
- for (let row = 0; row < screen.rows; row++) {
2505
- lines.push(buffer.getLine(row)?.translateToString(true) ?? '');
2506
- }
2507
- while (lines.length > 0 && lines[lines.length - 1].trim() === '')
2508
- lines.pop();
2509
- return { sid: input.sid, cols: screen.cols, rows: screen.rows, text: lines.join('\n').slice(0, 32 * 1024) };
2510
- },
2511
- })));
2512
- disposers.push(tools.register(defineTool({
2513
- name: 'tty_expect',
2514
- description: '在某个终端面板会话(tty_list 提供 sid)的后续输出中等待一个正则出现(如 dev server 的 ready/URL、构建完成标记、交互提示)。匹配到立即返回 matched:true 与周边输出;超时不抛错,返回 matched:false + 尾部输出供判断重试或放弃;期间该命令若已结束(shell 集成标记)也会提前返回并带退出码。适合先 tty_send 启动长任务、再 tty_expect 等就绪信号的流程。',
2515
- parameters: {
2516
- sid: { type: 'string', required: true, description: '会话 id(来自 tty_list)' },
2517
- pattern: { type: 'string', required: true, description: '等待匹配的正则表达式(JavaScript RegExp 语法)' },
2518
- timeoutSec: { type: 'number', description: '等待秒数(1~600,默认 30)' },
2519
- },
2520
- output: {
2521
- schema: {
2522
- type: 'object',
2523
- additionalProperties: false,
2524
- properties: {
2525
- matched: { type: 'boolean', required: true },
2526
- timedOut: { type: 'boolean', required: true },
2527
- text: { type: 'string', required: true },
2528
- exitCode: { type: 'number' },
2549
+ output: {
2550
+ schema: {
2551
+ type: 'object',
2552
+ additionalProperties: false,
2553
+ properties: {
2554
+ sid: { type: 'string', required: true },
2555
+ cols: { type: 'number', required: true },
2556
+ rows: { type: 'number', required: true },
2557
+ text: { type: 'string', required: true },
2558
+ },
2559
+ },
2560
+ render: (_args, value) => {
2561
+ const v = value;
2562
+ return [{ type: 'text', text: `终端会话 ${v.sid ?? '?'} 当前屏幕(${String(v.cols ?? '?')}×${String(v.rows ?? '?')}):\n\n${v.text ?? ''}` }];
2529
2563
  },
2530
2564
  },
2531
- render: (_args, value) => {
2532
- const v = value;
2533
- if (v.matched === true)
2534
- return [{ type: 'text', text: `已匹配到等待的模式:\n\n${v.text ?? ''}` }];
2535
- const why = v.timedOut === true ? '等待超时' : `命令已结束(exitCode=${String(v.exitCode ?? '?')})但未出现匹配`;
2536
- return [{ type: 'text', text: `${why}。尾部输出:\n\n${v.text ?? ''}` }];
2565
+ async execute(args) {
2566
+ const input = args;
2567
+ if (typeof input.sid !== 'string' || input.sid === '')
2568
+ throw new Error('sid 必须是非空字符串');
2569
+ const session = sessions.get(input.sid);
2570
+ if (session === undefined || session.closed)
2571
+ throw new Error(`会话不存在或已退出: ${input.sid}`);
2572
+ const screen = session.screen;
2573
+ if (screen === null)
2574
+ throw new Error(`虚拟屏不可用: ${input.sid}`);
2575
+ const buffer = screen.buffer.active;
2576
+ const lines = [];
2577
+ for (let row = 0; row < screen.rows; row++) {
2578
+ lines.push(buffer.getLine(row)?.translateToString(true) ?? '');
2579
+ }
2580
+ while (lines.length > 0 && lines[lines.length - 1].trim() === '')
2581
+ lines.pop();
2582
+ return { sid: input.sid, cols: screen.cols, rows: screen.rows, text: lines.join('\n').slice(0, 32 * 1024) };
2537
2583
  },
2538
- },
2539
- async execute(args) {
2540
- const input = args;
2541
- if (typeof input.sid !== 'string' || input.sid === '')
2542
- throw new Error('sid 必须是非空字符串');
2543
- if (typeof input.pattern !== 'string' || input.pattern === '')
2544
- throw new Error('pattern 必须是非空字符串');
2545
- const session = sessions.get(input.sid);
2546
- if (session === undefined || session.closed)
2547
- throw new Error(`会话不存在或已退出: ${input.sid}`);
2548
- let re;
2549
- try {
2550
- re = new RegExp(input.pattern);
2551
- }
2552
- catch (error) {
2553
- throw new Error('pattern 不是合法的正则表达式: ' + (error instanceof Error ? error.message : String(error)));
2554
- }
2555
- const timeoutSec = Math.max(1, Math.min(600, typeof input.timeoutSec === 'number' && Number.isInteger(input.timeoutSec) && input.timeoutSec >= 1 ? input.timeoutSec : 30));
2556
- const timeoutMs = timeoutSec * 1000;
2557
- return await new Promise((resolve) => {
2558
- const startedAt = Date.now();
2559
- const startedInCommand = session.shellState.inCommand;
2560
- let acc = '';
2561
- let settled = false;
2562
- const decoder = new StringDecoder('utf8');
2563
- const output = session.handle.output;
2564
- const finish = (result) => {
2565
- if (settled)
2566
- return;
2567
- settled = true;
2568
- clearTimeout(timer);
2569
- output.off('data', onData);
2570
- resolve(result);
2571
- };
2572
- const onData = (chunk) => {
2573
- acc += decoder.write(chunk);
2574
- const hay = acc.length > 16 * 1024 ? acc.slice(-16 * 1024) : acc;
2575
- if (re.test(hay)) {
2576
- finish({ matched: true, timedOut: false, text: cleanAnsiTail(hay.slice(-6 * 1024)) });
2577
- return;
2578
- }
2579
- // 命令早停:注册时命令在飞(B..D 之间),如今 D 已到仍未匹配
2580
- const state = session.shellState;
2581
- if (startedInCommand && !state.inCommand && state.lastCommand !== null && state.lastCommand.endedAt >= startedAt) {
2582
- finish({ matched: false, timedOut: false, exitCode: state.lastCommand.exitCode ?? undefined, text: cleanAnsiTail(acc.slice(-6 * 1024)) });
2583
- }
2584
- };
2585
- const timer = setTimeout(() => {
2586
- finish({ matched: false, timedOut: true, text: cleanAnsiTail(acc.slice(-6 * 1024)) });
2587
- }, timeoutMs);
2588
- timer.unref?.();
2589
- output.on('data', onData);
2590
- void session.handle.done.then(() => {
2591
- finish({ matched: false, timedOut: false, text: cleanAnsiTail(acc.slice(-6 * 1024)) });
2584
+ })));
2585
+ activeDisposers.push(tools.register(defineTool({
2586
+ name: 'tty_expect',
2587
+ description: '在某个终端面板会话(tty_list 提供 sid)的后续输出中等待一个正则出现(如 dev server 的 ready/URL、构建完成标记、交互提示)。匹配到立即返回 matched:true 与周边输出;超时不抛错,返回 matched:false + 尾部输出供判断重试或放弃;期间该命令若已结束(shell 集成标记)也会提前返回并带退出码。适合先 tty_send 启动长任务、再 tty_expect 等就绪信号的流程。',
2588
+ parameters: {
2589
+ sid: { type: 'string', required: true, description: '会话 id(来自 tty_list)' },
2590
+ pattern: { type: 'string', required: true, description: '等待匹配的正则表达式(JavaScript RegExp 语法)' },
2591
+ timeoutSec: { type: 'number', description: '等待秒数(1~600,默认 30)' },
2592
+ },
2593
+ output: {
2594
+ schema: {
2595
+ type: 'object',
2596
+ additionalProperties: false,
2597
+ properties: {
2598
+ matched: { type: 'boolean', required: true },
2599
+ timedOut: { type: 'boolean', required: true },
2600
+ text: { type: 'string', required: true },
2601
+ exitCode: { type: 'number' },
2602
+ },
2603
+ },
2604
+ render: (_args, value) => {
2605
+ const v = value;
2606
+ if (v.matched === true)
2607
+ return [{ type: 'text', text: `已匹配到等待的模式:\n\n${v.text ?? ''}` }];
2608
+ const why = v.timedOut === true ? '等待超时' : `命令已结束(exitCode=${String(v.exitCode ?? '?')})但未出现匹配`;
2609
+ return [{ type: 'text', text: `${why}。尾部输出:\n\n${v.text ?? ''}` }];
2610
+ },
2611
+ },
2612
+ async execute(args) {
2613
+ const input = args;
2614
+ if (typeof input.sid !== 'string' || input.sid === '')
2615
+ throw new Error('sid 必须是非空字符串');
2616
+ if (typeof input.pattern !== 'string' || input.pattern === '')
2617
+ throw new Error('pattern 必须是非空字符串');
2618
+ const session = sessions.get(input.sid);
2619
+ if (session === undefined || session.closed)
2620
+ throw new Error(`会话不存在或已退出: ${input.sid}`);
2621
+ let re;
2622
+ try {
2623
+ re = new RegExp(input.pattern);
2624
+ }
2625
+ catch (error) {
2626
+ throw new Error('pattern 不是合法的正则表达式: ' + (error instanceof Error ? error.message : String(error)));
2627
+ }
2628
+ const timeoutSec = Math.max(1, Math.min(600, typeof input.timeoutSec === 'number' && Number.isInteger(input.timeoutSec) && input.timeoutSec >= 1 ? input.timeoutSec : 30));
2629
+ const timeoutMs = timeoutSec * 1000;
2630
+ return await new Promise((resolve) => {
2631
+ const startedAt = Date.now();
2632
+ const startedInCommand = session.shellState.inCommand;
2633
+ let acc = '';
2634
+ let settled = false;
2635
+ const decoder = new StringDecoder('utf8');
2636
+ const output = session.handle.output;
2637
+ const finish = (result) => {
2638
+ if (settled)
2639
+ return;
2640
+ settled = true;
2641
+ clearTimeout(timer);
2642
+ output.off('data', onData);
2643
+ resolve(result);
2644
+ };
2645
+ const onData = (chunk) => {
2646
+ acc += decoder.write(chunk);
2647
+ const hay = acc.length > 16 * 1024 ? acc.slice(-16 * 1024) : acc;
2648
+ if (re.test(hay)) {
2649
+ finish({ matched: true, timedOut: false, text: cleanAnsiTail(hay.slice(-6 * 1024)) });
2650
+ return;
2651
+ }
2652
+ // 命令早停:注册时命令在飞(B..D 之间),如今 D 已到仍未匹配
2653
+ const state = session.shellState;
2654
+ if (startedInCommand && !state.inCommand && state.lastCommand !== null && state.lastCommand.endedAt >= startedAt) {
2655
+ finish({ matched: false, timedOut: false, exitCode: state.lastCommand.exitCode ?? undefined, text: cleanAnsiTail(acc.slice(-6 * 1024)) });
2656
+ }
2657
+ };
2658
+ const timer = setTimeout(() => {
2659
+ finish({ matched: false, timedOut: true, text: cleanAnsiTail(acc.slice(-6 * 1024)) });
2660
+ }, timeoutMs);
2661
+ timer.unref?.();
2662
+ output.on('data', onData);
2663
+ void session.handle.done.then(() => {
2664
+ finish({ matched: false, timedOut: false, text: cleanAnsiTail(acc.slice(-6 * 1024)) });
2665
+ });
2592
2666
  });
2593
- });
2594
- },
2595
- })));
2596
- disposers.push(tools.register(defineTool({
2597
- name: 'tty_send',
2598
- description: '向某个终端面板会话(tty_list 提供 sid)的 PTY 发送按键/文本(命令以 \\n 结尾)。适合给用户终端里运行的程序发交互输入(如 dev server 的 q 键、menu 选择、回答提示)。操作会实时显示在用户的终端面板里。',
2599
- parameters: {
2600
- sid: { type: 'string', required: true, description: '会话 id(来自 tty_list)' },
2601
- data: { type: 'string', required: true, description: '要发送的文本(含换行则直接发送命令)' },
2602
- },
2603
- output: {
2604
- schema: {
2605
- type: 'object',
2606
- additionalProperties: false,
2607
- properties: {
2608
- ok: { type: 'boolean', required: true },
2609
- sent: { type: 'number', required: true },
2667
+ },
2668
+ })));
2669
+ activeDisposers.push(tools.register(defineTool({
2670
+ name: 'tty_send',
2671
+ description: '向某个终端面板会话(tty_list 提供 sid)的 PTY 发送按键/文本(命令以 \\n 结尾)。适合给用户终端里运行的程序发交互输入(如 dev server 的 q 键、menu 选择、回答提示)。操作会实时显示在用户的终端面板里。',
2672
+ parameters: {
2673
+ sid: { type: 'string', required: true, description: '会话 id(来自 tty_list)' },
2674
+ data: { type: 'string', required: true, description: '要发送的文本(含换行则直接发送命令)' },
2675
+ },
2676
+ output: {
2677
+ schema: {
2678
+ type: 'object',
2679
+ additionalProperties: false,
2680
+ properties: {
2681
+ ok: { type: 'boolean', required: true },
2682
+ sent: { type: 'number', required: true },
2683
+ },
2684
+ },
2685
+ render: (_args, value) => {
2686
+ const v = value;
2687
+ return [{ type: 'text', text: `已向终端会话发送 ${v.sent ?? 0} 个字符` }];
2610
2688
  },
2611
2689
  },
2612
- render: (_args, value) => {
2613
- const v = value;
2614
- return [{ type: 'text', text: `已向终端会话发送 ${v.sent ?? 0} 个字符` }];
2690
+ async execute(args) {
2691
+ const input = args;
2692
+ if (typeof input.sid !== 'string' || input.sid === '')
2693
+ throw new Error('sid 必须是非空字符串');
2694
+ if (typeof input.data !== 'string' || input.data === '')
2695
+ throw new Error('data 必须是非空字符串');
2696
+ const session = sessions.get(input.sid);
2697
+ if (session === undefined || session.closed)
2698
+ throw new Error(`会话不存在或已退出: ${input.sid}`);
2699
+ await session.handle.write(input.data);
2700
+ return { ok: true, sent: input.data.length };
2615
2701
  },
2616
- },
2617
- async execute(args) {
2618
- const input = args;
2619
- if (typeof input.sid !== 'string' || input.sid === '')
2620
- throw new Error('sid 必须是非空字符串');
2621
- if (typeof input.data !== 'string' || input.data === '')
2622
- throw new Error('data 必须是非空字符串');
2623
- const session = sessions.get(input.sid);
2624
- if (session === undefined || session.closed)
2625
- throw new Error(`会话不存在或已退出: ${input.sid}`);
2626
- await session.handle.write(input.data);
2627
- return { ok: true, sent: input.data.length };
2628
- },
2629
- })));
2630
- disposers.push(tools.register(defineTool({
2631
- name: 'tunnel_list',
2632
- description: '列出端口转发隧道及其实时状态(活跃/连接中/错误/停止、规则、当前与累计连接数、最近错误)。用户说「隧道连不上 / 转发挂了 / 端口转发不通」时先用它诊断;隧道在 设置 → 插件 → 终端面板 卡片维护。',
2633
- parameters: {},
2634
- output: {
2635
- schema: {
2636
- type: 'object',
2637
- additionalProperties: false,
2638
- properties: {
2639
- tunnels: {
2640
- type: 'array',
2641
- required: true,
2642
- items: {
2643
- type: 'object',
2644
- additionalProperties: false,
2645
- properties: {
2646
- name: { type: 'string', required: true },
2647
- direction: { type: 'string', required: true },
2648
- rule: { type: 'string', required: true },
2649
- bookName: { type: 'string', required: true },
2650
- state: { type: 'string', required: true },
2651
- error: { type: 'string' },
2652
- connections: { type: 'number', required: true },
2653
- totalConnections: { type: 'number', required: true },
2702
+ })));
2703
+ activeDisposers.push(tools.register(defineTool({
2704
+ name: 'tunnel_list',
2705
+ description: '列出端口转发隧道及其实时状态(活跃/连接中/错误/停止、规则、当前与累计连接数、最近错误)。用户说「隧道连不上 / 转发挂了 / 端口转发不通」时先用它诊断;隧道在 设置 → 插件 → 终端面板 卡片维护。',
2706
+ parameters: {},
2707
+ output: {
2708
+ schema: {
2709
+ type: 'object',
2710
+ additionalProperties: false,
2711
+ properties: {
2712
+ tunnels: {
2713
+ type: 'array',
2714
+ required: true,
2715
+ items: {
2716
+ type: 'object',
2717
+ additionalProperties: false,
2718
+ properties: {
2719
+ name: { type: 'string', required: true },
2720
+ direction: { type: 'string', required: true },
2721
+ rule: { type: 'string', required: true },
2722
+ bookName: { type: 'string', required: true },
2723
+ state: { type: 'string', required: true },
2724
+ error: { type: 'string' },
2725
+ connections: { type: 'number', required: true },
2726
+ totalConnections: { type: 'number', required: true },
2727
+ },
2654
2728
  },
2655
2729
  },
2656
2730
  },
2657
2731
  },
2732
+ render: (_args, value) => {
2733
+ const v = value;
2734
+ const tunnels = v.tunnels ?? [];
2735
+ if (tunnels.length === 0)
2736
+ return [{ type: 'text', text: '当前没有配置端口转发隧道(设置 → 插件 → 终端面板 卡片可添加)' }];
2737
+ const text = '端口转发隧道:' + tunnels.map((t) => {
2738
+ const tail = t.error !== null && t.error !== undefined ? `(错误: ${t.error})` : t.lastForwardError !== null && t.lastForwardError !== undefined ? `(最近转发失败: ${t.lastForwardError})` : `(连接 ${String(t.connections)})`;
2739
+ return `\n- ${t.name} [${t.direction}] ${t.rule} — ${t.state}${tail}`;
2740
+ }).join('');
2741
+ return [{ type: 'text', text }];
2742
+ },
2658
2743
  },
2659
- render: (_args, value) => {
2660
- const v = value;
2661
- const tunnels = v.tunnels ?? [];
2662
- if (tunnels.length === 0)
2663
- return [{ type: 'text', text: '当前没有配置端口转发隧道(设置 → 插件 → 终端面板 卡片可添加)' }];
2664
- const text = '端口转发隧道:' + tunnels.map((t) => {
2665
- const tail = t.error !== null && t.error !== undefined ? `(错误: ${t.error})` : t.lastForwardError !== null && t.lastForwardError !== undefined ? `(最近转发失败: ${t.lastForwardError})` : `(连接 ${String(t.connections)})`;
2666
- return `\n- ${t.name} [${t.direction}] ${t.rule} — ${t.state}${tail}`;
2667
- }).join('');
2668
- return [{ type: 'text', text }];
2744
+ async execute() {
2745
+ return { tunnels: tunnelManager.list().map((t) => ({ ...t, error: t.error ?? undefined })) };
2669
2746
  },
2670
- },
2671
- async execute() {
2672
- return { tunnels: tunnelManager.list().map((t) => ({ ...t, error: t.error ?? undefined })) };
2673
- },
2674
- })));
2675
- // —— SFTP 文件传输工具(0.7.0)——
2676
- // 只收连接簿条目名(book),不接受内联凭证:agent 上下文不进明文密钥;
2677
- // 连接与终端/隧道共用同一 HostKeyStore(TOFU 同源)。
2678
- const sftpBookSpec = (book) => {
2679
- if (typeof book !== 'string' || book.trim() === '')
2680
- throw new Error('book 必须是 SSH 连接簿条目名');
2681
- const entry = live.findSshHost(book.trim());
2682
- if (entry === undefined)
2683
- throw new Error(`连接簿中不存在: ${book.trim()}`);
2684
- return entry;
2685
- };
2686
- disposers.push(tools.register(defineTool({
2687
- name: 'sftp_list',
2688
- description: '列出 SSH 远程目录内容(名称/类型/大小/修改时间,目录在前)。book 为 SSH 连接簿条目名;path 缺省为远程登录 home。用于查找远程文件、确认上传下载结果。',
2689
- parameters: {
2690
- book: { type: 'string', required: true, description: 'SSH 连接簿条目名(设置 → 插件 → 终端面板 维护)' },
2691
- path: { type: 'string', description: '远程目录路径(缺省 = 登录 home)' },
2692
- },
2693
- output: {
2694
- schema: {
2695
- type: 'object',
2696
- additionalProperties: false,
2697
- properties: {
2698
- path: { type: 'string', required: true },
2699
- entries: {
2700
- type: 'array',
2701
- required: true,
2702
- items: {
2703
- type: 'object',
2704
- additionalProperties: false,
2705
- properties: {
2706
- name: { type: 'string', required: true },
2707
- isDir: { type: 'boolean', required: true },
2708
- size: { type: 'number', required: true },
2709
- mtime: { type: 'number', required: true },
2747
+ })));
2748
+ // —— SFTP 文件传输工具(0.7.0)——
2749
+ // 只收连接簿条目名(book),不接受内联凭证:agent 上下文不进明文密钥;
2750
+ // 连接与终端/隧道共用同一 HostKeyStore(TOFU 同源)。
2751
+ const sftpBookSpec = (book) => {
2752
+ if (typeof book !== 'string' || book.trim() === '')
2753
+ throw new Error('book 必须是 SSH 连接簿条目名');
2754
+ const entry = live.findSshHost(book.trim());
2755
+ if (entry === undefined)
2756
+ throw new Error(`连接簿中不存在: ${book.trim()}`);
2757
+ return entry;
2758
+ };
2759
+ activeDisposers.push(tools.register(defineTool({
2760
+ name: 'sftp_list',
2761
+ description: '列出 SSH 远程目录内容(名称/类型/大小/修改时间,目录在前)。book 为 SSH 连接簿条目名;path 缺省为远程登录 home。用于查找远程文件、确认上传下载结果。',
2762
+ parameters: {
2763
+ book: { type: 'string', required: true, description: 'SSH 连接簿条目名(设置 → 插件 → 终端面板 维护)' },
2764
+ path: { type: 'string', description: '远程目录路径(缺省 = 登录 home)' },
2765
+ },
2766
+ output: {
2767
+ schema: {
2768
+ type: 'object',
2769
+ additionalProperties: false,
2770
+ properties: {
2771
+ path: { type: 'string', required: true },
2772
+ entries: {
2773
+ type: 'array',
2774
+ required: true,
2775
+ items: {
2776
+ type: 'object',
2777
+ additionalProperties: false,
2778
+ properties: {
2779
+ name: { type: 'string', required: true },
2780
+ isDir: { type: 'boolean', required: true },
2781
+ size: { type: 'number', required: true },
2782
+ mtime: { type: 'number', required: true },
2783
+ },
2710
2784
  },
2711
2785
  },
2712
2786
  },
2713
2787
  },
2788
+ render: (_args, value) => {
2789
+ const v = value;
2790
+ const entries = v.entries ?? [];
2791
+ if (entries.length === 0)
2792
+ return [{ type: 'text', text: `远程目录 ${v.path ?? '?'} 为空` }];
2793
+ const text = `远程目录 ${v.path ?? '?'}(${String(entries.length)} 项):` + entries.map((e) => `\n- ${e.name}${e.isDir ? '/' : ''} — ${e.isDir ? '目录' : humanFileSize(e.size)}`).join('');
2794
+ return [{ type: 'text', text }];
2795
+ },
2714
2796
  },
2715
- render: (_args, value) => {
2716
- const v = value;
2717
- const entries = v.entries ?? [];
2718
- if (entries.length === 0)
2719
- return [{ type: 'text', text: `远程目录 ${v.path ?? '?'} 为空` }];
2720
- const text = `远程目录 ${v.path ?? '?'}(${String(entries.length)} 项):` + entries.map((e) => `\n- ${e.name}${e.isDir ? '/' : ''} — ${e.isDir ? '目录' : humanFileSize(e.size)}`).join('');
2721
- return [{ type: 'text', text }];
2797
+ async execute(args) {
2798
+ const input = args;
2799
+ const spec = sftpBookSpec(input.book);
2800
+ const result = await sftpManager.list(spec, typeof input.path === 'string' ? input.path : '');
2801
+ return { path: result.path, entries: result.entries.map((e) => ({ name: e.name, isDir: e.isDir, size: e.size, mtime: e.mtime })) };
2722
2802
  },
2723
- },
2724
- async execute(args) {
2725
- const input = args;
2726
- const spec = sftpBookSpec(input.book);
2727
- const result = await sftpManager.list(spec, typeof input.path === 'string' ? input.path : '');
2728
- return { path: result.path, entries: result.entries.map((e) => ({ name: e.name, isDir: e.isDir, size: e.size, mtime: e.mtime })) };
2729
- },
2730
- })));
2731
- disposers.push(tools.register(defineTool({
2732
- name: 'sftp_read',
2733
- description: '读取 SSH 远程文本文件(book 连接簿条目 + path)。默认最多 256KB(可调至 1MB),超出截断;检测到 NUL 字节按二进制文件拒绝。适合查看远程配置、小日志。',
2734
- parameters: {
2735
- book: { type: 'string', required: true, description: 'SSH 连接簿条目名' },
2736
- path: { type: 'string', required: true, description: '远程文件路径' },
2737
- maxBytes: { type: 'number', description: '最大读取字节数(1~1048576,默认 262144)' },
2738
- },
2739
- output: {
2740
- schema: {
2741
- type: 'object',
2742
- additionalProperties: false,
2743
- properties: {
2744
- path: { type: 'string', required: true },
2745
- content: { type: 'string', required: true },
2746
- truncated: { type: 'boolean', required: true },
2747
- },
2803
+ })));
2804
+ activeDisposers.push(tools.register(defineTool({
2805
+ name: 'sftp_read',
2806
+ description: '读取 SSH 远程文本文件(book 连接簿条目 + path)。默认最多 256KB(可调至 1MB),超出截断;检测到 NUL 字节按二进制文件拒绝。适合查看远程配置、小日志。',
2807
+ parameters: {
2808
+ book: { type: 'string', required: true, description: 'SSH 连接簿条目名' },
2809
+ path: { type: 'string', required: true, description: '远程文件路径' },
2810
+ maxBytes: { type: 'number', description: '最大读取字节数(1~1048576,默认 262144)' },
2748
2811
  },
2749
- render: (_args, value) => {
2750
- const v = value;
2751
- const head = `远程文件 ${v.path ?? '?'}${v.truncated === true ? '(已截断)' : ''}:`;
2752
- return [{ type: 'text', text: head + '\n' + String(v.content ?? '') }];
2812
+ output: {
2813
+ schema: {
2814
+ type: 'object',
2815
+ additionalProperties: false,
2816
+ properties: {
2817
+ path: { type: 'string', required: true },
2818
+ content: { type: 'string', required: true },
2819
+ truncated: { type: 'boolean', required: true },
2820
+ },
2821
+ },
2822
+ render: (_args, value) => {
2823
+ const v = value;
2824
+ const head = `远程文件 ${v.path ?? '?'}${v.truncated === true ? '(已截断)' : ''}:`;
2825
+ return [{ type: 'text', text: head + '\n' + String(v.content ?? '') }];
2826
+ },
2753
2827
  },
2754
- },
2755
- async execute(args) {
2756
- const input = args;
2757
- const spec = sftpBookSpec(input.book);
2758
- if (typeof input.path !== 'string' || input.path.trim() === '')
2759
- throw new Error('path 必须是非空字符串');
2760
- const maxBytes = typeof input.maxBytes === 'number' && Number.isInteger(input.maxBytes) && input.maxBytes >= 1 && input.maxBytes <= 1024 * 1024 ? input.maxBytes : 256 * 1024;
2761
- const { stream } = await sftpManager.openDownload(spec, input.path);
2762
- const chunks = [];
2763
- let total = 0;
2764
- try {
2765
- for await (const chunk of stream) {
2766
- const piece = chunk;
2767
- chunks.push(piece);
2768
- total += piece.length;
2769
- if (total > maxBytes)
2770
- break; // 只多读一段用于判定截断,其余丢弃
2828
+ async execute(args) {
2829
+ const input = args;
2830
+ const spec = sftpBookSpec(input.book);
2831
+ if (typeof input.path !== 'string' || input.path.trim() === '')
2832
+ throw new Error('path 必须是非空字符串');
2833
+ const maxBytes = typeof input.maxBytes === 'number' && Number.isInteger(input.maxBytes) && input.maxBytes >= 1 && input.maxBytes <= 1024 * 1024 ? input.maxBytes : 256 * 1024;
2834
+ const { stream } = await sftpManager.openDownload(spec, input.path);
2835
+ const chunks = [];
2836
+ let total = 0;
2837
+ try {
2838
+ for await (const chunk of stream) {
2839
+ const piece = chunk;
2840
+ chunks.push(piece);
2841
+ total += piece.length;
2842
+ if (total > maxBytes)
2843
+ break; // 只多读一段用于判定截断,其余丢弃
2844
+ }
2771
2845
  }
2772
- }
2773
- finally {
2774
- stream.destroy();
2775
- }
2776
- const buf = Buffer.concat(chunks);
2777
- const truncated = buf.length > maxBytes;
2778
- const sliced = truncated ? buf.subarray(0, maxBytes) : buf;
2779
- if (sliced.includes(0))
2780
- throw new Error('疑似二进制文件(含 NUL 字节),sftp_read 只支持文本内容');
2781
- return { path: input.path.trim(), content: sliced.toString('utf8'), truncated };
2782
- },
2783
- })));
2784
- disposers.push(tools.register(defineTool({
2785
- name: 'sftp_write',
2786
- description: '写 SSH 远程文本文件(book 连接簿条目 + path + content)。默认覆盖写入,append:true 追加到文件尾;单次最多 1MB。适合远程写配置、落结果文件。',
2787
- parameters: {
2788
- book: { type: 'string', required: true, description: 'SSH 连接簿条目名' },
2789
- path: { type: 'string', required: true, description: '远程文件路径' },
2790
- content: { type: 'string', required: true, description: '要写入的文本内容(≤1MB)' },
2791
- append: { type: 'boolean', description: 'true 追加到文件尾(默认覆盖)' },
2792
- },
2793
- output: {
2794
- schema: {
2795
- type: 'object',
2796
- additionalProperties: false,
2797
- properties: {
2798
- ok: { type: 'boolean', required: true },
2799
- path: { type: 'string', required: true },
2800
- bytes: { type: 'number', required: true },
2801
- append: { type: 'boolean', required: true },
2846
+ finally {
2847
+ stream.destroy();
2848
+ }
2849
+ const buf = Buffer.concat(chunks);
2850
+ const truncated = buf.length > maxBytes;
2851
+ const sliced = truncated ? buf.subarray(0, maxBytes) : buf;
2852
+ if (sliced.includes(0))
2853
+ throw new Error('疑似二进制文件(含 NUL 字节),sftp_read 只支持文本内容');
2854
+ return { path: input.path.trim(), content: sliced.toString('utf8'), truncated };
2855
+ },
2856
+ })));
2857
+ activeDisposers.push(tools.register(defineTool({
2858
+ name: 'sftp_write',
2859
+ description: '写 SSH 远程文本文件(book 连接簿条目 + path + content)。默认覆盖写入,append:true 追加到文件尾;单次最多 1MB。适合远程写配置、落结果文件。',
2860
+ parameters: {
2861
+ book: { type: 'string', required: true, description: 'SSH 连接簿条目名' },
2862
+ path: { type: 'string', required: true, description: '远程文件路径' },
2863
+ content: { type: 'string', required: true, description: '要写入的文本内容(≤1MB)' },
2864
+ append: { type: 'boolean', description: 'true 追加到文件尾(默认覆盖)' },
2865
+ },
2866
+ output: {
2867
+ schema: {
2868
+ type: 'object',
2869
+ additionalProperties: false,
2870
+ properties: {
2871
+ ok: { type: 'boolean', required: true },
2872
+ path: { type: 'string', required: true },
2873
+ bytes: { type: 'number', required: true },
2874
+ append: { type: 'boolean', required: true },
2875
+ },
2876
+ },
2877
+ render: (_args, value) => {
2878
+ const v = value;
2879
+ return [{ type: 'text', text: `已${v.append === true ? '追加' : '写入'}远程文件 ${v.path ?? '?'}(${String(v.bytes ?? 0)} 字节)` }];
2802
2880
  },
2803
2881
  },
2804
- render: (_args, value) => {
2805
- const v = value;
2806
- return [{ type: 'text', text: `已${v.append === true ? '追加' : '写入'}远程文件 ${v.path ?? '?'}(${String(v.bytes ?? 0)} 字节)` }];
2882
+ async execute(args) {
2883
+ const input = args;
2884
+ const spec = sftpBookSpec(input.book);
2885
+ if (typeof input.path !== 'string' || input.path.trim() === '')
2886
+ throw new Error('path 必须是非空字符串');
2887
+ if (typeof input.content !== 'string')
2888
+ throw new Error('content 必须是字符串');
2889
+ const bytes = Buffer.byteLength(input.content, 'utf8');
2890
+ if (bytes > 1024 * 1024)
2891
+ throw new Error(`content 超过上限:${String(bytes)} 字节 > 1MB(大文件请用终端 scp 或面板上传)`);
2892
+ const append = input.append === true;
2893
+ const { stream, done } = await sftpManager.openUpload(spec, input.path, append);
2894
+ stream.write(input.content, 'utf8');
2895
+ stream.end();
2896
+ await done;
2897
+ return { ok: true, path: input.path.trim(), bytes, append };
2807
2898
  },
2808
- },
2809
- async execute(args) {
2810
- const input = args;
2811
- const spec = sftpBookSpec(input.book);
2812
- if (typeof input.path !== 'string' || input.path.trim() === '')
2813
- throw new Error('path 必须是非空字符串');
2814
- if (typeof input.content !== 'string')
2815
- throw new Error('content 必须是字符串');
2816
- const bytes = Buffer.byteLength(input.content, 'utf8');
2817
- if (bytes > 1024 * 1024)
2818
- throw new Error(`content 超过上限:${String(bytes)} 字节 > 1MB(大文件请用终端 scp 或面板上传)`);
2819
- const append = input.append === true;
2820
- const { stream, done } = await sftpManager.openUpload(spec, input.path, append);
2821
- stream.write(input.content, 'utf8');
2822
- stream.end();
2823
- await done;
2824
- return { ok: true, path: input.path.trim(), bytes, append };
2825
- },
2826
- })));
2827
- // —— SFTP 管理闭环(0.8.0)——
2828
- // mkdir(可逐级补齐)/ rename(可跨目录,等效移动)/ remove(目录
2829
- // 递归)/ tree(限深限数的递归列举),与 sftp_list/read/write 一起
2830
- // 让 agent 不开面板也能完整管理远程文件;同样只收连接簿条目名。
2831
- disposers.push(tools.register(defineTool({
2832
- name: 'sftp_mkdir',
2833
- description: '在 SSH 远程创建目录(book 连接簿条目 + path)。parents:true 时逐级补齐缺失的父目录(等效 mkdir -p,默认 false,父目录缺失直接报错)。',
2834
- parameters: {
2835
- book: { type: 'string', required: true, description: 'SSH 连接簿条目名(设置 → 插件 → 终端面板 维护)' },
2836
- path: { type: 'string', required: true, description: '要创建的远程目录路径' },
2837
- parents: { type: 'boolean', description: 'true 逐级补齐缺失父目录(默认 false)' },
2838
- },
2839
- output: {
2840
- schema: {
2841
- type: 'object',
2842
- additionalProperties: false,
2843
- properties: {
2844
- ok: { type: 'boolean', required: true },
2845
- path: { type: 'string', required: true },
2899
+ })));
2900
+ // —— SFTP 管理闭环(0.8.0)——
2901
+ // mkdir(可逐级补齐)/ rename(可跨目录,等效移动)/ remove(目录
2902
+ // 递归)/ tree(限深限数的递归列举),与 sftp_list/read/write 一起
2903
+ // 让 agent 不开面板也能完整管理远程文件;同样只收连接簿条目名。
2904
+ activeDisposers.push(tools.register(defineTool({
2905
+ name: 'sftp_mkdir',
2906
+ description: '在 SSH 远程创建目录(book 连接簿条目 + path)。parents:true 时逐级补齐缺失的父目录(等效 mkdir -p,默认 false,父目录缺失直接报错)。',
2907
+ parameters: {
2908
+ book: { type: 'string', required: true, description: 'SSH 连接簿条目名(设置 → 插件 → 终端面板 维护)' },
2909
+ path: { type: 'string', required: true, description: '要创建的远程目录路径' },
2910
+ parents: { type: 'boolean', description: 'true 逐级补齐缺失父目录(默认 false)' },
2911
+ },
2912
+ output: {
2913
+ schema: {
2914
+ type: 'object',
2915
+ additionalProperties: false,
2916
+ properties: {
2917
+ ok: { type: 'boolean', required: true },
2918
+ path: { type: 'string', required: true },
2919
+ },
2920
+ },
2921
+ render: (_args, value) => {
2922
+ const v = value;
2923
+ return [{ type: 'text', text: `已创建远程目录 ${v.path ?? '?'}` }];
2846
2924
  },
2847
2925
  },
2848
- render: (_args, value) => {
2849
- const v = value;
2850
- return [{ type: 'text', text: `已创建远程目录 ${v.path ?? '?'}` }];
2926
+ async execute(args) {
2927
+ const input = args;
2928
+ const spec = sftpBookSpec(input.book);
2929
+ if (typeof input.path !== 'string' || input.path.trim() === '')
2930
+ throw new Error('path 必须是非空字符串');
2931
+ await sftpManager.mkdir(spec, input.path, input.parents === true);
2932
+ return { ok: true, path: input.path.trim() };
2851
2933
  },
2852
- },
2853
- async execute(args) {
2854
- const input = args;
2855
- const spec = sftpBookSpec(input.book);
2856
- if (typeof input.path !== 'string' || input.path.trim() === '')
2857
- throw new Error('path 必须是非空字符串');
2858
- await sftpManager.mkdir(spec, input.path, input.parents === true);
2859
- return { ok: true, path: input.path.trim() };
2860
- },
2861
- })));
2862
- disposers.push(tools.register(defineTool({
2863
- name: 'sftp_rename',
2864
- description: '在 SSH 远程重命名 / 移动文件或目录(book 连接簿条目 + from + to)。to 与 from 不同目录即为移动(目标目录需已存在);不会覆盖已存在的目标(服务端 rename 语义)。',
2865
- parameters: {
2866
- book: { type: 'string', required: true, description: 'SSH 连接簿条目名' },
2867
- from: { type: 'string', required: true, description: '原远程路径' },
2868
- to: { type: 'string', required: true, description: '新远程路径(跨目录即移动)' },
2869
- },
2870
- output: {
2871
- schema: {
2872
- type: 'object',
2873
- additionalProperties: false,
2874
- properties: {
2875
- ok: { type: 'boolean', required: true },
2876
- from: { type: 'string', required: true },
2877
- to: { type: 'string', required: true },
2934
+ })));
2935
+ activeDisposers.push(tools.register(defineTool({
2936
+ name: 'sftp_rename',
2937
+ description: '在 SSH 远程重命名 / 移动文件或目录(book 连接簿条目 + from + to)。to 与 from 不同目录即为移动(目标目录需已存在);不会覆盖已存在的目标(服务端 rename 语义)。',
2938
+ parameters: {
2939
+ book: { type: 'string', required: true, description: 'SSH 连接簿条目名' },
2940
+ from: { type: 'string', required: true, description: '原远程路径' },
2941
+ to: { type: 'string', required: true, description: '新远程路径(跨目录即移动)' },
2942
+ },
2943
+ output: {
2944
+ schema: {
2945
+ type: 'object',
2946
+ additionalProperties: false,
2947
+ properties: {
2948
+ ok: { type: 'boolean', required: true },
2949
+ from: { type: 'string', required: true },
2950
+ to: { type: 'string', required: true },
2951
+ },
2952
+ },
2953
+ render: (_args, value) => {
2954
+ const v = value;
2955
+ return [{ type: 'text', text: `已将远程 ${v.from ?? '?'} 重命名/移动为 ${v.to ?? '?'}` }];
2878
2956
  },
2879
2957
  },
2880
- render: (_args, value) => {
2881
- const v = value;
2882
- return [{ type: 'text', text: `已将远程 ${v.from ?? '?'} 重命名/移动为 ${v.to ?? '?'}` }];
2958
+ async execute(args) {
2959
+ const input = args;
2960
+ const spec = sftpBookSpec(input.book);
2961
+ if (typeof input.from !== 'string' || input.from.trim() === '')
2962
+ throw new Error('from 必须是非空字符串');
2963
+ if (typeof input.to !== 'string' || input.to.trim() === '')
2964
+ throw new Error('to 必须是非空字符串');
2965
+ await sftpManager.rename(spec, input.from, input.to);
2966
+ return { ok: true, from: input.from.trim(), to: input.to.trim() };
2883
2967
  },
2884
- },
2885
- async execute(args) {
2886
- const input = args;
2887
- const spec = sftpBookSpec(input.book);
2888
- if (typeof input.from !== 'string' || input.from.trim() === '')
2889
- throw new Error('from 必须是非空字符串');
2890
- if (typeof input.to !== 'string' || input.to.trim() === '')
2891
- throw new Error('to 必须是非空字符串');
2892
- await sftpManager.rename(spec, input.from, input.to);
2893
- return { ok: true, from: input.from.trim(), to: input.to.trim() };
2894
- },
2895
- })));
2896
- disposers.push(tools.register(defineTool({
2897
- name: 'sftp_remove',
2898
- description: '删除 SSH 远程文件或目录(book 连接簿条目 + path)。文件直接删除;目录默认走 rmdir(非空明确报错),recursive:true 整目录递归删除(不可恢复,谨慎使用)。',
2899
- parameters: {
2900
- book: { type: 'string', required: true, description: 'SSH 连接簿条目名' },
2901
- path: { type: 'string', required: true, description: '要删除的远程路径' },
2902
- recursive: { type: 'boolean', description: '目录 true 时递归删除全部内容(默认 false)' },
2903
- },
2904
- output: {
2905
- schema: {
2906
- type: 'object',
2907
- additionalProperties: false,
2908
- properties: {
2909
- ok: { type: 'boolean', required: true },
2910
- path: { type: 'string', required: true },
2911
- recursive: { type: 'boolean', required: true },
2968
+ })));
2969
+ activeDisposers.push(tools.register(defineTool({
2970
+ name: 'sftp_remove',
2971
+ description: '删除 SSH 远程文件或目录(book 连接簿条目 + path)。文件直接删除;目录默认走 rmdir(非空明确报错),recursive:true 整目录递归删除(不可恢复,谨慎使用)。',
2972
+ parameters: {
2973
+ book: { type: 'string', required: true, description: 'SSH 连接簿条目名' },
2974
+ path: { type: 'string', required: true, description: '要删除的远程路径' },
2975
+ recursive: { type: 'boolean', description: '目录 true 时递归删除全部内容(默认 false)' },
2976
+ },
2977
+ output: {
2978
+ schema: {
2979
+ type: 'object',
2980
+ additionalProperties: false,
2981
+ properties: {
2982
+ ok: { type: 'boolean', required: true },
2983
+ path: { type: 'string', required: true },
2984
+ recursive: { type: 'boolean', required: true },
2985
+ },
2986
+ },
2987
+ render: (_args, value) => {
2988
+ const v = value;
2989
+ return [{ type: 'text', text: `已删除远程 ${v.path ?? '?'}${v.recursive === true ? '(含全部内容)' : ''}` }];
2912
2990
  },
2913
2991
  },
2914
- render: (_args, value) => {
2915
- const v = value;
2916
- return [{ type: 'text', text: `已删除远程 ${v.path ?? '?'}${v.recursive === true ? '(含全部内容)' : ''}` }];
2992
+ async execute(args) {
2993
+ const input = args;
2994
+ const spec = sftpBookSpec(input.book);
2995
+ if (typeof input.path !== 'string' || input.path.trim() === '')
2996
+ throw new Error('path 必须是非空字符串');
2997
+ const recursive = input.recursive === true;
2998
+ await sftpManager.remove(spec, input.path, recursive);
2999
+ return { ok: true, path: input.path.trim(), recursive };
2917
3000
  },
2918
- },
2919
- async execute(args) {
2920
- const input = args;
2921
- const spec = sftpBookSpec(input.book);
2922
- if (typeof input.path !== 'string' || input.path.trim() === '')
2923
- throw new Error('path 必须是非空字符串');
2924
- const recursive = input.recursive === true;
2925
- await sftpManager.remove(spec, input.path, recursive);
2926
- return { ok: true, path: input.path.trim(), recursive };
2927
- },
2928
- })));
2929
- disposers.push(tools.register(defineTool({
2930
- name: 'sftp_tree',
2931
- description: '递归列举 SSH 远程目录结构(book 连接簿条目 + path):深度优先、目录优先,maxDepth(1~8,默认 3)限层、maxEntries(1~2000,默认 500)限条数,超限 truncated:true;符号链接不跟随;读取失败的子目录列入 errors。适合先看远程项目结构再定位文件。',
2932
- parameters: {
2933
- book: { type: 'string', required: true, description: 'SSH 连接簿条目名' },
2934
- path: { type: 'string', description: '远程目录路径(缺省 = 登录 home)' },
2935
- maxDepth: { type: 'number', description: '最大下钻层数(1~8,默认 3)' },
2936
- maxEntries: { type: 'number', description: '最大条目数(1~2000,默认 500)' },
2937
- },
2938
- output: {
2939
- schema: {
2940
- type: 'object',
2941
- additionalProperties: false,
2942
- properties: {
2943
- path: { type: 'string', required: true },
2944
- entries: {
2945
- type: 'array',
2946
- required: true,
2947
- items: {
2948
- type: 'object',
2949
- additionalProperties: false,
2950
- properties: {
2951
- path: { type: 'string', required: true },
2952
- name: { type: 'string', required: true },
2953
- depth: { type: 'number', required: true },
2954
- isDir: { type: 'boolean', required: true },
2955
- size: { type: 'number', required: true },
2956
- mtime: { type: 'number', required: true },
3001
+ })));
3002
+ activeDisposers.push(tools.register(defineTool({
3003
+ name: 'sftp_tree',
3004
+ description: '递归列举 SSH 远程目录结构(book 连接簿条目 + path):深度优先、目录优先,maxDepth(1~8,默认 3)限层、maxEntries(1~2000,默认 500)限条数,超限 truncated:true;符号链接不跟随;读取失败的子目录列入 errors。适合先看远程项目结构再定位文件。',
3005
+ parameters: {
3006
+ book: { type: 'string', required: true, description: 'SSH 连接簿条目名' },
3007
+ path: { type: 'string', description: '远程目录路径(缺省 = 登录 home)' },
3008
+ maxDepth: { type: 'number', description: '最大下钻层数(1~8,默认 3)' },
3009
+ maxEntries: { type: 'number', description: '最大条目数(1~2000,默认 500)' },
3010
+ },
3011
+ output: {
3012
+ schema: {
3013
+ type: 'object',
3014
+ additionalProperties: false,
3015
+ properties: {
3016
+ path: { type: 'string', required: true },
3017
+ entries: {
3018
+ type: 'array',
3019
+ required: true,
3020
+ items: {
3021
+ type: 'object',
3022
+ additionalProperties: false,
3023
+ properties: {
3024
+ path: { type: 'string', required: true },
3025
+ name: { type: 'string', required: true },
3026
+ depth: { type: 'number', required: true },
3027
+ isDir: { type: 'boolean', required: true },
3028
+ size: { type: 'number', required: true },
3029
+ mtime: { type: 'number', required: true },
3030
+ },
2957
3031
  },
2958
3032
  },
2959
- },
2960
- truncated: { type: 'boolean', required: true },
2961
- errors: {
2962
- type: 'array',
2963
- required: true,
2964
- items: {
2965
- type: 'object',
2966
- additionalProperties: false,
2967
- properties: {
2968
- path: { type: 'string', required: true },
2969
- message: { type: 'string', required: true },
3033
+ truncated: { type: 'boolean', required: true },
3034
+ errors: {
3035
+ type: 'array',
3036
+ required: true,
3037
+ items: {
3038
+ type: 'object',
3039
+ additionalProperties: false,
3040
+ properties: {
3041
+ path: { type: 'string', required: true },
3042
+ message: { type: 'string', required: true },
3043
+ },
2970
3044
  },
2971
3045
  },
2972
3046
  },
2973
3047
  },
3048
+ render: (_args, value) => {
3049
+ const v = value;
3050
+ const entries = v.entries ?? [];
3051
+ if (entries.length === 0)
3052
+ return [{ type: 'text', text: `远程目录 ${v.path ?? '?'} 为空` }];
3053
+ const head = `远程目录 ${v.path ?? '?'} 结构(${String(entries.length)} 项${v.truncated === true ? ',已截断' : ''}):`;
3054
+ const lines = entries.map((e) => {
3055
+ const indent = ' '.repeat(Math.max(0, e.depth - 1));
3056
+ const tail = e.isDir ? '/' : ' — ' + humanFileSize(e.size);
3057
+ return `${indent}- ${e.name}${tail}`;
3058
+ });
3059
+ for (const item of v.errors ?? [])
3060
+ lines.push(`! ${item.path}(${item.message})`);
3061
+ return [{ type: 'text', text: head + '\n' + lines.join('\n') }];
3062
+ },
2974
3063
  },
2975
- render: (_args, value) => {
2976
- const v = value;
2977
- const entries = v.entries ?? [];
2978
- if (entries.length === 0)
2979
- return [{ type: 'text', text: `远程目录 ${v.path ?? '?'} 为空` }];
2980
- const head = `远程目录 ${v.path ?? '?'} 结构(${String(entries.length)} 项${v.truncated === true ? ',已截断' : ''}):`;
2981
- const lines = entries.map((e) => {
2982
- const indent = ' '.repeat(Math.max(0, e.depth - 1));
2983
- const tail = e.isDir ? '/' : ' — ' + humanFileSize(e.size);
2984
- return `${indent}- ${e.name}${tail}`;
3064
+ async execute(args) {
3065
+ const input = args;
3066
+ const spec = sftpBookSpec(input.book);
3067
+ const result = await sftpManager.tree(spec, typeof input.path === 'string' ? input.path : '', {
3068
+ maxDepth: typeof input.maxDepth === 'number' && Number.isInteger(input.maxDepth) ? input.maxDepth : undefined,
3069
+ maxEntries: typeof input.maxEntries === 'number' && Number.isInteger(input.maxEntries) ? input.maxEntries : undefined,
2985
3070
  });
2986
- for (const item of v.errors ?? [])
2987
- lines.push(`! ${item.path}(${item.message})`);
2988
- return [{ type: 'text', text: head + '\n' + lines.join('\n') }];
3071
+ return result;
2989
3072
  },
2990
- },
2991
- async execute(args) {
2992
- const input = args;
2993
- const spec = sftpBookSpec(input.book);
2994
- const result = await sftpManager.tree(spec, typeof input.path === 'string' ? input.path : '', {
2995
- maxDepth: typeof input.maxDepth === 'number' && Number.isInteger(input.maxDepth) ? input.maxDepth : undefined,
2996
- maxEntries: typeof input.maxEntries === 'number' && Number.isInteger(input.maxEntries) ? input.maxEntries : undefined,
2997
- });
2998
- return result;
2999
- },
3000
- })));
3001
- stateRef.toolsRegistered = true;
3002
- console.log('[dsh-tty] agent tools registered (tty_list, tty_capture, tty_screen, tty_expect, tty_send, tunnel_list, sftp_list, sftp_read, sftp_write, sftp_mkdir, sftp_rename, sftp_remove, sftp_tree)');
3073
+ })));
3074
+ stateRef.toolsRegistered = true;
3075
+ console.log('[dsh-tty] agent tools registered (tty_list, tty_capture, tty_screen, tty_expect, tty_send, tunnel_list, sftp_list, sftp_read, sftp_write, sftp_mkdir, sftp_rename, sftp_remove, sftp_tree)');
3076
+ };
3077
+ refreshToolsHook = registerAll;
3078
+ registerAll();
3003
3079
  return () => {
3004
3080
  stateRef.toolsRegistered = false;
3005
- for (const dispose of disposers) {
3081
+ refreshToolsHook = () => { };
3082
+ for (const dispose of activeDisposers) {
3006
3083
  try {
3007
3084
  dispose();
3008
3085
  }
@@ -3017,11 +3094,34 @@ const plugin = definePlugin({
3017
3094
  console.log('[dsh-tty] tools service unavailable; agent tools skipped');
3018
3095
  }
3019
3096
  // 向 agent 公告终端面板能力(静态 section)+ 每轮注入活跃会话快照(动态 context)
3020
- if (config?.announceToAgent !== false) {
3021
- ctx.inject(['systemPrompt'], (promptCtx) => {
3022
- promptCtx.effect(() => {
3023
- const systemPrompt = promptCtx.systemPrompt;
3024
- const contextDisposable = systemPrompt.context({
3097
+ ctx.inject(['systemPrompt'], (promptCtx) => {
3098
+ promptCtx.effect(() => {
3099
+ const systemPrompt = promptCtx.systemPrompt;
3100
+ let contextDisposable;
3101
+ let sectionDisposable;
3102
+ /** 幂等重建:按 enabled && announceToAgent 撤下/恢复公告与动态快照(禁用热生效入口)。 */
3103
+ const rebuild = () => {
3104
+ if (sectionDisposable !== undefined) {
3105
+ try {
3106
+ sectionDisposable();
3107
+ }
3108
+ catch {
3109
+ /* 已注销 */
3110
+ }
3111
+ sectionDisposable = undefined;
3112
+ }
3113
+ if (contextDisposable !== undefined) {
3114
+ try {
3115
+ contextDisposable();
3116
+ }
3117
+ catch {
3118
+ /* 已注销 */
3119
+ }
3120
+ contextDisposable = undefined;
3121
+ }
3122
+ if (!stateRef.enabled || !stateRef.announceToAgent)
3123
+ return;
3124
+ contextDisposable = systemPrompt.context({
3025
3125
  name: 'plugin:dsh-tty:terminals',
3026
3126
  order: 150,
3027
3127
  text: () => {
@@ -3034,14 +3134,31 @@ const plugin = definePlugin({
3034
3134
  }).join('\n');
3035
3135
  },
3036
3136
  });
3037
- const sectionDisposable = systemPrompt.section({ name: 'plugin:dsh-tty', order: 150, text: TTY_GUIDANCE });
3038
- return () => {
3039
- sectionDisposable();
3040
- contextDisposable();
3041
- };
3042
- }, 'dsh-tty: announcement');
3043
- });
3044
- }
3137
+ sectionDisposable = systemPrompt.section({ name: 'plugin:dsh-tty', order: 150, text: TTY_GUIDANCE });
3138
+ };
3139
+ refreshAnnouncementHook = rebuild;
3140
+ rebuild();
3141
+ return () => {
3142
+ refreshAnnouncementHook = () => { };
3143
+ if (sectionDisposable !== undefined) {
3144
+ try {
3145
+ sectionDisposable();
3146
+ }
3147
+ catch {
3148
+ /* 已注销 */
3149
+ }
3150
+ }
3151
+ if (contextDisposable !== undefined) {
3152
+ try {
3153
+ contextDisposable();
3154
+ }
3155
+ catch {
3156
+ /* 已注销 */
3157
+ }
3158
+ }
3159
+ };
3160
+ }, 'dsh-tty: announcement');
3161
+ });
3045
3162
  // 孤儿会话回收器:超过保活期的异常断开会话定期清理(grace=0 时为 no-op,
3046
3163
  // 断开时立即结束);插件卸载时随 effect 一起停掉
3047
3164
  const reaperTimer = setInterval(() => {