@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,220 @@
1
+ import { existsSync } from "node:fs";
2
+ import { basename, dirname, isAbsolute, sep } from "node:path";
3
+ import { defineTool } from "@earendil-works/pi-coding-agent";
4
+ import { Type } from "typebox";
5
+ import { passedCredentials } from "./agent-env.js";
6
+ import { missingToolchain } from "./test-scripts.js";
7
+ import { MODULES_DIR } from "./paths.js";
8
+ import { reportActivity } from "./activity.js";
9
+ import { LINUX_MACHINE, MACOS_MACHINE } from "./os-sandbox.js";
10
+ /**
11
+ * When the machine lacks what the work needs - a toolchain, a system library, a credential - no agent can fix it, and
12
+ * none tries: the pulse stops and is rolled back, the module's later pulses wait, and the person is told what is
13
+ * missing, how to fix it, and how to run it again. Raised two ways: an agent calls `report_environment_error`, which
14
+ * ends its turn - its reason goes to the person as it is; or the harness finds it itself - a testing script that cannot
15
+ * find its toolchain, a credential `agent-env:` names that is not there - and works out at once what to do about it.
16
+ */
17
+ export class EnvironmentError extends Error {
18
+ // Declared fields, not parameter properties: node's own type stripping cannot run those.
19
+ /** The module whose pulse stopped. */
20
+ moduleName;
21
+ /** What the environment lacks. */
22
+ missing;
23
+ /** What needs it. */
24
+ neededBy;
25
+ /** What showed it: the command, the output. */
26
+ shownBy;
27
+ /** How to fix it, when it is known. */
28
+ fix;
29
+ /** Who found it: an agent, or the harness. */
30
+ source;
31
+ /**
32
+ * Nothing is missing: what the agent could not reach is on this machine, and turri's own sandbox kept it from it - the
33
+ * files it named, and the toolchain folder that holds them, for `toolchains:` to let the agents read.
34
+ */
35
+ sandbox;
36
+ constructor(details) {
37
+ super(details.sandbox !== undefined
38
+ ? `${details.moduleName} stopped: turri's sandbox kept its agents from ${details.sandbox.paths[0]}, which is on this machine.`
39
+ : `${details.moduleName} stopped: the environment lacks ${details.missing}.`);
40
+ this.name = "EnvironmentError";
41
+ this.moduleName = details.moduleName;
42
+ this.missing = details.missing;
43
+ this.neededBy = details.neededBy;
44
+ if (details.shownBy !== undefined)
45
+ this.shownBy = details.shownBy;
46
+ if (details.fix !== undefined)
47
+ this.fix = details.fix;
48
+ this.source = details.source;
49
+ if (details.sandbox !== undefined)
50
+ this.sandbox = details.sandbox;
51
+ }
52
+ }
53
+ /** A folder named for a version - `24.16.0`, `v22.3.0`, `3.12.4` - the root of a version manager's install. */
54
+ const VERSION = /^v?\d+\.\d+/;
55
+ /** The parts of an install below its root, where the root ends: `…/24.16.0/lib/node_modules/…` ends before `lib`. */
56
+ const INSIDE_INSTALL = new Set(["bin", "lib", "lib64", "libexec", "share", "include", "node_modules"]);
57
+ /** The toolchain folder a file is part of: its version manager's install, else the folder above its bin or lib, else its own. */
58
+ function toolchainFolder(path) {
59
+ const parts = path.split(sep);
60
+ for (let at = parts.length - 2; at > 0; at -= 1)
61
+ if (VERSION.test(parts[at]))
62
+ return parts.slice(0, at + 1).join(sep);
63
+ const inside = parts.findIndex((part, at) => at > 1 && INSIDE_INSTALL.has(part));
64
+ return inside > 1 ? parts.slice(0, inside).join(sep) : dirname(path);
65
+ }
66
+ /**
67
+ * What an agent reported the machine lacks, read for what it says of turri's own sandbox: a file it names that is on
68
+ * this machine - outside the project and the system's folders, which the sandbox already lets it read - is not missing,
69
+ * the sandbox hid it. A version manager's Node under ~/.local/share/mise, say, whose npm the agent could not open. Then
70
+ * the stop is the sandbox's, and the fix is a line of config - `toolchains:` - not an install.
71
+ */
72
+ export function sandboxGapOf(text, options = {}) {
73
+ const exists = options.exists ?? existsSync;
74
+ const system = (options.platform ?? process.platform) === "darwin" ? MACOS_MACHINE : LINUX_MACHINE;
75
+ const within = (path, root) => path === root || path.startsWith(`${root}/`);
76
+ const named = [...text.matchAll(/(?:^|[\s'"`(=:])(\/[^\s'"`()<>,;]+)/g)].map((match) => match[1].replace(/[.:]+$/, ""));
77
+ const paths = [...new Set(named)].filter((path) => path.split("/").length > 3 && exists(path) && !system.some((root) => within(path, root)) && (options.project === undefined || !within(path, options.project)));
78
+ return paths.length > 0 ? { paths, folder: toolchainFolder(paths[0]) } : undefined;
79
+ }
80
+ /**
81
+ * What the person is told, in the chat, at the end of `turri regenerate`, and in the job's log. `rerun`: the command that runs
82
+ * the work again, as the pulse knows it - the spec the person ran; without it, the module's own spec by its name.
83
+ */
84
+ export function describeEnvironmentError(error, rerun) {
85
+ if (error.sandbox !== undefined) {
86
+ return [
87
+ `**${error.moduleName} stopped: turri's sandbox kept its agents from reading \`${error.sandbox.paths[0]}\` - it is on this machine; nothing is missing.**`,
88
+ `Needed by: ${error.neededBy}${error.shownBy !== undefined ? ` (${error.shownBy})` : ""}.`,
89
+ `To fix: let the agents read that toolchain - add its folder to \`toolchains:\` in config.yaml, keeping what is there: \`toolchains: [${error.sandbox.folder}]\`. Nothing needs installing.`,
90
+ `Then re-run: \`${rerun ?? `turri regenerate ${error.moduleName}.plain`}\`, or say "resume" in the chat.`,
91
+ ].join("\n");
92
+ }
93
+ return [
94
+ `**${error.moduleName} stopped: the environment lacks ${error.missing}.**`,
95
+ `Needed by: ${error.neededBy}${error.shownBy !== undefined ? ` (${error.shownBy})` : ""}.`,
96
+ `To fix: ${error.fix ?? "provide it on this machine, then run it again."}`,
97
+ `Then re-run: \`${rerun ?? `turri regenerate ${error.moduleName}.plain`}\`, or say "resume" in the chat.`,
98
+ ].join("\n");
99
+ }
100
+ /** The name agents know the tool by - their wall text names it. */
101
+ export const ENVIRONMENT_ERROR_TOOL = "report_environment_error";
102
+ /** How much of the harness's run a report that does not stand is shown, at most. */
103
+ const RUN_SHOWN_CHARS = 4000;
104
+ /**
105
+ * An agent's report checked against the harness's own run of the testing script - on this machine, outside the agent's
106
+ * sandbox, the run that decides whether the work is done. Its shell runs behind walls the script does not: what fails
107
+ * there may run here. The script passing, the machine has what the work needs: the report does not stand, and the agent
108
+ * goes on. The script not finding its toolchain, it stands. Failing otherwise, the agent is shown the run once - a
109
+ * report made again after it stands.
110
+ */
111
+ export function checkAgainstScript(run, script) {
112
+ let shown = false;
113
+ return async () => {
114
+ const result = await run();
115
+ const name = basename(script);
116
+ if (result.exitCode === 0) {
117
+ return {
118
+ stands: false,
119
+ reply: `Not recorded: the harness ran the project's testing script, ${name}, on this machine itself, and it passed - ` +
120
+ "the machine has what the work needs. What failed was your shell: your commands run behind a sandbox the " +
121
+ "testing scripts do not. Do not work around it, and do not report it again: carry on with the work, and " +
122
+ "check it with the tool that runs the testing script as the harness does.",
123
+ };
124
+ }
125
+ if (missingToolchain(result) !== undefined || shown)
126
+ return { stands: true };
127
+ shown = true;
128
+ const output = result.output.length > RUN_SHOWN_CHARS ? `...\n${result.output.slice(-RUN_SHOWN_CHARS)}` : result.output;
129
+ return {
130
+ stands: false,
131
+ reply: `Not recorded yet: the harness ran the project's testing script, ${name}, on this machine itself - outside your ` +
132
+ `sandbox - and it exited ${result.exitCode}, but not for want of a toolchain. Read its output below. A failure ` +
133
+ "of the code or the tests is yours to fix; if it shows the machine lacks what you reported, call " +
134
+ `${ENVIRONMENT_ERROR_TOOL} again and it stands.\n\n${output}`,
135
+ };
136
+ };
137
+ }
138
+ /**
139
+ * `report_environment_error`: an agent that finds the machine lacking what the work needs says so, and its turn ends.
140
+ * `record` is told what it reported; whoever asked the turn stops the pulse with it. With `check`, the report is checked
141
+ * first: one that does not stand is not recorded, and the agent is told why and goes on.
142
+ */
143
+ export function environmentErrorTool(record, check) {
144
+ return defineTool({
145
+ name: ENVIRONMENT_ERROR_TOOL,
146
+ label: "Report an environment error",
147
+ description: "Say that this machine lacks something the work needs - a toolchain, a system library, a driver, a service, a " +
148
+ "credential - which you cannot and may not install or make up. It ends your turn: the pulse stops, and the person " +
149
+ "is told exactly what you write here - unless the harness's own run of the testing script, outside your sandbox, " +
150
+ "shows the machine has it: then you are told so, and go on. Use it only for what is missing from the machine, never " +
151
+ "for a problem in the code or the tests.",
152
+ promptSnippet: `${ENVIRONMENT_ERROR_TOOL}: the machine lacks something the work needs - ends your turn`,
153
+ parameters: Type.Object({
154
+ missing: Type.String({ description: "What is missing - e.g. `JDK 21`, `the CUDA toolkit (nvcc)`, `the D365_CLIENT_SECRET credential`" }),
155
+ needed_by: Type.String({ description: "What needs it - the build, a test, a library - e.g. `mvn test`, `torch's GPU build`" }),
156
+ shown_by: Type.Optional(Type.String({ description: "The command that showed it, and what it said" })),
157
+ }),
158
+ async execute(_id, params) {
159
+ const { missing, needed_by: neededBy, shown_by: shownBy } = params;
160
+ const report = { missing, neededBy, ...(shownBy !== undefined ? { shownBy } : {}) };
161
+ if (check !== undefined) {
162
+ reportActivity(` the agent reports the environment lacks ${missing} - checking it with the harness's own run of the testing script`);
163
+ const verdict = await check(report);
164
+ if (!verdict.stands) {
165
+ reportActivity(" it does not stand: the agent goes on");
166
+ return { content: [{ type: "text", text: verdict.reply }], details: {} };
167
+ }
168
+ }
169
+ record(report);
170
+ return { content: [{ type: "text", text: "Recorded. Your turn ends here; the person is told." }], details: {}, terminate: true };
171
+ },
172
+ });
173
+ }
174
+ /**
175
+ * A testing script that cannot find its toolchain: the machine lacks it, and no change to the code or the tests would
176
+ * pass the script - so it is the environment's, found by the harness. `missing` is what the run's output named.
177
+ */
178
+ export function toolchainError(missing, details) {
179
+ const what = `${details.kind} testing script`;
180
+ const needs = missing.command !== undefined ? `\`${missing.command}\`` : `a toolchain the ${what} needs`;
181
+ return new EnvironmentError({
182
+ moduleName: details.moduleName,
183
+ missing: needs,
184
+ neededBy: `the ${what}, \`${basename(details.script)}\``,
185
+ shownBy: missing.command !== undefined ? `it could not find \`${missing.command}\`` : "it exited saying a toolchain is missing",
186
+ fix: `install ${missing.command ?? "it"} on this machine, so the testing script finds it on its PATH.`,
187
+ source: "harness",
188
+ });
189
+ }
190
+ /** The harness's run of a testing script, when it could not find its toolchain: the error that stops the pulse. */
191
+ export function missingToolchainError(run, details) {
192
+ const missing = missingToolchain(run);
193
+ return missing !== undefined ? toolchainError(missing, details) : undefined;
194
+ }
195
+ /** The module a folder is part of, by name: `plain_modules/<module>/...`. */
196
+ function moduleNameOf(moduleDir) {
197
+ return basename(moduleDir);
198
+ }
199
+ /**
200
+ * Before a pulse in `moduleDir` starts any agent: a credential `agent-env:` names that is not there stops it - every
201
+ * command that needs it would fail, and no agent may make one up. Nothing when all are there, or the folder is no
202
+ * module's.
203
+ */
204
+ export function missingCredentials(moduleDir) {
205
+ if (!isAbsolute(moduleDir) || basename(dirname(moduleDir)) !== MODULES_DIR)
206
+ return undefined;
207
+ // Only read: nothing of the module's environment - its home, its TMPDIR - is made for a check.
208
+ const { missing } = passedCredentials(moduleDir, process.env);
209
+ if (missing.length === 0)
210
+ return undefined;
211
+ const names = missing.map((item) => item.name);
212
+ return new EnvironmentError({
213
+ moduleName: moduleNameOf(moduleDir),
214
+ missing: `${names.length === 1 ? "the credential" : "the credentials"} ${names.map((name) => `\`${name}\``).join(", ")}`,
215
+ neededBy: "`agent-env:` in the project's config, which passes them to the agents",
216
+ shownBy: missing.map((item) => `${item.name}: ${item.why}`).join("; "),
217
+ fix: "provide them where `agent-env:` reads them - the dotenv file it names, or the environment turri is started in - or take them out of `agent-env:`.",
218
+ source: "harness",
219
+ });
220
+ }
@@ -0,0 +1,102 @@
1
+ import { appendFileSync, existsSync } from "node:fs";
2
+ import { maskSecrets } from "./masking.js";
3
+ import { dirname, isAbsolute, join, sep } from "node:path";
4
+ import { ensureInScratch, MODULES_DIR, projectRootOf, scratchDir } from "./paths.js";
5
+ /** The folder a job's log goes in: the project of the first module it writes, from its lanes. */
6
+ export function jobLogDir(lanes) {
7
+ const lane = lanes.find((candidate) => isAbsolute(candidate) && candidate.split(sep).includes(MODULES_DIR));
8
+ if (lane === undefined)
9
+ return undefined;
10
+ return join(scratchDir(join(projectRootOf(lane), MODULES_DIR)), "jobs");
11
+ }
12
+ export function startJobLog(job, lanes) {
13
+ const dir = jobLogDir(lanes);
14
+ if (dir === undefined)
15
+ return undefined;
16
+ let file;
17
+ try {
18
+ ensureInScratch(dirname(dir), dir);
19
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
20
+ file = join(dir, `${stamp}-${job.id}-${job.kind}.log`);
21
+ for (let n = 2; existsSync(file); n += 1)
22
+ file = join(dir, `${stamp}-${job.id}-${job.kind}-${n}.log`);
23
+ appendFileSync(file, maskSecrets([
24
+ `turri job ${job.id} · ${job.kind} · ${job.summary}`,
25
+ `started ${new Date().toISOString()}`,
26
+ ...(job.model !== undefined ? [`model ${job.model}`] : []),
27
+ ...(job.diff !== undefined ? ["", "── the spec change", job.diff.trimEnd()] : []),
28
+ "",
29
+ ].join("\n")), { flag: "wx" });
30
+ }
31
+ catch {
32
+ return undefined;
33
+ }
34
+ let broken = false;
35
+ const write = (text) => {
36
+ if (broken)
37
+ return;
38
+ try {
39
+ appendFileSync(file, maskSecrets(text));
40
+ }
41
+ catch {
42
+ broken = true;
43
+ }
44
+ };
45
+ /** The kind of the entry written last: deltas of a reply join it, as they do on screen. */
46
+ let last;
47
+ return {
48
+ file,
49
+ entry(entry) {
50
+ const text = entry.full ?? entry.text;
51
+ if ((entry.kind === "text" || entry.kind === "thinking") && last === entry.kind) {
52
+ write(text);
53
+ return;
54
+ }
55
+ last = entry.kind;
56
+ const at = stamp();
57
+ switch (entry.kind) {
58
+ case "stage":
59
+ write(`\n\n${at} ── ${text}\n`);
60
+ break;
61
+ case "spec": {
62
+ const [label, ...rest] = text.split("\n");
63
+ write(`\n\n${at} ── building ${label}\n${rest.map((line) => ` │ ${line}`).join("\n")}\n`);
64
+ break;
65
+ }
66
+ case "diff":
67
+ write(`\n\n${at} ── the spec change\n${text.trimEnd()}\n`);
68
+ break;
69
+ case "prompt":
70
+ write(`\n\n${at} harness ▸\n${text.trimEnd()}\n`);
71
+ break;
72
+ case "text":
73
+ write(`\n\n${at} agent ▸\n${text}`);
74
+ break;
75
+ case "thinking":
76
+ write(`\n\n${at} thinking ▸\n${text}`);
77
+ break;
78
+ case "tool":
79
+ write(`\n\n${at} ⚙ ${text}\n`);
80
+ break;
81
+ case "result":
82
+ write(`${at} ${entry.error ? "✗" : "↳"}\n${text === "" ? " (no output)" : text.replace(/\n+$/, "").split("\n").map((line) => ` ${line}`).join("\n")}\n`);
83
+ break;
84
+ }
85
+ },
86
+ note(line) {
87
+ last = undefined;
88
+ write(`\n\n${stamp()} · ${line.trim()}\n`);
89
+ },
90
+ end(status, report, agents = []) {
91
+ last = undefined;
92
+ write(`\n\n${stamp()} ── ${status}\n${(report ?? "").trimEnd()}\n`);
93
+ if (agents.length > 0)
94
+ write(`\n── its agents\n${agents.map((line) => ` ${line}`).join("\n")}\n`);
95
+ },
96
+ };
97
+ }
98
+ function stamp() {
99
+ const now = new Date();
100
+ const pad = (n, width = 2) => String(n).padStart(width, "0");
101
+ return `[${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}.${pad(now.getMilliseconds(), 3)}]`;
102
+ }
@@ -0,0 +1,336 @@
1
+ import { foregroundAnsi, mixColors, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
2
+ import { formatDuration, formatElapsed, pulseName } from "./jobs.js";
3
+ /**
4
+ * The live panel above the editor while background work runs.
5
+ *
6
+ * One block per running job: what it is and how long it has been going, the stage it is
7
+ * at, what is happening this second, and the last things its agents did. What waits is not
8
+ * in it - the side panel lists the queue. It disappears when nothing is running - the finished
9
+ * job announces itself in the transcript instead.
10
+ *
11
+ * It is drawn as a component rather than handed over as lines, because pi cuts a list of
12
+ * lines off at ten. Instead it sizes itself: compact by default, and the agents' trace in full
13
+ * while tool output is expanded - pi's own ctrl+o, which the person already uses to see more of
14
+ * what the agent did. What finished stays counted either way: the side panel lists it.
15
+ */
16
+ export const JOB_PANEL_KEY = "turri-jobs";
17
+ /**
18
+ * The key that opens the side panel on its jobs, and goes back to it. Not ctrl+j: a terminal without the Kitty keyboard
19
+ * protocol sends ctrl+j as a line feed - what it sends for shift+enter too, and what a dialog reads as enter - so it
20
+ * would answer a question, and take a new line from the editor. ctrl+h is the byte old terminals sent for backspace;
21
+ * every terminal now sends backspace as DEL, which pi never takes for ctrl+h, so it is free - in pi, and in a dialog.
22
+ */
23
+ export const PANEL_KEY = "ctrl+h";
24
+ const TICK_MS = 1000;
25
+ /**
26
+ * Each key in the line under the panel - ctrl+o, ctrl+h - breathes until it is first pressed: fully there, a little
27
+ * faint, and back, so it reads as something to press, not as text. Once every BREATH_MS, redrawn every BREATH_TICK_MS
28
+ * while the panel shows and a key still breathes, at most BREATH_FADE of the way from the text's colour to the dim one.
29
+ * Pressed once, it stays still for the rest of the process; the other breathes on.
30
+ */
31
+ export const BREATH_MS = 2400;
32
+ const BREATH_TICK_MS = 100;
33
+ const BREATH_FADE = 0.6;
34
+ const pressed = new Set();
35
+ /** A key of the line was pressed - ctrl+o, or the side panel opened: it stops breathing. */
36
+ export function keyPressed(key) {
37
+ pressed.add(key);
38
+ }
39
+ export function breathes(key) {
40
+ return !pressed.has(key);
41
+ }
42
+ /** For tests: every key breathing again. */
43
+ export function resetKeysForTests() {
44
+ pressed.clear();
45
+ }
46
+ /** How faint a breathing key is at `now`: 0, fully there, at the start of each breath, BREATH_FADE halfway through. */
47
+ export function breath(now) {
48
+ const phase = (now % BREATH_MS) / BREATH_MS;
49
+ return BREATH_FADE * (0.5 - 0.5 * Math.cos(2 * Math.PI * phase));
50
+ }
51
+ /** Tool-call progress arrives many times a second; the screen does not need to. */
52
+ const REDRAW_MS = 100;
53
+ /** Activity lines per running job while compact. */
54
+ const COMPACT_ACTIVITY = 2;
55
+ /**
56
+ * A spinner rather than a disclosure arrow: a `▸` promises something opens when you pick
57
+ * it. The frame follows the clock, so it moves on every redraw, including the tick.
58
+ */
59
+ const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
60
+ export function spinner(now) {
61
+ return SPINNER[Math.floor(now / 1000) % SPINNER.length];
62
+ }
63
+ /** A finished pulse in its module's line: its mark, number and kind - `✓ 3 regenerate`. What it regenerated is in the side panel. */
64
+ function finishedItem(job, marks) {
65
+ if (job.module === undefined || job.pulse === undefined)
66
+ return `${finishedMark(job, marks)} ${pulseName(job)}`;
67
+ return `${finishedMark(job, marks)} ${job.pulse} ${job.kind}`;
68
+ }
69
+ /**
70
+ * What finished, a line a module - its name, then its pulses oldest first - fitted to `width` from the left: the
71
+ * newest that fit, the rest counted as earlier. The side panel lists every one.
72
+ */
73
+ export function finishedLines(finished, width, marks = PLAIN_MARKS) {
74
+ const byModule = new Map();
75
+ for (const job of [...finished].sort((a, b) => a.sequence - b.sequence)) {
76
+ const key = job.module ?? job.kind;
77
+ byModule.set(key, [...(byModule.get(key) ?? []), job]);
78
+ }
79
+ const pad = Math.max(...[...byModule.keys()].map((name) => visibleWidth(name)));
80
+ return [...byModule].map(([name, jobs]) => {
81
+ const head = ` ${name}${" ".repeat(pad - visibleWidth(name))} `;
82
+ const items = jobs.map((job) => finishedItem(job, marks));
83
+ const lineOf = (from) => `${head}${from > 0 ? `… ${from} earlier · ` : ""}${items.slice(from).join(" · ")}`;
84
+ let from = 0;
85
+ while (width !== undefined && from < items.length - 1 && visibleWidth(lineOf(from)) > width)
86
+ from += 1;
87
+ return lineOf(from);
88
+ });
89
+ }
90
+ /** A pulse that ran to its end and whose build did not pass - stopped at a gate - is not a success. */
91
+ export function didNotPass(job) {
92
+ return job.status === "succeeded" && job.details?.passed === false;
93
+ }
94
+ const PLAIN_MARKS = { success: (text) => text, error: (text) => text, warning: (text) => text };
95
+ /** A finished pulse's mark, in the colour of how it ended. */
96
+ export function finishedMark(job, paint = PLAIN_MARKS) {
97
+ if (didNotPass(job) || job.status === "failed")
98
+ return paint.error("✗");
99
+ if (job.status === "stopped")
100
+ return paint.warning("■");
101
+ return paint.success("✓");
102
+ }
103
+ /** What a pulse cost, as its line says it: " · $0.12", or nothing when the price is not known. */
104
+ export function pulseCost(job) {
105
+ return job.usage.cost > 0 ? ` · $${job.usage.cost.toFixed(2)}` : "";
106
+ }
107
+ /**
108
+ * Jobs whose tool draws its own progress widget, which already shows the functional spec being
109
+ * built: the panel leaves it out, so it is not written twice.
110
+ */
111
+ const SPEC_SHOWN_BY_WIDGET = new Set(["regenerate"]);
112
+ /** Spec lines shown per running job while expanded. */
113
+ const EXPANDED_SPEC_LINES = 30;
114
+ const PLAIN_DIFF = { added: (text) => text, removed: (text) => text };
115
+ export function summarizeDiff(diff, style = PLAIN_DIFF) {
116
+ const files = new Set();
117
+ let added = 0;
118
+ let removed = 0;
119
+ let first;
120
+ let firstRemoved;
121
+ for (const line of diff.split("\n")) {
122
+ const file = /^\+\+\+ (?:b\/)?(.+)$/.exec(line);
123
+ if (file) {
124
+ if (file[1] !== "/dev/null")
125
+ files.add(file[1].split("/").pop());
126
+ continue;
127
+ }
128
+ if (line.startsWith("---") || line.startsWith("diff --git") || line.startsWith("index "))
129
+ continue;
130
+ if (line.startsWith("+")) {
131
+ added += 1;
132
+ const text = line.slice(1).replace(/^- /, "").trim();
133
+ if (first === undefined && text !== "" && !/^\*\*\*.*\*\*\*$/.test(text))
134
+ first = text;
135
+ }
136
+ else if (line.startsWith("-")) {
137
+ removed += 1;
138
+ const text = line.slice(1).replace(/^- /, "").trim();
139
+ if (firstRemoved === undefined && text !== "" && !/^\*\*\*.*\*\*\*$/.test(text))
140
+ firstRemoved = text;
141
+ }
142
+ }
143
+ // What it adds, when it adds anything; what it takes out, when that is all it does.
144
+ const what = first !== undefined ? ` · ${first}` : firstRemoved !== undefined ? ` · removes: ${firstRemoved}` : "";
145
+ return `${[...files].join(", ")} ${style.added(`+${added}`)} ${style.removed(`−${removed}`)}${what}`;
146
+ }
147
+ /** The lines of a diff worth reading in a panel: the hunks, not the headers. */
148
+ /**
149
+ * A line of the panel wrapped to its width, its continuations under its text: indented as far as it is,
150
+ * and past the rule of a diff box when it is in one.
151
+ */
152
+ export function wrapKeepingIndent(line, width) {
153
+ if (visibleWidth(line) <= width)
154
+ return [line];
155
+ const plain = line.replace(/\x1b\[[0-9;]*m/g, "");
156
+ const indent = /^(\s*(?:│ )?)/.exec(plain)?.[1] ?? "";
157
+ // Inside a diff box its rule goes on down the continuations; elsewhere, blanks.
158
+ const lead = indent;
159
+ const inner = Math.max(10, width - indent.length);
160
+ const [first = "", ...rest] = wrapTextWithAnsi(line, width);
161
+ return [first, ...rest.flatMap((part) => wrapTextWithAnsi(part, inner)).map((part) => `${lead}${part}`)];
162
+ }
163
+ export function jobPanelLines(jobs, options = {}) {
164
+ const now = options.now ?? Date.now();
165
+ const expanded = options.expanded === true;
166
+ const inOrder = [...jobs].sort((a, b) => a.sequence - b.sequence);
167
+ const running = inOrder.filter((job) => job.status === "running");
168
+ // A module whose work is being planned: the planner is an agent, and takes a while - shown at once, as work going on.
169
+ const planning = inOrder.filter((job) => job.status === "queued" && job.planning !== undefined);
170
+ // Nothing running or being planned: no panel - what waits and what finished are in the side panel.
171
+ if (running.length === 0 && planning.length === 0)
172
+ return [];
173
+ const finished = inOrder.filter((job) => (job.status === "succeeded" || job.status === "failed" || job.status === "stopped") && job.replacedBy === undefined);
174
+ const lines = [];
175
+ // What finished, on top - a line a module, oldest first - and a rule under it: what runs now is below, by the input.
176
+ if (finished.length > 0) {
177
+ lines.push(...finishedLines(finished, options.width, options.marks ?? PLAIN_MARKS));
178
+ lines.push(` ${"─".repeat(Math.max(10, (options.width ?? 80) - 4))}`);
179
+ }
180
+ for (const [index, job] of running.entries()) {
181
+ if (index > 0)
182
+ lines.push("");
183
+ // Its module, how long and what it has cost: what it is doing follows.
184
+ lines.push(`${spinner(now)} ${pulseName(job)} · ${formatDuration(job)}${pulseCost(job)}`);
185
+ // What it is on now, and for how long - beside the job's own time, which is the whole of it.
186
+ const onIt = job.building?.since !== undefined ? ` · ${formatElapsed(now - job.building.since)}` : "";
187
+ if (job.building !== undefined && SPEC_SHOWN_BY_WIDGET.has(job.kind)) {
188
+ // The module widget shows its text; the panel, how long it has taken so far.
189
+ lines.push(` ${job.building.label}${onIt}`);
190
+ }
191
+ else if (job.building !== undefined) {
192
+ const text = job.building.text.split("\n").filter((line) => line.trim() !== "");
193
+ if (!expanded) {
194
+ const more = text.length > 1 ? ` (+${text.length - 1} line${text.length === 2 ? "" : "s"} · ctrl+o)` : "";
195
+ lines.push(` ${job.building.label}${onIt}: ${(text[0] ?? "").trim()}${more}`);
196
+ }
197
+ else {
198
+ lines.push(` ${job.building.label}${onIt}:`);
199
+ for (const line of text.slice(0, EXPANDED_SPEC_LINES))
200
+ lines.push(` │ ${line}`);
201
+ if (text.length > EXPANDED_SPEC_LINES)
202
+ lines.push(` │ … ${text.length - EXPANDED_SPEC_LINES} more lines - ${PANEL_KEY} shows it all`);
203
+ }
204
+ }
205
+ const activity = expanded ? job.activity : job.activity.slice(-COMPACT_ACTIVITY);
206
+ for (const line of activity)
207
+ lines.push(` ${line}`);
208
+ if (job.now !== undefined) {
209
+ const seconds = Math.max(0, Math.round((now - job.now.since) / 1000));
210
+ lines.push(` … ${job.now.label} · ${seconds}s`);
211
+ }
212
+ }
213
+ for (const job of planning) {
214
+ if (lines.length > 0 && running.length > 0)
215
+ lines.push("");
216
+ lines.push(`${spinner(now)} ${job.module ?? job.summary} · planning the pulses · ${formatElapsed(now - job.planning.since)}`);
217
+ lines.push(" the planner is cutting the work into pulses - the first starts once it is done");
218
+ }
219
+ // Always a line of help: how to see more here, and how to inspect the jobs in the side panel.
220
+ if (options.hints === false) {
221
+ // The side panel shows it all already, and says its own keys.
222
+ }
223
+ else {
224
+ // Set apart from the trace above it.
225
+ lines.push("");
226
+ const key = options.keys ?? ((_key, text) => text);
227
+ lines.push(` ${key("trace", `ctrl+o to ${expanded ? "collapse" : "expand"} the trace`)} · ${key("peek", `${PANEL_KEY} to peek at regen`)}`);
228
+ }
229
+ return lines;
230
+ }
231
+ /**
232
+ * The keys of the line, painted at `now`'s breath: one not pressed yet in the text's colour mixed toward the dim one, one
233
+ * pressed as it is. Plain where the theme has no colours to mix.
234
+ */
235
+ export function breathing(theme, now) {
236
+ const text = theme.colors?.text;
237
+ const dim = theme.colors?.dim ?? theme.colors?.muted;
238
+ const mode = theme.getColorMode?.();
239
+ if (text === undefined || dim === undefined || (mode !== "truecolor" && mode !== "256color"))
240
+ return (_key, line) => line;
241
+ const ansi = foregroundAnsi(mixColors(text, dim, breath(now)), mode);
242
+ return (key, line) => (breathes(key) ? `${ansi}${line}\x1b[39m` : line);
243
+ }
244
+ export function createJobPanel(list) {
245
+ let ui;
246
+ let tui;
247
+ let mounted = false;
248
+ let timer;
249
+ /** The keys breathing: a redraw a tick of it, while the panel shows and a key still breathes. */
250
+ let breathTimer;
251
+ /** Whether the trace was open when the panel was first drawn. */
252
+ let firstExpanded;
253
+ let pending;
254
+ const active = () => list().some((job) => job.status === "running" || job.status === "queued");
255
+ const draw = () => {
256
+ if (ui === undefined)
257
+ return;
258
+ const target = ui;
259
+ try {
260
+ if (active() && !mounted) {
261
+ mounted = true;
262
+ target.setWidget(JOB_PANEL_KEY, (next, theme) => {
263
+ tui = next;
264
+ return {
265
+ // Laid out on every render, so ctrl+o takes effect on the very next frame.
266
+ render: (width) => {
267
+ const expanded = target.getToolsExpanded();
268
+ // ctrl+o turns the trace open or closed: once it is not as it first was, ctrl+o was pressed.
269
+ if (firstExpanded === undefined)
270
+ firstExpanded = expanded;
271
+ else if (expanded !== firstExpanded)
272
+ keyPressed("trace");
273
+ const marks = { success: (text) => theme.fg("success", text), error: (text) => theme.fg("error", text), warning: (text) => theme.fg("warning", text) };
274
+ const lines = jobPanelLines(list(), { expanded, width, marks, keys: breathing(theme, Date.now()) });
275
+ // Opened, nothing is cut off: every line wraps. Closed, a line each - the panel stays short.
276
+ return expanded ? lines.flatMap((line) => wrapKeepingIndent(line, width)) : lines.map((line) => truncateToWidth(line, width));
277
+ },
278
+ invalidate() { },
279
+ };
280
+ }, { placement: "aboveEditor" });
281
+ }
282
+ else if (!active() && mounted) {
283
+ mounted = false;
284
+ tui = undefined;
285
+ target.setWidget(JOB_PANEL_KEY, undefined);
286
+ }
287
+ else {
288
+ tui?.requestRender();
289
+ }
290
+ }
291
+ catch {
292
+ // The panel is a convenience; losing it must not fail the work it shows.
293
+ }
294
+ // The elapsed time moves even when the agents are quiet - a long model call is exactly
295
+ // when someone looks to see whether anything is happening.
296
+ if (mounted && timer === undefined) {
297
+ timer = setInterval(draw, TICK_MS);
298
+ timer.unref?.();
299
+ breathTimer = setInterval(() => {
300
+ // Both pressed: nothing breathes, and the panel is drawn as often as before.
301
+ if (!breathes("trace") && !breathes("peek")) {
302
+ clearInterval(breathTimer);
303
+ breathTimer = undefined;
304
+ return;
305
+ }
306
+ tui?.requestRender();
307
+ }, BREATH_TICK_MS);
308
+ breathTimer.unref?.();
309
+ }
310
+ else if (!mounted && timer !== undefined) {
311
+ clearInterval(timer);
312
+ timer = undefined;
313
+ if (breathTimer !== undefined)
314
+ clearInterval(breathTimer);
315
+ breathTimer = undefined;
316
+ }
317
+ };
318
+ return {
319
+ attach(next) {
320
+ // A new session starts with pi's widgets cleared, so whatever was mounted is gone.
321
+ ui = next;
322
+ mounted = false;
323
+ tui = undefined;
324
+ draw();
325
+ },
326
+ refresh() {
327
+ if (pending !== undefined)
328
+ return;
329
+ pending = setTimeout(() => {
330
+ pending = undefined;
331
+ draw();
332
+ }, REDRAW_MS);
333
+ pending.unref?.();
334
+ },
335
+ };
336
+ }