flowviant 0.72.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.
package/bin/cli.mjs CHANGED
@@ -244,7 +244,7 @@ if (process.argv[2] === 'env') {
244
244
  // two TTYs and cannot be asked anything — the first read raises SIGTTIN and the
245
245
  // kernel STOPS the process, which is why 0.55.2's timeout did not save it (a
246
246
  // stopped process runs no timers). See tty.mjs.
247
- const { canPrompt, askWithTimeout } = await import('./lib/tty.mjs');
247
+ const { canPrompt, askWithTimeout, selectMenu, menuSupported } = await import('./lib/tty.mjs');
248
248
  const interactive = canPrompt() && process.env.FLOWVIANT_REEXEC !== '1';
249
249
 
250
250
  /** How long the one-time binding confirm waits before serving unbound. A person
@@ -285,6 +285,8 @@ if (!FLEET_TOKEN) {
285
285
  }
286
286
  if (CREDENTIAL.choices?.length && interactive) {
287
287
  const creds = await import('./lib/credentials.mjs');
288
+ const { originSlug } = await import('./lib/git.mjs');
289
+ const { basename } = await import('node:path');
288
290
  const { choices, repoRoot } = CREDENTIAL;
289
291
  console.log(
290
292
  CREDENTIAL.reason === 'outside-repo'
@@ -293,27 +295,78 @@ if (!FLEET_TOKEN) {
293
295
  ? `More than one connected project names this repo (${repoRoot}) — pick which one this daemon serves:`
294
296
  : `This repo (${repoRoot}) is not connected to any project yet. Connected on this machine:`
295
297
  );
296
- console.log(listLines(choices, creds));
297
- console.log(` ${choices.length + 1}. connect ${repoRoot ? 'this repo' : 'a repo'} to a different project (flowviant login)`);
298
+
299
+ const loginLabel = `connect ${repoRoot ? 'this repo' : 'a repo'} to a different project (flowviant login)`;
300
+ // WHICH ONE LOOKS RIGHT — a pre-selection, never an auto-serve. The resolver
301
+ // refuses to serve a project the repo PATH did not name (the skadooble law);
302
+ // this only decides which row the cursor starts on, using the repo's folder
303
+ // name and its github repo-name against the stored project names. A unique
304
+ // match becomes ONE keypress; a wrong guess costs nothing, because the human
305
+ // still confirms. `multiple-bound` gets no hint — every choice already names
306
+ // this repo, so nothing distinguishes them.
307
+ const slug = repoRoot ? originSlug(repoRoot) : null;
308
+ const likely =
309
+ CREDENTIAL.reason === 'multiple-bound'
310
+ ? -1
311
+ : creds.likelyChoiceIndex(choices, {
312
+ repoBasename: repoRoot ? basename(repoRoot) : null,
313
+ repoSlugName: slug ? slug.split('/')[1] : null,
314
+ });
315
+
298
316
  // Bounded like the confirm below, and for the same reason — but silence
299
317
  // means something DIFFERENT here and the difference is load-bearing. There
300
318
  // is a real ambiguity to resolve; serving a guess is the skadooble bug.
301
319
  // So no answer REFUSES, which is exactly what this branch already does
302
320
  // headless, and the message says how to answer without being present.
303
- const raw = await askWithTimeout(
304
- `Which project should this daemon serve? [1-${choices.length + 1}] `,
305
- PICK_TIMEOUT_MS
306
- );
307
- if (raw === null) {
308
- console.error(
309
- `\nno answer in ${Math.round(PICK_TIMEOUT_MS / 1000)}s nothing started. ` +
310
- `Name one with \`--project <name|id>\`, or run \`flowviant\` here in the foreground and pick.`
321
+ let chosen = null; // 0-based into [...choices, login]
322
+ if (menuSupported()) {
323
+ const rowLabel = (e) =>
324
+ creds.projectLabel(e) + (e.repoRoot ? ` — connected for ${e.repoRoot}` : ' — not tied to a repo yet');
325
+ const options = [...choices.map(rowLabel), loginLabel];
326
+ // Say WHY the cursor starts where it does — "intuitive" made visible.
327
+ if (likely >= 0) options[likely] += ' ← looks like this repo';
328
+ const res = await selectMenu({
329
+ options,
330
+ defaultIndex: likely >= 0 ? likely : 0,
331
+ timeoutMs: PICK_TIMEOUT_MS,
332
+ });
333
+ if (res.timedOut) {
334
+ console.error(
335
+ `\nno answer in ${Math.round(PICK_TIMEOUT_MS / 1000)}s — nothing started. ` +
336
+ `Name one with \`--project <name|id>\`, or run \`flowviant\` here in the foreground and pick.`
337
+ );
338
+ process.exit(1);
339
+ }
340
+ if (res.cancelled) {
341
+ console.error('nothing chosen — nothing started.');
342
+ process.exit(1);
343
+ }
344
+ if (!res.unsupported) chosen = res.index;
345
+ }
346
+ if (chosen === null) {
347
+ // Numeric fallback — a pipe, or a terminal without raw mode. Empty Enter
348
+ // takes the likely default when there is one, so it is one keystroke here
349
+ // too.
350
+ console.log(listLines(choices, creds));
351
+ console.log(` ${choices.length + 1}. ${loginLabel}`);
352
+ const hint = likely >= 0 ? ` (enter for ${creds.projectLabel(choices[likely])})` : '';
353
+ const raw = await askWithTimeout(
354
+ `Which project should this daemon serve? [1-${choices.length + 1}]${hint} `,
355
+ PICK_TIMEOUT_MS
311
356
  );
312
- process.exit(1);
357
+ if (raw === null) {
358
+ console.error(
359
+ `\nno answer in ${Math.round(PICK_TIMEOUT_MS / 1000)}s — nothing started. ` +
360
+ `Name one with \`--project <name|id>\`, or run \`flowviant\` here in the foreground and pick.`
361
+ );
362
+ process.exit(1);
363
+ }
364
+ const n = raw === '' && likely >= 0 ? likely + 1 : Number.parseInt(raw, 10);
365
+ chosen = Number.isInteger(n) ? n - 1 : -1;
313
366
  }
314
- const n = Number.parseInt(raw, 10);
315
- if (n === choices.length + 1) await reexecAfterLogin();
316
- const picked = Number.isInteger(n) ? choices[n - 1] : undefined;
367
+
368
+ if (chosen === choices.length) await reexecAfterLogin();
369
+ const picked = chosen >= 0 ? choices[chosen] : undefined;
317
370
  if (!picked) {
318
371
  console.error('nothing chosen — nothing started.');
319
372
  process.exit(1);
@@ -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
@@ -136,6 +136,40 @@ export function projectLabel(e) {
136
136
  return e?.name ?? (e?.projectId ? `project ${e.projectId.slice(0, 8)}…` : 'an unnamed project');
137
137
  }
138
138
 
139
+ /** Collapse a name or slug for loose comparison: "My Project", "my-project"
140
+ * and "myproject" all become "myproject". */
141
+ function normalizeName(s) {
142
+ return String(s ?? '')
143
+ .toLowerCase()
144
+ .replace(/[^a-z0-9]/g, '');
145
+ }
146
+
147
+ /**
148
+ * WHICH stored project most likely goes with this repo — a HINT for the
149
+ * picker's default, and DELIBERATELY not a licence to serve it. `resolveStored
150
+ * Credential` matches by repo PATH and refuses to guess past that, because
151
+ * serving a project the path did not name is the "it said skadooble in my
152
+ * calendar repo" surprise this whole file exists to prevent. A name that
153
+ * matches the repo's folder or its github repo-name is softer evidence — good
154
+ * enough to put the cursor on that row so the answer is one keypress, never
155
+ * good enough to skip the human. Two names can collide (a project "api" and a
156
+ * repo "api"), which is exactly why this only pre-selects.
157
+ *
158
+ * Returns the index of a UNIQUE match, or -1 when nothing matches or more than
159
+ * one does — an ambiguous hint is not a hint, and a default that is as likely
160
+ * wrong as right is worse than starting at the top.
161
+ */
162
+ export function likelyChoiceIndex(choices, { repoBasename, repoSlugName } = {}) {
163
+ const wants = new Set([normalizeName(repoBasename), normalizeName(repoSlugName)].filter(Boolean));
164
+ if (wants.size === 0) return -1;
165
+ const hits = [];
166
+ choices.forEach((e, i) => {
167
+ const n = normalizeName(e?.name);
168
+ if (n && wants.has(n)) hits.push(i);
169
+ });
170
+ return hits.length === 1 ? hits[0] : -1;
171
+ }
172
+
139
173
  function mutate(fn) {
140
174
  const f = readFile() ?? {};
141
175
  if (!f.projects || typeof f.projects !== 'object') f.projects = {};
@@ -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/tty.mjs CHANGED
@@ -109,3 +109,126 @@ export async function askWithTimeout(query, timeoutMs) {
109
109
  process.off('SIGTTOU', noop);
110
110
  }
111
111
  }
112
+
113
+ /**
114
+ * Can we draw an arrow-navigable menu? Raw mode is what turns ↑/↓ into
115
+ * keystrokes we receive one at a time; without `setRawMode` (a pipe, a
116
+ * terminal that refuses raw input) there is nothing to drive, and the caller
117
+ * falls back to the numeric prompt rather than drawing a menu nobody can move.
118
+ * `canPrompt()` still gates whether we ask AT ALL — this only decides HOW.
119
+ */
120
+ export function menuSupported() {
121
+ return Boolean(
122
+ process.stdin.isTTY && process.stdout.isTTY && typeof process.stdin.setRawMode === 'function'
123
+ );
124
+ }
125
+
126
+ /**
127
+ * The menu's key handling, PURE so it can be tested without a terminal. Given a
128
+ * keypress and the current cursor, returns exactly one intent:
129
+ * { index } move the highlight (arrows wrap; k/j vim-style; g/G ends)
130
+ * { choose } take a row (Enter takes the highlight; 1–9 jump-and-take)
131
+ * { cancel } Esc, q, or Ctrl-C — nothing chosen
132
+ * null a key we ignore
133
+ * A number past the end is ignored, not clamped: pressing 9 in a 3-row list
134
+ * must not silently select row 3.
135
+ */
136
+ export function menuKey(key, { index, count }) {
137
+ if (key === '\x03' || key === '\x1b' || key === 'q' || key === 'Q') return { cancel: true };
138
+ if (key === '\r' || key === '\n') return { choose: index };
139
+ if (key === '\x1b[A' || key === 'k') return { index: (index - 1 + count) % count };
140
+ if (key === '\x1b[B' || key === 'j') return { index: (index + 1) % count };
141
+ if (key === '\x1b[H' || key === 'g') return { index: 0 };
142
+ if (key === '\x1b[F' || key === 'G') return { index: count - 1 };
143
+ if (/^[1-9]$/.test(key)) {
144
+ const n = Number(key) - 1;
145
+ return n < count ? { choose: n } : null;
146
+ }
147
+ return null;
148
+ }
149
+
150
+ const MENU_FOOTER = '↑/↓ move · enter select · 1–9 jump · esc cancel';
151
+
152
+ /**
153
+ * An arrow-navigable picker for the start path. Resolves one of:
154
+ * { index } the row taken
155
+ * { cancelled } Esc/q/Ctrl-C
156
+ * { timedOut } nobody drove it within timeoutMs
157
+ * { unsupported } no raw mode — the caller uses the numeric prompt instead
158
+ *
159
+ * Held to this file's one law — no start-path prompt may hang the daemon — the
160
+ * same way `askWithTimeout` is: it keeps the timeout, installs the
161
+ * SIGTTIN/SIGTTOU no-ops that keep that timer alive, restores the terminal in
162
+ * every exit, and refuses (returns `unsupported`) rather than half-drawing when
163
+ * raw mode is not really there. The caller still gates on `canPrompt()` before
164
+ * ever reaching here.
165
+ */
166
+ export async function selectMenu({ options, defaultIndex = 0, timeoutMs }) {
167
+ if (!menuSupported()) return { unsupported: true };
168
+ const stdin = process.stdin;
169
+ const out = process.stdout;
170
+ const count = options.length;
171
+ if (count === 0) return { unsupported: true };
172
+ let index = Math.min(Math.max(defaultIndex | 0, 0), count - 1);
173
+
174
+ const width = Math.max(24, (out.columns || 80) - 2);
175
+ const clip = (s) => (s.length > width ? s.slice(0, width - 1) + '…' : s);
176
+ const frame = () =>
177
+ options
178
+ .map((o, i) => (i === index ? `\x1b[7m> ${clip(o)}\x1b[0m` : ` ${clip(o)}`))
179
+ .join('\n') +
180
+ '\n' +
181
+ `\x1b[2m ${MENU_FOOTER}\x1b[0m`;
182
+ const lineCount = count + 1; // rows + footer
183
+
184
+ let rendered = false;
185
+ const draw = () => {
186
+ if (rendered) out.write(`\x1b[${lineCount}A`); // back to the top of our block
187
+ out.write('\r\x1b[0J'); // clear from here to the end of the screen
188
+ out.write(frame() + '\n');
189
+ rendered = true;
190
+ };
191
+
192
+ const noop = () => {};
193
+ return await new Promise((resolve) => {
194
+ let done = false;
195
+ const finish = (result) => {
196
+ if (done) return;
197
+ done = true;
198
+ clearTimeout(timer);
199
+ stdin.removeListener('data', onData);
200
+ try {
201
+ stdin.setRawMode(false);
202
+ } catch {
203
+ /* already restored / gone */
204
+ }
205
+ stdin.pause();
206
+ process.off('SIGTTIN', noop);
207
+ process.off('SIGTTOU', noop);
208
+ out.write('\x1b[?25h'); // show the cursor again
209
+ resolve(result);
210
+ };
211
+ const onData = (buf) => {
212
+ const action = menuKey(buf.toString('utf8'), { index, count });
213
+ if (!action) return;
214
+ if (action.cancel) return finish({ cancelled: true });
215
+ if (action.choose !== undefined) return finish({ index: action.choose });
216
+ if (action.index !== undefined) {
217
+ index = action.index;
218
+ draw();
219
+ }
220
+ };
221
+ process.on('SIGTTIN', noop);
222
+ process.on('SIGTTOU', noop);
223
+ const timer = setTimeout(() => finish({ timedOut: true }), timeoutMs);
224
+ try {
225
+ stdin.setRawMode(true);
226
+ } catch {
227
+ return finish({ unsupported: true });
228
+ }
229
+ stdin.resume();
230
+ out.write('\x1b[?25l'); // hide the cursor while we own the block
231
+ draw();
232
+ stdin.on('data', onData);
233
+ });
234
+ }
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.72.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": {