@co0ontty/wand 4.76.0 → 4.77.0-beta.g6c4df97

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 (71) hide show
  1. package/dist/agent-dispatch.d.ts +46 -0
  2. package/dist/agent-dispatch.js +84 -0
  3. package/dist/ai-team-availability.d.ts +69 -0
  4. package/dist/ai-team-availability.js +100 -0
  5. package/dist/ai-team-prompts.d.ts +113 -0
  6. package/dist/ai-team-prompts.js +435 -0
  7. package/dist/ai-team-runner.d.ts +241 -0
  8. package/dist/ai-team-runner.js +1370 -0
  9. package/dist/ai-team-types.d.ts +147 -0
  10. package/dist/ai-team-types.js +32 -0
  11. package/dist/attention.d.ts +40 -0
  12. package/dist/attention.js +86 -0
  13. package/dist/build-info.json +4 -4
  14. package/dist/claude-sdk-runner.d.ts +5 -0
  15. package/dist/claude-sdk-runner.js +2 -1
  16. package/dist/commit-task-archive.d.ts +3 -0
  17. package/dist/commit-task-archive.js +43 -0
  18. package/dist/config.js +0 -2
  19. package/dist/git-quick-commit.d.ts +2 -1
  20. package/dist/git-quick-commit.js +72 -60
  21. package/dist/message-truncator.d.ts +41 -5
  22. package/dist/message-truncator.js +255 -38
  23. package/dist/missions.d.ts +4 -2
  24. package/dist/missions.js +2 -2
  25. package/dist/process-manager.d.ts +1 -0
  26. package/dist/process-manager.js +17 -8
  27. package/dist/prompt-optimizer.js +13 -13
  28. package/dist/server-ai-team-routes.d.ts +11 -0
  29. package/dist/server-ai-team-routes.js +283 -0
  30. package/dist/server-attention-routes.d.ts +9 -0
  31. package/dist/server-attention-routes.js +26 -0
  32. package/dist/server-file-routes.js +1 -1
  33. package/dist/server-session-routes.js +41 -5
  34. package/dist/server-settings-routes.js +1 -1
  35. package/dist/server-task-routes.d.ts +2 -0
  36. package/dist/server-task-routes.js +6 -40
  37. package/dist/server-workspace-routes.js +13 -5
  38. package/dist/server.js +43 -0
  39. package/dist/session-topic.js +13 -13
  40. package/dist/session-transport.d.ts +3 -0
  41. package/dist/session-transport.js +1 -0
  42. package/dist/storage.d.ts +54 -1
  43. package/dist/storage.js +413 -2
  44. package/dist/structured-claude-adapter.d.ts +5 -0
  45. package/dist/structured-claude-adapter.js +13 -2
  46. package/dist/structured-claude-protocol.d.ts +2 -0
  47. package/dist/structured-claude-protocol.js +4 -0
  48. package/dist/structured-codex-adapter.js +2 -2
  49. package/dist/structured-grok-adapter.js +2 -1
  50. package/dist/structured-opencode-adapter.js +2 -2
  51. package/dist/structured-pi-adapter.js +2 -1
  52. package/dist/structured-provider-common.d.ts +17 -0
  53. package/dist/structured-provider-common.js +52 -0
  54. package/dist/structured-qoder-adapter.js +2 -1
  55. package/dist/structured-session-manager.d.ts +18 -1
  56. package/dist/structured-session-manager.js +67 -10
  57. package/dist/system-ai.d.ts +13 -2
  58. package/dist/system-ai.js +9 -5
  59. package/dist/task-title.js +10 -10
  60. package/dist/types.d.ts +25 -2
  61. package/dist/wand-task-sync.d.ts +1 -0
  62. package/dist/wand-task-sync.js +1 -0
  63. package/dist/web-ui/content/ai-teams.js +12 -0
  64. package/dist/web-ui/content/scripts.js +86 -5301
  65. package/dist/web-ui/content/styles.css +1 -1
  66. package/dist/web-ui/embedded-assets.d.ts +1 -0
  67. package/dist/web-ui/embedded-assets.js +3 -2
  68. package/dist/web-ui/scripts.d.ts +8 -0
  69. package/dist/web-ui/scripts.js +28 -2
  70. package/dist/ws-broadcast.js +52 -19
  71. package/package.json +1 -1
@@ -0,0 +1,283 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { AiTeamConflictError } from "./ai-team-runner.js";
3
+ import { AI_TEAM_AVATAR_MAX_CHARS, AI_TEAM_DEFAULT_MAX_STEPS, AI_TEAM_MAX_CANDIDATES, AI_TEAM_MAX_MEMBERS, AI_TEAM_MAX_STEPS, AI_TEAM_MIN_MEMBERS, AI_TEAM_MIN_STEPS, agentKey, isTeamMemberRole, memberAgents, } from "./ai-team-types.js";
4
+ import { asyncRoute } from "./express-async.js";
5
+ import { getErrorMessage } from "./error-utils.js";
6
+ import { defaultMilestoneIdForWrite, scopedMilestoneId } from "./milestone-scope.js";
7
+ import { bodyObject, sendRouteError, text } from "./server-request.js";
8
+ import { parseTaskAgent } from "./server-task-routes.js";
9
+ import { provisionalTaskTitleFromDescription } from "./task-title.js";
10
+ import { ensureWorkspaceTaskForBoardTask } from "./wand-task-sync.js";
11
+ const NOT_FOUND = /不存在/;
12
+ /** 团队页「直接开工」自动建卡的标记(§4.2 / Q3):看板靠它区分团队卡与手写卡。 */
13
+ export const TEAM_DIRECT_LABEL = "team_direct";
14
+ /** note 上限,与团队协作指令同口径(§4.2)。 */
15
+ const TEAM_DIRECT_NOTE_MAX = 4000;
16
+ /** `GET /api/ai-teams/:id` 附带多少个运行摘要(§4.1 B14):面板只要最近这一段。 */
17
+ const TEAM_DIRECT_RUNS_LIMIT = 50;
18
+ function sendTeamError(res, error) {
19
+ const message = getErrorMessage(error, "团队操作失败。");
20
+ const status = error instanceof AiTeamConflictError ? 409 : NOT_FOUND.test(message) ? 404 : 400;
21
+ sendRouteError(res, error, "团队操作失败。", status);
22
+ }
23
+ function boundedText(value, label, min, max) {
24
+ const result = text(value);
25
+ if (result.length < min)
26
+ throw new Error(`${label}不能为空。`);
27
+ if (result.length > max)
28
+ throw new Error(`${label}不能超过 ${max} 个字符。`);
29
+ return result;
30
+ }
31
+ function parseAvatar(value, name) {
32
+ const avatar = text(value);
33
+ if (!avatar || /^cat:\d{1,2}$/.test(avatar))
34
+ return avatar;
35
+ if (!/^data:image\/(png|jpeg|webp);base64,[A-Za-z0-9+/=]+$/.test(avatar))
36
+ throw new Error(`成员「${name}」的头像格式无效。`);
37
+ if (avatar.length > AI_TEAM_AVATAR_MAX_CHARS)
38
+ throw new Error(`成员「${name}」的头像太大。`);
39
+ return avatar;
40
+ }
41
+ function parseMembers(value) {
42
+ if (!Array.isArray(value))
43
+ throw new Error("成员列表必须是数组。");
44
+ if (value.length < AI_TEAM_MIN_MEMBERS || value.length > AI_TEAM_MAX_MEMBERS) {
45
+ throw new Error(`团队需要 ${AI_TEAM_MIN_MEMBERS}–${AI_TEAM_MAX_MEMBERS} 名成员。`);
46
+ }
47
+ const names = new Set();
48
+ const ids = new Set();
49
+ const members = value.map((raw, index) => {
50
+ const body = bodyObject(raw);
51
+ const name = boundedText(body.name, `第 ${index + 1} 位成员的名字`, 1, 40);
52
+ const key = name.toLowerCase();
53
+ if (names.has(key))
54
+ throw new Error(`成员名字「${name}」重复。`);
55
+ names.add(key);
56
+ const rawId = text(body.id);
57
+ const id = /^m_[A-Za-z0-9]{1,32}$/.test(rawId) && !ids.has(rawId)
58
+ ? rawId
59
+ : `m_${randomUUID().replace(/-/g, "").slice(0, 8)}`;
60
+ ids.add(id);
61
+ const duty = text(body.duty);
62
+ if (duty.length > 2000)
63
+ throw new Error(`成员「${name}」的职责不能超过 2000 个字符。`);
64
+ let agents;
65
+ try {
66
+ if (Array.isArray(body.agents)) {
67
+ if (body.agents.length === 0)
68
+ throw new Error("至少需要一个执行候选。");
69
+ if (body.agents.length > AI_TEAM_MAX_CANDIDATES)
70
+ throw new Error(`最多 ${AI_TEAM_MAX_CANDIDATES} 个执行候选。`);
71
+ agents = body.agents.map((rawAgent) => {
72
+ const parsed = parseTaskAgent(rawAgent ?? {});
73
+ if (!parsed)
74
+ throw new Error("请选择有效的 CLI 工具。");
75
+ return parsed;
76
+ });
77
+ }
78
+ else if (body.agents !== undefined && body.agents !== null) {
79
+ throw new Error("候选执行配置必须是数组。");
80
+ }
81
+ else {
82
+ const agent = parseTaskAgent(body.agent ?? {});
83
+ if (!agent)
84
+ throw new Error("没有选择 CLI 工具。");
85
+ agents = [agent];
86
+ }
87
+ }
88
+ catch (error) {
89
+ throw new Error(`成员「${name}」:${getErrorMessage(error)}`);
90
+ }
91
+ const seenKeys = new Set(agents.map(agentKey));
92
+ if (seenKeys.size !== agents.length)
93
+ throw new Error(`成员「${name}」的执行候选重复。`);
94
+ const member = {
95
+ id, name, duty, agents,
96
+ // 服务端强制兼容字段 = 首选候选,不信任客户端传来的 agent。
97
+ agent: agents[0],
98
+ isLeader: body.isLeader === true,
99
+ avatar: parseAvatar(body.avatar, name),
100
+ };
101
+ if (isTeamMemberRole(body.role))
102
+ member.role = body.role;
103
+ return member;
104
+ });
105
+ if (members.filter((member) => member.isLeader).length !== 1)
106
+ throw new Error("团队需要恰好一位负责人。");
107
+ return members;
108
+ }
109
+ export function parseAiTeamInput(value, existing, now) {
110
+ const body = bodyObject(value);
111
+ const maxSteps = body.maxSteps === undefined ? existing?.maxSteps ?? AI_TEAM_DEFAULT_MAX_STEPS : Number(body.maxSteps);
112
+ if (!Number.isInteger(maxSteps) || maxSteps < AI_TEAM_MIN_STEPS || maxSteps > AI_TEAM_MAX_STEPS) {
113
+ throw new Error(`步数上限需要是 ${AI_TEAM_MIN_STEPS}–${AI_TEAM_MAX_STEPS} 之间的整数。`);
114
+ }
115
+ const description = text(body.description);
116
+ if (description.length > 500)
117
+ throw new Error("说明不能超过 500 个字符。");
118
+ const instructions = body.instructions === undefined ? existing?.instructions ?? "" : text(body.instructions);
119
+ if (instructions.length > 4000)
120
+ throw new Error("协作指令不能超过 4000 个字符。");
121
+ return {
122
+ id: existing?.id ?? `team_${randomUUID().replace(/-/g, "").slice(0, 12)}`,
123
+ name: boundedText(body.name, "团队名", 1, 60),
124
+ description,
125
+ instructions,
126
+ members: parseMembers(body.members),
127
+ requirePlanApproval: typeof body.requirePlanApproval === "boolean"
128
+ ? body.requirePlanApproval
129
+ : existing?.requirePlanApproval ?? true,
130
+ maxSteps,
131
+ createdAt: existing?.createdAt ?? now,
132
+ updatedAt: now,
133
+ };
134
+ }
135
+ export function registerAiTeamRoutes(app, deps) {
136
+ const { storage, runner } = deps;
137
+ app.get("/api/ai-teams", (_req, res) => {
138
+ res.json(storage.listAiTeams());
139
+ });
140
+ app.post("/api/ai-teams", (req, res) => {
141
+ try {
142
+ const team = parseAiTeamInput(req.body, null, new Date().toISOString());
143
+ storage.saveAiTeam(team);
144
+ res.status(201).json(team);
145
+ }
146
+ catch (error) {
147
+ sendTeamError(res, error);
148
+ }
149
+ });
150
+ app.put("/api/ai-teams/:id", (req, res) => {
151
+ try {
152
+ const existing = storage.getAiTeam(req.params.id);
153
+ if (!existing)
154
+ throw new Error("团队不存在。");
155
+ const team = parseAiTeamInput(req.body, existing, new Date().toISOString());
156
+ storage.saveAiTeam(team);
157
+ res.json(team);
158
+ }
159
+ catch (error) {
160
+ sendTeamError(res, error);
161
+ }
162
+ });
163
+ app.delete("/api/ai-teams/:id", (req, res) => {
164
+ storage.deleteAiTeam(req.params.id);
165
+ res.json({ ok: true });
166
+ });
167
+ /** 团队详情:团队对象 + 该团队的运行摘要(修正 B14,Android AiTeamDetailScreen 与 Web 面板共用)。 */
168
+ app.get("/api/ai-teams/:id", (req, res) => {
169
+ try {
170
+ const team = storage.getAiTeam(req.params.id);
171
+ if (!team)
172
+ throw new Error("团队不存在。");
173
+ res.json({ team, runs: runner.listRuns({ teamId: team.id, limit: TEAM_DIRECT_RUNS_LIMIT }) });
174
+ }
175
+ catch (error) {
176
+ sendTeamError(res, error);
177
+ }
178
+ });
179
+ /**
180
+ * 直接开工(§4.2,需求 D):服务端自己建一张 `team_direct` 卡再起 run,**没有 cwd 入参**。
181
+ * 工作目录只能来自已校验的非 global workspace —— 缺省 workspaceId 的卡不会在建卡时报错,
182
+ * 而是在派发时静默落到 `<configDir>/scratch`(N2),所以整条校验链必须卡在入口。
183
+ */
184
+ app.post("/api/ai-teams/:id/runs", asyncRoute(async (req, res) => {
185
+ try {
186
+ const body = bodyObject(req.body ?? {});
187
+ const workspaceId = text(body.workspaceId);
188
+ if (!workspaceId)
189
+ throw new Error("请选择工作项目。");
190
+ const workspace = storage.getWorkspace(workspaceId);
191
+ if (!workspace)
192
+ throw new Error("请选择工作项目。");
193
+ if (workspace.kind === "global") {
194
+ throw new Error("AI 团队不能在全局暂存工作区运行,请先选择或创建一个项目。");
195
+ }
196
+ if (!text(workspace.cwd))
197
+ throw new Error("这个项目还没有工作目录,请先选择一个项目。");
198
+ const note = boundedText(body.note, "开工说明", 1, TEAM_DIRECT_NOTE_MAX);
199
+ const team = storage.getAiTeam(req.params.id);
200
+ if (!team)
201
+ throw new Error("团队不存在。");
202
+ // 卡片上的执行配置取团队首选(负责人的第一个候选),看板与快捷提交都按它显示。
203
+ const preferredMember = team.members.find((member) => member.isLeader) ?? team.members[0];
204
+ const agent = preferredMember ? memberAgents(preferredMember)[0] ?? null : null;
205
+ const milestoneId = defaultMilestoneIdForWrite(storage);
206
+ const task = storage.transaction(() => {
207
+ const card = storage.createWandTask({
208
+ workspaceId: workspace.id,
209
+ title: provisionalTaskTitleFromDescription(note) || team.name,
210
+ description: note,
211
+ // 团队开工即进行中(§4.2 的 "processing" 对应看板列 doing;runner.start 不再回退它)。
212
+ status: "doing",
213
+ labels: [TEAM_DIRECT_LABEL],
214
+ milestoneId: scopedMilestoneId(storage, milestoneId, workspace.id) ?? milestoneId,
215
+ agent,
216
+ });
217
+ ensureWorkspaceTaskForBoardTask(storage, card);
218
+ return storage.getWandTask(card.id);
219
+ });
220
+ try {
221
+ const detail = await runner.start({ teamId: team.id, taskId: task.id, note });
222
+ res.status(202).json({ ...detail, taskId: task.id });
223
+ }
224
+ catch (error) {
225
+ // 不留空卡:起 run 失败就删掉刚建的卡;回滚本身失败只记日志,错误原样映射出去。
226
+ try {
227
+ storage.deleteWandTask(task.id);
228
+ }
229
+ catch (rollbackError) {
230
+ console.error(`[AiTeam] rollback direct task ${task.id} failed:`, getErrorMessage(rollbackError));
231
+ }
232
+ throw error;
233
+ }
234
+ }
235
+ catch (error) {
236
+ sendTeamError(res, error);
237
+ }
238
+ }));
239
+ app.get("/api/ai-team-runs", (req, res) => {
240
+ const teamId = typeof req.query.teamId === "string" ? req.query.teamId : undefined;
241
+ const limit = Math.min(200, Math.max(1, Number(req.query.limit) || 50));
242
+ res.json(runner.listRuns({ teamId, activeOnly: req.query.active === "1", limit }));
243
+ });
244
+ app.get("/api/wand-tasks/:id/team-runs", (req, res) => {
245
+ res.json(runner.listForTask(req.params.id));
246
+ });
247
+ app.post("/api/wand-tasks/:id/team-runs", asyncRoute(async (req, res) => {
248
+ try {
249
+ const body = bodyObject(req.body);
250
+ const teamId = text(body.teamId);
251
+ if (!teamId)
252
+ throw new Error("请选择团队。");
253
+ res.status(202).json(await runner.start({ teamId, taskId: req.params.id, note: text(body.note) }));
254
+ }
255
+ catch (error) {
256
+ sendTeamError(res, error);
257
+ }
258
+ }));
259
+ app.get("/api/ai-team-runs/:id", (req, res) => {
260
+ try {
261
+ res.json(runner.detail(req.params.id));
262
+ }
263
+ catch (error) {
264
+ sendTeamError(res, error);
265
+ }
266
+ });
267
+ const action = (path, handler) => {
268
+ app.post(path, asyncRoute(async (req, res) => {
269
+ try {
270
+ res.json(await handler(req.params.id, bodyObject(req.body ?? {}), req.params));
271
+ }
272
+ catch (error) {
273
+ sendTeamError(res, error);
274
+ }
275
+ }));
276
+ };
277
+ action("/api/ai-team-runs/:id/approve", (runId) => runner.approve(runId));
278
+ action("/api/ai-team-runs/:id/reject", (runId, body) => runner.reject(runId, text(body.feedback)));
279
+ action("/api/ai-team-runs/:id/reply", (runId, body) => runner.reply(runId, text(body.text)));
280
+ action("/api/ai-team-runs/:id/continue", (runId, body) => runner.continueRun(runId, Number(body.extraSteps) || 10));
281
+ action("/api/ai-team-runs/:id/steps/:stepId/complete", (runId, body, params) => (runner.completeStep(runId, params.stepId, text(body.report))));
282
+ action("/api/ai-team-runs/:id/stop", (runId) => runner.stop(runId));
283
+ }
@@ -0,0 +1,9 @@
1
+ import type { Express } from "express";
2
+ import type { AiTeamRunner } from "./ai-team-runner.js";
3
+ import type { SessionSnapshot } from "./types.js";
4
+ export declare function registerAttentionRoutes(app: Express, deps: {
5
+ sessions: {
6
+ listSlim(): SessionSnapshot[];
7
+ };
8
+ runner: AiTeamRunner;
9
+ }): void;
@@ -0,0 +1,26 @@
1
+ import { collectAttentionItems } from "./attention.js";
2
+ export function registerAttentionRoutes(app, deps) {
3
+ app.get("/api/attention", (_req, res) => {
4
+ const runs = deps.runner.listRuns({ limit: 80 });
5
+ const teamRuns = runs.map((run) => ({
6
+ id: run.id,
7
+ status: run.status,
8
+ statusDetail: run.statusDetail,
9
+ taskTitle: run.taskTitle,
10
+ teamName: run.team.name,
11
+ chatSessionId: run.chatSessionId,
12
+ updatedAt: run.updatedAt,
13
+ }));
14
+ const sessions = deps.sessions.listSlim().map((session) => ({
15
+ id: session.id,
16
+ title: session.title?.trim() || session.description?.trim() || "",
17
+ archived: session.archived,
18
+ status: session.status,
19
+ lastError: session.structuredState?.lastError ?? null,
20
+ worktreeMergeStatus: session.worktreeMergeStatus ?? null,
21
+ automationId: session.automationId ?? null,
22
+ updatedAt: session.endedAt || session.startedAt,
23
+ }));
24
+ res.json({ items: collectAttentionItems({ runs: teamRuns, sessions }) });
25
+ });
26
+ }
@@ -593,7 +593,7 @@ export function registerFileRoutes(app, deps) {
593
593
  const cwd = typeof req.query.cwd === "string" ? req.query.cwd : defaultCwd;
594
594
  const maxDepth = parseBoundedInteger(req.query.depth, 5, 0, 8);
595
595
  const maxResults = parseBoundedInteger(req.query.limit, 50, 1, 200);
596
- const ignoredDirectories = new Set([".git", "node_modules", ".next", "dist", "build", "coverage", ".wand-uploads"]);
596
+ const ignoredDirectories = new Set([".git", "node_modules", ".next", "dist", "build", "coverage", ".wand-uploads", ".wand-team"]);
597
597
  const maxVisitedEntries = 20_000;
598
598
  let resolvedCwd;
599
599
  try {
@@ -1,13 +1,14 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { PtyInputDeliveryError, SessionInputError } from "./process-manager.js";
3
3
  import { getDefaultModelForProvider, isExecutionMode } from "./config.js";
4
- import { blockWindowMessagesForTransport, sliceTurnBlocksForTransport, truncateMessagesForTransport, windowMessagesForTransport } from "./message-truncator.js";
4
+ import { alignedBlockStart, blockWindowMessagesForTransport, sliceTurnBlocksForTransport, truncateMessagesForTransport, visibleBlockCount, windowMessagesForTransport } from "./message-truncator.js";
5
5
  import { toSessionDetailDTO, toSessionListItemDTO } from "./session-transport.js";
6
6
  import { checkSessionWorktreeMergeabilityAsync, cleanupSessionWorktreeAsync, getWorktreeMergeErrorCode, mergeSessionWorktreeAsync, WorktreeMergeError, } from "./git-worktree.js";
7
7
  import { resolveSessionCwd } from "./session-cwd.js";
8
8
  import { resolveCommitAiContext } from "./session-ai-context.js";
9
9
  import { getGitStatusAsync, QuickCommitError, runQuickCommitWithFallback, runTagHead, runPush, generateCommitMessageOnly, } from "./git-quick-commit.js";
10
10
  import { getErrorMessage } from "./error-utils.js";
11
+ import { archiveCommitTasks } from "./commit-task-archive.js";
11
12
  import { buildIterationCommitContext, COMMIT_CONTEXT_MODE_PREF_KEY, isCommitContextMode, readCommitContextMode, resolveCommitContextInput, } from "./iteration-log.js";
12
13
  import { inferProviderFromCommand, isSessionProvider, providerCliCommand, SESSION_PROVIDERS } from "./session-provider.js";
13
14
  import { buildProviderResumeCommand, isProviderSessionId } from "./resume-policy.js";
@@ -327,6 +328,8 @@ async function startResumedPtySession(processes, storage, existingSession, sessi
327
328
  provider,
328
329
  model: existingSession.selectedModel ?? undefined,
329
330
  thinkingEffort: existingSession.thinkingEffort ?? undefined,
331
+ // 恢复会话也要把角色与规则带回来(PTY 的指令是拼在启动命令上的)。
332
+ systemPrompt: existingSession.systemPrompt ?? undefined,
330
333
  workspaceId: existingSession.workspaceId
331
334
  ?? resolveWorkspaceIdForNewSession(storage, projectCwdForSession(existingSession) || existingSession.cwd),
332
335
  workspaceTaskId: existingSession.workspaceTaskId,
@@ -449,6 +452,9 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
449
452
  app.get("/api/sessions", (_req, res) => {
450
453
  res.json(sessions.listSlim().map(toSessionListItemDTO));
451
454
  });
455
+ app.get("/api/sessions/provider-usage", (_req, res) => {
456
+ res.json(storage.countInteractiveSessionsByProvider());
457
+ });
452
458
  app.post("/api/structured-sessions", asyncRoute(async (req, res) => {
453
459
  const body = req.body;
454
460
  try {
@@ -474,6 +480,8 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
474
480
  : config.defaultThinkingEffort,
475
481
  workspaceId: resolveWorkspaceIdForNewSession(storage, cwd, body.workspaceId),
476
482
  workspaceTaskId: body.workspaceTaskId,
483
+ // 角色与规则走系统提示通道,不拼进 prompt(见 AGENTS.md「Session 输入契约」)。
484
+ systemPrompt: body.systemPrompt,
477
485
  ...origin,
478
486
  });
479
487
  onSessionCreated?.(snapshot.cwd);
@@ -876,8 +884,23 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
876
884
  const consumed = result.ok && result.commit?.hash && context.entryIds.length > 0
877
885
  ? storage.markIterationPromptsConsumed(context.entryIds, result.commit.hash)
878
886
  : 0;
887
+ let archivedTaskIds = [];
888
+ let archiveError;
889
+ if (body.archiveRelatedTasks === true && result.ok && result.commit?.hash) {
890
+ try {
891
+ // A commit is already durable, even when push failed. Archiving must not turn that
892
+ // successful commit into a retryable HTTP error (which would duplicate the commit).
893
+ archivedTaskIds = archiveCommitTasks(storage, snapshot, context.entryIds);
894
+ }
895
+ catch (error) {
896
+ archiveError = getErrorMessage(error, "归档关联任务失败。");
897
+ console.error("[QuickCommit] Failed to archive related tasks:", archiveError);
898
+ }
899
+ }
879
900
  res.json({
880
901
  ...result,
902
+ archivedTaskIds,
903
+ ...(archiveError ? { archiveError } : {}),
881
904
  commitContext: {
882
905
  source: commitMessageFromIteration(body) && context.digest ? "iteration" : "diff",
883
906
  entryIds: context.entryIds,
@@ -1111,6 +1134,7 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
1111
1134
  messageTotal: windowed.messageTotal,
1112
1135
  leadingBlockOffset: windowed.leadingBlockOffset,
1113
1136
  leadingBlockTotal: windowed.leadingBlockTotal,
1137
+ leadingVisibleCount: windowed.leadingVisibleCount,
1114
1138
  }));
1115
1139
  return;
1116
1140
  }
@@ -1149,14 +1173,16 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
1149
1173
  offset: windowed.messageOffset, total });
1150
1174
  return;
1151
1175
  }
1152
- // 块级翻页(iOS):?turn=<i>&blockOffset=<当前 leading 偏移>&blockLimit=<N>
1176
+ // 块级翻页(iOS / Android):?turn=<i>&blockOffset=<当前 leading 偏移>&blockLimit=<N>
1153
1177
  // 取该 turn 的 [start, blockOffset) 段(start = max(0, blockOffset - blockLimit))。
1178
+ // 起点同样吸附到干净边界(不切开折叠的工具段、不留下无头 tool_result),并回一份
1179
+ // blockVisible:被切掉的头部里还剩多少条用户可感知的块(默认收起的工具块不计入)。
1154
1180
  const rawTurn = parseInt(String(req.query.turn ?? ""), 10);
1155
1181
  if (Number.isFinite(rawTurn)) {
1156
1182
  const turnIndex = Math.min(Math.max(rawTurn, 0), Math.max(total - 1, 0));
1157
1183
  const turn = all[turnIndex];
1158
1184
  if (!turn) {
1159
- res.json({ turnIndex, blocks: [], blockOffset: 0, blockTotal: 0 });
1185
+ res.json({ turnIndex, blocks: [], blockOffset: 0, blockTotal: 0, blockVisible: 0 });
1160
1186
  return;
1161
1187
  }
1162
1188
  const blockTotal = turn.content.length;
@@ -1165,11 +1191,20 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
1165
1191
  const blockLimit = Math.min(Math.max(Number.isFinite(rawBlockLimit) ? rawBlockLimit : 40, 1), 200);
1166
1192
  const blockEnd = Math.min(Math.max(Number.isFinite(rawBlockOffset) ? rawBlockOffset : blockTotal, 0), blockTotal);
1167
1193
  const blockStart = Math.max(0, blockEnd - blockLimit);
1194
+ const cardDefaults = config.cardDefaults ?? {};
1195
+ const startOffset = alignedBlockStart(turn.content, cardDefaults, blockStart, blockEnd);
1168
1196
  const blocks = enrichStructuredMessages([{
1169
1197
  ...turn,
1170
- content: sliceTurnBlocksForTransport(turn, blockStart, blockEnd, config.cardDefaults ?? {}),
1198
+ content: sliceTurnBlocksForTransport(turn, startOffset, blockEnd, cardDefaults),
1171
1199
  }], snapshot.id)[0].content;
1172
- res.json({ wandProtocolVersion: WAND_PROTOCOL_VERSION, turnIndex, blocks, blockOffset: blockStart, blockTotal });
1200
+ res.json({
1201
+ wandProtocolVersion: WAND_PROTOCOL_VERSION,
1202
+ turnIndex,
1203
+ blocks,
1204
+ blockOffset: startOffset,
1205
+ blockTotal,
1206
+ blockVisible: visibleBlockCount(turn.content, cardDefaults, startOffset),
1207
+ });
1173
1208
  return;
1174
1209
  }
1175
1210
  const rawLimit = parseInt(String(req.query.limit ?? ""), 10);
@@ -1240,6 +1275,7 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
1240
1275
  reuseId: existingSession.id,
1241
1276
  cols: reqCols,
1242
1277
  rows: reqRows,
1278
+ systemPrompt: existingSession.systemPrompt ?? undefined,
1243
1279
  workspaceId: existingSession.workspaceId
1244
1280
  ?? resolveWorkspaceIdForNewSession(storage, projectCwdForSession(existingSession) || existingSession.cwd),
1245
1281
  workspaceTaskId: existingSession.workspaceTaskId,
@@ -210,7 +210,7 @@ export function registerSettingsRoutes(app, deps) {
210
210
  }
211
211
  const startedAt = Date.now();
212
212
  try {
213
- const text = await callSystemAiText("这是 Wand 系统 API 线路验收。请只回复 WAND_API_OK。", route, 30_000);
213
+ const text = await callSystemAiText({ system: "你是 Wand 的系统 API 线路探针。", prompt: "这是 Wand 系统 API 线路验收。请只回复 WAND_API_OK。" }, route, 30_000);
214
214
  if (!text.trim())
215
215
  throw new Error("系统 AI API 返回了空结果。");
216
216
  res.json({
@@ -19,6 +19,8 @@ export interface TaskRouteDependencies {
19
19
  /** 可注入的任务标题生成器;测试里换成同步桩,避免真的起 CLI。 */
20
20
  generateTitle?: typeof generateWandTaskTitle;
21
21
  }
22
+ /** 新认领只能挂到同项目且正在处理的任务;历史归属在父任务完成后仍保留。 */
23
+ export declare function parentTaskIdFrom(storage: WandStorage, value: unknown, workspaceId: string | null, childId?: string | null, existingParentId?: string | null): string | null;
22
24
  /**
23
25
  * 任务派发配置:provider / model / thinkingEffort 必须合法;mode 缺省时用 `fallbackMode`。
24
26
  * 老客户端(Android / iOS 尚未发 mode)不传 mode,PATCH / dispatch 时传任务当前值,
@@ -1,4 +1,4 @@
1
- import { getDefaultModelForProvider } from "./config.js";
1
+ import { dispatchAgentForTask } from "./agent-dispatch.js";
2
2
  import { asyncRoute } from "./express-async.js";
3
3
  import { getErrorMessage } from "./error-utils.js";
4
4
  import { bodyObject, sendRouteError, text } from "./server-request.js";
@@ -9,7 +9,7 @@ import { recordIterationPromptForTask } from "./iteration-log.js";
9
9
  import { resolveSystemAiContext } from "./session-ai-context.js";
10
10
  import { DEFAULT_WAND_TASK_AGENT_KIND, DEFAULT_WAND_TASK_AGENT_MODE, DEFAULT_WAND_TASK_PRIORITY, isWandTaskAgentKind, isWandTaskAgentMode, normalizeWandTaskAgentMode, WAND_MILESTONE_NAME_MAX_LENGTH } from "./task-types.js";
11
11
  import { archiveBoardTask, ensureWorkspaceTaskForBoardTask, isAutoNameableBoardTask, moveSessionToWorkspaceTask, syncClosedBoardTask, syncUngroupedSessionsToBoard, syncWorkspaceTaskToBoard, taskAutoNameSignature, taskAutoNameSourceText } from "./wand-task-sync.js";
12
- import { isSessionProvider, providerCliCommand } from "./session-provider.js";
12
+ import { isSessionProvider } from "./session-provider.js";
13
13
  import { isThinkingEffort } from "./structured-provider-common.js";
14
14
  const STATUSES = new Set(["todo", "doing", "done", "archived"]);
15
15
  const PRIORITIES = new Set(["none", "low", "medium", "high", "urgent"]);
@@ -54,7 +54,7 @@ function labelsFrom(value) {
54
54
  return value.filter((item) => typeof item === "string").map((item) => item.trim()).filter(Boolean).slice(0, 20);
55
55
  }
56
56
  /** 新认领只能挂到同项目且正在处理的任务;历史归属在父任务完成后仍保留。 */
57
- function parentTaskIdFrom(storage, value, workspaceId, childId = null, existingParentId = null) {
57
+ export function parentTaskIdFrom(storage, value, workspaceId, childId = null, existingParentId = null) {
58
58
  if (value === null || value === "")
59
59
  return null;
60
60
  if (typeof value !== "string" || !value.trim())
@@ -588,59 +588,25 @@ export function registerTaskRoutes(app, deps) {
588
588
  assertTaskWorkspaceMove(storage, task.id, workspaceId);
589
589
  task = storage.updateWandTask(task.id, { workspaceId }) ?? task;
590
590
  }
591
- const workspace = task.workspaceId ? storage.getWorkspace(task.workspaceId) : null;
592
- if (task.workspaceId && !workspace)
593
- throw new Error("任务所属项目已被删除,请重新指定。");
594
- const group = ensureWorkspaceTaskForBoardTask(storage, task);
595
- task = storage.getWandTask(task.id);
596
- const cwd = group.worktree?.path || group.cwd || workspace?.cwd || config.defaultCwd;
597
591
  const requestedPrompt = text(body.prompt);
598
592
  const existingSessions = storage.listWandTaskSessionIds(task.id).length;
599
593
  if (existingSessions > 0 && !requestedPrompt)
600
594
  throw new Error("请输入提示词。");
601
595
  const prompt = requestedPrompt || task.description.trim() || task.title || "执行此任务";
602
- const model = agent.model === "default" ? "" : agent.model;
603
- const provider = agent.provider;
604
- const resolvedModel = model || getDefaultModelForProvider(config, provider) || undefined;
605
- const session = agent.kind === "pty"
606
- ? await processes.start(providerCliCommand(provider), cwd, agent.mode, prompt, {
607
- provider,
608
- model: resolvedModel,
609
- thinkingEffort: agent.thinkingEffort,
610
- sessionSource: "automation",
611
- automationId: `wand-task:${task.id}`,
612
- workspaceId: group.workspaceId,
613
- workspaceTaskId: group.id,
614
- })
615
- : structured.createSession({
616
- cwd,
617
- mode: agent.mode,
618
- provider,
619
- model: resolvedModel,
620
- thinkingEffort: agent.thinkingEffort,
621
- worktreeEnabled: false,
622
- sessionSource: "automation",
623
- automationId: `wand-task:${task.id}`,
624
- workspaceId: group.workspaceId,
625
- workspaceTaskId: group.id,
626
- });
596
+ const { session, cwd, workspaceId } = await dispatchAgentForTask({ storage, config, structured: structured ?? null, processes: processes ?? null }, { task, agent, prompt, automationId: `wand-task:${task.id}` });
597
+ task = storage.getWandTask(task.id);
627
598
  storage.updateWandTask(task.id, { agent, status: task.status === "todo" ? "doing" : task.status });
628
- storage.bindWandTaskSession(task.id, session.id);
629
599
  // 派发也算一轮迭代里的改动意图:直接把任务的标题 / 描述记进迭代记录。
630
600
  recordIterationPromptForTask(storage, {
631
601
  sessionId: session.id,
632
602
  cwd,
633
- workspaceId: group.workspaceId,
603
+ workspaceId,
634
604
  milestoneId: task.milestoneId,
635
605
  taskId: task.id,
636
606
  title: task.title,
637
607
  detail: task.description || prompt,
638
608
  source: "dispatch",
639
609
  });
640
- if (agent.kind !== "pty") {
641
- const completion = structured.sendMessage(session.id, prompt);
642
- completion.catch((error) => console.error(`[WandTask] Agent dispatch failed for ${task.id}:`, error));
643
- }
644
610
  res.status(202).json({
645
611
  ok: true,
646
612
  taskId: task.id,
@@ -13,7 +13,7 @@ import { defaultMilestoneIdForWrite, resolvedMilestoneFields, scopedMilestoneId
13
13
  import { archiveBoardTaskForWorkspaceTask, archiveWorkspaceTask, ensureBoardTaskForWorkspaceTask, isUnnamedWorkspaceTaskName, moveSessionToWorkspaceTask, syncSidebarTasksFromBoard, UNNAMED_WORKSPACE_TASK_NAME } from "./wand-task-sync.js";
14
14
  import { isSessionProvider } from "./session-provider.js";
15
15
  import { firstLayoutTabId } from "./layout-tree.js";
16
- import { refreshAutoBoardTaskTitles } from "./server-task-routes.js";
16
+ import { parentTaskIdFrom, refreshAutoBoardTaskTitles } from "./server-task-routes.js";
17
17
  import { provisionalTaskTitleFromDescription } from "./task-title.js";
18
18
  function workspaceSessionTitle(session, names = {}) {
19
19
  return resolveSessionDisplayTitle(session, collectSessionTopicBlocklist({
@@ -25,7 +25,7 @@ function workspaceSessionTitle(session, names = {}) {
25
25
  function liveSession(session, registry) {
26
26
  return registry?.getLatest(session.id) ?? session;
27
27
  }
28
- function workspaceSessionSummary(session, names = {}, registry) {
28
+ function workspaceSessionSummary(session, names = {}, registry, teamChat) {
29
29
  const live = liveSession(session, registry);
30
30
  return {
31
31
  id: live.id,
@@ -40,6 +40,7 @@ function workspaceSessionSummary(session, names = {}, registry) {
40
40
  inFlight: live.structuredState?.inFlight === true,
41
41
  cwd: live.cwd,
42
42
  startedAt: live.startedAt,
43
+ ...(teamChat ? { teamChat } : {}),
43
44
  };
44
45
  }
45
46
  function cheapTasksRevision(storage, registry) {
@@ -150,6 +151,8 @@ function createTaskForWorkspace(storage, workspace, body) {
150
151
  const name = typeof body.name === "string" ? body.name.trim() : "";
151
152
  // 首个会话的提示词:任务没起名时用它先总结一个标题,不再留「未命名任务」占位。
152
153
  const description = typeof body.description === "string" ? body.description.trim() : "";
154
+ // 在建 workspace task / worktree 前校验,避免父任务失效时留下孤立任务。
155
+ const parentTaskId = parentTaskIdFrom(storage, body.parentTaskId ?? null, isGlobalWorkspace(workspace) ? null : workspace.id);
153
156
  // 留空(或老客户端回传的占位名)走自动命名:先用提示词总结,再交给模型改写。
154
157
  const named = name.length > 0 && !isUnnamedWorkspaceTaskName(name);
155
158
  const provisional = named ? "" : provisionalTaskTitleFromDescription(description);
@@ -193,6 +196,7 @@ function createTaskForWorkspace(storage, workspace, body) {
193
196
  ensureBoardTaskForWorkspaceTask(storage, task, workspace, {
194
197
  titleSource: named ? "user" : "auto",
195
198
  description,
199
+ parentTaskId,
196
200
  });
197
201
  return {
198
202
  task,
@@ -353,10 +357,11 @@ export function registerWorkspaceRoutes(app, storage, sessions, titleOptions = {
353
357
  return;
354
358
  }
355
359
  storage.touchWorkspace(workspace.id);
360
+ const teamChatMarkers = storage.listAiTeamRunChatMarkers();
356
361
  res.json({
357
362
  ...workspaceWithCounts(storage, workspace),
358
363
  sessions: storage.listSessionsByWorkspace(workspace.id).map((session) => ({
359
- ...workspaceSessionSummary(session, { workspaceName: workspace.name }, sessions),
364
+ ...workspaceSessionSummary(session, { workspaceName: workspace.name }, sessions, teamChatMarkers.get(session.id)),
360
365
  workspaceTaskId: session.workspaceTaskId,
361
366
  })),
362
367
  });
@@ -506,7 +511,9 @@ export function registerWorkspaceRoutes(app, storage, sessions, titleOptions = {
506
511
  // messages 大字段 JSON 解析一遍(单会话可达十几 MB,整表 ~30MB)。
507
512
  const allSessions = storage.loadSessionsSlim();
508
513
  const workspaces = storage.listWorkspaces();
509
- const summarize = (session, names = {}) => workspaceSessionSummary(session, names, sessions);
514
+ // 群聊标记一次查全:列表里每条会话只做 Map 命中,不逐个查库。
515
+ const teamChatMarkers = storage.listAiTeamRunChatMarkers();
516
+ const summarize = (session, names = {}) => workspaceSessionSummary(session, names, sessions, teamChatMarkers.get(session.id));
510
517
  // 目录自定义名(工作区名称):没有项目实体的合成目录也按这个名字显示。
511
518
  const directoryNames = storage.listSessionDirectoryNames();
512
519
  const customNameForCwd = (cwd) => {
@@ -804,6 +811,7 @@ export function registerWorkspaceRoutes(app, storage, sessions, titleOptions = {
804
811
  }
805
812
  storage.touchWorkspaceTask(task.id);
806
813
  const workspace = storage.getWorkspace(task.workspaceId);
814
+ const teamChatMarkers = storage.listAiTeamRunChatMarkers();
807
815
  res.json({
808
816
  ...task,
809
817
  ...resolvedMilestoneFields(storage, task.milestoneId),
@@ -812,7 +820,7 @@ export function registerWorkspaceRoutes(app, storage, sessions, titleOptions = {
812
820
  ...workspaceSessionSummary(session, {
813
821
  taskName: task.name,
814
822
  workspaceName: workspace?.name,
815
- }, sessions),
823
+ }, sessions, teamChatMarkers.get(session.id)),
816
824
  workspaceTaskId: task.id,
817
825
  })),
818
826
  });