@co0ontty/wand 0.2.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.
@@ -0,0 +1,1132 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { EventEmitter } from "node:events";
3
+ import { spawn } from "node:child_process";
4
+ import path from "node:path";
5
+ import process from "node:process";
6
+ import os from "node:os";
7
+ import pty from "node-pty";
8
+ import { SessionLogger } from "./session-logger.js";
9
+ /** Check if running as root (uid 0) */
10
+ function isRunningAsRoot() {
11
+ return process.getuid?.() === 0 || process.geteuid?.() === 0;
12
+ }
13
+ const PROMPT_PATTERNS = [
14
+ /(?:^|\b)(?:press\s+)?(?:y|yes)\s*(?:\/|\bor\b)\s*(?:n|no)(?:\b|$)/i,
15
+ /\[(?:y|yes)\s*\/\s*(?:n|no)\]/i,
16
+ /\((?:y|yes)\s*\/\s*(?:n|no)\)/i,
17
+ /\((?:y|yes)\s*\/\s*(?:n|no)\s*\/\s*always\)/i,
18
+ /\bcontinue\?\s*(?:\((?:y|yes)\s*\/\s*(?:n|no)\))?/i,
19
+ /\bare you sure\??/i,
20
+ /\bdo you want to continue\??/i,
21
+ /\bdo you want to (?:create|write|delete|modify|execute)/i,
22
+ /\bconfirm(?:\s+execution|\s+changes|\s+action)?\??/i,
23
+ /\bproceed\??/i,
24
+ /\benter to confirm\b/i,
25
+ /\bwould you like to\b/i,
26
+ /\bshall i\b/i,
27
+ /\bcan i\b/i,
28
+ /\bpermission\b/i,
29
+ /\bgrant\b.*\bpermission\b/i
30
+ ];
31
+ // Patterns that indicate a selection-based prompt (needs Enter, not 'y')
32
+ const SELECTION_PROMPT_PATTERNS = [
33
+ /\bwould you like to\b/i,
34
+ /\bgrant.*permission\b/i,
35
+ /\bpermission\b/i,
36
+ /\btrust.*folder\b/i,
37
+ /\bconfirm\b/i
38
+ ];
39
+ const MAX_SESSIONS = 50;
40
+ const ARCHIVE_AFTER_MS = 1000 * 60 * 60 * 24;
41
+ const OUTPUT_WINDOW_SIZE = 4096;
42
+ const CONFIRM_WINDOW_SIZE = 800;
43
+ const OUTPUT_MAX_SIZE = 120000;
44
+ // Claude 会话 ID 格式:UUID v4
45
+ 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
+ /** Append text to a windowed buffer, trimming from start if over max size. */
47
+ function appendWindow(buffer, chunk, maxSize) {
48
+ const next = buffer + chunk;
49
+ return next.length > maxSize ? next.slice(-maxSize) : next;
50
+ }
51
+ export class ProcessManager extends EventEmitter {
52
+ config;
53
+ storage;
54
+ sessions = new Map();
55
+ logger;
56
+ constructor(config, storage, configDir) {
57
+ super();
58
+ this.config = config;
59
+ this.storage = storage;
60
+ this.logger = new SessionLogger(configDir || path.join(process.env.HOME || process.cwd(), ".wand"));
61
+ 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
+ });
79
+ }
80
+ this.archiveExpiredSessions();
81
+ }
82
+ on(event, listener) {
83
+ return super.on("process", listener);
84
+ }
85
+ emitEvent(event) {
86
+ this.emit("process", event);
87
+ }
88
+ cleanupOldSessions() {
89
+ // Remove oldest finished sessions if we're at the limit
90
+ if (this.sessions.size < MAX_SESSIONS)
91
+ return;
92
+ const finishedIds = [];
93
+ for (const [id, record] of this.sessions) {
94
+ if (record.status !== "running") {
95
+ finishedIds.push(id);
96
+ }
97
+ }
98
+ // Remove oldest finished sessions first
99
+ finishedIds
100
+ .sort((a, b) => {
101
+ const ra = this.sessions.get(a);
102
+ const rb = this.sessions.get(b);
103
+ return (ra?.endedAt || "").localeCompare(rb?.endedAt || "");
104
+ })
105
+ .slice(0, this.sessions.size - MAX_SESSIONS + 1)
106
+ .forEach((id) => {
107
+ this.sessions.delete(id);
108
+ this.storage.deleteSession(id);
109
+ });
110
+ }
111
+ start(command, cwd, mode, initialInput) {
112
+ this.assertCommandAllowed(command);
113
+ const resolvedCwd = cwd
114
+ ? path.resolve(process.cwd(), cwd)
115
+ : path.resolve(process.cwd(), this.config.defaultCwd);
116
+ // For full-access mode with claude, add permission flags
117
+ const processedCommand = this.processCommandForMode(command, mode);
118
+ const id = randomUUID();
119
+ const record = {
120
+ id,
121
+ command,
122
+ cwd: resolvedCwd,
123
+ mode,
124
+ status: "running",
125
+ exitCode: null,
126
+ startedAt: new Date().toISOString(),
127
+ endedAt: null,
128
+ output: "",
129
+ archived: false,
130
+ archivedAt: null,
131
+ claudeSessionId: null,
132
+ processId: null,
133
+ ptyProcess: null,
134
+ stopRequested: false,
135
+ confirmWindow: "",
136
+ lastAutoConfirmAt: 0,
137
+ sessionIdWindow: "",
138
+ storedOutput: "",
139
+ messages: [],
140
+ jsonChatBusy: false,
141
+ childProcess: null,
142
+ ptyChatState: "idle",
143
+ ptyAssistantBuffer: "",
144
+ ptyLastUserInput: "",
145
+ ptyEchoSkipped: false
146
+ };
147
+ this.sessions.set(id, record);
148
+ this.persist(record);
149
+ this.cleanupOldSessions();
150
+ // Emit started event
151
+ this.emitEvent({ type: "started", sessionId: id, data: this.snapshot(record) });
152
+ // For native mode, skip PTY creation — sendInput() 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);
162
+ }
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);
170
+ return this.snapshot(record);
171
+ }
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
+ record.processId = child.pid;
186
+ record.ptyProcess = child;
187
+ let initialInputSent = false;
188
+ const sendInitialInput = () => {
189
+ if (initialInputSent || !initialInput)
190
+ return;
191
+ initialInputSent = true;
192
+ const current = this.sessions.get(id);
193
+ if (!current || !current.ptyProcess || current.status !== "running") {
194
+ process.stderr.write(`[wand] Cannot send initial input: session not ready\n`);
195
+ return;
196
+ }
197
+ 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;
213
+ }
214
+ current.ptyProcess.write(initialInput);
215
+ // \n advances to a new line so subsequent output doesn't overwrite this input
216
+ current.ptyProcess.write("\n");
217
+ };
218
+ // Debounce rapid PTY output events to reduce WebSocket flooding
219
+ let outputDebounceTimer = null;
220
+ let pendingChunk = "";
221
+ child.onData((chunk) => {
222
+ const rec = this.sessions.get(id);
223
+ if (!rec)
224
+ return;
225
+ rec.output = appendWindow(rec.output, normalizePtyOutput(chunk), OUTPUT_MAX_SIZE);
226
+ // Log raw PTY output for analysis
227
+ 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") {
239
+ this.autoConfirmWithRecord(rec, chunk, child);
240
+ }
241
+ if (initialInput && !initialInputSent && chunk.includes("❯")) {
242
+ sendInitialInput();
243
+ }
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) });
285
+ });
286
+ if (initialInput) {
287
+ setTimeout(() => {
288
+ if (!initialInputSent)
289
+ sendInitialInput();
290
+ }, 3000);
291
+ }
292
+ return this.snapshot(record);
293
+ }
294
+ list() {
295
+ this.archiveExpiredSessions();
296
+ return Array.from(this.sessions.values())
297
+ .sort((a, b) => b.startedAt.localeCompare(a.startedAt))
298
+ .map((session) => this.snapshot(session));
299
+ }
300
+ get(id) {
301
+ this.archiveExpiredSessions();
302
+ const record = this.sessions.get(id);
303
+ if (!record)
304
+ return null;
305
+ // For sessions loaded from storage on startup, in-memory output starts empty.
306
+ // Prefer in-memory output (live PTY data), fall back to stored output.
307
+ if (!record.output && record.storedOutput) {
308
+ record.output = record.storedOutput;
309
+ }
310
+ return this.snapshot(record);
311
+ }
312
+ sendInput(id, input, view) {
313
+ 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 }]
340
+ });
341
+ // Add assistant placeholder for streaming updates
342
+ record.messages.push({
343
+ role: "assistant",
344
+ content: []
345
+ });
346
+ record.ptyChatState = "responding";
347
+ record.ptyAssistantBuffer = "";
348
+ record.ptyLastUserInput = cleanInput;
349
+ record.ptyEchoSkipped = false;
350
+ }
351
+ // Ensure input advances to a new line so subsequent PTY output doesn't overwrite it
352
+ record.ptyProcess.write(input);
353
+ if (!input.endsWith("\n")) {
354
+ record.ptyProcess.write("\n");
355
+ }
356
+ this.persist(record);
357
+ return this.snapshot(record);
358
+ }
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
+ }
812
+ }
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")
820
+ 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")
830
+ 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
+ }
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();
946
+ }
947
+ resize(id, cols, rows) {
948
+ const record = this.mustGet(id);
949
+ if (!record.ptyProcess || record.status !== "running") {
950
+ return this.snapshot(record);
951
+ }
952
+ const safeCols = clampDimension(cols, 20, 400);
953
+ const safeRows = clampDimension(rows, 10, 160);
954
+ record.ptyProcess.resize(safeCols, safeRows);
955
+ return this.snapshot(record);
956
+ }
957
+ stop(id) {
958
+ const record = this.mustGet(id);
959
+ if (record.status !== "running") {
960
+ return this.snapshot(record);
961
+ }
962
+ try {
963
+ record.stopRequested = true;
964
+ // For native / managed mode, kill the child process
965
+ if ((record.mode === "native" || record.mode === "managed") && record.childProcess) {
966
+ record.childProcess.kill();
967
+ record.childProcess = null;
968
+ }
969
+ // For PTY mode, kill the pty process
970
+ if (record.ptyProcess) {
971
+ record.ptyProcess.kill();
972
+ }
973
+ }
974
+ catch {
975
+ record.status = "failed";
976
+ record.endedAt = new Date().toISOString();
977
+ record.output += "\n[wand] Failed to stop session cleanly.\n";
978
+ }
979
+ this.persist(record);
980
+ return this.snapshot(record);
981
+ }
982
+ delete(id) {
983
+ const record = this.mustGet(id);
984
+ if (record.status === "running") {
985
+ try {
986
+ record.stopRequested = true;
987
+ // For native mode, kill the child process
988
+ if ((record.mode === "native" || record.mode === "managed") && record.childProcess) {
989
+ record.childProcess.kill();
990
+ record.childProcess = null;
991
+ }
992
+ // For PTY mode, kill the pty process
993
+ if (record.ptyProcess) {
994
+ record.ptyProcess.kill();
995
+ }
996
+ }
997
+ catch {
998
+ // Ignore and continue deleting persisted state.
999
+ }
1000
+ }
1001
+ this.sessions.delete(id);
1002
+ this.storage.deleteSession(id);
1003
+ }
1004
+ async runStartupCommands() {
1005
+ return this.config.startupCommands.map((command) => this.start(command, this.config.defaultCwd, this.config.defaultMode));
1006
+ }
1007
+ snapshot(record) {
1008
+ return {
1009
+ id: record.id,
1010
+ command: record.command,
1011
+ cwd: record.cwd,
1012
+ mode: record.mode,
1013
+ status: record.status,
1014
+ exitCode: record.exitCode,
1015
+ startedAt: record.startedAt,
1016
+ endedAt: record.endedAt,
1017
+ output: record.output,
1018
+ archived: record.archived,
1019
+ archivedAt: record.archivedAt,
1020
+ claudeSessionId: record.claudeSessionId,
1021
+ messages: record.messages.length > 0 ? record.messages : undefined
1022
+ };
1023
+ }
1024
+ persist(record) {
1025
+ this.storage.saveSession(this.snapshot(record));
1026
+ // Save structured messages to file for analysis
1027
+ if (record.messages.length > 0) {
1028
+ this.logger.saveMessages(record.id, record.messages);
1029
+ }
1030
+ }
1031
+ archiveExpiredSessions() {
1032
+ const now = Date.now();
1033
+ for (const record of this.sessions.values()) {
1034
+ if (record.archived || record.status === "running") {
1035
+ continue;
1036
+ }
1037
+ const referenceTime = record.endedAt ?? record.startedAt;
1038
+ const endedAtMs = Date.parse(referenceTime);
1039
+ if (!Number.isFinite(endedAtMs) || now - endedAtMs < ARCHIVE_AFTER_MS) {
1040
+ continue;
1041
+ }
1042
+ record.archived = true;
1043
+ record.archivedAt = new Date(now).toISOString();
1044
+ this.persist(record);
1045
+ }
1046
+ }
1047
+ assertCommandAllowed(command) {
1048
+ if (this.config.allowedCommandPrefixes.length === 0) {
1049
+ return;
1050
+ }
1051
+ const isAllowed = this.config.allowedCommandPrefixes.some((prefix) => command.startsWith(prefix));
1052
+ if (!isAllowed) {
1053
+ throw new Error("Command is not allowed by current configuration.");
1054
+ }
1055
+ }
1056
+ autoConfirmWithRecord(record, output, ptyProcess) {
1057
+ record.confirmWindow = appendWindow(record.confirmWindow, output, CONFIRM_WINDOW_SIZE);
1058
+ const normalized = normalizePromptText(record.confirmWindow);
1059
+ const now = Date.now();
1060
+ const trustFolderPrompt = /\byes,\s*i\s*trust\s*this\s*folder\b/i.test(normalized) &&
1061
+ /\benter to confirm\b/i.test(normalized);
1062
+ const claudeConfirmPrompt = /\bdo you want to\b/i.test(normalized) &&
1063
+ /\byes\b/i.test(normalized);
1064
+ // Check for Claude's tool permission prompt patterns
1065
+ const toolPermissionPrompt = /\bdo you want to\b/i.test(normalized) &&
1066
+ /\(yes\b/i.test(normalized);
1067
+ // Check if this is a selection-based prompt (needs Enter, not 'y')
1068
+ const isSelectionPrompt = SELECTION_PROMPT_PATTERNS.some((pattern) => pattern.test(normalized));
1069
+ // Reduced cooldown for faster response
1070
+ if (now - record.lastAutoConfirmAt < 500) {
1071
+ return;
1072
+ }
1073
+ const shouldConfirm = trustFolderPrompt || claudeConfirmPrompt || toolPermissionPrompt || PROMPT_PATTERNS.some((pattern) => pattern.test(normalized));
1074
+ if (shouldConfirm) {
1075
+ 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");
1084
+ }
1085
+ }
1086
+ }
1087
+ mustGet(id) {
1088
+ const record = this.sessions.get(id);
1089
+ if (!record) {
1090
+ throw new Error("Session not found.");
1091
+ }
1092
+ return record;
1093
+ }
1094
+ buildShellArgs(command) {
1095
+ if (os.platform() === "win32") {
1096
+ return ["/d", "/s", "/c", command];
1097
+ }
1098
+ return ["-ic", command];
1099
+ }
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
+ }
1111
+ }
1112
+ return command;
1113
+ }
1114
+ }
1115
+ function normalizePromptText(value) {
1116
+ return value
1117
+ .replace(/\u001b\[(\d+)C/g, (_match, count) => " ".repeat(Number(count) || 1))
1118
+ .replace(/\u001b\[[0-9;?]*[ -/]*[@-~]/g, "")
1119
+ .replace(/\r/g, "\n")
1120
+ .replace(/[ \t]+/g, " ")
1121
+ .replace(/\n+/g, "\n")
1122
+ .trim();
1123
+ }
1124
+ function normalizePtyOutput(value) {
1125
+ return value.replace(/\r\r\n/g, "\r\n");
1126
+ }
1127
+ function clampDimension(value, min, max) {
1128
+ if (!Number.isFinite(value)) {
1129
+ return min;
1130
+ }
1131
+ return Math.min(max, Math.max(min, Math.floor(value)));
1132
+ }