flowviant 0.51.2 → 0.52.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.
@@ -197,7 +197,7 @@ const oneLine = (s, n = 160) => String(s).replace(/\s+/g, ' ').trim().slice(0, n
197
197
  // every intermediate text block still NARRATES, but only the final `result`
198
198
  // event contributes text — otherwise the same sentences arrive twice, once as
199
199
  // they stream and once in the result, and the tab posts the duplicate.
200
- function handleStreamLine(line, { cwd, emit, onActivity, appendText, answerFromResult }) {
200
+ function handleStreamLine(line, { cwd, emit, onActivity, appendText, answerFromResult, onInit }) {
201
201
  let ev;
202
202
  try {
203
203
  ev = JSON.parse(line);
@@ -224,6 +224,19 @@ function handleStreamLine(line, { cwd, emit, onActivity, appendText, answerFromR
224
224
  push(humanizeToolUse(b.name, b.input || {}, cwd));
225
225
  }
226
226
  }
227
+ } else if (ev.type === 'system' && ev.subtype === 'init') {
228
+ // WHAT THIS MACHINE'S CLI CAN BE ASKED FOR BY NAME. The init event is the
229
+ // CLI's OWN answer — it has already resolved personal skills, this repo's
230
+ // skills, plugins and whatever the project settings enable or disable — so
231
+ // reading it costs nothing and cannot drift the way a `~/.claude/skills`
232
+ // scan of our own would. `skills` (rather than `slash_commands`) is the
233
+ // deliberate narrowing: the 50-odd commands beside it are the CLI's own
234
+ // interactive furniture (/clear, /model, /compact), and offering those in a
235
+ // relayed tab would be an offer wired to nothing.
236
+ //
237
+ // Only ever REPORTED, never enforced. Flowviant does not decide what your
238
+ // Claude can do; it relays what your Claude said it has.
239
+ if (Array.isArray(ev.skills)) onInit?.({ skills: ev.skills.map(String) });
227
240
  } else if (ev.type === 'result') {
228
241
  // The final assistant text (carries WIKI_DONE / REGROUND_DONE).
229
242
  if (typeof ev.result === 'string') appendText(ev.result + '\n');
@@ -247,7 +260,7 @@ function handleStreamLine(line, { cwd, emit, onActivity, appendText, answerFromR
247
260
  // returned string for sentinel detection, and each activity is handed to
248
261
  // `onActivity` so the caller can forward progress. Build-agent turns leave it
249
262
  // off and keep the raw text passthrough + line sentinels.
250
- export function runTurn({ prompt, resume, system, cwd, mcpConfig, mcpArgs, mcpEnv, runtime = 'claude', label, onSpawn, streamJson, answerFromResult, onActivity, onThreadId, wikiPerm, readOnly, planPerm, vaultDir, resultSchemaArgs, model, effort, adoptResumeId, resumeThreadId, resumeConversationId }) {
263
+ export function runTurn({ prompt, resume, system, cwd, mcpConfig, mcpArgs, mcpEnv, runtime = 'claude', label, onSpawn, streamJson, answerFromResult, onActivity, onInit, onThreadId, wikiPerm, readOnly, planPerm, vaultDir, resultSchemaArgs, model, effort, adoptResumeId, resumeThreadId, resumeConversationId }) {
251
264
  return new Promise((resolve) => {
252
265
  const rt = runtimeById(runtime);
253
266
  if (!rt.args) {
@@ -359,7 +372,7 @@ export function runTurn({ prompt, resume, system, cwd, mcpConfig, mcpArgs, mcpEn
359
372
  /** One line of the child's stdout, in whichever dialect it speaks. */
360
373
  const onLine = (line) => {
361
374
  if (!rt.parse)
362
- return handleStreamLine(line, { cwd, emit, onActivity, appendText, answerFromResult });
375
+ return handleStreamLine(line, { cwd, emit, onActivity, appendText, answerFromResult, onInit });
363
376
  const ev = rt.parse(line, cwd);
364
377
  if (!ev) return;
365
378
  // The conversation id, when the runtime announces one (codex's
package/bin/lib/fleet.mjs CHANGED
@@ -73,7 +73,7 @@ import {
73
73
  } from './env.mjs';
74
74
  import { processDeployJobs, reportDeployConfig } from './deploy.mjs';
75
75
  import { machineSnapshot } from './resources.mjs';
76
- import { detectRuntimes, pickRuntimeFor, RUNTIMES } from './runtimes.mjs';
76
+ import { detectRuntimes, knownSkills, pickRuntimeFor, RUNTIMES } from './runtimes.mjs';
77
77
  import { createWorkManager } from './work.mjs';
78
78
  import { scanLocalSessions } from './localSessions.mjs';
79
79
 
@@ -118,6 +118,21 @@ async function fetchRoster(haveIds) {
118
118
  } catch {
119
119
  /* detection is best-effort — a probe must never fail the poll */
120
120
  }
121
+ // WHAT THE CLI CAN BE ASKED FOR BY NAME, so the composer can autocomplete a
122
+ // `/` the way the terminal does. Learned from the init event of a turn we
123
+ // already ran (runtimes.mjs) — never probed, because spawning a CLI to fill a
124
+ // dropdown would spend the operator's quota on an affordance.
125
+ //
126
+ // NOT SENT until a turn has taught us: absent means "no turn has run here
127
+ // yet", and the app renders no menu rather than asserting this machine has no
128
+ // skills. An empty report, though, IS a fact and is sent as such — hence the
129
+ // null check rather than a truthiness check on the array.
130
+ try {
131
+ const skills = knownSkills();
132
+ if (skills !== null) url.searchParams.set('skills', skills.join(','));
133
+ } catch {
134
+ /* best-effort — the poll must never fail on a readout */
135
+ }
121
136
  // Env-sync identity + materialized version (the Settings "env vN" chip).
122
137
  try {
123
138
  for (const [k, v] of Object.entries(await envQueryParams())) {
@@ -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
@@ -41,7 +41,7 @@ import {
41
41
  WORK_TURN_KICKOFF_PLAIN,
42
42
  } from './prompts.mjs';
43
43
  import { materializeInto, excludeInWorktree, scrub as envScrub } from './env.mjs';
44
- import { detectRuntimes, canRun, RUNTIMES } from './runtimes.mjs';
44
+ import { detectRuntimes, canRun, recordSkills, RUNTIMES } from './runtimes.mjs';
45
45
  import { isTerminalSessionLive, isAgyConversationLive } from './localSessions.mjs';
46
46
  import { worktreeDiff } from './worktreeDiff.mjs';
47
47
  import { homedir } from 'node:os';
@@ -1353,6 +1353,12 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
1353
1353
  streamJson: true,
1354
1354
  answerFromResult: true,
1355
1355
  onActivity: (a) => narrator.line(a?.label),
1356
+ // What this CLI says it can be asked for by name. Harvested off
1357
+ // the init event the stream already carries — no probe, no scan,
1358
+ // no extra spawn — and reported on the next roster poll so the
1359
+ // composer can autocomplete a `/`. See runtimes.mjs for why it is
1360
+ // learned from a turn rather than looked up.
1361
+ onInit: (i) => recordSkills(i.skills),
1356
1362
  cwd: dir.wt,
1357
1363
  mcpArgs: mcp.args,
1358
1364
  mcpEnv: mcp.env,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.51.2",
3
+ "version": "0.52.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": {