@co0ontty/wand 1.3.4 → 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.
@@ -1,7 +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
- function getErrorMessage(error, fallback) {
4
+ export function getErrorMessage(error, fallback) {
5
5
  return error instanceof Error ? error.message : fallback;
6
6
  }
7
7
  function getInputErrorResponse(error, sessionId) {
@@ -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,16 +242,19 @@ 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
- console.error("[wand] Input request received", { sessionId, inputLength: input.length, view: view ?? "chat" });
188
251
  try {
252
+ if (structured.get(sessionId)) {
253
+ const snapshot = await structured.sendMessage(sessionId, input);
254
+ res.json(snapshot);
255
+ return;
256
+ }
189
257
  const snapshot = processes.sendInput(sessionId, input, view, shortcutKey);
190
- console.error("[wand] Input request succeeded", { sessionId, status: snapshot.status, inputLength: input.length, view: view ?? "chat" });
191
258
  res.json(snapshot);
192
259
  }
193
260
  catch (error) {
@@ -203,6 +270,10 @@ export function registerSessionRoutes(app, processes, storage, defaultMode) {
203
270
  app.post("/api/sessions/:id/resize", (req, res) => {
204
271
  const body = req.body;
205
272
  try {
273
+ if (structured.get(req.params.id)) {
274
+ res.status(400).json({ error: "结构化会话不支持调整终端大小。" });
275
+ return;
276
+ }
206
277
  const snapshot = processes.resize(req.params.id, body.cols ?? 0, body.rows ?? 0);
207
278
  res.json(snapshot);
208
279
  }
@@ -212,6 +283,10 @@ export function registerSessionRoutes(app, processes, storage, defaultMode) {
212
283
  });
213
284
  app.post("/api/sessions/:id/approve-permission", (req, res) => {
214
285
  try {
286
+ if (structured.get(req.params.id)) {
287
+ res.json(structured.approvePermission(req.params.id));
288
+ return;
289
+ }
215
290
  res.json(processes.approvePermission(req.params.id));
216
291
  }
217
292
  catch (error) {
@@ -220,16 +295,36 @@ export function registerSessionRoutes(app, processes, storage, defaultMode) {
220
295
  });
221
296
  app.post("/api/sessions/:id/deny-permission", (req, res) => {
222
297
  try {
298
+ if (structured.get(req.params.id)) {
299
+ res.json(structured.denyPermission(req.params.id));
300
+ return;
301
+ }
223
302
  res.json(processes.denyPermission(req.params.id));
224
303
  }
225
304
  catch (error) {
226
305
  res.status(400).json({ error: getErrorMessage(error, "无法拒绝该授权请求。") });
227
306
  }
228
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
+ });
229
320
  app.post("/api/sessions/:id/escalations/:requestId/resolve", (req, res) => {
230
321
  try {
231
322
  const { requestId } = req.params;
232
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
+ }
233
328
  res.json(processes.resolveEscalation(req.params.id, requestId, body.resolution));
234
329
  }
235
330
  catch (error) {
@@ -238,6 +333,10 @@ export function registerSessionRoutes(app, processes, storage, defaultMode) {
238
333
  });
239
334
  app.post("/api/sessions/:id/stop", (req, res) => {
240
335
  try {
336
+ if (structured.get(req.params.id)) {
337
+ res.json(structured.stop(req.params.id));
338
+ return;
339
+ }
241
340
  res.json(processes.stop(req.params.id));
242
341
  }
243
342
  catch (error) {
@@ -246,7 +345,12 @@ export function registerSessionRoutes(app, processes, storage, defaultMode) {
246
345
  });
247
346
  app.delete("/api/sessions/:id", (req, res) => {
248
347
  try {
249
- 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
+ }
250
354
  res.json({ ok: true });
251
355
  }
252
356
  catch (error) {
package/dist/server.js CHANGED
@@ -57,17 +57,14 @@ 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
- import { registerClaudeHistoryRoutes, registerSessionRoutes } from "./server-session-routes.js";
62
+ import { getErrorMessage, registerClaudeHistoryRoutes, registerSessionRoutes } from "./server-session-routes.js";
62
63
  import { resolveDatabasePath, WandStorage } from "./storage.js";
63
64
  import { renderApp } from "./web-ui/index.js";
64
65
  import { WsBroadcastManager } from "./ws-broadcast.js";
65
66
  import { checkRateLimit, recordFailedLogin, resetRateLimit } from "./middleware/rate-limit.js";
66
67
  import { isPathWithinBase, isBlockedFolderPath, normalizeFolderPath } from "./middleware/path-safety.js";
67
- // ── Error helpers ──
68
- function getErrorMessage(error, fallback) {
69
- return error instanceof Error ? error.message : fallback;
70
- }
71
68
  // ── Git helpers ──
72
69
  async function isGitRepo(dirPath) {
73
70
  try {
@@ -221,23 +218,6 @@ function parseStoredPathList(raw) {
221
218
  return [];
222
219
  }
223
220
  }
224
- const HIDDEN_CLAUDE_SESSIONS_KEY = "hidden_claude_sessions";
225
- function getHiddenClaudeSessionIds(storage) {
226
- return new Set(parseStoredPathList(storage.getConfigValue(HIDDEN_CLAUDE_SESSIONS_KEY)));
227
- }
228
- function saveHiddenClaudeSessionIds(storage, hidden) {
229
- storage.setConfigValue(HIDDEN_CLAUDE_SESSIONS_KEY, JSON.stringify(Array.from(hidden)));
230
- }
231
- function removeFromHiddenClaudeSessionIds(storage, idsToRemove) {
232
- const hidden = getHiddenClaudeSessionIds(storage);
233
- let changed = false;
234
- for (const id of idsToRemove) {
235
- if (hidden.delete(id))
236
- changed = true;
237
- }
238
- if (changed)
239
- saveHiddenClaudeSessionIds(storage, hidden);
240
- }
241
221
  const MAX_RECENT_PATHS = 10;
242
222
  // ── File language detection ──
243
223
  function getLanguageFromExt(ext, filePath) {
@@ -273,6 +253,7 @@ export async function startServer(config, configPath) {
273
253
  const configDir = resolveConfigDir(configPath);
274
254
  const avatarSeed = await ensureAvatarSeed(configDir);
275
255
  const processes = new ProcessManager(config, storage, configDir);
256
+ const structuredSessions = new StructuredSessionManager(storage);
276
257
  const useHttps = config.https === true;
277
258
  const protocol = useHttps ? "https" : "http";
278
259
  const nodeModulesDir = path.join(RUNTIME_ROOT_DIR, "node_modules");
@@ -289,18 +270,14 @@ export async function startServer(config, configPath) {
289
270
  res.setHeader("Content-Type", "application/manifest+json");
290
271
  res.send(generatePwaManifest());
291
272
  });
292
- app.get("/icon.svg", (_req, res) => {
293
- res.type("image/svg+xml").send(getAvatarSvg(avatarSeed, 192));
294
- });
273
+ for (const [route, size] of [["/icon.svg", 192], ["/icon-192.png", 192], ["/icon-512.png", 512]]) {
274
+ app.get(route, (_req, res) => {
275
+ res.type("image/svg+xml").send(getAvatarSvg(avatarSeed, size));
276
+ });
277
+ }
295
278
  const iconsDir = path.resolve(existsSync(path.join(SERVER_MODULE_DIR, "web-ui", "content"))
296
279
  ? path.join(SERVER_MODULE_DIR, "web-ui", "content")
297
280
  : path.join(RUNTIME_ROOT_DIR, "src", "web-ui", "content"));
298
- app.get("/icon-192.png", (_req, res) => {
299
- res.type("image/svg+xml").send(getAvatarSvg(avatarSeed, 192));
300
- });
301
- app.get("/icon-512.png", (_req, res) => {
302
- res.type("image/svg+xml").send(getAvatarSvg(avatarSeed, 512));
303
- });
304
281
  app.get("/sw.js", (_req, res) => {
305
282
  res.setHeader("Content-Type", "application/javascript");
306
283
  res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
@@ -358,6 +335,7 @@ export async function startServer(config, configPath) {
358
335
  defaultMode: config.defaultMode,
359
336
  defaultCwd: config.defaultCwd,
360
337
  commandPresets: config.commandPresets,
338
+ structuredRunners: [{ label: "Claude Structured", runner: "claude-cli-print" }],
361
339
  experimentalDomTerminal: config.experimentalDomTerminal ?? false,
362
340
  updateAvailable: cachedUpdateInfo?.updateAvailable ?? false,
363
341
  latestVersion: cachedUpdateInfo?.latest ?? null,
@@ -485,7 +463,7 @@ export async function startServer(config, configPath) {
485
463
  updateInFlight = false;
486
464
  }
487
465
  });
488
- registerSessionRoutes(app, processes, storage, config.defaultMode);
466
+ registerSessionRoutes(app, processes, structuredSessions, storage, config.defaultMode);
489
467
  registerClaudeHistoryRoutes(app, processes, storage);
490
468
  // ── Path suggestion ──
491
469
  app.get("/api/path-suggestions", async (req, res) => {
@@ -823,12 +801,14 @@ export async function startServer(config, configPath) {
823
801
  : createHttpServer(app);
824
802
  const wss = new WebSocketServer({ server, path: "/ws" });
825
803
  const wsManager = new WsBroadcastManager(wss);
826
- wsManager.setup((id) => processes.get(id));
804
+ wsManager.setup((id) => structuredSessions.get(id) ?? processes.get(id));
827
805
  // Wire process events to WebSocket broadcast
828
806
  processes.on("process", (event) => {
829
807
  wsManager.emitEvent(event);
830
808
  });
831
- // ── Start listening ──
809
+ structuredSessions.setEventEmitter((event) => {
810
+ wsManager.emitEvent(event);
811
+ });
832
812
  await new Promise((resolve, reject) => {
833
813
  server.listen(config.port, config.host, () => {
834
814
  const listenAddr = config.host === "0.0.0.0" ? "0.0.0.0 (所有接口)" : config.host;
@@ -25,7 +25,6 @@ export class SessionLifecycleManager {
25
25
  lastActivityAt: Date.now(),
26
26
  };
27
27
  this.sessions.set(sessionId, lifecycle);
28
- console.error(`[Lifecycle] Session ${sessionId} registered with state: ${initialState}`);
29
28
  }
30
29
  /**
31
30
  * Update session state
@@ -33,7 +32,6 @@ export class SessionLifecycleManager {
33
32
  setState(sessionId, newState) {
34
33
  const lifecycle = this.sessions.get(sessionId);
35
34
  if (!lifecycle) {
36
- console.error(`[Lifecycle] Session ${sessionId} not found`);
37
35
  return;
38
36
  }
39
37
  const oldState = lifecycle.state;
@@ -43,7 +41,6 @@ export class SessionLifecycleManager {
43
41
  lifecycle.state = newState;
44
42
  lifecycle.stateSince = Date.now();
45
43
  lifecycle.lastActivityAt = Date.now();
46
- console.error(`[Lifecycle] Session ${sessionId} state changed: ${oldState} -> ${newState}`);
47
44
  // Emit state change event
48
45
  this.events.onStateChange?.(sessionId, oldState, newState);
49
46
  }
@@ -89,7 +86,6 @@ export class SessionLifecycleManager {
89
86
  lifecycle.stateSince = Date.now();
90
87
  lifecycle.archivedBy = by;
91
88
  lifecycle.archiveReason = reason;
92
- console.error(`[Lifecycle] Session ${sessionId} archived: ${reason} (by: ${by})`);
93
89
  // Emit archived event
94
90
  this.events.onArchived?.(sessionId, reason);
95
91
  }
@@ -98,7 +94,6 @@ export class SessionLifecycleManager {
98
94
  */
99
95
  unregister(sessionId) {
100
96
  this.sessions.delete(sessionId);
101
- console.error(`[Lifecycle] Session ${sessionId} unregistered`);
102
97
  }
103
98
  /**
104
99
  * Get session lifecycle
@@ -3,12 +3,22 @@ import type { ConversationTurn, ExecutionMode } from "./types.js";
3
3
  export interface ShortcutLogContext {
4
4
  /** Execution mode the session is running in (e.g. "managed", "full-access") */
5
5
  mode: ExecutionMode;
6
+ /** Permission scope that was approved (e.g. "run_command", "write_file") */
7
+ scope?: string;
6
8
  /** Whether auto-approve is active for this session */
7
9
  autoApprove: boolean;
8
10
  /** Whether a permission prompt was blocking at the time of the keypress */
9
11
  permissionBlocked: boolean;
10
12
  /** The actual input string sent to PTY */
11
13
  input: string;
14
+ /** Auto-approve detection type: "strict" | "fallback" | "idle_probe" */
15
+ approveType?: string;
16
+ /** Fallback detection score */
17
+ score?: number;
18
+ /** Fallback detection matched keywords */
19
+ matched?: string[];
20
+ /** Whether the auto-approve was a false positive */
21
+ falsePositive?: boolean;
12
22
  }
13
23
  /**
14
24
  * SessionLogger saves raw session content to local files for debugging and analysis.
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
  }
@@ -0,0 +1,55 @@
1
+ import { WandStorage } from "./storage.js";
2
+ import { ExecutionMode, SessionRunner, SessionSnapshot } from "./types.js";
3
+ import { ProcessEvent } from "./ws-broadcast.js";
4
+ interface CreateStructuredSessionOptions {
5
+ cwd: string;
6
+ mode: ExecutionMode;
7
+ prompt?: string;
8
+ runner?: SessionRunner;
9
+ }
10
+ export declare class StructuredSessionManager {
11
+ private readonly storage;
12
+ private readonly sessions;
13
+ private readonly pendingChildren;
14
+ private emitEvent;
15
+ constructor(storage: WandStorage);
16
+ setEventEmitter(emitEvent: (event: ProcessEvent) => void): void;
17
+ list(): SessionSnapshot[];
18
+ get(id: string): SessionSnapshot | null;
19
+ createSession(options: CreateStructuredSessionOptions): SessionSnapshot;
20
+ sendMessage(id: string, input: string): Promise<SessionSnapshot>;
21
+ /** Approve a pending permission request. */
22
+ approvePermission(sessionId: string): SessionSnapshot;
23
+ /** Deny a pending permission request. */
24
+ denyPermission(sessionId: string): SessionSnapshot;
25
+ /** Toggle auto-approve for the session. */
26
+ toggleAutoApprove(sessionId: string): SessionSnapshot;
27
+ /** Resolve a specific escalation by requestId. */
28
+ resolveEscalation(sessionId: string, requestId: string, resolution?: "approve_once" | "approve_turn" | "deny"): SessionSnapshot;
29
+ stop(id: string): SessionSnapshot;
30
+ delete(id: string): void;
31
+ private requireSession;
32
+ private emit;
33
+ private resolvePermission;
34
+ private incrementApprovalStats;
35
+ private buildPermissionArgs;
36
+ /**
37
+ * Spawn `claude -p --output-format stream-json` and parse NDJSON lines as
38
+ * they arrive, emitting incremental WebSocket events so the UI can render
39
+ * text / thinking / tool_use blocks in real-time.
40
+ *
41
+ * Permission handling:
42
+ * - Non-root + full-access/managed: --permission-mode bypassPermissions
43
+ * - Non-root + auto-edit: --permission-mode acceptEdits
44
+ * - Root: --permission-mode acceptEdits + --allowedTools (extends approval
45
+ * outside CWD). stdin is always "ignore" — no ACP bidirectional control.
46
+ */
47
+ private runClaudeStreaming;
48
+ private extractAssistantMessage;
49
+ private compactContentBlocks;
50
+ private normalizeToolInput;
51
+ private normalizeToolResultContent;
52
+ private extractModelName;
53
+ private extractUsage;
54
+ }
55
+ export {};