@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.
@@ -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
  }
@@ -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, hasLiveProjectConversation, hasRealConversationMessages, hasStoredProjectConversation, shouldAllowResume, shouldDisplayResumeAction, } from "./resume-policy.js";
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: (sessionId) => {
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: ${initialInput}\n`);
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("[ProcessManager] Rejecting input for non-running session", {
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("[ProcessManager] Rejecting input because PTY is missing", {
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: { permissionBlocked: false },
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" && /^(?:claude|npx\s+claude|[^\s]+\/claude)(?:\s|$)/.test(command)) {
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
- return `${command} --append-system-prompt '${escaped}'`;
1528
+ result += ` --append-system-prompt '${escaped}'`;
1643
1529
  }
1644
- return command;
1530
+ return result;
1645
1531
  }
1646
1532
  }
1647
1533
  function clampDimension(value, min, max) {
@@ -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 {};
@@ -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.*\bno\b/i,
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
- app.get("/icon.svg", (_req, res) => {
293
- res.type("image/svg+xml").send(getAvatarSvg(avatarSeed, 192));
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
@@ -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
- state.lastRenderedHash = 0;
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
- state.lastRenderedHash = 0;
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
- state.lastRenderedHash = 0;
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
- state.lastRenderedHash = 0;
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
- state.lastRenderedHash = 0;
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
- state.lastRenderedHash = 0;
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
- state.lastRenderedHash = 0;
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
- background: rgba(197, 101, 61, 0.1);
273
- border-color: rgba(197, 101, 61, 0.2);
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@co0ontty/wand",
3
- "version": "1.3.4",
3
+ "version": "1.3.6",
4
4
  "description": "A web terminal for local CLI tools like Claude.",
5
5
  "type": "module",
6
6
  "bin": {