@co0ontty/wand 4.42.1 → 4.43.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.
@@ -16,6 +16,7 @@ interface ResolvedUpdateAsset {
16
16
  size: number;
17
17
  source: "local" | "github";
18
18
  releaseNotes?: string;
19
+ sha256?: string;
19
20
  }
20
21
  export interface PublicUpdateRoutesDependencies {
21
22
  resolveLatestApk(channel: "stable" | "beta"): Promise<ResolvedUpdateAsset | null>;
@@ -31,6 +31,8 @@ export function registerPublicUpdateRoutes(app, deps) {
31
31
  source: latest.source,
32
32
  channel,
33
33
  releaseNotes: updateAvailable ? (latest.releaseNotes ?? null) : null,
34
+ // 本地分发时为 hex SHA-256,Android 客户端下载后校验;GitHub 来源为 null。
35
+ sha256: updateAvailable ? (latest.sha256 ?? null) : null,
34
36
  });
35
37
  }));
36
38
  app.get("/android/download", asyncRoute(async (req, res) => {
@@ -5,6 +5,8 @@ import { asyncRoute } from "./express-async.js";
5
5
  import { getErrorMessage } from "./error-utils.js";
6
6
  import { expandHomePath } from "./middleware/path-safety.js";
7
7
  import { checkSessionWorktreeMergeabilityAsync, cleanupWorktreeSync, prepareSessionWorktree, resolveWorktreeTargetBranchAsync, } from "./git-worktree.js";
8
+ import { resolveSessionDisplayTitle } from "./session-transport.js";
9
+ import { attachUnboundSessionsToWorkspace, backfillSessionWorkspaces, } from "./workspace-binding.js";
8
10
  const PROVIDERS = new Set(["claude", "codex", "opencode", "grok", "qoder", "pi"]);
9
11
  function parseDefaultProvider(value) {
10
12
  return typeof value === "string" && PROVIDERS.has(value) ? value : undefined;
@@ -29,11 +31,13 @@ function deleteSessions(storage, sessions, sessionIds) {
29
31
  storage.deleteSession(sessionId);
30
32
  }
31
33
  }
32
- function workspaceWithWorktreeCount(storage, workspace) {
34
+ function workspaceWithCounts(storage, workspace, sessionCounts) {
33
35
  const worktreeCount = storage.listWorkspaceTasks(workspace.id)
34
36
  .filter((task) => task.worktree !== null)
35
37
  .length;
36
- return { ...workspace, worktreeCount };
38
+ const sessionCount = sessionCounts?.get(workspace.id)
39
+ ?? storage.listSessionsByWorkspace(workspace.id).length;
40
+ return { ...workspace, worktreeCount, sessionCount };
37
41
  }
38
42
  // ── Layout validation / sanitization(前端 PUT 与测试共用)──
39
43
  function sanitizePaneTab(value) {
@@ -148,7 +152,9 @@ export function sanitizeTaskLayout(value) {
148
152
  export function registerWorkspaceRoutes(app, storage, sessions) {
149
153
  // 列出所有项目(按最近打开排序)
150
154
  app.get("/api/workspaces", (_req, res) => {
151
- res.json(storage.listWorkspaces().map((workspace) => workspaceWithWorktreeCount(storage, workspace)));
155
+ backfillSessionWorkspaces(storage);
156
+ const sessionCounts = storage.countSessionsByWorkspace();
157
+ res.json(storage.listWorkspaces().map((workspace) => workspaceWithCounts(storage, workspace, sessionCounts)));
152
158
  });
153
159
  // 新建项目:名称 + 目录 + 默认 IDE,不启动会话
154
160
  app.post("/api/workspaces", asyncRoute(async (req, res) => {
@@ -168,7 +174,8 @@ export function registerWorkspaceRoutes(app, storage, sessions) {
168
174
  }
169
175
  const defaultProvider = parseDefaultProvider(body.defaultProvider);
170
176
  const workspace = storage.createWorkspace({ name, cwd, defaultProvider });
171
- res.status(201).json({ ...workspace, worktreeCount: 0 });
177
+ const attached = attachUnboundSessionsToWorkspace(storage, workspace);
178
+ res.status(201).json({ ...workspace, worktreeCount: 0, sessionCount: attached });
172
179
  }));
173
180
  // 项目详情:meta + 会话 + 布局;访问即更新 lastOpenedAt
174
181
  app.get("/api/workspaces/:id", (req, res) => {
@@ -179,8 +186,11 @@ export function registerWorkspaceRoutes(app, storage, sessions) {
179
186
  }
180
187
  storage.touchWorkspace(workspace.id);
181
188
  res.json({
182
- ...workspaceWithWorktreeCount(storage, workspace),
183
- sessions: storage.listSessionsByWorkspace(workspace.id),
189
+ ...workspaceWithCounts(storage, workspace),
190
+ sessions: storage.listSessionsByWorkspace(workspace.id).map((session) => ({
191
+ ...session,
192
+ title: resolveSessionDisplayTitle(session),
193
+ })),
184
194
  });
185
195
  });
186
196
  // 改名 / 目录 / 默认 IDE
@@ -213,7 +223,7 @@ export function registerWorkspaceRoutes(app, storage, sessions) {
213
223
  }
214
224
  storage.updateWorkspace(existing.id, patch);
215
225
  const updated = storage.getWorkspace(existing.id);
216
- res.json(updated ? workspaceWithWorktreeCount(storage, updated) : null);
226
+ res.json(updated ? workspaceWithCounts(storage, updated) : null);
217
227
  });
218
228
  // 删除项目;cascade=true 连带删会话,否则仅解绑
219
229
  app.delete("/api/workspaces/:id", (req, res) => {
package/dist/server.js CHANGED
@@ -28,6 +28,7 @@ import { refreshProviderCliUpdateState, registerAdminUpdateRoutes, registerPubli
28
28
  import { parseSessionCreationOrigin, registerClaudeHistoryRoutes, registerSessionRoutes } from "./server-session-routes.js";
29
29
  import { registerWorkspaceRoutes } from "./server-workspace-routes.js";
30
30
  import { resolveSessionCwd } from "./session-cwd.js";
31
+ import { resolveWorkspaceIdForNewSession } from "./workspace-binding.js";
31
32
  import { getErrorMessage } from "./error-utils.js";
32
33
  import { asyncRoute, jsonErrorHandler } from "./express-async.js";
33
34
  import { checkPackageUpdateAsync, installPackageGloballyAsync, normalizeUpdateChannel, resolveGlobalWandCli, } from "./npm-update-utils.js";
@@ -1020,23 +1021,25 @@ export async function startServer(config, configPath, options = {}) {
1020
1021
  : undefined;
1021
1022
  const reqCols = typeof body.cols === "number" && Number.isFinite(body.cols) ? body.cols : undefined;
1022
1023
  const reqRows = typeof body.rows === "number" && Number.isFinite(body.rows) ? body.rows : undefined;
1024
+ const sessionCwd = resolveSessionCwd(body.cwd, config.defaultCwd);
1025
+ const workspaceId = resolveWorkspaceIdForNewSession(storage, sessionCwd, body.workspaceId);
1023
1026
  const snapshot = await (interactiveShell
1024
- ? processes.startShell(body.cwd, body.mode ?? "default", {
1027
+ ? processes.startShell(sessionCwd, body.mode ?? "default", {
1025
1028
  worktreeEnabled: body.worktreeEnabled === true,
1026
1029
  cols: reqCols,
1027
1030
  rows: reqRows,
1028
- workspaceId: body.workspaceId,
1031
+ workspaceId,
1029
1032
  workspaceTaskId: body.workspaceTaskId,
1030
1033
  ...origin,
1031
1034
  })
1032
- : processes.start(command, body.cwd, body.mode ?? config.defaultMode, initialInput || undefined, {
1035
+ : processes.start(command, sessionCwd, body.mode ?? config.defaultMode, initialInput || undefined, {
1033
1036
  worktreeEnabled: body.worktreeEnabled === true,
1034
1037
  provider,
1035
1038
  model: effectiveModel,
1036
1039
  cols: reqCols,
1037
1040
  rows: reqRows,
1038
1041
  thinkingEffort: body.thinkingEffort ?? config.defaultThinkingEffort,
1039
- workspaceId: body.workspaceId,
1042
+ workspaceId,
1040
1043
  workspaceTaskId: body.workspaceTaskId,
1041
1044
  ...origin,
1042
1045
  }));
package/dist/storage.d.ts CHANGED
@@ -60,6 +60,14 @@ export declare class WandStorage {
60
60
  listSessionsByWorkspace(workspaceId: string): SessionSnapshot[];
61
61
  /** 显式更新某会话的工作空间归属(用于创建时绑定)。 */
62
62
  setSessionWorkspaceId(sessionId: string, workspaceId: string | null): void;
63
+ /** Lightweight count of persisted sessions grouped by workspace. */
64
+ countSessionsByWorkspace(): Map<string, number>;
65
+ /** Sessions not yet attached to a project. Omits messages/output. */
66
+ listUnboundSessionBindings(): Array<{
67
+ id: string;
68
+ cwd: string;
69
+ worktree: SessionSnapshot["worktree"];
70
+ }>;
63
71
  listWorkspaceTasks(workspaceId: string): WorkspaceTask[];
64
72
  getWorkspaceTask(id: string): WorkspaceTask | null;
65
73
  createWorkspaceTask(input: {
package/dist/storage.js CHANGED
@@ -861,6 +861,29 @@ export class WandStorage {
861
861
  setSessionWorkspaceId(sessionId, workspaceId) {
862
862
  this.db.prepare("UPDATE command_sessions SET workspace_id = ? WHERE id = ?").run(workspaceId, sessionId);
863
863
  }
864
+ /** Lightweight count of persisted sessions grouped by workspace. */
865
+ countSessionsByWorkspace() {
866
+ const rows = this.db
867
+ .prepare(`SELECT workspace_id AS id, COUNT(*) AS n
868
+ FROM command_sessions
869
+ WHERE workspace_id IS NOT NULL AND workspace_id != ''
870
+ GROUP BY workspace_id`)
871
+ .all();
872
+ return new Map(rows.map((row) => [row.id, Number(row.n) || 0]));
873
+ }
874
+ /** Sessions not yet attached to a project. Omits messages/output. */
875
+ listUnboundSessionBindings() {
876
+ const rows = this.db
877
+ .prepare(`SELECT id, cwd, worktree_info
878
+ FROM command_sessions
879
+ WHERE workspace_id IS NULL OR workspace_id = ''`)
880
+ .all();
881
+ return rows.map((row) => ({
882
+ id: row.id,
883
+ cwd: row.cwd,
884
+ worktree: parseWorktreeInfo(row.worktree_info) ?? null,
885
+ }));
886
+ }
864
887
  // ── Workspace tasks(任务 = 命名 + 独立 worktree + 一组标签)──
865
888
  listWorkspaceTasks(workspaceId) {
866
889
  const rows = this.db
@@ -1111,7 +1134,7 @@ export class WandStorage {
1111
1134
  deleteExpiredAuthSessions(now) {
1112
1135
  this.db.prepare("DELETE FROM auth_sessions WHERE expires_at < ?").run(now);
1113
1136
  }
1114
- // ============ Missions / Agent Inbox ============
1137
+ // ============ Missions ============
1115
1138
  saveMission(mission) {
1116
1139
  this.db.prepare(`INSERT INTO missions (
1117
1140
  id, title, prompt, cwd, status, base_ref, shared_directories, copy_paths, created_at, updated_at
@@ -11,12 +11,32 @@ export declare class TerminalDaemonClient implements TerminalHost {
11
11
  private readonly inventory;
12
12
  private readonly handles;
13
13
  private readonly pendingEvents;
14
+ private disposed;
15
+ private reconnectTimer;
16
+ private reconnectDelayMs;
17
+ private reconnectFailureLogged;
14
18
  constructor(socketPath: string, token: string);
15
19
  connect(): Promise<void>;
16
20
  attach(sessionId: string, afterSeq?: number): TerminalAttachResult | null;
17
21
  createOrAttach(request: TerminalSpawnRequest, afterSeq?: number): Promise<TerminalAttachResult>;
18
22
  forget(sessionId: string): void;
19
23
  disconnect(): void;
24
+ /**
25
+ * Socket teardown path. Without a reconnect, a daemon restart would leave
26
+ * every RemoteTerminalProcess silently dead while ProcessManager keeps the
27
+ * session status at "running" forever.
28
+ */
29
+ private handleDisconnect;
30
+ private scheduleReconnect;
31
+ private tryReconnect;
32
+ /**
33
+ * Diff the pre-disconnect inventory against the daemon's fresh `list`.
34
+ * Sessions that vanished (daemon restart/forget) or exited while we were
35
+ * disconnected get a synthetic exit delivered to their existing handle so
36
+ * ProcessManager finalizes them; live sessions replay chunks missed during
37
+ * the gap.
38
+ */
39
+ private reconcileAfterReconnect;
20
40
  request(method: TerminalDaemonRequest["method"], params?: Record<string, unknown>): Promise<unknown>;
21
41
  reportOperationError(sessionId: string, error: unknown): void;
22
42
  private resultFromState;
@@ -5,6 +5,8 @@ import process from "node:process";
5
5
  import { TERMINAL_DAEMON_PROTOCOL_VERSION, terminalDaemonPaths, } from "./terminal-daemon-protocol.js";
6
6
  import { appendTerminalChunkWindow, InProcessTerminalHost, } from "./terminal-host.js";
7
7
  import { appendWindow, PTY_OUTPUT_MAX_SIZE } from "./pty-text-utils.js";
8
+ const TERMINAL_DAEMON_RECONNECT_INITIAL_MS = 500;
9
+ const TERMINAL_DAEMON_RECONNECT_MAX_MS = 10_000;
8
10
  class RemoteTerminalProcess {
9
11
  sessionId;
10
12
  incarnationId;
@@ -106,11 +108,17 @@ export class TerminalDaemonClient {
106
108
  inventory = new Map();
107
109
  handles = new Map();
108
110
  pendingEvents = new Map();
111
+ disposed = false;
112
+ reconnectTimer = null;
113
+ reconnectDelayMs = TERMINAL_DAEMON_RECONNECT_INITIAL_MS;
114
+ reconnectFailureLogged = false;
109
115
  constructor(socketPath, token) {
110
116
  this.socketPath = socketPath;
111
117
  this.token = token;
112
118
  }
113
119
  async connect() {
120
+ if (this.disposed)
121
+ throw new Error("Terminal daemon client disposed");
114
122
  if (this.socket && !this.socket.destroyed)
115
123
  return;
116
124
  const socket = net.createConnection(this.socketPath);
@@ -127,13 +135,33 @@ export class TerminalDaemonClient {
127
135
  socket.once("error", onError);
128
136
  });
129
137
  socket.on("data", (data) => this.consume(data.toString("utf8")));
130
- socket.on("close", () => this.rejectPending(new Error("Terminal daemon disconnected")));
131
- socket.on("error", (error) => this.rejectPending(error));
132
- await this.request("hello");
133
- const sessions = await this.request("list");
134
- this.inventory.clear();
135
- for (const session of sessions)
136
- this.inventory.set(session.sessionId, session);
138
+ socket.on("close", () => this.handleDisconnect());
139
+ socket.on("error", (error) => {
140
+ this.rejectPending(error instanceof Error ? error : new Error(String(error)));
141
+ this.handleDisconnect();
142
+ });
143
+ try {
144
+ await this.request("hello");
145
+ const previous = new Map(this.inventory);
146
+ const sessions = await this.request("list");
147
+ this.inventory.clear();
148
+ for (const session of sessions)
149
+ this.inventory.set(session.sessionId, session);
150
+ if (previous.size > 0) {
151
+ this.reconcileAfterReconnect(previous);
152
+ process.stderr.write("[wand] Reconnected to terminal daemon; reconciled PTY inventory.\n");
153
+ }
154
+ }
155
+ catch (error) {
156
+ if (!this.disposed) {
157
+ try {
158
+ socket.destroy();
159
+ }
160
+ catch { /* best-effort cleanup */ }
161
+ this.handleDisconnect();
162
+ }
163
+ throw error;
164
+ }
137
165
  }
138
166
  attach(sessionId, afterSeq = 0) {
139
167
  const state = this.inventory.get(sessionId);
@@ -156,6 +184,11 @@ export class TerminalDaemonClient {
156
184
  void this.request("forget", { sessionId }).catch((error) => this.reportOperationError(sessionId, error));
157
185
  }
158
186
  disconnect() {
187
+ this.disposed = true;
188
+ if (this.reconnectTimer) {
189
+ clearTimeout(this.reconnectTimer);
190
+ this.reconnectTimer = null;
191
+ }
159
192
  const socket = this.socket;
160
193
  this.socket = null;
161
194
  if (socket && !socket.destroyed)
@@ -164,6 +197,78 @@ export class TerminalDaemonClient {
164
197
  this.handles.clear();
165
198
  this.pendingEvents.clear();
166
199
  }
200
+ /**
201
+ * Socket teardown path. Without a reconnect, a daemon restart would leave
202
+ * every RemoteTerminalProcess silently dead while ProcessManager keeps the
203
+ * session status at "running" forever.
204
+ */
205
+ handleDisconnect() {
206
+ if (this.socket?.destroyed)
207
+ this.socket = null;
208
+ this.rejectPending(new Error("Terminal daemon disconnected"));
209
+ if (this.disposed)
210
+ return;
211
+ this.scheduleReconnect();
212
+ }
213
+ scheduleReconnect() {
214
+ if (this.disposed || this.reconnectTimer)
215
+ return;
216
+ this.reconnectTimer = setTimeout(() => {
217
+ this.reconnectTimer = null;
218
+ void this.tryReconnect();
219
+ }, this.reconnectDelayMs);
220
+ this.reconnectTimer.unref?.();
221
+ this.reconnectDelayMs = Math.min(this.reconnectDelayMs * 2, TERMINAL_DAEMON_RECONNECT_MAX_MS);
222
+ }
223
+ async tryReconnect() {
224
+ if (this.disposed)
225
+ return;
226
+ try {
227
+ await this.connect();
228
+ this.reconnectDelayMs = TERMINAL_DAEMON_RECONNECT_INITIAL_MS;
229
+ this.reconnectFailureLogged = false;
230
+ }
231
+ catch (error) {
232
+ if (this.disposed)
233
+ return;
234
+ if (!this.reconnectFailureLogged) {
235
+ this.reconnectFailureLogged = true;
236
+ process.stderr.write(`[wand] Terminal daemon reconnect failed: ${error instanceof Error ? error.message : String(error)}\n`);
237
+ }
238
+ this.scheduleReconnect();
239
+ }
240
+ }
241
+ /**
242
+ * Diff the pre-disconnect inventory against the daemon's fresh `list`.
243
+ * Sessions that vanished (daemon restart/forget) or exited while we were
244
+ * disconnected get a synthetic exit delivered to their existing handle so
245
+ * ProcessManager finalizes them; live sessions replay chunks missed during
246
+ * the gap.
247
+ */
248
+ reconcileAfterReconnect(previous) {
249
+ for (const [sessionId, oldState] of previous) {
250
+ const current = this.inventory.get(sessionId);
251
+ const handle = this.handles.get(sessionId);
252
+ const handleMatches = !!handle && handle.incarnationId === oldState.incarnationId;
253
+ const stillRunning = !!current
254
+ && current.incarnationId === oldState.incarnationId
255
+ && current.status === "running";
256
+ if (stillRunning && handleMatches) {
257
+ for (const chunk of current.chunks) {
258
+ if (chunk.seq > oldState.seq)
259
+ handle.acceptData(chunk);
260
+ }
261
+ continue;
262
+ }
263
+ const exitedOnDaemon = !!current
264
+ && current.incarnationId === oldState.incarnationId
265
+ && current.status === "exited";
266
+ const exitCode = exitedOnDaemon ? current.exitCode ?? -1 : -1;
267
+ this.handles.delete(sessionId);
268
+ if (handleMatches)
269
+ handle.acceptExit({ exitCode });
270
+ }
271
+ }
167
272
  async request(method, params) {
168
273
  const socket = this.socket;
169
274
  if (!socket || socket.destroyed || !socket.writable)
package/dist/types.d.ts CHANGED
@@ -344,6 +344,8 @@ export interface InputRequest {
344
344
  * and continue receiving progress through the existing event stream.
345
345
  */
346
346
  respondImmediately?: boolean;
347
+ /** PTY input can request a small acknowledgement instead of a full session detail payload. */
348
+ responseMode?: "snapshot" | "accepted";
347
349
  /** Current UI view: "chat" or "terminal". Chat view uses PTY-derived structured messages. */
348
350
  view?: "chat" | "terminal";
349
351
  autonomyPolicy?: AutonomyPolicy;