flowviant 0.47.0 → 0.47.1

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.
@@ -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;
package/bin/lib/work.mjs CHANGED
@@ -869,6 +869,13 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
869
869
  // in the prompt, so the AGENT tells the user what stayed behind.
870
870
  let carryNote = '';
871
871
  if (adopting && dir.fresh && adoptSrc) carryNote = carryDirtyState(adoptSrc, dir.wt);
872
+ // The tab's transcript starts EMPTY on adoption (scrollback is
873
+ // disposable, the held context is the brain — never import an
874
+ // archive), so the first reply opens with a recap: the human sees
875
+ // the thread they are picking up without asking for it.
876
+ const adoptNote = adopting
877
+ ? '[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.]'
878
+ : '';
872
879
  const mcp = plainTab
873
880
  ? { args: [], env: null, dir: null }
874
881
  : mcpFor(rt.id, mint.token, getMcpUrl());
@@ -879,7 +886,7 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
879
886
  let seenThreadId = null; // codex's conversation id, off thread.started
880
887
  const spawned = []; // this turn's children, for the teardown registry
881
888
  try {
882
- const message = carryNote ? `${job.body}\n\n${carryNote}` : job.body;
889
+ const message = [job.body, adoptNote, carryNote].filter(Boolean).join('\n\n');
883
890
  const turnArgs = {
884
891
  // A plain tab has no tools to name and no session id to pass —
885
892
  // its kickoff asks for one complete report instead of a stream.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.47.0",
3
+ "version": "0.47.1",
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": {