@co0ontty/wand 1.3.4 → 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 +64 -178
- 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 +91 -21
- 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
|
}
|
package/dist/process-manager.js
CHANGED
|
@@ -9,7 +9,7 @@ import { SessionLogger } from "./session-logger.js";
|
|
|
9
9
|
import { SessionLifecycleManager } from "./session-lifecycle.js";
|
|
10
10
|
import { ClaudePtyBridge } from "./claude-pty-bridge.js";
|
|
11
11
|
import { appendWindow, hasExplicitConfirmSyntax, hasPermissionActionContext, normalizePromptText } from "./pty-text-utils.js";
|
|
12
|
-
import { getResumeCommandSessionId,
|
|
12
|
+
import { getResumeCommandSessionId, hasRealConversationMessages, } from "./resume-policy.js";
|
|
13
13
|
/** Check if the current process is running as root (UID 0). */
|
|
14
14
|
function isRunningAsRoot() {
|
|
15
15
|
return process.getuid?.() === 0 || process.geteuid?.() === 0;
|
|
@@ -26,20 +26,6 @@ export class SessionInputError extends Error {
|
|
|
26
26
|
this.name = "SessionInputError";
|
|
27
27
|
}
|
|
28
28
|
}
|
|
29
|
-
const PROMPT_PATTERNS = [
|
|
30
|
-
/(?:^|\b)(?:press\s+)?(?:y|yes)\s*(?:\/|\bor\b)\s*(?:n|no)(?:\b|$)/i,
|
|
31
|
-
/\[(?:y|yes)\s*\/\s*(?:n|no)\]/i,
|
|
32
|
-
/\((?:y|yes)\s*\/\s*(?:n|no)\)/i,
|
|
33
|
-
/\((?:y|yes)\s*\/\s*(?:n|no)\s*\/\s*always\)/i,
|
|
34
|
-
/\bcontinue\?\s*(?:\((?:y|yes)\s*\/\s*(?:n|no)\))?/i,
|
|
35
|
-
/\bare you sure\??/i,
|
|
36
|
-
/\bdo you want to continue\??/i,
|
|
37
|
-
/\bdo you want to (?:create|write|delete|modify|execute)\b/i,
|
|
38
|
-
/\bconfirm(?:\s+execution|\s+changes|\s+action)?\??/i,
|
|
39
|
-
/\bproceed\?\s*(?:\((?:y|yes)\s*\/\s*(?:n|no)\))?/i,
|
|
40
|
-
/\benter to confirm\b/i,
|
|
41
|
-
/\bgrant\b.*\bpermission\b/i,
|
|
42
|
-
];
|
|
43
29
|
const REAL_CONVERSATION_MIN_LINES = 2;
|
|
44
30
|
const DISCOVERY_RECENT_WINDOW_MS = 10 * 60 * 1000;
|
|
45
31
|
const START_TIME_SKEW_MS = 30 * 1000;
|
|
@@ -89,132 +75,6 @@ function readClaudeProjectSessionDetails(filePath, id) {
|
|
|
89
75
|
return null;
|
|
90
76
|
}
|
|
91
77
|
}
|
|
92
|
-
function hasVisibleProjectConversation(messages) {
|
|
93
|
-
return shouldDisplayResumeAction(messages);
|
|
94
|
-
}
|
|
95
|
-
function hasRecoverableProjectConversation(messages) {
|
|
96
|
-
return shouldAllowResume({ claudeSessionId: "resume-candidate", messages });
|
|
97
|
-
}
|
|
98
|
-
function shouldBindLiveProjectSessionId(messages) {
|
|
99
|
-
return hasLiveProjectConversation(messages);
|
|
100
|
-
}
|
|
101
|
-
function shouldBackfillStoredProjectSessionId(messages) {
|
|
102
|
-
return hasStoredProjectConversation(messages);
|
|
103
|
-
}
|
|
104
|
-
function shouldDisplayVisibleProjectSessionId(messages) {
|
|
105
|
-
return hasVisibleProjectConversation(messages);
|
|
106
|
-
}
|
|
107
|
-
function shouldResumeRecoverableProjectSessionId(messages) {
|
|
108
|
-
return hasRecoverableProjectConversation(messages);
|
|
109
|
-
}
|
|
110
|
-
function canBindLiveProjectSession(record) {
|
|
111
|
-
return shouldBindLiveProjectSessionId(record.messages);
|
|
112
|
-
}
|
|
113
|
-
function canBackfillStoredProjectSession(record) {
|
|
114
|
-
return shouldBackfillStoredProjectSessionId(record.messages);
|
|
115
|
-
}
|
|
116
|
-
function canDisplayVisibleProjectSession(messages) {
|
|
117
|
-
return shouldDisplayVisibleProjectSessionId(messages);
|
|
118
|
-
}
|
|
119
|
-
function canResumeRecoverableProjectSession(messages) {
|
|
120
|
-
return shouldResumeRecoverableProjectSessionId(messages);
|
|
121
|
-
}
|
|
122
|
-
function shouldAdoptProjectSessionDuringRuntime(record) {
|
|
123
|
-
return canBindLiveProjectSession(record);
|
|
124
|
-
}
|
|
125
|
-
function shouldAdoptProjectSessionDuringBackfill(record) {
|
|
126
|
-
return canBackfillStoredProjectSession(record);
|
|
127
|
-
}
|
|
128
|
-
function shouldAdoptProjectSessionForUi(messages) {
|
|
129
|
-
return canDisplayVisibleProjectSession(messages);
|
|
130
|
-
}
|
|
131
|
-
function shouldAdoptProjectSessionForResume(messages) {
|
|
132
|
-
return canResumeRecoverableProjectSession(messages);
|
|
133
|
-
}
|
|
134
|
-
function hasRuntimeProjectAdoption(messages) {
|
|
135
|
-
return shouldAdoptProjectSessionForUi(messages);
|
|
136
|
-
}
|
|
137
|
-
function hasBackfillProjectAdoption(messages) {
|
|
138
|
-
return shouldBackfillStoredProjectSessionId(messages);
|
|
139
|
-
}
|
|
140
|
-
function hasUiProjectAdoption(messages) {
|
|
141
|
-
return shouldAdoptProjectSessionForUi(messages);
|
|
142
|
-
}
|
|
143
|
-
function hasResumeProjectAdoption(messages) {
|
|
144
|
-
return shouldAdoptProjectSessionForResume(messages);
|
|
145
|
-
}
|
|
146
|
-
function shouldAdoptProjectSession(record) {
|
|
147
|
-
return shouldAdoptProjectSessionDuringRuntime(record);
|
|
148
|
-
}
|
|
149
|
-
function shouldAdoptStoredProjectSession(record) {
|
|
150
|
-
return shouldAdoptProjectSessionDuringBackfill(record);
|
|
151
|
-
}
|
|
152
|
-
function shouldAdoptUiProjectSession(messages) {
|
|
153
|
-
return hasUiProjectAdoption(messages);
|
|
154
|
-
}
|
|
155
|
-
function shouldAdoptResumeProjectSession(messages) {
|
|
156
|
-
return hasResumeProjectAdoption(messages);
|
|
157
|
-
}
|
|
158
|
-
function canUseProjectSessionAtRuntime(record) {
|
|
159
|
-
return shouldAdoptProjectSession(record);
|
|
160
|
-
}
|
|
161
|
-
function canUseProjectSessionAtBackfill(record) {
|
|
162
|
-
return shouldAdoptStoredProjectSession(record);
|
|
163
|
-
}
|
|
164
|
-
function canUseProjectSessionAtUi(messages) {
|
|
165
|
-
return shouldAdoptUiProjectSession(messages);
|
|
166
|
-
}
|
|
167
|
-
function canUseProjectSessionAtResume(messages) {
|
|
168
|
-
return shouldAdoptResumeProjectSession(messages);
|
|
169
|
-
}
|
|
170
|
-
function hasProjectSessionRuntimeEligibility(messages) {
|
|
171
|
-
return shouldAdoptProjectSessionDuringRuntime({ messages: messages ?? [] });
|
|
172
|
-
}
|
|
173
|
-
function hasProjectSessionBackfillEligibility(messages) {
|
|
174
|
-
return shouldAdoptProjectSessionDuringBackfill({ messages: messages ?? [] });
|
|
175
|
-
}
|
|
176
|
-
function hasProjectSessionUiEligibility(messages) {
|
|
177
|
-
return canUseProjectSessionAtUi(messages);
|
|
178
|
-
}
|
|
179
|
-
function hasProjectSessionResumeEligibility(messages) {
|
|
180
|
-
return canUseProjectSessionAtResume(messages);
|
|
181
|
-
}
|
|
182
|
-
function shouldClaimProjectSessionDuringRuntime(messages) {
|
|
183
|
-
return hasProjectSessionRuntimeEligibility(messages);
|
|
184
|
-
}
|
|
185
|
-
function shouldClaimProjectSessionDuringBackfill(messages) {
|
|
186
|
-
return hasProjectSessionBackfillEligibility(messages);
|
|
187
|
-
}
|
|
188
|
-
function shouldClaimProjectSessionForUi(messages) {
|
|
189
|
-
return hasProjectSessionUiEligibility(messages);
|
|
190
|
-
}
|
|
191
|
-
function shouldClaimProjectSessionForResume(messages) {
|
|
192
|
-
return hasProjectSessionResumeEligibility(messages);
|
|
193
|
-
}
|
|
194
|
-
function hasClaimableProjectSessionRuntime(messages) {
|
|
195
|
-
return shouldClaimProjectSessionDuringRuntime(messages);
|
|
196
|
-
}
|
|
197
|
-
function hasClaimableProjectSessionBackfill(messages) {
|
|
198
|
-
return shouldClaimProjectSessionDuringBackfill(messages);
|
|
199
|
-
}
|
|
200
|
-
function hasClaimableProjectSessionUi(messages) {
|
|
201
|
-
return shouldClaimProjectSessionForUi(messages);
|
|
202
|
-
}
|
|
203
|
-
function hasClaimableProjectSessionResume(messages) {
|
|
204
|
-
return shouldClaimProjectSessionForResume(messages);
|
|
205
|
-
}
|
|
206
|
-
function isClaimableProjectSessionRuntime(messages) {
|
|
207
|
-
return hasClaimableProjectSessionRuntime(messages);
|
|
208
|
-
}
|
|
209
|
-
function isClaimableProjectSessionBackfill(messages) {
|
|
210
|
-
return hasClaimableProjectSessionBackfill(messages);
|
|
211
|
-
}
|
|
212
|
-
function isClaimableProjectSessionUi(messages) {
|
|
213
|
-
return hasClaimableProjectSessionUi(messages);
|
|
214
|
-
}
|
|
215
|
-
function isClaimableProjectSessionResume(messages) {
|
|
216
|
-
return hasClaimableProjectSessionResume(messages);
|
|
217
|
-
}
|
|
218
78
|
function listClaudeProjectSessionCandidates(cwd) {
|
|
219
79
|
const projectDir = getClaudeProjectDir(cwd);
|
|
220
80
|
try {
|
|
@@ -557,9 +417,7 @@ export class ProcessManager extends EventEmitter {
|
|
|
557
417
|
onStateChange: (sessionId, oldState, newState) => {
|
|
558
418
|
this.emitEvent({ type: "status", sessionId, data: { oldState, newState } });
|
|
559
419
|
},
|
|
560
|
-
onIdle: (
|
|
561
|
-
console.error(`[ProcessManager] Session ${sessionId} is now idle`);
|
|
562
|
-
},
|
|
420
|
+
onIdle: (_sessionId) => { },
|
|
563
421
|
onArchived: (sessionId, reason) => {
|
|
564
422
|
console.error(`[ProcessManager] Session ${sessionId} archived: ${reason}`);
|
|
565
423
|
},
|
|
@@ -599,7 +457,8 @@ export class ProcessManager extends EventEmitter {
|
|
|
599
457
|
knownClaudeTaskIds: undefined,
|
|
600
458
|
claudeTaskDiscoveryTimer: null,
|
|
601
459
|
knownClaudeProjectMtimes: isClaudeCmd ? listClaudeProjectSessionMtimes(updated.cwd) : undefined,
|
|
602
|
-
claudeSessionId: resumeCommandSessionId ?? updated.claudeSessionId
|
|
460
|
+
claudeSessionId: resumeCommandSessionId ?? updated.claudeSessionId,
|
|
461
|
+
approvalStats: { tool: 0, command: 0, file: 0, total: 0 }
|
|
603
462
|
});
|
|
604
463
|
this.lifecycleManager.register(snapshot.id, "idle");
|
|
605
464
|
console.error(`[ProcessManager] Restored session ${snapshot.id} marked as exited (PTY orphaned)`);
|
|
@@ -631,7 +490,8 @@ export class ProcessManager extends EventEmitter {
|
|
|
631
490
|
knownClaudeTaskIds: undefined,
|
|
632
491
|
claudeTaskDiscoveryTimer: null,
|
|
633
492
|
knownClaudeProjectMtimes: isClaudeCmd ? listClaudeProjectSessionMtimes(snapshot.cwd) : undefined,
|
|
634
|
-
claudeSessionId: resumeCommandSessionId ?? snapshot.claudeSessionId
|
|
493
|
+
claudeSessionId: resumeCommandSessionId ?? snapshot.claudeSessionId,
|
|
494
|
+
approvalStats: { tool: 0, command: 0, file: 0, total: 0 }
|
|
635
495
|
});
|
|
636
496
|
this.lifecycleManager.register(snapshot.id, "archived");
|
|
637
497
|
}
|
|
@@ -741,7 +601,8 @@ export class ProcessManager extends EventEmitter {
|
|
|
741
601
|
lastEmittedTask: null,
|
|
742
602
|
knownClaudeTaskIds: knownClaudeTaskIds ?? undefined,
|
|
743
603
|
claudeTaskDiscoveryTimer: null,
|
|
744
|
-
knownClaudeProjectMtimes: knownClaudeProjectMtimes ?? undefined
|
|
604
|
+
knownClaudeProjectMtimes: knownClaudeProjectMtimes ?? undefined,
|
|
605
|
+
approvalStats: { tool: 0, command: 0, file: 0, total: 0 }
|
|
745
606
|
};
|
|
746
607
|
// Create PTY bridge for this session
|
|
747
608
|
record.ptyBridge = new ClaudePtyBridge({
|
|
@@ -844,7 +705,7 @@ export class ProcessManager extends EventEmitter {
|
|
|
844
705
|
process.stderr.write(`[wand] Cannot send initial input: session not ready\n`);
|
|
845
706
|
return;
|
|
846
707
|
}
|
|
847
|
-
process.stderr.write(`[wand] Sending initial input
|
|
708
|
+
process.stderr.write(`[wand] Sending initial input (${initialInput.length} chars)\n`);
|
|
848
709
|
// Track initial input via bridge for Chat mode
|
|
849
710
|
if (current.ptyBridge) {
|
|
850
711
|
current.ptyBridge.onUserInput(initialInput);
|
|
@@ -1012,35 +873,16 @@ export class ProcessManager extends EventEmitter {
|
|
|
1012
873
|
sendInput(id, input, view, shortcutKey) {
|
|
1013
874
|
const record = this.mustGet(id);
|
|
1014
875
|
if (record.status !== "running") {
|
|
1015
|
-
console.error(
|
|
1016
|
-
sessionId: id,
|
|
1017
|
-
status: record.status,
|
|
1018
|
-
hasPty: !!record.ptyProcess,
|
|
1019
|
-
inputLength: input.length,
|
|
1020
|
-
view: view ?? "chat"
|
|
1021
|
-
});
|
|
876
|
+
console.error(`[ProcessManager] Rejecting input: session ${id} not running (${record.status})`);
|
|
1022
877
|
throw new SessionInputError("Session is not running.", "SESSION_NOT_RUNNING", id, record.status);
|
|
1023
878
|
}
|
|
1024
879
|
// Update lifecycle
|
|
1025
880
|
this.lifecycleManager.touch(id);
|
|
1026
881
|
this.lifecycleManager.startThinking(id);
|
|
1027
882
|
if (!record.ptyProcess) {
|
|
1028
|
-
console.error(
|
|
1029
|
-
sessionId: id,
|
|
1030
|
-
status: record.status,
|
|
1031
|
-
hasPty: !!record.ptyProcess,
|
|
1032
|
-
inputLength: input.length,
|
|
1033
|
-
view: view ?? "chat"
|
|
1034
|
-
});
|
|
883
|
+
console.error(`[ProcessManager] Rejecting input: session ${id} has no PTY`);
|
|
1035
884
|
throw new SessionInputError("Session is not running.", "SESSION_NO_PTY", id, record.status);
|
|
1036
885
|
}
|
|
1037
|
-
console.error("[ProcessManager] Sending input to session", {
|
|
1038
|
-
sessionId: id,
|
|
1039
|
-
status: record.status,
|
|
1040
|
-
hasPty: !!record.ptyProcess,
|
|
1041
|
-
inputLength: input.length,
|
|
1042
|
-
view: view ?? "chat"
|
|
1043
|
-
});
|
|
1044
886
|
// Log shortcut key interactions for auto-confirm and mode analysis
|
|
1045
887
|
if (shortcutKey) {
|
|
1046
888
|
const outputLines = record.output.split("\n");
|
|
@@ -1253,7 +1095,8 @@ export class ProcessManager extends EventEmitter {
|
|
|
1253
1095
|
messages: messages.length > 0 ? messages : undefined,
|
|
1254
1096
|
resumedFromSessionId: record.resumedFromSessionId ?? undefined,
|
|
1255
1097
|
resumedToSessionId: record.resumedToSessionId ?? undefined,
|
|
1256
|
-
autoRecovered: record.autoRecovered ?? false
|
|
1098
|
+
autoRecovered: record.autoRecovered ?? false,
|
|
1099
|
+
approvalStats: record.approvalStats.total > 0 ? record.approvalStats : undefined
|
|
1257
1100
|
};
|
|
1258
1101
|
}
|
|
1259
1102
|
isPermissionBlocked(record) {
|
|
@@ -1493,11 +1336,9 @@ export class ProcessManager extends EventEmitter {
|
|
|
1493
1336
|
|| claudeConfirmPrompt
|
|
1494
1337
|
|| toolPermissionPrompt
|
|
1495
1338
|
|| (hasExplicitConfirmSyntax(normalized)
|
|
1496
|
-
&& hasPermissionActionContext(normalized)
|
|
1497
|
-
&& PROMPT_PATTERNS.some((pattern) => pattern.test(normalized)));
|
|
1339
|
+
&& hasPermissionActionContext(normalized));
|
|
1498
1340
|
if (shouldConfirm) {
|
|
1499
1341
|
record.lastAutoConfirmAt = now;
|
|
1500
|
-
process.stderr.write(`[wand] Auto-confirming prompt for ${record.mode} mode\n`);
|
|
1501
1342
|
// Always auto-confirm by sending Enter directly
|
|
1502
1343
|
ptyProcess.write("\r");
|
|
1503
1344
|
}
|
|
@@ -1562,15 +1403,50 @@ export class ProcessManager extends EventEmitter {
|
|
|
1562
1403
|
});
|
|
1563
1404
|
break;
|
|
1564
1405
|
}
|
|
1565
|
-
case "permission.resolved":
|
|
1406
|
+
case "permission.resolved": {
|
|
1407
|
+
// Increment approval stats before clearing pendingEscalation
|
|
1408
|
+
const resolvedScope = record.pendingEscalation?.scope;
|
|
1409
|
+
if (resolvedScope) {
|
|
1410
|
+
if (resolvedScope === "run_command" || resolvedScope === "dangerous_shell") {
|
|
1411
|
+
record.approvalStats.command++;
|
|
1412
|
+
}
|
|
1413
|
+
else if (resolvedScope === "write_file") {
|
|
1414
|
+
record.approvalStats.file++;
|
|
1415
|
+
}
|
|
1416
|
+
else {
|
|
1417
|
+
record.approvalStats.tool++;
|
|
1418
|
+
}
|
|
1419
|
+
record.approvalStats.total++;
|
|
1420
|
+
}
|
|
1566
1421
|
record.pendingEscalation = null;
|
|
1567
1422
|
record.ptyPermissionBlocked = false;
|
|
1568
1423
|
this.emitEvent({
|
|
1569
1424
|
type: "status",
|
|
1570
1425
|
sessionId: event.sessionId,
|
|
1571
|
-
data: {
|
|
1426
|
+
data: {
|
|
1427
|
+
permissionBlocked: false,
|
|
1428
|
+
approvalStats: record.approvalStats,
|
|
1429
|
+
},
|
|
1572
1430
|
});
|
|
1431
|
+
// Log auto-approve events to shortcut-interactions.jsonl for analysis
|
|
1432
|
+
const resolvedData = event.data;
|
|
1433
|
+
if (resolvedData?.autoApproved) {
|
|
1434
|
+
const outputLines = record.output.split("\n");
|
|
1435
|
+
const tailLines = outputLines.slice(-8).join("\n");
|
|
1436
|
+
this.logger.appendShortcutLog(record.id, "auto_approve", tailLines, {
|
|
1437
|
+
mode: record.mode,
|
|
1438
|
+
scope: resolvedScope ?? "unknown",
|
|
1439
|
+
autoApprove: record.autoApprovePermissions,
|
|
1440
|
+
permissionBlocked: true,
|
|
1441
|
+
input: "\r",
|
|
1442
|
+
approveType: resolvedData.approveType,
|
|
1443
|
+
score: resolvedData.score,
|
|
1444
|
+
matched: resolvedData.matched,
|
|
1445
|
+
falsePositive: resolvedData.falsePositive,
|
|
1446
|
+
});
|
|
1447
|
+
}
|
|
1573
1448
|
break;
|
|
1449
|
+
}
|
|
1574
1450
|
case "session.id":
|
|
1575
1451
|
// Claude session ID captured - already handled in onData
|
|
1576
1452
|
break;
|
|
@@ -1633,15 +1509,25 @@ export class ProcessManager extends EventEmitter {
|
|
|
1633
1509
|
return false;
|
|
1634
1510
|
}
|
|
1635
1511
|
processCommandForMode(command, mode) {
|
|
1512
|
+
const isClaudeCmd = /^(?:claude|npx\s+claude|[^\s]+\/claude)(?:\s|$)/.test(command);
|
|
1513
|
+
if (!isClaudeCmd)
|
|
1514
|
+
return command;
|
|
1515
|
+
let result = command;
|
|
1516
|
+
// When running as root, Claude CLI refuses --permission-mode bypassPermissions.
|
|
1517
|
+
// Use acceptEdits to let Claude auto-accept edits natively, reducing the number
|
|
1518
|
+
// of permission prompts wand has to auto-approve.
|
|
1519
|
+
if (isRunningAsRoot() && (mode === "managed" || mode === "full-access" || mode === "auto-edit")) {
|
|
1520
|
+
result += " --permission-mode acceptEdits";
|
|
1521
|
+
}
|
|
1636
1522
|
// In managed mode, append a system prompt instructing Claude to act autonomously
|
|
1637
1523
|
// without asking the user for confirmation, since the user may not be monitoring.
|
|
1638
|
-
if (mode === "managed"
|
|
1524
|
+
if (mode === "managed") {
|
|
1639
1525
|
const autonomousPrompt = "You are running in a fully managed, autonomous mode. The user may not be available to respond to questions or confirmations in a timely manner. You MUST make all decisions independently — choose the best approach yourself instead of asking the user for preferences, confirmations, or clarifications. If multiple approaches are viable, pick the one you judge most appropriate and proceed. Never block on user input unless the task is fundamentally ambiguous and cannot be reasonably inferred. Be decisive and self-directed.";
|
|
1640
1526
|
// Escape single quotes for shell safety
|
|
1641
1527
|
const escaped = autonomousPrompt.replace(/'/g, "'\\''");
|
|
1642
|
-
|
|
1528
|
+
result += ` --append-system-prompt '${escaped}'`;
|
|
1643
1529
|
}
|
|
1644
|
-
return
|
|
1530
|
+
return result;
|
|
1645
1531
|
}
|
|
1646
1532
|
}
|
|
1647
1533
|
function clampDimension(value, min, max) {
|
package/dist/pty-text-utils.d.ts
CHANGED
|
@@ -11,5 +11,17 @@ export declare function isNoiseLine(line: string): boolean;
|
|
|
11
11
|
export declare function appendWindow(buffer: string, chunk: string, maxSize: number): string;
|
|
12
12
|
export declare function hasExplicitConfirmSyntax(normalized: string): boolean;
|
|
13
13
|
export declare function hasPermissionActionContext(normalized: string): boolean;
|
|
14
|
+
interface PermissionScore {
|
|
15
|
+
score: number;
|
|
16
|
+
matched: string[];
|
|
17
|
+
}
|
|
18
|
+
/** Minimum score threshold for fallback permission detection */
|
|
19
|
+
export declare const FALLBACK_SCORE_THRESHOLD = 8;
|
|
20
|
+
/**
|
|
21
|
+
* Score how likely the recent output contains a permission prompt.
|
|
22
|
+
* Evaluates the last few lines of normalized text against weighted keywords.
|
|
23
|
+
*/
|
|
24
|
+
export declare function scorePermissionLikelihood(normalized: string): PermissionScore;
|
|
14
25
|
/** Normalize prompt text for permission detection (strip ANSI, collapse whitespace). */
|
|
15
26
|
export declare function normalizePromptText(value: string): string;
|
|
27
|
+
export {};
|
package/dist/pty-text-utils.js
CHANGED
|
@@ -77,8 +77,10 @@ const EXPLICIT_CONFIRM_PATTERNS = [
|
|
|
77
77
|
/\[(?:y|yes)\s*\/\s*(?:n|no)\]/i,
|
|
78
78
|
/\((?:y|yes)\s*\/\s*(?:n|no)\)/i,
|
|
79
79
|
/\((?:y|yes)\s*\/\s*(?:n|no)\s*\/\s*always\)/i,
|
|
80
|
-
/\byes\b
|
|
80
|
+
/\byes\b[\s\S]*\bno\b/i,
|
|
81
81
|
/\benter to confirm\b/i,
|
|
82
|
+
// Claude CLI numbered selection menus: "❯ 1. Yes" + "N. No"
|
|
83
|
+
/❯\s*1\.\s*yes\b/i,
|
|
82
84
|
];
|
|
83
85
|
const PERMISSION_ACTION_PATTERNS = [
|
|
84
86
|
/\bgrant\b.*\bpermission\b/i,
|
|
@@ -92,6 +94,40 @@ export function hasExplicitConfirmSyntax(normalized) {
|
|
|
92
94
|
export function hasPermissionActionContext(normalized) {
|
|
93
95
|
return PERMISSION_ACTION_PATTERNS.some((pattern) => pattern.test(normalized));
|
|
94
96
|
}
|
|
97
|
+
const PERMISSION_KEYWORD_WEIGHTS = [
|
|
98
|
+
{ pattern: /\bdo you want to proceed\b/i, weight: 5, label: "do you want to proceed" },
|
|
99
|
+
{ pattern: /\bwould you like to proceed\b/i, weight: 5, label: "would you like to proceed" },
|
|
100
|
+
{ pattern: /\bdo you want to\b/i, weight: 4, label: "do you want to" },
|
|
101
|
+
{ pattern: /\bwould you like to\b/i, weight: 4, label: "would you like to" },
|
|
102
|
+
{ pattern: /\benter to confirm\b/i, weight: 4, label: "enter to confirm" },
|
|
103
|
+
{ pattern: /\bgrant\b[\s\S]*\bpermission\b/i, weight: 4, label: "grant permission" },
|
|
104
|
+
{ pattern: /❯\s*1\./i, weight: 3, label: "❯ 1." },
|
|
105
|
+
{ pattern: /\b1\.\s*yes\b/i, weight: 3, label: "1. yes" },
|
|
106
|
+
{ pattern: /\bdon'?t ask again\b/i, weight: 2, label: "don't ask again" },
|
|
107
|
+
{ pattern: /\b(?:bash|shell)\s*command\b/i, weight: 2, label: "bash/shell command" },
|
|
108
|
+
{ pattern: /\b(?:run|read|write|edit)\s+(?:file|command|shell)\b/i, weight: 2, label: "run/read/write action" },
|
|
109
|
+
{ pattern: /\ballow\b.*\breading\b/i, weight: 2, label: "allow reading" },
|
|
110
|
+
];
|
|
111
|
+
/** Minimum score threshold for fallback permission detection */
|
|
112
|
+
export const FALLBACK_SCORE_THRESHOLD = 8;
|
|
113
|
+
/**
|
|
114
|
+
* Score how likely the recent output contains a permission prompt.
|
|
115
|
+
* Evaluates the last few lines of normalized text against weighted keywords.
|
|
116
|
+
*/
|
|
117
|
+
export function scorePermissionLikelihood(normalized) {
|
|
118
|
+
// Take the last ~5 lines
|
|
119
|
+
const lines = normalized.split("\n");
|
|
120
|
+
const tail = lines.slice(-8).join("\n");
|
|
121
|
+
let score = 0;
|
|
122
|
+
const matched = [];
|
|
123
|
+
for (const { pattern, weight, label } of PERMISSION_KEYWORD_WEIGHTS) {
|
|
124
|
+
if (pattern.test(tail)) {
|
|
125
|
+
score += weight;
|
|
126
|
+
matched.push(label);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return { score, matched };
|
|
130
|
+
}
|
|
95
131
|
/** Normalize prompt text for permission detection (strip ANSI, collapse whitespace). */
|
|
96
132
|
export function normalizePromptText(value) {
|
|
97
133
|
return value
|
|
@@ -2,5 +2,6 @@ import { Express } from "express";
|
|
|
2
2
|
import { ProcessManager } from "./process-manager.js";
|
|
3
3
|
import { WandStorage } from "./storage.js";
|
|
4
4
|
import { ExecutionMode } from "./types.js";
|
|
5
|
+
export declare function getErrorMessage(error: unknown, fallback: string): string;
|
|
5
6
|
export declare function registerSessionRoutes(app: Express, processes: ProcessManager, storage: WandStorage, defaultMode: ExecutionMode): void;
|
|
6
7
|
export declare function registerClaudeHistoryRoutes(app: Express, processes: ProcessManager, storage: WandStorage): void;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import express from "express";
|
|
2
2
|
import { parseMessages } from "./message-parser.js";
|
|
3
3
|
import { SessionInputError } from "./process-manager.js";
|
|
4
|
-
function getErrorMessage(error, fallback) {
|
|
4
|
+
export function getErrorMessage(error, fallback) {
|
|
5
5
|
return error instanceof Error ? error.message : fallback;
|
|
6
6
|
}
|
|
7
7
|
function getInputErrorResponse(error, sessionId) {
|
|
@@ -184,10 +184,8 @@ export function registerSessionRoutes(app, processes, storage, defaultMode) {
|
|
|
184
184
|
const input = body.input ?? "";
|
|
185
185
|
const view = body.view;
|
|
186
186
|
const shortcutKey = body.shortcutKey;
|
|
187
|
-
console.error("[wand] Input request received", { sessionId, inputLength: input.length, view: view ?? "chat" });
|
|
188
187
|
try {
|
|
189
188
|
const snapshot = processes.sendInput(sessionId, input, view, shortcutKey);
|
|
190
|
-
console.error("[wand] Input request succeeded", { sessionId, status: snapshot.status, inputLength: input.length, view: view ?? "chat" });
|
|
191
189
|
res.json(snapshot);
|
|
192
190
|
}
|
|
193
191
|
catch (error) {
|
package/dist/server.js
CHANGED
|
@@ -58,16 +58,12 @@ import { ensureCertificates } from "./cert.js";
|
|
|
58
58
|
import { isExecutionMode, resolveConfigDir, saveConfig } from "./config.js";
|
|
59
59
|
import { ProcessManager } from "./process-manager.js";
|
|
60
60
|
import { generatePwaManifest, generateServiceWorker } from "./pwa.js";
|
|
61
|
-
import { registerClaudeHistoryRoutes, registerSessionRoutes } from "./server-session-routes.js";
|
|
61
|
+
import { getErrorMessage, registerClaudeHistoryRoutes, registerSessionRoutes } from "./server-session-routes.js";
|
|
62
62
|
import { resolveDatabasePath, WandStorage } from "./storage.js";
|
|
63
63
|
import { renderApp } from "./web-ui/index.js";
|
|
64
64
|
import { WsBroadcastManager } from "./ws-broadcast.js";
|
|
65
65
|
import { checkRateLimit, recordFailedLogin, resetRateLimit } from "./middleware/rate-limit.js";
|
|
66
66
|
import { isPathWithinBase, isBlockedFolderPath, normalizeFolderPath } from "./middleware/path-safety.js";
|
|
67
|
-
// ── Error helpers ──
|
|
68
|
-
function getErrorMessage(error, fallback) {
|
|
69
|
-
return error instanceof Error ? error.message : fallback;
|
|
70
|
-
}
|
|
71
67
|
// ── Git helpers ──
|
|
72
68
|
async function isGitRepo(dirPath) {
|
|
73
69
|
try {
|
|
@@ -221,23 +217,6 @@ function parseStoredPathList(raw) {
|
|
|
221
217
|
return [];
|
|
222
218
|
}
|
|
223
219
|
}
|
|
224
|
-
const HIDDEN_CLAUDE_SESSIONS_KEY = "hidden_claude_sessions";
|
|
225
|
-
function getHiddenClaudeSessionIds(storage) {
|
|
226
|
-
return new Set(parseStoredPathList(storage.getConfigValue(HIDDEN_CLAUDE_SESSIONS_KEY)));
|
|
227
|
-
}
|
|
228
|
-
function saveHiddenClaudeSessionIds(storage, hidden) {
|
|
229
|
-
storage.setConfigValue(HIDDEN_CLAUDE_SESSIONS_KEY, JSON.stringify(Array.from(hidden)));
|
|
230
|
-
}
|
|
231
|
-
function removeFromHiddenClaudeSessionIds(storage, idsToRemove) {
|
|
232
|
-
const hidden = getHiddenClaudeSessionIds(storage);
|
|
233
|
-
let changed = false;
|
|
234
|
-
for (const id of idsToRemove) {
|
|
235
|
-
if (hidden.delete(id))
|
|
236
|
-
changed = true;
|
|
237
|
-
}
|
|
238
|
-
if (changed)
|
|
239
|
-
saveHiddenClaudeSessionIds(storage, hidden);
|
|
240
|
-
}
|
|
241
220
|
const MAX_RECENT_PATHS = 10;
|
|
242
221
|
// ── File language detection ──
|
|
243
222
|
function getLanguageFromExt(ext, filePath) {
|
|
@@ -289,18 +268,14 @@ export async function startServer(config, configPath) {
|
|
|
289
268
|
res.setHeader("Content-Type", "application/manifest+json");
|
|
290
269
|
res.send(generatePwaManifest());
|
|
291
270
|
});
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
271
|
+
for (const [route, size] of [["/icon.svg", 192], ["/icon-192.png", 192], ["/icon-512.png", 512]]) {
|
|
272
|
+
app.get(route, (_req, res) => {
|
|
273
|
+
res.type("image/svg+xml").send(getAvatarSvg(avatarSeed, size));
|
|
274
|
+
});
|
|
275
|
+
}
|
|
295
276
|
const iconsDir = path.resolve(existsSync(path.join(SERVER_MODULE_DIR, "web-ui", "content"))
|
|
296
277
|
? path.join(SERVER_MODULE_DIR, "web-ui", "content")
|
|
297
278
|
: path.join(RUNTIME_ROOT_DIR, "src", "web-ui", "content"));
|
|
298
|
-
app.get("/icon-192.png", (_req, res) => {
|
|
299
|
-
res.type("image/svg+xml").send(getAvatarSvg(avatarSeed, 192));
|
|
300
|
-
});
|
|
301
|
-
app.get("/icon-512.png", (_req, res) => {
|
|
302
|
-
res.type("image/svg+xml").send(getAvatarSvg(avatarSeed, 512));
|
|
303
|
-
});
|
|
304
279
|
app.get("/sw.js", (_req, res) => {
|
|
305
280
|
res.setHeader("Content-Type", "application/javascript");
|
|
306
281
|
res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
|
|
@@ -25,7 +25,6 @@ export class SessionLifecycleManager {
|
|
|
25
25
|
lastActivityAt: Date.now(),
|
|
26
26
|
};
|
|
27
27
|
this.sessions.set(sessionId, lifecycle);
|
|
28
|
-
console.error(`[Lifecycle] Session ${sessionId} registered with state: ${initialState}`);
|
|
29
28
|
}
|
|
30
29
|
/**
|
|
31
30
|
* Update session state
|
|
@@ -33,7 +32,6 @@ export class SessionLifecycleManager {
|
|
|
33
32
|
setState(sessionId, newState) {
|
|
34
33
|
const lifecycle = this.sessions.get(sessionId);
|
|
35
34
|
if (!lifecycle) {
|
|
36
|
-
console.error(`[Lifecycle] Session ${sessionId} not found`);
|
|
37
35
|
return;
|
|
38
36
|
}
|
|
39
37
|
const oldState = lifecycle.state;
|
|
@@ -43,7 +41,6 @@ export class SessionLifecycleManager {
|
|
|
43
41
|
lifecycle.state = newState;
|
|
44
42
|
lifecycle.stateSince = Date.now();
|
|
45
43
|
lifecycle.lastActivityAt = Date.now();
|
|
46
|
-
console.error(`[Lifecycle] Session ${sessionId} state changed: ${oldState} -> ${newState}`);
|
|
47
44
|
// Emit state change event
|
|
48
45
|
this.events.onStateChange?.(sessionId, oldState, newState);
|
|
49
46
|
}
|
|
@@ -89,7 +86,6 @@ export class SessionLifecycleManager {
|
|
|
89
86
|
lifecycle.stateSince = Date.now();
|
|
90
87
|
lifecycle.archivedBy = by;
|
|
91
88
|
lifecycle.archiveReason = reason;
|
|
92
|
-
console.error(`[Lifecycle] Session ${sessionId} archived: ${reason} (by: ${by})`);
|
|
93
89
|
// Emit archived event
|
|
94
90
|
this.events.onArchived?.(sessionId, reason);
|
|
95
91
|
}
|
|
@@ -98,7 +94,6 @@ export class SessionLifecycleManager {
|
|
|
98
94
|
*/
|
|
99
95
|
unregister(sessionId) {
|
|
100
96
|
this.sessions.delete(sessionId);
|
|
101
|
-
console.error(`[Lifecycle] Session ${sessionId} unregistered`);
|
|
102
97
|
}
|
|
103
98
|
/**
|
|
104
99
|
* Get session lifecycle
|
package/dist/session-logger.d.ts
CHANGED
|
@@ -3,12 +3,22 @@ import type { ConversationTurn, ExecutionMode } from "./types.js";
|
|
|
3
3
|
export interface ShortcutLogContext {
|
|
4
4
|
/** Execution mode the session is running in (e.g. "managed", "full-access") */
|
|
5
5
|
mode: ExecutionMode;
|
|
6
|
+
/** Permission scope that was approved (e.g. "run_command", "write_file") */
|
|
7
|
+
scope?: string;
|
|
6
8
|
/** Whether auto-approve is active for this session */
|
|
7
9
|
autoApprove: boolean;
|
|
8
10
|
/** Whether a permission prompt was blocking at the time of the keypress */
|
|
9
11
|
permissionBlocked: boolean;
|
|
10
12
|
/** The actual input string sent to PTY */
|
|
11
13
|
input: string;
|
|
14
|
+
/** Auto-approve detection type: "strict" | "fallback" | "idle_probe" */
|
|
15
|
+
approveType?: string;
|
|
16
|
+
/** Fallback detection score */
|
|
17
|
+
score?: number;
|
|
18
|
+
/** Fallback detection matched keywords */
|
|
19
|
+
matched?: string[];
|
|
20
|
+
/** Whether the auto-approve was a false positive */
|
|
21
|
+
falsePositive?: boolean;
|
|
12
22
|
}
|
|
13
23
|
/**
|
|
14
24
|
* SessionLogger saves raw session content to local files for debugging and analysis.
|
package/dist/types.d.ts
CHANGED
|
@@ -160,6 +160,13 @@ export interface SessionSnapshot {
|
|
|
160
160
|
resumedToSessionId?: string | null;
|
|
161
161
|
/** 服务器重启时是否自动恢复 */
|
|
162
162
|
autoRecovered?: boolean;
|
|
163
|
+
/** 自动批准统计(按类别分) */
|
|
164
|
+
approvalStats?: {
|
|
165
|
+
tool: number;
|
|
166
|
+
command: number;
|
|
167
|
+
file: number;
|
|
168
|
+
total: number;
|
|
169
|
+
};
|
|
163
170
|
}
|
|
164
171
|
export type SessionLifecycleState = "initializing" | "running" | "idle" | "thinking" | "waiting-input" | "archived";
|
|
165
172
|
export interface SessionLifecycle {
|
|
@@ -173,6 +173,12 @@
|
|
|
173
173
|
return (state.config && state.config.defaultCwd) || "/tmp";
|
|
174
174
|
}
|
|
175
175
|
|
|
176
|
+
function resetChatRenderCache() {
|
|
177
|
+
state.lastRenderedHash = 0;
|
|
178
|
+
state.lastRenderedMsgCount = 0;
|
|
179
|
+
state.lastRenderedEmpty = null;
|
|
180
|
+
}
|
|
181
|
+
|
|
176
182
|
function getEffectiveCwd() {
|
|
177
183
|
return state.workingDir || getConfigCwd();
|
|
178
184
|
}
|
|
@@ -324,9 +330,7 @@
|
|
|
324
330
|
|
|
325
331
|
app.innerHTML = isLoggedIn ? renderAppShell() : renderLogin();
|
|
326
332
|
// Reset chat render tracking since DOM was fully replaced
|
|
327
|
-
|
|
328
|
-
state.lastRenderedMsgCount = 0;
|
|
329
|
-
state.lastRenderedEmpty = null;
|
|
333
|
+
resetChatRenderCache();
|
|
330
334
|
attachEventListeners();
|
|
331
335
|
updateDrawerState();
|
|
332
336
|
syncComposerModeSelect();
|
|
@@ -367,6 +371,26 @@
|
|
|
367
371
|
'<button class="shortcut-key" data-key="escape" type="button">Esc</button>';
|
|
368
372
|
}
|
|
369
373
|
|
|
374
|
+
function renderApprovalStatsBadge() {
|
|
375
|
+
var selectedSession = state.sessions.find(function(s) { return s.id === state.selectedId; });
|
|
376
|
+
var stats = selectedSession && selectedSession.approvalStats;
|
|
377
|
+
if (!stats || stats.total === 0) return '<span class="approval-stats hidden" id="approval-stats"></span>';
|
|
378
|
+
return '<span class="approval-stats" id="approval-stats">' +
|
|
379
|
+
'<span class="approval-stats-divider"></span>' +
|
|
380
|
+
'<span class="approval-stats-badge" id="approval-stats-badge" title="本次会话自动批准统计">' +
|
|
381
|
+
'<svg class="approval-stats-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>' +
|
|
382
|
+
'<span class="approval-stats-total">' + stats.total + '</span>' +
|
|
383
|
+
'</span>' +
|
|
384
|
+
'<span class="approval-stats-popup" id="approval-stats-popup">' +
|
|
385
|
+
'<span class="approval-stats-popup-title">自动批准统计</span>' +
|
|
386
|
+
(stats.command > 0 ? '<span class="approval-stats-row"><span class="approval-stats-row-icon">⚡</span><span class="approval-stats-row-label">命令执行</span><span class="approval-stats-row-count">' + stats.command + '</span></span>' : '') +
|
|
387
|
+
(stats.file > 0 ? '<span class="approval-stats-row"><span class="approval-stats-row-icon">📝</span><span class="approval-stats-row-label">文件写入</span><span class="approval-stats-row-count">' + stats.file + '</span></span>' : '') +
|
|
388
|
+
(stats.tool > 0 ? '<span class="approval-stats-row"><span class="approval-stats-row-icon">🔧</span><span class="approval-stats-row-label">其他工具</span><span class="approval-stats-row-count">' + stats.tool + '</span></span>' : '') +
|
|
389
|
+
'<span class="approval-stats-row approval-stats-row-total"><span class="approval-stats-row-icon">∑</span><span class="approval-stats-row-label">合计</span><span class="approval-stats-row-count">' + stats.total + '</span></span>' +
|
|
390
|
+
'</span>' +
|
|
391
|
+
'</span>';
|
|
392
|
+
}
|
|
393
|
+
|
|
370
394
|
function renderInlineKeyboard() {
|
|
371
395
|
if (!state.selectedId) return "";
|
|
372
396
|
var isTerminal = state.currentView === "terminal";
|
|
@@ -462,6 +486,12 @@
|
|
|
462
486
|
'<span class="session-count" id="session-count">' + String(state.sessions.length) + '</span>' +
|
|
463
487
|
'</div>' +
|
|
464
488
|
'<div class="sidebar-header-actions">' +
|
|
489
|
+
'<button id="sidebar-home-btn" class="btn btn-ghost btn-sm" type="button" title="回到首页">' +
|
|
490
|
+
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>' +
|
|
491
|
+
'</button>' +
|
|
492
|
+
'<button id="sidebar-refresh-btn" class="btn btn-ghost btn-sm" type="button" title="刷新页面">' +
|
|
493
|
+
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>' +
|
|
494
|
+
'</button>' +
|
|
465
495
|
'<button id="close-drawer-button" class="btn btn-ghost btn-sm sidebar-close" type="button" aria-label="关闭菜单">×</button>' +
|
|
466
496
|
'</div>' +
|
|
467
497
|
'</div>' +
|
|
@@ -569,6 +599,7 @@
|
|
|
569
599
|
'<button id="approve-permission-btn" class="btn btn-permission btn-permission-approve" type="button">批准</button>' +
|
|
570
600
|
'<button id="deny-permission-btn" class="btn btn-permission btn-permission-deny" type="button">拒绝</button>' +
|
|
571
601
|
'</span>' +
|
|
602
|
+
renderApprovalStatsBadge() +
|
|
572
603
|
'</div>' +
|
|
573
604
|
'<div class="input-composer-right">' +
|
|
574
605
|
'<span id="queue-counter" class="queue-counter hidden">队列: 0</span>' +
|
|
@@ -1904,6 +1935,18 @@
|
|
|
1904
1935
|
if (drawerBackdrop) drawerBackdrop.addEventListener("click", closeSessionsDrawer);
|
|
1905
1936
|
var closeDrawerBtn = document.getElementById("close-drawer-button");
|
|
1906
1937
|
if (closeDrawerBtn) closeDrawerBtn.addEventListener("click", closeSessionsDrawer);
|
|
1938
|
+
var homeBtn = document.getElementById("sidebar-home-btn");
|
|
1939
|
+
if (homeBtn) homeBtn.addEventListener("click", function() {
|
|
1940
|
+
state.selectedId = null;
|
|
1941
|
+
persistSelectedId();
|
|
1942
|
+
resetChatRenderCache();
|
|
1943
|
+
closeSessionsDrawer();
|
|
1944
|
+
renderApp();
|
|
1945
|
+
});
|
|
1946
|
+
var refreshBtn = document.getElementById("sidebar-refresh-btn");
|
|
1947
|
+
if (refreshBtn) refreshBtn.addEventListener("click", function() {
|
|
1948
|
+
window.location.reload();
|
|
1949
|
+
});
|
|
1907
1950
|
var logoutBtn = document.getElementById("logout-button");
|
|
1908
1951
|
if (logoutBtn) logoutBtn.addEventListener("click", logout);
|
|
1909
1952
|
var settingsBtn = document.getElementById("settings-button");
|
|
@@ -3587,9 +3630,7 @@
|
|
|
3587
3630
|
function selectSession(id) {
|
|
3588
3631
|
state.selectedId = id;
|
|
3589
3632
|
persistSelectedId();
|
|
3590
|
-
|
|
3591
|
-
state.lastRenderedMsgCount = 0;
|
|
3592
|
-
state.lastRenderedEmpty = null;
|
|
3633
|
+
resetChatRenderCache();
|
|
3593
3634
|
state.currentMessages = [];
|
|
3594
3635
|
if (chatRenderTimer) { clearTimeout(chatRenderTimer); chatRenderTimer = null; }
|
|
3595
3636
|
// Reset todo progress bar
|
|
@@ -4176,9 +4217,7 @@
|
|
|
4176
4217
|
state.selectedId = data.id;
|
|
4177
4218
|
persistSelectedId();
|
|
4178
4219
|
state.drafts[data.id] = "";
|
|
4179
|
-
|
|
4180
|
-
state.lastRenderedMsgCount = 0;
|
|
4181
|
-
state.lastRenderedEmpty = null;
|
|
4220
|
+
resetChatRenderCache();
|
|
4182
4221
|
return refreshAll();
|
|
4183
4222
|
})
|
|
4184
4223
|
.then(function() { focusInputBox(true); })
|
|
@@ -4221,9 +4260,7 @@
|
|
|
4221
4260
|
state.selectedId = data.id;
|
|
4222
4261
|
persistSelectedId();
|
|
4223
4262
|
state.drafts[data.id] = "";
|
|
4224
|
-
|
|
4225
|
-
state.lastRenderedMsgCount = 0;
|
|
4226
|
-
state.lastRenderedEmpty = null;
|
|
4263
|
+
resetChatRenderCache();
|
|
4227
4264
|
closeSessionModal();
|
|
4228
4265
|
closeSessionsDrawer();
|
|
4229
4266
|
return refreshAll();
|
|
@@ -4653,9 +4690,7 @@
|
|
|
4653
4690
|
state.selectedId = data.id;
|
|
4654
4691
|
persistSelectedId();
|
|
4655
4692
|
state.drafts[data.id] = "";
|
|
4656
|
-
|
|
4657
|
-
state.lastRenderedMsgCount = 0;
|
|
4658
|
-
state.lastRenderedEmpty = null;
|
|
4693
|
+
resetChatRenderCache();
|
|
4659
4694
|
switchToSessionView(data.id);
|
|
4660
4695
|
updateSessionSnapshot(data);
|
|
4661
4696
|
updateSessionsList();
|
|
@@ -4711,9 +4746,7 @@
|
|
|
4711
4746
|
state.selectedId = data.id;
|
|
4712
4747
|
persistSelectedId();
|
|
4713
4748
|
state.drafts[data.id] = "";
|
|
4714
|
-
|
|
4715
|
-
state.lastRenderedMsgCount = 0;
|
|
4716
|
-
state.lastRenderedEmpty = null;
|
|
4749
|
+
resetChatRenderCache();
|
|
4717
4750
|
if (inputBox) inputBox.value = "";
|
|
4718
4751
|
if (welcomeInput) welcomeInput.value = "";
|
|
4719
4752
|
switchToSessionView(data.id);
|
|
@@ -5601,9 +5634,7 @@
|
|
|
5601
5634
|
|
|
5602
5635
|
function activateSession(data) {
|
|
5603
5636
|
if (!data || !data.id) return Promise.resolve();
|
|
5604
|
-
|
|
5605
|
-
state.lastRenderedMsgCount = 0;
|
|
5606
|
-
state.lastRenderedEmpty = null;
|
|
5637
|
+
resetChatRenderCache();
|
|
5607
5638
|
switchToSessionView(data.id);
|
|
5608
5639
|
updateSessionSnapshot(data);
|
|
5609
5640
|
updateSessionsList();
|
|
@@ -7056,9 +7087,15 @@
|
|
|
7056
7087
|
if (msg.data.permissionBlocked === false) {
|
|
7057
7088
|
statusUpdate.pendingEscalation = null;
|
|
7058
7089
|
}
|
|
7090
|
+
if (msg.data.approvalStats) {
|
|
7091
|
+
statusUpdate.approvalStats = msg.data.approvalStats;
|
|
7092
|
+
}
|
|
7059
7093
|
updateSessionSnapshot(statusUpdate);
|
|
7060
7094
|
if (msg.sessionId === state.selectedId) {
|
|
7061
7095
|
updateTaskDisplay();
|
|
7096
|
+
if (msg.data.approvalStats) {
|
|
7097
|
+
updateApprovalStats();
|
|
7098
|
+
}
|
|
7062
7099
|
}
|
|
7063
7100
|
}
|
|
7064
7101
|
break;
|
|
@@ -7128,6 +7165,39 @@
|
|
|
7128
7165
|
}
|
|
7129
7166
|
}
|
|
7130
7167
|
|
|
7168
|
+
function updateApprovalStats() {
|
|
7169
|
+
var container = document.getElementById("approval-stats");
|
|
7170
|
+
if (!container) return;
|
|
7171
|
+
var selectedSession = state.sessions.find(function(s) { return s.id === state.selectedId; });
|
|
7172
|
+
var stats = selectedSession && selectedSession.approvalStats;
|
|
7173
|
+
if (!stats || stats.total === 0) {
|
|
7174
|
+
container.className = "approval-stats hidden";
|
|
7175
|
+
container.innerHTML = "";
|
|
7176
|
+
return;
|
|
7177
|
+
}
|
|
7178
|
+
container.className = "approval-stats";
|
|
7179
|
+
container.innerHTML =
|
|
7180
|
+
'<span class="approval-stats-divider"></span>' +
|
|
7181
|
+
'<span class="approval-stats-badge" id="approval-stats-badge" title="本次会话自动批准统计">' +
|
|
7182
|
+
'<svg class="approval-stats-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>' +
|
|
7183
|
+
'<span class="approval-stats-total">' + stats.total + '</span>' +
|
|
7184
|
+
'</span>' +
|
|
7185
|
+
'<span class="approval-stats-popup" id="approval-stats-popup">' +
|
|
7186
|
+
'<span class="approval-stats-popup-title">自动批准统计</span>' +
|
|
7187
|
+
(stats.command > 0 ? '<span class="approval-stats-row"><span class="approval-stats-row-icon">⚡</span><span class="approval-stats-row-label">命令执行</span><span class="approval-stats-row-count">' + stats.command + '</span></span>' : '') +
|
|
7188
|
+
(stats.file > 0 ? '<span class="approval-stats-row"><span class="approval-stats-row-icon">📝</span><span class="approval-stats-row-label">文件写入</span><span class="approval-stats-row-count">' + stats.file + '</span></span>' : '') +
|
|
7189
|
+
(stats.tool > 0 ? '<span class="approval-stats-row"><span class="approval-stats-row-icon">🔧</span><span class="approval-stats-row-label">其他工具</span><span class="approval-stats-row-count">' + stats.tool + '</span></span>' : '') +
|
|
7190
|
+
'<span class="approval-stats-row approval-stats-row-total"><span class="approval-stats-row-icon">∑</span><span class="approval-stats-row-label">合计</span><span class="approval-stats-row-count">' + stats.total + '</span></span>' +
|
|
7191
|
+
'</span>';
|
|
7192
|
+
// Pulse animation on the badge
|
|
7193
|
+
var badge = container.querySelector(".approval-stats-badge");
|
|
7194
|
+
if (badge) {
|
|
7195
|
+
badge.classList.remove("approval-stats-pulse");
|
|
7196
|
+
void badge.offsetWidth;
|
|
7197
|
+
badge.classList.add("approval-stats-pulse");
|
|
7198
|
+
}
|
|
7199
|
+
}
|
|
7200
|
+
|
|
7131
7201
|
function approvePermission() {
|
|
7132
7202
|
if (!state.selectedId) return;
|
|
7133
7203
|
var approveBtn = document.getElementById("approve-permission-btn");
|
|
@@ -269,8 +269,9 @@
|
|
|
269
269
|
}
|
|
270
270
|
|
|
271
271
|
.floating-sidebar-toggle.active {
|
|
272
|
-
|
|
273
|
-
|
|
272
|
+
opacity: 0;
|
|
273
|
+
pointer-events: none;
|
|
274
|
+
transition: opacity 0.15s ease;
|
|
274
275
|
}
|
|
275
276
|
|
|
276
277
|
.floating-sidebar-toggle:active {
|
|
@@ -3429,6 +3430,114 @@
|
|
|
3429
3430
|
to { opacity: 1; transform: translateX(0); }
|
|
3430
3431
|
}
|
|
3431
3432
|
|
|
3433
|
+
/* Approval stats badge */
|
|
3434
|
+
.approval-stats {
|
|
3435
|
+
display: inline-flex;
|
|
3436
|
+
align-items: center;
|
|
3437
|
+
position: relative;
|
|
3438
|
+
}
|
|
3439
|
+
.approval-stats.hidden {
|
|
3440
|
+
display: none;
|
|
3441
|
+
}
|
|
3442
|
+
.approval-stats-divider {
|
|
3443
|
+
width: 1px;
|
|
3444
|
+
height: 16px;
|
|
3445
|
+
background: var(--border);
|
|
3446
|
+
margin: 0 4px 0 2px;
|
|
3447
|
+
}
|
|
3448
|
+
.approval-stats-badge {
|
|
3449
|
+
display: inline-flex;
|
|
3450
|
+
align-items: center;
|
|
3451
|
+
gap: 3px;
|
|
3452
|
+
padding: 1px 7px 1px 5px;
|
|
3453
|
+
border-radius: 10px;
|
|
3454
|
+
background: rgba(34, 197, 94, 0.1);
|
|
3455
|
+
color: #22c55e;
|
|
3456
|
+
font-size: 0.68rem;
|
|
3457
|
+
font-weight: 600;
|
|
3458
|
+
cursor: default;
|
|
3459
|
+
transition: background 0.15s;
|
|
3460
|
+
line-height: 1.5;
|
|
3461
|
+
}
|
|
3462
|
+
.approval-stats-badge:hover {
|
|
3463
|
+
background: rgba(34, 197, 94, 0.18);
|
|
3464
|
+
}
|
|
3465
|
+
.approval-stats-icon {
|
|
3466
|
+
stroke: #22c55e;
|
|
3467
|
+
flex-shrink: 0;
|
|
3468
|
+
}
|
|
3469
|
+
.approval-stats-total {
|
|
3470
|
+
font-variant-numeric: tabular-nums;
|
|
3471
|
+
}
|
|
3472
|
+
@keyframes approval-pulse {
|
|
3473
|
+
0% { transform: scale(1); }
|
|
3474
|
+
50% { transform: scale(1.15); }
|
|
3475
|
+
100% { transform: scale(1); }
|
|
3476
|
+
}
|
|
3477
|
+
.approval-stats-pulse {
|
|
3478
|
+
animation: approval-pulse 0.3s ease-out;
|
|
3479
|
+
}
|
|
3480
|
+
/* Popup tooltip */
|
|
3481
|
+
.approval-stats-popup {
|
|
3482
|
+
display: none;
|
|
3483
|
+
position: absolute;
|
|
3484
|
+
bottom: calc(100% + 6px);
|
|
3485
|
+
left: 50%;
|
|
3486
|
+
transform: translateX(-50%);
|
|
3487
|
+
background: var(--bg-elevated, #1e1e2e);
|
|
3488
|
+
border: 1px solid var(--border);
|
|
3489
|
+
border-radius: 8px;
|
|
3490
|
+
padding: 8px 10px;
|
|
3491
|
+
min-width: 140px;
|
|
3492
|
+
box-shadow: 0 4px 16px rgba(0,0,0,0.25);
|
|
3493
|
+
z-index: 100;
|
|
3494
|
+
flex-direction: column;
|
|
3495
|
+
gap: 4px;
|
|
3496
|
+
}
|
|
3497
|
+
.approval-stats:hover .approval-stats-popup {
|
|
3498
|
+
display: flex;
|
|
3499
|
+
}
|
|
3500
|
+
.approval-stats-popup-title {
|
|
3501
|
+
font-size: 0.65rem;
|
|
3502
|
+
color: var(--text-muted);
|
|
3503
|
+
font-weight: 500;
|
|
3504
|
+
margin-bottom: 2px;
|
|
3505
|
+
white-space: nowrap;
|
|
3506
|
+
}
|
|
3507
|
+
.approval-stats-row {
|
|
3508
|
+
display: flex;
|
|
3509
|
+
align-items: center;
|
|
3510
|
+
gap: 6px;
|
|
3511
|
+
font-size: 0.7rem;
|
|
3512
|
+
color: var(--text-secondary, #ccc);
|
|
3513
|
+
white-space: nowrap;
|
|
3514
|
+
}
|
|
3515
|
+
.approval-stats-row-icon {
|
|
3516
|
+
width: 16px;
|
|
3517
|
+
text-align: center;
|
|
3518
|
+
flex-shrink: 0;
|
|
3519
|
+
font-size: 0.72rem;
|
|
3520
|
+
}
|
|
3521
|
+
.approval-stats-row-label {
|
|
3522
|
+
flex: 1;
|
|
3523
|
+
}
|
|
3524
|
+
.approval-stats-row-count {
|
|
3525
|
+
font-weight: 600;
|
|
3526
|
+
color: var(--text-primary, #eee);
|
|
3527
|
+
font-variant-numeric: tabular-nums;
|
|
3528
|
+
min-width: 20px;
|
|
3529
|
+
text-align: right;
|
|
3530
|
+
}
|
|
3531
|
+
.approval-stats-row-total {
|
|
3532
|
+
border-top: 1px solid var(--border);
|
|
3533
|
+
padding-top: 4px;
|
|
3534
|
+
margin-top: 2px;
|
|
3535
|
+
color: #22c55e;
|
|
3536
|
+
}
|
|
3537
|
+
.approval-stats-row-total .approval-stats-row-count {
|
|
3538
|
+
color: #22c55e;
|
|
3539
|
+
}
|
|
3540
|
+
|
|
3432
3541
|
.composer-interactive-toggle {
|
|
3433
3542
|
display: inline-flex;
|
|
3434
3543
|
align-items: center;
|