agent-loft 0.2.17 → 0.2.18

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-loft",
3
- "version": "0.2.17",
3
+ "version": "0.2.18",
4
4
  "type": "module",
5
5
  "description": "多个 agent 的工作台:把本机 Claude Code / Codex 放到一处统一管理,用飞书和 Web 页面随时驱动——会话、审批、模型切换都共用",
6
6
  "keywords": [
@@ -116,6 +116,13 @@ export class ClaudeDriver implements AgentDriver {
116
116
  }
117
117
  }
118
118
 
119
+ async sessionTitle(id: string, cwd: string): Promise<string | undefined> {
120
+ if (!SESSION_ID.test(id)) return undefined;
121
+ // SDK 的 customTitle 已经是「手动改的名 || 自动生成的 aiTitle」,不含首条消息兜底
122
+ const info = await getSessionInfo(id, { dir: cwd });
123
+ return info?.customTitle?.replace(/\s+/g, " ").trim() || undefined;
124
+ }
125
+
119
126
  /**
120
127
  * 把会话记录整理成「一问一答」:用户一条,助手把这一轮所有文本拼成一条,
121
128
  * 中间的工具调用作为过程摘要挂在助手那条上。工具结果(type=user 的 tool_result)
@@ -102,6 +102,11 @@ export class CodexDriver implements AgentDriver {
102
102
  }
103
103
  }
104
104
 
105
+ async sessionTitle(id: string): Promise<string | undefined> {
106
+ const res = await appServer.call<{ thread: Thread }>("thread/read", { threadId: id, includeTurns: false });
107
+ return res.thread.name?.replace(/\s+/g, " ").trim() || undefined;
108
+ }
109
+
105
110
  async readHistory(id: string): Promise<HistoryMessage[]> {
106
111
  const res = await appServer.call<{
107
112
  thread: { turns?: { id?: string; items?: HistoryItem[]; startedAt?: number | null; completedAt?: number | null }[] };
@@ -61,8 +61,8 @@ export function approvalCard(interaction: Interaction, ctx: CardCtx) {
61
61
  const id = interaction.id;
62
62
  const buttons = [
63
63
  button("允许一次", { a: "approval", r: id, d: "allow-once" }, ctx, "primary"),
64
- ...(spec.alwaysLabel ? [button(spec.alwaysLabel, { a: "approval", r: id, d: "allow-always" }, ctx)] : []),
65
64
  ...(spec.allowSession ? [button("本会话全部允许", { a: "approval", r: id, d: "allow-session" }, ctx)] : []),
65
+ ...(spec.alwaysLabel ? [button(spec.alwaysLabel, { a: "approval", r: id, d: "allow-always" }, ctx)] : []),
66
66
  button("拒绝", { a: "approval", r: id, d: "deny" }, ctx, "danger"),
67
67
  ];
68
68
  return card(
@@ -434,7 +434,8 @@ export async function handleApi(req: IncomingMessage, res: ServerResponse, url:
434
434
  if (!sub && method === "PATCH") {
435
435
  const body = await readJsonBody<{ title?: string; hidden?: boolean }>(req);
436
436
  updateConversation(key, {
437
- ...(typeof body.title === "string" ? { title: body.title.trim().slice(0, 80) } : {}),
437
+ // 手动改过名,就不再拿 agent 生成的标题覆盖
438
+ ...(typeof body.title === "string" ? { title: body.title.trim().slice(0, 80), titleAuto: false } : {}),
438
439
  ...(typeof body.hidden === "boolean" ? { hidden: body.hidden } : {}),
439
440
  });
440
441
  hub.emitEvent(key, { type: "meta" });
package/src/core/hub.ts CHANGED
@@ -159,6 +159,8 @@ class Hub extends EventEmitter<{ event: [EventEnvelope] }> {
159
159
  private locks = new Map<string, Promise<unknown>>();
160
160
  /** 最近一次点停止的时间:紧接着冒出来的「被打断」错误是预期内的,不当错误显示 */
161
161
  private stoppedAt = new Map<string, number>();
162
+ /** 正在等 agent 标题的对话,避免一轮轮叠加重试 */
163
+ private titleSyncing = new Set<string>();
162
164
  /**
163
165
  * 每条会话的「生命周期代次」:开新会话、分叉、恢复、切 agent、切目录时加一。
164
166
  * 渠道收到消息后还要先做异步的事(飞书下载附件),期间会话被换掉了的话,
@@ -497,6 +499,7 @@ class Hub extends EventEmitter<{ event: [EventEnvelope] }> {
497
499
  recordFooter(holder.session?.sessionId ?? (conv ? agentBinding(conv, agent).sessionId : undefined), result.text, footer);
498
500
  this.setInflight(key, (this.inflight.get(key) ?? 1) - 1);
499
501
  this.refreshRuntimeOnce(key, liveId);
502
+ this.syncTitle(key, agent);
500
503
  },
501
504
  onError: (err) => {
502
505
  if (!alive()) return;
@@ -555,6 +558,47 @@ class Hub extends EventEmitter<{ event: [EventEnvelope] }> {
555
558
  return session;
556
559
  }
557
560
 
561
+ /**
562
+ * 自动标题换成 agent 自己起的。agent 的标题是异步生成的,一轮结束时未必已经有了,
563
+ * 所以隔一会儿再问几次。每轮结束都会再同步一次:agent 后来改了标题(比如在终端里 /rename)也能跟上。
564
+ */
565
+ private syncTitle(key: string, agent: string, attempt = 0): void {
566
+ const delays = [1_000, 5_000, 15_000, 45_000];
567
+ const driver = this.drivers.get(agent);
568
+ if (!driver?.sessionTitle) return;
569
+ if (attempt === 0) {
570
+ if (this.titleSyncing.has(key)) return; // 上一轮的重试还在跑
571
+ this.titleSyncing.add(key);
572
+ }
573
+ if (attempt >= delays.length) {
574
+ this.titleSyncing.delete(key);
575
+ return;
576
+ }
577
+ setTimeout(() => {
578
+ void (async () => {
579
+ const conv = getConversation(key);
580
+ const sessionId = conv && conv.agent === agent ? agentBinding(conv, agent).sessionId : undefined;
581
+ if (!conv?.titleAuto || !sessionId) return this.titleSyncing.delete(key);
582
+ const title = await driver.sessionTitle!(sessionId, conv.cwd).catch((err) => {
583
+ console.error(`[${agent}] 读取会话标题失败:`, err instanceof Error ? err.message : err);
584
+ return undefined;
585
+ });
586
+ // 等待期间用户可能改了名、切了会话
587
+ const now = getConversation(key);
588
+ if (!now?.titleAuto || now.agent !== agent || agentBinding(now, agent).sessionId !== sessionId) {
589
+ return this.titleSyncing.delete(key);
590
+ }
591
+ if (!title) return this.syncTitle(key, agent, attempt + 1);
592
+ this.titleSyncing.delete(key);
593
+ const clipped = title.slice(0, 80);
594
+ if (clipped !== now.title) {
595
+ updateConversation(key, { title: clipped });
596
+ this.emitEvent(key, { type: "meta" });
597
+ }
598
+ })();
599
+ }, delays[attempt]);
600
+ }
601
+
558
602
  /** 丢掉活着的会话(不动绑定)。挂着的审批一并取消,否则阻塞的那一轮永远走不完。 */
559
603
  async drop(key: string, note: string): Promise<void> {
560
604
  interactions.cancelAll(key, note);
@@ -713,7 +757,8 @@ class Hub extends EventEmitter<{ event: [EventEnvelope] }> {
713
757
  const lastActive = conv.activeAt;
714
758
  touchConversation(key);
715
759
  if (!conv.title && conv.channel === "web" && input.text.trim()) {
716
- updateConversation(key, { title: input.text.trim().replace(/\s+/g, " ").slice(0, 40) });
760
+ // 先用首条消息占位,第一轮结束后换成 agent 自己生成的短标题(syncTitle)
761
+ updateConversation(key, { title: input.text.trim().replace(/\s+/g, " ").slice(0, 40), titleAuto: true });
717
762
  this.emitEvent(key, { type: "meta" });
718
763
  }
719
764
  this.emitEvent(key, shown);
package/src/core/store.ts CHANGED
@@ -28,6 +28,11 @@ export type Conversation = {
28
28
  /** 渠道实例:飞书是机器人 id,Web 固定 web */
29
29
  channelId: string;
30
30
  title?: string;
31
+ /**
32
+ * 标题是自动起的(先用首条消息占位,之后换成 agent 生成的标题),可以继续被 agent 的标题覆盖。
33
+ * 用户手动改过名就清掉,之后不再动它。
34
+ */
35
+ titleAuto?: boolean;
31
36
  agent: string;
32
37
  cwd: string;
33
38
  /** cwd 是否用户显式指定。没指定过的跟随 workspaceRoot 变化 */
package/src/core/types.ts CHANGED
@@ -201,6 +201,8 @@ export interface AgentDriver {
201
201
  /** cwd 为 undefined 时列全部目录 */
202
202
  listSessions(cwd: string | undefined, limit?: number): Promise<SessionSummary[]>;
203
203
  sessionExists(id: string, cwd: string): Promise<boolean>;
204
+ /** agent 自己给会话起的标题(模型总结的或在别处改过的);还没生成就返回 undefined,不拿首条消息凑数 */
205
+ sessionTitle?(id: string, cwd: string): Promise<string | undefined>;
204
206
  readHistory(id: string, cwd: string): Promise<HistoryMessage[]>;
205
207
  /**
206
208
  * 把会话复制出一条新的、返回新会话 id。anchor(HistoryMessage / TurnResult 里的)给了就只保留到
package/web/app.js CHANGED
@@ -1948,12 +1948,12 @@ function renderInteraction(item) {
1948
1948
  "div",
1949
1949
  { class: "ix-buttons" },
1950
1950
  h("button", { class: "btn accent", onclick: () => ixOp(ix.id, "decide", { decision: "allow-once" }) }, "允许一次"),
1951
- spec.alwaysLabel
1952
- ? h("button", { class: "btn", onclick: () => ixOp(ix.id, "decide", { decision: "allow-always" }) }, spec.alwaysLabel)
1953
- : null,
1954
1951
  spec.allowSession
1955
1952
  ? h("button", { class: "btn", onclick: () => ixOp(ix.id, "decide", { decision: "allow-session" }) }, "本会话全部允许")
1956
1953
  : null,
1954
+ spec.alwaysLabel
1955
+ ? h("button", { class: "btn", onclick: () => ixOp(ix.id, "decide", { decision: "allow-always" }) }, spec.alwaysLabel)
1956
+ : null,
1957
1957
  h("button", { class: "btn danger", onclick: () => ixOp(ix.id, "decide", { decision: "deny" }) }, "拒绝"),
1958
1958
  ),
1959
1959
  );