@hyzyn/dsh-tty 0.32.3 → 0.33.0
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/README.en.md +66 -75
- package/README.md +57 -67
- package/lib/index.d.ts +11 -0
- package/lib/index.js +509 -449
- package/lib/index.js.map +1 -1
- package/lib/tier-restrict.d.ts +12 -0
- package/lib/tier-restrict.js +13 -0
- package/lib/tier-restrict.js.map +1 -0
- package/package.json +4 -4
- package/scripts/integration.mjs +15 -3
package/lib/index.js
CHANGED
|
@@ -12,10 +12,10 @@ import WebSocket, { WebSocketServer } from 'ws';
|
|
|
12
12
|
// found),必须默认导入后取 Terminal;类型用 InstanceType 别名保持同名可用
|
|
13
13
|
import xtermHeadless from '@xterm/headless';
|
|
14
14
|
const HeadlessTerminal = xtermHeadless.Terminal;
|
|
15
|
-
import { definePlugin, dshHome as resolveDshHome, getService, hasSameOriginProof, isLoopbackRequestStrict, plainConfig, settingsEntryScope, suppressAutoSettingsPage } from '@hyzyn/dsh-kit';
|
|
15
|
+
import { definePlugin, dshHome as resolveDshHome, getService, hasSameOriginProof, isLoopbackRequestStrict, plainConfig, registerLiteralSection, settingsEntryScope, suppressAutoSettingsPage } from '@hyzyn/dsh-kit';
|
|
16
16
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
17
17
|
import { sanitizeJumpSpec, sanitizeProxyCommand, spawnSsh, sshTarget, expandHome, setCredentialResolver, setProxyCommandPolicy, validateJumpSpec, validateProxyCommand } from './ssh.js';
|
|
18
|
-
import { sharedGrantStore, auditLoadedGrants, attachTierGate, bindCapabilitySources, capabilityDeniedMessage, capabilityGrantAt, capabilityGrantVia, capabilityGranted, capabilityPaths, createElevationManager, } from '@hyzyn/dsh-kit';
|
|
18
|
+
import { sharedGrantStore, auditLoadedGrants, attachTierGate, attachTierRestrict, bindCapabilitySources, capabilityDeniedMessage, capabilityGrantAt, capabilityGrantVia, capabilityGranted, capabilityPaths, createElevationManager, } from '@hyzyn/dsh-kit';
|
|
19
19
|
import { probeSsh } from './probe.js';
|
|
20
20
|
import { buildCommandSpawn, buildShellSpawn, commandShellHint, defaultShellPath, shellIntegrationSupported, tmuxSocketName } from './shell-integration.js';
|
|
21
21
|
import { KEY_VOCABULARY, resolveKeys } from './keys.js';
|
|
@@ -304,14 +304,16 @@ const STATS_ONESHOT_TIMEOUT_MS = 15_000;
|
|
|
304
304
|
* 各自的工具描述里**,这里不再复述一遍:同一件事写两处,既白占每轮上下文,又会随
|
|
305
305
|
* 改动漂成两份互相矛盾的真相(本文件历史上这两处就重复过)。
|
|
306
306
|
*
|
|
307
|
-
*
|
|
308
|
-
*
|
|
307
|
+
* 这里只留工具描述里**没有**、而模型**真的会据此做决策**的两句:什么时候别用终端,
|
|
308
|
+
* 以及用户提到终端相关需求时该把哪张卡片指给他。面板侧能力(SSH / SFTP / 隧道 /
|
|
309
|
+
* tmux 持久化)不进这段——模型动不了它们,写在这里只是每轮白占上下文,细节留在
|
|
310
|
+
* 设置卡片与包 README 里。
|
|
311
|
+
*
|
|
312
|
+
* 长度守卫见 `scripts/test/announcement-budget.test.ts`(预算 250):这段每一轮都在
|
|
313
|
+
* 上下文里,涨回去的代价是每一轮都付。
|
|
309
314
|
*/
|
|
310
|
-
const TTY_GUIDANCE = '
|
|
311
|
-
+ '
|
|
312
|
-
+ '标签栏「+」还能开 SSH 标签(ssh2 原生连接;连接簿、agent forwarding、主机指纹 TOFU、单跳跳板机都在设置卡片维护);面板内另有 SFTP 文件浏览(上传/下载/建目录/重命名/删除),以及由连接簿条目配置的端口转发隧道;设置卡片开「会话持久化(tmux)」后新标签由 tmux 托管,能跨宿主重启恢复,长任务建议开着它跑。'
|
|
313
|
-
+ '用户提到「开个终端 / 在终端里跑 / SSH 到某台机器 / 传文件 / 转发端口」时,引导他打开这个面板。'
|
|
314
|
-
+ '典型流程:tty_send 启动长任务 → tty_expect 等就绪标记 → tty_capture{last:true} 拿结果。';
|
|
315
|
+
const TTY_GUIDANCE = '**先判断要不要用终端**:只有需要跨调用保留状态(REPL / 调试器)、要跑长驻或交互式进程、或用户要看着这条命令跑,才用终端;一次跑完的有界命令直接用 bash 工具,别在 bash 里挂起等待。'
|
|
316
|
+
+ '本机装有 dsh-tty 插件(终端面板,tty_* 工具);用户提到「开终端 / SSH / 传文件 / 转发端口」时,引导他打开 设置 → 插件配置 → 终端面板。';
|
|
315
317
|
/** 本地 PTY 包装成 TermHandle(resize/kill 仍是透传 node-pty 的内部耦合;防御性降级)。
|
|
316
318
|
*
|
|
317
319
|
* `platform` 只为单测注入(D100 的闸门是 win32-only,macOS/Linux 上要能断言两条分支)。
|
|
@@ -1154,6 +1156,19 @@ function screenTextOf(screen) {
|
|
|
1154
1156
|
lines.pop();
|
|
1155
1157
|
return lines.join('\n');
|
|
1156
1158
|
}
|
|
1159
|
+
/** `tty_screen` 正文的保尾上限:屏幕末尾(提示符行)才是有效区,丢头部不丢尾部。 */
|
|
1160
|
+
const SCREEN_TEXT_CAP = 32 * 1024;
|
|
1161
|
+
/**
|
|
1162
|
+
* `tty_screen` 的正文裁剪:保尾截断到 32KB,并**自证**这次有没有截断。
|
|
1163
|
+
*
|
|
1164
|
+
* 大尺寸屏幕装满文本就可能越过 32KB(最多 200 行 × 500 列);此前截断是静默的,
|
|
1165
|
+
* agent 会把砍过的正文当成整屏。`truncated` 进返回体、也进 schema——让调用方
|
|
1166
|
+
* 从「有没有某句提示」反推是不可靠的。
|
|
1167
|
+
*/
|
|
1168
|
+
export function screenPayload(full) {
|
|
1169
|
+
const truncated = full.length > SCREEN_TEXT_CAP;
|
|
1170
|
+
return { text: truncated ? full.slice(-SCREEN_TEXT_CAP) : full, truncated };
|
|
1171
|
+
}
|
|
1157
1172
|
/**
|
|
1158
1173
|
* 唯一追加点(D72):环形缓冲与单调字符计数一起维护。水位线用绝对值定位
|
|
1159
1174
|
* 未读区(`buffer 起点 = outputSeq - buffer.length`,裁剪只会前移起点,
|
|
@@ -5139,6 +5154,8 @@ const plugin = definePlugin({
|
|
|
5139
5154
|
return parts.slice(-(lines + 1)).join('\n').replace(/^\n+/, '');
|
|
5140
5155
|
};
|
|
5141
5156
|
let activeDisposers = [];
|
|
5157
|
+
/** 本次真注册的工具名(启动日志的「实际注册集」;能力全集是文件末尾那行 registered)。 */
|
|
5158
|
+
let registeredNames = [];
|
|
5142
5159
|
/** 幂等重注册:撤下现有工具后按 enabled 决定是否重挂(禁用热生效的入口;由 applyPatch 经 refreshToolsHook 触发)。 */
|
|
5143
5160
|
const registerAll = () => {
|
|
5144
5161
|
for (const dispose of activeDisposers) {
|
|
@@ -5150,12 +5167,21 @@ const plugin = definePlugin({
|
|
|
5150
5167
|
}
|
|
5151
5168
|
}
|
|
5152
5169
|
activeDisposers = [];
|
|
5170
|
+
registeredNames = [];
|
|
5153
5171
|
if (!stateRef.enabled) {
|
|
5154
5172
|
stateRef.toolsRegistered = false;
|
|
5155
5173
|
console.log('[dsh-tty] agent tools skipped (disabled)');
|
|
5156
5174
|
return;
|
|
5157
5175
|
}
|
|
5158
|
-
|
|
5176
|
+
/**
|
|
5177
|
+
* 注册一个工具并**记下它的名字**(active 日志用;返回值照旧进 activeDisposers,push 顺序不变)。
|
|
5178
|
+
* 所有工具都必须经这里注册——绕过它 = 日志里的「实际注册集」少一个名字。
|
|
5179
|
+
*/
|
|
5180
|
+
const registerTool = (definition) => {
|
|
5181
|
+
registeredNames.push(definition.name);
|
|
5182
|
+
return tools.register(definition);
|
|
5183
|
+
};
|
|
5184
|
+
activeDisposers.push(registerTool(defineTool({
|
|
5159
5185
|
name: 'tty_list',
|
|
5160
5186
|
// 只读工具:与同轮其它工具并发执行(宿主默认把未声明的工具当独占,见项目级 ROADMAP 第 4 项)
|
|
5161
5187
|
isConcurrencySafe: () => true,
|
|
@@ -5241,7 +5267,7 @@ const plugin = definePlugin({
|
|
|
5241
5267
|
return { sessions: sessions.listForAgent() };
|
|
5242
5268
|
},
|
|
5243
5269
|
})));
|
|
5244
|
-
activeDisposers.push(
|
|
5270
|
+
activeDisposers.push(registerTool(defineTool({
|
|
5245
5271
|
name: 'tty_open',
|
|
5246
5272
|
description: '开一个新的终端会话(本地 shell,或 `command` 直接跑一条命令,如 dev server)。会话出现在用户的终端面板里、用户可见可接管,长驻进程与 watch 类任务应该用它(不要在 bash 工具里挂起等待)。开了之后用 tty_expect 等就绪信号、tty_capture{last:true} 拿结果;用完用 tty_close 关闭。`command` 按 **宿主 shell 的语法整段执行**(D78:旧版本里 `exec` 包装只跑第一条)——POSIX shell 上 `cd dir && cmd`、`a; b`、`for …; do …; done` 都可以,**Windows 的 cmd / PowerShell 则按它们自己的语法**(当前执行命令的 shell 与语法见 systemPrompt 里的终端一行);**它跑完退出后会话不会立刻消失**:会转成只读保留(留到显式关闭,最多留 16 条),退出前最后的输出与退出码都还能用 tty_capture / tty_screen 读——所以「跑一条会结束的命令、回头再取结果」不需要套一层 `sh`。cwd 缺省为插件配置的工作目录。',
|
|
5247
5273
|
parameters: {
|
|
@@ -5276,7 +5302,7 @@ const plugin = definePlugin({
|
|
|
5276
5302
|
});
|
|
5277
5303
|
},
|
|
5278
5304
|
})));
|
|
5279
|
-
activeDisposers.push(
|
|
5305
|
+
activeDisposers.push(registerTool(defineTool({
|
|
5280
5306
|
name: 'tty_close',
|
|
5281
5307
|
description: '关闭一个由 tty_open 开的终端会话(结束其中的进程)。**只能关 agent 自己开的会话**:用户在面板里开的标签会被拒绝,请让用户自己在面板里关,不要越权结束用户正在用的终端。对**进程已退出但仍只读保留着**的会话同样可用——那就是它的释放入口(把屏与缓冲还回去)。只读保留默认留到显式关闭(宿主重启也会清空),不按时间释放;同时最多留 16 条,超出按最旧淘汰。',
|
|
5282
5308
|
parameters: {
|
|
@@ -5300,7 +5326,7 @@ const plugin = definePlugin({
|
|
|
5300
5326
|
return await server.closeAgentSession(input.sid);
|
|
5301
5327
|
},
|
|
5302
5328
|
})));
|
|
5303
|
-
activeDisposers.push(
|
|
5329
|
+
activeDisposers.push(registerTool(defineTool({
|
|
5304
5330
|
name: 'tty_run',
|
|
5305
5331
|
description: '**一次性命令**:开一个终端会话跑 `command`,等它结束,直接返回尾部输出 + 退出码(会话出现在用户面板里、可见可接管)——把「tty_open → 等 → tty_capture{last} → tty_close」四步压成一次调用。命令**跑完就结束**:默认把这个会话关掉(结果已在本调用返回),`keep:true` 则留在「只读保留」态供回看。到 `timeoutSec` 还没结束会返回 `running:true`(会话照旧在跑,**杀不杀由你决定**),接着用 tty_expect / tty_capture 看,或 tty_close 关掉。何时用它而不是 bash 工具:需要用户**看得见**这条命令、或要跑在终端会话里(同一套 PTY / 会话名额 / 后续可接管)时用它;纯非交互、不需要用户看见的命令用 bash 工具更直接。`command` 按宿主 shell 的语法整段执行(POSIX 上 `cd x && cmd`、多行脚本都可以;Windows 的 cmd / PowerShell 按它们自己的语法)。',
|
|
5306
5332
|
parameters: {
|
|
@@ -5376,7 +5402,7 @@ const plugin = definePlugin({
|
|
|
5376
5402
|
return { ...result, closed: true };
|
|
5377
5403
|
},
|
|
5378
5404
|
})));
|
|
5379
|
-
activeDisposers.push(
|
|
5405
|
+
activeDisposers.push(registerTool(defineTool({
|
|
5380
5406
|
name: 'tty_stats',
|
|
5381
5407
|
// 只读工具:与同轮其它工具并发执行(宿主默认把未声明的工具当独占,见项目级 ROADMAP 第 4 项)
|
|
5382
5408
|
isConcurrencySafe: () => true,
|
|
@@ -5444,7 +5470,7 @@ const plugin = definePlugin({
|
|
|
5444
5470
|
return { sid: input.sid, available: true, ...(session.target !== '' ? { target: session.target } : {}), ...result.frame };
|
|
5445
5471
|
},
|
|
5446
5472
|
})));
|
|
5447
|
-
activeDisposers.push(
|
|
5473
|
+
activeDisposers.push(registerTool(defineTool({
|
|
5448
5474
|
name: 'tty_capture',
|
|
5449
5475
|
// 只读工具:与同轮其它工具并发执行(宿主默认把未声明的工具当独占,见项目级 ROADMAP 第 4 项)
|
|
5450
5476
|
isConcurrencySafe: () => true,
|
|
@@ -5538,7 +5564,7 @@ const plugin = definePlugin({
|
|
|
5538
5564
|
};
|
|
5539
5565
|
},
|
|
5540
5566
|
})));
|
|
5541
|
-
activeDisposers.push(
|
|
5567
|
+
activeDisposers.push(registerTool(defineTool({
|
|
5542
5568
|
name: 'tty_screen',
|
|
5543
5569
|
// 只读工具:与同轮其它工具并发执行(宿主默认把未声明的工具当独占,见项目级 ROADMAP 第 4 项)
|
|
5544
5570
|
isConcurrencySafe: () => true,
|
|
@@ -5555,6 +5581,7 @@ const plugin = definePlugin({
|
|
|
5555
5581
|
cols: { type: 'number', required: true },
|
|
5556
5582
|
rows: { type: 'number', required: true },
|
|
5557
5583
|
text: { type: 'string', required: true },
|
|
5584
|
+
truncated: { type: 'boolean', required: true },
|
|
5558
5585
|
exited: { type: 'boolean' },
|
|
5559
5586
|
signal: { type: 'string' },
|
|
5560
5587
|
},
|
|
@@ -5564,7 +5591,9 @@ const plugin = definePlugin({
|
|
|
5564
5591
|
const note = v.exited === true
|
|
5565
5592
|
? `(⚠️ 该会话的进程已退出${v.signal !== undefined && v.signal !== '' ? `(signal=${v.signal})` : ''}:这是退出那一刻的屏幕,只读保留中)`
|
|
5566
5593
|
: '';
|
|
5567
|
-
|
|
5594
|
+
// 截断提示只在真的截断时出现:≤32KB 的屏幕不该无缘由地把 agent 赶去 tty_capture
|
|
5595
|
+
const truncNote = v.truncated === true ? '\n\n(仅显示屏幕底部 32KB;完整滚动输出用 `tty_capture` 取)' : '';
|
|
5596
|
+
return [{ type: 'text', text: `终端会话 ${v.sid ?? '?'} 当前屏幕(${String(v.cols ?? '?')}×${String(v.rows ?? '?')})${note}:\n\n${v.text ?? ''}${truncNote}` }];
|
|
5568
5597
|
},
|
|
5569
5598
|
},
|
|
5570
5599
|
async execute(args) {
|
|
@@ -5581,17 +5610,19 @@ const plugin = definePlugin({
|
|
|
5581
5610
|
throw new Error(`虚拟屏不可用: ${input.sid}${why}`);
|
|
5582
5611
|
}
|
|
5583
5612
|
// 保尾截断:屏幕末尾(提示符行)才是有效区,丢头部不丢尾部
|
|
5613
|
+
const { text, truncated } = screenPayload(screenTextOf(screen));
|
|
5584
5614
|
return {
|
|
5585
5615
|
sid: input.sid,
|
|
5586
5616
|
cols: screen.cols,
|
|
5587
5617
|
rows: screen.rows,
|
|
5588
5618
|
// D77:只读保留态如实标注(屏不再更新,「当前屏幕」= 退出那一刻)
|
|
5589
5619
|
...(session.exited === null ? {} : { exited: true, ...(session.exited.signal === null || session.exited.signal === '' ? {} : { signal: session.exited.signal }) }),
|
|
5590
|
-
text
|
|
5620
|
+
text,
|
|
5621
|
+
truncated,
|
|
5591
5622
|
};
|
|
5592
5623
|
},
|
|
5593
5624
|
})));
|
|
5594
|
-
activeDisposers.push(
|
|
5625
|
+
activeDisposers.push(registerTool(defineTool({
|
|
5595
5626
|
name: 'tty_expect',
|
|
5596
5627
|
// 只读工具:与同轮其它工具并发执行(宿主默认把未声明的工具当独占,见项目级 ROADMAP 第 4 项)
|
|
5597
5628
|
isConcurrencySafe: () => true,
|
|
@@ -5753,7 +5784,7 @@ const plugin = definePlugin({
|
|
|
5753
5784
|
});
|
|
5754
5785
|
},
|
|
5755
5786
|
})));
|
|
5756
|
-
activeDisposers.push(
|
|
5787
|
+
activeDisposers.push(registerTool(defineTool({
|
|
5757
5788
|
name: 'tty_send',
|
|
5758
5789
|
description: '向某个终端面板会话(tty_list 提供 sid)的 PTY 发送文本与/或按键(data 含换行则以回车提交;Windows 本地会话上插件会把 `\\n` 归一成 CRLF,照常写 `\\n` 即可)。**控制键与方向键用具名 `keys`,不要自己在 data 里拼转义序列**:`"\\x1b[B"` / `"^[[B"` 这类写法会被当成普通字符打印进终端(而 sent 计数一样,看不出错)。data 与 keys 至少给一个;两个都给时先发 data、再按序发 keys(如 data=":wq" + keys=["Enter"])。适合给用户终端里运行的程序发交互输入(如 dev server 的 q 键、menu 选择、vim/less 的翻页、回答提示)。操作会实时显示在用户的终端面板里。**对已退出(只读保留)的会话会报错**——那种会话只能读,要接着操作请 tty_open 新开一条。',
|
|
5759
5790
|
parameters: {
|
|
@@ -5818,482 +5849,495 @@ const plugin = definePlugin({
|
|
|
5818
5849
|
return { ok: true, sent: data.length };
|
|
5819
5850
|
},
|
|
5820
5851
|
})));
|
|
5821
|
-
|
|
5822
|
-
|
|
5823
|
-
|
|
5824
|
-
|
|
5825
|
-
|
|
5826
|
-
|
|
5827
|
-
|
|
5828
|
-
|
|
5829
|
-
|
|
5830
|
-
|
|
5831
|
-
|
|
5832
|
-
|
|
5833
|
-
|
|
5834
|
-
|
|
5835
|
-
|
|
5836
|
-
|
|
5837
|
-
|
|
5838
|
-
|
|
5839
|
-
|
|
5840
|
-
|
|
5841
|
-
|
|
5842
|
-
|
|
5843
|
-
|
|
5844
|
-
|
|
5845
|
-
|
|
5846
|
-
|
|
5847
|
-
|
|
5852
|
+
// 条件注册(判据 live.tunnels):隧道启停只改**既有**配置(TunnelManager.setEnabled 找不到名字就报错),
|
|
5853
|
+
// 一条隧道都没配时挂上去只能报错,不注册就没有这条只有报错一条路的工具;
|
|
5854
|
+
// applyPatch 热应用后无条件调用 refreshToolsHook 重注册,配了隧道就自动出现。
|
|
5855
|
+
if (live.tunnels.length > 0) {
|
|
5856
|
+
activeDisposers.push(registerTool(defineTool({
|
|
5857
|
+
name: 'tunnel_list',
|
|
5858
|
+
// 只读工具:与同轮其它工具并发执行(宿主默认把未声明的工具当独占,见项目级 ROADMAP 第 4 项)
|
|
5859
|
+
isConcurrencySafe: () => true,
|
|
5860
|
+
description: '列出端口转发隧道及其实时状态(活跃/连接中/错误/停止、规则、当前与累计连接数、最近错误)。用户说「隧道连不上 / 转发挂了 / 端口转发不通」时先用它诊断;隧道在 插件配置 → 终端面板 卡片维护。',
|
|
5861
|
+
parameters: {},
|
|
5862
|
+
output: {
|
|
5863
|
+
schema: {
|
|
5864
|
+
type: 'object',
|
|
5865
|
+
additionalProperties: false,
|
|
5866
|
+
properties: {
|
|
5867
|
+
tunnels: {
|
|
5868
|
+
type: 'array',
|
|
5869
|
+
required: true,
|
|
5870
|
+
items: {
|
|
5871
|
+
type: 'object',
|
|
5872
|
+
additionalProperties: false,
|
|
5873
|
+
properties: {
|
|
5874
|
+
name: { type: 'string', required: true },
|
|
5875
|
+
direction: { type: 'string', required: true },
|
|
5876
|
+
rule: { type: 'string', required: true },
|
|
5877
|
+
bookName: { type: 'string', required: true },
|
|
5878
|
+
state: { type: 'string', required: true },
|
|
5879
|
+
error: { type: 'string' },
|
|
5880
|
+
fatal: { type: 'boolean', required: true },
|
|
5881
|
+
connections: { type: 'number', required: true },
|
|
5882
|
+
totalConnections: { type: 'number', required: true },
|
|
5883
|
+
},
|
|
5848
5884
|
},
|
|
5849
5885
|
},
|
|
5850
5886
|
},
|
|
5851
5887
|
},
|
|
5888
|
+
render: (_args, value) => {
|
|
5889
|
+
const v = value;
|
|
5890
|
+
const tunnels = v.tunnels ?? [];
|
|
5891
|
+
if (tunnels.length === 0)
|
|
5892
|
+
return [{ type: 'text', text: '当前没有配置端口转发隧道(插件配置 → 终端面板 卡片可添加)' }];
|
|
5893
|
+
const text = '端口转发隧道:' + tunnels.map((t) => {
|
|
5894
|
+
// fatal 单独措辞(D58):这类故障不会自愈,不说清就会一直等「正在连」
|
|
5895
|
+
const tail = t.error !== null && t.error !== undefined
|
|
5896
|
+
? (t.fatal === true ? `(错误: ${t.error} —— 不会自动重试,需修配置)` : `(错误: ${t.error})`)
|
|
5897
|
+
: t.lastForwardError !== null && t.lastForwardError !== undefined ? `(最近转发失败: ${t.lastForwardError})` : `(连接 ${String(t.connections)})`;
|
|
5898
|
+
return `\n- ${t.name} [${t.direction}] ${t.rule} — ${t.state}${tail}`;
|
|
5899
|
+
}).join('');
|
|
5900
|
+
return [{ type: 'text', text }];
|
|
5901
|
+
},
|
|
5852
5902
|
},
|
|
5853
|
-
|
|
5854
|
-
|
|
5855
|
-
|
|
5856
|
-
|
|
5857
|
-
|
|
5858
|
-
|
|
5859
|
-
|
|
5860
|
-
|
|
5861
|
-
|
|
5862
|
-
: t.
|
|
5863
|
-
|
|
5864
|
-
|
|
5865
|
-
|
|
5903
|
+
async execute() {
|
|
5904
|
+
// 显式挑字段(0.19.0):list() 还带 enabled / lastForwardError,
|
|
5905
|
+
// 整包展开会突破 schema 的 additionalProperties:false——PTC 生成的
|
|
5906
|
+
// TS 类型会漏字段。
|
|
5907
|
+
// error 必须是「**键不存在**」而不是「键存在但值为 undefined」:后者
|
|
5908
|
+
// 保留了一个 undefined,不是无损 JSON 值,宿主校验会判
|
|
5909
|
+
// 「must be a lossless JSON object」直接把工具调用变成 Error(B33 抓到)。
|
|
5910
|
+
return {
|
|
5911
|
+
tunnels: tunnelManager.list().map((t) => ({
|
|
5912
|
+
name: t.name,
|
|
5913
|
+
bookName: t.bookName,
|
|
5914
|
+
direction: t.direction,
|
|
5915
|
+
rule: t.rule,
|
|
5916
|
+
state: t.state,
|
|
5917
|
+
...(t.error === null || t.error === undefined ? {} : { error: t.error }),
|
|
5918
|
+
fatal: t.fatal,
|
|
5919
|
+
connections: t.connections,
|
|
5920
|
+
totalConnections: t.totalConnections,
|
|
5921
|
+
})),
|
|
5922
|
+
};
|
|
5866
5923
|
},
|
|
5867
|
-
}
|
|
5868
|
-
|
|
5869
|
-
|
|
5870
|
-
|
|
5871
|
-
|
|
5872
|
-
|
|
5873
|
-
|
|
5874
|
-
|
|
5875
|
-
|
|
5876
|
-
|
|
5877
|
-
|
|
5878
|
-
|
|
5879
|
-
|
|
5880
|
-
|
|
5881
|
-
|
|
5882
|
-
|
|
5883
|
-
|
|
5884
|
-
|
|
5885
|
-
|
|
5886
|
-
|
|
5887
|
-
|
|
5888
|
-
|
|
5889
|
-
|
|
5890
|
-
|
|
5891
|
-
|
|
5892
|
-
|
|
5893
|
-
|
|
5894
|
-
|
|
5895
|
-
|
|
5896
|
-
|
|
5897
|
-
|
|
5898
|
-
|
|
5899
|
-
|
|
5900
|
-
|
|
5901
|
-
|
|
5902
|
-
|
|
5903
|
-
|
|
5904
|
-
|
|
5924
|
+
})));
|
|
5925
|
+
/*
|
|
5926
|
+
* 隧道启停(agent 侧,0.25.0)——`tunnel_list` 只读,此前「停掉 → 重开 → 再看」
|
|
5927
|
+
* 这个诊断闭环只能让用户去卡片上点。
|
|
5928
|
+
*
|
|
5929
|
+
* 两个工具刻意**分成两个**(而不是一个 `tunnel_action(name, action)`):启停都是
|
|
5930
|
+
* 真变更(会掐断/建立真连接),合成一个工具会让「改配置」这件事在模型眼里可合并
|
|
5931
|
+
* 成一次可重试的调用;分开后每个名字只表达一件事,`enabled` 这个布尔也没有翻转歧义。
|
|
5932
|
+
*
|
|
5933
|
+
* 两者都**改配置**(见 TunnelManager.setEnabled 的说明):与卡片上那个勾同一条路,
|
|
5934
|
+
* 写完 settings 热应用 → reconcile,配置始终是唯一真相源。
|
|
5935
|
+
*/
|
|
5936
|
+
for (const [toolName, enable, verb] of [['tunnel_start', true, '启动'], ['tunnel_stop', false, '停止']]) {
|
|
5937
|
+
activeDisposers.push(registerTool(defineTool({
|
|
5938
|
+
name: toolName,
|
|
5939
|
+
description: `${verb}一条端口转发隧道(name 来自 tunnel_list)。**会改写配置**——与在 插件配置 → 终端面板 卡片上勾选/取消「启用」是同一件事,所以是持久的、用户看得到。返回的是操作后的状态快照:state 通常还是 connecting(拨号异步),要最终结论稍后 tunnel_list。`,
|
|
5940
|
+
parameters: {
|
|
5941
|
+
name: { type: 'string', required: true, description: '隧道名(tunnel_list 的 name 字段,形如 <连接簿条目>-L<端口>)' },
|
|
5942
|
+
},
|
|
5943
|
+
output: {
|
|
5944
|
+
schema: {
|
|
5945
|
+
type: 'object',
|
|
5946
|
+
additionalProperties: false,
|
|
5947
|
+
properties: {
|
|
5948
|
+
name: { type: 'string', required: true },
|
|
5949
|
+
enabled: { type: 'boolean', required: true },
|
|
5950
|
+
state: { type: 'string', required: true },
|
|
5951
|
+
rule: { type: 'string', required: true },
|
|
5952
|
+
bookName: { type: 'string', required: true },
|
|
5953
|
+
connections: { type: 'number', required: true },
|
|
5954
|
+
error: { type: 'string' },
|
|
5955
|
+
},
|
|
5956
|
+
},
|
|
5957
|
+
render: (args, value) => {
|
|
5958
|
+
const a = args;
|
|
5959
|
+
const v = value;
|
|
5960
|
+
const tail = typeof v.error === 'string' && v.error !== '' ? `(${v.error})` : '';
|
|
5961
|
+
return [{ type: 'text', text: `隧道「${String(a.name ?? '?')}」已${verb}:${String(v.rule ?? '')} · ${String(v.state ?? '')}${tail}` }];
|
|
5962
|
+
},
|
|
5963
|
+
},
|
|
5964
|
+
async execute(args) {
|
|
5965
|
+
const input = args;
|
|
5966
|
+
if (typeof input.name !== 'string' || input.name.trim() === '')
|
|
5967
|
+
throw new Error('name 必须是非空字符串(隧道名来自 tunnel_list)');
|
|
5968
|
+
const result = tunnelManager.setEnabled(input.name.trim(), enable);
|
|
5969
|
+
if (!result.ok)
|
|
5970
|
+
throw new Error(result.error);
|
|
5971
|
+
const t = result.status;
|
|
5972
|
+
return {
|
|
5973
|
+
name: t.name,
|
|
5974
|
+
enabled: t.enabled,
|
|
5975
|
+
state: t.state,
|
|
5976
|
+
rule: t.rule,
|
|
5977
|
+
bookName: t.bookName,
|
|
5978
|
+
connections: t.connections,
|
|
5979
|
+
// error 键必须「不存在」而不是 undefined(同 tunnel_list 的 D52 教训)
|
|
5980
|
+
...(t.error === null || t.error === undefined ? {} : { error: t.error }),
|
|
5981
|
+
};
|
|
5982
|
+
},
|
|
5983
|
+
})));
|
|
5984
|
+
}
|
|
5985
|
+
}
|
|
5986
|
+
// 条件注册(判据 live.sshHosts):sftpBookSpec 每次都走 live.findSshHost,连接簿为空时必然抛
|
|
5987
|
+
// 「连接簿中不存在」——七个工具挂上去只有报错一条路;
|
|
5988
|
+
// applyPatch 热应用后无条件调用 refreshToolsHook 重注册,配了连接簿条目就自动出现。
|
|
5989
|
+
if (live.sshHosts.length > 0) {
|
|
5990
|
+
// —— SFTP 文件传输工具(0.7.0)——
|
|
5991
|
+
// 只收连接簿条目名(book),不接受内联凭证:agent 上下文不进明文密钥;
|
|
5992
|
+
// 连接与终端/隧道共用同一 HostKeyStore(TOFU 同源)。
|
|
5993
|
+
const sftpBookSpec = (book) => {
|
|
5994
|
+
if (typeof book !== 'string' || book.trim() === '')
|
|
5995
|
+
throw new Error('book 必须是 SSH 连接簿条目名');
|
|
5996
|
+
const entry = live.findSshHost(book.trim());
|
|
5997
|
+
if (entry === undefined)
|
|
5998
|
+
throw new Error(`连接簿中不存在: ${book.trim()}`);
|
|
5999
|
+
return entry;
|
|
6000
|
+
};
|
|
6001
|
+
activeDisposers.push(registerTool(defineTool({
|
|
6002
|
+
name: 'sftp_list',
|
|
6003
|
+
// 只读工具:与同轮其它工具并发执行(宿主默认把未声明的工具当独占,见项目级 ROADMAP 第 4 项)
|
|
6004
|
+
isConcurrencySafe: () => true,
|
|
6005
|
+
description: '列出 SSH 远程目录内容(名称/类型/大小/修改时间,目录在前;isSymlink 区分符号链接与真目录)。book 为 SSH 连接簿条目名;path 缺省为远程登录 home。默认最多列 500 项(超限 truncated:true,可按子目录分批)。',
|
|
5905
6006
|
parameters: {
|
|
5906
|
-
|
|
6007
|
+
book: { type: 'string', required: true, description: 'SSH 连接簿条目名(插件配置 → 终端面板 维护)' },
|
|
6008
|
+
path: { type: 'string', description: '远程目录路径(缺省 = 登录 home)' },
|
|
6009
|
+
maxEntries: { type: 'number', description: '最大条目数(1~2000,默认 500)' },
|
|
5907
6010
|
},
|
|
5908
6011
|
output: {
|
|
5909
6012
|
schema: {
|
|
5910
6013
|
type: 'object',
|
|
5911
6014
|
additionalProperties: false,
|
|
5912
6015
|
properties: {
|
|
5913
|
-
|
|
5914
|
-
|
|
5915
|
-
|
|
5916
|
-
|
|
5917
|
-
|
|
5918
|
-
|
|
5919
|
-
|
|
6016
|
+
path: { type: 'string', required: true },
|
|
6017
|
+
truncated: { type: 'boolean', required: true },
|
|
6018
|
+
entries: {
|
|
6019
|
+
type: 'array',
|
|
6020
|
+
required: true,
|
|
6021
|
+
items: {
|
|
6022
|
+
type: 'object',
|
|
6023
|
+
additionalProperties: false,
|
|
6024
|
+
properties: {
|
|
6025
|
+
name: { type: 'string', required: true },
|
|
6026
|
+
isDir: { type: 'boolean', required: true },
|
|
6027
|
+
isSymlink: { type: 'boolean', required: true },
|
|
6028
|
+
size: { type: 'number', required: true },
|
|
6029
|
+
mtime: { type: 'number', required: true },
|
|
6030
|
+
},
|
|
6031
|
+
},
|
|
6032
|
+
},
|
|
5920
6033
|
},
|
|
5921
6034
|
},
|
|
5922
|
-
render: (
|
|
5923
|
-
const a = args;
|
|
6035
|
+
render: (_args, value) => {
|
|
5924
6036
|
const v = value;
|
|
5925
|
-
const
|
|
5926
|
-
|
|
6037
|
+
const entries = v.entries ?? [];
|
|
6038
|
+
if (entries.length === 0)
|
|
6039
|
+
return [{ type: 'text', text: `远程目录 ${v.path ?? '?'} 为空` }];
|
|
6040
|
+
const text = `远程目录 ${v.path ?? '?'}(${String(entries.length)} 项${v.truncated === true ? ',已截断——按子目录分批或加大 maxEntries' : ''}):` + entries.map((e) => `\n- ${e.name}${e.isDir ? '/' : e.isSymlink ? '@' : ''} — ${e.isDir ? '目录' : e.isSymlink ? '符号链接' : humanFileSize(e.size)}`).join('');
|
|
6041
|
+
return [{ type: 'text', text }];
|
|
5927
6042
|
},
|
|
5928
6043
|
},
|
|
5929
6044
|
async execute(args) {
|
|
5930
6045
|
const input = args;
|
|
5931
|
-
|
|
5932
|
-
|
|
5933
|
-
const result =
|
|
5934
|
-
|
|
5935
|
-
|
|
5936
|
-
|
|
5937
|
-
return {
|
|
5938
|
-
name: t.name,
|
|
5939
|
-
enabled: t.enabled,
|
|
5940
|
-
state: t.state,
|
|
5941
|
-
rule: t.rule,
|
|
5942
|
-
bookName: t.bookName,
|
|
5943
|
-
connections: t.connections,
|
|
5944
|
-
// error 键必须「不存在」而不是 undefined(同 tunnel_list 的 D52 教训)
|
|
5945
|
-
...(t.error === null || t.error === undefined ? {} : { error: t.error }),
|
|
5946
|
-
};
|
|
6046
|
+
const spec = sftpBookSpec(input.book);
|
|
6047
|
+
const maxEntries = Math.max(1, Math.min(2000, typeof input.maxEntries === 'number' && Number.isInteger(input.maxEntries) && input.maxEntries >= 1 ? input.maxEntries : 500));
|
|
6048
|
+
const result = await sftpManager.list(spec, typeof input.path === 'string' ? input.path : '');
|
|
6049
|
+
const truncated = result.entries.length > maxEntries;
|
|
6050
|
+
const entries = result.entries.slice(0, maxEntries).map((e) => ({ name: e.name, isDir: e.isDir, isSymlink: e.isSymlink, size: e.size, mtime: e.mtime }));
|
|
6051
|
+
return { path: result.path, truncated, entries };
|
|
5947
6052
|
},
|
|
5948
6053
|
})));
|
|
5949
|
-
|
|
5950
|
-
|
|
5951
|
-
|
|
5952
|
-
|
|
5953
|
-
|
|
5954
|
-
|
|
5955
|
-
|
|
5956
|
-
|
|
5957
|
-
|
|
5958
|
-
|
|
5959
|
-
|
|
5960
|
-
|
|
5961
|
-
|
|
5962
|
-
|
|
5963
|
-
|
|
5964
|
-
|
|
5965
|
-
|
|
5966
|
-
|
|
5967
|
-
|
|
5968
|
-
path: { type: 'string', description: '远程目录路径(缺省 = 登录 home)' },
|
|
5969
|
-
maxEntries: { type: 'number', description: '最大条目数(1~2000,默认 500)' },
|
|
5970
|
-
},
|
|
5971
|
-
output: {
|
|
5972
|
-
schema: {
|
|
5973
|
-
type: 'object',
|
|
5974
|
-
additionalProperties: false,
|
|
5975
|
-
properties: {
|
|
5976
|
-
path: { type: 'string', required: true },
|
|
5977
|
-
truncated: { type: 'boolean', required: true },
|
|
5978
|
-
entries: {
|
|
5979
|
-
type: 'array',
|
|
5980
|
-
required: true,
|
|
5981
|
-
items: {
|
|
5982
|
-
type: 'object',
|
|
5983
|
-
additionalProperties: false,
|
|
5984
|
-
properties: {
|
|
5985
|
-
name: { type: 'string', required: true },
|
|
5986
|
-
isDir: { type: 'boolean', required: true },
|
|
5987
|
-
isSymlink: { type: 'boolean', required: true },
|
|
5988
|
-
size: { type: 'number', required: true },
|
|
5989
|
-
mtime: { type: 'number', required: true },
|
|
5990
|
-
},
|
|
5991
|
-
},
|
|
6054
|
+
activeDisposers.push(registerTool(defineTool({
|
|
6055
|
+
name: 'sftp_read',
|
|
6056
|
+
// 只读工具:与同轮其它工具并发执行(宿主默认把未声明的工具当独占,见项目级 ROADMAP 第 4 项)
|
|
6057
|
+
isConcurrencySafe: () => true,
|
|
6058
|
+
description: '读取 SSH 远程文本文件(book 连接簿条目 + path)。默认最多 256KB(可调至 1MB,非法值直接报错);offset 可从指定字节起读(配合 maxBytes 分页拿到大文件尾部);二进制判定用 NUL + 非法 UTF-8 占比双重检测,拒绝时说明原因。',
|
|
6059
|
+
parameters: {
|
|
6060
|
+
book: { type: 'string', required: true, description: 'SSH 连接簿条目名' },
|
|
6061
|
+
path: { type: 'string', required: true, description: '远程文件路径' },
|
|
6062
|
+
maxBytes: { type: 'number', description: '最大读取字节数(1~1048576,默认 262144;非法值报错不再静默回落)' },
|
|
6063
|
+
offset: { type: 'number', description: '起始字节偏移(0~2^53-1,默认 0;>0 时跳过前缀,适合读日志尾部)' },
|
|
6064
|
+
},
|
|
6065
|
+
output: {
|
|
6066
|
+
schema: {
|
|
6067
|
+
type: 'object',
|
|
6068
|
+
additionalProperties: false,
|
|
6069
|
+
properties: {
|
|
6070
|
+
path: { type: 'string', required: true },
|
|
6071
|
+
content: { type: 'string', required: true },
|
|
6072
|
+
truncated: { type: 'boolean', required: true },
|
|
5992
6073
|
},
|
|
5993
6074
|
},
|
|
5994
|
-
|
|
5995
|
-
|
|
5996
|
-
|
|
5997
|
-
|
|
5998
|
-
if (entries.length === 0)
|
|
5999
|
-
return [{ type: 'text', text: `远程目录 ${v.path ?? '?'} 为空` }];
|
|
6000
|
-
const text = `远程目录 ${v.path ?? '?'}(${String(entries.length)} 项${v.truncated === true ? ',已截断——按子目录分批或加大 maxEntries' : ''}):` + entries.map((e) => `\n- ${e.name}${e.isDir ? '/' : e.isSymlink ? '@' : ''} — ${e.isDir ? '目录' : e.isSymlink ? '符号链接' : humanFileSize(e.size)}`).join('');
|
|
6001
|
-
return [{ type: 'text', text }];
|
|
6002
|
-
},
|
|
6003
|
-
},
|
|
6004
|
-
async execute(args) {
|
|
6005
|
-
const input = args;
|
|
6006
|
-
const spec = sftpBookSpec(input.book);
|
|
6007
|
-
const maxEntries = Math.max(1, Math.min(2000, typeof input.maxEntries === 'number' && Number.isInteger(input.maxEntries) && input.maxEntries >= 1 ? input.maxEntries : 500));
|
|
6008
|
-
const result = await sftpManager.list(spec, typeof input.path === 'string' ? input.path : '');
|
|
6009
|
-
const truncated = result.entries.length > maxEntries;
|
|
6010
|
-
const entries = result.entries.slice(0, maxEntries).map((e) => ({ name: e.name, isDir: e.isDir, isSymlink: e.isSymlink, size: e.size, mtime: e.mtime }));
|
|
6011
|
-
return { path: result.path, truncated, entries };
|
|
6012
|
-
},
|
|
6013
|
-
})));
|
|
6014
|
-
activeDisposers.push(tools.register(defineTool({
|
|
6015
|
-
name: 'sftp_read',
|
|
6016
|
-
// 只读工具:与同轮其它工具并发执行(宿主默认把未声明的工具当独占,见项目级 ROADMAP 第 4 项)
|
|
6017
|
-
isConcurrencySafe: () => true,
|
|
6018
|
-
description: '读取 SSH 远程文本文件(book 连接簿条目 + path)。默认最多 256KB(可调至 1MB,非法值直接报错);offset 可从指定字节起读(配合 maxBytes 分页拿到大文件尾部);二进制判定用 NUL + 非法 UTF-8 占比双重检测,拒绝时说明原因。',
|
|
6019
|
-
parameters: {
|
|
6020
|
-
book: { type: 'string', required: true, description: 'SSH 连接簿条目名' },
|
|
6021
|
-
path: { type: 'string', required: true, description: '远程文件路径' },
|
|
6022
|
-
maxBytes: { type: 'number', description: '最大读取字节数(1~1048576,默认 262144;非法值报错不再静默回落)' },
|
|
6023
|
-
offset: { type: 'number', description: '起始字节偏移(0~2^53-1,默认 0;>0 时跳过前缀,适合读日志尾部)' },
|
|
6024
|
-
},
|
|
6025
|
-
output: {
|
|
6026
|
-
schema: {
|
|
6027
|
-
type: 'object',
|
|
6028
|
-
additionalProperties: false,
|
|
6029
|
-
properties: {
|
|
6030
|
-
path: { type: 'string', required: true },
|
|
6031
|
-
content: { type: 'string', required: true },
|
|
6032
|
-
truncated: { type: 'boolean', required: true },
|
|
6075
|
+
render: (_args, value) => {
|
|
6076
|
+
const v = value;
|
|
6077
|
+
const head = `远程文件 ${v.path ?? '?'}${v.truncated === true ? '(已截断——加大 maxBytes 或用 offset 分页)' : ''}:`;
|
|
6078
|
+
return [{ type: 'text', text: head + '\n' + String(v.content ?? '') }];
|
|
6033
6079
|
},
|
|
6034
6080
|
},
|
|
6035
|
-
|
|
6036
|
-
const
|
|
6037
|
-
const
|
|
6038
|
-
|
|
6039
|
-
|
|
6040
|
-
|
|
6041
|
-
|
|
6042
|
-
const input = args;
|
|
6043
|
-
const spec = sftpBookSpec(input.book);
|
|
6044
|
-
if (typeof input.path !== 'string' || input.path.trim() === '')
|
|
6045
|
-
throw new Error('path 必须是非空字符串');
|
|
6046
|
-
if (input.maxBytes !== undefined && (typeof input.maxBytes !== 'number' || !Number.isInteger(input.maxBytes) || input.maxBytes < 1 || input.maxBytes > 1024 * 1024)) {
|
|
6047
|
-
throw new Error('maxBytes 必须是 1~1048576 的整数');
|
|
6048
|
-
}
|
|
6049
|
-
const maxBytes = input.maxBytes === undefined ? 256 * 1024 : input.maxBytes;
|
|
6050
|
-
if (input.offset !== undefined && (typeof input.offset !== 'number' || !Number.isInteger(input.offset) || input.offset < 0)) {
|
|
6051
|
-
throw new Error('offset 必须是非负整数');
|
|
6052
|
-
}
|
|
6053
|
-
const offset = input.offset === undefined ? 0 : input.offset;
|
|
6054
|
-
const { stream } = await sftpManager.openDownload(spec, input.path, offset > 0 ? { offset } : undefined);
|
|
6055
|
-
const chunks = [];
|
|
6056
|
-
let total = 0;
|
|
6057
|
-
try {
|
|
6058
|
-
for await (const chunk of stream) {
|
|
6059
|
-
const piece = chunk;
|
|
6060
|
-
chunks.push(piece);
|
|
6061
|
-
total += piece.length;
|
|
6062
|
-
if (total > maxBytes)
|
|
6063
|
-
break; // 只多读一段用于判定截断,其余丢弃
|
|
6081
|
+
async execute(args) {
|
|
6082
|
+
const input = args;
|
|
6083
|
+
const spec = sftpBookSpec(input.book);
|
|
6084
|
+
if (typeof input.path !== 'string' || input.path.trim() === '')
|
|
6085
|
+
throw new Error('path 必须是非空字符串');
|
|
6086
|
+
if (input.maxBytes !== undefined && (typeof input.maxBytes !== 'number' || !Number.isInteger(input.maxBytes) || input.maxBytes < 1 || input.maxBytes > 1024 * 1024)) {
|
|
6087
|
+
throw new Error('maxBytes 必须是 1~1048576 的整数');
|
|
6064
6088
|
}
|
|
6065
|
-
|
|
6066
|
-
|
|
6067
|
-
|
|
6068
|
-
|
|
6069
|
-
|
|
6070
|
-
|
|
6071
|
-
|
|
6072
|
-
|
|
6073
|
-
|
|
6074
|
-
|
|
6075
|
-
|
|
6076
|
-
|
|
6077
|
-
|
|
6078
|
-
|
|
6079
|
-
|
|
6080
|
-
|
|
6081
|
-
|
|
6082
|
-
|
|
6083
|
-
|
|
6084
|
-
|
|
6085
|
-
|
|
6086
|
-
|
|
6087
|
-
|
|
6088
|
-
|
|
6089
|
-
|
|
6090
|
-
|
|
6091
|
-
|
|
6092
|
-
|
|
6093
|
-
|
|
6094
|
-
|
|
6089
|
+
const maxBytes = input.maxBytes === undefined ? 256 * 1024 : input.maxBytes;
|
|
6090
|
+
if (input.offset !== undefined && (typeof input.offset !== 'number' || !Number.isInteger(input.offset) || input.offset < 0)) {
|
|
6091
|
+
throw new Error('offset 必须是非负整数');
|
|
6092
|
+
}
|
|
6093
|
+
const offset = input.offset === undefined ? 0 : input.offset;
|
|
6094
|
+
const { stream } = await sftpManager.openDownload(spec, input.path, offset > 0 ? { offset } : undefined);
|
|
6095
|
+
const chunks = [];
|
|
6096
|
+
let total = 0;
|
|
6097
|
+
try {
|
|
6098
|
+
for await (const chunk of stream) {
|
|
6099
|
+
const piece = chunk;
|
|
6100
|
+
chunks.push(piece);
|
|
6101
|
+
total += piece.length;
|
|
6102
|
+
if (total > maxBytes)
|
|
6103
|
+
break; // 只多读一段用于判定截断,其余丢弃
|
|
6104
|
+
}
|
|
6105
|
+
}
|
|
6106
|
+
finally {
|
|
6107
|
+
stream.destroy();
|
|
6108
|
+
}
|
|
6109
|
+
const buf = Buffer.concat(chunks);
|
|
6110
|
+
const truncated = buf.length > maxBytes;
|
|
6111
|
+
const sliced = truncated ? buf.subarray(0, maxBytes) : buf;
|
|
6112
|
+
if (looksLikeBinary(sliced))
|
|
6113
|
+
throw new Error('疑似二进制文件(含 NUL 或非法 UTF-8 占比过高),sftp_read 只支持文本内容');
|
|
6114
|
+
return { path: input.path.trim(), content: decodeUtf8ForAgent(sliced), truncated };
|
|
6115
|
+
},
|
|
6116
|
+
})));
|
|
6117
|
+
activeDisposers.push(registerTool(defineTool({
|
|
6118
|
+
name: 'sftp_write',
|
|
6119
|
+
description: '写 SSH 远程文本文件(book 连接簿条目 + path + content)。默认覆盖写入,append:true 追加到文件尾;单次最多 1MB。适合远程写配置、落结果文件。',
|
|
6120
|
+
parameters: {
|
|
6121
|
+
book: { type: 'string', required: true, description: 'SSH 连接簿条目名' },
|
|
6122
|
+
path: { type: 'string', required: true, description: '远程文件路径' },
|
|
6123
|
+
content: { type: 'string', required: true, description: '要写入的文本内容(≤1MB)' },
|
|
6124
|
+
append: { type: 'boolean', description: 'true 追加到文件尾(默认覆盖)' },
|
|
6125
|
+
},
|
|
6126
|
+
output: {
|
|
6127
|
+
schema: {
|
|
6128
|
+
type: 'object',
|
|
6129
|
+
additionalProperties: false,
|
|
6130
|
+
properties: {
|
|
6131
|
+
ok: { type: 'boolean', required: true },
|
|
6132
|
+
path: { type: 'string', required: true },
|
|
6133
|
+
bytes: { type: 'number', required: true },
|
|
6134
|
+
append: { type: 'boolean', required: true },
|
|
6135
|
+
},
|
|
6136
|
+
},
|
|
6137
|
+
render: (_args, value) => {
|
|
6138
|
+
const v = value;
|
|
6139
|
+
return [{ type: 'text', text: `已${v.append === true ? '追加' : '写入'}远程文件 ${v.path ?? '?'}(${String(v.bytes ?? 0)} 字节)` }];
|
|
6095
6140
|
},
|
|
6096
6141
|
},
|
|
6097
|
-
|
|
6098
|
-
const
|
|
6099
|
-
|
|
6142
|
+
async execute(args) {
|
|
6143
|
+
const input = args;
|
|
6144
|
+
const spec = sftpBookSpec(input.book);
|
|
6145
|
+
if (typeof input.path !== 'string' || input.path.trim() === '')
|
|
6146
|
+
throw new Error('path 必须是非空字符串');
|
|
6147
|
+
if (typeof input.content !== 'string')
|
|
6148
|
+
throw new Error('content 必须是字符串');
|
|
6149
|
+
const bytes = Buffer.byteLength(input.content, 'utf8');
|
|
6150
|
+
if (bytes > 1024 * 1024)
|
|
6151
|
+
throw new Error(`content 超过上限:${String(bytes)} 字节 > 1MB(大文件请用终端 scp 或面板上传)`);
|
|
6152
|
+
const append = input.append === true;
|
|
6153
|
+
const { stream, done } = await sftpManager.openUpload(spec, input.path, append);
|
|
6154
|
+
stream.write(input.content, 'utf8');
|
|
6155
|
+
stream.end();
|
|
6156
|
+
await done;
|
|
6157
|
+
return { ok: true, path: input.path.trim(), bytes, append };
|
|
6100
6158
|
},
|
|
6101
|
-
}
|
|
6102
|
-
|
|
6103
|
-
|
|
6104
|
-
|
|
6105
|
-
|
|
6106
|
-
|
|
6107
|
-
|
|
6108
|
-
|
|
6109
|
-
|
|
6110
|
-
|
|
6111
|
-
|
|
6112
|
-
|
|
6113
|
-
|
|
6114
|
-
|
|
6115
|
-
|
|
6116
|
-
|
|
6117
|
-
|
|
6118
|
-
|
|
6119
|
-
|
|
6120
|
-
|
|
6121
|
-
|
|
6122
|
-
|
|
6123
|
-
|
|
6124
|
-
|
|
6125
|
-
|
|
6126
|
-
description: '在 SSH 远程创建目录(book 连接簿条目 + path)。parents:true 时逐级补齐缺失的父目录(等效 mkdir -p,默认 false,父目录缺失直接报错)。',
|
|
6127
|
-
parameters: {
|
|
6128
|
-
book: { type: 'string', required: true, description: 'SSH 连接簿条目名(插件配置 → 终端面板 维护)' },
|
|
6129
|
-
path: { type: 'string', required: true, description: '要创建的远程目录路径' },
|
|
6130
|
-
parents: { type: 'boolean', description: 'true 逐级补齐缺失父目录(默认 false)' },
|
|
6131
|
-
},
|
|
6132
|
-
output: {
|
|
6133
|
-
schema: {
|
|
6134
|
-
type: 'object',
|
|
6135
|
-
additionalProperties: false,
|
|
6136
|
-
properties: {
|
|
6137
|
-
ok: { type: 'boolean', required: true },
|
|
6138
|
-
path: { type: 'string', required: true },
|
|
6159
|
+
})));
|
|
6160
|
+
// —— SFTP 管理闭环(0.8.0)——
|
|
6161
|
+
// mkdir(可逐级补齐)/ rename(可跨目录,等效移动)/ remove(目录
|
|
6162
|
+
// 递归)/ tree(限深限数的递归列举),与 sftp_list/read/write 一起
|
|
6163
|
+
// 让 agent 不开面板也能完整管理远程文件;同样只收连接簿条目名。
|
|
6164
|
+
activeDisposers.push(registerTool(defineTool({
|
|
6165
|
+
name: 'sftp_mkdir',
|
|
6166
|
+
description: '在 SSH 远程创建目录(book 连接簿条目 + path)。parents:true 时逐级补齐缺失的父目录(等效 mkdir -p,默认 false,父目录缺失直接报错)。',
|
|
6167
|
+
parameters: {
|
|
6168
|
+
book: { type: 'string', required: true, description: 'SSH 连接簿条目名(插件配置 → 终端面板 维护)' },
|
|
6169
|
+
path: { type: 'string', required: true, description: '要创建的远程目录路径' },
|
|
6170
|
+
parents: { type: 'boolean', description: 'true 逐级补齐缺失父目录(默认 false)' },
|
|
6171
|
+
},
|
|
6172
|
+
output: {
|
|
6173
|
+
schema: {
|
|
6174
|
+
type: 'object',
|
|
6175
|
+
additionalProperties: false,
|
|
6176
|
+
properties: {
|
|
6177
|
+
ok: { type: 'boolean', required: true },
|
|
6178
|
+
path: { type: 'string', required: true },
|
|
6179
|
+
},
|
|
6180
|
+
},
|
|
6181
|
+
render: (_args, value) => {
|
|
6182
|
+
const v = value;
|
|
6183
|
+
return [{ type: 'text', text: `已创建远程目录 ${v.path ?? '?'}` }];
|
|
6139
6184
|
},
|
|
6140
6185
|
},
|
|
6141
|
-
|
|
6142
|
-
const
|
|
6143
|
-
|
|
6186
|
+
async execute(args) {
|
|
6187
|
+
const input = args;
|
|
6188
|
+
const spec = sftpBookSpec(input.book);
|
|
6189
|
+
if (typeof input.path !== 'string' || input.path.trim() === '')
|
|
6190
|
+
throw new Error('path 必须是非空字符串');
|
|
6191
|
+
await sftpManager.mkdir(spec, input.path, input.parents === true);
|
|
6192
|
+
return { ok: true, path: input.path.trim() };
|
|
6144
6193
|
},
|
|
6145
|
-
}
|
|
6146
|
-
|
|
6147
|
-
|
|
6148
|
-
|
|
6149
|
-
|
|
6150
|
-
|
|
6151
|
-
|
|
6152
|
-
|
|
6153
|
-
|
|
6154
|
-
|
|
6155
|
-
|
|
6156
|
-
|
|
6157
|
-
|
|
6158
|
-
|
|
6159
|
-
|
|
6160
|
-
|
|
6161
|
-
|
|
6162
|
-
|
|
6163
|
-
|
|
6164
|
-
|
|
6165
|
-
|
|
6166
|
-
|
|
6167
|
-
properties: {
|
|
6168
|
-
ok: { type: 'boolean', required: true },
|
|
6169
|
-
from: { type: 'string', required: true },
|
|
6170
|
-
to: { type: 'string', required: true },
|
|
6194
|
+
})));
|
|
6195
|
+
activeDisposers.push(registerTool(defineTool({
|
|
6196
|
+
name: 'sftp_rename',
|
|
6197
|
+
description: '在 SSH 远程重命名 / 移动文件或目录(book 连接簿条目 + from + to)。to 与 from 不同目录即为移动(目标目录需已存在);不会覆盖已存在的目标(服务端 rename 语义)。',
|
|
6198
|
+
parameters: {
|
|
6199
|
+
book: { type: 'string', required: true, description: 'SSH 连接簿条目名' },
|
|
6200
|
+
from: { type: 'string', required: true, description: '原远程路径' },
|
|
6201
|
+
to: { type: 'string', required: true, description: '新远程路径(跨目录即移动)' },
|
|
6202
|
+
},
|
|
6203
|
+
output: {
|
|
6204
|
+
schema: {
|
|
6205
|
+
type: 'object',
|
|
6206
|
+
additionalProperties: false,
|
|
6207
|
+
properties: {
|
|
6208
|
+
ok: { type: 'boolean', required: true },
|
|
6209
|
+
from: { type: 'string', required: true },
|
|
6210
|
+
to: { type: 'string', required: true },
|
|
6211
|
+
},
|
|
6212
|
+
},
|
|
6213
|
+
render: (_args, value) => {
|
|
6214
|
+
const v = value;
|
|
6215
|
+
return [{ type: 'text', text: `已将远程 ${v.from ?? '?'} 重命名/移动为 ${v.to ?? '?'}` }];
|
|
6171
6216
|
},
|
|
6172
6217
|
},
|
|
6173
|
-
|
|
6174
|
-
const
|
|
6175
|
-
|
|
6218
|
+
async execute(args) {
|
|
6219
|
+
const input = args;
|
|
6220
|
+
const spec = sftpBookSpec(input.book);
|
|
6221
|
+
if (typeof input.from !== 'string' || input.from.trim() === '')
|
|
6222
|
+
throw new Error('from 必须是非空字符串');
|
|
6223
|
+
if (typeof input.to !== 'string' || input.to.trim() === '')
|
|
6224
|
+
throw new Error('to 必须是非空字符串');
|
|
6225
|
+
await sftpManager.rename(spec, input.from, input.to);
|
|
6226
|
+
return { ok: true, from: input.from.trim(), to: input.to.trim() };
|
|
6176
6227
|
},
|
|
6177
|
-
}
|
|
6178
|
-
|
|
6179
|
-
|
|
6180
|
-
|
|
6181
|
-
|
|
6182
|
-
|
|
6183
|
-
|
|
6184
|
-
|
|
6185
|
-
|
|
6186
|
-
|
|
6187
|
-
|
|
6188
|
-
|
|
6189
|
-
|
|
6190
|
-
|
|
6191
|
-
|
|
6192
|
-
|
|
6193
|
-
|
|
6194
|
-
|
|
6195
|
-
|
|
6196
|
-
|
|
6197
|
-
|
|
6198
|
-
|
|
6199
|
-
type: 'object',
|
|
6200
|
-
additionalProperties: false,
|
|
6201
|
-
properties: {
|
|
6202
|
-
ok: { type: 'boolean', required: true },
|
|
6203
|
-
path: { type: 'string', required: true },
|
|
6204
|
-
recursive: { type: 'boolean', required: true },
|
|
6228
|
+
})));
|
|
6229
|
+
activeDisposers.push(registerTool(defineTool({
|
|
6230
|
+
name: 'sftp_remove',
|
|
6231
|
+
description: '删除 SSH 远程文件或目录(book 连接簿条目 + path)。文件直接删除;目录默认走 rmdir(非空明确报错),recursive:true 整目录递归删除(不可恢复,谨慎使用)。根目录 / home / 含 . .. 相对段的路径会被直接拒绝(防整树误删),请先解析出具体的绝对路径。',
|
|
6232
|
+
parameters: {
|
|
6233
|
+
book: { type: 'string', required: true, description: 'SSH 连接簿条目名' },
|
|
6234
|
+
path: { type: 'string', required: true, description: '要删除的远程路径' },
|
|
6235
|
+
recursive: { type: 'boolean', description: '目录 true 时递归删除全部内容(默认 false)' },
|
|
6236
|
+
},
|
|
6237
|
+
output: {
|
|
6238
|
+
schema: {
|
|
6239
|
+
type: 'object',
|
|
6240
|
+
additionalProperties: false,
|
|
6241
|
+
properties: {
|
|
6242
|
+
ok: { type: 'boolean', required: true },
|
|
6243
|
+
path: { type: 'string', required: true },
|
|
6244
|
+
recursive: { type: 'boolean', required: true },
|
|
6245
|
+
},
|
|
6246
|
+
},
|
|
6247
|
+
render: (_args, value) => {
|
|
6248
|
+
const v = value;
|
|
6249
|
+
return [{ type: 'text', text: `已删除远程 ${v.path ?? '?'}${v.recursive === true ? '(含全部内容)' : ''}` }];
|
|
6205
6250
|
},
|
|
6206
6251
|
},
|
|
6207
|
-
|
|
6208
|
-
const
|
|
6209
|
-
|
|
6252
|
+
async execute(args) {
|
|
6253
|
+
const input = args;
|
|
6254
|
+
const spec = sftpBookSpec(input.book);
|
|
6255
|
+
if (typeof input.path !== 'string' || input.path.trim() === '')
|
|
6256
|
+
throw new Error('path 必须是非空字符串');
|
|
6257
|
+
const recursive = input.recursive === true;
|
|
6258
|
+
// 护栏在 SftpManager.remove 里(agent 工具与面板 HTTP 路由共用同一道)
|
|
6259
|
+
await sftpManager.remove(spec, input.path, recursive);
|
|
6260
|
+
return { ok: true, path: input.path.trim(), recursive };
|
|
6210
6261
|
},
|
|
6211
|
-
}
|
|
6212
|
-
|
|
6213
|
-
|
|
6214
|
-
|
|
6215
|
-
|
|
6216
|
-
|
|
6217
|
-
|
|
6218
|
-
|
|
6219
|
-
|
|
6220
|
-
|
|
6221
|
-
|
|
6222
|
-
|
|
6223
|
-
|
|
6224
|
-
|
|
6225
|
-
|
|
6226
|
-
|
|
6227
|
-
|
|
6228
|
-
|
|
6229
|
-
|
|
6230
|
-
|
|
6231
|
-
|
|
6232
|
-
|
|
6233
|
-
|
|
6234
|
-
|
|
6235
|
-
|
|
6236
|
-
|
|
6237
|
-
|
|
6238
|
-
|
|
6239
|
-
|
|
6240
|
-
|
|
6241
|
-
|
|
6242
|
-
|
|
6243
|
-
items: {
|
|
6244
|
-
type: 'object',
|
|
6245
|
-
additionalProperties: false,
|
|
6246
|
-
properties: {
|
|
6247
|
-
path: { type: 'string', required: true },
|
|
6248
|
-
name: { type: 'string', required: true },
|
|
6249
|
-
depth: { type: 'number', required: true },
|
|
6250
|
-
isDir: { type: 'boolean', required: true },
|
|
6251
|
-
size: { type: 'number', required: true },
|
|
6252
|
-
mtime: { type: 'number', required: true },
|
|
6262
|
+
})));
|
|
6263
|
+
activeDisposers.push(registerTool(defineTool({
|
|
6264
|
+
name: 'sftp_tree',
|
|
6265
|
+
// 只读工具:与同轮其它工具并发执行(宿主默认把未声明的工具当独占,见项目级 ROADMAP 第 4 项)
|
|
6266
|
+
isConcurrencySafe: () => true,
|
|
6267
|
+
description: '递归列举 SSH 远程目录结构(book 连接簿条目 + path):深度优先、目录优先,maxDepth(1~8,默认 3)限层、maxEntries(1~2000,默认 500)限条数,超限 truncated:true;符号链接不跟随;读取失败的子目录列入 errors。适合先看远程项目结构再定位文件。',
|
|
6268
|
+
parameters: {
|
|
6269
|
+
book: { type: 'string', required: true, description: 'SSH 连接簿条目名' },
|
|
6270
|
+
path: { type: 'string', description: '远程目录路径(缺省 = 登录 home)' },
|
|
6271
|
+
maxDepth: { type: 'number', description: '最大下钻层数(1~8,默认 3)' },
|
|
6272
|
+
maxEntries: { type: 'number', description: '最大条目数(1~2000,默认 500)' },
|
|
6273
|
+
},
|
|
6274
|
+
output: {
|
|
6275
|
+
schema: {
|
|
6276
|
+
type: 'object',
|
|
6277
|
+
additionalProperties: false,
|
|
6278
|
+
properties: {
|
|
6279
|
+
path: { type: 'string', required: true },
|
|
6280
|
+
entries: {
|
|
6281
|
+
type: 'array',
|
|
6282
|
+
required: true,
|
|
6283
|
+
items: {
|
|
6284
|
+
type: 'object',
|
|
6285
|
+
additionalProperties: false,
|
|
6286
|
+
properties: {
|
|
6287
|
+
path: { type: 'string', required: true },
|
|
6288
|
+
name: { type: 'string', required: true },
|
|
6289
|
+
depth: { type: 'number', required: true },
|
|
6290
|
+
isDir: { type: 'boolean', required: true },
|
|
6291
|
+
size: { type: 'number', required: true },
|
|
6292
|
+
mtime: { type: 'number', required: true },
|
|
6293
|
+
},
|
|
6253
6294
|
},
|
|
6254
6295
|
},
|
|
6255
|
-
|
|
6256
|
-
|
|
6257
|
-
|
|
6258
|
-
|
|
6259
|
-
|
|
6260
|
-
|
|
6261
|
-
|
|
6262
|
-
|
|
6263
|
-
|
|
6264
|
-
|
|
6265
|
-
|
|
6296
|
+
truncated: { type: 'boolean', required: true },
|
|
6297
|
+
errors: {
|
|
6298
|
+
type: 'array',
|
|
6299
|
+
required: true,
|
|
6300
|
+
items: {
|
|
6301
|
+
type: 'object',
|
|
6302
|
+
additionalProperties: false,
|
|
6303
|
+
properties: {
|
|
6304
|
+
path: { type: 'string', required: true },
|
|
6305
|
+
message: { type: 'string', required: true },
|
|
6306
|
+
},
|
|
6266
6307
|
},
|
|
6267
6308
|
},
|
|
6268
6309
|
},
|
|
6269
6310
|
},
|
|
6311
|
+
render: (_args, value) => {
|
|
6312
|
+
const v = value;
|
|
6313
|
+
const entries = v.entries ?? [];
|
|
6314
|
+
if (entries.length === 0)
|
|
6315
|
+
return [{ type: 'text', text: `远程目录 ${v.path ?? '?'} 为空` }];
|
|
6316
|
+
// 截断提示要说清「往哪个方向缩」:只写「已截断」的话,agent 下一轮往往原样重跑、
|
|
6317
|
+
// 拿到同一份截断——缩小 maxDepth 逐层看,或对某个子目录再 sftp_tree 才拿得全
|
|
6318
|
+
const truncatedNote = v.truncated === true ? ',已截断——缩小 `maxDepth` 逐层看,或对某个子目录再 `sftp_tree`' : '';
|
|
6319
|
+
const head = `远程目录 ${v.path ?? '?'} 结构(${String(entries.length)} 项${truncatedNote}):`;
|
|
6320
|
+
const lines = entries.map((e) => {
|
|
6321
|
+
const indent = ' '.repeat(Math.max(0, e.depth - 1));
|
|
6322
|
+
const tail = e.isDir ? '/' : ' — ' + humanFileSize(e.size);
|
|
6323
|
+
return `${indent}- ${e.name}${tail}`;
|
|
6324
|
+
});
|
|
6325
|
+
for (const item of v.errors ?? [])
|
|
6326
|
+
lines.push(`! ${item.path}(${item.message})`);
|
|
6327
|
+
return [{ type: 'text', text: head + '\n' + lines.join('\n') }];
|
|
6328
|
+
},
|
|
6270
6329
|
},
|
|
6271
|
-
|
|
6272
|
-
const
|
|
6273
|
-
const
|
|
6274
|
-
|
|
6275
|
-
|
|
6276
|
-
|
|
6277
|
-
const lines = entries.map((e) => {
|
|
6278
|
-
const indent = ' '.repeat(Math.max(0, e.depth - 1));
|
|
6279
|
-
const tail = e.isDir ? '/' : ' — ' + humanFileSize(e.size);
|
|
6280
|
-
return `${indent}- ${e.name}${tail}`;
|
|
6330
|
+
async execute(args) {
|
|
6331
|
+
const input = args;
|
|
6332
|
+
const spec = sftpBookSpec(input.book);
|
|
6333
|
+
const result = await sftpManager.tree(spec, typeof input.path === 'string' ? input.path : '', {
|
|
6334
|
+
maxDepth: typeof input.maxDepth === 'number' && Number.isInteger(input.maxDepth) ? input.maxDepth : undefined,
|
|
6335
|
+
maxEntries: typeof input.maxEntries === 'number' && Number.isInteger(input.maxEntries) ? input.maxEntries : undefined,
|
|
6281
6336
|
});
|
|
6282
|
-
|
|
6283
|
-
lines.push(`! ${item.path}(${item.message})`);
|
|
6284
|
-
return [{ type: 'text', text: head + '\n' + lines.join('\n') }];
|
|
6337
|
+
return result;
|
|
6285
6338
|
},
|
|
6286
|
-
}
|
|
6287
|
-
|
|
6288
|
-
const input = args;
|
|
6289
|
-
const spec = sftpBookSpec(input.book);
|
|
6290
|
-
const result = await sftpManager.tree(spec, typeof input.path === 'string' ? input.path : '', {
|
|
6291
|
-
maxDepth: typeof input.maxDepth === 'number' && Number.isInteger(input.maxDepth) ? input.maxDepth : undefined,
|
|
6292
|
-
maxEntries: typeof input.maxEntries === 'number' && Number.isInteger(input.maxEntries) ? input.maxEntries : undefined,
|
|
6293
|
-
});
|
|
6294
|
-
return result;
|
|
6295
|
-
},
|
|
6296
|
-
})));
|
|
6339
|
+
})));
|
|
6340
|
+
}
|
|
6297
6341
|
// 档位闸与工具同生命周期:enabled 热切换撤工具时一并撤下(activeDisposers 统一管理)。
|
|
6298
6342
|
activeDisposers.push(attachTierGate(ctx, {
|
|
6299
6343
|
pkg: 'tty',
|
|
@@ -6301,13 +6345,29 @@ const plugin = definePlugin({
|
|
|
6301
6345
|
classify: (tool) => TTY_TIER_CLASS[tool],
|
|
6302
6346
|
}));
|
|
6303
6347
|
stateRef.toolsRegistered = true;
|
|
6348
|
+
// 这行是**能力全集**(所有可能注册的工具名,档位闸的分类对账从它取数),不是本次真注册集——
|
|
6349
|
+
// 运行期真注册集取决于 live.sshHosts / live.tunnels(见上面两处条件注册),下一行才是实况。
|
|
6304
6350
|
console.log('[dsh-tty] agent tools registered (tty_list, tty_open, tty_close, tty_run, tty_stats, tty_capture, tty_screen, tty_expect, tty_send, tunnel_list, tunnel_start, tunnel_stop, sftp_list, sftp_read, sftp_write, sftp_mkdir, sftp_rename, sftp_remove, sftp_tree)');
|
|
6351
|
+
console.log(`[dsh-tty] agent tools active (${registeredNames.join(', ')})`);
|
|
6305
6352
|
};
|
|
6306
6353
|
refreshToolsHook = registerAll;
|
|
6307
6354
|
registerAll();
|
|
6355
|
+
/*
|
|
6356
|
+
* 按会话档位在 **agent 作用域**上裁掉用不到的 write / exec 工具(试点,见 tier-restrict.ts)。
|
|
6357
|
+
*
|
|
6358
|
+
* 刻意放在 effect 体里、**不放进 registerAll**:热应用设置会重注册工具(先撤后挂),
|
|
6359
|
+
* 而重注册不该把已有 agent 的裁剪一并撤掉——`agent/created` 不会为老 agent 再发一次,
|
|
6360
|
+
* 撤了就再也裁不回来。与工具同生命周期的是**这一层订阅**,随插件卸载一起撤。
|
|
6361
|
+
*/
|
|
6362
|
+
const disposeTierRestrict = attachTierRestrict(ctx, {
|
|
6363
|
+
pkg: 'tty',
|
|
6364
|
+
prefixes: TTY_TIER_PREFIXES,
|
|
6365
|
+
classify: (tool) => TTY_TIER_CLASS[tool],
|
|
6366
|
+
});
|
|
6308
6367
|
return () => {
|
|
6309
6368
|
stateRef.toolsRegistered = false;
|
|
6310
6369
|
refreshToolsHook = () => { };
|
|
6370
|
+
disposeTierRestrict();
|
|
6311
6371
|
for (const dispose of activeDisposers) {
|
|
6312
6372
|
try {
|
|
6313
6373
|
dispose();
|
|
@@ -6395,7 +6455,7 @@ const plugin = definePlugin({
|
|
|
6395
6455
|
return head + tail + hiddenNote + '\n' + shellLine;
|
|
6396
6456
|
},
|
|
6397
6457
|
});
|
|
6398
|
-
sectionDisposable = systemPrompt
|
|
6458
|
+
sectionDisposable = registerLiteralSection(systemPrompt, { name: 'plugin:dsh-tty', order: 150, text: TTY_GUIDANCE });
|
|
6399
6459
|
};
|
|
6400
6460
|
refreshAnnouncementHook = rebuild;
|
|
6401
6461
|
rebuild();
|