flowviant 0.114.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.
package/bin/cli.mjs CHANGED
@@ -325,7 +325,7 @@ async function uninstallCommand() {
325
325
  // repo each is bound to, and which the legacy mirror points at. Needs no
326
326
  // network: it reads the store, which is the exact thing a confused person is
327
327
  // trying to see. The remedies are named because this listing IS the moment of
328
- // confusion ("why did it say skadooble?"), not documentation.
328
+ // confusion ("why did it say contoso?"), not documentation.
329
329
  async function projectsCommand() {
330
330
  const { listStoredProjects, projectLabel } = await import('./lib/credentials.mjs');
331
331
  const { connectedOn } = await import('./lib/credentialDate.mjs');
@@ -336,7 +336,7 @@ async function projectsCommand() {
336
336
  }
337
337
  for (const e of entries) {
338
338
  // THE DATE RIDES THE ID, always — this listing exists for the moment
339
- // somebody is asking "why did it say skadooble?", and "which of these two
339
+ // somebody is asking "why did it say contoso?", and "which of these two
340
340
  // did I set up last month" is the same question one step on. Absent when
341
341
  // nothing recorded one (a credential stored before the field existed);
342
342
  // nothing is invented.
@@ -59,6 +59,25 @@ or, if you are stopping to ask:
59
59
  "progress" is REQUIRED on both shapes and REPLACES the previous one whole —
60
60
  past tense, plain sentences, no card ids.`;
61
61
 
62
+ /**
63
+ * NOBODY IS AT THIS SCREEN (2026-09-29, the owner: Chrome "kept opening on my
64
+ * desktop … but me as the user didnt click anything"). A board agent looking
65
+ * at the page it made started Chrome itself, and a Chrome started on the
66
+ * default profile hands its window to the person's own running Chrome. The
67
+ * turn's environment now closes those roads (noWindowEnv.mjs); these words
68
+ * are for what an environment cannot close — an agent reaching for a window
69
+ * on purpose — and say how to look without one: headless, a profile of its
70
+ * own, a screenshot it reads back. `flowviant shot` is not named because
71
+ * nothing puts `flowviant` on a turn's PATH (an npx or tray-bundled daemon
72
+ * has none there). Every file-handing kind that draws a page renders
73
+ * `LOOK_HEADLESS` in its rule 2; the image card renders `NO_WINDOW`.
74
+ */
75
+ export const NO_WINDOW = `Never open a browser window or any desktop application: this is somebody's
76
+ own screen, and nobody is at it.`;
77
+ export const LOOK_HEADLESS = `${NO_WINDOW} To look at your page, screenshot it
78
+ headless with a profile of its own and read the PNG:
79
+ google-chrome --headless=new --user-data-dir="$(mktemp -d)" --virtual-time-budget=5000 --screenshot=/tmp/<name>.png file://<the page's absolute path>`;
80
+
62
81
  /**
63
82
  * THE 3D-MODEL CARD (rewritten 0.107.0, the owner, 2026-09-27: "yeah just
64
83
  * glb"). The product is ONE .glb a person downloads and imports; the card
@@ -133,6 +152,8 @@ WHAT TO DO:
133
152
 
134
153
  Keep the page under 2 MB.
135
154
 
155
+ ${LOOK_HEADLESS}
156
+
136
157
  ${ARTIFACT_CONTRACT_TAIL({
137
158
  ask: `If you cannot model it without a DECISION only a person can make — which of
138
159
  two directions, what size or style, which asset it stands beside — STOP AND
@@ -171,6 +192,8 @@ WHAT TO DO:
171
192
  one slide on each page (break-after: page), so the browser prints it to a
172
193
  PDF. Keep it under 2 MB.
173
194
 
195
+ ${LOOK_HEADLESS}
196
+
174
197
  ${ARTIFACT_CONTRACT_TAIL({
175
198
  ask: `If you cannot write it without a DECISION only a person can make — who the
176
199
  audience is, how long it runs, what it must argue — STOP AND ASK. Do not
@@ -234,6 +257,9 @@ WHAT TO DO:
234
257
  no notes, no scripts, no discarded variants. Keep each file under
235
258
  ${IMAGE_CAP_WORDS}; if one is larger, save it as WebP or at a smaller size.
236
259
 
260
+ ${NO_WINDOW} Look at a picture through your own tools,
261
+ never an image viewer.
262
+
237
263
  ${ARTIFACT_CONTRACT_TAIL({
238
264
  ask: `If you cannot make it without a DECISION only a person can make — what it
239
265
  shows, its style, its size, where it goes — STOP AND ASK. Do not guess. And
@@ -153,7 +153,7 @@ export function safeName(name) {
153
153
  * (2026-09-19).
154
154
  *
155
155
  * Two projects genuinely named the same thing is not a hypothetical: production
156
- * held two called "BRIF AI", both bound to the same checkout on one box, so
156
+ * held two projects with one name, both bound to the same checkout on one box, so
157
157
  * `npx flowviant` there offered two IDENTICAL picker rows and the only way to
158
158
  * choose between them was to guess. A picker whose options cannot be told apart
159
159
  * is not a picker.
@@ -185,9 +185,9 @@ export function projectRowLabel(e, entries) {
185
185
  * WHICH STORED PROJECTS SHARE ONE REPO — the duplicate the owner met and could
186
186
  * not name (2026-09-23).
187
187
  *
188
- * `npx flowviant machines` printed "BRIF AI" twice, and he read it as two
189
- * daemons or two "machine profiles" on one box for one project: *"when I go to
190
- * BRIF AI to disconnect the duplicate, I only see one."* It was two PROJECTS
188
+ * `npx flowviant machines` printed one project name twice, and he read it as
189
+ * two daemons or two "machine profiles" on one box for one project: *"when I go
190
+ * to <the project> to disconnect the duplicate, I only see one."* It was two PROJECTS
191
191
  * with one name, both bound to one checkout — and the id-on-every-row rule
192
192
  * (2026-09-19) had not made that legible, because two hex prefixes say
193
193
  * "different" without saying "same repo". A project's Machines page shows its
@@ -256,8 +256,8 @@ export function boundElsewhere(entries, repoRoot, projectId) {
256
256
  */
257
257
  export function directoryTakenRefusal(bound, repoRoot, { incoming } = {}) {
258
258
  // Every project in the sentence is labelled against the WHOLE set, the
259
- // incoming one included: a refusal reading "already connected to BRIF AI,
260
- // so BRIF AI was not connected" is the puzzle it exists to end.
259
+ // incoming one included: a refusal reading "already connected to Acme App,
260
+ // so Acme App was not connected" is the puzzle it exists to end.
261
261
  const all = [...bound, ...(incoming ? [incoming] : [])];
262
262
  const names = bound.map((e) => projectRowLabel(e, all));
263
263
  const list = names.length > 1 ? `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}` : names[0];
@@ -284,7 +284,7 @@ function normalizeName(s) {
284
284
  * WHICH stored project most likely goes with this repo — a HINT for the
285
285
  * picker's default, and DELIBERATELY not a licence to serve it. `resolveStored
286
286
  * Credential` matches by repo PATH and refuses to guess past that, because
287
- * serving a project the path did not name is the "it said skadooble in my
287
+ * serving a project the path did not name is the "it said contoso in my
288
288
  * calendar repo" surprise this whole file exists to prevent. A name that
289
289
  * matches the repo's folder or its github repo-name is softer evidence — good
290
290
  * enough to put the cursor on that row so the answer is one keypress, never
@@ -450,7 +450,7 @@ export function matchStoredProject(ref) {
450
450
  *
451
451
  * Deliberately NO mirror fallback once more than one project is stored: the
452
452
  * mirror is whichever login happened last, and serving it because it was
453
- * recent is exactly the calendar-says-skadooble surprise this file exists to
453
+ * recent is exactly the calendar-says-contoso surprise this file exists to
454
454
  * end. Two projects means the answer is a QUESTION (or --project, or the
455
455
  * repo binding), never recency.
456
456
  */
@@ -473,7 +473,7 @@ export function resolveStoredCredential(argv = process.argv, cwd = process.cwd()
473
473
 
474
474
  // ONE stored project with no binding: the pre-0.55.0 world. Serve it — a
475
475
  // headless restart must not hang — but flag it, so a TTY start asks ONCE and
476
- // binds, which is what turns "it said skadooble in my calendar repo" into a
476
+ // binds, which is what turns "it said contoso in my calendar repo" into a
477
477
  // question instead of a surprise.
478
478
  if (entries.length === 1 && !entries[0].repoRoot) {
479
479
  return { entry: entries[0], source: 'only', needsConfirm: Boolean(repoRoot), repoRoot };
@@ -467,7 +467,7 @@ export function announceFirstPoll(roster) {
467
467
  );
468
468
  note(c.dim(' wiki + agents stream to THIS project — view its Code canvas in Flowviant.'));
469
469
  // Remember the NAME beside the stored credential, so the picker and
470
- // `flowviant projects` can say "skadooble" instead of an id — a
470
+ // `flowviant projects` can say "contoso" instead of an id — a
471
471
  // credential saved before the server sent names backfills here. No-op
472
472
  // for a --fleet/env token (nothing stored to annotate).
473
473
  setStoredProjectName(roster.project.id, roster.project.name);
package/bin/lib/git.mjs CHANGED
@@ -260,11 +260,11 @@ export function detectBaseRef(repoRoot) {
260
260
  * A base ref an agent's worktree can actually be cut from.
261
261
  *
262
262
  * `origin/<branch>` is the right base when the repo HAS an origin. A repo with
263
- * no remote (a local-only project — the owner's "Death Note - Roblox",
264
- * 2026-09-25) has no `origin/*` refs at all, and cutting a worktree from one
265
- * fails; every agent turn then ended "nothing" in a second. So: the ref itself
266
- * when it resolves to a commit, else the LOCAL branch of the same name, else
267
- * the ref unchanged (and the worktree step says why in git's words).
263
+ * no remote (a local-only project, 2026-09-25) has no `origin/*` refs at
264
+ * all, and cutting a worktree from one fails; every agent turn then ended
265
+ * "nothing" in a second. So: the ref itself when it resolves to a commit,
266
+ * else the LOCAL branch of the same name, else the ref unchanged (and the
267
+ * worktree step says why in git's words).
268
268
  */
269
269
  export function usableBaseRef(repoRoot, ref) {
270
270
  const resolves = (r) => {
@@ -36,14 +36,14 @@
36
36
  * the connected flowviants? because im not sure if i have any duplicate or
37
37
  * redundant daemons running". Measured the day it was asked: four live daemons
38
38
  * across two boxes, two credentials last heard three days ago, and two projects
39
- * both named "BRIF AI" bound to the same checkout — which is why `npx
39
+ * with one name bound to the same checkout — which is why `npx
40
40
  * flowviant` there offered two identical picker rows. THE PROJECT ID IS
41
41
  * THEREFORE ALWAYS PRINTED: it is the only thing that tells those two apart,
42
42
  * and the whole listing is worth less without it. **And since 0.95.0 the
43
43
  * collision is SAID, not left to be noticed**: the id told him the rows were
44
44
  * different projects, and he still read them as one project connected twice
45
- * ("BRIF AI appears twice meaning I likely have 2 daemon or 'machine
46
- * profiles' on my machine for the same BRIF AI project"). `renderCollisions`
45
+ * ("<the project> appears twice meaning I likely have 2 daemon or 'machine
46
+ * profiles' on my machine for the same <the project> project"). `renderCollisions`
47
47
  * prints one line per repo that two projects are bound to, naming both.
48
48
  *
49
49
  * ── IT IS A RELAY ──
@@ -266,7 +266,7 @@ export function projectPhrase(e) {
266
266
  /**
267
267
  * TWO PROJECTS ON ONE REPO, SAID OUT LOUD (2026-09-23).
268
268
  *
269
- * The owner ran this command, saw "BRIF AI" twice, and concluded he had two
269
+ * The owner ran this command, saw one project name twice, and concluded he had two
270
270
  * daemons for one project. The id on each row said they were different
271
271
  * projects; nothing said they were bound to the SAME checkout, which is the
272
272
  * fact that makes it a duplicate rather than two rows. So the listing ends
@@ -0,0 +1,104 @@
1
+ /**
2
+ * A TURN NOBODY IS SITTING AT PUTS NO WINDOW ON ANYBODY'S SCREEN (2026-09-29).
3
+ *
4
+ * The owner, on WSL: Chrome "kept opening on my desktop … but me as the user
5
+ * didnt click anything". Board agents checking their three.js pages started
6
+ * Chrome themselves, and an inherited environment gives such a process three
7
+ * roads to the person's screen:
8
+ * · a Chrome started without --user-data-dir takes the DEFAULT profile, and
9
+ * while the person's own Chrome runs on it the new process hands its
10
+ * command line to that one through the profile's SingletonSocket — taken
11
+ * before any display is opened (measured) — so the PERSON'S Chrome opens
12
+ * the window, and no display variable of ours is involved;
13
+ * · a headed browser, Playwright's `headless: false`, any GUI program draws
14
+ * wherever DISPLAY / WAYLAND_DISPLAY point, and WSLg points them at the
15
+ * Windows desktop;
16
+ * · xdg-open, Python's webbrowser, a dev server's --open honour BROWSER, which
17
+ * on WSL is usually the Windows browser.
18
+ *
19
+ * So every turn but a Terminal tab's — the one lane where a person at the
20
+ * keyboard may ask for a window — runs with:
21
+ * · DISPLAY and WAYLAND_SOCKET removed, and WAYLAND_DISPLAY set EMPTY rather
22
+ * than removed: unset, libwayland falls back to `$XDG_RUNTIME_DIR/wayland-0`
23
+ * (measured: a client with it unset connected there), and that is exactly
24
+ * the socket WSLg keeps; empty names no socket, a Chrome that picks Wayland
25
+ * from it fails "Failed to connect to Wayland display" and exits (measured),
26
+ * and xdg-open reads empty as no display at all;
27
+ * · BROWSER=none, childEnv.mjs's word for the same thing;
28
+ * · a browser home of its own: CHROME_CONFIG_HOME, the root Chrome and
29
+ * Chromium put their default profile under (chrome_paths_linux.cc), and
30
+ * CHROME_USER_DATA_DIR, the profile itself (chrome_main_delegate.cc) — the
31
+ * one a fork like Brave still honours where it ignores the first
32
+ * (measured) — so a browser the agent starts bare opens a profile nobody
33
+ * else is running and can never reach the person's.
34
+ *
35
+ * Nothing that runs here needs what is removed: the CLIs' own headless modes
36
+ * (`claude -p`, `codex exec`) and headless browsers run without a display
37
+ * (measured). XDG_CONFIG_HOME is deliberately NOT moved: it is where Claude,
38
+ * Codex and git keep their own configuration.
39
+ *
40
+ * What an environment cannot stop is an agent that runs a Windows program on
41
+ * purpose through WSL interop (`explorer.exe`, `cmd.exe /c start`); the kind
42
+ * contracts ask it not to (artifactContracts.mjs, `NO_WINDOW`).
43
+ */
44
+ import { lstatSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs';
45
+ import { tmpdir } from 'node:os';
46
+ import { join } from 'node:path';
47
+
48
+ /** Removed outright; WAYLAND_DISPLAY is kept but emptied (see above). */
49
+ export const WINDOW_ENV_REMOVED = Object.freeze(['DISPLAY', 'WAYLAND_SOCKET']);
50
+
51
+ /** A copy of `env` a turn nobody is sitting at runs with. Never mutates its
52
+ * input. `browserHome` is the directory its browsers keep their profile in. */
53
+ export function withoutWindows(env, browserHome = null) {
54
+ const out = { ...(env ?? {}) };
55
+ for (const k of WINDOW_ENV_REMOVED) delete out[k];
56
+ out.WAYLAND_DISPLAY = '';
57
+ out.BROWSER = 'none';
58
+ if (browserHome) {
59
+ out.CHROME_CONFIG_HOME = browserHome;
60
+ out.CHROME_USER_DATA_DIR = join(browserHome, 'user-data');
61
+ }
62
+ return out;
63
+ }
64
+
65
+ /**
66
+ * AN AGENT'S BROWSER HOME is kept beside its Codex home, in its worktree's
67
+ * private git dir: never committed, the same profile turn after turn, and
68
+ * removed with the worktree when the agent retires. The pre-review and the
69
+ * project check stand in the same worktree for the same agent, and take it
70
+ * too. Null when there is no git dir to hold it.
71
+ */
72
+ export const agentBrowserHome = (sessionMetaPath, wt, agentId) =>
73
+ sessionMetaPath?.(wt, 'flowviant-agent-browser', agentId) ?? null;
74
+
75
+ /**
76
+ * The directory, made: `kept` when a lane has one (0700, never removed here),
77
+ * else one of this turn's own that `cleanup` removes. A kept path that is not
78
+ * a real directory is replaced, never followed; one that cannot be made falls
79
+ * back to a turn's own. Never throws — the turn runs without a browser home
80
+ * sooner than not at all, and its display variables are gone either way.
81
+ */
82
+ export function openBrowserHome(kept) {
83
+ if (kept) {
84
+ try {
85
+ try { if (!lstatSync(kept).isDirectory()) rmSync(kept, { force: true }); } catch { /* absent */ }
86
+ mkdirSync(kept, { recursive: true, mode: 0o700 });
87
+ return { dir: kept, cleanup: () => {} };
88
+ } catch { /* fall through to a turn's own */ }
89
+ }
90
+ try {
91
+ const dir = mkdtempSync(join(tmpdir(), 'flowviant-browser-'));
92
+ let removed = false;
93
+ return {
94
+ dir,
95
+ cleanup: () => {
96
+ if (removed) return;
97
+ removed = true;
98
+ try { rmSync(dir, { recursive: true, force: true }); } catch { /* best effort */ }
99
+ },
100
+ };
101
+ } catch {
102
+ return { dir: null, cleanup: () => {} };
103
+ }
104
+ }
@@ -15,24 +15,51 @@
15
15
  * projectTools.mjs. projectTools.test.mjs pins every transport word to here.
16
16
  */
17
17
 
18
- import { existsSync, mkdirSync, readFileSync, readdirSync, symlinkSync, writeFileSync } from 'node:fs';
18
+ import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, renameSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
19
19
  import { join, resolve } from 'node:path';
20
20
  import { homedir } from 'node:os';
21
21
 
22
22
  export const toml = (value) => JSON.stringify(String(value));
23
23
 
24
- /** Keep personal Codex files, but do not copy its project trust grants. An
25
- * untrusted worktree's .codex/config.toml is ignored by Codex 0.156.1. */
26
- function isolatedCodexHome(dir, env, worktree) {
27
- const source = resolve(env.CODEX_HOME || join(homedir(), '.codex'));
28
- const dest = join(dir, 'codex-home');
29
- mkdirSync(dest, { mode: 0o700 });
30
- if (existsSync(source)) {
31
- for (const name of readdirSync(source)) {
32
- if (name === 'config.toml') continue;
33
- symlinkSync(join(source, name), join(dest, name));
34
- }
24
+ /**
25
+ * ONE LINK PER PERSONAL ENTRY, MADE OR MENDED IN PLACE (2026-09-29). A home
26
+ * that outlives its turn is refreshed rather than rebuilt, so it must end up
27
+ * exactly what a fresh build would link: a link already right stays, a link
28
+ * pointing anywhere else is re-pointed, and a real file or directory standing
29
+ * where a personal entry belongs — Codex rewrote a link by rename, or made its
30
+ * own before the person's existed — gives way to the link, because the
31
+ * personal copy is the one login and the one store (one login per CLI). A
32
+ * racing refresh that made the same link first is not an error.
33
+ */
34
+ function linkPersonalEntry(target, link) {
35
+ let st = null;
36
+ try { st = lstatSync(link); } catch { /* absent: link it */ }
37
+ if (st?.isSymbolicLink() && readlinkSync(link) === target) return;
38
+ if (st) rmSync(link, { recursive: true, force: true }); // never follows a link
39
+ try {
40
+ symlinkSync(target, link);
41
+ } catch (error) {
42
+ if (error?.code !== 'EEXIST' || readlinkSync(link) !== target) throw error;
35
43
  }
44
+ }
45
+
46
+ /**
47
+ * Keep personal Codex files, but do not copy its project trust grants. An
48
+ * untrusted worktree's .codex/config.toml is ignored by Codex 0.156.1.
49
+ *
50
+ * `dest` IS REFRESHED IN PLACE, NEVER ASSUMED EMPTY (2026-09-29). An agent's
51
+ * home is kept between its turns (prepareAgentTools' `codexHome`), because
52
+ * Codex's shared thread index (`state_5.sqlite`, reached through the link)
53
+ * records each rollout under the CODEX_HOME in force, unresolved: a home that
54
+ * died with its turn left the agent's pinned thread pointing into a deleted
55
+ * directory, and the next card's `resume <id>` failed "no rollout found".
56
+ * So every personal entry is (re)linked, every link whose entry left the
57
+ * personal home is dropped, config.toml is rewritten whole each turn (the
58
+ * worktree's skills can change between turns), and what Codex made here
59
+ * itself under a name the person has not got is kept.
60
+ */
61
+ function isolatedCodexHome(dest, env, worktree) {
62
+ const source = resolve(env.CODEX_HOME || join(homedir(), '.codex'));
36
63
  const configPath = join(source, 'config.toml');
37
64
  const sourceConfig = existsSync(configPath) ? readFileSync(configPath, 'utf8') : '';
38
65
  const topLevel = sourceConfig.split(/^\s*\[/m, 1)[0];
@@ -65,7 +92,33 @@ function isolatedCodexHome(dir, env, worktree) {
65
92
  throw new Error('Cannot isolate Codex project skills with inline skills.config');
66
93
  for (const skill of worktreeSkills) config += `\n[[skills.config]]\npath = ${toml(skill)}\nenabled = false\n`;
67
94
  }
68
- writeFileSync(join(dest, 'config.toml'), config, { mode: 0o600 });
95
+
96
+ // The home itself, only once the config is decided: a refused turn touches
97
+ // nothing here. A home that is not a real directory is not ours to write
98
+ // through — a link standing here would carry what follows somewhere else.
99
+ try { if (!lstatSync(dest).isDirectory()) rmSync(dest, { force: true }); } catch { /* absent */ }
100
+ mkdirSync(dest, { recursive: true, mode: 0o700 });
101
+ chmodSync(dest, 0o700);
102
+ const personal = existsSync(source) ? readdirSync(source).filter((name) => name !== 'config.toml') : [];
103
+ for (const name of personal) linkPersonalEntry(join(source, name), join(dest, name));
104
+ const linked = new Set(personal);
105
+ for (const name of readdirSync(dest)) {
106
+ if (linked.has(name) || name === 'config.toml') continue;
107
+ // Only a LINK is ours to drop: a real entry under a name the person has
108
+ // not got is Codex's own (its sessions, on a box whose personal home had
109
+ // none yet), and this agent's threads live in it.
110
+ try { if (lstatSync(join(dest, name)).isSymbolicLink()) rmSync(join(dest, name), { force: true }); } catch { /* gone */ }
111
+ }
112
+ // Written beside and RENAMED over, so a config.toml that is somehow a link
113
+ // is replaced as an entry and never written through: the personal
114
+ // config.toml is the one file this home must never reach.
115
+ const staged = join(dest, `.config.toml.${process.pid}.${Date.now()}`);
116
+ try {
117
+ writeFileSync(staged, config, { mode: 0o600, flag: 'wx' });
118
+ renameSync(staged, join(dest, 'config.toml'));
119
+ } finally {
120
+ rmSync(staged, { force: true });
121
+ }
69
122
  return { path: dest, personalDeveloperInstructions };
70
123
  }
71
124
 
@@ -85,8 +138,9 @@ function reviewedClaudePlugin(dir, snapshot) {
85
138
 
86
139
  /**
87
140
  * The table. `refuseMcp(config)` names why this runtime cannot take one MCP
88
- * configuration it otherwise could; `prepare(dir, snapshot, env, worktree)`
89
- * returns the runner-specific part of prepareAgentTools' result.
141
+ * configuration it otherwise could; `prepare(dir, snapshot, env, worktree,
142
+ * kept)` returns the runner-specific part of prepareAgentTools' result — under
143
+ * `dir`, which dies with the turn, except what `kept` names a lasting place for.
90
144
  */
91
145
  const TOOL_RUNTIMES = {
92
146
  claude: {
@@ -99,8 +153,11 @@ const TOOL_RUNTIMES = {
99
153
  mcp: true,
100
154
  skills: true,
101
155
  refuseMcp: (config) => (config.type === 'sse' ? 'Codex cannot take SSE MCP' : null),
102
- prepare: (dir, _snapshot, env, worktree) => {
103
- const isolated = isolatedCodexHome(dir, env, worktree);
156
+ // The agent's own lasting home when the caller has one (2026-09-29: its
157
+ // threads are indexed under it — see isolatedCodexHome), else one that
158
+ // dies with the turn, as every home did before.
159
+ prepare: (dir, _snapshot, env, worktree, kept) => {
160
+ const isolated = isolatedCodexHome(kept?.codexHome ?? join(dir, 'codex-home'), env, worktree);
104
161
  return { codexHome: isolated.path, personalDeveloperInstructions: isolated.personalDeveloperInstructions };
105
162
  },
106
163
  },
@@ -132,7 +189,8 @@ export function skillsRefusal(runner) {
132
189
  return runner === 'none' ? 'no installed CLI can take instructions' : `${runner} cannot take instructions`;
133
190
  }
134
191
 
135
- /** The runner-specific files a turn needs, written under `dir`. */
136
- export function prepareForRunner(runner, dir, snapshot, env, worktree) {
137
- return { pluginDir: null, codexHome: null, personalDeveloperInstructions: false, ...(rowFor(runner).prepare?.(dir, snapshot, env, worktree) ?? {}) };
192
+ /** The runner-specific files a turn needs, written under `dir` — or, for what
193
+ * `kept` names, where the caller keeps it between turns. */
194
+ export function prepareForRunner(runner, dir, snapshot, env, worktree, kept = {}) {
195
+ return { pluginDir: null, codexHome: null, personalDeveloperInstructions: false, ...(rowFor(runner).prepare?.(dir, snapshot, env, worktree, kept) ?? {}) };
138
196
  }
@@ -99,8 +99,16 @@ export function readBaseTools(root, ref, env = process.env) {
99
99
 
100
100
  const expand = (value, env) => String(value).replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_, key) => env[key] ?? '');
101
101
 
102
- /** Temporary per-turn files are removed after the last retry. No personal CLI config is written. */
103
- export function prepareAgentTools(snapshot, runner, env = process.env, worktree = null) {
102
+ /**
103
+ * Temporary per-turn files are removed after the last retry. No personal CLI config is written.
104
+ *
105
+ * `codexHome` is the AGENT'S OWN CODEX_HOME, kept between its turns
106
+ * (2026-09-29): a place the caller owns, refreshed here each turn and never
107
+ * removed by `cleanup`, because Codex indexes a thread under the home it ran
108
+ * in and the next card resumes that thread. Absent, a Codex turn gets a home
109
+ * that dies with it.
110
+ */
111
+ export function prepareAgentTools(snapshot, runner, env = process.env, worktree = null, { codexHome: keptCodexHome = null } = {}) {
104
112
  const dir = mkdtempSync(join(tmpdir(), 'flowviant-agent-tools-'));
105
113
  const usable = snapshot.tools.filter((t) => t.state === 'ready' && mcpRefusal(runner, t.config) == null);
106
114
  const mcpServers = {};
@@ -142,7 +150,7 @@ export function prepareAgentTools(snapshot, runner, env = process.env, worktree
142
150
  ].filter(Boolean).join('\n\n');
143
151
  let prepared;
144
152
  try {
145
- prepared = prepareForRunner(runner, dir, snapshot, env, worktree);
153
+ prepared = prepareForRunner(runner, dir, snapshot, env, worktree, { codexHome: keptCodexHome });
146
154
  } catch (error) {
147
155
  rmSync(dir, { recursive: true, force: true });
148
156
  throw error;
@@ -20,7 +20,7 @@
20
20
  import { agentTaskKindOf } from './agentTaskKinds.mjs';
21
21
  // A card's type (0.106.0) likewise: the table holds the words, this file prints them.
22
22
  import { AGENT_TASK_TYPES, agentTaskTypeFor, agentTaskTypeOf } from './agentTaskTypes.mjs';
23
- import { ARTIFACT_CONTRACT_TAIL, SYSTEM_AGENT_DECK, SYSTEM_AGENT_IMAGE, SYSTEM_AGENT_MODEL } from './artifactContracts.mjs';
23
+ import { ARTIFACT_CONTRACT_TAIL, LOOK_HEADLESS, SYSTEM_AGENT_DECK, SYSTEM_AGENT_IMAGE, SYSTEM_AGENT_MODEL } from './artifactContracts.mjs';
24
24
  import { isLibraryReference } from './knowledgeLibrary.mjs';
25
25
  import { readCardThread } from './cardThread.mjs';
26
26
  import { AGENT_FILE_LINE_MAX, AGENT_FILES_PER_MESSAGE_MAX, CARD_FILES_PER_TURN_MAX } from './agentFiles.mjs';
@@ -1022,6 +1022,8 @@ WHAT TO DO:
1022
1022
  images are data: URIs or inline SVG. Several pages asked for in one card
1023
1023
  go in the ONE file (sections, or tabs you script). Keep it under 2 MB.
1024
1024
 
1025
+ ${LOOK_HEADLESS}
1026
+
1025
1027
  ${ARTIFACT_CONTRACT_TAIL({
1026
1028
  ask: `If you cannot draw it without a DECISION only a person can make — which of
1027
1029
  two directions, which page, what the content should say — STOP AND ASK. Do
@@ -32,6 +32,12 @@ const RESUME_LOST = [
32
32
  /session .{0,80}not found/i,
33
33
  /conversation .{0,80}not found/i,
34
34
  /thread .{0,80}not found/i,
35
+ // Codex 0.156's words for a pinned thread whose rollout file is gone
36
+ // (2026-09-29): "Error: thread/resume: thread/resume failed: no rollout
37
+ // found for thread id <id> (code -32600)" on stderr, exit 1, no event on
38
+ // stdout. Every agent thread filed under a deleted per-turn /tmp home says
39
+ // exactly this, and none of the phrases above matched it.
40
+ /no rollout found/i,
35
41
  /trajectory not found/i,
36
42
  ];
37
43
  /**
@@ -13,6 +13,7 @@
13
13
  import { spawn } from 'node:child_process';
14
14
  import { runtimeById } from './runtimes.mjs';
15
15
  import { withoutMachineCredentials } from './machineEnv.mjs';
16
+ import { openBrowserHome, withoutWindows } from './noWindowEnv.mjs';
16
17
  import { resolveTurnProfile } from './turnProfile.mjs';
17
18
  import { claudePermFor } from './claudePosture.mjs';
18
19
  import { handleStreamLine } from './claudeStream.mjs';
@@ -38,9 +39,18 @@ const CODEX_MODEL_READ_MS = 1_500;
38
39
  * the MCP config file. So those two names, and only those, are removed —
39
40
  * by the one rule in machineEnv.mjs, which the project check obeys too.
40
41
  */
41
- export function cliEnv(mcpEnv) {
42
+ /**
43
+ * …AND, UNLESS A PERSON IS AT THE KEYBOARD, NO WAY TO A WINDOW (2026-09-29).
44
+ * `display: true` is the Terminal tab's alone: a person typing there may ask
45
+ * for a window and gets their own environment. Every other turn — an agent's,
46
+ * its pre-review, the planner, the wiki, intake, the capture chat, and any
47
+ * lane added later that does not say otherwise — runs under
48
+ * `withoutWindows` (noWindowEnv.mjs), its browsers in `browserHome`.
49
+ */
50
+ export function cliEnv(mcpEnv, { display = false, browserHome = null } = {}) {
42
51
  // After the merge, so an mcpEnv can never smuggle one back in.
43
- return withoutMachineCredentials({ ...process.env, ...(mcpEnv ?? {}) });
52
+ const env = withoutMachineCredentials({ ...process.env, ...(mcpEnv ?? {}) });
53
+ return display === true ? env : withoutWindows(env, browserHome);
44
54
  }
45
55
 
46
56
  // One Claude Code turn. Output is captured (for sentinel detection) and streamed
@@ -53,7 +63,7 @@ export function cliEnv(mcpEnv) {
53
63
  // `onActivity` so the caller can forward progress. Build-agent turns leave it
54
64
  // off and keep the raw text passthrough + line sentinels.
55
65
  export function runTurn(opts) {
56
- const { prompt, resume, system, cwd, mcpConfig, mcpArgs, mcpEnv, agentTools, runtime = 'claude', label, onSpawn, streamJson, answerFromResult, onActivity, onToolEvent, onInit, onUsage, onRateLimit, onModel, onThreadId, onAnswer, vaultDir, knowledgeDir, resultSchemaArgs, model, effort, adoptResumeId, resumeThreadId, resumeConversationId } = opts;
66
+ const { prompt, resume, system, cwd, mcpConfig, mcpArgs, mcpEnv, agentTools, runtime = 'claude', label, onSpawn, streamJson, answerFromResult, onActivity, onToolEvent, onInit, onUsage, onRateLimit, onModel, onThreadId, onAnswer, vaultDir, knowledgeDir, resultSchemaArgs, model, effort, adoptResumeId, resumeThreadId, resumeConversationId, display, browserHome } = opts;
57
67
  /**
58
68
  * AFTER THE TEARDOWN, NO TURN STARTS AND NO TURN ENDS (standDownGate.mjs).
59
69
  * A stand-down that drains a deploy keeps this process alive; without this
@@ -195,6 +205,11 @@ export function runTurn(opts) {
195
205
  // Which credential is correct, and whether an account may be shared, is
196
206
  // between the operator and the vendor. Flowviant does not detect it and does
197
207
  // not enforce it; it runs the CLI the ordinary way and relays what happens.
208
+ //
209
+ // A turn nobody is sitting at keeps its browsers in a home of its own —
210
+ // the lane's kept one (an agent's), else this spawn's, removed when the
211
+ // CLI closes. See `cliEnv`.
212
+ const browser = display === true ? null : openBrowserHome(browserHome);
198
213
  const child = spawn(rt.bin, args, {
199
214
  cwd,
200
215
  stdio: ['ignore', 'pipe', 'pipe'],
@@ -219,10 +234,15 @@ export function runTurn(opts) {
219
234
  detached: true,
220
235
  // Only ADDS to the environment (the worker token, for runtimes that read
221
236
  // it from there). Never replaces it: the CLI's own credentials live in
222
- // this environment, and handing it a curated one signs it out. The one
223
- // thing it REMOVES is this daemon's machine credential — see `cliEnv`.
224
- env: cliEnv(mcpEnv),
237
+ // this environment, and handing it a curated one signs it out. What it
238
+ // REMOVES is this daemon's machine credential and, for a turn nobody is
239
+ // sitting at, every way to a window — see `cliEnv`.
240
+ env: cliEnv(mcpEnv, { display: display === true, browserHome: browser?.dir }),
225
241
  });
242
+ if (browser) {
243
+ child.once('close', browser.cleanup);
244
+ child.once('error', browser.cleanup);
245
+ }
226
246
  onSpawn?.(child);
227
247
  // When this turn began, for the Codex rollout read: a `turn_context` older
228
248
  // than this is a previous turn's (turnModel.mjs).
@@ -243,9 +243,10 @@ export const CODEX_RUNTIME = {
243
243
  // one under `$CODEX_HOME/generated_images/…` and says the path (the
244
244
  // binary's own words: "Generated images are saved to … by default. If
245
245
  // you need to use a generated image at another path, copy it"). An agent
246
- // turn's CODEX_HOME is its isolated per-turn home (projectToolRuntimes);
247
- // `:root` reads admit it, so the agent copies what it keeps into the one
248
- // writable directory, where the artifact scan finds it.
246
+ // turn's CODEX_HOME is the agent's own isolated home (projectToolRuntimes,
247
+ // kept per agent since 2026-09-29); `:root` reads admit it, so the agent
248
+ // copies what it keeps into the one writable directory, where the
249
+ // artifact scan finds it.
249
250
  //
250
251
  // IMAGE GENERATION IS SWITCHED ON HERE AND ONLY HERE — a decision. This
251
252
  // is the one turn whose card asks for pictures, so it must not depend
@@ -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);
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.
@@ -38,6 +38,7 @@ import { brainFor } from './workBrain.mjs';
38
38
  import { begunTurnRefusal } from './workAgentTurnBegun.mjs';
39
39
  import { agentTurnSettlement } from './workAgentTurnOutcome.mjs';
40
40
  import { ensureArtifactDir } from './artifacts.mjs';
41
+ import { agentBrowserHome } from './noWindowEnv.mjs';
41
42
 
42
43
  export function createAgentTurnExecution({
43
44
  baseRef,
@@ -286,6 +287,23 @@ export function createAgentTurnExecution({
286
287
  rt === 'codex'
287
288
  ? Boolean(codexResumeId)
288
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;
289
307
 
290
308
  /**
291
309
  * THE TURN'S FILES, ON DISK BEFORE THE PROMPT NAMES THEM (0.112.0) — the
@@ -376,6 +394,15 @@ export function createAgentTurnExecution({
376
394
 
377
395
  let out = '';
378
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();
379
406
  /**
380
407
  * WHAT THIS TURN SPENT, AS THE CLI COUNTED IT (2026-09-19).
381
408
  *
@@ -409,7 +436,7 @@ export function createAgentTurnExecution({
409
436
  try {
410
437
  // A turn reads the base ref afresh. Changes the agent has made to its
411
438
  // own worktree cannot silently change the next turn's tools.
412
- projectTools = prepareAgentTools(readBaseTools(repoRoot, baseRef()), rt, process.env, wt);
439
+ projectTools = prepareAgentTools(readBaseTools(repoRoot, baseRef()), rt, process.env, wt, { codexHome: codexAgentHome });
413
440
  const agentTurnArgs = {
414
441
  prompt:
415
442
  job.kind === 'task' && job.task
@@ -450,6 +477,11 @@ export function createAgentTurnExecution({
450
477
  }) + (projectTools.instructions ? `\n\n${projectTools.instructions}` : ''),
451
478
  agentTools: projectTools,
452
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),
453
485
  ...(posture === 'build' && rt === 'claude'
454
486
  ? { mcpArgs: ['--strict-mcp-config', '--mcp-config', projectTools.mcpPath] }
455
487
  : posture === 'build' && rt === 'codex'
@@ -531,6 +563,7 @@ export function createAgentTurnExecution({
531
563
  },
532
564
  onSpawn: (ch) => {
533
565
  child = ch;
566
+ spawnedChildren.add(ch);
534
567
  // The AGENT it serves — what the machine snapshot charges this
535
568
  // child's memory to.
536
569
  workChildren.set(ch, agentId);
@@ -585,7 +618,7 @@ export function createAgentTurnExecution({
585
618
  */
586
619
  trace.stop();
587
620
  await trace.flush(TRACE_FINAL_FLUSH_MS);
588
- if (child) workChildren.delete(child);
621
+ for (const ch of spawnedChildren) workChildren.delete(ch);
589
622
  if (agentChildren.get(place) === child) agentChildren.delete(place);
590
623
  if (ranMarker && rt === 'claude') {
591
624
  try {
@@ -694,6 +694,11 @@ export function createWorkSessionTurns({
694
694
  cwd: dir.wt,
695
695
  mcpArgs: mcp.args,
696
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,
697
702
  runtime: rt.id,
698
703
  label: c.cyan('[tab]'),
699
704
  // Only codex announces one (thread.started); held here so the id
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.114.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": {