flowviant 0.73.0 → 0.74.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.
@@ -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,87 @@ 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
+ /**
111
+ * Tool-call → ONE STRUCTURED EVENT for the transcript's tool cards — the
112
+ * relay's durable form, where `humanizeClaudeTool` above is its one-line live
113
+ * form. Same source (the CLI's own tool_use input), zero inference: every
114
+ * field is something the CLI emitted, and a tool this doesn't know renders as
115
+ * nothing rather than as a guess.
116
+ *
117
+ * Wire vocabulary (compact keys — this rides a 1.5s-throttled POST):
118
+ * t: read|edit|write|grep|glob|bash|task|plan
119
+ * p: path (worktree-relative) q: pattern/description c: command
120
+ * a/d: line counts added/deleted (from the input's own strings)
121
+ * dl: a few "-/+" prefixed preview lines of an Edit
122
+ * items: the plan's todos, x = text, s = done|active|open
123
+ *
124
+ * The CALLER scrubs (work.mjs envScrub) — this stays a pure shape function so
125
+ * it is testable without a vault.
126
+ */
127
+ export function toolEventOf(name, input = {}, cwd = '') {
128
+ const rel = (p) => shortPath(p, cwd).slice(0, 300);
129
+ const lines = (s) => (s ? String(s).split('\n').length : 0);
130
+ switch (name) {
131
+ case 'Read':
132
+ return { t: 'read', p: rel(input.file_path) };
133
+ case 'Write':
134
+ return { t: 'write', p: rel(input.file_path), a: lines(input.content) };
135
+ case 'Edit': {
136
+ const oldS = String(input.old_string ?? '');
137
+ const newS = String(input.new_string ?? '');
138
+ // A MINI-DIFF, not the diff: the first lines of each side, enough to
139
+ // recognise the change at a glance. The real diff lives in git.
140
+ const dl = [
141
+ ...oldS.split('\n').slice(0, 2).map((l) => `- ${l}`),
142
+ ...newS.split('\n').slice(0, 3).map((l) => `+ ${l}`),
143
+ ].map((l) => l.slice(0, 160));
144
+ return { t: 'edit', p: rel(input.file_path), a: lines(newS), d: lines(oldS), dl };
145
+ }
146
+ case 'Grep':
147
+ return {
148
+ t: 'grep',
149
+ q: String(input.pattern ?? '').slice(0, 200),
150
+ ...(input.path ? { p: rel(input.path) } : {}),
151
+ };
152
+ case 'Glob':
153
+ return { t: 'glob', q: String(input.pattern ?? '').slice(0, 200) };
154
+ case 'Bash':
155
+ return { t: 'bash', c: String(input.command ?? '').slice(0, 200) };
156
+ case 'Task':
157
+ return { t: 'task', q: String(input.description ?? '').slice(0, 200) };
158
+ case 'TodoWrite': {
159
+ const todos = Array.isArray(input.todos) ? input.todos.slice(0, 20) : [];
160
+ const items = todos
161
+ .map((td) => ({
162
+ x: String(td?.content ?? '').slice(0, 120),
163
+ s: td?.status === 'completed' ? 'done' : td?.status === 'in_progress' ? 'active' : 'open',
164
+ }))
165
+ .filter((i) => i.x);
166
+ return items.length ? { t: 'plan', items } : null;
167
+ }
168
+ default:
169
+ return null;
170
+ }
171
+ }
172
+
97
173
  // ── Codex ──────────────────────────────────────────────────────────────────
98
174
 
99
175
  /**
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,66 @@ 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, with the shed counted (`dropped`) —
2408
+ * scrollback semantics, the same trade the transcript itself
2409
+ * makes.
2410
+ */
2411
+ const toolLog = { ev: [], dropped: 0 };
2412
+ const scrubEv = (e) => {
2413
+ for (const k of ['p', 'q', 'c']) if (typeof e[k] === 'string') e[k] = envScrub(e[k]);
2414
+ if (Array.isArray(e.dl)) e.dl = e.dl.map((l) => envScrub(l));
2415
+ if (Array.isArray(e.items)) for (const i of e.items) i.x = envScrub(i.x);
2416
+ return e;
2417
+ };
2418
+ const pushToolEvent = (name, input) => {
2419
+ const e = toolEventOf(name, input, dir.wt);
2420
+ if (!e) return;
2421
+ scrubEv(e);
2422
+ if (e.t === 'plan') {
2423
+ const i = toolLog.ev.findIndex((x) => x.t === 'plan');
2424
+ if (i >= 0) toolLog.ev.splice(i, 1);
2425
+ toolLog.ev.push(e);
2426
+ } else {
2427
+ const last = toolLog.ev[toolLog.ev.length - 1];
2428
+ const sameKey =
2429
+ last &&
2430
+ last.t === e.t &&
2431
+ last.p === e.p &&
2432
+ last.q === e.q &&
2433
+ last.c === e.c;
2434
+ if (sameKey && (e.t === 'edit' || e.t === 'write')) {
2435
+ last.n = (last.n ?? 1) + 1;
2436
+ last.a = (last.a ?? 0) + (e.a ?? 0);
2437
+ last.d = (last.d ?? 0) + (e.d ?? 0);
2438
+ if (e.dl) last.dl = e.dl;
2439
+ } else if (sameKey) {
2440
+ last.n = (last.n ?? 1) + 1;
2441
+ } else {
2442
+ toolLog.ev.push(e);
2443
+ }
2444
+ }
2445
+ while (toolLog.ev.length > 60) {
2446
+ toolLog.ev.shift();
2447
+ toolLog.dropped++;
2448
+ }
2449
+ };
2450
+ const narrator = makeNarrator(job.sessionId, job.id, () =>
2451
+ toolLog.ev.length > 0 ? toolLog : undefined
2452
+ );
2322
2453
 
2323
2454
  // THE COMMAND AUDIT — every `$ …` the CLI's stream reports, batched
2324
2455
  // to the server verbatim so an admin can read what actually ran on
@@ -2412,6 +2543,8 @@ export function createWorkManager({
2412
2543
  narrator.line(a?.label);
2413
2544
  auditCommand(a);
2414
2545
  },
2546
+ // The structured twin of the line above — see toolLog.
2547
+ onToolEvent: pushToolEvent,
2415
2548
  // What this CLI says it can be asked for by name. Harvested off
2416
2549
  // the init event the stream already carries — no probe, no scan,
2417
2550
  // no extra spawn — and reported on the next roster poll so the
@@ -2572,6 +2705,10 @@ export function createWorkManager({
2572
2705
  // here. Recording the path unconditionally is how a crashed first
2573
2706
  // turn used to brick resume for the session's whole life.
2574
2707
  ...(answer.length > 0 ? { sessionRef: dir.wt } : {}),
2708
+ // The turn's tool log, in final form — the durable copy that lands
2709
+ // on the settled message (the live copy on the record is cleared
2710
+ // at settle). Already scrubbed at collection.
2711
+ ...(toolLog.ev.length > 0 ? { tools: toolLog } : {}),
2575
2712
  });
2576
2713
  if (answer.length > 0) ok(`${c.cyan('tab')} ${c.dim('— replied in the session')}`);
2577
2714
  else warn('session turn produced no output — settled as failed');
@@ -2592,6 +2729,7 @@ export function createWorkManager({
2592
2729
  // awaited: this runs inside the session's chain, and a slow POST
2593
2730
  // would delay the next turn of that tab behind a readout.
2594
2731
  void reportPlaceWorktrees(job.sessionId).catch(() => {});
2732
+ burstListeners(job.sessionId);
2595
2733
  }
2596
2734
  });
2597
2735
  }
@@ -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,6 +1,6 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.73.0",
3
+ "version": "0.74.0",
4
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.",
5
5
  "type": "module",
6
6
  "bin": {