flowviant 0.113.0 → 0.114.1

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.
@@ -235,7 +235,25 @@ async function lastRateLimits(file) {
235
235
  */
236
236
  export async function learnCodexLimits(threadId, { env = process.env, now } = {}) {
237
237
  try {
238
- if (typeof threadId !== 'string' || !CODEX_THREAD_RE.test(threadId)) return false;
238
+ const file = await codexRolloutFile(threadId, { env });
239
+ if (!file) return false;
240
+ const rl = await lastRateLimits(file);
241
+ if (!rl) return false;
242
+ recordCodexRateLimits(rl, now ?? Date.now());
243
+ return true;
244
+ } catch {
245
+ return false;
246
+ }
247
+ }
248
+
249
+ /**
250
+ * WHERE A CODEX THREAD'S ROLLOUT IS, or null — the one lookup (and its cache)
251
+ * every reader of that file shares: the plan windows above, and the model a
252
+ * turn ran on (turnModel.mjs, 2026-09-29). Never throws.
253
+ */
254
+ export async function codexRolloutFile(threadId, { env = process.env } = {}) {
255
+ try {
256
+ if (typeof threadId !== 'string' || !CODEX_THREAD_RE.test(threadId)) return null;
239
257
  const root = join(env.CODEX_HOME || join(homedir(), '.codex'), 'sessions');
240
258
  const key = `${root}\0${threadId}`;
241
259
  let file = rolloutPaths.get(key) ?? null;
@@ -249,16 +267,13 @@ export async function learnCodexLimits(threadId, { env = process.env, now } = {}
249
267
  }
250
268
  if (!file) {
251
269
  file = await findRollout(root, threadId);
252
- if (!file) return false;
270
+ if (!file) return null;
253
271
  rolloutPaths.set(key, file);
254
272
  if (rolloutPaths.size > ROLLOUT_PATHS_MAX) rolloutPaths.delete(rolloutPaths.keys().next().value);
255
273
  }
256
- const rl = await lastRateLimits(file);
257
- if (!rl) return false;
258
- recordCodexRateLimits(rl, now ?? Date.now());
259
- return true;
274
+ return file;
260
275
  } catch {
261
- return false;
276
+ return null;
262
277
  }
263
278
  }
264
279
 
@@ -156,6 +156,9 @@ const PROFILE_NEEDS_MCP = {
156
156
  // says rides the final JSON — so neither non-code posture needs one.
157
157
  design: false,
158
158
  research: false,
159
+ // …and the image posture's (0.114.0), for the same reason: no MCP on an
160
+ // agent turn.
161
+ image: false,
159
162
  };
160
163
 
161
164
  /**
@@ -33,7 +33,7 @@ async function reexecAfterLogin() {
33
33
 
34
34
  // TWO ROWS THAT READ ALIKE ARE NOT A CHOICE. `projectRowLabel` adds the id and
35
35
  // the connected date to the COLLIDING rows only — production held two projects
36
- // both called "BRIF AI", both bound to the same checkout, so this picker
36
+ // with one name, both bound to the same checkout, so this picker
37
37
  // offered two identical lines and the only way to answer was to guess. See
38
38
  // credentials.mjs for why the suffix is not on every row. (That exact case is
39
39
  // a refusal now rather than a picker — a directory serves one project — but
@@ -62,7 +62,7 @@ export async function resolveStartProject({ dirGiven }) {
62
62
  // binds it, and a headless start with no unambiguous answer refuses in words
63
63
  // that name every stored project and every way out. The one thing this block
64
64
  // must never do is serve a project the resolution did not name — "it said
65
- // skadooble in my calendar repo" is the confusion this exists to end.
65
+ // contoso in my calendar repo" is the confusion this exists to end.
66
66
  // A RESTART IS NOT A PERSON. `reexec` (update.mjs) inherits stdio, so an
67
67
  // auto-updated daemon's child sees two TTYs; without this it would stop on the
68
68
  // binding confirm below and the machine would stay dark until somebody typed a
@@ -122,7 +122,7 @@ export async function resolveStartProject({ dirGiven }) {
122
122
 
123
123
  const loginLabel = `connect ${repoRoot ? 'this repo' : 'a repo'} to a different project (${terminalCommand('login')})`;
124
124
  // WHICH ONE LOOKS RIGHT — a pre-selection, never an auto-serve. The resolver
125
- // refuses to serve a project the repo PATH did not name (the skadooble law);
125
+ // refuses to serve a project the repo PATH did not name (the contoso law);
126
126
  // this only decides which row the cursor starts on, using the repo's folder
127
127
  // name and its github repo-name against the stored project names. A unique
128
128
  // match becomes ONE keypress; a wrong guess costs nothing, because the human
@@ -136,7 +136,7 @@ export async function resolveStartProject({ dirGiven }) {
136
136
 
137
137
  // Bounded like the confirm below, and for the same reason — but silence
138
138
  // means something DIFFERENT here and the difference is load-bearing. There
139
- // is a real ambiguity to resolve; serving a guess is the skadooble bug.
139
+ // is a real ambiguity to resolve; serving a guess is the contoso bug.
140
140
  // So no answer REFUSES, which is exactly what this branch already does
141
141
  // headless, and the message says how to answer without being present.
142
142
  let chosen = null; // 0-based into [...choices, login]
@@ -220,7 +220,7 @@ export async function resolveStartProject({ dirGiven }) {
220
220
  } else if (!externalToken && CREDENTIAL.needsConfirm && interactive) {
221
221
  // ONE stored project, never tied to a repo — the pre-0.55.0 world. Ask once;
222
222
  // yes binds and every later start is silent. This is the exact question
223
- // whose absence had a calendar checkout serving skadooble.
223
+ // whose absence had a calendar checkout serving contoso.
224
224
  //
225
225
  // AND IT TIMES OUT, because a prompt on a start path is a way for a machine
226
226
  // to go dark. `FLOWVIANT_REEXEC` above covers the restart THIS version
@@ -240,7 +240,7 @@ export async function resolveStartProject({ dirGiven }) {
240
240
  // not, which is the whole reason this product has exactly one refusal.
241
241
  // · DO NOT BIND, because binding is the thing the question was FOR. Nobody
242
242
  // answered, so nothing is cemented; the next human start asks again. That
243
- // keeps the skadooble case fixed for the person who is actually looking,
243
+ // keeps the contoso case fixed for the person who is actually looking,
244
244
  // which is the only person it could ever have been fixed for.
245
245
  const creds = await import('./credentials.mjs');
246
246
  const label = creds.projectLabel(CREDENTIAL.entry);
@@ -0,0 +1,120 @@
1
+ /**
2
+ * WHICH MODEL A TURN ACTUALLY RAN ON, IN THE CLI'S OWN WORDS (2026-09-29).
3
+ *
4
+ * The owner: "how do users know which model of opus are they using on
5
+ * flowviant? like opus 5, or opus 5.5". A pin reads as typed ("opus") or as
6
+ * the machine default, and `opus` is an alias the CLI resolves on its own
7
+ * cadence — so the only true answer is the one the CLI gives about the turn it
8
+ * just ran. This file reads it; the settle bodies carry it as `model`; the app
9
+ * shows it by name. A RELAY: nothing here maps an alias to a model or guesses.
10
+ *
11
+ * TWO SOURCES, ONE SHAPE:
12
+ *
13
+ * · CLAUDE CODE says it on the stream. MEASURED on Claude Code 2.1.284,
14
+ * 2026-09-29 (`claude -p "say hi" --model haiku --output-format
15
+ * stream-json --verbose`): the `system`/`init` event carries
16
+ * `model: "claude-haiku-4-5-20251001"`, and every top-level `assistant`
17
+ * event carries the same id as `message.model`. claudeStream.mjs hands both
18
+ * over; runTurn.mjs keeps the first valid one per spawn, so the init wins
19
+ * and the first reply is the fallback. A subagent's messages
20
+ * (`parent_tool_use_id` set) are not the turn's model and are not read; a
21
+ * synthetic error message says `<synthetic>`, which the shape refuses.
22
+ * · CODEX says it in a file. `codex exec --json` (0.156.1) puts no model on
23
+ * stdout — its ThreadEvent vocabulary is thread/turn/item lifecycle and
24
+ * token usage only — but the thread's rollout writes one `turn_context`
25
+ * line per turn whose `payload.model` is the model that turn resolved
26
+ * (`"model":"gpt-6-sol"`, beside `collaboration_mode.settings.model` and
27
+ * `effort`). After the child closes, runTurn.mjs asks
28
+ * `codexTurnModel` for the NEWEST `turn_context` — and only one written
29
+ * since the spawn, so a resumed thread whose turn died before its context
30
+ * was written never reports the previous turn's model as this one's.
31
+ *
32
+ * THREE STATES: a valid id is a measurement; null is "not measured" and the
33
+ * settle carries no key at all. There is no empty value.
34
+ *
35
+ * THE SHAPE is the app's (`packages/shared/src/schemas/turnModel.ts` exports
36
+ * `TURN_MODEL_MAX` and `TURN_MODEL_RE`; `scripts/check-app-parity.mjs` holds
37
+ * them equal): the server re-applies it, so a looser copy here would only
38
+ * have a model dropped at the boundary.
39
+ */
40
+
41
+ import { open } from 'node:fs/promises';
42
+ import { codexRolloutFile } from './runtimeLimits.mjs';
43
+
44
+ /** The longest model id a settle carries. */
45
+ export const TURN_MODEL_MAX = 80;
46
+ /** One model id — the CLIs' own alphabet (`claude-opus-5-5`, `gpt-6-sol`,
47
+ * `us.anthropic.claude-…:0`, `org/model`). Anything else is not relayed. */
48
+ export const TURN_MODEL_RE = /^[A-Za-z0-9._:\-/]{1,80}$/;
49
+
50
+ /** A model id this daemon will relay, or null. */
51
+ export function turnModelOf(v) {
52
+ return typeof v === 'string' && TURN_MODEL_RE.test(v) ? v : null;
53
+ }
54
+
55
+ /** Read backwards this much at a time… */
56
+ const ROLLOUT_CHUNK_BYTES = 256 * 1024;
57
+ /** …and no further than this: a turn's own lines past it are not searched. */
58
+ const ROLLOUT_MODEL_SCAN_MAX = 32 * 1024 * 1024;
59
+ /** A rollout stamp a little before the spawn still counts (clock rounding). */
60
+ const SINCE_SLACK_MS = 2_000;
61
+
62
+ /**
63
+ * THE MODEL OF THE NEWEST `turn_context` IN A CODEX THREAD'S ROLLOUT, when it
64
+ * was written at or after `since` (epoch ms); else null.
65
+ *
66
+ * Read BACKWARDS from the end in chunks, because the line sits at the START
67
+ * of its turn and a single turn's rollout runs to megabytes (2.8 MB measured
68
+ * for one agent turn). Only lines naming `"turn_context"` are parsed.
69
+ * Best-effort from end to end: it never throws.
70
+ */
71
+ export async function codexTurnModel(threadId, { since = 0, env = process.env } = {}) {
72
+ let fh = null;
73
+ try {
74
+ const file = await codexRolloutFile(threadId, { env });
75
+ if (!file) return null;
76
+ fh = await open(file, 'r');
77
+ const { size } = await fh.stat();
78
+ let pos = size;
79
+ // The bytes of a line whose start is in a chunk not read yet.
80
+ let rest = Buffer.alloc(0);
81
+ while (pos > 0 && size - pos < ROLLOUT_MODEL_SCAN_MAX) {
82
+ const len = Math.min(ROLLOUT_CHUNK_BYTES, pos);
83
+ pos -= len;
84
+ const chunk = Buffer.alloc(len);
85
+ await fh.read(chunk, 0, len, pos);
86
+ let data = Buffer.concat([chunk, rest]);
87
+ if (pos > 0) {
88
+ const nl = data.indexOf(0x0a);
89
+ if (nl < 0) {
90
+ rest = data;
91
+ continue;
92
+ }
93
+ rest = data.subarray(0, nl);
94
+ data = data.subarray(nl + 1);
95
+ }
96
+ if (!data.includes('"turn_context"')) continue;
97
+ const lines = data.toString('utf8').split('\n');
98
+ for (let i = lines.length - 1; i >= 0; i--) {
99
+ if (!lines[i].includes('"turn_context"')) continue;
100
+ let ev;
101
+ try {
102
+ ev = JSON.parse(lines[i]);
103
+ } catch {
104
+ continue;
105
+ }
106
+ if (ev?.type !== 'turn_context') continue;
107
+ // The NEWEST context decides, whatever it says: an older one is a
108
+ // previous turn's.
109
+ const at = Date.parse(ev.timestamp ?? '');
110
+ if (!Number.isFinite(at) || at < since - SINCE_SLACK_MS) return null;
111
+ return turnModelOf(ev.payload?.model);
112
+ }
113
+ }
114
+ return null;
115
+ } catch {
116
+ return null;
117
+ } finally {
118
+ await fh?.close().catch(() => {});
119
+ }
120
+ }
@@ -19,15 +19,29 @@
19
19
  * - plan-mode — a Workbench plan turn: Claude Code's own
20
20
  * `--permission-mode plan`, REPLACING the build posture, no MCP.
21
21
  * Claude only. The adapter sees `build` (argv is `perm`);
22
- * - design / research — a non-code card's posture (agentTaskKinds.mjs),
23
- * refused on any runtime that does not DECLARE it, because every
24
- * other adapter's fallback is its build branch.
22
+ * - design / research / image — a non-code card's posture
23
+ * (agentTaskKinds.mjs), refused on any runtime that does not
24
+ * DECLARE it, because every other adapter's fallback is its
25
+ * build branch. design and research are Claude Code's; image
26
+ * (0.114.0) is Codex's.
25
27
  *
26
28
  * Claude's permission list for each name lives beside the lists themselves
27
29
  * (`claudePermFor`, claudePosture.mjs); this module owns the vocabulary and the
28
- * refusals, and imports only the card-kind table.
30
+ * refusals, and imports the card-kind table and the runtime registry (whose
31
+ * declared `profiles` say which CLI a kind posture runs on).
29
32
  */
30
33
  import { KIND_POSTURES } from './agentTaskKinds.mjs';
34
+ import { RUNTIMES } from './runtimes.mjs';
35
+
36
+ /** The CLIs that DECLARE a posture, by label — "Claude Code", "Codex". Read
37
+ * from the registry, so the refusal names whoever can run it, never a CLI
38
+ * written into the sentence by hand. */
39
+ const declaredBy = (name) =>
40
+ Object.values(RUNTIMES)
41
+ .filter((rt) => (rt.profiles ?? []).includes(name))
42
+ .map((rt) => rt.label)
43
+ .join(' or ') || 'no CLI this daemon knows';
44
+ const article = (word) => (/^[aeiou]/i.test(word) ? 'an' : 'a');
31
45
 
32
46
  const plain = (adapterProfile) => Object.freeze({ adapterProfile, onlyOn: null, strictMcp: false });
33
47
 
@@ -46,12 +60,17 @@ export const TURN_PROFILES = Object.freeze({
46
60
  }),
47
61
  // The kind postures: fenced to "write only the artifacts directory", and a
48
62
  // runtime that does not declare one would run its build branch instead.
63
+ // The sentence names the CLI(s) that do declare it: "a design card runs on
64
+ // Claude Code only", "an image card runs on Codex only".
49
65
  ...Object.fromEntries(
50
66
  KIND_POSTURES.map((name) => [
51
67
  name,
52
68
  Object.freeze({
53
69
  adapterProfile: name,
54
- onlyOn: (rt) => ((rt.profiles ?? []).includes(name) ? null : `a ${name} card runs on Claude Code only — not '${rt.label}'`),
70
+ onlyOn: (rt) =>
71
+ (rt.profiles ?? []).includes(name)
72
+ ? null
73
+ : `${article(name)} ${name} card runs on ${declaredBy(name)} only — not '${rt.label}'`,
55
74
  strictMcp: false,
56
75
  }),
57
76
  ])
package/bin/lib/work.mjs CHANGED
@@ -330,6 +330,8 @@ export function createWorkManager({
330
330
  postBestEffort,
331
331
  workChildren,
332
332
  groupKillChildren,
333
+ // Only to find the agent's browser profile (noWindowEnv.mjs).
334
+ sessionMetaPath,
333
335
  });
334
336
  const { runPrecheck } = createWorkAgentPrecheck({
335
337
  baseRef,
@@ -17,6 +17,7 @@ import { warn } from './ui.mjs';
17
17
  import { ARTIFACT_DIR, artifactTypeFor } from './artifacts.mjs';
18
18
  import { scrub as envScrub } from './uplinkScrub.mjs';
19
19
  import { withoutMachineCredentials } from './machineEnv.mjs';
20
+ import { agentBrowserHome, openBrowserHome, withoutWindows } from './noWindowEnv.mjs';
20
21
  import { fleetEndpoint } from './fleetWire.mjs';
21
22
  import { workLanesStopped } from './standDownGate.mjs';
22
23
 
@@ -85,11 +86,14 @@ export function clearNonArtifacts(wt) {
85
86
  * authenticates with; everything else is what the agent's own turn saw when it
86
87
  * ran the same tests.
87
88
  */
88
- export function checkEnv(env = process.env) {
89
- return withoutMachineCredentials(env);
89
+ export function checkEnv(env = process.env, browserHome = null) {
90
+ // …and, like that turn since 2026-09-29, no way to a window: a test run
91
+ // nobody is watching that opens a headed browser or an HTML report opens it
92
+ // on nobody's behalf (noWindowEnv.mjs). The agent's own browser profile.
93
+ return withoutWindows(withoutMachineCredentials(env), browserHome);
90
94
  }
91
95
 
92
- export function createWorkAgentCheck({ repoRoot, postBestEffort, workChildren, groupKillChildren }) {
96
+ export function createWorkAgentCheck({ repoRoot, postBestEffort, workChildren, groupKillChildren, sessionMetaPath }) {
93
97
  const AGENT_CHECK_DONE_URL = fleetEndpoint('agent-check-done', FLEET_URL);
94
98
 
95
99
  // ── THE PROJECT'S OWN CHECK, and the MERGE ─────────────────────────────────
@@ -142,6 +146,7 @@ export function createWorkAgentCheck({ repoRoot, postBestEffort, workChildren, g
142
146
  warn(
143
147
  `agent ${agentId}: removed ${cleared.length} non-artifact file(s) from ${ARTIFACT_DIR} before the check — ${cleared.slice(0, 5).join(', ')}`
144
148
  );
149
+ const browser = openBrowserHome(agentBrowserHome(sessionMetaPath, wt, agentId));
145
150
  const out = await new Promise((resolve) => {
146
151
  let text = '';
147
152
  let done = false;
@@ -178,7 +183,7 @@ export function createWorkAgentCheck({ repoRoot, postBestEffort, workChildren, g
178
183
  shell: true,
179
184
  detached: true,
180
185
  stdio: ['ignore', 'pipe', 'pipe'],
181
- env: checkEnv(),
186
+ env: checkEnv(process.env, browser.dir),
182
187
  });
183
188
  /**
184
189
  * …AND IT IS TRACKED, so a stop or a takeover takes it with them.
@@ -266,6 +271,7 @@ export function createWorkAgentCheck({ repoRoot, postBestEffort, workChildren, g
266
271
  finish(code === 0 ? 'passed' : 'failed');
267
272
  });
268
273
  });
274
+ browser.cleanup();
269
275
  await postCheck({
270
276
  agentId,
271
277
  status: out.status,
@@ -20,6 +20,7 @@ import { removeProbeTranscript } from './runtimeCapabilities.mjs';
20
20
  import { taskIdsFromMessage } from './worktreeDiff.mjs';
21
21
  import { limitLine } from './cliLimit.mjs';
22
22
  import { fleetEndpoint } from './fleetWire.mjs';
23
+ import { agentBrowserHome } from './noWindowEnv.mjs';
23
24
 
24
25
  export function createWorkAgentPrecheck({ baseRef, admit, sessionMetaPath, workChildren }) {
25
26
  const AGENT_PRECHECK_URL = fleetEndpoint('agent-precheck', FLEET_URL);
@@ -257,6 +258,9 @@ export function createWorkAgentPrecheck({ baseRef, admit, sessionMetaPath, workC
257
258
  profile: 'consult',
258
259
  cwd: wt,
259
260
  runtime: rt,
261
+ // The agent's own browser profile, in the worktree this reader stands
262
+ // in; no display either way (noWindowEnv.mjs, 2026-09-29).
263
+ browserHome: agentBrowserHome(sessionMetaPath, wt, agentId),
260
264
  // NO `resume`, AND THAT IS THE FEATURE. A resumed turn would be the
261
265
  // agent grading its own homework out of its own context; this is a
262
266
  // stranger reading a diff.
@@ -37,6 +37,8 @@ import { CODEX_THREAD_RE } from './workSessionRuntime.mjs';
37
37
  import { brainFor } from './workBrain.mjs';
38
38
  import { begunTurnRefusal } from './workAgentTurnBegun.mjs';
39
39
  import { agentTurnSettlement } from './workAgentTurnOutcome.mjs';
40
+ import { ensureArtifactDir } from './artifacts.mjs';
41
+ import { agentBrowserHome } from './noWindowEnv.mjs';
40
42
 
41
43
  export function createAgentTurnExecution({
42
44
  baseRef,
@@ -227,6 +229,25 @@ export function createAgentTurnExecution({
227
229
  });
228
230
  return;
229
231
  }
232
+ /**
233
+ * A NON-CODE CARD WRITES ONE DIRECTORY, SO IT STANDS FIRST (0.114.0).
234
+ * Codex's image fence makes `.flowviant/artifacts` its only writable root
235
+ * and cannot create it from inside; every kind posture writes nowhere
236
+ * else. Made only as a real directory under a real `.flowviant`
237
+ * (`ensureArtifactDir`): a committed `.flowviant` symlink would otherwise
238
+ * point the fence's one write grant at somewhere outside the worktree. A
239
+ * worktree where it cannot stand is settled in words, before any CLI runs.
240
+ */
241
+ if (kind.artifact && !ensureArtifactDir(wt)) {
242
+ await postAgentTurn({
243
+ turnId,
244
+ outcome: 'nothing',
245
+ answer: "this worktree's .flowviant/artifacts is not a real directory, so there is nowhere to write what the card hands back",
246
+ branch,
247
+ worktree: wt,
248
+ });
249
+ return;
250
+ }
230
251
 
231
252
  // ONE AGENT IS ONE DIRECTORY, so the CLI's own cwd-keyed resume is exactly
232
253
  // right here — the ambiguity that forced per-tab session pinning in the
@@ -266,6 +287,23 @@ export function createAgentTurnExecution({
266
287
  rt === 'codex'
267
288
  ? Boolean(codexResumeId)
268
289
  : rt === 'claude' && Boolean(ranMarker && existsSync(ranMarker));
290
+ /**
291
+ * …AND THE HOME THAT THREAD IS FILED UNDER, KEPT PER AGENT (2026-09-29).
292
+ *
293
+ * Codex's thread index records each rollout under the CODEX_HOME the turn
294
+ * ran with, as that path — not the personal store the link leads to — so
295
+ * the per-turn home in /tmp every turn used to get died with its turn and
296
+ * took the pinned thread with it: the next card's `resume` failed "no
297
+ * rollout found" and the agent went to Stuck. Beside the thread marker,
298
+ * in the worktree's private git dir: outside the working tree (never
299
+ * committed, never seen by `git add -A`), refreshed by every turn and
300
+ * removed only with the worktree, when the agent retires and its threads
301
+ * stop mattering. One agent runs one turn at a time (its place's writer
302
+ * lock), so no two turns refresh it at once. Null (no git dir to hold it)
303
+ * is the per-turn home again, and a lost resume then runs once fresh.
304
+ */
305
+ const codexAgentHome =
306
+ rt === 'codex' ? sessionMetaPath(wt, 'flowviant-agent-codex-home', agentId) : null;
269
307
 
270
308
  /**
271
309
  * THE TURN'S FILES, ON DISK BEFORE THE PROMPT NAMES THEM (0.112.0) — the
@@ -356,6 +394,15 @@ export function createAgentTurnExecution({
356
394
 
357
395
  let out = '';
358
396
  let child = null;
397
+ /**
398
+ * EVERY CHILD THIS TURN SPAWNED, not only the last (2026-09-29). A lost
399
+ * resume runs once more fresh (runTurnResumingOnce), so a turn can own two
400
+ * CLIs, and `child` names only the second: the first stayed in
401
+ * `workChildren` for the life of the daemon — one phantom live turn per
402
+ * retry, held against the machine's ceiling, and `workBusy()` true for
403
+ * ever, which holds the self-update and the tray's install for ever.
404
+ */
405
+ const spawnedChildren = new Set();
359
406
  /**
360
407
  * WHAT THIS TURN SPENT, AS THE CLI COUNTED IT (2026-09-19).
361
408
  *
@@ -371,6 +418,9 @@ export function createAgentTurnExecution({
371
418
  * what makes `...(usage ? … : {})` the whole guard.
372
419
  */
373
420
  let usage = null;
421
+ /** THE MODEL THE TURN RAN ON, as its CLI named it (turnModel.mjs) — rides
422
+ * every settle `usage` rides, for the same reason; null sends no key. */
423
+ let model = null;
374
424
  /**
375
425
  * THE AGENT'S LAST MESSAGE, ALONE — where the final JSON object lives.
376
426
  * Codex's `out` is every message plus stderr plus error text, and
@@ -386,7 +436,7 @@ export function createAgentTurnExecution({
386
436
  try {
387
437
  // A turn reads the base ref afresh. Changes the agent has made to its
388
438
  // own worktree cannot silently change the next turn's tools.
389
- projectTools = prepareAgentTools(readBaseTools(repoRoot, baseRef()), rt, process.env, wt);
439
+ projectTools = prepareAgentTools(readBaseTools(repoRoot, baseRef()), rt, process.env, wt, { codexHome: codexAgentHome });
390
440
  const agentTurnArgs = {
391
441
  prompt:
392
442
  job.kind === 'task' && job.task
@@ -427,6 +477,11 @@ export function createAgentTurnExecution({
427
477
  }) + (projectTools.instructions ? `\n\n${projectTools.instructions}` : ''),
428
478
  agentTools: projectTools,
429
479
  ...(projectTools.codexHome ? { mcpEnv: { CODEX_HOME: projectTools.codexHome } } : {}),
480
+ // NOBODY IS AT THIS SCREEN (2026-09-29): no display, BROWSER=none, and
481
+ // a browser profile of the agent's own, kept beside its Codex home —
482
+ // so a Chrome it starts to look at its page never reaches the
483
+ // person's (noWindowEnv.mjs).
484
+ browserHome: agentBrowserHome(sessionMetaPath, wt, agentId),
430
485
  ...(posture === 'build' && rt === 'claude'
431
486
  ? { mcpArgs: ['--strict-mcp-config', '--mcp-config', projectTools.mcpPath] }
432
487
  : posture === 'build' && rt === 'codex'
@@ -493,6 +548,9 @@ export function createAgentTurnExecution({
493
548
  onUsage: (u) => {
494
549
  usage = { ...u, runtime: rt };
495
550
  },
551
+ onModel: (m) => {
552
+ model = m;
553
+ },
496
554
  // WHAT THE CLI SAYS IT CAN BE ASKED FOR, AND WHICH CONNECTORS NEED A
497
555
  // LOGIN — the same free fact off the same init event the tab lane
498
556
  // records (2026-09-23). Without it a box that only ever runs agents
@@ -505,6 +563,7 @@ export function createAgentTurnExecution({
505
563
  },
506
564
  onSpawn: (ch) => {
507
565
  child = ch;
566
+ spawnedChildren.add(ch);
508
567
  // The AGENT it serves — what the machine snapshot charges this
509
568
  // child's memory to.
510
569
  workChildren.set(ch, agentId);
@@ -539,11 +598,13 @@ export function createAgentTurnExecution({
539
598
  beforeFresh: () => {
540
599
  seenThreadId = null;
541
600
  lastAnswer = null;
601
+ model = null;
542
602
  },
543
603
  });
544
604
  } finally {
545
605
  // What the CLI counted, for the lane's catch if anything below throws.
546
606
  measured.usage = usage;
607
+ measured.model = model;
547
608
  projectTools?.cleanup();
548
609
  /**
549
610
  * THE TAIL, BEFORE THE SETTLE — so the last thing the agent did is on
@@ -557,7 +618,7 @@ export function createAgentTurnExecution({
557
618
  */
558
619
  trace.stop();
559
620
  await trace.flush(TRACE_FINAL_FLUSH_MS);
560
- if (child) workChildren.delete(child);
621
+ for (const ch of spawnedChildren) workChildren.delete(ch);
561
622
  if (agentChildren.get(place) === child) agentChildren.delete(place);
562
623
  if (ranMarker && rt === 'claude') {
563
624
  try {
@@ -610,6 +671,7 @@ export function createAgentTurnExecution({
610
671
  out,
611
672
  lastAnswer,
612
673
  usage,
674
+ model,
613
675
  commits,
614
676
  artifactsBefore,
615
677
  branch,
@@ -26,7 +26,7 @@ import { parseTurnResult } from './agentPlan.mjs';
26
26
  import { changedArtifacts, scanArtifacts } from './artifacts.mjs';
27
27
  import { scrub as envScrub } from './uplinkScrub.mjs';
28
28
 
29
- export function agentTurnSettlement({ turnId, job, kind, out, lastAnswer, usage, commits, artifactsBefore, branch, wt }) {
29
+ export function agentTurnSettlement({ turnId, job, kind, out, lastAnswer, usage, model = null, commits, artifactsBefore, branch, wt }) {
30
30
  const res = (lastAnswer && parseTurnResult(lastAnswer)) || parseTurnResult(out);
31
31
  /**
32
32
  * A LIMIT IS ONLY A LIMIT WHEN THE TURN PRODUCED NOTHING.
@@ -40,8 +40,10 @@ export function agentTurnSettlement({ turnId, job, kind, out, lastAnswer, usage,
40
40
  */
41
41
  const limit = res ? null : limitLine(out);
42
42
  /** What the caller posts, and whether it is the one settle that may say
43
- * delivered (the only one whose reply can open the review-entry beat). */
44
- const report = (body, final = false) => ({ res, limit, body, final });
43
+ * delivered (the only one whose reply can open the review-entry beat).
44
+ * Every one carries the model the CLI said it ran on, when it said
45
+ * (turnModel.mjs) — a turn that stopped still ran on something. */
46
+ const report = (body, final = false) => ({ res, limit, body: model ? { ...body, model } : body, final });
45
47
  if (limit) {
46
48
  // The turn itself is reported as `nothing` — it did not deliver and it
47
49
  // did not ask. The park that goes with it is the caller's side effect
@@ -193,7 +195,7 @@ export function agentTurnSettlement({ turnId, job, kind, out, lastAnswer, usage,
193
195
  * never lies by omission), the spend if the CLI counted one, and the branch
194
196
  * and worktree when a worktree existed. Nothing measured, nothing claimed.
195
197
  */
196
- export function failedTurnSettlement({ turnId, error, commits = [], usage = null, branch = null, wt = null }) {
198
+ export function failedTurnSettlement({ turnId, error, commits = [], usage = null, model = null, branch = null, wt = null }) {
197
199
  // An Error's message, or a thrown string; anything else says nothing rather
198
200
  // than "[object Object]".
199
201
  const why = (typeof error === 'string' ? error : typeof error?.message === 'string' ? error.message : '').trim();
@@ -205,6 +207,7 @@ export function failedTurnSettlement({ turnId, error, commits = [], usage = null
205
207
  ).slice(0, 1500),
206
208
  ...(commits.length ? { commits } : {}),
207
209
  ...(usage ? { usage } : {}),
210
+ ...(model ? { model } : {}),
208
211
  ...(wt ? { branch, worktree: wt } : {}),
209
212
  };
210
213
  }
@@ -162,7 +162,7 @@ export function createWorkAgentTurns({
162
162
  * wire, so a failed POST is re-sent on the next offer and the CLI is not
163
163
  * run again.
164
164
  */
165
- const measured = { wt: null, before: null, branch: null, usage: null };
165
+ const measured = { wt: null, before: null, branch: null, usage: null, model: null };
166
166
  let settled = false;
167
167
  const settleOnce = (body) => {
168
168
  settled = true;
@@ -184,6 +184,7 @@ export function createWorkAgentTurns({
184
184
  error,
185
185
  commits,
186
186
  usage: measured.usage,
187
+ model: measured.model,
187
188
  branch: measured.branch,
188
189
  wt: measured.wt,
189
190
  })
@@ -174,6 +174,10 @@ export function createWorkSessionTurns({
174
174
  * sits in the retry queue and is posted again adds nothing.
175
175
  */
176
176
  let usage = null;
177
+ /** THE MODEL THE TURN RAN ON, as its CLI named it (turnModel.mjs) —
178
+ * out here beside `usage`, and spread wherever it is. Last write wins
179
+ * across a fresh retry: that spawn is the one that answered. */
180
+ let model = null;
177
181
  try {
178
182
  const tries = workAttempts.get(job.id) ?? 0;
179
183
  if (tries >= MAX_WORK_TRIES) {
@@ -666,6 +670,9 @@ export function createWorkSessionTurns({
666
670
  onUsage: (u) => {
667
671
  usage = addUsage(usage, { ...u, runtime: rt.id });
668
672
  },
673
+ onModel: (m) => {
674
+ model = m;
675
+ },
669
676
  // What this CLI says it can be asked for by name. Harvested off
670
677
  // the init event the stream already carries — no probe, no scan,
671
678
  // no extra spawn — and reported on the next roster poll so the
@@ -687,6 +694,11 @@ export function createWorkSessionTurns({
687
694
  cwd: dir.wt,
688
695
  mcpArgs: mcp.args,
689
696
  mcpEnv: mcp.env,
697
+ // A PERSON AT THE KEYBOARD MAY ASK FOR A WINDOW (2026-09-29): a
698
+ // Terminal tab keeps its environment's display. The capture chat
699
+ // does not — it only reads and stages cards, and the person
700
+ // talking to it may be on a phone (noWindowEnv.mjs).
701
+ display: !captureTab,
690
702
  runtime: rt.id,
691
703
  label: c.cyan('[tab]'),
692
704
  // Only codex announces one (thread.started); held here so the id
@@ -827,6 +839,7 @@ export function createWorkSessionTurns({
827
839
  ...(toolLog.ev.length > 0 ? { tools: toolLog } : {}),
828
840
  // …and an empty fork still spent what it spent.
829
841
  ...(usage ? { usage } : {}),
842
+ ...(model ? { model } : {}),
830
843
  });
831
844
  warn('adopt turn produced no output — settled as failed');
832
845
  return;
@@ -865,6 +878,7 @@ export function createWorkSessionTurns({
865
878
  // at settle). Already scrubbed at collection.
866
879
  ...(toolLog.ev.length > 0 ? { tools: toolLog } : {}),
867
880
  ...(usage ? { usage } : {}),
881
+ ...(model ? { model } : {}),
868
882
  });
869
883
  if (answer.length > 0) ok(`${c.cyan('tab')} ${c.dim('— replied in the session')}`);
870
884
  else warn('session turn produced no output — settled as failed');
@@ -880,6 +894,7 @@ export function createWorkSessionTurns({
880
894
  ...(toolLog?.ev.length > 0 ? { tools: toolLog } : {}),
881
895
  // A turn that threw after its CLI ran still spent what it spent.
882
896
  ...(usage ? { usage } : {}),
897
+ ...(model ? { model } : {}),
883
898
  });
884
899
  warn(`session turn failed: ${e?.message ?? e}`);
885
900
  } finally {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.113.0",
3
+ "version": "0.114.1",
4
4
  "description": "Run your own coding CLIs as build agents for Flowviant — Claude Code, Codex or Antigravity, on your own credentials. Holds your sessions, keeps a worktree per tab, and ships branches on your word.",
5
5
  "type": "module",
6
6
  "bin": {