@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.
- package/dist/auth.d.ts +19 -5
- package/dist/auth.js +83 -45
- package/dist/build-info.json +3 -3
- package/dist/cert.d.ts +1 -1
- package/dist/cert.js +124 -74
- package/dist/config.js +25 -8
- package/dist/express-async.d.ts +6 -0
- package/dist/express-async.js +28 -0
- package/dist/git-quick-commit.d.ts +2 -0
- package/dist/git-quick-commit.js +215 -76
- package/dist/git-utils.d.ts +4 -0
- package/dist/git-utils.js +60 -11
- package/dist/git-worktree.d.ts +8 -1
- package/dist/git-worktree.js +406 -41
- package/dist/models.d.ts +34 -4
- package/dist/models.js +334 -48
- package/dist/process-manager.d.ts +22 -30
- package/dist/process-manager.js +374 -441
- package/dist/provider-history-scanner.d.ts +54 -0
- package/dist/provider-history-scanner.js +354 -0
- package/dist/request-limits.d.ts +1 -0
- package/dist/request-limits.js +8 -0
- package/dist/resume-policy.d.ts +2 -0
- package/dist/resume-policy.js +5 -0
- package/dist/runtime-config.d.ts +16 -0
- package/dist/runtime-config.js +49 -0
- package/dist/server-file-routes.d.ts +17 -0
- package/dist/server-file-routes.js +653 -0
- package/dist/server-session-routes.d.ts +16 -3
- package/dist/server-session-routes.js +170 -149
- package/dist/server-settings-routes.d.ts +43 -0
- package/dist/server-settings-routes.js +225 -0
- package/dist/server-update-routes.d.ts +61 -0
- package/dist/server-update-routes.js +215 -0
- package/dist/server.d.ts +6 -4
- package/dist/server.js +350 -1313
- package/dist/session-logger.d.ts +32 -2
- package/dist/session-logger.js +145 -15
- package/dist/session-registry.d.ts +27 -0
- package/dist/session-registry.js +153 -0
- package/dist/session-transport.d.ts +31 -0
- package/dist/session-transport.js +82 -0
- package/dist/storage.d.ts +24 -6
- package/dist/storage.js +291 -44
- package/dist/structured-claude-adapter.d.ts +19 -0
- package/dist/structured-claude-adapter.js +117 -0
- package/dist/structured-codex-adapter.d.ts +3 -0
- package/dist/structured-codex-adapter.js +29 -0
- package/dist/structured-opencode-adapter.d.ts +11 -0
- package/dist/structured-opencode-adapter.js +115 -0
- package/dist/structured-provider-common.d.ts +11 -0
- package/dist/structured-provider-common.js +77 -0
- package/dist/structured-session-manager.d.ts +32 -35
- package/dist/structured-session-manager.js +551 -605
- package/dist/types.d.ts +10 -0
- package/dist/update-helper.js +5 -1
- package/dist/web-ui/content/scripts.js +32 -32
- package/dist/web-ui/embedded-assets.d.ts +1 -1
- package/dist/web-ui/embedded-assets.js +2 -2
- package/dist/ws-broadcast.d.ts +16 -1
- package/dist/ws-broadcast.js +124 -58
- package/package.json +2 -1
package/dist/process-manager.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { EventEmitter } from "node:events";
|
|
3
|
-
import { existsSync, unlinkSync, rmSync,
|
|
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-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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.
|
|
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
|
|
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.
|
|
830
|
+
this.providerHistory.invalidate("codex");
|
|
951
831
|
}
|
|
952
832
|
else {
|
|
953
|
-
this.
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
1047
|
-
|
|
928
|
+
child.write(initialInput);
|
|
929
|
+
child.write("\r");
|
|
1048
930
|
};
|
|
1049
931
|
child.onData((chunk) => {
|
|
1050
932
|
const rec = this.sessions.get(id);
|
|
1051
|
-
|
|
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.
|
|
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 (
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1249
|
-
return false;
|
|
1250
|
-
return listAllCodexHistorySessions().some((s) => s.claudeSessionId === threadId);
|
|
1098
|
+
return this.providerHistory.hasCodexSessionFile(threadId);
|
|
1251
1099
|
}
|
|
1252
1100
|
deleteCodexHistoryFiles(threadIds) {
|
|
1253
|
-
|
|
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.
|
|
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.
|
|
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
|
-
//
|
|
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.
|
|
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
|
|
1361
|
+
const child = record.childProcess;
|
|
1505
1362
|
record.childProcess = null;
|
|
1363
|
+
child.kill();
|
|
1506
1364
|
}
|
|
1507
1365
|
if (record.ptyProcess) {
|
|
1508
|
-
record.ptyProcess
|
|
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.
|
|
1432
|
+
this.providerHistory.invalidate("codex");
|
|
1573
1433
|
}
|
|
1574
1434
|
else {
|
|
1575
|
-
this.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
1719
|
-
|
|
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
|
-
//
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
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" &&
|
|
1734
|
-
record.rememberedEscalationScopes.add(
|
|
1735
|
-
if (
|
|
1736
|
-
record.rememberedEscalationTargets.add(
|
|
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
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1791
|
-
|
|
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.
|
|
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
|
|
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
|