flowviant 0.117.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 +35 -0
- package/bin/cli.mjs +37 -0
- 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 +2 -2
- package/bin/lib/agentPlan.mjs +78 -7
- package/bin/lib/agentPrerequisiteContract.mjs +43 -0
- package/bin/lib/agentTaskKinds.mjs +1 -1
- package/bin/lib/artifactContracts.mjs +91 -20
- package/bin/lib/artifactIteration.mjs +182 -0
- package/bin/lib/artifactRecovery.mjs +36 -0
- package/bin/lib/artifactReporter.mjs +183 -19
- package/bin/lib/artifacts.mjs +6 -3
- package/bin/lib/claudeAuth.mjs +15 -5
- package/bin/lib/claudePosture.mjs +110 -14
- package/bin/lib/claudeStream.mjs +20 -2
- package/bin/lib/codexProjectRead.mjs +63 -0
- package/bin/lib/codexResearch.mjs +16 -3
- package/bin/lib/commandSpecs.mjs +6 -0
- package/bin/lib/daemonLocks.mjs +5 -3
- package/bin/lib/desktopContract.mjs +3 -0
- package/bin/lib/discoveryContract.mjs +19 -0
- package/bin/lib/fleet.mjs +46 -4
- package/bin/lib/fleetReports.mjs +44 -3
- package/bin/lib/hooks/mcpAllow.mjs +60 -0
- package/bin/lib/hooks/readGuard.mjs +13 -4
- package/bin/lib/imageArtifact.mjs +74 -0
- package/bin/lib/maintenance.mjs +47 -0
- package/bin/lib/maintenanceInventory.mjs +39 -0
- package/bin/lib/mcpInstall.mjs +163 -0
- package/bin/lib/mcpInventory.mjs +119 -0
- package/bin/lib/planDependencies.mjs +70 -0
- package/bin/lib/postureTools.mjs +85 -0
- package/bin/lib/projectToolRuntimes.mjs +227 -3
- package/bin/lib/projectTools.mjs +61 -4
- package/bin/lib/prompts.mjs +164 -41
- package/bin/lib/repositoryBaseline.mjs +68 -0
- package/bin/lib/runTurn.mjs +35 -16
- package/bin/lib/runtimeCapabilities.mjs +77 -3
- package/bin/lib/runtimeClaude.mjs +27 -9
- package/bin/lib/runtimeCodex.mjs +27 -59
- package/bin/lib/runtimeDetection.mjs +6 -0
- package/bin/lib/runtimeEvents.mjs +12 -1
- package/bin/lib/runtimeOfferings.mjs +75 -3
- package/bin/lib/turnDiagnostics.mjs +93 -0
- package/bin/lib/turnProfile.mjs +15 -3
- package/bin/lib/work.mjs +28 -7
- package/bin/lib/workAgentBrief.mjs +92 -0
- package/bin/lib/workAgentBriefRunner.mjs +59 -0
- package/bin/lib/workAgentPlans.mjs +21 -70
- package/bin/lib/workAgentPrecheck.mjs +5 -0
- package/bin/lib/workAgentTurnExecution.mjs +93 -22
- package/bin/lib/workAgentTurnOutcome.mjs +11 -2
- package/bin/lib/workAgentTurns.mjs +60 -4
- package/bin/lib/workArtifactReconcile.mjs +79 -0
- package/bin/lib/workCappedTurn.mjs +10 -0
- package/bin/lib/workCommandAudit.mjs +8 -1
- package/bin/lib/workPlaces.mjs +18 -2
- package/bin/lib/workSessionTurns.mjs +148 -13
- package/bin/lib/workToolLog.mjs +70 -3
- package/bin/lib/workTurnShape.mjs +6 -4
- package/npm-shrinkwrap.json +12 -2
- package/package.json +7 -3
|
@@ -141,9 +141,54 @@ export function normalizeMcpReport(entries, { charset = null } = {}) {
|
|
|
141
141
|
.slice(0, MAX_MCP_SERVERS);
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
-
|
|
144
|
+
/**
|
|
145
|
+
* EVERY SERVER CLAUDE CODE REPORTED, CONNECTED ONES INCLUDED, WITH ITS SOURCE
|
|
146
|
+
* AND — WHEN CONNECTED — ITS TOOLS' NAMES (2026-10-13, the owner: name every
|
|
147
|
+
* MCP server and capability each CLI gives the project). Learned off the same
|
|
148
|
+
* init as the list above, under the same harvest rule; it leaves the box only
|
|
149
|
+
* on this box's own `tools-report` (mcpInventory.mjs), never on the poll, and
|
|
150
|
+
* carries names, statuses and sources alone. The header's "only what is not
|
|
151
|
+
* connected leaves the box" still holds for the poll's `mcp`, which an older
|
|
152
|
+
* app reads.
|
|
153
|
+
*
|
|
154
|
+
* `scope` is the CLI's own word (measured on Claude Code 2.1.296's scope list:
|
|
155
|
+
* local, user, project, dynamic, enterprise, claudeai, managed, agent);
|
|
156
|
+
* mcpInventory.mjs maps it to a source. Tool names come from the init's
|
|
157
|
+
* `tools`, where a server's tools are `mcp__<server>__<tool>` and the server
|
|
158
|
+
* part is its name with every character outside `[A-Za-z0-9_-]` made `_`
|
|
159
|
+
* (`claude.ai Gmail` → `claude_ai_Gmail`, measured 2026-10-11).
|
|
160
|
+
*/
|
|
161
|
+
let mcpInventoryCache = null;
|
|
162
|
+
const MAX_INVENTORY_SERVERS = 80;
|
|
163
|
+
const mcpToolPrefix = (name) => `mcp__${name.replace(/[^A-Za-z0-9_-]/g, '_')}__`;
|
|
164
|
+
|
|
165
|
+
/** The init's servers and tools → `[{ name, status, scope, tools }]`. */
|
|
166
|
+
export function claudeMcpInventoryOf(list, tools) {
|
|
167
|
+
const names = Array.isArray(tools) ? tools.filter((t) => typeof t === 'string' && t.startsWith('mcp__')) : [];
|
|
168
|
+
const seen = new Map();
|
|
169
|
+
for (const e of list) {
|
|
170
|
+
if (!e || typeof e !== 'object' || typeof e.name !== 'string') continue;
|
|
171
|
+
const name = e.name.trim().slice(0, MCP_NAME_MAX).trim();
|
|
172
|
+
if (!name || name === 'flowviant' || seen.has(name)) continue;
|
|
173
|
+
const status = MCP_STATUSES.has(e.status) ? e.status : 'other';
|
|
174
|
+
seen.set(name, { name, status, scope: typeof e.scope === 'string' ? e.scope : typeof e.source === 'string' ? e.source : null, prefix: mcpToolPrefix(e.name.trim()), tools: status === 'connected' ? [] : null });
|
|
175
|
+
}
|
|
176
|
+
// The longest prefix wins, so `a_b` never claims `a`'s tools or the reverse.
|
|
177
|
+
const byPrefix = [...seen.values()].sort((a, b) => b.prefix.length - a.prefix.length);
|
|
178
|
+
for (const tool of names) {
|
|
179
|
+
const owner = byPrefix.find((s) => tool.startsWith(s.prefix));
|
|
180
|
+
if (owner?.tools) owner.tools.push(tool.slice(owner.prefix.length));
|
|
181
|
+
}
|
|
182
|
+
return [...seen.values()]
|
|
183
|
+
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
|
|
184
|
+
.slice(0, MAX_INVENTORY_SERVERS)
|
|
185
|
+
.map(({ prefix, ...server }) => server);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function recordMcpServers(list, tools) {
|
|
145
189
|
if (!Array.isArray(list)) return;
|
|
146
190
|
mcpServersCache = normalizeMcpReport(list);
|
|
191
|
+
mcpInventoryCache = claudeMcpInventoryOf(list, tools);
|
|
147
192
|
}
|
|
148
193
|
|
|
149
194
|
/** What to send on the roster poll as `mcp` — null until a turn has taught us. */
|
|
@@ -151,6 +196,12 @@ export function knownMcpServers() {
|
|
|
151
196
|
return mcpServersCache;
|
|
152
197
|
}
|
|
153
198
|
|
|
199
|
+
/** Claude Code's whole server list for the box's inventory — null until a
|
|
200
|
+
* turn (or the probe) has taught us. */
|
|
201
|
+
export function knownClaudeMcpInventory() {
|
|
202
|
+
return mcpInventoryCache;
|
|
203
|
+
}
|
|
204
|
+
|
|
154
205
|
/**
|
|
155
206
|
* WHICH TURNS ARE A MEASUREMENT OF THE MACHINE (2026-10-10, the audit) —
|
|
156
207
|
* asked by runTurn.mjs, the one spawn point, of the argv the adapter built.
|
|
@@ -339,6 +390,8 @@ export function parseInitLine(line) {
|
|
|
339
390
|
skills: Array.isArray(ev.skills) ? ev.skills : null,
|
|
340
391
|
// The CLI's mounted MCP servers (0.97.0) — `recordMcpServers` normalises.
|
|
341
392
|
mcpServers: Array.isArray(ev.mcp_servers) ? ev.mcp_servers : null,
|
|
393
|
+
// Its tool names, for the inventory's per-server lists (2026-10-13).
|
|
394
|
+
tools: Array.isArray(ev.tools) ? ev.tools : null,
|
|
342
395
|
sessionId: typeof ev.session_id === 'string' ? ev.session_id : null,
|
|
343
396
|
};
|
|
344
397
|
}
|
|
@@ -377,17 +430,36 @@ export const SKILL_PROBE_ARGS = Object.freeze([
|
|
|
377
430
|
export function probeSkillsOnce(cwd, { spawn: spawnChild = spawn } = {}) {
|
|
378
431
|
if (skillProbeStarted || skillsCache !== null) return;
|
|
379
432
|
skillProbeStarted = true;
|
|
433
|
+
runInitProbe(cwd, [], spawnChild);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* LOOK AGAIN, NOW, WITH THE BASE BRANCH'S SERVERS MOUNTED (2026-10-13) — the
|
|
438
|
+
* re-check after a confirmed MCP install (mcpInstall.mjs). The same cheap
|
|
439
|
+
* probe, same rules (hooks off, unattended, its group killed), with the base
|
|
440
|
+
* copy added through `--mcp-config` so Claude Code says whether each project
|
|
441
|
+
* server now starts; that is what clears a machine's checklist on the Machines
|
|
442
|
+
* page. A person pressed Install for this, so it is not the once-per-process
|
|
443
|
+
* probe: it runs every time it is asked, which is once per finished install.
|
|
444
|
+
* Resolves when the probe has finished (or given up).
|
|
445
|
+
*/
|
|
446
|
+
export function probeMcpNow(cwd, mcpConfigPath, { spawn: spawnChild = spawn } = {}) {
|
|
447
|
+
return new Promise((resolve) => runInitProbe(cwd, mcpConfigPath ? ['--mcp-config', mcpConfigPath] : [], spawnChild, resolve));
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function runInitProbe(cwd, extraArgs, spawnChild, done = () => {}) {
|
|
380
451
|
let child;
|
|
381
452
|
try {
|
|
382
453
|
// Claude Code specifically, not the `wiki` profile's runtime pick: `skills`
|
|
383
454
|
// is Claude Code's own field and the `/` tray only renders for claude tabs.
|
|
384
|
-
child = spawnChild(RUNTIMES.claude.bin, [...SKILL_PROBE_ARGS], {
|
|
455
|
+
child = spawnChild(RUNTIMES.claude.bin, [...SKILL_PROBE_ARGS, ...extraArgs], {
|
|
385
456
|
cwd,
|
|
386
457
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
387
458
|
detached: true,
|
|
388
459
|
env: unattendedEnv(),
|
|
389
460
|
});
|
|
390
461
|
} catch {
|
|
462
|
+
done();
|
|
391
463
|
return; // no claude on PATH — the machine simply stays unmeasured
|
|
392
464
|
}
|
|
393
465
|
let buf = '';
|
|
@@ -411,6 +483,7 @@ export function probeSkillsOnce(cwd, { spawn: spawnChild = spawn } = {}) {
|
|
|
411
483
|
// AFTER the kill, and on a delay: the transcript is the CHILD's file, so
|
|
412
484
|
// deleting it while the child still lives races a recreate.
|
|
413
485
|
removeTranscriptSoon(cwd, sessionId);
|
|
486
|
+
done();
|
|
414
487
|
};
|
|
415
488
|
const timer = setTimeout(() => finish(null), SKILL_PROBE_TIMEOUT_MS);
|
|
416
489
|
timer.unref?.();
|
|
@@ -436,7 +509,7 @@ export function probeSkillsOnce(cwd, { spawn: spawnChild = spawn } = {}) {
|
|
|
436
509
|
const init = parseInitLine(line);
|
|
437
510
|
if (!init) continue;
|
|
438
511
|
if (init.skills) recordSkills(init.skills);
|
|
439
|
-
if (init.mcpServers) recordMcpServers(init.mcpServers);
|
|
512
|
+
if (init.mcpServers) recordMcpServers(init.mcpServers, init.tools);
|
|
440
513
|
finish(init.sessionId);
|
|
441
514
|
return;
|
|
442
515
|
}
|
|
@@ -450,5 +523,6 @@ export function probeSkillsOnce(cwd, { spawn: spawnChild = spawn } = {}) {
|
|
|
450
523
|
export function resetCapabilitiesForTest() {
|
|
451
524
|
skillsCache = null;
|
|
452
525
|
mcpServersCache = null;
|
|
526
|
+
mcpInventoryCache = null;
|
|
453
527
|
skillProbeStarted = false;
|
|
454
528
|
}
|
|
@@ -15,6 +15,11 @@ import { tmpdir } from 'node:os';
|
|
|
15
15
|
import { join } from 'node:path';
|
|
16
16
|
import { MODEL, USER_AGENT } from './config.mjs';
|
|
17
17
|
import { NO_SETTING_SOURCES, personalClaudeSettings, releaseTurnSettings, withPersonalSettings } from './claudePersonal.mjs';
|
|
18
|
+
import { withMachineMcp } from './claudePosture.mjs';
|
|
19
|
+
|
|
20
|
+
/** The profiles an agent lane mounts the machine's MCP servers on (2026-10-11):
|
|
21
|
+
* the ones whose turn profile says `projectMcp` and that Claude declares. */
|
|
22
|
+
const MACHINE_MCP_PROFILES = Object.freeze(['build', 'design', 'research']);
|
|
18
23
|
|
|
19
24
|
function claudeMcp(token, mcpUrl) {
|
|
20
25
|
const dir = mkdtempSync(join(tmpdir(), 'flowviant-mcp-'));
|
|
@@ -53,8 +58,10 @@ export const CLAUDE_RUNTIME = {
|
|
|
53
58
|
* contract was written, not a claim that only Claude could ever satisfy it.
|
|
54
59
|
*/
|
|
55
60
|
// `design` and `research` (0.97.0): each is an `--allowedTools` list with a
|
|
56
|
-
// PATH-SCOPED write (claudePosture.mjs)
|
|
57
|
-
//
|
|
61
|
+
// PATH-SCOPED write (claudePosture.mjs); an agent's turn mounts the
|
|
62
|
+
// project's base MCP servers (2026-10-10) and this machine's own
|
|
63
|
+
// (2026-10-11). `research` is Codex's too since 0.117.0,
|
|
64
|
+
// over an isolated copy (codexResearch.mjs);
|
|
58
65
|
// `design` is Codex's too since 0.115.0 (2026-09-29), whose permission
|
|
59
66
|
// profile was measured to write only the artifacts directory
|
|
60
67
|
// (runtimeCodex.mjs `codexDesignFence`). agy's flags cannot express "write
|
|
@@ -94,7 +101,7 @@ export const CLAUDE_RUNTIME = {
|
|
|
94
101
|
* strongest form of it available anywhere: `--append-system-prompt` sits
|
|
95
102
|
* above the conversation rather than inside it.
|
|
96
103
|
*/
|
|
97
|
-
args({ prompt, system, model, effort, resume, perm, profile = 'build', mcp = [], resultSchemaArgs = [], adoptResumeId, resumeThreadId, knowledgeDir, vaultDir, agentTools, personalSettings }) {
|
|
104
|
+
args({ prompt, system, model, effort, resume, perm, profile = 'build', mcp = [], resultSchemaArgs = [], adoptResumeId, resumeThreadId, knowledgeDir, vaultDir, agentTools, personalSettings, machineMcp }) {
|
|
98
105
|
// THE CARTOGRAPHER'S FENCE NAMES ITS VAULT (2026-10-10): its one write rule
|
|
99
106
|
// is the vault's absolute path (claudePosture.mjs `wikiPermFor`), so a turn
|
|
100
107
|
// with no vault, or one no rule can name, has no list — refused here in
|
|
@@ -174,16 +181,26 @@ export const CLAUDE_RUNTIME = {
|
|
|
174
181
|
a.push('--system-prompt-snapshot', 'off');
|
|
175
182
|
if (agentTools.pluginDir) a.push('--plugin-dir', agentTools.pluginDir);
|
|
176
183
|
}
|
|
177
|
-
//
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
|
|
184
|
+
// WHICH MCP SERVERS A TURN MOUNTS. An agent's code, write-up and
|
|
185
|
+
// mockup/3D-model/deck turns (2026-10-11, the owner) mount this machine's
|
|
186
|
+
// own — its user and local servers and the account's claude.ai connectors —
|
|
187
|
+
// beside the base branch's `.mcp.json` copy the caller passes, so they are
|
|
188
|
+
// not strict; `machineMcp` names every `.mcp.json` server from the
|
|
189
|
+
// worktree up, which `withMachineMcp` disables so a worktree edit never
|
|
190
|
+
// mounts before review (claudePosture.mjs). Every other fenced posture —
|
|
191
|
+
// consult, wiki, and a design/research turn no lane mounted for — stays
|
|
192
|
+
// strict: it never discovers a .mcp.json, nor loads the machine's servers.
|
|
193
|
+
// The plan profile keeps its own principal.
|
|
194
|
+
const machine = Array.isArray(machineMcp) && MACHINE_MCP_PROFILES.includes(profile);
|
|
195
|
+
if (['consult', 'wiki', 'design', 'research'].includes(profile) && !machine && !mcp.includes('--strict-mcp-config')) a.push('--strict-mcp-config');
|
|
181
196
|
a.push(...mcp, ...resultSchemaArgs);
|
|
182
197
|
a.push('--model', model || MODEL);
|
|
183
198
|
if (effort) a.push('--effort', effort);
|
|
184
199
|
// ALWAYS THE EVENT STREAM (2026-10-10): runTurn reads every turn line by
|
|
185
200
|
// line — the activity, the init's skills and servers, the result.
|
|
186
|
-
|
|
201
|
+
// A completed thinking block can arrive minutes after reasoning began.
|
|
202
|
+
// Ask for its start too, so both live surfaces can relay that fact.
|
|
203
|
+
a.push('--output-format', 'stream-json', '--verbose', '--include-partial-messages');
|
|
187
204
|
// The knowledge library (0.94.0) sits in the checkout, outside a
|
|
188
205
|
// worktree's cwd: named as a readable directory so a curated profile
|
|
189
206
|
// does not refuse the path the prompt just handed it. Before `perm`,
|
|
@@ -199,7 +216,8 @@ export const CLAUDE_RUNTIME = {
|
|
|
199
216
|
// kept is written to a private file, never onto argv (claudePersonal.mjs)
|
|
200
217
|
// — so this is the LAST thing built: nothing after it may throw and
|
|
201
218
|
// leave the file with no turn to release it.
|
|
202
|
-
|
|
219
|
+
const postured = machine ? withMachineMcp(perm, machineMcp) : perm;
|
|
220
|
+
a.push(...(fenced ? withPersonalSettings(postured, personalSettings ?? personalClaudeSettings()) : postured));
|
|
203
221
|
return a;
|
|
204
222
|
},
|
|
205
223
|
/** What `args` wrote for a turn — the fenced posture's settings file —
|
package/bin/lib/runtimeCodex.mjs
CHANGED
|
@@ -15,6 +15,7 @@ import { parseCodexLine } from './runtimeEvents.mjs';
|
|
|
15
15
|
import { ARTIFACT_DIR, FENCE_TMP_DIR } from './artifacts.mjs';
|
|
16
16
|
import { codexPersonalTurn } from './codexPersonal.mjs';
|
|
17
17
|
import { researchFence } from './codexResearch.mjs';
|
|
18
|
+
import { projectReadBoundary, projectReadFence } from './codexProjectRead.mjs';
|
|
18
19
|
|
|
19
20
|
/**
|
|
20
21
|
* FLOWVIANT'S OWN TOOLS NEED NO APPROVAL FROM A CHAT NOBODY CAN ANSWER
|
|
@@ -90,21 +91,14 @@ export const NO_SUBAGENTS = Object.freeze(['-c', 'features.multi_agent=false', '
|
|
|
90
91
|
export const HERMETIC = Object.freeze(['--ignore-user-config', '--ignore-rules']);
|
|
91
92
|
|
|
92
93
|
/**
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
* wiki's copy had already lost the web and sub-agent closures.
|
|
99
|
-
*
|
|
100
|
-
* Image generation ships ON in codex-cli 0.156.1 even under
|
|
101
|
-
* --ignore-user-config (measured 2026-09-29): a turn that asked for no picture
|
|
102
|
-
* is handed no tool that spends image credits, and the one that did must not
|
|
103
|
-
* depend on the person's config to have it.
|
|
94
|
+
* Every fenced posture keeps config isolation, desktop/app closure and the
|
|
95
|
+
* delegation policy. Artifact postures explicitly enable native images as a
|
|
96
|
+
* supporting capability; consult, plan and wiki keep them off. Native web
|
|
97
|
+
* needs the isolated research read fence because broad-read tools can read
|
|
98
|
+
* host credentials. Feature flags do not prove account/model entitlement.
|
|
104
99
|
*/
|
|
105
100
|
export const fencedTail = ({ imageGeneration = false, web = false } = {}) => [
|
|
106
|
-
//
|
|
107
|
-
// reads only a copied view (CODEX_FENCES `research`).
|
|
101
|
+
// Native web is paired with the copied, root-denied read fence.
|
|
108
102
|
...(web ? [] : NO_WEB),
|
|
109
103
|
...NO_SUBAGENTS,
|
|
110
104
|
...NO_REACH_OUT,
|
|
@@ -135,33 +129,8 @@ export const CODEX_FENCED = Object.freeze({
|
|
|
135
129
|
export const codexIgnoresUserConfig = ({ profile = 'build', vaultDir } = {}) =>
|
|
136
130
|
Object.hasOwn(CODEX_FENCED, profile) && CODEX_FENCED[profile]({ vaultDir });
|
|
137
131
|
|
|
138
|
-
/**
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
* Claude gets a VERB allowlist: Read, Grep, Glob and a handful of read-only
|
|
142
|
-
* Bash forms. Codex has no such thing — it has no file-read tool at all, so
|
|
143
|
-
* reading the repo IS command execution (`cat`, `rg`). Removing the shell
|
|
144
|
-
* leaves the model with exactly ["update_plan","request_user_input"], which
|
|
145
|
-
* cannot answer a question about a codebase. You get both capabilities or
|
|
146
|
-
* neither.
|
|
147
|
-
*
|
|
148
|
-
* So the promise is kept one layer down instead. `read-only` is
|
|
149
|
-
* kernel-enforced (bubblewrap + seccomp on Linux): writes fail, and a
|
|
150
|
-
* direct-IP connect fails with "Operation not permitted" — socket() is
|
|
151
|
-
* denied, not merely DNS. An injected command still RUNS, and cannot write
|
|
152
|
-
* or send anything itself.
|
|
153
|
-
*
|
|
154
|
-
* THE WAY OUT IS THE ANSWER. What such a turn reads can leave the box in
|
|
155
|
-
* the words it hands back — a consult's reply, a plan's proposal, and
|
|
156
|
-
* intake's drafted brief, which lands on the board with no press — and this
|
|
157
|
-
* profile READS the whole box (see `codexArtifactFence`'s STATED note). Those
|
|
158
|
-
* words are scrubbed on the way up (uplinkScrub.mjs) — of the machine's known
|
|
159
|
-
* secret values and the token shapes that scrub knows, and of nothing else; it
|
|
160
|
-
* is that scrub, not this sandbox, that stands between a read secret and the
|
|
161
|
-
* board.
|
|
162
|
-
*/
|
|
163
|
-
const READ_ONLY_SANDBOX = ['-c', 'sandbox_mode="read-only"'];
|
|
164
|
-
|
|
132
|
+
/** Consult/capture use root-denied tool reads over the actual project,
|
|
133
|
+
* knowledge and Git metadata. Scrubbing remains supplemental. */
|
|
165
134
|
/**
|
|
166
135
|
* THE CARTOGRAPHER: read the repo, write ONLY the vault. Codex's permission
|
|
167
136
|
* profiles take a per-path filesystem map, so the rule is enforced by the
|
|
@@ -264,7 +233,7 @@ export const CODEX_RUNTIME = {
|
|
|
264
233
|
* placed before it. Appending them after the positional is the kind of argv
|
|
265
234
|
* that parses today and stops parsing on some future clap upgrade.
|
|
266
235
|
*/
|
|
267
|
-
args({ prompt, system, model, effort, resume, resumeThreadId, profile = 'build', vaultDir, cwd, mcp = [], resultSchemaArgs = [], adoptResumeId, agentTools, personal = null, researchIsolation }) {
|
|
236
|
+
args({ prompt, system, model, effort, resume, resumeThreadId, profile = 'build', vaultDir, cwd, mcp = [], resultSchemaArgs = [], adoptResumeId, agentTools, personal = null, researchIsolation, knowledgeDir, projectReads }) {
|
|
268
237
|
// Adoption resumes a conversation in ITS OWN CLI's store (claude forks,
|
|
269
238
|
// agy moves) — codex has no adoptable store wired yet. Reaching here
|
|
270
239
|
// with an adopt id is a wiring mistake upstream, and it fails loudly on
|
|
@@ -296,7 +265,7 @@ export const CODEX_RUNTIME = {
|
|
|
296
265
|
// posture builds its own (CODEX_FENCES below); anything else is a build.
|
|
297
266
|
const fenced = codexIgnoresUserConfig({ profile, vaultDir });
|
|
298
267
|
if (fenced) {
|
|
299
|
-
a.push(...CODEX_FENCES[profile]({ cwd, vaultDir, researchIsolation }));
|
|
268
|
+
a.push(...CODEX_FENCES[profile]({ cwd, vaultDir, researchIsolation, knowledgeDir, projectReads }));
|
|
300
269
|
} else {
|
|
301
270
|
// The daemon's build posture, mapped: SAFE keeps writes inside the
|
|
302
271
|
// workspace, the default lets the agent run its own tests and git
|
|
@@ -324,7 +293,10 @@ export const CODEX_RUNTIME = {
|
|
|
324
293
|
if (a.includes('--ignore-user-config')) a.push(...(personal?.args ?? []));
|
|
325
294
|
|
|
326
295
|
a.push(...mcp, ...resultSchemaArgs);
|
|
327
|
-
|
|
296
|
+
const readers = profile === 'consult' || profile === 'plan'
|
|
297
|
+
? '\nUse exec_command with shell="/bin/bash" and login=false for project discovery. Project files, local Git history and the project knowledge directory are readable; host files are outside this turn’s read boundary.'
|
|
298
|
+
: '';
|
|
299
|
+
a.push(`${system}${readers}\n\n---\n\n${prompt}`);
|
|
328
300
|
return a;
|
|
329
301
|
},
|
|
330
302
|
/**
|
|
@@ -349,14 +321,14 @@ export const CODEX_RUNTIME = {
|
|
|
349
321
|
* 2026-09-29, the owner: "its saying 3d models run on claude only but thats
|
|
350
322
|
* not true"), one builder under each card's own profile name since 2026-10-10.
|
|
351
323
|
*
|
|
352
|
-
*
|
|
324
|
+
* Design and image share supporting native image generation and a
|
|
353
325
|
* permission profile that reads the repo and writes ONLY
|
|
354
326
|
* `<worktree>/.flowviant/artifacts/` (and the turn's scratch), selected
|
|
355
327
|
* by `default_permissions` (`codex exec` and `exec resume` refuse `-P` on
|
|
356
328
|
* 0.156.1), no network, no web search, no sub-agents, no browser, computer or
|
|
357
|
-
* apps, nobody's config or rules, and no approvals to wait on.
|
|
358
|
-
*
|
|
359
|
-
*
|
|
329
|
+
* apps, nobody's config or rules, and no approvals to wait on. Needed image
|
|
330
|
+
* generation supports the page without changing its end product. CDN scripts
|
|
331
|
+
* load in the viewer, never on this machine.
|
|
360
332
|
*
|
|
361
333
|
* MEASURED on codex-cli 0.156.1 (Linux, bubblewrap), with gpt-6-luna at low
|
|
362
334
|
* effort, in a scratch git repository (2026-09-29), with exactly this argv:
|
|
@@ -428,9 +400,9 @@ export function codexArtifactFence(cwd, { name, imageGeneration = false, what })
|
|
|
428
400
|
}
|
|
429
401
|
|
|
430
402
|
/** The design card's fence — `codexArtifactFence` under its own profile name,
|
|
431
|
-
* with
|
|
403
|
+
* with native image generation available for supporting assets. */
|
|
432
404
|
export const codexDesignFence = (cwd) =>
|
|
433
|
-
codexArtifactFence(cwd, { name: 'flowviantdesign', what: 'a design turn' });
|
|
405
|
+
codexArtifactFence(cwd, { name: 'flowviantdesign', imageGeneration: true, what: 'a design turn' });
|
|
434
406
|
|
|
435
407
|
/**
|
|
436
408
|
* EVERY FENCED POSTURE'S ARGV, by the keys of CODEX_FENCED (a test holds the
|
|
@@ -439,8 +411,8 @@ export const codexDesignFence = (cwd) =>
|
|
|
439
411
|
const CODEX_FENCES = {
|
|
440
412
|
// A CONSULT, EXPRESSED THE ONLY WAY CODEX CAN EXPRESS IT — a different
|
|
441
413
|
// shape from Claude's verb allowlist, which is the whole reason `profile` is
|
|
442
|
-
// a promise rather than a flag list. See
|
|
443
|
-
consult: () => [...
|
|
414
|
+
// a promise rather than a flag list. See codexProjectRead.mjs.
|
|
415
|
+
consult: (opts) => [...projectReadFence(opts.projectReads ?? projectReadBoundary(opts)), ...fencedTail()],
|
|
444
416
|
// A PLANNING SESSION. Read-only on the filesystem, exactly like a consult —
|
|
445
417
|
// the writes it makes go through the control plane, not through this box —
|
|
446
418
|
// and for the same reason: its prompt is steered by anything a project
|
|
@@ -449,7 +421,7 @@ const CODEX_FENCES = {
|
|
|
449
421
|
// turn's scoped token. The plan token has the planning tools; a capture
|
|
450
422
|
// token has only staging and transcript tools. The server refuses
|
|
451
423
|
// everything outside that scope.
|
|
452
|
-
plan: () => [...
|
|
424
|
+
plan: (opts) => [...projectReadFence(opts.projectReads ?? projectReadBoundary(opts)), ...fencedTail()],
|
|
453
425
|
wiki: ({ vaultDir }) => codexWikiFence(vaultDir),
|
|
454
426
|
// AN IMAGE CARD (0.114.0): read the repo, write ONLY the worktree's
|
|
455
427
|
// `.flowviant/artifacts/`, generate pictures, and nothing else. MEASURED on
|
|
@@ -475,12 +447,8 @@ const CODEX_FENCES = {
|
|
|
475
447
|
// 2026-09-29); `:root` reads admit it, so the agent copies what it keeps
|
|
476
448
|
// into the one writable directory, where the artifact scan finds it.
|
|
477
449
|
//
|
|
478
|
-
//
|
|
479
|
-
//
|
|
480
|
-
// whatever the person's config says about the feature. A BUILD turn keeps
|
|
481
|
-
// the person's own config (not hermetic, by design), and the other fenced
|
|
482
|
-
// postures never asked for a picture, so none of them is handed a tool that
|
|
483
|
-
// spends image credits.
|
|
450
|
+
// Native images support every artifact kind; this profile still delivers
|
|
451
|
+
// PNG/WebP, while design/model/deck still deliver their self-contained page.
|
|
484
452
|
image: ({ cwd }) => codexArtifactFence(cwd, { name: 'flowviantimage', imageGeneration: true, what: 'an image turn' }),
|
|
485
453
|
// A MOCKUP, 3D-MODEL OR DECK CARD (0.115.0): the fence is
|
|
486
454
|
// `codexDesignFence`'s, argued and measured at `codexArtifactFence` above.
|
|
@@ -492,7 +460,7 @@ const CODEX_FENCES = {
|
|
|
492
460
|
// tail leaves NO_WEB out and closes everything else.
|
|
493
461
|
research: ({ researchIsolation }) => {
|
|
494
462
|
if (!researchIsolation) throw new Error('Codex research requires its isolated input copy.');
|
|
495
|
-
return [...researchFence(researchIsolation), ...fencedTail({ web: true })];
|
|
463
|
+
return [...researchFence(researchIsolation), ...fencedTail({ web: true, imageGeneration: true })];
|
|
496
464
|
},
|
|
497
465
|
};
|
|
498
466
|
/** The fenced profile names — for the test that holds both tables to one set. */
|
|
@@ -86,6 +86,12 @@ export function runtimesReport(detected) {
|
|
|
86
86
|
.join(',');
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
/** Existing probe evidence only; diagnostics never spawn another CLI. */
|
|
90
|
+
export function measuredRuntimeVersion(id) {
|
|
91
|
+
const row = detectedCache?.find((runtime) => runtime.id === id);
|
|
92
|
+
return row ? { version: row.version, measuredAt: new Date(detectedAt).toISOString() } : null;
|
|
93
|
+
}
|
|
94
|
+
|
|
89
95
|
export function detectRuntimes({ refresh = false } = {}) {
|
|
90
96
|
if (detectedCache && !refresh && Date.now() - detectedAt < DETECT_TTL_MS) return detectedCache;
|
|
91
97
|
detectedAt = Date.now();
|
|
@@ -322,6 +322,11 @@ export function parseCodexLine(line, cwd) {
|
|
|
322
322
|
// text: nothing here is the model speaking.
|
|
323
323
|
case 'thread.started':
|
|
324
324
|
return { activity: null, text: '', threadId: String(ev.thread_id ?? '') || null };
|
|
325
|
+
case 'item.started': {
|
|
326
|
+
const item = ev.item ?? {};
|
|
327
|
+
if ((item.item_type ?? item.type) !== 'command_execution') return null;
|
|
328
|
+
return { toolEvent: ['Bash', { command: item.command }, { id: item.id, runtime: 'codex' }] };
|
|
329
|
+
}
|
|
325
330
|
case 'item.completed': {
|
|
326
331
|
const item = ev.item ?? {};
|
|
327
332
|
const activity = humanizeCodexItem(item, cwd);
|
|
@@ -334,7 +339,13 @@ export function parseCodexLine(line, cwd) {
|
|
|
334
339
|
// the agent said LAST" (the agent lane's final JSON object) cannot recover
|
|
335
340
|
// it from `out`. Claude's stream has this for free under
|
|
336
341
|
// `answerFromResult`; codex needed it said.
|
|
337
|
-
|
|
342
|
+
const command = (item.item_type ?? item.type) === 'command_execution';
|
|
343
|
+
const toolEvent = command ? ['Bash', { command: item.command }, {
|
|
344
|
+
id: item.id, result: true, runtime: 'codex',
|
|
345
|
+
exitCode: item.exit_code, isError: item.status === 'failed',
|
|
346
|
+
output: item.aggregated_output ?? '',
|
|
347
|
+
}] : null;
|
|
348
|
+
return { activity, text, ...(toolEvent ? { toolEvent } : {}), ...(isMessage ? { answer: String(item.text ?? item.message ?? '') } : {}) };
|
|
338
349
|
}
|
|
339
350
|
// THE TURN'S OWN TOKEN COUNT (2026-09-24). `codex exec --json` closes a
|
|
340
351
|
// turn with `turn.completed` carrying `usage` — the codex twin of Claude's
|
|
@@ -1,7 +1,7 @@
|
|
|
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 { join } from 'node:path';
|
|
3
|
+
import { accessSync, constants, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
4
|
+
import { basename, delimiter, isAbsolute, join } from 'node:path';
|
|
5
5
|
import { readBaseTools } from './projectTools.mjs';
|
|
6
6
|
import { mcpRefusal } from './projectToolRuntimes.mjs';
|
|
7
7
|
import { CODEX_MCP_NAME, normalizeMcpReport } from './runtimeCapabilities.mjs';
|
|
@@ -52,6 +52,7 @@ export function localCodexOfferings({ env = process.env, runCodex = run, read =
|
|
|
52
52
|
} catch (error) { if (error?.code === 'ENOENT') result.skills = []; }
|
|
53
53
|
try {
|
|
54
54
|
const servers = JSON.parse(runCodex(['mcp', 'list', '--json'], env));
|
|
55
|
+
if (Array.isArray(servers)) result.servers = codexServerRows(servers, env);
|
|
55
56
|
if (Array.isArray(servers)) result.mcp = codexMcpReport(servers
|
|
56
57
|
// Unknown/unsupported auth is not proof of disconnection. Keep their
|
|
57
58
|
// names local, just as connected servers stay local.
|
|
@@ -61,11 +62,82 @@ export function localCodexOfferings({ env = process.env, runCodex = run, read =
|
|
|
61
62
|
return result;
|
|
62
63
|
}
|
|
63
64
|
|
|
64
|
-
|
|
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) {
|
|
65
123
|
if (!cached || refresh || Date.now() - cachedAt >= TTL) {
|
|
66
124
|
cached = localCodexOfferings();
|
|
67
125
|
cachedAt = Date.now();
|
|
68
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);
|
|
69
141
|
const out = { rtm: {}, rtc: {}, rts: {}, rtp: {} };
|
|
70
142
|
if (cached.models) out.rtm.codex = cached.models;
|
|
71
143
|
if (cached.capabilities) out.rtc.codex = cached.capabilities;
|
|
@@ -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
|
+
}
|