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.
- package/README.md +66 -12
- package/bin/cli.mjs +50 -17
- package/bin/lib/admission.mjs +7 -14
- package/bin/lib/agentAsk.mjs +123 -0
- package/bin/lib/agentBrief.mjs +36 -0
- package/bin/lib/agentFootprint.mjs +76 -0
- package/bin/lib/agentInternalWait.mjs +83 -0
- package/bin/lib/agentPlan.mjs +134 -31
- package/bin/lib/agentPrerequisiteContract.mjs +43 -0
- package/bin/lib/agentPublish.mjs +12 -13
- package/bin/lib/agentTaskKinds.mjs +5 -8
- package/bin/lib/agySessions.mjs +3 -3
- package/bin/lib/artifactContracts.mjs +91 -20
- package/bin/lib/artifactIteration.mjs +182 -0
- package/bin/lib/artifactPreview.mjs +92 -18
- package/bin/lib/artifactRecovery.mjs +36 -0
- package/bin/lib/artifactReporter.mjs +194 -22
- package/bin/lib/artifacts.mjs +70 -63
- package/bin/lib/authproxy.mjs +2 -1
- package/bin/lib/childEnv.mjs +9 -4
- package/bin/lib/claude.mjs +10 -20
- package/bin/lib/claudeAuth.mjs +15 -5
- package/bin/lib/claudePersonal.mjs +71 -6
- package/bin/lib/claudePosture.mjs +227 -54
- package/bin/lib/claudeSessions.mjs +7 -7
- package/bin/lib/claudeStream.mjs +27 -13
- package/bin/lib/cliLimit.mjs +2 -2
- package/bin/lib/codexHome.mjs +17 -0
- package/bin/lib/codexPersonal.mjs +44 -3
- package/bin/lib/codexProjectRead.mjs +63 -0
- package/bin/lib/codexRelay.mjs +21 -5
- package/bin/lib/codexResearch.mjs +193 -0
- package/bin/lib/commandSpecs.mjs +6 -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 +90 -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 +6 -5
- package/bin/lib/discoveryContract.mjs +19 -0
- package/bin/lib/env.mjs +104 -35
- package/bin/lib/fleet.mjs +53 -13
- package/bin/lib/fleetPost.mjs +172 -25
- package/bin/lib/fleetReports.mjs +172 -171
- 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/mcpAllow.mjs +60 -0
- package/bin/lib/hooks/readGuard.mjs +54 -11
- package/bin/lib/imageArtifact.mjs +74 -0
- 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/maintenance.mjs +47 -0
- package/bin/lib/maintenanceInventory.mjs +39 -0
- package/bin/lib/mcp-cli.mjs +9 -10
- package/bin/lib/mcpInstall.mjs +163 -0
- package/bin/lib/mcpInventory.mjs +119 -0
- package/bin/lib/noWindowEnv.mjs +23 -3
- package/bin/lib/ownedDirs.mjs +99 -0
- package/bin/lib/pinnedCapture.mjs +383 -0
- package/bin/lib/planDependencies.mjs +70 -0
- package/bin/lib/postureTools.mjs +85 -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 +328 -19
- package/bin/lib/projectTools.mjs +109 -34
- package/bin/lib/prompts.mjs +205 -60
- package/bin/lib/repoPath.mjs +19 -0
- package/bin/lib/repoState.mjs +17 -19
- package/bin/lib/repositoryBaseline.mjs +68 -0
- package/bin/lib/runTurn.mjs +286 -209
- package/bin/lib/runtimeAntigravity.mjs +16 -5
- package/bin/lib/runtimeCapabilities.mjs +215 -21
- package/bin/lib/runtimeClaude.mjs +78 -10
- package/bin/lib/runtimeCodex.mjs +217 -209
- package/bin/lib/runtimeDetection.mjs +8 -2
- package/bin/lib/runtimeEvents.mjs +28 -11
- package/bin/lib/runtimeLimits.mjs +5 -6
- package/bin/lib/runtimeOfferings.mjs +99 -14
- 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/turnDiagnostics.mjs +93 -0
- package/bin/lib/turnLock.mjs +7 -14
- package/bin/lib/turnProfile.mjs +16 -4
- 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 +44 -13
- package/bin/lib/workAdoptCarry.mjs +2 -4
- package/bin/lib/workAgentBrief.mjs +92 -0
- package/bin/lib/workAgentBriefRunner.mjs +59 -0
- package/bin/lib/workAgentCheck.mjs +104 -57
- package/bin/lib/workAgentMerges.mjs +62 -57
- package/bin/lib/workAgentPlans.mjs +82 -199
- package/bin/lib/workAgentPrecheck.mjs +76 -138
- package/bin/lib/workAgentPublish.mjs +6 -8
- package/bin/lib/workAgentTurnBegun.mjs +15 -11
- package/bin/lib/workAgentTurnExecution.mjs +131 -67
- package/bin/lib/workAgentTurnOutcome.mjs +11 -2
- package/bin/lib/workAgentTurnReports.mjs +9 -37
- package/bin/lib/workAgentTurns.mjs +64 -7
- package/bin/lib/workArtifactReconcile.mjs +79 -0
- package/bin/lib/workAttachments.mjs +66 -11
- package/bin/lib/workBrain.mjs +9 -4
- package/bin/lib/workCappedTurn.mjs +119 -0
- package/bin/lib/workChildren.mjs +5 -5
- package/bin/lib/workCommandAudit.mjs +18 -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 +113 -31
- 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 +256 -199
- package/bin/lib/workShip.mjs +43 -23
- package/bin/lib/workToolLog.mjs +70 -3
- package/bin/lib/workTurnShape.mjs +165 -0
- package/npm-shrinkwrap.json +69 -0
- package/package.json +9 -4
- 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
|
-
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
|
4
|
-
import {
|
|
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(
|
|
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(
|
|
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.
|
|
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) =>
|
|
48
|
-
.
|
|
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
|
-
|
|
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
|
-
|
|
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) => ({
|
|
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
|
}
|
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();
|
|
@@ -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
|
+
}
|