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.
Files changed (72) hide show
  1. package/README.md +364 -0
  2. package/bin/cli.mjs +23 -0
  3. package/bin/lib/agentPlan.mjs +8 -0
  4. package/bin/lib/agentQueueContext.mjs +6 -0
  5. package/bin/lib/agentSplit.mjs +90 -0
  6. package/bin/lib/architectureContract.mjs +57 -0
  7. package/bin/lib/architectureReport.mjs +38 -0
  8. package/bin/lib/artifactContracts.mjs +21 -2
  9. package/bin/lib/claudeInput.mjs +1 -1
  10. package/bin/lib/claudeSubagents.mjs +46 -6
  11. package/bin/lib/claudeWorkflows.mjs +94 -0
  12. package/bin/lib/cliLimit.mjs +5 -0
  13. package/bin/lib/codexInput.mjs +191 -0
  14. package/bin/lib/codexSubagentEvents.mjs +3 -1
  15. package/bin/lib/codexSubagents.mjs +34 -9
  16. package/bin/lib/conversationOutput.mjs +14 -0
  17. package/bin/lib/crossCliChild.mjs +327 -0
  18. package/bin/lib/crossCliLaunch.mjs +92 -0
  19. package/bin/lib/crossCliPosture.mjs +55 -0
  20. package/bin/lib/crossCliResearch.mjs +19 -0
  21. package/bin/lib/environmentProcess.mjs +44 -0
  22. package/bin/lib/fixtures/workflow-ultracode.jsonl +22 -0
  23. package/bin/lib/fleet.mjs +2 -0
  24. package/bin/lib/linuxDesktopBridge.mjs +62 -0
  25. package/bin/lib/managedBrowser.mjs +50 -10
  26. package/bin/lib/mergeBatch.mjs +1 -1
  27. package/bin/lib/nativeAgentTools.mjs +12 -5
  28. package/bin/lib/noWindowEnv.mjs +4 -2
  29. package/bin/lib/orchestrator.mjs +24 -7
  30. package/bin/lib/orchestratorClient.mjs +8 -2
  31. package/bin/lib/orchestratorPrompt.mjs +13 -5
  32. package/bin/lib/orchestratorProtocol.mjs +18 -0
  33. package/bin/lib/outsideCli.mjs +124 -0
  34. package/bin/lib/projectToolRuntimes.mjs +4 -2
  35. package/bin/lib/projectTools.mjs +2 -2
  36. package/bin/lib/prompts.mjs +7 -5
  37. package/bin/lib/repositoryPlaces.mjs +1 -1
  38. package/bin/lib/repositorySetup.mjs +15 -24
  39. package/bin/lib/reviewBridge.mjs +110 -0
  40. package/bin/lib/reviewDatabase.mjs +112 -0
  41. package/bin/lib/reviewDesktop.mjs +153 -0
  42. package/bin/lib/reviewEnvironment.mjs +286 -0
  43. package/bin/lib/reviewEnvironmentConfig.mjs +104 -0
  44. package/bin/lib/reviewEnvironmentMain.mjs +38 -0
  45. package/bin/lib/reviewImage.mjs +58 -0
  46. package/bin/lib/reviewMcpClient.mjs +69 -0
  47. package/bin/lib/reviewProcess.mjs +51 -0
  48. package/bin/lib/reviewTargets.mjs +50 -0
  49. package/bin/lib/robloxReview.mjs +234 -0
  50. package/bin/lib/robloxStudioBridge.mjs +68 -0
  51. package/bin/lib/runTurn.mjs +30 -8
  52. package/bin/lib/runtimeClaude.mjs +7 -3
  53. package/bin/lib/runtimeCodex.mjs +12 -6
  54. package/bin/lib/runtimeLimits.mjs +59 -1
  55. package/bin/lib/runtimeReview.mjs +185 -0
  56. package/bin/lib/splitGate.mjs +115 -0
  57. package/bin/lib/subagentBounds.mjs +26 -0
  58. package/bin/lib/subagentLedger.mjs +110 -0
  59. package/bin/lib/trace.mjs +134 -6
  60. package/bin/lib/turnDeclaration.mjs +4 -4
  61. package/bin/lib/turnGaps.mjs +6 -2
  62. package/bin/lib/turnSubagents.mjs +23 -9
  63. package/bin/lib/uiReview.mjs +5 -2
  64. package/bin/lib/wikiDraft.mjs +9 -0
  65. package/bin/lib/work.mjs +22 -1
  66. package/bin/lib/workAgentCheck.mjs +24 -1
  67. package/bin/lib/workAgentResume.mjs +11 -2
  68. package/bin/lib/workAgentTools.mjs +6 -4
  69. package/bin/lib/workAgentTurnExecution.mjs +77 -9
  70. package/bin/lib/workAgentTurnOutcome.mjs +11 -0
  71. package/bin/lib/workAgentTurnReports.mjs +5 -2
  72. 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
+ }
@@ -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 = rt.liveInput === true && opts.liveMessages ? createClaudeInput(child, { onTaken: opts.liveMessages.onTaken, onContext: opts.onContext ?? null }) : null;
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
- const perm = readCoordinator ? withReadOnlyCoordination(postureList) : gated && fenced ? withNativeChildren(postureList) : postureList;
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" —
@@ -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: false,
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
- a.push(...(cardTurn && profile === 'build' && codexSubagentsSafe(codexVersion) ? CARD_SUBAGENTS : noSubagents(codexVersion)));
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
- return [...researchFence(researchIsolation), ...fencedTail({ web: true, imageGeneration: true, subagents, codexVersion })];
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. */