@co0ontty/wand 4.77.0-beta.gee68088 → 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.
package/README.md CHANGED
@@ -13,15 +13,7 @@
13
13
 
14
14
  Wand is a web console for remotely accessing and managing local CLI tools from a browser. It supports [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Codex](https://github.com/openai/codex), [OpenCode](https://opencode.ai/), [Grok Build](https://grok.com/), [Qoder CLI](https://docs.qoder.com/en/cli/quick-start), and [Pi](https://pi.dev/), with terminal and structured conversation views, persistent resumable sessions, permission controls, file browsing, and native clients for multiple platforms.
15
15
 
16
- The browser password manager extension source lives in `browser-extension/`; setup and supported baseline features are documented in [docs/browser-extension.md](docs/browser-extension.md).
17
-
18
- <p align="center">
19
- <img src="docs/screenshots/clients-overview.png" width="100%" alt="Web centered with iOS and Android clients on both sides" />
20
- </p>
21
-
22
- <p align="center">
23
- <img src="docs/screenshots/hero.png" width="100%" alt="PTY terminal and structured conversation views" />
24
- </p>
16
+ The browser password manager extension source lives in `browser-extension/`.
25
17
 
26
18
  ### Installation
27
19
 
@@ -164,14 +156,6 @@ npm install -g opencode-ai@latest
164
156
 
165
157
  通过浏览器远程访问和管理本地 CLI 工具的 Web 控制台。支持 [Claude Code](https://docs.anthropic.com/en/docs/claude-code)、[Codex](https://github.com/openai/codex)、[OpenCode](https://opencode.ai/)、[Grok Build](https://grok.com/)、[Qoder CLI](https://docs.qoder.com/zh/cli/quick-start) 和 [Pi](https://pi.dev/),提供终端和结构化对话双视图、会话持久化与恢复、权限管控、文件浏览和多平台客户端。
166
158
 
167
- <p align="center">
168
- <img src="docs/screenshots/clients-overview.png" width="100%" alt="Web 居中,iOS 和 Android 客户端在两侧的多端详情总览" />
169
- </p>
170
-
171
- <p align="center">
172
- <img src="docs/screenshots/hero.png" width="100%" alt="PTY 终端与结构化对话双视图" />
173
- </p>
174
-
175
159
  ### 安装
176
160
 
177
161
  选择下面任意一种安装方式:
@@ -232,24 +216,6 @@ npm install -g opencode-ai@latest
232
216
  - **版本管理** — 内置更新检查与升级提示,支持 stable/beta 双通道
233
217
  - **CLI 更新** — 在服务端设置中检查并快速更新 Claude Code、Codex、OpenCode、Qoder CLI 和 Pi,也可开启自动更新
234
218
 
235
- ### 截图
236
-
237
- | Web 结构化详情 | Web PTY 详情 |
238
- |:---:|:---:|
239
- | <img src="docs/screenshots/web-structured-detail.png" width="400" alt="Web 结构化会话详情" /> | <img src="docs/screenshots/web-pty-detail.png" width="400" alt="Web PTY 终端详情" /> |
240
-
241
- | iOS 客户端 | Android 客户端 |
242
- |:---:|:---:|
243
- | <img src="docs/screenshots/ios-home.png" width="300" alt="iOS 原生会话列表" /> | <img src="docs/screenshots/android-terminal.png" width="300" alt="Android 移动端 PTY 详情" /> |
244
-
245
- | 登录页 | App 连接页 |
246
- |:---:|:---:|
247
- | <img src="docs/screenshots/login.png" width="400" alt="登录页" /> | <img src="docs/screenshots/android-main.png" width="300" alt="Android App 连接页" /> |
248
-
249
- | Android 会话列表 | Android 聊天视图 |
250
- |:---:|:---:|
251
- | <img src="docs/screenshots/android-home.png" width="300" alt="Android 会话列表" /> | <img src="docs/screenshots/android-chat.png" width="300" alt="Android 聊天视图" /> |
252
-
253
219
  ### 配置
254
220
 
255
221
  配置文件位于 `~/.wand/config.json`,首次 `wand init` 时自动生成。
@@ -327,9 +293,7 @@ wand/
327
293
  │ └── web-ui/ # 前端 HTML/CSS/JS
328
294
  ├── android/ # Android 客户端(submodule)
329
295
  ├── ios/ # iOS 客户端(submodule)
330
- ├── macos/ # macOS 客户端(submodule)
331
- ├── 架构图.md # 运行时架构与代码边界(mermaid)
332
- └── docs/ # 文档与截图
296
+ └── macos/ # macOS 客户端(submodule)
333
297
  ```
334
298
 
335
299
  服务端开发只需检出固定版本的 Render 二进制。原生客户端和 Rust 源码都是独立子模块,修改哪个再检出哪个:
@@ -343,8 +307,7 @@ git submodule update --init -- render-bin
343
307
 
344
308
  全量 `npm test` 包含跨端源码契约检查,运行前另检出 `android ios macos render`。
345
309
  `npm run clean:build-cache` 预览可清理的编译缓存,追加 `-- --apply` 才执行删除;
346
- 保留分发包、Release Render 二进制、签名、依赖和会话数据。审计与续接记录见
347
- [`docs/repository-slimming.md`](docs/repository-slimming.md)。
310
+ 保留分发包、Release Render 二进制、签名、依赖和会话数据。
348
311
 
349
312
  数据存储在 `~/.wand/` 下:`config.json`(配置)、`wand.db`(SQLite)、`sessions/`(日志)。
350
313
 
@@ -199,6 +199,9 @@ function leaderReplyFormat() {
199
199
  "- 省略 after:等上一步完成后开始(顺序执行);",
200
200
  "- \"after\": []:立刻开始,可以和别的成员并行;",
201
201
  "- \"after\": [1, 2]:等第 1、2 步都做完再开始。",
202
+ // 依据就是被依赖步骤的产物(设计 v2.1 规则 7):群里显示成「依据:第 N 步「标题」的产物」,
203
+ // 所以 after 要指向真正的上游,不要挂着不相关的步骤。
204
+ "- 这个依赖关系会在群里显示成「依据:第 N 步「标题」的产物」,所以 after 只能指向这一步真正要接着做的上游步骤。",
202
205
  "",
203
206
  "instructions 的写法要求:",
204
207
  "- 实现步骤的 instructions 必须列出允许改动的文件/目录范围;",
@@ -1,6 +1,6 @@
1
1
  import { type AgentDispatchDeps } from "./agent-dispatch.js";
2
2
  import { type ModelCatalogView } from "./ai-team-availability.js";
3
- import { type AiTeamLiveStep, type AiTeamLiveUpdate, type AiTeamRun, type AiTeamRunDetail, type AiTeamRunSummary } from "./ai-team-types.js";
3
+ import { type AiTeam, type AiTeamLiveStep, type AiTeamLiveUpdate, type AiTeamRun, type AiTeamRunDetail, type AiTeamRunSummary, type AiTeamStep } from "./ai-team-types.js";
4
4
  import type { SessionRegistry } from "./session-registry.js";
5
5
  import type { WandStorage } from "./storage.js";
6
6
  import type { WandTask, WandTaskAgent } from "./task-types.js";
@@ -66,6 +66,12 @@ export declare function createAiTeamRunner(deps: AgentDispatchDeps & {
66
66
  models?: (provider: WandTaskAgent["provider"]) => ModelCatalogView;
67
67
  defaultModelOf?: (provider: WandTaskAgent["provider"]) => string;
68
68
  }): AiTeamRunner;
69
+ /**
70
+ * 群聊里的发言人。上传的头像是 data URL,每条消息都带一份太重,改用按 id 取的像素猫。
71
+ * `agent` 传该步**实际使用候选**(§3.6/B15,降级换候选后跟着变),model / thinkingEffort 与
72
+ * provider 同源;Leader 或没有步骤的场景回退首选候选。只填真值,展示文案归客户端。
73
+ */
74
+ export declare function displayAiTeam(snapshot: AiTeam, current: AiTeam | null): AiTeam;
69
75
  /** 遮蔽参照目录:家目录与激活配置目录(含 `/tmp/wand-dev` 这类隔离路径,按传入处理)。 */
70
76
  export interface RedactionDirs {
71
77
  homeDir?: string;
@@ -78,6 +84,35 @@ export interface RedactionDirs {
78
84
  * 方向是宁多勿漏:认不准的超长串一律隐藏,代价只是文案少一段可调试信息。
79
85
  */
80
86
  export declare function redactAiTeamErrorText(text: string, dirs?: RedactionDirs): string;
87
+ /** 每行邀请名单上限(S2/S3):`邀请 @a、@b、@c、@d 加入群聊`。 */
88
+ export declare const CHAT_INVITE_PER_LINE = 4;
89
+ /**
90
+ * 首次建群的入群序列(S1–S3):负责人先入群,再按 `team.members` 原顺序邀请其余成员。
91
+ * 每行最多 [CHAT_INVITE_PER_LINE] 人,第二行起用 `继续邀请 …`;除负责人外没人时给
92
+ * `还没有邀请其他成员入群`(仍两条,不伪造成员)。
93
+ */
94
+ export declare function chatIntroLines(team: Pick<AiTeam, "name" | "members">): string[];
95
+ /** 开工发言里的一条依据(来自 `step.dependsOn` 解析出的上游步骤)。 */
96
+ export interface AiTeamStepBasis {
97
+ seq: number;
98
+ title: string;
99
+ memberName: string;
100
+ reportPath: string;
101
+ }
102
+ /**
103
+ * 开工发言正文(S4):
104
+ * - 第 1 行 `我正在开始工作:第 N 步「标题」`;
105
+ * - 有依赖时第 2 行 `依据 @上游成员 第 M 步「上游标题」的产物 <reportPath>`,超过两条
106
+ * 只列前两条并在尾上 `等 N 步`(N = 依赖总数);
107
+ * - 依赖解析不到(旧数据/步骤被删)→ `依据上游步骤的产物继续`。
108
+ */
109
+ export declare function stepStartText(step: Pick<AiTeamStep, "seq" | "title">, upstream?: ReadonlyArray<AiTeamStepBasis>, dependencyCount?: number): string;
110
+ /**
111
+ * 派工行的依据括注(S5):`(依据:第 1 步「设计规格」的产物)`,多个依赖用 `、` 连接。
112
+ * 位置 `at` 是**本批 steps 内**的 0-based 下标(展示成第 at+1 步);没有依赖就是空串(首步无依据)。
113
+ * 它**取代**旧的 `(等第 N 项完成后)`:同一个依赖关系不写两遍。
114
+ */
115
+ export declare function assignmentBasisNote(after: readonly number[], titles: readonly string[]): string;
81
116
  /**
82
117
  * AI 团队调度器。状态全部在数据库里,会话事件驱动推进;同一运行的所有操作串行执行。
83
118
  * 成员会话是普通 Wand 会话,服务端只负责传话:Leader 决定派谁做什么,成员交报告。
@@ -194,6 +229,16 @@ export declare class AiTeamRunner {
194
229
  * 成员步骤的上游交接:把依赖步骤的报告写成一个交接文件,提示词只给路径。
195
230
  * 没有依赖、或写文件失败时返回 null(提示词仍逐行列出上游报告文件)。
196
231
  */
232
+ /**
233
+ * 开工发言(S4):成员**自己发的真实发言**(头像 + 名字 + 气泡),不是居中 notice;
234
+ * 文案由 [stepStartText] 拼(依据只来自 `step.dependsOn`,不引 `decision.message` / 报告正文 / 提示词文本)。
235
+ */
236
+ private postStepStart;
237
+ /**
238
+ * 开工发言的依据:只看 `step.dependsOn`,按依赖顺序解析出上游步骤(没有依赖返回空数组)。
239
+ * 不调 `memberUpstream`:那个会顺手写交接文件,而这里只要读数据(设计 v2.1 强调不引 LLM 文本)。
240
+ */
241
+ private upstreamBasis;
197
242
  private memberUpstream;
198
243
  /**
199
244
  * 同一个群聊里接着开新运行时的续跑交接(§4.4 同一套文件交接):把之前几轮的步骤摘要
@@ -89,6 +89,20 @@ export function createAiTeamRunner(deps) {
89
89
  * `agent` 传该步**实际使用候选**(§3.6/B15,降级换候选后跟着变),model / thinkingEffort 与
90
90
  * provider 同源;Leader 或没有步骤的场景回退首选候选。只填真值,展示文案归客户端。
91
91
  */
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
+ }
92
106
  function chatAuthor(member, sessionId, agent) {
93
107
  const used = agent ?? memberAgents(member)[0] ?? member.agent;
94
108
  return {
@@ -162,6 +176,73 @@ function turnText(turn) {
162
176
  .filter(Boolean)
163
177
  .join("\n\n");
164
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
+ }
165
246
  /** 找到提示词(含 reportPath)那一轮之后的 assistant 文本;没有回复返回 null。 */
166
247
  function assistantReplyAfterPrompt(snapshot, reportPath) {
167
248
  const messages = snapshot.messages ?? [];
@@ -493,10 +574,13 @@ export class AiTeamRunner {
493
574
  }
494
575
  // 群聊回合只给最近这一段(§4.4);没有群聊会话的旧运行给空数组,前端不用判 null。
495
576
  const messages = run.chatSessionId ? this.ops.snapshot(run.chatSessionId)?.messages ?? [] : [];
577
+ const currentTeam = this.storage.getAiTeam(run.teamId);
496
578
  return {
497
579
  run,
498
580
  steps,
499
581
  memberStates,
582
+ // 别把展示字段写回 run.team:运行快照里的执行候选/职责由 runner 独立管理。
583
+ displayTeam: displayAiTeam(run.team, currentTeam),
500
584
  chatTurns: messages.slice(-AI_TEAM_DETAIL_CHAT_TURNS),
501
585
  };
502
586
  }
@@ -984,6 +1068,35 @@ export class AiTeamRunner {
984
1068
  * 成员步骤的上游交接:把依赖步骤的报告写成一个交接文件,提示词只给路径。
985
1069
  * 没有依赖、或写文件失败时返回 null(提示词仍逐行列出上游报告文件)。
986
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
+ }
987
1100
  memberUpstream(run, step) {
988
1101
  if (step.dependsOn.length === 0)
989
1102
  return null;
@@ -1123,9 +1236,11 @@ export class AiTeamRunner {
1123
1236
  let running = { ...step, instructions: step.kind === "leader" ? prompt.message : step.instructions, status: "running", startedAt, sessionId: reusable };
1124
1237
  this.saveStep(running);
1125
1238
  this.notify(run);
1126
- if (member && step.kind === "work") {
1127
- this.postNotice(run, `${member.name} 开始「${step.title}」`, reusable, member, agent);
1128
- }
1239
+ // 开工发言(S4):就发在这个位置(原来那条居中 notice 的地方),所以候选起不来的那次尝试
1240
+ // 也会留一句「我正在开始工作」+ 随后的降级/失败出口;作者署名与报告同一套口径,
1241
+ // 复用会话时带真实 sessionId,新建在 open 前未知;若 open 失败,失败报告也可能无 ID。
1242
+ if (member && step.kind === "work")
1243
+ this.postStepStart(run, running, member, agent, reusable);
1129
1244
  try {
1130
1245
  if (!member)
1131
1246
  throw new Error("成员不存在");
@@ -1423,8 +1538,11 @@ export class AiTeamRunner {
1423
1538
  openChat(run, task, objective, chatHistory) {
1424
1539
  if (!this.chat)
1425
1540
  return;
1541
+ // 「首次建群」只能在这一拍判定:`open()` 之后 run.chatSessionId 就有值了。
1542
+ // 入群序列(S1–S3)只跟这个分支走,续跑/重启接回/同一个群里开新 run 都不重播(v2.1 规则 1)。
1543
+ const firstChat = !run.chatSessionId;
1426
1544
  try {
1427
- if (!run.chatSessionId) {
1545
+ if (firstChat) {
1428
1546
  run.chatSessionId = this.chat.open({ task, run, title: `${run.team.name} · ${task.title}` });
1429
1547
  }
1430
1548
  }
@@ -1432,13 +1550,21 @@ export class AiTeamRunner {
1432
1550
  console.error(`[AiTeam] open chat for ${run.id} failed:`, getErrorMessage(error));
1433
1551
  return;
1434
1552
  }
1435
- const roster = run.team.members.map((member) => member.isLeader ? `${member.name}(负责人)` : member.name).join("、");
1436
1553
  if (objective)
1437
1554
  this.postUser(run, objective);
1438
- // 续跑时不说「接手」:用户看得出这是同一轮工作往下走,并把交给团队的历史文件摊开给他核。
1439
- this.postNotice(run, chatHistory
1440
- ? `接着这个群聊里上一轮的进度继续;交给负责人和成员的记录见 ${chatHistory}`
1441
- : `团队「${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
+ }
1442
1568
  }
1443
1569
  postTurn(run, turn) {
1444
1570
  if (!this.chat || !run.chatSessionId)
@@ -1465,8 +1591,8 @@ export class AiTeamRunner {
1465
1591
  if (decision.action === "assign") {
1466
1592
  const assigned = decision.steps.map((item, index) => {
1467
1593
  const name = this.member(run.team, item.memberId)?.name ?? item.memberId;
1468
- const after = item.after.length > 0 ? `(等第 ${item.after.map((at) => at + 1).join("、")} 项完成后)` : "";
1469
- 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}`;
1470
1596
  });
1471
1597
  lines.push(assigned.join("\n"));
1472
1598
  }
@@ -76,7 +76,7 @@ export declare const AI_TEAM_TERMINAL_RUN_STATUSES: readonly AiTeamRunStatus[];
76
76
  export interface AiTeamRun {
77
77
  id: string;
78
78
  teamId: string;
79
- /** 启动时的团队快照;之后改团队不影响已开始的运行。 */
79
+ /** 执行快照;仅 runner 在明确续跑/回复边界按规则刷新,展示层改名不写回这里。 */
80
80
  team: AiTeam;
81
81
  taskId: string;
82
82
  objective: string;
@@ -105,6 +105,24 @@ export interface AiTeamRunChatMarker {
105
105
  teamName: string;
106
106
  memberCount: number;
107
107
  }
108
+ /**
109
+ * 会话列表用的成员步骤标记:`ai_team_steps.session_id` → 这一步的身份(`AiTeamStepSessionMarker` 索引的 value)。
110
+ * 派发出来的会话没有人类起的标题,列表用 `title` 当短标题;`runFinished` 决定它算进行中还是历史。
111
+ * 成员名按当前团队定义投影,团队或成员被删时退回运行快照,和群聊署名同一套规则。
112
+ */
113
+ export interface AiTeamStepSessionMarker {
114
+ runId: string;
115
+ stepId: string;
116
+ kind: AiTeamStepKind;
117
+ /** 这一步的任务标题,短、稳定。 */
118
+ title: string;
119
+ memberId: string;
120
+ memberName: string;
121
+ teamName: string;
122
+ stepStatus: AiTeamStepStatus;
123
+ runStatus: AiTeamRunStatus;
124
+ runFinished: boolean;
125
+ }
108
126
  export type AiTeamStepKind = "leader" | "work";
109
127
  export type AiTeamStepStatus = "queued" | "running" | "done" | "failed" | "skipped";
110
128
  export interface AiTeamStep {
@@ -177,6 +195,8 @@ export interface AiTeamRunDetail {
177
195
  steps: AiTeamStep[];
178
196
  /** 运行中步骤所在会话的实时状态(等待授权 / 等待回答等)。 */
179
197
  memberStates: Record<string, AgentActivityState>;
180
- /** 群聊 relay 会话的消息,截尾 AI_TEAM_DETAIL_CHAT_TURNS 条;没有群聊会话的旧运行为空数组。 */
198
+ /** 仅供展示:运行时成员顺序/职责保持快照,仍存在的成员按 id 取当前名字/头像。不得用于派工。 */
199
+ displayTeam?: AiTeam;
200
+ /** 群聊 relay 会话的原始消息;作者和正文是历史记录,不因改名而改写。 */
181
201
  chatTurns: ConversationTurn[];
182
202
  }
@@ -1,6 +1,6 @@
1
1
  {
2
- "commit": "ee68088ff775bfa9819f6afd141f4cbef701c7c6",
3
- "builtAt": "2026-09-28T12:59:22.789Z",
4
- "version": "4.77.0-beta.gee68088",
2
+ "commit": "a1e4f594e3431de8d5d816fba8ed84c8c4611dbf",
3
+ "builtAt": "2026-09-29T11:21:51.150Z",
4
+ "version": "4.78.0-beta.ga1e4f59",
5
5
  "channel": "beta"
6
6
  }
@@ -1,7 +1,21 @@
1
1
  /**
2
- * Login rate limiter — tracks failed attempts per IP.
2
+ * Login rate limiter — tracks failed admin-password attempts per IP.
3
3
  * In-memory only; resets on process restart.
4
+ *
5
+ * Only human-typed passwords count. An app token is a machine-held credential the
6
+ * user cannot "type correctly", so a stale connect code must not lock out the whole
7
+ * shared IP bucket (that is what kept the web console answering 429).
4
8
  */
5
- export declare function checkRateLimit(ip: string): boolean;
6
- export declare function recordFailedLogin(ip: string): void;
7
- export declare function resetRateLimit(ip: string): void;
9
+ export interface LoginLimitState {
10
+ lockedUntil: number;
11
+ /** Seconds to advertise via Retry-After; 0 when not locked. */
12
+ retryAfter: number;
13
+ }
14
+ export declare function checkPasswordRateLimit(ip: string, now?: number): LoginLimitState | null;
15
+ /**
16
+ * Records one wrong admin password. Returns the lock that was just imposed, if any.
17
+ * A previous lock that has already expired starts a fresh window instead of being
18
+ * topped up by the first typo after it.
19
+ */
20
+ export declare function recordFailedPassword(ip: string, now?: number): LoginLimitState | null;
21
+ export declare function resetPasswordRateLimit(ip: string): void;
@@ -1,35 +1,67 @@
1
1
  /**
2
- * Login rate limiter — tracks failed attempts per IP.
2
+ * Login rate limiter — tracks failed admin-password attempts per IP.
3
3
  * In-memory only; resets on process restart.
4
+ *
5
+ * Only human-typed passwords count. An app token is a machine-held credential the
6
+ * user cannot "type correctly", so a stale connect code must not lock out the whole
7
+ * shared IP bucket (that is what kept the web console answering 429).
4
8
  */
5
- const RATE_LIMIT_WINDOW = 15 * 60 * 1000; // 15 minutes
6
- const RATE_LIMIT_MAX = 10; // 10 attempts per window
7
- const loginAttempts = new Map();
8
- export function checkRateLimit(ip) {
9
- const now = Date.now();
10
- const record = loginAttempts.get(ip);
11
- if (!record || now > record.resetAt) {
12
- return true;
13
- }
14
- return record.count < RATE_LIMIT_MAX;
9
+ const PASSWORD_WINDOW = 15 * 60 * 1000; // failures are counted over a sliding window
10
+ const PASSWORD_MAX = 10; // allowed failures inside the window
11
+ const LOCKOUT = 15 * 60 * 1000; // fixed cooldown once the window is exhausted
12
+ const passwordAttempts = new Map();
13
+ function prune(attempts, now) {
14
+ const cutoff = now - PASSWORD_WINDOW;
15
+ let start = 0;
16
+ while (start < attempts.length && attempts[start] <= cutoff)
17
+ start++;
18
+ return start === 0 ? attempts : attempts.slice(start);
15
19
  }
16
- export function recordFailedLogin(ip) {
17
- const now = Date.now();
18
- const record = loginAttempts.get(ip);
19
- if (!record || now > record.resetAt) {
20
- loginAttempts.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW });
21
- return;
20
+ function lockedState(lockedUntil, now) {
21
+ return { lockedUntil, retryAfter: Math.ceil((lockedUntil - now) / 1000) };
22
+ }
23
+ export function checkPasswordRateLimit(ip, now = Date.now()) {
24
+ const record = passwordAttempts.get(ip);
25
+ if (!record)
26
+ return null;
27
+ if (now < record.lockedUntil)
28
+ return lockedState(record.lockedUntil, now);
29
+ record.attempts = prune(record.attempts, now);
30
+ if (!record.attempts.length)
31
+ passwordAttempts.delete(ip);
32
+ return null;
33
+ }
34
+ /**
35
+ * Records one wrong admin password. Returns the lock that was just imposed, if any.
36
+ * A previous lock that has already expired starts a fresh window instead of being
37
+ * topped up by the first typo after it.
38
+ */
39
+ export function recordFailedPassword(ip, now = Date.now()) {
40
+ const existing = passwordAttempts.get(ip);
41
+ // A lock that has already expired must not leave its failures behind to tip the new
42
+ // window over the limit on the first typo; `lockedUntil` 0 means "never locked".
43
+ const expiredLock = !!existing && existing.lockedUntil > 0 && now >= existing.lockedUntil;
44
+ const record = !existing || expiredLock ? { attempts: [], lockedUntil: 0 } : existing;
45
+ record.attempts = prune(record.attempts, now);
46
+ record.attempts.push(now);
47
+ let lock = null;
48
+ if (record.attempts.length >= PASSWORD_MAX) {
49
+ record.lockedUntil = now + LOCKOUT;
50
+ record.attempts = [];
51
+ lock = lockedState(record.lockedUntil, now);
22
52
  }
23
- record.count++;
53
+ passwordAttempts.set(ip, record);
54
+ return lock;
24
55
  }
25
- export function resetRateLimit(ip) {
26
- loginAttempts.delete(ip);
56
+ export function resetPasswordRateLimit(ip) {
57
+ passwordAttempts.delete(ip);
27
58
  }
28
59
  function cleanupRateLimiter() {
29
60
  const now = Date.now();
30
- for (const [ip, record] of loginAttempts.entries()) {
31
- if (now > record.resetAt) {
32
- loginAttempts.delete(ip);
61
+ for (const [ip, record] of passwordAttempts.entries()) {
62
+ record.attempts = prune(record.attempts, now);
63
+ if (!record.attempts.length && now >= record.lockedUntil) {
64
+ passwordAttempts.delete(ip);
33
65
  }
34
66
  }
35
67
  }
package/dist/missions.js CHANGED
@@ -333,7 +333,6 @@ export class Missions {
333
333
  sessionSource: "automation",
334
334
  automationId: mission.id,
335
335
  });
336
- // 关联任务的派发会话立刻落到任务卡片上,不等下一次看板列表的兜底同步。
337
336
  attempt = {
338
337
  ...attempt,
339
338
  sessionId: session.id,
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Render 协议 v1 的 TS 镜像。Rust 单一真源在
3
3
  * `render/crates/wand-render-protocol/src/lib.rs`,契约文档在
4
- * `render/docs/render-protocol.md`(本仓库 `docs/render-protocol.md` 只是指针)。
4
+ * `render/docs/render-protocol.md`。
5
5
  *
6
6
  * 两侧常量与字段名必须逐一对齐;任一侧改动都要同时提升版本号并更新文档。
7
7
  */
@@ -5,7 +5,7 @@ import path from "node:path";
5
5
  /**
6
6
  * Render 协议 v1 的 TS 镜像。Rust 单一真源在
7
7
  * `render/crates/wand-render-protocol/src/lib.rs`,契约文档在
8
- * `render/docs/render-protocol.md`(本仓库 `docs/render-protocol.md` 只是指针)。
8
+ * `render/docs/render-protocol.md`。
9
9
  *
10
10
  * 两侧常量与字段名必须逐一对齐;任一侧改动都要同时提升版本号并更新文档。
11
11
  */
@@ -8,4 +8,5 @@ export declare function parseAiTeamInput(value: unknown, existing: AiTeam | null
8
8
  export declare function registerAiTeamRoutes(app: Express, deps: {
9
9
  storage: WandStorage;
10
10
  runner: AiTeamRunner;
11
+ notifyTeamChanged?: (teamId: string) => void;
11
12
  }): void;
@@ -133,6 +133,14 @@ export function parseAiTeamInput(value, existing, now) {
133
133
  }
134
134
  export function registerAiTeamRoutes(app, deps) {
135
135
  const { storage, runner } = deps;
136
+ const notifyTeam = (teamId) => {
137
+ try {
138
+ deps.notifyTeamChanged?.(teamId);
139
+ }
140
+ catch (error) {
141
+ console.error("[AiTeam] notify definition failed:", getErrorMessage(error));
142
+ }
143
+ };
136
144
  app.get("/api/ai-teams", (_req, res) => {
137
145
  res.json(storage.listAiTeams());
138
146
  });
@@ -153,6 +161,7 @@ export function registerAiTeamRoutes(app, deps) {
153
161
  throw new Error("团队不存在。");
154
162
  const team = parseAiTeamInput(req.body, existing, new Date().toISOString());
155
163
  storage.saveAiTeam(team);
164
+ notifyTeam(team.id);
156
165
  res.json(team);
157
166
  }
158
167
  catch (error) {
@@ -161,6 +170,7 @@ export function registerAiTeamRoutes(app, deps) {
161
170
  });
162
171
  app.delete("/api/ai-teams/:id", (req, res) => {
163
172
  storage.deleteAiTeam(req.params.id);
173
+ notifyTeam(req.params.id);
164
174
  res.json({ ok: true });
165
175
  });
166
176
  /** 团队详情:团队对象 + 该团队的运行摘要(修正 B14,Android AiTeamDetailScreen 与 Web 面板共用)。 */
@@ -484,7 +484,6 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
484
484
  ...origin,
485
485
  });
486
486
  onSessionCreated?.(snapshot.cwd);
487
- // 会话一落地就挂到任务的看板卡片上,不等下一次看板列表的兜底同步。
488
487
  const prompt = body.prompt?.trim();
489
488
  if (prompt) {
490
489
  if (body.respondImmediately === true) {