@co0ontty/wand 4.89.2 → 4.89.5

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 (44) hide show
  1. package/README.md +12 -1
  2. package/browser-extension/icons/wand-128.png +0 -0
  3. package/browser-extension/icons/wand-16.png +0 -0
  4. package/browser-extension/icons/wand-32.png +0 -0
  5. package/browser-extension/icons/wand-48.png +0 -0
  6. package/dist/ai-team-employee-binding.js +2 -1
  7. package/dist/archive-policy.d.ts +17 -0
  8. package/dist/archive-policy.js +8 -0
  9. package/dist/build-info.json +3 -3
  10. package/dist/fixed-employee-avatar.d.ts +6 -0
  11. package/dist/fixed-employee-avatar.js +4 -0
  12. package/dist/npm-update-utils.js +14 -12
  13. package/dist/plush-avatar.d.ts +2 -1
  14. package/dist/plush-avatar.js +4 -2
  15. package/dist/retention.js +8 -10
  16. package/dist/server-employee-routes.js +9 -3
  17. package/dist/server-session-routes.js +27 -23
  18. package/dist/server-task-routes.js +10 -0
  19. package/dist/server-update-routes.js +12 -3
  20. package/dist/server-workspace-routes.js +12 -0
  21. package/dist/server.js +4 -4
  22. package/dist/session-registry.js +7 -0
  23. package/dist/storage.js +12 -4
  24. package/dist/structured-session-manager.d.ts +5 -0
  25. package/dist/structured-session-manager.js +43 -9
  26. package/dist/task-archive-policy.d.ts +4 -0
  27. package/dist/task-archive-policy.js +16 -0
  28. package/dist/tui/commands.d.ts +1 -1
  29. package/dist/tui/commands.js +6 -3
  30. package/dist/web-ui/chat-message-meta.d.ts +3 -1
  31. package/dist/web-ui/chat-message-meta.js +21 -1
  32. package/dist/web-ui/content/ai-teams.js +13 -13
  33. package/dist/web-ui/content/licenses/codex-LICENSE.txt +21 -0
  34. package/dist/web-ui/content/plush-avatar.js +34 -34
  35. package/dist/web-ui/content/scripts.js +304 -400
  36. package/dist/web-ui/content/styles.css +1 -1
  37. package/dist/web-ui/content/tailwind.css +1 -1
  38. package/dist/web-ui/embedded-assets.js +8 -8
  39. package/dist/web-ui/markdown.js +3 -1
  40. package/dist/web-ui/provider-identity.d.ts +1 -1
  41. package/dist/web-ui/provider-identity.js +4 -3
  42. package/package.json +1 -1
  43. package/dist/commit-task-archive.d.ts +0 -8
  44. package/dist/commit-task-archive.js +0 -65
package/README.md CHANGED
@@ -233,7 +233,7 @@ npm install -g @google/gemini-cli@latest
233
233
 
234
234
  #### 交互体验
235
235
 
236
- - **结构化对话** — 代码块语法高亮、工具调用折叠/展开、多问题分组渲染、Token 用量按轮累计
236
+ - **结构化对话** — 代码块语法高亮、工具调用折叠/展开、多问题分组渲染、输入区展示最近一轮 Token 用量与费用
237
237
  - **个性化角色** — 像素风猫咪头像(赛博虎妞 / 勤劳初二),支持自定义对话角色名称
238
238
  - **消息排队** — 在 AI 思考时可继续输入,消息自动排队发送
239
239
  - **文件浏览器** — 内置路径浏览和搜索功能
@@ -245,6 +245,17 @@ npm install -g @google/gemini-cli@latest
245
245
  - **多平台客户端** — Android / iOS / macOS 原生客户端,支持加密连接码分发、自动更新检查
246
246
  - **HTTPS** — 可选自签证书,适合远程或移动端访问
247
247
  - **版本管理** — 内置更新检查与升级提示,支持 stable/beta 双通道
248
+
249
+ Web 设置中的「Beta 通道」决定服务端 npm 更新来源:关闭时仅检查
250
+ `@co0ontty/wand@latest`,开启时检查 `@co0ontty/wand@beta`;「自动更新 Web 服务」
251
+ 独立决定是否自动安装。检查后安装锁定具体版本,避免 dist-tag 在安装期间移动。
252
+ 关闭 Beta 不会自动降级;需要立即返回较旧正式版时,点击「更新或重新安装」。
253
+
254
+ 正式 npm 发布由 `vX.Y.Z` Git tag 触发,只写入 `latest`,拒绝预发布版本。
255
+ master 的 Beta 发布只写入 `beta`,版本为下一补丁号加
256
+ `-beta.<构建序号>.<重试序号>.g<提交>`,例如 `4.89.5-beta.100.1.gabcdef0`。
257
+ 版本基数同时检查正式 Git tags 和 npm latest,兼容未推送 Git 的本地正式发布。
258
+ Beta 尚未发布、通道版本无效或 npm 暂不可达时,不自动安装其他通道。
248
259
  - **CLI 更新** — 在服务端设置中检查并快速更新 Claude Code、Codex、OpenCode、Qoder CLI、Pi 和 Gemini CLI,也可开启自动更新
249
260
 
250
261
  ### 配置
@@ -31,7 +31,8 @@ export function requireTeamEmployee(source, id) {
31
31
  }
32
32
  // The default partner's read projection includes short-term habits; they are not team snapshot data.
33
33
  return isDefaultSiliconEmployee(employee)
34
- ? defaultEmployeeDefinition(employee.agents, employee.updatedAt, source.getSiliconEmployeeDefinition?.(id) ?? employee, null)
34
+ ? { ...defaultEmployeeDefinition(employee.agents, employee.updatedAt, source.getSiliconEmployeeDefinition?.(id) ?? employee, null),
35
+ avatar: employee.avatar }
35
36
  : employee;
36
37
  }
37
38
  /** Resolve once per new run. Team duty/role/leadership remain local; no knowledge is read. */
@@ -0,0 +1,17 @@
1
+ /** Shared execution facts for archive controls and server enforcement. An idle PTY shell is safe. */
2
+ export interface ArchiveSessionFacts {
3
+ sessionKind?: string;
4
+ runner?: string;
5
+ status?: string;
6
+ ptyBusy?: boolean;
7
+ ptyCommandRunning?: boolean;
8
+ inFlight?: boolean;
9
+ structuredState?: {
10
+ inFlight?: boolean;
11
+ } | null;
12
+ permissionBlocked?: boolean;
13
+ pendingEscalation?: unknown;
14
+ queuedMessages?: readonly unknown[];
15
+ queuedMessageCount?: number;
16
+ }
17
+ export declare function isArchiveBlocked(session: ArchiveSessionFacts): boolean;
@@ -0,0 +1,8 @@
1
+ export function isArchiveBlocked(session) {
2
+ if (session.ptyBusy || session.ptyCommandRunning || session.inFlight || session.structuredState?.inFlight
3
+ || session.permissionBlocked || session.pendingEscalation || session.queuedMessages?.length || session.queuedMessageCount)
4
+ return true;
5
+ if (["thinking", "initializing", "starting", "reconnecting", "permission-blocked", "waiting-input", "waiting_input"].includes(session.status ?? ""))
6
+ return true;
7
+ return session.status === "running" && (session.sessionKind === "structured" || session.runner === "claude-cli-print");
8
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
- "commit": "d071c20396020d559bc1d7ed8bcc036d9379ca0e",
3
- "builtAt": "2026-10-10T03:24:36.047Z",
4
- "version": "4.89.2",
2
+ "commit": "ec13fefa558fb2d1236d377d46e6c3152e4eb79b",
3
+ "builtAt": "2026-10-10T07:01:42.094Z",
4
+ "version": "4.89.5",
5
5
  "channel": "stable"
6
6
  }
@@ -12,6 +12,12 @@ export declare function fixedEmployeeAvatar(identity: {
12
12
  id?: string | null;
13
13
  systemKey?: string | null;
14
14
  }): string | null;
15
+ /** One read-only avatar value for API projections and client drafts; never migrate saved data while displaying it. */
16
+ export declare function employeeAvatarValue(identity: {
17
+ id?: string | null;
18
+ systemKey?: string | null;
19
+ avatar?: string | null;
20
+ }): string;
15
21
  /** The avatar exception does not change any other built-in employee's edit policy. */
16
22
  export declare function isFixedAvatarEmployee(identity: {
17
23
  id?: string | null;
@@ -7,6 +7,10 @@ export const FIXED_EMPLOYEE_AVATARS = [
7
7
  export function fixedEmployeeAvatar(identity) {
8
8
  return FIXED_EMPLOYEE_AVATARS.find((entry) => entry.id === identity.id && entry.systemKey === identity.systemKey)?.avatar ?? null;
9
9
  }
10
+ /** One read-only avatar value for API projections and client drafts; never migrate saved data while displaying it. */
11
+ export function employeeAvatarValue(identity) {
12
+ return fixedEmployeeAvatar(identity) ?? identity.avatar ?? "";
13
+ }
10
14
  /** The avatar exception does not change any other built-in employee's edit policy. */
11
15
  export function isFixedAvatarEmployee(identity) {
12
16
  return fixedEmployeeAvatar(identity) !== null;
@@ -45,16 +45,19 @@ export function getInstallSpecForChannel(channel) {
45
45
  function cleanVersion(value) {
46
46
  return value.trim().replace(/^v/, "");
47
47
  }
48
- function getStableTagVersion(version) {
49
- return cleanVersion(version).split("+")[0]?.split("-")[0] ?? cleanVersion(version);
50
- }
51
- function computeUpdateAvailable(currentVersion, latestVersion, channel) {
48
+ function acceptedVersion(version, channel) {
49
+ const value = version ? cleanVersion(version) : "";
50
+ // Never turn a prerelease into a nonexistent stable version or install a tag
51
+ // that changed between checking and installing.
52
+ const stable = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
53
+ const beta = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
54
+ return (channel === "stable" ? stable : beta).test(value) ? value : null;
55
+ }
56
+ function computeUpdateAvailable(currentVersion, latestVersion) {
52
57
  if (!latestVersion)
53
58
  return false;
54
59
  const current = cleanVersion(currentVersion);
55
- const target = channel === "stable"
56
- ? getStableTagVersion(latestVersion)
57
- : cleanVersion(latestVersion);
60
+ const target = latestVersion;
58
61
  // Invalid/manual labels cannot be ordered safely, so offer the selected
59
62
  // dist-tag. Valid versions only update forward. In particular, Wand's local
60
63
  // X.Y.Z-debug.* build represents code after X.Y.Z and must not be replaced
@@ -64,15 +67,14 @@ function computeUpdateAvailable(currentVersion, latestVersion, channel) {
64
67
  return compareWandInstallOrder(target, current) > 0;
65
68
  }
66
69
  export function buildPackageUpdateInfo(currentVersion, channel, latestVersion) {
67
- const latest = latestVersion?.trim() || null;
68
- const stableLatest = channel === "stable" && latest ? getStableTagVersion(latest) : latest;
70
+ const latest = acceptedVersion(latestVersion, channel);
69
71
  return {
70
72
  channel,
71
73
  current: cleanVersion(currentVersion),
72
- latest: stableLatest,
73
- updateAvailable: computeUpdateAvailable(currentVersion, stableLatest, channel),
74
+ latest,
75
+ updateAvailable: computeUpdateAvailable(currentVersion, latest),
74
76
  distTag: getUpdateDistTag(channel),
75
- installSpec: getInstallSpecForChannel(channel),
77
+ installSpec: latest ? `${PACKAGE_NAME}@${latest}` : getInstallSpecForChannel(channel),
76
78
  };
77
79
  }
78
80
  async function viewPackageVersionAsync(channel, timeoutMs = NPM_VIEW_TIMEOUT_MS) {
@@ -84,12 +84,13 @@ export type PlushRenderConfig = PlushAvatarConfig | PlushCatAvatarConfig;
84
84
  export declare function isPlushCatAvatar(config: PlushRenderConfig): config is PlushCatAvatarConfig;
85
85
  export declare function parsePlushCatAvatar(value: string): PlushCatAvatarConfig | null;
86
86
  export declare function encodePlushCatAvatar(config: PlushCatAvatarConfig): string;
87
- /** Common identity projection; only an explicit cat value opts into this exception. */
87
+ /** All employee surfaces share the API's avatar policy, including stale fixed-identity drafts. */
88
88
  export declare function resolveEmployeeAvatar(identity: PlushAvatarIdentity): PlushRenderConfig | null;
89
89
  export interface PlushAvatarIdentity {
90
90
  id?: string | null;
91
91
  name?: string | null;
92
92
  avatar?: string | null;
93
+ systemKey?: string | null;
93
94
  }
94
95
  /** Only the exact known v1 contract is accepted for explicit configuration; no URL or future-version guessing. */
95
96
  export declare function parsePlushAvatar(value: string): PlushAvatarConfig | null;
@@ -1,3 +1,4 @@
1
+ import { employeeAvatarValue } from "./fixed-employee-avatar.js";
1
2
  /** Shared, versioned employee avatar contract. Keep v1 option IDs and seed rules stable across clients. */
2
3
  export const PLUSH_SHAPES = [
3
4
  { id: "heart", label: "心形" },
@@ -41,9 +42,10 @@ export function encodePlushCatAvatar(config) {
41
42
  throw new Error("毛绒猫头像配置无效。");
42
43
  return value;
43
44
  }
44
- /** Common identity projection; only an explicit cat value opts into this exception. */
45
+ /** All employee surfaces share the API's avatar policy, including stale fixed-identity drafts. */
45
46
  export function resolveEmployeeAvatar(identity) {
46
- return parsePlushCatAvatar(identity.avatar ?? "") ?? resolvePlushAvatar(identity);
47
+ const avatar = employeeAvatarValue(identity);
48
+ return parsePlushCatAvatar(avatar) ?? resolvePlushAvatar({ ...identity, avatar });
47
49
  }
48
50
  function hasId(options, id) {
49
51
  return options.some(option => option.id === id);
package/dist/retention.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { AI_TEAM_ACTIVE_RUN_STATUSES } from "./ai-team-types.js";
2
+ import { isArchiveBlocked } from "./archive-policy.js";
1
3
  import { cleanupWorktreeSync } from "./git-worktree.js";
2
4
  import { taskRetentionWindows } from "./task-retention.js";
3
5
  /**
@@ -12,13 +14,7 @@ export const RETENTION_ARCHIVED_MS = 7 * 24 * 60 * 60 * 1000;
12
14
  * 空闲 zsh 不算。真正挡住清理的是正在回复、或停在权限/确认上。
13
15
  */
14
16
  export function isRetentionBusy(session) {
15
- if (session.ptyBusy === true || session.structuredState?.inFlight === true)
16
- return true;
17
- if (session.permissionBlocked || session.pendingEscalation)
18
- return true;
19
- if ((session.sessionKind ?? "pty") === "pty" || session.runner === "pty")
20
- return false;
21
- return session.status === "running";
17
+ return isArchiveBlocked(session);
22
18
  }
23
19
  function isIdleLivePty(session) {
24
20
  return session.status === "running" && !isRetentionBusy(session)
@@ -62,7 +58,7 @@ export function runRetentionSweep(deps, now = Date.now()) {
62
58
  const snapshots = deps.sessions.listSlim();
63
59
  for (const session of snapshots) {
64
60
  // 真正还在处理的会话不能归档或杀掉。空闲 PTY 壳不算。
65
- if (isRetentionBusy(session))
61
+ if (isArchiveBlocked(session))
66
62
  continue;
67
63
  if (session.archived) {
68
64
  if (taskWindows.archivedMs === null)
@@ -113,9 +109,11 @@ export function runRetentionSweep(deps, now = Date.now()) {
113
109
  else
114
110
  sessionsByTask.set(taskId, [session]);
115
111
  }
112
+ const activeTeamTaskIds = new Set(deps.storage.listAiTeamRuns({ statuses: AI_TEAM_ACTIVE_RUN_STATUSES }).map(run => run.taskId));
116
113
  for (const task of deps.storage.listWandTasks()) {
117
114
  const bound = task.workspaceTaskId ? sessionsByTask.get(task.workspaceTaskId) ?? [] : [];
118
- const running = bound.some((session) => isRetentionBusy(session));
115
+ const running = bound.some((session) => isArchiveBlocked(session))
116
+ || activeTeamTaskIds.has(task.id) || (!!task.workspaceTaskId && activeTeamTaskIds.has(task.workspaceTaskId));
119
117
  if (task.status === "archived") {
120
118
  if (running || taskWindows.archivedMs === null)
121
119
  continue;
@@ -138,7 +136,7 @@ export function runRetentionSweep(deps, now = Date.now()) {
138
136
  result.purgedTasks += 1;
139
137
  continue;
140
138
  }
141
- if (running || taskWindows.idleMs === null)
139
+ if (running || task.status === "doing" || taskWindows.idleMs === null)
142
140
  continue;
143
141
  const lastActivity = taskLastActivity(deps.storage, task, bound);
144
142
  if (lastActivity === null || now - lastActivity < taskWindows.idleMs)
@@ -1,4 +1,4 @@
1
- import { isFixedAvatarEmployee } from "./fixed-employee-avatar.js";
1
+ import { employeeAvatarValue, isFixedAvatarEmployee } from "./fixed-employee-avatar.js";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { AI_TEAM_MAX_CANDIDATES, SILICON_EMPLOYEE_AVATAR_MAX_CHARS, agentKey, isBuiltinSiliconEmployee, parseSiliconEmployeeTags, siliconEmployeeTags, } from "./ai-team-types.js";
4
4
  import { getErrorMessage } from "./error-utils.js";
@@ -93,7 +93,8 @@ export function parseSystemEmployeeAgents(value, existing) {
93
93
  if (incoming === undefined || (incoming === null && !isFixedAvatarEmployee(existing)))
94
94
  continue;
95
95
  const current = existing[field.key];
96
- const isSame = typeof incoming === "string" && incoming === current;
96
+ const isSame = typeof incoming === "string" && (incoming === current
97
+ || (field.key === "avatar" && isFixedAvatarEmployee(existing) && incoming === employeeAvatarValue(existing)));
97
98
  if (!isSame) {
98
99
  throw new Error(`该员工是内置的,${field.label}不可修改。`);
99
100
  }
@@ -214,6 +215,11 @@ export function registerSiliconEmployeeRoutes(app, deps) {
214
215
  console.error("[SiliconEmployee] definition notification failed:", getErrorMessage(error));
215
216
  }
216
217
  };
218
+ // Identity is live data, not a versioned asset; old HTTP caches must not retain another face.
219
+ app.use("/api/silicon-employees", (_req, res, next) => {
220
+ res.setHeader("Cache-Control", "no-store");
221
+ next();
222
+ });
217
223
  app.get("/api/silicon-employees", (req, res) => {
218
224
  try {
219
225
  const includeArchived = req.query.includeArchived === "true" || req.query.includeArchived === "1";
@@ -361,7 +367,7 @@ export function registerSiliconEmployeeRoutes(app, deps) {
361
367
  : parseSiliconEmployeeInput(input, existing, now);
362
368
  storage.saveSiliconEmployee(employee);
363
369
  notifyEmployee(employee.id);
364
- res.json(employee);
370
+ res.json(storage.getSiliconEmployee(employee.id));
365
371
  }
366
372
  catch (error) {
367
373
  sendEmployeeError(res, error);
@@ -11,7 +11,6 @@ import { resolveSessionCwd } from "./session-cwd.js";
11
11
  import { resolveSystemAiContext } from "./session-ai-context.js";
12
12
  import { getGitStatusAsync, QuickCommitError, runQuickCommitWithFallback, runTagHead, runPush, generateCommitMessageOnly, } from "./git-quick-commit.js";
13
13
  import { getErrorMessage } from "./error-utils.js";
14
- import { archiveCommitTasks, archiveCommitStandaloneSessions } from "./commit-task-archive.js";
15
14
  import { buildIterationCommitContext, COMMIT_CONTEXT_MODE_PREF_KEY, isCommitContextMode, readCommitContextMode, resolveCommitContextInput, } from "./iteration-log.js";
16
15
  import { inferProviderFromCommand, isSessionProvider, providerCliCommand, SESSION_PROVIDERS } from "./session-provider.js";
17
16
  import { selectEmployeeCandidate } from "./silicon-employee-dispatch.js";
@@ -597,6 +596,29 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
597
596
  sendRouteError(res, error, "切换运行目录失败。");
598
597
  }
599
598
  });
599
+ app.post("/api/sessions/:id/employee", (req, res) => {
600
+ const employeeId = req.body?.employeeId;
601
+ if (typeof employeeId !== "string" || !employeeId.trim()) {
602
+ res.status(400).json({ error: "请选择有效的员工身份。" });
603
+ return;
604
+ }
605
+ const id = req.params.id;
606
+ const owner = sessions.ownerOf(id);
607
+ if (!owner) {
608
+ res.status(404).json({ error: "未找到该会话。" });
609
+ return;
610
+ }
611
+ if (owner !== "structured") {
612
+ res.status(400).json({ error: "只有新建空白结构化对话可以更换员工。" });
613
+ return;
614
+ }
615
+ try {
616
+ res.json(sessionResponseDTO(structured.setSessionEmployee(id, employeeId.trim()), req));
617
+ }
618
+ catch (error) {
619
+ sendRouteError(res, error, "切换员工失败。");
620
+ }
621
+ });
600
622
  app.post("/api/sessions/:id/provider", (req, res) => {
601
623
  const provider = req.body?.provider;
602
624
  const engine = req.body?.engine;
@@ -1119,29 +1141,11 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
1119
1141
  const consumed = result.ok && result.commit?.hash && context.entryIds.length > 0
1120
1142
  ? storage.markIterationPromptsConsumed(context.entryIds, result.commit.hash)
1121
1143
  : 0;
1122
- let archivedTaskIds = [];
1123
- const archivedSessionIds = [];
1124
- let archiveError;
1125
- if (body.archiveRelatedTasks === true && result.ok && result.commit?.hash) {
1126
- try {
1127
- // A commit is already durable, even when push failed. Archiving must not turn that
1128
- // successful commit into a retryable HTTP error (which would duplicate the commit).
1129
- const openedTaskId = typeof body.workspaceTaskId === "string" ? body.workspaceTaskId.trim() : "";
1130
- archivedTaskIds = archiveCommitTasks(storage, snapshot, context.entryIds, {
1131
- workspaceTaskId: openedTaskId || null,
1132
- });
1133
- archiveCommitStandaloneSessions(storage, sessions, snapshot, archivedSessionIds);
1134
- }
1135
- catch (error) {
1136
- archiveError = getErrorMessage(error, "归档任务与会话失败。");
1137
- console.error("[QuickCommit] Failed to archive related tasks:", archiveError);
1138
- }
1139
- }
1140
1144
  res.json({
1141
1145
  ...result,
1142
- archivedTaskIds,
1143
- archivedSessionIds,
1144
- ...(archiveError ? { archiveError } : {}),
1146
+ // Compatibility for older clients: Git never changes task/session lifecycle.
1147
+ archivedTaskIds: [],
1148
+ archivedSessionIds: [],
1145
1149
  commitContext: {
1146
1150
  source: commitMessageFromIteration(body) && context.digest ? "iteration" : "diff",
1147
1151
  entryIds: context.entryIds,
@@ -1747,7 +1751,7 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
1747
1751
  res.json(sessionResponseDTO(updated, req));
1748
1752
  }
1749
1753
  catch (error) {
1750
- sendRouteError(res, error, "无法归档会话。");
1754
+ sendRouteError(res, error, "无法归档会话。", 409);
1751
1755
  }
1752
1756
  });
1753
1757
  app.post("/api/sessions/:id/unarchive", asyncRoute(async (req, res) => {
@@ -1,3 +1,4 @@
1
+ import { canArchiveTask } from "./task-archive-policy.js";
1
2
  import { dispatchAgentForTask, resolveTaskDispatchTarget } from "./agent-dispatch.js";
2
3
  import { asyncRoute } from "./express-async.js";
3
4
  import { getErrorMessage } from "./error-utils.js";
@@ -207,6 +208,7 @@ function taskDto({ storage, sessions }, task) {
207
208
  const milestone = resolvedMilestoneFields(storage, task.milestoneId);
208
209
  return {
209
210
  ...task,
211
+ canArchive: canArchiveTask(storage, sessions, task.id),
210
212
  sessionIds,
211
213
  sessions: sessionIds.flatMap((id) => {
212
214
  const session = sessions?.getLatest(id) ?? storage.getSession(id);
@@ -587,6 +589,10 @@ export function registerTaskRoutes(app, deps) {
587
589
  if (body.sortOrder !== undefined && Number.isFinite(Number(body.sortOrder))) {
588
590
  patch.sortOrder = Math.floor(Number(body.sortOrder));
589
591
  }
592
+ if (patch.status === "archived" && !canArchiveTask(storage, sessions, currentTask.id)) {
593
+ res.status(409).json({ error: "任务尚未完成或仍有执行,完成后才能归档。" });
594
+ return;
595
+ }
590
596
  const task = storage.updateWandTask(req.params.id, patch);
591
597
  if (!task) {
592
598
  res.status(404).json({ error: "未找到该任务。" });
@@ -637,6 +643,10 @@ export function registerTaskRoutes(app, deps) {
637
643
  }
638
644
  });
639
645
  app.delete("/api/wand-tasks/:id", (req, res) => {
646
+ if (storage.getWandTask(req.params.id) && !canArchiveTask(storage, sessions, req.params.id)) {
647
+ res.status(409).json({ error: "任务尚未完成或仍有执行,完成后才能归档。" });
648
+ return;
649
+ }
640
650
  const archived = storage.updateWandTask(req.params.id, { status: "archived" });
641
651
  if (!archived) {
642
652
  res.status(404).json({ error: "未找到该任务。" });
@@ -449,9 +449,14 @@ export function registerAdminUpdateRoutes(app, deps) {
449
449
  }
450
450
  state.updateInFlight = true;
451
451
  try {
452
- const info = await deps.checkLatestPackageVersion(deps.getUpdateChannel(), true);
452
+ const channel = deps.getUpdateChannel();
453
+ const info = await deps.checkLatestPackageVersion(channel, true);
454
+ if (channel !== deps.getUpdateChannel()) {
455
+ res.status(409).json({ error: "更新通道已切换,请重新检查更新。" });
456
+ return;
457
+ }
453
458
  if (!info.latest) {
454
- res.status(502).json({ error: "无法连接到 npm registry。" });
459
+ res.status(502).json({ error: "npm 所选通道暂无有效版本,或 registry 暂时不可用。" });
455
460
  return;
456
461
  }
457
462
  if (!canUseDetachedUpdateHelper()) {
@@ -529,7 +534,11 @@ export function registerAdminUpdateRoutes(app, deps) {
529
534
  });
530
535
  app.post("/api/update-channel", requireAdmin, (req, res) => {
531
536
  const body = (req.body ?? {});
532
- const channel = body.channel === "beta" ? "beta" : "stable";
537
+ if (body.channel !== "beta" && body.channel !== "stable") {
538
+ res.status(400).json({ error: "更新通道必须为 stable 或 beta。" });
539
+ return;
540
+ }
541
+ const channel = body.channel;
533
542
  storage.setConfigValue("updateChannel", channel);
534
543
  res.json({ channel });
535
544
  });
@@ -1,3 +1,5 @@
1
+ import { canArchiveTask } from "./task-archive-policy.js";
2
+ import { isArchiveBlocked } from "./archive-policy.js";
1
3
  import path from "node:path";
2
4
  import crypto from "node:crypto";
3
5
  import { existsSync, statSync } from "node:fs";
@@ -44,6 +46,7 @@ function workspaceSessionSummary(session, names = {}, registry, team = {}) {
44
46
  providerCliActive: live.providerCliActive,
45
47
  providerCliExitCode: live.providerCliExitCode,
46
48
  inFlight: live.structuredState?.inFlight === true,
49
+ canArchive: !isArchiveBlocked(live),
47
50
  cwd: live.cwd,
48
51
  startedAt: live.startedAt,
49
52
  employeeId: live.employeeId,
@@ -88,6 +91,7 @@ function cheapTasksRevision(storage, registry, includeArchived = false) {
88
91
  session.ptyCommandRunning === true ? 1 : 0,
89
92
  session.providerCliActive ? 1 : 0,
90
93
  session.structuredState?.inFlight === true ? 1 : 0,
94
+ isArchiveBlocked(session) ? 1 : 0,
91
95
  session.title ?? "",
92
96
  session.workspaceId ?? "",
93
97
  session.workspaceTaskId ?? "",
@@ -610,6 +614,7 @@ export function registerWorkspaceRoutes(app, storage, sessions, titleOptions = {
610
614
  }
611
615
  return snapshots;
612
616
  };
617
+ const sessionRegistry = sessions;
613
618
  const appendTask = (target, workspace, task) => {
614
619
  const visibleSessions = taskSessions(task.id).filter(sessionVisible);
615
620
  const sessions = visibleSessions
@@ -620,6 +625,7 @@ export function registerWorkspaceRoutes(app, storage, sessions, titleOptions = {
620
625
  }));
621
626
  target.tasks.push({
622
627
  ...task,
628
+ canArchive: (() => { const card = storage.getWandTaskByWorkspaceTaskId(task.id); return card ? canArchiveTask(storage, sessionRegistry, card.id) : true; })(),
623
629
  // 没单独指定迭代的任务读作默认迭代(id 与名字成对)。
624
630
  ...resolvedMilestoneFields(storage, task.milestoneId),
625
631
  cwd: taskRuntimeCwd(task, workspace),
@@ -746,6 +752,7 @@ export function registerWorkspaceRoutes(app, storage, sessions, titleOptions = {
746
752
  .filter((task) => req.query.includeArchived === "1" || !task.archived)
747
753
  .map((task) => ({
748
754
  ...task,
755
+ canArchive: (() => { const card = storage.getWandTaskByWorkspaceTaskId(task.id); return card ? canArchiveTask(storage, sessions, card.id) : true; })(),
749
756
  ...resolvedMilestoneFields(storage, task.milestoneId),
750
757
  })));
751
758
  });
@@ -923,6 +930,11 @@ export function registerWorkspaceRoutes(app, storage, sessions, titleOptions = {
923
930
  res.status(404).json({ error: "未找到该任务。" });
924
931
  return;
925
932
  }
933
+ const card = storage.getWandTaskByWorkspaceTaskId(existing.id);
934
+ if (card && !canArchiveTask(storage, sessions, card.id)) {
935
+ res.status(409).json({ error: "任务尚未完成或仍有执行,完成后才能归档。" });
936
+ return;
937
+ }
926
938
  storage.archiveWorkspaceTask(existing.id);
927
939
  const archived = storage.getWorkspaceTask(existing.id);
928
940
  res.json({
package/dist/server.js CHANGED
@@ -821,8 +821,8 @@ export async function startServer(config, configPath, options = {}) {
821
821
  // 把语言偏好暴露给前端做 UI 文案 i18n。后端原本只用它给 Claude 拼 system prompt,
822
822
  // 前端没收到 → "SUBAGENT" / "Read" 这些 UI label 一直是英文,跟用户设的中文不匹配。
823
823
  language: config.language ?? "",
824
- updateAvailable: cachedUpdateInfo?.updateAvailable ?? false,
825
- latestVersion: cachedUpdateInfo?.latest ?? null,
824
+ updateAvailable: cachedUpdateInfo?.channel === getUpdateChannel() ? cachedUpdateInfo.updateAvailable : false,
825
+ latestVersion: cachedUpdateInfo?.channel === getUpdateChannel() ? cachedUpdateInfo.latest : null,
826
826
  updateChannel: getUpdateChannel(),
827
827
  currentVersion: DISPLAY_VERSION,
828
828
  packageVersion: PKG_VERSION,
@@ -844,7 +844,7 @@ export async function startServer(config, configPath, options = {}) {
844
844
  requireAdminOrSessionPreferences,
845
845
  packageInfo: { version: DISPLAY_VERSION, name: PKG_NAME, nodeVersion: PKG_NODE_REQ, repoUrl: PKG_REPO_URL },
846
846
  buildInfo: BUILD_INFO,
847
- getCachedUpdateInfo: () => cachedUpdateInfo,
847
+ getCachedUpdateInfo: () => cachedUpdateInfo?.channel === getUpdateChannel() ? cachedUpdateInfo : null,
848
848
  getUpdateChannel,
849
849
  getDistributionSettings,
850
850
  getGithubConnector: () => getGithubConnectorStatus(storage),
@@ -1327,7 +1327,7 @@ export async function startServer(config, configPath, options = {}) {
1327
1327
  return;
1328
1328
  const channel = getUpdateChannel();
1329
1329
  const info = await checkLatestPackageVersion(channel, true);
1330
- if (shuttingDown)
1330
+ if (shuttingDown || channel !== getUpdateChannel())
1331
1331
  return;
1332
1332
  cachedUpdateInfo = info;
1333
1333
  if (!info.latest || !info.updateAvailable)
@@ -1,3 +1,4 @@
1
+ import { isArchiveBlocked } from "./archive-policy.js";
1
2
  import { cleanupWorktreeSync } from "./git-worktree.js";
2
3
  import { inferProviderFromCommand } from "./session-provider.js";
3
4
  export function deleteProviderNativeHistory(deleter, provider, providerSessionId, cwd) {
@@ -180,6 +181,12 @@ export class SessionRegistry {
180
181
  return this.processes.stop(id);
181
182
  }
182
183
  setArchived(id, archived) {
184
+ const current = this.getLatest(id);
185
+ if (current && !!current.archived === archived && (!archived || current.archivedAt))
186
+ return current;
187
+ if (archived && current && isArchiveBlocked(current)) {
188
+ throw Object.assign(new Error("会话仍在执行或等待处理,完成后才能归档。"), { statusCode: 409 });
189
+ }
183
190
  const owner = this.ownerOf(id);
184
191
  if (owner === "structured")
185
192
  return this.structured.setSessionArchived(id, archived);
package/dist/storage.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import crypto from "node:crypto";
2
- import { FIXED_EMPLOYEE_AVATARS, fixedEmployeeAvatar, isFixedAvatarEmployee } from "./fixed-employee-avatar.js";
2
+ import { FIXED_EMPLOYEE_AVATARS, employeeAvatarValue, fixedEmployeeAvatar, isFixedAvatarEmployee } from "./fixed-employee-avatar.js";
3
3
  import { CONVERSATION_OWNER } from "./conversation-types.js";
4
4
  import { chmodSync, existsSync, mkdirSync } from "node:fs";
5
5
  import path from "node:path";
@@ -2715,6 +2715,8 @@ export class WandStorage {
2715
2715
  return rows.map((row) => this.projectDefaultEmployee(mapSiliconEmployeeRow(row)));
2716
2716
  }
2717
2717
  projectDefaultEmployee(employee) {
2718
+ // Presentation only: legacy columns stay untouched until an explicit migration.
2719
+ employee = { ...employee, avatar: employeeAvatarValue(employee) };
2718
2720
  if (employee.systemKey !== DEFAULT_EMPLOYEE_KEY)
2719
2721
  return employee;
2720
2722
  const state = this.getUserMemoryState();
@@ -2745,7 +2747,10 @@ export class WandStorage {
2745
2747
  /** 内置「系统运维」员工;未创建(或被删除过)时返回 null。 */
2746
2748
  getSystemSiliconEmployee(key = SYSTEM_EMPLOYEE_KEY) {
2747
2749
  const row = this.db.prepare("SELECT * FROM silicon_employees WHERE system_key = ?").get(key);
2748
- return row ? mapSiliconEmployeeRow(row) : null;
2750
+ if (!row)
2751
+ return null;
2752
+ const employee = mapSiliconEmployeeRow(row);
2753
+ return { ...employee, avatar: employeeAvatarValue(employee) };
2749
2754
  }
2750
2755
  /** Seed only when missing; restart never overwrites user profile, candidates, or legacy avatars. */
2751
2756
  ensureSystemSiliconEmployee(seed = {}) {
@@ -2786,8 +2791,9 @@ export class WandStorage {
2786
2791
  throw new Error("内置员工身份不可修改或冒用。");
2787
2792
  if (existing && !isFixedAvatarEmployee(existing))
2788
2793
  throw new Error("内置员工身份冲突,不能覆盖已有记录。");
2789
- if (employee.avatar !== (existing?.avatar ?? target.avatar))
2794
+ if (employee.avatar !== (existing?.avatar ?? target.avatar) && employee.avatar !== employeeAvatarValue(employee)) {
2790
2795
  throw new Error("该员工是内置的,头像不可修改。");
2796
+ }
2791
2797
  if (existing && JSON.stringify(employee.tags) !== JSON.stringify(existing.tags))
2792
2798
  throw new Error("该员工是内置的,标签不可修改。");
2793
2799
  if (employee.archivedAt !== existing?.archivedAt)
@@ -2808,7 +2814,9 @@ export class WandStorage {
2808
2814
  tags_json = excluded.tags_json,
2809
2815
  system_key = excluded.system_key,
2810
2816
  archived_at = excluded.archived_at,
2811
- updated_at = excluded.updated_at`).run(employee.id, employee.name, employee.duty, employee.prompt, employee.avatar, JSON.stringify(employee.agents), employee.systemKey ?? null, employee.archivedAt ?? null, employee.createdAt, employee.updatedAt, JSON.stringify(tags));
2817
+ updated_at = excluded.updated_at`).run(employee.id, employee.name, employee.duty, employee.prompt,
2818
+ // Accept a canonical read echo without persisting a display projection over a legacy value.
2819
+ target && existing ? existing.avatar : employee.avatar, JSON.stringify(employee.agents), employee.systemKey ?? null, employee.archivedAt ?? null, employee.createdAt, employee.updatedAt, JSON.stringify(tags));
2812
2820
  }
2813
2821
  /** Explicit, atomic migration; not called from GET, config load, seeds, or ordinary saves.
2814
2822
  * Compare all expected avatar values first; change only avatar, including preserving timestamps.
@@ -236,6 +236,11 @@ export declare class StructuredSessionManager {
236
236
  setSessionDirectory(sessionId: string, cwd: string): SessionSnapshot;
237
237
  /** 空白对话原位换执行工具;引擎也是工具身份,不跨工具搬历史或能力设置。 */
238
238
  setSessionProvider(sessionId: string, provider: SessionProvider, engine?: "cli" | "sdk"): SessionSnapshot;
239
+ /** 首次发送前显式选择真实员工;同一会话、目录和未发送输入的归属不变。 */
240
+ setSessionEmployee(sessionId: string, employeeId: string): SessionSnapshot;
241
+ private requireBlankSessionSelection;
242
+ private blankSessionToolSelection;
243
+ private saveBlankSessionSelection;
239
244
  /**
240
245
  * 新建 pi 结构化会话前的引擎裁决。
241
246
  *