@co0ontty/wand 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/avatar.d.ts +14 -0
- package/dist/avatar.js +110 -0
- package/dist/claude-pty-bridge.d.ts +0 -2
- package/dist/claude-pty-bridge.js +63 -93
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +10 -2
- package/dist/config.js +6 -2
- package/dist/message-parser.js +9 -89
- package/dist/middleware/path-safety.d.ts +6 -0
- package/dist/middleware/path-safety.js +19 -0
- package/dist/middleware/rate-limit.d.ts +8 -0
- package/dist/middleware/rate-limit.js +37 -0
- package/dist/process-manager.d.ts +52 -4
- package/dist/process-manager.js +1025 -125
- package/dist/pty-text-utils.d.ts +13 -0
- package/dist/pty-text-utils.js +84 -0
- package/dist/pwa.d.ts +5 -0
- package/dist/pwa.js +118 -0
- package/dist/server.js +346 -559
- package/dist/session-lifecycle.js +17 -12
- package/dist/session-logger.d.ts +13 -3
- package/dist/session-logger.js +56 -5
- package/dist/storage.d.ts +9 -0
- package/dist/storage.js +62 -7
- package/dist/types.d.ts +8 -2
- package/dist/web-ui/content/icon-192.png +0 -0
- package/dist/web-ui/content/icon-512.png +0 -0
- package/dist/web-ui/content/scripts.js +1571 -302
- package/dist/web-ui/content/styles.css +882 -669
- package/dist/web-ui/index.js +2 -2
- package/dist/ws-broadcast.d.ts +27 -0
- package/dist/ws-broadcast.js +160 -0
- package/package.json +1 -1
package/dist/process-manager.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { EventEmitter } from "node:events";
|
|
3
|
+
import { existsSync, unlinkSync, openSync, readSync, closeSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
3
4
|
import path from "node:path";
|
|
4
5
|
import process from "node:process";
|
|
5
6
|
import os from "node:os";
|
|
@@ -7,6 +8,7 @@ import pty from "node-pty";
|
|
|
7
8
|
import { SessionLogger } from "./session-logger.js";
|
|
8
9
|
import { SessionLifecycleManager } from "./session-lifecycle.js";
|
|
9
10
|
import { ClaudePtyBridge } from "./claude-pty-bridge.js";
|
|
11
|
+
import { appendWindow, normalizePromptText } from "./pty-text-utils.js";
|
|
10
12
|
/** Check if the current process is running as root (UID 0). */
|
|
11
13
|
function isRunningAsRoot() {
|
|
12
14
|
return process.getuid?.() === 0 || process.geteuid?.() === 0;
|
|
@@ -38,26 +40,682 @@ const PROMPT_PATTERNS = [
|
|
|
38
40
|
/\bwould you like to\b/i,
|
|
39
41
|
/\bshall i\b/i,
|
|
40
42
|
/\bcan i\b/i,
|
|
41
|
-
/\bpermission\b/i,
|
|
42
43
|
/\bgrant\b.*\bpermission\b/i
|
|
43
44
|
];
|
|
44
|
-
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
45
|
+
const REAL_CONVERSATION_MIN_LINES = 2;
|
|
46
|
+
const REAL_CONVERSATION_MIN_MESSAGES = 2;
|
|
47
|
+
const DISCOVERY_RECENT_WINDOW_MS = 10 * 60 * 1000;
|
|
48
|
+
const START_TIME_SKEW_MS = 30 * 1000;
|
|
49
|
+
const RESUME_COMMAND_ID_PATTERN = /(?:^|\s)--resume\s+([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(?:\s|$)/i;
|
|
50
|
+
function hasRealConversationMessages(messages) {
|
|
51
|
+
if (!messages || messages.length < REAL_CONVERSATION_MIN_MESSAGES) {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
const hasUser = messages.some((turn) => turn.role === "user"
|
|
55
|
+
&& turn.content.some((block) => block.type === "text" && block.text.trim().length > 0));
|
|
56
|
+
const hasAssistant = messages.some((turn) => turn.role === "assistant"
|
|
57
|
+
&& turn.content.some((block) => block.type === "text" && block.text.trim().length > 0));
|
|
58
|
+
return hasUser && hasAssistant;
|
|
59
|
+
}
|
|
60
|
+
function getResumeCommandSessionId(command) {
|
|
61
|
+
const match = RESUME_COMMAND_ID_PATTERN.exec(command);
|
|
62
|
+
return match?.[1] ?? null;
|
|
63
|
+
}
|
|
64
|
+
function readClaudeProjectSessionDetails(filePath, id) {
|
|
65
|
+
try {
|
|
66
|
+
const stats = statSync(filePath);
|
|
67
|
+
const raw = readFileSync(filePath, "utf8");
|
|
68
|
+
const lines = raw.split("\n").filter((line) => line.trim().length > 0);
|
|
69
|
+
const fileSessionIds = new Set();
|
|
70
|
+
let hasAssistant = false;
|
|
71
|
+
let hasUser = false;
|
|
72
|
+
for (const line of lines) {
|
|
73
|
+
try {
|
|
74
|
+
const parsed = JSON.parse(line);
|
|
75
|
+
if (parsed.sessionId) {
|
|
76
|
+
fileSessionIds.add(parsed.sessionId);
|
|
77
|
+
}
|
|
78
|
+
if (parsed.type === "user" || parsed.message?.role === "user") {
|
|
79
|
+
hasUser = true;
|
|
80
|
+
}
|
|
81
|
+
if (parsed.type === "assistant" || parsed.message?.role === "assistant") {
|
|
82
|
+
hasAssistant = true;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
// Only reject if the file explicitly claims a DIFFERENT primary session ID.
|
|
90
|
+
// A resumed session's JSONL may contain multiple session IDs across turns.
|
|
91
|
+
// If no sessionId appears at all (early startup file), don't reject.
|
|
92
|
+
if (fileSessionIds.size > 0 && !fileSessionIds.has(id)) {
|
|
93
|
+
// Check if at least one line references this ID (partial match is ok)
|
|
94
|
+
const hasAnyReference = lines.some((line) => line.includes(`"${id}"`));
|
|
95
|
+
if (!hasAnyReference) {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
id,
|
|
101
|
+
filePath,
|
|
102
|
+
mtimeMs: stats.mtimeMs,
|
|
103
|
+
hasConversation: hasUser && hasAssistant && lines.length >= REAL_CONVERSATION_MIN_LINES
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
function hasRuntimeConversationSignal(messages) {
|
|
111
|
+
if (!messages || messages.length === 0) {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
const hasUser = messages.some((turn) => turn.role === "user"
|
|
115
|
+
&& turn.content.some((block) => block.type === "text" && block.text.trim().length > 0));
|
|
116
|
+
const hasAssistant = messages.some((turn) => turn.role === "assistant");
|
|
117
|
+
return hasUser && hasAssistant;
|
|
118
|
+
}
|
|
119
|
+
function hasStoredConversationHistory(messages) {
|
|
120
|
+
return hasRealConversationMessages(messages);
|
|
121
|
+
}
|
|
122
|
+
function shouldBindClaudeSessionId(record) {
|
|
123
|
+
return hasRuntimeConversationSignal(record.messages);
|
|
124
|
+
}
|
|
125
|
+
function shouldAllowResume(record) {
|
|
126
|
+
return Boolean(record.claudeSessionId) && hasStoredConversationHistory(record.messages);
|
|
127
|
+
}
|
|
128
|
+
function shouldBackfillFromStoredHistory(record) {
|
|
129
|
+
return hasStoredConversationHistory(record.messages);
|
|
130
|
+
}
|
|
131
|
+
function shouldDisplayResumeAction(messages) {
|
|
132
|
+
return hasStoredConversationHistory(messages);
|
|
133
|
+
}
|
|
134
|
+
function shouldAutoResumeMessages(messages) {
|
|
135
|
+
return hasStoredConversationHistory(messages);
|
|
136
|
+
}
|
|
137
|
+
function shouldBackfillMessages(messages) {
|
|
138
|
+
return hasStoredConversationHistory(messages);
|
|
139
|
+
}
|
|
140
|
+
function shouldPromoteProjectSessionId(record) {
|
|
141
|
+
return shouldBindClaudeSessionId(record);
|
|
142
|
+
}
|
|
143
|
+
function shouldPromoteStoredSessionId(record) {
|
|
144
|
+
return shouldBackfillMessages(record.messages);
|
|
145
|
+
}
|
|
146
|
+
function shouldPromoteUiSessionId(messages) {
|
|
147
|
+
return shouldDisplayResumeAction(messages);
|
|
148
|
+
}
|
|
149
|
+
function shouldPromoteResumeSessionId(messages) {
|
|
150
|
+
return shouldAutoResumeMessages(messages);
|
|
151
|
+
}
|
|
152
|
+
function hasBindableConversation(messages) {
|
|
153
|
+
return shouldBindFromRuntimeMessages({ messages: messages ?? [] });
|
|
154
|
+
}
|
|
155
|
+
function hasBackfillableConversation(messages) {
|
|
156
|
+
return shouldBackfillMessages(messages);
|
|
157
|
+
}
|
|
158
|
+
function hasUiConversation(messages) {
|
|
159
|
+
return shouldPromoteUiSessionId(messages);
|
|
160
|
+
}
|
|
161
|
+
function hasResumeConversation(messages) {
|
|
162
|
+
return shouldPromoteResumeSessionId(messages);
|
|
163
|
+
}
|
|
164
|
+
function isRuntimeConversationReady(messages) {
|
|
165
|
+
return hasBindableConversation(messages);
|
|
166
|
+
}
|
|
167
|
+
function isStoredConversationReady(messages) {
|
|
168
|
+
return hasBackfillableConversation(messages);
|
|
169
|
+
}
|
|
170
|
+
function isResumeConversationReady(messages) {
|
|
171
|
+
return hasResumeConversation(messages);
|
|
172
|
+
}
|
|
173
|
+
function shouldBindFromRuntimeMessages(record) {
|
|
174
|
+
return isRuntimeConversationReady(record.messages);
|
|
175
|
+
}
|
|
176
|
+
function shouldAllowUiResume(messages) {
|
|
177
|
+
return hasUiConversation(messages);
|
|
178
|
+
}
|
|
179
|
+
function shouldPromoteResumeAction(record) {
|
|
180
|
+
return shouldAllowResume(record);
|
|
181
|
+
}
|
|
182
|
+
function shouldBackfillClaudeSessionIdFromDisk(record) {
|
|
183
|
+
return isStoredConversationReady(record.messages);
|
|
184
|
+
}
|
|
185
|
+
function shouldUseProjectCandidate(record) {
|
|
186
|
+
return shouldBindFromRuntimeMessages(record);
|
|
187
|
+
}
|
|
188
|
+
function shouldResumeProjectCandidate(record) {
|
|
189
|
+
return shouldPromoteResumeAction(record);
|
|
190
|
+
}
|
|
191
|
+
function shouldBackfillProjectCandidate(record) {
|
|
192
|
+
return shouldBackfillClaudeSessionIdFromDisk(record);
|
|
193
|
+
}
|
|
194
|
+
function hasMinimumRuntimeConversation(messages) {
|
|
195
|
+
return shouldBindFromRuntimeMessages({ messages: messages ?? [] });
|
|
196
|
+
}
|
|
197
|
+
function hasMinimumStoredConversation(messages) {
|
|
198
|
+
return shouldAllowUiResume(messages);
|
|
199
|
+
}
|
|
200
|
+
function hasMinimumResumeConversation(messages) {
|
|
201
|
+
return isResumeConversationReady(messages);
|
|
202
|
+
}
|
|
203
|
+
function hasMinimumBackfillConversation(messages) {
|
|
204
|
+
return isStoredConversationReady(messages);
|
|
205
|
+
}
|
|
206
|
+
function hasProjectConversationSignal(messages) {
|
|
207
|
+
return hasMinimumRuntimeConversation(messages);
|
|
208
|
+
}
|
|
209
|
+
function hasStoredProjectConversationSignal(messages) {
|
|
210
|
+
return hasMinimumBackfillConversation(messages);
|
|
211
|
+
}
|
|
212
|
+
function hasUiProjectConversationSignal(messages) {
|
|
213
|
+
return hasMinimumStoredConversation(messages);
|
|
214
|
+
}
|
|
215
|
+
function hasResumeProjectConversationSignal(messages) {
|
|
216
|
+
return hasMinimumResumeConversation(messages);
|
|
217
|
+
}
|
|
218
|
+
function canBindFromProjectConversation(messages) {
|
|
219
|
+
return hasProjectConversationSignal(messages);
|
|
220
|
+
}
|
|
221
|
+
function canBackfillFromProjectConversation(messages) {
|
|
222
|
+
return hasStoredProjectConversationSignal(messages);
|
|
223
|
+
}
|
|
224
|
+
function canShowUiProjectConversation(messages) {
|
|
225
|
+
return hasUiProjectConversationSignal(messages);
|
|
226
|
+
}
|
|
227
|
+
function canResumeProjectConversation(messages) {
|
|
228
|
+
return hasResumeProjectConversationSignal(messages);
|
|
229
|
+
}
|
|
230
|
+
function shouldUseRuntimeProjectConversation(messages) {
|
|
231
|
+
return canBindFromProjectConversation(messages);
|
|
232
|
+
}
|
|
233
|
+
function shouldUseStoredProjectConversation(messages) {
|
|
234
|
+
return canBackfillFromProjectConversation(messages);
|
|
235
|
+
}
|
|
236
|
+
function shouldUseUiProjectConversation(messages) {
|
|
237
|
+
return canShowUiProjectConversation(messages);
|
|
238
|
+
}
|
|
239
|
+
function shouldUseResumeProjectConversation(messages) {
|
|
240
|
+
return canResumeProjectConversation(messages);
|
|
241
|
+
}
|
|
242
|
+
function hasProjectConversationForBinding(messages) {
|
|
243
|
+
return shouldUseRuntimeProjectConversation(messages);
|
|
244
|
+
}
|
|
245
|
+
function hasProjectConversationForBackfill(messages) {
|
|
246
|
+
return shouldUseStoredProjectConversation(messages);
|
|
247
|
+
}
|
|
248
|
+
function hasProjectConversationForUi(messages) {
|
|
249
|
+
return shouldUseUiProjectConversation(messages);
|
|
250
|
+
}
|
|
251
|
+
function hasProjectConversationForResume(messages) {
|
|
252
|
+
return shouldUseResumeProjectConversation(messages);
|
|
253
|
+
}
|
|
254
|
+
function isBindableProjectConversation(messages) {
|
|
255
|
+
return hasProjectConversationForBinding(messages);
|
|
256
|
+
}
|
|
257
|
+
function isBackfillableProjectConversation(messages) {
|
|
258
|
+
return hasProjectConversationForBackfill(messages);
|
|
259
|
+
}
|
|
260
|
+
function isUiProjectConversation(messages) {
|
|
261
|
+
return hasProjectConversationForUi(messages);
|
|
262
|
+
}
|
|
263
|
+
function isResumeProjectConversation(messages) {
|
|
264
|
+
return hasProjectConversationForResume(messages);
|
|
265
|
+
}
|
|
266
|
+
function hasLiveProjectConversation(messages) {
|
|
267
|
+
return isBindableProjectConversation(messages);
|
|
268
|
+
}
|
|
269
|
+
function hasStoredProjectConversation(messages) {
|
|
270
|
+
return isBackfillableProjectConversation(messages);
|
|
271
|
+
}
|
|
272
|
+
function hasVisibleProjectConversation(messages) {
|
|
273
|
+
return isUiProjectConversation(messages);
|
|
274
|
+
}
|
|
275
|
+
function hasRecoverableProjectConversation(messages) {
|
|
276
|
+
return isResumeProjectConversation(messages);
|
|
277
|
+
}
|
|
278
|
+
function shouldBindLiveProjectSessionId(messages) {
|
|
279
|
+
return hasLiveProjectConversation(messages);
|
|
280
|
+
}
|
|
281
|
+
function shouldBackfillStoredProjectSessionId(messages) {
|
|
282
|
+
return hasStoredProjectConversation(messages);
|
|
283
|
+
}
|
|
284
|
+
function shouldDisplayVisibleProjectSessionId(messages) {
|
|
285
|
+
return hasVisibleProjectConversation(messages);
|
|
286
|
+
}
|
|
287
|
+
function shouldResumeRecoverableProjectSessionId(messages) {
|
|
288
|
+
return hasRecoverableProjectConversation(messages);
|
|
289
|
+
}
|
|
290
|
+
function canBindLiveProjectSession(record) {
|
|
291
|
+
return shouldBindLiveProjectSessionId(record.messages);
|
|
292
|
+
}
|
|
293
|
+
function canBackfillStoredProjectSession(record) {
|
|
294
|
+
return shouldBackfillStoredProjectSessionId(record.messages);
|
|
295
|
+
}
|
|
296
|
+
function canDisplayVisibleProjectSession(messages) {
|
|
297
|
+
return shouldDisplayVisibleProjectSessionId(messages);
|
|
298
|
+
}
|
|
299
|
+
function canResumeRecoverableProjectSession(messages) {
|
|
300
|
+
return shouldResumeRecoverableProjectSessionId(messages);
|
|
301
|
+
}
|
|
302
|
+
function shouldAdoptProjectSessionDuringRuntime(record) {
|
|
303
|
+
return canBindLiveProjectSession(record);
|
|
304
|
+
}
|
|
305
|
+
function shouldAdoptProjectSessionDuringBackfill(record) {
|
|
306
|
+
return canBackfillStoredProjectSession(record);
|
|
307
|
+
}
|
|
308
|
+
function shouldAdoptProjectSessionForUi(messages) {
|
|
309
|
+
return canDisplayVisibleProjectSession(messages);
|
|
310
|
+
}
|
|
311
|
+
function shouldAdoptProjectSessionForResume(messages) {
|
|
312
|
+
return canResumeRecoverableProjectSession(messages);
|
|
313
|
+
}
|
|
314
|
+
function hasRuntimeProjectAdoption(messages) {
|
|
315
|
+
return shouldAdoptProjectSessionForUi(messages);
|
|
316
|
+
}
|
|
317
|
+
function hasBackfillProjectAdoption(messages) {
|
|
318
|
+
return shouldBackfillStoredProjectSessionId(messages);
|
|
319
|
+
}
|
|
320
|
+
function hasUiProjectAdoption(messages) {
|
|
321
|
+
return shouldAdoptProjectSessionForUi(messages);
|
|
322
|
+
}
|
|
323
|
+
function hasResumeProjectAdoption(messages) {
|
|
324
|
+
return shouldAdoptProjectSessionForResume(messages);
|
|
325
|
+
}
|
|
326
|
+
function shouldAdoptProjectSession(record) {
|
|
327
|
+
return shouldAdoptProjectSessionDuringRuntime(record);
|
|
328
|
+
}
|
|
329
|
+
function shouldAdoptStoredProjectSession(record) {
|
|
330
|
+
return shouldAdoptProjectSessionDuringBackfill(record);
|
|
331
|
+
}
|
|
332
|
+
function shouldAdoptUiProjectSession(messages) {
|
|
333
|
+
return hasUiProjectAdoption(messages);
|
|
334
|
+
}
|
|
335
|
+
function shouldAdoptResumeProjectSession(messages) {
|
|
336
|
+
return hasResumeProjectAdoption(messages);
|
|
337
|
+
}
|
|
338
|
+
function canUseProjectSessionAtRuntime(record) {
|
|
339
|
+
return shouldAdoptProjectSession(record);
|
|
340
|
+
}
|
|
341
|
+
function canUseProjectSessionAtBackfill(record) {
|
|
342
|
+
return shouldAdoptStoredProjectSession(record);
|
|
343
|
+
}
|
|
344
|
+
function canUseProjectSessionAtUi(messages) {
|
|
345
|
+
return shouldAdoptUiProjectSession(messages);
|
|
346
|
+
}
|
|
347
|
+
function canUseProjectSessionAtResume(messages) {
|
|
348
|
+
return shouldAdoptResumeProjectSession(messages);
|
|
349
|
+
}
|
|
350
|
+
function hasProjectSessionRuntimeEligibility(messages) {
|
|
351
|
+
return shouldAdoptProjectSessionDuringRuntime({ messages: messages ?? [] });
|
|
352
|
+
}
|
|
353
|
+
function hasProjectSessionBackfillEligibility(messages) {
|
|
354
|
+
return shouldAdoptProjectSessionDuringBackfill({ messages: messages ?? [] });
|
|
355
|
+
}
|
|
356
|
+
function hasProjectSessionUiEligibility(messages) {
|
|
357
|
+
return canUseProjectSessionAtUi(messages);
|
|
358
|
+
}
|
|
359
|
+
function hasProjectSessionResumeEligibility(messages) {
|
|
360
|
+
return canUseProjectSessionAtResume(messages);
|
|
361
|
+
}
|
|
362
|
+
function shouldClaimProjectSessionDuringRuntime(messages) {
|
|
363
|
+
return hasProjectSessionRuntimeEligibility(messages);
|
|
364
|
+
}
|
|
365
|
+
function shouldClaimProjectSessionDuringBackfill(messages) {
|
|
366
|
+
return hasProjectSessionBackfillEligibility(messages);
|
|
367
|
+
}
|
|
368
|
+
function shouldClaimProjectSessionForUi(messages) {
|
|
369
|
+
return hasProjectSessionUiEligibility(messages);
|
|
370
|
+
}
|
|
371
|
+
function shouldClaimProjectSessionForResume(messages) {
|
|
372
|
+
return hasProjectSessionResumeEligibility(messages);
|
|
373
|
+
}
|
|
374
|
+
function hasClaimableProjectSessionRuntime(messages) {
|
|
375
|
+
return shouldClaimProjectSessionDuringRuntime(messages);
|
|
376
|
+
}
|
|
377
|
+
function hasClaimableProjectSessionBackfill(messages) {
|
|
378
|
+
return shouldClaimProjectSessionDuringBackfill(messages);
|
|
379
|
+
}
|
|
380
|
+
function hasClaimableProjectSessionUi(messages) {
|
|
381
|
+
return shouldClaimProjectSessionForUi(messages);
|
|
382
|
+
}
|
|
383
|
+
function hasClaimableProjectSessionResume(messages) {
|
|
384
|
+
return shouldClaimProjectSessionForResume(messages);
|
|
385
|
+
}
|
|
386
|
+
function isClaimableProjectSessionRuntime(messages) {
|
|
387
|
+
return hasClaimableProjectSessionRuntime(messages);
|
|
388
|
+
}
|
|
389
|
+
function isClaimableProjectSessionBackfill(messages) {
|
|
390
|
+
return hasClaimableProjectSessionBackfill(messages);
|
|
391
|
+
}
|
|
392
|
+
function isClaimableProjectSessionUi(messages) {
|
|
393
|
+
return hasClaimableProjectSessionUi(messages);
|
|
394
|
+
}
|
|
395
|
+
function isClaimableProjectSessionResume(messages) {
|
|
396
|
+
return hasClaimableProjectSessionResume(messages);
|
|
397
|
+
}
|
|
398
|
+
function listClaudeProjectSessionCandidates(cwd) {
|
|
399
|
+
const projectDir = getClaudeProjectDir(cwd);
|
|
400
|
+
try {
|
|
401
|
+
return readdirSync(projectDir, { withFileTypes: true })
|
|
402
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl"))
|
|
403
|
+
.map((entry) => entry.name.replace(/\.jsonl$/, ""))
|
|
404
|
+
.filter((name) => UUID_V4_PATTERN.test(name))
|
|
405
|
+
.map((id) => {
|
|
406
|
+
const filePath = path.join(projectDir, `${id}.jsonl`);
|
|
407
|
+
const stats = statSync(filePath);
|
|
408
|
+
return { id, filePath, mtimeMs: stats.mtimeMs };
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
catch {
|
|
412
|
+
return [];
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
function listClaudeProjectSessionMtimes(cwd) {
|
|
416
|
+
return new Map(listClaudeProjectSessionCandidates(cwd).map((candidate) => [candidate.id, candidate.mtimeMs]));
|
|
417
|
+
}
|
|
418
|
+
function hasRecentProjectActivity(candidate, startedAt) {
|
|
419
|
+
const startedAtMs = Date.parse(startedAt);
|
|
420
|
+
if (!Number.isFinite(startedAtMs)) {
|
|
421
|
+
return true;
|
|
422
|
+
}
|
|
423
|
+
return candidate.mtimeMs >= startedAtMs - START_TIME_SKEW_MS
|
|
424
|
+
&& candidate.mtimeMs <= Date.now() + DISCOVERY_RECENT_WINDOW_MS;
|
|
425
|
+
}
|
|
426
|
+
function selectClaudeProjectSessionForRecord(record) {
|
|
427
|
+
const knownMtimes = record.knownClaudeProjectMtimes ?? new Map();
|
|
428
|
+
const candidates = listClaudeProjectSessionCandidates(record.cwd)
|
|
429
|
+
.filter((candidate) => {
|
|
430
|
+
const previousMtime = knownMtimes.get(candidate.id);
|
|
431
|
+
return previousMtime === undefined || candidate.mtimeMs > previousMtime;
|
|
432
|
+
})
|
|
433
|
+
.filter((candidate) => hasRecentProjectActivity(candidate, record.startedAt))
|
|
434
|
+
.map((candidate) => readClaudeProjectSessionDetails(candidate.filePath, candidate.id))
|
|
435
|
+
.filter((candidate) => Boolean(candidate?.hasConversation))
|
|
436
|
+
.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
437
|
+
if (candidates.length === 0) {
|
|
438
|
+
return null;
|
|
439
|
+
}
|
|
440
|
+
const hasUserTurn = record.messages.some((turn) => turn.role === "user"
|
|
441
|
+
&& turn.content.some((block) => block.type === "text" && block.text.trim().length > 0));
|
|
442
|
+
if (!hasUserTurn) {
|
|
443
|
+
return null;
|
|
444
|
+
}
|
|
445
|
+
return candidates[0] ?? null;
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* Broader fallback: find a JSONL file by mtime proximity when strict
|
|
449
|
+
* mtime-correlation fails (e.g., file existed before session but Claude
|
|
450
|
+
* wrote conversation content during this session).
|
|
451
|
+
* Looks for the most recently modified file that was active near the
|
|
452
|
+
* session's start time and has real conversation content.
|
|
453
|
+
*/
|
|
454
|
+
function selectClaudeProjectSessionByProximity(record) {
|
|
455
|
+
const hasUserTurn = record.messages.some((turn) => turn.role === "user"
|
|
456
|
+
&& turn.content.some((block) => block.type === "text" && block.text.trim().length > 0));
|
|
457
|
+
if (!hasUserTurn) {
|
|
458
|
+
return null;
|
|
459
|
+
}
|
|
460
|
+
const startedAtMs = Date.parse(record.startedAt);
|
|
461
|
+
const now = Date.now();
|
|
462
|
+
// Look for files modified from ~60s before session start up to now
|
|
463
|
+
const proximityWindowMs = 60 * 1000;
|
|
464
|
+
const candidates = listClaudeProjectSessionCandidates(record.cwd)
|
|
465
|
+
.filter((candidate) => {
|
|
466
|
+
if (!Number.isFinite(startedAtMs))
|
|
467
|
+
return true;
|
|
468
|
+
return candidate.mtimeMs >= startedAtMs - proximityWindowMs
|
|
469
|
+
&& candidate.mtimeMs <= now + DISCOVERY_RECENT_WINDOW_MS;
|
|
470
|
+
})
|
|
471
|
+
.map((candidate) => readClaudeProjectSessionDetails(candidate.filePath, candidate.id))
|
|
472
|
+
.filter((candidate) => Boolean(candidate?.hasConversation))
|
|
473
|
+
.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
474
|
+
return candidates[0] ?? null;
|
|
475
|
+
}
|
|
476
|
+
function getResumeEligibility(record) {
|
|
477
|
+
const hasClaudeSessionId = Boolean(record.claudeSessionId);
|
|
478
|
+
const hasRealConversation = hasRealConversationMessages(record.messages);
|
|
479
|
+
return {
|
|
480
|
+
hasClaudeSessionId,
|
|
481
|
+
hasRealConversation,
|
|
482
|
+
eligible: hasClaudeSessionId && hasRealConversation
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
function hasResumeEligibleConversation(record) {
|
|
486
|
+
return getResumeEligibility(record).eligible;
|
|
487
|
+
}
|
|
488
|
+
function getLatestClaudeProjectSessionId(record) {
|
|
489
|
+
// Try strict mtime-correlation first, then fall back to mtime proximity
|
|
490
|
+
return selectClaudeProjectSessionForRecord(record)?.id
|
|
491
|
+
?? selectClaudeProjectSessionByProximity(record)?.id
|
|
492
|
+
?? null;
|
|
493
|
+
}
|
|
494
|
+
function listRecentClaudeProjectSessionIds(cwd, startedAt) {
|
|
495
|
+
return listClaudeProjectSessionCandidates(cwd)
|
|
496
|
+
.filter((candidate) => hasRecentProjectActivity(candidate, startedAt))
|
|
497
|
+
.sort((a, b) => b.mtimeMs - a.mtimeMs)
|
|
498
|
+
.map((candidate) => candidate.id);
|
|
499
|
+
}
|
|
500
|
+
function findRealClaudeProjectSessionId(cwd, startedAt) {
|
|
501
|
+
// Strict mtime-based discovery first
|
|
502
|
+
const candidates = listRecentClaudeProjectSessionIds(cwd, startedAt)
|
|
503
|
+
.map((id) => {
|
|
504
|
+
const filePath = path.join(getClaudeProjectDir(cwd), `${id}.jsonl`);
|
|
505
|
+
return readClaudeProjectSessionDetails(filePath, id);
|
|
506
|
+
})
|
|
507
|
+
.filter((candidate) => Boolean(candidate?.hasConversation))
|
|
508
|
+
.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
509
|
+
if (candidates.length > 0)
|
|
510
|
+
return candidates[0].id;
|
|
511
|
+
// Fallback: broader proximity search for files with conversation content
|
|
512
|
+
const startedAtMs = Date.parse(startedAt);
|
|
513
|
+
const now = Date.now();
|
|
514
|
+
const proximityWindowMs = 60 * 1000;
|
|
515
|
+
const proximityCandidates = listClaudeProjectSessionCandidates(cwd)
|
|
516
|
+
.filter((candidate) => {
|
|
517
|
+
if (!Number.isFinite(startedAtMs))
|
|
518
|
+
return true;
|
|
519
|
+
return candidate.mtimeMs >= startedAtMs - proximityWindowMs
|
|
520
|
+
&& candidate.mtimeMs <= now + DISCOVERY_RECENT_WINDOW_MS;
|
|
521
|
+
})
|
|
522
|
+
.map((candidate) => readClaudeProjectSessionDetails(candidate.filePath, candidate.id))
|
|
523
|
+
.filter((candidate) => Boolean(candidate?.hasConversation))
|
|
524
|
+
.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
525
|
+
return proximityCandidates[0]?.id ?? null;
|
|
526
|
+
}
|
|
527
|
+
function isClaudeSessionFileAvailable(cwd, claudeSessionId) {
|
|
528
|
+
const filePath = path.join(getClaudeProjectDir(cwd), `${claudeSessionId}.jsonl`);
|
|
529
|
+
return Boolean(readClaudeProjectSessionDetails(filePath, claudeSessionId));
|
|
530
|
+
}
|
|
531
|
+
/**
|
|
532
|
+
* Reverse the normalization done by getClaudeProjectDir.
|
|
533
|
+
* "-vol1-1000-yolo-claude-wand" → "/vol1/1000/yolo-claude/wand"
|
|
534
|
+
* This is lossy (real hyphens become slashes), so we try all possible
|
|
535
|
+
* interpretations and validate with existsSync, falling back to naive replacement.
|
|
536
|
+
*/
|
|
537
|
+
function invertNormalizedProjectDir(dirName) {
|
|
538
|
+
// The normalization is: path.resolve(cwd).replace(/\//g, "-")
|
|
539
|
+
const naive = dirName.replace(/-/g, "/");
|
|
540
|
+
if (existsSync(naive))
|
|
541
|
+
return naive;
|
|
542
|
+
// BFS: at each hyphen position, try "/" (path separator) or "-" (literal hyphen).
|
|
543
|
+
// Prune candidates that don't exist as directories, but only if at least one
|
|
544
|
+
// candidate survives pruning. Otherwise keep all to allow deeper merges.
|
|
545
|
+
const parts = dirName.split("-").filter(Boolean);
|
|
546
|
+
if (parts.length === 0 || parts.length > 20)
|
|
547
|
+
return naive;
|
|
548
|
+
let candidates = ["/" + parts[0]];
|
|
549
|
+
for (let i = 1; i < parts.length; i++) {
|
|
550
|
+
const next = [];
|
|
551
|
+
for (const prefix of candidates) {
|
|
552
|
+
next.push(prefix + "/" + parts[i]);
|
|
553
|
+
next.push(prefix + "-" + parts[i]);
|
|
554
|
+
}
|
|
555
|
+
if (i < parts.length - 1) {
|
|
556
|
+
// Prune non-existent prefixes, but keep all if none exist
|
|
557
|
+
const valid = next.filter((c) => { try {
|
|
558
|
+
return existsSync(c);
|
|
559
|
+
}
|
|
560
|
+
catch {
|
|
561
|
+
return false;
|
|
562
|
+
} });
|
|
563
|
+
candidates = valid.length > 0 ? valid : next;
|
|
564
|
+
}
|
|
565
|
+
else {
|
|
566
|
+
candidates = next;
|
|
567
|
+
}
|
|
568
|
+
if (candidates.length > 200)
|
|
569
|
+
candidates = candidates.slice(0, 200);
|
|
570
|
+
}
|
|
571
|
+
// Return the first candidate that exists, or the first one, or naive
|
|
572
|
+
for (const c of candidates) {
|
|
573
|
+
if (existsSync(c))
|
|
574
|
+
return c;
|
|
575
|
+
}
|
|
576
|
+
return candidates[0] || naive;
|
|
577
|
+
}
|
|
578
|
+
/** Read only the first ~8KB of a JSONL file to extract summary metadata. */
|
|
579
|
+
function readClaudeSessionSummary(filePath, id, cwd) {
|
|
580
|
+
try {
|
|
581
|
+
const stats = statSync(filePath);
|
|
582
|
+
const fd = openSync(filePath, "r");
|
|
583
|
+
const buffer = Buffer.alloc(8192);
|
|
584
|
+
const bytesRead = readSync(fd, buffer, 0, 8192, 0);
|
|
585
|
+
closeSync(fd);
|
|
586
|
+
const chunk = buffer.toString("utf8", 0, bytesRead);
|
|
587
|
+
const lines = chunk.split("\n").filter((line) => line.trim().length > 0);
|
|
588
|
+
let timestamp = "";
|
|
589
|
+
let firstUserMessage = "";
|
|
590
|
+
let hasUser = false;
|
|
591
|
+
let hasAssistant = false;
|
|
592
|
+
for (const line of lines) {
|
|
593
|
+
try {
|
|
594
|
+
const parsed = JSON.parse(line);
|
|
595
|
+
if (!timestamp && parsed.timestamp) {
|
|
596
|
+
timestamp = parsed.timestamp;
|
|
597
|
+
}
|
|
598
|
+
if (parsed.type === "user" || parsed.message?.role === "user") {
|
|
599
|
+
hasUser = true;
|
|
600
|
+
if (!firstUserMessage) {
|
|
601
|
+
if (typeof parsed.content === "string" && parsed.content.trim()) {
|
|
602
|
+
firstUserMessage = parsed.content.trim().slice(0, 120);
|
|
603
|
+
}
|
|
604
|
+
else if (parsed.message?.content && typeof parsed.message.content === "string") {
|
|
605
|
+
firstUserMessage = parsed.message.content.trim().slice(0, 120);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
if (parsed.type === "assistant" || parsed.message?.role === "assistant") {
|
|
610
|
+
hasAssistant = true;
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
catch {
|
|
614
|
+
continue;
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
// cwd is passed in from the caller
|
|
618
|
+
return {
|
|
619
|
+
claudeSessionId: id,
|
|
620
|
+
projectDir: path.basename(path.dirname(filePath)),
|
|
621
|
+
cwd,
|
|
622
|
+
firstUserMessage,
|
|
623
|
+
timestamp: timestamp || new Date(stats.mtimeMs).toISOString(),
|
|
624
|
+
mtimeMs: stats.mtimeMs,
|
|
625
|
+
hasConversation: hasUser && hasAssistant,
|
|
626
|
+
managedByWand: false,
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
catch {
|
|
630
|
+
return null;
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
/** Scan all ~/.claude/projects/ directories for session JSONL files. */
|
|
634
|
+
function listAllClaudeHistorySessions() {
|
|
635
|
+
const projectsDir = path.join(os.homedir(), ".claude", "projects");
|
|
636
|
+
try {
|
|
637
|
+
const projectDirs = readdirSync(projectsDir, { withFileTypes: true })
|
|
638
|
+
.filter((entry) => entry.isDirectory());
|
|
639
|
+
const results = [];
|
|
640
|
+
for (const dir of projectDirs) {
|
|
641
|
+
const dirPath = path.join(projectsDir, dir.name);
|
|
642
|
+
const cwd = invertNormalizedProjectDir(dir.name);
|
|
643
|
+
try {
|
|
644
|
+
const files = readdirSync(dirPath, { withFileTypes: true })
|
|
645
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl"))
|
|
646
|
+
.map((entry) => entry.name.replace(/\.jsonl$/, ""))
|
|
647
|
+
.filter((name) => UUID_V4_PATTERN.test(name));
|
|
648
|
+
for (const sessionId of files) {
|
|
649
|
+
const filePath = path.join(dirPath, `${sessionId}.jsonl`);
|
|
650
|
+
const summary = readClaudeSessionSummary(filePath, sessionId, cwd);
|
|
651
|
+
if (summary) {
|
|
652
|
+
results.push(summary);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
catch {
|
|
657
|
+
continue;
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
return results.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
661
|
+
}
|
|
662
|
+
catch {
|
|
663
|
+
return [];
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
function shouldAutoResumeSession(record) {
|
|
667
|
+
return record.status === "exited"
|
|
668
|
+
&& !record.archived
|
|
669
|
+
&& !record.resumedToSessionId
|
|
670
|
+
&& record.ptyProcess === null
|
|
671
|
+
&& hasResumeEligibleConversation(record);
|
|
672
|
+
}
|
|
673
|
+
function shouldBackfillClaudeSessionId(record) {
|
|
674
|
+
return record.status === "exited"
|
|
675
|
+
&& !record.claudeSessionId
|
|
676
|
+
&& /^claude\b/.test(record.command.trim())
|
|
677
|
+
&& hasRealConversationMessages(record.messages);
|
|
678
|
+
}
|
|
679
|
+
function snapshotMessages(record) {
|
|
680
|
+
return record.ptyBridge?.getMessages() ?? record.messages;
|
|
681
|
+
}
|
|
52
682
|
const MAX_SESSIONS = 50;
|
|
53
683
|
const ARCHIVE_AFTER_MS = 1000 * 60 * 60 * 24;
|
|
54
684
|
const CONFIRM_WINDOW_SIZE = 800;
|
|
55
685
|
// Claude 会话 ID 格式:UUID v4
|
|
56
686
|
const CLAUDE_SESSION_ID_PATTERN = /"session_id"\s*:\s*"([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})"/i;
|
|
57
|
-
|
|
58
|
-
function
|
|
59
|
-
const
|
|
60
|
-
|
|
687
|
+
const UUID_V4_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
688
|
+
function listClaudeTaskIds() {
|
|
689
|
+
const tasksDir = path.join(os.homedir(), ".claude", "tasks");
|
|
690
|
+
try {
|
|
691
|
+
return readdirSync(tasksDir, { withFileTypes: true })
|
|
692
|
+
.filter((entry) => entry.isDirectory() && UUID_V4_PATTERN.test(entry.name))
|
|
693
|
+
.map((entry) => entry.name);
|
|
694
|
+
}
|
|
695
|
+
catch {
|
|
696
|
+
return [];
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
function getClaudeProjectDir(cwd) {
|
|
700
|
+
const normalized = path.resolve(cwd).replace(/\//g, "-");
|
|
701
|
+
return path.join(os.homedir(), ".claude", "projects", normalized);
|
|
702
|
+
}
|
|
703
|
+
function getLatestClaudeTaskId(excludeIds) {
|
|
704
|
+
const tasksDir = path.join(os.homedir(), ".claude", "tasks");
|
|
705
|
+
try {
|
|
706
|
+
const candidates = readdirSync(tasksDir, { withFileTypes: true })
|
|
707
|
+
.filter((entry) => entry.isDirectory() && UUID_V4_PATTERN.test(entry.name) && !excludeIds.has(entry.name))
|
|
708
|
+
.map((entry) => {
|
|
709
|
+
const fullPath = path.join(tasksDir, entry.name);
|
|
710
|
+
const stats = statSync(fullPath);
|
|
711
|
+
return { id: entry.name, mtimeMs: stats.mtimeMs };
|
|
712
|
+
})
|
|
713
|
+
.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
714
|
+
return candidates[0]?.id ?? null;
|
|
715
|
+
}
|
|
716
|
+
catch {
|
|
717
|
+
return null;
|
|
718
|
+
}
|
|
61
719
|
}
|
|
62
720
|
export class ProcessManager extends EventEmitter {
|
|
63
721
|
config;
|
|
@@ -65,6 +723,10 @@ export class ProcessManager extends EventEmitter {
|
|
|
65
723
|
sessions = new Map();
|
|
66
724
|
logger;
|
|
67
725
|
lifecycleManager;
|
|
726
|
+
/** Per-session debounce timers for throttled persist calls */
|
|
727
|
+
persistDebounceTimers = new Map();
|
|
728
|
+
/** Last persisted message count per session — used to skip redundant file writes */
|
|
729
|
+
lastPersistedMessageCount = new Map();
|
|
68
730
|
constructor(config, storage, configDir) {
|
|
69
731
|
super();
|
|
70
732
|
this.config = config;
|
|
@@ -84,6 +746,7 @@ export class ProcessManager extends EventEmitter {
|
|
|
84
746
|
});
|
|
85
747
|
for (const snapshot of this.storage.loadSessions()) {
|
|
86
748
|
const isClaudeCmd = /^claude\b/.test(snapshot.command.trim());
|
|
749
|
+
const resumeCommandSessionId = getResumeCommandSessionId(snapshot.command);
|
|
87
750
|
// Sessions restored from storage have ptyProcess: null — the old server's PTY
|
|
88
751
|
// belongs to a dead process. Mark running sessions as exited so the UI
|
|
89
752
|
// reflects reality and users can start fresh sessions.
|
|
@@ -112,7 +775,11 @@ export class ProcessManager extends EventEmitter {
|
|
|
112
775
|
ptyBridge: null,
|
|
113
776
|
currentTask: null,
|
|
114
777
|
taskDebounceTimer: null,
|
|
115
|
-
lastEmittedTask: null
|
|
778
|
+
lastEmittedTask: null,
|
|
779
|
+
knownClaudeTaskIds: undefined,
|
|
780
|
+
claudeTaskDiscoveryTimer: null,
|
|
781
|
+
knownClaudeProjectMtimes: isClaudeCmd ? listClaudeProjectSessionMtimes(updated.cwd) : undefined,
|
|
782
|
+
claudeSessionId: resumeCommandSessionId ?? updated.claudeSessionId
|
|
116
783
|
});
|
|
117
784
|
this.lifecycleManager.register(snapshot.id, "idle");
|
|
118
785
|
console.error(`[ProcessManager] Restored session ${snapshot.id} marked as exited (PTY orphaned)`);
|
|
@@ -140,11 +807,21 @@ export class ProcessManager extends EventEmitter {
|
|
|
140
807
|
ptyBridge: null,
|
|
141
808
|
currentTask: null,
|
|
142
809
|
taskDebounceTimer: null,
|
|
143
|
-
lastEmittedTask: null
|
|
810
|
+
lastEmittedTask: null,
|
|
811
|
+
knownClaudeTaskIds: undefined,
|
|
812
|
+
claudeTaskDiscoveryTimer: null,
|
|
813
|
+
knownClaudeProjectMtimes: isClaudeCmd ? listClaudeProjectSessionMtimes(snapshot.cwd) : undefined,
|
|
814
|
+
claudeSessionId: resumeCommandSessionId ?? snapshot.claudeSessionId
|
|
144
815
|
});
|
|
145
816
|
this.lifecycleManager.register(snapshot.id, "archived");
|
|
146
817
|
}
|
|
147
818
|
}
|
|
819
|
+
// Defer expensive file-system scanning and auto-recovery so the server
|
|
820
|
+
// can start responding to requests immediately.
|
|
821
|
+
setImmediate(() => {
|
|
822
|
+
this.backfillExitedClaudeSessionIds();
|
|
823
|
+
this.autoRecoverExitedSessions();
|
|
824
|
+
});
|
|
148
825
|
this.archiveExpiredSessions();
|
|
149
826
|
}
|
|
150
827
|
on(event, listener) {
|
|
@@ -172,19 +849,30 @@ export class ProcessManager extends EventEmitter {
|
|
|
172
849
|
})
|
|
173
850
|
.slice(0, this.sessions.size - MAX_SESSIONS + 1)
|
|
174
851
|
.forEach((id) => {
|
|
852
|
+
const record = this.sessions.get(id);
|
|
853
|
+
if (record) {
|
|
854
|
+
this.logger.deleteSession(id);
|
|
855
|
+
this.deleteClaudeCache(record);
|
|
856
|
+
}
|
|
175
857
|
this.sessions.delete(id);
|
|
858
|
+
this.lastPersistedMessageCount.delete(id);
|
|
176
859
|
this.storage.deleteSession(id);
|
|
177
860
|
});
|
|
178
861
|
}
|
|
179
|
-
start(command, cwd, mode, initialInput) {
|
|
862
|
+
start(command, cwd, mode, initialInput, opts) {
|
|
180
863
|
this.assertCommandAllowed(command);
|
|
181
864
|
const resolvedCwd = cwd
|
|
182
865
|
? path.resolve(process.cwd(), cwd)
|
|
183
866
|
: path.resolve(process.cwd(), this.config.defaultCwd);
|
|
867
|
+
const isClaudeCmd = this.isClaudeCommand(command);
|
|
184
868
|
// For full-access mode with claude, add permission flags
|
|
185
869
|
const processedCommand = this.processCommandForMode(command, mode);
|
|
870
|
+
const resumeCommandSessionId = getResumeCommandSessionId(processedCommand) ?? getResumeCommandSessionId(command);
|
|
871
|
+
const knownClaudeTaskIds = isClaudeCmd ? new Set(listRecentClaudeProjectSessionIds(resolvedCwd, new Date().toISOString())) : null;
|
|
872
|
+
const knownClaudeProjectMtimes = isClaudeCmd ? listClaudeProjectSessionMtimes(resolvedCwd) : null;
|
|
873
|
+
const initialClaudeSessionId = resumeCommandSessionId ?? null;
|
|
874
|
+
const startedAt = new Date().toISOString();
|
|
186
875
|
const id = randomUUID();
|
|
187
|
-
const isClaudeCmd = this.isClaudeCommand(command);
|
|
188
876
|
const record = {
|
|
189
877
|
id,
|
|
190
878
|
command,
|
|
@@ -195,7 +883,7 @@ export class ProcessManager extends EventEmitter {
|
|
|
195
883
|
allowedScopes: [],
|
|
196
884
|
status: "running",
|
|
197
885
|
exitCode: null,
|
|
198
|
-
startedAt
|
|
886
|
+
startedAt,
|
|
199
887
|
endedAt: null,
|
|
200
888
|
output: "",
|
|
201
889
|
archived: false,
|
|
@@ -203,7 +891,7 @@ export class ProcessManager extends EventEmitter {
|
|
|
203
891
|
permissionBlocked: undefined,
|
|
204
892
|
pendingEscalation: null,
|
|
205
893
|
lastEscalationResult: null,
|
|
206
|
-
claudeSessionId:
|
|
894
|
+
claudeSessionId: initialClaudeSessionId,
|
|
207
895
|
processId: null,
|
|
208
896
|
ptyProcess: null,
|
|
209
897
|
stopRequested: false,
|
|
@@ -211,6 +899,8 @@ export class ProcessManager extends EventEmitter {
|
|
|
211
899
|
ptyPermissionBlocked: false,
|
|
212
900
|
lastAutoConfirmAt: 0,
|
|
213
901
|
autoApprovePermissions: this.shouldAutoApprovePermissions(command, mode),
|
|
902
|
+
resumedFromSessionId: opts?.resumedFromSessionId ?? null,
|
|
903
|
+
autoRecovered: opts?.autoRecovered ?? false,
|
|
214
904
|
rememberedEscalationScopes: new Set(),
|
|
215
905
|
rememberedEscalationTargets: new Set(),
|
|
216
906
|
storedOutput: "",
|
|
@@ -219,7 +909,10 @@ export class ProcessManager extends EventEmitter {
|
|
|
219
909
|
ptyBridge: null,
|
|
220
910
|
currentTask: null,
|
|
221
911
|
taskDebounceTimer: null,
|
|
222
|
-
lastEmittedTask: null
|
|
912
|
+
lastEmittedTask: null,
|
|
913
|
+
knownClaudeTaskIds: knownClaudeTaskIds ?? undefined,
|
|
914
|
+
claudeTaskDiscoveryTimer: null,
|
|
915
|
+
knownClaudeProjectMtimes: knownClaudeProjectMtimes ?? undefined
|
|
223
916
|
};
|
|
224
917
|
// Create PTY bridge for this session
|
|
225
918
|
record.ptyBridge = new ClaudePtyBridge({
|
|
@@ -275,15 +968,28 @@ export class ProcessManager extends EventEmitter {
|
|
|
275
968
|
const current = this.sessions.get(id);
|
|
276
969
|
if (!current)
|
|
277
970
|
return;
|
|
971
|
+
if (current.claudeTaskDiscoveryTimer) {
|
|
972
|
+
clearTimeout(current.claudeTaskDiscoveryTimer);
|
|
973
|
+
current.claudeTaskDiscoveryTimer = null;
|
|
974
|
+
}
|
|
975
|
+
if (current.initialInputTimer) {
|
|
976
|
+
clearTimeout(current.initialInputTimer);
|
|
977
|
+
current.initialInputTimer = null;
|
|
978
|
+
}
|
|
278
979
|
if (current.ptyBridge) {
|
|
279
980
|
current.ptyBridge.onExit(exitCode);
|
|
981
|
+
current.ptyBridge.removeAllListeners();
|
|
280
982
|
}
|
|
983
|
+
current.pendingEscalation = null;
|
|
984
|
+
current.ptyPermissionBlocked = false;
|
|
281
985
|
current.status = current.stopRequested ? "stopped" : exitCode === 0 ? "exited" : "failed";
|
|
282
986
|
current.exitCode = current.stopRequested ? null : exitCode;
|
|
283
987
|
current.endedAt = new Date().toISOString();
|
|
284
988
|
current.ptyProcess = null;
|
|
285
989
|
this.lifecycleManager.archive(id, `Session ${current.status}`, current.stopRequested ? "user" : "error");
|
|
286
|
-
this.
|
|
990
|
+
this.flushPersist(current);
|
|
991
|
+
// Final full snapshot with messages to SQLite (persist() only saves metadata)
|
|
992
|
+
this.storage.saveSession(this.snapshot(current));
|
|
287
993
|
this.emitEvent({ type: "ended", sessionId: id, data: this.snapshot(current) });
|
|
288
994
|
});
|
|
289
995
|
// Set PTY write function for bridge (for permission approval).
|
|
@@ -336,21 +1042,65 @@ export class ProcessManager extends EventEmitter {
|
|
|
336
1042
|
rec.claudeSessionId = bridgeSessionId;
|
|
337
1043
|
process.stderr.write(`[wand] Captured Claude session ID: ${bridgeSessionId}\n`);
|
|
338
1044
|
}
|
|
339
|
-
|
|
340
|
-
|
|
1045
|
+
if (!rec.claudeSessionId && rec.knownClaudeTaskIds) {
|
|
1046
|
+
rec.messages = snapshotMessages(rec);
|
|
1047
|
+
const discoveredTaskId = getLatestClaudeProjectSessionId({
|
|
1048
|
+
cwd: rec.cwd,
|
|
1049
|
+
startedAt: rec.startedAt,
|
|
1050
|
+
knownClaudeProjectMtimes: rec.knownClaudeProjectMtimes,
|
|
1051
|
+
messages: rec.messages
|
|
1052
|
+
});
|
|
1053
|
+
if (discoveredTaskId) {
|
|
1054
|
+
rec.claudeSessionId = discoveredTaskId;
|
|
1055
|
+
rec.knownClaudeTaskIds.add(discoveredTaskId);
|
|
1056
|
+
process.stderr.write(`[wand] Captured Claude project session ID: ${discoveredTaskId}\n`);
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
// Auto-confirm for full-access mode (legacy path for non-Claude sessions without ptyBridge)
|
|
1060
|
+
if (rec.autoApprovePermissions && !rec.ptyBridge) {
|
|
341
1061
|
this.autoConfirmWithRecord(rec, chunk, child);
|
|
342
1062
|
}
|
|
343
1063
|
if (initialInput && !initialInputSent && chunk.includes("❯")) {
|
|
344
1064
|
sendInitialInput();
|
|
345
1065
|
}
|
|
346
|
-
this.
|
|
1066
|
+
this.schedulePersist(rec);
|
|
347
1067
|
});
|
|
348
1068
|
if (initialInput) {
|
|
349
|
-
setTimeout(() => {
|
|
1069
|
+
record.initialInputTimer = setTimeout(() => {
|
|
1070
|
+
record.initialInputTimer = null;
|
|
350
1071
|
if (!initialInputSent)
|
|
351
1072
|
sendInitialInput();
|
|
352
1073
|
}, 3000);
|
|
353
1074
|
}
|
|
1075
|
+
if (record.knownClaudeTaskIds) {
|
|
1076
|
+
const tryDiscoverClaudeTaskId = () => {
|
|
1077
|
+
const current = this.sessions.get(id);
|
|
1078
|
+
if (!current || current.status !== "running" || current.claudeSessionId || !current.knownClaudeTaskIds) {
|
|
1079
|
+
return;
|
|
1080
|
+
}
|
|
1081
|
+
if (getResumeCommandSessionId(current.command)) {
|
|
1082
|
+
current.claudeTaskDiscoveryTimer = null;
|
|
1083
|
+
return;
|
|
1084
|
+
}
|
|
1085
|
+
current.messages = snapshotMessages(current);
|
|
1086
|
+
const discoveredTaskId = getLatestClaudeProjectSessionId({
|
|
1087
|
+
cwd: current.cwd,
|
|
1088
|
+
startedAt: current.startedAt,
|
|
1089
|
+
knownClaudeProjectMtimes: current.knownClaudeProjectMtimes,
|
|
1090
|
+
messages: current.messages
|
|
1091
|
+
});
|
|
1092
|
+
if (discoveredTaskId) {
|
|
1093
|
+
current.claudeSessionId = discoveredTaskId;
|
|
1094
|
+
current.knownClaudeTaskIds.add(discoveredTaskId);
|
|
1095
|
+
current.claudeTaskDiscoveryTimer = null;
|
|
1096
|
+
process.stderr.write(`[wand] Discovered Claude resumable project session ID: ${discoveredTaskId}\n`);
|
|
1097
|
+
this.persist(current);
|
|
1098
|
+
return;
|
|
1099
|
+
}
|
|
1100
|
+
current.claudeTaskDiscoveryTimer = setTimeout(tryDiscoverClaudeTaskId, 1000);
|
|
1101
|
+
};
|
|
1102
|
+
record.claudeTaskDiscoveryTimer = setTimeout(tryDiscoverClaudeTaskId, 500);
|
|
1103
|
+
}
|
|
354
1104
|
return this.snapshot(record);
|
|
355
1105
|
}
|
|
356
1106
|
list() {
|
|
@@ -359,6 +1109,32 @@ export class ProcessManager extends EventEmitter {
|
|
|
359
1109
|
.sort((a, b) => b.startedAt.localeCompare(a.startedAt))
|
|
360
1110
|
.map((session) => this.snapshot(session));
|
|
361
1111
|
}
|
|
1112
|
+
hasClaudeSessionFile(cwd, claudeSessionId) {
|
|
1113
|
+
return isClaudeSessionFileAvailable(cwd, claudeSessionId);
|
|
1114
|
+
}
|
|
1115
|
+
claudeHistoryCache = null;
|
|
1116
|
+
static HISTORY_CACHE_TTL_MS = 30_000;
|
|
1117
|
+
listClaudeHistorySessions() {
|
|
1118
|
+
const now = Date.now();
|
|
1119
|
+
if (this.claudeHistoryCache && now < this.claudeHistoryCache.expiresAt) {
|
|
1120
|
+
return this.claudeHistoryCache.data;
|
|
1121
|
+
}
|
|
1122
|
+
const allSessions = listAllClaudeHistorySessions();
|
|
1123
|
+
// Cross-reference with wand-managed sessions
|
|
1124
|
+
const managedClaudeIds = new Set();
|
|
1125
|
+
for (const record of this.sessions.values()) {
|
|
1126
|
+
if (record.claudeSessionId) {
|
|
1127
|
+
managedClaudeIds.add(record.claudeSessionId);
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
for (const session of allSessions) {
|
|
1131
|
+
if (managedClaudeIds.has(session.claudeSessionId)) {
|
|
1132
|
+
session.managedByWand = true;
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
this.claudeHistoryCache = { data: allSessions, expiresAt: now + ProcessManager.HISTORY_CACHE_TTL_MS };
|
|
1136
|
+
return allSessions;
|
|
1137
|
+
}
|
|
362
1138
|
get(id) {
|
|
363
1139
|
this.archiveExpiredSessions();
|
|
364
1140
|
const record = this.sessions.get(id);
|
|
@@ -462,22 +1238,23 @@ export class ProcessManager extends EventEmitter {
|
|
|
462
1238
|
clearTimeout(record.taskDebounceTimer);
|
|
463
1239
|
record.taskDebounceTimer = null;
|
|
464
1240
|
}
|
|
465
|
-
|
|
466
|
-
record.
|
|
467
|
-
|
|
468
|
-
if (record.childProcess) {
|
|
469
|
-
record.childProcess.kill();
|
|
470
|
-
record.childProcess = null;
|
|
471
|
-
}
|
|
472
|
-
// Kill the PTY process
|
|
473
|
-
if (record.ptyProcess) {
|
|
474
|
-
record.ptyProcess.kill();
|
|
475
|
-
}
|
|
1241
|
+
if (record.claudeTaskDiscoveryTimer) {
|
|
1242
|
+
clearTimeout(record.claudeTaskDiscoveryTimer);
|
|
1243
|
+
record.claudeTaskDiscoveryTimer = null;
|
|
476
1244
|
}
|
|
477
|
-
|
|
478
|
-
record.
|
|
479
|
-
record.
|
|
480
|
-
|
|
1245
|
+
if (record.initialInputTimer) {
|
|
1246
|
+
clearTimeout(record.initialInputTimer);
|
|
1247
|
+
record.initialInputTimer = null;
|
|
1248
|
+
}
|
|
1249
|
+
record.stopRequested = true;
|
|
1250
|
+
// Kill any running child process (from JSON chat turns)
|
|
1251
|
+
if (record.childProcess) {
|
|
1252
|
+
record.childProcess.kill();
|
|
1253
|
+
record.childProcess = null;
|
|
1254
|
+
}
|
|
1255
|
+
// Kill the PTY process
|
|
1256
|
+
if (record.ptyProcess) {
|
|
1257
|
+
record.ptyProcess.kill();
|
|
481
1258
|
}
|
|
482
1259
|
// Immediately update status and clear PTY references so the session no longer
|
|
483
1260
|
// appears "running" and subsequent sendInput() calls are rejected cleanly.
|
|
@@ -486,7 +1263,10 @@ export class ProcessManager extends EventEmitter {
|
|
|
486
1263
|
record.exitCode = null;
|
|
487
1264
|
record.endedAt = new Date().toISOString();
|
|
488
1265
|
record.ptyProcess = null;
|
|
489
|
-
record.ptyBridge
|
|
1266
|
+
if (record.ptyBridge) {
|
|
1267
|
+
record.ptyBridge.removeAllListeners();
|
|
1268
|
+
record.ptyBridge = null;
|
|
1269
|
+
}
|
|
490
1270
|
// Update lifecycle
|
|
491
1271
|
this.lifecycleManager.archive(id, "Session stopped by user", "user");
|
|
492
1272
|
this.persist(record);
|
|
@@ -499,6 +1279,19 @@ export class ProcessManager extends EventEmitter {
|
|
|
499
1279
|
clearTimeout(record.taskDebounceTimer);
|
|
500
1280
|
record.taskDebounceTimer = null;
|
|
501
1281
|
}
|
|
1282
|
+
if (record.claudeTaskDiscoveryTimer) {
|
|
1283
|
+
clearTimeout(record.claudeTaskDiscoveryTimer);
|
|
1284
|
+
record.claudeTaskDiscoveryTimer = null;
|
|
1285
|
+
}
|
|
1286
|
+
if (record.initialInputTimer) {
|
|
1287
|
+
clearTimeout(record.initialInputTimer);
|
|
1288
|
+
record.initialInputTimer = null;
|
|
1289
|
+
}
|
|
1290
|
+
const pendingPersist = this.persistDebounceTimers.get(id);
|
|
1291
|
+
if (pendingPersist) {
|
|
1292
|
+
clearTimeout(pendingPersist);
|
|
1293
|
+
this.persistDebounceTimers.delete(id);
|
|
1294
|
+
}
|
|
502
1295
|
// Kill live processes if still running
|
|
503
1296
|
if (record.status === "running") {
|
|
504
1297
|
try {
|
|
@@ -519,11 +1312,33 @@ export class ProcessManager extends EventEmitter {
|
|
|
519
1312
|
// Always clean up all state references, regardless of current status
|
|
520
1313
|
record.childProcess = null;
|
|
521
1314
|
record.ptyProcess = null;
|
|
522
|
-
record.ptyBridge
|
|
523
|
-
|
|
1315
|
+
if (record.ptyBridge) {
|
|
1316
|
+
record.ptyBridge.removeAllListeners();
|
|
1317
|
+
record.ptyBridge = null;
|
|
1318
|
+
}
|
|
1319
|
+
// Delete from persistent storage BEFORE removing from in-memory map,
|
|
1320
|
+
// so a storage failure doesn't leave orphan records in the database.
|
|
524
1321
|
this.storage.deleteSession(id);
|
|
1322
|
+
this.logger.deleteSession(id);
|
|
1323
|
+
this.deleteClaudeCache(record);
|
|
1324
|
+
this.sessions.delete(id);
|
|
1325
|
+
this.lastPersistedMessageCount.delete(id);
|
|
1326
|
+
this.lifecycleManager.unregister(id);
|
|
525
1327
|
}
|
|
526
|
-
|
|
1328
|
+
deleteClaudeCache(record) {
|
|
1329
|
+
if (!record.claudeSessionId)
|
|
1330
|
+
return;
|
|
1331
|
+
const jsonlPath = path.join(getClaudeProjectDir(record.cwd), `${record.claudeSessionId}.jsonl`);
|
|
1332
|
+
try {
|
|
1333
|
+
if (existsSync(jsonlPath)) {
|
|
1334
|
+
unlinkSync(jsonlPath);
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
catch {
|
|
1338
|
+
// Non-critical — Claude cache cleanup is best-effort
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
runStartupCommands() {
|
|
527
1342
|
return this.config.startupCommands.map((command) => this.start(command, this.config.defaultCwd, this.config.defaultMode));
|
|
528
1343
|
}
|
|
529
1344
|
snapshot(record) {
|
|
@@ -548,7 +1363,10 @@ export class ProcessManager extends EventEmitter {
|
|
|
548
1363
|
pendingEscalation: record.pendingEscalation || undefined,
|
|
549
1364
|
lastEscalationResult: record.lastEscalationResult || undefined,
|
|
550
1365
|
claudeSessionId: record.claudeSessionId || null,
|
|
551
|
-
messages: messages.length > 0 ? messages : undefined
|
|
1366
|
+
messages: messages.length > 0 ? messages : undefined,
|
|
1367
|
+
resumedFromSessionId: record.resumedFromSessionId ?? undefined,
|
|
1368
|
+
resumedToSessionId: record.resumedToSessionId ?? undefined,
|
|
1369
|
+
autoRecovered: record.autoRecovered ?? false
|
|
552
1370
|
};
|
|
553
1371
|
}
|
|
554
1372
|
isPermissionBlocked(record) {
|
|
@@ -561,84 +1379,49 @@ export class ProcessManager extends EventEmitter {
|
|
|
561
1379
|
return "assist";
|
|
562
1380
|
}
|
|
563
1381
|
resolveEscalation(id, requestId, resolution) {
|
|
564
|
-
|
|
565
|
-
const escalation = record.pendingEscalation;
|
|
566
|
-
if (!escalation || escalation.requestId !== requestId) {
|
|
567
|
-
throw new Error("Escalation request not found.");
|
|
568
|
-
}
|
|
569
|
-
const finalResolution = resolution ?? "approve_once";
|
|
570
|
-
record.lastEscalationResult = {
|
|
571
|
-
requestId,
|
|
572
|
-
resolution: finalResolution,
|
|
573
|
-
reason: escalation.reason
|
|
574
|
-
};
|
|
575
|
-
if (finalResolution === "deny") {
|
|
576
|
-
record.pendingEscalation = null;
|
|
577
|
-
if (record.ptyProcess && record.status === "running") {
|
|
578
|
-
record.ptyProcess.write("n\r");
|
|
579
|
-
}
|
|
580
|
-
record.ptyPermissionBlocked = false;
|
|
581
|
-
this.persist(record);
|
|
582
|
-
return this.snapshot(record);
|
|
583
|
-
}
|
|
584
|
-
if (finalResolution === "approve_turn") {
|
|
585
|
-
record.rememberedEscalationScopes.add(escalation.scope);
|
|
586
|
-
if (escalation.target) {
|
|
587
|
-
record.rememberedEscalationTargets.add(escalation.target);
|
|
588
|
-
}
|
|
589
|
-
}
|
|
590
|
-
record.pendingEscalation = null;
|
|
591
|
-
record.ptyPermissionBlocked = false;
|
|
592
|
-
if (record.ptyProcess && record.status === "running") {
|
|
593
|
-
record.ptyProcess.write("\r");
|
|
594
|
-
}
|
|
595
|
-
this.persist(record);
|
|
596
|
-
return this.snapshot(record);
|
|
1382
|
+
return this.resolvePermission(id, resolution ?? "approve_once", requestId);
|
|
597
1383
|
}
|
|
598
1384
|
approvePermission(id) {
|
|
599
|
-
|
|
600
|
-
// Use bridge for permission resolution
|
|
601
|
-
if (record.ptyBridge) {
|
|
602
|
-
record.ptyBridge.resolvePermission("approve_once");
|
|
603
|
-
}
|
|
604
|
-
else if (record.ptyProcess && record.status === "running") {
|
|
605
|
-
record.ptyProcess.write("\r");
|
|
606
|
-
}
|
|
607
|
-
record.ptyPermissionBlocked = false;
|
|
608
|
-
record.pendingEscalation = null;
|
|
609
|
-
this.persist(record);
|
|
610
|
-
return this.snapshot(record);
|
|
1385
|
+
return this.resolvePermission(id, "approve_once");
|
|
611
1386
|
}
|
|
612
1387
|
denyPermission(id) {
|
|
613
|
-
|
|
614
|
-
// Use bridge for permission resolution
|
|
615
|
-
if (record.ptyBridge) {
|
|
616
|
-
record.ptyBridge.resolvePermission("deny");
|
|
617
|
-
}
|
|
618
|
-
else if (record.ptyProcess && record.status === "running") {
|
|
619
|
-
record.ptyProcess.write("n\r");
|
|
620
|
-
}
|
|
621
|
-
record.ptyPermissionBlocked = false;
|
|
622
|
-
record.pendingEscalation = null;
|
|
623
|
-
this.persist(record);
|
|
624
|
-
return this.snapshot(record);
|
|
1388
|
+
return this.resolvePermission(id, "deny");
|
|
625
1389
|
}
|
|
626
1390
|
/**
|
|
627
|
-
*
|
|
1391
|
+
* Canonical permission resolution method.
|
|
1392
|
+
* All other permission methods delegate to this.
|
|
628
1393
|
* @param resolution - "approve_once", "approve_turn", or "deny"
|
|
1394
|
+
* @param requestId - Optional escalation request ID for validation
|
|
629
1395
|
*/
|
|
630
|
-
resolvePermission(id, resolution) {
|
|
1396
|
+
resolvePermission(id, resolution, requestId) {
|
|
631
1397
|
const record = this.mustGet(id);
|
|
1398
|
+
// Validate requestId if provided
|
|
1399
|
+
if (requestId && record.pendingEscalation) {
|
|
1400
|
+
if (record.pendingEscalation.requestId !== requestId) {
|
|
1401
|
+
throw new Error("Escalation request not found.");
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
// Record escalation result for audit trail
|
|
1405
|
+
if (record.pendingEscalation) {
|
|
1406
|
+
record.lastEscalationResult = {
|
|
1407
|
+
requestId: record.pendingEscalation.requestId,
|
|
1408
|
+
resolution,
|
|
1409
|
+
reason: record.pendingEscalation.reason,
|
|
1410
|
+
};
|
|
1411
|
+
}
|
|
1412
|
+
// Handle "approve_turn" memory — only in ProcessManager for non-bridge sessions
|
|
1413
|
+
if (resolution === "approve_turn" && record.pendingEscalation && !record.ptyBridge) {
|
|
1414
|
+
record.rememberedEscalationScopes.add(record.pendingEscalation.scope);
|
|
1415
|
+
if (record.pendingEscalation.target) {
|
|
1416
|
+
record.rememberedEscalationTargets.add(record.pendingEscalation.target);
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
// Resolve via bridge or direct PTY write
|
|
632
1420
|
if (record.ptyBridge) {
|
|
633
1421
|
record.ptyBridge.resolvePermission(resolution);
|
|
634
1422
|
}
|
|
635
1423
|
else if (record.ptyProcess && record.status === "running") {
|
|
636
|
-
|
|
637
|
-
record.ptyProcess.write("n\r");
|
|
638
|
-
}
|
|
639
|
-
else {
|
|
640
|
-
record.ptyProcess.write("\r");
|
|
641
|
-
}
|
|
1424
|
+
record.ptyProcess.write(resolution === "deny" ? "n\r" : "\r");
|
|
642
1425
|
}
|
|
643
1426
|
record.ptyPermissionBlocked = false;
|
|
644
1427
|
record.pendingEscalation = null;
|
|
@@ -651,10 +1434,125 @@ export class ProcessManager extends EventEmitter {
|
|
|
651
1434
|
if (messages !== record.messages) {
|
|
652
1435
|
record.messages = messages;
|
|
653
1436
|
}
|
|
654
|
-
|
|
655
|
-
|
|
1437
|
+
// Use lightweight metadata-only write (skips large messages JSON)
|
|
1438
|
+
this.storage.saveSessionMetadata(this.snapshot(record));
|
|
1439
|
+
this.logger.saveMetadata(record.id, {
|
|
1440
|
+
id: record.id,
|
|
1441
|
+
command: record.command,
|
|
1442
|
+
status: record.status,
|
|
1443
|
+
startedAt: record.startedAt,
|
|
1444
|
+
endedAt: record.endedAt,
|
|
1445
|
+
claudeSessionId: record.claudeSessionId,
|
|
1446
|
+
resumedFromSessionId: record.resumedFromSessionId ?? null,
|
|
1447
|
+
resumedToSessionId: record.resumedToSessionId ?? null,
|
|
1448
|
+
autoRecovered: record.autoRecovered ?? false,
|
|
1449
|
+
});
|
|
1450
|
+
// Save structured messages to file only when count changes
|
|
656
1451
|
if (messages.length > 0) {
|
|
657
|
-
this.
|
|
1452
|
+
const lastCount = this.lastPersistedMessageCount.get(record.id) ?? 0;
|
|
1453
|
+
if (messages.length !== lastCount) {
|
|
1454
|
+
this.lastPersistedMessageCount.set(record.id, messages.length);
|
|
1455
|
+
this.logger.saveMessages(record.id, messages);
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
/**
|
|
1460
|
+
* Schedule a debounced persist call for the given record.
|
|
1461
|
+
* Multiple calls within the debounce window are coalesced into a single write.
|
|
1462
|
+
* Use this in hot paths (e.g. onData) to reduce I/O pressure.
|
|
1463
|
+
*/
|
|
1464
|
+
schedulePersist(record) {
|
|
1465
|
+
const existing = this.persistDebounceTimers.get(record.id);
|
|
1466
|
+
if (existing) {
|
|
1467
|
+
clearTimeout(existing);
|
|
1468
|
+
}
|
|
1469
|
+
const timer = setTimeout(() => {
|
|
1470
|
+
this.persistDebounceTimers.delete(record.id);
|
|
1471
|
+
this.persist(record);
|
|
1472
|
+
}, 1000);
|
|
1473
|
+
this.persistDebounceTimers.set(record.id, timer);
|
|
1474
|
+
}
|
|
1475
|
+
/**
|
|
1476
|
+
* Immediately persist any pending debounced write and clear the timer.
|
|
1477
|
+
* Use this at critical points (exit, stop, delete) to ensure no data loss.
|
|
1478
|
+
*/
|
|
1479
|
+
flushPersist(record) {
|
|
1480
|
+
const existing = this.persistDebounceTimers.get(record.id);
|
|
1481
|
+
if (existing) {
|
|
1482
|
+
clearTimeout(existing);
|
|
1483
|
+
this.persistDebounceTimers.delete(record.id);
|
|
1484
|
+
}
|
|
1485
|
+
this.persist(record);
|
|
1486
|
+
}
|
|
1487
|
+
backfillExitedClaudeSessionIds() {
|
|
1488
|
+
for (const record of this.sessions.values()) {
|
|
1489
|
+
record.messages = snapshotMessages(record);
|
|
1490
|
+
if (!shouldBackfillClaudeSessionId(record)) {
|
|
1491
|
+
continue;
|
|
1492
|
+
}
|
|
1493
|
+
const discoveredSessionId = findRealClaudeProjectSessionId(record.cwd, record.startedAt);
|
|
1494
|
+
if (!discoveredSessionId) {
|
|
1495
|
+
continue;
|
|
1496
|
+
}
|
|
1497
|
+
record.claudeSessionId = discoveredSessionId;
|
|
1498
|
+
this.persist(record);
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
/**
|
|
1502
|
+
* Auto-recover the most recent exited session that has a Claude session ID.
|
|
1503
|
+
* Only resumes one session per server start, using the most recent eligible
|
|
1504
|
+
* session. Sets `resumedToSessionId` on the original session and
|
|
1505
|
+
* `autoRecovered: true` on the new session.
|
|
1506
|
+
*/
|
|
1507
|
+
autoRecoverExitedSessions() {
|
|
1508
|
+
// Find eligible exited sessions
|
|
1509
|
+
const eligibleSessions = [];
|
|
1510
|
+
for (const record of this.sessions.values()) {
|
|
1511
|
+
record.messages = snapshotMessages(record);
|
|
1512
|
+
if (shouldAutoResumeSession(record)) {
|
|
1513
|
+
eligibleSessions.push(record);
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
if (eligibleSessions.length === 0)
|
|
1517
|
+
return;
|
|
1518
|
+
// Sort by startedAt descending (most recent first)
|
|
1519
|
+
eligibleSessions.sort((a, b) => b.startedAt.localeCompare(a.startedAt));
|
|
1520
|
+
// Only auto-recover the single most recent session
|
|
1521
|
+
const original = eligibleSessions[0];
|
|
1522
|
+
const isClaude = /^claude\b/.test(original.command.trim());
|
|
1523
|
+
if (!isClaude)
|
|
1524
|
+
return;
|
|
1525
|
+
// If no claudeSessionId is bound yet, try to discover it via proximity search
|
|
1526
|
+
if (!original.claudeSessionId) {
|
|
1527
|
+
const discovered = findRealClaudeProjectSessionId(original.cwd, original.startedAt);
|
|
1528
|
+
if (discovered) {
|
|
1529
|
+
original.claudeSessionId = discovered;
|
|
1530
|
+
process.stderr.write(`[wand] Backfilled Claude session ID for auto-recovery: ${discovered}\n`);
|
|
1531
|
+
this.persist(original);
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
if (!original.claudeSessionId) {
|
|
1535
|
+
console.error(`[ProcessManager] Skipping auto-recovery: no Claude session ID for session ${original.id}`);
|
|
1536
|
+
return;
|
|
1537
|
+
}
|
|
1538
|
+
console.error(`[ProcessManager] Auto-recovering session ${original.id} with Claude session ID ${original.claudeSessionId}`);
|
|
1539
|
+
const resumeCommand = `${original.command.trim()} --resume ${original.claudeSessionId}`;
|
|
1540
|
+
let newRecord = null;
|
|
1541
|
+
try {
|
|
1542
|
+
const snapshot = this.start(resumeCommand, original.cwd, original.mode, undefined, {
|
|
1543
|
+
resumedFromSessionId: original.id,
|
|
1544
|
+
autoRecovered: true
|
|
1545
|
+
});
|
|
1546
|
+
newRecord = this.sessions.get(snapshot.id) ?? null;
|
|
1547
|
+
if (!newRecord)
|
|
1548
|
+
return;
|
|
1549
|
+
// Set resumedToSessionId on the original session
|
|
1550
|
+
original.resumedToSessionId = snapshot.id;
|
|
1551
|
+
this.storage.saveSession(this.snapshot(original));
|
|
1552
|
+
console.error(`[ProcessManager] Auto-recovered session ${snapshot.id} from ${original.id}`);
|
|
1553
|
+
}
|
|
1554
|
+
catch (err) {
|
|
1555
|
+
console.error(`[ProcessManager] Auto-recovery failed: ${String(err)}`);
|
|
658
1556
|
}
|
|
659
1557
|
}
|
|
660
1558
|
archiveExpiredSessions() {
|
|
@@ -682,6 +1580,10 @@ export class ProcessManager extends EventEmitter {
|
|
|
682
1580
|
throw new Error("Command is not allowed by current configuration.");
|
|
683
1581
|
}
|
|
684
1582
|
}
|
|
1583
|
+
/**
|
|
1584
|
+
* @deprecated Only retained for non-Claude-CLI sessions without ptyBridge.
|
|
1585
|
+
* For Claude CLI sessions, auto-approval is handled by ClaudePtyBridge.detectPermission().
|
|
1586
|
+
*/
|
|
685
1587
|
autoConfirmWithRecord(record, output, ptyProcess) {
|
|
686
1588
|
if (!record.autoApprovePermissions) {
|
|
687
1589
|
return;
|
|
@@ -696,8 +1598,6 @@ export class ProcessManager extends EventEmitter {
|
|
|
696
1598
|
// Check for Claude's tool permission prompt patterns
|
|
697
1599
|
const toolPermissionPrompt = /\bdo you want to\b/i.test(normalized) &&
|
|
698
1600
|
/\(yes\b/i.test(normalized);
|
|
699
|
-
// Check if this is a selection-based prompt (needs Enter, not 'y')
|
|
700
|
-
const isSelectionPrompt = SELECTION_PROMPT_PATTERNS.some((pattern) => pattern.test(normalized));
|
|
701
1601
|
// Reduced cooldown for faster response
|
|
702
1602
|
if (now - record.lastAutoConfirmAt < 500) {
|
|
703
1603
|
return;
|
|
@@ -716,6 +1616,8 @@ export class ProcessManager extends EventEmitter {
|
|
|
716
1616
|
handleBridgeEvent(record, event) {
|
|
717
1617
|
switch (event.type) {
|
|
718
1618
|
case "output.raw":
|
|
1619
|
+
// Sync record.output from bridge before emitting so the event carries fresh data
|
|
1620
|
+
record.output = record.ptyBridge?.getRawOutput() ?? record.output;
|
|
719
1621
|
// Emit output event for terminal view
|
|
720
1622
|
this.emitEvent({
|
|
721
1623
|
type: "output",
|
|
@@ -729,6 +1631,8 @@ export class ProcessManager extends EventEmitter {
|
|
|
729
1631
|
});
|
|
730
1632
|
break;
|
|
731
1633
|
case "output.chat":
|
|
1634
|
+
// Sync record.output from bridge before emitting so the event carries fresh data
|
|
1635
|
+
record.output = record.ptyBridge?.getRawOutput() ?? record.output;
|
|
732
1636
|
// Emit output event with updated messages for chat view
|
|
733
1637
|
this.emitEvent({
|
|
734
1638
|
type: "output",
|
|
@@ -779,11 +1683,16 @@ export class ProcessManager extends EventEmitter {
|
|
|
779
1683
|
// Claude session ID captured - already handled in onData
|
|
780
1684
|
break;
|
|
781
1685
|
case "chat.turn":
|
|
782
|
-
// Turn completed - persist messages
|
|
1686
|
+
// Turn completed - persist full messages snapshot
|
|
783
1687
|
record.messages = record.ptyBridge?.getMessages() ?? record.messages;
|
|
1688
|
+
// Clear remembered permissions at turn boundaries
|
|
1689
|
+
record.ptyBridge?.clearRememberedPermissions();
|
|
1690
|
+
record.rememberedEscalationScopes.clear();
|
|
1691
|
+
record.rememberedEscalationTargets.clear();
|
|
784
1692
|
this.lifecycleManager.stopThinking(record.id);
|
|
785
1693
|
this.lifecycleManager.waitingInput(record.id);
|
|
786
1694
|
this.persist(record);
|
|
1695
|
+
this.storage.saveSession(this.snapshot(record));
|
|
787
1696
|
break;
|
|
788
1697
|
case "ended":
|
|
789
1698
|
// Session ended - handled in onExit
|
|
@@ -838,15 +1747,6 @@ export class ProcessManager extends EventEmitter {
|
|
|
838
1747
|
return command;
|
|
839
1748
|
}
|
|
840
1749
|
}
|
|
841
|
-
function normalizePromptText(value) {
|
|
842
|
-
return value
|
|
843
|
-
.replace(/\u001b\[(\d+)C/g, (_match, count) => " ".repeat(Number(count) || 1))
|
|
844
|
-
.replace(/\u001b\[[0-9;?]*[ -/]*[@-~]/g, "")
|
|
845
|
-
.replace(/\r/g, "\n")
|
|
846
|
-
.replace(/[ \t]+/g, " ")
|
|
847
|
-
.replace(/\n+/g, "\n")
|
|
848
|
-
.trim();
|
|
849
|
-
}
|
|
850
1750
|
function clampDimension(value, min, max) {
|
|
851
1751
|
if (!Number.isFinite(value)) {
|
|
852
1752
|
return min;
|