flowviant 0.97.0 → 0.100.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 (50) hide show
  1. package/README.md +15 -13
  2. package/bin/cli.mjs +108 -28
  3. package/bin/lib/agentPlan.mjs +159 -37
  4. package/bin/lib/artifacts.mjs +136 -26
  5. package/bin/lib/authReject.mjs +32 -0
  6. package/bin/lib/authproxy.mjs +52 -16
  7. package/bin/lib/childEnv.mjs +33 -1
  8. package/bin/lib/claude.mjs +166 -71
  9. package/bin/lib/config.mjs +36 -9
  10. package/bin/lib/credentials.mjs +29 -5
  11. package/bin/lib/deploy.mjs +364 -30
  12. package/bin/lib/desktopContract.mjs +227 -0
  13. package/bin/lib/env.mjs +130 -13
  14. package/bin/lib/fleet.mjs +121 -30
  15. package/bin/lib/git.mjs +131 -3
  16. package/bin/lib/hooks/readGuard.mjs +24 -0
  17. package/bin/lib/instance.mjs +129 -14
  18. package/bin/lib/knowledge.mjs +180 -60
  19. package/bin/lib/landed.mjs +38 -30
  20. package/bin/lib/launchCommand.mjs +23 -0
  21. package/bin/lib/listeners.mjs +211 -0
  22. package/bin/lib/localSessions.mjs +29 -4
  23. package/bin/lib/login.mjs +36 -12
  24. package/bin/lib/machines.mjs +24 -14
  25. package/bin/lib/mcp-cli.mjs +4 -3
  26. package/bin/lib/preflight.mjs +2 -1
  27. package/bin/lib/preview.mjs +95 -6
  28. package/bin/lib/printable.mjs +42 -0
  29. package/bin/lib/procRegistry.mjs +65 -0
  30. package/bin/lib/processes.mjs +20 -7
  31. package/bin/lib/projectTools.mjs +248 -0
  32. package/bin/lib/prompts.mjs +16 -11
  33. package/bin/lib/resources.mjs +46 -4
  34. package/bin/lib/runtimeOfferings.mjs +75 -0
  35. package/bin/lib/runtimes.mjs +71 -9
  36. package/bin/lib/shipMerge.mjs +8 -4
  37. package/bin/lib/trace.mjs +16 -6
  38. package/bin/lib/update.mjs +114 -25
  39. package/bin/lib/work.mjs +1192 -4584
  40. package/bin/lib/workAgentMerges.mjs +504 -0
  41. package/bin/lib/workAgentPlans.mjs +447 -0
  42. package/bin/lib/workAgentReview.mjs +702 -0
  43. package/bin/lib/workAgentTurns.mjs +1151 -0
  44. package/bin/lib/workDiffs.mjs +107 -0
  45. package/bin/lib/workPreviews.mjs +351 -0
  46. package/bin/lib/workProcesses.mjs +236 -0
  47. package/bin/lib/workPullRequests.mjs +313 -0
  48. package/bin/lib/workShip.mjs +385 -0
  49. package/bin/lib/worktreeDiff.mjs +99 -43
  50. package/package.json +2 -3
@@ -61,10 +61,66 @@ export const READ_GUARD_SETTINGS = JSON.stringify({
61
61
  * `--settings` pair must stand before it or be swallowed as a tool name. */
62
62
  const READ_GUARD = ['--settings', READ_GUARD_SETTINGS];
63
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
+
64
119
  /**
65
120
  * PLAN — read the repo, write the plan, never the code.
66
121
  *
67
- * 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
68
124
  * anything a project editor can type, so the same threat applies and the same
69
125
  * allowlist answers it. What is added is the control plane and NOTHING else —
70
126
  * `mcp__flowviant` is the plan principal's token, whose entire tool set is the
@@ -77,22 +133,15 @@ const READ_GUARD = ['--settings', READ_GUARD_SETTINGS];
77
133
  * through an API, and there is no file on this machine it has any business
78
134
  * touching.
79
135
  */
80
- const PLAN_PERM = [
81
- ...READ_GUARD,
82
- '--allowedTools',
83
- 'mcp__flowviant',
84
- 'Read',
85
- 'Grep',
86
- 'Glob',
87
- 'Bash(ls:*)',
88
- 'Bash(wc:*)',
89
- 'Bash(head:*)',
90
- 'Bash(cat:*)',
91
- 'Bash(git log:*)',
92
- 'Bash(git show:*)',
93
- 'Bash(git diff:*)',
94
- 'Bash(git rev-parse:*)',
95
- ];
136
+ export function planPermFor(knowledgeDir) {
137
+ return [
138
+ ...READ_GUARD,
139
+ '--allowedTools',
140
+ 'mcp__flowviant',
141
+ ...fencedReads(knowledgeDir),
142
+ ...CONSULT_BASH,
143
+ ];
144
+ }
96
145
 
97
146
  // Unattended (default) skips prompts so the agent never stalls with no terminal;
98
147
  // FLOWVIANT_SAFE=1 restricts to a curated toolset instead.
@@ -154,21 +203,9 @@ const WIKI_PERM = [
154
203
  // someone else's machine. The permission list is the enforcement; the prompt's
155
204
  // "do not change anything" is only an instruction, and instructions are exactly
156
205
  // what an injected question competes with.
157
- const CONSULT_PERM = [
158
- ...READ_GUARD,
159
- '--allowedTools',
160
- 'Read',
161
- 'Grep',
162
- 'Glob',
163
- 'Bash(ls:*)',
164
- 'Bash(wc:*)',
165
- 'Bash(head:*)',
166
- 'Bash(cat:*)',
167
- 'Bash(git log:*)',
168
- 'Bash(git show:*)',
169
- 'Bash(git diff:*)',
170
- 'Bash(git rev-parse:*)',
171
- ];
206
+ export function consultPermFor(knowledgeDir) {
207
+ return [...READ_GUARD, '--allowedTools', ...fencedReads(knowledgeDir), ...CONSULT_BASH];
208
+ }
172
209
 
173
210
  /**
174
211
  * THE TWO NON-CODE POSTURES (0.97.0) — a design card and a research card may
@@ -195,8 +232,10 @@ const CONSULT_PERM = [
195
232
  *
196
233
  * THEY ARE NOT THE SAME READER, and the difference is the network:
197
234
  *
198
- * · DESIGN draws THIS product, so it reads the repo broadly — Read, Grep,
199
- * Glob and `ls` — and has no web at all (a mockup that went looking on the
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
200
239
  * web would be a mockup of somebody else's product). No git readers and no
201
240
  * cat/head/wc: Read covers every one of them, and each git reader was an
202
241
  * `--output` away from a write (see READ_GUARD, which it still carries for
@@ -233,9 +272,10 @@ const CONSULT_PERM = [
233
272
  * STATED, not closed: research may still read any file in the worktree and
234
273
  * name it in a WebFetch URL — the repository is what a research card is about,
235
274
  * and a turn that could not read it could not answer. Design may read any
236
- * file the Read tool reaches and put it in an artifact the server stores;
237
- * text artifacts are scrubbed of the machine's known secret values on the way
238
- * out (artifacts.mjs), and binary ones are withheld when they carry one.
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.
239
279
  * Design still runs a plain `git log` on the CLI's own read-only classifier
240
280
  * though no git reader is allowed (measured) — the guard is what refuses its
241
281
  * `--output`. And the Agent tool is offered with no allow rule on this CLI:
@@ -274,21 +314,22 @@ const ARTIFACT_WRITE = 'Edit(.flowviant/artifacts/**)';
274
314
  * plane whose every call the CLI refuses. See work.mjs.
275
315
  */
276
316
  export const PLAN_MODE_PERM = ['--permission-mode', 'plan'];
277
- export const DESIGN_PERM = [
278
- ...READ_GUARD,
279
- '--allowedTools',
280
- 'Read',
281
- 'Grep',
282
- 'Glob',
283
- 'Bash(ls:*)',
284
- ARTIFACT_WRITE,
285
- ];
286
-
287
- /** A path a permission rule can name: absolute POSIX, and free of the glob and
288
- * rule-syntax characters that would change what the rule means. Anything else
289
- * gets no rule — `--add-dir` still admits the directory (measured). */
290
- const ruleSafeAbsolute = (p) =>
291
- typeof p === 'string' && p.startsWith('/') && !/[*?[\]{}()\\\n]/.test(p) ? p.replace(/\/+$/, '') : null;
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);
292
333
 
293
334
  /**
294
335
  * RESEARCH, built at spawn — the knowledge library is the one directory
@@ -296,14 +337,10 @@ const ruleSafeAbsolute = (p) =>
296
337
  * `//` is the CLI's own spelling for an absolute path in a rule.
297
338
  */
298
339
  export function researchPerm(knowledgeDir) {
299
- const kd = ruleSafeAbsolute(knowledgeDir);
300
340
  return [
301
341
  ...READ_GUARD,
302
342
  '--allowedTools',
303
- 'Read(./**)',
304
- ...(kd ? [`Read(/${kd}/**)`] : []),
305
- 'Glob(./**)',
306
- ...(kd ? [`Glob(/${kd}/**)`] : []),
343
+ ...fencedReads(knowledgeDir),
307
344
  'WebSearch',
308
345
  'WebFetch',
309
346
  ARTIFACT_WRITE,
@@ -322,10 +359,26 @@ export const sleep = (s) => new Promise((r) => setTimeout(r, s * 1000));
322
359
  // matching falsely fired when an agent merely *mentioned* the word in prose
323
360
  // (e.g. "I won't fabricate a BLOCKED:<id> line"), trapping the worker in a fake
324
361
  // blocked loop. Anchor to a full line instead.
325
- 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);
326
375
  export const blockedId = (out) => {
327
- const m = out.match(/^\s*BLOCKED:(\S+)\s*$/m);
328
- 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;
329
382
  };
330
383
 
331
384
  /**
@@ -539,6 +592,27 @@ export function handleStreamLine(line, { cwd, emit, onActivity, onToolEvent, app
539
592
  }
540
593
  }
541
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
+
542
616
  // One Claude Code turn. Output is captured (for sentinel detection) and streamed
543
617
  // through, line-prefixed with the worker label so a fleet stays legible.
544
618
  //
@@ -548,7 +622,7 @@ export function handleStreamLine(line, { cwd, emit, onActivity, onToolEvent, app
548
622
  // returned string for sentinel detection, and each activity is handed to
549
623
  // `onActivity` so the caller can forward progress. Build-agent turns leave it
550
624
  // off and keep the raw text passthrough + line sentinels.
551
- export function runTurn({ prompt, resume, system, cwd, mcpConfig, mcpArgs, mcpEnv, runtime = 'claude', label, onSpawn, streamJson, answerFromResult, onActivity, onToolEvent, onInit, onUsage, onThreadId, wikiPerm, readOnly, planPerm, planMode, posture, 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 }) {
552
626
  return new Promise((resolve) => {
553
627
  const rt = runtimeById(runtime);
554
628
  // PLAN MODE IS CLAUDE'S (0.97.0). The other adapters build their argv
@@ -622,6 +696,7 @@ export function runTurn({ prompt, resume, system, cwd, mcpConfig, mcpArgs, mcpEn
622
696
  resume,
623
697
  streamJson,
624
698
  profile,
699
+ agentTools,
625
700
  // Adopting a terminal session (work.mjs): Claude turns it into
626
701
  // `--resume <id> --fork-session` (a FORK — the original is untouched);
627
702
  // agy turns it into `--conversation <id>` (a MOVE — agy has no fork, the
@@ -652,15 +727,19 @@ export function runTurn({ prompt, resume, system, cwd, mcpConfig, mcpArgs, mcpEn
652
727
  perm: planMode
653
728
  ? PLAN_MODE_PERM
654
729
  : profile === 'design'
655
- ? DESIGN_PERM
730
+ ? designPermFor(knowledgeDir)
656
731
  : profile === 'research'
657
732
  ? researchPerm(knowledgeDir)
658
- : planPerm ? PLAN_PERM : readOnly ? CONSULT_PERM : wikiPerm ? WIKI_PERM : PERM,
733
+ : planPerm
734
+ ? planPermFor(knowledgeDir)
735
+ : readOnly
736
+ ? consultPermFor(knowledgeDir)
737
+ : wikiPerm ? WIKI_PERM : PERM,
659
738
  // Handed to the adapter rather than appended here, because WHERE these go
660
739
  // is a property of the CLI: Codex reads its prompt as a trailing
661
740
  // positional, so a flag after it is a flag in the wrong place.
662
741
  // Wiki-vault turns are pure file work and pass neither — no MCP at all.
663
- mcp: mcpConfig ? ['--mcp-config', mcpConfig] : (mcpArgs ?? []),
742
+ mcp: planMode ? ['--strict-mcp-config'] : mcpConfig ? ['--mcp-config', mcpConfig] : (mcpArgs ?? []),
664
743
  // Resuming a SPECIFIC held conversation by its own id (work.mjs, codex
665
744
  // sessions). Runtimes without a by-id resume ignore it and keep their
666
745
  // `resume` behavior unchanged.
@@ -706,8 +785,9 @@ export function runTurn({ prompt, resume, system, cwd, mcpConfig, mcpArgs, mcpEn
706
785
  detached: true,
707
786
  // Only ADDS to the environment (the worker token, for runtimes that read
708
787
  // it from there). Never replaces it: the CLI's own credentials live in
709
- // this environment, and handing it a curated one signs it out.
710
- ...(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),
711
791
  });
712
792
  onSpawn?.(child);
713
793
  let out = '';
@@ -734,14 +814,28 @@ export function runTurn({ prompt, resume, system, cwd, mcpConfig, mcpArgs, mcpEn
734
814
  // thread.started). Purely additive: callers that pass no onThreadId —
735
815
  // every dispatch path — see zero behavior change.
736
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);
737
824
  if (ev.text) appendText(ev.text);
738
825
  if (ev.activity) {
739
826
  emit(`${ev.activity.label}\n`);
740
827
  onActivity?.(ev.activity);
741
828
  }
742
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');
743
837
  child.stdout.on('data', (d) => {
744
- buf += d.toString();
838
+ buf += d;
745
839
  let nl;
746
840
  while ((nl = buf.indexOf('\n')) >= 0) {
747
841
  const line = buf.slice(0, nl);
@@ -750,8 +844,7 @@ export function runTurn({ prompt, resume, system, cwd, mcpConfig, mcpArgs, mcpEn
750
844
  }
751
845
  });
752
846
  // stderr is not JSON (warnings/errors) — pass through and keep for sentinels.
753
- child.stderr.on('data', (d) => {
754
- const s = d.toString();
847
+ child.stderr.on('data', (s) => {
755
848
  out += s;
756
849
  emit(s);
757
850
  });
@@ -780,11 +873,13 @@ export function runTurn({ prompt, resume, system, cwd, mcpConfig, mcpArgs, mcpEn
780
873
  return;
781
874
  }
782
875
 
783
- const onChunk = (d) => {
784
- const s = d.toString();
876
+ const onChunk = (s) => {
785
877
  out += s;
786
878
  emit(s);
787
879
  };
880
+ // Stream-decoded for the same reason as the line-parsed path above.
881
+ child.stdout.setEncoding('utf8');
882
+ child.stderr.setEncoding('utf8');
788
883
  child.stdout.on('data', onChunk);
789
884
  child.stderr.on('data', onChunk);
790
885
  child.on('error', (e) => {
@@ -11,14 +11,18 @@ import { cpus, hostname, totalmem } from 'node:os';
11
11
  // '0.28.0' across every release through 0.28.6, so the startup banner, the
12
12
  // User-Agent the server version-gates on, and the self-update check all reported
13
13
  // a stale version (and the "update available" nag never cleared).
14
- export const VERSION = (() => {
15
- try {
16
- const pkgPath = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'package.json');
17
- return JSON.parse(readFileSync(pkgPath, 'utf8')).version || '0.0.0';
18
- } catch {
19
- return '0.0.0';
20
- }
21
- })();
14
+ // The compile script substitutes this constant into the binary. npm installs
15
+ // still read their own package.json exactly as before (including on Node 20).
16
+ export const VERSION = typeof __FLOWVIANT_BUILD_VERSION__ === 'string'
17
+ ? __FLOWVIANT_BUILD_VERSION__
18
+ : (() => {
19
+ try {
20
+ const pkgPath = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'package.json');
21
+ return JSON.parse(readFileSync(pkgPath, 'utf8')).version || '0.0.0';
22
+ } catch {
23
+ return '0.0.0';
24
+ }
25
+ })();
22
26
 
23
27
  // The model EVERY daemon Claude turn runs on — pinned so autonomous work never
24
28
  // inherits your interactive `~/.claude/settings.json` default. That matters: a
@@ -327,7 +331,30 @@ export let FLEET_TOKEN =
327
331
  * A frozen copy read at import would salt a picked project's report with null
328
332
  * and report nothing at all, for the whole life of the process.
329
333
  */
330
- export let PROJECT_ID = CREDENTIAL.entry?.projectId ?? null;
334
+ export let PROJECT_ID =
335
+ CREDENTIAL.entry?.fleetToken && CREDENTIAL.entry.fleetToken === FLEET_TOKEN
336
+ ? (CREDENTIAL.entry.projectId ?? null)
337
+ : null;
338
+
339
+ /**
340
+ * THE STORE'S PROJECT IS THIS DAEMON'S ONLY WHEN IT IS THIS DAEMON'S TOKEN.
341
+ * `FLEET_TOKEN` prefers `--fleet` and the environment over the store, and the
342
+ * id above used to come from the store unconditionally — so a box holding one
343
+ * unbound project A, started with project B's token in the environment, salted
344
+ * B's env report with A's id (every variable then read `differs` beside B's
345
+ * other boxes, the confident wrong answer the salt exists to prevent) and
346
+ * printed "serves · A". It starts null in that case, and the ROSTER's own word
347
+ * settles it here on the first poll — the "names no project until the roster
348
+ * does" the paragraph above always promised and nothing ever did.
349
+ */
350
+ export function learnProjectId(id) {
351
+ if (typeof id === 'string' && id) PROJECT_ID = id;
352
+ }
353
+
354
+ /** Whether the stored resolution describes the token actually in use. */
355
+ export function storedCredentialInUse() {
356
+ return Boolean(CREDENTIAL.entry?.fleetToken) && CREDENTIAL.entry.fleetToken === FLEET_TOKEN;
357
+ }
331
358
 
332
359
  /** cli.mjs's picker chose. Must run BEFORE runFleetDaemon — nothing here
333
360
  * re-authenticates a connection already made. */
@@ -48,6 +48,8 @@ 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';
52
+ import { terminalCommand } from './launchCommand.mjs';
51
53
 
52
54
  const CRED_DIR = join(homedir(), '.flowviant');
53
55
  const CRED_FILE = join(CRED_DIR, 'credentials.json');
@@ -133,7 +135,28 @@ export function listStoredProjects() {
133
135
 
134
136
  /** What a project is CALLED on a terminal: its name, or an id you can grep. */
135
137
  export function projectLabel(e) {
136
- return e?.name ?? (e?.projectId ? `project ${e.projectId.slice(0, 8)}…` : 'an unnamed project');
138
+ return safeName(e?.name) ?? (e?.projectId ? `project ${e.projectId.slice(0, 8)}…` : 'an unnamed project');
139
+ }
140
+
141
+ /**
142
+ * A SERVER-SUPPLIED NAME, SAFE TO PRINT ON A TERMINAL — or null.
143
+ *
144
+ * A project's name is whatever an owner typed in the app, and the server's
145
+ * schema caps its length and nothing else. Printed raw it reaches every
146
+ * terminal that connects a machine to the project — `login`, the banner, the
147
+ * picker, `projects`, `machines` — and a name carrying an escape sequence can
148
+ * rewrite the clipboard (OSC 52 in kitty, WezTerm, Windows Terminal) or hide
149
+ * lines of the very listing whose job is to say which project is which. The
150
+ * scrub is `printable()` (C0, DEL, C1 — the server's own `parseBoxName` makes
151
+ * the same cut for hostnames — plus the bidi/format controls that reorder a
152
+ * line, added 2026-09-24 rather than re-derived: see printable.mjs), and what
153
+ * is left is trimmed; nothing left is null, so the caller's id fallback
154
+ * speaks instead of an empty label.
155
+ */
156
+ export function safeName(name) {
157
+ if (typeof name !== 'string') return null;
158
+ const clean = printable(name).trim();
159
+ return clean || null;
137
160
  }
138
161
 
139
162
  /**
@@ -259,7 +282,7 @@ export function directoryTakenRefusal(bound, repoRoot, { incoming } = {}) {
259
282
  `${head}\n` +
260
283
  'A directory serves one project — two projects must not edit it at the same time.\n' +
261
284
  'Delete the project you do not mean in Flowviant first (project settings → General → Delete project),\n' +
262
- 'or disconnect this box from it with `flowviant machines`, then run this again.'
285
+ `or disconnect this box from it with \`${terminalCommand('machines')}\`, then run this again.`
263
286
  );
264
287
  }
265
288
 
@@ -316,7 +339,7 @@ export function saveLogin({ fleetToken, projectId, mcpUrl, name, repoRoot }) {
316
339
  ...(f.projects[projectId] ?? {}),
317
340
  fleetToken,
318
341
  mcpUrl: mcpUrl ?? null,
319
- ...(name ? { name } : {}),
342
+ ...(safeName(name) ? { name: safeName(name) } : {}),
320
343
  ...(repoRoot ? { repoRoot } : {}),
321
344
  savedAt: new Date().toISOString(),
322
345
  };
@@ -391,8 +414,9 @@ export function bindStoredRepo(projectId, repoRoot) {
391
414
 
392
415
  /** The roster names the project on every poll; remember it so the picker and
393
416
  * `flowviant projects` can say a NAME instead of an id. */
394
- export function setStoredProjectName(projectId, name) {
395
- if (!projectId || typeof name !== 'string' || !name) return;
417
+ export function setStoredProjectName(projectId, raw) {
418
+ const name = safeName(raw);
419
+ if (!projectId || !name) return;
396
420
  const entries = listStoredProjects();
397
421
  const e = entries.find((x) => x.projectId === projectId);
398
422
  if (!e || e.name === name) return;