@co0ontty/wand 4.66.0 → 4.67.0-beta.gf935003

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.
package/README.md CHANGED
@@ -322,6 +322,7 @@ wand/
322
322
  ├── android/ # Android 客户端(submodule)
323
323
  ├── ios/ # iOS 客户端(submodule)
324
324
  ├── macos/ # macOS 客户端(submodule)
325
+ ├── 架构图.md # 运行时架构与代码边界(mermaid)
325
326
  └── docs/ # 文档与截图
326
327
  ```
327
328
 
@@ -1,6 +1,6 @@
1
1
  {
2
- "commit": "835dbaf124ea4532285528a48054bb550d7cdb47",
3
- "builtAt": "2026-09-16T09:25:29.444Z",
4
- "version": "4.66.0",
5
- "channel": "stable"
2
+ "commit": "f935003c09101e33e46544770329311baa98c26e",
3
+ "builtAt": "2026-09-17T01:35:10.525Z",
4
+ "version": "4.67.0-beta.gf935003",
5
+ "channel": "beta"
6
6
  }
@@ -409,7 +409,16 @@ export async function mergeSessionWorktreeAsync(options) {
409
409
  }
410
410
  export function prepareSessionWorktree(options) {
411
411
  const resolvedCwd = path.resolve(options.cwd);
412
- const repoRoot = runGit(["rev-parse", "--show-toplevel"], resolvedCwd);
412
+ let repoRoot;
413
+ try {
414
+ repoRoot = runGit(["rev-parse", "--show-toplevel"], resolvedCwd);
415
+ }
416
+ catch (error) {
417
+ if (/not a git repository/i.test(getGitErrorMessage(error))) {
418
+ throw new Error("当前目录不是 Git 仓库。请选择 Git 仓库,或关闭“独立 worktree 隔离”后重试。");
419
+ }
420
+ throw error;
421
+ }
413
422
  if (!repoRoot || !existsSync(repoRoot)) {
414
423
  throw new Error("当前目录不在 git 仓库中,无法启用 worktree 模式。");
415
424
  }
@@ -182,7 +182,11 @@ export function registerFileRoutes(app, deps) {
182
182
  return;
183
183
  }
184
184
  try {
185
- const fileStat = await stat(resolvedPath);
185
+ const fileStat = await lstat(resolvedPath);
186
+ if (fileStat.isSymbolicLink()) {
187
+ res.status(400).json({ error: "无法保存符号链接,请打开目标文件后编辑。" });
188
+ return;
189
+ }
186
190
  if (fileStat.isDirectory()) {
187
191
  res.status(400).json({ error: "目标是目录,无法写入。" });
188
192
  return;
@@ -401,7 +401,8 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
401
401
  }
402
402
  try {
403
403
  const tree = currentDirectoryTree();
404
- if (!directoryTreeContainsPath(tree.roots, directoryPath)) {
404
+ const hasWorkspace = storage.listWorkspaces().some((workspace) => workspace.kind !== "global" && normalizeSessionDirectory(workspace.cwd) === directoryPath);
405
+ if (!directoryTreeContainsPath(tree.roots, directoryPath) && !hasWorkspace) {
405
406
  res.status(404).json({ error: "未找到该会话目录。" });
406
407
  return;
407
408
  }
@@ -105,30 +105,36 @@ export function registerSettingsRoutes(app, deps) {
105
105
  });
106
106
  }));
107
107
  app.get("/api/settings/env-preview", (req, res, next) => {
108
- if (req.query.reveal === "1")
109
- return requireAdmin(req, res, next);
110
- next();
111
- }, (req, res) => {
112
- const inheritEnv = config.inheritEnv !== false;
113
- const env = buildChildEnv(inheritEnv, {
114
- WAND_MODE: "<runtime>",
115
- WAND_AUTO_CONFIRM: "<runtime>",
116
- WAND_AUTO_EDIT: "<runtime>",
117
- });
118
108
  const reveal = req.query.reveal === "1" || req.query.reveal === "true";
119
- const sensitivePattern = /(KEY|TOKEN|SECRET|PASSWORD|AUTH|CREDENTIAL|COOKIE|SESSION)/i;
120
- const entries = Object.keys(env).sort().map((name) => {
121
- const raw = env[name] ?? "";
122
- const sensitive = sensitivePattern.test(name);
123
- const placeholder = raw.startsWith("<") && raw.endsWith(">");
124
- return {
125
- name,
126
- value: sensitive && !reveal && !placeholder ? "***" : raw,
127
- length: raw.length,
128
- sensitive,
129
- };
130
- });
131
- res.json({ inheritEnv, total: entries.length, reveal, entries });
109
+ if (reveal) {
110
+ return requireAdmin(req, res, (error) => {
111
+ if (error)
112
+ return next(error);
113
+ sendEnvironmentPreview(true);
114
+ });
115
+ }
116
+ sendEnvironmentPreview(false);
117
+ function sendEnvironmentPreview(reveal) {
118
+ const inheritEnv = config.inheritEnv !== false;
119
+ const env = buildChildEnv(inheritEnv, {
120
+ WAND_MODE: "<runtime>",
121
+ WAND_AUTO_CONFIRM: "<runtime>",
122
+ WAND_AUTO_EDIT: "<runtime>",
123
+ });
124
+ const sensitivePattern = /(KEY|TOKEN|SECRET|PASSWORD|AUTH|CREDENTIAL|COOKIE|SESSION)/i;
125
+ const entries = Object.keys(env).sort().map((name) => {
126
+ const raw = env[name] ?? "";
127
+ const sensitive = sensitivePattern.test(name);
128
+ const placeholder = raw.startsWith("<") && raw.endsWith(">");
129
+ return {
130
+ name,
131
+ value: sensitive && !reveal && !placeholder ? "***" : raw,
132
+ length: raw.length,
133
+ sensitive,
134
+ };
135
+ });
136
+ res.json({ inheritEnv, total: entries.length, reveal, entries });
137
+ }
132
138
  });
133
139
  app.post("/api/settings/system-ai/import", requireAdmin, (_req, res) => {
134
140
  const imported = discoverCliSystemAiConfigs();
@@ -485,8 +485,20 @@ export function registerWorkspaceRoutes(app, storage, sessions) {
485
485
  groups.set(id, created);
486
486
  return created;
487
487
  };
488
+ const tasksByWorkspace = new Map(workspaces.map((workspace) => [
489
+ workspace.id, storage.listWorkspaceTasks(workspace.id),
490
+ ]));
491
+ const sessionsByTask = new Map();
492
+ const taskSessions = (taskId) => {
493
+ let snapshots = sessionsByTask.get(taskId);
494
+ if (!snapshots) {
495
+ snapshots = storage.listSessionsByWorkspaceTask(taskId);
496
+ sessionsByTask.set(taskId, snapshots);
497
+ }
498
+ return snapshots;
499
+ };
488
500
  const appendTask = (target, workspace, task) => {
489
- const allSessions = storage.listSessionsByWorkspaceTask(task.id);
501
+ const allSessions = taskSessions(task.id);
490
502
  const sessions = allSessions
491
503
  .slice(0, sessionLimit ?? undefined)
492
504
  .map((session) => ({
@@ -508,7 +520,7 @@ export function registerWorkspaceRoutes(app, storage, sessions) {
508
520
  if (!base)
509
521
  continue;
510
522
  const global = isGlobalWorkspace(workspace);
511
- for (const task of storage.listWorkspaceTasks(workspace.id).slice(0, taskLimit ?? undefined)) {
523
+ for (const task of tasksByWorkspace.get(workspace.id).slice(0, taskLimit ?? undefined)) {
512
524
  const taskCwd = taskRuntimeCwd(task, workspace);
513
525
  const target = global && taskCwd
514
526
  && normalizeProjectCwd(taskCwd) !== normalizeProjectCwd(workspace.cwd)
@@ -518,9 +530,10 @@ export function registerWorkspaceRoutes(app, storage, sessions) {
518
530
  }
519
531
  }
520
532
  const taskBoundSessionIds = new Set();
521
- for (const workspace of workspaces) {
522
- for (const task of storage.listWorkspaceTasks(workspace.id)) {
523
- for (const session of storage.listSessionsByWorkspaceTask(task.id))
533
+ // Membership must include tasks/sessions hidden by the response limits.
534
+ for (const tasks of tasksByWorkspace.values()) {
535
+ for (const task of tasks) {
536
+ for (const session of taskSessions(task.id))
524
537
  taskBoundSessionIds.add(session.id);
525
538
  }
526
539
  }
package/dist/server.js CHANGED
@@ -319,8 +319,8 @@ export async function startServer(config, configPath, options = {}) {
319
319
  const terminalHost = options.terminalHost ?? await createTerminalHost(configPath);
320
320
  const processes = new ProcessManager(config, storage, configDir, terminalHost);
321
321
  const structuredLogger = new SessionLogger(configDir, config.shortcutLogMaxBytes);
322
- // Only the daemon-backed host keeps structured CLI runs alive across restarts;
323
- // the in-process fallback keeps the legacy die-with-server lifecycle.
322
+ // Production startup requires the daemon-backed host so CLI runs outlive web
323
+ // restarts. In-process hosts are retained only for explicit test injection.
324
324
  const structuredExecHost = terminalHost instanceof TerminalDaemonClient ? terminalHost : undefined;
325
325
  const structuredSessions = new StructuredSessionManager(storage, config, structuredLogger, undefined, {}, structuredExecHost);
326
326
  const sessionRegistry = new SessionRegistry(processes, structuredSessions, storage);
@@ -1,9 +1,5 @@
1
1
  import { cleanupWorktreeSync } from "./git-worktree.js";
2
2
  import { inferProviderFromCommand } from "./session-provider.js";
3
- function slimSnapshot(snapshot) {
4
- const { output: _output, messages: _messages, ...slim } = snapshot;
5
- return { ...slim, output: "" };
6
- }
7
3
  /** 读取 `hidden_claude_session_ids`;坏数据 / 空值按空集合处理,不抛错。 */
8
4
  export function readHiddenSessionIds(storage) {
9
5
  const raw = storage.getConfigValue("hidden_claude_session_ids");
@@ -82,9 +78,9 @@ export class SessionRegistry {
82
78
  if (!byId.has(snapshot.id))
83
79
  byId.set(snapshot.id, snapshot);
84
80
  }
85
- for (const snapshot of this.storage.loadSessions()) {
81
+ for (const snapshot of this.storage.loadSessionsSlim()) {
86
82
  if (!byId.has(snapshot.id))
87
- byId.set(snapshot.id, slimSnapshot(snapshot));
83
+ byId.set(snapshot.id, snapshot);
88
84
  }
89
85
  return Array.from(byId.values()).sort((a, b) => b.startedAt.localeCompare(a.startedAt));
90
86
  }
package/dist/storage.d.ts CHANGED
@@ -229,6 +229,8 @@ export declare class WandStorage {
229
229
  getSession(id: string): SessionSnapshot | null;
230
230
  getLatestSessionByClaudeSessionId(claudeSessionId: string): SessionSnapshot | null;
231
231
  loadSessions(): SessionSnapshot[];
232
+ /** List durable metadata without reading or parsing output/messages payloads. */
233
+ loadSessionsSlim(): SessionSnapshot[];
232
234
  private mapSessionRow;
233
235
  deleteSession(id: string): void;
234
236
  }
package/dist/storage.js CHANGED
@@ -251,8 +251,8 @@ function mapWorktreeMergeFields(row) {
251
251
  worktreeMergeInfo: parseWorktreeMergeInfo(row.worktree_merge_info) ?? null,
252
252
  };
253
253
  }
254
- function sessionSelectFields() {
255
- 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
254
+ function sessionSelectFields(slim = false) {
255
+ return `id, session_source, automation_id, provider, session_kind, runner, command, cwd, mode, status, exit_code, started_at, ended_at, ${slim ? "'' AS output" : "output"}, pty_output_seq, archived, archived_at, claude_session_id, ${slim ? "NULL AS messages" : "messages"}, queued_messages, queued_message_skills, structured_state
256
256
  , 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`;
257
257
  }
258
258
  function mapWorkspaceKind(raw) {
@@ -1149,11 +1149,11 @@ export class WandStorage {
1149
1149
  COALESCE(MAX(ended_at), '') AS ended,
1150
1150
  COALESCE(MAX(title), '') AS title
1151
1151
  FROM command_sessions`).get();
1152
- const tasks = this.db.prepare(`SELECT COUNT(*) AS count,
1153
- COALESCE(MAX(created_at), '') AS created,
1154
- COALESCE(MAX(last_opened_at), '') AS opened,
1155
- COALESCE(MAX(layout_revision), 0) AS revision
1156
- FROM workspace_tasks`).get();
1152
+ // Per-task metadata catches edits even when another task owns the maximum
1153
+ // timestamp/revision. Layout payloads are represented by their own revision.
1154
+ const tasks = this.db.prepare(`SELECT id, workspace_id, name, status, cwd, worktree_json, milestone_id,
1155
+ created_at, last_opened_at, layout_revision
1156
+ FROM workspace_tasks ORDER BY id`).all();
1157
1157
  const workspaces = this.db.prepare(`SELECT COUNT(*) AS count, COALESCE(MAX(last_opened_at), '') AS opened FROM workspaces`).get();
1158
1158
  // 名称也进指纹:改名(含工作区/项目名与目录自定义名)后客户端才肯重拉任务列表。
1159
1159
  const names = this.db.prepare(`SELECT
@@ -1725,6 +1725,11 @@ export class WandStorage {
1725
1725
  .all();
1726
1726
  return rows.map((row) => this.mapSessionRow(row));
1727
1727
  }
1728
+ /** List durable metadata without reading or parsing output/messages payloads. */
1729
+ loadSessionsSlim() {
1730
+ const rows = this.db.prepare(`SELECT ${sessionSelectFields(true)} FROM command_sessions ORDER BY started_at DESC`).all();
1731
+ return rows.map((row) => this.mapSessionRow(row));
1732
+ }
1728
1733
  mapSessionRow(row) {
1729
1734
  return mapSessionCore(row);
1730
1735
  }
@@ -2,12 +2,17 @@ import { spawn } from "node:child_process";
2
2
  import type { StructuredExecHost } from "./structured-exec-host.js";
3
3
  import type { StructuredRunnerAdapter, StructuredRunnerContext, StructuredRunnerExecution, StructuredRunnerObserver, StructuredRunnerTurnState } from "./structured-runner.js";
4
4
  import type { SessionSnapshot } from "./types.js";
5
+ type PiTurnState = StructuredRunnerTurnState & {
6
+ pendingSessionId?: string;
7
+ };
8
+ export declare function isMissingPiSession(error: string | null | undefined, sessionId: string | null | undefined): boolean;
5
9
  export declare function buildPiArgs(session: SessionSnapshot, prompt: string): string[];
6
10
  export declare function piToolName(name: string): string;
7
- export declare function applyPiEvent(state: StructuredRunnerTurnState, event: Record<string, unknown>): string | null;
11
+ export declare function applyPiEvent(state: PiTurnState, event: Record<string, unknown>): string | null;
8
12
  export declare class PiRunner implements StructuredRunnerAdapter {
9
13
  private readonly spawnProcess;
10
14
  private readonly execHost?;
11
15
  constructor(spawnProcess?: typeof spawn, execHost?: StructuredExecHost | undefined);
12
16
  start(context: StructuredRunnerContext, observer: StructuredRunnerObserver): StructuredRunnerExecution;
13
17
  }
18
+ export {};
@@ -2,6 +2,9 @@ import { spawn } from "node:child_process";
2
2
  import { startStructuredCli } from "./structured-exec-pump.js";
3
3
  import { asRecord } from "./structured-content.js";
4
4
  import { thinkingEffortToPiLevel } from "./structured-provider-common.js";
5
+ export function isMissingPiSession(error, sessionId) {
6
+ return !!sessionId && !!error?.includes(`No session found matching '${sessionId}'`);
7
+ }
5
8
  function textContent(value) {
6
9
  if (typeof value === "string")
7
10
  return value;
@@ -88,9 +91,15 @@ function applyPiAssistantMessage(state, message) {
88
91
  return null;
89
92
  }
90
93
  export function applyPiEvent(state, event) {
94
+ // Pi announces the ID before it writes a session file. The file is only
95
+ // created when an assistant message is saved, so this ID is not resumable yet.
91
96
  if (event.type === "session" && typeof event.id === "string")
92
- state.sessionId = event.id;
97
+ state.pendingSessionId = event.id;
98
+ if (event.type === "turn_start" || event.type === "message_start" || event.type === "tool_execution_start") {
99
+ state.phase = "responding";
100
+ }
93
101
  if (event.type === "message_update") {
102
+ state.phase = "responding";
94
103
  const update = asRecord(event.assistantMessageEvent);
95
104
  const delta = typeof update?.delta === "string" ? update.delta : "";
96
105
  if (update?.type === "text_delta" && delta) {
@@ -136,13 +145,25 @@ export function applyPiEvent(state, event) {
136
145
  }
137
146
  if (event.type === "message_end" || event.type === "turn_end") {
138
147
  const message = asRecord(event.message);
139
- if (message?.role === "assistant")
140
- return applyPiAssistantMessage(state, message);
148
+ if (message?.role === "assistant") {
149
+ if (state.pendingSessionId)
150
+ state.sessionId = state.pendingSessionId;
151
+ const error = applyPiAssistantMessage(state, message);
152
+ if (event.type === "turn_end") {
153
+ const content = Array.isArray(message.content) ? message.content : [];
154
+ const hasToolCall = content.some((part) => asRecord(part)?.type === "toolCall");
155
+ if (!hasToolCall && message.stopReason !== "toolUse")
156
+ state.phase = "background";
157
+ }
158
+ return error;
159
+ }
141
160
  }
142
161
  if (event.type === "agent_end" && Array.isArray(event.messages)) {
143
162
  for (const raw of event.messages) {
144
163
  const message = asRecord(raw);
145
164
  if (message?.role === "assistant") {
165
+ if (state.pendingSessionId)
166
+ state.sessionId = state.pendingSessionId;
146
167
  const error = applyPiAssistantMessage(state, message);
147
168
  if (error)
148
169
  return error;
@@ -160,7 +181,13 @@ export class PiRunner {
160
181
  }
161
182
  start(context, observer) {
162
183
  const args = buildPiArgs(context.session, context.prompt);
163
- const state = { blocks: [], result: "", sessionId: context.session.claudeSessionId, model: context.session.selectedModel ?? undefined };
184
+ const state = {
185
+ blocks: [],
186
+ result: "",
187
+ sessionId: context.session.claudeSessionId,
188
+ model: context.session.selectedModel ?? undefined,
189
+ phase: "responding",
190
+ };
164
191
  let primaryError = null;
165
192
  return startStructuredCli({
166
193
  sessionId: context.session.id,
@@ -5,6 +5,8 @@ export interface StructuredRunnerTurnState {
5
5
  sessionId: string | null;
6
6
  model?: string;
7
7
  usage?: ConversationTurn["usage"];
8
+ /** Provider turn phase when the CLI stays alive after its visible reply. */
9
+ phase?: "responding" | "background";
8
10
  }
9
11
  export interface StructuredRunnerContext {
10
12
  session: SessionSnapshot;
@@ -101,14 +101,19 @@ export declare class StructuredSessionManager {
101
101
  private readonly piRunner;
102
102
  /** Structured CLI runs that were mid-flight when the previous web process died. */
103
103
  private pendingRecoveryIds;
104
+ private detachedRecoveryPromise;
105
+ private detachedRecoveryRetryTimer;
106
+ private detachedRecoveryFailureLogged;
104
107
  private disposed;
105
108
  constructor(storage: WandStorage, config: WandConfig, logger?: SessionLogger | null, sdkQueryFactory?: typeof sdkQuery, runners?: StructuredSessionManagerRunners, execHost?: StructuredExecHost | undefined);
106
109
  private archiveExpiredSessions;
107
110
  setEventEmitter(emitEvent: (event: ProcessEvent) => void): void;
108
111
  /** Stop every runner and flush terminal state before storage is closed. */
109
112
  dispose(): void;
110
- /** Called once after startup wiring; safe to skip when no host or candidates. */
113
+ /** Called once after startup wiring; transient daemon failures retry automatically. */
111
114
  recoverDetachedRuns(): Promise<void>;
115
+ private recoverDetachedRunsOnce;
116
+ private scheduleDetachedRecovery;
112
117
  private resumeDetachedRun;
113
118
  private finalizeRecoveredRun;
114
119
  private trackStreamEmitTimer;