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
@@ -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
3
  import { readFileSync, readdirSync, statSync } from 'node:fs';
4
- import { homedir } from 'node:os';
5
4
  import { 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,18 +46,17 @@ 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.mcp = codexMcpReport(servers
45
56
  // Unknown/unsupported auth is not proof of disconnection. Keep their
46
57
  // 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' }));
58
+ .filter((s) => typeof s?.name === 'string' && ['needs_auth', 'failed'].includes(s.auth_status))
59
+ .map((s) => ({ name: s.name, status: s.auth_status === 'needs_auth' ? 'needs-auth' : 'failed' })));
50
60
  } catch { /* no read is unknown */ }
51
61
  return result;
52
62
  }
@@ -68,11 +78,14 @@ export function runtimeOfferings(repoRoot, baseRef, { refresh = false } = {}) {
68
78
  }
69
79
  const snapshot = repoCached.snapshot;
70
80
  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
81
+ // The repo's rows FIRST: a name both report reads as the repo's word, as
82
+ // the merge it replaced kept (its last write won).
83
+ if (out.rtp.codex) out.rtp.codex = codexMcpReport([...snapshot.tools
72
84
  // A repo server Codex refuses (projectToolRuntimes.mjs owns which) is
73
85
  // one this machine cannot connect for Codex, like a broken one.
74
86
  .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);
87
+ .map((t) => ({ name: t.name, status: 'failed' })),
88
+ ...out.rtp.codex.map((r) => ({ name: r.n, status: r.s }))]);
76
89
  } catch { /* keep local measurements */ }
77
90
  return out;
78
91
  }
@@ -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();
@@ -10,7 +10,7 @@
10
10
  * means is this module's.
11
11
  */
12
12
  import { existsSync, readFileSync, rmSync } from 'node:fs';
13
- import { processStartTime } from './procRegistry.mjs';
13
+ import { aliveAndOurs, processStartTime } from './procRegistry.mjs';
14
14
 
15
15
  /**
16
16
  * The spawn lock: the pid of the CLI currently live in this worktree. A
@@ -45,19 +45,12 @@ export const turnLockedByLivePid = (lockPath) => {
45
45
  } catch {
46
46
  /* unreadable — treat as stale */
47
47
  }
48
- if (Number.isInteger(pid) && pid > 0) {
49
- let alive = false;
50
- try {
51
- process.kill(pid, 0);
52
- alive = true; // signal 0 delivered — a process of OUR uid holds the pid
53
- } catch {
54
- /* ESRCH: gone. EPERM: another uid's process — never our CLI. Stale. */
55
- }
56
- if (alive) {
57
- const now = start ? processStartTime(pid) : null;
58
- // Unmeasurable start time is "cannot tell", which keeps the lock.
59
- if (!start || !now || now === start) return true;
60
- }
48
+ // Signal 0 delivered to a process of OUR uid; EPERM is another uid's process,
49
+ // never our CLI, so stale (procRegistry.aliveAndOurs).
50
+ if (aliveAndOurs(pid)) {
51
+ const now = start ? processStartTime(pid) : null;
52
+ // Unmeasurable start time is "cannot tell", which keeps the lock.
53
+ if (!start || !now || now === start) return true;
61
54
  }
62
55
  try {
63
56
  rmSync(lockPath, { force: true }); // dead holder — clear the stale lock
@@ -22,7 +22,7 @@
22
22
  * - design / research / image — a non-code card's posture
23
23
  * (agentTaskKinds.mjs), refused on any runtime that does not
24
24
  * DECLARE it, because every other adapter's fallback is its
25
- * build branch. research is Claude Code's; design is
25
+ * build branch. research is Claude Code's and isolated Codex's; design is
26
26
  * Claude Code's and, since 0.115.0, Codex's; image (0.114.0)
27
27
  * is Codex's.
28
28
  *
package/bin/lib/ui.mjs CHANGED
@@ -9,9 +9,6 @@ export const c = {
9
9
  blue: wrap(34), magenta: wrap(35), cyan: wrap(36), gray: wrap(90),
10
10
  };
11
11
 
12
- // Stable, cycled colours so each agent's label is easy to scan in a fleet.
13
- export const LABEL_COLORS = [c.cyan, c.magenta, c.blue, c.yellow, c.green, c.red];
14
-
15
12
  const stamp = () => {
16
13
  const d = new Date();
17
14
  const p = (n) => String(n).padStart(2, '0');
@@ -3,7 +3,9 @@ import { execFile as nodeExecFile } from 'node:child_process';
3
3
  import { homedir } from 'node:os';
4
4
  import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
5
5
  import { promisify } from 'node:util';
6
- import { runningCompiledBinary } from './update.mjs';
6
+ import { runningCompiledBinary } from './launchCommand.mjs';
7
+ import { listDaemonLocks } from './daemonLocks.mjs';
8
+ import { processAlive } from './procRegistry.mjs';
7
9
  import { VERSION } from './config.mjs';
8
10
  import { parseVersion } from './versionOrder.mjs';
9
11
  import { copyKind, exactBlock, inside } from './uninstallCopies.mjs';
@@ -18,7 +20,9 @@ function defaults(options = {}) {
18
20
  home: homedir(),
19
21
  path: process.env.PATH || '',
20
22
  execFile: (file, args, opts) => execFileAsync(file, args, opts),
21
- pidLive: (pid) => { try { process.kill(pid, 0); return true; } catch { return false; } },
23
+ // EPERM is alive (2026-10-10, F090/F123): a conservative alignment with the
24
+ // one probe — a pid we may not signal still protects the copy it may run.
25
+ pidLive: processAlive,
22
26
  procReader: (pid, file) => file === 'exe' || file === 'cwd'
23
27
  ? nodeFs.readlinkSync(`/proc/${pid}/${file}`) : nodeFs.readFileSync(`/proc/${pid}/${file}`),
24
28
  procAvailable: nodeFs.existsSync('/proc/self/cmdline'),
@@ -58,32 +62,32 @@ function compiledExecutable(fs, path) {
58
62
  } catch { return false; }
59
63
  }
60
64
 
65
+ // The lock files and their records come from the one scan instance.mjs uses
66
+ // (daemonLocks.mjs), through the injected fs and home; an unreadable lock is
67
+ // not evidence of a live daemon.
61
68
  function liveDaemons(o) {
62
69
  const found = [];
63
- for (const name of files(o.fs, join(o.home, '.flowviant'))) {
64
- if (!/^daemon-[0-9a-f]{12}\.lock$/.test(name)) continue;
65
- try {
66
- const pid = JSON.parse(o.fs.readFileSync(join(o.home, '.flowviant', name), 'utf8')).pid;
67
- if (!Number.isInteger(pid) || pid <= 0 || !o.pidLive(pid)) continue;
68
- let paths = [];
69
- if (o.procAvailable) {
70
- try {
71
- const exe = o.procReader(pid, 'exe');
72
- const command = o.procReader(pid, 'cmdline');
73
- const args = Buffer.from(command).toString('utf8').split('\0').filter(Boolean);
74
- let scripts = args.filter((arg) => isAbsolute(arg));
75
- const relative = args.filter((arg) => !arg.startsWith('-') && arg.includes('/') && !isAbsolute(arg));
76
- if (relative.length) {
77
- const cwd = String(o.procReader(pid, 'cwd'));
78
- scripts.push(...relative.map((arg) => resolve(cwd, arg)));
79
- }
80
- const executable = String(exe).replace(/ \(deleted\)$/, '');
81
- paths = basename(executable).startsWith('node') && !scripts.length ? [] :
82
- [executable, ...scripts].map((path) => real(o.fs, path));
83
- } catch { /* The live pid is still protected when its command is hidden. */ }
84
- }
85
- found.push({ pid, paths });
86
- } catch { /* An unreadable lock is not evidence of a live daemon. */ }
70
+ for (const { holder } of listDaemonLocks({ fs: o.fs, home: o.home })) {
71
+ if (!holder || !o.pidLive(holder.pid)) continue;
72
+ const pid = holder.pid;
73
+ let paths = [];
74
+ if (o.procAvailable) {
75
+ try {
76
+ const exe = o.procReader(pid, 'exe');
77
+ const command = o.procReader(pid, 'cmdline');
78
+ const args = Buffer.from(command).toString('utf8').split('\0').filter(Boolean);
79
+ let scripts = args.filter((arg) => isAbsolute(arg));
80
+ const relative = args.filter((arg) => !arg.startsWith('-') && arg.includes('/') && !isAbsolute(arg));
81
+ if (relative.length) {
82
+ const cwd = String(o.procReader(pid, 'cwd'));
83
+ scripts.push(...relative.map((arg) => resolve(cwd, arg)));
84
+ }
85
+ const executable = String(exe).replace(/ \(deleted\)$/, '');
86
+ paths = basename(executable).startsWith('node') && !scripts.length ? [] :
87
+ [executable, ...scripts].map((path) => real(o.fs, path));
88
+ } catch { /* The live pid is still protected when its command is hidden. */ }
89
+ }
90
+ found.push({ pid, paths });
87
91
  }
88
92
  return found;
89
93
  }