@co0ontty/wand 1.7.0 → 1.10.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,7 @@
1
1
  import express from "express";
2
2
  import { parseMessages } from "./message-parser.js";
3
3
  import { SessionInputError } from "./process-manager.js";
4
+ import { checkSessionWorktreeMergeability, cleanupSessionWorktree, getWorktreeMergeErrorCode, mergeSessionWorktree, WorktreeMergeError } from "./git-worktree.js";
4
5
  export function getErrorMessage(error, fallback) {
5
6
  return error instanceof Error ? error.message : fallback;
6
7
  }
@@ -70,6 +71,66 @@ function listAllSessions(processes, structured) {
70
71
  return [...structured.list(), ...processes.list()]
71
72
  .sort((a, b) => b.startedAt.localeCompare(a.startedAt));
72
73
  }
74
+ function requireWorktreeSession(snapshot) {
75
+ if (!snapshot) {
76
+ throw new Error("未找到该会话。");
77
+ }
78
+ if (!snapshot.worktreeEnabled || !snapshot.worktree?.branch || !snapshot.worktree?.path) {
79
+ throw new Error("该会话未启用 worktree 模式。 ");
80
+ }
81
+ return snapshot;
82
+ }
83
+ function buildWorktreeMergeInfo(current, status, info) {
84
+ return {
85
+ ...(current.worktreeMergeInfo ?? null),
86
+ ...(info ?? null),
87
+ lastError: info?.lastError,
88
+ conflict: info?.conflict,
89
+ };
90
+ }
91
+ function saveWorktreeMergeState(storage, current, status, info) {
92
+ const mergedInfo = buildWorktreeMergeInfo(current, status, info);
93
+ const updated = {
94
+ ...current,
95
+ worktreeMergeStatus: status,
96
+ worktreeMergeInfo: mergedInfo,
97
+ };
98
+ storage.saveSessionMetadata(updated);
99
+ return updated;
100
+ }
101
+ function getWorktreeMergeResponseStatus(error) {
102
+ const code = getWorktreeMergeErrorCode(error);
103
+ if (!code) {
104
+ return 400;
105
+ }
106
+ if (code === "WORKTREE_MERGE_CONFLICT") {
107
+ return 409;
108
+ }
109
+ return 400;
110
+ }
111
+ function getWorktreeMergePayload(error, fallback) {
112
+ if (error instanceof WorktreeMergeError) {
113
+ return {
114
+ error: error.message,
115
+ errorCode: error.code,
116
+ result: error.result ?? null,
117
+ };
118
+ }
119
+ return {
120
+ error: getErrorMessage(error, fallback),
121
+ errorCode: getWorktreeMergeErrorCode(error) ?? null,
122
+ result: null,
123
+ };
124
+ }
125
+ function getLatestSessionSnapshot(processes, structured, storage, id) {
126
+ return getSessionById(processes, structured, id) ?? storage.getSession(id);
127
+ }
128
+ function canMergeSession(snapshot) {
129
+ return Boolean(snapshot.worktreeEnabled && snapshot.worktree?.branch && snapshot.worktree?.path);
130
+ }
131
+ function isMergeActionAllowed(snapshot) {
132
+ return snapshot.status !== "running";
133
+ }
73
134
  export function registerSessionRoutes(app, processes, structured, storage, defaultMode) {
74
135
  app.get("/api/sessions", (_req, res) => {
75
136
  const all = listAllSessions(processes, structured);
@@ -117,6 +178,98 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
117
178
  res.status(400).json({ error: getErrorMessage(error, "无法发送结构化消息。") });
118
179
  }
119
180
  });
181
+ app.post("/api/sessions/:id/worktree/merge/check", (req, res) => {
182
+ try {
183
+ const current = requireWorktreeSession(getLatestSessionSnapshot(processes, structured, storage, req.params.id));
184
+ if (!isMergeActionAllowed(current)) {
185
+ res.status(409).json({ error: "会话仍在运行,请结束后再合并。", errorCode: "SESSION_STILL_RUNNING" });
186
+ return;
187
+ }
188
+ const checking = saveWorktreeMergeState(storage, current, "checking", {
189
+ ...(current.worktreeMergeInfo ?? null),
190
+ targetBranch: current.worktreeMergeInfo?.targetBranch,
191
+ lastError: undefined,
192
+ conflict: false,
193
+ });
194
+ const result = checkSessionWorktreeMergeability({
195
+ worktree: checking.worktree,
196
+ targetBranch: current.worktreeMergeInfo?.targetBranch,
197
+ });
198
+ const nextStatus = result.ok ? "ready" : "failed";
199
+ const updated = saveWorktreeMergeState(storage, checking, nextStatus, {
200
+ targetBranch: result.targetBranch,
201
+ conflict: result.hasConflicts,
202
+ lastError: result.ok ? undefined : result.reason,
203
+ });
204
+ res.json({ session: updated, result });
205
+ }
206
+ catch (error) {
207
+ res.status(getWorktreeMergeResponseStatus(error)).json(getWorktreeMergePayload(error, "无法检查 worktree 合并状态。"));
208
+ }
209
+ });
210
+ app.post("/api/sessions/:id/worktree/merge", express.json(), (req, res) => {
211
+ try {
212
+ const current = requireWorktreeSession(getLatestSessionSnapshot(processes, structured, storage, req.params.id));
213
+ if (!isMergeActionAllowed(current)) {
214
+ res.status(409).json({ error: "会话仍在运行,请结束后再合并。", errorCode: "SESSION_STILL_RUNNING" });
215
+ return;
216
+ }
217
+ const merging = saveWorktreeMergeState(storage, current, "merging", {
218
+ ...(current.worktreeMergeInfo ?? null),
219
+ lastError: undefined,
220
+ conflict: false,
221
+ });
222
+ const result = mergeSessionWorktree({
223
+ worktree: merging.worktree,
224
+ targetBranch: current.worktreeMergeInfo?.targetBranch,
225
+ });
226
+ const updated = saveWorktreeMergeState(storage, merging, "merged", {
227
+ targetBranch: result.targetBranch,
228
+ mergedAt: result.mergedAt,
229
+ mergeCommit: result.mergeCommit,
230
+ cleanupDone: result.cleanupDone,
231
+ lastError: undefined,
232
+ conflict: false,
233
+ });
234
+ res.json({ session: updated, result });
235
+ }
236
+ catch (error) {
237
+ const current = getLatestSessionSnapshot(processes, structured, storage, req.params.id);
238
+ if (current && canMergeSession(current)) {
239
+ const payload = getWorktreeMergePayload(error, "无法合并 worktree。");
240
+ saveWorktreeMergeState(storage, current, "failed", {
241
+ ...(current.worktreeMergeInfo ?? null),
242
+ targetBranch: payload.result?.targetBranch ?? current.worktreeMergeInfo?.targetBranch,
243
+ mergedAt: payload.result?.mergedAt,
244
+ mergeCommit: payload.result?.mergeCommit,
245
+ cleanupDone: payload.result?.cleanupDone,
246
+ lastError: payload.error,
247
+ conflict: payload.result?.conflict === true,
248
+ });
249
+ }
250
+ res.status(getWorktreeMergeResponseStatus(error)).json(getWorktreeMergePayload(error, "无法合并 worktree。"));
251
+ }
252
+ });
253
+ app.post("/api/sessions/:id/worktree/cleanup", (req, res) => {
254
+ try {
255
+ const current = requireWorktreeSession(getLatestSessionSnapshot(processes, structured, storage, req.params.id));
256
+ if (current.worktreeMergeStatus !== "merged" || current.worktreeMergeInfo?.cleanupDone !== false) {
257
+ res.status(400).json({ error: "当前 worktree 无需补偿清理。", errorCode: "WORKTREE_CLEANUP_NOT_NEEDED" });
258
+ return;
259
+ }
260
+ cleanupSessionWorktree({ worktree: current.worktree });
261
+ const updated = saveWorktreeMergeState(storage, current, "merged", {
262
+ ...(current.worktreeMergeInfo ?? null),
263
+ cleanupDone: true,
264
+ lastError: undefined,
265
+ conflict: false,
266
+ });
267
+ res.json({ session: updated, ok: true });
268
+ }
269
+ catch (error) {
270
+ res.status(getWorktreeMergeResponseStatus(error)).json(getWorktreeMergePayload(error, "无法清理 worktree。"));
271
+ }
272
+ });
120
273
  app.post("/api/sessions/batch-delete", express.json(), (req, res) => {
121
274
  const sessionIds = Array.isArray(req.body?.sessionIds)
122
275
  ? req.body.sessionIds.filter((value) => typeof value === "string" && value.trim().length > 0)
package/dist/server.js CHANGED
@@ -3,7 +3,7 @@ import { readdir, readFile, stat } from "node:fs/promises";
3
3
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
4
4
  import { createServer as createHttpServer } from "node:http";
5
5
  import { createServer as createHttpsServer } from "node:https";
6
- import { exec } from "node:child_process";
6
+ import { exec, spawn } from "node:child_process";
7
7
  import { promisify } from "node:util";
8
8
  import path from "node:path";
9
9
  import process from "node:process";
@@ -478,6 +478,8 @@ export async function startServer(config, configPath) {
478
478
  repoUrl: PKG_REPO_URL,
479
479
  config: safeConfig,
480
480
  hasCert: existsSync(certPaths.keyPath) && existsSync(certPaths.certPath),
481
+ updateAvailable: cachedUpdateInfo?.updateAvailable ?? false,
482
+ latestVersion: cachedUpdateInfo?.latest ?? null,
481
483
  });
482
484
  });
483
485
  app.post("/api/settings/config", async (req, res) => {
@@ -885,6 +887,30 @@ export async function startServer(config, configPath) {
885
887
  structuredSessions.setEventEmitter((event) => {
886
888
  wsManager.emitEvent(event);
887
889
  });
890
+ // ── Restart endpoint (needs server + wss in scope) ──
891
+ app.post("/api/restart", async (_req, res) => {
892
+ res.json({ ok: true, message: "服务正在重启..." });
893
+ wsManager.emitEvent({
894
+ type: "notification",
895
+ sessionId: "__system__",
896
+ data: { kind: "restart" },
897
+ });
898
+ setTimeout(() => {
899
+ // Close all WebSocket connections first
900
+ wss.clients.forEach((client) => client.close());
901
+ server.close(() => {
902
+ spawn(process.execPath, process.argv.slice(1), {
903
+ detached: true,
904
+ stdio: "inherit",
905
+ cwd: process.cwd(),
906
+ env: process.env,
907
+ }).unref();
908
+ process.exit(0);
909
+ });
910
+ // Force exit after 5s if graceful shutdown stalls
911
+ setTimeout(() => process.exit(0), 5000);
912
+ }, 600);
913
+ });
888
914
  await new Promise((resolve, reject) => {
889
915
  server.listen(config.port, config.host, () => {
890
916
  const listenAddr = config.host === "0.0.0.0" ? "0.0.0.0 (所有接口)" : config.host;
package/dist/storage.d.ts CHANGED
@@ -37,5 +37,6 @@ export declare class WandStorage {
37
37
  getLatestSessionByClaudeSessionId(claudeSessionId: string): SessionSnapshot | null;
38
38
  loadSessions(): SessionSnapshot[];
39
39
  private mapSessionRow;
40
+ updateSessionWorktreeMergeState(id: string, status: SessionSnapshot["worktreeMergeStatus"], info: SessionSnapshot["worktreeMergeInfo"]): SessionSnapshot | null;
40
41
  deleteSession(id: string): void;
41
42
  }
package/dist/storage.js CHANGED
@@ -1,10 +1,9 @@
1
1
  import { existsSync, mkdirSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { DatabaseSync } from "node:sqlite";
4
- function parseStoredMessages(raw) {
5
- if (!raw) {
4
+ function safeJsonParse(raw) {
5
+ if (!raw)
6
6
  return undefined;
7
- }
8
7
  try {
9
8
  return JSON.parse(raw);
10
9
  }
@@ -13,27 +12,8 @@ function parseStoredMessages(raw) {
13
12
  }
14
13
  }
15
14
  function parseQueuedMessages(raw) {
16
- if (!raw) {
17
- return undefined;
18
- }
19
- try {
20
- const parsed = JSON.parse(raw);
21
- return Array.isArray(parsed) ? parsed.filter((item) => typeof item === "string") : undefined;
22
- }
23
- catch {
24
- return undefined;
25
- }
26
- }
27
- function parseStructuredState(raw) {
28
- if (!raw) {
29
- return undefined;
30
- }
31
- try {
32
- return JSON.parse(raw);
33
- }
34
- catch {
35
- return undefined;
36
- }
15
+ const parsed = safeJsonParse(raw);
16
+ return Array.isArray(parsed) ? parsed.filter((item) => typeof item === "string") : undefined;
37
17
  }
38
18
  function inferSessionProvider(row) {
39
19
  if (row.provider === "claude" || row.provider === "codex") {
@@ -45,23 +25,164 @@ function inferSessionProvider(row) {
45
25
  return /^claude\b/.test(row.command.trim()) ? "claude" : undefined;
46
26
  }
47
27
  function parseWorktreeInfo(raw) {
48
- if (!raw) {
49
- return undefined;
28
+ const parsed = safeJsonParse(raw);
29
+ if (parsed && typeof parsed.branch === "string" && typeof parsed.path === "string") {
30
+ return { branch: parsed.branch, path: parsed.path };
50
31
  }
51
- try {
52
- const parsed = JSON.parse(raw);
53
- if (parsed
54
- && typeof parsed === "object"
55
- && typeof parsed.branch === "string"
56
- && typeof parsed.path === "string") {
57
- return parsed;
58
- }
59
- }
60
- catch {
61
- return undefined;
32
+ return undefined;
33
+ }
34
+ function parseWorktreeMergeInfo(raw) {
35
+ return safeJsonParse(raw);
36
+ }
37
+ function serializeWorktreeMergeInfo(info) {
38
+ return info ? JSON.stringify(info) : null;
39
+ }
40
+ function serializeWorktreeInfo(info) {
41
+ return info ? JSON.stringify(info) : null;
42
+ }
43
+ function normalizeWorktreeMergeStatus(raw) {
44
+ if (raw === "ready" || raw === "checking" || raw === "merging" || raw === "merged" || raw === "failed") {
45
+ return raw;
62
46
  }
63
47
  return undefined;
64
48
  }
49
+ function mapWorktreeMergeFields(row) {
50
+ return {
51
+ worktreeMergeStatus: normalizeWorktreeMergeStatus(row.worktree_merge_status),
52
+ worktreeMergeInfo: parseWorktreeMergeInfo(row.worktree_merge_info) ?? null,
53
+ };
54
+ }
55
+ function sessionSelectFields() {
56
+ return `id, provider, session_kind, runner, command, cwd, mode, status, exit_code, started_at, ended_at, output, archived, archived_at, claude_session_id, messages, queued_messages, structured_state
57
+ , resumed_from_session_id, resumed_to_session_id, auto_recovered, worktree_enabled, worktree_info, worktree_merge_status, worktree_merge_info`;
58
+ }
59
+ function sessionPersistFields() {
60
+ return `id, command, cwd, mode, status, exit_code, started_at, ended_at, output
61
+ , archived, archived_at, claude_session_id, provider, session_kind, runner, messages, queued_messages, structured_state
62
+ , resumed_from_session_id, resumed_to_session_id, auto_recovered, worktree_enabled, worktree_info, worktree_merge_status, worktree_merge_info`;
63
+ }
64
+ function sessionPersistAssignments() {
65
+ return `command = excluded.command,
66
+ cwd = excluded.cwd,
67
+ mode = excluded.mode,
68
+ status = excluded.status,
69
+ exit_code = excluded.exit_code,
70
+ started_at = excluded.started_at,
71
+ ended_at = excluded.ended_at,
72
+ output = excluded.output,
73
+ archived = excluded.archived,
74
+ archived_at = excluded.archived_at,
75
+ claude_session_id = excluded.claude_session_id,
76
+ provider = excluded.provider,
77
+ session_kind = excluded.session_kind,
78
+ runner = excluded.runner,
79
+ messages = excluded.messages,
80
+ queued_messages = excluded.queued_messages,
81
+ structured_state = excluded.structured_state,
82
+ resumed_from_session_id = excluded.resumed_from_session_id,
83
+ resumed_to_session_id = excluded.resumed_to_session_id,
84
+ auto_recovered = excluded.auto_recovered,
85
+ worktree_enabled = excluded.worktree_enabled,
86
+ worktree_info = excluded.worktree_info,
87
+ worktree_merge_status = excluded.worktree_merge_status,
88
+ worktree_merge_info = excluded.worktree_merge_info`;
89
+ }
90
+ function sessionMetadataAssignments() {
91
+ return `command = ?, cwd = ?, mode = ?, status = ?, exit_code = ?,
92
+ started_at = ?, ended_at = ?, output = ?,
93
+ archived = ?, archived_at = ?, claude_session_id = ?,
94
+ provider = ?, session_kind = ?, runner = ?, structured_state = ?,
95
+ resumed_from_session_id = ?, resumed_to_session_id = ?, auto_recovered = ?,
96
+ worktree_enabled = ?, worktree_info = ?, worktree_merge_status = ?, worktree_merge_info = ?`;
97
+ }
98
+ function sessionPersistValues(snapshot) {
99
+ return [
100
+ snapshot.id,
101
+ snapshot.command,
102
+ snapshot.cwd,
103
+ snapshot.mode,
104
+ snapshot.status,
105
+ snapshot.exitCode,
106
+ snapshot.startedAt,
107
+ snapshot.endedAt,
108
+ snapshot.output,
109
+ snapshot.archived ? 1 : 0,
110
+ snapshot.archivedAt,
111
+ snapshot.claudeSessionId,
112
+ snapshot.provider ?? null,
113
+ snapshot.sessionKind ?? "pty",
114
+ snapshot.runner ?? null,
115
+ snapshot.messages ? JSON.stringify(snapshot.messages) : null,
116
+ snapshot.queuedMessages ? JSON.stringify(snapshot.queuedMessages) : null,
117
+ snapshot.structuredState ? JSON.stringify(snapshot.structuredState) : null,
118
+ snapshot.resumedFromSessionId ?? null,
119
+ snapshot.resumedToSessionId ?? null,
120
+ snapshot.autoRecovered ? 1 : 0,
121
+ snapshot.worktreeEnabled ? 1 : 0,
122
+ serializeWorktreeInfo(snapshot.worktree),
123
+ snapshot.worktreeMergeStatus ?? null,
124
+ serializeWorktreeMergeInfo(snapshot.worktreeMergeInfo),
125
+ ];
126
+ }
127
+ function sessionMetadataValues(snapshot) {
128
+ return [
129
+ snapshot.command,
130
+ snapshot.cwd,
131
+ snapshot.mode,
132
+ snapshot.status,
133
+ snapshot.exitCode,
134
+ snapshot.startedAt,
135
+ snapshot.endedAt,
136
+ snapshot.output,
137
+ snapshot.archived ? 1 : 0,
138
+ snapshot.archivedAt,
139
+ snapshot.claudeSessionId,
140
+ snapshot.provider ?? null,
141
+ snapshot.sessionKind ?? "pty",
142
+ snapshot.runner ?? null,
143
+ snapshot.structuredState ? JSON.stringify(snapshot.structuredState) : null,
144
+ snapshot.resumedFromSessionId ?? null,
145
+ snapshot.resumedToSessionId ?? null,
146
+ snapshot.autoRecovered ? 1 : 0,
147
+ snapshot.worktreeEnabled ? 1 : 0,
148
+ serializeWorktreeInfo(snapshot.worktree),
149
+ snapshot.worktreeMergeStatus ?? null,
150
+ serializeWorktreeMergeInfo(snapshot.worktreeMergeInfo),
151
+ snapshot.id,
152
+ ];
153
+ }
154
+ function mapSessionCore(row) {
155
+ const provider = inferSessionProvider(row);
156
+ return {
157
+ id: row.id,
158
+ sessionKind: row.session_kind ?? "pty",
159
+ provider,
160
+ runner: row.runner ?? undefined,
161
+ command: row.command,
162
+ cwd: row.cwd,
163
+ mode: row.mode,
164
+ status: row.status,
165
+ exitCode: row.exit_code,
166
+ startedAt: row.started_at,
167
+ endedAt: row.ended_at,
168
+ output: row.output,
169
+ archived: Boolean(row.archived),
170
+ archivedAt: row.archived_at,
171
+ claudeSessionId: row.claude_session_id,
172
+ messages: safeJsonParse(row.messages),
173
+ queuedMessages: parseQueuedMessages(row.queued_messages),
174
+ structuredState: safeJsonParse(row.structured_state),
175
+ resumedFromSessionId: row.resumed_from_session_id ?? undefined,
176
+ resumedToSessionId: row.resumed_to_session_id ?? undefined,
177
+ autoRecovered: Boolean(row.auto_recovered),
178
+ worktreeEnabled: Boolean(row.worktree_enabled),
179
+ worktree: parseWorktreeInfo(row.worktree_info) ?? null,
180
+ ...mapWorktreeMergeFields(row),
181
+ };
182
+ }
183
+ function sessionRowQuery(base) {
184
+ return `${base} ${sessionSelectFields()}`;
185
+ }
65
186
  export const DEFAULT_DB_FILE = "wand.db";
66
187
  export function resolveDatabasePath(configPath) {
67
188
  return path.resolve(path.dirname(configPath), DEFAULT_DB_FILE);
@@ -95,7 +216,9 @@ const INIT_SQL = `
95
216
  resumed_to_session_id TEXT,
96
217
  auto_recovered INTEGER NOT NULL DEFAULT 0,
97
218
  worktree_enabled INTEGER NOT NULL DEFAULT 0,
98
- worktree_info TEXT
219
+ worktree_info TEXT,
220
+ worktree_merge_status TEXT,
221
+ worktree_merge_info TEXT
99
222
  );
100
223
 
101
224
  CREATE TABLE IF NOT EXISTS app_config (
@@ -185,34 +308,11 @@ export class WandStorage {
185
308
  try {
186
309
  this.db
187
310
  .prepare(`INSERT INTO command_sessions (
188
- id, command, cwd, mode, status, exit_code, started_at, ended_at, output
189
- , archived, archived_at, claude_session_id, provider, session_kind, runner, messages, queued_messages, structured_state
190
- , resumed_from_session_id, resumed_to_session_id, auto_recovered, worktree_enabled, worktree_info
191
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
311
+ ${sessionPersistFields()}
312
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
192
313
  ON CONFLICT(id) DO UPDATE SET
193
- command = excluded.command,
194
- cwd = excluded.cwd,
195
- mode = excluded.mode,
196
- status = excluded.status,
197
- exit_code = excluded.exit_code,
198
- started_at = excluded.started_at,
199
- ended_at = excluded.ended_at,
200
- output = excluded.output,
201
- archived = excluded.archived,
202
- archived_at = excluded.archived_at,
203
- claude_session_id = excluded.claude_session_id,
204
- provider = excluded.provider,
205
- session_kind = excluded.session_kind,
206
- runner = excluded.runner,
207
- messages = excluded.messages,
208
- queued_messages = excluded.queued_messages,
209
- structured_state = excluded.structured_state,
210
- resumed_from_session_id = excluded.resumed_from_session_id,
211
- resumed_to_session_id = excluded.resumed_to_session_id,
212
- auto_recovered = excluded.auto_recovered,
213
- worktree_enabled = excluded.worktree_enabled,
214
- worktree_info = excluded.worktree_info`)
215
- .run(snapshot.id, snapshot.command, snapshot.cwd, snapshot.mode, snapshot.status, snapshot.exitCode, snapshot.startedAt, snapshot.endedAt, snapshot.output, snapshot.archived ? 1 : 0, snapshot.archivedAt, snapshot.claudeSessionId, snapshot.provider ?? null, snapshot.sessionKind ?? "pty", snapshot.runner ?? null, snapshot.messages ? JSON.stringify(snapshot.messages) : null, snapshot.queuedMessages ? JSON.stringify(snapshot.queuedMessages) : null, snapshot.structuredState ? JSON.stringify(snapshot.structuredState) : null, snapshot.resumedFromSessionId ?? null, snapshot.resumedToSessionId ?? null, snapshot.autoRecovered ? 1 : 0, snapshot.worktreeEnabled ? 1 : 0, snapshot.worktree ? JSON.stringify(snapshot.worktree) : null);
314
+ ${sessionPersistAssignments()}`)
315
+ .run(...sessionPersistValues(snapshot));
216
316
  this.db.exec("COMMIT");
217
317
  }
218
318
  catch (error) {
@@ -228,19 +328,13 @@ export class WandStorage {
228
328
  saveSessionMetadata(snapshot) {
229
329
  this.db
230
330
  .prepare(`UPDATE command_sessions SET
231
- command = ?, cwd = ?, mode = ?, status = ?, exit_code = ?,
232
- started_at = ?, ended_at = ?, output = ?,
233
- archived = ?, archived_at = ?, claude_session_id = ?,
234
- provider = ?, session_kind = ?, runner = ?, structured_state = ?,
235
- resumed_from_session_id = ?, resumed_to_session_id = ?, auto_recovered = ?,
236
- worktree_enabled = ?, worktree_info = ?
331
+ ${sessionMetadataAssignments()}
237
332
  WHERE id = ?`)
238
- .run(snapshot.command, snapshot.cwd, snapshot.mode, snapshot.status, snapshot.exitCode, snapshot.startedAt, snapshot.endedAt, snapshot.output, snapshot.archived ? 1 : 0, snapshot.archivedAt, snapshot.claudeSessionId, snapshot.provider ?? null, snapshot.sessionKind ?? "pty", snapshot.runner ?? null, snapshot.structuredState ? JSON.stringify(snapshot.structuredState) : null, snapshot.resumedFromSessionId ?? null, snapshot.resumedToSessionId ?? null, snapshot.autoRecovered ? 1 : 0, snapshot.worktreeEnabled ? 1 : 0, snapshot.worktree ? JSON.stringify(snapshot.worktree) : null, snapshot.id);
333
+ .run(...sessionMetadataValues(snapshot));
239
334
  }
240
335
  getSession(id) {
241
336
  const row = this.db
242
- .prepare(`SELECT id, provider, session_kind, runner, command, cwd, mode, status, exit_code, started_at, ended_at, output, archived, archived_at, claude_session_id, messages, queued_messages, structured_state
243
- , resumed_from_session_id, resumed_to_session_id, auto_recovered, worktree_enabled, worktree_info
337
+ .prepare(`${sessionRowQuery("SELECT")}
244
338
  FROM command_sessions
245
339
  WHERE id = ?`)
246
340
  .get(id);
@@ -248,8 +342,7 @@ export class WandStorage {
248
342
  }
249
343
  getLatestSessionByClaudeSessionId(claudeSessionId) {
250
344
  const row = this.db
251
- .prepare(`SELECT id, provider, session_kind, runner, command, cwd, mode, status, exit_code, started_at, ended_at, output, archived, archived_at, claude_session_id, messages, queued_messages, structured_state
252
- , resumed_from_session_id, resumed_to_session_id, auto_recovered, worktree_enabled, worktree_info
345
+ .prepare(`${sessionRowQuery("SELECT")}
253
346
  FROM command_sessions
254
347
  WHERE claude_session_id = ?
255
348
  ORDER BY started_at DESC
@@ -259,85 +352,56 @@ export class WandStorage {
259
352
  }
260
353
  loadSessions() {
261
354
  const rows = this.db
262
- .prepare(`SELECT id, provider, session_kind, runner, command, cwd, mode, status, exit_code, started_at, ended_at, output, archived, archived_at, claude_session_id, messages, queued_messages, structured_state
263
- , resumed_from_session_id, resumed_to_session_id, auto_recovered, worktree_enabled, worktree_info
355
+ .prepare(`${sessionRowQuery("SELECT")}
264
356
  FROM command_sessions
265
357
  ORDER BY started_at DESC`)
266
358
  .all();
267
359
  return rows.map((row) => this.mapSessionRow(row));
268
360
  }
269
361
  mapSessionRow(row) {
270
- const provider = inferSessionProvider(row);
271
- return {
272
- id: row.id,
273
- sessionKind: row.session_kind ?? "pty",
274
- provider,
275
- runner: row.runner ?? undefined,
276
- command: row.command,
277
- cwd: row.cwd,
278
- mode: row.mode,
279
- status: row.status,
280
- exitCode: row.exit_code,
281
- startedAt: row.started_at,
282
- endedAt: row.ended_at,
283
- output: row.output,
284
- archived: Boolean(row.archived),
285
- archivedAt: row.archived_at,
286
- claudeSessionId: row.claude_session_id,
287
- messages: parseStoredMessages(row.messages),
288
- queuedMessages: parseQueuedMessages(row.queued_messages),
289
- structuredState: parseStructuredState(row.structured_state),
290
- resumedFromSessionId: row.resumed_from_session_id ?? undefined,
291
- resumedToSessionId: row.resumed_to_session_id ?? undefined,
292
- autoRecovered: Boolean(row.auto_recovered),
293
- worktreeEnabled: Boolean(row.worktree_enabled),
294
- worktree: parseWorktreeInfo(row.worktree_info) ?? null
362
+ return mapSessionCore(row);
363
+ }
364
+ updateSessionWorktreeMergeState(id, status, info) {
365
+ const current = this.getSession(id);
366
+ if (!current) {
367
+ return null;
368
+ }
369
+ const updated = {
370
+ ...current,
371
+ worktreeMergeStatus: status,
372
+ worktreeMergeInfo: info,
295
373
  };
374
+ this.saveSessionMetadata(updated);
375
+ return updated;
296
376
  }
297
377
  deleteSession(id) {
298
378
  this.db.prepare("DELETE FROM command_sessions WHERE id = ?").run(id);
299
379
  }
300
380
  }
381
+ const SCHEMA_MIGRATIONS = [
382
+ ["archived", "ALTER TABLE command_sessions ADD COLUMN archived INTEGER NOT NULL DEFAULT 0"],
383
+ ["archived_at", "ALTER TABLE command_sessions ADD COLUMN archived_at TEXT"],
384
+ ["claude_session_id", "ALTER TABLE command_sessions ADD COLUMN claude_session_id TEXT"],
385
+ ["provider", "ALTER TABLE command_sessions ADD COLUMN provider TEXT"],
386
+ ["session_kind", "ALTER TABLE command_sessions ADD COLUMN session_kind TEXT NOT NULL DEFAULT 'pty'"],
387
+ ["runner", "ALTER TABLE command_sessions ADD COLUMN runner TEXT"],
388
+ ["messages", "ALTER TABLE command_sessions ADD COLUMN messages TEXT"],
389
+ ["queued_messages", "ALTER TABLE command_sessions ADD COLUMN queued_messages TEXT"],
390
+ ["structured_state", "ALTER TABLE command_sessions ADD COLUMN structured_state TEXT"],
391
+ ["resumed_from_session_id", "ALTER TABLE command_sessions ADD COLUMN resumed_from_session_id TEXT"],
392
+ ["resumed_to_session_id", "ALTER TABLE command_sessions ADD COLUMN resumed_to_session_id TEXT"],
393
+ ["auto_recovered", "ALTER TABLE command_sessions ADD COLUMN auto_recovered INTEGER NOT NULL DEFAULT 0"],
394
+ ["worktree_enabled", "ALTER TABLE command_sessions ADD COLUMN worktree_enabled INTEGER NOT NULL DEFAULT 0"],
395
+ ["worktree_info", "ALTER TABLE command_sessions ADD COLUMN worktree_info TEXT"],
396
+ ["worktree_merge_status", "ALTER TABLE command_sessions ADD COLUMN worktree_merge_status TEXT"],
397
+ ["worktree_merge_info", "ALTER TABLE command_sessions ADD COLUMN worktree_merge_info TEXT"],
398
+ ];
301
399
  function ensureCommandSessionSchema(db) {
302
400
  const columns = db.prepare("PRAGMA table_info(command_sessions)").all();
303
401
  const names = new Set(columns.map((column) => column.name));
304
- if (!names.has("archived")) {
305
- db.exec("ALTER TABLE command_sessions ADD COLUMN archived INTEGER NOT NULL DEFAULT 0");
306
- }
307
- if (!names.has("archived_at")) {
308
- db.exec("ALTER TABLE command_sessions ADD COLUMN archived_at TEXT");
309
- }
310
- if (!names.has("claude_session_id")) {
311
- db.exec("ALTER TABLE command_sessions ADD COLUMN claude_session_id TEXT");
312
- }
313
- if (!names.has("provider")) {
314
- db.exec("ALTER TABLE command_sessions ADD COLUMN provider TEXT");
315
- }
316
- if (!names.has("session_kind")) {
317
- db.exec("ALTER TABLE command_sessions ADD COLUMN session_kind TEXT NOT NULL DEFAULT 'pty'");
318
- }
319
- if (!names.has("runner")) {
320
- db.exec("ALTER TABLE command_sessions ADD COLUMN runner TEXT");
321
- }
322
- if (!names.has("messages")) {
323
- db.exec("ALTER TABLE command_sessions ADD COLUMN messages TEXT");
324
- }
325
- if (!names.has("queued_messages")) {
326
- db.exec("ALTER TABLE command_sessions ADD COLUMN queued_messages TEXT");
327
- }
328
- if (!names.has("structured_state")) {
329
- db.exec("ALTER TABLE command_sessions ADD COLUMN structured_state TEXT");
330
- }
331
- if (!names.has("resumed_from_session_id")) {
332
- db.exec("ALTER TABLE command_sessions ADD COLUMN resumed_from_session_id TEXT");
333
- }
334
- if (!names.has("resumed_to_session_id")) {
335
- db.exec("ALTER TABLE command_sessions ADD COLUMN resumed_to_session_id TEXT");
336
- }
337
- if (!names.has("worktree_enabled")) {
338
- db.exec("ALTER TABLE command_sessions ADD COLUMN worktree_enabled INTEGER NOT NULL DEFAULT 0");
339
- }
340
- if (!names.has("worktree_info")) {
341
- db.exec("ALTER TABLE command_sessions ADD COLUMN worktree_info TEXT");
402
+ for (const [column, sql] of SCHEMA_MIGRATIONS) {
403
+ if (!names.has(column)) {
404
+ db.exec(sql);
405
+ }
342
406
  }
343
407
  }