@co0ontty/wand 1.3.4 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/auth.js CHANGED
@@ -3,9 +3,10 @@ const sessions = new Map();
3
3
  const SESSION_TTL_MS = 1000 * 60 * 60 * 12;
4
4
  let storage = null;
5
5
  // Periodic cleanup every 10 minutes
6
- setInterval(() => {
6
+ const sessionCleanupTimer = setInterval(() => {
7
7
  cleanupExpiredSessions();
8
8
  }, 1000 * 60 * 10);
9
+ sessionCleanupTimer.unref();
9
10
  export function createSession() {
10
11
  const token = crypto.randomBytes(24).toString("hex");
11
12
  const expiresAt = Date.now() + SESSION_TTL_MS;
@@ -23,6 +23,18 @@ 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;
36
+ /** Consecutive auto-approve attempts for the same prompt without resolution */
37
+ retryCount: number;
26
38
  }
27
39
  /** Permission resolution result */
28
40
  export type PermissionResolution = "approve_once" | "approve_turn" | "deny";
@@ -73,6 +85,10 @@ export declare class ClaudePtyBridge extends EventEmitter {
73
85
  private _exited;
74
86
  private rememberedScopes;
75
87
  private rememberedTargets;
88
+ private lastOutputAt;
89
+ private lastUserInputAt;
90
+ private idleProbeTimer;
91
+ private lastIdleProbeAt;
76
92
  constructor(options: ClaudePtyBridgeOptions);
77
93
  /**
78
94
  * Process a raw PTY chunk.
@@ -99,6 +115,10 @@ export declare class ClaudePtyBridge extends EventEmitter {
99
115
  * Set the PTY write function for sending approval input.
100
116
  */
101
117
  setPtyWrite(fn: (input: string) => void): void;
118
+ /**
119
+ * Toggle auto-approve at runtime.
120
+ */
121
+ setAutoApprove(enabled: boolean): void;
102
122
  /**
103
123
  * Resolve the current permission prompt.
104
124
  * @param resolution - How to resolve the permission
@@ -125,7 +145,25 @@ export declare class ClaudePtyBridge extends EventEmitter {
125
145
  * selection prompt time to fully render and enter its input loop before we send \r.
126
146
  */
127
147
  private scheduleAutoApprove;
148
+ /**
149
+ * Schedule a fallback auto-approve with false-positive verification.
150
+ * Similar to scheduleAutoApprove but sets up post-approve monitoring.
151
+ */
152
+ private scheduleFallbackAutoApprove;
128
153
  private cancelPendingAutoApprove;
154
+ /**
155
+ * Reset the idle probe timer. Called on every new output chunk.
156
+ * If the terminal goes idle (no output for IDLE_PROBE_DELAY_MS) while
157
+ * auto-approve is enabled and no permission is currently detected,
158
+ * we send a speculative \r to catch prompts that all detection layers missed.
159
+ */
160
+ private resetIdleProbeTimer;
161
+ private clearIdleProbeTimer;
162
+ /**
163
+ * Idle probe: the terminal has been quiet for a while. If conditions suggest
164
+ * a stuck permission prompt, send a speculative \r and monitor the result.
165
+ */
166
+ private maybeIdleProbe;
129
167
  private isPermissionPromptDetected;
130
168
  private extractPromptText;
131
169
  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
- const AUTO_APPROVE_DELAY_MS = 150;
15
+ const AUTO_APPROVE_DELAY_MS = 350;
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,16 @@ 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,
104
+ retryCount: 0,
90
105
  };
91
106
  this.sessionIdWindow = "";
107
+ this.lastOutputAt = 0;
108
+ this.lastUserInputAt = 0;
109
+ this.idleProbeTimer = null;
110
+ this.lastIdleProbeAt = 0;
92
111
  }
93
112
  // ── Core API ──
94
113
  /**
@@ -101,6 +120,7 @@ export class ClaudePtyBridge extends EventEmitter {
101
120
  return;
102
121
  // 1. Append to raw output
103
122
  this.rawOutput = appendWindow(this.rawOutput, normalizePtyOutput(chunk), OUTPUT_MAX_SIZE);
123
+ this.lastOutputAt = Date.now();
104
124
  // 2. Emit raw output event (for terminal view)
105
125
  this.emitEvent({
106
126
  type: "output.raw",
@@ -117,6 +137,10 @@ export class ClaudePtyBridge extends EventEmitter {
117
137
  this.chatState.buffer += chunk;
118
138
  this.parseChatResponse();
119
139
  }
140
+ // 6. Reset idle probe timer on new output
141
+ if (this.autoApprove && this.isClaudeCommand) {
142
+ this.resetIdleProbeTimer();
143
+ }
120
144
  }
121
145
  /**
122
146
  * Called when user sends input.
@@ -126,13 +150,12 @@ export class ClaudePtyBridge extends EventEmitter {
126
150
  // Guard against post-exit calls
127
151
  if (this._exited)
128
152
  return;
153
+ this.lastUserInputAt = Date.now();
129
154
  // Filter out non-chat input (control chars, etc.)
130
155
  if (!this.isRealChatInput(input)) {
131
- process.stderr.write(`[Bridge] Input filtered as non-chat: ${input.substring(0, 50)}\n`);
132
156
  return;
133
157
  }
134
158
  const cleanInput = input.replace(/[\r\n]+$/, "").trim();
135
- process.stderr.write(`[Bridge] Starting chat tracking for: ${cleanInput.substring(0, 50)}\n`);
136
159
  // Add user message
137
160
  this.messages.push({
138
161
  role: "user",
@@ -181,6 +204,7 @@ export class ClaudePtyBridge extends EventEmitter {
181
204
  }
182
205
  // Clear permission state — prevents stale blocked state after exit
183
206
  this.cancelPendingAutoApprove();
207
+ this.clearIdleProbeTimer();
184
208
  this.permissionState.isBlocked = false;
185
209
  this.permissionState.lastPrompt = null;
186
210
  this.permissionState.lastScope = null;
@@ -217,6 +241,24 @@ export class ClaudePtyBridge extends EventEmitter {
217
241
  setPtyWrite(fn) {
218
242
  this.ptyWrite = fn;
219
243
  }
244
+ /**
245
+ * Toggle auto-approve at runtime.
246
+ */
247
+ setAutoApprove(enabled) {
248
+ this.autoApprove = enabled;
249
+ if (!enabled) {
250
+ // Cancel any pending auto-approve timer
251
+ if (this.permissionState.pendingAutoApproveTimer) {
252
+ clearTimeout(this.permissionState.pendingAutoApproveTimer);
253
+ this.permissionState.pendingAutoApproveTimer = null;
254
+ }
255
+ // Cancel idle probe
256
+ if (this.idleProbeTimer) {
257
+ clearTimeout(this.idleProbeTimer);
258
+ this.idleProbeTimer = null;
259
+ }
260
+ }
261
+ }
220
262
  // ── Permission Resolution ──
221
263
  /**
222
264
  * Resolve the current permission prompt.
@@ -334,7 +376,75 @@ export class ClaudePtyBridge extends EventEmitter {
334
376
  return;
335
377
  this.permissionState.window = appendWindow(this.permissionState.window, chunk, PERMISSION_WINDOW_SIZE);
336
378
  const normalized = normalizePromptText(this.permissionState.window);
379
+ // ── Fallback false-positive detection ──
380
+ // After a fallback auto-approve, monitor output for signs it was wrong
381
+ if (this.permissionState.fallbackContext) {
382
+ const now = Date.now();
383
+ if (now < this.permissionState.fallbackVerifyUntil) {
384
+ // Check if the output grew with a prompt echo (false positive: \r was consumed as input)
385
+ const outputGrew = this.rawOutput.length > this.permissionState.fallbackOutputLenAtApprove;
386
+ if (outputGrew) {
387
+ // Check if the new output looks like Claude echoed our empty input (prompt re-appeared)
388
+ const newOutput = this.rawOutput.slice(this.permissionState.fallbackOutputLenAtApprove);
389
+ const stripped = stripAnsi(newOutput).trim();
390
+ // If we see a bare prompt symbol and no permission keywords → likely false positive
391
+ const looksLikeFalsePositive = /^❯\s*$/.test(stripped)
392
+ || (stripped.includes("❯") && !this.isPermissionPromptDetected(normalized));
393
+ if (looksLikeFalsePositive) {
394
+ const ctx = this.permissionState.fallbackContext;
395
+ this.emitEvent({
396
+ type: "permission.resolved",
397
+ sessionId: this.sessionId,
398
+ timestamp: now,
399
+ data: {
400
+ resolution: "approve_once",
401
+ autoApproved: true,
402
+ approveType: "fallback",
403
+ falsePositive: true,
404
+ score: ctx.score,
405
+ matched: ctx.matched,
406
+ tail: ctx.text,
407
+ },
408
+ });
409
+ this.permissionState.fallbackContext = null;
410
+ return;
411
+ }
412
+ }
413
+ }
414
+ else {
415
+ // Verification window expired — assume it was correct
416
+ const ctx = this.permissionState.fallbackContext;
417
+ this.emitEvent({
418
+ type: "permission.resolved",
419
+ sessionId: this.sessionId,
420
+ timestamp: now,
421
+ data: {
422
+ resolution: "approve_once",
423
+ autoApproved: true,
424
+ approveType: "fallback",
425
+ falsePositive: false,
426
+ score: ctx.score,
427
+ matched: ctx.matched,
428
+ tail: ctx.text,
429
+ },
430
+ });
431
+ this.permissionState.fallbackContext = null;
432
+ }
433
+ }
337
434
  const blocked = this.isPermissionPromptDetected(normalized);
435
+ // ── Fallback: weighted keyword scoring ──
436
+ // If strict detection missed but auto-approve is on, try scoring
437
+ if (!blocked && !this.permissionState.isBlocked && this.autoApprove) {
438
+ const { score, matched } = scorePermissionLikelihood(normalized);
439
+ if (score >= FALLBACK_SCORE_THRESHOLD) {
440
+ const target = this.extractPermissionTarget(normalized);
441
+ const scope = this.inferScope(normalized, target);
442
+ const lines = normalized.split("\n");
443
+ const tailText = lines.slice(-8).join("\n");
444
+ this.scheduleFallbackAutoApprove(scope, target, { score, matched, text: tailText });
445
+ return;
446
+ }
447
+ }
338
448
  // If state hasn't changed, check if a new distinct prompt appeared while already blocked
339
449
  if (this.permissionState.isBlocked === blocked) {
340
450
  if (blocked) {
@@ -409,12 +519,10 @@ export class ClaudePtyBridge extends EventEmitter {
409
519
  if (this.permissionState.pendingAutoApproveTimer)
410
520
  return;
411
521
  this.permissionState.lastAutoConfirmAt = now;
412
- process.stderr.write(`[wand] Scheduling auto-confirm for ${scope}${target ? `: ${target}` : ""} (${AUTO_APPROVE_DELAY_MS}ms)\n`);
413
522
  this.permissionState.pendingAutoApproveTimer = setTimeout(() => {
414
523
  this.permissionState.pendingAutoApproveTimer = null;
415
524
  if (this._exited)
416
525
  return;
417
- process.stderr.write(`[wand] Auto-confirming permission for ${scope}${target ? `: ${target}` : ""}\n`);
418
526
  if (this.ptyWrite) {
419
527
  this.ptyWrite("\r");
420
528
  }
@@ -427,8 +535,56 @@ export class ClaudePtyBridge extends EventEmitter {
427
535
  type: "permission.resolved",
428
536
  sessionId: this.sessionId,
429
537
  timestamp: Date.now(),
430
- data: { resolution: "approve_once", autoApproved: true },
538
+ data: { resolution: "approve_once", autoApproved: true, approveType: "strict" },
431
539
  });
540
+ // Schedule a retry check: if the prompt re-appears shortly after,
541
+ // the \r may have arrived before the CLI was ready. Retry with a
542
+ // longer delay to handle slow-rendering selection menus.
543
+ if (this.permissionState.retryCount < 3) {
544
+ const retryDelay = 800 + this.permissionState.retryCount * 400;
545
+ setTimeout(() => {
546
+ if (this._exited)
547
+ return;
548
+ if (this.permissionState.isBlocked && this.autoApprove) {
549
+ this.permissionState.retryCount++;
550
+ this.permissionState.lastAutoConfirmAt = 0; // allow immediate retry
551
+ this.scheduleAutoApprove(scope, target);
552
+ }
553
+ else {
554
+ this.permissionState.retryCount = 0;
555
+ }
556
+ }, retryDelay);
557
+ }
558
+ }, AUTO_APPROVE_DELAY_MS);
559
+ }
560
+ /**
561
+ * Schedule a fallback auto-approve with false-positive verification.
562
+ * Similar to scheduleAutoApprove but sets up post-approve monitoring.
563
+ */
564
+ scheduleFallbackAutoApprove(scope, target, context) {
565
+ const now = Date.now();
566
+ if (now - this.permissionState.lastAutoConfirmAt < 500)
567
+ return;
568
+ if (this.permissionState.pendingAutoApproveTimer)
569
+ return;
570
+ this.permissionState.lastAutoConfirmAt = now;
571
+ this.permissionState.pendingAutoApproveTimer = setTimeout(() => {
572
+ this.permissionState.pendingAutoApproveTimer = null;
573
+ if (this._exited)
574
+ return;
575
+ // Snapshot output length before sending \r for false-positive detection
576
+ this.permissionState.fallbackOutputLenAtApprove = this.rawOutput.length;
577
+ this.permissionState.fallbackVerifyUntil = Date.now() + FALLBACK_VERIFY_WINDOW_MS;
578
+ this.permissionState.fallbackContext = context;
579
+ if (this.ptyWrite) {
580
+ this.ptyWrite("\r");
581
+ }
582
+ // Don't clear isBlocked/window here — let the verification logic in detectPermission handle it
583
+ this.permissionState.isBlocked = false;
584
+ this.permissionState.window = "";
585
+ this.permissionState.lastPrompt = null;
586
+ this.permissionState.lastScope = null;
587
+ this.permissionState.lastTarget = null;
432
588
  }, AUTO_APPROVE_DELAY_MS);
433
589
  }
434
590
  cancelPendingAutoApprove() {
@@ -437,6 +593,67 @@ export class ClaudePtyBridge extends EventEmitter {
437
593
  this.permissionState.pendingAutoApproveTimer = null;
438
594
  }
439
595
  }
596
+ // ── Idle Probe (last-resort robustness) ──
597
+ /**
598
+ * Reset the idle probe timer. Called on every new output chunk.
599
+ * If the terminal goes idle (no output for IDLE_PROBE_DELAY_MS) while
600
+ * auto-approve is enabled and no permission is currently detected,
601
+ * we send a speculative \r to catch prompts that all detection layers missed.
602
+ */
603
+ resetIdleProbeTimer() {
604
+ this.clearIdleProbeTimer();
605
+ this.idleProbeTimer = setTimeout(() => {
606
+ this.idleProbeTimer = null;
607
+ this.maybeIdleProbe();
608
+ }, IDLE_PROBE_DELAY_MS);
609
+ }
610
+ clearIdleProbeTimer() {
611
+ if (this.idleProbeTimer) {
612
+ clearTimeout(this.idleProbeTimer);
613
+ this.idleProbeTimer = null;
614
+ }
615
+ }
616
+ /**
617
+ * Idle probe: the terminal has been quiet for a while. If conditions suggest
618
+ * a stuck permission prompt, send a speculative \r and monitor the result.
619
+ */
620
+ maybeIdleProbe() {
621
+ if (this._exited || !this.autoApprove || !this.ptyWrite)
622
+ return;
623
+ // Don't probe if permission is already detected or fallback is pending verification
624
+ if (this.permissionState.isBlocked)
625
+ return;
626
+ if (this.permissionState.fallbackContext)
627
+ return;
628
+ if (this.permissionState.pendingAutoApproveTimer)
629
+ return;
630
+ const now = Date.now();
631
+ // Cooldown between probes
632
+ if (now - this.lastIdleProbeAt < IDLE_PROBE_COOLDOWN_MS)
633
+ return;
634
+ // Don't probe if user recently sent input (they're actively interacting)
635
+ if (now - this.lastUserInputAt < IDLE_PROBE_DELAY_MS)
636
+ return;
637
+ // Only probe if output stopped recently (not ancient idle sessions)
638
+ if (now - this.lastOutputAt > 15000)
639
+ return;
640
+ // Quick heuristic: check if recent output has ANY permission-like keywords
641
+ const normalized = normalizePromptText(this.permissionState.window);
642
+ const { score, matched } = scorePermissionLikelihood(normalized);
643
+ // Lower threshold than fallback — we're being speculative
644
+ if (score < 4)
645
+ return;
646
+ this.lastIdleProbeAt = now;
647
+ // Snapshot output before probe
648
+ this.permissionState.fallbackOutputLenAtApprove = this.rawOutput.length;
649
+ this.permissionState.fallbackVerifyUntil = now + FALLBACK_VERIFY_WINDOW_MS;
650
+ this.permissionState.fallbackContext = {
651
+ score,
652
+ matched,
653
+ text: normalized.split("\n").slice(-8).join("\n"),
654
+ };
655
+ this.ptyWrite("\r");
656
+ }
440
657
  isPermissionPromptDetected(normalized) {
441
658
  const hasIntent = /\bdo you want to\b/i.test(normalized)
442
659
  || /\bwould you like to\b/i.test(normalized)
@@ -495,10 +712,8 @@ export class ClaudePtyBridge extends EventEmitter {
495
712
  }
496
713
  this.chatState.echoSkipped = true;
497
714
  this.chatState.buffer = clean.slice(echoEndIndex);
498
- process.stderr.write(`[Bridge] Echo skipped, remaining length: ${this.chatState.buffer.length}\n`);
499
715
  }
500
716
  if (this.detectCompletion(this.chatState.buffer)) {
501
- process.stderr.write("[Bridge] Completion detected, finalizing\n");
502
717
  this.finalizeResponse();
503
718
  return;
504
719
  }
package/dist/cli.js CHANGED
@@ -1,8 +1,6 @@
1
1
  #!/usr/bin/env -S node --disable-warning=ExperimentalWarning
2
2
  import process from "node:process";
3
3
  import { ensureConfig, hasConfigFile, isExecutionMode, resolveConfigPath, saveConfig } from "./config.js";
4
- import { startServer } from "./server.js";
5
- import { ensureDatabaseFile, resolveDatabasePath } from "./storage.js";
6
4
  async function main() {
7
5
  const args = process.argv.slice(2);
8
6
  const command = args[0] || "help";
@@ -14,6 +12,7 @@ async function main() {
14
12
  }
15
13
  case "web": {
16
14
  const config = await ensureRequiredFiles(configPath);
15
+ const { startServer } = await import("./server.js");
17
16
  await startServer(config, configPath);
18
17
  break;
19
18
  }
@@ -67,6 +66,7 @@ Options:
67
66
  `);
68
67
  }
69
68
  async function ensureRequiredFiles(configPath) {
69
+ const { ensureDatabaseFile, resolveDatabasePath } = await import("./storage.js");
70
70
  const dbPath = resolveDatabasePath(configPath);
71
71
  const hadConfig = hasConfigFile(configPath);
72
72
  const config = await ensureConfig(configPath);
@@ -34,4 +34,5 @@ export function cleanupRateLimiter() {
34
34
  }
35
35
  }
36
36
  // Cleanup expired entries every 5 minutes
37
- setInterval(cleanupRateLimiter, 5 * 60 * 1000);
37
+ const rateLimitCleanupTimer = setInterval(cleanupRateLimiter, 5 * 60 * 1000);
38
+ rateLimitCleanupTimer.unref();
@@ -71,6 +71,7 @@ export declare class ProcessManager extends EventEmitter {
71
71
  resolveEscalation(id: string, requestId: string, resolution?: "approve_once" | "approve_turn" | "deny"): SessionSnapshot;
72
72
  approvePermission(id: string): SessionSnapshot;
73
73
  denyPermission(id: string): SessionSnapshot;
74
+ toggleAutoApprove(id: string): SessionSnapshot;
74
75
  /**
75
76
  * Canonical permission resolution method.
76
77
  * All other permission methods delegate to this.