im-agent 0.1.0 → 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 CHANGED
@@ -48,7 +48,7 @@ im-agent service install
48
48
  | `im-agent service install \| uninstall \| restart \| status` | 开机自启 |
49
49
  | `im-agent doctor` | 体检 |
50
50
 
51
- 聊天里的命令(飞书和 Web 通用):`/new` `/fork` `/sessions [all]` `/resume <id>` `/model [名称]` `/effort <程度>` `/agent [名称]`(或者直接发 `/claude`、`/codex`)`/stop` `/cd <目录>` `/pwd` `/status` `/approve <id> allow-once|allow-always|deny` `/help`
51
+ 聊天里的命令(飞书和 Web 通用;Web 输入框打 `/` 会弹出命令补全,飞书里打 `/` 会弹出机器人的指令面板(im-agent 自动注册,约 5~8 分钟生效;只发一个 `/` 也会回一张可点击的命令卡片),顺序和显示项在 设置 → 命令 里调整):`/new` `/fork` `/sessions [all]` `/resume <id>` `/model [名称]` `/effort <程度>` `/agent [名称]`(或者直接发 `/claude`、`/codex`)`/stop` `/cd <目录>` `/pwd` `/status` `/approve <id> allow-once|allow-always|deny` `/help`
52
52
 
53
53
  ## 安全
54
54
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "im-agent",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "description": "把飞书(以及以后的 Telegram 等)和 Web 页面桥接到本机 Claude Code / Codex:聊天驱动、卡片审批、会话与模型切换",
6
6
  "keywords": [
@@ -0,0 +1,100 @@
1
+ // 账号额度:Claude 订阅的 5 小时 / 7 天用量窗口,和 Codex 那边的 account.ts 对齐。
2
+ //
3
+ // 数据来自 SDK 的 usage 控制请求 —— 也就是 Claude Code 里 `/usage` 背后的那份结构化数据,
4
+ // 通过探针 query 读取(见 probe.ts)。
5
+ //
6
+ // 注意:SDK 把它标成了 EXPERIMENTAL,方法名以后会改。改了的话这里只会读失败,
7
+ // /status 少几行而已,不影响别的。
8
+ import { cachedProbe, withProbe } from "./probe.js";
9
+
10
+ type Window = { utilization: number | null; resets_at: string | null } | null | undefined;
11
+
12
+ type LimitRow = {
13
+ kind: string;
14
+ group: string;
15
+ percent: number;
16
+ resets_at: string | null;
17
+ severity?: string;
18
+ scope?: { model?: { display_name: string } | null; surface?: { display_name: string } | null } | null;
19
+ };
20
+
21
+ export type ClaudeUsage = {
22
+ subscriptionType: string | null;
23
+ rows: { label: string; percent: number; resetsAt: string | null; severity?: string }[];
24
+ };
25
+
26
+ function labelOf(row: LimitRow): string {
27
+ const scope = row.scope?.model?.display_name || row.scope?.surface?.display_name;
28
+ const base = row.kind === "session" ? "5 小时" : row.group === "weekly" ? "7 天" : row.kind;
29
+ return scope ? `${base}(${scope})` : base;
30
+ }
31
+
32
+ function normalize(res: {
33
+ subscription_type: string | null;
34
+ rate_limits_available: boolean;
35
+ rate_limits: Record<string, unknown> | null;
36
+ }): ClaudeUsage | null {
37
+ if (!res.rate_limits_available || !res.rate_limits) return null;
38
+ const rl = res.rate_limits;
39
+ const limits = rl.limits as LimitRow[] | null | undefined;
40
+ let rows: ClaudeUsage["rows"];
41
+ if (Array.isArray(limits)) {
42
+ // 新版服务端直接给出要显示哪些行、什么顺序,照单全收
43
+ rows = limits.map((r) => ({
44
+ label: labelOf(r),
45
+ percent: r.percent,
46
+ resetsAt: r.resets_at,
47
+ ...(r.severity ? { severity: r.severity } : {}),
48
+ }));
49
+ } else {
50
+ // 老格式兜底:只认两个主窗口
51
+ rows = [];
52
+ for (const [key, label] of [
53
+ ["five_hour", "5 小时"],
54
+ ["seven_day", "7 天"],
55
+ ] as const) {
56
+ const w = rl[key] as Window;
57
+ if (w && w.utilization != null) rows.push({ label, percent: w.utilization, resetsAt: w.resets_at });
58
+ }
59
+ }
60
+ return { subscriptionType: res.subscription_type, rows };
61
+ }
62
+
63
+ async function fetchUsage(): Promise<ClaudeUsage | null> {
64
+ const res = await withProbe((q) =>
65
+ q.usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET({ skipBehaviors: true }),
66
+ );
67
+ return normalize(res as Parameters<typeof normalize>[0]);
68
+ }
69
+
70
+ /**
71
+ * 同 Codex:缓存 60 秒,挡住连敲几次 /status,又不至于数字明显过期。
72
+ * 读不到额度不该让整条 /status 失败 —— 它只是其中几行,所以失败时给 null。
73
+ */
74
+ export const readUsage = cachedProbe(60_000, "额度", fetchUsage, null);
75
+
76
+ /** 距离重置还有多久,和 Codex 那边同一个说法 */
77
+ function untilReset(resetsAt: string | null, now = Date.now()): string {
78
+ if (!resetsAt) return "";
79
+ const ms = Date.parse(resetsAt);
80
+ if (Number.isNaN(ms)) return "";
81
+ const seconds = Math.floor((ms - now) / 1000);
82
+ if (seconds <= 0) return "即将重置";
83
+ const days = Math.floor(seconds / 86400);
84
+ const hours = Math.floor((seconds % 86400) / 3600);
85
+ const minutes = Math.floor((seconds % 3600) / 60);
86
+ if (days > 0) return `${days} 天 ${hours} 小时后重置`;
87
+ if (hours > 0) return `${hours} 小时 ${minutes} 分后重置`;
88
+ return `${minutes} 分钟后重置`;
89
+ }
90
+
91
+ /** 渲染成 /status 里的几行;没有可显示的就返回空数组(API key / Bedrock 之类没有订阅额度) */
92
+ export function describeUsage(usage: ClaudeUsage | null, now = Date.now()): string[] {
93
+ if (!usage || usage.rows.length === 0) return [];
94
+ const rows = usage.rows.map((r) => {
95
+ const reset = untilReset(r.resetsAt, now);
96
+ return ` ${r.label} 已用 ${Math.round(r.percent)}%${reset ? ` ${reset}` : ""}`;
97
+ });
98
+ if (usage.rows.some((r) => r.percent >= 100)) rows.push(" ⚠️ 已达上限,等窗口重置后才能继续");
99
+ return [`额度${usage.subscriptionType ? `(${usage.subscriptionType})` : ""}`, ...rows];
100
+ }
@@ -7,6 +7,8 @@ import {
7
7
  getSessionMessages,
8
8
  listSessions,
9
9
  } from "@anthropic-ai/claude-agent-sdk";
10
+ import os from "node:os";
11
+ import type { AgentCommand } from "../../core/command-catalog.js";
10
12
  import { config } from "../../core/config.js";
11
13
  import type {
12
14
  AgentDriver,
@@ -19,8 +21,43 @@ import type {
19
21
  SessionOptions,
20
22
  SessionSummary,
21
23
  } from "../../core/types.js";
24
+ import { describeUsage, readUsage } from "./account.js";
25
+ import { cachedProbe, withProbe } from "./probe.js";
22
26
  import { diagnose } from "./errors.js";
23
- import { ClaudeSession, describeToolUse, summarize } from "./session.js";
27
+ import { ClaudeSession, describeToolUse, summarize, toModelInfo } from "./session.js";
28
+
29
+ /** 模型列表几乎不变,缓存 10 分钟;读失败抛给设置页,让它退回手填 */
30
+ const readCatalog = cachedProbe(10 * 60_000, "模型列表", () => withProbe(async (q) => toModelInfo(await q.supportedModels())));
31
+
32
+ /**
33
+ * 斜杠命令(skills、/compact 这些)。项目目录里可能有自己的命令,所以按 cwd 分开缓存;
34
+ * 装了新 skill 最多 5 分钟后出现。读不到就只显示 im-agent 自己的命令。
35
+ */
36
+ const commandReaders = new Map<string, () => Promise<AgentCommand[]>>();
37
+ function readCommands(cwd: string): Promise<AgentCommand[]> {
38
+ let reader = commandReaders.get(cwd);
39
+ if (!reader) {
40
+ // 目录数量一般很少;万一很多,丢掉最早的,别无限涨
41
+ if (commandReaders.size >= 50) commandReaders.delete(commandReaders.keys().next().value!);
42
+ reader = cachedProbe(
43
+ 5 * 60_000,
44
+ "Claude 命令列表",
45
+ () =>
46
+ withProbe(
47
+ async (q) =>
48
+ (await q.supportedCommands()).map((c) => ({
49
+ name: c.name,
50
+ description: c.description,
51
+ ...(c.argumentHint ? { args: c.argumentHint } : {}),
52
+ })),
53
+ cwd,
54
+ ),
55
+ [] as AgentCommand[],
56
+ );
57
+ commandReaders.set(cwd, reader);
58
+ }
59
+ return reader();
60
+ }
24
61
 
25
62
  /** sessionId 会被拼进路径,先验形,防 /resume ../../x 越界 */
26
63
  const SESSION_ID = /^[A-Za-z0-9_-]{8,64}$/;
@@ -131,6 +168,14 @@ export class ClaudeDriver implements AgentDriver {
131
168
  return (session as ClaudeSession).listModels();
132
169
  }
133
170
 
171
+ catalog(): Promise<ModelInfo[]> {
172
+ return readCatalog();
173
+ }
174
+
175
+ commands(cwd?: string): Promise<AgentCommand[]> {
176
+ return readCommands(cwd ?? os.homedir());
177
+ }
178
+
134
179
  async defaults(): Promise<RuntimeInfo> {
135
180
  const c = config().agents.claude;
136
181
  return {
@@ -140,7 +185,7 @@ export class ClaudeDriver implements AgentDriver {
140
185
  }
141
186
 
142
187
  async statusLines(): Promise<string[]> {
143
- return [];
188
+ return describeUsage(await readUsage());
144
189
  }
145
190
 
146
191
  diagnose(err: unknown) {
@@ -3,6 +3,7 @@
3
3
  // Codex 把「总是允许」做进了协议(acceptForSession / execpolicy 补丁),Claude 没有,
4
4
  // 只能自己记一份名单。特征刻意保守:Bash 只认命令的第一个词,文件类只认所在目录。
5
5
  // 宁可多问几次,也不要因为特征太宽而放行了本不该放行的东西。
6
+ import os from "node:os";
6
7
  import path from "node:path";
7
8
  import { dataDir, readJson, writeJsonAtomic } from "../../home.js";
8
9
  import type { QuestionSpec } from "../../core/types.js";
@@ -143,7 +144,39 @@ export function signatureOf(toolName: string, input: Record<string, unknown>): s
143
144
 
144
145
  export function isPreApproved(toolName: string, signature: string): boolean {
145
146
  // 防绕过靠的是 signatureOf:复合命令拿到的是 BashExact 签名,命中不了 Bash:<首词>
146
- return isRememberableSignature(toolName, signature) && load().includes(signature);
147
+ if (!isRememberableSignature(toolName, signature)) return false;
148
+ const list = load();
149
+ if (list.includes(signature)) return true;
150
+ return coveredBySubdirectoryRule(toolName, signature, list);
151
+ }
152
+
153
+ const DIR_TOOLS = new Set(["Edit", "Write", "NotebookEdit"]);
154
+
155
+ /**
156
+ * 按目录记的批准连带子目录:批准过 /home/me/project,改 project/src/core 下的文件不用再问。
157
+ * 以前只认完全相同的目录,项目里每进一个子目录都要再点一次,点到后来就是闭眼点 —— 那才不安全。
158
+ *
159
+ * 两道收口:
160
+ * - 太宽的批准不往下延伸:/、家目录、家目录的上级。批准过「在家目录下新建文件」
161
+ * 不等于能改 ~/.ssh、~/.bashrc;
162
+ * - 不延伸进隐藏目录(.git/hooks、.github/workflows、.husky…):往那里写东西等于
163
+ * 让它下次自动执行。真要改,单独批一次那个目录。
164
+ */
165
+ function coveredBySubdirectoryRule(toolName: string, signature: string, list: string[]): boolean {
166
+ if (!DIR_TOOLS.has(toolName)) return false;
167
+ const target = signature.slice(toolName.length + 1);
168
+ const home = os.homedir();
169
+ for (const entry of list) {
170
+ if (!entry.startsWith(`${toolName}:`)) continue;
171
+ const approved = entry.slice(toolName.length + 1);
172
+ if (!path.isAbsolute(approved)) continue;
173
+ if (approved === path.parse(approved).root || approved === home || home.startsWith(`${approved}${path.sep}`)) continue;
174
+ const rel = path.relative(approved, target);
175
+ if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) continue;
176
+ if (rel.split(path.sep).some((part) => part.startsWith("."))) continue;
177
+ return true;
178
+ }
179
+ return false;
147
180
  }
148
181
 
149
182
  export function rememberAlways(signature: string): void {
@@ -168,7 +201,7 @@ export function alwaysLabelOf(toolName: string, signature: string): string {
168
201
  if (toolName === "Bash") {
169
202
  return signature.startsWith("BashExact:") ? "总是允许这条完全相同的命令" : `总是允许 ${signature.slice(5)} 开头的单条命令`;
170
203
  }
171
- if (signature.includes(":")) return `总是允许在该目录下${toolName === "Write" ? "新建" : "修改"}`;
204
+ if (signature.includes(":")) return `总是允许在该目录及子目录下${toolName === "Write" ? "新建" : "修改"}`;
172
205
  return `总是允许 ${toolName}`;
173
206
  }
174
207
 
@@ -0,0 +1,54 @@
1
+ // 「探针」query:拉一个从不发消息的 CLI 进程,只用来问控制请求(额度、模型列表),问完就关。
2
+ //
3
+ // 这些控制方法挂在 Query 上,正常要借活会话才能问;但 /status、设置页不该依赖
4
+ // 「当前恰好有活会话」。不发消息就不耗 token,代价只是起一次进程(两三秒)。
5
+ import os from "node:os";
6
+ import { query, type Query, type SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
7
+ import { agentEnvironment } from "../../core/agent-env.js";
8
+
9
+ /** 永远不产出消息的输入流:query 必须是流式输入模式才能发控制请求 */
10
+ async function* silence(): AsyncGenerator<SDKUserMessage> {
11
+ await new Promise<never>(() => {});
12
+ }
13
+
14
+ /** cwd 会影响项目级的东西(.claude/commands、项目 skills),不关心就用家目录 */
15
+ export async function withProbe<T>(fn: (q: Query) => Promise<T>, cwd = os.homedir()): Promise<T> {
16
+ const q = query({ prompt: silence(), options: { cwd, env: agentEnvironment() } });
17
+ try {
18
+ return await fn(q);
19
+ } finally {
20
+ q.close();
21
+ }
22
+ }
23
+
24
+ /**
25
+ * 带缓存、并发合并的读取:同一时刻多个调用方共用一次探针,结果缓存 ttl 毫秒。
26
+ * 给了 fallback 就是「读不到也无所谓」:失败记日志、返回并缓存 fallback,免得每次都再等几秒;
27
+ * 没给就把错误抛给调用方,也不缓存,下次重试。
28
+ */
29
+ export function cachedProbe<T>(
30
+ ttl: number,
31
+ what: string,
32
+ fetch: () => Promise<T>,
33
+ ...fallback: [] | [T]
34
+ ): () => Promise<T> {
35
+ let cache: { at: number; value: T } | null = null;
36
+ let inflight: Promise<T> | null = null;
37
+ return () => {
38
+ if (cache && Date.now() - cache.at < ttl) return Promise.resolve(cache.value);
39
+ inflight ??= fetch()
40
+ .catch((err) => {
41
+ console.error(`[${what}] 读取失败:`, err instanceof Error ? err.message : err);
42
+ if (fallback.length === 0) throw err;
43
+ return fallback[0] as T;
44
+ })
45
+ .then((value) => {
46
+ cache = { at: Date.now(), value };
47
+ return value;
48
+ })
49
+ .finally(() => {
50
+ inflight = null;
51
+ });
52
+ return inflight;
53
+ };
54
+ }
@@ -33,6 +33,15 @@ import {
33
33
  signatureOf,
34
34
  } from "./permissions.js";
35
35
 
36
+ export function toModelInfo(models: Awaited<ReturnType<Query["supportedModels"]>>): ModelInfo[] {
37
+ return models.map((m) => ({
38
+ id: m.value,
39
+ label: m.displayName || m.value,
40
+ ...(m.description ? { description: m.description } : {}),
41
+ ...(m.supportedEffortLevels?.length ? { efforts: [...m.supportedEffortLevels] } : {}),
42
+ }));
43
+ }
44
+
36
45
  class AsyncQueue<T> {
37
46
  private items: T[] = [];
38
47
  private waiting: ((result: IteratorResult<T>) => void)[] = [];
@@ -152,8 +161,9 @@ export class ClaudeSession implements AgentSession {
152
161
  options: {
153
162
  cwd: this.opts.cwd,
154
163
  env: agentEnvironment(),
155
- // 必须是 default:bypassPermissions 会让 canUseTool 变成死代码
156
- permissionMode: "default",
164
+ // 只能是 default 或 acceptEdits:bypassPermissions 会让 canUseTool 变成死代码。
165
+ // acceptEdits 只放行工作目录(含附件目录)内的改文件,其它照样走 canUseTool
166
+ permissionMode: this.opts.autoEdit ? "acceptEdits" : "default",
157
167
  // 逐字输出给 Web 用;飞书渠道直接忽略这些片段
158
168
  includePartialMessages: true,
159
169
  ...(this.opts.model ? { model: this.opts.model } : {}),
@@ -342,13 +352,13 @@ export class ClaudeSession implements AgentSession {
342
352
 
343
353
  async listModels(): Promise<ModelInfo[]> {
344
354
  this.start();
345
- const models = await this.q!.supportedModels();
346
- return models.map((m) => ({
347
- id: m.value,
348
- label: m.displayName || m.value,
349
- ...(m.description ? { description: m.description } : {}),
350
- ...(m.supportedEffortLevels?.length ? { efforts: [...m.supportedEffortLevels] } : {}),
351
- }));
355
+ return toModelInfo(await this.q!.supportedModels());
356
+ }
357
+
358
+ async setAutoEdit(on: boolean): Promise<void> {
359
+ this.opts.autoEdit = on;
360
+ // 还没起 CLI 就不用管:下次拉起时按 opts 来
361
+ if (this.q) await this.q.setPermissionMode(on ? "acceptEdits" : "default");
352
362
  }
353
363
 
354
364
  async setModel(model?: string, effort?: string): Promise<void> {
@@ -155,6 +155,10 @@ export class CodexDriver implements AgentDriver {
155
155
  }));
156
156
  }
157
157
 
158
+ catalog(): Promise<ModelInfo[]> {
159
+ return this.listModels();
160
+ }
161
+
158
162
  /** config.toml 里的默认值(config/read),不需要为此建 thread。 */
159
163
  async defaults(): Promise<RuntimeInfo> {
160
164
  const c = config().agents.codex;
@@ -17,9 +17,12 @@ import { getConversation, updateConversation } from "../../core/store.js";
17
17
  import { adoptFile, KIND_LABEL } from "../../core/uploads.js";
18
18
  import type { SavedAttachment } from "../../core/types.js";
19
19
  import { FeishuClient } from "./client.js";
20
+ import { SlashSync } from "./slash-sync.js";
21
+ import type { CommandEntry } from "../../core/command-catalog.js";
20
22
  import {
21
23
  agentPickerCard,
22
24
  approvalCard,
25
+ commandListCard,
23
26
  modelPickerCard,
24
27
  progressCard,
25
28
  questionCard,
@@ -56,8 +59,27 @@ export class FeishuBot {
56
59
  private stopped = false;
57
60
  status: BotStatus = { state: "stopped", since: Date.now() };
58
61
 
62
+ /** 飞书输入框里「/」弹出的指令面板 */
63
+ readonly slash: SlashSync;
64
+ private slashTimer: NodeJS.Timeout | null = null;
65
+
59
66
  constructor(readonly cfg: FeishuBotConfig) {
60
67
  this.client = new FeishuClient(cfg.appId, cfg.appSecret, cfg.domain, `飞书:${cfg.name}`);
68
+ this.slash = new SlashSync(this.client.lark, () => this.slashCommands(), `[飞书:${cfg.name}]`);
69
+ }
70
+
71
+ /**
72
+ * 这个机器人该注册的命令:默认 agent 的命令表在前,其它可用 agent 的原生命令接在后面。
73
+ * 飞书的指令面板是整个应用一份,不分会话,没法跟着每个会话当前的 agent 变。
74
+ */
75
+ private async slashCommands(): Promise<CommandEntry[]> {
76
+ const agents = [this.cfg.defaultAgent, ...this.cfg.agents.filter((a) => a !== this.cfg.defaultAgent)];
77
+ const out: CommandEntry[] = [];
78
+ for (const agent of agents) {
79
+ if (!hub.driver(agent)) continue;
80
+ out.push(...(await hub.commandCatalog(agent, { allowedAgents: this.cfg.agents })));
81
+ }
82
+ return out;
61
83
  }
62
84
 
63
85
  get id(): string {
@@ -120,10 +142,17 @@ export class FeishuBot {
120
142
 
121
143
  this.connect();
122
144
  this.startWatchdog();
145
+ // 等 agent 起来再同步(Claude 的原生命令要起进程读);之后每 6 小时对一次账,新装的 skill 会跟上
146
+ this.slash.request(15_000);
147
+ this.slashTimer = setInterval(() => this.slash.request(0), 6 * 3600_000);
148
+ this.slashTimer.unref?.();
123
149
  }
124
150
 
125
151
  stop(): void {
126
152
  this.stopped = true;
153
+ this.slash.stop();
154
+ if (this.slashTimer) clearInterval(this.slashTimer);
155
+ this.slashTimer = null;
127
156
  for (const off of this.offs.splice(0)) off();
128
157
  if (this.watchdog) clearInterval(this.watchdog);
129
158
  this.watchdog = null;
@@ -486,6 +515,12 @@ export class FeishuBot {
486
515
  case "agents":
487
516
  await this.client.sendCard(chatId, agentPickerCard({ ...reply, sessionKey: key, ctx }));
488
517
  return;
518
+ case "commands":
519
+ await this.client.sendCard(
520
+ chatId,
521
+ commandListCard({ agentLabel: hub.label(reply.agent), commands: reply.commands, sessionKey: key, ctx }),
522
+ );
523
+ return;
489
524
  case "none":
490
525
  return;
491
526
  }
@@ -595,9 +630,34 @@ export class FeishuBot {
595
630
  case "agent":
596
631
  this.background(envelope.k, () => hub.setAgent(envelope.k, envelope.g, this.allowedAgents()));
597
632
  return { toast: { type: "success", content: `正在切换到 ${hub.label(envelope.g)}` } };
633
+ case "cmd":
634
+ return this.runCardCommand(envelope.k, `/${envelope.c}`);
598
635
  }
599
636
  }
600
637
 
638
+ /**
639
+ * 命令表上点「执行」:等同于用户自己发了这条命令。
640
+ * im-agent 的命令就地执行并回卡片;agent 原生命令(skills 等)当一条消息发给 agent。
641
+ */
642
+ private runCardCommand(key: string, text: string): CardResponse {
643
+ if (!this.mine(key) || !getConversation(key)) {
644
+ return { toast: { type: "error", content: "这条会话已不存在,请重新发一次「/」。" } };
645
+ }
646
+ const command = parseCommand(text);
647
+ if (command.kind === "chat") {
648
+ const origin = { channel: "feishu", channelId: this.cfg.id, name: "飞书" };
649
+ const gen = hub.generation(key);
650
+ this.enqueue(key, async () => hub.send(key, { text, attachments: [] }, origin, gen));
651
+ return { toast: { type: "success", content: `已发送 ${text}` } };
652
+ }
653
+ const chatId = this.chatIdOf(key);
654
+ void hub
655
+ .command(key, command, { allowedAgents: this.allowedAgents(), by: "飞书命令" })
656
+ .then((reply) => this.renderReply(key, reply))
657
+ .catch((err) => this.client.sendText(chatId, `出错了:${err instanceof Error ? err.message : String(err)}`));
658
+ return { toast: { type: "info", content: `正在执行 ${text}` } };
659
+ }
660
+
601
661
  /** 卡片回调里耗时的操作:先应答,做完在会话里回一句。 */
602
662
  private background(key: string, task: () => Promise<string>): void {
603
663
  if (!this.mine(key) || !getConversation(key)) return;
@@ -6,6 +6,7 @@
6
6
  // 3. 没有 `tag: "note"`,小字提示只能并进 markdown
7
7
  import { createEnvelope, type CardCtx, type EnvelopeAction } from "./envelope.js";
8
8
  import { answerTextOf, SETTLEMENT_LABEL, type Interaction } from "../../core/interactions.js";
9
+ import { usageOf, type CommandEntry } from "../../core/command-catalog.js";
9
10
  import type { ModelInfo, SessionSummary } from "../../core/types.js";
10
11
 
11
12
  function button(
@@ -226,6 +227,55 @@ export function modelPickerCard(params: {
226
227
  return card(elements, { title: "切换模型", template: "blue" });
227
228
  }
228
229
 
230
+ /** 卡片太大会被飞书拒收(JSON 上限约 30KB),列表截到这么多条 */
231
+ const COMMAND_CARD_MAX = 30;
232
+
233
+ /**
234
+ * 只发「/」时回的命令表。顺序就是设置里排好的顺序;不带参数的命令给一个「执行」按钮,
235
+ * 要参数的只展示用法,让用户自己打。
236
+ */
237
+ export function commandListCard(params: {
238
+ agentLabel: string;
239
+ commands: CommandEntry[];
240
+ sessionKey: string;
241
+ ctx: CardCtx;
242
+ }) {
243
+ const shown = params.commands.slice(0, COMMAND_CARD_MAX);
244
+ const rest = params.commands.length - shown.length;
245
+ const oneLine = (s: string) => {
246
+ const t = s.replace(/\s+/g, " ").trim();
247
+ return t.length > 60 ? `${t.slice(0, 60)}…` : t;
248
+ };
249
+ const elements: unknown[] = [
250
+ {
251
+ tag: "markdown",
252
+ content: `当前 agent:**${params.agentLabel}**。点「执行」直接运行;带 \`<参数>\` 的请手动输入。\n顺序和显示哪些可以在 Web 设置 → 命令里调整。`,
253
+ },
254
+ { tag: "hr" },
255
+ ];
256
+ for (const c of shown) {
257
+ const tag = c.source === "agent" ? ` <font color='grey'>· ${params.agentLabel}</font>` : "";
258
+ const text = `**${usageOf(c)}**${tag}\n<font color='grey'>${oneLine(c.description) || "(无说明)"}</font>`;
259
+ elements.push({
260
+ tag: "column_set",
261
+ horizontal_spacing: "8px",
262
+ columns: [
263
+ { tag: "column", width: "weighted", weight: 1, vertical_align: "center", elements: [{ tag: "markdown", content: text }] },
264
+ {
265
+ tag: "column",
266
+ width: "auto",
267
+ vertical_align: "center",
268
+ elements: c.runnable ? [button("执行", { a: "cmd", c: c.name, k: params.sessionKey }, params.ctx)] : [],
269
+ },
270
+ ],
271
+ });
272
+ }
273
+ if (rest > 0) {
274
+ elements.push({ tag: "markdown", content: `<font color='grey'>还有 ${rest} 个没列出,可以在设置里把常用的排到前面、隐藏用不到的。</font>` });
275
+ }
276
+ return card(elements, { title: "可用命令", template: "blue" });
277
+ }
278
+
229
279
  export function agentPickerCard(params: {
230
280
  agents: { id: string; label: string }[];
231
281
  current: string;
@@ -21,7 +21,9 @@ export type EnvelopeAction =
21
21
  | { a: "model"; m: string; k: string }
22
22
  | { a: "effort"; f: string; k: string }
23
23
  | { a: "agent"; g: string; k: string }
24
- | { a: "resume"; s: string; k: string; c?: string };
24
+ | { a: "resume"; s: string; k: string; c?: string }
25
+ /** 命令列表卡上的「执行」:c 是命令名,不带斜杠 */
26
+ | { a: "cmd"; c: string; k: string };
25
27
 
26
28
  export type CardEnvelope = EnvelopeAction & {
27
29
  oc: typeof ENVELOPE_VERSION;
@@ -98,6 +100,9 @@ function validAction(v: Record<string, unknown>): boolean {
98
100
  return str(v.g) && str(v.k);
99
101
  case "resume":
100
102
  return str(v.s) && str(v.k) && (v.c === undefined || str(v.c));
103
+ case "cmd":
104
+ // 只放行命令名该有的字符(skill 名会带冒号),别让参数、换行之类混进来
105
+ return str(v.c) && /^[\w:.-]+$/.test(v.c) && str(v.k);
101
106
  default:
102
107
  return false;
103
108
  }
@@ -35,7 +35,17 @@ function reconcile(list: FeishuBotConfig[]): void {
35
35
 
36
36
  export function startFeishu(): void {
37
37
  reconcile(config().feishu.bots);
38
- onConfigChange((next) => reconcile(next.feishu.bots));
38
+ onConfigChange((next, prev) => {
39
+ reconcile(next.feishu.bots);
40
+ // 命令顺序 / 隐藏改了,或者机器人能用的 agent 变了:重新同步飞书指令面板
41
+ const commandsChanged = JSON.stringify(next.commands) !== JSON.stringify(prev.commands);
42
+ for (const bot of bots.values()) {
43
+ const before = prev.feishu.bots.find((b) => b.id === bot.id);
44
+ const agentsChanged =
45
+ !before || JSON.stringify([before.agents, before.defaultAgent]) !== JSON.stringify([bot.cfg.agents, bot.cfg.defaultAgent]);
46
+ if (commandsChanged || agentsChanged) bot.slash.request();
47
+ }
48
+ });
39
49
  // 配对被批准(Web 上点的,或终端里另一个进程批的):通知等着的人
40
50
  watchPairing((channelId, _userId, chatId) => {
41
51
  if (chatId) bots.get(channelId)?.notifyApproved(chatId);