codeply-cli 0.5.5 → 0.6.0

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 (429) hide show
  1. package/LICENSE +21 -21
  2. package/THIRD_PARTY_NOTICES.md +65 -65
  3. package/bin/codeply.js +8 -3
  4. package/lib/agent.mjs +2596 -2268
  5. package/lib/ai.js +135 -27
  6. package/lib/ai.mjs +6 -6
  7. package/lib/apply-patch.js +217 -217
  8. package/lib/arity-table.json +138 -138
  9. package/lib/arity.js +70 -70
  10. package/lib/auth.mjs +6 -6
  11. package/lib/autopilot.js +84 -0
  12. package/lib/bot-mail.js +46 -0
  13. package/lib/bots.js +142 -15
  14. package/lib/brain.js +914 -0
  15. package/lib/call-assist.js +120 -0
  16. package/lib/chatgpt.js +282 -282
  17. package/lib/cloud-browser.mjs +143 -143
  18. package/lib/cloud.mjs +1396 -1312
  19. package/lib/commands.js +103 -103
  20. package/lib/computer.js +136 -0
  21. package/lib/design-library/assemble-artifact.mjs +21 -21
  22. package/lib/design-library/cleanup.mjs +45 -45
  23. package/lib/design-library/index.json +22379 -22379
  24. package/lib/design-library/make-table-data.mjs +39 -39
  25. package/lib/design-tools.mjs +173 -0
  26. package/lib/diagnostics.js +192 -192
  27. package/lib/dsh-acp.mjs +444 -0
  28. package/lib/edit-engine.js +364 -364
  29. package/lib/github-agent.mjs +326 -326
  30. package/lib/handoff.mjs +245 -0
  31. package/lib/history.js +78 -75
  32. package/lib/life-sources.js +319 -0
  33. package/lib/life-store.js +356 -0
  34. package/lib/life-triggers.js +286 -0
  35. package/lib/life.js +487 -0
  36. package/lib/mail-watch.js +3 -3
  37. package/lib/mcp.js +258 -258
  38. package/lib/native-tools.mjs +17 -5
  39. package/lib/oauth-connectors.js +76 -21
  40. package/lib/permissions.js +109 -109
  41. package/lib/plugins.js +356 -353
  42. package/lib/reach.js +67 -67
  43. package/lib/render-md.mjs +647 -617
  44. package/lib/screen.js +77 -0
  45. package/lib/server.mjs +458 -457
  46. package/lib/session-db.js +220 -220
  47. package/lib/share.js +208 -168
  48. package/lib/skills.js +4 -1
  49. package/lib/snapshot.js +146 -146
  50. package/lib/subagents.js +4 -2
  51. package/lib/telemetry.js +105 -105
  52. package/lib/terminal-hints.js +154 -154
  53. package/lib/textarea.mjs +264 -258
  54. package/lib/tools.mjs +326 -40
  55. package/lib/tui.mjs +1459 -1086
  56. package/lib/typecheck-worker.js +147 -147
  57. package/lib/web-tools.js +209 -209
  58. package/package.json +1 -1
  59. package/skills/SOURCE.md +58 -58
  60. package/skills/accessibility/SKILL.md +147 -147
  61. package/skills/agent-architecture-audit/SKILL.md +257 -257
  62. package/skills/agent-eval/SKILL.md +147 -147
  63. package/skills/agent-harness-construction/SKILL.md +74 -74
  64. package/skills/agent-introspection-debugging/SKILL.md +154 -154
  65. package/skills/agent-payment-x402/SKILL.md +225 -225
  66. package/skills/agent-self-evaluation/SKILL.md +182 -182
  67. package/skills/agent-self-evaluation/references/evaluation-criteria.md +71 -71
  68. package/skills/agent-self-evaluation/references/hook-integration.md +64 -64
  69. package/skills/agent-sort/SKILL.md +216 -216
  70. package/skills/agentic-engineering/SKILL.md +64 -64
  71. package/skills/agentic-os/SKILL.md +388 -388
  72. package/skills/ai-first-engineering/SKILL.md +52 -52
  73. package/skills/ai-regression-testing/SKILL.md +386 -386
  74. package/skills/android-clean-architecture/SKILL.md +340 -340
  75. package/skills/angular-developer/SKILL.md +155 -155
  76. package/skills/angular-developer/references/angular-animations.md +160 -160
  77. package/skills/angular-developer/references/angular-aria.md +410 -410
  78. package/skills/angular-developer/references/cli.md +86 -86
  79. package/skills/angular-developer/references/component-harnesses.md +59 -59
  80. package/skills/angular-developer/references/component-styling.md +91 -91
  81. package/skills/angular-developer/references/components.md +117 -117
  82. package/skills/angular-developer/references/creating-services.md +97 -97
  83. package/skills/angular-developer/references/data-resolvers.md +69 -69
  84. package/skills/angular-developer/references/define-routes.md +67 -67
  85. package/skills/angular-developer/references/defining-providers.md +72 -72
  86. package/skills/angular-developer/references/di-fundamentals.md +120 -120
  87. package/skills/angular-developer/references/e2e-testing.md +56 -56
  88. package/skills/angular-developer/references/effects.md +83 -83
  89. package/skills/angular-developer/references/hierarchical-injectors.md +43 -43
  90. package/skills/angular-developer/references/host-elements.md +80 -80
  91. package/skills/angular-developer/references/injection-context.md +63 -63
  92. package/skills/angular-developer/references/inputs.md +101 -101
  93. package/skills/angular-developer/references/linked-signal.md +59 -59
  94. package/skills/angular-developer/references/loading-strategies.md +61 -61
  95. package/skills/angular-developer/references/mcp.md +108 -108
  96. package/skills/angular-developer/references/navigate-to-routes.md +69 -69
  97. package/skills/angular-developer/references/outputs.md +86 -86
  98. package/skills/angular-developer/references/reactive-forms.md +122 -122
  99. package/skills/angular-developer/references/rendering-strategies.md +44 -44
  100. package/skills/angular-developer/references/resource.md +77 -77
  101. package/skills/angular-developer/references/route-animations.md +56 -56
  102. package/skills/angular-developer/references/route-guards.md +52 -52
  103. package/skills/angular-developer/references/router-lifecycle.md +45 -45
  104. package/skills/angular-developer/references/router-testing.md +87 -87
  105. package/skills/angular-developer/references/show-routes-with-outlets.md +68 -68
  106. package/skills/angular-developer/references/signal-forms.md +795 -795
  107. package/skills/angular-developer/references/signals-overview.md +94 -94
  108. package/skills/angular-developer/references/tailwind-css.md +69 -69
  109. package/skills/angular-developer/references/template-driven-forms.md +114 -114
  110. package/skills/angular-developer/references/testing-fundamentals.md +65 -65
  111. package/skills/api-connector-builder/SKILL.md +121 -121
  112. package/skills/api-design/SKILL.md +524 -524
  113. package/skills/architecture-decision-records/SKILL.md +180 -180
  114. package/skills/article-writing/SKILL.md +80 -80
  115. package/skills/automation-audit-ops/SKILL.md +143 -143
  116. package/skills/autonomous-agent-harness/SKILL.md +274 -274
  117. package/skills/autonomous-loops/SKILL.md +611 -611
  118. package/skills/backend-patterns/SKILL.md +562 -562
  119. package/skills/benchmark/SKILL.md +95 -95
  120. package/skills/benchmark-methodology/SKILL.md +191 -191
  121. package/skills/benchmark-optimization-loop/SKILL.md +71 -71
  122. package/skills/blender-motion-state-inspection/SKILL.md +165 -165
  123. package/skills/blueprint/SKILL.md +106 -106
  124. package/skills/brand-discovery/SKILL.md +145 -145
  125. package/skills/brand-discovery/references/10_purpose-why.md +40 -40
  126. package/skills/brand-discovery/references/20_positioning.md +44 -44
  127. package/skills/brand-discovery/references/30_audience-niche.md +52 -52
  128. package/skills/brand-discovery/references/40_personality-archetype.md +57 -57
  129. package/skills/brand-discovery/references/50_voice-tone.md +59 -59
  130. package/skills/brand-discovery/references/60_narrative-story.md +50 -50
  131. package/skills/brand-discovery/references/70_founder-tension.md +49 -49
  132. package/skills/brand-discovery/references/90_SYNTHESIS.md +133 -133
  133. package/skills/brand-voice/SKILL.md +98 -98
  134. package/skills/brand-voice/references/voice-profile-schema.md +55 -55
  135. package/skills/browser-qa/SKILL.md +105 -105
  136. package/skills/bun-runtime/SKILL.md +85 -85
  137. package/skills/canary-watch/SKILL.md +108 -108
  138. package/skills/carrier-relationship-management/SKILL.md +212 -212
  139. package/skills/cisco-ios-patterns/SKILL.md +164 -164
  140. package/skills/ck/SKILL.md +148 -148
  141. package/skills/claude-devfleet/SKILL.md +112 -112
  142. package/skills/click-path-audit/SKILL.md +245 -245
  143. package/skills/clickhouse-io/SKILL.md +445 -445
  144. package/skills/code-tour/SKILL.md +254 -254
  145. package/skills/codebase-onboarding/SKILL.md +234 -234
  146. package/skills/codehealth-mcp/SKILL.md +167 -167
  147. package/skills/codeply-taste/LICENSE +21 -0
  148. package/skills/codeply-taste/SKILL.md +100 -0
  149. package/skills/codeply-taste/SOURCE.md +10 -0
  150. package/skills/codeply-taste/assets/base.css +100 -0
  151. package/skills/codeply-taste/assets/base.js +37 -0
  152. package/skills/codeply-taste/library/directions.json +1078 -0
  153. package/skills/codeply-taste/library/heroes.json +894 -0
  154. package/skills/codeply-taste/library/motions.json +57 -0
  155. package/skills/codeply-taste/library/palettes.json +1485 -0
  156. package/skills/codeply-taste/library/photos.json +7240 -0
  157. package/skills/codeply-taste/library/sections.json +4136 -0
  158. package/skills/codeply-taste/library/sites.json +7274 -0
  159. package/skills/codeply-taste/library/type.json +2024 -0
  160. package/skills/codeply-taste/references/colour.md +141 -0
  161. package/skills/codeply-taste/references/copy.md +177 -0
  162. package/skills/codeply-taste/references/critique.md +171 -0
  163. package/skills/codeply-taste/references/imagery.md +377 -0
  164. package/skills/codeply-taste/references/layout.md +178 -0
  165. package/skills/codeply-taste/references/motion.md +292 -0
  166. package/skills/codeply-taste/references/research.md +306 -0
  167. package/skills/codeply-taste/references/tells.md +115 -0
  168. package/skills/codeply-taste/references/type.md +170 -0
  169. package/skills/codeply-taste/scripts/assets.mjs +865 -0
  170. package/skills/codeply-taste/scripts/draw.mjs +525 -0
  171. package/skills/codeply-taste/scripts/inspect.mjs +205 -0
  172. package/skills/codeply-taste/scripts/lib/cdp.mjs +205 -0
  173. package/skills/codeply-taste/scripts/lib/probe.mjs +569 -0
  174. package/skills/codeply-taste/scripts/lib/rules.mjs +137 -0
  175. package/skills/codeply-taste/scripts/lint.mjs +108 -0
  176. package/skills/coding-standards/SKILL.md +551 -551
  177. package/skills/competitive-platform-analysis/SKILL.md +214 -214
  178. package/skills/competitive-report-structure/SKILL.md +162 -162
  179. package/skills/compose-multiplatform-patterns/SKILL.md +300 -300
  180. package/skills/config-gc/SKILL.md +120 -120
  181. package/skills/configure-ecc/SKILL.md +385 -385
  182. package/skills/connections-optimizer/SKILL.md +190 -190
  183. package/skills/content-engine/SKILL.md +132 -132
  184. package/skills/content-hash-cache-pattern/SKILL.md +162 -162
  185. package/skills/context-budget/SKILL.md +136 -136
  186. package/skills/continuous-agent-loop/SKILL.md +46 -46
  187. package/skills/continuous-learning/SKILL.md +132 -132
  188. package/skills/continuous-learning-v2/SKILL.md +361 -361
  189. package/skills/contract-first/SKILL.md +287 -287
  190. package/skills/cost-aware-llm-pipeline/SKILL.md +184 -184
  191. package/skills/cost-tracking/SKILL.md +97 -97
  192. package/skills/council/SKILL.md +204 -204
  193. package/skills/cpp-coding-standards/SKILL.md +724 -724
  194. package/skills/cpp-testing/SKILL.md +325 -325
  195. package/skills/crosspost/SKILL.md +112 -112
  196. package/skills/csharp-testing/SKILL.md +322 -322
  197. package/skills/customer-billing-ops/SKILL.md +141 -141
  198. package/skills/customs-trade-compliance/SKILL.md +263 -263
  199. package/skills/dart-flutter-patterns/SKILL.md +564 -564
  200. package/skills/dashboard-builder/SKILL.md +109 -109
  201. package/skills/data-scraper-agent/SKILL.md +765 -765
  202. package/skills/data-throughput-accelerator/SKILL.md +74 -74
  203. package/skills/database-migrations/SKILL.md +430 -430
  204. package/skills/deep-research/SKILL.md +160 -160
  205. package/skills/defi-amm-security/SKILL.md +167 -167
  206. package/skills/delivery-gate/SKILL.md +126 -126
  207. package/skills/deployment-patterns/SKILL.md +428 -428
  208. package/skills/design-system/SKILL.md +83 -83
  209. package/skills/django-celery/SKILL.md +458 -458
  210. package/skills/django-patterns/SKILL.md +735 -735
  211. package/skills/django-security/SKILL.md +644 -644
  212. package/skills/django-tdd/SKILL.md +730 -730
  213. package/skills/django-verification/SKILL.md +470 -470
  214. package/skills/dmux-workflows/SKILL.md +192 -192
  215. package/skills/docker-patterns/SKILL.md +365 -365
  216. package/skills/documentation-lookup/SKILL.md +91 -91
  217. package/skills/dotnet-patterns/SKILL.md +322 -322
  218. package/skills/dynamic-workflow-mode/SKILL.md +124 -124
  219. package/skills/e2e-testing/SKILL.md +327 -327
  220. package/skills/ecc-guide/SKILL.md +190 -190
  221. package/skills/ecc-recipes/SKILL.md +149 -149
  222. package/skills/ecc-tools-cost-audit/SKILL.md +161 -161
  223. package/skills/email-ops/SKILL.md +122 -122
  224. package/skills/energy-procurement/SKILL.md +228 -228
  225. package/skills/enterprise-agent-ops/SKILL.md +51 -51
  226. package/skills/error-handling/SKILL.md +377 -377
  227. package/skills/eval-harness/SKILL.md +271 -271
  228. package/skills/evm-token-decimals/SKILL.md +131 -131
  229. package/skills/exa-search/SKILL.md +108 -108
  230. package/skills/fal-ai-media/SKILL.md +289 -289
  231. package/skills/fastapi-patterns/SKILL.md +514 -514
  232. package/skills/finance-billing-ops/SKILL.md +128 -128
  233. package/skills/flox-environments/SKILL.md +497 -497
  234. package/skills/flutter-dart-code-review/SKILL.md +436 -436
  235. package/skills/foundation-models-on-device/SKILL.md +243 -243
  236. package/skills/frontend-a11y/SKILL.md +446 -446
  237. package/skills/frontend-design-direction/SKILL.md +93 -93
  238. package/skills/frontend-patterns/SKILL.md +657 -657
  239. package/skills/frontend-slides/SKILL.md +185 -185
  240. package/skills/fsharp-testing/SKILL.md +281 -281
  241. package/skills/gan-style-harness/SKILL.md +279 -279
  242. package/skills/gateguard/SKILL.md +133 -133
  243. package/skills/generating-python-installer/SKILL.md +820 -820
  244. package/skills/git-workflow/SKILL.md +716 -716
  245. package/skills/github-ops/SKILL.md +145 -145
  246. package/skills/golang-patterns/SKILL.md +676 -676
  247. package/skills/golang-testing/SKILL.md +721 -721
  248. package/skills/google-workspace-ops/SKILL.md +96 -96
  249. package/skills/growth-log/SKILL.md +128 -128
  250. package/skills/healthcare-cdss-patterns/SKILL.md +246 -246
  251. package/skills/healthcare-emr-patterns/SKILL.md +160 -160
  252. package/skills/healthcare-eval-harness/SKILL.md +208 -208
  253. package/skills/healthcare-phi-compliance/SKILL.md +146 -146
  254. package/skills/hermes-imports/SKILL.md +89 -89
  255. package/skills/hexagonal-architecture/SKILL.md +277 -277
  256. package/skills/hipaa-compliance/SKILL.md +79 -79
  257. package/skills/homelab-network-readiness/SKILL.md +170 -170
  258. package/skills/homelab-network-setup/SKILL.md +130 -130
  259. package/skills/homelab-pihole-dns/SKILL.md +275 -275
  260. package/skills/homelab-vlan-segmentation/SKILL.md +312 -312
  261. package/skills/homelab-wireguard-vpn/SKILL.md +306 -306
  262. package/skills/hookify-rules/SKILL.md +128 -128
  263. package/skills/inherit-legacy-style/SKILL.md +157 -157
  264. package/skills/intent-driven-development/SKILL.md +360 -360
  265. package/skills/inventory-demand-planning/SKILL.md +247 -247
  266. package/skills/investor-materials/SKILL.md +97 -97
  267. package/skills/investor-outreach/SKILL.md +92 -92
  268. package/skills/ios-icon-gen/SKILL.md +158 -158
  269. package/skills/iterative-retrieval/SKILL.md +212 -212
  270. package/skills/ito-basket-compare/SKILL.md +64 -64
  271. package/skills/ito-compute/SKILL.md +129 -129
  272. package/skills/ito-data-atlas-agent/SKILL.md +64 -64
  273. package/skills/ito-market-intelligence/SKILL.md +61 -61
  274. package/skills/ito-trade-planner/SKILL.md +68 -68
  275. package/skills/java-coding-standards/SKILL.md +384 -384
  276. package/skills/jira-integration/SKILL.md +303 -303
  277. package/skills/jpa-patterns/SKILL.md +152 -152
  278. package/skills/knowledge-ops/SKILL.md +155 -155
  279. package/skills/kotlin-coroutines-flows/SKILL.md +285 -285
  280. package/skills/kotlin-exposed-patterns/SKILL.md +720 -720
  281. package/skills/kotlin-ktor-patterns/SKILL.md +690 -690
  282. package/skills/kotlin-patterns/SKILL.md +712 -712
  283. package/skills/kotlin-testing/SKILL.md +825 -825
  284. package/skills/kubernetes-patterns/SKILL.md +756 -756
  285. package/skills/laravel-patterns/SKILL.md +416 -416
  286. package/skills/laravel-plugin-discovery/SKILL.md +230 -230
  287. package/skills/laravel-security/SKILL.md +948 -948
  288. package/skills/laravel-tdd/SKILL.md +675 -675
  289. package/skills/laravel-verification/SKILL.md +180 -180
  290. package/skills/latency-critical-systems/SKILL.md +75 -75
  291. package/skills/lead-intelligence/SKILL.md +322 -322
  292. package/skills/liquid-glass-design/SKILL.md +279 -279
  293. package/skills/llm-trading-agent-security/SKILL.md +147 -147
  294. package/skills/logistics-exception-management/SKILL.md +222 -222
  295. package/skills/loop-design-check/SKILL.md +143 -143
  296. package/skills/mailtrap-email-integration/SKILL.md +77 -77
  297. package/skills/make-interfaces-feel-better/SKILL.md +152 -152
  298. package/skills/manim-video/SKILL.md +90 -90
  299. package/skills/market-research/SKILL.md +76 -76
  300. package/skills/marketing-campaign/SKILL.md +114 -114
  301. package/skills/mcp-server-patterns/SKILL.md +70 -70
  302. package/skills/messages-ops/SKILL.md +105 -105
  303. package/skills/ml-adoption-playbook/SKILL.md +57 -57
  304. package/skills/mle-workflow/SKILL.md +348 -348
  305. package/skills/motion-advanced/SKILL.md +596 -596
  306. package/skills/motion-foundations/SKILL.md +299 -299
  307. package/skills/motion-patterns/SKILL.md +434 -434
  308. package/skills/motion-ui/SKILL.md +576 -576
  309. package/skills/mysql-patterns/SKILL.md +413 -413
  310. package/skills/nanoclaw-repl/SKILL.md +34 -34
  311. package/skills/nestjs-patterns/SKILL.md +231 -231
  312. package/skills/netmiko-ssh-automation/SKILL.md +174 -174
  313. package/skills/network-bgp-diagnostics/SKILL.md +168 -168
  314. package/skills/network-config-validation/SKILL.md +211 -211
  315. package/skills/network-interface-health/SKILL.md +153 -153
  316. package/skills/nextjs-turbopack/SKILL.md +58 -58
  317. package/skills/nodejs-keccak256/SKILL.md +103 -103
  318. package/skills/nutrient-document-processing/SKILL.md +168 -168
  319. package/skills/nuxt4-patterns/SKILL.md +101 -101
  320. package/skills/openclaw-persona-forge/SKILL.md +289 -289
  321. package/skills/openclaw-persona-forge/references/avatar-style.md +124 -124
  322. package/skills/openclaw-persona-forge/references/boundary-rules.md +53 -53
  323. package/skills/openclaw-persona-forge/references/error-handling.md +53 -53
  324. package/skills/openclaw-persona-forge/references/identity-tension.md +48 -48
  325. package/skills/openclaw-persona-forge/references/naming-system.md +39 -39
  326. package/skills/openclaw-persona-forge/references/output-template.md +166 -166
  327. package/skills/opensource-pipeline/SKILL.md +256 -256
  328. package/skills/orch-add-feature/SKILL.md +45 -45
  329. package/skills/orch-build-mvp/SKILL.md +49 -49
  330. package/skills/orch-change-feature/SKILL.md +43 -43
  331. package/skills/orch-fix-defect/SKILL.md +43 -43
  332. package/skills/orch-pipeline/SKILL.md +121 -121
  333. package/skills/orch-refine-code/SKILL.md +44 -44
  334. package/skills/parallel-execution-optimizer/SKILL.md +74 -74
  335. package/skills/perl-patterns/SKILL.md +505 -505
  336. package/skills/perl-security/SKILL.md +504 -504
  337. package/skills/perl-testing/SKILL.md +476 -476
  338. package/skills/plan-canvas/SKILL.md +153 -153
  339. package/skills/plan-orchestrate/SKILL.md +263 -263
  340. package/skills/plankton-code-quality/SKILL.md +237 -237
  341. package/skills/postgres-patterns/SKILL.md +148 -148
  342. package/skills/prediction-market-oracle-research/SKILL.md +64 -64
  343. package/skills/prediction-market-risk-review/SKILL.md +61 -61
  344. package/skills/premium-web-design/SKILL.md +154 -154
  345. package/skills/prisma-patterns/SKILL.md +401 -401
  346. package/skills/product-capability/SKILL.md +142 -142
  347. package/skills/product-lens/SKILL.md +93 -93
  348. package/skills/production-audit/SKILL.md +207 -207
  349. package/skills/production-scheduling/SKILL.md +238 -238
  350. package/skills/project-flow-ops/SKILL.md +112 -112
  351. package/skills/prompt-optimizer/SKILL.md +398 -398
  352. package/skills/python-patterns/SKILL.md +751 -751
  353. package/skills/python-testing/SKILL.md +817 -817
  354. package/skills/pytorch-patterns/SKILL.md +397 -397
  355. package/skills/quality-nonconformance/SKILL.md +260 -260
  356. package/skills/quarkus-patterns/SKILL.md +723 -723
  357. package/skills/quarkus-security/SKILL.md +468 -468
  358. package/skills/quarkus-tdd/SKILL.md +812 -812
  359. package/skills/quarkus-verification/SKILL.md +480 -480
  360. package/skills/ralphinho-rfc-pipeline/SKILL.md +68 -68
  361. package/skills/react-native-patterns/SKILL.md +326 -326
  362. package/skills/react-patterns/SKILL.md +342 -342
  363. package/skills/react-performance/SKILL.md +575 -575
  364. package/skills/react-testing/SKILL.md +424 -424
  365. package/skills/recsys-pipeline-architect/SKILL.md +115 -115
  366. package/skills/recursive-decision-ledger/SKILL.md +81 -81
  367. package/skills/redis-patterns/SKILL.md +404 -404
  368. package/skills/regex-vs-llm-structured-text/SKILL.md +221 -221
  369. package/skills/remotion-video-creation/SKILL.md +43 -43
  370. package/skills/repo-scan/SKILL.md +79 -79
  371. package/skills/research-ops/SKILL.md +113 -113
  372. package/skills/returns-reverse-logistics/SKILL.md +240 -240
  373. package/skills/rules-distill/SKILL.md +265 -265
  374. package/skills/rust-patterns/SKILL.md +500 -500
  375. package/skills/rust-testing/SKILL.md +501 -501
  376. package/skills/safety-guard/SKILL.md +76 -76
  377. package/skills/santa-method/SKILL.md +307 -307
  378. package/skills/scientific-db-pubmed-database/SKILL.md +176 -176
  379. package/skills/scientific-db-uspto-database/SKILL.md +178 -178
  380. package/skills/scientific-pkg-gget/SKILL.md +167 -167
  381. package/skills/scientific-thinking-literature-review/SKILL.md +193 -193
  382. package/skills/scientific-thinking-scholar-evaluation/SKILL.md +161 -161
  383. package/skills/search-first/SKILL.md +183 -183
  384. package/skills/security-bounty-hunter/SKILL.md +100 -100
  385. package/skills/security-review/SKILL.md +504 -504
  386. package/skills/security-scan/SKILL.md +166 -166
  387. package/skills/seo/SKILL.md +155 -155
  388. package/skills/skill-comply/SKILL.md +59 -59
  389. package/skills/skill-scout/SKILL.md +141 -141
  390. package/skills/skill-stocktake/SKILL.md +195 -195
  391. package/skills/social-graph-ranker/SKILL.md +155 -155
  392. package/skills/social-publisher/SKILL.md +130 -130
  393. package/skills/springboot-patterns/SKILL.md +315 -315
  394. package/skills/springboot-security/SKILL.md +273 -273
  395. package/skills/springboot-tdd/SKILL.md +159 -159
  396. package/skills/springboot-verification/SKILL.md +232 -232
  397. package/skills/strategic-compact/SKILL.md +142 -142
  398. package/skills/swift-actor-persistence/SKILL.md +144 -144
  399. package/skills/swift-concurrency-6-2/SKILL.md +216 -216
  400. package/skills/swift-protocol-di-testing/SKILL.md +191 -191
  401. package/skills/swiftui-patterns/SKILL.md +259 -259
  402. package/skills/taste/SKILL.md +264 -264
  403. package/skills/taste/references/genre-taxonomy.md +87 -87
  404. package/skills/tdd-workflow/SKILL.md +583 -583
  405. package/skills/team-agent-orchestration/SKILL.md +111 -111
  406. package/skills/team-builder/SKILL.md +169 -169
  407. package/skills/terminal-ops/SKILL.md +110 -110
  408. package/skills/tinystruct-patterns/SKILL.md +279 -279
  409. package/skills/tinystruct-patterns/references/architecture.md +90 -90
  410. package/skills/tinystruct-patterns/references/data-handling.md +60 -60
  411. package/skills/tinystruct-patterns/references/database.md +99 -99
  412. package/skills/tinystruct-patterns/references/routing.md +64 -64
  413. package/skills/tinystruct-patterns/references/system-usage.md +97 -97
  414. package/skills/tinystruct-patterns/references/testing.md +72 -72
  415. package/skills/token-budget-advisor/SKILL.md +134 -134
  416. package/skills/ui-demo/SKILL.md +466 -466
  417. package/skills/ui-to-vue/SKILL.md +135 -135
  418. package/skills/uncloud/SKILL.md +344 -344
  419. package/skills/unified-memory/SKILL.md +170 -170
  420. package/skills/unified-notifications-ops/SKILL.md +188 -188
  421. package/skills/verification-loop/SKILL.md +129 -129
  422. package/skills/video-editing/SKILL.md +311 -311
  423. package/skills/videodb/SKILL.md +375 -375
  424. package/skills/visa-doc-translate/SKILL.md +117 -117
  425. package/skills/vite-patterns/SKILL.md +450 -450
  426. package/skills/vue-patterns/SKILL.md +471 -471
  427. package/skills/windows-desktop-e2e/SKILL.md +888 -888
  428. package/skills/workspace-surface-audit/SKILL.md +126 -126
  429. package/skills/x-api/SKILL.md +235 -235
package/lib/cloud.mjs CHANGED
@@ -1,1312 +1,1396 @@
1
- /**
2
- * Craft Cloud: the agent keeps working while this PC is off, on GitHub's
3
- * machines, in the user's own account. Nothing runs on Codeply servers.
4
- *
5
- * mirror A private repo (craft-workspace-<project>) that holds snapshots
6
- * of the project. It is separate from the user's real repo, so a
7
- * cloud run never touches production code.
8
- * snapshot The working tree, pushed to the mirror's main branch through a
9
- * git folder of Craft's own (~/.codeply/cloud/<id>.git). The
10
- * project's own .git is never read or written. .gitignore is
11
- * respected, .env files and keys are always left out, and files
12
- * holding key-like strings are skipped.
13
- * run A workflow_dispatch of craft-cloud.yml. The runner reports
14
- * progress in a check run, pushes code changes to craft/task-<id>,
15
- * and keeps the chat on the craft-sessions branch so the next run
16
- * continues the same conversation.
17
- * pull The task branch's changes, applied back onto the local project
18
- * with a 3-way merge, so edits made meanwhile survive.
19
- *
20
- * The model key lives only in the mirror's encrypted Actions secrets.
21
- */
22
- import fs from 'fs';
23
- import os from 'os';
24
- import path from 'path';
25
- import crypto from 'crypto';
26
- import { createRequire } from 'module';
27
- import { runAgent } from './agent.mjs';
28
- import { git, ciApprove, routeFromEnv } from './github-agent.mjs';
29
-
30
- const require = createRequire(import.meta.url);
31
- const nacl = require('tweetnacl');
32
- const blake = require('blakejs');
33
-
34
- export const WORKFLOW_FILE = 'craft-cloud.yml';
35
- export const WORKFLOW_PATH = `.github/workflows/${WORKFLOW_FILE}`;
36
- export const MIRROR_PREFIX = 'craft-workspace-';
37
- export const MIRROR_DESCRIPTION = 'Craft workspace mirror: a private backup that Craft cloud runs work in.';
38
- export const SESSIONS_BRANCH = 'craft-sessions';
39
- export const ENGINE = 'codeply-cli@0.5';
40
- export const taskBranch = (id) => `craft/task-${id}`;
41
- export const checkName = (id) => `craft ${id}`;
42
-
43
- const MAX_FILE_BYTES = 50 * 1024 * 1024;
44
- const MAX_SCAN_BYTES = 2 * 1024 * 1024;
45
- const MAX_PROMPT = 20000;
46
- const MAX_BOT_PROMPT = 30000;
47
- const MAX_OUTPUT = 60000;
48
- const HISTORY_TURNS = 40;
49
-
50
- export const CLOUD_WORKFLOW = `# Written by Codeply Craft. Starts only from Craft (workflow_dispatch), which
51
- # needs write access to this repo, so nobody else can spend its minutes.
52
- name: craft-cloud
53
- run-name: craft \${{ inputs.task_id }}
54
-
55
- on:
56
- workflow_dispatch:
57
- inputs:
58
- prompt:
59
- description: What to do
60
- required: true
61
- type: string
62
- mode:
63
- description: Build, Plan or Ask
64
- required: false
65
- default: Build
66
- type: string
67
- task_id:
68
- required: true
69
- type: string
70
- session_id:
71
- required: false
72
- default: ''
73
- type: string
74
-
75
- permissions:
76
- contents: write
77
- checks: write
78
-
79
- concurrency:
80
- group: craft-\${{ inputs.session_id || inputs.task_id }}
81
- cancel-in-progress: false
82
-
83
- jobs:
84
- craft:
85
- runs-on: ubuntu-latest
86
- timeout-minutes: 60
87
- steps:
88
- - uses: actions/checkout@v4
89
- with:
90
- fetch-depth: 1
91
- persist-credentials: false
92
- - uses: actions/setup-node@v4
93
- with:
94
- node-version: 22
95
- - name: Run Craft
96
- run: npx -y --package="\${CRAFT_ENGINE:-${ENGINE}}" codeply cloud runner
97
- env:
98
- GITHUB_TOKEN: \${{ secrets.GITHUB_TOKEN }}
99
- CODEPLY_API_KEY: \${{ secrets.CODEPLY_API_KEY }}
100
- CODEPLY_TRACK_KEY: \${{ secrets.CODEPLY_TRACK_KEY }}
101
- CODEPLY_MODEL_KIND: \${{ vars.CODEPLY_MODEL_KIND }}
102
- CODEPLY_MODEL: \${{ vars.CODEPLY_MODEL }}
103
- CODEPLY_BASE_URL: \${{ vars.CODEPLY_BASE_URL }}
104
- CRAFT_ENGINE: \${{ vars.CRAFT_ENGINE }}
105
- CRAFT_ENV: \${{ secrets.CRAFT_ENV }}
106
- CRAFT_MERGE: \${{ vars.CRAFT_MERGE }}
107
- CRAFT_PERMISSIONS: \${{ vars.CRAFT_PERMISSIONS }}
108
- CRAFT_BASE: \${{ github.ref_name }}
109
- CRAFT_PROMPT: \${{ inputs.prompt }}
110
- CRAFT_MODE: \${{ inputs.mode }}
111
- CRAFT_TASK_ID: \${{ inputs.task_id }}
112
- CRAFT_SESSION_ID: \${{ inputs.session_id }}
113
- `;
114
-
115
- // Never mirrored, whatever .gitignore says.
116
- export const ALWAYS_EXCLUDE = [
117
- '.env', '.env.*', '!.env.example', '!.env.sample', '!.env.template',
118
- '*.pem', '*.key', '*.p12', '*.pfx', '*.keystore', '*.jks', '*.mobileprovision',
119
- 'id_rsa*', 'id_dsa*', 'id_ecdsa*', 'id_ed25519*', '.netrc', '.pgpass',
120
- 'node_modules/', '.codeply/', '*.log',
121
- ];
122
-
123
- // High-confidence key formats only: a false alarm leaves a real source file out of the mirror.
124
- const SECRET_PATTERNS = [
125
- ['private key', /-----BEGIN [A-Z ]*PRIVATE KEY-----/],
126
- ['Anthropic key', /\bsk-ant-[A-Za-z0-9_-]{20,}/],
127
- ['OpenAI key', /\bsk-(?:proj-|svcacct-)?[A-Za-z0-9_-]{32,}/],
128
- ['GitHub token', /\b(?:gh[pousr]_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{30,})/],
129
- ['Slack token', /\bxox[abprs]-[A-Za-z0-9-]{10,}/],
130
- ['AWS key', /\bAKIA[0-9A-Z]{16}\b/],
131
- ['Google key', /\bAIza[0-9A-Za-z_-]{35}\b/],
132
- ['Stripe key', /\b[sr]k_live_[A-Za-z0-9]{20,}/],
133
- ['npm token', /\bnpm_[A-Za-z0-9]{36}\b/],
134
- ['Groq key', /\bgsk_[A-Za-z0-9]{40,}/],
135
- ['OpenRouter key', /\bsk-or-v1-[a-f0-9]{40,}/],
136
- ];
137
-
138
- /** The first key-like string in a file's text, or null. Supabase anon keys are public; service_role keys are not. */
139
- export function findSecret(text) {
140
- for (const [label, re] of SECRET_PATTERNS) if (re.test(text)) return label;
141
- const jwts = text.match(/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g) || [];
142
- for (const t of jwts) {
143
- try { if (/service_role/.test(Buffer.from(t.split('.')[1], 'base64url').toString('utf8'))) return 'Supabase service_role key'; } catch {}
144
- }
145
- return null;
146
- }
147
-
148
- // ─── Encrypted secrets (libsodium sealed box, what GitHub expects) ─────────
149
-
150
- export function sealSecret(publicKeyB64, value) {
151
- const pk = new Uint8Array(Buffer.from(publicKeyB64, 'base64'));
152
- const eph = nacl.box.keyPair();
153
- const nonceIn = new Uint8Array(eph.publicKey.length + pk.length);
154
- nonceIn.set(eph.publicKey); nonceIn.set(pk, eph.publicKey.length);
155
- const nonce = blake.blake2b(nonceIn, undefined, nacl.box.nonceLength);
156
- const boxed = nacl.box(new Uint8Array(Buffer.from(String(value), 'utf8')), nonce, pk, eph.secretKey);
157
- return Buffer.concat([Buffer.from(eph.publicKey), Buffer.from(boxed)]).toString('base64');
158
- }
159
-
160
- /** Inverse of sealSecret, for tests. */
161
- export function openSealed(sealedB64, publicKey, secretKey) {
162
- const all = new Uint8Array(Buffer.from(sealedB64, 'base64'));
163
- const epk = all.slice(0, 32);
164
- const nonceIn = new Uint8Array(64); nonceIn.set(epk); nonceIn.set(publicKey, 32);
165
- const nonce = blake.blake2b(nonceIn, undefined, nacl.box.nonceLength);
166
- const out = nacl.box.open(all.slice(32), nonce, epk, secretKey);
167
- return out ? Buffer.from(out).toString('utf8') : null;
168
- }
169
-
170
- // ─── GitHub API ──────────────────────────────────────────────────────────
171
-
172
- export function githubApi({ token, apiUrl = 'https://api.github.com', fetchImpl = fetch }) {
173
- const call = async (method, route, body, { allow = [] } = {}) => {
174
- const res = await fetchImpl(`${apiUrl.replace(/\/$/, '')}${route}`, {
175
- method,
176
- headers: { Authorization: `Bearer ${token}`, Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28', 'User-Agent': 'codeply-craft', ...(body ? { 'Content-Type': 'application/json' } : {}) },
177
- body: body ? JSON.stringify(body) : undefined,
178
- });
179
- const text = await res.text();
180
- let json = null;
181
- try { json = text ? JSON.parse(text) : null; } catch {}
182
- if (!res.ok && !allow.includes(res.status)) {
183
- const e = new Error(`GitHub ${method} ${route.split('?')[0]}: ${res.status} ${(json && json.message) || text.slice(0, 200)}`);
184
- e.status = res.status;
185
- throw e;
186
- }
187
- return { status: res.status, json, text, scopes: res.headers && res.headers.get ? res.headers.get('x-oauth-scopes') : null };
188
- };
189
- return call;
190
- }
191
-
192
- /**
193
- * Why a run died before Craft could report back (install failed, time limit,
194
- * permissions), from the job's steps and log. Plain words, or null.
195
- */
196
- export async function diagnoseRun(api, repo, runId) {
197
- try {
198
- const jobs = ((await api('GET', `/repos/${repo}/actions/runs/${runId}/jobs`)).json || {}).jobs || [];
199
- const job = jobs.find((j) => j.conclusion === 'failure' || j.conclusion === 'timed_out') || jobs[0];
200
- if (!job) return null;
201
- if (job.conclusion === 'timed_out') return 'The run hit its 60-minute limit and was stopped.';
202
- const step = (job.steps || []).find((s) => s.conclusion === 'failure');
203
- let log = '';
204
- try { log = (await api('GET', `/repos/${repo}/actions/jobs/${job.id}/logs`)).text || ''; } catch {}
205
- if (/No matching version found for codeply-cli|notarget[\s\S]{0,200}codeply-cli/i.test(log)) {
206
- return `GitHub couldn't install Craft's cloud engine (${ENGINE}): that version isn't on npm yet. Publish it, then run again.`;
207
- }
208
- if (/Resource not accessible by integration/i.test(log)) return 'GitHub refused the run permission to save its work. Check Settings > Actions > General > Workflow permissions on the mirror repo.';
209
- if (/ENOTFOUND|ECONNRESET|ETIMEDOUT|network/i.test(log) && /npm (error|ERR)/i.test(log)) return 'GitHub\'s runner could not download Craft\'s engine (a network hiccup on GitHub\'s side). Run it again.';
210
- const lastError = log.split('\n').map((l) => l.replace(/^\S+Z\s/, '').replace(/\x1b\[[0-9;]*m/g, '').trim())
211
- .filter((l) => /error|failed|fatal/i.test(l) && !/^##\[group\]/.test(l)).slice(-1)[0];
212
- if (step && step.name === 'Run Craft') return `Craft stopped before it could report back${lastError ? `: ${lastError.replace(/^##\[error\]/, '').slice(0, 240)}` : '.'}`;
213
- if (step) return `The "${step.name}" step failed on GitHub${lastError ? `: ${lastError.replace(/^##\[error\]/, '').slice(0, 240)}` : '.'}`;
214
- return null;
215
- } catch { return null; }
216
- }
217
-
218
- // ─── Local state ─────────────────────────────────────────────────────────
219
-
220
- const defaultHome = () => path.join(os.homedir(), '.codeply');
221
- const projectKey = (cwd) => path.resolve(cwd).replace(/\\/g, '/').replace(/\/$/, '').toLowerCase();
222
- const projectId = (cwd) => crypto.createHash('sha1').update(projectKey(cwd)).digest('hex').slice(0, 16);
223
-
224
- function stateFile(home) { return path.join(home, 'cloud.json'); }
225
- export function readState(home = defaultHome()) {
226
- try { const s = JSON.parse(fs.readFileSync(stateFile(home), 'utf8')); return s && typeof s === 'object' && s.projects ? s : { projects: {} }; } catch { return { projects: {} }; }
227
- }
228
- function writeState(home, state) {
229
- fs.mkdirSync(home, { recursive: true });
230
- const tmp = `${stateFile(home)}.tmp`;
231
- fs.writeFileSync(tmp, JSON.stringify(state, null, 2));
232
- fs.renameSync(tmp, stateFile(home));
233
- }
234
- export function getProject(cwd, home = defaultHome()) { return readState(home).projects[projectKey(cwd)] || null; }
235
- function updateProject(cwd, home, fn) {
236
- const state = readState(home);
237
- const key = projectKey(cwd);
238
- state.projects[key] = fn(state.projects[key] || { cwd: path.resolve(cwd), tasks: [] });
239
- writeState(home, state);
240
- return state.projects[key];
241
- }
242
- function saveTask(cwd, home, task) {
243
- return updateProject(cwd, home, (p) => {
244
- const tasks = (p.tasks || []).filter((t) => t.id !== task.id);
245
- tasks.unshift(task);
246
- return { ...p, tasks: tasks.slice(0, 100) };
247
- });
248
- }
249
- /** Per-project switches the app keeps: cloudOn (send new messages to the cloud), autoBackup. */
250
- export function setProjectOptions(cwd, opts, home = defaultHome()) {
251
- const allowed = {};
252
- for (const k of ['cloudOn', 'autoBackup']) if (typeof opts[k] === 'boolean') allowed[k] = opts[k];
253
- return updateProject(cwd, home, (p) => ({ ...p, ...allowed }));
254
- }
255
-
256
- export function getTask(cwd, id, home = defaultHome()) {
257
- const p = getProject(cwd, home);
258
- return p && (p.tasks || []).find((t) => t.id === id) || null;
259
- }
260
-
261
- // ─── Snapshots ───────────────────────────────────────────────────────────
262
-
263
- const BOT = ['-c', 'user.name=Codeply Craft', '-c', 'user.email=craft-bot@users.noreply.github.com'];
264
-
265
- function mirrorGit(cwd, home) {
266
- const gitDir = path.join(home, 'cloud', `${projectId(cwd)}.git`);
267
- const G = (args, opts) => git(cwd, ['--git-dir', gitDir, '--work-tree', cwd, ...args], opts);
268
- return { gitDir, G };
269
- }
270
-
271
- async function initMirrorGit(cwd, home) {
272
- const m = mirrorGit(cwd, home);
273
- if (!fs.existsSync(path.join(m.gitDir, 'HEAD'))) {
274
- fs.mkdirSync(m.gitDir, { recursive: true });
275
- await git(cwd, ['init', '-q', '--bare', m.gitDir]);
276
- await m.G(['config', 'core.bare', 'false']);
277
- await m.G(['config', 'core.autocrlf', 'false']);
278
- await m.G(['config', 'core.quotepath', 'false']);
279
- await m.G(['symbolic-ref', 'HEAD', 'refs/heads/main']);
280
- }
281
- fs.mkdirSync(path.join(m.gitDir, 'info'), { recursive: true });
282
- fs.writeFileSync(path.join(m.gitDir, 'info', 'exclude'), `${ALWAYS_EXCLUDE.join('\n')}\n`);
283
- return m;
284
- }
285
-
286
- /**
287
- * Stage the working tree into Craft's own index: .gitignore and ALWAYS_EXCLUDE
288
- * respected, the project's own workflows left out (they must not run on the
289
- * mirror), big files and files holding keys dropped, craft-cloud.yml added.
290
- */
291
- async function stageWorkingTree(cwd, home) {
292
- const m = await initMirrorGit(cwd, home);
293
- const { G } = m;
294
- await G(['add', '-A', '--', '.']);
295
- await G(['rm', '-r', '-q', '--cached', '--ignore-unmatch', '--', '.github/workflows']);
296
- const hasHead = await G(['rev-parse', '-q', '--verify', 'refs/heads/main']).then(() => true, () => false);
297
- const listed = hasHead
298
- ? await G(['diff', '--cached', '--name-only', '--diff-filter=AM', '-z', 'refs/heads/main'])
299
- : await G(['ls-files', '-z', '--cached']);
300
- const skipped = [];
301
- for (const rel of listed.split('\0').filter(Boolean)) {
302
- const full = path.join(cwd, rel);
303
- let reason = null;
304
- try {
305
- const st = fs.statSync(full);
306
- if (st.size > MAX_FILE_BYTES) reason = 'over 50 MB';
307
- else if (st.size <= MAX_SCAN_BYTES) {
308
- const buf = fs.readFileSync(full);
309
- if (!buf.subarray(0, 8000).includes(0)) {
310
- const found = findSecret(buf.toString('utf8'));
311
- if (found) reason = `holds a ${found}`;
312
- }
313
- }
314
- } catch { continue; }
315
- if (reason) {
316
- await G(['rm', '-q', '--cached', '--', rel]);
317
- skipped.push({ path: rel.replace(/\\/g, '/'), reason });
318
- }
319
- }
320
- const blob = await git(cwd, ['--git-dir', m.gitDir, 'hash-object', '-w', '--stdin'], { input: CLOUD_WORKFLOW });
321
- await G(['update-index', '--add', '--cacheinfo', `100644,${blob},${WORKFLOW_PATH}`]);
322
- return { ...m, hasHead, skipped };
323
- }
324
-
325
- /** Commit the working tree to the local mirror git (no network). */
326
- export async function snapshot({ cwd, home = defaultHome() }) {
327
- const s = await stageWorkingTree(cwd, home);
328
- const tree = await s.G(['write-tree']);
329
- const head = s.hasHead ? await s.G(['rev-parse', 'refs/heads/main']) : null;
330
- if (head && (await s.G(['rev-parse', `${head}^{tree}`])) === tree) return { gitDir: s.gitDir, sha: head, changed: false, skipped: s.skipped };
331
- const sha = await git(cwd, ['--git-dir', s.gitDir, ...BOT, 'commit-tree', tree, ...(head ? ['-p', head] : []), '-m', `Snapshot from ${os.hostname()} at ${new Date().toISOString()}`]);
332
- await s.G(['update-ref', 'refs/heads/main', sha]);
333
- return { gitDir: s.gitDir, sha, changed: true, skipped: s.skipped };
334
- }
335
-
336
- const authFor = (token, serverUrl = 'https://github.com') => ({ token, server: serverUrl.replace(/\/$/, '') });
337
-
338
- /** Snapshot, then push it to the mirror. The mirror's main is Craft's, so it is force-pushed. */
339
- export async function pushSnapshot({ cwd, token, home = defaultHome(), serverUrl = 'https://github.com', remoteUrl }) {
340
- const p = getProject(cwd, home);
341
- if (!p || !p.repo) throw new Error('Cloud is not set up for this project yet.');
342
- // A project that works in its own GitHub repo is never snapshotted: that would overwrite its real main.
343
- if (p.kind === 'repo') throw new Error(`${p.repo} is this project's own repo; Craft does not back up over it.`);
344
- const snap = await snapshot({ cwd, home });
345
- if (snap.changed || !p.lastPush || p.lastPush.sha !== snap.sha) {
346
- const url = remoteUrl || `${serverUrl.replace(/\/$/, '')}/${p.repo}.git`;
347
- await git(cwd, ['--git-dir', snap.gitDir, 'push', '-q', '--force', url, 'refs/heads/main:refs/heads/main'], { auth: authFor(token, serverUrl) });
348
- updateProject(cwd, home, (q) => ({ ...q, lastPush: { sha: snap.sha, at: Date.now(), skipped: snap.skipped } }));
349
- return { ...snap, pushed: true };
350
- }
351
- return { ...snap, pushed: false };
352
- }
353
-
354
- // ─── Setup ───────────────────────────────────────────────────────────────
355
-
356
- const slug = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^[-.]+|[-.]+$/g, '').slice(0, 60) || 'project';
357
-
358
- /** Find or create the private mirror repo for a project. */
359
- export async function ensureMirror({ api, cwd, home = defaultHome() }) {
360
- const existing = getProject(cwd, home);
361
- if (existing && existing.repo) {
362
- const r = await api('GET', `/repos/${existing.repo}`, null, { allow: [404] });
363
- if (r.status === 200) return { repo: existing.repo, created: false };
364
- }
365
- const me = (await api('GET', '/user')).json;
366
- const base = `${MIRROR_PREFIX}${slug(path.basename(path.resolve(cwd)))}`;
367
- for (let i = 1; i <= 9; i++) {
368
- const name = i === 1 ? base : `${base}-${i}`;
369
- const r = await api('GET', `/repos/${me.login}/${name}`, null, { allow: [404] });
370
- if (r.status === 200 && r.json.description === MIRROR_DESCRIPTION && r.json.private) {
371
- updateProject(cwd, home, (p) => ({ ...p, repo: r.json.full_name }));
372
- return { repo: r.json.full_name, created: false };
373
- }
374
- if (r.status === 404) {
375
- const made = await api('POST', '/user/repos', { name, private: true, description: MIRROR_DESCRIPTION, auto_init: false, has_issues: false, has_wiki: false, has_projects: false });
376
- updateProject(cwd, home, (p) => ({ ...p, repo: made.json.full_name, createdAt: Date.now() }));
377
- return { repo: made.json.full_name, created: true };
378
- }
379
- }
380
- throw new Error(`Could not find a free name for the mirror (tried ${base} to ${base}-9).`);
381
- }
382
-
383
- /**
384
- * Which model a cloud run uses. Auto (Codeply's router, through the run's usage
385
- * key, counted in the person's allowance) unless they picked one of their own
386
- * models that GitHub can reach; a model on this PC (or with no key) uses Auto.
387
- */
388
- export const AUTO_CLOUD = { kind: 'auto', model: 'Auto', sig: 'auto' };
389
- export function cloudModelConfig(model) {
390
- if (!model || !model.model) return AUTO_CLOUD;
391
- const kind = model.kind === 'ollama' ? 'ollama' : 'openai';
392
- const baseUrl = kind === 'ollama' ? String(model.baseUrl || 'https://ollama.com').replace(/\/+$/, '').replace(/\/(api|v1)$/i, '') : String(model.baseUrl || '').trim();
393
- if (!baseUrl || !model.apiKey) return AUTO_CLOUD;
394
- if (/^https?:\/\/(localhost|127\.|0\.0\.0\.0|\[::1\]|192\.168\.|10\.|172\.(1[6-9]|2\d|3[01])\.)/i.test(baseUrl)) return AUTO_CLOUD;
395
- return { kind, baseUrl, model: model.model, apiKey: model.apiKey, sig: crypto.createHash('sha256').update([kind, baseUrl, model.model, model.apiKey].join('\n')).digest('hex').slice(0, 16) };
396
- }
397
-
398
- async function setVariable(api, repo, name, value) {
399
- const r = await api('PATCH', `/repos/${repo}/actions/variables/${name}`, { name, value }, { allow: [404] });
400
- if (r.status === 404) await api('POST', `/repos/${repo}/actions/variables`, { name, value });
401
- }
402
-
403
- /** Put the model key in the mirror's encrypted secrets and the rest in variables. */
404
- export async function configureModel({ api, repo, model }) {
405
- const cfg = cloudModelConfig(model);
406
- if (cfg.kind === 'auto') {
407
- // Auto: no model key in the repo at all; the runner reaches Codeply's router with its usage key
408
- await api('DELETE', `/repos/${repo}/actions/secrets/CODEPLY_API_KEY`, null, { allow: [404] });
409
- for (const name of ['CODEPLY_BASE_URL', 'CODEPLY_MODEL']) await api('DELETE', `/repos/${repo}/actions/variables/${name}`, null, { allow: [404] });
410
- await setVariable(api, repo, 'CODEPLY_MODEL_KIND', 'auto');
411
- return cfg;
412
- }
413
- const pk = (await api('GET', `/repos/${repo}/actions/secrets/public-key`)).json;
414
- await api('PUT', `/repos/${repo}/actions/secrets/CODEPLY_API_KEY`, { encrypted_value: sealSecret(pk.key, cfg.apiKey), key_id: pk.key_id });
415
- await setVariable(api, repo, 'CODEPLY_MODEL_KIND', cfg.kind);
416
- await setVariable(api, repo, 'CODEPLY_BASE_URL', cfg.baseUrl);
417
- await setVariable(api, repo, 'CODEPLY_MODEL', cfg.model);
418
- return cfg;
419
- }
420
-
421
- /** Check the token can create repos and push workflow files. */
422
- export async function checkToken(api) {
423
- const r = await api('GET', '/user');
424
- const scopes = String(r.scopes || '').split(',').map((s) => s.trim()).filter(Boolean);
425
- // Fine-grained and app tokens send no scope header; let GitHub decide on those.
426
- if (r.scopes != null && scopes.length && !(scopes.includes('repo') && scopes.includes('workflow'))) {
427
- return { ok: false, login: r.json.login, error: 'GitHub needs to be reconnected so Craft may create private repos and workflow files (repo and workflow access).' };
428
- }
429
- return { ok: true, login: r.json.login };
430
- }
431
-
432
- // ─── Targets: the project's own GitHub repo, or a private mirror ─────────
433
-
434
- /** owner/name of the project's GitHub `origin`, or null. */
435
- export async function githubOrigin(cwd) {
436
- try {
437
- const url = await git(cwd, ['remote', 'get-url', 'origin']);
438
- const m = /github\.com[:/]([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/i.exec(url);
439
- return m ? `${m[1]}/${m[2]}` : null;
440
- } catch { return null; }
441
- }
442
-
443
- /** The repos this login can push to (for the phone's and the app's repo picker). */
444
- export async function listMyRepos(api) {
445
- const r = await api('GET', '/user/repos?affiliation=owner,collaborator&sort=pushed&per_page=100');
446
- return (r.json || []).filter((x) => x.permissions && x.permissions.push)
447
- .map((x) => ({ repo: x.full_name, private: x.private, base: x.default_branch, mirror: x.description === MIRROR_DESCRIPTION, pushedAt: x.pushed_at }));
448
- }
449
-
450
- /** Commit craft-cloud.yml to the repo's default branch (only when missing or out of date). */
451
- export async function installWorkflowInRepo({ api, repo, base }) {
452
- const files = branchFiles(api, repo, base);
453
- const cur = await api('GET', `/repos/${repo}/contents/${WORKFLOW_PATH}?ref=${encodeURIComponent(base)}`, null, { allow: [404] });
454
- const want = Buffer.from(CLOUD_WORKFLOW).toString('base64');
455
- if (cur.status === 200 && String(cur.json.content || '').replace(/\s/g, '') === want) return false;
456
- const ok = await files.writeBytes(WORKFLOW_PATH, Buffer.from(CLOUD_WORKFLOW), 'Add Craft Cloud workflow');
457
- if (!ok) throw new Error(`Could not add ${WORKFLOW_PATH} to ${repo}. GitHub needs the workflow permission for that.`);
458
- return true;
459
- }
460
-
461
- /** The project's environment for running and testing (KEY=value lines), as the CRAFT_ENV secret. */
462
- export async function setProjectEnv({ api, repo, envText }) {
463
- const text = String(envText || '').trim();
464
- if (!text) { await api('DELETE', `/repos/${repo}/actions/secrets/CRAFT_ENV`, null, { allow: [404] }); return 0; }
465
- const pk = (await api('GET', `/repos/${repo}/actions/secrets/public-key`)).json;
466
- await api('PUT', `/repos/${repo}/actions/secrets/CRAFT_ENV`, { encrypted_value: sealSecret(pk.key, text), key_id: pk.key_id });
467
- return text.split('\n').filter((l) => /^\s*[A-Za-z_][A-Za-z0-9_]*\s*=/.test(l)).length;
468
- }
469
-
470
- /**
471
- * Usage counts for the admin dashboard from cloud runs: a run has no Codeply
472
- * sign-in, so Craft asks Codeply for a usage key for the signed-in user and puts
473
- * it in the repo's encrypted secrets (CODEPLY_TRACK_KEY). Counts only, like the
474
- * desktop apps. Never fails a setup or a run: without a sign-in, or with
475
- * telemetry off, it does nothing.
476
- */
477
- export async function ensureTrackKey({ api, repo, cwd, home = defaultHome(), fetchImpl = fetch }) {
478
- try {
479
- const p = getProject(cwd, home) || {};
480
- if (p.trackKeyRepo === repo) return false;
481
- // Needed whatever the usage-stats setting: the run reaches Auto and the connected
482
- // apps with it (telemetry.js still sends no stats when they're switched off).
483
- const auth = require('./auth.js');
484
- const token = await auth.getAccessToken().catch(() => null);
485
- if (!token) return false;
486
- const r = await fetchImpl(auth.SUPABASE_URL + '/functions/v1/track', {
487
- method: 'POST',
488
- headers: { Authorization: 'Bearer ' + token, apikey: auth.SUPABASE_ANON_KEY, 'Content-Type': 'application/json' },
489
- body: JSON.stringify({ action: 'mint', label: 'cloud ' + repo }),
490
- signal: AbortSignal.timeout(15_000),
491
- });
492
- const j = await r.json().catch(() => ({}));
493
- if (!r.ok || !j.key) return false;
494
- const pk = (await api('GET', '/repos/' + repo + '/actions/secrets/public-key')).json;
495
- await api('PUT', '/repos/' + repo + '/actions/secrets/CODEPLY_TRACK_KEY', { encrypted_value: sealSecret(pk.key, j.key), key_id: pk.key_id });
496
- updateProject(cwd, home, (q) => ({ ...q, trackKeyRepo: repo }));
497
- return true;
498
- } catch { return false; }
499
- }
500
-
501
- /**
502
- * Connected apps (Gmail, Slack, Vercel, Supabase, GitHub) for cloud runs. A run
503
- * happens on GitHub's servers, which don't have the sign-ins saved on this PC,
504
- * so Craft keeps an encrypted copy on Codeply's server for the signed-in user
505
- * (the connections function) and the runner loads it at start with the usage
506
- * key. Sent only when something changed. Off with "cloudConnections": false in
507
- * ~/.codeply/config.json. Never fails a setup or a run.
508
- */
509
- export const CONNECTION_APPS = ['gmail', 'slack', 'vercel', 'supabase', 'github'];
510
- export async function syncCloudConnections({ home = defaultHome(), fetchImpl = fetch } = {}) {
511
- try {
512
- const config = require('./config.js');
513
- if (config.getConfig().cloudConnections === false) return false;
514
- const connections = {};
515
- for (const name of CONNECTION_APPS) {
516
- const c = config.getIntegration(name); // includes the app's OAuth client, which token refresh needs
517
- if (c.accessToken) connections[name] = c;
518
- }
519
- const sig = crypto.createHash('sha256').update(JSON.stringify(connections)).digest('hex');
520
- const state = readState(home);
521
- if (state.connectionsSig === sig) return false;
522
- const auth = require('./auth.js');
523
- const token = await auth.getAccessToken().catch(() => null);
524
- if (!token) return false;
525
- const r = await fetchImpl(auth.SUPABASE_URL + '/functions/v1/connections', {
526
- method: 'POST',
527
- headers: { Authorization: 'Bearer ' + token, apikey: auth.SUPABASE_ANON_KEY, 'Content-Type': 'application/json' },
528
- body: JSON.stringify({ action: 'save', connections }),
529
- signal: AbortSignal.timeout(15_000),
530
- });
531
- if (!r.ok) return false;
532
- writeState(home, { ...readState(home), connectionsSig: sig });
533
- return true;
534
- } catch { return false; }
535
- }
536
-
537
- /** In the runner: put the user's connected apps into this machine's config, so the agent's tools can use them. */
538
- export async function loadCloudConnections({ env = process.env, fetchImpl = fetch, log = () => {} } = {}) {
539
- const key = String(env.CODEPLY_TRACK_KEY || '').trim();
540
- if (!key) return [];
541
- try {
542
- const auth = require('./auth.js');
543
- const r = await fetchImpl(auth.SUPABASE_URL + '/functions/v1/connections', {
544
- method: 'POST',
545
- headers: { 'x-codeply-track-key': key, apikey: auth.SUPABASE_ANON_KEY, 'Content-Type': 'application/json' },
546
- body: JSON.stringify({ action: 'get' }),
547
- signal: AbortSignal.timeout(15_000),
548
- });
549
- const j = await r.json().catch(() => ({}));
550
- if (!r.ok || !j.connections) return [];
551
- const config = require('./config.js');
552
- const loaded = [];
553
- for (const name of CONNECTION_APPS) {
554
- const c = j.connections[name];
555
- if (c && c.accessToken) { config.saveIntegration(name, c); loaded.push(name); }
556
- }
557
- if (loaded.length) log('Connected apps: ' + loaded.join(', '));
558
- return loaded;
559
- } catch (e) { log('Could not load connected apps: ' + e.message); return []; }
560
- }
561
-
562
- // Bumped when CLOUD_WORKFLOW changes, so repos set up earlier get the new file once.
563
- export const WORKFLOW_REV = 3;
564
-
565
- /**
566
- * One-click setup. With `target: 'repo'` (default when the project has a GitHub
567
- * origin) the cloud works in that repo: new branch per task, merged into the
568
- * default branch, and the PC pulls. Otherwise a private mirror holds snapshots.
569
- */
570
- export async function setupCloud({ cwd, token, model, target, envText, home = defaultHome(), apiUrl, serverUrl, fetchImpl, remoteUrl }) {
571
- const api = githubApi({ token, apiUrl, fetchImpl });
572
- const t = await checkToken(api);
573
- if (!t.ok) throw new Error(t.error);
574
- const cfg = cloudModelConfig(model);
575
- if (cfg.error) throw new Error(cfg.error);
576
- const origin = target === 'mirror' ? null : await githubOrigin(cwd);
577
- let repo; let created = false; let skipped = []; let base = 'main'; let kind = 'mirror';
578
- if (origin) {
579
- const r = await api('GET', `/repos/${origin}`, null, { allow: [404] });
580
- if (r.status !== 200) throw new Error(`Craft can't open ${origin} with this GitHub login.`);
581
- if (!r.json.permissions || !r.json.permissions.push) throw new Error(`This GitHub login can't push to ${origin}.`);
582
- repo = origin; base = r.json.default_branch || 'main'; kind = 'repo';
583
- updateProject(cwd, home, (p) => ({ ...p, repo, base, kind }));
584
- await installWorkflowInRepo({ api, repo, base });
585
- } else {
586
- ({ repo, created } = await ensureMirror({ api, cwd, home }));
587
- updateProject(cwd, home, (p) => ({ ...p, base: 'main', kind: 'mirror' }));
588
- skipped = (await pushSnapshot({ cwd, token, home, serverUrl, remoteUrl })).skipped;
589
- }
590
- await configureModel({ api, repo, model });
591
- if (envText != null) await setProjectEnv({ api, repo, envText });
592
- updateProject(cwd, home, (p) => ({ ...p, modelSig: cfg.sig, modelName: cfg.kind === 'auto' ? 'Auto' : (model.name || model.model), enabled: true, workflowRev: WORKFLOW_REV }));
593
- await ensureTrackKey({ api, repo, cwd, home, fetchImpl });
594
- await syncCloudConnections({ home, fetchImpl });
595
- return { repo, created, kind, base, url: `${(serverUrl || 'https://github.com').replace(/\/$/, '')}/${repo}`, skipped };
596
- }
597
-
598
- // ─── Runs ────────────────────────────────────────────────────────────────
599
-
600
- const newTaskId = () => `${Date.now().toString(36)}${crypto.randomBytes(3).toString('hex')}`;
601
- const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
602
- const LIVE_FRESH_MS = 60000;
603
-
604
- /** Is a runner up for this chat right now (idle and waiting, or busy and will get to it)? */
605
- export async function liveRunner(api, repo, sessionId) {
606
- if (!sessionId) return null;
607
- const f = await branchFiles(api, repo).read(SESSION_PATHS.live(sessionId)).catch(() => null);
608
- const live = f && f.json;
609
- if (!live || Date.now() - (live.at || 0) > LIVE_FRESH_MS) return null;
610
- if (!live.busy && (live.until || 0) < Date.now() + 8000) return null;
611
- return live;
612
- }
613
-
614
- async function dispatch(api, repo, base, task, retryMs) {
615
- let lastErr = null;
616
- // Right after the workflow file first lands, GitHub can take a few seconds to know it.
617
- for (let i = 0; i < 10; i++) {
618
- try {
619
- await api('POST', `/repos/${repo}/actions/workflows/${WORKFLOW_FILE}/dispatches`, { ref: base || 'main', inputs: { prompt: task.prompt, mode: task.mode, task_id: task.id, session_id: task.sessionId } });
620
- return;
621
- } catch (e) {
622
- lastErr = e;
623
- if (e.status !== 404 && e.status !== 422) break;
624
- await sleep(retryMs);
625
- }
626
- }
627
- throw lastErr;
628
- }
629
-
630
- /**
631
- * Start a cloud task. A runner already up for this chat gets it through the
632
- * queue at once; otherwise a new runner is dispatched. Mirror projects push
633
- * the latest snapshot first.
634
- */
635
- export async function startCloudRun({ cwd, token, prompt, mode = 'Build', sessionId = '', bot = null, kind = 'code', model, home = defaultHome(), apiUrl, serverUrl, fetchImpl, remoteUrl, retryMs = 3000, startedAt }) {
636
- const p = getProject(cwd, home);
637
- if (!p || !p.repo) throw new Error('Set up cloud runs for this project first.');
638
- prompt = String(prompt || '').trim();
639
- if (!prompt) throw new Error('Write a task first.');
640
- if (prompt.length > MAX_PROMPT) throw new Error(`That task is too long for a cloud run (${prompt.length} characters, the limit is ${MAX_PROMPT}).`);
641
- mode = ['Build', 'Plan', 'Ask'].includes(mode) ? mode : 'Build';
642
- const api = githubApi({ token, apiUrl, fetchImpl });
643
- // model: undefined leaves the repo's model as it is; null (Auto selected) or a model sets it
644
- if (model !== undefined) {
645
- const cfg = cloudModelConfig(model);
646
- if (cfg.sig !== p.modelSig) {
647
- await configureModel({ api, repo: p.repo, model });
648
- updateProject(cwd, home, (q) => ({ ...q, modelSig: cfg.sig, modelName: cfg.kind === 'auto' ? 'Auto' : (model.name || model.model) }));
649
- }
650
- }
651
- const base = p.base || 'main';
652
- // repos set up before a workflow change get the new file once (mirrors get it with every snapshot)
653
- if (p.kind === 'repo' && p.workflowRev !== WORKFLOW_REV) {
654
- await installWorkflowInRepo({ api, repo: p.repo, base });
655
- updateProject(cwd, home, (q) => ({ ...q, workflowRev: WORKFLOW_REV }));
656
- }
657
- await ensureTrackKey({ api, repo: p.repo, cwd, home, fetchImpl });
658
- await syncCloudConnections({ home, fetchImpl });
659
- let baseSha;
660
- if (p.kind === 'repo') {
661
- const ref = await api('GET', `/repos/${p.repo}/git/ref/heads/${encodeURIComponent(base)}`, null, { allow: [404] });
662
- baseSha = ref.status === 200 ? ref.json.object.sha : null;
663
- } else {
664
- baseSha = (await pushSnapshot({ cwd, token, home, serverUrl, remoteUrl })).sha;
665
- }
666
- const task = { id: newTaskId(), prompt, mode, sessionId: String(sessionId || ''), baseSha, repo: p.repo, status: 'starting', startedAt: startedAt || Date.now() };
667
- const b = cleanBot(bot);
668
- const extra = { ...(b ? { bot: b } : {}), ...(cleanKind(kind) === 'task' ? { kind: 'task' } : {}) };
669
- if (b) task.bot = { name: b.name };
670
- if (extra.kind) task.kind = 'task';
671
- const files = branchFiles(api, p.repo);
672
- const live = await liveRunner(api, p.repo, task.sessionId);
673
- if (live) {
674
- await files.write(`${SESSION_PATHS.queue(task.sessionId)}/${Date.now()}-${task.id}.json`, { id: task.id, prompt, mode, ...extra }, `Craft queue ${task.id}`);
675
- Object.assign(task, { queued: true, enqueuedAt: Date.now() });
676
- } else {
677
- // A bot's prompt is too big for a workflow input: the runner reads it from craft-sessions.
678
- if (Object.keys(extra).length) {
679
- if (baseSha) await files.ensure(baseSha).catch(() => {});
680
- if (!(await files.write(SESSION_PATHS.request(task.id), extra, `Craft request ${task.id}`))) throw new Error('Could not hand the bot to the cloud. Try again.');
681
- }
682
- await dispatch(api, p.repo, base, task, retryMs);
683
- }
684
- saveTask(cwd, home, task);
685
- return task;
686
- }
687
-
688
- /** A task's live status document (tasks/<id>.json), or null before the runner has written it. */
689
- export async function readTaskDoc(api, repo, taskId) {
690
- const f = await branchFiles(api, repo).read(SESSION_PATHS.task(taskId)).catch(() => null);
691
- return f && f.json ? f.json : null;
692
- }
693
-
694
- /** Where a cloud task is: its status file first, then the workflow run (for starts and failed starts). */
695
- /** The person's answer to a cloud run's question (allow or not), picked up by the runner within seconds. */
696
- export async function answerCloudAsk({ cwd, token, taskId, askId, approve, home = defaultHome(), apiUrl, fetchImpl }) {
697
- const task = getTask(cwd, taskId, home);
698
- if (!task) throw new Error('Unknown cloud task.');
699
- if (!/^[\w.-]+$/.test(String(askId || ''))) throw new Error('Unknown question.');
700
- const api = githubApi({ token, apiUrl, fetchImpl });
701
- const ok = await branchFiles(api, task.repo).write(SESSION_PATHS.answer(askId), { verdict: approve ? 'approve' : 'reject', at: Date.now() }, `Craft answer ${askId}`);
702
- if (!ok) throw new Error('Could not send your answer to GitHub. Try again.');
703
- saveTask(cwd, home, { ...task, ask: null });
704
- return true;
705
- }
706
-
707
- /** "ask" (risky actions wait for an OK) or "full" (they just run), kept as the repo variable CRAFT_PERMISSIONS. */
708
- export async function setCloudPermission({ cwd, token, permission, home = defaultHome(), apiUrl, fetchImpl }) {
709
- const p = getProject(cwd, home);
710
- if (!p || !p.repo) throw new Error('Set up cloud runs for this project first.');
711
- const value = permission === 'full' ? 'full' : 'ask';
712
- await setVariable(githubApi({ token, apiUrl, fetchImpl }), p.repo, 'CRAFT_PERMISSIONS', value);
713
- updateProject(cwd, home, (q) => ({ ...q, permission: value }));
714
- return value;
715
- }
716
-
717
- export async function cloudRunStatus({ cwd, taskId, token, home = defaultHome(), apiUrl, fetchImpl, retryMs = 3000 }) {
718
- const task = getTask(cwd, taskId, home);
719
- if (!task) throw new Error('Unknown cloud task.');
720
- if (task.status === 'done' || task.status === 'failed' || task.status === 'cancelled') return task;
721
- const api = githubApi({ token, apiUrl, fetchImpl });
722
- const next = { ...task };
723
- const doc = await readTaskDoc(api, task.repo, task.id);
724
- if (doc) {
725
- const { events = [], status, ...rest } = doc;
726
- if (doc.runId) { next.runId = doc.runId; next.runUrl = `https://github.com/${task.repo}/actions/runs/${doc.runId}`; }
727
- if (status === 'done' || status === 'failed') {
728
- const { id: _i, startedAt: _s, updatedAt: _u, runId: _r, ...result } = rest;
729
- Object.assign(next, { status, result, error: result.error || null, finishedAt: Date.now(), queued: false });
730
- } else next.status = 'running';
731
- // a risky action waiting for the person's OK (answerCloudAsk)
732
- next.ask = next.status === 'running' && doc.ask && doc.ask.id ? doc.ask : null;
733
- saveTask(cwd, home, next);
734
- return { ...next, events };
735
- }
736
- if (task.queued) {
737
- // Handed to a runner that went away before taking it: start a new one.
738
- if (Date.now() - task.enqueuedAt > 30000 && !(await liveRunner(api, task.repo, task.sessionId))) {
739
- const p = getProject(cwd, home) || {};
740
- await dispatch(api, task.repo, p.base || 'main', task, retryMs);
741
- Object.assign(next, { queued: false, status: 'starting', redispatchedAt: Date.now() });
742
- } else next.status = 'queued';
743
- saveTask(cwd, home, next);
744
- return { ...next, events: [] };
745
- }
746
- let run = null;
747
- if (task.runId) run = (await api('GET', `/repos/${task.repo}/actions/runs/${task.runId}`)).json;
748
- else {
749
- const list = (await api('GET', `/repos/${task.repo}/actions/workflows/${WORKFLOW_FILE}/runs?event=workflow_dispatch&per_page=30`)).json;
750
- run = (list.workflow_runs || []).find((r) => r.display_title === checkName(task.id)) || null;
751
- }
752
- if (!run) {
753
- // GitHub drops a dispatch it can't start; don't wait forever.
754
- if (Date.now() - task.startedAt > 5 * 60 * 1000) Object.assign(next, { status: 'failed', error: 'GitHub never started the run. Check that Actions is enabled on the repo.' });
755
- saveTask(cwd, home, next);
756
- return { ...next, events: [] };
757
- }
758
- next.runId = run.id;
759
- next.runUrl = run.html_url;
760
- if (run.status === 'completed') {
761
- // Finished without a status file: it died before Craft could report (or an older engine ran).
762
- const checks = (await api('GET', `/repos/${task.repo}/commits/${run.head_sha}/check-runs?check_name=${encodeURIComponent(checkName(task.id))}`)).json;
763
- const check = (checks.check_runs || [])[0];
764
- let data = {};
765
- try { data = JSON.parse((check && check.output && check.output.text) || '{}'); } catch {}
766
- if (check && check.status === 'completed' && data.mode) {
767
- const { events = [], ...result } = data;
768
- Object.assign(next, { status: check.conclusion === 'success' ? 'done' : 'failed', result, error: result.error || null, finishedAt: Date.now() });
769
- saveTask(cwd, home, next);
770
- return { ...next, events };
771
- }
772
- const why = run.conclusion === 'cancelled' ? 'The run was cancelled.' : (await diagnoseRun(api, task.repo, run.id)) || `The run ended (${run.conclusion}) before Craft could report back. The run log has details.`;
773
- Object.assign(next, { status: run.conclusion === 'cancelled' ? 'cancelled' : 'failed', error: why, finishedAt: Date.now() });
774
- } else {
775
- next.status = run.status === 'queued' || run.status === 'waiting' || run.status === 'pending' ? 'queued' : 'running';
776
- }
777
- saveTask(cwd, home, next);
778
- return { ...next, events: [] };
779
- }
780
-
781
- /** Download a cloud screenshot (shots/<task>/<n>.png on craft-sessions) to `dest`. */
782
- export async function downloadShot({ token, repo, path: rel, dest, apiUrl, fetchImpl }) {
783
- const api = githubApi({ token, apiUrl, fetchImpl });
784
- const r = await api('GET', `/repos/${repo}/contents/${rel.split('/').map(encodeURIComponent).join('/')}?ref=${SESSIONS_BRANCH}`, null, { allow: [404] });
785
- if (r.status !== 200 || !r.json || !r.json.content) return null;
786
- fs.mkdirSync(path.dirname(dest), { recursive: true });
787
- fs.writeFileSync(dest, Buffer.from(r.json.content, 'base64'));
788
- return dest;
789
- }
790
-
791
- /**
792
- * Before coding: is the local branch behind GitHub (a cloud run merged work
793
- * into it)? Only for projects whose cloud target is their own repo.
794
- */
795
- export async function checkBehind(cwd) {
796
- try {
797
- const branch = await git(cwd, ['rev-parse', '--abbrev-ref', 'HEAD']);
798
- const upstream = await git(cwd, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}']).catch(() => null);
799
- if (!upstream) return { ok: false, reason: 'no upstream' };
800
- await git(cwd, ['fetch', '-q', '--no-tags', upstream.split('/')[0]]);
801
- const [behind, ahead] = (await git(cwd, ['rev-list', '--left-right', '--count', `${upstream}...HEAD`])).split(/\s+/).map(Number);
802
- const dirty = !!(await git(cwd, ['status', '--porcelain', '--untracked-files=no']));
803
- const latest = behind ? (await git(cwd, ['log', '-1', '--format=%s', upstream]).catch(() => '')) : '';
804
- return { ok: true, branch, upstream, behind, ahead, dirty, latest };
805
- } catch (e) { return { ok: false, reason: e.message }; }
806
- }
807
-
808
- /** Pull what GitHub has (a fast-forward when possible, else a merge; stashes local edits around it). */
809
- export async function pullLatest(cwd) {
810
- const st = await checkBehind(cwd);
811
- if (!st.ok) throw new Error(`Can't pull: ${st.reason}.`);
812
- if (!st.behind) return { pulled: 0 };
813
- let stashed = false;
814
- if (st.dirty) { await git(cwd, ['stash', 'push', '-q', '-m', 'craft: before pulling cloud changes']); stashed = true; }
815
- try {
816
- await git(cwd, ['-c', 'user.name=Codeply Craft', '-c', 'user.email=craft-bot@users.noreply.github.com', 'pull', '-q', '--no-rebase', '--no-edit']);
817
- } catch (e) {
818
- // A conflicting pull is undone, so the project is never left half-merged.
819
- const conflicted = (await git(cwd, ['diff', '--name-only', '--diff-filter=U']).catch(() => '')).split('\n').filter(Boolean);
820
- await git(cwd, ['merge', '--abort']).catch(() => {});
821
- throw new Error(conflicted.length
822
- ? `GitHub's changes clash with your commits in ${conflicted.join(', ')}, so nothing was pulled. Commit or undo those, then pull again.`
823
- : `Could not pull: ${e.message}`);
824
- } finally {
825
- if (stashed) await git(cwd, ['stash', 'pop', '-q']).catch(() => { throw new Error('Pulled, but your own edits clashed with them. They are saved in git stash.'); });
826
- }
827
- return { pulled: st.behind };
828
- }
829
-
830
-
831
- /**
832
- * Runs started somewhere else (the phone, while this PC was off) become local
833
- * tasks, so their changes can be pulled here. Only finished runs are imported.
834
- */
835
- export async function importRemoteTasks({ cwd, token, home = defaultHome(), apiUrl, fetchImpl, limit = 20 }) {
836
- const p = getProject(cwd, home);
837
- if (!p || !p.repo) return [];
838
- const api = githubApi({ token, apiUrl, fetchImpl });
839
- const known = new Set((p.tasks || []).map((t) => t.id));
840
- const list = (await api('GET', `/repos/${p.repo}/actions/workflows/${WORKFLOW_FILE}/runs?event=workflow_dispatch&status=completed&per_page=${limit}`, null, { allow: [404] })).json;
841
- const added = [];
842
- for (const run of (list && list.workflow_runs) || []) {
843
- const id = String(run.display_title || '').replace(/^craft /, '');
844
- if (!/^[A-Za-z0-9_-]+$/.test(id) || known.has(id)) continue;
845
- const checks = (await api('GET', `/repos/${p.repo}/commits/${run.head_sha}/check-runs?check_name=${encodeURIComponent(checkName(id))}`)).json;
846
- const check = (checks.check_runs || [])[0];
847
- if (!check || check.status !== 'completed') continue;
848
- let result = {};
849
- try { result = JSON.parse(check.output.text || '{}'); } catch {}
850
- delete result.events;
851
- const task = {
852
- id, prompt: result.prompt || '(started elsewhere)', mode: result.mode || 'Build', sessionId: result.sessionId || '', baseSha: result.baseSha || run.head_sha,
853
- repo: p.repo, runId: run.id, runUrl: run.html_url, status: check.conclusion === 'success' ? 'done' : 'failed', result, error: result.error || null,
854
- startedAt: Date.parse(run.created_at) || Date.now(), finishedAt: Date.parse(run.updated_at) || Date.now(), remote: true,
855
- };
856
- saveTask(cwd, home, task);
857
- added.push(task);
858
- }
859
- return added;
860
- }
861
-
862
- /** Stop a cloud run. */
863
- export async function cancelCloudRun({ cwd, taskId, token, home = defaultHome(), apiUrl, fetchImpl }) {
864
- const task = await cloudRunStatus({ cwd, taskId, token, home, apiUrl, fetchImpl });
865
- if (!task.runId || ['done', 'failed', 'cancelled'].includes(task.status)) return task;
866
- const api = githubApi({ token, apiUrl, fetchImpl });
867
- await api('POST', `/repos/${task.repo}/actions/runs/${task.runId}/cancel`, null, { allow: [409] });
868
- return task;
869
- }
870
-
871
- /**
872
- * Bring a finished run's code changes into the local project. Stages the
873
- * current working tree first, so the 3-way merge keeps edits made since the
874
- * run started; overlapping edits come back as conflict markers.
875
- */
876
- export async function pullCloudRun({ cwd, taskId, token, home = defaultHome(), serverUrl = 'https://github.com', remoteUrl }) {
877
- const task = getTask(cwd, taskId, home);
878
- if (!task) throw new Error('Unknown cloud task.');
879
- if (task.status !== 'done') throw new Error('That cloud run has not finished.');
880
- if (!task.result || !task.result.branch) return { files: [], conflicts: [], applied: false, message: 'That run did not change any files.' };
881
- if (task.pulledAt) return { files: task.result.files || [], conflicts: [], applied: false, message: 'Already applied.' };
882
- const m = await initMirrorGit(cwd, home);
883
- const url = remoteUrl || `${serverUrl.replace(/\/$/, '')}/${task.repo}.git`;
884
- const ref = `refs/craft/tasks/${task.id}`;
885
- await git(cwd, ['--git-dir', m.gitDir, 'fetch', '-q', '--no-tags', url, `+refs/heads/${task.result.branch}:${ref}`], { auth: authFor(token, serverUrl) });
886
- const patch = await git(cwd, ['--git-dir', m.gitDir, 'diff', '--binary', '--full-index', task.baseSha, ref, '--', '.', `:(exclude)${WORKFLOW_PATH}`, ':(exclude).codeply']);
887
- if (!patch.trim()) return { files: [], conflicts: [], applied: false, message: 'That run did not change any files.' };
888
- const files = (await git(cwd, ['--git-dir', m.gitDir, 'diff', '--name-only', task.baseSha, ref, '--', '.', `:(exclude)${WORKFLOW_PATH}`, ':(exclude).codeply'])).split('\n').filter(Boolean);
889
- await stageWorkingTree(cwd, home);
890
- let conflicts = [];
891
- try {
892
- await m.G(['apply', '--3way', '--whitespace=nowarn', '-'], { input: `${patch}\n` });
893
- } catch (e) {
894
- const unmerged = await m.G(['diff', '--name-only', '--diff-filter=U']).catch(() => '');
895
- conflicts = unmerged.split('\n').filter(Boolean);
896
- if (!conflicts.length) throw new Error(`Could not apply the changes: ${e.message}`);
897
- }
898
- saveTask(cwd, home, { ...task, pulledAt: Date.now(), conflicts });
899
- return { files, conflicts, applied: true };
900
- }
901
-
902
- // ─── The runner (inside GitHub Actions) ─────────────────────────────────
903
-
904
- function describeTool(ev) {
905
- const a = ev.args || {};
906
- const what = a.path || a.file || a.command || a.pattern || a.query || a.url || '';
907
- return `- ${ev.ok === false ? '(failed) ' : ''}${ev.name}${what ? ` ${String(what).split('\n')[0].slice(0, 100)}` : ''}`;
908
- }
909
-
910
- function renderProgress(lines, note) {
911
- const shown = lines.length > 60 ? [`- ... ${lines.length - 60} earlier steps`, ...lines.slice(-60)] : lines;
912
- return `${note}\n\n${shown.join('\n')}`.slice(0, MAX_OUTPUT);
913
- }
914
-
915
- // ─── The run as a chat: every step the agent took, published live ─────────
916
- // Cloud chats look like local ones (commands, edits with their + and - lines,
917
- // narration, the "what actually happened" card), so the runner records the
918
- // same events the desktop gets from a local run and puts them in the check
919
- // run's text, where the PC and the phone read them while it works.
920
-
921
- const EVENT_BUDGET = 58000;
922
- const clipText = (s, n) => (typeof s === 'string' && s.length > n ? `${s.slice(0, n)}\n...` : s);
923
-
924
- /** One agent event as a small record; tool arguments kept the way the chat shows them. */
925
- export function recordEvent(ev) {
926
- if (ev.type === 'text') return { t: 'text', text: clipText(ev.text, 8000), interim: !!ev.interim };
927
- if (ev.type === 'reasoning') return { t: 'reasoning', text: clipText(ev.text, 2000), ms: ev.ms };
928
- if (ev.type === 'notice') return { t: 'notice', level: ev.level || 'info', text: clipText(ev.text, 1000) };
929
- if (ev.type === 'tool_end') {
930
- const a = ev.args || {};
931
- let args;
932
- if (ev.name === 'write_file') args = { path: a.path };
933
- else if (ev.name === 'apply_patch') args = { files: (ev.meta && ev.meta.files) || [] };
934
- else {
935
- args = {};
936
- for (const [k, v] of Object.entries(a)) {
937
- if (typeof v === 'string') args[k] = clipText(v, k === 'search' || k === 'replace' ? 3000 : 1500);
938
- else if (typeof v === 'number' || typeof v === 'boolean') args[k] = v;
939
- else if (Array.isArray(v)) args[k] = v.slice(0, 50);
940
- }
941
- }
942
- const m = ev.meta || {};
943
- return {
944
- t: 'tool', name: ev.name, ok: ev.ok, summary: clipText(ev.summary || '', 300), args,
945
- ...(typeof m.exitCode === 'number' ? { exitCode: m.exitCode } : {}),
946
- ...(typeof m.added === 'number' ? { added: m.added } : {}),
947
- ...(typeof m.removed === 'number' ? { removed: m.removed } : {}),
948
- };
949
- }
950
- if (ev.type === 'done' && Array.isArray(ev.actions)) {
951
- const changed = ev.actions.filter((x) => x.ok && ['write_file', 'edit_file', 'apply_patch', 'fetch_image'].includes(x.tool))
952
- .flatMap((x) => (Array.isArray(x.files) && x.files.length ? x.files.map((f) => ({ ...x, label: f })) : [x]));
953
- const checks = ev.actions.filter((x) => ['run', 'browser_check'].includes(x.tool));
954
- if (!changed.length && !checks.length) return null;
955
- return {
956
- t: 'summary', files: [...new Set(changed.map((x) => x.label))].slice(0, 30),
957
- checks: [...new Map(checks.map((x) => [`${x.tool}|${x.label}`, x])).values()].slice(-8).map((x) => ({ tool: x.tool, label: x.label, ok: x.ok, exitCode: x.exitCode })),
958
- unverified: ev.unverifiedFiles || [],
959
- };
960
- }
961
- return null;
962
- }
963
-
964
- /** Fit the event list in the check run's text: shorten diffs, drop thinking, then the oldest steps. */
965
- export function packEvents(events, budget = EVENT_BUDGET) {
966
- let list = events.map((e) => ({ ...e }));
967
- const size = () => JSON.stringify(list).length;
968
- if (size() <= budget) return list;
969
- list = list.map((e) => (e.t === 'reasoning' ? { ...e, text: clipText(e.text, 200) } : e));
970
- for (const n of [800, 200]) {
971
- if (size() <= budget) return list;
972
- list = list.map((e) => (e.t === 'tool' && e.args ? { ...e, args: Object.fromEntries(Object.entries(e.args).map(([k, v]) => [k, typeof v === 'string' ? clipText(v, n) : v])) } : e));
973
- }
974
- let dropped = 0;
975
- while (size() > budget && list.length > 1) { list.shift(); dropped++; }
976
- if (dropped) list.unshift({ t: 'notice', level: 'info', text: `${dropped} earlier step${dropped === 1 ? '' : 's'} are only in the run log.` });
977
- return list;
978
- }
979
-
980
- // ─── Files on the craft-sessions branch (chat memory, status, queue, shots) ──
981
-
982
- export const SESSION_PATHS = {
983
- history: (sid) => `sessions/${sid}.json`,
984
- task: (id) => `tasks/${id}.json`,
985
- live: (sid) => `live/${sid}.json`,
986
- queue: (sid) => `queue/${sid}`,
987
- shot: (id, n) => `shots/${id}/${n}.png`,
988
- // A risky action waiting for the person: Craft or the phone writes the answer here.
989
- answer: (askId) => `answers/${askId}.json`,
990
- // What does not fit in the workflow's inputs (a bot's prompt), written before the dispatch.
991
- request: (id) => `requests/${id}.json`,
992
- };
993
-
994
- /**
995
- * A task's optional bot: { name, prompt }, the full prompt the PC built
996
- * (bots.js buildBotPrompt). Anything else, or an empty prompt, is no bot.
997
- */
998
- export function cleanBot(bot) {
999
- if (!bot || typeof bot !== 'object') return null;
1000
- const prompt = String(bot.prompt || '').trim().slice(0, MAX_BOT_PROMPT);
1001
- if (!prompt) return null;
1002
- return { name: String(bot.name || 'Bot').trim().slice(0, 80) || 'Bot', prompt };
1003
- }
1004
- /** 'task' is a job for a bot (not about the repo): its final reply is what the user hears. */
1005
- const cleanKind = (k) => (k === 'task' ? 'task' : 'code');
1006
-
1007
- /** Small read / write / delete / list helpers for one branch, with retries for concurrent writes. */
1008
- export function branchFiles(api, repo, branch = SESSIONS_BRANCH) {
1009
- const enc = (p) => p.split('/').map(encodeURIComponent).join('/');
1010
- const read = async (p) => {
1011
- const r = await api('GET', `/repos/${repo}/contents/${enc(p)}?ref=${branch}`, null, { allow: [404] });
1012
- if (r.status !== 200 || !r.json || Array.isArray(r.json)) return null;
1013
- let json = null;
1014
- try { json = JSON.parse(Buffer.from(r.json.content || '', 'base64').toString('utf8')); } catch {}
1015
- return { json, sha: r.json.sha, size: r.json.size };
1016
- };
1017
- const list = async (p) => {
1018
- const r = await api('GET', `/repos/${repo}/contents/${enc(p)}?ref=${branch}`, null, { allow: [404] });
1019
- return r.status === 200 && Array.isArray(r.json) ? r.json.map((x) => ({ name: x.name, path: x.path, sha: x.sha })) : [];
1020
- };
1021
- const put = async (p, content, message) => {
1022
- for (let i = 0; i < 4; i++) {
1023
- const cur = await api('GET', `/repos/${repo}/contents/${enc(p)}?ref=${branch}`, null, { allow: [404] });
1024
- const sha = cur.status === 200 && cur.json && !Array.isArray(cur.json) ? cur.json.sha : undefined;
1025
- const r = await api('PUT', `/repos/${repo}/contents/${enc(p)}`, { message, branch, content, ...(sha ? { sha } : {}) }, { allow: [409, 422] });
1026
- if (r.status < 300) return true;
1027
- await sleep(300 + i * 400);
1028
- }
1029
- return false;
1030
- };
1031
- const write = (p, obj, message = `Craft ${p}`) => put(p, Buffer.from(JSON.stringify(obj)).toString('base64'), message);
1032
- const writeBytes = (p, buf, message = `Craft ${p}`) => put(p, Buffer.from(buf).toString('base64'), message);
1033
- const remove = async (p, sha) => {
1034
- for (let i = 0; i < 3; i++) {
1035
- const s = sha || (await read(p) || {}).sha;
1036
- if (!s) return;
1037
- const r = await api('DELETE', `/repos/${repo}/contents/${enc(p)}`, { message: `Craft ${p}`, branch, sha: s }, { allow: [404, 409, 422] });
1038
- if (r.status < 300 || r.status === 404) return;
1039
- sha = null;
1040
- await sleep(300);
1041
- }
1042
- };
1043
- const ensure = async (baseSha) => {
1044
- const ref = await api('GET', `/repos/${repo}/git/ref/heads/${branch}`, null, { allow: [404] });
1045
- if (ref.status === 404) await api('POST', `/repos/${repo}/git/refs`, { ref: `refs/heads/${branch}`, sha: baseSha }, { allow: [422] });
1046
- };
1047
- return { read, list, write, writeBytes, remove, ensure };
1048
- }
1049
-
1050
- async function loadHistory(files, sessionId) {
1051
- if (!sessionId) return { messages: [] };
1052
- const f = await files.read(SESSION_PATHS.history(sessionId));
1053
- return { messages: f && f.json && Array.isArray(f.json.messages) ? f.json.messages : [] };
1054
- }
1055
-
1056
- async function saveHistory(files, sessionId, messages) {
1057
- if (!sessionId) return;
1058
- await files.write(SESSION_PATHS.history(sessionId), { sessionId, updatedAt: new Date().toISOString(), messages }, `Chat ${sessionId}`);
1059
- }
1060
-
1061
- const branchSlug = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').split('-').slice(0, 6).join('-').slice(0, 40) || 'change';
1062
- export const cloudBranchName = (prompt, id) => `craft/${branchSlug(prompt)}-${String(id).slice(-6)}`;
1063
-
1064
- /**
1065
- * `codeply cloud runner`, inside GitHub Actions. Runs the dispatched task, then
1066
- * (for a chat) stays up to CRAFT_IDLE_MS (2 minutes) waiting for the next
1067
- * message in queue/<session>, so a follow-up starts at once instead of waiting
1068
- * for a new runner. Each task:
1069
- * - runs like a local run, with a real browser (screenshots) when Chrome is there
1070
- * - publishes its steps live to tasks/<id>.json (and the first one's check run)
1071
- * - commits Build work to craft/<what-it-did>-<id>, pushes it, and merges it
1072
- * into the base branch (CRAFT_MERGE=false keeps it as a branch only)
1073
- * @returns {Promise<{status:'done'|'failed', result:object, results:object[]}>}
1074
- */
1075
- export async function runCloudRunner({ env = process.env, cwd = process.cwd(), token, route, setupError = null, fetchImpl = fetch, runAgentImpl = runAgent, log = () => {}, maxSteps = 80, updateMs = 4000, idleMs, pollMs = 3000, browserImpl }) {
1076
- const repo = env.GITHUB_REPOSITORY;
1077
- const firstId = String(env.CRAFT_TASK_ID || '').replace(/[^A-Za-z0-9_-]/g, '');
1078
- const sessionId = String(env.CRAFT_SESSION_ID || '').replace(/[^A-Za-z0-9_-]/g, '');
1079
- const headSha = env.GITHUB_SHA;
1080
- if (!repo || !token || !firstId || !headSha) return { status: 'failed', result: { error: 'Missing GITHUB_REPOSITORY, GITHUB_TOKEN, GITHUB_SHA or CRAFT_TASK_ID.' }, results: [] };
1081
- const apiUrl = env.GITHUB_API_URL || 'https://api.github.com';
1082
- const serverUrl = (env.GITHUB_SERVER_URL || 'https://github.com').replace(/\/$/, '');
1083
- const api = githubApi({ token, apiUrl, fetchImpl });
1084
- const files = branchFiles(api, repo);
1085
- const idle = idleMs != null ? idleMs : Number(env.CRAFT_IDLE_MS || 120000);
1086
- const base = env.CRAFT_BASE || env.GITHUB_REF_NAME || 'main';
1087
- const merge = String(env.CRAFT_MERGE || 'true') !== 'false';
1088
- await files.ensure(headSha).catch((e) => log(`Could not create ${SESSIONS_BRANCH}: ${e.message}`));
1089
- await loadCloudConnections({ env, log });
1090
-
1091
- // The project's environment for running and testing: never committed.
1092
- const envFile = path.join(cwd, '.env');
1093
- if (env.CRAFT_ENV && String(env.CRAFT_ENV).trim()) {
1094
- try { if (!fs.existsSync(envFile)) fs.writeFileSync(envFile, `${String(env.CRAFT_ENV).trim()}\n`); } catch (e) { log(`Could not write .env: ${e.message}`); }
1095
- }
1096
- const browser = browserImpl !== undefined ? browserImpl : (await import('./cloud-browser.mjs')).makeCloudBrowser();
1097
-
1098
- const heartbeat = (busy, until) => files.write(SESSION_PATHS.live(sessionId), { runId: env.GITHUB_RUN_ID || null, busy, at: Date.now(), until }, `Craft live ${sessionId}`).catch(() => {});
1099
-
1100
- let task = { id: firstId, prompt: String(env.CRAFT_PROMPT || ''), mode: env.CRAFT_MODE, sessionId, dispatched: true };
1101
- // A bot (and the task kind) comes next to the dispatch, on craft-sessions.
1102
- const req = await files.read(SESSION_PATHS.request(firstId)).catch(() => null);
1103
- if (req && req.json) {
1104
- task = { ...task, bot: req.json.bot, kind: req.json.kind };
1105
- await files.remove(SESSION_PATHS.request(firstId), req.sha).catch(() => {});
1106
- }
1107
- const results = [];
1108
- try {
1109
- while (task) {
1110
- if (sessionId) await heartbeat(true, Date.now() + 60 * 60 * 1000);
1111
- results.push(await runOneTask({ task, env, cwd, api, files, repo, token, serverUrl, headSha, route, setupError, runAgentImpl, log, maxSteps, updateMs, browser, base, merge, heartbeat: sessionId ? () => heartbeat(true, Date.now() + 60 * 60 * 1000) : null }));
1112
- if (!sessionId || idle <= 0 || setupError) break;
1113
- task = await waitForNext({ files, sessionId, idle, pollMs, heartbeat });
1114
- }
1115
- } finally {
1116
- if (sessionId) await files.remove(SESSION_PATHS.live(sessionId)).catch(() => {});
1117
- if (browser && browser.close) await browser.close().catch(() => {});
1118
- }
1119
- const last = results[results.length - 1] || { status: 'failed', result: { error: 'Nothing ran.' } };
1120
- return { ...last, results };
1121
- }
1122
-
1123
- /** Idle: the next queued message for this chat, or null after `idle` ms of nothing. */
1124
- async function waitForNext({ files, sessionId, idle, pollMs, heartbeat }) {
1125
- const deadline = Date.now() + idle;
1126
- let beat = 0;
1127
- while (Date.now() < deadline) {
1128
- if (Date.now() - beat > 20000) { await heartbeat(false, deadline); beat = Date.now(); }
1129
- const items = (await files.list(SESSION_PATHS.queue(sessionId)).catch(() => [])).sort((a, b) => a.name.localeCompare(b.name));
1130
- for (const it of items) {
1131
- const f = await files.read(it.path).catch(() => null);
1132
- await files.remove(it.path, f && f.sha).catch(() => {});
1133
- const t = f && f.json;
1134
- if (!t || !t.id) continue;
1135
- // Picked up by another runner already (it was re-dispatched): skip.
1136
- if (await files.read(SESSION_PATHS.task(t.id)).catch(() => null)) continue;
1137
- return { ...t, sessionId, dispatched: false };
1138
- }
1139
- await sleep(pollMs);
1140
- }
1141
- return null;
1142
- }
1143
-
1144
- async function runOneTask({ task, env, cwd, api, files, repo, token, serverUrl, headSha, route, setupError, runAgentImpl, log, maxSteps, updateMs, browser, base, merge, heartbeat }) {
1145
- const taskId = String(task.id).replace(/[^A-Za-z0-9_-]/g, '');
1146
- const mode = ['Build', 'Plan', 'Ask'].includes(task.mode) ? task.mode : 'Build';
1147
- const prompt = String(task.prompt || '').slice(0, MAX_PROMPT);
1148
- const sessionId = task.sessionId || '';
1149
- const bot = cleanBot(task.bot);
1150
- const kind = cleanKind(task.kind);
1151
- const startedAt = Date.now();
1152
- const events = [];
1153
- const lines = [];
1154
-
1155
- // The dispatched task also gets a check run: it is how a failed start is diagnosed.
1156
- const check = task.dispatched ? (await api('POST', `/repos/${repo}/check-runs`, {
1157
- name: checkName(taskId), head_sha: headSha, status: 'in_progress', started_at: new Date().toISOString(),
1158
- output: { title: 'Starting', summary: 'Starting...' },
1159
- }).catch(() => ({ json: null }))).json : null;
1160
-
1161
- let pendingAsk = null; let asks = 0;
1162
- const statusDoc = (status, extra = {}) => ({ ...(pendingAsk && status === 'running' ? { ask: pendingAsk } : {}), id: taskId, status, mode, prompt: prompt.slice(0, 4000), sessionId, ...(kind === 'task' ? { kind } : {}), ...(bot ? { bot: { name: bot.name } } : {}), startedAt, updatedAt: Date.now(), runId: env.GITHUB_RUN_ID || null, ...extra });
1163
- const finish = async (ok, title, summary, result) => {
1164
- const baseLen = JSON.stringify(result).length;
1165
- const packed = packEvents(events, Math.max(4000, MAX_OUTPUT - baseLen - 200));
1166
- await files.write(SESSION_PATHS.task(taskId), statusDoc(ok ? 'done' : 'failed', { ...result, events: packEvents(events, 400000) }), `Craft task ${taskId}: ${title}`).catch(() => {});
1167
- if (check) {
1168
- await api('PATCH', `/repos/${repo}/check-runs/${check.id}`, {
1169
- status: 'completed', conclusion: ok ? 'success' : 'failure', completed_at: new Date().toISOString(),
1170
- output: { title, summary: String(summary || title).slice(0, MAX_OUTPUT), text: JSON.stringify({ ...result, events: packed }).slice(0, MAX_OUTPUT) },
1171
- }).catch((e) => log(`Could not finish the check run: ${e.message}`));
1172
- }
1173
- return { status: ok ? 'done' : 'failed', result: { ...result, events: packed } };
1174
- };
1175
-
1176
- if (setupError) return finish(false, 'Could not start', setupError, { error: setupError, mode });
1177
- if (!prompt) return finish(false, 'Could not start', 'The task was empty.', { error: 'The task was empty.', mode });
1178
-
1179
- let lastPush = 0; let pending = null;
1180
- const pushProgress = async (note, force = false) => {
1181
- if (!force && Date.now() - lastPush < updateMs) return;
1182
- lastPush = Date.now();
1183
- const packed = packEvents(events);
1184
- pending = Promise.all([
1185
- files.write(SESSION_PATHS.task(taskId), statusDoc('running', { events: packEvents(events, 400000) }), `Craft task ${taskId}`).catch(() => {}),
1186
- check ? api('PATCH', `/repos/${repo}/check-runs/${check.id}`, { output: { title: 'Working', summary: renderProgress(lines, note), text: JSON.stringify({ running: true, events: packed }) } }).catch(() => {}) : null,
1187
- heartbeat ? heartbeat() : null,
1188
- ]);
1189
- await pending;
1190
- };
1191
-
1192
- // Screenshots go next to the status, where the PC and the phone read them.
1193
- let shots = 0; let pendingShot = null;
1194
- const agentBrowser = browser ? async (url, opts) => {
1195
- const r = await browser(url, opts);
1196
- if (r && r.ok && r.screenshotPath) {
1197
- try {
1198
- const rel = SESSION_PATHS.shot(taskId, ++shots);
1199
- if (await files.writeBytes(rel, fs.readFileSync(r.screenshotPath), `Craft screenshot ${taskId}`)) pendingShot = rel;
1200
- } catch {}
1201
- }
1202
- return r;
1203
- } : undefined;
1204
-
1205
- try {
1206
- // Where this task starts (a later message in the same runner starts after the earlier merge).
1207
- const taskBase = await git(cwd, ['rev-parse', 'HEAD']).catch(() => headSha);
1208
- const hist = await loadHistory(files, sessionId);
1209
- await pushProgress(hist.messages.length ? `Continuing the chat (${hist.messages.length / 2} earlier turns).` : 'Working on it.', true);
1210
- // Risky actions (deploys...) ask the person unless the repo's Cloud permission is "full".
1211
- const ASK_WAIT_MS = 15 * 60 * 1000;
1212
- // Only a repo with the current workflow passes CRAFT_PERMISSIONS (empty when unset): its
1213
- // Craft and phone can show the question. An older setup keeps the old rule (no) until updated.
1214
- const rules = ciApprove(cwd, { permission: env.CRAFT_PERMISSIONS, canAsk: env.CRAFT_PERMISSIONS !== undefined });
1215
- const approve = async (req) => {
1216
- const v = await rules(req);
1217
- if (v !== 'ask') return v;
1218
- const askId = `${taskId}-${++asks}`;
1219
- pendingAsk = { id: askId, tool: req.tool, title: String(req.title || req.tool).slice(0, 300), detail: String(req.detail || '').slice(0, 2000), at: Date.now() };
1220
- events.push({ t: 'notice', level: 'info', text: `Waiting for your OK: ${pendingAsk.title}` });
1221
- await pushProgress('Waiting for your OK.', true);
1222
- const until = Date.now() + ASK_WAIT_MS;
1223
- let verdict = null;
1224
- while (Date.now() < until && !verdict) {
1225
- await sleep(4000);
1226
- const f = await files.read(SESSION_PATHS.answer(askId)).catch(() => null);
1227
- if (f && f.json) { verdict = f.json.verdict === 'approve' ? 'once' : 'reject'; files.remove(SESSION_PATHS.answer(askId), f.sha).catch(() => {}); }
1228
- else if (heartbeat) heartbeat(true).catch(() => {});
1229
- }
1230
- pendingAsk = null;
1231
- events.push({ t: 'notice', level: 'info', text: verdict === 'once' ? 'Approved.' : verdict === 'reject' ? 'You said no, so it was skipped.' : 'No answer in 15 minutes, so it was skipped.' });
1232
- await pushProgress('Working.', true);
1233
- return verdict || 'reject';
1234
- };
1235
- let answer = ''; let steps = 0; let failure = '';
1236
- const userMessage = kind === 'task'
1237
- ? `${prompt}\n\n(You are running unattended in Codeply Cloud on a Linux machine, not on the user's PC: you cannot reach their local files or desktop apps. Use the shell, the web and this repository. Nobody can answer questions mid-run, so make sensible choices and say what you assumed. Your final reply is read out to the user: say plainly what you did or found, in a few short sentences, no markdown. Do not commit or push; Craft does that when you finish.)`
1238
- : `${prompt}\n\n(You are running unattended in Craft Cloud, in a fresh copy of the repository on a Linux machine. Nobody can answer questions mid-run, so make sensible choices and say what you assumed. You can install dependencies, run the app and tests, and check pages with browser_check. Do not commit or push; Craft does that when you finish.)`;
1239
- for await (const ev of runAgentImpl({ userMessage, history: hist.messages.slice(-HISTORY_TURNS), mode, cwd, approve, browser: agentBrowser, signal: new AbortController().signal, route, maxSteps, ...(bot ? { botPrompt: bot.prompt } : {}) })) {
1240
- const rec = recordEvent(ev);
1241
- if (rec && rec.t === 'tool' && ev.name === 'browser_check' && pendingShot) { rec.screenshot = pendingShot; pendingShot = null; }
1242
- // The final answer arrives as the reply; everything else is part of the chat's steps.
1243
- if (rec && !(rec.t === 'text' && !rec.interim)) events.push(rec);
1244
- if (ev.type === 'text' && !ev.interim) answer = ev.text;
1245
- else if (ev.type === 'tool_end') { steps++; lines.push(describeTool(ev)); }
1246
- else if (ev.type === 'error') failure = ev.error;
1247
- if (rec) await pushProgress(`Working: ${steps} step${steps === 1 ? '' : 's'} so far.`);
1248
- if (ev.type === 'done' || ev.type === 'error' || ev.type === 'aborted') break;
1249
- }
1250
- if (pending) await pending;
1251
- if (failure && !answer) throw new Error(failure);
1252
-
1253
- // The bot's own last words, before a plan file replaces the answer: what the phone speaks.
1254
- const reply = answer;
1255
- if (mode === 'Plan') {
1256
- const dir = path.join(cwd, '.codeply', 'plans');
1257
- const plans = fs.existsSync(dir) ? fs.readdirSync(dir).filter((f) => f.endsWith('.md')).map((f) => ({ f, t: fs.statSync(path.join(dir, f)).mtimeMs })).sort((a, b) => b.t - a.t) : [];
1258
- if (plans.length) answer = fs.readFileSync(path.join(dir, plans[0].f), 'utf8');
1259
- }
1260
-
1261
- let branch = null; let sha = null; let changed = []; let stats = []; let merged = null;
1262
- if (mode === 'Build') {
1263
- await git(cwd, ['add', '-A', '--', '.', ':(exclude).codeply', `:(exclude)${WORKFLOW_PATH}`, ':(exclude).env']);
1264
- const staged = await git(cwd, ['diff', '--cached', '--name-only']);
1265
- if (staged) {
1266
- changed = staged.split('\n').filter(Boolean);
1267
- // Per-file + and - lines, for the chat's record of what was pushed.
1268
- stats = (await git(cwd, ['diff', '--cached', '--numstat'])).split('\n').filter(Boolean).map((l) => {
1269
- const [add, del, ...name] = l.split('\t');
1270
- return { file: name.join('\t'), added: add === '-' ? null : Number(add), removed: del === '-' ? null : Number(del) };
1271
- });
1272
- branch = cloudBranchName(prompt, taskId);
1273
- const subject = prompt.split('\n')[0].slice(0, 60);
1274
- await git(cwd, [...BOT, 'commit', '-q', '-m', `${subject}\n\nCraft cloud task ${taskId}.`]);
1275
- sha = await git(cwd, ['rev-parse', 'HEAD']);
1276
- await git(cwd, ['push', '-q', 'origin', `HEAD:refs/heads/${branch}`], { auth: authFor(token, serverUrl) });
1277
- // Into the base branch too, so the next run (and the PC's pull) starts from it.
1278
- if (merge) {
1279
- const m = await api('POST', `/repos/${repo}/merges`, { base, head: branch, commit_message: `Merge ${branch}: ${subject}` }, { allow: [204, 404, 409] }).catch((e) => ({ status: 0, error: e.message }));
1280
- merged = m.status === 201 || m.status === 204
1281
- ? { ok: true, base, sha: m.json && m.json.sha ? m.json.sha : sha }
1282
- : { ok: false, base, reason: m.status === 409 ? `it conflicts with newer changes on ${base}` : (m.error || `GitHub answered ${m.status}`) };
1283
- if (merged.ok) {
1284
- // Keep working from the merged base for the next message in this chat.
1285
- await git(cwd, ['fetch', '-q', 'origin', base], { auth: authFor(token, serverUrl) }).catch(() => {});
1286
- await git(cwd, ['reset', '-q', '--hard', 'FETCH_HEAD']).catch(() => {});
1287
- }
1288
- }
1289
- const plus = stats.reduce((n, s) => n + (s.added || 0), 0);
1290
- const minus = stats.reduce((n, s) => n + (s.removed || 0), 0);
1291
- events.push({ t: 'pushed', branch, sha: sha.slice(0, 7), files: stats.slice(0, 50), added: plus, removed: minus, merged });
1292
- }
1293
- }
1294
-
1295
- await saveHistory(files, sessionId, [...hist.messages, { role: 'user', content: prompt }, { role: 'assistant', content: answer || '(no reply)' }].slice(-HISTORY_TURNS * 2))
1296
- .catch((e) => log(`Could not save the chat: ${e.message}`));
1297
- const result = { mode, prompt: prompt.slice(0, 4000), sessionId, answer: answer.slice(0, 20000), branch, sha, files: changed, stats: stats.slice(0, 100), merged, steps, baseSha: taskBase,
1298
- ...(bot || kind === 'task' ? { reply: reply.slice(0, 20000) } : {}) };
1299
- const title = changed.length ? `Changed ${changed.length} file${changed.length === 1 ? '' : 's'}` : mode === 'Build' ? 'No files changed' : 'Answered';
1300
- return finish(true, title, answer || title, result);
1301
- } catch (e) {
1302
- return finish(false, 'Failed', `Something went wrong: ${e.message}`, { error: e.message, mode, steps: lines.length });
1303
- }
1304
- }
1305
-
1306
- /** Route and token from the runner environment, with secrets removed from process.env. */
1307
- export function runnerSetup(env = process.env) {
1308
- const cfg = { ...env };
1309
- for (const k of ['GITHUB_TOKEN', 'GH_TOKEN', 'CODEPLY_API_KEY', 'ACTIONS_RUNTIME_TOKEN', 'ACTIONS_ID_TOKEN_REQUEST_TOKEN']) delete env[k];
1310
- const r = routeFromEnv(cfg);
1311
- return { cfg, token: cfg.GITHUB_TOKEN || cfg.GH_TOKEN, route: r.route, setupError: r.error || null };
1312
- }
1
+ /**
2
+ * Craft Cloud: the agent keeps working while this PC is off, on GitHub's
3
+ * machines, in the user's own account. Nothing runs on Codeply servers.
4
+ *
5
+ * mirror A private repo (craft-workspace-<project>) that holds snapshots
6
+ * of the project. It is separate from the user's real repo, so a
7
+ * cloud run never touches production code.
8
+ * snapshot The working tree, pushed to the mirror's main branch through a
9
+ * git folder of Craft's own (~/.codeply/cloud/<id>.git). The
10
+ * project's own .git is never read or written. .gitignore is
11
+ * respected, .env files and keys are always left out, and files
12
+ * holding key-like strings are skipped.
13
+ * run A workflow_dispatch of craft-cloud.yml. The runner reports
14
+ * progress in a check run, pushes code changes to craft/task-<id>,
15
+ * and keeps the chat on the craft-sessions branch so the next run
16
+ * continues the same conversation.
17
+ * pull The task branch's changes, applied back onto the local project
18
+ * with a 3-way merge, so edits made meanwhile survive.
19
+ *
20
+ * The model key lives only in the mirror's encrypted Actions secrets.
21
+ */
22
+ import fs from 'fs';
23
+ import os from 'os';
24
+ import path from 'path';
25
+ import crypto from 'crypto';
26
+ import { createRequire } from 'module';
27
+ import { runAgent } from './agent.mjs';
28
+ import { git, ciApprove, routeFromEnv } from './github-agent.mjs';
29
+ import { takeStateFile, continuationPrompt, recordEvent as recordHandoff, wipBranch, HANDBACK, STATE_FILE } from './handoff.mjs';
30
+
31
+ const require = createRequire(import.meta.url);
32
+ const nacl = require('tweetnacl');
33
+ const blake = require('blakejs');
34
+
35
+ export const WORKFLOW_FILE = 'craft-cloud.yml';
36
+ export const WORKFLOW_PATH = `.github/workflows/${WORKFLOW_FILE}`;
37
+ export const MIRROR_PREFIX = 'craft-workspace-';
38
+ export const MIRROR_DESCRIPTION = 'Craft workspace mirror: a private backup that Craft cloud runs work in.';
39
+ export const SESSIONS_BRANCH = 'craft-sessions';
40
+ export const ENGINE = 'codeply-cli@0.5';
41
+ export const taskBranch = (id) => `craft/task-${id}`;
42
+ export const checkName = (id) => `craft ${id}`;
43
+
44
+ const MAX_FILE_BYTES = 50 * 1024 * 1024;
45
+ const MAX_SCAN_BYTES = 2 * 1024 * 1024;
46
+ const MAX_PROMPT = 20000;
47
+ const MAX_BOT_PROMPT = 30000;
48
+ const MAX_OUTPUT = 60000;
49
+ const HISTORY_TURNS = 40;
50
+
51
+ export const CLOUD_WORKFLOW = `# Written by Codeply Craft. Starts only from Craft (workflow_dispatch), which
52
+ # needs write access to this repo, so nobody else can spend its minutes.
53
+ name: craft-cloud
54
+ run-name: craft \${{ inputs.task_id }}
55
+
56
+ on:
57
+ workflow_dispatch:
58
+ inputs:
59
+ prompt:
60
+ description: What to do
61
+ required: true
62
+ type: string
63
+ mode:
64
+ description: Build, Plan or Ask
65
+ required: false
66
+ default: Build
67
+ type: string
68
+ task_id:
69
+ required: true
70
+ type: string
71
+ session_id:
72
+ required: false
73
+ default: ''
74
+ type: string
75
+ handoff:
76
+ description: resume = carry on a run from the PC (craft/wip-<chat>)
77
+ required: false
78
+ default: ''
79
+ type: string
80
+
81
+ permissions:
82
+ contents: write
83
+ checks: write
84
+
85
+ concurrency:
86
+ group: craft-\${{ inputs.handoff }}\${{ inputs.session_id || inputs.task_id }}
87
+ cancel-in-progress: false
88
+
89
+ jobs:
90
+ craft:
91
+ runs-on: ubuntu-latest
92
+ timeout-minutes: 60
93
+ steps:
94
+ - uses: actions/checkout@v4
95
+ with:
96
+ ref: \${{ inputs.handoff == 'resume' && format('craft/wip-{0}', inputs.session_id) || '' }}
97
+ fetch-depth: 1
98
+ persist-credentials: false
99
+ - uses: actions/setup-node@v4
100
+ with:
101
+ node-version: 22
102
+ - name: Run Craft
103
+ run: npx -y --package="\${CRAFT_ENGINE:-${ENGINE}}" codeply cloud runner
104
+ env:
105
+ GITHUB_TOKEN: \${{ secrets.GITHUB_TOKEN }}
106
+ CODEPLY_API_KEY: \${{ secrets.CODEPLY_API_KEY }}
107
+ CODEPLY_TRACK_KEY: \${{ secrets.CODEPLY_TRACK_KEY }}
108
+ CODEPLY_MODEL_KIND: \${{ vars.CODEPLY_MODEL_KIND }}
109
+ CODEPLY_MODEL: \${{ vars.CODEPLY_MODEL }}
110
+ CODEPLY_BASE_URL: \${{ vars.CODEPLY_BASE_URL }}
111
+ CRAFT_ENGINE: \${{ vars.CRAFT_ENGINE }}
112
+ CRAFT_ENV: \${{ secrets.CRAFT_ENV }}
113
+ CRAFT_MERGE: \${{ vars.CRAFT_MERGE }}
114
+ CRAFT_PERMISSIONS: \${{ vars.CRAFT_PERMISSIONS }}
115
+ CRAFT_BASE: \${{ github.ref_name }}
116
+ CRAFT_PROMPT: \${{ inputs.prompt }}
117
+ CRAFT_MODE: \${{ inputs.mode }}
118
+ CRAFT_TASK_ID: \${{ inputs.task_id }}
119
+ CRAFT_SESSION_ID: \${{ inputs.session_id }}
120
+ CRAFT_HANDOFF: \${{ inputs.handoff }}
121
+ `;
122
+
123
+ // Never mirrored, whatever .gitignore says.
124
+ export const ALWAYS_EXCLUDE = [
125
+ '.env', '.env.*', '!.env.example', '!.env.sample', '!.env.template',
126
+ '*.pem', '*.key', '*.p12', '*.pfx', '*.keystore', '*.jks', '*.mobileprovision',
127
+ 'id_rsa*', 'id_dsa*', 'id_ecdsa*', 'id_ed25519*', '.netrc', '.pgpass',
128
+ 'node_modules/', '.codeply/', '*.log',
129
+ ];
130
+
131
+ // High-confidence key formats only: a false alarm leaves a real source file out of the mirror.
132
+ const SECRET_PATTERNS = [
133
+ ['private key', /-----BEGIN [A-Z ]*PRIVATE KEY-----/],
134
+ ['Anthropic key', /\bsk-ant-[A-Za-z0-9_-]{20,}/],
135
+ ['OpenAI key', /\bsk-(?:proj-|svcacct-)?[A-Za-z0-9_-]{32,}/],
136
+ ['GitHub token', /\b(?:gh[pousr]_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{30,})/],
137
+ ['Slack token', /\bxox[abprs]-[A-Za-z0-9-]{10,}/],
138
+ ['AWS key', /\bAKIA[0-9A-Z]{16}\b/],
139
+ ['Google key', /\bAIza[0-9A-Za-z_-]{35}\b/],
140
+ ['Stripe key', /\b[sr]k_live_[A-Za-z0-9]{20,}/],
141
+ ['npm token', /\bnpm_[A-Za-z0-9]{36}\b/],
142
+ ['Groq key', /\bgsk_[A-Za-z0-9]{40,}/],
143
+ ['OpenRouter key', /\bsk-or-v1-[a-f0-9]{40,}/],
144
+ ];
145
+
146
+ /** The first key-like string in a file's text, or null. Supabase anon keys are public; service_role keys are not. */
147
+ export function findSecret(text) {
148
+ for (const [label, re] of SECRET_PATTERNS) if (re.test(text)) return label;
149
+ const jwts = text.match(/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g) || [];
150
+ for (const t of jwts) {
151
+ try { if (/service_role/.test(Buffer.from(t.split('.')[1], 'base64url').toString('utf8'))) return 'Supabase service_role key'; } catch {}
152
+ }
153
+ return null;
154
+ }
155
+
156
+ // ─── Encrypted secrets (libsodium sealed box, what GitHub expects) ─────────
157
+
158
+ export function sealSecret(publicKeyB64, value) {
159
+ const pk = new Uint8Array(Buffer.from(publicKeyB64, 'base64'));
160
+ const eph = nacl.box.keyPair();
161
+ const nonceIn = new Uint8Array(eph.publicKey.length + pk.length);
162
+ nonceIn.set(eph.publicKey); nonceIn.set(pk, eph.publicKey.length);
163
+ const nonce = blake.blake2b(nonceIn, undefined, nacl.box.nonceLength);
164
+ const boxed = nacl.box(new Uint8Array(Buffer.from(String(value), 'utf8')), nonce, pk, eph.secretKey);
165
+ return Buffer.concat([Buffer.from(eph.publicKey), Buffer.from(boxed)]).toString('base64');
166
+ }
167
+
168
+ /** Inverse of sealSecret, for tests. */
169
+ export function openSealed(sealedB64, publicKey, secretKey) {
170
+ const all = new Uint8Array(Buffer.from(sealedB64, 'base64'));
171
+ const epk = all.slice(0, 32);
172
+ const nonceIn = new Uint8Array(64); nonceIn.set(epk); nonceIn.set(publicKey, 32);
173
+ const nonce = blake.blake2b(nonceIn, undefined, nacl.box.nonceLength);
174
+ const out = nacl.box.open(all.slice(32), nonce, epk, secretKey);
175
+ return out ? Buffer.from(out).toString('utf8') : null;
176
+ }
177
+
178
+ // ─── GitHub API ──────────────────────────────────────────────────────────
179
+
180
+ export function githubApi({ token, apiUrl = 'https://api.github.com', fetchImpl = fetch }) {
181
+ const call = async (method, route, body, { allow = [] } = {}) => {
182
+ const res = await fetchImpl(`${apiUrl.replace(/\/$/, '')}${route}`, {
183
+ method,
184
+ headers: { Authorization: `Bearer ${token}`, Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28', 'User-Agent': 'codeply-craft', ...(body ? { 'Content-Type': 'application/json' } : {}) },
185
+ body: body ? JSON.stringify(body) : undefined,
186
+ });
187
+ const text = await res.text();
188
+ let json = null;
189
+ try { json = text ? JSON.parse(text) : null; } catch {}
190
+ if (!res.ok && !allow.includes(res.status)) {
191
+ const e = new Error(`GitHub ${method} ${route.split('?')[0]}: ${res.status} ${(json && json.message) || text.slice(0, 200)}`);
192
+ e.status = res.status;
193
+ throw e;
194
+ }
195
+ return { status: res.status, json, text, scopes: res.headers && res.headers.get ? res.headers.get('x-oauth-scopes') : null };
196
+ };
197
+ return call;
198
+ }
199
+
200
+ /**
201
+ * Why a run died before Craft could report back (install failed, time limit,
202
+ * permissions), from the job's steps and log. Plain words, or null.
203
+ */
204
+ export async function diagnoseRun(api, repo, runId) {
205
+ try {
206
+ const jobs = ((await api('GET', `/repos/${repo}/actions/runs/${runId}/jobs`)).json || {}).jobs || [];
207
+ const job = jobs.find((j) => j.conclusion === 'failure' || j.conclusion === 'timed_out') || jobs[0];
208
+ if (!job) return null;
209
+ if (job.conclusion === 'timed_out') return 'The run hit its 60-minute limit and was stopped.';
210
+ const step = (job.steps || []).find((s) => s.conclusion === 'failure');
211
+ let log = '';
212
+ try { log = (await api('GET', `/repos/${repo}/actions/jobs/${job.id}/logs`)).text || ''; } catch {}
213
+ if (/No matching version found for codeply-cli|notarget[\s\S]{0,200}codeply-cli/i.test(log)) {
214
+ return `GitHub couldn't install Craft's cloud engine (${ENGINE}): that version isn't on npm yet. Publish it, then run again.`;
215
+ }
216
+ if (/Resource not accessible by integration/i.test(log)) return 'GitHub refused the run permission to save its work. Check Settings > Actions > General > Workflow permissions on the mirror repo.';
217
+ if (/ENOTFOUND|ECONNRESET|ETIMEDOUT|network/i.test(log) && /npm (error|ERR)/i.test(log)) return 'GitHub\'s runner could not download Craft\'s engine (a network hiccup on GitHub\'s side). Run it again.';
218
+ const lastError = log.split('\n').map((l) => l.replace(/^\S+Z\s/, '').replace(/\x1b\[[0-9;]*m/g, '').trim())
219
+ .filter((l) => /error|failed|fatal/i.test(l) && !/^##\[group\]/.test(l)).slice(-1)[0];
220
+ if (step && step.name === 'Run Craft') return `Craft stopped before it could report back${lastError ? `: ${lastError.replace(/^##\[error\]/, '').slice(0, 240)}` : '.'}`;
221
+ if (step) return `The "${step.name}" step failed on GitHub${lastError ? `: ${lastError.replace(/^##\[error\]/, '').slice(0, 240)}` : '.'}`;
222
+ return null;
223
+ } catch { return null; }
224
+ }
225
+
226
+ // ─── Local state ─────────────────────────────────────────────────────────
227
+
228
+ const defaultHome = () => path.join(os.homedir(), '.codeply');
229
+ const projectKey = (cwd) => path.resolve(cwd).replace(/\\/g, '/').replace(/\/$/, '').toLowerCase();
230
+ const projectId = (cwd) => crypto.createHash('sha1').update(projectKey(cwd)).digest('hex').slice(0, 16);
231
+
232
+ function stateFile(home) { return path.join(home, 'cloud.json'); }
233
+ export function readState(home = defaultHome()) {
234
+ try { const s = JSON.parse(fs.readFileSync(stateFile(home), 'utf8')); return s && typeof s === 'object' && s.projects ? s : { projects: {} }; } catch { return { projects: {} }; }
235
+ }
236
+ function writeState(home, state) {
237
+ fs.mkdirSync(home, { recursive: true });
238
+ const tmp = `${stateFile(home)}.tmp`;
239
+ fs.writeFileSync(tmp, JSON.stringify(state, null, 2));
240
+ fs.renameSync(tmp, stateFile(home));
241
+ }
242
+ export function getProject(cwd, home = defaultHome()) { return readState(home).projects[projectKey(cwd)] || null; }
243
+ function updateProject(cwd, home, fn) {
244
+ const state = readState(home);
245
+ const key = projectKey(cwd);
246
+ state.projects[key] = fn(state.projects[key] || { cwd: path.resolve(cwd), tasks: [] });
247
+ writeState(home, state);
248
+ return state.projects[key];
249
+ }
250
+ /** Remember a task this PC did not start (a handoff run the server dispatched), so status and replay work. */
251
+ export function rememberTask(cwd, task, home = defaultHome()) { return saveTask(cwd, home, task); }
252
+ function saveTask(cwd, home, task) {
253
+ return updateProject(cwd, home, (p) => {
254
+ const tasks = (p.tasks || []).filter((t) => t.id !== task.id);
255
+ tasks.unshift(task);
256
+ return { ...p, tasks: tasks.slice(0, 100) };
257
+ });
258
+ }
259
+ /** Per-project switches the app keeps: cloudOn (send new messages to the cloud), autoBackup, handoff (carry on in the cloud if the PC drops; on unless turned off). */
260
+ export function setProjectOptions(cwd, opts, home = defaultHome()) {
261
+ const allowed = {};
262
+ for (const k of ['cloudOn', 'autoBackup', 'handoff']) if (typeof opts[k] === 'boolean') allowed[k] = opts[k];
263
+ return updateProject(cwd, home, (p) => ({ ...p, ...allowed }));
264
+ }
265
+
266
+ export function getTask(cwd, id, home = defaultHome()) {
267
+ const p = getProject(cwd, home);
268
+ return p && (p.tasks || []).find((t) => t.id === id) || null;
269
+ }
270
+
271
+ // ─── Snapshots ───────────────────────────────────────────────────────────
272
+
273
+ export const BOT = ['-c', 'user.name=Codeply Craft', '-c', 'user.email=craft-bot@users.noreply.github.com'];
274
+
275
+ function mirrorGit(cwd, home) {
276
+ const gitDir = path.join(home, 'cloud', `${projectId(cwd)}.git`);
277
+ const G = (args, opts) => git(cwd, ['--git-dir', gitDir, '--work-tree', cwd, ...args], opts);
278
+ return { gitDir, G };
279
+ }
280
+
281
+ export async function initMirrorGit(cwd, home) {
282
+ const m = mirrorGit(cwd, home);
283
+ if (!fs.existsSync(path.join(m.gitDir, 'HEAD'))) {
284
+ fs.mkdirSync(m.gitDir, { recursive: true });
285
+ await git(cwd, ['init', '-q', '--bare', m.gitDir]);
286
+ await m.G(['config', 'core.bare', 'false']);
287
+ await m.G(['config', 'core.autocrlf', 'false']);
288
+ await m.G(['config', 'core.quotepath', 'false']);
289
+ await m.G(['symbolic-ref', 'HEAD', 'refs/heads/main']);
290
+ }
291
+ fs.mkdirSync(path.join(m.gitDir, 'info'), { recursive: true });
292
+ fs.writeFileSync(path.join(m.gitDir, 'info', 'exclude'), `${ALWAYS_EXCLUDE.join('\n')}\n`);
293
+ return m;
294
+ }
295
+
296
+ /**
297
+ * Stage the working tree into Craft's own index: .gitignore and ALWAYS_EXCLUDE
298
+ * respected, the project's own workflows left out (they must not run on the
299
+ * mirror), big files and files holding keys dropped, craft-cloud.yml added.
300
+ */
301
+ export async function stageWorkingTree(cwd, home) {
302
+ const m = await initMirrorGit(cwd, home);
303
+ const { G } = m;
304
+ await G(['add', '-A', '--', '.']);
305
+ await G(['rm', '-r', '-q', '--cached', '--ignore-unmatch', '--', '.github/workflows']);
306
+ const hasHead = await G(['rev-parse', '-q', '--verify', 'refs/heads/main']).then(() => true, () => false);
307
+ const listed = hasHead
308
+ ? await G(['diff', '--cached', '--name-only', '--diff-filter=AM', '-z', 'refs/heads/main'])
309
+ : await G(['ls-files', '-z', '--cached']);
310
+ const skipped = [];
311
+ for (const rel of listed.split('\0').filter(Boolean)) {
312
+ const full = path.join(cwd, rel);
313
+ let reason = null;
314
+ try {
315
+ const st = fs.statSync(full);
316
+ if (st.size > MAX_FILE_BYTES) reason = 'over 50 MB';
317
+ else if (st.size <= MAX_SCAN_BYTES) {
318
+ const buf = fs.readFileSync(full);
319
+ if (!buf.subarray(0, 8000).includes(0)) {
320
+ const found = findSecret(buf.toString('utf8'));
321
+ if (found) reason = `holds a ${found}`;
322
+ }
323
+ }
324
+ } catch { continue; }
325
+ if (reason) {
326
+ await G(['rm', '-q', '--cached', '--', rel]);
327
+ skipped.push({ path: rel.replace(/\\/g, '/'), reason });
328
+ }
329
+ }
330
+ const blob = await git(cwd, ['--git-dir', m.gitDir, 'hash-object', '-w', '--stdin'], { input: CLOUD_WORKFLOW });
331
+ await G(['update-index', '--add', '--cacheinfo', `100644,${blob},${WORKFLOW_PATH}`]);
332
+ return { ...m, hasHead, skipped };
333
+ }
334
+
335
+ /** Commit the working tree to the local mirror git (no network). */
336
+ export async function snapshot({ cwd, home = defaultHome() }) {
337
+ const s = await stageWorkingTree(cwd, home);
338
+ const tree = await s.G(['write-tree']);
339
+ const head = s.hasHead ? await s.G(['rev-parse', 'refs/heads/main']) : null;
340
+ if (head && (await s.G(['rev-parse', `${head}^{tree}`])) === tree) return { gitDir: s.gitDir, sha: head, changed: false, skipped: s.skipped };
341
+ const sha = await git(cwd, ['--git-dir', s.gitDir, ...BOT, 'commit-tree', tree, ...(head ? ['-p', head] : []), '-m', `Snapshot from ${os.hostname()} at ${new Date().toISOString()}`]);
342
+ await s.G(['update-ref', 'refs/heads/main', sha]);
343
+ return { gitDir: s.gitDir, sha, changed: true, skipped: s.skipped };
344
+ }
345
+
346
+ export const authFor = (token, serverUrl = 'https://github.com') => ({ token, server: serverUrl.replace(/\/$/, '') });
347
+
348
+ /** Snapshot, then push it to the mirror. The mirror's main is Craft's, so it is force-pushed. */
349
+ export async function pushSnapshot({ cwd, token, home = defaultHome(), serverUrl = 'https://github.com', remoteUrl }) {
350
+ const p = getProject(cwd, home);
351
+ if (!p || !p.repo) throw new Error('Cloud is not set up for this project yet.');
352
+ // A project that works in its own GitHub repo is never snapshotted: that would overwrite its real main.
353
+ if (p.kind === 'repo') throw new Error(`${p.repo} is this project's own repo; Craft does not back up over it.`);
354
+ const snap = await snapshot({ cwd, home });
355
+ if (snap.changed || !p.lastPush || p.lastPush.sha !== snap.sha) {
356
+ const url = remoteUrl || `${serverUrl.replace(/\/$/, '')}/${p.repo}.git`;
357
+ await git(cwd, ['--git-dir', snap.gitDir, 'push', '-q', '--force', url, 'refs/heads/main:refs/heads/main'], { auth: authFor(token, serverUrl) });
358
+ updateProject(cwd, home, (q) => ({ ...q, lastPush: { sha: snap.sha, at: Date.now(), skipped: snap.skipped } }));
359
+ return { ...snap, pushed: true };
360
+ }
361
+ return { ...snap, pushed: false };
362
+ }
363
+
364
+ // ─── Setup ───────────────────────────────────────────────────────────────
365
+
366
+ const slug = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^[-.]+|[-.]+$/g, '').slice(0, 60) || 'project';
367
+
368
+ /** Find or create the private mirror repo for a project. */
369
+ export async function ensureMirror({ api, cwd, home = defaultHome() }) {
370
+ const existing = getProject(cwd, home);
371
+ if (existing && existing.repo) {
372
+ const r = await api('GET', `/repos/${existing.repo}`, null, { allow: [404] });
373
+ if (r.status === 200) return { repo: existing.repo, created: false };
374
+ }
375
+ const me = (await api('GET', '/user')).json;
376
+ const base = `${MIRROR_PREFIX}${slug(path.basename(path.resolve(cwd)))}`;
377
+ for (let i = 1; i <= 9; i++) {
378
+ const name = i === 1 ? base : `${base}-${i}`;
379
+ const r = await api('GET', `/repos/${me.login}/${name}`, null, { allow: [404] });
380
+ if (r.status === 200 && r.json.description === MIRROR_DESCRIPTION && r.json.private) {
381
+ updateProject(cwd, home, (p) => ({ ...p, repo: r.json.full_name }));
382
+ return { repo: r.json.full_name, created: false };
383
+ }
384
+ if (r.status === 404) {
385
+ const made = await api('POST', '/user/repos', { name, private: true, description: MIRROR_DESCRIPTION, auto_init: false, has_issues: false, has_wiki: false, has_projects: false });
386
+ updateProject(cwd, home, (p) => ({ ...p, repo: made.json.full_name, createdAt: Date.now() }));
387
+ return { repo: made.json.full_name, created: true };
388
+ }
389
+ }
390
+ throw new Error(`Could not find a free name for the mirror (tried ${base} to ${base}-9).`);
391
+ }
392
+
393
+ /**
394
+ * Which model a cloud run uses. Auto (Codeply's router, through the run's usage
395
+ * key, counted in the person's allowance) unless they picked one of their own
396
+ * models that GitHub can reach; a model on this PC (or with no key) uses Auto.
397
+ */
398
+ export const AUTO_CLOUD = { kind: 'auto', model: 'Auto', sig: 'auto' };
399
+ export function cloudModelConfig(model) {
400
+ if (!model || !model.model) return AUTO_CLOUD;
401
+ const kind = model.kind === 'ollama' ? 'ollama' : 'openai';
402
+ const baseUrl = kind === 'ollama' ? String(model.baseUrl || 'https://ollama.com').replace(/\/+$/, '').replace(/\/(api|v1)$/i, '') : String(model.baseUrl || '').trim();
403
+ if (!baseUrl || !model.apiKey) return AUTO_CLOUD;
404
+ if (/^https?:\/\/(localhost|127\.|0\.0\.0\.0|\[::1\]|192\.168\.|10\.|172\.(1[6-9]|2\d|3[01])\.)/i.test(baseUrl)) return AUTO_CLOUD;
405
+ return { kind, baseUrl, model: model.model, apiKey: model.apiKey, sig: crypto.createHash('sha256').update([kind, baseUrl, model.model, model.apiKey].join('\n')).digest('hex').slice(0, 16) };
406
+ }
407
+
408
+ async function setVariable(api, repo, name, value) {
409
+ const r = await api('PATCH', `/repos/${repo}/actions/variables/${name}`, { name, value }, { allow: [404] });
410
+ if (r.status === 404) await api('POST', `/repos/${repo}/actions/variables`, { name, value });
411
+ }
412
+
413
+ /** Put the model key in the mirror's encrypted secrets and the rest in variables. */
414
+ export async function configureModel({ api, repo, model }) {
415
+ const cfg = cloudModelConfig(model);
416
+ if (cfg.kind === 'auto') {
417
+ // Auto: no model key in the repo at all; the runner reaches Codeply's router with its usage key
418
+ await api('DELETE', `/repos/${repo}/actions/secrets/CODEPLY_API_KEY`, null, { allow: [404] });
419
+ for (const name of ['CODEPLY_BASE_URL', 'CODEPLY_MODEL']) await api('DELETE', `/repos/${repo}/actions/variables/${name}`, null, { allow: [404] });
420
+ await setVariable(api, repo, 'CODEPLY_MODEL_KIND', 'auto');
421
+ return cfg;
422
+ }
423
+ const pk = (await api('GET', `/repos/${repo}/actions/secrets/public-key`)).json;
424
+ await api('PUT', `/repos/${repo}/actions/secrets/CODEPLY_API_KEY`, { encrypted_value: sealSecret(pk.key, cfg.apiKey), key_id: pk.key_id });
425
+ await setVariable(api, repo, 'CODEPLY_MODEL_KIND', cfg.kind);
426
+ await setVariable(api, repo, 'CODEPLY_BASE_URL', cfg.baseUrl);
427
+ await setVariable(api, repo, 'CODEPLY_MODEL', cfg.model);
428
+ return cfg;
429
+ }
430
+
431
+ /** Check the token can create repos and push workflow files. */
432
+ export async function checkToken(api) {
433
+ const r = await api('GET', '/user');
434
+ const scopes = String(r.scopes || '').split(',').map((s) => s.trim()).filter(Boolean);
435
+ // Fine-grained and app tokens send no scope header; let GitHub decide on those.
436
+ if (r.scopes != null && scopes.length && !(scopes.includes('repo') && scopes.includes('workflow'))) {
437
+ return { ok: false, login: r.json.login, error: 'GitHub needs to be reconnected so Craft may create private repos and workflow files (repo and workflow access).' };
438
+ }
439
+ return { ok: true, login: r.json.login };
440
+ }
441
+
442
+ // ─── Targets: the project's own GitHub repo, or a private mirror ─────────
443
+
444
+ /** owner/name of the project's GitHub `origin`, or null. */
445
+ export async function githubOrigin(cwd) {
446
+ try {
447
+ const url = await git(cwd, ['remote', 'get-url', 'origin']);
448
+ const m = /github\.com[:/]([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/i.exec(url);
449
+ return m ? `${m[1]}/${m[2]}` : null;
450
+ } catch { return null; }
451
+ }
452
+
453
+ /** The repos this login can push to (for the phone's and the app's repo picker). */
454
+ export async function listMyRepos(api) {
455
+ const r = await api('GET', '/user/repos?affiliation=owner,collaborator&sort=pushed&per_page=100');
456
+ return (r.json || []).filter((x) => x.permissions && x.permissions.push)
457
+ .map((x) => ({ repo: x.full_name, private: x.private, base: x.default_branch, mirror: x.description === MIRROR_DESCRIPTION, pushedAt: x.pushed_at }));
458
+ }
459
+
460
+ /** Commit craft-cloud.yml to the repo's default branch (only when missing or out of date). */
461
+ export async function installWorkflowInRepo({ api, repo, base }) {
462
+ const files = branchFiles(api, repo, base);
463
+ const cur = await api('GET', `/repos/${repo}/contents/${WORKFLOW_PATH}?ref=${encodeURIComponent(base)}`, null, { allow: [404] });
464
+ const want = Buffer.from(CLOUD_WORKFLOW).toString('base64');
465
+ if (cur.status === 200 && String(cur.json.content || '').replace(/\s/g, '') === want) return false;
466
+ const ok = await files.writeBytes(WORKFLOW_PATH, Buffer.from(CLOUD_WORKFLOW), 'Add Craft Cloud workflow');
467
+ if (!ok) throw new Error(`Could not add ${WORKFLOW_PATH} to ${repo}. GitHub needs the workflow permission for that.`);
468
+ return true;
469
+ }
470
+
471
+ /** The project's environment for running and testing (KEY=value lines), as the CRAFT_ENV secret. */
472
+ export async function setProjectEnv({ api, repo, envText }) {
473
+ const text = String(envText || '').trim();
474
+ if (!text) { await api('DELETE', `/repos/${repo}/actions/secrets/CRAFT_ENV`, null, { allow: [404] }); return 0; }
475
+ const pk = (await api('GET', `/repos/${repo}/actions/secrets/public-key`)).json;
476
+ await api('PUT', `/repos/${repo}/actions/secrets/CRAFT_ENV`, { encrypted_value: sealSecret(pk.key, text), key_id: pk.key_id });
477
+ return text.split('\n').filter((l) => /^\s*[A-Za-z_][A-Za-z0-9_]*\s*=/.test(l)).length;
478
+ }
479
+
480
+ /**
481
+ * Usage counts for the admin dashboard from cloud runs: a run has no Codeply
482
+ * sign-in, so Craft asks Codeply for a usage key for the signed-in user and puts
483
+ * it in the repo's encrypted secrets (CODEPLY_TRACK_KEY). Counts only, like the
484
+ * desktop apps. Never fails a setup or a run: without a sign-in, or with
485
+ * telemetry off, it does nothing.
486
+ */
487
+ export async function ensureTrackKey({ api, repo, cwd, home = defaultHome(), fetchImpl = fetch }) {
488
+ try {
489
+ const p = getProject(cwd, home) || {};
490
+ if (p.trackKeyRepo === repo) return false;
491
+ // Needed whatever the usage-stats setting: the run reaches Auto and the connected
492
+ // apps with it (telemetry.js still sends no stats when they're switched off).
493
+ const auth = require('./auth.js');
494
+ const token = await auth.getAccessToken().catch(() => null);
495
+ if (!token) return false;
496
+ const r = await fetchImpl(auth.SUPABASE_URL + '/functions/v1/track', {
497
+ method: 'POST',
498
+ headers: { Authorization: 'Bearer ' + token, apikey: auth.SUPABASE_ANON_KEY, 'Content-Type': 'application/json' },
499
+ body: JSON.stringify({ action: 'mint', label: 'cloud ' + repo }),
500
+ signal: AbortSignal.timeout(15_000),
501
+ });
502
+ const j = await r.json().catch(() => ({}));
503
+ if (!r.ok || !j.key) return false;
504
+ const pk = (await api('GET', '/repos/' + repo + '/actions/secrets/public-key')).json;
505
+ await api('PUT', '/repos/' + repo + '/actions/secrets/CODEPLY_TRACK_KEY', { encrypted_value: sealSecret(pk.key, j.key), key_id: pk.key_id });
506
+ updateProject(cwd, home, (q) => ({ ...q, trackKeyRepo: repo }));
507
+ return true;
508
+ } catch { return false; }
509
+ }
510
+
511
+ /**
512
+ * Connected apps (Gmail, Slack, Vercel, Supabase, GitHub) for cloud runs. A run
513
+ * happens on GitHub's servers, which don't have the sign-ins saved on this PC,
514
+ * so Craft keeps an encrypted copy on Codeply's server for the signed-in user
515
+ * (the connections function) and the runner loads it at start with the usage
516
+ * key. Sent only when something changed. Off with "cloudConnections": false in
517
+ * ~/.codeply/config.json. Never fails a setup or a run.
518
+ */
519
+ export const CONNECTION_APPS = ['gmail', 'slack', 'vercel', 'supabase', 'github'];
520
+ export async function syncCloudConnections({ home = defaultHome(), fetchImpl = fetch } = {}) {
521
+ try {
522
+ const config = require('./config.js');
523
+ if (config.getConfig().cloudConnections === false) return false;
524
+ const connections = {};
525
+ for (const name of CONNECTION_APPS) {
526
+ const c = config.getIntegration(name); // includes the app's OAuth client, which token refresh needs
527
+ if (c.accessToken) connections[name] = c;
528
+ }
529
+ const sig = crypto.createHash('sha256').update(JSON.stringify(connections)).digest('hex');
530
+ const state = readState(home);
531
+ if (state.connectionsSig === sig) return false;
532
+ const auth = require('./auth.js');
533
+ const token = await auth.getAccessToken().catch(() => null);
534
+ if (!token) return false;
535
+ const r = await fetchImpl(auth.SUPABASE_URL + '/functions/v1/connections', {
536
+ method: 'POST',
537
+ headers: { Authorization: 'Bearer ' + token, apikey: auth.SUPABASE_ANON_KEY, 'Content-Type': 'application/json' },
538
+ body: JSON.stringify({ action: 'save', connections }),
539
+ signal: AbortSignal.timeout(15_000),
540
+ });
541
+ if (!r.ok) return false;
542
+ writeState(home, { ...readState(home), connectionsSig: sig });
543
+ return true;
544
+ } catch { return false; }
545
+ }
546
+
547
+ /** In the runner: put the user's connected apps into this machine's config, so the agent's tools can use them. */
548
+ export async function loadCloudConnections({ env = process.env, fetchImpl = fetch, log = () => {} } = {}) {
549
+ const key = String(env.CODEPLY_TRACK_KEY || '').trim();
550
+ if (!key) return [];
551
+ try {
552
+ const auth = require('./auth.js');
553
+ const r = await fetchImpl(auth.SUPABASE_URL + '/functions/v1/connections', {
554
+ method: 'POST',
555
+ headers: { 'x-codeply-track-key': key, apikey: auth.SUPABASE_ANON_KEY, 'Content-Type': 'application/json' },
556
+ body: JSON.stringify({ action: 'get' }),
557
+ signal: AbortSignal.timeout(15_000),
558
+ });
559
+ const j = await r.json().catch(() => ({}));
560
+ if (!r.ok || !j.connections) return [];
561
+ const config = require('./config.js');
562
+ const loaded = [];
563
+ for (const name of CONNECTION_APPS) {
564
+ const c = j.connections[name];
565
+ if (c && c.accessToken) { config.saveIntegration(name, c); loaded.push(name); }
566
+ }
567
+ if (loaded.length) log('Connected apps: ' + loaded.join(', '));
568
+ return loaded;
569
+ } catch (e) { log('Could not load connected apps: ' + e.message); return []; }
570
+ }
571
+
572
+ // Bumped when CLOUD_WORKFLOW changes, so repos set up earlier get the new file once.
573
+ export const WORKFLOW_REV = 4;
574
+
575
+ /**
576
+ * One-click setup. With `target: 'repo'` (default when the project has a GitHub
577
+ * origin) the cloud works in that repo: new branch per task, merged into the
578
+ * default branch, and the PC pulls. Otherwise a private mirror holds snapshots.
579
+ */
580
+ export async function setupCloud({ cwd, token, model, target, envText, home = defaultHome(), apiUrl, serverUrl, fetchImpl, remoteUrl }) {
581
+ const api = githubApi({ token, apiUrl, fetchImpl });
582
+ const t = await checkToken(api);
583
+ if (!t.ok) throw new Error(t.error);
584
+ const cfg = cloudModelConfig(model);
585
+ if (cfg.error) throw new Error(cfg.error);
586
+ const origin = target === 'mirror' ? null : await githubOrigin(cwd);
587
+ let repo; let created = false; let skipped = []; let base = 'main'; let kind = 'mirror';
588
+ if (origin) {
589
+ const r = await api('GET', `/repos/${origin}`, null, { allow: [404] });
590
+ if (r.status !== 200) throw new Error(`Craft can't open ${origin} with this GitHub login.`);
591
+ if (!r.json.permissions || !r.json.permissions.push) throw new Error(`This GitHub login can't push to ${origin}.`);
592
+ repo = origin; base = r.json.default_branch || 'main'; kind = 'repo';
593
+ updateProject(cwd, home, (p) => ({ ...p, repo, base, kind }));
594
+ await installWorkflowInRepo({ api, repo, base });
595
+ } else {
596
+ ({ repo, created } = await ensureMirror({ api, cwd, home }));
597
+ updateProject(cwd, home, (p) => ({ ...p, base: 'main', kind: 'mirror' }));
598
+ skipped = (await pushSnapshot({ cwd, token, home, serverUrl, remoteUrl })).skipped;
599
+ }
600
+ await configureModel({ api, repo, model });
601
+ if (envText != null) await setProjectEnv({ api, repo, envText });
602
+ updateProject(cwd, home, (p) => ({ ...p, modelSig: cfg.sig, modelName: cfg.kind === 'auto' ? 'Auto' : (model.name || model.model), enabled: true, workflowRev: WORKFLOW_REV }));
603
+ await ensureTrackKey({ api, repo, cwd, home, fetchImpl });
604
+ await syncCloudConnections({ home, fetchImpl });
605
+ return { repo, created, kind, base, url: `${(serverUrl || 'https://github.com').replace(/\/$/, '')}/${repo}`, skipped };
606
+ }
607
+
608
+ /** Before a PC run is protected (handoff.mjs): the repo's craft-cloud.yml must know handoff runs. */
609
+ export async function ensureHandoffReady({ cwd, token, home = defaultHome(), apiUrl, fetchImpl, serverUrl, remoteUrl }) {
610
+ const p = getProject(cwd, home);
611
+ if (!p || !p.repo) throw new Error('Set up cloud runs for this project first.');
612
+ if (p.workflowRev === WORKFLOW_REV) return p;
613
+ if (p.kind === 'repo') await installWorkflowInRepo({ api: githubApi({ token, apiUrl, fetchImpl }), repo: p.repo, base: p.base || 'main' });
614
+ else await pushSnapshot({ cwd, token, home, serverUrl, remoteUrl }); // the mirror gets the workflow with every snapshot
615
+ return updateProject(cwd, home, (q) => ({ ...q, workflowRev: WORKFLOW_REV }));
616
+ }
617
+
618
+ // ─── Runs ────────────────────────────────────────────────────────────────
619
+
620
+ const newTaskId = () => `${Date.now().toString(36)}${crypto.randomBytes(3).toString('hex')}`;
621
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
622
+ const LIVE_FRESH_MS = 60000;
623
+
624
+ /** Is a runner up for this chat right now (idle and waiting, or busy and will get to it)? */
625
+ export async function liveRunner(api, repo, sessionId) {
626
+ if (!sessionId) return null;
627
+ const f = await branchFiles(api, repo).read(SESSION_PATHS.live(sessionId)).catch(() => null);
628
+ const live = f && f.json;
629
+ if (!live || Date.now() - (live.at || 0) > LIVE_FRESH_MS) return null;
630
+ if (!live.busy && (live.until || 0) < Date.now() + 8000) return null;
631
+ return live;
632
+ }
633
+
634
+ async function dispatch(api, repo, base, task, retryMs) {
635
+ let lastErr = null;
636
+ // Right after the workflow file first lands, GitHub can take a few seconds to know it.
637
+ for (let i = 0; i < 10; i++) {
638
+ try {
639
+ await api('POST', `/repos/${repo}/actions/workflows/${WORKFLOW_FILE}/dispatches`, { ref: base || 'main', inputs: { prompt: task.prompt, mode: task.mode, task_id: task.id, session_id: task.sessionId } });
640
+ return;
641
+ } catch (e) {
642
+ lastErr = e;
643
+ if (e.status !== 404 && e.status !== 422) break;
644
+ await sleep(retryMs);
645
+ }
646
+ }
647
+ throw lastErr;
648
+ }
649
+
650
+ /**
651
+ * Start a cloud task. A runner already up for this chat gets it through the
652
+ * queue at once; otherwise a new runner is dispatched. Mirror projects push
653
+ * the latest snapshot first.
654
+ */
655
+ export async function startCloudRun({ cwd, token, prompt, mode = 'Build', sessionId = '', bot = null, kind = 'code', model, home = defaultHome(), apiUrl, serverUrl, fetchImpl, remoteUrl, retryMs = 3000, startedAt }) {
656
+ const p = getProject(cwd, home);
657
+ if (!p || !p.repo) throw new Error('Set up cloud runs for this project first.');
658
+ prompt = String(prompt || '').trim();
659
+ if (!prompt) throw new Error('Write a task first.');
660
+ if (prompt.length > MAX_PROMPT) throw new Error(`That task is too long for a cloud run (${prompt.length} characters, the limit is ${MAX_PROMPT}).`);
661
+ mode = ['Build', 'Plan', 'Ask'].includes(mode) ? mode : 'Build';
662
+ const api = githubApi({ token, apiUrl, fetchImpl });
663
+ // model: undefined leaves the repo's model as it is; null (Auto selected) or a model sets it
664
+ if (model !== undefined) {
665
+ const cfg = cloudModelConfig(model);
666
+ if (cfg.sig !== p.modelSig) {
667
+ await configureModel({ api, repo: p.repo, model });
668
+ updateProject(cwd, home, (q) => ({ ...q, modelSig: cfg.sig, modelName: cfg.kind === 'auto' ? 'Auto' : (model.name || model.model) }));
669
+ }
670
+ }
671
+ const base = p.base || 'main';
672
+ // repos set up before a workflow change get the new file once (mirrors get it with every snapshot)
673
+ if (p.kind === 'repo' && p.workflowRev !== WORKFLOW_REV) {
674
+ await installWorkflowInRepo({ api, repo: p.repo, base });
675
+ updateProject(cwd, home, (q) => ({ ...q, workflowRev: WORKFLOW_REV }));
676
+ }
677
+ await ensureTrackKey({ api, repo: p.repo, cwd, home, fetchImpl });
678
+ await syncCloudConnections({ home, fetchImpl });
679
+ let baseSha;
680
+ if (p.kind === 'repo') {
681
+ const ref = await api('GET', `/repos/${p.repo}/git/ref/heads/${encodeURIComponent(base)}`, null, { allow: [404] });
682
+ baseSha = ref.status === 200 ? ref.json.object.sha : null;
683
+ } else {
684
+ baseSha = (await pushSnapshot({ cwd, token, home, serverUrl, remoteUrl })).sha;
685
+ }
686
+ const task = { id: newTaskId(), prompt, mode, sessionId: String(sessionId || ''), baseSha, repo: p.repo, status: 'starting', startedAt: startedAt || Date.now() };
687
+ const b = cleanBot(bot);
688
+ const extra = { ...(b ? { bot: b } : {}), ...(cleanKind(kind) === 'task' ? { kind: 'task' } : {}) };
689
+ if (b) task.bot = { name: b.name };
690
+ if (extra.kind) task.kind = 'task';
691
+ const files = branchFiles(api, p.repo);
692
+ const live = await liveRunner(api, p.repo, task.sessionId);
693
+ if (live) {
694
+ await files.write(`${SESSION_PATHS.queue(task.sessionId)}/${Date.now()}-${task.id}.json`, { id: task.id, prompt, mode, ...extra }, `Craft queue ${task.id}`);
695
+ Object.assign(task, { queued: true, enqueuedAt: Date.now() });
696
+ } else {
697
+ // A bot's prompt is too big for a workflow input: the runner reads it from craft-sessions.
698
+ if (Object.keys(extra).length) {
699
+ if (baseSha) await files.ensure(baseSha).catch(() => {});
700
+ if (!(await files.write(SESSION_PATHS.request(task.id), extra, `Craft request ${task.id}`))) throw new Error('Could not hand the bot to the cloud. Try again.');
701
+ }
702
+ await dispatch(api, p.repo, base, task, retryMs);
703
+ }
704
+ saveTask(cwd, home, task);
705
+ return task;
706
+ }
707
+
708
+ /** A task's live status document (tasks/<id>.json), or null before the runner has written it. */
709
+ export async function readTaskDoc(api, repo, taskId) {
710
+ const f = await branchFiles(api, repo).read(SESSION_PATHS.task(taskId)).catch(() => null);
711
+ return f && f.json ? f.json : null;
712
+ }
713
+
714
+ /** Where a cloud task is: its status file first, then the workflow run (for starts and failed starts). */
715
+ /** The person's answer to a cloud run's question (allow or not), picked up by the runner within seconds. */
716
+ export async function answerCloudAsk({ cwd, token, taskId, askId, approve, home = defaultHome(), apiUrl, fetchImpl }) {
717
+ const task = getTask(cwd, taskId, home);
718
+ if (!task) throw new Error('Unknown cloud task.');
719
+ if (!/^[\w.-]+$/.test(String(askId || ''))) throw new Error('Unknown question.');
720
+ const api = githubApi({ token, apiUrl, fetchImpl });
721
+ const ok = await branchFiles(api, task.repo).write(SESSION_PATHS.answer(askId), { verdict: approve ? 'approve' : 'reject', at: Date.now() }, `Craft answer ${askId}`);
722
+ if (!ok) throw new Error('Could not send your answer to GitHub. Try again.');
723
+ saveTask(cwd, home, { ...task, ask: null });
724
+ return true;
725
+ }
726
+
727
+ /** "ask" (risky actions wait for an OK) or "full" (they just run), kept as the repo variable CRAFT_PERMISSIONS. */
728
+ export async function setCloudPermission({ cwd, token, permission, home = defaultHome(), apiUrl, fetchImpl }) {
729
+ const p = getProject(cwd, home);
730
+ if (!p || !p.repo) throw new Error('Set up cloud runs for this project first.');
731
+ const value = permission === 'full' ? 'full' : 'ask';
732
+ await setVariable(githubApi({ token, apiUrl, fetchImpl }), p.repo, 'CRAFT_PERMISSIONS', value);
733
+ updateProject(cwd, home, (q) => ({ ...q, permission: value }));
734
+ return value;
735
+ }
736
+
737
+ export async function cloudRunStatus({ cwd, taskId, token, home = defaultHome(), apiUrl, fetchImpl, retryMs = 3000 }) {
738
+ const task = getTask(cwd, taskId, home);
739
+ if (!task) throw new Error('Unknown cloud task.');
740
+ if (task.status === 'done' || task.status === 'failed' || task.status === 'cancelled') return task;
741
+ const api = githubApi({ token, apiUrl, fetchImpl });
742
+ const next = { ...task };
743
+ const doc = await readTaskDoc(api, task.repo, task.id);
744
+ if (doc) {
745
+ const { events = [], status, ...rest } = doc;
746
+ if (doc.runId) { next.runId = doc.runId; next.runUrl = `https://github.com/${task.repo}/actions/runs/${doc.runId}`; }
747
+ if (status === 'done' || status === 'failed') {
748
+ const { id: _i, startedAt: _s, updatedAt: _u, runId: _r, ...result } = rest;
749
+ Object.assign(next, { status, result, error: result.error || null, finishedAt: Date.now(), queued: false });
750
+ } else next.status = 'running';
751
+ // a risky action waiting for the person's OK (answerCloudAsk)
752
+ next.ask = next.status === 'running' && doc.ask && doc.ask.id ? doc.ask : null;
753
+ saveTask(cwd, home, next);
754
+ return { ...next, events };
755
+ }
756
+ if (task.queued) {
757
+ // Handed to a runner that went away before taking it: start a new one.
758
+ if (Date.now() - task.enqueuedAt > 30000 && !(await liveRunner(api, task.repo, task.sessionId))) {
759
+ const p = getProject(cwd, home) || {};
760
+ await dispatch(api, task.repo, p.base || 'main', task, retryMs);
761
+ Object.assign(next, { queued: false, status: 'starting', redispatchedAt: Date.now() });
762
+ } else next.status = 'queued';
763
+ saveTask(cwd, home, next);
764
+ return { ...next, events: [] };
765
+ }
766
+ let run = null;
767
+ if (task.runId) run = (await api('GET', `/repos/${task.repo}/actions/runs/${task.runId}`)).json;
768
+ else {
769
+ const list = (await api('GET', `/repos/${task.repo}/actions/workflows/${WORKFLOW_FILE}/runs?event=workflow_dispatch&per_page=30`)).json;
770
+ run = (list.workflow_runs || []).find((r) => r.display_title === checkName(task.id)) || null;
771
+ }
772
+ if (!run) {
773
+ // GitHub drops a dispatch it can't start; don't wait forever.
774
+ if (Date.now() - task.startedAt > 5 * 60 * 1000) Object.assign(next, { status: 'failed', error: 'GitHub never started the run. Check that Actions is enabled on the repo.' });
775
+ saveTask(cwd, home, next);
776
+ return { ...next, events: [] };
777
+ }
778
+ next.runId = run.id;
779
+ next.runUrl = run.html_url;
780
+ if (run.status === 'completed') {
781
+ // Finished without a status file: it died before Craft could report (or an older engine ran).
782
+ const checks = (await api('GET', `/repos/${task.repo}/commits/${run.head_sha}/check-runs?check_name=${encodeURIComponent(checkName(task.id))}`)).json;
783
+ const check = (checks.check_runs || [])[0];
784
+ let data = {};
785
+ try { data = JSON.parse((check && check.output && check.output.text) || '{}'); } catch {}
786
+ if (check && check.status === 'completed' && data.mode) {
787
+ const { events = [], ...result } = data;
788
+ Object.assign(next, { status: check.conclusion === 'success' ? 'done' : 'failed', result, error: result.error || null, finishedAt: Date.now() });
789
+ saveTask(cwd, home, next);
790
+ return { ...next, events };
791
+ }
792
+ const why = run.conclusion === 'cancelled' ? 'The run was cancelled.' : (await diagnoseRun(api, task.repo, run.id)) || `The run ended (${run.conclusion}) before Craft could report back. The run log has details.`;
793
+ Object.assign(next, { status: run.conclusion === 'cancelled' ? 'cancelled' : 'failed', error: why, finishedAt: Date.now() });
794
+ } else {
795
+ next.status = run.status === 'queued' || run.status === 'waiting' || run.status === 'pending' ? 'queued' : 'running';
796
+ }
797
+ saveTask(cwd, home, next);
798
+ return { ...next, events: [] };
799
+ }
800
+
801
+ /** Download a cloud screenshot (shots/<task>/<n>.png on craft-sessions) to `dest`. */
802
+ export async function downloadShot({ token, repo, path: rel, dest, apiUrl, fetchImpl }) {
803
+ const api = githubApi({ token, apiUrl, fetchImpl });
804
+ const r = await api('GET', `/repos/${repo}/contents/${rel.split('/').map(encodeURIComponent).join('/')}?ref=${SESSIONS_BRANCH}`, null, { allow: [404] });
805
+ if (r.status !== 200 || !r.json || !r.json.content) return null;
806
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
807
+ fs.writeFileSync(dest, Buffer.from(r.json.content, 'base64'));
808
+ return dest;
809
+ }
810
+
811
+ /**
812
+ * Before coding: is the local branch behind GitHub (a cloud run merged work
813
+ * into it)? Only for projects whose cloud target is their own repo.
814
+ */
815
+ /**
816
+ * Background git talks to GitHub with Craft's own GitHub login (when given),
817
+ * never through Windows' credential manager, which would pop up a sign-in
818
+ * window every time Craft checks for new commits.
819
+ */
820
+ const QUIET_GIT = ['-c', 'credential.helper='];
821
+ const quietAuth = (token) => (token ? { server: 'https://github.com', token } : undefined);
822
+
823
+ export async function checkBehind(cwd, { token } = {}) {
824
+ try {
825
+ const branch = await git(cwd, ['rev-parse', '--abbrev-ref', 'HEAD']);
826
+ const upstream = await git(cwd, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}']).catch(() => null);
827
+ if (!upstream) return { ok: false, reason: 'no upstream' };
828
+ await git(cwd, [...QUIET_GIT, 'fetch', '-q', '--no-tags', upstream.split('/')[0]], { auth: quietAuth(token) });
829
+ const [behind, ahead] = (await git(cwd, ['rev-list', '--left-right', '--count', `${upstream}...HEAD`])).split(/\s+/).map(Number);
830
+ const dirty = !!(await git(cwd, ['status', '--porcelain', '--untracked-files=no']));
831
+ const latest = behind ? (await git(cwd, ['log', '-1', '--format=%s', upstream]).catch(() => '')) : '';
832
+ return { ok: true, branch, upstream, behind, ahead, dirty, latest };
833
+ } catch (e) { return { ok: false, reason: e.message }; }
834
+ }
835
+
836
+ /** Pull what GitHub has (a fast-forward when possible, else a merge; stashes local edits around it). */
837
+ export async function pullLatest(cwd, { token } = {}) {
838
+ const st = await checkBehind(cwd, { token });
839
+ if (!st.ok) throw new Error(`Can't pull: ${st.reason}.`);
840
+ if (!st.behind) return { pulled: 0 };
841
+ let stashed = false;
842
+ if (st.dirty) { await git(cwd, ['stash', 'push', '-q', '-m', 'craft: before pulling cloud changes']); stashed = true; }
843
+ try {
844
+ await git(cwd, [...QUIET_GIT, '-c', 'user.name=Codeply Craft', '-c', 'user.email=craft-bot@users.noreply.github.com', 'pull', '-q', '--no-rebase', '--no-edit'], { auth: quietAuth(token) });
845
+ } catch (e) {
846
+ // A conflicting pull is undone, so the project is never left half-merged.
847
+ const conflicted = (await git(cwd, ['diff', '--name-only', '--diff-filter=U']).catch(() => '')).split('\n').filter(Boolean);
848
+ await git(cwd, ['merge', '--abort']).catch(() => {});
849
+ throw new Error(conflicted.length
850
+ ? `GitHub's changes clash with your commits in ${conflicted.join(', ')}, so nothing was pulled. Commit or undo those, then pull again.`
851
+ : `Could not pull: ${e.message}`);
852
+ } finally {
853
+ if (stashed) await git(cwd, ['stash', 'pop', '-q']).catch(() => { throw new Error('Pulled, but your own edits clashed with them. They are saved in git stash.'); });
854
+ }
855
+ return { pulled: st.behind };
856
+ }
857
+
858
+
859
+ /**
860
+ * Runs started somewhere else (the phone, while this PC was off) become local
861
+ * tasks, so their changes can be pulled here. Only finished runs are imported.
862
+ */
863
+ export async function importRemoteTasks({ cwd, token, home = defaultHome(), apiUrl, fetchImpl, limit = 20 }) {
864
+ const p = getProject(cwd, home);
865
+ if (!p || !p.repo) return [];
866
+ const api = githubApi({ token, apiUrl, fetchImpl });
867
+ const known = new Set((p.tasks || []).map((t) => t.id));
868
+ const list = (await api('GET', `/repos/${p.repo}/actions/workflows/${WORKFLOW_FILE}/runs?event=workflow_dispatch&status=completed&per_page=${limit}`, null, { allow: [404] })).json;
869
+ const added = [];
870
+ for (const run of (list && list.workflow_runs) || []) {
871
+ const id = String(run.display_title || '').replace(/^craft /, '');
872
+ if (!/^[A-Za-z0-9_-]+$/.test(id) || known.has(id)) continue;
873
+ const checks = (await api('GET', `/repos/${p.repo}/commits/${run.head_sha}/check-runs?check_name=${encodeURIComponent(checkName(id))}`)).json;
874
+ const check = (checks.check_runs || [])[0];
875
+ if (!check || check.status !== 'completed') continue;
876
+ let result = {};
877
+ try { result = JSON.parse(check.output.text || '{}'); } catch {}
878
+ delete result.events;
879
+ const task = {
880
+ id, prompt: result.prompt || '(started elsewhere)', mode: result.mode || 'Build', sessionId: result.sessionId || '', baseSha: result.baseSha || run.head_sha,
881
+ repo: p.repo, runId: run.id, runUrl: run.html_url, status: check.conclusion === 'success' ? 'done' : 'failed', result, error: result.error || null,
882
+ startedAt: Date.parse(run.created_at) || Date.now(), finishedAt: Date.parse(run.updated_at) || Date.now(), remote: true,
883
+ };
884
+ saveTask(cwd, home, task);
885
+ added.push(task);
886
+ }
887
+ return added;
888
+ }
889
+
890
+ /** Stop a cloud run. */
891
+ export async function cancelCloudRun({ cwd, taskId, token, home = defaultHome(), apiUrl, fetchImpl }) {
892
+ const task = await cloudRunStatus({ cwd, taskId, token, home, apiUrl, fetchImpl });
893
+ if (!task.runId || ['done', 'failed', 'cancelled'].includes(task.status)) return task;
894
+ const api = githubApi({ token, apiUrl, fetchImpl });
895
+ await api('POST', `/repos/${task.repo}/actions/runs/${task.runId}/cancel`, null, { allow: [409] });
896
+ return task;
897
+ }
898
+
899
+ /**
900
+ * Bring a finished run's code changes into the local project. Stages the
901
+ * current working tree first, so the 3-way merge keeps edits made since the
902
+ * run started; overlapping edits come back as conflict markers.
903
+ */
904
+ export async function pullCloudRun({ cwd, taskId, token, home = defaultHome(), serverUrl = 'https://github.com', remoteUrl }) {
905
+ const task = getTask(cwd, taskId, home);
906
+ if (!task) throw new Error('Unknown cloud task.');
907
+ if (task.status !== 'done') throw new Error('That cloud run has not finished.');
908
+ if (!task.result || !task.result.branch) return { files: [], conflicts: [], applied: false, message: 'That run did not change any files.' };
909
+ if (task.pulledAt) return { files: task.result.files || [], conflicts: [], applied: false, message: 'Already applied.' };
910
+ const m = await initMirrorGit(cwd, home);
911
+ const url = remoteUrl || `${serverUrl.replace(/\/$/, '')}/${task.repo}.git`;
912
+ const ref = `refs/craft/tasks/${task.id}`;
913
+ await git(cwd, ['--git-dir', m.gitDir, 'fetch', '-q', '--no-tags', url, `+refs/heads/${task.result.branch}:${ref}`], { auth: authFor(token, serverUrl) });
914
+ const patch = await git(cwd, ['--git-dir', m.gitDir, 'diff', '--binary', '--full-index', task.baseSha, ref, '--', '.', `:(exclude)${WORKFLOW_PATH}`, ':(exclude).codeply']);
915
+ if (!patch.trim()) return { files: [], conflicts: [], applied: false, message: 'That run did not change any files.' };
916
+ const files = (await git(cwd, ['--git-dir', m.gitDir, 'diff', '--name-only', task.baseSha, ref, '--', '.', `:(exclude)${WORKFLOW_PATH}`, ':(exclude).codeply'])).split('\n').filter(Boolean);
917
+ await stageWorkingTree(cwd, home);
918
+ let conflicts = [];
919
+ try {
920
+ await m.G(['apply', '--3way', '--whitespace=nowarn', '-'], { input: `${patch}\n` });
921
+ } catch (e) {
922
+ const unmerged = await m.G(['diff', '--name-only', '--diff-filter=U']).catch(() => '');
923
+ conflicts = unmerged.split('\n').filter(Boolean);
924
+ if (!conflicts.length) throw new Error(`Could not apply the changes: ${e.message}`);
925
+ }
926
+ saveTask(cwd, home, { ...task, pulledAt: Date.now(), conflicts });
927
+ return { files, conflicts, applied: true };
928
+ }
929
+
930
+ // ─── The runner (inside GitHub Actions) ─────────────────────────────────
931
+
932
+ function describeTool(ev) {
933
+ const a = ev.args || {};
934
+ const what = a.path || a.file || a.command || a.pattern || a.query || a.url || '';
935
+ return `- ${ev.ok === false ? '(failed) ' : ''}${ev.name}${what ? ` ${String(what).split('\n')[0].slice(0, 100)}` : ''}`;
936
+ }
937
+
938
+ function renderProgress(lines, note) {
939
+ const shown = lines.length > 60 ? [`- ... ${lines.length - 60} earlier steps`, ...lines.slice(-60)] : lines;
940
+ return `${note}\n\n${shown.join('\n')}`.slice(0, MAX_OUTPUT);
941
+ }
942
+
943
+ // ─── The run as a chat: every step the agent took, published live ─────────
944
+ // Cloud chats look like local ones (commands, edits with their + and - lines,
945
+ // narration, the "what actually happened" card), so the runner records the
946
+ // same events the desktop gets from a local run and puts them in the check
947
+ // run's text, where the PC and the phone read them while it works.
948
+
949
+ const EVENT_BUDGET = 58000;
950
+ const clipText = (s, n) => (typeof s === 'string' && s.length > n ? `${s.slice(0, n)}\n...` : s);
951
+
952
+ /** One agent event as a small record; tool arguments kept the way the chat shows them. */
953
+ export function recordEvent(ev) {
954
+ if (ev.type === 'text') return { t: 'text', text: clipText(ev.text, 8000), interim: !!ev.interim };
955
+ if (ev.type === 'reasoning') return { t: 'reasoning', text: clipText(ev.text, 2000), ms: ev.ms };
956
+ if (ev.type === 'notice') return { t: 'notice', level: ev.level || 'info', text: clipText(ev.text, 1000) };
957
+ if (ev.type === 'tool_end') {
958
+ const a = ev.args || {};
959
+ let args;
960
+ if (ev.name === 'write_file') args = { path: a.path };
961
+ else if (ev.name === 'apply_patch') args = { files: (ev.meta && ev.meta.files) || [] };
962
+ else {
963
+ args = {};
964
+ for (const [k, v] of Object.entries(a)) {
965
+ if (typeof v === 'string') args[k] = clipText(v, k === 'search' || k === 'replace' ? 3000 : 1500);
966
+ else if (typeof v === 'number' || typeof v === 'boolean') args[k] = v;
967
+ else if (Array.isArray(v)) args[k] = v.slice(0, 50);
968
+ }
969
+ }
970
+ const m = ev.meta || {};
971
+ return {
972
+ t: 'tool', name: ev.name, ok: ev.ok, summary: clipText(ev.summary || '', 300), args,
973
+ ...(typeof m.exitCode === 'number' ? { exitCode: m.exitCode } : {}),
974
+ ...(typeof m.added === 'number' ? { added: m.added } : {}),
975
+ ...(typeof m.removed === 'number' ? { removed: m.removed } : {}),
976
+ };
977
+ }
978
+ if (ev.type === 'done' && Array.isArray(ev.actions)) {
979
+ const changed = ev.actions.filter((x) => x.ok && ['write_file', 'edit_file', 'apply_patch', 'fetch_image'].includes(x.tool))
980
+ .flatMap((x) => (Array.isArray(x.files) && x.files.length ? x.files.map((f) => ({ ...x, label: f })) : [x]));
981
+ const checks = ev.actions.filter((x) => ['run', 'browser_check'].includes(x.tool));
982
+ if (!changed.length && !checks.length) return null;
983
+ return {
984
+ t: 'summary', files: [...new Set(changed.map((x) => x.label))].slice(0, 30),
985
+ checks: [...new Map(checks.map((x) => [`${x.tool}|${x.label}`, x])).values()].slice(-8).map((x) => ({ tool: x.tool, label: x.label, ok: x.ok, exitCode: x.exitCode })),
986
+ unverified: ev.unverifiedFiles || [],
987
+ };
988
+ }
989
+ return null;
990
+ }
991
+
992
+ /** Fit the event list in the check run's text: shorten diffs, drop thinking, then the oldest steps. */
993
+ export function packEvents(events, budget = EVENT_BUDGET) {
994
+ let list = events.map((e) => ({ ...e }));
995
+ const size = () => JSON.stringify(list).length;
996
+ if (size() <= budget) return list;
997
+ list = list.map((e) => (e.t === 'reasoning' ? { ...e, text: clipText(e.text, 200) } : e));
998
+ for (const n of [800, 200]) {
999
+ if (size() <= budget) return list;
1000
+ list = list.map((e) => (e.t === 'tool' && e.args ? { ...e, args: Object.fromEntries(Object.entries(e.args).map(([k, v]) => [k, typeof v === 'string' ? clipText(v, n) : v])) } : e));
1001
+ }
1002
+ let dropped = 0;
1003
+ while (size() > budget && list.length > 1) { list.shift(); dropped++; }
1004
+ if (dropped) list.unshift({ t: 'notice', level: 'info', text: `${dropped} earlier step${dropped === 1 ? '' : 's'} are only in the run log.` });
1005
+ return list;
1006
+ }
1007
+
1008
+ // ─── Files on the craft-sessions branch (chat memory, status, queue, shots) ──
1009
+
1010
+ export const SESSION_PATHS = {
1011
+ history: (sid) => `sessions/${sid}.json`,
1012
+ task: (id) => `tasks/${id}.json`,
1013
+ live: (sid) => `live/${sid}.json`,
1014
+ queue: (sid) => `queue/${sid}`,
1015
+ shot: (id, n) => `shots/${id}/${n}.png`,
1016
+ // A risky action waiting for the person: Craft or the phone writes the answer here.
1017
+ answer: (askId) => `answers/${askId}.json`,
1018
+ // What does not fit in the workflow's inputs (a bot's prompt), written before the dispatch.
1019
+ request: (id) => `requests/${id}.json`,
1020
+ };
1021
+
1022
+ /**
1023
+ * A task's optional bot: { name, prompt }, the full prompt the PC built
1024
+ * (bots.js buildBotPrompt). Anything else, or an empty prompt, is no bot.
1025
+ */
1026
+ export function cleanBot(bot) {
1027
+ if (!bot || typeof bot !== 'object') return null;
1028
+ const prompt = String(bot.prompt || '').trim().slice(0, MAX_BOT_PROMPT);
1029
+ if (!prompt) return null;
1030
+ return { name: String(bot.name || 'Bot').trim().slice(0, 80) || 'Bot', prompt };
1031
+ }
1032
+ /** 'task' is a job for a bot (not about the repo): its final reply is what the user hears. */
1033
+ const cleanKind = (k) => (k === 'task' ? 'task' : 'code');
1034
+
1035
+ /** Small read / write / delete / list helpers for one branch, with retries for concurrent writes. */
1036
+ export function branchFiles(api, repo, branch = SESSIONS_BRANCH) {
1037
+ const enc = (p) => p.split('/').map(encodeURIComponent).join('/');
1038
+ const read = async (p) => {
1039
+ const r = await api('GET', `/repos/${repo}/contents/${enc(p)}?ref=${branch}`, null, { allow: [404] });
1040
+ if (r.status !== 200 || !r.json || Array.isArray(r.json)) return null;
1041
+ let json = null;
1042
+ try { json = JSON.parse(Buffer.from(r.json.content || '', 'base64').toString('utf8')); } catch {}
1043
+ return { json, sha: r.json.sha, size: r.json.size };
1044
+ };
1045
+ const list = async (p) => {
1046
+ const r = await api('GET', `/repos/${repo}/contents/${enc(p)}?ref=${branch}`, null, { allow: [404] });
1047
+ return r.status === 200 && Array.isArray(r.json) ? r.json.map((x) => ({ name: x.name, path: x.path, sha: x.sha })) : [];
1048
+ };
1049
+ const put = async (p, content, message) => {
1050
+ for (let i = 0; i < 4; i++) {
1051
+ const cur = await api('GET', `/repos/${repo}/contents/${enc(p)}?ref=${branch}`, null, { allow: [404] });
1052
+ const sha = cur.status === 200 && cur.json && !Array.isArray(cur.json) ? cur.json.sha : undefined;
1053
+ const r = await api('PUT', `/repos/${repo}/contents/${enc(p)}`, { message, branch, content, ...(sha ? { sha } : {}) }, { allow: [409, 422] });
1054
+ if (r.status < 300) return true;
1055
+ await sleep(300 + i * 400);
1056
+ }
1057
+ return false;
1058
+ };
1059
+ const write = (p, obj, message = `Craft ${p}`) => put(p, Buffer.from(JSON.stringify(obj)).toString('base64'), message);
1060
+ const writeBytes = (p, buf, message = `Craft ${p}`) => put(p, Buffer.from(buf).toString('base64'), message);
1061
+ const remove = async (p, sha) => {
1062
+ for (let i = 0; i < 3; i++) {
1063
+ const s = sha || (await read(p) || {}).sha;
1064
+ if (!s) return;
1065
+ const r = await api('DELETE', `/repos/${repo}/contents/${enc(p)}`, { message: `Craft ${p}`, branch, sha: s }, { allow: [404, 409, 422] });
1066
+ if (r.status < 300 || r.status === 404) return;
1067
+ sha = null;
1068
+ await sleep(300);
1069
+ }
1070
+ };
1071
+ const ensure = async (baseSha) => {
1072
+ const ref = await api('GET', `/repos/${repo}/git/ref/heads/${branch}`, null, { allow: [404] });
1073
+ if (ref.status === 404) await api('POST', `/repos/${repo}/git/refs`, { ref: `refs/heads/${branch}`, sha: baseSha }, { allow: [422] });
1074
+ };
1075
+ return { read, list, write, writeBytes, remove, ensure };
1076
+ }
1077
+
1078
+ async function loadHistory(files, sessionId) {
1079
+ if (!sessionId) return { messages: [] };
1080
+ const f = await files.read(SESSION_PATHS.history(sessionId));
1081
+ return { messages: f && f.json && Array.isArray(f.json.messages) ? f.json.messages : [] };
1082
+ }
1083
+
1084
+ async function saveHistory(files, sessionId, messages) {
1085
+ if (!sessionId) return;
1086
+ await files.write(SESSION_PATHS.history(sessionId), { sessionId, updatedAt: new Date().toISOString(), messages }, `Chat ${sessionId}`);
1087
+ }
1088
+
1089
+ const branchSlug = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').split('-').slice(0, 6).join('-').slice(0, 40) || 'change';
1090
+ export const cloudBranchName = (prompt, id) => `craft/${branchSlug(prompt)}-${String(id).slice(-6)}`;
1091
+
1092
+ /**
1093
+ * `codeply cloud runner`, inside GitHub Actions. Runs the dispatched task, then
1094
+ * (for a chat) stays up to CRAFT_IDLE_MS (2 minutes) waiting for the next
1095
+ * message in queue/<session>, so a follow-up starts at once instead of waiting
1096
+ * for a new runner. Each task:
1097
+ * - runs like a local run, with a real browser (screenshots) when Chrome is there
1098
+ * - publishes its steps live to tasks/<id>.json (and the first one's check run)
1099
+ * - commits Build work to craft/<what-it-did>-<id>, pushes it, and merges it
1100
+ * into the base branch (CRAFT_MERGE=false keeps it as a branch only)
1101
+ * @returns {Promise<{status:'done'|'failed', result:object, results:object[]}>}
1102
+ */
1103
+ export async function runCloudRunner({ env = process.env, cwd = process.cwd(), token, route, setupError = null, fetchImpl = fetch, runAgentImpl = runAgent, log = () => {}, maxSteps = 80, updateMs = 4000, idleMs, pollMs = 3000, browserImpl }) {
1104
+ const repo = env.GITHUB_REPOSITORY;
1105
+ const firstId = String(env.CRAFT_TASK_ID || '').replace(/[^A-Za-z0-9_-]/g, '');
1106
+ const sessionId = String(env.CRAFT_SESSION_ID || '').replace(/[^A-Za-z0-9_-]/g, '');
1107
+ const headSha = env.GITHUB_SHA;
1108
+ if (!repo || !token || !firstId || !headSha) return { status: 'failed', result: { error: 'Missing GITHUB_REPOSITORY, GITHUB_TOKEN, GITHUB_SHA or CRAFT_TASK_ID.' }, results: [] };
1109
+ const apiUrl = env.GITHUB_API_URL || 'https://api.github.com';
1110
+ const serverUrl = (env.GITHUB_SERVER_URL || 'https://github.com').replace(/\/$/, '');
1111
+ const api = githubApi({ token, apiUrl, fetchImpl });
1112
+ const files = branchFiles(api, repo);
1113
+ const idle = idleMs != null ? idleMs : Number(env.CRAFT_IDLE_MS || 120000);
1114
+ const base = env.CRAFT_BASE || env.GITHUB_REF_NAME || 'main';
1115
+ const merge = String(env.CRAFT_MERGE || 'true') !== 'false';
1116
+ await files.ensure(headSha).catch((e) => log(`Could not create ${SESSIONS_BRANCH}: ${e.message}`));
1117
+ await loadCloudConnections({ env, log });
1118
+
1119
+ // The project's environment for running and testing: never committed.
1120
+ const envFile = path.join(cwd, '.env');
1121
+ if (env.CRAFT_ENV && String(env.CRAFT_ENV).trim()) {
1122
+ try { if (!fs.existsSync(envFile)) fs.writeFileSync(envFile, `${String(env.CRAFT_ENV).trim()}\n`); } catch (e) { log(`Could not write .env: ${e.message}`); }
1123
+ }
1124
+ const browser = browserImpl !== undefined ? browserImpl : (await import('./cloud-browser.mjs')).makeCloudBrowser();
1125
+
1126
+ const heartbeat = (busy, until) => files.write(SESSION_PATHS.live(sessionId), { runId: env.GITHUB_RUN_ID || null, busy, at: Date.now(), until }, `Craft live ${sessionId}`).catch(() => {});
1127
+
1128
+ let task = { id: firstId, prompt: String(env.CRAFT_PROMPT || ''), mode: env.CRAFT_MODE, sessionId, dispatched: true };
1129
+ // A run carried on from the PC: the checked-out craft/wip-<chat> holds its state.
1130
+ if (String(env.CRAFT_HANDOFF || '') === 'resume' && sessionId) {
1131
+ const state = takeStateFile(cwd);
1132
+ if (state) task = { ...task, prompt: state.userMessage || task.prompt, mode: state.mode || task.mode, handoff: state };
1133
+ else setupError = setupError || 'The run from the PC left no saved state on its WIP branch.';
1134
+ }
1135
+ // A bot (and the task kind) comes next to the dispatch, on craft-sessions.
1136
+ const req = await files.read(SESSION_PATHS.request(firstId)).catch(() => null);
1137
+ if (req && req.json) {
1138
+ task = { ...task, bot: req.json.bot, kind: req.json.kind };
1139
+ await files.remove(SESSION_PATHS.request(firstId), req.sha).catch(() => {});
1140
+ }
1141
+ const results = [];
1142
+ const isHandoff = !!task.handoff;
1143
+ try {
1144
+ while (task) {
1145
+ // A handoff run is not a chat runner: new messages for this chat get their own run.
1146
+ const live = sessionId && !task.handoff;
1147
+ if (live) await heartbeat(true, Date.now() + 60 * 60 * 1000);
1148
+ results.push(await runOneTask({ task, env, cwd, api, files, repo, token, serverUrl, headSha, route, setupError, runAgentImpl, log, maxSteps, updateMs, browser, base, merge, heartbeat: live ? () => heartbeat(true, Date.now() + 60 * 60 * 1000) : null, handbackPollMs: pollMs }));
1149
+ if (!live || idle <= 0 || setupError) break;
1150
+ task = await waitForNext({ files, sessionId, idle, pollMs, heartbeat });
1151
+ }
1152
+ } finally {
1153
+ if (sessionId && !isHandoff) await files.remove(SESSION_PATHS.live(sessionId)).catch(() => {});
1154
+ if (browser && browser.close) await browser.close().catch(() => {});
1155
+ }
1156
+ const last = results[results.length - 1] || { status: 'failed', result: { error: 'Nothing ran.' } };
1157
+ return { ...last, results };
1158
+ }
1159
+
1160
+ /** Idle: the next queued message for this chat, or null after `idle` ms of nothing. */
1161
+ async function waitForNext({ files, sessionId, idle, pollMs, heartbeat }) {
1162
+ const deadline = Date.now() + idle;
1163
+ let beat = 0;
1164
+ while (Date.now() < deadline) {
1165
+ if (Date.now() - beat > 20000) { await heartbeat(false, deadline); beat = Date.now(); }
1166
+ const items = (await files.list(SESSION_PATHS.queue(sessionId)).catch(() => [])).sort((a, b) => a.name.localeCompare(b.name));
1167
+ for (const it of items) {
1168
+ const f = await files.read(it.path).catch(() => null);
1169
+ await files.remove(it.path, f && f.sha).catch(() => {});
1170
+ const t = f && f.json;
1171
+ if (!t || !t.id) continue;
1172
+ // Picked up by another runner already (it was re-dispatched): skip.
1173
+ if (await files.read(SESSION_PATHS.task(t.id)).catch(() => null)) continue;
1174
+ return { ...t, sessionId, dispatched: false };
1175
+ }
1176
+ await sleep(pollMs);
1177
+ }
1178
+ return null;
1179
+ }
1180
+
1181
+ async function runOneTask({ task, env, cwd, api, files, repo, token, serverUrl, headSha, route, setupError, runAgentImpl, log, maxSteps, updateMs, browser, base, merge, heartbeat, handbackPollMs = 3000 }) {
1182
+ const taskId = String(task.id).replace(/[^A-Za-z0-9_-]/g, '');
1183
+ const mode = ['Build', 'Plan', 'Ask'].includes(task.mode) ? task.mode : 'Build';
1184
+ const prompt = String(task.prompt || '').slice(0, MAX_PROMPT);
1185
+ const sessionId = task.sessionId || '';
1186
+ const bot = cleanBot(task.bot);
1187
+ const kind = cleanKind(task.kind);
1188
+ const startedAt = Date.now();
1189
+ const events = [];
1190
+ const lines = [];
1191
+
1192
+ // The dispatched task also gets a check run: it is how a failed start is diagnosed.
1193
+ const check = task.dispatched ? (await api('POST', `/repos/${repo}/check-runs`, {
1194
+ name: checkName(taskId), head_sha: headSha, status: 'in_progress', started_at: new Date().toISOString(),
1195
+ output: { title: 'Starting', summary: 'Starting...' },
1196
+ }).catch(() => ({ json: null }))).json : null;
1197
+
1198
+ let pendingAsk = null; let asks = 0;
1199
+ const statusDoc = (status, extra = {}) => ({ ...(pendingAsk && status === 'running' ? { ask: pendingAsk } : {}), id: taskId, status, mode, prompt: prompt.slice(0, 4000), sessionId, ...(kind === 'task' ? { kind } : {}), ...(bot ? { bot: { name: bot.name } } : {}), startedAt, updatedAt: Date.now(), runId: env.GITHUB_RUN_ID || null, ...extra });
1200
+ const state = task.handoff || null; // a run carried on from the PC
1201
+ const finish = async (ok, title, summary, result) => {
1202
+ const baseLen = JSON.stringify(result).length;
1203
+ const packed = packEvents(events, Math.max(4000, MAX_OUTPUT - baseLen - 200));
1204
+ await files.write(SESSION_PATHS.task(taskId), statusDoc(ok ? 'done' : 'failed', { ...result, events: packEvents(events, 400000) }), `Craft task ${taskId}: ${title}`).catch(() => {});
1205
+ if (check) {
1206
+ await api('PATCH', `/repos/${repo}/check-runs/${check.id}`, {
1207
+ status: 'completed', conclusion: ok ? 'success' : 'failure', completed_at: new Date().toISOString(),
1208
+ output: { title, summary: String(summary || title).slice(0, MAX_OUTPUT), text: JSON.stringify({ ...result, events: packed }).slice(0, MAX_OUTPUT) },
1209
+ }).catch((e) => log(`Could not finish the check run: ${e.message}`));
1210
+ }
1211
+ return { status: ok ? 'done' : 'failed', result: { ...result, events: packed }, ...(state ? { handoff: true } : {}) };
1212
+ };
1213
+
1214
+ // Handoff: the work goes on craft/wip-<chat> with the state next to it, never merged.
1215
+ // done.json tells the PC where it ended: 'paused' (it asked for the chat back),
1216
+ // 'done' or 'failed'.
1217
+ const commitWip = async (status) => {
1218
+ if (!state) return null;
1219
+ state.side = 'cloud';
1220
+ fs.writeFileSync(path.join(cwd, STATE_FILE), JSON.stringify({ ...state, savedAt: Date.now() }));
1221
+ await git(cwd, ['add', '-A', '--', '.', ':(exclude).codeply', `:(exclude)${WORKFLOW_PATH}`, ':(exclude).env']);
1222
+ await git(cwd, [...BOT, 'commit', '-q', '--allow-empty', '-m', `Craft WIP ${sessionId} (cloud, ${state.steps.length} steps)`]);
1223
+ try { fs.unlinkSync(path.join(cwd, STATE_FILE)); } catch {}
1224
+ const sha = await git(cwd, ['rev-parse', 'HEAD']);
1225
+ // Not forced: if anything else moved the branch, this side lost the lease.
1226
+ await git(cwd, ['push', '-q', 'origin', `HEAD:refs/heads/${wipBranch(sessionId)}`], { auth: authFor(token, serverUrl) });
1227
+ if (status) {
1228
+ await files.write(HANDBACK.done(sessionId), { sha, status, taskId, at: Date.now() }, `Craft handback ${sessionId}`);
1229
+ const want = await files.read(HANDBACK.want(sessionId)).catch(() => null);
1230
+ if (want) await files.remove(HANDBACK.want(sessionId), want.sha).catch(() => {});
1231
+ }
1232
+ return sha;
1233
+ };
1234
+
1235
+ if (setupError) return finish(false, 'Could not start', setupError, { error: setupError, mode });
1236
+ if (!prompt) return finish(false, 'Could not start', 'The task was empty.', { error: 'The task was empty.', mode });
1237
+
1238
+ let lastPush = 0; let pending = null;
1239
+ const pushProgress = async (note, force = false) => {
1240
+ if (!force && Date.now() - lastPush < updateMs) return;
1241
+ lastPush = Date.now();
1242
+ const packed = packEvents(events);
1243
+ pending = Promise.all([
1244
+ files.write(SESSION_PATHS.task(taskId), statusDoc('running', { events: packEvents(events, 400000) }), `Craft task ${taskId}`).catch(() => {}),
1245
+ check ? api('PATCH', `/repos/${repo}/check-runs/${check.id}`, { output: { title: 'Working', summary: renderProgress(lines, note), text: JSON.stringify({ running: true, events: packed }) } }).catch(() => {}) : null,
1246
+ heartbeat ? heartbeat() : null,
1247
+ ]);
1248
+ await pending;
1249
+ };
1250
+
1251
+ // Screenshots go next to the status, where the PC and the phone read them.
1252
+ let shots = 0; let pendingShot = null;
1253
+ const agentBrowser = browser ? async (url, opts) => {
1254
+ const r = await browser(url, opts);
1255
+ if (r && r.ok && r.screenshotPath) {
1256
+ try {
1257
+ const rel = SESSION_PATHS.shot(taskId, ++shots);
1258
+ if (await files.writeBytes(rel, fs.readFileSync(r.screenshotPath), `Craft screenshot ${taskId}`)) pendingShot = rel;
1259
+ } catch {}
1260
+ }
1261
+ return r;
1262
+ } : undefined;
1263
+
1264
+ try {
1265
+ // Where this task starts (a later message in the same runner starts after the earlier merge).
1266
+ const taskBase = await git(cwd, ['rev-parse', 'HEAD']).catch(() => headSha);
1267
+ const hist = state ? { messages: Array.isArray(state.history) ? state.history : [] } : await loadHistory(files, sessionId);
1268
+ if (state) events.push({ t: 'notice', level: 'info', text: `Your PC went offline, so Craft Cloud is carrying on from step ${state.steps.length + 1}.` });
1269
+ await pushProgress(state ? 'Carrying on from your PC.' : hist.messages.length ? `Continuing the chat (${hist.messages.length / 2} earlier turns).` : 'Working on it.', true);
1270
+ // Risky actions (deploys...) ask the person unless the repo's Cloud permission is "full".
1271
+ const ASK_WAIT_MS = 15 * 60 * 1000;
1272
+ // Only a repo with the current workflow passes CRAFT_PERMISSIONS (empty when unset): its
1273
+ // Craft and phone can show the question. An older setup keeps the old rule (no) until updated.
1274
+ const rules = ciApprove(cwd, { permission: env.CRAFT_PERMISSIONS, canAsk: env.CRAFT_PERMISSIONS !== undefined });
1275
+ const approve = async (req) => {
1276
+ const v = await rules(req);
1277
+ if (v !== 'ask') return v;
1278
+ const askId = `${taskId}-${++asks}`;
1279
+ pendingAsk = { id: askId, tool: req.tool, title: String(req.title || req.tool).slice(0, 300), detail: String(req.detail || '').slice(0, 2000), at: Date.now() };
1280
+ events.push({ t: 'notice', level: 'info', text: `Waiting for your OK: ${pendingAsk.title}` });
1281
+ await pushProgress('Waiting for your OK.', true);
1282
+ const until = Date.now() + ASK_WAIT_MS;
1283
+ let verdict = null;
1284
+ while (Date.now() < until && !verdict) {
1285
+ await sleep(4000);
1286
+ const f = await files.read(SESSION_PATHS.answer(askId)).catch(() => null);
1287
+ if (f && f.json) { verdict = f.json.verdict === 'approve' ? 'once' : 'reject'; files.remove(SESSION_PATHS.answer(askId), f.sha).catch(() => {}); }
1288
+ else if (heartbeat) heartbeat(true).catch(() => {});
1289
+ }
1290
+ pendingAsk = null;
1291
+ events.push({ t: 'notice', level: 'info', text: verdict === 'once' ? 'Approved.' : verdict === 'reject' ? 'You said no, so it was skipped.' : 'No answer in 15 minutes, so it was skipped.' });
1292
+ await pushProgress('Working.', true);
1293
+ return verdict || 'reject';
1294
+ };
1295
+ let answer = ''; let steps = 0; let failure = '';
1296
+ const userMessage = state
1297
+ ? `${continuationPrompt(state, 'cloud')}\n\n(You are now in Craft Cloud, on a Linux machine with a copy of the project as the PC left it. Nobody can answer questions mid-run, so make sensible choices and say what you assumed. Do not commit or push; Craft does that.)`
1298
+ : kind === 'task'
1299
+ ? `${prompt}\n\n(You are running unattended in Codeply Cloud on a Linux machine, not on the user's PC: you cannot reach their local files or desktop apps. Use the shell, the web and this repository. Nobody can answer questions mid-run, so make sensible choices and say what you assumed. Your final reply is read out to the user: say plainly what you did or found, in a few short sentences, no markdown. Do not commit or push; Craft does that when you finish.)`
1300
+ : `${prompt}\n\n(You are running unattended in Craft Cloud, in a fresh copy of the repository on a Linux machine. Nobody can answer questions mid-run, so make sensible choices and say what you assumed. You can install dependencies, run the app and tests, and check pages with browser_check. Do not commit or push; Craft does that when you finish.)`;
1301
+ const ac = new AbortController();
1302
+ let handedBack = false; let lastWant = Date.now(); let lastWip = Date.now();
1303
+ for await (const ev of runAgentImpl({ userMessage, history: hist.messages.slice(-HISTORY_TURNS), mode, cwd, approve, browser: agentBrowser, signal: ac.signal, route, maxSteps, ...(bot ? { botPrompt: bot.prompt } : {}), ...(state ? { todos: state.todos } : {}) })) {
1304
+ if (state) {
1305
+ recordHandoff(state, ev);
1306
+ // Between steps: has the PC asked for the chat back? Then stop here, never mid-tool.
1307
+ if (!handedBack && (ev.type === 'tool_end' || Date.now() - lastWant > handbackPollMs)) {
1308
+ lastWant = Date.now();
1309
+ if (await files.read(HANDBACK.want(sessionId)).catch(() => null)) { handedBack = true; ac.abort(); events.push({ t: 'notice', level: 'info', text: 'Your PC is back, so the task is going back to it.' }); }
1310
+ }
1311
+ // A WIP every minute, so a runner that dies still leaves its work.
1312
+ if (!handedBack && ev.type === 'tool_end' && Date.now() - lastWip > 60000) { lastWip = Date.now(); await commitWip(null).catch((e) => log(`WIP push failed: ${e.message}`)); }
1313
+ }
1314
+ const rec = recordEvent(ev);
1315
+ if (rec && rec.t === 'tool' && ev.name === 'browser_check' && pendingShot) { rec.screenshot = pendingShot; pendingShot = null; }
1316
+ // The final answer arrives as the reply; everything else is part of the chat's steps.
1317
+ if (rec && !(rec.t === 'text' && !rec.interim)) events.push(rec);
1318
+ if (ev.type === 'text' && !ev.interim) answer = ev.text;
1319
+ else if (ev.type === 'tool_end') { steps++; lines.push(describeTool(ev)); }
1320
+ else if (ev.type === 'error') failure = ev.error;
1321
+ if (rec) await pushProgress(`Working: ${steps} step${steps === 1 ? '' : 's'} so far.`);
1322
+ if (ev.type === 'done' || ev.type === 'error' || ev.type === 'aborted') break;
1323
+ if (handedBack && ev.type === 'tool_end') break;
1324
+ }
1325
+ if (pending) await pending;
1326
+ if (state) {
1327
+ if (failure && !answer && !handedBack) throw new Error(failure);
1328
+ const wipSha = await commitWip(handedBack ? 'paused' : 'done');
1329
+ const changed = (await git(cwd, ['diff', '--name-only', headSha, 'HEAD', '--', '.', `:(exclude)${STATE_FILE}`, `:(exclude)${WORKFLOW_PATH}`]).catch(() => '')).split('\n').filter(Boolean);
1330
+ const result = { mode, prompt: prompt.slice(0, 4000), sessionId, answer: handedBack ? '' : answer.slice(0, 20000), branch: null, sha: wipSha, files: changed, stats: [], merged: null, steps, baseSha: headSha, handoff: { wipSha, handedBack } };
1331
+ return finish(true, handedBack ? 'Handed back to the PC' : changed.length ? `Changed ${changed.length} file${changed.length === 1 ? '' : 's'}` : 'Done', answer || 'Handed back to the PC.', result);
1332
+ }
1333
+ if (failure && !answer) throw new Error(failure);
1334
+
1335
+ // The bot's own last words, before a plan file replaces the answer: what the phone speaks.
1336
+ const reply = answer;
1337
+ if (mode === 'Plan') {
1338
+ const dir = path.join(cwd, '.codeply', 'plans');
1339
+ const plans = fs.existsSync(dir) ? fs.readdirSync(dir).filter((f) => f.endsWith('.md')).map((f) => ({ f, t: fs.statSync(path.join(dir, f)).mtimeMs })).sort((a, b) => b.t - a.t) : [];
1340
+ if (plans.length) answer = fs.readFileSync(path.join(dir, plans[0].f), 'utf8');
1341
+ }
1342
+
1343
+ let branch = null; let sha = null; let changed = []; let stats = []; let merged = null;
1344
+ if (mode === 'Build') {
1345
+ await git(cwd, ['add', '-A', '--', '.', ':(exclude).codeply', `:(exclude)${WORKFLOW_PATH}`, ':(exclude).env']);
1346
+ const staged = await git(cwd, ['diff', '--cached', '--name-only']);
1347
+ if (staged) {
1348
+ changed = staged.split('\n').filter(Boolean);
1349
+ // Per-file + and - lines, for the chat's record of what was pushed.
1350
+ stats = (await git(cwd, ['diff', '--cached', '--numstat'])).split('\n').filter(Boolean).map((l) => {
1351
+ const [add, del, ...name] = l.split('\t');
1352
+ return { file: name.join('\t'), added: add === '-' ? null : Number(add), removed: del === '-' ? null : Number(del) };
1353
+ });
1354
+ branch = cloudBranchName(prompt, taskId);
1355
+ const subject = prompt.split('\n')[0].slice(0, 60);
1356
+ await git(cwd, [...BOT, 'commit', '-q', '-m', `${subject}\n\nCraft cloud task ${taskId}.`]);
1357
+ sha = await git(cwd, ['rev-parse', 'HEAD']);
1358
+ await git(cwd, ['push', '-q', 'origin', `HEAD:refs/heads/${branch}`], { auth: authFor(token, serverUrl) });
1359
+ // Into the base branch too, so the next run (and the PC's pull) starts from it.
1360
+ if (merge) {
1361
+ const m = await api('POST', `/repos/${repo}/merges`, { base, head: branch, commit_message: `Merge ${branch}: ${subject}` }, { allow: [204, 404, 409] }).catch((e) => ({ status: 0, error: e.message }));
1362
+ merged = m.status === 201 || m.status === 204
1363
+ ? { ok: true, base, sha: m.json && m.json.sha ? m.json.sha : sha }
1364
+ : { ok: false, base, reason: m.status === 409 ? `it conflicts with newer changes on ${base}` : (m.error || `GitHub answered ${m.status}`) };
1365
+ if (merged.ok) {
1366
+ // Keep working from the merged base for the next message in this chat.
1367
+ await git(cwd, ['fetch', '-q', 'origin', base], { auth: authFor(token, serverUrl) }).catch(() => {});
1368
+ await git(cwd, ['reset', '-q', '--hard', 'FETCH_HEAD']).catch(() => {});
1369
+ }
1370
+ }
1371
+ const plus = stats.reduce((n, s) => n + (s.added || 0), 0);
1372
+ const minus = stats.reduce((n, s) => n + (s.removed || 0), 0);
1373
+ events.push({ t: 'pushed', branch, sha: sha.slice(0, 7), files: stats.slice(0, 50), added: plus, removed: minus, merged });
1374
+ }
1375
+ }
1376
+
1377
+ await saveHistory(files, sessionId, [...hist.messages, { role: 'user', content: prompt }, { role: 'assistant', content: answer || '(no reply)' }].slice(-HISTORY_TURNS * 2))
1378
+ .catch((e) => log(`Could not save the chat: ${e.message}`));
1379
+ const result = { mode, prompt: prompt.slice(0, 4000), sessionId, answer: answer.slice(0, 20000), branch, sha, files: changed, stats: stats.slice(0, 100), merged, steps, baseSha: taskBase,
1380
+ ...(bot || kind === 'task' ? { reply: reply.slice(0, 20000) } : {}) };
1381
+ const title = changed.length ? `Changed ${changed.length} file${changed.length === 1 ? '' : 's'}` : mode === 'Build' ? 'No files changed' : 'Answered';
1382
+ return finish(true, title, answer || title, result);
1383
+ } catch (e) {
1384
+ // A handoff still leaves its work for the PC.
1385
+ const wipSha = state ? await commitWip('failed').catch(() => null) : null;
1386
+ return finish(false, 'Failed', `Something went wrong: ${e.message}`, { error: e.message, mode, steps: lines.length, ...(state ? { handoff: { wipSha, handedBack: false } } : {}) });
1387
+ }
1388
+ }
1389
+
1390
+ /** Route and token from the runner environment, with secrets removed from process.env. */
1391
+ export function runnerSetup(env = process.env) {
1392
+ const cfg = { ...env };
1393
+ for (const k of ['GITHUB_TOKEN', 'GH_TOKEN', 'CODEPLY_API_KEY', 'ACTIONS_RUNTIME_TOKEN', 'ACTIONS_ID_TOKEN_REQUEST_TOKEN']) delete env[k];
1394
+ const r = routeFromEnv(cfg);
1395
+ return { cfg, token: cfg.GITHUB_TOKEN || cfg.GH_TOKEN, route: r.route, setupError: r.error || null };
1396
+ }