@xiaoqiong0v0/opencode-ssh-tool 0.3.0 → 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";
@@ -13,7 +15,7 @@ import { SshSession } from "./session.js";
13
15
  import { LocalSession } from "./local-session.js";
14
16
  import {} from "./server.js";
15
17
  import { ensureServer } from "./server-manager.js";
16
- import { writeSessionState, removeSessionState } from "./session-store.js";
18
+ import { writeSessionState, removeSessionState, removeAllSessionStates } from "./session-store.js";
17
19
  const log = createLogger("opencode-ssh-tool");
18
20
  /** 默认终端名(不传 name 时用) */
19
21
  const DEFAULT_NAME = "default";
@@ -136,10 +138,16 @@ function cleanupAllLocalSessions(sessionID) {
136
138
  if (map) {
137
139
  for (const s of map.values())
138
140
  s.close();
139
- for (const name of map.keys())
140
- removeSessionState(cacheRoot(), sessionID, name);
141
141
  localSessions.delete(sessionID);
142
142
  }
143
+ // 无论内存是否有句柄都清残留:状态文件 + 历史目录(含其他进程建的会话)
144
+ removeAllSessionStates(cacheRoot(), sessionID);
145
+ try {
146
+ rmSync(join(cacheRoot(), sessionID), { recursive: true, force: true });
147
+ }
148
+ catch {
149
+ /* 忽略 */
150
+ }
143
151
  }
144
152
  /** 插件缓存根目录(历史消息对存文件,随会话清理) */
145
153
  function cacheRoot() {
@@ -173,17 +181,15 @@ function cleanupAllSessions(sessionID) {
173
181
  if (map) {
174
182
  for (const s of map.values())
175
183
  s.close();
176
- for (const name of map.keys())
177
- removeSessionState(cacheRoot(), sessionID, name);
178
184
  sshSessions.delete(sessionID);
179
185
  }
180
- else {
181
- try {
182
- rmSync(join(cacheRoot(), sessionID), { recursive: true, force: true });
183
- }
184
- catch {
185
- /* 忽略 */
186
- }
186
+ // 无论内存是否有句柄都清残留:状态文件 + 历史目录(含其他进程建的会话)
187
+ removeAllSessionStates(cacheRoot(), sessionID);
188
+ try {
189
+ rmSync(join(cacheRoot(), sessionID), { recursive: true, force: true });
190
+ }
191
+ catch {
192
+ /* 忽略 */
187
193
  }
188
194
  }
189
195
  export const OpenCodeSshTool = async () => {
@@ -196,7 +202,7 @@ export const OpenCodeSshTool = async () => {
196
202
  const decide = createDecider(cfg.permission.deny, cfg.permission.allow);
197
203
  if (cfg.server.enabled) {
198
204
  try {
199
- const sr = await ensureServer(cfg.server.port, listSessionEntries, cacheRoot());
205
+ const sr = await ensureServer(cfg.server.port, listSessionEntries, cacheRoot(), getLang(cfg.webLang));
200
206
  if (sr.server) {
201
207
  httpServer = sr.server;
202
208
  httpUrl = sr.url;
@@ -217,6 +223,236 @@ export const OpenCodeSshTool = async () => {
217
223
  else {
218
224
  log.info("HTTP 服务未启用(配置 server.enabled=false)");
219
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
+ }
220
456
  return {
221
457
  event: async ({ event }) => {
222
458
  // 会话创建/更新时记录标题与目录(供 HTTP 页面显示会话名称)
@@ -233,8 +469,9 @@ export const OpenCodeSshTool = async () => {
233
469
  }
234
470
  // 会话删除时清理:关闭全部终端连接 + 删缓存目录 + 删元信息
235
471
  if (event.type === "session.deleted") {
236
- const info = event.properties?.info;
237
- const sid = info?.id;
472
+ const props = event.properties;
473
+ // sessionID 直接挂在 properties 层(完整事件);info.id 为兜底
474
+ const sid = props?.sessionID ?? props?.info?.id;
238
475
  if (sid) {
239
476
  cleanupAllSessions(sid);
240
477
  cleanupAllLocalSessions(sid);
@@ -244,314 +481,13 @@ export const OpenCodeSshTool = async () => {
244
481
  }
245
482
  },
246
483
  tool: {
247
- ssh_connect: tool({
248
- description: T.ssh_connect[lang],
249
- args: {
250
- host: tool.schema.string().describe(T.ssh_connect_args.host[lang]),
251
- user: tool.schema.string().describe(T.ssh_connect_args.user[lang]),
252
- port: tool.schema.number().optional().describe(T.ssh_connect_args.port[lang]),
253
- name: tool.schema.string().optional().describe(T.ssh_connect_args.name[lang]),
254
- password: tool.schema.string().optional().describe(T.ssh_connect_args.password[lang]),
255
- },
256
- async execute(args, context) {
257
- const { sessionID } = context;
258
- const name = args.name ?? DEFAULT_NAME;
259
- if (sanitizeName(name) === null) {
260
- return tr("invalid_name", lang);
261
- }
262
- try {
263
- await context.ask({
264
- permission: "ssh_connect",
265
- patterns: [`${args.user}@${args.host}:${args.port ?? 22}`],
266
- always: [`ssh_connect:${args.user}@${args.host}:${args.port ?? 22}`],
267
- metadata: { title: `${args.user}@${args.host}` },
268
- });
269
- }
270
- catch {
271
- return tr("rejected_connect", lang);
272
- }
273
- // 同名终端已有连接 → 先关旧的
274
- const map = getSessionMap(sessionID);
275
- const old = map?.get(name);
276
- if (old) {
277
- old.close();
278
- map.delete(name);
279
- }
280
- const session = new SshSession(sessionID, new SessionHistory(join(cacheRoot(), sessionID), name, cfg.history.maxMessages), name);
281
- const result = await session.connect({ host: args.host, user: args.user, port: args.port, password: args.password });
282
- log.tool("ssh_connect", { host: args.host, user: args.user, port: args.port ?? 22, name });
283
- if (!result.ok) {
284
- session.close(); // 关闭连接 + 清理 history 缓存目录
285
- return {
286
- title: tr("connect_title_fail", lang),
287
- output: result.error ?? tr("unknown_error", lang),
288
- };
289
- }
290
- if (!map)
291
- sshSessions.set(sessionID, new Map());
292
- sshSessions.get(sessionID).set(name, session);
293
- syncSessionState(sessionID, name);
294
- return {
295
- title: `${tr("connect_title_ok", lang)} ${args.user}@${args.host}`,
296
- output: tr("connect_ok", lang)
297
- .replace("{user}", args.user)
298
- .replace("{host}", args.host)
299
- .replace("{port}", String(args.port ?? 22))
300
- .replace("{sid}", sessionID)
301
- .replace("{name}", name),
302
- metadata: { host: args.host, user: args.user, port: args.port ?? 22, name },
303
- };
304
- },
305
- }),
306
- term_exec: tool({
307
- description: T.term_exec[lang],
308
- args: {
309
- command: tool.schema.string().describe(T.term_exec_args.command[lang]),
310
- waitResult: tool.schema.boolean().optional().describe(T.term_exec_args.waitResult[lang]),
311
- name: tool.schema.string().optional().describe(T.term_exec_args.name[lang]),
312
- },
313
- async execute(args, context) {
314
- const { sessionID } = context;
315
- const session = resolveSession(sessionID, args.name);
316
- if (!session)
317
- return tr("not_connected", lang);
318
- // 权限判定:先黑后白再问(SSH 与本地/容器一致)
319
- const decision = decide(args.command);
320
- if (decision === "deny") {
321
- log.tool("term_exec_denied", { command: args.command });
322
- return tr("denied_danger", lang);
323
- }
324
- if (decision === "ask") {
325
- log.info(`[ask] 命令需审批,调用 context.ask: ${args.command.slice(0, 60)}`);
326
- try {
327
- await context.ask({
328
- permission: "term_exec",
329
- patterns: [args.command],
330
- always: [`term_exec:${args.command}`],
331
- metadata: { title: args.command.slice(0, 60) },
332
- });
333
- log.info(`[ask] context.ask 返回(已允许): ${args.command.slice(0, 60)}`);
334
- }
335
- catch {
336
- log.info(`[ask] context.ask 抛异常(已拒绝): ${args.command.slice(0, 60)}`);
337
- return tr("denied", lang);
338
- }
339
- }
340
- // 默认异步提交(立即返回,不占上下文);waitResult=true 同步等结果
341
- const result = args.waitResult ? await session.exec(args.command) : await session.submit(args.command);
342
- syncSessionState(sessionID, args.name ?? DEFAULT_NAME);
343
- log.tool("term_exec", { command: args.command, ok: result.ok, submitted: result.submitted, interactive: result.interactive, running: result.running });
344
- const meta = {
345
- host: ("host" in result ? result.host : undefined),
346
- command: result.command,
347
- duration: result.duration,
348
- submitted: result.submitted,
349
- interactive: result.interactive,
350
- running: result.running,
351
- timeout: result.timeout,
352
- };
353
- if (result.submitted) {
354
- return { title: tr("exec_title", lang).replace("{cmd}", args.command.slice(0, 60)), output: tr("submitted", lang), metadata: meta };
355
- }
356
- return {
357
- title: tr("exec_title", lang).replace("{cmd}", args.command.slice(0, 60)),
358
- output: result.ok ? result.output : result.error ?? tr("exec_failed", lang),
359
- metadata: meta,
360
- };
361
- },
362
- }),
363
- term_read: tool({
364
- description: T.term_read[lang],
365
- args: {
366
- source: tool.schema.enum(["buffer", "history"]).optional().describe(T.term_read_args.source[lang]),
367
- lines: tool.schema.number().optional().describe(T.term_read_args.lines[lang]),
368
- direction: tool.schema.enum(["tail", "head"]).optional().describe(T.term_read_args.direction[lang]),
369
- limit: tool.schema.number().optional().describe(T.term_read_args.limit[lang]),
370
- includeCommand: tool.schema.boolean().optional().describe(T.term_read_args.includeCommand[lang]),
371
- name: tool.schema.string().optional().describe(T.term_read_args.name[lang]),
372
- },
373
- async execute(args, context) {
374
- const session = resolveSession(context.sessionID, args.name);
375
- if (!session)
376
- return tr("not_connected", lang);
377
- const source = args.source ?? "history";
378
- if (source === "buffer") {
379
- // 实时未消费缓冲
380
- const r = await session.readBuffer();
381
- const out = args.lines ? r.output.split(/\r?\n/).slice(0, args.lines).join("\n") : r.output;
382
- return { title: tr("session_output_title", lang), output: out };
383
- }
384
- // history:已完成命令+输出对(前 N / 后 N)
385
- const history = session.getHistory();
386
- const all = history.getPairs();
387
- const limit = Math.max(1, args.limit ?? 10);
388
- const direction = args.direction ?? "tail";
389
- const selected = direction === "tail" ? all.slice(-limit) : all.slice(0, limit);
390
- const total = all.length;
391
- const includeCommand = args.includeCommand ?? false;
392
- const text = selected
393
- .map((p) => (includeCommand ? `$ ${p.command}\n${history.readOutput(p)}` : history.readOutput(p)))
394
- .join("\n");
395
- const browserLine = httpUrl
396
- ? tr("browser_full_record", lang).replace("{url}", httpUrl)
397
- : tr("server_not_enabled", lang);
398
- const titleKey = direction === "tail" ? "history_title" : "history_title_head";
399
- const st = session.getStatus();
400
- return {
401
- title: tr(titleKey, lang).replace("{n}", String(selected.length)).replace("{total}", String(total)),
402
- output: text + browserLine,
403
- metadata: {
404
- direction,
405
- returned: selected.length,
406
- total,
407
- sessionID: context.sessionID,
408
- host: "host" in st ? st.host : undefined,
409
- },
410
- };
411
- },
412
- }),
413
- term_send: tool({
414
- description: T.term_send[lang],
415
- args: {
416
- text: tool.schema.string().describe(T.term_send_args.text[lang]),
417
- name: tool.schema.string().optional().describe(T.term_send_args.name[lang]),
418
- },
419
- async execute(args, context) {
420
- const session = resolveSession(context.sessionID, args.name);
421
- if (!session)
422
- return tr("not_connected", lang);
423
- const r = session.send(args.text);
424
- if (!r.ok)
425
- return tr("err_not_connected", lang);
426
- syncSessionState(context.sessionID, args.name ?? DEFAULT_NAME);
427
- log.tool("term_send", { text: args.text.slice(0, 60), name: args.name ?? DEFAULT_NAME });
428
- return { title: tr("send_title", lang), output: tr("send_ok", lang).replace("{text}", args.text.slice(0, 60)) };
429
- },
430
- }),
431
- term_status: tool({
432
- description: T.term_status[lang],
433
- args: {
434
- name: tool.schema.string().optional().describe(T.term_status_args.name[lang]),
435
- },
436
- async execute(args, context) {
437
- // 合并 SSH 与本地/容器会话状态
438
- const smap = getSessionMap(context.sessionID);
439
- const lmap = getLocalSessionMap(context.sessionID);
440
- if ((!smap || smap.size === 0) && (!lmap || lmap.size === 0))
441
- return tr("no_sessions", lang);
442
- let sessions;
443
- if (args.name) {
444
- const s = resolveSession(context.sessionID, args.name);
445
- if (!s)
446
- return tr("no_sessions", lang);
447
- sessions = [s];
448
- }
449
- else {
450
- sessions = [...(smap?.values() ?? []), ...(lmap?.values() ?? [])];
451
- }
452
- const parts = [];
453
- for (const s of sessions) {
454
- const st = s.getStatus();
455
- if (!st.connected) {
456
- parts.push(tr("session_disconnected", lang));
457
- continue;
458
- }
459
- // SSH 显示 host@user:port;本地/容器显示 program
460
- const connLine = "host" in st && st.host
461
- ? `host: ${st.host}@${st.user}:${st.port}`
462
- : `program: ${st.program ?? "-"}`;
463
- parts.push([
464
- `name: ${st.name ?? "default"}`,
465
- `type: ${"host" in st ? "ssh" : "local"}`,
466
- `connected: ${st.connected}`,
467
- `busy: ${st.busy}`,
468
- `pending: ${st.pending} bytes`,
469
- connLine,
470
- st.lastActive ? `lastActive: ${new Date(st.lastActive).toISOString()}` : null,
471
- st.connectedAt ? `connectedAt: ${new Date(st.connectedAt).toISOString()}` : null,
472
- ].filter(Boolean).join("\n"));
473
- parts.push(st.busy ? tr("status_busy_hint", lang) : tr("status_idle_hint", lang));
474
- }
475
- // 并入 HTTP 服务状态
476
- const serverLines = httpUrl
477
- ? [
478
- "",
479
- `httpServer: ${httpUrl}`,
480
- `activeSessions: ${listSessionEntries().length}`,
481
- ]
482
- : ["", "httpServer: disabled"];
483
- return { title: tr("status_title", lang), output: parts.join("\n\n") + serverLines.join("\n") };
484
- },
485
- }),
486
- term_disconnect: tool({
487
- description: T.term_disconnect[lang],
488
- args: {
489
- name: tool.schema.string().optional().describe(T.term_disconnect_args.name[lang]),
490
- },
491
- async execute(args, context) {
492
- const { sessionID } = context;
493
- const smap = getSessionMap(sessionID);
494
- const lmap = getLocalSessionMap(sessionID);
495
- if ((!smap || smap.size === 0) && (!lmap || lmap.size === 0))
496
- return tr("no_sessions", lang);
497
- // 未指定 name → 断开全部终端(SSH + 本地);否则断开指定终端(自动识别类型)
498
- if (!args.name) {
499
- const firstHost = [...(smap?.values() ?? [])][0]?.getStatus().host;
500
- cleanupAllSessions(sessionID);
501
- cleanupAllLocalSessions(sessionID);
502
- return tr("disconnected_all_ok", lang).replace("{host}", firstHost ?? "-");
503
- }
504
- const ssh = getSession(sessionID, args.name);
505
- if (ssh) {
506
- const host = ssh.getStatus().host;
507
- cleanupSession(sessionID, args.name);
508
- return tr("disconnected_ok", lang).replace("{host}", host ?? "-");
509
- }
510
- const local = getLocalSession(sessionID, args.name);
511
- if (local) {
512
- const program = local.getStatus().program;
513
- cleanupLocalSession(sessionID, args.name);
514
- return tr("disconnected_ok", lang).replace("{host}", program ?? "-");
515
- }
516
- return tr("no_sessions", lang);
517
- },
518
- }),
519
- local_connect: tool({
520
- description: T.local_connect[lang],
484
+ ssh_cli: tool({
485
+ description: T.ssh_cli[lang],
521
486
  args: {
522
- command: tool.schema.string().describe(T.local_connect_args.command[lang]),
523
- name: tool.schema.string().optional().describe(T.local_connect_args.name[lang]),
524
- cwd: tool.schema.string().optional().describe(T.local_connect_args.cwd[lang]),
487
+ args: tool.schema.string().optional().describe(T.ssh_cli_args[lang]),
525
488
  },
526
489
  async execute(args, context) {
527
- const { sessionID } = context;
528
- const name = args.name ?? DEFAULT_NAME;
529
- if (sanitizeName(name) === null) {
530
- return tr("invalid_name", lang);
531
- }
532
- // 同名终端已有 → 先关旧的
533
- const map = getLocalSessionMap(sessionID);
534
- const old = map?.get(name);
535
- if (old) {
536
- old.close();
537
- map.delete(name);
538
- }
539
- const session = new LocalSession(sessionID, new SessionHistory(join(cacheRoot(), sessionID), `local-${name}`, cfg.history.maxMessages), name);
540
- const result = await session.connect({ command: args.command, cwd: args.cwd });
541
- log.tool("local_connect", { command: args.command, name });
542
- if (!result.ok) {
543
- session.close();
544
- return { title: tr("local_connect_title_fail", lang), output: result.error ?? tr("unknown_error", lang) };
545
- }
546
- if (!map)
547
- localSessions.set(sessionID, new Map());
548
- localSessions.get(sessionID).set(name, session);
549
- syncSessionState(sessionID, name);
550
- return {
551
- title: tr("local_connect_title_ok", lang),
552
- output: tr("local_connect_ok", lang).replace("{cmd}", args.command).replace("{name}", name),
553
- metadata: { command: args.command, name },
554
- };
490
+ return { title: "ssh_cli", output: await handleCliCommand(args.args ?? "help", context) };
555
491
  },
556
492
  }),
557
493
  },