@co0ontty/wand 4.39.0 → 4.40.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": "9896125e169fcac60a7a4fd7093eb675dfec329d",
3
- "builtAt": "2026-08-08T07:24:00.026Z",
4
- "version": "4.39.0",
2
+ "commit": "039e06a3e112daed7a0815a38eef48a36f7b31f7",
3
+ "builtAt": "2026-08-09T03:33:04.966Z",
4
+ "version": "4.40.0",
5
5
  "channel": "stable"
6
6
  }
@@ -60,6 +60,8 @@ export declare class ProcessManager extends EventEmitter {
60
60
  thinkingEffort?: SessionSnapshot["thinkingEffort"];
61
61
  sessionSource?: SessionSource;
62
62
  automationId?: string;
63
+ workspaceId?: string;
64
+ workspaceTaskId?: string;
63
65
  interactiveShell?: boolean;
64
66
  }): Promise<SessionSnapshot>;
65
67
  list(): SessionSnapshot[];
@@ -176,6 +178,8 @@ export declare class ProcessManager extends EventEmitter {
176
178
  rows?: number;
177
179
  sessionSource?: SessionSource;
178
180
  automationId?: string;
181
+ workspaceId?: string;
182
+ workspaceTaskId?: string;
179
183
  }): Promise<SessionSnapshot>;
180
184
  private shouldAutoApprovePermissions;
181
185
  private processCommandForMode;
@@ -1032,6 +1032,8 @@ export class ProcessManager extends EventEmitter {
1032
1032
  id,
1033
1033
  sessionSource: opts?.sessionSource ?? inheritedSessionSource ?? "interactive",
1034
1034
  automationId: opts?.automationId ?? inheritedAutomationId,
1035
+ workspaceId: opts?.workspaceId,
1036
+ workspaceTaskId: opts?.workspaceTaskId,
1035
1037
  provider,
1036
1038
  command,
1037
1039
  cwd: resolvedCwd,
@@ -1844,6 +1846,8 @@ export class ProcessManager extends EventEmitter {
1844
1846
  ptyRows: record.ptyRows,
1845
1847
  ptyOutputSeq: record.ptyOutputSeq ?? 0,
1846
1848
  ptyLaunchMarkerToken: record.ptyLaunchMarkerToken ?? null,
1849
+ workspaceId: record.workspaceId,
1850
+ workspaceTaskId: record.workspaceTaskId,
1847
1851
  };
1848
1852
  }
1849
1853
  /** Lightweight snapshot for list views — omits output and messages. */
@@ -1,7 +1,7 @@
1
1
  import crypto from "node:crypto";
2
2
  import { exec } from "node:child_process";
3
3
  import { createReadStream } from "node:fs";
4
- import { lstat, readdir, readFile, rename, stat, unlink, writeFile } from "node:fs/promises";
4
+ import { lstat, mkdir, readdir, readFile, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
5
5
  import path from "node:path";
6
6
  import process from "node:process";
7
7
  import { promisify } from "node:util";
@@ -220,6 +220,124 @@ export function registerFileRoutes(app, deps) {
220
220
  res.status(400).json({ error: getErrorMessage(error, "保存文件失败。") });
221
221
  }
222
222
  }));
223
+ app.post("/api/file-create", asyncRoute(async (req, res) => {
224
+ const body = (req.body ?? {});
225
+ const filePath = typeof body.path === "string" ? body.path.trim() : "";
226
+ if (!filePath) {
227
+ res.status(400).json({ error: "缺少 path 参数。" });
228
+ return;
229
+ }
230
+ const resolvedPath = path.resolve(filePath);
231
+ if (isBlockedFolderPath(resolvedPath)) {
232
+ res.status(403).json({ error: "访问被拒绝:无法在系统目录下创建文件。" });
233
+ return;
234
+ }
235
+ try {
236
+ await writeFile(resolvedPath, "", { flag: "wx", encoding: "utf-8" });
237
+ const fileStat = await stat(resolvedPath);
238
+ res.json({ ok: true, path: resolvedPath, size: fileStat.size, mtime: fileStat.mtime.toISOString() });
239
+ }
240
+ catch (error) {
241
+ const err = error;
242
+ if (err.code === "EEXIST") {
243
+ res.status(409).json({ error: "文件已存在。", path: resolvedPath });
244
+ return;
245
+ }
246
+ res.status(400).json({ error: getErrorMessage(error, "创建文件失败。") });
247
+ }
248
+ }));
249
+ app.post("/api/dir-create", asyncRoute(async (req, res) => {
250
+ const body = (req.body ?? {});
251
+ const dirPath = typeof body.path === "string" ? body.path.trim() : "";
252
+ if (!dirPath) {
253
+ res.status(400).json({ error: "缺少 path 参数。" });
254
+ return;
255
+ }
256
+ const resolvedPath = path.resolve(dirPath);
257
+ if (isBlockedFolderPath(resolvedPath)) {
258
+ res.status(403).json({ error: "访问被拒绝:无法在系统目录下创建文件夹。" });
259
+ return;
260
+ }
261
+ try {
262
+ await mkdir(resolvedPath, { recursive: true });
263
+ const fileStat = await stat(resolvedPath);
264
+ res.json({ ok: true, path: resolvedPath, mtime: fileStat.mtime.toISOString() });
265
+ }
266
+ catch (error) {
267
+ res.status(400).json({ error: getErrorMessage(error, "创建文件夹失败。") });
268
+ }
269
+ }));
270
+ app.post("/api/file-rename", asyncRoute(async (req, res) => {
271
+ const body = (req.body ?? {});
272
+ const fromRaw = typeof body.from === "string" ? body.from.trim() : "";
273
+ const toRaw = typeof body.to === "string" ? body.to.trim() : "";
274
+ if (!fromRaw || !toRaw) {
275
+ res.status(400).json({ error: "缺少 from 或 to 参数。" });
276
+ return;
277
+ }
278
+ const fromPath = path.resolve(fromRaw);
279
+ const toPath = path.resolve(toRaw);
280
+ if (isBlockedFolderPath(fromPath) || isBlockedFolderPath(toPath)) {
281
+ res.status(403).json({ error: "访问被拒绝:无法操作系统目录。" });
282
+ return;
283
+ }
284
+ if (fromPath === toPath) {
285
+ res.status(400).json({ error: "源路径与目标路径相同。" });
286
+ return;
287
+ }
288
+ try {
289
+ const fromStat = await lstat(fromPath);
290
+ try {
291
+ const toStat = await lstat(toPath);
292
+ // macOS 默认大小写不敏感;仅当两个路径实际指向同一 inode 时允许
293
+ // foo.ts → Foo.ts 这种大小写重命名,其余目标一律拒绝覆盖。
294
+ if (fromStat.dev !== toStat.dev || fromStat.ino !== toStat.ino) {
295
+ res.status(409).json({ error: "目标路径已存在。" });
296
+ return;
297
+ }
298
+ }
299
+ catch (targetError) {
300
+ if (targetError.code !== "ENOENT")
301
+ throw targetError;
302
+ }
303
+ await rename(fromPath, toPath);
304
+ res.json({ ok: true, from: fromPath, to: toPath });
305
+ }
306
+ catch (error) {
307
+ const err = error;
308
+ if (err.code === "ENOENT") {
309
+ res.status(404).json({ error: "源路径不存在。" });
310
+ return;
311
+ }
312
+ res.status(400).json({ error: getErrorMessage(error, "重命名/移动失败。") });
313
+ }
314
+ }));
315
+ app.post("/api/file-delete", asyncRoute(async (req, res) => {
316
+ const body = (req.body ?? {});
317
+ const targetPath = typeof body.path === "string" ? body.path.trim() : "";
318
+ if (!targetPath) {
319
+ res.status(400).json({ error: "缺少 path 参数。" });
320
+ return;
321
+ }
322
+ const resolvedPath = path.resolve(targetPath);
323
+ if (isBlockedFolderPath(resolvedPath)) {
324
+ res.status(403).json({ error: "访问被拒绝:无法删除系统目录。" });
325
+ return;
326
+ }
327
+ try {
328
+ const fileStat = await stat(resolvedPath);
329
+ await rm(resolvedPath, { recursive: fileStat.isDirectory(), force: false });
330
+ res.json({ ok: true, path: resolvedPath, wasDirectory: fileStat.isDirectory() });
331
+ }
332
+ catch (error) {
333
+ const err = error;
334
+ if (err.code === "ENOENT") {
335
+ res.status(404).json({ error: "路径不存在。" });
336
+ return;
337
+ }
338
+ res.status(400).json({ error: getErrorMessage(error, "删除失败。") });
339
+ }
340
+ }));
223
341
  app.get("/api/file-raw", asyncRoute(async (req, res) => {
224
342
  const filePath = typeof req.query.path === "string" ? req.query.path : "";
225
343
  const asDownload = req.query.download === "1" || req.query.download === "true";
@@ -525,6 +525,8 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
525
525
  thinkingEffort: typeof body.thinkingEffort === "string"
526
526
  ? body.thinkingEffort
527
527
  : config.defaultThinkingEffort,
528
+ workspaceId: body.workspaceId,
529
+ workspaceTaskId: body.workspaceTaskId,
528
530
  ...origin,
529
531
  });
530
532
  onSessionCreated?.(snapshot.cwd);
@@ -0,0 +1,16 @@
1
+ import type { Express } from "express";
2
+ import type { SessionRegistry } from "./session-registry.js";
3
+ import type { WandStorage } from "./storage.js";
4
+ import type { LayoutNode, TaskWindowLayout } from "./types.js";
5
+ /** 校验并清洗前端提交的布局树;非法输入返回 null(空工作空间)。 */
6
+ export declare function sanitizeLayout(value: unknown): LayoutNode | null;
7
+ /** 校验任务级工作窗口集合;旧版单棵布局会兼容升级成一个 window。 */
8
+ export declare function sanitizeTaskLayout(value: unknown): TaskWindowLayout | null;
9
+ /**
10
+ * Workspace(多标签 / 分屏项目)REST 路由。
11
+ *
12
+ * 设计要点:
13
+ * - POST 只建项目实体,**不启动任何会话**;会话由工作空间内「+」标签按需创建并绑定 workspaceId。
14
+ * - 布局树由前端 allotment 构造,PUT 时经 sanitizeLayout 校验/规整后落库。
15
+ */
16
+ export declare function registerWorkspaceRoutes(app: Express, storage: WandStorage, sessions?: SessionRegistry): void;
@@ -0,0 +1,373 @@
1
+ import path from "node:path";
2
+ import crypto from "node:crypto";
3
+ import { existsSync, statSync } from "node:fs";
4
+ import { asyncRoute } from "./express-async.js";
5
+ import { getErrorMessage } from "./error-utils.js";
6
+ import { expandHomePath } from "./middleware/path-safety.js";
7
+ import { prepareSessionWorktree } from "./git-worktree.js";
8
+ import { runGit } from "./git-utils.js";
9
+ const PROVIDERS = new Set(["claude", "codex", "opencode", "grok", "qoder", "pi"]);
10
+ function parseDefaultProvider(value) {
11
+ return typeof value === "string" && PROVIDERS.has(value) ? value : undefined;
12
+ }
13
+ /** Resolve + validate a workspace cwd: expand home, require an existing directory. */
14
+ function resolveWorkspaceCwd(raw) {
15
+ const expanded = expandHomePath(typeof raw === "string" ? raw : "");
16
+ if (!expanded.trim())
17
+ throw new Error("请选择项目目录。");
18
+ const resolved = path.resolve(expanded);
19
+ if (!existsSync(resolved))
20
+ throw new Error(`目录不存在:${resolved}`);
21
+ if (!statSync(resolved).isDirectory())
22
+ throw new Error(`不是目录:${resolved}`);
23
+ return resolved;
24
+ }
25
+ function cleanupTaskWorktree(worktree) {
26
+ if (!worktree?.repoRoot)
27
+ return;
28
+ try {
29
+ runGit(["worktree", "remove", "--force", worktree.path], worktree.repoRoot);
30
+ }
31
+ catch { /* best effort */ }
32
+ try {
33
+ runGit(["branch", "-D", worktree.branch], worktree.repoRoot);
34
+ }
35
+ catch { /* best effort */ }
36
+ }
37
+ function deleteSessions(storage, sessions, sessionIds) {
38
+ for (const sessionId of new Set(sessionIds)) {
39
+ if (sessions)
40
+ sessions.deleteWithProviderHistory(sessionId);
41
+ else
42
+ storage.deleteSession(sessionId);
43
+ }
44
+ }
45
+ // ── Layout validation / sanitization(前端 PUT 与测试共用)──
46
+ function sanitizePaneTab(value) {
47
+ if (!value || typeof value !== "object")
48
+ return null;
49
+ const v = value;
50
+ const id = typeof v.id === "string" && v.id ? v.id : null;
51
+ if (!id)
52
+ return null;
53
+ if (v.kind === "session") {
54
+ if (typeof v.sessionId !== "string" || !v.sessionId)
55
+ return null;
56
+ return { id, kind: "session", sessionId: v.sessionId };
57
+ }
58
+ if (v.kind === "editor" || v.kind === "preview") {
59
+ if (typeof v.path !== "string" || !v.path)
60
+ return null;
61
+ return { id, kind: v.kind, path: v.path };
62
+ }
63
+ return null;
64
+ }
65
+ function sanitizeLayoutNode(value) {
66
+ if (!value || typeof value !== "object")
67
+ return null;
68
+ const v = value;
69
+ if (v.type === "pane") {
70
+ const rawTabs = Array.isArray(v.tabs) ? v.tabs : [];
71
+ const tabs = rawTabs
72
+ .map(sanitizePaneTab)
73
+ .filter((tab) => tab !== null);
74
+ const tabCount = Math.max(1, tabs.length);
75
+ const active = typeof v.active === "number" && Number.isFinite(v.active)
76
+ ? Math.max(0, Math.min(Math.floor(v.active), tabCount - 1))
77
+ : 0;
78
+ return { type: "pane", tabs, active };
79
+ }
80
+ if (v.type === "split") {
81
+ if (v.dir !== "h" && v.dir !== "v")
82
+ return null;
83
+ const kids = Array.isArray(v.children) ? v.children : [];
84
+ if (kids.length !== 2)
85
+ return null;
86
+ const a = sanitizeLayoutNode(kids[0]);
87
+ const b = sanitizeLayoutNode(kids[1]);
88
+ if (!a || !b)
89
+ return null;
90
+ const ratio = typeof v.ratio === "number" && Number.isFinite(v.ratio)
91
+ ? Math.max(0.05, Math.min(0.95, v.ratio))
92
+ : 0.5;
93
+ return { type: "split", dir: v.dir, ratio, children: [a, b] };
94
+ }
95
+ return null;
96
+ }
97
+ /** 校验并清洗前端提交的布局树;非法输入返回 null(空工作空间)。 */
98
+ export function sanitizeLayout(value) {
99
+ return sanitizeLayoutNode(value);
100
+ }
101
+ function layoutHasTab(node, tabId) {
102
+ if (node.type === "pane")
103
+ return node.tabs.some((tab) => tab.id === tabId);
104
+ return layoutHasTab(node.children[0], tabId) || layoutHasTab(node.children[1], tabId);
105
+ }
106
+ function firstLayoutTabId(node) {
107
+ if (node.type === "pane")
108
+ return node.tabs[node.active]?.id ?? node.tabs[0]?.id;
109
+ return firstLayoutTabId(node.children[0]) ?? firstLayoutTabId(node.children[1]);
110
+ }
111
+ /** 校验任务级工作窗口集合;旧版单棵布局会兼容升级成一个 window。 */
112
+ export function sanitizeTaskLayout(value) {
113
+ const legacy = sanitizeLayoutNode(value);
114
+ if (legacy) {
115
+ return {
116
+ type: "windows",
117
+ windows: [{ id: "window-legacy", layout: legacy, activeTabId: firstLayoutTabId(legacy) }],
118
+ activeWindowId: "window-legacy",
119
+ };
120
+ }
121
+ if (!value || typeof value !== "object")
122
+ return null;
123
+ const record = value;
124
+ if (record.type !== "windows" || !Array.isArray(record.windows))
125
+ return null;
126
+ const used = new Set();
127
+ const windows = record.windows.slice(0, 128).flatMap((candidate) => {
128
+ if (!candidate || typeof candidate !== "object")
129
+ return [];
130
+ const window = candidate;
131
+ const id = typeof window.id === "string" ? window.id.trim().slice(0, 160) : "";
132
+ const layout = sanitizeLayoutNode(window.layout);
133
+ if (!id || used.has(id) || !layout)
134
+ return [];
135
+ used.add(id);
136
+ const requestedActive = typeof window.activeTabId === "string" ? window.activeTabId : undefined;
137
+ const activeTabId = requestedActive && layoutHasTab(layout, requestedActive)
138
+ ? requestedActive
139
+ : firstLayoutTabId(layout);
140
+ return [{ id, layout, ...(activeTabId ? { activeTabId } : {}) }];
141
+ });
142
+ const requestedWindow = typeof record.activeWindowId === "string" ? record.activeWindowId : null;
143
+ const activeWindowId = windows.some((window) => window.id === requestedWindow)
144
+ ? requestedWindow
145
+ : windows[0]?.id ?? null;
146
+ return { type: "windows", windows, activeWindowId };
147
+ }
148
+ /**
149
+ * Workspace(多标签 / 分屏项目)REST 路由。
150
+ *
151
+ * 设计要点:
152
+ * - POST 只建项目实体,**不启动任何会话**;会话由工作空间内「+」标签按需创建并绑定 workspaceId。
153
+ * - 布局树由前端 allotment 构造,PUT 时经 sanitizeLayout 校验/规整后落库。
154
+ */
155
+ export function registerWorkspaceRoutes(app, storage, sessions) {
156
+ // 列出所有项目(按最近打开排序)
157
+ app.get("/api/workspaces", (_req, res) => {
158
+ res.json(storage.listWorkspaces());
159
+ });
160
+ // 新建项目:名称 + 目录 + 默认 IDE,不启动会话
161
+ app.post("/api/workspaces", asyncRoute(async (req, res) => {
162
+ const body = req.body;
163
+ const name = typeof body.name === "string" ? body.name.trim() : "";
164
+ if (!name) {
165
+ res.status(400).json({ error: "请输入项目名称。" });
166
+ return;
167
+ }
168
+ let cwd;
169
+ try {
170
+ cwd = resolveWorkspaceCwd(body.cwd);
171
+ }
172
+ catch (error) {
173
+ res.status(400).json({ error: getErrorMessage(error, "目录无效。") });
174
+ return;
175
+ }
176
+ const defaultProvider = parseDefaultProvider(body.defaultProvider);
177
+ const workspace = storage.createWorkspace({ name, cwd, defaultProvider });
178
+ res.status(201).json(workspace);
179
+ }));
180
+ // 项目详情:meta + 会话 + 布局;访问即更新 lastOpenedAt
181
+ app.get("/api/workspaces/:id", (req, res) => {
182
+ const workspace = storage.getWorkspace(req.params.id);
183
+ if (!workspace) {
184
+ res.status(404).json({ error: "未找到该项目。" });
185
+ return;
186
+ }
187
+ storage.touchWorkspace(workspace.id);
188
+ res.json({ ...workspace, sessions: storage.listSessionsByWorkspace(workspace.id) });
189
+ });
190
+ // 改名 / 目录 / 默认 IDE
191
+ app.patch("/api/workspaces/:id", (req, res) => {
192
+ const existing = storage.getWorkspace(req.params.id);
193
+ if (!existing) {
194
+ res.status(404).json({ error: "未找到该项目。" });
195
+ return;
196
+ }
197
+ const body = req.body;
198
+ const patch = {};
199
+ if (typeof body.name === "string" && body.name.trim())
200
+ patch.name = body.name.trim();
201
+ if (body.cwd !== undefined) {
202
+ try {
203
+ patch.cwd = resolveWorkspaceCwd(body.cwd);
204
+ }
205
+ catch (error) {
206
+ res.status(400).json({ error: getErrorMessage(error, "目录无效。") });
207
+ return;
208
+ }
209
+ }
210
+ if (body.defaultProvider === null) {
211
+ patch.defaultProvider = null;
212
+ }
213
+ else if (body.defaultProvider !== undefined) {
214
+ const parsed = parseDefaultProvider(body.defaultProvider);
215
+ if (parsed)
216
+ patch.defaultProvider = parsed;
217
+ }
218
+ storage.updateWorkspace(existing.id, patch);
219
+ res.json(storage.getWorkspace(existing.id));
220
+ });
221
+ // 删除项目;cascade=true 连带删会话,否则仅解绑
222
+ app.delete("/api/workspaces/:id", (req, res) => {
223
+ const cascade = req.query.cascade === "1" || req.query.cascade === "true";
224
+ const existing = storage.getWorkspace(req.params.id);
225
+ if (!existing) {
226
+ res.status(404).json({ error: "未找到该项目。" });
227
+ return;
228
+ }
229
+ const tasks = storage.listWorkspaceTasks(existing.id);
230
+ if (cascade) {
231
+ deleteSessions(storage, sessions, [
232
+ ...storage.listSessionsByWorkspace(existing.id).map((session) => session.id),
233
+ ...tasks.flatMap((task) => storage.listSessionsByWorkspaceTask(task.id).map((session) => session.id)),
234
+ ]);
235
+ }
236
+ else {
237
+ // 非级联删除可以保留项目根目录里的会话,但隔离任务的 cwd 会随
238
+ // worktree 一起消失,因此必须删除这些会话,不能留下僵尸记录。
239
+ deleteSessions(storage, sessions, tasks.flatMap((task) => task.worktree
240
+ ? storage.listSessionsByWorkspaceTask(task.id).map((session) => session.id)
241
+ : []));
242
+ }
243
+ for (const task of tasks) {
244
+ if (cascade || task.worktree)
245
+ cleanupTaskWorktree(task.worktree);
246
+ }
247
+ storage.deleteWorkspace(existing.id, { cascade });
248
+ res.json({ ok: true });
249
+ });
250
+ // 保存布局树(标签 + 分屏)
251
+ app.put("/api/workspaces/:id/layout", (req, res) => {
252
+ const existing = storage.getWorkspace(req.params.id);
253
+ if (!existing) {
254
+ res.status(404).json({ error: "未找到该项目。" });
255
+ return;
256
+ }
257
+ const body = req.body;
258
+ const layout = sanitizeLayout(body === null || typeof body !== "object" ? undefined : body.layout);
259
+ storage.saveWorkspaceLayout(existing.id, layout);
260
+ res.json({ ok: true, layout });
261
+ });
262
+ // ── 任务(Task = 命名 + 独立 worktree + 一组标签)──
263
+ // 列出某工作空间下的任务
264
+ app.get("/api/workspaces/:id/tasks", (req, res) => {
265
+ const workspace = storage.getWorkspace(req.params.id);
266
+ if (!workspace) {
267
+ res.status(404).json({ error: "未找到该项目。" });
268
+ return;
269
+ }
270
+ res.json(storage.listWorkspaceTasks(workspace.id));
271
+ });
272
+ // 新建任务:命名 + 创建独立 worktree(非 git 仓库时退化为直接用项目目录)
273
+ app.post("/api/workspaces/:id/tasks", asyncRoute(async (req, res) => {
274
+ const workspace = storage.getWorkspace(req.params.id);
275
+ if (!workspace) {
276
+ res.status(404).json({ error: "未找到该项目。" });
277
+ return;
278
+ }
279
+ const body = req.body;
280
+ const name = typeof body.name === "string" ? body.name.trim() : "";
281
+ if (!name) {
282
+ res.status(400).json({ error: "请输入任务名称。" });
283
+ return;
284
+ }
285
+ const baseRef = typeof body.baseRef === "string" && body.baseRef.trim() ? body.baseRef.trim() : undefined;
286
+ let worktree = null;
287
+ let worktreeError;
288
+ try {
289
+ const setup = prepareSessionWorktree({
290
+ cwd: workspace.cwd,
291
+ // 用随机短 id 作分支后缀,避免同名任务撞分支。
292
+ sessionId: crypto.randomUUID(),
293
+ spec: { taskName: name, baseRef },
294
+ });
295
+ worktree = setup.worktree;
296
+ }
297
+ catch (error) {
298
+ // 非 git 仓库 / 基线不存在:任务照常创建,但无 worktree 隔离。
299
+ worktreeError = getErrorMessage(error, "无法创建 worktree,将在项目目录直接运行。");
300
+ }
301
+ const task = storage.createWorkspaceTask({ workspaceId: workspace.id, name, worktree });
302
+ res.status(201).json({
303
+ ...task,
304
+ cwd: worktree?.path ?? workspace.cwd,
305
+ isolated: worktree !== null,
306
+ worktreeError,
307
+ });
308
+ }));
309
+ // 任务详情:meta + 该任务下的会话;访问即更新 lastOpenedAt
310
+ app.get("/api/workspace-tasks/:taskId", (req, res) => {
311
+ const task = storage.getWorkspaceTask(req.params.taskId);
312
+ if (!task) {
313
+ res.status(404).json({ error: "未找到该任务。" });
314
+ return;
315
+ }
316
+ storage.touchWorkspaceTask(task.id);
317
+ const workspace = storage.getWorkspace(task.workspaceId);
318
+ res.json({
319
+ ...task,
320
+ cwd: task.worktree?.path ?? workspace?.cwd ?? "",
321
+ sessions: storage.listSessionsByWorkspaceTask(task.id),
322
+ });
323
+ });
324
+ // 改名 / 状态
325
+ app.patch("/api/workspace-tasks/:taskId", (req, res) => {
326
+ const existing = storage.getWorkspaceTask(req.params.taskId);
327
+ if (!existing) {
328
+ res.status(404).json({ error: "未找到该任务。" });
329
+ return;
330
+ }
331
+ const body = req.body;
332
+ const patch = {};
333
+ if (typeof body.name === "string" && body.name.trim())
334
+ patch.name = body.name.trim();
335
+ if (body.status === "active" || body.status === "done")
336
+ patch.status = body.status;
337
+ storage.updateWorkspaceTask(existing.id, patch);
338
+ res.json(storage.getWorkspaceTask(existing.id));
339
+ });
340
+ // 删除任务;cascade=true 连带删会话,否则仅解绑;尽力清理 worktree
341
+ app.delete("/api/workspace-tasks/:taskId", (req, res) => {
342
+ const existing = storage.getWorkspaceTask(req.params.taskId);
343
+ if (!existing) {
344
+ res.status(404).json({ error: "未找到该任务。" });
345
+ return;
346
+ }
347
+ // 删除隔离任务必然会移除其 cwd,因此即使调用方没有显式传 cascade,
348
+ // 也必须同步删除会话;非隔离任务仍保留旧的“默认解绑”API 语义。
349
+ const cascade = req.query.cascade === "1"
350
+ || req.query.cascade === "true"
351
+ || existing.worktree !== null;
352
+ if (cascade) {
353
+ deleteSessions(storage, sessions, storage.listSessionsByWorkspaceTask(existing.id).map((session) => session.id));
354
+ }
355
+ // 尽力清理 worktree 与分支;失败不阻塞删除任务行。
356
+ if (cascade)
357
+ cleanupTaskWorktree(existing.worktree);
358
+ storage.deleteWorkspaceTask(existing.id, { cascade });
359
+ res.json({ ok: true });
360
+ });
361
+ // 保存任务的标签 / 分屏布局
362
+ app.put("/api/workspace-tasks/:taskId/layout", (req, res) => {
363
+ const existing = storage.getWorkspaceTask(req.params.taskId);
364
+ if (!existing) {
365
+ res.status(404).json({ error: "未找到该任务。" });
366
+ return;
367
+ }
368
+ const body = req.body;
369
+ const layout = sanitizeTaskLayout(body === null || typeof body !== "object" ? undefined : body.layout);
370
+ storage.saveWorkspaceTaskLayout(existing.id, layout);
371
+ res.json({ ok: true, layout });
372
+ });
373
+ }
package/dist/server.js CHANGED
@@ -26,6 +26,7 @@ import { registerMissionRoutes } from "./server-mission-routes.js";
26
26
  import { Missions } from "./missions.js";
27
27
  import { refreshProviderCliUpdateState, registerAdminUpdateRoutes, registerPublicUpdateRoutes, ServerUpdateState, } from "./server-update-routes.js";
28
28
  import { parseSessionCreationOrigin, registerClaudeHistoryRoutes, registerSessionRoutes } from "./server-session-routes.js";
29
+ import { registerWorkspaceRoutes } from "./server-workspace-routes.js";
29
30
  import { resolveSessionCwd } from "./session-cwd.js";
30
31
  import { getErrorMessage } from "./error-utils.js";
31
32
  import { asyncRoute, jsonErrorHandler } from "./express-async.js";
@@ -938,6 +939,7 @@ export async function startServer(config, configPath, options = {}) {
938
939
  recordRecentPath(storage, cwd);
939
940
  });
940
941
  registerClaudeHistoryRoutes(app, processes, structuredSessions, storage, sessionRegistry);
942
+ registerWorkspaceRoutes(app, storage, sessionRegistry);
941
943
  registerMissionRoutes(app, missions);
942
944
  registerUploadRoutes(app, processes);
943
945
  app.post("/api/optimize-prompt", asyncRoute(async (req, res) => {
@@ -1023,6 +1025,8 @@ export async function startServer(config, configPath, options = {}) {
1023
1025
  worktreeEnabled: body.worktreeEnabled === true,
1024
1026
  cols: reqCols,
1025
1027
  rows: reqRows,
1028
+ workspaceId: body.workspaceId,
1029
+ workspaceTaskId: body.workspaceTaskId,
1026
1030
  ...origin,
1027
1031
  })
1028
1032
  : processes.start(command, body.cwd, body.mode ?? config.defaultMode, initialInput || undefined, {
@@ -1032,6 +1036,8 @@ export async function startServer(config, configPath, options = {}) {
1032
1036
  cols: reqCols,
1033
1037
  rows: reqRows,
1034
1038
  thinkingEffort: body.thinkingEffort ?? config.defaultThinkingEffort,
1039
+ workspaceId: body.workspaceId,
1040
+ workspaceTaskId: body.workspaceTaskId,
1035
1041
  ...origin,
1036
1042
  }));
1037
1043
  recordRecentPath(storage, snapshot.cwd);
package/dist/storage.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { SessionSnapshot, ConversationTurn, StructuredSessionState } from "./types.js";
1
+ import { SessionSnapshot, ConversationTurn, StructuredSessionState, Workspace, LayoutNode, TaskWindowLayout, WorkspaceDefaultProvider, WorkspaceTask, WorkspaceTaskWorktree, WorkspaceTaskStatus } from "./types.js";
2
2
  import type { AgentActivityItem, Mission, MissionAttempt, MissionReviewComment, MissionReviewStatus, MissionStatus } from "./mission-types.js";
3
3
  import { type PasswordVault, type PasswordVaultItem, type PasswordVaultItemFilter, type PasswordVaultItemInput } from "./password-manager.js";
4
4
  export declare const DEFAULT_DB_FILE = "wand.db";
@@ -40,6 +40,46 @@ export declare class WandStorage {
40
40
  listSessionDirectoryNames(): Map<string, string>;
41
41
  /** Set a workspace label, or remove it when name is null/blank. */
42
42
  setSessionDirectoryName(directoryPath: string, name: string | null): void;
43
+ listWorkspaces(): Workspace[];
44
+ getWorkspace(id: string): Workspace | null;
45
+ createWorkspace(input: {
46
+ name: string;
47
+ cwd: string;
48
+ defaultProvider?: WorkspaceDefaultProvider;
49
+ }): Workspace;
50
+ updateWorkspace(id: string, patch: {
51
+ name?: string;
52
+ cwd?: string;
53
+ defaultProvider?: WorkspaceDefaultProvider | null;
54
+ }): void;
55
+ saveWorkspaceLayout(id: string, layout: LayoutNode | null): void;
56
+ touchWorkspace(id: string): void;
57
+ deleteWorkspace(id: string, options?: {
58
+ cascade?: boolean;
59
+ }): void;
60
+ listSessionsByWorkspace(workspaceId: string): SessionSnapshot[];
61
+ /** 显式更新某会话的工作空间归属(用于创建时绑定)。 */
62
+ setSessionWorkspaceId(sessionId: string, workspaceId: string | null): void;
63
+ listWorkspaceTasks(workspaceId: string): WorkspaceTask[];
64
+ getWorkspaceTask(id: string): WorkspaceTask | null;
65
+ createWorkspaceTask(input: {
66
+ workspaceId: string;
67
+ name: string;
68
+ worktree?: WorkspaceTaskWorktree | null;
69
+ status?: WorkspaceTaskStatus;
70
+ }): WorkspaceTask;
71
+ updateWorkspaceTask(id: string, patch: {
72
+ name?: string;
73
+ status?: WorkspaceTaskStatus;
74
+ worktree?: WorkspaceTaskWorktree | null;
75
+ }): void;
76
+ saveWorkspaceTaskLayout(id: string, layout: TaskWindowLayout | null): void;
77
+ touchWorkspaceTask(id: string): void;
78
+ deleteWorkspaceTask(id: string, options?: {
79
+ cascade?: boolean;
80
+ }): void;
81
+ listSessionsByWorkspaceTask(taskId: string): SessionSnapshot[];
82
+ setSessionWorkspaceTaskId(sessionId: string, taskId: string | null): void;
43
83
  /** Get password from database */
44
84
  getPassword(): string | null;
45
85
  /** Set password in database */