@co0ontty/wand 4.77.0 → 4.78.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +21 -46
  2. package/dist/agent-dispatch.js +3 -2
  3. package/dist/ai-team-live.d.ts +25 -0
  4. package/dist/ai-team-live.js +135 -0
  5. package/dist/ai-team-prompts.d.ts +10 -6
  6. package/dist/ai-team-prompts.js +74 -41
  7. package/dist/ai-team-runner.d.ts +85 -3
  8. package/dist/ai-team-runner.js +336 -33
  9. package/dist/ai-team-types.d.ts +57 -2
  10. package/dist/ai-team-types.js +4 -0
  11. package/dist/build-info.json +3 -3
  12. package/dist/commit-task-archive.js +14 -18
  13. package/dist/config.js +14 -6
  14. package/dist/git-quick-commit.js +1 -1
  15. package/dist/middleware/rate-limit.d.ts +18 -4
  16. package/dist/middleware/rate-limit.js +55 -23
  17. package/dist/missions.js +0 -3
  18. package/dist/models.d.ts +6 -1
  19. package/dist/models.js +35 -0
  20. package/dist/path-repair.d.ts +6 -5
  21. package/dist/path-repair.js +3 -3
  22. package/dist/render-protocol.d.ts +1 -1
  23. package/dist/render-protocol.js +1 -1
  24. package/dist/server-ai-team-routes.d.ts +1 -0
  25. package/dist/server-ai-team-routes.js +28 -14
  26. package/dist/server-local-preview-routes.js +1 -1
  27. package/dist/server-session-routes.js +0 -3
  28. package/dist/server-settings-routes.d.ts +1 -1
  29. package/dist/server-settings-routes.js +3 -2
  30. package/dist/server-task-routes.js +16 -32
  31. package/dist/server-workspace-routes.js +31 -24
  32. package/dist/server.js +60 -21
  33. package/dist/storage.d.ts +54 -21
  34. package/dist/storage.js +485 -122
  35. package/dist/types.d.ts +7 -0
  36. package/dist/upload-routes.d.ts +2 -2
  37. package/dist/upload-routes.js +10 -3
  38. package/dist/wand-task-sync.d.ts +2 -34
  39. package/dist/wand-task-sync.js +0 -206
  40. package/dist/web-ui/asset-version.d.ts +5 -0
  41. package/dist/web-ui/asset-version.js +5 -0
  42. package/dist/web-ui/content/ai-teams.js +17 -8
  43. package/dist/web-ui/content/scripts.js +67 -66
  44. package/dist/web-ui/content/styles.css +1 -1
  45. package/dist/web-ui/content/tailwind.css +0 -21
  46. package/dist/web-ui/embedded-assets.js +8 -7
  47. package/dist/web-ui/index.js +9 -17
  48. package/dist/web-ui/scripts.d.ts +3 -1
  49. package/dist/web-ui/scripts.js +52 -25
  50. package/dist/web-ui/styles.d.ts +2 -0
  51. package/dist/web-ui/styles.js +12 -0
  52. package/package.json +13 -12
  53. package/scripts/install-render-binary.js +2 -2
  54. package/scripts/stage-render-binaries.js +1 -1
@@ -71,10 +71,12 @@ export interface AiTeam {
71
71
  }
72
72
  export type AiTeamRunStatus = "running" | "awaiting_approval" | "waiting_user" | "done" | "failed" | "stopped";
73
73
  export declare const AI_TEAM_ACTIVE_RUN_STATUSES: readonly AiTeamRunStatus[];
74
+ /** 终态:不会再有 live 变化,runner 据此清掉该 run 的推送指纹,避免长跑服务按 run 数攒字符串。 */
75
+ export declare const AI_TEAM_TERMINAL_RUN_STATUSES: readonly AiTeamRunStatus[];
74
76
  export interface AiTeamRun {
75
77
  id: string;
76
78
  teamId: string;
77
- /** 启动时的团队快照;之后改团队不影响已开始的运行。 */
79
+ /** 执行快照;仅 runner 在明确续跑/回复边界按规则刷新,展示层改名不写回这里。 */
78
80
  team: AiTeam;
79
81
  taskId: string;
80
82
  objective: string;
@@ -103,6 +105,24 @@ export interface AiTeamRunChatMarker {
103
105
  teamName: string;
104
106
  memberCount: number;
105
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
+ }
106
126
  export type AiTeamStepKind = "leader" | "work";
107
127
  export type AiTeamStepStatus = "queued" | "running" | "done" | "failed" | "skipped";
108
128
  export interface AiTeamStep {
@@ -137,11 +157,46 @@ export interface AiTeamRunSummary extends AiTeamRun {
137
157
  }
138
158
  /** detail() 里群聊回合的截尾条数(§4.4):面板只给最近这一段,完整会话走「打开群聊」。 */
139
159
  export declare const AI_TEAM_DETAIL_CHAT_TURNS = 200;
160
+ /** live 卡片文本的尾部保留上限(§4.9),超出部分回报 omittedChars 给前端显示「已省略前面 N 字」。 */
161
+ export declare const AI_TEAM_LIVE_TEXT_MAX_CHARS = 2000;
162
+ /**
163
+ * 一个正在干活的运行中步骤的实时快照(§4.9):Web 走 ai-team-step-live 推送,
164
+ * 移动端轮询 GET /api/ai-team-runs/:id/live,两边共用这个形状。
165
+ */
166
+ export interface AiTeamLiveStep {
167
+ stepId: string;
168
+ seq: number;
169
+ memberId: string;
170
+ memberName: string;
171
+ /** 该步实际使用候选的 provider(署名口径同 §3.6)。 */
172
+ provider: WandTaskAgent["provider"];
173
+ /**
174
+ * 该步实际使用候选的模型 id(provider 的原生 model id,`"default"` = 跟随服务端默认);
175
+ * 取不到候选就不填。中文标签由客户端做。
176
+ */
177
+ model?: string;
178
+ /** 该步实际使用候选的思考深度;同上,取不到不填。 */
179
+ thinkingEffort?: string;
180
+ sessionId: string;
181
+ state: AgentActivityState;
182
+ /** renderLiveStepText 的渲染结果,尾部保留、至多 AI_TEAM_LIVE_TEXT_MAX_CHARS 字。 */
183
+ text: string;
184
+ omittedChars: number;
185
+ updatedAt: string;
186
+ }
187
+ /** notifyLive 回调与 ai-team-step-live 通知的 payload(§4.9)。 */
188
+ export interface AiTeamLiveUpdate {
189
+ runId: string;
190
+ taskId: string;
191
+ steps: AiTeamLiveStep[];
192
+ }
140
193
  export interface AiTeamRunDetail {
141
194
  run: AiTeamRun;
142
195
  steps: AiTeamStep[];
143
196
  /** 运行中步骤所在会话的实时状态(等待授权 / 等待回答等)。 */
144
197
  memberStates: Record<string, AgentActivityState>;
145
- /** 群聊 relay 会话的消息,截尾 AI_TEAM_DETAIL_CHAT_TURNS 条;没有群聊会话的旧运行为空数组。 */
198
+ /** 仅供展示:运行时成员顺序/职责保持快照,仍存在的成员按 id 取当前名字/头像。不得用于派工。 */
199
+ displayTeam?: AiTeam;
200
+ /** 群聊 relay 会话的原始消息;作者和正文是历史记录,不因改名而改写。 */
146
201
  chatTurns: ConversationTurn[];
147
202
  }
@@ -28,5 +28,9 @@ export function agentKey(agent) {
28
28
  /** 上传头像在前端缩成小图后的上限(data URL 字符数)。 */
29
29
  export const AI_TEAM_AVATAR_MAX_CHARS = 60_000;
30
30
  export const AI_TEAM_ACTIVE_RUN_STATUSES = ["running", "awaiting_approval", "waiting_user"];
31
+ /** 终态:不会再有 live 变化,runner 据此清掉该 run 的推送指纹,避免长跑服务按 run 数攒字符串。 */
32
+ export const AI_TEAM_TERMINAL_RUN_STATUSES = ["done", "failed", "stopped"];
31
33
  /** detail() 里群聊回合的截尾条数(§4.4):面板只给最近这一段,完整会话走「打开群聊」。 */
32
34
  export const AI_TEAM_DETAIL_CHAT_TURNS = 200;
35
+ /** live 卡片文本的尾部保留上限(§4.9),超出部分回报 omittedChars 给前端显示「已省略前面 N 字」。 */
36
+ export const AI_TEAM_LIVE_TEXT_MAX_CHARS = 2000;
@@ -1,6 +1,6 @@
1
1
  {
2
- "commit": "6c4df97957956df8033a9691d42023ff53089519",
3
- "builtAt": "2026-09-27T07:05:14.345Z",
4
- "version": "4.77.0",
2
+ "commit": "a1e4f594e3431de8d5d816fba8ed84c8c4611dbf",
3
+ "builtAt": "2026-09-29T11:29:24.761Z",
4
+ "version": "4.78.0",
5
5
  "channel": "stable"
6
6
  }
@@ -22,22 +22,18 @@ export function archiveCommitTasks(storage, session, entryIds) {
22
22
  if (taskId)
23
23
  related.add(taskId);
24
24
  }
25
- return storage.transaction(() => {
26
- const archived = [];
27
- for (const id of related) {
28
- const task = storage.getWandTask(id);
29
- if (task?.status !== "done")
30
- continue;
31
- // The workspace is the authoritative directory for a card. Worktree sessions use the
32
- // parent project directory; a global/unassigned card only qualifies via this very session.
33
- const workspace = task.workspaceId ? storage.getWorkspace(task.workspaceId) : null;
34
- const sameDirectory = !!workspace && workspace.kind !== "global"
35
- && !!directory && normalizeProjectCwd(workspace.cwd) === directory;
36
- if (!sameDirectory && !(id === currentTaskId && (!workspace || workspace.kind === "global")))
37
- continue;
38
- storage.updateWandTask(id, { status: "archived" });
39
- archived.push(id);
40
- }
41
- return archived;
42
- });
25
+ const completed = [];
26
+ for (const id of related) {
27
+ const task = storage.getWandTask(id);
28
+ if (task?.status !== "done")
29
+ continue;
30
+ // Project directory constrains selected history; standalone cards qualify via this session.
31
+ const workspace = task.workspaceId ? storage.getWorkspace(task.workspaceId) : null;
32
+ const sameDirectory = !!workspace && workspace.kind !== "global"
33
+ && !!directory && normalizeProjectCwd(workspace.cwd) === directory;
34
+ if (!sameDirectory && !(id === currentTaskId && (!workspace || workspace.kind === "global")))
35
+ continue;
36
+ completed.push(id);
37
+ }
38
+ return storage.archiveCompletedWandTasks(completed);
43
39
  }
package/dist/config.js CHANGED
@@ -804,14 +804,22 @@ function mergeWithDefaults(input) {
804
804
  export function isExecutionMode(value) {
805
805
  return value === "assist" || value === "agent" || value === "agent-max" || value === "auto-edit" || value === "default" || value === "full-access" || value === "native" || value === "managed";
806
806
  }
807
+ /**
808
+ * 配置里的默认模型 id:`default` 是「跟随 CLI 默认」的哨兵值、不是模型 id,
809
+ * 历史设置里可能存过它(会一路传成 `--model default`),读到就当没配。
810
+ */
811
+ function configuredModelId(value) {
812
+ const trimmed = (value ?? "").trim();
813
+ return trimmed === "default" ? "" : trimmed;
814
+ }
807
815
  export function getProviderDefaultModels(config) {
808
816
  return {
809
- claude: (config.defaultModel ?? "").trim(),
810
- codex: (config.defaultCodexModel ?? "").trim(),
811
- opencode: (config.defaultOpenCodeModel ?? "").trim(),
812
- grok: (config.defaultGrokModel ?? "").trim(),
813
- qoder: (config.defaultQoderModel ?? "").trim(),
814
- pi: (config.defaultPiModel ?? "").trim(),
817
+ claude: configuredModelId(config.defaultModel),
818
+ codex: configuredModelId(config.defaultCodexModel),
819
+ opencode: configuredModelId(config.defaultOpenCodeModel),
820
+ grok: configuredModelId(config.defaultGrokModel),
821
+ qoder: configuredModelId(config.defaultQoderModel),
822
+ pi: configuredModelId(config.defaultPiModel),
815
823
  };
816
824
  }
817
825
  export function getDefaultModelForProvider(config, provider) {
@@ -768,7 +768,7 @@ function sanitizeSuggestedTag(value) {
768
768
  return cleaned;
769
769
  }
770
770
  async function generateCommitMessageWithTag(cwd, language, ai = {}, options = {}) {
771
- const { input, usedIteration } = await buildCommitPromptInput(cwd, options);
771
+ const { input } = await buildCommitPromptInput(cwd, options);
772
772
  let latestTag;
773
773
  try {
774
774
  latestTag = await runGitAsync(["describe", "--tags", "--abbrev=0"], cwd) || undefined;
@@ -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
@@ -4,7 +4,6 @@ import path from "node:path";
4
4
  import { buildMissionDiff } from "./mission-diff.js";
5
5
  import { recordIterationPromptForTask } from "./iteration-log.js";
6
6
  import { isSessionProvider } from "./session-provider.js";
7
- import { syncWorkspaceTaskToBoard } from "./wand-task-sync.js";
8
7
  const MAX_ATTEMPTS = 6;
9
8
  function nowIso() {
10
9
  return new Date().toISOString();
@@ -334,8 +333,6 @@ export class Missions {
334
333
  sessionSource: "automation",
335
334
  automationId: mission.id,
336
335
  });
337
- // 关联任务的派发会话立刻落到任务卡片上,不等下一次看板列表的兜底同步。
338
- syncWorkspaceTaskToBoard(this.storage, mission.taskId);
339
336
  attempt = {
340
337
  ...attempt,
341
338
  sessionId: session.id,
package/dist/models.d.ts CHANGED
@@ -64,7 +64,12 @@ export interface ModelCache {
64
64
  }
65
65
  /** 探针失败时的已知档位。下一次 CLI 成功应答会把它换掉。 */
66
66
  export declare const FALLBACK_PROVIDER_THINKING: ProviderThinkingEfforts;
67
- /** Immutable-looking snapshot returned to API clients. */
67
+ /**
68
+ * 目录里 `default` 项(「不传 --model」那一项)的标签补上服务端配置的默认模型名:
69
+ * CLI 自己报的文案(「跟随 Claude Code 默认」)看不出真正会跑哪个模型,两端选择器只能显示占位。
70
+ * 没配置、列表里没有这一项、或标签里已经写了这个 id 时原样返回(引用不变,便于上游比对缓存)。
71
+ */
72
+ export declare function withConfiguredDefaultModelLabels<T extends object>(catalog: T, defaults: Readonly<Record<string, string>>): T;
68
73
  export interface ModelCatalogSnapshot extends ModelCache {
69
74
  /** SHA-256 of the catalog excluding `refreshedAt`. Changes only with content. */
70
75
  revision: string;
package/dist/models.js CHANGED
@@ -79,6 +79,41 @@ function cloneThinkingEfforts(efforts) {
79
79
  pi: copy(efforts.pi),
80
80
  };
81
81
  }
82
+ /** Immutable-looking snapshot returned to API clients. */
83
+ /** provider → 目录里的模型列表字段(与 `/api/models` 的 payload 同源)。 */
84
+ const PROVIDER_MODEL_LIST_FIELDS = {
85
+ claude: "models",
86
+ codex: "codexModels",
87
+ opencode: "opencodeModels",
88
+ grok: "grokModels",
89
+ qoder: "qoderModels",
90
+ pi: "piModels",
91
+ };
92
+ /**
93
+ * 目录里 `default` 项(「不传 --model」那一项)的标签补上服务端配置的默认模型名:
94
+ * CLI 自己报的文案(「跟随 Claude Code 默认」)看不出真正会跑哪个模型,两端选择器只能显示占位。
95
+ * 没配置、列表里没有这一项、或标签里已经写了这个 id 时原样返回(引用不变,便于上游比对缓存)。
96
+ */
97
+ export function withConfiguredDefaultModelLabels(catalog, defaults) {
98
+ let next = null;
99
+ for (const [provider, field] of Object.entries(PROVIDER_MODEL_LIST_FIELDS)) {
100
+ const list = catalog[field];
101
+ const configured = (defaults[provider] ?? "").trim();
102
+ if (!configured || !Array.isArray(list))
103
+ continue;
104
+ const at = list.findIndex((entry) => (!!entry && typeof entry === "object" && entry.id === "default"));
105
+ if (at < 0)
106
+ continue;
107
+ const entry = list[at];
108
+ if (String(entry.label ?? "").includes(configured))
109
+ continue;
110
+ const updated = [...list];
111
+ updated[at] = { ...entry, label: `跟随服务端默认(${configured})` };
112
+ next ??= { ...catalog };
113
+ next[field] = updated;
114
+ }
115
+ return (next ?? catalog);
116
+ }
82
117
  function cloneModels(models) {
83
118
  return models.map((model) => ({
84
119
  ...model,
@@ -15,9 +15,10 @@
15
15
  * 修复分两层(都在 startServer() 开头跑):
16
16
  * 1. repairRuntimePath()(同步、cheap):扫常见工具链 bin 目录,把存在但 PATH
17
17
  * 里缺的"追加"到 process.env.PATH 末尾。
18
- * 2. deepRepairRuntimePath()(异步、贵一点):起一个 login shell 拉用户实际的
19
- * PATH(覆盖 ~/.bashrc、~/.zshrc、nvm/fnm/volta 的 shell-init 等所有动态
20
- * 逻辑),把里面 PATH 里我们还没的目录"前插"到 process.env.PATH 前面。这
18
+ * 2. deepRepairRuntimePath()(异步、贵一点):起一个交互式 login shell 拉用户实际的
19
+ * PATH(与 provider PTY 的 -lic 一致,覆盖 ~/.bashrc、~/.zshrc、
20
+ * nvm/fnm/volta 的 shell-init 等动态逻辑),把里面 PATH 里我们还没的目录
21
+ * "前插"到 process.env.PATH 前面。这
21
22
  * 是真正能修好"unit 里 PATH 太旧、用户 shell 里却好好的"这种场景的关键。
22
23
  *
23
24
  * 设计决策:
@@ -27,7 +28,7 @@
27
28
  * - 用 path.delimiter:Windows 用 `;`,POSIX 用 `:`,跨平台安全。
28
29
  * - 不重写 service unit:那需要 sudo,且语义太重;只在 install.sh 主动调
29
30
  * `wand service:install` 时才重新烧 PATH。
30
- * - login shell 用 -lc 跑,4 秒超时;失败就静默走同步路径,不阻塞启动。
31
+ * - provider PTY 用 -lic,PATH 探测也用 -lic(不是 -lc);4 秒超时,失败走同步路径。
31
32
  *
32
33
  * WAND_PATH_REPAIR_DISABLE=1 可以彻底关掉同步那层(极端情况下用户想完全控制 PATH 时用)。
33
34
  * WAND_PATH_REPAIR_DEEP_DISABLE=1 只关掉 login shell 探测,但保留同步追加。
@@ -57,7 +58,7 @@ export declare function repairRuntimePath(): PathRepairResult;
57
58
  * 这种动态注入的 PATH。
58
59
  *
59
60
  * 行为:
60
- * - 跑 `${shell} -l -c '...'` 拉 $PATH 和 command -v claude/codex/opencode(4s 超时)
61
+ * - 跑 `${shell} -lic '...'` 拉 $PATH 和 command -v claude/codex/opencode(4s 超时)
61
62
  * - 按 login shell 的 PATH 顺序重排 process.env.PATH,再把仅存在于 service 的
62
63
  * 目录追加回去。不能只前插新增目录:同一个 CLI 同时装在 nvm 和 Homebrew 时,
63
64
  * 两个目录本来都存在,旧实现不会重排,structured 与 PTY 会命中不同版本。
@@ -154,7 +154,7 @@ export function repairRuntimePath() {
154
154
  * 这种动态注入的 PATH。
155
155
  *
156
156
  * 行为:
157
- * - 跑 `${shell} -l -c '...'` 拉 $PATH 和 command -v claude/codex/opencode(4s 超时)
157
+ * - 跑 `${shell} -lic '...'` 拉 $PATH 和 command -v claude/codex/opencode(4s 超时)
158
158
  * - 按 login shell 的 PATH 顺序重排 process.env.PATH,再把仅存在于 service 的
159
159
  * 目录追加回去。不能只前插新增目录:同一个 CLI 同时装在 nvm 和 Homebrew 时,
160
160
  * 两个目录本来都存在,旧实现不会重排,structured 与 PTY 会命中不同版本。
@@ -254,7 +254,7 @@ function pickProbeShell(configured) {
254
254
  return null;
255
255
  }
256
256
  /**
257
- * 用 login shell 跑一段最小脚本,拿到用户实际的 PATH 和 provider CLI 解析路径。
257
+ * 用与 PTY provider 相同的交互式 login shell 跑最小脚本,拿到实际的 PATH 和 CLI 路径。
258
258
  * 用 \x1f(ASCII Unit Separator)作字段分隔符避免和路径里的字符冲突。
259
259
  */
260
260
  function probeLoginShell(shell, timeoutMs) {
@@ -265,7 +265,7 @@ function probeLoginShell(shell, timeoutMs) {
265
265
  `printf 'GROK\\x1f%s\\n' "$(command -v grok 2>/dev/null)"; ` +
266
266
  `printf 'QODERCLI\\x1f%s\\n' "$(command -v qodercli 2>/dev/null)"`;
267
267
  return new Promise((resolve, reject) => {
268
- const child = spawn(shell, ["-l", "-c", script], {
268
+ const child = spawn(shell, ["-lic", script], {
269
269
  env: {
270
270
  ...process.env,
271
271
  // 防止 PROMPT_COMMAND / PS1 等钩子往 stdout 喷东西干扰解析
@@ -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;
@@ -7,7 +7,6 @@ import { defaultMilestoneIdForWrite, scopedMilestoneId } from "./milestone-scope
7
7
  import { bodyObject, sendRouteError, text } from "./server-request.js";
8
8
  import { parseTaskAgent } from "./server-task-routes.js";
9
9
  import { provisionalTaskTitleFromDescription } from "./task-title.js";
10
- import { ensureWorkspaceTaskForBoardTask } from "./wand-task-sync.js";
11
10
  const NOT_FOUND = /不存在/;
12
11
  /** 团队页「直接开工」自动建卡的标记(§4.2 / Q3):看板靠它区分团队卡与手写卡。 */
13
12
  export const TEAM_DIRECT_LABEL = "team_direct";
@@ -134,6 +133,14 @@ export function parseAiTeamInput(value, existing, now) {
134
133
  }
135
134
  export function registerAiTeamRoutes(app, deps) {
136
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
+ };
137
144
  app.get("/api/ai-teams", (_req, res) => {
138
145
  res.json(storage.listAiTeams());
139
146
  });
@@ -154,6 +161,7 @@ export function registerAiTeamRoutes(app, deps) {
154
161
  throw new Error("团队不存在。");
155
162
  const team = parseAiTeamInput(req.body, existing, new Date().toISOString());
156
163
  storage.saveAiTeam(team);
164
+ notifyTeam(team.id);
157
165
  res.json(team);
158
166
  }
159
167
  catch (error) {
@@ -162,6 +170,7 @@ export function registerAiTeamRoutes(app, deps) {
162
170
  });
163
171
  app.delete("/api/ai-teams/:id", (req, res) => {
164
172
  storage.deleteAiTeam(req.params.id);
173
+ notifyTeam(req.params.id);
165
174
  res.json({ ok: true });
166
175
  });
167
176
  /** 团队详情:团队对象 + 该团队的运行摘要(修正 B14,Android AiTeamDetailScreen 与 Web 面板共用)。 */
@@ -203,19 +212,15 @@ export function registerAiTeamRoutes(app, deps) {
203
212
  const preferredMember = team.members.find((member) => member.isLeader) ?? team.members[0];
204
213
  const agent = preferredMember ? memberAgents(preferredMember)[0] ?? null : null;
205
214
  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);
215
+ const task = storage.createWandTask({
216
+ workspaceId: workspace.id,
217
+ title: provisionalTaskTitleFromDescription(note) || team.name,
218
+ description: note,
219
+ // 团队开工即进行中(§4.2 的 "processing" 对应看板列 doing;runner.start 不再回退它)。
220
+ status: "doing",
221
+ labels: [TEAM_DIRECT_LABEL],
222
+ milestoneId: scopedMilestoneId(storage, milestoneId, workspace.id) ?? milestoneId,
223
+ agent,
219
224
  });
220
225
  try {
221
226
  const detail = await runner.start({ teamId: team.id, taskId: task.id, note });
@@ -264,6 +269,15 @@ export function registerAiTeamRoutes(app, deps) {
264
269
  sendTeamError(res, error);
265
270
  }
266
271
  });
272
+ /** 运行中步骤的 live 文本(§4.9):Web 走 ai-team-step-live 推送,移动端轻量轮询这个端点。 */
273
+ app.get("/api/ai-team-runs/:id/live", (req, res) => {
274
+ try {
275
+ res.json({ runId: req.params.id, steps: runner.live(req.params.id) });
276
+ }
277
+ catch (error) {
278
+ sendTeamError(res, error);
279
+ }
280
+ });
267
281
  const action = (path, handler) => {
268
282
  app.post(path, asyncRoute(async (req, res) => {
269
283
  try {
@@ -71,7 +71,7 @@ function proxyLocation(value, target) {
71
71
  * uploads and other content types that Express does not need to inspect.
72
72
  */
73
73
  export function registerLocalPreviewRoutes(app, dependencies) {
74
- const handler = asyncRoute(async (req, res, next) => {
74
+ const handler = asyncRoute(async (req, res) => {
75
75
  const target = parseLocalPreviewTarget(String(req.params.host ?? ""), String(req.params.port ?? ""), req.url || "/");
76
76
  if (!target) {
77
77
  res.status(400).json({ error: "只允许预览本机(localhost)HTTP 服务。" });
@@ -19,7 +19,6 @@ import { registerStructuredResumeRoutes } from "./server-resume-routes.js";
19
19
  import { addHiddenSessionIds, removeHiddenSessionIds } from "./session-registry.js";
20
20
  import { enrichStructuredMessages, WAND_PROTOCOL_VERSION } from "./structured-client-protocol.js";
21
21
  import { asRecord, isStructuredImagePart } from "./structured-content.js";
22
- import { syncWorkspaceTaskToBoard } from "./wand-task-sync.js";
23
22
  import { buildDirectoryTree, normalizeSessionDirectory, } from "./session-directory-tree.js";
24
23
  import { projectCwdForSession, renameWorkspaceDirectory, resolveWorkspaceIdForNewSession, } from "./workspace-binding.js";
25
24
  export function parseExecutionMode(value, fallback) {
@@ -485,8 +484,6 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
485
484
  ...origin,
486
485
  });
487
486
  onSessionCreated?.(snapshot.cwd);
488
- // 会话一落地就挂到任务的看板卡片上,不等下一次看板列表的兜底同步。
489
- syncWorkspaceTaskToBoard(storage, snapshot.workspaceTaskId);
490
487
  const prompt = body.prompt?.trim();
491
488
  if (prompt) {
492
489
  if (body.respondImmediately === true) {
@@ -1,6 +1,6 @@
1
1
  import type { Express, Request, RequestHandler } from "express";
2
2
  import { type BuildInfo } from "./build-info.js";
3
- import type { ModelCatalogService } from "./models.js";
3
+ import { type ModelCatalogService } from "./models.js";
4
4
  import { type RuntimeConfigState } from "./runtime-config.js";
5
5
  import type { WandStorage } from "./storage.js";
6
6
  import type { WandConfig } from "./types.js";
@@ -6,6 +6,7 @@ import { getErrorMessage } from "./error-utils.js";
6
6
  import { asyncRoute } from "./express-async.js";
7
7
  import { sendRouteError } from "./server-request.js";
8
8
  import { getProviderDefaultModels, PREFERENCE_KEYS, saveConfig, validateCommitAiConfig, writePreferenceToStorage, } from "./config.js";
9
+ import { withConfiguredDefaultModelLabels } from "./models.js";
9
10
  import { DEPLOYMENT_CONFIG_KEYS } from "./runtime-config.js";
10
11
  import { callSystemAiText, discoverCliSystemAiConfigs, mergeSystemAiConfigs, normalizeSystemAiConfig, } from "./system-ai.js";
11
12
  function systemAiRouteIdentity(profile) {
@@ -399,7 +400,7 @@ export function registerSettingsRoutes(app, deps) {
399
400
  const cached = deps.modelCatalog.snapshot();
400
401
  const defaults = getProviderDefaultModels(config);
401
402
  res.json({
402
- ...cached,
403
+ ...withConfiguredDefaultModelLabels(cached, defaults),
403
404
  defaultModel: defaults.claude,
404
405
  defaultCodexModel: defaults.codex,
405
406
  defaultOpenCodeModel: defaults.opencode,
@@ -414,7 +415,7 @@ export function registerSettingsRoutes(app, deps) {
414
415
  const refreshed = await deps.modelCatalog.refresh({ verifyClaudeCandidates: true });
415
416
  const defaults = getProviderDefaultModels(config);
416
417
  res.json({
417
- ...refreshed,
418
+ ...withConfiguredDefaultModelLabels(refreshed, defaults),
418
419
  defaultModel: defaults.claude,
419
420
  defaultCodexModel: defaults.codex,
420
421
  defaultOpenCodeModel: defaults.opencode,