agent-loft 0.0.0-stage → 0.2.8

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 (58) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +110 -2
  3. package/bin/cli.js +52 -0
  4. package/package.json +64 -4
  5. package/src/agents/claude/account.ts +122 -0
  6. package/src/agents/claude/driver.ts +216 -0
  7. package/src/agents/claude/errors.ts +129 -0
  8. package/src/agents/claude/permissions.ts +260 -0
  9. package/src/agents/claude/probe.ts +54 -0
  10. package/src/agents/claude/session.ts +487 -0
  11. package/src/agents/codex/account.ts +122 -0
  12. package/src/agents/codex/app-server.ts +346 -0
  13. package/src/agents/codex/driver.ts +210 -0
  14. package/src/agents/codex/errors.ts +158 -0
  15. package/src/agents/codex/protocol.ts +284 -0
  16. package/src/agents/codex/refusals.ts +62 -0
  17. package/src/agents/codex/session.ts +773 -0
  18. package/src/channels/feishu/bot.ts +669 -0
  19. package/src/channels/feishu/cards.ts +360 -0
  20. package/src/channels/feishu/client.ts +185 -0
  21. package/src/channels/feishu/envelope.ts +117 -0
  22. package/src/channels/feishu/index.ts +62 -0
  23. package/src/channels/feishu/message.ts +115 -0
  24. package/src/channels/feishu/register.ts +171 -0
  25. package/src/channels/feishu/slash-sync.ts +192 -0
  26. package/src/channels/feishu/turn-stream.ts +98 -0
  27. package/src/channels/web/api.ts +645 -0
  28. package/src/channels/web/auth.ts +204 -0
  29. package/src/channels/web/http.ts +118 -0
  30. package/src/channels/web/push.ts +218 -0
  31. package/src/channels/web/server.ts +446 -0
  32. package/src/cli/doctor.ts +39 -0
  33. package/src/cli/import.ts +155 -0
  34. package/src/cli/pair.ts +35 -0
  35. package/src/cli/passwd.ts +15 -0
  36. package/src/cli/service.ts +137 -0
  37. package/src/core/agent-env.ts +11 -0
  38. package/src/core/command-catalog.ts +112 -0
  39. package/src/core/commands.ts +104 -0
  40. package/src/core/config.ts +285 -0
  41. package/src/core/dedup.ts +16 -0
  42. package/src/core/footers.ts +88 -0
  43. package/src/core/hub.ts +1152 -0
  44. package/src/core/interactions.ts +273 -0
  45. package/src/core/pairing.ts +156 -0
  46. package/src/core/store.ts +178 -0
  47. package/src/core/types.ts +213 -0
  48. package/src/core/uploads.ts +200 -0
  49. package/src/home.ts +43 -0
  50. package/src/main.ts +68 -0
  51. package/web/app.js +3731 -0
  52. package/web/apple-touch-icon.png +0 -0
  53. package/web/icon-maskable.svg +13 -0
  54. package/web/icon.svg +12 -0
  55. package/web/index.html +23 -0
  56. package/web/manifest.webmanifest +15 -0
  57. package/web/style.css +2604 -0
  58. package/web/sw.js +76 -0
@@ -0,0 +1,1152 @@
1
+ // 中枢:所有渠道共用的「会话 → agent」调度。
2
+ //
3
+ // 渠道只做三件事:把用户的话(和附件)交给 hub.send,把命令交给 hub.command,
4
+ // 订阅 hub 的事件流画到自己的界面上。会话的生命周期、闲置过期、切 agent / 模型 /
5
+ // 目录、取消挂着的审批,全在这里 —— 以前飞书桥接器 index.ts 里那 1000 行流程,
6
+ // 现在只写一遍。
7
+ //
8
+ // 同一条会话可以被多个渠道同时看着:飞书群里的会话也能在 Web 上打开,Web 上
9
+ // 说的话照样回到飞书。所以事件是广播出去的,每个订阅者自己挑感兴趣的会话。
10
+ import crypto from "node:crypto";
11
+ import { EventEmitter } from "node:events";
12
+ import { config, onConfigChange, resolveWorkspacePath } from "./config.js";
13
+ import { attachFooters, copyFooters, recordFooter } from "./footers.js";
14
+ import { interactions, SETTLEMENT_LABEL } from "./interactions.js";
15
+ import {
16
+ agentBinding,
17
+ ensureConversation,
18
+ getConversation,
19
+ isExpired,
20
+ listConversations,
21
+ touchConversation,
22
+ updateAgentBinding,
23
+ updateConversation,
24
+ type Conversation,
25
+ } from "./store.js";
26
+ import { clearUploads, ensureUploadsDir, KIND_LABEL } from "./uploads.js";
27
+ import type {
28
+ AgentDriver,
29
+ AgentSession,
30
+ ContextUsage,
31
+ ApprovalDecision,
32
+ HistoryMessage,
33
+ InteractionSpec,
34
+ ModelInfo,
35
+ RuntimeInfo,
36
+ SessionSummary,
37
+ StepKind,
38
+ UserInput,
39
+ } from "./types.js";
40
+ import { buildCatalog, type AgentCommand, type CommandEntry, type CommandPrefs } from "./command-catalog.js";
41
+ import { helpText, type ParsedCommand } from "./commands.js";
42
+
43
+ // ---------- 事件 ----------
44
+
45
+ export type Origin = {
46
+ /** feishu / web / telegram… */
47
+ channel: string;
48
+ /** 渠道实例 id(飞书机器人 id;Web 为 web) */
49
+ channelId: string;
50
+ /** 显示用的人名 / 设备名 */
51
+ name?: string;
52
+ };
53
+
54
+ export type ConvEvent =
55
+ | { type: "user"; text: string; attachments: string[]; origin: Origin }
56
+ | { type: "turn_start" }
57
+ | { type: "step"; kind: StepKind; text: string }
58
+ | { type: "delta"; text: string }
59
+ | { type: "text"; text: string }
60
+ | { type: "result"; text: string; footer: string; at: number; anchor?: string }
61
+ | { type: "error"; message: string }
62
+ | { type: "notice"; text: string }
63
+ | { type: "state"; busy: boolean }
64
+ | { type: "meta" };
65
+
66
+ /** 一条输入是在哪个时刻收到的:停止代次 + 生命周期代次 */
67
+ /**
68
+ * 一条输入收到时的状态快照。
69
+ * stop:停止代次。
70
+ * boundary:收到时已发起的最后一个切换操作的序号。序号比它大的切换是「收到之后才发起的」,
71
+ * 它们中任何一个真正生效了,这条消息就是冲着旧会话说的,作废。序号不大于它的
72
+ * (收到之前就已发起、哪怕还在排队)不算 —— 这条本来就是冲着切换之后说的。
73
+ * at:收到的时刻。
74
+ */
75
+ type SwitchOp = { at: number; seq: number };
76
+
77
+ export type InputGen = { stop: number; boundary: number; at: number };
78
+
79
+ export type EventEnvelope = { key: string; seq: number; ts: number; ev: ConvEvent };
80
+
81
+ /** 命令的结构化结果。各渠道自己决定怎么画(飞书画卡片,Web 画列表)。 */
82
+ export type CommandReply =
83
+ | { type: "text"; text: string }
84
+ | {
85
+ type: "sessions";
86
+ agent: string;
87
+ sessions: SessionSummary[];
88
+ current?: string;
89
+ cwd: string;
90
+ allDirs: boolean;
91
+ fellBack: boolean;
92
+ }
93
+ | {
94
+ type: "models";
95
+ agent: string;
96
+ models: ModelInfo[];
97
+ current?: string;
98
+ currentEffort?: string;
99
+ effortSupported: boolean;
100
+ }
101
+ | { type: "agents"; agents: { id: string; label: string }[]; current: string }
102
+ /** 只发了「/」:可用命令(已按用户配置排序、去掉隐藏的) */
103
+ | { type: "commands"; agent: string; commands: CommandEntry[] }
104
+ | { type: "none" };
105
+
106
+ export type ConversationInfo = {
107
+ key: string;
108
+ title?: string;
109
+ channel: string;
110
+ channelId: string;
111
+ agent: string;
112
+ cwd: string;
113
+ sessionId?: string;
114
+ model?: string;
115
+ effort?: string;
116
+ /** 实际在用的模型 / 思考程度(尽力而为,可能不全)。model/effort 只是显式选过的 */
117
+ runtimeModel?: string;
118
+ runtimeEffort?: string;
119
+ /** 当前 agent 会话的上下文占用(这次启动以来跑过至少一轮才有) */
120
+ context?: ContextUsage;
121
+ busy: boolean;
122
+ activeAt: number;
123
+ createdAt: number;
124
+ hidden?: boolean;
125
+ /** 从哪条对话分叉出来的 */
126
+ forkOf?: string;
127
+ /** 当前 agent 能不能从某条回复处分叉成新对话 */
128
+ canFork?: boolean;
129
+ };
130
+
131
+ const BUFFER_MAX = 400;
132
+ const CONTEXT_MAX = 500;
133
+ /** 会占住会话操作队列的 agent 查询,最多等这么久 */
134
+ const QUERY_TIMEOUT_MS = 30_000;
135
+
136
+ function withTimeout<T>(p: Promise<T>, what: string, ms = QUERY_TIMEOUT_MS): Promise<T> {
137
+ let timer: NodeJS.Timeout | undefined;
138
+ const timeout = new Promise<never>((_, reject) => {
139
+ timer = setTimeout(() => reject(new Error(`${what}超时(${ms / 1000} 秒没有响应)`)), ms);
140
+ timer.unref?.();
141
+ });
142
+ return Promise.race([p, timeout]).finally(() => clearTimeout(timer));
143
+ }
144
+
145
+ type Live = { id: number; agent: string; cwd: string; session: AgentSession };
146
+
147
+ class Hub extends EventEmitter<{ event: [EventEnvelope] }> {
148
+ private drivers = new Map<string, AgentDriver>();
149
+ private live = new Map<string, Live>();
150
+ private buffers = new Map<string, EventEnvelope[]>();
151
+ private seq = 0;
152
+ /** 每条会话还没收到结果的轮数,> 0 即忙 */
153
+ private inflight = new Map<string, number>();
154
+ private announced = new Set<string>();
155
+ /** 每条会话因缓冲区溢出被丢掉的最后一个 seq */
156
+ private truncated = new Map<string, number>();
157
+ /** 每条会话的生命周期操作串行执行,见 serial() */
158
+ private locks = new Map<string, Promise<unknown>>();
159
+ /** 最近一次点停止的时间:紧接着冒出来的「被打断」错误是预期内的,不当错误显示 */
160
+ private stoppedAt = new Map<string, number>();
161
+ /**
162
+ * 每条会话的「生命周期代次」:开新会话、分叉、恢复、切 agent、切目录时加一。
163
+ * 渠道收到消息后还要先做异步的事(飞书下载附件),期间会话被换掉了的话,
164
+ * 这条消息是冲着旧会话 / 旧目录说的,不能送进新的里去。
165
+ */
166
+ /** 每条会话已发起的切换操作序号(调用那一刻领号) */
167
+ private opSeq = new Map<string, number>();
168
+ /** 真正生效了的切换里,序号最大的那个,以及它生效的时刻 */
169
+ private lastSwitch = new Map<string, { seq: number; at: number }>();
170
+ /** 已发起、还在排队或执行中的切换操作数 */
171
+ private pendingOps = new Map<string, number>();
172
+ private sessionSeq = 0;
173
+ /** 每条会话的「停止代次」:排队中的发送在真正送出前对一下,点过停止的就作废 */
174
+ private stopGen = new Map<string, number>();
175
+ /** 每条会话最近一次实际生效的模型 / 思考程度(会话进程退了也留着,顶栏显示用) */
176
+ private lastRuntime = new Map<string, { agent: string; runtime: RuntimeInfo }>();
177
+ /** 各 agent 的默认模型 / 思考程度(driver.defaults() 的结果,Codex 要问 app-server,所以缓存) */
178
+ private defaultsCache = new Map<string, RuntimeInfo>();
179
+ private defaultsLoading = new Set<string>();
180
+ /** 已经补问过实际设置的会话进程(Live.id) */
181
+ private runtimeAsked = new Set<number>();
182
+ /**
183
+ * 上下文占用,按 agent 的会话 id 记:开新会话 / 恢复 / 切 agent 后 id 变了,自然就不显示旧的。
184
+ * 只在内存里,最多留 CONTEXT_MAX 条。
185
+ */
186
+ private contextBySession = new Map<string, ContextUsage>();
187
+
188
+ constructor() {
189
+ super();
190
+ this.setMaxListeners(100);
191
+ // workspaceRoot 改了:store 会把没显式 /cd 过的会话目录重置,正在跑的旧目录会话要作废
192
+ onConfigChange((next, prev) => {
193
+ // 默认模型可能改了,缓存作废,下次显示时重新读
194
+ this.defaultsCache.clear();
195
+ // agent 被禁用:它所有活着的会话立刻停掉
196
+ const agents = next.agents as Record<string, { enabled: boolean }>;
197
+ for (const [key, live] of [...this.live]) {
198
+ if (agents[live.agent]?.enabled === false) {
199
+ void this.serial(key, async () => {
200
+ if (this.live.get(key)?.agent !== live.agent) return;
201
+ await this.drop(key, `${this.label(live.agent)} 已在设置里关闭`);
202
+ this.emitEvent(key, { type: "notice", text: `${this.label(live.agent)} 已在设置里关闭,这条会话暂时不能用它。` });
203
+ });
204
+ }
205
+ }
206
+ if (next.workspaceRoot === prev.workspaceRoot) return;
207
+ // 工作区根目录变了:没显式 /cd 过的会话目录都会被重置,处理中的旧消息一律作废
208
+ for (const conv of listConversations()) this.markSwitched(conv.key, this.nextOp(conv.key));
209
+ for (const key of [...this.live.keys()]) {
210
+ void this.serial(key, async () => {
211
+ const conv = getConversation(key);
212
+ if (conv && conv.cwd !== this.live.get(key)?.cwd) {
213
+ await this.drop(key, "工作目录配置已变更");
214
+ this.emitEvent(key, { type: "notice", text: `工作目录配置已变更,之后在 ${conv.cwd} 里开新会话。` });
215
+ this.emitEvent(key, { type: "meta" });
216
+ }
217
+ });
218
+ }
219
+ });
220
+ }
221
+
222
+ // ---------- agent 注册 ----------
223
+
224
+ registerDriver(driver: AgentDriver): void {
225
+ this.drivers.set(driver.id, driver);
226
+ }
227
+
228
+ /** 后台读一次 agent 的默认配置(第一次要显示时才读),读到后通知用它的会话刷新顶栏 */
229
+ private loadDefaults(agent: string): void {
230
+ const driver = this.drivers.get(agent);
231
+ if (!driver || this.defaultsLoading.has(agent)) return;
232
+ this.defaultsLoading.add(agent);
233
+ void withTimeout(driver.defaults(), "读取默认配置")
234
+ .then((defaults) => {
235
+ const prev = this.defaultsCache.get(agent);
236
+ this.defaultsCache.set(agent, defaults);
237
+ if (prev?.model === defaults.model && prev?.effort === defaults.effort) return;
238
+ for (const conv of listConversations()) {
239
+ if (conv.agent === agent && !conv.hidden) this.emitEvent(conv.key, { type: "meta" });
240
+ }
241
+ })
242
+ .catch((err) => {
243
+ // 记个空结果,别每次刷新顶栏都重试
244
+ this.defaultsCache.set(agent, {});
245
+ console.error(`[${agent}] 读取默认配置失败:`, err);
246
+ })
247
+ .finally(() => this.defaultsLoading.delete(agent));
248
+ }
249
+
250
+ /** 会话进程要退了:记下它最后实际用的模型,之后顶栏还能显示 */
251
+ private rememberRuntime(key: string, live: Live): void {
252
+ this.runtimeAsked.delete(live.id);
253
+ const runtime = live.session.peekRuntime?.();
254
+ if (runtime && (runtime.model || runtime.effort)) this.lastRuntime.set(key, { agent: live.agent, runtime: { ...runtime } });
255
+ }
256
+
257
+ /**
258
+ * 一轮结束后补问一次实际设置(Claude 的 init 消息里不一定带思考程度)。
259
+ * 进程已经在跑,不会额外拉起;每个会话进程只问一次。
260
+ */
261
+ private refreshRuntimeOnce(key: string, liveId: number): void {
262
+ const live = this.live.get(key);
263
+ if (!live || live.id !== liveId || this.runtimeAsked.has(liveId)) return;
264
+ this.runtimeAsked.add(liveId);
265
+ const before = live.session.peekRuntime?.() ?? {};
266
+ void withTimeout(live.session.describeRuntime(), "读取模型信息")
267
+ .then((rt) => {
268
+ if (this.live.get(key)?.id !== liveId) return;
269
+ if (rt.model !== before.model || rt.effort !== before.effort) this.emitEvent(key, { type: "meta" });
270
+ })
271
+ .catch(() => {});
272
+ }
273
+
274
+ /**
275
+ * 顶栏显示用的模型 / 思考程度:不拉起进程、不发请求,只拼手上已有的信息。
276
+ * 优先级和 runtimeNow 一致:活着的会话 > 显式选过的 > 上次实际跑的 > 设置里的默认 > agent 自己的默认。
277
+ */
278
+ private displayRuntime(conv: { key: string; agent: string }, binding: { model?: string; effort?: string }): RuntimeInfo {
279
+ const live = this.live.get(conv.key);
280
+ const peek = live && live.agent === conv.agent ? (live.session.peekRuntime?.() ?? {}) : {};
281
+ const last = this.lastRuntime.get(conv.key);
282
+ const lastRt = last && last.agent === conv.agent ? last.runtime : {};
283
+ const defaults = (config().agents as Record<string, { defaultModel?: string; defaultEffort?: string }>)[conv.agent];
284
+ const fallback = this.defaultsCache.get(conv.agent);
285
+ if (!fallback) this.loadDefaults(conv.agent);
286
+ const model = peek.model ?? binding.model ?? lastRt.model ?? (defaults?.defaultModel || undefined) ?? fallback?.model;
287
+ const effort = peek.effort ?? binding.effort ?? lastRt.effort ?? (defaults?.defaultEffort || undefined) ?? fallback?.effort;
288
+ return { ...(model ? { model } : {}), ...(effort ? { effort } : {}) };
289
+ }
290
+
291
+ driver(id: string): AgentDriver | undefined {
292
+ return this.drivers.get(id);
293
+ }
294
+
295
+ /** 已注册且在配置里启用的 agent */
296
+ enabledAgents(): AgentDriver[] {
297
+ const agents = config().agents as Record<string, { enabled: boolean }>;
298
+ return [...this.drivers.values()].filter((d) => agents[d.id]?.enabled !== false);
299
+ }
300
+
301
+ private requireDriver(agent: string): AgentDriver {
302
+ const driver = this.drivers.get(agent);
303
+ const enabled = (config().agents as Record<string, { enabled: boolean }>)[agent]?.enabled !== false;
304
+ if (!driver || !enabled) throw new Error(`agent「${agent}」不可用(未启用或未安装)`);
305
+ return driver;
306
+ }
307
+
308
+ // ---------- 事件 ----------
309
+
310
+ emitEvent(key: string, ev: ConvEvent): void {
311
+ const env: EventEnvelope = { key, seq: ++this.seq, ts: Date.now(), ev };
312
+ let buf = this.buffers.get(key);
313
+ if (!buf) this.buffers.set(key, (buf = []));
314
+ buf.push(env);
315
+ // 一轮结束后,这轮的流式片段已经被完整文本覆盖,没必要再留着占内存
316
+ if (ev.type === "result" || ev.type === "error") {
317
+ const kept = buf.filter((e) => e.ev.type !== "delta");
318
+ buf.length = 0;
319
+ buf.push(...kept);
320
+ }
321
+ if (buf.length > BUFFER_MAX) {
322
+ const dropped = buf.splice(0, buf.length - BUFFER_MAX);
323
+ // 记下截断水位:之后再压缩 delta 让数组变短,也不能假装还能补全
324
+ this.truncated.set(key, dropped[dropped.length - 1]!.seq);
325
+ }
326
+ this.emit("event", env);
327
+ }
328
+
329
+ currentSeq(): number {
330
+ return this.seq;
331
+ }
332
+
333
+ /**
334
+ * seq 之后的事件。返回 null 表示缺口已经超出缓冲区(客户端该整页重新拉历史)。
335
+ */
336
+ eventsSince(key: string, since: number): EventEnvelope[] | null {
337
+ const buf = this.buffers.get(key) ?? [];
338
+ // 客户端没见过的事件里有被截掉的:补不全了
339
+ if (since < (this.truncated.get(key) ?? 0)) return null;
340
+ return buf.filter((e) => e.seq > since);
341
+ }
342
+
343
+ /** 正在进行的这一轮(从最后一条用户消息起),给刚打开页面的客户端补画用。 */
344
+ liveTurn(key: string): EventEnvelope[] {
345
+ if (!this.isBusy(key)) return [];
346
+ const buf = this.buffers.get(key) ?? [];
347
+ let start = buf.length;
348
+ for (let i = buf.length - 1; i >= 0; i--) {
349
+ const t = buf[i]!.ev.type;
350
+ if (t === "user") {
351
+ start = i;
352
+ break;
353
+ }
354
+ if (t === "result" || t === "error") {
355
+ start = i + 1;
356
+ break;
357
+ }
358
+ if (i === 0) start = 0;
359
+ }
360
+ return buf.slice(start);
361
+ }
362
+
363
+ isBusy(key: string): boolean {
364
+ return (this.inflight.get(key) ?? 0) > 0;
365
+ }
366
+
367
+ private setInflight(key: string, n: number): void {
368
+ const before = this.isBusy(key);
369
+ this.inflight.set(key, Math.max(0, n));
370
+ const after = this.isBusy(key);
371
+ if (before !== after) this.emitEvent(key, { type: "state", busy: after });
372
+ }
373
+
374
+ // ---------- 会话 ----------
375
+
376
+ ensure(params: {
377
+ key: string;
378
+ channel: string;
379
+ channelId: string;
380
+ defaultAgent: string;
381
+ title?: string;
382
+ }): Conversation {
383
+ return ensureConversation({
384
+ key: params.key,
385
+ channel: params.channel,
386
+ channelId: params.channelId,
387
+ agent: params.defaultAgent,
388
+ ...(params.title ? { title: params.title } : {}),
389
+ });
390
+ }
391
+
392
+ conversation(key: string): Conversation | undefined {
393
+ return getConversation(key);
394
+ }
395
+
396
+ info(key: string): ConversationInfo | undefined {
397
+ const conv = getConversation(key);
398
+ if (!conv) return undefined;
399
+ const binding = conv.perAgent[conv.agent] ?? {};
400
+ return {
401
+ key: conv.key,
402
+ ...(conv.title ? { title: conv.title } : {}),
403
+ channel: conv.channel,
404
+ channelId: conv.channelId,
405
+ agent: conv.agent,
406
+ cwd: conv.cwd,
407
+ ...(binding.sessionId ? { sessionId: binding.sessionId } : {}),
408
+ ...(binding.model ? { model: binding.model } : {}),
409
+ ...(binding.effort ? { effort: binding.effort } : {}),
410
+ ...(() => {
411
+ const rt = this.displayRuntime(conv, binding);
412
+ return { ...(rt.model ? { runtimeModel: rt.model } : {}), ...(rt.effort ? { runtimeEffort: rt.effort } : {}) };
413
+ })(),
414
+ ...(() => {
415
+ const ctx = binding.sessionId ? this.contextBySession.get(binding.sessionId) : undefined;
416
+ return ctx ? { context: ctx } : {};
417
+ })(),
418
+ busy: this.isBusy(key),
419
+ activeAt: conv.activeAt,
420
+ createdAt: conv.createdAt,
421
+ ...(conv.hidden ? { hidden: true } : {}),
422
+ ...(conv.forkOf ? { forkOf: conv.forkOf } : {}),
423
+ ...(this.drivers.get(conv.agent)?.forkAt ? { canFork: true } : {}),
424
+ };
425
+ }
426
+
427
+ list(): ConversationInfo[] {
428
+ return listConversations()
429
+ .map((c) => this.info(c.key)!)
430
+ .sort((a, b) => b.activeAt - a.activeAt);
431
+ }
432
+
433
+ /** 取活着的 agent 会话,没有就按绑定拉起(默认续用上次的会话)。 */
434
+ session(key: string, opts: { resumeId?: string; fork?: boolean } = {}): AgentSession {
435
+ const conv = getConversation(key);
436
+ if (!conv) throw new Error(`会话不存在:${key}`);
437
+ // 先检查 agent 还启用着:设置里关掉了,已经打开的会话也不能再用
438
+ const driver = this.requireDriver(conv.agent);
439
+ const existing = this.live.get(key);
440
+ if (existing && existing.agent === conv.agent && existing.cwd === conv.cwd && !opts.resumeId) {
441
+ return existing.session;
442
+ }
443
+ if (existing) {
444
+ interactions.cancelAll(key, "会话已切换");
445
+ this.rememberRuntime(key, existing);
446
+ this.live.delete(key);
447
+ void existing.session.dispose();
448
+ }
449
+
450
+ const binding = agentBinding(conv);
451
+
452
+ const agentDefaults = (config().agents as Record<string, { defaultModel?: string; defaultEffort?: string }>)[
453
+ conv.agent
454
+ ];
455
+ const model = binding.model || agentDefaults?.defaultModel || undefined;
456
+ const effort = binding.effort || agentDefaults?.defaultEffort || undefined;
457
+ // 有待分叉的记录:从它分叉(见 AgentBinding.forkFrom)
458
+ const pendingFork = !opts.resumeId && binding.forkFrom ? binding.forkFrom : undefined;
459
+ const resumeId = opts.resumeId ?? pendingFork ?? (opts.fork ? undefined : binding.sessionId);
460
+ const fork = opts.fork || !!pendingFork;
461
+ const agent = conv.agent;
462
+ const liveId = ++this.sessionSeq;
463
+ // 被丢弃的旧会话晚到的事件(dispose 时的中断错误等)不该再出现在这条会话里
464
+ const holder: { session?: AgentSession } = {};
465
+ const alive = () => this.live.get(key)?.session === holder.session;
466
+
467
+ const session = driver.createSession(
468
+ {
469
+ cwd: conv.cwd,
470
+ uploadsDir: ensureUploadsDir(key),
471
+ ...(model ? { model } : {}),
472
+ ...(effort ? { effort } : {}),
473
+ ...(resumeId ? { resumeId } : {}),
474
+ ...(fork ? { fork: true } : {}),
475
+ ...(conv.autoEdit ? { autoEdit: true } : {}),
476
+ },
477
+ {
478
+ onTurnStart: () => alive() && this.emitEvent(key, { type: "turn_start" }),
479
+ onStep: (kind, text) => alive() && this.emitEvent(key, { type: "step", kind, text }),
480
+ onTextDelta: (text) => alive() && this.emitEvent(key, { type: "delta", text }),
481
+ onText: (text) => alive() && this.emitEvent(key, { type: "text", text }),
482
+ onResult: (result) => {
483
+ if (!alive()) return;
484
+ const parts = [`用时 ${(result.durationMs / 1000).toFixed(1)}s`];
485
+ if (result.costUsd !== undefined) parts.push(`约 $${result.costUsd.toFixed(4)}`);
486
+ if (result.tokens !== undefined) parts.push(`${result.tokens} tokens`);
487
+ const footer = parts.join(" ");
488
+ this.emitEvent(key, {
489
+ type: "result",
490
+ text: result.text,
491
+ footer,
492
+ at: Date.now(),
493
+ ...(result.anchor ? { anchor: result.anchor } : {}),
494
+ });
495
+ const conv = getConversation(key);
496
+ recordFooter(holder.session?.sessionId ?? (conv ? agentBinding(conv, agent).sessionId : undefined), result.text, footer);
497
+ this.setInflight(key, (this.inflight.get(key) ?? 1) - 1);
498
+ this.refreshRuntimeOnce(key, liveId);
499
+ },
500
+ onError: (err) => {
501
+ if (!alive()) return;
502
+ const diagnosis = driver.diagnose(err);
503
+ if (!this.announced.has(diagnosis.kind)) {
504
+ this.announced.add(diagnosis.kind);
505
+ console.error(`[${agent}] ${diagnosis.consoleHint}`);
506
+ }
507
+ console.error(`[${agent}] ${key} 出错:`, err instanceof Error ? err.message : err);
508
+ // 这一轮已经结束了,挂着的审批没人会再等它
509
+ interactions.cancelAll(key, "本轮出错结束");
510
+ if (Date.now() - (this.stoppedAt.get(key) ?? 0) < 15_000) {
511
+ // 用户自己点的停止:中断导致的「没能正常收尾」不是故障
512
+ this.emitEvent(key, { type: "notice", text: "已中断。" });
513
+ } else {
514
+ this.emitEvent(key, { type: "error", message: diagnosis.chatMessage });
515
+ }
516
+ this.setInflight(key, (this.inflight.get(key) ?? 1) - 1);
517
+ },
518
+ onContext: (ctx) => {
519
+ if (!alive()) return;
520
+ const sid = holder.session?.sessionId;
521
+ if (!sid) return;
522
+ const prev = this.contextBySession.get(sid);
523
+ this.contextBySession.delete(sid);
524
+ this.contextBySession.set(sid, ctx);
525
+ if (this.contextBySession.size > CONTEXT_MAX) {
526
+ this.contextBySession.delete(this.contextBySession.keys().next().value!);
527
+ }
528
+ // Codex 一轮里会报好几次,数字没变就不打扰前端
529
+ if (prev?.used !== ctx.used || prev?.window !== ctx.window) this.emitEvent(key, { type: "meta" });
530
+ },
531
+ onDropped: (n) => {
532
+ if (!alive() || n <= 0) return;
533
+ this.setInflight(key, (this.inflight.get(key) ?? n) - n);
534
+ },
535
+ onSessionId: (sessionId) => {
536
+ // 切走之后旧会话晚到的回调不该覆盖新绑定
537
+ if (!alive()) return;
538
+ const current = getConversation(key);
539
+ const forkFrom = current ? agentBinding(current, agent).forkFrom : undefined;
540
+ // 分叉中还报的是原会话 id:分叉尚未生效,什么都不改
541
+ if (forkFrom && sessionId === forkFrom) return;
542
+ updateAgentBinding(key, agent, { sessionId, forkFrom: undefined });
543
+ this.emitEvent(key, { type: "meta" });
544
+ },
545
+ },
546
+ // 已经被丢弃的旧进程晚到的提问 / 审批:直接按取消处理,不能挂到新会话上
547
+ (spec: InteractionSpec) =>
548
+ alive()
549
+ ? interactions.create(key, spec, config().approvalTimeoutMs, liveId)
550
+ : Promise.resolve({ settlement: "cancelled" as const, answers: {} }),
551
+ );
552
+ holder.session = session;
553
+ this.live.set(key, { id: liveId, agent, cwd: conv.cwd, session });
554
+ return session;
555
+ }
556
+
557
+ /** 丢掉活着的会话(不动绑定)。挂着的审批一并取消,否则阻塞的那一轮永远走不完。 */
558
+ async drop(key: string, note: string): Promise<void> {
559
+ interactions.cancelAll(key, note);
560
+ const existing = this.live.get(key);
561
+ if (existing) this.rememberRuntime(key, existing);
562
+ this.live.delete(key);
563
+ this.setInflight(key, 0);
564
+ if (!existing) return;
565
+ // dispose 卡住(CLI 不响应)不能把这条会话的操作队列永远堵死
566
+ await Promise.race([
567
+ existing.session.dispose().catch(() => {}),
568
+ new Promise((r) => setTimeout(r, 10_000).unref()),
569
+ ]);
570
+ }
571
+
572
+ /**
573
+ * 同一条会话的生命周期操作(开新会话、切目录、切 agent、恢复、发消息…)串行执行。
574
+ *
575
+ * 不串行的话:/cd 正在等旧会话 dispose,另一个渠道恰好发来一条消息,就会用旧目录
576
+ * 拉起一个新会话;/cd 随后只改了记录,实际进程却还在旧目录里干活。
577
+ */
578
+ private serial<T>(key: string, fn: () => Promise<T>): Promise<T> {
579
+ const prev = this.locks.get(key) ?? Promise.resolve();
580
+ const next = prev.catch(() => {}).then(fn);
581
+ const tail = next.catch(() => {});
582
+ this.locks.set(key, tail);
583
+ void tail.then(() => {
584
+ if (this.locks.get(key) === tail) this.locks.delete(key);
585
+ });
586
+ return next;
587
+ }
588
+
589
+ // ---------- 说话 ----------
590
+
591
+ /**
592
+ * 送一句话进会话。
593
+ *
594
+ * 提问挂着的时候整轮是阻塞的,这条消息送进去也没人接 —— 所以先拿它当作答。
595
+ * 带附件的不拿来作答:答案只能是文字。
596
+ */
597
+ /** 当前的停止代次 */
598
+ stopGeneration(key: string): number {
599
+ return this.stopGen.get(key) ?? 0;
600
+ }
601
+
602
+ /** 渠道在「收到消息」那一刻取,送进 send 的 gen 参数 */
603
+ generation(key: string): InputGen {
604
+ return { stop: this.stopGeneration(key), boundary: this.opSeq.get(key) ?? 0, at: Date.now() };
605
+ }
606
+
607
+ private nextOp(key: string): number {
608
+ const seq = (this.opSeq.get(key) ?? 0) + 1;
609
+ this.opSeq.set(key, seq);
610
+ return seq;
611
+ }
612
+
613
+ /** 收到这条输入之后发起的切换里,有没有已经真正生效的 */
614
+ private switchedSince(key: string, g: InputGen): boolean {
615
+ return (this.lastSwitch.get(key)?.seq ?? 0) > g.boundary;
616
+ }
617
+
618
+ /** 当前活着的会话进程编号(交互请求绑定到它) */
619
+ liveSessionId(key: string): number | undefined {
620
+ return this.live.get(key)?.id;
621
+ }
622
+
623
+ /** 序号为 seq 的切换真的生效了(不是「操作被调用了」—— 没实际效果的操作不算) */
624
+ private markSwitched(key: string, seq: number): void {
625
+ const prev = this.lastSwitch.get(key);
626
+ this.lastSwitch.set(key, { seq: Math.max(prev?.seq ?? 0, seq), at: Date.now() });
627
+ }
628
+
629
+ /**
630
+ * 切换类操作的统一入口:调用那一刻记下「有一个切换在排队」和发起时刻(清上传目录用),
631
+ * 进串行队列执行,结束后撤掉排队计数。真正换没换会话由操作自己 markSwitched。
632
+ */
633
+ private switchOp<T>(key: string, fn: (op: { at: number; seq: number }) => Promise<T>): Promise<T> {
634
+ const op = { at: Date.now(), seq: this.nextOp(key) };
635
+ this.pendingOps.set(key, (this.pendingOps.get(key) ?? 0) + 1);
636
+ return this.serial(key, () => fn(op)).finally(() => {
637
+ this.pendingOps.set(key, Math.max(0, (this.pendingOps.get(key) ?? 1) - 1));
638
+ });
639
+ }
640
+
641
+ /**
642
+ * 切换类操作清上传目录:只清这次操作发起之前就有的文件。切换途中刚收到的附件
643
+ * 属于切换之后的新消息,不能一起删掉。截止时间由各操作在入口自己捕获后传进来,
644
+ * 不读共享状态 —— 否则后面再来一条命令就会把它覆盖掉。
645
+ */
646
+ private clearOldUploads(key: string, before: number): void {
647
+ clearUploads(key, [], before);
648
+ }
649
+
650
+ /**
651
+ * gen:这条消息是在哪个停止代次下收到的。渠道自己还要先做点异步的事(飞书下载附件)
652
+ * 才调到这里时必须传:下载期间点了停止,这条消息就不该再发出去。
653
+ */
654
+ send(key: string, input: UserInput, origin: Origin, gen?: InputGen): void {
655
+ const conv = getConversation(key);
656
+ if (!conv) throw new Error(`会话不存在:${key}`);
657
+ const g = gen ?? this.generation(key);
658
+ const cancelled = () => {
659
+ if (this.stopGeneration(key) !== g.stop) {
660
+ this.emitEvent(key, { type: "notice", text: "已停止,这条消息没有发出。" });
661
+ return true;
662
+ }
663
+ // 收到这条之后才发起的切换生效了(新会话 / 分叉 / 恢复 / 切 agent / 切目录 / 改了工作区根目录):
664
+ // 这条是冲着旧会话说的,不能送进新的里去
665
+ if (this.switchedSince(key, g)) {
666
+ this.emitEvent(key, {
667
+ type: "notice",
668
+ text: "这条消息处理期间会话被切换了(新会话 / 目录 / agent 变了),它没有发出,请重新发送。",
669
+ });
670
+ return true;
671
+ }
672
+ return false;
673
+ };
674
+ // 最先检查:停止之前收到的旧消息,不能去回答停止之后新冒出来的提问
675
+ if (cancelled()) return;
676
+ const shown = {
677
+ type: "user" as const,
678
+ text: input.text,
679
+ attachments: input.attachments.map((a) => `${KIND_LABEL[a.kind]} ${a.name}`),
680
+ origin,
681
+ };
682
+ const by = `${origin.name ?? "用户"}以消息作答`;
683
+
684
+ /**
685
+ * 拿这条消息回答挂着的提问。条件:提问属于当前活着的会话进程、这条消息收到后
686
+ * 没有切换生效过;expectId 给了的话,还必须正是收到时挂着的那一个(不能去回答后来才冒出的新提问)。
687
+ */
688
+ const tryAnswer = (expectId?: string): boolean => {
689
+ if (input.attachments.length > 0) return false;
690
+ const asking = interactions.findQuestion(key);
691
+ if (!asking || asking.owner === undefined || asking.owner !== this.liveSessionId(key)) return false;
692
+ if (expectId !== undefined && asking.id !== expectId) return false;
693
+ if (this.switchedSince(key, g)) return false;
694
+ return interactions.answerWithText(asking.id, input.text, by);
695
+ };
696
+
697
+ // 没有排队中的切换:能作答就立刻作答。有的话先排队,轮到它时再判断 ——
698
+ // 那个切换可能是无效操作(会话没变,提问还在等),也可能真的换了会话(那就不该作答)
699
+ const pending = (this.pendingOps.get(key) ?? 0) > 0;
700
+ if (!pending && tryAnswer()) {
701
+ this.emitEvent(key, shown);
702
+ return;
703
+ }
704
+ const questionAtEntry = pending && input.attachments.length === 0 ? interactions.findQuestion(key)?.id : undefined;
705
+
706
+ // 闲置过期要按这次说话之前的活跃时间判断,所以先记下、后 touch
707
+ const lastActive = conv.activeAt;
708
+ touchConversation(key);
709
+ if (!conv.title && conv.channel === "web" && input.text.trim()) {
710
+ updateConversation(key, { title: input.text.trim().replace(/\s+/g, " ").slice(0, 40) });
711
+ this.emitEvent(key, { type: "meta" });
712
+ }
713
+ this.emitEvent(key, shown);
714
+ void this.serial(key, async () => {
715
+ if (cancelled()) return;
716
+ if (questionAtEntry && tryAnswer(questionAtEntry)) return;
717
+ // 收到之后发生过显式切换(恢复 / 分叉 / 新会话…):用户已经明确指定了会话,不再按旧的闲置时间作废它
718
+ const ttl = config().sessionTtlMs;
719
+ const switchedAfterEntry = (this.lastSwitch.get(key)?.at ?? 0) >= g.at;
720
+ const expired = !switchedAfterEntry && ttl > 0 && Date.now() - lastActive > ttl;
721
+ if (expired) {
722
+ await this.expireNow(key);
723
+ // 清理要等旧会话 dispose,期间可能又点了停止
724
+ if (cancelled()) return;
725
+ }
726
+ this.sendNow(key, input);
727
+ });
728
+ }
729
+
730
+ /** 闲置太久:不再续用上次的会话(活着的也丢掉),开新的 */
731
+ private async expireNow(key: string): Promise<void> {
732
+ const conv = getConversation(key);
733
+ if (!conv || !(agentBinding(conv).sessionId || agentBinding(conv).forkFrom)) return;
734
+ console.log(`[会话] ${key} 闲置超过 ${config().sessionTtlMs / 3600000} 小时,开新会话`);
735
+ await this.drop(key, "会话已闲置过期");
736
+ updateAgentBinding(key, conv.agent, { sessionId: undefined, forkFrom: undefined, model: undefined, effort: undefined });
737
+ // 不清上传目录:本次和排在后面的消息可能都带着刚上传的附件。
738
+ // 旧附件留到 /new 或下次启动的清扫时再删。
739
+ this.emitEvent(key, { type: "notice", text: "上次的会话闲置太久,已自动开新会话。" });
740
+ this.emitEvent(key, { type: "meta" });
741
+ }
742
+
743
+ private sendNow(key: string, input: UserInput): void {
744
+ let session: AgentSession;
745
+ try {
746
+ session = this.session(key);
747
+ } catch (err) {
748
+ this.emitEvent(key, { type: "error", message: err instanceof Error ? err.message : String(err) });
749
+ return;
750
+ }
751
+ this.setInflight(key, (this.inflight.get(key) ?? 0) + 1);
752
+ try {
753
+ session.send(input);
754
+ } catch (err) {
755
+ this.setInflight(key, (this.inflight.get(key) ?? 1) - 1);
756
+ this.emitEvent(key, { type: "error", message: err instanceof Error ? err.message : String(err) });
757
+ }
758
+ }
759
+
760
+ notice(key: string, text: string): void {
761
+ this.emitEvent(key, { type: "notice", text });
762
+ }
763
+
764
+ // ---------- 操作(命令和 Web 按钮共用) ----------
765
+
766
+ private async runtimeNow(key: string): Promise<RuntimeInfo> {
767
+ const conv = getConversation(key)!;
768
+ const driver = this.requireDriver(conv.agent);
769
+ const binding = agentBinding(conv);
770
+ let runtime: RuntimeInfo = {};
771
+ try {
772
+ runtime = await withTimeout(this.session(key).describeRuntime(), "读取模型信息");
773
+ } catch (err) {
774
+ console.error("[会话] 读取模型信息失败:", err);
775
+ }
776
+ const defaults = (config().agents as Record<string, { defaultModel?: string; defaultEffort?: string }>)[
777
+ conv.agent
778
+ ];
779
+ const fallback =
780
+ runtime.model && runtime.effort
781
+ ? {}
782
+ : await withTimeout(driver.defaults(), "读取默认配置").catch(() => ({}) as RuntimeInfo);
783
+ const model = runtime.model ?? binding.model ?? (defaults?.defaultModel || undefined) ?? fallback.model;
784
+ const effort = runtime.effort ?? binding.effort ?? (defaults?.defaultEffort || undefined) ?? fallback.effort;
785
+ if (runtime.model || runtime.effort) this.lastRuntime.set(key, { agent: conv.agent, runtime });
786
+ return { ...(model ? { model } : {}), ...(effort ? { effort } : {}) };
787
+ }
788
+
789
+ private async newSessionNow(key: string, model: string | undefined, op: SwitchOp): Promise<string> {
790
+ const conv = getConversation(key)!;
791
+ await this.drop(key, "已开新会话");
792
+ this.markSwitched(key, op.seq);
793
+ updateAgentBinding(key, conv.agent, { sessionId: undefined, forkFrom: undefined, model: model || undefined, effort: undefined });
794
+ this.clearOldUploads(key, op.at);
795
+ this.emitEvent(key, { type: "meta" });
796
+ const runtime = await this.runtimeNow(key);
797
+ return `已开新会话(${this.label(conv.agent)},模型 ${runtime.model ?? "默认"}${
798
+ runtime.effort ? `,思考 ${runtime.effort}` : ""
799
+ })。`;
800
+ }
801
+
802
+ private async forkNow(key: string, op: SwitchOp): Promise<string> {
803
+ const conv = getConversation(key)!;
804
+ const binding = agentBinding(conv);
805
+ const current = binding.forkFrom ?? binding.sessionId;
806
+ if (!current) return "当前还没有可分叉的会话。";
807
+ await this.drop(key, "已分叉会话");
808
+ this.markSwitched(key, op.seq);
809
+ // 只记下意图,下一条消息时才真正分叉(见 session() 里的 pendingFork)
810
+ updateAgentBinding(key, conv.agent, { forkFrom: current });
811
+ touchConversation(key);
812
+ this.emitEvent(key, { type: "meta" });
813
+ return "已分叉,原会话保留。下一条消息起在分叉出来的新会话里继续。";
814
+ }
815
+
816
+ private async resumeNow(key: string, sessionId: string, originCwd: string | undefined, op: SwitchOp): Promise<string> {
817
+ const conv = getConversation(key)!;
818
+ const driver = this.requireDriver(conv.agent);
819
+ if (!(await withTimeout(driver.sessionExists(sessionId, conv.cwd), "查询会话"))) {
820
+ return `找不到会话 ${sessionId}。\n发 /sessions 看看有哪些可恢复的。`;
821
+ }
822
+ await this.drop(key, "会话已切换");
823
+ this.markSwitched(key, op.seq);
824
+ if (sessionId !== agentBinding(conv).sessionId) this.clearOldUploads(key, op.at);
825
+ updateAgentBinding(key, conv.agent, { sessionId, forkFrom: undefined });
826
+ // 显式恢复 = 用户要接着它聊:刷新活跃时间,否则下一条消息会按恢复前的闲置时间判成过期
827
+ touchConversation(key);
828
+ this.session(key, { resumeId: sessionId });
829
+ this.emitEvent(key, { type: "meta" });
830
+ const note =
831
+ originCwd && originCwd !== conv.cwd
832
+ ? `\n\n注意:这条会话原本在 \`${originCwd}\`,恢复后会在 \`${conv.cwd}\` 里继续 —— 上下文都在,但读写范围跟着当前工作目录走。`
833
+ : "";
834
+ return `已恢复会话 ${sessionId},直接接着说就行。${note}`;
835
+ }
836
+
837
+ async sessions(key: string, all: boolean): Promise<Extract<CommandReply, { type: "sessions" }>> {
838
+ const conv = getConversation(key)!;
839
+ const driver = this.requireDriver(conv.agent);
840
+ // 先按当前目录列,一条都没有就自动退回全局 —— 回一句「没有历史」是误导
841
+ let sessions = all ? [] : await driver.listSessions(conv.cwd, 10);
842
+ const fellBack = !all && sessions.length === 0;
843
+ if (all || fellBack) sessions = await driver.listSessions(undefined, 10);
844
+ const current = agentBinding(conv).sessionId;
845
+ return {
846
+ type: "sessions",
847
+ agent: conv.agent,
848
+ sessions,
849
+ ...(current ? { current } : {}),
850
+ cwd: conv.cwd,
851
+ allDirs: all || fellBack,
852
+ fellBack,
853
+ };
854
+ }
855
+
856
+ private async modelsNow(key: string): Promise<Extract<CommandReply, { type: "models" }>> {
857
+ const conv = getConversation(key)!;
858
+ const driver = this.requireDriver(conv.agent);
859
+ const models = await withTimeout(driver.listModels(this.session(key)), "读取模型列表");
860
+ const runtime = await this.runtimeNow(key);
861
+ return {
862
+ type: "models",
863
+ agent: conv.agent,
864
+ models,
865
+ ...(runtime.model ? { current: runtime.model } : {}),
866
+ ...(runtime.effort ? { currentEffort: runtime.effort } : {}),
867
+ effortSupported: driver.capabilities.effort,
868
+ };
869
+ }
870
+
871
+ private async setModelNow(key: string, model: string): Promise<string> {
872
+ const conv = getConversation(key)!;
873
+ await withTimeout(this.session(key).setModel(model), "切换模型");
874
+ updateAgentBinding(key, conv.agent, { model });
875
+ this.emitEvent(key, { type: "meta" });
876
+ return `模型已切换为 ${model}。`;
877
+ }
878
+
879
+ private async setEffortNow(key: string, effort: string): Promise<string> {
880
+ const conv = getConversation(key)!;
881
+ const driver = this.requireDriver(conv.agent);
882
+ if (!driver.capabilities.effort) return `${driver.label} 不支持切换思考程度。`;
883
+ await withTimeout(this.session(key).setModel(undefined, effort), "切换思考程度");
884
+ updateAgentBinding(key, conv.agent, { effort });
885
+ this.emitEvent(key, { type: "meta" });
886
+ return `思考程度已切换为 ${effort}。`;
887
+ }
888
+
889
+ /** /autoedit:on 不给就是切换。只影响正在跑的会话和以后新建的,不追溯已经弹出的授权。 */
890
+ private async setAutoEditNow(key: string, on: boolean | undefined): Promise<string> {
891
+ const conv = getConversation(key)!;
892
+ const next = on ?? !conv.autoEdit;
893
+ const live = this.live.get(key);
894
+ if (live?.session.setAutoEdit) await withTimeout(live.session.setAutoEdit(next), "切换自动改文件");
895
+ updateConversation(key, { autoEdit: next });
896
+ this.emitEvent(key, { type: "meta" });
897
+ const driver = this.requireDriver(conv.agent);
898
+ const note =
899
+ conv.agent === "codex"
900
+ ? `\n\n注意:${driver.label} 改文件由沙箱控制(设置 → Agent → 沙箱),这个开关只对 Claude 生效。`
901
+ : "";
902
+ return next
903
+ ? `已开启:这条对话里 Claude 修改工作目录(${conv.cwd})内的文件不再询问;目录外的文件、Bash 命令照常询问。发 /autoedit off 关闭。${note}`
904
+ : `已关闭:改文件恢复逐次询问。${note}`;
905
+ }
906
+
907
+ setAutoEdit(key: string, on?: boolean): Promise<string> {
908
+ return this.serial(key, () => this.setAutoEditNow(key, on));
909
+ }
910
+
911
+ /** 切 agent:各 agent 的会话各自保留,切回来接着聊。 */
912
+ private async setAgentNow(key: string, agent: string, allowed: string[], op: SwitchOp): Promise<string> {
913
+ const conv = getConversation(key)!;
914
+ if (!allowed.includes(agent)) {
915
+ return `这里不能用「${agent}」。可选:${allowed.join("、")}`;
916
+ }
917
+ this.requireDriver(agent);
918
+ if (conv.agent === agent) return `当前已经是 ${this.label(agent)}。`;
919
+ await this.drop(key, `已切换到 ${this.label(agent)}`);
920
+ this.markSwitched(key, op.seq);
921
+ updateConversation(key, { agent });
922
+ this.emitEvent(key, { type: "meta" });
923
+ const resumed = agentBinding(getConversation(key)!, agent).sessionId;
924
+ return `已切换到 ${this.label(agent)}${resumed ? ",接着它上次的会话" : ""}。`;
925
+ }
926
+
927
+ async stop(key: string): Promise<string> {
928
+ // 不进串行队列:停止要立刻生效。排队中还没送出的消息靠代次作废
929
+ this.stopGen.set(key, (this.stopGen.get(key) ?? 0) + 1);
930
+ this.stoppedAt.set(key, Date.now());
931
+ interactions.cancelAll(key, "本轮已中断");
932
+ await this.live.get(key)?.session.interrupt();
933
+ this.setInflight(key, 0);
934
+ return "已请求中断。";
935
+ }
936
+
937
+ private async cdNow(key: string, dir: string, op: SwitchOp): Promise<string> {
938
+ const resolved = resolveWorkspacePath(dir);
939
+ if (!resolved) return `不允许:目录必须存在,且在 ${config().workspaceRoot} 之内(不能经符号链接跳出去)。`;
940
+ const conv = getConversation(key)!;
941
+ await this.drop(key, "工作目录已切换");
942
+ this.markSwitched(key, op.seq);
943
+ // 会话记录是按目录存的,换目录后旧会话 resume 不回来,干脆都清掉
944
+ const perAgent = Object.fromEntries(
945
+ Object.entries(conv.perAgent).map(([a, b]) => [a, { ...b, sessionId: undefined, forkFrom: undefined }]),
946
+ );
947
+ updateConversation(key, { cwd: resolved, cwdExplicit: true, perAgent });
948
+ this.clearOldUploads(key, op.at);
949
+ this.emitEvent(key, { type: "meta" });
950
+ return `工作目录已切换到 ${resolved},下条消息将开新会话。`;
951
+ }
952
+
953
+ private async statusNow(key: string): Promise<string> {
954
+ const conv = getConversation(key)!;
955
+ const driver = this.requireDriver(conv.agent);
956
+ const runtime = await this.runtimeNow(key);
957
+ const extra = await withTimeout(driver.statusLines(), "读取状态").catch(() => [] as string[]);
958
+ return [
959
+ `Agent:${driver.label}`,
960
+ `会话:${agentBinding(conv).sessionId ?? "(尚未建立)"}`,
961
+ `目录:${conv.cwd}`,
962
+ `模型:${runtime.model ?? "默认"}`,
963
+ ...(runtime.effort ? [`思考程度:${runtime.effort}`] : []),
964
+ ...(conv.autoEdit ? ["自动允许改文件:开(/autoedit off 关闭)"] : []),
965
+ ...(extra.length > 0 ? ["", ...extra] : []),
966
+ ].join("\n");
967
+ }
968
+
969
+ async history(key: string): Promise<HistoryMessage[]> {
970
+ const conv = getConversation(key);
971
+ if (!conv) return [];
972
+ const sessionId = agentBinding(conv).sessionId;
973
+ if (!sessionId) return [];
974
+ const driver = this.requireDriver(conv.agent);
975
+ try {
976
+ return attachFooters(sessionId, await driver.readHistory(sessionId, conv.cwd));
977
+ } catch (err) {
978
+ console.error(`[历史] 读取 ${key} 失败:`, err);
979
+ return [];
980
+ }
981
+ }
982
+
983
+ // ---------- 对外的操作:都经过 serial() ----------
984
+
985
+ runtime(key: string): Promise<RuntimeInfo> {
986
+ return this.serial(key, () => this.runtimeNow(key));
987
+ }
988
+
989
+ newSession(key: string, model?: string): Promise<string> {
990
+ return this.switchOp(key, (op) => this.newSessionNow(key, model, op));
991
+ }
992
+
993
+ fork(key: string): Promise<string> {
994
+ return this.switchOp(key, (op) => this.forkNow(key, op));
995
+ }
996
+
997
+ /**
998
+ * 分叉成一条新的 Web 对话:agent 会话复制一份(给了 anchor 就只到那条回复为止),
999
+ * 原对话完全不动。返回新对话的 key。和 /fork 不同 —— /fork 是在同一条对话里换成分叉出来的会话。
1000
+ */
1001
+ async forkConversation(key: string, anchor?: string): Promise<string> {
1002
+ const conv = getConversation(key);
1003
+ if (!conv) throw new Error(`会话不存在:${key}`);
1004
+ const driver = this.requireDriver(conv.agent);
1005
+ if (!driver.forkAt) throw new Error(`${driver.label} 不支持从这里分叉`);
1006
+ const binding = agentBinding(conv);
1007
+ // /fork 之后还没发消息:真正的内容还在原会话里
1008
+ const source = binding.sessionId ?? binding.forkFrom;
1009
+ if (!source) throw new Error("这条对话还没有可分叉的会话");
1010
+ const sessionId = await withTimeout(driver.forkAt(source, conv.cwd, anchor), "分叉会话");
1011
+ copyFooters(source, sessionId);
1012
+ const newKey = `web:${crypto.randomBytes(6).toString("hex")}`;
1013
+ ensureConversation({ key: newKey, channel: "web", channelId: "web", agent: conv.agent });
1014
+ updateConversation(newKey, {
1015
+ title: `${conv.title ?? "会话"} · 分叉`.slice(0, 80),
1016
+ cwd: conv.cwd,
1017
+ ...(conv.cwdExplicit ? { cwdExplicit: true } : {}),
1018
+ ...(conv.autoEdit ? { autoEdit: true } : {}),
1019
+ forkOf: key,
1020
+ perAgent: {
1021
+ [conv.agent]: {
1022
+ sessionId,
1023
+ ...(binding.model ? { model: binding.model } : {}),
1024
+ ...(binding.effort ? { effort: binding.effort } : {}),
1025
+ },
1026
+ },
1027
+ });
1028
+ this.emitEvent(newKey, { type: "meta" });
1029
+ return newKey;
1030
+ }
1031
+
1032
+ resume(key: string, sessionId: string, originCwd?: string): Promise<string> {
1033
+ return this.switchOp(key, (op) => this.resumeNow(key, sessionId, originCwd, op));
1034
+ }
1035
+
1036
+ models(key: string): Promise<Extract<CommandReply, { type: "models" }>> {
1037
+ return this.serial(key, () => this.modelsNow(key));
1038
+ }
1039
+
1040
+ setModel(key: string, model: string): Promise<string> {
1041
+ return this.serial(key, () => this.setModelNow(key, model));
1042
+ }
1043
+
1044
+ setEffort(key: string, effort: string): Promise<string> {
1045
+ return this.serial(key, () => this.setEffortNow(key, effort));
1046
+ }
1047
+
1048
+ setAgent(key: string, agent: string, allowed: string[]): Promise<string> {
1049
+ return this.switchOp(key, (op) => this.setAgentNow(key, agent, allowed, op));
1050
+ }
1051
+
1052
+ cd(key: string, dir: string): Promise<string> {
1053
+ return this.switchOp(key, (op) => this.cdNow(key, dir, op));
1054
+ }
1055
+
1056
+ status(key: string): Promise<string> {
1057
+ return this.serial(key, () => this.statusNow(key));
1058
+ }
1059
+
1060
+ label(agent: string): string {
1061
+ return this.drivers.get(agent)?.label ?? agent;
1062
+ }
1063
+
1064
+ /** 执行一条已解析的命令。chat 不在这里处理。 */
1065
+ async command(key: string, cmd: ParsedCommand, ctx: { allowedAgents: string[]; by: string }): Promise<CommandReply> {
1066
+ const text = (t: string): CommandReply => ({ type: "text", text: t });
1067
+ switch (cmd.kind) {
1068
+ case "help":
1069
+ return text(helpText(ctx.allowedAgents));
1070
+ case "commands": {
1071
+ const conv = getConversation(key)!;
1072
+ const all = await this.commandCatalog(conv.agent, { cwd: conv.cwd, allowedAgents: ctx.allowedAgents });
1073
+ return { type: "commands", agent: conv.agent, commands: all.filter((c) => !c.hidden) };
1074
+ }
1075
+ case "pwd":
1076
+ return text(getConversation(key)!.cwd);
1077
+ case "autoedit":
1078
+ return text(await this.setAutoEdit(key, cmd.on));
1079
+ case "status":
1080
+ return text(await this.status(key));
1081
+ case "cd":
1082
+ return text(await this.cd(key, cmd.dir));
1083
+ case "new":
1084
+ return text(await this.newSession(key, cmd.model));
1085
+ case "fork":
1086
+ return text(await this.fork(key));
1087
+ case "sessions": {
1088
+ const reply = await this.sessions(key, cmd.all);
1089
+ return reply.sessions.length === 0 ? text("还没有任何历史会话。") : reply;
1090
+ }
1091
+ case "resume":
1092
+ return text(await this.resume(key, cmd.sessionId));
1093
+ case "model":
1094
+ return cmd.model ? text(await this.setModel(key, cmd.model)) : await this.models(key);
1095
+ case "effort":
1096
+ if (!cmd.effort) return await this.models(key);
1097
+ return text(await this.setEffort(key, cmd.effort));
1098
+ case "agent": {
1099
+ if (!cmd.agent) {
1100
+ return {
1101
+ type: "agents",
1102
+ agents: ctx.allowedAgents
1103
+ .filter((a) => this.drivers.has(a))
1104
+ .map((id) => ({ id, label: this.label(id) })),
1105
+ current: getConversation(key)!.agent,
1106
+ };
1107
+ }
1108
+ return text(await this.setAgent(key, cmd.agent, ctx.allowedAgents));
1109
+ }
1110
+ case "stop":
1111
+ return text(await this.stop(key));
1112
+ case "approve": {
1113
+ const settled = interactions.decide(cmd.requestId, cmd.decision as ApprovalDecision, ctx.by);
1114
+ if (!settled && interactions.isPending(cmd.requestId)) {
1115
+ return text(`请求 ${cmd.requestId} 不支持 ${cmd.decision}。`);
1116
+ }
1117
+ if (!settled) return text(`请求 ${cmd.requestId} 不存在或已处理。`);
1118
+ return text(`${SETTLEMENT_LABEL[cmd.decision]}:${cmd.requestId}`);
1119
+ }
1120
+ case "chat":
1121
+ return { type: "none" };
1122
+ }
1123
+ }
1124
+
1125
+ /**
1126
+ * 某个 agent 的完整命令表(含隐藏项,带 hidden 标记),已按配置排序。
1127
+ * 原生命令读不到、或者超时,就只给 agent-loft 自己的 —— 列表少几项,总比「/」没反应强。
1128
+ * 未启用的 agent 不去问:问一次就可能拉起它的进程。
1129
+ */
1130
+ async commandCatalog(
1131
+ agent: string,
1132
+ opts: { cwd?: string; allowedAgents: string[] },
1133
+ ): Promise<CommandEntry[]> {
1134
+ const driver = this.enabledAgents().find((d) => d.id === agent);
1135
+ let native: AgentCommand[] = [];
1136
+ if (driver?.commands) {
1137
+ native = await withTimeout(driver.commands(opts.cwd), "读取命令列表").catch((err) => {
1138
+ console.error(`[${agent}] 读取命令列表失败:`, err instanceof Error ? err.message : err);
1139
+ return [];
1140
+ });
1141
+ }
1142
+ const prefs = (config().commands as Record<string, CommandPrefs | undefined>)[agent];
1143
+ return buildCatalog({ allowedAgents: opts.allowedAgents, native, prefs });
1144
+ }
1145
+
1146
+ async shutdown(): Promise<void> {
1147
+ for (const key of [...this.live.keys()]) await this.drop(key, "服务正在重启");
1148
+ for (const driver of this.drivers.values()) await driver.stop().catch(() => {});
1149
+ }
1150
+ }
1151
+
1152
+ export const hub = new Hub();