flowviant 0.51.1 → 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
@@ -60,6 +60,7 @@ import {
60
60
  REGROUND_KICKOFF,
61
61
  } from './claude.mjs';
62
62
  import { reapOrphanPreviews } from './preview.mjs';
63
+ import { acquireInstanceLock } from './instance.mjs';
63
64
  import { preflight } from './preflight.mjs';
64
65
  import { connectStream } from './stream.mjs';
65
66
  import { ensureVault, syncVault } from './vault.mjs';
@@ -72,7 +73,7 @@ import {
72
73
  } from './env.mjs';
73
74
  import { processDeployJobs, reportDeployConfig } from './deploy.mjs';
74
75
  import { machineSnapshot } from './resources.mjs';
75
- import { detectRuntimes, pickRuntimeFor, RUNTIMES } from './runtimes.mjs';
76
+ import { detectRuntimes, knownSkills, pickRuntimeFor, RUNTIMES } from './runtimes.mjs';
76
77
  import { createWorkManager } from './work.mjs';
77
78
  import { scanLocalSessions } from './localSessions.mjs';
78
79
 
@@ -117,6 +118,21 @@ async function fetchRoster(haveIds) {
117
118
  } catch {
118
119
  /* detection is best-effort — a probe must never fail the poll */
119
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
+ }
120
136
  // Env-sync identity + materialized version (the Settings "env vN" chip).
121
137
  try {
122
138
  for (const [k, v] of Object.entries(await envQueryParams())) {
@@ -261,6 +277,32 @@ export async function runFleetDaemon() {
261
277
  );
262
278
  info(`server · ${FLEET_URL}`);
263
279
  console.log('');
280
+
281
+ // ONE DAEMON PER CREDENTIAL. Before preflight, before the preview reap,
282
+ // before anything with a side effect — a second daemon must not so much as
283
+ // install a CLI or clear a registry on its way to being refused. Keyed on the
284
+ // credential rather than the repo, because two checkouts on one credential is
285
+ // the SAME project served twice, and the worst version of this: their session
286
+ // worktrees are in different directories, so the per-turn lock cannot even see
287
+ // across them. See instance.mjs for why that lock is not enough on its own.
288
+ const instance = acquireInstanceLock(FLEET_TOKEN, repoRoot);
289
+ if (!instance.ok) {
290
+ const h = instance.holder;
291
+ console.log('');
292
+ fail('a flowviant daemon is already running for this credential.');
293
+ if (h?.pid) info(`holder · pid ${h.pid}${h.repoRoot ? ` in ${h.repoRoot}` : ''}`);
294
+ // The two-checkouts case is the one nobody spots on their own: both tabs
295
+ // look healthy, and the damage is doubled cards and doubled edits in a repo
296
+ // you are not looking at. Name the other repo when it is a different one.
297
+ if (h?.repoRoot && h.repoRoot !== repoRoot)
298
+ warn('that is a DIFFERENT checkout — one credential serves one project, so both would answer the same tabs.');
299
+ note('stop the other one first, or run this one with FLOWVIANT_ALLOW_MULTI=1 if you know what you are doing.');
300
+ console.log('');
301
+ process.exit(1);
302
+ }
303
+ if (instance.unguarded)
304
+ warn('could not take the single-instance lock (unwritable ~/.flowviant) — running unguarded');
305
+
264
306
  await preflight({ needGit: true });
265
307
  // Kill any preview dev-server/tunnel groups a previously-crashed daemon left
266
308
  // running (detached children survive an ungraceful exit) before we start fresh.
@@ -0,0 +1,152 @@
1
+ /**
2
+ * ONE DAEMON PER CREDENTIAL, refused at startup.
3
+ *
4
+ * WHY THIS EXISTS. Nothing stopped two daemons before, and the server hands
5
+ * work out by READING, never claiming: `listWorkTurnJobs` selects every pending
6
+ * turn for the fleet token, `listShipJobs` reads a flag. So two daemons on one
7
+ * credential are offered the SAME turn — and the ProjectRoom nudges every
8
+ * connected daemon socket at once, so they do not even drift out of phase.
9
+ *
10
+ * The per-worktree `flowviant-turn.lock` cannot save it. That lock is written
11
+ * AFTER the work token is minted and the attachments are fetched — a window
12
+ * containing a network round trip — so both daemons clear the check and both
13
+ * spawn a CLI into one held conversation. It was built for a RESTARTED daemon
14
+ * (its own comment says so, work.mjs), where the holder is already live when
15
+ * the successor looks; it was never a concurrency primitive.
16
+ *
17
+ * What the duplicate run costs, all of it invisible in the tab: two Claudes
18
+ * editing one worktree, two cards from one `file_card` (no idempotency key),
19
+ * the session write budget spent twice, quota spent twice — and then exactly
20
+ * ONE answer survives, because `settleWorkTurn` is atomic. The side effects
21
+ * land twice and the transcript shows one turn.
22
+ *
23
+ * KEYED ON THE CREDENTIAL, NOT THE REPO. The credential is stored once, at
24
+ * ~/.flowviant/credentials.json, so `flowviant` in two DIFFERENT checkouts is
25
+ * still one project served twice — and that case is strictly worse, because the
26
+ * two daemons have different worktree roots and the turn lock cannot even see
27
+ * across them. Keying on the token catches both, and still lets a second
28
+ * credential run a second project on the same machine.
29
+ *
30
+ * IT FAILS OPEN. A home directory we cannot write to is not a reason to refuse
31
+ * to start; it is a reason to say so and carry on unguarded.
32
+ */
33
+
34
+ import { closeSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync, writeSync } from 'node:fs';
35
+ import { homedir } from 'node:os';
36
+ import { join } from 'node:path';
37
+ import { createHash } from 'node:crypto';
38
+
39
+ /** Deliberately a HASH: a credential must never become a filename. */
40
+ export function instanceLockPath(fleetToken) {
41
+ const key = createHash('sha256').update(String(fleetToken || 'anon')).digest('hex').slice(0, 12);
42
+ return join(homedir(), '.flowviant', `daemon-${key}.lock`);
43
+ }
44
+
45
+ /** Signal 0 — a liveness probe, not a kill. EPERM means alive and not ours. */
46
+ function alive(pid) {
47
+ if (!Number.isInteger(pid) || pid <= 0) return false;
48
+ try {
49
+ process.kill(pid, 0);
50
+ return true;
51
+ } catch (e) {
52
+ return e.code === 'EPERM';
53
+ }
54
+ }
55
+
56
+ function readHolder(path) {
57
+ try {
58
+ const v = JSON.parse(readFileSync(path, 'utf8'));
59
+ return v && Number.isInteger(v.pid) && v.pid > 0 ? v : null;
60
+ } catch {
61
+ return null; // absent, truncated, or half-written — treat as no holder
62
+ }
63
+ }
64
+
65
+ const record = (repoRoot) =>
66
+ JSON.stringify({ pid: process.pid, repoRoot, startedAt: new Date().toISOString() });
67
+
68
+ /**
69
+ * Take the lock, or report who holds it.
70
+ *
71
+ * Returns `{ ok: true, release }` — call `release()` to drop it, and it is
72
+ * already wired to process exit — or `{ ok: false, holder }` with the other
73
+ * daemon's pid and repo so the caller can say something useful.
74
+ *
75
+ * `wx` is the whole guarantee: create-exclusive is one atomic syscall, which is
76
+ * the property the turn lock's check-then-write does not have.
77
+ */
78
+ export function acquireInstanceLock(fleetToken, repoRoot) {
79
+ if (process.env.FLOWVIANT_ALLOW_MULTI === '1') return { ok: true, release: () => {} };
80
+ const path = instanceLockPath(fleetToken);
81
+ try {
82
+ mkdirSync(join(homedir(), '.flowviant'), { recursive: true });
83
+ } catch {
84
+ return { ok: true, release: () => {}, unguarded: true };
85
+ }
86
+
87
+ // Two passes at most: one to clear a stale holder, one to take the lock. A
88
+ // loop here would spin against a peer that keeps re-taking it.
89
+ for (let attempt = 0; attempt < 2; attempt++) {
90
+ let fd;
91
+ try {
92
+ fd = openSync(path, 'wx');
93
+ } catch (e) {
94
+ if (e.code !== 'EEXIST') return { ok: true, release: () => {}, unguarded: true };
95
+ const holder = readHolder(path);
96
+ if (!holder || !alive(holder.pid)) {
97
+ // A crashed daemon's leftover. Clear it and take it on the next pass.
98
+ try {
99
+ rmSync(path, { force: true });
100
+ } catch {
101
+ return { ok: true, release: () => {}, unguarded: true };
102
+ }
103
+ continue;
104
+ }
105
+ // OUR OWN PARENT, which is not a second daemon — it is this one, mid
106
+ // re-exec. The SELF-UPDATE is the case: a live daemon holding this lock
107
+ // installs a new version, spawns it, and stays alive as a proxy awaiting
108
+ // it (update.mjs), so the successor's ppid IS the holder. Refusing there
109
+ // would brick every auto-update. Adopt instead; the parent's release is
110
+ // ownership-checked, so it will not delete the lock it handed over.
111
+ // (`flowviant login` also proxies a child, but that parent never reached
112
+ // the daemon and holds nothing — the child simply acquires.)
113
+ if (holder.pid === process.ppid) {
114
+ try {
115
+ writeFileSync(path, record(repoRoot));
116
+ } catch {
117
+ return { ok: true, release: () => {}, unguarded: true };
118
+ }
119
+ return { ok: true, release: makeRelease(path) };
120
+ }
121
+ return { ok: false, holder };
122
+ }
123
+ try {
124
+ writeSync(fd, record(repoRoot));
125
+ } finally {
126
+ closeSync(fd);
127
+ }
128
+ return { ok: true, release: makeRelease(path) };
129
+ }
130
+ // Both passes lost to something re-creating the file — assume a peer.
131
+ return { ok: false, holder: readHolder(path) };
132
+ }
133
+
134
+ /** Release ONLY what we still own: a successor that adopted the lock (see the
135
+ * ppid branch) must not have it deleted out from under it when we exit. */
136
+ function makeRelease(path) {
137
+ let released = false;
138
+ const release = () => {
139
+ if (released) return;
140
+ released = true;
141
+ const holder = readHolder(path);
142
+ if (holder && holder.pid !== process.pid) return; // handed over — leave it
143
+ try {
144
+ rmSync(path, { force: true });
145
+ } catch {
146
+ /* best-effort; a stale file is cleared by the next acquire */
147
+ }
148
+ };
149
+ // 'exit' covers the SIGINT/SIGTERM handlers too — both call process.exit().
150
+ process.on('exit', release);
151
+ return release;
152
+ }
@@ -304,6 +304,12 @@ rules:
304
304
  spec is what somebody's review is about. And when list_cards says
305
305
  \`truncated\` is above zero, the queue is LONGER than the list you were
306
306
  handed — say so rather than letting a short list read as the whole board.
307
+ SAY WHAT WAITS ON WHAT. \`waitsOn\` takes the task ids a card cannot start
308
+ until, and it is what turns a feature from a heap into a sequence: the
309
+ migration before the endpoint, the endpoint before the UI, the polish last.
310
+ The Board orders and bands cards from it — READY vs WAITING — so a person who
311
+ was not in this conversation can still see where to start. Declare it while
312
+ you are decomposing, because that is the one moment anyone knows.
307
313
  11. DELIVER WITH RECEIPTS. When a card's work is committed, deliver_card with a
308
314
  one-paragraph summary and the commit shas. Delivered is ASSERTED; done is
309
315
  OBSERVED (the merge, on their word). Never claim done, and never deliver
@@ -391,24 +397,73 @@ for work, do it.
391
397
 
392
398
  Write plain Markdown for a person reading your reply in a chat tab.`;
393
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
+
394
448
  export const WORK_TURN_KICKOFF = ({ sessionId, sessionName, message, askedByName }) =>
395
- // The speaker is the tab's OWNER — the same person who owns this machine —
396
- // so this is the one prompt whose author is fully trusted. The fence stays
397
- // anyway: it costs nothing and keeps the shape identical everywhere, and repo
398
- // content this turn READS is as untrusted as ever.
399
- `Continue the session${sessionName ? ` "${sessionName}"` : ''}.\n\n` +
400
- `SESSION ID (pass this to stream_session_turn / update_session): ${sessionId}\n\n` +
401
- `${fence('WHO IS TALKING', askedByName || 'the tab owner')}\n\n` +
402
- `${fence('WHAT THEY SAID', message)}\n\n` +
403
- `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
+ });
404
457
 
405
458
  /** The plain tab's kickoff: no session id (there is no tool to pass it to)
406
459
  * and no streaming instruction — the final message is the reply. */
407
460
  export const WORK_TURN_KICKOFF_PLAIN = ({ sessionName, message, askedByName }) =>
408
- `Continue the session${sessionName ? ` "${sessionName}"` : ''}.\n\n` +
409
- `${fence('WHO IS TALKING', askedByName || 'the tab owner')}\n\n` +
410
- `${fence('WHAT THEY SAID', message)}\n\n` +
411
- `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
+ });
412
467
 
413
468
 
414
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.1",
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": {