create-walle 0.9.25 → 0.9.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (181) hide show
  1. package/README.md +8 -0
  2. package/bin/create-walle.js +815 -45
  3. package/package.json +2 -2
  4. package/template/bin/ctm-dev-cleanup.js +90 -4
  5. package/template/bin/ctm-launch.sh +49 -1
  6. package/template/bin/dev.sh +45 -1
  7. package/template/bin/ensure-stable-node.js +132 -0
  8. package/template/bin/install-service.sh +9 -0
  9. package/template/claude-task-manager/api-prompts.js +904 -119
  10. package/template/claude-task-manager/approval-agent.js +360 -40
  11. package/template/claude-task-manager/bin/ctm-disclaim.c +42 -0
  12. package/template/claude-task-manager/bin/ctm-hotkey.swift +67 -81
  13. package/template/claude-task-manager/bin/ctm-screen-auth.swift +37 -0
  14. package/template/claude-task-manager/bin/install-hotkey.sh +97 -49
  15. package/template/claude-task-manager/bin/restart-ctm.sh +14 -0
  16. package/template/claude-task-manager/db.js +399 -48
  17. package/template/claude-task-manager/docs/approval-hook-sandbox.md +84 -0
  18. package/template/claude-task-manager/docs/codex-app-server-approvals.md +72 -0
  19. package/template/claude-task-manager/docs/codex-native-sandbox.md +47 -0
  20. package/template/claude-task-manager/docs/prompt-editing-tree-design.md +18 -1
  21. package/template/claude-task-manager/lib/approval-hook.js +200 -0
  22. package/template/claude-task-manager/lib/approval-self-adapt.js +1 -0
  23. package/template/claude-task-manager/lib/auth-rules.js +11 -0
  24. package/template/claude-task-manager/lib/background-llm.js +32 -4
  25. package/template/claude-task-manager/lib/codesign-identity.js +140 -0
  26. package/template/claude-task-manager/lib/codex-app-server-client.js +119 -0
  27. package/template/claude-task-manager/lib/codex-approval-bridge.js +118 -0
  28. package/template/claude-task-manager/lib/codex-history-terminal-renderer.js +571 -0
  29. package/template/claude-task-manager/lib/codex-paths.js +73 -0
  30. package/template/claude-task-manager/lib/codex-rollout-snapshot.js +164 -0
  31. package/template/claude-task-manager/lib/codex-rollout-tail.js +72 -0
  32. package/template/claude-task-manager/lib/codex-sandbox-args.js +47 -0
  33. package/template/claude-task-manager/lib/coding-agent-models.js +118 -71
  34. package/template/claude-task-manager/lib/command-targets.js +163 -0
  35. package/template/claude-task-manager/lib/conversation-tail-merge.js +61 -19
  36. package/template/claude-task-manager/lib/db-owner-worker-client.js +29 -1
  37. package/template/claude-task-manager/lib/escalation-review.js +80 -3
  38. package/template/claude-task-manager/lib/flow-control.js +52 -0
  39. package/template/claude-task-manager/lib/fs-watcher.js +24 -15
  40. package/template/claude-task-manager/lib/ingest-cooldown.js +68 -0
  41. package/template/claude-task-manager/lib/jsonl-conversation-parser.js +8 -4
  42. package/template/claude-task-manager/lib/launchd-recovery.js +92 -0
  43. package/template/claude-task-manager/lib/microsoft-dev-tunnel-setup.js +207 -52
  44. package/template/claude-task-manager/lib/mobile-push-store.js +7 -0
  45. package/template/claude-task-manager/lib/model-overview-brain-fallback.js +102 -1
  46. package/template/claude-task-manager/lib/model-overview-cache.js +1 -0
  47. package/template/claude-task-manager/lib/oauth-proxy-supervisor.js +2 -1
  48. package/template/claude-task-manager/lib/perf-tracker.js +29 -2
  49. package/template/claude-task-manager/lib/permission-match.js +146 -16
  50. package/template/claude-task-manager/lib/project-slug.js +33 -0
  51. package/template/claude-task-manager/lib/prompt-intent.js +51 -4
  52. package/template/claude-task-manager/lib/read-pool-client.js +48 -3
  53. package/template/claude-task-manager/lib/real-node.js +73 -0
  54. package/template/claude-task-manager/lib/runtime-work-registry.js +131 -14
  55. package/template/claude-task-manager/lib/session-content-backfill.js +24 -5
  56. package/template/claude-task-manager/lib/session-diagnostics-batch.js +87 -0
  57. package/template/claude-task-manager/lib/session-history.js +5 -7
  58. package/template/claude-task-manager/lib/session-host-manager.js +19 -0
  59. package/template/claude-task-manager/lib/session-jobs.js +6 -0
  60. package/template/claude-task-manager/lib/session-message-response-cache.js +89 -0
  61. package/template/claude-task-manager/lib/session-messages-page.js +211 -0
  62. package/template/claude-task-manager/lib/session-messages-projection.js +199 -0
  63. package/template/claude-task-manager/lib/session-standup.js +8 -0
  64. package/template/claude-task-manager/lib/session-timeline-summary.js +16 -2
  65. package/template/claude-task-manager/lib/session-token-usage.js +30 -8
  66. package/template/claude-task-manager/lib/session-workspace-binding.js +29 -15
  67. package/template/claude-task-manager/lib/storage-migration.js +2 -1
  68. package/template/claude-task-manager/lib/transcript-store.js +179 -12
  69. package/template/claude-task-manager/lib/walle-ctm-history.js +298 -11
  70. package/template/claude-task-manager/lib/walle-permission-reply.js +49 -0
  71. package/template/claude-task-manager/lib/walle-session-cache.js +22 -1
  72. package/template/claude-task-manager/lib/walle-supervisor.js +42 -3
  73. package/template/claude-task-manager/package.json +5 -2
  74. package/template/claude-task-manager/prompt-harvest.js +31 -11
  75. package/template/claude-task-manager/providers/claude-code.js +29 -1
  76. package/template/claude-task-manager/providers/codex.js +13 -1
  77. package/template/claude-task-manager/public/css/setup.css +11 -0
  78. package/template/claude-task-manager/public/css/walle-session.css +132 -4
  79. package/template/claude-task-manager/public/css/walle.css +89 -0
  80. package/template/claude-task-manager/public/icon-16.png +0 -0
  81. package/template/claude-task-manager/public/icon-32.png +0 -0
  82. package/template/claude-task-manager/public/icon-512.png +0 -0
  83. package/template/claude-task-manager/public/index.html +2533 -167
  84. package/template/claude-task-manager/public/js/activation-render-check.js +55 -0
  85. package/template/claude-task-manager/public/js/flow-control-policy.js +52 -0
  86. package/template/claude-task-manager/public/js/message-renderer.js +60 -1
  87. package/template/claude-task-manager/public/js/prompts.js +13 -1
  88. package/template/claude-task-manager/public/js/session-status-precedence.js +9 -3
  89. package/template/claude-task-manager/public/js/setup.js +54 -10
  90. package/template/claude-task-manager/public/js/stream-resize-policy.js +80 -0
  91. package/template/claude-task-manager/public/js/stream-view.js +78 -0
  92. package/template/claude-task-manager/public/js/terminal-reconciler.js +52 -2
  93. package/template/claude-task-manager/public/js/tool-state.js +155 -0
  94. package/template/claude-task-manager/public/js/walle-session.js +887 -326
  95. package/template/claude-task-manager/public/js/walle.js +306 -195
  96. package/template/claude-task-manager/public/m/app.css +1 -0
  97. package/template/claude-task-manager/public/m/app.js +33 -3
  98. package/template/claude-task-manager/queue-engine.js +45 -1
  99. package/template/claude-task-manager/server.js +3492 -541
  100. package/template/claude-task-manager/workers/approval-blocklist.js +130 -17
  101. package/template/claude-task-manager/workers/db-owner-worker.js +31 -1
  102. package/template/claude-task-manager/workers/read-pool-worker.js +102 -5
  103. package/template/claude-task-manager/workers/session-host-process.js +10 -0
  104. package/template/claude-task-manager/workers/state-detectors/codex.js +58 -7
  105. package/template/package.json +2 -3
  106. package/template/shared/icons/AppIcon-ctm.icns +0 -0
  107. package/template/shared/icons/AppIcon-walle.icns +0 -0
  108. package/template/wall-e/agent.js +213 -39
  109. package/template/wall-e/api-walle.js +201 -22
  110. package/template/wall-e/bin/train-gemma-e4b-tooluse.js +1981 -0
  111. package/template/wall-e/brain.js +1306 -39
  112. package/template/wall-e/chat.js +456 -110
  113. package/template/wall-e/coding/acceptance-contract.js +26 -1
  114. package/template/wall-e/coding/action-memory-policy.js +353 -0
  115. package/template/wall-e/coding/action-memory-store.js +814 -0
  116. package/template/wall-e/coding/initial-messages.js +197 -0
  117. package/template/wall-e/coding/no-progress-guard.js +327 -0
  118. package/template/wall-e/coding/permission-service.js +88 -22
  119. package/template/wall-e/coding/session-plan.js +79 -0
  120. package/template/wall-e/coding/session-workspaces.js +81 -0
  121. package/template/wall-e/coding/shell-sandbox.js +124 -0
  122. package/template/wall-e/coding/stream-processor.js +63 -2
  123. package/template/wall-e/coding/tool-execution-controller.js +14 -1
  124. package/template/wall-e/coding/tool-registry.js +1 -1
  125. package/template/wall-e/coding/transcript-writer.js +3 -0
  126. package/template/wall-e/coding-orchestrator.js +644 -37
  127. package/template/wall-e/coding-prompts.js +60 -4
  128. package/template/wall-e/docs/model-routing-policy.md +59 -0
  129. package/template/wall-e/docs/walle-shell-sandbox.md +61 -0
  130. package/template/wall-e/extraction/knowledge-extractor.js +76 -23
  131. package/template/wall-e/http/chat-api.js +30 -12
  132. package/template/wall-e/http/model-admin.js +93 -1
  133. package/template/wall-e/lib/background-lanes.js +133 -0
  134. package/template/wall-e/lib/boot-profile.js +11 -0
  135. package/template/wall-e/lib/brain-owner-worker-client.js +324 -0
  136. package/template/wall-e/lib/brain-read-pool-client.js +311 -0
  137. package/template/wall-e/lib/diagnostics-flags.js +87 -0
  138. package/template/wall-e/lib/event-loop-monitor.js +74 -3
  139. package/template/wall-e/lib/mcp-integration.js +7 -1
  140. package/template/wall-e/lib/real-node.js +98 -0
  141. package/template/wall-e/lib/runtime-health.js +206 -0
  142. package/template/wall-e/lib/runtime-worker-pool.js +101 -0
  143. package/template/wall-e/lib/scheduler-worker-jobs.js +231 -0
  144. package/template/wall-e/lib/scheduler.js +600 -25
  145. package/template/wall-e/lib/service-health.js +61 -2
  146. package/template/wall-e/lib/service-readiness.js +258 -0
  147. package/template/wall-e/lib/usage.js +152 -0
  148. package/template/wall-e/lib/worker-thread-pool.js +397 -0
  149. package/template/wall-e/llm/client.js +81 -4
  150. package/template/wall-e/llm/default-fallback.js +54 -8
  151. package/template/wall-e/llm/mlx.js +536 -73
  152. package/template/wall-e/llm/mlx.plugin.json +1 -1
  153. package/template/wall-e/llm/ollama.js +342 -43
  154. package/template/wall-e/llm/provider-error.js +18 -1
  155. package/template/wall-e/llm/provider-health-state.js +176 -0
  156. package/template/wall-e/llm/routing-policy.js +796 -0
  157. package/template/wall-e/llm/supported-models.js +5 -0
  158. package/template/wall-e/loops/tasks.js +60 -14
  159. package/template/wall-e/loops/think.js +115 -27
  160. package/template/wall-e/mcp-server.js +208 -28
  161. package/template/wall-e/server.js +32 -7
  162. package/template/wall-e/skills/script-skill-runner.js +8 -1
  163. package/template/wall-e/skills/skill-planner.js +64 -1
  164. package/template/wall-e/sources/jsonl-utils.js +84 -11
  165. package/template/wall-e/tools/builtin-middleware.js +67 -2
  166. package/template/wall-e/tools/local-tools.js +132 -26
  167. package/template/wall-e/tools/permission-checker.js +52 -4
  168. package/template/wall-e/tools/permission-rules.js +36 -0
  169. package/template/wall-e/tools/shell-analyzer.js +46 -1
  170. package/template/wall-e/training/gemma-e4b-qlora.js +314 -0
  171. package/template/wall-e/training/real-trajectory-miner.js +2617 -0
  172. package/template/wall-e/training/replay-eval-analysis.js +151 -0
  173. package/template/wall-e/training/run-shell-command-selector.js +277 -0
  174. package/template/wall-e/training/tool-sft-dataset.js +312 -0
  175. package/template/wall-e/training/tool-sft-renderers.js +144 -0
  176. package/template/wall-e/training/tool-trace-harvester.js +1440 -0
  177. package/template/wall-e/training/trajectory-action-selector.js +364 -0
  178. package/template/wall-e/weather-runtime.js +232 -0
  179. package/template/wall-e/workers/brain-owner-worker.js +162 -0
  180. package/template/wall-e/workers/brain-read-worker.js +148 -0
  181. package/template/wall-e/workers/runtime-worker.js +169 -0
@@ -1,9 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  'use strict';
3
3
 
4
- const { execFileSync, spawn } = require('child_process');
4
+ const { execFileSync, spawn, spawnSync } = require('child_process');
5
5
  const fs = require('fs');
6
6
  const path = require('path');
7
+ const os = require('os');
7
8
 
8
9
  const BOLD = '\x1b[1m';
9
10
  const DIM = '\x1b[2m';
@@ -27,9 +28,216 @@ const NATIVE_DEPENDENCIES = new Set([
27
28
  'tree-sitter-bash',
28
29
  ]);
29
30
 
31
+ // The pinned Node runtime the macOS daemon adopts: an official, Apple-notarized build
32
+ // (signed by the Node.js Foundation) downloaded once from nodejs.org and cached under
33
+ // ~/.walle/notarized-node. Running the launchd daemon under a notarized identity is what lets
34
+ // a background macOS process obtain Screen Recording (and other TCC) grants — a self-signed or
35
+ // cloned node cannot. The whole runtime tree (CTM server, Wall-E supervisor, session-host
36
+ // forks) inherits this node via process.execPath, so native modules are built against THIS
37
+ // version's ABI (see daemonNodeForBuild / npmInstall). Keep in sync with the vendored Node in
38
+ // create-walle/macos/build-macos-app.sh (NODE_VERSION). Set WALLE_NO_NOTARIZED_NODE=1 to opt out.
39
+ const NOTARIZED_NODE_VERSION = '25.2.1';
40
+
30
41
  // Files to preserve during update (user config, not code)
31
42
  const PRESERVE_ON_UPDATE = ['.env', 'wall-e/wall-e-config.json'];
32
43
 
44
+ // ── macOS .app bundle + self-signed signing (Activity Monitor icon/name + stable TCC) ──
45
+ // macOS shows a process's icon from the .app bundle that encloses its executable, so we
46
+ // place the node binary inside per-daemon LSUIElement bundles. Self-signing with a stable
47
+ // cert gives a stable codesign designated requirement → TCC grants (hotkey) persist across
48
+ // updates. All best-effort: never block install; failures degrade to unsigned/iconless.
49
+ const BUNDLE_ROOT = process.env.WALLE_BUNDLE_DIR || path.join(process.env.HOME, '.walle', 'bundles');
50
+ const SIGN_IDENTITY_CN = 'Wall-E Local Signing';
51
+ const SIGN_KEYCHAIN = process.env.WALLE_SIGN_KEYCHAIN || path.join(process.env.HOME, '.walle', 'walle-signing.keychain-db');
52
+ const APP_BUNDLES = [
53
+ { app: 'Coding Task Manager.app', exec: 'Coding Task Manager', name: 'Coding Task Manager', bundleId: 'com.walle.ctm', icns: 'AppIcon-ctm.icns' },
54
+ { app: 'Wall-E.app', exec: 'Wall-E', name: 'Wall-E', bundleId: 'com.walle.agent', icns: 'AppIcon-walle.icns' },
55
+ ];
56
+
57
+ function ctmBundleExec() {
58
+ return path.join(BUNDLE_ROOT, 'Coding Task Manager.app', 'Contents', 'MacOS', 'Coding Task Manager');
59
+ }
60
+
61
+ function walleBundleExec() {
62
+ return path.join(BUNDLE_ROOT, 'Wall-E.app', 'Contents', 'MacOS', 'Wall-E');
63
+ }
64
+
65
+ // The code-signing Team Identifier of a binary, or '' when unsigned/self-signed. A NON-empty
66
+ // value means the binary carries a stable, OS-trusted Designated Requirement (Developer-ID or
67
+ // notarized) — its TCC grants persist AND macOS keeps showing the .app's CFBundleName in prompts.
68
+ function execTeamIdentifier(binaryPath) {
69
+ if (process.platform !== 'darwin' || !binaryPath) return '';
70
+ try {
71
+ if (!fs.existsSync(binaryPath)) return '';
72
+ // `codesign -dv` writes its report to STDERR and exits 0, so read both streams via spawnSync.
73
+ const r = spawnSync('codesign', ['-dv', '--verbose=4', binaryPath], { encoding: 'utf8' });
74
+ const text = `${r.stdout || ''}\n${r.stderr || ''}`;
75
+ const m = text.match(/^TeamIdentifier=(.+)$/m);
76
+ const team = m ? m[1].trim() : '';
77
+ return team === 'not set' ? '' : team;
78
+ } catch {
79
+ return '';
80
+ }
81
+ }
82
+
83
+ // Which node binary should launch the CTM daemon. We want an identity that is BOTH stable (its
84
+ // TCC grants persist across restarts) AND branded (macOS shows "Coding Task Manager"/"Wall-E" in
85
+ // prompts, not the anonymous "node"). A Developer-ID-signed .app bundle exec is the only thing
86
+ // that is both; a bare notarized node is stable but anonymous. Order:
87
+ // 1. WALLE_NOTARIZED_NODE — set by the downloadable Developer-ID Wall-E.app (runs under the
88
+ // app's own branded launcher identity, so it shows "Wall-E").
89
+ // 2. The CTM .app bundle exec WHEN it carries a stable Team Identifier (Developer-ID-signed by
90
+ // bin/ensure-stable-node.js) — branded AND grant-persisting.
91
+ // 3. The official notarized node we downloaded ourselves — stable but anonymous ("node"); the
92
+ // fallback for machines without a Developer ID, where a branded-stable bundle isn't possible.
93
+ // 4. The self-signed CTM bundle exec — branded but re-prompts (no stable Team ID).
94
+ // 5. The running node.
95
+ function daemonExec() {
96
+ const notarized = process.env.WALLE_NOTARIZED_NODE;
97
+ if (notarized && process.platform === 'darwin') {
98
+ try { if (fs.existsSync(notarized)) return notarized; } catch {}
99
+ }
100
+ if (process.platform === 'darwin') {
101
+ const bundle = ctmBundleExec();
102
+ let bundleExists = false;
103
+ try { bundleExists = fs.existsSync(bundle); } catch {}
104
+ // Prefer the branded bundle only when it is Developer-ID-signed (has a Team ID): branded AND
105
+ // its grants persist. Otherwise prefer the notarized bare node (stable but "node").
106
+ if (bundleExists && execTeamIdentifier(bundle)) return bundle;
107
+ const own = validatedNotarizedNode();
108
+ if (own) return own;
109
+ if (bundleExists) return bundle; // self-signed branded bundle — branded name, but re-prompts
110
+ }
111
+ return process.execPath;
112
+ }
113
+
114
+ // ── Notarized daemon node (npx parity with the downloadable app's TCC fix) ──
115
+ // The downloadable app ships its own notarized node; for the `npx create-walle` path we fetch
116
+ // the equivalent official notarized Node once and point the daemon at it. All best-effort:
117
+ // any failure leaves the daemon on its default node — never blocks install.
118
+
119
+ function notarizedNodeDir() {
120
+ return process.env.WALLE_NOTARIZED_NODE_DIR || path.join(process.env.HOME, '.walle', 'notarized-node');
121
+ }
122
+
123
+ function notarizedNodePath() {
124
+ return path.join(notarizedNodeDir(), 'bin', 'node');
125
+ }
126
+
127
+ // Returns the cached notarized node ONLY when the post-verify marker matches the pinned
128
+ // version (the marker is written last, so a partial/failed download is never trusted).
129
+ function validatedNotarizedNode() {
130
+ if (process.platform !== 'darwin') return null;
131
+ if (process.env.WALLE_NO_NOTARIZED_NODE === '1') return null;
132
+ const dest = notarizedNodePath();
133
+ const marker = path.join(notarizedNodeDir(), 'version');
134
+ try {
135
+ if (fs.existsSync(dest) && fs.existsSync(marker) &&
136
+ fs.readFileSync(marker, 'utf8').trim() === NOTARIZED_NODE_VERSION) {
137
+ return dest;
138
+ }
139
+ } catch {}
140
+ return null;
141
+ }
142
+
143
+ // The node whose ABI native modules must target = whatever the daemon will run under.
144
+ function daemonNodeForBuild() {
145
+ if (process.platform !== 'darwin') return process.execPath;
146
+ return validatedNotarizedNode() || process.execPath;
147
+ }
148
+
149
+ function nodeReportsVersion(bin, version) {
150
+ try {
151
+ return execFileSync(bin, ['-v'], { encoding: 'utf8', timeout: 10000 }).trim() === `v${version}`;
152
+ } catch { return false; }
153
+ }
154
+
155
+ // Integrity check: codesign --verify catches a tampered/altered binary (gates adoption).
156
+ // spctl reports the Gatekeeper/notarization assessment (informational only — a validly-signed
157
+ // official node is still safe to RUN even if spctl is quirky for a bare CLI binary; the TCC
158
+ // benefit only adds to today's behavior, it never regresses it).
159
+ function verifyNotarizedNode(bin, { log } = {}) {
160
+ try {
161
+ execFileSync('codesign', ['--verify', '--strict', bin], { stdio: 'pipe', timeout: 30000 });
162
+ } catch {
163
+ return false;
164
+ }
165
+ if (log) {
166
+ let assessment;
167
+ try {
168
+ execFileSync('spctl', ['-a', '-vv', '--type', 'exec', bin], { stdio: 'pipe', timeout: 30000 });
169
+ assessment = 'accepted (notarized)';
170
+ } catch { assessment = 'signature valid (spctl unconfirmed)'; }
171
+ log(` ${DIM}node signature: ${assessment}${RESET}`);
172
+ }
173
+ return true;
174
+ }
175
+
176
+ // Download (once) the pinned official notarized Node + npm and cache it. Returns the node path
177
+ // on success, else null (install proceeds on the default node). npm is bundled alongside so
178
+ // native modules can be (re)built under this exact node — guaranteeing the daemon's runtime
179
+ // and its node_modules share one ABI.
180
+ function ensureNotarizedDaemonNode({ log = console.log } = {}) {
181
+ if (process.platform !== 'darwin') return null;
182
+ if (process.env.WALLE_NO_NOTARIZED_NODE === '1') return null;
183
+ const arch = process.arch === 'arm64' ? 'arm64' : process.arch === 'x64' ? 'x64' : null;
184
+ if (!arch) return null;
185
+
186
+ const dir = notarizedNodeDir();
187
+ const dest = notarizedNodePath();
188
+ const marker = path.join(dir, 'version');
189
+
190
+ // Cached and still valid → reuse (the "download once").
191
+ const cached = validatedNotarizedNode();
192
+ if (cached && nodeReportsVersion(cached, NOTARIZED_NODE_VERSION) && verifyNotarizedNode(cached)) {
193
+ return cached;
194
+ }
195
+
196
+ let tmp;
197
+ try {
198
+ fs.mkdirSync(dir, { recursive: true });
199
+ const base = `node-v${NOTARIZED_NODE_VERSION}-darwin-${arch}`;
200
+ const tarball = `${base}.tar.gz`;
201
+ const url = `https://nodejs.org/dist/v${NOTARIZED_NODE_VERSION}/${tarball}`;
202
+ tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'walle-nnode-'));
203
+ const tgz = path.join(tmp, tarball);
204
+ log(` Downloading notarized Node ${NOTARIZED_NODE_VERSION} (${arch})…`);
205
+ execFileSync('curl', ['-fsSL', '-o', tgz, url], { timeout: 300000, stdio: 'pipe' });
206
+ // Extract just the node binary + bundled npm, stripping the version-named top dir.
207
+ execFileSync('tar', [
208
+ '-xzf', tgz, '-C', tmp, '--strip-components=1',
209
+ `${base}/bin/node`, `${base}/lib/node_modules/npm`,
210
+ ], { timeout: 120000, stdio: 'pipe' });
211
+
212
+ fs.rmSync(path.join(dir, 'bin'), { recursive: true, force: true });
213
+ fs.rmSync(path.join(dir, 'lib'), { recursive: true, force: true });
214
+ fs.rmSync(marker, { force: true });
215
+ fs.cpSync(path.join(tmp, 'bin'), path.join(dir, 'bin'), { recursive: true });
216
+ fs.cpSync(path.join(tmp, 'lib'), path.join(dir, 'lib'), { recursive: true });
217
+ fs.chmodSync(dest, 0o755);
218
+
219
+ if (!nodeReportsVersion(dest, NOTARIZED_NODE_VERSION)) throw new Error('version check failed');
220
+ if (!verifyNotarizedNode(dest, { log })) throw new Error('signature verification failed');
221
+
222
+ fs.writeFileSync(marker, `${NOTARIZED_NODE_VERSION}\n`);
223
+ log(` ${GREEN}Notarized Node ready${RESET} ${DIM}(${dest})${RESET}`);
224
+ return dest;
225
+ } catch (e) {
226
+ log(` ${DIM}Notarized node unavailable (${e && e.message ? e.message : e}) — daemon will use the default node${RESET}`);
227
+ disableNotarizedNode();
228
+ return null;
229
+ } finally {
230
+ if (tmp) { try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {} }
231
+ }
232
+ }
233
+
234
+ // Drop the marker (and binary) so daemonExec/daemonNodeForBuild stop trusting it. Used when the
235
+ // download fails OR when we can't build native modules under it (avoids an ABI split).
236
+ function disableNotarizedNode() {
237
+ try { fs.rmSync(path.join(notarizedNodeDir(), 'version'), { force: true }); } catch {}
238
+ try { fs.rmSync(notarizedNodePath(), { force: true }); } catch {}
239
+ }
240
+
33
241
  function writeCliLifecycleEvent(event, meta = {}) {
34
242
  if (process.env.WALLE_TELEMETRY === '0' || process.env.WALLE_TELEMETRY === 'false') return;
35
243
  try {
@@ -178,10 +386,16 @@ function install(targetDir) {
178
386
  console.log(`\n Copying files...`);
179
387
  copyRecursive(TEMPLATE_DIR, targetDir);
180
388
 
389
+ // Fetch the notarized daemon node BEFORE installing deps, so native modules build against
390
+ // its ABI (the daemon and its forks will all run under it). Best-effort / macOS-only.
391
+ ensureNotarizedDaemonNode();
392
+
181
393
  console.log(` Installing dependencies...\n`);
182
394
  npmInstall(targetDir);
183
395
 
184
396
  compileHotkeyDaemon(targetDir);
397
+ compileScreenAuthHelper(targetDir);
398
+ compileDisclaimHelper(targetDir);
185
399
 
186
400
  // .env
187
401
  const envLines = [
@@ -223,6 +437,10 @@ function install(targetDir) {
223
437
 
224
438
  saveWalleDir(path.resolve(targetDir));
225
439
 
440
+ // Build branded .app bundles (icon + name in Activity Monitor) before starting,
441
+ // so the launchd plist / background spawn can point at the bundle's node exec.
442
+ buildAppBundles(path.resolve(targetDir));
443
+
226
444
  // Start the service
227
445
  console.log(` Starting Wall-E...`);
228
446
  startForegroundOrService(path.resolve(targetDir), port);
@@ -288,13 +506,21 @@ function update() {
288
506
  // 5. Stamp version
289
507
  stampVersion(dir);
290
508
 
291
- // 6. Reinstall deps (in case package.json changed)
509
+ // 6. Reinstall deps (in case package.json changed). Refresh the notarized daemon node first
510
+ // so native modules rebuild against its ABI (idempotent: a valid cache is reused, not re-DLed).
511
+ ensureNotarizedDaemonNode();
292
512
  console.log(` Installing dependencies...\n`);
293
513
  npmInstall(dir);
294
514
 
295
515
  compileHotkeyDaemon(dir);
516
+ compileScreenAuthHelper(dir);
517
+ compileDisclaimHelper(dir);
518
+
519
+ // 7. Refresh branded .app bundles (re-clone node to match the freshly built native
520
+ // modules' ABI, re-sign) then start again.
521
+ buildAppBundles(dir);
296
522
 
297
- // 7. Start again
523
+ // 8. Start again
298
524
  console.log(`\n Starting Wall-E...`);
299
525
  startForegroundOrService(dir, port);
300
526
 
@@ -353,6 +579,12 @@ function status() {
353
579
  }
354
580
 
355
581
  function logs() {
582
+ // On Linux with systemd, the unit logs to the journal.
583
+ if (process.platform === 'linux' && hasSystemdUser() && fs.existsSync(path.join(process.env.HOME, '.config', 'systemd', 'user', 'walle.service'))) {
584
+ const child = spawn('journalctl', ['--user', '-u', 'walle.service', '-f'], { stdio: 'inherit' });
585
+ child.on('error', () => console.log(' Could not read the journal; try ~/.walle/logs/'));
586
+ return;
587
+ }
356
588
  const logFile = path.join(process.env.HOME, '.walle', 'logs', 'walle.log');
357
589
  if (!fs.existsSync(logFile)) {
358
590
  console.log(`\n No logs yet at ${logFile}\n`);
@@ -363,9 +595,25 @@ function logs() {
363
595
  }
364
596
 
365
597
  function uninstall() {
366
- const plist = path.join(process.env.HOME, 'Library', 'LaunchAgents', `${LABEL}.plist`);
367
- try { execFileSync('launchctl', ['unload', plist], { stdio: 'ignore' }); } catch {}
368
- try { fs.unlinkSync(plist); } catch {}
598
+ if (process.platform === 'darwin') {
599
+ const plist = path.join(process.env.HOME, 'Library', 'LaunchAgents', `${LABEL}.plist`);
600
+ try { execFileSync('launchctl', ['unload', plist], { stdio: 'ignore' }); } catch {}
601
+ try { fs.unlinkSync(plist); } catch {}
602
+ // Branded .app bundles + Launchpad launcher.
603
+ try { fs.rmSync(BUNDLE_ROOT, { recursive: true, force: true }); } catch {}
604
+ try { fs.rmSync(path.join(process.env.HOME, 'Applications', 'Wall-E.app'), { recursive: true, force: true }); } catch {}
605
+ } else if (process.platform === 'linux') {
606
+ if (hasSystemdUser()) {
607
+ try { execFileSync('systemctl', ['--user', 'disable', '--now', 'walle.service'], { stdio: 'ignore' }); } catch {}
608
+ }
609
+ try { fs.unlinkSync(path.join(process.env.HOME, '.config', 'systemd', 'user', 'walle.service')); } catch {}
610
+ try { execFileSync('systemctl', ['--user', 'daemon-reload'], { stdio: 'ignore' }); } catch {}
611
+ try { fs.unlinkSync(path.join(process.env.HOME, '.local', 'share', 'applications', 'walle.desktop')); } catch {}
612
+ const themeDir = path.join(process.env.HOME, '.local', 'share', 'icons', 'hicolor');
613
+ try { fs.unlinkSync(path.join(themeDir, 'scalable', 'apps', 'walle.svg')); } catch {}
614
+ for (const s of ['16x16', '32x32', '512x512']) { try { fs.unlinkSync(path.join(themeDir, s, 'apps', 'walle.png')); } catch {} }
615
+ try { execFileSync('gtk-update-icon-cache', ['-f', '-t', themeDir], { stdio: 'ignore' }); } catch {}
616
+ }
369
617
  try { fs.unlinkSync(INSTALL_PATH_FILE); } catch {}
370
618
  console.log(`\n ${GREEN}Service uninstalled.${RESET} Your data in ~/.walle/data is preserved.\n`);
371
619
  }
@@ -378,6 +626,10 @@ function stopQuiet(dir, port) {
378
626
  if (process.platform === 'darwin' && fs.existsSync(plist)) {
379
627
  try { execFileSync('launchctl', ['unload', plist], { stdio: 'ignore' }); } catch {}
380
628
  }
629
+ // Linux: stop the systemd user unit so it doesn't auto-restart during update.
630
+ if (process.platform === 'linux' && hasSystemdUser() && fs.existsSync(path.join(process.env.HOME, '.config', 'systemd', 'user', 'walle.service'))) {
631
+ try { execFileSync('systemctl', ['--user', 'stop', 'walle.service'], { stdio: 'ignore' }); } catch {}
632
+ }
381
633
  // Kill CTM and Wall-E processes
382
634
  const wallePort = dir ? readWallePort(dir) : String(parseInt(port) + 1);
383
635
  for (const p of [port, wallePort]) {
@@ -409,7 +661,9 @@ function startForegroundOrService(dir, port) {
409
661
  // Start in background without launchd
410
662
  const logDir = path.join(process.env.HOME, '.walle', 'logs');
411
663
  fs.mkdirSync(logDir, { recursive: true });
412
- const child = spawn(process.execPath, ['claude-task-manager/server.js'], {
664
+ // Launch via the notarized app node / CTM .app bundle exec when present (TCC + icon).
665
+ const ctmExec = daemonExec();
666
+ const child = spawn(ctmExec, ['claude-task-manager/server.js'], {
413
667
  cwd: dir,
414
668
  detached: true,
415
669
  stdio: ['ignore', fs.openSync(path.join(logDir, 'walle.log'), 'a'), fs.openSync(path.join(logDir, 'walle.err'), 'a')],
@@ -425,12 +679,18 @@ function installService(walleDir, port) {
425
679
 
426
680
  if (process.platform === 'darwin') {
427
681
  const nodePath = process.execPath;
682
+ // Prefer the notarized app's node (downloadable app) → self-signed CTM bundle → node.
683
+ const launchExec = daemonExec();
428
684
  const plistDir = path.join(process.env.HOME, 'Library', 'LaunchAgents');
429
685
  fs.mkdirSync(plistDir, { recursive: true });
430
686
  const plistPath = path.join(plistDir, `${LABEL}.plist`);
431
687
 
432
688
  const xmlEsc = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
433
- const nodeBinDir = path.dirname(nodePath);
689
+ // Put the notarized node's dir first (when adopted) so bare `node` invocations inside the
690
+ // daemon resolve to the same ABI it runs under; keep the real node dir as a fallback.
691
+ const notarizedNode = validatedNotarizedNode();
692
+ const nodeBinDir = [notarizedNode && path.dirname(notarizedNode), path.dirname(nodePath)]
693
+ .filter(Boolean).join(':');
434
694
  let envDict = ` <key>PATH</key>\n <string>${xmlEsc(nodeBinDir)}:/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin</string>\n`;
435
695
  envDict += ` <key>HOME</key>\n <string>${xmlEsc(process.env.HOME)}</string>\n`;
436
696
  envDict += ` <key>CTM_PORT</key>\n <string>${port}</string>\n`;
@@ -453,7 +713,7 @@ function installService(walleDir, port) {
453
713
  <string>${LABEL}</string>
454
714
  <key>ProgramArguments</key>
455
715
  <array>
456
- <string>${nodePath}</string>
716
+ <string>${launchExec}</string>
457
717
  <string>${walleDir}/claude-task-manager/server.js</string>
458
718
  </array>
459
719
  <key>WorkingDirectory</key>
@@ -480,10 +740,114 @@ ${envDict} </dict>
480
740
  fs.writeFileSync(plistPath, plist);
481
741
  try { execFileSync('launchctl', ['unload', plistPath], { stdio: 'ignore' }); } catch {}
482
742
  execFileSync('launchctl', ['load', plistPath]);
743
+ } else if (process.platform === 'linux') {
744
+ // Always register desktop integration (name + icon in launchers / system monitors),
745
+ // independent of the service manager.
746
+ installDesktopIntegration(walleDir, port);
747
+ if (hasSystemdUser()) {
748
+ installSystemdService(walleDir, port, logDir);
749
+ } else {
750
+ installWatchdogFallback(walleDir, port, logDir);
751
+ }
483
752
  } else {
484
- // Linux/other: spawn with a restart-on-crash wrapper shell script
485
- const watchdogScript = path.join(walleDir, 'bin', 'watchdog.sh');
486
- const watchdogContent = `#!/bin/bash
753
+ installWatchdogFallback(walleDir, port, logDir);
754
+ }
755
+ }
756
+
757
+ // ── Linux service / desktop integration ──
758
+
759
+ // True only when a systemd *user* instance is actually reachable (catches non-systemd
760
+ // distros, bare SSH without lingering, WSL without systemd).
761
+ function hasSystemdUser() {
762
+ if (process.platform !== 'linux') return false;
763
+ try {
764
+ execFileSync('systemctl', ['--user', 'show-environment'], { stdio: 'ignore', timeout: 4000 });
765
+ return true;
766
+ } catch { return false; }
767
+ }
768
+
769
+ function installSystemdService(walleDir, port, logDir) {
770
+ const unitDir = path.join(process.env.HOME, '.config', 'systemd', 'user');
771
+ fs.mkdirSync(unitDir, { recursive: true });
772
+ fs.mkdirSync(logDir, { recursive: true });
773
+ const sq = (s) => String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
774
+ let envLines = `Environment="CTM_PORT=${port}"\nEnvironment="CTM_MANAGED_BY_SYSTEMD=1"\n`;
775
+ try {
776
+ for (const line of fs.readFileSync(path.join(walleDir, '.env'), 'utf8').split('\n')) {
777
+ const m = line.match(/^\s*([^#=\s]+)\s*=\s*(.+?)\s*$/);
778
+ if (m && m[1] !== 'CTM_PORT') envLines += `Environment="${sq(m[1])}=${sq(m[2])}"\n`;
779
+ }
780
+ } catch {}
781
+ const unit = `[Unit]
782
+ Description=Wall-E — personal digital twin (Coding Task Manager)
783
+ After=network-online.target
784
+ Wants=network-online.target
785
+
786
+ [Service]
787
+ Type=simple
788
+ WorkingDirectory=${walleDir}
789
+ ExecStart=${process.execPath} ${walleDir}/claude-task-manager/server.js
790
+ Restart=on-failure
791
+ RestartSec=5
792
+ StartLimitIntervalSec=300
793
+ StartLimitBurst=5
794
+ ${envLines}StandardOutput=journal
795
+ StandardError=journal
796
+ SyslogIdentifier=walle
797
+
798
+ [Install]
799
+ WantedBy=default.target
800
+ `;
801
+ fs.writeFileSync(path.join(unitDir, 'walle.service'), unit);
802
+ try { execFileSync('systemctl', ['--user', 'daemon-reload'], { stdio: 'ignore' }); } catch {}
803
+ try { execFileSync('systemctl', ['--user', 'enable', '--now', 'walle.service'], { stdio: 'ignore' }); } catch {}
804
+ try { execFileSync('systemctl', ['--user', 'restart', 'walle.service'], { stdio: 'ignore' }); } catch {}
805
+ }
806
+
807
+ // Install a freedesktop .desktop entry + themed icons so Wall-E shows with a name + icon
808
+ // in app launchers and graphical system monitors. Best-effort.
809
+ function installDesktopIntegration(walleDir, port) {
810
+ if (process.platform !== 'linux') return;
811
+ try {
812
+ const appsDir = path.join(process.env.HOME, '.local', 'share', 'applications');
813
+ const themeDir = path.join(process.env.HOME, '.local', 'share', 'icons', 'hicolor');
814
+ fs.mkdirSync(appsDir, { recursive: true });
815
+ const pub = path.join(walleDir, 'claude-task-manager', 'public');
816
+ const svg = path.join(pub, 'icon.svg');
817
+ if (fs.existsSync(svg)) {
818
+ const d = path.join(themeDir, 'scalable', 'apps');
819
+ fs.mkdirSync(d, { recursive: true });
820
+ try { fs.copyFileSync(svg, path.join(d, 'walle.svg')); } catch {}
821
+ }
822
+ for (const [file, size] of [['icon-16.png', '16x16'], ['icon-32.png', '32x32'], ['icon-512.png', '512x512']]) {
823
+ const src = path.join(pub, file);
824
+ if (fs.existsSync(src)) {
825
+ const d = path.join(themeDir, size, 'apps');
826
+ fs.mkdirSync(d, { recursive: true });
827
+ try { fs.copyFileSync(src, path.join(d, 'walle.png')); } catch {}
828
+ }
829
+ }
830
+ fs.writeFileSync(path.join(appsDir, 'walle.desktop'), `[Desktop Entry]
831
+ Type=Application
832
+ Name=Wall-E
833
+ GenericName=Personal Digital Twin
834
+ Comment=Coding Task Manager and Wall-E memory daemon
835
+ Exec=xdg-open http://localhost:${port}
836
+ Icon=walle
837
+ Terminal=false
838
+ Categories=Development;Utility;
839
+ Keywords=walle;ctm;claude;tasks;
840
+ StartupNotify=false
841
+ `);
842
+ try { execFileSync('gtk-update-icon-cache', ['-f', '-t', themeDir], { stdio: 'ignore' }); } catch {}
843
+ try { execFileSync('update-desktop-database', [appsDir], { stdio: 'ignore' }); } catch {}
844
+ } catch {}
845
+ }
846
+
847
+ function installWatchdogFallback(walleDir, port, logDir) {
848
+ // Linux/other without systemd: spawn with a restart-on-crash wrapper shell script.
849
+ const watchdogScript = path.join(walleDir, 'bin', 'watchdog.sh');
850
+ const watchdogContent = `#!/bin/bash
487
851
  # Auto-restart CTM on crash. Clean exit (code 0) stops the watchdog.
488
852
  CRASHES=0
489
853
  while true; do
@@ -502,28 +866,37 @@ while true; do
502
866
  sleep 5
503
867
  done
504
868
  `;
505
- fs.mkdirSync(path.join(walleDir, 'bin'), { recursive: true });
506
- fs.writeFileSync(watchdogScript, watchdogContent, { mode: 0o755 });
507
-
508
- const child = spawn('bash', [watchdogScript], {
509
- cwd: walleDir,
510
- detached: true,
511
- stdio: 'ignore',
512
- env: { ...process.env, CTM_PORT: port },
513
- });
514
- child.unref();
515
- fs.writeFileSync(path.join(logDir, 'walle.pid'), String(child.pid));
516
- }
869
+ fs.mkdirSync(path.join(walleDir, 'bin'), { recursive: true });
870
+ fs.writeFileSync(watchdogScript, watchdogContent, { mode: 0o755 });
871
+
872
+ const child = spawn('bash', [watchdogScript], {
873
+ cwd: walleDir,
874
+ detached: true,
875
+ stdio: 'ignore',
876
+ env: { ...process.env, CTM_PORT: port },
877
+ });
878
+ child.unref();
879
+ fs.writeFileSync(path.join(logDir, 'walle.pid'), String(child.pid));
517
880
  }
518
881
 
519
882
  // ── Helpers ──
520
883
 
521
884
  function npmInstall(dir) {
885
+ // Build native modules under the node the daemon will actually run (the notarized node when
886
+ // adopted), so its ABI matches. Safety: if we can't run npm UNDER that node (no co-located
887
+ // npm-cli.js → npm would fall back to the user's node and build the wrong ABI), don't adopt
888
+ // the notarized node at all — keeping one consistent ABI beats a half-applied switch.
889
+ let nodeBin = daemonNodeForBuild();
890
+ if (nodeBin !== process.execPath && resolveNpmRunner(process.env, nodeBin).type !== 'node-cli') {
891
+ console.log(` ${DIM}No npm co-located with the notarized node — keeping the default daemon node${RESET}`);
892
+ disableNotarizedNode();
893
+ nodeBin = process.execPath;
894
+ }
522
895
  try {
523
896
  for (const relDir of MANAGED_PACKAGE_DIRS) {
524
- runNpm(path.join(dir, relDir), ['install', '--loglevel=warn']);
897
+ runNpm(path.join(dir, relDir), ['install', '--loglevel=warn'], nodeBin);
525
898
  }
526
- repairNativeDependencies(dir, { phase: 'install' });
899
+ repairNativeDependencies(dir, { phase: 'install', nodeBin });
527
900
  } catch (err) {
528
901
  console.error(`\n ${RED}npm install failed.${RESET}`);
529
902
  if (err && err.message) console.error(` ${DIM}${err.message}${RESET}`);
@@ -537,6 +910,7 @@ function repairNativeDependencies(walleDir, {
537
910
  checkDependency = checkNativeDependency,
538
911
  runNpmCommand = runNpm,
539
912
  log = console.log,
913
+ nodeBin = process.execPath,
540
914
  } = {}) {
541
915
  const repairs = [];
542
916
  for (const relDir of MANAGED_PACKAGE_DIRS) {
@@ -546,17 +920,17 @@ function repairNativeDependencies(walleDir, {
546
920
 
547
921
  const failed = [];
548
922
  for (const dep of deps) {
549
- const check = checkDependency(packageDir, dep);
923
+ const check = checkDependency(packageDir, dep, nodeBin);
550
924
  if (!check.ok) failed.push(dep);
551
925
  }
552
926
  if (!failed.length) continue;
553
927
 
554
- log(` ${YELLOW}Rebuilding native modules for ${relDir}${RESET} ${DIM}(Node ${process.version}, ABI ${process.versions.modules})${RESET}`);
555
- runNpmCommand(packageDir, ['rebuild', ...failed, '--loglevel=warn']);
928
+ log(` ${YELLOW}Rebuilding native modules for ${relDir}${RESET} ${DIM}(daemon node ${nodeBin !== process.execPath ? `notarized v${NOTARIZED_NODE_VERSION}` : process.version})${RESET}`);
929
+ runNpmCommand(packageDir, ['rebuild', ...failed, '--loglevel=warn'], nodeBin);
556
930
 
557
931
  const stillBroken = [];
558
932
  for (const dep of failed) {
559
- const check = checkDependency(packageDir, dep);
933
+ const check = checkDependency(packageDir, dep, nodeBin);
560
934
  if (!check.ok) stillBroken.push(`${dep}: ${firstLine(check.error)}`);
561
935
  }
562
936
  if (stillBroken.length) {
@@ -586,14 +960,14 @@ function nativeDependenciesForPackage(packageDir) {
586
960
  return Object.keys(declared).filter((name) => NATIVE_DEPENDENCIES.has(name));
587
961
  }
588
962
 
589
- function checkNativeDependency(packageDir, dependency) {
963
+ function checkNativeDependency(packageDir, dependency, nodeBin = process.execPath) {
590
964
  try {
591
- execFileSync(process.execPath, ['-e', `require(${JSON.stringify(dependency)})`], {
965
+ execFileSync(nodeBin, ['-e', `require(${JSON.stringify(dependency)})`], {
592
966
  cwd: packageDir,
593
967
  encoding: 'utf8',
594
968
  stdio: ['ignore', 'pipe', 'pipe'],
595
969
  timeout: 15000,
596
- env: npmChildEnv(),
970
+ env: npmChildEnv(nodeBin),
597
971
  });
598
972
  return { ok: true };
599
973
  } catch (err) {
@@ -602,20 +976,21 @@ function checkNativeDependency(packageDir, dependency) {
602
976
  }
603
977
  }
604
978
 
605
- function runNpm(cwd, args) {
606
- const runner = resolveNpmRunner();
979
+ function runNpm(cwd, args, nodeBin = process.execPath) {
980
+ const runner = resolveNpmRunner(process.env, nodeBin);
607
981
  if (runner.type === 'node-cli') {
608
- execFileSync(process.execPath, [runner.path, ...args], {
982
+ // Run npm UNDER nodeBin so node-gyp targets nodeBin's ABI (= the daemon's runtime).
983
+ execFileSync(nodeBin, [runner.path, ...args], {
609
984
  cwd,
610
985
  stdio: 'inherit',
611
- env: npmChildEnv(),
986
+ env: npmChildEnv(nodeBin),
612
987
  });
613
988
  return;
614
989
  }
615
990
  execFileSync('npm', args, {
616
991
  cwd,
617
992
  stdio: 'inherit',
618
- env: npmChildEnv(),
993
+ env: npmChildEnv(nodeBin),
619
994
  });
620
995
  }
621
996
 
@@ -647,10 +1022,11 @@ function npmCliCandidates(execPath = process.execPath) {
647
1022
  ].map((candidate) => path.resolve(candidate));
648
1023
  }
649
1024
 
650
- function npmChildEnv() {
651
- const nodeDir = path.dirname(process.execPath);
1025
+ function npmChildEnv(nodeBin = process.execPath) {
1026
+ const nodeDir = path.dirname(nodeBin);
652
1027
  return {
653
1028
  ...process.env,
1029
+ // nodeBin's dir first so any `node` node-gyp shells out to is the same ABI we're targeting.
654
1030
  PATH: [nodeDir, process.env.PATH || ''].filter(Boolean).join(path.delimiter),
655
1031
  npm_config_update_notifier: 'false',
656
1032
  };
@@ -728,19 +1104,397 @@ function detectTimezone() {
728
1104
  return 'UTC';
729
1105
  }
730
1106
 
1107
+ // Build the global screenshot hotkey as a real LSUIElement .app bundle (a peer of the
1108
+ // Coding Task Manager.app / Wall-E.app daemon bundles). RegisterEventHotKey only delivers global
1109
+ // hotkeys to a GUI app the window server recognizes — a BARE launchd executable never receives the
1110
+ // keypress, which is why the old bare-binary build registered but never fired. Best-effort: if this
1111
+ // fails (no swiftc), CTM's boot-time ensureHotkeyDaemon self-heals it on first launch.
731
1112
  function compileHotkeyDaemon(walleDir) {
732
1113
  if (process.platform !== 'darwin') return; // macOS only
733
1114
  const swiftSource = path.join(walleDir, 'claude-task-manager', 'bin', 'ctm-hotkey.swift');
734
1115
  if (!fs.existsSync(swiftSource)) return;
735
- const binary = path.join(process.env.HOME, '.local', 'bin', 'ctm-hotkey');
1116
+ const bundleDir = path.join(BUNDLE_ROOT, 'CTM-Screenshot.app');
1117
+ const exec = path.join(bundleDir, 'Contents', 'MacOS', 'ctm-hotkey');
1118
+ try {
1119
+ fs.mkdirSync(path.dirname(exec), { recursive: true });
1120
+ // Carbon: RegisterEventHotKey / InstallEventHandler. Cocoa: NSApplication (the GUI app context).
1121
+ execFileSync('swiftc', ['-O', '-o', exec, swiftSource, '-framework', 'Cocoa', '-framework', 'Carbon'], { timeout: 120000, stdio: 'pipe' });
1122
+ fs.chmodSync(exec, 0o755);
1123
+ fs.writeFileSync(path.join(bundleDir, 'Contents', 'Info.plist'), `<?xml version="1.0" encoding="UTF-8"?>
1124
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1125
+ <plist version="1.0">
1126
+ <dict>
1127
+ <key>CFBundleName</key><string>CTM Screenshot</string>
1128
+ <key>CFBundleDisplayName</key><string>CTM Screenshot</string>
1129
+ <key>CFBundleIdentifier</key><string>com.walle.ctm.hotkey</string>
1130
+ <key>CFBundleExecutable</key><string>ctm-hotkey</string>
1131
+ <key>CFBundlePackageType</key><string>APPL</string>
1132
+ <key>CFBundleVersion</key><string>1.0</string>
1133
+ <key>CFBundleShortVersionString</key><string>1.0</string>
1134
+ <key>LSUIElement</key><true/>
1135
+ <key>LSMinimumSystemVersion</key><string>11.0</string>
1136
+ <key>NSPrincipalClass</key><string>NSApplication</string>
1137
+ </dict>
1138
+ </plist>
1139
+ `);
1140
+ // Sign so it runs for every user (arm64 won't execute an unsigned binary): self-signed local
1141
+ // cert if available, else ad-hoc. CTM's boot ensureHotkeyDaemon upgrades to Developer-ID on
1142
+ // machines that have one. The hotkey needs no TCC permission, so any valid signature suffices.
1143
+ const id = ensureSigningIdentity();
1144
+ if (!id || !signArtifacts(id, [{ path: bundleDir, id: 'com.walle.ctm.hotkey', deep: true }])) {
1145
+ try { execFileSync('codesign', ['--force', '--deep', '--sign', '-', bundleDir], { stdio: 'ignore', timeout: 120000 }); } catch {}
1146
+ }
1147
+ // Remove any pre-bundle bare binary so two daemons can't compete for the combo.
1148
+ try { fs.unlinkSync(path.join(process.env.HOME, '.local', 'bin', 'ctm-hotkey')); } catch {}
1149
+ console.log(` ${GREEN}Built hotkey app bundle${RESET} ${DIM}(${bundleDir})${RESET}`);
1150
+ } catch {
1151
+ // swiftc not available or build failed — non-fatal; CTM's boot self-heal will retry.
1152
+ console.log(` ${DIM}Skipped hotkey bundle (Swift compiler not available)${RESET}`);
1153
+ }
1154
+ }
1155
+
1156
+ // Compile the Screen Recording permission helper (macOS only). CTM spawns this when a
1157
+ // screenshot fails for lack of Screen Recording permission; calling
1158
+ // CGRequestScreenCaptureAccess() from a CTM child attributes the request to CTM's identity
1159
+ // so the system prompt reads "Coding Task Manager" and the grant lands on the right
1160
+ // identity. Best-effort: missing swiftc → screenshots just surface a manual-grant hint.
1161
+ function compileScreenAuthHelper(walleDir) {
1162
+ if (process.platform !== 'darwin') return; // macOS only
1163
+ const swiftSource = path.join(walleDir, 'claude-task-manager', 'bin', 'ctm-screen-auth.swift');
1164
+ if (!fs.existsSync(swiftSource)) return;
1165
+ const binary = path.join(process.env.HOME, '.local', 'bin', 'ctm-screen-auth');
1166
+ try {
1167
+ fs.mkdirSync(path.dirname(binary), { recursive: true });
1168
+ execFileSync('swiftc', ['-O', '-o', binary, swiftSource, '-framework', 'CoreGraphics'], { timeout: 120000, stdio: 'pipe' });
1169
+ fs.chmodSync(binary, 0o755);
1170
+ console.log(` ${GREEN}Compiled screen-recording helper${RESET} ${DIM}(${binary})${RESET}`);
1171
+ } catch {
1172
+ console.log(` ${DIM}Skipped screen-recording helper (Swift compiler not available)${RESET}`);
1173
+ }
1174
+ }
1175
+
1176
+ // Compile the disclaim helper (macOS only). Lets CTM run `screencapture` as its own
1177
+ // responsible TCC process via the user's already-granted `node`, so screenshots work even
1178
+ // though CTM runs from a self-signed bundle macOS won't prompt for. Best-effort.
1179
+ function compileDisclaimHelper(walleDir) {
1180
+ if (process.platform !== 'darwin') return; // macOS only
1181
+ const src = path.join(walleDir, 'claude-task-manager', 'bin', 'ctm-disclaim.c');
1182
+ if (!fs.existsSync(src)) return;
1183
+ const binary = path.join(process.env.HOME, '.local', 'bin', 'ctm-disclaim');
736
1184
  try {
737
1185
  fs.mkdirSync(path.dirname(binary), { recursive: true });
738
- execFileSync('swiftc', ['-O', '-o', binary, swiftSource, '-framework', 'Cocoa'], { timeout: 120000, stdio: 'pipe' });
1186
+ execFileSync('clang', ['-O2', '-o', binary, src], { timeout: 120000, stdio: 'pipe' });
739
1187
  fs.chmodSync(binary, 0o755);
740
- console.log(` ${GREEN}Compiled hotkey daemon${RESET} ${DIM}(${binary})${RESET}`);
1188
+ console.log(` ${GREEN}Compiled screenshot disclaim helper${RESET} ${DIM}(${binary})${RESET}`);
1189
+ } catch {
1190
+ console.log(` ${DIM}Skipped disclaim helper (clang not available)${RESET}`);
1191
+ }
1192
+ }
1193
+
1194
+ // Resolve a REAL node binary (never a bundle exec — guards against copying the
1195
+ // bundle's own node onto itself once CTM runs from the bundle after migration).
1196
+ function resolveRealNode() {
1197
+ let n = process.execPath;
1198
+ if (!n || n.includes('.app/Contents/MacOS/')) {
1199
+ try { n = execFileSync('node', ['-e', 'process.stdout.write(process.execPath)'], { encoding: 'utf8' }).trim(); } catch {}
1200
+ }
1201
+ return n;
1202
+ }
1203
+
1204
+ // Clone (APFS copy-on-write, near-free) or copy a binary into place, executable.
1205
+ function placeBinary(src, dest) {
1206
+ try { fs.rmSync(dest, { force: true }); } catch {}
1207
+ try { execFileSync('cp', ['-c', src, dest], { stdio: 'ignore' }); }
1208
+ catch { fs.copyFileSync(src, dest); }
1209
+ fs.chmodSync(dest, 0o755);
1210
+ }
1211
+
1212
+ // A dynamically-linked node (e.g. Homebrew) references @rpath/libnode.*.dylib (and ICU)
1213
+ // resolved via its rpath @loader_path/../lib. When we clone node into Contents/MacOS, that
1214
+ // rpath resolves to Contents/lib — so we copy the transitive @rpath dylibs there and the
1215
+ // unchanged rpath finds them (no install_name_tool needed). Self-contained nodes have no
1216
+ // @rpath deps and this is a no-op. Returns the list of copied dylib paths (to sign).
1217
+ function bundleNodeDylibs(realNode, libDir) {
1218
+ const copied = [];
1219
+ const seen = new Set();
1220
+ const nodeLibDir = path.resolve(path.dirname(realNode), '..', 'lib');
1221
+ const rpathDeps = (bin) => {
1222
+ try {
1223
+ return execFileSync('otool', ['-L', bin], { encoding: 'utf8' })
1224
+ .split('\n').map((l) => l.trim().split(/\s+/)[0]).filter((d) => d.startsWith('@rpath/'));
1225
+ } catch { return []; }
1226
+ };
1227
+ const rpathsOf = (bin) => {
1228
+ const rp = [];
1229
+ try {
1230
+ const o = execFileSync('otool', ['-l', bin], { encoding: 'utf8' });
1231
+ const re = /cmd LC_RPATH[\s\S]*?path (.+?) \(offset/g;
1232
+ let m; while ((m = re.exec(o))) rp.push(m[1].trim());
1233
+ } catch {}
1234
+ return rp;
1235
+ };
1236
+ const resolveSrc = (dep, binPath, binOrigDir) => {
1237
+ const name = dep.slice('@rpath/'.length);
1238
+ for (const rp of rpathsOf(binPath)) {
1239
+ const base = rp.replace('@loader_path', binOrigDir).replace('@executable_path', path.dirname(realNode));
1240
+ const cand = path.resolve(base, name);
1241
+ if (fs.existsSync(cand)) return cand;
1242
+ }
1243
+ const fb = path.join(nodeLibDir, name); // node's own lib dir (covers Homebrew layout)
1244
+ return fs.existsSync(fb) ? fb : null;
1245
+ };
1246
+ const walk = (binPath, binOrigDir) => {
1247
+ for (const dep of rpathDeps(binPath)) {
1248
+ const name = dep.slice('@rpath/'.length);
1249
+ if (seen.has(name)) continue;
1250
+ const src = resolveSrc(dep, binPath, binOrigDir);
1251
+ if (!src) continue;
1252
+ seen.add(name);
1253
+ fs.mkdirSync(libDir, { recursive: true });
1254
+ const dest = path.join(libDir, name);
1255
+ placeBinary(src, dest);
1256
+ copied.push(dest);
1257
+ walk(dest, path.dirname(src)); // recurse; resolve nested deps against the source's dir
1258
+ }
1259
+ };
1260
+ walk(realNode, path.dirname(realNode));
1261
+ return copied;
1262
+ }
1263
+
1264
+ function writeInfoPlist(plistPath, b, version) {
1265
+ const esc = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
1266
+ fs.writeFileSync(plistPath, `<?xml version="1.0" encoding="UTF-8"?>
1267
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1268
+ <plist version="1.0">
1269
+ <dict>
1270
+ <key>CFBundleName</key><string>${esc(b.name)}</string>
1271
+ <key>CFBundleDisplayName</key><string>${esc(b.name)}</string>
1272
+ <key>CFBundleIdentifier</key><string>${esc(b.bundleId)}</string>
1273
+ <key>CFBundleExecutable</key><string>${esc(b.exec)}</string>
1274
+ <key>CFBundleIconFile</key><string>AppIcon</string>
1275
+ <key>CFBundlePackageType</key><string>APPL</string>
1276
+ <key>CFBundleInfoDictionaryVersion</key><string>6.0</string>
1277
+ <key>CFBundleVersion</key><string>${esc(version)}</string>
1278
+ <key>CFBundleShortVersionString</key><string>${esc(version)}</string>
1279
+ <key>LSUIElement</key><true/>
1280
+ <key>LSMinimumSystemVersion</key><string>11.0</string>
1281
+ </dict>
1282
+ </plist>
1283
+ `);
1284
+ }
1285
+
1286
+ // Generate an .icns from a square PNG via sips + iconutil (install-time fallback when
1287
+ // no prebuilt .icns shipped). Best-effort; throws if the toolchain is missing.
1288
+ function generateIcns(srcPng, outIcns) {
1289
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'walle-icns-'));
1290
+ try {
1291
+ const iconset = path.join(tmp, 'AppIcon.iconset');
1292
+ fs.mkdirSync(iconset, { recursive: true });
1293
+ for (const sz of [16, 32, 128, 256, 512]) {
1294
+ execFileSync('sips', ['-z', String(sz), String(sz), srcPng, '--out', path.join(iconset, `icon_${sz}x${sz}.png`)], { stdio: 'ignore' });
1295
+ execFileSync('sips', ['-z', String(sz * 2), String(sz * 2), srcPng, '--out', path.join(iconset, `icon_${sz}x${sz}@2x.png`)], { stdio: 'ignore' });
1296
+ }
1297
+ execFileSync('iconutil', ['-c', 'icns', iconset, '-o', outIcns], { stdio: 'ignore' });
1298
+ } finally {
1299
+ try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {}
1300
+ }
1301
+ }
1302
+
1303
+ function placeIcns(walleDir, b, resDir) {
1304
+ const out = path.join(resDir, 'AppIcon.icns');
1305
+ const shipped = path.join(walleDir, 'shared', 'icons', b.icns);
1306
+ if (fs.existsSync(shipped)) { try { fs.copyFileSync(shipped, out); return true; } catch {} }
1307
+ const png = path.join(walleDir, 'claude-task-manager', 'public', 'icon-512.png');
1308
+ if (fs.existsSync(png)) { try { generateIcns(png, out); return true; } catch {} }
1309
+ return false; // valid bundle without an icon (generic) — graceful
1310
+ }
1311
+
1312
+ // A clickable launcher app in ~/Applications (Launchpad / Spotlight / Dock) that brings
1313
+ // the CTM primary back up if it's down and opens the dashboard. Unlike the daemon bundles
1314
+ // (LSUIElement node), this is a normal app the user can launch when the server is dead —
1315
+ // a web button can't help then (nothing is serving the page). Returns the .app path or null.
1316
+ function buildLauncherApp(walleDir) {
1317
+ if (process.platform !== 'darwin') return null;
1318
+ try {
1319
+ const appsDir = process.env.WALLE_APPLICATIONS_DIR || path.join(process.env.HOME, 'Applications');
1320
+ const appDir = path.join(appsDir, 'Wall-E.app');
1321
+ const macosDir = path.join(appDir, 'Contents', 'MacOS');
1322
+ const resDir = path.join(appDir, 'Contents', 'Resources');
1323
+ fs.mkdirSync(macosDir, { recursive: true });
1324
+ fs.mkdirSync(resDir, { recursive: true });
1325
+
1326
+ let port = '3456';
1327
+ try { const m = fs.readFileSync(path.join(walleDir, '.env'), 'utf8').match(/^\s*CTM_PORT\s*=\s*(\d+)/m); if (m) port = m[1]; } catch {}
1328
+
1329
+ const exec = path.join(macosDir, 'Wall-E');
1330
+ fs.writeFileSync(exec, `#!/bin/bash
1331
+ # Wall-E launcher — start the CTM primary if it's down, then open the dashboard.
1332
+ PORT="${port}"
1333
+ UID_N="$(id -u)"
1334
+ if launchctl print "gui/$UID_N/com.walle.server" >/dev/null 2>&1; then
1335
+ launchctl kickstart "gui/$UID_N/com.walle.server" >/dev/null 2>&1 || true
1336
+ else
1337
+ PLIST="$HOME/Library/LaunchAgents/com.walle.server.plist"
1338
+ if [ -f "$PLIST" ]; then
1339
+ launchctl bootstrap "gui/$UID_N" "$PLIST" >/dev/null 2>&1 || true
1340
+ elif [ -f "${walleDir}/claude-task-manager/bin/restart-ctm.sh" ]; then
1341
+ bash "${walleDir}/claude-task-manager/bin/restart-ctm.sh" >/dev/null 2>&1 || true
1342
+ fi
1343
+ fi
1344
+ SCHEME="http"
1345
+ for i in $(seq 1 30); do
1346
+ if curl -s --max-time 1 "http://localhost:$PORT" >/dev/null 2>&1; then SCHEME="http"; break; fi
1347
+ if curl -sk --max-time 1 "https://localhost:$PORT" >/dev/null 2>&1; then SCHEME="https"; break; fi
1348
+ sleep 0.5
1349
+ done
1350
+ open "$SCHEME://localhost:$PORT" 2>/dev/null || true
1351
+ `, { mode: 0o755 });
1352
+
1353
+ const version = (() => { try { return String(require('../package.json').version || '0'); } catch { return '0'; } })();
1354
+ const esc = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
1355
+ fs.writeFileSync(path.join(appDir, 'Contents', 'Info.plist'), `<?xml version="1.0" encoding="UTF-8"?>
1356
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1357
+ <plist version="1.0">
1358
+ <dict>
1359
+ <key>CFBundleName</key><string>Wall-E</string>
1360
+ <key>CFBundleDisplayName</key><string>Wall-E</string>
1361
+ <key>CFBundleIdentifier</key><string>com.walle.launcher</string>
1362
+ <key>CFBundleExecutable</key><string>Wall-E</string>
1363
+ <key>CFBundleIconFile</key><string>AppIcon</string>
1364
+ <key>CFBundlePackageType</key><string>APPL</string>
1365
+ <key>CFBundleInfoDictionaryVersion</key><string>6.0</string>
1366
+ <key>CFBundleVersion</key><string>${esc(version)}</string>
1367
+ <key>CFBundleShortVersionString</key><string>${esc(version)}</string>
1368
+ <key>LSMinimumSystemVersion</key><string>11.0</string>
1369
+ </dict>
1370
+ </plist>
1371
+ `);
1372
+ placeIcns(walleDir, { icns: 'AppIcon-walle.icns' }, resDir);
1373
+ return appDir;
1374
+ } catch { return null; }
1375
+ }
1376
+
1377
+ // Idempotently ensure a stable self-signed code-signing identity in a dedicated keychain
1378
+ // (avoids login-keychain prompts; stable designated requirement so TCC survives updates).
1379
+ // Returns the identity common name (codesign --sign "<CN>" works with an untrusted
1380
+ // self-signed cert — find-identity -v would omit it). Null if signing is unavailable.
1381
+ function ensureSigningIdentity() {
1382
+ if (process.platform !== 'darwin') return null;
1383
+ const pw = 'walle';
1384
+ // Count CERTS by common name (trust-independent; find-identity -v hides untrusted certs).
1385
+ // codesign matches certs BY NAME, so 2 certs with this CN → "ambiguous" failures.
1386
+ const certCount = () => {
1387
+ try { return (execFileSync('security', ['find-certificate', '-a', '-c', SIGN_IDENTITY_CN, SIGN_KEYCHAIN], { encoding: 'utf8' }).match(/^keychain:/gm) || []).length; }
1388
+ catch { return 0; }
1389
+ };
1390
+ // The cert's SHA-1 (trust-independent via -Z). Sign by hash, not name: codesign --sign
1391
+ // "<CN>" scans the WHOLE search list and goes "ambiguous" if another keychain holds a
1392
+ // same-named cert; a hash matches exactly one cert. Same cert → same hash → stable DR.
1393
+ const identitySha = () => {
1394
+ try {
1395
+ const out = execFileSync('security', ['find-certificate', '-a', '-c', SIGN_IDENTITY_CN, '-Z', SIGN_KEYCHAIN], { encoding: 'utf8' });
1396
+ const m = out.match(/SHA-1 hash:\s*([0-9A-Fa-f]{40})/);
1397
+ return m ? m[1] : null;
1398
+ } catch { return null; }
1399
+ };
1400
+ try {
1401
+ if (fs.existsSync(SIGN_KEYCHAIN)) {
1402
+ let unlocked = false;
1403
+ try {
1404
+ execFileSync('security', ['unlock-keychain', '-p', pw, SIGN_KEYCHAIN], { stdio: 'ignore' });
1405
+ unlocked = true;
1406
+ } catch {}
1407
+ if (!unlocked && certCount() < 1) {
1408
+ try { execFileSync('security', ['delete-keychain', SIGN_KEYCHAIN], { stdio: 'ignore' }); } catch {}
1409
+ }
1410
+ // Reset to a single clean identity if duplicate certs accumulated (→ ambiguous).
1411
+ if (fs.existsSync(SIGN_KEYCHAIN) && certCount() > 1) { try { execFileSync('security', ['delete-keychain', SIGN_KEYCHAIN], { stdio: 'ignore' }); } catch {} }
1412
+ }
1413
+ fs.mkdirSync(path.dirname(SIGN_KEYCHAIN), { recursive: true });
1414
+ if (!fs.existsSync(SIGN_KEYCHAIN)) execFileSync('security', ['create-keychain', '-p', pw, SIGN_KEYCHAIN], { stdio: 'ignore' });
1415
+ execFileSync('security', ['set-keychain-settings', SIGN_KEYCHAIN], { stdio: 'ignore' }); // no auto-lock timeout
1416
+ execFileSync('security', ['unlock-keychain', '-p', pw, SIGN_KEYCHAIN], { stdio: 'ignore' });
1417
+ try {
1418
+ const list = execFileSync('security', ['list-keychains', '-d', 'user'], { encoding: 'utf8' })
1419
+ .split('\n').map((s) => s.trim().replace(/^"|"$/g, '')).filter(Boolean);
1420
+ if (!list.includes(SIGN_KEYCHAIN)) execFileSync('security', ['list-keychains', '-d', 'user', '-s', ...list, SIGN_KEYCHAIN], { stdio: 'ignore' });
1421
+ } catch {}
1422
+ if (certCount() < 1) {
1423
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'walle-sign-'));
1424
+ try {
1425
+ const keyPem = path.join(tmp, 'key.pem'), certPem = path.join(tmp, 'cert.pem'), p12 = path.join(tmp, 'id.p12');
1426
+ execFileSync('openssl', ['req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-days', '3650', '-keyout', keyPem, '-out', certPem,
1427
+ '-subj', `/CN=${SIGN_IDENTITY_CN}`, '-addext', 'keyUsage=critical,digitalSignature', '-addext', 'extendedKeyUsage=critical,codeSigning'], { stdio: 'ignore' });
1428
+ execFileSync('openssl', ['pkcs12', '-export', '-inkey', keyPem, '-in', certPem, '-out', p12, '-passout', 'pass:' + pw], { stdio: 'ignore' });
1429
+ execFileSync('security', ['import', p12, '-k', SIGN_KEYCHAIN, '-P', pw, '-T', '/usr/bin/codesign', '-T', '/usr/bin/security'], { stdio: 'ignore' });
1430
+ } finally {
1431
+ try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {}
1432
+ }
1433
+ }
1434
+ // ALWAYS (re)authorize codesign to use the key non-interactively (reuse may have relocked).
1435
+ try { execFileSync('security', ['set-key-partition-list', '-S', 'apple-tool:,apple:', '-s', '-k', pw, SIGN_KEYCHAIN], { stdio: 'ignore' }); } catch {}
1436
+ return identitySha();
741
1437
  } catch {
742
- // swiftc not available or compilation failed — non-fatal
743
- console.log(` ${DIM}Skipped hotkey daemon (Swift compiler not available)${RESET}`);
1438
+ return null;
1439
+ }
1440
+ }
1441
+
1442
+ function signArtifacts(identity, items) {
1443
+ if (!identity) return false;
1444
+ let ok = true;
1445
+ for (const it of items) {
1446
+ try {
1447
+ const args = ['--force', '--sign', identity, '--identifier', it.id, '--keychain', SIGN_KEYCHAIN];
1448
+ if (it.deep) args.push('--deep'); // also sign nested dylibs in Contents/lib (self-signed local use)
1449
+ args.push(it.path);
1450
+ execFileSync('codesign', args, { stdio: 'ignore', timeout: 120000 });
1451
+ } catch { ok = false; }
1452
+ }
1453
+ return ok;
1454
+ }
1455
+
1456
+ // Build (or refresh) the per-daemon .app bundles so CTM and Wall-E show a real icon +
1457
+ // name in Activity Monitor, then self-sign them and the hotkey for stable TCC. darwin-only,
1458
+ // best-effort: any failure logs a dim notice and leaves the install working (unsigned/
1459
+ // iconless at worst — process names still come from process.title).
1460
+ function buildAppBundles(walleDir) {
1461
+ if (process.platform !== 'darwin') return;
1462
+ try {
1463
+ const realNode = resolveRealNode();
1464
+ if (!realNode || !fs.existsSync(realNode)) return;
1465
+ const version = (() => { try { return String(require('../package.json').version || '0'); } catch { return '0'; } })();
1466
+ fs.mkdirSync(BUNDLE_ROOT, { recursive: true });
1467
+ // Record the real node we cloned from. At runtime CTM/Wall-E spawn their children from
1468
+ // this (not the self-signed bundle clone) so a multi-session restart doesn't bury the
1469
+ // macOS endpoint-security stack under a storm of novel-binary AUTH_EXEC evaluations.
1470
+ try { fs.writeFileSync(path.join(BUNDLE_ROOT, 'node-origin'), realNode + '\n'); } catch {}
1471
+ for (const b of APP_BUNDLES) {
1472
+ const appDir = path.join(BUNDLE_ROOT, b.app);
1473
+ const macosDir = path.join(appDir, 'Contents', 'MacOS');
1474
+ const resDir = path.join(appDir, 'Contents', 'Resources');
1475
+ fs.mkdirSync(macosDir, { recursive: true });
1476
+ fs.mkdirSync(resDir, { recursive: true });
1477
+ placeBinary(realNode, path.join(macosDir, b.exec));
1478
+ // Co-locate node's @rpath dylibs (libnode/ICU for dynamically-linked builds) so the
1479
+ // cloned exec runs from inside the bundle. No-op for a self-contained node.
1480
+ bundleNodeDylibs(realNode, path.join(appDir, 'Contents', 'lib'));
1481
+ writeInfoPlist(path.join(appDir, 'Contents', 'Info.plist'), b, version);
1482
+ placeIcns(walleDir, b, resDir);
1483
+ }
1484
+ // Clickable launcher in ~/Applications (Launchpad/Dock) to revive a dead primary.
1485
+ const launcher = buildLauncherApp(walleDir);
1486
+ const identity = ensureSigningIdentity();
1487
+ const items = APP_BUNDLES.map((b) => ({ path: path.join(BUNDLE_ROOT, b.app), id: b.bundleId, deep: true }));
1488
+ if (launcher) items.push({ path: launcher, id: 'com.walle.launcher', deep: true });
1489
+ // The hotkey is its own LSUIElement .app bundle under BUNDLE_ROOT (built by
1490
+ // compileHotkeyDaemon); sign it alongside the daemon bundles. It follows BUNDLE_ROOT, so a
1491
+ // WALLE_BUNDLE_DIR test dir signs the test bundle, never the live one.
1492
+ const hotkeyBundle = path.join(BUNDLE_ROOT, 'CTM-Screenshot.app');
1493
+ if (fs.existsSync(hotkeyBundle)) items.push({ path: hotkeyBundle, id: 'com.walle.ctm.hotkey', deep: true });
1494
+ const signed = signArtifacts(identity, items);
1495
+ console.log(` ${GREEN}Built app bundles + launcher${RESET} ${DIM}(${BUNDLE_ROOT}${signed ? ', signed' : ', unsigned'})${RESET}`);
1496
+ } catch (e) {
1497
+ console.log(` ${DIM}Skipped app bundles (${e && e.message ? e.message : e})${RESET}`);
744
1498
  }
745
1499
  }
746
1500
 
@@ -767,4 +1521,20 @@ module.exports = {
767
1521
  repairNativeDependencies,
768
1522
  resolveNpmRunner,
769
1523
  writeCliLifecycleEvent,
1524
+ buildAppBundles,
1525
+ ensureSigningIdentity,
1526
+ signArtifacts,
1527
+ ctmBundleExec,
1528
+ walleBundleExec,
1529
+ execTeamIdentifier,
1530
+ daemonExec,
1531
+ daemonNodeForBuild,
1532
+ validatedNotarizedNode,
1533
+ verifyNotarizedNode,
1534
+ nodeReportsVersion,
1535
+ ensureNotarizedDaemonNode,
1536
+ disableNotarizedNode,
1537
+ notarizedNodeDir,
1538
+ notarizedNodePath,
1539
+ NOTARIZED_NODE_VERSION,
770
1540
  };