@xiaoqiong0v0/opencode-ssh-tool 0.3.0 → 0.5.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.
Files changed (68) hide show
  1. package/README.md +30 -24
  2. package/dist/base-session.d.ts +128 -0
  3. package/dist/base-session.d.ts.map +1 -0
  4. package/dist/base-session.js +414 -0
  5. package/dist/base-session.js.map +1 -0
  6. package/dist/config.d.ts +2 -0
  7. package/dist/config.d.ts.map +1 -1
  8. package/dist/config.js +5 -0
  9. package/dist/config.js.map +1 -1
  10. package/dist/constants.d.ts +27 -3
  11. package/dist/constants.d.ts.map +1 -1
  12. package/dist/constants.js +28 -3
  13. package/dist/constants.js.map +1 -1
  14. package/dist/history.d.ts +14 -5
  15. package/dist/history.d.ts.map +1 -1
  16. package/dist/history.js +25 -9
  17. package/dist/history.js.map +1 -1
  18. package/dist/i18n.d.ts +249 -196
  19. package/dist/i18n.d.ts.map +1 -1
  20. package/dist/i18n.js +171 -180
  21. package/dist/i18n.js.map +1 -1
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +320 -332
  24. package/dist/index.js.map +1 -1
  25. package/dist/last-match.d.ts +17 -0
  26. package/dist/last-match.d.ts.map +1 -0
  27. package/dist/last-match.js +63 -0
  28. package/dist/last-match.js.map +1 -0
  29. package/dist/local-session.d.ts +9 -91
  30. package/dist/local-session.d.ts.map +1 -1
  31. package/dist/local-session.js +46 -308
  32. package/dist/local-session.js.map +1 -1
  33. package/dist/log.d.ts +3 -0
  34. package/dist/log.d.ts.map +1 -0
  35. package/dist/log.js +5 -0
  36. package/dist/log.js.map +1 -0
  37. package/dist/server-manager.d.ts +3 -1
  38. package/dist/server-manager.d.ts.map +1 -1
  39. package/dist/server-manager.js +3 -2
  40. package/dist/server-manager.js.map +1 -1
  41. package/dist/server.d.ts +35 -6
  42. package/dist/server.d.ts.map +1 -1
  43. package/dist/server.js +362 -305
  44. package/dist/server.js.map +1 -1
  45. package/dist/session-store.d.ts +2 -0
  46. package/dist/session-store.d.ts.map +1 -1
  47. package/dist/session-store.js +15 -0
  48. package/dist/session-store.js.map +1 -1
  49. package/dist/session.d.ts +15 -108
  50. package/dist/session.d.ts.map +1 -1
  51. package/dist/session.js +69 -361
  52. package/dist/session.js.map +1 -1
  53. package/dist/shell-adapter.d.ts +20 -0
  54. package/dist/shell-adapter.d.ts.map +1 -0
  55. package/dist/shell-adapter.js +70 -0
  56. package/dist/shell-adapter.js.map +1 -0
  57. package/dist/ssh-auth.d.ts.map +1 -1
  58. package/dist/ssh-auth.js +1 -2
  59. package/dist/ssh-auth.js.map +1 -1
  60. package/dist/utils.d.ts +25 -24
  61. package/dist/utils.d.ts.map +1 -1
  62. package/dist/utils.js +277 -45
  63. package/dist/utils.js.map +1 -1
  64. package/dist/web/app.css +35 -0
  65. package/dist/web/app.js +2 -0
  66. package/dist/web/fonts/CaskaydiaCoveNerdFontMono-Regular.ttf +0 -0
  67. package/dist/web/index.html +24 -0
  68. package/package.json +8 -4
package/dist/index.js CHANGED
@@ -1,6 +1,8 @@
1
1
  // 插件入口:导出 5 个 SSH 工具 + 会话生命周期管理 + HTTP 终端记录服务
2
2
  import { tool } from "@opencode-ai/plugin";
3
- import createLogger from "@xiaoqiong0v0/opencode-plugin-logger";
3
+ import stringArgv from "string-argv";
4
+ import { parseArgs } from "node:util";
5
+ import log from "./log.js";
4
6
  import { CACHE_DIR } from "./constants.js";
5
7
  import { homedir } from "node:os";
6
8
  import { join } from "node:path";
@@ -8,13 +10,13 @@ import { rmSync } from "node:fs";
8
10
  import { loadConfig } from "./config.js";
9
11
  import { SessionHistory } from "./history.js";
10
12
  import { T, getLang, tr } from "./i18n.js";
13
+ import { toModelText } from "./utils.js";
11
14
  import { createDecider } from "./permission.js";
12
15
  import { SshSession } from "./session.js";
13
16
  import { LocalSession } from "./local-session.js";
14
17
  import {} from "./server.js";
15
18
  import { ensureServer } from "./server-manager.js";
16
- import { writeSessionState, removeSessionState } from "./session-store.js";
17
- const log = createLogger("opencode-ssh-tool");
19
+ import { writeSessionState, removeSessionState, removeAllSessionStates } from "./session-store.js";
18
20
  /** 默认终端名(不传 name 时用) */
19
21
  const DEFAULT_NAME = "default";
20
22
  /** 终端名合法字符(字母/数字/下划线/中划线/点),防止路径穿越 */
@@ -66,13 +68,18 @@ function getSessionMap(sessionID) {
66
68
  function getSession(sessionID, name = DEFAULT_NAME) {
67
69
  return getSessionMap(sessionID)?.get(name);
68
70
  }
69
- /** 扁平化会话条目列表(供 HTTP 服务展示) */
71
+ /** 扁平化会话条目列表(供 HTTP 服务展示,SSH + 本地/容器,kind 区分历史目录与展示) */
70
72
  function listSessionEntries() {
71
73
  const entries = [];
72
74
  for (const [sessionID, map] of sshSessions) {
73
75
  const meta = sessionMeta.get(sessionID);
74
76
  for (const [name, session] of map)
75
- entries.push({ sessionID, name, session, title: meta?.title, directory: meta?.directory });
77
+ entries.push({ sessionID, name, session, kind: "ssh", title: meta?.title, directory: meta?.directory });
78
+ }
79
+ for (const [sessionID, map] of localSessions) {
80
+ const meta = sessionMeta.get(sessionID);
81
+ for (const [name, session] of map)
82
+ entries.push({ sessionID, name, session, kind: "local", title: meta?.title, directory: meta?.directory });
76
83
  }
77
84
  return entries;
78
85
  }
@@ -136,10 +143,16 @@ function cleanupAllLocalSessions(sessionID) {
136
143
  if (map) {
137
144
  for (const s of map.values())
138
145
  s.close();
139
- for (const name of map.keys())
140
- removeSessionState(cacheRoot(), sessionID, name);
141
146
  localSessions.delete(sessionID);
142
147
  }
148
+ // 无论内存是否有句柄都清残留:状态文件 + 历史目录(含其他进程建的会话)
149
+ removeAllSessionStates(cacheRoot(), sessionID);
150
+ try {
151
+ rmSync(join(cacheRoot(), sessionID), { recursive: true, force: true });
152
+ }
153
+ catch {
154
+ /* 忽略 */
155
+ }
143
156
  }
144
157
  /** 插件缓存根目录(历史消息对存文件,随会话清理) */
145
158
  function cacheRoot() {
@@ -173,20 +186,39 @@ function cleanupAllSessions(sessionID) {
173
186
  if (map) {
174
187
  for (const s of map.values())
175
188
  s.close();
176
- for (const name of map.keys())
177
- removeSessionState(cacheRoot(), sessionID, name);
178
189
  sshSessions.delete(sessionID);
179
190
  }
180
- else {
181
- try {
182
- rmSync(join(cacheRoot(), sessionID), { recursive: true, force: true });
183
- }
184
- catch {
185
- /* 忽略 */
186
- }
191
+ // 无论内存是否有句柄都清残留:状态文件 + 历史目录(含其他进程建的会话)
192
+ removeAllSessionStates(cacheRoot(), sessionID);
193
+ try {
194
+ rmSync(join(cacheRoot(), sessionID), { recursive: true, force: true });
195
+ }
196
+ catch {
197
+ /* 忽略 */
187
198
  }
188
199
  }
189
200
  export const OpenCodeSshTool = async () => {
201
+ // 父子会话关系(sessionID → parentID;根的 parentID 为 undefined)
202
+ // 通过 session.created/updated 事件构建,用于根系查找(主子会话共享终端)
203
+ const childMap = new Map();
204
+ /**
205
+ * 沿 parentID 链找到根会话 ID(主子会话共享存储key)
206
+ * 输出异常时回退回原 sessionID
207
+ */
208
+ const findRoot = (sessionID) => {
209
+ let cur = sessionID;
210
+ const visited = new Set();
211
+ while (cur && !visited.has(cur)) {
212
+ visited.add(cur);
213
+ const parent = childMap.get(cur);
214
+ if (parent !== undefined && parent !== null) {
215
+ cur = parent;
216
+ continue;
217
+ }
218
+ break;
219
+ }
220
+ return cur;
221
+ };
190
222
  log.loaded();
191
223
  // 加载配置并启动 HTTP 终端记录服务(默认开启;端口 0=自动分配)
192
224
  const cfg = loadConfig();
@@ -196,7 +228,7 @@ export const OpenCodeSshTool = async () => {
196
228
  const decide = createDecider(cfg.permission.deny, cfg.permission.allow);
197
229
  if (cfg.server.enabled) {
198
230
  try {
199
- const sr = await ensureServer(cfg.server.port, listSessionEntries, cacheRoot());
231
+ const sr = await ensureServer(cfg.server.port, listSessionEntries, cacheRoot(), getLang(cfg.webLang));
200
232
  if (sr.server) {
201
233
  httpServer = sr.server;
202
234
  httpUrl = sr.url;
@@ -217,341 +249,297 @@ export const OpenCodeSshTool = async () => {
217
249
  else {
218
250
  log.info("HTTP 服务未启用(配置 server.enabled=false)");
219
251
  }
252
+ // ===== CLI 风格单工具(term_cli)子命令实现 =====
253
+ /** 完整用法(模型自学手册) */
254
+ const HELP_TEXT = tr("term_cli_help", lang);
255
+ /** connect:SSH 连接(target=user@host[:port]) */
256
+ async function doConnect(args, ctx) {
257
+ const { values, positionals } = parseArgs({ args, options: { name: { type: "string", short: "n" }, password: { type: "string", short: "p" }, user: { type: "string", short: "u" } }, allowPositionals: true });
258
+ const target = positionals[0] ?? "";
259
+ const m = target.match(/^(?:([^@]+)@)?([^:]+)(?::(\d+))?$/);
260
+ if (!m || !m[2])
261
+ return `${tr("cli_bad_target", lang).replace("{target}", target)}\n${tr("cli_connect_usage", lang)}\n\n${HELP_TEXT}`;
262
+ const user = m[1] ?? values.user;
263
+ const host = m[2];
264
+ const port = m[3] ? parseInt(m[3], 10) : undefined;
265
+ if (!user || !host)
266
+ return `${tr("cli_need_user_host", lang)}\n${tr("cli_connect_usage", lang)}\n\n${HELP_TEXT}`;
267
+ const name = values.name ?? DEFAULT_NAME;
268
+ if (sanitizeName(name) === null)
269
+ return tr("invalid_name", lang);
270
+ try {
271
+ await ctx.ask({
272
+ permission: "ssh_connect",
273
+ patterns: [`${user}@${host}:${port ?? 22}`],
274
+ always: [`ssh_connect:${user}@${host}:${port ?? 22}`],
275
+ metadata: { title: `${user}@${host}` },
276
+ });
277
+ }
278
+ catch {
279
+ return tr("rejected_connect", lang);
280
+ }
281
+ const map = getSessionMap(ctx.sessionID);
282
+ const old = map?.get(name);
283
+ if (old) {
284
+ old.close();
285
+ map.delete(name);
286
+ }
287
+ const session = new SshSession(ctx.sessionID, new SessionHistory(join(cacheRoot(), ctx.sessionID), name, cfg.history.maxMessages), name);
288
+ const result = await session.connect({ host, user, port, password: values.password });
289
+ log.tool("ssh_connect", { host, user, port: port ?? 22, name });
290
+ if (!result.ok) {
291
+ session.close();
292
+ return `${tr("connect_title_fail", lang)}: ${result.error ?? tr("unknown_error", lang)}`;
293
+ }
294
+ if (!map)
295
+ sshSessions.set(ctx.sessionID, new Map());
296
+ sshSessions.get(ctx.sessionID).set(name, session);
297
+ syncSessionState(ctx.sessionID, name);
298
+ return tr("connect_ok", lang).replace("{user}", user).replace("{host}", host).replace("{port}", String(port ?? 22)).replace("{sid}", ctx.sessionID).replace("{name}", name);
299
+ }
300
+ /** local:本地/容器终端连接 */
301
+ async function doLocal(args, ctx) {
302
+ const { values, positionals } = parseArgs({ args, options: { name: { type: "string", short: "n" }, cwd: { type: "string", short: "c" } }, allowPositionals: true });
303
+ const command = positionals.join(" ");
304
+ if (!command)
305
+ return `${tr("cli_need_command", lang)}\n${tr("cli_local_usage", lang)}\n\n${HELP_TEXT}`;
306
+ const name = values.name ?? DEFAULT_NAME;
307
+ if (sanitizeName(name) === null)
308
+ return tr("invalid_name", lang);
309
+ const map = getLocalSessionMap(ctx.sessionID);
310
+ const old = map?.get(name);
311
+ if (old) {
312
+ old.close();
313
+ map.delete(name);
314
+ }
315
+ const session = new LocalSession(ctx.sessionID, new SessionHistory(join(cacheRoot(), ctx.sessionID), `local-${name}`, cfg.history.maxMessages), name);
316
+ const result = await session.connect({ command, cwd: values.cwd });
317
+ log.tool("local_connect", { command, name });
318
+ if (!result.ok) {
319
+ session.close();
320
+ return `${tr("local_connect_title_fail", lang)}: ${result.error ?? tr("unknown_error", lang)}`;
321
+ }
322
+ if (!map)
323
+ localSessions.set(ctx.sessionID, new Map());
324
+ localSessions.get(ctx.sessionID).set(name, session);
325
+ syncSessionState(ctx.sessionID, name);
326
+ return tr("local_connect_ok", lang).replace("{cmd}", command).replace("{name}", name);
327
+ }
328
+ /** exec:在指定终端执行命令 */
329
+ async function doExec(args, ctx) {
330
+ const { values, positionals } = parseArgs({ args, options: { name: { type: "string", short: "n" }, waitResult: { type: "boolean", short: "w" } }, allowPositionals: true });
331
+ const command = positionals.join(" ");
332
+ if (!command)
333
+ return `${tr("cli_need_command", lang)}\n${tr("cli_exec_usage", lang)}\n\n${HELP_TEXT}`;
334
+ const session = resolveSession(ctx.sessionID, values.name);
335
+ if (!session)
336
+ return tr("not_connected", lang);
337
+ const decision = decide(command);
338
+ if (decision === "deny") {
339
+ log.tool("term_exec_denied", { command });
340
+ return tr("denied_danger", lang);
341
+ }
342
+ if (decision === "ask") {
343
+ try {
344
+ await ctx.ask({ permission: "term_exec", patterns: [command], always: [`term_exec:${command}`], metadata: { title: command.slice(0, 60) } });
345
+ }
346
+ catch {
347
+ return tr("denied", lang);
348
+ }
349
+ }
350
+ const name = values.name ?? DEFAULT_NAME;
351
+ syncSessionState(ctx.sessionID, name);
352
+ const result = values.waitResult ? await session.exec(command) : await session.submit(command);
353
+ syncSessionState(ctx.sessionID, name);
354
+ log.tool("term_exec", { command, ok: result.ok, submitted: result.submitted, interactive: result.interactive, running: result.running });
355
+ if (result.submitted)
356
+ return tr("submitted", lang);
357
+ return result.ok ? result.output : result.error ?? tr("exec_failed", lang);
358
+ }
359
+ /** read:读取终端输出 */
360
+ async function doRead(args, ctx) {
361
+ 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 });
362
+ const session = resolveSession(ctx.sessionID, values.name);
363
+ if (!session)
364
+ return tr("not_connected", lang);
365
+ const source = values.source ?? "history";
366
+ if (source === "buffer") {
367
+ const r = await session.readBuffer();
368
+ return r.output;
369
+ }
370
+ const history = session.getHistory();
371
+ const all = history.getPairs();
372
+ const limit = Math.max(1, parseInt(values.limit ?? "10", 10) || 10);
373
+ const direction = values.head ? "head" : "tail";
374
+ const selected = direction === "tail" ? all.slice(-limit) : all.slice(0, limit);
375
+ const includeCommand = values.includeCommand ?? false;
376
+ const text = selected.map((p) => (includeCommand ? `$ ${p.command}\n` : "") + toModelText(history.readOutput(p))).join("\n");
377
+ const browserLine = httpUrl ? tr("browser_full_record", lang).replace("{url}", httpUrl) : tr("server_not_enabled", lang);
378
+ return `${tr(direction === "tail" ? "history_title" : "history_title_head", lang).replace("{n}", String(selected.length)).replace("{total}", String(all.length))}\n${text}${browserLine}`;
379
+ }
380
+ /** send:发送文本/按键到终端 */
381
+ async function doSend(args, ctx) {
382
+ const { values, positionals } = parseArgs({ args, options: { name: { type: "string", short: "n" } }, allowPositionals: true });
383
+ const text = positionals.join(" ");
384
+ if (!text)
385
+ return `${tr("cli_need_text", lang)}\n${tr("cli_send_usage", lang)}\n\n${HELP_TEXT}`;
386
+ const session = resolveSession(ctx.sessionID, values.name);
387
+ if (!session)
388
+ return tr("not_connected", lang);
389
+ const r = session.send(text);
390
+ if (!r.ok)
391
+ return tr("err_not_connected", lang);
392
+ syncSessionState(ctx.sessionID, values.name ?? DEFAULT_NAME);
393
+ log.tool("term_send", { text: text.slice(0, 60), name: values.name ?? DEFAULT_NAME });
394
+ return tr("send_ok", lang).replace("{text}", text.slice(0, 60));
395
+ }
396
+ /** status:终端状态 */
397
+ async function doStatus(args, ctx) {
398
+ const { values } = parseArgs({ args, options: { name: { type: "string", short: "n" } }, allowPositionals: true });
399
+ const smap = getSessionMap(ctx.sessionID);
400
+ const lmap = getLocalSessionMap(ctx.sessionID);
401
+ if ((!smap || smap.size === 0) && (!lmap || lmap.size === 0))
402
+ return tr("no_sessions", lang);
403
+ let sessions;
404
+ if (values.name) {
405
+ const s = resolveSession(ctx.sessionID, values.name);
406
+ if (!s)
407
+ return tr("no_sessions", lang);
408
+ sessions = [s];
409
+ }
410
+ else {
411
+ sessions = [...(smap?.values() ?? []), ...(lmap?.values() ?? [])];
412
+ }
413
+ const parts = [];
414
+ for (const s of sessions) {
415
+ const st = s.getStatus();
416
+ if (!st.connected) {
417
+ parts.push(tr("session_disconnected", lang));
418
+ continue;
419
+ }
420
+ const connLine = "host" in st && st.host
421
+ ? `${tr("st_host", lang)}: ${st.host}@${st.user}:${st.port}`
422
+ : `${tr("st_program", lang)}: ${st.program ?? "-"}`;
423
+ parts.push([
424
+ `${tr("st_name", lang)}: ${st.name ?? "default"}`,
425
+ `${tr("st_type", lang)}: ${"host" in st ? "ssh" : "local"}`,
426
+ `${tr("st_connected", lang)}: ${st.connected}`,
427
+ `${tr("st_busy", lang)}: ${st.busy}`,
428
+ `${tr("st_pending", lang)}: ${st.pending} ${tr("st_bytes", lang)}`,
429
+ connLine,
430
+ st.lastActive ? `${tr("st_last_active", lang)}: ${new Date(st.lastActive).toISOString()}` : null,
431
+ st.connectedAt ? `${tr("st_connected_at", lang)}: ${new Date(st.connectedAt).toISOString()}` : null,
432
+ ].filter(Boolean).join("\n"));
433
+ parts.push(st.busy ? tr("status_busy_hint", lang) : tr("status_idle_hint", lang));
434
+ }
435
+ const serverLines = httpUrl
436
+ ? ["", `${tr("st_http_server", lang)}: ${httpUrl}`, `${tr("st_active_sessions", lang)}: ${listSessionEntries().length}`]
437
+ : ["", `${tr("st_http_server", lang)}: ${tr("st_disabled", lang)}`];
438
+ return parts.join("\n\n") + serverLines.join("\n");
439
+ }
440
+ /** disconnect:断开终端 */
441
+ async function doDisconnect(args, ctx) {
442
+ const { values } = parseArgs({ args, options: { name: { type: "string", short: "n" } }, allowPositionals: true });
443
+ const smap = getSessionMap(ctx.sessionID);
444
+ const lmap = getLocalSessionMap(ctx.sessionID);
445
+ if ((!smap || smap.size === 0) && (!lmap || lmap.size === 0))
446
+ return tr("no_sessions", lang);
447
+ if (!values.name) {
448
+ const firstSshHost = [...(smap?.values() ?? [])][0]?.getStatus().host;
449
+ const firstLocalProgram = [...(lmap?.values() ?? [])][0]?.getStatus().program;
450
+ cleanupAllSessions(ctx.sessionID);
451
+ cleanupAllLocalSessions(ctx.sessionID);
452
+ if (firstSshHost)
453
+ return tr("disconnected_all_ok", lang).replace("{host}", firstSshHost);
454
+ return tr("disconnected_all_ok_local", lang).replace("{program}", firstLocalProgram ?? "-");
455
+ }
456
+ const ssh = getSession(ctx.sessionID, values.name);
457
+ if (ssh) {
458
+ const host = ssh.getStatus().host;
459
+ cleanupSession(ctx.sessionID, values.name);
460
+ return tr("disconnected_ok", lang).replace("{host}", host ?? "-");
461
+ }
462
+ const local = getLocalSession(ctx.sessionID, values.name);
463
+ if (local) {
464
+ const program = local.getStatus().program;
465
+ cleanupLocalSession(ctx.sessionID, values.name);
466
+ return tr("disconnected_ok_local", lang).replace("{program}", program ?? "-");
467
+ }
468
+ return tr("no_sessions", lang);
469
+ }
470
+ /** CLI 入口:解析命令行字符串并分发子命令 */
471
+ async function handleCliCommand(raw, ctx) {
472
+ const tokens = stringArgv(raw);
473
+ const [cmd, ...rest] = tokens;
474
+ if (!cmd || cmd === "help")
475
+ return HELP_TEXT;
476
+ // 统一按根会话 ID 路由(主子会话共享终端)
477
+ const rootID = await findRoot(ctx.sessionID);
478
+ const rootCtx = { ...ctx, sessionID: rootID };
479
+ switch (cmd) {
480
+ case "connect": return doConnect(rest, rootCtx);
481
+ case "local": return doLocal(rest, rootCtx);
482
+ case "exec": return doExec(rest, rootCtx);
483
+ case "read": return doRead(rest, rootCtx);
484
+ case "send": return doSend(rest, rootCtx);
485
+ case "status": return doStatus(rest, rootCtx);
486
+ case "disconnect": return doDisconnect(rest, rootCtx);
487
+ default: return `${tr("cli_unknown", lang).replace("{cmd}", cmd)}\n\n${HELP_TEXT}`;
488
+ }
489
+ }
490
+ // 记录哪些根会话已收到过"来自根自身"的标题(防止子会话标题覆盖根标题)
491
+ const rootTitleOwned = new Set();
220
492
  return {
221
493
  event: async ({ event }) => {
222
494
  // 会话创建/更新时记录标题与目录(供 HTTP 页面显示会话名称)
223
495
  if (event.type === "session.created" || event.type === "session.updated") {
224
496
  const info = event.properties?.info;
225
497
  if (info?.id) {
226
- sessionMeta.set(info.id, { title: info.title ?? "", directory: info.directory ?? "" });
227
- // 有活动终端时同步标题/目录到状态文件(供跨进程聚合)
228
- const map = getSessionMap(info.id);
498
+ // 记录父子关系(用于根系查找)
499
+ if (info.parentID !== undefined)
500
+ childMap.set(info.id, info.parentID);
501
+ else if (!childMap.has(info.id))
502
+ childMap.set(info.id, undefined);
503
+ // 标题仅来自根会话自身;子会话不覆盖根标题
504
+ const root = findRoot(info.id);
505
+ if (info.id === root) {
506
+ sessionMeta.set(root, { title: info.title ?? "", directory: info.directory ?? "" });
507
+ rootTitleOwned.add(root);
508
+ }
509
+ else if (!rootTitleOwned.has(root) && !sessionMeta.has(root)) {
510
+ sessionMeta.set(root, { title: info.title ?? "", directory: info.directory ?? "" });
511
+ }
512
+ // 有活动终端时同步状态文件
513
+ const map = getSessionMap(root);
229
514
  if (map)
230
515
  for (const name of map.keys())
231
- syncSessionState(info.id, name);
516
+ syncSessionState(root, name);
232
517
  }
233
518
  }
234
- // 会话删除时清理:关闭全部终端连接 + 删缓存目录 + 删元信息
235
519
  if (event.type === "session.deleted") {
236
- const info = event.properties?.info;
237
- const sid = info?.id;
520
+ const props = event.properties;
521
+ const sid = props?.sessionID ?? props?.info?.id;
238
522
  if (sid) {
239
- cleanupAllSessions(sid);
240
- cleanupAllLocalSessions(sid);
523
+ childMap.delete(sid);
524
+ const root = findRoot(sid);
525
+ if (root === sid) {
526
+ cleanupAllSessions(sid);
527
+ cleanupAllLocalSessions(sid);
528
+ }
241
529
  sessionMeta.delete(sid);
242
- log.info(`会话 ${sid} 删除,已清理`);
530
+ rootTitleOwned.delete(sid);
531
+ log.info(`会话 ${sid} 删除,根 ${root} 清理`);
243
532
  }
244
533
  }
245
534
  },
246
535
  tool: {
247
- ssh_connect: tool({
248
- description: T.ssh_connect[lang],
536
+ term_cli: tool({
537
+ description: T.term_cli[lang],
249
538
  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]),
539
+ args: tool.schema.string().optional().describe(T.term_cli_args[lang]),
255
540
  },
256
541
  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],
521
- 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]),
525
- },
526
- 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
- };
542
+ return { title: "term_cli", output: await handleCliCommand(args.args ?? "help", context) };
555
543
  },
556
544
  }),
557
545
  },