codeply-cli 0.5.1 → 0.5.2

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