@co0ontty/wand 4.70.0 → 4.71.0-beta.g755e143

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": "ff952fe9969d72b01a59cc98cd1ca17b9d16649e",
3
- "builtAt": "2026-09-17T14:25:36.640Z",
4
- "version": "4.70.0",
5
- "channel": "stable"
2
+ "commit": "755e143838da4c21744bf6e0b6a394d963b9c6ac",
3
+ "builtAt": "2026-09-19T07:21:07.361Z",
4
+ "version": "4.71.0-beta.g755e143",
5
+ "channel": "beta"
6
6
  }
@@ -0,0 +1,6 @@
1
+ import type { WandStorage } from "./storage.js";
2
+ /**
3
+ * 把里程碑 id 收敛到「任务所在工作区能用」的范围;不兼容就返回 null(解绑)。
4
+ * 调用方负责先校验 id 是否存在。
5
+ */
6
+ export declare function scopedMilestoneId(storage: WandStorage, milestoneId: string | null | undefined, workspaceId: string | null | undefined): string | null;
@@ -0,0 +1,20 @@
1
+ // 迭代(里程碑)按工作区归属,规则只有一条:
2
+ // 项目里的任务只能挂「本项目的迭代」或「全局迭代(没绑工作区的)」。
3
+ // 没归属项目的任务(未分配 / 隐藏的全局工作区)不受限,避免把独立任务限制死。
4
+ // 移动端只会 PATCH `workspaceId`,不会自己清里程碑,所以这条规则必须由服务端兜住。
5
+ /**
6
+ * 把里程碑 id 收敛到「任务所在工作区能用」的范围;不兼容就返回 null(解绑)。
7
+ * 调用方负责先校验 id 是否存在。
8
+ */
9
+ export function scopedMilestoneId(storage, milestoneId, workspaceId) {
10
+ if (!milestoneId)
11
+ return null;
12
+ const milestone = storage.getWandMilestone(milestoneId);
13
+ // 里程碑没了 / 是全局迭代:原样保留。
14
+ if (!milestone || !milestone.workspaceId)
15
+ return milestoneId;
16
+ // 任务不在项目里:不限。
17
+ if (!workspaceId)
18
+ return milestoneId;
19
+ return milestone.workspaceId === workspaceId ? milestoneId : null;
20
+ }
package/dist/missions.js CHANGED
@@ -3,6 +3,7 @@ import { existsSync, statSync } from "node:fs";
3
3
  import path from "node:path";
4
4
  import { buildMissionDiff } from "./mission-diff.js";
5
5
  import { isSessionProvider } from "./session-provider.js";
6
+ import { syncWorkspaceTaskToBoard } from "./wand-task-sync.js";
6
7
  const MAX_ATTEMPTS = 6;
7
8
  function nowIso() {
8
9
  return new Date().toISOString();
@@ -321,6 +322,8 @@ export class Missions {
321
322
  sessionSource: "automation",
322
323
  automationId: mission.id,
323
324
  });
325
+ // 关联任务的派发会话立刻落到任务卡片上,不等下一次看板列表的兜底同步。
326
+ syncWorkspaceTaskToBoard(this.storage, mission.taskId);
324
327
  attempt = {
325
328
  ...attempt,
326
329
  sessionId: session.id,
@@ -1,4 +1,4 @@
1
- export type ProviderCliId = "claude" | "codex" | "opencode" | "qoder" | "pi";
1
+ export type ProviderCliId = "claude" | "codex" | "opencode" | "grok" | "qoder" | "pi";
2
2
  export interface ProviderCliUpdateStatus {
3
3
  id: ProviderCliId;
4
4
  label: string;
@@ -31,7 +31,21 @@ export interface ProviderCliUpdaterOptions {
31
31
  onLog?: (line: string) => void;
32
32
  }
33
33
  export declare function parseProviderCliVersion(output: string): string | null;
34
+ /**
35
+ * Homebrew 托管的 CLI 官方更新器会拒绍动手(实测 `claude update` 只打印
36
+ * "managed by Homebrew" 并不改版本),所以直接给出正确的 brew 命令。
37
+ */
38
+ export declare function homebrewUpdateHint(executable: string | null): string | null;
39
+ /** 从 `/opt/homebrew/Caskroom/<cask>/…` / `/Cellar/<formula>/…` 反解 Homebrew 包名。 */
40
+ export declare function parseHomebrewInstall(executable: string | null): {
41
+ kind: "cask" | "formula";
42
+ name: string;
43
+ } | null;
34
44
  export declare function providerCliUpdateAvailable(currentVersion: string | null, latestVersion: string | null): boolean;
45
+ /** 从 `brew info --json=v2` 输出里取 cask version / formula stable 版本。 */
46
+ export declare function parseBrewInfoVersion(output: string, kind: "cask" | "formula"): string | null;
47
+ /** `grok update --check --json` 的 JSON 行里取 `latestVersion`;日志混排也能挑出来。 */
48
+ export declare function parseCliLatestVersionJson(output: string): string | null;
35
49
  export declare function checkProviderCliUpdates(options?: ProviderCliUpdaterOptions): Promise<ProviderCliUpdateStatus[]>;
36
50
  export declare function updateProviderClis(statuses: ProviderCliUpdateStatus[], ids?: ProviderCliId[], options?: ProviderCliUpdaterOptions): Promise<ProviderCliUpdateResult[]>;
37
51
  /** Re-check the active PATH after updating so duplicate installs cannot masquerade as success. */
@@ -36,6 +36,15 @@ const PROVIDER_CLI_SPECS = [
36
36
  versionArgs: ["--version"],
37
37
  updateArgs: ["upgrade"],
38
38
  },
39
+ {
40
+ id: "grok",
41
+ label: "Grok CLI",
42
+ command: "grok",
43
+ // Grok CLI 没有 npm 发布渠道,最新版只能问它自己。
44
+ versionArgs: ["--version"],
45
+ updateArgs: ["update"],
46
+ cliLatestArgs: ["update", "--check", "--json"],
47
+ },
39
48
  {
40
49
  id: "qoder",
41
50
  label: "Qoder CLI",
@@ -48,7 +57,9 @@ const PROVIDER_CLI_SPECS = [
48
57
  id: "pi",
49
58
  label: "Pi CLI",
50
59
  command: "pi",
51
- npmPackage: "@mariozechner/pi-coding-agent",
60
+ // pi 已从 @mariozechner/pi-coding-agent 改名到 @earendil-works/pi-coding-agent,
61
+ // 继续查旧包会拿到低于本机安装版本的“最新版”,把更新判断彻底带偏。
62
+ npmPackage: "@earendil-works/pi-coding-agent",
52
63
  versionArgs: ["--version"],
53
64
  updateArgs: ["update", "self"],
54
65
  },
@@ -79,10 +90,32 @@ function resolveInstallKind(executable, id, version) {
79
90
  return "npm";
80
91
  if (normalized.includes("/cellar/") || normalized.includes("/caskroom/") || normalized.includes("/homebrew/"))
81
92
  return "brew";
82
- if (normalized.includes("/.claude/") || normalized.includes("/.codex/") || normalized.includes("/.opencode/") || normalized.includes("/.qoder/"))
93
+ if (normalized.includes("/.claude/") || normalized.includes("/.codex/") || normalized.includes("/.opencode/") || normalized.includes("/.grok/") || normalized.includes("/.qoder/") || normalized.includes("/.qoder-cn/"))
83
94
  return "native";
84
95
  return "unknown";
85
96
  }
97
+ /**
98
+ * Homebrew 托管的 CLI 官方更新器会拒绍动手(实测 `claude update` 只打印
99
+ * "managed by Homebrew" 并不改版本),所以直接给出正确的 brew 命令。
100
+ */
101
+ export function homebrewUpdateHint(executable) {
102
+ const install = parseHomebrewInstall(executable);
103
+ return install ? `brew upgrade ${install.name}` : null;
104
+ }
105
+ /** 从 `/opt/homebrew/Caskroom/<cask>/…` / `/Cellar/<formula>/…` 反解 Homebrew 包名。 */
106
+ export function parseHomebrewInstall(executable) {
107
+ if (!executable)
108
+ return null;
109
+ let resolved = executable;
110
+ try {
111
+ resolved = realpathSync(executable);
112
+ }
113
+ catch { /* keep original */ }
114
+ const matched = /\/(Caskroom|Cellar)\/([^/]+)\//i.exec(resolved.replace(/\\/g, "/"));
115
+ if (!matched)
116
+ return null;
117
+ return { kind: matched[1].toLowerCase() === "caskroom" ? "cask" : "formula", name: matched[2] };
118
+ }
86
119
  function isUpdateSupported(id, version) {
87
120
  return !(id === "opencode" && version !== null && /^0\.0\./.test(version));
88
121
  }
@@ -107,7 +140,18 @@ async function readInstalledVersion(spec, options) {
107
140
  return { executable, version: null, error: getErrorMessage(error, "读取版本失败。") };
108
141
  }
109
142
  }
110
- async function readLatestVersion(spec, options) {
143
+ async function readLatestVersion(spec, options, status) {
144
+ if (spec.cliLatestArgs)
145
+ return readLatestVersionFromCli(spec, options);
146
+ // Homebrew 安装跟 npm 的 latest 不是同一个发布渠道(cask 通常落后一两个版本),
147
+ // 拿 npm 比会永久显示“有更新”。
148
+ if (status?.installKind === "brew") {
149
+ const install = parseHomebrewInstall(status.executable);
150
+ if (install)
151
+ return readBrewLatestVersion(install, options);
152
+ }
153
+ if (!spec.npmPackage)
154
+ return { version: null, error: "该 CLI 没有配置版本来源。" };
111
155
  const npm = options.env?.WAND_NPM_BIN || process.env.WAND_NPM_BIN || (process.platform === "win32" ? "npm.cmd" : "npm");
112
156
  try {
113
157
  const result = await runCommand(npm, ["view", `${spec.npmPackage}@latest`, "version"], options.registryTimeoutMs ?? REGISTRY_TIMEOUT_MS, options);
@@ -118,12 +162,85 @@ async function readLatestVersion(spec, options) {
118
162
  return { version: null, error: getErrorMessage(error, "无法连接 npm registry。") };
119
163
  }
120
164
  }
165
+ /** `brew info --json=v2` 的 cask/formula 最新版。 */
166
+ async function readBrewLatestVersion(install, options) {
167
+ const env = resolveChildEnv(options);
168
+ const brew = whichSync("brew", { env, timeoutMs: options.versionTimeoutMs ?? VERSION_TIMEOUT_MS });
169
+ if (!brew)
170
+ return { version: null, error: "未找到 brew 命令,无法检查 Homebrew 最新版。" };
171
+ try {
172
+ const result = await runCommand(brew, ["info", "--json=v2", install.kind === "cask" ? "--cask" : "--formula", install.name], options.registryTimeoutMs ?? REGISTRY_TIMEOUT_MS, options);
173
+ const version = parseBrewInfoVersion(result.stdout, install.kind);
174
+ return version ? { version } : { version: null, error: "brew info 未返回版本。" };
175
+ }
176
+ catch (error) {
177
+ return { version: null, error: getErrorMessage(error, "无法运行 brew info。") };
178
+ }
179
+ }
180
+ /** 从 `brew info --json=v2` 输出里取 cask version / formula stable 版本。 */
181
+ export function parseBrewInfoVersion(output, kind) {
182
+ let parsed;
183
+ try {
184
+ parsed = JSON.parse(output);
185
+ }
186
+ catch {
187
+ return null;
188
+ }
189
+ const root = parsed && typeof parsed === "object" ? parsed : {};
190
+ const list = kind === "cask" ? root.casks : root.formulae;
191
+ const entry = Array.isArray(list) && list.length && list[0] && typeof list[0] === "object"
192
+ ? list[0]
193
+ : null;
194
+ if (!entry)
195
+ return null;
196
+ if (kind === "cask")
197
+ return typeof entry.version === "string" ? extractSemver(entry.version) : null;
198
+ const versions = entry.versions && typeof entry.versions === "object" ? entry.versions : {};
199
+ return typeof versions.stable === "string" ? extractSemver(versions.stable) : null;
200
+ }
201
+ /** `grok update --check --json` 的 JSON 行里取 `latestVersion`;日志混排也能挑出来。 */
202
+ export function parseCliLatestVersionJson(output) {
203
+ for (const line of output.split("\n")) {
204
+ const trimmed = line.trim();
205
+ if (!trimmed.startsWith("{") || !trimmed.endsWith("}"))
206
+ continue;
207
+ let parsed;
208
+ try {
209
+ parsed = JSON.parse(trimmed);
210
+ }
211
+ catch {
212
+ continue;
213
+ }
214
+ const latest = parsed && typeof parsed === "object"
215
+ ? parsed.latestVersion
216
+ : null;
217
+ const version = typeof latest === "string" ? extractSemver(latest) : null;
218
+ if (version)
219
+ return version;
220
+ }
221
+ return null;
222
+ }
223
+ async function readLatestVersionFromCli(spec, options) {
224
+ const env = resolveChildEnv(options);
225
+ const executable = whichSync(spec.command, { env, timeoutMs: options.versionTimeoutMs ?? VERSION_TIMEOUT_MS });
226
+ // 没装就没有“最新版”可比,交给上面按未安装展示。
227
+ if (!executable)
228
+ return { version: null };
229
+ try {
230
+ const result = await runCommand(executable, spec.cliLatestArgs, options.registryTimeoutMs ?? REGISTRY_TIMEOUT_MS, options);
231
+ const version = parseCliLatestVersionJson(`${result.stdout}\n${result.stderr}`);
232
+ return version ? { version } : { version: null, error: "CLI 未返回可解析的最新版本。" };
233
+ }
234
+ catch (error) {
235
+ return { version: null, error: getErrorMessage(error, "无法检查 CLI 最新版本。") };
236
+ }
237
+ }
121
238
  export async function checkProviderCliUpdates(options = {}) {
122
239
  return Promise.all(PROVIDER_CLI_SPECS.map(async (spec) => {
123
- const [installed, latest] = await Promise.all([
124
- readInstalledVersion(spec, options),
125
- readLatestVersion(spec, options),
126
- ]);
240
+ // 串行:查最新版要先用 installKind 判断是不是 Homebrew 安装(其它 5 个 CLI 仍并发)。
241
+ const installed = await readInstalledVersion(spec, options);
242
+ const installKind = resolveInstallKind(installed.executable, spec.id, installed.version);
243
+ const latest = await readLatestVersion(spec, options, { executable: installed.executable, installKind });
127
244
  const updateSupported = isUpdateSupported(spec.id, installed.version);
128
245
  const errors = [installed.error, latest.error].filter(Boolean);
129
246
  if (!updateSupported) {
@@ -139,7 +256,7 @@ export async function checkProviderCliUpdates(options = {}) {
139
256
  latestVersion: latest.version,
140
257
  updateAvailable: providerCliUpdateAvailable(installed.version, latest.version),
141
258
  updateSupported,
142
- installKind: resolveInstallKind(installed.executable, spec.id, installed.version),
259
+ installKind,
143
260
  ...(errors.length ? { error: errors.join(";") } : {}),
144
261
  };
145
262
  }));
@@ -192,6 +309,22 @@ export async function updateProviderClis(statuses, ids, options = {}) {
192
309
  continue;
193
310
  }
194
311
  const executable = status.executable;
312
+ // Homebrew 托管的安装不能靠 CLI 自更新(它会直接拒绍),别浪费一次必然失败的子进程。
313
+ if (status.installKind === "brew") {
314
+ const hint = homebrewUpdateHint(executable);
315
+ results.push({
316
+ id: spec.id,
317
+ label: spec.label,
318
+ ok: false,
319
+ skipped: true,
320
+ fromVersion: status.currentVersion,
321
+ toVersion: status.latestVersion,
322
+ message: hint
323
+ ? `${spec.label} 由 Homebrew 安装,请运行 \`${hint}\` 更新。`
324
+ : `${spec.label} 由 Homebrew 安装,请用 \`brew upgrade\` 更新。`,
325
+ });
326
+ continue;
327
+ }
195
328
  options.onLog?.(`[CLI Update] ${spec.label}: ${status.currentVersion} -> ${status.latestVersion}`);
196
329
  try {
197
330
  const output = await runCommand(executable, spec.updateArgs, options.updateTimeoutMs ?? UPDATE_TIMEOUT_MS, options);
@@ -16,6 +16,7 @@ import { sendRouteError } from "./server-request.js";
16
16
  import { registerStructuredResumeRoutes } from "./server-resume-routes.js";
17
17
  import { addHiddenSessionIds, removeHiddenSessionIds } from "./session-registry.js";
18
18
  import { enrichStructuredMessages, WAND_PROTOCOL_VERSION } from "./structured-client-protocol.js";
19
+ import { syncWorkspaceTaskToBoard } from "./wand-task-sync.js";
19
20
  import { buildDirectoryTree, normalizeSessionDirectory, } from "./session-directory-tree.js";
20
21
  import { projectCwdForSession, renameWorkspaceDirectory, resolveWorkspaceIdForNewSession, } from "./workspace-binding.js";
21
22
  export function parseExecutionMode(value, fallback) {
@@ -446,6 +447,8 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
446
447
  ...origin,
447
448
  });
448
449
  onSessionCreated?.(snapshot.cwd);
450
+ // 会话一落地就挂到任务的看板卡片上,不等下一次看板列表的兜底同步。
451
+ syncWorkspaceTaskToBoard(storage, snapshot.workspaceTaskId);
449
452
  const prompt = body.prompt?.trim();
450
453
  if (prompt) {
451
454
  const finished = await structured.sendMessage(snapshot.id, prompt);
@@ -3,7 +3,8 @@ import { type WandStorage } from "./storage.js";
3
3
  import { generateWandTaskTitle } from "./task-title.js";
4
4
  import type { SessionRegistry } from "./session-registry.js";
5
5
  import type { StructuredSessionManager } from "./structured-session-manager.js";
6
- import type { WandTaskAgent, WandTaskAgentMode } from "./task-types.js";
6
+ import type { ProcessManager } from "./process-manager.js";
7
+ import type { WandTaskAgent, WandTaskAgentKind, WandTaskAgentMode } from "./task-types.js";
7
8
  import type { WandConfig } from "./types.js";
8
9
  /** 任务面板上次选用的 CLI 工具 / 模型 / 思考深度;从未保存过时回落到 Claude 默认。 */
9
10
  export declare function readTaskBoardLastAgent(storage: WandStorage): WandTaskAgent;
@@ -12,6 +13,8 @@ export interface TaskRouteDependencies {
12
13
  storage: WandStorage;
13
14
  sessions?: SessionRegistry;
14
15
  structured?: StructuredSessionManager;
16
+ /** PTY 会话创建入口;派发 `kind: "pty"` 的任务时使用。测试里可换成同步桩。 */
17
+ processes?: ProcessManager;
15
18
  config?: WandConfig;
16
19
  /** 可注入的任务标题生成器;测试里换成同步桩,避免真的起 CLI。 */
17
20
  generateTitle?: typeof generateWandTaskTitle;
@@ -21,7 +24,7 @@ export interface TaskRouteDependencies {
21
24
  * 老客户端(Android / iOS 尚未发 mode)不传 mode,PATCH / dispatch 时传任务当前值,
22
25
  * 避免把用户在 Web 上选的工作模式悄悄复位成标准。
23
26
  */
24
- export declare function parseTaskAgent(value: unknown, fallbackMode?: WandTaskAgentMode): WandTaskAgent | null;
27
+ export declare function parseTaskAgent(value: unknown, fallbackMode?: WandTaskAgentMode, fallbackKind?: WandTaskAgentKind): WandTaskAgent | null;
25
28
  /** 等待排队中的标题生成全部结束;只有测试用,避免用例轮询。 */
26
29
  export declare function whenWandTaskTitlesSettled(): Promise<void>;
27
30
  export interface AutoTaskTitleOptions {
@@ -3,17 +3,18 @@ import { asyncRoute } from "./express-async.js";
3
3
  import { getErrorMessage } from "./error-utils.js";
4
4
  import { bodyObject, sendRouteError, text } from "./server-request.js";
5
5
  import { parseWandTaskAgent } from "./storage.js";
6
+ import { scopedMilestoneId } from "./milestone-scope.js";
6
7
  import { generateWandTaskTitle, provisionalTaskTitleFromDescription, TASK_TITLE_MAX_LENGTH } from "./task-title.js";
7
8
  import { resolveSystemAiContext } from "./session-ai-context.js";
8
- import { DEFAULT_WAND_TASK_AGENT_MODE, isWandTaskAgentMode, normalizeWandTaskAgentMode, WAND_MILESTONE_NAME_MAX_LENGTH } from "./task-types.js";
9
- import { archiveBoardTask, ensureWorkspaceTaskForBoardTask, isAutoNameableBoardTask, moveSessionToWorkspaceTask, syncClosedBoardTask, syncUngroupedSessionsToBoard, taskAutoNameSignature, taskAutoNameSourceText } from "./wand-task-sync.js";
10
- import { isSessionProvider } from "./session-provider.js";
9
+ import { DEFAULT_WAND_TASK_AGENT_KIND, DEFAULT_WAND_TASK_AGENT_MODE, DEFAULT_WAND_TASK_PRIORITY, isWandTaskAgentKind, isWandTaskAgentMode, normalizeWandTaskAgentMode, WAND_MILESTONE_NAME_MAX_LENGTH } from "./task-types.js";
10
+ import { archiveBoardTask, ensureWorkspaceTaskForBoardTask, isAutoNameableBoardTask, moveSessionToWorkspaceTask, syncClosedBoardTask, syncUngroupedSessionsToBoard, syncWorkspaceTaskToBoard, taskAutoNameSignature, taskAutoNameSourceText } from "./wand-task-sync.js";
11
+ import { isSessionProvider, providerCliCommand } from "./session-provider.js";
11
12
  const STATUSES = new Set(["todo", "doing", "done", "archived"]);
12
13
  const PRIORITIES = new Set(["none", "low", "medium", "high", "urgent"]);
13
14
  const AGENT_EFFORTS = new Set(["off", "standard", "deep", "max"]);
14
15
  const TASK_BOARD_LAST_AGENT_KEY = "pref:taskBoardLastAgent";
15
16
  function defaultTaskBoardAgent() {
16
- return { provider: "claude", model: "default", thinkingEffort: "off", mode: DEFAULT_WAND_TASK_AGENT_MODE };
17
+ return { provider: "claude", model: "default", thinkingEffort: "off", mode: DEFAULT_WAND_TASK_AGENT_MODE, kind: DEFAULT_WAND_TASK_AGENT_KIND };
17
18
  }
18
19
  /** 任务面板上次选用的 CLI 工具 / 模型 / 思考深度;从未保存过时回落到 Claude 默认。 */
19
20
  export function readTaskBoardLastAgent(storage) {
@@ -33,6 +34,7 @@ export function writeTaskBoardLastAgent(storage, agent) {
33
34
  model: agent.model,
34
35
  thinkingEffort: agent.thinkingEffort,
35
36
  mode: agent.mode,
37
+ kind: agent.kind,
36
38
  });
37
39
  }
38
40
  function dateValue(value) {
@@ -68,12 +70,23 @@ function milestoneIdFrom(storage, value) {
68
70
  throw new Error("未找到该里程碑。");
69
71
  return id;
70
72
  }
73
+ /** 新建里程碑时归属的工作区;null 表示全局(未指定项目)。 */
74
+ function milestoneWorkspaceIdFrom(storage, value) {
75
+ if (value === null || value === undefined)
76
+ return null;
77
+ const workspaceId = text(value);
78
+ if (!workspaceId)
79
+ return null;
80
+ if (!storage.getWorkspace(workspaceId))
81
+ throw new Error("项目不存在。");
82
+ return workspaceId;
83
+ }
71
84
  /**
72
85
  * 任务派发配置:provider / model / thinkingEffort 必须合法;mode 缺省时用 `fallbackMode`。
73
86
  * 老客户端(Android / iOS 尚未发 mode)不传 mode,PATCH / dispatch 时传任务当前值,
74
87
  * 避免把用户在 Web 上选的工作模式悄悄复位成标准。
75
88
  */
76
- export function parseTaskAgent(value, fallbackMode = DEFAULT_WAND_TASK_AGENT_MODE) {
89
+ export function parseTaskAgent(value, fallbackMode = DEFAULT_WAND_TASK_AGENT_MODE, fallbackKind = DEFAULT_WAND_TASK_AGENT_KIND) {
77
90
  if (value === null)
78
91
  return null;
79
92
  if (!value || typeof value !== "object" || Array.isArray(value))
@@ -93,7 +106,11 @@ export function parseTaskAgent(value, fallbackMode = DEFAULT_WAND_TASK_AGENT_MOD
93
106
  throw new Error("工作模式无效。");
94
107
  // Codex 之类只认一种模式的 provider 在这里夹到有效值,落库值与实际执行保持一致。
95
108
  const mode = normalizeWandTaskAgentMode(provider, rawMode);
96
- return { provider, model, thinkingEffort, mode };
109
+ // kind 与 mode 同样兼容老客户端:不传就沿用任务当前值,避免把 PTY 悄悄复位成结构化。
110
+ const rawKind = body.kind === undefined || body.kind === null || body.kind === "" ? fallbackKind : body.kind;
111
+ if (!isWandTaskAgentKind(rawKind))
112
+ throw new Error("会话形态无效。");
113
+ return { provider, model, thinkingEffort, mode, kind: rawKind };
97
114
  }
98
115
  function taskDto({ storage, sessions }, task) {
99
116
  if (!task)
@@ -214,7 +231,7 @@ function taskTitleAiOptions(config) {
214
231
  return resolveSystemAiContext(defaultSession, config);
215
232
  }
216
233
  export function registerTaskRoutes(app, deps) {
217
- const { storage, sessions, structured, config } = deps;
234
+ const { storage, sessions, structured, processes, config } = deps;
218
235
  const dto = (task) => taskDto(deps, task);
219
236
  app.get("/api/wand-tasks", (req, res) => {
220
237
  try {
@@ -237,11 +254,13 @@ export function registerTaskRoutes(app, deps) {
237
254
  app.get("/api/wand-task-agent-defaults", (_req, res) => {
238
255
  res.json(readTaskBoardLastAgent(storage));
239
256
  });
240
- // ── 里程碑:跨项目全局列表,所有任务面板的「新建任务」共用 ──
241
- app.get("/api/wand-milestones", (_req, res) => {
257
+ // ── 里程碑(迭代):归属工作区,任务面板的「新建任务」按工作区过滤 ──
258
+ app.get("/api/wand-milestones", (req, res) => {
259
+ // 不传 workspaceId 返回全部;传了就只给「该工作区 + 全局」的里程碑。
260
+ const workspaceId = typeof req.query.workspaceId === "string" ? req.query.workspaceId : null;
242
261
  const counts = storage.countWandTasksByMilestone();
243
262
  res.json({
244
- milestones: storage.listWandMilestones().map((milestone) => ({
263
+ milestones: storage.listWandMilestones(workspaceId).map((milestone) => ({
245
264
  ...milestone,
246
265
  taskCount: counts[milestone.id] ?? 0,
247
266
  })),
@@ -251,11 +270,13 @@ export function registerTaskRoutes(app, deps) {
251
270
  try {
252
271
  const body = bodyObject(req.body);
253
272
  const name = milestoneNameFrom(body.name);
254
- const duplicate = storage.listWandMilestones()
273
+ const workspaceId = milestoneWorkspaceIdFrom(storage, body.workspaceId);
274
+ // 重名只在「同一个工作区可见的里程碑」里查:不同工作区可以各有同名迭代。
275
+ const duplicate = storage.listWandMilestones(workspaceId)
255
276
  .find((milestone) => milestone.name.toLowerCase() === name.toLowerCase());
256
277
  if (duplicate)
257
278
  throw new Error(`里程碑「${duplicate.name}」已存在。`);
258
- const created = storage.createWandMilestone({ name, dueDate: dateValue(body.dueDate) ?? null });
279
+ const created = storage.createWandMilestone({ name, dueDate: dateValue(body.dueDate) ?? null, workspaceId });
259
280
  res.status(201).json({ ...created, taskCount: 0 });
260
281
  }
261
282
  catch (error) {
@@ -273,12 +294,21 @@ export function registerTaskRoutes(app, deps) {
273
294
  const patch = {};
274
295
  if (body.name !== undefined) {
275
296
  const name = milestoneNameFrom(body.name);
276
- const duplicate = storage.listWandMilestones()
297
+ const duplicate = storage.listWandMilestones(current.workspaceId)
277
298
  .find((milestone) => milestone.id !== current.id && milestone.name.toLowerCase() === name.toLowerCase());
278
299
  if (duplicate)
279
300
  throw new Error(`里程碑「${duplicate.name}」已存在。`);
280
301
  patch.name = name;
281
302
  }
303
+ if (body.workspaceId !== undefined) {
304
+ // 允许改挂工作区(例如新建任务时目录对应的项目还没建,提交后再绑上)。
305
+ const workspaceId = milestoneWorkspaceIdFrom(storage, body.workspaceId);
306
+ const duplicate = storage.listWandMilestones(workspaceId)
307
+ .find((milestone) => milestone.id !== current.id && milestone.name.toLowerCase() === current.name.toLowerCase());
308
+ if (duplicate)
309
+ throw new Error(`里程碑「${duplicate.name}」已存在。`);
310
+ patch.workspaceId = workspaceId;
311
+ }
282
312
  if (body.dueDate !== undefined)
283
313
  patch.dueDate = dateValue(body.dueDate) ?? null;
284
314
  const updated = storage.updateWandMilestone(current.id, patch);
@@ -320,13 +350,13 @@ export function registerTaskRoutes(app, deps) {
320
350
  const titleSource = providedTitle ? "user" : "auto";
321
351
  const title = providedTitle || provisionalTaskTitleFromDescription(description) || "新建任务";
322
352
  const status = STATUSES.has(body.status) ? body.status : "todo";
323
- const priority = PRIORITIES.has(body.priority) ? body.priority : "none";
353
+ const priority = PRIORITIES.has(body.priority) ? body.priority : DEFAULT_WAND_TASK_PRIORITY;
324
354
  const agent = body.agent === undefined ? null : parseTaskAgent(body.agent);
325
355
  if (agent)
326
356
  writeTaskBoardLastAgent(storage, agent);
327
357
  const labels = labelsFrom(body.labels);
328
358
  const dueDate = dateValue(body.dueDate) ?? null;
329
- const milestoneId = milestoneIdFrom(storage, body.milestoneId);
359
+ const milestoneId = scopedMilestoneId(storage, milestoneIdFrom(storage, body.milestoneId), workspaceId);
330
360
  const task = storage.transaction(() => {
331
361
  const card = storage.createWandTask({
332
362
  workspaceId,
@@ -391,11 +421,16 @@ export function registerTaskRoutes(app, deps) {
391
421
  if (workspaceId && !storage.getWorkspace(workspaceId))
392
422
  throw new Error("项目不存在。");
393
423
  patch.workspaceId = workspaceId || null;
424
+ // 换项目后原迭代不再属于新工作区就一并摘掉;移动端只 PATCH workspaceId,不会自己清。
425
+ const milestoneId = body.milestoneId !== undefined
426
+ ? patch.milestoneId
427
+ : storage.getWandTask(req.params.id)?.milestoneId ?? null;
428
+ patch.milestoneId = scopedMilestoneId(storage, milestoneId, patch.workspaceId);
394
429
  }
395
430
  if (body.agent !== undefined) {
396
- // 老客户端不传 mode:沿用任务当前的工作模式,而不是复位成标准。
431
+ // 老客户端不传 mode / kind:沿用任务当前值,而不是复位成标准 / 结构化。
397
432
  const current = storage.getWandTask(req.params.id);
398
- patch.agent = parseTaskAgent(body.agent, current?.agent?.mode);
433
+ patch.agent = parseTaskAgent(body.agent, current?.agent?.mode, current?.agent?.kind);
399
434
  if (patch.agent)
400
435
  writeTaskBoardLastAgent(storage, patch.agent);
401
436
  }
@@ -457,8 +492,9 @@ export function registerTaskRoutes(app, deps) {
457
492
  res.json({ ok: true });
458
493
  });
459
494
  /**
460
- * 用选定的 CLI 工具开一个结构化会话,把这次派发的 prompt 作为首条消息,
461
- * 并把会话绑定回该任务。cwd 取任务所属项目目录;未指定项目时用全局默认目录。
495
+ * 用选定的 CLI 工具开一个会话(`agent.kind` 为 structured 时是结构化对话,pty 时是原始
496
+ * CLI 终端),把这次派发的 prompt 作为首条消息 / 初始输入,并把会话绑定回该任务。
497
+ * cwd 取任务所属项目目录;未指定项目时用全局默认目录。
462
498
  */
463
499
  app.post("/api/wand-tasks/:id/dispatch", asyncRoute(async (req, res) => {
464
500
  try {
@@ -467,14 +503,18 @@ export function registerTaskRoutes(app, deps) {
467
503
  res.status(404).json({ error: "未找到该任务。" });
468
504
  return;
469
505
  }
470
- if (!structured || !config)
471
- throw new Error("当前服务未启用结构化会话,无法派发 Agent。");
506
+ if (!config)
507
+ throw new Error("当前服务未启用派发,无法派发 Agent。");
472
508
  const body = bodyObject(req.body);
473
509
  const agent = body.agent === undefined
474
510
  ? task.agent ?? parseWandTaskAgent(JSON.stringify(body))
475
- : parseTaskAgent(body.agent, task.agent?.mode);
511
+ : parseTaskAgent(body.agent, task.agent?.mode, task.agent?.kind);
476
512
  if (!agent)
477
513
  throw new Error("请先为该任务选择 CLI 工具。");
514
+ if (agent.kind === "structured" && !structured)
515
+ throw new Error("当前服务未启用结构化会话,无法派发 Agent。");
516
+ if (agent.kind === "pty" && !processes)
517
+ throw new Error("当前服务未启用终端会话,无法派发 Agent。");
478
518
  writeTaskBoardLastAgent(storage, agent);
479
519
  if (body.workspaceId !== undefined) {
480
520
  const workspaceId = body.workspaceId === null ? null : text(body.workspaceId) || null;
@@ -494,28 +534,43 @@ export function registerTaskRoutes(app, deps) {
494
534
  throw new Error("请输入提示词。");
495
535
  const prompt = requestedPrompt || task.description.trim() || task.title || "执行此任务";
496
536
  const model = agent.model === "default" ? "" : agent.model;
497
- const session = structured.createSession({
498
- cwd,
499
- mode: agent.mode,
500
- provider: agent.provider,
501
- model: model || getDefaultModelForProvider(config, agent.provider) || undefined,
502
- thinkingEffort: agent.thinkingEffort,
503
- worktreeEnabled: false,
504
- sessionSource: "automation",
505
- automationId: `wand-task:${task.id}`,
506
- workspaceId: group.workspaceId,
507
- workspaceTaskId: group.id,
508
- });
537
+ const provider = agent.provider;
538
+ const resolvedModel = model || getDefaultModelForProvider(config, provider) || undefined;
539
+ const session = agent.kind === "pty"
540
+ ? await processes.start(providerCliCommand(provider), cwd, agent.mode, prompt, {
541
+ provider,
542
+ model: resolvedModel,
543
+ thinkingEffort: agent.thinkingEffort,
544
+ sessionSource: "automation",
545
+ automationId: `wand-task:${task.id}`,
546
+ workspaceId: group.workspaceId,
547
+ workspaceTaskId: group.id,
548
+ })
549
+ : structured.createSession({
550
+ cwd,
551
+ mode: agent.mode,
552
+ provider,
553
+ model: resolvedModel,
554
+ thinkingEffort: agent.thinkingEffort,
555
+ worktreeEnabled: false,
556
+ sessionSource: "automation",
557
+ automationId: `wand-task:${task.id}`,
558
+ workspaceId: group.workspaceId,
559
+ workspaceTaskId: group.id,
560
+ });
509
561
  storage.updateWandTask(task.id, { agent, status: task.status === "todo" ? "doing" : task.status });
510
562
  storage.bindWandTaskSession(task.id, session.id);
511
- const completion = structured.sendMessage(session.id, prompt);
512
- completion.catch((error) => console.error(`[WandTask] Agent dispatch failed for ${task.id}:`, error));
563
+ if (agent.kind !== "pty") {
564
+ const completion = structured.sendMessage(session.id, prompt);
565
+ completion.catch((error) => console.error(`[WandTask] Agent dispatch failed for ${task.id}:`, error));
566
+ }
513
567
  res.status(202).json({
514
568
  ok: true,
515
569
  taskId: task.id,
516
570
  session: {
517
571
  id: session.id,
518
572
  provider: session.provider,
573
+ sessionKind: session.sessionKind ?? "pty",
519
574
  model: session.selectedModel,
520
575
  thinkingEffort: session.thinkingEffort,
521
576
  mode: session.mode,
@@ -528,6 +583,9 @@ export function registerTaskRoutes(app, deps) {
528
583
  }
529
584
  }));
530
585
  app.get("/api/wand-tasks/:id", asyncRoute(async (req, res) => {
586
+ // 单卡读取不跑全量同步,但至少把这张卡片自己的任务归属对上:
587
+ // 原生客户端 / 详情面板只拉单卡时,也不会看到一张没有会话的卡片。
588
+ syncWorkspaceTaskToBoard(storage, storage.getWandTask(req.params.id)?.workspaceTaskId);
531
589
  const task = dto(storage.getWandTask(req.params.id));
532
590
  if (!task) {
533
591
  res.status(404).json({ error: "未找到该任务。" });
@@ -361,7 +361,7 @@ export function registerAdminUpdateRoutes(app, deps) {
361
361
  return;
362
362
  }
363
363
  const rawIds = Array.isArray(req.body?.ids) ? req.body.ids : [];
364
- const ids = rawIds.filter((value) => value === "claude" || value === "codex" || value === "opencode" || value === "qoder" || value === "pi");
364
+ const ids = rawIds.filter((value) => value === "claude" || value === "codex" || value === "opencode" || value === "grok" || value === "qoder" || value === "pi");
365
365
  state.providerCliUpdateInFlight = true;
366
366
  try {
367
367
  const before = await refreshProviderCliUpdateState(state, config);
@@ -9,6 +9,7 @@ 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 { scopedMilestoneId } from "./milestone-scope.js";
12
13
  import { archiveBoardTaskForWorkspaceTask, archiveWorkspaceTask, ensureBoardTaskForWorkspaceTask, isUnnamedWorkspaceTaskName, moveSessionToWorkspaceTask, syncSidebarTasksFromBoard, UNNAMED_WORKSPACE_TASK_NAME } from "./wand-task-sync.js";
13
14
  import { isSessionProvider } from "./session-provider.js";
14
15
  import { firstLayoutTabId } from "./layout-tree.js";
@@ -118,12 +119,12 @@ function createTaskForWorkspace(storage, workspace, body) {
118
119
  // 留空(或老客户端回传的占位名)走自动命名:先用提示词总结,再交给模型改写。
119
120
  const named = name.length > 0 && !isUnnamedWorkspaceTaskName(name);
120
121
  const provisional = named ? "" : provisionalTaskTitleFromDescription(description);
121
- // 里程碑是全局列表;建任务时可选,非法 id 直接报错而不是静默丢掉。
122
+ // 迭代按工作区归属:项目里的任务只能挂本项目或全局的迭代(详见 milestone-scope.ts)。
122
123
  const requestedMilestoneId = typeof body.milestoneId === "string" ? body.milestoneId.trim() : "";
123
124
  if (requestedMilestoneId && !storage.getWandMilestone(requestedMilestoneId)) {
124
125
  throw new Error("未找到该里程碑。");
125
126
  }
126
- const milestoneId = requestedMilestoneId || null;
127
+ const milestoneId = scopedMilestoneId(storage, requestedMilestoneId || null, isGlobalWorkspace(workspace) ? null : workspace.id);
127
128
  let mountedCwd;
128
129
  if (body.cwd !== undefined && body.cwd !== null && String(body.cwd).trim()) {
129
130
  mountedCwd = resolveWorkspaceCwd(body.cwd);