@co0ontty/wand 0.2.0 → 0.3.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/claude-pty-bridge.d.ts +139 -0
- package/dist/claude-pty-bridge.js +649 -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/config.js +2 -2
- package/dist/message-parser.js +12 -66
- package/dist/message-queue.d.ts +57 -0
- package/dist/message-queue.js +127 -0
- package/dist/process-manager.d.ts +32 -25
- package/dist/process-manager.js +503 -780
- package/dist/server.js +366 -51
- package/dist/session-lifecycle.d.ts +81 -0
- package/dist/session-lifecycle.js +176 -0
- package/dist/storage.js +12 -1
- package/dist/types.d.ts +105 -5
- package/dist/web-ui/content/scripts.js +2307 -658
- package/dist/web-ui/content/styles.css +5284 -2771
- package/dist/web-ui/index.js +8 -5
- package/dist/web-ui/scripts.js +8 -1
- package/package.json +3 -10
- 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,28 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { EventEmitter } from "node:events";
|
|
3
|
-
import { spawn } from "node:child_process";
|
|
4
3
|
import path from "node:path";
|
|
5
4
|
import process from "node:process";
|
|
6
5
|
import os from "node:os";
|
|
7
6
|
import pty from "node-pty";
|
|
8
7
|
import { SessionLogger } from "./session-logger.js";
|
|
9
|
-
|
|
8
|
+
import { SessionLifecycleManager } from "./session-lifecycle.js";
|
|
9
|
+
import { ClaudePtyBridge } from "./claude-pty-bridge.js";
|
|
10
|
+
/** Check if the current process is running as root (UID 0). */
|
|
10
11
|
function isRunningAsRoot() {
|
|
11
12
|
return process.getuid?.() === 0 || process.geteuid?.() === 0;
|
|
12
13
|
}
|
|
14
|
+
export class SessionInputError extends Error {
|
|
15
|
+
code;
|
|
16
|
+
sessionId;
|
|
17
|
+
sessionStatus;
|
|
18
|
+
constructor(message, code, sessionId, sessionStatus) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.code = code;
|
|
21
|
+
this.sessionId = sessionId;
|
|
22
|
+
this.sessionStatus = sessionStatus;
|
|
23
|
+
this.name = "SessionInputError";
|
|
24
|
+
}
|
|
25
|
+
}
|
|
13
26
|
const PROMPT_PATTERNS = [
|
|
14
27
|
/(?:^|\b)(?:press\s+)?(?:y|yes)\s*(?:\/|\bor\b)\s*(?:n|no)(?:\b|$)/i,
|
|
15
28
|
/\[(?:y|yes)\s*\/\s*(?:n|no)\]/i,
|
|
@@ -38,9 +51,7 @@ const SELECTION_PROMPT_PATTERNS = [
|
|
|
38
51
|
];
|
|
39
52
|
const MAX_SESSIONS = 50;
|
|
40
53
|
const ARCHIVE_AFTER_MS = 1000 * 60 * 60 * 24;
|
|
41
|
-
const OUTPUT_WINDOW_SIZE = 4096;
|
|
42
54
|
const CONFIRM_WINDOW_SIZE = 800;
|
|
43
|
-
const OUTPUT_MAX_SIZE = 120000;
|
|
44
55
|
// Claude 会话 ID 格式:UUID v4
|
|
45
56
|
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
57
|
/** Append text to a windowed buffer, trimming from start if over max size. */
|
|
@@ -53,29 +64,86 @@ export class ProcessManager extends EventEmitter {
|
|
|
53
64
|
storage;
|
|
54
65
|
sessions = new Map();
|
|
55
66
|
logger;
|
|
67
|
+
lifecycleManager;
|
|
56
68
|
constructor(config, storage, configDir) {
|
|
57
69
|
super();
|
|
58
70
|
this.config = config;
|
|
59
71
|
this.storage = storage;
|
|
60
72
|
this.logger = new SessionLogger(configDir || path.join(process.env.HOME || process.cwd(), ".wand"));
|
|
73
|
+
// Initialize lifecycle manager
|
|
74
|
+
this.lifecycleManager = new SessionLifecycleManager({
|
|
75
|
+
onStateChange: (sessionId, oldState, newState) => {
|
|
76
|
+
this.emitEvent({ type: "status", sessionId, data: { oldState, newState } });
|
|
77
|
+
},
|
|
78
|
+
onIdle: (sessionId) => {
|
|
79
|
+
console.error(`[ProcessManager] Session ${sessionId} is now idle`);
|
|
80
|
+
},
|
|
81
|
+
onArchived: (sessionId, reason) => {
|
|
82
|
+
console.error(`[ProcessManager] Session ${sessionId} archived: ${reason}`);
|
|
83
|
+
},
|
|
84
|
+
});
|
|
61
85
|
for (const snapshot of this.storage.loadSessions()) {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
86
|
+
const isClaudeCmd = /^claude\b/.test(snapshot.command.trim());
|
|
87
|
+
// Sessions restored from storage have ptyProcess: null — the old server's PTY
|
|
88
|
+
// belongs to a dead process. Mark running sessions as exited so the UI
|
|
89
|
+
// reflects reality and users can start fresh sessions.
|
|
90
|
+
if (snapshot.status === "running") {
|
|
91
|
+
const updated = { ...snapshot, status: "exited", endedAt: new Date().toISOString() };
|
|
92
|
+
this.storage.saveSession(updated);
|
|
93
|
+
this.sessions.set(snapshot.id, {
|
|
94
|
+
...updated,
|
|
95
|
+
processId: null,
|
|
96
|
+
ptyProcess: null,
|
|
97
|
+
stopRequested: false,
|
|
98
|
+
confirmWindow: "",
|
|
99
|
+
ptyPermissionBlocked: false,
|
|
100
|
+
lastAutoConfirmAt: 0,
|
|
101
|
+
autoApprovePermissions: this.shouldAutoApprovePermissions(snapshot.command, snapshot.mode),
|
|
102
|
+
pendingEscalation: snapshot.pendingEscalation ?? null,
|
|
103
|
+
lastEscalationResult: snapshot.lastEscalationResult ?? null,
|
|
104
|
+
autonomyPolicy: snapshot.autonomyPolicy ?? this.defaultAutonomyPolicy(snapshot.mode),
|
|
105
|
+
approvalPolicy: snapshot.approvalPolicy ?? "ask-every-time",
|
|
106
|
+
allowedScopes: snapshot.allowedScopes ?? [],
|
|
107
|
+
rememberedEscalationScopes: new Set(),
|
|
108
|
+
rememberedEscalationTargets: new Set(),
|
|
109
|
+
storedOutput: snapshot.output,
|
|
110
|
+
messages: snapshot.messages ?? [],
|
|
111
|
+
childProcess: null,
|
|
112
|
+
ptyBridge: null,
|
|
113
|
+
currentTask: null,
|
|
114
|
+
taskDebounceTimer: null,
|
|
115
|
+
lastEmittedTask: null
|
|
116
|
+
});
|
|
117
|
+
this.lifecycleManager.register(snapshot.id, "idle");
|
|
118
|
+
console.error(`[ProcessManager] Restored session ${snapshot.id} marked as exited (PTY orphaned)`);
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
this.sessions.set(snapshot.id, {
|
|
122
|
+
...snapshot,
|
|
123
|
+
processId: null,
|
|
124
|
+
ptyProcess: null,
|
|
125
|
+
stopRequested: false,
|
|
126
|
+
confirmWindow: "",
|
|
127
|
+
ptyPermissionBlocked: false,
|
|
128
|
+
lastAutoConfirmAt: 0,
|
|
129
|
+
autoApprovePermissions: this.shouldAutoApprovePermissions(snapshot.command, snapshot.mode),
|
|
130
|
+
pendingEscalation: snapshot.pendingEscalation ?? null,
|
|
131
|
+
lastEscalationResult: snapshot.lastEscalationResult ?? null,
|
|
132
|
+
autonomyPolicy: snapshot.autonomyPolicy ?? this.defaultAutonomyPolicy(snapshot.mode),
|
|
133
|
+
approvalPolicy: snapshot.approvalPolicy ?? "ask-every-time",
|
|
134
|
+
allowedScopes: snapshot.allowedScopes ?? [],
|
|
135
|
+
rememberedEscalationScopes: new Set(),
|
|
136
|
+
rememberedEscalationTargets: new Set(),
|
|
137
|
+
storedOutput: snapshot.output,
|
|
138
|
+
messages: snapshot.messages ?? [],
|
|
139
|
+
childProcess: null,
|
|
140
|
+
ptyBridge: null,
|
|
141
|
+
currentTask: null,
|
|
142
|
+
taskDebounceTimer: null,
|
|
143
|
+
lastEmittedTask: null
|
|
144
|
+
});
|
|
145
|
+
this.lifecycleManager.register(snapshot.id, "archived");
|
|
146
|
+
}
|
|
79
147
|
}
|
|
80
148
|
this.archiveExpiredSessions();
|
|
81
149
|
}
|
|
@@ -116,11 +184,15 @@ export class ProcessManager extends EventEmitter {
|
|
|
116
184
|
// For full-access mode with claude, add permission flags
|
|
117
185
|
const processedCommand = this.processCommandForMode(command, mode);
|
|
118
186
|
const id = randomUUID();
|
|
187
|
+
const isClaudeCmd = this.isClaudeCommand(command);
|
|
119
188
|
const record = {
|
|
120
189
|
id,
|
|
121
190
|
command,
|
|
122
191
|
cwd: resolvedCwd,
|
|
123
192
|
mode,
|
|
193
|
+
autonomyPolicy: this.defaultAutonomyPolicy(mode),
|
|
194
|
+
approvalPolicy: "ask-every-time",
|
|
195
|
+
allowedScopes: [],
|
|
124
196
|
status: "running",
|
|
125
197
|
exitCode: null,
|
|
126
198
|
startedAt: new Date().toISOString(),
|
|
@@ -128,62 +200,105 @@ export class ProcessManager extends EventEmitter {
|
|
|
128
200
|
output: "",
|
|
129
201
|
archived: false,
|
|
130
202
|
archivedAt: null,
|
|
203
|
+
permissionBlocked: undefined,
|
|
204
|
+
pendingEscalation: null,
|
|
205
|
+
lastEscalationResult: null,
|
|
131
206
|
claudeSessionId: null,
|
|
132
207
|
processId: null,
|
|
133
208
|
ptyProcess: null,
|
|
134
209
|
stopRequested: false,
|
|
135
210
|
confirmWindow: "",
|
|
211
|
+
ptyPermissionBlocked: false,
|
|
136
212
|
lastAutoConfirmAt: 0,
|
|
137
|
-
|
|
213
|
+
autoApprovePermissions: this.shouldAutoApprovePermissions(command, mode),
|
|
214
|
+
rememberedEscalationScopes: new Set(),
|
|
215
|
+
rememberedEscalationTargets: new Set(),
|
|
138
216
|
storedOutput: "",
|
|
139
217
|
messages: [],
|
|
140
|
-
jsonChatBusy: false,
|
|
141
218
|
childProcess: null,
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
219
|
+
ptyBridge: null,
|
|
220
|
+
currentTask: null,
|
|
221
|
+
taskDebounceTimer: null,
|
|
222
|
+
lastEmittedTask: null
|
|
146
223
|
};
|
|
224
|
+
// Create PTY bridge for this session
|
|
225
|
+
record.ptyBridge = new ClaudePtyBridge({
|
|
226
|
+
sessionId: id,
|
|
227
|
+
isClaudeCommand: isClaudeCmd,
|
|
228
|
+
autoApprove: record.autoApprovePermissions,
|
|
229
|
+
approvalPolicy: record.approvalPolicy,
|
|
230
|
+
});
|
|
231
|
+
record.ptyBridge.on("event", (event) => {
|
|
232
|
+
this.handleBridgeEvent(record, event);
|
|
233
|
+
});
|
|
147
234
|
this.sessions.set(id, record);
|
|
148
235
|
this.persist(record);
|
|
149
236
|
this.cleanupOldSessions();
|
|
150
|
-
//
|
|
151
|
-
this.
|
|
152
|
-
//
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
237
|
+
// Register lifecycle
|
|
238
|
+
this.lifecycleManager.register(id, "initializing");
|
|
239
|
+
// All modes use PTY execution — JSON turns are only used for internal recovery
|
|
240
|
+
const shellArgs = this.buildShellArgs(processedCommand);
|
|
241
|
+
let child;
|
|
242
|
+
try {
|
|
243
|
+
child = pty.spawn(this.config.shell, shellArgs, {
|
|
244
|
+
cwd: resolvedCwd,
|
|
245
|
+
env: {
|
|
246
|
+
...process.env,
|
|
247
|
+
WAND_MODE: mode,
|
|
248
|
+
WAND_AUTO_CONFIRM: mode === "full-access" ? "1" : "0",
|
|
249
|
+
WAND_AUTO_EDIT: mode === "auto-edit" ? "1" : "0"
|
|
250
|
+
},
|
|
251
|
+
name: "xterm-color",
|
|
252
|
+
cols: 120,
|
|
253
|
+
rows: 36
|
|
254
|
+
});
|
|
162
255
|
}
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
this.
|
|
256
|
+
catch (err) {
|
|
257
|
+
console.error("[ProcessManager] pty.spawn threw", { sessionId: id, error: String(err) });
|
|
258
|
+
record.status = "failed";
|
|
259
|
+
record.exitCode = -1;
|
|
260
|
+
record.endedAt = new Date().toISOString();
|
|
261
|
+
record.ptyProcess = null;
|
|
262
|
+
this.lifecycleManager.archive(id, "Session spawn failed", "error");
|
|
263
|
+
this.persist(record);
|
|
170
264
|
return this.snapshot(record);
|
|
171
265
|
}
|
|
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
266
|
record.processId = child.pid;
|
|
186
267
|
record.ptyProcess = child;
|
|
268
|
+
record.status = "running";
|
|
269
|
+
this.lifecycleManager.setState(id, "running");
|
|
270
|
+
// Register exit handler AFTER ptyProcess is assigned — node-pty's EventEmitter
|
|
271
|
+
// fires 'exit' synchronously when the child has already exited (e.g. "command
|
|
272
|
+
// not found"). If we register first, onExit fires with ptyProcess still null and
|
|
273
|
+
// status never updates. By assigning first, onExit always sees a consistent state.
|
|
274
|
+
child.onExit(({ exitCode }) => {
|
|
275
|
+
const current = this.sessions.get(id);
|
|
276
|
+
if (!current)
|
|
277
|
+
return;
|
|
278
|
+
if (current.ptyBridge) {
|
|
279
|
+
current.ptyBridge.onExit(exitCode);
|
|
280
|
+
}
|
|
281
|
+
current.status = current.stopRequested ? "stopped" : exitCode === 0 ? "exited" : "failed";
|
|
282
|
+
current.exitCode = current.stopRequested ? null : exitCode;
|
|
283
|
+
current.endedAt = new Date().toISOString();
|
|
284
|
+
current.ptyProcess = null;
|
|
285
|
+
this.lifecycleManager.archive(id, `Session ${current.status}`, current.stopRequested ? "user" : "error");
|
|
286
|
+
this.persist(current);
|
|
287
|
+
this.emitEvent({ type: "ended", sessionId: id, data: this.snapshot(current) });
|
|
288
|
+
});
|
|
289
|
+
// Set PTY write function for bridge (for permission approval).
|
|
290
|
+
// Write directly to record.ptyProcess — the status guard in sendInput() already
|
|
291
|
+
// ensures no input is sent when the session is not running, so we just guard
|
|
292
|
+
// the PTY write itself against a null process.
|
|
293
|
+
if (record.ptyBridge) {
|
|
294
|
+
record.ptyBridge.setPtyWrite((input) => {
|
|
295
|
+
if (record.ptyProcess) {
|
|
296
|
+
record.ptyProcess.write(input);
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
// Emit started event AFTER PTY is fully set up so clients receive a consistent snapshot.
|
|
301
|
+
this.emitEvent({ type: "started", sessionId: id, data: this.snapshot(record) });
|
|
187
302
|
let initialInputSent = false;
|
|
188
303
|
const sendInitialInput = () => {
|
|
189
304
|
if (initialInputSent || !initialInput)
|
|
@@ -195,93 +310,40 @@ export class ProcessManager extends EventEmitter {
|
|
|
195
310
|
return;
|
|
196
311
|
}
|
|
197
312
|
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;
|
|
313
|
+
// Track initial input via bridge for Chat mode
|
|
314
|
+
if (current.ptyBridge) {
|
|
315
|
+
current.ptyBridge.onUserInput(initialInput);
|
|
213
316
|
}
|
|
214
317
|
current.ptyProcess.write(initialInput);
|
|
215
318
|
// \n advances to a new line so subsequent output doesn't overwrite this input
|
|
216
319
|
current.ptyProcess.write("\n");
|
|
217
320
|
};
|
|
218
|
-
// Debounce rapid PTY output events to reduce WebSocket flooding
|
|
219
|
-
let outputDebounceTimer = null;
|
|
220
|
-
let pendingChunk = "";
|
|
221
321
|
child.onData((chunk) => {
|
|
222
322
|
const rec = this.sessions.get(id);
|
|
223
323
|
if (!rec)
|
|
224
324
|
return;
|
|
225
|
-
|
|
325
|
+
// Route chunk through PTY bridge
|
|
326
|
+
if (rec.ptyBridge) {
|
|
327
|
+
rec.ptyBridge.processChunk(chunk);
|
|
328
|
+
}
|
|
329
|
+
// Update legacy output field for backward compatibility
|
|
330
|
+
rec.output = rec.ptyBridge?.getRawOutput() ?? "";
|
|
226
331
|
// Log raw PTY output for analysis
|
|
227
332
|
this.logger.appendPtyOutput(id, chunk);
|
|
228
|
-
//
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
if (mode === "full-access") {
|
|
333
|
+
// Update Claude session ID from bridge
|
|
334
|
+
const bridgeSessionId = rec.ptyBridge?.getClaudeSessionId();
|
|
335
|
+
if (bridgeSessionId && bridgeSessionId !== rec.claudeSessionId) {
|
|
336
|
+
rec.claudeSessionId = bridgeSessionId;
|
|
337
|
+
process.stderr.write(`[wand] Captured Claude session ID: ${bridgeSessionId}\n`);
|
|
338
|
+
}
|
|
339
|
+
// Auto-confirm for full-access mode
|
|
340
|
+
if (rec.autoApprovePermissions) {
|
|
239
341
|
this.autoConfirmWithRecord(rec, chunk, child);
|
|
240
342
|
}
|
|
241
343
|
if (initialInput && !initialInputSent && chunk.includes("❯")) {
|
|
242
344
|
sendInitialInput();
|
|
243
345
|
}
|
|
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) });
|
|
346
|
+
this.persist(rec);
|
|
285
347
|
});
|
|
286
348
|
if (initialInput) {
|
|
287
349
|
setTimeout(() => {
|
|
@@ -311,638 +373,74 @@ export class ProcessManager extends EventEmitter {
|
|
|
311
373
|
}
|
|
312
374
|
sendInput(id, input, view) {
|
|
313
375
|
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 }]
|
|
376
|
+
if (record.status !== "running") {
|
|
377
|
+
console.error("[ProcessManager] Rejecting input for non-running session", {
|
|
378
|
+
sessionId: id,
|
|
379
|
+
status: record.status,
|
|
380
|
+
hasPty: !!record.ptyProcess,
|
|
381
|
+
inputLength: input.length,
|
|
382
|
+
view: view ?? "chat"
|
|
340
383
|
});
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
384
|
+
throw new SessionInputError("Session is not running.", "SESSION_NOT_RUNNING", id, record.status);
|
|
385
|
+
}
|
|
386
|
+
// Update lifecycle
|
|
387
|
+
this.lifecycleManager.touch(id);
|
|
388
|
+
this.lifecycleManager.startThinking(id);
|
|
389
|
+
if (!record.ptyProcess) {
|
|
390
|
+
console.error("[ProcessManager] Rejecting input because PTY is missing", {
|
|
391
|
+
sessionId: id,
|
|
392
|
+
status: record.status,
|
|
393
|
+
hasPty: !!record.ptyProcess,
|
|
394
|
+
inputLength: input.length,
|
|
395
|
+
view: view ?? "chat"
|
|
345
396
|
});
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
397
|
+
throw new SessionInputError("Session is not running.", "SESSION_NO_PTY", id, record.status);
|
|
398
|
+
}
|
|
399
|
+
console.error("[ProcessManager] Sending input to session", {
|
|
400
|
+
sessionId: id,
|
|
401
|
+
status: record.status,
|
|
402
|
+
hasPty: !!record.ptyProcess,
|
|
403
|
+
inputLength: input.length,
|
|
404
|
+
view: view ?? "chat"
|
|
405
|
+
});
|
|
406
|
+
// Track user input via bridge for Chat mode
|
|
407
|
+
if (record.ptyBridge) {
|
|
408
|
+
record.ptyBridge.onUserInput(input);
|
|
350
409
|
}
|
|
351
410
|
// Ensure input advances to a new line so subsequent PTY output doesn't overwrite it
|
|
352
411
|
record.ptyProcess.write(input);
|
|
353
|
-
if (!input.endsWith("\n")) {
|
|
412
|
+
if (view !== "terminal" && !input.endsWith("\n")) {
|
|
354
413
|
record.ptyProcess.write("\n");
|
|
355
414
|
}
|
|
356
415
|
this.persist(record);
|
|
357
416
|
return this.snapshot(record);
|
|
358
417
|
}
|
|
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;
|
|
707
|
-
}
|
|
708
|
-
}
|
|
709
|
-
buildContentBlocks(blocks) {
|
|
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
|
|
797
|
-
return;
|
|
798
|
-
}
|
|
799
|
-
// Phase 2: Check if assistant is done (❯ prompt reappeared)
|
|
800
|
-
const lines = clean.split("\n");
|
|
801
|
-
for (let i = lines.length - 1; i >= 0; i--) {
|
|
802
|
-
const trimmed = lines[i].trim();
|
|
803
|
-
if (trimmed.startsWith("❯")) {
|
|
804
|
-
const afterPrompt = trimmed.slice(1).trim();
|
|
805
|
-
// Standalone ❯ or ❯ with prompt suggestions = assistant done
|
|
806
|
-
if (!afterPrompt || afterPrompt.startsWith("Try")) {
|
|
807
|
-
this.finalizePtyAssistantMessage(record);
|
|
808
|
-
return;
|
|
809
|
-
}
|
|
810
|
-
break;
|
|
811
|
-
}
|
|
418
|
+
/** Emit a task event for a session, debounced to avoid flooding */
|
|
419
|
+
emitTask(record, task) {
|
|
420
|
+
// Clear existing debounce timer
|
|
421
|
+
if (record.taskDebounceTimer) {
|
|
422
|
+
clearTimeout(record.taskDebounceTimer);
|
|
423
|
+
record.taskDebounceTimer = null;
|
|
812
424
|
}
|
|
813
|
-
//
|
|
814
|
-
|
|
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")
|
|
425
|
+
// Don't re-emit the same task
|
|
426
|
+
if (task && task.title === record.lastEmittedTask)
|
|
820
427
|
return;
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
const lastMsg = record.messages[record.messages.length - 1];
|
|
829
|
-
if (!lastMsg || lastMsg.role !== "assistant")
|
|
428
|
+
if (task === null) {
|
|
429
|
+
// Clear task after a delay — allows a brief display of "idle" state
|
|
430
|
+
record.taskDebounceTimer = setTimeout(() => {
|
|
431
|
+
record.currentTask = null;
|
|
432
|
+
record.lastEmittedTask = null;
|
|
433
|
+
this.emitEvent({ type: "task", sessionId: record.id, data: null });
|
|
434
|
+
}, 2000);
|
|
830
435
|
return;
|
|
831
|
-
const text = this.cleanPtyOutputForChat(record.ptyAssistantBuffer);
|
|
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
436
|
}
|
|
839
|
-
|
|
840
|
-
record.
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
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();
|
|
437
|
+
// Debounce task changes by 100ms to avoid flickering on rapid tool switches
|
|
438
|
+
record.taskDebounceTimer = setTimeout(() => {
|
|
439
|
+
record.taskDebounceTimer = null;
|
|
440
|
+
record.currentTask = task;
|
|
441
|
+
record.lastEmittedTask = task.title;
|
|
442
|
+
this.emitEvent({ type: "task", sessionId: record.id, data: task });
|
|
443
|
+
}, 100);
|
|
946
444
|
}
|
|
947
445
|
resize(id, cols, rows) {
|
|
948
446
|
const record = this.mustGet(id);
|
|
@@ -959,14 +457,19 @@ Begin now:`;
|
|
|
959
457
|
if (record.status !== "running") {
|
|
960
458
|
return this.snapshot(record);
|
|
961
459
|
}
|
|
460
|
+
// Clear any pending task debounce timer
|
|
461
|
+
if (record.taskDebounceTimer) {
|
|
462
|
+
clearTimeout(record.taskDebounceTimer);
|
|
463
|
+
record.taskDebounceTimer = null;
|
|
464
|
+
}
|
|
962
465
|
try {
|
|
963
466
|
record.stopRequested = true;
|
|
964
|
-
//
|
|
965
|
-
if (
|
|
467
|
+
// Kill any running child process (from JSON chat turns)
|
|
468
|
+
if (record.childProcess) {
|
|
966
469
|
record.childProcess.kill();
|
|
967
470
|
record.childProcess = null;
|
|
968
471
|
}
|
|
969
|
-
//
|
|
472
|
+
// Kill the PTY process
|
|
970
473
|
if (record.ptyProcess) {
|
|
971
474
|
record.ptyProcess.kill();
|
|
972
475
|
}
|
|
@@ -976,18 +479,33 @@ Begin now:`;
|
|
|
976
479
|
record.endedAt = new Date().toISOString();
|
|
977
480
|
record.output += "\n[wand] Failed to stop session cleanly.\n";
|
|
978
481
|
}
|
|
482
|
+
// Immediately update status and clear PTY references so the session no longer
|
|
483
|
+
// appears "running" and subsequent sendInput() calls are rejected cleanly.
|
|
484
|
+
// The async onExit handler will re-persist but will find stopRequested already true.
|
|
485
|
+
record.status = "stopped";
|
|
486
|
+
record.exitCode = null;
|
|
487
|
+
record.endedAt = new Date().toISOString();
|
|
488
|
+
record.ptyProcess = null;
|
|
489
|
+
record.ptyBridge = null;
|
|
490
|
+
// Update lifecycle
|
|
491
|
+
this.lifecycleManager.archive(id, "Session stopped by user", "user");
|
|
979
492
|
this.persist(record);
|
|
980
493
|
return this.snapshot(record);
|
|
981
494
|
}
|
|
982
495
|
delete(id) {
|
|
983
496
|
const record = this.mustGet(id);
|
|
497
|
+
// Always clear pending timers
|
|
498
|
+
if (record.taskDebounceTimer) {
|
|
499
|
+
clearTimeout(record.taskDebounceTimer);
|
|
500
|
+
record.taskDebounceTimer = null;
|
|
501
|
+
}
|
|
502
|
+
// Kill live processes if still running
|
|
984
503
|
if (record.status === "running") {
|
|
985
504
|
try {
|
|
986
505
|
record.stopRequested = true;
|
|
987
506
|
// For native mode, kill the child process
|
|
988
507
|
if ((record.mode === "native" || record.mode === "managed") && record.childProcess) {
|
|
989
508
|
record.childProcess.kill();
|
|
990
|
-
record.childProcess = null;
|
|
991
509
|
}
|
|
992
510
|
// For PTY mode, kill the pty process
|
|
993
511
|
if (record.ptyProcess) {
|
|
@@ -998,6 +516,10 @@ Begin now:`;
|
|
|
998
516
|
// Ignore and continue deleting persisted state.
|
|
999
517
|
}
|
|
1000
518
|
}
|
|
519
|
+
// Always clean up all state references, regardless of current status
|
|
520
|
+
record.childProcess = null;
|
|
521
|
+
record.ptyProcess = null;
|
|
522
|
+
record.ptyBridge = null;
|
|
1001
523
|
this.sessions.delete(id);
|
|
1002
524
|
this.storage.deleteSession(id);
|
|
1003
525
|
}
|
|
@@ -1005,11 +527,16 @@ Begin now:`;
|
|
|
1005
527
|
return this.config.startupCommands.map((command) => this.start(command, this.config.defaultCwd, this.config.defaultMode));
|
|
1006
528
|
}
|
|
1007
529
|
snapshot(record) {
|
|
530
|
+
// Get messages from bridge if available, otherwise use stored messages
|
|
531
|
+
const messages = record.ptyBridge?.getMessages() ?? record.messages;
|
|
1008
532
|
return {
|
|
1009
533
|
id: record.id,
|
|
1010
534
|
command: record.command,
|
|
1011
535
|
cwd: record.cwd,
|
|
1012
536
|
mode: record.mode,
|
|
537
|
+
autonomyPolicy: record.autonomyPolicy,
|
|
538
|
+
approvalPolicy: record.approvalPolicy,
|
|
539
|
+
allowedScopes: record.allowedScopes,
|
|
1013
540
|
status: record.status,
|
|
1014
541
|
exitCode: record.exitCode,
|
|
1015
542
|
startedAt: record.startedAt,
|
|
@@ -1017,15 +544,117 @@ Begin now:`;
|
|
|
1017
544
|
output: record.output,
|
|
1018
545
|
archived: record.archived,
|
|
1019
546
|
archivedAt: record.archivedAt,
|
|
1020
|
-
|
|
1021
|
-
|
|
547
|
+
permissionBlocked: this.isPermissionBlocked(record),
|
|
548
|
+
pendingEscalation: record.pendingEscalation || undefined,
|
|
549
|
+
lastEscalationResult: record.lastEscalationResult || undefined,
|
|
550
|
+
claudeSessionId: record.claudeSessionId || null,
|
|
551
|
+
messages: messages.length > 0 ? messages : undefined
|
|
1022
552
|
};
|
|
1023
553
|
}
|
|
554
|
+
isPermissionBlocked(record) {
|
|
555
|
+
return record.ptyBridge?.isPermissionBlocked() ?? record.pendingEscalation !== null;
|
|
556
|
+
}
|
|
557
|
+
defaultAutonomyPolicy(mode) {
|
|
558
|
+
if (mode === "agent" || mode === "agent-max" || mode === "managed" || mode === "native" || mode === "full-access") {
|
|
559
|
+
return "agent";
|
|
560
|
+
}
|
|
561
|
+
return "assist";
|
|
562
|
+
}
|
|
563
|
+
resolveEscalation(id, requestId, resolution) {
|
|
564
|
+
const record = this.mustGet(id);
|
|
565
|
+
const escalation = record.pendingEscalation;
|
|
566
|
+
if (!escalation || escalation.requestId !== requestId) {
|
|
567
|
+
throw new Error("Escalation request not found.");
|
|
568
|
+
}
|
|
569
|
+
const finalResolution = resolution ?? "approve_once";
|
|
570
|
+
record.lastEscalationResult = {
|
|
571
|
+
requestId,
|
|
572
|
+
resolution: finalResolution,
|
|
573
|
+
reason: escalation.reason
|
|
574
|
+
};
|
|
575
|
+
if (finalResolution === "deny") {
|
|
576
|
+
record.pendingEscalation = null;
|
|
577
|
+
if (record.ptyProcess && record.status === "running") {
|
|
578
|
+
record.ptyProcess.write("n\r");
|
|
579
|
+
}
|
|
580
|
+
record.ptyPermissionBlocked = false;
|
|
581
|
+
this.persist(record);
|
|
582
|
+
return this.snapshot(record);
|
|
583
|
+
}
|
|
584
|
+
if (finalResolution === "approve_turn") {
|
|
585
|
+
record.rememberedEscalationScopes.add(escalation.scope);
|
|
586
|
+
if (escalation.target) {
|
|
587
|
+
record.rememberedEscalationTargets.add(escalation.target);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
record.pendingEscalation = null;
|
|
591
|
+
record.ptyPermissionBlocked = false;
|
|
592
|
+
if (record.ptyProcess && record.status === "running") {
|
|
593
|
+
record.ptyProcess.write("\r");
|
|
594
|
+
}
|
|
595
|
+
this.persist(record);
|
|
596
|
+
return this.snapshot(record);
|
|
597
|
+
}
|
|
598
|
+
approvePermission(id) {
|
|
599
|
+
const record = this.mustGet(id);
|
|
600
|
+
// Use bridge for permission resolution
|
|
601
|
+
if (record.ptyBridge) {
|
|
602
|
+
record.ptyBridge.resolvePermission("approve_once");
|
|
603
|
+
}
|
|
604
|
+
else if (record.ptyProcess && record.status === "running") {
|
|
605
|
+
record.ptyProcess.write("\r");
|
|
606
|
+
}
|
|
607
|
+
record.ptyPermissionBlocked = false;
|
|
608
|
+
record.pendingEscalation = null;
|
|
609
|
+
this.persist(record);
|
|
610
|
+
return this.snapshot(record);
|
|
611
|
+
}
|
|
612
|
+
denyPermission(id) {
|
|
613
|
+
const record = this.mustGet(id);
|
|
614
|
+
// Use bridge for permission resolution
|
|
615
|
+
if (record.ptyBridge) {
|
|
616
|
+
record.ptyBridge.resolvePermission("deny");
|
|
617
|
+
}
|
|
618
|
+
else if (record.ptyProcess && record.status === "running") {
|
|
619
|
+
record.ptyProcess.write("n\r");
|
|
620
|
+
}
|
|
621
|
+
record.ptyPermissionBlocked = false;
|
|
622
|
+
record.pendingEscalation = null;
|
|
623
|
+
this.persist(record);
|
|
624
|
+
return this.snapshot(record);
|
|
625
|
+
}
|
|
626
|
+
/**
|
|
627
|
+
* Resolve permission with specific resolution type.
|
|
628
|
+
* @param resolution - "approve_once", "approve_turn", or "deny"
|
|
629
|
+
*/
|
|
630
|
+
resolvePermission(id, resolution) {
|
|
631
|
+
const record = this.mustGet(id);
|
|
632
|
+
if (record.ptyBridge) {
|
|
633
|
+
record.ptyBridge.resolvePermission(resolution);
|
|
634
|
+
}
|
|
635
|
+
else if (record.ptyProcess && record.status === "running") {
|
|
636
|
+
if (resolution === "deny") {
|
|
637
|
+
record.ptyProcess.write("n\r");
|
|
638
|
+
}
|
|
639
|
+
else {
|
|
640
|
+
record.ptyProcess.write("\r");
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
record.ptyPermissionBlocked = false;
|
|
644
|
+
record.pendingEscalation = null;
|
|
645
|
+
this.persist(record);
|
|
646
|
+
return this.snapshot(record);
|
|
647
|
+
}
|
|
1024
648
|
persist(record) {
|
|
649
|
+
// Update messages from bridge before persisting
|
|
650
|
+
const messages = record.ptyBridge?.getMessages() ?? record.messages;
|
|
651
|
+
if (messages !== record.messages) {
|
|
652
|
+
record.messages = messages;
|
|
653
|
+
}
|
|
1025
654
|
this.storage.saveSession(this.snapshot(record));
|
|
1026
655
|
// Save structured messages to file for analysis
|
|
1027
|
-
if (
|
|
1028
|
-
this.logger.saveMessages(record.id,
|
|
656
|
+
if (messages.length > 0) {
|
|
657
|
+
this.logger.saveMessages(record.id, messages);
|
|
1029
658
|
}
|
|
1030
659
|
}
|
|
1031
660
|
archiveExpiredSessions() {
|
|
@@ -1054,6 +683,9 @@ Begin now:`;
|
|
|
1054
683
|
}
|
|
1055
684
|
}
|
|
1056
685
|
autoConfirmWithRecord(record, output, ptyProcess) {
|
|
686
|
+
if (!record.autoApprovePermissions) {
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
1057
689
|
record.confirmWindow = appendWindow(record.confirmWindow, output, CONFIRM_WINDOW_SIZE);
|
|
1058
690
|
const normalized = normalizePromptText(record.confirmWindow);
|
|
1059
691
|
const now = Date.now();
|
|
@@ -1073,21 +705,101 @@ Begin now:`;
|
|
|
1073
705
|
const shouldConfirm = trustFolderPrompt || claudeConfirmPrompt || toolPermissionPrompt || PROMPT_PATTERNS.some((pattern) => pattern.test(normalized));
|
|
1074
706
|
if (shouldConfirm) {
|
|
1075
707
|
record.lastAutoConfirmAt = now;
|
|
1076
|
-
process.stderr.write(`[wand] Auto-confirming prompt
|
|
1077
|
-
//
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
708
|
+
process.stderr.write(`[wand] Auto-confirming prompt for ${record.mode} mode\n`);
|
|
709
|
+
// Always auto-confirm by sending Enter directly
|
|
710
|
+
ptyProcess.write("\r");
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
/**
|
|
714
|
+
* Handle events from ClaudePtyBridge
|
|
715
|
+
*/
|
|
716
|
+
handleBridgeEvent(record, event) {
|
|
717
|
+
switch (event.type) {
|
|
718
|
+
case "output.raw":
|
|
719
|
+
// Emit output event for terminal view
|
|
720
|
+
this.emitEvent({
|
|
721
|
+
type: "output",
|
|
722
|
+
sessionId: event.sessionId,
|
|
723
|
+
data: {
|
|
724
|
+
chunk: event.data.chunk,
|
|
725
|
+
output: record.output,
|
|
726
|
+
messages: record.ptyBridge?.getMessages(),
|
|
727
|
+
permissionBlocked: this.isPermissionBlocked(record),
|
|
728
|
+
},
|
|
729
|
+
});
|
|
730
|
+
break;
|
|
731
|
+
case "output.chat":
|
|
732
|
+
// Emit output event with updated messages for chat view
|
|
733
|
+
this.emitEvent({
|
|
734
|
+
type: "output",
|
|
735
|
+
sessionId: event.sessionId,
|
|
736
|
+
data: {
|
|
737
|
+
output: record.output,
|
|
738
|
+
messages: record.ptyBridge?.getMessages(),
|
|
739
|
+
permissionBlocked: this.isPermissionBlocked(record),
|
|
740
|
+
},
|
|
741
|
+
});
|
|
742
|
+
break;
|
|
743
|
+
case "permission.prompt": {
|
|
744
|
+
const data = event.data;
|
|
745
|
+
record.pendingEscalation = {
|
|
746
|
+
requestId: `bridge-${Date.now()}`,
|
|
747
|
+
scope: data.scope,
|
|
748
|
+
runner: "pty",
|
|
749
|
+
source: "tool_permission_request",
|
|
750
|
+
target: data.target,
|
|
751
|
+
reason: data.prompt,
|
|
752
|
+
};
|
|
753
|
+
record.ptyPermissionBlocked = true;
|
|
754
|
+
// Emit status event with full permission details for UI
|
|
755
|
+
this.emitEvent({
|
|
756
|
+
type: "status",
|
|
757
|
+
sessionId: event.sessionId,
|
|
758
|
+
data: {
|
|
759
|
+
permissionBlocked: true,
|
|
760
|
+
permissionRequest: {
|
|
761
|
+
scope: data.scope,
|
|
762
|
+
target: data.target,
|
|
763
|
+
prompt: data.prompt,
|
|
764
|
+
},
|
|
765
|
+
},
|
|
766
|
+
});
|
|
767
|
+
break;
|
|
1084
768
|
}
|
|
769
|
+
case "permission.resolved":
|
|
770
|
+
record.pendingEscalation = null;
|
|
771
|
+
record.ptyPermissionBlocked = false;
|
|
772
|
+
this.emitEvent({
|
|
773
|
+
type: "status",
|
|
774
|
+
sessionId: event.sessionId,
|
|
775
|
+
data: { permissionBlocked: false },
|
|
776
|
+
});
|
|
777
|
+
break;
|
|
778
|
+
case "session.id":
|
|
779
|
+
// Claude session ID captured - already handled in onData
|
|
780
|
+
break;
|
|
781
|
+
case "chat.turn":
|
|
782
|
+
// Turn completed - persist messages
|
|
783
|
+
record.messages = record.ptyBridge?.getMessages() ?? record.messages;
|
|
784
|
+
this.lifecycleManager.stopThinking(record.id);
|
|
785
|
+
this.lifecycleManager.waitingInput(record.id);
|
|
786
|
+
this.persist(record);
|
|
787
|
+
break;
|
|
788
|
+
case "ended":
|
|
789
|
+
// Session ended - handled in onExit
|
|
790
|
+
break;
|
|
1085
791
|
}
|
|
1086
792
|
}
|
|
793
|
+
/** Check if a command is a Claude CLI command */
|
|
794
|
+
isClaudeCommand(command) {
|
|
795
|
+
const trimmed = command.trim();
|
|
796
|
+
return /^claude\b/.test(trimmed);
|
|
797
|
+
}
|
|
1087
798
|
mustGet(id) {
|
|
1088
799
|
const record = this.sessions.get(id);
|
|
1089
800
|
if (!record) {
|
|
1090
|
-
|
|
801
|
+
console.error("[ProcessManager] Session lookup failed", { sessionId: id });
|
|
802
|
+
throw new SessionInputError("Session not found.", "SESSION_NOT_FOUND", id);
|
|
1091
803
|
}
|
|
1092
804
|
return record;
|
|
1093
805
|
}
|
|
@@ -1095,20 +807,34 @@ Begin now:`;
|
|
|
1095
807
|
if (os.platform() === "win32") {
|
|
1096
808
|
return ["/d", "/s", "/c", command];
|
|
1097
809
|
}
|
|
1098
|
-
|
|
810
|
+
// -l: login shell — sources ~/.bash_profile, ~/.profile, etc., ensuring PATH
|
|
811
|
+
// and other env vars set by profile files are available.
|
|
812
|
+
// -c: run the following command.
|
|
813
|
+
// Using -ic (interactive + command) skips login-shell initialization on many
|
|
814
|
+
// platforms, which causes commands that depend on profile-set env vars to fail
|
|
815
|
+
// immediately with "command not found" — a silent exit before onExit is ready.
|
|
816
|
+
return ["-lc", command];
|
|
1099
817
|
}
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
// Check if permission-mode is already specified
|
|
1104
|
-
if (!/--permission-mode\b/.test(command)) {
|
|
1105
|
-
// Add --permission-mode bypassPermissions for full-access mode
|
|
1106
|
-
if (command === "claude") {
|
|
1107
|
-
return "claude --permission-mode bypassPermissions";
|
|
1108
|
-
}
|
|
1109
|
-
return command.replace(/^claude\s/, "claude --permission-mode bypassPermissions ");
|
|
1110
|
-
}
|
|
818
|
+
shouldAutoApprovePermissions(command, mode) {
|
|
819
|
+
if (!/^claude(?:\s|$)/.test(command)) {
|
|
820
|
+
return false;
|
|
1111
821
|
}
|
|
822
|
+
// Root mode: always auto-approve (Claude CLI refuses --permission-mode bypassPermissions under root)
|
|
823
|
+
if (isRunningAsRoot()) {
|
|
824
|
+
return true;
|
|
825
|
+
}
|
|
826
|
+
if (mode === "full-access" || mode === "auto-edit") {
|
|
827
|
+
return true;
|
|
828
|
+
}
|
|
829
|
+
if (mode === "managed" || mode === "native") {
|
|
830
|
+
return true;
|
|
831
|
+
}
|
|
832
|
+
return false;
|
|
833
|
+
}
|
|
834
|
+
processCommandForMode(command, _mode) {
|
|
835
|
+
// Don't automatically add --enable-auto-mode as it may not be available
|
|
836
|
+
// for all plans and can cause issues with normal interactive mode.
|
|
837
|
+
// Let users specify it explicitly if they want auto mode.
|
|
1112
838
|
return command;
|
|
1113
839
|
}
|
|
1114
840
|
}
|
|
@@ -1121,9 +847,6 @@ function normalizePromptText(value) {
|
|
|
1121
847
|
.replace(/\n+/g, "\n")
|
|
1122
848
|
.trim();
|
|
1123
849
|
}
|
|
1124
|
-
function normalizePtyOutput(value) {
|
|
1125
|
-
return value.replace(/\r\r\n/g, "\r\n");
|
|
1126
|
-
}
|
|
1127
850
|
function clampDimension(value, min, max) {
|
|
1128
851
|
if (!Number.isFinite(value)) {
|
|
1129
852
|
return min;
|