flowviant 0.125.29 → 0.125.31
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 +364 -0
- package/bin/cli.mjs +23 -0
- package/bin/lib/agentPlan.mjs +8 -0
- package/bin/lib/agentQueueContext.mjs +6 -0
- package/bin/lib/agentSplit.mjs +90 -0
- package/bin/lib/architectureContract.mjs +57 -0
- package/bin/lib/architectureReport.mjs +38 -0
- package/bin/lib/artifactContracts.mjs +21 -2
- package/bin/lib/claudeInput.mjs +1 -1
- package/bin/lib/claudeSubagents.mjs +46 -6
- package/bin/lib/claudeWorkflows.mjs +94 -0
- package/bin/lib/cliLimit.mjs +5 -0
- package/bin/lib/codexInput.mjs +191 -0
- package/bin/lib/codexSubagentEvents.mjs +3 -1
- package/bin/lib/codexSubagents.mjs +34 -9
- package/bin/lib/conversationOutput.mjs +14 -0
- package/bin/lib/crossCliChild.mjs +327 -0
- package/bin/lib/crossCliLaunch.mjs +92 -0
- package/bin/lib/crossCliPosture.mjs +55 -0
- package/bin/lib/crossCliResearch.mjs +19 -0
- package/bin/lib/environmentProcess.mjs +44 -0
- package/bin/lib/fixtures/workflow-ultracode.jsonl +22 -0
- package/bin/lib/fleet.mjs +2 -0
- package/bin/lib/linuxDesktopBridge.mjs +62 -0
- package/bin/lib/managedBrowser.mjs +50 -10
- package/bin/lib/mergeBatch.mjs +1 -1
- package/bin/lib/nativeAgentTools.mjs +12 -5
- package/bin/lib/noWindowEnv.mjs +4 -2
- package/bin/lib/orchestrator.mjs +24 -7
- package/bin/lib/orchestratorClient.mjs +8 -2
- package/bin/lib/orchestratorPrompt.mjs +13 -5
- package/bin/lib/orchestratorProtocol.mjs +18 -0
- package/bin/lib/outsideCli.mjs +124 -0
- package/bin/lib/projectToolRuntimes.mjs +4 -2
- package/bin/lib/projectTools.mjs +2 -2
- package/bin/lib/prompts.mjs +7 -5
- package/bin/lib/repositoryPlaces.mjs +1 -1
- package/bin/lib/repositorySetup.mjs +15 -24
- package/bin/lib/reviewBridge.mjs +110 -0
- package/bin/lib/reviewDatabase.mjs +112 -0
- package/bin/lib/reviewDesktop.mjs +153 -0
- package/bin/lib/reviewEnvironment.mjs +286 -0
- package/bin/lib/reviewEnvironmentConfig.mjs +104 -0
- package/bin/lib/reviewEnvironmentMain.mjs +38 -0
- package/bin/lib/reviewImage.mjs +58 -0
- package/bin/lib/reviewMcpClient.mjs +69 -0
- package/bin/lib/reviewProcess.mjs +51 -0
- package/bin/lib/reviewTargets.mjs +50 -0
- package/bin/lib/robloxReview.mjs +234 -0
- package/bin/lib/robloxStudioBridge.mjs +68 -0
- package/bin/lib/runTurn.mjs +30 -8
- package/bin/lib/runtimeClaude.mjs +7 -3
- package/bin/lib/runtimeCodex.mjs +12 -6
- package/bin/lib/runtimeLimits.mjs +59 -1
- package/bin/lib/runtimeReview.mjs +185 -0
- package/bin/lib/splitGate.mjs +115 -0
- package/bin/lib/subagentBounds.mjs +26 -0
- package/bin/lib/subagentLedger.mjs +110 -0
- package/bin/lib/trace.mjs +134 -6
- package/bin/lib/turnDeclaration.mjs +4 -4
- package/bin/lib/turnGaps.mjs +6 -2
- package/bin/lib/turnSubagents.mjs +23 -9
- package/bin/lib/uiReview.mjs +5 -2
- package/bin/lib/wikiDraft.mjs +9 -0
- package/bin/lib/work.mjs +22 -1
- package/bin/lib/workAgentCheck.mjs +24 -1
- package/bin/lib/workAgentResume.mjs +11 -2
- package/bin/lib/workAgentTools.mjs +6 -4
- package/bin/lib/workAgentTurnExecution.mjs +77 -9
- package/bin/lib/workAgentTurnOutcome.mjs +11 -0
- package/bin/lib/workAgentTurnReports.mjs +5 -2
- package/package.json +1 -1
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/** A reviewed recipe selects workflows; languages do not select viewers. */
|
|
2
|
+
import { accessSync, constants, realpathSync } from 'node:fs';
|
|
3
|
+
import { delimiter, isAbsolute, relative, resolve } from 'node:path';
|
|
4
|
+
import { git } from './git.mjs';
|
|
5
|
+
import { environmentKey } from './reviewEnvironmentConfig.mjs';
|
|
6
|
+
import { robloxConfiguration } from './robloxReview.mjs';
|
|
7
|
+
|
|
8
|
+
export const REVIEW_RECIPE = '.flowviant/review.json';
|
|
9
|
+
const quiet = { stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 128 * 1024 };
|
|
10
|
+
const nameOk = value => typeof value === 'string' && /^[A-Za-z0-9_-]{1,60}$/.test(value);
|
|
11
|
+
const commandOk = value => Array.isArray(value) && value.length > 0 && value.length <= 40 && value.every(v => typeof v === 'string' && v.length > 0 && v.length <= 2000 && !/[\0\r\n]/.test(v));
|
|
12
|
+
|
|
13
|
+
export function reviewCwd(wt, cwd = '.') {
|
|
14
|
+
if (typeof cwd !== 'string' || isAbsolute(cwd) || /[\0\r\n\\]/.test(cwd)) throw new Error('Review cwd must be inside this worktree.');
|
|
15
|
+
const path = realpathSync(resolve(wt, cwd)), rel = relative(realpathSync(wt), path);
|
|
16
|
+
if (isAbsolute(rel) || rel === '..' || rel.startsWith('../')) throw new Error('Review cwd must be inside this worktree.');
|
|
17
|
+
return path;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function commandOnPath(command, env = process.env) {
|
|
21
|
+
const candidates = isAbsolute(command) ? [command] : command.includes('/') || command.includes('\\') ? [] : (env.PATH ?? '').split(delimiter).map(dir => resolve(dir, command));
|
|
22
|
+
return candidates.find(path => { try { accessSync(path, constants.X_OK); return true; } catch { return false; } }) ?? null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function readReviewTargets(root, ref = 'HEAD') {
|
|
26
|
+
const commit = git(['rev-parse', '--verify', ref], root, quiet);
|
|
27
|
+
const entry = git(['ls-tree', commit, '--', REVIEW_RECIPE], root, quiet);
|
|
28
|
+
if (!entry) return { root, commit, targets: {} };
|
|
29
|
+
if (!/^100(?:644|755) blob /.test(entry)) throw new Error('The review recipe must be a regular tracked file.');
|
|
30
|
+
let recipe;
|
|
31
|
+
try { recipe = JSON.parse(git(['show', `${commit}:${REVIEW_RECIPE}`], root, quiet)); }
|
|
32
|
+
catch { throw new Error('The reviewed review.json is not valid JSON.'); }
|
|
33
|
+
if (recipe?.version !== 1 || !recipe.targets || typeof recipe.targets !== 'object' || Array.isArray(recipe.targets) || Object.keys(recipe.targets).length > 24) throw new Error('Review recipes need version 1 and at most 24 named targets.');
|
|
34
|
+
const targets = Object.create(null);
|
|
35
|
+
for (const [name, target] of Object.entries(recipe.targets)) {
|
|
36
|
+
if (!nameOk(name) || !target || !['command', 'browser', 'desktop', 'bridge', 'roblox'].includes(target.adapter) || !commandOk(target.command)) throw new Error('Each review target needs a name, supported adapter and command argv.');
|
|
37
|
+
if (target.adapter === 'roblox') robloxConfiguration(target.configuration);
|
|
38
|
+
if (target.cwd != null && (typeof target.cwd !== 'string' || target.cwd.length > 500 || isAbsolute(target.cwd) || /[\0\r\n\\]/.test(target.cwd))) throw new Error(`Invalid review cwd for ${name}.`);
|
|
39
|
+
const timeoutSeconds = target.timeoutSeconds ?? (target.adapter === 'command' ? 120 : 600);
|
|
40
|
+
if (!Number.isInteger(timeoutSeconds) || timeoutSeconds < 1 || timeoutSeconds > 600) throw new Error(`Review target ${name} timeoutSeconds must be 1–600.`);
|
|
41
|
+
if (target.adapter !== 'command' && (typeof target.fixture !== 'string' || target.fixture.trim().length < 12 || target.fixture.length > 2000)) throw new Error(`Review target ${name} needs a disposable fixture description.`);
|
|
42
|
+
if (target.adapter === 'browser' && (!Number.isInteger(target.port) || target.port < 0 || target.port > 65535 || (target.port === 0 && !target.command.some(arg => arg.includes('${PORT}'))) || (target.hostname != null && target.hostname !== 'localhost') || (target.scheme != null && !['http', 'https'].includes(target.scheme)))) throw new Error(`Review target ${name} needs a valid local port/protocol (port 0 requires a PORT placeholder in command argv).`);
|
|
43
|
+
if (target.adapter === 'browser' && target.path != null && (typeof target.path !== 'string' || !target.path.startsWith('/') || target.path.startsWith('//') || /[\x00-\x20\\]/.test(target.path) || target.path.length > 2000 || new URL(target.path, 'http://preview.invalid').pathname.startsWith('//'))) throw new Error(`Review target ${name} needs a local path.`);
|
|
44
|
+
if (target.platforms != null && (!Array.isArray(target.platforms) || target.platforms.length > 3 || target.platforms.some(p => !['linux', 'darwin', 'win32'].includes(p)))) throw new Error(`Review target ${name} platforms must name operating systems.`);
|
|
45
|
+
if (target.configuration != null && (typeof target.configuration !== 'object' || Array.isArray(target.configuration) || Buffer.byteLength(JSON.stringify(target.configuration)) > 16_000)) throw new Error(`Review target ${name} bridge configuration exceeds its bound.`);
|
|
46
|
+
if (target.env != null && (!Array.isArray(target.env) || target.env.length > 40 || target.env.some(key => typeof key !== 'string' || !environmentKey(key)))) throw new Error(`Review target ${name} env must name explicitly selected runtime variables.`);
|
|
47
|
+
targets[name] = { ...target, cwd: target.cwd ?? '.', timeoutSeconds };
|
|
48
|
+
}
|
|
49
|
+
return { root: realpathSync(root), commit, targets };
|
|
50
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/** Studio viewport adapter. All engine operations name one verified fixture. */
|
|
2
|
+
import { readFileSync, realpathSync } from 'node:fs';
|
|
3
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
4
|
+
import { basename, isAbsolute, relative, resolve } from 'node:path';
|
|
5
|
+
import { connectReviewMcp, mcpText } from './reviewMcpClient.mjs';
|
|
6
|
+
import { createReviewImage } from './reviewImage.mjs';
|
|
7
|
+
import { reviewDiagnostic } from './uiReviewBrowser.mjs';
|
|
8
|
+
|
|
9
|
+
const MARKER = 'FLOWVIANT_REVIEW_PROBE:';
|
|
10
|
+
const CLIENT_PROBE = `local camera=workspace.CurrentCamera
|
|
11
|
+
return '${MARKER}'..game:GetService('HttpService'):JSONEncode({player=game:GetService('Players').LocalPlayer ~= nil,width=camera and camera.ViewportSize.X or 0,height=camera and camera.ViewportSize.Y or 0,session=game:GetAttribute('FlowviantReviewSession')})`;
|
|
12
|
+
const sha = value => createHash('sha256').update(value).digest('hex');
|
|
13
|
+
const normalize = value => value.replace(/\r\n/g, '\n');
|
|
14
|
+
// Decimal byte escapes preserve Unicode without creating hundreds of call
|
|
15
|
+
// arguments (long UTF-8 names can exceed Luau's register limit).
|
|
16
|
+
const luauString = value => '"' + [...Buffer.from(value)].map(byte => '\\' + String(byte).padStart(3, '0')).join('') + '"';
|
|
17
|
+
const pathOk = value => Array.isArray(value) && value.length > 0 && value.length <= 20 && value.every(part => typeof part === 'string' && part.length > 0 && part.length <= 150 && !/[\x00-\x1f]/.test(part));
|
|
18
|
+
|
|
19
|
+
export function robloxConfiguration(config) {
|
|
20
|
+
if (!config || typeof config.studioName !== 'string' || config.studioName.length < 1 || config.studioName.length > 250 || /[\x00-\x1f]/.test(config.studioName)) throw new Error('Roblox review needs the exact Studio fixture name (optionally containing ${WORKTREE}).');
|
|
21
|
+
if (config.startPlay != null && typeof config.startPlay !== 'boolean') throw new Error('Roblox startPlay must be a boolean.');
|
|
22
|
+
if (!Array.isArray(config.scripts) || config.scripts.length < 1 || config.scripts.length > 40 || config.scripts.some(script => !script || typeof script.file !== 'string' || !script.file || script.file.length > 500 || isAbsolute(script.file) || script.file.split('/').includes('..') || /[\x00-\x1f\\]/.test(script.file) || !/\.(?:lua|luau)$/.test(script.file) || !pathOk(script.path))) throw new Error('Roblox review needs 1–40 Lua/Luau files mapped to explicit Studio instance path segments.');
|
|
23
|
+
if (new Set(config.scripts.map(s => s.file)).size !== config.scripts.length || new Set(config.scripts.map(s => JSON.stringify(s.path))).size !== config.scripts.length) throw new Error('Roblox source mappings must be unique.');
|
|
24
|
+
return { studioName: config.studioName, startPlay: config.startPlay ?? true, scripts: config.scripts };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function values(result) {
|
|
28
|
+
const out = [];
|
|
29
|
+
const visit = (value, depth = 0) => {
|
|
30
|
+
if (depth > 5) return;
|
|
31
|
+
out.push(value);
|
|
32
|
+
if (typeof value === 'string') { try { visit(JSON.parse(value), depth + 1); } catch { /* ordinary text */ } }
|
|
33
|
+
else if (value && typeof value === 'object') for (const entry of Object.values(value)) visit(entry, depth + 1);
|
|
34
|
+
};
|
|
35
|
+
visit(result.structuredContent); for (const item of result.content ?? []) if (item.type === 'text') visit(item.text);
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function studioPlayState(result) {
|
|
40
|
+
const states = new Set();
|
|
41
|
+
const mode = value => {
|
|
42
|
+
if (typeof value !== 'string') return;
|
|
43
|
+
if (/^(edit|stopped|stop)$/i.test(value)) states.add('edit');
|
|
44
|
+
if (/^(play|playing|run|running)$/i.test(value)) states.add('play');
|
|
45
|
+
};
|
|
46
|
+
for (const value of values(result)) {
|
|
47
|
+
if (typeof value === 'string') {
|
|
48
|
+
const match = /(?:^|\n)\s*(?:current\s+)?(?:studio\s+)?(?:play\s*(?:state|mode)|state|mode)\s*:\s*(Edit|Stopped|Playing|Play|Running|Run)\s*(?:$|\n)/i.exec(value);
|
|
49
|
+
if (match) mode(match[1]);
|
|
50
|
+
} else if (value && !Array.isArray(value) && typeof value === 'object') {
|
|
51
|
+
for (const key of ['playState', 'play_state', 'playMode', 'play_mode', 'state']) mode(value[key]);
|
|
52
|
+
for (const key of ['isPlaying', 'is_playing', 'isRunning', 'is_running']) if (typeof value[key] === 'boolean') states.add(value[key] ? 'play' : 'edit');
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (states.size !== 1) throw new Error('Studio did not report an unambiguous supported play state. No play session was changed.');
|
|
56
|
+
return [...states][0];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function probeValue(result) {
|
|
60
|
+
for (const value of values(result)) if (typeof value === 'string') {
|
|
61
|
+
const index = value.indexOf(MARKER);
|
|
62
|
+
if (index >= 0) { try { return JSON.parse(value.slice(index + MARKER.length)); } catch { /* require a complete probe result */ } }
|
|
63
|
+
}
|
|
64
|
+
throw new Error('Studio did not return the complete source-binding probe.');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function studioProbe(config) {
|
|
68
|
+
return `local paths={${config.scripts.map(s => `{${s.path.map(luauString).join(',')}}`).join(',')}}
|
|
69
|
+
local sources={}
|
|
70
|
+
local checked=0
|
|
71
|
+
for _,path in ipairs(paths) do
|
|
72
|
+
local node=game
|
|
73
|
+
for _,name in ipairs(path) do
|
|
74
|
+
local found=nil
|
|
75
|
+
for _,child in ipairs(node:GetChildren()) do
|
|
76
|
+
checked+=1
|
|
77
|
+
if checked%500==0 then task.wait() end
|
|
78
|
+
if child.Name==name then if found then error('Ambiguous review source path') end found=child end
|
|
79
|
+
end
|
|
80
|
+
if not found then error('Missing review source path') end
|
|
81
|
+
node=found
|
|
82
|
+
end
|
|
83
|
+
if not node:IsA('LuaSourceContainer') then error('Review source is not a script') end
|
|
84
|
+
local source=node.Source
|
|
85
|
+
if #source>131072 then error('Review source exceeds byte bound') end
|
|
86
|
+
table.insert(sources,source)
|
|
87
|
+
end
|
|
88
|
+
return '${MARKER}'..game:GetService('HttpService'):JSONEncode({fixture=game:GetService('ReplicatedStorage'):GetAttribute('FlowviantReviewWorktree'),sources=sources})`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function openRobloxPage({ command, cwd, worktree, configuration, env, signal, onChild, onFrame = () => {}, onChange = () => {}, timeoutMs = 600_000,
|
|
92
|
+
connect = connectReviewMcp, imageFactory = createReviewImage, frameIntervalMs = 2000, startupWaitMs = 10_000 }) {
|
|
93
|
+
const config = robloxConfiguration(configuration), wt = realpathSync(worktree), fixture = basename(wt);
|
|
94
|
+
const expectedName = config.studioName.replaceAll('${WORKTREE}', fixture);
|
|
95
|
+
let mcp, encoder, studioId, ownedPlay = false, marked = false, closed = false, closePromise, timer, epoch = 0, captureSerial = 0, privateUsed = false, lastImage, queue = Promise.resolve();
|
|
96
|
+
const session = randomUUID();
|
|
97
|
+
const sourceHashes = [];
|
|
98
|
+
const tool = (name, args = {}, limit) => mcp.call(name, { ...args, studio_id: studioId }, limit);
|
|
99
|
+
const serial = fn => { const result = queue.catch(() => {}).then(fn); queue = result; return result; };
|
|
100
|
+
const close = () => {
|
|
101
|
+
if (closePromise) return closePromise;
|
|
102
|
+
closed = true; epoch++; clearTimeout(timer);
|
|
103
|
+
closePromise = (async () => {
|
|
104
|
+
// Only stop a play session this adapter started; never close Studio.
|
|
105
|
+
try {
|
|
106
|
+
await queue.catch(() => {});
|
|
107
|
+
if (ownedPlay && marked) {
|
|
108
|
+
const current = probeValue(await tool('execute_luau', { datamodel_type: 'Client', code: CLIENT_PROBE }, 4000));
|
|
109
|
+
if (current.session !== session) ownedPlay = false;
|
|
110
|
+
}
|
|
111
|
+
if (ownedPlay) await tool('start_stop_play', { is_start: false }, 8000);
|
|
112
|
+
}
|
|
113
|
+
finally { await Promise.allSettled([mcp?.close(), encoder?.close()]); }
|
|
114
|
+
})();
|
|
115
|
+
return closePromise;
|
|
116
|
+
};
|
|
117
|
+
const capture = async () => {
|
|
118
|
+
const viewport = probeValue(await tool('execute_luau', { datamodel_type: 'Client', code: CLIENT_PROBE }));
|
|
119
|
+
if (ownedPlay && marked && viewport.session !== session) { ownedPlay = false; throw new Error('Studio play was replaced outside this review; the replacement session was left alone.'); }
|
|
120
|
+
if (viewport.player !== true || !(viewport.width > 0 && viewport.height > 0)) throw new Error('No player camera viewport is available in Studio.');
|
|
121
|
+
const result = await tool('screen_capture', { capture_id: `Flowviant_${epoch}_${++captureSerial}` });
|
|
122
|
+
const images = (result.content ?? []).filter(c => c.type === 'image');
|
|
123
|
+
if (images.length !== 1) throw new Error('Studio screen_capture must return one native image content block.');
|
|
124
|
+
const image = await encoder.encode(images[0]);
|
|
125
|
+
if (Math.abs((image.width / image.height) / (viewport.width / viewport.height) - 1) > 0.01) throw new Error('Studio capture and player viewport aspect ratios differ; coordinate input cannot be mapped reliably.');
|
|
126
|
+
return { ...image, inputWidth: viewport.width, inputHeight: viewport.height };
|
|
127
|
+
};
|
|
128
|
+
const stream = async () => {
|
|
129
|
+
const current = epoch;
|
|
130
|
+
try { const image = await serial(capture); if (!closed && current === epoch) { lastImage = image; onFrame({ type: 'frame', data: image.data, width: 1440, height: 900 }); } }
|
|
131
|
+
catch (error) { if (!closed && current === epoch) { onChange({ error: reviewDiagnostic(error.message) }); void close().catch(() => {}); } }
|
|
132
|
+
finally { if (!closed && current === epoch) timer = setTimeout(stream, frameIntervalMs); }
|
|
133
|
+
};
|
|
134
|
+
try {
|
|
135
|
+
signal?.throwIfAborted();
|
|
136
|
+
// Keep dependencies alive through the outer bridge's 30-second cleanup
|
|
137
|
+
// grace, including a queued capture plus session-check/stop round trips.
|
|
138
|
+
mcp = await connect({ command, cwd, env, onChild, timeoutMs: timeoutMs + 35_000, detached: false });
|
|
139
|
+
for (const required of ['list_roblox_studios', 'get_studio_state', 'execute_luau', 'screen_capture', 'user_mouse_input', 'user_keyboard_input', ...(config.startPlay ? ['start_stop_play'] : [])]) {
|
|
140
|
+
if (!mcp.tools.some(t => t.name === required)) throw new Error(`Studio MCP is missing ${required}. Update Studio and enable its built-in MCP server.`);
|
|
141
|
+
}
|
|
142
|
+
const listed = await mcp.call('list_roblox_studios', {});
|
|
143
|
+
const studios = values(listed).find(value => value && !Array.isArray(value) && Array.isArray(value.studios))?.studios;
|
|
144
|
+
if (!studios) throw new Error('Studio MCP did not return a supported Studio instance list.');
|
|
145
|
+
const selected = studios.filter(studio => studio.name === expectedName && typeof studio.id === 'string');
|
|
146
|
+
if (selected.length !== 1) throw new Error(`Open exactly one disposable Studio fixture named ${expectedName}. ${selected.length ? 'Several matching instances are connected.' : 'No matching instance is connected.'}`);
|
|
147
|
+
studioId = selected[0].id;
|
|
148
|
+
const initial = studioPlayState(await tool('get_studio_state'));
|
|
149
|
+
if (config.startPlay && initial !== 'edit') throw new Error('This Studio fixture already has a play session. Return it to Edit mode, or configure startPlay:false to watch the existing session.');
|
|
150
|
+
const loaded = probeValue(await tool('execute_luau', { datamodel_type: 'Edit', code: studioProbe(config) }));
|
|
151
|
+
if (loaded.fixture !== fixture) throw new Error('Studio fixture is not bound to this worktree. Set the FlowviantReviewWorktree attribute on ReplicatedStorage to the worktree folder name in the disposable fixture.');
|
|
152
|
+
if (!Array.isArray(loaded.sources) || loaded.sources.length !== config.scripts.length) throw new Error('Studio did not return every mapped source.');
|
|
153
|
+
for (let index = 0; index < config.scripts.length; index++) {
|
|
154
|
+
const source = config.scripts[index], file = realpathSync(resolve(wt, source.file)), rel = relative(wt, file);
|
|
155
|
+
if (isAbsolute(rel) || rel === '..' || rel.startsWith('../')) throw new Error('A mapped Studio source escapes the worktree.');
|
|
156
|
+
const bytes = readFileSync(file); if (bytes.length > 128 * 1024 || typeof loaded.sources[index] !== 'string' || loaded.sources[index].length > 128 * 1024) throw new Error('Mapped Studio sources exceed the per-script bound.');
|
|
157
|
+
if (normalize(bytes.toString('utf8')) !== normalize(loaded.sources[index])) throw new Error(`Studio has stale/different code for ${source.file}. Sync this worktree into its fixture before review.`);
|
|
158
|
+
sourceHashes.push({ file: source.file, sha256: sha(normalize(bytes.toString('utf8'))) });
|
|
159
|
+
}
|
|
160
|
+
encoder = await imageFactory({ onChild, timeoutMs: timeoutMs + 35_000, detached: false });
|
|
161
|
+
signal?.throwIfAborted();
|
|
162
|
+
if (config.startPlay) {
|
|
163
|
+
if (studioPlayState(await tool('get_studio_state')) !== 'edit') throw new Error('Studio started playing during review setup; that existing session was left alone.');
|
|
164
|
+
signal?.throwIfAborted(); ownedPlay = true; await tool('start_stop_play', { is_start: true });
|
|
165
|
+
}
|
|
166
|
+
signal?.throwIfAborted();
|
|
167
|
+
if (studioPlayState(await tool('get_studio_state')) !== 'play') throw new Error('Studio did not enter play mode; no gameplay input was enabled.');
|
|
168
|
+
// Verify Client context rather than treating server-only Run as a player.
|
|
169
|
+
let player = false;
|
|
170
|
+
const until = Date.now() + startupWaitMs;
|
|
171
|
+
do {
|
|
172
|
+
signal?.throwIfAborted();
|
|
173
|
+
try {
|
|
174
|
+
const client = probeValue(await tool('execute_luau', { datamodel_type: 'Client', code: CLIENT_PROBE }, Math.max(1, Math.min(2000, until - Date.now()))));
|
|
175
|
+
player = client.player === true && client.width > 0 && client.height > 0;
|
|
176
|
+
} catch { /* Client context can arrive after the play-state transition. */ }
|
|
177
|
+
if (player || Date.now() >= until) break;
|
|
178
|
+
await new Promise(resolve => setTimeout(resolve, Math.min(250, until - Date.now())));
|
|
179
|
+
} while (Date.now() < until);
|
|
180
|
+
if (!player) throw new Error('No local player was measured in the Studio Client context.');
|
|
181
|
+
if (ownedPlay) {
|
|
182
|
+
const receipt = probeValue(await tool('execute_luau', { datamodel_type: 'Client', code: `game:SetAttribute('FlowviantReviewSession', '${session}')
|
|
183
|
+
return '${MARKER}'..game:GetService('HttpService'):JSONEncode({session=game:GetAttribute('FlowviantReviewSession')})` }));
|
|
184
|
+
if (receipt.session !== session) throw new Error('Studio did not acknowledge the owned play-session marker.');
|
|
185
|
+
marked = true;
|
|
186
|
+
}
|
|
187
|
+
timer = setTimeout(stream, frameIntervalMs);
|
|
188
|
+
return { close,
|
|
189
|
+
async setPrivate(privateInput) { epoch++; clearTimeout(timer); lastImage = null; if (privateInput) privateUsed = true; await queue.catch(() => {}); if (!closed) timer = setTimeout(stream, frameIntervalMs); },
|
|
190
|
+
async snapshot() {
|
|
191
|
+
const current = epoch;
|
|
192
|
+
return serial(async () => {
|
|
193
|
+
if (closed) throw new Error('Studio review ended.');
|
|
194
|
+
if (studioPlayState(await tool('get_studio_state')) !== 'play') throw new Error('The Studio play session ended.');
|
|
195
|
+
const image = await capture();
|
|
196
|
+
let logs = '';
|
|
197
|
+
if (!privateUsed && mcp.tools.some(t => t.name === 'get_console_output')) {
|
|
198
|
+
try { logs = reviewDiagnostic(mcpText(await tool('get_console_output'))).slice(-4000); }
|
|
199
|
+
catch (error) { logs = `Console unavailable: ${reviewDiagnostic(error.message)}`; }
|
|
200
|
+
}
|
|
201
|
+
if (closed || current !== epoch) throw new Error('Studio control changed during capture.');
|
|
202
|
+
lastImage = image;
|
|
203
|
+
return { data: image.data, observation: JSON.stringify({ studio: expectedName, sourceHashes, console: privateUsed ? 'Console omitted after private control.' : logs,
|
|
204
|
+
note: 'Studio viewport observation. Mapped script sources matched at startup; assets and unmapped scripts are unverified. Captures/input do not establish a passing gameplay test. Studio sign-in dialogs require the Windows host.' }), diagnostics: [] };
|
|
205
|
+
});
|
|
206
|
+
},
|
|
207
|
+
async act(input) {
|
|
208
|
+
return serial(async () => {
|
|
209
|
+
if (closed) throw new Error('Studio review ended.');
|
|
210
|
+
if (input.selector || input.type === 'navigate') throw new Error('Studio supports viewport input; browser navigation and DOM selectors do not apply.');
|
|
211
|
+
if (studioPlayState(await tool('get_studio_state')) !== 'play') throw new Error('The Studio play session ended.');
|
|
212
|
+
if (ownedPlay && marked) {
|
|
213
|
+
const current = probeValue(await tool('execute_luau', { datamodel_type: 'Client', code: CLIENT_PROBE }));
|
|
214
|
+
if (current.session !== session) { ownedPlay = false; throw new Error('Studio play was replaced outside this review; the replacement session was left alone.'); }
|
|
215
|
+
}
|
|
216
|
+
const base = { datamodel_type: 'Client' };
|
|
217
|
+
if (input.type === 'click') {
|
|
218
|
+
const image = lastImage;
|
|
219
|
+
if (!image || input.x < image.left || input.y < image.top || input.x >= image.left + image.w || input.y >= image.top + image.h) throw new Error('Click inside the captured Studio viewport.');
|
|
220
|
+
await tool('user_mouse_input', { ...base, actions: [{ action: 'mouseButtonClick', mouse_button: 'left', x: Math.floor((input.x - image.left) * image.inputWidth / image.w), y: Math.floor((input.y - image.top) * image.inputHeight / image.h) }] });
|
|
221
|
+
} else if (input.type === 'type') await tool('user_keyboard_input', { ...base, actions: [{ action: 'textInput', text_inputs: input.text }] });
|
|
222
|
+
else if (input.type === 'key') {
|
|
223
|
+
const key = ({ Enter: 'Return', ArrowUp: 'Up', ArrowDown: 'Down', ArrowLeft: 'Left', ArrowRight: 'Right' })[input.key] ?? input.key;
|
|
224
|
+
await tool('user_keyboard_input', { ...base, actions: [{ action: 'keyPress', key_code: key }] });
|
|
225
|
+
} else if (input.type === 'scroll' && input.deltaY !== 0) {
|
|
226
|
+
if (!lastImage) throw new Error('Observe the Studio viewport before scrolling.');
|
|
227
|
+
await tool('user_mouse_input', { ...base, actions: Array.from({ length: Math.min(10, Math.ceil(Math.abs(input.deltaY) / 100)) }, () => ({ action: input.deltaY < 0 ? 'scrollUp' : 'scrollDown', x: Math.floor(lastImage.inputWidth / 2), y: Math.floor(lastImage.inputHeight / 2) })) });
|
|
228
|
+
}
|
|
229
|
+
else if (!['snapshot', 'scroll'].includes(input.type)) throw new Error('Studio supports viewport input; browser navigation and DOM selectors do not apply.');
|
|
230
|
+
});
|
|
231
|
+
},
|
|
232
|
+
};
|
|
233
|
+
} catch (error) { await close().catch(() => {}); throw error; }
|
|
234
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/** Windows Studio through WSL stdio; no Windows daemon or desktop capture. */
|
|
2
|
+
import { createInterface } from 'node:readline';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { mkdirSync } from 'node:fs';
|
|
5
|
+
import { homedir } from 'node:os';
|
|
6
|
+
import { basename, join } from 'node:path';
|
|
7
|
+
import { openBridgePage } from './reviewBridge.mjs';
|
|
8
|
+
import { openRobloxPage, robloxConfiguration } from './robloxReview.mjs';
|
|
9
|
+
import { heldToolArgv } from './heldTool.mjs';
|
|
10
|
+
import { browserAction } from './managedBrowser.mjs';
|
|
11
|
+
import { reviewDiagnostic } from './uiReviewBrowser.mjs';
|
|
12
|
+
import { commandOnPath } from './reviewTargets.mjs';
|
|
13
|
+
|
|
14
|
+
export function openRobloxStudio({ command, cwd, worktree, configuration, ...options }) {
|
|
15
|
+
const config = robloxConfiguration(configuration);
|
|
16
|
+
const name = config.studioName.replaceAll('${WORKTREE}', basename(worktree));
|
|
17
|
+
const flock = commandOnPath('flock', options.env);
|
|
18
|
+
if (process.platform !== 'linux' || !flock) throw new Error('The first Studio adapter requires a Linux/WSL daemon with flock and a configured Windows Studio MCP launcher.');
|
|
19
|
+
const directory = join(homedir(), '.flowviant', 'review-studios'); mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
20
|
+
const lock = join(directory, createHash('sha256').update(name).digest('hex') + '.lock');
|
|
21
|
+
return openBridgePage({ ...options, command: [flock, '-n', '-E', '75', lock, process.execPath, ...heldToolArgv('__roblox-studio-bridge')], cwd, worktree,
|
|
22
|
+
configuration: { ...config, command, cwd, timeoutMs: options.timeoutMs }, requestTimeoutMs: 60_000, closeGraceMs: 30_000,
|
|
23
|
+
}).catch(error => {
|
|
24
|
+
if (/exited \(75\)/.test(error.message)) throw new Error('This Studio fixture is already being reviewed by another local turn. Return or stop that review first.');
|
|
25
|
+
throw error;
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function robloxStudioBridgeMain() {
|
|
30
|
+
const controller = new AbortController();
|
|
31
|
+
let page, configuration, worktree, epoch = 0, closing = false, shuttingDown;
|
|
32
|
+
const send = value => { if (!closing) process.stdout.write(JSON.stringify({ jsonrpc: '2.0', ...value }) + '\n'); };
|
|
33
|
+
const shutdown = () => {
|
|
34
|
+
if (shuttingDown) return shuttingDown;
|
|
35
|
+
closing = true; controller.abort();
|
|
36
|
+
shuttingDown = Promise.resolve().then(() => page?.close()).catch(() => { process.exitCode = 1; });
|
|
37
|
+
return shuttingDown;
|
|
38
|
+
};
|
|
39
|
+
process.stdin.once('end', () => { void shutdown(); }); process.stdin.once('error', () => { void shutdown(); }); process.stdout.once('error', () => { void shutdown(); });
|
|
40
|
+
process.once('SIGTERM', () => { void shutdown(); process.stdin.destroy(); }); process.once('SIGINT', () => { void shutdown(); process.stdin.destroy(); });
|
|
41
|
+
try {
|
|
42
|
+
for await (const line of createInterface({ input: process.stdin })) {
|
|
43
|
+
if (closing) break;
|
|
44
|
+
let request;
|
|
45
|
+
try {
|
|
46
|
+
if (Buffer.byteLength(line) > 128 * 1024) throw new Error('Studio bridge request exceeds its byte bound.');
|
|
47
|
+
request = JSON.parse(line);
|
|
48
|
+
const { id, method, params } = request; let result = {};
|
|
49
|
+
if (method === 'initialize' && !configuration) {
|
|
50
|
+
configuration = params.configuration; worktree = params.worktree;
|
|
51
|
+
if (params.protocol !== 1 || !Array.isArray(configuration?.command) || typeof worktree !== 'string') throw new Error('Invalid Studio bridge initialization.');
|
|
52
|
+
robloxConfiguration(configuration);
|
|
53
|
+
result = { protocol: 1, isolation: 'consented-window', privateHandoff: true };
|
|
54
|
+
} else if (method === 'open' && configuration && !page) {
|
|
55
|
+
const opened = await openRobloxPage({ command: configuration.command, cwd: configuration.cwd, worktree, configuration, env: process.env, signal: controller.signal, timeoutMs: configuration.timeoutMs,
|
|
56
|
+
onFrame: frame => send({ method: 'frame', params: { ...frame, epoch } }), onChange: patch => send({ method: 'failure', params: { epoch, message: patch.error } }) });
|
|
57
|
+
page = opened;
|
|
58
|
+
// Parent EOF may have arrived while Studio was starting play.
|
|
59
|
+
if (closing) await opened.close();
|
|
60
|
+
} else if (method === 'snapshot' && page) result = { ...await page.snapshot(), width: 1440, height: 900, epoch };
|
|
61
|
+
else if (method === 'act' && page && params.epoch === epoch) await page.act(browserAction(params.input));
|
|
62
|
+
else if (method === 'privacy' && page) { await page.setPrivate(params.private); epoch = params.epoch; result = { epoch, drained: true }; }
|
|
63
|
+
else throw new Error('Unsupported or stale Studio bridge operation.');
|
|
64
|
+
send({ id, result });
|
|
65
|
+
} catch (error) { send({ id: request?.id, error: { message: reviewDiagnostic(error.message) } }); }
|
|
66
|
+
}
|
|
67
|
+
} finally { await shutdown(); }
|
|
68
|
+
}
|
package/bin/lib/runTurn.mjs
CHANGED
|
@@ -27,6 +27,7 @@ import { createTurnOutput } from './turnOutput.mjs';
|
|
|
27
27
|
import { createObservedLines } from './observedLines.mjs';
|
|
28
28
|
import { startTurnDiagnostics } from './turnDiagnostics.mjs';
|
|
29
29
|
import { createClaudeInput } from './claudeInput.mjs';
|
|
30
|
+
import { codexAppArgs, createCodexInput } from './codexInput.mjs';
|
|
30
31
|
import { measuredRuntimeVersion } from './runtimeDetection.mjs';
|
|
31
32
|
import { createTurnSubagents } from './turnSubagents.mjs';
|
|
32
33
|
import { withPublicReference } from './publicReferenceRuntime.mjs';
|
|
@@ -79,7 +80,7 @@ const MAX_ARG_BYTES = 128 * 1024;
|
|
|
79
80
|
|
|
80
81
|
export function runTurn(opts) {
|
|
81
82
|
if (workLanesStopped()) return never();
|
|
82
|
-
if ((opts.profile === 'research' || opts.researchIdentity) && ['claude', 'codex'].includes(opts.runtime ?? 'claude'))
|
|
83
|
+
if (!opts.crossCliChild && (opts.profile === 'research' || opts.researchIdentity) && ['claude', 'codex'].includes(opts.runtime ?? 'claude'))
|
|
83
84
|
return withPublicReference(opts, runProfileTurn);
|
|
84
85
|
return runProfileTurn(opts);
|
|
85
86
|
}
|
|
@@ -88,7 +89,7 @@ function runProfileTurn(opts) {
|
|
|
88
89
|
// A Codex write-up runs over an isolated copy of its worktree
|
|
89
90
|
// (codexResearch.mjs). After the teardown it copies nothing either: the
|
|
90
91
|
// stand-down gate below is asked before the copy, not only before the spawn.
|
|
91
|
-
if (opts.runtime === 'codex' && opts.profile === 'research')
|
|
92
|
+
if (!opts.crossCliChild && opts.runtime === 'codex' && opts.profile === 'research')
|
|
92
93
|
return workLanesStopped() ? never() : runIsolatedResearch(opts, prepared => runResearchWithReceipt(prepared, runPlainTurn));
|
|
93
94
|
if (opts.profile === 'research' && opts.researchHandoff)
|
|
94
95
|
return prepareResearchLaunch(opts).then(prepared => runResearchWithReceipt(prepared, runPlainTurn));
|
|
@@ -208,6 +209,8 @@ function runPlainTurn(opts) {
|
|
|
208
209
|
verifiedWorktree: opts.verifiedWorktree === true,
|
|
209
210
|
orchestration: opts.orchestration === true,
|
|
210
211
|
readOnlyCoordination: opts.readOnlyCoordination === true,
|
|
212
|
+
// A subagent on another CLI's card (crossCliChild.mjs, crossCliPosture.mjs).
|
|
213
|
+
crossCliChild: opts.crossCliChild === true,
|
|
211
214
|
// The lane's own measurement when it decided to orchestrate, so the
|
|
212
215
|
// hooks and the controller cannot disagree (workAgentTurnExecution.mjs).
|
|
213
216
|
codexVersion: opts.codexVersion ?? measuredRuntimeVersion('codex')?.version,
|
|
@@ -303,8 +306,13 @@ function runPlainTurn(opts) {
|
|
|
303
306
|
* so no throw on the way leaves that file behind: the sweep takes only a
|
|
304
307
|
* dead process's leftovers, and this one names the daemon's own pid.
|
|
305
308
|
*/
|
|
309
|
+
const nativeVersion = opts.codexVersion ?? measuredRuntimeVersion('codex')?.version;
|
|
310
|
+
// Only a measured native protocol switches transport. Unknown/older CLIs
|
|
311
|
+
// still receive the original prompt and durable next-turn queue via exec.
|
|
312
|
+
const appArgs = rt.id === 'codex' && opts.liveMessages && nativeVersion ? codexAppArgs(args, nativeVersion) : null;
|
|
313
|
+
const codexApp = Boolean(appArgs);
|
|
306
314
|
let transport;
|
|
307
|
-
try { transport = promptTransport(rt.id, args, stdinText); }
|
|
315
|
+
try { transport = appArgs ? { args: appArgs, text: null, cleanup() {} } : promptTransport(rt.id, args, stdinText); }
|
|
308
316
|
catch (error) { rt.releaseArgs?.(args); throw error; }
|
|
309
317
|
args.splice(0, args.length, ...transport.args);
|
|
310
318
|
let argsReleased = false;
|
|
@@ -403,7 +411,13 @@ function runPlainTurn(opts) {
|
|
|
403
411
|
const spawnedAt = Date.now();
|
|
404
412
|
let out = '';
|
|
405
413
|
let stdinError = null;
|
|
406
|
-
const input =
|
|
414
|
+
const input = codexApp ? createCodexInput(child, { cwd, threadId: resumeThreadId,
|
|
415
|
+
onTaken: id => { if (!id.startsWith('recovery-')) opts.liveMessages.onTaken(id); },
|
|
416
|
+
onThread: id => opts.liveMessages.onInit(id),
|
|
417
|
+
onEvent: event => onLine(JSON.stringify(event), Date.now(), true),
|
|
418
|
+
onFailure: () => { cliFailed = true; opts.onCliFailure?.(); },
|
|
419
|
+
onContext: opts.onContext ?? null,
|
|
420
|
+
}) : !rt.parse && rt.liveInput === true && opts.liveMessages ? createClaudeInput(child, { onTaken: opts.liveMessages.onTaken, onContext: opts.onContext ?? null }) : null;
|
|
407
421
|
// EVERY TURN IS LINE-PARSED (2026-10-10, the audit). Claude's adapter
|
|
408
422
|
// always asks for `--output-format stream-json` and every other runtime
|
|
409
423
|
// brings its own `parse` — for Codex the JSONL stream is the only output
|
|
@@ -436,7 +450,7 @@ function runPlainTurn(opts) {
|
|
|
436
450
|
};
|
|
437
451
|
// The native children this CLI starts, observed (turnSubagents.mjs).
|
|
438
452
|
const natives = createTurnSubagents({ runtime: rt.id, cwd, since: spawnedAt, env: { ...process.env, ...(mcpEnv ?? {}) },
|
|
439
|
-
owned: opts.orchestration === true, onSubagent: opts.onSubagent, onSpawnResult: opts.onSpawnResult, resumeThreadId,
|
|
453
|
+
owned: opts.orchestration === true, onSubagent: opts.onSubagent, onWorkflow: opts.onWorkflow, onSpawnResult: opts.onSpawnResult, resumeThreadId, knownSubagents: opts.knownSubagents,
|
|
440
454
|
sinks: { emit, onActivity, onToolEvent, appendText, onDetail: output.detail } });
|
|
441
455
|
let codexThread = rt.id === 'codex' && typeof resumeThreadId === 'string' ? resumeThreadId : null;
|
|
442
456
|
/**
|
|
@@ -471,14 +485,18 @@ function runPlainTurn(opts) {
|
|
|
471
485
|
const taskPlan = new Map();
|
|
472
486
|
/** One line of the child's stdout, in whichever dialect it speaks. */
|
|
473
487
|
let cliFailed = false;
|
|
474
|
-
const onLine = (line, observedAt) => {
|
|
488
|
+
const onLine = (line, observedAt, nativeEvent = false) => {
|
|
489
|
+
if (codexApp && !nativeEvent) {
|
|
490
|
+
try { input.event(JSON.parse(line)); } catch { /* non-protocol stdout */ }
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
475
493
|
const emitObserved = (text, meta) => emit(text, { ...meta, observedAt });
|
|
476
494
|
const toolObserved = (name, input, meta) => onToolEvent(name, input, meta, observedAt);
|
|
477
495
|
try {
|
|
478
496
|
const event = JSON.parse(line);
|
|
479
497
|
const parentEvent = event.parent_tool_use_id == null && !event.isSidechain;
|
|
480
498
|
opts.researchReceipt?.event(event);
|
|
481
|
-
if (input && parentEvent) {
|
|
499
|
+
if (input && !codexApp && parentEvent) {
|
|
482
500
|
input.event(event);
|
|
483
501
|
if (event.type === 'system' && event.subtype === 'init' && event.session_id) opts.liveMessages.onInit(event.session_id);
|
|
484
502
|
// A message written just before result may become a second native
|
|
@@ -486,6 +504,7 @@ function runPlainTurn(opts) {
|
|
|
486
504
|
// structured objects joined together; every result still reports spend.
|
|
487
505
|
if (event.type === 'result') out = '';
|
|
488
506
|
}
|
|
507
|
+
if (codexApp && event.type === 'turn.started') out = '';
|
|
489
508
|
if (parentEvent && ((event.type === 'result' && event.is_error === true) ||
|
|
490
509
|
event.type === 'error' || event.type === 'turn.failed')) {
|
|
491
510
|
cliFailed = true;
|
|
@@ -500,7 +519,9 @@ function runPlainTurn(opts) {
|
|
|
500
519
|
// thread.started). Purely additive: callers that pass no onThreadId —
|
|
501
520
|
// every dispatch path — see zero behavior change.
|
|
502
521
|
if (ev.threadId) {
|
|
503
|
-
if (rt.id === 'codex') { codexThread = ev.threadId; natives.thread(ev.threadId);
|
|
522
|
+
if (rt.id === 'codex') { codexThread = ev.threadId; natives.thread(ev.threadId);
|
|
523
|
+
if (!input && opts.liveMessages) opts.liveMessages.onTaken(opts.liveMessages.turnId);
|
|
524
|
+
}
|
|
504
525
|
onThreadId?.(ev.threadId);
|
|
505
526
|
}
|
|
506
527
|
// The runtime's own token count (codex's `turn.completed`) — the same
|
|
@@ -657,6 +678,7 @@ function runPlainTurn(opts) {
|
|
|
657
678
|
if (input) {
|
|
658
679
|
const initial = opts.liveMessages.initialPrompt(prompt, resumeThreadId, resume, opts.liveContinuation === true);
|
|
659
680
|
input.send(opts.liveMessages.initialId || opts.liveMessages.turnId, initial);
|
|
681
|
+
if (codexApp) input.start();
|
|
660
682
|
opts.liveMessages.connect(input.send);
|
|
661
683
|
}
|
|
662
684
|
});
|
|
@@ -21,6 +21,7 @@ import { withSettings } from './claudeSettingsArg.mjs';
|
|
|
21
21
|
import { orchestratorHooks, claudeOrchestrationSafe } from './orchestratorHooks.mjs';
|
|
22
22
|
import { orchestratesPosture } from './orchestratorPrompt.mjs';
|
|
23
23
|
import { versionAtLeast } from './versionOrder.mjs';
|
|
24
|
+
import { crossCliClaudePerm } from './crossCliPosture.mjs';
|
|
24
25
|
|
|
25
26
|
/** The release the agent lane first measured `--system-prompt-snapshot` on
|
|
26
27
|
* (decisions.md, 2026-09-24). */
|
|
@@ -123,7 +124,7 @@ export const CLAUDE_RUNTIME = {
|
|
|
123
124
|
* strongest form of it available anywhere: `--append-system-prompt` sits
|
|
124
125
|
* above the conversation rather than inside it.
|
|
125
126
|
*/
|
|
126
|
-
args({ managedBrowser = false, writableDirs = [], prompt, system, model, effort, resume, perm: postureList, profile = 'build', mcp = [], resultSchemaArgs = [], adoptResumeId, resumeThreadId, knowledgeDir, vaultDir, agentTools, personalSettings, machineMcp, baseMcp = [], mcpCheckout = null, streamInput = false, orchestration = false, readOnlyCoordination = false, claudeVersion = null }) {
|
|
127
|
+
args({ managedBrowser = false, writableDirs = [], prompt, system, model, effort, resume, perm: postureList, profile = 'build', mcp = [], resultSchemaArgs = [], adoptResumeId, resumeThreadId, knowledgeDir, vaultDir, agentTools, personalSettings, machineMcp, baseMcp = [], mcpCheckout = null, streamInput = false, orchestration = false, readOnlyCoordination = false, claudeVersion = null, crossCliChild = false, cwd }) {
|
|
127
128
|
// THE CARTOGRAPHER'S FENCE NAMES ITS VAULT (2026-10-10): its one write rule
|
|
128
129
|
// is the vault's absolute path (claudePosture.mjs `wikiPermFor`), so a turn
|
|
129
130
|
// with no vault, or one no rule can name, has no list — refused here in
|
|
@@ -195,7 +196,10 @@ export const CLAUDE_RUNTIME = {
|
|
|
195
196
|
// a research card's list then admits the children its fence denied.
|
|
196
197
|
const readCoordinator = orchestration && readOnlyCoordination && ['consult', 'plan'].includes(profile) && claudeOrchestrationSafe(claudeVersion);
|
|
197
198
|
const gated = readCoordinator || orchestration && orchestratesPosture(profile);
|
|
198
|
-
|
|
199
|
+
// A subagent on another CLI's card runs the build fenced to its worktree,
|
|
200
|
+
// with no children of its own (crossCliPosture.mjs).
|
|
201
|
+
const perm = readCoordinator ? withReadOnlyCoordination(postureList) : gated && fenced ? withNativeChildren(postureList)
|
|
202
|
+
: crossCliChild ? crossCliClaudePerm(postureList, cwd, profile) : postureList;
|
|
199
203
|
if (fenced) a.push(...NO_SETTING_SOURCES);
|
|
200
204
|
// A FENCED POSTURE NAMES ITS OWN MODE (2026-10-10). Its lists fence only
|
|
201
205
|
// under the CLI's `default` mode, where a tool the list does not name is
|
|
@@ -205,7 +209,7 @@ export const CLAUDE_RUNTIME = {
|
|
|
205
209
|
// classifier that may approve what the list never named. So the posture
|
|
206
210
|
// states `default` itself, unless its own list names a mode (plan-mode).
|
|
207
211
|
if (fenced && !perm.includes('--permission-mode')) a.push('--permission-mode', 'default');
|
|
208
|
-
else if (agentTools) a.push('--setting-sources', 'user');
|
|
212
|
+
else if (agentTools || crossCliChild) a.push('--setting-sources', 'user');
|
|
209
213
|
// EVERY TURN RENDERS ITS OWN SYSTEM PROMPT (2026-10-19). By default the
|
|
210
214
|
// CLI records a conversation's first system prompt and resends that record
|
|
211
215
|
// on every resume "even when a later launch passes different text" —
|
package/bin/lib/runtimeCodex.mjs
CHANGED
|
@@ -19,6 +19,7 @@ import { projectReadBoundary, projectReadFence } from './codexProjectRead.mjs';
|
|
|
19
19
|
import { versionAtLeast } from './versionOrder.mjs';
|
|
20
20
|
import { codexOrchestratorArgs } from './orchestratorHooks.mjs';
|
|
21
21
|
import { orchestratesPosture } from './orchestratorPrompt.mjs';
|
|
22
|
+
import { CROSS_CLI_TMP_FENCE } from './crossCliPosture.mjs';
|
|
22
23
|
|
|
23
24
|
/**
|
|
24
25
|
* FLOWVIANT'S OWN TOOLS NEED NO APPROVAL FROM A CHAT NOBODY CAN ANSWER
|
|
@@ -215,7 +216,7 @@ export const CODEX_RUNTIME = {
|
|
|
215
216
|
bin: 'codex',
|
|
216
217
|
install: 'npm i -g @openai/codex',
|
|
217
218
|
login: 'codex login',
|
|
218
|
-
liveInput:
|
|
219
|
+
liveInput: true,
|
|
219
220
|
/**
|
|
220
221
|
* BUILD AND CONSULT. See `args()` below for how consult is expressed — the
|
|
221
222
|
* short version is that Codex keeps the promise at the kernel rather than in
|
|
@@ -281,7 +282,7 @@ export const CODEX_RUNTIME = {
|
|
|
281
282
|
* limit even when its argv plus environment is far below ARG_MAX. Both exec
|
|
282
283
|
* and exec resume support `-` (verified on Codex 0.160.0, 2026-10-16).
|
|
283
284
|
*/
|
|
284
|
-
args({ managedBrowser = false, streamInput = false, writableDirs = [], prompt, system, model, effort, resume, resumeThreadId, profile = 'build', vaultDir, cwd, mcp = [], resultSchemaArgs = [], adoptResumeId, agentTools, personal = null, researchIsolation, knowledgeDir, projectReads, cardTurn = false, verifiedWorktree = false, codexVersion, orchestration = false, readOnlyCoordination = false }) {
|
|
285
|
+
args({ managedBrowser = false, streamInput = false, writableDirs = [], prompt, system, model, effort, resume, resumeThreadId, profile = 'build', vaultDir, cwd, mcp = [], resultSchemaArgs = [], adoptResumeId, agentTools, personal = null, researchIsolation, knowledgeDir, projectReads, cardTurn = false, verifiedWorktree = false, codexVersion, orchestration = false, readOnlyCoordination = false, crossCliChild = false }) {
|
|
285
286
|
// Adoption resumes a conversation in ITS OWN CLI's store (claude forks,
|
|
286
287
|
// agy moves) — codex has no adoptable store wired yet. Reaching here
|
|
287
288
|
// with an adopt id is a wiring mistake upstream, and it fails loudly on
|
|
@@ -319,7 +320,7 @@ export const CODEX_RUNTIME = {
|
|
|
319
320
|
// the parent's sandbox (2026-10-19, orchestratorPrompt.mjs).
|
|
320
321
|
const gated = orchestration && (orchestratesPosture(fenced ? profile : 'build') || readOnlyCoordination && ['consult', 'plan'].includes(profile)) && codexSubagentsSafe(codexVersion);
|
|
321
322
|
if (fenced) {
|
|
322
|
-
a.push(...CODEX_FENCES[profile]({ cwd, vaultDir, researchIsolation, knowledgeDir, projectReads, subagents: gated, codexVersion }));
|
|
323
|
+
a.push(...CODEX_FENCES[profile]({ cwd, vaultDir, researchIsolation, knowledgeDir, projectReads, subagents: gated, codexVersion, crossCliChild }));
|
|
323
324
|
} else {
|
|
324
325
|
// The daemon's build posture, mapped: SAFE keeps writes inside the
|
|
325
326
|
// workspace, the default lets the agent run its own tests and git
|
|
@@ -334,7 +335,9 @@ export const CODEX_RUNTIME = {
|
|
|
334
335
|
if (writableDirs.length) a.push('-c', `sandbox_workspace_write.writable_roots=${JSON.stringify(writableDirs)}`);
|
|
335
336
|
a.push('-c', `sandbox_mode="${SAFE ? 'workspace-write' : 'danger-full-access'}"`);
|
|
336
337
|
if (cardTurn) a.push('-c', 'approval_policy="never"', ...(agentTools?.codexApprovals ?? []));
|
|
337
|
-
|
|
338
|
+
// A subagent on another CLI's card has none of its own (crossCliPosture.mjs).
|
|
339
|
+
a.push(...(crossCliChild ? [...noSubagents(codexVersion), ...(SAFE ? CROSS_CLI_TMP_FENCE : [])]
|
|
340
|
+
: cardTurn && profile === 'build' && codexSubagentsSafe(codexVersion) ? CARD_SUBAGENTS : noSubagents(codexVersion)));
|
|
338
341
|
}
|
|
339
342
|
|
|
340
343
|
/**
|
|
@@ -521,9 +524,12 @@ const CODEX_FENCES = {
|
|
|
521
524
|
// An orchestrated research card's children (2026-10-19) inherit this same
|
|
522
525
|
// permission profile, approval policy and cwd (0.160.0 child_config.rs);
|
|
523
526
|
// the gate refuses their writes.
|
|
524
|
-
research: ({ researchIsolation, subagents = false, codexVersion }) => {
|
|
527
|
+
research: ({ researchIsolation, subagents = false, codexVersion, crossCliChild = false }) => {
|
|
525
528
|
if (!researchIsolation) throw new Error('Codex research requires its isolated input copy.');
|
|
526
|
-
|
|
529
|
+
const fence = researchFence(researchIsolation);
|
|
530
|
+
// A child returns findings to its lead: even the artifact/scratch roots read only.
|
|
531
|
+
return [...(crossCliChild ? fence.map(a => a.startsWith('permissions.flowviantresearch.filesystem=') ? a.replaceAll('="write"', '="read"') : a) : fence),
|
|
532
|
+
...fencedTail({ web: true, imageGeneration: !crossCliChild, subagents: !crossCliChild && subagents, codexVersion })];
|
|
527
533
|
},
|
|
528
534
|
};
|
|
529
535
|
/** The fenced profile names — for the test that holds both tables to one set. */
|