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.
@@ -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
- // The speaker is the tab's OWNER — the same person who owns this machine —
402
- // so this is the one prompt whose author is fully trusted. The fence stays
403
- // anyway: it costs nothing and keeps the shape identical everywhere, and repo
404
- // content this turn READS is as untrusted as ever.
405
- `Continue the session${sessionName ? ` "${sessionName}"` : ''}.\n\n` +
406
- `SESSION ID (pass this to stream_session_turn / update_session): ${sessionId}\n\n` +
407
- `${fence('WHO IS TALKING', askedByName || 'the tab owner')}\n\n` +
408
- `${fence('WHAT THEY SAID', message)}\n\n` +
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
- `Continue the session${sessionName ? ` "${sessionName}"` : ''}.\n\n` +
415
- `${fence('WHO IS TALKING', askedByName || 'the tab owner')}\n\n` +
416
- `${fence('WHAT THEY SAID', message)}\n\n` +
417
- `Reply with your complete report when the work is done.`;
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 = [] }) =>
@@ -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 { FLEET_URL, FLEET_TOKEN, USER_AGENT, REFRESH_BEFORE_SECONDS } from './config.mjs';
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 d = worktreeDiff(join(baseDir, 'sessions', sessionId), baseRef);
331
- return d ? { sessionId, ...d } : null;
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.51.2",
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": {