@co0ontty/wand 1.3.3 → 1.3.6

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.
@@ -23,6 +23,16 @@ interface PermissionState {
23
23
  lastAutoConfirmAt: number;
24
24
  /** Timer for delayed auto-approve (gives CLI time to be ready) */
25
25
  pendingAutoApproveTimer: ReturnType<typeof setTimeout> | null;
26
+ /** Fallback auto-approve: verification deadline timestamp */
27
+ fallbackVerifyUntil: number;
28
+ /** Fallback auto-approve: context for logging */
29
+ fallbackContext: {
30
+ score: number;
31
+ matched: string[];
32
+ text: string;
33
+ } | null;
34
+ /** Output length snapshot taken right before fallback auto-approve fires */
35
+ fallbackOutputLenAtApprove: number;
26
36
  }
27
37
  /** Permission resolution result */
28
38
  export type PermissionResolution = "approve_once" | "approve_turn" | "deny";
@@ -73,6 +83,10 @@ export declare class ClaudePtyBridge extends EventEmitter {
73
83
  private _exited;
74
84
  private rememberedScopes;
75
85
  private rememberedTargets;
86
+ private lastOutputAt;
87
+ private lastUserInputAt;
88
+ private idleProbeTimer;
89
+ private lastIdleProbeAt;
76
90
  constructor(options: ClaudePtyBridgeOptions);
77
91
  /**
78
92
  * Process a raw PTY chunk.
@@ -125,7 +139,25 @@ export declare class ClaudePtyBridge extends EventEmitter {
125
139
  * selection prompt time to fully render and enter its input loop before we send \r.
126
140
  */
127
141
  private scheduleAutoApprove;
142
+ /**
143
+ * Schedule a fallback auto-approve with false-positive verification.
144
+ * Similar to scheduleAutoApprove but sets up post-approve monitoring.
145
+ */
146
+ private scheduleFallbackAutoApprove;
128
147
  private cancelPendingAutoApprove;
148
+ /**
149
+ * Reset the idle probe timer. Called on every new output chunk.
150
+ * If the terminal goes idle (no output for IDLE_PROBE_DELAY_MS) while
151
+ * auto-approve is enabled and no permission is currently detected,
152
+ * we send a speculative \r to catch prompts that all detection layers missed.
153
+ */
154
+ private resetIdleProbeTimer;
155
+ private clearIdleProbeTimer;
156
+ /**
157
+ * Idle probe: the terminal has been quiet for a while. If conditions suggest
158
+ * a stuck permission prompt, send a speculative \r and monitor the result.
159
+ */
160
+ private maybeIdleProbe;
129
161
  private isPermissionPromptDetected;
130
162
  private extractPromptText;
131
163
  private extractPermissionTarget;
@@ -7,12 +7,18 @@
7
7
  * 2. Structured messages for chat view (parsed)
8
8
  */
9
9
  import { EventEmitter } from "node:events";
10
- import { stripAnsi, isNoiseLine, appendWindow, normalizePromptText, hasExplicitConfirmSyntax, hasPermissionActionContext } from "./pty-text-utils.js";
10
+ import { stripAnsi, isNoiseLine, appendWindow, normalizePromptText, hasExplicitConfirmSyntax, hasPermissionActionContext, scorePermissionLikelihood, FALLBACK_SCORE_THRESHOLD } from "./pty-text-utils.js";
11
11
  // ── Constants ──
12
12
  const OUTPUT_MAX_SIZE = 120000;
13
13
  const SESSION_ID_WINDOW_SIZE = 16384;
14
14
  const PERMISSION_WINDOW_SIZE = 2000;
15
15
  const AUTO_APPROVE_DELAY_MS = 150;
16
+ /** How long to monitor output after fallback auto-approve for false-positive detection */
17
+ const FALLBACK_VERIFY_WINDOW_MS = 600;
18
+ /** How long a session must be idle (no user input, no new output) before sending a probe */
19
+ const IDLE_PROBE_DELAY_MS = 3000;
20
+ /** Minimum time between idle probes */
21
+ const IDLE_PROBE_COOLDOWN_MS = 5000;
16
22
  const UUID_PATTERN = "([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})";
17
23
  const CLAUDE_SESSION_ID_PATTERNS = [
18
24
  new RegExp(`"session_id"\\s*:\\s*"${UUID_PATTERN}"`, "i"),
@@ -63,6 +69,11 @@ export class ClaudePtyBridge extends EventEmitter {
63
69
  // Permission memory for "approve_turn" policy
64
70
  rememberedScopes = new Set();
65
71
  rememberedTargets = new Set();
72
+ // Idle probe state (last-resort robustness)
73
+ lastOutputAt;
74
+ lastUserInputAt;
75
+ idleProbeTimer;
76
+ lastIdleProbeAt;
66
77
  constructor(options) {
67
78
  super();
68
79
  this.sessionId = options.sessionId;
@@ -87,8 +98,15 @@ export class ClaudePtyBridge extends EventEmitter {
87
98
  lastTarget: null,
88
99
  lastAutoConfirmAt: 0,
89
100
  pendingAutoApproveTimer: null,
101
+ fallbackVerifyUntil: 0,
102
+ fallbackContext: null,
103
+ fallbackOutputLenAtApprove: 0,
90
104
  };
91
105
  this.sessionIdWindow = "";
106
+ this.lastOutputAt = 0;
107
+ this.lastUserInputAt = 0;
108
+ this.idleProbeTimer = null;
109
+ this.lastIdleProbeAt = 0;
92
110
  }
93
111
  // ── Core API ──
94
112
  /**
@@ -101,6 +119,7 @@ export class ClaudePtyBridge extends EventEmitter {
101
119
  return;
102
120
  // 1. Append to raw output
103
121
  this.rawOutput = appendWindow(this.rawOutput, normalizePtyOutput(chunk), OUTPUT_MAX_SIZE);
122
+ this.lastOutputAt = Date.now();
104
123
  // 2. Emit raw output event (for terminal view)
105
124
  this.emitEvent({
106
125
  type: "output.raw",
@@ -117,6 +136,10 @@ export class ClaudePtyBridge extends EventEmitter {
117
136
  this.chatState.buffer += chunk;
118
137
  this.parseChatResponse();
119
138
  }
139
+ // 6. Reset idle probe timer on new output
140
+ if (this.autoApprove && this.isClaudeCommand) {
141
+ this.resetIdleProbeTimer();
142
+ }
120
143
  }
121
144
  /**
122
145
  * Called when user sends input.
@@ -126,13 +149,12 @@ export class ClaudePtyBridge extends EventEmitter {
126
149
  // Guard against post-exit calls
127
150
  if (this._exited)
128
151
  return;
152
+ this.lastUserInputAt = Date.now();
129
153
  // Filter out non-chat input (control chars, etc.)
130
154
  if (!this.isRealChatInput(input)) {
131
- process.stderr.write(`[Bridge] Input filtered as non-chat: ${input.substring(0, 50)}\n`);
132
155
  return;
133
156
  }
134
157
  const cleanInput = input.replace(/[\r\n]+$/, "").trim();
135
- process.stderr.write(`[Bridge] Starting chat tracking for: ${cleanInput.substring(0, 50)}\n`);
136
158
  // Add user message
137
159
  this.messages.push({
138
160
  role: "user",
@@ -181,6 +203,7 @@ export class ClaudePtyBridge extends EventEmitter {
181
203
  }
182
204
  // Clear permission state — prevents stale blocked state after exit
183
205
  this.cancelPendingAutoApprove();
206
+ this.clearIdleProbeTimer();
184
207
  this.permissionState.isBlocked = false;
185
208
  this.permissionState.lastPrompt = null;
186
209
  this.permissionState.lastScope = null;
@@ -334,7 +357,75 @@ export class ClaudePtyBridge extends EventEmitter {
334
357
  return;
335
358
  this.permissionState.window = appendWindow(this.permissionState.window, chunk, PERMISSION_WINDOW_SIZE);
336
359
  const normalized = normalizePromptText(this.permissionState.window);
360
+ // ── Fallback false-positive detection ──
361
+ // After a fallback auto-approve, monitor output for signs it was wrong
362
+ if (this.permissionState.fallbackContext) {
363
+ const now = Date.now();
364
+ if (now < this.permissionState.fallbackVerifyUntil) {
365
+ // Check if the output grew with a prompt echo (false positive: \r was consumed as input)
366
+ const outputGrew = this.rawOutput.length > this.permissionState.fallbackOutputLenAtApprove;
367
+ if (outputGrew) {
368
+ // Check if the new output looks like Claude echoed our empty input (prompt re-appeared)
369
+ const newOutput = this.rawOutput.slice(this.permissionState.fallbackOutputLenAtApprove);
370
+ const stripped = stripAnsi(newOutput).trim();
371
+ // If we see a bare prompt symbol and no permission keywords → likely false positive
372
+ const looksLikeFalsePositive = /^❯\s*$/.test(stripped)
373
+ || (stripped.includes("❯") && !this.isPermissionPromptDetected(normalized));
374
+ if (looksLikeFalsePositive) {
375
+ const ctx = this.permissionState.fallbackContext;
376
+ this.emitEvent({
377
+ type: "permission.resolved",
378
+ sessionId: this.sessionId,
379
+ timestamp: now,
380
+ data: {
381
+ resolution: "approve_once",
382
+ autoApproved: true,
383
+ approveType: "fallback",
384
+ falsePositive: true,
385
+ score: ctx.score,
386
+ matched: ctx.matched,
387
+ tail: ctx.text,
388
+ },
389
+ });
390
+ this.permissionState.fallbackContext = null;
391
+ return;
392
+ }
393
+ }
394
+ }
395
+ else {
396
+ // Verification window expired — assume it was correct
397
+ const ctx = this.permissionState.fallbackContext;
398
+ this.emitEvent({
399
+ type: "permission.resolved",
400
+ sessionId: this.sessionId,
401
+ timestamp: now,
402
+ data: {
403
+ resolution: "approve_once",
404
+ autoApproved: true,
405
+ approveType: "fallback",
406
+ falsePositive: false,
407
+ score: ctx.score,
408
+ matched: ctx.matched,
409
+ tail: ctx.text,
410
+ },
411
+ });
412
+ this.permissionState.fallbackContext = null;
413
+ }
414
+ }
337
415
  const blocked = this.isPermissionPromptDetected(normalized);
416
+ // ── Fallback: weighted keyword scoring ──
417
+ // If strict detection missed but auto-approve is on, try scoring
418
+ if (!blocked && !this.permissionState.isBlocked && this.autoApprove) {
419
+ const { score, matched } = scorePermissionLikelihood(normalized);
420
+ if (score >= FALLBACK_SCORE_THRESHOLD) {
421
+ const target = this.extractPermissionTarget(normalized);
422
+ const scope = this.inferScope(normalized, target);
423
+ const lines = normalized.split("\n");
424
+ const tailText = lines.slice(-8).join("\n");
425
+ this.scheduleFallbackAutoApprove(scope, target, { score, matched, text: tailText });
426
+ return;
427
+ }
428
+ }
338
429
  // If state hasn't changed, check if a new distinct prompt appeared while already blocked
339
430
  if (this.permissionState.isBlocked === blocked) {
340
431
  if (blocked) {
@@ -409,12 +500,10 @@ export class ClaudePtyBridge extends EventEmitter {
409
500
  if (this.permissionState.pendingAutoApproveTimer)
410
501
  return;
411
502
  this.permissionState.lastAutoConfirmAt = now;
412
- process.stderr.write(`[wand] Scheduling auto-confirm for ${scope}${target ? `: ${target}` : ""} (${AUTO_APPROVE_DELAY_MS}ms)\n`);
413
503
  this.permissionState.pendingAutoApproveTimer = setTimeout(() => {
414
504
  this.permissionState.pendingAutoApproveTimer = null;
415
505
  if (this._exited)
416
506
  return;
417
- process.stderr.write(`[wand] Auto-confirming permission for ${scope}${target ? `: ${target}` : ""}\n`);
418
507
  if (this.ptyWrite) {
419
508
  this.ptyWrite("\r");
420
509
  }
@@ -427,16 +516,107 @@ export class ClaudePtyBridge extends EventEmitter {
427
516
  type: "permission.resolved",
428
517
  sessionId: this.sessionId,
429
518
  timestamp: Date.now(),
430
- data: { resolution: "approve_once", autoApproved: true },
519
+ data: { resolution: "approve_once", autoApproved: true, approveType: "strict" },
431
520
  });
432
521
  }, AUTO_APPROVE_DELAY_MS);
433
522
  }
523
+ /**
524
+ * Schedule a fallback auto-approve with false-positive verification.
525
+ * Similar to scheduleAutoApprove but sets up post-approve monitoring.
526
+ */
527
+ scheduleFallbackAutoApprove(scope, target, context) {
528
+ const now = Date.now();
529
+ if (now - this.permissionState.lastAutoConfirmAt < 500)
530
+ return;
531
+ if (this.permissionState.pendingAutoApproveTimer)
532
+ return;
533
+ this.permissionState.lastAutoConfirmAt = now;
534
+ this.permissionState.pendingAutoApproveTimer = setTimeout(() => {
535
+ this.permissionState.pendingAutoApproveTimer = null;
536
+ if (this._exited)
537
+ return;
538
+ // Snapshot output length before sending \r for false-positive detection
539
+ this.permissionState.fallbackOutputLenAtApprove = this.rawOutput.length;
540
+ this.permissionState.fallbackVerifyUntil = Date.now() + FALLBACK_VERIFY_WINDOW_MS;
541
+ this.permissionState.fallbackContext = context;
542
+ if (this.ptyWrite) {
543
+ this.ptyWrite("\r");
544
+ }
545
+ // Don't clear isBlocked/window here — let the verification logic in detectPermission handle it
546
+ this.permissionState.isBlocked = false;
547
+ this.permissionState.window = "";
548
+ this.permissionState.lastPrompt = null;
549
+ this.permissionState.lastScope = null;
550
+ this.permissionState.lastTarget = null;
551
+ }, AUTO_APPROVE_DELAY_MS);
552
+ }
434
553
  cancelPendingAutoApprove() {
435
554
  if (this.permissionState.pendingAutoApproveTimer) {
436
555
  clearTimeout(this.permissionState.pendingAutoApproveTimer);
437
556
  this.permissionState.pendingAutoApproveTimer = null;
438
557
  }
439
558
  }
559
+ // ── Idle Probe (last-resort robustness) ──
560
+ /**
561
+ * Reset the idle probe timer. Called on every new output chunk.
562
+ * If the terminal goes idle (no output for IDLE_PROBE_DELAY_MS) while
563
+ * auto-approve is enabled and no permission is currently detected,
564
+ * we send a speculative \r to catch prompts that all detection layers missed.
565
+ */
566
+ resetIdleProbeTimer() {
567
+ this.clearIdleProbeTimer();
568
+ this.idleProbeTimer = setTimeout(() => {
569
+ this.idleProbeTimer = null;
570
+ this.maybeIdleProbe();
571
+ }, IDLE_PROBE_DELAY_MS);
572
+ }
573
+ clearIdleProbeTimer() {
574
+ if (this.idleProbeTimer) {
575
+ clearTimeout(this.idleProbeTimer);
576
+ this.idleProbeTimer = null;
577
+ }
578
+ }
579
+ /**
580
+ * Idle probe: the terminal has been quiet for a while. If conditions suggest
581
+ * a stuck permission prompt, send a speculative \r and monitor the result.
582
+ */
583
+ maybeIdleProbe() {
584
+ if (this._exited || !this.autoApprove || !this.ptyWrite)
585
+ return;
586
+ // Don't probe if permission is already detected or fallback is pending verification
587
+ if (this.permissionState.isBlocked)
588
+ return;
589
+ if (this.permissionState.fallbackContext)
590
+ return;
591
+ if (this.permissionState.pendingAutoApproveTimer)
592
+ return;
593
+ const now = Date.now();
594
+ // Cooldown between probes
595
+ if (now - this.lastIdleProbeAt < IDLE_PROBE_COOLDOWN_MS)
596
+ return;
597
+ // Don't probe if user recently sent input (they're actively interacting)
598
+ if (now - this.lastUserInputAt < IDLE_PROBE_DELAY_MS)
599
+ return;
600
+ // Only probe if output stopped recently (not ancient idle sessions)
601
+ if (now - this.lastOutputAt > 15000)
602
+ return;
603
+ // Quick heuristic: check if recent output has ANY permission-like keywords
604
+ const normalized = normalizePromptText(this.permissionState.window);
605
+ const { score, matched } = scorePermissionLikelihood(normalized);
606
+ // Lower threshold than fallback — we're being speculative
607
+ if (score < 4)
608
+ return;
609
+ this.lastIdleProbeAt = now;
610
+ // Snapshot output before probe
611
+ this.permissionState.fallbackOutputLenAtApprove = this.rawOutput.length;
612
+ this.permissionState.fallbackVerifyUntil = now + FALLBACK_VERIFY_WINDOW_MS;
613
+ this.permissionState.fallbackContext = {
614
+ score,
615
+ matched,
616
+ text: normalized.split("\n").slice(-8).join("\n"),
617
+ };
618
+ this.ptyWrite("\r");
619
+ }
440
620
  isPermissionPromptDetected(normalized) {
441
621
  const hasIntent = /\bdo you want to\b/i.test(normalized)
442
622
  || /\bwould you like to\b/i.test(normalized)
@@ -495,10 +675,8 @@ export class ClaudePtyBridge extends EventEmitter {
495
675
  }
496
676
  this.chatState.echoSkipped = true;
497
677
  this.chatState.buffer = clean.slice(echoEndIndex);
498
- process.stderr.write(`[Bridge] Echo skipped, remaining length: ${this.chatState.buffer.length}\n`);
499
678
  }
500
679
  if (this.detectCompletion(this.chatState.buffer)) {
501
- process.stderr.write("[Bridge] Completion detected, finalizing\n");
502
680
  this.finalizeResponse();
503
681
  return;
504
682
  }