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.
- package/bin/lib/claude.mjs +127 -3
- package/bin/lib/config.mjs +1 -1
- package/bin/lib/fleet.mjs +65 -0
- package/package.json +1 -1
package/bin/lib/claude.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
346
|
+
emit(s);
|
|
223
347
|
};
|
|
224
348
|
child.stdout.on('data', onChunk);
|
|
225
349
|
child.stderr.on('data', onChunk);
|
package/bin/lib/config.mjs
CHANGED
|
@@ -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.
|
|
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.
|
|
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": {
|