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 +9 -0
- package/bin/cli.mjs +2 -2
- package/bin/lib/artifactContracts.mjs +26 -0
- package/bin/lib/claudePersonal.mjs +145 -0
- package/bin/lib/credentials.mjs +9 -9
- package/bin/lib/fleetRoster.mjs +1 -1
- package/bin/lib/git.mjs +5 -5
- package/bin/lib/machineListing.mjs +4 -4
- package/bin/lib/noWindowEnv.mjs +104 -0
- package/bin/lib/projectToolRuntimes.mjs +78 -20
- package/bin/lib/projectTools.mjs +11 -3
- package/bin/lib/prompts.mjs +62 -22
- package/bin/lib/resumeLost.mjs +22 -2
- package/bin/lib/runTurn.mjs +26 -6
- package/bin/lib/runtimeClaude.mjs +36 -6
- package/bin/lib/runtimeCodex.mjs +4 -3
- package/bin/lib/startProject.mjs +6 -6
- package/bin/lib/work.mjs +2 -0
- package/bin/lib/workAgentCheck.mjs +10 -4
- package/bin/lib/workAgentPrecheck.mjs +4 -0
- package/bin/lib/workAgentTurnExecution.mjs +35 -2
- package/bin/lib/workSessionTurns.mjs +116 -23
- package/package.json +1 -1
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
|
|
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
|
|
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
|
+
}
|
package/bin/lib/credentials.mjs
CHANGED
|
@@ -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
|
|
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
|
|
189
|
-
* daemons or two "machine profiles" on one box for one project: *"when I go
|
|
190
|
-
*
|
|
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
|
|
260
|
-
// so
|
|
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
|
|
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-
|
|
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
|
|
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 };
|
package/bin/lib/fleetRoster.mjs
CHANGED
|
@@ -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 "
|
|
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
|
|
264
|
-
*
|
|
265
|
-
*
|
|
266
|
-
*
|
|
267
|
-
*
|
|
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
|
-
*
|
|
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
|
-
* ("
|
|
46
|
-
* profiles' on my machine for the same
|
|
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
|
|
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
|
-
/**
|
|
25
|
-
*
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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
|
-
|
|
103
|
-
|
|
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
|
-
|
|
137
|
-
|
|
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
|
}
|
package/bin/lib/projectTools.mjs
CHANGED
|
@@ -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
|
-
/**
|
|
103
|
-
|
|
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;
|