@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
|
@@ -0,0 +1,649 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ClaudePtyBridge - PTY output parsing and event bridge
|
|
3
|
+
*
|
|
4
|
+
* Transforms raw PTY output into structured events for WebSocket broadcast.
|
|
5
|
+
* Maintains two parallel output streams:
|
|
6
|
+
* 1. Raw output for terminal view (passthrough)
|
|
7
|
+
* 2. Structured messages for chat view (parsed)
|
|
8
|
+
*/
|
|
9
|
+
import { EventEmitter } from "node:events";
|
|
10
|
+
// ── Constants ──
|
|
11
|
+
const OUTPUT_MAX_SIZE = 120000;
|
|
12
|
+
const SESSION_ID_WINDOW_SIZE = 4096;
|
|
13
|
+
const PERMISSION_WINDOW_SIZE = 800;
|
|
14
|
+
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;
|
|
15
|
+
// Patterns for permission detection
|
|
16
|
+
const PERMISSION_PATTERNS = [
|
|
17
|
+
/(?:^|\b)(?:press\s+)?(?:y|yes)\s*(?:\/|\bor\b)\s*(?:n|no)(?:\b|$)/i,
|
|
18
|
+
/\[(?:y|yes)\s*\/\s*(?:n|no)\]/i,
|
|
19
|
+
/\((?:y|yes)\s*\/\s*(?:n|no)\)/i,
|
|
20
|
+
/\((?:y|yes)\s*\/\s*(?:n|no)\s*\/\s*always\)/i,
|
|
21
|
+
/\bcontinue\?\s*(?:\((?:y|yes)\s*\/\s*(?:n|no)\))?/i,
|
|
22
|
+
/\bare you sure\??/i,
|
|
23
|
+
/\bdo you want to continue\??/i,
|
|
24
|
+
/\bdo you want to (?:create|write|delete|modify|execute)/i,
|
|
25
|
+
/\bconfirm(?:\s+execution|\s+changes|\s+action)?\??/i,
|
|
26
|
+
/\bproceed\??/i,
|
|
27
|
+
/\benter to confirm\b/i,
|
|
28
|
+
/\bwould you like to\b/i,
|
|
29
|
+
/\bshall i\b/i,
|
|
30
|
+
/\bcan i\b/i,
|
|
31
|
+
/\bpermission\b/i,
|
|
32
|
+
/\bgrant\b.*\bpermission\b/i
|
|
33
|
+
];
|
|
34
|
+
// ── Helper Functions ──
|
|
35
|
+
/** Append text to a windowed buffer, trimming from start if over max size. */
|
|
36
|
+
function appendWindow(buffer, chunk, maxSize) {
|
|
37
|
+
const next = buffer + chunk;
|
|
38
|
+
return next.length > maxSize ? next.slice(-maxSize) : next;
|
|
39
|
+
}
|
|
40
|
+
/** Normalize prompt text for permission detection */
|
|
41
|
+
function normalizePromptText(value) {
|
|
42
|
+
return value
|
|
43
|
+
.replace(/\u001b\[(\d+)C/g, (_match, count) => " ".repeat(Number(count) || 1))
|
|
44
|
+
.replace(/\u001b\[[0-9;?]*[ -/]*[@-~]/g, "")
|
|
45
|
+
.replace(/\r/g, "\n")
|
|
46
|
+
.replace(/[ \t]+/g, " ")
|
|
47
|
+
.replace(/\n+/g, "\n")
|
|
48
|
+
.trim();
|
|
49
|
+
}
|
|
50
|
+
/** Normalize PTY output (fix line endings) */
|
|
51
|
+
function normalizePtyOutput(value) {
|
|
52
|
+
return value.replace(/\r\r\n/g, "\r\n");
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* ClaudePtyBridge transforms raw PTY output into structured events.
|
|
56
|
+
*
|
|
57
|
+
* Events emitted:
|
|
58
|
+
* - "output.raw" - Raw PTY output for terminal view
|
|
59
|
+
* - "output.chat" - Structured chat content update
|
|
60
|
+
* - "chat.turn" - Conversation turn completed
|
|
61
|
+
* - "permission.prompt" - Permission request detected
|
|
62
|
+
* - "permission.resolved" - Permission resolved
|
|
63
|
+
* - "session.id" - Claude session ID captured
|
|
64
|
+
* - "task" - Task info update
|
|
65
|
+
* - "ended" - Session ended
|
|
66
|
+
*/
|
|
67
|
+
export class ClaudePtyBridge extends EventEmitter {
|
|
68
|
+
sessionId;
|
|
69
|
+
// Output state
|
|
70
|
+
rawOutput;
|
|
71
|
+
messages;
|
|
72
|
+
// Chat parsing state
|
|
73
|
+
chatState;
|
|
74
|
+
// Permission detection state
|
|
75
|
+
permissionState;
|
|
76
|
+
// Session ID capture
|
|
77
|
+
sessionIdWindow;
|
|
78
|
+
claudeSessionId = null;
|
|
79
|
+
// Task tracking
|
|
80
|
+
currentTask = null;
|
|
81
|
+
taskDebounceTimer = null;
|
|
82
|
+
lastEmittedTask = null;
|
|
83
|
+
// Options
|
|
84
|
+
isClaudeCommand;
|
|
85
|
+
autoApprove;
|
|
86
|
+
approvalPolicy;
|
|
87
|
+
ptyWrite;
|
|
88
|
+
/** Set to true once onExit() has been called; guards against post-exit method calls */
|
|
89
|
+
_exited = false;
|
|
90
|
+
// Permission memory for "approve_turn" policy
|
|
91
|
+
rememberedScopes = new Set();
|
|
92
|
+
rememberedTargets = new Set();
|
|
93
|
+
constructor(options) {
|
|
94
|
+
super();
|
|
95
|
+
this.sessionId = options.sessionId;
|
|
96
|
+
this.messages = options.initialMessages ?? [];
|
|
97
|
+
this.rawOutput = options.initialOutput ?? "";
|
|
98
|
+
this.isClaudeCommand = options.isClaudeCommand ?? false;
|
|
99
|
+
this.autoApprove = options.autoApprove ?? false;
|
|
100
|
+
this.approvalPolicy = options.approvalPolicy ?? "ask-every-time";
|
|
101
|
+
this.ptyWrite = options.ptyWrite ?? null;
|
|
102
|
+
this.chatState = {
|
|
103
|
+
phase: "idle",
|
|
104
|
+
buffer: "",
|
|
105
|
+
lastUserInput: "",
|
|
106
|
+
echoSkipped: false,
|
|
107
|
+
assistantIndex: null,
|
|
108
|
+
};
|
|
109
|
+
this.permissionState = {
|
|
110
|
+
window: "",
|
|
111
|
+
isBlocked: false,
|
|
112
|
+
lastPrompt: null,
|
|
113
|
+
lastScope: null,
|
|
114
|
+
lastTarget: null,
|
|
115
|
+
lastAutoConfirmAt: 0,
|
|
116
|
+
};
|
|
117
|
+
this.sessionIdWindow = "";
|
|
118
|
+
}
|
|
119
|
+
// ── Core API ──
|
|
120
|
+
/**
|
|
121
|
+
* Process a raw PTY chunk.
|
|
122
|
+
* Emits events via EventEmitter.
|
|
123
|
+
*/
|
|
124
|
+
processChunk(chunk) {
|
|
125
|
+
// Guard against post-exit calls (e.g., late PTY drain data after onExit)
|
|
126
|
+
if (this._exited)
|
|
127
|
+
return;
|
|
128
|
+
// 1. Append to raw output
|
|
129
|
+
this.rawOutput = appendWindow(this.rawOutput, normalizePtyOutput(chunk), OUTPUT_MAX_SIZE);
|
|
130
|
+
// 2. Emit raw output event (for terminal view)
|
|
131
|
+
this.emitEvent({
|
|
132
|
+
type: "output.raw",
|
|
133
|
+
sessionId: this.sessionId,
|
|
134
|
+
timestamp: Date.now(),
|
|
135
|
+
data: { chunk, output: this.rawOutput },
|
|
136
|
+
});
|
|
137
|
+
// 3. Session ID capture
|
|
138
|
+
this.captureSessionId(chunk);
|
|
139
|
+
// 4. Permission detection
|
|
140
|
+
this.detectPermission(chunk);
|
|
141
|
+
// 5. Chat parsing (if responding)
|
|
142
|
+
if (this.chatState.phase === "responding") {
|
|
143
|
+
this.chatState.buffer += chunk;
|
|
144
|
+
this.parseChatResponse();
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Called when user sends input.
|
|
149
|
+
* Starts tracking a new assistant response.
|
|
150
|
+
*/
|
|
151
|
+
onUserInput(input) {
|
|
152
|
+
// Guard against post-exit calls
|
|
153
|
+
if (this._exited)
|
|
154
|
+
return;
|
|
155
|
+
// Filter out non-chat input (control chars, etc.)
|
|
156
|
+
if (!this.isRealChatInput(input)) {
|
|
157
|
+
process.stderr.write(`[Bridge] Input filtered as non-chat: ${input.substring(0, 50)}\n`);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
const cleanInput = input.replace(/[\r\n]+$/, "").trim();
|
|
161
|
+
process.stderr.write(`[Bridge] Starting chat tracking for: ${cleanInput.substring(0, 50)}\n`);
|
|
162
|
+
// Add user message
|
|
163
|
+
this.messages.push({
|
|
164
|
+
role: "user",
|
|
165
|
+
content: [{ type: "text", text: cleanInput }],
|
|
166
|
+
});
|
|
167
|
+
// Add assistant placeholder for streaming
|
|
168
|
+
this.messages.push({
|
|
169
|
+
role: "assistant",
|
|
170
|
+
content: [],
|
|
171
|
+
});
|
|
172
|
+
// Initialize chat state
|
|
173
|
+
this.chatState = {
|
|
174
|
+
phase: "responding",
|
|
175
|
+
buffer: "",
|
|
176
|
+
lastUserInput: cleanInput,
|
|
177
|
+
echoSkipped: false,
|
|
178
|
+
assistantIndex: this.messages.length - 1,
|
|
179
|
+
};
|
|
180
|
+
// Emit chat state update
|
|
181
|
+
this.emitEvent({
|
|
182
|
+
type: "output.chat",
|
|
183
|
+
sessionId: this.sessionId,
|
|
184
|
+
timestamp: Date.now(),
|
|
185
|
+
data: {
|
|
186
|
+
messages: this.messages,
|
|
187
|
+
streamingIndex: this.chatState.assistantIndex,
|
|
188
|
+
isResponding: true,
|
|
189
|
+
},
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Called when PTY process exits.
|
|
194
|
+
* Finalizes any pending response.
|
|
195
|
+
*/
|
|
196
|
+
onExit(exitCode) {
|
|
197
|
+
// Mark as exited FIRST — prevents any concurrent or subsequent method calls
|
|
198
|
+
this._exited = true;
|
|
199
|
+
if (this.chatState.phase === "responding") {
|
|
200
|
+
this.chatState.echoSkipped = true; // Force skip echo on exit
|
|
201
|
+
this.finalizeResponse();
|
|
202
|
+
}
|
|
203
|
+
// Clear task debounce timer
|
|
204
|
+
if (this.taskDebounceTimer) {
|
|
205
|
+
clearTimeout(this.taskDebounceTimer);
|
|
206
|
+
this.taskDebounceTimer = null;
|
|
207
|
+
}
|
|
208
|
+
// Clear permission state — prevents stale blocked state after exit
|
|
209
|
+
this.permissionState.isBlocked = false;
|
|
210
|
+
this.permissionState.lastPrompt = null;
|
|
211
|
+
this.permissionState.lastScope = null;
|
|
212
|
+
this.permissionState.lastTarget = null;
|
|
213
|
+
this.emitEvent({
|
|
214
|
+
type: "ended",
|
|
215
|
+
sessionId: this.sessionId,
|
|
216
|
+
timestamp: Date.now(),
|
|
217
|
+
data: { exitCode },
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
// ── State Accessors ──
|
|
221
|
+
getMessages() {
|
|
222
|
+
return this.messages;
|
|
223
|
+
}
|
|
224
|
+
getRawOutput() {
|
|
225
|
+
return this.rawOutput;
|
|
226
|
+
}
|
|
227
|
+
getClaudeSessionId() {
|
|
228
|
+
return this.claudeSessionId;
|
|
229
|
+
}
|
|
230
|
+
isPermissionBlocked() {
|
|
231
|
+
return this.permissionState.isBlocked;
|
|
232
|
+
}
|
|
233
|
+
getPermissionState() {
|
|
234
|
+
return this.permissionState;
|
|
235
|
+
}
|
|
236
|
+
getCurrentTask() {
|
|
237
|
+
return this.currentTask;
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Set the PTY write function for sending approval input.
|
|
241
|
+
*/
|
|
242
|
+
setPtyWrite(fn) {
|
|
243
|
+
this.ptyWrite = fn;
|
|
244
|
+
}
|
|
245
|
+
// ── Permission Resolution ──
|
|
246
|
+
/**
|
|
247
|
+
* Resolve the current permission prompt.
|
|
248
|
+
* @param resolution - How to resolve the permission
|
|
249
|
+
*/
|
|
250
|
+
resolvePermission(resolution) {
|
|
251
|
+
// Guard against post-exit calls — ptyWrite may be stale after exit
|
|
252
|
+
if (this._exited)
|
|
253
|
+
return;
|
|
254
|
+
if (!this.permissionState.isBlocked)
|
|
255
|
+
return;
|
|
256
|
+
// Handle "approve_turn" - remember this scope/target for the rest of the turn
|
|
257
|
+
if (resolution === "approve_turn") {
|
|
258
|
+
if (this.permissionState.lastScope) {
|
|
259
|
+
this.rememberedScopes.add(this.permissionState.lastScope);
|
|
260
|
+
}
|
|
261
|
+
if (this.permissionState.lastTarget) {
|
|
262
|
+
this.rememberedTargets.add(this.permissionState.lastTarget);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
// Send approval/denial to PTY
|
|
266
|
+
if (this.ptyWrite) {
|
|
267
|
+
if (resolution === "deny") {
|
|
268
|
+
this.ptyWrite("n\r");
|
|
269
|
+
}
|
|
270
|
+
else {
|
|
271
|
+
this.ptyWrite("\r");
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
// Clear state
|
|
275
|
+
this.permissionState.isBlocked = false;
|
|
276
|
+
this.permissionState.lastPrompt = null;
|
|
277
|
+
this.permissionState.lastScope = null;
|
|
278
|
+
this.permissionState.lastTarget = null;
|
|
279
|
+
this.emitEvent({
|
|
280
|
+
type: "permission.resolved",
|
|
281
|
+
sessionId: this.sessionId,
|
|
282
|
+
timestamp: Date.now(),
|
|
283
|
+
data: { resolution },
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Check if a permission scope/target should be auto-approved based on remembered decisions.
|
|
288
|
+
*/
|
|
289
|
+
shouldAutoApprove(scope, target) {
|
|
290
|
+
if (this.rememberedScopes.has(scope))
|
|
291
|
+
return true;
|
|
292
|
+
if (target && this.rememberedTargets.has(target))
|
|
293
|
+
return true;
|
|
294
|
+
return false;
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Clear remembered permissions (call at the start of a new turn).
|
|
298
|
+
*/
|
|
299
|
+
clearRememberedPermissions() {
|
|
300
|
+
this.rememberedScopes.clear();
|
|
301
|
+
this.rememberedTargets.clear();
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* Clear permission blocked state (called when permission is resolved externally).
|
|
305
|
+
*/
|
|
306
|
+
clearPermissionBlocked() {
|
|
307
|
+
this.permissionState.isBlocked = false;
|
|
308
|
+
this.permissionState.lastPrompt = null;
|
|
309
|
+
this.permissionState.lastScope = null;
|
|
310
|
+
this.permissionState.lastTarget = null;
|
|
311
|
+
}
|
|
312
|
+
// ── Private Implementation ──
|
|
313
|
+
emitEvent(event) {
|
|
314
|
+
this.emit("event", event);
|
|
315
|
+
}
|
|
316
|
+
isRealChatInput(input) {
|
|
317
|
+
const trimmed = input.replace(/[\r\n]+$/, "").trim();
|
|
318
|
+
// Empty or whitespace-only
|
|
319
|
+
if (!trimmed)
|
|
320
|
+
return false;
|
|
321
|
+
// Single control character (Ctrl+C, Ctrl+D, etc.)
|
|
322
|
+
if (trimmed.length === 1 && trimmed.charCodeAt(0) < 32)
|
|
323
|
+
return false;
|
|
324
|
+
// ANSI escape sequences (arrow keys, etc.)
|
|
325
|
+
if (trimmed.startsWith("\x1b"))
|
|
326
|
+
return false;
|
|
327
|
+
// Single "y" or "n" — likely auto-confirm response
|
|
328
|
+
if (/^[yn]$/i.test(trimmed))
|
|
329
|
+
return false;
|
|
330
|
+
// Just Enter/CR
|
|
331
|
+
if (trimmed === "\r" || trimmed === "\n")
|
|
332
|
+
return false;
|
|
333
|
+
return true;
|
|
334
|
+
}
|
|
335
|
+
captureSessionId(chunk) {
|
|
336
|
+
if (this.claudeSessionId)
|
|
337
|
+
return;
|
|
338
|
+
this.sessionIdWindow = appendWindow(this.sessionIdWindow, chunk, SESSION_ID_WINDOW_SIZE);
|
|
339
|
+
const match = CLAUDE_SESSION_ID_PATTERN.exec(this.sessionIdWindow);
|
|
340
|
+
if (match?.[1]) {
|
|
341
|
+
this.claudeSessionId = match[1];
|
|
342
|
+
this.emitEvent({
|
|
343
|
+
type: "session.id",
|
|
344
|
+
sessionId: this.sessionId,
|
|
345
|
+
timestamp: Date.now(),
|
|
346
|
+
data: { claudeSessionId: match[1] },
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
detectPermission(chunk) {
|
|
351
|
+
if (!this.isClaudeCommand)
|
|
352
|
+
return;
|
|
353
|
+
this.permissionState.window = appendWindow(this.permissionState.window, chunk, PERMISSION_WINDOW_SIZE);
|
|
354
|
+
const normalized = normalizePromptText(this.permissionState.window);
|
|
355
|
+
const blocked = this.isPermissionPromptDetected(normalized);
|
|
356
|
+
if (this.permissionState.isBlocked === blocked)
|
|
357
|
+
return;
|
|
358
|
+
this.permissionState.isBlocked = blocked;
|
|
359
|
+
if (blocked) {
|
|
360
|
+
const prompt = this.extractPromptText(normalized);
|
|
361
|
+
const target = this.extractPermissionTarget(normalized);
|
|
362
|
+
const scope = this.inferScope(normalized, target);
|
|
363
|
+
this.permissionState.lastPrompt = prompt;
|
|
364
|
+
this.permissionState.lastScope = scope;
|
|
365
|
+
this.permissionState.lastTarget = target ?? null;
|
|
366
|
+
// Check if we should auto-approve
|
|
367
|
+
const shouldAutoApprove = this.autoApprove || this.shouldAutoApprove(scope, target);
|
|
368
|
+
if (shouldAutoApprove) {
|
|
369
|
+
// Debounce auto-confirm to avoid rapid repeats
|
|
370
|
+
const now = Date.now();
|
|
371
|
+
if (now - this.permissionState.lastAutoConfirmAt < 500)
|
|
372
|
+
return;
|
|
373
|
+
this.permissionState.lastAutoConfirmAt = now;
|
|
374
|
+
process.stderr.write(`[wand] Auto-confirming permission for ${scope}${target ? `: ${target}` : ""}\n`);
|
|
375
|
+
// Send approval to PTY
|
|
376
|
+
if (this.ptyWrite) {
|
|
377
|
+
this.ptyWrite("\r");
|
|
378
|
+
}
|
|
379
|
+
// Clear blocked state immediately
|
|
380
|
+
this.permissionState.isBlocked = false;
|
|
381
|
+
this.permissionState.lastPrompt = null;
|
|
382
|
+
this.permissionState.lastScope = null;
|
|
383
|
+
this.permissionState.lastTarget = null;
|
|
384
|
+
this.emitEvent({
|
|
385
|
+
type: "permission.resolved",
|
|
386
|
+
sessionId: this.sessionId,
|
|
387
|
+
timestamp: Date.now(),
|
|
388
|
+
data: { resolution: "approve_once", autoApproved: true },
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
else {
|
|
392
|
+
// Emit permission prompt event for UI to handle
|
|
393
|
+
this.emitEvent({
|
|
394
|
+
type: "permission.prompt",
|
|
395
|
+
sessionId: this.sessionId,
|
|
396
|
+
timestamp: Date.now(),
|
|
397
|
+
data: { prompt, scope, target },
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
else {
|
|
402
|
+
this.permissionState.lastPrompt = null;
|
|
403
|
+
this.permissionState.lastScope = null;
|
|
404
|
+
this.permissionState.lastTarget = null;
|
|
405
|
+
this.emitEvent({
|
|
406
|
+
type: "permission.resolved",
|
|
407
|
+
sessionId: this.sessionId,
|
|
408
|
+
timestamp: Date.now(),
|
|
409
|
+
data: {},
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
isPermissionPromptDetected(normalized) {
|
|
414
|
+
return (/\bdo you want to\b/i.test(normalized) ||
|
|
415
|
+
/\bgrant\b.*\bpermission\b/i.test(normalized) ||
|
|
416
|
+
/\bhaven't granted\b/i.test(normalized) ||
|
|
417
|
+
/\bpermission\b/i.test(normalized) ||
|
|
418
|
+
/\benter to confirm\b/i.test(normalized));
|
|
419
|
+
}
|
|
420
|
+
extractPromptText(normalized) {
|
|
421
|
+
// Return a snippet around the permission prompt
|
|
422
|
+
const match = normalized.match(/.{0,100}(?:do you want to|permission|grant|enter to confirm).{0,100}/i);
|
|
423
|
+
return match?.[0] ?? normalized.slice(-100);
|
|
424
|
+
}
|
|
425
|
+
extractPermissionTarget(normalized) {
|
|
426
|
+
const match = normalized.match(/write to\s+([^,.\n]+)/i)
|
|
427
|
+
|| normalized.match(/modify\s+([^,.\n]+)/i)
|
|
428
|
+
|| normalized.match(/delete\s+([^,.\n]+)/i)
|
|
429
|
+
|| normalized.match(/execute\s+([^,.\n]+)/i);
|
|
430
|
+
return match?.[1]?.trim();
|
|
431
|
+
}
|
|
432
|
+
inferScope(normalized, target) {
|
|
433
|
+
const lower = normalized.toLowerCase();
|
|
434
|
+
if (lower.includes("write") || lower.includes("redirection") || lower.includes("output redirection")) {
|
|
435
|
+
return "write_file";
|
|
436
|
+
}
|
|
437
|
+
if (lower.includes("network") || lower.includes("web") || lower.includes("fetch") || lower.includes("url")) {
|
|
438
|
+
return "network";
|
|
439
|
+
}
|
|
440
|
+
if (lower.includes("command") || lower.includes("bash") || lower.includes("execute")) {
|
|
441
|
+
return "run_command";
|
|
442
|
+
}
|
|
443
|
+
return "unknown";
|
|
444
|
+
}
|
|
445
|
+
parseChatResponse() {
|
|
446
|
+
const clean = this.stripAnsi(this.chatState.buffer);
|
|
447
|
+
if (!this.chatState.echoSkipped) {
|
|
448
|
+
const echoEndIndex = this.findEchoEndIndex(clean, this.chatState.lastUserInput);
|
|
449
|
+
if (echoEndIndex <= 0) {
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
this.chatState.echoSkipped = true;
|
|
453
|
+
this.chatState.buffer = clean.slice(echoEndIndex);
|
|
454
|
+
process.stderr.write(`[Bridge] Echo skipped, remaining length: ${this.chatState.buffer.length}\n`);
|
|
455
|
+
}
|
|
456
|
+
if (this.detectCompletion(this.chatState.buffer)) {
|
|
457
|
+
process.stderr.write("[Bridge] Completion detected, finalizing\n");
|
|
458
|
+
this.finalizeResponse();
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
this.updateAssistantContent();
|
|
462
|
+
}
|
|
463
|
+
detectCompletion(clean) {
|
|
464
|
+
const lines = clean.split("\n").map((line) => line.trimEnd());
|
|
465
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
466
|
+
const trimmed = lines[i].trim();
|
|
467
|
+
if (!trimmed) {
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
if (this.isStatusLine(trimmed)) {
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
if (trimmed.startsWith("❯")) {
|
|
474
|
+
const afterPrompt = trimmed.slice(1).trim();
|
|
475
|
+
return afterPrompt.length === 0 || afterPrompt.startsWith("Try");
|
|
476
|
+
}
|
|
477
|
+
return false;
|
|
478
|
+
}
|
|
479
|
+
return false;
|
|
480
|
+
}
|
|
481
|
+
updateAssistantContent() {
|
|
482
|
+
const idx = this.chatState.assistantIndex;
|
|
483
|
+
if (idx === null)
|
|
484
|
+
return;
|
|
485
|
+
const text = this.cleanForChat(this.chatState.buffer);
|
|
486
|
+
if (text) {
|
|
487
|
+
this.messages[idx].content = [{ type: "text", text }];
|
|
488
|
+
}
|
|
489
|
+
this.emitEvent({
|
|
490
|
+
type: "output.chat",
|
|
491
|
+
sessionId: this.sessionId,
|
|
492
|
+
timestamp: Date.now(),
|
|
493
|
+
data: {
|
|
494
|
+
messages: this.messages,
|
|
495
|
+
streamingIndex: idx,
|
|
496
|
+
isResponding: true,
|
|
497
|
+
},
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
finalizeResponse() {
|
|
501
|
+
const idx = this.chatState.assistantIndex;
|
|
502
|
+
if (idx !== null) {
|
|
503
|
+
const text = this.cleanForChat(this.chatState.buffer);
|
|
504
|
+
if (text) {
|
|
505
|
+
this.messages[idx].content = [{ type: "text", text }];
|
|
506
|
+
}
|
|
507
|
+
else if (this.messages[idx].content.length === 0) {
|
|
508
|
+
this.messages.splice(idx, 1);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
// Emit turn completed
|
|
512
|
+
const lastTurn = this.messages[this.messages.length - 1];
|
|
513
|
+
if (lastTurn?.role === "assistant" && lastTurn.content.length > 0) {
|
|
514
|
+
this.emitEvent({
|
|
515
|
+
type: "chat.turn",
|
|
516
|
+
sessionId: this.sessionId,
|
|
517
|
+
timestamp: Date.now(),
|
|
518
|
+
data: { turn: lastTurn, messages: this.messages },
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
// Reset state
|
|
522
|
+
this.chatState = {
|
|
523
|
+
phase: "idle",
|
|
524
|
+
buffer: "",
|
|
525
|
+
lastUserInput: "",
|
|
526
|
+
echoSkipped: false,
|
|
527
|
+
assistantIndex: null,
|
|
528
|
+
};
|
|
529
|
+
// Emit idle state
|
|
530
|
+
this.emitEvent({
|
|
531
|
+
type: "output.chat",
|
|
532
|
+
sessionId: this.sessionId,
|
|
533
|
+
timestamp: Date.now(),
|
|
534
|
+
data: { messages: this.messages, isResponding: false },
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
// ── Text Processing Utilities ──
|
|
538
|
+
stripAnsi(text) {
|
|
539
|
+
// eslint-disable-next-line no-control-regex
|
|
540
|
+
return text
|
|
541
|
+
.replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, "") // CSI sequences
|
|
542
|
+
.replace(/\x1b\][^\x07]*(\x07|\x1b\\)/g, "") // OSC sequences
|
|
543
|
+
.replace(/\x1b[><=ePX^_]/g, "") // Single-char escapes
|
|
544
|
+
// eslint-disable-next-line no-control-regex
|
|
545
|
+
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, "") // Control chars (keep \t \n \r)
|
|
546
|
+
.replace(/\r\n?/g, "\n");
|
|
547
|
+
}
|
|
548
|
+
/**
|
|
549
|
+
* Find the end index of the echoed user input in the PTY buffer.
|
|
550
|
+
* The echo may contain ANSI codes between characters.
|
|
551
|
+
* Returns the index after the last character of the echo.
|
|
552
|
+
*/
|
|
553
|
+
findEchoEndIndex(buffer, userInput) {
|
|
554
|
+
// Keep alphanumeric and common symbols for matching
|
|
555
|
+
const inputChars = userInput.replace(/[^a-zA-Z0-9+=?!\-]/g, "");
|
|
556
|
+
if (inputChars.length === 0)
|
|
557
|
+
return 0;
|
|
558
|
+
let matchedChars = 0;
|
|
559
|
+
let endIndex = 0;
|
|
560
|
+
for (let i = 0; i < buffer.length && matchedChars < inputChars.length; i++) {
|
|
561
|
+
const ch = buffer[i];
|
|
562
|
+
// Check if this printable char matches the next expected char
|
|
563
|
+
if (/[a-zA-Z0-9+=?!\-]/.test(ch) && ch.toLowerCase() === inputChars[matchedChars].toLowerCase()) {
|
|
564
|
+
matchedChars++;
|
|
565
|
+
endIndex = i + 1;
|
|
566
|
+
}
|
|
567
|
+
// Skip ANSI codes and other non-matching characters
|
|
568
|
+
}
|
|
569
|
+
// Look for a newline or prompt marker after the echo
|
|
570
|
+
for (let i = endIndex; i < buffer.length && i < endIndex + 50; i++) {
|
|
571
|
+
if (buffer[i] === "\n" || buffer[i] === "\r") {
|
|
572
|
+
endIndex = i + 1;
|
|
573
|
+
break;
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
return matchedChars === inputChars.length ? endIndex : 0;
|
|
577
|
+
}
|
|
578
|
+
isStatusLine(line) {
|
|
579
|
+
if (!line)
|
|
580
|
+
return false;
|
|
581
|
+
if (line.startsWith("────"))
|
|
582
|
+
return true;
|
|
583
|
+
if (line === "❯")
|
|
584
|
+
return true;
|
|
585
|
+
if (line.includes("esc to interrupt"))
|
|
586
|
+
return true;
|
|
587
|
+
if (line.includes("Claude Code v"))
|
|
588
|
+
return true;
|
|
589
|
+
if (line.includes("Failed to install Anthropic"))
|
|
590
|
+
return true;
|
|
591
|
+
if (line.includes("Claude Code has switched"))
|
|
592
|
+
return true;
|
|
593
|
+
if (line.includes("[wand]"))
|
|
594
|
+
return true;
|
|
595
|
+
if (line.includes("Captured Claude session ID"))
|
|
596
|
+
return true;
|
|
597
|
+
if (line.includes("ctrl+g"))
|
|
598
|
+
return true;
|
|
599
|
+
if (line.includes("/effort"))
|
|
600
|
+
return true;
|
|
601
|
+
if (line.includes("? for shortcuts"))
|
|
602
|
+
return true;
|
|
603
|
+
if (line.includes("auto mode is unavailable"))
|
|
604
|
+
return true;
|
|
605
|
+
if (/MCP server.*failed/i.test(line))
|
|
606
|
+
return true;
|
|
607
|
+
if (line.includes("Germinating") || line.includes("Doodling") || line.includes("Brewing"))
|
|
608
|
+
return true;
|
|
609
|
+
if (line.includes("Permissions") && line.includes("mode"))
|
|
610
|
+
return true;
|
|
611
|
+
if (line.startsWith("●") && line.includes("·"))
|
|
612
|
+
return true;
|
|
613
|
+
if (line.startsWith("[>") || line.startsWith("[<"))
|
|
614
|
+
return true;
|
|
615
|
+
return false;
|
|
616
|
+
}
|
|
617
|
+
cleanForChat(raw) {
|
|
618
|
+
const text = this.stripAnsi(raw);
|
|
619
|
+
const lines = text.split("\n");
|
|
620
|
+
const cleanLines = [];
|
|
621
|
+
for (const rawLine of lines) {
|
|
622
|
+
const trimmed = rawLine.trim();
|
|
623
|
+
if (!trimmed) {
|
|
624
|
+
if (cleanLines.length > 0 && cleanLines[cleanLines.length - 1] !== "") {
|
|
625
|
+
cleanLines.push("");
|
|
626
|
+
}
|
|
627
|
+
continue;
|
|
628
|
+
}
|
|
629
|
+
if (trimmed === this.chatState.lastUserInput.trim()) {
|
|
630
|
+
continue;
|
|
631
|
+
}
|
|
632
|
+
if (this.isStatusLine(trimmed)) {
|
|
633
|
+
continue;
|
|
634
|
+
}
|
|
635
|
+
if (trimmed.startsWith("❯")) {
|
|
636
|
+
continue;
|
|
637
|
+
}
|
|
638
|
+
let normalized = trimmed;
|
|
639
|
+
if (normalized.startsWith("●") || normalized.startsWith("⏺")) {
|
|
640
|
+
normalized = normalized.slice(1).trimStart();
|
|
641
|
+
}
|
|
642
|
+
cleanLines.push(normalized);
|
|
643
|
+
}
|
|
644
|
+
while (cleanLines.length > 0 && cleanLines[cleanLines.length - 1] === "") {
|
|
645
|
+
cleanLines.pop();
|
|
646
|
+
}
|
|
647
|
+
return cleanLines.join("\n");
|
|
648
|
+
}
|
|
649
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { ContentBlock } from "./types.js";
|
|
2
|
+
export interface ClaudeStreamAdapterState {
|
|
3
|
+
blocks: ContentBlock[];
|
|
4
|
+
usage?: {
|
|
5
|
+
inputTokens?: number;
|
|
6
|
+
outputTokens?: number;
|
|
7
|
+
cacheReadInputTokens?: number;
|
|
8
|
+
cacheCreationInputTokens?: number;
|
|
9
|
+
totalCostUsd?: number;
|
|
10
|
+
};
|
|
11
|
+
sessionId: string | null;
|
|
12
|
+
}
|
|
13
|
+
type ClaudeStreamEvent = {
|
|
14
|
+
type?: string;
|
|
15
|
+
session_id?: string;
|
|
16
|
+
message?: {
|
|
17
|
+
role?: string;
|
|
18
|
+
content?: unknown[];
|
|
19
|
+
};
|
|
20
|
+
content_block?: {
|
|
21
|
+
type?: string;
|
|
22
|
+
[key: string]: unknown;
|
|
23
|
+
};
|
|
24
|
+
delta?: {
|
|
25
|
+
type?: string;
|
|
26
|
+
text?: string;
|
|
27
|
+
thinking?: string;
|
|
28
|
+
partial_json?: string;
|
|
29
|
+
};
|
|
30
|
+
result?: unknown;
|
|
31
|
+
usage?: Record<string, unknown>;
|
|
32
|
+
total_cost_usd?: number;
|
|
33
|
+
};
|
|
34
|
+
export declare function updateClaudeStreamState(state: ClaudeStreamAdapterState, event: ClaudeStreamEvent): ClaudeStreamAdapterState;
|
|
35
|
+
export {};
|