@co0ontty/wand 0.2.1 → 0.3.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,15 +1,28 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { EventEmitter } from "node:events";
3
- import { spawn } from "node:child_process";
4
3
  import path from "node:path";
5
4
  import process from "node:process";
6
5
  import os from "node:os";
7
6
  import pty from "node-pty";
8
7
  import { SessionLogger } from "./session-logger.js";
9
- /** Check if running as root (uid 0) */
8
+ import { SessionLifecycleManager } from "./session-lifecycle.js";
9
+ import { ClaudePtyBridge } from "./claude-pty-bridge.js";
10
+ /** Check if the current process is running as root (UID 0). */
10
11
  function isRunningAsRoot() {
11
12
  return process.getuid?.() === 0 || process.geteuid?.() === 0;
12
13
  }
14
+ export class SessionInputError extends Error {
15
+ code;
16
+ sessionId;
17
+ sessionStatus;
18
+ constructor(message, code, sessionId, sessionStatus) {
19
+ super(message);
20
+ this.code = code;
21
+ this.sessionId = sessionId;
22
+ this.sessionStatus = sessionStatus;
23
+ this.name = "SessionInputError";
24
+ }
25
+ }
13
26
  const PROMPT_PATTERNS = [
14
27
  /(?:^|\b)(?:press\s+)?(?:y|yes)\s*(?:\/|\bor\b)\s*(?:n|no)(?:\b|$)/i,
15
28
  /\[(?:y|yes)\s*\/\s*(?:n|no)\]/i,
@@ -38,9 +51,7 @@ const SELECTION_PROMPT_PATTERNS = [
38
51
  ];
39
52
  const MAX_SESSIONS = 50;
40
53
  const ARCHIVE_AFTER_MS = 1000 * 60 * 60 * 24;
41
- const OUTPUT_WINDOW_SIZE = 4096;
42
54
  const CONFIRM_WINDOW_SIZE = 800;
43
- const OUTPUT_MAX_SIZE = 120000;
44
55
  // Claude 会话 ID 格式:UUID v4
45
56
  const CLAUDE_SESSION_ID_PATTERN = /"session_id"\s*:\s*"([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})"/i;
46
57
  /** Append text to a windowed buffer, trimming from start if over max size. */
@@ -53,29 +64,86 @@ export class ProcessManager extends EventEmitter {
53
64
  storage;
54
65
  sessions = new Map();
55
66
  logger;
67
+ lifecycleManager;
56
68
  constructor(config, storage, configDir) {
57
69
  super();
58
70
  this.config = config;
59
71
  this.storage = storage;
60
72
  this.logger = new SessionLogger(configDir || path.join(process.env.HOME || process.cwd(), ".wand"));
73
+ // Initialize lifecycle manager
74
+ this.lifecycleManager = new SessionLifecycleManager({
75
+ onStateChange: (sessionId, oldState, newState) => {
76
+ this.emitEvent({ type: "status", sessionId, data: { oldState, newState } });
77
+ },
78
+ onIdle: (sessionId) => {
79
+ console.error(`[ProcessManager] Session ${sessionId} is now idle`);
80
+ },
81
+ onArchived: (sessionId, reason) => {
82
+ console.error(`[ProcessManager] Session ${sessionId} archived: ${reason}`);
83
+ },
84
+ });
61
85
  for (const snapshot of this.storage.loadSessions()) {
62
- this.sessions.set(snapshot.id, {
63
- ...snapshot,
64
- processId: null,
65
- ptyProcess: null,
66
- stopRequested: false,
67
- confirmWindow: "",
68
- lastAutoConfirmAt: 0,
69
- sessionIdWindow: "",
70
- storedOutput: snapshot.output,
71
- messages: snapshot.messages ?? [],
72
- jsonChatBusy: false,
73
- childProcess: null,
74
- ptyChatState: "idle",
75
- ptyAssistantBuffer: "",
76
- ptyLastUserInput: "",
77
- ptyEchoSkipped: false
78
- });
86
+ const isClaudeCmd = /^claude\b/.test(snapshot.command.trim());
87
+ // Sessions restored from storage have ptyProcess: null — the old server's PTY
88
+ // belongs to a dead process. Mark running sessions as exited so the UI
89
+ // reflects reality and users can start fresh sessions.
90
+ if (snapshot.status === "running") {
91
+ const updated = { ...snapshot, status: "exited", endedAt: new Date().toISOString() };
92
+ this.storage.saveSession(updated);
93
+ this.sessions.set(snapshot.id, {
94
+ ...updated,
95
+ processId: null,
96
+ ptyProcess: null,
97
+ stopRequested: false,
98
+ confirmWindow: "",
99
+ ptyPermissionBlocked: false,
100
+ lastAutoConfirmAt: 0,
101
+ autoApprovePermissions: this.shouldAutoApprovePermissions(snapshot.command, snapshot.mode),
102
+ pendingEscalation: snapshot.pendingEscalation ?? null,
103
+ lastEscalationResult: snapshot.lastEscalationResult ?? null,
104
+ autonomyPolicy: snapshot.autonomyPolicy ?? this.defaultAutonomyPolicy(snapshot.mode),
105
+ approvalPolicy: snapshot.approvalPolicy ?? "ask-every-time",
106
+ allowedScopes: snapshot.allowedScopes ?? [],
107
+ rememberedEscalationScopes: new Set(),
108
+ rememberedEscalationTargets: new Set(),
109
+ storedOutput: snapshot.output,
110
+ messages: snapshot.messages ?? [],
111
+ childProcess: null,
112
+ ptyBridge: null,
113
+ currentTask: null,
114
+ taskDebounceTimer: null,
115
+ lastEmittedTask: null
116
+ });
117
+ this.lifecycleManager.register(snapshot.id, "idle");
118
+ console.error(`[ProcessManager] Restored session ${snapshot.id} marked as exited (PTY orphaned)`);
119
+ }
120
+ else {
121
+ this.sessions.set(snapshot.id, {
122
+ ...snapshot,
123
+ processId: null,
124
+ ptyProcess: null,
125
+ stopRequested: false,
126
+ confirmWindow: "",
127
+ ptyPermissionBlocked: false,
128
+ lastAutoConfirmAt: 0,
129
+ autoApprovePermissions: this.shouldAutoApprovePermissions(snapshot.command, snapshot.mode),
130
+ pendingEscalation: snapshot.pendingEscalation ?? null,
131
+ lastEscalationResult: snapshot.lastEscalationResult ?? null,
132
+ autonomyPolicy: snapshot.autonomyPolicy ?? this.defaultAutonomyPolicy(snapshot.mode),
133
+ approvalPolicy: snapshot.approvalPolicy ?? "ask-every-time",
134
+ allowedScopes: snapshot.allowedScopes ?? [],
135
+ rememberedEscalationScopes: new Set(),
136
+ rememberedEscalationTargets: new Set(),
137
+ storedOutput: snapshot.output,
138
+ messages: snapshot.messages ?? [],
139
+ childProcess: null,
140
+ ptyBridge: null,
141
+ currentTask: null,
142
+ taskDebounceTimer: null,
143
+ lastEmittedTask: null
144
+ });
145
+ this.lifecycleManager.register(snapshot.id, "archived");
146
+ }
79
147
  }
80
148
  this.archiveExpiredSessions();
81
149
  }
@@ -116,11 +184,15 @@ export class ProcessManager extends EventEmitter {
116
184
  // For full-access mode with claude, add permission flags
117
185
  const processedCommand = this.processCommandForMode(command, mode);
118
186
  const id = randomUUID();
187
+ const isClaudeCmd = this.isClaudeCommand(command);
119
188
  const record = {
120
189
  id,
121
190
  command,
122
191
  cwd: resolvedCwd,
123
192
  mode,
193
+ autonomyPolicy: this.defaultAutonomyPolicy(mode),
194
+ approvalPolicy: "ask-every-time",
195
+ allowedScopes: [],
124
196
  status: "running",
125
197
  exitCode: null,
126
198
  startedAt: new Date().toISOString(),
@@ -128,62 +200,105 @@ export class ProcessManager extends EventEmitter {
128
200
  output: "",
129
201
  archived: false,
130
202
  archivedAt: null,
203
+ permissionBlocked: undefined,
204
+ pendingEscalation: null,
205
+ lastEscalationResult: null,
131
206
  claudeSessionId: null,
132
207
  processId: null,
133
208
  ptyProcess: null,
134
209
  stopRequested: false,
135
210
  confirmWindow: "",
211
+ ptyPermissionBlocked: false,
136
212
  lastAutoConfirmAt: 0,
137
- sessionIdWindow: "",
213
+ autoApprovePermissions: this.shouldAutoApprovePermissions(command, mode),
214
+ rememberedEscalationScopes: new Set(),
215
+ rememberedEscalationTargets: new Set(),
138
216
  storedOutput: "",
139
217
  messages: [],
140
- jsonChatBusy: false,
141
218
  childProcess: null,
142
- ptyChatState: "idle",
143
- ptyAssistantBuffer: "",
144
- ptyLastUserInput: "",
145
- ptyEchoSkipped: false
219
+ ptyBridge: null,
220
+ currentTask: null,
221
+ taskDebounceTimer: null,
222
+ lastEmittedTask: null
146
223
  };
224
+ // Create PTY bridge for this session
225
+ record.ptyBridge = new ClaudePtyBridge({
226
+ sessionId: id,
227
+ isClaudeCommand: isClaudeCmd,
228
+ autoApprove: record.autoApprovePermissions,
229
+ approvalPolicy: record.approvalPolicy,
230
+ });
231
+ record.ptyBridge.on("event", (event) => {
232
+ this.handleBridgeEvent(record, event);
233
+ });
147
234
  this.sessions.set(id, record);
148
235
  this.persist(record);
149
236
  this.cleanupOldSessions();
150
- // Emit started event
151
- this.emitEvent({ type: "started", sessionId: id, data: this.snapshot(record) });
152
- // For native mode, skip PTY creationsendInput() will spawn child processes directly
153
- if (mode === "native" || mode === "managed") {
154
- // If there's an initial input, kick off the first JSON chat turn
155
- if (initialInput) {
156
- const message = mode === "managed"
157
- ? this.wrapManagedPrompt(initialInput)
158
- : initialInput;
159
- this.runJsonChatTurn(record, message);
160
- }
161
- return this.snapshot(record);
237
+ // Register lifecycle
238
+ this.lifecycleManager.register(id, "initializing");
239
+ // All modes use PTY executionJSON turns are only used for internal recovery
240
+ const shellArgs = this.buildShellArgs(processedCommand);
241
+ let child;
242
+ try {
243
+ child = pty.spawn(this.config.shell, shellArgs, {
244
+ cwd: resolvedCwd,
245
+ env: {
246
+ ...process.env,
247
+ WAND_MODE: mode,
248
+ WAND_AUTO_CONFIRM: mode === "full-access" ? "1" : "0",
249
+ WAND_AUTO_EDIT: mode === "auto-edit" ? "1" : "0"
250
+ },
251
+ name: "xterm-color",
252
+ cols: 120,
253
+ rows: 36
254
+ });
162
255
  }
163
- // For managed mode without initial input, create PTY but don't send anything
164
- // (managed mode waits for user input via sendInput)
165
- // For default mode with Claude commands and initial input, also use JSON chat turn
166
- // This ensures chat view works correctly from the first message
167
- if (initialInput && this.isClaudeCommand(command) && this.isRealChatInput(initialInput)) {
168
- const cleanInput = initialInput.replace(/[\r\n]+$/, "").trim();
169
- this.runJsonChatTurn(record, cleanInput);
256
+ catch (err) {
257
+ console.error("[ProcessManager] pty.spawn threw", { sessionId: id, error: String(err) });
258
+ record.status = "failed";
259
+ record.exitCode = -1;
260
+ record.endedAt = new Date().toISOString();
261
+ record.ptyProcess = null;
262
+ this.lifecycleManager.archive(id, "Session spawn failed", "error");
263
+ this.persist(record);
170
264
  return this.snapshot(record);
171
265
  }
172
- const shellArgs = this.buildShellArgs(processedCommand);
173
- const child = pty.spawn(this.config.shell, shellArgs, {
174
- cwd: resolvedCwd,
175
- env: {
176
- ...process.env,
177
- WAND_MODE: mode,
178
- WAND_AUTO_CONFIRM: mode === "full-access" ? "1" : "0",
179
- WAND_AUTO_EDIT: mode === "auto-edit" ? "1" : "0"
180
- },
181
- name: "xterm-color",
182
- cols: 120,
183
- rows: 36
184
- });
185
266
  record.processId = child.pid;
186
267
  record.ptyProcess = child;
268
+ record.status = "running";
269
+ this.lifecycleManager.setState(id, "running");
270
+ // Register exit handler AFTER ptyProcess is assigned — node-pty's EventEmitter
271
+ // fires 'exit' synchronously when the child has already exited (e.g. "command
272
+ // not found"). If we register first, onExit fires with ptyProcess still null and
273
+ // status never updates. By assigning first, onExit always sees a consistent state.
274
+ child.onExit(({ exitCode }) => {
275
+ const current = this.sessions.get(id);
276
+ if (!current)
277
+ return;
278
+ if (current.ptyBridge) {
279
+ current.ptyBridge.onExit(exitCode);
280
+ }
281
+ current.status = current.stopRequested ? "stopped" : exitCode === 0 ? "exited" : "failed";
282
+ current.exitCode = current.stopRequested ? null : exitCode;
283
+ current.endedAt = new Date().toISOString();
284
+ current.ptyProcess = null;
285
+ this.lifecycleManager.archive(id, `Session ${current.status}`, current.stopRequested ? "user" : "error");
286
+ this.persist(current);
287
+ this.emitEvent({ type: "ended", sessionId: id, data: this.snapshot(current) });
288
+ });
289
+ // Set PTY write function for bridge (for permission approval).
290
+ // Write directly to record.ptyProcess — the status guard in sendInput() already
291
+ // ensures no input is sent when the session is not running, so we just guard
292
+ // the PTY write itself against a null process.
293
+ if (record.ptyBridge) {
294
+ record.ptyBridge.setPtyWrite((input) => {
295
+ if (record.ptyProcess) {
296
+ record.ptyProcess.write(input);
297
+ }
298
+ });
299
+ }
300
+ // Emit started event AFTER PTY is fully set up so clients receive a consistent snapshot.
301
+ this.emitEvent({ type: "started", sessionId: id, data: this.snapshot(record) });
187
302
  let initialInputSent = false;
188
303
  const sendInitialInput = () => {
189
304
  if (initialInputSent || !initialInput)
@@ -195,93 +310,40 @@ export class ProcessManager extends EventEmitter {
195
310
  return;
196
311
  }
197
312
  process.stderr.write(`[wand] Sending initial input: ${initialInput}\n`);
198
- // Track initial input as a user message for Chat mode
199
- if (this.isRealChatInput(initialInput)) {
200
- const cleanInput = initialInput.replace(/[\r\n]+$/, "").trim();
201
- current.messages.push({
202
- role: "user",
203
- content: [{ type: "text", text: cleanInput }]
204
- });
205
- current.messages.push({
206
- role: "assistant",
207
- content: []
208
- });
209
- current.ptyChatState = "responding";
210
- current.ptyAssistantBuffer = "";
211
- current.ptyLastUserInput = cleanInput;
212
- current.ptyEchoSkipped = false;
313
+ // Track initial input via bridge for Chat mode
314
+ if (current.ptyBridge) {
315
+ current.ptyBridge.onUserInput(initialInput);
213
316
  }
214
317
  current.ptyProcess.write(initialInput);
215
318
  // \n advances to a new line so subsequent output doesn't overwrite this input
216
319
  current.ptyProcess.write("\n");
217
320
  };
218
- // Debounce rapid PTY output events to reduce WebSocket flooding
219
- let outputDebounceTimer = null;
220
- let pendingChunk = "";
221
321
  child.onData((chunk) => {
222
322
  const rec = this.sessions.get(id);
223
323
  if (!rec)
224
324
  return;
225
- rec.output = appendWindow(rec.output, normalizePtyOutput(chunk), OUTPUT_MAX_SIZE);
325
+ // Route chunk through PTY bridge
326
+ if (rec.ptyBridge) {
327
+ rec.ptyBridge.processChunk(chunk);
328
+ }
329
+ // Update legacy output field for backward compatibility
330
+ rec.output = rec.ptyBridge?.getRawOutput() ?? "";
226
331
  // Log raw PTY output for analysis
227
332
  this.logger.appendPtyOutput(id, chunk);
228
- // Capture Claude session ID from output
229
- if (!rec.claudeSessionId) {
230
- rec.sessionIdWindow = appendWindow(rec.sessionIdWindow, chunk, OUTPUT_WINDOW_SIZE);
231
- const match = CLAUDE_SESSION_ID_PATTERN.exec(rec.sessionIdWindow);
232
- if (match?.[1]) {
233
- rec.claudeSessionId = match[1];
234
- process.stderr.write(`[wand] Captured Claude session ID: ${match[1]}\n`);
235
- this.persist(rec);
236
- }
237
- }
238
- if (mode === "full-access") {
333
+ // Update Claude session ID from bridge
334
+ const bridgeSessionId = rec.ptyBridge?.getClaudeSessionId();
335
+ if (bridgeSessionId && bridgeSessionId !== rec.claudeSessionId) {
336
+ rec.claudeSessionId = bridgeSessionId;
337
+ process.stderr.write(`[wand] Captured Claude session ID: ${bridgeSessionId}\n`);
338
+ }
339
+ // Auto-confirm for full-access mode
340
+ if (rec.autoApprovePermissions) {
239
341
  this.autoConfirmWithRecord(rec, chunk, child);
240
342
  }
241
343
  if (initialInput && !initialInputSent && chunk.includes("❯")) {
242
344
  sendInitialInput();
243
345
  }
244
- // Track assistant response for Chat mode (PTY sessions)
245
- if (rec.ptyChatState === "responding") {
246
- rec.ptyAssistantBuffer += chunk;
247
- this.trackPtyAssistantResponse(rec);
248
- }
249
- // Batch rapid output chunks to reduce WebSocket messages
250
- pendingChunk += chunk;
251
- if (outputDebounceTimer) {
252
- clearTimeout(outputDebounceTimer);
253
- }
254
- outputDebounceTimer = setTimeout(() => {
255
- const finalChunk = pendingChunk;
256
- pendingChunk = "";
257
- outputDebounceTimer = null;
258
- this.persist(rec);
259
- this.emitEvent({
260
- type: "output",
261
- sessionId: id,
262
- data: {
263
- chunk: finalChunk,
264
- output: rec.output,
265
- messages: rec.messages.length > 0 ? rec.messages : undefined
266
- }
267
- });
268
- }, 30); // 30ms debounce for PTY mode
269
- });
270
- child.onExit(({ exitCode }) => {
271
- const current = this.sessions.get(id);
272
- if (!current)
273
- return;
274
- // Finalize any pending assistant response before ending
275
- if (current.ptyChatState === "responding") {
276
- current.ptyEchoSkipped = true; // Force skip echo on exit
277
- this.finalizePtyAssistantMessage(current);
278
- }
279
- current.status = current.stopRequested ? "stopped" : exitCode === 0 ? "exited" : "failed";
280
- current.exitCode = current.stopRequested ? null : exitCode;
281
- current.endedAt = new Date().toISOString();
282
- current.ptyProcess = null;
283
- this.persist(current);
284
- this.emitEvent({ type: "ended", sessionId: id, data: this.snapshot(current) });
346
+ this.persist(rec);
285
347
  });
286
348
  if (initialInput) {
287
349
  setTimeout(() => {
@@ -311,638 +373,74 @@ export class ProcessManager extends EventEmitter {
311
373
  }
312
374
  sendInput(id, input, view) {
313
375
  const record = this.mustGet(id);
314
- // Native / managed mode: always use JSON chat turn
315
- // Strip trailing newlines — input from chat UI includes Enter key as "\n"
316
- if (record.mode === "native" || record.mode === "managed") {
317
- const cleanInput = input.replace(/[\r\n]+$/, "").trim();
318
- if (cleanInput) {
319
- const message = record.mode === "managed"
320
- ? this.wrapManagedPrompt(cleanInput)
321
- : cleanInput;
322
- return this.runJsonChatTurn(record, message);
323
- }
324
- return this.snapshot(record);
325
- }
326
- // Chat view + Claude command → route through native pipeline for structured output
327
- // This gives Chat mode the same structured messages as native mode, regardless of session mode
328
- if (view === "chat" && this.isClaudeCommand(record.command) && this.isRealChatInput(input)) {
329
- return this.runJsonChatTurn(record, input.replace(/[\r\n]+$/, "").trim());
330
- }
331
- if (!record.ptyProcess || record.status !== "running") {
332
- throw new Error("Session is not running.");
333
- }
334
- // Track user input as a structured message for Chat mode display (PTY fallback)
335
- if (this.isRealChatInput(input)) {
336
- const cleanInput = input.replace(/[\r\n]+$/, "").trim();
337
- record.messages.push({
338
- role: "user",
339
- content: [{ type: "text", text: cleanInput }]
376
+ if (record.status !== "running") {
377
+ console.error("[ProcessManager] Rejecting input for non-running session", {
378
+ sessionId: id,
379
+ status: record.status,
380
+ hasPty: !!record.ptyProcess,
381
+ inputLength: input.length,
382
+ view: view ?? "chat"
340
383
  });
341
- // Add assistant placeholder for streaming updates
342
- record.messages.push({
343
- role: "assistant",
344
- content: []
384
+ throw new SessionInputError("Session is not running.", "SESSION_NOT_RUNNING", id, record.status);
385
+ }
386
+ // Update lifecycle
387
+ this.lifecycleManager.touch(id);
388
+ this.lifecycleManager.startThinking(id);
389
+ if (!record.ptyProcess) {
390
+ console.error("[ProcessManager] Rejecting input because PTY is missing", {
391
+ sessionId: id,
392
+ status: record.status,
393
+ hasPty: !!record.ptyProcess,
394
+ inputLength: input.length,
395
+ view: view ?? "chat"
345
396
  });
346
- record.ptyChatState = "responding";
347
- record.ptyAssistantBuffer = "";
348
- record.ptyLastUserInput = cleanInput;
349
- record.ptyEchoSkipped = false;
397
+ throw new SessionInputError("Session is not running.", "SESSION_NO_PTY", id, record.status);
398
+ }
399
+ console.error("[ProcessManager] Sending input to session", {
400
+ sessionId: id,
401
+ status: record.status,
402
+ hasPty: !!record.ptyProcess,
403
+ inputLength: input.length,
404
+ view: view ?? "chat"
405
+ });
406
+ // Track user input via bridge for Chat mode
407
+ if (record.ptyBridge) {
408
+ record.ptyBridge.onUserInput(input);
350
409
  }
351
410
  // Ensure input advances to a new line so subsequent PTY output doesn't overwrite it
352
411
  record.ptyProcess.write(input);
353
- if (!input.endsWith("\n")) {
412
+ if (view !== "terminal" && !input.endsWith("\n")) {
354
413
  record.ptyProcess.write("\n");
355
414
  }
356
415
  this.persist(record);
357
416
  return this.snapshot(record);
358
417
  }
359
- runJsonChatTurn(record, message) {
360
- if (record.jsonChatBusy) {
361
- // Queue or reject — for now just ignore until previous turn finishes
362
- process.stderr.write(`[wand] JSON chat turn already in progress for ${record.id}, ignoring\n`);
363
- return this.snapshot(record);
364
- }
365
- record.jsonChatBusy = true;
366
- record.status = "running";
367
- const baseCommand = record.command.trim();
368
- const escapedMessage = message.replace(/'/g, "'\\''");
369
- // Build command: claude -p 'message' --output-format stream-json --verbose [--resume sessionId] [--permission-mode bypassPermissions]
370
- // Note: --verbose is required when using --output-format stream-json with --print (Claude CLI only)
371
- const isClaude = /^claude\b/.test(baseCommand);
372
- const parts = [baseCommand, "-p", `'${escapedMessage}'`, "--output-format", "stream-json"];
373
- if (isClaude)
374
- parts.push("--verbose");
375
- if (record.claudeSessionId) {
376
- parts.push("--resume", record.claudeSessionId);
377
- }
378
- // Add permission mode for full-access (skip for root users who have all permissions)
379
- if (/^claude(?:\s|$)/.test(baseCommand) && !/--permission-mode\b/.test(baseCommand) && !isRunningAsRoot()) {
380
- parts.push("--permission-mode", "bypassPermissions");
381
- }
382
- const nativeCommand = parts.join(" ");
383
- process.stderr.write(`[wand] Running JSON chat turn: ${nativeCommand}\n`);
384
- // Add user message to conversation
385
- record.messages.push({
386
- role: "user",
387
- content: [{ type: "text", text: message }]
388
- });
389
- // Also append to raw output for terminal view
390
- record.output = appendWindow(record.output, `\n❯ ${message}\n`, OUTPUT_MAX_SIZE);
391
- this.persist(record);
392
- this.emitEvent({ type: "output", sessionId: record.id, data: { chunk: `\n❯ ${message}\n`, output: record.output, messages: record.messages } });
393
- const child = spawn(nativeCommand, [], {
394
- cwd: record.cwd,
395
- env: {
396
- ...process.env,
397
- WAND_MODE: "native",
398
- TERM: process.env.TERM || "xterm-256color"
399
- },
400
- shell: true,
401
- stdio: ["ignore", "pipe", "pipe"]
402
- });
403
- // Store child process reference for cleanup
404
- record.childProcess = child;
405
- // Collect NDJSON lines from stdout
406
- let stdoutBuffer = "";
407
- const assistantBlocks = [];
408
- let turnSessionId = null;
409
- // Store usage data from result event (attached as a property on the blocks array)
410
- assistantBlocks._lastUsage = null;
411
- // Add assistant placeholder immediately so frontend has both messages during streaming
412
- const assistantIndex = record.messages.length;
413
- record.messages.push({
414
- role: "assistant",
415
- content: []
416
- });
417
- // Debounce rapid output to reduce flicker during streaming
418
- let outputDebounceTimer = null;
419
- let pendingOutput = false;
420
- child.stdout?.on("data", (chunk) => {
421
- const text = chunk.toString();
422
- stdoutBuffer += text;
423
- // Process complete NDJSON lines
424
- const lines = stdoutBuffer.split("\n");
425
- stdoutBuffer = lines.pop() || ""; // Keep incomplete last line in buffer
426
- let hasNewContent = false;
427
- for (const line of lines) {
428
- const trimmed = line.trim();
429
- if (!trimmed)
430
- continue;
431
- try {
432
- const event = JSON.parse(trimmed);
433
- this.processJsonEvent(record, event, assistantBlocks);
434
- // Log native mode event for analysis
435
- this.logger.appendStreamEvent(record.id, event);
436
- // Extract session_id from any event that has it
437
- if (event.session_id && !turnSessionId) {
438
- turnSessionId = event.session_id;
439
- }
440
- hasNewContent = true;
441
- }
442
- catch {
443
- // Not valid JSON — might be debug output, append to raw output
444
- record.output = appendWindow(record.output, trimmed + "\n", OUTPUT_MAX_SIZE);
445
- hasNewContent = true;
446
- }
447
- }
448
- if (hasNewContent) {
449
- // Update assistant message content from collected blocks during streaming
450
- if (assistantBlocks.length > 0) {
451
- record.messages[assistantIndex].content = this.buildContentBlocks(assistantBlocks);
452
- }
453
- this.persist(record);
454
- pendingOutput = true;
455
- // Debounce output events to reduce WebSocket flooding
456
- if (outputDebounceTimer) {
457
- clearTimeout(outputDebounceTimer);
458
- }
459
- outputDebounceTimer = setTimeout(() => {
460
- outputDebounceTimer = null;
461
- if (pendingOutput) {
462
- pendingOutput = false;
463
- this.emitEvent({ type: "output", sessionId: record.id, data: { chunk: "", output: record.output, messages: record.messages } });
464
- }
465
- }, 50); // 50ms debounce for native mode
466
- }
467
- });
468
- child.stderr?.on("data", (chunk) => {
469
- const text = chunk.toString();
470
- record.output = appendWindow(record.output, text, OUTPUT_MAX_SIZE);
471
- this.persist(record);
472
- this.emitEvent({ type: "output", sessionId: record.id, data: { chunk: text, output: record.output, messages: record.messages } });
473
- });
474
- child.on("close", (code) => {
475
- // Process any remaining buffer
476
- if (stdoutBuffer.trim()) {
477
- try {
478
- const event = JSON.parse(stdoutBuffer.trim());
479
- this.processJsonEvent(record, event, assistantBlocks);
480
- if (event.session_id && !turnSessionId) {
481
- turnSessionId = event.session_id;
482
- }
483
- }
484
- catch {
485
- record.output = appendWindow(record.output, stdoutBuffer, OUTPUT_MAX_SIZE);
486
- }
487
- }
488
- // Finalize assistant message - update the placeholder we created
489
- if (assistantBlocks.length > 0) {
490
- record.messages[assistantIndex].content = this.buildContentBlocks(assistantBlocks);
491
- }
492
- // Extract and apply token usage from result event
493
- const blocksMeta = assistantBlocks;
494
- const lastUsage = blocksMeta._lastUsage;
495
- if (lastUsage) {
496
- record.messages[assistantIndex].usage = {
497
- inputTokens: lastUsage.input_tokens,
498
- outputTokens: lastUsage.output_tokens,
499
- cacheReadInputTokens: lastUsage.cache_read_input_tokens,
500
- cacheCreationInputTokens: lastUsage.cache_creation_input_tokens,
501
- totalCostUsd: lastUsage._totalCostUsd,
502
- };
503
- }
504
- // Update session ID for multi-turn resume
505
- if (turnSessionId) {
506
- record.claudeSessionId = turnSessionId;
507
- process.stderr.write(`[wand] Captured Claude session ID: ${turnSessionId}\n`);
508
- }
509
- record.jsonChatBusy = false;
510
- // Native mode: session stays "running" to accept more turns, unless stop was requested
511
- if (record.stopRequested) {
512
- record.status = "stopped";
513
- record.endedAt = new Date().toISOString();
514
- }
515
- else if (code !== 0 && code !== null) {
516
- // Non-zero exit but don't end the session — just log it
517
- process.stderr.write(`[wand] JSON chat turn exited with code ${code}\n`);
518
- }
519
- // Session stays running for more turns
520
- this.persist(record);
521
- this.emitEvent({ type: "output", sessionId: record.id, data: { chunk: "", output: record.output, messages: record.messages } });
522
- });
523
- child.on("error", (err) => {
524
- const errMsg = `\n[wand] Error: ${err.message}\n`;
525
- record.output = appendWindow(record.output, errMsg, OUTPUT_MAX_SIZE);
526
- record.jsonChatBusy = false;
527
- this.persist(record);
528
- this.emitEvent({ type: "output", sessionId: record.id, data: { chunk: errMsg, output: record.output, messages: record.messages } });
529
- });
530
- this.persist(record);
531
- return this.snapshot(record);
532
- }
533
- /**
534
- * Wrap user message with autonomous completion instructions for managed mode.
535
- * The AI is told to complete the task in one shot without asking questions.
536
- */
537
- wrapManagedPrompt(userMessage) {
538
- return `${userMessage}
539
-
540
- ---
541
-
542
- You are in **managed (autonomous) mode**. Complete the task above in your response.
543
-
544
- Rules:
545
- - Do NOT ask clarifying questions — make reasonable assumptions and proceed.
546
- - Do NOT ask for confirmation — use your best judgment.
547
- - Complete the entire task in one response without stopping for input.
548
- - If you encounter an error, try alternative approaches automatically.
549
- - Execute all necessary steps: write code, run commands, fix issues, and verify results.
550
- - After completing the task, briefly summarize what was done.
551
-
552
- Begin now:`;
553
- }
554
- processJsonEvent(record, event, assistantBlocks) {
555
- switch (event.type) {
556
- case "assistant": {
557
- // Assistant message — may arrive as multiple separate events (e.g. thinking block first, text block second)
558
- // Merge new blocks that aren't yet in assistantBlocks
559
- const msg = event.message;
560
- if (msg?.content && Array.isArray(msg.content)) {
561
- for (const block of msg.content) {
562
- if (block && typeof block === "object" && "type" in block) {
563
- const blockType = block.type;
564
- const blockId = block.id;
565
- // For tool_use/tool_result, match by ID; for others, match by type
566
- let existing = -1;
567
- if (blockId) {
568
- existing = assistantBlocks.findIndex((b) => b.id === blockId);
569
- }
570
- else {
571
- existing = assistantBlocks.findIndex((b) => b.type === blockType);
572
- }
573
- if (existing >= 0) {
574
- // Update existing block — merge fields (e.g. thinking → text transition)
575
- Object.assign(assistantBlocks[existing], block);
576
- }
577
- else {
578
- // New block type — add it
579
- assistantBlocks.push(block);
580
- this.appendBlockToOutput(record, block);
581
- }
582
- }
583
- }
584
- }
585
- break;
586
- }
587
- case "content_block_start": {
588
- // Streaming: new content block starting
589
- if (event.content_block) {
590
- assistantBlocks.push({ ...event.content_block });
591
- }
592
- break;
593
- }
594
- case "content_block_delta": {
595
- // Streaming: delta for the current block
596
- if (event.delta) {
597
- const lastBlock = assistantBlocks[assistantBlocks.length - 1];
598
- if (lastBlock) {
599
- if (event.delta.text) {
600
- lastBlock.text = (lastBlock.text || "") + event.delta.text;
601
- record.output = appendWindow(record.output, event.delta.text, OUTPUT_MAX_SIZE);
602
- }
603
- if (event.delta.partial_json) {
604
- lastBlock._partialJson = (lastBlock._partialJson || "") + event.delta.partial_json;
605
- }
606
- if (event.delta.thinking) {
607
- lastBlock.thinking = (lastBlock.thinking || "") + event.delta.thinking;
608
- }
609
- }
610
- }
611
- break;
612
- }
613
- case "result": {
614
- // Final result event — `event.result` can be a string or an object with `result` + `content`
615
- const resultStr = typeof event.result === "string" ? event.result : event.result?.result;
616
- if (typeof event.result === "object" && event.result !== null) {
617
- const result = event.result;
618
- if (result.content && Array.isArray(result.content)) {
619
- for (const block of result.content) {
620
- if (block && typeof block === "object" && "type" in block) {
621
- const blockType = block.type;
622
- const blockId = block.id;
623
- // For tool_use/tool_result, match by ID; for others, match by type
624
- let existing = -1;
625
- if (blockId) {
626
- existing = assistantBlocks.findIndex((b) => b.id === blockId);
627
- }
628
- else {
629
- existing = assistantBlocks.findIndex((b) => b.type === blockType);
630
- }
631
- if (existing >= 0) {
632
- Object.assign(assistantBlocks[existing], block);
633
- }
634
- else {
635
- assistantBlocks.push(block);
636
- this.appendBlockToOutput(record, block);
637
- }
638
- }
639
- }
640
- }
641
- }
642
- // Use the result string as text if no text block exists yet
643
- if (resultStr) {
644
- const hasTextBlock = assistantBlocks.some((b) => b.type === "text");
645
- if (!hasTextBlock) {
646
- const textBlock = { type: "text", text: resultStr };
647
- assistantBlocks.push(textBlock);
648
- this.appendBlockToOutput(record, textBlock);
649
- }
650
- else {
651
- // Update existing text block with final result
652
- const textBlock = assistantBlocks.find((b) => b.type === "text");
653
- if (textBlock) {
654
- textBlock.text = resultStr;
655
- }
656
- }
657
- }
658
- // Capture token usage from result event (store in assistantBlocks metadata)
659
- if (event.usage || event.total_cost_usd) {
660
- const usage = {};
661
- if (event.usage)
662
- Object.assign(usage, event.usage);
663
- if (event.total_cost_usd !== undefined) {
664
- usage._totalCostUsd = event.total_cost_usd;
665
- }
666
- assistantBlocks._lastUsage = usage;
667
- }
668
- break;
669
- }
670
- case "user": {
671
- // User message — contains tool_result blocks from tool execution
672
- // These should be appended to the assistant message's content
673
- const msg = event.message;
674
- if (msg?.content && Array.isArray(msg.content)) {
675
- for (const block of msg.content) {
676
- if (block && typeof block === "object" && "type" in block) {
677
- const blockType = block.type;
678
- // Tool results come as user messages with type "tool_result"
679
- if (blockType === "tool_result") {
680
- assistantBlocks.push(block);
681
- this.appendBlockToOutput(record, block);
682
- }
683
- }
684
- }
685
- }
686
- break;
687
- }
688
- // system, error, etc. — just log
689
- default:
690
- break;
691
- }
692
- }
693
- appendBlockToOutput(record, block) {
694
- switch (block.type) {
695
- case "text":
696
- record.output = appendWindow(record.output, block.text + "\n", OUTPUT_MAX_SIZE);
697
- break;
698
- case "thinking":
699
- record.output = appendWindow(record.output, "[thinking...]\n", OUTPUT_MAX_SIZE);
700
- break;
701
- case "tool_use":
702
- record.output = appendWindow(record.output, `[tool: ${block.name}]\n`, OUTPUT_MAX_SIZE);
703
- break;
704
- case "tool_result":
705
- record.output = appendWindow(record.output, `[tool result: ${(block.content || "").slice(0, 200)}]\n`, OUTPUT_MAX_SIZE);
706
- break;
707
- }
708
- }
709
- buildContentBlocks(blocks) {
710
- return blocks.map((b) => {
711
- switch (b.type) {
712
- case "text":
713
- return { type: "text", text: b.text || "" };
714
- case "thinking":
715
- return { type: "thinking", thinking: b.thinking || "" };
716
- case "tool_use": {
717
- let input = b.input || {};
718
- if (b._partialJson && typeof b._partialJson === "string") {
719
- try {
720
- input = JSON.parse(b._partialJson);
721
- }
722
- catch { /* keep original */ }
723
- }
724
- return {
725
- type: "tool_use",
726
- id: b.id || "",
727
- name: b.name || "",
728
- description: b.description || "",
729
- input
730
- };
731
- }
732
- case "tool_result":
733
- return {
734
- type: "tool_result",
735
- tool_use_id: b.tool_use_id || "",
736
- content: b.content || "",
737
- is_error: b.is_error || false
738
- };
739
- default:
740
- return { type: "text", text: JSON.stringify(b) };
741
- }
742
- });
743
- }
744
- buildAssistantTurn(blocks) {
745
- return { role: "assistant", content: this.buildContentBlocks(blocks) };
746
- }
747
- // ── PTY Chat Tracking helpers ──
748
- /** Determine if input looks like a real chat message (not control characters) */
749
- isRealChatInput(input) {
750
- const trimmed = input.replace(/[\r\n]+$/, "").trim();
751
- // Empty or whitespace-only
752
- if (!trimmed)
753
- return false;
754
- // Single control character (Ctrl+C, Ctrl+D, etc.)
755
- if (trimmed.length === 1 && trimmed.charCodeAt(0) < 32)
756
- return false;
757
- // ANSI escape sequences (arrow keys, etc.)
758
- if (trimmed.startsWith("\x1b"))
759
- return false;
760
- // Single "y" or "n" — likely auto-confirm response
761
- if (/^[yn]$/i.test(trimmed))
762
- return false;
763
- // Just Enter/CR
764
- if (trimmed === "\r" || trimmed === "\n")
765
- return false;
766
- return true;
767
- }
768
- /** Check if a command is a Claude CLI command */
769
- isClaudeCommand(command) {
770
- const trimmed = command.trim();
771
- return /^claude\b/.test(trimmed);
772
- }
773
- /** Strip ANSI escape sequences from raw PTY output */
774
- stripAnsiSequences(text) {
775
- // eslint-disable-next-line no-control-regex
776
- return text
777
- .replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, "") // CSI sequences
778
- .replace(/\x1b\][^\x07]*(\x07|\x1b\\)/g, "") // OSC sequences
779
- .replace(/\x1b[><=ePX^_]/g, "") // Single-char escapes
780
- // eslint-disable-next-line no-control-regex
781
- .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, "") // Control chars (keep \t \n \r)
782
- .replace(/\r\n?/g, "\n");
783
- }
784
- /** Track and update assistant response from PTY output */
785
- trackPtyAssistantResponse(record) {
786
- const clean = this.stripAnsiSequences(record.ptyAssistantBuffer);
787
- // Phase 1: Skip user input echo
788
- if (!record.ptyEchoSkipped) {
789
- // Look for the user's input text in the cleaned output (it's the PTY echo)
790
- const echoIdx = clean.indexOf(record.ptyLastUserInput);
791
- if (echoIdx !== -1) {
792
- record.ptyEchoSkipped = true;
793
- // Don't try to trim the raw buffer — cleanPtyOutputForChat will filter the echo line.
794
- // The echo line starts with ❯ which is already filtered by cleanPtyOutputForChat.
795
- }
796
- // Don't update assistant content until echo is skipped
797
- return;
798
- }
799
- // Phase 2: Check if assistant is done (❯ prompt reappeared)
800
- const lines = clean.split("\n");
801
- for (let i = lines.length - 1; i >= 0; i--) {
802
- const trimmed = lines[i].trim();
803
- if (trimmed.startsWith("❯")) {
804
- const afterPrompt = trimmed.slice(1).trim();
805
- // Standalone ❯ or ❯ with prompt suggestions = assistant done
806
- if (!afterPrompt || afterPrompt.startsWith("Try")) {
807
- this.finalizePtyAssistantMessage(record);
808
- return;
809
- }
810
- break;
811
- }
418
+ /** Emit a task event for a session, debounced to avoid flooding */
419
+ emitTask(record, task) {
420
+ // Clear existing debounce timer
421
+ if (record.taskDebounceTimer) {
422
+ clearTimeout(record.taskDebounceTimer);
423
+ record.taskDebounceTimer = null;
812
424
  }
813
- // Phase 3: Update assistant content progressively during streaming
814
- this.updatePtyAssistantContent(record);
815
- }
816
- /** Update the assistant placeholder message with cleaned PTY content */
817
- updatePtyAssistantContent(record) {
818
- const lastMsg = record.messages[record.messages.length - 1];
819
- if (!lastMsg || lastMsg.role !== "assistant")
425
+ // Don't re-emit the same task
426
+ if (task && task.title === record.lastEmittedTask)
820
427
  return;
821
- const text = this.cleanPtyOutputForChat(record.ptyAssistantBuffer);
822
- if (text) {
823
- lastMsg.content = [{ type: "text", text }];
824
- }
825
- }
826
- /** Finalize the assistant message when prompt is detected */
827
- finalizePtyAssistantMessage(record) {
828
- const lastMsg = record.messages[record.messages.length - 1];
829
- if (!lastMsg || lastMsg.role !== "assistant")
428
+ if (task === null) {
429
+ // Clear task after a delay — allows a brief display of "idle" state
430
+ record.taskDebounceTimer = setTimeout(() => {
431
+ record.currentTask = null;
432
+ record.lastEmittedTask = null;
433
+ this.emitEvent({ type: "task", sessionId: record.id, data: null });
434
+ }, 2000);
830
435
  return;
831
- const text = this.cleanPtyOutputForChat(record.ptyAssistantBuffer);
832
- if (text) {
833
- lastMsg.content = [{ type: "text", text }];
834
- }
835
- else if (lastMsg.content.length === 0) {
836
- // Remove empty assistant placeholder if no content was captured
837
- record.messages.pop();
838
436
  }
839
- record.ptyChatState = "idle";
840
- record.ptyAssistantBuffer = "";
841
- record.ptyLastUserInput = "";
842
- record.ptyEchoSkipped = false;
843
- process.stderr.write(`[wand] PTY assistant response finalized (${record.messages.length} messages)\n`);
844
- }
845
- /** Clean raw PTY output into readable chat content */
846
- cleanPtyOutputForChat(raw) {
847
- const text = this.stripAnsiSequences(raw);
848
- const lines = text.split("\n").map(l => l.trim()).filter(Boolean);
849
- const cleanLines = lines.filter(line => {
850
- // Noise filters (same as frontend parseMessages but server-side)
851
- if (line.startsWith("────"))
852
- return false;
853
- if (line === "❯")
854
- return false;
855
- if (line.startsWith("❯"))
856
- return false;
857
- if (line.includes("esc to interrupt"))
858
- return false;
859
- if (line.includes("Claude Code v"))
860
- return false;
861
- if (/^Sonnet\b/.test(line))
862
- return false;
863
- if (line.startsWith("~/"))
864
- return false;
865
- if (line.includes("● high"))
866
- return false;
867
- if (line.includes("Failed to install Anthropic"))
868
- return false;
869
- if (line.includes("Claude Code has switched"))
870
- return false;
871
- if (line.includes("Fluttering"))
872
- return false;
873
- if (line.includes("? for shortcuts"))
874
- return false;
875
- if (line.startsWith("0;") || line.startsWith("9;"))
876
- return false;
877
- if (line.includes("Claude is waiting"))
878
- return false;
879
- if (/[✢✳✶✻✽]/.test(line))
880
- return false;
881
- if (/^[▐▝▘]/.test(line))
882
- return false;
883
- if (["lu", "ue", "tr", "ti", "g", "n", "i…", "…", "uts", "lt", "rg", "·"].includes(line) && line.length < 4)
884
- return false;
885
- if (line.startsWith("✽F") || line.startsWith("✻F"))
886
- return false;
887
- if (line.includes("[wand]"))
888
- return false;
889
- if (line.includes("Captured Claude session ID"))
890
- return false;
891
- if (line.includes("⏵"))
892
- return false;
893
- if (line.includes("acceptedit"))
894
- return false;
895
- if (line.includes("shift+tab"))
896
- return false;
897
- if (line.includes("tabtocycle"))
898
- return false;
899
- if (line.includes("ctrl+g"))
900
- return false;
901
- if (line.includes("/effort"))
902
- return false;
903
- if (line.includes("Opus") && line.includes("model"))
904
- return false;
905
- if (line.includes("Haiku"))
906
- return false;
907
- if (line.includes("to cycle"))
908
- return false;
909
- if (/\bhigh\s*·/.test(line) || /\bmedium\s*·/.test(line) || /\blow\s*·/.test(line))
910
- return false;
911
- if (line.includes("thinking with"))
912
- return false;
913
- if (/^thought for \d+/.test(line))
914
- return false;
915
- if (line.includes("Germinating") || line.includes("Doodling") || line.includes("Brewing"))
916
- return false;
917
- if (line.includes("npm WARN") || line.includes("npm notice"))
918
- return false;
919
- if (/^Using .* for .* session/.test(line))
920
- return false;
921
- if (line.includes("Permissions") && line.includes("mode"))
922
- return false;
923
- if (line.includes("You can use"))
924
- return false;
925
- if (line.startsWith("Press ") && line.includes(" for"))
926
- return false;
927
- if (line.startsWith("type ") && line.includes(" to "))
928
- return false;
929
- if (line.length < 3 && !/^[a-zA-Z]{3}$/.test(line))
930
- return false;
931
- // Strip bullet prefix and keep content
932
- if (line.startsWith("●")) {
933
- return line.slice(1).trim().length > 0;
934
- }
935
- return true;
936
- }).map(line => {
937
- // Clean bullet prefix
938
- if (line.startsWith("●"))
939
- return line.slice(1).trim();
940
- // Clean ⏺ prefix (Claude TUI response marker)
941
- if (line.startsWith("⏺"))
942
- return line.slice(1).trim();
943
- return line;
944
- });
945
- return cleanLines.join("\n").trim();
437
+ // Debounce task changes by 100ms to avoid flickering on rapid tool switches
438
+ record.taskDebounceTimer = setTimeout(() => {
439
+ record.taskDebounceTimer = null;
440
+ record.currentTask = task;
441
+ record.lastEmittedTask = task.title;
442
+ this.emitEvent({ type: "task", sessionId: record.id, data: task });
443
+ }, 100);
946
444
  }
947
445
  resize(id, cols, rows) {
948
446
  const record = this.mustGet(id);
@@ -959,14 +457,19 @@ Begin now:`;
959
457
  if (record.status !== "running") {
960
458
  return this.snapshot(record);
961
459
  }
460
+ // Clear any pending task debounce timer
461
+ if (record.taskDebounceTimer) {
462
+ clearTimeout(record.taskDebounceTimer);
463
+ record.taskDebounceTimer = null;
464
+ }
962
465
  try {
963
466
  record.stopRequested = true;
964
- // For native / managed mode, kill the child process
965
- if ((record.mode === "native" || record.mode === "managed") && record.childProcess) {
467
+ // Kill any running child process (from JSON chat turns)
468
+ if (record.childProcess) {
966
469
  record.childProcess.kill();
967
470
  record.childProcess = null;
968
471
  }
969
- // For PTY mode, kill the pty process
472
+ // Kill the PTY process
970
473
  if (record.ptyProcess) {
971
474
  record.ptyProcess.kill();
972
475
  }
@@ -976,18 +479,33 @@ Begin now:`;
976
479
  record.endedAt = new Date().toISOString();
977
480
  record.output += "\n[wand] Failed to stop session cleanly.\n";
978
481
  }
482
+ // Immediately update status and clear PTY references so the session no longer
483
+ // appears "running" and subsequent sendInput() calls are rejected cleanly.
484
+ // The async onExit handler will re-persist but will find stopRequested already true.
485
+ record.status = "stopped";
486
+ record.exitCode = null;
487
+ record.endedAt = new Date().toISOString();
488
+ record.ptyProcess = null;
489
+ record.ptyBridge = null;
490
+ // Update lifecycle
491
+ this.lifecycleManager.archive(id, "Session stopped by user", "user");
979
492
  this.persist(record);
980
493
  return this.snapshot(record);
981
494
  }
982
495
  delete(id) {
983
496
  const record = this.mustGet(id);
497
+ // Always clear pending timers
498
+ if (record.taskDebounceTimer) {
499
+ clearTimeout(record.taskDebounceTimer);
500
+ record.taskDebounceTimer = null;
501
+ }
502
+ // Kill live processes if still running
984
503
  if (record.status === "running") {
985
504
  try {
986
505
  record.stopRequested = true;
987
506
  // For native mode, kill the child process
988
507
  if ((record.mode === "native" || record.mode === "managed") && record.childProcess) {
989
508
  record.childProcess.kill();
990
- record.childProcess = null;
991
509
  }
992
510
  // For PTY mode, kill the pty process
993
511
  if (record.ptyProcess) {
@@ -998,6 +516,10 @@ Begin now:`;
998
516
  // Ignore and continue deleting persisted state.
999
517
  }
1000
518
  }
519
+ // Always clean up all state references, regardless of current status
520
+ record.childProcess = null;
521
+ record.ptyProcess = null;
522
+ record.ptyBridge = null;
1001
523
  this.sessions.delete(id);
1002
524
  this.storage.deleteSession(id);
1003
525
  }
@@ -1005,11 +527,16 @@ Begin now:`;
1005
527
  return this.config.startupCommands.map((command) => this.start(command, this.config.defaultCwd, this.config.defaultMode));
1006
528
  }
1007
529
  snapshot(record) {
530
+ // Get messages from bridge if available, otherwise use stored messages
531
+ const messages = record.ptyBridge?.getMessages() ?? record.messages;
1008
532
  return {
1009
533
  id: record.id,
1010
534
  command: record.command,
1011
535
  cwd: record.cwd,
1012
536
  mode: record.mode,
537
+ autonomyPolicy: record.autonomyPolicy,
538
+ approvalPolicy: record.approvalPolicy,
539
+ allowedScopes: record.allowedScopes,
1013
540
  status: record.status,
1014
541
  exitCode: record.exitCode,
1015
542
  startedAt: record.startedAt,
@@ -1017,15 +544,117 @@ Begin now:`;
1017
544
  output: record.output,
1018
545
  archived: record.archived,
1019
546
  archivedAt: record.archivedAt,
1020
- claudeSessionId: record.claudeSessionId,
1021
- messages: record.messages.length > 0 ? record.messages : undefined
547
+ permissionBlocked: this.isPermissionBlocked(record),
548
+ pendingEscalation: record.pendingEscalation || undefined,
549
+ lastEscalationResult: record.lastEscalationResult || undefined,
550
+ claudeSessionId: record.claudeSessionId || null,
551
+ messages: messages.length > 0 ? messages : undefined
1022
552
  };
1023
553
  }
554
+ isPermissionBlocked(record) {
555
+ return record.ptyBridge?.isPermissionBlocked() ?? record.pendingEscalation !== null;
556
+ }
557
+ defaultAutonomyPolicy(mode) {
558
+ if (mode === "agent" || mode === "agent-max" || mode === "managed" || mode === "native" || mode === "full-access") {
559
+ return "agent";
560
+ }
561
+ return "assist";
562
+ }
563
+ resolveEscalation(id, requestId, resolution) {
564
+ const record = this.mustGet(id);
565
+ const escalation = record.pendingEscalation;
566
+ if (!escalation || escalation.requestId !== requestId) {
567
+ throw new Error("Escalation request not found.");
568
+ }
569
+ const finalResolution = resolution ?? "approve_once";
570
+ record.lastEscalationResult = {
571
+ requestId,
572
+ resolution: finalResolution,
573
+ reason: escalation.reason
574
+ };
575
+ if (finalResolution === "deny") {
576
+ record.pendingEscalation = null;
577
+ if (record.ptyProcess && record.status === "running") {
578
+ record.ptyProcess.write("n\r");
579
+ }
580
+ record.ptyPermissionBlocked = false;
581
+ this.persist(record);
582
+ return this.snapshot(record);
583
+ }
584
+ if (finalResolution === "approve_turn") {
585
+ record.rememberedEscalationScopes.add(escalation.scope);
586
+ if (escalation.target) {
587
+ record.rememberedEscalationTargets.add(escalation.target);
588
+ }
589
+ }
590
+ record.pendingEscalation = null;
591
+ record.ptyPermissionBlocked = false;
592
+ if (record.ptyProcess && record.status === "running") {
593
+ record.ptyProcess.write("\r");
594
+ }
595
+ this.persist(record);
596
+ return this.snapshot(record);
597
+ }
598
+ approvePermission(id) {
599
+ const record = this.mustGet(id);
600
+ // Use bridge for permission resolution
601
+ if (record.ptyBridge) {
602
+ record.ptyBridge.resolvePermission("approve_once");
603
+ }
604
+ else if (record.ptyProcess && record.status === "running") {
605
+ record.ptyProcess.write("\r");
606
+ }
607
+ record.ptyPermissionBlocked = false;
608
+ record.pendingEscalation = null;
609
+ this.persist(record);
610
+ return this.snapshot(record);
611
+ }
612
+ denyPermission(id) {
613
+ const record = this.mustGet(id);
614
+ // Use bridge for permission resolution
615
+ if (record.ptyBridge) {
616
+ record.ptyBridge.resolvePermission("deny");
617
+ }
618
+ else if (record.ptyProcess && record.status === "running") {
619
+ record.ptyProcess.write("n\r");
620
+ }
621
+ record.ptyPermissionBlocked = false;
622
+ record.pendingEscalation = null;
623
+ this.persist(record);
624
+ return this.snapshot(record);
625
+ }
626
+ /**
627
+ * Resolve permission with specific resolution type.
628
+ * @param resolution - "approve_once", "approve_turn", or "deny"
629
+ */
630
+ resolvePermission(id, resolution) {
631
+ const record = this.mustGet(id);
632
+ if (record.ptyBridge) {
633
+ record.ptyBridge.resolvePermission(resolution);
634
+ }
635
+ else if (record.ptyProcess && record.status === "running") {
636
+ if (resolution === "deny") {
637
+ record.ptyProcess.write("n\r");
638
+ }
639
+ else {
640
+ record.ptyProcess.write("\r");
641
+ }
642
+ }
643
+ record.ptyPermissionBlocked = false;
644
+ record.pendingEscalation = null;
645
+ this.persist(record);
646
+ return this.snapshot(record);
647
+ }
1024
648
  persist(record) {
649
+ // Update messages from bridge before persisting
650
+ const messages = record.ptyBridge?.getMessages() ?? record.messages;
651
+ if (messages !== record.messages) {
652
+ record.messages = messages;
653
+ }
1025
654
  this.storage.saveSession(this.snapshot(record));
1026
655
  // Save structured messages to file for analysis
1027
- if (record.messages.length > 0) {
1028
- this.logger.saveMessages(record.id, record.messages);
656
+ if (messages.length > 0) {
657
+ this.logger.saveMessages(record.id, messages);
1029
658
  }
1030
659
  }
1031
660
  archiveExpiredSessions() {
@@ -1054,6 +683,9 @@ Begin now:`;
1054
683
  }
1055
684
  }
1056
685
  autoConfirmWithRecord(record, output, ptyProcess) {
686
+ if (!record.autoApprovePermissions) {
687
+ return;
688
+ }
1057
689
  record.confirmWindow = appendWindow(record.confirmWindow, output, CONFIRM_WINDOW_SIZE);
1058
690
  const normalized = normalizePromptText(record.confirmWindow);
1059
691
  const now = Date.now();
@@ -1073,21 +705,101 @@ Begin now:`;
1073
705
  const shouldConfirm = trustFolderPrompt || claudeConfirmPrompt || toolPermissionPrompt || PROMPT_PATTERNS.some((pattern) => pattern.test(normalized));
1074
706
  if (shouldConfirm) {
1075
707
  record.lastAutoConfirmAt = now;
1076
- process.stderr.write(`[wand] Auto-confirming prompt in full-access mode\n`);
1077
- // For Claude Code's selection UI, Enter confirms the selected option
1078
- // For other prompts, "y" + Enter confirms
1079
- if (trustFolderPrompt || claudeConfirmPrompt || toolPermissionPrompt || isSelectionPrompt) {
1080
- ptyProcess.write("\r");
1081
- }
1082
- else {
1083
- ptyProcess.write("y\r");
708
+ process.stderr.write(`[wand] Auto-confirming prompt for ${record.mode} mode\n`);
709
+ // Always auto-confirm by sending Enter directly
710
+ ptyProcess.write("\r");
711
+ }
712
+ }
713
+ /**
714
+ * Handle events from ClaudePtyBridge
715
+ */
716
+ handleBridgeEvent(record, event) {
717
+ switch (event.type) {
718
+ case "output.raw":
719
+ // Emit output event for terminal view
720
+ this.emitEvent({
721
+ type: "output",
722
+ sessionId: event.sessionId,
723
+ data: {
724
+ chunk: event.data.chunk,
725
+ output: record.output,
726
+ messages: record.ptyBridge?.getMessages(),
727
+ permissionBlocked: this.isPermissionBlocked(record),
728
+ },
729
+ });
730
+ break;
731
+ case "output.chat":
732
+ // Emit output event with updated messages for chat view
733
+ this.emitEvent({
734
+ type: "output",
735
+ sessionId: event.sessionId,
736
+ data: {
737
+ output: record.output,
738
+ messages: record.ptyBridge?.getMessages(),
739
+ permissionBlocked: this.isPermissionBlocked(record),
740
+ },
741
+ });
742
+ break;
743
+ case "permission.prompt": {
744
+ const data = event.data;
745
+ record.pendingEscalation = {
746
+ requestId: `bridge-${Date.now()}`,
747
+ scope: data.scope,
748
+ runner: "pty",
749
+ source: "tool_permission_request",
750
+ target: data.target,
751
+ reason: data.prompt,
752
+ };
753
+ record.ptyPermissionBlocked = true;
754
+ // Emit status event with full permission details for UI
755
+ this.emitEvent({
756
+ type: "status",
757
+ sessionId: event.sessionId,
758
+ data: {
759
+ permissionBlocked: true,
760
+ permissionRequest: {
761
+ scope: data.scope,
762
+ target: data.target,
763
+ prompt: data.prompt,
764
+ },
765
+ },
766
+ });
767
+ break;
1084
768
  }
769
+ case "permission.resolved":
770
+ record.pendingEscalation = null;
771
+ record.ptyPermissionBlocked = false;
772
+ this.emitEvent({
773
+ type: "status",
774
+ sessionId: event.sessionId,
775
+ data: { permissionBlocked: false },
776
+ });
777
+ break;
778
+ case "session.id":
779
+ // Claude session ID captured - already handled in onData
780
+ break;
781
+ case "chat.turn":
782
+ // Turn completed - persist messages
783
+ record.messages = record.ptyBridge?.getMessages() ?? record.messages;
784
+ this.lifecycleManager.stopThinking(record.id);
785
+ this.lifecycleManager.waitingInput(record.id);
786
+ this.persist(record);
787
+ break;
788
+ case "ended":
789
+ // Session ended - handled in onExit
790
+ break;
1085
791
  }
1086
792
  }
793
+ /** Check if a command is a Claude CLI command */
794
+ isClaudeCommand(command) {
795
+ const trimmed = command.trim();
796
+ return /^claude\b/.test(trimmed);
797
+ }
1087
798
  mustGet(id) {
1088
799
  const record = this.sessions.get(id);
1089
800
  if (!record) {
1090
- throw new Error("Session not found.");
801
+ console.error("[ProcessManager] Session lookup failed", { sessionId: id });
802
+ throw new SessionInputError("Session not found.", "SESSION_NOT_FOUND", id);
1091
803
  }
1092
804
  return record;
1093
805
  }
@@ -1095,20 +807,34 @@ Begin now:`;
1095
807
  if (os.platform() === "win32") {
1096
808
  return ["/d", "/s", "/c", command];
1097
809
  }
1098
- return ["-ic", command];
810
+ // -l: login shell — sources ~/.bash_profile, ~/.profile, etc., ensuring PATH
811
+ // and other env vars set by profile files are available.
812
+ // -c: run the following command.
813
+ // Using -ic (interactive + command) skips login-shell initialization on many
814
+ // platforms, which causes commands that depend on profile-set env vars to fail
815
+ // immediately with "command not found" — a silent exit before onExit is ready.
816
+ return ["-lc", command];
1099
817
  }
1100
- processCommandForMode(command, mode) {
1101
- // For full-access mode with claude commands, add permission flags (skip for root)
1102
- if (mode === "full-access" && /^claude(?:\s|$)/.test(command) && !isRunningAsRoot()) {
1103
- // Check if permission-mode is already specified
1104
- if (!/--permission-mode\b/.test(command)) {
1105
- // Add --permission-mode bypassPermissions for full-access mode
1106
- if (command === "claude") {
1107
- return "claude --permission-mode bypassPermissions";
1108
- }
1109
- return command.replace(/^claude\s/, "claude --permission-mode bypassPermissions ");
1110
- }
818
+ shouldAutoApprovePermissions(command, mode) {
819
+ if (!/^claude(?:\s|$)/.test(command)) {
820
+ return false;
1111
821
  }
822
+ // Root mode: always auto-approve (Claude CLI refuses --permission-mode bypassPermissions under root)
823
+ if (isRunningAsRoot()) {
824
+ return true;
825
+ }
826
+ if (mode === "full-access" || mode === "auto-edit") {
827
+ return true;
828
+ }
829
+ if (mode === "managed" || mode === "native") {
830
+ return true;
831
+ }
832
+ return false;
833
+ }
834
+ processCommandForMode(command, _mode) {
835
+ // Don't automatically add --enable-auto-mode as it may not be available
836
+ // for all plans and can cause issues with normal interactive mode.
837
+ // Let users specify it explicitly if they want auto mode.
1112
838
  return command;
1113
839
  }
1114
840
  }
@@ -1121,9 +847,6 @@ function normalizePromptText(value) {
1121
847
  .replace(/\n+/g, "\n")
1122
848
  .trim();
1123
849
  }
1124
- function normalizePtyOutput(value) {
1125
- return value.replace(/\r\r\n/g, "\r\n");
1126
- }
1127
850
  function clampDimension(value, min, max) {
1128
851
  if (!Number.isFinite(value)) {
1129
852
  return min;