flowviant 0.116.0 → 0.124.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 (161) hide show
  1. package/README.md +66 -12
  2. package/bin/cli.mjs +50 -17
  3. package/bin/lib/admission.mjs +7 -14
  4. package/bin/lib/agentAsk.mjs +123 -0
  5. package/bin/lib/agentBrief.mjs +36 -0
  6. package/bin/lib/agentFootprint.mjs +76 -0
  7. package/bin/lib/agentInternalWait.mjs +83 -0
  8. package/bin/lib/agentPlan.mjs +134 -31
  9. package/bin/lib/agentPrerequisiteContract.mjs +43 -0
  10. package/bin/lib/agentPublish.mjs +12 -13
  11. package/bin/lib/agentTaskKinds.mjs +5 -8
  12. package/bin/lib/agySessions.mjs +3 -3
  13. package/bin/lib/artifactContracts.mjs +91 -20
  14. package/bin/lib/artifactIteration.mjs +182 -0
  15. package/bin/lib/artifactPreview.mjs +92 -18
  16. package/bin/lib/artifactRecovery.mjs +36 -0
  17. package/bin/lib/artifactReporter.mjs +194 -22
  18. package/bin/lib/artifacts.mjs +70 -63
  19. package/bin/lib/authproxy.mjs +2 -1
  20. package/bin/lib/childEnv.mjs +9 -4
  21. package/bin/lib/claude.mjs +10 -20
  22. package/bin/lib/claudeAuth.mjs +15 -5
  23. package/bin/lib/claudePersonal.mjs +71 -6
  24. package/bin/lib/claudePosture.mjs +227 -54
  25. package/bin/lib/claudeSessions.mjs +7 -7
  26. package/bin/lib/claudeStream.mjs +27 -13
  27. package/bin/lib/cliLimit.mjs +2 -2
  28. package/bin/lib/codexHome.mjs +17 -0
  29. package/bin/lib/codexPersonal.mjs +44 -3
  30. package/bin/lib/codexProjectRead.mjs +63 -0
  31. package/bin/lib/codexRelay.mjs +21 -5
  32. package/bin/lib/codexResearch.mjs +193 -0
  33. package/bin/lib/commandSpecs.mjs +6 -0
  34. package/bin/lib/config.mjs +37 -24
  35. package/bin/lib/conversationIds.mjs +51 -0
  36. package/bin/lib/credentials.mjs +2 -7
  37. package/bin/lib/daemonLocks.mjs +90 -0
  38. package/bin/lib/daemonState.mjs +5 -5
  39. package/bin/lib/deploy.mjs +6 -9
  40. package/bin/lib/deployCheckout.mjs +26 -22
  41. package/bin/lib/deployRunner.mjs +54 -18
  42. package/bin/lib/deployWire.mjs +32 -23
  43. package/bin/lib/desktopContract.mjs +6 -5
  44. package/bin/lib/discoveryContract.mjs +19 -0
  45. package/bin/lib/env.mjs +104 -35
  46. package/bin/lib/fleet.mjs +53 -13
  47. package/bin/lib/fleetPost.mjs +172 -25
  48. package/bin/lib/fleetReports.mjs +172 -171
  49. package/bin/lib/fleetStartup.mjs +6 -6
  50. package/bin/lib/fleetWire.mjs +42 -0
  51. package/bin/lib/git.mjs +111 -61
  52. package/bin/lib/gitMergeIdentity.mjs +2 -8
  53. package/bin/lib/grant.mjs +23 -7
  54. package/bin/lib/holder.mjs +108 -81
  55. package/bin/lib/hooks/mcpAllow.mjs +60 -0
  56. package/bin/lib/hooks/readGuard.mjs +54 -11
  57. package/bin/lib/imageArtifact.mjs +74 -0
  58. package/bin/lib/install.mjs +80 -33
  59. package/bin/lib/instance.mjs +36 -83
  60. package/bin/lib/intakeDraft.mjs +4 -1
  61. package/bin/lib/knowledgeFileSync.mjs +31 -0
  62. package/bin/lib/knowledgeFiles.mjs +28 -83
  63. package/bin/lib/landed.mjs +22 -29
  64. package/bin/lib/launchCommand.mjs +91 -0
  65. package/bin/lib/listeners.mjs +0 -5
  66. package/bin/lib/localSessions.mjs +52 -39
  67. package/bin/lib/login.mjs +22 -12
  68. package/bin/lib/loginPath.mjs +22 -17
  69. package/bin/lib/machineListing.mjs +6 -3
  70. package/bin/lib/maintenance.mjs +47 -0
  71. package/bin/lib/maintenanceInventory.mjs +39 -0
  72. package/bin/lib/mcp-cli.mjs +9 -10
  73. package/bin/lib/mcpInstall.mjs +163 -0
  74. package/bin/lib/mcpInventory.mjs +119 -0
  75. package/bin/lib/noWindowEnv.mjs +23 -3
  76. package/bin/lib/ownedDirs.mjs +99 -0
  77. package/bin/lib/pinnedCapture.mjs +383 -0
  78. package/bin/lib/planDependencies.mjs +70 -0
  79. package/bin/lib/postureTools.mjs +85 -0
  80. package/bin/lib/prWorkflow.mjs +94 -17
  81. package/bin/lib/preflight.mjs +8 -25
  82. package/bin/lib/preview.mjs +19 -25
  83. package/bin/lib/previewGatePolicy.mjs +34 -9
  84. package/bin/lib/previewRegistry.mjs +4 -11
  85. package/bin/lib/printable.mjs +70 -7
  86. package/bin/lib/procRegistry.mjs +33 -13
  87. package/bin/lib/procScan.mjs +2 -3
  88. package/bin/lib/procStat.mjs +76 -0
  89. package/bin/lib/processGroup.mjs +48 -0
  90. package/bin/lib/processes.mjs +10 -24
  91. package/bin/lib/projectToolRuntimes.mjs +328 -19
  92. package/bin/lib/projectTools.mjs +109 -34
  93. package/bin/lib/prompts.mjs +205 -60
  94. package/bin/lib/repoPath.mjs +19 -0
  95. package/bin/lib/repoState.mjs +17 -19
  96. package/bin/lib/repositoryBaseline.mjs +68 -0
  97. package/bin/lib/runTurn.mjs +286 -209
  98. package/bin/lib/runtimeAntigravity.mjs +16 -5
  99. package/bin/lib/runtimeCapabilities.mjs +215 -21
  100. package/bin/lib/runtimeClaude.mjs +78 -10
  101. package/bin/lib/runtimeCodex.mjs +217 -209
  102. package/bin/lib/runtimeDetection.mjs +8 -2
  103. package/bin/lib/runtimeEvents.mjs +28 -11
  104. package/bin/lib/runtimeLimits.mjs +5 -6
  105. package/bin/lib/runtimeOfferings.mjs +99 -14
  106. package/bin/lib/runtimes.mjs +10 -0
  107. package/bin/lib/shipSweep.mjs +5 -3
  108. package/bin/lib/shot.mjs +91 -17
  109. package/bin/lib/sleep.mjs +11 -0
  110. package/bin/lib/startProject.mjs +5 -5
  111. package/bin/lib/statePaths.mjs +40 -0
  112. package/bin/lib/tty.mjs +25 -15
  113. package/bin/lib/turnDiagnostics.mjs +93 -0
  114. package/bin/lib/turnLock.mjs +7 -14
  115. package/bin/lib/turnProfile.mjs +16 -4
  116. package/bin/lib/ui.mjs +0 -3
  117. package/bin/lib/uninstall.mjs +30 -26
  118. package/bin/lib/update.mjs +79 -27
  119. package/bin/lib/uplinkScrub.mjs +296 -31
  120. package/bin/lib/vault.mjs +78 -10
  121. package/bin/lib/vaultDiff.mjs +5 -2
  122. package/bin/lib/views.mjs +37 -12
  123. package/bin/lib/wikiRunner.mjs +40 -61
  124. package/bin/lib/work.mjs +44 -13
  125. package/bin/lib/workAdoptCarry.mjs +2 -4
  126. package/bin/lib/workAgentBrief.mjs +92 -0
  127. package/bin/lib/workAgentBriefRunner.mjs +59 -0
  128. package/bin/lib/workAgentCheck.mjs +104 -57
  129. package/bin/lib/workAgentMerges.mjs +62 -57
  130. package/bin/lib/workAgentPlans.mjs +82 -199
  131. package/bin/lib/workAgentPrecheck.mjs +76 -138
  132. package/bin/lib/workAgentPublish.mjs +6 -8
  133. package/bin/lib/workAgentTurnBegun.mjs +15 -11
  134. package/bin/lib/workAgentTurnExecution.mjs +131 -67
  135. package/bin/lib/workAgentTurnOutcome.mjs +11 -2
  136. package/bin/lib/workAgentTurnReports.mjs +9 -37
  137. package/bin/lib/workAgentTurns.mjs +64 -7
  138. package/bin/lib/workArtifactReconcile.mjs +79 -0
  139. package/bin/lib/workAttachments.mjs +66 -11
  140. package/bin/lib/workBrain.mjs +9 -4
  141. package/bin/lib/workCappedTurn.mjs +119 -0
  142. package/bin/lib/workChildren.mjs +5 -5
  143. package/bin/lib/workCommandAudit.mjs +18 -2
  144. package/bin/lib/workDiffs.mjs +52 -6
  145. package/bin/lib/workIntake.mjs +35 -60
  146. package/bin/lib/workPlaceReports.mjs +7 -4
  147. package/bin/lib/workPlaces.mjs +113 -31
  148. package/bin/lib/workPreviews.mjs +47 -112
  149. package/bin/lib/workProcesses.mjs +8 -35
  150. package/bin/lib/workPullRequests.mjs +37 -56
  151. package/bin/lib/workReportQueue.mjs +100 -148
  152. package/bin/lib/workRetire.mjs +2 -1
  153. package/bin/lib/workSessionRuntime.mjs +38 -37
  154. package/bin/lib/workSessionTurns.mjs +256 -199
  155. package/bin/lib/workShip.mjs +43 -23
  156. package/bin/lib/workToolLog.mjs +70 -3
  157. package/bin/lib/workTurnShape.mjs +165 -0
  158. package/npm-shrinkwrap.json +69 -0
  159. package/package.json +9 -4
  160. package/bin/lib/fleetJobs.mjs +0 -120
  161. package/bin/lib/patchRevert.mjs +0 -60
@@ -40,12 +40,14 @@
40
40
  */
41
41
 
42
42
  import { readdir, open, stat } from 'node:fs/promises';
43
- import { homedir } from 'node:os';
44
43
  import { join } from 'node:path';
45
44
  import { isoFromStamp } from './isoStamp.mjs';
46
45
  import { claudeAuthContext } from './claudeAuth.mjs';
47
46
  import { detectRuntimes } from './runtimeDetection.mjs';
48
- import { CODEX_THREAD_RE } from './workSessionRuntime.mjs';
47
+ // Both from leaves (audit 2026-10-10, F126): this machine report sits on
48
+ // runTurn's import path, below the work lanes, and imports none of them.
49
+ import { CODEX_THREAD_RE } from './conversationIds.mjs';
50
+ import { codexHome } from './codexHome.mjs';
49
51
 
50
52
  /** Claude's `utilization` is a fraction (0.03 = 3%) — MEASURED, see above. */
51
53
  export const CLAUDE_UTILIZATION_SCALE = 100;
@@ -254,7 +256,7 @@ export async function learnCodexLimits(threadId, { env = process.env, now } = {}
254
256
  export async function codexRolloutFile(threadId, { env = process.env } = {}) {
255
257
  try {
256
258
  if (typeof threadId !== 'string' || !CODEX_THREAD_RE.test(threadId)) return null;
257
- const root = join(env.CODEX_HOME || join(homedir(), '.codex'), 'sessions');
259
+ const root = join(codexHome(env), 'sessions');
258
260
  const key = `${root}\0${threadId}`;
259
261
  let file = rolloutPaths.get(key) ?? null;
260
262
  if (file) {
@@ -294,9 +296,6 @@ export function claudePlanReading(now = Date.now()) {
294
296
  planMemo = { plan, readAt: now };
295
297
  return planMemo;
296
298
  }
297
- export function claudePlan() {
298
- return claudePlanReading().plan;
299
- }
300
299
 
301
300
  const windowOrder = (a, b) =>
302
301
  (a.minutes ?? Infinity) - (b.minutes ?? Infinity) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
@@ -1,26 +1,37 @@
1
1
  /** Local, bounded Codex readouts. No agent turn is started for these reports. */
2
2
  import { execFileSync } from 'node:child_process';
3
- import { readFileSync, readdirSync, statSync } from 'node:fs';
4
- import { homedir } from 'node:os';
5
- import { join } from 'node:path';
3
+ import { accessSync, constants, readFileSync, readdirSync, statSync } from 'node:fs';
4
+ import { basename, delimiter, isAbsolute, join } from 'node:path';
6
5
  import { readBaseTools } from './projectTools.mjs';
7
6
  import { mcpRefusal } from './projectToolRuntimes.mjs';
7
+ import { CODEX_MCP_NAME, normalizeMcpReport } from './runtimeCapabilities.mjs';
8
+ import { unattendedEnv } from './noWindowEnv.mjs';
9
+ import { codexHome } from './codexHome.mjs';
8
10
 
9
11
  const TTL = 5 * 60_000;
10
12
  let cachedAt = 0;
11
13
  let cached = null;
12
14
  let repoCached = null;
13
- const home = (env) => env.CODEX_HOME || join(homedir(), '.codex');
14
15
  const nameOk = (name) => typeof name === 'string' && /^[A-Za-z0-9_.:-]{1,80}$/.test(name) && name !== 'flowviant';
16
+ // An unattended CLI child like any other (noWindowEnv.mjs, 2026-10-10): `codex
17
+ // mcp list` may start the person's configured servers, and none of them is
18
+ // handed the machine credential or a display.
15
19
  const run = (args, env) => execFileSync('codex', args, {
16
20
  encoding: 'utf8', timeout: 5000, maxBuffer: 128 * 1024,
17
- stdio: ['ignore', 'pipe', 'ignore'], env,
21
+ stdio: ['ignore', 'pipe', 'ignore'], env: unattendedEnv(env),
18
22
  });
23
+ /**
24
+ * THE `rtp.codex` REPORT, IN THE ONE SHAPE (runtimeCapabilities.mjs
25
+ * `normalizeMcpReport`, 2026-10-10): the server's charset, names clipped to
26
+ * its 60 rather than dropped past 80, sorted, at most its 40 — before, names
27
+ * of 61–80 characters and rows 41–80 were cut silently at the boundary.
28
+ */
29
+ const codexMcpReport = (entries) => normalizeMcpReport(entries, { charset: CODEX_MCP_NAME });
19
30
 
20
31
  export function localCodexOfferings({ env = process.env, runCodex = run, read = readFileSync, list = readdirSync } = {}) {
21
32
  const result = {};
22
33
  try {
23
- const cachePath = join(home(env), 'models_cache.json');
34
+ const cachePath = join(codexHome(env), 'models_cache.json');
24
35
  if (read === readFileSync && statSync(cachePath).size > 2 * 1024 * 1024) throw Error('model cache too large');
25
36
  const data = JSON.parse(read(cachePath, 'utf8'));
26
37
  if (Array.isArray(data.models)) result.models = data.models
@@ -35,27 +46,98 @@ export function localCodexOfferings({ env = process.env, runCodex = run, read =
35
46
  result.capabilities = lines.some((line) => /^image_generation\s+\S+\s+true\s*$/.test(line)) ? ['image'] : [];
36
47
  } catch { /* a failed local probe is unknown */ }
37
48
  try {
38
- result.skills = list(join(home(env), 'skills'), { withFileTypes: true })
49
+ result.skills = list(join(codexHome(env), 'skills'), { withFileTypes: true })
39
50
  .filter((entry) => entry.isDirectory() && nameOk(entry.name))
40
51
  .map((entry) => entry.name).slice(0, 80);
41
52
  } catch (error) { if (error?.code === 'ENOENT') result.skills = []; }
42
53
  try {
43
54
  const servers = JSON.parse(runCodex(['mcp', 'list', '--json'], env));
44
- if (Array.isArray(servers)) result.mcp = servers
55
+ if (Array.isArray(servers)) result.servers = codexServerRows(servers, env);
56
+ if (Array.isArray(servers)) result.mcp = codexMcpReport(servers
45
57
  // Unknown/unsupported auth is not proof of disconnection. Keep their
46
58
  // names local, just as connected servers stay local.
47
- .filter((s) => nameOk(s?.name) && ['needs_auth', 'failed'].includes(s.auth_status))
48
- .slice(0, 80)
49
- .map((s) => ({ n: s.name, s: s.auth_status === 'needs_auth' ? 'needs-auth' : 'failed' }));
59
+ .filter((s) => typeof s?.name === 'string' && ['needs_auth', 'failed'].includes(s.auth_status))
60
+ .map((s) => ({ name: s.name, status: s.auth_status === 'needs_auth' ? 'needs-auth' : 'failed' })));
50
61
  } catch { /* no read is unknown */ }
51
62
  return result;
52
63
  }
53
64
 
54
- export function runtimeOfferings(repoRoot, baseRef, { refresh = false } = {}) {
65
+ /** A bare command found on PATH, or an absolute one that is executable. */
66
+ function commandFound(command, env) {
67
+ if (isAbsolute(command)) {
68
+ try { accessSync(command, constants.X_OK); return true; } catch { return false; }
69
+ }
70
+ if (command.includes('/')) return null; // relative to a cwd Codex picks: unknown
71
+ return (env.PATH ?? '').split(delimiter).some((dir) => {
72
+ try { accessSync(join(dir, command), constants.X_OK); return true; } catch { return false; }
73
+ });
74
+ }
75
+
76
+ /**
77
+ * EVERY SERVER IN THIS MACHINE'S CODEX CONFIG, AS `codex mcp list --json`
78
+ * NAMES IT (2026-10-13) — `[{ name, status, reason }]` for the box's MCP
79
+ * inventory (mcpInventory.mjs). Codex reports no connection state without
80
+ * starting a server, so a server it would start reads `configured`; what
81
+ * this daemon can tell cheaply is said instead: `disabled` (Codex's own
82
+ * `enabled = false`), `needs-auth` (its OAuth status says not logged in),
83
+ * `failed` with the reason when a stdio command is not found or an env
84
+ * variable it reads a token or header from is unset. MEASURED on codex-cli
85
+ * 0.159.2: entries carry `name`, `enabled`, `disabled_reason`, `transport`
86
+ * (`stdio` with `command`; `streamable_http` with `bearer_token_env_var` and
87
+ * `env_http_headers`) and `auth_status` (`unsupported` for both kinds). A
88
+ * reason names a command (its basename) or a variable, never a value, a path
89
+ * or a URL.
90
+ */
91
+ export function codexServerRows(servers, env = process.env) {
92
+ const rows = [];
93
+ for (const s of servers) {
94
+ if (!s || typeof s.name !== 'string' || !nameOk(s.name)) continue;
95
+ const t = s.transport && typeof s.transport === 'object' ? s.transport : {};
96
+ const auth = typeof s.auth_status === 'string' ? s.auth_status : '';
97
+ let status = 'configured';
98
+ let reason = null;
99
+ const unset = [
100
+ ...(typeof t.bearer_token_env_var === 'string' ? [t.bearer_token_env_var] : []),
101
+ ...(t.env_http_headers && typeof t.env_http_headers === 'object' ? Object.values(t.env_http_headers).filter((v) => typeof v === 'string') : []),
102
+ ].filter((name) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(name) && !env[name]);
103
+ if (s.enabled === false) {
104
+ status = 'disabled';
105
+ reason = typeof s.disabled_reason === 'string' ? s.disabled_reason.slice(0, 200) : null;
106
+ } else if (typeof t.command === 'string' && commandFound(t.command, env) === false) {
107
+ status = 'failed';
108
+ reason = `command ${basename(t.command).slice(0, 60)} not found`;
109
+ } else if (unset.length) {
110
+ status = 'failed';
111
+ reason = `env ${[...new Set(unset)].sort().join(', ')} unset`;
112
+ } else if (['needs_auth', 'not_logged_in', 'notLoggedIn'].includes(auth)) {
113
+ status = 'needs-auth';
114
+ } else if (auth === 'failed') {
115
+ status = 'failed';
116
+ }
117
+ rows.push({ name: s.name, status, reason });
118
+ }
119
+ return rows;
120
+ }
121
+
122
+ function codexOfferings(refresh = false) {
55
123
  if (!cached || refresh || Date.now() - cachedAt >= TTL) {
56
124
  cached = localCodexOfferings();
57
125
  cachedAt = Date.now();
58
126
  }
127
+ return cached;
128
+ }
129
+
130
+ /** This machine's Codex servers for the inventory, or null when the local
131
+ * read failed (unknown). Shares the offerings' five-minute cache. */
132
+ export function codexMcpServers({ refresh = false } = {}) {
133
+ return codexOfferings(refresh).servers ?? null;
134
+ }
135
+
136
+ /** Existing model evidence only: a brief never probes another CLI or vendor. */
137
+ export function knownCodexModels() { return cached?.models ?? []; }
138
+
139
+ export function runtimeOfferings(repoRoot, baseRef, { refresh = false } = {}) {
140
+ codexOfferings(refresh);
59
141
  const out = { rtm: {}, rtc: {}, rts: {}, rtp: {} };
60
142
  if (cached.models) out.rtm.codex = cached.models;
61
143
  if (cached.capabilities) out.rtc.codex = cached.capabilities;
@@ -68,11 +150,14 @@ export function runtimeOfferings(repoRoot, baseRef, { refresh = false } = {}) {
68
150
  }
69
151
  const snapshot = repoCached.snapshot;
70
152
  if (out.rts.codex) out.rts.codex = [...new Set([...out.rts.codex, ...snapshot.skills.map((s) => s.path.split('/')[2])])].slice(0, 80);
71
- if (out.rtp.codex) out.rtp.codex = [...new Map([...out.rtp.codex, ...snapshot.tools
153
+ // The repo's rows FIRST: a name both report reads as the repo's word, as
154
+ // the merge it replaced kept (its last write won).
155
+ if (out.rtp.codex) out.rtp.codex = codexMcpReport([...snapshot.tools
72
156
  // A repo server Codex refuses (projectToolRuntimes.mjs owns which) is
73
157
  // one this machine cannot connect for Codex, like a broken one.
74
158
  .filter((t) => t.state !== 'ready' || mcpRefusal('codex', t.config) != null)
75
- .map((t) => ({ n: t.name, s: 'failed' }))].map((server) => [server.n, server])).values()].slice(0, 80);
159
+ .map((t) => ({ name: t.name, status: 'failed' })),
160
+ ...out.rtp.codex.map((r) => ({ name: r.n, status: r.s }))]);
76
161
  } catch { /* keep local measurements */ }
77
162
  return out;
78
163
  }
@@ -101,6 +101,16 @@ export const RUNTIMES = {
101
101
  */
102
102
  export const runtimeById = (id) => (typeof id === 'string' && Object.hasOwn(RUNTIMES, id) ? RUNTIMES[id] : null);
103
103
 
104
+ /**
105
+ * The name a sentence uses for a runtime id: its label when this daemon
106
+ * declares it, else the id as given — an unknown id is relayed, never renamed.
107
+ * Through `runtimeById`, so an id off the wire or a marker file that happens
108
+ * to name a prototype key (`constructor`, `__proto__`) reads as the unknown
109
+ * string it is (audit 2026-10-10, F125). Every lookup of a runtime by id goes
110
+ * through these two; the registry is subscripted nowhere else.
111
+ */
112
+ export const runtimeLabel = (id) => runtimeById(id)?.label ?? String(id);
113
+
104
114
  /**
105
115
  * Can the worker this daemon is running actually DRIVE this runtime right now?
106
116
  *
@@ -1,4 +1,4 @@
1
- import { baseBranchName } from './git.mjs';
1
+ import { baseBranchName, isSafePathSegment, sessionBranch } from './git.mjs';
2
2
 
3
3
  /**
4
4
  * RETIRE A BRANCH FLOWVIANT MADE AND FLOWVIANT MERGED.
@@ -40,10 +40,12 @@ import { baseBranchName } from './git.mjs';
40
40
  * nothing to clean on a remote.
41
41
  */
42
42
  export function sweepMergedBranch(sessionId, { git, repoRoot, baseRef, note, isReportPending }) {
43
- if (!/^[A-Za-z0-9_-]{1,128}$/.test(String(sessionId ?? ''))) return false;
43
+ // The NAME is git.mjs's one definition, behind the same segment rule every
44
+ // place passes (audit 2026-10-10, F122) — not a second spelling of either.
45
+ if (!isSafePathSegment(sessionId)) return false;
44
46
  // The report has not landed. See ORDERING above.
45
47
  if (isReportPending?.(sessionId)) return false;
46
- const name = `session/${sessionId}`;
48
+ const name = sessionBranch(sessionId);
47
49
  try {
48
50
  git(['rev-parse', '--verify', '--quiet', `refs/heads/${name}`], repoRoot);
49
51
  } catch {
package/bin/lib/shot.mjs CHANGED
@@ -20,12 +20,25 @@
20
20
  * - A bare VM with no browser AND no system libs: probing/render fails →
21
21
  * graceful text-evidence fallback with an `apt install chromium` hint.
22
22
  * - FLOWVIANT_CHROME / CHROME_PATH override wins over all discovery.
23
+ *
24
+ * THE SANDBOX STAYS ON, AND CHROME NEVER HOLDS THE MACHINE CREDENTIAL
25
+ * (audit 2026-10-10, F081/F135). Every page this file opens is one somebody
26
+ * else wrote: a design artifact the model authored from a member's card, or
27
+ * the URL an agent passed to `flowviant shot`. `--no-sandbox` used to ride
28
+ * every launch "because root and some VMs need it", which turned any renderer
29
+ * bug into code execution as the operator. It is now passed only where it is
30
+ * needed: as root (Chrome refuses to start sandboxed there),
31
+ * `FLOWVIANT_CHROME_NO_SANDBOX=1` (the operator's own call for a box whose
32
+ * sandbox cannot start), or ONE retry after a launch whose own stderr says the
33
+ * sandbox could not start — a measured failure, never a guess. The browser's
34
+ * environment is the daemon's minus the machine credential (`machineEnv.mjs`).
23
35
  */
24
36
 
25
37
  import { spawn, execFileSync } from 'node:child_process';
26
38
  import { existsSync, statSync, readFileSync, mkdtempSync, rmSync } from 'node:fs';
27
39
  import { join } from 'node:path';
28
40
  import { tmpdir, platform } from 'node:os';
41
+ import { withoutMachineCredentials } from './machineEnv.mjs';
29
42
 
30
43
  // ── Browser discovery ───────────────────────────────────────────────────────
31
44
 
@@ -99,13 +112,51 @@ export function resolveBrowser() {
99
112
  return null;
100
113
  }
101
114
 
115
+ // ── Launch policy ─────────────────────────────────────────────────────────
116
+
117
+ /** The operator's opt-out for a box whose sandbox cannot start (a container
118
+ * without user namespaces). Exactly '1'; anything else keeps it on. */
119
+ export const NO_SANDBOX_ENV = 'FLOWVIANT_CHROME_NO_SANDBOX';
120
+
121
+ /** Chrome's own words when its sandbox cannot start: the zygote's "No usable
122
+ * sandbox!", the setuid helper's complaint, the root refusal, and a namespace
123
+ * the kernel would not grant. Only a launch that FAILED is read for these. */
124
+ export const SANDBOX_FAILURE_RE =
125
+ /No usable sandbox|SUID sandbox helper|without --no-sandbox|Failed to move to new namespace/i;
126
+
127
+ /** True when Chrome must start without its sandbox before anything is
128
+ * measured: as root (Chrome refuses to start sandboxed), or on the operator's
129
+ * explicit `FLOWVIANT_CHROME_NO_SANDBOX=1`. */
130
+ export function sandboxOff({ uid = process.getuid?.(), env = process.env } = {}) {
131
+ return uid === 0 || env?.[NO_SANDBOX_ENV] === '1';
132
+ }
133
+
134
+ /** The environment every Chrome this daemon starts runs in. */
135
+ export const chromeEnv = (env = process.env) => withoutMachineCredentials(env);
136
+
137
+ /** The flags every launch shares. `--no-sandbox` only when `noSandbox`. */
138
+ export function chromeFlags({ headlessFlag, noSandbox, userDataDir, width, height }) {
139
+ return [
140
+ headlessFlag,
141
+ '--disable-gpu',
142
+ ...(noSandbox ? ['--no-sandbox'] : []),
143
+ '--disable-dev-shm-usage', // small /dev/shm in containers/VMs crashes Chrome otherwise
144
+ '--hide-scrollbars',
145
+ '--force-color-profile=srgb',
146
+ '--no-first-run',
147
+ '--disable-extensions',
148
+ `--user-data-dir=${userDataDir}`,
149
+ `--window-size=${width},${height}`,
150
+ ];
151
+ }
152
+
102
153
  // ── Capture ─────────────────────────────────────────────────────────────────
103
154
 
104
155
  function toWinPath(p) {
105
156
  return execFileSync('wslpath', ['-w', p], { encoding: 'utf8' }).trim();
106
157
  }
107
158
 
108
- function runChrome(browser, { url, out, width, height, headlessFlag, timeoutMs }) {
159
+ function runChrome(browser, { url, out, width, height, headlessFlag, timeoutMs, noSandbox }, { spawnImpl, env }) {
109
160
  return new Promise((resolve) => {
110
161
  const userDataDir = mkdtempSync(join(tmpdir(), 'flowviant-shot-'));
111
162
  const cleanup = () => { try { rmSync(userDataDir, { recursive: true, force: true }); } catch { /* best effort */ } };
@@ -124,14 +175,7 @@ function runChrome(browser, { url, out, width, height, headlessFlag, timeoutMs }
124
175
  }
125
176
 
126
177
  const args = [
127
- headlessFlag,
128
- '--disable-gpu',
129
- '--no-sandbox', // required as root / in many VMs + containers
130
- '--disable-dev-shm-usage', // small /dev/shm in containers/VMs crashes Chrome otherwise
131
- '--hide-scrollbars',
132
- '--force-color-profile=srgb',
133
- `--user-data-dir=${udArg}`,
134
- `--window-size=${width},${height}`,
178
+ ...chromeFlags({ headlessFlag, noSandbox, userDataDir: udArg, width, height }),
135
179
  '--virtual-time-budget=5000', // let fonts/JS settle before capture (SPAs)
136
180
  `--screenshot=${outArg}`,
137
181
  url,
@@ -140,7 +184,7 @@ function runChrome(browser, { url, out, width, height, headlessFlag, timeoutMs }
140
184
  let err = '';
141
185
  let child;
142
186
  try {
143
- child = spawn(browser.bin, args, { stdio: ['ignore', 'ignore', 'pipe'] });
187
+ child = spawnImpl(browser.bin, args, { stdio: ['ignore', 'ignore', 'pipe'], env: chromeEnv(env) });
144
188
  } catch (e) {
145
189
  cleanup();
146
190
  return resolve({ ok: false, reason: 'spawn', message: e.message });
@@ -157,15 +201,34 @@ function runChrome(browser, { url, out, width, height, headlessFlag, timeoutMs }
157
201
  cleanup();
158
202
  if (existsSync(out) && statSync(out).size > 0) return resolve({ ok: true, path: out });
159
203
  const tail = err.trim().split('\n').slice(-2).join(' ');
160
- resolve({ ok: false, reason: 'render', message: `Chrome exited (code ${code}) without an image.${tail ? ' ' + tail : ''}` });
204
+ resolve({
205
+ ok: false,
206
+ reason: 'render',
207
+ message: `Chrome exited (code ${code}) without an image.${tail ? ' ' + tail : ''}`,
208
+ sandboxFailed: !noSandbox && SANDBOX_FAILURE_RE.test(err),
209
+ });
161
210
  });
162
211
  });
163
212
  }
164
213
 
165
214
  /** Capture `url` to a PNG at `out`. Tries new headless, then classic headless
166
- * (older Chromium). Resolves { ok, path } or { ok:false, reason, message }. */
167
- export async function captureScreenshot({ url, out, width = 1440, height = 900, timeoutMs = 60_000 }) {
168
- const browser = resolveBrowser();
215
+ * (older Chromium), sandboxed unless `sandboxOff` — and at most ONE launch
216
+ * without it, after a sandboxed launch that said its sandbox could not start.
217
+ * Resolves { ok, path } or { ok:false, reason, message }.
218
+ *
219
+ * `browser`, `spawn`, `uid` and `env` are injectable so the launch policy is
220
+ * provable without a browser; nothing in the daemon passes them. */
221
+ export async function captureScreenshot({
222
+ url,
223
+ out,
224
+ width = 1440,
225
+ height = 900,
226
+ timeoutMs = 60_000,
227
+ browser = resolveBrowser(),
228
+ spawn: spawnImpl = spawn,
229
+ uid = process.getuid?.(),
230
+ env = process.env,
231
+ }) {
169
232
  if (!browser) {
170
233
  return {
171
234
  ok: false,
@@ -173,12 +236,23 @@ export async function captureScreenshot({ url, out, width = 1440, height = 900,
173
236
  message: 'No Chrome/Chromium/Edge found. Install one (Linux: `sudo apt install chromium`) to capture screenshot evidence.',
174
237
  };
175
238
  }
176
- let r = await runChrome(browser, { url, out, width, height, headlessFlag: '--headless=new', timeoutMs });
239
+ let noSandbox = sandboxOff({ uid, env });
240
+ const attempt = async (headlessFlag) => {
241
+ const run = () => runChrome(browser, { url, out, width, height, headlessFlag, timeoutMs, noSandbox }, { spawnImpl, env });
242
+ let r = await run();
243
+ if (!r.ok && r.sandboxFailed) {
244
+ noSandbox = true; // sticky: the one retry this capture gets
245
+ r = await run();
246
+ }
247
+ return r;
248
+ };
249
+ let r = await attempt('--headless=new');
177
250
  if (!r.ok && r.reason === 'render') {
178
251
  // Older Chromium rejects `--headless=new` — retry with classic headless.
179
- r = await runChrome(browser, { url, out, width, height, headlessFlag: '--headless', timeoutMs });
252
+ r = await attempt('--headless');
180
253
  }
181
- return r;
254
+ const { sandboxFailed: _measured, ...result } = r;
255
+ return result;
182
256
  }
183
257
 
184
258
  // ── CLI ───────────────────────────────────────────────────────────────────
@@ -0,0 +1,11 @@
1
+ /**
2
+ * WAIT N SECONDS — a leaf with no imports (2026-10-10, the audit).
3
+ *
4
+ * It lived in claude.mjs, the module that hands a coding CLI its MCP server,
5
+ * which imports the runtime registry (runtimes.mjs) and through it every
6
+ * vendor's adapter. So `flowviant login`, the one command that has to work on
7
+ * a fresh box, loaded every runtime module just to wait between two polls, and
8
+ * a fault importing any of them could break it. login.mjs, fleet.mjs and
9
+ * holder.mjs import it from here; nothing here may import anything.
10
+ */
11
+ export const sleep = (s) => new Promise((r) => setTimeout(r, s * 1000));
@@ -17,18 +17,18 @@
17
17
  */
18
18
  import { FLEET_TOKEN, CREDENTIAL, adoptStoredCredential } from './config.mjs';
19
19
  import { runLogin } from './login.mjs';
20
- import { terminalCommand } from './launchCommand.mjs';
20
+ import { relaunchSelf, selfArgv, terminalCommand } from './launchCommand.mjs';
21
21
  import { canPrompt, askWithTimeout, selectMenu, menuSupported } from './tty.mjs';
22
22
 
23
23
  /** Re-exec a plain `flowviant` after an inline login — the login command's own
24
24
  * pattern: config.mjs read the store at IMPORT time, before the credential
25
- * existed, so this process cannot serve; the child can. */
25
+ * existed, so this process cannot serve; the child can. Through the one
26
+ * relaunch proxy (launchCommand.mjs), which passes signals on and reads a
27
+ * child killed by one as a failure. */
26
28
  async function reexecAfterLogin() {
27
29
  const login = await runLogin({ thenStart: false });
28
30
  if (!login?.saved) process.exit(1); // refused: the directory already serves another project
29
- const { spawn } = await import('node:child_process');
30
- const child = spawn(process.execPath, process.argv[1]?.startsWith('/$bunfs/') ? [] : [process.argv[1]], { stdio: 'inherit', env: process.env });
31
- process.exit(await new Promise((resolve) => child.on('exit', (code) => resolve(code ?? 0))));
31
+ await relaunchSelf({ argv: selfArgv(), env: process.env });
32
32
  }
33
33
 
34
34
  // TWO ROWS THAT READ ALIKE ARE NOT A CHOICE. `projectRowLabel` adds the id and
@@ -0,0 +1,40 @@
1
+ /**
2
+ * WHERE THE DAEMON KEEPS A CHECKOUT'S WORKTREES — one derivation (audit
3
+ * 2026-10-10, F075).
4
+ *
5
+ * The per-checkout worktree home, `~/.flowviant/worktrees/<basename>-<8 hex
6
+ * of sha256(repoRoot)>`, was written twice — fleetStartup.worktreeHome and
7
+ * deployCheckout's fallback — and its parent a third time in `flowviant
8
+ * clean`. Two copies of a key can drift silently, and a drift here is a
9
+ * deploy checkout landing somewhere the startup reap and the machine's disk
10
+ * line never look, while `clean` still sweeps the parent. So every reader
11
+ * asks this leaf.
12
+ *
13
+ * A LEAF: node built-ins only, so a command that must stay light (`clean`)
14
+ * can import it without the daemon's config. Nothing here makes a directory —
15
+ * the caller that needs one (worktreeHome) makes it. `homedir()` is read AT
16
+ * CALL TIME, so a test (or anything else) that sets HOME after import is
17
+ * heard.
18
+ *
19
+ * THE KEY IS NOT TO BE CHANGED casually: a different key strands every
20
+ * persisted worktree and deploy scratch under the old one, outside the reap.
21
+ */
22
+
23
+ import { createHash } from 'node:crypto';
24
+ import { homedir } from 'node:os';
25
+ import { basename, join } from 'node:path';
26
+
27
+ /** `~/.flowviant/worktrees` — the parent of every checkout's worktree home. */
28
+ export function worktreesRoot() {
29
+ return join(homedir(), '.flowviant', 'worktrees');
30
+ }
31
+
32
+ /** `<basename>-<first 8 hex of sha256(repoRoot)>` — the checkout's key. */
33
+ export function worktreeHomeKey(repoRoot) {
34
+ return `${basename(repoRoot)}-${createHash('sha256').update(repoRoot).digest('hex').slice(0, 8)}`;
35
+ }
36
+
37
+ /** The checkout's worktree home, NOT created. */
38
+ export function worktreeHomeDir(repoRoot) {
39
+ return join(worktreesRoot(), worktreeHomeKey(repoRoot));
40
+ }
package/bin/lib/tty.mjs CHANGED
@@ -44,16 +44,20 @@
44
44
  * they are what keeps a backgrounded job from freezing.
45
45
  */
46
46
 
47
- import { readFileSync } from 'node:fs';
48
47
  import { execFileSync } from 'node:child_process';
48
+ import { procStat } from './procStat.mjs';
49
49
 
50
50
  /**
51
- * Is this process in the controlling terminal's foreground process group?
51
+ * WHERE THIS PROCESS STANDS ON ITS TERMINAL: `true` measured in the
52
+ * controlling terminal's foreground process group, `false` measured in the
53
+ * background, `null` when there is no controlling terminal or it could not be
54
+ * read. `inForeground` folds the two non-answers into "foreground"; the
55
+ * relaunch proxy (launchCommand.mjs) needs them apart.
52
56
  *
53
57
  * Linux: BOTH numbers come out of one `/proc/self/stat` read — field 5 is our
54
58
  * own `pgrp` and field 8 is `tpgid`, the foreground group of our controlling
55
- * terminal. Parsed from AFTER the last ')' because field 2 is the executable
56
- * name and may itself contain spaces and parentheses.
59
+ * terminal — through procStat.mjs, because field 2 is the executable name and
60
+ * may itself contain spaces and parentheses.
57
61
  *
58
62
  * NOT `process.getpgrp()`: it DOES NOT EXIST in Node (verified on 24.16 — it
59
63
  * throws `TypeError: process.getpgrp is not a function`). The first cut of this
@@ -64,18 +68,15 @@ import { execFileSync } from 'node:child_process';
64
68
  * isolation on a real pty; nothing else would have shown it, because the
65
69
  * fallback it degraded to still behaves acceptably.
66
70
  *
67
- * Elsewhere: ask `ps` for both. Unknown: assume foreground (see the header).
71
+ * Elsewhere: ask `ps` for both.
68
72
  */
69
- export function inForeground() {
73
+ export function terminalForeground() {
70
74
  try {
71
75
  if (process.platform === 'linux') {
72
- const stat = readFileSync('/proc/self/stat', 'utf8');
73
- const after = stat.slice(stat.lastIndexOf(')') + 1).trim().split(/\s+/);
74
- const pgrp = Number(after[2]); // state ppid PGRP session tty_nr tpgid
75
- const tpgid = Number(after[5]);
76
- if (!Number.isFinite(tpgid) || !Number.isFinite(pgrp)) return true;
77
- if (tpgid <= 0) return true; // no controlling terminal — nothing to be behind
78
- return tpgid === pgrp;
76
+ const stat = procStat('self');
77
+ if (!stat) return null;
78
+ if (stat.tpgid <= 0) return null; // no controlling terminal — nothing to be behind
79
+ return stat.tpgid === stat.pgrp;
79
80
  }
80
81
  const out = execFileSync('ps', ['-o', 'tpgid=,pgid=', '-p', String(process.pid)], {
81
82
  encoding: 'utf8',
@@ -84,13 +85,22 @@ export function inForeground() {
84
85
  }).trim().split(/\s+/);
85
86
  const tpgid = Number(out[0]);
86
87
  const pgrp = Number(out[1]);
87
- if (!Number.isFinite(tpgid) || !Number.isFinite(pgrp) || tpgid <= 0) return true;
88
+ if (!Number.isFinite(tpgid) || !Number.isFinite(pgrp) || tpgid <= 0) return null;
88
89
  return tpgid === pgrp;
89
90
  } catch {
90
- return true;
91
+ return null;
91
92
  }
92
93
  }
93
94
 
95
+ /**
96
+ * Is this process in the controlling terminal's foreground process group?
97
+ * No controlling terminal has nothing to be behind, and unknown assumes
98
+ * foreground (see the header): only a MEASURED background answers false.
99
+ */
100
+ export function inForeground() {
101
+ return terminalForeground() !== false;
102
+ }
103
+
94
104
  /** A human is at this terminal AND can actually be reached by a question. */
95
105
  export function canPrompt() {
96
106
  return Boolean(process.stdin.isTTY && process.stdout.isTTY) && inForeground();
@@ -0,0 +1,93 @@
1
+ /** Local, correlated CLI lifecycle evidence. No prompts, answers, argv, token
2
+ * values or token fingerprints are logged. Credential metadata is remeasured
3
+ * at close so a refresh/rotation can be distinguished from a stale environment.
4
+ * Logging is best effort and rides the daemon's existing bounded private log.
5
+ */
6
+ import { randomUUID } from 'node:crypto';
7
+ import { accessSync, constants, readFileSync, realpathSync, statSync } from 'node:fs';
8
+ import { delimiter, isAbsolute, resolve } from 'node:path';
9
+ import { AUTH_ENV_VARS, claudeAuthContext, claudeCredentialPath } from './claudeAuth.mjs';
10
+ import { scrub } from './uplinkScrub.mjs';
11
+ import { printableText } from './printable.mjs';
12
+ import { measuredRuntimeVersion } from './runtimeDetection.mjs';
13
+
14
+ let active = 0;
15
+ const authNames = (env) => AUTH_ENV_VARS.filter((name) =>
16
+ typeof env[name] === 'string' && env[name].trim() !== '');
17
+ function executableFor(bin, env, cwd) {
18
+ const candidates = isAbsolute(bin) || bin.includes('/')
19
+ ? [resolve(cwd, bin)]
20
+ : (env.PATH ?? '').split(delimiter).map((dir) => resolve(cwd, dir, bin));
21
+ for (const candidate of candidates) {
22
+ try { accessSync(candidate, constants.X_OK); return realpathSync(candidate); } catch { /* next */ }
23
+ }
24
+ return null;
25
+ }
26
+ function authFor(env) {
27
+ const context = claudeAuthContext({ env });
28
+ const credentialPath = claudeCredentialPath(env);
29
+ let file = null;
30
+ try {
31
+ const stat = statSync(credentialPath);
32
+ file = { modifiedAt: new Date(stat.mtimeMs).toISOString(), inode: stat.ino };
33
+ } catch { /* absent or keychain: unknown */ }
34
+ return { ...context, configDir: env.CLAUDE_CONFIG_DIR || null, credentialPath, file };
35
+ }
36
+
37
+ export function startTurnDiagnostics(options) {
38
+ try { return createTurnDiagnostics(options); }
39
+ catch { return () => {}; }
40
+ }
41
+
42
+ function createTurnDiagnostics({ runtime, bin, env, cwd = process.cwd(), profile,
43
+ planId = null, args = [], version, write = (row) => console.info(`[cli-turn] ${JSON.stringify(row)}`) }) {
44
+ const startedAt = Date.now();
45
+ const turnId = randomUUID();
46
+ const executable = executableFor(bin, env, cwd);
47
+ let settingsEnv = {};
48
+ if (runtime === 'claude') {
49
+ const at = args.indexOf('--settings');
50
+ if (at >= 0) {
51
+ try {
52
+ const input = args[at + 1];
53
+ const settings = JSON.parse(input.startsWith('{') ? input : readFileSync(input, 'utf8'));
54
+ if (settings.env && typeof settings.env === 'object' && !Array.isArray(settings.env)) {
55
+ for (const [key, value] of Object.entries(settings.env))
56
+ if (typeof value === 'string') settingsEnv[key] = value;
57
+ }
58
+ } catch { /* CLI settings unavailable */ }
59
+ }
60
+ }
61
+ const base = { turnId, runtime, profile, planId, cwd, daemonPid: process.pid,
62
+ uid: process.getuid?.() ?? null, platform: process.platform,
63
+ nodeVersion: process.version, executable, version: version ?? null,
64
+ runtimeMeasurement: measuredRuntimeVersion(runtime) };
65
+ const auth = () => {
66
+ try {
67
+ return runtime === 'claude' ? { ...authFor({ ...env, ...settingsEnv }),
68
+ inheritedAuthVars: authNames(env),
69
+ settingsAuthVars: authNames(settingsEnv) } : null;
70
+ } catch { return null; }
71
+ };
72
+ const emit = (row) => {
73
+ try {
74
+ // Scrub strings even in the allowlisted metadata: paths/user names are
75
+ // operator supplied. No environment object or error body reaches here.
76
+ write(JSON.parse(scrub(printableText(JSON.stringify(row)))));
77
+ } catch { /* diagnostics never fail a turn */ }
78
+ };
79
+ active++;
80
+ emit({ ...base, phase: 'start', at: new Date(startedAt).toISOString(), activeTurns: active, auth: auth() });
81
+ let finished = false;
82
+ return ({ code = null, signal = null, pid = null, output = '', spawnError = null, cliFailed = false } = {}) => {
83
+ if (finished) return;
84
+ finished = true;
85
+ active--;
86
+ const failed = cliFailed || (code !== null && code !== 0);
87
+ emit({ ...base, phase: 'end', at: new Date().toISOString(), durationMs: Date.now() - startedAt,
88
+ pid, code, signal, spawnError, activeTurns: active, auth: auth(),
89
+ failureKind: failed && /OAuth session expired and could not be refreshed/i.test(output)
90
+ ? 'oauth_refresh' : failed && /Failed to authenticate/i.test(output) ? 'authentication'
91
+ : spawnError ? 'spawn' : code !== null && code !== 0 ? 'cli_exit' : null });
92
+ };
93
+ }