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.
- package/README.md +8 -0
- package/bin/create-walle.js +815 -45
- package/package.json +2 -2
- package/template/bin/ctm-dev-cleanup.js +90 -4
- package/template/bin/ctm-launch.sh +49 -1
- package/template/bin/dev.sh +45 -1
- package/template/bin/ensure-stable-node.js +132 -0
- package/template/bin/install-service.sh +9 -0
- package/template/claude-task-manager/api-prompts.js +899 -119
- package/template/claude-task-manager/approval-agent.js +360 -40
- package/template/claude-task-manager/bin/ctm-disclaim.c +42 -0
- package/template/claude-task-manager/bin/ctm-hotkey.swift +67 -81
- package/template/claude-task-manager/bin/ctm-screen-auth.swift +37 -0
- package/template/claude-task-manager/bin/install-hotkey.sh +97 -49
- package/template/claude-task-manager/bin/restart-ctm.sh +14 -0
- package/template/claude-task-manager/db.js +399 -48
- package/template/claude-task-manager/docs/approval-hook-sandbox.md +84 -0
- package/template/claude-task-manager/docs/codex-app-server-approvals.md +72 -0
- package/template/claude-task-manager/docs/codex-native-sandbox.md +47 -0
- package/template/claude-task-manager/docs/prompt-editing-tree-design.md +18 -1
- package/template/claude-task-manager/lib/approval-hook.js +200 -0
- package/template/claude-task-manager/lib/approval-self-adapt.js +1 -0
- package/template/claude-task-manager/lib/auth-rules.js +11 -0
- package/template/claude-task-manager/lib/background-llm.js +32 -4
- package/template/claude-task-manager/lib/codesign-identity.js +140 -0
- package/template/claude-task-manager/lib/codex-app-server-client.js +119 -0
- package/template/claude-task-manager/lib/codex-approval-bridge.js +118 -0
- package/template/claude-task-manager/lib/codex-history-terminal-renderer.js +571 -0
- package/template/claude-task-manager/lib/codex-paths.js +73 -0
- package/template/claude-task-manager/lib/codex-rollout-snapshot.js +164 -0
- package/template/claude-task-manager/lib/codex-rollout-tail.js +72 -0
- package/template/claude-task-manager/lib/codex-sandbox-args.js +47 -0
- package/template/claude-task-manager/lib/coding-agent-models.js +118 -71
- package/template/claude-task-manager/lib/command-targets.js +163 -0
- package/template/claude-task-manager/lib/conversation-tail-merge.js +61 -19
- package/template/claude-task-manager/lib/db-owner-worker-client.js +29 -1
- package/template/claude-task-manager/lib/escalation-review.js +80 -3
- package/template/claude-task-manager/lib/flow-control.js +52 -0
- package/template/claude-task-manager/lib/fs-watcher.js +24 -15
- package/template/claude-task-manager/lib/ingest-cooldown.js +68 -0
- package/template/claude-task-manager/lib/jsonl-conversation-parser.js +8 -4
- package/template/claude-task-manager/lib/launchd-recovery.js +92 -0
- package/template/claude-task-manager/lib/microsoft-dev-tunnel-setup.js +207 -52
- package/template/claude-task-manager/lib/mobile-push-store.js +7 -0
- package/template/claude-task-manager/lib/model-overview-brain-fallback.js +102 -1
- package/template/claude-task-manager/lib/model-overview-cache.js +1 -0
- package/template/claude-task-manager/lib/oauth-proxy-supervisor.js +2 -1
- package/template/claude-task-manager/lib/perf-tracker.js +29 -2
- package/template/claude-task-manager/lib/permission-match.js +146 -16
- package/template/claude-task-manager/lib/project-slug.js +33 -0
- package/template/claude-task-manager/lib/prompt-intent.js +51 -4
- package/template/claude-task-manager/lib/read-pool-client.js +48 -3
- package/template/claude-task-manager/lib/real-node.js +73 -0
- package/template/claude-task-manager/lib/runtime-work-registry.js +131 -14
- package/template/claude-task-manager/lib/session-content-backfill.js +24 -5
- package/template/claude-task-manager/lib/session-diagnostics-batch.js +87 -0
- package/template/claude-task-manager/lib/session-history.js +5 -7
- package/template/claude-task-manager/lib/session-host-manager.js +19 -0
- package/template/claude-task-manager/lib/session-jobs.js +6 -0
- package/template/claude-task-manager/lib/session-message-response-cache.js +89 -0
- package/template/claude-task-manager/lib/session-messages-page.js +211 -0
- package/template/claude-task-manager/lib/session-messages-projection.js +170 -0
- package/template/claude-task-manager/lib/session-standup.js +8 -0
- package/template/claude-task-manager/lib/session-timeline-summary.js +16 -2
- package/template/claude-task-manager/lib/session-token-usage.js +30 -8
- package/template/claude-task-manager/lib/session-workspace-binding.js +29 -15
- package/template/claude-task-manager/lib/storage-migration.js +2 -1
- package/template/claude-task-manager/lib/transcript-store.js +179 -12
- package/template/claude-task-manager/lib/walle-ctm-history.js +298 -11
- package/template/claude-task-manager/lib/walle-permission-reply.js +49 -0
- package/template/claude-task-manager/lib/walle-session-cache.js +22 -1
- package/template/claude-task-manager/lib/walle-supervisor.js +42 -3
- package/template/claude-task-manager/package.json +5 -2
- package/template/claude-task-manager/prompt-harvest.js +31 -11
- package/template/claude-task-manager/providers/claude-code.js +29 -1
- package/template/claude-task-manager/providers/codex.js +13 -1
- package/template/claude-task-manager/public/css/setup.css +11 -0
- package/template/claude-task-manager/public/css/walle-session.css +132 -4
- package/template/claude-task-manager/public/css/walle.css +89 -0
- package/template/claude-task-manager/public/icon-16.png +0 -0
- package/template/claude-task-manager/public/icon-32.png +0 -0
- package/template/claude-task-manager/public/icon-512.png +0 -0
- package/template/claude-task-manager/public/index.html +2483 -165
- package/template/claude-task-manager/public/js/activation-render-check.js +55 -0
- package/template/claude-task-manager/public/js/flow-control-policy.js +52 -0
- package/template/claude-task-manager/public/js/message-renderer.js +60 -1
- package/template/claude-task-manager/public/js/prompts.js +13 -1
- package/template/claude-task-manager/public/js/session-status-precedence.js +9 -3
- package/template/claude-task-manager/public/js/setup.js +54 -10
- package/template/claude-task-manager/public/js/stream-resize-policy.js +80 -0
- package/template/claude-task-manager/public/js/stream-view.js +78 -0
- package/template/claude-task-manager/public/js/terminal-reconciler.js +52 -2
- package/template/claude-task-manager/public/js/tool-state.js +155 -0
- package/template/claude-task-manager/public/js/walle-session.js +887 -326
- package/template/claude-task-manager/public/js/walle.js +306 -195
- package/template/claude-task-manager/public/m/app.css +1 -0
- package/template/claude-task-manager/public/m/app.js +33 -3
- package/template/claude-task-manager/queue-engine.js +45 -1
- package/template/claude-task-manager/server.js +3367 -540
- package/template/claude-task-manager/workers/approval-blocklist.js +130 -17
- package/template/claude-task-manager/workers/db-owner-worker.js +31 -1
- package/template/claude-task-manager/workers/read-pool-worker.js +92 -5
- package/template/claude-task-manager/workers/session-host-process.js +10 -0
- package/template/claude-task-manager/workers/state-detectors/codex.js +58 -7
- package/template/package.json +2 -3
- package/template/shared/icons/AppIcon-ctm.icns +0 -0
- package/template/shared/icons/AppIcon-walle.icns +0 -0
- package/template/wall-e/agent.js +139 -18
- package/template/wall-e/api-walle.js +201 -22
- package/template/wall-e/bin/train-gemma-e4b-tooluse.js +1981 -0
- package/template/wall-e/brain.js +1053 -43
- package/template/wall-e/chat.js +427 -86
- package/template/wall-e/coding/acceptance-contract.js +26 -1
- package/template/wall-e/coding/action-memory-policy.js +353 -0
- package/template/wall-e/coding/action-memory-store.js +814 -0
- package/template/wall-e/coding/initial-messages.js +197 -0
- package/template/wall-e/coding/no-progress-guard.js +327 -0
- package/template/wall-e/coding/permission-service.js +88 -22
- package/template/wall-e/coding/session-workspaces.js +81 -0
- package/template/wall-e/coding/shell-sandbox.js +124 -0
- package/template/wall-e/coding/stream-processor.js +63 -2
- package/template/wall-e/coding/tool-execution-controller.js +14 -1
- package/template/wall-e/coding/tool-registry.js +1 -1
- package/template/wall-e/coding/transcript-writer.js +3 -0
- package/template/wall-e/coding-orchestrator.js +636 -35
- package/template/wall-e/coding-prompts.js +51 -2
- package/template/wall-e/docs/model-routing-policy.md +59 -0
- package/template/wall-e/docs/walle-shell-sandbox.md +61 -0
- package/template/wall-e/extraction/knowledge-extractor.js +76 -23
- package/template/wall-e/http/chat-api.js +30 -12
- package/template/wall-e/http/model-admin.js +93 -1
- package/template/wall-e/lib/background-lanes.js +133 -0
- package/template/wall-e/lib/boot-profile.js +11 -0
- package/template/wall-e/lib/brain-owner-worker-client.js +324 -0
- package/template/wall-e/lib/brain-read-pool-client.js +311 -0
- package/template/wall-e/lib/diagnostics-flags.js +87 -0
- package/template/wall-e/lib/event-loop-monitor.js +74 -3
- package/template/wall-e/lib/mcp-integration.js +7 -1
- package/template/wall-e/lib/real-node.js +98 -0
- package/template/wall-e/lib/runtime-health.js +206 -0
- package/template/wall-e/lib/runtime-worker-pool.js +101 -0
- package/template/wall-e/lib/scheduler-worker-jobs.js +231 -0
- package/template/wall-e/lib/scheduler.js +446 -17
- package/template/wall-e/lib/service-health.js +61 -2
- package/template/wall-e/lib/service-readiness.js +258 -0
- package/template/wall-e/lib/usage.js +152 -0
- package/template/wall-e/lib/worker-thread-pool.js +389 -0
- package/template/wall-e/llm/client.js +81 -4
- package/template/wall-e/llm/default-fallback.js +54 -8
- package/template/wall-e/llm/mlx.js +536 -73
- package/template/wall-e/llm/mlx.plugin.json +1 -1
- package/template/wall-e/llm/ollama.js +342 -43
- package/template/wall-e/llm/provider-error.js +18 -1
- package/template/wall-e/llm/provider-health-state.js +176 -0
- package/template/wall-e/llm/routing-policy.js +796 -0
- package/template/wall-e/llm/supported-models.js +5 -0
- package/template/wall-e/loops/tasks.js +60 -14
- package/template/wall-e/loops/think.js +89 -24
- package/template/wall-e/mcp-server.js +192 -28
- package/template/wall-e/server.js +32 -7
- package/template/wall-e/shared/sqlite-owner-guard.js +30 -0
- package/template/wall-e/shared/sqlite-owner-write-queue.js +225 -0
- package/template/wall-e/shared/sqlite-storage-policy.js +111 -0
- package/template/wall-e/shared/sqlite-write-lock.js +428 -0
- package/template/wall-e/skills/script-skill-runner.js +8 -1
- package/template/wall-e/skills/skill-planner.js +64 -1
- package/template/wall-e/tools/builtin-middleware.js +67 -2
- package/template/wall-e/tools/local-tools.js +116 -26
- package/template/wall-e/tools/permission-checker.js +52 -4
- package/template/wall-e/tools/permission-rules.js +36 -0
- package/template/wall-e/tools/shell-analyzer.js +46 -1
- package/template/wall-e/training/gemma-e4b-qlora.js +314 -0
- package/template/wall-e/training/real-trajectory-miner.js +2617 -0
- package/template/wall-e/training/replay-eval-analysis.js +151 -0
- package/template/wall-e/training/run-shell-command-selector.js +277 -0
- package/template/wall-e/training/tool-sft-dataset.js +312 -0
- package/template/wall-e/training/tool-sft-renderers.js +144 -0
- package/template/wall-e/training/tool-trace-harvester.js +1440 -0
- package/template/wall-e/training/trajectory-action-selector.js +364 -0
- package/template/wall-e/weather-runtime.js +232 -0
- package/template/wall-e/workers/brain-owner-worker.js +162 -0
- package/template/wall-e/workers/brain-read-worker.js +148 -0
- 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
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
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
|
|
630
|
-
const
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
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}
|
|
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 (
|
|
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
|
-
|
|
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(
|
|
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
|
-
//
|
|
2458
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2474
|
-
|
|
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
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
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
|
-
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
|
|
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
|
-
|
|
2494
|
-
|
|
2495
|
-
|
|
2496
|
-
|
|
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>${
|
|
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
|
-
<
|
|
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
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
2525
|
-
|
|
2526
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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(
|
|
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
|
|
2590
|
-
|
|
2591
|
-
|
|
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')
|
|
2807
|
-
|
|
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
|
-
|
|
2894
|
-
|
|
2895
|
-
|
|
2896
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
|
4333
|
+
try { db.resolveApprovalDecision(id, resolveDecision); resolved += 1; _resolvedIds.push(id); }
|
|
4334
|
+
catch (e) { failed += 1; if (!firstError) firstError = e.message; }
|
|
3617
4335
|
}
|
|
3618
|
-
|
|
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
|
-
//
|
|
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
|
|
3683
|
-
// nothing is persisted. The hard floor is editable but never
|
|
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
|
-
|
|
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 };
|