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 +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 +35 -10
- package/src/agents/codex/driver.ts +4 -0
- package/src/agents/codex/session.ts +24 -6
- package/src/channels/feishu/bot.ts +60 -0
- package/src/channels/feishu/cards.ts +52 -1
- package/src/channels/feishu/envelope.ts +7 -2
- 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 +22 -17
- package/src/core/config.ts +5 -0
- package/src/core/hub.ts +58 -0
- package/src/core/interactions.ts +13 -1
- package/src/core/store.ts +2 -0
- package/src/core/types.ts +16 -1
- package/web/app.js +547 -15
- package/web/style.css +147 -0
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
|
|
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
|
@@ -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
|
-
|
|
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
|
|
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
|
-
//
|
|
156
|
-
|
|
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-
|
|
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
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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-
|
|
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;
|