create-walle 0.9.24 → 0.9.26

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.
Files changed (183) hide show
  1. package/README.md +8 -0
  2. package/bin/create-walle.js +815 -45
  3. package/package.json +2 -2
  4. package/template/bin/ctm-dev-cleanup.js +90 -4
  5. package/template/bin/ctm-launch.sh +49 -1
  6. package/template/bin/dev.sh +45 -1
  7. package/template/bin/ensure-stable-node.js +132 -0
  8. package/template/bin/install-service.sh +9 -0
  9. package/template/claude-task-manager/api-prompts.js +899 -119
  10. package/template/claude-task-manager/approval-agent.js +360 -40
  11. package/template/claude-task-manager/bin/ctm-disclaim.c +42 -0
  12. package/template/claude-task-manager/bin/ctm-hotkey.swift +67 -81
  13. package/template/claude-task-manager/bin/ctm-screen-auth.swift +37 -0
  14. package/template/claude-task-manager/bin/install-hotkey.sh +97 -49
  15. package/template/claude-task-manager/bin/restart-ctm.sh +14 -0
  16. package/template/claude-task-manager/db.js +399 -48
  17. package/template/claude-task-manager/docs/approval-hook-sandbox.md +84 -0
  18. package/template/claude-task-manager/docs/codex-app-server-approvals.md +72 -0
  19. package/template/claude-task-manager/docs/codex-native-sandbox.md +47 -0
  20. package/template/claude-task-manager/docs/prompt-editing-tree-design.md +18 -1
  21. package/template/claude-task-manager/lib/approval-hook.js +200 -0
  22. package/template/claude-task-manager/lib/approval-self-adapt.js +1 -0
  23. package/template/claude-task-manager/lib/auth-rules.js +11 -0
  24. package/template/claude-task-manager/lib/background-llm.js +32 -4
  25. package/template/claude-task-manager/lib/codesign-identity.js +140 -0
  26. package/template/claude-task-manager/lib/codex-app-server-client.js +119 -0
  27. package/template/claude-task-manager/lib/codex-approval-bridge.js +118 -0
  28. package/template/claude-task-manager/lib/codex-history-terminal-renderer.js +571 -0
  29. package/template/claude-task-manager/lib/codex-paths.js +73 -0
  30. package/template/claude-task-manager/lib/codex-rollout-snapshot.js +164 -0
  31. package/template/claude-task-manager/lib/codex-rollout-tail.js +72 -0
  32. package/template/claude-task-manager/lib/codex-sandbox-args.js +47 -0
  33. package/template/claude-task-manager/lib/coding-agent-models.js +118 -71
  34. package/template/claude-task-manager/lib/command-targets.js +163 -0
  35. package/template/claude-task-manager/lib/conversation-tail-merge.js +61 -19
  36. package/template/claude-task-manager/lib/db-owner-worker-client.js +29 -1
  37. package/template/claude-task-manager/lib/escalation-review.js +80 -3
  38. package/template/claude-task-manager/lib/flow-control.js +52 -0
  39. package/template/claude-task-manager/lib/fs-watcher.js +24 -15
  40. package/template/claude-task-manager/lib/ingest-cooldown.js +68 -0
  41. package/template/claude-task-manager/lib/jsonl-conversation-parser.js +8 -4
  42. package/template/claude-task-manager/lib/launchd-recovery.js +92 -0
  43. package/template/claude-task-manager/lib/microsoft-dev-tunnel-setup.js +207 -52
  44. package/template/claude-task-manager/lib/mobile-push-store.js +7 -0
  45. package/template/claude-task-manager/lib/model-overview-brain-fallback.js +102 -1
  46. package/template/claude-task-manager/lib/model-overview-cache.js +1 -0
  47. package/template/claude-task-manager/lib/oauth-proxy-supervisor.js +2 -1
  48. package/template/claude-task-manager/lib/perf-tracker.js +29 -2
  49. package/template/claude-task-manager/lib/permission-match.js +146 -16
  50. package/template/claude-task-manager/lib/project-slug.js +33 -0
  51. package/template/claude-task-manager/lib/prompt-intent.js +51 -4
  52. package/template/claude-task-manager/lib/read-pool-client.js +48 -3
  53. package/template/claude-task-manager/lib/real-node.js +73 -0
  54. package/template/claude-task-manager/lib/runtime-work-registry.js +131 -14
  55. package/template/claude-task-manager/lib/session-content-backfill.js +24 -5
  56. package/template/claude-task-manager/lib/session-diagnostics-batch.js +87 -0
  57. package/template/claude-task-manager/lib/session-history.js +5 -7
  58. package/template/claude-task-manager/lib/session-host-manager.js +19 -0
  59. package/template/claude-task-manager/lib/session-jobs.js +6 -0
  60. package/template/claude-task-manager/lib/session-message-response-cache.js +89 -0
  61. package/template/claude-task-manager/lib/session-messages-page.js +211 -0
  62. package/template/claude-task-manager/lib/session-messages-projection.js +170 -0
  63. package/template/claude-task-manager/lib/session-standup.js +8 -0
  64. package/template/claude-task-manager/lib/session-timeline-summary.js +16 -2
  65. package/template/claude-task-manager/lib/session-token-usage.js +30 -8
  66. package/template/claude-task-manager/lib/session-workspace-binding.js +29 -15
  67. package/template/claude-task-manager/lib/storage-migration.js +2 -1
  68. package/template/claude-task-manager/lib/transcript-store.js +179 -12
  69. package/template/claude-task-manager/lib/walle-ctm-history.js +298 -11
  70. package/template/claude-task-manager/lib/walle-permission-reply.js +49 -0
  71. package/template/claude-task-manager/lib/walle-session-cache.js +22 -1
  72. package/template/claude-task-manager/lib/walle-supervisor.js +42 -3
  73. package/template/claude-task-manager/package.json +5 -2
  74. package/template/claude-task-manager/prompt-harvest.js +31 -11
  75. package/template/claude-task-manager/providers/claude-code.js +29 -1
  76. package/template/claude-task-manager/providers/codex.js +13 -1
  77. package/template/claude-task-manager/public/css/setup.css +11 -0
  78. package/template/claude-task-manager/public/css/walle-session.css +132 -4
  79. package/template/claude-task-manager/public/css/walle.css +89 -0
  80. package/template/claude-task-manager/public/icon-16.png +0 -0
  81. package/template/claude-task-manager/public/icon-32.png +0 -0
  82. package/template/claude-task-manager/public/icon-512.png +0 -0
  83. package/template/claude-task-manager/public/index.html +2483 -165
  84. package/template/claude-task-manager/public/js/activation-render-check.js +55 -0
  85. package/template/claude-task-manager/public/js/flow-control-policy.js +52 -0
  86. package/template/claude-task-manager/public/js/message-renderer.js +60 -1
  87. package/template/claude-task-manager/public/js/prompts.js +13 -1
  88. package/template/claude-task-manager/public/js/session-status-precedence.js +9 -3
  89. package/template/claude-task-manager/public/js/setup.js +54 -10
  90. package/template/claude-task-manager/public/js/stream-resize-policy.js +80 -0
  91. package/template/claude-task-manager/public/js/stream-view.js +78 -0
  92. package/template/claude-task-manager/public/js/terminal-reconciler.js +52 -2
  93. package/template/claude-task-manager/public/js/tool-state.js +155 -0
  94. package/template/claude-task-manager/public/js/walle-session.js +887 -326
  95. package/template/claude-task-manager/public/js/walle.js +306 -195
  96. package/template/claude-task-manager/public/m/app.css +1 -0
  97. package/template/claude-task-manager/public/m/app.js +33 -3
  98. package/template/claude-task-manager/queue-engine.js +45 -1
  99. package/template/claude-task-manager/server.js +3367 -540
  100. package/template/claude-task-manager/workers/approval-blocklist.js +130 -17
  101. package/template/claude-task-manager/workers/db-owner-worker.js +31 -1
  102. package/template/claude-task-manager/workers/read-pool-worker.js +92 -5
  103. package/template/claude-task-manager/workers/session-host-process.js +10 -0
  104. package/template/claude-task-manager/workers/state-detectors/codex.js +58 -7
  105. package/template/package.json +2 -3
  106. package/template/shared/icons/AppIcon-ctm.icns +0 -0
  107. package/template/shared/icons/AppIcon-walle.icns +0 -0
  108. package/template/wall-e/agent.js +139 -18
  109. package/template/wall-e/api-walle.js +201 -22
  110. package/template/wall-e/bin/train-gemma-e4b-tooluse.js +1981 -0
  111. package/template/wall-e/brain.js +1053 -43
  112. package/template/wall-e/chat.js +427 -86
  113. package/template/wall-e/coding/acceptance-contract.js +26 -1
  114. package/template/wall-e/coding/action-memory-policy.js +353 -0
  115. package/template/wall-e/coding/action-memory-store.js +814 -0
  116. package/template/wall-e/coding/initial-messages.js +197 -0
  117. package/template/wall-e/coding/no-progress-guard.js +327 -0
  118. package/template/wall-e/coding/permission-service.js +88 -22
  119. package/template/wall-e/coding/session-workspaces.js +81 -0
  120. package/template/wall-e/coding/shell-sandbox.js +124 -0
  121. package/template/wall-e/coding/stream-processor.js +63 -2
  122. package/template/wall-e/coding/tool-execution-controller.js +14 -1
  123. package/template/wall-e/coding/tool-registry.js +1 -1
  124. package/template/wall-e/coding/transcript-writer.js +3 -0
  125. package/template/wall-e/coding-orchestrator.js +636 -35
  126. package/template/wall-e/coding-prompts.js +51 -2
  127. package/template/wall-e/docs/model-routing-policy.md +59 -0
  128. package/template/wall-e/docs/walle-shell-sandbox.md +61 -0
  129. package/template/wall-e/extraction/knowledge-extractor.js +76 -23
  130. package/template/wall-e/http/chat-api.js +30 -12
  131. package/template/wall-e/http/model-admin.js +93 -1
  132. package/template/wall-e/lib/background-lanes.js +133 -0
  133. package/template/wall-e/lib/boot-profile.js +11 -0
  134. package/template/wall-e/lib/brain-owner-worker-client.js +324 -0
  135. package/template/wall-e/lib/brain-read-pool-client.js +311 -0
  136. package/template/wall-e/lib/diagnostics-flags.js +87 -0
  137. package/template/wall-e/lib/event-loop-monitor.js +74 -3
  138. package/template/wall-e/lib/mcp-integration.js +7 -1
  139. package/template/wall-e/lib/real-node.js +98 -0
  140. package/template/wall-e/lib/runtime-health.js +206 -0
  141. package/template/wall-e/lib/runtime-worker-pool.js +101 -0
  142. package/template/wall-e/lib/scheduler-worker-jobs.js +231 -0
  143. package/template/wall-e/lib/scheduler.js +446 -17
  144. package/template/wall-e/lib/service-health.js +61 -2
  145. package/template/wall-e/lib/service-readiness.js +258 -0
  146. package/template/wall-e/lib/usage.js +152 -0
  147. package/template/wall-e/lib/worker-thread-pool.js +389 -0
  148. package/template/wall-e/llm/client.js +81 -4
  149. package/template/wall-e/llm/default-fallback.js +54 -8
  150. package/template/wall-e/llm/mlx.js +536 -73
  151. package/template/wall-e/llm/mlx.plugin.json +1 -1
  152. package/template/wall-e/llm/ollama.js +342 -43
  153. package/template/wall-e/llm/provider-error.js +18 -1
  154. package/template/wall-e/llm/provider-health-state.js +176 -0
  155. package/template/wall-e/llm/routing-policy.js +796 -0
  156. package/template/wall-e/llm/supported-models.js +5 -0
  157. package/template/wall-e/loops/tasks.js +60 -14
  158. package/template/wall-e/loops/think.js +89 -24
  159. package/template/wall-e/mcp-server.js +192 -28
  160. package/template/wall-e/server.js +32 -7
  161. package/template/wall-e/shared/sqlite-owner-guard.js +30 -0
  162. package/template/wall-e/shared/sqlite-owner-write-queue.js +225 -0
  163. package/template/wall-e/shared/sqlite-storage-policy.js +111 -0
  164. package/template/wall-e/shared/sqlite-write-lock.js +428 -0
  165. package/template/wall-e/skills/script-skill-runner.js +8 -1
  166. package/template/wall-e/skills/skill-planner.js +64 -1
  167. package/template/wall-e/tools/builtin-middleware.js +67 -2
  168. package/template/wall-e/tools/local-tools.js +116 -26
  169. package/template/wall-e/tools/permission-checker.js +52 -4
  170. package/template/wall-e/tools/permission-rules.js +36 -0
  171. package/template/wall-e/tools/shell-analyzer.js +46 -1
  172. package/template/wall-e/training/gemma-e4b-qlora.js +314 -0
  173. package/template/wall-e/training/real-trajectory-miner.js +2617 -0
  174. package/template/wall-e/training/replay-eval-analysis.js +151 -0
  175. package/template/wall-e/training/run-shell-command-selector.js +277 -0
  176. package/template/wall-e/training/tool-sft-dataset.js +312 -0
  177. package/template/wall-e/training/tool-sft-renderers.js +144 -0
  178. package/template/wall-e/training/tool-trace-harvester.js +1440 -0
  179. package/template/wall-e/training/trajectory-action-selector.js +364 -0
  180. package/template/wall-e/weather-runtime.js +232 -0
  181. package/template/wall-e/workers/brain-owner-worker.js +162 -0
  182. package/template/wall-e/workers/brain-read-worker.js +148 -0
  183. package/template/wall-e/workers/runtime-worker.js +145 -0
@@ -7,6 +7,8 @@ const queueEngine = require('./queue-engine');
7
7
  const harvest = require('./prompt-harvest');
8
8
  const approvalAgent = require('./approval-agent');
9
9
  const escalationReview = require('./lib/escalation-review');
10
+ const selfAdapt = require('./lib/approval-self-adapt');
11
+ const permissionMatch = require('./lib/permission-match');
10
12
  // permission-sync (Claude/Codex settings mirroring) intentionally removed —
11
13
  // CTM permissions are a standalone global config in the CTM DB.
12
14
  const walleClient = require('./lib/walle-client');
@@ -22,6 +24,7 @@ const {
22
24
  sourceIdFromPath: transcriptSourceIdFromPath,
23
25
  } = require('./lib/transcript-store');
24
26
  const { queryPromptExecutions } = require('./lib/prompt-executions-query');
27
+ const { createIngestCooldown } = require('./lib/ingest-cooldown');
25
28
  const { claudeFileSessionId, _usageMetadata } = require('./lib/jsonl-conversation-parser');
26
29
  const structuredCapture = require('./lib/structured-capture');
27
30
  // AI search uses direct HTTP calls to Claude API (supports Portkey proxy)
@@ -33,6 +36,17 @@ const TRANSCRIPT_IMPORT_MAX_BYTES = Math.max(1024 * 1024, Number(process.env.CTM
33
36
  const TRANSCRIPT_IMPORT_LARGE_FILE_BYTES = Math.max(1024 * 1024, Number(process.env.CTM_TRANSCRIPT_IMPORT_LARGE_FILE_BYTES || 64 * 1024 * 1024));
34
37
  const CONVERSATION_IMPORT_RETRY_AFTER_MS = 30 * 1000;
35
38
  const BACKGROUND_TRANSCRIPT_IMPORT_DEFAULT_BYTES = 2 * 1024 * 1024;
39
+ // Per-session re-import debounce. An actively-typed session's JSONL grows on every keystroke turn,
40
+ // so it is "cacheBehind" (file > cached size) on practically every import tick — which made the
41
+ // importer re-process the WHOLE conversation (a worker write-lock hold of 0.2–1s for 16k-msg
42
+ // sessions) continuously, starving the main thread off-CPU on the shared lock and lagging the very
43
+ // Codex typing that triggered the re-import. Coalesce: once a session has been imported, don't
44
+ // re-import it on a small-growth ("cacheBehind") trigger again until either MIN_INTERVAL elapses or
45
+ // a large byte delta accrues. Urgent reasons (cold file, stale parser, missing model, linked-missing,
46
+ // shrink-after-change) are never debounced. Tunable; MIN_INTERVAL=0 disables.
47
+ const CONVERSATION_IMPORT_MIN_INTERVAL_MS = Math.max(0, Number(process.env.CTM_CONVERSATION_IMPORT_MIN_INTERVAL_MS ?? 8000));
48
+ const CONVERSATION_IMPORT_FORCE_BYTES = Math.max(0, Number(process.env.CTM_CONVERSATION_IMPORT_FORCE_BYTES ?? 256 * 1024));
49
+ const _lastConversationImportAt = new Map(); // sessionId → Date.now() of last completed import
36
50
 
37
51
  function setDbMaintenanceRunner(fn) {
38
52
  dbMaintenanceRunner = typeof fn === 'function' ? fn : null;
@@ -612,44 +626,20 @@ async function handleReorderFolders(req, res) {
612
626
  }
613
627
 
614
628
  // --- AI Search ---
615
- function parseCustomHeaders() {
616
- const headers = {};
617
- const b64 = process.env.ANTHROPIC_CUSTOM_HEADERS_B64;
618
- if (b64) {
619
- const decoded = Buffer.from(b64, 'base64').toString();
620
- for (const line of decoded.split('\n')) {
621
- const idx = line.indexOf(':');
622
- if (idx > 0) headers[line.slice(0, idx).trim()] = line.slice(idx + 1).trim();
623
- }
624
- }
625
- return headers;
626
- }
627
-
629
+ // Background AI helper for the prompts/permissions features (AI search, rule
630
+ // interpretation, history scan, group summaries). Delegates to the provider-aware
631
+ // background-LLM layer (Codex CLI / Claude CLI / Anthropic / DeepSeek / Ollama /
632
+ // whatever the user starred on the AI Providers page) instead of a hardcoded
633
+ // Anthropic fetch which 404'd for anyone without ANTHROPIC_* env configured.
634
+ // Keeps the Anthropic response shape ({ content: [{ text }] }) callers expect.
628
635
  async function callClaude(messages, maxTokens = 1024) {
629
- const baseUrl = process.env.ANTHROPIC_BASE_URL || 'https://api.anthropic.com';
630
- const apiKey = process.env.ANTHROPIC_API_KEY || 'dummy';
631
- const customHeaders = parseCustomHeaders();
632
-
633
- const res = await fetch(`${baseUrl}/messages`, {
634
- method: 'POST',
635
- headers: {
636
- 'Content-Type': 'application/json',
637
- 'x-api-key': apiKey,
638
- 'anthropic-version': '2023-06-01',
639
- ...customHeaders,
640
- },
641
- body: JSON.stringify({
642
- model: 'claude-sonnet-4-20250514',
643
- max_tokens: maxTokens,
644
- messages,
645
- }),
646
- });
647
-
648
- if (!res.ok) {
649
- const text = await res.text();
650
- throw new Error(`Claude API ${res.status}: ${text}`);
651
- }
652
- return await res.json();
636
+ const { callBackgroundLlm } = require('./lib/background-llm');
637
+ const prompt = (Array.isArray(messages) ? messages : [])
638
+ .map((m) => (typeof m?.content === 'string' ? m.content : ''))
639
+ .filter(Boolean)
640
+ .join('\n\n');
641
+ const result = await callBackgroundLlm(prompt, { maxTokens });
642
+ return { content: [{ text: result.text || '' }] };
653
643
  }
654
644
 
655
645
  async function handleAiSearch(req, res) {
@@ -1698,6 +1688,18 @@ async function _conversationImportCandidates(allFiles, lastScanAt) {
1698
1688
  continue;
1699
1689
  }
1700
1690
 
1691
+ // Debounce small-growth re-imports of a hot session (see CONVERSATION_IMPORT_MIN_INTERVAL_MS).
1692
+ // Only when cacheBehind is the SOLE trigger — every other reason is urgent and imports now.
1693
+ const _urgent = changedColdFile || cacheShrankAfterChange || linkedMissingCache || missingModel || staleParser;
1694
+ if (!_urgent && cacheBehind && CONVERSATION_IMPORT_MIN_INTERVAL_MS > 0) {
1695
+ const lastAt = _lastConversationImportAt.get(sessionId) || 0;
1696
+ const sinceLast = Date.now() - lastAt;
1697
+ const grewBytes = Math.max(0, effectiveSize - existingSize);
1698
+ if (lastAt > 0 && sinceLast < CONVERSATION_IMPORT_MIN_INTERVAL_MS && grewBytes < CONVERSATION_IMPORT_FORCE_BYTES) {
1699
+ continue; // imported very recently and only a little new data — let appends coalesce
1700
+ }
1701
+ }
1702
+
1701
1703
  let priority = 6;
1702
1704
  if (cacheBehind) priority = 0;
1703
1705
  else if (staleParser) priority = 1;
@@ -1912,6 +1914,18 @@ async function _importCodexSessionFile(parsed, filePath, options = {}) {
1912
1914
  return true;
1913
1915
  }
1914
1916
 
1917
+ // Zero-yield backoff: a file whose slices keep consuming bytes without inserting any rows
1918
+ // (a runaway multi-GB rollout full of dedup/non-message lines) cools down exponentially
1919
+ // instead of claiming the write lock every tick. Productive slices reset it. Disable via
1920
+ // CTM_TRANSCRIPT_INGEST_COOLDOWN_MS=0; see lib/ingest-cooldown.js.
1921
+ const _ingestCooldownBaseMs = Number(process.env.CTM_TRANSCRIPT_INGEST_COOLDOWN_MS ?? 15000);
1922
+ const _ingestCooldown = _ingestCooldownBaseMs > 0
1923
+ ? createIngestCooldown({
1924
+ baseMs: _ingestCooldownBaseMs,
1925
+ maxMs: Number(process.env.CTM_TRANSCRIPT_INGEST_COOLDOWN_MAX_MS) || undefined,
1926
+ })
1927
+ : null;
1928
+
1915
1929
  function _ingestTranscriptStoreForParsedFile(filePath, parsed, options = {}) {
1916
1930
  // Claude Desktop sessions use a virtual path (`…#ctm-claude-desktop=<uuid>`) that is never a
1917
1931
  // real file on disk — fs.statSync / ingestJsonlFile always throw ENOENT on it. Their
@@ -1919,6 +1933,7 @@ function _ingestTranscriptStoreForParsedFile(filePath, parsed, options = {}) {
1919
1933
  // store, so this ingest can only ever fail for them. Skipping removes a guaranteed-failing
1920
1934
  // synchronous statSync that was firing ~97×/sec in a hot loop on dead desktop sessions.
1921
1935
  if (claudeDesktopSessions.isVirtualSessionPath(filePath)) return null;
1936
+ if (_ingestCooldown && _ingestCooldown.shouldSkip(filePath)) return null;
1922
1937
  try {
1923
1938
  const size = Number(parsed?.fileSize || fs.statSync(filePath).size || 0);
1924
1939
  const agentSessionId = String(parsed?.sessionId || transcriptSourceIdFromPath(filePath) || '').trim();
@@ -1929,16 +1944,29 @@ function _ingestTranscriptStoreForParsedFile(filePath, parsed, options = {}) {
1929
1944
  const result = ingestJsonlFile(db.getDb(), {
1930
1945
  filePath,
1931
1946
  agentSessionId,
1947
+ // ctmSessionId is resolved canonically inside ingestJsonlFile via agent_sessions (a resumed
1948
+ // session owns many rollout ids). agentSessionId is only a bootstrap hint for the first
1949
+ // import before that mapping exists; ingest heals the key on a later pass. Do NOT self-link here.
1932
1950
  ctmSessionId: agentSessionId,
1933
1951
  provider,
1934
1952
  mode: largeColdMode,
1935
1953
  initialTailBytes: maxBytes,
1936
1954
  maxBytes: largeColdMode ? maxBytes : Math.min(size || maxBytes, maxBytes),
1937
1955
  });
1956
+ if (_ingestCooldown) {
1957
+ const cooldown = _ingestCooldown.recordResult(filePath, result);
1958
+ if (cooldown.cooling && cooldown.strikes === 1) {
1959
+ console.log(
1960
+ `[auto-import] backing off ${path.basename(filePath).slice(0, 32)} — zero-yield slice ` +
1961
+ `(bytes=${result.bytesRead || 0}, inserted=0); backlog drains at low priority`
1962
+ );
1963
+ }
1964
+ }
1938
1965
  if ((result.inserted || 0) > 0 || (result.bytesRead || 0) > 0) {
1939
1966
  console.log(
1940
1967
  `[auto-import] transcript-store ${path.basename(filePath).slice(0, 32)}: ` +
1941
- `inserted=${result.inserted || 0}, bytes=${result.bytesRead || 0}${largeColdMode ? ', mode=tail' : ''}`
1968
+ `inserted=${result.inserted || 0}, bytes=${result.bytesRead || 0}` +
1969
+ `${result.partial ? ', partial=1' : ''}${largeColdMode ? ', mode=tail' : ''}`
1942
1970
  );
1943
1971
  }
1944
1972
  return result;
@@ -2055,11 +2083,10 @@ async function importSessionFile(filePath, projectPath, projectEntry, options =
2055
2083
  // This is critical for active sessions (100MB+ files) to avoid blocking the event
2056
2084
  // loop for seconds during the synchronous JSON parse step.
2057
2085
  const prevFileSize = (existing && existing.file_size) || 0;
2086
+ const isAppend = prevFileSize > 0 && parsed.fileSize > prevFileSize;
2058
2087
  let content;
2059
- let baseMessages = [];
2060
- let baseAssistantCount = 0;
2061
2088
 
2062
- if (prevFileSize > 0 && parsed.fileSize > prevFileSize) {
2089
+ if (isAppend) {
2063
2090
  // Only read the newly appended bytes (typically a few KB-MB, not 100MB)
2064
2091
  const fh = await fsp.open(filePath, 'r');
2065
2092
  try {
@@ -2070,17 +2097,6 @@ async function importSessionFile(filePath, projectPath, projectEntry, options =
2070
2097
  } finally {
2071
2098
  await fh.close();
2072
2099
  }
2073
- // Carry forward existing parsed messages. Phase 6: load the base from the faithful
2074
- // per-message rows when known-complete (column read, no multi-MB JSON.parse — and it
2075
- // survives blob retirement in Phase 7); fall back to the blob when rows aren't ready.
2076
- try {
2077
- if (typeof db.sessionContentRowsAvailable === 'function' && db.sessionContentRowsAvailable(parsed.sessionId)) {
2078
- baseMessages = db.getSessionMessagesArray(parsed.sessionId, { fallbackToBlob: false });
2079
- } else {
2080
- baseMessages = JSON.parse(existing.messages || '[]');
2081
- }
2082
- } catch {}
2083
- baseAssistantCount = existing.assistant_msg_count || 0;
2084
2100
  } else {
2085
2101
  // Full read for new files or when file shrank (truncated/rotated)
2086
2102
  content = await fsp.readFile(filePath, 'utf8');
@@ -2096,6 +2112,48 @@ async function importSessionFile(filePath, projectPath, projectEntry, options =
2096
2112
  fileSessionId: claudeFileSessionId(jsonlPath),
2097
2113
  fileDir: path.dirname(jsonlPath),
2098
2114
  });
2115
+
2116
+ // O(Δ) APPEND FAST PATH: for a pure append (file grew, not a compaction pair) append ONLY the
2117
+ // new messages — no full base-array load, no full re-estimate, no full sort. This is what keeps
2118
+ // an active 6k-msg/54MB session from re-processing the whole conversation on every turn (the
2119
+ // giant-transcript freeze). db.appendSessionConversation refuses (ok:false) when the row base
2120
+ // isn't provably safe — prefix changed, gaps, or HWM unset — in which case we fall through to
2121
+ // the full rebuild below. (isCompactPair was already handled above and never reaches here.)
2122
+ if (isAppend && newMessages.length > 0) {
2123
+ const appended = db.appendSessionConversation({
2124
+ session_id: parsed.sessionId,
2125
+ new_messages: newMessages,
2126
+ model_id: parsed.modelId || (existing && existing.model_id) || '',
2127
+ model_provider: parsed.modelProvider || (existing && existing.model_provider) || CLAUDE_MODEL_PROVIDER,
2128
+ file_size: parsed.fileSize,
2129
+ last_user_content: parsedLastUser || '',
2130
+ // Cheap metadata corrections (provider cwd preferred over a lossy decoded project dir, branch,
2131
+ // title, rename, host) — same prefer-new-non-empty semantics as the full importSessionConversation.
2132
+ project_path: parsed.cwd || parsed.project || '',
2133
+ git_branch: parsed.gitBranch || '',
2134
+ title: parsed.title || '',
2135
+ hostname: parsed.hostname || '',
2136
+ rename_name: parsedRename || parsed.renameName || '',
2137
+ import_parser_version: DEFAULT_CONVERSATION_IMPORT_PARSER_VERSION,
2138
+ });
2139
+ if (appended && appended.ok) return true;
2140
+ }
2141
+
2142
+ // Full rebuild: cold import, file shrank/rotated, or the append guard refused. Load the base
2143
+ // array (faithful per-message rows preferred — a column read, no multi-MB JSON.parse, and it
2144
+ // survives blob retirement; blob fallback when rows aren't ready) and re-derive the whole thing.
2145
+ let baseMessages = [];
2146
+ let baseAssistantCount = 0;
2147
+ if (isAppend) {
2148
+ try {
2149
+ if (typeof db.sessionContentRowsAvailable === 'function' && db.sessionContentRowsAvailable(parsed.sessionId)) {
2150
+ baseMessages = db.getSessionMessagesArray(parsed.sessionId, { fallbackToBlob: false });
2151
+ } else {
2152
+ baseMessages = JSON.parse(existing.messages || '[]');
2153
+ }
2154
+ } catch {}
2155
+ baseAssistantCount = existing.assistant_msg_count || 0;
2156
+ }
2099
2157
  const allMessages = baseMessages.concat(newMessages);
2100
2158
  const indexedMessages = prevFileSize > 0 && parsed.fileSize > prevFileSize
2101
2159
  ? _loadIndexedSessionMessages(parsed.sessionId)
@@ -2190,7 +2248,7 @@ async function runIncrementalConversationImport() {
2190
2248
  // Yield after each processed file. importSessionFile() still performs a
2191
2249
  // synchronous SQLite upsert/JSON.stringify at the end, so do not stack
2192
2250
  // several imports in the same event-loop turn.
2193
- for (const { filePath, projectPath, projectEntry, error } of candidates) {
2251
+ for (const { filePath, projectPath, projectEntry, sessionId: candidateSessionId, error } of candidates) {
2194
2252
  try {
2195
2253
  if (error) throw error;
2196
2254
  scanned++;
@@ -2198,6 +2256,10 @@ async function runIncrementalConversationImport() {
2198
2256
  cooperative: true,
2199
2257
  transcriptMaxBytes: _backgroundTranscriptImportMaxBytes(),
2200
2258
  })) imported++;
2259
+ // Stamp the debounce clock for this session (whether or not a row was written) so a hot
2260
+ // session's rapid small appends coalesce instead of re-importing the whole conversation
2261
+ // every tick. Best-effort — keyed by the candidate's resolved session id.
2262
+ if (candidateSessionId) _lastConversationImportAt.set(candidateSessionId, Date.now());
2201
2263
  const importLimited = imported >= maxImportedPerRun;
2202
2264
  const processedLimited = scanned >= maxProcessedPerRun;
2203
2265
  if ((importLimited || processedLimited) && scanned < candidates.length) {
@@ -2437,16 +2499,78 @@ let _broadcastUiPrefs = null;
2437
2499
  function setUiPrefsBroadcaster(fn) { _broadcastUiPrefs = fn; }
2438
2500
 
2439
2501
  // --- Global Hotkey (macOS) ---
2440
- const HOTKEY_BINARY = path.join(os.homedir(), '.local', 'bin', 'ctm-hotkey');
2502
+ // The hotkey runs as a real Developer-ID-signed LSUIElement .app bundle (a peer of
2503
+ // Coding Task Manager.app / Wall-E.app), NOT a bare executable. RegisterEventHotKey needs a GUI
2504
+ // app registered with the window server; a bare launchd executable — even with
2505
+ // setActivationPolicy(.accessory) — never gets that connection, so it registered but never
2506
+ // received the keypress. Wrapping it in a .app with an Info.plist (LSUIElement + bundle identity)
2507
+ // is the fix, and it reuses the exact bundle+signing pipeline the node daemons already use.
2508
+ const HOTKEY_BUNDLE_NAME = 'CTM Screenshot'; // CFBundleName (Activity Monitor display)
2509
+ const HOTKEY_BUNDLE_ID = 'com.walle.ctm.hotkey'; // CFBundleIdentifier + codesign identifier
2510
+ const HOTKEY_BUNDLE_DIR = path.join(os.homedir(), '.walle', 'bundles', 'CTM-Screenshot.app');
2511
+ const HOTKEY_BUNDLE_EXEC = path.join(HOTKEY_BUNDLE_DIR, 'Contents', 'MacOS', 'ctm-hotkey');
2512
+ const HOTKEY_LEGACY_BINARY = path.join(os.homedir(), '.local', 'bin', 'ctm-hotkey'); // pre-bundle build; removed on migrate
2441
2513
  const HOTKEY_PLIST_NAME = 'com.ctm.hotkey';
2442
2514
  const HOTKEY_PLIST = path.join(os.homedir(), 'Library', 'LaunchAgents', `${HOTKEY_PLIST_NAME}.plist`);
2443
2515
  const HOTKEY_SWIFT_SOURCE = path.join(__dirname, 'bin', 'ctm-hotkey.swift');
2516
+ // Bump when the BUNDLE LAYOUT or build approach changes (not just the swift) to force a rebuild.
2517
+ const HOTKEY_BUILD_TAG = 'bundle-v1';
2518
+ // Records sha256(swift source)+tag of what's installed, so a boot-time ensure rebuilds when CTM
2519
+ // ships new daemon source OR a new bundle layout. Content hash (not mtime) because npm installs and
2520
+ // git checkouts don't preserve mtimes — the "new source → rebuild" guarantee must not depend on it.
2521
+ const HOTKEY_BUILD_MARKER = path.join(os.homedir(), '.walle', 'bundles', '.ctm-hotkey.build');
2522
+ const SCREEN_AUTH_BINARY = path.join(os.homedir(), '.local', 'bin', 'ctm-screen-auth');
2523
+ const SCREEN_AUTH_SWIFT_SOURCE = path.join(__dirname, 'bin', 'ctm-screen-auth.swift');
2524
+ const SCREEN_AUTH_SIGN_IDENTIFIER = 'com.walle.ctm';
2525
+ // Full-screen, non-interactive capture (-x = no camera sound). The hotkey/button should produce a
2526
+ // screenshot immediately; the old '-i' popped a region-select crosshair that, when not drag-completed,
2527
+ // exited with no file and logged as "cancelled" — the #1 "the screenshot does nothing" report.
2528
+ const SCREEN_CAPTURE_NODE_SCRIPT = "require('child_process').execFileSync('/usr/sbin/screencapture',['-x',process.argv[1]],{stdio:'inherit'})";
2529
+ const SCREEN_AUTH_NODE_SCRIPT = "const cp=require('child_process');const r=cp.spawnSync(process.argv[1],{encoding:'utf8'});if(r.stdout)process.stdout.write(r.stdout);if(r.stderr)process.stderr.write(r.stderr);process.exit(r.status==null?1:r.status);";
2530
+ // Like SCREEN_AUTH_NODE_SCRIPT but runs the helper in --preflight mode (report grant, NEVER prompt)
2531
+ // so we can probe several node identities and pick one already granted without popping dialogs.
2532
+ const SCREEN_AUTH_PREFLIGHT_SCRIPT = "const cp=require('child_process');const r=cp.spawnSync(process.argv[1],['--preflight'],{encoding:'utf8'});process.stdout.write((r.stdout||'').trim());process.exit(r.status==null?1:r.status);";
2533
+
2534
+ // macOS code-signing identity helpers (extracted so bin/ensure-stable-node.js can reuse them
2535
+ // without pulling in this whole server module). SIGN_KEYCHAIN is re-exposed under the old name
2536
+ // for the screen-auth gate below.
2537
+ const {
2538
+ SIGN_KEYCHAIN: HOTKEY_SIGN_KEYCHAIN,
2539
+ developerIdIdentityHash,
2540
+ signCtmBinaryPreferDeveloperId,
2541
+ signLocalCtmBinary,
2542
+ devIdSignBundle,
2543
+ localCodeSignIdentifier,
2544
+ localCodeSignTeamId,
2545
+ } = require('./lib/codesign-identity');
2546
+
2547
+ // Sign the hotkey .app so it works for EVERY user, regardless of whether they have a Developer ID:
2548
+ // 1. Developer ID present → Dev-ID-sign the whole bundle (--deep): stable, branded Team Identifier,
2549
+ // exactly like Coding Task Manager.app / Wall-E.app.
2550
+ // 2. Else, the stable self-signed local cert (most `npx create-walle` installs already have one).
2551
+ // 3. Else, AD-HOC sign (`codesign --sign -`): a bare unsigned binary will not even execute on
2552
+ // Apple Silicon, so this guarantees the bundle runs. The hotkey needs NO TCC permission, so an
2553
+ // ad-hoc signature is fully sufficient for it to register and fire.
2554
+ function signHotkeyBundle() {
2555
+ const dev = devIdSignBundle(HOTKEY_BUNDLE_DIR, HOTKEY_BUNDLE_ID);
2556
+ if (dev.signed) return dev;
2557
+ if (signLocalCtmBinary(HOTKEY_BUNDLE_DIR, HOTKEY_BUNDLE_ID)) return { signed: true, identity: 'self-signed' };
2558
+ try {
2559
+ require('child_process').execFileSync(
2560
+ 'codesign', ['--force', '--deep', '--sign', '-', HOTKEY_BUNDLE_DIR],
2561
+ { stdio: 'ignore', timeout: 120000 },
2562
+ );
2563
+ return { signed: true, identity: 'ad-hoc' };
2564
+ } catch {
2565
+ return { signed: false, identity: null };
2566
+ }
2567
+ }
2444
2568
 
2445
2569
  function handleHotkeyStatus(req, res) {
2446
2570
  if (process.platform !== 'darwin') {
2447
2571
  return jsonResponse(res, 200, { supported: false, reason: 'macOS only' });
2448
2572
  }
2449
- const binaryExists = fs.existsSync(HOTKEY_BINARY);
2573
+ const binaryExists = fs.existsSync(HOTKEY_BUNDLE_EXEC);
2450
2574
  const plistExists = fs.existsSync(HOTKEY_PLIST);
2451
2575
  let running = false;
2452
2576
  try {
@@ -2454,15 +2578,16 @@ function handleHotkeyStatus(req, res) {
2454
2578
  const out = execSync('launchctl list 2>/dev/null', { encoding: 'utf8' });
2455
2579
  running = out.includes(HOTKEY_PLIST_NAME);
2456
2580
  } catch {}
2457
- // Check if event tap actually works (accessibility permission granted)
2458
- let permissionGranted = false;
2581
+ // Confirm the daemon actually registered the system-wide hotkey (no permission involved —
2582
+ // RegisterEventHotKey needs none; this just verifies it bound the combo successfully).
2583
+ let registered = false;
2459
2584
  if (running) {
2460
2585
  try {
2461
2586
  const { execSync } = require('child_process');
2462
2587
  const log = path.join(os.homedir(), '.local', 'log', 'ctm-hotkey.log');
2463
2588
  if (fs.existsSync(log)) {
2464
2589
  const tail = execSync(`tail -5 "${log}"`, { encoding: 'utf8' });
2465
- permissionGranted = tail.includes('Global hotkey registered');
2590
+ registered = tail.includes('Global hotkey registered');
2466
2591
  }
2467
2592
  } catch {}
2468
2593
  }
@@ -2470,31 +2595,66 @@ function handleHotkeyStatus(req, res) {
2470
2595
  supported: true,
2471
2596
  installed: binaryExists && plistExists,
2472
2597
  running,
2473
- permissionGranted,
2474
- binaryPath: HOTKEY_BINARY,
2598
+ registered,
2599
+ // Back-compat alias: the hotkey no longer needs a TCC permission, so "registered" is the
2600
+ // meaningful signal. Kept so any older client reading `permissionGranted` still works.
2601
+ permissionGranted: registered,
2602
+ binaryPath: HOTKEY_BUNDLE_EXEC,
2603
+ bundlePath: HOTKEY_BUNDLE_DIR,
2475
2604
  sourceExists: fs.existsSync(HOTKEY_SWIFT_SOURCE),
2476
2605
  });
2477
2606
  }
2478
2607
 
2479
- async function handleHotkeyInstall(req, res) {
2480
- if (process.platform !== 'darwin') {
2481
- return jsonResponse(res, 400, { error: 'macOS only' });
2482
- }
2483
- try {
2484
- const { execSync } = require('child_process');
2608
+ function _hotkeyCtmPort() {
2609
+ return process.env.DEV_CTM_PORT || process.env.CTM_PORT || '3456';
2610
+ }
2485
2611
 
2486
- // 1. Compile Swift binary
2487
- fs.mkdirSync(path.dirname(HOTKEY_BINARY), { recursive: true });
2488
- execSync(`swiftc -O -o "${HOTKEY_BINARY}" "${HOTKEY_SWIFT_SOURCE}" -framework Cocoa`, {
2489
- timeout: 60000, encoding: 'utf8',
2490
- });
2491
- fs.chmodSync(HOTKEY_BINARY, 0o755);
2612
+ // Identifies WHAT is installed: the swift source content + the bundle-layout tag. A change in
2613
+ // either (new source, or a new bundle build approach) flips this and triggers a rebuild.
2614
+ function _hotkeyBuildSignature() {
2615
+ const src = require('crypto').createHash('sha256').update(fs.readFileSync(HOTKEY_SWIFT_SOURCE)).digest('hex');
2616
+ return `${src}:${HOTKEY_BUILD_TAG}`;
2617
+ }
2618
+
2619
+ // The bundle's Info.plist — what turns the bare swift binary into a real GUI app the window server
2620
+ // recognizes. LSUIElement=true makes it a menu-bar accessory (no Dock icon); the bundle identity +
2621
+ // NSPrincipalClass are what let RegisterEventHotKey actually receive the global hotkey. Same shape
2622
+ // as the node-daemon bundles' Info.plist (create-walle.js writeInfoPlist).
2623
+ function _writeHotkeyInfoPlist() {
2624
+ const infoPlist = path.join(HOTKEY_BUNDLE_DIR, 'Contents', 'Info.plist');
2625
+ fs.mkdirSync(path.dirname(infoPlist), { recursive: true });
2626
+ fs.writeFileSync(infoPlist, `<?xml version="1.0" encoding="UTF-8"?>
2627
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
2628
+ <plist version="1.0">
2629
+ <dict>
2630
+ <key>CFBundleName</key>
2631
+ <string>${HOTKEY_BUNDLE_NAME}</string>
2632
+ <key>CFBundleDisplayName</key>
2633
+ <string>${HOTKEY_BUNDLE_NAME}</string>
2634
+ <key>CFBundleIdentifier</key>
2635
+ <string>${HOTKEY_BUNDLE_ID}</string>
2636
+ <key>CFBundleExecutable</key>
2637
+ <string>${path.basename(HOTKEY_BUNDLE_EXEC)}</string>
2638
+ <key>CFBundleVersion</key>
2639
+ <string>1.0</string>
2640
+ <key>CFBundleShortVersionString</key>
2641
+ <string>1.0</string>
2642
+ <key>CFBundlePackageType</key>
2643
+ <string>APPL</string>
2644
+ <key>LSUIElement</key>
2645
+ <true/>
2646
+ <key>LSMinimumSystemVersion</key>
2647
+ <string>11.0</string>
2648
+ <key>NSPrincipalClass</key>
2649
+ <string>NSApplication</string>
2650
+ </dict>
2651
+ </plist>`);
2652
+ }
2492
2653
 
2493
- // 2. Write launchd plist
2494
- const ctmPort = process.env.DEV_CTM_PORT || process.env.CTM_PORT || '3456';
2495
- const logDir = path.join(os.homedir(), '.local', 'log');
2496
- fs.mkdirSync(logDir, { recursive: true });
2497
- const plistContent = `<?xml version="1.0" encoding="UTF-8"?>
2654
+ function _writeHotkeyPlist(ctmPort) {
2655
+ const logDir = path.join(os.homedir(), '.local', 'log');
2656
+ fs.mkdirSync(logDir, { recursive: true });
2657
+ const plistContent = `<?xml version="1.0" encoding="UTF-8"?>
2498
2658
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
2499
2659
  <plist version="1.0">
2500
2660
  <dict>
@@ -2502,7 +2662,7 @@ async function handleHotkeyInstall(req, res) {
2502
2662
  <string>${HOTKEY_PLIST_NAME}</string>
2503
2663
  <key>ProgramArguments</key>
2504
2664
  <array>
2505
- <string>${HOTKEY_BINARY}</string>
2665
+ <string>${HOTKEY_BUNDLE_EXEC}</string>
2506
2666
  </array>
2507
2667
  <key>EnvironmentVariables</key>
2508
2668
  <dict>
@@ -2512,21 +2672,120 @@ async function handleHotkeyInstall(req, res) {
2512
2672
  <key>RunAtLoad</key>
2513
2673
  <true/>
2514
2674
  <key>KeepAlive</key>
2515
- <true/>
2675
+ <dict>
2676
+ <key>SuccessfulExit</key>
2677
+ <false/>
2678
+ </dict>
2516
2679
  <key>StandardOutPath</key>
2517
2680
  <string>${logDir}/ctm-hotkey.log</string>
2518
2681
  <key>StandardErrorPath</key>
2519
2682
  <string>${logDir}/ctm-hotkey.log</string>
2520
2683
  </dict>
2521
2684
  </plist>`;
2522
- fs.mkdirSync(path.dirname(HOTKEY_PLIST), { recursive: true });
2523
- fs.writeFileSync(HOTKEY_PLIST, plistContent);
2524
-
2525
- // 3. Load the agent
2526
- try { execSync(`launchctl unload "${HOTKEY_PLIST}" 2>/dev/null`); } catch {}
2685
+ fs.mkdirSync(path.dirname(HOTKEY_PLIST), { recursive: true });
2686
+ fs.writeFileSync(HOTKEY_PLIST, plistContent);
2687
+ }
2688
+
2689
+ // Load (or reload) the launchd agent. Modern domain-target API: legacy `load`/`unload` fail with
2690
+ // "5: Input/output error" when the label carries a stale *disabled* override (left by a prior
2691
+ // bootout/disable or repeated early exits). `enable` clears it; without it the daemon can never
2692
+ // be reinstalled. Fall back to legacy `load` only if bootstrap is absent.
2693
+ function _loadHotkeyLaunchAgent() {
2694
+ const { execSync } = require('child_process');
2695
+ const uid = process.getuid();
2696
+ const svc = `gui/${uid}/${HOTKEY_PLIST_NAME}`;
2697
+ try { execSync(`launchctl bootout ${svc} 2>/dev/null`); } catch {}
2698
+ try { execSync(`launchctl enable ${svc} 2>/dev/null`); } catch {}
2699
+ try {
2700
+ execSync(`launchctl bootstrap gui/${uid} "${HOTKEY_PLIST}"`);
2701
+ } catch {
2527
2702
  execSync(`launchctl load "${HOTKEY_PLIST}"`);
2703
+ }
2704
+ try { execSync(`launchctl kickstart -k ${svc} 2>/dev/null`); } catch {}
2705
+ }
2706
+
2707
+ // Build the hotkey .app bundle, sign it, write its LaunchAgent plist, and (re)load it. Compiles the
2708
+ // Carbon swift (RegisterEventHotKey / InstallEventHandler; Cocoa for NSApplication) straight INTO
2709
+ // the bundle's Contents/MacOS, then writes Info.plist + signs the whole bundle. Records the build
2710
+ // signature so the boot-time ensure knows what's installed. Synchronous; throws if swiftc fails.
2711
+ function buildAndLoadHotkeyDaemon() {
2712
+ const { execFileSync } = require('child_process');
2713
+ fs.mkdirSync(path.dirname(HOTKEY_BUNDLE_EXEC), { recursive: true });
2714
+ execFileSync('swiftc', ['-O', '-o', HOTKEY_BUNDLE_EXEC, HOTKEY_SWIFT_SOURCE, '-framework', 'Cocoa', '-framework', 'Carbon'], {
2715
+ timeout: 120000, stdio: 'ignore',
2716
+ });
2717
+ fs.chmodSync(HOTKEY_BUNDLE_EXEC, 0o755);
2718
+ _writeHotkeyInfoPlist();
2719
+ const sign = signHotkeyBundle(); // Dev-ID-sign the whole bundle (--deep), else self-sign the exec
2720
+ _writeHotkeyPlist(_hotkeyCtmPort());
2721
+ _loadHotkeyLaunchAgent();
2722
+ try { fs.writeFileSync(HOTKEY_BUILD_MARKER, _hotkeyBuildSignature()); } catch {}
2723
+ // Migrate off the pre-bundle bare binary + its stale marker so two daemons can't compete.
2724
+ try { fs.unlinkSync(HOTKEY_LEGACY_BINARY); } catch {}
2725
+ try { fs.unlinkSync(path.join(os.homedir(), '.local', 'bin', '.ctm-hotkey.build')); } catch {}
2726
+ return { signed: sign.signed, identity: sign.identity, bundle: HOTKEY_BUNDLE_DIR };
2727
+ }
2728
+
2729
+ function isHotkeyDaemonLoaded() {
2730
+ try {
2731
+ require('child_process').execSync(
2732
+ `launchctl print gui/${process.getuid()}/${HOTKEY_PLIST_NAME}`,
2733
+ { stdio: 'ignore' },
2734
+ );
2735
+ return true;
2736
+ } catch {
2737
+ return false;
2738
+ }
2739
+ }
2740
+
2741
+ // Pure decision: given the installed daemon's state, what should the boot-time ensure do?
2742
+ // 'rebuild' — binary missing, or the source hash no longer matches what was built (new source
2743
+ // shipped, e.g. the CGEventTap→Carbon migration)
2744
+ // 'reload' — binary is current but the launchd job isn't loaded (e.g. it was booted out)
2745
+ // 'noop' — current and loaded; nothing to do (the cheap happy path on every boot)
2746
+ function hotkeyEnsureAction({ binaryExists, builtHash, srcHash, loaded }) {
2747
+ if (!binaryExists || builtHash !== srcHash) return 'rebuild';
2748
+ if (!loaded) return 'reload';
2749
+ return 'noop';
2750
+ }
2751
+
2752
+ // Boot-time self-heal. End users never run the installer by hand — and a CTM restart alone never
2753
+ // rebuilds the Swift LaunchAgent — so when CTM ships new daemon source the installed binary would
2754
+ // stay stale forever (exactly the CGEventTap→Carbon migration failure). This rebuilds the daemon
2755
+ // when the source hash no longer matches what's installed (or the binary is missing), otherwise
2756
+ // just ensures it's loaded. Idempotent and cheap on the happy path (no recompile). Never throws —
2757
+ // a daemon hiccup must not block CTM boot.
2758
+ function ensureHotkeyDaemon() {
2759
+ if (process.platform !== 'darwin') return { ok: false, reason: 'unsupported' };
2760
+ if (!fs.existsSync(HOTKEY_SWIFT_SOURCE)) return { ok: false, reason: 'missing_source' };
2761
+ try {
2762
+ const srcHash = _hotkeyBuildSignature();
2763
+ let builtHash = null;
2764
+ try { builtHash = fs.readFileSync(HOTKEY_BUILD_MARKER, 'utf8').trim(); } catch {}
2765
+ const binaryExists = fs.existsSync(HOTKEY_BUNDLE_EXEC);
2766
+ const action = hotkeyEnsureAction({ binaryExists, builtHash, srcHash, loaded: isHotkeyDaemonLoaded() });
2767
+ if (action === 'rebuild') {
2768
+ const r = buildAndLoadHotkeyDaemon();
2769
+ return { ok: true, action: binaryExists ? 'rebuilt' : 'installed', signed: r.signed, identity: r.identity };
2770
+ }
2771
+ if (action === 'reload') {
2772
+ _writeHotkeyPlist(_hotkeyCtmPort());
2773
+ _loadHotkeyLaunchAgent();
2774
+ return { ok: true, action: 'reloaded' };
2775
+ }
2776
+ return { ok: true, action: 'noop' };
2777
+ } catch (e) {
2778
+ return { ok: false, reason: 'ensure_failed', error: e && e.message ? e.message : String(e) };
2779
+ }
2780
+ }
2528
2781
 
2529
- jsonResponse(res, 200, { ok: true, binaryPath: HOTKEY_BINARY });
2782
+ async function handleHotkeyInstall(req, res) {
2783
+ if (process.platform !== 'darwin') {
2784
+ return jsonResponse(res, 400, { error: 'macOS only' });
2785
+ }
2786
+ try {
2787
+ const r = buildAndLoadHotkeyDaemon();
2788
+ jsonResponse(res, 200, { ok: true, binaryPath: HOTKEY_BUNDLE_EXEC, bundlePath: HOTKEY_BUNDLE_DIR, signed: r.signed, identity: r.identity });
2530
2789
  } catch (e) {
2531
2790
  jsonResponse(res, 500, { error: e.message });
2532
2791
  }
@@ -2535,15 +2794,218 @@ async function handleHotkeyInstall(req, res) {
2535
2794
  function handleHotkeyUninstall(req, res) {
2536
2795
  try {
2537
2796
  const { execSync } = require('child_process');
2538
- try { execSync(`launchctl unload "${HOTKEY_PLIST}" 2>/dev/null`); } catch {}
2797
+ // bootout (not `disable`) so the label stays enabled and a future reinstall isn't blocked
2798
+ // by a stale disabled override.
2799
+ try { execSync(`launchctl bootout gui/${process.getuid()}/${HOTKEY_PLIST_NAME} 2>/dev/null`); } catch {}
2539
2800
  try { fs.unlinkSync(HOTKEY_PLIST); } catch {}
2540
- try { fs.unlinkSync(HOTKEY_BINARY); } catch {}
2801
+ try { fs.rmSync(HOTKEY_BUNDLE_DIR, { recursive: true, force: true }); } catch {}
2802
+ try { fs.unlinkSync(HOTKEY_BUILD_MARKER); } catch {}
2803
+ try { fs.unlinkSync(HOTKEY_LEGACY_BINARY); } catch {} // clean up any pre-bundle binary too
2541
2804
  jsonResponse(res, 200, { ok: true });
2542
2805
  } catch (e) {
2543
2806
  jsonResponse(res, 500, { error: e.message });
2544
2807
  }
2545
2808
  }
2546
2809
 
2810
+ function ensureScreenRecordingAuthBinary() {
2811
+ if (process.platform !== 'darwin') {
2812
+ return { ok: false, reason: 'unsupported', verdict: 'unavailable' };
2813
+ }
2814
+ if (!fs.existsSync(SCREEN_AUTH_SWIFT_SOURCE)) {
2815
+ return { ok: false, reason: 'missing_source', verdict: 'unavailable', binaryPath: SCREEN_AUTH_BINARY };
2816
+ }
2817
+ try {
2818
+ let needsCompile = !fs.existsSync(SCREEN_AUTH_BINARY);
2819
+ if (!needsCompile) {
2820
+ const srcStat = fs.statSync(SCREEN_AUTH_SWIFT_SOURCE);
2821
+ const binStat = fs.statSync(SCREEN_AUTH_BINARY);
2822
+ needsCompile = srcStat.mtimeMs > binStat.mtimeMs;
2823
+ }
2824
+ let signed = false;
2825
+ let signatureId = '';
2826
+ if (needsCompile) {
2827
+ const { execFileSync } = require('child_process');
2828
+ fs.mkdirSync(path.dirname(SCREEN_AUTH_BINARY), { recursive: true });
2829
+ execFileSync('swiftc', ['-O', '-o', SCREEN_AUTH_BINARY, SCREEN_AUTH_SWIFT_SOURCE, '-framework', 'CoreGraphics'], {
2830
+ timeout: 60000,
2831
+ stdio: 'ignore',
2832
+ });
2833
+ fs.chmodSync(SCREEN_AUTH_BINARY, 0o755);
2834
+ }
2835
+ signatureId = localCodeSignIdentifier(SCREEN_AUTH_BINARY);
2836
+ const devIdAvailable = !!developerIdIdentityHash();
2837
+ const teamId = localCodeSignTeamId(SCREEN_AUTH_BINARY);
2838
+ // Re-sign when the identifier is wrong/absent, OR a Developer ID is now available but the
2839
+ // binary isn't yet Developer-ID-signed — upgrade self-signed → Developer ID so the Screen
2840
+ // Recording prompt (CGRequestScreenCaptureAccess) is honored and the grant persists.
2841
+ const needsSign = (fs.existsSync(HOTKEY_SIGN_KEYCHAIN) || devIdAvailable)
2842
+ && (signatureId !== SCREEN_AUTH_SIGN_IDENTIFIER || (devIdAvailable && !teamId));
2843
+ if (needsSign) {
2844
+ const r = signCtmBinaryPreferDeveloperId(SCREEN_AUTH_BINARY, SCREEN_AUTH_SIGN_IDENTIFIER);
2845
+ signed = r.signed;
2846
+ signatureId = localCodeSignIdentifier(SCREEN_AUTH_BINARY) || (signed ? SCREEN_AUTH_SIGN_IDENTIFIER : signatureId);
2847
+ } else {
2848
+ signed = signatureId === SCREEN_AUTH_SIGN_IDENTIFIER;
2849
+ }
2850
+ return { ok: true, binaryPath: SCREEN_AUTH_BINARY, compiled: needsCompile, signed, signatureId };
2851
+ } catch (e) {
2852
+ return {
2853
+ ok: false,
2854
+ reason: 'install_failed',
2855
+ verdict: 'unavailable',
2856
+ error: e && e.message ? e.message : String(e),
2857
+ binaryPath: SCREEN_AUTH_BINARY,
2858
+ };
2859
+ }
2860
+ }
2861
+
2862
+ // Candidate node identities that could own the Screen Recording grant, in preference order:
2863
+ // (1) the node create-walle recorded as the granted identity, (2) CTM's own exec (the stable
2864
+ // notarized node; or the resolved real node when CTM runs from the .app bundle, which can't hold
2865
+ // the grant), (3) common node locations. Only existing files are returned.
2866
+ function screenshotNodeCandidates() {
2867
+ const out = [];
2868
+ const push = (p) => { if (p && !out.includes(p)) out.push(p); };
2869
+ try { push(fs.readFileSync(path.join(os.homedir(), '.walle', 'bundles', 'node-origin'), 'utf8').trim()); } catch {}
2870
+ try {
2871
+ const { isBundleExec, resolveRealNode } = require('./lib/real-node');
2872
+ push(isBundleExec(process.execPath) ? resolveRealNode() : process.execPath);
2873
+ } catch { push(process.execPath); }
2874
+ ['/opt/homebrew/bin/node', '/usr/local/bin/node', '/usr/bin/node'].forEach(push);
2875
+ return out.filter((p) => { try { return fs.existsSync(p); } catch { return false; } });
2876
+ }
2877
+
2878
+ // Probe whether `node`, run as its OWN responsible process (via ctm-disclaim), already holds the
2879
+ // Screen Recording grant — using the helper's --preflight mode so it NEVER pops a dialog.
2880
+ function probeScreenshotNodeGranted(node, disclaim) {
2881
+ try {
2882
+ const out = require('child_process').execFileSync(
2883
+ disclaim, [node, '-e', SCREEN_AUTH_PREFLIGHT_SCRIPT, SCREEN_AUTH_BINARY],
2884
+ { timeout: 8000, encoding: 'utf8' },
2885
+ );
2886
+ return String(out).trim().endsWith('granted');
2887
+ } catch { return false; }
2888
+ }
2889
+
2890
+ let _grantedScreenshotNode = null; // positive cache only: once a granted node is found, reuse it
2891
+ function pickGrantedScreenshotNode(disclaim) {
2892
+ if (_grantedScreenshotNode) return _grantedScreenshotNode;
2893
+ try { ensureScreenRecordingAuthBinary(); } catch {}
2894
+ for (const node of screenshotNodeCandidates()) {
2895
+ if (probeScreenshotNodeGranted(node, disclaim)) { _grantedScreenshotNode = node; return node; }
2896
+ }
2897
+ return null; // not cached → re-probe next time, so a later user grant is picked up automatically
2898
+ }
2899
+ function _resetGrantedScreenshotNode() { _grantedScreenshotNode = null; }
2900
+
2901
+ function screenshotResponsibleContext() {
2902
+ // macOS keys the Screen Recording grant on the RESPONSIBLE process of the `screencapture`
2903
+ // child. A direct capture inherits that from CTM's launcher ancestry (fragile: it's the
2904
+ // notarized node only when CTM is launchd-rooted; under /ctm-dev or a relaunch it's the
2905
+ // terminal/parent), so we ALWAYS route through ctm-disclaim — it makes the chosen node its OWN
2906
+ // responsible process. And we PREFER a node that is ALREADY granted (probed, no prompt) so the
2907
+ // common case needs zero user action; only a fresh machine with nothing granted falls through
2908
+ // to the stable identity + the one-time prompt.
2909
+ const disclaim = path.join(os.homedir(), '.local', 'bin', 'ctm-disclaim');
2910
+ const disclaimOk = process.platform === 'darwin' && fs.existsSync(disclaim);
2911
+ let node = null;
2912
+ let granted = false;
2913
+ if (disclaimOk) {
2914
+ try { node = pickGrantedScreenshotNode(disclaim); granted = !!node; } catch {}
2915
+ }
2916
+ if (!node) {
2917
+ node = process.execPath;
2918
+ try {
2919
+ const { isBundleExec, resolveRealNode } = require('./lib/real-node');
2920
+ if (isBundleExec(process.execPath)) node = resolveRealNode();
2921
+ } catch {}
2922
+ }
2923
+ let nodeIsBundle = false;
2924
+ try { nodeIsBundle = require('./lib/real-node').isBundleExec(node); } catch {}
2925
+ const useBridge = disclaimOk && !!node && !nodeIsBundle;
2926
+ const responsiblePath = useBridge ? node : process.execPath;
2927
+ const responsibleName = responsiblePath ? path.basename(responsiblePath) : 'CTM';
2928
+ return { realNode: node, disclaim, useBridge, responsiblePath, responsibleName, granted };
2929
+ }
2930
+
2931
+ function screenshotCaptureCommand(tmpFile, context = {}) {
2932
+ if (context.useBridge) {
2933
+ return {
2934
+ cmd: context.disclaim,
2935
+ cmdArgs: [context.realNode, '-e', SCREEN_CAPTURE_NODE_SCRIPT, tmpFile],
2936
+ };
2937
+ }
2938
+ return { cmd: '/usr/sbin/screencapture', cmdArgs: ['-x', tmpFile] };
2939
+ }
2940
+
2941
+ // Spawn the Screen Recording permission helper under the same macOS responsible process
2942
+ // as the actual capture command. Direct captures preflight CTM's bundle identity. Bridged
2943
+ // captures preflight the user's real node identity via ctm-disclaim, matching the
2944
+ // screencapture child that will run next.
2945
+ function requestScreenRecordingAccess(context = {}) {
2946
+ const helper = ensureScreenRecordingAuthBinary();
2947
+ if (!helper.ok) return Promise.resolve({ verdict: helper.verdict || 'unavailable', helper });
2948
+ return new Promise((resolve) => {
2949
+ let done = false;
2950
+ const finish = (verdict) => { if (!done) { done = true; resolve({ verdict, helper }); } };
2951
+ try {
2952
+ const { execFile } = require('child_process');
2953
+ const cmd = context.useBridge ? context.disclaim : SCREEN_AUTH_BINARY;
2954
+ const cmdArgs = context.useBridge ? [context.realNode, '-e', SCREEN_AUTH_NODE_SCRIPT, SCREEN_AUTH_BINARY] : [];
2955
+ execFile(cmd, cmdArgs, { timeout: 15000 }, (err, stdout) => {
2956
+ const out = String(stdout || '').trim();
2957
+ if (out === 'granted' || out === 'requested' || out === 'denied') return finish(out);
2958
+ if (err && err.code === 'ENOENT') return finish('unavailable');
2959
+ finish('denied');
2960
+ });
2961
+ } catch { finish('unavailable'); }
2962
+ });
2963
+ }
2964
+
2965
+ // Heuristic: a screencapture failure that means "no Screen Recording permission" (vs. a
2966
+ // user-cancelled selection, which exits 0 with no file and is handled separately).
2967
+ function isScreenRecordingPermissionError(msg) {
2968
+ return /could not create image|not authorized|screen recording|cannot run interactive|operation not permitted/i.test(msg || '');
2969
+ }
2970
+
2971
+ function screenRecordingPermissionPayload(access, extra = {}) {
2972
+ const verdict = access && access.verdict ? access.verdict : 'unavailable';
2973
+ const helper = access && access.helper ? access.helper : null;
2974
+ const helperError = helper && helper.error ? ` Helper install error: ${helper.error}` : '';
2975
+ // Name the EXACT identity macOS attributes the capture to (the responsible process of the
2976
+ // `screencapture` child), so the user grants the right thing. Historically this guidance named
2977
+ // "Coding Task Manager" — but CTM now runs as the stable notarized node, so the bundle grant is
2978
+ // a no-op and capture stays blocked. responsiblePath/Name come from screenshotResponsibleContext.
2979
+ const responsiblePath = extra.responsiblePath || process.execPath;
2980
+ const responsibleName = extra.responsibleName || (responsiblePath ? path.basename(responsiblePath) : 'CTM');
2981
+ const hint = `Enable Screen Recording for "${responsibleName}" in System Settings → Privacy & Security → `
2982
+ + `Screen & System Audio Recording, then restart CTM. If "${responsibleName}" is not in the list, click +, `
2983
+ + `press ⌘⇧G, paste ${responsiblePath}, add it, and turn it on. (CTM captures under this exact binary — `
2984
+ + `granting any other app, including "Coding Task Manager", will not work.)`;
2985
+ return {
2986
+ error: 'Screen Recording permission required',
2987
+ reason: 'screen_recording_permission',
2988
+ permission: 'screen-recording',
2989
+ verdict,
2990
+ prompted: verdict === 'requested',
2991
+ helper: helper ? {
2992
+ ok: !!helper.ok,
2993
+ reason: helper.reason || '',
2994
+ compiled: !!helper.compiled,
2995
+ signed: !!helper.signed,
2996
+ signatureId: helper.signatureId || '',
2997
+ binaryPath: helper.binaryPath || SCREEN_AUTH_BINARY,
2998
+ } : undefined,
2999
+ hint,
3000
+ details: verdict === 'requested'
3001
+ ? `macOS showed the Screen Recording prompt for "${responsibleName}". Enable "${responsibleName}" (${responsiblePath}) in System Settings → Privacy & Security → Screen & System Audio Recording, then restart CTM. Granting any other app will not work.`
3002
+ : `CTM could not confirm Screen Recording access for "${responsibleName}" (${responsiblePath}). Enable it in System Settings → Privacy & Security → Screen & System Audio Recording, then restart CTM.${helperError}`,
3003
+ responsiblePath,
3004
+ responsibleName,
3005
+ ...extra,
3006
+ };
3007
+ }
3008
+
2547
3009
  // --- Screenshot (macOS) ---
2548
3010
  async function handleScreenshot(req, res) {
2549
3011
  // Self-describing diagnostics for the "press hotkey / take screenshot → nothing
@@ -2556,14 +3018,27 @@ async function handleScreenshot(req, res) {
2556
3018
  let captureMs = 0;
2557
3019
  let writeMs = 0;
2558
3020
  try {
3021
+ const captureContext = screenshotResponsibleContext();
3022
+ const access = await requestScreenRecordingAccess(captureContext);
3023
+ if (process.platform === 'darwin' && access.verdict !== 'granted') {
3024
+ console.warn(`[screenshot] PERMISSION preflight total=${Date.now() - t0}ms sessions=${sessionCount} bridge=${captureContext.useBridge ? 1 : 0} verdict=${access.verdict} helper=${access.helper && access.helper.reason ? access.helper.reason : 'ok'}`);
3025
+ return jsonResponse(res, 403, screenRecordingPermissionPayload(access, { phase: 'preflight', bridge: !!captureContext.useBridge, responsiblePath: captureContext.responsiblePath, responsibleName: captureContext.responsibleName }));
3026
+ }
2559
3027
  const { execFile } = require('child_process');
2560
3028
  const tmpFile = path.join(db.DEFAULT_IMAGES_DIR, `screenshot-${Date.now()}.png`);
3029
+ // Capture command. When CTM runs from the self-signed .app bundle, `screencapture` is
3030
+ // attributed to com.walle.ctm (which macOS won't let a background daemon obtain a Screen
3031
+ // Recording grant for) and fails. The disclaim helper + the user's real, already-granted
3032
+ // `node` lets the capture inherit the granted "node" identity instead — see ctm-disclaim.c
3033
+ // and lib/real-node.js. Falls back to calling screencapture directly (e.g. when CTM runs
3034
+ // from real node, or the helper isn't built).
3035
+ const { cmd, cmdArgs } = screenshotCaptureCommand(tmpFile, captureContext);
2561
3036
  // Use async execFile so the event loop stays alive — this lets the browser
2562
3037
  // remain responsive and allows screencapture to work across all monitors.
2563
3038
  const tCap = Date.now();
2564
3039
  await new Promise((resolve, reject) => {
2565
- execFile('/usr/sbin/screencapture', ['-i', tmpFile], { timeout: 30000 }, (err) => {
2566
- if (err) reject(err); else resolve();
3040
+ execFile(cmd, cmdArgs, { timeout: 30000, encoding: 'utf8' }, (err, _stdout, stderr) => {
3041
+ if (err) { if (stderr) err._stderr = stderr; reject(err); } else resolve();
2567
3042
  });
2568
3043
  });
2569
3044
  captureMs = Date.now() - tCap;
@@ -2586,9 +3061,21 @@ async function handleScreenshot(req, res) {
2586
3061
  console.warn(`[screenshot] ok id=${result.id} total=${Date.now() - t0}ms capture=${captureMs}ms write=${writeMs}ms sessions=${sessionCount}`);
2587
3062
  jsonResponse(res, 201, result);
2588
3063
  } catch (e) {
2589
- const busy = /SQLITE_BUSY|write lock|database is locked/i.test(e && e.message || '');
2590
- console.warn(`[screenshot] FAILED total=${Date.now() - t0}ms capture=${captureMs}ms write=${writeMs}ms sessions=${sessionCount} busy=${busy} err=${e && e.message}`);
2591
- jsonResponse(res, 500, { error: e.message });
3064
+ const msg = ((e && e.message) || '') + ' ' + ((e && e._stderr) || '');
3065
+ const busy = /SQLITE_BUSY|write lock|database is locked/i.test(msg);
3066
+ // Fallback for stale TCC state or helper/capture disagreement. Normal requests
3067
+ // preflight Screen Recording before this point so user-initiated screenshots prompt
3068
+ // through CoreGraphics. (The global hotkey path needs no permission of its own.)
3069
+ if (isScreenRecordingPermissionError(msg)) {
3070
+ // The cached granted-node may have lost its grant (revoked / node moved) — re-probe.
3071
+ _resetGrantedScreenshotNode();
3072
+ const captureContext = screenshotResponsibleContext();
3073
+ const access = await requestScreenRecordingAccess(captureContext);
3074
+ console.warn(`[screenshot] PERMISSION capture total=${Date.now() - t0}ms capture=${captureMs}ms sessions=${sessionCount} bridge=${captureContext.useBridge ? 1 : 0} verdict=${access.verdict} err=${msg}`);
3075
+ return jsonResponse(res, 403, screenRecordingPermissionPayload(access, { phase: 'capture', bridge: !!captureContext.useBridge, responsiblePath: captureContext.responsiblePath, responsibleName: captureContext.responsibleName }));
3076
+ }
3077
+ console.warn(`[screenshot] FAILED total=${Date.now() - t0}ms capture=${captureMs}ms write=${writeMs}ms sessions=${sessionCount} busy=${busy} err=${msg}`);
3078
+ jsonResponse(res, 500, { error: msg });
2592
3079
  }
2593
3080
  }
2594
3081
 
@@ -2803,12 +3290,39 @@ async function handleGetToolPermRules(req, res) {
2803
3290
  const denyRules = [];
2804
3291
  for (const r of allRules) {
2805
3292
  const entry = { scope: 'global', project: '__global__', rule: r.rule };
2806
- if (r.list_type === 'deny') denyRules.push(entry);
2807
- else rules.push(entry);
3293
+ if (r.list_type === 'deny') {
3294
+ entry.exceptions = permissionMatch.parseExceptions(r);
3295
+ denyRules.push(entry);
3296
+ } else rules.push(entry);
2808
3297
  }
2809
3298
  jsonResponse(res, 200, { rules, denyRules, projects: [] });
2810
3299
  }
2811
3300
 
3301
+ // Validate + canonicalize one deny-rule exception entry from the client.
3302
+ // type 'path': must start with / or ~, no `.`/`..` segments; glob suffixes and
3303
+ // trailing slashes collapse ("/tmp/**" → "/tmp"). type 'rule': plain rule
3304
+ // syntax. Returns { ok, exception? , error? }.
3305
+ function validateRuleException(exc) {
3306
+ if (!exc || typeof exc !== 'object') return { ok: false, error: 'exception must be an object' };
3307
+ const type = exc.type === 'path' ? 'path' : exc.type === 'rule' ? 'rule' : null;
3308
+ if (!type) return { ok: false, error: 'exception type must be "path" or "rule"' };
3309
+ let value = String(exc.value || '').trim();
3310
+ if (!value) return { ok: false, error: 'exception value is required' };
3311
+ if (value.length > 500) return { ok: false, error: 'exception value too long (max 500)' };
3312
+ if (type === 'path') {
3313
+ value = value.replace(/\/\*{1,2}$/, '');
3314
+ while (value.length > 1 && value.endsWith('/')) value = value.slice(0, -1);
3315
+ if (!/^[~/]/.test(value)) return { ok: false, error: 'path must start with / or ~' };
3316
+ if (/\/\.\.?(\/|$)/.test(value)) return { ok: false, error: 'path must not contain . or .. segments' };
3317
+ if (value === '/' || value === '~') return { ok: false, error: 'pick a directory, not the root or home itself' };
3318
+ } else {
3319
+ if (!/^[A-Za-z]+\([^)]*\)$/.test(value)) return { ok: false, error: 'rule must look like Bash(cmd:*)' };
3320
+ if (!permissionMatch.parseRule(value)) return { ok: false, error: 'unparseable rule' };
3321
+ }
3322
+ const note = String(exc.note || '').slice(0, 200).trim();
3323
+ return { ok: true, exception: note ? { type, value, note } : { type, value } };
3324
+ }
3325
+
2812
3326
  async function handleSetToolPermRules(req, res) {
2813
3327
  try {
2814
3328
  const body = await readBody(req);
@@ -2824,6 +3338,25 @@ async function handleSetToolPermRules(req, res) {
2824
3338
  // Rules are global; remove the global row (legacy per-project rows are
2825
3339
  // collapsed to global by the db migration).
2826
3340
  db.removePermRule({ rule, listType: lt, project: '__global__' });
3341
+ } else if (action === 'add-exception' || action === 'remove-exception') {
3342
+ // Allow-exceptions attached to a DENY rule ("deny rm except under /tmp").
3343
+ const row = db.listPermRules({ listType: 'deny' }).find((r) => r.rule === rule);
3344
+ if (!row) return jsonResponse(res, 404, { error: `deny rule not found: ${rule}` });
3345
+ let exceptions = permissionMatch.parseExceptions(row);
3346
+ if (action === 'add-exception') {
3347
+ const v = validateRuleException(body.exception);
3348
+ if (!v.ok) return jsonResponse(res, 400, { error: v.error });
3349
+ if (exceptions.length >= 20) return jsonResponse(res, 400, { error: 'too many exceptions on this rule (max 20)' });
3350
+ if (!exceptions.some((e) => e.type === v.exception.type && e.value === v.exception.value)) {
3351
+ exceptions.push(v.exception);
3352
+ }
3353
+ } else {
3354
+ const value = String(body.value || '').trim();
3355
+ exceptions = exceptions.filter((e) => e.value !== value);
3356
+ }
3357
+ db.setPermRuleExceptions({ rule, listType: 'deny', project: '__global__', exceptions });
3358
+ console.log(`[perm-rules] ${action} on ${rule}: ${exceptions.length} exception(s)`);
3359
+ return jsonResponse(res, 200, { ok: true, rule, exceptions });
2827
3360
  }
2828
3361
 
2829
3362
  // CTM permissions are a standalone config in the CTM DB — intentionally NOT
@@ -2860,6 +3393,7 @@ async function classifyPermissionIntent(query) {
2860
3393
  - "listType": "allow" or "deny" (does the user want to ALLOW or BLOCK/DENY this action?)
2861
3394
  - "rules": array of Claude Code permission rule strings
2862
3395
  - "explanation": short explanation of what the rules do
3396
+ - "exceptions" (optional, ONLY with listType "deny"): array of {"type":"path","value":"/dir"} or {"type":"rule","value":"Bash(cmd:*)"} entries that carve allowed holes out of the deny — e.g. "block rm except in /tmp" → {"listType":"deny","rules":["Bash(rm:*)"],"exceptions":[{"type":"path","value":"/tmp"}]}. Phrases like "except in <dir>", "but allow under <dir>", "other than <dir>" map to a path exception.
2863
3397
 
2864
3398
  Claude Code permission rule syntax:
2865
3399
  - Bash(command:*) - match any bash command starting with "command" (e.g. Bash(git:*) matches all git commands)
@@ -2879,8 +3413,17 @@ Claude Code permission rule syntax:
2879
3413
  Deny intent indicators: "stop", "block", "deny", "prevent", "no", "never", "don't allow", "disable", "forbid", "ban"
2880
3414
  Allow intent indicators: "allow", "enable", "approve", "let", "permit", "auto-approve"
2881
3415
 
3416
+ DEFAULT TO ALLOW. This box is normally used to ADD commands to the allow list.
3417
+ Only choose "deny" when the request contains an explicit negation/blocking word
3418
+ from the deny indicators above. A bare command or rule pattern with NO negation —
3419
+ even a dangerous-looking one like "rm", "kill", "sudo", or "git push" — is an
3420
+ ALLOW request: the user is explicitly choosing to allow it. Do NOT infer "deny"
3421
+ just because a command looks risky.
3422
+
2882
3423
  For deny rules, be SPECIFIC (e.g. "no push" -> Bash(git push:*), NOT Bash(git:*)).
2883
3424
  For allow rules, match the scope the user requests.
3425
+ Shell operations map to Bash(...) rules — e.g. removing/deleting files is Bash(rm:*),
3426
+ not Write/Edit (those are the file-editing TOOLS, not shell commands).
2884
3427
 
2885
3428
  IMPORTANT: Output ONLY the JSON object, no markdown fencing.
2886
3429
 
@@ -2890,18 +3433,45 @@ User request: (see below)`;
2890
3433
  // Pass query as a separate message to avoid prompt injection via string interpolation
2891
3434
  const result = await callClaude([{ role: 'user', content: prompt + '\n\n' + query }], 256);
2892
3435
  const text = result.content?.[0]?.text || '';
2893
- const parsed = JSON.parse(text);
2894
- return {
2895
- rules: Array.isArray(parsed.rules) ? parsed.rules : [],
2896
- explanation: parsed.explanation || '',
2897
- listType: parsed.listType === 'deny' ? 'deny' : 'allow',
2898
- };
3436
+ // Tolerate models that wrap the JSON in code fences or prose despite instructions.
3437
+ const jsonText = (text.match(/\{[\s\S]*\}/) || [text])[0];
3438
+ const parsed = JSON.parse(jsonText);
3439
+ return _finalizePermIntent(query, parsed);
2899
3440
  } catch (e) {
2900
3441
  // Fallback: treat as raw rule if it looks like one
2901
3442
  return { rules: [], explanation: `Failed to interpret: ${e.message}`, listType: 'allow' };
2902
3443
  }
2903
3444
  }
2904
3445
 
3446
+ // True only when the request carries an explicit negation/blocking WORD (as a
3447
+ // standalone token, not a flag like `--no-verify`). Used to keep the add-rule
3448
+ // box allow-first: a deny rule should require the user to actually ask to block.
3449
+ function _denyHinted(query) {
3450
+ return /(?<![\w-])(no|not|never|don'?t|do not|cannot|stop|block|deny|prevent|disable|forbid|ban|refuse|reject|disallow|blacklist|avoid)(?![\w-])/i
3451
+ .test(String(query || ''));
3452
+ }
3453
+
3454
+ // Apply the allow-first correction to the LLM's raw classification. The model
3455
+ // over-DENIES scary-looking bare commands (rm, kill) that the user is actually
3456
+ // choosing to ALLOW; unless the query carries an explicit negation word, force
3457
+ // allow (and drop the now-irrelevant deny exceptions + the "blocks…" wording).
3458
+ // Pure + exported so it can be unit-tested without the LLM in the loop.
3459
+ function _finalizePermIntent(query, parsed) {
3460
+ const rules = Array.isArray(parsed && parsed.rules) ? parsed.rules : [];
3461
+ let listType = (parsed && parsed.listType) === 'deny' ? 'deny' : 'allow';
3462
+ let explanation = (parsed && parsed.explanation) || '';
3463
+ if (listType === 'deny' && !_denyHinted(query)) {
3464
+ listType = 'allow';
3465
+ explanation = rules.length ? `Allow ${rules.join(', ')}.` : `Allow ${String(query || '').trim()}.`;
3466
+ }
3467
+ // Deny-rule exceptions: validate each entry; drop anything malformed (and all
3468
+ // of them when we flipped to allow — exceptions only carve holes out of deny).
3469
+ const exceptions = (listType === 'deny' && Array.isArray(parsed && parsed.exceptions))
3470
+ ? parsed.exceptions.map((e) => validateRuleException(e)).filter((v) => v.ok).map((v) => v.exception)
3471
+ : [];
3472
+ return { rules, explanation, listType, exceptions };
3473
+ }
3474
+
2905
3475
  function handlePermAIException(res, query, parentRule, body) {
2906
3476
  const rules = [];
2907
3477
  let explanation = '';
@@ -2910,6 +3480,34 @@ function handlePermAIException(res, query, parentRule, body) {
2910
3480
  const parentMatch = parentRule.match(/^Bash\(([a-zA-Z0-9_./-]+)/);
2911
3481
  const parentBin = parentMatch ? parentMatch[1] : null;
2912
3482
 
3483
+ // Deny-side editor: suggest ALLOW exceptions for a DENY rule ("deny rm except
3484
+ // under /tmp"). A path-looking query becomes a path exception; otherwise a
3485
+ // rule-syntax exception scoped to the parent verb.
3486
+ if (body.exceptionFor === 'deny') {
3487
+ const raw = String(body.query || '').trim();
3488
+ if (/^[~/]/.test(raw)) {
3489
+ const v = validateRuleException({ type: 'path', value: raw });
3490
+ if (!v.ok) return jsonResponse(res, 200, { exceptions: [], explanation: v.error });
3491
+ return jsonResponse(res, 200, {
3492
+ exceptions: [v.exception],
3493
+ explanation: `Allow ${parentBin || 'this command'} when every target is under ${v.exception.value}.`,
3494
+ });
3495
+ }
3496
+ if (/^[A-Za-z]+\(/.test(raw)) {
3497
+ const v = validateRuleException({ type: 'rule', value: raw });
3498
+ if (!v.ok) return jsonResponse(res, 200, { exceptions: [], explanation: v.error });
3499
+ return jsonResponse(res, 200, { exceptions: [v.exception], explanation: `Allow commands matching ${v.exception.value}.` });
3500
+ }
3501
+ const cleaned = raw.replace(/\b(allow|except|in|under|the|dir|directory|folder)\b/gi, ' ').trim();
3502
+ if (parentBin && cleaned) {
3503
+ const v = validateRuleException({ type: 'rule', value: `Bash(${parentBin} ${cleaned}:*)` });
3504
+ if (v.ok) {
3505
+ return jsonResponse(res, 200, { exceptions: [v.exception], explanation: `Allow "${parentBin} ${cleaned}" commands.` });
3506
+ }
3507
+ }
3508
+ return jsonResponse(res, 200, { exceptions: [], explanation: 'Type a directory (e.g. /tmp) or a rule like Bash(rm -i:*).' });
3509
+ }
3510
+
2913
3511
  // Check if it looks like a direct rule pattern already
2914
3512
  if (/^Bash\(/.test(body.query || '')) {
2915
3513
  rules.push(body.query.trim());
@@ -3364,13 +3962,16 @@ function handleListQueues(req, res) {
3364
3962
  async function handleCreateQueue(req, res) {
3365
3963
  try {
3366
3964
  const body = await readBody(req);
3367
- const { sessionId, mode, items, idleTimeoutMs, autoStart, append, strategy } = body;
3965
+ const { sessionId, mode, items, idleTimeoutMs, autoStart, append, strategy, resend } = body;
3368
3966
  if (!sessionId || !Array.isArray(items) || items.length === 0) {
3369
3967
  return jsonResponse(res, 400, { error: 'sessionId and non-empty items[] required' });
3370
3968
  }
3371
3969
  const appendMode = append === true || strategy === 'append';
3970
+ // `resend` (request- or per-item-level) is the explicit "Re-send this item"
3971
+ // intent — the ONLY thing that re-runs an already-finished queue item. A bare
3972
+ // re-post without it never re-dispatches a delivered item (c3f3af97 guard).
3372
3973
  let state = appendMode
3373
- ? queueEngine.appendItems(sessionId, { mode, items, idleTimeoutMs, autoStart })
3974
+ ? queueEngine.appendItems(sessionId, { mode, items, idleTimeoutMs, autoStart, resend })
3374
3975
  : queueEngine.createQueue(sessionId, { mode, items, idleTimeoutMs });
3375
3976
  if (!appendMode && autoStart) state = queueEngine.start(sessionId) || state;
3376
3977
  jsonResponse(res, 201, state);
@@ -3534,17 +4135,20 @@ function handleListApprovalRules(req, res) {
3534
4135
  }
3535
4136
 
3536
4137
  // Grouping key for an escalation row: prefer the recorded signature; for legacy
3537
- // rows (recorded before signatures were stored) derive it from the captured
4138
+ // rows (recorded before signatures were stored, OR whose summary is a reason
4139
+ // label like "Blocklist: …" — those often carry contaminated signatures that
4140
+ // split identical commands into duplicate groups) derive it from the captured
3538
4141
  // context via the same helper the approver uses, so old + new rows group together.
3539
4142
  function _escalationKeyFn(row) {
3540
- if (row && row.command_signature) return row.command_signature;
4143
+ const labelRow = escalationReview.isLabelSummary(row && row.command_summary);
4144
+ if (row && row.command_signature && !labelRow) return row.command_signature;
3541
4145
  try {
3542
4146
  return approvalAgent.escalationCommandParts({
3543
4147
  toolName: row && row.tool_name,
3544
4148
  command: '',
3545
4149
  fullContext: row && row.full_context,
3546
- }).signature;
3547
- } catch { return ''; }
4150
+ }).signature || (row && row.command_signature) || '';
4151
+ } catch { return (row && row.command_signature) || ''; }
3548
4152
  }
3549
4153
 
3550
4154
  // GET /api/approval-escalations — escalated commands grouped by TYPE for the
@@ -3561,6 +4165,65 @@ function handleListEscalations(req, res) {
3561
4165
  }
3562
4166
  }
3563
4167
 
4168
+ // A Wall-E coding turn registers its park as an escalated row carrying the
4169
+ // Wall-E request id. When that row is resolved here, the live turn is still
4170
+ // blocked on its own Promise — so call the Wall-E reply endpoint to unpark it
4171
+ // (the same endpoint the chat card uses). On a newly-created allow rule, also
4172
+ // ask Wall-E to re-check its OTHER parks so a now-covered one self-heals (the
4173
+ // no-poller cascade). Best-effort: CTM's row is already resolved regardless.
4174
+ async function _notifyWalleParkResolution(rowsById, resolvedIds, reply, { ruleAdded = false } = {}) {
4175
+ const sessions = new Set();
4176
+ for (const id of resolvedIds) {
4177
+ const row = rowsById.get(Number(id));
4178
+ const reqId = row && (row.walle_request_id || row.walleRequestId);
4179
+ if (!reqId) continue;
4180
+ if (row.walle_session_id) sessions.add(row.walle_session_id);
4181
+ try {
4182
+ await walleClient.requestJson(`/api/wall-e/permissions/${encodeURIComponent(reqId)}/reply`, {
4183
+ method: 'POST', body: { reply },
4184
+ });
4185
+ } catch (e) { /* the turn may be gone — the CTM row state is already updated */ }
4186
+ }
4187
+ if (ruleAdded) {
4188
+ for (const sid of sessions) {
4189
+ try {
4190
+ await walleClient.requestJson('/api/wall-e/permissions/reevaluate', { method: 'POST', body: { session_id: sid } });
4191
+ } catch (e) { /* best-effort cascade */ }
4192
+ }
4193
+ }
4194
+ }
4195
+
4196
+ // Mint durable low-risk learned allow rules from a set of normalized command
4197
+ // signatures. These are matched tier-1 by signature in decideApproval (BEFORE the
4198
+ // verifier), so the same command SHAPE auto-approves next time regardless of the
4199
+ // varying parts the signature collapses (ports/SIDs/paths → <num>/<id>/<path>).
4200
+ // This is what makes "Approve" actually stick — the prefix perm-rule the button
4201
+ // also creates can't express a parameterized command. Explicit user approval (the
4202
+ // Pending-tab buttons) vouches for the command, so we promote regardless of the
4203
+ // escalation's risk level; the dangerous-command blocklist still runs first in the
4204
+ // cascade and is never overridden. Idempotent (upsert keys on the escaped pattern).
4205
+ function _learnSignatureRules(signatures, { label, category, source } = {}, dbi = db) {
4206
+ let created = 0;
4207
+ for (const sig of (Array.isArray(signatures) ? signatures : [])) {
4208
+ const s = String(sig || '').trim();
4209
+ if (!s) continue;
4210
+ try {
4211
+ dbi.upsertApprovalRule({
4212
+ pattern: selfAdapt._escapeRegex(s),
4213
+ commandSignature: s,
4214
+ label: label || 'Approved from queue',
4215
+ description: 'Auto-learned: you approved this command from the Pending queue.',
4216
+ category: category || 'bash-command',
4217
+ riskLevel: 'low',
4218
+ enabled: true,
4219
+ autoLearnedSource: source || 'approve_queue',
4220
+ });
4221
+ created += 1;
4222
+ } catch { /* rule may already exist — ON CONFLICT just bumps times_matched */ }
4223
+ }
4224
+ return created;
4225
+ }
4226
+
3564
4227
  // POST /api/approval-escalations/resolve — resolve a whole TYPE at once.
3565
4228
  // body: { action: 'whitelist'|'block'|'dismiss'|'dismiss-all', ids:[...], rule? }
3566
4229
  // whitelist/block create an authoritative perm_rules allow/deny (honored by the
@@ -3573,6 +4236,9 @@ async function handleResolveEscalations(req, res) {
3573
4236
  if (!['whitelist', 'block', 'dismiss', 'dismiss-all', 'approve-all'].includes(action)) {
3574
4237
  return jsonResponse(res, 400, { error: 'invalid_action' });
3575
4238
  }
4239
+ // Snapshot pending rows BEFORE resolving so we can still see which carry a
4240
+ // Wall-E request id (resolved rows drop out of getPendingEscalations()).
4241
+ const _pendingRowsById = new Map((db.getPendingEscalations() || []).map((r) => [Number(r.id), r]));
3576
4242
  // Bulk approve: whitelist EVERY pending type (one allow rule per group from its
3577
4243
  // suggested narrow pattern) and clear the queue. Re-group server-side so the
3578
4244
  // action is atomic and can't be skewed by a stale client view.
@@ -3580,8 +4246,27 @@ async function handleResolveEscalations(req, res) {
3580
4246
  const rows = db.getPendingEscalations() || [];
3581
4247
  const groups = escalationReview.groupEscalations(rows, _escalationKeyFn);
3582
4248
  let resolved = 0;
4249
+ let failed = 0;
3583
4250
  let rulesCreated = 0;
4251
+ let exceptionsAdded = 0;
4252
+ let firstError = '';
4253
+ const _approveAllResolvedIds = [];
3584
4254
  for (const g of groups) {
4255
+ // Blocklist-escalated types can't be allow-listed (the blocklist runs first
4256
+ // and is not overridden by allow rules). Bulk-approve them EXACTLY like the
4257
+ // per-row "Exception & resolve" button: add the same NARROW "never block"
4258
+ // exception (single plain clause, verb+dir-anchored — can't match rm -rf /
4259
+ // or curl|bash) and DISMISS the rows. One click clears the whole queue; the
4260
+ // floor stays for everything it didn't explicitly except.
4261
+ if (escalationReview.isBlocklistGroup(g)) {
4262
+ const exc = escalationReview.suggestException(g);
4263
+ if (exc) exceptionsAdded += _appendBlocklistExceptions([exc], `Approved in bulk: ${g.title || 'command'}`.slice(0, 200));
4264
+ for (const id of (g.ids || [])) {
4265
+ try { db.resolveApprovalDecision(id, 'dismissed'); resolved += 1; }
4266
+ catch (e) { failed += 1; if (!firstError) firstError = e.message; }
4267
+ }
4268
+ continue;
4269
+ }
3585
4270
  const r = String(g.suggestedRule || '').trim();
3586
4271
  if (/^[A-Za-z]+\([^)]*\)$/.test(r)) {
3587
4272
  try {
@@ -3589,11 +4274,29 @@ async function handleResolveEscalations(req, res) {
3589
4274
  rulesCreated += 1;
3590
4275
  } catch (e) { /* rule may already exist — still clear the rows below */ }
3591
4276
  }
4277
+ // Durable signature rules — the matcher that actually auto-approves the next
4278
+ // (parameterized) run; the prefix rule above can't express ports/SIDs/paths.
4279
+ rulesCreated += _learnSignatureRules(g.signatures, {
4280
+ label: g.title || 'Approved in bulk',
4281
+ category: String(g.toolName || 'bash').toLowerCase().replace(/\s+/g, '-'),
4282
+ source: 'approve_all',
4283
+ });
3592
4284
  for (const id of (g.ids || [])) {
3593
- try { db.resolveApprovalDecision(id, 'approved'); resolved += 1; } catch (e) { /* skip */ }
4285
+ // Don't swallow resolve failures into a false "success": if the write
4286
+ // fails (DB contention, etc.) the row stays escalated and the queue
4287
+ // won't clear, so count it and report it instead of reporting ok.
4288
+ try { db.resolveApprovalDecision(id, 'approved'); resolved += 1; _approveAllResolvedIds.push(id); }
4289
+ catch (e) { failed += 1; if (!firstError) firstError = e.message; }
3594
4290
  }
3595
4291
  }
3596
- return jsonResponse(res, 200, { ok: true, action, resolved, rulesCreated, typeCount: groups.length });
4292
+ // Unpark any Wall-E turns among the approved rows + cascade to their siblings.
4293
+ await _notifyWalleParkResolution(_pendingRowsById, _approveAllResolvedIds, 'once', { ruleAdded: true });
4294
+ const total = rows.length;
4295
+ return jsonResponse(res, 200, {
4296
+ ok: failed === 0,
4297
+ action, resolved, failed, total, rulesCreated, exceptionsAdded, typeCount: groups.length,
4298
+ error: failed ? `Could not clear ${failed} of ${total} escalation(s): ${firstError}` : undefined,
4299
+ });
3597
4300
  }
3598
4301
  let createdRule = null;
3599
4302
  if (action === 'whitelist' || action === 'block') {
@@ -3610,12 +4313,36 @@ async function handleResolveEscalations(req, res) {
3610
4313
  targetIds = (Array.isArray(body.ids) ? body.ids : []).map(Number).filter(Number.isFinite);
3611
4314
  if (!targetIds.length) return jsonResponse(res, 400, { error: 'ids_required' });
3612
4315
  }
4316
+ // For an explicit whitelist, also mint durable signature rules from the rows
4317
+ // being approved (read their signatures BEFORE resolving clears them) — so the
4318
+ // same command shape auto-approves next run, not just this one instance.
4319
+ let rulesCreated = 0;
4320
+ if (action === 'whitelist') {
4321
+ const idSet = new Set(targetIds);
4322
+ const sigs = (db.getPendingEscalations() || [])
4323
+ .filter((r) => idSet.has(r.id))
4324
+ .map((r) => r.command_signature);
4325
+ rulesCreated = _learnSignatureRules(sigs, { label: createdRule || 'Approved', source: 'approve_row' });
4326
+ }
3613
4327
  const resolveDecision = action === 'block' ? 'denied' : action === 'whitelist' ? 'approved' : 'dismissed';
3614
4328
  let resolved = 0;
4329
+ let failed = 0;
4330
+ let firstError = '';
4331
+ const _resolvedIds = [];
3615
4332
  for (const id of targetIds) {
3616
- try { db.resolveApprovalDecision(id, resolveDecision); resolved += 1; } catch (e) { /* skip */ }
4333
+ try { db.resolveApprovalDecision(id, resolveDecision); resolved += 1; _resolvedIds.push(id); }
4334
+ catch (e) { failed += 1; if (!firstError) firstError = e.message; }
3617
4335
  }
3618
- jsonResponse(res, 200, { ok: true, action, resolved, rule: createdRule });
4336
+ // Unpark any Wall-E coding turns among the resolved rows: approve (whitelist)
4337
+ // → reply 'once' (the CTM rule already persists for next time); block/dismiss
4338
+ // → 'reject'. A new allow rule also cascades to the session's other parks.
4339
+ const walleReply = (action === 'whitelist') ? 'once' : 'reject';
4340
+ await _notifyWalleParkResolution(_pendingRowsById, _resolvedIds, walleReply, { ruleAdded: action === 'whitelist' });
4341
+ jsonResponse(res, 200, {
4342
+ ok: failed === 0,
4343
+ action, resolved, failed, total: targetIds.length, rule: createdRule, rulesCreated,
4344
+ error: failed ? `Could not clear ${failed} of ${targetIds.length} escalation(s): ${firstError}` : undefined,
4345
+ });
3619
4346
  } catch (e) {
3620
4347
  jsonResponse(res, 400, { error: e.message });
3621
4348
  }
@@ -3676,16 +4403,47 @@ async function handleResolveApprovalDecision(req, res, id) {
3676
4403
 
3677
4404
  // --- Dangerous-command blocklist (configurable; default ON) ---
3678
4405
  // GET /api/approval/blocklist
3679
- // -> { enabled, patterns: [defaults], disabledIds: [int], custom: [{source,flags,reason,category,id}] }
3680
- // POST /api/approval/blocklist { enabled?, disabledIds?, customPatterns? }
4406
+ // -> { enabled, patterns: [defaults], disabledIds: [int], custom: [{source,flags,reason,category,id}],
4407
+ // exceptions: [{source,flags,reason,id}] }
4408
+ // POST /api/approval/blocklist { enabled?, disabledIds?, customPatterns?, exceptions? }
3681
4409
  // -> the same shape as GET (the merged view). Any field omitted is left as-is.
3682
- // Custom patterns are validated server-side; an invalid one returns 400 and
3683
- // nothing is persisted. The hard floor is editable but never silently broken.
4410
+ // Custom patterns and exceptions are validated server-side; an invalid one
4411
+ // returns 400 and nothing is persisted. The hard floor is editable but never
4412
+ // silently broken.
3684
4413
  //
3685
4414
  // Persistence: `approval_blocklist_enabled` (bool) + `approval_blocklist_config`
3686
- // ({ disabledIds:[int], customPatterns:[{source,flags,reason,category}] }).
4415
+ // ({ disabledIds:[int], customPatterns:[{source,flags,reason,category}],
4416
+ // exceptions:[{source,flags,reason}] }).
3687
4417
  // The agent reads both on every check (approval-agent isBlocklistEnabled /
3688
4418
  // getBlocklistConfig), so edits take effect without a restart.
4419
+ // Append NARROW "never block" exceptions to the blocklist config (validated via
4420
+ // the same guard the endpoint uses, deduped by source). Shared by the Pending
4421
+ // "Approve all" path (parity with the per-row "Exception & resolve" button) and
4422
+ // available to any caller that needs to add an exception programmatically.
4423
+ // Skips un-validatable patterns rather than throwing, so a bulk action never aborts
4424
+ // on one bad signature. Returns the count actually added.
4425
+ function _appendBlocklistExceptions(sources, reasonLabel) {
4426
+ const list = Array.isArray(sources) ? sources : [];
4427
+ if (!list.length) return 0;
4428
+ const { validateUserPattern } = require('./workers/approval-blocklist');
4429
+ const cfg = db.getSetting('approval_blocklist_config', null) || {};
4430
+ const exceptions = Array.isArray(cfg.exceptions) ? cfg.exceptions.slice() : [];
4431
+ const seen = new Set(exceptions.map((e) => String((e && e.source) || '')));
4432
+ let added = 0;
4433
+ for (const src of list) {
4434
+ const s = String(src || '').trim();
4435
+ if (!s || seen.has(s)) continue;
4436
+ const v = validateUserPattern({ source: s, flags: '', reason: reasonLabel || 'Approved from queue (never block)' });
4437
+ if (!v.ok) continue;
4438
+ const { category, ...rest } = v.normalized; // exceptions carry no category
4439
+ exceptions.push({ ...rest, reason: rest.reason === 'Custom blocklist pattern' ? 'Blocklist exception' : rest.reason });
4440
+ seen.add(s);
4441
+ added += 1;
4442
+ }
4443
+ if (added) { cfg.exceptions = exceptions; db.setSetting('approval_blocklist_config', cfg); }
4444
+ return added;
4445
+ }
4446
+
3689
4447
  function _blocklistView() {
3690
4448
  const { PATTERN_META } = require('./workers/approval-blocklist');
3691
4449
  const enabled = db.getSetting('approval_blocklist_enabled', true) !== false;
@@ -3702,7 +4460,15 @@ function _blocklistView() {
3702
4460
  category: String((p && p.category) || 'custom'),
3703
4461
  }))
3704
4462
  : [];
3705
- return { enabled, patterns: PATTERN_META, disabledIds, custom };
4463
+ const exceptions = Array.isArray(cfg.exceptions)
4464
+ ? cfg.exceptions.map((p, i) => ({
4465
+ id: (p && p.id != null) ? p.id : `e${i}`,
4466
+ source: String((p && p.source) || ''),
4467
+ flags: String((p && p.flags) || ''),
4468
+ reason: String((p && p.reason) || 'Blocklist exception'),
4469
+ }))
4470
+ : [];
4471
+ return { enabled, patterns: PATTERN_META, disabledIds, custom, exceptions };
3706
4472
  }
3707
4473
  function handleGetBlocklist(_req, res) {
3708
4474
  try {
@@ -3720,7 +4486,8 @@ async function handleSetBlocklistEnabled(req, res) {
3720
4486
  }
3721
4487
 
3722
4488
  const touchesConfig = Object.prototype.hasOwnProperty.call(body, 'disabledIds')
3723
- || Object.prototype.hasOwnProperty.call(body, 'customPatterns');
4489
+ || Object.prototype.hasOwnProperty.call(body, 'customPatterns')
4490
+ || Object.prototype.hasOwnProperty.call(body, 'exceptions');
3724
4491
  if (touchesConfig) {
3725
4492
  const cfg = db.getSetting('approval_blocklist_config', null) || {};
3726
4493
 
@@ -3747,8 +4514,21 @@ async function handleSetBlocklistEnabled(req, res) {
3747
4514
  cfg.customPatterns = normalized;
3748
4515
  }
3749
4516
 
4517
+ if (Object.prototype.hasOwnProperty.call(body, 'exceptions')) {
4518
+ if (!Array.isArray(body.exceptions)) return jsonResponse(res, 400, { error: 'exceptions must be an array' });
4519
+ if (body.exceptions.length > 200) return jsonResponse(res, 400, { error: 'too many exceptions (max 200)' });
4520
+ const normalized = [];
4521
+ for (let i = 0; i < body.exceptions.length; i++) {
4522
+ const v = validateUserPattern(body.exceptions[i]);
4523
+ if (!v.ok) return jsonResponse(res, 400, { error: `exception ${i + 1}: ${v.error}` });
4524
+ const { category, ...rest } = v.normalized; // exceptions have no category
4525
+ normalized.push({ ...rest, reason: rest.reason === 'Custom blocklist pattern' ? 'Blocklist exception' : rest.reason });
4526
+ }
4527
+ cfg.exceptions = normalized;
4528
+ }
4529
+
3750
4530
  db.setSetting('approval_blocklist_config', cfg);
3751
- console.log(`[approval-blocklist] config updated: ${(cfg.disabledIds || []).length} disabled, ${(cfg.customPatterns || []).length} custom`);
4531
+ console.log(`[approval-blocklist] config updated: ${(cfg.disabledIds || []).length} disabled, ${(cfg.customPatterns || []).length} custom, ${(cfg.exceptions || []).length} exceptions`);
3752
4532
  }
3753
4533
 
3754
4534
  jsonResponse(res, 200, _blocklistView());
@@ -4347,4 +5127,4 @@ function safeParse(json, fallback) {
4347
5127
  try { return JSON.parse(json); } catch { return fallback; }
4348
5128
  }
4349
5129
 
4350
- module.exports = { handlePromptApi, queueEngine, runIncrementalConversationImport, runCursorConversationImport, importSessionFile, setUiPrefsBroadcaster, setPromptExecutionsOffThread, setDbMaintenanceRunner, setImageSaveRunner, _ingestPathFromInput, _ingestSourceAllowed, _conversationImportCandidates, _ingestTranscriptStoreForParsedFile };
5130
+ module.exports = { handlePromptApi, queueEngine, runIncrementalConversationImport, runCursorConversationImport, importSessionFile, setUiPrefsBroadcaster, setPromptExecutionsOffThread, setDbMaintenanceRunner, setImageSaveRunner, ensureHotkeyDaemon, hotkeyEnsureAction, screenshotResponsibleContext, screenshotNodeCandidates, probeScreenshotNodeGranted, _ingestPathFromInput, _ingestSourceAllowed, _conversationImportCandidates, _ingestTranscriptStoreForParsedFile, _learnSignatureRules, _appendBlocklistExceptions, _finalizePermIntent, _denyHinted, _lastConversationImportAt };