@turri-dev/cli 0.0.0-stage → 0.1.1

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 (315) hide show
  1. package/.pi/themes/turri-dark.json +80 -0
  2. package/.pi/themes/turri-light.json +82 -0
  3. package/LICENSE +21 -0
  4. package/README.md +272 -2
  5. package/assets/jellyfish/dark-00.png +0 -0
  6. package/assets/jellyfish/dark-01.png +0 -0
  7. package/assets/jellyfish/dark-02.png +0 -0
  8. package/assets/jellyfish/dark-03.png +0 -0
  9. package/assets/jellyfish/dark-04.png +0 -0
  10. package/assets/jellyfish/dark-05.png +0 -0
  11. package/assets/jellyfish/dark-06.png +0 -0
  12. package/assets/jellyfish/dark-07.png +0 -0
  13. package/assets/jellyfish/dark-08.png +0 -0
  14. package/assets/jellyfish/dark-09.png +0 -0
  15. package/assets/jellyfish/dark-10.png +0 -0
  16. package/assets/jellyfish/dark-11.png +0 -0
  17. package/assets/jellyfish/dark-12.png +0 -0
  18. package/assets/jellyfish/dark-13.png +0 -0
  19. package/assets/jellyfish/dark-14.png +0 -0
  20. package/assets/jellyfish/fallback.txt +252 -0
  21. package/assets/jellyfish/light-00.png +0 -0
  22. package/assets/jellyfish/light-01.png +0 -0
  23. package/assets/jellyfish/light-02.png +0 -0
  24. package/assets/jellyfish/light-03.png +0 -0
  25. package/assets/jellyfish/light-04.png +0 -0
  26. package/assets/jellyfish/light-05.png +0 -0
  27. package/assets/jellyfish/light-06.png +0 -0
  28. package/assets/jellyfish/light-07.png +0 -0
  29. package/assets/jellyfish/light-08.png +0 -0
  30. package/assets/jellyfish/light-09.png +0 -0
  31. package/assets/jellyfish/light-10.png +0 -0
  32. package/assets/jellyfish/light-11.png +0 -0
  33. package/assets/jellyfish/light-12.png +0 -0
  34. package/assets/jellyfish/light-13.png +0 -0
  35. package/assets/jellyfish/light-14.png +0 -0
  36. package/bin/turri.mjs +29 -0
  37. package/dist/ask.js +149 -0
  38. package/dist/brand.js +50 -0
  39. package/dist/btw.js +174 -0
  40. package/dist/chat-models.js +37 -0
  41. package/dist/chat-skills.js +18 -0
  42. package/dist/cli.js +178 -0
  43. package/dist/config-command.js +197 -0
  44. package/dist/config-edit.js +367 -0
  45. package/dist/config.js +489 -0
  46. package/dist/conform-cli.js +29 -0
  47. package/dist/cultivate-cli.js +110 -0
  48. package/dist/drift-cli.js +25 -0
  49. package/dist/environment.js +70 -0
  50. package/dist/extension.js +2372 -0
  51. package/dist/genome.js +301 -0
  52. package/dist/graph.js +246 -0
  53. package/dist/identity.js +35 -0
  54. package/dist/init-config.js +511 -0
  55. package/dist/judge.js +62 -0
  56. package/dist/lineage-cli.js +13 -0
  57. package/dist/loop-cli.js +132 -0
  58. package/dist/loop-tools.js +225 -0
  59. package/dist/mature-tools.js +193 -0
  60. package/dist/model-check.js +117 -0
  61. package/dist/module-report.js +9 -0
  62. package/dist/pi-launch.js +31 -0
  63. package/dist/pi.js +813 -0
  64. package/dist/plain/check.js +76 -0
  65. package/dist/plain/concepts.js +213 -0
  66. package/dist/plain/errors.js +43 -0
  67. package/dist/plain/files.js +57 -0
  68. package/dist/plain/highlight.js +195 -0
  69. package/dist/plain/index.js +15 -0
  70. package/dist/plain/liquid.js +129 -0
  71. package/dist/plain/markdown.js +208 -0
  72. package/dist/plain/parser.js +752 -0
  73. package/dist/plain/plain.tmLanguage.json +124 -0
  74. package/dist/plain/spec.js +291 -0
  75. package/dist/plain/stdlib.js +44 -0
  76. package/dist/question-dialog.js +125 -0
  77. package/dist/questioning.js +109 -0
  78. package/dist/quiet.js +125 -0
  79. package/dist/regenerate/activity.js +187 -0
  80. package/dist/regenerate/agent-env.js +190 -0
  81. package/dist/regenerate/agent-shell.js +114 -0
  82. package/dist/regenerate/arbiter.js +133 -0
  83. package/dist/regenerate/audit.js +203 -0
  84. package/dist/regenerate/cases.js +83 -0
  85. package/dist/regenerate/change-view.js +102 -0
  86. package/dist/regenerate/choose-conformance.js +117 -0
  87. package/dist/regenerate/chunk-plan.js +625 -0
  88. package/dist/regenerate/codebase.js +75 -0
  89. package/dist/regenerate/command-check.js +312 -0
  90. package/dist/regenerate/compaction.js +20 -0
  91. package/dist/regenerate/confine.js +310 -0
  92. package/dist/regenerate/conform.js +133 -0
  93. package/dist/regenerate/conformance-agents.js +955 -0
  94. package/dist/regenerate/conformance.js +936 -0
  95. package/dist/regenerate/conversation-writes.js +100 -0
  96. package/dist/regenerate/credentials.js +88 -0
  97. package/dist/regenerate/debug-log.js +228 -0
  98. package/dist/regenerate/debugger.js +90 -0
  99. package/dist/regenerate/delivery.js +19 -0
  100. package/dist/regenerate/diagnose-job.js +40 -0
  101. package/dist/regenerate/diagnose.js +81 -0
  102. package/dist/regenerate/diff-view.js +240 -0
  103. package/dist/regenerate/drift.js +194 -0
  104. package/dist/regenerate/engine.js +118 -0
  105. package/dist/regenerate/environment-error.js +220 -0
  106. package/dist/regenerate/job-log.js +102 -0
  107. package/dist/regenerate/job-panel.js +336 -0
  108. package/dist/regenerate/jobs-tree.js +300 -0
  109. package/dist/regenerate/jobs.js +427 -0
  110. package/dist/regenerate/lanes.js +32 -0
  111. package/dist/regenerate/lineage.js +384 -0
  112. package/dist/regenerate/loop.js +1435 -0
  113. package/dist/regenerate/masking.js +95 -0
  114. package/dist/regenerate/notes.js +397 -0
  115. package/dist/regenerate/os-sandbox.js +243 -0
  116. package/dist/regenerate/paths.js +165 -0
  117. package/dist/regenerate/plain-palette.js +83 -0
  118. package/dist/regenerate/prompt.js +704 -0
  119. package/dist/regenerate/records.js +294 -0
  120. package/dist/regenerate/regenerate.js +598 -0
  121. package/dist/regenerate/regression.js +181 -0
  122. package/dist/regenerate/release.js +262 -0
  123. package/dist/regenerate/requires.js +241 -0
  124. package/dist/regenerate/resources.js +164 -0
  125. package/dist/regenerate/self-check.js +111 -0
  126. package/dist/regenerate/services.js +155 -0
  127. package/dist/regenerate/shell.js +319 -0
  128. package/dist/regenerate/side-panel.js +278 -0
  129. package/dist/regenerate/spec-map-view.js +574 -0
  130. package/dist/regenerate/spec-map.js +561 -0
  131. package/dist/regenerate/spec-steps.js +527 -0
  132. package/dist/regenerate/state.js +10 -0
  133. package/dist/regenerate/steer.js +91 -0
  134. package/dist/regenerate/steering.js +119 -0
  135. package/dist/regenerate/stop-report.js +161 -0
  136. package/dist/regenerate/submit-work.js +71 -0
  137. package/dist/regenerate/test-kinds.js +124 -0
  138. package/dist/regenerate/test-scripts.js +695 -0
  139. package/dist/regenerate/testing.js +406 -0
  140. package/dist/regenerate/transcript-view.js +1 -0
  141. package/dist/regenerate/unit-gate.js +20 -0
  142. package/dist/regenerate/unit-review.js +347 -0
  143. package/dist/regenerate/web.js +256 -0
  144. package/dist/regenerate/workspace.js +99 -0
  145. package/dist/regenerate-cli.js +68 -0
  146. package/dist/release-agent.js +114 -0
  147. package/dist/release-cli.js +131 -0
  148. package/dist/release-tools.js +148 -0
  149. package/dist/review/live.js +165 -0
  150. package/dist/rules.js +47 -0
  151. package/dist/self-invocation.js +110 -0
  152. package/dist/setup.js +245 -0
  153. package/dist/skill-progress.js +78 -0
  154. package/dist/skill.js +149 -0
  155. package/dist/skills.js +74 -0
  156. package/dist/spec-diff-cli.js +37 -0
  157. package/dist/spec-tools.js +155 -0
  158. package/dist/stack.js +158 -0
  159. package/dist/stacks.js +97 -0
  160. package/dist/stdlib.js +100 -0
  161. package/dist/terminal-images.js +46 -0
  162. package/dist/tools.js +242 -0
  163. package/dist/validate-cli.js +52 -0
  164. package/docs/README.md +58 -0
  165. package/docs/authoring-plain.md +245 -0
  166. package/docs/config.md +353 -0
  167. package/docs/deploy.md +109 -0
  168. package/docs/development.md +75 -0
  169. package/docs/headless.md +148 -0
  170. package/docs/images/how-turri-works.svg +50 -0
  171. package/docs/images/turri-banner.png +0 -0
  172. package/docs/installation.md +118 -0
  173. package/docs/parser.md +108 -0
  174. package/docs/regenerating.md +1116 -0
  175. package/docs/sandbox.md +233 -0
  176. package/docs/skills-and-gates.md +254 -0
  177. package/docs/theming.md +76 -0
  178. package/docs/using.md +454 -0
  179. package/genome/rules/bullet-continuation.md +69 -0
  180. package/genome/rules/concise-specs-examples.md +431 -0
  181. package/genome/rules/concise-specs-integration-examples.md +270 -0
  182. package/genome/rules/concise-specs.md +80 -0
  183. package/genome/rules/definitions.md +107 -0
  184. package/genome/rules/examples/integration-embedded-testing/run_conformance_tests_java.sh +163 -0
  185. package/genome/rules/examples/integration-embedded-testing/run_unittests_java.sh +51 -0
  186. package/genome/rules/exported-concepts.md +39 -0
  187. package/genome/rules/func-specs.md +129 -0
  188. package/genome/rules/impl-reqs.md +68 -0
  189. package/genome/rules/import-modules.md +48 -0
  190. package/genome/rules/integration-embedded-testing.md +276 -0
  191. package/genome/rules/integration-embedded.md +171 -0
  192. package/genome/rules/integration-standalone.md +135 -0
  193. package/genome/rules/integrations.md +137 -0
  194. package/genome/rules/linked-resources.md +69 -0
  195. package/genome/rules/module-structure.md +105 -0
  196. package/genome/rules/project-model.md +88 -0
  197. package/genome/rules/required-concepts.md +44 -0
  198. package/genome/rules/requires-modules.md +80 -0
  199. package/genome/rules/stdlib.md +73 -0
  200. package/genome/rules/test-reqs.md +57 -0
  201. package/genome/skills/add-acceptance-test/SKILL.md +100 -0
  202. package/genome/skills/add-concept/SKILL.md +71 -0
  203. package/genome/skills/add-functional-spec/SKILL.md +84 -0
  204. package/genome/skills/add-functional-specs/SKILL.md +118 -0
  205. package/genome/skills/add-implementation-requirement/SKILL.md +86 -0
  206. package/genome/skills/add-resource/SKILL.md +105 -0
  207. package/genome/skills/add-template/SKILL.md +64 -0
  208. package/genome/skills/add-test-requirement/SKILL.md +73 -0
  209. package/genome/skills/analyze-2-func-specs/SKILL.md +101 -0
  210. package/genome/skills/analyze-func-specs/SKILL.md +123 -0
  211. package/genome/skills/analyze-if-func-spec-too-complex/SKILL.md +211 -0
  212. package/genome/skills/break-down-func-spec/SKILL.md +205 -0
  213. package/genome/skills/brood/SKILL.md +229 -0
  214. package/genome/skills/brood/references/intent.md +35 -0
  215. package/genome/skills/brood/references/product.md +101 -0
  216. package/genome/skills/brood/references/stack.md +61 -0
  217. package/genome/skills/brood/references/testing.md +56 -0
  218. package/genome/skills/bud/SKILL.md +148 -0
  219. package/genome/skills/bud/references/checklist.md +44 -0
  220. package/genome/skills/bud/references/intent.md +30 -0
  221. package/genome/skills/check-plain-env/SKILL.md +285 -0
  222. package/genome/skills/consolidate-concepts/SKILL.md +192 -0
  223. package/genome/skills/create-import-module/SKILL.md +104 -0
  224. package/genome/skills/create-requires-module/SKILL.md +109 -0
  225. package/genome/skills/debug-specs/SKILL.md +188 -0
  226. package/genome/skills/implement-conformance-testing-script/SKILL.md +238 -0
  227. package/genome/skills/implement-conformance-testing-script/assets/run_conformance_tests_cypress.ps1 +337 -0
  228. package/genome/skills/implement-conformance-testing-script/assets/run_conformance_tests_golang.ps1 +111 -0
  229. package/genome/skills/implement-conformance-testing-script/assets/run_conformance_tests_java.sh +136 -0
  230. package/genome/skills/implement-conformance-testing-script/assets/run_conformance_tests_python.ps1 +103 -0
  231. package/genome/skills/implement-conformance-testing-script/assets/run_conformance_tests_python.sh +150 -0
  232. package/genome/skills/implement-unit-testing-script/SKILL.md +160 -0
  233. package/genome/skills/implement-unit-testing-script/assets/run_unittests_flutter.ps1 +87 -0
  234. package/genome/skills/implement-unit-testing-script/assets/run_unittests_golang.ps1 +73 -0
  235. package/genome/skills/implement-unit-testing-script/assets/run_unittests_java.sh +72 -0
  236. package/genome/skills/implement-unit-testing-script/assets/run_unittests_python.ps1 +81 -0
  237. package/genome/skills/implement-unit-testing-script/assets/run_unittests_python.sh +98 -0
  238. package/genome/skills/implement-unit-testing-script/assets/run_unittests_react.ps1 +87 -0
  239. package/genome/skills/init-plain-project/SKILL.md +117 -0
  240. package/genome/skills/init-plain-project/references/checklist.md +30 -0
  241. package/genome/skills/mature/SKILL.md +126 -0
  242. package/genome/skills/refactor-module/SKILL.md +196 -0
  243. package/genome/skills/resolve-section-ownership/SKILL.md +64 -0
  244. package/genome/skills/resolve-spec-conflict/SKILL.md +96 -0
  245. package/genome/skills/spawn/SKILL.md +163 -0
  246. package/package.json +68 -4
  247. package/stdlib/backend/common.plain +25 -0
  248. package/stdlib/backend/conformance/common.plain +12 -0
  249. package/stdlib/backend/conformance/go-gin.plain +9 -0
  250. package/stdlib/backend/conformance/go-nethttp.plain +9 -0
  251. package/stdlib/backend/conformance/java-springboot.plain +9 -0
  252. package/stdlib/backend/conformance/python-django.plain +9 -0
  253. package/stdlib/backend/conformance/python-fastapi.plain +9 -0
  254. package/stdlib/backend/conformance/python-flask.plain +9 -0
  255. package/stdlib/backend/conformance/rust-axum.plain +9 -0
  256. package/stdlib/backend/conformance/typescript-express.plain +9 -0
  257. package/stdlib/backend/conformance/typescript-fastify.plain +9 -0
  258. package/stdlib/backend/conformance/typescript-nestjs.plain +9 -0
  259. package/stdlib/backend/go-gin.plain +18 -0
  260. package/stdlib/backend/go-nethttp.plain +18 -0
  261. package/stdlib/backend/java-springboot.plain +19 -0
  262. package/stdlib/backend/python-django.plain +19 -0
  263. package/stdlib/backend/python-fastapi.plain +23 -0
  264. package/stdlib/backend/python-flask.plain +23 -0
  265. package/stdlib/backend/rust-axum.plain +17 -0
  266. package/stdlib/backend/typescript-express.plain +19 -0
  267. package/stdlib/backend/typescript-fastify.plain +19 -0
  268. package/stdlib/backend/typescript-nestjs.plain +19 -0
  269. package/stdlib/cli/common.plain +17 -0
  270. package/stdlib/cli/conformance/common.plain +11 -0
  271. package/stdlib/cli/conformance/go-cobra.plain +9 -0
  272. package/stdlib/cli/conformance/go-flag.plain +9 -0
  273. package/stdlib/cli/conformance/java-picocli.plain +9 -0
  274. package/stdlib/cli/conformance/python-argparse.plain +9 -0
  275. package/stdlib/cli/conformance/python-click.plain +9 -0
  276. package/stdlib/cli/conformance/python-typer.plain +9 -0
  277. package/stdlib/cli/conformance/rust-clap.plain +9 -0
  278. package/stdlib/cli/conformance/typescript-commander.plain +9 -0
  279. package/stdlib/cli/go-cobra.plain +14 -0
  280. package/stdlib/cli/go-flag.plain +14 -0
  281. package/stdlib/cli/java-picocli.plain +15 -0
  282. package/stdlib/cli/python-argparse.plain +18 -0
  283. package/stdlib/cli/python-click.plain +16 -0
  284. package/stdlib/cli/python-typer.plain +16 -0
  285. package/stdlib/cli/rust-clap.plain +14 -0
  286. package/stdlib/cli/typescript-commander.plain +17 -0
  287. package/stdlib/frontend/angular.plain +17 -0
  288. package/stdlib/frontend/common.plain +17 -0
  289. package/stdlib/frontend/conformance/angular.plain +5 -0
  290. package/stdlib/frontend/conformance/common.plain +11 -0
  291. package/stdlib/frontend/conformance/react-vite.plain +5 -0
  292. package/stdlib/frontend/conformance/sveltekit-spa.plain +5 -0
  293. package/stdlib/frontend/conformance/vue-vite.plain +5 -0
  294. package/stdlib/frontend/react-vite.plain +16 -0
  295. package/stdlib/frontend/sveltekit-spa.plain +16 -0
  296. package/stdlib/frontend/vue-vite.plain +16 -0
  297. package/stdlib/lang/go.plain +13 -0
  298. package/stdlib/lang/java.plain +12 -0
  299. package/stdlib/lang/python.plain +18 -0
  300. package/stdlib/lang/rust.plain +12 -0
  301. package/stdlib/lang/typescript.plain +13 -0
  302. package/stdlib/library/common.plain +15 -0
  303. package/stdlib/library/conformance/common.plain +9 -0
  304. package/stdlib/library/conformance/go.plain +9 -0
  305. package/stdlib/library/conformance/java-gradle.plain +9 -0
  306. package/stdlib/library/conformance/java-maven.plain +9 -0
  307. package/stdlib/library/conformance/python.plain +9 -0
  308. package/stdlib/library/conformance/rust.plain +9 -0
  309. package/stdlib/library/conformance/typescript.plain +9 -0
  310. package/stdlib/library/go.plain +12 -0
  311. package/stdlib/library/java-gradle.plain +13 -0
  312. package/stdlib/library/java-maven.plain +13 -0
  313. package/stdlib/library/python.plain +13 -0
  314. package/stdlib/library/rust.plain +12 -0
  315. package/stdlib/library/typescript.plain +15 -0
@@ -0,0 +1,2372 @@
1
+ // Whether the terminal shows images, before anything draws: the banner's jellyfish is a picture or text.
2
+ import "./terminal-images.js";
3
+ import { createHash } from "node:crypto";
4
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
5
+ import { basename, dirname, join, relative, resolve } from "node:path";
6
+ import { conversationCommand, conversationSettled, conversationWriting } from "./regenerate/conversation-writes.js";
7
+ import { fileURLToPath } from "node:url";
8
+ import { allocateImageId, Box, compositeTuiLine, getCapabilities, getPngDimensions, isKeyRelease, isKeyRepeat, matchesKey, renderImage, Text, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
9
+ import { defineTool } from "@earendil-works/pi-coding-agent";
10
+ import { Type } from "typebox";
11
+ import { CODING_MODEL_ENV } from "./chat-models.js";
12
+ import { AGENT_ROLES, CONFIG_FILE, configForSpec, hasProjectConfig, LEGACY_CONFIG_FILE, projectConfigFile, stackConfigFileIn, stackOf, stacksOf, withStack, modelSpec, projectModels, readProjectConfig, roleModel, splitModelSpec } from "./config.js";
13
+ import { skillRunDir } from "./regenerate/paths.js";
14
+ import { sweepOrphanedGroups } from "./regenerate/shell.js";
15
+ import { readScript, scriptAgainstMachine, stackOnMachine, toolchainsBlock } from "./stack.js";
16
+ import { sandboxAvailable, sandboxSummary } from "./regenerate/os-sandbox.js";
17
+ import { findTestScripts } from "./regenerate/test-scripts.js";
18
+ import { createAskUserTool } from "./tools.js";
19
+ import { asksFromScratch, driftTool, fromWorkingDir, haltTool, loopReply, MODEL_PARAM, regenerateTool, stopJob, WAIT_PARAM, WORKING_DIR_PARAM } from "./loop-tools.js";
20
+ import { createWebTools } from "./regenerate/web.js";
21
+ import { describeGates, interruptedRun, runSkill } from "./genome.js";
22
+ import { skillStatus } from "./skill.js";
23
+ import { runningDeployed, stopDeployed } from "./regenerate/release.js";
24
+ import { createDeployState, createDeployTools } from "./release-tools.js";
25
+ import { runReleaseAgent } from "./release-agent.js";
26
+ import { createSteeringStore, EVERY_MODULE, moduleNameOf, queueSectionOf, steeringFor } from "./regenerate/steer.js";
27
+ import { askedForCommandLine, refusal, selfInvocation, TOOLS_NOT_CLI } from "./self-invocation.js";
28
+ import { describeChoices, describeResolution, modelFor, resolveModel } from "./regenerate/engine.js";
29
+ import { checkPlainFile, checkPlainTargets, formatCheckResults } from "./plain/check.js";
30
+ import { whyFailed } from "./regenerate/diagnose-job.js";
31
+ import { createWorkLoop, describeModules, isUpToDate, previewJobs } from "./regenerate/loop.js";
32
+ import { brandBlue, plainPaint } from "./regenerate/plain-palette.js";
33
+ import { createJobPanel, didNotPass, keyPressed, PANEL_KEY, spinner } from "./regenerate/job-panel.js";
34
+ import { skillAboutLine, skillProgressLine } from "./skill-progress.js";
35
+ import { createSidePanel } from "./regenerate/side-panel.js";
36
+ import { createSpecMapSource, defaultMapFiles } from "./regenerate/spec-map.js";
37
+ import { createSpecMapState } from "./regenerate/spec-map-view.js";
38
+ import { createJobsTreeState, selectJob } from "./regenerate/jobs-tree.js";
39
+ import { describeDiagnosis, diagnoseFailure } from "./regenerate/diagnose.js";
40
+ import { HISTORY_DIR, historyLog, historyShow } from "./regenerate/lineage.js";
41
+ import { defaultBuildDir } from "./regenerate/paths.js";
42
+ import { describeUsage } from "./regenerate/activity.js";
43
+ import { describeSpecConflict } from "./regenerate/stop-report.js";
44
+ import { inheritProviders, inheritThinkingLevel } from "./pi.js";
45
+ import { beginCapture, captureConsole, describeCaptured, dropNoise } from "./quiet.js";
46
+ import { loadRules, renderRulesBlock } from "./rules.js";
47
+ import { registerTurriConfig } from "./config-command.js";
48
+ import { describeStdlib, stdlibStackCount } from "./stdlib.js";
49
+ import { peekKeyListener, runAsk } from "./ask.js";
50
+ import { QuestionDialog } from "./question-dialog.js";
51
+ import { registerStacks } from "./stacks.js";
52
+ import { catalogueLine, discoverSkills, leftOutNote } from "./skills.js";
53
+ import { supersededByTurri } from "./chat-skills.js";
54
+ import { TURRI_IDENTITY } from "./identity.js";
55
+ import { conformanceChooser } from "./regenerate/choose-conformance.js";
56
+ import { answerSideQuestion, askWithAsides, BTW_HOW, runningSkill, skillRunStarted, skillSessionText, transcriptOf } from "./btw.js";
57
+ import { createJobRegistry, defaultParallelJobs, describeJob, formatDuration, pulseName, shouldWait, STOPPED_ON_REQUEST, } from "./regenerate/jobs.js";
58
+ /**
59
+ * Makes gated skills reachable from a conversation.
60
+ *
61
+ * The conversational agent keeps ordinary progressive disclosure - it reads the
62
+ * skill descriptions and decides when one applies. What it does not get is the
63
+ * choice of how to run it: `cultivate` hands the work to the graph, which owns
64
+ * the steps, the gates and the judges. The agent asks for the skill; it does not
65
+ * perform it.
66
+ */
67
+ const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
68
+ /**
69
+ * Puts a skill step's question to the person, when there is one to ask.
70
+ *
71
+ * A skill runs inside one tool call, in its own session, so its dialogs are not
72
+ * conversation turns and nothing it asks would otherwise reach the transcript. Every
73
+ * exchange is therefore reported back, so the interview is visible while it happens and
74
+ * survives in the tool's result afterwards.
75
+ */
76
+ export function askUserVia(ctx, onExchange,
77
+ /** Given, "/btw ..." typed as an answer is a side question: answered with this, and the question asked again. */
78
+ aside) {
79
+ return async (request) => {
80
+ if (!ctx.hasUI) {
81
+ return "No user is available to answer. Decide yourself, and say in your report what you assumed.";
82
+ }
83
+ ctx.ui.setWorkingMessage("waiting for your answer");
84
+ // The question has the keys: ctrl+h is caught here, or it would never reach the shortcut, which only the chat's editor sees.
85
+ const unhook = hookPeekKey(ctx);
86
+ try {
87
+ // The question, with what the asker gives to read for it - a brief to confirm - above it; what it said of its work
88
+ // before asking is kept in its record, never put in the dialog.
89
+ // A dialog of turri's own: a long brief scrolls above the question, and the options always show (question-dialog.ts).
90
+ const dialog = (question, context, options) => ctx.ui.custom((tui, theme, _keybindings, done) => new QuestionDialog({
91
+ question,
92
+ ...(context !== undefined ? { context } : {}),
93
+ ...(options !== undefined ? { options } : {}),
94
+ rows: () => tui.terminal.rows,
95
+ style: { accent: (text) => theme.fg("accent", text), muted: (text) => theme.fg("muted", text), bold: (text) => theme.bold(text) },
96
+ done,
97
+ requestRender: () => tui.requestRender(),
98
+ }));
99
+ const asking = (asked) => runAsk(asked, {
100
+ select: (title, options, context) => dialog(title, context, options),
101
+ input: (title, context) => dialog(title, context),
102
+ });
103
+ const bare = { ...request, said: undefined };
104
+ const answer = aside !== undefined ? await askWithAsides(bare, asking, aside) : await asking(bare);
105
+ // Kept: what was shown with it, and what it said before it.
106
+ onExchange(request.question, answer, [request.context, request.said].map((part) => part?.trim() ?? "").filter((part) => part !== "").join("\n\n") || undefined);
107
+ return answer;
108
+ }
109
+ finally {
110
+ unhook();
111
+ ctx.ui.setWorkingMessage();
112
+ }
113
+ };
114
+ }
115
+ /** The side panel, as a question open in the chat reaches it: set once the extension has its panel. */
116
+ let questionPeek;
117
+ /** A widget with nothing to draw: below the editor, it takes no line. */
118
+ const PEEK_KEY_WIDGET = "turri-peek-key";
119
+ /**
120
+ * While a question is open, ctrl+h opens the side panel over it - the question waits under it. pi gives an extension's
121
+ * shortcut only the keys its editor sees, and a question has the focus: the key is caught on the terminal's input
122
+ * instead. While the panel shows, its own listener has ctrl+h - to close it - and this lets it be.
123
+ */
124
+ function hookPeekKey(ctx) {
125
+ if (questionPeek === undefined)
126
+ return () => { };
127
+ const peek = questionPeek;
128
+ ctx.ui.setWidget(PEEK_KEY_WIDGET, (tui) => {
129
+ const unlisten = tui.addInputListener(peekKeyListener({ open: () => peek.open(ctx), shown: () => peek.shown() }));
130
+ return { render: () => [], invalidate() { }, dispose: unlisten };
131
+ }, { placement: "belowEditor" });
132
+ return () => ctx.ui.setWidget(PEEK_KEY_WIDGET, undefined);
133
+ }
134
+ /**
135
+ * ask_user, for the conversation's agent too - the same dialog a skill's interview uses: options to
136
+ * pick from, several at once, "Something else…" to type instead, "Chat about this" to talk it over in
137
+ * the chat first - so a question with a handful of answers is picked, not typed.
138
+ */
139
+ export function chatAskUserTool() {
140
+ let conversation;
141
+ const tool = createAskUserTool((request) => (conversation !== undefined ? askUserVia(conversation, () => { })(request) : Promise.resolve("No user is available to answer.")), undefined, undefined, undefined, "reply");
142
+ return {
143
+ ...tool,
144
+ promptGuidelines: [
145
+ ...(tool.promptGuidelines ?? []),
146
+ "When a question to the user has a handful of plausible answers - a choice, yes or no, which of these - ask it with ask_user rather than in your reply.",
147
+ ],
148
+ async execute(...args) {
149
+ conversation = args[4];
150
+ return tool.execute(...args);
151
+ },
152
+ };
153
+ }
154
+ export default function (pi) {
155
+ dropNoise();
156
+ const cwd = process.cwd();
157
+ // turri's own skills, and the project's - .turri/skills, .agents/skills (skills.ts).
158
+ const discoveredSkills = discoverSkills(PACKAGE_ROOT, cwd);
159
+ // What the background work is doing right now, drawn above the editor while it runs.
160
+ const panel = createJobPanel(() => shownJobs(jobs));
161
+ // Finished work is announced twice, to two audiences, because pi treats them differently.
162
+ // An entry is drawn the moment it is appended: the person sees the job end, now. The agent
163
+ // gets the full report as a hidden message that starts a turn of its own - at once when it is
164
+ // idle, else as soon as the turn it is in ends (a follow-up, never cutting into a reply or an
165
+ // interview) - so it reacts to every job that ends without waiting for the person to speak:
166
+ // does what the project's guidance says to do then (release what was built, say), or says
167
+ // what finished.
168
+ // What release has shipped, module by module: release does nothing for a module whose last commit it already shipped.
169
+ const deployState = createDeployState();
170
+ const jobs = createJobRegistry({
171
+ // The loop's plan changed - a module being planned, or planned: the live panel shows it at once.
172
+ onNotify: () => panel.refresh(),
173
+ // A job that fails is run once more; failing again, it and every job behind it fail, and the
174
+ // harness - which can read the skills, the rules, the code, the tests and turri itself -
175
+ // works out why, and what has to be done.
176
+ retries: 1,
177
+ // Modules that share nothing build side by side - as many at once as the machine has room for.
178
+ maxRunning: parallelJobs(cwd),
179
+ diagnose: async (job, failures, jobLog) => {
180
+ const diagnosis = await diagnoseFailure({
181
+ job,
182
+ failures,
183
+ projectDir: cwd,
184
+ harnessDir: PACKAGE_ROOT,
185
+ jobLog,
186
+ model: modelSpec(projectModels(readProjectConfig(cwd)).harness),
187
+ });
188
+ return diagnosis !== undefined ? describeDiagnosis(diagnosis) : undefined;
189
+ },
190
+ onChange: () => panel.refresh(),
191
+ onFinish: (job) => {
192
+ panel.refresh();
193
+ // Queued again as another job: that one says how it went.
194
+ if (job.replacedBy !== undefined)
195
+ return;
196
+ announceJob(pi, job);
197
+ // Gone wrong: the next ctrl+h opens on it - the live panel that showed it is gone with the last pulse.
198
+ if (job.status === "failed" || job.status === "stopped" || didNotPass(job))
199
+ needsLooking.set(jobs, job.id);
200
+ // A spec that contradicts itself is the person's to settle, now: the agent is started on it.
201
+ if (job.specConflict !== undefined) {
202
+ void pi.sendMessage({ customType: "turri_spec_conflict", content: specConflictTask(job.specConflict), display: false, details: { jobId: job.id } }, { triggerTurn: true });
203
+ return;
204
+ }
205
+ void pi.sendMessage({
206
+ customType: "turri_job",
207
+ content: (job.status === "succeeded"
208
+ ? `Pulse ${job.id} (${job.kind}) finished after ${formatDuration(job)}.\n\n${job.report ?? ""}`
209
+ : job.status === "stopped"
210
+ ? `Pulse ${job.id} (${job.kind}) was stopped after ${formatDuration(job)}. ${job.error ?? STOPPED_ON_REQUEST}${job.error === undefined || job.error === STOPPED_ON_REQUEST ? " The person stopped it; what it was building counts as not built." : ""}`
211
+ : `Pulse ${job.id} (${job.kind}) failed after ${formatDuration(job)}.\n\n${job.error ?? ""}`) +
212
+ `\n\n${JOB_FINISHED_REACTION}`,
213
+ display: false,
214
+ details: { jobId: job.id, status: job.status },
215
+ }, { triggerTurn: true, deliverAs: "followUp" });
216
+ },
217
+ });
218
+ // The ***plain rules go into the system prompt in full, every turn. The block is
219
+ // identical each time, so it sits inside the cached prefix rather than invalidating it.
220
+ // With them, the instruction to do turri's work with its tools rather than its command
221
+ // line - enforced below, not only asked for.
222
+ const rules = loadRules(join(PACKAGE_ROOT, "genome", "rules"), readProjectConfig(cwd).rulesOff ?? []);
223
+ // Laid out again once rules-off changes - /turri-config changes it as turri runs - and the same block in between.
224
+ let rulesKept = { off: (readProjectConfig(cwd).rulesOff ?? []).join(","), block: chatRulesBlock(rules) };
225
+ const rulesBlock = () => {
226
+ const off = readProjectConfig(cwd).rulesOff ?? [];
227
+ if (off.join(",") !== rulesKept.off)
228
+ rulesKept = { off: off.join(","), block: chatRulesBlock(loadRules(join(PACKAGE_ROOT, "genome", "rules"), off)) };
229
+ return rulesKept.block;
230
+ };
231
+ // What the project's config.turri.yaml says, read afresh every turn: the agent answers from it, and
232
+ // the tools default to it.
233
+ // pi's skills that turri has already - its own and the project's, run with cultivate, and any about ***plain from
234
+ // another tool - are left out of the list the conversation is offered (chat-skills.ts); the person is told once.
235
+ const turriRuns = new Set(discoveredSkills.skills.map((skill) => skill.name));
236
+ // What the modules need of this machine: worked out once the chat is up, and after a turn when a spec changed - never
237
+ // while a message waits to be sent.
238
+ const machine = machineNeeds(cwd);
239
+ pi.on("session_start", () => machine.refreshSoon());
240
+ pi.on("agent_settled", () => machine.refreshSoon());
241
+ let leftOutSaid = false;
242
+ pi.on("before_agent_start", (event, ctx) => {
243
+ const offered = event.systemPromptOptions.skills;
244
+ const leftOut = offered.filter((skill) => supersededByTurri(skill, turriRuns));
245
+ if (leftOut.length > 0) {
246
+ event.systemPromptOptions.skills = offered.filter((skill) => !leftOut.includes(skill));
247
+ if (!leftOutSaid && ctx.hasUI) {
248
+ leftOutSaid = true;
249
+ const names = leftOut.map((skill) => skill.name);
250
+ ctx.ui.notify(`turri left out ${names.length} of pi's skills it has already - ${names.slice(0, 6).join(", ")}${names.length > 6 ? ", ..." : ""}: ` +
251
+ "the ***plain reference is in its prompt, and it runs its own skills with cultivate.", "info");
252
+ }
253
+ }
254
+ return {
255
+ systemPrompt: [event.systemPrompt, TURRI_IDENTITY, TOOLS_NOT_CLI, projectConfigBlock(cwd), machineBlock(cwd, machine.now()), rulesBlock()].filter((part) => part !== "").join("\n\n"),
256
+ };
257
+ });
258
+ // The conversation's agent does not run turri again through its shell, unless the person
259
+ // asked for the command line in the message it is answering.
260
+ let lastUserMessage = "";
261
+ pi.on("input", (event) => {
262
+ if (event.source !== "extension")
263
+ lastUserMessage = event.text;
264
+ return { action: "continue" };
265
+ });
266
+ pi.on("tool_call", (event) => {
267
+ const command = event.input.command;
268
+ if (event.toolName === "bash" && typeof command === "string") {
269
+ const what = selfInvocation(command);
270
+ if (what !== undefined && !askedForCommandLine(lastUserMessage))
271
+ return { block: true, reason: refusal(what) };
272
+ }
273
+ markConversationWrite(event.toolCallId, event.toolName, event.input);
274
+ return undefined;
275
+ });
276
+ // What the conversation writes in the project while a pulse's agent runs a command is its own: the audit after that
277
+ // command leaves it, rather than putting it back as the agent's (conversation-writes.ts).
278
+ const conversationWrites = new Map();
279
+ const markConversationWrite = (id, tool, input) => {
280
+ const path = typeof input.path === "string" ? input.path : undefined;
281
+ if ((tool === "edit" || tool === "write") && path !== undefined)
282
+ conversationWrites.set(id, conversationWriting([resolve(cwd, path)]));
283
+ else if (tool === "bash" || tool === "powershell")
284
+ conversationWrites.set(id, conversationCommand());
285
+ };
286
+ pi.on("tool_result", (event) => {
287
+ conversationWrites.get(event.toolCallId)?.();
288
+ conversationWrites.delete(event.toolCallId);
289
+ return undefined;
290
+ });
291
+ const settleConversation = () => {
292
+ for (const done of conversationWrites.values())
293
+ done();
294
+ conversationWrites.clear();
295
+ conversationSettled();
296
+ };
297
+ pi.on("agent_end", settleConversation);
298
+ // A turn's own model calls happen inside pi's agent loop, outside any tool, so the
299
+ // capture is bracketed around the turn itself. Without this, every model call writes
300
+ // through the terminal the TUI is drawing on and strands a frame in the scrollback.
301
+ let turnCapture;
302
+ pi.on("agent_start", () => {
303
+ turnCapture?.end();
304
+ turnCapture = beginCapture();
305
+ });
306
+ const endTurnCapture = () => {
307
+ turnCapture?.end();
308
+ turnCapture = undefined;
309
+ };
310
+ pi.on("agent_end", endTurnCapture);
311
+ pi.on("agent_settled", endTurnCapture);
312
+ // What a turri that was killed left running - its commands' process groups - stopped as this one starts.
313
+ const orphans = sweepOrphanedGroups();
314
+ let orphansSaid = orphans === 0;
315
+ pi.on("session_start", (_event, ctx) => {
316
+ if (orphansSaid || !ctx.hasUI)
317
+ return;
318
+ orphansSaid = true;
319
+ ctx.ui.notify(`Stopped ${orphans} process group${orphans === 1 ? "" : "s"} a turri that was killed left running.`, "info");
320
+ });
321
+ pi.on("session_start", (_event, ctx) => {
322
+ if (!ctx.hasUI)
323
+ return;
324
+ const ui = ctx.ui;
325
+ panel.attach({
326
+ setWidget: (key, content, options) => ui.setWidget(key, content, options),
327
+ getToolsExpanded: () => ui.getToolsExpanded(),
328
+ });
329
+ });
330
+ // Sub-agents think as hard as the conversation that started them, not pi's default.
331
+ inheritThinkingLevel(() => pi.getThinkingLevel());
332
+ // And reach the providers its extensions registered - a company's own gateway - which they load none of.
333
+ let conversationModels;
334
+ pi.on("session_start", (_event, ctx) => {
335
+ conversationModels = ctx.modelRegistry;
336
+ });
337
+ pi.on("before_agent_start", (_event, ctx) => {
338
+ conversationModels = ctx.modelRegistry;
339
+ });
340
+ inheritProviders(() => conversationModels);
341
+ registerBranding(pi, rules.length, discoveredSkills);
342
+ registerInterviewLog(pi);
343
+ registerJobTools(pi, jobs, cwd);
344
+ registerCheckPlain(pi, cwd);
345
+ registerRegenerate(pi, cwd, jobs);
346
+ registerDrift(pi, cwd, jobs);
347
+ registerGlimpse(pi, cwd, jobs);
348
+ registerBind(pi, cwd, jobs);
349
+ registerRenderModels(pi);
350
+ deployStates.set(jobs, deployState);
351
+ registerDeploy(pi, cwd, jobs, deployState);
352
+ registerSteering(pi, cwd);
353
+ registerSideQuestions(pi, cwd, jobs);
354
+ registerStacks(pi, cwd);
355
+ const { skills } = discoveredSkills;
356
+ if (skills.length === 0)
357
+ return;
358
+ const catalogue = skills.map(catalogueLine).join("\n");
359
+ const pendingNote = leftOutNote(discoveredSkills);
360
+ pi.registerTool({
361
+ name: "cultivate",
362
+ label: "Cultivate",
363
+ description: "Execute one of this project's gated skills end to end. The skill's steps, review gates and " +
364
+ "judges are enforced by the harness, so a gated skill's result has already passed review when it comes back; a skill " +
365
+ "without gates is not reviewed. Every result says which files the run changed in the project.\n\n" +
366
+ `Available skills:\n${catalogue}${pendingNote}`,
367
+ promptSnippet: "cultivate: run a gated skill - its steps and review gates enforced by turri",
368
+ promptGuidelines: [
369
+ "When a request matches a skill listed in cultivate's description, call cultivate instead of doing the work yourself. Doing it by hand skips the review gates.",
370
+ // pi lists .agents/skills among its own skills too: read and followed by hand, a skill's gates would be skipped.
371
+ "A skill cultivate lists may also be among your own skills, from the project's .agents/skills: run it with cultivate all the same, never by reading its SKILL.md and following it.",
372
+ "cultivate returns the reviewed artifact and a trace of every gate decision. Report the artifact to the user; do not rewrite it.",
373
+ "If cultivate fails, say so and stop. Never carry out the skill's work yourself in the conversation - the gates and the judges are the point of running it, and an unreviewed hand-run silently drops them.",
374
+ "Always pass `context` to cultivate, summarising what the user has already told you.",
375
+ ],
376
+ parameters: Type.Object({
377
+ skill: Type.String({
378
+ description: `Skill name. One of: ${skills.map((s) => s.name).join(", ")}`,
379
+ }),
380
+ context: Type.Optional(Type.String({
381
+ description: "Everything the user has already told you that this skill would otherwise ask for - what " +
382
+ "they are building, for whom, any decisions already made, names they have used. The skill " +
383
+ "runs in its own session and cannot see this conversation, so whatever you leave out it " +
384
+ "will ask again.",
385
+ })),
386
+ resume: Type.Optional(Type.Boolean({
387
+ description: "Continue the run of this skill that was interrupted here - by a restart, a crash - from the step " +
388
+ "it was in, with its session as it was. Use when the user wants to carry on where they left off.",
389
+ })),
390
+ fresh: Type.Optional(Type.Boolean({
391
+ description: "Start a new run even though an interrupted one exists here, discarding it.",
392
+ })),
393
+ }),
394
+ async execute(_id, params, _signal, onUpdate, ctx) {
395
+ const skill = skills.find((s) => s.name === params.skill);
396
+ if (!skill) {
397
+ return {
398
+ content: [{ type: "text", text: `Unknown skill '${params.skill}'. Available: ${skills.map((s) => s.name).join(", ")}` }],
399
+ details: {},
400
+ };
401
+ }
402
+ // A new run clears the run folder. An interrupted one is not thrown away by accident:
403
+ // whether to pick it up or start over is the user's call.
404
+ const runDir = skillRunDir(cwd, skill.name);
405
+ const interrupted = interruptedRun(runDir, skill.name);
406
+ if (interrupted !== undefined && params.resume !== true && params.fresh !== true) {
407
+ return {
408
+ content: [
409
+ {
410
+ type: "text",
411
+ text: `There is an interrupted run of ${skill.name} here${interrupted.step ? `, stopped in its ${interrupted.step} step` : ""} ` +
412
+ `(last active ${interrupted.updatedAt}). Ask the user whether to continue it or start over, then call ` +
413
+ "cultivate again with resume: true or fresh: true. Nothing has been started.",
414
+ },
415
+ ],
416
+ details: { interrupted },
417
+ };
418
+ }
419
+ const trace = [`skill: ${skill.name}`, ...describeGates(skill).map((l) => ` ${l}`)];
420
+ // While it runs, what its agent is doing leads: pi shows a running tool's first lines, and the trace's first
421
+ // lines are the gates, which say nothing about now. The result keeps the trace alone.
422
+ const live = { started: Date.now(), step: "", stepStarted: Date.now(), recent: [], saying: "" };
423
+ const show = () => {
424
+ const elapsed = (since) => {
425
+ const s = Math.round((Date.now() - since) / 1000);
426
+ return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`;
427
+ };
428
+ const words = live.saying.replace(/\s+/g, " ").trim();
429
+ const view = [
430
+ `${skill.name}${live.step !== "" ? ` · ${live.step}, ${elapsed(live.stepStarted)}` : ""} · ${elapsed(live.started)} in all`,
431
+ ...live.recent.map((line) => ` ${line}`),
432
+ ...(words !== "" ? [` says: ${words.length > 160 ? `…${words.slice(-159)}` : words}`] : []),
433
+ "",
434
+ ];
435
+ onUpdate?.({ content: [{ type: "text", text: [...view, ...trace].join("\n") }], details: {} });
436
+ };
437
+ const push = (line) => {
438
+ trace.push(line);
439
+ show();
440
+ };
441
+ const ticker = setInterval(show, 5000);
442
+ ticker.unref?.();
443
+ // Above the input, while it runs: every phase by name, the one it is in, and how far through - and under it, what that phase does.
444
+ const phases = skill.steps.map((step) => step.name);
445
+ const progress = { current: -1, attempt: 1 };
446
+ if (ctx.hasUI) {
447
+ ctx.ui.setWidget("turri-skill", (tui, theme) => {
448
+ // The spinner on the phase it is in moves.
449
+ const redraw = setInterval(() => tui.requestRender(), 1000);
450
+ redraw.unref?.();
451
+ const style = {
452
+ heading: (text) => theme.bold(theme.fg("accent", text)),
453
+ done: (text) => theme.fg("success", text),
454
+ now: (text) => theme.bold(theme.fg("accent", text)),
455
+ muted: (text) => theme.fg("muted", text),
456
+ };
457
+ return {
458
+ render: (width) => {
459
+ const about = skill.steps[progress.current]?.about;
460
+ const now = { skill: skill.name, phases, current: progress.current, attempt: progress.attempt, frame: spinner(Date.now()), ...(about !== undefined ? { about } : {}) };
461
+ const under = skillAboutLine(now, width, style);
462
+ return [skillProgressLine(now, width, style), ...(under !== undefined ? [under] : [])];
463
+ },
464
+ invalidate: () => { },
465
+ dispose: () => clearInterval(redraw),
466
+ };
467
+ });
468
+ }
469
+ const report = {
470
+ step: (name, attempt) => {
471
+ progress.current = phases.indexOf(name);
472
+ progress.attempt = attempt;
473
+ live.step = skillStatus(skill, name, attempt).slice(skill.name.length + 2);
474
+ live.stepStarted = Date.now();
475
+ push(`step ${name} (attempt ${attempt})`);
476
+ },
477
+ activity: (line) => {
478
+ live.recent = [...live.recent, line].slice(-6);
479
+ show();
480
+ },
481
+ said: (delta, fresh) => {
482
+ live.saying = fresh ? "" : live.saying + delta;
483
+ show();
484
+ },
485
+ artifact: (path, body) => push(` ${path}: ${body.split("\n")[0]}${body.includes("\n") ? " ..." : ""}`),
486
+ gate: (name, judges) => push(judges === 0 ? `gate ${name}: no artifact` : `gate ${name}: ${judges} judges`),
487
+ livePass: (step, scope, findings) => push(` review ${step}: ${scope} - ${findings === 0 ? "clean" : `${findings} to fix`}`),
488
+ liveReview: (name, passes, findings) => push(findings === 0
489
+ ? `gate ${name}: reviewer read along in ${passes} passes, nothing outstanding`
490
+ : `gate ${name}: ${findings} unresolved from the reviewer`),
491
+ judge: (index, model, verdict, violations) => push(` judge ${index + 1} (${model}): ${verdict}${violations ? ` (${violations} violations)` : ""}`),
492
+ outcome: (name, decision, critique) => {
493
+ push(` ${decision} <- gate ${name}`);
494
+ if (critique)
495
+ for (const line of critique.split("\n"))
496
+ push(` ${line}`);
497
+ },
498
+ };
499
+ // What the run changes in the project - its specs, resources, scripts - is said with its result: a skill that
500
+ // writes nothing is not taken for one that did its work.
501
+ const filesBefore = projectFiles(cwd);
502
+ // A side question asked while it runs is told what it has done so far.
503
+ const ended = skillRunStarted({ skill: skill.name, dir: skill.dir, step: () => live.step, trace: () => [...trace], runDir });
504
+ const { result, output: captured } = await captureConsole(() => runSkill({
505
+ skillDir: skill.dir,
506
+ cwd,
507
+ runDir,
508
+ resume: params.resume === true && interrupted !== undefined,
509
+ report,
510
+ context: conversationContext(ctx, params.context),
511
+ askUser: askUserVia(ctx, (question, answer, said) => {
512
+ // What the skill's agent said before asking stays in the run's record, not only in the dialog.
513
+ for (const line of (said ?? "").trim().split("\n").filter((line) => line.trim() !== ""))
514
+ push(` ${line}`);
515
+ push(` Q: ${question}`);
516
+ push(` A: ${answer}`);
517
+ recordExchange(pi, { skill: skill.name, question, answer, ...(said !== undefined && said.trim() !== "" ? { said: said.trim() } : {}) });
518
+ }, (question) => askOnTheSide(pi, ctx, cwd, jobs, question)),
519
+ model: conversationModel(ctx),
520
+ // Renders queued by a step land in the same registry as everything else, so
521
+ // pulses sees them without knowing a skill started them.
522
+ // Asked per job as it is queued, so a model chosen mid-interview applies from then on.
523
+ renderQueue: workLoopFor(jobs, cwd, ctx),
524
+ // A skill that waits for its builds and reads what they recorded - `mature` - sees the same jobs.
525
+ jobs,
526
+ // The person answers the skill's agent while it runs, so it can take "render these with ..." too.
527
+ // validate too: a skill validates what it wrote with the same check the conversation has.
528
+ sharedTools: [setRenderModelTool(pi, ctx), checkPlainTool(cwd), ...createWebTools()],
529
+ // The conversation's own regenerate, drift, halt and conform, on its loop: a skill waits for nothing, and regenerates on the model chosen.
530
+ loopTools: [
531
+ regenerateTool({ cwd, jobs, loop: () => workLoopFor(jobs, cwd, ctx), model: (_skill, plainFile, named, work) => jobModel(ctx, work ?? "spec_changes", named, plainFile), wait: () => false }),
532
+ driftTool({ cwd, jobs, loop: () => workLoopFor(jobs, cwd, ctx) }),
533
+ haltTool({ jobs }),
534
+ conformTool({ cwd, loop: () => workLoopFor(jobs, cwd, ctx), model: (_skill, plainFile, named) => jobModel(ctx, "spec_changes", named, plainFile), wait: () => false }),
535
+ ],
536
+ // The escalation becomes a real dialog in the TUI. Without dialog-capable
537
+ // UI there is nobody to ask, so the gate stands.
538
+ escalate: async ({ question, critique }) => {
539
+ if (!ctx.hasUI) {
540
+ push(" no UI available for human review - gate stands");
541
+ return false;
542
+ }
543
+ return ctx.ui.confirm(`Review gate: ${skill.name}`, `${critique}\n\n${question}`);
544
+ },
545
+ })).finally(() => {
546
+ ended();
547
+ clearInterval(ticker);
548
+ if (ctx.hasUI)
549
+ ctx.ui.setWidget("turri-skill", undefined);
550
+ });
551
+ const noise = describeCaptured(captured);
552
+ if (noise !== null)
553
+ push(noise);
554
+ const changes = changedFiles(filesBefore, projectFiles(cwd));
555
+ const changed = changes.length > 0
556
+ ? `It changed ${changes.length} file${changes.length === 1 ? "" : "s"} in the project: ${changes.join(", ")}.`
557
+ : "It changed no file in the project - whatever it said it wrote is not there. Say so to the user; do not report the work as done.";
558
+ // A skill that edits the specs with the file tools builds nothing: the change is the conversation's to render.
559
+ const specsChanged = changes.filter((path) => path.endsWith(".plain"));
560
+ const unrendered = specsChanged.length > 0 && !skill.allowedTools.includes("write_spec_diff")
561
+ ? `${specsChanged.join(", ")} changed, and nothing is regenerated from that yet: call regenerate for it, and the code is brought up to the spec.`
562
+ : "";
563
+ const gated = skill.steps.some((step) => step.gate !== undefined);
564
+ const text = result.ok
565
+ ? [
566
+ gated ? `${skill.name}: passed all gates.` : `${skill.name}: finished. It has no review gates, so nothing checked its work: read what it changed before you report it.`,
567
+ changed,
568
+ ...(unrendered !== "" ? [unrendered] : []),
569
+ "",
570
+ "<artifact>",
571
+ result.output,
572
+ "</artifact>",
573
+ "",
574
+ "<trace>",
575
+ ...trace,
576
+ "</trace>",
577
+ ].join("\n")
578
+ : [`${skill.name}: stopped at a gate. No approved artifact.`, changed, "", "<trace>", ...trace, "</trace>"].join("\n");
579
+ return { content: [{ type: "text", text }], details: { ok: result.ok, artifacts: result.artifacts } };
580
+ },
581
+ });
582
+ }
583
+ function registerRegenerate(pi, cwd, jobs) {
584
+ pi.registerTool(regenerateTool({
585
+ cwd,
586
+ jobs,
587
+ loop: (ctx) => workLoopFor(jobs, cwd, ctx),
588
+ model: (ctx, plainFile, named, work) => jobModel(ctx, work ?? "spec_changes", named, plainFile),
589
+ wait: (ctx, asked) => shouldWait(ctx.mode, asked),
590
+ }));
591
+ // /regenerate: the same as the tool, from the person - a module brought up to its spec as it is now, cut into pulses
592
+ // by the planner: one functional spec at a time, a few together or the whole module in one, as it judges. What
593
+ // follows the module is guidance for that plan, in the person's words.
594
+ pi.registerCommand("regenerate", {
595
+ description: "Regenerate a module from its spec as it is now, in pulses the planner cuts - named functionalities though they did not change, or all of it from scratch: /regenerate [module | file.plain] [functionality ...] [from scratch] [how to cut the work]",
596
+ handler: async (args, ctx) => {
597
+ const command = `/regenerate ${args.trim()}`.trim();
598
+ await echoCommand(pi, command);
599
+ const say = (text, level) => shownAfterCommand(pi, ctx, command, text, level, true);
600
+ const all = args.trim().split(/\s+/).filter((word) => word !== "");
601
+ const [first = ""] = all;
602
+ const { named, rest } = moduleNamedFirst(cwd, all);
603
+ if (named === undefined && first.endsWith(".plain")) {
604
+ say(`There is no ${first} here.`, "error");
605
+ return;
606
+ }
607
+ // Functionalities named first, by id - 3, 2.1, fs 4 - as on the command line; what follows them is guidance.
608
+ const words = rest;
609
+ const again = [];
610
+ while (words.length > 0 && /^(fs)?\d+(\.\d+)*,?$/i.test(words[0]))
611
+ again.push(words.shift().replace(/^fs/i, "").replace(/,$/, ""));
612
+ const guidance = words.join(" ").trim();
613
+ let plainFile = named;
614
+ if (plainFile === undefined) {
615
+ const modules = defaultMapFiles(cwd);
616
+ if (modules.length === 0) {
617
+ say("No .plain file to regenerate: name one - /regenerate app.plain - or set module: in config.turri.yaml.", "warning");
618
+ return;
619
+ }
620
+ if (modules.length === 1 || !ctx.hasUI)
621
+ plainFile = modules[0];
622
+ else {
623
+ const label = (file) => relative(cwd, file) || basename(file);
624
+ const picked = await ctx.ui.select("Regenerate which module?", modules.map(label));
625
+ plainFile = modules.find((file) => label(file) === picked);
626
+ if (plainFile === undefined)
627
+ return;
628
+ }
629
+ }
630
+ const check = checkPlainFile(plainFile);
631
+ if (!check.ok) {
632
+ say(`${relative(cwd, plainFile) || plainFile} does not parse, so nothing can be regenerated from it yet: ${check.error}`, "error");
633
+ return;
634
+ }
635
+ // From scratch - started over, its code deleted - asked in the person's words: confirmed first, as it deletes.
636
+ const scratch = asksFromScratch(guidance);
637
+ const module = basename(plainFile).replace(/\.plain$/, "");
638
+ if (scratch && again.length > 0) {
639
+ say("Name functionalities or ask for it from scratch, not both: from scratch regenerates every one of them.", "warning");
640
+ return;
641
+ }
642
+ if (scratch && ctx.hasUI) {
643
+ const agreed = await ctx.ui.confirm(`Regenerate ${module} from scratch?`, `Its code and conformance tests are deleted - committed first, so /lineage ${module} keeps them - and the whole module is regenerated from its spec.`);
644
+ if (!agreed)
645
+ return;
646
+ }
647
+ const loop = workLoopFor(jobs, cwd, ctx);
648
+ const model = jobModel(ctx, again.length > 0 ? "functionalities" : "spec_changes", undefined, plainFile);
649
+ const request = { ...(model !== undefined ? { model } : {}), ...(guidance !== "" ? { guidance } : {}) };
650
+ // Said at once, and kept in the chat: what follows takes a while - the planner is an agent - and the person sees it
651
+ // began, not a chat that stays empty until the first pulse starts. The plan is said under it once it is made.
652
+ say(scratch
653
+ ? `Deleting ${module}'s code, then planning all of it again…`
654
+ : again.length > 0
655
+ ? `Planning ${module}'s functionalit${again.length === 1 ? "y" : "ies"} ${again.join(", ")} again…`
656
+ : `Planning ${module}'s pulses…`, "info");
657
+ await nextFrame();
658
+ try {
659
+ const statuses = scratch
660
+ ? await loop.fromScratch(plainFile, request)
661
+ : again.length > 0
662
+ ? await loop.regenerateAgain(plainFile, again, request)
663
+ : await loop.bringUp(plainFile, request);
664
+ // Nothing to do: say how to build it again anyway.
665
+ const hint = statuses.length > 0 && statuses.every(isUpToDate)
666
+ ? `\nTo regenerate parts of it anyway, name their functionalities - /regenerate ${basename(plainFile)} 2.1 - or start over with /regenerate ${basename(plainFile)} from scratch.`
667
+ : "";
668
+ say(describeModules(statuses) + hint, "info");
669
+ }
670
+ catch (error) {
671
+ say(error instanceof Error ? error.message : String(error), "error");
672
+ }
673
+ },
674
+ });
675
+ }
676
+ /**
677
+ * What a slash command showed the person: kept in the chat, where they read it, and put in the conversation as well -
678
+ * unseen there - so that when they speak of it next ("fix it"), the agent knows what they saw. A command runs outside
679
+ * the agent's turns, and a notification is gone in seconds and reaches no model.
680
+ */
681
+ export function shownAfterCommand(pi, ctx, command, text, level,
682
+ /** The command is in the chat already, above it (echoCommand): the text alone. */
683
+ echoed = false) {
684
+ try {
685
+ pi.appendEntry(COMMAND_OUTCOME, { command, text, level, ...(echoed ? { echoed } : {}) });
686
+ }
687
+ catch {
688
+ // Shown for a moment all the same.
689
+ if (ctx.hasUI)
690
+ ctx.ui.notify(text, level);
691
+ }
692
+ void pi.sendMessage({ customType: COMMAND_OUTCOME, content: `The person ran ${command}, and was shown:\n${text}`, display: false });
693
+ }
694
+ export const COMMAND_OUTCOME = "turri_command_outcome";
695
+ /** Longer than a frame of the TUI's (16ms): an entry appended before it is drawn by its end. */
696
+ const ONE_FRAME_MS = 20;
697
+ /**
698
+ * What the person typed, put in the chat the moment they send it - before the command does anything - and drawn before
699
+ * it goes on: a command's work can hold the event loop (a long requires chain parsed), and the chat stays as it was
700
+ * until it lets go. What the command says comes under it.
701
+ */
702
+ export async function echoCommand(pi, command) {
703
+ try {
704
+ pi.appendEntry(COMMAND_OUTCOME, { command });
705
+ }
706
+ catch {
707
+ return;
708
+ }
709
+ await nextFrame();
710
+ }
711
+ /** Until the TUI has drawn what was just put in the chat. */
712
+ export const nextFrame = () => new Promise((resolve) => setTimeout(resolve, ONE_FRAME_MS));
713
+ /**
714
+ * The module a /regenerate's first words name, and the words after it - all of them when none is named. A name typed
715
+ * with spaces for its underscores - "d365_function executor" - is the module d365_function_executor: the most words
716
+ * that name one win.
717
+ */
718
+ export function moduleNamedFirst(cwd, words) {
719
+ for (let count = words.length; count >= 1; count -= 1) {
720
+ const named = plainFileNamed(cwd, words.slice(0, count).join("_"));
721
+ if (named !== undefined)
722
+ return { named, rest: words.slice(count) };
723
+ }
724
+ return { rest: words };
725
+ }
726
+ /** The .plain file a /regenerate argument names - a path, or a module's name in the folder - if it names one. */
727
+ function plainFileNamed(cwd, word) {
728
+ for (const candidate of [word, `${word}.plain`]) {
729
+ const path = resolve(cwd, candidate);
730
+ if (path.endsWith(".plain") && existsSync(path))
731
+ return path;
732
+ }
733
+ return undefined;
734
+ }
735
+ /** What is left to regenerate: the drift between a module's code and its spec - planning nothing, starting nothing. */
736
+ function registerDrift(pi, cwd, jobs) {
737
+ pi.registerTool(driftTool({ cwd, jobs, loop: (ctx) => workLoopFor(jobs, cwd, ctx) }));
738
+ }
739
+ function registerBind(pi, cwd, jobs) {
740
+ pi.registerTool(conformTool({
741
+ cwd,
742
+ loop: (ctx) => workLoopFor(jobs, cwd, ctx),
743
+ model: (ctx, plainFile, named) => jobModel(ctx, "spec_changes", named, plainFile),
744
+ wait: (ctx, asked) => shouldWait(ctx.mode, asked),
745
+ }));
746
+ }
747
+ /**
748
+ * conform, for the conversation and for a skill that names it in its allowed-tools - the skill's on the conversation's
749
+ * loop, its pulses scheduled like any other.
750
+ */
751
+ function conformTool(options) {
752
+ const { cwd } = options;
753
+ return defineTool({
754
+ name: "conform",
755
+ label: "Conform",
756
+ description: "Write and run the black-box conformance tests of a module's functional specs - the ones named, or every one - on " +
757
+ "the code as it is built, in a pulse of their own - every functional spec tested in one, as far as a size guardrail " +
758
+ "lets, or one a functional spec when the user asks for that: each writes its tests from the spec's test reqs and acceptance " +
759
+ "tests, runs them with the whole suite the module inherits, and, where one fails, fixes the test or the code, " +
760
+ "whichever is wrong. What they are written for is recorded apart from the code. For this session, whatever the " +
761
+ "config's conformance-tests says. Functional specs not built yet can be named: the conformance pulse is scheduled at " +
762
+ "once and starts when the module's code is built - every code pulse of it landed - before anything is built on it. A functional spec " +
763
+ "the spec does not have, or a module without test reqs, is refused. A conformance pulse checks, as it starts, that " +
764
+ "the code record has every functional spec it tests, and does not start otherwise.",
765
+ promptSnippet: "conform: write and run conformance tests for functional specs - now, or ahead of their code",
766
+ promptGuidelines: [
767
+ "When the user asks for conformance tests - for a module, or some of its functional specs - call conform. Never edit config.turri.yaml for it, and never regenerate a functionality to get them.",
768
+ "Under conformance-tests: chosen or module, the chooser decides which functional specs get conformance tests - under chosen as each code pulse ends, under module once the whole module is regenerated - and a pulse's report says what it chose and why. You do not choose them: call conform only when the user asks for conformance tests. The ones named are added to those chosen before; a functional spec tested already keeps its tests.",
769
+ "What the user asks for comes first: for a module already regenerated, while a module built on it is being regenerated, conform stops that pulse, deletes the codebase built on it, writes the tests, and the module above is regenerated from it after - say so. For a module not regenerated yet - one above the one being regenerated - the tests are scheduled now and written once its code is built, before anything is built on it.",
770
+ "Conformance tests go in one pulse for all the functional specs tested. Set one_by_one only when the user asks for a pulse a functional spec - one at a time, separately, or the like.",
771
+ "When the user asks for the conformance tests in pulses of their own grouping - 1-3 and 4-6 - pass those groups as groups, in one call: each group is a pulse of its own, never sharing one with another. Without groups, every functional spec named goes in one pulse.",
772
+ "A conformance pulse that did not start because its code is not built: tell the user which functional specs it waits for, and which code pulse builds them.",
773
+ ],
774
+ parameters: Type.Object({
775
+ plain_file: Type.String({ description: "A .plain file of the module" }),
776
+ working_dir: WORKING_DIR_PARAM,
777
+ frids: Type.Optional(Type.Array(Type.String(), { description: "The functional specs to test, by id - added to those chosen before. Omitted: every one" })),
778
+ groups: Type.Optional(Type.Array(Type.Array(Type.String()), {
779
+ description: "Only when the user asks for them in pulses of their own grouping: the functional specs by id, a list a pulse - [[\"1\",\"2\",\"3\"],[\"4\",\"5\",\"6\"]]. Each is tested in a pulse of its own; they are added to those chosen, as frids are",
780
+ })),
781
+ one_by_one: Type.Optional(Type.Boolean({ description: "Only when the user asks for it: a pulse a functional spec. Omitted: every functional spec tested in one pulse, as far as the size guardrail lets" })),
782
+ model: MODEL_PARAM,
783
+ wait: WAIT_PARAM,
784
+ }),
785
+ async execute(_id, given, _signal, _onUpdate, ctx) {
786
+ const params = fromWorkingDir(cwd, given);
787
+ const plainFile = resolve(params.base, params.plain_file);
788
+ const model = options.model(ctx, plainFile, params.model);
789
+ const loop = options.loop(ctx);
790
+ const statuses = await loop.conformance(plainFile, {
791
+ ...(params.frids !== undefined ? { frids: params.frids } : {}),
792
+ ...(params.groups !== undefined ? { groups: params.groups } : {}),
793
+ ...(model !== undefined ? { model } : {}),
794
+ ...(params.one_by_one === true ? { separately: true } : {}),
795
+ });
796
+ return loopReply(loop, statuses, options.wait(ctx, params.wait));
797
+ },
798
+ });
799
+ }
800
+ function registerGlimpse(pi, cwd, jobs) {
801
+ pi.registerTool({
802
+ name: "glimpse",
803
+ label: "Glimpse the next pulse",
804
+ description: "Plan the work of a module as regenerate would, and show the pulse each codebase would start next and the whole " +
805
+ "prompt its coding agent would get - starting nothing. A codebase with a pulse running is left out: what its next pulse " +
806
+ "is given depends on what the running one lands.",
807
+ promptSnippet: "glimpse: the next pulse of a module and its coding agent's whole prompt, starting nothing",
808
+ promptGuidelines: [
809
+ "Use glimpse when the user wants to see what a pulse will be told before it runs, or to check a plan before steering it with steer.",
810
+ ],
811
+ parameters: Type.Object({
812
+ plain_file: Type.String({ description: "Path to the .plain file" }),
813
+ working_dir: WORKING_DIR_PARAM,
814
+ }),
815
+ async execute(_id, given, _signal, _onUpdate, ctx) {
816
+ const params = fromWorkingDir(cwd, given);
817
+ const plainFile = resolve(params.base, params.plain_file);
818
+ const running = new Set(workLoopFor(jobs, cwd, ctx).status().filter((status) => status.running !== undefined).map((status) => status.module));
819
+ const planner = chatModelOf(ctx);
820
+ const model = modelFor(chosenModels, "spec_changes", projectModel(ctx, plainFile) ?? planner);
821
+ const { statuses, previews, busy } = await previewJobs({
822
+ plainFile,
823
+ cwd,
824
+ ...(model !== undefined ? { model } : {}),
825
+ ...(planner !== undefined ? { plannerModel: planner } : {}),
826
+ steering: (file) => steeringFor(file, cwd, steeringStore),
827
+ busy: (module) => running.has(module),
828
+ });
829
+ const text = [
830
+ describeModules(statuses),
831
+ ...busy.map((module) => `\n${module} has a pulse running: its next pulse is planned from what that one lands - preview it once it has.`),
832
+ ...previews.map((preview) => [
833
+ "",
834
+ `<next pulse module="${preview.module}" track="${preview.track}" title="${preview.title}">`,
835
+ preview.task ?? "A conformance pulse's prompt is built from what the code it tests holds once it is built: it is not shown before.",
836
+ "</next pulse>",
837
+ ].join("\n")),
838
+ "",
839
+ "Nothing was started. Tell the user what the next pulse is and what it will be told - the parts they are likely to care about, not the whole prompt unless they ask.",
840
+ ].join("\n");
841
+ return { content: [{ type: "text", text }], details: {} };
842
+ },
843
+ });
844
+ }
845
+ /** The config a spec is built with - its project's, with the chosen stack's over it - or, with no spec, the session folder's. */
846
+ function configOf(plainFile) {
847
+ return plainFile !== undefined ? configForSpec(resolve(process.cwd(), plainFile)) : readProjectConfig(process.cwd(), stackOf(process.cwd()));
848
+ }
849
+ /**
850
+ * How many jobs the chat runs at once: `parallel-jobs:` in the session folder's config.turri.yaml - the only place it
851
+ * is set - else half this machine's cores, between one and four.
852
+ */
853
+ export function parallelJobs(cwd) {
854
+ return readProjectConfig(cwd).parallelJobs ?? defaultParallelJobs();
855
+ }
856
+ /** The project's own files and a digest of each - not plain_modules, dependencies, or turri's and git's folders. */
857
+ export function projectFiles(root) {
858
+ const files = new Map();
859
+ const walk = (dir, depth) => {
860
+ if (depth > 8)
861
+ return;
862
+ let entries;
863
+ try {
864
+ entries = readdirSync(dir, { withFileTypes: true });
865
+ }
866
+ catch {
867
+ return;
868
+ }
869
+ for (const entry of entries) {
870
+ if (entry.name.startsWith(".") || entry.name === "plain_modules" || entry.name === "node_modules" || entry.name === "__pycache__")
871
+ continue;
872
+ const path = join(dir, entry.name);
873
+ if (entry.isDirectory())
874
+ walk(path, depth + 1);
875
+ else if (entry.isFile()) {
876
+ try {
877
+ files.set(relative(root, path), createHash("sha1").update(readFileSync(path)).digest("hex"));
878
+ }
879
+ catch {
880
+ // A file that cannot be read is left out.
881
+ }
882
+ }
883
+ }
884
+ };
885
+ walk(root, 0);
886
+ return files;
887
+ }
888
+ /** The files added, changed or removed between two digests of the project. */
889
+ export function changedFiles(before, after) {
890
+ const changed = [...after].filter(([path, digest]) => before.get(path) !== digest).map(([path]) => path);
891
+ const removed = [...before.keys()].filter((path) => !after.has(path)).map((path) => `${path} (removed)`);
892
+ return [...changed, ...removed].sort();
893
+ }
894
+ /**
895
+ * This machine, for the chat's agent, read afresh every turn: which wall is up under the agents' commands, whether the
896
+ * machine has the toolchains the project's modules name, and what in its testing scripts it lacks - for the person to
897
+ * install or fix; the agents install nothing. `modules`: what the modules need, when it is known already - working it
898
+ * out parses every spec of the project.
899
+ */
900
+ export function machineBlock(cwd, modules = stackOnMachine(cwd)) {
901
+ const wall = sandboxAvailable();
902
+ const walls = wall.kind === "none"
903
+ ? `behind the command check and the audit alone - no OS sandbox: ${wall.why}${wall.fix !== undefined ? ` (to give it one: ${wall.fix})` : ""}`
904
+ : `behind the command check, ${wall.kind === "seatbelt" ? "Seatbelt" : "bubblewrap"} and the audit`;
905
+ const needing = modules.filter(({ needs }) => needs.length > 0);
906
+ const scripts = findTestScripts(cwd);
907
+ const misfits = [scripts.unit, scripts.prepare, scripts.conformance].flatMap((script) => {
908
+ if (script === undefined)
909
+ return [];
910
+ const text = readScript(script);
911
+ const found = text !== undefined ? scriptAgainstMachine(text) : [];
912
+ return found.length > 0 ? [`- ${relative(cwd, script)} does not fit this machine: it ${found.join("; ")} - the person's to fix.`] : [];
913
+ });
914
+ return [
915
+ "<this machine>",
916
+ `The agents' commands run on this machine, with its toolchains, ${walls}. The testing scripts run here too, unfenced, in the person's environment. Nothing is installed by an agent: what the machine lacks is the person's to install.`,
917
+ ...needing.map(({ module, needs, missing }) => `- ${module}: its implementation reqs need ${needs.map((need) => `${need.toolchain} (${need.words.join(", ")})`).join(", ")}${missing.length > 0 ? ` - this machine LACKS ${missing.map((need) => need.toolchain).join(", ")}` : " - this machine has them"}.`),
918
+ ...misfits,
919
+ "</this machine>",
920
+ "",
921
+ toolchainsBlock(),
922
+ ].join("\n");
923
+ }
924
+ /**
925
+ * What the project's modules need of this machine, kept between turns - working it out parses every spec, seconds on a
926
+ * project of long requires chains, and a message is never kept waiting for it. Worked out once the chat is up, and again
927
+ * when a turn has settled and a spec changed since: a turn is told what was known last, at most a turn behind.
928
+ */
929
+ export function machineNeeds(cwd, work = stackOnMachine) {
930
+ let known;
931
+ /** The project's specs as they are on disk: their names and when each last changed. */
932
+ const stamp = () => {
933
+ try {
934
+ return readdirSync(cwd)
935
+ .filter((name) => name.endsWith(".plain"))
936
+ .sort()
937
+ .map((name) => `${name}\0${statSync(join(cwd, name)).mtimeMs}`)
938
+ .join("\n");
939
+ }
940
+ catch {
941
+ return "";
942
+ }
943
+ };
944
+ const refresh = () => {
945
+ const now = stamp();
946
+ if (known?.stamp === now)
947
+ return;
948
+ known = { stamp: now, modules: work(cwd) };
949
+ };
950
+ return {
951
+ /** What is known - worked out now only when nothing is yet. */
952
+ now() {
953
+ if (known === undefined)
954
+ refresh();
955
+ return known.modules;
956
+ },
957
+ /** Worked out again, if a spec changed - once the chat has drawn what it is drawing. */
958
+ refreshSoon() {
959
+ setTimeout(() => {
960
+ try {
961
+ refresh();
962
+ }
963
+ catch {
964
+ // Told what was known last.
965
+ }
966
+ }, ONE_FRAME_MS);
967
+ },
968
+ };
969
+ }
970
+ /** Whether a folder holds a project's config: config.turri.yaml, a module's own, or the config.turri.yaml read until there is one. */
971
+ function hasConfig(dir) {
972
+ return hasProjectConfig(dir);
973
+ }
974
+ /**
975
+ * The project's config.turri.yaml, for the chat's agent: what it says, and what that means for the
976
+ * tools - so a question about the project's settings is answered from the file, not from the
977
+ * tools' defaults.
978
+ */
979
+ export function projectConfigBlock(root) {
980
+ // The session's folder, and the projects one folder down - specs kept in plain/, with its own
981
+ // config.turri.yaml and plain_modules - which its specs are built in when named from here.
982
+ const projects = [
983
+ ...(hasConfig(root) ? [root] : []),
984
+ ...readdirSync(root, { withFileTypes: true })
985
+ .filter((entry) => entry.isDirectory() && !entry.name.startsWith(".") && entry.name !== "node_modules" && entry.name !== "plain_modules")
986
+ .map((entry) => join(root, entry.name))
987
+ .filter(hasConfig),
988
+ ];
989
+ if (projects.length === 0)
990
+ return "";
991
+ const describe = (dir) => {
992
+ const stacks = stacksOf(dir);
993
+ const chosen = stackOf(dir);
994
+ const where = dir === root ? "" : ` (paths relative to ${relative(root, dir)}/)`;
995
+ const at = (name) => (dir === root ? name : `${relative(root, dir)}/${name}`);
996
+ return [
997
+ ...settings(readProjectConfig(dir), withStack(undefined, () => findTestScripts(dir)), dir === root ? `${projectConfigFile(dir)}, for the specs here:` : `${relative(root, dir)}/${projectConfigFile(dir)}, for the specs in ${relative(root, dir)}/ - built in ${relative(root, dir)}/plain_modules:`, where, stacks.length > 0 ? "project of stacks" : "project"),
998
+ ...(stacks.length > 0
999
+ ? ["", `Stacks: ${stacks.join(", ")} - chosen now: ${chosen ?? "none, so the project's file alone"}. Everything is built with the chosen one; switch with the stack tool when the user asks.`]
1000
+ : []),
1001
+ // A stack: its file over the project's, for everything built while it is chosen.
1002
+ ...stacks.flatMap((stack) => [
1003
+ "",
1004
+ ...settings(readProjectConfig(dir, stack), withStack(stack, () => findTestScripts(dir)), `${at(stackConfigFileIn(dir, stack))}, over ${projectConfigFile(dir)}, for everything built while the stack ${stack} is chosen${chosen === stack ? " - as it is now" : ""}:`, where, "stack"),
1005
+ ]),
1006
+ ];
1007
+ };
1008
+ const settings = (config, scripts, heading, where,
1009
+ /** The project's file, and whether it has stacks - whose scripts are theirs - or a stack's. */
1010
+ of) => {
1011
+ const perModule = of === "project of stacks";
1012
+ return [
1013
+ heading,
1014
+ ...[
1015
+ config.module !== undefined && of !== "stack" ? `- module: ${config.module}${where} - the module to build when the user does not name one.` : undefined,
1016
+ config.conformanceTests === false
1017
+ ? "- conformance-tests: false - conformance tests are OFF: renders write none and run none, unless the user asks for them in this conversation."
1018
+ : config.conformanceTests === "chosen"
1019
+ ? "- conformance-tests: chosen - conformance tests are on for the functional specs chosen: those with acceptance tests always, those the chooser picks as each code pulse ends from what it regenerated, and those the user asks for with conform. A conformance pulse starts once the module's code is built, before anything is built on it. Once chosen, a functional spec keeps them."
1020
+ : config.conformanceTests === "module"
1021
+ ? "- conformance-tests: module - conformance tests are on for the functional specs chosen: those with acceptance tests always, those the chooser picks - and plans into pulses - once the whole module is regenerated, and those the user asks for with conform. They run before anything is built on the module. Once chosen, a functional spec keeps them."
1022
+ : config.conformanceTests === true
1023
+ ? "- conformance-tests: all - conformance tests are on, for every functional spec of every module that has test reqs."
1024
+ : "- conformance-tests: not set - conformance tests are OFF, as with false: renders write none and run none, unless the user asks for them in this conversation.",
1025
+ config.review === true
1026
+ ? "- review: true - the code and its tests are reviewed once they pass: two reviews at most, the arbiter weighing the second."
1027
+ : `- review: ${config.review === false ? "false" : "not set, so off"} - the code and its tests are not reviewed: a pulse lands once its tests pass.`,
1028
+ ...(() => {
1029
+ // Each as it holds, with what it inherits: judge from coding, coding from harness.
1030
+ const models = projectModels(config);
1031
+ const shown = (choice) => modelSpec(choice) ?? "";
1032
+ return [
1033
+ models.coding !== undefined ? `- coding-model: ${shown(models.coding)}${config.codingModel === undefined ? " (from harness-model)" : ""} - what renders run on, unless engine chose another in this session.` : undefined,
1034
+ models.judge !== undefined ? `- judge-model: ${shown(models.judge)}${config.judgeModel === undefined ? " (from coding-model)" : ""} - the judge and the test review in a conformance run.` : undefined,
1035
+ models.harness !== undefined && of !== "stack" ? `- harness-model: ${shown(models.harness)} - the model you start with.` : undefined,
1036
+ // The other roles' own, where models: names them.
1037
+ ...AGENT_ROLES.filter((role) => role !== "coding" && role !== "chat").flatMap((role) => {
1038
+ const own = roleModel(config, role);
1039
+ return own !== undefined ? [`- models: ${role}: ${shown(own)}`] : [];
1040
+ }),
1041
+ ...(() => {
1042
+ const limits = AGENT_ROLES.flatMap((role) => (config.models?.[role]?.turnLimit !== undefined ? [`${role} ${config.models[role].turnLimit}s`] : []));
1043
+ const all = config.models?.default?.turnLimit ?? config.turnLimit;
1044
+ return all !== undefined || limits.length > 0 ? [`- turn limits: ${all !== undefined ? `${all}s each` : "two minutes each"}${limits.length > 0 ? `; ${limits.join(", ")}` : ""}.`] : [];
1045
+ })(),
1046
+ ];
1047
+ })(),
1048
+ `- unit testing script: ${scripts.unit !== undefined ? relative(root, scripts.unit) : perModule ? "none for the whole project - each stack's file below names its own" : "none found - every build stops until there is one"}`,
1049
+ `- conformance testing script: ${scripts.conformance !== undefined ? relative(root, scripts.conformance) : perModule ? "none for the whole project - see each stack's file" : "none found"}`,
1050
+ scripts.prepare !== undefined ? `- prepare script: ${relative(root, scripts.prepare)} - run on the build before every conformance testing script run, which runs only once it passes` : undefined,
1051
+ config.testingScriptsRunOn === "copy"
1052
+ ? "- testing-scripts-run-on: copy - the testing scripts run on this machine, on a fresh copy of each module's code; `testing-scripts-run-on: in-place` (the default) runs them on the code in place."
1053
+ : "- testing-scripts-run-on: in-place - the testing scripts run on this machine on the code in place, from the project, against the host codebase; `testing-scripts-run-on: copy` runs them on a fresh copy of each module's code.",
1054
+ ].filter((line) => line !== undefined),
1055
+ ];
1056
+ };
1057
+ return [
1058
+ `<project config>`,
1059
+ `A project's ${CONFIG_FILE} sets how its specs are built - and a stack's config.turri.<stack>.yaml, over it, how everything is built`,
1060
+ "while that stack is chosen: its testing scripts, toolchains, models and conformance - a backend and a frontend each with their own scripts.",
1061
+ "One stack is chosen at a time, for the session: the render tools follow the project's file with the chosen stack's over it;",
1062
+ `a project with no ${CONFIG_FILE} yet is read from its ${LEGACY_CONFIG_FILE} (and config.<stack>.yaml): to change a setting there, copy`,
1063
+ `${LEGACY_CONFIG_FILE} to ${CONFIG_FILE} - and each config.<stack>.yaml to config.turri.<stack>.yaml - first, then edit the copy; never edit ${LEGACY_CONFIG_FILE}.`,
1064
+ "answer questions about the project's settings from it, and say when a render will differ from it because the user",
1065
+ "asked. Name a spec by its path from here - plain/hubspot.plain - and it is built in its own project.",
1066
+ ...projects.flatMap((dir) => ["", ...describe(dir)]),
1067
+ `</project config>`,
1068
+ ].join("\n");
1069
+ }
1070
+ /**
1071
+ * The model the spawned agents should use.
1072
+ *
1073
+ * Without this they fall back to whatever pi is configured to default to, which is not
1074
+ * necessarily the model the conversation is on - switching model mid-session would leave
1075
+ * every tool still building with the old one, silently.
1076
+ */
1077
+ function conversationModel(ctx, override) {
1078
+ if (override !== undefined)
1079
+ return override;
1080
+ return ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined;
1081
+ }
1082
+ /**
1083
+ * The chat's model, read now - it can change while a skill runs - or, from a context that has
1084
+ * gone stale since, the model it had when it was last readable.
1085
+ */
1086
+ function chatModelOf(ctx) {
1087
+ try {
1088
+ const now = conversationModel(ctx);
1089
+ lastChatModel = now ?? lastChatModel;
1090
+ return now;
1091
+ }
1092
+ catch {
1093
+ return lastChatModel;
1094
+ }
1095
+ }
1096
+ let lastChatModel;
1097
+ /** The models chosen in this session, by kind of work. Saved in the session, so a resume keeps them. */
1098
+ const chosenModels = {};
1099
+ const MODEL_CHOICE_ENTRY = "turri_render_models";
1100
+ /**
1101
+ * The model a job's agents run on: the one named on the call, checked now so a wrong name fails
1102
+ * here and not minutes later in the background; else the one chosen for its kind of work; else
1103
+ * the conversation's.
1104
+ */
1105
+ /**
1106
+ * The model a render job runs on: the one asked for, or the one chosen in this session for its kind
1107
+ * of work, or the project's - `model:` in config.turri.yaml - or the conversation's.
1108
+ */
1109
+ function jobModel(ctx, work, named, plainFile) {
1110
+ if (named !== undefined && named.trim() !== "") {
1111
+ const resolved = resolveModel(named, ctx.modelRegistry);
1112
+ if (!resolved.ok)
1113
+ throw new Error(describeResolution(resolved));
1114
+ return resolved.model;
1115
+ }
1116
+ return modelFor(chosenModels, work, projectModel(ctx, plainFile) ?? chatModelOf(ctx));
1117
+ }
1118
+ /**
1119
+ * The coding model this session starts from, and where it was given: `turri
1120
+ * --coding-model`, or `coding-model:` in the project's config.turri.yaml - read afresh, so an edit to the
1121
+ * file holds for the next render.
1122
+ */
1123
+ function codingModelDefault(plainFile) {
1124
+ const flag = process.env[CODING_MODEL_ENV]?.trim();
1125
+ if (flag)
1126
+ return { named: flag, from: "--coding-model" };
1127
+ // The coding model inherits what it leaves out from the harness model.
1128
+ const named = modelSpec(projectModels(configOf(plainFile)).coding);
1129
+ return named !== undefined ? { named, from: CONFIG_FILE } : undefined;
1130
+ }
1131
+ /**
1132
+ * The default coding model, resolved as a name the person said would be. One that cannot be used
1133
+ * says so, and where it came from, rather than running on something else.
1134
+ */
1135
+ function projectModel(ctx, plainFile) {
1136
+ const given = codingModelDefault(plainFile);
1137
+ if (given === undefined)
1138
+ return undefined;
1139
+ // The model is looked up by name; how hard it thinks goes along with it as it was given.
1140
+ const { model, thinking } = splitModelSpec(given.named);
1141
+ if (model === undefined)
1142
+ return modelSpec({ thinking });
1143
+ const resolved = resolveModel(model, ctx.modelRegistry);
1144
+ if (!resolved.ok) {
1145
+ throw new Error(`${given.from} names the coding model "${model}", which cannot be used here.\n\n${describeResolution(resolved)}`);
1146
+ }
1147
+ return modelSpec({ model: resolved.model, thinking });
1148
+ }
1149
+ /** The default coding model for showing, with where it came from - never an error. */
1150
+ function projectModelLabel(ctx) {
1151
+ const given = codingModelDefault();
1152
+ if (given === undefined)
1153
+ return undefined;
1154
+ try {
1155
+ return { model: projectModel(ctx), from: given.from };
1156
+ }
1157
+ catch {
1158
+ return { model: `${given.named} - which cannot be used here`, from: given.from };
1159
+ }
1160
+ }
1161
+ /**
1162
+ * engine, for the conversation's agent and for a skill's agent - whom the person talks
1163
+ * to while the skill runs - alike: both write the same choice. `conversation` is the context to
1164
+ * read models and the chat's model from; omitted, the one the tool is called with.
1165
+ */
1166
+ function setRenderModelTool(pi, conversation) {
1167
+ return defineTool({
1168
+ name: "engine",
1169
+ label: "Engine",
1170
+ description: "The engine: which model the agents that regenerate the code run on. With no model, say which it is. With one, choose it " +
1171
+ "for the rest of this session: for spec changes " +
1172
+ "(render spec diff pulses, and the ones write_spec_diff queues), for functionalities (render, module, " +
1173
+ "regenerate with functionalities named, conformance), or for everything. By default " +
1174
+ "each runs on the model the project's config.turri.yaml names, or else the conversation's model. The model may be given as provider/model-id or by name as " +
1175
+ "the user said it; a name that fits more than one model, or none with credentials, comes back as a " +
1176
+ "list - ask the user which they meant, never pick one yourself. Pulses already queued keep their model.",
1177
+ promptSnippet: "the engine: show or choose the model that regenerates the code",
1178
+ promptGuidelines: [
1179
+ "When the user says which model should regenerate or build something, call engine with it - once; it holds for the rest of the session. To say which one it is, call engine with no model.",
1180
+ "If engine returns several candidates, ask the user which one with ask_user or in your reply, then call it again with the exact provider/model-id.",
1181
+ "Say which model a render will run on when you start one on something other than the conversation's model.",
1182
+ ],
1183
+ parameters: Type.Object({
1184
+ work: Type.Optional(Type.Union([Type.Literal("spec_changes"), Type.Literal("functionalities"), Type.Literal("all")], {
1185
+ description: "spec_changes, functionalities, or all of it. Omitted: all",
1186
+ })),
1187
+ model: Type.Optional(Type.String({
1188
+ description: 'provider/model-id, or the model\'s name as the user said it. "chat" goes back to the conversation\'s model. Omitted: say which it is, changing nothing',
1189
+ })),
1190
+ }),
1191
+ async execute(_id, given, _signal, _onUpdate, called) {
1192
+ const ctx = conversation ?? called;
1193
+ if (given.model === undefined) {
1194
+ const text = `The engine:\n${describeChoices(chosenModels, chatModelOf(ctx), projectModelLabel(ctx))}`;
1195
+ return { content: [{ type: "text", text }], details: { ...chosenModels } };
1196
+ }
1197
+ const params = { work: given.work ?? "all", model: given.model };
1198
+ const works = params.work === "all" ? ["all", "spec_changes", "functionalities"] : [params.work];
1199
+ if (/^(chat|default|conversation)$/i.test(params.model.trim())) {
1200
+ for (const work of works)
1201
+ delete chosenModels[work];
1202
+ }
1203
+ else {
1204
+ const resolved = resolveModel(params.model, ctx.modelRegistry);
1205
+ if (!resolved.ok)
1206
+ throw new Error(describeResolution(resolved));
1207
+ if (params.work === "all") {
1208
+ delete chosenModels.spec_changes;
1209
+ delete chosenModels.functionalities;
1210
+ }
1211
+ chosenModels[params.work] = resolved.model;
1212
+ }
1213
+ pi.appendEntry(MODEL_CHOICE_ENTRY, { ...chosenModels });
1214
+ const text = `The engine now:\n${describeChoices(chosenModels, chatModelOf(ctx), projectModelLabel(ctx))}`;
1215
+ return { content: [{ type: "text", text }], details: { ...chosenModels } };
1216
+ },
1217
+ });
1218
+ }
1219
+ /** Choosing which model renders what, for the rest of the session. */
1220
+ function registerRenderModels(pi) {
1221
+ pi.on("session_start", (_event, ctx) => {
1222
+ for (const key of Object.keys(chosenModels))
1223
+ delete chosenModels[key];
1224
+ const saved = ctx.sessionManager
1225
+ .getBranch()
1226
+ .filter((entry) => entry.type === "custom" && entry.customType === MODEL_CHOICE_ENTRY)
1227
+ .at(-1);
1228
+ Object.assign(chosenModels, saved?.data ?? {});
1229
+ });
1230
+ pi.registerTool(setRenderModelTool(pi));
1231
+ pi.registerCommand("engine", {
1232
+ description: "Show the engine: which model regenerates spec changes and functionalities. To change it, say so in the conversation.",
1233
+ handler: async (_args, ctx) => {
1234
+ const text = `The engine:\n${describeChoices(chosenModels, chatModelOf(ctx), projectModelLabel(ctx))}\n\nTo change it, say so - e.g. "regenerate spec changes with gpt 5.5".`;
1235
+ if (ctx.hasUI)
1236
+ ctx.ui.notify(text, "info");
1237
+ },
1238
+ });
1239
+ }
1240
+ /**
1241
+ * Run the work now, or start it as a background job and hand back an id.
1242
+ *
1243
+ * Background only makes sense where the session outlives the turn. In print, json and rpc
1244
+ * modes the process is gone the moment the answer is written, taking the job with it, so
1245
+ * those wait by default.
1246
+ */
1247
+ /** The one work loop of a conversation's jobs: every tool that builds brings its modules up through it. */
1248
+ const workLoops = new WeakMap();
1249
+ /** How the conversation wants the work planned, for the session - set with steer. */
1250
+ const steeringStore = createSteeringStore();
1251
+ /** What release has shipped in a conversation, by its jobs: shared by the chat's release and a pulse's own. */
1252
+ const deployStates = new WeakMap();
1253
+ function workLoopFor(jobs, cwd, ctx) {
1254
+ let shared = workLoops.get(jobs);
1255
+ if (shared === undefined) {
1256
+ const from = { ctx };
1257
+ const loop = createWorkLoop({
1258
+ registry: jobs,
1259
+ cwd,
1260
+ modelFor: (plainFile) => modelFor(chosenModels, "spec_changes", projectModel(from.ctx, plainFile) ?? chatModelOf(from.ctx)),
1261
+ // The planner reasons about the whole of the work: it runs on the chat's model.
1262
+ plannerModel: () => chatModelOf(from.ctx),
1263
+ // Under conformance-tests: chosen or module, the chooser decides as a pulse ends - on the chat's model - so the choice
1264
+ // is made before anything is built on the module; the pulse's report says what it chose, and why.
1265
+ onChoose: conformanceChooser({ model: () => chatModelOf(from.ctx) }),
1266
+ // How the work is to be planned: the conversation's steering, and AGENTS.md's - read afresh each plan.
1267
+ steering: (plainFile) => steeringFor(plainFile, cwd, steeringStore),
1268
+ // Under release-after-pulse, a pulse releases what it landed itself, before the next starts - on the model that
1269
+ // regenerated it: a release follows steps, and a small model thinking little does that well.
1270
+ release: ({ plainFile, moduleName, title, onDelta }) => {
1271
+ const model = modelFor(chosenModels, "spec_changes", projectModel(from.ctx, plainFile) ?? chatModelOf(from.ctx));
1272
+ return runReleaseAgent({
1273
+ plainFile,
1274
+ cwd,
1275
+ state: deployStates.get(jobs) ?? createDeployState(),
1276
+ afterPulse: { moduleName, subject: title },
1277
+ onDelta,
1278
+ ...(model !== undefined ? { model } : {}),
1279
+ });
1280
+ },
1281
+ });
1282
+ shared = { loop, from };
1283
+ workLoops.set(jobs, shared);
1284
+ }
1285
+ shared.from.ctx = ctx;
1286
+ return shared.loop;
1287
+ }
1288
+ async function deliver(jobs, input) {
1289
+ const wait = shouldWait(input.mode, input.wait);
1290
+ if (wait) {
1291
+ let streamed = "";
1292
+ const { result: outcome, output } = await captureConsole(() => input.run((delta) => {
1293
+ streamed += delta;
1294
+ input.onUpdate?.({ content: [{ type: "text", text: streamed }], details: {} });
1295
+ }));
1296
+ const noise = describeCaptured(output);
1297
+ return {
1298
+ content: [{ type: "text", text: noise ? `${outcome.report}\n\n${noise}` : outcome.report }],
1299
+ details: outcome.details ?? {},
1300
+ };
1301
+ }
1302
+ // The job's name stays short for the panel; which model it runs on goes to the agent.
1303
+ const summary = input.summary;
1304
+ const onModel = input.model ? ` on ${input.model}` : "";
1305
+ const job = jobs.start({
1306
+ kind: input.kind,
1307
+ summary,
1308
+ diff: input.diff,
1309
+ model: input.model,
1310
+ lanes: input.lanes,
1311
+ // Background work writes through the same terminal the TUI is drawing on, so its
1312
+ // console output is collected rather than printed.
1313
+ run: async (onDelta) => {
1314
+ const { result, output } = await captureConsole(() => input.run(onDelta));
1315
+ const noise = describeCaptured(output);
1316
+ return noise ? { ...result, report: `${result.report}\n\n${noise}` } : result;
1317
+ },
1318
+ });
1319
+ return {
1320
+ content: [
1321
+ {
1322
+ type: "text",
1323
+ text: (job.status === "queued"
1324
+ ? `Queued ${input.kind} as pulse ${job.id}: ${summary}${onModel}. It writes the same ` +
1325
+ `module as ${job.waitingOn.map((id) => `pulse ${id}`).join(" and ")}, so it starts when that ` +
1326
+ "finishes. Nothing to wait for: if what you are doing has a next step, take it now, in this same turn, and tell " +
1327
+ "the user it is queued and why alongside it, never as a reply that ends your turn. "
1328
+ : `Started ${input.kind} as pulse ${job.id}: ${summary}${onModel}.\n` +
1329
+ "It is running now and will report back when it finishes - nothing to wait for: if what you are doing has a " +
1330
+ "next step, take it now, in this same turn, and tell the user it has started alongside it, never as a reply " +
1331
+ "that ends your turn. ") +
1332
+ `Use pulses with "${job.id}" if they ask how it is going.`,
1333
+ },
1334
+ ],
1335
+ details: { jobId: job.id, status: job.status },
1336
+ };
1337
+ }
1338
+ /** Looking in on work that is still running, and collecting what finished. */
1339
+ /** By registry: what opens the side panel - for a skill's question that invites the person to peek. */
1340
+ const panelOpeners = new WeakMap();
1341
+ /** By registry: the last pulse that failed, stopped or did not pass, not yet looked at - ctrl+h opens on it. */
1342
+ const needsLooking = new WeakMap();
1343
+ function registerJobTools(pi, jobs, cwd) {
1344
+ pi.registerTool({
1345
+ name: "diagnose",
1346
+ label: "Diagnose a pulse",
1347
+ description: "Why a pulse did not land, from its whole transcript: how it ended and its diagnosis, the stages that went wrong, the " +
1348
+ "last failing test run with its output, the last review that sent the work back, and the tool calls that failed - " +
1349
+ "and where the whole log is.",
1350
+ promptSnippet: "diagnose: why a pulse did not land, from its transcript",
1351
+ promptGuidelines: ["When a pulse failed or did not land, call diagnose before you explain it or change the spec: go by what happened, not by the report alone."],
1352
+ parameters: Type.Object({ job_id: Type.String({ description: "The pulse's id" }) }),
1353
+ async execute(_id, params) {
1354
+ const job = jobs.get(params.job_id);
1355
+ if (job === undefined)
1356
+ return { content: [{ type: "text", text: `No pulse ${params.job_id} has run in this session.` }], details: { status: "unknown" } };
1357
+ return { content: [{ type: "text", text: whyFailed(job) }], details: { status: job.status } };
1358
+ },
1359
+ });
1360
+ pi.registerTool({
1361
+ name: "pulses",
1362
+ label: "Pulses",
1363
+ description: "What is regenerating: with no pulse, every pulse of this session - running, planned and finished - and what deploy started; " +
1364
+ "with a pulse's id, that pulse - whether it is still running, its latest output, and its report once it has finished.",
1365
+ promptSnippet: "pulses: every pulse, or one - what is regenerating and how it is going",
1366
+ parameters: Type.Object({
1367
+ job_id: Type.Optional(Type.String({ description: "The id returned when the pulse started. Omitted: every pulse" })),
1368
+ wait: Type.Optional(Type.Boolean({ description: "With a pulse: block until it finishes instead of reporting its current state" })),
1369
+ }),
1370
+ async execute(_id, params) {
1371
+ if (params.job_id === undefined) {
1372
+ return { content: [{ type: "text", text: listJobs(jobs) }], details: { status: "all" } };
1373
+ }
1374
+ const existing = jobs.get(params.job_id);
1375
+ const planned = shownJobs(jobs).find((job) => job.id === params.job_id && jobs.get(job.id) === undefined);
1376
+ if (planned !== undefined) {
1377
+ return {
1378
+ content: [{ type: "text", text: `${planned.id} · ${pulseName(planned)}: ${planned.note ?? "planned, not started"}${planned.waitingOn.length > 0 ? ` It runs after ${planned.waitingOn.join(", ")}.` : ""}` }],
1379
+ details: { status: "planned" },
1380
+ };
1381
+ }
1382
+ if (existing === undefined) {
1383
+ const known = shownJobs(jobs).map((job) => job.id);
1384
+ return {
1385
+ content: [
1386
+ {
1387
+ type: "text",
1388
+ text: `No pulse ${params.job_id}. ${known.length > 0 ? `Known pulses: ${known.join(", ")}.` : "No pulses have been started."}`,
1389
+ },
1390
+ ],
1391
+ details: { status: "unknown" },
1392
+ };
1393
+ }
1394
+ const job = params.wait === true ? await jobs.wait(params.job_id) : existing;
1395
+ return {
1396
+ content: [{ type: "text", text: describeJob(job) }],
1397
+ details: { status: job.status },
1398
+ };
1399
+ },
1400
+ });
1401
+ // The panel is compact and ctrl+o only lasts as long as the work does. This is the whole
1402
+ // list, finished jobs included, put in the transcript where it stays.
1403
+ pi.registerCommand("halt", {
1404
+ description: "Halt a queued or running pulse, or what activate started: /halt [pulse id | activated <name>]",
1405
+ handler: async (args, ctx) => {
1406
+ if (!ctx.hasUI)
1407
+ return;
1408
+ const live = jobs.list().filter((job) => job.status === "running" || job.status === "queued");
1409
+ const deployed = runningDeployed();
1410
+ let id = args.trim();
1411
+ if (id === "") {
1412
+ if (live.length === 0 && deployed.length === 0)
1413
+ return ctx.ui.notify("Nothing is regenerating or queued.", "info");
1414
+ const label = (job) => `${job.id} ${job.status.padEnd(8)} ${pulseName(job)}`;
1415
+ const labels = [...live.map(label), ...deployed.map((item) => deployedLabel(item.name, item.url ?? item.command))];
1416
+ const picked = await ctx.ui.select("Halt which?", labels);
1417
+ const job = live.find((candidate) => picked !== undefined && label(candidate) === picked);
1418
+ const server = deployed.find((item) => picked !== undefined && deployedLabel(item.name, item.url ?? item.command) === picked);
1419
+ if (server !== undefined)
1420
+ id = `activated ${server.name}`;
1421
+ else if (job !== undefined)
1422
+ id = job.id;
1423
+ else
1424
+ return;
1425
+ }
1426
+ const name = /^(?:activated|deploy)\s+(.+)$/.exec(id)?.[1]?.trim();
1427
+ if (name !== undefined) {
1428
+ const stopped = await stopDeployed({ name });
1429
+ return shownAfterCommand(pi, ctx, `/halt ${id}`, stopped.length > 0 ? `Halted ${stopped.join(", ")}.` : `Nothing activate started is running as ${name}.`, "info");
1430
+ }
1431
+ ctx.ui.notify(stopJob(jobs, id), "info");
1432
+ },
1433
+ });
1434
+ // Debug mode, for the render agents started from now on - see debug-log.ts.
1435
+ // A module's history: every render, regeneration and conformance run, as git commits.
1436
+ pi.registerCommand("lineage", {
1437
+ description: "Show a module's lineage - every generation of its code and tests, and what each was regenerated from: /lineage [module]",
1438
+ handler: async (args, ctx) => {
1439
+ if (!ctx.hasUI)
1440
+ return;
1441
+ const modules = modulesWithHistory(cwd);
1442
+ let name = args.trim();
1443
+ if (name === "") {
1444
+ if (modules.length === 0)
1445
+ return ctx.ui.notify("No module has any history yet.", "info");
1446
+ name = modules.length === 1 ? modules[0] : ((await ctx.ui.select("Lineage of which module?", modules)) ?? "");
1447
+ if (name === "")
1448
+ return;
1449
+ }
1450
+ pi.appendEntry(HISTORY_ENTRY, { module: name, text: historyLog(defaultBuildDir(name, cwd)) });
1451
+ },
1452
+ });
1453
+ pi.registerTool({
1454
+ name: "lineage",
1455
+ label: "Lineage",
1456
+ description: "Read a module's lineage: every generation of its code and tests - each regeneration, conformance run and spec change, " +
1457
+ "with what it was regenerated from - as git commits. Without a commit, the log; with one, that commit in full - its message and " +
1458
+ "the change it made.",
1459
+ promptSnippet: "a module's lineage: what was regenerated into it, and when",
1460
+ parameters: Type.Object({
1461
+ module: Type.String({ description: "The module's name" }),
1462
+ commit: Type.Optional(Type.String({ description: "A commit from the log, to see in full" })),
1463
+ }),
1464
+ async execute(_id, params) {
1465
+ const buildDir = defaultBuildDir(params.module, cwd);
1466
+ const text = params.commit ? historyShow(buildDir, params.commit) : historyLog(buildDir);
1467
+ return { content: [{ type: "text", text }], details: {} };
1468
+ },
1469
+ });
1470
+ // The side panel: the regeneration - and the pulses behind p - over the whole screen. Its key (PANEL_KEY) opens it; the
1471
+ // key again, or esc, closes it and goes back to the chat. While it shows, every key is its own.
1472
+ let panel;
1473
+ // What is open in the pulse list and the map, and where their cursors are: the conversation's, as left each time it opens.
1474
+ const jobsTree = createJobsTreeState();
1475
+ const mapState = createSpecMapState();
1476
+ // The map from the files - the .plain files asked for, else the project's - with the work loop's plan laid over them.
1477
+ const specMap = createSpecMapSource({
1478
+ files: () => {
1479
+ const asked = workLoops.get(jobs)?.loop.mapPlan().files ?? [];
1480
+ return asked.length > 0 ? asked : defaultMapFiles(cwd);
1481
+ },
1482
+ plan: () => workLoops.get(jobs)?.loop.mapPlan() ?? { files: [], codebases: [] },
1483
+ });
1484
+ /** Open the side panel - on the view named, or as it was left. */
1485
+ const openPanel = (ctx, view) => {
1486
+ // Found: ctrl+h under the job panel stops breathing.
1487
+ keyPressed("peek");
1488
+ if (view !== undefined)
1489
+ mapState.view = view;
1490
+ // A pulse that failed or stopped since the panel was last opened: opened as it is, it is on that pulse - what went
1491
+ // wrong is what the person opens it to see.
1492
+ else {
1493
+ const id = needsLooking.get(jobs);
1494
+ if (id !== undefined && selectJob(shownJobs(jobs), jobsTree, id))
1495
+ mapState.view = "pulses";
1496
+ }
1497
+ needsLooking.delete(jobs);
1498
+ if (panel !== undefined) {
1499
+ panel.handle?.setHidden(false);
1500
+ panel.handle?.focus();
1501
+ return;
1502
+ }
1503
+ // Not awaited: the panel lives on while the jobs it shows run.
1504
+ void ctx.ui.custom((tui, theme, _keybindings, done) => {
1505
+ const close = () => done(undefined);
1506
+ let component;
1507
+ // Opened, it reads the files again at once: they may have changed while it was closed.
1508
+ specMap.refresh();
1509
+ const view = createSidePanel({
1510
+ jobs: () => shownJobs(jobs),
1511
+ tree: jobsTree,
1512
+ map: () => specMap.read(),
1513
+ mapState,
1514
+ rows: () => tui.terminal.rows,
1515
+ key: PANEL_KEY,
1516
+ style: {
1517
+ heading: (text) => theme.bold(theme.fg("accent", text)),
1518
+ muted: (text) => theme.fg("muted", text),
1519
+ accent: (text) => theme.fg("accent", text),
1520
+ error: (text) => theme.fg("error", text),
1521
+ success: (text) => theme.fg("success", text),
1522
+ warning: (text) => theme.fg("warning", text),
1523
+ added: (text) => theme.fg("toolDiffAdded", text),
1524
+ removed: (text) => theme.fg("toolDiffRemoved", text),
1525
+ // A spec diff's ***plain highlighted as plyn highlights it, and its added and removed lines filled.
1526
+ diff: plainPaint(theme),
1527
+ // What conformance tests show does what its spec says: brand blue.
1528
+ conforms: brandBlue(theme),
1529
+ },
1530
+ back: close,
1531
+ close,
1532
+ // A module that needs the person: the chat takes it up with them, in their words - what went wrong, and what now.
1533
+ discuss: (page) => {
1534
+ close();
1535
+ const why = page.codebase?.stuck?.why;
1536
+ pi.sendUserMessage(`regen says ${page.module} needs me: ${page.status}. What went wrong, and what do we do about it?${why !== undefined ? `\n\n(turri's reason: ${why})` : ""}`, { deliverAs: "followUp" });
1537
+ },
1538
+ // Something took the keys' focus while the panel showed - a question in the chat - and waits there.
1539
+ waiting: () => {
1540
+ const focused = tui.getFocusedComponent?.() ?? null;
1541
+ return focused !== null && focused !== component;
1542
+ },
1543
+ });
1544
+ const entry = { view, close };
1545
+ panel = entry;
1546
+ // Every key goes to the panel while it shows, whatever has the focus: a question the chat model asks in the
1547
+ // background takes the focus, but no key reaches it - an enter never answers it unseen. It waits in the chat
1548
+ // until the panel closes, and has the keys then. Mouse events are the screen's, before this sees them.
1549
+ // Only while it is on the screen: up in the TUI's overlays and not hidden. An overlay taken off without the panel
1550
+ // being disposed - another one closed over it - leaves no panel to take keys for: it lets them go, and goes.
1551
+ const shown = () => {
1552
+ const overlays = tui.overlayStack;
1553
+ if (entry.handle === undefined || component === undefined)
1554
+ return false;
1555
+ if (overlays !== undefined && !overlays.some((overlay) => overlay.component === component))
1556
+ return "gone";
1557
+ return entry.handle.isHidden() !== true;
1558
+ };
1559
+ const unlisten = tui.addInputListener((data) => {
1560
+ const state = shown();
1561
+ if (state === "gone") {
1562
+ component?.dispose?.();
1563
+ return undefined;
1564
+ }
1565
+ if (!state)
1566
+ return undefined;
1567
+ // A terminal on the Kitty keyboard protocol reports a key's release, and listeners are given it: the release of
1568
+ // the ctrl+h that opened the panel would close it again at once, and every arrow would move twice. Only
1569
+ // presses count - and ctrl+h held down, repeating, does not open and close it over and over.
1570
+ if (isKeyRelease(data) || (isKeyRepeat(data) && matchesKey(data, PANEL_KEY)))
1571
+ return { consume: true };
1572
+ // ctrl+c closes it too: whatever else goes wrong, a key always leads back to the chat.
1573
+ if (matchesKey(data, PANEL_KEY) || matchesKey(data, "ctrl+c"))
1574
+ close();
1575
+ else {
1576
+ try {
1577
+ view.handleInput(data);
1578
+ }
1579
+ catch {
1580
+ /* a key the panel fails on is dropped, never the panel's hold on the keys */
1581
+ }
1582
+ }
1583
+ tui.requestRender();
1584
+ return { consume: true };
1585
+ });
1586
+ // Jobs keep writing; redraw so the panel follows them while it shows.
1587
+ const timer = setInterval(() => {
1588
+ if (entry.handle?.isHidden() !== true)
1589
+ tui.requestRender();
1590
+ }, WATCH_REDRAW_MS);
1591
+ timer.unref?.();
1592
+ component = {
1593
+ render: (width) => view.render(width),
1594
+ // Reached only if the listener above is gone: the same, so the panel never passes a key on.
1595
+ handleInput: (data) => {
1596
+ if (isKeyRepeat(data) && matchesKey(data, PANEL_KEY))
1597
+ return;
1598
+ if (matchesKey(data, PANEL_KEY))
1599
+ close();
1600
+ else
1601
+ view.handleInput(data);
1602
+ tui.requestRender();
1603
+ },
1604
+ handleMouse: (event) => view.handleMouse(event),
1605
+ invalidate: () => view.invalidate(),
1606
+ dispose: () => {
1607
+ unlisten();
1608
+ clearInterval(timer);
1609
+ if (panel === entry)
1610
+ panel = undefined;
1611
+ },
1612
+ };
1613
+ return component;
1614
+ }, {
1615
+ overlay: true,
1616
+ // The whole screen: the panel or the chat, one at a time.
1617
+ overlayOptions: { width: "100%", maxHeight: "100%", anchor: "top-left", margin: 0 },
1618
+ onHandle: (handle) => {
1619
+ if (panel !== undefined)
1620
+ panel.handle = handle;
1621
+ },
1622
+ });
1623
+ };
1624
+ // The side panel's two views: /regen opens it on regen - the spec, with how far the code has been regenerated into it
1625
+ // - and /pulses on every pulse, regenerating, planned and finished, and what each regenerates. What the agents did is
1626
+ // in each pulse's log.
1627
+ for (const [name, description, view] of [
1628
+ ["regen", `regen, in the side panel: every item of the spec, built, regenerating, planned or waiting. ${PANEL_KEY} opens the panel too.`, "map"],
1629
+ ["pulses", `The pulses, in the side panel: every pulse, regenerating, planned and finished, and what each regenerates. ${PANEL_KEY} opens the panel too.`, "pulses"],
1630
+ ]) {
1631
+ pi.registerCommand(name, {
1632
+ description,
1633
+ handler: async (_args, ctx) => {
1634
+ if (!ctx.hasUI)
1635
+ return;
1636
+ openPanel(ctx, view);
1637
+ },
1638
+ });
1639
+ }
1640
+ panelOpeners.set(jobs, (ctx) => openPanel(ctx));
1641
+ questionPeek = {
1642
+ open: (ctx) => openPanel(ctx),
1643
+ shown: () => panel !== undefined,
1644
+ };
1645
+ pi.registerShortcut(PANEL_KEY, {
1646
+ description: "Peek at regen and the pulses - a question waiting in the chat waits for you - or go back to it",
1647
+ handler: async (ctx) => {
1648
+ if (!ctx.hasUI)
1649
+ return;
1650
+ openPanel(ctx);
1651
+ },
1652
+ });
1653
+ // /turri-config: every setting of config.turri.yaml from the chat, each taking effect as it says - what turri fixed at its
1654
+ // start changed here, as it runs.
1655
+ registerTurriConfig(pi, {
1656
+ cwd,
1657
+ rules: () => loadRules(join(PACKAGE_ROOT, "genome", "rules")).map((rule) => rule.name),
1658
+ chatSpec: () => modelSpec(projectModels(readProjectConfig(cwd)).harness),
1659
+ live: {
1660
+ chatModel: async (spec, ctx) => {
1661
+ if (spec === undefined)
1662
+ return "The conversation keeps the model it runs on now; turri starts on pi's own choice next time.";
1663
+ const { model, thinking } = splitModelSpec(spec);
1664
+ if (model !== undefined) {
1665
+ const resolved = resolveModel(model, ctx.modelRegistry);
1666
+ if (!resolved.ok)
1667
+ return `The conversation keeps its model: ${model} cannot be used here.\n${describeResolution(resolved)}`;
1668
+ const slash = resolved.model.indexOf("/");
1669
+ const found = ctx.modelRegistry.find(resolved.model.slice(0, slash), resolved.model.slice(slash + 1));
1670
+ if (found === undefined || !(await pi.setModel(found)))
1671
+ return `The conversation keeps its model: ${resolved.model} could not be switched to.`;
1672
+ }
1673
+ if (thinking !== undefined)
1674
+ pi.setThinkingLevel(thinking);
1675
+ return `The conversation runs on ${spec} now.`;
1676
+ },
1677
+ codingModel: () => {
1678
+ const dropped = [];
1679
+ // What the session chose wins over the file; changed in the file, the file's holds from now on.
1680
+ if (process.env[CODING_MODEL_ENV] !== undefined) {
1681
+ dropped.push(`--coding-model ${process.env[CODING_MODEL_ENV]}`);
1682
+ delete process.env[CODING_MODEL_ENV];
1683
+ }
1684
+ for (const work of Object.keys(chosenModels)) {
1685
+ dropped.push(`the engine's ${work === "all" ? "" : `${work.replace(/_/g, " ")} `}model ${chosenModels[work]}`);
1686
+ delete chosenModels[work];
1687
+ }
1688
+ return dropped.length === 0 ? undefined : `Chosen this session, and given way to the config: ${dropped.join("; ")}.`;
1689
+ },
1690
+ parallelJobs: (count) => {
1691
+ const limit = count ?? parallelJobs(cwd);
1692
+ jobs.setMaxRunning(limit);
1693
+ return `Up to ${limit} pulse${limit === 1 ? "" : "s"} run at once now.`;
1694
+ },
1695
+ },
1696
+ });
1697
+ }
1698
+ export { fromWorkingDir };
1699
+ const WATCH_REDRAW_MS = 500;
1700
+ /**
1701
+ * What the agent is to do with a job that just ended, unasked: what the project's guidance says to do then, or else say
1702
+ * in a line or two what ended - the person saw it end already, in the transcript.
1703
+ */
1704
+ export const JOB_FINISHED_REACTION = [
1705
+ "React to this now - the person has not asked, and need not. If guidance for this project - its AGENTS.md, a skill,",
1706
+ "what the person said earlier - says what to do when a pulse finishes (release what was built, run something, tell",
1707
+ "someone), do that, as it says. Otherwise say in a line or two what finished and what it means for the person - the",
1708
+ "report is theirs to read in full; do not repeat it - and, when it failed, what they can do. Start no other work.",
1709
+ "A report that says \"Released after the pulse\" was released by turri itself, as the pulse's last stage: do not call",
1710
+ "release for it - say in a line what was released, or why nothing was.",
1711
+ "A pulse that stopped on its environment - \"stopped: turri's sandbox kept its agents from…\" or \"stopped: the",
1712
+ "environment lacks…\" - stops the same way again until that is fixed: never regenerate it before. Heal it, then regenerate the",
1713
+ "module to resume:",
1714
+ "- turri's sandbox kept the agents from a folder on this machine: nothing is missing. Add the folder its report names to",
1715
+ " `toolchains:` in config.turri.yaml - a list, keeping what is there - say so in a line, and resume.",
1716
+ "- the machine lacks a toolchain or a tool - a JDK, Maven, Gradle, Go, uv, Node, a system library: first find out, in your",
1717
+ " shell, whether it is installed somewhere off the PATH (`command -v`, Homebrew's opt folders, /Library/Java/",
1718
+ " JavaVirtualMachines, a version manager the person uses - mise, asdf, sdkman, nvm). Then ask the person with ask_user",
1719
+ " to let you put it in place the way this machine installs things - the exact command, `brew install openjdk@21`,",
1720
+ " `mise use -g java@21`, `sdk install maven` - and on yes run it in your shell, check that the command is found now as",
1721
+ " the testing script would find it (`command -v`, `turri doctor`), and resume. A tool installed where the PATH turri",
1722
+ " was started with does not reach needs turri restarted: say so, with the line for the shell profile. On no, or when it",
1723
+ " needs what you cannot do - sudo with a password, a licence, a download behind a login - tell the person exactly what",
1724
+ " to do, and that \"resume\" picks it up once it is in.",
1725
+ "- a credential `agent-env:` names that is not there: never make one up - tell the person where it is read from.",
1726
+ ].join("\n");
1727
+ /** Opens the side panel, and goes back to it from the conversation. */
1728
+ /** The modules in this project that have a history, by name. */
1729
+ function modulesWithHistory(cwd) {
1730
+ const root = join(cwd, "plain_modules");
1731
+ try {
1732
+ return readdirSync(root).filter((name) => existsSync(join(root, name, HISTORY_DIR)));
1733
+ }
1734
+ catch {
1735
+ return [];
1736
+ }
1737
+ }
1738
+ /** Stop a job by id or id prefix, and say what that means for the queue. */
1739
+ /** Every job, in the order it was started, with what a queued one is waiting for. */
1740
+ /** What deploy started, as /halt offers it. */
1741
+ function deployedLabel(name, what) {
1742
+ return `deploy ${name} running ${what}`;
1743
+ }
1744
+ /** The jobs as the person is shown them: those started, and those the work loop has planned after them. */
1745
+ function shownJobs(jobs) {
1746
+ return [...(workLoops.get(jobs)?.loop.planned() ?? []), ...jobs.list()];
1747
+ }
1748
+ function listJobs(jobs) {
1749
+ const all = [...shownJobs(jobs)].reverse().filter((job) => job.replacedBy === undefined);
1750
+ const deployed = runningDeployed().map((item) => `deploy ${item.name} running ${item.moduleName}: ${item.url ?? item.command} (pid ${item.pid}, output in ${relative(process.cwd(), item.log) || item.log})`);
1751
+ const listed = deployed.length > 0 ? `\n\nActivated from dist - /halt activated <name> halts one:\n${deployed.join("\n")}` : "";
1752
+ if (all.length === 0)
1753
+ return `No pulses have been started.${listed}`;
1754
+ return `${jobLines(all)}${listed}`;
1755
+ }
1756
+ function jobLines(all) {
1757
+ return all
1758
+ .map((job) => {
1759
+ // Planned by the work loop, not started: it has no job of the registry's yet.
1760
+ const planned = job.id.startsWith("planned-");
1761
+ const time = job.status === "queued" ? "waiting" : formatDuration(job);
1762
+ const after = job.status === "queued" && job.waitingOn.length > 0 ? ` (after ${job.waitingOn.join(", ")})` : "";
1763
+ const spent = describeUsage(job.usage);
1764
+ const model = job.model ? ` · ${job.model}` : "";
1765
+ return `${job.id} ${(planned ? "planned" : job.status).padEnd(9)} ${time.padStart(7)} ${pulseName(job)}${model}${after}${spent ? ` · ${spent}` : ""}`;
1766
+ })
1767
+ .join("\n");
1768
+ }
1769
+ /**
1770
+ * Static validation of a ***plain specification.
1771
+ *
1772
+ * This is the check that used to mean a renderer dry-run. It runs this repo's own parser,
1773
+ * so it needs no external tool, no API key and no credits - and it is the only honest way
1774
+ * to answer "is this spec valid?" without generating anything.
1775
+ */
1776
+ function registerCheckPlain(pi, cwd) {
1777
+ pi.registerTool(checkPlainTool(cwd));
1778
+ pi.registerTool(chatAskUserTool());
1779
+ // The web, for the conversation too - a library's documentation, an API, an error; curl is its
1780
+ // bash. Run here, like the rendering agents' (see render/web.ts).
1781
+ for (const tool of createWebTools())
1782
+ pi.registerTool(tool);
1783
+ }
1784
+ /**
1785
+ * Steering the queue from the conversation: how the person wants the work cut into jobs and ordered, for the whole
1786
+ * project or one module, kept for the session and given to the planner with every plan - beside
1787
+ * what the project's AGENTS.md says of the queue (see render/steering.ts).
1788
+ */
1789
+ function registerSteering(pi, cwd) {
1790
+ pi.registerTool({
1791
+ name: "steer",
1792
+ label: "Steer",
1793
+ description: "Say how the work is to be planned from now on - which functional specs are regenerated together, what goes first, how " +
1794
+ "big a pulse may be - for the whole project, or one module: every plan after this follows it as a strong preference, " +
1795
+ "within the planner's limits, for the rest of the session. It changes nothing queued by itself: the next plan of the " +
1796
+ "module follows it - regenerate makes one now. What the project's AGENTS.md says under a heading " +
1797
+ "naming the queue is followed too, with every plan.",
1798
+ promptSnippet: "steer how the work is planned, for the project or one module",
1799
+ promptGuidelines: [
1800
+ "When the user says how the work should be cut or ordered - 'build the batch worker's helpers with the worker', 'models first' - for more than one change, call steer with it in their words. For one change only, pass it as the guidance of regenerate.",
1801
+ "After steering, call regenerate to plan again. Where its reply lists what the plan did not do as asked, tell the user that and the reasons it gives - never a reason of your own.",
1802
+ ],
1803
+ parameters: Type.Object({
1804
+ action: Type.Union([Type.Literal("add"), Type.Literal("set"), Type.Literal("clear"), Type.Literal("show")], {
1805
+ description: "add: one more wish; set: replace them all; clear: drop them; show: say what they are",
1806
+ }),
1807
+ text: Type.Optional(Type.String({ description: "The wish, in the user's words - for add and set" })),
1808
+ module: Type.Optional(Type.String({ description: "A module's .plain file or name. Omit: every module of the project" })),
1809
+ }),
1810
+ async execute(_id, params) {
1811
+ const scope = params.module !== undefined ? moduleNameOf(params.module) : EVERY_MODULE;
1812
+ const where = scope === EVERY_MODULE ? "every module" : scope;
1813
+ const text = params.text?.trim() ?? "";
1814
+ if ((params.action === "add" || params.action === "set") && text === "")
1815
+ throw new Error(`Say what to ${params.action}: text is empty.`);
1816
+ if (params.action === "add")
1817
+ steeringStore.set(scope, [...steeringStore.get(scope), text]);
1818
+ if (params.action === "set")
1819
+ steeringStore.set(scope, [text]);
1820
+ if (params.action === "clear")
1821
+ steeringStore.set(scope, []);
1822
+ const now = steeringStore.scopes().flatMap((key) => steeringStore.get(key).map((wish) => `- ${key === EVERY_MODULE ? "every module" : key}: ${wish}`));
1823
+ const agents = existsSync(join(cwd, "AGENTS.md")) ? queueSectionOf(readFileSync(join(cwd, "AGENTS.md"), "utf8")) : "";
1824
+ return {
1825
+ content: [
1826
+ {
1827
+ type: "text",
1828
+ text: [
1829
+ params.action === "show" ? "How the work is planned:" : `${params.action === "clear" ? "Cleared" : "Noted"} for ${where}. How the work is planned now:`,
1830
+ ...(now.length > 0 ? now : ["- (nothing from the conversation)"]),
1831
+ ...(agents !== "" ? ["", "And from AGENTS.md, every plan:", agents] : []),
1832
+ "",
1833
+ "The next plan of each module follows it; nothing planned changes until then.",
1834
+ ].join("\n"),
1835
+ },
1836
+ ],
1837
+ details: { scope, steering: now },
1838
+ };
1839
+ },
1840
+ });
1841
+ }
1842
+ /**
1843
+ * Deploy: the conversation puts what is built out in dist/<module> and starts it on this machine (see
1844
+ * regenerate/release.ts), installing what it needs in its own shell - nothing is put to the person. What it
1845
+ * started is stopped when the session ends.
1846
+ */
1847
+ const SIDE_ENTRY = "turri_btw";
1848
+ /**
1849
+ * /btw, a question on the side (btw.ts): answered at once, even while a skill runs - pi runs a command mid-turn - and
1850
+ * shown in the transcript, never in a model's context.
1851
+ */
1852
+ function registerSideQuestions(pi, cwd, jobs) {
1853
+ pi.registerCommand("btw", {
1854
+ description: "Ask turri something on the side - answered now, even mid-skill, and left out of the conversation: /btw <question>",
1855
+ handler: async (args, ctx) => {
1856
+ const question = args.trim();
1857
+ if (question === "")
1858
+ return ctx.ui.notify(BTW_HOW, "info");
1859
+ ctx.ui.notify(`Looking into it: ${question}`, "info");
1860
+ // Not awaited: the editor is free while it is answered, for another /btw or the conversation.
1861
+ void askOnTheSide(pi, ctx, cwd, jobs, question).catch((error) => ctx.ui.notify(`The side question could not be answered: ${error instanceof Error ? error.message : String(error)}`, "error"));
1862
+ },
1863
+ });
1864
+ pi.registerEntryRenderer(COMMAND_OUTCOME, (entry, _options, theme) => {
1865
+ const data = entry.data;
1866
+ if (data === undefined)
1867
+ return undefined;
1868
+ const box = new Box(1, 0);
1869
+ // The command as typed; what it said, under it - or, said without it above, with it.
1870
+ const said = data.text !== undefined ? theme.fg(data.level === undefined || data.level === "info" ? "text" : data.level, data.text) : undefined;
1871
+ const lines = data.text === undefined ? [theme.bold(theme.fg("accent", data.command))] : data.echoed === true ? [said] : [theme.fg("muted", data.command), said];
1872
+ box.addChild(new Text(lines.join("\n"), 0, 0));
1873
+ return box;
1874
+ });
1875
+ pi.registerEntryRenderer(SIDE_ENTRY, (entry, _options, theme) => {
1876
+ const data = entry.data;
1877
+ if (data === undefined)
1878
+ return undefined;
1879
+ const box = new Box(1, 0);
1880
+ box.addChild(new Text([theme.fg("muted", `btw: ${data.question}`), theme.fg("text", data.answer)].join("\n"), 0, 0));
1881
+ return box;
1882
+ });
1883
+ }
1884
+ /** A side question answered - from the conversation, the skill running, the pulses - and kept in the transcript. */
1885
+ async function askOnTheSide(pi, ctx, cwd, jobs, question) {
1886
+ let entries = [];
1887
+ try {
1888
+ entries = ctx.sessionManager.getEntries();
1889
+ }
1890
+ catch {
1891
+ // Without the conversation it is still answered, from the skill and the project.
1892
+ }
1893
+ const skill = runningSkill();
1894
+ const answer = await answerSideQuestion({
1895
+ question,
1896
+ conversation: transcriptOf(entries, 24_000),
1897
+ ...(skill !== undefined ? { skill: { name: skill.skill, dir: skill.dir, step: skill.step(), trace: skill.trace(), session: skillSessionText(skill.runDir) } } : {}),
1898
+ pulses: jobs.list().map((job) => `${job.id} ${job.status}: ${pulseName(job)}`),
1899
+ projectDir: cwd,
1900
+ harnessDir: PACKAGE_ROOT,
1901
+ }, chatModelOf(ctx));
1902
+ try {
1903
+ pi.appendEntry(SIDE_ENTRY, { question, answer });
1904
+ }
1905
+ catch {
1906
+ // Shown where it was asked all the same.
1907
+ }
1908
+ return answer;
1909
+ }
1910
+ function registerDeploy(pi, cwd, jobs, state) {
1911
+ const tools = createDeployTools({
1912
+ cwd,
1913
+ state,
1914
+ jobs,
1915
+ defaultPlainFile: () => readProjectConfig(cwd).module,
1916
+ });
1917
+ for (const tool of tools)
1918
+ pi.registerTool(tool);
1919
+ pi.on("session_shutdown", async () => {
1920
+ await stopDeployed();
1921
+ });
1922
+ }
1923
+ /** validate, for the conversation and for the skills that write specs alike. */
1924
+ function checkPlainTool(cwd) {
1925
+ return defineTool({
1926
+ name: "validate",
1927
+ label: "Validate spec",
1928
+ description: "Validate one or more .plain specifications without rendering: parses each file, resolves its " +
1929
+ "imports and required modules, and reports every syntax, concept and structure error with the " +
1930
+ "line it is on. Pass a directory to check every .plain file in it. Fast, free, and offline.",
1931
+ promptSnippet: "validate a .plain spec, regenerating nothing",
1932
+ promptGuidelines: [
1933
+ "Run validate after editing a .plain file, and before telling the user a spec is valid or ready. Never shell out to another renderer to check one.",
1934
+ ],
1935
+ parameters: Type.Object({
1936
+ target: Type.String({ description: "A .plain file, or a directory holding .plain files" }),
1937
+ template_dir: Type.Optional(Type.Array(Type.String(), { description: "Extra template directories" })),
1938
+ }),
1939
+ async execute(_id, params) {
1940
+ const results = checkPlainTargets(resolve(cwd, params.target), (params.template_dir ?? []).map((dir) => resolve(cwd, dir)));
1941
+ const failed = results.filter((result) => !result.ok).length;
1942
+ const text = results.length === 0
1943
+ ? `No .plain files found in ${params.target}.`
1944
+ : `${formatCheckResults(results)}\n\n${failed === 0 ? "All specs valid." : `${failed} of ${results.length} failed.`}`;
1945
+ return { content: [{ type: "text", text }], details: { failed, checked: results.length } };
1946
+ },
1947
+ });
1948
+ }
1949
+ const MILESTONE_ENTRY = "turri_milestone";
1950
+ const JOB_ENTRY = "turri_job";
1951
+ const JOBS_LIST_ENTRY = "turri_jobs";
1952
+ const HISTORY_ENTRY = "turri_history";
1953
+ function announceJob(pi, job) {
1954
+ if (job.status !== "succeeded" && job.status !== "failed" && job.status !== "stopped")
1955
+ return;
1956
+ const announcement = {
1957
+ id: job.id,
1958
+ // Its module, kind and number - never what it regenerates, which is the agents' words and long.
1959
+ summary: pulseName(job),
1960
+ status: job.status,
1961
+ incomplete: job.status === "succeeded" && job.details?.passed === false,
1962
+ duration: formatDuration(job),
1963
+ spent: describeUsage(job.usage) || undefined,
1964
+ text: job.specConflict !== undefined
1965
+ ? describeSpecConflict(job.specConflict)
1966
+ : job.status === "succeeded"
1967
+ ? (job.announce ?? job.report ?? "")
1968
+ : job.status === "stopped"
1969
+ ? (job.note ?? "Stopped on request. Whatever it changed before it stopped is still on disk; the functionality it was on counts as not built.")
1970
+ : (job.error ?? "no error recorded"),
1971
+ };
1972
+ try {
1973
+ pi.appendEntry(JOB_ENTRY, announcement);
1974
+ }
1975
+ catch {
1976
+ // The agent still gets the report; losing the entry must not lose the result.
1977
+ }
1978
+ }
1979
+ /**
1980
+ * What the conversation's agent is asked to do about a spec that contradicts itself: confirm it
1981
+ * with analyze-2-func-specs, work out the ways out with resolve-spec-conflict, and put the choice
1982
+ * to the person - who decides. Nothing in the spec is changed before they have.
1983
+ */
1984
+ function specConflictTask(conflict) {
1985
+ const [one, two] = conflict.specs;
1986
+ return [
1987
+ describeSpecConflict(conflict),
1988
+ "",
1989
+ `The render stopped on it: the conformance run's judge found them conflicting, ${conflict.reasons.length} times -`,
1990
+ `functional spec ${one.frid} of ${one.module} (${one.plainFile}) and functional spec ${two.frid} of ${two.module} (${two.plainFile}). Now:`,
1991
+ "1. Run the analyze-2-func-specs skill (cultivate) on these two functional specs, to confirm they conflict.",
1992
+ "2. If they do, run the resolve-spec-conflict skill on the pair, to work out what could be changed - but change nothing yet.",
1993
+ "3. Tell the user exactly what the conflict is - both specs as they read, and the behaviour that cannot satisfy both -",
1994
+ " and put the decision to them with ask_user: the ways out resolve-spec-conflict found, and Something else. Suggest they",
1995
+ " simply decide which is meant; it is their call, not yours.",
1996
+ "4. Only once they have decided, make the change they chose to the .plain file it belongs in - either module's -",
1997
+ " and regenerate it - its conformance tests follow it.",
1998
+ "If analyze-2-func-specs finds the two compatible, tell the user so, and that the conformance tests of these two are what",
1999
+ "kept failing - offer to conform them again.",
2000
+ ].join("\n");
2001
+ }
2002
+ const INTERVIEW_ENTRY = "turri_exchange";
2003
+ /**
2004
+ * A skill's question as the transcript keeps it: who asked, what it said before asking - a brief to confirm, the choices
2005
+ * it weighed, shown in the dialog that is gone once answered - the question, and the answer.
2006
+ */
2007
+ export function exchangeLines(data, fg) {
2008
+ const said = data.said !== undefined ? data.said.split("\n").map((line) => fg("text", line)) : [];
2009
+ const answer = data.answer.trim() === "" ? fg("dim", "> (no answer)") : fg("accent", `> ${data.answer}`);
2010
+ return [fg("muted", `${data.skill} asked`), ...said, ...(said.length > 0 ? [""] : []), fg("text", data.question), answer];
2011
+ }
2012
+ /**
2013
+ * Show each question and answer in the transcript, where the person can see it.
2014
+ *
2015
+ * A skill runs inside one tool call and asks through dialogs, so its interview leaves no
2016
+ * trace in the conversation - the questions vanish once answered. These are custom
2017
+ * *entries* rather than messages on purpose: they are durable and rendered, but they do
2018
+ * not enter the model's context, so a long interview is readable without being paid for
2019
+ * twice.
2020
+ */
2021
+ function registerInterviewLog(pi) {
2022
+ pi.registerEntryRenderer(JOB_ENTRY, (entry, _options, theme) => {
2023
+ const data = entry.data;
2024
+ if (data === undefined)
2025
+ return undefined;
2026
+ // A job that ran to its end but whose build did not pass is not a success: it says so.
2027
+ const header = data.status === "succeeded" && data.incomplete === true
2028
+ ? `${theme.fg("error", "✗")} ${theme.fg("text", data.summary)} ${theme.fg("error", `did not pass - stopped after ${data.duration}`)}`
2029
+ : data.status === "succeeded"
2030
+ ? `${theme.fg("success", "✓")} ${theme.fg("text", data.summary)} ${theme.fg("muted", `finished after ${data.duration}`)}`
2031
+ : data.status === "stopped"
2032
+ ? `${theme.fg("warning", "■")} ${theme.fg("text", data.summary)} ${theme.fg("warning", `stopped after ${data.duration}`)}`
2033
+ : `${theme.fg("error", "✗")} ${theme.fg("text", data.summary)} ${theme.fg("error", `failed after ${data.duration}`)}`;
2034
+ const spent = data.spent ? ` ${theme.fg("muted", `· ${data.spent}`)}` : "";
2035
+ // Its headline only: the chat has the whole report, and says what it means - or does what the project says to. One
2036
+ // that went wrong says where to see why: the live panel, which said it, is gone with the last pulse.
2037
+ const wrong = data.status !== "succeeded" || data.incomplete === true;
2038
+ const box = new Box(1, 0);
2039
+ box.addChild(new Text(header + spent + (wrong ? `\n${theme.fg("muted", ` ${PANEL_KEY} shows this pulse - its stages, what its agents did, its log`)}` : ""), 0, 0));
2040
+ return box;
2041
+ });
2042
+ pi.registerEntryRenderer(HISTORY_ENTRY, (entry, _options, theme) => {
2043
+ const data = entry.data;
2044
+ if (data === undefined)
2045
+ return undefined;
2046
+ const box = new Box(1, 0);
2047
+ box.addChild(new Text([theme.fg("muted", `history of ${data.module}`), theme.fg("text", data.text)].join("\n"), 0, 0));
2048
+ return box;
2049
+ });
2050
+ pi.registerEntryRenderer(JOBS_LIST_ENTRY, (entry, _options, theme) => {
2051
+ const data = entry.data;
2052
+ if (data === undefined)
2053
+ return undefined;
2054
+ const box = new Box(1, 0);
2055
+ box.addChild(new Text([theme.fg("muted", "pulses"), theme.fg("text", data.text)].join("\n"), 0, 0));
2056
+ return box;
2057
+ });
2058
+ pi.registerEntryRenderer(MILESTONE_ENTRY, (entry, _options, theme) => {
2059
+ const data = entry.data;
2060
+ if (data === undefined)
2061
+ return undefined;
2062
+ const box = new Box(1, 0);
2063
+ box.addChild(new Text(`${theme.fg("success", "●")} ${theme.fg("muted", data.module)} ${theme.fg("text", data.text)}`, 0, 0));
2064
+ return box;
2065
+ });
2066
+ pi.registerEntryRenderer(INTERVIEW_ENTRY, (entry, _options, theme) => {
2067
+ const data = entry.data;
2068
+ if (data === undefined)
2069
+ return undefined;
2070
+ // Two details of the TUI components matter here. Box's third argument is a
2071
+ // *background* function - passing a foreground colour into it tints every line, which
2072
+ // is what made these unreadable. And Text pads by 1 on both axes unless told
2073
+ // otherwise, so one Text per line puts a blank line between each. Hence a single Text
2074
+ // with explicit zero padding, coloured per line.
2075
+ const box = new Box(1, 0);
2076
+ box.addChild(new Text(exchangeLines(data, (color, text) => theme.fg(color, text)).join("\n"), 0, 0));
2077
+ return box;
2078
+ });
2079
+ }
2080
+ function recordExchange(pi, exchange) {
2081
+ try {
2082
+ pi.appendEntry(INTERVIEW_ENTRY, exchange);
2083
+ }
2084
+ catch {
2085
+ // The trace still carries it; a rendering failure must not lose the answer.
2086
+ }
2087
+ }
2088
+ /**
2089
+ * What the skill should be told about the conversation that started it.
2090
+ *
2091
+ * The agent's own summary comes first, because it knows which parts matter. The user's
2092
+ * recent messages are appended verbatim underneath: a summary drops the particulars - a
2093
+ * product name, a phrasing the user chose - and those are exactly what makes a skill's
2094
+ * first question sound like it was listening.
2095
+ */
2096
+ function conversationContext(ctx, summary) {
2097
+ const spoken = recentUserMessages(ctx, 12);
2098
+ const parts = [];
2099
+ if (summary !== undefined && summary.trim() !== "")
2100
+ parts.push(summary.trim());
2101
+ if (spoken.length > 0) {
2102
+ parts.push(`In their own words:\n${spoken.map((line) => `- ${line}`).join("\n")}`);
2103
+ }
2104
+ return parts.length > 0 ? parts.join("\n\n") : undefined;
2105
+ }
2106
+ function recentUserMessages(ctx, limit) {
2107
+ let entries;
2108
+ try {
2109
+ entries = ctx.sessionManager.getEntries();
2110
+ }
2111
+ catch {
2112
+ return [];
2113
+ }
2114
+ const said = [];
2115
+ for (const entry of entries) {
2116
+ const message = entry.message;
2117
+ if (entry.type !== "message" || message?.role !== "user")
2118
+ continue;
2119
+ const text = typeof message.content === "string"
2120
+ ? message.content
2121
+ : Array.isArray(message.content)
2122
+ ? message.content
2123
+ .filter((part) => part.type === "text")
2124
+ .map((part) => part.text ?? "")
2125
+ .join(" ")
2126
+ : "";
2127
+ const trimmed = text.trim().replace(/\s+/g, " ");
2128
+ if (trimmed !== "")
2129
+ said.push(trimmed.length > 400 ? `${trimmed.slice(0, 400)}…` : trimmed);
2130
+ }
2131
+ return said.slice(-limit);
2132
+ }
2133
+ const BANNER_ENTRY = "turri_banner";
2134
+ /**
2135
+ * The name on the door.
2136
+ *
2137
+ * Pi's own startup header is a built-in component an extension cannot replace, so this
2138
+ * adds a wordmark of its own at the top of the session. `turri` starts pi without
2139
+ * its header (see pi-launch.ts), leaving only this one.
2140
+ */
2141
+ /**
2142
+ * The ***plain rules as the conversation has them in its system prompt, every turn - the standard library's
2143
+ * inventory among them, in its rule, so the conversation knows what a spec can import from the start.
2144
+ */
2145
+ export function chatRulesBlock(rules = loadRules(join(PACKAGE_ROOT, "genome", "rules"))) {
2146
+ return renderRulesBlock(rules);
2147
+ }
2148
+ function registerBranding(pi, ruleCount, skills) {
2149
+ pi.registerCommand("stdlib", {
2150
+ description: "List the ***plain standard library: the stacks a spec can import instead of writing its implementation reqs.",
2151
+ handler: async (_args, ctx) => {
2152
+ if (ctx.hasUI)
2153
+ ctx.ui.notify(describeStdlib(), "info");
2154
+ },
2155
+ });
2156
+ pi.registerEntryRenderer(BANNER_ENTRY, (entry, _options, theme) => {
2157
+ const data = entry.data;
2158
+ if (data === undefined)
2159
+ return undefined;
2160
+ const lines = [
2161
+ `${theme.fg("accent", "turri")} ${theme.fg("muted", `v${data.version}`)}`,
2162
+ theme.fg("text", "regenerative software - the code is regenerated from the spec"),
2163
+ theme.fg("muted", `${data.rules} rules · ${data.skills} skill${data.skills === 1 ? "" : "s"}` +
2164
+ (data.projectSkills !== undefined && data.projectSkills > 0 ? ` (${data.projectSkills} the project's)` : "") +
2165
+ (data.stdlib !== undefined && data.stdlib > 0 ? ` · ${data.stdlib} stdlib stacks (/stdlib)` : "") +
2166
+ (data.pending > 0 ? theme.fg("warning", ` (${data.pending} failed to load)`) : "")),
2167
+ // Where the settings are changed, without leaving the chat.
2168
+ theme.fg("muted", "configure with /turri-config"),
2169
+ theme.fg("dim", data.cwd),
2170
+ // Where every command an agent runs will run - or that none can.
2171
+ ...(data.sandbox
2172
+ ? [theme.fg(data.sandbox.level === "ok" ? "dim" : data.sandbox.level === "limited" ? "warning" : "error", data.sandbox.text)]
2173
+ : []),
2174
+ ];
2175
+ // The jellyfish turri is named after, in ink for the theme's background: a picture where the
2176
+ // terminal shows images, drawn in text where it does not.
2177
+ return new Banner(lines, (rule) => theme.fg("borderMuted", rule), jellyfishArt(theme.appearance, (art) => theme.fg("text", art)));
2178
+ });
2179
+ pi.on("session_start", (_event, ctx) => {
2180
+ if (ctx.hasUI)
2181
+ animateJellyfish(ctx);
2182
+ try {
2183
+ pi.appendEntry(BANNER_ENTRY, {
2184
+ version: packageVersion(),
2185
+ rules: ruleCount,
2186
+ skills: skills.skills.length,
2187
+ projectSkills: skills.skills.filter((skill) => skill.origin !== "turri").length,
2188
+ pending: skills.pending.length,
2189
+ stdlib: stdlibStackCount(),
2190
+ cwd: ctx.cwd,
2191
+ sandbox: sandboxSummary(),
2192
+ });
2193
+ }
2194
+ catch {
2195
+ // Branding is the least important thing here; never let it stop a session starting.
2196
+ }
2197
+ });
2198
+ }
2199
+ /** How many rows the banner's jellyfish takes: taller than the lines beside it. */
2200
+ const JELLYFISH_ROWS = 13;
2201
+ /** How long each frame of the jellyfish shows. */
2202
+ const JELLYFISH_FRAME_MS = 250;
2203
+ /** Which frame of the jellyfish every banner shows, moved on by `animateJellyfish`. */
2204
+ let jellyfishFrame = 0;
2205
+ let jellyfishTimer;
2206
+ /**
2207
+ * Moves the jellyfish on a frame every JELLYFISH_FRAME_MS while the banner is on the screen. An entry's
2208
+ * renderer is given no TUI to redraw, so an empty widget below the editor - which pi gives no
2209
+ * spacer - hands it over. Once the conversation is taller than the screen the banner has scrolled off,
2210
+ * and a line changing up there would have pi-tui clear and redraw everything, frame after frame: the
2211
+ * jellyfish stops on the frame it is at, for the rest of the session.
2212
+ */
2213
+ function animateJellyfish(ctx) {
2214
+ if (jellyfishTimer !== undefined)
2215
+ clearInterval(jellyfishTimer);
2216
+ jellyfishTimer = undefined;
2217
+ ctx.ui.setWidget(JELLYFISH_CLOCK_KEY, (tui) => {
2218
+ drawOverlaysBesidePicture(tui);
2219
+ const timer = setInterval(() => {
2220
+ if (tui.render(tui.terminal.columns).length > tui.terminal.rows) {
2221
+ clearInterval(timer);
2222
+ return;
2223
+ }
2224
+ jellyfishFrame++;
2225
+ tui.requestRender();
2226
+ }, JELLYFISH_FRAME_MS);
2227
+ timer.unref?.();
2228
+ jellyfishTimer = timer;
2229
+ return {
2230
+ render: () => [],
2231
+ invalidate: () => { },
2232
+ dispose: () => clearInterval(timer),
2233
+ };
2234
+ }, { placement: "belowEditor" });
2235
+ }
2236
+ const JELLYFISH_CLOCK_KEY = "turri-jellyfish";
2237
+ /** What the banner puts between a row's text and the picture after it: back to the jellyfish's column. */
2238
+ const PICTURE_AT = "\x1b[2G";
2239
+ /** The columns the banner's picture takes from the left edge, while it shows one; 0 when it does not. */
2240
+ let pictureRight = 0;
2241
+ /**
2242
+ * pi-tui draws no overlay over a line holding an image: it leaves the line as it is, and the side panel
2243
+ * would have a gap on the banner's row that carries the picture. So that row's text gets the overlay as
2244
+ * any other does, and the picture goes back on after it - unless the overlay reaches over the picture,
2245
+ * which is then left out of that row until it no longer does.
2246
+ */
2247
+ function drawOverlaysBesidePicture(tui) {
2248
+ const target = tui;
2249
+ if (target.turriPicture === true || typeof target.compositeLineAt !== "function")
2250
+ return;
2251
+ target.turriPicture = true;
2252
+ const original = target.compositeLineAt.bind(tui);
2253
+ target.compositeLineAt = (base, overlay, col, width, total) => {
2254
+ const at = base.lastIndexOf(PICTURE_AT);
2255
+ const picture = at === -1 ? "" : base.slice(at + PICTURE_AT.length);
2256
+ if (!/^(\x1b\[\d+A)?(\x1b_G|\x1b\]1337;File=)/.test(picture))
2257
+ return original(base, overlay, col, width, total);
2258
+ const drawn = compositeTuiLine(base.slice(0, at), overlay, col, width, total);
2259
+ return col >= pictureRight ? `${drawn}${PICTURE_AT}${picture}` : drawn;
2260
+ };
2261
+ }
2262
+ /** Columns between the jellyfish and the lines beside it. */
2263
+ const JELLYFISH_GAP = 3;
2264
+ /** The fewest columns the lines keep beside the jellyfish; a terminal narrower than that gets the lines alone. */
2265
+ const BANNER_TEXT_MIN = 30;
2266
+ /**
2267
+ * The banner: the jellyfish on the left at the frame `animateJellyfish` has it on, its lines beside it in
2268
+ * the middle of its height, and a rule the width of the terminal under them both.
2269
+ *
2270
+ * pi-tui writes a line holding an image as it is. So the picture goes on one of the rows after that
2271
+ * row's text: back to the jellyfish's column (`CSI G`), then the image's escape sequence. Kitty places
2272
+ * the image there without moving the cursor, from the first row; iTerm2 moves the cursor past what it
2273
+ * draws, so it goes on the last row and climbs back up to start, as pi-tui's Image does.
2274
+ */
2275
+ class Banner {
2276
+ lines;
2277
+ rule;
2278
+ art;
2279
+ kittyImageId;
2280
+ /** Without `art` the banner is its lines alone. */
2281
+ constructor(lines, rule, art) {
2282
+ this.lines = lines;
2283
+ this.rule = rule;
2284
+ this.art = art;
2285
+ }
2286
+ invalidate() { }
2287
+ render(width) {
2288
+ const inner = Math.max(1, width - 2);
2289
+ const art = this.art;
2290
+ const placed = art?.kind === "image" ? this.placeImage(art, Math.max(JELLYFISH_ROWS, this.lines.length), width - 2 - JELLYFISH_GAP - BANNER_TEXT_MIN) : art?.kind === "text" && art.columns <= width - 2 - JELLYFISH_GAP - BANNER_TEXT_MIN ? art : undefined;
2291
+ const left = placed !== undefined ? 1 + placed.columns + JELLYFISH_GAP : 1;
2292
+ const text = this.lines.map((line) => truncateToWidth(line, Math.max(1, width - 1 - left)));
2293
+ const height = Math.max(text.length, placed?.rows ?? 0);
2294
+ const above = Math.floor((height - text.length) / 2);
2295
+ const drawn = placed?.kind === "text" ? placed.frames[jellyfishFrame % placed.frames.length] : undefined;
2296
+ const rows = Array.from({ length: height }, (_, row) => {
2297
+ const line = text[row - above];
2298
+ const jellyfish = drawn !== undefined ? (drawn[row] ?? " ".repeat(placed.columns)) : " ".repeat(placed?.columns ?? 0);
2299
+ const before = placed !== undefined ? ` ${jellyfish}${" ".repeat(JELLYFISH_GAP)}` : " ";
2300
+ return line !== undefined ? `${before}${line}` : placed?.kind === "text" ? ` ${jellyfish}` : "";
2301
+ });
2302
+ // The side panel is drawn beside the picture (drawOverlaysBesidePicture), which goes on showing.
2303
+ pictureRight = placed?.kind === "image" ? 1 + placed.columns : 0;
2304
+ if (placed?.kind === "image") {
2305
+ const at = placed.onLastRow ? placed.rows - 1 : 0;
2306
+ rows[at] = `${rows[at]}${PICTURE_AT}${placed.sequence}`;
2307
+ }
2308
+ return [...rows, ` ${this.rule("─".repeat(inner))}`];
2309
+ }
2310
+ placeImage(art, rows, room) {
2311
+ if (room < 4)
2312
+ return undefined;
2313
+ const kitty = getCapabilities().images === "kitty";
2314
+ if (kitty && this.kittyImageId === undefined)
2315
+ this.kittyImageId = allocateImageId();
2316
+ const result = renderImage(art.frames[jellyfishFrame % art.frames.length], art.dimensions, {
2317
+ maxWidthCells: room,
2318
+ maxHeightCells: rows,
2319
+ imageId: this.kittyImageId,
2320
+ moveCursor: false,
2321
+ });
2322
+ if (result === null || result.columns > room)
2323
+ return undefined;
2324
+ const sequence = kitty ? result.sequence : `${result.rows > 1 ? `\x1b[${result.rows - 1}A` : ""}${result.sequence}`;
2325
+ return { kind: "image", sequence, columns: result.columns, rows: result.rows, onLastRow: !kitty };
2326
+ }
2327
+ }
2328
+ const jellyfishFrameCache = new Map();
2329
+ /**
2330
+ * The banner's jellyfish for this terminal: where it shows images, the picture's frames spread evenly
2331
+ * over the video, light ink for a dark background and dark ink for a light one; where it does not, the
2332
+ * jellyfish drawn in text, in `ink`. None when they cannot be read.
2333
+ */
2334
+ function jellyfishArt(appearance, ink) {
2335
+ const dir = join(PACKAGE_ROOT, "assets", "jellyfish");
2336
+ try {
2337
+ if (getCapabilities().images === null) {
2338
+ // Its frames one after another, each ended by a line of "---" (scripts/jellyfish-frames.mjs writes them).
2339
+ const drawn = readFileSync(join(dir, "fallback.txt"), "utf8")
2340
+ .split(/^---$/m)
2341
+ .map((frame) => frame.replace(/^\n+|\n+$/g, "").split("\n"))
2342
+ .filter((frame) => frame.some((line) => line.trim() !== ""));
2343
+ if (drawn.length === 0)
2344
+ return undefined;
2345
+ const columns = Math.max(...drawn.flat().map((line) => visibleWidth(line)));
2346
+ const rows = Math.max(...drawn.map((frame) => frame.length));
2347
+ return { kind: "text", frames: drawn.map((frame) => frame.map((line) => ink(line.padEnd(columns)))), columns, rows };
2348
+ }
2349
+ let frames = jellyfishFrameCache.get(appearance);
2350
+ if (frames === undefined) {
2351
+ frames = readdirSync(dir)
2352
+ .filter((name) => name.startsWith(`${appearance}-`) && name.endsWith(".png"))
2353
+ .sort()
2354
+ .map((name) => readFileSync(join(dir, name)).toString("base64"));
2355
+ jellyfishFrameCache.set(appearance, frames);
2356
+ }
2357
+ const dimensions = frames.length > 0 ? getPngDimensions(frames[0]) : null;
2358
+ return dimensions !== null ? { kind: "image", frames, dimensions } : undefined;
2359
+ }
2360
+ catch {
2361
+ return undefined;
2362
+ }
2363
+ }
2364
+ function packageVersion() {
2365
+ try {
2366
+ const raw = readFileSync(join(PACKAGE_ROOT, "package.json"), "utf8");
2367
+ return JSON.parse(raw).version ?? "0.0.0";
2368
+ }
2369
+ catch {
2370
+ return "0.0.0";
2371
+ }
2372
+ }