@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.
- package/dist/claude-pty-bridge.d.ts +32 -0
- package/dist/claude-pty-bridge.js +186 -8
- package/dist/process-manager.js +87 -190
- package/dist/pty-text-utils.d.ts +12 -0
- package/dist/pty-text-utils.js +37 -1
- package/dist/server-session-routes.d.ts +1 -0
- package/dist/server-session-routes.js +1 -3
- package/dist/server.js +6 -31
- package/dist/session-lifecycle.js +0 -5
- package/dist/session-logger.d.ts +10 -0
- package/dist/types.d.ts +7 -0
- package/dist/web-ui/content/scripts.js +134 -30
- package/dist/web-ui/content/styles.css +111 -2
- package/package.json +1 -1
|
@@ -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
|
}
|