@co0ontty/wand 4.68.1 → 4.69.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,6 +1,6 @@
1
1
  {
2
- "commit": "8d5c20f13265b4217eb6ab6196bc97c1f3b03702",
3
- "builtAt": "2026-09-17T03:16:25.802Z",
4
- "version": "4.68.1",
2
+ "commit": "249c98c050191ae67009c35602ab9c133caf0378",
3
+ "builtAt": "2026-09-17T11:29:15.968Z",
4
+ "version": "4.69.0",
5
5
  "channel": "stable"
6
6
  }
@@ -125,6 +125,7 @@ export declare class ProcessManager extends EventEmitter {
125
125
  /** Lightweight snapshot for list views — omits output and messages. */
126
126
  private snapshotSlim;
127
127
  private isPermissionBlocked;
128
+ setSessionWorkspace(id: string, membership: Pick<SessionSnapshot, "workspaceId" | "workspaceTaskId">): void;
128
129
  setSessionTopic(id: string, title: string, description: string): SessionSnapshot;
129
130
  private setSessionTopicGenerating;
130
131
  /**
@@ -1669,6 +1669,10 @@ export class ProcessManager extends EventEmitter {
1669
1669
  record.ptyBridge = null;
1670
1670
  }
1671
1671
  this.flushPersist(record, true);
1672
+ // handleTerminalExit 会因为 ptyProcess 已被清空而把这次异步退出判定为过期回调并
1673
+ // return,所以主动停止必须自己广播 ended。少了这一步,各端会一直以为会话还在跑:
1674
+ // 直通输入框不关闭,附件/发送全都打进已经死掉的 PTY。结构化 stop() 同样发 ended。
1675
+ this.emitEvent({ type: "ended", sessionId: record.id, data: this.snapshot(record) });
1672
1676
  return this.snapshot(record);
1673
1677
  }
1674
1678
  cleanupRecord(record, terminateTerminal = true) {
@@ -1880,6 +1884,10 @@ export class ProcessManager extends EventEmitter {
1880
1884
  isPermissionBlocked(record) {
1881
1885
  return record.ptyPermissionBlocked || record.pendingEscalation !== null;
1882
1886
  }
1887
+ setSessionWorkspace(id, membership) {
1888
+ Object.assign(this.mustGet(id), membership);
1889
+ this.emitEvent({ type: "status", sessionId: id, data: membership });
1890
+ }
1883
1891
  setSessionTopic(id, title, description) {
1884
1892
  const record = this.mustGet(id);
1885
1893
  record.title = title;
@@ -24,4 +24,18 @@ export interface TaskRouteDependencies {
24
24
  export declare function parseTaskAgent(value: unknown, fallbackMode?: WandTaskAgentMode): WandTaskAgent | null;
25
25
  /** 等待排队中的标题生成全部结束;只有测试用,避免用例轮询。 */
26
26
  export declare function whenWandTaskTitlesSettled(): Promise<void>;
27
+ export interface AutoTaskTitleOptions {
28
+ config?: WandConfig;
29
+ /** 可注入的标题生成器;测试里换成同步桩,避免真的起 CLI。 */
30
+ generateTitle?: typeof generateWandTaskTitle;
31
+ /** 未从任务所属工作区解析到目录时的兜底 cwd。 */
32
+ cwd?: string;
33
+ }
34
+ /**
35
+ * 扫描标题仍可自动命名的看板任务(titleSource=auto,或仍是占位名的历史卡片):
36
+ * 先用任务描述 + 所有绑定会话的内容立即改掉占位名,再交给后台模型总结更贴切的标题。
37
+ * 同一个输入指纹只处理一次:内容没变就跳过,也避免自动标题与模型结果来回震荡。
38
+ * 用户建任务时写了名字、或事后改过名(titleSource=user 且不是占位名)的任务永不进入这里。
39
+ */
40
+ export declare function refreshAutoBoardTaskTitles(storage: WandStorage, options?: AutoTaskTitleOptions): void;
27
41
  export declare function registerTaskRoutes(app: Express, deps: TaskRouteDependencies): void;
@@ -6,7 +6,7 @@ import { parseWandTaskAgent } from "./storage.js";
6
6
  import { generateWandTaskTitle, provisionalTaskTitleFromDescription, TASK_TITLE_MAX_LENGTH } from "./task-title.js";
7
7
  import { resolveSystemAiContext } from "./session-ai-context.js";
8
8
  import { DEFAULT_WAND_TASK_AGENT_MODE, isWandTaskAgentMode, normalizeWandTaskAgentMode, WAND_MILESTONE_NAME_MAX_LENGTH } from "./task-types.js";
9
- import { archiveBoardTask, syncClosedBoardTask, syncUngroupedSessionsToBoard } from "./wand-task-sync.js";
9
+ import { archiveBoardTask, ensureWorkspaceTaskForBoardTask, isAutoNameableBoardTask, moveSessionToWorkspaceTask, syncClosedBoardTask, syncUngroupedSessionsToBoard, taskAutoNameSignature, taskAutoNameSourceText } from "./wand-task-sync.js";
10
10
  import { isSessionProvider } from "./session-provider.js";
11
11
  const STATUSES = new Set(["todo", "doing", "done", "archived"]);
12
12
  const PRIORITIES = new Set(["none", "low", "medium", "high", "urgent"]);
@@ -130,20 +130,24 @@ function taskSessionSummary(session) {
130
130
  };
131
131
  }
132
132
  /**
133
- * 用户没写标题时,先用描述首行当占位标题,再由后台模型总结一个更好的。
133
+ * 用户没写标题时,先用描述/会话内容当占位标题,再由后台模型总结一个更好的。
134
134
  * 全程不阻塞创建请求:失败只是保留占位标题,不返回错误、也不覆盖用户后来改的标题。
135
- * 多次建任务会排队而不是丢弃,保证每张卡片最终都会拿到标题。
135
+ * 多次总结会排队而不是丢弃,保证每张卡片最终都会拿到标题。
136
136
  */
137
137
  let titleGenerationChain = Promise.resolve();
138
- function scheduleWandTaskTitleGeneration(storage, taskId, description, options) {
138
+ function scheduleWandTaskTitleGeneration(storage, taskId, source, signature, options) {
139
+ const generateTitle = options.generateTitle ?? generateWandTaskTitle;
139
140
  const cwd = options.cwd || options.config?.defaultCwd || process.cwd();
140
141
  const run = async () => {
141
142
  try {
142
- const title = await options.generateTitle(description, cwd, options.config?.language ?? "", taskTitleAiOptions(options.config));
143
+ const title = await generateTitle(source, cwd, options.config?.language ?? "", taskTitleAiOptions(options.config));
143
144
  const current = storage.getWandTask(taskId);
144
145
  // 用户已经自己写了标题(原生端 / 面板编辑)就不要覆盖。
145
146
  if (!current || current.titleSource !== "auto")
146
147
  return;
148
+ // 命名输入已经变了(新增会话 / 会话内容更新):这次是过期结果,交给下一轮。
149
+ if (current.autoTitleSignature !== signature)
150
+ return;
147
151
  const clipped = title.slice(0, TASK_TITLE_MAX_LENGTH);
148
152
  if (!clipped || clipped === current.title)
149
153
  return;
@@ -159,6 +163,40 @@ function scheduleWandTaskTitleGeneration(storage, taskId, description, options)
159
163
  export function whenWandTaskTitlesSettled() {
160
164
  return titleGenerationChain;
161
165
  }
166
+ /**
167
+ * 扫描标题仍可自动命名的看板任务(titleSource=auto,或仍是占位名的历史卡片):
168
+ * 先用任务描述 + 所有绑定会话的内容立即改掉占位名,再交给后台模型总结更贴切的标题。
169
+ * 同一个输入指纹只处理一次:内容没变就跳过,也避免自动标题与模型结果来回震荡。
170
+ * 用户建任务时写了名字、或事后改过名(titleSource=user 且不是占位名)的任务永不进入这里。
171
+ */
172
+ export function refreshAutoBoardTaskTitles(storage, options = {}) {
173
+ for (const card of storage.listWandTasks()) {
174
+ if (!isAutoNameableBoardTask(card))
175
+ continue;
176
+ const source = taskAutoNameSourceText(storage, card);
177
+ if (!source)
178
+ continue;
179
+ const signature = taskAutoNameSignature(source);
180
+ if (card.autoTitleSignature === signature)
181
+ continue;
182
+ const provisional = provisionalTaskTitleFromDescription(source);
183
+ storage.updateWandTask(card.id, {
184
+ titleSource: "auto",
185
+ autoTitleSignature: signature,
186
+ ...(provisional && provisional !== card.title ? { title: provisional } : {}),
187
+ });
188
+ // 未启用模型(如部分只验证占位改名的单测)时只保留立即生效的占位标题。
189
+ if (!options.generateTitle && !options.config)
190
+ continue;
191
+ const cwd = options.cwd
192
+ || (card.workspaceId ? storage.getWorkspace(card.workspaceId)?.cwd : undefined);
193
+ scheduleWandTaskTitleGeneration(storage, card.id, source, signature, {
194
+ cwd,
195
+ config: options.config,
196
+ generateTitle: options.generateTitle,
197
+ });
198
+ }
199
+ }
162
200
  /** 自动生成标题没有会话上下文,按「默认 provider + 默认模型」解析,与提示词优化一致。 */
163
201
  function taskTitleAiOptions(config) {
164
202
  if (!config)
@@ -185,6 +223,13 @@ export function registerTaskRoutes(app, deps) {
185
223
  catch (error) {
186
224
  console.error("[WandTask] Failed to sync ungrouped sessions onto the board:", getErrorMessage(error));
187
225
  }
226
+ try {
227
+ // 侧栏建的任务 / 后续新增的会话都会在这里补上自动标题。
228
+ refreshAutoBoardTaskTitles(storage, { config, generateTitle: deps.generateTitle });
229
+ }
230
+ catch (error) {
231
+ console.error("[WandTask] Failed to refresh auto task titles:", getErrorMessage(error));
232
+ }
188
233
  const workspaceId = typeof req.query.workspaceId === "string" ? req.query.workspaceId : undefined;
189
234
  // 数组顺序即展示顺序:created_at 倒序,新创建的在上面。
190
235
  res.json(storage.listWandTasks(workspaceId).map((task) => dto(task)));
@@ -282,26 +327,30 @@ export function registerTaskRoutes(app, deps) {
282
327
  const labels = labelsFrom(body.labels);
283
328
  const dueDate = dateValue(body.dueDate) ?? null;
284
329
  const milestoneId = milestoneIdFrom(storage, body.milestoneId);
285
- const task = storage.createWandTask({
286
- workspaceId,
287
- title,
288
- titleSource,
289
- description,
290
- status,
291
- priority,
292
- labels,
293
- dueDate,
294
- milestoneId,
295
- agent,
330
+ const task = storage.transaction(() => {
331
+ const card = storage.createWandTask({
332
+ workspaceId,
333
+ title,
334
+ titleSource,
335
+ description,
336
+ status,
337
+ priority,
338
+ labels,
339
+ dueDate,
340
+ milestoneId,
341
+ agent,
342
+ });
343
+ ensureWorkspaceTaskForBoardTask(storage, card);
344
+ return storage.getWandTask(card.id);
296
345
  });
297
346
  if (titleSource === "auto") {
298
- scheduleWandTaskTitleGeneration(storage, task.id, description, {
347
+ refreshAutoBoardTaskTitles(storage, {
299
348
  cwd: workspaceId ? storage.getWorkspace(workspaceId)?.cwd : undefined,
300
349
  config,
301
- generateTitle: deps.generateTitle ?? generateWandTaskTitle,
350
+ generateTitle: deps.generateTitle,
302
351
  });
303
352
  }
304
- res.status(201).json(dto(task));
353
+ res.status(201).json(dto(storage.getWandTask(task.id) ?? task));
305
354
  }
306
355
  catch (error) {
307
356
  sendRouteError(res, error, "无法创建任务。");
@@ -381,7 +430,14 @@ export function registerTaskRoutes(app, deps) {
381
430
  throw new Error("缺少 sessionId。");
382
431
  if (sessions && !sessions.get(sessionId))
383
432
  throw new Error("未找到该会话。");
384
- storage.bindWandTaskSession(req.params.id, sessionId);
433
+ const card = storage.getWandTask(req.params.id);
434
+ if (!card) {
435
+ res.status(404).json({ error: "未找到该任务。" });
436
+ return;
437
+ }
438
+ const task = ensureWorkspaceTaskForBoardTask(storage, card);
439
+ moveSessionToWorkspaceTask(storage, sessionId, task.id);
440
+ sessions?.refreshSessionWorkspace(sessionId);
385
441
  res.status(201).json(dto(storage.getWandTask(req.params.id)));
386
442
  }
387
443
  catch (error) {
@@ -389,7 +445,15 @@ export function registerTaskRoutes(app, deps) {
389
445
  }
390
446
  });
391
447
  app.delete("/api/wand-tasks/:id/sessions/:sessionId", (req, res) => {
392
- storage.unbindWandTaskSession(req.params.id, req.params.sessionId);
448
+ const card = storage.getWandTask(req.params.id);
449
+ const session = storage.getSession(req.params.sessionId);
450
+ if (card?.workspaceTaskId && session?.workspaceTaskId === card.workspaceTaskId) {
451
+ moveSessionToWorkspaceTask(storage, session.id, null);
452
+ sessions?.refreshSessionWorkspace(session.id);
453
+ }
454
+ else {
455
+ storage.unbindWandTaskSession(req.params.id, req.params.sessionId);
456
+ }
393
457
  res.json({ ok: true });
394
458
  });
395
459
  /**
@@ -421,7 +485,9 @@ export function registerTaskRoutes(app, deps) {
421
485
  const workspace = task.workspaceId ? storage.getWorkspace(task.workspaceId) : null;
422
486
  if (task.workspaceId && !workspace)
423
487
  throw new Error("任务所属项目已被删除,请重新指定。");
424
- const cwd = workspace?.cwd || config.defaultCwd;
488
+ const group = ensureWorkspaceTaskForBoardTask(storage, task);
489
+ task = storage.getWandTask(task.id);
490
+ const cwd = group.worktree?.path || group.cwd || workspace?.cwd || config.defaultCwd;
425
491
  const requestedPrompt = text(body.prompt);
426
492
  const existingSessions = storage.listWandTaskSessionIds(task.id).length;
427
493
  if (existingSessions > 0 && !requestedPrompt)
@@ -437,8 +503,8 @@ export function registerTaskRoutes(app, deps) {
437
503
  worktreeEnabled: false,
438
504
  sessionSource: "automation",
439
505
  automationId: `wand-task:${task.id}`,
440
- workspaceId: task.workspaceId ?? undefined,
441
- workspaceTaskId: task.workspaceTaskId ?? undefined,
506
+ workspaceId: group.workspaceId,
507
+ workspaceTaskId: group.id,
442
508
  });
443
509
  storage.updateWandTask(task.id, { agent, status: task.status === "todo" ? "doing" : task.status });
444
510
  storage.bindWandTaskSession(task.id, session.id);
@@ -2,6 +2,7 @@ import type { Express } from "express";
2
2
  import type { SessionRegistry } from "./session-registry.js";
3
3
  import type { WandStorage } from "./storage.js";
4
4
  import { type LayoutNode, type TaskWindowLayout } from "./types.js";
5
+ import { type AutoTaskTitleOptions } from "./server-task-routes.js";
5
6
  /** 校验并清洗前端提交的布局树;非法输入返回 null(空工作空间)。 */
6
7
  export declare function sanitizeLayout(value: unknown): LayoutNode | null;
7
8
  /** 校验任务级工作窗口集合;旧版单棵布局会兼容升级成一个 window。 */
@@ -13,4 +14,4 @@ export declare function sanitizeTaskLayout(value: unknown): TaskWindowLayout | n
13
14
  * - POST 只建项目实体,**不启动任何会话**;会话由工作空间内「+」标签按需创建并绑定 workspaceId。
14
15
  * - 布局树由前端 allotment 构造,PUT 时经 sanitizeLayout 校验/规整后落库。
15
16
  */
16
- export declare function registerWorkspaceRoutes(app: Express, storage: WandStorage, sessions?: SessionRegistry): void;
17
+ export declare function registerWorkspaceRoutes(app: Express, storage: WandStorage, sessions?: SessionRegistry, titleOptions?: AutoTaskTitleOptions): void;
@@ -9,9 +9,10 @@ import { checkSessionWorktreeMergeabilityAsync, cleanupWorktreeSync, prepareSess
9
9
  import { collectSessionTopicBlocklist } from "./session-topic.js";
10
10
  import { resolveSessionDisplayTitle } from "./session-transport.js";
11
11
  import { attachUnboundSessionsToWorkspace, backfillSessionWorkspaces, normalizeProjectCwd, projectCwdForSession, isGlobalWorkspace, syncDirectoryNameForWorkspace } from "./workspace-binding.js";
12
- import { archiveBoardTaskForWorkspaceTask, ensureBoardTaskForWorkspaceTask } from "./wand-task-sync.js";
12
+ import { archiveBoardTaskForWorkspaceTask, archiveWorkspaceTask, ensureBoardTaskForWorkspaceTask, isUnnamedWorkspaceTaskName, moveSessionToWorkspaceTask, syncSidebarTasksFromBoard, UNNAMED_WORKSPACE_TASK_NAME } from "./wand-task-sync.js";
13
13
  import { isSessionProvider } from "./session-provider.js";
14
14
  import { firstLayoutTabId } from "./layout-tree.js";
15
+ import { refreshAutoBoardTaskTitles } from "./server-task-routes.js";
15
16
  function workspaceSessionTitle(session, names = {}) {
16
17
  return resolveSessionDisplayTitle(session, collectSessionTopicBlocklist({
17
18
  taskName: names.taskName,
@@ -96,12 +97,24 @@ function deleteSessions(storage, sessions, sessionIds) {
96
97
  storage.deleteSession(sessionId);
97
98
  }
98
99
  }
100
+ function worktreeUsedOutsideTasks(storage, tasks) {
101
+ const deleting = new Set(tasks.map((task) => task.id));
102
+ return storage.loadSessionsSlim().some((session) => !deleting.has(session.workspaceTaskId ?? "")
103
+ && tasks.some((task) => {
104
+ if (!task.worktree)
105
+ return false;
106
+ const relative = path.relative(task.worktree.path, session.cwd);
107
+ return !relative || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative));
108
+ }));
109
+ }
99
110
  function taskRuntimeCwd(task, workspace) {
100
111
  return task.worktree?.path ?? task.cwd ?? workspace?.cwd ?? "";
101
112
  }
102
113
  function createTaskForWorkspace(storage, workspace, body) {
103
114
  const name = typeof body.name === "string" ? body.name.trim() : "";
104
- const taskName = name || "未命名任务";
115
+ // 留空(或老客户端回传的占位名)走自动命名:先用占位名建组,会话内容一到位就改名。
116
+ const named = name.length > 0 && !isUnnamedWorkspaceTaskName(name);
117
+ const taskName = named ? name : UNNAMED_WORKSPACE_TASK_NAME;
105
118
  // 里程碑是全局列表;建任务时可选,非法 id 直接报错而不是静默丢掉。
106
119
  const requestedMilestoneId = typeof body.milestoneId === "string" ? body.milestoneId.trim() : "";
107
120
  if (requestedMilestoneId && !storage.getWandMilestone(requestedMilestoneId)) {
@@ -118,25 +131,14 @@ function createTaskForWorkspace(storage, workspace, body) {
118
131
  }
119
132
  const baseRef = typeof body.baseRef === "string" && body.baseRef.trim() ? body.baseRef.trim() : undefined;
120
133
  const runCwd = mountedCwd ?? workspace.cwd;
121
- const requireWorktree = body.worktree === true;
122
- const wantWorktree = requireWorktree || (body.worktree !== false && !isGlobalWorkspace(workspace));
123
134
  let worktree = null;
124
- let worktreeError;
125
- if (wantWorktree) {
126
- try {
127
- const setup = prepareSessionWorktree({
128
- cwd: runCwd,
129
- sessionId: crypto.randomUUID(),
130
- spec: { taskName, baseRef },
131
- });
132
- worktree = setup.worktree;
133
- }
134
- catch (error) {
135
- if (requireWorktree) {
136
- throw new Error(getErrorMessage(error, "无法创建隔离 worktree。"));
137
- }
138
- worktreeError = getErrorMessage(error, "无法创建 worktree,将在项目目录直接运行。");
139
- }
135
+ if (body.worktree === true) {
136
+ const setup = prepareSessionWorktree({
137
+ cwd: runCwd,
138
+ sessionId: crypto.randomUUID(),
139
+ spec: { taskName, baseRef },
140
+ });
141
+ worktree = setup.worktree;
140
142
  }
141
143
  const storedCwd = mountedCwd && mountedCwd !== workspace.cwd ? mountedCwd : undefined;
142
144
  const task = storage.createWorkspaceTask({
@@ -146,12 +148,11 @@ function createTaskForWorkspace(storage, workspace, body) {
146
148
  worktree,
147
149
  milestoneId,
148
150
  });
149
- ensureBoardTaskForWorkspaceTask(storage, task, workspace);
151
+ ensureBoardTaskForWorkspaceTask(storage, task, workspace, { titleSource: named ? "user" : "auto" });
150
152
  return {
151
153
  task,
152
154
  cwd: taskRuntimeCwd(task, workspace),
153
155
  isolated: worktree !== null,
154
- worktreeError,
155
156
  };
156
157
  }
157
158
  function workspaceWithCounts(storage, workspace, sessionCounts) {
@@ -267,7 +268,7 @@ export function sanitizeTaskLayout(value) {
267
268
  * - POST 只建项目实体,**不启动任何会话**;会话由工作空间内「+」标签按需创建并绑定 workspaceId。
268
269
  * - 布局树由前端 allotment 构造,PUT 时经 sanitizeLayout 校验/规整后落库。
269
270
  */
270
- export function registerWorkspaceRoutes(app, storage, sessions) {
271
+ export function registerWorkspaceRoutes(app, storage, sessions, titleOptions = {}) {
271
272
  // 列出所有项目(按创建时间降序:新建在前,打开不会改位置)
272
273
  app.get("/api/workspaces", (_req, res) => {
273
274
  backfillSessionWorkspaces(storage);
@@ -368,6 +369,10 @@ export function registerWorkspaceRoutes(app, storage, sessions) {
368
369
  return;
369
370
  }
370
371
  const tasks = storage.listWorkspaceTasks(existing.id);
372
+ if (worktreeUsedOutsideTasks(storage, tasks)) {
373
+ res.status(409).json({ error: "该 Worktree 仍被已移出的会话使用,请先结束并删除这些会话。" });
374
+ return;
375
+ }
371
376
  if (cascade) {
372
377
  deleteSessions(storage, sessions, [
373
378
  ...storage.listSessionsByWorkspace(existing.id).map((session) => session.id),
@@ -384,6 +389,9 @@ export function registerWorkspaceRoutes(app, storage, sessions) {
384
389
  for (const task of tasks) {
385
390
  if (cascade || task.worktree)
386
391
  cleanupWorktreeSync(task.worktree);
392
+ const card = storage.getWandTaskByWorkspaceTaskId(task.id);
393
+ if (card)
394
+ storage.updateWandTask(card.id, { status: "archived", workspaceTaskId: null });
387
395
  }
388
396
  storage.deleteWorkspace(existing.id, { cascade });
389
397
  res.json({ ok: true });
@@ -419,6 +427,14 @@ export function registerWorkspaceRoutes(app, storage, sessions) {
419
427
  }));
420
428
  // 目录组为一级容器的任务聚合列表,供侧栏「任务」视图一次拉全。
421
429
  app.get("/api/tasks", (req, res) => {
430
+ syncSidebarTasksFromBoard(storage);
431
+ // 侧栏轮询也在补自动标题:不打开看板的任务(以及新增会话后的改名)也要吃到。
432
+ try {
433
+ refreshAutoBoardTaskTitles(storage, titleOptions);
434
+ }
435
+ catch (error) {
436
+ console.error("[WandTask] Failed to refresh auto task titles:", getErrorMessage(error));
437
+ }
422
438
  // 查询参数:workspaceId 过滤单目录;limit 截断每目录任务数;
423
439
  // maxSessions 截断每任务内嵌会话数(大数据量时控制响应体积)。
424
440
  const hasRevisionQuery = typeof req.query.revision === "string";
@@ -686,8 +702,7 @@ export function registerWorkspaceRoutes(app, storage, sessions) {
686
702
  worktrees,
687
703
  });
688
704
  }));
689
- // 新建任务:命名 + 可选独立 worktree(默认尝试创建,非 git 仓库时退化为直接用项目目录;
690
- // 显式 worktree:false 跳过隔离;显式 worktree:true 失败则 400,不静默降级)。
705
+ // 默认只创建逻辑分组;仅显式 worktree:true 才创建隔离目录。
691
706
  app.post("/api/workspaces/:id/tasks", asyncRoute(async (req, res) => {
692
707
  const workspace = storage.getWorkspace(req.params.id);
693
708
  if (!workspace) {
@@ -728,6 +743,22 @@ export function registerWorkspaceRoutes(app, storage, sessions) {
728
743
  })),
729
744
  });
730
745
  });
746
+ // 移动会话只改变归属,不修改运行目录,也不重启 CLI。
747
+ app.post("/api/workspace-tasks/:taskId/sessions", (req, res) => {
748
+ const sessionId = typeof req.body?.sessionId === "string" ? req.body.sessionId.trim() : "";
749
+ if (!storage.getWorkspaceTask(req.params.taskId) || !storage.getSession(sessionId)) {
750
+ res.status(404).json({ error: "未找到任务或会话。" });
751
+ return;
752
+ }
753
+ try {
754
+ moveSessionToWorkspaceTask(storage, sessionId, req.params.taskId);
755
+ sessions?.refreshSessionWorkspace(sessionId);
756
+ res.json({ ok: true });
757
+ }
758
+ catch (error) {
759
+ sendRouteError(res, error, "无法移动会话。");
760
+ }
761
+ });
731
762
  // 改名 / 状态
732
763
  app.patch("/api/workspace-tasks/:taskId", (req, res) => {
733
764
  const existing = storage.getWorkspaceTask(req.params.taskId);
@@ -754,6 +785,16 @@ export function registerWorkspaceRoutes(app, storage, sessions) {
754
785
  archiveBoardTaskForWorkspaceTask(storage, existing.id);
755
786
  res.json(storage.getWorkspaceTask(existing.id));
756
787
  });
788
+ // 归档任务:软删除。终端继续运行、worktree 与卡片历史都保留,只是侧栏不再显示。
789
+ app.post("/api/workspace-tasks/:taskId/archive", (req, res) => {
790
+ const existing = storage.getWorkspaceTask(req.params.taskId);
791
+ if (!existing) {
792
+ res.status(404).json({ error: "未找到该任务。" });
793
+ return;
794
+ }
795
+ archiveWorkspaceTask(storage, existing);
796
+ res.json(storage.getWorkspaceTask(existing.id));
797
+ });
757
798
  // 删除任务;cascade=true 连带删会话,否则仅解绑;尽力清理 worktree
758
799
  app.delete("/api/workspace-tasks/:taskId", (req, res) => {
759
800
  const existing = storage.getWorkspaceTask(req.params.taskId);
@@ -761,6 +802,10 @@ export function registerWorkspaceRoutes(app, storage, sessions) {
761
802
  res.status(404).json({ error: "未找到该任务。" });
762
803
  return;
763
804
  }
805
+ if (worktreeUsedOutsideTasks(storage, [existing])) {
806
+ res.status(409).json({ error: "该 Worktree 仍被已移出的会话使用,请先结束并删除这些会话。" });
807
+ return;
808
+ }
764
809
  // 删除隔离任务必然会移除其 cwd,因此即使调用方没有显式传 cascade,
765
810
  // 也必须同步删除会话;非隔离任务仍保留旧的“默认解绑”API 语义。
766
811
  const cascade = req.query.cascade === "1"
@@ -775,7 +820,7 @@ export function registerWorkspaceRoutes(app, storage, sessions) {
775
820
  archiveBoardTaskForWorkspaceTask(storage, existing.id);
776
821
  const board = storage.getWandTaskByWorkspaceTaskId(existing.id);
777
822
  if (board)
778
- storage.updateWandTask(board.id, { workspaceTaskId: null });
823
+ storage.updateWandTask(board.id, { workspaceTaskId: null, status: "archived" });
779
824
  storage.deleteWorkspaceTask(existing.id, { cascade });
780
825
  res.json({ ok: true });
781
826
  });
package/dist/server.js CHANGED
@@ -654,7 +654,7 @@ export async function startServer(config, configPath, options = {}) {
654
654
  recordRecentPath(storage, cwd);
655
655
  });
656
656
  registerClaudeHistoryRoutes(app, processes, storage);
657
- registerWorkspaceRoutes(app, storage, sessionRegistry);
657
+ registerWorkspaceRoutes(app, storage, sessionRegistry, { config });
658
658
  registerMissionRoutes(app, missions);
659
659
  registerUploadRoutes(app, processes);
660
660
  app.post("/api/optimize-prompt", asyncRoute(async (req, res) => {
@@ -25,6 +25,8 @@ export declare class SessionRegistry {
25
25
  get(id: string): SessionSnapshot | null;
26
26
  getLatest(id: string): SessionSnapshot | null;
27
27
  listSlim(): SessionSnapshot[];
28
+ /** Membership belongs to storage; both runners only refresh their live projection. */
29
+ refreshSessionWorkspace(id: string): void;
28
30
  setSessionModel(id: string, model: string | null): SessionSnapshot | null;
29
31
  setSessionThinkingEffort(id: string, effort: SessionSnapshot["thinkingEffort"]): SessionSnapshot | null;
30
32
  setSessionMode(id: string, mode: ExecutionMode): SessionSnapshot | null;
@@ -65,7 +65,9 @@ export class SessionRegistry {
65
65
  return this.storage.getSession(id) ? "storage" : null;
66
66
  }
67
67
  get(id) {
68
- return this.structured.get(id) ?? this.processes.getOwned(id) ?? this.storage.getSession(id);
68
+ const snapshot = this.structured.get(id) ?? this.processes.getOwned(id) ?? this.storage.getSession(id);
69
+ const membership = this.storage.getSessionWorkspace(id);
70
+ return snapshot && membership ? { ...snapshot, ...membership } : snapshot;
69
71
  }
70
72
  getLatest(id) {
71
73
  return this.get(id);
@@ -79,11 +81,24 @@ export class SessionRegistry {
79
81
  byId.set(snapshot.id, snapshot);
80
82
  }
81
83
  for (const snapshot of this.storage.loadSessionsSlim()) {
82
- if (!byId.has(snapshot.id))
83
- byId.set(snapshot.id, snapshot);
84
+ const live = byId.get(snapshot.id);
85
+ byId.set(snapshot.id, live ? {
86
+ ...live, workspaceId: snapshot.workspaceId, workspaceTaskId: snapshot.workspaceTaskId,
87
+ } : snapshot);
84
88
  }
85
89
  return Array.from(byId.values()).sort((a, b) => b.startedAt.localeCompare(a.startedAt));
86
90
  }
91
+ /** Membership belongs to storage; both runners only refresh their live projection. */
92
+ refreshSessionWorkspace(id) {
93
+ const membership = this.storage.getSessionWorkspace(id);
94
+ if (!membership)
95
+ return;
96
+ const owner = this.ownerOf(id);
97
+ if (owner === "structured")
98
+ this.structured.setSessionWorkspace(id, membership);
99
+ else if (owner === "pty")
100
+ this.processes.setSessionWorkspace(id, membership);
101
+ }
87
102
  setSessionModel(id, model) {
88
103
  const owner = this.ownerOf(id);
89
104
  if (owner === "structured")
package/dist/storage.d.ts CHANGED
@@ -75,6 +75,7 @@ export declare class WandStorage {
75
75
  }): void;
76
76
  listSessionsByWorkspace(workspaceId: string): SessionSnapshot[];
77
77
  /** 显式更新某会话的工作空间归属(用于创建时绑定)。 */
78
+ getSessionWorkspace(sessionId: string): Pick<SessionSnapshot, "workspaceId" | "workspaceTaskId"> | null;
78
79
  setSessionWorkspaceId(sessionId: string, workspaceId: string | null): void;
79
80
  /** Lightweight count of persisted sessions grouped by workspace. */
80
81
  countSessionsByWorkspace(): Map<string, number>;
@@ -100,6 +101,7 @@ export declare class WandStorage {
100
101
  status?: WorkspaceTaskStatus;
101
102
  worktree?: WorkspaceTaskWorktree | null;
102
103
  milestoneId?: string | null;
104
+ workspaceId?: string;
103
105
  }): void;
104
106
  saveWorkspaceTaskLayout(id: string, layout: TaskWindowLayout | null, expectedRevision?: number): {
105
107
  ok: true;
@@ -120,7 +122,6 @@ export declare class WandStorage {
120
122
  private mapWandTaskRow;
121
123
  getWandTask(id: string): import("./task-types.js").WandTask | null;
122
124
  getWandTaskByWorkspaceTaskId(workspaceTaskId: string): import("./task-types.js").WandTask | null;
123
- findUnlinkedWandTask(workspaceId: string, title: string): import("./task-types.js").WandTask | null;
124
125
  createWandTask(input: {
125
126
  workspaceId?: string | null;
126
127
  workspaceTaskId?: string | null;
@@ -134,7 +135,7 @@ export declare class WandStorage {
134
135
  milestoneId?: string | null;
135
136
  agent?: import("./task-types.js").WandTaskAgent | null;
136
137
  }): import("./task-types.js").WandTask;
137
- updateWandTask(id: string, patch: Partial<Pick<import("./task-types.js").WandTask, "workspaceId" | "workspaceTaskId" | "title" | "titleSource" | "description" | "status" | "priority" | "labels" | "dueDate" | "milestoneId" | "sortOrder" | "agent">>): import("./task-types.js").WandTask | null;
138
+ updateWandTask(id: string, patch: Partial<Pick<import("./task-types.js").WandTask, "workspaceId" | "workspaceTaskId" | "title" | "titleSource" | "autoTitleSignature" | "description" | "status" | "priority" | "labels" | "dueDate" | "milestoneId" | "sortOrder" | "agent">>): import("./task-types.js").WandTask | null;
138
139
  deleteWandTask(id: string): void;
139
140
  listWandMilestones(): import("./task-types.js").WandTaskMilestone[];
140
141
  getWandMilestone(id: string): import("./task-types.js").WandTaskMilestone | null;