flowviant 0.103.0 → 0.104.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/bin/cli.mjs +87 -474
  2. package/bin/lib/admission.mjs +19 -3
  3. package/bin/lib/agentPlan.mjs +2 -2
  4. package/bin/lib/agentTaskKinds.mjs +93 -0
  5. package/bin/lib/agySessions.mjs +158 -0
  6. package/bin/lib/artifactPolicy.mjs +44 -0
  7. package/bin/lib/artifactPreview.mjs +52 -0
  8. package/bin/lib/artifactReporter.mjs +163 -0
  9. package/bin/lib/artifacts.mjs +47 -227
  10. package/bin/lib/authReject.mjs +16 -4
  11. package/bin/lib/authproxy.mjs +47 -304
  12. package/bin/lib/boxIdentity.mjs +245 -0
  13. package/bin/lib/childEnv.mjs +11 -9
  14. package/bin/lib/claude.mjs +14 -851
  15. package/bin/lib/claudeAuth.mjs +3 -3
  16. package/bin/lib/claudePosture.mjs +367 -0
  17. package/bin/lib/claudeSessions.mjs +411 -0
  18. package/bin/lib/claudeStream.mjs +207 -0
  19. package/bin/lib/cliLimit.mjs +34 -0
  20. package/bin/lib/commandSpecs.mjs +173 -0
  21. package/bin/lib/config.mjs +11 -42
  22. package/bin/lib/credentialDate.mjs +22 -0
  23. package/bin/lib/credentials.mjs +21 -26
  24. package/bin/lib/daemonLogging.mjs +55 -0
  25. package/bin/lib/daemonState.mjs +64 -0
  26. package/bin/lib/deploy.mjs +148 -647
  27. package/bin/lib/deployCheckout.mjs +265 -0
  28. package/bin/lib/deployConfig.mjs +142 -0
  29. package/bin/lib/deployRunner.mjs +311 -0
  30. package/bin/lib/deployWire.mjs +41 -0
  31. package/bin/lib/desktopContract.mjs +56 -105
  32. package/bin/lib/env.mjs +49 -484
  33. package/bin/lib/fleet.mjs +183 -2383
  34. package/bin/lib/fleetJobs.mjs +120 -0
  35. package/bin/lib/fleetPost.mjs +40 -0
  36. package/bin/lib/fleetReports.mjs +392 -0
  37. package/bin/lib/fleetRoster.mjs +470 -0
  38. package/bin/lib/fleetStartup.mjs +196 -0
  39. package/bin/lib/fleetWake.mjs +45 -0
  40. package/bin/lib/fleetWire.mjs +46 -0
  41. package/bin/lib/git.mjs +2 -2
  42. package/bin/lib/gitMergeIdentity.mjs +51 -0
  43. package/bin/lib/grant.mjs +5 -3
  44. package/bin/lib/help.mjs +3 -125
  45. package/bin/lib/holder.mjs +482 -0
  46. package/bin/lib/instance.mjs +124 -46
  47. package/bin/lib/knowledge.mjs +16 -799
  48. package/bin/lib/knowledgeFetch.mjs +38 -0
  49. package/bin/lib/knowledgeFileSync.mjs +112 -0
  50. package/bin/lib/knowledgeFiles.mjs +487 -0
  51. package/bin/lib/knowledgeLibrary.mjs +249 -0
  52. package/bin/lib/landed.mjs +2 -1
  53. package/bin/lib/listeners.mjs +99 -141
  54. package/bin/lib/localSessions.mjs +30 -555
  55. package/bin/lib/login.mjs +3 -2
  56. package/bin/lib/loginPath.mjs +3 -5
  57. package/bin/lib/machineDisconnect.mjs +203 -0
  58. package/bin/lib/machineEnv.mjs +22 -0
  59. package/bin/lib/machineLimits.mjs +61 -0
  60. package/bin/lib/machineListing.mjs +338 -0
  61. package/bin/lib/machines.mjs +78 -455
  62. package/bin/lib/machinesCommand.mjs +214 -0
  63. package/bin/lib/mcp-cli.mjs +2 -1
  64. package/bin/lib/patchRevert.mjs +60 -0
  65. package/bin/lib/prWorkflow.mjs +157 -0
  66. package/bin/lib/preflight.mjs +2 -1
  67. package/bin/lib/pressurePolicy.mjs +104 -0
  68. package/bin/lib/preview.mjs +2 -272
  69. package/bin/lib/previewFramePolicy.mjs +112 -0
  70. package/bin/lib/previewGatePolicy.mjs +236 -0
  71. package/bin/lib/previewRegistry.mjs +235 -0
  72. package/bin/lib/printable.mjs +2 -2
  73. package/bin/lib/procRegistry.mjs +28 -18
  74. package/bin/lib/procScan.mjs +146 -0
  75. package/bin/lib/processes.mjs +29 -37
  76. package/bin/lib/projectToolRuntimes.mjs +138 -0
  77. package/bin/lib/projectTools.mjs +13 -88
  78. package/bin/lib/prompts.mjs +28 -42
  79. package/bin/lib/repoPath.mjs +26 -0
  80. package/bin/lib/resources.mjs +7 -69
  81. package/bin/lib/resumeLost.mjs +107 -0
  82. package/bin/lib/runTurn.mjs +324 -0
  83. package/bin/lib/runtimeAntigravity.mjs +230 -0
  84. package/bin/lib/runtimeCapabilities.mjs +334 -0
  85. package/bin/lib/runtimeClaude.mjs +126 -0
  86. package/bin/lib/runtimeCodex.mjs +217 -0
  87. package/bin/lib/runtimeDetection.mjs +121 -0
  88. package/bin/lib/runtimeEvents.mjs +482 -0
  89. package/bin/lib/runtimeOfferings.mjs +4 -1
  90. package/bin/lib/runtimes.mjs +42 -1402
  91. package/bin/lib/safeFileName.mjs +74 -0
  92. package/bin/lib/standDownExit.mjs +181 -0
  93. package/bin/lib/standDownGate.mjs +48 -0
  94. package/bin/lib/startProject.mjs +279 -0
  95. package/bin/lib/trace.mjs +3 -3
  96. package/bin/lib/turnLock.mjs +68 -0
  97. package/bin/lib/turnProfile.mjs +77 -0
  98. package/bin/lib/uninstall.mjs +55 -45
  99. package/bin/lib/uninstallCopies.mjs +112 -0
  100. package/bin/lib/update.mjs +30 -17
  101. package/bin/lib/uplinkScrub.mjs +265 -0
  102. package/bin/lib/vault.mjs +22 -151
  103. package/bin/lib/vaultDiff.mjs +183 -0
  104. package/bin/lib/versionOrder.mjs +42 -0
  105. package/bin/lib/views.mjs +17 -17
  106. package/bin/lib/wikiRunner.mjs +630 -0
  107. package/bin/lib/work.mjs +188 -3034
  108. package/bin/lib/workAdoptCarry.mjs +94 -0
  109. package/bin/lib/workAgentCheck.mjs +278 -0
  110. package/bin/lib/workAgentMerges.mjs +54 -142
  111. package/bin/lib/workAgentPlans.mjs +14 -12
  112. package/bin/lib/workAgentPrecheck.mjs +393 -0
  113. package/bin/lib/workAgentPublish.mjs +177 -0
  114. package/bin/lib/workAgentReview.mjs +8 -666
  115. package/bin/lib/workAgentTurnBegun.mjs +109 -0
  116. package/bin/lib/workAgentTurnExecution.mjs +604 -0
  117. package/bin/lib/workAgentTurnOutcome.mjs +207 -0
  118. package/bin/lib/workAgentTurnReports.mjs +189 -0
  119. package/bin/lib/workAgentTurns.mjs +91 -898
  120. package/bin/lib/workAttachments.mjs +93 -0
  121. package/bin/lib/workBrain.mjs +53 -0
  122. package/bin/lib/workChildren.mjs +110 -0
  123. package/bin/lib/workCommandAudit.mjs +62 -0
  124. package/bin/lib/workDiffs.mjs +2 -1
  125. package/bin/lib/workNarration.mjs +152 -0
  126. package/bin/lib/workPlaceReports.mjs +426 -0
  127. package/bin/lib/workPlaces.mjs +228 -0
  128. package/bin/lib/workPreviews.mjs +31 -9
  129. package/bin/lib/workProcesses.mjs +4 -5
  130. package/bin/lib/workPullRequests.mjs +52 -126
  131. package/bin/lib/workReportQueue.mjs +254 -0
  132. package/bin/lib/workRetire.mjs +185 -0
  133. package/bin/lib/workSessionGroups.mjs +166 -0
  134. package/bin/lib/workSessionRuntime.mjs +164 -0
  135. package/bin/lib/workSessionTokens.mjs +68 -0
  136. package/bin/lib/workSessionTurns.mjs +892 -0
  137. package/bin/lib/workShip.mjs +9 -29
  138. package/bin/lib/workToolLog.mjs +76 -0
  139. package/package.json +3 -2
  140. package/bin/lib/patch.mjs +0 -363
@@ -0,0 +1,217 @@
1
+ /**
2
+ * CODEX, AS A RUNTIME — its registry row: argv per profile (the kernel
3
+ * sandbox postures), efforts, the env-carried MCP token, and its parser.
4
+ *
5
+ * Split out of runtimes.mjs (2026-09-26, SOLID F045). Each vendor's argv moves
6
+ * on its own vendor's release schedule; the registry (runtimes.mjs) assembles
7
+ * the rows and owns the rule that reads them, and the JSONL parser this row
8
+ * hands out as `parse` lives with the other stream parsers in
9
+ * runtimeEvents.mjs.
10
+ */
11
+
12
+ import { SAFE } from './config.mjs';
13
+ import { parseCodexLine } from './runtimeEvents.mjs';
14
+
15
+ function codexMcp(token, mcpUrl) {
16
+ return {
17
+ dir: null, // nothing written — the token rides in the environment
18
+ args: [
19
+ '-c',
20
+ `mcp_servers.flowviant.url="${mcpUrl}"`,
21
+ '-c',
22
+ 'mcp_servers.flowviant.bearer_token_env_var="FLOWVIANT_MCP_TOKEN"',
23
+ ],
24
+ env: { FLOWVIANT_MCP_TOKEN: token },
25
+ };
26
+ }
27
+
28
+ export const CODEX_RUNTIME = {
29
+ id: 'codex',
30
+ label: 'Codex',
31
+ vendor: 'OpenAI',
32
+ bin: 'codex',
33
+ install: 'npm i -g @openai/codex',
34
+ login: 'codex login',
35
+ live: false,
36
+ /**
37
+ * BUILD AND CONSULT. See `args()` below for how consult is expressed — the
38
+ * short version is that Codex keeps the promise at the kernel rather than in
39
+ * a verb allowlist, and for a consult's actual threat (exfiltration driven
40
+ * by an injected question) that is sufficient and arguably stronger.
41
+ *
42
+ * ALL THREE, and `wiki` is the one worth pausing on: Codex expresses it
43
+ * MORE strictly than Claude does. A cartographer must read the repo and
44
+ * write only the vault; Claude's WIKI_PERM cannot path-scope Write and says
45
+ * so, leaning on the worktree reset as a backstop. Codex's permission
46
+ * profiles enforce the same rule in the kernel — measured: repo readable,
47
+ * vault writable, repo writes refused, network off.
48
+ *
49
+ * Enforcement was verified on Linux (bubblewrap + seccomp). macOS Seatbelt
50
+ * and Windows are UNTESTED; if this daemon starts running there, re-verify
51
+ * before trusting the consult posture on those platforms.
52
+ */
53
+ profiles: ['build', 'consult', 'wiki', 'plan'],
54
+ /** `model_reasoning_effort` tops out at `xhigh` — there is no `max`, and a
55
+ * queued or skewed job naming it would be an argument Codex refuses. */
56
+ efforts: ['low', 'medium', 'high', 'xhigh'],
57
+ mcp: codexMcp,
58
+ /**
59
+ * Codex has NO system-prompt flag. The contract therefore rides inside the
60
+ * prompt, fenced and placed first, and this is a genuine weakening worth
61
+ * stating plainly: a system prompt is a rule, and a prompt preamble is a
62
+ * strong suggestion the model may drift from over a long turn. It is the
63
+ * best available; AGENTS.md was the alternative and is worse, because it is
64
+ * a FILE IN THE WORKTREE — one `git add -A` from being committed into the
65
+ * user's repository, which is not a risk worth taking for a slightly
66
+ * stickier instruction.
67
+ *
68
+ * Agent turns additionally pass reviewed base instructions as a developer
69
+ * config override when the personal config has no developer instruction;
70
+ * the prompt always carries them too. Workbench tabs keep their normal
71
+ * checkout discovery.
72
+ *
73
+ * `--skip-git-repo-check` is deliberately NOT passed: a task always builds
74
+ * in a git worktree, and if it somehow is not one, failing loudly beats
75
+ * silently editing files nobody can diff.
76
+ *
77
+ * THE PROMPT IS LAST, and that is load-bearing rather than tidy. Codex takes
78
+ * it as a trailing POSITIONAL, so every flag — including the two `-c` MCP
79
+ * overrides, which the caller hands in rather than appending — has to be
80
+ * placed before it. Appending them after the positional is the kind of argv
81
+ * that parses today and stops parsing on some future clap upgrade.
82
+ */
83
+ args({ prompt, system, model, effort, resume, resumeThreadId, profile = 'build', vaultDir, mcp = [], resultSchemaArgs = [], adoptResumeId, agentTools }) {
84
+ // Adoption resumes a conversation in ITS OWN CLI's store (claude forks,
85
+ // agy moves) — codex has no adoptable store wired yet. Reaching here
86
+ // with an adopt id is a wiring mistake upstream, and it fails loudly on
87
+ // purpose: quietly dropping the flag would answer that session's held
88
+ // context with a different brain.
89
+ if (adoptResumeId) throw new Error("codex has no adoptable terminal store — an adopt id can't reach this builder");
90
+ const a = ['exec'];
91
+ // BY ID when the caller knows WHICH conversation this is — a Workbench
92
+ // tab's held context, captured off thread.started and stored with its
93
+ // worktree. `--last` resumes the machine's most recent codex conversation,
94
+ // which is only safe on the dispatch path (one lane, one turn at a time,
95
+ // in its own worktree); for a session it is a machine-global guess that
96
+ // two tabs — or a tab plus a dispatch — would cross-resume.
97
+ if (resumeThreadId) a.push('resume', resumeThreadId);
98
+ else if (resume) a.push('resume', '--last');
99
+ a.push('--json');
100
+ if (agentTools) {
101
+ a.push('-c', 'project_doc_max_bytes=0');
102
+ // A person's existing developer instructions keep their authority.
103
+ // The base snapshot is also present in the per-turn prompt preamble.
104
+ if (!agentTools.personalDeveloperInstructions)
105
+ a.push('-c', `developer_instructions=${JSON.stringify(agentTools.instructions)}`);
106
+ }
107
+ if (model) a.push('--model', model);
108
+ // Effort is a config value on Codex rather than a flag.
109
+ if (effort) a.push('-c', `model_reasoning_effort="${effort}"`);
110
+
111
+ if (profile === 'consult') {
112
+ // A CONSULT, EXPRESSED THE ONLY WAY CODEX CAN EXPRESS IT — and it is a
113
+ // different shape from Claude's, which is the whole reason `profile` is
114
+ // a promise rather than a flag list.
115
+ //
116
+ // Claude gets a VERB allowlist: Read, Grep, Glob and a handful of
117
+ // read-only Bash forms, with nothing that reaches the network. Codex has
118
+ // no such thing — it has no file-read tool at all, so reading the repo
119
+ // IS command execution (`cat`, `rg`). Removing the shell leaves the
120
+ // model with exactly ["update_plan","request_user_input"], which cannot
121
+ // answer a question about a codebase. You get both capabilities or
122
+ // neither.
123
+ //
124
+ // So the promise is kept one layer down instead. `read-only` is
125
+ // kernel-enforced (bubblewrap + seccomp on Linux): writes fail, and a
126
+ // direct-IP connect fails with "Operation not permitted" — socket() is
127
+ // denied, not merely DNS. An injected command still RUNS and still
128
+ // cannot take the repository anywhere, which is the threat a consult
129
+ // actually has: its prompt is steered by a question any project editor
130
+ // can type. Arguably a stronger guarantee than the allowlist, being
131
+ // below the agent rather than inside it.
132
+ a.push('--sandbox', 'read-only');
133
+
134
+ // THE HOLE THE SANDBOX DOES NOT COVER. `web_search` ships in `codex
135
+ // exec`'s default tool list even without --search, and it executes
136
+ // SERVER-SIDE at OpenAI — no local sandbox touches it. An injected turn
137
+ // could pack repo contents into a query and egress them straight past
138
+ // everything above. Both spellings, because the two config systems
139
+ // disagree about which one is live.
140
+ a.push('-c', 'tools.web_search=false', '-c', 'web_search="disabled"');
141
+
142
+ // Sub-agents would be a second turn whose posture nobody here chose.
143
+ a.push('-c', 'features.multi_agent=false', '-c', 'features.goals=false');
144
+
145
+ // HERMETIC. Without these a user's ~/.codex/config.toml, a project
146
+ // `.rules` execpolicy file, or their own MCP servers can widen a posture
147
+ // we are asserting on their behalf — silently, and on the one turn whose
148
+ // prompt comes from someone else's typing.
149
+ a.push('--ignore-user-config', '--ignore-rules');
150
+ } else if (profile === 'plan') {
151
+ // A PLANNING SESSION. Read-only on the filesystem, exactly like a
152
+ // consult — the writes it makes go through the control plane, not
153
+ // through this box — so the kernel sandbox is the same one, and for the
154
+ // same reason: this turn's prompt is steered by anything a project
155
+ // editor can type.
156
+ //
157
+ // Everything the consult branch above closes stays closed, and the
158
+ // reasoning is unchanged, so it is not restated: web_search egresses
159
+ // server-side at OpenAI where no local sandbox reaches it, sub-agents
160
+ // would be a turn whose posture nobody here chose, and a user's own
161
+ // config or MCP servers must not widen a posture we are asserting on
162
+ // their behalf.
163
+ //
164
+ // What differs from a consult is the ONE thing this profile exists for:
165
+ // an MCP config IS passed, carrying the plan principal's token. That
166
+ // token's whole tool set is the five plan tools — the server refuses
167
+ // anything else on it — so the control plane being open here does not
168
+ // widen what a hijacked turn could reach beyond the plan it is already
169
+ // sitting in.
170
+ a.push('--sandbox', 'read-only');
171
+ a.push('-c', 'tools.web_search=false', '-c', 'web_search="disabled"');
172
+ a.push('-c', 'features.multi_agent=false', '-c', 'features.goals=false');
173
+ a.push('--ignore-user-config', '--ignore-rules');
174
+ } else if (profile === 'wiki' && vaultDir) {
175
+ // THE CARTOGRAPHER, AND THIS ONE IS STRICTER THAN CLAUDE'S.
176
+ //
177
+ // A wiki turn reads the whole repo and writes ONLY the vault. Claude
178
+ // cannot actually express that: WIKI_PERM hands it Write/Edit and its own
179
+ // comment admits "Write/Edit can't be path-scoped here; the worktree
180
+ // reset is the backstop" — i.e. the cartographer CAN scribble on the
181
+ // checkout and we clean up afterwards.
182
+ //
183
+ // Codex's permission profiles take a per-path filesystem map, so the
184
+ // rule is enforced by the kernel instead of apologised for. Measured:
185
+ // repo readable, vault writable, repo writes fail "Read-only file
186
+ // system", and curl returns 000 — the network is off, which is the half
187
+ // WIKI_PERM was really protecting (its comment: "Command execution is
188
+ // the line: it enables network exfil").
189
+ //
190
+ // The table is set WHOLE rather than as a dotted key, and that is not
191
+ // style: `-c permissions.wiki.filesystem."<path>"="write"` splits the
192
+ // dotted path on the dots INSIDE the path, and every real vault lives
193
+ // under `~/.flowviant/vaults/…`. It fails with "filesystem path must be
194
+ // absolute", which reads like a path problem and is a parsing one.
195
+ a.push('-c', 'permissions.flowviantwiki.extends=":read-only"');
196
+ a.push('-c', `permissions.flowviantwiki.filesystem={"${vaultDir}"="write"}`);
197
+ a.push('-P', 'flowviantwiki');
198
+ a.push('--ignore-user-config', '--ignore-rules');
199
+ } else {
200
+ // The daemon's build posture, mapped: SAFE keeps writes inside the
201
+ // workspace, the default lets the agent run its own tests and git
202
+ // commands. Neither asks a human — there is no human on this end of the
203
+ // pipe. Deliberately NOT hermetic: a build is work the user asked for by
204
+ // @mentioning this CLI, and their own config is theirs to apply.
205
+ a.push('--sandbox', SAFE ? 'workspace-write' : 'danger-full-access');
206
+ }
207
+
208
+ a.push(...mcp, ...resultSchemaArgs);
209
+ a.push(`${system}\n\n---\n\n${prompt}`);
210
+ return a;
211
+ },
212
+ parse: parseCodexLine,
213
+ /** `codex exec --output-schema <file>` constrains the FINAL message to a
214
+ * JSON Schema. Only needed on the mediated path; the direct path reports
215
+ * through MCP tool calls, which are already structured. */
216
+ resultSchema: (path) => ['--output-schema', path],
217
+ };
@@ -0,0 +1,121 @@
1
+ /**
2
+ * WHAT THIS MACHINE HAS — which declared CLIs are installed here, and, of
3
+ * those, which one runs a job nobody @mentioned.
4
+ *
5
+ * Split out of runtimes.mjs (2026-09-26, SOLID F045). The registry is a
6
+ * statement about the CLIs (their argv, their profiles); this file is a
7
+ * measurement of the box (a `--version` exec per CLI, cached with a TTL), and a
8
+ * probe changing is not a reason to edit a table of argv builders.
9
+ *
10
+ * `pickRuntimeFor` lives HERE rather than beside `canRun` because it is the one
11
+ * reader that joins the registry's promise with this box's measurement: the
12
+ * registry imports nothing machine-stateful, so the dependency runs one way
13
+ * (this file → runtimes.mjs) and never in a cycle.
14
+ */
15
+
16
+ import { execFileSync } from 'node:child_process';
17
+ import { RUNTIMES, canRun, drivableHere } from './runtimes.mjs';
18
+
19
+ /**
20
+ * WHICH RUNTIME RUNS A JOB THAT NOBODY @MENTIONED.
21
+ *
22
+ * Building a task has an author: you @mentioned a CLI, and that is the only
23
+ * dispatch in this product. The other turns have none — the wiki sweep, the
24
+ * re-ground, the plan check, the quick edit and the consult are all started by
25
+ * the daemon or the server, and until now every one of them took `runTurn`'s
26
+ * default parameter value and ran Claude. That was not a decision; it was five
27
+ * call sites omitting an argument, and it only looked correct while Claude was
28
+ * the only runtime and preflight refused to start without it.
29
+ *
30
+ * A PROFILE is a promise about what is IMPOSSIBLE during the turn, not a flag
31
+ * list — flag lists are per-vendor, promises are not, and the goal is that every
32
+ * runtime behaves the same way predictably. A runtime declares the profiles it
33
+ * can actually express; one that cannot express a profile does not get that job,
34
+ * rather than getting it with weaker guarantees nobody wrote down.
35
+ *
36
+ * Claude first when it qualifies — not favouritism, and worth saying plainly:
37
+ * these prompts were written and tuned against it, so it is the known-good
38
+ * answer and anything else is a substitution. When it is absent, any runtime
39
+ * that can express the profile runs the job, which is the whole point.
40
+ */
41
+ export function pickRuntimeFor(profile, { detected } = {}) {
42
+ const rows = detected ?? detectRuntimes();
43
+ const ok = (id) =>
44
+ canRun(RUNTIMES[id], profile) && Boolean(rows.find((d) => d.id === id)?.installed);
45
+ if (ok('claude')) return 'claude';
46
+ return Object.keys(RUNTIMES).find(ok) ?? null;
47
+ }
48
+
49
+ /**
50
+ * Which of these is on this machine, asked at most once per DETECT_TTL_MS.
51
+ *
52
+ * `--version` rather than `which`: a binary on PATH that cannot execute (a
53
+ * broken install, a wrong-arch download, a shell alias pointing at nothing) is
54
+ * not a runtime you can dispatch to, and reporting it as one sends work into a
55
+ * hole. 5s is generous for a version print and short enough that three missing
56
+ * CLIs cannot stall a roster poll.
57
+ *
58
+ * Reported to the server on the roster poll so the app can stop saying "we have
59
+ * not looked". It is a statement about THIS MACHINE and nothing else — no
60
+ * account, no quota, no entitlement. Flowviant relays; it does not enforce.
61
+ */
62
+ let detectedCache = null;
63
+ let detectedAt = 0;
64
+ // The cache EXPIRES rather than living for the process: pickRuntimeFor's whole
65
+ // premise is "a CLI can be installed while the daemon runs", and both it and
66
+ // the roster poll read this cache — a forever-cache made that comment a lie
67
+ // (nothing after preflight ever passed refresh, so a mid-run install was
68
+ // invisible until restart, to the server included). 5 minutes keeps the probes
69
+ // (3 sync --version execs) off the hot path while an install still surfaces on
70
+ // the next poll or job.
71
+ const DETECT_TTL_MS = 5 * 60 * 1000;
72
+ /**
73
+ * THE POLL'S `runtimes` VALUE — the CLIs this machine can drive, comma-joined,
74
+ * and '' when a detection that RAN found none (2026-09-26, SOLID F015).
75
+ *
76
+ * Three states on the wire, like `skills`: absent = nothing was measured (an
77
+ * older daemon, or a detection that threw); '' = measured, and none; a list =
78
+ * measured. Omitting the param after an empty detection — the old spelling —
79
+ * read as "said nothing", so the server kept advertising the last CLI a
80
+ * machine lost.
81
+ */
82
+ export function runtimesReport(detected) {
83
+ return detected
84
+ .filter((r) => r.dispatchable)
85
+ .map((r) => r.id)
86
+ .join(',');
87
+ }
88
+
89
+ export function detectRuntimes({ refresh = false } = {}) {
90
+ if (detectedCache && !refresh && Date.now() - detectedAt < DETECT_TTL_MS) return detectedCache;
91
+ detectedAt = Date.now();
92
+ detectedCache = Object.values(RUNTIMES).map((rt) => {
93
+ let version = null;
94
+ try {
95
+ version = String(
96
+ execFileSync(rt.bin, ['--version'], {
97
+ timeout: 5000,
98
+ stdio: ['ignore', 'pipe', 'ignore'],
99
+ })
100
+ )
101
+ .trim()
102
+ .split('\n')[0]
103
+ .slice(0, 40);
104
+ } catch {
105
+ version = null;
106
+ }
107
+ return {
108
+ id: rt.id,
109
+ installed: version !== null,
110
+ version,
111
+ // Installed and drivable are different questions, and conflating them is
112
+ // how a user ends up @mentioning something that silently never starts.
113
+ // THREE questions, in fact — see `drivableHere`: the CLI can be installed,
114
+ // and this module can know how to spawn it, and the worker this daemon is
115
+ // running can still be unable to drive it.
116
+ dispatchable: version !== null && drivableHere(rt),
117
+ blocked: rt.blocked ?? null,
118
+ };
119
+ });
120
+ return detectedCache;
121
+ }