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.
- package/bin/lib/localSessions.mjs +90 -5
- package/bin/lib/work.mjs +8 -1
- package/package.json +1 -1
|
@@ -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() -
|
|
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; //
|
|
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
|
-
|
|
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() -
|
|
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 =
|
|
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.
|
|
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": {
|