@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,155 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { existsSync, mkdirSync, readFileSync } from "node:fs";
3
+ import { connect } from "node:net";
4
+ import { join } from "node:path";
5
+ import { defineTool } from "@earendil-works/pi-coding-agent";
6
+ import { Type } from "typebox";
7
+ import { agentShell } from "./agent-shell.js";
8
+ import { maskSecrets } from "./masking.js";
9
+ import { modulesScratchDir } from "./paths.js";
10
+ import { isRunning, startInBackground, stopGroup } from "./shell.js";
11
+ /**
12
+ * What an agent starts and keeps running while it works - a server, a worker - to try the software as someone would,
13
+ * from outside: with curl, or what the stack's own client is. Whatever the stack: the agent gives the command that
14
+ * starts it, and how to tell it is up - a port that answers, or a line in its log. It runs on this machine behind the
15
+ * same walls as the agent's own shell - the command check, the module's environment, the OS sandbox, the audit while it
16
+ * starts - in a process group of its own, so it outlives the command that started it; it is stopped - it and everything
17
+ * it started - when the agent asks, when the agent's session ends, and in any case when its pulse ends.
18
+ */
19
+ /** How many services one agent may have running at once. */
20
+ export const MAX_SERVICES = 3;
21
+ const DEFAULT_READY_SECONDS = 60;
22
+ const MAX_READY_SECONDS = 300;
23
+ const LOG_LINES = 80;
24
+ /** Whether something on this machine answers on `port`. */
25
+ function answers(port) {
26
+ return new Promise((done) => {
27
+ const socket = connect({ host: "127.0.0.1", port });
28
+ const end = (up) => {
29
+ socket.destroy();
30
+ done(up);
31
+ };
32
+ socket.once("connect", () => end(true));
33
+ socket.once("error", () => end(false));
34
+ socket.setTimeout(1000, () => end(false));
35
+ });
36
+ }
37
+ /** The last `lines` of a log, masked. */
38
+ function tail(log, lines = LOG_LINES) {
39
+ if (!existsSync(log))
40
+ return "";
41
+ return maskSecrets(readFileSync(log, "utf8").trimEnd().split("\n").slice(-lines).join("\n"));
42
+ }
43
+ /**
44
+ * Start, read and stop services from `dir` - the folder the agent works in - fenced as its role is: what it may read and
45
+ * write (its workdir when not given), or the shell given.
46
+ */
47
+ export function createServiceTools(options) {
48
+ let shell = options.shell;
49
+ const shellOf = () => (shell ??= agentShell({ workdir: options.dir, ...(options.read !== undefined ? { read: options.read } : {}), ...(options.write !== undefined ? { write: options.write } : {}) }));
50
+ const running = new Map();
51
+ const say = (text) => ({ content: [{ type: "text", text }], details: {} });
52
+ const logsDir = join(modulesScratchDir(options.dir), "services");
53
+ const stop = async (service) => {
54
+ running.delete(service.name);
55
+ await stopGroup(service.pid);
56
+ };
57
+ const start = defineTool({
58
+ name: "start_service",
59
+ label: "Start a service",
60
+ description: "Start something that keeps running - a server, a worker - in the background, behind the same walls as your shell, to " +
61
+ "try what is built from outside: then reach it with curl (or the stack's own client) in bash. Give the command that " +
62
+ "starts it, from the code folder, and how to tell it is up: the port it listens on, or a line its log prints once it is " +
63
+ `ready. It returns once it is up - or, if it exits or is not up in time, what it printed. ${MAX_SERVICES} at once at ` +
64
+ "most; each runs until you stop it with stop_service, and is stopped in any case when you finish. A port something " +
65
+ "else holds is not yours: choose another.",
66
+ promptSnippet: "start_service: run a server in the background, to try it from outside with curl",
67
+ parameters: Type.Object({
68
+ name: Type.String({ description: "A short name for it, to read its log and stop it by" }),
69
+ command: Type.String({ description: "The command that starts it, run from the code folder - e.g. `uvicorn src.main:app --port 8000`" }),
70
+ port: Type.Optional(Type.Number({ description: "The port it listens on: it is up once the port answers" })),
71
+ ready_log: Type.Optional(Type.String({ description: "Text its log prints once it is up - used when it has no port" })),
72
+ timeout_seconds: Type.Optional(Type.Number({ description: `How long it may take to come up. Default ${DEFAULT_READY_SECONDS}, at most ${MAX_READY_SECONDS}` })),
73
+ }),
74
+ async execute(_id, params) {
75
+ const { name, command, port, ready_log: readyLog, timeout_seconds: seconds } = params;
76
+ if (running.has(name))
77
+ return say(`A service called ${name} is running already: stop it first, or give this one another name.`);
78
+ if (running.size >= MAX_SERVICES)
79
+ return say(`${MAX_SERVICES} services are running already (${[...running.keys()].join(", ")}): stop one first.`);
80
+ const fenced = shellOf();
81
+ const checked = fenced.check(command);
82
+ if (!checked.ok)
83
+ return say(`Not started: ${checked.reason}`);
84
+ mkdirSync(logsDir, { recursive: true });
85
+ const log = join(logsDir, `${name.replace(/[^\w.-]+/g, "_")}-${randomUUID().slice(0, 8)}.log`);
86
+ const limit = Math.min(Math.max(seconds ?? DEFAULT_READY_SECONDS, 1), MAX_READY_SECONDS);
87
+ // Started and waited on under the audit: what it writes while it comes up, outside its role, is put back.
88
+ const { value: outcome, note } = await fenced.audited(async () => {
89
+ const { pid } = startInBackground(command, { cwd: options.dir, env: fenced.env, wrap: (argv) => fenced.wrap(argv), logFile: log });
90
+ running.set(name, { name, command, pid, log });
91
+ for (let i = 0; i < limit * 4; i += 1) {
92
+ if (!isRunning(pid))
93
+ return "EXITED";
94
+ if (port !== undefined ? await answers(Math.trunc(port)) : readyLog !== undefined ? tail(log, 10_000).includes(readyLog) : i >= 8) {
95
+ // Up - and still running a moment later: a port someone else holds answers too, while this one exits.
96
+ await new Promise((done) => setTimeout(done, 100));
97
+ return isRunning(pid) ? "READY" : "EXITED";
98
+ }
99
+ await new Promise((done) => setTimeout(done, 250));
100
+ }
101
+ return "TIMEOUT";
102
+ });
103
+ const service = running.get(name);
104
+ const printed = tail(log);
105
+ const audit = note !== undefined ? `\n\n${note}` : "";
106
+ if (outcome === "READY") {
107
+ const where = port !== undefined ? ` on port ${Math.trunc(port)} - reach it with curl http://127.0.0.1:${Math.trunc(port)}/...` : "";
108
+ return say(`${name} is up${where}. stop_service stops it; service_logs reads what it prints.\n\n<its log so far>\n${printed}\n</its log so far>${audit}`);
109
+ }
110
+ await stop(service);
111
+ const why = outcome === "EXITED" ? "It exited before it was up" : `It was not up after ${limit} seconds, and was stopped`;
112
+ return say(`${why}. What it printed:\n\n${printed || "(nothing)"}${audit}`);
113
+ },
114
+ });
115
+ const logs = defineTool({
116
+ name: "service_logs",
117
+ label: "Service logs",
118
+ description: "The last lines a service started with start_service has printed - its requests, its errors and their traces.",
119
+ promptSnippet: "service_logs: what a service you started has printed",
120
+ parameters: Type.Object({
121
+ name: Type.String({ description: "The service's name" }),
122
+ lines: Type.Optional(Type.Number({ description: `How many of the last lines. Default ${LOG_LINES}` })),
123
+ }),
124
+ async execute(_id, params) {
125
+ const { name, lines } = params;
126
+ const service = running.get(name);
127
+ if (service === undefined)
128
+ return say(`No service called ${name} is running.${running.size > 0 ? ` Running: ${[...running.keys()].join(", ")}.` : ""}`);
129
+ const printed = tail(service.log, Math.min(Math.max(Math.trunc(lines ?? LOG_LINES), 1), 1000));
130
+ return say(`${isRunning(service.pid) ? `${name} is running.` : `${name} has exited.`}\n\n${printed || "(nothing printed)"}`);
131
+ },
132
+ });
133
+ const stopTool = defineTool({
134
+ name: "stop_service",
135
+ label: "Stop a service",
136
+ description: "Stop a service started with start_service, and everything it started.",
137
+ promptSnippet: "stop_service: stop a service you started",
138
+ parameters: Type.Object({ name: Type.String({ description: "The service's name" }) }),
139
+ async execute(_id, params) {
140
+ const { name } = params;
141
+ const service = running.get(name);
142
+ if (service === undefined)
143
+ return say(`No service called ${name} is running.`);
144
+ await stop(service);
145
+ return say(`${name} is stopped.`);
146
+ },
147
+ });
148
+ return {
149
+ tools: [start, logs, stopTool],
150
+ names: ["start_service", "service_logs", "stop_service"],
151
+ async stopAll() {
152
+ await Promise.all([...running.values()].map((service) => stop(service)));
153
+ },
154
+ };
155
+ }
@@ -0,0 +1,319 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import { spawn, spawnSync } from "node:child_process";
3
+ import { closeSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4
+ import { constants, tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { currentStopSignal } from "./activity.js";
7
+ import { maskingStream } from "./masking.js";
8
+ /**
9
+ * How long a command may run before it is stopped - an agent's own, its tests, the harness's runs. Nothing an agent
10
+ * starts may hang a build: a server started in the foreground, a watcher, a prompt waiting for input would otherwise
11
+ * hold the agent's turn, and the job, forever. TURRI_COMMAND_TIMEOUT sets it, in seconds.
12
+ */
13
+ export const COMMAND_TIMEOUT_MS = Number(process.env.TURRI_COMMAND_TIMEOUT ?? 120) * 1000;
14
+ /** The longest an agent may ask one of its own commands to run for. */
15
+ export const MAX_COMMAND_TIMEOUT_MS = Math.max(COMMAND_TIMEOUT_MS, 30 * 60_000);
16
+ /** What a command that ran out of time leaves in its output, for the agent to act on. */
17
+ export function timedOutNote(timeoutMs) {
18
+ return (`\n[stopped after ${Math.round(timeoutMs / 1000)}s, the time limit. A command that never ends on its own - a ` +
19
+ "server, a watcher, a prompt waiting for input - has to be started in the background (`cmd > app.log 2>&1 &`) " +
20
+ "or given its input; a slow one can be given a longer timeout.]");
21
+ }
22
+ /** How long a command's output is waited for once it has ended: past that, what holds it is a server it left behind. */
23
+ const OUTPUT_GRACE_MS = 100;
24
+ const WINDOWS = process.platform === "win32";
25
+ /** Every group this turri started that may still be running: by its leader's process id, which is the group's id. */
26
+ const groups = new Set();
27
+ /** The current pulse. */
28
+ const pulse = new AsyncLocalStorage();
29
+ /** Every pulse running in this process. */
30
+ const pulses = new Set();
31
+ /**
32
+ * Whether a pulse other than the current one is at work in `moduleDir` - what changes there is that pulse's, and the
33
+ * audit leaves it.
34
+ */
35
+ export function anotherPulseIn(moduleDir) {
36
+ const mine = pulse.getStore();
37
+ for (const other of pulses)
38
+ if (other !== mine && other.modules.has(moduleDir))
39
+ return true;
40
+ return false;
41
+ }
42
+ /** Where each running turri records its groups, a file named for its process id: for its watcher, and the sweep. */
43
+ export function groupRecordsDir() {
44
+ return join(tmpdir(), "turri-groups");
45
+ }
46
+ function recordFile() {
47
+ return join(groupRecordsDir(), `${process.pid}`);
48
+ }
49
+ function writeRecord() {
50
+ try {
51
+ if (groups.size === 0) {
52
+ rmSync(recordFile(), { force: true });
53
+ return;
54
+ }
55
+ mkdirSync(groupRecordsDir(), { recursive: true });
56
+ writeFileSync(recordFile(), [...groups].map((group) => `${group}\n`).join(""));
57
+ }
58
+ catch {
59
+ // Only the watcher and the sweep read it; exit kills the groups all the same.
60
+ }
61
+ }
62
+ let watching = false;
63
+ /**
64
+ * A small shell beside this turri that waits for it to end, then kills every group it recorded - for a turri killed
65
+ * outright, which runs no exit handler. It ends with the turri; it holds nothing open.
66
+ */
67
+ function watch() {
68
+ if (watching || WINDOWS)
69
+ return;
70
+ watching = true;
71
+ const file = recordFile();
72
+ const script = [
73
+ `while kill -0 ${process.pid} 2>/dev/null; do sleep 2; done`,
74
+ `[ -f '${file}' ] || exit 0`,
75
+ `while read -r group; do kill -KILL -- "-$group" 2>/dev/null; done < '${file}'`,
76
+ `rm -f '${file}'`,
77
+ ].join("\n");
78
+ try {
79
+ spawn("/bin/sh", ["-c", script], { detached: true, stdio: "ignore" }).unref();
80
+ }
81
+ catch {
82
+ // The sweep at the next start is there still.
83
+ }
84
+ }
85
+ function track(group) {
86
+ groups.add(group);
87
+ pulse.getStore()?.groups.add(group);
88
+ writeRecord();
89
+ watch();
90
+ }
91
+ function forget(group) {
92
+ groups.delete(group);
93
+ writeRecord();
94
+ }
95
+ /** Whether anything in the group still runs. */
96
+ function groupAlive(group) {
97
+ if (WINDOWS)
98
+ return false;
99
+ try {
100
+ process.kill(-group, 0);
101
+ return true;
102
+ }
103
+ catch {
104
+ return false;
105
+ }
106
+ }
107
+ /** Kill a group, everything in it. */
108
+ function killGroup(group) {
109
+ if (WINDOWS) {
110
+ spawnSync("taskkill", ["/T", "/F", "/PID", String(group)], { stdio: "ignore", windowsHide: true });
111
+ return;
112
+ }
113
+ try {
114
+ process.kill(-group, "SIGKILL");
115
+ }
116
+ catch {
117
+ // Already gone.
118
+ }
119
+ }
120
+ /**
121
+ * Run `work` - a pulse - and kill every group it started when it ends, however it ends: what an agent left running in
122
+ * the background is the pulse's, and goes with it. Groups of a pulse running beside it are left alone. `modules`: the
123
+ * module folders it works in - its lanes - which the audit of another pulse's commands leaves to it.
124
+ */
125
+ export async function withPulse(work, options = {}) {
126
+ const mine = { groups: new Set(), modules: new Set(options.modules ?? []) };
127
+ pulses.add(mine);
128
+ try {
129
+ return await pulse.run(mine, work);
130
+ }
131
+ finally {
132
+ pulses.delete(mine);
133
+ for (const group of mine.groups) {
134
+ killGroup(group);
135
+ forget(group);
136
+ }
137
+ }
138
+ }
139
+ /** Kill every group this turri started. Called as it exits; the tests call it between files. */
140
+ export function killAllGroups() {
141
+ for (const group of groups)
142
+ killGroup(group);
143
+ groups.clear();
144
+ writeRecord();
145
+ }
146
+ /**
147
+ * Kill the groups turris that are gone left behind - killed outright, their watcher gone too - from their records in
148
+ * `dir`. A running turri's are its own. Returns how many groups were still there to kill.
149
+ */
150
+ export function sweepOrphanedGroups(dir = groupRecordsDir()) {
151
+ if (WINDOWS || !existsSync(dir))
152
+ return 0;
153
+ let killed = 0;
154
+ for (const name of readdirSync(dir)) {
155
+ const owner = Number(name);
156
+ if (!Number.isInteger(owner) || owner === process.pid || processAlive(owner))
157
+ continue;
158
+ const file = join(dir, name);
159
+ try {
160
+ for (const line of readFileSync(file, "utf8").split("\n")) {
161
+ const group = Number(line.trim());
162
+ if (!Number.isInteger(group) || group <= 1 || !groupAlive(group))
163
+ continue;
164
+ killGroup(group);
165
+ killed += 1;
166
+ }
167
+ }
168
+ finally {
169
+ rmSync(file, { force: true });
170
+ }
171
+ }
172
+ return killed;
173
+ }
174
+ function processAlive(pid) {
175
+ try {
176
+ process.kill(pid, 0);
177
+ return true;
178
+ }
179
+ catch (error) {
180
+ // Alive, but not ours to signal.
181
+ return error.code === "EPERM";
182
+ }
183
+ }
184
+ /**
185
+ * Run `command` with bash on this machine, from `cwd`, in a process group of its own.
186
+ *
187
+ * It has a time limit - COMMAND_TIMEOUT_MS unless given another - and ends at once when it is stopped, by the caller's
188
+ * signal or the job's; either way its whole group is killed. Ended on its own, what it left in the background runs on
189
+ * until its pulse ends.
190
+ */
191
+ export async function runCommand(command, options) {
192
+ const timeoutMs = options.timeoutMs ?? COMMAND_TIMEOUT_MS;
193
+ const signals = [options.signal, currentStopSignal()].filter((signal) => signal !== undefined);
194
+ // An abort that has already happened fires no event: the command is not started at all.
195
+ if (signals.some((signal) => signal.aborted))
196
+ throw new Error("aborted");
197
+ const argv = options.wrap !== undefined ? options.wrap(["bash", "-c", command]) : ["bash", "-c", command];
198
+ const child = spawn(argv[0], argv.slice(1), {
199
+ cwd: options.cwd,
200
+ env: options.env ?? process.env,
201
+ detached: !WINDOWS,
202
+ stdio: ["ignore", "pipe", "pipe"],
203
+ windowsHide: true,
204
+ });
205
+ const group = child.pid;
206
+ if (group !== undefined)
207
+ track(group);
208
+ // The credentials passed to agents are masked as the output streams, before anything sees it.
209
+ let output = "";
210
+ const masked = maskingStream((data) => {
211
+ output += data.toString("utf8");
212
+ options.onData?.(data);
213
+ });
214
+ const take = (data) => masked.write(data);
215
+ child.stdout.on("data", take);
216
+ child.stderr.on("data", take);
217
+ let timedOut = false;
218
+ let stopped = false;
219
+ const kill = () => {
220
+ if (group !== undefined)
221
+ killGroup(group);
222
+ };
223
+ const timer = setTimeout(() => {
224
+ timedOut = true;
225
+ kill();
226
+ }, timeoutMs);
227
+ const onAbort = () => {
228
+ stopped = true;
229
+ kill();
230
+ };
231
+ for (const signal of signals)
232
+ signal.addEventListener("abort", onAbort, { once: true });
233
+ const exitCode = await new Promise((done) => {
234
+ child.on("error", (error) => {
235
+ take(Buffer.from(String(error.message)));
236
+ done(1);
237
+ });
238
+ child.on("exit", (code, signal) => {
239
+ // The rest of its output, unless a server it left behind holds the pipe: that is not waited for.
240
+ const grace = setTimeout(() => {
241
+ child.stdout.destroy();
242
+ child.stderr.destroy();
243
+ }, OUTPUT_GRACE_MS);
244
+ child.on("close", () => clearTimeout(grace));
245
+ const ended = () => done(code ?? 128 + (signal !== null ? (constants.signals[signal] ?? 0) : 0));
246
+ const open = [child.stdout, child.stderr].filter((stream) => !stream.closed);
247
+ let left = open.length;
248
+ if (left === 0)
249
+ ended();
250
+ for (const stream of open)
251
+ stream.once("close", () => (--left === 0 ? ended() : undefined));
252
+ });
253
+ });
254
+ masked.end();
255
+ clearTimeout(timer);
256
+ for (const signal of signals)
257
+ signal.removeEventListener("abort", onAbort);
258
+ if (group !== undefined && !groupAlive(group))
259
+ forget(group);
260
+ if (stopped)
261
+ throw new Error("aborted");
262
+ if (timedOut) {
263
+ const note = (options.timeoutNote ?? timedOutNote)(timeoutMs);
264
+ options.onData?.(Buffer.from(note));
265
+ return { exitCode: 124, output: `${output}${note}`, timedOut: true };
266
+ }
267
+ return { exitCode, output };
268
+ }
269
+ /**
270
+ * Start `command` in the background, in a process group of its own, its output to `logFile` - a server an agent starts
271
+ * to try from outside. It is its pulse's: running until stopped (`stopGroup`), and killed when the pulse ends.
272
+ */
273
+ export function startInBackground(command, options) {
274
+ const argv = options.wrap !== undefined ? options.wrap(["bash", "-c", command]) : ["bash", "-c", command];
275
+ const log = openSync(options.logFile, "a");
276
+ try {
277
+ const child = spawn(argv[0], argv.slice(1), { cwd: options.cwd, env: options.env ?? process.env, detached: !WINDOWS, stdio: ["ignore", log, log], windowsHide: true });
278
+ child.on("error", () => undefined);
279
+ child.unref();
280
+ if (child.pid === undefined)
281
+ throw new Error(`${argv[0]} could not be started`);
282
+ track(child.pid);
283
+ return { pid: child.pid };
284
+ }
285
+ finally {
286
+ closeSync(log);
287
+ }
288
+ }
289
+ /** Whether a process is still running. */
290
+ export function isRunning(pid) {
291
+ if (WINDOWS)
292
+ return spawnSync("tasklist", ["/FI", `PID eq ${pid}`, "/NH"], { encoding: "utf8", windowsHide: true }).stdout.includes(String(pid));
293
+ return processAlive(pid);
294
+ }
295
+ /** Stop a group started in the background: asked to end, then - after `graceMs` - made to. */
296
+ export async function stopGroup(group, graceMs = 1000) {
297
+ if (!WINDOWS) {
298
+ try {
299
+ process.kill(-group, "SIGTERM");
300
+ }
301
+ catch {
302
+ // Already gone.
303
+ }
304
+ const end = Date.now() + graceMs;
305
+ while (groupAlive(group) && Date.now() < end)
306
+ await new Promise((done) => setTimeout(done, 50));
307
+ }
308
+ killGroup(group);
309
+ forget(group);
310
+ }
311
+ process.on("exit", killAllGroups);
312
+ // A process ended by a signal never exits in the sense that runs the handler above: where nothing else handles the
313
+ // signal, exit properly on it.
314
+ for (const [signal, code] of [["SIGTERM", 143], ["SIGHUP", 129], ["SIGINT", 130]]) {
315
+ process.once(signal, () => {
316
+ if (process.listenerCount(signal) === 0)
317
+ process.exit(code);
318
+ });
319
+ }