@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.
- package/README.md +30 -24
- package/dist/base-session.d.ts +128 -0
- package/dist/base-session.d.ts.map +1 -0
- package/dist/base-session.js +414 -0
- package/dist/base-session.js.map +1 -0
- package/dist/config.d.ts +2 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +5 -0
- package/dist/config.js.map +1 -1
- package/dist/constants.d.ts +27 -3
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +28 -3
- package/dist/constants.js.map +1 -1
- package/dist/history.d.ts +14 -5
- package/dist/history.d.ts.map +1 -1
- package/dist/history.js +25 -9
- package/dist/history.js.map +1 -1
- package/dist/i18n.d.ts +249 -196
- package/dist/i18n.d.ts.map +1 -1
- package/dist/i18n.js +171 -180
- package/dist/i18n.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +320 -332
- package/dist/index.js.map +1 -1
- package/dist/last-match.d.ts +17 -0
- package/dist/last-match.d.ts.map +1 -0
- package/dist/last-match.js +63 -0
- package/dist/last-match.js.map +1 -0
- package/dist/local-session.d.ts +9 -91
- package/dist/local-session.d.ts.map +1 -1
- package/dist/local-session.js +46 -308
- package/dist/local-session.js.map +1 -1
- package/dist/log.d.ts +3 -0
- package/dist/log.d.ts.map +1 -0
- package/dist/log.js +5 -0
- package/dist/log.js.map +1 -0
- package/dist/server-manager.d.ts +3 -1
- package/dist/server-manager.d.ts.map +1 -1
- package/dist/server-manager.js +3 -2
- package/dist/server-manager.js.map +1 -1
- package/dist/server.d.ts +35 -6
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +362 -305
- package/dist/server.js.map +1 -1
- package/dist/session-store.d.ts +2 -0
- package/dist/session-store.d.ts.map +1 -1
- package/dist/session-store.js +15 -0
- package/dist/session-store.js.map +1 -1
- package/dist/session.d.ts +15 -108
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +69 -361
- package/dist/session.js.map +1 -1
- package/dist/shell-adapter.d.ts +20 -0
- package/dist/shell-adapter.d.ts.map +1 -0
- package/dist/shell-adapter.js +70 -0
- package/dist/shell-adapter.js.map +1 -0
- package/dist/ssh-auth.d.ts.map +1 -1
- package/dist/ssh-auth.js +1 -2
- package/dist/ssh-auth.js.map +1 -1
- package/dist/utils.d.ts +25 -24
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +277 -45
- package/dist/utils.js.map +1 -1
- package/dist/web/app.css +35 -0
- package/dist/web/app.js +2 -0
- package/dist/web/fonts/CaskaydiaCoveNerdFontMono-Regular.ttf +0 -0
- package/dist/web/index.html +24 -0
- 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
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
}
|
|
184
|
-
|
|
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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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(
|
|
516
|
+
syncSessionState(root, name);
|
|
232
517
|
}
|
|
233
518
|
}
|
|
234
|
-
// 会话删除时清理:关闭全部终端连接 + 删缓存目录 + 删元信息
|
|
235
519
|
if (event.type === "session.deleted") {
|
|
236
|
-
const
|
|
237
|
-
const sid = info?.id;
|
|
520
|
+
const props = event.properties;
|
|
521
|
+
const sid = props?.sessionID ?? props?.info?.id;
|
|
238
522
|
if (sid) {
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
|
|
530
|
+
rootTitleOwned.delete(sid);
|
|
531
|
+
log.info(`会话 ${sid} 删除,根 ${root} 清理`);
|
|
243
532
|
}
|
|
244
533
|
}
|
|
245
534
|
},
|
|
246
535
|
tool: {
|
|
247
|
-
|
|
248
|
-
description: T.
|
|
536
|
+
term_cli: tool({
|
|
537
|
+
description: T.term_cli[lang],
|
|
249
538
|
args: {
|
|
250
|
-
|
|
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
|
-
|
|
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
|
},
|