create-walle 0.9.21 → 0.9.23

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 (500) hide show
  1. package/README.md +27 -5
  2. package/package.json +2 -2
  3. package/template/CLAUDE.md +2 -2
  4. package/template/LICENSE +1 -1
  5. package/template/bin/ctm-dev-cleanup.js +24 -3
  6. package/template/bin/ctm-launch.sh +13 -0
  7. package/template/bin/dev.sh +156 -18
  8. package/template/bin/node-bin.sh +84 -0
  9. package/template/bin/pin-node.sh +51 -0
  10. package/template/claude-task-manager/api-prompts.js +1203 -182
  11. package/template/claude-task-manager/api-reviews.js +109 -15
  12. package/template/claude-task-manager/approval-agent.js +1360 -280
  13. package/template/claude-task-manager/bin/restart-ctm.sh +64 -23
  14. package/template/claude-task-manager/bin/storage-migration-supervisor.js +338 -0
  15. package/template/claude-task-manager/db.js +4417 -295
  16. package/template/claude-task-manager/docs/app-update-refresh-protocol.md +69 -0
  17. package/template/claude-task-manager/docs/approval-ai-refinement.md +138 -0
  18. package/template/claude-task-manager/docs/approval-rescue-loop.md +74 -0
  19. package/template/claude-task-manager/docs/codex-operational-warning-health.md +107 -0
  20. package/template/claude-task-manager/docs/codex-resume-state-guard-design.md +17 -12
  21. package/template/claude-task-manager/docs/codex-terminal-render-controller-handoff.md +311 -0
  22. package/template/claude-task-manager/docs/coding-agent-hooks-architecture.md +418 -0
  23. package/template/claude-task-manager/docs/conversation-import-freshness.md +20 -0
  24. package/template/claude-task-manager/docs/google-workspace-auth-health.md +77 -0
  25. package/template/claude-task-manager/docs/image-paste-ux.md +13 -0
  26. package/template/claude-task-manager/docs/ipad-web-preview.md +88 -0
  27. package/template/claude-task-manager/docs/main-loop-offload-architecture.md +66 -0
  28. package/template/claude-task-manager/docs/microsoft-dev-tunnel-phone-access-design.md +274 -519
  29. package/template/claude-task-manager/docs/mobile-live-streaming.md +27 -5
  30. package/template/claude-task-manager/docs/mobile-remote-submission-lifecycle.md +69 -0
  31. package/template/claude-task-manager/docs/phone-access-design.md +53 -15
  32. package/template/claude-task-manager/docs/phone-passkey-identity.md +122 -0
  33. package/template/claude-task-manager/docs/phone-setup.md +3 -0
  34. package/template/claude-task-manager/docs/prompt-editing-tree-design.md +25 -1
  35. package/template/claude-task-manager/docs/remote-desktop-access-design.md +268 -0
  36. package/template/claude-task-manager/docs/restart-lifecycle-architecture.md +95 -0
  37. package/template/claude-task-manager/docs/runtime-work-control-plane.md +53 -0
  38. package/template/claude-task-manager/docs/session-interactive-wait-surfaces.md +38 -0
  39. package/template/claude-task-manager/docs/session-needs-you-dismissal.md +84 -0
  40. package/template/claude-task-manager/docs/session-render-state-management-design.md +91 -3
  41. package/template/claude-task-manager/docs/session-standup-command-center-design.md +25 -1
  42. package/template/claude-task-manager/docs/session-title-authority.md +32 -0
  43. package/template/claude-task-manager/docs/session-workspace-binding.md +33 -0
  44. package/template/claude-task-manager/docs/skill-intent-resolution-design.md +72 -0
  45. package/template/claude-task-manager/docs/walle-mcp-supervisor-health.md +86 -0
  46. package/template/claude-task-manager/docs/walle-relay-phone-access-design.md +24 -15
  47. package/template/claude-task-manager/docs/walle-session-history-hydration.md +114 -0
  48. package/template/claude-task-manager/docs/walle-session-input-queue.md +104 -0
  49. package/template/claude-task-manager/docs/walle-session-model-catalog.md +90 -0
  50. package/template/claude-task-manager/docs/walle-session-model-preferences.md +15 -6
  51. package/template/claude-task-manager/git-utils.js +897 -27
  52. package/template/claude-task-manager/lib/agent-capabilities.js +33 -0
  53. package/template/claude-task-manager/lib/agent-cli-cache.js +37 -7
  54. package/template/claude-task-manager/lib/agent-hooks-installer.js +26 -2
  55. package/template/claude-task-manager/lib/agent-presets.js +17 -1
  56. package/template/claude-task-manager/lib/all-sessions-query.js +108 -0
  57. package/template/claude-task-manager/lib/approval-ai-refinement.js +488 -0
  58. package/template/claude-task-manager/lib/approval-self-adapt.js +168 -0
  59. package/template/claude-task-manager/lib/async-semaphore.js +44 -0
  60. package/template/claude-task-manager/lib/auth-context.js +5 -0
  61. package/template/claude-task-manager/lib/auth-rate-limit.js +47 -4
  62. package/template/claude-task-manager/lib/auth-rules.js +29 -2
  63. package/template/claude-task-manager/lib/auto-approval-verifier.js +129 -16
  64. package/template/claude-task-manager/lib/background-llm.js +144 -17
  65. package/template/claude-task-manager/lib/branch-inventory.js +212 -0
  66. package/template/claude-task-manager/lib/claude-desktop-sessions.js +15 -3
  67. package/template/claude-task-manager/lib/coalesce-sync-frames.js +151 -0
  68. package/template/claude-task-manager/lib/codex-launch-health.js +762 -0
  69. package/template/claude-task-manager/lib/codex-transcript-pager.js +51 -0
  70. package/template/claude-task-manager/lib/codex-zst.js +124 -0
  71. package/template/claude-task-manager/lib/coding-agent-models.js +233 -30
  72. package/template/claude-task-manager/lib/connection-health.js +232 -0
  73. package/template/claude-task-manager/lib/conversation-blob-parser.js +42 -0
  74. package/template/claude-task-manager/lib/conversation-tail-merge.js +89 -26
  75. package/template/claude-task-manager/lib/ctm-session-context-api.js +39 -10
  76. package/template/claude-task-manager/lib/cursor-conversation-store.js +354 -0
  77. package/template/claude-task-manager/lib/db-owner-worker-client.js +315 -0
  78. package/template/claude-task-manager/lib/document-review.js +141 -6
  79. package/template/claude-task-manager/lib/escalation-review.js +152 -0
  80. package/template/claude-task-manager/lib/graceful-shutdown.js +159 -0
  81. package/template/claude-task-manager/lib/headless-term-service.js +678 -0
  82. package/template/claude-task-manager/lib/heavy-worker-fallback.js +38 -0
  83. package/template/claude-task-manager/lib/jsonl-conversation-parser.js +542 -0
  84. package/template/claude-task-manager/lib/jsonl-range-reader.js +112 -0
  85. package/template/claude-task-manager/lib/main-db-census.js +216 -0
  86. package/template/claude-task-manager/lib/message-pagination.js +106 -4
  87. package/template/claude-task-manager/lib/microsoft-dev-tunnel-setup.js +750 -26
  88. package/template/claude-task-manager/lib/mobile-auth-api.js +274 -7
  89. package/template/claude-task-manager/lib/mobile-auth-store.js +592 -10
  90. package/template/claude-task-manager/lib/mobile-notification-dispatcher.js +15 -0
  91. package/template/claude-task-manager/lib/model-overview-brain-fallback.js +311 -0
  92. package/template/claude-task-manager/lib/model-overview-cache.js +141 -0
  93. package/template/claude-task-manager/lib/models-health-routing-notice.js +126 -0
  94. package/template/claude-task-manager/lib/node-pin-guard.js +93 -0
  95. package/template/claude-task-manager/lib/perf-tracker.js +242 -6
  96. package/template/claude-task-manager/lib/permission-match.js +76 -0
  97. package/template/claude-task-manager/lib/permission-sync.js +133 -20
  98. package/template/claude-task-manager/lib/process-title.js +35 -0
  99. package/template/claude-task-manager/lib/prompt-executions-query.js +25 -0
  100. package/template/claude-task-manager/lib/prompt-index-disk-cache.js +44 -0
  101. package/template/claude-task-manager/lib/prompt-intent.js +132 -0
  102. package/template/claude-task-manager/lib/provider-user-context.js +34 -0
  103. package/template/claude-task-manager/lib/read-pool-client.js +313 -0
  104. package/template/claude-task-manager/lib/readpool-breaker.js +31 -0
  105. package/template/claude-task-manager/lib/recent-sessions-breaker.js +12 -0
  106. package/template/claude-task-manager/lib/remote-feedback-client.js +72 -0
  107. package/template/claude-task-manager/lib/remote-relay-protocol.js +37 -4
  108. package/template/claude-task-manager/lib/remote-relay-store.js +159 -0
  109. package/template/claude-task-manager/lib/remote-submission-observer.js +278 -0
  110. package/template/claude-task-manager/lib/restart-guard.js +109 -0
  111. package/template/claude-task-manager/lib/restore-interruption-detector.js +439 -0
  112. package/template/claude-task-manager/lib/restore-policy.js +13 -0
  113. package/template/claude-task-manager/lib/restore-resume-batch.js +74 -0
  114. package/template/claude-task-manager/lib/restore-runtime.js +68 -0
  115. package/template/claude-task-manager/lib/restore-storm.js +34 -0
  116. package/template/claude-task-manager/lib/resume-cwd.js +36 -0
  117. package/template/claude-task-manager/lib/resume-preflight.js +313 -0
  118. package/template/claude-task-manager/lib/runtime-work-registry.js +444 -0
  119. package/template/claude-task-manager/lib/sanitize-openai-auth.js +31 -0
  120. package/template/claude-task-manager/lib/scheduler.js +21 -1
  121. package/template/claude-task-manager/lib/scrollback-snapshot-store.js +159 -0
  122. package/template/claude-task-manager/lib/serial-task-queue.js +64 -0
  123. package/template/claude-task-manager/lib/server-listeners.js +239 -0
  124. package/template/claude-task-manager/lib/session-capture.js +42 -7
  125. package/template/claude-task-manager/lib/session-content-backfill.js +131 -0
  126. package/template/claude-task-manager/lib/session-history.js +388 -43
  127. package/template/claude-task-manager/lib/session-host-manager.js +287 -0
  128. package/template/claude-task-manager/lib/session-image-refs.js +209 -0
  129. package/template/claude-task-manager/lib/session-jobs.js +399 -59
  130. package/template/claude-task-manager/lib/session-prompt-index.js +137 -0
  131. package/template/claude-task-manager/lib/session-restore.js +53 -0
  132. package/template/claude-task-manager/lib/session-standup.js +123 -23
  133. package/template/claude-task-manager/lib/session-state-bus.js +14 -0
  134. package/template/claude-task-manager/lib/session-stream.js +64 -16
  135. package/template/claude-task-manager/lib/session-timeline-summary.js +260 -0
  136. package/template/claude-task-manager/lib/session-token-usage.js +494 -0
  137. package/template/claude-task-manager/lib/session-workspace-binding.js +356 -0
  138. package/template/claude-task-manager/lib/setup-network-config.js +9 -0
  139. package/template/claude-task-manager/lib/size-cap.js +45 -0
  140. package/template/claude-task-manager/lib/size-cap.test.js +62 -0
  141. package/template/claude-task-manager/lib/skill-autocomplete.js +180 -1
  142. package/template/claude-task-manager/lib/skill-intent-resolver.js +304 -0
  143. package/template/claude-task-manager/lib/sqlite-driver.js +19 -3
  144. package/template/claude-task-manager/lib/standup-attention.js +7 -3
  145. package/template/claude-task-manager/lib/status-authority.js +39 -0
  146. package/template/claude-task-manager/lib/status-hooks.js +4 -0
  147. package/template/claude-task-manager/lib/storage-migration.js +235 -0
  148. package/template/claude-task-manager/lib/structured-capture.js +298 -0
  149. package/template/claude-task-manager/lib/sync-io-census.js +163 -0
  150. package/template/claude-task-manager/lib/tailscale-setup.js +6 -0
  151. package/template/claude-task-manager/lib/terminal-activity-evidence.js +33 -0
  152. package/template/claude-task-manager/lib/terminal-choice.js +364 -0
  153. package/template/claude-task-manager/lib/terminal-control-sanitize.js +17 -0
  154. package/template/claude-task-manager/lib/terminal-fingerprint.js +48 -0
  155. package/template/claude-task-manager/lib/terminal-output-flush.js +84 -0
  156. package/template/claude-task-manager/lib/timeline-order.js +122 -0
  157. package/template/claude-task-manager/lib/transcript-store.js +348 -43
  158. package/template/claude-task-manager/lib/transport-security.js +84 -1
  159. package/template/claude-task-manager/lib/wait-state.js +184 -0
  160. package/template/claude-task-manager/lib/walle-client.js +47 -5
  161. package/template/claude-task-manager/lib/walle-ctm-history.js +564 -4
  162. package/template/claude-task-manager/lib/walle-external-actions.js +135 -16
  163. package/template/claude-task-manager/lib/walle-history-hydration.js +46 -0
  164. package/template/claude-task-manager/lib/walle-native-health.js +403 -0
  165. package/template/claude-task-manager/lib/walle-repair.js +701 -0
  166. package/template/claude-task-manager/lib/walle-session-cache.js +109 -0
  167. package/template/claude-task-manager/lib/walle-session-context.js +57 -21
  168. package/template/claude-task-manager/lib/walle-session-model-catalog.js +34 -0
  169. package/template/claude-task-manager/lib/walle-supervisor.js +539 -63
  170. package/template/claude-task-manager/lib/walle-transcript.js +52 -0
  171. package/template/claude-task-manager/lib/worktree-active-sync.js +11 -7
  172. package/template/claude-task-manager/lib/worktree-cwd.js +32 -1
  173. package/template/claude-task-manager/package.json +1 -1
  174. package/template/claude-task-manager/prompt-harvest.js +89 -66
  175. package/template/claude-task-manager/providers/claude-code.js +51 -3
  176. package/template/claude-task-manager/providers/cursor.js +140 -45
  177. package/template/claude-task-manager/public/css/reviews.css +551 -61
  178. package/template/claude-task-manager/public/css/setup.css +191 -0
  179. package/template/claude-task-manager/public/css/walle-session.css +865 -10
  180. package/template/claude-task-manager/public/css/walle.css +154 -0
  181. package/template/claude-task-manager/public/designs/ai-providers-consolidation-v2.html +830 -0
  182. package/template/claude-task-manager/public/index.html +18516 -2058
  183. package/template/claude-task-manager/public/ipad.html +363 -0
  184. package/template/claude-task-manager/public/js/document-review-links.js +301 -0
  185. package/template/claude-task-manager/public/js/image-normalize.js +69 -36
  186. package/template/claude-task-manager/public/js/message-renderer.js +1265 -77
  187. package/template/claude-task-manager/public/js/prompts.js +66 -29
  188. package/template/claude-task-manager/public/js/reviews.js +901 -133
  189. package/template/claude-task-manager/public/js/session-activity-utils.js +11 -1
  190. package/template/claude-task-manager/public/js/session-search-utils.js +94 -10
  191. package/template/claude-task-manager/public/js/session-status-precedence.js +23 -5
  192. package/template/claude-task-manager/public/js/setup.js +1273 -176
  193. package/template/claude-task-manager/public/js/stream-view.js +691 -73
  194. package/template/claude-task-manager/public/js/terminal-reconciler.js +210 -0
  195. package/template/claude-task-manager/public/js/walle-session.js +2455 -158
  196. package/template/claude-task-manager/public/js/walle.js +455 -28
  197. package/template/claude-task-manager/public/m/app.css +2909 -262
  198. package/template/claude-task-manager/public/m/app.js +6601 -398
  199. package/template/claude-task-manager/public/m/claim.html +224 -17
  200. package/template/claude-task-manager/public/m/index.html +117 -21
  201. package/template/claude-task-manager/public/m/sw.js +3 -1
  202. package/template/claude-task-manager/public/manifest.json +2 -2
  203. package/template/claude-task-manager/public/prompts.html +30 -14
  204. package/template/claude-task-manager/queue-engine.js +507 -28
  205. package/template/claude-task-manager/scripts/repair-claude-session-images.js +27 -8
  206. package/template/claude-task-manager/server.js +14341 -2197
  207. package/template/claude-task-manager/session-integrity.js +160 -18
  208. package/template/claude-task-manager/session-search-ranking.js +1 -0
  209. package/template/claude-task-manager/session-utils.js +25 -5
  210. package/template/claude-task-manager/workers/approval-blocklist.js +96 -6
  211. package/template/claude-task-manager/workers/approval-widget-validator.js +14 -8
  212. package/template/claude-task-manager/workers/conversation-import-worker.js +11 -50
  213. package/template/claude-task-manager/workers/db-owner-worker.js +386 -0
  214. package/template/claude-task-manager/workers/harvest-worker.js +9 -55
  215. package/template/claude-task-manager/workers/headless-term-worker.js +9 -530
  216. package/template/claude-task-manager/workers/read-pool-worker.js +387 -0
  217. package/template/claude-task-manager/workers/scrollback-worker.js +11 -72
  218. package/template/claude-task-manager/workers/session-host-process.js +146 -0
  219. package/template/claude-task-manager/workers/session-integrity-worker.js +10 -54
  220. package/template/claude-task-manager/workers/state-detectors/base.js +18 -1
  221. package/template/claude-task-manager/workers/state-detectors/claude-code.js +182 -9
  222. package/template/claude-task-manager/workers/state-detectors/codex.js +150 -2
  223. package/template/claude-task-manager/workers/state-detectors/cursor.js +127 -0
  224. package/template/claude-task-manager/workers/state-detectors/gemini.js +21 -0
  225. package/template/claude-task-manager/workers/state-detectors/index.js +29 -0
  226. package/template/claude-task-manager/workers/state-detectors/opencode.js +103 -0
  227. package/template/docs/design/markdown-review-pane.md +206 -0
  228. package/template/docs/designs/2026-05-17-portkey-gateway-provider-ux.md +129 -38
  229. package/template/docs/designs/2026-05-20-mobile-worktree-finish-command.md +27 -0
  230. package/template/docs/designs/2026-05-22-ai-configuration-consolidation.md +248 -0
  231. package/template/docs/designs/ai-configuration-consolidation-mock.html +812 -0
  232. package/template/docs/private-memory-and-pii-policy.md +69 -0
  233. package/template/package.json +2 -1
  234. package/template/scripts/check-private-data.js +201 -0
  235. package/template/shared/sqlite-owner-guard.js +30 -0
  236. package/template/shared/sqlite-owner-write-queue.js +225 -0
  237. package/template/shared/sqlite-storage-policy.js +111 -0
  238. package/template/shared/sqlite-write-lock.js +428 -0
  239. package/template/wall-e/agent-runners/claude-code.js +5 -0
  240. package/template/wall-e/agent.js +166 -22
  241. package/template/wall-e/api-walle.js +524 -70
  242. package/template/wall-e/auth/provider-flows.js +11 -1
  243. package/template/wall-e/bin/walle-mcp-stdio.js +341 -17
  244. package/template/wall-e/brain.js +1614 -141
  245. package/template/wall-e/chat/attachment-blocks.js +96 -0
  246. package/template/wall-e/chat/attachments.js +2 -1
  247. package/template/wall-e/chat/capability-resolver.js +7 -7
  248. package/template/wall-e/chat/context-messages.js +28 -0
  249. package/template/wall-e/chat/conversation-frame.js +630 -0
  250. package/template/wall-e/chat/provider-messages.js +125 -0
  251. package/template/wall-e/chat.js +1002 -233
  252. package/template/wall-e/coding/acceptance-contract.js +170 -0
  253. package/template/wall-e/coding/acp-adapter.js +1 -1
  254. package/template/wall-e/coding/agent-catalog.js +3 -0
  255. package/template/wall-e/coding/artifact-store.js +93 -0
  256. package/template/wall-e/coding/capability-router.js +120 -0
  257. package/template/wall-e/coding/coding-run-controller.js +423 -0
  258. package/template/wall-e/coding/compaction-service.js +157 -12
  259. package/template/wall-e/coding/frontend-verification.js +258 -0
  260. package/template/wall-e/coding/lifecycle-hooks.js +75 -0
  261. package/template/wall-e/coding/local-preview-contract.js +157 -0
  262. package/template/wall-e/coding/permission-service.js +57 -13
  263. package/template/wall-e/coding/prompt-bundle.js +19 -1
  264. package/template/wall-e/coding/prompt-section-registry.js +227 -0
  265. package/template/wall-e/coding/provider-compat.js +15 -0
  266. package/template/wall-e/coding/runtime-events.js +224 -0
  267. package/template/wall-e/coding/runtime-mode.js +3 -0
  268. package/template/wall-e/coding/side-git-snapshot.js +160 -4
  269. package/template/wall-e/coding/snapshot-service.js +143 -1
  270. package/template/wall-e/coding/stream-processor.js +388 -34
  271. package/template/wall-e/coding/task-tool.js +141 -4
  272. package/template/wall-e/coding/tool-execution-controller.js +365 -0
  273. package/template/wall-e/coding/tool-registry.js +43 -5
  274. package/template/wall-e/coding/user-hooks.js +217 -0
  275. package/template/wall-e/coding-orchestrator.js +1330 -221
  276. package/template/wall-e/coding-prompts.js +20 -4
  277. package/template/wall-e/context/context-builder.js +15 -2
  278. package/template/wall-e/decision/confidence.js +1 -1
  279. package/template/wall-e/docs/coding-acceptance-contract.md +41 -0
  280. package/template/wall-e/docs/external-action-controller.md +26 -6
  281. package/template/wall-e/docs/telemetry-lifecycle.md +8 -2
  282. package/template/wall-e/embeddings.js +591 -53
  283. package/template/wall-e/external-action-controller.js +12 -0
  284. package/template/wall-e/http/auth.js +1 -0
  285. package/template/wall-e/http/chat-api.js +46 -11
  286. package/template/wall-e/http/model-admin.js +836 -34
  287. package/template/wall-e/lib/boot-profile.js +88 -0
  288. package/template/wall-e/lib/event-loop-monitor.js +93 -0
  289. package/template/wall-e/lib/service-health.js +194 -0
  290. package/template/wall-e/llm/anthropic.js +130 -5
  291. package/template/wall-e/llm/client.js +266 -63
  292. package/template/wall-e/llm/default-fallback.js +382 -0
  293. package/template/wall-e/llm/health.js +19 -0
  294. package/template/wall-e/llm/message-guard.js +78 -0
  295. package/template/wall-e/llm/model-catalog.js +252 -1
  296. package/template/wall-e/llm/openai.js +26 -4
  297. package/template/wall-e/llm/portkey-sync.js +654 -0
  298. package/template/wall-e/llm/provider-error.js +30 -2
  299. package/template/wall-e/llm/registry.js +5 -1
  300. package/template/wall-e/llm/request-compat.js +67 -0
  301. package/template/wall-e/loops/backfill.js +79 -23
  302. package/template/wall-e/loops/brain-optimize.js +67 -0
  303. package/template/wall-e/loops/ingest.js +25 -10
  304. package/template/wall-e/loops/question-digest.js +160 -0
  305. package/template/wall-e/loops/reflect.js +6 -4
  306. package/template/wall-e/loops/think.js +39 -12
  307. package/template/wall-e/mcp-server.js +318 -36
  308. package/template/wall-e/memory/ctm-context-client.js +52 -14
  309. package/template/wall-e/memory/ctm-operational-context.js +237 -0
  310. package/template/wall-e/memory/ctm-prompt-executions-client.js +128 -0
  311. package/template/wall-e/memory/ctm-session-context.js +111 -63
  312. package/template/wall-e/prompts/coding/deepseek.txt +3 -0
  313. package/template/wall-e/prompts/coding/gemini.txt +6 -0
  314. package/template/wall-e/prompts/coding/gpt.txt +6 -0
  315. package/template/wall-e/prompts/coding/local.txt +7 -0
  316. package/template/wall-e/runtime/decision-hooks.js +115 -0
  317. package/template/wall-e/runtime/devbox-gateway.js +82 -8
  318. package/template/wall-e/runtime/prompt-manifest.js +86 -0
  319. package/template/wall-e/runtime/tool-executor.js +269 -0
  320. package/template/wall-e/runtime/tool-result-envelope.js +138 -0
  321. package/template/wall-e/runtime/transcript-projection.js +60 -0
  322. package/template/wall-e/runtime/walle-runtime.js +224 -0
  323. package/template/wall-e/scripts/db-optimize/migrate.js +162 -0
  324. package/template/wall-e/scripts/db-optimize/recall-eval.js +117 -0
  325. package/template/wall-e/server.js +15 -0
  326. package/template/wall-e/session-files.js +9 -0
  327. package/template/wall-e/skills/_bundled/google-calendar/run.js +1 -1
  328. package/template/wall-e/skills/_bundled/gws-workspace/run.js +1 -1
  329. package/template/wall-e/skills/_bundled/slack-mentions/run.js +76 -6
  330. package/template/wall-e/skills/claude-code-reader.js +7 -3
  331. package/template/wall-e/skills/script-skill-runner.js +10 -0
  332. package/template/wall-e/skills/skill-planner.js +38 -0
  333. package/template/wall-e/tools/builtin-middleware.js +19 -9
  334. package/template/wall-e/tools/local-tools.js +1428 -16
  335. package/template/wall-e/tools/permission-checker.js +73 -5
  336. package/template/wall-e/tools/question-manager.js +117 -7
  337. package/template/wall-e/training/harvester.js +12 -28
  338. package/template/wall-e/training/replay.js +25 -80
  339. package/template/website/index.html +10 -10
  340. package/template/wall-e/eval/ab-test.js +0 -203
  341. package/template/wall-e/eval/agent-runner.js +0 -772
  342. package/template/wall-e/eval/agent-scorer.js +0 -461
  343. package/template/wall-e/eval/aggregator.js +0 -414
  344. package/template/wall-e/eval/allowed-test-commands.js +0 -34
  345. package/template/wall-e/eval/benchmark-generator.js +0 -113
  346. package/template/wall-e/eval/benchmarks/chat-eval.json +0 -1662
  347. package/template/wall-e/eval/benchmarks/chat.json +0 -82
  348. package/template/wall-e/eval/benchmarks/coding-agent-real.json +0 -1
  349. package/template/wall-e/eval/benchmarks/coding-agent.json +0 -1581
  350. package/template/wall-e/eval/benchmarks/coding.json +0 -122
  351. package/template/wall-e/eval/benchmarks/memory-retrieval.json +0 -234
  352. package/template/wall-e/eval/benchmarks/reasoning.json +0 -82
  353. package/template/wall-e/eval/benchmarks/swebench-lite-30.json +0 -212
  354. package/template/wall-e/eval/benchmarks.js +0 -669
  355. package/template/wall-e/eval/cc-replay.js +0 -719
  356. package/template/wall-e/eval/chat-eval.js +0 -525
  357. package/template/wall-e/eval/check-keys.js +0 -15
  358. package/template/wall-e/eval/check-providers.js +0 -42
  359. package/template/wall-e/eval/codex-cli-baseline.js +0 -669
  360. package/template/wall-e/eval/coding-agent-real.js +0 -570
  361. package/template/wall-e/eval/context-compactor.js +0 -251
  362. package/template/wall-e/eval/debug-agent003.js +0 -68
  363. package/template/wall-e/eval/diagnostics.js +0 -216
  364. package/template/wall-e/eval/eval-orchestrator.js +0 -642
  365. package/template/wall-e/eval/evaluate.js +0 -202
  366. package/template/wall-e/eval/evaluator.js +0 -373
  367. package/template/wall-e/eval/exporter.js +0 -212
  368. package/template/wall-e/eval/fixtures/express-basic/package.json +0 -9
  369. package/template/wall-e/eval/fixtures/express-basic/server.js +0 -115
  370. package/template/wall-e/eval/fixtures/express-basic/test.js +0 -83
  371. package/template/wall-e/eval/fixtures/express-buggy/package.json +0 -9
  372. package/template/wall-e/eval/fixtures/express-buggy/server.js +0 -113
  373. package/template/wall-e/eval/fixtures/express-buggy/test.js +0 -83
  374. package/template/wall-e/eval/fixtures/express-buggy-items/package.json +0 -9
  375. package/template/wall-e/eval/fixtures/express-buggy-items/server.js +0 -112
  376. package/template/wall-e/eval/fixtures/express-buggy-items/test.js +0 -83
  377. package/template/wall-e/eval/fixtures/express-buggy-search/package.json +0 -9
  378. package/template/wall-e/eval/fixtures/express-buggy-search/server.js +0 -121
  379. package/template/wall-e/eval/fixtures/express-buggy-search/test.js +0 -83
  380. package/template/wall-e/eval/fixtures/express-rename-data/data.js +0 -34
  381. package/template/wall-e/eval/fixtures/express-rename-data/package.json +0 -9
  382. package/template/wall-e/eval/fixtures/express-rename-data/server.js +0 -97
  383. package/template/wall-e/eval/fixtures/express-rename-data/test.js +0 -88
  384. package/template/wall-e/eval/fixtures/express-xss/package.json +0 -12
  385. package/template/wall-e/eval/fixtures/express-xss/server.js +0 -90
  386. package/template/wall-e/eval/fixtures/express-xss/test.js +0 -67
  387. package/template/wall-e/eval/fixtures/express-xss/views/profile.ejs +0 -9
  388. package/template/wall-e/eval/fixtures/fullstack-app/config/default.js +0 -9
  389. package/template/wall-e/eval/fixtures/fullstack-app/config/test.js +0 -13
  390. package/template/wall-e/eval/fixtures/fullstack-app/package.json +0 -11
  391. package/template/wall-e/eval/fixtures/fullstack-app/public/css/style.css +0 -137
  392. package/template/wall-e/eval/fixtures/fullstack-app/public/index.html +0 -46
  393. package/template/wall-e/eval/fixtures/fullstack-app/public/js/app.js +0 -121
  394. package/template/wall-e/eval/fixtures/fullstack-app/public/js/auth.js +0 -71
  395. package/template/wall-e/eval/fixtures/fullstack-app/public/js/items.js +0 -80
  396. package/template/wall-e/eval/fixtures/fullstack-app/public/js/users.js +0 -46
  397. package/template/wall-e/eval/fixtures/fullstack-app/public/login.html +0 -45
  398. package/template/wall-e/eval/fixtures/fullstack-app/public/register.html +0 -38
  399. package/template/wall-e/eval/fixtures/fullstack-app/scripts/migrate.js +0 -23
  400. package/template/wall-e/eval/fixtures/fullstack-app/scripts/seed.js +0 -46
  401. package/template/wall-e/eval/fixtures/fullstack-app/server/db.js +0 -99
  402. package/template/wall-e/eval/fixtures/fullstack-app/server/index.js +0 -94
  403. package/template/wall-e/eval/fixtures/fullstack-app/server/middleware/auth.js +0 -19
  404. package/template/wall-e/eval/fixtures/fullstack-app/server/middleware/logger.js +0 -19
  405. package/template/wall-e/eval/fixtures/fullstack-app/server/router.js +0 -50
  406. package/template/wall-e/eval/fixtures/fullstack-app/server/routes/auth.js +0 -69
  407. package/template/wall-e/eval/fixtures/fullstack-app/server/routes/health.js +0 -23
  408. package/template/wall-e/eval/fixtures/fullstack-app/server/routes/items.js +0 -88
  409. package/template/wall-e/eval/fixtures/fullstack-app/server/routes/users.js +0 -75
  410. package/template/wall-e/eval/fixtures/fullstack-app/server/test.js +0 -198
  411. package/template/wall-e/eval/fixtures/fullstack-app/server/utils/response.js +0 -34
  412. package/template/wall-e/eval/fixtures/fullstack-app/server/utils/validate.js +0 -26
  413. package/template/wall-e/eval/fixtures/fullstack-app/server.js +0 -8
  414. package/template/wall-e/eval/fixtures/fullstack-app/test.js +0 -12
  415. package/template/wall-e/eval/fixtures/monorepo-basic/package.json +0 -8
  416. package/template/wall-e/eval/fixtures/monorepo-basic/packages/api/data.js +0 -58
  417. package/template/wall-e/eval/fixtures/monorepo-basic/packages/api/middleware.js +0 -46
  418. package/template/wall-e/eval/fixtures/monorepo-basic/packages/api/package.json +0 -8
  419. package/template/wall-e/eval/fixtures/monorepo-basic/packages/api/routes.js +0 -64
  420. package/template/wall-e/eval/fixtures/monorepo-basic/packages/api/server.js +0 -56
  421. package/template/wall-e/eval/fixtures/monorepo-basic/packages/api/test.js +0 -116
  422. package/template/wall-e/eval/fixtures/monorepo-basic/packages/cli/commands.js +0 -61
  423. package/template/wall-e/eval/fixtures/monorepo-basic/packages/cli/index.js +0 -62
  424. package/template/wall-e/eval/fixtures/monorepo-basic/packages/cli/output.js +0 -43
  425. package/template/wall-e/eval/fixtures/monorepo-basic/packages/cli/package.json +0 -11
  426. package/template/wall-e/eval/fixtures/monorepo-basic/packages/cli/test.js +0 -44
  427. package/template/wall-e/eval/fixtures/monorepo-basic/packages/shared/formatters.js +0 -43
  428. package/template/wall-e/eval/fixtures/monorepo-basic/packages/shared/index.js +0 -12
  429. package/template/wall-e/eval/fixtures/monorepo-basic/packages/shared/package.json +0 -5
  430. package/template/wall-e/eval/fixtures/monorepo-basic/packages/shared/test.js +0 -55
  431. package/template/wall-e/eval/fixtures/monorepo-basic/packages/shared/validators.js +0 -29
  432. package/template/wall-e/eval/fixtures/monorepo-basic/test.js +0 -46
  433. package/template/wall-e/eval/fixtures/node-cli/index.js +0 -78
  434. package/template/wall-e/eval/fixtures/node-cli/package.json +0 -10
  435. package/template/wall-e/eval/fixtures/node-cli/test.js +0 -57
  436. package/template/wall-e/eval/fixtures/node-typed/package.json +0 -8
  437. package/template/wall-e/eval/fixtures/node-typed/src/handlers.js +0 -31
  438. package/template/wall-e/eval/fixtures/node-typed/src/utils.js +0 -33
  439. package/template/wall-e/eval/fixtures/node-typed/test.js +0 -36
  440. package/template/wall-e/eval/fixtures/python-flask/app.py +0 -14
  441. package/template/wall-e/eval/fixtures/python-flask/requirements.txt +0 -2
  442. package/template/wall-e/eval/fixtures/python-flask/test_app.py +0 -25
  443. package/template/wall-e/eval/fixtures/wall-e-subset/brain.js +0 -105
  444. package/template/wall-e/eval/fixtures/wall-e-subset/eval/aggregator.js +0 -101
  445. package/template/wall-e/eval/fixtures/wall-e-subset/eval/benchmarks/chat.json +0 -20
  446. package/template/wall-e/eval/fixtures/wall-e-subset/eval/benchmarks/coding.json +0 -32
  447. package/template/wall-e/eval/fixtures/wall-e-subset/eval/benchmarks.js +0 -64
  448. package/template/wall-e/eval/fixtures/wall-e-subset/eval/fixtures/simple-project/package.json +0 -6
  449. package/template/wall-e/eval/fixtures/wall-e-subset/eval/fixtures/simple-project/server.js +0 -31
  450. package/template/wall-e/eval/fixtures/wall-e-subset/eval/fixtures/simple-project/test.js +0 -18
  451. package/template/wall-e/eval/fixtures/wall-e-subset/eval/fixtures/simple-project/utils.js +0 -34
  452. package/template/wall-e/eval/fixtures/wall-e-subset/eval/runner.js +0 -104
  453. package/template/wall-e/eval/fixtures/wall-e-subset/eval/scorer.js +0 -73
  454. package/template/wall-e/eval/fixtures/wall-e-subset/eval/test.js +0 -134
  455. package/template/wall-e/eval/fixtures/wall-e-subset/llm/client.js +0 -99
  456. package/template/wall-e/eval/fixtures/wall-e-subset/llm/providers.js +0 -63
  457. package/template/wall-e/eval/fixtures/wall-e-subset/llm/test.js +0 -70
  458. package/template/wall-e/eval/fixtures/wall-e-subset/package.json +0 -10
  459. package/template/wall-e/eval/fixtures/wall-e-subset/test.js +0 -86
  460. package/template/wall-e/eval/harvester.js +0 -685
  461. package/template/wall-e/eval/head-to-head.js +0 -388
  462. package/template/wall-e/eval/humaneval-adapter.js +0 -321
  463. package/template/wall-e/eval/list-models.js +0 -31
  464. package/template/wall-e/eval/livecodebench-adapter.js +0 -291
  465. package/template/wall-e/eval/mail-integration.js +0 -443
  466. package/template/wall-e/eval/manifest.js +0 -186
  467. package/template/wall-e/eval/meta-harness/adapters/coding-agent.js +0 -57
  468. package/template/wall-e/eval/meta-harness/bootstrap-snapshot.js +0 -149
  469. package/template/wall-e/eval/meta-harness/candidate-store.js +0 -117
  470. package/template/wall-e/eval/meta-harness/cli.js +0 -86
  471. package/template/wall-e/eval/meta-harness/domain-spec.js +0 -154
  472. package/template/wall-e/eval/meta-harness/domains/coding-agent.domain.json +0 -84
  473. package/template/wall-e/eval/meta-harness/examples/env-bootstrap-candidate.js +0 -29
  474. package/template/wall-e/eval/meta-harness/experience-store.js +0 -174
  475. package/template/wall-e/eval/meta-harness/frontier.js +0 -96
  476. package/template/wall-e/eval/meta-harness/harness-interface.js +0 -90
  477. package/template/wall-e/eval/meta-harness/leakage-guard.js +0 -80
  478. package/template/wall-e/eval/meta-harness/optimizer.js +0 -207
  479. package/template/wall-e/eval/meta-harness/proposer-runner.js +0 -110
  480. package/template/wall-e/eval/meta-harness/reporting.js +0 -58
  481. package/template/wall-e/eval/meta-harness/telemetry.js +0 -27
  482. package/template/wall-e/eval/meta-harness/validation.js +0 -81
  483. package/template/wall-e/eval/promoter.js +0 -228
  484. package/template/wall-e/eval/provider-normalizer.js +0 -33
  485. package/template/wall-e/eval/replay.js +0 -395
  486. package/template/wall-e/eval/run-agent-benchmarks.js +0 -386
  487. package/template/wall-e/eval/run-codex-cli-baseline.js +0 -177
  488. package/template/wall-e/eval/run-coding-agent-real.js +0 -187
  489. package/template/wall-e/eval/run-eval.js +0 -435
  490. package/template/wall-e/eval/run-model-comparison.js +0 -142
  491. package/template/wall-e/eval/session-evaluator.js +0 -187
  492. package/template/wall-e/eval/session-miner.js +0 -207
  493. package/template/wall-e/eval/session-retrieval-benchmark.js +0 -150
  494. package/template/wall-e/eval/session-transcripts.js +0 -509
  495. package/template/wall-e/eval/shadow.js +0 -161
  496. package/template/wall-e/eval/swebench-adapter.js +0 -345
  497. package/template/wall-e/eval/swebench-docker.js +0 -192
  498. package/template/wall-e/eval/train.py +0 -320
  499. package/template/wall-e/eval/trainer.js +0 -232
  500. package/template/wall-e/eval/weekly-eval-loop.js +0 -241
@@ -4,6 +4,8 @@ const { promisify } = require('util');
4
4
  const fs = require('fs');
5
5
  const path = require('path');
6
6
  const os = require('os');
7
+ const crypto = require('crypto');
8
+ const http = require('http');
7
9
  const https = require('https');
8
10
  const execFileAsync = promisify(execFile);
9
11
  const execAsync = promisify(exec);
@@ -16,6 +18,15 @@ const MAIL_AUTOMATION_COOLDOWN_PATH = process.env.WALLE_MAIL_COOLDOWN_PATH ||
16
18
  path.join(os.tmpdir(), 'wall-e-mail-appleevent-cooldown.json');
17
19
  const MAIL_AUTOMATION_COOLDOWN_MS = 5 * 60 * 1000;
18
20
  const MAIL_AUTOMATION_SLOW_MS = 30 * 1000;
21
+ const STATIC_SERVER_PROCESSES = new Map();
22
+
23
+ function expandUserPath(value) {
24
+ if (typeof value !== 'string' || !value.startsWith('~')) return value;
25
+ const home = process.env.HOME || HOME;
26
+ if (value === '~') return home;
27
+ if (value.startsWith('~/')) return path.join(home, value.slice(2));
28
+ return value;
29
+ }
19
30
 
20
31
  function _pidIsAlive(pid) {
21
32
  if (!pid || pid <= 0) return false;
@@ -1513,9 +1524,12 @@ function hasShellControlArg(args) {
1513
1524
  async function runShell(commandOrOpts, legacyArgs, legacyOpts) {
1514
1525
  let command, args, timeout_ms, cwd;
1515
1526
 
1527
+ let background = false;
1528
+ let sessionId = '';
1529
+ let persist = false;
1516
1530
  if (typeof commandOrOpts === 'object' && commandOrOpts !== null) {
1517
- // New format: runShell({ command, args, timeout_ms, cwd })
1518
- ({ command, args, timeout_ms = 30000, cwd } = commandOrOpts);
1531
+ // New format: runShell({ command, args, timeout_ms, cwd, background })
1532
+ ({ command, args, timeout_ms = 30000, cwd, background = false, sessionId = '', persist = false } = commandOrOpts);
1519
1533
  } else {
1520
1534
  // Legacy format: runShell(command, args, { timeout_ms })
1521
1535
  command = commandOrOpts;
@@ -1542,6 +1556,12 @@ async function runShell(commandOrOpts, legacyArgs, legacyOpts) {
1542
1556
  commandStr = command;
1543
1557
  }
1544
1558
 
1559
+ // Background mode: detach into the session-scoped registry (it runs its
1560
+ // own shell-analyzer gate before spawning).
1561
+ if (background) {
1562
+ return runShellBackground({ command: commandStr, cwd, sessionId, persist });
1563
+ }
1564
+
1545
1565
  // Tree-sitter analysis gates execution
1546
1566
  const analysis = await analyzeShellCommand(commandStr, cwd || process.cwd());
1547
1567
  if (!analysis.allowed) {
@@ -1895,11 +1915,18 @@ async function writeFile(filePath, content, { sessionId, projectRoot } = {}) {
1895
1915
  return { written: true, path: resolved, bytes: Buffer.byteLength(content) };
1896
1916
  }
1897
1917
 
1898
- async function searchFiles(query, { directory, extensions, max_results = 20 } = {}) {
1899
- const searchDir = directory ? resolveSafePath(directory) : HOME;
1918
+ async function searchFiles(query, { directory, extensions, max_results = 20, projectRoot } = {}) {
1919
+ let searchDir;
1920
+ try {
1921
+ searchDir = projectRoot
1922
+ ? resolveToolPath(directory || '.', projectRoot)
1923
+ : (directory ? resolveSafePath(directory) : HOME);
1924
+ } catch (err) {
1925
+ return { error: err.message, count: 0, files: [] };
1926
+ }
1900
1927
  // Use macOS Spotlight (mdfind) for fast indexed search
1901
1928
  const args = [query];
1902
- if (directory) args.push('-onlyin', searchDir);
1929
+ if (directory || projectRoot) args.push('-onlyin', searchDir);
1903
1930
  try {
1904
1931
  const { stdout } = await execFileAsync('mdfind', args, {
1905
1932
  timeout: 10000,
@@ -1972,12 +1999,260 @@ function findChromeExecutable() {
1972
1999
  return CHROME_CANDIDATES.find((p) => fs.existsSync(p)) || null;
1973
2000
  }
1974
2001
 
1975
- async function browserScreenshot({ url, output_path, viewport = 'desktop' } = {}) {
2002
+ function hostAutomationEnv() {
2003
+ const env = { ...process.env };
2004
+ const realHome = process.env.WALL_E_REAL_HOME;
2005
+ if (realHome && fs.existsSync(realHome)) env.HOME = realHome;
2006
+ delete env.WALL_E_DATA_DIR;
2007
+ delete env.WALLE_NOTIFICATIONS;
2008
+ return env;
2009
+ }
2010
+
2011
+ // Minimal CDP-over-pipe client. Chrome 86+ supports --remote-debugging-pipe
2012
+ // which exposes the DevTools Protocol on FD 3 (browser→client) and FD 4
2013
+ // (client→browser) with null-byte-delimited JSON messages. Lets us drive
2014
+ // Page.captureScreenshot({ captureBeyondViewport: true }) for true full-
2015
+ // page captures without depending on puppeteer/playwright/ws.
2016
+ function _openCdpPipe(chromePath, extraArgs = []) {
2017
+ const { spawn } = require('node:child_process');
2018
+ const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'walle-chrome-cdp-'));
2019
+ const args = [
2020
+ '--headless=new',
2021
+ '--disable-gpu',
2022
+ '--no-sandbox',
2023
+ '--hide-scrollbars',
2024
+ `--user-data-dir=${userDataDir}`,
2025
+ '--remote-debugging-pipe',
2026
+ ...extraArgs,
2027
+ 'about:blank',
2028
+ ];
2029
+ // stdio: stdin, stdout, stderr, FD3 (browser→client), FD4 (client→browser).
2030
+ // Node treats every entry as a pipe when set to 'pipe'.
2031
+ const proc = spawn(chromePath, args, { stdio: ['ignore', 'pipe', 'pipe', 'pipe', 'pipe'], env: hostAutomationEnv() });
2032
+ // CDP-over-pipe wire format: Chrome's FD 3 is its READ side (we write commands
2033
+ // there), FD 4 is its WRITE side (we read events/responses there). On the
2034
+ // Node side, proc.stdio[N] is the OTHER end — so stdio[3] is our writable
2035
+ // and stdio[4] is our readable. Flipping these silently hangs every call.
2036
+ const send = proc.stdio[3];
2037
+ const recv = proc.stdio[4];
2038
+ let msgId = 0;
2039
+ const pending = new Map();
2040
+ const events = new Map(); // method -> handler
2041
+ let buffer = Buffer.alloc(0);
2042
+
2043
+ const rejectPending = (err) => {
2044
+ for (const { reject } of pending.values()) reject(err);
2045
+ pending.clear();
2046
+ };
2047
+
2048
+ recv.on('data', (chunk) => {
2049
+ buffer = Buffer.concat([buffer, chunk]);
2050
+ let nul;
2051
+ while ((nul = buffer.indexOf(0)) !== -1) {
2052
+ const frame = buffer.subarray(0, nul).toString('utf8');
2053
+ buffer = buffer.subarray(nul + 1);
2054
+ if (!frame) continue;
2055
+ let msg;
2056
+ try { msg = JSON.parse(frame); } catch { continue; }
2057
+ if (msg.id != null && pending.has(msg.id)) {
2058
+ const { resolve, reject } = pending.get(msg.id);
2059
+ pending.delete(msg.id);
2060
+ if (msg.error) reject(new Error(`CDP ${msg.error.code || ''}: ${msg.error.message || JSON.stringify(msg.error)}`));
2061
+ else resolve(msg.result);
2062
+ } else if (msg.method && events.has(msg.method)) {
2063
+ // Event handlers receive (params, sessionId) so callers can filter
2064
+ // by attached page session vs. browser-level events.
2065
+ events.get(msg.method)(msg.params || {}, msg.sessionId);
2066
+ }
2067
+ }
2068
+ });
2069
+
2070
+ // CDP-over-pipe lands on the Browser target. To send Page.*, Runtime.*,
2071
+ // Emulation.* etc, attach to a page target via Target.attachToTarget
2072
+ // with flatten:true, then pass sessionId on every subsequent message.
2073
+ const call = (method, params = {}, sessionId, timeoutMs = 15000) => new Promise((resolve, reject) => {
2074
+ const id = ++msgId;
2075
+ const timer = setTimeout(() => {
2076
+ pending.delete(id);
2077
+ reject(new Error(`CDP ${method} timed out after ${timeoutMs}ms`));
2078
+ }, timeoutMs);
2079
+ pending.set(id, {
2080
+ resolve: (value) => {
2081
+ clearTimeout(timer);
2082
+ resolve(value);
2083
+ },
2084
+ reject: (err) => {
2085
+ clearTimeout(timer);
2086
+ reject(err);
2087
+ },
2088
+ });
2089
+ const frame = sessionId
2090
+ ? { id, method, params, sessionId }
2091
+ : { id, method, params };
2092
+ try {
2093
+ send.write(JSON.stringify(frame) + '\0');
2094
+ } catch (err) {
2095
+ clearTimeout(timer);
2096
+ pending.delete(id);
2097
+ reject(err);
2098
+ }
2099
+ });
2100
+ const on = (method, handler) => { events.set(method, handler); };
2101
+ const close = () => {
2102
+ try { proc.kill('SIGTERM'); } catch {}
2103
+ setTimeout(() => { try { proc.kill('SIGKILL'); } catch {} }, 1500).unref();
2104
+ try { fs.rmSync(userDataDir, { recursive: true, force: true }); } catch {}
2105
+ };
2106
+
2107
+ // Surface spawn failures (e.g. Chrome binary missing) as a usable error.
2108
+ const readyOrError = new Promise((resolve, reject) => {
2109
+ let resolved = false;
2110
+ proc.on('error', (err) => { if (!resolved) { resolved = true; reject(err); } });
2111
+ // The pipe is open immediately; resolve on next tick so callers can wire up handlers first.
2112
+ setImmediate(() => { if (!resolved) { resolved = true; resolve(); } });
2113
+ });
2114
+ proc.once('error', (err) => rejectPending(err));
2115
+ proc.once('exit', (code, signal) => {
2116
+ rejectPending(new Error(`Chrome exited before completing CDP call: ${code ?? signal}`));
2117
+ });
2118
+
2119
+ return { proc, call, on, close, ready: readyOrError };
2120
+ }
2121
+
2122
+ async function _fullPageScreenshotViaCdp({ chrome, url, outPath, width, height, settleMs = 2000, timeoutMs = 45000 }) {
2123
+ const cdp = _openCdpPipe(chrome);
2124
+ let timeoutHandle;
2125
+ try {
2126
+ await Promise.race([
2127
+ cdp.ready,
2128
+ new Promise((_, reject) => { timeoutHandle = setTimeout(() => reject(new Error('Chrome CDP startup timed out')), 10000); }),
2129
+ ]);
2130
+ clearTimeout(timeoutHandle);
2131
+
2132
+ // Browser-target call: find the about:blank page and attach to it.
2133
+ // Without an attached page session, Page.*/Runtime.*/Emulation.* are
2134
+ // not routable and Chrome returns "method not found".
2135
+ const { targetInfos } = await cdp.call('Target.getTargets');
2136
+ const pageTarget = (targetInfos || []).find((t) => t.type === 'page');
2137
+ if (!pageTarget) throw new Error('No page target found in headless Chrome');
2138
+ const { sessionId } = await cdp.call('Target.attachToTarget', {
2139
+ targetId: pageTarget.targetId,
2140
+ flatten: true,
2141
+ });
2142
+ if (!sessionId) throw new Error('Target.attachToTarget returned no sessionId');
2143
+
2144
+ // Filter Page.loadEventFired to this session only — other (background)
2145
+ // targets can fire the same event under flattened sessions.
2146
+ const loaded = new Promise((resolve) => {
2147
+ cdp.on('Page.loadEventFired', (_params, sid) => { if (sid === sessionId) resolve(); });
2148
+ });
2149
+ await cdp.call('Page.enable', {}, sessionId);
2150
+ // Emulation lets us simulate the right device pixel + viewport even
2151
+ // when capturing beyond it — important so responsive CSS picks the
2152
+ // right breakpoint.
2153
+ await cdp.call('Emulation.setDeviceMetricsOverride', {
2154
+ width,
2155
+ height,
2156
+ deviceScaleFactor: 1,
2157
+ mobile: false,
2158
+ }, sessionId);
2159
+ await cdp.call('Page.navigate', { url }, sessionId);
2160
+ // Cap how long we wait for `load`; static file:// pages usually fire
2161
+ // immediately, but a remote URL can stall. After that, give JS a beat
2162
+ // to lay out fonts/images before capturing.
2163
+ await Promise.race([
2164
+ loaded,
2165
+ new Promise((resolve) => setTimeout(resolve, Math.max(2000, Math.min(timeoutMs - 5000, 15000)))),
2166
+ ]);
2167
+ await new Promise((resolve) => setTimeout(resolve, settleMs));
2168
+
2169
+ const result = await cdp.call('Page.captureScreenshot', {
2170
+ format: 'png',
2171
+ captureBeyondViewport: true,
2172
+ fromSurface: true,
2173
+ }, sessionId);
2174
+ if (!result?.data) throw new Error('Page.captureScreenshot returned no data');
2175
+ fs.writeFileSync(outPath, Buffer.from(result.data, 'base64'));
2176
+ return outPath;
2177
+ } finally {
2178
+ clearTimeout(timeoutHandle);
2179
+ cdp.close();
2180
+ }
2181
+ }
2182
+
2183
+ async function _navigateCdpPage({
2184
+ cdp,
2185
+ sessionId,
2186
+ url,
2187
+ width,
2188
+ height,
2189
+ timeoutMs = 45000,
2190
+ settleMs = 750,
2191
+ eventSink,
2192
+ }) {
2193
+ const loaded = new Promise((resolve) => {
2194
+ cdp.on('Page.loadEventFired', (_params, sid) => { if (sid === sessionId) resolve({ ok: true }); });
2195
+ });
2196
+ if (eventSink) {
2197
+ cdp.on('Runtime.exceptionThrown', (params, sid) => {
2198
+ if (sid !== sessionId) return;
2199
+ const details = params.exceptionDetails || {};
2200
+ eventSink.runtimeExceptions.push({
2201
+ text: details.text || '',
2202
+ url: details.url || '',
2203
+ lineNumber: details.lineNumber ?? null,
2204
+ columnNumber: details.columnNumber ?? null,
2205
+ exception: details.exception?.description || details.exception?.value || '',
2206
+ });
2207
+ });
2208
+ cdp.on('Runtime.consoleAPICalled', (params, sid) => {
2209
+ if (sid !== sessionId) return;
2210
+ if (!['error', 'assert'].includes(params.type)) return;
2211
+ eventSink.consoleErrors.push({
2212
+ type: params.type,
2213
+ args: (params.args || []).map((arg) => arg.value ?? arg.description ?? '').join(' ').slice(0, 1000),
2214
+ });
2215
+ });
2216
+ cdp.on('Network.loadingFailed', (params, sid) => {
2217
+ if (sid !== sessionId) return;
2218
+ eventSink.failedRequests.push({
2219
+ requestId: params.requestId,
2220
+ type: params.type,
2221
+ errorText: params.errorText || '',
2222
+ canceled: Boolean(params.canceled),
2223
+ });
2224
+ });
2225
+ }
2226
+
2227
+ await cdp.call('Page.enable', {}, sessionId);
2228
+ await cdp.call('Runtime.enable', {}, sessionId);
2229
+ await cdp.call('Network.enable', {}, sessionId);
2230
+ await cdp.call('Emulation.setDeviceMetricsOverride', {
2231
+ width,
2232
+ height,
2233
+ deviceScaleFactor: 1,
2234
+ mobile: width <= 480,
2235
+ }, sessionId);
2236
+ await cdp.call('Page.navigate', { url }, sessionId);
2237
+ await Promise.race([
2238
+ loaded,
2239
+ new Promise((resolve) => setTimeout(resolve, Math.max(2000, Math.min(timeoutMs - 5000, 15000)))),
2240
+ ]);
2241
+ await new Promise((resolve) => setTimeout(resolve, settleMs));
2242
+ }
2243
+
2244
+ async function browserSmokeTest({
2245
+ url,
2246
+ viewport = 'desktop',
2247
+ click_selectors,
2248
+ max_clicks = 20,
2249
+ settle_ms = 750,
2250
+ timeout_ms = 45000,
2251
+ } = {}) {
1976
2252
  if (!url || typeof url !== 'string') {
1977
2253
  return { ok: false, error: 'url is required (string)' };
1978
2254
  }
1979
2255
  const vp = BROWSER_VIEWPORTS[viewport] || BROWSER_VIEWPORTS.desktop;
1980
- const outPath = output_path || path.join(os.tmpdir(), `walle-shot-${viewport}-${Date.now()}.png`);
1981
2256
  const chrome = findChromeExecutable();
1982
2257
  if (!chrome) {
1983
2258
  return {
@@ -1988,6 +2263,179 @@ async function browserScreenshot({ url, output_path, viewport = 'desktop' } = {}
1988
2263
  };
1989
2264
  }
1990
2265
 
2266
+ const cdp = _openCdpPipe(chrome);
2267
+ let timeoutHandle;
2268
+ const events = { runtimeExceptions: [], consoleErrors: [], failedRequests: [] };
2269
+ try {
2270
+ await Promise.race([
2271
+ cdp.ready,
2272
+ new Promise((_, reject) => { timeoutHandle = setTimeout(() => reject(new Error('Chrome CDP startup timed out')), 10000); }),
2273
+ ]);
2274
+ clearTimeout(timeoutHandle);
2275
+
2276
+ const { targetInfos } = await cdp.call('Target.getTargets');
2277
+ const pageTarget = (targetInfos || []).find((t) => t.type === 'page');
2278
+ if (!pageTarget) throw new Error('No page target found in headless Chrome');
2279
+ const { sessionId } = await cdp.call('Target.attachToTarget', {
2280
+ targetId: pageTarget.targetId,
2281
+ flatten: true,
2282
+ });
2283
+ if (!sessionId) throw new Error('Target.attachToTarget returned no sessionId');
2284
+
2285
+ await _navigateCdpPage({
2286
+ cdp,
2287
+ sessionId,
2288
+ url,
2289
+ width: vp.width,
2290
+ height: vp.height,
2291
+ timeoutMs: timeout_ms,
2292
+ settleMs: settle_ms,
2293
+ eventSink: events,
2294
+ });
2295
+
2296
+ const selectors = Array.isArray(click_selectors) && click_selectors.length
2297
+ ? click_selectors
2298
+ : ['[onclick]', 'button', '[role="button"]', 'a[href^="#"]'];
2299
+ const clickResult = await cdp.call('Runtime.evaluate', {
2300
+ awaitPromise: true,
2301
+ returnByValue: true,
2302
+ expression: `(() => {
2303
+ const selectors = ${JSON.stringify(selectors)};
2304
+ const maxClicks = ${Math.max(0, Math.min(100, Number(max_clicks) || 20))};
2305
+ const seen = new Set();
2306
+ const failures = [];
2307
+ const clicked = [];
2308
+ const describe = (el) => (el.getAttribute('aria-label') || el.textContent || el.id || el.className || el.tagName || '').toString().replace(/\\s+/g, ' ').trim().slice(0, 120);
2309
+ const elements = [];
2310
+ for (const selector of selectors) {
2311
+ for (const el of Array.from(document.querySelectorAll(selector))) {
2312
+ if (seen.has(el)) continue;
2313
+ seen.add(el);
2314
+ if (elements.length >= maxClicks) break;
2315
+ elements.push({ el, selector });
2316
+ }
2317
+ if (elements.length >= maxClicks) break;
2318
+ }
2319
+ for (const { el, selector } of elements) {
2320
+ if (el.disabled || el.getAttribute('aria-disabled') === 'true') continue;
2321
+ try {
2322
+ el.scrollIntoView({ block: 'center', inline: 'center' });
2323
+ el.click();
2324
+ clicked.push({ selector, label: describe(el) });
2325
+ } catch (err) {
2326
+ failures.push({ selector, label: describe(el), error: String(err && (err.message || err)) });
2327
+ }
2328
+ }
2329
+ return {
2330
+ readyState: document.readyState,
2331
+ title: document.title || '',
2332
+ bodyTextLength: document.body ? document.body.innerText.length : 0,
2333
+ clicked,
2334
+ failures,
2335
+ };
2336
+ })()`,
2337
+ }, sessionId);
2338
+ await new Promise((resolve) => setTimeout(resolve, Math.min(1000, Math.max(100, settle_ms))));
2339
+
2340
+ const value = clickResult?.result?.value || {};
2341
+ const failures = [];
2342
+ for (const item of events.runtimeExceptions) failures.push({ type: 'runtime_exception', ...item });
2343
+ for (const item of events.consoleErrors) failures.push({ type: 'console_error', ...item });
2344
+ for (const item of value.failures || []) failures.push({ type: 'click_failure', ...item });
2345
+ const significantFailedRequests = events.failedRequests.filter((item) => !item.canceled);
2346
+ for (const item of significantFailedRequests) failures.push({ type: 'network_failure', ...item });
2347
+
2348
+ return {
2349
+ ok: failures.length === 0,
2350
+ url,
2351
+ viewport,
2352
+ width: vp.width,
2353
+ height: vp.height,
2354
+ chrome,
2355
+ readyState: value.readyState || '',
2356
+ title: value.title || '',
2357
+ bodyTextLength: value.bodyTextLength || 0,
2358
+ clicked: value.clicked || [],
2359
+ runtimeExceptions: events.runtimeExceptions,
2360
+ consoleErrors: events.consoleErrors,
2361
+ failedRequests: significantFailedRequests,
2362
+ failures,
2363
+ };
2364
+ } catch (err) {
2365
+ return {
2366
+ ok: false,
2367
+ error: `Chrome CDP browser smoke test failed: ${err.message}`,
2368
+ url,
2369
+ viewport,
2370
+ chrome,
2371
+ };
2372
+ } finally {
2373
+ clearTimeout(timeoutHandle);
2374
+ cdp.close();
2375
+ }
2376
+ }
2377
+
2378
+ async function browserScreenshot({ url, output_path, viewport = 'desktop', full_page = false } = {}) {
2379
+ if (!url || typeof url !== 'string') {
2380
+ return { ok: false, error: 'url is required (string)' };
2381
+ }
2382
+ const vp = BROWSER_VIEWPORTS[viewport] || BROWSER_VIEWPORTS.desktop;
2383
+ const outPath = expandUserPath(output_path) || path.join(os.tmpdir(), `walle-shot-${viewport}${full_page ? '-fullpage' : ''}-${Date.now()}.png`);
2384
+ const chrome = findChromeExecutable();
2385
+ if (!chrome) {
2386
+ return {
2387
+ ok: false,
2388
+ error: 'No Chromium-based browser found at standard /Applications paths. Install Chrome, Chromium, Edge, or Brave; or set WALLE_CHROME_PATH.',
2389
+ url,
2390
+ viewport,
2391
+ };
2392
+ }
2393
+
2394
+ // Full-page path: drive Chrome via CDP and use captureBeyondViewport so
2395
+ // content below the fold is included. Slower (~3-5s) but captures the
2396
+ // entire scrollable page in a single PNG — the only reliable way to
2397
+ // self-critique galleries, footers, etc. that sit below the viewport.
2398
+ if (full_page) {
2399
+ try {
2400
+ await _fullPageScreenshotViaCdp({
2401
+ chrome,
2402
+ url,
2403
+ outPath,
2404
+ width: vp.width,
2405
+ height: vp.height,
2406
+ });
2407
+ } catch (err) {
2408
+ return {
2409
+ ok: false,
2410
+ error: `Chrome CDP full-page capture failed: ${err.message}`,
2411
+ url,
2412
+ viewport,
2413
+ chrome,
2414
+ };
2415
+ }
2416
+ const exists = fs.existsSync(outPath);
2417
+ const size = exists ? fs.statSync(outPath).size : 0;
2418
+ return {
2419
+ ok: exists && size > 0,
2420
+ path: outPath,
2421
+ exists,
2422
+ size,
2423
+ viewport,
2424
+ width: vp.width,
2425
+ height: vp.height, // viewport height; actual PNG can be taller
2426
+ full_page: true,
2427
+ url,
2428
+ chrome,
2429
+ artifact: exists && size > 0 ? {
2430
+ kind: 'screenshot',
2431
+ path: outPath,
2432
+ mimeType: 'image/png',
2433
+ bytes: size,
2434
+ metadata: { viewport, width: vp.width, height: vp.height, full_page: true, url },
2435
+ } : null,
2436
+ };
2437
+ }
2438
+
1991
2439
  const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'walle-chrome-'));
1992
2440
  const args = [
1993
2441
  '--headless=new',
@@ -2002,11 +2450,12 @@ async function browserScreenshot({ url, output_path, viewport = 'desktop' } = {}
2002
2450
  ];
2003
2451
 
2004
2452
  try {
2005
- await execFileAsync(chrome, args, { timeout: 30000, maxBuffer: 4 * 1024 * 1024 });
2453
+ await execFileAsync(chrome, args, { timeout: 30000, maxBuffer: 4 * 1024 * 1024, env: hostAutomationEnv() });
2006
2454
  } catch (err) {
2007
2455
  return {
2008
2456
  ok: false,
2009
2457
  error: `Chrome headless exited with error: ${err.message}`,
2458
+ stderr: String(err.stderr || '').slice(0, 2000),
2010
2459
  url,
2011
2460
  viewport,
2012
2461
  chrome,
@@ -2027,9 +2476,739 @@ async function browserScreenshot({ url, output_path, viewport = 'desktop' } = {}
2027
2476
  height: vp.height,
2028
2477
  url,
2029
2478
  chrome,
2479
+ artifact: exists && size > 0 ? {
2480
+ kind: 'screenshot',
2481
+ path: outPath,
2482
+ mimeType: 'image/png',
2483
+ bytes: size,
2484
+ metadata: { viewport, width: vp.width, height: vp.height, url },
2485
+ } : null,
2486
+ };
2487
+ }
2488
+
2489
+ const PDF_MAX_BYTES = 32 * 1024 * 1024;
2490
+ const PDF_MAX_PAGE_RANGE = 20;
2491
+
2492
+ function fileSha256(filePath) {
2493
+ return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex');
2494
+ }
2495
+
2496
+ function normalizePdfPath(filePath, projectRoot) {
2497
+ if (!filePath || typeof filePath !== 'string') throw new Error('file_path is required');
2498
+ return resolveToolPath(expandUserPath(filePath), projectRoot);
2499
+ }
2500
+
2501
+ function validatePdfFile(filePath, { maxBytes = PDF_MAX_BYTES } = {}) {
2502
+ if (!fs.existsSync(filePath)) return { ok: false, error: `PDF not found: ${filePath}` };
2503
+ const stat = fs.statSync(filePath);
2504
+ if (!stat.isFile()) return { ok: false, error: `PDF path is not a file: ${filePath}` };
2505
+ if (stat.size <= 0) return { ok: false, error: 'PDF file is empty', path: filePath, bytes: stat.size };
2506
+ if (stat.size > maxBytes) {
2507
+ return { ok: false, error: `PDF file exceeds size limit (${stat.size} > ${maxBytes} bytes)`, path: filePath, bytes: stat.size };
2508
+ }
2509
+ const fd = fs.openSync(filePath, 'r');
2510
+ try {
2511
+ const magic = Buffer.alloc(5);
2512
+ fs.readSync(fd, magic, 0, 5, 0);
2513
+ if (magic.toString('utf8') !== '%PDF-') {
2514
+ return { ok: false, error: 'File does not start with %PDF- magic bytes', path: filePath, bytes: stat.size };
2515
+ }
2516
+ } finally {
2517
+ fs.closeSync(fd);
2518
+ }
2519
+ return { ok: true, path: filePath, bytes: stat.size, sha256: fileSha256(filePath) };
2520
+ }
2521
+
2522
+ async function pdfInfo({ file_path, max_bytes, projectRoot } = {}) {
2523
+ let resolved;
2524
+ try {
2525
+ resolved = normalizePdfPath(file_path, projectRoot);
2526
+ } catch (err) {
2527
+ return { ok: false, error: err.message };
2528
+ }
2529
+ const validation = validatePdfFile(resolved, { maxBytes: Number(max_bytes) || PDF_MAX_BYTES });
2530
+ if (!validation.ok) return validation;
2531
+
2532
+ const base = {
2533
+ ok: true,
2534
+ path: resolved,
2535
+ bytes: validation.bytes,
2536
+ sha256: validation.sha256,
2537
+ mimeType: 'application/pdf',
2538
+ page_count: null,
2539
+ encrypted: null,
2540
+ };
2541
+
2542
+ try {
2543
+ const { stdout } = await execFileAsync('pdfinfo', [resolved], {
2544
+ timeout: 15000,
2545
+ maxBuffer: 512 * 1024,
2546
+ });
2547
+ const pages = stdout.match(/^Pages:\s*(\d+)/mi);
2548
+ const encrypted = stdout.match(/^Encrypted:\s*(yes|no)/mi);
2549
+ return {
2550
+ ...base,
2551
+ page_count: pages ? Number(pages[1]) : null,
2552
+ encrypted: encrypted ? encrypted[1].toLowerCase() === 'yes' : null,
2553
+ raw_info: stdout.trim().slice(0, 8000),
2554
+ artifact: {
2555
+ kind: 'pdf',
2556
+ path: resolved,
2557
+ mimeType: 'application/pdf',
2558
+ bytes: validation.bytes,
2559
+ sha256: validation.sha256,
2560
+ metadata: { page_count: pages ? Number(pages[1]) : null },
2561
+ },
2562
+ };
2563
+ } catch (err) {
2564
+ if (err && err.code === 'ENOENT') {
2565
+ return {
2566
+ ...base,
2567
+ warnings: ['pdfinfo is unavailable; validated file existence, size, sha256, and %PDF- magic bytes only.'],
2568
+ artifact: {
2569
+ kind: 'pdf',
2570
+ path: resolved,
2571
+ mimeType: 'application/pdf',
2572
+ bytes: validation.bytes,
2573
+ sha256: validation.sha256,
2574
+ metadata: { page_count: null, verification: 'magic-bytes-only' },
2575
+ },
2576
+ };
2577
+ }
2578
+ return {
2579
+ ...base,
2580
+ ok: false,
2581
+ error: `pdfinfo failed: ${String(err.stderr || err.message || err).trim().slice(0, 2000)}`,
2582
+ exitCode: err.code,
2583
+ };
2584
+ }
2585
+ }
2586
+
2587
+ function parsePdfPageRange(pages, { pageCount = null, defaultRange = '1', maxPages = PDF_MAX_PAGE_RANGE } = {}) {
2588
+ const raw = String(pages || defaultRange || '1').trim();
2589
+ const match = raw.match(/^(\d+)(?:-(\d*)?)?$/);
2590
+ if (!match) return { ok: false, error: `Invalid page range "${raw}". Use "1", "1-3", or "2-".` };
2591
+ const start = Number(match[1]);
2592
+ if (!Number.isInteger(start) || start < 1) return { ok: false, error: 'PDF page range must start at page 1 or later.' };
2593
+ let end;
2594
+ if (!raw.includes('-')) end = start;
2595
+ else if (match[2]) end = Number(match[2]);
2596
+ else end = pageCount ? Number(pageCount) : start + maxPages - 1;
2597
+ if (!Number.isInteger(end) || end < start) return { ok: false, error: `Invalid page range "${raw}". End must be >= start.` };
2598
+ if (pageCount && start > pageCount) return { ok: false, error: `PDF page range starts after the last page (${pageCount}).` };
2599
+ if (pageCount) end = Math.min(end, Number(pageCount));
2600
+ const count = end - start + 1;
2601
+ if (count > maxPages) return { ok: false, error: `PDF page range requests ${count} pages; maximum is ${maxPages}.` };
2602
+ return { ok: true, start, end, count, raw };
2603
+ }
2604
+
2605
+ function resolvePdfOutputDir(outputDir, projectRoot) {
2606
+ const expanded = expandUserPath(outputDir);
2607
+ const resolved = path.isAbsolute(expanded)
2608
+ ? path.resolve(expanded)
2609
+ : path.resolve(projectRoot || process.cwd(), expanded);
2610
+ const roots = [os.tmpdir(), HOME];
2611
+ if (projectRoot) roots.push(projectRoot);
2612
+ const realResolved = realpathBestEffort(resolved);
2613
+ if (!roots.map(realpathBestEffort).some((root) => isPathWithin(root, realResolved))) {
2614
+ throw new Error(`PDF preview output directory ${resolved} must be under the project, HOME, or the system temp directory`);
2615
+ }
2616
+ return realResolved;
2617
+ }
2618
+
2619
+ async function pdfRenderPages({ file_path, pages = '1', output_dir, dpi = 144, projectRoot } = {}) {
2620
+ const info = await pdfInfo({ file_path, projectRoot });
2621
+ if (!info.ok) return info;
2622
+ const range = parsePdfPageRange(pages, { pageCount: info.page_count, defaultRange: '1' });
2623
+ if (!range.ok) return { ok: false, error: range.error, path: info.path };
2624
+ const renderDpi = Math.max(72, Math.min(200, Number(dpi) || 144));
2625
+ let outDir;
2626
+ try {
2627
+ outDir = output_dir
2628
+ ? resolvePdfOutputDir(output_dir, projectRoot)
2629
+ : fs.mkdtempSync(path.join(os.tmpdir(), 'walle-pdf-pages-'));
2630
+ } catch (err) {
2631
+ return { ok: false, error: err.message, path: info.path };
2632
+ }
2633
+ fs.mkdirSync(outDir, { recursive: true });
2634
+ const prefix = path.join(outDir, `page-${Date.now()}`);
2635
+ try {
2636
+ await execFileAsync('pdftoppm', [
2637
+ '-jpeg',
2638
+ '-r', String(renderDpi),
2639
+ '-f', String(range.start),
2640
+ '-l', String(range.end),
2641
+ info.path,
2642
+ prefix,
2643
+ ], {
2644
+ timeout: 30000,
2645
+ maxBuffer: 1024 * 1024,
2646
+ });
2647
+ } catch (err) {
2648
+ if (err && err.code === 'ENOENT') {
2649
+ return { ok: false, error: 'pdftoppm is unavailable; install poppler to render PDF page previews.', dependency: 'pdftoppm', path: info.path };
2650
+ }
2651
+ return { ok: false, error: `pdftoppm failed: ${String(err.stderr || err.message || err).trim().slice(0, 2000)}`, path: info.path };
2652
+ }
2653
+ const base = path.basename(prefix);
2654
+ const previews = fs.readdirSync(outDir)
2655
+ .filter((name) => name.startsWith(base) && /\.(?:jpg|jpeg)$/i.test(name))
2656
+ .map((name) => path.join(outDir, name))
2657
+ .sort((a, b) => a.localeCompare(b));
2658
+ if (!previews.length) return { ok: false, error: 'pdftoppm completed but produced no preview images.', path: info.path, output_dir: outDir };
2659
+ return {
2660
+ ok: true,
2661
+ path: info.path,
2662
+ output_dir: outDir,
2663
+ pages: { start: range.start, end: range.end, count: range.count },
2664
+ dpi: renderDpi,
2665
+ preview_paths: previews,
2666
+ artifacts: previews.map((previewPath, index) => {
2667
+ const stat = fs.statSync(previewPath);
2668
+ return {
2669
+ kind: 'pdf_page_preview',
2670
+ path: previewPath,
2671
+ mimeType: 'image/jpeg',
2672
+ bytes: stat.size,
2673
+ metadata: {
2674
+ source_pdf: info.path,
2675
+ page: range.start + index,
2676
+ page_count: info.page_count,
2677
+ dpi: renderDpi,
2678
+ },
2679
+ };
2680
+ }),
2681
+ };
2682
+ }
2683
+
2684
+ async function pdfReadPages({ file_path, pages = '1-5', max_chars = 20000, projectRoot } = {}) {
2685
+ const info = await pdfInfo({ file_path, projectRoot });
2686
+ if (!info.ok) return info;
2687
+ const range = parsePdfPageRange(pages, { pageCount: info.page_count, defaultRange: '1-5' });
2688
+ if (!range.ok) return { ok: false, error: range.error, path: info.path };
2689
+ try {
2690
+ const { stdout } = await execFileAsync('pdftotext', [
2691
+ '-f', String(range.start),
2692
+ '-l', String(range.end),
2693
+ '-layout',
2694
+ info.path,
2695
+ '-',
2696
+ ], {
2697
+ timeout: 30000,
2698
+ maxBuffer: 4 * 1024 * 1024,
2699
+ });
2700
+ const maxChars = Math.max(1000, Math.min(Number(max_chars) || 20000, 200000));
2701
+ return {
2702
+ ok: true,
2703
+ path: info.path,
2704
+ page_count: info.page_count,
2705
+ pages: { start: range.start, end: range.end, count: range.count },
2706
+ text: stdout.slice(0, maxChars),
2707
+ truncated: stdout.length > maxChars,
2708
+ artifact: info.artifact || {
2709
+ kind: 'pdf',
2710
+ path: info.path,
2711
+ mimeType: 'application/pdf',
2712
+ bytes: info.bytes,
2713
+ sha256: info.sha256,
2714
+ },
2715
+ };
2716
+ } catch (err) {
2717
+ if (err && err.code === 'ENOENT') {
2718
+ return { ok: false, error: 'pdftotext is unavailable; install poppler to extract PDF text.', dependency: 'pdftotext', path: info.path };
2719
+ }
2720
+ return { ok: false, error: `pdftotext failed: ${String(err.stderr || err.message || err).trim().slice(0, 2000)}`, path: info.path };
2721
+ }
2722
+ }
2723
+
2724
+ function makePdfBinaryCandidates(projectRoot) {
2725
+ const env = process.env;
2726
+ return [
2727
+ env.MAKE_PDF_BIN,
2728
+ projectRoot ? path.join(projectRoot, '.agents/skills/gstack/make-pdf/dist/pdf') : '',
2729
+ path.join(HOME, '.codex/skills/gstack/make-pdf/dist/pdf'),
2730
+ path.join(HOME, '.gstack/repos/gstack/.agents/skills/gstack/make-pdf/dist/pdf'),
2731
+ path.join(HOME, '.gstack/repos/gstack/.agents/skills/gstack-make-pdf/dist/pdf'),
2732
+ env.GSTACK_MAKE_PDF ? path.join(HOME, env.GSTACK_MAKE_PDF, 'pdf') : '',
2733
+ ].filter(Boolean);
2734
+ }
2735
+
2736
+ function findMakePdfBinary(projectRoot) {
2737
+ return makePdfBinaryCandidates(projectRoot).find((candidate) => {
2738
+ try {
2739
+ return fs.existsSync(candidate) && fs.statSync(candidate).isFile() && (fs.statSync(candidate).mode & 0o111);
2740
+ } catch {
2741
+ return false;
2742
+ }
2743
+ }) || '';
2744
+ }
2745
+
2746
+ function inferPdfOutputPath(inputPath, outputPath, projectRoot) {
2747
+ if (outputPath) return resolveToolPath(expandUserPath(outputPath), projectRoot);
2748
+ return path.join(path.dirname(inputPath), `${path.basename(inputPath, path.extname(inputPath))}.pdf`);
2749
+ }
2750
+
2751
+ async function makePdf({
2752
+ input_path,
2753
+ output_path,
2754
+ title,
2755
+ author,
2756
+ date,
2757
+ page_size,
2758
+ margins,
2759
+ cover = false,
2760
+ toc = false,
2761
+ watermark = '',
2762
+ allow_network = false,
2763
+ no_confidential = false,
2764
+ render_preview = true,
2765
+ projectRoot,
2766
+ } = {}) {
2767
+ const project = projectRoot || process.cwd();
2768
+ const binary = findMakePdfBinary(project);
2769
+ if (!binary) {
2770
+ return {
2771
+ ok: false,
2772
+ error: 'make-pdf renderer is not available. Build gstack make-pdf or set MAKE_PDF_BIN to the renderer binary.',
2773
+ candidates: makePdfBinaryCandidates(project),
2774
+ };
2775
+ }
2776
+ let inputPath;
2777
+ let outputPath;
2778
+ try {
2779
+ if (!input_path || typeof input_path !== 'string') throw new Error('input_path is required');
2780
+ inputPath = resolveToolPath(expandUserPath(input_path), project);
2781
+ outputPath = inferPdfOutputPath(inputPath, output_path, project);
2782
+ } catch (err) {
2783
+ return { ok: false, error: err.message };
2784
+ }
2785
+ if (!fs.existsSync(inputPath) || !fs.statSync(inputPath).isFile()) {
2786
+ return { ok: false, error: `Input file not found: ${inputPath}` };
2787
+ }
2788
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true });
2789
+ const args = ['generate', '--quiet'];
2790
+ if (cover) args.push('--cover');
2791
+ if (toc) args.push('--toc');
2792
+ if (watermark) args.push('--watermark', String(watermark));
2793
+ if (allow_network) args.push('--allow-network');
2794
+ if (no_confidential) args.push('--no-confidential');
2795
+ if (title) args.push('--title', String(title));
2796
+ if (author) args.push('--author', String(author));
2797
+ if (date) args.push('--date', String(date));
2798
+ if (page_size) args.push('--page-size', String(page_size));
2799
+ if (margins) args.push('--margins', String(margins));
2800
+ args.push(inputPath, outputPath);
2801
+
2802
+ let stdout = '';
2803
+ let stderr = '';
2804
+ try {
2805
+ const result = await execFileAsync(binary, args, {
2806
+ timeout: 120000,
2807
+ maxBuffer: 2 * 1024 * 1024,
2808
+ cwd: project,
2809
+ });
2810
+ stdout = result.stdout || '';
2811
+ stderr = result.stderr || '';
2812
+ } catch (err) {
2813
+ return {
2814
+ ok: false,
2815
+ error: `make-pdf failed: ${String(err.stderr || err.message || err).trim().slice(0, 4000)}`,
2816
+ stdout: String(err.stdout || '').slice(0, 4000),
2817
+ stderr: String(err.stderr || '').slice(0, 4000),
2818
+ exitCode: err.code,
2819
+ };
2820
+ }
2821
+
2822
+ const stdoutPath = stdout.trim().split(/\r?\n/).find((line) => /\.pdf$/i.test(line.trim()));
2823
+ const producedPath = stdoutPath ? resolveToolPath(expandUserPath(stdoutPath.trim()), project) : outputPath;
2824
+ const info = await pdfInfo({ file_path: producedPath, projectRoot: project });
2825
+ if (!info.ok) {
2826
+ return {
2827
+ ok: false,
2828
+ error: `Generated PDF failed validation: ${info.error || 'unknown validation error'}`,
2829
+ path: producedPath,
2830
+ stdout: stdout.trim(),
2831
+ stderr: stderr.trim(),
2832
+ };
2833
+ }
2834
+
2835
+ const artifacts = [info.artifact || {
2836
+ kind: 'pdf',
2837
+ path: producedPath,
2838
+ mimeType: 'application/pdf',
2839
+ bytes: info.bytes,
2840
+ sha256: info.sha256,
2841
+ metadata: { page_count: info.page_count },
2842
+ }];
2843
+ let preview = null;
2844
+ if (render_preview !== false && process.env.WALLE_DISABLE_PDF_PREVIEW !== '1') {
2845
+ preview = await pdfRenderPages({
2846
+ file_path: producedPath,
2847
+ pages: '1',
2848
+ output_dir: path.join(os.tmpdir(), `walle-pdf-preview-${Date.now()}`),
2849
+ projectRoot: project,
2850
+ });
2851
+ if (!preview.ok && preview.dependency !== 'pdftoppm') {
2852
+ return {
2853
+ ok: false,
2854
+ error: `Generated PDF preview validation failed: ${preview.error}`,
2855
+ path: producedPath,
2856
+ pdf: info,
2857
+ };
2858
+ }
2859
+ if (preview.ok && Array.isArray(preview.artifacts)) artifacts.push(...preview.artifacts);
2860
+ }
2861
+
2862
+ return {
2863
+ ok: true,
2864
+ path: producedPath,
2865
+ bytes: info.bytes,
2866
+ sha256: info.sha256,
2867
+ page_count: info.page_count,
2868
+ preview_paths: preview?.ok ? preview.preview_paths : [],
2869
+ preview_warning: preview && !preview.ok ? preview.error : '',
2870
+ stdout: stdout.trim(),
2871
+ stderr: stderr.trim(),
2872
+ artifact: artifacts[0],
2873
+ artifacts,
2030
2874
  };
2031
2875
  }
2032
2876
 
2877
+ function checkUrl({ url, timeout_ms = 5000 } = {}) {
2878
+ return new Promise((resolve) => {
2879
+ if (!url || typeof url !== 'string') {
2880
+ resolve({ ok: false, error: 'url is required (string)' });
2881
+ return;
2882
+ }
2883
+ let parsed;
2884
+ try {
2885
+ parsed = new URL(url);
2886
+ } catch (err) {
2887
+ resolve({ ok: false, error: `Invalid URL: ${err.message}`, url });
2888
+ return;
2889
+ }
2890
+ const client = parsed.protocol === 'https:' ? https : parsed.protocol === 'http:' ? http : null;
2891
+ if (!client) {
2892
+ resolve({ ok: false, error: 'Only http:// and https:// URLs are supported', url });
2893
+ return;
2894
+ }
2895
+ const started = Date.now();
2896
+ const req = client.request(parsed, { method: 'GET', timeout: timeout_ms }, (res) => {
2897
+ let bytes = 0;
2898
+ res.on('data', (chunk) => {
2899
+ bytes += chunk.length;
2900
+ });
2901
+ res.on('end', () => {
2902
+ resolve({
2903
+ ok: res.statusCode >= 200 && res.statusCode < 400,
2904
+ status: res.statusCode,
2905
+ bytes,
2906
+ elapsed_ms: Date.now() - started,
2907
+ url,
2908
+ reachability_scope: _localUrlScope(parsed),
2909
+ user_browser_reachability: _localUrlScope(parsed) === 'wall-e-host-loopback' ? 'not_proven' : 'not_checked',
2910
+ note: _localUrlScope(parsed) === 'wall-e-host-loopback'
2911
+ ? 'This proves only that Wall-E host loopback can reach the URL. Phone or remote-browser reachability needs CTM remote/tunnel context.'
2912
+ : undefined,
2913
+ });
2914
+ });
2915
+ });
2916
+ req.on('timeout', () => {
2917
+ req.destroy(new Error(`Timed out after ${timeout_ms}ms`));
2918
+ });
2919
+ req.on('error', (err) => {
2920
+ resolve({
2921
+ ok: false,
2922
+ error: err.message,
2923
+ elapsed_ms: Date.now() - started,
2924
+ url,
2925
+ reachability_scope: _localUrlScope(parsed),
2926
+ user_browser_reachability: _localUrlScope(parsed) === 'wall-e-host-loopback' ? 'not_proven' : 'not_checked',
2927
+ });
2928
+ });
2929
+ req.end();
2930
+ });
2931
+ }
2932
+
2933
+ function _localUrlScope(parsed) {
2934
+ const hostname = String(parsed?.hostname || '').toLowerCase();
2935
+ return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1'
2936
+ ? 'wall-e-host-loopback'
2937
+ : 'network';
2938
+ }
2939
+
2940
+ const STATIC_SERVER_SCRIPT = `
2941
+ const http = require('http');
2942
+ const fs = require('fs');
2943
+ const path = require('path');
2944
+ const root = path.resolve(process.argv[1]);
2945
+ const port = Number(process.argv[2] || 0);
2946
+ const mime = {
2947
+ '.html': 'text/html; charset=utf-8',
2948
+ '.css': 'text/css; charset=utf-8',
2949
+ '.js': 'application/javascript; charset=utf-8',
2950
+ '.json': 'application/json; charset=utf-8',
2951
+ '.png': 'image/png',
2952
+ '.jpg': 'image/jpeg',
2953
+ '.jpeg': 'image/jpeg',
2954
+ '.gif': 'image/gif',
2955
+ '.svg': 'image/svg+xml',
2956
+ };
2957
+ function send(res, status, body) {
2958
+ res.writeHead(status, { 'content-type': 'text/plain; charset=utf-8' });
2959
+ res.end(body);
2960
+ }
2961
+ const server = http.createServer((req, res) => {
2962
+ let pathname = '/';
2963
+ try { pathname = decodeURIComponent(new URL(req.url, 'http://localhost').pathname); }
2964
+ catch { return send(res, 400, 'bad request'); }
2965
+ let file = path.normalize(path.join(root, pathname));
2966
+ if (file !== root && !file.startsWith(root + path.sep)) return send(res, 403, 'forbidden');
2967
+ try {
2968
+ const stat = fs.existsSync(file) ? fs.statSync(file) : null;
2969
+ if (stat && stat.isDirectory()) file = path.join(file, 'index.html');
2970
+ if (!fs.existsSync(file) || !fs.statSync(file).isFile()) return send(res, 404, 'not found');
2971
+ res.writeHead(200, { 'content-type': mime[path.extname(file).toLowerCase()] || 'application/octet-stream' });
2972
+ fs.createReadStream(file).pipe(res);
2973
+ } catch (err) {
2974
+ send(res, 500, err.message);
2975
+ }
2976
+ });
2977
+ server.listen(port, '127.0.0.1', () => {
2978
+ const address = server.address();
2979
+ process.stdout.write(JSON.stringify({ port: address.port }) + '\\n');
2980
+ });
2981
+ `;
2982
+
2983
+ async function startStaticServer({ directory, port = 0, route = '/index.html', timeout_ms = 5000, projectRoot } = {}) {
2984
+ let root;
2985
+ try {
2986
+ root = projectRoot
2987
+ ? resolveToolPath(directory || '.', projectRoot)
2988
+ : path.resolve(expandUserPath(directory || process.cwd()));
2989
+ } catch (err) {
2990
+ return { ok: false, error: err.message };
2991
+ }
2992
+ if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) {
2993
+ return { ok: false, error: `directory is not a directory: ${root}` };
2994
+ }
2995
+ const stdoutLog = path.join(os.tmpdir(), `walle-static-${Date.now()}-${Math.random().toString(36).slice(2)}.out.log`);
2996
+ const stderrLog = stdoutLog.replace(/\.out\.log$/, '.err.log');
2997
+ fs.writeFileSync(stdoutLog, '');
2998
+ fs.writeFileSync(stderrLog, '');
2999
+
3000
+ const child = require('child_process').spawn(process.execPath, ['-e', STATIC_SERVER_SCRIPT, root, String(port || 0)], {
3001
+ cwd: root,
3002
+ stdio: ['ignore', 'pipe', 'pipe'],
3003
+ detached: false,
3004
+ env: hostAutomationEnv(),
3005
+ });
3006
+ child.stdout.on('data', (chunk) => fs.appendFileSync(stdoutLog, chunk));
3007
+ child.stderr.on('data', (chunk) => fs.appendFileSync(stderrLog, chunk));
3008
+
3009
+ const listenInfo = await new Promise((resolve) => {
3010
+ let buffer = '';
3011
+ const timer = setTimeout(() => resolve({ error: `Static server did not start within ${timeout_ms}ms` }), timeout_ms);
3012
+ child.stdout.on('data', (chunk) => {
3013
+ buffer += chunk.toString('utf8');
3014
+ const line = buffer.split(/\r?\n/).find(Boolean);
3015
+ if (!line) return;
3016
+ try {
3017
+ clearTimeout(timer);
3018
+ resolve(JSON.parse(line));
3019
+ } catch {}
3020
+ });
3021
+ child.once('exit', (code, signal) => {
3022
+ clearTimeout(timer);
3023
+ resolve({ error: `Static server exited before listening: ${code ?? signal}` });
3024
+ });
3025
+ });
3026
+
3027
+ if (!listenInfo || listenInfo.error || !listenInfo.port) {
3028
+ try { child.kill(); } catch {}
3029
+ return { ok: false, error: listenInfo?.error || 'Static server failed to start', stdout_log: stdoutLog, stderr_log: stderrLog };
3030
+ }
3031
+
3032
+ const resourceId = `static-${child.pid}-${listenInfo.port}`;
3033
+ const url = `http://127.0.0.1:${listenInfo.port}${route && route.startsWith('/') ? route : `/${route || ''}`}`;
3034
+ const health = await checkUrl({ url, timeout_ms: Math.min(timeout_ms, 5000) });
3035
+ STATIC_SERVER_PROCESSES.set(resourceId, { child, root, port: listenInfo.port, url, stdoutLog, stderrLog });
3036
+ if (child.unref) child.unref();
3037
+ return {
3038
+ ok: health.ok,
3039
+ resource_id: resourceId,
3040
+ pid: child.pid,
3041
+ port: listenInfo.port,
3042
+ root,
3043
+ url,
3044
+ health,
3045
+ reachability_scope: 'wall-e-host-loopback',
3046
+ user_browser_reachability: 'not_proven',
3047
+ note: 'Managed static servers bind to 127.0.0.1. This verifies Wall-E host loopback only; phone or remote-browser access needs CTM remote/tunnel context.',
3048
+ stdout_log: stdoutLog,
3049
+ stderr_log: stderrLog,
3050
+ };
3051
+ }
3052
+
3053
+ async function stopStaticServer({ resource_id } = {}) {
3054
+ const resource = STATIC_SERVER_PROCESSES.get(resource_id);
3055
+ if (!resource) return { ok: false, error: `No static server resource found for ${resource_id || '(missing id)'}` };
3056
+ STATIC_SERVER_PROCESSES.delete(resource_id);
3057
+ try {
3058
+ resource.child.kill();
3059
+ } catch (err) {
3060
+ return { ok: false, error: err.message, resource_id };
3061
+ }
3062
+ return { ok: true, resource_id, pid: resource.child.pid, url: resource.url };
3063
+ }
3064
+
3065
+ // ── Background shell processes ──
3066
+ // Dev servers, watchers, and long builds: run_shell {background:true}
3067
+ // detaches the command into a session-scoped registry; the agent polls with
3068
+ // bg_output and stops with bg_kill. Non-persistent processes are killed at
3069
+ // the end of the run (cleanupBackgroundProcesses).
3070
+
3071
+ const BACKGROUND_PROCESSES = new Map(); // resourceId -> record
3072
+ const BG_OUTPUT_MAX_BYTES = 512 * 1024;
3073
+
3074
+ async function runShellBackground({ command, cwd, sessionId = '', persist = false } = {}) {
3075
+ if (!command || typeof command !== 'string') return { ok: false, error: 'command is required' };
3076
+ const { analyzeShellCommand, initParser } = require('./shell-analyzer');
3077
+ await initParser();
3078
+ const analysis = await analyzeShellCommand(command, cwd || process.cwd());
3079
+ if (!analysis.allowed) return { ok: false, error: analysis.reason };
3080
+
3081
+ const logFile = path.join(os.tmpdir(), `walle-bg-${Date.now()}-${Math.random().toString(36).slice(2)}.log`);
3082
+ const logFd = fs.openSync(logFile, 'a');
3083
+ let child;
3084
+ try {
3085
+ child = require('child_process').spawn('/bin/bash', ['-c', command], {
3086
+ cwd: cwd || undefined,
3087
+ env: { ...process.env, HOME },
3088
+ stdio: ['ignore', logFd, logFd],
3089
+ detached: true, // own process group so bg_kill can stop child trees
3090
+ });
3091
+ } catch (err) {
3092
+ fs.closeSync(logFd);
3093
+ return { ok: false, error: err.message };
3094
+ }
3095
+ fs.closeSync(logFd);
3096
+
3097
+ const resourceId = `bg-${child.pid}`;
3098
+ const record = {
3099
+ resourceId,
3100
+ pid: child.pid,
3101
+ command,
3102
+ cwd: cwd || process.cwd(),
3103
+ sessionId,
3104
+ persist: Boolean(persist),
3105
+ logFile,
3106
+ startedAt: Date.now(),
3107
+ exited: false,
3108
+ exitCode: null,
3109
+ exitSignal: null,
3110
+ child,
3111
+ };
3112
+ child.once('exit', (code, signal) => {
3113
+ record.exited = true;
3114
+ record.exitCode = code;
3115
+ record.exitSignal = signal;
3116
+ record.endedAt = Date.now();
3117
+ });
3118
+ child.unref();
3119
+ BACKGROUND_PROCESSES.set(resourceId, record);
3120
+ return {
3121
+ ok: true,
3122
+ background: true,
3123
+ resource_id: resourceId,
3124
+ pid: child.pid,
3125
+ log_file: logFile,
3126
+ note: 'Command is running in the background. Poll with bg_output {resource_id}; stop with bg_kill {resource_id}. Non-persistent background processes are stopped when the run ends.',
3127
+ };
3128
+ }
3129
+
3130
+ function _bgStatus(record) {
3131
+ if (!record.exited && !_pidIsAlive(record.pid)) {
3132
+ record.exited = true; // exited while we weren't looking (e.g. after restart)
3133
+ }
3134
+ return record.exited ? 'exited' : 'running';
3135
+ }
3136
+
3137
+ async function bgOutput({ resource_id, tail_lines = 100 } = {}) {
3138
+ const record = BACKGROUND_PROCESSES.get(resource_id);
3139
+ if (!record) return { ok: false, error: `No background process found for ${resource_id || '(missing id)'}` };
3140
+ let output = '';
3141
+ try {
3142
+ const stat = fs.statSync(record.logFile);
3143
+ const start = Math.max(0, stat.size - BG_OUTPUT_MAX_BYTES);
3144
+ const fd = fs.openSync(record.logFile, 'r');
3145
+ try {
3146
+ const buf = Buffer.alloc(stat.size - start);
3147
+ fs.readSync(fd, buf, 0, buf.length, start);
3148
+ output = buf.toString('utf8');
3149
+ } finally {
3150
+ fs.closeSync(fd);
3151
+ }
3152
+ } catch (err) {
3153
+ output = `(could not read log: ${err.message})`;
3154
+ }
3155
+ const lines = output.split('\n');
3156
+ const maxLines = Math.max(1, Math.min(2000, tail_lines || 100));
3157
+ const tail = lines.slice(-maxLines).join('\n');
3158
+ const status = _bgStatus(record);
3159
+ return {
3160
+ ok: true,
3161
+ resource_id,
3162
+ status,
3163
+ exit_code: record.exitCode,
3164
+ exit_signal: record.exitSignal,
3165
+ uptime_ms: (record.endedAt || Date.now()) - record.startedAt,
3166
+ output: tail,
3167
+ truncated: lines.length > maxLines,
3168
+ };
3169
+ }
3170
+
3171
+ async function bgKill({ resource_id } = {}) {
3172
+ const record = BACKGROUND_PROCESSES.get(resource_id);
3173
+ if (!record) return { ok: false, error: `No background process found for ${resource_id || '(missing id)'}` };
3174
+ if (_bgStatus(record) === 'exited') {
3175
+ return { ok: true, resource_id, status: 'exited', exit_code: record.exitCode, note: 'Process had already exited.' };
3176
+ }
3177
+ try {
3178
+ process.kill(-record.pid, 'SIGTERM'); // whole process group
3179
+ } catch {
3180
+ try { record.child.kill('SIGTERM'); } catch {}
3181
+ }
3182
+ return { ok: true, resource_id, status: 'terminating', pid: record.pid };
3183
+ }
3184
+
3185
+ /**
3186
+ * Kill all non-persistent background processes for a session (or all
3187
+ * sessions when sessionId is omitted). Returns what was stopped/leaked so
3188
+ * run summaries can report it.
3189
+ */
3190
+ function cleanupBackgroundProcesses({ sessionId = null } = {}) {
3191
+ const stopped = [];
3192
+ const persisted = [];
3193
+ for (const [resourceId, record] of BACKGROUND_PROCESSES) {
3194
+ if (sessionId && record.sessionId !== sessionId) continue;
3195
+ if (record.persist) {
3196
+ if (_bgStatus(record) === 'running') persisted.push({ resource_id: resourceId, pid: record.pid, command: record.command });
3197
+ continue;
3198
+ }
3199
+ if (_bgStatus(record) === 'running') {
3200
+ try {
3201
+ process.kill(-record.pid, 'SIGTERM');
3202
+ } catch {
3203
+ try { record.child.kill('SIGTERM'); } catch {}
3204
+ }
3205
+ stopped.push({ resource_id: resourceId, pid: record.pid, command: record.command });
3206
+ }
3207
+ BACKGROUND_PROCESSES.delete(resourceId);
3208
+ }
3209
+ return { stopped, persisted };
3210
+ }
3211
+
2033
3212
  async function clipboardRead() {
2034
3213
  const { stdout } = await execFileAsync('pbpaste', [], { timeout: 5000 });
2035
3214
  return { content: stdout.slice(0, 50000) };
@@ -2937,7 +4116,8 @@ async function webFetch(url, { method = 'GET', extract_text = true, max_bytes =
2937
4116
  args.push(url);
2938
4117
  const { stdout, stderr } = await execFileAsync('curl', args, { timeout: timeout_ms + 2000, maxBuffer: max_bytes * 2 });
2939
4118
  if (stderr && !stdout) throw new Error(stderr.slice(0, 500));
2940
- let content = stdout.slice(0, max_bytes);
4119
+ const rawContent = stdout.slice(0, max_bytes);
4120
+ let content = rawContent;
2941
4121
  // Strip HTML tags if requested to get readable text
2942
4122
  if (extract_text && content.includes('<')) {
2943
4123
  content = content
@@ -2952,7 +4132,72 @@ async function webFetch(url, { method = 'GET', extract_text = true, max_bytes =
2952
4132
  .trim()
2953
4133
  .slice(0, max_bytes);
2954
4134
  }
2955
- return { url, content, length: content.length, truncated: stdout.length > max_bytes };
4135
+ const result = { url, content, length: content.length, truncated: stdout.length > max_bytes };
4136
+ if (extract_text && rawContent.includes('<') && content.length <= 40 && /<script[\s>]/i.test(rawContent)) {
4137
+ result.fetch_status = 'needs_browser_rendering';
4138
+ result.warning = 'HTTP fetch succeeded, but extracted text is too small and the page appears to require JavaScript/browser rendering.';
4139
+ }
4140
+ return result;
4141
+ }
4142
+
4143
+ // ── Web search (DuckDuckGo HTML endpoint — no API key) ──
4144
+
4145
+ function parseDuckDuckGoResults(html, maxResults = 8) {
4146
+ const results = [];
4147
+ const linkRe = /<a[^>]*class="[^"]*result__a[^"]*"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/gi;
4148
+ const snippetRe = /<a[^>]*class="[^"]*result__snippet[^"]*"[^>]*>([\s\S]*?)<\/a>/gi;
4149
+ const snippets = [];
4150
+ let match;
4151
+ while ((match = snippetRe.exec(html))) snippets.push(stripHtmlText(match[1]));
4152
+ let index = 0;
4153
+ while ((match = linkRe.exec(html)) && results.length < maxResults) {
4154
+ let url = match[1];
4155
+ // DDG wraps targets in a redirect: //duckduckgo.com/l/?uddg=<encoded>
4156
+ const uddg = url.match(/[?&]uddg=([^&]+)/);
4157
+ if (uddg) {
4158
+ try { url = decodeURIComponent(uddg[1]); } catch {}
4159
+ }
4160
+ if (!/^https?:\/\//i.test(url)) { index += 1; continue; }
4161
+ results.push({
4162
+ title: stripHtmlText(match[2]),
4163
+ url,
4164
+ snippet: snippets[index] || '',
4165
+ });
4166
+ index += 1;
4167
+ }
4168
+ return results;
4169
+ }
4170
+
4171
+ function stripHtmlText(html) {
4172
+ return String(html || '')
4173
+ .replace(/<[^>]+>/g, ' ')
4174
+ .replace(/&nbsp;/g, ' ')
4175
+ .replace(/&amp;/g, '&')
4176
+ .replace(/&lt;/g, '<')
4177
+ .replace(/&gt;/g, '>')
4178
+ .replace(/&#x27;|&#39;/g, "'")
4179
+ .replace(/&quot;/g, '"')
4180
+ .replace(/\s{2,}/g, ' ')
4181
+ .trim();
4182
+ }
4183
+
4184
+ async function webSearch({ query, max_results = 8, timeout_ms = 15000 } = {}) {
4185
+ if (!query || !String(query).trim()) return { ok: false, error: 'query is required' };
4186
+ const url = `https://html.duckduckgo.com/html/?q=${encodeURIComponent(String(query).trim())}`;
4187
+ const { stdout } = await execFileAsync('curl', [
4188
+ '-sS', '-L', '--max-time', String(Math.round(timeout_ms / 1000)),
4189
+ '-A', 'Mozilla/5.0 (compatible; WALL-E/1.0)',
4190
+ url,
4191
+ ], { timeout: timeout_ms + 2000, maxBuffer: 4 * 1024 * 1024 });
4192
+ const results = parseDuckDuckGoResults(stdout, Math.max(1, Math.min(20, max_results)));
4193
+ return {
4194
+ ok: true,
4195
+ query,
4196
+ results,
4197
+ note: results.length === 0
4198
+ ? 'No results parsed. The query may have no hits, or the search endpoint may have changed.'
4199
+ : 'Use web_fetch on a result URL to read the page.',
4200
+ };
2956
4201
  }
2957
4202
 
2958
4203
  // ── Tool definitions for Claude API ──
@@ -2967,10 +4212,34 @@ const LOCAL_TOOL_DEFINITIONS = [
2967
4212
  command: { type: 'string', description: 'Complete shell command string to run, e.g. `npm test`, `git status --short`, or `cd app && npm run build`. Include any pipes/redirects/heredocs here.' },
2968
4213
  args: { type: 'array', items: { type: 'string' }, description: 'Optional legacy argument array for simple commands only. Do not put `-c` scripts, pipes, redirects, heredocs, `<`, `>`, or `|` here.' },
2969
4214
  timeout_ms: { type: 'number', description: 'Timeout in ms (default 15000)' },
4215
+ background: { type: 'boolean', description: 'Run detached in the background (dev servers, watchers, long builds). Returns a resource_id immediately; poll with bg_output, stop with bg_kill. Never use `&` for backgrounding — use this flag.' },
2970
4216
  },
2971
4217
  required: ['command'],
2972
4218
  },
2973
4219
  },
4220
+ {
4221
+ name: 'bg_output',
4222
+ description: 'Read the latest output of a background process started with run_shell {background:true}. Returns status (running/exited), exit code, and the log tail.',
4223
+ input_schema: {
4224
+ type: 'object',
4225
+ properties: {
4226
+ resource_id: { type: 'string', description: 'resource_id returned by run_shell {background:true}' },
4227
+ tail_lines: { type: 'number', description: 'How many trailing log lines to return (default 100, max 2000)' },
4228
+ },
4229
+ required: ['resource_id'],
4230
+ },
4231
+ },
4232
+ {
4233
+ name: 'bg_kill',
4234
+ description: 'Stop a background process started with run_shell {background:true}.',
4235
+ input_schema: {
4236
+ type: 'object',
4237
+ properties: {
4238
+ resource_id: { type: 'string', description: 'resource_id returned by run_shell {background:true}' },
4239
+ },
4240
+ required: ['resource_id'],
4241
+ },
4242
+ },
2974
4243
  {
2975
4244
  name: 'read_file',
2976
4245
  description: 'Read a local text file. In coding sessions, files must be inside the current project/cwd; relative paths resolve from that project. Prefer this over `run_shell` with cat/head/tail/sed when the path is known. Supports max_bytes for partial reads.',
@@ -3022,17 +4291,132 @@ const LOCAL_TOOL_DEFINITIONS = [
3022
4291
  },
3023
4292
  {
3024
4293
  name: 'browser_screenshot',
3025
- description: 'Render a URL (http:// or file://) in headless Chrome and capture a PNG at a preset viewport. Use after building UI to self-critique what you actually built before claiming done. Capture both desktop (1440×900) and mobile (390×844) for any responsive UI. Returns { ok, path, viewport, width, height }.',
4294
+ description: 'Render a URL (http:// or file://) in headless Chrome and capture a PNG. Use after building UI to self-critique what you actually built before claiming done. Capture both desktop (1440×900) and mobile (390×844) for any responsive UI. Pass full_page: true to capture EVERYTHING below the fold (galleries, footers, long content) — essential before declaring a multi-section page done. Returns { ok, path, viewport, width, height, full_page? }.',
3026
4295
  input_schema: {
3027
4296
  type: 'object',
3028
4297
  properties: {
3029
4298
  url: { type: 'string', description: 'URL to capture — supports file:// for local files (e.g., file:///path/to/index.html) or http://localhost:port for a dev server.' },
3030
- viewport: { type: 'string', enum: ['desktop', 'mobile', 'tablet'], description: 'Viewport preset: desktop (1440×900), mobile (390×844), tablet (1024×768). Default: desktop.' },
3031
- output_path: { type: 'string', description: 'Save path. Default: ~/<tmp>/walle-shot-<viewport>-<timestamp>.png' },
4299
+ viewport: { type: 'string', enum: ['desktop', 'mobile', 'tablet'], description: 'Viewport preset: desktop (1440×900), mobile (390×844), tablet (1024×768). Default: desktop. The viewport drives responsive CSS even when full_page is true.' },
4300
+ output_path: { type: 'string', description: 'Save path. Default: ~/<tmp>/walle-shot-<viewport>[-fullpage]-<timestamp>.png' },
4301
+ full_page: { type: 'boolean', description: 'When true, capture the entire scrollable document — not just the viewport. Slower (~3-5s, drives Chrome via CDP) but the only way to see content below the fold. Default: false.' },
3032
4302
  },
3033
4303
  required: ['url'],
3034
4304
  },
3035
4305
  },
4306
+ {
4307
+ name: 'browser_smoke_test',
4308
+ description: 'Render a URL in headless Chrome through CDP, capture JavaScript runtime exceptions, console errors, failed requests, and safely click interactive elements such as [onclick], buttons, role=button, and hash links. Use this after frontend/UI changes before claiming completion; it catches broken HTML-to-JS handlers that screenshots can miss. Returns { ok, failures, runtimeExceptions, consoleErrors, failedRequests, clicked }.',
4309
+ input_schema: {
4310
+ type: 'object',
4311
+ properties: {
4312
+ url: { type: 'string', description: 'URL to validate — supports file:// local HTML files or http://localhost URLs from start_static_server.' },
4313
+ viewport: { type: 'string', enum: ['desktop', 'mobile', 'tablet'], description: 'Viewport preset. Default: desktop.' },
4314
+ click_selectors: { type: 'array', items: { type: 'string' }, description: 'Optional selectors to click. Defaults to [onclick], button, [role=button], and hash links.' },
4315
+ max_clicks: { type: 'number', description: 'Maximum interactive elements to click. Default: 20.' },
4316
+ settle_ms: { type: 'number', description: 'Milliseconds to wait after load/clicks. Default: 750.' },
4317
+ timeout_ms: { type: 'number', description: 'Overall timeout. Default: 45000.' },
4318
+ },
4319
+ required: ['url'],
4320
+ },
4321
+ },
4322
+ {
4323
+ name: 'check_url',
4324
+ description: 'Fetch an http:// or https:// URL and report whether it returns a 2xx/3xx response. Use this before claiming a local dev/static server is actually reachable. For localhost/127.0.0.1, this verifies Wall-E host loopback only; do not claim phone or remote-browser access without CTM remote/tunnel evidence.',
4325
+ input_schema: {
4326
+ type: 'object',
4327
+ properties: {
4328
+ url: { type: 'string', description: 'URL to fetch, for example http://127.0.0.1:9090/index.html.' },
4329
+ timeout_ms: { type: 'number', description: 'Timeout in ms (default 5000).' },
4330
+ },
4331
+ required: ['url'],
4332
+ },
4333
+ },
4334
+ {
4335
+ name: 'start_static_server',
4336
+ description: 'Start a managed local static file server for a directory, wait for it to answer HTTP 200/3xx from Wall-E host loopback, and return the verified local URL plus a resource_id. Prefer this over run_shell background commands such as `python3 -m http.server &`. Do not present the returned localhost/127.0.0.1 URL as phone or remote-browser reachable unless CTM remote/tunnel evidence confirms it.',
4337
+ input_schema: {
4338
+ type: 'object',
4339
+ properties: {
4340
+ directory: { type: 'string', description: 'Directory to serve. Defaults to current project directory.' },
4341
+ port: { type: 'number', description: 'Port to bind. Use 0 or omit for an available port.' },
4342
+ route: { type: 'string', description: 'Route to health-check after start. Default: /index.html.' },
4343
+ timeout_ms: { type: 'number', description: 'Startup timeout in ms (default 5000).' },
4344
+ },
4345
+ },
4346
+ },
4347
+ {
4348
+ name: 'stop_static_server',
4349
+ description: 'Stop a static server started by start_static_server using its resource_id.',
4350
+ input_schema: {
4351
+ type: 'object',
4352
+ properties: {
4353
+ resource_id: { type: 'string', description: 'resource_id returned by start_static_server.' },
4354
+ },
4355
+ required: ['resource_id'],
4356
+ },
4357
+ },
4358
+ {
4359
+ name: 'pdf_info',
4360
+ description: 'Validate a PDF and return metadata (bytes, sha256, page count when pdfinfo is available). Use before reading or claiming a PDF artifact is valid.',
4361
+ input_schema: {
4362
+ type: 'object',
4363
+ properties: {
4364
+ file_path: { type: 'string', description: 'PDF file path.' },
4365
+ max_bytes: { type: 'number', description: 'Maximum allowed bytes, default 32MB.' },
4366
+ },
4367
+ required: ['file_path'],
4368
+ },
4369
+ },
4370
+ {
4371
+ name: 'pdf_render_pages',
4372
+ description: 'Render a bounded PDF page range to JPEG previews using pdftoppm. Use previews to inspect generated PDFs visually before claiming success.',
4373
+ input_schema: {
4374
+ type: 'object',
4375
+ properties: {
4376
+ file_path: { type: 'string', description: 'PDF file path.' },
4377
+ pages: { type: 'string', description: 'Page range like "1", "1-3", or "2-". Default: "1". Max 20 pages.' },
4378
+ output_dir: { type: 'string', description: 'Preview output directory. Must be under HOME, temp, or the project.' },
4379
+ dpi: { type: 'number', description: 'Render DPI from 72 to 200. Default: 144.' },
4380
+ },
4381
+ required: ['file_path'],
4382
+ },
4383
+ },
4384
+ {
4385
+ name: 'pdf_read_pages',
4386
+ description: 'Extract text from a bounded PDF page range using pdftotext and return metadata. Use for PDF analysis and summaries.',
4387
+ input_schema: {
4388
+ type: 'object',
4389
+ properties: {
4390
+ file_path: { type: 'string', description: 'PDF file path.' },
4391
+ pages: { type: 'string', description: 'Page range like "1", "1-3", or "2-". Default: "1-5". Max 20 pages.' },
4392
+ max_chars: { type: 'number', description: 'Maximum text chars to return, default 20000.' },
4393
+ },
4394
+ required: ['file_path'],
4395
+ },
4396
+ },
4397
+ {
4398
+ name: 'make_pdf',
4399
+ description: 'Generate a PDF from Markdown/HTML through the configured make-pdf renderer, validate the generated file, and optionally render a page preview. Use for PDF creation/export work.',
4400
+ input_schema: {
4401
+ type: 'object',
4402
+ properties: {
4403
+ input_path: { type: 'string', description: 'Markdown or HTML source file.' },
4404
+ output_path: { type: 'string', description: 'Optional PDF output path.' },
4405
+ title: { type: 'string', description: 'Optional title metadata.' },
4406
+ author: { type: 'string', description: 'Optional author metadata.' },
4407
+ date: { type: 'string', description: 'Optional cover/metadata date.' },
4408
+ page_size: { type: 'string', description: 'Optional page size such as letter or a4.' },
4409
+ margins: { type: 'string', description: 'Optional margin value such as 1in.' },
4410
+ cover: { type: 'boolean', description: 'Generate a cover page when supported.' },
4411
+ toc: { type: 'boolean', description: 'Generate a table of contents when supported.' },
4412
+ watermark: { type: 'string', description: 'Optional watermark text.' },
4413
+ allow_network: { type: 'boolean', description: 'Allow external network assets when supported. Default false.' },
4414
+ no_confidential: { type: 'boolean', description: 'Suppress the default confidential footer when supported.' },
4415
+ render_preview: { type: 'boolean', description: 'Render first-page preview after generation. Default true.' },
4416
+ },
4417
+ required: ['input_path'],
4418
+ },
4419
+ },
3036
4420
  {
3037
4421
  name: 'clipboard_read',
3038
4422
  description: 'Read the current system clipboard contents.',
@@ -3307,6 +4691,18 @@ const LOCAL_TOOL_DEFINITIONS = [
3307
4691
  required: ['url'],
3308
4692
  },
3309
4693
  },
4694
+ {
4695
+ name: 'web_search',
4696
+ description: 'Search the public web (DuckDuckGo) and return result titles, URLs, and snippets. Use to discover documentation or error-message references when you do not know the URL; then read the page with web_fetch.',
4697
+ input_schema: {
4698
+ type: 'object',
4699
+ properties: {
4700
+ query: { type: 'string', description: 'Search query' },
4701
+ max_results: { type: 'number', description: 'Max results to return (default 8, max 20)' },
4702
+ },
4703
+ required: ['query'],
4704
+ },
4705
+ },
3310
4706
  {
3311
4707
  name: 'glean_search',
3312
4708
  description: 'Search company documents, wikis, Google Docs, Confluence, Jira, and other connected sources via Glean. Use when the user asks to "search docs", "find a document", "look up X in our wiki", "search Glean", or needs information from internal company knowledge bases.',
@@ -3412,12 +4808,22 @@ async function executeLocalTool(name, input) {
3412
4808
  }
3413
4809
 
3414
4810
  switch (name) {
3415
- case 'run_shell': return runShell({ command: input.command, args: input.args, timeout_ms: input.timeout_ms, cwd: input.cwd });
4811
+ case 'run_shell': return runShell({ command: input.command, args: input.args, timeout_ms: input.timeout_ms, cwd: input.cwd, background: input.background, sessionId: input.sessionId || input.session_id, persist: input.persist });
4812
+ case 'bg_output': return bgOutput(input);
4813
+ case 'bg_kill': return bgKill(input);
3416
4814
  case 'read_file': return readFile(input.file_path, { max_bytes: input.max_bytes, offset: input.offset, limit: input.limit, sessionId: input.sessionId, projectRoot: input.projectRoot });
3417
4815
  case 'write_file': return writeFile(input.file_path, input.content, { sessionId: input.sessionId, projectRoot: input.projectRoot });
3418
4816
  case 'search_files': return searchFiles(input.query, input);
3419
4817
  case 'screenshot': return takeScreenshot(input);
3420
4818
  case 'browser_screenshot': return browserScreenshot(input);
4819
+ case 'browser_smoke_test': return browserSmokeTest(input);
4820
+ case 'check_url': return checkUrl(input);
4821
+ case 'start_static_server': return startStaticServer(input);
4822
+ case 'stop_static_server': return stopStaticServer(input);
4823
+ case 'pdf_info': return pdfInfo(input);
4824
+ case 'pdf_render_pages': return pdfRenderPages(input);
4825
+ case 'pdf_read_pages': return pdfReadPages(input);
4826
+ case 'make_pdf': return makePdf(input);
3421
4827
  case 'clipboard_read': return clipboardRead();
3422
4828
  case 'clipboard_write': return clipboardWrite(input.text);
3423
4829
  case 'open_url': return openUrl(input.url);
@@ -3439,6 +4845,7 @@ async function executeLocalTool(name, input) {
3439
4845
  case 'drive_read': return driveRead(input);
3440
4846
  case 'system_info': return getSystemInfo();
3441
4847
  case 'web_fetch': return webFetch(input.url, input);
4848
+ case 'web_search': return webSearch(input);
3442
4849
  case 'glean_search': return gleanSearch(input);
3443
4850
  case 'glean_people': return gleanPeople(input);
3444
4851
  case 'glean_chat': return gleanChat(input);
@@ -3918,9 +5325,14 @@ module.exports = {
3918
5325
  executeLocalTool,
3919
5326
  LOCAL_TOOL_DEFINITIONS,
3920
5327
  // Individual exports for testing
3921
- runShell, readFile, writeFile, searchFiles,
5328
+ runShell, runShellBackground, bgOutput, bgKill, cleanupBackgroundProcesses,
5329
+ webSearch, parseDuckDuckGoResults,
5330
+ readFile, writeFile, searchFiles,
3922
5331
  runAppleScript, sendNotification, takeScreenshot,
3923
- browserScreenshot, findChromeExecutable, BROWSER_VIEWPORTS, CHROME_CANDIDATES,
5332
+ browserScreenshot, browserSmokeTest, findChromeExecutable, BROWSER_VIEWPORTS, CHROME_CANDIDATES,
5333
+ checkUrl, startStaticServer, stopStaticServer,
5334
+ pdfInfo, pdfRenderPages, pdfReadPages, makePdf,
5335
+ parsePdfPageRange, resolvePdfOutputDir, findMakePdfBinary, makePdfBinaryCandidates,
3924
5336
  clipboardRead, clipboardWrite, openUrl, openApp,
3925
5337
  getCalendarEvents, listCalendars, createCalendarEvent, createReminder,
3926
5338
  getMailMessages, readMailMessage, searchMail, mailAttachments, downloadMailAttachment, sendMail, sendMailReply,