flowviant 0.114.0 → 0.114.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -147,6 +147,15 @@ Two knobs bound the blast radius, and both are worth setting on a shared box:
147
147
  its permission engine is machine-wide — which is surfaced in the app rather
148
148
  than papered over.
149
149
 
150
+ Fenced turns — design, 3D-model, deck and research cards, the pre-review,
151
+ the planner, intake, the wiki and the New task chat — read none of your Claude
152
+ Code settings files: their permissions and hooks are Flowviant's posture
153
+ alone, and of `~/.claude/settings.json` they keep only how Claude signs in
154
+ (`apiKeyHelper`, `env` and the like) and your model defaults. Code agents and
155
+ Terminal tabs still read yours, so your own allow rules and `defaultMode` add
156
+ to `FLOWVIANT_SAFE=1`'s allowlist there. Codex's fenced turns run with
157
+ `--ignore-user-config` the same way.
158
+
150
159
  The posture is reported on every poll and shown in the project's
151
160
  Settings → Machine section, so the team can see whether the box runs the
152
161
  guarded toolset or full permissions.
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
@@ -0,0 +1,145 @@
1
+ /**
2
+ * A FENCED CLAUDE TURN TAKES ITS PERMISSIONS AND HOOKS FROM FLOWVIANT ALONE
3
+ * (2026-09-29, the owner, asked whether an agent's fence should ignore the
4
+ * person's own permission settings: "yes, the agent's fence can ignore").
5
+ *
6
+ * A 3D-model agent under the design posture, whose whole shell is
7
+ * `Bash(ls:*)`, ran `google-chrome` and Playwright scripts. Every fenced lane
8
+ * read the person's `~/.claude/settings.json` — an agent turn through
9
+ * `--setting-sources user`, the pre-review, planner, intake, wiki and capture
10
+ * chat by naming no source at all, which ALSO reads the cwd's
11
+ * `.claude/settings*.json`, and the pre-review's cwd is the agent's own
12
+ * worktree. A settings file is a second author of the posture. MEASURED on
13
+ * Claude Code 2.1.284 — see claudePersonal.test.mjs for the probes and the
14
+ * offline check that pins them on the real CLI. In short: a personal
15
+ * `permissions.allow` rule admitted a command the list refused, a personal
16
+ * `defaultMode: "bypassPermissions"` turned the fenced turn into a bypass, and
17
+ * personal and worktree hooks fired; under `--setting-sources ''` none of them
18
+ * loaded, and the turn ran on the same login.
19
+ *
20
+ * AN EMPTY SOURCE LIST DROPS THE WHOLE USER SOURCE, not only its permissions:
21
+ * `~/.claude/CLAUDE.md`, personal skills and subagents went too (measured),
22
+ * and with the file go the plugins it enables. No flag keeps half of it — a
23
+ * flag setting's allow rules only add to the person's. On a fenced lane that
24
+ * is the right loss: the posture and the contract are Flowviant's, the base
25
+ * branch's instructions and skills arrive through the prompt and the reviewed
26
+ * plugin (`--plugin-dir` still loads, measured), and a personal plugin brings
27
+ * hooks of its own. Codex's fenced postures are hermetic the same way
28
+ * (`--ignore-user-config`, runtimeCodex.mjs).
29
+ *
30
+ * WHAT IS KEPT is what makes the CLI the person's CLI, never what it may do:
31
+ * how it signs in and reaches its provider, how a model the turn does not pin
32
+ * runs, and how long the person's own store is kept. An ALLOWLIST, so a key a
33
+ * later CLI adds — a sandbox auto-allow, a permission mode — never rides by
34
+ * default. It rides INSIDE the posture's own `--settings`, because the CLI
35
+ * reads one: given two, only the last one's hook fired (measured).
36
+ */
37
+ import { readFileSync, statSync } from 'node:fs';
38
+ import { homedir } from 'node:os';
39
+ import { join } from 'node:path';
40
+ import { MACHINE_CREDENTIAL_ENV } from './machineEnv.mjs';
41
+ import { WINDOW_ENV_REMOVED, withoutWindows } from './noWindowEnv.mjs';
42
+
43
+ /** The flag pair that loads no settings file — user, project or local.
44
+ * Managed (policy) settings and `--settings` still apply. */
45
+ export const NO_SETTING_SOURCES = Object.freeze(['--setting-sources', '']);
46
+
47
+ /**
48
+ * The personal settings a fenced turn keeps, by key.
49
+ *
50
+ * · THE LOGIN — the helpers the CLI runs to authenticate and where it signs
51
+ * in, and `env`, where a login routes (ANTHROPIC_BASE_URL,
52
+ * CLAUDE_CODE_USE_BEDROCK, AWS_PROFILE). `env` gives the person's file no
53
+ * more reach than the operator's shell, which every turn inherits already;
54
+ * what a turn's environment promises is still removed from it
55
+ * (`keptEnv`).
56
+ * · HOW AN UNPINNED MODEL RUNS ("Machine default uses the CLI's own
57
+ * setting"): per-model effort, thinking, the model map a provider needs.
58
+ * `model` itself is not here: every Claude turn names `--model`, the
59
+ * daemon's pin when the card has none (config.mjs), so the person's
60
+ * default model never reached a daemon turn before either.
61
+ * · THE PERSON'S OWN STORE: how long transcripts live and whether memory is
62
+ * kept, so a fenced turn's start never prunes or writes on a default the
63
+ * person turned off.
64
+ */
65
+ export const PERSONAL_KEPT = Object.freeze([
66
+ 'apiKeyHelper',
67
+ 'proxyAuthHelper',
68
+ 'awsAuthRefresh',
69
+ 'awsCredentialExport',
70
+ 'gcpAuthRefresh',
71
+ 'forceLoginMethod',
72
+ 'forceLoginOrgUUID',
73
+ 'forceLoginGatewayUrl',
74
+ 'env',
75
+ 'modelOverrides',
76
+ 'modelSettings',
77
+ 'effortLevel',
78
+ 'maxEffortLevel',
79
+ 'alwaysThinkingEnabled',
80
+ 'fallbackModel',
81
+ 'cleanupPeriodDays',
82
+ 'autoMemoryEnabled',
83
+ ]);
84
+
85
+ /** What a turn nobody is sitting at has set or removed (noWindowEnv.mjs) and
86
+ * the machine credential (machineEnv.mjs): never put back by a settings file. */
87
+ const TURN_OWNED_ENV = new Set([
88
+ ...MACHINE_CREDENTIAL_ENV,
89
+ ...WINDOW_ENV_REMOVED,
90
+ ...Object.keys(withoutWindows({}, '/')),
91
+ ]);
92
+ function keptEnv(env) {
93
+ if (!env || typeof env !== 'object' || Array.isArray(env)) return null;
94
+ const out = {};
95
+ for (const [k, v] of Object.entries(env)) if (typeof v === 'string' && !TURN_OWNED_ENV.has(k)) out[k] = v;
96
+ return Object.keys(out).length ? out : null;
97
+ }
98
+
99
+ /** Far past any real settings file; a bigger one is not read. */
100
+ const SETTINGS_MAX_BYTES = 1024 * 1024;
101
+
102
+ /**
103
+ * The person's user settings file — `$CLAUDE_CONFIG_DIR/settings.json`, else
104
+ * `~/.claude/settings.json`, where the CLI itself looks — reduced to
105
+ * PERSONAL_KEPT. `{}` when it is absent, unreadable or not a JSON object: the
106
+ * turn then runs on the CLI's stored login and defaults, and the fence is the
107
+ * same either way.
108
+ */
109
+ export function personalClaudeSettings(env = process.env) {
110
+ const dir = env.CLAUDE_CONFIG_DIR || join(env.HOME || homedir(), '.claude');
111
+ const path = join(dir, 'settings.json');
112
+ let raw;
113
+ try {
114
+ if (statSync(path).size > SETTINGS_MAX_BYTES) return {};
115
+ raw = JSON.parse(readFileSync(path, 'utf8'));
116
+ } catch {
117
+ return {};
118
+ }
119
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {};
120
+ const out = {};
121
+ for (const key of PERSONAL_KEPT) {
122
+ if (!Object.hasOwn(raw, key) || raw[key] == null) continue;
123
+ if (key !== 'env') out[key] = raw[key];
124
+ else {
125
+ const kept = keptEnv(raw.env);
126
+ if (kept) out.env = kept;
127
+ }
128
+ }
129
+ return out;
130
+ }
131
+
132
+ /**
133
+ * `perm` with `kept` folded into its `--settings` — the posture's keys win, so
134
+ * a kept key a posture ever names is the fence's word. A posture with no
135
+ * `--settings` gets one before its lists (`--allowedTools` is variadic). With
136
+ * nothing kept, `perm` is returned as it was.
137
+ */
138
+ export function withPersonalSettings(perm, kept) {
139
+ const personal = kept && typeof kept === 'object' && !Array.isArray(kept) ? kept : {};
140
+ if (!Object.keys(personal).length) return [...perm];
141
+ const at = perm.indexOf('--settings');
142
+ if (at < 0) return ['--settings', JSON.stringify(personal), ...perm];
143
+ const posture = JSON.parse(perm[at + 1]);
144
+ return [...perm.slice(0, at), '--settings', JSON.stringify({ ...personal, ...posture }), ...perm.slice(at + 2)];
145
+ }
@@ -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;