im-agent 0.1.1 → 0.2.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 +1 -1
- package/package.json +1 -1
- package/src/agents/claude/account.ts +100 -0
- package/src/agents/claude/driver.ts +47 -2
- package/src/agents/claude/permissions.ts +35 -2
- package/src/agents/claude/probe.ts +54 -0
- package/src/agents/claude/session.ts +19 -9
- package/src/agents/codex/driver.ts +4 -0
- package/src/channels/feishu/bot.ts +60 -0
- package/src/channels/feishu/cards.ts +50 -0
- package/src/channels/feishu/envelope.ts +6 -1
- package/src/channels/feishu/index.ts +11 -1
- package/src/channels/feishu/slash-sync.ts +192 -0
- package/src/channels/web/api.ts +54 -0
- package/src/channels/web/server.ts +12 -0
- package/src/core/command-catalog.ts +112 -0
- package/src/core/commands.ts +21 -16
- package/src/core/config.ts +5 -0
- package/src/core/hub.ts +55 -0
- package/src/core/store.ts +2 -0
- package/src/core/types.ts +9 -0
- package/web/app.js +541 -15
- package/web/style.css +147 -0
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// 把命令表同步成飞书的「斜杠指令」:用户在飞书输入框里打「/」就能看到并选择。
|
|
2
|
+
//
|
|
3
|
+
// 接口:/open-apis/application/v7/app_slash_commands(增删改查)。扫码创建的应用自带读写权限。
|
|
4
|
+
// 实测出来的规矩(文档没写全,别凭感觉改):
|
|
5
|
+
// - 指令名必须匹配 ^[a-z][a-z0-9_-]{0,31}$ —— 带冒号的 plugin skill、大写、超长的都注册不了,跳过;
|
|
6
|
+
// - 说明最长 100 字;每个应用最多 100 条;
|
|
7
|
+
// - 列表按创建时间倒序返回(后建的在前),没有排序字段。所以想要某个顺序,就得
|
|
8
|
+
// 按目标顺序倒着建,每条之间隔开 1 秒以上(create_time 精确到秒,同一秒内顺序不定);
|
|
9
|
+
// - 改动约 5 分钟生效,客户端还有约 3 分钟缓存。
|
|
10
|
+
//
|
|
11
|
+
// 这个应用是 im-agent 专用的:同步时会删掉不在命令表里的指令,包括手动加的。
|
|
12
|
+
import type * as Lark from "@larksuiteoapi/node-sdk";
|
|
13
|
+
import type { CommandEntry } from "../../core/command-catalog.js";
|
|
14
|
+
|
|
15
|
+
const API = "/open-apis/application/v7/app_slash_commands";
|
|
16
|
+
const NAME = /^[a-z][a-z0-9_-]{0,31}$/;
|
|
17
|
+
const MAX_COMMANDS = 100;
|
|
18
|
+
const MAX_DESCRIPTION = 100;
|
|
19
|
+
/** create_time 精确到秒,间隔要超过 1 秒顺序才稳定 */
|
|
20
|
+
const CREATE_GAP_MS = 1100;
|
|
21
|
+
|
|
22
|
+
export type FeishuSlashCommand = { command: string; description: string };
|
|
23
|
+
export type RemoteSlashCommand = { command_id: string; command: string; description?: { default_value?: string } };
|
|
24
|
+
|
|
25
|
+
export type SlashSyncStatus = {
|
|
26
|
+
state: "idle" | "syncing" | "done" | "error";
|
|
27
|
+
/** 最近一次完成(或失败)的时间 */
|
|
28
|
+
at?: number;
|
|
29
|
+
/** 飞书上现在注册了多少条 */
|
|
30
|
+
registered?: number;
|
|
31
|
+
/** 名字不合飞书规矩、或者超出 100 条而没注册的命令 */
|
|
32
|
+
skipped?: string[];
|
|
33
|
+
/** 最近一次是否真的改了东西(没改就是已经一致) */
|
|
34
|
+
changed?: boolean;
|
|
35
|
+
error?: string;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
function clipChars(text: string, max: number): string {
|
|
39
|
+
const chars = Array.from(text);
|
|
40
|
+
return chars.length <= max ? text : `${chars.slice(0, max - 1).join("")}…`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** 命令表 → 飞书指令。参数提示并进说明里,飞书那边没有单独的参数字段。 */
|
|
44
|
+
export function toFeishuCommands(entries: CommandEntry[]): { commands: FeishuSlashCommand[]; skipped: string[] } {
|
|
45
|
+
const commands: FeishuSlashCommand[] = [];
|
|
46
|
+
const skipped: string[] = [];
|
|
47
|
+
const seen = new Set<string>();
|
|
48
|
+
for (const c of entries) {
|
|
49
|
+
if (c.hidden || seen.has(c.name)) continue;
|
|
50
|
+
seen.add(c.name);
|
|
51
|
+
if (!NAME.test(c.name) || commands.length >= MAX_COMMANDS) {
|
|
52
|
+
skipped.push(c.name);
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
const text = `${c.args ? `${c.args} ` : ""}${c.description}`.replace(/\s+/g, " ").trim() || c.name;
|
|
56
|
+
commands.push({ command: c.name, description: clipChars(text, MAX_DESCRIPTION) });
|
|
57
|
+
}
|
|
58
|
+
return { commands, skipped };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export type SyncPlan =
|
|
62
|
+
| { action: "none" }
|
|
63
|
+
| { action: "patch"; updates: { id: string; description: string }[] }
|
|
64
|
+
| { action: "rebuild" };
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* remote 是飞书返回的顺序(后建的在前),desired 是想要的显示顺序(第一条在最前)。
|
|
68
|
+
* 名字和顺序都对得上,最多改改说明;否则整体重建 —— 飞书没有排序字段,只能靠重建来排。
|
|
69
|
+
*/
|
|
70
|
+
export function planSync(remote: RemoteSlashCommand[], desired: FeishuSlashCommand[]): SyncPlan {
|
|
71
|
+
const sameOrder =
|
|
72
|
+
remote.length === desired.length && remote.every((r, i) => r.command === desired[i]!.command);
|
|
73
|
+
if (!sameOrder) return { action: "rebuild" };
|
|
74
|
+
const updates = remote
|
|
75
|
+
.map((r, i) => ({ r, d: desired[i]! }))
|
|
76
|
+
.filter(({ r, d }) => (r.description?.default_value ?? "") !== d.description)
|
|
77
|
+
.map(({ r, d }) => ({ id: r.command_id, description: d.description }));
|
|
78
|
+
return updates.length > 0 ? { action: "patch", updates } : { action: "none" };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
type ApiResponse<T> = { code?: number; msg?: string; data?: T };
|
|
82
|
+
|
|
83
|
+
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* 一个机器人一个。request() 可以随便多次调用:会合并成一次,正在同步时再要求就等这次完了再跑一遍。
|
|
87
|
+
*/
|
|
88
|
+
export class SlashSync {
|
|
89
|
+
status: SlashSyncStatus = { state: "idle" };
|
|
90
|
+
private running = false;
|
|
91
|
+
private again = false;
|
|
92
|
+
private stopped = false;
|
|
93
|
+
private debounce: NodeJS.Timeout | null = null;
|
|
94
|
+
|
|
95
|
+
constructor(
|
|
96
|
+
private readonly lark: Lark.Client,
|
|
97
|
+
/** 现在应该注册哪些命令(已排好序) */
|
|
98
|
+
private readonly desired: () => Promise<CommandEntry[]>,
|
|
99
|
+
private readonly tag: string,
|
|
100
|
+
) {}
|
|
101
|
+
|
|
102
|
+
/** delayMs:设置里连着点几次保存,只同步一次 */
|
|
103
|
+
request(delayMs = 3000): void {
|
|
104
|
+
if (this.stopped) return;
|
|
105
|
+
if (this.debounce) clearTimeout(this.debounce);
|
|
106
|
+
this.debounce = setTimeout(() => {
|
|
107
|
+
this.debounce = null;
|
|
108
|
+
void this.run();
|
|
109
|
+
}, delayMs);
|
|
110
|
+
this.debounce.unref?.();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
stop(): void {
|
|
114
|
+
this.stopped = true;
|
|
115
|
+
if (this.debounce) clearTimeout(this.debounce);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private async run(): Promise<void> {
|
|
119
|
+
if (this.running) {
|
|
120
|
+
this.again = true;
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
this.running = true;
|
|
124
|
+
this.status = { ...this.status, state: "syncing" };
|
|
125
|
+
try {
|
|
126
|
+
const { commands, skipped } = toFeishuCommands(await this.desired());
|
|
127
|
+
const remote = await this.list();
|
|
128
|
+
const plan = planSync(remote, commands);
|
|
129
|
+
if (plan.action === "patch") {
|
|
130
|
+
for (const u of plan.updates) {
|
|
131
|
+
await this.call("PATCH", `${API}/${u.id}`, { description: { default_value: u.description } });
|
|
132
|
+
}
|
|
133
|
+
} else if (plan.action === "rebuild") {
|
|
134
|
+
for (const r of remote) {
|
|
135
|
+
if (this.stopped) return;
|
|
136
|
+
await this.call("DELETE", `${API}/${r.command_id}`);
|
|
137
|
+
}
|
|
138
|
+
// 倒着建:最后建的显示在最前
|
|
139
|
+
for (const [i, c] of [...commands].reverse().entries()) {
|
|
140
|
+
if (this.stopped) return;
|
|
141
|
+
if (i > 0) await sleep(CREATE_GAP_MS);
|
|
142
|
+
await this.call("POST", API, { command: c.command, description: { default_value: c.description } });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
this.status = {
|
|
146
|
+
state: "done",
|
|
147
|
+
at: Date.now(),
|
|
148
|
+
registered: commands.length,
|
|
149
|
+
skipped,
|
|
150
|
+
changed: plan.action !== "none",
|
|
151
|
+
};
|
|
152
|
+
if (plan.action !== "none") {
|
|
153
|
+
console.log(`${this.tag} 斜杠指令已同步:${commands.length} 条(${plan.action === "rebuild" ? "重建" : "更新说明"}),约 5~8 分钟后在飞书里生效`);
|
|
154
|
+
}
|
|
155
|
+
} catch (err) {
|
|
156
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
157
|
+
this.status = { ...this.status, state: "error", at: Date.now(), error: message };
|
|
158
|
+
console.error(`${this.tag} 斜杠指令同步失败:${message}`);
|
|
159
|
+
} finally {
|
|
160
|
+
this.running = false;
|
|
161
|
+
if (this.again && !this.stopped) {
|
|
162
|
+
this.again = false;
|
|
163
|
+
void this.run();
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
private async list(): Promise<RemoteSlashCommand[]> {
|
|
169
|
+
const data = await this.call<{ items?: RemoteSlashCommand[] }>("GET", API);
|
|
170
|
+
return data?.items ?? [];
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private async call<T>(method: "GET" | "POST" | "PATCH" | "DELETE", url: string, data?: unknown): Promise<T | undefined> {
|
|
174
|
+
let res: ApiResponse<T>;
|
|
175
|
+
try {
|
|
176
|
+
res = await this.lark.request<ApiResponse<T>>({ method, url, ...(data !== undefined ? { data } : {}) });
|
|
177
|
+
} catch (err) {
|
|
178
|
+
const body = (err as { response?: { data?: ApiResponse<T> } })?.response?.data;
|
|
179
|
+
if (body?.code) throw new Error(explain(body.code, body.msg));
|
|
180
|
+
throw err;
|
|
181
|
+
}
|
|
182
|
+
if (res?.code) throw new Error(explain(res.code, res.msg));
|
|
183
|
+
return res?.data;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function explain(code: number, msg?: string): string {
|
|
188
|
+
if (code === 99991640 || code === 99991672) {
|
|
189
|
+
return `应用没有斜杠指令权限(${code})。到飞书开发者后台给这个应用开通 application:app_slash_command:write / read,并发布新版本`;
|
|
190
|
+
}
|
|
191
|
+
return `code=${code} ${msg ?? ""}`.trim();
|
|
192
|
+
}
|
package/src/channels/web/api.ts
CHANGED
|
@@ -134,6 +134,19 @@ function applyConfig(input: Partial<AppConfig>): void {
|
|
|
134
134
|
const { passwordHash: _ignored, ...web } = input.web as AppConfig["web"];
|
|
135
135
|
next.web = { ...next.web, ...web, passwordHash: current.web.passwordHash };
|
|
136
136
|
}
|
|
137
|
+
if (input.commands) {
|
|
138
|
+
// 只收字符串数组,别的一律当没传
|
|
139
|
+
const names = (v: unknown) =>
|
|
140
|
+
Array.isArray(v) ? [...new Set(v.filter((s): s is string => typeof s === "string" && s.length > 0 && s.length < 128))] : null;
|
|
141
|
+
for (const id of AGENT_IDS) {
|
|
142
|
+
const prefs = (input.commands as Record<string, { order?: unknown; hidden?: unknown } | undefined>)[id];
|
|
143
|
+
if (!prefs) continue;
|
|
144
|
+
next.commands[id] = {
|
|
145
|
+
order: names(prefs.order) ?? next.commands[id].order,
|
|
146
|
+
hidden: names(prefs.hidden) ?? next.commands[id].hidden,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
}
|
|
137
150
|
if (input.feishu) {
|
|
138
151
|
if (typeof input.feishu.watchdogMs === "number") next.feishu.watchdogMs = input.feishu.watchdogMs;
|
|
139
152
|
if (Array.isArray(input.feishu.bots)) {
|
|
@@ -413,6 +426,38 @@ export async function handleApi(req: IncomingMessage, res: ServerResponse, url:
|
|
|
413
426
|
sendJson(res, 200, publicConfig());
|
|
414
427
|
return;
|
|
415
428
|
}
|
|
429
|
+
{
|
|
430
|
+
// 设置页的模型下拉框:不需要会话。
|
|
431
|
+
// 未启用的 agent 不去问 —— Codex 的 app-server 一问就会被拉起来常驻,违背「未启用」
|
|
432
|
+
const m = /^\/api\/agents\/([a-z0-9_-]+)\/models$/.exec(p);
|
|
433
|
+
if (m && method === "GET") {
|
|
434
|
+
const driver = hub.enabledAgents().find((d) => d.id === m[1]);
|
|
435
|
+
if (!driver) {
|
|
436
|
+
sendJson(res, 409, { error: "未启用,启用并保存后可下拉选择" });
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
sendJson(res, 200, { models: await driver.catalog() });
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
{
|
|
444
|
+
// 命令表(含隐藏项)。带 key 时按那条会话的目录读(项目里可能有自己的命令),
|
|
445
|
+
// Web 输入框补全用;不带 key 是设置页排序用。
|
|
446
|
+
const m = /^\/api\/agents\/([a-z0-9_-]+)\/commands$/.exec(p);
|
|
447
|
+
if (m && method === "GET") {
|
|
448
|
+
if (!(AGENT_IDS as readonly string[]).includes(m[1]!)) {
|
|
449
|
+
sendJson(res, 404, { error: `没有 agent「${m[1]}」` });
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
const conv = getConversation(url.searchParams.get("key") ?? "");
|
|
453
|
+
const commands = await hub.commandCatalog(m[1]!, {
|
|
454
|
+
...(conv ? { cwd: conv.cwd } : {}),
|
|
455
|
+
allowedAgents: hub.enabledAgents().map((d) => d.id),
|
|
456
|
+
});
|
|
457
|
+
sendJson(res, 200, { commands });
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
416
461
|
if (p === "/api/password" && method === "POST") {
|
|
417
462
|
const body = await readJsonBody<{ old?: string; password?: string }>(req);
|
|
418
463
|
const left = takeAttempt(ip);
|
|
@@ -459,6 +504,15 @@ export async function handleApi(req: IncomingMessage, res: ServerResponse, url:
|
|
|
459
504
|
sendJson(res, 200, listAllowlist());
|
|
460
505
|
return;
|
|
461
506
|
}
|
|
507
|
+
if (p === "/api/feishu/slash" && method === "GET") {
|
|
508
|
+
sendJson(res, 200, { bots: feishuBots().map((b) => ({ id: b.id, name: b.cfg.name, status: b.slash.status })) });
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
if (p === "/api/feishu/slash/sync" && method === "POST") {
|
|
512
|
+
for (const b of feishuBots()) b.slash.request(0);
|
|
513
|
+
sendJson(res, 200, { ok: true });
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
462
516
|
if (p === "/api/feishu/status" && method === "GET") {
|
|
463
517
|
sendJson(res, 200, feishuBots().map((b) => ({ id: b.id, name: b.cfg.name, ...b.status })));
|
|
464
518
|
return;
|
|
@@ -9,6 +9,7 @@ import { createRequire } from "node:module";
|
|
|
9
9
|
import { fileURLToPath } from "node:url";
|
|
10
10
|
import { WebSocketServer, type WebSocket } from "ws";
|
|
11
11
|
import { config } from "../../core/config.js";
|
|
12
|
+
import { parseCommand } from "../../core/commands.js";
|
|
12
13
|
import { hub, type EventEnvelope } from "../../core/hub.js";
|
|
13
14
|
import { interactions, type Interaction } from "../../core/interactions.js";
|
|
14
15
|
import { getConversation } from "../../core/store.js";
|
|
@@ -198,6 +199,17 @@ function handleClientMessage(client: Client, raw: string): void {
|
|
|
198
199
|
const text = typeof msg.text === "string" ? msg.text : "";
|
|
199
200
|
const attachments = takeUploads(key, msg.uploads);
|
|
200
201
|
if (!text.trim() && attachments.length === 0) return ack(false, { error: "空消息" });
|
|
202
|
+
// 和飞书一致:整条消息是 im-agent 命令就由 hub 执行,结果随 ack 回给发命令的这个页面,
|
|
203
|
+
// 不进会话记录。带附件的一律当聊天。
|
|
204
|
+
const command = attachments.length > 0 ? null : parseCommand(text);
|
|
205
|
+
if (command && command.kind !== "chat") {
|
|
206
|
+
const allowedAgents = hub.enabledAgents().map((d) => d.id);
|
|
207
|
+
void hub
|
|
208
|
+
.command(key, command, { allowedAgents, by: "Web 命令" })
|
|
209
|
+
.then((reply) => ack(true, { reply, info: hub.info(key) }))
|
|
210
|
+
.catch((err) => ack(false, { error: err instanceof Error ? err.message : String(err) }));
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
201
213
|
hub.send(key, { text, attachments }, { channel: "web", channelId: "web", name: "Web" });
|
|
202
214
|
return ack(true);
|
|
203
215
|
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// 命令目录:输入「/」时给用户看的那张表。
|
|
2
|
+
//
|
|
3
|
+
// 两种来源:
|
|
4
|
+
// - im-agent 自己的命令(/new /status …),由 hub 拦下来执行,所有 agent 通用;
|
|
5
|
+
// - agent 原生命令(Claude 的 skills、/compact 之类),im-agent 不认识,原样转给 agent。
|
|
6
|
+
// 顺序和隐藏按 agent 分开存在配置里(设置 → 命令),用户自己排。
|
|
7
|
+
|
|
8
|
+
/** im-agent 内置命令。name 不带斜杠;args 里尖括号是必填、方括号是可选 */
|
|
9
|
+
export type BuiltinSpec = { name: string; args?: string; description: string; multiAgentOnly?: boolean };
|
|
10
|
+
|
|
11
|
+
export const BUILTIN_COMMANDS: BuiltinSpec[] = [
|
|
12
|
+
{ name: "new", args: "[模型]", description: "开一条新会话" },
|
|
13
|
+
{ name: "status", description: "查看当前会话、模型、目录、剩余额度" },
|
|
14
|
+
{ name: "stop", description: "中断当前执行" },
|
|
15
|
+
{ name: "model", args: "[名称]", description: "切换模型(不带参数弹选择)" },
|
|
16
|
+
{ name: "effort", args: "[程度]", description: "切换思考程度" },
|
|
17
|
+
{ name: "sessions", args: "[all]", description: "列出历史会话,点按钮恢复;加 all 列所有目录" },
|
|
18
|
+
{ name: "resume", args: "<会话ID>", description: "恢复指定会话" },
|
|
19
|
+
{ name: "fork", description: "从当前会话分叉,原会话保留" },
|
|
20
|
+
{ name: "agent", args: "[名称]", description: "查看 / 切换 agent", multiAgentOnly: true },
|
|
21
|
+
{ name: "claude", description: "切换到 Claude Code", multiAgentOnly: true },
|
|
22
|
+
{ name: "codex", description: "切换到 Codex", multiAgentOnly: true },
|
|
23
|
+
{ name: "cd", args: "<相对目录>", description: "切换工作目录(限 workspace 内)" },
|
|
24
|
+
{ name: "pwd", description: "显示当前工作目录" },
|
|
25
|
+
{ name: "autoedit", args: "[on|off]", description: "本对话自动允许改工作目录内的文件(Bash 仍询问)" },
|
|
26
|
+
{ name: "approve", args: "<请求ID> allow-once|allow-always|deny", description: "审批兜底(卡片失效时用)" },
|
|
27
|
+
{ name: "help", description: "显示帮助" },
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
/** agent 自己报上来的命令 */
|
|
31
|
+
export type AgentCommand = { name: string; description: string; args?: string };
|
|
32
|
+
|
|
33
|
+
export type CommandEntry = {
|
|
34
|
+
name: string;
|
|
35
|
+
args?: string;
|
|
36
|
+
description: string;
|
|
37
|
+
source: "im-agent" | "agent";
|
|
38
|
+
/** 不带参数就能直接执行(飞书卡片上给按钮、Web 里回车直接发) */
|
|
39
|
+
runnable: boolean;
|
|
40
|
+
hidden: boolean;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export type CommandPrefs = { order: string[]; hidden: string[] };
|
|
44
|
+
|
|
45
|
+
/** 参数里有必填项(尖括号)就不能一键执行 */
|
|
46
|
+
function needsArgs(args: string | undefined): boolean {
|
|
47
|
+
return !!args && args.includes("<");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 合并内置命令和 agent 原生命令,再按用户配置排序。
|
|
52
|
+
* - 只对接得上的 agent 才列 /claude /codex 这类快捷切换;
|
|
53
|
+
* - 和内置命令重名的原生命令丢掉:反正会被 im-agent 先拦下来,列出来只会误导;
|
|
54
|
+
* - 配置里排过序的在前,按配置的顺序;没排过的(比如新装的 skill)按默认顺序接在后面。
|
|
55
|
+
*/
|
|
56
|
+
export function buildCatalog(params: {
|
|
57
|
+
allowedAgents: string[];
|
|
58
|
+
native: AgentCommand[];
|
|
59
|
+
prefs: CommandPrefs | undefined;
|
|
60
|
+
}): CommandEntry[] {
|
|
61
|
+
const multi = params.allowedAgents.length > 1;
|
|
62
|
+
const builtin: CommandEntry[] = BUILTIN_COMMANDS.filter(
|
|
63
|
+
(c) =>
|
|
64
|
+
(!c.multiAgentOnly || multi) &&
|
|
65
|
+
// /claude /codex 只在这个渠道能用对应 agent 时才有意义
|
|
66
|
+
(!["claude", "codex"].includes(c.name) || params.allowedAgents.includes(c.name)),
|
|
67
|
+
).map((c) => ({
|
|
68
|
+
name: c.name,
|
|
69
|
+
...(c.args ? { args: c.args } : {}),
|
|
70
|
+
description: c.description,
|
|
71
|
+
source: "im-agent" as const,
|
|
72
|
+
runnable: !needsArgs(c.args),
|
|
73
|
+
hidden: false,
|
|
74
|
+
}));
|
|
75
|
+
const taken = new Set(BUILTIN_COMMANDS.map((c) => c.name));
|
|
76
|
+
const seen = new Set<string>();
|
|
77
|
+
const native: CommandEntry[] = [];
|
|
78
|
+
for (const c of params.native) {
|
|
79
|
+
const name = c.name.replace(/^\//, "");
|
|
80
|
+
if (!name || taken.has(name) || seen.has(name)) continue;
|
|
81
|
+
seen.add(name);
|
|
82
|
+
native.push({
|
|
83
|
+
name,
|
|
84
|
+
...(c.args ? { args: c.args } : {}),
|
|
85
|
+
description: c.description,
|
|
86
|
+
source: "agent",
|
|
87
|
+
runnable: !needsArgs(c.args),
|
|
88
|
+
hidden: false,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const all = [...builtin, ...native];
|
|
93
|
+
const order = params.prefs?.order ?? [];
|
|
94
|
+
const hidden = new Set(params.prefs?.hidden ?? []);
|
|
95
|
+
const rank = new Map(order.map((name, i) => [name, i]));
|
|
96
|
+
return all
|
|
97
|
+
.map((c, i) => ({ c, i }))
|
|
98
|
+
.sort((a, b) => {
|
|
99
|
+
const ra = rank.get(a.c.name);
|
|
100
|
+
const rb = rank.get(b.c.name);
|
|
101
|
+
if (ra !== undefined && rb !== undefined) return ra - rb;
|
|
102
|
+
if (ra !== undefined) return -1;
|
|
103
|
+
if (rb !== undefined) return 1;
|
|
104
|
+
return a.i - b.i;
|
|
105
|
+
})
|
|
106
|
+
.map(({ c }) => ({ ...c, hidden: hidden.has(c.name) }));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** 「/name args」,给人看的写法 */
|
|
110
|
+
export function usageOf(c: { name: string; args?: string }): string {
|
|
111
|
+
return `/${c.name}${c.args ? ` ${c.args}` : ""}`;
|
|
112
|
+
}
|
package/src/core/commands.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
//
|
|
3
3
|
// 只有「整条消息就是一个命令」时才当命令处理,参数对不上就退化成普通聊天 ——
|
|
4
4
|
// 避免把正常对话误当指令执行。
|
|
5
|
+
import { BUILTIN_COMMANDS, usageOf } from "./command-catalog.js";
|
|
5
6
|
import type { ApprovalDecision } from "./types.js";
|
|
6
7
|
|
|
7
8
|
export type ParsedCommand =
|
|
@@ -16,7 +17,10 @@ export type ParsedCommand =
|
|
|
16
17
|
| { kind: "cd"; dir: string }
|
|
17
18
|
| { kind: "status" }
|
|
18
19
|
| { kind: "pwd" }
|
|
20
|
+
| { kind: "autoedit"; on?: boolean }
|
|
19
21
|
| { kind: "help" }
|
|
22
|
+
/** 只发了一个「/」:列出可用命令 */
|
|
23
|
+
| { kind: "commands" }
|
|
20
24
|
| { kind: "approve"; requestId: string; decision: ApprovalDecision }
|
|
21
25
|
| { kind: "chat"; text: string };
|
|
22
26
|
|
|
@@ -24,6 +28,8 @@ const DECISIONS: ApprovalDecision[] = ["allow-once", "allow-always", "deny"];
|
|
|
24
28
|
|
|
25
29
|
export function parseCommand(raw: string): ParsedCommand {
|
|
26
30
|
const text = raw.trim();
|
|
31
|
+
// 全角斜杠也认:中文输入法下很容易打成「/」
|
|
32
|
+
if (text === "/" || text === "/") return { kind: "commands" };
|
|
27
33
|
if (!text.startsWith("/") || text.includes("\n")) return { kind: "chat", text };
|
|
28
34
|
|
|
29
35
|
const [head = "", ...rest] = text.slice(1).split(/\s+/);
|
|
@@ -56,6 +62,13 @@ export function parseCommand(raw: string): ParsedCommand {
|
|
|
56
62
|
return { kind: "status" };
|
|
57
63
|
case "pwd":
|
|
58
64
|
return { kind: "pwd" };
|
|
65
|
+
case "autoedit": {
|
|
66
|
+
const v = arg.toLowerCase();
|
|
67
|
+
if (!v) return { kind: "autoedit" };
|
|
68
|
+
if (["on", "开", "1", "true"].includes(v)) return { kind: "autoedit", on: true };
|
|
69
|
+
if (["off", "关", "0", "false"].includes(v)) return { kind: "autoedit", on: false };
|
|
70
|
+
return { kind: "chat", text };
|
|
71
|
+
}
|
|
59
72
|
case "help":
|
|
60
73
|
return { kind: "help" };
|
|
61
74
|
case "approve": {
|
|
@@ -78,22 +91,14 @@ export function helpText(agents: string[]): string {
|
|
|
78
91
|
"",
|
|
79
92
|
"Agent 给选项时会发一张提问卡,点按钮作答;都不合适就直接发消息,那条消息会被当成你的回答。",
|
|
80
93
|
"",
|
|
81
|
-
"
|
|
94
|
+
"**命令**(需整条消息只有命令;只发一个 `/` 可以列出全部命令,包括 agent 自己的)",
|
|
82
95
|
"",
|
|
83
|
-
...(multi
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
"`/model [名称]` 切换模型(不带参数弹选择卡片)",
|
|
91
|
-
"`/effort <程度>` 切换思考程度",
|
|
92
|
-
"`/stop` 中断当前执行",
|
|
93
|
-
"`/cd <相对目录>` 切换工作目录(限 workspace 内)",
|
|
94
|
-
"`/pwd` 显示当前工作目录",
|
|
95
|
-
"`/status` 查看当前会话、模型、目录",
|
|
96
|
-
"`/approve <请求ID> allow-once|allow-always|deny` 审批兜底",
|
|
97
|
-
"`/help` 显示本帮助",
|
|
96
|
+
...BUILTIN_COMMANDS.filter((c) => !c.multiAgentOnly || multi)
|
|
97
|
+
.filter((c) => !["claude", "codex"].includes(c.name))
|
|
98
|
+
.map((c) =>
|
|
99
|
+
c.name === "agent"
|
|
100
|
+
? `\`${usageOf(c)}\` ${c.description}(可选:${agents.join("、")}),也可以直接发 ${agents.map((a) => `\`/${a}\``).join(" ")}`
|
|
101
|
+
: `\`${usageOf(c)}\` ${c.description}`,
|
|
102
|
+
),
|
|
98
103
|
].join("\n");
|
|
99
104
|
}
|
package/src/core/config.ts
CHANGED
|
@@ -6,6 +6,8 @@ import fs from "node:fs";
|
|
|
6
6
|
import path from "node:path";
|
|
7
7
|
import crypto from "node:crypto";
|
|
8
8
|
import { configPath, ensureDirs, readJson, writeJsonAtomic } from "../home.js";
|
|
9
|
+
import type { CommandPrefs } from "./command-catalog.js";
|
|
10
|
+
|
|
9
11
|
|
|
10
12
|
export type SandboxMode = "read-only" | "workspace-write" | "danger-full-access";
|
|
11
13
|
|
|
@@ -56,6 +58,8 @@ export type AppConfig = {
|
|
|
56
58
|
*/
|
|
57
59
|
trustedProxies: string[];
|
|
58
60
|
};
|
|
61
|
+
/** 输入「/」时命令列表的顺序和隐藏项,按 agent 分开(设置 → 命令) */
|
|
62
|
+
commands: { claude: CommandPrefs; codex: CommandPrefs };
|
|
59
63
|
feishu: {
|
|
60
64
|
/** 长连接掉线多久就重建,0 关闭 */
|
|
61
65
|
watchdogMs: number;
|
|
@@ -82,6 +86,7 @@ export function defaultConfig(): AppConfig {
|
|
|
82
86
|
},
|
|
83
87
|
},
|
|
84
88
|
web: { enabled: true, host: "0.0.0.0", port: 8800, passwordHash: "", trustedProxies: ["127.0.0.1", "::1"] },
|
|
89
|
+
commands: { claude: { order: [], hidden: [] }, codex: { order: [], hidden: [] } },
|
|
85
90
|
feishu: { watchdogMs: 10 * 60 * 1000, bots: [] },
|
|
86
91
|
};
|
|
87
92
|
}
|
package/src/core/hub.ts
CHANGED
|
@@ -34,6 +34,7 @@ import type {
|
|
|
34
34
|
StepKind,
|
|
35
35
|
UserInput,
|
|
36
36
|
} from "./types.js";
|
|
37
|
+
import { buildCatalog, type AgentCommand, type CommandEntry, type CommandPrefs } from "./command-catalog.js";
|
|
37
38
|
import { helpText, type ParsedCommand } from "./commands.js";
|
|
38
39
|
|
|
39
40
|
// ---------- 事件 ----------
|
|
@@ -95,6 +96,8 @@ export type CommandReply =
|
|
|
95
96
|
effortSupported: boolean;
|
|
96
97
|
}
|
|
97
98
|
| { type: "agents"; agents: { id: string; label: string }[]; current: string }
|
|
99
|
+
/** 只发了「/」:可用命令(已按用户配置排序、去掉隐藏的) */
|
|
100
|
+
| { type: "commands"; agent: string; commands: CommandEntry[] }
|
|
98
101
|
| { type: "none" };
|
|
99
102
|
|
|
100
103
|
export type ConversationInfo = {
|
|
@@ -368,6 +371,7 @@ class Hub extends EventEmitter<{ event: [EventEnvelope] }> {
|
|
|
368
371
|
...(effort ? { effort } : {}),
|
|
369
372
|
...(resumeId ? { resumeId } : {}),
|
|
370
373
|
...(fork ? { fork: true } : {}),
|
|
374
|
+
...(conv.autoEdit ? { autoEdit: true } : {}),
|
|
371
375
|
},
|
|
372
376
|
{
|
|
373
377
|
onTurnStart: () => alive() && this.emitEvent(key, { type: "turn_start" }),
|
|
@@ -756,6 +760,28 @@ class Hub extends EventEmitter<{ event: [EventEnvelope] }> {
|
|
|
756
760
|
return `思考程度已切换为 ${effort}。`;
|
|
757
761
|
}
|
|
758
762
|
|
|
763
|
+
/** /autoedit:on 不给就是切换。只影响正在跑的会话和以后新建的,不追溯已经弹出的授权。 */
|
|
764
|
+
private async setAutoEditNow(key: string, on: boolean | undefined): Promise<string> {
|
|
765
|
+
const conv = getConversation(key)!;
|
|
766
|
+
const next = on ?? !conv.autoEdit;
|
|
767
|
+
const live = this.live.get(key);
|
|
768
|
+
if (live?.session.setAutoEdit) await withTimeout(live.session.setAutoEdit(next), "切换自动改文件");
|
|
769
|
+
updateConversation(key, { autoEdit: next });
|
|
770
|
+
this.emitEvent(key, { type: "meta" });
|
|
771
|
+
const driver = this.requireDriver(conv.agent);
|
|
772
|
+
const note =
|
|
773
|
+
conv.agent === "codex"
|
|
774
|
+
? `\n\n注意:${driver.label} 改文件由沙箱控制(设置 → Agent → 沙箱),这个开关只对 Claude 生效。`
|
|
775
|
+
: "";
|
|
776
|
+
return next
|
|
777
|
+
? `已开启:这条对话里 Claude 修改工作目录(${conv.cwd})内的文件不再询问;目录外的文件、Bash 命令照常询问。发 /autoedit off 关闭。${note}`
|
|
778
|
+
: `已关闭:改文件恢复逐次询问。${note}`;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
setAutoEdit(key: string, on?: boolean): Promise<string> {
|
|
782
|
+
return this.serial(key, () => this.setAutoEditNow(key, on));
|
|
783
|
+
}
|
|
784
|
+
|
|
759
785
|
/** 切 agent:各 agent 的会话各自保留,切回来接着聊。 */
|
|
760
786
|
private async setAgentNow(key: string, agent: string, allowed: string[], op: SwitchOp): Promise<string> {
|
|
761
787
|
const conv = getConversation(key)!;
|
|
@@ -809,6 +835,7 @@ class Hub extends EventEmitter<{ event: [EventEnvelope] }> {
|
|
|
809
835
|
`目录:${conv.cwd}`,
|
|
810
836
|
`模型:${runtime.model ?? "默认"}`,
|
|
811
837
|
...(runtime.effort ? [`思考程度:${runtime.effort}`] : []),
|
|
838
|
+
...(conv.autoEdit ? ["自动允许改文件:开(/autoedit off 关闭)"] : []),
|
|
812
839
|
...(extra.length > 0 ? ["", ...extra] : []),
|
|
813
840
|
].join("\n");
|
|
814
841
|
}
|
|
@@ -879,8 +906,15 @@ class Hub extends EventEmitter<{ event: [EventEnvelope] }> {
|
|
|
879
906
|
switch (cmd.kind) {
|
|
880
907
|
case "help":
|
|
881
908
|
return text(helpText(ctx.allowedAgents));
|
|
909
|
+
case "commands": {
|
|
910
|
+
const conv = getConversation(key)!;
|
|
911
|
+
const all = await this.commandCatalog(conv.agent, { cwd: conv.cwd, allowedAgents: ctx.allowedAgents });
|
|
912
|
+
return { type: "commands", agent: conv.agent, commands: all.filter((c) => !c.hidden) };
|
|
913
|
+
}
|
|
882
914
|
case "pwd":
|
|
883
915
|
return text(getConversation(key)!.cwd);
|
|
916
|
+
case "autoedit":
|
|
917
|
+
return text(await this.setAutoEdit(key, cmd.on));
|
|
884
918
|
case "status":
|
|
885
919
|
return text(await this.status(key));
|
|
886
920
|
case "cd":
|
|
@@ -924,6 +958,27 @@ class Hub extends EventEmitter<{ event: [EventEnvelope] }> {
|
|
|
924
958
|
}
|
|
925
959
|
}
|
|
926
960
|
|
|
961
|
+
/**
|
|
962
|
+
* 某个 agent 的完整命令表(含隐藏项,带 hidden 标记),已按配置排序。
|
|
963
|
+
* 原生命令读不到、或者超时,就只给 im-agent 自己的 —— 列表少几项,总比「/」没反应强。
|
|
964
|
+
* 未启用的 agent 不去问:问一次就可能拉起它的进程。
|
|
965
|
+
*/
|
|
966
|
+
async commandCatalog(
|
|
967
|
+
agent: string,
|
|
968
|
+
opts: { cwd?: string; allowedAgents: string[] },
|
|
969
|
+
): Promise<CommandEntry[]> {
|
|
970
|
+
const driver = this.enabledAgents().find((d) => d.id === agent);
|
|
971
|
+
let native: AgentCommand[] = [];
|
|
972
|
+
if (driver?.commands) {
|
|
973
|
+
native = await withTimeout(driver.commands(opts.cwd), "读取命令列表").catch((err) => {
|
|
974
|
+
console.error(`[${agent}] 读取命令列表失败:`, err instanceof Error ? err.message : err);
|
|
975
|
+
return [];
|
|
976
|
+
});
|
|
977
|
+
}
|
|
978
|
+
const prefs = (config().commands as Record<string, CommandPrefs | undefined>)[agent];
|
|
979
|
+
return buildCatalog({ allowedAgents: opts.allowedAgents, native, prefs });
|
|
980
|
+
}
|
|
981
|
+
|
|
927
982
|
async shutdown(): Promise<void> {
|
|
928
983
|
for (const key of [...this.live.keys()]) await this.drop(key, "服务正在重启");
|
|
929
984
|
for (const driver of this.drivers.values()) await driver.stop().catch(() => {});
|
package/src/core/store.ts
CHANGED
|
@@ -38,6 +38,8 @@ export type Conversation = {
|
|
|
38
38
|
createdAt: number;
|
|
39
39
|
/** Web 列表里隐藏(Web 上删除的会话只是隐藏,agent 侧的历史还在) */
|
|
40
40
|
hidden?: boolean;
|
|
41
|
+
/** /autoedit:这条对话里 agent 在工作目录内改文件不再询问(跨 /new 保留,属于对话不属于某个会话) */
|
|
42
|
+
autoEdit?: boolean;
|
|
41
43
|
};
|
|
42
44
|
|
|
43
45
|
type StoreShape = Record<string, Conversation>;
|
package/src/core/types.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// 核心类型:渠道(飞书 / Web / 以后的 Telegram)与 agent(Claude / Codex / 以后的 ACP)
|
|
2
2
|
// 之间只通过这里的类型说话。渠道不认识 Claude,agent 不认识飞书。
|
|
3
|
+
import type { AgentCommand } from "./command-catalog.js";
|
|
3
4
|
|
|
4
5
|
// ---------- 用户输入 ----------
|
|
5
6
|
|
|
@@ -118,6 +119,8 @@ export type SessionOptions = {
|
|
|
118
119
|
fork?: boolean;
|
|
119
120
|
/** 附件所在目录,agent 需要能直接读 */
|
|
120
121
|
uploadsDir: string;
|
|
122
|
+
/** 工作目录内改文件不再询问(/autoedit)。Bash 等照常询问 */
|
|
123
|
+
autoEdit?: boolean;
|
|
121
124
|
};
|
|
122
125
|
|
|
123
126
|
export interface AgentSession {
|
|
@@ -127,6 +130,8 @@ export interface AgentSession {
|
|
|
127
130
|
setModel(model?: string, effort?: string): Promise<void>;
|
|
128
131
|
describeRuntime(): Promise<RuntimeInfo>;
|
|
129
132
|
dispose(): Promise<void>;
|
|
133
|
+
/** 运行中切换「自动允许改文件」;不支持的 agent 不实现 */
|
|
134
|
+
setAutoEdit?(on: boolean): Promise<void>;
|
|
130
135
|
}
|
|
131
136
|
|
|
132
137
|
export type Diagnosis = {
|
|
@@ -151,6 +156,10 @@ export interface AgentDriver {
|
|
|
151
156
|
readHistory(id: string, cwd: string): Promise<HistoryMessage[]>;
|
|
152
157
|
/** 有的 agent(Claude)要借一个活着的会话才能问到模型列表 */
|
|
153
158
|
listModels(session: AgentSession): Promise<ModelInfo[]>;
|
|
159
|
+
/** 不依赖会话的模型列表,设置页的下拉框用 */
|
|
160
|
+
catalog(): Promise<ModelInfo[]>;
|
|
161
|
+
/** agent 自己的斜杠命令(skills 之类),没有就不实现。cwd 影响项目级命令 */
|
|
162
|
+
commands?(cwd?: string): Promise<AgentCommand[]>;
|
|
154
163
|
/** 不建会话也能知道的默认模型 / 思考程度 */
|
|
155
164
|
defaults(): Promise<RuntimeInfo>;
|
|
156
165
|
/** /status 里额外的几行(比如 Codex 的额度) */
|