@co0ontty/wand 0.2.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +25 -5
  2. package/dist/acp-protocol.d.ts +67 -0
  3. package/dist/acp-protocol.js +291 -0
  4. package/dist/avatar.d.ts +14 -0
  5. package/dist/avatar.js +110 -0
  6. package/dist/claude-pty-bridge.d.ts +137 -0
  7. package/dist/claude-pty-bridge.js +619 -0
  8. package/dist/claude-stream-adapter.d.ts +35 -0
  9. package/dist/claude-stream-adapter.js +153 -0
  10. package/dist/claude-structured-runner.d.ts +27 -0
  11. package/dist/claude-structured-runner.js +106 -0
  12. package/dist/cli.d.ts +1 -1
  13. package/dist/cli.js +10 -2
  14. package/dist/config.js +8 -4
  15. package/dist/message-parser.js +16 -150
  16. package/dist/message-queue.d.ts +57 -0
  17. package/dist/message-queue.js +127 -0
  18. package/dist/middleware/path-safety.d.ts +6 -0
  19. package/dist/middleware/path-safety.js +19 -0
  20. package/dist/middleware/rate-limit.d.ts +8 -0
  21. package/dist/middleware/rate-limit.js +37 -0
  22. package/dist/process-manager.d.ts +82 -27
  23. package/dist/process-manager.js +1445 -822
  24. package/dist/pty-text-utils.d.ts +13 -0
  25. package/dist/pty-text-utils.js +84 -0
  26. package/dist/pwa.d.ts +5 -0
  27. package/dist/pwa.js +118 -0
  28. package/dist/server.js +511 -409
  29. package/dist/session-lifecycle.d.ts +81 -0
  30. package/dist/session-lifecycle.js +181 -0
  31. package/dist/session-logger.d.ts +13 -3
  32. package/dist/session-logger.js +56 -5
  33. package/dist/storage.d.ts +9 -0
  34. package/dist/storage.js +73 -7
  35. package/dist/types.d.ts +112 -6
  36. package/dist/web-ui/content/icon-192.png +0 -0
  37. package/dist/web-ui/content/icon-512.png +0 -0
  38. package/dist/web-ui/content/scripts.js +3770 -852
  39. package/dist/web-ui/content/styles.css +5505 -2779
  40. package/dist/web-ui/index.js +8 -5
  41. package/dist/web-ui/scripts.js +8 -1
  42. package/dist/ws-broadcast.d.ts +27 -0
  43. package/dist/ws-broadcast.js +160 -0
  44. package/package.json +2 -9
  45. package/dist/web-ui/utils.d.ts +0 -4
  46. package/dist/web-ui/utils.js +0 -12
  47. package/dist/web-ui.d.ts +0 -1
  48. package/dist/web-ui.js +0 -2
@@ -1,15 +1,30 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { EventEmitter } from "node:events";
3
- import { spawn } from "node:child_process";
3
+ import { existsSync, unlinkSync, openSync, readSync, closeSync, readFileSync, readdirSync, statSync } from "node:fs";
4
4
  import path from "node:path";
5
5
  import process from "node:process";
6
6
  import os from "node:os";
7
7
  import pty from "node-pty";
8
8
  import { SessionLogger } from "./session-logger.js";
9
- /** Check if running as root (uid 0) */
9
+ import { SessionLifecycleManager } from "./session-lifecycle.js";
10
+ import { ClaudePtyBridge } from "./claude-pty-bridge.js";
11
+ import { appendWindow, normalizePromptText } from "./pty-text-utils.js";
12
+ /** Check if the current process is running as root (UID 0). */
10
13
  function isRunningAsRoot() {
11
14
  return process.getuid?.() === 0 || process.geteuid?.() === 0;
12
15
  }
16
+ export class SessionInputError extends Error {
17
+ code;
18
+ sessionId;
19
+ sessionStatus;
20
+ constructor(message, code, sessionId, sessionStatus) {
21
+ super(message);
22
+ this.code = code;
23
+ this.sessionId = sessionId;
24
+ this.sessionStatus = sessionStatus;
25
+ this.name = "SessionInputError";
26
+ }
27
+ }
13
28
  const PROMPT_PATTERNS = [
14
29
  /(?:^|\b)(?:press\s+)?(?:y|yes)\s*(?:\/|\bor\b)\s*(?:n|no)(?:\b|$)/i,
15
30
  /\[(?:y|yes)\s*\/\s*(?:n|no)\]/i,
@@ -25,58 +40,788 @@ const PROMPT_PATTERNS = [
25
40
  /\bwould you like to\b/i,
26
41
  /\bshall i\b/i,
27
42
  /\bcan i\b/i,
28
- /\bpermission\b/i,
29
43
  /\bgrant\b.*\bpermission\b/i
30
44
  ];
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
- ];
45
+ const REAL_CONVERSATION_MIN_LINES = 2;
46
+ const REAL_CONVERSATION_MIN_MESSAGES = 2;
47
+ const DISCOVERY_RECENT_WINDOW_MS = 10 * 60 * 1000;
48
+ const START_TIME_SKEW_MS = 30 * 1000;
49
+ const RESUME_COMMAND_ID_PATTERN = /(?:^|\s)--resume\s+([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(?:\s|$)/i;
50
+ function hasRealConversationMessages(messages) {
51
+ if (!messages || messages.length < REAL_CONVERSATION_MIN_MESSAGES) {
52
+ return false;
53
+ }
54
+ const hasUser = messages.some((turn) => turn.role === "user"
55
+ && turn.content.some((block) => block.type === "text" && block.text.trim().length > 0));
56
+ const hasAssistant = messages.some((turn) => turn.role === "assistant"
57
+ && turn.content.some((block) => block.type === "text" && block.text.trim().length > 0));
58
+ return hasUser && hasAssistant;
59
+ }
60
+ function getResumeCommandSessionId(command) {
61
+ const match = RESUME_COMMAND_ID_PATTERN.exec(command);
62
+ return match?.[1] ?? null;
63
+ }
64
+ function readClaudeProjectSessionDetails(filePath, id) {
65
+ try {
66
+ const stats = statSync(filePath);
67
+ const raw = readFileSync(filePath, "utf8");
68
+ const lines = raw.split("\n").filter((line) => line.trim().length > 0);
69
+ const fileSessionIds = new Set();
70
+ let hasAssistant = false;
71
+ let hasUser = false;
72
+ for (const line of lines) {
73
+ try {
74
+ const parsed = JSON.parse(line);
75
+ if (parsed.sessionId) {
76
+ fileSessionIds.add(parsed.sessionId);
77
+ }
78
+ if (parsed.type === "user" || parsed.message?.role === "user") {
79
+ hasUser = true;
80
+ }
81
+ if (parsed.type === "assistant" || parsed.message?.role === "assistant") {
82
+ hasAssistant = true;
83
+ }
84
+ }
85
+ catch {
86
+ continue;
87
+ }
88
+ }
89
+ // Only reject if the file explicitly claims a DIFFERENT primary session ID.
90
+ // A resumed session's JSONL may contain multiple session IDs across turns.
91
+ // If no sessionId appears at all (early startup file), don't reject.
92
+ if (fileSessionIds.size > 0 && !fileSessionIds.has(id)) {
93
+ // Check if at least one line references this ID (partial match is ok)
94
+ const hasAnyReference = lines.some((line) => line.includes(`"${id}"`));
95
+ if (!hasAnyReference) {
96
+ return null;
97
+ }
98
+ }
99
+ return {
100
+ id,
101
+ filePath,
102
+ mtimeMs: stats.mtimeMs,
103
+ hasConversation: hasUser && hasAssistant && lines.length >= REAL_CONVERSATION_MIN_LINES
104
+ };
105
+ }
106
+ catch {
107
+ return null;
108
+ }
109
+ }
110
+ function hasRuntimeConversationSignal(messages) {
111
+ if (!messages || messages.length === 0) {
112
+ return false;
113
+ }
114
+ const hasUser = messages.some((turn) => turn.role === "user"
115
+ && turn.content.some((block) => block.type === "text" && block.text.trim().length > 0));
116
+ const hasAssistant = messages.some((turn) => turn.role === "assistant");
117
+ return hasUser && hasAssistant;
118
+ }
119
+ function hasStoredConversationHistory(messages) {
120
+ return hasRealConversationMessages(messages);
121
+ }
122
+ function shouldBindClaudeSessionId(record) {
123
+ return hasRuntimeConversationSignal(record.messages);
124
+ }
125
+ function shouldAllowResume(record) {
126
+ return Boolean(record.claudeSessionId) && hasStoredConversationHistory(record.messages);
127
+ }
128
+ function shouldBackfillFromStoredHistory(record) {
129
+ return hasStoredConversationHistory(record.messages);
130
+ }
131
+ function shouldDisplayResumeAction(messages) {
132
+ return hasStoredConversationHistory(messages);
133
+ }
134
+ function shouldAutoResumeMessages(messages) {
135
+ return hasStoredConversationHistory(messages);
136
+ }
137
+ function shouldBackfillMessages(messages) {
138
+ return hasStoredConversationHistory(messages);
139
+ }
140
+ function shouldPromoteProjectSessionId(record) {
141
+ return shouldBindClaudeSessionId(record);
142
+ }
143
+ function shouldPromoteStoredSessionId(record) {
144
+ return shouldBackfillMessages(record.messages);
145
+ }
146
+ function shouldPromoteUiSessionId(messages) {
147
+ return shouldDisplayResumeAction(messages);
148
+ }
149
+ function shouldPromoteResumeSessionId(messages) {
150
+ return shouldAutoResumeMessages(messages);
151
+ }
152
+ function hasBindableConversation(messages) {
153
+ return shouldBindFromRuntimeMessages({ messages: messages ?? [] });
154
+ }
155
+ function hasBackfillableConversation(messages) {
156
+ return shouldBackfillMessages(messages);
157
+ }
158
+ function hasUiConversation(messages) {
159
+ return shouldPromoteUiSessionId(messages);
160
+ }
161
+ function hasResumeConversation(messages) {
162
+ return shouldPromoteResumeSessionId(messages);
163
+ }
164
+ function isRuntimeConversationReady(messages) {
165
+ return hasBindableConversation(messages);
166
+ }
167
+ function isStoredConversationReady(messages) {
168
+ return hasBackfillableConversation(messages);
169
+ }
170
+ function isResumeConversationReady(messages) {
171
+ return hasResumeConversation(messages);
172
+ }
173
+ function shouldBindFromRuntimeMessages(record) {
174
+ return isRuntimeConversationReady(record.messages);
175
+ }
176
+ function shouldAllowUiResume(messages) {
177
+ return hasUiConversation(messages);
178
+ }
179
+ function shouldPromoteResumeAction(record) {
180
+ return shouldAllowResume(record);
181
+ }
182
+ function shouldBackfillClaudeSessionIdFromDisk(record) {
183
+ return isStoredConversationReady(record.messages);
184
+ }
185
+ function shouldUseProjectCandidate(record) {
186
+ return shouldBindFromRuntimeMessages(record);
187
+ }
188
+ function shouldResumeProjectCandidate(record) {
189
+ return shouldPromoteResumeAction(record);
190
+ }
191
+ function shouldBackfillProjectCandidate(record) {
192
+ return shouldBackfillClaudeSessionIdFromDisk(record);
193
+ }
194
+ function hasMinimumRuntimeConversation(messages) {
195
+ return shouldBindFromRuntimeMessages({ messages: messages ?? [] });
196
+ }
197
+ function hasMinimumStoredConversation(messages) {
198
+ return shouldAllowUiResume(messages);
199
+ }
200
+ function hasMinimumResumeConversation(messages) {
201
+ return isResumeConversationReady(messages);
202
+ }
203
+ function hasMinimumBackfillConversation(messages) {
204
+ return isStoredConversationReady(messages);
205
+ }
206
+ function hasProjectConversationSignal(messages) {
207
+ return hasMinimumRuntimeConversation(messages);
208
+ }
209
+ function hasStoredProjectConversationSignal(messages) {
210
+ return hasMinimumBackfillConversation(messages);
211
+ }
212
+ function hasUiProjectConversationSignal(messages) {
213
+ return hasMinimumStoredConversation(messages);
214
+ }
215
+ function hasResumeProjectConversationSignal(messages) {
216
+ return hasMinimumResumeConversation(messages);
217
+ }
218
+ function canBindFromProjectConversation(messages) {
219
+ return hasProjectConversationSignal(messages);
220
+ }
221
+ function canBackfillFromProjectConversation(messages) {
222
+ return hasStoredProjectConversationSignal(messages);
223
+ }
224
+ function canShowUiProjectConversation(messages) {
225
+ return hasUiProjectConversationSignal(messages);
226
+ }
227
+ function canResumeProjectConversation(messages) {
228
+ return hasResumeProjectConversationSignal(messages);
229
+ }
230
+ function shouldUseRuntimeProjectConversation(messages) {
231
+ return canBindFromProjectConversation(messages);
232
+ }
233
+ function shouldUseStoredProjectConversation(messages) {
234
+ return canBackfillFromProjectConversation(messages);
235
+ }
236
+ function shouldUseUiProjectConversation(messages) {
237
+ return canShowUiProjectConversation(messages);
238
+ }
239
+ function shouldUseResumeProjectConversation(messages) {
240
+ return canResumeProjectConversation(messages);
241
+ }
242
+ function hasProjectConversationForBinding(messages) {
243
+ return shouldUseRuntimeProjectConversation(messages);
244
+ }
245
+ function hasProjectConversationForBackfill(messages) {
246
+ return shouldUseStoredProjectConversation(messages);
247
+ }
248
+ function hasProjectConversationForUi(messages) {
249
+ return shouldUseUiProjectConversation(messages);
250
+ }
251
+ function hasProjectConversationForResume(messages) {
252
+ return shouldUseResumeProjectConversation(messages);
253
+ }
254
+ function isBindableProjectConversation(messages) {
255
+ return hasProjectConversationForBinding(messages);
256
+ }
257
+ function isBackfillableProjectConversation(messages) {
258
+ return hasProjectConversationForBackfill(messages);
259
+ }
260
+ function isUiProjectConversation(messages) {
261
+ return hasProjectConversationForUi(messages);
262
+ }
263
+ function isResumeProjectConversation(messages) {
264
+ return hasProjectConversationForResume(messages);
265
+ }
266
+ function hasLiveProjectConversation(messages) {
267
+ return isBindableProjectConversation(messages);
268
+ }
269
+ function hasStoredProjectConversation(messages) {
270
+ return isBackfillableProjectConversation(messages);
271
+ }
272
+ function hasVisibleProjectConversation(messages) {
273
+ return isUiProjectConversation(messages);
274
+ }
275
+ function hasRecoverableProjectConversation(messages) {
276
+ return isResumeProjectConversation(messages);
277
+ }
278
+ function shouldBindLiveProjectSessionId(messages) {
279
+ return hasLiveProjectConversation(messages);
280
+ }
281
+ function shouldBackfillStoredProjectSessionId(messages) {
282
+ return hasStoredProjectConversation(messages);
283
+ }
284
+ function shouldDisplayVisibleProjectSessionId(messages) {
285
+ return hasVisibleProjectConversation(messages);
286
+ }
287
+ function shouldResumeRecoverableProjectSessionId(messages) {
288
+ return hasRecoverableProjectConversation(messages);
289
+ }
290
+ function canBindLiveProjectSession(record) {
291
+ return shouldBindLiveProjectSessionId(record.messages);
292
+ }
293
+ function canBackfillStoredProjectSession(record) {
294
+ return shouldBackfillStoredProjectSessionId(record.messages);
295
+ }
296
+ function canDisplayVisibleProjectSession(messages) {
297
+ return shouldDisplayVisibleProjectSessionId(messages);
298
+ }
299
+ function canResumeRecoverableProjectSession(messages) {
300
+ return shouldResumeRecoverableProjectSessionId(messages);
301
+ }
302
+ function shouldAdoptProjectSessionDuringRuntime(record) {
303
+ return canBindLiveProjectSession(record);
304
+ }
305
+ function shouldAdoptProjectSessionDuringBackfill(record) {
306
+ return canBackfillStoredProjectSession(record);
307
+ }
308
+ function shouldAdoptProjectSessionForUi(messages) {
309
+ return canDisplayVisibleProjectSession(messages);
310
+ }
311
+ function shouldAdoptProjectSessionForResume(messages) {
312
+ return canResumeRecoverableProjectSession(messages);
313
+ }
314
+ function hasRuntimeProjectAdoption(messages) {
315
+ return shouldAdoptProjectSessionForUi(messages);
316
+ }
317
+ function hasBackfillProjectAdoption(messages) {
318
+ return shouldBackfillStoredProjectSessionId(messages);
319
+ }
320
+ function hasUiProjectAdoption(messages) {
321
+ return shouldAdoptProjectSessionForUi(messages);
322
+ }
323
+ function hasResumeProjectAdoption(messages) {
324
+ return shouldAdoptProjectSessionForResume(messages);
325
+ }
326
+ function shouldAdoptProjectSession(record) {
327
+ return shouldAdoptProjectSessionDuringRuntime(record);
328
+ }
329
+ function shouldAdoptStoredProjectSession(record) {
330
+ return shouldAdoptProjectSessionDuringBackfill(record);
331
+ }
332
+ function shouldAdoptUiProjectSession(messages) {
333
+ return hasUiProjectAdoption(messages);
334
+ }
335
+ function shouldAdoptResumeProjectSession(messages) {
336
+ return hasResumeProjectAdoption(messages);
337
+ }
338
+ function canUseProjectSessionAtRuntime(record) {
339
+ return shouldAdoptProjectSession(record);
340
+ }
341
+ function canUseProjectSessionAtBackfill(record) {
342
+ return shouldAdoptStoredProjectSession(record);
343
+ }
344
+ function canUseProjectSessionAtUi(messages) {
345
+ return shouldAdoptUiProjectSession(messages);
346
+ }
347
+ function canUseProjectSessionAtResume(messages) {
348
+ return shouldAdoptResumeProjectSession(messages);
349
+ }
350
+ function hasProjectSessionRuntimeEligibility(messages) {
351
+ return shouldAdoptProjectSessionDuringRuntime({ messages: messages ?? [] });
352
+ }
353
+ function hasProjectSessionBackfillEligibility(messages) {
354
+ return shouldAdoptProjectSessionDuringBackfill({ messages: messages ?? [] });
355
+ }
356
+ function hasProjectSessionUiEligibility(messages) {
357
+ return canUseProjectSessionAtUi(messages);
358
+ }
359
+ function hasProjectSessionResumeEligibility(messages) {
360
+ return canUseProjectSessionAtResume(messages);
361
+ }
362
+ function shouldClaimProjectSessionDuringRuntime(messages) {
363
+ return hasProjectSessionRuntimeEligibility(messages);
364
+ }
365
+ function shouldClaimProjectSessionDuringBackfill(messages) {
366
+ return hasProjectSessionBackfillEligibility(messages);
367
+ }
368
+ function shouldClaimProjectSessionForUi(messages) {
369
+ return hasProjectSessionUiEligibility(messages);
370
+ }
371
+ function shouldClaimProjectSessionForResume(messages) {
372
+ return hasProjectSessionResumeEligibility(messages);
373
+ }
374
+ function hasClaimableProjectSessionRuntime(messages) {
375
+ return shouldClaimProjectSessionDuringRuntime(messages);
376
+ }
377
+ function hasClaimableProjectSessionBackfill(messages) {
378
+ return shouldClaimProjectSessionDuringBackfill(messages);
379
+ }
380
+ function hasClaimableProjectSessionUi(messages) {
381
+ return shouldClaimProjectSessionForUi(messages);
382
+ }
383
+ function hasClaimableProjectSessionResume(messages) {
384
+ return shouldClaimProjectSessionForResume(messages);
385
+ }
386
+ function isClaimableProjectSessionRuntime(messages) {
387
+ return hasClaimableProjectSessionRuntime(messages);
388
+ }
389
+ function isClaimableProjectSessionBackfill(messages) {
390
+ return hasClaimableProjectSessionBackfill(messages);
391
+ }
392
+ function isClaimableProjectSessionUi(messages) {
393
+ return hasClaimableProjectSessionUi(messages);
394
+ }
395
+ function isClaimableProjectSessionResume(messages) {
396
+ return hasClaimableProjectSessionResume(messages);
397
+ }
398
+ function listClaudeProjectSessionCandidates(cwd) {
399
+ const projectDir = getClaudeProjectDir(cwd);
400
+ try {
401
+ return readdirSync(projectDir, { withFileTypes: true })
402
+ .filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl"))
403
+ .map((entry) => entry.name.replace(/\.jsonl$/, ""))
404
+ .filter((name) => UUID_V4_PATTERN.test(name))
405
+ .map((id) => {
406
+ const filePath = path.join(projectDir, `${id}.jsonl`);
407
+ const stats = statSync(filePath);
408
+ return { id, filePath, mtimeMs: stats.mtimeMs };
409
+ });
410
+ }
411
+ catch {
412
+ return [];
413
+ }
414
+ }
415
+ function listClaudeProjectSessionMtimes(cwd) {
416
+ return new Map(listClaudeProjectSessionCandidates(cwd).map((candidate) => [candidate.id, candidate.mtimeMs]));
417
+ }
418
+ function hasRecentProjectActivity(candidate, startedAt) {
419
+ const startedAtMs = Date.parse(startedAt);
420
+ if (!Number.isFinite(startedAtMs)) {
421
+ return true;
422
+ }
423
+ return candidate.mtimeMs >= startedAtMs - START_TIME_SKEW_MS
424
+ && candidate.mtimeMs <= Date.now() + DISCOVERY_RECENT_WINDOW_MS;
425
+ }
426
+ function selectClaudeProjectSessionForRecord(record) {
427
+ const knownMtimes = record.knownClaudeProjectMtimes ?? new Map();
428
+ const candidates = listClaudeProjectSessionCandidates(record.cwd)
429
+ .filter((candidate) => {
430
+ const previousMtime = knownMtimes.get(candidate.id);
431
+ return previousMtime === undefined || candidate.mtimeMs > previousMtime;
432
+ })
433
+ .filter((candidate) => hasRecentProjectActivity(candidate, record.startedAt))
434
+ .map((candidate) => readClaudeProjectSessionDetails(candidate.filePath, candidate.id))
435
+ .filter((candidate) => Boolean(candidate?.hasConversation))
436
+ .sort((a, b) => b.mtimeMs - a.mtimeMs);
437
+ if (candidates.length === 0) {
438
+ return null;
439
+ }
440
+ const hasUserTurn = record.messages.some((turn) => turn.role === "user"
441
+ && turn.content.some((block) => block.type === "text" && block.text.trim().length > 0));
442
+ if (!hasUserTurn) {
443
+ return null;
444
+ }
445
+ return candidates[0] ?? null;
446
+ }
447
+ /**
448
+ * Broader fallback: find a JSONL file by mtime proximity when strict
449
+ * mtime-correlation fails (e.g., file existed before session but Claude
450
+ * wrote conversation content during this session).
451
+ * Looks for the most recently modified file that was active near the
452
+ * session's start time and has real conversation content.
453
+ */
454
+ function selectClaudeProjectSessionByProximity(record) {
455
+ const hasUserTurn = record.messages.some((turn) => turn.role === "user"
456
+ && turn.content.some((block) => block.type === "text" && block.text.trim().length > 0));
457
+ if (!hasUserTurn) {
458
+ return null;
459
+ }
460
+ const startedAtMs = Date.parse(record.startedAt);
461
+ const now = Date.now();
462
+ // Look for files modified from ~60s before session start up to now
463
+ const proximityWindowMs = 60 * 1000;
464
+ const candidates = listClaudeProjectSessionCandidates(record.cwd)
465
+ .filter((candidate) => {
466
+ if (!Number.isFinite(startedAtMs))
467
+ return true;
468
+ return candidate.mtimeMs >= startedAtMs - proximityWindowMs
469
+ && candidate.mtimeMs <= now + DISCOVERY_RECENT_WINDOW_MS;
470
+ })
471
+ .map((candidate) => readClaudeProjectSessionDetails(candidate.filePath, candidate.id))
472
+ .filter((candidate) => Boolean(candidate?.hasConversation))
473
+ .sort((a, b) => b.mtimeMs - a.mtimeMs);
474
+ return candidates[0] ?? null;
475
+ }
476
+ function getResumeEligibility(record) {
477
+ const hasClaudeSessionId = Boolean(record.claudeSessionId);
478
+ const hasRealConversation = hasRealConversationMessages(record.messages);
479
+ return {
480
+ hasClaudeSessionId,
481
+ hasRealConversation,
482
+ eligible: hasClaudeSessionId && hasRealConversation
483
+ };
484
+ }
485
+ function hasResumeEligibleConversation(record) {
486
+ return getResumeEligibility(record).eligible;
487
+ }
488
+ function getLatestClaudeProjectSessionId(record) {
489
+ // Try strict mtime-correlation first, then fall back to mtime proximity
490
+ return selectClaudeProjectSessionForRecord(record)?.id
491
+ ?? selectClaudeProjectSessionByProximity(record)?.id
492
+ ?? null;
493
+ }
494
+ function listRecentClaudeProjectSessionIds(cwd, startedAt) {
495
+ return listClaudeProjectSessionCandidates(cwd)
496
+ .filter((candidate) => hasRecentProjectActivity(candidate, startedAt))
497
+ .sort((a, b) => b.mtimeMs - a.mtimeMs)
498
+ .map((candidate) => candidate.id);
499
+ }
500
+ function findRealClaudeProjectSessionId(cwd, startedAt) {
501
+ // Strict mtime-based discovery first
502
+ const candidates = listRecentClaudeProjectSessionIds(cwd, startedAt)
503
+ .map((id) => {
504
+ const filePath = path.join(getClaudeProjectDir(cwd), `${id}.jsonl`);
505
+ return readClaudeProjectSessionDetails(filePath, id);
506
+ })
507
+ .filter((candidate) => Boolean(candidate?.hasConversation))
508
+ .sort((a, b) => b.mtimeMs - a.mtimeMs);
509
+ if (candidates.length > 0)
510
+ return candidates[0].id;
511
+ // Fallback: broader proximity search for files with conversation content
512
+ const startedAtMs = Date.parse(startedAt);
513
+ const now = Date.now();
514
+ const proximityWindowMs = 60 * 1000;
515
+ const proximityCandidates = listClaudeProjectSessionCandidates(cwd)
516
+ .filter((candidate) => {
517
+ if (!Number.isFinite(startedAtMs))
518
+ return true;
519
+ return candidate.mtimeMs >= startedAtMs - proximityWindowMs
520
+ && candidate.mtimeMs <= now + DISCOVERY_RECENT_WINDOW_MS;
521
+ })
522
+ .map((candidate) => readClaudeProjectSessionDetails(candidate.filePath, candidate.id))
523
+ .filter((candidate) => Boolean(candidate?.hasConversation))
524
+ .sort((a, b) => b.mtimeMs - a.mtimeMs);
525
+ return proximityCandidates[0]?.id ?? null;
526
+ }
527
+ function isClaudeSessionFileAvailable(cwd, claudeSessionId) {
528
+ const filePath = path.join(getClaudeProjectDir(cwd), `${claudeSessionId}.jsonl`);
529
+ return Boolean(readClaudeProjectSessionDetails(filePath, claudeSessionId));
530
+ }
531
+ /**
532
+ * Reverse the normalization done by getClaudeProjectDir.
533
+ * "-vol1-1000-yolo-claude-wand" → "/vol1/1000/yolo-claude/wand"
534
+ * This is lossy (real hyphens become slashes), so we try all possible
535
+ * interpretations and validate with existsSync, falling back to naive replacement.
536
+ */
537
+ function invertNormalizedProjectDir(dirName) {
538
+ // The normalization is: path.resolve(cwd).replace(/\//g, "-")
539
+ const naive = dirName.replace(/-/g, "/");
540
+ if (existsSync(naive))
541
+ return naive;
542
+ // BFS: at each hyphen position, try "/" (path separator) or "-" (literal hyphen).
543
+ // Prune candidates that don't exist as directories, but only if at least one
544
+ // candidate survives pruning. Otherwise keep all to allow deeper merges.
545
+ const parts = dirName.split("-").filter(Boolean);
546
+ if (parts.length === 0 || parts.length > 20)
547
+ return naive;
548
+ let candidates = ["/" + parts[0]];
549
+ for (let i = 1; i < parts.length; i++) {
550
+ const next = [];
551
+ for (const prefix of candidates) {
552
+ next.push(prefix + "/" + parts[i]);
553
+ next.push(prefix + "-" + parts[i]);
554
+ }
555
+ if (i < parts.length - 1) {
556
+ // Prune non-existent prefixes, but keep all if none exist
557
+ const valid = next.filter((c) => { try {
558
+ return existsSync(c);
559
+ }
560
+ catch {
561
+ return false;
562
+ } });
563
+ candidates = valid.length > 0 ? valid : next;
564
+ }
565
+ else {
566
+ candidates = next;
567
+ }
568
+ if (candidates.length > 200)
569
+ candidates = candidates.slice(0, 200);
570
+ }
571
+ // Return the first candidate that exists, or the first one, or naive
572
+ for (const c of candidates) {
573
+ if (existsSync(c))
574
+ return c;
575
+ }
576
+ return candidates[0] || naive;
577
+ }
578
+ /** Read only the first ~8KB of a JSONL file to extract summary metadata. */
579
+ function readClaudeSessionSummary(filePath, id, cwd) {
580
+ try {
581
+ const stats = statSync(filePath);
582
+ const fd = openSync(filePath, "r");
583
+ const buffer = Buffer.alloc(8192);
584
+ const bytesRead = readSync(fd, buffer, 0, 8192, 0);
585
+ closeSync(fd);
586
+ const chunk = buffer.toString("utf8", 0, bytesRead);
587
+ const lines = chunk.split("\n").filter((line) => line.trim().length > 0);
588
+ let timestamp = "";
589
+ let firstUserMessage = "";
590
+ let hasUser = false;
591
+ let hasAssistant = false;
592
+ for (const line of lines) {
593
+ try {
594
+ const parsed = JSON.parse(line);
595
+ if (!timestamp && parsed.timestamp) {
596
+ timestamp = parsed.timestamp;
597
+ }
598
+ if (parsed.type === "user" || parsed.message?.role === "user") {
599
+ hasUser = true;
600
+ if (!firstUserMessage) {
601
+ if (typeof parsed.content === "string" && parsed.content.trim()) {
602
+ firstUserMessage = parsed.content.trim().slice(0, 120);
603
+ }
604
+ else if (parsed.message?.content && typeof parsed.message.content === "string") {
605
+ firstUserMessage = parsed.message.content.trim().slice(0, 120);
606
+ }
607
+ }
608
+ }
609
+ if (parsed.type === "assistant" || parsed.message?.role === "assistant") {
610
+ hasAssistant = true;
611
+ }
612
+ }
613
+ catch {
614
+ continue;
615
+ }
616
+ }
617
+ // cwd is passed in from the caller
618
+ return {
619
+ claudeSessionId: id,
620
+ projectDir: path.basename(path.dirname(filePath)),
621
+ cwd,
622
+ firstUserMessage,
623
+ timestamp: timestamp || new Date(stats.mtimeMs).toISOString(),
624
+ mtimeMs: stats.mtimeMs,
625
+ hasConversation: hasUser && hasAssistant,
626
+ managedByWand: false,
627
+ };
628
+ }
629
+ catch {
630
+ return null;
631
+ }
632
+ }
633
+ /** Scan all ~/.claude/projects/ directories for session JSONL files. */
634
+ function listAllClaudeHistorySessions() {
635
+ const projectsDir = path.join(os.homedir(), ".claude", "projects");
636
+ try {
637
+ const projectDirs = readdirSync(projectsDir, { withFileTypes: true })
638
+ .filter((entry) => entry.isDirectory());
639
+ const results = [];
640
+ for (const dir of projectDirs) {
641
+ const dirPath = path.join(projectsDir, dir.name);
642
+ const cwd = invertNormalizedProjectDir(dir.name);
643
+ try {
644
+ const files = readdirSync(dirPath, { withFileTypes: true })
645
+ .filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl"))
646
+ .map((entry) => entry.name.replace(/\.jsonl$/, ""))
647
+ .filter((name) => UUID_V4_PATTERN.test(name));
648
+ for (const sessionId of files) {
649
+ const filePath = path.join(dirPath, `${sessionId}.jsonl`);
650
+ const summary = readClaudeSessionSummary(filePath, sessionId, cwd);
651
+ if (summary) {
652
+ results.push(summary);
653
+ }
654
+ }
655
+ }
656
+ catch {
657
+ continue;
658
+ }
659
+ }
660
+ return results.sort((a, b) => b.mtimeMs - a.mtimeMs);
661
+ }
662
+ catch {
663
+ return [];
664
+ }
665
+ }
666
+ function shouldAutoResumeSession(record) {
667
+ return record.status === "exited"
668
+ && !record.archived
669
+ && !record.resumedToSessionId
670
+ && record.ptyProcess === null
671
+ && hasResumeEligibleConversation(record);
672
+ }
673
+ function shouldBackfillClaudeSessionId(record) {
674
+ return record.status === "exited"
675
+ && !record.claudeSessionId
676
+ && /^claude\b/.test(record.command.trim())
677
+ && hasRealConversationMessages(record.messages);
678
+ }
679
+ function snapshotMessages(record) {
680
+ return record.ptyBridge?.getMessages() ?? record.messages;
681
+ }
39
682
  const MAX_SESSIONS = 50;
40
683
  const ARCHIVE_AFTER_MS = 1000 * 60 * 60 * 24;
41
- const OUTPUT_WINDOW_SIZE = 4096;
42
684
  const CONFIRM_WINDOW_SIZE = 800;
43
- const OUTPUT_MAX_SIZE = 120000;
44
685
  // Claude 会话 ID 格式:UUID v4
45
686
  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;
687
+ const UUID_V4_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
688
+ function listClaudeTaskIds() {
689
+ const tasksDir = path.join(os.homedir(), ".claude", "tasks");
690
+ try {
691
+ return readdirSync(tasksDir, { withFileTypes: true })
692
+ .filter((entry) => entry.isDirectory() && UUID_V4_PATTERN.test(entry.name))
693
+ .map((entry) => entry.name);
694
+ }
695
+ catch {
696
+ return [];
697
+ }
698
+ }
699
+ function getClaudeProjectDir(cwd) {
700
+ const normalized = path.resolve(cwd).replace(/\//g, "-");
701
+ return path.join(os.homedir(), ".claude", "projects", normalized);
702
+ }
703
+ function getLatestClaudeTaskId(excludeIds) {
704
+ const tasksDir = path.join(os.homedir(), ".claude", "tasks");
705
+ try {
706
+ const candidates = readdirSync(tasksDir, { withFileTypes: true })
707
+ .filter((entry) => entry.isDirectory() && UUID_V4_PATTERN.test(entry.name) && !excludeIds.has(entry.name))
708
+ .map((entry) => {
709
+ const fullPath = path.join(tasksDir, entry.name);
710
+ const stats = statSync(fullPath);
711
+ return { id: entry.name, mtimeMs: stats.mtimeMs };
712
+ })
713
+ .sort((a, b) => b.mtimeMs - a.mtimeMs);
714
+ return candidates[0]?.id ?? null;
715
+ }
716
+ catch {
717
+ return null;
718
+ }
50
719
  }
51
720
  export class ProcessManager extends EventEmitter {
52
721
  config;
53
722
  storage;
54
723
  sessions = new Map();
55
724
  logger;
725
+ lifecycleManager;
726
+ /** Per-session debounce timers for throttled persist calls */
727
+ persistDebounceTimers = new Map();
728
+ /** Last persisted message count per session — used to skip redundant file writes */
729
+ lastPersistedMessageCount = new Map();
56
730
  constructor(config, storage, configDir) {
57
731
  super();
58
732
  this.config = config;
59
733
  this.storage = storage;
60
734
  this.logger = new SessionLogger(configDir || path.join(process.env.HOME || process.cwd(), ".wand"));
735
+ // Initialize lifecycle manager
736
+ this.lifecycleManager = new SessionLifecycleManager({
737
+ onStateChange: (sessionId, oldState, newState) => {
738
+ this.emitEvent({ type: "status", sessionId, data: { oldState, newState } });
739
+ },
740
+ onIdle: (sessionId) => {
741
+ console.error(`[ProcessManager] Session ${sessionId} is now idle`);
742
+ },
743
+ onArchived: (sessionId, reason) => {
744
+ console.error(`[ProcessManager] Session ${sessionId} archived: ${reason}`);
745
+ },
746
+ });
61
747
  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
- });
748
+ const isClaudeCmd = /^claude\b/.test(snapshot.command.trim());
749
+ const resumeCommandSessionId = getResumeCommandSessionId(snapshot.command);
750
+ // Sessions restored from storage have ptyProcess: null — the old server's PTY
751
+ // belongs to a dead process. Mark running sessions as exited so the UI
752
+ // reflects reality and users can start fresh sessions.
753
+ if (snapshot.status === "running") {
754
+ const updated = { ...snapshot, status: "exited", endedAt: new Date().toISOString() };
755
+ this.storage.saveSession(updated);
756
+ this.sessions.set(snapshot.id, {
757
+ ...updated,
758
+ processId: null,
759
+ ptyProcess: null,
760
+ stopRequested: false,
761
+ confirmWindow: "",
762
+ ptyPermissionBlocked: false,
763
+ lastAutoConfirmAt: 0,
764
+ autoApprovePermissions: this.shouldAutoApprovePermissions(snapshot.command, snapshot.mode),
765
+ pendingEscalation: snapshot.pendingEscalation ?? null,
766
+ lastEscalationResult: snapshot.lastEscalationResult ?? null,
767
+ autonomyPolicy: snapshot.autonomyPolicy ?? this.defaultAutonomyPolicy(snapshot.mode),
768
+ approvalPolicy: snapshot.approvalPolicy ?? "ask-every-time",
769
+ allowedScopes: snapshot.allowedScopes ?? [],
770
+ rememberedEscalationScopes: new Set(),
771
+ rememberedEscalationTargets: new Set(),
772
+ storedOutput: snapshot.output,
773
+ messages: snapshot.messages ?? [],
774
+ childProcess: null,
775
+ ptyBridge: null,
776
+ currentTask: null,
777
+ taskDebounceTimer: null,
778
+ lastEmittedTask: null,
779
+ knownClaudeTaskIds: undefined,
780
+ claudeTaskDiscoveryTimer: null,
781
+ knownClaudeProjectMtimes: isClaudeCmd ? listClaudeProjectSessionMtimes(updated.cwd) : undefined,
782
+ claudeSessionId: resumeCommandSessionId ?? updated.claudeSessionId
783
+ });
784
+ this.lifecycleManager.register(snapshot.id, "idle");
785
+ console.error(`[ProcessManager] Restored session ${snapshot.id} marked as exited (PTY orphaned)`);
786
+ }
787
+ else {
788
+ this.sessions.set(snapshot.id, {
789
+ ...snapshot,
790
+ processId: null,
791
+ ptyProcess: null,
792
+ stopRequested: false,
793
+ confirmWindow: "",
794
+ ptyPermissionBlocked: false,
795
+ lastAutoConfirmAt: 0,
796
+ autoApprovePermissions: this.shouldAutoApprovePermissions(snapshot.command, snapshot.mode),
797
+ pendingEscalation: snapshot.pendingEscalation ?? null,
798
+ lastEscalationResult: snapshot.lastEscalationResult ?? null,
799
+ autonomyPolicy: snapshot.autonomyPolicy ?? this.defaultAutonomyPolicy(snapshot.mode),
800
+ approvalPolicy: snapshot.approvalPolicy ?? "ask-every-time",
801
+ allowedScopes: snapshot.allowedScopes ?? [],
802
+ rememberedEscalationScopes: new Set(),
803
+ rememberedEscalationTargets: new Set(),
804
+ storedOutput: snapshot.output,
805
+ messages: snapshot.messages ?? [],
806
+ childProcess: null,
807
+ ptyBridge: null,
808
+ currentTask: null,
809
+ taskDebounceTimer: null,
810
+ lastEmittedTask: null,
811
+ knownClaudeTaskIds: undefined,
812
+ claudeTaskDiscoveryTimer: null,
813
+ knownClaudeProjectMtimes: isClaudeCmd ? listClaudeProjectSessionMtimes(snapshot.cwd) : undefined,
814
+ claudeSessionId: resumeCommandSessionId ?? snapshot.claudeSessionId
815
+ });
816
+ this.lifecycleManager.register(snapshot.id, "archived");
817
+ }
79
818
  }
819
+ // Defer expensive file-system scanning and auto-recovery so the server
820
+ // can start responding to requests immediately.
821
+ setImmediate(() => {
822
+ this.backfillExitedClaudeSessionIds();
823
+ this.autoRecoverExitedSessions();
824
+ });
80
825
  this.archiveExpiredSessions();
81
826
  }
82
827
  on(event, listener) {
@@ -104,86 +849,162 @@ export class ProcessManager extends EventEmitter {
104
849
  })
105
850
  .slice(0, this.sessions.size - MAX_SESSIONS + 1)
106
851
  .forEach((id) => {
852
+ const record = this.sessions.get(id);
853
+ if (record) {
854
+ this.logger.deleteSession(id);
855
+ this.deleteClaudeCache(record);
856
+ }
107
857
  this.sessions.delete(id);
858
+ this.lastPersistedMessageCount.delete(id);
108
859
  this.storage.deleteSession(id);
109
860
  });
110
861
  }
111
- start(command, cwd, mode, initialInput) {
862
+ start(command, cwd, mode, initialInput, opts) {
112
863
  this.assertCommandAllowed(command);
113
864
  const resolvedCwd = cwd
114
865
  ? path.resolve(process.cwd(), cwd)
115
866
  : path.resolve(process.cwd(), this.config.defaultCwd);
867
+ const isClaudeCmd = this.isClaudeCommand(command);
116
868
  // For full-access mode with claude, add permission flags
117
869
  const processedCommand = this.processCommandForMode(command, mode);
870
+ const resumeCommandSessionId = getResumeCommandSessionId(processedCommand) ?? getResumeCommandSessionId(command);
871
+ const knownClaudeTaskIds = isClaudeCmd ? new Set(listRecentClaudeProjectSessionIds(resolvedCwd, new Date().toISOString())) : null;
872
+ const knownClaudeProjectMtimes = isClaudeCmd ? listClaudeProjectSessionMtimes(resolvedCwd) : null;
873
+ const initialClaudeSessionId = resumeCommandSessionId ?? null;
874
+ const startedAt = new Date().toISOString();
118
875
  const id = randomUUID();
119
876
  const record = {
120
877
  id,
121
878
  command,
122
879
  cwd: resolvedCwd,
123
880
  mode,
881
+ autonomyPolicy: this.defaultAutonomyPolicy(mode),
882
+ approvalPolicy: "ask-every-time",
883
+ allowedScopes: [],
124
884
  status: "running",
125
885
  exitCode: null,
126
- startedAt: new Date().toISOString(),
886
+ startedAt,
127
887
  endedAt: null,
128
888
  output: "",
129
889
  archived: false,
130
890
  archivedAt: null,
131
- claudeSessionId: null,
891
+ permissionBlocked: undefined,
892
+ pendingEscalation: null,
893
+ lastEscalationResult: null,
894
+ claudeSessionId: initialClaudeSessionId,
132
895
  processId: null,
133
896
  ptyProcess: null,
134
897
  stopRequested: false,
135
898
  confirmWindow: "",
899
+ ptyPermissionBlocked: false,
136
900
  lastAutoConfirmAt: 0,
137
- sessionIdWindow: "",
901
+ autoApprovePermissions: this.shouldAutoApprovePermissions(command, mode),
902
+ resumedFromSessionId: opts?.resumedFromSessionId ?? null,
903
+ autoRecovered: opts?.autoRecovered ?? false,
904
+ rememberedEscalationScopes: new Set(),
905
+ rememberedEscalationTargets: new Set(),
138
906
  storedOutput: "",
139
907
  messages: [],
140
- jsonChatBusy: false,
141
908
  childProcess: null,
142
- ptyChatState: "idle",
143
- ptyAssistantBuffer: "",
144
- ptyLastUserInput: "",
145
- ptyEchoSkipped: false
909
+ ptyBridge: null,
910
+ currentTask: null,
911
+ taskDebounceTimer: null,
912
+ lastEmittedTask: null,
913
+ knownClaudeTaskIds: knownClaudeTaskIds ?? undefined,
914
+ claudeTaskDiscoveryTimer: null,
915
+ knownClaudeProjectMtimes: knownClaudeProjectMtimes ?? undefined
146
916
  };
917
+ // Create PTY bridge for this session
918
+ record.ptyBridge = new ClaudePtyBridge({
919
+ sessionId: id,
920
+ isClaudeCommand: isClaudeCmd,
921
+ autoApprove: record.autoApprovePermissions,
922
+ approvalPolicy: record.approvalPolicy,
923
+ });
924
+ record.ptyBridge.on("event", (event) => {
925
+ this.handleBridgeEvent(record, event);
926
+ });
147
927
  this.sessions.set(id, record);
148
928
  this.persist(record);
149
929
  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);
930
+ // Register lifecycle
931
+ this.lifecycleManager.register(id, "initializing");
932
+ // All modes use PTY executionJSON turns are only used for internal recovery
933
+ const shellArgs = this.buildShellArgs(processedCommand);
934
+ let child;
935
+ try {
936
+ child = pty.spawn(this.config.shell, shellArgs, {
937
+ cwd: resolvedCwd,
938
+ env: {
939
+ ...process.env,
940
+ WAND_MODE: mode,
941
+ WAND_AUTO_CONFIRM: mode === "full-access" ? "1" : "0",
942
+ WAND_AUTO_EDIT: mode === "auto-edit" ? "1" : "0"
943
+ },
944
+ name: "xterm-color",
945
+ cols: 120,
946
+ rows: 36
947
+ });
162
948
  }
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);
949
+ catch (err) {
950
+ console.error("[ProcessManager] pty.spawn threw", { sessionId: id, error: String(err) });
951
+ record.status = "failed";
952
+ record.exitCode = -1;
953
+ record.endedAt = new Date().toISOString();
954
+ record.ptyProcess = null;
955
+ this.lifecycleManager.archive(id, "Session spawn failed", "error");
956
+ this.persist(record);
170
957
  return this.snapshot(record);
171
958
  }
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
959
  record.processId = child.pid;
186
960
  record.ptyProcess = child;
961
+ record.status = "running";
962
+ this.lifecycleManager.setState(id, "running");
963
+ // Register exit handler AFTER ptyProcess is assigned — node-pty's EventEmitter
964
+ // fires 'exit' synchronously when the child has already exited (e.g. "command
965
+ // not found"). If we register first, onExit fires with ptyProcess still null and
966
+ // status never updates. By assigning first, onExit always sees a consistent state.
967
+ child.onExit(({ exitCode }) => {
968
+ const current = this.sessions.get(id);
969
+ if (!current)
970
+ return;
971
+ if (current.claudeTaskDiscoveryTimer) {
972
+ clearTimeout(current.claudeTaskDiscoveryTimer);
973
+ current.claudeTaskDiscoveryTimer = null;
974
+ }
975
+ if (current.initialInputTimer) {
976
+ clearTimeout(current.initialInputTimer);
977
+ current.initialInputTimer = null;
978
+ }
979
+ if (current.ptyBridge) {
980
+ current.ptyBridge.onExit(exitCode);
981
+ current.ptyBridge.removeAllListeners();
982
+ }
983
+ current.pendingEscalation = null;
984
+ current.ptyPermissionBlocked = false;
985
+ current.status = current.stopRequested ? "stopped" : exitCode === 0 ? "exited" : "failed";
986
+ current.exitCode = current.stopRequested ? null : exitCode;
987
+ current.endedAt = new Date().toISOString();
988
+ current.ptyProcess = null;
989
+ this.lifecycleManager.archive(id, `Session ${current.status}`, current.stopRequested ? "user" : "error");
990
+ this.flushPersist(current);
991
+ // Final full snapshot with messages to SQLite (persist() only saves metadata)
992
+ this.storage.saveSession(this.snapshot(current));
993
+ this.emitEvent({ type: "ended", sessionId: id, data: this.snapshot(current) });
994
+ });
995
+ // Set PTY write function for bridge (for permission approval).
996
+ // Write directly to record.ptyProcess — the status guard in sendInput() already
997
+ // ensures no input is sent when the session is not running, so we just guard
998
+ // the PTY write itself against a null process.
999
+ if (record.ptyBridge) {
1000
+ record.ptyBridge.setPtyWrite((input) => {
1001
+ if (record.ptyProcess) {
1002
+ record.ptyProcess.write(input);
1003
+ }
1004
+ });
1005
+ }
1006
+ // Emit started event AFTER PTY is fully set up so clients receive a consistent snapshot.
1007
+ this.emitEvent({ type: "started", sessionId: id, data: this.snapshot(record) });
187
1008
  let initialInputSent = false;
188
1009
  const sendInitialInput = () => {
189
1010
  if (initialInputSent || !initialInput)
@@ -195,100 +1016,91 @@ export class ProcessManager extends EventEmitter {
195
1016
  return;
196
1017
  }
197
1018
  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;
1019
+ // Track initial input via bridge for Chat mode
1020
+ if (current.ptyBridge) {
1021
+ current.ptyBridge.onUserInput(initialInput);
213
1022
  }
214
1023
  current.ptyProcess.write(initialInput);
215
1024
  // \n advances to a new line so subsequent output doesn't overwrite this input
216
1025
  current.ptyProcess.write("\n");
217
1026
  };
218
- // Debounce rapid PTY output events to reduce WebSocket flooding
219
- let outputDebounceTimer = null;
220
- let pendingChunk = "";
221
1027
  child.onData((chunk) => {
222
1028
  const rec = this.sessions.get(id);
223
1029
  if (!rec)
224
1030
  return;
225
- rec.output = appendWindow(rec.output, normalizePtyOutput(chunk), OUTPUT_MAX_SIZE);
1031
+ // Route chunk through PTY bridge
1032
+ if (rec.ptyBridge) {
1033
+ rec.ptyBridge.processChunk(chunk);
1034
+ }
1035
+ // Update legacy output field for backward compatibility
1036
+ rec.output = rec.ptyBridge?.getRawOutput() ?? "";
226
1037
  // Log raw PTY output for analysis
227
1038
  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);
1039
+ // Update Claude session ID from bridge
1040
+ const bridgeSessionId = rec.ptyBridge?.getClaudeSessionId();
1041
+ if (bridgeSessionId && bridgeSessionId !== rec.claudeSessionId) {
1042
+ rec.claudeSessionId = bridgeSessionId;
1043
+ process.stderr.write(`[wand] Captured Claude session ID: ${bridgeSessionId}\n`);
1044
+ }
1045
+ if (!rec.claudeSessionId && rec.knownClaudeTaskIds) {
1046
+ rec.messages = snapshotMessages(rec);
1047
+ const discoveredTaskId = getLatestClaudeProjectSessionId({
1048
+ cwd: rec.cwd,
1049
+ startedAt: rec.startedAt,
1050
+ knownClaudeProjectMtimes: rec.knownClaudeProjectMtimes,
1051
+ messages: rec.messages
1052
+ });
1053
+ if (discoveredTaskId) {
1054
+ rec.claudeSessionId = discoveredTaskId;
1055
+ rec.knownClaudeTaskIds.add(discoveredTaskId);
1056
+ process.stderr.write(`[wand] Captured Claude project session ID: ${discoveredTaskId}\n`);
236
1057
  }
237
1058
  }
238
- if (mode === "full-access") {
1059
+ // Auto-confirm for full-access mode (legacy path for non-Claude sessions without ptyBridge)
1060
+ if (rec.autoApprovePermissions && !rec.ptyBridge) {
239
1061
  this.autoConfirmWithRecord(rec, chunk, child);
240
1062
  }
241
1063
  if (initialInput && !initialInputSent && chunk.includes("❯")) {
242
1064
  sendInitialInput();
243
1065
  }
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) });
1066
+ this.schedulePersist(rec);
285
1067
  });
286
1068
  if (initialInput) {
287
- setTimeout(() => {
1069
+ record.initialInputTimer = setTimeout(() => {
1070
+ record.initialInputTimer = null;
288
1071
  if (!initialInputSent)
289
1072
  sendInitialInput();
290
1073
  }, 3000);
291
1074
  }
1075
+ if (record.knownClaudeTaskIds) {
1076
+ const tryDiscoverClaudeTaskId = () => {
1077
+ const current = this.sessions.get(id);
1078
+ if (!current || current.status !== "running" || current.claudeSessionId || !current.knownClaudeTaskIds) {
1079
+ return;
1080
+ }
1081
+ if (getResumeCommandSessionId(current.command)) {
1082
+ current.claudeTaskDiscoveryTimer = null;
1083
+ return;
1084
+ }
1085
+ current.messages = snapshotMessages(current);
1086
+ const discoveredTaskId = getLatestClaudeProjectSessionId({
1087
+ cwd: current.cwd,
1088
+ startedAt: current.startedAt,
1089
+ knownClaudeProjectMtimes: current.knownClaudeProjectMtimes,
1090
+ messages: current.messages
1091
+ });
1092
+ if (discoveredTaskId) {
1093
+ current.claudeSessionId = discoveredTaskId;
1094
+ current.knownClaudeTaskIds.add(discoveredTaskId);
1095
+ current.claudeTaskDiscoveryTimer = null;
1096
+ process.stderr.write(`[wand] Discovered Claude resumable project session ID: ${discoveredTaskId}\n`);
1097
+ this.persist(current);
1098
+ return;
1099
+ }
1100
+ current.claudeTaskDiscoveryTimer = setTimeout(tryDiscoverClaudeTaskId, 1000);
1101
+ };
1102
+ record.claudeTaskDiscoveryTimer = setTimeout(tryDiscoverClaudeTaskId, 500);
1103
+ }
292
1104
  return this.snapshot(record);
293
1105
  }
294
1106
  list() {
@@ -297,6 +1109,32 @@ export class ProcessManager extends EventEmitter {
297
1109
  .sort((a, b) => b.startedAt.localeCompare(a.startedAt))
298
1110
  .map((session) => this.snapshot(session));
299
1111
  }
1112
+ hasClaudeSessionFile(cwd, claudeSessionId) {
1113
+ return isClaudeSessionFileAvailable(cwd, claudeSessionId);
1114
+ }
1115
+ claudeHistoryCache = null;
1116
+ static HISTORY_CACHE_TTL_MS = 30_000;
1117
+ listClaudeHistorySessions() {
1118
+ const now = Date.now();
1119
+ if (this.claudeHistoryCache && now < this.claudeHistoryCache.expiresAt) {
1120
+ return this.claudeHistoryCache.data;
1121
+ }
1122
+ const allSessions = listAllClaudeHistorySessions();
1123
+ // Cross-reference with wand-managed sessions
1124
+ const managedClaudeIds = new Set();
1125
+ for (const record of this.sessions.values()) {
1126
+ if (record.claudeSessionId) {
1127
+ managedClaudeIds.add(record.claudeSessionId);
1128
+ }
1129
+ }
1130
+ for (const session of allSessions) {
1131
+ if (managedClaudeIds.has(session.claudeSessionId)) {
1132
+ session.managedByWand = true;
1133
+ }
1134
+ }
1135
+ this.claudeHistoryCache = { data: allSessions, expiresAt: now + ProcessManager.HISTORY_CACHE_TTL_MS };
1136
+ return allSessions;
1137
+ }
300
1138
  get(id) {
301
1139
  this.archiveExpiredSessions();
302
1140
  const record = this.sessions.get(id);
@@ -311,638 +1149,74 @@ export class ProcessManager extends EventEmitter {
311
1149
  }
312
1150
  sendInput(id, input, view) {
313
1151
  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 }]
1152
+ if (record.status !== "running") {
1153
+ console.error("[ProcessManager] Rejecting input for non-running session", {
1154
+ sessionId: id,
1155
+ status: record.status,
1156
+ hasPty: !!record.ptyProcess,
1157
+ inputLength: input.length,
1158
+ view: view ?? "chat"
340
1159
  });
341
- // Add assistant placeholder for streaming updates
342
- record.messages.push({
343
- role: "assistant",
344
- content: []
1160
+ throw new SessionInputError("Session is not running.", "SESSION_NOT_RUNNING", id, record.status);
1161
+ }
1162
+ // Update lifecycle
1163
+ this.lifecycleManager.touch(id);
1164
+ this.lifecycleManager.startThinking(id);
1165
+ if (!record.ptyProcess) {
1166
+ console.error("[ProcessManager] Rejecting input because PTY is missing", {
1167
+ sessionId: id,
1168
+ status: record.status,
1169
+ hasPty: !!record.ptyProcess,
1170
+ inputLength: input.length,
1171
+ view: view ?? "chat"
345
1172
  });
346
- record.ptyChatState = "responding";
347
- record.ptyAssistantBuffer = "";
348
- record.ptyLastUserInput = cleanInput;
349
- record.ptyEchoSkipped = false;
1173
+ throw new SessionInputError("Session is not running.", "SESSION_NO_PTY", id, record.status);
1174
+ }
1175
+ console.error("[ProcessManager] Sending input to session", {
1176
+ sessionId: id,
1177
+ status: record.status,
1178
+ hasPty: !!record.ptyProcess,
1179
+ inputLength: input.length,
1180
+ view: view ?? "chat"
1181
+ });
1182
+ // Track user input via bridge for Chat mode
1183
+ if (record.ptyBridge) {
1184
+ record.ptyBridge.onUserInput(input);
350
1185
  }
351
1186
  // Ensure input advances to a new line so subsequent PTY output doesn't overwrite it
352
1187
  record.ptyProcess.write(input);
353
- if (!input.endsWith("\n")) {
1188
+ if (view !== "terminal" && !input.endsWith("\n")) {
354
1189
  record.ptyProcess.write("\n");
355
1190
  }
356
1191
  this.persist(record);
357
1192
  return this.snapshot(record);
358
1193
  }
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;
1194
+ /** Emit a task event for a session, debounced to avoid flooding */
1195
+ emitTask(record, task) {
1196
+ // Clear existing debounce timer
1197
+ if (record.taskDebounceTimer) {
1198
+ clearTimeout(record.taskDebounceTimer);
1199
+ record.taskDebounceTimer = null;
707
1200
  }
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
1201
+ // Don't re-emit the same task
1202
+ if (task && task.title === record.lastEmittedTask)
797
1203
  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")
1204
+ if (task === null) {
1205
+ // Clear task after a delay allows a brief display of "idle" state
1206
+ record.taskDebounceTimer = setTimeout(() => {
1207
+ record.currentTask = null;
1208
+ record.lastEmittedTask = null;
1209
+ this.emitEvent({ type: "task", sessionId: record.id, data: null });
1210
+ }, 2000);
820
1211
  return;
821
- const text = this.cleanPtyOutputForChat(record.ptyAssistantBuffer);
822
- if (text) {
823
- lastMsg.content = [{ type: "text", text }];
824
1212
  }
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();
1213
+ // Debounce task changes by 100ms to avoid flickering on rapid tool switches
1214
+ record.taskDebounceTimer = setTimeout(() => {
1215
+ record.taskDebounceTimer = null;
1216
+ record.currentTask = task;
1217
+ record.lastEmittedTask = task.title;
1218
+ this.emitEvent({ type: "task", sessionId: record.id, data: task });
1219
+ }, 100);
946
1220
  }
947
1221
  resize(id, cols, rows) {
948
1222
  const record = this.mustGet(id);
@@ -959,35 +1233,72 @@ Begin now:`;
959
1233
  if (record.status !== "running") {
960
1234
  return this.snapshot(record);
961
1235
  }
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
- }
1236
+ // Clear any pending task debounce timer
1237
+ if (record.taskDebounceTimer) {
1238
+ clearTimeout(record.taskDebounceTimer);
1239
+ record.taskDebounceTimer = null;
973
1240
  }
974
- catch {
975
- record.status = "failed";
976
- record.endedAt = new Date().toISOString();
977
- record.output += "\n[wand] Failed to stop session cleanly.\n";
1241
+ if (record.claudeTaskDiscoveryTimer) {
1242
+ clearTimeout(record.claudeTaskDiscoveryTimer);
1243
+ record.claudeTaskDiscoveryTimer = null;
1244
+ }
1245
+ if (record.initialInputTimer) {
1246
+ clearTimeout(record.initialInputTimer);
1247
+ record.initialInputTimer = null;
1248
+ }
1249
+ record.stopRequested = true;
1250
+ // Kill any running child process (from JSON chat turns)
1251
+ if (record.childProcess) {
1252
+ record.childProcess.kill();
1253
+ record.childProcess = null;
978
1254
  }
1255
+ // Kill the PTY process
1256
+ if (record.ptyProcess) {
1257
+ record.ptyProcess.kill();
1258
+ }
1259
+ // Immediately update status and clear PTY references so the session no longer
1260
+ // appears "running" and subsequent sendInput() calls are rejected cleanly.
1261
+ // The async onExit handler will re-persist but will find stopRequested already true.
1262
+ record.status = "stopped";
1263
+ record.exitCode = null;
1264
+ record.endedAt = new Date().toISOString();
1265
+ record.ptyProcess = null;
1266
+ if (record.ptyBridge) {
1267
+ record.ptyBridge.removeAllListeners();
1268
+ record.ptyBridge = null;
1269
+ }
1270
+ // Update lifecycle
1271
+ this.lifecycleManager.archive(id, "Session stopped by user", "user");
979
1272
  this.persist(record);
980
1273
  return this.snapshot(record);
981
1274
  }
982
1275
  delete(id) {
983
1276
  const record = this.mustGet(id);
1277
+ // Always clear pending timers
1278
+ if (record.taskDebounceTimer) {
1279
+ clearTimeout(record.taskDebounceTimer);
1280
+ record.taskDebounceTimer = null;
1281
+ }
1282
+ if (record.claudeTaskDiscoveryTimer) {
1283
+ clearTimeout(record.claudeTaskDiscoveryTimer);
1284
+ record.claudeTaskDiscoveryTimer = null;
1285
+ }
1286
+ if (record.initialInputTimer) {
1287
+ clearTimeout(record.initialInputTimer);
1288
+ record.initialInputTimer = null;
1289
+ }
1290
+ const pendingPersist = this.persistDebounceTimers.get(id);
1291
+ if (pendingPersist) {
1292
+ clearTimeout(pendingPersist);
1293
+ this.persistDebounceTimers.delete(id);
1294
+ }
1295
+ // Kill live processes if still running
984
1296
  if (record.status === "running") {
985
1297
  try {
986
1298
  record.stopRequested = true;
987
1299
  // For native mode, kill the child process
988
1300
  if ((record.mode === "native" || record.mode === "managed") && record.childProcess) {
989
1301
  record.childProcess.kill();
990
- record.childProcess = null;
991
1302
  }
992
1303
  // For PTY mode, kill the pty process
993
1304
  if (record.ptyProcess) {
@@ -998,18 +1309,49 @@ Begin now:`;
998
1309
  // Ignore and continue deleting persisted state.
999
1310
  }
1000
1311
  }
1001
- this.sessions.delete(id);
1312
+ // Always clean up all state references, regardless of current status
1313
+ record.childProcess = null;
1314
+ record.ptyProcess = null;
1315
+ if (record.ptyBridge) {
1316
+ record.ptyBridge.removeAllListeners();
1317
+ record.ptyBridge = null;
1318
+ }
1319
+ // Delete from persistent storage BEFORE removing from in-memory map,
1320
+ // so a storage failure doesn't leave orphan records in the database.
1002
1321
  this.storage.deleteSession(id);
1322
+ this.logger.deleteSession(id);
1323
+ this.deleteClaudeCache(record);
1324
+ this.sessions.delete(id);
1325
+ this.lastPersistedMessageCount.delete(id);
1326
+ this.lifecycleManager.unregister(id);
1327
+ }
1328
+ deleteClaudeCache(record) {
1329
+ if (!record.claudeSessionId)
1330
+ return;
1331
+ const jsonlPath = path.join(getClaudeProjectDir(record.cwd), `${record.claudeSessionId}.jsonl`);
1332
+ try {
1333
+ if (existsSync(jsonlPath)) {
1334
+ unlinkSync(jsonlPath);
1335
+ }
1336
+ }
1337
+ catch {
1338
+ // Non-critical — Claude cache cleanup is best-effort
1339
+ }
1003
1340
  }
1004
- async runStartupCommands() {
1341
+ runStartupCommands() {
1005
1342
  return this.config.startupCommands.map((command) => this.start(command, this.config.defaultCwd, this.config.defaultMode));
1006
1343
  }
1007
1344
  snapshot(record) {
1345
+ // Get messages from bridge if available, otherwise use stored messages
1346
+ const messages = record.ptyBridge?.getMessages() ?? record.messages;
1008
1347
  return {
1009
1348
  id: record.id,
1010
1349
  command: record.command,
1011
1350
  cwd: record.cwd,
1012
1351
  mode: record.mode,
1352
+ autonomyPolicy: record.autonomyPolicy,
1353
+ approvalPolicy: record.approvalPolicy,
1354
+ allowedScopes: record.allowedScopes,
1013
1355
  status: record.status,
1014
1356
  exitCode: record.exitCode,
1015
1357
  startedAt: record.startedAt,
@@ -1017,15 +1359,200 @@ Begin now:`;
1017
1359
  output: record.output,
1018
1360
  archived: record.archived,
1019
1361
  archivedAt: record.archivedAt,
1020
- claudeSessionId: record.claudeSessionId,
1021
- messages: record.messages.length > 0 ? record.messages : undefined
1362
+ permissionBlocked: this.isPermissionBlocked(record),
1363
+ pendingEscalation: record.pendingEscalation || undefined,
1364
+ lastEscalationResult: record.lastEscalationResult || undefined,
1365
+ claudeSessionId: record.claudeSessionId || null,
1366
+ messages: messages.length > 0 ? messages : undefined,
1367
+ resumedFromSessionId: record.resumedFromSessionId ?? undefined,
1368
+ resumedToSessionId: record.resumedToSessionId ?? undefined,
1369
+ autoRecovered: record.autoRecovered ?? false
1022
1370
  };
1023
1371
  }
1372
+ isPermissionBlocked(record) {
1373
+ return record.ptyBridge?.isPermissionBlocked() ?? record.pendingEscalation !== null;
1374
+ }
1375
+ defaultAutonomyPolicy(mode) {
1376
+ if (mode === "agent" || mode === "agent-max" || mode === "managed" || mode === "native" || mode === "full-access") {
1377
+ return "agent";
1378
+ }
1379
+ return "assist";
1380
+ }
1381
+ resolveEscalation(id, requestId, resolution) {
1382
+ return this.resolvePermission(id, resolution ?? "approve_once", requestId);
1383
+ }
1384
+ approvePermission(id) {
1385
+ return this.resolvePermission(id, "approve_once");
1386
+ }
1387
+ denyPermission(id) {
1388
+ return this.resolvePermission(id, "deny");
1389
+ }
1390
+ /**
1391
+ * Canonical permission resolution method.
1392
+ * All other permission methods delegate to this.
1393
+ * @param resolution - "approve_once", "approve_turn", or "deny"
1394
+ * @param requestId - Optional escalation request ID for validation
1395
+ */
1396
+ resolvePermission(id, resolution, requestId) {
1397
+ const record = this.mustGet(id);
1398
+ // Validate requestId if provided
1399
+ if (requestId && record.pendingEscalation) {
1400
+ if (record.pendingEscalation.requestId !== requestId) {
1401
+ throw new Error("Escalation request not found.");
1402
+ }
1403
+ }
1404
+ // Record escalation result for audit trail
1405
+ if (record.pendingEscalation) {
1406
+ record.lastEscalationResult = {
1407
+ requestId: record.pendingEscalation.requestId,
1408
+ resolution,
1409
+ reason: record.pendingEscalation.reason,
1410
+ };
1411
+ }
1412
+ // Handle "approve_turn" memory — only in ProcessManager for non-bridge sessions
1413
+ if (resolution === "approve_turn" && record.pendingEscalation && !record.ptyBridge) {
1414
+ record.rememberedEscalationScopes.add(record.pendingEscalation.scope);
1415
+ if (record.pendingEscalation.target) {
1416
+ record.rememberedEscalationTargets.add(record.pendingEscalation.target);
1417
+ }
1418
+ }
1419
+ // Resolve via bridge or direct PTY write
1420
+ if (record.ptyBridge) {
1421
+ record.ptyBridge.resolvePermission(resolution);
1422
+ }
1423
+ else if (record.ptyProcess && record.status === "running") {
1424
+ record.ptyProcess.write(resolution === "deny" ? "n\r" : "\r");
1425
+ }
1426
+ record.ptyPermissionBlocked = false;
1427
+ record.pendingEscalation = null;
1428
+ this.persist(record);
1429
+ return this.snapshot(record);
1430
+ }
1024
1431
  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);
1432
+ // Update messages from bridge before persisting
1433
+ const messages = record.ptyBridge?.getMessages() ?? record.messages;
1434
+ if (messages !== record.messages) {
1435
+ record.messages = messages;
1436
+ }
1437
+ // Use lightweight metadata-only write (skips large messages JSON)
1438
+ this.storage.saveSessionMetadata(this.snapshot(record));
1439
+ this.logger.saveMetadata(record.id, {
1440
+ id: record.id,
1441
+ command: record.command,
1442
+ status: record.status,
1443
+ startedAt: record.startedAt,
1444
+ endedAt: record.endedAt,
1445
+ claudeSessionId: record.claudeSessionId,
1446
+ resumedFromSessionId: record.resumedFromSessionId ?? null,
1447
+ resumedToSessionId: record.resumedToSessionId ?? null,
1448
+ autoRecovered: record.autoRecovered ?? false,
1449
+ });
1450
+ // Save structured messages to file only when count changes
1451
+ if (messages.length > 0) {
1452
+ const lastCount = this.lastPersistedMessageCount.get(record.id) ?? 0;
1453
+ if (messages.length !== lastCount) {
1454
+ this.lastPersistedMessageCount.set(record.id, messages.length);
1455
+ this.logger.saveMessages(record.id, messages);
1456
+ }
1457
+ }
1458
+ }
1459
+ /**
1460
+ * Schedule a debounced persist call for the given record.
1461
+ * Multiple calls within the debounce window are coalesced into a single write.
1462
+ * Use this in hot paths (e.g. onData) to reduce I/O pressure.
1463
+ */
1464
+ schedulePersist(record) {
1465
+ const existing = this.persistDebounceTimers.get(record.id);
1466
+ if (existing) {
1467
+ clearTimeout(existing);
1468
+ }
1469
+ const timer = setTimeout(() => {
1470
+ this.persistDebounceTimers.delete(record.id);
1471
+ this.persist(record);
1472
+ }, 1000);
1473
+ this.persistDebounceTimers.set(record.id, timer);
1474
+ }
1475
+ /**
1476
+ * Immediately persist any pending debounced write and clear the timer.
1477
+ * Use this at critical points (exit, stop, delete) to ensure no data loss.
1478
+ */
1479
+ flushPersist(record) {
1480
+ const existing = this.persistDebounceTimers.get(record.id);
1481
+ if (existing) {
1482
+ clearTimeout(existing);
1483
+ this.persistDebounceTimers.delete(record.id);
1484
+ }
1485
+ this.persist(record);
1486
+ }
1487
+ backfillExitedClaudeSessionIds() {
1488
+ for (const record of this.sessions.values()) {
1489
+ record.messages = snapshotMessages(record);
1490
+ if (!shouldBackfillClaudeSessionId(record)) {
1491
+ continue;
1492
+ }
1493
+ const discoveredSessionId = findRealClaudeProjectSessionId(record.cwd, record.startedAt);
1494
+ if (!discoveredSessionId) {
1495
+ continue;
1496
+ }
1497
+ record.claudeSessionId = discoveredSessionId;
1498
+ this.persist(record);
1499
+ }
1500
+ }
1501
+ /**
1502
+ * Auto-recover the most recent exited session that has a Claude session ID.
1503
+ * Only resumes one session per server start, using the most recent eligible
1504
+ * session. Sets `resumedToSessionId` on the original session and
1505
+ * `autoRecovered: true` on the new session.
1506
+ */
1507
+ autoRecoverExitedSessions() {
1508
+ // Find eligible exited sessions
1509
+ const eligibleSessions = [];
1510
+ for (const record of this.sessions.values()) {
1511
+ record.messages = snapshotMessages(record);
1512
+ if (shouldAutoResumeSession(record)) {
1513
+ eligibleSessions.push(record);
1514
+ }
1515
+ }
1516
+ if (eligibleSessions.length === 0)
1517
+ return;
1518
+ // Sort by startedAt descending (most recent first)
1519
+ eligibleSessions.sort((a, b) => b.startedAt.localeCompare(a.startedAt));
1520
+ // Only auto-recover the single most recent session
1521
+ const original = eligibleSessions[0];
1522
+ const isClaude = /^claude\b/.test(original.command.trim());
1523
+ if (!isClaude)
1524
+ return;
1525
+ // If no claudeSessionId is bound yet, try to discover it via proximity search
1526
+ if (!original.claudeSessionId) {
1527
+ const discovered = findRealClaudeProjectSessionId(original.cwd, original.startedAt);
1528
+ if (discovered) {
1529
+ original.claudeSessionId = discovered;
1530
+ process.stderr.write(`[wand] Backfilled Claude session ID for auto-recovery: ${discovered}\n`);
1531
+ this.persist(original);
1532
+ }
1533
+ }
1534
+ if (!original.claudeSessionId) {
1535
+ console.error(`[ProcessManager] Skipping auto-recovery: no Claude session ID for session ${original.id}`);
1536
+ return;
1537
+ }
1538
+ console.error(`[ProcessManager] Auto-recovering session ${original.id} with Claude session ID ${original.claudeSessionId}`);
1539
+ const resumeCommand = `${original.command.trim()} --resume ${original.claudeSessionId}`;
1540
+ let newRecord = null;
1541
+ try {
1542
+ const snapshot = this.start(resumeCommand, original.cwd, original.mode, undefined, {
1543
+ resumedFromSessionId: original.id,
1544
+ autoRecovered: true
1545
+ });
1546
+ newRecord = this.sessions.get(snapshot.id) ?? null;
1547
+ if (!newRecord)
1548
+ return;
1549
+ // Set resumedToSessionId on the original session
1550
+ original.resumedToSessionId = snapshot.id;
1551
+ this.storage.saveSession(this.snapshot(original));
1552
+ console.error(`[ProcessManager] Auto-recovered session ${snapshot.id} from ${original.id}`);
1553
+ }
1554
+ catch (err) {
1555
+ console.error(`[ProcessManager] Auto-recovery failed: ${String(err)}`);
1029
1556
  }
1030
1557
  }
1031
1558
  archiveExpiredSessions() {
@@ -1053,7 +1580,14 @@ Begin now:`;
1053
1580
  throw new Error("Command is not allowed by current configuration.");
1054
1581
  }
1055
1582
  }
1583
+ /**
1584
+ * @deprecated Only retained for non-Claude-CLI sessions without ptyBridge.
1585
+ * For Claude CLI sessions, auto-approval is handled by ClaudePtyBridge.detectPermission().
1586
+ */
1056
1587
  autoConfirmWithRecord(record, output, ptyProcess) {
1588
+ if (!record.autoApprovePermissions) {
1589
+ return;
1590
+ }
1057
1591
  record.confirmWindow = appendWindow(record.confirmWindow, output, CONFIRM_WINDOW_SIZE);
1058
1592
  const normalized = normalizePromptText(record.confirmWindow);
1059
1593
  const now = Date.now();
@@ -1064,8 +1598,6 @@ Begin now:`;
1064
1598
  // Check for Claude's tool permission prompt patterns
1065
1599
  const toolPermissionPrompt = /\bdo you want to\b/i.test(normalized) &&
1066
1600
  /\(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
1601
  // Reduced cooldown for faster response
1070
1602
  if (now - record.lastAutoConfirmAt < 500) {
1071
1603
  return;
@@ -1073,21 +1605,110 @@ Begin now:`;
1073
1605
  const shouldConfirm = trustFolderPrompt || claudeConfirmPrompt || toolPermissionPrompt || PROMPT_PATTERNS.some((pattern) => pattern.test(normalized));
1074
1606
  if (shouldConfirm) {
1075
1607
  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");
1608
+ process.stderr.write(`[wand] Auto-confirming prompt for ${record.mode} mode\n`);
1609
+ // Always auto-confirm by sending Enter directly
1610
+ ptyProcess.write("\r");
1611
+ }
1612
+ }
1613
+ /**
1614
+ * Handle events from ClaudePtyBridge
1615
+ */
1616
+ handleBridgeEvent(record, event) {
1617
+ switch (event.type) {
1618
+ case "output.raw":
1619
+ // Sync record.output from bridge before emitting so the event carries fresh data
1620
+ record.output = record.ptyBridge?.getRawOutput() ?? record.output;
1621
+ // Emit output event for terminal view
1622
+ this.emitEvent({
1623
+ type: "output",
1624
+ sessionId: event.sessionId,
1625
+ data: {
1626
+ chunk: event.data.chunk,
1627
+ output: record.output,
1628
+ messages: record.ptyBridge?.getMessages(),
1629
+ permissionBlocked: this.isPermissionBlocked(record),
1630
+ },
1631
+ });
1632
+ break;
1633
+ case "output.chat":
1634
+ // Sync record.output from bridge before emitting so the event carries fresh data
1635
+ record.output = record.ptyBridge?.getRawOutput() ?? record.output;
1636
+ // Emit output event with updated messages for chat view
1637
+ this.emitEvent({
1638
+ type: "output",
1639
+ sessionId: event.sessionId,
1640
+ data: {
1641
+ output: record.output,
1642
+ messages: record.ptyBridge?.getMessages(),
1643
+ permissionBlocked: this.isPermissionBlocked(record),
1644
+ },
1645
+ });
1646
+ break;
1647
+ case "permission.prompt": {
1648
+ const data = event.data;
1649
+ record.pendingEscalation = {
1650
+ requestId: `bridge-${Date.now()}`,
1651
+ scope: data.scope,
1652
+ runner: "pty",
1653
+ source: "tool_permission_request",
1654
+ target: data.target,
1655
+ reason: data.prompt,
1656
+ };
1657
+ record.ptyPermissionBlocked = true;
1658
+ // Emit status event with full permission details for UI
1659
+ this.emitEvent({
1660
+ type: "status",
1661
+ sessionId: event.sessionId,
1662
+ data: {
1663
+ permissionBlocked: true,
1664
+ permissionRequest: {
1665
+ scope: data.scope,
1666
+ target: data.target,
1667
+ prompt: data.prompt,
1668
+ },
1669
+ },
1670
+ });
1671
+ break;
1084
1672
  }
1673
+ case "permission.resolved":
1674
+ record.pendingEscalation = null;
1675
+ record.ptyPermissionBlocked = false;
1676
+ this.emitEvent({
1677
+ type: "status",
1678
+ sessionId: event.sessionId,
1679
+ data: { permissionBlocked: false },
1680
+ });
1681
+ break;
1682
+ case "session.id":
1683
+ // Claude session ID captured - already handled in onData
1684
+ break;
1685
+ case "chat.turn":
1686
+ // Turn completed - persist full messages snapshot
1687
+ record.messages = record.ptyBridge?.getMessages() ?? record.messages;
1688
+ // Clear remembered permissions at turn boundaries
1689
+ record.ptyBridge?.clearRememberedPermissions();
1690
+ record.rememberedEscalationScopes.clear();
1691
+ record.rememberedEscalationTargets.clear();
1692
+ this.lifecycleManager.stopThinking(record.id);
1693
+ this.lifecycleManager.waitingInput(record.id);
1694
+ this.persist(record);
1695
+ this.storage.saveSession(this.snapshot(record));
1696
+ break;
1697
+ case "ended":
1698
+ // Session ended - handled in onExit
1699
+ break;
1085
1700
  }
1086
1701
  }
1702
+ /** Check if a command is a Claude CLI command */
1703
+ isClaudeCommand(command) {
1704
+ const trimmed = command.trim();
1705
+ return /^claude\b/.test(trimmed);
1706
+ }
1087
1707
  mustGet(id) {
1088
1708
  const record = this.sessions.get(id);
1089
1709
  if (!record) {
1090
- throw new Error("Session not found.");
1710
+ console.error("[ProcessManager] Session lookup failed", { sessionId: id });
1711
+ throw new SessionInputError("Session not found.", "SESSION_NOT_FOUND", id);
1091
1712
  }
1092
1713
  return record;
1093
1714
  }
@@ -1095,35 +1716,37 @@ Begin now:`;
1095
1716
  if (os.platform() === "win32") {
1096
1717
  return ["/d", "/s", "/c", command];
1097
1718
  }
1098
- return ["-ic", command];
1719
+ // -l: login shell — sources ~/.bash_profile, ~/.profile, etc., ensuring PATH
1720
+ // and other env vars set by profile files are available.
1721
+ // -c: run the following command.
1722
+ // Using -ic (interactive + command) skips login-shell initialization on many
1723
+ // platforms, which causes commands that depend on profile-set env vars to fail
1724
+ // immediately with "command not found" — a silent exit before onExit is ready.
1725
+ return ["-lc", command];
1099
1726
  }
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
- }
1727
+ shouldAutoApprovePermissions(command, mode) {
1728
+ if (!/^claude(?:\s|$)/.test(command)) {
1729
+ return false;
1730
+ }
1731
+ // Root mode: always auto-approve (Claude CLI refuses --permission-mode bypassPermissions under root)
1732
+ if (isRunningAsRoot()) {
1733
+ return true;
1734
+ }
1735
+ if (mode === "full-access" || mode === "auto-edit") {
1736
+ return true;
1111
1737
  }
1738
+ if (mode === "managed" || mode === "native") {
1739
+ return true;
1740
+ }
1741
+ return false;
1742
+ }
1743
+ processCommandForMode(command, _mode) {
1744
+ // Don't automatically add --enable-auto-mode as it may not be available
1745
+ // for all plans and can cause issues with normal interactive mode.
1746
+ // Let users specify it explicitly if they want auto mode.
1112
1747
  return command;
1113
1748
  }
1114
1749
  }
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
1750
  function clampDimension(value, min, max) {
1128
1751
  if (!Number.isFinite(value)) {
1129
1752
  return min;