flowviant 0.69.0 → 0.70.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.
@@ -74,6 +74,47 @@ function transcriptTitle(file, mtimeMs) {
74
74
  return title;
75
75
  }
76
76
 
77
+ /**
78
+ * THE TITLE CLAUDE GAVE ITS OWN CONVERSATION, for one session we already know
79
+ * the id of.
80
+ *
81
+ * `scanLocalSessions` finds transcripts the hard way — walk every project
82
+ * directory, read each file's own `cwd` — because it is answering "what is in
83
+ * this repo" and knows no ids. This asks the same store a narrower question:
84
+ * the tab pinned the id the CLI reported at `system.init`, and the directory is
85
+ * the place the turn was spawned in, so the file is one munge away (Claude Code
86
+ * names the directory after the cwd with `/` and `.` replaced by `-`).
87
+ *
88
+ * The realpath fallback is not defensive padding: a place under a symlinked
89
+ * home munges to a DIFFERENT directory name depending on which form the CLI was
90
+ * handed, and the daemon does not control which that was.
91
+ *
92
+ * Returns null for anything it cannot read. A missing title is honest silence —
93
+ * the tab keeps whatever name it has.
94
+ */
95
+ export function titleForSession(cwd, sessionId) {
96
+ if (!cwd || typeof sessionId !== 'string' || !/^[A-Za-z0-9_-]{8,64}$/.test(sessionId)) {
97
+ return null;
98
+ }
99
+ const projects = join(homedir(), '.claude', 'projects');
100
+ const dirs = [String(cwd)];
101
+ try {
102
+ const real = realpathSync(String(cwd));
103
+ if (real !== String(cwd)) dirs.push(real);
104
+ } catch {
105
+ /* the place is gone — nothing to read */
106
+ }
107
+ for (const dir of dirs) {
108
+ const file = join(projects, dir.replace(/[/.]/g, '-'), `${sessionId}.jsonl`);
109
+ try {
110
+ return transcriptTitle(file, statSync(file).mtimeMs);
111
+ } catch {
112
+ /* not this munge — try the other */
113
+ }
114
+ }
115
+ return null;
116
+ }
117
+
77
118
  /** Path-prefix containment on already-realpath'd absolute paths. */
78
119
  const inside = (p, root) => p === root || p.startsWith(root.endsWith('/') ? root : `${root}/`);
79
120
 
package/bin/lib/work.mjs CHANGED
@@ -59,7 +59,11 @@ import { detectRuntimes, canRun, recordSkills, RUNTIMES } from './runtimes.mjs';
59
59
  /** The place id meaning "the checkout", not a worktree. Must match the
60
60
  * server's REPO_PLACE — it is a wire value, not a local convention. */
61
61
  const REPO_PLACE = 'repo';
62
- import { isTerminalSessionLive, isAgyConversationLive } from './localSessions.mjs';
62
+ import {
63
+ isTerminalSessionLive,
64
+ isAgyConversationLive,
65
+ titleForSession,
66
+ } from './localSessions.mjs';
63
67
  import { worktreeDiff } from './worktreeDiff.mjs';
64
68
  import { homedir } from 'node:os';
65
69
 
@@ -398,10 +402,21 @@ export function createWorkManager({ repoRoot, baseDir, getBaseRef, getMcpUrl, ge
398
402
  const learnPlaces = (map) => {
399
403
  if (!map || typeof map !== 'object') return;
400
404
  for (const [sid, place] of Object.entries(map)) {
401
- if (typeof place === 'string' && place) sessionPlaces.set(sid, place);
402
- // null / '' / anything else: the server is telling us this tab is in its
403
- // OWN worktree. Falling back to the default requires forgetting, not
404
- // ignoring.
405
+ // VALIDATE AT THE TRUST BOUNDARY. `placeDir` joins this value straight
406
+ // into `sessions/<place>` and it is the directory a turn is SPAWNED in
407
+ // and a preview port is measured against — the security boundary for the
408
+ // whole preview feature. The server resolves place to an enum (never a
409
+ // path), but a server bug or compromise sending `../../etc` would
410
+ // otherwise point a session's measured directory anywhere on the box and
411
+ // defeat the port attribution. `sessionWorktreeReport` and `placeWtFor`
412
+ // already reject an unsafe segment; doing it here covers every consumer
413
+ // (the preview-claim `placeDir` did not re-check). REPO_PLACE resolves to
414
+ // repoRoot, so it is allowed through despite not being a path segment.
415
+ if (typeof place === 'string' && place && (place === REPO_PLACE || isSafePathSegment(place)))
416
+ sessionPlaces.set(sid, place);
417
+ // null / '' / an unsafe value: the server is telling us this tab is in its
418
+ // OWN worktree (or is malformed). Falling back to the default requires
419
+ // forgetting, not ignoring.
405
420
  else sessionPlaces.delete(sid);
406
421
  }
407
422
  };
@@ -465,6 +480,29 @@ export function createWorkManager({ repoRoot, baseDir, getBaseRef, getMcpUrl, ge
465
480
  // `processesSupported` keeps "cannot look" (Windows) apart from "looked and
466
481
  // found none", which renders differently.
467
482
  const processes = sessionProcesses(sessionId);
483
+ // THE NAME CLAUDE ALREADY GAVE THIS CONVERSATION, relayed.
484
+ //
485
+ // Claude Code titles its own sessions; a Flowviant tab was born "session 3"
486
+ // and stayed that way unless somebody renamed it by hand, so a strip of
487
+ // eight tabs said nothing about any of them. The title is not ours to
488
+ // invent — reading it is the same relay this whole file does, and asking
489
+ // a model for one would be a second brain, which the product forbids.
490
+ //
491
+ // Only CLAUDE tabs have one here: the id is the one the CLI reported at
492
+ // `system.init` and pinned per tab, so a codex or agy tab simply has no
493
+ // marker and reports no title. No runtime check is needed for that — the
494
+ // absent marker IS the check.
495
+ //
496
+ // No version floor: a daemon→server report on an endpoint that already
497
+ // exists, so an older daemon sends no key and the server leaves the name
498
+ // exactly as it found it.
499
+ let title = null;
500
+ try {
501
+ const marker = sessionMetaPath(wt, 'flowviant-claude-session', sessionId);
502
+ if (marker) title = titleForSession(wt, readFileSync(marker, 'utf8').trim());
503
+ } catch {
504
+ /* no marker yet — this tab has not spoken, or is not Claude */
505
+ }
468
506
  return {
469
507
  sessionId,
470
508
  ...d,
@@ -472,6 +510,7 @@ export function createWorkManager({ repoRoot, baseDir, getBaseRef, getMcpUrl, ge
472
510
  listeningSupported: listenersSupported(),
473
511
  ...(processes === null ? {} : { processes }),
474
512
  processesSupported: processesSupported(),
513
+ ...(title ? { title } : {}),
475
514
  };
476
515
  };
477
516
  /** One session, now — called after its turn settles. */
@@ -1977,7 +2016,14 @@ export function createWorkManager({ repoRoot, baseDir, getBaseRef, getMcpUrl, ge
1977
2016
  };
1978
2017
  const auditCommand = (a) => {
1979
2018
  if (a?.kind !== 'bash' || !a.command) return;
1980
- auditBatch.push({ command: a.command, at: new Date().toISOString() });
2019
+ // Scrubbed like every other string that leaves this box (the
2020
+ // narrator label, the settle answer, commit subjects, ship/merge
2021
+ // lines, the per-tab process report). A command line is exactly
2022
+ // where a secret leaks — `curl -H "authorization: <token>"`,
2023
+ // `PGPASSWORD=… psql` — and the audit is stored 30 days and rendered
2024
+ // in the admin view, so the one uplink that omitted scrub was the
2025
+ // one most likely to carry a plaintext secret.
2026
+ auditBatch.push({ command: envScrub(a.command), at: new Date().toISOString() });
1981
2027
  if (auditBatch.length >= 25) flushAudit();
1982
2028
  };
1983
2029
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.69.0",
3
+ "version": "0.70.0",
4
4
  "description": "Run your own coding CLIs as build agents for Flowviant — 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": {