im-agent 0.1.1 → 0.2.1

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|allow-session|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.1",
3
+ "version": "0.2.1",
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
+ }
@@ -27,12 +27,22 @@ import {
27
27
  alwaysLabelOf,
28
28
  describeToolCall,
29
29
  isPreApproved,
30
+ isRememberable,
30
31
  isRememberableSignature,
31
32
  parseQuestions,
32
33
  rememberAlways,
33
34
  signatureOf,
34
35
  } from "./permissions.js";
35
36
 
37
+ export function toModelInfo(models: Awaited<ReturnType<Query["supportedModels"]>>): ModelInfo[] {
38
+ return models.map((m) => ({
39
+ id: m.value,
40
+ label: m.displayName || m.value,
41
+ ...(m.description ? { description: m.description } : {}),
42
+ ...(m.supportedEffortLevels?.length ? { efforts: [...m.supportedEffortLevels] } : {}),
43
+ }));
44
+ }
45
+
36
46
  class AsyncQueue<T> {
37
47
  private items: T[] = [];
38
48
  private waiting: ((result: IteratorResult<T>) => void)[] = [];
@@ -125,6 +135,11 @@ export class ClaudeSession implements AgentSession {
125
135
  */
126
136
  private turnBusy = false;
127
137
  private pending: UserInput[] = [];
138
+ /**
139
+ * 用户点过「本会话全部允许」。只在内存里:这个对象一换(/new、切目录、切 agent、
140
+ * 恢复历史、进程重启),就重新逐条询问。
141
+ */
142
+ private allowAll = false;
128
143
 
129
144
  constructor(
130
145
  private readonly opts: SessionOptions,
@@ -152,8 +167,9 @@ export class ClaudeSession implements AgentSession {
152
167
  options: {
153
168
  cwd: this.opts.cwd,
154
169
  env: agentEnvironment(),
155
- // 必须是 default:bypassPermissions 会让 canUseTool 变成死代码
156
- permissionMode: "default",
170
+ // 只能是 default 或 acceptEdits:bypassPermissions 会让 canUseTool 变成死代码。
171
+ // acceptEdits 只放行工作目录(含附件目录)内的改文件,其它照样走 canUseTool
172
+ permissionMode: this.opts.autoEdit ? "acceptEdits" : "default",
157
173
  // 逐字输出给 Web 用;飞书渠道直接忽略这些片段
158
174
  includePartialMessages: true,
159
175
  ...(this.opts.model ? { model: this.opts.model } : {}),
@@ -255,6 +271,10 @@ export class ClaudeSession implements AgentSession {
255
271
  if (answered) return answered;
256
272
  }
257
273
 
274
+ // 提问、批准计划这类不是「安全确认」,而是要用户真的做决定,全部允许也照样问
275
+ const sessionable = isRememberable(toolName);
276
+ if (sessionable && this.allowAll) return { behavior: "allow", updatedInput: input };
277
+
258
278
  const signature = signatureOf(toolName, input);
259
279
  if (isPreApproved(toolName, signature)) return { behavior: "allow", updatedInput: input };
260
280
 
@@ -263,9 +283,14 @@ export class ClaudeSession implements AgentSession {
263
283
  title: `需要授权:${toolName}`,
264
284
  detail: describeToolCall(toolName, input),
265
285
  ...(isRememberableSignature(toolName, signature) ? { alwaysLabel: alwaysLabelOf(toolName, signature) } : {}),
286
+ ...(sessionable ? { allowSession: true } : {}),
266
287
  });
267
288
  if (outcome.settlement === "allow-always" && isRememberableSignature(toolName, signature)) rememberAlways(signature);
268
- if (outcome.settlement === "allow-once" || outcome.settlement === "allow-always") {
289
+ if (outcome.settlement === "allow-session") {
290
+ if (!this.allowAll) this.events.onStep("warning", "🔓 本会话内后续操作不再询问(/new 或切换会话后恢复逐条确认)");
291
+ this.allowAll = true;
292
+ }
293
+ if (outcome.settlement === "allow-once" || outcome.settlement === "allow-always" || outcome.settlement === "allow-session") {
269
294
  return { behavior: "allow", updatedInput: input };
270
295
  }
271
296
  const why =
@@ -342,13 +367,13 @@ export class ClaudeSession implements AgentSession {
342
367
 
343
368
  async listModels(): Promise<ModelInfo[]> {
344
369
  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
- }));
370
+ return toModelInfo(await this.q!.supportedModels());
371
+ }
372
+
373
+ async setAutoEdit(on: boolean): Promise<void> {
374
+ this.opts.autoEdit = on;
375
+ // 还没起 CLI 就不用管:下次拉起时按 opts 来
376
+ if (this.q) await this.q.setPermissionMode(on ? "acceptEdits" : "default");
352
377
  }
353
378
 
354
379
  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;
@@ -10,6 +10,8 @@ import type {
10
10
  AgentEvents,
11
11
  AgentSession,
12
12
  InteractionHost,
13
+ InteractionOutcome,
14
+ InteractionSpec,
13
15
  QuestionSpec,
14
16
  RuntimeInfo,
15
17
  SessionOptions,
@@ -113,7 +115,7 @@ function describePermissions(params: PermissionsApprovalParams): string {
113
115
  }
114
116
 
115
117
  function allowed(s: Settlement): boolean {
116
- return s === "allow-once" || s === "allow-always";
118
+ return s === "allow-once" || s === "allow-always" || s === "allow-session";
117
119
  }
118
120
 
119
121
  type ParsedQuestion = QuestionSpec & { secret: boolean };
@@ -176,6 +178,8 @@ export class CodexSession implements AgentSession {
176
178
  private opening: Promise<string> | null = null;
177
179
  private detached = false;
178
180
  private runtime: RuntimeInfo = {};
181
+ /** 用户点过「本会话全部允许」。只在内存里,会话对象一换就重新逐条询问。 */
182
+ private allowAll = false;
179
183
  private replyText = "";
180
184
  private lastTokens: number | undefined;
181
185
  /** 分叉已经真正完成(拿到并接管了新 thread)。失败了不算,重试还要接着分叉 */
@@ -473,6 +477,16 @@ export class CodexSession implements AgentSession {
473
477
 
474
478
  // ---------- 反向请求:审批与提问 ----------
475
479
 
480
+ /** 审批都走这里:带上「本会话全部允许」按钮,点了就记住。 */
481
+ private async ask(spec: Extract<InteractionSpec, { kind: "approval" }>): Promise<InteractionOutcome> {
482
+ const outcome = await this.host({ ...spec, allowSession: true });
483
+ if (outcome.settlement === "allow-session") {
484
+ if (!this.allowAll) this.events.onStep("warning", "🔓 本会话内后续操作不再询问(/new 或切换会话后恢复逐条确认)");
485
+ this.allowAll = true;
486
+ }
487
+ return outcome;
488
+ }
489
+
476
490
  /**
477
491
  * 每一条都**必须**返回点什么 —— 漏回一次,Codex 那一轮就永远挂在等审批上。
478
492
  * 未知方法也要按它自己的协议结构明确拒绝。
@@ -481,8 +495,9 @@ export class CodexSession implements AgentSession {
481
495
  switch (method) {
482
496
  case "item/commandExecution/requestApproval": {
483
497
  const req = params as CommandExecutionApprovalParams;
498
+ if (this.allowAll) return { decision: "acceptForSession" };
484
499
  const amendment = req.proposedExecpolicyAmendment ?? null;
485
- const outcome = await this.host({
500
+ const outcome = await this.ask({
486
501
  kind: "approval",
487
502
  title: req.kind === "writeStdin" ? "需要授权:向运行中的命令输入" : "需要授权:执行命令",
488
503
  detail: describeCommand(req),
@@ -491,6 +506,7 @@ export class CodexSession implements AgentSession {
491
506
  });
492
507
  const s = outcome.settlement;
493
508
  if (s === "allow-once") return { decision: "accept" };
509
+ if (s === "allow-session") return { decision: "acceptForSession" };
494
510
  if (s === "allow-always") {
495
511
  return {
496
512
  decision: amendment
@@ -504,7 +520,8 @@ export class CodexSession implements AgentSession {
504
520
 
505
521
  case "item/fileChange/requestApproval": {
506
522
  const req = params as FileChangeApprovalParams;
507
- const outcome = await this.host({
523
+ if (this.allowAll) return { decision: "acceptForSession" };
524
+ const outcome = await this.ask({
508
525
  kind: "approval",
509
526
  title: "需要授权:改动文件",
510
527
  detail: describeFileChange(req),
@@ -512,14 +529,15 @@ export class CodexSession implements AgentSession {
512
529
  });
513
530
  const s = outcome.settlement;
514
531
  if (s === "allow-once") return { decision: "accept" };
515
- if (s === "allow-always") return { decision: "acceptForSession" };
532
+ if (s === "allow-always" || s === "allow-session") return { decision: "acceptForSession" };
516
533
  if (s === "cancelled") return { decision: "cancel" };
517
534
  return { decision: "decline" };
518
535
  }
519
536
 
520
537
  case "item/permissions/requestApproval": {
521
538
  const req = params as PermissionsApprovalParams;
522
- const outcome = await this.host({
539
+ if (this.allowAll) return { permissions: req.permissions ?? {}, scope: "session" };
540
+ const outcome = await this.ask({
523
541
  kind: "approval",
524
542
  title: "需要授权:额外权限",
525
543
  detail: describePermissions(req),
@@ -528,7 +546,7 @@ export class CodexSession implements AgentSession {
528
546
  if (!allowed(outcome.settlement)) return { permissions: {}, scope: "turn" };
529
547
  return {
530
548
  permissions: req.permissions ?? {},
531
- scope: outcome.settlement === "allow-always" ? "session" : "turn",
549
+ scope: outcome.settlement === "allow-once" ? "turn" : "session",
532
550
  };
533
551
  }
534
552
 
@@ -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;