@co0ontty/wand 1.6.2 → 1.9.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);
@@ -78,13 +139,18 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
78
139
  });
79
140
  app.post("/api/structured-sessions", express.json(), async (req, res) => {
80
141
  const body = req.body;
81
- console.log("[WAND] POST /api/structured-sessions body:", JSON.stringify({ cwd: body.cwd, mode: body.mode, runner: body.runner, hasPrompt: !!body.prompt }));
142
+ console.log("[WAND] POST /api/structured-sessions body:", JSON.stringify({ cwd: body.cwd, mode: body.mode, runner: body.runner, provider: body.provider, worktreeEnabled: body.worktreeEnabled === true, hasPrompt: !!body.prompt }));
82
143
  try {
144
+ if (body.provider && body.provider !== "claude") {
145
+ res.status(400).json({ error: "结构化会话当前仅支持 Claude provider。" });
146
+ return;
147
+ }
83
148
  const snapshot = structured.createSession({
84
149
  cwd: body.cwd?.trim() || process.cwd(),
85
150
  mode: normalizeMode(body.mode, defaultMode),
86
151
  prompt: body.prompt,
87
152
  runner: body.runner ?? "claude-cli-print",
153
+ worktreeEnabled: body.worktreeEnabled === true,
88
154
  });
89
155
  console.log("[WAND] structured session created:", JSON.stringify({ id: snapshot.id, sessionKind: snapshot.sessionKind, runner: snapshot.runner, status: snapshot.status }));
90
156
  res.status(201).json(snapshot);
@@ -112,6 +178,98 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
112
178
  res.status(400).json({ error: getErrorMessage(error, "无法发送结构化消息。") });
113
179
  }
114
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
+ });
115
273
  app.post("/api/sessions/batch-delete", express.json(), (req, res) => {
116
274
  const sessionIds = Array.isArray(req.body?.sessionIds)
117
275
  ? req.body.sessionIds.filter((value) => typeof value === "string" && value.trim().length > 0)
@@ -148,17 +306,21 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
148
306
  res.status(404).json({ error: "未找到该会话,可能已被删除。" });
149
307
  return;
150
308
  }
309
+ const transcriptOutput = (snapshot.sessionKind ?? "pty") === "pty"
310
+ ? processes.getPtyTranscript(snapshot.id) ?? snapshot.output
311
+ : snapshot.output;
151
312
  if (req.query.format === "chat") {
152
313
  const allowFallback = (snapshot.sessionKind ?? "pty") === "pty";
314
+ const fallbackOutput = allowFallback ? transcriptOutput : "";
153
315
  const messages = snapshot.messages && snapshot.messages.length > 0
154
316
  ? snapshot.messages
155
317
  : allowFallback
156
- ? parseMessages(snapshot.output)
318
+ ? parseMessages(fallbackOutput, snapshot.command)
157
319
  : [];
158
- res.json({ ...snapshot, messages });
320
+ res.json({ ...snapshot, output: transcriptOutput, messages });
159
321
  }
160
322
  else {
161
- res.json(snapshot);
323
+ res.json({ ...snapshot, output: transcriptOutput });
162
324
  }
163
325
  });
164
326
  app.post("/api/sessions/:id/resume", (req, res) => {
@@ -176,6 +338,10 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
176
338
  res.status(400).json({ error: "结构化会话不支持 Claude CLI resume。" });
177
339
  return;
178
340
  }
341
+ if (existingSession.provider && existingSession.provider !== "claude") {
342
+ res.status(400).json({ error: "只有 Claude provider 支持恢复功能。" });
343
+ return;
344
+ }
179
345
  const claudeSessionId = existingSession.claudeSessionId;
180
346
  if (!claudeSessionId) {
181
347
  res.status(400).json({ error: "此会话没有 Claude 会话 ID,无法恢复。" });
@@ -207,6 +373,10 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
207
373
  }
208
374
  const existingSession = storage.getLatestSessionByClaudeSessionId(claudeSessionId);
209
375
  if (existingSession) {
376
+ if (existingSession.provider && existingSession.provider !== "claude") {
377
+ res.status(400).json({ error: "只有 Claude provider 支持按 Claude Session ID 恢复。" });
378
+ return;
379
+ }
210
380
  const command = existingSession.command.trim();
211
381
  if ((existingSession.sessionKind ?? "pty") !== "pty") {
212
382
  res.status(400).json({ error: "结构化会话不支持按 Claude Session ID 恢复。" });
@@ -284,7 +454,12 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
284
454
  app.post("/api/sessions/:id/approve-permission", (req, res) => {
285
455
  try {
286
456
  if (structured.get(req.params.id)) {
287
- res.json(structured.approvePermission(req.params.id));
457
+ res.status(400).json({ error: "结构化会话不需要终端权限操作。" });
458
+ return;
459
+ }
460
+ const snapshot = processes.get(req.params.id);
461
+ if (snapshot?.provider === "codex") {
462
+ res.status(400).json({ error: "Codex provider 不支持权限批准操作。" });
288
463
  return;
289
464
  }
290
465
  res.json(processes.approvePermission(req.params.id));
@@ -296,7 +471,12 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
296
471
  app.post("/api/sessions/:id/deny-permission", (req, res) => {
297
472
  try {
298
473
  if (structured.get(req.params.id)) {
299
- res.json(structured.denyPermission(req.params.id));
474
+ res.status(400).json({ error: "结构化会话不需要终端权限操作。" });
475
+ return;
476
+ }
477
+ const snapshot = processes.get(req.params.id);
478
+ if (snapshot?.provider === "codex") {
479
+ res.status(400).json({ error: "Codex provider 不支持权限拒绝操作。" });
300
480
  return;
301
481
  }
302
482
  res.json(processes.denyPermission(req.params.id));
@@ -308,7 +488,12 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
308
488
  app.post("/api/sessions/:id/toggle-auto-approve", (req, res) => {
309
489
  try {
310
490
  if (structured.get(req.params.id)) {
311
- res.json(structured.toggleAutoApprove(req.params.id));
491
+ res.status(400).json({ error: "结构化会话不需要切换终端自动批准。" });
492
+ return;
493
+ }
494
+ const snapshot = processes.get(req.params.id);
495
+ if (snapshot?.provider === "codex") {
496
+ res.status(400).json({ error: "Codex provider 不支持自动批准切换。" });
312
497
  return;
313
498
  }
314
499
  res.json(processes.toggleAutoApprove(req.params.id));
package/dist/server.js CHANGED
@@ -519,6 +519,40 @@ export async function startServer(config, configPath) {
519
519
  changed = true;
520
520
  }
521
521
  }
522
+ if (body.uiPreferences && typeof body.uiPreferences === "object") {
523
+ const defaultPanelStateInput = body.uiPreferences.defaultPanelState;
524
+ if (defaultPanelStateInput && typeof defaultPanelStateInput === "object") {
525
+ const nextDefaultPanelState = {
526
+ ...config.uiPreferences?.defaultPanelState,
527
+ };
528
+ let panelChanged = false;
529
+ const panelFields = [
530
+ "sessionsDrawerOpen",
531
+ "filePanelOpen",
532
+ "shortcutsExpanded",
533
+ "claudeHistoryExpanded",
534
+ "chatMessageExpanded",
535
+ "structuredThinkingExpanded",
536
+ "structuredToolGroupExpanded",
537
+ "structuredInlineToolExpanded",
538
+ "structuredTerminalExpanded",
539
+ "structuredToolCardExpanded"
540
+ ];
541
+ for (const field of panelFields) {
542
+ if (field in defaultPanelStateInput && typeof defaultPanelStateInput[field] === "boolean") {
543
+ nextDefaultPanelState[field] = defaultPanelStateInput[field];
544
+ panelChanged = true;
545
+ }
546
+ }
547
+ if (panelChanged) {
548
+ config.uiPreferences = {
549
+ ...config.uiPreferences,
550
+ defaultPanelState: nextDefaultPanelState,
551
+ };
552
+ changed = true;
553
+ }
554
+ }
555
+ }
522
556
  if (!changed) {
523
557
  res.status(400).json({ error: "没有可更新的配置字段。" });
524
558
  return;
@@ -858,7 +892,10 @@ export async function startServer(config, configPath) {
858
892
  }
859
893
  const initialInput = body.initialInput?.trim();
860
894
  try {
861
- const snapshot = processes.start(body.command, body.cwd, normalizeMode(body.mode, config.defaultMode), initialInput || undefined);
895
+ const snapshot = processes.start(body.command, body.cwd, normalizeMode(body.mode, config.defaultMode), initialInput || undefined, {
896
+ worktreeEnabled: body.worktreeEnabled === true,
897
+ provider: body.provider,
898
+ });
862
899
  res.status(201).json(snapshot);
863
900
  }
864
901
  catch (error) {
@@ -44,6 +44,8 @@ export declare class SessionLogger {
44
44
  private rotatePtyLog;
45
45
  /** Append raw PTY output chunk */
46
46
  appendPtyOutput(sessionId: string, chunk: string): void;
47
+ /** Read the full PTY transcript including rotated logs, oldest first. */
48
+ readPtyOutput(sessionId: string): string | null;
47
49
  /** Append a native mode NDJSON event */
48
50
  appendStreamEvent(sessionId: string, event: unknown): void;
49
51
  /** Save the current structured messages snapshot */
@@ -89,6 +89,29 @@ export class SessionLogger {
89
89
  // Non-critical — don't let logging failures affect main flow
90
90
  }
91
91
  }
92
+ /** Read the full PTY transcript including rotated logs, oldest first. */
93
+ readPtyOutput(sessionId) {
94
+ try {
95
+ const dir = this.ensureDir(sessionId);
96
+ const parts = [];
97
+ for (let index = PTY_LOG_MAX_ROTATIONS; index >= 1; index -= 1) {
98
+ const rotatedPath = path.join(dir, `pty-output.log.${index}`);
99
+ if (existsSync(rotatedPath)) {
100
+ parts.push(readFileSync(rotatedPath, "utf8"));
101
+ }
102
+ }
103
+ const currentPath = path.join(dir, "pty-output.log");
104
+ if (existsSync(currentPath)) {
105
+ parts.push(readFileSync(currentPath, "utf8"));
106
+ }
107
+ if (parts.length === 0)
108
+ return null;
109
+ return parts.join("");
110
+ }
111
+ catch {
112
+ return null;
113
+ }
114
+ }
92
115
  /** Append a native mode NDJSON event */
93
116
  appendStreamEvent(sessionId, event) {
94
117
  try {
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
  }