@co0ontty/wand 4.53.0 → 4.54.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": "dd763b29e0d0810f30a090302594ebe40a2e0c78",
3
- "builtAt": "2026-09-06T07:59:01.447Z",
4
- "version": "4.53.0",
2
+ "commit": "cb99d899728b5b5a2570b5bec4fce6882449ddb8",
3
+ "builtAt": "2026-09-07T01:21:35.988Z",
4
+ "version": "4.54.0",
5
5
  "channel": "stable"
6
6
  }
@@ -1208,7 +1208,7 @@ async function runQuickCommitFallbackCli(opts, priorError) {
1208
1208
  });
1209
1209
  }
1210
1210
  else if (provider === "opencode") {
1211
- const args = ["run", "--format", "json", "--dangerously-skip-permissions"];
1211
+ const args = ["run", "--format", "json", "--auto"];
1212
1212
  const model = opts.model?.trim();
1213
1213
  if (model && model !== "default")
1214
1214
  args.push("--model", model);
@@ -14,7 +14,7 @@ import { buildLanguageDirective, buildManagedAutonomyDirective } from "./languag
14
14
  import { prepareSessionWorktree } from "./git-worktree.js";
15
15
  import { getProviderCommandSessionId, getProviderResumeCommandSessionId } from "./resume-policy.js";
16
16
  import { normalizeThinkingEffort, thinkingEffortToClaudeCliEffort, thinkingEffortToClaudeSlashEffort, thinkingEffortToCodexReasoningEffort, thinkingEffortToOpenCodeVariant, thinkingEffortToPiLevel } from "./structured-provider-common.js";
17
- import { provisionalSessionTopic, SessionTopicCoordinator, sessionTopicBlocklistForSnapshot, shouldAcceptGeneratedSessionTitle, shouldGenerateSessionTopicFromPtyInput, } from "./session-topic.js";
17
+ import { consumePtyInputForTopic, createPtyTopicLineBuffer, provisionalSessionTopic, SessionTopicCoordinator, sessionTopicBlocklistForSnapshot, shouldAcceptGeneratedSessionTitle, } from "./session-topic.js";
18
18
  import { getErrorMessage } from "./error-utils.js";
19
19
  import { resolveSystemAiContext } from "./session-ai-context.js";
20
20
  import { resolveSessionCwd } from "./session-cwd.js";
@@ -1497,8 +1497,11 @@ export class ProcessManager extends EventEmitter {
1497
1497
  console.error(`[ProcessManager] Rejecting input: session ${id} has no PTY`);
1498
1498
  throw new SessionInputError("Session is not running.", "SESSION_NO_PTY", id, record.status);
1499
1499
  }
1500
- if (shouldGenerateSessionTopicFromPtyInput(view, shortcutKey))
1501
- this.maybeGenerateSessionTopic(id, input);
1500
+ if (!record.ptyTopicDraft)
1501
+ record.ptyTopicDraft = createPtyTopicLineBuffer();
1502
+ const topicInput = consumePtyInputForTopic(record.ptyTopicDraft, input, view, shortcutKey);
1503
+ if (topicInput)
1504
+ this.maybeGenerateSessionTopic(id, topicInput);
1502
1505
  // Log shortcut key interactions for auto-confirm and mode analysis
1503
1506
  if (shortcutKey) {
1504
1507
  const outputLines = record.output.split("\n");
@@ -508,7 +508,7 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
508
508
  const body = req.body;
509
509
  try {
510
510
  if (body.provider && body.provider !== "claude" && body.provider !== "codex" && body.provider !== "opencode" && body.provider !== "grok" && body.provider !== "qoder" && body.provider !== "pi") {
511
- res.status(400).json({ error: "结构化会话当前仅支持 Claude、Codex、OpenCode、Grok 或 Qoder provider。" });
511
+ res.status(400).json({ error: "结构化会话当前仅支持 Claude、Codex、OpenCode、Grok、Qoder 或 Pi provider。" });
512
512
  return;
513
513
  }
514
514
  const provider = body.provider === "codex" || body.provider === "opencode" || body.provider === "grok" || body.provider === "qoder" || body.provider === "pi" ? body.provider : "claude";
@@ -1,7 +1,7 @@
1
1
  import type { Express } from "express";
2
2
  import type { SessionRegistry } from "./session-registry.js";
3
3
  import type { WandStorage } from "./storage.js";
4
- import type { LayoutNode, TaskWindowLayout } from "./types.js";
4
+ import { type LayoutNode, type TaskWindowLayout } from "./types.js";
5
5
  /** 校验并清洗前端提交的布局树;非法输入返回 null(空工作空间)。 */
6
6
  export declare function sanitizeLayout(value: unknown): LayoutNode | null;
7
7
  /** 校验任务级工作窗口集合;旧版单棵布局会兼容升级成一个 window。 */
@@ -7,6 +7,7 @@ import { expandHomePath } from "./middleware/path-safety.js";
7
7
  import { checkSessionWorktreeMergeabilityAsync, cleanupWorktreeSync, prepareSessionWorktree, resolveWorktreeTargetBranchAsync, } from "./git-worktree.js";
8
8
  import { collectSessionTopicBlocklist } from "./session-topic.js";
9
9
  import { resolveSessionDisplayTitle } from "./session-transport.js";
10
+ import { GLOBAL_WORKSPACE_ID } from "./types.js";
10
11
  import { attachUnboundSessionsToWorkspace, backfillSessionWorkspaces, } from "./workspace-binding.js";
11
12
  const PROVIDERS = new Set(["claude", "codex", "opencode", "grok", "qoder", "pi"]);
12
13
  function workspaceSessionTitle(session, names = {}) {
@@ -62,6 +63,52 @@ function deleteSessions(storage, sessions, sessionIds) {
62
63
  storage.deleteSession(sessionId);
63
64
  }
64
65
  }
66
+ function isGlobalWorkspace(workspace) {
67
+ return workspace.kind === "global" || workspace.id === GLOBAL_WORKSPACE_ID;
68
+ }
69
+ function taskRuntimeCwd(task, workspace) {
70
+ return task.worktree?.path ?? task.cwd ?? workspace?.cwd ?? "";
71
+ }
72
+ function createTaskForWorkspace(storage, workspace, body) {
73
+ const name = typeof body.name === "string" ? body.name.trim() : "";
74
+ if (!name)
75
+ throw new Error("请输入任务名称。");
76
+ let mountedCwd;
77
+ if (body.cwd !== undefined && body.cwd !== null && String(body.cwd).trim()) {
78
+ mountedCwd = resolveWorkspaceCwd(body.cwd);
79
+ }
80
+ const baseRef = typeof body.baseRef === "string" && body.baseRef.trim() ? body.baseRef.trim() : undefined;
81
+ const runCwd = mountedCwd ?? workspace.cwd;
82
+ const wantWorktree = body.worktree === true || (body.worktree !== false && !isGlobalWorkspace(workspace));
83
+ let worktree = null;
84
+ let worktreeError;
85
+ if (wantWorktree) {
86
+ try {
87
+ const setup = prepareSessionWorktree({
88
+ cwd: runCwd,
89
+ sessionId: crypto.randomUUID(),
90
+ spec: { taskName: name, baseRef },
91
+ });
92
+ worktree = setup.worktree;
93
+ }
94
+ catch (error) {
95
+ worktreeError = getErrorMessage(error, "无法创建 worktree,将在项目目录直接运行。");
96
+ }
97
+ }
98
+ const storedCwd = mountedCwd && mountedCwd !== workspace.cwd ? mountedCwd : undefined;
99
+ const task = storage.createWorkspaceTask({
100
+ workspaceId: workspace.id,
101
+ name,
102
+ worktree,
103
+ cwd: storedCwd,
104
+ });
105
+ return {
106
+ task,
107
+ cwd: taskRuntimeCwd(task, workspace),
108
+ isolated: worktree !== null,
109
+ worktreeError,
110
+ };
111
+ }
65
112
  function workspaceWithCounts(storage, workspace, sessionCounts) {
66
113
  const worktreeCount = storage.listWorkspaceTasks(workspace.id)
67
114
  .filter((task) => task.worktree !== null)
@@ -185,7 +232,9 @@ export function registerWorkspaceRoutes(app, storage, sessions) {
185
232
  app.get("/api/workspaces", (_req, res) => {
186
233
  backfillSessionWorkspaces(storage);
187
234
  const sessionCounts = storage.countSessionsByWorkspace();
188
- res.json(storage.listWorkspaces().map((workspace) => workspaceWithCounts(storage, workspace, sessionCounts)));
235
+ res.json(storage.listWorkspaces()
236
+ .filter((workspace) => !isGlobalWorkspace(workspace))
237
+ .map((workspace) => workspaceWithCounts(storage, workspace, sessionCounts)));
189
238
  });
190
239
  // 新建项目:名称 + 目录 + 默认 IDE,不启动会话
191
240
  app.post("/api/workspaces", asyncRoute(async (req, res) => {
@@ -264,6 +313,10 @@ export function registerWorkspaceRoutes(app, storage, sessions) {
264
313
  res.status(404).json({ error: "未找到该项目。" });
265
314
  return;
266
315
  }
316
+ if (isGlobalWorkspace(existing)) {
317
+ res.status(400).json({ error: "不能删除全局任务空间。" });
318
+ return;
319
+ }
267
320
  const tasks = storage.listWorkspaceTasks(existing.id);
268
321
  if (cascade) {
269
322
  deleteSessions(storage, sessions, [
@@ -298,6 +351,22 @@ export function registerWorkspaceRoutes(app, storage, sessions) {
298
351
  res.json({ ok: true, layout });
299
352
  });
300
353
  // ── 任务(Task = 命名 + 独立 worktree + 一组标签)──
354
+ // 不依赖项目的独立任务:挂到隐藏的全局空间,目录可省略(使用全局临时目录)。
355
+ app.post("/api/tasks", asyncRoute(async (req, res) => {
356
+ const workspace = storage.ensureGlobalWorkspace();
357
+ try {
358
+ const created = createTaskForWorkspace(storage, workspace, req.body);
359
+ res.status(201).json({
360
+ ...created.task,
361
+ cwd: created.cwd,
362
+ isolated: created.isolated,
363
+ worktreeError: created.worktreeError,
364
+ });
365
+ }
366
+ catch (error) {
367
+ res.status(400).json({ error: getErrorMessage(error, "创建任务失败。") });
368
+ }
369
+ }));
301
370
  // 目录组为一级容器的任务聚合列表,供侧栏「任务」视图一次拉全。
302
371
  app.get("/api/tasks", (req, res) => {
303
372
  // 查询参数:workspaceId 过滤单目录;limit 截断每目录任务数;
@@ -317,10 +386,12 @@ export function registerWorkspaceRoutes(app, storage, sessions) {
317
386
  : workspaces;
318
387
  const groups = new Map();
319
388
  for (const workspace of visibleWorkspaces) {
389
+ const global = isGlobalWorkspace(workspace);
320
390
  groups.set(workspace.id, {
321
391
  workspaceId: workspace.id,
322
- workspaceName: workspace.name,
392
+ workspaceName: global ? "全局" : workspace.name,
323
393
  workspaceCwd: workspace.cwd,
394
+ ...(global ? { global: true } : {}),
324
395
  tasks: storage.listWorkspaceTasks(workspace.id)
325
396
  .slice(0, taskLimit ?? undefined)
326
397
  .map((task) => {
@@ -328,12 +399,12 @@ export function registerWorkspaceRoutes(app, storage, sessions) {
328
399
  const sessions = allSessions
329
400
  .slice(0, sessionLimit ?? undefined)
330
401
  .map((session) => ({
331
- ...summarize(session, { taskName: task.name, workspaceName: workspace.name }),
402
+ ...summarize(session, { taskName: task.name, workspaceName: global ? undefined : workspace.name }),
332
403
  workspaceTaskId: task.id,
333
404
  }));
334
405
  return {
335
406
  ...task,
336
- cwd: task.worktree?.path ?? workspace.cwd,
407
+ cwd: taskRuntimeCwd(task, workspace),
337
408
  isolated: task.worktree !== null,
338
409
  sessions,
339
410
  totalSessions: allSessions.length,
@@ -356,7 +427,8 @@ export function registerWorkspaceRoutes(app, storage, sessions) {
356
427
  let group = direct && !direct.synthetic ? direct : undefined;
357
428
  const resolved = session.cwd ? path.resolve(session.cwd) : "";
358
429
  if (!group && resolved) {
359
- group = [...groups.values()].find((candidate) => !candidate.synthetic && candidate.workspaceCwd === resolved);
430
+ const cwdMatches = [...groups.values()].filter((candidate) => !candidate.synthetic && candidate.workspaceCwd === resolved);
431
+ group = cwdMatches.find((candidate) => !candidate.global) ?? cwdMatches[0];
360
432
  }
361
433
  // 过滤模式下不创建合成组:不属于目标目录的会话直接排除。
362
434
  if (!group && resolved && !workspaceFilter) {
@@ -382,7 +454,9 @@ export function registerWorkspaceRoutes(app, storage, sessions) {
382
454
  workspaceName: group.workspaceName,
383
455
  }));
384
456
  }
385
- const payload = [...groups.values()];
457
+ const payload = [...groups.values()]
458
+ .filter((group) => !group.global || group.tasks.length > 0 || group.standaloneSessions.length > 0)
459
+ .sort((left, right) => Number(Boolean(right.global)) - Number(Boolean(left.global)));
386
460
  const revision = tasksRevision(payload);
387
461
  if (typeof req.query.revision === "string") {
388
462
  if (req.query.revision === revision) {
@@ -487,38 +561,18 @@ export function registerWorkspaceRoutes(app, storage, sessions) {
487
561
  res.status(404).json({ error: "未找到该项目。" });
488
562
  return;
489
563
  }
490
- const body = req.body;
491
- const name = typeof body.name === "string" ? body.name.trim() : "";
492
- if (!name) {
493
- res.status(400).json({ error: "请输入任务名称。" });
494
- return;
564
+ try {
565
+ const created = createTaskForWorkspace(storage, workspace, req.body);
566
+ res.status(201).json({
567
+ ...created.task,
568
+ cwd: created.cwd,
569
+ isolated: created.isolated,
570
+ worktreeError: created.worktreeError,
571
+ });
495
572
  }
496
- const baseRef = typeof body.baseRef === "string" && body.baseRef.trim() ? body.baseRef.trim() : undefined;
497
- const wantWorktree = body.worktree !== false;
498
- let worktree = null;
499
- let worktreeError;
500
- if (wantWorktree) {
501
- try {
502
- const setup = prepareSessionWorktree({
503
- cwd: workspace.cwd,
504
- // 用随机短 id 作分支后缀,避免同名任务撞分支。
505
- sessionId: crypto.randomUUID(),
506
- spec: { taskName: name, baseRef },
507
- });
508
- worktree = setup.worktree;
509
- }
510
- catch (error) {
511
- // 非 git 仓库 / 基线不存在:任务照常创建,但无 worktree 隔离。
512
- worktreeError = getErrorMessage(error, "无法创建 worktree,将在项目目录直接运行。");
513
- }
573
+ catch (error) {
574
+ res.status(400).json({ error: getErrorMessage(error, "创建任务失败。") });
514
575
  }
515
- const task = storage.createWorkspaceTask({ workspaceId: workspace.id, name, worktree });
516
- res.status(201).json({
517
- ...task,
518
- cwd: worktree?.path ?? workspace.cwd,
519
- isolated: worktree !== null,
520
- worktreeError,
521
- });
522
576
  }));
523
577
  // 任务详情:meta + 该任务下的会话;访问即更新 lastOpenedAt
524
578
  app.get("/api/workspace-tasks/:taskId", (req, res) => {
@@ -531,7 +585,7 @@ export function registerWorkspaceRoutes(app, storage, sessions) {
531
585
  const workspace = storage.getWorkspace(task.workspaceId);
532
586
  res.json({
533
587
  ...task,
534
- cwd: task.worktree?.path ?? workspace?.cwd ?? "",
588
+ cwd: taskRuntimeCwd(task, workspace),
535
589
  sessions: storage.listSessionsByWorkspaceTask(task.id).map((session) => ({
536
590
  ...workspaceSessionSummary(session, {
537
591
  taskName: task.name,
@@ -1,10 +1,22 @@
1
1
  import { type QuickCommitAiOptions } from "./git-quick-commit.js";
2
2
  import type { ConversationTurn } from "./types.js";
3
3
  /**
4
- * PTY 终端视图里,只有底部输入框整段提交(shortcutKey=enter_text)才总结标题。
5
- * 逐键 pty_input / 方向键 / 单独回车不能触发,避免把按键当主题。
4
+ * PTY 底部输入框整段提交(shortcutKey=enter_text)会总结标题。
5
+ * 终端直通键入没有这个标记:服务端把可打印字符拼成一行,回车后再总结。
6
+ * 方向键 / 单独回车 / y/n / 控制序列不能触发,避免把按键当主题。
6
7
  */
7
8
  export declare function shouldGenerateSessionTopicFromPtyInput(view?: "chat" | "terminal", shortcutKey?: string): boolean;
9
+ export interface PtyTopicLineBuffer {
10
+ text: string;
11
+ }
12
+ export declare function createPtyTopicLineBuffer(): PtyTopicLineBuffer;
13
+ /** Raw TTY lines that are almost certainly not a user prompt. */
14
+ export declare function isPtyTypedTopicCandidate(prompt: string): boolean;
15
+ /**
16
+ * Decide whether this PTY write completes a user prompt worth summarizing.
17
+ * Mutates `buffer` so keystroke-by-keystroke terminal input can be assembled.
18
+ */
19
+ export declare function consumePtyInputForTopic(buffer: PtyTopicLineBuffer, input: string, view?: "chat" | "terminal", shortcutKey?: string): string | null;
8
20
  export interface SessionTopic {
9
21
  title: string;
10
22
  description: string;
@@ -1,12 +1,117 @@
1
1
  import { callConfiguredAiText } from "./git-quick-commit.js";
2
+ import { skipAnsiSequence } from "./pty-text-utils.js";
2
3
  const MAX_PROMPT_LENGTH = 12_000;
4
+ const PTY_TOPIC_DRAFT_MAX = 4_000;
3
5
  /**
4
- * PTY 终端视图里,只有底部输入框整段提交(shortcutKey=enter_text)才总结标题。
5
- * 逐键 pty_input / 方向键 / 单独回车不能触发,避免把按键当主题。
6
+ * PTY 底部输入框整段提交(shortcutKey=enter_text)会总结标题。
7
+ * 终端直通键入没有这个标记:服务端把可打印字符拼成一行,回车后再总结。
8
+ * 方向键 / 单独回车 / y/n / 控制序列不能触发,避免把按键当主题。
6
9
  */
7
10
  export function shouldGenerateSessionTopicFromPtyInput(view, shortcutKey) {
8
11
  return view !== "terminal" || shortcutKey === "enter_text";
9
12
  }
13
+ export function createPtyTopicLineBuffer() {
14
+ return { text: "" };
15
+ }
16
+ function stripTrailingNewlines(input) {
17
+ return input.replace(/[\r\n]+$/g, "").trim();
18
+ }
19
+ function popCodePoint(text) {
20
+ const chars = Array.from(text);
21
+ chars.pop();
22
+ return chars.join("");
23
+ }
24
+ /** Raw TTY lines that are almost certainly not a user prompt. */
25
+ export function isPtyTypedTopicCandidate(prompt) {
26
+ if (!prompt)
27
+ return false;
28
+ if (/^[yn]$/i.test(prompt))
29
+ return false;
30
+ if (/^\d+$/.test(prompt))
31
+ return false;
32
+ if (/^\/[a-z][\w-]*$/i.test(prompt))
33
+ return false;
34
+ if (/[\u3400-\u9fff]/.test(prompt))
35
+ return true;
36
+ if (/\s/.test(prompt) && prompt.length >= 2)
37
+ return true;
38
+ return prompt.length >= 8;
39
+ }
40
+ function flushPtyTopicDraft(buffer) {
41
+ const prompt = buffer.text.replace(/\s+/g, " ").trim();
42
+ buffer.text = "";
43
+ return isPtyTypedTopicCandidate(prompt) ? prompt : null;
44
+ }
45
+ function consumeRawPtyTopicLine(buffer, input) {
46
+ let submitted = null;
47
+ let i = 0;
48
+ while (i < input.length) {
49
+ const code = input.charCodeAt(i);
50
+ if (code === 0x1b) {
51
+ i = skipAnsiSequence(input, i);
52
+ continue;
53
+ }
54
+ if (code === 0x0d) {
55
+ const flushed = flushPtyTopicDraft(buffer);
56
+ if (flushed && !submitted)
57
+ submitted = flushed;
58
+ i += input.charCodeAt(i + 1) === 0x0a ? 2 : 1;
59
+ continue;
60
+ }
61
+ if (code === 0x08 || code === 0x7f) {
62
+ buffer.text = popCodePoint(buffer.text);
63
+ i += 1;
64
+ continue;
65
+ }
66
+ if (code === 0x03 || code === 0x15) {
67
+ buffer.text = "";
68
+ i += 1;
69
+ continue;
70
+ }
71
+ if (code === 0x0a) {
72
+ if (buffer.text)
73
+ buffer.text += "\n";
74
+ i += 1;
75
+ continue;
76
+ }
77
+ if (code < 32) {
78
+ i += 1;
79
+ continue;
80
+ }
81
+ const point = input.codePointAt(i) ?? code;
82
+ buffer.text += String.fromCodePoint(point);
83
+ i += point > 0xffff ? 2 : 1;
84
+ if (buffer.text.length > PTY_TOPIC_DRAFT_MAX) {
85
+ buffer.text = buffer.text.slice(-PTY_TOPIC_DRAFT_MAX);
86
+ }
87
+ }
88
+ return submitted;
89
+ }
90
+ /**
91
+ * Decide whether this PTY write completes a user prompt worth summarizing.
92
+ * Mutates `buffer` so keystroke-by-keystroke terminal input can be assembled.
93
+ */
94
+ export function consumePtyInputForTopic(buffer, input, view, shortcutKey) {
95
+ if (view !== "terminal") {
96
+ buffer.text = "";
97
+ const prompt = stripTrailingNewlines(input);
98
+ return prompt && shouldGenerateSessionTopicFromPtyInput(view, shortcutKey) ? prompt : null;
99
+ }
100
+ if (shortcutKey === "enter_text") {
101
+ buffer.text = "";
102
+ return stripTrailingNewlines(input) || null;
103
+ }
104
+ if (shortcutKey) {
105
+ if (shortcutKey === "ctrl_c"
106
+ || shortcutKey === "ctrl_u"
107
+ || shortcutKey === "ctrl_d"
108
+ || shortcutKey === "escape") {
109
+ buffer.text = "";
110
+ }
111
+ return null;
112
+ }
113
+ return consumeRawPtyTopicLine(buffer, input);
114
+ }
10
115
  const TITLE_MAX_LENGTH = 24;
11
116
  const DESCRIPTION_MAX_LENGTH = 120;
12
117
  function cleanTopicText(value, maxLength) {
package/dist/storage.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { SessionSnapshot, ConversationTurn, StructuredSessionState, Workspace, LayoutNode, TaskWindowLayout, WorkspaceDefaultProvider, WorkspaceTask, WorkspaceTaskWorktree, WorkspaceTaskStatus } from "./types.js";
1
+ import { SessionSnapshot, ConversationTurn, StructuredSessionState, Workspace, LayoutNode, TaskWindowLayout, WorkspaceDefaultProvider, WorkspaceKind, 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
  type AuthPrincipalKind = "browser-admin" | "connected-app";
@@ -16,7 +16,9 @@ export declare function resolveDatabasePath(configPath: string): string;
16
16
  export declare function ensureDatabaseFile(dbPath: string): boolean;
17
17
  export declare class WandStorage {
18
18
  private readonly db;
19
+ private readonly dbPath;
19
20
  constructor(dbPath: string);
21
+ directory(): string;
20
22
  close(): void;
21
23
  /**
22
24
  * Run a synchronous group of storage operations atomically. Calls must not
@@ -42,10 +44,13 @@ export declare class WandStorage {
42
44
  listWorkspaces(): Workspace[];
43
45
  getWorkspace(id: string): Workspace | null;
44
46
  createWorkspace(input: {
47
+ id?: string;
45
48
  name: string;
46
49
  cwd: string;
50
+ kind?: WorkspaceKind;
47
51
  defaultProvider?: WorkspaceDefaultProvider;
48
52
  }): Workspace;
53
+ ensureGlobalWorkspace(): Workspace;
49
54
  updateWorkspace(id: string, patch: {
50
55
  name?: string;
51
56
  cwd?: string;
@@ -73,6 +78,7 @@ export declare class WandStorage {
73
78
  workspaceId: string;
74
79
  name: string;
75
80
  worktree?: WorkspaceTaskWorktree | null;
81
+ cwd?: string | null;
76
82
  status?: WorkspaceTaskStatus;
77
83
  }): WorkspaceTask;
78
84
  updateWorkspaceTask(id: string, patch: {
package/dist/storage.js CHANGED
@@ -2,6 +2,7 @@ import crypto from "node:crypto";
2
2
  import { chmodSync, existsSync, mkdirSync } from "node:fs";
3
3
  import path from "node:path";
4
4
  import { DatabaseSync } from "node:sqlite";
5
+ import { GLOBAL_WORKSPACE_ID } from "./types.js";
5
6
  import { normalizeSessionDirectory } from "./session-directory-tree.js";
6
7
  import { DEFAULT_PASSWORD_VAULT_ID, DEFAULT_PASSWORD_VAULT_NAME, decryptVaultSecret, encryptVaultSecret, itemMatchesFilter, normalizePasswordItemInput, normalizeVaultName, nowIso, } from "./password-manager.js";
7
8
  function safeJsonParse(raw) {
@@ -257,11 +258,15 @@ function sessionSelectFields() {
257
258
  return `id, session_source, automation_id, provider, session_kind, runner, command, cwd, mode, status, exit_code, started_at, ended_at, output, pty_output_seq, archived, archived_at, claude_session_id, messages, queued_messages, queued_message_skills, structured_state
258
259
  , resumed_from_session_id, auto_recovered, worktree_enabled, worktree_info, worktree_merge_status, worktree_merge_info, title, description, session_options, workspace_id, workspace_task_id`;
259
260
  }
261
+ function mapWorkspaceKind(raw) {
262
+ return raw === "global" ? "global" : "project";
263
+ }
260
264
  function mapWorkspaceRow(row) {
261
265
  return {
262
266
  id: row.id,
263
267
  name: row.name,
264
268
  cwd: row.cwd,
269
+ kind: mapWorkspaceKind(row.kind),
265
270
  defaultProvider: (row.default_provider ?? undefined),
266
271
  layout: row.layout_json ? safeJsonParse(row.layout_json) ?? null : null,
267
272
  createdAt: row.created_at,
@@ -299,11 +304,13 @@ function mapWorkspaceTaskLayout(raw) {
299
304
  return null;
300
305
  }
301
306
  function mapWorkspaceTaskRow(row) {
307
+ const cwd = typeof row.cwd === "string" && row.cwd.trim() ? row.cwd : undefined;
302
308
  return {
303
309
  id: row.id,
304
310
  workspaceId: row.workspace_id,
305
311
  name: row.name,
306
312
  worktree: mapWorkspaceTaskWorktree(row.worktree_json),
313
+ ...(cwd ? { cwd } : {}),
307
314
  layout: mapWorkspaceTaskLayout(row.layout_json),
308
315
  status: (row.status === "done" ? "done" : "active"),
309
316
  createdAt: row.created_at,
@@ -631,6 +638,7 @@ const INIT_SQL = `
631
638
  id TEXT PRIMARY KEY,
632
639
  name TEXT NOT NULL,
633
640
  cwd TEXT NOT NULL,
641
+ kind TEXT NOT NULL DEFAULT 'project',
634
642
  default_provider TEXT,
635
643
  layout_json TEXT,
636
644
  created_at TEXT NOT NULL,
@@ -647,6 +655,7 @@ const INIT_SQL = `
647
655
  worktree_json TEXT,
648
656
  layout_json TEXT,
649
657
  status TEXT NOT NULL DEFAULT 'active',
658
+ cwd TEXT,
650
659
  created_at TEXT NOT NULL,
651
660
  last_opened_at TEXT,
652
661
  FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE
@@ -655,6 +664,18 @@ const INIT_SQL = `
655
664
  CREATE INDEX IF NOT EXISTS idx_workspace_tasks_workspace ON workspace_tasks(workspace_id);
656
665
  CREATE INDEX IF NOT EXISTS idx_workspace_tasks_last_opened ON workspace_tasks(last_opened_at);
657
666
  `;
667
+ function ensureWorkspaceSchema(db) {
668
+ const workspaceColumns = db.prepare("PRAGMA table_info(workspaces)").all();
669
+ const workspaceNames = new Set(workspaceColumns.map((column) => column.name));
670
+ if (workspaceColumns.length > 0 && !workspaceNames.has("kind")) {
671
+ db.exec("ALTER TABLE workspaces ADD COLUMN kind TEXT NOT NULL DEFAULT 'project'");
672
+ }
673
+ const taskColumns = db.prepare("PRAGMA table_info(workspace_tasks)").all();
674
+ const taskNames = new Set(taskColumns.map((column) => column.name));
675
+ if (taskColumns.length > 0 && !taskNames.has("cwd")) {
676
+ db.exec("ALTER TABLE workspace_tasks ADD COLUMN cwd TEXT");
677
+ }
678
+ }
658
679
  export function ensureDatabaseFile(dbPath) {
659
680
  const dir = path.dirname(dbPath);
660
681
  mkdirSync(dir, { recursive: true, mode: 0o700 });
@@ -664,6 +685,7 @@ export function ensureDatabaseFile(dbPath) {
664
685
  db.exec(INIT_SQL);
665
686
  ensureAuthSessionSchema(db);
666
687
  ensureCommandSessionSchema(db);
688
+ ensureWorkspaceSchema(db);
667
689
  {
668
690
  const missionColumns = db.prepare("PRAGMA table_info(missions)").all();
669
691
  if (missionColumns.length > 0 && !missionColumns.some((column) => column.name === "task_id")) {
@@ -676,17 +698,23 @@ export function ensureDatabaseFile(dbPath) {
676
698
  }
677
699
  export class WandStorage {
678
700
  db;
701
+ dbPath;
679
702
  constructor(dbPath) {
680
703
  const dir = path.dirname(dbPath);
681
704
  mkdirSync(dir, { recursive: true, mode: 0o700 });
682
705
  chmodSync(dir, 0o700);
706
+ this.dbPath = dbPath;
683
707
  this.db = new DatabaseSync(dbPath);
684
708
  chmodSync(dbPath, 0o600);
685
709
  this.db.exec(INIT_SQL);
686
710
  ensureAuthSessionSchema(this.db);
687
711
  ensureCommandSessionSchema(this.db);
712
+ ensureWorkspaceSchema(this.db);
688
713
  this.ensureDefaultPasswordVault();
689
714
  }
715
+ directory() {
716
+ return path.dirname(this.dbPath);
717
+ }
690
718
  close() {
691
719
  this.db.close();
692
720
  }
@@ -786,33 +814,49 @@ export class WandStorage {
786
814
  // ============ Workspaces(多标签 / 分屏项目)============
787
815
  listWorkspaces() {
788
816
  const rows = this.db
789
- .prepare("SELECT id, name, cwd, default_provider, layout_json, created_at, last_opened_at FROM workspaces ORDER BY COALESCE(last_opened_at, created_at) DESC")
817
+ .prepare("SELECT id, name, cwd, kind, default_provider, layout_json, created_at, last_opened_at FROM workspaces ORDER BY COALESCE(last_opened_at, created_at) DESC")
790
818
  .all();
791
819
  return rows.map(mapWorkspaceRow);
792
820
  }
793
821
  getWorkspace(id) {
794
822
  const row = this.db
795
- .prepare("SELECT id, name, cwd, default_provider, layout_json, created_at, last_opened_at FROM workspaces WHERE id = ?")
823
+ .prepare("SELECT id, name, cwd, kind, default_provider, layout_json, created_at, last_opened_at FROM workspaces WHERE id = ?")
796
824
  .get(id);
797
825
  return row ? mapWorkspaceRow(row) : null;
798
826
  }
799
827
  createWorkspace(input) {
800
- const id = crypto.randomUUID();
828
+ const id = input.id?.trim() || crypto.randomUUID();
829
+ const kind = input.kind === "global" ? "global" : "project";
801
830
  const createdAt = nowIso();
802
831
  this.db
803
- .prepare(`INSERT INTO workspaces (id, name, cwd, default_provider, layout_json, created_at, last_opened_at)
804
- VALUES (?, ?, ?, ?, NULL, ?, NULL)`)
805
- .run(id, input.name, input.cwd, input.defaultProvider ?? null, createdAt);
832
+ .prepare(`INSERT INTO workspaces (id, name, cwd, kind, default_provider, layout_json, created_at, last_opened_at)
833
+ VALUES (?, ?, ?, ?, ?, NULL, ?, NULL)`)
834
+ .run(id, input.name, input.cwd, kind, input.defaultProvider ?? null, createdAt);
806
835
  return {
807
836
  id,
808
837
  name: input.name,
809
838
  cwd: input.cwd,
839
+ kind,
810
840
  defaultProvider: input.defaultProvider,
811
841
  layout: null,
812
842
  createdAt,
813
843
  lastOpenedAt: null,
814
844
  };
815
845
  }
846
+ ensureGlobalWorkspace() {
847
+ const existing = this.getWorkspace(GLOBAL_WORKSPACE_ID)
848
+ ?? this.listWorkspaces().find((workspace) => workspace.kind === "global");
849
+ if (existing)
850
+ return existing;
851
+ const scratch = path.join(this.directory(), "scratch");
852
+ mkdirSync(scratch, { recursive: true, mode: 0o700 });
853
+ return this.createWorkspace({
854
+ id: GLOBAL_WORKSPACE_ID,
855
+ name: "全局任务",
856
+ cwd: scratch,
857
+ kind: "global",
858
+ });
859
+ }
816
860
  updateWorkspace(id, patch) {
817
861
  const assignments = [];
818
862
  const values = [];
@@ -894,7 +938,7 @@ export class WandStorage {
894
938
  // ── Workspace tasks(任务 = 命名 + 独立 worktree + 一组标签)──
895
939
  listWorkspaceTasks(workspaceId) {
896
940
  const rows = this.db
897
- .prepare(`SELECT id, workspace_id, name, worktree_json, layout_json, status, created_at, last_opened_at
941
+ .prepare(`SELECT id, workspace_id, name, worktree_json, layout_json, status, cwd, created_at, last_opened_at
898
942
  FROM workspace_tasks WHERE workspace_id = ?
899
943
  ORDER BY COALESCE(last_opened_at, created_at) DESC`)
900
944
  .all(workspaceId);
@@ -902,7 +946,7 @@ export class WandStorage {
902
946
  }
903
947
  getWorkspaceTask(id) {
904
948
  const row = this.db
905
- .prepare(`SELECT id, workspace_id, name, worktree_json, layout_json, status, created_at, last_opened_at
949
+ .prepare(`SELECT id, workspace_id, name, worktree_json, layout_json, status, cwd, created_at, last_opened_at
906
950
  FROM workspace_tasks WHERE id = ?`)
907
951
  .get(id);
908
952
  return row ? mapWorkspaceTaskRow(row) : null;
@@ -911,15 +955,17 @@ export class WandStorage {
911
955
  const id = crypto.randomUUID();
912
956
  const createdAt = nowIso();
913
957
  const status = input.status ?? "active";
958
+ const cwd = typeof input.cwd === "string" && input.cwd.trim() ? input.cwd.trim() : null;
914
959
  this.db
915
- .prepare(`INSERT INTO workspace_tasks (id, workspace_id, name, worktree_json, layout_json, status, created_at, last_opened_at)
916
- VALUES (?, ?, ?, ?, NULL, ?, ?, NULL)`)
917
- .run(id, input.workspaceId, input.name, input.worktree ? JSON.stringify(input.worktree) : null, status, createdAt);
960
+ .prepare(`INSERT INTO workspace_tasks (id, workspace_id, name, worktree_json, layout_json, status, cwd, created_at, last_opened_at)
961
+ VALUES (?, ?, ?, ?, NULL, ?, ?, ?, NULL)`)
962
+ .run(id, input.workspaceId, input.name, input.worktree ? JSON.stringify(input.worktree) : null, status, cwd, createdAt);
918
963
  return {
919
964
  id,
920
965
  workspaceId: input.workspaceId,
921
966
  name: input.name,
922
967
  worktree: input.worktree ?? null,
968
+ ...(cwd ? { cwd } : {}),
923
969
  layout: null,
924
970
  status,
925
971
  createdAt,