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