@co0ontty/wand 1.3.6 → 1.4.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.
package/dist/auth.js CHANGED
@@ -3,9 +3,10 @@ const sessions = new Map();
3
3
  const SESSION_TTL_MS = 1000 * 60 * 60 * 12;
4
4
  let storage = null;
5
5
  // Periodic cleanup every 10 minutes
6
- setInterval(() => {
6
+ const sessionCleanupTimer = setInterval(() => {
7
7
  cleanupExpiredSessions();
8
8
  }, 1000 * 60 * 10);
9
+ sessionCleanupTimer.unref();
9
10
  export function createSession() {
10
11
  const token = crypto.randomBytes(24).toString("hex");
11
12
  const expiresAt = Date.now() + SESSION_TTL_MS;
@@ -33,6 +33,8 @@ interface PermissionState {
33
33
  } | null;
34
34
  /** Output length snapshot taken right before fallback auto-approve fires */
35
35
  fallbackOutputLenAtApprove: number;
36
+ /** Consecutive auto-approve attempts for the same prompt without resolution */
37
+ retryCount: number;
36
38
  }
37
39
  /** Permission resolution result */
38
40
  export type PermissionResolution = "approve_once" | "approve_turn" | "deny";
@@ -113,6 +115,10 @@ export declare class ClaudePtyBridge extends EventEmitter {
113
115
  * Set the PTY write function for sending approval input.
114
116
  */
115
117
  setPtyWrite(fn: (input: string) => void): void;
118
+ /**
119
+ * Toggle auto-approve at runtime.
120
+ */
121
+ setAutoApprove(enabled: boolean): void;
116
122
  /**
117
123
  * Resolve the current permission prompt.
118
124
  * @param resolution - How to resolve the permission
@@ -12,7 +12,7 @@ import { stripAnsi, isNoiseLine, appendWindow, normalizePromptText, hasExplicitC
12
12
  const OUTPUT_MAX_SIZE = 120000;
13
13
  const SESSION_ID_WINDOW_SIZE = 16384;
14
14
  const PERMISSION_WINDOW_SIZE = 2000;
15
- const AUTO_APPROVE_DELAY_MS = 150;
15
+ const AUTO_APPROVE_DELAY_MS = 350;
16
16
  /** How long to monitor output after fallback auto-approve for false-positive detection */
17
17
  const FALLBACK_VERIFY_WINDOW_MS = 600;
18
18
  /** How long a session must be idle (no user input, no new output) before sending a probe */
@@ -101,6 +101,7 @@ export class ClaudePtyBridge extends EventEmitter {
101
101
  fallbackVerifyUntil: 0,
102
102
  fallbackContext: null,
103
103
  fallbackOutputLenAtApprove: 0,
104
+ retryCount: 0,
104
105
  };
105
106
  this.sessionIdWindow = "";
106
107
  this.lastOutputAt = 0;
@@ -240,6 +241,24 @@ export class ClaudePtyBridge extends EventEmitter {
240
241
  setPtyWrite(fn) {
241
242
  this.ptyWrite = fn;
242
243
  }
244
+ /**
245
+ * Toggle auto-approve at runtime.
246
+ */
247
+ setAutoApprove(enabled) {
248
+ this.autoApprove = enabled;
249
+ if (!enabled) {
250
+ // Cancel any pending auto-approve timer
251
+ if (this.permissionState.pendingAutoApproveTimer) {
252
+ clearTimeout(this.permissionState.pendingAutoApproveTimer);
253
+ this.permissionState.pendingAutoApproveTimer = null;
254
+ }
255
+ // Cancel idle probe
256
+ if (this.idleProbeTimer) {
257
+ clearTimeout(this.idleProbeTimer);
258
+ this.idleProbeTimer = null;
259
+ }
260
+ }
261
+ }
243
262
  // ── Permission Resolution ──
244
263
  /**
245
264
  * Resolve the current permission prompt.
@@ -518,6 +537,24 @@ export class ClaudePtyBridge extends EventEmitter {
518
537
  timestamp: Date.now(),
519
538
  data: { resolution: "approve_once", autoApproved: true, approveType: "strict" },
520
539
  });
540
+ // Schedule a retry check: if the prompt re-appears shortly after,
541
+ // the \r may have arrived before the CLI was ready. Retry with a
542
+ // longer delay to handle slow-rendering selection menus.
543
+ if (this.permissionState.retryCount < 3) {
544
+ const retryDelay = 800 + this.permissionState.retryCount * 400;
545
+ setTimeout(() => {
546
+ if (this._exited)
547
+ return;
548
+ if (this.permissionState.isBlocked && this.autoApprove) {
549
+ this.permissionState.retryCount++;
550
+ this.permissionState.lastAutoConfirmAt = 0; // allow immediate retry
551
+ this.scheduleAutoApprove(scope, target);
552
+ }
553
+ else {
554
+ this.permissionState.retryCount = 0;
555
+ }
556
+ }, retryDelay);
557
+ }
521
558
  }, AUTO_APPROVE_DELAY_MS);
522
559
  }
523
560
  /**
package/dist/cli.js CHANGED
@@ -1,8 +1,6 @@
1
1
  #!/usr/bin/env -S node --disable-warning=ExperimentalWarning
2
2
  import process from "node:process";
3
3
  import { ensureConfig, hasConfigFile, isExecutionMode, resolveConfigPath, saveConfig } from "./config.js";
4
- import { startServer } from "./server.js";
5
- import { ensureDatabaseFile, resolveDatabasePath } from "./storage.js";
6
4
  async function main() {
7
5
  const args = process.argv.slice(2);
8
6
  const command = args[0] || "help";
@@ -14,6 +12,7 @@ async function main() {
14
12
  }
15
13
  case "web": {
16
14
  const config = await ensureRequiredFiles(configPath);
15
+ const { startServer } = await import("./server.js");
17
16
  await startServer(config, configPath);
18
17
  break;
19
18
  }
@@ -67,6 +66,7 @@ Options:
67
66
  `);
68
67
  }
69
68
  async function ensureRequiredFiles(configPath) {
69
+ const { ensureDatabaseFile, resolveDatabasePath } = await import("./storage.js");
70
70
  const dbPath = resolveDatabasePath(configPath);
71
71
  const hadConfig = hasConfigFile(configPath);
72
72
  const config = await ensureConfig(configPath);
@@ -34,4 +34,5 @@ export function cleanupRateLimiter() {
34
34
  }
35
35
  }
36
36
  // Cleanup expired entries every 5 minutes
37
- setInterval(cleanupRateLimiter, 5 * 60 * 1000);
37
+ const rateLimitCleanupTimer = setInterval(cleanupRateLimiter, 5 * 60 * 1000);
38
+ rateLimitCleanupTimer.unref();
@@ -71,6 +71,7 @@ export declare class ProcessManager extends EventEmitter {
71
71
  resolveEscalation(id: string, requestId: string, resolution?: "approve_once" | "approve_turn" | "deny"): SessionSnapshot;
72
72
  approvePermission(id: string): SessionSnapshot;
73
73
  denyPermission(id: string): SessionSnapshot;
74
+ toggleAutoApprove(id: string): SessionSnapshot;
74
75
  /**
75
76
  * Canonical permission resolution method.
76
77
  * All other permission methods delegate to this.
@@ -423,6 +423,9 @@ export class ProcessManager extends EventEmitter {
423
423
  },
424
424
  });
425
425
  for (const snapshot of this.storage.loadSessions()) {
426
+ if ((snapshot.sessionKind ?? "pty") !== "pty") {
427
+ continue;
428
+ }
426
429
  const isClaudeCmd = /^claude\b/.test(snapshot.command.trim());
427
430
  const resumeCommandSessionId = getResumeCommandSessionId(snapshot.command);
428
431
  // Sessions restored from storage have ptyProcess: null — the old server's PTY
@@ -1075,6 +1078,8 @@ export class ProcessManager extends EventEmitter {
1075
1078
  const messages = record.ptyBridge?.getMessages() ?? record.messages;
1076
1079
  return {
1077
1080
  id: record.id,
1081
+ sessionKind: "pty",
1082
+ runner: "pty",
1078
1083
  command: record.command,
1079
1084
  cwd: record.cwd,
1080
1085
  mode: record.mode,
@@ -1096,6 +1101,7 @@ export class ProcessManager extends EventEmitter {
1096
1101
  resumedFromSessionId: record.resumedFromSessionId ?? undefined,
1097
1102
  resumedToSessionId: record.resumedToSessionId ?? undefined,
1098
1103
  autoRecovered: record.autoRecovered ?? false,
1104
+ autoApprovePermissions: record.autoApprovePermissions || undefined,
1099
1105
  approvalStats: record.approvalStats.total > 0 ? record.approvalStats : undefined
1100
1106
  };
1101
1107
  }
@@ -1117,6 +1123,15 @@ export class ProcessManager extends EventEmitter {
1117
1123
  denyPermission(id) {
1118
1124
  return this.resolvePermission(id, "deny");
1119
1125
  }
1126
+ toggleAutoApprove(id) {
1127
+ const record = this.mustGet(id);
1128
+ record.autoApprovePermissions = !record.autoApprovePermissions;
1129
+ if (record.ptyBridge) {
1130
+ record.ptyBridge.setAutoApprove(record.autoApprovePermissions);
1131
+ }
1132
+ this.persist(record);
1133
+ return this.snapshot(record);
1134
+ }
1120
1135
  /**
1121
1136
  * Canonical permission resolution method.
1122
1137
  * All other permission methods delegate to this.
@@ -1513,11 +1528,28 @@ export class ProcessManager extends EventEmitter {
1513
1528
  if (!isClaudeCmd)
1514
1529
  return command;
1515
1530
  let result = command;
1516
- // When running as root, Claude CLI refuses --permission-mode bypassPermissions.
1517
- // Use acceptEdits to let Claude auto-accept edits natively, reducing the number
1518
- // of permission prompts wand has to auto-approve.
1519
- if (isRunningAsRoot() && (mode === "managed" || mode === "full-access" || mode === "auto-edit")) {
1520
- result += " --permission-mode acceptEdits";
1531
+ // Skip if command already contains --permission-mode
1532
+ const hasPermFlag = /--permission-mode\s/.test(command);
1533
+ if (!hasPermFlag) {
1534
+ if (isRunningAsRoot()) {
1535
+ // Root: Claude CLI refuses --permission-mode bypassPermissions.
1536
+ // Use acceptEdits + --allowedTools to auto-approve all tool calls
1537
+ // regardless of whether the target path is inside or outside the CWD.
1538
+ if (mode === "managed" || mode === "full-access" || mode === "auto-edit") {
1539
+ result += " --permission-mode acceptEdits";
1540
+ result += " --allowedTools Bash Edit Write Read Glob Grep NotebookEdit WebFetch WebSearch";
1541
+ }
1542
+ }
1543
+ else {
1544
+ // Non-root: use bypassPermissions for full-access (skips all prompts),
1545
+ // acceptEdits for auto-edit (auto-accepts file writes, prompts for bash).
1546
+ if (mode === "full-access" || mode === "managed") {
1547
+ result += " --permission-mode bypassPermissions";
1548
+ }
1549
+ else if (mode === "auto-edit") {
1550
+ result += " --permission-mode acceptEdits";
1551
+ }
1552
+ }
1521
1553
  }
1522
1554
  // In managed mode, append a system prompt instructing Claude to act autonomously
1523
1555
  // without asking the user for confirmation, since the user may not be monitoring.
@@ -1,7 +1,8 @@
1
1
  import { Express } from "express";
2
2
  import { ProcessManager } from "./process-manager.js";
3
+ import { StructuredSessionManager } from "./structured-session-manager.js";
3
4
  import { WandStorage } from "./storage.js";
4
5
  import { ExecutionMode } from "./types.js";
5
6
  export declare function getErrorMessage(error: unknown, fallback: string): string;
6
- export declare function registerSessionRoutes(app: Express, processes: ProcessManager, storage: WandStorage, defaultMode: ExecutionMode): void;
7
+ export declare function registerSessionRoutes(app: Express, processes: ProcessManager, structured: StructuredSessionManager, storage: WandStorage, defaultMode: ExecutionMode): void;
7
8
  export declare function registerClaudeHistoryRoutes(app: Express, processes: ProcessManager, storage: WandStorage): void;
@@ -63,9 +63,54 @@ function removeFromHiddenClaudeSessionIds(storage, ids) {
63
63
  saveHiddenClaudeSessionIds(storage, hidden);
64
64
  }
65
65
  }
66
- export function registerSessionRoutes(app, processes, storage, defaultMode) {
66
+ function getSessionById(processes, structured, id) {
67
+ return structured.get(id) ?? processes.get(id);
68
+ }
69
+ function listAllSessions(processes, structured) {
70
+ return [...structured.list(), ...processes.list()]
71
+ .sort((a, b) => b.startedAt.localeCompare(a.startedAt));
72
+ }
73
+ export function registerSessionRoutes(app, processes, structured, storage, defaultMode) {
67
74
  app.get("/api/sessions", (_req, res) => {
68
- res.json(processes.list());
75
+ const all = listAllSessions(processes, structured);
76
+ console.log("[WAND] GET /api/sessions count:", all.length, "sessions:", all.map(s => ({ id: s.id.substring(0, 8), kind: s.sessionKind, runner: s.runner, status: s.status })));
77
+ res.json(all);
78
+ });
79
+ app.post("/api/structured-sessions", express.json(), async (req, res) => {
80
+ 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 }));
82
+ try {
83
+ const snapshot = structured.createSession({
84
+ cwd: body.cwd?.trim() || process.cwd(),
85
+ mode: normalizeMode(body.mode, defaultMode),
86
+ prompt: body.prompt,
87
+ runner: body.runner ?? "claude-cli-print",
88
+ });
89
+ console.log("[WAND] structured session created:", JSON.stringify({ id: snapshot.id, sessionKind: snapshot.sessionKind, runner: snapshot.runner, status: snapshot.status }));
90
+ res.status(201).json(snapshot);
91
+ }
92
+ catch (error) {
93
+ res.status(400).json({ error: getErrorMessage(error, "无法启动结构化会话。") });
94
+ }
95
+ });
96
+ app.get("/api/structured-sessions/:id/messages", (req, res) => {
97
+ const snapshot = structured.get(req.params.id);
98
+ if (!snapshot) {
99
+ res.status(404).json({ error: "未找到该结构化会话。" });
100
+ return;
101
+ }
102
+ res.json({ id: snapshot.id, messages: snapshot.messages ?? [] });
103
+ });
104
+ app.post("/api/structured-sessions/:id/messages", express.json(), async (req, res) => {
105
+ const input = String(req.body?.input ?? "");
106
+ console.log("[WAND] POST /api/structured-sessions/:id/messages id:", req.params.id, "input:", input.substring(0, 50));
107
+ try {
108
+ const snapshot = await structured.sendMessage(req.params.id, input);
109
+ res.json(snapshot);
110
+ }
111
+ catch (error) {
112
+ res.status(400).json({ error: getErrorMessage(error, "无法发送结构化消息。") });
113
+ }
69
114
  });
70
115
  app.post("/api/sessions/batch-delete", express.json(), (req, res) => {
71
116
  const sessionIds = Array.isArray(req.body?.sessionIds)
@@ -79,7 +124,12 @@ export function registerSessionRoutes(app, processes, storage, defaultMode) {
79
124
  const failed = [];
80
125
  for (const sessionId of sessionIds) {
81
126
  try {
82
- processes.delete(sessionId);
127
+ if (structured.get(sessionId)) {
128
+ structured.delete(sessionId);
129
+ }
130
+ else {
131
+ processes.delete(sessionId);
132
+ }
83
133
  deleted += 1;
84
134
  }
85
135
  catch {
@@ -93,15 +143,18 @@ export function registerSessionRoutes(app, processes, storage, defaultMode) {
93
143
  res.json({ ok: true, deleted, failed });
94
144
  });
95
145
  app.get("/api/sessions/:id", (req, res) => {
96
- const snapshot = processes.get(req.params.id);
146
+ const snapshot = getSessionById(processes, structured, req.params.id);
97
147
  if (!snapshot) {
98
148
  res.status(404).json({ error: "未找到该会话,可能已被删除。" });
99
149
  return;
100
150
  }
101
151
  if (req.query.format === "chat") {
152
+ const allowFallback = (snapshot.sessionKind ?? "pty") === "pty";
102
153
  const messages = snapshot.messages && snapshot.messages.length > 0
103
154
  ? snapshot.messages
104
- : parseMessages(snapshot.output);
155
+ : allowFallback
156
+ ? parseMessages(snapshot.output)
157
+ : [];
105
158
  res.json({ ...snapshot, messages });
106
159
  }
107
160
  else {
@@ -111,12 +164,18 @@ export function registerSessionRoutes(app, processes, storage, defaultMode) {
111
164
  app.post("/api/sessions/:id/resume", (req, res) => {
112
165
  const sessionId = req.params.id;
113
166
  const body = req.body;
167
+ console.log("[WAND] POST /api/sessions/:id/resume sessionId:", sessionId);
114
168
  try {
115
169
  const existingSession = processes.get(sessionId) || storage.getSession(sessionId);
170
+ console.log("[WAND] resume lookup: found:", !!existingSession, "sessionKind:", existingSession?.sessionKind, "claudeSessionId:", existingSession?.claudeSessionId);
116
171
  if (!existingSession) {
117
172
  res.status(404).json({ error: "会话不存在。" });
118
173
  return;
119
174
  }
175
+ if ((existingSession.sessionKind ?? "pty") !== "pty") {
176
+ res.status(400).json({ error: "结构化会话不支持 Claude CLI resume。" });
177
+ return;
178
+ }
120
179
  const claudeSessionId = existingSession.claudeSessionId;
121
180
  if (!claudeSessionId) {
122
181
  res.status(400).json({ error: "此会话没有 Claude 会话 ID,无法恢复。" });
@@ -140,6 +199,7 @@ export function registerSessionRoutes(app, processes, storage, defaultMode) {
140
199
  app.post("/api/claude-sessions/:claudeSessionId/resume", (req, res) => {
141
200
  const claudeSessionId = String(req.params.claudeSessionId || "").trim();
142
201
  const body = req.body;
202
+ console.log("[WAND] POST /api/claude-sessions/:claudeSessionId/resume claudeSessionId:", claudeSessionId, "cwd:", body.cwd);
143
203
  try {
144
204
  if (!claudeSessionId) {
145
205
  res.status(400).json({ error: "Claude 会话 ID 不能为空。" });
@@ -148,6 +208,10 @@ export function registerSessionRoutes(app, processes, storage, defaultMode) {
148
208
  const existingSession = storage.getLatestSessionByClaudeSessionId(claudeSessionId);
149
209
  if (existingSession) {
150
210
  const command = existingSession.command.trim();
211
+ if ((existingSession.sessionKind ?? "pty") !== "pty") {
212
+ res.status(400).json({ error: "结构化会话不支持按 Claude Session ID 恢复。" });
213
+ return;
214
+ }
151
215
  if (!/^claude\b/.test(command)) {
152
216
  res.status(400).json({ error: "只有 Claude 命令支持按 Claude Session ID 恢复。" });
153
217
  return;
@@ -178,13 +242,18 @@ export function registerSessionRoutes(app, processes, storage, defaultMode) {
178
242
  res.status(400).json({ error: getErrorMessage(error, "无法按 Claude 会话 ID 恢复会话。") });
179
243
  }
180
244
  });
181
- app.post("/api/sessions/:id/input", (req, res) => {
245
+ app.post("/api/sessions/:id/input", async (req, res) => {
182
246
  const body = req.body;
183
247
  const sessionId = req.params.id;
184
248
  const input = body.input ?? "";
185
249
  const view = body.view;
186
250
  const shortcutKey = body.shortcutKey;
187
251
  try {
252
+ if (structured.get(sessionId)) {
253
+ const snapshot = await structured.sendMessage(sessionId, input);
254
+ res.json(snapshot);
255
+ return;
256
+ }
188
257
  const snapshot = processes.sendInput(sessionId, input, view, shortcutKey);
189
258
  res.json(snapshot);
190
259
  }
@@ -201,6 +270,10 @@ export function registerSessionRoutes(app, processes, storage, defaultMode) {
201
270
  app.post("/api/sessions/:id/resize", (req, res) => {
202
271
  const body = req.body;
203
272
  try {
273
+ if (structured.get(req.params.id)) {
274
+ res.status(400).json({ error: "结构化会话不支持调整终端大小。" });
275
+ return;
276
+ }
204
277
  const snapshot = processes.resize(req.params.id, body.cols ?? 0, body.rows ?? 0);
205
278
  res.json(snapshot);
206
279
  }
@@ -210,6 +283,10 @@ export function registerSessionRoutes(app, processes, storage, defaultMode) {
210
283
  });
211
284
  app.post("/api/sessions/:id/approve-permission", (req, res) => {
212
285
  try {
286
+ if (structured.get(req.params.id)) {
287
+ res.json(structured.approvePermission(req.params.id));
288
+ return;
289
+ }
213
290
  res.json(processes.approvePermission(req.params.id));
214
291
  }
215
292
  catch (error) {
@@ -218,16 +295,36 @@ export function registerSessionRoutes(app, processes, storage, defaultMode) {
218
295
  });
219
296
  app.post("/api/sessions/:id/deny-permission", (req, res) => {
220
297
  try {
298
+ if (structured.get(req.params.id)) {
299
+ res.json(structured.denyPermission(req.params.id));
300
+ return;
301
+ }
221
302
  res.json(processes.denyPermission(req.params.id));
222
303
  }
223
304
  catch (error) {
224
305
  res.status(400).json({ error: getErrorMessage(error, "无法拒绝该授权请求。") });
225
306
  }
226
307
  });
308
+ app.post("/api/sessions/:id/toggle-auto-approve", (req, res) => {
309
+ try {
310
+ if (structured.get(req.params.id)) {
311
+ res.json(structured.toggleAutoApprove(req.params.id));
312
+ return;
313
+ }
314
+ res.json(processes.toggleAutoApprove(req.params.id));
315
+ }
316
+ catch (error) {
317
+ res.status(400).json({ error: getErrorMessage(error, "无法切换自动批准状态。") });
318
+ }
319
+ });
227
320
  app.post("/api/sessions/:id/escalations/:requestId/resolve", (req, res) => {
228
321
  try {
229
322
  const { requestId } = req.params;
230
323
  const body = req.body;
324
+ if (structured.get(req.params.id)) {
325
+ res.json(structured.resolveEscalation(req.params.id, requestId, body.resolution));
326
+ return;
327
+ }
231
328
  res.json(processes.resolveEscalation(req.params.id, requestId, body.resolution));
232
329
  }
233
330
  catch (error) {
@@ -236,6 +333,10 @@ export function registerSessionRoutes(app, processes, storage, defaultMode) {
236
333
  });
237
334
  app.post("/api/sessions/:id/stop", (req, res) => {
238
335
  try {
336
+ if (structured.get(req.params.id)) {
337
+ res.json(structured.stop(req.params.id));
338
+ return;
339
+ }
239
340
  res.json(processes.stop(req.params.id));
240
341
  }
241
342
  catch (error) {
@@ -244,7 +345,12 @@ export function registerSessionRoutes(app, processes, storage, defaultMode) {
244
345
  });
245
346
  app.delete("/api/sessions/:id", (req, res) => {
246
347
  try {
247
- processes.delete(req.params.id);
348
+ if (structured.get(req.params.id)) {
349
+ structured.delete(req.params.id);
350
+ }
351
+ else {
352
+ processes.delete(req.params.id);
353
+ }
248
354
  res.json({ ok: true });
249
355
  }
250
356
  catch (error) {
package/dist/server.js CHANGED
@@ -57,6 +57,7 @@ import { createSession, revokeSession, setAuthStorage, validateSession } from ".
57
57
  import { ensureCertificates } from "./cert.js";
58
58
  import { isExecutionMode, resolveConfigDir, saveConfig } from "./config.js";
59
59
  import { ProcessManager } from "./process-manager.js";
60
+ import { StructuredSessionManager } from "./structured-session-manager.js";
60
61
  import { generatePwaManifest, generateServiceWorker } from "./pwa.js";
61
62
  import { getErrorMessage, registerClaudeHistoryRoutes, registerSessionRoutes } from "./server-session-routes.js";
62
63
  import { resolveDatabasePath, WandStorage } from "./storage.js";
@@ -252,6 +253,7 @@ export async function startServer(config, configPath) {
252
253
  const configDir = resolveConfigDir(configPath);
253
254
  const avatarSeed = await ensureAvatarSeed(configDir);
254
255
  const processes = new ProcessManager(config, storage, configDir);
256
+ const structuredSessions = new StructuredSessionManager(storage);
255
257
  const useHttps = config.https === true;
256
258
  const protocol = useHttps ? "https" : "http";
257
259
  const nodeModulesDir = path.join(RUNTIME_ROOT_DIR, "node_modules");
@@ -333,6 +335,7 @@ export async function startServer(config, configPath) {
333
335
  defaultMode: config.defaultMode,
334
336
  defaultCwd: config.defaultCwd,
335
337
  commandPresets: config.commandPresets,
338
+ structuredRunners: [{ label: "Claude Structured", runner: "claude-cli-print" }],
336
339
  experimentalDomTerminal: config.experimentalDomTerminal ?? false,
337
340
  updateAvailable: cachedUpdateInfo?.updateAvailable ?? false,
338
341
  latestVersion: cachedUpdateInfo?.latest ?? null,
@@ -460,7 +463,7 @@ export async function startServer(config, configPath) {
460
463
  updateInFlight = false;
461
464
  }
462
465
  });
463
- registerSessionRoutes(app, processes, storage, config.defaultMode);
466
+ registerSessionRoutes(app, processes, structuredSessions, storage, config.defaultMode);
464
467
  registerClaudeHistoryRoutes(app, processes, storage);
465
468
  // ── Path suggestion ──
466
469
  app.get("/api/path-suggestions", async (req, res) => {
@@ -798,12 +801,14 @@ export async function startServer(config, configPath) {
798
801
  : createHttpServer(app);
799
802
  const wss = new WebSocketServer({ server, path: "/ws" });
800
803
  const wsManager = new WsBroadcastManager(wss);
801
- wsManager.setup((id) => processes.get(id));
804
+ wsManager.setup((id) => structuredSessions.get(id) ?? processes.get(id));
802
805
  // Wire process events to WebSocket broadcast
803
806
  processes.on("process", (event) => {
804
807
  wsManager.emitEvent(event);
805
808
  });
806
- // ── Start listening ──
809
+ structuredSessions.setEventEmitter((event) => {
810
+ wsManager.emitEvent(event);
811
+ });
807
812
  await new Promise((resolve, reject) => {
808
813
  server.listen(config.port, config.host, () => {
809
814
  const listenAddr = config.host === "0.0.0.0" ? "0.0.0.0 (所有接口)" : config.host;
package/dist/storage.js CHANGED
@@ -12,6 +12,17 @@ function parseStoredMessages(raw) {
12
12
  return undefined;
13
13
  }
14
14
  }
15
+ function parseStructuredState(raw) {
16
+ if (!raw) {
17
+ return undefined;
18
+ }
19
+ try {
20
+ return JSON.parse(raw);
21
+ }
22
+ catch {
23
+ return undefined;
24
+ }
25
+ }
15
26
  export const DEFAULT_DB_FILE = "wand.db";
16
27
  export function resolveDatabasePath(configPath) {
17
28
  return path.resolve(path.dirname(configPath), DEFAULT_DB_FILE);
@@ -34,7 +45,14 @@ const INIT_SQL = `
34
45
  output TEXT NOT NULL,
35
46
  archived INTEGER NOT NULL DEFAULT 0,
36
47
  archived_at TEXT,
37
- claude_session_id TEXT
48
+ claude_session_id TEXT,
49
+ session_kind TEXT NOT NULL DEFAULT 'pty',
50
+ runner TEXT,
51
+ messages TEXT,
52
+ structured_state TEXT,
53
+ resumed_from_session_id TEXT,
54
+ resumed_to_session_id TEXT,
55
+ auto_recovered INTEGER NOT NULL DEFAULT 0
38
56
  );
39
57
 
40
58
  CREATE TABLE IF NOT EXISTS app_config (
@@ -125,9 +143,9 @@ export class WandStorage {
125
143
  this.db
126
144
  .prepare(`INSERT INTO command_sessions (
127
145
  id, command, cwd, mode, status, exit_code, started_at, ended_at, output
128
- , archived, archived_at, claude_session_id, messages
146
+ , archived, archived_at, claude_session_id, session_kind, runner, messages, structured_state
129
147
  , resumed_from_session_id, resumed_to_session_id, auto_recovered
130
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
148
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
131
149
  ON CONFLICT(id) DO UPDATE SET
132
150
  command = excluded.command,
133
151
  cwd = excluded.cwd,
@@ -140,11 +158,14 @@ export class WandStorage {
140
158
  archived = excluded.archived,
141
159
  archived_at = excluded.archived_at,
142
160
  claude_session_id = excluded.claude_session_id,
161
+ session_kind = excluded.session_kind,
162
+ runner = excluded.runner,
143
163
  messages = excluded.messages,
164
+ structured_state = excluded.structured_state,
144
165
  resumed_from_session_id = excluded.resumed_from_session_id,
145
166
  resumed_to_session_id = excluded.resumed_to_session_id,
146
167
  auto_recovered = excluded.auto_recovered`)
147
- .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.messages ? JSON.stringify(snapshot.messages) : null, snapshot.resumedFromSessionId ?? null, snapshot.resumedToSessionId ?? null, snapshot.autoRecovered ? 1 : 0);
168
+ .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.sessionKind ?? "pty", snapshot.runner ?? null, snapshot.messages ? JSON.stringify(snapshot.messages) : null, snapshot.structuredState ? JSON.stringify(snapshot.structuredState) : null, snapshot.resumedFromSessionId ?? null, snapshot.resumedToSessionId ?? null, snapshot.autoRecovered ? 1 : 0);
148
169
  this.db.exec("COMMIT");
149
170
  }
150
171
  catch (error) {
@@ -163,13 +184,14 @@ export class WandStorage {
163
184
  command = ?, cwd = ?, mode = ?, status = ?, exit_code = ?,
164
185
  started_at = ?, ended_at = ?, output = ?,
165
186
  archived = ?, archived_at = ?, claude_session_id = ?,
187
+ session_kind = ?, runner = ?, structured_state = ?,
166
188
  resumed_from_session_id = ?, resumed_to_session_id = ?, auto_recovered = ?
167
189
  WHERE id = ?`)
168
- .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.resumedFromSessionId ?? null, snapshot.resumedToSessionId ?? null, snapshot.autoRecovered ? 1 : 0, snapshot.id);
190
+ .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.sessionKind ?? "pty", snapshot.runner ?? null, snapshot.structuredState ? JSON.stringify(snapshot.structuredState) : null, snapshot.resumedFromSessionId ?? null, snapshot.resumedToSessionId ?? null, snapshot.autoRecovered ? 1 : 0, snapshot.id);
169
191
  }
170
192
  getSession(id) {
171
193
  const row = this.db
172
- .prepare(`SELECT id, command, cwd, mode, status, exit_code, started_at, ended_at, output, archived, archived_at, claude_session_id, messages
194
+ .prepare(`SELECT id, session_kind, runner, command, cwd, mode, status, exit_code, started_at, ended_at, output, archived, archived_at, claude_session_id, messages, structured_state
173
195
  , resumed_from_session_id, resumed_to_session_id, auto_recovered
174
196
  FROM command_sessions
175
197
  WHERE id = ?`)
@@ -178,7 +200,7 @@ export class WandStorage {
178
200
  }
179
201
  getLatestSessionByClaudeSessionId(claudeSessionId) {
180
202
  const row = this.db
181
- .prepare(`SELECT id, command, cwd, mode, status, exit_code, started_at, ended_at, output, archived, archived_at, claude_session_id, messages
203
+ .prepare(`SELECT id, session_kind, runner, command, cwd, mode, status, exit_code, started_at, ended_at, output, archived, archived_at, claude_session_id, messages, structured_state
182
204
  , resumed_from_session_id, resumed_to_session_id, auto_recovered
183
205
  FROM command_sessions
184
206
  WHERE claude_session_id = ?
@@ -189,7 +211,7 @@ export class WandStorage {
189
211
  }
190
212
  loadSessions() {
191
213
  const rows = this.db
192
- .prepare(`SELECT id, command, cwd, mode, status, exit_code, started_at, ended_at, output, archived, archived_at, claude_session_id, messages
214
+ .prepare(`SELECT id, session_kind, runner, command, cwd, mode, status, exit_code, started_at, ended_at, output, archived, archived_at, claude_session_id, messages, structured_state
193
215
  , resumed_from_session_id, resumed_to_session_id, auto_recovered
194
216
  FROM command_sessions
195
217
  ORDER BY started_at DESC`)
@@ -199,6 +221,8 @@ export class WandStorage {
199
221
  mapSessionRow(row) {
200
222
  return {
201
223
  id: row.id,
224
+ sessionKind: row.session_kind ?? "pty",
225
+ runner: row.runner ?? undefined,
202
226
  command: row.command,
203
227
  cwd: row.cwd,
204
228
  mode: row.mode,
@@ -211,6 +235,7 @@ export class WandStorage {
211
235
  archivedAt: row.archived_at,
212
236
  claudeSessionId: row.claude_session_id,
213
237
  messages: parseStoredMessages(row.messages),
238
+ structuredState: parseStructuredState(row.structured_state),
214
239
  resumedFromSessionId: row.resumed_from_session_id ?? undefined,
215
240
  resumedToSessionId: row.resumed_to_session_id ?? undefined,
216
241
  autoRecovered: Boolean(row.auto_recovered)
@@ -232,9 +257,18 @@ function ensureCommandSessionSchema(db) {
232
257
  if (!names.has("claude_session_id")) {
233
258
  db.exec("ALTER TABLE command_sessions ADD COLUMN claude_session_id TEXT");
234
259
  }
260
+ if (!names.has("session_kind")) {
261
+ db.exec("ALTER TABLE command_sessions ADD COLUMN session_kind TEXT NOT NULL DEFAULT 'pty'");
262
+ }
263
+ if (!names.has("runner")) {
264
+ db.exec("ALTER TABLE command_sessions ADD COLUMN runner TEXT");
265
+ }
235
266
  if (!names.has("messages")) {
236
267
  db.exec("ALTER TABLE command_sessions ADD COLUMN messages TEXT");
237
268
  }
269
+ if (!names.has("structured_state")) {
270
+ db.exec("ALTER TABLE command_sessions ADD COLUMN structured_state TEXT");
271
+ }
238
272
  if (!names.has("resumed_from_session_id")) {
239
273
  db.exec("ALTER TABLE command_sessions ADD COLUMN resumed_from_session_id TEXT");
240
274
  }