flowviant 0.115.0 → 0.117.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 (135) hide show
  1. package/README.md +31 -12
  2. package/bin/cli.mjs +13 -17
  3. package/bin/lib/admission.mjs +7 -14
  4. package/bin/lib/agentInternalWait.mjs +83 -0
  5. package/bin/lib/agentPlan.mjs +56 -24
  6. package/bin/lib/agentPublish.mjs +12 -13
  7. package/bin/lib/agentTaskKinds.mjs +4 -7
  8. package/bin/lib/agySessions.mjs +3 -3
  9. package/bin/lib/artifactPreview.mjs +92 -18
  10. package/bin/lib/artifactReporter.mjs +16 -8
  11. package/bin/lib/artifacts.mjs +64 -60
  12. package/bin/lib/authproxy.mjs +2 -1
  13. package/bin/lib/childEnv.mjs +9 -4
  14. package/bin/lib/claude.mjs +10 -20
  15. package/bin/lib/claudePersonal.mjs +71 -6
  16. package/bin/lib/claudePosture.mjs +119 -42
  17. package/bin/lib/claudeSessions.mjs +7 -7
  18. package/bin/lib/claudeStream.mjs +7 -11
  19. package/bin/lib/cliLimit.mjs +2 -2
  20. package/bin/lib/codexHome.mjs +17 -0
  21. package/bin/lib/codexPersonal.mjs +44 -3
  22. package/bin/lib/codexRelay.mjs +21 -5
  23. package/bin/lib/codexResearch.mjs +180 -0
  24. package/bin/lib/config.mjs +37 -24
  25. package/bin/lib/conversationIds.mjs +51 -0
  26. package/bin/lib/credentials.mjs +2 -7
  27. package/bin/lib/daemonLocks.mjs +88 -0
  28. package/bin/lib/daemonState.mjs +5 -5
  29. package/bin/lib/deploy.mjs +6 -9
  30. package/bin/lib/deployCheckout.mjs +26 -22
  31. package/bin/lib/deployRunner.mjs +54 -18
  32. package/bin/lib/deployWire.mjs +32 -23
  33. package/bin/lib/desktopContract.mjs +3 -5
  34. package/bin/lib/env.mjs +104 -35
  35. package/bin/lib/fleet.mjs +7 -9
  36. package/bin/lib/fleetPost.mjs +172 -25
  37. package/bin/lib/fleetReports.mjs +130 -170
  38. package/bin/lib/fleetStartup.mjs +6 -6
  39. package/bin/lib/fleetWire.mjs +42 -0
  40. package/bin/lib/git.mjs +111 -61
  41. package/bin/lib/gitMergeIdentity.mjs +2 -8
  42. package/bin/lib/grant.mjs +23 -7
  43. package/bin/lib/holder.mjs +108 -81
  44. package/bin/lib/hooks/readGuard.mjs +41 -7
  45. package/bin/lib/install.mjs +80 -33
  46. package/bin/lib/instance.mjs +36 -83
  47. package/bin/lib/intakeDraft.mjs +4 -1
  48. package/bin/lib/knowledgeFileSync.mjs +31 -0
  49. package/bin/lib/knowledgeFiles.mjs +28 -83
  50. package/bin/lib/landed.mjs +22 -29
  51. package/bin/lib/launchCommand.mjs +91 -0
  52. package/bin/lib/listeners.mjs +0 -5
  53. package/bin/lib/localSessions.mjs +52 -39
  54. package/bin/lib/login.mjs +22 -12
  55. package/bin/lib/loginPath.mjs +22 -17
  56. package/bin/lib/machineListing.mjs +6 -3
  57. package/bin/lib/mcp-cli.mjs +9 -10
  58. package/bin/lib/noWindowEnv.mjs +23 -3
  59. package/bin/lib/ownedDirs.mjs +99 -0
  60. package/bin/lib/pinnedCapture.mjs +383 -0
  61. package/bin/lib/prWorkflow.mjs +94 -17
  62. package/bin/lib/preflight.mjs +8 -25
  63. package/bin/lib/preview.mjs +19 -25
  64. package/bin/lib/previewGatePolicy.mjs +34 -9
  65. package/bin/lib/previewRegistry.mjs +4 -11
  66. package/bin/lib/printable.mjs +70 -7
  67. package/bin/lib/procRegistry.mjs +33 -13
  68. package/bin/lib/procScan.mjs +2 -3
  69. package/bin/lib/procStat.mjs +76 -0
  70. package/bin/lib/processGroup.mjs +48 -0
  71. package/bin/lib/processes.mjs +10 -24
  72. package/bin/lib/projectToolRuntimes.mjs +101 -16
  73. package/bin/lib/projectTools.mjs +50 -32
  74. package/bin/lib/prompts.mjs +41 -19
  75. package/bin/lib/repoPath.mjs +19 -0
  76. package/bin/lib/repoState.mjs +17 -19
  77. package/bin/lib/runTurn.mjs +268 -210
  78. package/bin/lib/runtimeAntigravity.mjs +16 -5
  79. package/bin/lib/runtimeCapabilities.mjs +140 -20
  80. package/bin/lib/runtimeClaude.mjs +55 -5
  81. package/bin/lib/runtimeCodex.mjs +245 -205
  82. package/bin/lib/runtimeDetection.mjs +2 -2
  83. package/bin/lib/runtimeEvents.mjs +16 -10
  84. package/bin/lib/runtimeLimits.mjs +5 -6
  85. package/bin/lib/runtimeOfferings.mjs +24 -11
  86. package/bin/lib/runtimes.mjs +10 -0
  87. package/bin/lib/shipSweep.mjs +5 -3
  88. package/bin/lib/shot.mjs +91 -17
  89. package/bin/lib/sleep.mjs +11 -0
  90. package/bin/lib/startProject.mjs +5 -5
  91. package/bin/lib/statePaths.mjs +40 -0
  92. package/bin/lib/tty.mjs +25 -15
  93. package/bin/lib/turnLock.mjs +7 -14
  94. package/bin/lib/turnProfile.mjs +1 -1
  95. package/bin/lib/ui.mjs +0 -3
  96. package/bin/lib/uninstall.mjs +30 -26
  97. package/bin/lib/update.mjs +79 -27
  98. package/bin/lib/uplinkScrub.mjs +296 -31
  99. package/bin/lib/vault.mjs +78 -10
  100. package/bin/lib/vaultDiff.mjs +5 -2
  101. package/bin/lib/views.mjs +37 -12
  102. package/bin/lib/wikiRunner.mjs +40 -61
  103. package/bin/lib/work.mjs +17 -7
  104. package/bin/lib/workAdoptCarry.mjs +2 -4
  105. package/bin/lib/workAgentCheck.mjs +104 -57
  106. package/bin/lib/workAgentMerges.mjs +62 -57
  107. package/bin/lib/workAgentPlans.mjs +99 -145
  108. package/bin/lib/workAgentPrecheck.mjs +71 -138
  109. package/bin/lib/workAgentPublish.mjs +6 -8
  110. package/bin/lib/workAgentTurnBegun.mjs +15 -11
  111. package/bin/lib/workAgentTurnExecution.mjs +46 -53
  112. package/bin/lib/workAgentTurnReports.mjs +9 -37
  113. package/bin/lib/workAgentTurns.mjs +4 -3
  114. package/bin/lib/workAttachments.mjs +66 -11
  115. package/bin/lib/workBrain.mjs +9 -4
  116. package/bin/lib/workCappedTurn.mjs +109 -0
  117. package/bin/lib/workChildren.mjs +5 -5
  118. package/bin/lib/workCommandAudit.mjs +11 -2
  119. package/bin/lib/workDiffs.mjs +52 -6
  120. package/bin/lib/workIntake.mjs +35 -60
  121. package/bin/lib/workPlaceReports.mjs +7 -4
  122. package/bin/lib/workPlaces.mjs +96 -30
  123. package/bin/lib/workPreviews.mjs +47 -112
  124. package/bin/lib/workProcesses.mjs +8 -35
  125. package/bin/lib/workPullRequests.mjs +37 -56
  126. package/bin/lib/workReportQueue.mjs +100 -148
  127. package/bin/lib/workRetire.mjs +2 -1
  128. package/bin/lib/workSessionRuntime.mjs +38 -37
  129. package/bin/lib/workSessionTurns.mjs +111 -189
  130. package/bin/lib/workShip.mjs +43 -23
  131. package/bin/lib/workTurnShape.mjs +163 -0
  132. package/npm-shrinkwrap.json +59 -0
  133. package/package.json +3 -2
  134. package/bin/lib/fleetJobs.mjs +0 -120
  135. package/bin/lib/patchRevert.mjs +0 -60
@@ -69,6 +69,10 @@ function argFlag(name) {
69
69
  */
70
70
  const API_BASE = process.env.FLOWVIANT_API_URL || 'https://api.flowviant.com/api';
71
71
  export const MCP_URL = process.env.FLOWVIANT_MCP_URL || `${API_BASE}/mcp`;
72
+ /** The web app a terminal view or a device login sends a person to. One
73
+ * declaration (audit 2026-10-10, F753): login.mjs and views.mjs each held a
74
+ * copy of this line. */
75
+ export const APP_URL = process.env.FLOWVIANT_APP_URL || 'https://app.flowviant.com';
72
76
  export const FLEET_URL = process.env.FLOWVIANT_FLEET_URL || `${API_BASE}/fleet/agents`;
73
77
  // Push channel: the daemon holds this WebSocket open and the server nudges it
74
78
  // the instant a job lands, so dispatch is ~a round-trip instead of a full poll.
@@ -76,7 +80,6 @@ export const FLEET_URL = process.env.FLOWVIANT_FLEET_URL || `${API_BASE}/fleet/a
76
80
  export const STREAM_URL =
77
81
  process.env.FLOWVIANT_STREAM_URL ||
78
82
  FLEET_URL.replace(/\/agents(\/?)$/, '/stream$1').replace(/^http/, 'ws');
79
- export const POLL_SECONDS = Number(process.env.POLL_SECONDS || 20);
80
83
 
81
84
  // What this machine can actually be given — the cgroup's limits before the
82
85
  // host's. The rule and its reader live in machineLimits.mjs; this is the
@@ -147,16 +150,42 @@ export const MACHINE = measureMachineLimits();
147
150
  * every agent. `cores * 2` lets the scheduler do what it is for, and memory
148
151
  * stays the bound that actually refuses.
149
152
  *
150
- * `min(32, …)` and the env override are untouched: the hard 32 is a runaway
151
- * bound on a box, not a product decision, and an operator who typed a number at
152
- * the machine gets exactly that number.
153
+ * `min(TURN_CEILING_MAX, …)` and the env override are untouched: the hard 32 is
154
+ * a runaway bound on a box, not a product decision, and an operator who typed
155
+ * a number at the machine gets exactly that number.
153
156
  */
154
157
  export function deriveMaxConcurrent(memBytes, cores) {
155
158
  const byMem = Math.floor(memBytes / 2 ** 30 - 2);
156
159
  const byCpu = Number(cores) * 2;
157
- return Math.max(1, Math.min(32, byMem, byCpu));
160
+ return Math.max(1, Math.min(TURN_CEILING_MAX, byMem, byCpu));
158
161
  }
159
162
 
163
+ /**
164
+ * THE RUNAWAY BOUND ON TURNS AT ONCE, AND THE ONE READING OF A CEILING
165
+ * (2026-10-10, audit F070).
166
+ *
167
+ * Every author of the ceiling — the env var, the server's dial, the
168
+ * derivation and an admission's own read — goes through `clampTurnCeiling`,
169
+ * which was five hand copies of one rule across this file and admission.mjs:
170
+ * a usable ceiling is a finite number ≥ 1, floored and capped at
171
+ * `TURN_CEILING_MAX`; anything else is ABSENT (null), never 0 or NaN — a 0
172
+ * refuses every spawn forever, and `live >= NaN` is no ceiling at all.
173
+ *
174
+ * 32 is the server's own bound on the dial (`machineTurnsSchema.maxTurns`,
175
+ * `max(32)` in the shared agent-runner schema): the release parity gate
176
+ * (`scripts/check-app-parity.mjs`) holds this copy to it, so the app never
177
+ * stores a dial no machine honours, and no machine caps one it would.
178
+ */
179
+ export const TURN_CEILING_MAX = 32;
180
+ export function clampTurnCeiling(raw) {
181
+ const v = Number(raw);
182
+ return Number.isFinite(v) && v >= 1 ? Math.min(Math.floor(v), TURN_CEILING_MAX) : null;
183
+ }
184
+
185
+ /** The operator's `FLOWVIANT_MAX_CONCURRENT`, read once through the one clamp:
186
+ * null when unset or unusable (the derivation stands). */
187
+ const MAX_CONCURRENT_ASKED = clampTurnCeiling(process.env.FLOWVIANT_MAX_CONCURRENT);
188
+
160
189
  /**
161
190
  * WAS THE ENV VAR THE SOURCE? — the one fact the precedence rule cannot
162
191
  * reconstruct afterwards.
@@ -166,20 +195,10 @@ export function deriveMaxConcurrent(memBytes, cores) {
166
195
  * last word and must outrank the app's dial, the other is a default the app may
167
196
  * override. See `pickMaxTurns` in admission.mjs for what is done with it.
168
197
  */
169
- export const MAX_CONCURRENT_FROM_ENV = (() => {
170
- const asked = Number(process.env.FLOWVIANT_MAX_CONCURRENT);
171
- return Number.isFinite(asked) && asked >= 1;
172
- })();
198
+ export const MAX_CONCURRENT_FROM_ENV = MAX_CONCURRENT_ASKED !== null;
173
199
 
174
- export const MAX_CONCURRENT = (() => {
175
- const asked = Number(process.env.FLOWVIANT_MAX_CONCURRENT);
176
- if (Number.isFinite(asked) && asked >= 1) return Math.min(Math.floor(asked), 32);
177
- return deriveMaxConcurrent(MACHINE.memBytes, MACHINE.cores);
178
- })();
179
- export const IDLE_SECONDS = Number(process.env.IDLE_SECONDS || 30);
180
- // Live mode: after this long idle-parked on a blocker, tear the session down to
181
- // free the Claude process (the intent stays claimed; it resumes when answered).
182
- export const PARK_TIMEOUT_SECONDS = Number(process.env.PARK_TIMEOUT_SECONDS || 900);
200
+ export const MAX_CONCURRENT =
201
+ MAX_CONCURRENT_ASKED ?? deriveMaxConcurrent(MACHINE.memBytes, MACHINE.cores);
183
202
  export const RECONCILE_SECONDS = Number(process.env.RECONCILE_SECONDS || 10);
184
203
  // Proactively refresh a worker token this many seconds before its lease lapses,
185
204
  // so a long-lived daemon never silently 401s on an expired token.
@@ -190,12 +209,6 @@ export const SAFE = process.env.FLOWVIANT_SAFE === '1';
190
209
  // keeps it nag-only (it still tells you to update, never installs). Below the
191
210
  // server's MIN version it updates regardless, since live mode won't work.
192
211
  export const AUTO_UPDATE = process.env.FLOWVIANT_NO_UPDATE !== '1';
193
- // Live mode (DEFAULT since 0.8.0): persistent Agent-SDK session per task —
194
- // streams into the task channel, injectable mid-task, blocker-parks in place,
195
- // delivery card on complete, branch preview tunnels. The legacy poll/sentinel
196
- // path (one-shot `claude -p` turns) survives behind FLOWVIANT_POLL=1 as the
197
- // escape hatch; FLOWVIANT_LIVE=1 is still honored for old scripts.
198
- export const LIVE = process.env.FLOWVIANT_POLL !== '1';
199
212
  // Sent on the daemon's own HTTP calls so Cloudflare Bot Fight Mode doesn't 403
200
213
  // them (Node's default UA is treated as a bot). Claude Code sends its own UA.
201
214
  export const USER_AGENT = `flowviant/${VERSION}`;
@@ -0,0 +1,51 @@
1
+ /**
2
+ * WHICH CONVERSATION A TAB RESUMES — the shape each CLI's conversation id must
3
+ * have, and the marker each tab pins it under (audit 2026-10-10, F157).
4
+ *
5
+ * A LEAF ON PURPOSE: no imports. These regexes are argv-injection and
6
+ * path-traversal guards — an id rides argv as `--resume <id>`, `resume <id>`
7
+ * or `--conversation <id>`, and Claude's is joined into a transcript path the
8
+ * probe cleanup then REMOVES — and they were written out by hand: Claude's
9
+ * four times, Antigravity's twice. A CLI that changes its id format would
10
+ * have had someone loosen one copy and miss the one guarding `rmSync`. The
11
+ * readers (claudeSessions, agySessions, runtimeCapabilities, runtimeLimits)
12
+ * sit below the work lanes, so the shapes cannot live in a lane module.
13
+ *
14
+ * THE MARKER NAMES ARE HERE FOR THE SAME REASON. `workSessionTurns.mjs`
15
+ * WRITES them (`sessionMetaPath(wt, <name>, sessionId)`), workPlaceReports
16
+ * reads Claude's for the tab's title, and localSessions READS all of them to
17
+ * know which conversations are the daemon's own — a drift between the writer
18
+ * and that fence is silent, and offers somebody their own tab back as an
19
+ * adoptable terminal session.
20
+ */
21
+
22
+ /**
23
+ * Claude Code's session id, as `system.init` reports it. Conservative: word
24
+ * characters and dash, 8–64 of them, so it can neither climb a path nor
25
+ * carry whitespace into argv.
26
+ */
27
+ export const CLAUDE_SESSION_RE = /^[A-Za-z0-9_-]{8,64}$/;
28
+
29
+ /**
30
+ * The shape a codex thread id must have before it is written to disk or —
31
+ * decisive — pushed into argv as `resume <id>`. Conservative on purpose:
32
+ * alphanumeric plus dash/underscore, never a leading dash (an argv that
33
+ * parses as a flag), never whitespace. Anything else is dropped and the
34
+ * session simply runs fresh in its own worktree.
35
+ */
36
+ export const CODEX_THREAD_RE = /^[0-9a-zA-Z][0-9a-zA-Z_-]{7,63}$/;
37
+
38
+ /** agy conversation ids are plain UUIDs (the db filename IS the identity —
39
+ * measured: a renamed copy fails "trajectory not found"). Guarded the same
40
+ * way as the codex id: it rides in argv as `--conversation <id>`. */
41
+ export const AGY_CONV_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
42
+
43
+ /** Each runtime's per-tab conversation marker (scoped by the session id). */
44
+ export const RESUME_MARKERS = Object.freeze({
45
+ claude: 'flowviant-claude-session',
46
+ codex: 'flowviant-codex-thread',
47
+ antigravity: 'flowviant-agy-conversation',
48
+ });
49
+
50
+ /** Every tab's conversation marker — what the adopt fence reads back. */
51
+ export const SESSION_MARKERS = Object.freeze(Object.values(RESUME_MARKERS));
@@ -44,7 +44,6 @@
44
44
  * mistake permanent.
45
45
  */
46
46
 
47
- import { execFileSync } from 'node:child_process';
48
47
  import { mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from 'node:fs';
49
48
  import { homedir } from 'node:os';
50
49
  import { join } from 'node:path';
@@ -52,6 +51,7 @@ import { printable } from './printable.mjs';
52
51
  import { connectedOn } from './credentialDate.mjs';
53
52
  import { terminalCommand } from './launchCommand.mjs';
54
53
  import { samePath } from './repoPath.mjs';
54
+ import { git } from './git.mjs';
55
55
 
56
56
  const CRED_DIR = join(homedir(), '.flowviant');
57
57
  const CRED_FILE = join(CRED_DIR, 'credentials.json');
@@ -76,12 +76,7 @@ function writeFile(v) {
76
76
  /** The repo root of `cwd`, realpath'd, or null when not inside a git repo. */
77
77
  export function detectRepoRoot(cwd = process.cwd()) {
78
78
  try {
79
- const root = execFileSync('git', ['rev-parse', '--show-toplevel'], {
80
- cwd,
81
- encoding: 'utf8',
82
- stdio: ['ignore', 'pipe', 'ignore'],
83
- timeout: 5000,
84
- }).trim();
79
+ const root = git(['rev-parse', '--show-toplevel'], cwd, { stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000 });
85
80
  return root ? realpathSync(root) : null;
86
81
  } catch {
87
82
  return null;
@@ -0,0 +1,88 @@
1
+ /**
2
+ * THE DAEMON LOCK FILE — its name, its record, and the one scan of them
3
+ * (audit 2026-10-10, F090/F123).
4
+ *
5
+ * instance.mjs arbitrates with these files; the status readout
6
+ * (daemonState.mjs, desktopContract.mjs) and `flowviant uninstall` only READ
7
+ * them. Each of those readers used to keep its own copy of the filename
8
+ * pattern or the record parse, and they had drifted: uninstall carried its own
9
+ * regex and pid read, the readout returned a pid that was not a positive
10
+ * integer, and `daemonRunningFor` re-implemented `readHolder` line for line. The
11
+ * FILENAME PATTERN is the thing that must not drift between them: it is what
12
+ * separates our locks from anything else living in ~/.flowviant, and a scan
13
+ * that stops matching sees an empty machine — so `uninstall --others` would
14
+ * remove the copy a live daemon runs from.
15
+ *
16
+ * A leaf (node builtins only), so a reader never has to load instance.mjs's
17
+ * takeover machinery to look at a file; instance.mjs re-exports all of it.
18
+ */
19
+
20
+ import * as nodeFs from 'node:fs';
21
+ import { createHash } from 'node:crypto';
22
+ import { homedir } from 'node:os';
23
+ import { join } from 'node:path';
24
+
25
+ /** Every daemon lock's file name: `daemon-` + 12 hex of the credential's hash. */
26
+ export const DAEMON_LOCK_RE = /^daemon-[0-9a-f]{12}\.lock$/;
27
+
28
+ /** Deliberately a HASH: a credential must never become a filename. */
29
+ export function instanceLockPath(fleetToken, home = homedir()) {
30
+ const key = createHash('sha256').update(String(fleetToken || 'anon')).digest('hex').slice(0, 12);
31
+ return join(home, '.flowviant', `daemon-${key}.lock`);
32
+ }
33
+
34
+ /**
35
+ * A lock record, or null: absent, truncated, half-written, or naming no
36
+ * positive integer pid (a pid of '12' is not a pid). Everything else the
37
+ * record carries — repoRoot, startedAt, entry, version, draining — is the
38
+ * caller's to read; none of it is required.
39
+ */
40
+ export function parseLockRecord(text) {
41
+ try {
42
+ const v = JSON.parse(text);
43
+ return v && Number.isInteger(v.pid) && v.pid > 0 ? v : null;
44
+ } catch {
45
+ return null;
46
+ }
47
+ }
48
+
49
+ /** The record in the lock file at `path`, or null (see parseLockRecord). */
50
+ export function readLockFile(path, { fs = nodeFs } = {}) {
51
+ try {
52
+ return parseLockRecord(fs.readFileSync(path, 'utf8'));
53
+ } catch {
54
+ return null;
55
+ }
56
+ }
57
+
58
+ /** The record in this credential's lock, or null. */
59
+ export function readLockHolder(fleetToken) {
60
+ return readLockFile(instanceLockPath(fleetToken));
61
+ }
62
+
63
+ /**
64
+ * Every daemon lock on this machine, sorted by name for a stable read:
65
+ * `[{ path, holder }]`, where `holder` is null for a file with no readable
66
+ * record (no caller reads that as a daemon). ONE scan, because three callers
67
+ * walk this directory for different reasons — instance.mjs looking for a
68
+ * neighbour in the same repo, `flowviant stop` sweeping the lot, uninstall
69
+ * protecting the copy a live daemon runs from. An unreadable (or absent)
70
+ * directory is not an error here, it is an empty machine. `fs` and `home` are
71
+ * injectable for uninstall's test seams.
72
+ */
73
+ export function listDaemonLocks({ fs = nodeFs, home = homedir() } = {}) {
74
+ const dir = join(home, '.flowviant');
75
+ let names;
76
+ try {
77
+ names = fs.readdirSync(dir);
78
+ } catch {
79
+ return [];
80
+ }
81
+ return names
82
+ .filter((name) => DAEMON_LOCK_RE.test(name))
83
+ .sort()
84
+ .map((name) => {
85
+ const path = join(dir, name);
86
+ return { path, holder: readLockFile(path, { fs }) };
87
+ });
88
+ }
@@ -13,7 +13,7 @@ import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
13
13
  import { homedir } from 'node:os';
14
14
  import { join } from 'node:path';
15
15
  import { emitMachineEvent } from './daemonLogging.mjs';
16
- import { instanceLockPath } from './instance.mjs';
16
+ import { readLockHolder } from './daemonLocks.mjs';
17
17
 
18
18
  const store = () => join(homedir(), '.flowviant');
19
19
  const key = (projectId) => createHash('sha256').update(String(projectId)).digest('hex').slice(0, 16);
@@ -56,9 +56,9 @@ export function setRuntimeLimit(projectId, runtime, words) {
56
56
  return true;
57
57
  }
58
58
 
59
- /** The pid in the instance lock for this credential, or null. */
59
+ /** The pid in the instance lock for this credential, or null — read through
60
+ * the lock's one record parse, so a pid that is not a positive integer is no
61
+ * pid (2026-10-10, F090). */
60
62
  export function daemonLockPid(token) {
61
- try {
62
- return JSON.parse(readFileSync(instanceLockPath(token), 'utf8')).pid;
63
- } catch { return null; }
63
+ return readLockHolder(token)?.pid ?? null;
64
64
  }
@@ -16,13 +16,13 @@
16
16
  * - deployRunner.mjs the commands, their environment and log scrubbing,
17
17
  * the health check (and why a deploy's secrets are
18
18
  * the machine's own environment)
19
- * - deployWire.mjs the one `/fleet/deploy-*` POST
19
+ * - deployWire.mjs the lane's `/fleet/deploy-*` wire (post, claim)
20
20
  */
21
21
 
22
22
  import { DAEMON_INSTANCE } from './config.mjs';
23
23
  import { c, note, ok, warn } from './ui.mjs';
24
24
  import { scrub } from './uplinkScrub.mjs';
25
- import { post } from './deployWire.mjs';
25
+ import { claim, post } from './deployWire.mjs';
26
26
  import { runDeploy } from './deployRunner.mjs';
27
27
  import { workLanesStopped } from './standDownGate.mjs';
28
28
 
@@ -99,16 +99,13 @@ export function processDeployJobs(jobs, ctx) {
99
99
  void (async () => {
100
100
  let beat = null;
101
101
  try {
102
- // `instance` names THIS PROCESS. The pubkey cannot: it is the env
102
+ // `instance` names THIS PROCESS — the shared lease claim stamps it
103
+ // (fleetPost.mjs's `claimFleetLease`). The pubkey cannot: it is the env
103
104
  // keypair read from one file per home directory, so two daemons on one
104
105
  // box share it and a pubkey-only read-back let both "win" the claim
105
106
  // and run the same deploy twice concurrently.
106
- const claimed = await post('deploy-claim', {
107
- jobId: job.id,
108
- pubkey: ctx.myPubB64(),
109
- instance: DAEMON_INSTANCE,
110
- }).catch(() => null);
111
- if (!claimed?.claimed) return; // another daemon won the claim
107
+ const claimed = await claim('deploy-claim', { jobId: job.id, pubkey: ctx.myPubB64() });
108
+ if (!claimed) return; // another daemon won the claim, or none was made
112
109
  /**
113
110
  * A CLAIM THAT COMES BACK AFTER THE STAND-DOWN BEGAN IS NOT RUN. The
114
111
  * loop gate cannot see it (the claim was already on the wire), and the
@@ -22,9 +22,12 @@ import {
22
22
  symlinkSync,
23
23
  unlinkSync,
24
24
  } from 'node:fs';
25
- import { homedir } from 'node:os';
26
- import { basename, isAbsolute, join, relative, sep } from 'node:path';
25
+ import { isAbsolute, join, relative, sep } from 'node:path';
27
26
  import { git, gitNetAsync } from './git.mjs';
27
+ import { worktreeHomeDir } from './statePaths.mjs';
28
+ // WHICH FILES ARE ENV FILES has one home, beside the report's own narrower
29
+ // rule; that block says why the two differ (2026-10-10, the audit's F085).
30
+ import { isCheckoutEnvFile } from './env.mjs';
28
31
 
29
32
  /**
30
33
  * A DEPLOY BUILDS THE BASE COMMIT, NEVER THE OPERATOR'S WORKING TREE
@@ -60,7 +63,6 @@ import { git, gitNetAsync } from './git.mjs';
60
63
  * a workspace package's own CLI still runs from the checkout. Submodules are
61
64
  * not initialised. The code and the commands no longer come from the checkout.
62
65
  */
63
- const ENV_FILE_RE = /^(?:\.env(?:\..+)?|\.dev\.vars)$/;
64
66
  const lexists = (p) => {
65
67
  try {
66
68
  lstatSync(p);
@@ -71,11 +73,15 @@ const lexists = (p) => {
71
73
  };
72
74
 
73
75
  /** Bring the checkout's untracked environment into a deploy worktree. Returns
74
- * every path it created, so the cleanup can unlink them BEFORE the worktree
75
- * is removed (a recursive delete must never be handed a symlink into the
76
- * operator's own node_modules). */
76
+ * `{ made, envFiles }`: `made` is every path it created, so the cleanup can
77
+ * unlink them BEFORE the worktree is removed (a recursive delete must never
78
+ * be handed a symlink into the operator's own node_modules); `envFiles` is
79
+ * every env file it COPIED (2026-10-10, the audit's F085), so the deploy lane
80
+ * can teach the scrubber the values its own build is about to be able to
81
+ * print — nested files the root-only env scan never reads. */
77
82
  export function linkCheckoutEnvironment(repoRoot, dir) {
78
83
  const made = [];
84
+ const envFiles = [];
79
85
  let realRoot = repoRoot;
80
86
  try {
81
87
  realRoot = realpathSync(repoRoot);
@@ -149,11 +155,12 @@ export function linkCheckoutEnvironment(repoRoot, dir) {
149
155
  if (e.isDirectory() && !lexists(dst)) linkNodeModules(src, dst, 0);
150
156
  continue;
151
157
  }
152
- if (e.isFile() && ENV_FILE_RE.test(e.name)) {
158
+ if (e.isFile() && isCheckoutEnvFile(e.name)) {
153
159
  if (!lexists(dst)) {
154
160
  try {
155
161
  copyFileSync(src, dst);
156
162
  made.push(dst);
163
+ envFiles.push(dst);
157
164
  } catch {
158
165
  /* unreadable — the build says so if it needed it */
159
166
  }
@@ -168,15 +175,18 @@ export function linkCheckoutEnvironment(repoRoot, dir) {
168
175
  }
169
176
  };
170
177
  walk('', 0);
171
- return made;
178
+ return { made, envFiles };
172
179
  }
173
180
 
174
181
  /**
175
- * Cut the deploy worktree at the base tip. Returns `{ dir, sha, cleanup }`, or
182
+ * Cut the deploy worktree at the base tip. Returns `{ dir, sha, cleanup,
183
+ * envFiles }` (`envFiles`: the env files copied in — see
184
+ * `linkCheckoutEnvironment`), or
176
185
  * throws with a sentence when base does not resolve. `worktreeDir` defaults to
177
- * the daemon's own worktree home for this repo (fleetStartup.mjs keys it the same
178
- * way); the job id is hashed into the path, never joined raw — it is a
179
- * server-named string.
186
+ * the daemon's own worktree home for this repo — statePaths.mjs's one
187
+ * derivation, the same one fleetStartup.worktreeHome makes and reaps — and the
188
+ * deploy lane relies on that default rather than passing it; the job id is
189
+ * hashed into the path, never joined raw — it is a server-named string.
180
190
  */
181
191
  export async function openDeployCheckout({ repoRoot, baseRef, jobId, worktreeDir }) {
182
192
  if (!baseRef) throw new Error('no base branch resolved, so there is nothing to deploy from');
@@ -195,14 +205,7 @@ export async function openDeployCheckout({ repoRoot, baseRef, jobId, worktreeDir
195
205
  } catch {
196
206
  throw new Error(`the base branch ${baseRef} does not resolve on this machine`);
197
207
  }
198
- const home =
199
- worktreeDir ??
200
- join(
201
- homedir(),
202
- '.flowviant',
203
- 'worktrees',
204
- `${basename(repoRoot)}-${createHash('sha256').update(repoRoot).digest('hex').slice(0, 8)}`
205
- );
208
+ const home = worktreeDir ?? worktreeHomeDir(repoRoot);
206
209
  const dir = join(home, 'deploy', createHash('sha256').update(String(jobId)).digest('hex').slice(0, 16));
207
210
  mkdirSync(join(home, 'deploy'), { recursive: true });
208
211
  const drop = (made = [], target = dir) => {
@@ -256,10 +259,11 @@ export async function openDeployCheckout({ repoRoot, baseRef, jobId, worktreeDir
256
259
  }
257
260
  git(['worktree', 'add', '--detach', dir, sha], repoRoot);
258
261
  let made = [];
262
+ let envFiles = [];
259
263
  try {
260
- made = linkCheckoutEnvironment(repoRoot, dir);
264
+ ({ made, envFiles } = linkCheckoutEnvironment(repoRoot, dir));
261
265
  } catch {
262
266
  /* the build says so if it needed any of it */
263
267
  }
264
- return { dir, sha, cleanup: () => drop(made) };
268
+ return { dir, sha, envFiles, cleanup: () => drop(made) };
265
269
  }
@@ -61,19 +61,35 @@
61
61
  * files, and this box's shell is not one). Deriving the redaction list from the
62
62
  * admission list is what stops the two drifting the next time a name is added.
63
63
  *
64
- * The honest residue, stated: a credential that is neither in `DEPLOY_KEEP` nor
65
- * in a checkout `.env*` — one a `build` script fetches for itself, say — is not
64
+ * AND THE NESTED ENV FILES THIS DAEMON COPIES IN ITSELF (2026-10-10, the
65
+ * audit's F085). The root scan reads only the checkout root's `.env*`
66
+ * (env.mjs states that limit), while `openDeployCheckout` copies every
67
+ * `.env*` and `.dev.vars` from tracked directories three levels deep into the
68
+ * worktree the build runs in — `apps/api/.dev.vars` among them — so a build
69
+ * that printed its config relayed values the scrubber had never been told.
70
+ * The checkout now reports which files it copied, and `runDeploy` teaches the
71
+ * scrubber their values for that job's life only (`learnExtraSecrets`), then
72
+ * forgets them with the worktree. The root-only report scan is unchanged.
73
+ * Well-known vendor token SHAPES are redacted too (uplinkScrub.mjs).
74
+ *
75
+ * The honest residue, stated: a credential that is in none of those — one a
76
+ * `build` script fetches for itself, say, in a shape nobody listed — is not
66
77
  * redacted, because this daemon has never seen it.
67
78
  */
68
79
 
69
80
  import { spawn } from 'node:child_process';
70
- import { scrub } from './uplinkScrub.mjs';
81
+ import { forgetExtraSecrets, learnExtraSecrets, scrubTail } from './uplinkScrub.mjs';
71
82
  import { childEnv } from './childEnv.mjs';
72
83
  import { readDeployConfig } from './deployConfig.mjs';
73
84
  import { openDeployCheckout } from './deployCheckout.mjs';
85
+ import { readEnvFileValues } from './env.mjs';
86
+ import { killGroup } from './processGroup.mjs';
74
87
 
75
88
  /** How long one deploy command may run before it is stopped. */
76
89
  const DEPLOY_COMMAND_TIMEOUT_MS = 30 * 60_000;
90
+ /** How much of a command's output is kept — a memory bound; only the tail is
91
+ * ever read (`tailLines`, the version id). */
92
+ const DEPLOY_OUTPUT_KEEP = 512 * 1024;
77
93
 
78
94
  /** Run a shell command ASYNC (never blocks the daemon's event loop — the
79
95
  * reconcile poll + the deploy heartbeat must keep firing during a long
@@ -88,7 +104,8 @@ const DEPLOY_COMMAND_TIMEOUT_MS = 30 * 60_000;
88
104
  * `deploysInFlight` holding off every self-update). Measured: `sleep 30 &
89
105
  * wait` held the job past its timeout. So the command runs as its own process
90
106
  * group (the shape `gitNetAsync` in git.mjs and the project check in
91
- * workAgentCheck.mjs already use), the timer kills the group, and the timer
107
+ * workAgentCheck.mjs already use), the timer kills the group (`killGroup`,
108
+ * processGroup.mjs — one home since 2026-10-10), and the timer
92
109
  * itself resolves rather than waiting on a pipe a survivor could still hold.
93
110
  * The output gains one line saying so, which reaches the server in the
94
111
  * report's `message` — a relayed fact, not a new wire field.
@@ -131,27 +148,40 @@ export function run(command, { cwd, env, timeoutMs = DEPLOY_COMMAND_TIMEOUT_MS }
131
148
  clearTimeout(timer);
132
149
  resolve(v);
133
150
  };
151
+ /**
152
+ * SCRUBBED BEFORE IT IS CUT (2026-10-10, the audit's F143) — workAgentCheck's
153
+ * order. This used to keep the last 512 KB per chunk and scrub only at
154
+ * settle, so a secret straddling the window's edge was cut first and its
155
+ * surviving half matched nothing. Now the window is cut only from text
156
+ * that has been scrubbed whole (`scrubTail`); a secret still arriving at
157
+ * the END of the buffer is not cut, and the next scrub sees it whole.
158
+ *
159
+ * Amortised rather than per chunk, unlike the check's 4000-character tail:
160
+ * re-scrubbing a 512 KB buffer on every small write of a chatty build is
161
+ * quadratic, so the buffer may grow to twice the window before it is
162
+ * scrubbed and cut. The settle-time scrub stays as the second pass over
163
+ * what ships.
164
+ *
165
+ * A DECODER PER STREAM: `setEncoding` hands whole characters, where
166
+ * `d.toString()` split a multi-byte character across two chunks into
167
+ * replacement characters — and a secret holding one could then never
168
+ * match.
169
+ */
134
170
  const cap = (d) => {
135
- buf += d.toString();
136
- if (buf.length > 512 * 1024) buf = buf.slice(-512 * 1024); // bound memory
171
+ buf += d;
172
+ if (buf.length > 2 * DEPLOY_OUTPUT_KEEP) buf = scrubTail(buf, DEPLOY_OUTPUT_KEEP);
137
173
  };
174
+ child.stdout.setEncoding('utf8');
175
+ child.stderr.setEncoding('utf8');
138
176
  child.stdout.on('data', cap);
139
177
  child.stderr.on('data', cap);
140
178
  const timer = setTimeout(() => {
141
- try {
142
- process.kill(-child.pid, 'SIGKILL'); // the whole group
143
- } catch {
144
- try {
145
- child.kill('SIGKILL');
146
- } catch {
147
- /* already gone */
148
- }
149
- }
179
+ killGroup(child, 'SIGKILL'); // the whole group
150
180
  const limit = timeoutMs >= 60_000 ? `${Math.round(timeoutMs / 60_000)} minutes` : `${timeoutMs}ms`;
151
181
  settle({
152
182
  ok: false,
153
183
  code: -1,
154
- out: scrub(`${buf}\n[flowviant] the command ran past ${limit} and was stopped`),
184
+ out: scrubTail(`${buf}\n[flowviant] the command ran past ${limit} and was stopped`, DEPLOY_OUTPUT_KEEP),
155
185
  });
156
186
  }, timeoutMs);
157
187
  // A broken pipe (a child that exits before its stdin is closed) surfaces as
@@ -166,10 +196,10 @@ export function run(command, { cwd, env, timeoutMs = DEPLOY_COMMAND_TIMEOUT_MS }
166
196
  child.stdin.on('error', () => {});
167
197
  child.stdin.end();
168
198
  child.on('close', (code) => {
169
- settle({ ok: code === 0, code: code ?? -1, out: scrub(buf) });
199
+ settle({ ok: code === 0, code: code ?? -1, out: scrubTail(buf, DEPLOY_OUTPUT_KEEP) });
170
200
  });
171
201
  child.on('error', (e) => {
172
- settle({ ok: false, code: -1, out: scrub(`${buf}\n${e.message}`) });
202
+ settle({ ok: false, code: -1, out: scrubTail(`${buf}\n${e.message}`, DEPLOY_OUTPUT_KEEP) });
173
203
  });
174
204
  });
175
205
  }
@@ -203,7 +233,12 @@ export async function runDeploy(job, ctx) {
203
233
  } catch (e) {
204
234
  return { ok: false, message: `deploy not run — ${e.message}`, logs: [] };
205
235
  }
236
+ // THE FILES THIS JOB COPIED IN, TAUGHT TO THE SCRUBBER FOR ITS LIFE ONLY
237
+ // (2026-10-10, the audit's F085) — see the header. Keyed per job, so two
238
+ // deploys never forget each other's.
239
+ const secretsKey = `deploy:${job.id}`;
206
240
  try {
241
+ learnExtraSecrets(secretsKey, (checkout.envFiles ?? []).flatMap((f) => readEnvFileValues(f)));
207
242
  const targets = readDeployConfig(ctx.repoRoot, checkout.sha);
208
243
  const target = targets.find((t) => t.id === job.targetId);
209
244
  if (!target) {
@@ -213,6 +248,7 @@ export async function runDeploy(job, ctx) {
213
248
  const short = checkout.sha.slice(0, 12);
214
249
  return { ...outcome, sha: checkout.sha, message: `${outcome.message} (${short})` };
215
250
  } finally {
251
+ forgetExtraSecrets(secretsKey); // first: it cannot throw, and the cleanup can
216
252
  checkout.cleanup();
217
253
  }
218
254
  }
@@ -1,41 +1,50 @@
1
1
  /**
2
- * THE DEPLOY LANE'S ONE POST — `/fleet/deploy-*`, with the machine credential.
2
+ * THE DEPLOY LANE'S WIRE — `/fleet/deploy-*`, with the machine credential.
3
3
  *
4
4
  * Split out of deploy.mjs (SOLID F060) because two halves of the lane speak
5
5
  * it: the config report (deployConfig.mjs) and the job lease (deploy.mjs —
6
6
  * claim, heartbeat, outcome). A leaf, so neither has to import the other to
7
- * reach the wire, and a change to how the lane authenticates or times out is
8
- * one edit.
7
+ * reach the wire.
8
+ *
9
+ * The request itself is fleetPost.mjs's (audit 2026-10-10, F082): this was a
10
+ * byte-identical copy of `postToFleet`'s skeleton that differed only in what it
11
+ * returned. What stays here is the lane's own CONTRACT over that request — a
12
+ * throw on anything but an accepted answer, carrying whether it was the API's
13
+ * own refusal of the credential — and the lane's 30-second ceiling.
9
14
  */
10
15
 
11
- import { FLEET_URL, FLEET_TOKEN, USER_AGENT } from './config.mjs';
16
+ import { FLEET_URL } from './config.mjs';
12
17
  import { fleetEndpoint } from './fleetWire.mjs';
13
- import { isCredentialRejection } from './authReject.mjs';
18
+ import { claimFleetLease, fleetRequest } from './fleetPost.mjs';
14
19
 
15
20
  const deployUrl = (tail) => fleetEndpoint(tail, FLEET_URL);
21
+ const DEPLOY_TIMEOUT_MS = 30_000;
16
22
 
23
+ /** POST to `/fleet/<tail>` and return the reply's `data`. A network error or
24
+ * timeout is rethrown as `fetch` threw it; a refusal (a non-2xx, or a 2xx
25
+ * saying `success: false`) throws an Error carrying `status` and
26
+ * `credentialRejected`. */
17
27
  export async function post(tail, body) {
18
- const res = await fetch(deployUrl(tail), {
19
- method: 'POST',
20
- headers: {
21
- Authorization: `Bearer ${FLEET_TOKEN}`,
22
- 'User-Agent': USER_AGENT,
23
- 'Content-Type': 'application/json',
24
- },
25
- signal: AbortSignal.timeout(30_000),
26
- body: JSON.stringify(body),
27
- });
28
- const json = await res.json().catch(() => ({}));
29
- if (!res.ok || json?.success === false) {
28
+ const r = await fleetRequest(deployUrl(tail), { body, timeoutMs: DEPLOY_TIMEOUT_MS });
29
+ if (r.error) throw r.error;
30
+ const json = r.json;
31
+ if (!r.ok || json?.success === false) {
30
32
  // The sentence is unchanged. What rides beside it is whether this was the
31
33
  // API's OWN refusal of the credential (a Disconnect rotates it, even
32
- // mid-deploy) — authReject.mjs's one rule, never the bare status: an edge
33
- // 401/403 (Cloudflare's bot checks, which this client class trips) is a
34
- // blip and is retried like any other failed post.
35
- const e = new Error(`${tail} failed (${res.status}${json?.error ? `: ${json.error}` : ''})`);
36
- e.status = res.status;
37
- e.credentialRejected = isCredentialRejection(res.status, res.headers.get('content-type'), json);
34
+ // mid-deploy) — authReject.mjs's one rule, asked by fleetRequest, never
35
+ // the bare status: an edge 401/403 (Cloudflare's bot checks, which this
36
+ // client class trips) is a blip and is retried like any other failed post.
37
+ const e = new Error(`${tail} failed (${r.status}${json?.error ? `: ${json.error}` : ''})`);
38
+ e.status = r.status;
39
+ e.credentialRejected = r.credentialRejected;
38
40
  throw e;
39
41
  }
40
42
  return json?.data;
41
43
  }
44
+
45
+ /** Claim a deploy job — the shared lease rule (fleetPost.mjs's
46
+ * `claimFleetLease`: this process's `instance` stamped, the winner read from
47
+ * `data.claimed`, any failure "not claimed"), on this lane's ceiling. */
48
+ export function claim(tail, fields) {
49
+ return claimFleetLease(deployUrl(tail), fields, { timeoutMs: DEPLOY_TIMEOUT_MS });
50
+ }