flowviant 0.47.0 → 0.47.2

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/README.md CHANGED
@@ -68,6 +68,31 @@ Flowviant only stores the tunnel URL; your browser talks to it directly.
68
68
  | `FLOWVIANT_TOKENS=a,b,c` | a static fleet, one worktree each |
69
69
  | `FLOWVIANT_SAFE=1` | restrict the toolset instead of running unattended |
70
70
 
71
+ ## Security posture
72
+
73
+ Every project member with edit access can run turns on this machine —
74
+ Workbench tabs and @-dispatches both execute a coding agent with the daemon's
75
+ own OS permissions. Membership is the consent boundary, the same trust plane
76
+ as the shared repository: invite people you would give a shell to.
77
+
78
+ Two knobs bound the blast radius, and both are worth setting on a shared box:
79
+
80
+ - **Run the daemon under a dedicated OS user** that owns only the repository
81
+ checkout and `~/.flowviant`. This is the single biggest hardening available
82
+ — a session can then only touch that account's files, not your keys, your
83
+ home directory, or the rest of the machine. A plain separate account works;
84
+ a systemd unit with `ProtectHome=read-only` and `ReadWritePaths=` works
85
+ better.
86
+ - **`FLOWVIANT_SAFE=1`** narrows the toolset: Claude to an allowlist
87
+ (edit/read/search plus `git`/`gh`/`npm`/`bun` — no arbitrary shell), Codex
88
+ to a workspace-write sandbox. Antigravity has no per-invocation narrowing —
89
+ its permission engine is machine-wide — which is surfaced in the app rather
90
+ than papered over.
91
+
92
+ The posture is reported on every poll and shown in the project's
93
+ Settings → Machine section, so the team can see whether the box runs the
94
+ guarded toolset or full permissions.
95
+
71
96
  ## License
72
97
 
73
98
  MIT — see [LICENSE](./LICENSE).
package/bin/lib/fleet.mjs CHANGED
@@ -101,6 +101,12 @@ async function fetchRoster(haveIds) {
101
101
  // our own package.json). Older servers ignore unknown params, so sending it
102
102
  // unconditionally is always safe.
103
103
  url.searchParams.set('dv', VERSION);
104
+ // The permission posture this machine runs turns under — '1' when
105
+ // FLOWVIANT_SAFE narrows the toolset, '0' when everything is granted. A
106
+ // statement of configuration, not a request: the app SHOWS it in Settings
107
+ // so a team can see whether the shared box runs wide open, and enforces
108
+ // nothing (membership is the consent boundary). Older servers ignore it.
109
+ url.searchParams.set('safe', SAFE ? '1' : '0');
104
110
  // WHICH CLIs this machine actually has, so the app can stop guessing.
105
111
  //
106
112
  // Until now every surface that listed Gemini or Codex said "not wired up yet"
@@ -24,8 +24,66 @@ import {
24
24
  import { homedir } from 'node:os';
25
25
  import { join } from 'node:path';
26
26
 
27
- const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
28
27
  const REPORT_CAP = 30;
28
+ // ENDED sessions are the adoptable inventory, and the useful ones are FRESH:
29
+ // "closed my laptop terminal, picking it up here". Claude Code prunes its own
30
+ // history anyway, so a week-old row was a soon-to-be-dead offer — 48 hours,
31
+ // newest per directory, few. (The first ship reported 7 days of everything
32
+ // and the strip read as session history instead of presence.)
33
+ const ENDED_WINDOW_MS = 48 * 60 * 60 * 1000;
34
+ const ENDED_CAP = 5;
35
+
36
+ /**
37
+ * The conversation's own title, off the transcript's `ai-title` records
38
+ * (the LAST one wins — titles get rewritten as a session evolves), falling
39
+ * back to the first real user message. Those records sit anywhere in the
40
+ * file (measured: line 81 to line 4457), so this reads the WHOLE transcript
41
+ * — behind an mtime cache, because the scan runs every minute and a title
42
+ * only changes when the file does: steady state is a stat, not a read.
43
+ */
44
+ const titleCache = new Map(); // file → { mtimeMs, title }
45
+ function transcriptTitle(file, mtimeMs) {
46
+ const hit = titleCache.get(file);
47
+ if (hit && hit.mtimeMs === mtimeMs) return hit.title;
48
+ let title = null;
49
+ try {
50
+ const stat = statSync(file);
51
+ // A transcript past this is not worth a read per minute of drift.
52
+ if (stat.size <= 64 * 1024 * 1024) {
53
+ let firstUser = null;
54
+ for (const line of readFileSync(file, 'utf8').split('\n')) {
55
+ if (line.includes('"type":"ai-title"')) {
56
+ try {
57
+ const t = JSON.parse(line)?.aiTitle;
58
+ if (typeof t === 'string' && t.trim()) title = t.trim(); // last wins
59
+ } catch {
60
+ /* torn line */
61
+ }
62
+ } else if (!firstUser && !title && line.includes('"type":"user"') && !line.includes('"isMeta":true')) {
63
+ try {
64
+ const content = JSON.parse(line)?.message?.content;
65
+ const text =
66
+ typeof content === 'string'
67
+ ? content
68
+ : Array.isArray(content)
69
+ ? (content.find((b) => typeof b?.text === 'string')?.text ?? '')
70
+ : '';
71
+ if (text.trim() && !text.startsWith('<')) firstUser = text.trim();
72
+ } catch {
73
+ /* torn line */
74
+ }
75
+ }
76
+ }
77
+ if (!title && firstUser) title = firstUser;
78
+ if (title) title = title.replace(/\s+/g, ' ').slice(0, 120);
79
+ }
80
+ } catch {
81
+ title = null;
82
+ }
83
+ if (titleCache.size > 400) titleCache.clear(); // a bound, not an LRU — refills in one scan
84
+ titleCache.set(file, { mtimeMs, title });
85
+ return title;
86
+ }
29
87
 
30
88
  /** Path-prefix containment on already-realpath'd absolute paths. */
31
89
  const inside = (p, root) => p === root || p.startsWith(root.endsWith('/') ? root : `${root}/`);
@@ -182,12 +240,29 @@ export function scanLocalSessions({ repoRoot, excludeDirs = [] }) {
182
240
  }
183
241
  if (!ours(cwd)) continue;
184
242
  liveIds.add(rec.sessionId);
243
+ // A live session's title, off its own transcript (the registry `name`
244
+ // is a machine-y fallback like "flowviant-35").
245
+ let liveTitle = null;
246
+ try {
247
+ const liveFile = join(
248
+ homedir(),
249
+ '.claude',
250
+ 'projects',
251
+ cwd.replace(/[/.]/g, '-'),
252
+ `${rec.sessionId}.jsonl`
253
+ );
254
+ liveTitle = transcriptTitle(liveFile, statSync(liveFile).mtimeMs);
255
+ } catch {
256
+ /* no transcript yet */
257
+ }
258
+ if (!liveTitle && typeof rec.name === 'string' && rec.name.trim()) liveTitle = rec.name.trim();
185
259
  live.push({
186
260
  id: rec.sessionId,
187
261
  cwd,
188
262
  live: true,
189
263
  lastActiveAt: nowIso,
190
264
  ...(typeof rec.gitBranch === 'string' && rec.gitBranch ? { branch: rec.gitBranch } : {}),
265
+ ...(liveTitle ? { title: liveTitle } : {}),
191
266
  });
192
267
  }
193
268
  live.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
@@ -207,7 +282,7 @@ export function scanLocalSessions({ repoRoot, excludeDirs = [] }) {
207
282
  } catch {
208
283
  /* no transcript store — live sessions still report */
209
284
  }
210
- const cutoff = Date.now() - SEVEN_DAYS_MS;
285
+ const cutoff = Date.now() - ENDED_WINDOW_MS;
211
286
  const candidates = [];
212
287
  for (const dirName of projDirs) {
213
288
  if (dirName !== munged && !dirName.startsWith(`${munged}-`)) continue;
@@ -228,7 +303,7 @@ export function scanLocalSessions({ repoRoot, excludeDirs = [] }) {
228
303
  } catch {
229
304
  continue;
230
305
  }
231
- if (mtimeMs < cutoff) continue; // week-old sessions are history, not presence
306
+ if (mtimeMs < cutoff) continue; // an aged session is history, not presence
232
307
  candidates.push({ id, file, mtimeMs });
233
308
  }
234
309
  }
@@ -236,8 +311,14 @@ export function scanLocalSessions({ repoRoot, excludeDirs = [] }) {
236
311
  // the verification read is the expensive step, so it is not spent on
237
312
  // sessions the report would drop anyway.
238
313
  candidates.sort((a, b) => b.mtimeMs - a.mtimeMs || (a.id < b.id ? -1 : 1));
239
- const room = Math.max(0, REPORT_CAP - Math.min(live.length, REPORT_CAP));
314
+ // ONE row per DIRECTORY, newest first, few: twenty sessions in the repo
315
+ // root are one offer — the newest is the one `--resume`'s picker would
316
+ // reach for and the only one worth importing 95% of the time. The rest
317
+ // are scrollback, and the product's own law says scrollback doesn't
318
+ // matter.
319
+ const room = Math.min(ENDED_CAP, Math.max(0, REPORT_CAP - Math.min(live.length, REPORT_CAP)));
240
320
  const endedIds = new Set();
321
+ const seenCwds = new Set(live.map((s) => s.cwd));
241
322
  for (const cand of candidates) {
242
323
  if (ended.length >= room) break;
243
324
  if (endedIds.has(cand.id)) continue; // one row per session, whatever dir names it
@@ -251,12 +332,16 @@ export function scanLocalSessions({ repoRoot, excludeDirs = [] }) {
251
332
  continue;
252
333
  }
253
334
  if (!ours(cwd)) continue;
335
+ if (seenCwds.has(cwd)) continue; // newest per directory; a live one owns its cwd
336
+ seenCwds.add(cwd);
337
+ const title = transcriptTitle(cand.file, cand.mtimeMs);
254
338
  ended.push({
255
339
  id: cand.id,
256
340
  cwd,
257
341
  live: false,
258
342
  lastActiveAt: new Date(cand.mtimeMs).toISOString(),
259
343
  ...(typeof rec.gitBranch === 'string' && rec.gitBranch ? { branch: rec.gitBranch } : {}),
344
+ ...(title ? { title } : {}),
260
345
  });
261
346
  }
262
347
  } catch {
@@ -365,7 +450,7 @@ function scanAgyConversations({ repoRoot, excludeDirs = [] }) {
365
450
  const raw = readFileSync(join(AGY_DIR(), 'cache', 'last_conversations.json'), 'utf8');
366
451
  const map = JSON.parse(raw);
367
452
  if (!map || typeof map !== 'object') return out;
368
- const cutoff = Date.now() - SEVEN_DAYS_MS;
453
+ const cutoff = Date.now() - ENDED_WINDOW_MS;
369
454
  const processUp = agyProcessAlive();
370
455
  for (const [cwd, id] of Object.entries(map)) {
371
456
  if (typeof id !== 'string' || !AGY_UUID_RE.test(id)) continue;
@@ -458,26 +458,40 @@ MECHANICS OF THIS TAB:
458
458
  4. NEVER merge to main, deploy, or force-push unless the human explicitly says
459
459
  so in this conversation. Branch pushes and PRs are fine when asked. Shipping
460
460
  is their word to say, not yours to infer.
461
+ 5. WHEN THEY HAVE TO CHOOSE, HAND THEM THE CHOICES. A real pick between known
462
+ options — not an open question — ends your reply with a fenced block the app
463
+ renders as buttons; their click composes their answer as the next message:
464
+
465
+ \`\`\`flowviant-ask
466
+ {"question": "Which auth flow?", "options": ["Magic link", "Password", "Both"], "multiSelect": false}
467
+ \`\`\`
468
+
469
+ ONE block per reply, and always the LAST thing in it. Two to eight options,
470
+ each label short enough to sit on a button. multiSelect true only for a
471
+ genuine check-several-of-these case. NEVER for an open question — ask those
472
+ in prose, like anyone would. And ask the question in prose above the block
473
+ as well: a client that doesn't render the fence shows it as plain text, so
474
+ the reply has to read as a question with its options either way.
461
475
 
462
476
  THE LEDGER. This session's work is logged as CARDS as it happens, by you,
463
477
  through tools — so a four-hour churn doesn't evaporate into scrollback. The
464
478
  rules:
465
479
 
466
- 5. CLAIM WHAT YOU WORK. When they say "take the auth card" or "next", call
480
+ 6. CLAIM WHAT YOU WORK. When they say "take the auth card" or "next", call
467
481
  list_cards, then claim_card the one they mean. The card you hold is the
468
482
  tab's "Now" — it is how they and their team see what this session is doing.
469
- 6. LOG DRIFT, don't ask permission for it. "Also fix that redirect" mid-flow:
483
+ 7. LOG DRIFT, don't ask permission for it. "Also fix that redirect" mid-flow:
470
484
  do the work, and file_card it — check list_cards FIRST; if a planned card
471
485
  already covers it, claim that one instead of filing a twin. One card per
472
486
  shippable unit. Never card-ify chatter, questions, or exploration.
473
- 7. DELIVER WITH RECEIPTS. When a card's work is committed, deliver_card with a
487
+ 8. DELIVER WITH RECEIPTS. When a card's work is committed, deliver_card with a
474
488
  one-paragraph summary and the commit shas. Delivered is ASSERTED; done is
475
489
  OBSERVED (the merge, on their word). Never claim done, and never deliver
476
490
  work that isn't committed.
477
- 8. RAISE WHAT YOU SPOT. A design flaw, a follow-up they named for later —
491
+ 9. RAISE WHAT YOU SPOT. A design flaw, a follow-up they named for later —
478
492
  raise_card, queued, unheld. You do not start raised work.
479
- 9. BE PROPORTIONAL. A one-line typo fix inside the card you already hold is
480
- that card's work, not a new card. When in doubt, fewer cards.
493
+ 10. BE PROPORTIONAL. A one-line typo fix inside the card you already hold is
494
+ that card's work, not a new card. When in doubt, fewer cards.
481
495
 
482
496
  POSTURE: terminal, not ticket. Don't ask permission to look at things. Don't
483
497
  narrate ceremony. Ground claims in files you opened. When they ask a question,
@@ -514,6 +528,21 @@ MECHANICS OF THIS TAB:
514
528
  4. NEVER merge to main, deploy, or force-push unless the human explicitly says
515
529
  so in this conversation. Branch pushes are fine when asked. Shipping is
516
530
  their word to say, not yours to infer.
531
+ 5. WHEN THEY HAVE TO CHOOSE, HAND THEM THE CHOICES. You have no tools here, but
532
+ this one costs none — it is text. A real pick between known options (not an
533
+ open question) ends your reply with a fenced block the app renders as
534
+ buttons; their click composes their answer as the next message:
535
+
536
+ \`\`\`flowviant-ask
537
+ {"question": "Which auth flow?", "options": ["Magic link", "Password", "Both"], "multiSelect": false}
538
+ \`\`\`
539
+
540
+ ONE block per reply, and always the LAST thing in it. Two to eight options,
541
+ each label short enough to sit on a button. multiSelect true only for a
542
+ genuine check-several-of-these case. NEVER for an open question — ask those
543
+ in prose, like anyone would. And ask the question in prose above the block
544
+ as well: a client that doesn't render the fence shows it as plain text, so
545
+ the reply has to read as a question with its options either way.
517
546
 
518
547
  POSTURE: terminal, not ticket. Don't ask permission to look at things. Ground
519
548
  claims in files you opened. When they ask a question, answer it; when they ask
package/bin/lib/work.mjs CHANGED
@@ -45,6 +45,45 @@ import { detectRuntimes, canRun, RUNTIMES } from './runtimes.mjs';
45
45
  import { isTerminalSessionLive, isAgyConversationLive } from './localSessions.mjs';
46
46
  import { homedir } from 'node:os';
47
47
 
48
+ /**
49
+ * The shape a per-tab model name must have before it rides argv as
50
+ * `--model <name>`. Conservative for the same reason the codex thread id is
51
+ * (below): it comes off the wire and lands in a child process's arguments —
52
+ * alphanumerics plus dot/dash/underscore, at most 40 characters, and NEVER a
53
+ * leading dash, which is an argv that parses as a flag.
54
+ */
55
+ const WORK_MODEL_RE = /^[a-zA-Z0-9._][a-zA-Z0-9._-]{0,39}$/;
56
+
57
+ /** The five efforts the CLIs actually accept. A literal set rather than a
58
+ * pattern: there is no such thing as an effort we haven't heard of, and the
59
+ * server's own union is exactly this list. */
60
+ const WORK_EFFORTS = new Set(['low', 'medium', 'high', 'xhigh', 'max']);
61
+
62
+ /**
63
+ * WHICH BRAIN, AT WHICH EFFORT — the tab's own pick, off the roster.
64
+ *
65
+ * Absent is the resting state and it must stay genuinely absent: every tab ran
66
+ * with no `--model` and no `--effort` until now, so a job that names neither
67
+ * has to produce the byte-identical argv it produced yesterday — Claude falling
68
+ * back to the machine's MODEL pin, codex and agy to their own defaults. Hence
69
+ * an object with the key MISSING rather than one holding null: a null would
70
+ * reach the builders as a value and Claude's `model || MODEL` is the only one
71
+ * that would survive it.
72
+ *
73
+ * A value that fails its guard is DROPPED, not passed through and not an error.
74
+ * The honest outcome of "the server named a model this machine can't spell" is
75
+ * the machine's own default — a turn that runs — rather than a flag no CLI
76
+ * understands and a tab that fails every message.
77
+ */
78
+ function brainFor(job) {
79
+ const out = {};
80
+ const model = typeof job?.model === 'string' ? job.model.trim() : '';
81
+ if (model && WORK_MODEL_RE.test(model)) out.model = model;
82
+ const effort = typeof job?.effort === 'string' ? job.effort.trim() : '';
83
+ if (effort && WORK_EFFORTS.has(effort)) out.effort = effort;
84
+ return out;
85
+ }
86
+
48
87
  export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLeaseTtl }) {
49
88
  const WORK_TOKEN_URL = FLEET_URL.replace(/\/agents\/?$/, '/work-token');
50
89
  const WORK_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/work-turn-done');
@@ -869,9 +908,20 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
869
908
  // in the prompt, so the AGENT tells the user what stayed behind.
870
909
  let carryNote = '';
871
910
  if (adopting && dir.fresh && adoptSrc) carryNote = carryDirtyState(adoptSrc, dir.wt);
911
+ // The tab's transcript starts EMPTY on adoption (scrollback is
912
+ // disposable, the held context is the brain — never import an
913
+ // archive), so the first reply opens with a recap: the human sees
914
+ // the thread they are picking up without asking for it.
915
+ const adoptNote = adopting
916
+ ? '[ADOPTED SESSION — this conversation was brought in from a terminal. Begin your reply with a 2-3 sentence recap of where it left off and what state carried over, then answer the message.]'
917
+ : '';
872
918
  const mcp = plainTab
873
919
  ? { args: [], env: null, dir: null }
874
920
  : mcpFor(rt.id, mint.token, getMcpUrl());
921
+ // The tab's model/effort, if it named any. Spread into turnArgs so
922
+ // BOTH runTurn calls below carry it — the retry is the same turn on
923
+ // the same brain, not a quieter second opinion.
924
+ const brain = brainFor(job);
875
925
  // Attempts count RUNS: the infra refusals above consumed nothing and
876
926
  // settled on their own terms.
877
927
  workAttempts.set(job.id, tries + 1);
@@ -879,7 +929,7 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
879
929
  let seenThreadId = null; // codex's conversation id, off thread.started
880
930
  const spawned = []; // this turn's children, for the teardown registry
881
931
  try {
882
- const message = carryNote ? `${job.body}\n\n${carryNote}` : job.body;
932
+ const message = [job.body, adoptNote, carryNote].filter(Boolean).join('\n\n');
883
933
  const turnArgs = {
884
934
  // A plain tab has no tools to name and no session id to pass —
885
935
  // its kickoff asks for one complete report instead of a stream.
@@ -901,6 +951,8 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
901
951
  // ordinary --continue resume path, unchanged.
902
952
  ...(adopting ? { adoptResumeId: job.adopt.id } : {}),
903
953
  system: plainTab ? SYSTEM_WORK_PLAIN : SYSTEM_WORK,
954
+ // Present only when the tab named one — see brainFor.
955
+ ...brain,
904
956
  cwd: dir.wt,
905
957
  mcpArgs: mcp.args,
906
958
  mcpEnv: mcp.env,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.47.0",
3
+ "version": "0.47.2",
4
4
  "description": "Run your own coding CLIs as headless build agents for Flowviant — Claude Code or Codex, 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": {