flowviant 0.55.0 → 0.55.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/cli.mjs CHANGED
@@ -233,7 +233,12 @@ if (process.argv[2] === 'env') {
233
233
  // that name every stored project and every way out. The one thing this block
234
234
  // must never do is serve a project the resolution did not name — "it said
235
235
  // skadooble in my calendar repo" is the confusion this exists to end.
236
- const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
236
+ // A RESTART IS NOT A PERSON. `reexec` (update.mjs) inherits stdio, so an
237
+ // auto-updated daemon's child sees two TTYs; without this it would stop on the
238
+ // binding confirm below and the machine would stay dark until somebody typed a
239
+ // key. Same reasoning as the headless case, and the same answer.
240
+ const interactive =
241
+ Boolean(process.stdin.isTTY && process.stdout.isTTY) && process.env.FLOWVIANT_REEXEC !== '1';
237
242
  const externalToken = process.argv.includes('--fleet') || Boolean(process.env.FLOWVIANT_FLEET);
238
243
 
239
244
  /** Re-exec a plain `flowviant` after an inline login — the login command's own
package/bin/lib/env.mjs CHANGED
@@ -34,6 +34,8 @@ import {
34
34
  mkdirSync,
35
35
  existsSync,
36
36
  appendFileSync,
37
+ chmodSync,
38
+ lstatSync,
37
39
  rmSync,
38
40
  } from 'node:fs';
39
41
  import { execFileSync } from 'node:child_process';
@@ -123,11 +125,52 @@ export function myPubB64() {
123
125
  return keypair ? sodium.to_base64(keypair.publicKey, B64()) : null;
124
126
  }
125
127
 
126
- /** Query params the roster poll carries: identity + materialized version. */
128
+ /** Query params the roster poll carries: identity, materialized version, and
129
+ * the target files we REFUSED to write.
130
+ *
131
+ * `envv` alone was a half-truth and the surface built on it said the wrong
132
+ * thing out loud: it is set the moment the bundle DECRYPTS, independent of
133
+ * whether a single byte reached a worktree, so a project whose `.env` is
134
+ * tracked in git got the green "on the current env" chip while every session
135
+ * ran on whatever stale placeholder git had checked out. The daemon knew —
136
+ * it warned, to a console nobody reads. `envskip` is that warning routed
137
+ * somewhere a human is actually looking.
138
+ *
139
+ * A daemon→server REPORT, so it needs no version floor: an older daemon
140
+ * simply sends no `envskip` key, which reads as "nothing to report" — and
141
+ * that is honest, because an older daemon genuinely is not measuring it.
142
+ * Bounded hard: a query string is not a log. */
127
143
  export async function envQueryParams() {
128
144
  await ensureKeypair();
129
145
  const params = { envpub: myPubB64() };
130
146
  if (bundleVersion >= 0) params.envv = String(bundleVersion);
147
+ // THE EMPTY STRING IS A REPORT, and it is the only thing that can ever CLEAR
148
+ // the surface's warning. Gating this on truthiness (which is what it did
149
+ // first) meant a person who followed the on-screen remedy exactly — gitignore
150
+ // the file, restart — sent no `envskip` at all, the server left the column
151
+ // alone by design, and the amber line stayed up forever telling them to fix
152
+ // something already fixed. Absence must keep meaning IGNORANCE, so the gate
153
+ // is "has a pass actually run", never "is there something to say".
154
+ // (fleet.mjs's query loop had to stop filtering on truthiness too — one
155
+ // check here is useless while a second one downstream drops the same value.)
156
+ if (everMaterialized) {
157
+ const files = [...new Set([...skippedByWorktree.values()].flat())].sort();
158
+ // Each path is percent-encoded BEFORE the join, because `isSafeEnvTargetFile`
159
+ // permits a comma in a filename and the server splits on one — unencoded,
160
+ // `a,b.env` would arrive as two files that do not exist. Truncation is by
161
+ // WHOLE ELEMENTS against a byte budget; a mid-path cut names a file nobody
162
+ // has, which is worse than naming fewer.
163
+ const parts = [];
164
+ let budget = 400;
165
+ for (const f of files) {
166
+ if (parts.length >= 10) break;
167
+ const enc = encodeURIComponent(f);
168
+ if (enc.length + 1 > budget) break;
169
+ parts.push(enc);
170
+ budget -= enc.length + 1;
171
+ }
172
+ params.envskip = parts.join(',');
173
+ }
131
174
  return params;
132
175
  }
133
176
 
@@ -310,6 +353,37 @@ function isIgnoredInGit(wt, relPath) {
310
353
 
311
354
  // Per-worktree: the target files we last materialized THIS SESSION.
312
355
  const lastFilesByWorktree = new Map();
356
+
357
+ /** Target files refused for a GIT reason, PER WORKTREE — reported to the
358
+ * server on the next poll. Per-worktree because the refusal is: both
359
+ * predicates (`isTrackedInGit`, `isIgnoredInGit`) run with `cwd: wt`, so
360
+ * ".env is refused" is a fact about ONE tree. A process-global set mixed two
361
+ * trees' answers together and, worse, could only ever grow.
362
+ *
363
+ * Names only — a path is not a secret, and the whole point is that a human
364
+ * can act on it ("gitignore apps/api/.dev.vars"). Only the two GIT causes go
365
+ * in here: they have a remedy the reader can carry out, and the surface names
366
+ * that remedy. A transient write failure is a warn, not a standing claim. */
367
+ const skippedByWorktree = new Map();
368
+
369
+ /** Worktrees whose most recent pass wrote everything it was asked to.
370
+ * `hasMaterialized` is built on THIS rather than on "a pass ran", so a pass
371
+ * that refused something RETRIES on the next turn — which is what lets
372
+ * `echo .env >> .gitignore` actually take effect without waiting for an
373
+ * unrelated bundle change. A pass with nothing to write counts as clean. */
374
+ const cleanWorktrees = new Set();
375
+
376
+ /** True once any materialization pass has completed. Distinguishes "we refused
377
+ * nothing" from "we have not looked", which is the whole contract of the
378
+ * `envskip` report — see envQueryParams. */
379
+ let everMaterialized = false;
380
+
381
+ /** Has this process completed a CLEAN materialization pass for this worktree?
382
+ * The creation-only rule (work.mjs) needs a second condition or a directory
383
+ * that existed before the bundle did is never revisited. */
384
+ export function hasMaterialized(wt) {
385
+ return cleanWorktrees.has(wt);
386
+ }
313
387
  // Project-global union of every target file we've ever materialized — PERSISTED
314
388
  // in the cache and seeded on load, so a file whose key was deleted while the
315
389
  // daemon was down still gets its stale plaintext copy cleaned on the next
@@ -366,6 +440,15 @@ export function appSecretsFor(env) {
366
440
  * app secrets go to the provider at deploy, deploy creds are injected only. */
367
441
  export function materializeInto(wt) {
368
442
  if (!wt || !existsSync(wt)) return;
443
+ // NEVER SYNCED IS NOT "NO SECRETS", and conflating them cost a whole session.
444
+ // `values` is empty both before the first bundle lands and for a project that
445
+ // genuinely has none; `bundleVersion < 0` is the one that means IGNORANCE.
446
+ // Writing nothing here and RECORDING it as materialized let a worktree
447
+ // created on the first poll after a restart — before handleRosterEnv had
448
+ // warmed the cache — sit secret-less for its entire life, because nothing
449
+ // re-materializes a directory that is neither fresh nor covered by a bundle
450
+ // CHANGE. Returning without recording is what makes the next turn retry.
451
+ if (bundleVersion < 0) return;
369
452
  const byFile = new Map();
370
453
  for (const v of values) {
371
454
  if (v.scope !== 'app' || v.env !== 'dev') continue; // only local dev secrets hit a worktree file
@@ -382,9 +465,16 @@ export function materializeInto(wt) {
382
465
  excludeInWorktree(wt, [...byFile.keys()]);
383
466
 
384
467
  const written = [];
468
+ /** Refused for a GIT reason this pass — reported, and remediable. */
469
+ const refusedForGit = [];
470
+ /** Anything that did not get written, git reasons and write failures alike.
471
+ * Blocks the clean mark so the next turn tries again. */
472
+ let anyProblem = false;
385
473
  for (const [file, list] of byFile) {
386
474
  if (isTrackedInGit(wt, file)) {
387
475
  warn(`env: "${file}" is tracked in git — refusing to write secrets there (gitignore it). Its keys are NOT materialized.`);
476
+ refusedForGit.push(file);
477
+ anyProblem = true;
388
478
  continue;
389
479
  }
390
480
  // The load-bearing check. A materialized secret sits in a worktree whose
@@ -392,10 +482,27 @@ export function materializeInto(wt) {
392
482
  // "git cannot see this file" is a precondition for writing it, not a nicety.
393
483
  if (!isIgnoredInGit(wt, file)) {
394
484
  warn(`env: "${file}" is not gitignored — refusing to write secrets there. Add it to .gitignore. Its keys are NOT materialized.`);
485
+ refusedForGit.push(file);
486
+ anyProblem = true;
395
487
  continue;
396
488
  }
397
489
  try {
398
490
  const abs = join(wt, file);
491
+ // A SYMLINK AT THE TARGET IS NOT A TARGET. `writeFileSync` follows one,
492
+ // so a link committed into the repo (or dropped by an agent) at the
493
+ // materialization path would write the project's decrypted secrets
494
+ // wherever it points — outside the worktree, and outside everything the
495
+ // check-ignore gate can reason about. `lstat`, not `stat`, and refuse.
496
+ // Cheap, and the whole exposure is one call away otherwise.
497
+ try {
498
+ if (lstatSync(abs).isSymbolicLink()) {
499
+ warn(`env: "${file}" is a symlink — refusing to write secrets through it.`);
500
+ anyProblem = true;
501
+ continue;
502
+ }
503
+ } catch {
504
+ /* does not exist yet — the ordinary case */
505
+ }
399
506
  mkdirSync(dirname(abs), { recursive: true });
400
507
  const body = renderEnvFile(list);
401
508
  // Skip an identical rewrite — otherwise every bundle bump touches the
@@ -407,9 +514,22 @@ export function materializeInto(wt) {
407
514
  /* new file */
408
515
  }
409
516
  if (prior !== body) writeFileSync(abs, body, { mode: 0o600 });
517
+ // `mode` on writeFileSync applies at CREATION only — an overwrite of a
518
+ // file that already existed keeps whatever mode it had, so a 0644 stub
519
+ // committed by a teammate (or left by an older daemon) would hold
520
+ // plaintext secrets world-readable on a shared box. chmod every time.
521
+ try {
522
+ chmodSync(abs, 0o600);
523
+ } catch {
524
+ /* best-effort: a filesystem without modes is not a reason to refuse */
525
+ }
410
526
  written.push(file);
411
527
  } catch (e) {
528
+ // NOT reported as a refusal: the surface's line names a git cause and a
529
+ // git remedy, and a full disk is neither. It still blocks the clean mark,
530
+ // so the next turn retries.
412
531
  warn(`env: could not write ${file} into worktree: ${e.message}`);
532
+ anyProblem = true;
413
533
  }
414
534
  }
415
535
 
@@ -423,6 +543,18 @@ export function materializeInto(wt) {
423
543
  }
424
544
  for (const f of written) knownTargetFiles.add(f);
425
545
  lastFilesByWorktree.set(wt, written);
546
+
547
+ // THE PASS'S VERDICT, recorded whole and REPLACING the previous one — this is
548
+ // what lets a refusal clear. `refusedForGit` is recomputed from scratch every
549
+ // pass, so a file that gets gitignored simply is not in the next one, and the
550
+ // union reported on the poll shrinks. `anyProblem` (which also covers a write
551
+ // failure) is what decides whether this worktree gets retried on the next
552
+ // turn; a clean pass is remembered so we stop touching a live directory.
553
+ if (refusedForGit.length > 0) skippedByWorktree.set(wt, refusedForGit);
554
+ else skippedByWorktree.delete(wt);
555
+ if (anyProblem) cleanWorktrees.delete(wt);
556
+ else cleanWorktrees.add(wt);
557
+ everMaterialized = true;
426
558
  }
427
559
 
428
560
  /**
package/bin/lib/fleet.mjs CHANGED
@@ -69,6 +69,7 @@ import { ensureVault, syncVault } from './vault.mjs';
69
69
  import {
70
70
  envQueryParams,
71
71
  handleRosterEnv,
72
+ loadCachedEnv,
72
73
  materializeInto,
73
74
  myPubB64,
74
75
  scrub as envScrub,
@@ -150,9 +151,17 @@ async function fetchRoster(haveIds, livePreviewSessionIds = [], heldSessionIds =
150
151
  /* best-effort — the poll must never fail on a readout */
151
152
  }
152
153
  // Env-sync identity + materialized version (the Settings "env vN" chip).
154
+ //
155
+ // `!= null`, NOT truthiness. `envskip` uses the EMPTY STRING as a real
156
+ // report — "measured, refused nothing" — and it is the only value that can
157
+ // clear the surface's warning. A `if (v)` here silently dropped it, so
158
+ // fixing the filter in envQueryParams alone would have changed nothing.
159
+ // This is the same trap the skills relay eight lines up documents and
160
+ // sidesteps by calling `url.searchParams.set` directly; the general fix is
161
+ // better than a second special case.
153
162
  try {
154
163
  for (const [k, v] of Object.entries(await envQueryParams())) {
155
- if (v) url.searchParams.set(k, v);
164
+ if (v != null) url.searchParams.set(k, v);
156
165
  }
157
166
  } catch {
158
167
  /* env identity is best-effort — the poll must never fail on it */
@@ -395,6 +404,39 @@ export async function runFleetDaemon() {
395
404
  warn('could not take the single-instance lock (unwritable ~/.flowviant) — running unguarded');
396
405
 
397
406
  await preflight({ needGit: true });
407
+
408
+ // WARM THE ENV CACHE BEFORE THE FIRST POLL, not on the first roster tick.
409
+ // `handleRosterEnv` loads it, and `handleRosterEnv` runs AFTER
410
+ // `processWorkTurns` in the reconcile below — so on the first poll after a
411
+ // restart a brand-new session worktree was materialized against an EMPTY
412
+ // bundle, and then never revisited (creation-only, and `needSync` is false
413
+ // when the cache holds the version the server is already on). The turn ran
414
+ // with no secrets and nothing said so.
415
+ //
416
+ // This is only possible since 0.55.0: the credential store knows which
417
+ // project this checkout is, so the cache — which is keyed by projectId — can
418
+ // be found before the server has named anything. A `--fleet`/env token names
419
+ // no project until the roster does, so it keeps the old lazy path.
420
+ // Best-effort throughout: a cache miss is the ordinary first-run state.
421
+ //
422
+ // GATED ON THE STORE ACTUALLY BEING THE SOURCE. `--fleet` / `FLOWVIANT_FLEET`
423
+ // OVERRIDE the stored credential (config.mjs), but `CREDENTIAL` is resolved
424
+ // from the store regardless — so reading its projectId here would decrypt and
425
+ // materialize project A's cached secrets while this daemon is serving project
426
+ // B's token. That is the wrong project's plaintext in a worktree, which is
427
+ // the exact failure the repo binding exists to prevent, arriving by a
428
+ // different door. An external token names no project until the roster does,
429
+ // so it keeps the lazy path and loses nothing but one poll.
430
+ const externalToken =
431
+ process.argv.includes('--fleet') || Boolean(process.env.FLOWVIANT_FLEET);
432
+ if (!externalToken && CREDENTIAL?.entry?.projectId) {
433
+ try {
434
+ await loadCachedEnv(CREDENTIAL.entry.projectId);
435
+ } catch {
436
+ /* no cache, no keypair yet, unreadable home — the roster tick retries */
437
+ }
438
+ }
439
+
398
440
  // Kill any preview dev-server/tunnel groups a previously-crashed daemon left
399
441
  // running (detached children survive an ungraceful exit) before we start fresh.
400
442
  reapOrphanPreviews((m) => info(m));
@@ -79,7 +79,16 @@ export function humanizeClaudeTool(name, input = {}, cwd = '') {
79
79
  case 'LS':
80
80
  return { kind: 'list', label: `ls ${shortPath(input.path ?? '.', cwd)}` };
81
81
  case 'Bash':
82
- return { kind: 'bash', label: `$ ${oneLine(input.command, 60)}` };
82
+ // `command` rides beside the display label, VERBATIM (capped): the label
83
+ // is a 60-char readout for a console and the rail, and truncation is
84
+ // fine there — but the admin's command audit exists to answer "what
85
+ // actually ran on our box", and an ellipsis is exactly where the part
86
+ // that matters would hide.
87
+ return {
88
+ kind: 'bash',
89
+ command: String(input.command ?? '').slice(0, 2000),
90
+ label: `$ ${oneLine(input.command, 60)}`,
91
+ };
83
92
  default:
84
93
  return null; // other tools: silent
85
94
  }
@@ -103,7 +112,11 @@ function humanizeCodexItem(item = {}, cwd = '') {
103
112
  case 'reasoning':
104
113
  return { kind: 'think', label: oneLine(item.text) || 'thinking…' };
105
114
  case 'command_execution':
106
- return { kind: 'bash', label: `$ ${oneLine(item.command, 60)}` };
115
+ return {
116
+ kind: 'bash',
117
+ command: String(item.command ?? '').slice(0, 2000),
118
+ label: `$ ${oneLine(item.command, 60)}`,
119
+ };
107
120
  case 'file_change': {
108
121
  // `changes` is a list of touched paths; the daemon counts distinct files,
109
122
  // so emit one activity per path rather than one for the batch.
@@ -207,7 +220,11 @@ function humanizeAgyTool(name, p = {}, cwd = '') {
207
220
  case 'list_dir':
208
221
  return { kind: 'list', label: `ls ${shortPath(path, cwd)}` };
209
222
  case 'run_command':
210
- return { kind: 'bash', label: `$ ${oneLine(p.CommandLine, 60)}` };
223
+ return {
224
+ kind: 'bash',
225
+ command: String(p.CommandLine ?? '').slice(0, 2000),
226
+ label: `$ ${oneLine(p.CommandLine, 60)}`,
227
+ };
211
228
  case 'call_mcp_tool':
212
229
  return { kind: 'tool', label: `mcp.${p.ToolName ?? ''}` };
213
230
  default:
@@ -59,7 +59,17 @@ function reexec(teardown) {
59
59
  }
60
60
  const child = spawn(process.execPath, process.argv.slice(1), {
61
61
  stdio: 'inherit',
62
- env: process.env,
62
+ // MARK THE CHILD AS A RESTART, not as a person typing `flowviant`.
63
+ // stdio is inherited, so the child sees two TTYs and believes a human is
64
+ // watching — and 0.55.0 asks a one-time binding question on exactly that
65
+ // signal. An auto-update that lands while nobody is looking would then sit
66
+ // on `Serve this repo as X? [Y/n]` with the machine dark until someone
67
+ // walks past. The rule credentials.mjs already states for systemd applies
68
+ // verbatim here: a RESTART must not hang on a prompt. Skipping the confirm
69
+ // is not a widening — the daemon serves exactly the credential it was
70
+ // already serving one second ago, and the question gets asked the next
71
+ // time a human starts it by hand.
72
+ env: { ...process.env, FLOWVIANT_REEXEC: '1' },
63
73
  });
64
74
  child.on('exit', (code) => process.exit(code ?? 0));
65
75
  }
package/bin/lib/work.mjs CHANGED
@@ -48,7 +48,7 @@ import {
48
48
  SYSTEM_WORK_PLAIN,
49
49
  WORK_TURN_KICKOFF_PLAIN,
50
50
  } from './prompts.mjs';
51
- import { materializeInto, excludeInWorktree, scrub as envScrub } from './env.mjs';
51
+ import { materializeInto, hasMaterialized, excludeInWorktree, scrub as envScrub } from './env.mjs';
52
52
  import { detectRuntimes, canRun, recordSkills, RUNTIMES } from './runtimes.mjs';
53
53
  import { isTerminalSessionLive, isAgyConversationLive } from './localSessions.mjs';
54
54
  import { worktreeDiff } from './worktreeDiff.mjs';
@@ -102,6 +102,7 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
102
102
  const DIFF_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/diff-done');
103
103
  const PREVIEW_CLAIM_URL = FLEET_URL.replace(/\/agents\/?$/, '/preview-claim');
104
104
  const PREVIEW_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/preview-done');
105
+ const SESSION_COMMANDS_URL = FLEET_URL.replace(/\/agents\/?$/, '/session-commands');
105
106
  const ATTACHMENT_URL = FLEET_URL.replace(/\/agents\/?$/, '/attachment');
106
107
  const workAnswering = new Set(); // turn ids currently queued/running here
107
108
  const workAttempts = new Map(); // turn id -> completed runTurn attempts
@@ -899,6 +900,25 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
899
900
  } catch {
900
901
  /* best-effort */
901
902
  }
903
+ } else if (!hasMaterialized(wt)) {
904
+ // CREATION-ONLY NEEDED A SECOND CONDITION. The rule above is right about
905
+ // a LIVE directory — its env belongs to the session and re-writing it
906
+ // mid-flight is not ours to do — but "created" and "ever given a bundle"
907
+ // are different events, and the gap between them is a whole daemon
908
+ // restart: `handleRosterEnv` (which warms the encrypted cache) runs
909
+ // AFTER `processWorkTurns` on the same poll, so a worktree made on the
910
+ // first turn after a restart was materialized against an EMPTY bundle
911
+ // and, being neither fresh nor covered by a bundle CHANGE, never
912
+ // revisited. `materializeInto` now declines to record a pass it made in
913
+ // ignorance (bundleVersion < 0), so this branch is what retries it —
914
+ // once, on the next turn, and never again after it succeeds. Idempotent
915
+ // by construction: identical bodies are not rewritten, so nothing
916
+ // hot-restarts a dev server the driver is watching.
917
+ try {
918
+ materializeInto(wt);
919
+ } catch {
920
+ /* best-effort */
921
+ }
902
922
  }
903
923
  return { wt, fresh };
904
924
  };
@@ -1584,6 +1604,45 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
1584
1604
  let seenThreadId = null; // codex's conversation id, off thread.started
1585
1605
  const spawned = []; // this turn's children, for the teardown registry
1586
1606
  const narrator = makeNarrator(job.sessionId, job.id);
1607
+
1608
+ // THE COMMAND AUDIT — every `$ …` the CLI's stream reports, batched
1609
+ // to the server verbatim so an admin can read what actually ran on
1610
+ // this box. Same events the narrator renders and forgets; this is
1611
+ // the durable copy, and it carries ONLY commands — no prose, no
1612
+ // thinking, no file reads (the session stays private; what executed
1613
+ // on the shared machine is the machine's own fact to relay).
1614
+ // Flushed mid-turn every 25 so a long turn is not one giant loss on
1615
+ // a kill, and again at settle. Best-effort: a failed post drops the
1616
+ // batch rather than blocking the turn — the surface says it is the
1617
+ // machine's report, not a syscall trace.
1618
+ const auditBatch = [];
1619
+ const flushAudit = () => {
1620
+ if (auditBatch.length === 0) return;
1621
+ const commands = auditBatch.splice(0, auditBatch.length);
1622
+ void fetch(SESSION_COMMANDS_URL, {
1623
+ method: 'POST',
1624
+ headers: {
1625
+ Authorization: `Bearer ${FLEET_TOKEN}`,
1626
+ 'User-Agent': USER_AGENT,
1627
+ 'Content-Type': 'application/json',
1628
+ },
1629
+ signal: AbortSignal.timeout(30_000),
1630
+ body: JSON.stringify({
1631
+ sessionId: job.sessionId,
1632
+ turnId: job.id,
1633
+ runtime: rt.id,
1634
+ cwd: dir.wt,
1635
+ commands,
1636
+ }),
1637
+ }).catch(() => {
1638
+ /* best-effort — the audit records what reached it */
1639
+ });
1640
+ };
1641
+ const auditCommand = (a) => {
1642
+ if (a?.kind !== 'bash' || !a.command) return;
1643
+ auditBatch.push({ command: a.command, at: new Date().toISOString() });
1644
+ if (auditBatch.length >= 25) flushAudit();
1645
+ };
1587
1646
  try {
1588
1647
  // Files first, then the message that references them: the agent
1589
1648
  // must be able to open what it is being told about. Only the ones
@@ -1627,7 +1686,10 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
1627
1686
  // posted once. Every line goes to the narrator above, throttled.
1628
1687
  streamJson: true,
1629
1688
  answerFromResult: true,
1630
- onActivity: (a) => narrator.line(a?.label),
1689
+ onActivity: (a) => {
1690
+ narrator.line(a?.label);
1691
+ auditCommand(a);
1692
+ },
1631
1693
  // What this CLI says it can be asked for by name. Harvested off
1632
1694
  // the init event the stream already carries — no probe, no scan,
1633
1695
  // no extra spawn — and reported on the next roster poll so the
@@ -1683,6 +1745,7 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
1683
1745
  // is cleared server-side at settle — clearing it here would race
1684
1746
  // the settle and blank the tab a beat before the reply lands.
1685
1747
  narrator.stop();
1748
+ flushAudit();
1686
1749
  for (const ch of spawned) workChildren.delete(ch);
1687
1750
  if (lockPath) {
1688
1751
  try {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.55.0",
4
- "description": "Run your own coding CLIs as build agents for Flowviant Claude Code, Codex or Antigravity, on your own credentials. Holds your sessions, keeps a worktree per tab, and ships branches on your word.",
3
+ "version": "0.55.1",
4
+ "description": "Run your own coding CLIs as build agents for Flowviant \u2014 Claude Code, Codex or Antigravity, on your own credentials. Holds your sessions, keeps a worktree per tab, and ships branches on your word.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "flowviant": "bin/cli.mjs"