im-agent 0.0.0-stage → 0.1.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.
Files changed (50) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +100 -2
  3. package/bin/cli.js +52 -0
  4. package/package.json +64 -4
  5. package/src/agents/claude/driver.ts +149 -0
  6. package/src/agents/claude/errors.ts +129 -0
  7. package/src/agents/claude/permissions.ts +227 -0
  8. package/src/agents/claude/session.ts +430 -0
  9. package/src/agents/codex/account.ts +92 -0
  10. package/src/agents/codex/app-server.ts +346 -0
  11. package/src/agents/codex/driver.ts +183 -0
  12. package/src/agents/codex/errors.ts +158 -0
  13. package/src/agents/codex/protocol.ts +284 -0
  14. package/src/agents/codex/refusals.ts +62 -0
  15. package/src/agents/codex/session.ts +738 -0
  16. package/src/channels/feishu/bot.ts +609 -0
  17. package/src/channels/feishu/cards.ts +309 -0
  18. package/src/channels/feishu/client.ts +185 -0
  19. package/src/channels/feishu/envelope.ts +112 -0
  20. package/src/channels/feishu/index.ts +52 -0
  21. package/src/channels/feishu/message.ts +115 -0
  22. package/src/channels/feishu/register.ts +171 -0
  23. package/src/channels/feishu/turn-stream.ts +98 -0
  24. package/src/channels/web/api.ts +505 -0
  25. package/src/channels/web/auth.ts +204 -0
  26. package/src/channels/web/http.ts +118 -0
  27. package/src/channels/web/server.ts +363 -0
  28. package/src/cli/doctor.ts +39 -0
  29. package/src/cli/import.ts +155 -0
  30. package/src/cli/pair.ts +35 -0
  31. package/src/cli/passwd.ts +15 -0
  32. package/src/cli/service.ts +117 -0
  33. package/src/core/agent-env.ts +11 -0
  34. package/src/core/commands.ts +99 -0
  35. package/src/core/config.ts +227 -0
  36. package/src/core/dedup.ts +16 -0
  37. package/src/core/hub.ts +933 -0
  38. package/src/core/interactions.ts +261 -0
  39. package/src/core/pairing.ts +156 -0
  40. package/src/core/store.ts +174 -0
  41. package/src/core/types.ts +159 -0
  42. package/src/core/uploads.ts +200 -0
  43. package/src/home.ts +36 -0
  44. package/src/main.ts +68 -0
  45. package/web/app.js +1939 -0
  46. package/web/icon.svg +6 -0
  47. package/web/index.html +23 -0
  48. package/web/manifest.webmanifest +13 -0
  49. package/web/style.css +1267 -0
  50. package/web/sw.js +5 -0
@@ -0,0 +1,738 @@
1
+ // 一条会话 ↔ 一条 Codex thread。
2
+ //
3
+ // 所有会话共用同一个 app-server 进程,靠 threadId 区分。所以本模块除了 CodexSession
4
+ // 之外还负责一件全局的事:把 app-server 收到的通知和反向请求按 threadId 分发到会话上。
5
+ import { appServer } from "./app-server.js";
6
+ import { refusalFor, UnsupportedRequest } from "./refusals.js";
7
+ import { config } from "../../core/config.js";
8
+ import { describeAttachments } from "../../core/uploads.js";
9
+ import type {
10
+ AgentEvents,
11
+ AgentSession,
12
+ InteractionHost,
13
+ QuestionSpec,
14
+ RuntimeInfo,
15
+ SessionOptions,
16
+ Settlement,
17
+ UserInput,
18
+ } from "../../core/types.js";
19
+ import type {
20
+ AgentMessageDeltaNotification,
21
+ CommandExecutionApprovalParams,
22
+ FileChangeApprovalParams,
23
+ ItemNotification,
24
+ PermissionsApprovalParams,
25
+ Thread,
26
+ ThreadItem,
27
+ ThreadResponse,
28
+ ToolRequestUserInputParams,
29
+ TurnCompletedNotification,
30
+ } from "./protocol.js";
31
+
32
+ // ---------- 全局分发 ----------
33
+
34
+ const byThread = new Map<string, CodexSession>();
35
+
36
+ function threadIdOf(params: unknown): string | undefined {
37
+ const id = (params as { threadId?: unknown } | null)?.threadId;
38
+ return typeof id === "string" ? id : undefined;
39
+ }
40
+
41
+ appServer.onNotification((method, params) => {
42
+ const threadId = threadIdOf(params);
43
+ if (threadId) byThread.get(threadId)?.handleNotification(method, params);
44
+ });
45
+
46
+ appServer.setServerRequestHandler(async (method, params) => {
47
+ const threadId = threadIdOf(params);
48
+ const session = threadId ? byThread.get(threadId) : undefined;
49
+ if (!session) {
50
+ console.warn(`[codex] 收到 ${method},但没有对应的会话(thread=${threadId}),自动拒绝`);
51
+ const refusal = refusalFor(method);
52
+ if (refusal === null) throw new UnsupportedRequest(method);
53
+ return refusal;
54
+ }
55
+ return session.handleServerRequest(method, params);
56
+ });
57
+
58
+ // app-server 退出:进程里的 thread 都没了,但盘上还在。在途的轮次立刻收尾,
59
+ // 之后说话时先 resume 回来,上下文不丢。
60
+ appServer.onExit((reason) => {
61
+ for (const session of new Set([...byThread.values(), ...opening])) session.onServerExit(reason);
62
+ });
63
+
64
+ /** 还在 thread/start 途中、尚未登记到 byThread 的会话 */
65
+ const opening = new Set<CodexSession>();
66
+
67
+ /** 配置里的沙箱模式 → turn/start 的 sandboxPolicy */
68
+ function sandboxPolicyFor(mode: string, cwd: string): unknown {
69
+ if (mode === "danger-full-access") return { type: "dangerFullAccess" };
70
+ if (mode === "read-only") return { type: "readOnly", networkAccess: false };
71
+ return {
72
+ type: "workspaceWrite",
73
+ writableRoots: [cwd],
74
+ networkAccess: false,
75
+ excludeTmpdirEnvVar: false,
76
+ excludeSlashTmp: false,
77
+ };
78
+ }
79
+
80
+ // ---------- 审批正文 ----------
81
+
82
+ function describeCommand(params: CommandExecutionApprovalParams): string {
83
+ const lines: string[] = [];
84
+ if (params.reason) lines.push(params.reason);
85
+ const actions = (params.commandActions ?? [])
86
+ .filter((a) => a.type !== "unknown")
87
+ .map((a) =>
88
+ a.type === "read"
89
+ ? `读取 ${a.path ?? a.name ?? ""}`
90
+ : a.type === "listFiles"
91
+ ? `列目录 ${a.path ?? ""}`
92
+ : `搜索 ${(a as { query?: string | null }).query ?? ""}`,
93
+ );
94
+ if (actions.length > 0) lines.push(actions.join("、"));
95
+ if (params.command) lines.push(`\`\`\`bash\n${params.command}\n\`\`\``);
96
+ if (params.cwd) lines.push(`目录 \`${params.cwd}\``);
97
+ return lines.join("\n\n") || "(没有更多信息)";
98
+ }
99
+
100
+ function describeFileChange(params: FileChangeApprovalParams): string {
101
+ const lines: string[] = [];
102
+ if (params.reason) lines.push(params.reason);
103
+ if (params.grantRoot) lines.push(`想要写入 \`${params.grantRoot}\` 之下`);
104
+ return lines.join("\n\n") || "Codex 想改动沙箱之外的文件。";
105
+ }
106
+
107
+ function describePermissions(params: PermissionsApprovalParams): string {
108
+ const lines: string[] = [];
109
+ if (params.reason) lines.push(params.reason);
110
+ lines.push(`\`\`\`json\n${JSON.stringify(params.permissions ?? {}, null, 2)}\n\`\`\``);
111
+ if (params.cwd) lines.push(`目录 \`${params.cwd}\``);
112
+ return lines.join("\n\n");
113
+ }
114
+
115
+ function allowed(s: Settlement): boolean {
116
+ return s === "allow-once" || s === "allow-always";
117
+ }
118
+
119
+ type ParsedQuestion = QuestionSpec & { secret: boolean };
120
+
121
+ function parseQuestions(raw: unknown): ParsedQuestion[] | null {
122
+ if (!Array.isArray(raw) || raw.length === 0) return null;
123
+ const specs: ParsedQuestion[] = [];
124
+ for (const item of raw) {
125
+ if (typeof item !== "object" || item === null) return null;
126
+ const rec = item as Record<string, unknown>;
127
+ const id = typeof rec.id === "string" ? rec.id : "";
128
+ const question = typeof rec.question === "string" ? rec.question.trim() : "";
129
+ if (!id || !question) return null;
130
+ const options: QuestionSpec["options"] = [];
131
+ for (const opt of Array.isArray(rec.options) ? rec.options : []) {
132
+ if (typeof opt !== "object" || opt === null) continue;
133
+ const o = opt as Record<string, unknown>;
134
+ const label = typeof o.label === "string" ? o.label.trim() : "";
135
+ if (!label) continue;
136
+ options.push({
137
+ label,
138
+ ...(typeof o.description === "string" && o.description.trim() ? { description: o.description.trim() } : {}),
139
+ });
140
+ }
141
+ specs.push({
142
+ id,
143
+ header: typeof rec.header === "string" && rec.header.trim() ? rec.header.trim() : "选择",
144
+ question,
145
+ options,
146
+ // Codex 的问题都是单选
147
+ multiSelect: false,
148
+ allowOther: rec.isOther === true || options.length === 0,
149
+ secret: rec.isSecret === true,
150
+ });
151
+ }
152
+ return specs;
153
+ }
154
+
155
+ // ---------- 会话 ----------
156
+
157
+ /** 会话已被丢弃,迟到的异步结果直接作废 */
158
+ class DisposedError extends Error {}
159
+
160
+ export class CodexSession implements AgentSession {
161
+ private threadId: string | undefined;
162
+ private turnId: string | undefined;
163
+ /**
164
+ * 当前轮次的阶段。idle:没有轮次;starting:turn/start 已发出、还没拿到轮次 id;
165
+ * running:拿到 id 了。一条会话同一时间只跑一轮,期间新来的消息排队。
166
+ */
167
+ private phase: "idle" | "starting" | "running" = "idle";
168
+ /** 每开一轮加一:迟到的回调据此判断自己是不是已经过时 */
169
+ private turnSeq = 0;
170
+ private queue: UserInput[] = [];
171
+ /** 启动途中就被叫停(停止或丢弃)的轮次代次:turn/start 迟到返回时,拿它的 id 补一个中断 */
172
+ private abandoned = new Set<number>();
173
+ /** 已经收尾过的轮次:error 通知之后还可能再来一个 turn/completed,不能收两次尾 */
174
+ private endedTurns = new Set<string>();
175
+ /** thread/start 或 thread/resume 的在途请求,避免并发的两条消息各开一条 thread */
176
+ private opening: Promise<string> | null = null;
177
+ private detached = false;
178
+ private runtime: RuntimeInfo = {};
179
+ private replyText = "";
180
+ private lastTokens: number | undefined;
181
+ /** 分叉已经真正完成(拿到并接管了新 thread)。失败了不算,重试还要接着分叉 */
182
+ private forked = false;
183
+ /** agentMessage 的 itemId → 是不是旁白(commentary 属于过程,不流式到正文里) */
184
+ private commentaryItems = new Set<string>();
185
+ private disposed = false;
186
+ /** 轮次还在 starting 时点了停止:拿到轮次 id 立刻中断 */
187
+ private stopRequested = false;
188
+
189
+ constructor(
190
+ private readonly opts: SessionOptions,
191
+ private readonly events: AgentEvents,
192
+ private readonly host: InteractionHost,
193
+ ) {}
194
+
195
+ get sessionId(): string | undefined {
196
+ return this.threadId;
197
+ }
198
+
199
+ markDetached(): void {
200
+ this.detached = true;
201
+ }
202
+
203
+ get busy(): boolean {
204
+ return this.phase !== "idle";
205
+ }
206
+
207
+ /**
208
+ * thread 被另一条会话接管了(正常情况下只会在空闲时发生,见 openOnce 的检查)。
209
+ * 万一时序上撞上了正在跑的一轮:先让服务端中断它,再在本地收尾 —— 不中断的话,
210
+ * 它后续的输出和审批会按 threadId 串到新所有者那边。
211
+ */
212
+ evict(): void {
213
+ this.detached = true;
214
+ if (this.phase !== "idle" && this.threadId && this.turnId) {
215
+ void appServer.call("turn/interrupt", { threadId: this.threadId, turnId: this.turnId }).catch(() => {});
216
+ }
217
+ if (this.phase !== "idle" && !this.disposed) {
218
+ const dropped = this.queue.length;
219
+ this.queue = [];
220
+ this.endTurn();
221
+ this.events.onError(new Error("这条 Codex 会话在别处被恢复了,这一轮已中断"));
222
+ if (dropped > 0) this.events.onDropped?.(dropped);
223
+ }
224
+ }
225
+
226
+ /** app-server 挂了 / 重启了:这一轮不会再有任何通知,必须自己收尾,否则界面一直显示忙。 */
227
+ onServerExit(reason: string): void {
228
+ this.markDetached();
229
+ const inFlight = this.phase !== "idle";
230
+ const dropped = this.queue.length;
231
+ this.endTurn();
232
+ this.queue = [];
233
+ if (this.disposed) return;
234
+ if (inFlight) this.events.onError(new Error(`${reason},这一轮已中断,请重新发送`));
235
+ if (dropped > 0) {
236
+ this.events.onDropped?.(dropped);
237
+ this.events.onStep("warning", `⚠️ 还有 ${dropped} 条排队的消息没来得及处理,请重新发送`);
238
+ }
239
+ }
240
+
241
+ private endTurn(): void {
242
+ // 还在 starting、没拿到 id 就收尾了:记下来,turn/start 迟到返回时补中断
243
+ if (this.phase === "starting" && !this.turnId) {
244
+ this.abandoned.add(this.turnSeq);
245
+ if (this.abandoned.size > 20) this.abandoned.delete(this.abandoned.values().next().value!);
246
+ }
247
+ if (this.turnId) {
248
+ this.endedTurns.add(this.turnId);
249
+ if (this.endedTurns.size > 50) this.endedTurns.delete(this.endedTurns.values().next().value!);
250
+ }
251
+ this.phase = "idle";
252
+ this.turnId = undefined;
253
+ this.stopRequested = false;
254
+ this.turnSeq += 1;
255
+ }
256
+
257
+ /** 一轮结束后,排着队的下一条消息接着开 */
258
+ private drainQueue(): void {
259
+ const next = this.queue.shift();
260
+ if (next && !this.disposed) this.startTurn(next);
261
+ }
262
+
263
+ /**
264
+ * 确保有一条可用的 thread。三条路都显式写死 approvalPolicy 和 sandbox ——
265
+ * app-server 会继承 ~/.codex/config.toml,很多人那里是 never + danger-full-access,
266
+ * 不覆盖的话审批请求根本不会发过来,卡片授权整条路就是死代码。
267
+ */
268
+ private async open(): Promise<string> {
269
+ if (this.threadId && !this.detached) return this.threadId;
270
+ this.opening ??= this.openOnce().finally(() => {
271
+ this.opening = null;
272
+ });
273
+ return this.opening;
274
+ }
275
+
276
+ private async openOnce(): Promise<string> {
277
+ const shared = {
278
+ cwd: this.opts.cwd,
279
+ approvalPolicy: "on-request",
280
+ sandbox: config().agents.codex.sandbox,
281
+ ...(this.opts.model ? { model: this.opts.model } : {}),
282
+ };
283
+ const resumeId = this.threadId ?? this.opts.resumeId;
284
+ const fork = this.opts.fork && !this.forked;
285
+ // 这个 thread 正在别的会话里跑:不接管。接管后它的后续输出和审批请求都按 threadId
286
+ // 路由,会串到这边来;等它跑完再恢复。(fork 出来的是新 thread,不受影响)
287
+ const owner = resumeId && !fork ? byThread.get(resumeId) : undefined;
288
+ if (owner && owner !== this && owner.busy) {
289
+ throw new Error("这条 Codex 会话正在另一个聊天里运行,等那边这一轮结束后再试。");
290
+ }
291
+
292
+ let res: ThreadResponse;
293
+ opening.add(this);
294
+ try {
295
+ if (resumeId && fork) {
296
+ res = await appServer.call<ThreadResponse>("thread/fork", { threadId: resumeId, excludeTurns: true, ...shared });
297
+ } else if (resumeId) {
298
+ res = await appServer.call<ThreadResponse>("thread/resume", { threadId: resumeId, excludeTurns: true, ...shared });
299
+ } else {
300
+ res = await appServer.call<ThreadResponse>("thread/start", shared);
301
+ }
302
+ } finally {
303
+ opening.delete(this);
304
+ }
305
+ // 等待期间会话已被丢弃(切了目录 / 开了新会话):迟到的 thread 只做清理,绝不再开轮次
306
+ if (this.disposed) {
307
+ // 只有没人在用时才退订:这个 thread 可能正被另一条会话持有,退订会断掉它的通知
308
+ if (!byThread.has(res.thread.id)) {
309
+ void appServer.call("thread/unsubscribe", { threadId: res.thread.id }).catch(() => {});
310
+ }
311
+ throw new DisposedError();
312
+ }
313
+ this.adopt(res.thread);
314
+ // 到这里分叉才算完成;之前任何一步失败,下次重试都还会走 thread/fork,绝不会续写原会话
315
+ if (fork) this.forked = true;
316
+ return res.thread.id;
317
+ }
318
+
319
+ /**
320
+ * 接管前最后再看一眼:等 resume 响应的这段时间里,原所有者可能刚好开了新一轮。
321
+ * 这时放弃接管、路由完全不动 —— 否则它的输出和审批会串到这边来。
322
+ * 这个检查和下面的 byThread.set 之间没有 await,不存在再插进来的空隙。
323
+ */
324
+ private adopt(thread: Thread): void {
325
+ const busyOwner = byThread.get(thread.id);
326
+ if (busyOwner && busyOwner !== this && busyOwner.busy) {
327
+ throw new Error("这条 Codex 会话正在另一个聊天里运行,等那边这一轮结束后再试。");
328
+ }
329
+ const previous = this.threadId;
330
+ if (previous && previous !== thread.id && byThread.get(previous) === this) byThread.delete(previous);
331
+ this.threadId = thread.id;
332
+ this.detached = false;
333
+ // 同一个 thread 被两条会话恢复(比如 Web 上恢复了飞书群里那条):通知按 threadId 路由,
334
+ // 只能有一个接收方。所有权交给最后恢复它的这条,原来那条标记为待恢复,
335
+ // 它下次说话时会重新 resume、再把所有权拿回去 —— 回复不会再串到别的聊天里。
336
+ const owner = byThread.get(thread.id);
337
+ if (owner && owner !== this) {
338
+ // 原所有者空闲(忙的情况在函数开头已经拒绝,且那里到这里之间没有 await)
339
+ owner.evict();
340
+ }
341
+ byThread.set(thread.id, this);
342
+ this.runtime = {
343
+ ...(thread.model ? { model: thread.model } : {}),
344
+ ...(thread.reasoningEffort ? { effort: thread.reasoningEffort } : {}),
345
+ };
346
+ if (thread.id !== previous) this.events.onSessionId(thread.id);
347
+ }
348
+
349
+ /**
350
+ * turn/start 的响应要等这一轮跑完才回来,所以不 await —— 过程和结果都从通知走。
351
+ * 图片作为 localImage 一起送进去,模型能直接看到。
352
+ */
353
+ send(input: UserInput): void {
354
+ if (this.disposed) return;
355
+ // 上一轮还没完:排队。不能并发开两个 turn —— 它们会互相覆盖回复文本和中断状态
356
+ if (this.phase !== "idle") {
357
+ this.queue.push(input);
358
+ return;
359
+ }
360
+ this.startTurn(input);
361
+ }
362
+
363
+ private startTurn(input: UserInput): void {
364
+ this.replyText = "";
365
+ this.phase = "starting";
366
+ this.turnId = undefined;
367
+ this.stopRequested = false;
368
+ const token = ++this.turnSeq;
369
+ const note = input.attachments.length
370
+ ? describeAttachments(input.attachments, (a) => a.kind === "image")
371
+ : "";
372
+ const text = note ? (input.text ? `${input.text}\n\n${note}` : note) : input.text;
373
+ const images = input.attachments.filter((a) => a.kind === "image").map((a) => a.path);
374
+ let startedOn: string | undefined;
375
+ void this.open()
376
+ .then((threadId) => {
377
+ if (this.disposed) throw new DisposedError();
378
+ if (token !== this.turnSeq) throw new DisposedError(); // 等 thread 期间被停止了
379
+ startedOn = threadId;
380
+ return appServer.call<{ turn?: { id?: string } }>("turn/start", {
381
+ threadId,
382
+ input: [
383
+ { type: "text", text, text_elements: [] },
384
+ ...images.map((path) => ({ type: "localImage" as const, path })),
385
+ ],
386
+ cwd: this.opts.cwd,
387
+ approvalPolicy: "on-request",
388
+ // 每一轮都按当前配置显式给沙箱:thread 是开着复用的,只在 thread/start 时给的话,
389
+ // 设置里收紧了沙箱(比如改成只读),已有会话还会按旧权限干活
390
+ sandboxPolicy: sandboxPolicyFor(config().agents.codex.sandbox, this.opts.cwd),
391
+ ...(this.opts.model ? { model: this.opts.model } : {}),
392
+ ...(this.opts.effort ? { effort: this.opts.effort } : {}),
393
+ });
394
+ })
395
+ .then((res) => {
396
+ const id = res?.turn?.id;
397
+ if (!id) return;
398
+ if (token === this.turnSeq) {
399
+ // turn/start 的响应带着轮次 id(它只是「轮次已开始」,不是结果)
400
+ this.onTurnId(id);
401
+ return;
402
+ }
403
+ // 这一轮在启动途中被叫停 / 会话被丢弃了,但服务端其实已经开跑:补一个中断,别让它在后台继续干活
404
+ if (this.abandoned.delete(token) && startedOn) {
405
+ this.endedTurns.add(id);
406
+ void appServer.call("turn/interrupt", { threadId: startedOn, turnId: id }).catch(() => {});
407
+ }
408
+ })
409
+ .catch((err) => {
410
+ // 这一轮已经被别的路径收尾了(停止、进程退出、丢弃):不再重复报错
411
+ if (err instanceof DisposedError || this.disposed || token !== this.turnSeq) return;
412
+ this.endTurn();
413
+ this.events.onError(err);
414
+ this.drainQueue();
415
+ });
416
+ }
417
+
418
+ private onTurnId(id: string): void {
419
+ this.turnId = id;
420
+ this.phase = "running";
421
+ if (this.stopRequested) {
422
+ this.stopRequested = false;
423
+ void this.interrupt();
424
+ }
425
+ }
426
+
427
+ /** 记下来,下一轮 turn/start 带上即可。 */
428
+ async setModel(model?: string, effort?: string): Promise<void> {
429
+ if (model !== undefined) this.opts.model = model;
430
+ if (effort !== undefined) this.opts.effort = effort;
431
+ const nextModel = model ?? this.runtime.model;
432
+ const nextEffort = effort ?? this.runtime.effort;
433
+ this.runtime = { ...(nextModel ? { model: nextModel } : {}), ...(nextEffort ? { effort: nextEffort } : {}) };
434
+ }
435
+
436
+ /** 不额外发请求,也绝不为此新开 thread(否则历史里会留一串空会话)。 */
437
+ async describeRuntime(): Promise<RuntimeInfo> {
438
+ return this.runtime;
439
+ }
440
+
441
+ async interrupt(): Promise<void> {
442
+ // 停止 = 这一轮和排着队的都不要了
443
+ this.queue = [];
444
+ if (this.phase === "starting" && !this.turnId) {
445
+ // 轮次还没真正开始:记下来,拿到轮次 id 立刻中断
446
+ this.stopRequested = true;
447
+ return;
448
+ }
449
+ if (this.phase !== "running" || !this.threadId || !this.turnId) return;
450
+ try {
451
+ await appServer.call("turn/interrupt", { threadId: this.threadId, turnId: this.turnId });
452
+ } catch (err) {
453
+ console.warn("[codex] 中断请求未生效:", err instanceof Error ? err.message : err);
454
+ }
455
+ }
456
+
457
+ async dispose(): Promise<void> {
458
+ this.disposed = true;
459
+ this.queue = [];
460
+ await this.interrupt();
461
+ // 只有当前所有者才清理映射和退订:thread 已经被别的会话接管时,退订会把它的订阅一起断掉
462
+ if (this.threadId && byThread.get(this.threadId) === this) {
463
+ byThread.delete(this.threadId);
464
+ try {
465
+ await appServer.call("thread/unsubscribe", { threadId: this.threadId });
466
+ } catch {
467
+ // 老版本没有这个方法,或者 thread 已经没了
468
+ }
469
+ }
470
+ this.threadId = undefined;
471
+ this.endTurn();
472
+ }
473
+
474
+ // ---------- 反向请求:审批与提问 ----------
475
+
476
+ /**
477
+ * 每一条都**必须**返回点什么 —— 漏回一次,Codex 那一轮就永远挂在等审批上。
478
+ * 未知方法也要按它自己的协议结构明确拒绝。
479
+ */
480
+ async handleServerRequest(method: string, params: unknown): Promise<unknown> {
481
+ switch (method) {
482
+ case "item/commandExecution/requestApproval": {
483
+ const req = params as CommandExecutionApprovalParams;
484
+ const amendment = req.proposedExecpolicyAmendment ?? null;
485
+ const outcome = await this.host({
486
+ kind: "approval",
487
+ title: req.kind === "writeStdin" ? "需要授权:向运行中的命令输入" : "需要授权:执行命令",
488
+ detail: describeCommand(req),
489
+ // 「总是允许」到底记多宽取决于 Codex 有没有给 execpolicy 补丁,差别很大,得写在按钮上
490
+ alwaysLabel: amendment ? "以后同类不再问" : "本会话内不再问",
491
+ });
492
+ const s = outcome.settlement;
493
+ if (s === "allow-once") return { decision: "accept" };
494
+ if (s === "allow-always") {
495
+ return {
496
+ decision: amendment
497
+ ? { acceptWithExecpolicyAmendment: { execpolicy_amendment: amendment } }
498
+ : "acceptForSession",
499
+ };
500
+ }
501
+ if (s === "cancelled") return { decision: "cancel" };
502
+ return { decision: "decline" };
503
+ }
504
+
505
+ case "item/fileChange/requestApproval": {
506
+ const req = params as FileChangeApprovalParams;
507
+ const outcome = await this.host({
508
+ kind: "approval",
509
+ title: "需要授权:改动文件",
510
+ detail: describeFileChange(req),
511
+ alwaysLabel: "本会话内不再问",
512
+ });
513
+ const s = outcome.settlement;
514
+ if (s === "allow-once") return { decision: "accept" };
515
+ if (s === "allow-always") return { decision: "acceptForSession" };
516
+ if (s === "cancelled") return { decision: "cancel" };
517
+ return { decision: "decline" };
518
+ }
519
+
520
+ case "item/permissions/requestApproval": {
521
+ const req = params as PermissionsApprovalParams;
522
+ const outcome = await this.host({
523
+ kind: "approval",
524
+ title: "需要授权:额外权限",
525
+ detail: describePermissions(req),
526
+ alwaysLabel: "本会话内都给",
527
+ });
528
+ if (!allowed(outcome.settlement)) return { permissions: {}, scope: "turn" };
529
+ return {
530
+ permissions: req.permissions ?? {},
531
+ scope: outcome.settlement === "allow-always" ? "session" : "turn",
532
+ };
533
+ }
534
+
535
+ case "item/tool/requestUserInput": {
536
+ const req = params as ToolRequestUserInputParams;
537
+ const questions = parseQuestions(req.questions);
538
+ if (!questions) {
539
+ console.warn("[codex] 解不出问题清单,按未作答处理");
540
+ return { answers: {} };
541
+ }
542
+ // 敏感问题(密码之类)不在聊天里问:内容会明明白白留在聊天记录里
543
+ if (questions.some((q) => q.secret)) {
544
+ this.events.onStep(
545
+ "warning",
546
+ "⚠️ Codex 想问一个敏感信息(密码 / 密钥之类),聊天里不收这种内容,已按未作答处理。请到终端里直接跑 Codex 完成这一步。",
547
+ );
548
+ return { answers: {} };
549
+ }
550
+ const outcome = await this.host({
551
+ kind: "question",
552
+ title: "Codex 想确认一下",
553
+ questions: questions.map(({ secret: _secret, ...q }) => q),
554
+ });
555
+ const answers: Record<string, { answers: string[] }> = {};
556
+ for (const [id, picked] of Object.entries(outcome.answers)) answers[id] = { answers: picked };
557
+ return { answers };
558
+ }
559
+
560
+ default: {
561
+ // MCP elicitation、动态工具调用等还没接。拒绝必须用这种请求自己的响应结构
562
+ const refusal = refusalFor(method);
563
+ if (refusal === null) throw new UnsupportedRequest(method);
564
+ console.warn(`[codex] 暂不支持的请求 ${method},已按其协议拒绝`);
565
+ return refusal;
566
+ }
567
+ }
568
+ }
569
+
570
+ // ---------- 通知 ----------
571
+
572
+ handleNotification(method: string, params: unknown): void {
573
+
574
+ switch (method) {
575
+ case "turn/started": {
576
+ // 轮次 id 在 params.turn.id,不在顶层
577
+ const id = (params as { turn?: { id?: string } }).turn?.id;
578
+ // 只认「正在等它开始」的那一轮;收过尾的旧轮次晚到的通知忽略
579
+ if (this.phase === "idle" || (id && this.endedTurns.has(id))) return;
580
+ if (id) this.onTurnId(id);
581
+ this.commentaryItems.clear();
582
+ this.events.onTurnStart?.();
583
+ return;
584
+ }
585
+
586
+ case "item/started":
587
+ this.onItem((params as ItemNotification).item, "started");
588
+ return;
589
+
590
+ case "item/completed":
591
+ this.onItem((params as ItemNotification).item, "completed");
592
+ return;
593
+
594
+ case "item/agentMessage/delta": {
595
+ const p = params as AgentMessageDeltaNotification;
596
+ if (!this.commentaryItems.has(p.itemId) && p.delta) this.events.onTextDelta(p.delta);
597
+ return;
598
+ }
599
+
600
+ case "thread/tokenUsage/updated": {
601
+ // last 是这一轮的用量,total 是整条 thread 的累计;页脚显示这一轮的
602
+ const usage = (params as { tokenUsage?: { last?: { totalTokens?: number } } }).tokenUsage;
603
+ const turnTokens = usage?.last?.totalTokens;
604
+ if (typeof turnTokens === "number") this.lastTokens = turnTokens;
605
+ return;
606
+ }
607
+
608
+ case "turn/completed": {
609
+ const { turn } = params as TurnCompletedNotification;
610
+ // 已经收过尾的、或者不是当前这一轮的,不处理
611
+ if (this.phase === "idle" || (turn.id && this.endedTurns.has(turn.id))) return;
612
+ if (this.turnId && turn.id && turn.id !== this.turnId) return;
613
+ const text = this.replyText;
614
+ this.replyText = "";
615
+ this.endTurn();
616
+ if (turn.status === "failed") {
617
+ this.events.onError(new Error(turn.error?.message ?? "这一轮失败了"));
618
+ } else if (turn.status === "interrupted") {
619
+ this.events.onResult({ text: text || "(已中断)", durationMs: turn.durationMs ?? 0 });
620
+ } else {
621
+ this.events.onResult({
622
+ text,
623
+ durationMs: turn.durationMs ?? 0,
624
+ ...(this.lastTokens !== undefined ? { tokens: this.lastTokens } : {}),
625
+ });
626
+ }
627
+ this.drainQueue();
628
+ return;
629
+ }
630
+
631
+ case "error": {
632
+ const { error, willRetry } = params as { error?: { message?: string }; willRetry?: boolean };
633
+ if (willRetry) {
634
+ this.events.onStep("warning", `⚠️ ${summarize(error?.message ?? "出错,正在重试")}`);
635
+ return;
636
+ }
637
+ if (this.phase === "idle") return;
638
+ this.endTurn();
639
+ this.events.onError(new Error(error?.message ?? "未知错误"));
640
+ this.drainQueue();
641
+ return;
642
+ }
643
+
644
+ case "thread/settings/updated": {
645
+ const thread = (params as { thread?: Thread }).thread;
646
+ if (thread) {
647
+ this.runtime = {
648
+ ...(thread.model ? { model: thread.model } : {}),
649
+ ...(thread.reasoningEffort ? { effort: thread.reasoningEffort } : {}),
650
+ };
651
+ }
652
+ return;
653
+ }
654
+
655
+ default:
656
+ return;
657
+ }
658
+ }
659
+
660
+ private onItem(item: ThreadItem, phase: "started" | "completed"): void {
661
+ switch (item.type) {
662
+ case "agentMessage": {
663
+ const message = item as Extract<ThreadItem, { type: "agentMessage" }>;
664
+ if (message.phase === "commentary") this.commentaryItems.add(message.id);
665
+ if (phase !== "completed" || !message.text.trim()) return;
666
+ // commentary 是 Codex 边干边说的旁白,属于过程;正文才是要给人看的回复
667
+ if (message.phase === "commentary") {
668
+ this.events.onStep("note", `💬 ${summarize(message.text)}`);
669
+ return;
670
+ }
671
+ this.replyText += (this.replyText ? "\n\n" : "") + message.text;
672
+ this.events.onText(message.text);
673
+ return;
674
+ }
675
+ case "reasoning": {
676
+ if (phase !== "completed") return;
677
+ const reasoning = item as Extract<ThreadItem, { type: "reasoning" }>;
678
+ const thought = (reasoning.summary ?? []).join(" ").trim();
679
+ if (thought) this.events.onStep("thinking", `💭 ${summarize(thought)}`);
680
+ return;
681
+ }
682
+ case "commandExecution": {
683
+ const exec = item as Extract<ThreadItem, { type: "commandExecution" }>;
684
+ if (phase === "started") {
685
+ this.events.onStep("tool", `🔧 ${summarize(exec.command, 70)}`);
686
+ return;
687
+ }
688
+ if (exec.status === "failed" || (exec.exitCode ?? 0) !== 0) {
689
+ this.events.onStep("warning", ` ↳ 退出码 ${exec.exitCode ?? "?"}`);
690
+ }
691
+ return;
692
+ }
693
+ case "fileChange": {
694
+ if (phase !== "completed") return;
695
+ const change = item as Extract<ThreadItem, { type: "fileChange" }>;
696
+ const files = change.changes.map((c) => `${kindMark(c.kind)}${c.path}`).join("、");
697
+ this.events.onStep("tool", `📝 ${change.status === "failed" ? "改文件失败:" : ""}${summarize(files, 90)}`);
698
+ return;
699
+ }
700
+ case "mcpToolCall": {
701
+ if (phase !== "started") return;
702
+ const call = item as Extract<ThreadItem, { type: "mcpToolCall" }>;
703
+ this.events.onStep("tool", `🔌 ${call.server}.${call.tool}`);
704
+ return;
705
+ }
706
+ case "webSearch": {
707
+ if (phase !== "started") return;
708
+ const search = item as Extract<ThreadItem, { type: "webSearch" }>;
709
+ this.events.onStep("tool", `🔍 ${summarize(search.query ?? "", 60)}`);
710
+ return;
711
+ }
712
+ case "plan": {
713
+ if (phase !== "completed") return;
714
+ const plan = item as Extract<ThreadItem, { type: "plan" }>;
715
+ this.events.onStep("note", `📋 ${summarize(plan.text, 90)}`);
716
+ return;
717
+ }
718
+ case "error": {
719
+ if (phase !== "completed") return;
720
+ const err = item as Extract<ThreadItem, { type: "error" }>;
721
+ this.events.onStep("warning", `⚠️ ${summarize(err.message)}`);
722
+ return;
723
+ }
724
+ default:
725
+ return;
726
+ }
727
+ }
728
+ }
729
+
730
+ function kindMark(kind: unknown): string {
731
+ const k = typeof kind === "string" ? kind : (kind as { type?: string } | null)?.type;
732
+ return k === "add" ? "+" : k === "delete" ? "-" : "~";
733
+ }
734
+
735
+ export function summarize(text: string, max = 80): string {
736
+ const line = text.replace(/\s+/g, " ").trim();
737
+ return line.length > max ? `${line.slice(0, max)}…` : line;
738
+ }