@co0ontty/wand 0.3.0 → 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.
@@ -1,5 +1,6 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { EventEmitter } from "node:events";
3
+ import { existsSync, unlinkSync, openSync, readSync, closeSync, readFileSync, readdirSync, statSync } from "node:fs";
3
4
  import path from "node:path";
4
5
  import process from "node:process";
5
6
  import os from "node:os";
@@ -7,6 +8,7 @@ import pty from "node-pty";
7
8
  import { SessionLogger } from "./session-logger.js";
8
9
  import { SessionLifecycleManager } from "./session-lifecycle.js";
9
10
  import { ClaudePtyBridge } from "./claude-pty-bridge.js";
11
+ import { appendWindow, normalizePromptText } from "./pty-text-utils.js";
10
12
  /** Check if the current process is running as root (UID 0). */
11
13
  function isRunningAsRoot() {
12
14
  return process.getuid?.() === 0 || process.geteuid?.() === 0;
@@ -38,26 +40,682 @@ const PROMPT_PATTERNS = [
38
40
  /\bwould you like to\b/i,
39
41
  /\bshall i\b/i,
40
42
  /\bcan i\b/i,
41
- /\bpermission\b/i,
42
43
  /\bgrant\b.*\bpermission\b/i
43
44
  ];
44
- // Patterns that indicate a selection-based prompt (needs Enter, not 'y')
45
- const SELECTION_PROMPT_PATTERNS = [
46
- /\bwould you like to\b/i,
47
- /\bgrant.*permission\b/i,
48
- /\bpermission\b/i,
49
- /\btrust.*folder\b/i,
50
- /\bconfirm\b/i
51
- ];
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
+ }
52
682
  const MAX_SESSIONS = 50;
53
683
  const ARCHIVE_AFTER_MS = 1000 * 60 * 60 * 24;
54
684
  const CONFIRM_WINDOW_SIZE = 800;
55
685
  // Claude 会话 ID 格式:UUID v4
56
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;
57
- /** Append text to a windowed buffer, trimming from start if over max size. */
58
- function appendWindow(buffer, chunk, maxSize) {
59
- const next = buffer + chunk;
60
- 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
+ }
61
719
  }
62
720
  export class ProcessManager extends EventEmitter {
63
721
  config;
@@ -65,6 +723,10 @@ export class ProcessManager extends EventEmitter {
65
723
  sessions = new Map();
66
724
  logger;
67
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();
68
730
  constructor(config, storage, configDir) {
69
731
  super();
70
732
  this.config = config;
@@ -84,6 +746,7 @@ export class ProcessManager extends EventEmitter {
84
746
  });
85
747
  for (const snapshot of this.storage.loadSessions()) {
86
748
  const isClaudeCmd = /^claude\b/.test(snapshot.command.trim());
749
+ const resumeCommandSessionId = getResumeCommandSessionId(snapshot.command);
87
750
  // Sessions restored from storage have ptyProcess: null — the old server's PTY
88
751
  // belongs to a dead process. Mark running sessions as exited so the UI
89
752
  // reflects reality and users can start fresh sessions.
@@ -112,7 +775,11 @@ export class ProcessManager extends EventEmitter {
112
775
  ptyBridge: null,
113
776
  currentTask: null,
114
777
  taskDebounceTimer: null,
115
- lastEmittedTask: null
778
+ lastEmittedTask: null,
779
+ knownClaudeTaskIds: undefined,
780
+ claudeTaskDiscoveryTimer: null,
781
+ knownClaudeProjectMtimes: isClaudeCmd ? listClaudeProjectSessionMtimes(updated.cwd) : undefined,
782
+ claudeSessionId: resumeCommandSessionId ?? updated.claudeSessionId
116
783
  });
117
784
  this.lifecycleManager.register(snapshot.id, "idle");
118
785
  console.error(`[ProcessManager] Restored session ${snapshot.id} marked as exited (PTY orphaned)`);
@@ -140,11 +807,21 @@ export class ProcessManager extends EventEmitter {
140
807
  ptyBridge: null,
141
808
  currentTask: null,
142
809
  taskDebounceTimer: null,
143
- lastEmittedTask: null
810
+ lastEmittedTask: null,
811
+ knownClaudeTaskIds: undefined,
812
+ claudeTaskDiscoveryTimer: null,
813
+ knownClaudeProjectMtimes: isClaudeCmd ? listClaudeProjectSessionMtimes(snapshot.cwd) : undefined,
814
+ claudeSessionId: resumeCommandSessionId ?? snapshot.claudeSessionId
144
815
  });
145
816
  this.lifecycleManager.register(snapshot.id, "archived");
146
817
  }
147
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
+ });
148
825
  this.archiveExpiredSessions();
149
826
  }
150
827
  on(event, listener) {
@@ -172,19 +849,30 @@ export class ProcessManager extends EventEmitter {
172
849
  })
173
850
  .slice(0, this.sessions.size - MAX_SESSIONS + 1)
174
851
  .forEach((id) => {
852
+ const record = this.sessions.get(id);
853
+ if (record) {
854
+ this.logger.deleteSession(id);
855
+ this.deleteClaudeCache(record);
856
+ }
175
857
  this.sessions.delete(id);
858
+ this.lastPersistedMessageCount.delete(id);
176
859
  this.storage.deleteSession(id);
177
860
  });
178
861
  }
179
- start(command, cwd, mode, initialInput) {
862
+ start(command, cwd, mode, initialInput, opts) {
180
863
  this.assertCommandAllowed(command);
181
864
  const resolvedCwd = cwd
182
865
  ? path.resolve(process.cwd(), cwd)
183
866
  : path.resolve(process.cwd(), this.config.defaultCwd);
867
+ const isClaudeCmd = this.isClaudeCommand(command);
184
868
  // For full-access mode with claude, add permission flags
185
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();
186
875
  const id = randomUUID();
187
- const isClaudeCmd = this.isClaudeCommand(command);
188
876
  const record = {
189
877
  id,
190
878
  command,
@@ -195,7 +883,7 @@ export class ProcessManager extends EventEmitter {
195
883
  allowedScopes: [],
196
884
  status: "running",
197
885
  exitCode: null,
198
- startedAt: new Date().toISOString(),
886
+ startedAt,
199
887
  endedAt: null,
200
888
  output: "",
201
889
  archived: false,
@@ -203,7 +891,7 @@ export class ProcessManager extends EventEmitter {
203
891
  permissionBlocked: undefined,
204
892
  pendingEscalation: null,
205
893
  lastEscalationResult: null,
206
- claudeSessionId: null,
894
+ claudeSessionId: initialClaudeSessionId,
207
895
  processId: null,
208
896
  ptyProcess: null,
209
897
  stopRequested: false,
@@ -211,6 +899,8 @@ export class ProcessManager extends EventEmitter {
211
899
  ptyPermissionBlocked: false,
212
900
  lastAutoConfirmAt: 0,
213
901
  autoApprovePermissions: this.shouldAutoApprovePermissions(command, mode),
902
+ resumedFromSessionId: opts?.resumedFromSessionId ?? null,
903
+ autoRecovered: opts?.autoRecovered ?? false,
214
904
  rememberedEscalationScopes: new Set(),
215
905
  rememberedEscalationTargets: new Set(),
216
906
  storedOutput: "",
@@ -219,7 +909,10 @@ export class ProcessManager extends EventEmitter {
219
909
  ptyBridge: null,
220
910
  currentTask: null,
221
911
  taskDebounceTimer: null,
222
- lastEmittedTask: null
912
+ lastEmittedTask: null,
913
+ knownClaudeTaskIds: knownClaudeTaskIds ?? undefined,
914
+ claudeTaskDiscoveryTimer: null,
915
+ knownClaudeProjectMtimes: knownClaudeProjectMtimes ?? undefined
223
916
  };
224
917
  // Create PTY bridge for this session
225
918
  record.ptyBridge = new ClaudePtyBridge({
@@ -275,15 +968,28 @@ export class ProcessManager extends EventEmitter {
275
968
  const current = this.sessions.get(id);
276
969
  if (!current)
277
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
+ }
278
979
  if (current.ptyBridge) {
279
980
  current.ptyBridge.onExit(exitCode);
981
+ current.ptyBridge.removeAllListeners();
280
982
  }
983
+ current.pendingEscalation = null;
984
+ current.ptyPermissionBlocked = false;
281
985
  current.status = current.stopRequested ? "stopped" : exitCode === 0 ? "exited" : "failed";
282
986
  current.exitCode = current.stopRequested ? null : exitCode;
283
987
  current.endedAt = new Date().toISOString();
284
988
  current.ptyProcess = null;
285
989
  this.lifecycleManager.archive(id, `Session ${current.status}`, current.stopRequested ? "user" : "error");
286
- this.persist(current);
990
+ this.flushPersist(current);
991
+ // Final full snapshot with messages to SQLite (persist() only saves metadata)
992
+ this.storage.saveSession(this.snapshot(current));
287
993
  this.emitEvent({ type: "ended", sessionId: id, data: this.snapshot(current) });
288
994
  });
289
995
  // Set PTY write function for bridge (for permission approval).
@@ -336,21 +1042,65 @@ export class ProcessManager extends EventEmitter {
336
1042
  rec.claudeSessionId = bridgeSessionId;
337
1043
  process.stderr.write(`[wand] Captured Claude session ID: ${bridgeSessionId}\n`);
338
1044
  }
339
- // Auto-confirm for full-access mode
340
- if (rec.autoApprovePermissions) {
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`);
1057
+ }
1058
+ }
1059
+ // Auto-confirm for full-access mode (legacy path for non-Claude sessions without ptyBridge)
1060
+ if (rec.autoApprovePermissions && !rec.ptyBridge) {
341
1061
  this.autoConfirmWithRecord(rec, chunk, child);
342
1062
  }
343
1063
  if (initialInput && !initialInputSent && chunk.includes("❯")) {
344
1064
  sendInitialInput();
345
1065
  }
346
- this.persist(rec);
1066
+ this.schedulePersist(rec);
347
1067
  });
348
1068
  if (initialInput) {
349
- setTimeout(() => {
1069
+ record.initialInputTimer = setTimeout(() => {
1070
+ record.initialInputTimer = null;
350
1071
  if (!initialInputSent)
351
1072
  sendInitialInput();
352
1073
  }, 3000);
353
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
+ }
354
1104
  return this.snapshot(record);
355
1105
  }
356
1106
  list() {
@@ -359,6 +1109,32 @@ export class ProcessManager extends EventEmitter {
359
1109
  .sort((a, b) => b.startedAt.localeCompare(a.startedAt))
360
1110
  .map((session) => this.snapshot(session));
361
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
+ }
362
1138
  get(id) {
363
1139
  this.archiveExpiredSessions();
364
1140
  const record = this.sessions.get(id);
@@ -462,22 +1238,23 @@ export class ProcessManager extends EventEmitter {
462
1238
  clearTimeout(record.taskDebounceTimer);
463
1239
  record.taskDebounceTimer = null;
464
1240
  }
465
- try {
466
- record.stopRequested = true;
467
- // Kill any running child process (from JSON chat turns)
468
- if (record.childProcess) {
469
- record.childProcess.kill();
470
- record.childProcess = null;
471
- }
472
- // Kill the PTY process
473
- if (record.ptyProcess) {
474
- record.ptyProcess.kill();
475
- }
1241
+ if (record.claudeTaskDiscoveryTimer) {
1242
+ clearTimeout(record.claudeTaskDiscoveryTimer);
1243
+ record.claudeTaskDiscoveryTimer = null;
476
1244
  }
477
- catch {
478
- record.status = "failed";
479
- record.endedAt = new Date().toISOString();
480
- record.output += "\n[wand] Failed to stop session cleanly.\n";
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;
1254
+ }
1255
+ // Kill the PTY process
1256
+ if (record.ptyProcess) {
1257
+ record.ptyProcess.kill();
481
1258
  }
482
1259
  // Immediately update status and clear PTY references so the session no longer
483
1260
  // appears "running" and subsequent sendInput() calls are rejected cleanly.
@@ -486,7 +1263,10 @@ export class ProcessManager extends EventEmitter {
486
1263
  record.exitCode = null;
487
1264
  record.endedAt = new Date().toISOString();
488
1265
  record.ptyProcess = null;
489
- record.ptyBridge = null;
1266
+ if (record.ptyBridge) {
1267
+ record.ptyBridge.removeAllListeners();
1268
+ record.ptyBridge = null;
1269
+ }
490
1270
  // Update lifecycle
491
1271
  this.lifecycleManager.archive(id, "Session stopped by user", "user");
492
1272
  this.persist(record);
@@ -499,6 +1279,19 @@ export class ProcessManager extends EventEmitter {
499
1279
  clearTimeout(record.taskDebounceTimer);
500
1280
  record.taskDebounceTimer = null;
501
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
+ }
502
1295
  // Kill live processes if still running
503
1296
  if (record.status === "running") {
504
1297
  try {
@@ -519,11 +1312,33 @@ export class ProcessManager extends EventEmitter {
519
1312
  // Always clean up all state references, regardless of current status
520
1313
  record.childProcess = null;
521
1314
  record.ptyProcess = null;
522
- record.ptyBridge = null;
523
- this.sessions.delete(id);
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.
524
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);
525
1327
  }
526
- async runStartupCommands() {
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
+ }
1340
+ }
1341
+ runStartupCommands() {
527
1342
  return this.config.startupCommands.map((command) => this.start(command, this.config.defaultCwd, this.config.defaultMode));
528
1343
  }
529
1344
  snapshot(record) {
@@ -548,7 +1363,10 @@ export class ProcessManager extends EventEmitter {
548
1363
  pendingEscalation: record.pendingEscalation || undefined,
549
1364
  lastEscalationResult: record.lastEscalationResult || undefined,
550
1365
  claudeSessionId: record.claudeSessionId || null,
551
- messages: messages.length > 0 ? messages : undefined
1366
+ messages: messages.length > 0 ? messages : undefined,
1367
+ resumedFromSessionId: record.resumedFromSessionId ?? undefined,
1368
+ resumedToSessionId: record.resumedToSessionId ?? undefined,
1369
+ autoRecovered: record.autoRecovered ?? false
552
1370
  };
553
1371
  }
554
1372
  isPermissionBlocked(record) {
@@ -561,84 +1379,49 @@ export class ProcessManager extends EventEmitter {
561
1379
  return "assist";
562
1380
  }
563
1381
  resolveEscalation(id, requestId, resolution) {
564
- const record = this.mustGet(id);
565
- const escalation = record.pendingEscalation;
566
- if (!escalation || escalation.requestId !== requestId) {
567
- throw new Error("Escalation request not found.");
568
- }
569
- const finalResolution = resolution ?? "approve_once";
570
- record.lastEscalationResult = {
571
- requestId,
572
- resolution: finalResolution,
573
- reason: escalation.reason
574
- };
575
- if (finalResolution === "deny") {
576
- record.pendingEscalation = null;
577
- if (record.ptyProcess && record.status === "running") {
578
- record.ptyProcess.write("n\r");
579
- }
580
- record.ptyPermissionBlocked = false;
581
- this.persist(record);
582
- return this.snapshot(record);
583
- }
584
- if (finalResolution === "approve_turn") {
585
- record.rememberedEscalationScopes.add(escalation.scope);
586
- if (escalation.target) {
587
- record.rememberedEscalationTargets.add(escalation.target);
588
- }
589
- }
590
- record.pendingEscalation = null;
591
- record.ptyPermissionBlocked = false;
592
- if (record.ptyProcess && record.status === "running") {
593
- record.ptyProcess.write("\r");
594
- }
595
- this.persist(record);
596
- return this.snapshot(record);
1382
+ return this.resolvePermission(id, resolution ?? "approve_once", requestId);
597
1383
  }
598
1384
  approvePermission(id) {
599
- const record = this.mustGet(id);
600
- // Use bridge for permission resolution
601
- if (record.ptyBridge) {
602
- record.ptyBridge.resolvePermission("approve_once");
603
- }
604
- else if (record.ptyProcess && record.status === "running") {
605
- record.ptyProcess.write("\r");
606
- }
607
- record.ptyPermissionBlocked = false;
608
- record.pendingEscalation = null;
609
- this.persist(record);
610
- return this.snapshot(record);
1385
+ return this.resolvePermission(id, "approve_once");
611
1386
  }
612
1387
  denyPermission(id) {
613
- const record = this.mustGet(id);
614
- // Use bridge for permission resolution
615
- if (record.ptyBridge) {
616
- record.ptyBridge.resolvePermission("deny");
617
- }
618
- else if (record.ptyProcess && record.status === "running") {
619
- record.ptyProcess.write("n\r");
620
- }
621
- record.ptyPermissionBlocked = false;
622
- record.pendingEscalation = null;
623
- this.persist(record);
624
- return this.snapshot(record);
1388
+ return this.resolvePermission(id, "deny");
625
1389
  }
626
1390
  /**
627
- * Resolve permission with specific resolution type.
1391
+ * Canonical permission resolution method.
1392
+ * All other permission methods delegate to this.
628
1393
  * @param resolution - "approve_once", "approve_turn", or "deny"
1394
+ * @param requestId - Optional escalation request ID for validation
629
1395
  */
630
- resolvePermission(id, resolution) {
1396
+ resolvePermission(id, resolution, requestId) {
631
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
632
1420
  if (record.ptyBridge) {
633
1421
  record.ptyBridge.resolvePermission(resolution);
634
1422
  }
635
1423
  else if (record.ptyProcess && record.status === "running") {
636
- if (resolution === "deny") {
637
- record.ptyProcess.write("n\r");
638
- }
639
- else {
640
- record.ptyProcess.write("\r");
641
- }
1424
+ record.ptyProcess.write(resolution === "deny" ? "n\r" : "\r");
642
1425
  }
643
1426
  record.ptyPermissionBlocked = false;
644
1427
  record.pendingEscalation = null;
@@ -651,10 +1434,125 @@ export class ProcessManager extends EventEmitter {
651
1434
  if (messages !== record.messages) {
652
1435
  record.messages = messages;
653
1436
  }
654
- this.storage.saveSession(this.snapshot(record));
655
- // Save structured messages to file for analysis
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
656
1451
  if (messages.length > 0) {
657
- this.logger.saveMessages(record.id, messages);
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)}`);
658
1556
  }
659
1557
  }
660
1558
  archiveExpiredSessions() {
@@ -682,6 +1580,10 @@ export class ProcessManager extends EventEmitter {
682
1580
  throw new Error("Command is not allowed by current configuration.");
683
1581
  }
684
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
+ */
685
1587
  autoConfirmWithRecord(record, output, ptyProcess) {
686
1588
  if (!record.autoApprovePermissions) {
687
1589
  return;
@@ -696,8 +1598,6 @@ export class ProcessManager extends EventEmitter {
696
1598
  // Check for Claude's tool permission prompt patterns
697
1599
  const toolPermissionPrompt = /\bdo you want to\b/i.test(normalized) &&
698
1600
  /\(yes\b/i.test(normalized);
699
- // Check if this is a selection-based prompt (needs Enter, not 'y')
700
- const isSelectionPrompt = SELECTION_PROMPT_PATTERNS.some((pattern) => pattern.test(normalized));
701
1601
  // Reduced cooldown for faster response
702
1602
  if (now - record.lastAutoConfirmAt < 500) {
703
1603
  return;
@@ -716,6 +1616,8 @@ export class ProcessManager extends EventEmitter {
716
1616
  handleBridgeEvent(record, event) {
717
1617
  switch (event.type) {
718
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;
719
1621
  // Emit output event for terminal view
720
1622
  this.emitEvent({
721
1623
  type: "output",
@@ -729,6 +1631,8 @@ export class ProcessManager extends EventEmitter {
729
1631
  });
730
1632
  break;
731
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;
732
1636
  // Emit output event with updated messages for chat view
733
1637
  this.emitEvent({
734
1638
  type: "output",
@@ -779,11 +1683,16 @@ export class ProcessManager extends EventEmitter {
779
1683
  // Claude session ID captured - already handled in onData
780
1684
  break;
781
1685
  case "chat.turn":
782
- // Turn completed - persist messages
1686
+ // Turn completed - persist full messages snapshot
783
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();
784
1692
  this.lifecycleManager.stopThinking(record.id);
785
1693
  this.lifecycleManager.waitingInput(record.id);
786
1694
  this.persist(record);
1695
+ this.storage.saveSession(this.snapshot(record));
787
1696
  break;
788
1697
  case "ended":
789
1698
  // Session ended - handled in onExit
@@ -838,15 +1747,6 @@ export class ProcessManager extends EventEmitter {
838
1747
  return command;
839
1748
  }
840
1749
  }
841
- function normalizePromptText(value) {
842
- return value
843
- .replace(/\u001b\[(\d+)C/g, (_match, count) => " ".repeat(Number(count) || 1))
844
- .replace(/\u001b\[[0-9;?]*[ -/]*[@-~]/g, "")
845
- .replace(/\r/g, "\n")
846
- .replace(/[ \t]+/g, " ")
847
- .replace(/\n+/g, "\n")
848
- .trim();
849
- }
850
1750
  function clampDimension(value, min, max) {
851
1751
  if (!Number.isFinite(value)) {
852
1752
  return min;