flowviant 0.51.2 → 0.53.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/authproxy.mjs +131 -32
- package/bin/lib/claude.mjs +16 -3
- package/bin/lib/config.mjs +16 -0
- package/bin/lib/fleet.mjs +52 -16
- package/bin/lib/listeners.mjs +269 -0
- package/bin/lib/preview.mjs +294 -390
- package/bin/lib/prompts.mjs +62 -13
- package/bin/lib/runtimes.mjs +47 -0
- package/bin/lib/work.mjs +218 -4
- package/package.json +1 -1
package/bin/lib/prompts.mjs
CHANGED
|
@@ -397,24 +397,73 @@ for work, do it.
|
|
|
397
397
|
|
|
398
398
|
Write plain Markdown for a person reading your reply in a chat tab.`;
|
|
399
399
|
|
|
400
|
+
/**
|
|
401
|
+
* A LEADING SLASH COMMAND, which the CLI will only expand at position 0.
|
|
402
|
+
*
|
|
403
|
+
* Claude Code parses `/name …` as a command ONLY when it opens the prompt. Every
|
|
404
|
+
* turn here wraps the human's words in the scaffolding below, so a `/code-review`
|
|
405
|
+
* typed into a tab used to arrive on line 6 of a fenced block — inert text that
|
|
406
|
+
* looked like it should have worked. That is the product telling you no for
|
|
407
|
+
* bookkeeping reasons, which is the one thing it never does.
|
|
408
|
+
*
|
|
409
|
+
* SHAPE, NOT MEMBERSHIP. We do not check the name against the machine's skill
|
|
410
|
+
* list: that list is only learned after a turn has run (runtimes.mjs), so
|
|
411
|
+
* gating on it would make the first `/foo` of a machine's life behave
|
|
412
|
+
* differently from the second. Instead this matches what a command can LOOK
|
|
413
|
+
* like — one segment, no second slash — which leaves `/home/user/x.ts is
|
|
414
|
+
* broken` fenced as the prose it is. Measured on 2.1.238: an unknown command
|
|
415
|
+
* is treated as ordinary text, so a false positive costs nothing anyway.
|
|
416
|
+
*/
|
|
417
|
+
const LEADING_SLASH_COMMAND = /^\/[A-Za-z0-9][A-Za-z0-9_:-]*(?=\s|$)/;
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* The kickoff, in the two orders it can be written.
|
|
421
|
+
*
|
|
422
|
+
* ORDINARY: scaffolding first, the human's words fenced inside it. The speaker
|
|
423
|
+
* is the tab's OWNER — the same person who owns this machine — so this is the
|
|
424
|
+
* one prompt whose author is fully trusted. The fence stays anyway: it costs
|
|
425
|
+
* nothing and keeps the shape identical everywhere, and repo content this turn
|
|
426
|
+
* READS is as untrusted as ever.
|
|
427
|
+
*
|
|
428
|
+
* SLASH: the human's words go FIRST, verbatim and unfenced, because that is the
|
|
429
|
+
* only position the CLI expands a command from — and the scaffolding follows,
|
|
430
|
+
* LABELLED as ours so the trailing lines cannot read as more of what the person
|
|
431
|
+
* typed. The fence is what is traded away, and only for the one author already
|
|
432
|
+
* trusted above; nothing else about the turn changes.
|
|
433
|
+
*/
|
|
434
|
+
const kickoff = ({ message, askedByName, head, tail }) => {
|
|
435
|
+
const scaffold =
|
|
436
|
+
`${head}\n\n` +
|
|
437
|
+
`${fence('WHO IS TALKING', askedByName || 'the tab owner')}\n\n`;
|
|
438
|
+
if (LEADING_SLASH_COMMAND.test(message.trim()))
|
|
439
|
+
return (
|
|
440
|
+
`${message.trim()}\n\n` +
|
|
441
|
+
`---\n` +
|
|
442
|
+
`[FLOWVIANT SESSION CONTEXT — written by Flowviant, not typed by the person above]\n` +
|
|
443
|
+
`${scaffold}${tail}`
|
|
444
|
+
);
|
|
445
|
+
return `${scaffold}${fence('WHAT THEY SAID', message)}\n\n${tail}`;
|
|
446
|
+
};
|
|
447
|
+
|
|
400
448
|
export const WORK_TURN_KICKOFF = ({ sessionId, sessionName, message, askedByName }) =>
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
`Stream your reply with stream_session_turn as you work.`;
|
|
449
|
+
kickoff({
|
|
450
|
+
message,
|
|
451
|
+
askedByName,
|
|
452
|
+
head:
|
|
453
|
+
`Continue the session${sessionName ? ` "${sessionName}"` : ''}.\n\n` +
|
|
454
|
+
`SESSION ID (pass this to stream_session_turn / update_session): ${sessionId}`,
|
|
455
|
+
tail: `Stream your reply with stream_session_turn as you work.`,
|
|
456
|
+
});
|
|
410
457
|
|
|
411
458
|
/** The plain tab's kickoff: no session id (there is no tool to pass it to)
|
|
412
459
|
* and no streaming instruction — the final message is the reply. */
|
|
413
460
|
export const WORK_TURN_KICKOFF_PLAIN = ({ sessionName, message, askedByName }) =>
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
461
|
+
kickoff({
|
|
462
|
+
message,
|
|
463
|
+
askedByName,
|
|
464
|
+
head: `Continue the session${sessionName ? ` "${sessionName}"` : ''}.`,
|
|
465
|
+
tail: `Reply with your complete report when the work is done.`,
|
|
466
|
+
});
|
|
418
467
|
|
|
419
468
|
|
|
420
469
|
export const REGROUND_KICKOFF = ({ sha, title, files, vaultDir, predictedPages = [] }) =>
|
package/bin/lib/runtimes.mjs
CHANGED
|
@@ -903,3 +903,50 @@ export function detectRuntimes({ refresh = false } = {}) {
|
|
|
903
903
|
});
|
|
904
904
|
return detectedCache;
|
|
905
905
|
}
|
|
906
|
+
|
|
907
|
+
/**
|
|
908
|
+
* WHAT THE CLI SAID IT CAN BE ASKED FOR BY NAME — the machine's skills.
|
|
909
|
+
*
|
|
910
|
+
* Learned, never scanned. Claude Code's `system.init` event names its own
|
|
911
|
+
* resolved skill set on every stream-json turn, and the daemon already parses
|
|
912
|
+
* that stream (claude.mjs), so this costs nothing and is authoritative: it has
|
|
913
|
+
* plugins, this repo's `.claude/skills`, and whatever project settings enabled
|
|
914
|
+
* or disabled already folded in. A `~/.claude/skills` scan of our own would be
|
|
915
|
+
* a second implementation of the CLI's resolution rules, and would drift.
|
|
916
|
+
*
|
|
917
|
+
* THE PRICE OF LEARNING RATHER THAN PROBING is that a machine which has not run
|
|
918
|
+
* a turn yet knows nothing, and says nothing. That is the honest answer: the
|
|
919
|
+
* app renders no menu rather than an empty one, and a slash typed into a tab
|
|
920
|
+
* still reaches the CLI either way — the menu is an autocomplete, never a gate.
|
|
921
|
+
* We do NOT probe for it: a `claude -p` run purely to populate a dropdown would
|
|
922
|
+
* spend the operator's quota on a UI affordance.
|
|
923
|
+
*
|
|
924
|
+
* PER MACHINE, not per session. Every session worktree is a checkout of the one
|
|
925
|
+
* repo this daemon serves, so project skills are identical across tabs and
|
|
926
|
+
* personal skills are machine-wide. Last turn wins, which is what makes a skill
|
|
927
|
+
* added mid-run show up on the next poll.
|
|
928
|
+
*/
|
|
929
|
+
let skillsCache = null;
|
|
930
|
+
|
|
931
|
+
/** Claude Code's own names: letters, digits, dash, underscore, and the colon a
|
|
932
|
+
* plugin skill wears (`plugin:skill`). Anything else is not a name we could
|
|
933
|
+
* put after a `/` anyway, so it is dropped rather than relayed as garbage. */
|
|
934
|
+
const SKILL_NAME = /^[A-Za-z0-9][A-Za-z0-9_:-]{0,63}$/;
|
|
935
|
+
|
|
936
|
+
/** Record what a turn's init event reported. Bounded and sorted so the poll's
|
|
937
|
+
* query param has a stable length and a stable order — an unstable order would
|
|
938
|
+
* make the server write a "change" on every single poll. */
|
|
939
|
+
export function recordSkills(names) {
|
|
940
|
+
if (!Array.isArray(names)) return;
|
|
941
|
+
const clean = [...new Set(names.map((n) => String(n).trim()).filter((n) => SKILL_NAME.test(n)))]
|
|
942
|
+
.sort()
|
|
943
|
+
.slice(0, 100);
|
|
944
|
+
// An empty report is a FACT (a machine with no skills installed), so it is
|
|
945
|
+
// recorded as []. Never conflated with null, which stays "no turn has run".
|
|
946
|
+
skillsCache = clean;
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
/** What to send on the roster poll — null until a turn has taught us. */
|
|
950
|
+
export function knownSkills() {
|
|
951
|
+
return skillsCache;
|
|
952
|
+
}
|
package/bin/lib/work.mjs
CHANGED
|
@@ -30,8 +30,16 @@ import {
|
|
|
30
30
|
} from 'node:fs';
|
|
31
31
|
import { execFileSync } from 'node:child_process';
|
|
32
32
|
import { join, dirname } from 'node:path';
|
|
33
|
-
import {
|
|
33
|
+
import {
|
|
34
|
+
FLEET_URL,
|
|
35
|
+
FLEET_TOKEN,
|
|
36
|
+
USER_AGENT,
|
|
37
|
+
REFRESH_BEFORE_SECONDS,
|
|
38
|
+
DAEMON_INSTANCE,
|
|
39
|
+
} from './config.mjs';
|
|
34
40
|
import { git, gitRaw, splitNul, baseBranchName, isSafePathSegment } from './git.mjs';
|
|
41
|
+
import { listenersIn } from './listeners.mjs';
|
|
42
|
+
import { openTunnel } from './preview.mjs';
|
|
35
43
|
import { c, note, ok, warn } from './ui.mjs';
|
|
36
44
|
import { mcpFor, runTurn } from './claude.mjs';
|
|
37
45
|
import {
|
|
@@ -41,7 +49,7 @@ import {
|
|
|
41
49
|
WORK_TURN_KICKOFF_PLAIN,
|
|
42
50
|
} from './prompts.mjs';
|
|
43
51
|
import { materializeInto, excludeInWorktree, scrub as envScrub } from './env.mjs';
|
|
44
|
-
import { detectRuntimes, canRun, RUNTIMES } from './runtimes.mjs';
|
|
52
|
+
import { detectRuntimes, canRun, recordSkills, RUNTIMES } from './runtimes.mjs';
|
|
45
53
|
import { isTerminalSessionLive, isAgyConversationLive } from './localSessions.mjs';
|
|
46
54
|
import { worktreeDiff } from './worktreeDiff.mjs';
|
|
47
55
|
import { homedir } from 'node:os';
|
|
@@ -92,6 +100,8 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
92
100
|
const ACTIVITY_URL = FLEET_URL.replace(/\/agents\/?$/, '/session-activity');
|
|
93
101
|
const WORKTREES_URL = FLEET_URL.replace(/\/agents\/?$/, '/session-worktrees');
|
|
94
102
|
const DIFF_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/diff-done');
|
|
103
|
+
const PREVIEW_CLAIM_URL = FLEET_URL.replace(/\/agents\/?$/, '/preview-claim');
|
|
104
|
+
const PREVIEW_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/preview-done');
|
|
95
105
|
const ATTACHMENT_URL = FLEET_URL.replace(/\/agents\/?$/, '/attachment');
|
|
96
106
|
const workAnswering = new Set(); // turn ids currently queued/running here
|
|
97
107
|
const workAttempts = new Map(); // turn id -> completed runTurn attempts
|
|
@@ -327,8 +337,19 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
327
337
|
};
|
|
328
338
|
const sessionWorktreeReport = (sessionId) => {
|
|
329
339
|
if (!isSafePathSegment(sessionId)) return null;
|
|
330
|
-
const
|
|
331
|
-
|
|
340
|
+
const wt = join(baseDir, 'sessions', sessionId);
|
|
341
|
+
const d = worktreeDiff(wt, baseRef);
|
|
342
|
+
if (!d) return null;
|
|
343
|
+
// WHAT IS LISTENING in this worktree, attributed by the CWD of the process
|
|
344
|
+
// holding the socket. It rides the sweep the daemon already makes rather
|
|
345
|
+
// than taking a beat of its own, exactly as the commit trailers do — and
|
|
346
|
+
// like them it needs no version floor, because it is a daemon→server report
|
|
347
|
+
// on an endpoint that already exists. An older server ignores the key.
|
|
348
|
+
//
|
|
349
|
+
// The browser NEVER names a directory and never names a port this did not
|
|
350
|
+
// report: ports are global to a box and a worktree is not, so this
|
|
351
|
+
// measurement is the security boundary for the whole preview feature.
|
|
352
|
+
return { sessionId, ...d, listening: listenersIn(wt) };
|
|
332
353
|
};
|
|
333
354
|
/** One session, now — called after its turn settles. */
|
|
334
355
|
const reportSessionWorktree = async (sessionId) => {
|
|
@@ -403,6 +424,189 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
403
424
|
/* the row stays pending and expires; the next click re-requests */
|
|
404
425
|
}
|
|
405
426
|
};
|
|
427
|
+
// ── SESSION PREVIEWS ──────────────────────────────────────────────────────
|
|
428
|
+
//
|
|
429
|
+
// Share the dev server the DRIVER is already running in their tab, behind a
|
|
430
|
+
// generated password, on a quick tunnel. This daemon never starts an app: the
|
|
431
|
+
// deleted live-preview feature ran a repo-declared command through a shell,
|
|
432
|
+
// and that is the reason it is deleted. Here the human runs their own server,
|
|
433
|
+
// `listenersIn` notices it, and this only ever wraps a port that measurement
|
|
434
|
+
// already named for that session.
|
|
435
|
+
//
|
|
436
|
+
// CLAIM BEFORE ACTING. Two daemons legitimately share one fleet credential —
|
|
437
|
+
// the case `machineDaemonsDisagree` exists because it happens, and the 0.51.2
|
|
438
|
+
// instance lock is blind to an OLDER peer — so both are handed the same job
|
|
439
|
+
// array. Both opening a tunnel leaves a public hostname alive that nobody
|
|
440
|
+
// owns and nobody can tear down, because only the lease holder can settle the
|
|
441
|
+
// row. `processDiffJobs` gets away without this because running `git show`
|
|
442
|
+
// twice costs nothing.
|
|
443
|
+
const livePreviews = new Map(); // sessionId -> { port, url, stop }
|
|
444
|
+
const previewClaiming = new Set(); // sessionIds mid-claim on this tick
|
|
445
|
+
|
|
446
|
+
const postPreview = async (body) => {
|
|
447
|
+
try {
|
|
448
|
+
await fetch(PREVIEW_DONE_URL, {
|
|
449
|
+
method: 'POST',
|
|
450
|
+
headers: {
|
|
451
|
+
Authorization: `Bearer ${FLEET_TOKEN}`,
|
|
452
|
+
'User-Agent': USER_AGENT,
|
|
453
|
+
'Content-Type': 'application/json',
|
|
454
|
+
},
|
|
455
|
+
signal: AbortSignal.timeout(30_000),
|
|
456
|
+
body: JSON.stringify({ ...body, instance: DAEMON_INSTANCE }),
|
|
457
|
+
});
|
|
458
|
+
} catch {
|
|
459
|
+
/* the row stops being confirmed and reads as ended — which is true */
|
|
460
|
+
}
|
|
461
|
+
};
|
|
462
|
+
|
|
463
|
+
const claimPreview = async (sessionId) => {
|
|
464
|
+
try {
|
|
465
|
+
const res = await fetch(PREVIEW_CLAIM_URL, {
|
|
466
|
+
method: 'POST',
|
|
467
|
+
headers: {
|
|
468
|
+
Authorization: `Bearer ${FLEET_TOKEN}`,
|
|
469
|
+
'User-Agent': USER_AGENT,
|
|
470
|
+
'Content-Type': 'application/json',
|
|
471
|
+
},
|
|
472
|
+
signal: AbortSignal.timeout(15_000),
|
|
473
|
+
body: JSON.stringify({ sessionId, instance: DAEMON_INSTANCE }),
|
|
474
|
+
});
|
|
475
|
+
const j = await res.json().catch(() => null);
|
|
476
|
+
return j?.data?.claimed === true;
|
|
477
|
+
} catch {
|
|
478
|
+
return false; // could not claim → do nothing at all. The other daemon may have.
|
|
479
|
+
}
|
|
480
|
+
};
|
|
481
|
+
|
|
482
|
+
/** Tear one down here, and say so. `reason` is why, stored server-side rather
|
|
483
|
+
* than inferred: "the origin stopped listening" and "the owner pressed Stop"
|
|
484
|
+
* are different sentences to a teammate holding a phone. */
|
|
485
|
+
const stopPreview = async (sessionId, reason) => {
|
|
486
|
+
const live = livePreviews.get(sessionId);
|
|
487
|
+
livePreviews.delete(sessionId);
|
|
488
|
+
if (live) {
|
|
489
|
+
try {
|
|
490
|
+
live.stop();
|
|
491
|
+
} catch {
|
|
492
|
+
/* best-effort */
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
await postPreview({ sessionId, ended: true, endedReason: reason });
|
|
496
|
+
};
|
|
497
|
+
|
|
498
|
+
const processPreviewJobs = (jobs) => {
|
|
499
|
+
if (!Array.isArray(jobs) || jobs.length === 0) return;
|
|
500
|
+
for (const job of jobs.slice(0, 5)) {
|
|
501
|
+
const sessionId = String(job?.sessionId || '');
|
|
502
|
+
const port = Number(job?.port);
|
|
503
|
+
if (!isSafePathSegment(sessionId)) continue;
|
|
504
|
+
|
|
505
|
+
if (job?.action === 'stop') {
|
|
506
|
+
if (previewClaiming.has(sessionId)) continue;
|
|
507
|
+
previewClaiming.add(sessionId);
|
|
508
|
+
void stopPreview(sessionId, 'stopped').finally(() => previewClaiming.delete(sessionId));
|
|
509
|
+
continue;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
if (!Number.isInteger(port) || port <= 0 || port > 65535) continue;
|
|
513
|
+
// Already serving exactly this. Re-opening would replace a working URL
|
|
514
|
+
// somebody may be looking at right now.
|
|
515
|
+
if (livePreviews.get(sessionId)?.port === port) continue;
|
|
516
|
+
if (previewClaiming.has(sessionId)) continue;
|
|
517
|
+
previewClaiming.add(sessionId);
|
|
518
|
+
|
|
519
|
+
void (async () => {
|
|
520
|
+
try {
|
|
521
|
+
if (!(await claimPreview(sessionId))) return; // somebody else has it
|
|
522
|
+
const wt = join(baseDir, 'sessions', sessionId);
|
|
523
|
+
// RE-VALIDATE the attribution here, not just the liveness. The server
|
|
524
|
+
// checked this port against a report up to a minute old; more
|
|
525
|
+
// importantly, checking `listenersIn` again is what keeps the answer
|
|
526
|
+
// to "whose port is this" on the machine that can actually see it.
|
|
527
|
+
const measured = listenersIn(wt).some((l) => l.port === port);
|
|
528
|
+
if (!measured) {
|
|
529
|
+
await postPreview({
|
|
530
|
+
sessionId,
|
|
531
|
+
error: `nothing is listening on port ${port} in this worktree.`,
|
|
532
|
+
});
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
// Replace anything this session already had — one tab, one door.
|
|
536
|
+
const prev = livePreviews.get(sessionId);
|
|
537
|
+
if (prev) {
|
|
538
|
+
try {
|
|
539
|
+
prev.stop();
|
|
540
|
+
} catch {
|
|
541
|
+
/* best-effort */
|
|
542
|
+
}
|
|
543
|
+
livePreviews.delete(sessionId);
|
|
544
|
+
}
|
|
545
|
+
const t = await openTunnel({
|
|
546
|
+
port,
|
|
547
|
+
log: (m) => note(`preview ${sessionId.slice(0, 8)}: ${m}`),
|
|
548
|
+
// The origin died under a live tunnel. cloudflared happily outlives
|
|
549
|
+
// a dead dev server and the gate answers a dead origin with 502, so
|
|
550
|
+
// without this the app would print "live" over a 502.
|
|
551
|
+
onDead: () => {
|
|
552
|
+
livePreviews.delete(sessionId);
|
|
553
|
+
void postPreview({ sessionId, ended: true, endedReason: 'origin_gone' });
|
|
554
|
+
},
|
|
555
|
+
});
|
|
556
|
+
if (t.error) {
|
|
557
|
+
await postPreview({ sessionId, error: t.error });
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
livePreviews.set(sessionId, { port, url: t.url, stop: t.stop });
|
|
561
|
+
await postPreview({ sessionId, url: t.url, user: t.user, password: t.password });
|
|
562
|
+
} finally {
|
|
563
|
+
previewClaiming.delete(sessionId);
|
|
564
|
+
}
|
|
565
|
+
})();
|
|
566
|
+
}
|
|
567
|
+
};
|
|
568
|
+
|
|
569
|
+
/** The sessionIds this machine is still serving — sent on the poll so the
|
|
570
|
+
* server can tell a live share from one whose machine went away. Silence
|
|
571
|
+
* must never read as "live". */
|
|
572
|
+
const livePreviewIds = () => [...livePreviews.keys()];
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* The tab closed (or the server stopped listing it). Ordered BEFORE
|
|
576
|
+
* `retireWorkSessions`, and that ordering is load-bearing: `git worktree
|
|
577
|
+
* remove` under a running dev server reintroduces the stale-server bug — on
|
|
578
|
+
* Linux the process keeps serving bytes from open file handles in a directory
|
|
579
|
+
* that no longer exists, which shows a human the wrong thing without erroring
|
|
580
|
+
* anywhere.
|
|
581
|
+
*/
|
|
582
|
+
const retirePreviews = (activeIds) => {
|
|
583
|
+
// Same guard `retireWorkSessions` keeps: a roster response missing the
|
|
584
|
+
// field is an older server, not a close, and must not tear down every live
|
|
585
|
+
// share at once.
|
|
586
|
+
if (!Array.isArray(activeIds)) return;
|
|
587
|
+
const live = new Set(activeIds);
|
|
588
|
+
for (const sessionId of [...livePreviews.keys()]) {
|
|
589
|
+
if (live.has(sessionId)) continue;
|
|
590
|
+
if (previewClaiming.has(sessionId)) continue;
|
|
591
|
+
previewClaiming.add(sessionId);
|
|
592
|
+
void stopPreview(sessionId, 'tab_closed').finally(() => previewClaiming.delete(sessionId));
|
|
593
|
+
}
|
|
594
|
+
};
|
|
595
|
+
|
|
596
|
+
/** Daemon shutdown. Detached tunnels survive our exit by design, so leaving
|
|
597
|
+
* them would strand a public hostname until the box rebooted — the exact
|
|
598
|
+
* case `reapOrphanPreviews` exists to clean up after an UNgraceful death. */
|
|
599
|
+
const shutdownPreviews = () => {
|
|
600
|
+
for (const [, live] of livePreviews) {
|
|
601
|
+
try {
|
|
602
|
+
live.stop();
|
|
603
|
+
} catch {
|
|
604
|
+
/* best-effort */
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
livePreviews.clear();
|
|
608
|
+
};
|
|
609
|
+
|
|
406
610
|
const processDiffJobs = (jobs) => {
|
|
407
611
|
if (!Array.isArray(jobs) || jobs.length === 0) return;
|
|
408
612
|
for (const job of jobs.slice(0, 5)) {
|
|
@@ -1353,6 +1557,12 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
1353
1557
|
streamJson: true,
|
|
1354
1558
|
answerFromResult: true,
|
|
1355
1559
|
onActivity: (a) => narrator.line(a?.label),
|
|
1560
|
+
// What this CLI says it can be asked for by name. Harvested off
|
|
1561
|
+
// the init event the stream already carries — no probe, no scan,
|
|
1562
|
+
// no extra spawn — and reported on the next roster poll so the
|
|
1563
|
+
// composer can autocomplete a `/`. See runtimes.mjs for why it is
|
|
1564
|
+
// learned from a turn rather than looked up.
|
|
1565
|
+
onInit: (i) => recordSkills(i.skills),
|
|
1356
1566
|
cwd: dir.wt,
|
|
1357
1567
|
mcpArgs: mcp.args,
|
|
1358
1568
|
mcpEnv: mcp.env,
|
|
@@ -1854,6 +2064,10 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
1854
2064
|
processWorkTurns,
|
|
1855
2065
|
processShipJobs,
|
|
1856
2066
|
processDiffJobs,
|
|
2067
|
+
processPreviewJobs,
|
|
2068
|
+
livePreviewIds,
|
|
2069
|
+
retirePreviews,
|
|
2070
|
+
shutdownPreviews,
|
|
1857
2071
|
retireWorkSessions,
|
|
1858
2072
|
reportWorktrees,
|
|
1859
2073
|
shutdownWork,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flowviant",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.53.0",
|
|
4
4
|
"description": "Run your own coding CLIs as build agents for Flowviant \u2014 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": {
|