@co0ontty/wand 4.77.0 → 4.78.0-beta.ga1e4f59

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 (54) hide show
  1. package/README.md +21 -46
  2. package/dist/agent-dispatch.js +3 -2
  3. package/dist/ai-team-live.d.ts +25 -0
  4. package/dist/ai-team-live.js +135 -0
  5. package/dist/ai-team-prompts.d.ts +10 -6
  6. package/dist/ai-team-prompts.js +74 -41
  7. package/dist/ai-team-runner.d.ts +85 -3
  8. package/dist/ai-team-runner.js +336 -33
  9. package/dist/ai-team-types.d.ts +57 -2
  10. package/dist/ai-team-types.js +4 -0
  11. package/dist/build-info.json +4 -4
  12. package/dist/commit-task-archive.js +14 -18
  13. package/dist/config.js +14 -6
  14. package/dist/git-quick-commit.js +1 -1
  15. package/dist/middleware/rate-limit.d.ts +18 -4
  16. package/dist/middleware/rate-limit.js +55 -23
  17. package/dist/missions.js +0 -3
  18. package/dist/models.d.ts +6 -1
  19. package/dist/models.js +35 -0
  20. package/dist/path-repair.d.ts +6 -5
  21. package/dist/path-repair.js +3 -3
  22. package/dist/render-protocol.d.ts +1 -1
  23. package/dist/render-protocol.js +1 -1
  24. package/dist/server-ai-team-routes.d.ts +1 -0
  25. package/dist/server-ai-team-routes.js +28 -14
  26. package/dist/server-local-preview-routes.js +1 -1
  27. package/dist/server-session-routes.js +0 -3
  28. package/dist/server-settings-routes.d.ts +1 -1
  29. package/dist/server-settings-routes.js +3 -2
  30. package/dist/server-task-routes.js +16 -32
  31. package/dist/server-workspace-routes.js +31 -24
  32. package/dist/server.js +60 -21
  33. package/dist/storage.d.ts +54 -21
  34. package/dist/storage.js +485 -122
  35. package/dist/types.d.ts +7 -0
  36. package/dist/upload-routes.d.ts +2 -2
  37. package/dist/upload-routes.js +10 -3
  38. package/dist/wand-task-sync.d.ts +2 -34
  39. package/dist/wand-task-sync.js +0 -206
  40. package/dist/web-ui/asset-version.d.ts +5 -0
  41. package/dist/web-ui/asset-version.js +5 -0
  42. package/dist/web-ui/content/ai-teams.js +17 -8
  43. package/dist/web-ui/content/scripts.js +67 -66
  44. package/dist/web-ui/content/styles.css +1 -1
  45. package/dist/web-ui/content/tailwind.css +0 -21
  46. package/dist/web-ui/embedded-assets.js +8 -7
  47. package/dist/web-ui/index.js +9 -17
  48. package/dist/web-ui/scripts.d.ts +3 -1
  49. package/dist/web-ui/scripts.js +52 -25
  50. package/dist/web-ui/styles.d.ts +2 -0
  51. package/dist/web-ui/styles.js +12 -0
  52. package/package.json +13 -12
  53. package/scripts/install-render-binary.js +2 -2
  54. package/scripts/stage-render-binaries.js +1 -1
@@ -6,13 +6,16 @@ import path from "node:path";
6
6
  import { dispatchAgentForTask, resolveTaskDispatchTarget, sendToAgentSession, stopAgentSession, } from "./agent-dispatch.js";
7
7
  import { AI_TEAM_REPORT_DIR, aiTeamChatHistoryPath, aiTeamHandoffPath, aiTeamReportPath, buildLeaderFollowupPrompt, buildLeaderFormatRetryPrompt, buildAiTeamObjective, buildLeaderKickoffPrompt, buildMemberPrompt, leaderOf, parseLeaderDecision, renderChatHistoryFile, renderHandoffFile, } from "./ai-team-prompts.js";
8
8
  import { AI_TEAM_STARTUP_WINDOW_MS, classifyCandidateFailure, classifyStartupFailure, isDegradableWorkFailure, isModelUnknownBeforeDispatch, } from "./ai-team-availability.js";
9
- import { AI_TEAM_ACTIVE_RUN_STATUSES, AI_TEAM_DETAIL_CHAT_TURNS, agentKey, memberAgents, } from "./ai-team-types.js";
9
+ import { AI_TEAM_ACTIVE_RUN_STATUSES, AI_TEAM_DETAIL_CHAT_TURNS, AI_TEAM_TERMINAL_RUN_STATUSES, agentKey, memberAgents, } from "./ai-team-types.js";
10
+ import { renderLiveStepText } from "./ai-team-live.js";
10
11
  import { getErrorMessage } from "./error-utils.js";
11
12
  import { activityState } from "./missions.js";
12
13
  const REPORT_MAX_BYTES = 64 * 1024;
13
14
  /** 报告文件最后一次写入后要稳定这么久才读取,避免读到写了一半的文件。 */
14
15
  const REPORT_SETTLE_MS = 1500;
15
16
  const OUTPUT_DEBOUNCE_MS = 1000;
17
+ /** live 文本推送的独立去抖(§4.9),与 evaluate 的 OUTPUT_DEBOUNCE_MS 互不干扰。 */
18
+ const LIVE_NOTIFY_DEBOUNCE_MS = 500;
16
19
  const SWEEP_INTERVAL_MS = 5000;
17
20
  const MAX_FORMAT_RETRIES = 2;
18
21
  /** 同一个候选累计这么多次 startup-timeout 就按五元组拉黑(§3.4 黑名单 agents 层)。 */
@@ -22,6 +25,9 @@ export const AI_TEAM_CHAT_PREFIX = "ai-team-chat:";
22
25
  /** 等批准时,群里回复这些词就是批准,其余内容都当修改意见。 */
23
26
  const APPROVE_REPLY = /^(批准|同意|可以|开始|开干|好的?|ok|okay|yes|lgtm|approve)[。.!!]*$/i;
24
27
  const STOP_REPLY = /^(停止|停下|stop)[。.!!]*$/i;
28
+ /** 步数用完后,纯「继续」保留尚未执行的计划;带新要求的回复交回负责人安排。 */
29
+ const CONTINUE_REPLY = /^(继续|让团队继续|继续执行|接着做|接着干|continue)[。.!!]*$/i;
30
+ const STEP_LIMIT_DETAIL = "已达到步数上限";
25
31
  /** 用户可见的冲突错误(路由转 409)。 */
26
32
  export class AiTeamConflictError extends Error {
27
33
  }
@@ -71,22 +77,42 @@ export function createAiTeamRunner(deps) {
71
77
  ops: createAiTeamSessionOps(deps),
72
78
  chat: createAiTeamChatOps(deps),
73
79
  notify: deps.notify,
80
+ notifyLive: deps.notifyLive,
74
81
  models: deps.models,
82
+ defaultModelOf: deps.defaultModelOf,
75
83
  });
76
84
  deps.structured?.registerRelay(AI_TEAM_CHAT_PREFIX, (sessionId, text) => runner.chatInput(sessionId, text));
77
85
  return runner;
78
86
  }
79
87
  /**
80
88
  * 群聊里的发言人。上传的头像是 data URL,每条消息都带一份太重,改用按 id 取的像素猫。
81
- * `provider` 传该步**实际使用候选**的 provider(§3.6/B15);Leader 或没有步骤的场景回退首选。
89
+ * `agent` 传该步**实际使用候选**(§3.6/B15,降级换候选后跟着变),model / thinkingEffort 与
90
+ * provider 同源;Leader 或没有步骤的场景回退首选候选。只填真值,展示文案归客户端。
82
91
  */
83
- function chatAuthor(member, sessionId, provider) {
92
+ export function displayAiTeam(snapshot, current) {
93
+ if (!current)
94
+ return snapshot;
95
+ const byId = new Map(current.members.map((member) => [member.id, member]));
96
+ return {
97
+ ...snapshot,
98
+ name: current.name,
99
+ updatedAt: current.updatedAt,
100
+ members: snapshot.members.map((member) => {
101
+ const live = byId.get(member.id);
102
+ return live ? { ...member, name: live.name, avatar: live.avatar } : member;
103
+ }),
104
+ };
105
+ }
106
+ function chatAuthor(member, sessionId, agent) {
107
+ const used = agent ?? memberAgents(member)[0] ?? member.agent;
84
108
  return {
85
109
  id: member.id,
86
110
  name: member.name,
87
111
  avatar: member.avatar && !member.avatar.startsWith("data:") ? member.avatar : undefined,
88
112
  leader: member.isLeader || undefined,
89
- provider: provider ?? memberAgents(member)[0]?.provider ?? member.agent.provider,
113
+ provider: used?.provider ?? member.agent.provider,
114
+ model: used?.model?.trim() || undefined,
115
+ thinkingEffort: used?.thinkingEffort || undefined,
90
116
  sessionId: sessionId ?? undefined,
91
117
  };
92
118
  }
@@ -150,6 +176,73 @@ function turnText(turn) {
150
176
  .filter(Boolean)
151
177
  .join("\n\n");
152
178
  }
179
+ // ── 群聊开场与协作流转的文案(设计 v2.1 S1–S5)──
180
+ // 全部由服务端纯函数拼好:客户端只做 @ 高亮,不拼句子、不猜字段;也**不引任何 LLM 文本**。
181
+ /** 每行邀请名单上限(S2/S3):`邀请 @a、@b、@c、@d 加入群聊`。 */
182
+ export const CHAT_INVITE_PER_LINE = 4;
183
+ /** 开工发言与派工行共用的步骤说法:`第 2 步「实现 Web 端」`;标题空白时只剩步数。 */
184
+ function chatStepLabel(seq, title) {
185
+ const label = title.trim();
186
+ return label ? `第 ${seq} 步「${label}」` : `第 ${seq} 步`;
187
+ }
188
+ /**
189
+ * 首次建群的入群序列(S1–S3):负责人先入群,再按 `team.members` 原顺序邀请其余成员。
190
+ * 每行最多 [CHAT_INVITE_PER_LINE] 人,第二行起用 `继续邀请 …`;除负责人外没人时给
191
+ * `还没有邀请其他成员入群`(仍两条,不伪造成员)。
192
+ */
193
+ export function chatIntroLines(team) {
194
+ const teamName = team.name.trim();
195
+ const lines = [teamName ? `创建了团队群聊「${teamName}」` : "创建了团队群聊"];
196
+ const invitees = team.members.filter((member) => !member.isLeader)
197
+ .map((member) => member.name.trim())
198
+ .filter(Boolean);
199
+ if (invitees.length === 0) {
200
+ lines.push("还没有邀请其他成员入群");
201
+ return lines;
202
+ }
203
+ for (let index = 0; index < invitees.length; index += CHAT_INVITE_PER_LINE) {
204
+ const names = invitees.slice(index, index + CHAT_INVITE_PER_LINE).map((name) => `@${name}`).join("、");
205
+ lines.push(`${index === 0 ? "邀请" : "继续邀请"} ${names} 加入群聊`);
206
+ }
207
+ return lines;
208
+ }
209
+ /**
210
+ * 开工发言正文(S4):
211
+ * - 第 1 行 `我正在开始工作:第 N 步「标题」`;
212
+ * - 有依赖时第 2 行 `依据 @上游成员 第 M 步「上游标题」的产物 <reportPath>`,超过两条
213
+ * 只列前两条并在尾上 `等 N 步`(N = 依赖总数);
214
+ * - 依赖解析不到(旧数据/步骤被删)→ `依据上游步骤的产物继续`。
215
+ */
216
+ export function stepStartText(step, upstream = [], dependencyCount = upstream.length) {
217
+ const lines = [`我正在开始工作:${chatStepLabel(step.seq, step.title)}`];
218
+ if (dependencyCount > 0)
219
+ lines.push(stepBasisLine(upstream, dependencyCount));
220
+ return lines.join("\n");
221
+ }
222
+ function stepBasisLine(upstream, dependencyCount) {
223
+ const shown = upstream.slice(0, 2);
224
+ if (shown.length === 0)
225
+ return "依据上游步骤的产物继续";
226
+ const parts = shown.map((item) => {
227
+ const reportPath = item.reportPath.trim();
228
+ return `@${item.memberName} ${chatStepLabel(item.seq, item.title)}的产物${reportPath ? ` ${reportPath}` : ""}`;
229
+ });
230
+ return `依据 ${parts.join("、")}${dependencyCount > shown.length ? ` 等 ${dependencyCount} 步` : ""}`;
231
+ }
232
+ /**
233
+ * 派工行的依据括注(S5):`(依据:第 1 步「设计规格」的产物)`,多个依赖用 `、` 连接。
234
+ * 位置 `at` 是**本批 steps 内**的 0-based 下标(展示成第 at+1 步);没有依赖就是空串(首步无依据)。
235
+ * 它**取代**旧的 `(等第 N 项完成后)`:同一个依赖关系不写两遍。
236
+ */
237
+ export function assignmentBasisNote(after, titles) {
238
+ if (after.length === 0)
239
+ return "";
240
+ const parts = after.map((at) => {
241
+ const label = (titles[at] ?? "").trim();
242
+ return `第 ${at + 1} 步${label ? `「${label}」` : ""}的产物`;
243
+ });
244
+ return `(依据:${parts.join("、")})`;
245
+ }
153
246
  /** 找到提示词(含 reportPath)那一轮之后的 assistant 文本;没有回复返回 null。 */
154
247
  function assistantReplyAfterPrompt(snapshot, reportPath) {
155
248
  const messages = snapshot.messages ?? [];
@@ -168,6 +261,28 @@ function assistantReplyAfterPrompt(snapshot, reportPath) {
168
261
  return null;
169
262
  return replies.map(turnText).filter(Boolean).join("\n\n");
170
263
  }
264
+ /** 只看本步提示词之后的真实输出;structured 的失败提示是系统生成的,不是模型回答。 */
265
+ function hasStepAssistantReply(snapshot, reportPath, errorMessage) {
266
+ const messages = snapshot.messages ?? [];
267
+ let promptIndex = -1;
268
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
269
+ const turn = messages[index];
270
+ if (turn.role === "user" && turnText(turn).includes(reportPath)) {
271
+ promptIndex = index;
272
+ break;
273
+ }
274
+ }
275
+ // 无法确认本步起点时,不把历史会话误判成「启动失败」后再次派工。
276
+ if (promptIndex < 0)
277
+ return true;
278
+ const failureText = errorMessage ? `结构化会话执行失败:${errorMessage}` : null;
279
+ return messages.slice(promptIndex + 1).some((turn) => {
280
+ if (turn.role !== "assistant")
281
+ return false;
282
+ return !failureText || turn.content.length !== 1
283
+ || turn.content[0]?.type !== "text" || turn.content[0].text !== failureText;
284
+ });
285
+ }
171
286
  function readReport(file) {
172
287
  const buffer = readFileSync(file);
173
288
  if (buffer.length <= REPORT_MAX_BYTES)
@@ -203,10 +318,16 @@ export class AiTeamRunner {
203
318
  chat;
204
319
  resolveCwd;
205
320
  notifyListener;
321
+ notifyLiveListener;
206
322
  modelsOf;
323
+ defaultModelOf;
207
324
  now;
208
325
  chains = new Map();
209
326
  outputTimers = new Map();
327
+ /** live 推送的去抖计时器,按 runId 计(§4.9)。 */
328
+ liveTimers = new Map();
329
+ /** 上次推出去的 live 列表指纹(§4.9):stepId + text + omittedChars + state 全同就不重复推。 */
330
+ lastLiveKey = new Map();
210
331
  recheckTimers = new Map();
211
332
  /** 已经确定「不降级、按既有路径记 failed」的步骤,防止 finishStep 再判一次形成回环。 */
212
333
  noDegrade = new Set();
@@ -219,9 +340,13 @@ export class AiTeamRunner {
219
340
  this.chat = options.chat;
220
341
  this.resolveCwd = options.resolveCwd;
221
342
  this.notifyListener = options.notify;
343
+ this.notifyLiveListener = options.notifyLive;
222
344
  this.modelsOf = options.models;
345
+ this.defaultModelOf = options.defaultModelOf;
223
346
  this.now = options.now ?? Date.now;
224
347
  }
348
+ /** 提示词里的模型名解析器:`default` 哨兵交给服务端配置的默认模型。 */
349
+ defaultModelName = (provider) => this.defaultModelOf?.(provider)?.trim() ?? "";
225
350
  /** 本 runner 的遮蔽参照目录(§4.5)。 */
226
351
  get redactionDirs() {
227
352
  if (!this.redactionDirsValue) {
@@ -288,7 +413,7 @@ export class AiTeamRunner {
288
413
  excludeReportDir(cwd);
289
414
  await this.enqueue(run.id, async () => {
290
415
  const step = this.createLeaderStep(run, "制定计划");
291
- await this.dispatchStep(run, step, (reportPath, fresh) => (buildLeaderKickoffPrompt(run, reportPath, fresh, chatHistory)));
416
+ await this.dispatchStep(run, step, (reportPath, fresh) => (buildLeaderKickoffPrompt(run, reportPath, fresh, chatHistory, this.defaultModelName)));
292
417
  });
293
418
  return this.detail(run.id);
294
419
  }
@@ -327,6 +452,16 @@ export class AiTeamRunner {
327
452
  if (options.echo !== false)
328
453
  this.postUser(run, note);
329
454
  run.formatRetries = 0;
455
+ if (run.statusDetail === STEP_LIMIT_DETAIL) {
456
+ // 群聊的「继续」也要真正增加这次运行的预算,且不能作废尚未派出的计划。
457
+ this.extendStepLimit(run, 10);
458
+ this.setStatus(run, "running", "");
459
+ if (CONTINUE_REPLY.test(note))
460
+ await this.advance(run);
461
+ else
462
+ await this.startLeaderRound(run, note);
463
+ return;
464
+ }
330
465
  this.refreshTeam(run);
331
466
  this.setStatus(run, "running", "");
332
467
  await this.startLeaderRound(run, note);
@@ -335,14 +470,22 @@ export class AiTeamRunner {
335
470
  continueRun(runId, extraSteps) {
336
471
  return this.act(runId, async (run) => {
337
472
  this.requireStatus(run, "waiting_user", "团队当前没有在等你。");
338
- const extra = Math.min(50, Math.max(5, Math.round(extraSteps) || 10));
339
- this.refreshTeam(run);
340
- run.stepLimit = Math.max(run.stepLimit, run.stepsUsed) + extra;
341
- this.postNotice(run, `步数上限加到 ${run.stepLimit},继续执行`);
473
+ if (run.statusDetail !== STEP_LIMIT_DETAIL) {
474
+ throw new AiTeamConflictError("团队正在等你回复,当前不是步数上限暂停。");
475
+ }
476
+ this.extendStepLimit(run, extraSteps);
342
477
  this.setStatus(run, "running", "");
343
478
  await this.advance(run);
344
479
  });
345
480
  }
481
+ /** 已有运行的上限独立持久化;修改团队定义后,下次续跑至少采用新的团队上限。 */
482
+ extendStepLimit(run, extraSteps) {
483
+ const extra = Math.min(50, Math.max(5, Math.round(extraSteps) || 10));
484
+ this.refreshTeam(run);
485
+ run.stepLimit = Math.max(run.stepLimit, run.stepsUsed) + extra;
486
+ run.stepLimit = Math.max(run.stepLimit, run.team.maxSteps);
487
+ this.postNotice(run, `步数上限加到 ${run.stepLimit},继续执行`);
488
+ }
346
489
  completeStep(runId, stepId, report) {
347
490
  return this.act(runId, async (run) => {
348
491
  const step = this.storage.listAiTeamSteps(run.id).find((item) => item.id === stepId);
@@ -372,6 +515,8 @@ export class AiTeamRunner {
372
515
  }
373
516
  this.postNotice(run, "团队已停止。在群里发消息可以让团队接着处理。");
374
517
  this.setStatus(run, "stopped", "已由用户停止");
518
+ // 步骤全变 skipped:推一次空的 live 列表让卡片收尾(§4.9)。
519
+ this.pushLive(run.id);
375
520
  });
376
521
  }
377
522
  /**
@@ -429,16 +574,106 @@ export class AiTeamRunner {
429
574
  }
430
575
  // 群聊回合只给最近这一段(§4.4);没有群聊会话的旧运行给空数组,前端不用判 null。
431
576
  const messages = run.chatSessionId ? this.ops.snapshot(run.chatSessionId)?.messages ?? [] : [];
577
+ const currentTeam = this.storage.getAiTeam(run.teamId);
432
578
  return {
433
579
  run,
434
580
  steps,
435
581
  memberStates,
582
+ // 别把展示字段写回 run.team:运行快照里的执行候选/职责由 runner 独立管理。
583
+ displayTeam: displayAiTeam(run.team, currentTeam),
436
584
  chatTurns: messages.slice(-AI_TEAM_DETAIL_CHAT_TURNS),
437
585
  };
438
586
  }
439
587
  listForTask(taskId) {
440
588
  return this.storage.listAiTeamRuns({ taskId });
441
589
  }
590
+ /**
591
+ * 此刻正在干活的步骤的实时文本(§4.9)。只覆盖 running 且有会话的步骤,按 seq 升序;
592
+ * 快照拿不到的步骤跳过而不抛。run 不存在时与 detail() 同口径抛错。
593
+ */
594
+ live(runId) {
595
+ const run = this.storage.getAiTeamRun(runId);
596
+ if (!run)
597
+ throw new Error("团队运行不存在。");
598
+ return this.liveSteps(run);
599
+ }
600
+ liveSteps(run) {
601
+ const steps = [];
602
+ for (const step of this.storage.listAiTeamSteps(run.id)) {
603
+ if (step.status !== "running" || !step.sessionId)
604
+ continue;
605
+ const snapshot = this.ops.snapshot(step.sessionId);
606
+ if (!snapshot)
607
+ continue;
608
+ steps.push(this.liveStep(run, step, snapshot));
609
+ }
610
+ return steps;
611
+ }
612
+ liveStep(run, step, snapshot) {
613
+ const member = this.member(run.team, step.memberId);
614
+ const agent = member ? this.actualAgent(member, step) : null;
615
+ const provider = agent?.provider ?? snapshot.provider ?? leaderOf(run.team).agent.provider;
616
+ const rendered = renderLiveStepText(snapshot.messages ?? [], snapshot.output ?? "", {
617
+ // pty bridge 只给「claude 且 CLI 已激活」的 PTY 会话挂载(`process-manager.ts` 的
618
+ // `initializeClaudeBridge` 首行 `record.provider !== "claude" || !record.providerCliActive`
619
+ // 就早退),其余 PTY 在流式期 `messages` 不增长,里面留着的是上一条 turn 的过期文本
620
+ // → 一律以终端 output 尾部为准。structured 有流式 messages,照旧。
621
+ preferOutput: this.ops.ownerOf(step.sessionId) === "pty"
622
+ && !(provider === "claude" && snapshot.providerCliActive === true),
623
+ });
624
+ return {
625
+ stepId: step.id,
626
+ seq: step.seq,
627
+ memberId: step.memberId,
628
+ memberName: member?.name ?? step.memberId,
629
+ provider,
630
+ model: agent?.model?.trim() || undefined,
631
+ thinkingEffort: agent?.thinkingEffort || undefined,
632
+ sessionId: step.sessionId,
633
+ state: activityState(snapshot),
634
+ text: rendered.text,
635
+ omittedChars: rendered.omittedChars,
636
+ updatedAt: this.iso(),
637
+ };
638
+ }
639
+ /** 挂一次 run 级 live 去抖:同一 run 每 500ms 至多算一遍、推一次,与 evaluate 的计时器互不干扰。 */
640
+ scheduleLivePush(runId) {
641
+ if (!this.notifyLiveListener || this.liveTimers.has(runId))
642
+ return;
643
+ const timer = setTimeout(() => {
644
+ this.liveTimers.delete(runId);
645
+ void this.enqueue(runId, async () => { this.pushLive(runId); });
646
+ }, LIVE_NOTIFY_DEBOUNCE_MS);
647
+ timer.unref?.();
648
+ this.liveTimers.set(runId, timer);
649
+ }
650
+ /** 推一次 live 列表(§4.9);指纹(stepId + text + omittedChars + state)没变就不重复推,推送失败只忽略。 */
651
+ pushLive(runId) {
652
+ if (!this.notifyLiveListener)
653
+ return;
654
+ const run = this.storage.getAiTeamRun(runId);
655
+ if (!run)
656
+ return;
657
+ const steps = this.liveSteps(run);
658
+ const key = steps
659
+ .map((step) => `${step.stepId}\u0000${step.text}\u0000${step.omittedChars}\u0000${step.state}`)
660
+ .join("\u0001");
661
+ if (this.lastLiveKey.get(runId) === key)
662
+ return;
663
+ if (AI_TEAM_TERMINAL_RUN_STATUSES.includes(run.status)) {
664
+ // 终态的收尾推送之后不再留指纹:留着就没人清,长跑服务会按 run 数攒字符串(§4.9)。
665
+ this.lastLiveKey.delete(runId);
666
+ }
667
+ else {
668
+ this.lastLiveKey.set(runId, key);
669
+ }
670
+ try {
671
+ this.notifyLiveListener({ runId, taskId: run.taskId, steps });
672
+ }
673
+ catch {
674
+ // 通知失败不影响调度;客户端靠下一次事件或轮询端点补齐。
675
+ }
676
+ }
442
677
  /** 团队页的运行记录;teamId 为空时列出所有团队(侧边栏计数用)。 */
443
678
  listRuns(filter) {
444
679
  const runs = this.storage.listAiTeamRuns({
@@ -461,6 +696,9 @@ export class AiTeamRunner {
461
696
  const step = this.storage.getRunningAiTeamStepBySession(event.sessionId);
462
697
  if (!step)
463
698
  return;
699
+ // 四类事件都挂上 live 去抖:权限/提问这类状态变化是靠 status、task 事件进来的,
700
+ // 只挂在 output 上,芯片就只能等下一次 ai-team-run 重拉才更新(§4.9)。
701
+ this.scheduleLivePush(step.runId);
464
702
  if (event.type === "output") {
465
703
  if (this.outputTimers.has(event.sessionId))
466
704
  return;
@@ -495,13 +733,21 @@ export class AiTeamRunner {
495
733
  }
496
734
  this.startSweep();
497
735
  }
498
- /** 兜底巡检:PTY 成员写完报告后可能不再产生事件。 */
736
+ /**
737
+ * 兜底巡检:PTY 成员写完报告后可能不再产生事件。
738
+ * 顺带对每个 run 强制对账一次 live(§4.9):registry 内部状态静默翻转时一条事件都不会进来,
739
+ * 状态芯片只能等客户端下一次重拉;这里在同一串里重算列表走 pushLive,指纹变了才推,
740
+ * 没变就什么都不发。巡检间隔与 evaluate 行为都不动。
741
+ */
499
742
  startSweep() {
500
743
  if (this.sweepTimer)
501
744
  return;
502
745
  this.sweepTimer = setInterval(() => {
503
746
  for (const run of this.storage.listAiTeamRuns({ statuses: ["running"] })) {
504
- void this.enqueue(run.id, () => this.evaluate(run.id));
747
+ void this.enqueue(run.id, async () => {
748
+ this.pushLive(run.id);
749
+ await this.evaluate(run.id);
750
+ });
505
751
  }
506
752
  }, SWEEP_INTERVAL_MS);
507
753
  this.sweepTimer.unref?.();
@@ -514,8 +760,12 @@ export class AiTeamRunner {
514
760
  clearTimeout(timer);
515
761
  for (const timer of this.recheckTimers.values())
516
762
  clearTimeout(timer);
763
+ for (const timer of this.liveTimers.values())
764
+ clearTimeout(timer);
517
765
  this.outputTimers.clear();
518
766
  this.recheckTimers.clear();
767
+ this.liveTimers.clear();
768
+ this.lastLiveKey.clear();
519
769
  this.noDegrade.clear();
520
770
  }
521
771
  /** 等当前排队的所有操作跑完(测试用)。 */
@@ -628,8 +878,13 @@ export class AiTeamRunner {
628
878
  }
629
879
  return { kind: "pending" };
630
880
  }
631
- /** proceed=false 时只记结果不推进,由调用方在记完一批之后统一 advance。 */
881
+ /** 步骤落地后补推一次 live(§4.9):让卡片收尾,列表可能已空。内部抛错时不推。 */
632
882
  async finishStep(run, step, outcome, proceed = true) {
883
+ await this.finishStepCore(run, step, outcome, proceed);
884
+ this.pushLive(run.id);
885
+ }
886
+ /** proceed=false 时只记结果不推进,由调用方在记完一批之后统一 advance。 */
887
+ async finishStepCore(run, step, outcome, proceed = true) {
633
888
  const endedAt = this.iso();
634
889
  // §3.4 触发点 2(异步主通道):必须在 stepsUsed 递增之前判,否则首个失败候选白扣一步预算。
635
890
  if (step.kind === "work" && outcome.kind === "failed" && outcome.sessionError
@@ -648,7 +903,7 @@ export class AiTeamRunner {
648
903
  if (member) {
649
904
  this.postTurn(run, {
650
905
  role: "assistant",
651
- author: chatAuthor(member, step.sessionId, this.actualAgent(member, step)?.provider),
906
+ author: chatAuthor(member, step.sessionId, this.actualAgent(member, step)),
652
907
  content: chatText(`${outcome.kind === "done" ? "✅ 完成" : "❌ 没完成"}「${step.title}」\n\n${report}`),
653
908
  });
654
909
  }
@@ -662,7 +917,7 @@ export class AiTeamRunner {
662
917
  if (outcome.kind === "failed" && outcome.sessionError) {
663
918
  this.saveStep({ ...step, status: "failed", report: outcome.text, endedAt });
664
919
  this.postTurn(run, {
665
- role: "assistant", notice: true, author: chatAuthor(leader, step.sessionId),
920
+ role: "assistant", notice: true, author: chatAuthor(leader, step.sessionId, this.actualAgent(leader, step)),
666
921
  content: chatText(`负责人的模型出错:${outcome.text}\n可以去团队页换个模型,然后在群里回复一句让团队继续。`),
667
922
  });
668
923
  this.setStatus(run, "waiting_user", `负责人出错:${outcome.text}`);
@@ -683,7 +938,7 @@ export class AiTeamRunner {
683
938
  this.postNotice(run, `负责人的回复没能读懂,让他重答(${run.formatRetries}/${MAX_FORMAT_RETRIES})`, step.sessionId);
684
939
  this.saveRun(run);
685
940
  const retry = this.createLeaderStep(run, "重新回复");
686
- await this.dispatchStep(run, retry, (reportPath, fresh) => (buildLeaderFormatRetryPrompt(run, parsed.error, reportPath, fresh, this.chatHistoryFor(run))));
941
+ await this.dispatchStep(run, retry, (reportPath, fresh) => (buildLeaderFormatRetryPrompt(run, parsed.error, reportPath, fresh, this.chatHistoryFor(run), this.defaultModelName)));
687
942
  return;
688
943
  }
689
944
  run.formatRetries = 0;
@@ -796,7 +1051,7 @@ export class AiTeamRunner {
796
1051
  .map((step) => ({ step, memberName: this.member(run.team, step.memberId)?.name ?? step.memberId }));
797
1052
  const step = this.createLeaderStep(run, userNote ? "回应用户" : "安排下一步");
798
1053
  const handoffPath = this.writeHandoff(run, step.seq, "leader", finished.map(({ step: item, memberName }) => this.handoffEntry(run, item, memberName)));
799
- await this.dispatchStep(run, step, (reportPath, fresh) => (buildLeaderFollowupPrompt(run, finished, reportPath, userNote, fresh, handoffPath, this.chatHistoryFor(run))));
1054
+ await this.dispatchStep(run, step, (reportPath, fresh) => (buildLeaderFollowupPrompt(run, finished, reportPath, userNote, fresh, handoffPath, this.chatHistoryFor(run), this.defaultModelName)));
800
1055
  }
801
1056
  /** 一条交接条目:元信息 + 报告全文(全文只进交接文件,不进提示词)。 */
802
1057
  handoffEntry(run, step, memberName) {
@@ -813,6 +1068,35 @@ export class AiTeamRunner {
813
1068
  * 成员步骤的上游交接:把依赖步骤的报告写成一个交接文件,提示词只给路径。
814
1069
  * 没有依赖、或写文件失败时返回 null(提示词仍逐行列出上游报告文件)。
815
1070
  */
1071
+ /**
1072
+ * 开工发言(S4):成员**自己发的真实发言**(头像 + 名字 + 气泡),不是居中 notice;
1073
+ * 文案由 [stepStartText] 拼(依据只来自 `step.dependsOn`,不引 `decision.message` / 报告正文 / 提示词文本)。
1074
+ */
1075
+ postStepStart(run, step, member, agent, reusable) {
1076
+ this.postTurn(run, {
1077
+ role: "assistant",
1078
+ author: chatAuthor(member, reusable, agent),
1079
+ content: chatText(stepStartText(step, this.upstreamBasis(run, step), step.dependsOn.length)),
1080
+ });
1081
+ }
1082
+ /**
1083
+ * 开工发言的依据:只看 `step.dependsOn`,按依赖顺序解析出上游步骤(没有依赖返回空数组)。
1084
+ * 不调 `memberUpstream`:那个会顺手写交接文件,而这里只要读数据(设计 v2.1 强调不引 LLM 文本)。
1085
+ */
1086
+ upstreamBasis(run, step) {
1087
+ if (step.dependsOn.length === 0)
1088
+ return [];
1089
+ const byId = new Map(this.storage.listAiTeamSteps(run.id).map((item) => [item.id, item]));
1090
+ return step.dependsOn
1091
+ .map((id) => byId.get(id))
1092
+ .filter((item) => Boolean(item))
1093
+ .map((item) => ({
1094
+ seq: item.seq,
1095
+ title: item.title,
1096
+ memberName: this.member(run.team, item.memberId)?.name ?? item.memberId,
1097
+ reportPath: item.reportPath,
1098
+ }));
1099
+ }
816
1100
  memberUpstream(run, step) {
817
1101
  if (step.dependsOn.length === 0)
818
1102
  return null;
@@ -896,7 +1180,7 @@ export class AiTeamRunner {
896
1180
  if (run.stepsUsed < run.stepLimit)
897
1181
  return true;
898
1182
  this.postNotice(run, `已用完 ${run.stepLimit} 步的上限,团队先停下。回复一句让团队继续,或在任务面板里加步数。`);
899
- this.setStatus(run, "waiting_user", "已达到步数上限");
1183
+ this.setStatus(run, "waiting_user", STEP_LIMIT_DETAIL);
900
1184
  return false;
901
1185
  }
902
1186
  createLeaderStep(run, title) {
@@ -952,9 +1236,11 @@ export class AiTeamRunner {
952
1236
  let running = { ...step, instructions: step.kind === "leader" ? prompt.message : step.instructions, status: "running", startedAt, sessionId: reusable };
953
1237
  this.saveStep(running);
954
1238
  this.notify(run);
955
- if (member && step.kind === "work") {
956
- this.postNotice(run, `${member.name} 开始「${step.title}」`, reusable, member, agent?.provider);
957
- }
1239
+ // 开工发言(S4):就发在这个位置(原来那条居中 notice 的地方),所以候选起不来的那次尝试
1240
+ // 也会留一句「我正在开始工作」+ 随后的降级/失败出口;作者署名与报告同一套口径,
1241
+ // 复用会话时带真实 sessionId,新建在 open 前未知;若 open 失败,失败报告也可能无 ID。
1242
+ if (member && step.kind === "work")
1243
+ this.postStepStart(run, running, member, agent, reusable);
958
1244
  try {
959
1245
  if (!member)
960
1246
  throw new Error("成员不存在");
@@ -1078,7 +1364,7 @@ export class AiTeamRunner {
1078
1364
  this.saveStep(replacement);
1079
1365
  // 群聊降级行(§5.3/B15):一次降级一行,署名用切过去的新候选;事前 model-unknown 跳过
1080
1366
  // 同样走 degradeWorkStep,所以这里也覆盖它。reason 已经过脱敏 + 头尾截断(§4.5)。
1081
- this.postNotice(run, `⚠️ ${member.name} 的首选配置不可用(${reason}),已切换到候选 ${next + 1}`, null, member, candidates[next]?.provider);
1367
+ this.postNotice(run, `⚠️ ${member.name} 的首选配置不可用(${reason}),已切换到候选 ${next + 1}`, null, member, candidates[next]);
1082
1368
  // ④ 依赖重指向(R1):排队步骤等的仍是「这件事做完」,只是换了承载候选的步骤。
1083
1369
  // running / done 的历史步骤不改写。
1084
1370
  for (const queued of this.storage.listAiTeamSteps(run.id)) {
@@ -1116,7 +1402,9 @@ export class AiTeamRunner {
1116
1402
  const errorMessage = snapshot?.structuredState?.lastError ?? outcomeText;
1117
1403
  const kind = classifyStartupFailure({
1118
1404
  status: snapshot?.status ?? "failed",
1119
- hasAssistantReply: Boolean(snapshot?.messages?.some((turn) => turn.role === "assistant")),
1405
+ hasAssistantReply: snapshot
1406
+ ? hasStepAssistantReply(snapshot, step.reportPath, errorMessage)
1407
+ : true,
1120
1408
  elapsedMs,
1121
1409
  errorMessage,
1122
1410
  });
@@ -1250,8 +1538,11 @@ export class AiTeamRunner {
1250
1538
  openChat(run, task, objective, chatHistory) {
1251
1539
  if (!this.chat)
1252
1540
  return;
1541
+ // 「首次建群」只能在这一拍判定:`open()` 之后 run.chatSessionId 就有值了。
1542
+ // 入群序列(S1–S3)只跟这个分支走,续跑/重启接回/同一个群里开新 run 都不重播(v2.1 规则 1)。
1543
+ const firstChat = !run.chatSessionId;
1253
1544
  try {
1254
- if (!run.chatSessionId) {
1545
+ if (firstChat) {
1255
1546
  run.chatSessionId = this.chat.open({ task, run, title: `${run.team.name} · ${task.title}` });
1256
1547
  }
1257
1548
  }
@@ -1259,13 +1550,21 @@ export class AiTeamRunner {
1259
1550
  console.error(`[AiTeam] open chat for ${run.id} failed:`, getErrorMessage(error));
1260
1551
  return;
1261
1552
  }
1262
- const roster = run.team.members.map((member) => member.isLeader ? `${member.name}(负责人)` : member.name).join("、");
1263
1553
  if (objective)
1264
1554
  this.postUser(run, objective);
1265
- // 续跑时不说「接手」:用户看得出这是同一轮工作往下走,并把交给团队的历史文件摊开给他核。
1266
- this.postNotice(run, chatHistory
1267
- ? `接着这个群聊里上一轮的进度继续;交给负责人和成员的记录见 ${chatHistory}`
1268
- : `团队「${run.team.name}」接手了这个任务:${roster}`);
1555
+ if (!firstChat) {
1556
+ // 续跑时不说「接手」:用户看得出这是同一轮工作往下走,并把交给团队的历史文件摊开给他核。
1557
+ if (chatHistory) {
1558
+ this.postNotice(run, `接着这个群聊里上一轮的进度继续;交给负责人和成员的记录见 ${chatHistory}`);
1559
+ }
1560
+ return;
1561
+ }
1562
+ // 首次建群:负责人先入群,再按名单邀请其余成员(2–3 条系统行,作者都是负责人)。
1563
+ const leader = leaderOf(run.team);
1564
+ const leaderAgent = leader ? memberAgents(leader)[0] ?? leader.agent : undefined;
1565
+ for (const line of chatIntroLines(run.team)) {
1566
+ this.postNotice(run, line, run.chatSessionId, leader, leaderAgent);
1567
+ }
1269
1568
  }
1270
1569
  postTurn(run, turn) {
1271
1570
  if (!this.chat || !run.chatSessionId)
@@ -1280,10 +1579,10 @@ export class AiTeamRunner {
1280
1579
  postUser(run, text) {
1281
1580
  this.postTurn(run, { role: "user", content: chatText(text) });
1282
1581
  }
1283
- postNotice(run, text, sessionId, member, provider) {
1582
+ postNotice(run, text, sessionId, member, agent) {
1284
1583
  this.postTurn(run, {
1285
1584
  role: "assistant", notice: true, content: chatText(text),
1286
- author: member ? chatAuthor(member, sessionId, provider) : undefined,
1585
+ author: member ? chatAuthor(member, sessionId, agent) : undefined,
1287
1586
  });
1288
1587
  }
1289
1588
  /** 负责人的决定:说明 + 派工清单;等批准 / 等回复时告诉用户在群里怎么接话。 */
@@ -1292,14 +1591,15 @@ export class AiTeamRunner {
1292
1591
  if (decision.action === "assign") {
1293
1592
  const assigned = decision.steps.map((item, index) => {
1294
1593
  const name = this.member(run.team, item.memberId)?.name ?? item.memberId;
1295
- const after = item.after.length > 0 ? `(等第 ${item.after.map((at) => at + 1).join("、")} 项完成后)` : "";
1296
- return `${index + 1}. **@${name}** ${item.title}${after}`;
1594
+ const basis = assignmentBasisNote(item.after, decision.steps.map((entry) => entry.title));
1595
+ return `${index + 1}. **@${name}** ${item.title}${basis}`;
1297
1596
  });
1298
1597
  lines.push(assigned.join("\n"));
1299
1598
  }
1599
+ const leader = leaderOf(run.team);
1300
1600
  this.postTurn(run, {
1301
1601
  role: "assistant",
1302
- author: chatAuthor(leaderOf(run.team), step.sessionId),
1602
+ author: chatAuthor(leader, step.sessionId, this.actualAgent(leader, step)),
1303
1603
  content: chatText(lines.filter(Boolean).join("\n\n")),
1304
1604
  });
1305
1605
  if (decision.action === "assign" && !run.planApproved) {
@@ -1347,6 +1647,9 @@ export class AiTeamRunner {
1347
1647
  run.status = status;
1348
1648
  run.statusDetail = detail;
1349
1649
  this.saveRun(run);
1650
+ // 进终态就不可能再有 live 变化,清掉该 run 的推送指纹:不然长跑的服务会按 run 数线性攒字符串。
1651
+ if (AI_TEAM_TERMINAL_RUN_STATUSES.includes(status))
1652
+ this.lastLiveKey.delete(run.id);
1350
1653
  }
1351
1654
  saveRun(run) {
1352
1655
  run.updatedAt = this.iso();