@co0ontty/wand 4.60.0 → 4.61.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.
@@ -1,11 +1,13 @@
1
1
  import { getDefaultModelForProvider } from "./config.js";
2
2
  import { asyncRoute } from "./express-async.js";
3
3
  import { getErrorMessage } from "./error-utils.js";
4
+ import { bodyObject, sendRouteError, text } from "./server-request.js";
4
5
  import { parseWandTaskAgent } from "./storage.js";
5
6
  import { generateWandTaskTitle, provisionalTaskTitleFromDescription, TASK_TITLE_MAX_LENGTH } from "./task-title.js";
6
7
  import { resolveSystemAiContext } from "./session-ai-context.js";
7
- import { archiveBoardTask, syncUngroupedSessionsToBoard } from "./wand-task-sync.js";
8
- const STATUSES = new Set(["todo", "doing", "done"]);
8
+ import { WAND_MILESTONE_NAME_MAX_LENGTH } from "./task-types.js";
9
+ import { archiveBoardTask, syncClosedBoardTask, syncUngroupedSessionsToBoard } from "./wand-task-sync.js";
10
+ const STATUSES = new Set(["todo", "doing", "done", "archived"]);
9
11
  const PRIORITIES = new Set(["none", "low", "medium", "high", "urgent"]);
10
12
  const AGENT_PROVIDERS = new Set(["claude", "codex", "opencode", "grok", "qoder", "pi"]);
11
13
  const AGENT_EFFORTS = new Set(["off", "standard", "deep", "max"]);
@@ -42,19 +44,29 @@ function dateValue(value) {
42
44
  }
43
45
  return value.trim();
44
46
  }
45
- function bodyObject(value) {
46
- if (!value || typeof value !== "object" || Array.isArray(value))
47
- throw new Error("请求体必须是对象。");
48
- return value;
49
- }
50
- function text(value) {
51
- return typeof value === "string" ? value.trim() : "";
52
- }
53
47
  function labelsFrom(value) {
54
48
  if (!Array.isArray(value))
55
49
  return [];
56
50
  return value.filter((item) => typeof item === "string").map((item) => item.trim()).filter(Boolean).slice(0, 20);
57
51
  }
52
+ /** 里程碑名:必填、去首尾空白、限长。 */
53
+ function milestoneNameFrom(value) {
54
+ const name = text(value).slice(0, WAND_MILESTONE_NAME_MAX_LENGTH);
55
+ if (!name)
56
+ throw new Error("请填写里程碑名称。");
57
+ return name;
58
+ }
59
+ /** 只有已存在的里程碑才能挂到任务上;null / 空串表示解绑。 */
60
+ function milestoneIdFrom(storage, value) {
61
+ if (value === null || value === undefined)
62
+ return null;
63
+ const id = text(value);
64
+ if (!id)
65
+ return null;
66
+ if (!storage.getWandMilestone(id))
67
+ throw new Error("未找到该里程碑。");
68
+ return id;
69
+ }
58
70
  /** 任务派发配置:provider / model / thinkingEffort 三者必须同时给出且合法。 */
59
71
  export function parseTaskAgent(value) {
60
72
  if (value === null)
@@ -78,6 +90,7 @@ function taskDto({ storage, sessions }, task) {
78
90
  return null;
79
91
  const workspace = task.workspaceId ? storage.getWorkspace(task.workspaceId) : null;
80
92
  const sessionIds = storage.listWandTaskSessionIds(task.id);
93
+ const milestone = task.milestoneId ? storage.getWandMilestone(task.milestoneId) : null;
81
94
  return {
82
95
  ...task,
83
96
  sessionIds,
@@ -88,6 +101,8 @@ function taskDto({ storage, sessions }, task) {
88
101
  workspace: workspace
89
102
  ? { id: workspace.id, name: workspace.name, cwd: workspace.cwd }
90
103
  : null,
104
+ // 卡片上要显示里程碑名字,直接在这里解出,免去前端再查一次列表。
105
+ milestone: milestone ? { id: milestone.id, name: milestone.name } : null,
91
106
  };
92
107
  }
93
108
  function taskSessionSummary(session) {
@@ -103,7 +118,7 @@ function taskSessionSummary(session) {
103
118
  };
104
119
  }
105
120
  function sendTaskError(res, error, fallback) {
106
- res.status(400).json({ error: getErrorMessage(error, fallback) });
121
+ sendRouteError(res, error, fallback);
107
122
  }
108
123
  /**
109
124
  * 用户没写标题时,先用描述首行当占位标题,再由后台模型总结一个更好的。
@@ -167,11 +182,68 @@ export function registerTaskRoutes(app, deps, sessionsLegacy, requireAdminLegacy
167
182
  console.error("[WandTask] Failed to sync ungrouped sessions onto the board:", getErrorMessage(error));
168
183
  }
169
184
  const workspaceId = typeof req.query.workspaceId === "string" ? req.query.workspaceId : undefined;
185
+ // 数组顺序即展示顺序:created_at 倒序,新创建的在上面。
170
186
  res.json(storage.listWandTasks(workspaceId).map((task) => dto(task)));
171
187
  });
172
188
  app.get("/api/wand-task-agent-defaults", guard, (_req, res) => {
173
189
  res.json(readTaskBoardLastAgent(storage));
174
190
  });
191
+ // ── 里程碑:跨项目全局列表,所有任务面板的「新建任务」共用 ──
192
+ app.get("/api/wand-milestones", guard, (_req, res) => {
193
+ const counts = storage.countWandTasksByMilestone();
194
+ res.json({
195
+ milestones: storage.listWandMilestones().map((milestone) => ({
196
+ ...milestone,
197
+ taskCount: counts[milestone.id] ?? 0,
198
+ })),
199
+ });
200
+ });
201
+ app.post("/api/wand-milestones", guard, (req, res) => {
202
+ try {
203
+ const body = bodyObject(req.body);
204
+ const name = milestoneNameFrom(body.name);
205
+ const duplicate = storage.listWandMilestones()
206
+ .find((milestone) => milestone.name.toLowerCase() === name.toLowerCase());
207
+ if (duplicate)
208
+ throw new Error(`里程碑「${duplicate.name}」已存在。`);
209
+ const created = storage.createWandMilestone({ name, dueDate: dateValue(body.dueDate) ?? null });
210
+ res.status(201).json({ ...created, taskCount: 0 });
211
+ }
212
+ catch (error) {
213
+ sendTaskError(res, error, "无法创建里程碑。");
214
+ }
215
+ });
216
+ app.patch("/api/wand-milestones/:id", guard, (req, res) => {
217
+ try {
218
+ const body = bodyObject(req.body);
219
+ const current = storage.getWandMilestone(req.params.id);
220
+ if (!current) {
221
+ res.status(404).json({ error: "未找到该里程碑。" });
222
+ return;
223
+ }
224
+ const patch = {};
225
+ if (body.name !== undefined) {
226
+ const name = milestoneNameFrom(body.name);
227
+ const duplicate = storage.listWandMilestones()
228
+ .find((milestone) => milestone.id !== current.id && milestone.name.toLowerCase() === name.toLowerCase());
229
+ if (duplicate)
230
+ throw new Error(`里程碑「${duplicate.name}」已存在。`);
231
+ patch.name = name;
232
+ }
233
+ if (body.dueDate !== undefined)
234
+ patch.dueDate = dateValue(body.dueDate) ?? null;
235
+ const updated = storage.updateWandMilestone(current.id, patch);
236
+ res.json({ ...updated, taskCount: storage.countWandTasksByMilestone()[current.id] ?? 0 });
237
+ }
238
+ catch (error) {
239
+ sendTaskError(res, error, "无法更新里程碑。");
240
+ }
241
+ });
242
+ app.delete("/api/wand-milestones/:id", guard, (req, res) => {
243
+ // 只解绑任务,不删任务;已经删过的 id 幂等返回 ok。
244
+ storage.deleteWandMilestone(req.params.id);
245
+ res.json({ ok: true });
246
+ });
175
247
  app.put("/api/wand-task-agent-defaults", guard, (req, res) => {
176
248
  try {
177
249
  const agent = parseTaskAgent(req.body);
@@ -202,6 +274,9 @@ export function registerTaskRoutes(app, deps, sessionsLegacy, requireAdminLegacy
202
274
  const agent = body.agent === undefined ? null : parseTaskAgent(body.agent);
203
275
  if (agent)
204
276
  writeTaskBoardLastAgent(storage, agent);
277
+ const labels = labelsFrom(body.labels);
278
+ const dueDate = dateValue(body.dueDate) ?? null;
279
+ const milestoneId = milestoneIdFrom(storage, body.milestoneId);
205
280
  const task = storage.createWandTask({
206
281
  workspaceId,
207
282
  title,
@@ -209,8 +284,9 @@ export function registerTaskRoutes(app, deps, sessionsLegacy, requireAdminLegacy
209
284
  description,
210
285
  status,
211
286
  priority,
212
- labels: labelsFrom(body.labels),
213
- dueDate: dateValue(body.dueDate) ?? null,
287
+ labels,
288
+ dueDate,
289
+ milestoneId,
214
290
  agent,
215
291
  });
216
292
  if (titleSource === "auto") {
@@ -254,6 +330,8 @@ export function registerTaskRoutes(app, deps, sessionsLegacy, requireAdminLegacy
254
330
  patch.labels = labelsFrom(body.labels);
255
331
  if (body.dueDate !== undefined)
256
332
  patch.dueDate = dateValue(body.dueDate) ?? null;
333
+ if (body.milestoneId !== undefined)
334
+ patch.milestoneId = milestoneIdFrom(storage, body.milestoneId);
257
335
  if (body.workspaceId !== undefined) {
258
336
  const workspaceId = body.workspaceId === null ? null : text(body.workspaceId);
259
337
  if (workspaceId && !storage.getWorkspace(workspaceId))
@@ -273,8 +351,8 @@ export function registerTaskRoutes(app, deps, sessionsLegacy, requireAdminLegacy
273
351
  res.status(404).json({ error: "未找到该任务。" });
274
352
  return;
275
353
  }
276
- if (task.status === "done")
277
- archiveBoardTask(storage, task.id);
354
+ if (task.status === "done" || task.status === "archived")
355
+ syncClosedBoardTask(storage, task);
278
356
  res.json(dto(storage.getWandTask(task.id) ?? task));
279
357
  }
280
358
  catch (error) {
@@ -2,6 +2,7 @@ import { Readable } from "node:stream";
2
2
  import { pipeline } from "node:stream/promises";
3
3
  import { getErrorMessage } from "./error-utils.js";
4
4
  import { asyncRoute } from "./express-async.js";
5
+ import { sendRouteError, stringQuery } from "./server-request.js";
5
6
  import { checkProviderCliUpdates, updateProviderClis, verifyProviderCliUpdateResults, } from "./provider-cli-updater.js";
6
7
  import { absoluteUrl, buildIosInstallPage, buildIosOtaManifest, buildItmsServicesUrl, collectIosOtaBlockers, formatIosAssetSize, inspectIpa, normalizePublicOrigin, publicOriginFromRequest, } from "./ios-ota.js";
7
8
  import { streamFileWithRange } from "./server-file-routes.js";
@@ -48,13 +49,23 @@ async function proxyGitHubApk(_req, res, asset, fetchImpl = fetch) {
48
49
  res.destroy();
49
50
  }
50
51
  }
52
+ /**
53
+ * 三个 update 检查接口都要求 `currentVersion`。缺参数是客户端 bug,不是服务端异常,
54
+ * 用 400 + 固定的英文文案(老客户端按字符串匹配)。
55
+ */
56
+ function requireCurrentVersion(req, res) {
57
+ const currentVersion = stringQuery(req.query.currentVersion);
58
+ if (!currentVersion) {
59
+ res.status(400).json({ error: "Missing currentVersion query parameter." });
60
+ return null;
61
+ }
62
+ return currentVersion;
63
+ }
51
64
  export function registerPublicUpdateRoutes(app, deps) {
52
65
  app.get("/api/android-apk-update", asyncRoute(async (req, res) => {
53
- const currentVersion = typeof req.query.currentVersion === "string" ? req.query.currentVersion.trim() : "";
54
- if (!currentVersion) {
55
- res.status(400).json({ error: "Missing currentVersion query parameter." });
66
+ const currentVersion = requireCurrentVersion(req, res);
67
+ if (currentVersion === null)
56
68
  return;
57
- }
58
69
  const channel = req.query.channel === "beta" ? "beta" : "stable";
59
70
  const latest = await deps.resolveLatestApk(channel);
60
71
  if (!latest) {
@@ -126,11 +137,9 @@ export function registerPublicUpdateRoutes(app, deps) {
126
137
  });
127
138
  }));
128
139
  app.get("/api/macos-dmg-update", asyncRoute(async (req, res) => {
129
- const currentVersion = typeof req.query.currentVersion === "string" ? req.query.currentVersion.trim() : "";
130
- if (!currentVersion) {
131
- res.status(400).json({ error: "Missing currentVersion query parameter." });
140
+ const currentVersion = requireCurrentVersion(req, res);
141
+ if (currentVersion === null)
132
142
  return;
133
- }
134
143
  const latest = await deps.resolveLatestDmg();
135
144
  if (!latest) {
136
145
  res.json({ updateAvailable: false, currentVersion, latestVersion: null, downloadUrl: null, source: null });
@@ -162,11 +171,9 @@ export function registerPublicUpdateRoutes(app, deps) {
162
171
  });
163
172
  }));
164
173
  app.get("/api/ios-ipa-update", asyncRoute(async (req, res) => {
165
- const currentVersion = typeof req.query.currentVersion === "string" ? req.query.currentVersion.trim() : "";
166
- if (!currentVersion) {
167
- res.status(400).json({ error: "Missing currentVersion query parameter." });
174
+ const currentVersion = requireCurrentVersion(req, res);
175
+ if (currentVersion === null)
168
176
  return;
169
- }
170
177
  const payload = await resolveIosOtaPayload(req, deps);
171
178
  if (!payload) {
172
179
  res.json({
@@ -344,7 +351,7 @@ export function registerAdminUpdateRoutes(app, deps) {
344
351
  });
345
352
  }
346
353
  catch (error) {
347
- res.status(500).json({ error: getErrorMessage(error, "检查 CLI 更新失败。") });
354
+ sendRouteError(res, error, "检查 CLI 更新失败。", 500);
348
355
  }
349
356
  }));
350
357
  app.post("/api/provider-cli-updates", requireAdmin, asyncRoute(async (req, res) => {
@@ -369,7 +376,7 @@ export function registerAdminUpdateRoutes(app, deps) {
369
376
  res.json({ ok: results.every((item) => item.ok), results, ...after, autoUpdate: storage.getConfigValue("autoUpdateProviderClis") === "true" });
370
377
  }
371
378
  catch (error) {
372
- res.status(500).json({ error: getErrorMessage(error, "更新 CLI 失败。") });
379
+ sendRouteError(res, error, "更新 CLI 失败。", 500);
373
380
  }
374
381
  finally {
375
382
  state.providerCliUpdateInFlight = false;
@@ -387,7 +394,7 @@ export function registerAdminUpdateRoutes(app, deps) {
387
394
  });
388
395
  }
389
396
  catch (error) {
390
- res.status(500).json({ error: getErrorMessage(error, "检查更新失败。") });
397
+ sendRouteError(res, error, "检查更新失败。", 500);
391
398
  }
392
399
  }));
393
400
  app.post("/api/update", requireAdmin, asyncRoute(async (_req, res) => {
@@ -437,7 +444,7 @@ export function registerAdminUpdateRoutes(app, deps) {
437
444
  });
438
445
  }
439
446
  catch (error) {
440
- res.status(500).json({ error: getErrorMessage(error, "更新失败。") });
447
+ sendRouteError(res, error, "更新失败。", 500);
441
448
  }
442
449
  finally {
443
450
  state.updateInFlight = false;
@@ -0,0 +1,16 @@
1
+ /**
2
+ * 浏览器扩展密码库 HTTP 路由(`/api/browser-extension/*`)。
3
+ *
4
+ * 挂在 `requireAuth` + `requirePasswordVault` scope 之后,因此这里的 handler
5
+ * 不再自己鉴权。原来它们挤在 `server.ts` 组合根中间,把启动逻辑切成了两半;
6
+ * 独立成模块后组合根只剩下顺序清晰的装配。
7
+ */
8
+ import type { Express } from "express";
9
+ import type { WandStorage } from "./storage.js";
10
+ import type { WandConfig } from "./types.js";
11
+ export interface VaultRoutesDependencies {
12
+ storage: WandStorage;
13
+ config: WandConfig;
14
+ useHttps: boolean;
15
+ }
16
+ export declare function registerVaultRoutes(app: Express, deps: VaultRoutesDependencies): void;
@@ -0,0 +1,125 @@
1
+ /**
2
+ * 浏览器扩展密码库 HTTP 路由(`/api/browser-extension/*`)。
3
+ *
4
+ * 挂在 `requireAuth` + `requirePasswordVault` scope 之后,因此这里的 handler
5
+ * 不再自己鉴权。原来它们挤在 `server.ts` 组合根中间,把启动逻辑切成了两半;
6
+ * 独立成模块后组合根只剩下顺序清晰的装配。
7
+ */
8
+ import { buildPasswordSecurityReport, generatePassword, generateTotpCode, normalizePasswordItemType, } from "./password-manager.js";
9
+ import { firstQueryStringValue, sendRouteError } from "./server-request.js";
10
+ import { resolveRequestServerUrl } from "./server-app-connect.js";
11
+ export function registerVaultRoutes(app, deps) {
12
+ const { storage, config, useHttps } = deps;
13
+ app.get("/api/browser-extension/status", (req, res) => {
14
+ res.json({
15
+ ok: true,
16
+ serverUrl: resolveRequestServerUrl(req, config, useHttps),
17
+ features: {
18
+ loginAutofill: true,
19
+ saveLogins: true,
20
+ federatedLoginMemory: true,
21
+ passwordGenerator: true,
22
+ totp: true,
23
+ cardsAndIdentities: true,
24
+ vaults: true,
25
+ securityReport: true,
26
+ passkeys: "webauthn-proxy",
27
+ },
28
+ });
29
+ });
30
+ app.get("/api/browser-extension/vaults", (_req, res) => {
31
+ res.json({ vaults: storage.listPasswordVaults() });
32
+ });
33
+ app.post("/api/browser-extension/vaults", (req, res) => {
34
+ try {
35
+ const vault = storage.createPasswordVault(req.body.name);
36
+ res.status(201).json({ vault });
37
+ }
38
+ catch (error) {
39
+ sendRouteError(res, error, "无法创建 vault。");
40
+ }
41
+ });
42
+ app.get("/api/browser-extension/items", (req, res) => {
43
+ const filter = {
44
+ q: firstQueryStringValue(req.query.q),
45
+ url: firstQueryStringValue(req.query.url),
46
+ vaultId: firstQueryStringValue(req.query.vaultId),
47
+ type: req.query.type ? normalizePasswordItemType(firstQueryStringValue(req.query.type)) : undefined,
48
+ limit: req.query.limit ? Number(firstQueryStringValue(req.query.limit)) : undefined,
49
+ };
50
+ res.json({ items: storage.listPasswordItems(filter) });
51
+ });
52
+ app.post("/api/browser-extension/items", (req, res) => {
53
+ try {
54
+ const item = storage.createPasswordItem(req.body);
55
+ res.status(201).json({ item });
56
+ }
57
+ catch (error) {
58
+ sendRouteError(res, error, "无法保存条目。");
59
+ }
60
+ });
61
+ app.get("/api/browser-extension/items/:id", (req, res) => {
62
+ const item = storage.getPasswordItem(req.params.id);
63
+ if (!item) {
64
+ res.status(404).json({ error: "条目不存在。" });
65
+ return;
66
+ }
67
+ res.json({ item });
68
+ });
69
+ app.put("/api/browser-extension/items/:id", (req, res) => {
70
+ try {
71
+ const item = storage.updatePasswordItem(req.params.id, req.body);
72
+ if (!item) {
73
+ res.status(404).json({ error: "条目不存在。" });
74
+ return;
75
+ }
76
+ res.json({ item });
77
+ }
78
+ catch (error) {
79
+ sendRouteError(res, error, "无法更新条目。");
80
+ }
81
+ });
82
+ app.delete("/api/browser-extension/items/:id", (req, res) => {
83
+ if (!storage.deletePasswordItem(req.params.id)) {
84
+ res.status(404).json({ error: "条目不存在。" });
85
+ return;
86
+ }
87
+ res.json({ ok: true });
88
+ });
89
+ app.post("/api/browser-extension/items/:id/use", (req, res) => {
90
+ const item = storage.touchPasswordItem(req.params.id);
91
+ if (!item) {
92
+ res.status(404).json({ error: "条目不存在。" });
93
+ return;
94
+ }
95
+ res.json({ item });
96
+ });
97
+ app.get("/api/browser-extension/generator/password", (req, res) => {
98
+ res.json({
99
+ password: generatePassword({
100
+ length: Number(firstQueryStringValue(req.query.length)),
101
+ digits: firstQueryStringValue(req.query.digits) !== "false",
102
+ symbols: firstQueryStringValue(req.query.symbols) !== "false",
103
+ }),
104
+ });
105
+ });
106
+ app.post("/api/browser-extension/totp/preview", (req, res) => {
107
+ try {
108
+ const { secret, digits, period } = req.body;
109
+ if (!secret) {
110
+ res.status(400).json({ error: "缺少 TOTP secret。" });
111
+ return;
112
+ }
113
+ res.json({
114
+ code: generateTotpCode(secret, Date.now(), digits ?? 6, period ?? 30),
115
+ period: period ?? 30,
116
+ });
117
+ }
118
+ catch (error) {
119
+ sendRouteError(res, error, "无法生成 TOTP。");
120
+ }
121
+ });
122
+ app.get("/api/browser-extension/security-report", (_req, res) => {
123
+ res.json({ report: buildPasswordSecurityReport(storage.listPasswordItems({ includeArchived: false, limit: 200 })) });
124
+ });
125
+ }
@@ -2,13 +2,14 @@ import path from "node:path";
2
2
  import crypto from "node:crypto";
3
3
  import { existsSync, statSync } from "node:fs";
4
4
  import { asyncRoute } from "./express-async.js";
5
+ import { sendRouteError } from "./server-request.js";
5
6
  import { getErrorMessage } from "./error-utils.js";
6
7
  import { expandHomePath } from "./middleware/path-safety.js";
7
8
  import { checkSessionWorktreeMergeabilityAsync, cleanupWorktreeSync, prepareSessionWorktree, resolveWorktreeTargetBranchAsync, } from "./git-worktree.js";
8
9
  import { collectSessionTopicBlocklist } from "./session-topic.js";
9
10
  import { resolveSessionDisplayTitle } from "./session-transport.js";
10
11
  import { GLOBAL_WORKSPACE_ID } from "./types.js";
11
- import { attachUnboundSessionsToWorkspace, backfillSessionWorkspaces, normalizeProjectCwd, projectCwdForSession, } from "./workspace-binding.js";
12
+ import { attachUnboundSessionsToWorkspace, backfillSessionWorkspaces, normalizeProjectCwd, projectCwdForSession, syncDirectoryNameForWorkspace, } from "./workspace-binding.js";
12
13
  import { archiveBoardTaskForWorkspaceTask, ensureBoardTaskForWorkspaceTask } from "./wand-task-sync.js";
13
14
  const PROVIDERS = new Set(["claude", "codex", "opencode", "grok", "qoder", "pi"]);
14
15
  function workspaceSessionTitle(session, names = {}) {
@@ -104,6 +105,12 @@ function taskRuntimeCwd(task, workspace) {
104
105
  function createTaskForWorkspace(storage, workspace, body) {
105
106
  const name = typeof body.name === "string" ? body.name.trim() : "";
106
107
  const taskName = name || "未命名任务";
108
+ // 里程碑是全局列表;建任务时可选,非法 id 直接报错而不是静默丢掉。
109
+ const requestedMilestoneId = typeof body.milestoneId === "string" ? body.milestoneId.trim() : "";
110
+ if (requestedMilestoneId && !storage.getWandMilestone(requestedMilestoneId)) {
111
+ throw new Error("未找到该里程碑。");
112
+ }
113
+ const milestoneId = requestedMilestoneId || null;
107
114
  let mountedCwd;
108
115
  if (body.cwd !== undefined && body.cwd !== null && String(body.cwd).trim()) {
109
116
  mountedCwd = resolveWorkspaceCwd(body.cwd);
@@ -140,6 +147,7 @@ function createTaskForWorkspace(storage, workspace, body) {
140
147
  name: taskName,
141
148
  cwd: storedCwd,
142
149
  worktree,
150
+ milestoneId,
143
151
  });
144
152
  ensureBoardTaskForWorkspaceTask(storage, task, workspace);
145
153
  return {
@@ -289,11 +297,13 @@ export function registerWorkspaceRoutes(app, storage, sessions) {
289
297
  cwd = resolveWorkspaceCwd(body.cwd);
290
298
  }
291
299
  catch (error) {
292
- res.status(400).json({ error: getErrorMessage(error, "目录无效。") });
300
+ sendRouteError(res, error, "目录无效。");
293
301
  return;
294
302
  }
295
303
  const defaultProvider = parseDefaultProvider(body.defaultProvider);
296
304
  const workspace = storage.createWorkspace({ name, cwd, defaultProvider });
305
+ // 项目名就是工作区显示名:目录自定义名一起写,保证各端显示一致。
306
+ storage.setSessionDirectoryName(cwd, name);
297
307
  const attached = attachUnboundSessionsToWorkspace(storage, workspace);
298
308
  res.status(201).json({ ...workspace, worktreeCount: 0, sessionCount: attached });
299
309
  }));
@@ -321,6 +331,11 @@ export function registerWorkspaceRoutes(app, storage, sessions) {
321
331
  return;
322
332
  }
323
333
  const body = req.body;
334
+ // 全局任务空间不是用户目录,不允许改名。
335
+ if (isGlobalWorkspace(existing) && typeof body.name === "string" && body.name.trim()) {
336
+ res.status(400).json({ error: "不能重命名全局任务空间。" });
337
+ return;
338
+ }
324
339
  const patch = {};
325
340
  if (typeof body.name === "string" && body.name.trim())
326
341
  patch.name = body.name.trim();
@@ -329,7 +344,7 @@ export function registerWorkspaceRoutes(app, storage, sessions) {
329
344
  patch.cwd = resolveWorkspaceCwd(body.cwd);
330
345
  }
331
346
  catch (error) {
332
- res.status(400).json({ error: getErrorMessage(error, "目录无效。") });
347
+ sendRouteError(res, error, "目录无效。");
333
348
  return;
334
349
  }
335
350
  }
@@ -343,6 +358,9 @@ export function registerWorkspaceRoutes(app, storage, sessions) {
343
358
  }
344
359
  storage.updateWorkspace(existing.id, patch);
345
360
  const updated = storage.getWorkspace(existing.id);
361
+ // 改名时同步目录自定义名,否则其它端(会话列表 / 项目视图)仍是旧名字。
362
+ if (updated)
363
+ syncDirectoryNameForWorkspace(storage, updated);
346
364
  res.json(updated ? workspaceWithCounts(storage, updated) : null);
347
365
  });
348
366
  // 删除项目;cascade=true 连带删会话,否则仅解绑
@@ -404,7 +422,7 @@ export function registerWorkspaceRoutes(app, storage, sessions) {
404
422
  });
405
423
  }
406
424
  catch (error) {
407
- res.status(400).json({ error: getErrorMessage(error, "创建任务失败。") });
425
+ sendRouteError(res, error, "创建任务失败。");
408
426
  }
409
427
  }));
410
428
  // 目录组为一级容器的任务聚合列表,供侧栏「任务」视图一次拉全。
@@ -430,6 +448,12 @@ export function registerWorkspaceRoutes(app, storage, sessions) {
430
448
  backfillSessionWorkspaces(storage);
431
449
  const workspaces = storage.listWorkspaces();
432
450
  const summarize = (session, names = {}) => workspaceSessionSummary(session, names, sessions);
451
+ // 目录自定义名(工作区名称):没有项目实体的合成目录也按这个名字显示。
452
+ const directoryNames = storage.listSessionDirectoryNames();
453
+ const customNameForCwd = (cwd) => {
454
+ const name = directoryNames.get(normalizeProjectCwd(cwd))?.trim();
455
+ return name || undefined;
456
+ };
433
457
  const visibleWorkspaces = workspaceFilter
434
458
  ? workspaces.filter((workspace) => workspace.id === workspaceFilter)
435
459
  : workspaces;
@@ -458,7 +482,9 @@ export function registerWorkspaceRoutes(app, storage, sessions) {
458
482
  return existing;
459
483
  const created = {
460
484
  workspaceId: id,
461
- workspaceName: normalized.split("/").filter(Boolean).at(-1) || normalized,
485
+ workspaceName: customNameForCwd(normalized)
486
+ ?? normalized.split("/").filter(Boolean).at(-1)
487
+ ?? normalized,
462
488
  workspaceCwd: normalized,
463
489
  synthetic: true,
464
490
  tasks: [],
@@ -529,7 +555,9 @@ export function registerWorkspaceRoutes(app, storage, sessions) {
529
555
  if (!synthetic) {
530
556
  synthetic = {
531
557
  workspaceId: id,
532
- workspaceName: resolved.split("/").filter(Boolean).at(-1) || resolved,
558
+ workspaceName: customNameForCwd(resolved)
559
+ ?? resolved.split("/").filter(Boolean).at(-1)
560
+ ?? resolved,
533
561
  workspaceCwd: resolved,
534
562
  synthetic: true,
535
563
  tasks: [],
@@ -594,7 +622,7 @@ export function registerWorkspaceRoutes(app, storage, sessions) {
594
622
  target = await resolveWorktreeTargetBranchAsync(workspace.cwd);
595
623
  }
596
624
  catch (error) {
597
- res.status(400).json({ error: getErrorMessage(error, "无法识别项目默认分支。") });
625
+ sendRouteError(res, error, "无法识别项目默认分支。");
598
626
  return;
599
627
  }
600
628
  const worktrees = await Promise.all(tasks.map(async (task) => {
@@ -671,7 +699,7 @@ export function registerWorkspaceRoutes(app, storage, sessions) {
671
699
  });
672
700
  }
673
701
  catch (error) {
674
- res.status(400).json({ error: getErrorMessage(error, "创建任务失败。") });
702
+ sendRouteError(res, error, "创建任务失败。");
675
703
  }
676
704
  }));
677
705
  // 任务详情:meta + 该任务下的会话;访问即更新 lastOpenedAt
@@ -708,6 +736,14 @@ export function registerWorkspaceRoutes(app, storage, sessions) {
708
736
  patch.name = body.name.trim();
709
737
  if (body.status === "active" || body.status === "done")
710
738
  patch.status = body.status;
739
+ if (body.milestoneId !== undefined) {
740
+ const milestoneId = typeof body.milestoneId === "string" ? body.milestoneId.trim() : "";
741
+ if (milestoneId && !storage.getWandMilestone(milestoneId)) {
742
+ sendRouteError(res, new Error("未找到该里程碑。"), "无法更新任务。");
743
+ return;
744
+ }
745
+ patch.milestoneId = milestoneId || null;
746
+ }
711
747
  storage.updateWorkspaceTask(existing.id, patch);
712
748
  if (patch.status === "done")
713
749
  archiveBoardTaskForWorkspaceTask(storage, existing.id);