flowviant 0.20.0 → 0.22.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,100 @@ 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
+ // Collapse whitespace + clip so a narration/thinking snippet is one tidy feed line.
239
+ const oneLine = (s, n = 160) => String(s).replace(/\s+/g, ' ').trim().slice(0, n);
240
+
241
+ // Parse ONE line of `--output-format stream-json` NDJSON into feed activities.
242
+ // Surfaces the WHOLE turn — thinking, narration, AND every tool — so neither the
243
+ // daemon console nor the app cover goes dark while Claude reasons (Opus thinks in
244
+ // bursts before/between tools; emitting only tools left long silent gaps).
245
+ // Assistant text is also folded into `out` so the WIKI_DONE/REGROUND_DONE
246
+ // sentinels still match. A non-JSON line (a stray warning) is kept as raw text.
247
+ function handleStreamLine(line, { cwd, emit, onActivity, appendText }) {
248
+ let ev;
249
+ try {
250
+ ev = JSON.parse(line);
251
+ } catch {
252
+ appendText(line + '\n');
253
+ emit(line + '\n');
254
+ return;
255
+ }
256
+ const push = (a) => {
257
+ if (!a || !a.label) return;
258
+ emit(a.label + '\n');
259
+ onActivity?.(a);
260
+ };
261
+ if (ev.type === 'assistant' && Array.isArray(ev.message?.content)) {
262
+ for (const b of ev.message.content) {
263
+ if (b.type === 'thinking' || b.type === 'redacted_thinking') {
264
+ // The `thinking` text is usually redacted (signature only), so emit a
265
+ // marker — enough to show Claude is actively reasoning, not hung.
266
+ push({ kind: 'think', label: b.thinking ? `thinking: ${oneLine(b.thinking)}` : 'thinking…' });
267
+ } else if (b.type === 'text' && b.text?.trim()) {
268
+ appendText(b.text + '\n');
269
+ push({ kind: 'say', label: oneLine(b.text) });
270
+ } else if (b.type === 'tool_use') {
271
+ push(humanizeToolUse(b.name, b.input || {}, cwd));
272
+ }
273
+ }
274
+ } else if (ev.type === 'result' && typeof ev.result === 'string') {
275
+ // The final assistant text (carries WIKI_DONE / REGROUND_DONE).
276
+ appendText(ev.result + '\n');
277
+ }
278
+ }
279
+
202
280
  // One Claude Code turn. Output is captured (for sentinel detection) and streamed
203
281
  // through, line-prefixed with the worker label so a fleet stays legible.
204
- export function runTurn({ prompt, resume, system, cwd, mcpConfig, label, onSpawn }) {
282
+ //
283
+ // `streamJson` switches to `--output-format stream-json` and parses the event
284
+ // stream: only the humanized tool activity reaches the console (a legible
285
+ // stream of `read …`, `grep …`, `+ node …`), assistant text is folded into the
286
+ // returned string for sentinel detection, and each activity is handed to
287
+ // `onActivity` so the caller can forward progress. Build-agent turns leave it
288
+ // off and keep the raw text passthrough + line sentinels.
289
+ export function runTurn({ prompt, resume, system, cwd, mcpConfig, label, onSpawn, streamJson, onActivity }) {
205
290
  return new Promise((resolve) => {
206
291
  const args = [];
207
292
  if (resume) args.push('--continue');
208
- args.push('-p', prompt, '--mcp-config', mcpConfig, '--append-system-prompt', system, ...PERM);
293
+ args.push('-p', prompt, '--mcp-config', mcpConfig, '--append-system-prompt', system);
294
+ if (streamJson) args.push('--output-format', 'stream-json', '--verbose');
295
+ args.push(...PERM);
209
296
  // Force the user's Claude Code subscription — never the API. A key exported in
210
297
  // the shell would otherwise silently bill every poll-mode turn as raw API
211
298
  // usage (same invariant live mode enforces on its SDK session env).
@@ -216,10 +303,47 @@ export function runTurn({ prompt, resume, system, cwd, mcpConfig, label, onSpawn
216
303
  onSpawn?.(child);
217
304
  let out = '';
218
305
  const pfx = label ? `${label} ` : '';
306
+ const emit = (s) => process.stdout.write(pfx ? s.replace(/\n/g, `\n${pfx}`) : s);
307
+
308
+ if (streamJson) {
309
+ let buf = '';
310
+ const appendText = (t) => {
311
+ out += t;
312
+ };
313
+ child.stdout.on('data', (d) => {
314
+ buf += d.toString();
315
+ let nl;
316
+ while ((nl = buf.indexOf('\n')) >= 0) {
317
+ const line = buf.slice(0, nl);
318
+ buf = buf.slice(nl + 1);
319
+ if (line.trim()) handleStreamLine(line, { cwd, emit, onActivity, appendText });
320
+ }
321
+ });
322
+ // stderr is not JSON (warnings/errors) — pass through and keep for sentinels.
323
+ child.stderr.on('data', (d) => {
324
+ const s = d.toString();
325
+ out += s;
326
+ emit(s);
327
+ });
328
+ child.on('error', (e) => {
329
+ if (e.code === 'ENOENT') {
330
+ console.error("\nerror: 'claude' CLI not found on PATH. Install Claude Code first.");
331
+ process.exit(1);
332
+ }
333
+ console.error(e);
334
+ resolve(out);
335
+ });
336
+ child.on('close', () => {
337
+ if (buf.trim()) handleStreamLine(buf, { cwd, emit, onActivity, appendText });
338
+ resolve(out);
339
+ });
340
+ return;
341
+ }
342
+
219
343
  const onChunk = (d) => {
220
344
  const s = d.toString();
221
345
  out += s;
222
- process.stdout.write(pfx ? s.replace(/\n/g, `\n${pfx}`) : s);
346
+ emit(s);
223
347
  };
224
348
  child.stdout.on('data', onChunk);
225
349
  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.22.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 < 600) 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,7 +486,40 @@ 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: a rolling FEED of everything Claude does
490
+ // (thinking, narration, reads, node writes), the file count, and the
491
+ // phase — streamed to the app (throttled; each frame carries the whole
492
+ // recent tail so a dropped POST loses nothing). elapsedSec is the
493
+ // daemon's own clock.
494
+ const mode = task.type === 'sweep' ? 'sweep' : 'reground';
495
+ const startedAt = Date.now();
496
+ let filesRead = 0;
497
+ let phase = 'reading';
498
+ const feed = [];
499
+ const frame = (extra) => ({
500
+ mode,
501
+ phase,
502
+ activity: feed[feed.length - 1] ?? '',
503
+ recent: feed.slice(-24),
504
+ filesRead,
505
+ elapsedSec: Math.round((Date.now() - startedAt) / 1000),
506
+ ...extra,
507
+ });
508
+ const onActivity = (a) => {
509
+ if (a.kind === 'read') filesRead++;
510
+ if (a.kind === 'write') phase = 'writing';
511
+ // Collapse runs of bare "thinking…" so the feed doesn't fill with it.
512
+ if (!(a.label === 'thinking…' && feed[feed.length - 1] === 'thinking…')) {
513
+ feed.push(a.label);
514
+ if (feed.length > 48) feed.shift();
515
+ }
516
+ void postWikiProgress(frame());
517
+ };
464
518
  try {
519
+ // Immediate frame so the cover shows the daemon feed right away (the
520
+ // "reading your code" phase), not a static message, while Claude warms up.
521
+ feed.push('starting…');
522
+ await postWikiProgress(frame(), true);
465
523
  if (!existsSync(wikiWt)) {
466
524
  try {
467
525
  git(['worktree', 'add', '--detach', wikiWt, baseRef], repoRoot);
@@ -486,6 +544,8 @@ export async function runFleetDaemon() {
486
544
  cwd: wikiWt,
487
545
  mcpConfig,
488
546
  label: c.cyan('[wiki]'),
547
+ streamJson: true,
548
+ onActivity,
489
549
  });
490
550
  if (sawSentinel(out, 'WIKI_DONE'))
491
551
  ok(`${c.cyan('wiki')} ${c.dim('— regenerated from your code.')}`);
@@ -504,6 +564,8 @@ export async function runFleetDaemon() {
504
564
  cwd: wikiWt,
505
565
  mcpConfig,
506
566
  label: c.cyan('[wiki]'),
567
+ streamJson: true,
568
+ onActivity,
507
569
  });
508
570
  if (sawSentinel(out, 'REGROUND_DONE'))
509
571
  ok(`${c.cyan('wiki')} ${c.dim(`— wiki updated for "${task.title}".`)}`);
@@ -518,6 +580,9 @@ export async function runFleetDaemon() {
518
580
  } catch (e) {
519
581
  warn(`wiki ${task.type} failed: ${e.message}`);
520
582
  } finally {
583
+ // Terminal frame so the app cover clears promptly (don't wait for the
584
+ // freshness window to lapse). force-sent past the throttle.
585
+ await postWikiProgress(frame({ done: true }), true);
521
586
  rmSync(dir, { recursive: true, force: true });
522
587
  }
523
588
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.20.0",
3
+ "version": "0.22.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": {