flowviant 0.96.0 → 0.99.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/bin/cli.mjs +13 -3
  2. package/bin/lib/agentPlan.mjs +159 -37
  3. package/bin/lib/artifacts.mjs +174 -33
  4. package/bin/lib/authReject.mjs +32 -0
  5. package/bin/lib/authproxy.mjs +52 -16
  6. package/bin/lib/childEnv.mjs +33 -1
  7. package/bin/lib/claude.mjs +377 -45
  8. package/bin/lib/config.mjs +24 -1
  9. package/bin/lib/credentials.mjs +27 -4
  10. package/bin/lib/deploy.mjs +364 -30
  11. package/bin/lib/env.mjs +146 -12
  12. package/bin/lib/fleet.mjs +125 -30
  13. package/bin/lib/git.mjs +131 -3
  14. package/bin/lib/hooks/readGuard.mjs +162 -0
  15. package/bin/lib/instance.mjs +115 -14
  16. package/bin/lib/knowledge.mjs +460 -23
  17. package/bin/lib/landed.mjs +38 -30
  18. package/bin/lib/listeners.mjs +211 -0
  19. package/bin/lib/localSessions.mjs +29 -4
  20. package/bin/lib/login.mjs +2 -2
  21. package/bin/lib/machines.mjs +16 -7
  22. package/bin/lib/preview.mjs +95 -6
  23. package/bin/lib/printable.mjs +42 -0
  24. package/bin/lib/procRegistry.mjs +65 -0
  25. package/bin/lib/processes.mjs +20 -7
  26. package/bin/lib/projectTools.mjs +248 -0
  27. package/bin/lib/prompts.mjs +354 -32
  28. package/bin/lib/resources.mjs +46 -4
  29. package/bin/lib/runtimeOfferings.mjs +75 -0
  30. package/bin/lib/runtimes.mjs +152 -11
  31. package/bin/lib/shipMerge.mjs +8 -4
  32. package/bin/lib/trace.mjs +16 -6
  33. package/bin/lib/update.mjs +5 -0
  34. package/bin/lib/work.mjs +1261 -4476
  35. package/bin/lib/workAgentMerges.mjs +504 -0
  36. package/bin/lib/workAgentPlans.mjs +447 -0
  37. package/bin/lib/workAgentReview.mjs +702 -0
  38. package/bin/lib/workAgentTurns.mjs +1148 -0
  39. package/bin/lib/workDiffs.mjs +107 -0
  40. package/bin/lib/workPreviews.mjs +351 -0
  41. package/bin/lib/workProcesses.mjs +236 -0
  42. package/bin/lib/workPullRequests.mjs +313 -0
  43. package/bin/lib/workShip.mjs +385 -0
  44. package/bin/lib/worktreeDiff.mjs +99 -43
  45. package/package.json +2 -2
@@ -12,6 +12,7 @@
12
12
  */
13
13
 
14
14
  import { spawn } from 'node:child_process';
15
+ import { fileURLToPath } from 'node:url';
15
16
  import { SAFE } from './config.mjs';
16
17
  import { runtimeById, humanizeClaudeTool, THINK_MARKER } from './runtimes.mjs';
17
18
 
@@ -20,10 +21,106 @@ import { runtimeById, humanizeClaudeTool, THINK_MARKER } from './runtimes.mjs';
20
21
  // the strings live.
21
22
  export * from './prompts.mjs';
22
23
 
24
+ /**
25
+ * THE READ GUARD, ON EVERY CURATED POSTURE (2026-09-23) — see
26
+ * hooks/readGuard.mjs for what it refuses and the probe that proved it fires.
27
+ *
28
+ * A `Bash(git log:*)` allow is a PREFIX: it names the program and says nothing
29
+ * about the arguments, and `git log --output=<path>` writes any file while
30
+ * `-c core.fsmonitor=…` / `--ext-diff` run a program. A reviewer landed
31
+ * `git log -1 --format='tformat:x' --output=pwned.txt` under RESEARCH_PERM with
32
+ * no denial, and it re-measured the same way here. So every list below that
33
+ * promises "reads, and nothing else" carries a `PreToolUse` hook through
34
+ * `--settings`, measured on Claude Code 2.1.281: it fires under
35
+ * `--allowedTools`, exit 2 blocks the call, and its sentence reaches the model.
36
+ *
37
+ * NEVER ON THE BUILD POSTURE: `--dangerously-skip-permissions` is the
38
+ * operator's own choice for a turn that is meant to write, and a guard there
39
+ * would be refusing the job. Nor on PLAN_MODE_PERM, which is the CLI's own
40
+ * posture on the owner's own tab, not a list this file promises anything about.
41
+ *
42
+ * The hook runs as `<this node> <readGuard.mjs>` — the daemon's own
43
+ * `process.execPath`, never a `node` looked up on a PATH the operator may not
44
+ * have, because a hook that fails to START is a non-blocking error and the
45
+ * command runs anyway.
46
+ */
47
+ export const READ_GUARD_PATH = fileURLToPath(new URL('./hooks/readGuard.mjs', import.meta.url));
48
+ const hookQuote = (s) =>
49
+ process.platform === 'win32' ? `"${s}"` : `'${String(s).replace(/'/g, `'\\''`)}'`;
50
+ export const READ_GUARD_SETTINGS = JSON.stringify({
51
+ hooks: {
52
+ PreToolUse: [
53
+ {
54
+ matcher: 'Bash',
55
+ hooks: [{ type: 'command', command: `${hookQuote(process.execPath)} ${hookQuote(READ_GUARD_PATH)}` }],
56
+ },
57
+ ],
58
+ },
59
+ });
60
+ /** Spread FIRST into a curated list: `--allowedTools` is variadic, so the
61
+ * `--settings` pair must stand before it or be swallowed as a tool name. */
62
+ const READ_GUARD = ['--settings', READ_GUARD_SETTINGS];
63
+
64
+ /** A path a permission rule can name: absolute POSIX, and free of the glob and
65
+ * rule-syntax characters that would change what the rule means. Anything else
66
+ * gets no rule — `--add-dir` still admits the directory (measured). */
67
+ const ruleSafeAbsolute = (p) =>
68
+ typeof p === 'string' && p.startsWith('/') && !/[*?[\]{}()\\\n]/.test(p) ? p.replace(/\/+$/, '') : null;
69
+
70
+ /**
71
+ * THE READ FENCE — the worktree, plus the knowledge library by its absolute
72
+ * path, and nothing else on the box (2026-09-24, the audit).
73
+ *
74
+ * Every curated posture used to allow bare `Read`, `Grep` and `Glob`, and a
75
+ * bare allow is not "read the repo": measured on Claude Code 2.1.281, bare
76
+ * `Read` read a file outside the working directory and bare `Grep` searched
77
+ * one. So a design, consult or capture turn — each steered by words anyone who
78
+ * can file a card can write — could read `~/.flowviant/credentials.json`, which
79
+ * holds the MACHINE CREDENTIAL of every project connected on this box, and put
80
+ * it in an artifact, a plan note or a transcript that leaves the machine. That
81
+ * is a cross-project leak from a posture this file calls read-only.
82
+ *
83
+ * The fence is research's, measured the same day in the design shape:
84
+ * `Read(./**)` and `Glob(./**)` read the worktree; the knowledge library reads
85
+ * through `--add-dir` plus its own `//` rule; a file outside both was refused
86
+ * ("Claude requested permissions to read from …, but you haven't granted it
87
+ * yet"). GREP CARRIES NO ALLOW RULE AT ALL, on purpose: with none, Grep in the
88
+ * working directory (and in the added knowledge directory) ran, and Grep of a
89
+ * path outside them was refused. The Bash readers these postures keep are
90
+ * path-validated by the CLI on its own (see CONSULT_BASH).
91
+ */
92
+ function fencedReads(knowledgeDir) {
93
+ const kd = ruleSafeAbsolute(knowledgeDir);
94
+ return [
95
+ 'Read(./**)',
96
+ ...(kd ? [`Read(/${kd}/**)`] : []),
97
+ 'Glob(./**)',
98
+ ...(kd ? [`Glob(/${kd}/**)`] : []),
99
+ ];
100
+ }
101
+
102
+ /** The shell readers CONSULT and PLAN keep. Each one is PATH-VALIDATED by the
103
+ * CLI itself — measured on 2.1.281 (2026-09-24): with `Bash(cat:*)`,
104
+ * `Bash(head:*)` and `Bash(ls:*)` allowed, `cat`/`ls`/`head` on a file outside
105
+ * the working directories were each refused ("Claude Code may only
106
+ * concatenate files from the allowed working directories") — so it is the
107
+ * Read/Grep/Glob TOOLS, not these, that needed the fence above. */
108
+ const CONSULT_BASH = [
109
+ 'Bash(ls:*)',
110
+ 'Bash(wc:*)',
111
+ 'Bash(head:*)',
112
+ 'Bash(cat:*)',
113
+ 'Bash(git log:*)',
114
+ 'Bash(git show:*)',
115
+ 'Bash(git diff:*)',
116
+ 'Bash(git rev-parse:*)',
117
+ ];
118
+
23
119
  /**
24
120
  * PLAN — read the repo, write the plan, never the code.
25
121
  *
26
- * The read half is CONSULT_PERM verbatim: this turn's prompt is steered by
122
+ * The read half is `consultPermFor`'s verbatim (the read fence and the
123
+ * path-validated shell readers): this turn's prompt is steered by
27
124
  * anything a project editor can type, so the same threat applies and the same
28
125
  * allowlist answers it. What is added is the control plane and NOTHING else —
29
126
  * `mcp__flowviant` is the plan principal's token, whose entire tool set is the
@@ -36,21 +133,15 @@ export * from './prompts.mjs';
36
133
  * through an API, and there is no file on this machine it has any business
37
134
  * touching.
38
135
  */
39
- const PLAN_PERM = [
40
- '--allowedTools',
41
- 'mcp__flowviant',
42
- 'Read',
43
- 'Grep',
44
- 'Glob',
45
- 'Bash(ls:*)',
46
- 'Bash(wc:*)',
47
- 'Bash(head:*)',
48
- 'Bash(cat:*)',
49
- 'Bash(git log:*)',
50
- 'Bash(git show:*)',
51
- 'Bash(git diff:*)',
52
- 'Bash(git rev-parse:*)',
53
- ];
136
+ export function planPermFor(knowledgeDir) {
137
+ return [
138
+ ...READ_GUARD,
139
+ '--allowedTools',
140
+ 'mcp__flowviant',
141
+ ...fencedReads(knowledgeDir),
142
+ ...CONSULT_BASH,
143
+ ];
144
+ }
54
145
 
55
146
  // Unattended (default) skips prompts so the agent never stalls with no terminal;
56
147
  // FLOWVIANT_SAFE=1 restricts to a curated toolset instead.
@@ -81,6 +172,7 @@ const PERM = SAFE
81
172
  // after every wiki turn, and the vault has its own git history.
82
173
  // (Write/Edit can't be path-scoped here; the worktree reset is the backstop.)
83
174
  const WIKI_PERM = [
175
+ ...READ_GUARD,
84
176
  '--allowedTools',
85
177
  'Read',
86
178
  'Grep',
@@ -111,20 +203,155 @@ const WIKI_PERM = [
111
203
  // someone else's machine. The permission list is the enforcement; the prompt's
112
204
  // "do not change anything" is only an instruction, and instructions are exactly
113
205
  // what an injected question competes with.
114
- const CONSULT_PERM = [
115
- '--allowedTools',
116
- 'Read',
117
- 'Grep',
118
- 'Glob',
119
- 'Bash(ls:*)',
120
- 'Bash(wc:*)',
121
- 'Bash(head:*)',
122
- 'Bash(cat:*)',
123
- 'Bash(git log:*)',
124
- 'Bash(git show:*)',
125
- 'Bash(git diff:*)',
126
- 'Bash(git rev-parse:*)',
127
- ];
206
+ export function consultPermFor(knowledgeDir) {
207
+ return [...READ_GUARD, '--allowedTools', ...fencedReads(knowledgeDir), ...CONSULT_BASH];
208
+ }
209
+
210
+ /**
211
+ * THE TWO NON-CODE POSTURES (0.97.0) — a design card and a research card may
212
+ * WRITE, and only under `.flowviant/artifacts/`.
213
+ *
214
+ * The card's words are steered by anyone who can file a card, so "change
215
+ * nothing" as prose is only an instruction, and the permission list is the
216
+ * enforcement. What each carries is the ONE write its contract needs:
217
+ *
218
+ * · `Edit(.flowviant/artifacts/**)` — PROBED on Claude Code 2.1.281 before
219
+ * relying on it (2026-09-23): with `--allowedTools 'Read'
220
+ * 'Edit(.flowviant/artifacts/**)'`, a `-p` turn told to write
221
+ * `.flowviant/artifacts/a.html` AND `b.txt` at the root wrote the first and
222
+ * was DENIED the second. The Edit rule governs every file-writing tool,
223
+ * Write included. Spelled `Write(.flowviant/artifacts/**)` — or `./`- or
224
+ * `//abs`-anchored — the same probe was denied BOTH files, so that spelling
225
+ * is not a scoped write at all, it is no write. Relative to the turn's cwd,
226
+ * which is the agent's own worktree, which is where the artifact scan looks.
227
+ * `**` AND NOT `*`, measured the same day: `Edit(.flowviant/artifacts/*)`
228
+ * let a Write to `.flowviant/artifacts/sub/b.html` through exactly as `**`
229
+ * did, so the single-level spelling is not narrower on this CLI and would
230
+ * only read as if it were. The scan keeps top-level files, so the
231
+ * contracts say "no subfolders" — the prompt is the only thing that can.
232
+ *
233
+ * THEY ARE NOT THE SAME READER, and the difference is the network:
234
+ *
235
+ * · DESIGN draws THIS product, so it reads the repo — the worktree and the
236
+ * knowledge library through the read fence (`fencedReads`, 2026-09-24:
237
+ * it was bare Read/Grep/Glob, which reach the whole box), Grep with no
238
+ * allow rule, `ls`, and `.env*` denied by name — and has no web at all (a mockup that went looking on the
239
+ * web would be a mockup of somebody else's product). No git readers and no
240
+ * cat/head/wc: Read covers every one of them, and each git reader was an
241
+ * `--output` away from a write (see READ_GUARD, which it still carries for
242
+ * the `ls` it keeps).
243
+ * · RESEARCH has the web — WebSearch and WebFetch — and a turn that can both
244
+ * read a secret and fetch a URL can send one. So its reading is FENCED to
245
+ * what the card is about: `Read(./**)` (the worktree) plus the project's
246
+ * knowledge library by its absolute path, `Glob(./**)` for names, NO Grep
247
+ * (an unscoped search of the home directory prints matching lines), NO
248
+ * Bash at all, and `.env*` DENIED by name. PROBED on 2.1.281, the whole
249
+ * list with `--add-dir <knowledge>`: a file in the cwd read; `/etc/hostname`,
250
+ * a sibling directory's file and `~/.flowviant/credentials.json` were each
251
+ * refused ("Claude requested permissions to read from …, but you haven't
252
+ * granted it yet"); `.env` was refused ("File is in a directory that is
253
+ * denied by your permission settings"); the knowledge file read. Two
254
+ * controls: WITHOUT the `--disallowedTools` pair, `Read(./**)` read `.env`
255
+ * — the deny is load-bearing; and a bare `Glob` listed `~/.flowviant`
256
+ * while `Glob(./**)` refused it and still listed the cwd and the added
257
+ * directory. The knowledge `Read(//…)` rule is belt: `--add-dir` alone
258
+ * admitted the file, and the rule keeps the read allowed should the
259
+ * adapter ever stop passing that flag.
260
+ * AND `Bash` IS DENIED BY NAME, because leaving it off the allow list is
261
+ * not "no Bash": measured the same day, with Bash absent from
262
+ * `--allowedTools` the CLI still ran `git log -1 --format=%s` and `ls` on
263
+ * its own read-only classifier (it did refuse `git log … --output=…`,
264
+ * "This command requires approval" — the reviewer's write needed the
265
+ * explicit `Bash(git log:*)` prefix the old list carried). With `Bash` in
266
+ * `--disallowedTools` the tool was not there at all.
267
+ *
268
+ * NO MCP, and no shell that could commit: the agent turn has no MCP anyway
269
+ * (see SYSTEM_AGENT's header), and a posture that could run `git commit` could
270
+ * commit the mockup it was told to keep out of git.
271
+ *
272
+ * STATED, not closed: research may still read any file in the worktree and
273
+ * name it in a WebFetch URL — the repository is what a research card is about,
274
+ * and a turn that could not read it could not answer. Design may read any
275
+ * file in its worktree and put it in an artifact the server stores; text
276
+ * artifacts are scrubbed of the machine's known secret values — and of any
277
+ * Flowviant credential, by shape — on the way out (artifacts.mjs, env.mjs), and
278
+ * binary ones are withheld when they carry one.
279
+ * Design still runs a plain `git log` on the CLI's own read-only classifier
280
+ * though no git reader is allowed (measured) — the guard is what refuses its
281
+ * `--output`. And the Agent tool is offered with no allow rule on this CLI:
282
+ * a research turn with Bash denied launched a subagent to run one, and the
283
+ * subagent had no Bash either (measured) — the rules are inherited. Whether
284
+ * the guard's hook sees a subagent's own calls was not measured here.
285
+ */
286
+ const ARTIFACT_WRITE = 'Edit(.flowviant/artifacts/**)';
287
+ /**
288
+ * PLAN MODE (0.97.0) — a Workbench tab's `planMode` switch, and Claude Code's
289
+ * own `--permission-mode plan` INSTEAD OF every list above: the CLI reads,
290
+ * decides and answers with a plan, and refuses each write itself.
291
+ *
292
+ * PROBED on Claude Code 2.1.281 before relying on it (2026-09-23), in a scratch
293
+ * git repo, `-p … --output-format stream-json --verbose`:
294
+ *
295
+ * · ALONE, asked to "add a subtract(a, b) function to app.js": the init event
296
+ * reads `permissionMode: "plan"`, the turn Read the file, ran a read-only
297
+ * `find`, wrote its plan to `~/.claude/plans/<slug>.md` (the CLI's own plan
298
+ * file, outside the repo), tried `ExitPlanMode` and was told "Error: No such
299
+ * tool available: ExitPlanMode. ExitPlanMode is disabled for this session",
300
+ * and ended with the plan as its result text. app.js was untouched.
301
+ * · BESIDE `--dangerously-skip-permissions`, same ask: the init event reads
302
+ * `permissionMode: "bypassPermissions"` — no error, no warning — and the
303
+ * edit LANDED. The bypass silently wins. So this list is a REPLACEMENT for
304
+ * `perm`, never an addition, and a plan turn can never carry both.
305
+ * · WITH `--mcp-config` and `--allowedTools mcp__flowviant`: the server
306
+ * connected and its tools were listed, but a call was refused — "Cannot
307
+ * call mcp__flowviant__log_work while in plan mode." — and without the
308
+ * `--allowedTools` entry, "Claude requested permissions to use
309
+ * mcp__flowviant__log_work, but you haven't granted it yet." Plan mode
310
+ * admitted the call ONLY when the server annotated the tool
311
+ * `readOnlyHint: true`, and none of the session tools (stream_session_turn,
312
+ * log_work, file_card …) are read-only or annotated so. So a plan turn runs
313
+ * PLAIN — no MCP, the plain tab's contract — rather than mounting a control
314
+ * plane whose every call the CLI refuses. See work.mjs.
315
+ */
316
+ export const PLAN_MODE_PERM = ['--permission-mode', 'plan'];
317
+ export function designPermFor(knowledgeDir) {
318
+ return [
319
+ ...READ_GUARD,
320
+ '--allowedTools',
321
+ ...fencedReads(knowledgeDir),
322
+ 'Bash(ls:*)',
323
+ ARTIFACT_WRITE,
324
+ // A mockup is uploaded and runs scripts in a frame that may navigate
325
+ // itself, so the checkout's own secrets are denied by name too — the same
326
+ // pair research carries, measured load-bearing there.
327
+ '--disallowedTools',
328
+ 'Read(./.env*)',
329
+ 'Read(./**/.env*)',
330
+ ];
331
+ }
332
+ export const DESIGN_PERM = designPermFor(null);
333
+
334
+ /**
335
+ * RESEARCH, built at spawn — the knowledge library is the one directory
336
+ * outside the worktree it may read, and only the spawn knows where that is.
337
+ * `//` is the CLI's own spelling for an absolute path in a rule.
338
+ */
339
+ export function researchPerm(knowledgeDir) {
340
+ return [
341
+ ...READ_GUARD,
342
+ '--allowedTools',
343
+ ...fencedReads(knowledgeDir),
344
+ 'WebSearch',
345
+ 'WebFetch',
346
+ ARTIFACT_WRITE,
347
+ '--disallowedTools',
348
+ 'Read(./.env*)',
349
+ 'Read(./**/.env*)',
350
+ 'Bash',
351
+ ];
352
+ }
353
+ /** The list with no library — what a project that keeps none runs under. */
354
+ export const RESEARCH_PERM = researchPerm(null);
128
355
 
129
356
  export const sleep = (s) => new Promise((r) => setTimeout(r, s * 1000));
130
357
 
@@ -132,10 +359,26 @@ export const sleep = (s) => new Promise((r) => setTimeout(r, s * 1000));
132
359
  // matching falsely fired when an agent merely *mentioned* the word in prose
133
360
  // (e.g. "I won't fabricate a BLOCKED:<id> line"), trapping the worker in a fake
134
361
  // blocked loop. Anchor to a full line instead.
135
- export const sawSentinel = (out, name) => new RegExp(`^\\s*${name}\\s*$`, 'm').test(out);
362
+ //
363
+ // LINE-SPLIT, NEVER A `^...$` REGEX WITH A `\s*` ON BOTH ENDS (audit
364
+ // 2026-09-24). `new RegExp('^\\s*NAME\\s*$', 'm')` and
365
+ // `/^\s*BLOCKED:(\S+)\s*$/m` are QUADRATIC against a long run of
366
+ // whitespace on one line — the same shape `taskIdsFromMessage` already had
367
+ // to fix, measured ~0.5s at 40KB here — and a CLI's own stdout is exactly
368
+ // the kind of text a stray control sequence or a pasted blob can grow past
369
+ // that. A length cap first, then plain string work, same as there.
370
+ const SENTINEL_MAX_LINE = 2000;
371
+ export const sawSentinel = (out, name) =>
372
+ String(out ?? '')
373
+ .split('\n')
374
+ .some((l) => l.length < SENTINEL_MAX_LINE && l.trim() === name);
136
375
  export const blockedId = (out) => {
137
- const m = out.match(/^\s*BLOCKED:(\S+)\s*$/m);
138
- return m ? m[1] : null;
376
+ for (const l of String(out ?? '').split('\n')) {
377
+ if (l.length > SENTINEL_MAX_LINE) continue;
378
+ const t = l.trim();
379
+ if (t.startsWith('BLOCKED:') && /^BLOCKED:\S+$/.test(t)) return t.slice(8);
380
+ }
381
+ return null;
139
382
  };
140
383
 
141
384
  /**
@@ -300,10 +543,17 @@ export function handleStreamLine(line, { cwd, emit, onActivity, onToolEvent, app
300
543
  // ADOPTABLE — so any headless turn we run for our own purposes would leave
301
544
  // a phantom untitled session in the `+` menu. The caller that needs to
302
545
  // clean up after itself cannot do so without this id.
303
- if (Array.isArray(ev.skills) || typeof ev.session_id === 'string') {
546
+ //
547
+ // `mcpServers` (0.97.0) is the same kind of fact from the same event: the
548
+ // MCP servers and claude.ai connectors the CLI mounted, each with its own
549
+ // status (`needs-auth` above all — a sign-in that has to happen at this
550
+ // box). Relayed raw; `recordMcpServers` (runtimes.mjs) normalises it and
551
+ // drops this daemon's own `flowviant` server.
552
+ if (Array.isArray(ev.skills) || typeof ev.session_id === 'string' || Array.isArray(ev.mcp_servers)) {
304
553
  onInit?.({
305
554
  skills: Array.isArray(ev.skills) ? ev.skills.map(String) : undefined,
306
555
  sessionId: typeof ev.session_id === 'string' ? ev.session_id : undefined,
556
+ mcpServers: Array.isArray(ev.mcp_servers) ? ev.mcp_servers : undefined,
307
557
  });
308
558
  }
309
559
  } else if (ev.type === 'result') {
@@ -342,6 +592,27 @@ export function handleStreamLine(line, { cwd, emit, onActivity, onToolEvent, app
342
592
  }
343
593
  }
344
594
 
595
+ /**
596
+ * THE ENVIRONMENT A CLI TURN IS SPAWNED WITH — the daemon's own, plus the
597
+ * turn's MCP token, MINUS the machine credential (2026-09-24, the audit).
598
+ *
599
+ * A turn cannot be handed a curated environment the way `childEnv` builds one
600
+ * for a deploy: the CLI's own sign-in lives in this environment, and stripping
601
+ * it signs the CLI out. But on a headless box started as
602
+ * `FLOWVIANT_MACHINE_TOKEN=… npx flowviant` the MACHINE CREDENTIAL lives here
603
+ * too, and every turn — and every dev server and script a turn starts, which
604
+ * inherit it — could read it with one `env`. Nothing a CLI or its children do
605
+ * needs it: the daemon authenticates to the server itself, and a turn that
606
+ * needs the project's tools gets its own per-turn token through `mcpEnv` or
607
+ * the MCP config file. So those two names, and only those, are removed.
608
+ */
609
+ const MACHINE_CREDENTIAL_ENV = ['FLOWVIANT_MACHINE_TOKEN', 'FLOWVIANT_FLEET'];
610
+ export function cliEnv(mcpEnv) {
611
+ const env = { ...process.env, ...(mcpEnv ?? {}) };
612
+ for (const k of MACHINE_CREDENTIAL_ENV) delete env[k];
613
+ return env;
614
+ }
615
+
345
616
  // One Claude Code turn. Output is captured (for sentinel detection) and streamed
346
617
  // through, line-prefixed with the worker label so a fleet stays legible.
347
618
  //
@@ -351,9 +622,32 @@ export function handleStreamLine(line, { cwd, emit, onActivity, onToolEvent, app
351
622
  // returned string for sentinel detection, and each activity is handed to
352
623
  // `onActivity` so the caller can forward progress. Build-agent turns leave it
353
624
  // off and keep the raw text passthrough + line sentinels.
354
- export function runTurn({ prompt, resume, system, cwd, mcpConfig, mcpArgs, mcpEnv, runtime = 'claude', label, onSpawn, streamJson, answerFromResult, onActivity, onToolEvent, onInit, onUsage, onThreadId, wikiPerm, readOnly, planPerm, vaultDir, knowledgeDir, resultSchemaArgs, model, effort, adoptResumeId, resumeThreadId, resumeConversationId }) {
625
+ export function runTurn({ prompt, resume, system, cwd, mcpConfig, mcpArgs, mcpEnv, agentTools, runtime = 'claude', label, onSpawn, streamJson, answerFromResult, onActivity, onToolEvent, onInit, onUsage, onThreadId, onAnswer, wikiPerm, readOnly, planPerm, planMode, posture, vaultDir, knowledgeDir, resultSchemaArgs, model, effort, adoptResumeId, resumeThreadId, resumeConversationId }) {
355
626
  return new Promise((resolve) => {
356
627
  const rt = runtimeById(runtime);
628
+ // PLAN MODE IS CLAUDE'S (0.97.0). The other adapters build their argv
629
+ // from `profile` and never read `perm`, so a codex or agy turn handed
630
+ // `planMode` would run a BUILD turn wearing the word "plan". The caller
631
+ // refuses first, in words; this is the belt — fail the turn, never
632
+ // substitute.
633
+ if (planMode && rt.id !== 'claude') {
634
+ console.error(`\nerror: plan mode runs on Claude Code only — not '${rt.label}'`);
635
+ resolve('');
636
+ return;
637
+ }
638
+ // …AND SO ARE THE TWO NON-CODE POSTURES, for the same reason and in the
639
+ // same shape. The agent lane asks `canRun` first and settles `nothing` in
640
+ // words; this is the layer no caller can skip. A codex or agy adapter
641
+ // handed `posture: 'design'` never reads `perm` — it builds from `profile`
642
+ // and runs whatever that falls back to, which for codex is its build
643
+ // branch, `--sandbox danger-full-access`: a research card's words with the
644
+ // whole machine to act on. Asked against the runtime's own declared profiles,
645
+ // so the day an adapter learns to express one this opens by itself.
646
+ if ((posture === 'design' || posture === 'research') && !(rt.profiles ?? []).includes(posture)) {
647
+ console.error(`\nerror: a ${posture} card runs on Claude Code only — not '${rt.label}'`);
648
+ resolve('');
649
+ return;
650
+ }
357
651
  if (!rt.args) {
358
652
  // Reached only if a brief names a runtime this daemon declares but cannot
359
653
  // drive. Fail as a turn with no sentinel — the loop already treats that as
@@ -385,7 +679,15 @@ export function runTurn({ prompt, resume, system, cwd, mcpConfig, mcpArgs, mcpEn
385
679
  // of the two and a planning turn that fell through to 'consult' would lose
386
680
  // the control plane it exists to use — it would read the repo, decide what
387
681
  // the slices are, and have no way to write any of them down.
388
- const profile = planPerm ? 'plan' : readOnly ? 'consult' : wikiPerm ? 'wiki' : 'build';
682
+ // A DESIGN OR RESEARCH CARD'S POSTURE (0.97.0) is asked before all of
683
+ // them, by NAME, because it is the narrowest promise here: write only the
684
+ // artifacts directory. It is set only by the agent lane, from the card's
685
+ // kind; every other caller passes nothing and gets exactly the branch it
686
+ // got before this existed.
687
+ const profile =
688
+ posture === 'design' || posture === 'research'
689
+ ? posture
690
+ : planPerm ? 'plan' : readOnly ? 'consult' : wikiPerm ? 'wiki' : 'build';
389
691
  const args = rt.args({
390
692
  prompt,
391
693
  system,
@@ -394,6 +696,7 @@ export function runTurn({ prompt, resume, system, cwd, mcpConfig, mcpArgs, mcpEn
394
696
  resume,
395
697
  streamJson,
396
698
  profile,
699
+ agentTools,
397
700
  // Adopting a terminal session (work.mjs): Claude turns it into
398
701
  // `--resume <id> --fork-session` (a FORK — the original is untouched);
399
702
  // agy turns it into `--conversation <id>` (a MOVE — agy has no fork, the
@@ -418,12 +721,25 @@ export function runTurn({ prompt, resume, system, cwd, mcpConfig, mcpArgs, mcpEn
418
721
  // prompt as a trailing positional, so a flag after it is in the wrong
419
722
  // place.
420
723
  resultSchemaArgs,
421
- perm: planPerm ? PLAN_PERM : readOnly ? CONSULT_PERM : wikiPerm ? WIKI_PERM : PERM,
724
+ // PLAN MODE REPLACES THE POSTURE, never joins it: beside
725
+ // `--dangerously-skip-permissions` the bypass wins silently (measured —
726
+ // see PLAN_MODE_PERM).
727
+ perm: planMode
728
+ ? PLAN_MODE_PERM
729
+ : profile === 'design'
730
+ ? designPermFor(knowledgeDir)
731
+ : profile === 'research'
732
+ ? researchPerm(knowledgeDir)
733
+ : planPerm
734
+ ? planPermFor(knowledgeDir)
735
+ : readOnly
736
+ ? consultPermFor(knowledgeDir)
737
+ : wikiPerm ? WIKI_PERM : PERM,
422
738
  // Handed to the adapter rather than appended here, because WHERE these go
423
739
  // is a property of the CLI: Codex reads its prompt as a trailing
424
740
  // positional, so a flag after it is a flag in the wrong place.
425
741
  // Wiki-vault turns are pure file work and pass neither — no MCP at all.
426
- mcp: mcpConfig ? ['--mcp-config', mcpConfig] : (mcpArgs ?? []),
742
+ mcp: planMode ? ['--strict-mcp-config'] : mcpConfig ? ['--mcp-config', mcpConfig] : (mcpArgs ?? []),
427
743
  // Resuming a SPECIFIC held conversation by its own id (work.mjs, codex
428
744
  // sessions). Runtimes without a by-id resume ignore it and keep their
429
745
  // `resume` behavior unchanged.
@@ -469,8 +785,9 @@ export function runTurn({ prompt, resume, system, cwd, mcpConfig, mcpArgs, mcpEn
469
785
  detached: true,
470
786
  // Only ADDS to the environment (the worker token, for runtimes that read
471
787
  // it from there). Never replaces it: the CLI's own credentials live in
472
- // this environment, and handing it a curated one signs it out.
473
- ...(mcpEnv ? { env: { ...process.env, ...mcpEnv } } : {}),
788
+ // this environment, and handing it a curated one signs it out. The one
789
+ // thing it REMOVES is this daemon's machine credential — see `cliEnv`.
790
+ env: cliEnv(mcpEnv),
474
791
  });
475
792
  onSpawn?.(child);
476
793
  let out = '';
@@ -497,14 +814,28 @@ export function runTurn({ prompt, resume, system, cwd, mcpConfig, mcpArgs, mcpEn
497
814
  // thread.started). Purely additive: callers that pass no onThreadId —
498
815
  // every dispatch path — see zero behavior change.
499
816
  if (ev.threadId) onThreadId?.(ev.threadId);
817
+ // The runtime's own token count (codex's `turn.completed`) — the same
818
+ // `onUsage` Claude's result event feeds, so a caller charges a turn
819
+ // identically whichever CLI ran it.
820
+ if (ev.usage) onUsage?.(ev.usage);
821
+ // One agent MESSAGE, alone — see parseCodexLine's `answer`. Last call
822
+ // wins at the caller, which is what "the final answer" means.
823
+ if (typeof ev.answer === 'string') onAnswer?.(ev.answer);
500
824
  if (ev.text) appendText(ev.text);
501
825
  if (ev.activity) {
502
826
  emit(`${ev.activity.label}\n`);
503
827
  onActivity?.(ev.activity);
504
828
  }
505
829
  };
830
+ // DECODED AS A STREAM, never chunk by chunk (2026-09-24, the audit): a
831
+ // pipe read can end inside a multi-byte character — the em dash models
832
+ // write constantly — and `d.toString()` per chunk turned each half into
833
+ // U+FFFD, which JSON.parse accepts and the reply then carried verbatim.
834
+ // `setEncoding` holds the partial bytes over to the next chunk.
835
+ child.stdout.setEncoding('utf8');
836
+ child.stderr.setEncoding('utf8');
506
837
  child.stdout.on('data', (d) => {
507
- buf += d.toString();
838
+ buf += d;
508
839
  let nl;
509
840
  while ((nl = buf.indexOf('\n')) >= 0) {
510
841
  const line = buf.slice(0, nl);
@@ -513,8 +844,7 @@ export function runTurn({ prompt, resume, system, cwd, mcpConfig, mcpArgs, mcpEn
513
844
  }
514
845
  });
515
846
  // stderr is not JSON (warnings/errors) — pass through and keep for sentinels.
516
- child.stderr.on('data', (d) => {
517
- const s = d.toString();
847
+ child.stderr.on('data', (s) => {
518
848
  out += s;
519
849
  emit(s);
520
850
  });
@@ -543,11 +873,13 @@ export function runTurn({ prompt, resume, system, cwd, mcpConfig, mcpArgs, mcpEn
543
873
  return;
544
874
  }
545
875
 
546
- const onChunk = (d) => {
547
- const s = d.toString();
876
+ const onChunk = (s) => {
548
877
  out += s;
549
878
  emit(s);
550
879
  };
880
+ // Stream-decoded for the same reason as the line-parsed path above.
881
+ child.stdout.setEncoding('utf8');
882
+ child.stderr.setEncoding('utf8');
551
883
  child.stdout.on('data', onChunk);
552
884
  child.stderr.on('data', onChunk);
553
885
  child.on('error', (e) => {
@@ -327,7 +327,30 @@ export let FLEET_TOKEN =
327
327
  * A frozen copy read at import would salt a picked project's report with null
328
328
  * and report nothing at all, for the whole life of the process.
329
329
  */
330
- export let PROJECT_ID = CREDENTIAL.entry?.projectId ?? null;
330
+ export let PROJECT_ID =
331
+ CREDENTIAL.entry?.fleetToken && CREDENTIAL.entry.fleetToken === FLEET_TOKEN
332
+ ? (CREDENTIAL.entry.projectId ?? null)
333
+ : null;
334
+
335
+ /**
336
+ * THE STORE'S PROJECT IS THIS DAEMON'S ONLY WHEN IT IS THIS DAEMON'S TOKEN.
337
+ * `FLEET_TOKEN` prefers `--fleet` and the environment over the store, and the
338
+ * id above used to come from the store unconditionally — so a box holding one
339
+ * unbound project A, started with project B's token in the environment, salted
340
+ * B's env report with A's id (every variable then read `differs` beside B's
341
+ * other boxes, the confident wrong answer the salt exists to prevent) and
342
+ * printed "serves · A". It starts null in that case, and the ROSTER's own word
343
+ * settles it here on the first poll — the "names no project until the roster
344
+ * does" the paragraph above always promised and nothing ever did.
345
+ */
346
+ export function learnProjectId(id) {
347
+ if (typeof id === 'string' && id) PROJECT_ID = id;
348
+ }
349
+
350
+ /** Whether the stored resolution describes the token actually in use. */
351
+ export function storedCredentialInUse() {
352
+ return Boolean(CREDENTIAL.entry?.fleetToken) && CREDENTIAL.entry.fleetToken === FLEET_TOKEN;
353
+ }
331
354
 
332
355
  /** cli.mjs's picker chose. Must run BEFORE runFleetDaemon — nothing here
333
356
  * re-authenticates a connection already made. */
@@ -48,6 +48,7 @@ import { execFileSync } from 'node:child_process';
48
48
  import { mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from 'node:fs';
49
49
  import { homedir } from 'node:os';
50
50
  import { join } from 'node:path';
51
+ import { printable } from './printable.mjs';
51
52
 
52
53
  const CRED_DIR = join(homedir(), '.flowviant');
53
54
  const CRED_FILE = join(CRED_DIR, 'credentials.json');
@@ -133,7 +134,28 @@ export function listStoredProjects() {
133
134
 
134
135
  /** What a project is CALLED on a terminal: its name, or an id you can grep. */
135
136
  export function projectLabel(e) {
136
- return e?.name ?? (e?.projectId ? `project ${e.projectId.slice(0, 8)}…` : 'an unnamed project');
137
+ return safeName(e?.name) ?? (e?.projectId ? `project ${e.projectId.slice(0, 8)}…` : 'an unnamed project');
138
+ }
139
+
140
+ /**
141
+ * A SERVER-SUPPLIED NAME, SAFE TO PRINT ON A TERMINAL — or null.
142
+ *
143
+ * A project's name is whatever an owner typed in the app, and the server's
144
+ * schema caps its length and nothing else. Printed raw it reaches every
145
+ * terminal that connects a machine to the project — `login`, the banner, the
146
+ * picker, `projects`, `machines` — and a name carrying an escape sequence can
147
+ * rewrite the clipboard (OSC 52 in kitty, WezTerm, Windows Terminal) or hide
148
+ * lines of the very listing whose job is to say which project is which. The
149
+ * scrub is `printable()` (C0, DEL, C1 — the server's own `parseBoxName` makes
150
+ * the same cut for hostnames — plus the bidi/format controls that reorder a
151
+ * line, added 2026-09-24 rather than re-derived: see printable.mjs), and what
152
+ * is left is trimmed; nothing left is null, so the caller's id fallback
153
+ * speaks instead of an empty label.
154
+ */
155
+ export function safeName(name) {
156
+ if (typeof name !== 'string') return null;
157
+ const clean = printable(name).trim();
158
+ return clean || null;
137
159
  }
138
160
 
139
161
  /**
@@ -316,7 +338,7 @@ export function saveLogin({ fleetToken, projectId, mcpUrl, name, repoRoot }) {
316
338
  ...(f.projects[projectId] ?? {}),
317
339
  fleetToken,
318
340
  mcpUrl: mcpUrl ?? null,
319
- ...(name ? { name } : {}),
341
+ ...(safeName(name) ? { name: safeName(name) } : {}),
320
342
  ...(repoRoot ? { repoRoot } : {}),
321
343
  savedAt: new Date().toISOString(),
322
344
  };
@@ -391,8 +413,9 @@ export function bindStoredRepo(projectId, repoRoot) {
391
413
 
392
414
  /** The roster names the project on every poll; remember it so the picker and
393
415
  * `flowviant projects` can say a NAME instead of an id. */
394
- export function setStoredProjectName(projectId, name) {
395
- if (!projectId || typeof name !== 'string' || !name) return;
416
+ export function setStoredProjectName(projectId, raw) {
417
+ const name = safeName(raw);
418
+ if (!projectId || !name) return;
396
419
  const entries = listStoredProjects();
397
420
  const e = entries.find((x) => x.projectId === projectId);
398
421
  if (!e || e.name === name) return;