flowviant 0.21.0 → 0.23.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.
@@ -235,10 +235,15 @@ export function humanizeToolUse(name, input = {}, cwd = '') {
235
235
  }
236
236
  }
237
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.
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.
242
247
  function handleStreamLine(line, { cwd, emit, onActivity, appendText }) {
243
248
  let ev;
244
249
  try {
@@ -248,15 +253,22 @@ function handleStreamLine(line, { cwd, emit, onActivity, appendText }) {
248
253
  emit(line + '\n');
249
254
  return;
250
255
  }
256
+ const push = (a) => {
257
+ if (!a || !a.label) return;
258
+ emit(a.label + '\n');
259
+ onActivity?.(a);
260
+ };
251
261
  if (ev.type === 'assistant' && Array.isArray(ev.message?.content)) {
252
262
  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
- }
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));
260
272
  }
261
273
  }
262
274
  } else if (ev.type === 'result' && typeof ev.result === 'string') {
@@ -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.21.0';
7
+ export const VERSION = '0.23.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
@@ -426,7 +426,7 @@ export async function runFleetDaemon() {
426
426
  let lastProgressAt = 0;
427
427
  const postWikiProgress = async (body, force = false) => {
428
428
  const now = Date.now();
429
- if (!force && now - lastProgressAt < 1000) return;
429
+ if (!force && now - lastProgressAt < 600) return;
430
430
  lastProgressAt = now;
431
431
  try {
432
432
  await fetch(WIKI_PROGRESS_URL, {
@@ -486,25 +486,46 @@ export async function runFleetDaemon() {
486
486
  }
487
487
  const task = wikiQueue.shift();
488
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.
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.
492
494
  const mode = task.type === 'sweep' ? 'sweep' : 'reground';
493
495
  const startedAt = Date.now();
494
496
  let filesRead = 0;
495
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
+ });
496
508
  const onActivity = (a) => {
497
509
  if (a.kind === 'read') filesRead++;
498
510
  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
- });
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());
506
517
  };
518
+ // Heartbeat: re-send the current frame every 5s even with no new stream
519
+ // event, so the app's freshness window never lapses during a long
520
+ // thinking block or slow tool (which emit nothing until they finish) —
521
+ // otherwise the cover would flap back to the empty state mid-sweep.
522
+ let heartbeat = null;
507
523
  try {
524
+ // Immediate frame so the cover shows the daemon feed right away (the
525
+ // "reading your code" phase), not a static message, while Claude warms up.
526
+ feed.push('starting…');
527
+ await postWikiProgress(frame(), true);
528
+ heartbeat = setInterval(() => void postWikiProgress(frame(), true), 5000);
508
529
  if (!existsSync(wikiWt)) {
509
530
  try {
510
531
  git(['worktree', 'add', '--detach', wikiWt, baseRef], repoRoot);
@@ -565,19 +586,10 @@ export async function runFleetDaemon() {
565
586
  } catch (e) {
566
587
  warn(`wiki ${task.type} failed: ${e.message}`);
567
588
  } finally {
589
+ if (heartbeat) clearInterval(heartbeat);
568
590
  // Terminal frame so the app cover clears promptly (don't wait for the
569
591
  // 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
- );
592
+ await postWikiProgress(frame({ done: true }), true);
581
593
  rmSync(dir, { recursive: true, force: true });
582
594
  }
583
595
  }
@@ -621,6 +633,14 @@ export async function runFleetDaemon() {
621
633
  if (!connected) {
622
634
  connected = true;
623
635
  ok('Connected to Flowviant — watching your roster.');
636
+ // Name the scoped project so a mismatch (this daemon serves project A, but
637
+ // you're viewing project B's wiki) is obvious instead of a silent no-op.
638
+ if (roster.project) {
639
+ note(
640
+ `${c.cyan('project')} · ${c.bold(roster.project.name)} ${c.dim(`(${roster.project.id})`)}`
641
+ );
642
+ note(c.dim(' wiki + agents stream to THIS project — view its Code canvas in Flowviant.'));
643
+ }
624
644
  }
625
645
  if (roster.mcpUrl) mcpUrl = roster.mcpUrl;
626
646
  if (roster.leaseTtlSeconds) leaseTtlSeconds = roster.leaseTtlSeconds;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.21.0",
3
+ "version": "0.23.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": {