@co0ontty/wand 4.47.0 → 4.48.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": "8f7762a1d3d16afbb54650544c054f7e26df7c07",
3
- "builtAt": "2026-08-23T03:26:15.182Z",
4
- "version": "4.47.0",
2
+ "commit": "2c450db11f34504be1a3e73354679a0231a91c43",
3
+ "builtAt": "2026-08-23T13:37:45.889Z",
4
+ "version": "4.48.0",
5
5
  "channel": "stable"
6
6
  }
package/dist/config.d.ts CHANGED
@@ -7,7 +7,7 @@ import type { WandStorage } from "./storage.js";
7
7
  * 升级路径:老 JSON 里仍存有这些字段时,首次启动会被搬到 DB(见 migrateLegacyPreferencesToDb),
8
8
  * 然后下一次 saveConfig 写回 JSON 时它们会被剥离(见 stripPreferenceFields)。
9
9
  */
10
- export declare const PREFERENCE_KEYS: readonly ["defaultProvider", "defaultSessionKind", "defaultMode", "defaultCwd", "defaultModel", "defaultCodexModel", "defaultOpenCodeModel", "defaultGrokModel", "defaultQoderModel", "defaultPiModel", "commitCli", "commitModel", "commitAiSource", "systemAi", "defaultThinkingEffort", "structuredRunner", "language", "cardDefaults", "inheritEnv"];
10
+ export declare const PREFERENCE_KEYS: readonly ["defaultProvider", "defaultSessionKind", "defaultTaskWorktree", "defaultMode", "defaultCwd", "defaultModel", "defaultCodexModel", "defaultOpenCodeModel", "defaultGrokModel", "defaultQoderModel", "defaultPiModel", "commitCli", "commitModel", "commitAiSource", "systemAi", "defaultThinkingEffort", "structuredRunner", "language", "cardDefaults", "inheritEnv"];
11
11
  export type PreferenceKey = (typeof PREFERENCE_KEYS)[number];
12
12
  export declare function isPreferenceKey(key: string): key is PreferenceKey;
13
13
  export declare const defaultConfig: () => WandConfig;
package/dist/config.js CHANGED
@@ -24,6 +24,7 @@ const DEFAULT_CONFIG_FILE = "config.json";
24
24
  export const PREFERENCE_KEYS = [
25
25
  "defaultProvider",
26
26
  "defaultSessionKind",
27
+ "defaultTaskWorktree",
27
28
  "defaultMode",
28
29
  "defaultCwd",
29
30
  "defaultModel",
@@ -56,6 +57,7 @@ export const defaultConfig = () => ({
56
57
  password: "change-me",
57
58
  defaultProvider: "claude",
58
59
  defaultSessionKind: "structured",
60
+ defaultTaskWorktree: true,
59
61
  // 非 root 启动时才有资格用 Claude 的 permission-bypass(root 会被 Claude CLI 拒绝),
60
62
  // 所以这种环境下把默认执行模式抬到「托管」——开箱即得自动确认权限的全自主体验。
61
63
  // root 启动则保守回落到「default」(托管在 root 下也只能降级成 acceptEdits)。
@@ -296,6 +298,11 @@ export function applyStoragePreferences(config, storage) {
296
298
  if (v === "pty" || v === "structured")
297
299
  config.defaultSessionKind = v;
298
300
  }
301
+ if (storage.hasPreference(preferenceStorageKey("defaultTaskWorktree"))) {
302
+ const v = storage.getPreference(preferenceStorageKey("defaultTaskWorktree"), defaults.defaultTaskWorktree ?? true);
303
+ if (typeof v === "boolean")
304
+ config.defaultTaskWorktree = v;
305
+ }
299
306
  if (storage.hasPreference(preferenceStorageKey("defaultMode"))) {
300
307
  const v = storage.getPreference(preferenceStorageKey("defaultMode"), defaults.defaultMode);
301
308
  if (isExecutionMode(v))
@@ -399,6 +406,13 @@ export function writePreferenceToStorage(config, storage, key, value, options =
399
406
  config.defaultSessionKind = value;
400
407
  break;
401
408
  }
409
+ case "defaultTaskWorktree": {
410
+ if (typeof value !== "boolean")
411
+ throw new Error("defaultTaskWorktree 必须是布尔值。");
412
+ storage.setPreference(dbKey, value);
413
+ config.defaultTaskWorktree = value;
414
+ break;
415
+ }
402
416
  case "defaultMode": {
403
417
  if (!isExecutionMode(value))
404
418
  throw new Error(`无效执行模式: ${value}`);
@@ -691,6 +705,7 @@ function mergeWithDefaults(input) {
691
705
  cardDefaults: normalizeCardDefaults(input.cardDefaults),
692
706
  defaultProvider: input.defaultProvider === "codex" || input.defaultProvider === "opencode" || input.defaultProvider === "grok" || input.defaultProvider === "qoder" || input.defaultProvider === "pi" ? input.defaultProvider : "claude",
693
707
  defaultSessionKind: input.defaultSessionKind === "pty" ? "pty" : "structured",
708
+ defaultTaskWorktree: typeof input.defaultTaskWorktree === "boolean" ? input.defaultTaskWorktree : defaults.defaultTaskWorktree,
694
709
  defaultModel: typeof input.defaultModel === "string" ? input.defaultModel.trim() : defaults.defaultModel,
695
710
  defaultCodexModel: typeof input.defaultCodexModel === "string" ? input.defaultCodexModel.trim() : defaults.defaultCodexModel,
696
711
  defaultOpenCodeModel: typeof input.defaultOpenCodeModel === "string" ? input.defaultOpenCodeModel.trim() : defaults.defaultOpenCodeModel,
@@ -14,6 +14,8 @@ export interface Mission {
14
14
  prompt: string;
15
15
  cwd: string;
16
16
  status: MissionStatus;
17
+ /** 可选关联的任务(workspace task):派发的 attempt 会话绑定该任务。 */
18
+ taskId?: string | null;
17
19
  worktree: MissionWorktreeOptions;
18
20
  createdAt: string;
19
21
  updatedAt: string;
@@ -78,6 +80,8 @@ export interface CreateMissionInput {
78
80
  title?: string;
79
81
  cwd: string;
80
82
  providers: SessionProvider[];
83
+ /** 关联到指定 workspace task;派发会话将绑定 workspaceTaskId。 */
84
+ taskId?: string;
81
85
  baseRef?: string;
82
86
  sharedDirectories?: string[];
83
87
  copyPaths?: string[];
package/dist/missions.js CHANGED
@@ -133,6 +133,7 @@ export class Missions {
133
133
  prompt,
134
134
  cwd,
135
135
  status: "dispatching",
136
+ taskId: input.taskId?.trim() || null,
136
137
  worktree: {
137
138
  baseRef: input.baseRef?.trim() || undefined,
138
139
  sharedDirectories: normalizeStringList(input.sharedDirectories, "sharedDirectories"),
@@ -253,13 +254,20 @@ export class Missions {
253
254
  cwd: mission.cwd,
254
255
  mode: "agent",
255
256
  provider,
256
- worktreeEnabled: true,
257
- worktreeSpec: {
258
- baseRef: mission.worktree.baseRef,
259
- taskName: `${mission.title}-${provider}`,
260
- sharedDirectories: mission.worktree.sharedDirectories,
261
- copyPaths: mission.worktree.copyPaths,
262
- },
257
+ // 关联任务的派发直接落在任务目录(不再叠加一层隔离),
258
+ // 会话绑定 workspaceTaskId,出现在该任务下。
259
+ worktreeEnabled: mission.taskId ? false : true,
260
+ ...(mission.taskId ? { workspaceTaskId: mission.taskId } : {}),
261
+ ...(!mission.taskId
262
+ ? {
263
+ worktreeSpec: {
264
+ baseRef: mission.worktree.baseRef,
265
+ taskName: `${mission.title}-${provider}`,
266
+ sharedDirectories: mission.worktree.sharedDirectories,
267
+ copyPaths: mission.worktree.copyPaths,
268
+ },
269
+ }
270
+ : {}),
263
271
  sessionSource: "automation",
264
272
  automationId: mission.id,
265
273
  });
@@ -428,10 +428,10 @@ export function registerFileRoutes(app, deps) {
428
428
  app.get("/api/quick-paths", asyncRoute(async (_req, res) => {
429
429
  const home = process.env.HOME || process.env.USERPROFILE || "/home";
430
430
  res.json([
431
- { path: "/tmp", name: "临时目录", icon: "🗑️" },
432
- { path: home, name: "主目录", icon: "🏠" },
433
- { path: process.cwd(), name: "当前目录", icon: "📂" },
434
- { path: "/", name: "根目录", icon: "📁" },
431
+ { path: "/tmp", name: "临时目录", icon: "folder" },
432
+ { path: home, name: "主目录", icon: "home" },
433
+ { path: process.cwd(), name: "当前目录", icon: "folder" },
434
+ { path: "/", name: "根目录", icon: "folder" },
435
435
  ]);
436
436
  }));
437
437
  app.get("/api/recent-paths", (_req, res) => {
@@ -667,8 +667,9 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
667
667
  res.status(400).json({ error: "下标无效。" });
668
668
  return;
669
669
  }
670
+ const expectedText = typeof req.body?.expectedText === "string" ? req.body.expectedText : undefined;
670
671
  try {
671
- const snapshot = structured.deleteQueuedMessage(req.params.id, index);
672
+ const snapshot = structured.deleteQueuedMessage(req.params.id, index, expectedText);
672
673
  res.json(sessionResponseDTO(snapshot));
673
674
  }
674
675
  catch (error) {
@@ -267,6 +267,99 @@ export function registerWorkspaceRoutes(app, storage, sessions) {
267
267
  res.json({ ok: true, layout });
268
268
  });
269
269
  // ── 任务(Task = 命名 + 独立 worktree + 一组标签)──
270
+ // 目录组为一级容器的任务聚合列表,供侧栏「任务」视图一次拉全。
271
+ app.get("/api/tasks", (req, res) => {
272
+ // 查询参数:workspaceId 过滤单目录;limit 截断每目录任务数;
273
+ // maxSessions 截断每任务内嵌会话数(大数据量时控制响应体积)。
274
+ const workspaceFilter = typeof req.query.workspaceId === "string" ? req.query.workspaceId : "";
275
+ const parseBoundedCount = (raw) => {
276
+ const value = Number(raw);
277
+ return Number.isFinite(value) && value > 0 ? Math.min(Math.floor(value), 500) : null;
278
+ };
279
+ const taskLimit = parseBoundedCount(req.query.limit);
280
+ const sessionLimit = parseBoundedCount(req.query.maxSessions);
281
+ backfillSessionWorkspaces(storage);
282
+ const workspaces = storage.listWorkspaces();
283
+ const summarize = (session) => ({
284
+ id: session.id,
285
+ provider: session.provider,
286
+ sessionKind: session.sessionKind,
287
+ runner: session.runner,
288
+ title: resolveSessionDisplayTitle(session),
289
+ status: session.status,
290
+ cwd: session.cwd,
291
+ startedAt: session.startedAt,
292
+ });
293
+ const visibleWorkspaces = workspaceFilter
294
+ ? workspaces.filter((workspace) => workspace.id === workspaceFilter)
295
+ : workspaces;
296
+ const groups = new Map();
297
+ for (const workspace of visibleWorkspaces) {
298
+ groups.set(workspace.id, {
299
+ workspaceId: workspace.id,
300
+ workspaceName: workspace.name,
301
+ workspaceCwd: workspace.cwd,
302
+ tasks: storage.listWorkspaceTasks(workspace.id)
303
+ .slice(0, taskLimit ?? undefined)
304
+ .map((task) => {
305
+ const allSessions = storage.listSessionsByWorkspaceTask(task.id);
306
+ const sessions = allSessions
307
+ .slice(0, sessionLimit ?? undefined)
308
+ .map((session) => ({
309
+ ...summarize(session),
310
+ workspaceTaskId: task.id,
311
+ }));
312
+ return {
313
+ ...task,
314
+ cwd: task.worktree?.path ?? workspace.cwd,
315
+ isolated: task.worktree !== null,
316
+ sessions,
317
+ totalSessions: allSessions.length,
318
+ };
319
+ }),
320
+ standaloneSessions: [],
321
+ });
322
+ }
323
+ const taskBoundSessionIds = new Set();
324
+ for (const workspace of workspaces) {
325
+ for (const task of storage.listWorkspaceTasks(workspace.id)) {
326
+ for (const session of storage.listSessionsByWorkspaceTask(task.id))
327
+ taskBoundSessionIds.add(session.id);
328
+ }
329
+ }
330
+ for (const session of storage.loadSessions()) {
331
+ if (taskBoundSessionIds.has(session.id))
332
+ continue;
333
+ const direct = session.workspaceId ? groups.get(session.workspaceId) : undefined;
334
+ let group = direct && !direct.synthetic ? direct : undefined;
335
+ const resolved = session.cwd ? path.resolve(session.cwd) : "";
336
+ if (!group && resolved) {
337
+ group = [...groups.values()].find((candidate) => !candidate.synthetic && candidate.workspaceCwd === resolved);
338
+ }
339
+ // 过滤模式下不创建合成组:不属于目标目录的会话直接排除。
340
+ if (!group && resolved && !workspaceFilter) {
341
+ const id = `cwd:${resolved}`;
342
+ let synthetic = groups.get(id);
343
+ if (!synthetic) {
344
+ synthetic = {
345
+ workspaceId: id,
346
+ workspaceName: resolved.split("/").filter(Boolean).at(-1) || resolved,
347
+ workspaceCwd: resolved,
348
+ synthetic: true,
349
+ tasks: [],
350
+ standaloneSessions: [],
351
+ };
352
+ groups.set(id, synthetic);
353
+ }
354
+ group = synthetic;
355
+ }
356
+ // workspaceId 过滤时,不属于目标目录组的会话直接排除(含合成组)。
357
+ if (workspaceFilter && group?.workspaceId !== workspaceFilter)
358
+ continue;
359
+ group?.standaloneSessions.push(summarize(session));
360
+ }
361
+ res.json([...groups.values()]);
362
+ });
270
363
  // 列出某工作空间下的任务
271
364
  app.get("/api/workspaces/:id/tasks", (req, res) => {
272
365
  const workspace = storage.getWorkspace(req.params.id);
@@ -352,7 +445,8 @@ export function registerWorkspaceRoutes(app, storage, sessions) {
352
445
  worktrees,
353
446
  });
354
447
  }));
355
- // 新建任务:命名 + 创建独立 worktree(非 git 仓库时退化为直接用项目目录)
448
+ // 新建任务:命名 + 可选独立 worktree(默认尝试创建,非 git 仓库时退化为直接用项目目录;
449
+ // 显式传 worktree:false 时跳过隔离,会话直接跑在项目目录)。
356
450
  app.post("/api/workspaces/:id/tasks", asyncRoute(async (req, res) => {
357
451
  const workspace = storage.getWorkspace(req.params.id);
358
452
  if (!workspace) {
@@ -366,20 +460,23 @@ export function registerWorkspaceRoutes(app, storage, sessions) {
366
460
  return;
367
461
  }
368
462
  const baseRef = typeof body.baseRef === "string" && body.baseRef.trim() ? body.baseRef.trim() : undefined;
463
+ const wantWorktree = body.worktree !== false;
369
464
  let worktree = null;
370
465
  let worktreeError;
371
- try {
372
- const setup = prepareSessionWorktree({
373
- cwd: workspace.cwd,
374
- // 用随机短 id 作分支后缀,避免同名任务撞分支。
375
- sessionId: crypto.randomUUID(),
376
- spec: { taskName: name, baseRef },
377
- });
378
- worktree = setup.worktree;
379
- }
380
- catch (error) {
381
- // git 仓库 / 基线不存在:任务照常创建,但无 worktree 隔离。
382
- worktreeError = getErrorMessage(error, "无法创建 worktree,将在项目目录直接运行。");
466
+ if (wantWorktree) {
467
+ try {
468
+ const setup = prepareSessionWorktree({
469
+ cwd: workspace.cwd,
470
+ // 用随机短 id 作分支后缀,避免同名任务撞分支。
471
+ sessionId: crypto.randomUUID(),
472
+ spec: { taskName: name, baseRef },
473
+ });
474
+ worktree = setup.worktree;
475
+ }
476
+ catch (error) {
477
+ // git 仓库 / 基线不存在:任务照常创建,但无 worktree 隔离。
478
+ worktreeError = getErrorMessage(error, "无法创建 worktree,将在项目目录直接运行。");
479
+ }
383
480
  }
384
481
  const task = storage.createWorkspaceTask({ workspaceId: workspace.id, name, worktree });
385
482
  res.status(201).json({
package/dist/server.js CHANGED
@@ -213,6 +213,7 @@ const CONNECTED_APP_PREFERENCE_KEYS = new Set([
213
213
  "defaultThinkingEffort",
214
214
  "defaultProvider",
215
215
  "defaultSessionKind",
216
+ "defaultTaskWorktree",
216
217
  ]);
217
218
  function requireAdminOrSessionPreferences(req, res, next) {
218
219
  const principal = requestPrincipals.get(req);
@@ -763,6 +764,7 @@ export async function startServer(config, configPath, options = {}) {
763
764
  port: config.port,
764
765
  defaultProvider: config.defaultProvider ?? "claude",
765
766
  defaultSessionKind: config.defaultSessionKind ?? "structured",
767
+ defaultTaskWorktree: config.defaultTaskWorktree !== false,
766
768
  defaultMode: config.defaultMode,
767
769
  defaultCwd: config.defaultCwd,
768
770
  defaultModel: defaultModels.claude,
package/dist/storage.js CHANGED
@@ -568,6 +568,7 @@ const INIT_SQL = `
568
568
  prompt TEXT NOT NULL,
569
569
  cwd TEXT NOT NULL,
570
570
  status TEXT NOT NULL,
571
+ task_id TEXT,
571
572
  base_ref TEXT,
572
573
  shared_directories TEXT NOT NULL DEFAULT '[]',
573
574
  copy_paths TEXT NOT NULL DEFAULT '[]',
@@ -663,6 +664,12 @@ export function ensureDatabaseFile(dbPath) {
663
664
  db.exec(INIT_SQL);
664
665
  ensureAuthSessionSchema(db);
665
666
  ensureCommandSessionSchema(db);
667
+ {
668
+ const missionColumns = db.prepare("PRAGMA table_info(missions)").all();
669
+ if (missionColumns.length > 0 && !missionColumns.some((column) => column.name === "task_id")) {
670
+ db.exec("ALTER TABLE missions ADD COLUMN task_id TEXT");
671
+ }
672
+ }
666
673
  db.close();
667
674
  chmodSync(dbPath, 0o600);
668
675
  return created;
@@ -1147,13 +1154,13 @@ export class WandStorage {
1147
1154
  // ============ Missions ============
1148
1155
  saveMission(mission) {
1149
1156
  this.db.prepare(`INSERT INTO missions (
1150
- id, title, prompt, cwd, status, base_ref, shared_directories, copy_paths, created_at, updated_at
1151
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1157
+ id, title, prompt, cwd, status, base_ref, shared_directories, copy_paths, created_at, updated_at, task_id
1158
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1152
1159
  ON CONFLICT(id) DO UPDATE SET
1153
1160
  title = excluded.title, prompt = excluded.prompt, cwd = excluded.cwd,
1154
1161
  status = excluded.status, base_ref = excluded.base_ref,
1155
1162
  shared_directories = excluded.shared_directories, copy_paths = excluded.copy_paths,
1156
- updated_at = excluded.updated_at`).run(mission.id, mission.title, mission.prompt, mission.cwd, mission.status, mission.worktree.baseRef ?? null, JSON.stringify(mission.worktree.sharedDirectories ?? []), JSON.stringify(mission.worktree.copyPaths ?? []), mission.createdAt, mission.updatedAt);
1163
+ updated_at = excluded.updated_at, task_id = excluded.task_id`).run(mission.id, mission.title, mission.prompt, mission.cwd, mission.status, mission.worktree.baseRef ?? null, JSON.stringify(mission.worktree.sharedDirectories ?? []), JSON.stringify(mission.worktree.copyPaths ?? []), mission.createdAt, mission.updatedAt, mission.taskId ?? null);
1157
1164
  }
1158
1165
  getMission(id) {
1159
1166
  const row = this.db.prepare("SELECT * FROM missions WHERE id = ?").get(id);
@@ -1336,6 +1343,7 @@ function mapMissionRow(row) {
1336
1343
  sharedDirectories: safeJsonParse(typeof row.shared_directories === "string" ? row.shared_directories : null) ?? [],
1337
1344
  copyPaths: safeJsonParse(typeof row.copy_paths === "string" ? row.copy_paths : null) ?? [],
1338
1345
  },
1346
+ taskId: typeof row.task_id === "string" ? row.task_id : null,
1339
1347
  createdAt: String(row.created_at),
1340
1348
  updatedAt: String(row.updated_at),
1341
1349
  };
@@ -145,8 +145,8 @@ export declare class StructuredSessionManager {
145
145
  * 本身,flushNext 在另一段时序里读 sessions.get(...) 当前快照,已经天然安全。
146
146
  */
147
147
  reorderQueuedMessages(sessionId: string, order: number[]): SessionSnapshot;
148
- /** Remove a single queued message by index. */
149
- deleteQueuedMessage(sessionId: string, index: number): SessionSnapshot;
148
+ /** Remove a single queued message only while index and text still identify the same item. */
149
+ deleteQueuedMessage(sessionId: string, index: number, expectedText?: string): SessionSnapshot;
150
150
  /**
151
151
  * Remove one queued message by index before sending it. Keeping this operation
152
152
  * on the server prevents clients from re-sending the text while the original
@@ -1255,13 +1255,16 @@ export class StructuredSessionManager {
1255
1255
  this.emitStructuredSnapshot(updated);
1256
1256
  return updated;
1257
1257
  }
1258
- /** Remove a single queued message by index. */
1259
- deleteQueuedMessage(sessionId, index) {
1258
+ /** Remove a single queued message only while index and text still identify the same item. */
1259
+ deleteQueuedMessage(sessionId, index, expectedText) {
1260
1260
  const session = this.requireSession(sessionId);
1261
1261
  const queue = session.queuedMessages ?? [];
1262
1262
  if (!Number.isInteger(index) || index < 0 || index >= queue.length) {
1263
1263
  throw new Error("队列中没有该条消息(可能已被处理)。");
1264
1264
  }
1265
+ if (expectedText !== undefined && queue[index] !== expectedText) {
1266
+ throw new Error("排队消息已变化,请按最新顺序重试。");
1267
+ }
1265
1268
  const next = queue.slice(0, index).concat(queue.slice(index + 1));
1266
1269
  const skills = session.queuedMessageSkills ?? [];
1267
1270
  const updated = {
package/dist/types.d.ts CHANGED
@@ -94,6 +94,8 @@ export interface WandConfig {
94
94
  defaultProvider?: SessionProvider;
95
95
  /** 新建会话时默认使用的承载类型。 */
96
96
  defaultSessionKind?: SessionKind;
97
+ /** 新建任务时是否默认开启独立 worktree。 */
98
+ defaultTaskWorktree?: boolean;
97
99
  defaultMode: ExecutionMode;
98
100
  shell: string;
99
101
  defaultCwd: string;