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.
- package/README.md +31 -12
- package/bin/cli.mjs +13 -17
- package/bin/lib/admission.mjs +7 -14
- package/bin/lib/agentInternalWait.mjs +83 -0
- package/bin/lib/agentPlan.mjs +56 -24
- package/bin/lib/agentPublish.mjs +12 -13
- package/bin/lib/agentTaskKinds.mjs +4 -7
- package/bin/lib/agySessions.mjs +3 -3
- package/bin/lib/artifactPreview.mjs +92 -18
- package/bin/lib/artifactReporter.mjs +16 -8
- package/bin/lib/artifacts.mjs +64 -60
- package/bin/lib/authproxy.mjs +2 -1
- package/bin/lib/childEnv.mjs +9 -4
- package/bin/lib/claude.mjs +10 -20
- package/bin/lib/claudePersonal.mjs +71 -6
- package/bin/lib/claudePosture.mjs +119 -42
- package/bin/lib/claudeSessions.mjs +7 -7
- package/bin/lib/claudeStream.mjs +7 -11
- package/bin/lib/cliLimit.mjs +2 -2
- package/bin/lib/codexHome.mjs +17 -0
- package/bin/lib/codexPersonal.mjs +44 -3
- package/bin/lib/codexRelay.mjs +21 -5
- package/bin/lib/codexResearch.mjs +180 -0
- package/bin/lib/config.mjs +37 -24
- package/bin/lib/conversationIds.mjs +51 -0
- package/bin/lib/credentials.mjs +2 -7
- package/bin/lib/daemonLocks.mjs +88 -0
- package/bin/lib/daemonState.mjs +5 -5
- package/bin/lib/deploy.mjs +6 -9
- package/bin/lib/deployCheckout.mjs +26 -22
- package/bin/lib/deployRunner.mjs +54 -18
- package/bin/lib/deployWire.mjs +32 -23
- package/bin/lib/desktopContract.mjs +3 -5
- package/bin/lib/env.mjs +104 -35
- package/bin/lib/fleet.mjs +7 -9
- package/bin/lib/fleetPost.mjs +172 -25
- package/bin/lib/fleetReports.mjs +130 -170
- package/bin/lib/fleetStartup.mjs +6 -6
- package/bin/lib/fleetWire.mjs +42 -0
- package/bin/lib/git.mjs +111 -61
- package/bin/lib/gitMergeIdentity.mjs +2 -8
- package/bin/lib/grant.mjs +23 -7
- package/bin/lib/holder.mjs +108 -81
- package/bin/lib/hooks/readGuard.mjs +41 -7
- package/bin/lib/install.mjs +80 -33
- package/bin/lib/instance.mjs +36 -83
- package/bin/lib/intakeDraft.mjs +4 -1
- package/bin/lib/knowledgeFileSync.mjs +31 -0
- package/bin/lib/knowledgeFiles.mjs +28 -83
- package/bin/lib/landed.mjs +22 -29
- package/bin/lib/launchCommand.mjs +91 -0
- package/bin/lib/listeners.mjs +0 -5
- package/bin/lib/localSessions.mjs +52 -39
- package/bin/lib/login.mjs +22 -12
- package/bin/lib/loginPath.mjs +22 -17
- package/bin/lib/machineListing.mjs +6 -3
- package/bin/lib/mcp-cli.mjs +9 -10
- package/bin/lib/noWindowEnv.mjs +23 -3
- package/bin/lib/ownedDirs.mjs +99 -0
- package/bin/lib/pinnedCapture.mjs +383 -0
- package/bin/lib/prWorkflow.mjs +94 -17
- package/bin/lib/preflight.mjs +8 -25
- package/bin/lib/preview.mjs +19 -25
- package/bin/lib/previewGatePolicy.mjs +34 -9
- package/bin/lib/previewRegistry.mjs +4 -11
- package/bin/lib/printable.mjs +70 -7
- package/bin/lib/procRegistry.mjs +33 -13
- package/bin/lib/procScan.mjs +2 -3
- package/bin/lib/procStat.mjs +76 -0
- package/bin/lib/processGroup.mjs +48 -0
- package/bin/lib/processes.mjs +10 -24
- package/bin/lib/projectToolRuntimes.mjs +101 -16
- package/bin/lib/projectTools.mjs +50 -32
- package/bin/lib/prompts.mjs +41 -19
- package/bin/lib/repoPath.mjs +19 -0
- package/bin/lib/repoState.mjs +17 -19
- package/bin/lib/runTurn.mjs +268 -210
- package/bin/lib/runtimeAntigravity.mjs +16 -5
- package/bin/lib/runtimeCapabilities.mjs +140 -20
- package/bin/lib/runtimeClaude.mjs +55 -5
- package/bin/lib/runtimeCodex.mjs +245 -205
- package/bin/lib/runtimeDetection.mjs +2 -2
- package/bin/lib/runtimeEvents.mjs +16 -10
- package/bin/lib/runtimeLimits.mjs +5 -6
- package/bin/lib/runtimeOfferings.mjs +24 -11
- package/bin/lib/runtimes.mjs +10 -0
- package/bin/lib/shipSweep.mjs +5 -3
- package/bin/lib/shot.mjs +91 -17
- package/bin/lib/sleep.mjs +11 -0
- package/bin/lib/startProject.mjs +5 -5
- package/bin/lib/statePaths.mjs +40 -0
- package/bin/lib/tty.mjs +25 -15
- package/bin/lib/turnLock.mjs +7 -14
- package/bin/lib/turnProfile.mjs +1 -1
- package/bin/lib/ui.mjs +0 -3
- package/bin/lib/uninstall.mjs +30 -26
- package/bin/lib/update.mjs +79 -27
- package/bin/lib/uplinkScrub.mjs +296 -31
- package/bin/lib/vault.mjs +78 -10
- package/bin/lib/vaultDiff.mjs +5 -2
- package/bin/lib/views.mjs +37 -12
- package/bin/lib/wikiRunner.mjs +40 -61
- package/bin/lib/work.mjs +17 -7
- package/bin/lib/workAdoptCarry.mjs +2 -4
- package/bin/lib/workAgentCheck.mjs +104 -57
- package/bin/lib/workAgentMerges.mjs +62 -57
- package/bin/lib/workAgentPlans.mjs +99 -145
- package/bin/lib/workAgentPrecheck.mjs +71 -138
- package/bin/lib/workAgentPublish.mjs +6 -8
- package/bin/lib/workAgentTurnBegun.mjs +15 -11
- package/bin/lib/workAgentTurnExecution.mjs +46 -53
- package/bin/lib/workAgentTurnReports.mjs +9 -37
- package/bin/lib/workAgentTurns.mjs +4 -3
- package/bin/lib/workAttachments.mjs +66 -11
- package/bin/lib/workBrain.mjs +9 -4
- package/bin/lib/workCappedTurn.mjs +109 -0
- package/bin/lib/workChildren.mjs +5 -5
- package/bin/lib/workCommandAudit.mjs +11 -2
- package/bin/lib/workDiffs.mjs +52 -6
- package/bin/lib/workIntake.mjs +35 -60
- package/bin/lib/workPlaceReports.mjs +7 -4
- package/bin/lib/workPlaces.mjs +96 -30
- package/bin/lib/workPreviews.mjs +47 -112
- package/bin/lib/workProcesses.mjs +8 -35
- package/bin/lib/workPullRequests.mjs +37 -56
- package/bin/lib/workReportQueue.mjs +100 -148
- package/bin/lib/workRetire.mjs +2 -1
- package/bin/lib/workSessionRuntime.mjs +38 -37
- package/bin/lib/workSessionTurns.mjs +111 -189
- package/bin/lib/workShip.mjs +43 -23
- package/bin/lib/workTurnShape.mjs +163 -0
- package/npm-shrinkwrap.json +59 -0
- package/package.json +3 -2
- package/bin/lib/fleetJobs.mjs +0 -120
- 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
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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) =>
|
|
48
|
-
.
|
|
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
|
-
|
|
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) => ({
|
|
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
|
}
|
package/bin/lib/runtimes.mjs
CHANGED
|
@@ -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
|
*
|
package/bin/lib/shipSweep.mjs
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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 =
|
|
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({
|
|
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)
|
|
167
|
-
|
|
168
|
-
|
|
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
|
|
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
|
|
252
|
+
r = await attempt('--headless');
|
|
180
253
|
}
|
|
181
|
-
|
|
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));
|
package/bin/lib/startProject.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
*
|
|
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
|
|
56
|
-
*
|
|
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.
|
|
71
|
+
* Elsewhere: ask `ps` for both.
|
|
68
72
|
*/
|
|
69
|
-
export function
|
|
73
|
+
export function terminalForeground() {
|
|
70
74
|
try {
|
|
71
75
|
if (process.platform === 'linux') {
|
|
72
|
-
const stat =
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
|
88
|
+
if (!Number.isFinite(tpgid) || !Number.isFinite(pgrp) || tpgid <= 0) return null;
|
|
88
89
|
return tpgid === pgrp;
|
|
89
90
|
} catch {
|
|
90
|
-
return
|
|
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();
|
package/bin/lib/turnLock.mjs
CHANGED
|
@@ -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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
package/bin/lib/turnProfile.mjs
CHANGED
|
@@ -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');
|
package/bin/lib/uninstall.mjs
CHANGED
|
@@ -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 './
|
|
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
|
-
|
|
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
|
|
64
|
-
if (
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
}
|