im-agent 0.2.0 → 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 通用;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`
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.2.0",
3
+ "version": "0.2.1",
4
4
  "type": "module",
5
5
  "description": "把飞书(以及以后的 Telegram 等)和 Web 页面桥接到本机 Claude Code / Codex:聊天驱动、卡片审批、会话与模型切换",
6
6
  "keywords": [
@@ -27,6 +27,7 @@ import {
27
27
  alwaysLabelOf,
28
28
  describeToolCall,
29
29
  isPreApproved,
30
+ isRememberable,
30
31
  isRememberableSignature,
31
32
  parseQuestions,
32
33
  rememberAlways,
@@ -134,6 +135,11 @@ export class ClaudeSession implements AgentSession {
134
135
  */
135
136
  private turnBusy = false;
136
137
  private pending: UserInput[] = [];
138
+ /**
139
+ * 用户点过「本会话全部允许」。只在内存里:这个对象一换(/new、切目录、切 agent、
140
+ * 恢复历史、进程重启),就重新逐条询问。
141
+ */
142
+ private allowAll = false;
137
143
 
138
144
  constructor(
139
145
  private readonly opts: SessionOptions,
@@ -265,6 +271,10 @@ export class ClaudeSession implements AgentSession {
265
271
  if (answered) return answered;
266
272
  }
267
273
 
274
+ // 提问、批准计划这类不是「安全确认」,而是要用户真的做决定,全部允许也照样问
275
+ const sessionable = isRememberable(toolName);
276
+ if (sessionable && this.allowAll) return { behavior: "allow", updatedInput: input };
277
+
268
278
  const signature = signatureOf(toolName, input);
269
279
  if (isPreApproved(toolName, signature)) return { behavior: "allow", updatedInput: input };
270
280
 
@@ -273,9 +283,14 @@ export class ClaudeSession implements AgentSession {
273
283
  title: `需要授权:${toolName}`,
274
284
  detail: describeToolCall(toolName, input),
275
285
  ...(isRememberableSignature(toolName, signature) ? { alwaysLabel: alwaysLabelOf(toolName, signature) } : {}),
286
+ ...(sessionable ? { allowSession: true } : {}),
276
287
  });
277
288
  if (outcome.settlement === "allow-always" && isRememberableSignature(toolName, signature)) rememberAlways(signature);
278
- 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") {
279
294
  return { behavior: "allow", updatedInput: input };
280
295
  }
281
296
  const why =
@@ -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
 
@@ -62,6 +62,7 @@ export function approvalCard(interaction: Interaction, ctx: CardCtx) {
62
62
  const buttons = [
63
63
  button("允许一次", { a: "approval", r: id, d: "allow-once" }, ctx, "primary"),
64
64
  ...(spec.alwaysLabel ? [button(spec.alwaysLabel, { a: "approval", r: id, d: "allow-always" }, ctx)] : []),
65
+ ...(spec.allowSession ? [button("本会话全部允许", { a: "approval", r: id, d: "allow-session" }, ctx)] : []),
65
66
  button("拒绝", { a: "approval", r: id, d: "deny" }, ctx, "danger"),
66
67
  ];
67
68
  return card(
@@ -127,7 +128,7 @@ export function settledCard(interaction: Interaction) {
127
128
  const settlement = settled?.settlement ?? "cancelled";
128
129
  const by = settled?.by ?? "";
129
130
  if (interaction.spec.kind === "approval") {
130
- const ok = settlement === "allow-once" || settlement === "allow-always";
131
+ const ok = settlement === "allow-once" || settlement === "allow-always" || settlement === "allow-session";
131
132
  const label =
132
133
  settlement === "allow-always"
133
134
  ? `已允许(${interaction.spec.alwaysLabel ?? "后续同类自动放行"})`
@@ -86,7 +86,7 @@ const idx = (v: unknown): v is number => typeof v === "number" && Number.isInteg
86
86
  function validAction(v: Record<string, unknown>): boolean {
87
87
  switch (v.a) {
88
88
  case "approval":
89
- return str(v.r) && (v.d === "allow-once" || v.d === "allow-always" || v.d === "deny");
89
+ return str(v.r) && (v.d === "allow-once" || v.d === "allow-always" || v.d === "allow-session" || v.d === "deny");
90
90
  case "ask":
91
91
  return str(v.r) && idx(v.q) && idx(v.o) && (v.v === 0 || v.v === 1);
92
92
  case "ask-submit":
@@ -23,7 +23,7 @@ export const BUILTIN_COMMANDS: BuiltinSpec[] = [
23
23
  { name: "cd", args: "<相对目录>", description: "切换工作目录(限 workspace 内)" },
24
24
  { name: "pwd", description: "显示当前工作目录" },
25
25
  { name: "autoedit", args: "[on|off]", description: "本对话自动允许改工作目录内的文件(Bash 仍询问)" },
26
- { name: "approve", args: "<请求ID> allow-once|allow-always|deny", description: "审批兜底(卡片失效时用)" },
26
+ { name: "approve", args: "<请求ID> allow-once|allow-always|allow-session|deny", description: "审批兜底(卡片失效时用)" },
27
27
  { name: "help", description: "显示帮助" },
28
28
  ];
29
29
 
@@ -24,7 +24,7 @@ export type ParsedCommand =
24
24
  | { kind: "approve"; requestId: string; decision: ApprovalDecision }
25
25
  | { kind: "chat"; text: string };
26
26
 
27
- const DECISIONS: ApprovalDecision[] = ["allow-once", "allow-always", "deny"];
27
+ const DECISIONS: ApprovalDecision[] = ["allow-once", "allow-always", "allow-session", "deny"];
28
28
 
29
29
  export function parseCommand(raw: string): ParsedCommand {
30
30
  const text = raw.trim();
package/src/core/hub.ts CHANGED
@@ -950,6 +950,9 @@ class Hub extends EventEmitter<{ event: [EventEnvelope] }> {
950
950
  return text(await this.stop(key));
951
951
  case "approve": {
952
952
  const settled = interactions.decide(cmd.requestId, cmd.decision as ApprovalDecision, ctx.by);
953
+ if (!settled && interactions.isPending(cmd.requestId)) {
954
+ return text(`请求 ${cmd.requestId} 不支持 ${cmd.decision}。`);
955
+ }
953
956
  if (!settled) return text(`请求 ${cmd.requestId} 不存在或已处理。`);
954
957
  return text(`${SETTLEMENT_LABEL[cmd.decision]}:${cmd.requestId}`);
955
958
  }
@@ -89,7 +89,18 @@ class InteractionCenter extends EventEmitter<InteractionEvents> {
89
89
  decide(id: string, decision: Settlement, by: string): Interaction | undefined {
90
90
  const entry = this.pending.get(id);
91
91
  if (!entry || entry.spec.kind !== "approval") return undefined;
92
- return this.settle(id, decision, by);
92
+ // 卡上没有这个按钮(比如批准计划)就不认:不能靠手打 /approve 绕过去
93
+ if (decision === "allow-session" && !entry.spec.allowSession) return undefined;
94
+ const settled = this.settle(id, decision, by);
95
+ if (decision === "allow-session") {
96
+ // 并行的工具调用会同时挂着好几张卡:同一个会话进程里能一并放行的,一起放掉
97
+ for (const other of [...this.pending.values()]) {
98
+ if (other.convKey !== entry.convKey || other.owner !== entry.owner) continue;
99
+ if (other.spec.kind !== "approval" || !other.spec.allowSession) continue;
100
+ this.settle(other.id, "allow-session", `${by}(随「本会话全部允许」)`);
101
+ }
102
+ }
103
+ return settled;
93
104
  }
94
105
 
95
106
  /** 把某个选项设成选中 / 未选中。全单选且都答完时自动收束。 */
@@ -251,6 +262,7 @@ export function answerTextOf(entry: Interaction): Record<string, string> {
251
262
  export const SETTLEMENT_LABEL: Record<Settlement, string> = {
252
263
  "allow-once": "已允许(本次)",
253
264
  "allow-always": "已允许",
265
+ "allow-session": "已允许(本会话全部放行)",
254
266
  deny: "已拒绝",
255
267
  timeout: "已超时,按拒绝处理",
256
268
  cancelled: "已取消",
package/src/core/types.ts CHANGED
@@ -20,7 +20,11 @@ export type UserInput = { text: string; attachments: SavedAttachment[] };
20
20
 
21
21
  // ---------- 交互(审批 / 提问) ----------
22
22
 
23
- export type ApprovalDecision = "allow-once" | "allow-always" | "deny";
23
+ /**
24
+ * allow-session:本会话内后续的审批全部自动放行(不落盘)。会话重建
25
+ * (/new、切目录、切 agent、恢复历史、进程重启)后失效。
26
+ */
27
+ export type ApprovalDecision = "allow-once" | "allow-always" | "allow-session" | "deny";
24
28
 
25
29
  /** 一个卡点的收束方式。timeout / cancelled 和 deny 一样都是「没放行」,但显示不同。 */
26
30
  export type Settlement = ApprovalDecision | "timeout" | "cancelled" | "answered" | "skipped";
@@ -44,6 +48,8 @@ export type InteractionSpec =
44
48
  detail: string;
45
49
  /** 「总是允许」按钮的文字;不给就没有这个按钮 */
46
50
  alwaysLabel?: string;
51
+ /** 给了就显示「本会话全部允许」按钮。只有 agent 能真正兑现时才给 */
52
+ allowSession?: boolean;
47
53
  }
48
54
  | { kind: "question"; title: string; questions: QuestionSpec[] };
49
55
 
package/web/app.js CHANGED
@@ -957,6 +957,7 @@ async function ixOp(id, op, extra = {}) {
957
957
  const SETTLE_LABEL = {
958
958
  "allow-once": "已允许(本次)",
959
959
  "allow-always": "已允许(总是)",
960
+ "allow-session": "已允许(本会话全部放行)",
960
961
  deny: "已拒绝",
961
962
  timeout: "已超时,按拒绝处理",
962
963
  cancelled: "已取消",
@@ -984,6 +985,9 @@ function renderInteraction(item) {
984
985
  spec.alwaysLabel
985
986
  ? h("button", { class: "btn", onclick: () => ixOp(ix.id, "decide", { decision: "allow-always" }) }, spec.alwaysLabel)
986
987
  : null,
988
+ spec.allowSession
989
+ ? h("button", { class: "btn", onclick: () => ixOp(ix.id, "decide", { decision: "allow-session" }) }, "本会话全部允许")
990
+ : null,
987
991
  h("button", { class: "btn danger", onclick: () => ixOp(ix.id, "decide", { decision: "deny" }) }, "拒绝"),
988
992
  ),
989
993
  );
@@ -1734,7 +1738,7 @@ function modelPicker(agent, model, effort, defaultLabel) {
1734
1738
  const asText = (reason) => {
1735
1739
  const m = h("input", { value: getModel(), placeholder: `留空${defaultLabel}` });
1736
1740
  const e = h("input", { value: getEffort(), placeholder: "low / medium / high …,留空默认" });
1737
- modelSlot.replaceChildren(m, reason ? h("small", {}, reason) : null);
1741
+ fill(modelSlot, m, reason ? h("small", {}, reason) : null);
1738
1742
  effortSlot.replaceChildren(e);
1739
1743
  getModel = () => m.value.trim();
1740
1744
  getEffort = () => e.value.trim();
@@ -1761,7 +1765,9 @@ function modelPicker(agent, model, effort, defaultLabel) {
1761
1765
  const picked = modelSel.value ? models.find((m) => m.id === modelSel.value) : fallback;
1762
1766
  const efforts = picked?.efforts ?? [];
1763
1767
  const keep = effortSel.value || effort;
1764
- effortSel.replaceChildren(
1768
+ // 子项里有数组:replaceChildren 不展开数组,会把它变成文本节点(select 里直接被忽略)
1769
+ fill(
1770
+ effortSel,
1765
1771
  option("", "默认", keep),
1766
1772
  withCurrent(efforts, keep).map((v) => option(v, `${v}(当前值,该模型可能不支持)`, keep)),
1767
1773
  efforts.map((v) => option(v, v, keep)),