@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 +3 -40
- package/dist/ai-team-prompts.js +3 -0
- package/dist/ai-team-runner.d.ts +46 -1
- package/dist/ai-team-runner.js +137 -11
- package/dist/ai-team-types.d.ts +22 -2
- package/dist/build-info.json +3 -3
- package/dist/middleware/rate-limit.d.ts +18 -4
- package/dist/middleware/rate-limit.js +55 -23
- package/dist/missions.js +0 -1
- package/dist/render-protocol.d.ts +1 -1
- package/dist/render-protocol.js +1 -1
- package/dist/server-ai-team-routes.d.ts +1 -0
- package/dist/server-ai-team-routes.js +10 -0
- package/dist/server-session-routes.js +0 -1
- package/dist/server-workspace-routes.js +27 -9
- package/dist/server.js +24 -10
- package/dist/storage.d.ts +13 -1
- package/dist/storage.js +196 -47
- package/dist/upload-routes.d.ts +2 -2
- package/dist/upload-routes.js +10 -3
- package/dist/web-ui/content/ai-teams.js +17 -8
- package/dist/web-ui/content/scripts.js +68 -67
- package/dist/web-ui/content/styles.css +1 -1
- package/dist/web-ui/embedded-assets.js +3 -3
- package/package.json +1 -1
- package/scripts/install-render-binary.js +2 -2
- package/scripts/stage-render-binaries.js +1 -1
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
|
|
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
|
-
|
|
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
|
|
package/dist/ai-team-prompts.js
CHANGED
|
@@ -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 必须列出允许改动的文件/目录范围;",
|
package/dist/ai-team-runner.d.ts
CHANGED
|
@@ -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 同一套文件交接):把之前几轮的步骤摘要
|
package/dist/ai-team-runner.js
CHANGED
|
@@ -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
|
-
|
|
1127
|
-
|
|
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 (
|
|
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
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
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
|
|
1469
|
-
return `${index + 1}. **@${name}** ${item.title}${
|
|
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
|
}
|
package/dist/ai-team-types.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
198
|
+
/** 仅供展示:运行时成员顺序/职责保持快照,仍存在的成员按 id 取当前名字/头像。不得用于派工。 */
|
|
199
|
+
displayTeam?: AiTeam;
|
|
200
|
+
/** 群聊 relay 会话的原始消息;作者和正文是历史记录,不因改名而改写。 */
|
|
181
201
|
chatTurns: ConversationTurn[];
|
|
182
202
|
}
|
package/dist/build-info.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
|
-
"commit": "
|
|
3
|
-
"builtAt": "2026-09-
|
|
4
|
-
"version": "4.
|
|
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
|
|
6
|
-
|
|
7
|
-
|
|
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
|
|
6
|
-
const
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
return
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
|
53
|
+
passwordAttempts.set(ip, record);
|
|
54
|
+
return lock;
|
|
24
55
|
}
|
|
25
|
-
export function
|
|
26
|
-
|
|
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
|
|
31
|
-
|
|
32
|
-
|
|
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
package/dist/render-protocol.js
CHANGED
|
@@ -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
|
|
8
|
+
* `render/docs/render-protocol.md`。
|
|
9
9
|
*
|
|
10
10
|
* 两侧常量与字段名必须逐一对齐;任一侧改动都要同时提升版本号并更新文档。
|
|
11
11
|
*/
|
|
@@ -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) {
|