@co0ontty/wand 0.2.1 → 0.4.0

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