@co0ontty/wand 3.1.1 → 4.0.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 (62) hide show
  1. package/dist/auth.d.ts +19 -5
  2. package/dist/auth.js +83 -45
  3. package/dist/build-info.json +3 -3
  4. package/dist/cert.d.ts +1 -1
  5. package/dist/cert.js +124 -74
  6. package/dist/config.js +25 -8
  7. package/dist/express-async.d.ts +6 -0
  8. package/dist/express-async.js +28 -0
  9. package/dist/git-quick-commit.d.ts +2 -0
  10. package/dist/git-quick-commit.js +215 -76
  11. package/dist/git-utils.d.ts +4 -0
  12. package/dist/git-utils.js +60 -11
  13. package/dist/git-worktree.d.ts +8 -1
  14. package/dist/git-worktree.js +406 -41
  15. package/dist/models.d.ts +34 -4
  16. package/dist/models.js +334 -48
  17. package/dist/process-manager.d.ts +22 -30
  18. package/dist/process-manager.js +374 -441
  19. package/dist/provider-history-scanner.d.ts +54 -0
  20. package/dist/provider-history-scanner.js +354 -0
  21. package/dist/request-limits.d.ts +1 -0
  22. package/dist/request-limits.js +8 -0
  23. package/dist/resume-policy.d.ts +2 -0
  24. package/dist/resume-policy.js +5 -0
  25. package/dist/runtime-config.d.ts +16 -0
  26. package/dist/runtime-config.js +49 -0
  27. package/dist/server-file-routes.d.ts +17 -0
  28. package/dist/server-file-routes.js +653 -0
  29. package/dist/server-session-routes.d.ts +16 -3
  30. package/dist/server-session-routes.js +170 -149
  31. package/dist/server-settings-routes.d.ts +43 -0
  32. package/dist/server-settings-routes.js +225 -0
  33. package/dist/server-update-routes.d.ts +61 -0
  34. package/dist/server-update-routes.js +215 -0
  35. package/dist/server.d.ts +6 -4
  36. package/dist/server.js +350 -1313
  37. package/dist/session-logger.d.ts +32 -2
  38. package/dist/session-logger.js +145 -15
  39. package/dist/session-registry.d.ts +27 -0
  40. package/dist/session-registry.js +153 -0
  41. package/dist/session-transport.d.ts +31 -0
  42. package/dist/session-transport.js +82 -0
  43. package/dist/storage.d.ts +24 -6
  44. package/dist/storage.js +291 -44
  45. package/dist/structured-claude-adapter.d.ts +19 -0
  46. package/dist/structured-claude-adapter.js +117 -0
  47. package/dist/structured-codex-adapter.d.ts +3 -0
  48. package/dist/structured-codex-adapter.js +29 -0
  49. package/dist/structured-opencode-adapter.d.ts +11 -0
  50. package/dist/structured-opencode-adapter.js +115 -0
  51. package/dist/structured-provider-common.d.ts +11 -0
  52. package/dist/structured-provider-common.js +77 -0
  53. package/dist/structured-session-manager.d.ts +32 -35
  54. package/dist/structured-session-manager.js +551 -605
  55. package/dist/types.d.ts +10 -0
  56. package/dist/update-helper.js +5 -1
  57. package/dist/web-ui/content/scripts.js +32 -32
  58. package/dist/web-ui/embedded-assets.d.ts +1 -1
  59. package/dist/web-ui/embedded-assets.js +2 -2
  60. package/dist/ws-broadcast.d.ts +16 -1
  61. package/dist/ws-broadcast.js +124 -58
  62. package/package.json +2 -1
@@ -1,6 +1,6 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { EventEmitter } from "node:events";
3
- import { existsSync, unlinkSync, rmSync, openSync, readSync, closeSync, readFileSync, readdirSync, statSync } from "node:fs";
3
+ import { existsSync, unlinkSync, rmSync, 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";
@@ -14,15 +14,129 @@ import { ensureNodePtyHelperExecutable } from "./ensure-node-pty-helper.js";
14
14
  import { buildLanguageDirective, buildManagedAutonomyDirective } from "./language-prompt.js";
15
15
  import { prepareSessionWorktree } from "./git-worktree.js";
16
16
  import { getCodexResumeCommandSessionId, getResumeCommandSessionId } from "./resume-policy.js";
17
- import { normalizeThinkingEffort, thinkingEffortToClaudeCliEffort, thinkingEffortToClaudeSlashEffort, thinkingEffortToCodexReasoningEffort, thinkingEffortToOpenCodeVariant } from "./structured-session-manager.js";
17
+ import { normalizeThinkingEffort, thinkingEffortToClaudeCliEffort, thinkingEffortToClaudeSlashEffort, thinkingEffortToCodexReasoningEffort, thinkingEffortToOpenCodeVariant } from "./structured-provider-common.js";
18
18
  import { generateSessionTopic } from "./session-topic.js";
19
19
  import { getErrorMessage } from "./error-utils.js";
20
20
  import { resolveSessionCwd } from "./session-cwd.js";
21
+ import { ProviderHistoryScanner, } from "./provider-history-scanner.js";
21
22
  function resolveProviderFromCommand(command) {
22
23
  if (/^codex\b/.test(command.trim()))
23
24
  return "codex";
24
25
  return /^opencode\b/.test(command.trim()) ? "opencode" : "claude";
25
26
  }
27
+ /**
28
+ * Tokenize the restricted shell-command subset accepted by the command
29
+ * allowlist. Commands still run through a login shell, so accepting raw string
30
+ * prefixes here would let an allowed executable be followed by another command
31
+ * (`claude; evil`) or be replaced with a similarly-named binary
32
+ * (`claude-malicious`).
33
+ *
34
+ * Quoted and escaped operator characters are retained as ordinary argument
35
+ * data. Unquoted shell control operators and command substitutions are rejected
36
+ * because they can introduce additional executable commands.
37
+ */
38
+ function tokenizeAllowedCommand(value) {
39
+ const tokens = [];
40
+ let token = "";
41
+ let tokenStarted = false;
42
+ let quote = null;
43
+ const pushToken = () => {
44
+ if (!tokenStarted)
45
+ return;
46
+ tokens.push(token);
47
+ token = "";
48
+ tokenStarted = false;
49
+ };
50
+ for (let index = 0; index < value.length; index += 1) {
51
+ const char = value[index];
52
+ if (char === "\n" || char === "\r") {
53
+ return null;
54
+ }
55
+ if (quote === "single") {
56
+ if (char === "'") {
57
+ quote = null;
58
+ }
59
+ else {
60
+ token += char;
61
+ }
62
+ continue;
63
+ }
64
+ if (quote === "double") {
65
+ if (char === '"') {
66
+ quote = null;
67
+ continue;
68
+ }
69
+ if (char === "\\") {
70
+ const escaped = value[index + 1];
71
+ if (escaped === undefined || escaped === "\n" || escaped === "\r")
72
+ return null;
73
+ token += escaped === "$" || escaped === "`" || escaped === '"' || escaped === "\\"
74
+ ? escaped
75
+ : `\\${escaped}`;
76
+ tokenStarted = true;
77
+ index += 1;
78
+ continue;
79
+ }
80
+ if (char === "`" || (char === "$" && value[index + 1] === "(")) {
81
+ return null;
82
+ }
83
+ token += char;
84
+ tokenStarted = true;
85
+ continue;
86
+ }
87
+ if (/\s/.test(char)) {
88
+ pushToken();
89
+ continue;
90
+ }
91
+ if (char === "'") {
92
+ quote = "single";
93
+ tokenStarted = true;
94
+ continue;
95
+ }
96
+ if (char === '"') {
97
+ quote = "double";
98
+ tokenStarted = true;
99
+ continue;
100
+ }
101
+ if (char === "\\") {
102
+ const escaped = value[index + 1];
103
+ if (escaped === undefined || escaped === "\n" || escaped === "\r")
104
+ return null;
105
+ token += escaped;
106
+ tokenStarted = true;
107
+ index += 1;
108
+ continue;
109
+ }
110
+ if (char === ";" || char === "|" || char === "&" || char === "<" || char === ">" || char === "`") {
111
+ return null;
112
+ }
113
+ if (char === "$" && value[index + 1] === "(") {
114
+ return null;
115
+ }
116
+ token += char;
117
+ tokenStarted = true;
118
+ }
119
+ if (quote !== null)
120
+ return null;
121
+ pushToken();
122
+ return tokens;
123
+ }
124
+ /** Exported for focused policy tests. */
125
+ export function isCommandAllowedByPrefixes(command, allowedPrefixes) {
126
+ if (allowedPrefixes.length === 0)
127
+ return true;
128
+ const commandTokens = tokenizeAllowedCommand(command);
129
+ if (!commandTokens || commandTokens.length === 0)
130
+ return false;
131
+ return allowedPrefixes.some((prefix) => {
132
+ const prefixTokens = tokenizeAllowedCommand(prefix);
133
+ if (!prefixTokens)
134
+ return false;
135
+ if (prefixTokens.length === 0 || prefixTokens.length > commandTokens.length)
136
+ return false;
137
+ return prefixTokens.every((token, index) => token === commandTokens[index]);
138
+ });
139
+ }
26
140
  export class SessionInputError extends Error {
27
141
  code;
28
142
  sessionId;
@@ -193,279 +307,8 @@ function isClaudeSessionFileAvailable(cwd, claudeSessionId) {
193
307
  const filePath = path.join(getClaudeProjectDir(cwd), `${claudeSessionId}.jsonl`);
194
308
  return Boolean(readClaudeProjectSessionDetails(filePath, claudeSessionId));
195
309
  }
196
- /**
197
- * Reverse the normalization done by getClaudeProjectDir.
198
- * "-vol1-1000-yolo-claude-wand" → "/vol1/1000/yolo-claude/wand"
199
- * This is lossy (real hyphens become slashes), so we try all possible
200
- * interpretations and validate with existsSync, falling back to naive replacement.
201
- */
202
- function invertNormalizedProjectDir(dirName) {
203
- // The normalization replaces every non-alphanumeric char with "-", so this
204
- // inversion is best-effort: "-" most often maps back to "/", but may also be
205
- // a literal "-", ".", or "_". We try "/" vs "-" per position and validate with
206
- // existsSync; dots/underscores in the original path can't be recovered here.
207
- const naive = dirName.replace(/-/g, "/");
208
- if (existsSync(naive))
209
- return naive;
210
- // BFS: at each hyphen position, try "/" (path separator) or "-" (literal hyphen).
211
- // Prune candidates that don't exist as directories, but only if at least one
212
- // candidate survives pruning. Otherwise keep all to allow deeper merges.
213
- const parts = dirName.split("-").filter(Boolean);
214
- if (parts.length === 0 || parts.length > 20)
215
- return naive;
216
- let candidates = ["/" + parts[0]];
217
- for (let i = 1; i < parts.length; i++) {
218
- const next = [];
219
- for (const prefix of candidates) {
220
- next.push(prefix + "/" + parts[i]);
221
- next.push(prefix + "-" + parts[i]);
222
- }
223
- if (i < parts.length - 1) {
224
- // Prune non-existent prefixes, but keep all if none exist
225
- const valid = next.filter((c) => { try {
226
- return existsSync(c);
227
- }
228
- catch {
229
- return false;
230
- } });
231
- candidates = valid.length > 0 ? valid : next;
232
- }
233
- else {
234
- candidates = next;
235
- }
236
- if (candidates.length > 200)
237
- candidates = candidates.slice(0, 200);
238
- }
239
- // Return the first candidate that exists, or the first one, or naive
240
- for (const c of candidates) {
241
- if (existsSync(c))
242
- return c;
243
- }
244
- return candidates[0] || naive;
245
- }
246
- /** Read only the first ~8KB of a JSONL file to extract summary metadata. */
247
- function readClaudeSessionSummary(filePath, id, cwd) {
248
- try {
249
- const stats = statSync(filePath);
250
- const fd = openSync(filePath, "r");
251
- const buffer = Buffer.alloc(8192);
252
- const bytesRead = readSync(fd, buffer, 0, 8192, 0);
253
- closeSync(fd);
254
- const chunk = buffer.toString("utf8", 0, bytesRead);
255
- const lines = chunk.split("\n").filter((line) => line.trim().length > 0);
256
- let timestamp = "";
257
- let firstUserMessage = "";
258
- let hasUser = false;
259
- let hasAssistant = false;
260
- for (const line of lines) {
261
- try {
262
- const parsed = JSON.parse(line);
263
- if (!timestamp && parsed.timestamp) {
264
- timestamp = parsed.timestamp;
265
- }
266
- if (parsed.type === "user" || parsed.message?.role === "user") {
267
- hasUser = true;
268
- if (!firstUserMessage) {
269
- if (typeof parsed.content === "string" && parsed.content.trim()) {
270
- firstUserMessage = parsed.content.trim().slice(0, 120);
271
- }
272
- else if (parsed.message?.content && typeof parsed.message.content === "string") {
273
- firstUserMessage = parsed.message.content.trim().slice(0, 120);
274
- }
275
- }
276
- }
277
- if (parsed.type === "assistant" || parsed.message?.role === "assistant") {
278
- hasAssistant = true;
279
- }
280
- }
281
- catch {
282
- continue;
283
- }
284
- }
285
- // cwd is passed in from the caller
286
- return {
287
- claudeSessionId: id,
288
- projectDir: path.basename(path.dirname(filePath)),
289
- cwd,
290
- firstUserMessage,
291
- timestamp: timestamp || new Date(stats.mtimeMs).toISOString(),
292
- mtimeMs: stats.mtimeMs,
293
- hasConversation: hasUser && hasAssistant,
294
- managedByWand: false,
295
- };
296
- }
297
- catch {
298
- return null;
299
- }
300
- }
301
- const WORKTREE_DIR_PATTERN = /--?\.?(?:wand-worktrees|claude-worktrees)-/;
302
- /** Scan all ~/.claude/projects/ directories for session JSONL files. */
303
- function listAllClaudeHistorySessions() {
304
- const projectsDir = path.join(os.homedir(), ".claude", "projects");
305
- try {
306
- const projectDirs = readdirSync(projectsDir, { withFileTypes: true })
307
- .filter((entry) => entry.isDirectory())
308
- .filter((entry) => !WORKTREE_DIR_PATTERN.test(entry.name));
309
- const results = [];
310
- for (const dir of projectDirs) {
311
- const dirPath = path.join(projectsDir, dir.name);
312
- const cwd = invertNormalizedProjectDir(dir.name);
313
- try {
314
- const files = readdirSync(dirPath, { withFileTypes: true })
315
- .filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl"))
316
- .map((entry) => entry.name.replace(/\.jsonl$/, ""))
317
- .filter((name) => UUID_V4_PATTERN.test(name));
318
- for (const sessionId of files) {
319
- const filePath = path.join(dirPath, `${sessionId}.jsonl`);
320
- const summary = readClaudeSessionSummary(filePath, sessionId, cwd);
321
- if (summary) {
322
- results.push(summary);
323
- }
324
- }
325
- }
326
- catch {
327
- continue;
328
- }
329
- }
330
- return results.sort((a, b) => b.mtimeMs - a.mtimeMs);
331
- }
332
- catch {
333
- return [];
334
- }
335
- }
336
- function getCodexSessionsDir() {
337
- return path.join(os.homedir(), ".codex", "sessions");
338
- }
339
- /**
340
- * codex 的 user message 里混杂了系统注入(AGENTS.md 指令、<environment_context> 等
341
- * XML 包裹块),它们都以 "#" 或 "<" 开头。真正的用户输入是首条不以这两者开头的
342
- * input_text。
343
- */
344
- function isCodexSystemInjectedText(text) {
345
- const trimmed = text.trimStart();
346
- return trimmed.startsWith("#") || trimmed.startsWith("<");
347
- }
348
- /**
349
- * Read the head of a rollout file to extract summary metadata. Codex prepends a
350
- * large session_meta (full base_instructions + AGENTS.md + <environment_context>)
351
- * before the first real user turn, so a small window misses it. 64KB covers the
352
- * first real user message in every observed session.
353
- */
354
- function readCodexSessionSummary(filePath) {
355
- try {
356
- const stats = statSync(filePath);
357
- const fd = openSync(filePath, "r");
358
- const buffer = Buffer.alloc(65536);
359
- const bytesRead = readSync(fd, buffer, 0, 65536, 0);
360
- closeSync(fd);
361
- const chunk = buffer.toString("utf8", 0, bytesRead);
362
- const lines = chunk.split("\n").filter((line) => line.trim().length > 0);
363
- let id = "";
364
- let cwd = "";
365
- let timestamp = "";
366
- let firstUserMessage = "";
367
- let firstUserAt = "";
368
- let hasUser = false;
369
- let hasAssistant = false;
370
- for (const line of lines) {
371
- let parsed;
372
- try {
373
- parsed = JSON.parse(line);
374
- }
375
- catch {
376
- continue;
377
- }
378
- if (!timestamp && parsed.timestamp) {
379
- timestamp = parsed.timestamp;
380
- }
381
- const payload = parsed.payload;
382
- if (!payload)
383
- continue;
384
- if (parsed.type === "session_meta" || payload.type === "session_meta") {
385
- if (!id && typeof payload.id === "string")
386
- id = payload.id;
387
- if (!cwd && typeof payload.cwd === "string")
388
- cwd = payload.cwd;
389
- continue;
390
- }
391
- if (payload.type === "message" && payload.role === "user") {
392
- const text = Array.isArray(payload.content)
393
- ? payload.content
394
- .filter((b) => b?.type === "input_text" && typeof b.text === "string")
395
- .map((b) => b.text)
396
- .join("")
397
- : "";
398
- if (text.trim()) {
399
- hasUser = true;
400
- if (!firstUserAt && parsed.timestamp) {
401
- firstUserAt = parsed.timestamp;
402
- }
403
- if (!firstUserMessage && !isCodexSystemInjectedText(text)) {
404
- firstUserMessage = text.trim().slice(0, 120);
405
- }
406
- }
407
- }
408
- else if (payload.type === "message" && payload.role === "assistant") {
409
- hasAssistant = true;
410
- }
411
- }
412
- if (!id)
413
- return null;
414
- return {
415
- claudeSessionId: id,
416
- cwd,
417
- firstUserMessage,
418
- firstUserAt,
419
- timestamp: timestamp || new Date(stats.mtimeMs).toISOString(),
420
- mtimeMs: stats.mtimeMs,
421
- hasUser,
422
- hasConversation: hasUser && hasAssistant,
423
- managedByWand: false,
424
- provider: "codex",
425
- };
426
- }
427
- catch {
428
- return null;
429
- }
430
- }
431
- /** Recursively collect rollout-*.jsonl paths under ~/.codex/sessions/. */
432
- function listCodexRolloutFiles() {
433
- const root = getCodexSessionsDir();
434
- try {
435
- return readdirSync(root, { recursive: true, withFileTypes: true })
436
- .filter((entry) => entry.isFile()
437
- && entry.name.startsWith("rollout-")
438
- && entry.name.endsWith(".jsonl"))
439
- .map((entry) => {
440
- // Node ≥ 20 dirents from a recursive read carry parentPath/path.
441
- const parent = entry.parentPath
442
- ?? entry.path
443
- ?? root;
444
- return path.join(parent, entry.name);
445
- });
446
- }
447
- catch {
448
- return [];
449
- }
450
- }
451
- /** Scan ~/.codex/sessions/ and return one entry per thread id (newest rollout wins). */
452
- function listAllCodexHistorySessions() {
453
- const files = listCodexRolloutFiles();
454
- // 同一 thread 同一天可能有多个 rollout 文件,按 thread id 去重保留 mtime 最新。
455
- const byThread = new Map();
456
- for (const filePath of files) {
457
- const summary = readCodexSessionSummary(filePath);
458
- if (!summary)
459
- continue;
460
- const existing = byThread.get(summary.claudeSessionId);
461
- if (!existing || summary.mtimeMs > existing.mtimeMs) {
462
- byThread.set(summary.claudeSessionId, summary);
463
- }
464
- }
465
- return Array.from(byThread.values()).sort((a, b) => b.mtimeMs - a.mtimeMs);
466
- }
467
- function listCodexSessionMtimes() {
468
- return new Map(listAllCodexHistorySessions().map((session) => [session.claudeSessionId, session.mtimeMs]));
310
+ function listCodexSessionMtimes(sessions) {
311
+ return new Map(sessions.map((session) => [session.claudeSessionId, session.mtimeMs]));
469
312
  }
470
313
  function isSameResolvedPath(left, right) {
471
314
  if (!left || !right)
@@ -481,9 +324,9 @@ function parseTimeMs(value) {
481
324
  const parsed = Date.parse(value);
482
325
  return Number.isFinite(parsed) ? parsed : null;
483
326
  }
484
- function selectCodexSessionForRecord(record) {
327
+ function selectCodexSessionForRecord(record, sessions) {
485
328
  const knownMtimes = record.knownCodexSessionMtimes ?? new Map();
486
- const candidates = listAllCodexHistorySessions()
329
+ const candidates = sessions
487
330
  .filter(isUsableCodexHistorySession)
488
331
  .filter((session) => isSameResolvedPath(session.cwd, record.cwd))
489
332
  .filter((session) => {
@@ -504,17 +347,17 @@ function selectCodexSessionForRecord(record) {
504
347
  }
505
348
  return candidates.length === 1 ? candidates[0] : null;
506
349
  }
507
- function getLatestCodexSessionId(record) {
508
- return selectCodexSessionForRecord(record)?.claudeSessionId ?? null;
350
+ function getLatestCodexSessionId(record, sessions) {
351
+ return selectCodexSessionForRecord(record, sessions)?.claudeSessionId ?? null;
509
352
  }
510
- function selectCodexSessionForTimeWindow(record) {
353
+ function selectCodexSessionForTimeWindow(record, sessions) {
511
354
  const startedAtMs = parseTimeMs(record.startedAt);
512
355
  if (startedAtMs === null)
513
356
  return null;
514
357
  const endedAtMs = parseTimeMs(record.endedAt) ?? Date.now();
515
358
  const windowStart = startedAtMs - START_TIME_SKEW_MS;
516
359
  const windowEnd = endedAtMs + START_TIME_SKEW_MS;
517
- const candidates = listAllCodexHistorySessions()
360
+ const candidates = sessions
518
361
  .filter(isUsableCodexHistorySession)
519
362
  .filter((session) => isSameResolvedPath(session.cwd, record.cwd))
520
363
  .filter((session) => {
@@ -531,11 +374,11 @@ function selectCodexSessionForTimeWindow(record) {
531
374
  });
532
375
  return candidates.length === 1 ? candidates[0] : null;
533
376
  }
534
- function recoverCodexSessionIdFromHistory(snapshot) {
377
+ function recoverCodexSessionIdFromHistory(snapshot, sessions) {
535
378
  if (snapshot.provider !== "codex" || snapshot.claudeSessionId) {
536
379
  return null;
537
380
  }
538
- return getCodexResumeCommandSessionId(snapshot.command) ?? selectCodexSessionForTimeWindow(snapshot)?.claudeSessionId ?? null;
381
+ return getCodexResumeCommandSessionId(snapshot.command) ?? selectCodexSessionForTimeWindow(snapshot, sessions)?.claudeSessionId ?? null;
539
382
  }
540
383
  function recoverClaudeSessionIdFromHistory(snapshot) {
541
384
  if (snapshot.provider !== "claude" || snapshot.claudeSessionId) {
@@ -543,25 +386,6 @@ function recoverClaudeSessionIdFromHistory(snapshot) {
543
386
  }
544
387
  return getResumeCommandSessionId(snapshot.command) ?? selectClaudeProjectSessionForTimeWindow(snapshot)?.id ?? null;
545
388
  }
546
- /** Delete every rollout file belonging to the given codex thread ids. */
547
- function deleteCodexRolloutFiles(threadIds) {
548
- if (threadIds.size === 0)
549
- return 0;
550
- let deleted = 0;
551
- for (const filePath of listCodexRolloutFiles()) {
552
- const summary = readCodexSessionSummary(filePath);
553
- if (summary && threadIds.has(summary.claudeSessionId)) {
554
- try {
555
- unlinkSync(filePath);
556
- deleted++;
557
- }
558
- catch {
559
- // Best-effort — file may already be gone
560
- }
561
- }
562
- }
563
- return deleted;
564
- }
565
389
  function snapshotMessages(record) {
566
390
  return record.ptyBridge?.getMessages() ?? record.messages;
567
391
  }
@@ -623,24 +447,34 @@ export class ProcessManager extends EventEmitter {
623
447
  storage;
624
448
  sessions = new Map();
625
449
  logger;
450
+ providerHistory = new ProviderHistoryScanner();
626
451
  /** 24h archive scan timer */
627
452
  archiveTimer = null;
628
453
  /** Per-session debounce timers for throttled persist calls */
629
454
  persistDebounceTimers = new Map();
630
455
  /** Last persisted message state per session — used to skip redundant message writes */
631
456
  lastPersistedMessageState = new Map();
457
+ /** Columns that changed since the last per-session checkpoint. */
458
+ dirtySessions = new Map();
632
459
  /** 启动时被识别为孤儿 PTY 并标记为 exited 的旧会话数(旧服务器进程已死) */
633
460
  orphanRecoveredCount = 0;
634
461
  topicRequests = new Set();
462
+ disposed = false;
635
463
  constructor(config, storage, configDir) {
636
464
  super();
637
465
  this.config = config;
638
466
  this.storage = storage;
639
467
  this.logger = new SessionLogger(configDir || path.join(process.env.HOME || process.cwd(), ".wand"), config.shortcutLogMaxBytes);
468
+ let startupCodexHistory = null;
469
+ const getStartupCodexHistory = () => {
470
+ startupCodexHistory ??= this.providerHistory.listCodexHistorySessions();
471
+ return startupCodexHistory;
472
+ };
640
473
  for (const snapshot of this.storage.loadSessions()) {
641
474
  if ((snapshot.sessionKind ?? "pty") !== "pty") {
642
475
  continue;
643
476
  }
477
+ this.lastPersistedMessageState.set(snapshot.id, getPersistedMessageState(snapshot.messages ?? []));
644
478
  const provider = snapshot.provider ?? resolveProviderFromCommand(snapshot.command);
645
479
  const isClaudeCmd = provider === "claude";
646
480
  const isCodexCmd = provider === "codex";
@@ -661,7 +495,7 @@ export class ProcessManager extends EventEmitter {
661
495
  ...snapshot,
662
496
  provider: "codex",
663
497
  endedAt: snapshot.endedAt ?? orphanEndedAt,
664
- })
498
+ }, getStartupCodexHistory())
665
499
  : null;
666
500
  const restoredSessionId = resumeCommandSessionId ?? snapshot.claudeSessionId ?? sessionIdFromHistory;
667
501
  // Sessions restored from storage have ptyProcess: null — the old server's PTY
@@ -710,10 +544,10 @@ export class ProcessManager extends EventEmitter {
710
544
  knownClaudeTaskIds: undefined,
711
545
  claudeTaskDiscoveryTimer: null,
712
546
  knownClaudeProjectMtimes: isClaudeCmd ? listClaudeProjectSessionMtimes(updated.cwd) : undefined,
713
- knownCodexSessionMtimes: isCodexCmd ? listCodexSessionMtimes() : undefined,
547
+ knownCodexSessionMtimes: isCodexCmd ? listCodexSessionMtimes(getStartupCodexHistory()) : undefined,
714
548
  codexSessionDiscoveryTimer: null,
715
549
  claudeSessionId: restoredSessionId ?? updated.claudeSessionId,
716
- approvalStats: { tool: 0, command: 0, file: 0, total: 0 },
550
+ approvalStats: snapshot.approvalStats ?? { tool: 0, command: 0, file: 0, total: 0 },
717
551
  ptyCols: snapshot.ptyCols ?? 120,
718
552
  ptyRows: snapshot.ptyRows ?? 36,
719
553
  });
@@ -753,10 +587,10 @@ export class ProcessManager extends EventEmitter {
753
587
  knownClaudeTaskIds: undefined,
754
588
  claudeTaskDiscoveryTimer: null,
755
589
  knownClaudeProjectMtimes: isClaudeCmd ? listClaudeProjectSessionMtimes(updated.cwd) : undefined,
756
- knownCodexSessionMtimes: isCodexCmd ? listCodexSessionMtimes() : undefined,
590
+ knownCodexSessionMtimes: isCodexCmd ? listCodexSessionMtimes(getStartupCodexHistory()) : undefined,
757
591
  codexSessionDiscoveryTimer: null,
758
592
  claudeSessionId: restoredSessionId ?? updated.claudeSessionId,
759
- approvalStats: { tool: 0, command: 0, file: 0, total: 0 },
593
+ approvalStats: snapshot.approvalStats ?? { tool: 0, command: 0, file: 0, total: 0 },
760
594
  ptyCols: snapshot.ptyCols ?? 120,
761
595
  ptyRows: snapshot.ptyRows ?? 36,
762
596
  });
@@ -780,7 +614,47 @@ export class ProcessManager extends EventEmitter {
780
614
  getOrphanRecoveredCount() {
781
615
  return this.orphanRecoveredCount;
782
616
  }
617
+ /** Stop all live work and flush pending state before storage is closed. */
618
+ dispose() {
619
+ if (this.disposed)
620
+ return;
621
+ this.disposed = true;
622
+ if (this.archiveTimer) {
623
+ clearInterval(this.archiveTimer);
624
+ this.archiveTimer = null;
625
+ }
626
+ const pendingPersistIds = new Set(this.persistDebounceTimers.keys());
627
+ for (const timer of this.persistDebounceTimers.values())
628
+ clearTimeout(timer);
629
+ this.persistDebounceTimers.clear();
630
+ for (const record of this.sessions.values()) {
631
+ const wasRunning = record.status === "running";
632
+ if (record.ptyBridge) {
633
+ record.messages = record.ptyBridge.getMessages();
634
+ }
635
+ this.cleanupRecord(record);
636
+ if (wasRunning) {
637
+ record.stopRequested = true;
638
+ record.status = "stopped";
639
+ record.exitCode = null;
640
+ record.endedAt = new Date().toISOString();
641
+ record.pendingEscalation = null;
642
+ record.ptyPermissionBlocked = false;
643
+ }
644
+ if (wasRunning || pendingPersistIds.has(record.id)) {
645
+ try {
646
+ this.persist(record, { forceFullSave: wasRunning, metadataDirty: true });
647
+ }
648
+ catch { /* best-effort shutdown flush */ }
649
+ }
650
+ }
651
+ this.topicRequests.clear();
652
+ this.removeAllListeners("process");
653
+ this.logger.dispose();
654
+ }
783
655
  emitEvent(event) {
656
+ if (this.disposed)
657
+ return;
784
658
  this.emit("process", event);
785
659
  }
786
660
  cleanupOldSessions() {
@@ -820,14 +694,16 @@ export class ProcessManager extends EventEmitter {
820
694
  }
821
695
  this.sessions.delete(id);
822
696
  this.lastPersistedMessageState.delete(id);
697
+ this.dirtySessions.delete(id);
823
698
  this.storage.deleteSession(id);
824
699
  }
825
700
  if (toRemove.length > 0) {
826
- this.claudeHistoryCache = null;
827
- this.codexHistoryCache = null;
701
+ this.providerHistory.invalidate();
828
702
  }
829
703
  }
830
704
  start(command, cwd, mode, initialInput, opts) {
705
+ if (this.disposed)
706
+ throw new Error("ProcessManager has been disposed.");
831
707
  this.assertCommandAllowed(command);
832
708
  const baseCwd = resolveSessionCwd(cwd, this.config.defaultCwd);
833
709
  const id = opts?.reuseId || randomUUID();
@@ -874,7 +750,9 @@ export class ProcessManager extends EventEmitter {
874
750
  : null;
875
751
  const knownClaudeTaskIds = isClaudeProvider ? new Set(listRecentClaudeProjectSessionIds(resolvedCwd, new Date().toISOString())) : null;
876
752
  const knownClaudeProjectMtimes = isClaudeProvider ? listClaudeProjectSessionMtimes(resolvedCwd) : null;
877
- const knownCodexSessionMtimes = isCodexProvider && !codexResumeCommandSessionId ? listCodexSessionMtimes() : null;
753
+ const knownCodexSessionMtimes = isCodexProvider && !codexResumeCommandSessionId
754
+ ? listCodexSessionMtimes(this.providerHistory.listCodexHistorySessions())
755
+ : null;
878
756
  const initialClaudeSessionId = isClaudeProvider
879
757
  ? resumeCommandSessionId ?? null
880
758
  : codexResumeCommandSessionId ?? null;
@@ -940,6 +818,8 @@ export class ProcessManager extends EventEmitter {
940
818
  initialMessages: priorMessages,
941
819
  });
942
820
  record.ptyBridge.on("event", (event) => {
821
+ if (this.sessions.get(id) !== record)
822
+ return;
943
823
  this.handleBridgeEvent(record, event);
944
824
  });
945
825
  }
@@ -947,10 +827,10 @@ export class ProcessManager extends EventEmitter {
947
827
  this.persist(record, { forceFullSave: true });
948
828
  if (initialClaudeSessionId) {
949
829
  if (provider === "codex") {
950
- this.codexHistoryCache = null;
830
+ this.providerHistory.invalidate("codex");
951
831
  }
952
832
  else {
953
- this.claudeHistoryCache = null;
833
+ this.providerHistory.invalidate("claude");
954
834
  }
955
835
  }
956
836
  this.cleanupOldSessions();
@@ -981,7 +861,7 @@ export class ProcessManager extends EventEmitter {
981
861
  record.exitCode = -1;
982
862
  record.endedAt = new Date().toISOString();
983
863
  record.ptyProcess = null;
984
- this.persist(record);
864
+ this.persist(record, { forceFullSave: true, metadataDirty: true });
985
865
  return this.snapshot(record);
986
866
  }
987
867
  record.processId = child.pid;
@@ -989,7 +869,10 @@ export class ProcessManager extends EventEmitter {
989
869
  record.status = "running";
990
870
  child.onExit(({ exitCode }) => {
991
871
  const current = this.sessions.get(id);
992
- if (!current)
872
+ // A stopped session can be resumed under the same public id before the
873
+ // old PTY has emitted its asynchronous exit event. Never let that stale
874
+ // callback finalize or clear the replacement run.
875
+ if (current !== record || current.ptyProcess !== child)
993
876
  return;
994
877
  if (current.claudeTaskDiscoveryTimer) {
995
878
  clearTimeout(current.claudeTaskDiscoveryTimer);
@@ -1015,15 +898,14 @@ export class ProcessManager extends EventEmitter {
1015
898
  current.exitCode = current.stopRequested ? null : exitCode;
1016
899
  current.endedAt = new Date().toISOString();
1017
900
  current.ptyProcess = null;
1018
- this.flushPersist(current);
1019
- this.storage.saveSession(this.snapshot(current));
901
+ this.flushPersist(current, true);
1020
902
  this.emitEvent({ type: "ended", sessionId: id, data: this.snapshot(current) });
1021
903
  });
1022
904
  if (record.ptyBridge) {
1023
905
  record.ptyBridge.setPtyWrite((input) => {
1024
- if (record.ptyProcess) {
1025
- record.ptyProcess.write(input);
1026
- }
906
+ if (this.sessions.get(id) !== record || record.ptyProcess !== child)
907
+ return;
908
+ child.write(input);
1027
909
  });
1028
910
  }
1029
911
  this.emitEvent({ type: "started", sessionId: id, data: this.snapshot(record) });
@@ -1035,7 +917,7 @@ export class ProcessManager extends EventEmitter {
1035
917
  return;
1036
918
  initialInputSent = true;
1037
919
  const current = this.sessions.get(id);
1038
- if (!current || !current.ptyProcess || current.status !== "running") {
920
+ if (current !== record || current.ptyProcess !== child || current.status !== "running") {
1039
921
  process.stderr.write(`[wand] Cannot send initial input: session not ready\n`);
1040
922
  return;
1041
923
  }
@@ -1043,12 +925,15 @@ export class ProcessManager extends EventEmitter {
1043
925
  if (current.ptyBridge) {
1044
926
  current.ptyBridge.onUserInput(initialInput);
1045
927
  }
1046
- current.ptyProcess.write(initialInput);
1047
- current.ptyProcess.write("\r");
928
+ child.write(initialInput);
929
+ child.write("\r");
1048
930
  };
1049
931
  child.onData((chunk) => {
1050
932
  const rec = this.sessions.get(id);
1051
- if (!rec)
933
+ // PTYs may still drain data after kill(). A replacement session can use
934
+ // the same id, so both record identity and the concrete PTY handle must
935
+ // match before accepting the chunk.
936
+ if (rec !== record || rec.ptyProcess !== child)
1052
937
  return;
1053
938
  if (rec.ptyBridge) {
1054
939
  rec.ptyBridge.processChunk(chunk);
@@ -1072,7 +957,8 @@ export class ProcessManager extends EventEmitter {
1072
957
  const bridgeSessionId = rec.ptyBridge?.getClaudeSessionId();
1073
958
  if (bridgeSessionId && bridgeSessionId !== rec.claudeSessionId) {
1074
959
  rec.claudeSessionId = bridgeSessionId;
1075
- this.claudeHistoryCache = null;
960
+ this.markDirty(rec.id, { metadata: true });
961
+ this.providerHistory.invalidate("claude");
1076
962
  process.stderr.write(`[wand] Captured Claude session ID: ${bridgeSessionId}\n`);
1077
963
  }
1078
964
  if (!rec.claudeSessionId && rec.knownClaudeTaskIds) {
@@ -1085,6 +971,7 @@ export class ProcessManager extends EventEmitter {
1085
971
  });
1086
972
  if (discoveredTaskId) {
1087
973
  rec.claudeSessionId = discoveredTaskId;
974
+ this.markDirty(rec.id, { metadata: true });
1088
975
  rec.knownClaudeTaskIds.add(discoveredTaskId);
1089
976
  process.stderr.write(`[wand] Captured Claude project session ID: ${discoveredTaskId}\n`);
1090
977
  }
@@ -1106,8 +993,10 @@ export class ProcessManager extends EventEmitter {
1106
993
  }
1107
994
  if (record.knownClaudeTaskIds) {
1108
995
  const tryDiscoverClaudeTaskId = () => {
996
+ if (this.disposed)
997
+ return;
1109
998
  const current = this.sessions.get(id);
1110
- if (!current || current.status !== "running" || current.claudeSessionId || !current.knownClaudeTaskIds) {
999
+ if (current !== record || current.ptyProcess !== child || current.status !== "running" || current.claudeSessionId || !current.knownClaudeTaskIds) {
1111
1000
  return;
1112
1001
  }
1113
1002
  if (getResumeCommandSessionId(current.command)) {
@@ -1135,8 +1024,10 @@ export class ProcessManager extends EventEmitter {
1135
1024
  }
1136
1025
  if (record.knownCodexSessionMtimes) {
1137
1026
  const tryDiscoverCodexSessionId = () => {
1027
+ if (this.disposed)
1028
+ return;
1138
1029
  const current = this.sessions.get(id);
1139
- if (!current || current.status !== "running" || current.claudeSessionId || !current.knownCodexSessionMtimes) {
1030
+ if (current !== record || current.ptyProcess !== child || current.status !== "running" || current.claudeSessionId || !current.knownCodexSessionMtimes) {
1140
1031
  return;
1141
1032
  }
1142
1033
  if (getCodexResumeCommandSessionId(current.command)) {
@@ -1168,14 +1059,8 @@ export class ProcessManager extends EventEmitter {
1168
1059
  hasClaudeSessionFile(cwd, claudeSessionId) {
1169
1060
  return isClaudeSessionFileAvailable(cwd, claudeSessionId);
1170
1061
  }
1171
- claudeHistoryCache = null;
1172
- static HISTORY_CACHE_TTL_MS = 30_000;
1173
1062
  listClaudeHistorySessions() {
1174
- const now = Date.now();
1175
- if (this.claudeHistoryCache && now < this.claudeHistoryCache.expiresAt) {
1176
- return this.claudeHistoryCache.data;
1177
- }
1178
- const allSessions = listAllClaudeHistorySessions();
1063
+ const allSessions = this.providerHistory.listClaudeHistorySessions();
1179
1064
  // Cross-reference with wand-managed sessions
1180
1065
  const managedClaudeIds = new Set();
1181
1066
  for (const record of this.sessions.values()) {
@@ -1188,47 +1073,13 @@ export class ProcessManager extends EventEmitter {
1188
1073
  session.managedByWand = true;
1189
1074
  }
1190
1075
  }
1191
- this.claudeHistoryCache = { data: allSessions, expiresAt: now + ProcessManager.HISTORY_CACHE_TTL_MS };
1192
1076
  return allSessions;
1193
1077
  }
1194
1078
  deleteClaudeHistoryFiles(sessions) {
1195
- let deleted = 0;
1196
- const claudeHome = path.join(os.homedir(), ".claude");
1197
- for (const { claudeSessionId, cwd } of sessions) {
1198
- if (!UUID_V4_PATTERN.test(claudeSessionId))
1199
- continue;
1200
- const jsonlPath = path.join(getClaudeProjectDir(cwd), `${claudeSessionId}.jsonl`);
1201
- try {
1202
- unlinkSync(jsonlPath);
1203
- deleted++;
1204
- }
1205
- catch {
1206
- // Best-effort — file may already be gone
1207
- }
1208
- // Clean up related directories under ~/.claude/
1209
- for (const sub of ["session-env", "tasks", "todos"]) {
1210
- const dir = path.join(claudeHome, sub, claudeSessionId);
1211
- try {
1212
- if (existsSync(dir))
1213
- rmSync(dir, { recursive: true, force: true });
1214
- }
1215
- catch {
1216
- // Non-critical — best-effort
1217
- }
1218
- }
1219
- }
1220
- if (sessions.length > 0) {
1221
- this.claudeHistoryCache = null;
1222
- }
1223
- return deleted;
1079
+ return this.providerHistory.deleteClaudeHistoryFiles(sessions);
1224
1080
  }
1225
- codexHistoryCache = null;
1226
1081
  listCodexHistorySessions() {
1227
- const now = Date.now();
1228
- if (this.codexHistoryCache && now < this.codexHistoryCache.expiresAt) {
1229
- return this.codexHistoryCache.data;
1230
- }
1231
- const allSessions = listAllCodexHistorySessions();
1082
+ const allSessions = this.providerHistory.listCodexHistorySessions();
1232
1083
  // Cross-reference with wand-managed sessions(codex 的 thread id 存在 claudeSessionId 字段)
1233
1084
  const managedIds = new Set();
1234
1085
  for (const record of this.sessions.values()) {
@@ -1241,21 +1092,13 @@ export class ProcessManager extends EventEmitter {
1241
1092
  session.managedByWand = true;
1242
1093
  }
1243
1094
  }
1244
- this.codexHistoryCache = { data: allSessions, expiresAt: now + ProcessManager.HISTORY_CACHE_TTL_MS };
1245
1095
  return allSessions;
1246
1096
  }
1247
1097
  hasCodexSessionFile(threadId) {
1248
- if (!UUID_V4_PATTERN.test(threadId))
1249
- return false;
1250
- return listAllCodexHistorySessions().some((s) => s.claudeSessionId === threadId);
1098
+ return this.providerHistory.hasCodexSessionFile(threadId);
1251
1099
  }
1252
1100
  deleteCodexHistoryFiles(threadIds) {
1253
- const valid = new Set(threadIds.filter((id) => UUID_V4_PATTERN.test(id)));
1254
- const deleted = deleteCodexRolloutFiles(valid);
1255
- if (valid.size > 0) {
1256
- this.codexHistoryCache = null;
1257
- }
1258
- return deleted;
1101
+ return this.providerHistory.deleteCodexHistoryFiles(threadIds);
1259
1102
  }
1260
1103
  captureCodexSessionId(record, options) {
1261
1104
  if (record.provider !== "codex" || record.claudeSessionId) {
@@ -1266,12 +1109,12 @@ export class ProcessManager extends EventEmitter {
1266
1109
  cwd: record.cwd,
1267
1110
  startedAt: record.startedAt,
1268
1111
  knownCodexSessionMtimes: record.knownCodexSessionMtimes,
1269
- })
1112
+ }, this.providerHistory.listCodexHistorySessions())
1270
1113
  : null;
1271
1114
  const fallbackThreadId = discoveredThreadId
1272
1115
  ? null
1273
1116
  : options?.allowTimeWindowFallback
1274
- ? selectCodexSessionForTimeWindow(record)?.claudeSessionId ?? null
1117
+ ? selectCodexSessionForTimeWindow(record, this.providerHistory.listCodexHistorySessions())?.claudeSessionId ?? null
1275
1118
  : null;
1276
1119
  const threadId = discoveredThreadId ?? fallbackThreadId;
1277
1120
  if (!threadId) {
@@ -1279,7 +1122,7 @@ export class ProcessManager extends EventEmitter {
1279
1122
  }
1280
1123
  record.claudeSessionId = threadId;
1281
1124
  record.knownCodexSessionMtimes?.set(threadId, Date.now());
1282
- this.codexHistoryCache = null;
1125
+ this.providerHistory.invalidate("codex");
1283
1126
  process.stderr.write(`[wand] Captured Codex thread ID: ${threadId}\n`);
1284
1127
  return true;
1285
1128
  }
@@ -1307,7 +1150,7 @@ export class ProcessManager extends EventEmitter {
1307
1150
  }
1308
1151
  record.claudeSessionId = sessionId;
1309
1152
  record.knownClaudeProjectMtimes?.set(sessionId, Date.now());
1310
- this.claudeHistoryCache = null;
1153
+ this.providerHistory.invalidate("claude");
1311
1154
  process.stderr.write(`[wand] Captured Claude session ID: ${sessionId}\n`);
1312
1155
  return true;
1313
1156
  }
@@ -1322,6 +1165,16 @@ export class ProcessManager extends EventEmitter {
1322
1165
  }
1323
1166
  return result;
1324
1167
  }
1168
+ /** Return only a session owned by this manager, without the SQLite fallback used by get(). */
1169
+ getOwned(id) {
1170
+ const record = this.sessions.get(id);
1171
+ if (!record)
1172
+ return null;
1173
+ const result = this.snapshot(record);
1174
+ if (!record.output && record.storedOutput)
1175
+ result.output = record.storedOutput;
1176
+ return result;
1177
+ }
1325
1178
  getPtyTranscript(id) {
1326
1179
  return this.logger.readPtyOutput(id);
1327
1180
  }
@@ -1380,6 +1233,8 @@ export class ProcessManager extends EventEmitter {
1380
1233
  return this.snapshot(record);
1381
1234
  }
1382
1235
  sendInput(id, input, view, shortcutKey) {
1236
+ if (this.disposed)
1237
+ throw new Error("ProcessManager has been disposed.");
1383
1238
  const record = this.mustGet(id);
1384
1239
  if (record.status !== "running") {
1385
1240
  console.error(`[ProcessManager] Rejecting input: session ${id} not running (${record.status})`);
@@ -1464,7 +1319,8 @@ export class ProcessManager extends EventEmitter {
1464
1319
  }
1465
1320
  // Immediately update status and clear PTY references so the session no longer
1466
1321
  // appears "running" and subsequent sendInput() calls are rejected cleanly.
1467
- // The async onExit handler will re-persist but will find stopRequested already true.
1322
+ // Clearing the handle also makes a later onExit callback stale; stop() owns
1323
+ // the terminal state transition and persistence from this point onward.
1468
1324
  record.status = "stopped";
1469
1325
  record.exitCode = null;
1470
1326
  record.endedAt = new Date().toISOString();
@@ -1474,10 +1330,11 @@ export class ProcessManager extends EventEmitter {
1474
1330
  this.captureClaudeSessionId(record, { allowTimeWindowFallback: true });
1475
1331
  this.captureCodexSessionId(record, { allowTimeWindowFallback: true });
1476
1332
  if (record.ptyBridge) {
1333
+ record.messages = record.ptyBridge.getMessages();
1477
1334
  record.ptyBridge.removeAllListeners();
1478
1335
  record.ptyBridge = null;
1479
1336
  }
1480
- this.persist(record);
1337
+ this.flushPersist(record, true);
1481
1338
  return this.snapshot(record);
1482
1339
  }
1483
1340
  cleanupRecord(record) {
@@ -1501,12 +1358,14 @@ export class ProcessManager extends EventEmitter {
1501
1358
  if (record.status === "running") {
1502
1359
  record.stopRequested = true;
1503
1360
  if (record.childProcess) {
1504
- record.childProcess.kill();
1361
+ const child = record.childProcess;
1505
1362
  record.childProcess = null;
1363
+ child.kill();
1506
1364
  }
1507
1365
  if (record.ptyProcess) {
1508
- record.ptyProcess.kill();
1366
+ const ptyProcess = record.ptyProcess;
1509
1367
  record.ptyProcess = null;
1368
+ ptyProcess.kill();
1510
1369
  }
1511
1370
  }
1512
1371
  if (record.ptyBridge) {
@@ -1567,12 +1426,13 @@ export class ProcessManager extends EventEmitter {
1567
1426
  }
1568
1427
  this.sessions.delete(id);
1569
1428
  this.lastPersistedMessageState.delete(id);
1429
+ this.dirtySessions.delete(id);
1570
1430
  if (record.claudeSessionId) {
1571
1431
  if (record.provider === "codex") {
1572
- this.codexHistoryCache = null;
1432
+ this.providerHistory.invalidate("codex");
1573
1433
  }
1574
1434
  else {
1575
- this.claudeHistoryCache = null;
1435
+ this.providerHistory.invalidate("claude");
1576
1436
  }
1577
1437
  }
1578
1438
  }
@@ -1620,6 +1480,8 @@ export class ProcessManager extends EventEmitter {
1620
1480
  mode: record.mode,
1621
1481
  worktreeEnabled: record.worktreeEnabled ?? false,
1622
1482
  worktree: record.worktree ?? null,
1483
+ worktreeMergeStatus: record.worktreeMergeStatus,
1484
+ worktreeMergeInfo: record.worktreeMergeInfo ?? null,
1623
1485
  autonomyPolicy: record.autonomyPolicy,
1624
1486
  approvalPolicy: record.approvalPolicy,
1625
1487
  allowedScopes: record.allowedScopes,
@@ -1637,9 +1499,12 @@ export class ProcessManager extends EventEmitter {
1637
1499
  messages: messages.length > 0 ? messages : undefined,
1638
1500
  resumedFromSessionId: record.resumedFromSessionId ?? undefined,
1639
1501
  autoRecovered: record.autoRecovered ?? false,
1640
- autoApprovePermissions: record.autoApprovePermissions || undefined,
1502
+ // `false` is an intentional user setting and must survive metadata
1503
+ // persistence/restarts; truthiness would silently drop it.
1504
+ autoApprovePermissions: record.autoApprovePermissions,
1641
1505
  approvalStats: record.approvalStats.total > 0 ? record.approvalStats : undefined,
1642
- summary: record.description ?? deriveSessionSummary(messages),
1506
+ currentTaskTitle: record.currentTaskTitle,
1507
+ summary: record.description ?? record.summary ?? deriveSessionSummary(messages),
1643
1508
  title: record.title,
1644
1509
  description: record.description,
1645
1510
  selectedModel: record.selectedModel ?? null,
@@ -1665,19 +1530,43 @@ export class ProcessManager extends EventEmitter {
1665
1530
  record.title = title;
1666
1531
  record.description = description;
1667
1532
  const snapshot = this.snapshot(record);
1668
- this.storage.saveSessionMetadata(snapshot);
1533
+ this.storage.updateSessionRuntimeMetadata(snapshot);
1669
1534
  this.emitEvent({ type: "output", sessionId: id, data: { title, description, summary: description } });
1670
1535
  return snapshot;
1671
1536
  }
1537
+ /**
1538
+ * Persist worktree merge progress through the manager that owns the live
1539
+ * session record. Returning null lets callers fall back to another owner (or
1540
+ * directly to storage for a row that is not currently loaded by a manager).
1541
+ */
1542
+ setWorktreeMergeState(id, status, info) {
1543
+ const record = this.sessions.get(id);
1544
+ if (!record)
1545
+ return null;
1546
+ record.worktreeMergeStatus = status;
1547
+ record.worktreeMergeInfo = info ?? null;
1548
+ const snapshot = this.snapshot(record);
1549
+ this.storage.updateSessionRuntimeMetadata(snapshot);
1550
+ this.emitEvent({
1551
+ type: "status",
1552
+ sessionId: id,
1553
+ data: {
1554
+ sessionKind: "pty",
1555
+ worktreeMergeStatus: status,
1556
+ worktreeMergeInfo: snapshot.worktreeMergeInfo,
1557
+ },
1558
+ });
1559
+ return snapshot;
1560
+ }
1672
1561
  maybeGenerateSessionTopic(id, input) {
1673
1562
  const prompt = input.trim();
1674
1563
  const record = this.sessions.get(id);
1675
- if (!prompt || !record || record.title || this.topicRequests.has(id))
1564
+ if (this.disposed || !prompt || !record || record.title || this.topicRequests.has(id))
1676
1565
  return;
1677
1566
  this.topicRequests.add(id);
1678
1567
  void generateSessionTopic(prompt, record.cwd, this.config.language)
1679
1568
  .then(({ title, description }) => {
1680
- if (this.sessions.has(id))
1569
+ if (!this.disposed && this.sessions.has(id))
1681
1570
  this.setSessionTopic(id, title, description);
1682
1571
  })
1683
1572
  .catch((error) => console.error(`[ProcessManager] Failed to generate session topic ${id}:`, getErrorMessage(error)))
@@ -1715,25 +1604,30 @@ export class ProcessManager extends EventEmitter {
1715
1604
  */
1716
1605
  resolvePermission(id, resolution, requestId) {
1717
1606
  const record = this.mustGet(id);
1718
- // Validate requestId if provided
1719
- if (requestId && record.pendingEscalation) {
1720
- if (record.pendingEscalation.requestId !== requestId) {
1721
- throw new Error("Escalation request not found.");
1722
- }
1607
+ if (resolution !== "approve_once" && resolution !== "approve_turn" && resolution !== "deny") {
1608
+ throw new Error("Invalid permission resolution.");
1723
1609
  }
1724
- // Record escalation result for audit trail
1725
- if (record.pendingEscalation) {
1726
- record.lastEscalationResult = {
1727
- requestId: record.pendingEscalation.requestId,
1728
- resolution,
1729
- reason: record.pendingEscalation.reason,
1730
- };
1610
+ // A permission response is only meaningful for the currently pending
1611
+ // prompt. In particular, never turn a stale/missing escalation request into
1612
+ // an unconditional Enter key sent to the live provider process.
1613
+ const pendingEscalation = record.pendingEscalation;
1614
+ if (!pendingEscalation) {
1615
+ throw new Error("Escalation request not found.");
1616
+ }
1617
+ if (requestId !== undefined && pendingEscalation.requestId !== requestId) {
1618
+ throw new Error("Escalation request not found.");
1731
1619
  }
1620
+ // Record escalation result for audit trail
1621
+ record.lastEscalationResult = {
1622
+ requestId: pendingEscalation.requestId,
1623
+ resolution,
1624
+ reason: pendingEscalation.reason,
1625
+ };
1732
1626
  // Handle "approve_turn" memory — only in ProcessManager for non-bridge sessions
1733
- if (resolution === "approve_turn" && record.pendingEscalation && !record.ptyBridge) {
1734
- record.rememberedEscalationScopes.add(record.pendingEscalation.scope);
1735
- if (record.pendingEscalation.target) {
1736
- record.rememberedEscalationTargets.add(record.pendingEscalation.target);
1627
+ if (resolution === "approve_turn" && !record.ptyBridge) {
1628
+ record.rememberedEscalationScopes.add(pendingEscalation.scope);
1629
+ if (pendingEscalation.target) {
1630
+ record.rememberedEscalationTargets.add(pendingEscalation.target);
1737
1631
  }
1738
1632
  }
1739
1633
  // Resolve via bridge or direct PTY write
@@ -1748,65 +1642,107 @@ export class ProcessManager extends EventEmitter {
1748
1642
  this.persist(record);
1749
1643
  return this.snapshot(record);
1750
1644
  }
1751
- persist(record, options) {
1645
+ persist(record, options = {}) {
1646
+ this.markDirty(record.id, {
1647
+ metadata: options.metadataDirty ?? true,
1648
+ output: options.outputDirty,
1649
+ messages: options.messagesDirty,
1650
+ });
1752
1651
  // Update messages from bridge before persisting
1753
1652
  const messages = record.ptyBridge?.getMessages() ?? record.messages;
1754
1653
  if (messages !== record.messages) {
1755
1654
  record.messages = messages;
1756
1655
  }
1757
1656
  const snapshot = this.snapshot(record);
1758
- const shouldSaveMessages = options?.forceFullSave === true
1759
- || shouldPersistMessages(this.lastPersistedMessageState.get(record.id), messages);
1760
- // Persist full messages as soon as the structured conversation changes so
1761
- // service restarts cannot roll the session back to an older tail message.
1762
- if (shouldSaveMessages) {
1657
+ const dirty = this.dirtySessions.get(record.id);
1658
+ if (shouldPersistMessages(this.lastPersistedMessageState.get(record.id), messages)) {
1659
+ dirty.messages = true;
1660
+ }
1661
+ const shouldSaveMessages = options.forceFullSave === true || dirty.messages;
1662
+ const shouldSaveMetadata = options.forceFullSave === true || dirty.metadata;
1663
+ if (options.forceFullSave === true) {
1763
1664
  this.storage.saveSession(snapshot);
1764
- this.lastPersistedMessageState.set(record.id, getPersistedMessageState(messages));
1765
1665
  }
1766
1666
  else {
1767
- this.storage.saveSessionMetadata(snapshot);
1667
+ if (dirty.metadata)
1668
+ this.storage.updateSessionRuntimeMetadata(snapshot);
1669
+ if (dirty.messages) {
1670
+ this.storage.checkpointSessionMessages(record.id, messages, snapshot.structuredState, dirty.output ? snapshot.output : undefined);
1671
+ dirty.output = false;
1672
+ }
1673
+ else if (dirty.output) {
1674
+ this.storage.checkpointSessionOutput(record.id, snapshot.output);
1675
+ }
1676
+ }
1677
+ if (shouldSaveMessages)
1678
+ this.lastPersistedMessageState.set(record.id, getPersistedMessageState(messages));
1679
+ this.dirtySessions.delete(record.id);
1680
+ if (shouldSaveMetadata) {
1681
+ this.logger.saveMetadata(record.id, {
1682
+ id: record.id,
1683
+ command: record.command,
1684
+ status: record.status,
1685
+ startedAt: record.startedAt,
1686
+ endedAt: record.endedAt,
1687
+ claudeSessionId: record.claudeSessionId,
1688
+ resumedFromSessionId: record.resumedFromSessionId ?? null,
1689
+ autoRecovered: record.autoRecovered ?? false,
1690
+ });
1768
1691
  }
1769
- this.logger.saveMetadata(record.id, {
1770
- id: record.id,
1771
- command: record.command,
1772
- status: record.status,
1773
- startedAt: record.startedAt,
1774
- endedAt: record.endedAt,
1775
- claudeSessionId: record.claudeSessionId,
1776
- resumedFromSessionId: record.resumedFromSessionId ?? null,
1777
- autoRecovered: record.autoRecovered ?? false,
1778
- });
1779
1692
  if (shouldSaveMessages) {
1780
1693
  this.logger.saveMessages(record.id, messages);
1781
1694
  }
1782
1695
  }
1696
+ markDirty(sessionId, next) {
1697
+ const dirty = this.dirtySessions.get(sessionId) ?? { metadata: false, output: false, messages: false };
1698
+ if (next.metadata)
1699
+ dirty.metadata = true;
1700
+ if (next.output)
1701
+ dirty.output = true;
1702
+ if (next.messages)
1703
+ dirty.messages = true;
1704
+ this.dirtySessions.set(sessionId, dirty);
1705
+ return dirty;
1706
+ }
1783
1707
  /**
1784
1708
  * Schedule a debounced persist call for the given record.
1785
1709
  * Multiple calls within the debounce window are coalesced into a single write.
1786
1710
  * Use this in hot paths (e.g. onData) to reduce I/O pressure.
1787
1711
  */
1788
1712
  schedulePersist(record) {
1713
+ if (this.disposed)
1714
+ return;
1715
+ this.markDirty(record.id, { output: true });
1789
1716
  const existing = this.persistDebounceTimers.get(record.id);
1790
- if (existing) {
1791
- clearTimeout(existing);
1792
- }
1717
+ // This is a throttle window, not a quiet-period debounce: continuous PTY
1718
+ // output still reaches SQLite at least once per second.
1719
+ if (existing)
1720
+ return;
1793
1721
  const timer = setTimeout(() => {
1794
1722
  this.persistDebounceTimers.delete(record.id);
1795
- this.persist(record);
1723
+ if (this.disposed)
1724
+ return;
1725
+ this.persist(record, { metadataDirty: false });
1796
1726
  }, 1000);
1727
+ timer.unref?.();
1797
1728
  this.persistDebounceTimers.set(record.id, timer);
1798
1729
  }
1799
1730
  /**
1800
1731
  * Immediately persist any pending debounced write and clear the timer.
1801
1732
  * Use this at critical points (exit, stop, delete) to ensure no data loss.
1802
1733
  */
1803
- flushPersist(record) {
1734
+ flushPersist(record, forceFullSave = false) {
1804
1735
  const existing = this.persistDebounceTimers.get(record.id);
1805
1736
  if (existing) {
1806
1737
  clearTimeout(existing);
1807
1738
  this.persistDebounceTimers.delete(record.id);
1808
1739
  }
1809
- this.persist(record);
1740
+ this.persist(record, {
1741
+ forceFullSave,
1742
+ metadataDirty: true,
1743
+ outputDirty: true,
1744
+ messagesDirty: forceFullSave,
1745
+ });
1810
1746
  }
1811
1747
  archiveExpiredSessions() {
1812
1748
  const now = Date.now();
@@ -1825,11 +1761,7 @@ export class ProcessManager extends EventEmitter {
1825
1761
  }
1826
1762
  }
1827
1763
  assertCommandAllowed(command) {
1828
- if (this.config.allowedCommandPrefixes.length === 0) {
1829
- return;
1830
- }
1831
- const isAllowed = this.config.allowedCommandPrefixes.some((prefix) => command.startsWith(prefix));
1832
- if (!isAllowed) {
1764
+ if (!isCommandAllowedByPrefixes(command, this.config.allowedCommandPrefixes)) {
1833
1765
  throw new Error("Command is not allowed by current configuration.");
1834
1766
  }
1835
1767
  }
@@ -1927,6 +1859,7 @@ export class ProcessManager extends EventEmitter {
1927
1859
  reason: data.prompt,
1928
1860
  };
1929
1861
  record.ptyPermissionBlocked = true;
1862
+ this.markDirty(record.id, { metadata: true });
1930
1863
  // Emit status event with full permission details for UI
1931
1864
  this.emitEvent({
1932
1865
  type: "status",
@@ -1959,6 +1892,7 @@ export class ProcessManager extends EventEmitter {
1959
1892
  }
1960
1893
  record.pendingEscalation = null;
1961
1894
  record.ptyPermissionBlocked = false;
1895
+ this.markDirty(record.id, { metadata: true });
1962
1896
  this.emitEvent({
1963
1897
  type: "status",
1964
1898
  sessionId: event.sessionId,
@@ -1996,8 +1930,7 @@ export class ProcessManager extends EventEmitter {
1996
1930
  record.ptyBridge?.clearRememberedPermissions();
1997
1931
  record.rememberedEscalationScopes.clear();
1998
1932
  record.rememberedEscalationTargets.clear();
1999
- this.persist(record);
2000
- this.storage.saveSession(this.snapshot(record));
1933
+ this.persist(record, { metadataDirty: true, outputDirty: true, messagesDirty: true });
2001
1934
  break;
2002
1935
  case "ended":
2003
1936
  // Session ended - handled in onExit