@xiaoqiong0v0/opencode-ssh-tool 0.2.7 → 0.4.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/dist/index.js CHANGED
@@ -1,5 +1,7 @@
1
1
  // 插件入口:导出 5 个 SSH 工具 + 会话生命周期管理 + HTTP 终端记录服务
2
2
  import { tool } from "@opencode-ai/plugin";
3
+ import stringArgv from "string-argv";
4
+ import { parseArgs } from "node:util";
3
5
  import createLogger from "@xiaoqiong0v0/opencode-plugin-logger";
4
6
  import { CACHE_DIR } from "./constants.js";
5
7
  import { homedir } from "node:os";
@@ -10,9 +12,10 @@ import { SessionHistory } from "./history.js";
10
12
  import { T, getLang, tr } from "./i18n.js";
11
13
  import { createDecider } from "./permission.js";
12
14
  import { SshSession } from "./session.js";
15
+ import { LocalSession } from "./local-session.js";
13
16
  import {} from "./server.js";
14
17
  import { ensureServer } from "./server-manager.js";
15
- import { writeSessionState, removeSessionState } from "./session-store.js";
18
+ import { writeSessionState, removeSessionState, removeAllSessionStates } from "./session-store.js";
16
19
  const log = createLogger("opencode-ssh-tool");
17
20
  /** 默认终端名(不传 name 时用) */
18
21
  const DEFAULT_NAME = "default";
@@ -32,17 +35,22 @@ function sanitizeName(name) {
32
35
  }
33
36
  /** 会话表:key = opencode sessionID → 内层 key = 终端名 → SshSession(内部状态,不导出) */
34
37
  const sshSessions = new Map();
38
+ /** 本地/容器会话表:key = sessionID → 终端名 → LocalSession(Bun.Terminal,内部状态) */
39
+ const localSessions = new Map();
35
40
  /** 会话元信息(标题/目录,供 HTTP 页面显示),key = sessionID */
36
41
  const sessionMeta = new Map();
37
42
  /** HTTP 终端记录服务句柄(本进程持有,可能为 null) */
38
43
  let httpServer = null;
39
44
  /** HTTP 服务已知地址(本进程启动或复用时记录,供展示) */
40
45
  let httpUrl = "";
41
- /** 进程退出兜底:关闭全部连接 + 关闭本进程持有的 HTTP 服务 */
46
+ /** 进程退出兜底:关闭全部连接(SSH + 本地终端)+ 关闭本进程持有的 HTTP 服务 */
42
47
  process.on("exit", () => {
43
48
  for (const map of sshSessions.values())
44
49
  for (const s of map.values())
45
50
  s.close();
51
+ for (const map of localSessions.values())
52
+ for (const s of map.values())
53
+ s.close();
46
54
  if (httpServer) {
47
55
  try {
48
56
  httpServer.close();
@@ -71,22 +79,25 @@ function listSessionEntries() {
71
79
  return entries;
72
80
  }
73
81
  /**
74
- * 同步某终端状态到共享文件(跨进程聚合展示用)
82
+ * 同步某终端状态到共享文件(跨进程聚合展示用,SSH 与本地/容器通用)
75
83
  * @param sessionID opencode 会话 ID
76
84
  * @param name 终端名(默认 default)
77
85
  */
78
86
  function syncSessionState(sessionID, name = DEFAULT_NAME) {
79
- const session = getSession(sessionID, name);
87
+ const session = resolveSession(sessionID, name);
80
88
  if (!session)
81
89
  return;
82
90
  const st = session.getStatus();
83
91
  const meta = sessionMeta.get(sessionID);
92
+ const isLocal = !("host" in st) || !st.host;
84
93
  writeSessionState(cacheRoot(), {
85
94
  sessionID,
86
95
  name,
87
- host: st.host,
88
- user: st.user,
89
- port: st.port,
96
+ kind: isLocal ? "local" : "ssh",
97
+ host: "host" in st ? st.host : undefined,
98
+ user: "user" in st ? st.user : undefined,
99
+ port: "port" in st ? st.port : undefined,
100
+ program: "program" in st ? st.program : undefined,
90
101
  connected: st.connected,
91
102
  busy: st.busy,
92
103
  pending: st.pending,
@@ -97,6 +108,47 @@ function syncSessionState(sessionID, name = DEFAULT_NAME) {
97
108
  updatedAt: Date.now(),
98
109
  });
99
110
  }
111
+ /** 按 sessionID 取本地/容器终端表(无则返回 undefined) */
112
+ function getLocalSessionMap(sessionID) {
113
+ return localSessions.get(sessionID);
114
+ }
115
+ /** 按 sessionID + 终端名取本地/容器会话(name 默认 default) */
116
+ function getLocalSession(sessionID, name = DEFAULT_NAME) {
117
+ return getLocalSessionMap(sessionID)?.get(name);
118
+ }
119
+ /** 解析终端会话:优先 SSH,其次本地/容器(合并后的操作工具统一用此查找) */
120
+ function resolveSession(sessionID, name = DEFAULT_NAME) {
121
+ return getSession(sessionID, name) ?? getLocalSession(sessionID, name);
122
+ }
123
+ /** 清理指定本地终端:关闭 + 删状态文件 + 从会话表移除 */
124
+ function cleanupLocalSession(sessionID, name = DEFAULT_NAME) {
125
+ const map = getLocalSessionMap(sessionID);
126
+ const session = map?.get(name);
127
+ if (session) {
128
+ session.close();
129
+ map.delete(name);
130
+ if (map.size === 0)
131
+ localSessions.delete(sessionID);
132
+ }
133
+ removeSessionState(cacheRoot(), sessionID, name);
134
+ }
135
+ /** 清理一个 sessionID 下全部本地终端 */
136
+ function cleanupAllLocalSessions(sessionID) {
137
+ const map = getLocalSessionMap(sessionID);
138
+ if (map) {
139
+ for (const s of map.values())
140
+ s.close();
141
+ localSessions.delete(sessionID);
142
+ }
143
+ // 无论内存是否有句柄都清残留:状态文件 + 历史目录(含其他进程建的会话)
144
+ removeAllSessionStates(cacheRoot(), sessionID);
145
+ try {
146
+ rmSync(join(cacheRoot(), sessionID), { recursive: true, force: true });
147
+ }
148
+ catch {
149
+ /* 忽略 */
150
+ }
151
+ }
100
152
  /** 插件缓存根目录(历史消息对存文件,随会话清理) */
101
153
  function cacheRoot() {
102
154
  const home = process.env.USERPROFILE || process.env.HOME || homedir();
@@ -129,17 +181,15 @@ function cleanupAllSessions(sessionID) {
129
181
  if (map) {
130
182
  for (const s of map.values())
131
183
  s.close();
132
- for (const name of map.keys())
133
- removeSessionState(cacheRoot(), sessionID, name);
134
184
  sshSessions.delete(sessionID);
135
185
  }
136
- else {
137
- try {
138
- rmSync(join(cacheRoot(), sessionID), { recursive: true, force: true });
139
- }
140
- catch {
141
- /* 忽略 */
142
- }
186
+ // 无论内存是否有句柄都清残留:状态文件 + 历史目录(含其他进程建的会话)
187
+ removeAllSessionStates(cacheRoot(), sessionID);
188
+ try {
189
+ rmSync(join(cacheRoot(), sessionID), { recursive: true, force: true });
190
+ }
191
+ catch {
192
+ /* 忽略 */
143
193
  }
144
194
  }
145
195
  export const OpenCodeSshTool = async () => {
@@ -152,7 +202,7 @@ export const OpenCodeSshTool = async () => {
152
202
  const decide = createDecider(cfg.permission.deny, cfg.permission.allow);
153
203
  if (cfg.server.enabled) {
154
204
  try {
155
- const sr = await ensureServer(cfg.server.port, listSessionEntries, cacheRoot());
205
+ const sr = await ensureServer(cfg.server.port, listSessionEntries, cacheRoot(), getLang(cfg.webLang));
156
206
  if (sr.server) {
157
207
  httpServer = sr.server;
158
208
  httpUrl = sr.url;
@@ -173,6 +223,236 @@ export const OpenCodeSshTool = async () => {
173
223
  else {
174
224
  log.info("HTTP 服务未启用(配置 server.enabled=false)");
175
225
  }
226
+ // ===== CLI 风格单工具(ssh_cli)子命令实现 =====
227
+ /** 完整用法(模型自学手册) */
228
+ const HELP_TEXT = tr("ssh_cli_help", lang);
229
+ /** connect:SSH 连接(target=user@host[:port]) */
230
+ async function doConnect(args, ctx) {
231
+ const { values, positionals } = parseArgs({ args, options: { name: { type: "string", short: "n" }, password: { type: "string", short: "p" }, user: { type: "string", short: "u" } }, allowPositionals: true });
232
+ const target = positionals[0] ?? "";
233
+ const m = target.match(/^(?:([^@]+)@)?([^:]+)(?::(\d+))?$/);
234
+ if (!m || !m[2])
235
+ return `${tr("cli_bad_target", lang).replace("{target}", target)}\n${tr("cli_connect_usage", lang)}\n\n${HELP_TEXT}`;
236
+ const user = m[1] ?? values.user;
237
+ const host = m[2];
238
+ const port = m[3] ? parseInt(m[3], 10) : undefined;
239
+ if (!user || !host)
240
+ return `${tr("cli_need_user_host", lang)}\n${tr("cli_connect_usage", lang)}\n\n${HELP_TEXT}`;
241
+ const name = values.name ?? DEFAULT_NAME;
242
+ if (sanitizeName(name) === null)
243
+ return tr("invalid_name", lang);
244
+ try {
245
+ await ctx.ask({
246
+ permission: "ssh_connect",
247
+ patterns: [`${user}@${host}:${port ?? 22}`],
248
+ always: [`ssh_connect:${user}@${host}:${port ?? 22}`],
249
+ metadata: { title: `${user}@${host}` },
250
+ });
251
+ }
252
+ catch {
253
+ return tr("rejected_connect", lang);
254
+ }
255
+ const map = getSessionMap(ctx.sessionID);
256
+ const old = map?.get(name);
257
+ if (old) {
258
+ old.close();
259
+ map.delete(name);
260
+ }
261
+ const session = new SshSession(ctx.sessionID, new SessionHistory(join(cacheRoot(), ctx.sessionID), name, cfg.history.maxMessages), name);
262
+ const result = await session.connect({ host, user, port, password: values.password });
263
+ log.tool("ssh_connect", { host, user, port: port ?? 22, name });
264
+ if (!result.ok) {
265
+ session.close();
266
+ return `${tr("connect_title_fail", lang)}: ${result.error ?? tr("unknown_error", lang)}`;
267
+ }
268
+ if (!map)
269
+ sshSessions.set(ctx.sessionID, new Map());
270
+ sshSessions.get(ctx.sessionID).set(name, session);
271
+ syncSessionState(ctx.sessionID, name);
272
+ return tr("connect_ok", lang).replace("{user}", user).replace("{host}", host).replace("{port}", String(port ?? 22)).replace("{sid}", ctx.sessionID).replace("{name}", name);
273
+ }
274
+ /** local:本地/容器终端连接 */
275
+ async function doLocal(args, ctx) {
276
+ const { values, positionals } = parseArgs({ args, options: { name: { type: "string", short: "n" }, cwd: { type: "string", short: "c" } }, allowPositionals: true });
277
+ const command = positionals.join(" ");
278
+ if (!command)
279
+ return `${tr("cli_need_command", lang)}\n${tr("cli_local_usage", lang)}\n\n${HELP_TEXT}`;
280
+ const name = values.name ?? DEFAULT_NAME;
281
+ if (sanitizeName(name) === null)
282
+ return tr("invalid_name", lang);
283
+ const map = getLocalSessionMap(ctx.sessionID);
284
+ const old = map?.get(name);
285
+ if (old) {
286
+ old.close();
287
+ map.delete(name);
288
+ }
289
+ const session = new LocalSession(ctx.sessionID, new SessionHistory(join(cacheRoot(), ctx.sessionID), `local-${name}`, cfg.history.maxMessages), name);
290
+ const result = await session.connect({ command, cwd: values.cwd });
291
+ log.tool("local_connect", { command, name });
292
+ if (!result.ok) {
293
+ session.close();
294
+ return `${tr("local_connect_title_fail", lang)}: ${result.error ?? tr("unknown_error", lang)}`;
295
+ }
296
+ if (!map)
297
+ localSessions.set(ctx.sessionID, new Map());
298
+ localSessions.get(ctx.sessionID).set(name, session);
299
+ syncSessionState(ctx.sessionID, name);
300
+ return tr("local_connect_ok", lang).replace("{cmd}", command).replace("{name}", name);
301
+ }
302
+ /** exec:在指定终端执行命令 */
303
+ async function doExec(args, ctx) {
304
+ const { values, positionals } = parseArgs({ args, options: { name: { type: "string", short: "n" }, waitResult: { type: "boolean", short: "w" } }, allowPositionals: true });
305
+ const command = positionals.join(" ");
306
+ if (!command)
307
+ return `${tr("cli_need_command", lang)}\n${tr("cli_exec_usage", lang)}\n\n${HELP_TEXT}`;
308
+ const session = resolveSession(ctx.sessionID, values.name);
309
+ if (!session)
310
+ return tr("not_connected", lang);
311
+ const decision = decide(command);
312
+ if (decision === "deny") {
313
+ log.tool("term_exec_denied", { command });
314
+ return tr("denied_danger", lang);
315
+ }
316
+ if (decision === "ask") {
317
+ try {
318
+ await ctx.ask({ permission: "term_exec", patterns: [command], always: [`term_exec:${command}`], metadata: { title: command.slice(0, 60) } });
319
+ }
320
+ catch {
321
+ return tr("denied", lang);
322
+ }
323
+ }
324
+ const result = values.waitResult ? await session.exec(command) : await session.submit(command);
325
+ syncSessionState(ctx.sessionID, values.name ?? DEFAULT_NAME);
326
+ log.tool("term_exec", { command, ok: result.ok, submitted: result.submitted, interactive: result.interactive, running: result.running });
327
+ if (result.submitted)
328
+ return tr("submitted", lang);
329
+ return result.ok ? result.output : result.error ?? tr("exec_failed", lang);
330
+ }
331
+ /** read:读取终端输出 */
332
+ async function doRead(args, ctx) {
333
+ const { values } = parseArgs({ args, options: { name: { type: "string", short: "n" }, source: { type: "string", short: "s" }, limit: { type: "string", short: "l" }, head: { type: "boolean" }, includeCommand: { type: "boolean" } }, allowPositionals: true });
334
+ const session = resolveSession(ctx.sessionID, values.name);
335
+ if (!session)
336
+ return tr("not_connected", lang);
337
+ const source = values.source ?? "history";
338
+ if (source === "buffer") {
339
+ const r = await session.readBuffer();
340
+ return r.output;
341
+ }
342
+ const history = session.getHistory();
343
+ const all = history.getPairs();
344
+ const limit = Math.max(1, parseInt(values.limit ?? "10", 10) || 10);
345
+ const direction = values.head ? "head" : "tail";
346
+ const selected = direction === "tail" ? all.slice(-limit) : all.slice(0, limit);
347
+ const includeCommand = values.includeCommand ?? false;
348
+ const text = selected.map((p) => (includeCommand ? `$ ${p.command}\n${history.readOutput(p)}` : history.readOutput(p))).join("\n");
349
+ const browserLine = httpUrl ? tr("browser_full_record", lang).replace("{url}", httpUrl) : tr("server_not_enabled", lang);
350
+ return `${tr(direction === "tail" ? "history_title" : "history_title_head", lang).replace("{n}", String(selected.length)).replace("{total}", String(all.length))}\n${text}${browserLine}`;
351
+ }
352
+ /** send:发送文本/按键到终端 */
353
+ async function doSend(args, ctx) {
354
+ const { values, positionals } = parseArgs({ args, options: { name: { type: "string", short: "n" } }, allowPositionals: true });
355
+ const text = positionals.join(" ");
356
+ if (!text)
357
+ return `${tr("cli_need_text", lang)}\n${tr("cli_send_usage", lang)}\n\n${HELP_TEXT}`;
358
+ const session = resolveSession(ctx.sessionID, values.name);
359
+ if (!session)
360
+ return tr("not_connected", lang);
361
+ const r = session.send(text);
362
+ if (!r.ok)
363
+ return tr("err_not_connected", lang);
364
+ syncSessionState(ctx.sessionID, values.name ?? DEFAULT_NAME);
365
+ log.tool("term_send", { text: text.slice(0, 60), name: values.name ?? DEFAULT_NAME });
366
+ return tr("send_ok", lang).replace("{text}", text.slice(0, 60));
367
+ }
368
+ /** status:终端状态 */
369
+ async function doStatus(args, ctx) {
370
+ const { values } = parseArgs({ args, options: { name: { type: "string", short: "n" } }, allowPositionals: true });
371
+ const smap = getSessionMap(ctx.sessionID);
372
+ const lmap = getLocalSessionMap(ctx.sessionID);
373
+ if ((!smap || smap.size === 0) && (!lmap || lmap.size === 0))
374
+ return tr("no_sessions", lang);
375
+ let sessions;
376
+ if (values.name) {
377
+ const s = resolveSession(ctx.sessionID, values.name);
378
+ if (!s)
379
+ return tr("no_sessions", lang);
380
+ sessions = [s];
381
+ }
382
+ else {
383
+ sessions = [...(smap?.values() ?? []), ...(lmap?.values() ?? [])];
384
+ }
385
+ const parts = [];
386
+ for (const s of sessions) {
387
+ const st = s.getStatus();
388
+ if (!st.connected) {
389
+ parts.push(tr("session_disconnected", lang));
390
+ continue;
391
+ }
392
+ const connLine = "host" in st && st.host
393
+ ? `${tr("st_host", lang)}: ${st.host}@${st.user}:${st.port}`
394
+ : `${tr("st_program", lang)}: ${st.program ?? "-"}`;
395
+ parts.push([
396
+ `${tr("st_name", lang)}: ${st.name ?? "default"}`,
397
+ `${tr("st_type", lang)}: ${"host" in st ? "ssh" : "local"}`,
398
+ `${tr("st_connected", lang)}: ${st.connected}`,
399
+ `${tr("st_busy", lang)}: ${st.busy}`,
400
+ `${tr("st_pending", lang)}: ${st.pending} ${tr("st_bytes", lang)}`,
401
+ connLine,
402
+ st.lastActive ? `${tr("st_last_active", lang)}: ${new Date(st.lastActive).toISOString()}` : null,
403
+ st.connectedAt ? `${tr("st_connected_at", lang)}: ${new Date(st.connectedAt).toISOString()}` : null,
404
+ ].filter(Boolean).join("\n"));
405
+ parts.push(st.busy ? tr("status_busy_hint", lang) : tr("status_idle_hint", lang));
406
+ }
407
+ const serverLines = httpUrl
408
+ ? ["", `${tr("st_http_server", lang)}: ${httpUrl}`, `${tr("st_active_sessions", lang)}: ${listSessionEntries().length}`]
409
+ : ["", `${tr("st_http_server", lang)}: ${tr("st_disabled", lang)}`];
410
+ return parts.join("\n\n") + serverLines.join("\n");
411
+ }
412
+ /** disconnect:断开终端 */
413
+ async function doDisconnect(args, ctx) {
414
+ const { values } = parseArgs({ args, options: { name: { type: "string", short: "n" } }, allowPositionals: true });
415
+ const smap = getSessionMap(ctx.sessionID);
416
+ const lmap = getLocalSessionMap(ctx.sessionID);
417
+ if ((!smap || smap.size === 0) && (!lmap || lmap.size === 0))
418
+ return tr("no_sessions", lang);
419
+ if (!values.name) {
420
+ const firstHost = [...(smap?.values() ?? [])][0]?.getStatus().host;
421
+ cleanupAllSessions(ctx.sessionID);
422
+ cleanupAllLocalSessions(ctx.sessionID);
423
+ return tr("disconnected_all_ok", lang).replace("{host}", firstHost ?? "-");
424
+ }
425
+ const ssh = getSession(ctx.sessionID, values.name);
426
+ if (ssh) {
427
+ const host = ssh.getStatus().host;
428
+ cleanupSession(ctx.sessionID, values.name);
429
+ return tr("disconnected_ok", lang).replace("{host}", host ?? "-");
430
+ }
431
+ const local = getLocalSession(ctx.sessionID, values.name);
432
+ if (local) {
433
+ const program = local.getStatus().program;
434
+ cleanupLocalSession(ctx.sessionID, values.name);
435
+ return tr("disconnected_ok", lang).replace("{host}", program ?? "-");
436
+ }
437
+ return tr("no_sessions", lang);
438
+ }
439
+ /** CLI 入口:解析命令行字符串并分发子命令 */
440
+ async function handleCliCommand(raw, ctx) {
441
+ const tokens = stringArgv(raw);
442
+ const [cmd, ...rest] = tokens;
443
+ if (!cmd || cmd === "help")
444
+ return HELP_TEXT;
445
+ switch (cmd) {
446
+ case "connect": return doConnect(rest, ctx);
447
+ case "local": return doLocal(rest, ctx);
448
+ case "exec": return doExec(rest, ctx);
449
+ case "read": return doRead(rest, ctx);
450
+ case "send": return doSend(rest, ctx);
451
+ case "status": return doStatus(rest, ctx);
452
+ case "disconnect": return doDisconnect(rest, ctx);
453
+ default: return `${tr("cli_unknown", lang).replace("{cmd}", cmd)}\n\n${HELP_TEXT}`;
454
+ }
455
+ }
176
456
  return {
177
457
  event: async ({ event }) => {
178
458
  // 会话创建/更新时记录标题与目录(供 HTTP 页面显示会话名称)
@@ -189,264 +469,25 @@ export const OpenCodeSshTool = async () => {
189
469
  }
190
470
  // 会话删除时清理:关闭全部终端连接 + 删缓存目录 + 删元信息
191
471
  if (event.type === "session.deleted") {
192
- const info = event.properties?.info;
193
- const sid = info?.id;
472
+ const props = event.properties;
473
+ // sessionID 直接挂在 properties 层(完整事件);info.id 为兜底
474
+ const sid = props?.sessionID ?? props?.info?.id;
194
475
  if (sid) {
195
476
  cleanupAllSessions(sid);
477
+ cleanupAllLocalSessions(sid);
196
478
  sessionMeta.delete(sid);
197
479
  log.info(`会话 ${sid} 删除,已清理`);
198
480
  }
199
481
  }
200
482
  },
201
483
  tool: {
202
- ssh_connect: tool({
203
- description: T.ssh_connect[lang],
204
- args: {
205
- host: tool.schema.string().describe(T.ssh_connect_args.host[lang]),
206
- user: tool.schema.string().describe(T.ssh_connect_args.user[lang]),
207
- port: tool.schema.number().optional().describe(T.ssh_connect_args.port[lang]),
208
- name: tool.schema.string().optional().describe(T.ssh_connect_args.name[lang]),
209
- password: tool.schema.string().optional().describe(T.ssh_connect_args.password[lang]),
210
- },
211
- async execute(args, context) {
212
- const { sessionID } = context;
213
- const name = args.name ?? DEFAULT_NAME;
214
- if (sanitizeName(name) === null) {
215
- return tr("invalid_name", lang);
216
- }
217
- try {
218
- await context.ask({
219
- permission: "ssh_connect",
220
- patterns: [`${args.user}@${args.host}:${args.port ?? 22}`],
221
- always: [`ssh_connect:${args.user}@${args.host}:${args.port ?? 22}`],
222
- metadata: { title: `${args.user}@${args.host}` },
223
- });
224
- }
225
- catch {
226
- return tr("rejected_connect", lang);
227
- }
228
- // 同名终端已有连接 → 先关旧的
229
- const map = getSessionMap(sessionID);
230
- const old = map?.get(name);
231
- if (old) {
232
- old.close();
233
- map.delete(name);
234
- }
235
- const session = new SshSession(sessionID, new SessionHistory(join(cacheRoot(), sessionID), name, cfg.history.maxMessages), name);
236
- const result = await session.connect({ host: args.host, user: args.user, port: args.port, password: args.password });
237
- log.tool("ssh_connect", { host: args.host, user: args.user, port: args.port ?? 22, name });
238
- if (!result.ok) {
239
- session.close(); // 关闭连接 + 清理 history 缓存目录
240
- return {
241
- title: tr("connect_title_fail", lang),
242
- output: result.error ?? tr("unknown_error", lang),
243
- };
244
- }
245
- if (!map)
246
- sshSessions.set(sessionID, new Map());
247
- sshSessions.get(sessionID).set(name, session);
248
- syncSessionState(sessionID, name);
249
- return {
250
- title: `${tr("connect_title_ok", lang)} ${args.user}@${args.host}`,
251
- output: tr("connect_ok", lang)
252
- .replace("{user}", args.user)
253
- .replace("{host}", args.host)
254
- .replace("{port}", String(args.port ?? 22))
255
- .replace("{sid}", sessionID)
256
- .replace("{name}", name),
257
- metadata: { host: args.host, user: args.user, port: args.port ?? 22, name },
258
- };
259
- },
260
- }),
261
- ssh_exec: tool({
262
- description: T.ssh_exec[lang],
263
- args: {
264
- command: tool.schema.string().describe(T.ssh_exec_args.command[lang]),
265
- waitResult: tool.schema.boolean().optional().describe(T.ssh_exec_args.waitResult[lang]),
266
- name: tool.schema.string().optional().describe(T.ssh_exec_args.name[lang]),
267
- },
268
- async execute(args, context) {
269
- const { sessionID } = context;
270
- const session = getSession(sessionID, args.name);
271
- if (!session)
272
- return tr("not_connected", lang);
273
- // 权限判定:先黑后白再问
274
- const decision = decide(args.command);
275
- if (decision === "deny") {
276
- log.tool("ssh_exec_denied", { command: args.command });
277
- return tr("denied_danger", lang);
278
- }
279
- if (decision === "ask") {
280
- log.info(`[ask] 命令需审批,调用 context.ask: ${args.command.slice(0, 60)}`);
281
- try {
282
- await context.ask({
283
- permission: "ssh_exec",
284
- patterns: [args.command],
285
- always: [`ssh_exec:${args.command}`],
286
- metadata: { title: args.command.slice(0, 60) },
287
- });
288
- log.info(`[ask] context.ask 返回(已允许): ${args.command.slice(0, 60)}`);
289
- }
290
- catch {
291
- log.info(`[ask] context.ask 抛异常(已拒绝): ${args.command.slice(0, 60)}`);
292
- return tr("denied", lang);
293
- }
294
- }
295
- // 默认异步提交(立即返回,不占上下文);waitResult=true 同步等结果
296
- const result = args.waitResult ? await session.exec(args.command) : await session.submit(args.command);
297
- syncSessionState(sessionID, args.name ?? DEFAULT_NAME);
298
- log.tool("ssh_exec", { command: args.command, ok: result.ok, submitted: result.submitted, interactive: result.interactive, running: result.running });
299
- const meta = {
300
- host: result.host,
301
- command: result.command,
302
- duration: result.duration,
303
- submitted: result.submitted,
304
- interactive: result.interactive,
305
- running: result.running,
306
- timeout: result.timeout,
307
- };
308
- if (result.submitted) {
309
- return { title: tr("exec_title", lang).replace("{cmd}", args.command.slice(0, 60)), output: tr("submitted", lang), metadata: meta };
310
- }
311
- return {
312
- title: tr("exec_title", lang).replace("{cmd}", args.command.slice(0, 60)),
313
- output: result.ok ? result.output : result.error ?? tr("exec_failed", lang),
314
- metadata: meta,
315
- };
316
- },
317
- }),
318
- ssh_read: tool({
319
- description: T.ssh_read[lang],
320
- args: {
321
- source: tool.schema.enum(["buffer", "history"]).optional().describe(T.ssh_read_args.source[lang]),
322
- lines: tool.schema.number().optional().describe(T.ssh_read_args.lines[lang]),
323
- direction: tool.schema.enum(["tail", "head"]).optional().describe(T.ssh_read_args.direction[lang]),
324
- limit: tool.schema.number().optional().describe(T.ssh_read_args.limit[lang]),
325
- includeCommand: tool.schema.boolean().optional().describe(T.ssh_read_args.includeCommand[lang]),
326
- name: tool.schema.string().optional().describe(T.ssh_read_args.name[lang]),
327
- },
328
- async execute(args, context) {
329
- const session = getSession(context.sessionID, args.name);
330
- if (!session)
331
- return tr("not_connected", lang);
332
- const source = args.source ?? "history";
333
- if (source === "buffer") {
334
- // 实时未消费缓冲
335
- const r = await session.readBuffer();
336
- const out = args.lines ? r.output.split(/\r?\n/).slice(0, args.lines).join("\n") : r.output;
337
- return { title: tr("session_output_title", lang), output: out };
338
- }
339
- // history:已完成命令+输出对(前 N / 后 N)
340
- const history = session.getHistory();
341
- const all = history.getPairs();
342
- const limit = Math.max(1, args.limit ?? 10);
343
- const direction = args.direction ?? "tail";
344
- const selected = direction === "tail" ? all.slice(-limit) : all.slice(0, limit);
345
- const total = all.length;
346
- const includeCommand = args.includeCommand ?? false;
347
- const text = selected
348
- .map((p) => (includeCommand ? `$ ${p.command}\n${history.readOutput(p)}` : history.readOutput(p)))
349
- .join("\n");
350
- const browserLine = httpUrl
351
- ? tr("browser_full_record", lang).replace("{url}", httpUrl)
352
- : tr("server_not_enabled", lang);
353
- const titleKey = direction === "tail" ? "history_title" : "history_title_head";
354
- return {
355
- title: tr(titleKey, lang).replace("{n}", String(selected.length)).replace("{total}", String(total)),
356
- output: text + browserLine,
357
- metadata: {
358
- direction,
359
- returned: selected.length,
360
- total,
361
- sessionID: context.sessionID,
362
- host: session.getStatus().host,
363
- },
364
- };
365
- },
366
- }),
367
- ssh_send: tool({
368
- description: T.ssh_send[lang],
369
- args: {
370
- text: tool.schema.string().describe(T.ssh_send_args.text[lang]),
371
- name: tool.schema.string().optional().describe(T.ssh_send_args.name[lang]),
372
- },
373
- async execute(args, context) {
374
- const session = getSession(context.sessionID, args.name);
375
- if (!session)
376
- return tr("not_connected", lang);
377
- const r = session.send(args.text);
378
- if (!r.ok)
379
- return tr("err_not_connected", lang);
380
- syncSessionState(context.sessionID, args.name ?? DEFAULT_NAME);
381
- log.tool("ssh_send", { text: args.text.slice(0, 60), name: args.name ?? DEFAULT_NAME });
382
- return { title: tr("send_title", lang), output: tr("send_ok", lang).replace("{text}", args.text.slice(0, 60)) };
383
- },
384
- }),
385
- ssh_status: tool({
386
- description: T.ssh_status[lang],
387
- args: {
388
- name: tool.schema.string().optional().describe(T.ssh_status_args.name[lang]),
389
- },
390
- async execute(args, context) {
391
- const map = getSessionMap(context.sessionID);
392
- if (!map || map.size === 0)
393
- return tr("no_sessions", lang);
394
- // 未指定 name → 列出全部终端状态
395
- const session = args.name ? map.get(args.name) : undefined;
396
- if (args.name && !session)
397
- return tr("no_sessions", lang);
398
- const sessions = session ? [session] : [...map.values()];
399
- const parts = [];
400
- for (const s of sessions) {
401
- const st = s.getStatus();
402
- if (!st.connected) {
403
- parts.push(tr("session_disconnected", lang));
404
- continue;
405
- }
406
- parts.push([
407
- `name: ${st.name ?? "default"}`,
408
- `connected: ${st.connected}`,
409
- `busy: ${st.busy}`,
410
- `pending: ${st.pending} bytes`,
411
- `host: ${st.host}@${st.user}:${st.port}`,
412
- st.lastActive ? `lastActive: ${new Date(st.lastActive).toISOString()}` : null,
413
- st.connectedAt ? `connectedAt: ${new Date(st.connectedAt).toISOString()}` : null,
414
- ].filter(Boolean).join("\n"));
415
- parts.push(st.busy ? tr("status_busy_hint", lang) : tr("status_idle_hint", lang));
416
- }
417
- // 并入 HTTP 服务状态
418
- const serverLines = httpUrl
419
- ? [
420
- "",
421
- `httpServer: ${httpUrl}`,
422
- `activeSessions: ${listSessionEntries().length}`,
423
- ]
424
- : ["", "httpServer: disabled"];
425
- return { title: tr("status_title", lang), output: parts.join("\n\n") + serverLines.join("\n") };
426
- },
427
- }),
428
- ssh_disconnect: tool({
429
- description: T.ssh_disconnect[lang],
484
+ ssh_cli: tool({
485
+ description: T.ssh_cli[lang],
430
486
  args: {
431
- name: tool.schema.string().optional().describe(T.ssh_disconnect_args.name[lang]),
487
+ args: tool.schema.string().optional().describe(T.ssh_cli_args[lang]),
432
488
  },
433
489
  async execute(args, context) {
434
- const { sessionID } = context;
435
- const map = getSessionMap(sessionID);
436
- if (!map || map.size === 0)
437
- return tr("no_sessions", lang);
438
- // 未指定 name → 断开全部终端;否则断开指定终端
439
- if (!args.name) {
440
- const host = [...map.values()][0]?.getStatus().host;
441
- cleanupAllSessions(sessionID);
442
- return tr("disconnected_all_ok", lang).replace("{host}", host ?? "-");
443
- }
444
- const session = map.get(args.name);
445
- if (!session)
446
- return tr("no_sessions", lang);
447
- const host = session.getStatus().host;
448
- cleanupSession(sessionID, args.name);
449
- return tr("disconnected_ok", lang).replace("{host}", host ?? "-");
490
+ return { title: "ssh_cli", output: await handleCliCommand(args.args ?? "help", context) };
450
491
  },
451
492
  }),
452
493
  },