flowviant 0.73.0 → 0.74.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.
@@ -197,7 +197,7 @@ const oneLine = (s, n = 160) => String(s).replace(/\s+/g, ' ').trim().slice(0, n
197
197
  // every intermediate text block still NARRATES, but only the final `result`
198
198
  // event contributes text — otherwise the same sentences arrive twice, once as
199
199
  // they stream and once in the result, and the tab posts the duplicate.
200
- function handleStreamLine(line, { cwd, emit, onActivity, appendText, answerFromResult, onInit }) {
200
+ function handleStreamLine(line, { cwd, emit, onActivity, onToolEvent, appendText, answerFromResult, onInit }) {
201
201
  let ev;
202
202
  try {
203
203
  ev = JSON.parse(line);
@@ -222,6 +222,10 @@ function handleStreamLine(line, { cwd, emit, onActivity, appendText, answerFromR
222
222
  push({ kind: 'say', label: oneLine(b.text) });
223
223
  } else if (b.type === 'tool_use') {
224
224
  push(humanizeToolUse(b.name, b.input || {}, cwd));
225
+ // The STRUCTURED form of the same event, for the transcript's tool
226
+ // cards — raw name + input, so the collector can keep what the
227
+ // one-line humanizer drops (an Edit's counts, the plan's items).
228
+ onToolEvent?.(b.name, b.input || {});
225
229
  }
226
230
  }
227
231
  } else if (ev.type === 'system' && ev.subtype === 'init') {
@@ -270,7 +274,7 @@ function handleStreamLine(line, { cwd, emit, onActivity, appendText, answerFromR
270
274
  // returned string for sentinel detection, and each activity is handed to
271
275
  // `onActivity` so the caller can forward progress. Build-agent turns leave it
272
276
  // off and keep the raw text passthrough + line sentinels.
273
- export function runTurn({ prompt, resume, system, cwd, mcpConfig, mcpArgs, mcpEnv, runtime = 'claude', label, onSpawn, streamJson, answerFromResult, onActivity, onInit, onThreadId, wikiPerm, readOnly, planPerm, vaultDir, resultSchemaArgs, model, effort, adoptResumeId, resumeThreadId, resumeConversationId }) {
277
+ export function runTurn({ prompt, resume, system, cwd, mcpConfig, mcpArgs, mcpEnv, runtime = 'claude', label, onSpawn, streamJson, answerFromResult, onActivity, onToolEvent, onInit, onThreadId, wikiPerm, readOnly, planPerm, vaultDir, resultSchemaArgs, model, effort, adoptResumeId, resumeThreadId, resumeConversationId }) {
274
278
  return new Promise((resolve) => {
275
279
  const rt = runtimeById(runtime);
276
280
  if (!rt.args) {
@@ -401,7 +405,7 @@ export function runTurn({ prompt, resume, system, cwd, mcpConfig, mcpArgs, mcpEn
401
405
  /** One line of the child's stdout, in whichever dialect it speaks. */
402
406
  const onLine = (line) => {
403
407
  if (!rt.parse)
404
- return handleStreamLine(line, { cwd, emit, onActivity, appendText, answerFromResult, onInit });
408
+ return handleStreamLine(line, { cwd, emit, onActivity, onToolEvent, appendText, answerFromResult, onInit });
405
409
  const ev = rt.parse(line, cwd);
406
410
  if (!ev) return;
407
411
  // The conversation id, when the runtime announces one (codex's
@@ -89,11 +89,118 @@ export function humanizeClaudeTool(name, input = {}, cwd = '') {
89
89
  command: String(input.command ?? '').slice(0, 2000),
90
90
  label: `$ ${oneLine(input.command, 60)}`,
91
91
  };
92
+ case 'TodoWrite': {
93
+ // The CLI's own todo list — the plan the transcript's PLAN card renders.
94
+ // The label narrates the change ("plan: 2 of 5 — cut over /api/session");
95
+ // the structured items ride through toolEventOf below.
96
+ const todos = Array.isArray(input.todos) ? input.todos : [];
97
+ if (todos.length === 0) return null;
98
+ const done = todos.filter((t) => t?.status === 'completed').length;
99
+ const active = todos.find((t) => t?.status === 'in_progress');
100
+ return {
101
+ kind: 'plan',
102
+ label: `plan: ${done} of ${todos.length}${active?.content ? ` — ${oneLine(active.content, 60)}` : ''}`,
103
+ };
104
+ }
92
105
  default:
93
106
  return null; // other tools: silent
94
107
  }
95
108
  }
96
109
 
110
+ /** Count '\n' without materializing a split — a Write's content can be a
111
+ * multi-MB generated file, and `split('\n')` re-allocates all of it as line
112
+ * strings on the synchronous stream path the narrator and wake socket share.
113
+ * Numbers cannot leak, so counting runs on the RAW string. */
114
+ export function countLines(s) {
115
+ const str = String(s ?? '');
116
+ if (!str) return 0;
117
+ let n = 1;
118
+ let i = -1;
119
+ while ((i = str.indexOf('\n', i + 1)) !== -1) n++;
120
+ return n;
121
+ }
122
+
123
+ /** How much raw string the scrubber sees before any cap is applied. A secret
124
+ * can only surface in the first ~capped chars of a field; giving the scrub a
125
+ * window this much larger means a secret would have to be longer than the
126
+ * window minus the cap to straddle out of it — no real credential is. */
127
+ const SCRUB_WINDOW = 8_192;
128
+
129
+ /**
130
+ * Tool-call → ONE STRUCTURED EVENT for the transcript's tool cards — the
131
+ * relay's durable form, where `humanizeClaudeTool` above is its one-line live
132
+ * form. Same source (the CLI's own tool_use input), zero inference: every
133
+ * field is something the CLI emitted, and a tool this doesn't know renders as
134
+ * nothing rather than as a guess.
135
+ *
136
+ * Wire vocabulary (compact keys — this rides a 1.5s-throttled POST):
137
+ * t: read|edit|write|grep|glob|bash|task|plan
138
+ * p: path (worktree-relative) q: pattern/description c: command
139
+ * a/d: line counts added/deleted (from the input's own strings)
140
+ * dl: a few "-/+" prefixed preview lines of an Edit
141
+ * items: the plan's todos, x = text, s = done|active|open
142
+ *
143
+ * SCRUB BEFORE CAP — the order is load-bearing (review, 2026-09-01). The
144
+ * caller passes its `envScrub`; every string is scrubbed over a bounded
145
+ * window FIRST and capped LAST, because the reverse order had two failures:
146
+ * a secret straddling the cap boundary was cut into a prefix the
147
+ * exact-substring scrub could no longer match (a partial credential on the
148
+ * wire), and a scrub REPLACEMENT that grew a string past the cap tripped the
149
+ * server's field limits. `scrub` defaults to identity so this stays testable
150
+ * without a vault.
151
+ */
152
+ export function toolEventOf(name, input = {}, cwd = '', scrub = (s) => s) {
153
+ // Window → scrub → cap. The window bounds what a multi-MB input costs; the
154
+ // cap is applied AFTER the scrub so a replacement cannot overflow it.
155
+ const clean = (v, cap) => scrub(String(v ?? '').slice(0, SCRUB_WINDOW)).slice(0, cap);
156
+ const rel = (p) => clean(shortPath(p, cwd), 300);
157
+ // The first k lines of a side, from a scrubbed bounded prefix — never a
158
+ // full split of the raw string.
159
+ const firstLines = (s, k) =>
160
+ scrub(String(s ?? '').slice(0, SCRUB_WINDOW)).split('\n', k).slice(0, k);
161
+ switch (name) {
162
+ case 'Read':
163
+ return { t: 'read', p: rel(input.file_path) };
164
+ case 'Write':
165
+ return { t: 'write', p: rel(input.file_path), a: countLines(input.content) };
166
+ case 'Edit': {
167
+ const oldS = String(input.old_string ?? '');
168
+ const newS = String(input.new_string ?? '');
169
+ // A MINI-DIFF, not the diff: the first lines of each side, enough to
170
+ // recognise the change at a glance. The real diff lives in git.
171
+ const dl = [
172
+ ...firstLines(oldS, 2).map((l) => `- ${l}`),
173
+ ...firstLines(newS, 3).map((l) => `+ ${l}`),
174
+ ].map((l) => l.slice(0, 160));
175
+ return { t: 'edit', p: rel(input.file_path), a: countLines(newS), d: countLines(oldS), dl };
176
+ }
177
+ case 'Grep':
178
+ return {
179
+ t: 'grep',
180
+ q: clean(input.pattern, 200),
181
+ ...(input.path ? { p: rel(input.path) } : {}),
182
+ };
183
+ case 'Glob':
184
+ return { t: 'glob', q: clean(input.pattern, 200) };
185
+ case 'Bash':
186
+ return { t: 'bash', c: clean(input.command, 200) };
187
+ case 'Task':
188
+ return { t: 'task', q: clean(input.description, 200) };
189
+ case 'TodoWrite': {
190
+ const todos = Array.isArray(input.todos) ? input.todos.slice(0, 20) : [];
191
+ const items = todos
192
+ .map((td) => ({
193
+ x: clean(td?.content, 120),
194
+ s: td?.status === 'completed' ? 'done' : td?.status === 'in_progress' ? 'active' : 'open',
195
+ }))
196
+ .filter((i) => i.x);
197
+ return items.length ? { t: 'plan', items } : null;
198
+ }
199
+ default:
200
+ return null;
201
+ }
202
+ }
203
+
97
204
  // ── Codex ──────────────────────────────────────────────────────────────────
98
205
 
99
206
  /**
package/bin/lib/work.mjs CHANGED
@@ -55,7 +55,7 @@ import {
55
55
  WORK_TURN_KICKOFF_PLAIN,
56
56
  } from './prompts.mjs';
57
57
  import { materializeInto, hasMaterialized, excludeInWorktree, scrub as envScrub } from './env.mjs';
58
- import { detectRuntimes, canRun, recordSkills, RUNTIMES } from './runtimes.mjs';
58
+ import { detectRuntimes, canRun, recordSkills, toolEventOf, RUNTIMES } from './runtimes.mjs';
59
59
 
60
60
  /** The place id meaning "the checkout", not a worktree. Must match the
61
61
  * server's REPO_PLACE — it is a wire value, not a local convention. */
@@ -336,6 +336,7 @@ export function createWorkManager({
336
336
  // an action that changes what the machine would measure must cause a new
337
337
  // measurement, and the 60s sweep is not that.
338
338
  void reportPlaceWorktrees(sessionId).catch(() => {});
339
+ burstListeners(sessionId);
339
340
  // …and the REPO picture changed too: the session branch is gone and base
340
341
  // moved. Without this the Repository block keeps counting a branch the
341
342
  // ship just deleted.
@@ -364,7 +365,7 @@ export function createWorkManager({
364
365
  * line over the finished reply — the server drops narration for a turn
365
366
  * that is no longer pending. (A session-level pending count can't tell the
366
367
  * settled turn's stale line from the queued NEXT turn's fresh one.) */
367
- const makeNarrator = (sessionId, turnId) => {
368
+ const makeNarrator = (sessionId, turnId, getTools) => {
368
369
  const recent = [];
369
370
  let lastSent = 0;
370
371
  let dirty = false;
@@ -386,7 +387,15 @@ export function createWorkManager({
386
387
  'Content-Type': 'application/json',
387
388
  },
388
389
  signal: AbortSignal.timeout(10_000),
389
- body: JSON.stringify({ sessionId, turnId, lines }),
390
+ body: JSON.stringify({
391
+ sessionId,
392
+ turnId,
393
+ lines,
394
+ // The structured tool log so far, riding the same throttled beat.
395
+ // Same lifecycle as the lines: overwritten as the turn moves,
396
+ // cleared server-side at settle. Absent until something ran.
397
+ ...(getTools ? { tools: getTools() } : {}),
398
+ }),
390
399
  });
391
400
  } catch {
392
401
  /* narration is decoration — a dropped line is not an incident */
@@ -649,6 +658,69 @@ export function createWorkManager({
649
658
  const reports = ids.map(sessionWorktreeReport).filter(Boolean);
650
659
  if (reports.length) await postWorktrees(reports);
651
660
  };
661
+
662
+ /**
663
+ * THE FIRST MINUTE AFTER A SETTLE — when "run the dev server" actually binds.
664
+ *
665
+ * The settle-time report fires the moment the reply lands, but a dev server
666
+ * the agent just started usually takes a few more seconds to open its socket
667
+ * (vite boots, next compiles). It therefore missed the settle measurement and
668
+ * waited the full 60s sweep — up to a minute of "nothing is running here"
669
+ * over a server that was already up, which is the slowest link in the whole
670
+ * "ask for dev → see the preview" chain. Asked directly: "how do we make it
671
+ * more responsive when the user prompts claude to run dev to waiting for it
672
+ * to appear on the preview?"
673
+ *
674
+ * A DECAYING BURST, and it re-CHECKS before it re-REPORTS: each beat walks
675
+ * /proc for the place's listeners (purely local, no git, no network) and only
676
+ * when the PORT SET actually changed does the full place report run and post.
677
+ * A settle where nothing ever binds costs five /proc walks and zero posts; a
678
+ * dev server that binds at +7s is on the wire at +9 instead of +60. The burst
679
+ * for a place restarts on its next settle, so overlapping turns cannot stack
680
+ * timers, and every timer is unref'd — a readout must never hold the process
681
+ * open.
682
+ *
683
+ * This also serves the OPPOSITE transition for free: a stopped dev server
684
+ * (the panel's Stop, a ctrl-C in a terminal) vanishes from the port set the
685
+ * same way it appeared, so the preview's "origin gone" story starts in
686
+ * seconds too.
687
+ */
688
+ const LISTEN_BURST_DELAYS_MS = [4_000, 9_000, 16_000, 30_000, 55_000];
689
+ const listenBursts = new Map(); // place -> timers[]
690
+ const listenSignature = (wt) => {
691
+ try {
692
+ const l = measureListeners(wt);
693
+ return l.rows.map((r) => r.port).sort((a, b) => a - b).join(',');
694
+ } catch {
695
+ return '';
696
+ }
697
+ };
698
+ const burstListeners = (sessionId) => {
699
+ try {
700
+ const place = placeOf(sessionId);
701
+ for (const t of listenBursts.get(place) ?? []) clearTimeout(t);
702
+ const wt = placeDir(sessionId);
703
+ // Captured alongside the settle report, so only a CHANGE after this
704
+ // moment triggers a post — the settle report already said the rest.
705
+ let last = listenSignature(wt);
706
+ const timers = LISTEN_BURST_DELAYS_MS.map((d) =>
707
+ setTimeout(() => {
708
+ try {
709
+ const sig = listenSignature(wt);
710
+ if (sig === last) return;
711
+ last = sig;
712
+ void reportPlaceWorktrees(sessionId).catch(() => {});
713
+ } catch {
714
+ /* a readout — the sweep still carries it */
715
+ }
716
+ }, d)
717
+ );
718
+ for (const t of timers) t.unref?.();
719
+ listenBursts.set(place, timers);
720
+ } catch {
721
+ /* never let the burst break a settle */
722
+ }
723
+ };
652
724
  /** Every live session, throttled — called from the reconcile loop. */
653
725
  /**
654
726
  * A SESSION NOBODY HAS MEASURED YET JUMPS THE SWEEP (2026-08-26).
@@ -2318,7 +2390,70 @@ export function createWorkManager({
2318
2390
  let seenThreadId = null; // codex's conversation id, off thread.started
2319
2391
  let seenClaudeSession = null; // claude's own conversation id, off system.init
2320
2392
  const spawned = []; // this turn's children, for the teardown registry
2321
- const narrator = makeNarrator(job.sessionId, job.id);
2393
+ /**
2394
+ * THE TURN'S TOOL LOG — the structured relay behind the transcript's
2395
+ * tool cards. Same source as the narrator (the CLI's own tool_use
2396
+ * events), zero inference; scrubbed AT COLLECTION so every copy that
2397
+ * leaves the machine — live beat and settle alike — is already clean.
2398
+ *
2399
+ * Shape rules, applied here because the collector is the one writer:
2400
+ * · consecutive identical read/grep/glob/bash/task events collapse
2401
+ * into one row with a count (n);
2402
+ * · consecutive edits of ONE file merge, summing counts, keeping
2403
+ * the newest preview;
2404
+ * · the PLAN is a single event — a new TodoWrite replaces the old
2405
+ * plan at the current position, so the log shows the latest plan
2406
+ * where it last changed rather than five stale copies;
2407
+ * · capped at the newest 60 NON-PLAN rows, with the shed counted
2408
+ * call-for-call (`dropped += n`) — scrollback semantics, the
2409
+ * same trade the transcript itself makes; the plan is exempt,
2410
+ * because it is current state rather than scrollback.
2411
+ */
2412
+ const toolLog = { ev: [], dropped: 0 };
2413
+ const pushToolEvent = (name, input) => {
2414
+ // envScrub rides INTO the builder, which scrubs over a bounded
2415
+ // window BEFORE its caps — scrubbing after the cut both leaked a
2416
+ // boundary-straddling secret's prefix and grew a capped field
2417
+ // past the server's limits (review, 2026-09-01).
2418
+ const e = toolEventOf(name, input, dir.wt, envScrub);
2419
+ if (!e) return;
2420
+ if (e.t === 'plan') {
2421
+ const i = toolLog.ev.findIndex((x) => x.t === 'plan');
2422
+ if (i >= 0) toolLog.ev.splice(i, 1);
2423
+ toolLog.ev.push(e);
2424
+ } else {
2425
+ const last = toolLog.ev[toolLog.ev.length - 1];
2426
+ const sameKey =
2427
+ last &&
2428
+ last.t === e.t &&
2429
+ last.p === e.p &&
2430
+ last.q === e.q &&
2431
+ last.c === e.c;
2432
+ if (sameKey && (e.t === 'edit' || e.t === 'write')) {
2433
+ last.n = (last.n ?? 1) + 1;
2434
+ last.a = (last.a ?? 0) + (e.a ?? 0);
2435
+ last.d = (last.d ?? 0) + (e.d ?? 0);
2436
+ if (e.dl) last.dl = e.dl;
2437
+ } else if (sameKey) {
2438
+ last.n = (last.n ?? 1) + 1;
2439
+ } else {
2440
+ toolLog.ev.push(e);
2441
+ }
2442
+ }
2443
+ // The cap evicts the oldest NON-plan row: the plan is current
2444
+ // state, not scrollback — the one card the fold keeps out — and a
2445
+ // shed collapsed row counts its repeats, so "N steps" never
2446
+ // understates what the cut removed.
2447
+ while (toolLog.ev.length > 60) {
2448
+ const i = toolLog.ev.findIndex((x) => x.t !== 'plan');
2449
+ if (i < 0) break; // only the plan left; it stays
2450
+ const [shed] = toolLog.ev.splice(i, 1);
2451
+ toolLog.dropped = Math.min(1_000_000, toolLog.dropped + (shed?.n ?? 1));
2452
+ }
2453
+ };
2454
+ const narrator = makeNarrator(job.sessionId, job.id, () =>
2455
+ toolLog.ev.length > 0 ? toolLog : undefined
2456
+ );
2322
2457
 
2323
2458
  // THE COMMAND AUDIT — every `$ …` the CLI's stream reports, batched
2324
2459
  // to the server verbatim so an admin can read what actually ran on
@@ -2412,6 +2547,8 @@ export function createWorkManager({
2412
2547
  narrator.line(a?.label);
2413
2548
  auditCommand(a);
2414
2549
  },
2550
+ // The structured twin of the line above — see toolLog.
2551
+ onToolEvent: pushToolEvent,
2415
2552
  // What this CLI says it can be asked for by name. Harvested off
2416
2553
  // the init event the stream already carries — no probe, no scan,
2417
2554
  // no extra spawn — and reported on the next roster poll so the
@@ -2539,6 +2676,9 @@ export function createWorkManager({
2539
2676
  await settleWorkTurn(job.id, {
2540
2677
  ok: false,
2541
2678
  answer: "Couldn't resume the terminal session — it may have been removed.",
2679
+ // Whatever it DID before coming back empty is exactly the
2680
+ // question a failed turn's log answers.
2681
+ ...(toolLog.ev.length > 0 ? { tools: toolLog } : {}),
2542
2682
  });
2543
2683
  warn('adopt turn produced no output — settled as failed');
2544
2684
  return;
@@ -2572,6 +2712,10 @@ export function createWorkManager({
2572
2712
  // here. Recording the path unconditionally is how a crashed first
2573
2713
  // turn used to brick resume for the session's whole life.
2574
2714
  ...(answer.length > 0 ? { sessionRef: dir.wt } : {}),
2715
+ // The turn's tool log, in final form — the durable copy that lands
2716
+ // on the settled message (the live copy on the record is cleared
2717
+ // at settle). Already scrubbed at collection.
2718
+ ...(toolLog.ev.length > 0 ? { tools: toolLog } : {}),
2575
2719
  });
2576
2720
  if (answer.length > 0) ok(`${c.cyan('tab')} ${c.dim('— replied in the session')}`);
2577
2721
  else warn('session turn produced no output — settled as failed');
@@ -2582,6 +2726,9 @@ export function createWorkManager({
2582
2726
  // routinely quotes command output, and command output can quote a
2583
2727
  // synced secret.
2584
2728
  answer: envScrub(String(e?.message ?? 'the session turn failed')).slice(0, 2000),
2729
+ // "What did it do before it failed" is exactly the question a
2730
+ // crashed turn's log answers — same spread as the main settle.
2731
+ ...(toolLog.ev.length > 0 ? { tools: toolLog } : {}),
2585
2732
  });
2586
2733
  warn(`session turn failed: ${e?.message ?? e}`);
2587
2734
  } finally {
@@ -2592,6 +2739,7 @@ export function createWorkManager({
2592
2739
  // awaited: this runs inside the session's chain, and a slow POST
2593
2740
  // would delay the next turn of that tab behind a readout.
2594
2741
  void reportPlaceWorktrees(job.sessionId).catch(() => {});
2742
+ burstListeners(job.sessionId);
2595
2743
  }
2596
2744
  });
2597
2745
  }
@@ -169,6 +169,16 @@ export function worktreeDiff(wt, baseRef) {
169
169
  } catch {
170
170
  return null; // not a worktree (or not readable) — report nothing, not zeros
171
171
  }
172
+ // The commit HEAD names, for the strip's branch chip. Best-effort: an
173
+ // unborn branch (fresh repo, no commit yet) has a name and no sha, and the
174
+ // report simply omits the key — absent must never become an empty string,
175
+ // which would render as a blank chip.
176
+ let headSha = '';
177
+ try {
178
+ headSha = git(['rev-parse', 'HEAD'], wt);
179
+ } catch {
180
+ /* unborn HEAD — no sha to report */
181
+ }
172
182
  let base = '';
173
183
  try {
174
184
  base = git(['merge-base', 'HEAD', baseRef], wt);
@@ -289,6 +299,7 @@ export function worktreeDiff(wt, baseRef) {
289
299
  // pathological commit subject — would 400 every session's readout at once.
290
300
  return {
291
301
  branch: branch.slice(0, 200),
302
+ ...(headSha ? { headSha: headSha.slice(0, 64) } : {}),
292
303
  path: wt,
293
304
  ahead,
294
305
  behind,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.73.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.74.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"
@@ -40,4 +40,4 @@
40
40
  "bugs": {
41
41
  "url": "https://github.com/flowviant/cli/issues"
42
42
  }
43
- }
43
+ }