@co0ontty/wand 1.3.3 → 1.3.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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 {
@@ -499,7 +359,7 @@ function shouldBackfillClaudeSessionId(record) {
499
359
  function snapshotMessages(record) {
500
360
  return record.ptyBridge?.getMessages() ?? record.messages;
501
361
  }
502
- const MAX_SESSIONS = 50;
362
+ const MAX_SESSIONS = 200;
503
363
  const ARCHIVE_AFTER_MS = 1000 * 60 * 60 * 24;
504
364
  const CONFIRM_WINDOW_SIZE = 800;
505
365
  // Claude 会话 ID 格式:UUID v4
@@ -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
  }
@@ -651,24 +511,33 @@ export class ProcessManager extends EventEmitter {
651
511
  this.emit("process", event);
652
512
  }
653
513
  cleanupOldSessions() {
654
- // Remove oldest finished sessions if we're at the limit
514
+ // Only clean up when well over the limit
655
515
  if (this.sessions.size < MAX_SESSIONS)
656
516
  return;
657
- const finishedIds = [];
517
+ const now = Date.now();
518
+ const STALE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
519
+ const removable = [];
658
520
  for (const [id, record] of this.sessions) {
659
- if (record.status !== "running") {
660
- finishedIds.push(id);
521
+ // Only remove archived, non-running sessions older than 7 days
522
+ if (record.status === "running")
523
+ continue;
524
+ if (!record.archived)
525
+ continue;
526
+ const ref = record.endedAt ?? record.startedAt;
527
+ const refMs = Date.parse(ref);
528
+ if (Number.isFinite(refMs) && now - refMs > STALE_MS) {
529
+ removable.push(id);
661
530
  }
662
531
  }
663
- // Remove oldest finished sessions first
664
- finishedIds
532
+ // Sort oldest first and remove enough to get back under the limit
533
+ const toRemove = removable
665
534
  .sort((a, b) => {
666
535
  const ra = this.sessions.get(a);
667
536
  const rb = this.sessions.get(b);
668
537
  return (ra?.endedAt || "").localeCompare(rb?.endedAt || "");
669
538
  })
670
- .slice(0, this.sessions.size - MAX_SESSIONS + 1)
671
- .forEach((id) => {
539
+ .slice(0, this.sessions.size - MAX_SESSIONS + 1);
540
+ for (const id of toRemove) {
672
541
  const record = this.sessions.get(id);
673
542
  if (record) {
674
543
  this.logger.deleteSession(id);
@@ -677,7 +546,7 @@ export class ProcessManager extends EventEmitter {
677
546
  this.sessions.delete(id);
678
547
  this.lastPersistedMessageCount.delete(id);
679
548
  this.storage.deleteSession(id);
680
- });
549
+ }
681
550
  }
682
551
  start(command, cwd, mode, initialInput, opts) {
683
552
  this.assertCommandAllowed(command);
@@ -732,7 +601,8 @@ export class ProcessManager extends EventEmitter {
732
601
  lastEmittedTask: null,
733
602
  knownClaudeTaskIds: knownClaudeTaskIds ?? undefined,
734
603
  claudeTaskDiscoveryTimer: null,
735
- knownClaudeProjectMtimes: knownClaudeProjectMtimes ?? undefined
604
+ knownClaudeProjectMtimes: knownClaudeProjectMtimes ?? undefined,
605
+ approvalStats: { tool: 0, command: 0, file: 0, total: 0 }
736
606
  };
737
607
  // Create PTY bridge for this session
738
608
  record.ptyBridge = new ClaudePtyBridge({
@@ -835,7 +705,7 @@ export class ProcessManager extends EventEmitter {
835
705
  process.stderr.write(`[wand] Cannot send initial input: session not ready\n`);
836
706
  return;
837
707
  }
838
- process.stderr.write(`[wand] Sending initial input: ${initialInput}\n`);
708
+ process.stderr.write(`[wand] Sending initial input (${initialInput.length} chars)\n`);
839
709
  // Track initial input via bridge for Chat mode
840
710
  if (current.ptyBridge) {
841
711
  current.ptyBridge.onUserInput(initialInput);
@@ -989,8 +859,10 @@ export class ProcessManager extends EventEmitter {
989
859
  get(id) {
990
860
  this.archiveExpiredSessions();
991
861
  const record = this.sessions.get(id);
992
- if (!record)
993
- return null;
862
+ if (!record) {
863
+ // Fallback: check SQLite for sessions that were evicted from memory
864
+ return this.storage.getSession(id) ?? null;
865
+ }
994
866
  // For sessions loaded from storage on startup, in-memory output starts empty.
995
867
  // Prefer in-memory output (live PTY data), fall back to stored output.
996
868
  if (!record.output && record.storedOutput) {
@@ -1001,35 +873,16 @@ export class ProcessManager extends EventEmitter {
1001
873
  sendInput(id, input, view, shortcutKey) {
1002
874
  const record = this.mustGet(id);
1003
875
  if (record.status !== "running") {
1004
- console.error("[ProcessManager] Rejecting input for non-running session", {
1005
- sessionId: id,
1006
- status: record.status,
1007
- hasPty: !!record.ptyProcess,
1008
- inputLength: input.length,
1009
- view: view ?? "chat"
1010
- });
876
+ console.error(`[ProcessManager] Rejecting input: session ${id} not running (${record.status})`);
1011
877
  throw new SessionInputError("Session is not running.", "SESSION_NOT_RUNNING", id, record.status);
1012
878
  }
1013
879
  // Update lifecycle
1014
880
  this.lifecycleManager.touch(id);
1015
881
  this.lifecycleManager.startThinking(id);
1016
882
  if (!record.ptyProcess) {
1017
- console.error("[ProcessManager] Rejecting input because PTY is missing", {
1018
- sessionId: id,
1019
- status: record.status,
1020
- hasPty: !!record.ptyProcess,
1021
- inputLength: input.length,
1022
- view: view ?? "chat"
1023
- });
883
+ console.error(`[ProcessManager] Rejecting input: session ${id} has no PTY`);
1024
884
  throw new SessionInputError("Session is not running.", "SESSION_NO_PTY", id, record.status);
1025
885
  }
1026
- console.error("[ProcessManager] Sending input to session", {
1027
- sessionId: id,
1028
- status: record.status,
1029
- hasPty: !!record.ptyProcess,
1030
- inputLength: input.length,
1031
- view: view ?? "chat"
1032
- });
1033
886
  // Log shortcut key interactions for auto-confirm and mode analysis
1034
887
  if (shortcutKey) {
1035
888
  const outputLines = record.output.split("\n");
@@ -1242,7 +1095,8 @@ export class ProcessManager extends EventEmitter {
1242
1095
  messages: messages.length > 0 ? messages : undefined,
1243
1096
  resumedFromSessionId: record.resumedFromSessionId ?? undefined,
1244
1097
  resumedToSessionId: record.resumedToSessionId ?? undefined,
1245
- autoRecovered: record.autoRecovered ?? false
1098
+ autoRecovered: record.autoRecovered ?? false,
1099
+ approvalStats: record.approvalStats.total > 0 ? record.approvalStats : undefined
1246
1100
  };
1247
1101
  }
1248
1102
  isPermissionBlocked(record) {
@@ -1482,11 +1336,9 @@ export class ProcessManager extends EventEmitter {
1482
1336
  || claudeConfirmPrompt
1483
1337
  || toolPermissionPrompt
1484
1338
  || (hasExplicitConfirmSyntax(normalized)
1485
- && hasPermissionActionContext(normalized)
1486
- && PROMPT_PATTERNS.some((pattern) => pattern.test(normalized)));
1339
+ && hasPermissionActionContext(normalized));
1487
1340
  if (shouldConfirm) {
1488
1341
  record.lastAutoConfirmAt = now;
1489
- process.stderr.write(`[wand] Auto-confirming prompt for ${record.mode} mode\n`);
1490
1342
  // Always auto-confirm by sending Enter directly
1491
1343
  ptyProcess.write("\r");
1492
1344
  }
@@ -1551,15 +1403,50 @@ export class ProcessManager extends EventEmitter {
1551
1403
  });
1552
1404
  break;
1553
1405
  }
1554
- 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
+ }
1555
1421
  record.pendingEscalation = null;
1556
1422
  record.ptyPermissionBlocked = false;
1557
1423
  this.emitEvent({
1558
1424
  type: "status",
1559
1425
  sessionId: event.sessionId,
1560
- data: { permissionBlocked: false },
1426
+ data: {
1427
+ permissionBlocked: false,
1428
+ approvalStats: record.approvalStats,
1429
+ },
1561
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
+ }
1562
1448
  break;
1449
+ }
1563
1450
  case "session.id":
1564
1451
  // Claude session ID captured - already handled in onData
1565
1452
  break;
@@ -1622,15 +1509,25 @@ export class ProcessManager extends EventEmitter {
1622
1509
  return false;
1623
1510
  }
1624
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
+ }
1625
1522
  // In managed mode, append a system prompt instructing Claude to act autonomously
1626
1523
  // without asking the user for confirmation, since the user may not be monitoring.
1627
- if (mode === "managed" && /^(?:claude|npx\s+claude|[^\s]+\/claude)(?:\s|$)/.test(command)) {
1524
+ if (mode === "managed") {
1628
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.";
1629
1526
  // Escape single quotes for shell safety
1630
1527
  const escaped = autonomousPrompt.replace(/'/g, "'\\''");
1631
- return `${command} --append-system-prompt '${escaped}'`;
1528
+ result += ` --append-system-prompt '${escaped}'`;
1632
1529
  }
1633
- return command;
1530
+ return result;
1634
1531
  }
1635
1532
  }
1636
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 {