flowviant 0.20.0 → 0.21.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.
@@ -199,13 +199,88 @@ export function mcpConfigFor(token, mcpUrl) {
199
199
  return { dir, path: p };
200
200
  }
201
201
 
202
+ // Shorten an absolute tool path to a repo-relative one for legible output.
203
+ const shortPath = (p, cwd) => {
204
+ if (typeof p !== 'string') return '';
205
+ let s = p;
206
+ if (cwd && s.startsWith(cwd)) s = s.slice(cwd.length).replace(/^\/+/, '');
207
+ return s;
208
+ };
209
+
210
+ // Turn one Claude tool_use into a compact activity {kind, label}, or null for
211
+ // tools not worth surfacing. `kind:'read'` is what the file counter counts;
212
+ // an emit_wiki_node flips the phase to "writing". Used by wiki turns to stream
213
+ // exactly which files Claude is touching (daemon console + app cover).
214
+ export function humanizeToolUse(name, input = {}, cwd = '') {
215
+ switch (name) {
216
+ case 'Read':
217
+ return { kind: 'read', label: `read ${shortPath(input.file_path, cwd)}` };
218
+ case 'Grep':
219
+ return {
220
+ kind: 'search',
221
+ label: `grep ${JSON.stringify(input.pattern ?? '')}${input.path ? ` in ${shortPath(input.path, cwd)}` : ''}`,
222
+ };
223
+ case 'Glob':
224
+ return { kind: 'glob', label: `glob ${input.pattern ?? ''}` };
225
+ case 'LS':
226
+ return { kind: 'list', label: `ls ${shortPath(input.path ?? '.', cwd)}` };
227
+ case 'Bash':
228
+ return { kind: 'bash', label: `$ ${String(input.command ?? '').replace(/\s+/g, ' ').slice(0, 60)}` };
229
+ default:
230
+ if (typeof name !== 'string') return null;
231
+ if (name.includes('emit_wiki_node')) return { kind: 'write', label: `+ node ${input.id ?? ''}` };
232
+ if (name.includes('finish_wiki_generation')) return { kind: 'write', label: 'finalize wiki' };
233
+ if (name.includes('list_wiki_nodes')) return { kind: 'mcp', label: 'list wiki nodes' };
234
+ return null; // other tools: silent
235
+ }
236
+ }
237
+
238
+ // Parse ONE line of `--output-format stream-json` NDJSON. Pulls assistant text
239
+ // into `out` (so sentinel detection still works) and turns each tool_use into a
240
+ // streamed activity line. A line that isn't JSON (a stray warning) is treated as
241
+ // raw text so nothing is lost.
242
+ function handleStreamLine(line, { cwd, emit, onActivity, appendText }) {
243
+ let ev;
244
+ try {
245
+ ev = JSON.parse(line);
246
+ } catch {
247
+ appendText(line + '\n');
248
+ emit(line + '\n');
249
+ return;
250
+ }
251
+ if (ev.type === 'assistant' && Array.isArray(ev.message?.content)) {
252
+ for (const b of ev.message.content) {
253
+ if (b.type === 'text' && b.text) appendText(b.text + '\n');
254
+ else if (b.type === 'tool_use') {
255
+ const a = humanizeToolUse(b.name, b.input || {}, cwd);
256
+ if (a) {
257
+ emit(a.label + '\n');
258
+ onActivity?.(a);
259
+ }
260
+ }
261
+ }
262
+ } else if (ev.type === 'result' && typeof ev.result === 'string') {
263
+ // The final assistant text (carries WIKI_DONE / REGROUND_DONE).
264
+ appendText(ev.result + '\n');
265
+ }
266
+ }
267
+
202
268
  // One Claude Code turn. Output is captured (for sentinel detection) and streamed
203
269
  // through, line-prefixed with the worker label so a fleet stays legible.
204
- export function runTurn({ prompt, resume, system, cwd, mcpConfig, label, onSpawn }) {
270
+ //
271
+ // `streamJson` switches to `--output-format stream-json` and parses the event
272
+ // stream: only the humanized tool activity reaches the console (a legible
273
+ // stream of `read …`, `grep …`, `+ node …`), assistant text is folded into the
274
+ // returned string for sentinel detection, and each activity is handed to
275
+ // `onActivity` so the caller can forward progress. Build-agent turns leave it
276
+ // off and keep the raw text passthrough + line sentinels.
277
+ export function runTurn({ prompt, resume, system, cwd, mcpConfig, label, onSpawn, streamJson, onActivity }) {
205
278
  return new Promise((resolve) => {
206
279
  const args = [];
207
280
  if (resume) args.push('--continue');
208
- args.push('-p', prompt, '--mcp-config', mcpConfig, '--append-system-prompt', system, ...PERM);
281
+ args.push('-p', prompt, '--mcp-config', mcpConfig, '--append-system-prompt', system);
282
+ if (streamJson) args.push('--output-format', 'stream-json', '--verbose');
283
+ args.push(...PERM);
209
284
  // Force the user's Claude Code subscription — never the API. A key exported in
210
285
  // the shell would otherwise silently bill every poll-mode turn as raw API
211
286
  // usage (same invariant live mode enforces on its SDK session env).
@@ -216,10 +291,47 @@ export function runTurn({ prompt, resume, system, cwd, mcpConfig, label, onSpawn
216
291
  onSpawn?.(child);
217
292
  let out = '';
218
293
  const pfx = label ? `${label} ` : '';
294
+ const emit = (s) => process.stdout.write(pfx ? s.replace(/\n/g, `\n${pfx}`) : s);
295
+
296
+ if (streamJson) {
297
+ let buf = '';
298
+ const appendText = (t) => {
299
+ out += t;
300
+ };
301
+ child.stdout.on('data', (d) => {
302
+ buf += d.toString();
303
+ let nl;
304
+ while ((nl = buf.indexOf('\n')) >= 0) {
305
+ const line = buf.slice(0, nl);
306
+ buf = buf.slice(nl + 1);
307
+ if (line.trim()) handleStreamLine(line, { cwd, emit, onActivity, appendText });
308
+ }
309
+ });
310
+ // stderr is not JSON (warnings/errors) — pass through and keep for sentinels.
311
+ child.stderr.on('data', (d) => {
312
+ const s = d.toString();
313
+ out += s;
314
+ emit(s);
315
+ });
316
+ child.on('error', (e) => {
317
+ if (e.code === 'ENOENT') {
318
+ console.error("\nerror: 'claude' CLI not found on PATH. Install Claude Code first.");
319
+ process.exit(1);
320
+ }
321
+ console.error(e);
322
+ resolve(out);
323
+ });
324
+ child.on('close', () => {
325
+ if (buf.trim()) handleStreamLine(buf, { cwd, emit, onActivity, appendText });
326
+ resolve(out);
327
+ });
328
+ return;
329
+ }
330
+
219
331
  const onChunk = (d) => {
220
332
  const s = d.toString();
221
333
  out += s;
222
- process.stdout.write(pfx ? s.replace(/\n/g, `\n${pfx}`) : s);
334
+ emit(s);
223
335
  };
224
336
  child.stdout.on('data', onChunk);
225
337
  child.stderr.on('data', onChunk);
@@ -4,7 +4,7 @@ import { readFileSync } from 'node:fs';
4
4
  import { join } from 'node:path';
5
5
  import { homedir } from 'node:os';
6
6
 
7
- export const VERSION = '0.20.0';
7
+ export const VERSION = '0.21.0';
8
8
 
9
9
  // Credential stored by `flowviant login` (device auth) — the no-token,
10
10
  // no-env-var path. An explicit --fleet flag or FLOWVIANT_FLEET env still wins.
package/bin/lib/fleet.mjs CHANGED
@@ -400,6 +400,7 @@ export async function runFleetDaemon() {
400
400
  const wikiWt = join(baseDir, 'wiki');
401
401
  const REGROUND_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/reground-done');
402
402
  const WIKI_TOKEN_URL = FLEET_URL.replace(/\/agents\/?$/, '/wiki-token');
403
+ const WIKI_PROGRESS_URL = FLEET_URL.replace(/\/agents\/?$/, '/wiki-progress');
403
404
  const wikiQueue = [];
404
405
  let wikiBusy = false;
405
406
  let lastSweepAt = null; // dedup: run each Regenerate request once
@@ -419,6 +420,30 @@ export async function runFleetDaemon() {
419
420
  }
420
421
  };
421
422
 
423
+ // Stream what the wiki turn is doing to the app (the canvas renders the read
424
+ // phase). Throttled to ~1/s — the FIRST activity of a run and the terminal
425
+ // `done` frame force-send so the cover appears fast and clears cleanly.
426
+ let lastProgressAt = 0;
427
+ const postWikiProgress = async (body, force = false) => {
428
+ const now = Date.now();
429
+ if (!force && now - lastProgressAt < 1000) return;
430
+ lastProgressAt = now;
431
+ try {
432
+ await fetch(WIKI_PROGRESS_URL, {
433
+ method: 'POST',
434
+ headers: {
435
+ Authorization: `Bearer ${FLEET_TOKEN}`,
436
+ 'User-Agent': USER_AGENT,
437
+ 'Content-Type': 'application/json',
438
+ },
439
+ signal: AbortSignal.timeout(15_000),
440
+ body: JSON.stringify(body),
441
+ });
442
+ } catch {
443
+ /* best-effort — a dropped frame is harmless, the next one supersedes it */
444
+ }
445
+ };
446
+
422
447
  const enqueueSweep = (job) => {
423
448
  if (!job || job.requestedAt === lastSweepAt) return;
424
449
  lastSweepAt = job.requestedAt;
@@ -461,6 +486,24 @@ export async function runFleetDaemon() {
461
486
  }
462
487
  const task = wikiQueue.shift();
463
488
  const { dir, path: mcpConfig } = mcpConfigFor(token, mcpUrl);
489
+ // Live progress for this turn: count the files Claude reads, flip to the
490
+ // "writing" phase once it starts emitting nodes, and stream each action
491
+ // to the app (throttled). elapsedSec is on the daemon's own clock.
492
+ const mode = task.type === 'sweep' ? 'sweep' : 'reground';
493
+ const startedAt = Date.now();
494
+ let filesRead = 0;
495
+ let phase = 'reading';
496
+ const onActivity = (a) => {
497
+ if (a.kind === 'read') filesRead++;
498
+ if (a.kind === 'write') phase = 'writing';
499
+ void postWikiProgress({
500
+ mode,
501
+ phase,
502
+ activity: a.label,
503
+ filesRead,
504
+ elapsedSec: Math.round((Date.now() - startedAt) / 1000),
505
+ });
506
+ };
464
507
  try {
465
508
  if (!existsSync(wikiWt)) {
466
509
  try {
@@ -486,6 +529,8 @@ export async function runFleetDaemon() {
486
529
  cwd: wikiWt,
487
530
  mcpConfig,
488
531
  label: c.cyan('[wiki]'),
532
+ streamJson: true,
533
+ onActivity,
489
534
  });
490
535
  if (sawSentinel(out, 'WIKI_DONE'))
491
536
  ok(`${c.cyan('wiki')} ${c.dim('— regenerated from your code.')}`);
@@ -504,6 +549,8 @@ export async function runFleetDaemon() {
504
549
  cwd: wikiWt,
505
550
  mcpConfig,
506
551
  label: c.cyan('[wiki]'),
552
+ streamJson: true,
553
+ onActivity,
507
554
  });
508
555
  if (sawSentinel(out, 'REGROUND_DONE'))
509
556
  ok(`${c.cyan('wiki')} ${c.dim(`— wiki updated for "${task.title}".`)}`);
@@ -518,6 +565,19 @@ export async function runFleetDaemon() {
518
565
  } catch (e) {
519
566
  warn(`wiki ${task.type} failed: ${e.message}`);
520
567
  } finally {
568
+ // Terminal frame so the app cover clears promptly (don't wait for the
569
+ // freshness window to lapse). force-sent past the throttle.
570
+ await postWikiProgress(
571
+ {
572
+ mode,
573
+ phase,
574
+ activity: '',
575
+ filesRead,
576
+ elapsedSec: Math.round((Date.now() - startedAt) / 1000),
577
+ done: true,
578
+ },
579
+ true
580
+ );
521
581
  rmSync(dir, { recursive: true, force: true });
522
582
  }
523
583
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.20.0",
3
+ "version": "0.21.0",
4
4
  "description": "Run your own Claude Code as headless build agents for Flowviant — on your own credentials. Claims dispatched work, opens PRs, captures review evidence, and routes questions back to you.",
5
5
  "type": "module",
6
6
  "bin": {