flowviant 0.47.2 → 0.48.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.
@@ -191,7 +191,13 @@ const oneLine = (s, n = 160) => String(s).replace(/\s+/g, ' ').trim().slice(0, n
191
191
  // bursts before/between tools; emitting only tools left long silent gaps).
192
192
  // Assistant text is also folded into `out` so the WIKI_DONE/REGROUND_DONE
193
193
  // sentinels still match. A non-JSON line (a stray warning) is kept as raw text.
194
- function handleStreamLine(line, { cwd, emit, onActivity, appendText }) {
194
+ //
195
+ // `answerFromResult` narrows that last part for callers whose `out` IS the
196
+ // answer rather than a haystack to match sentinels in (a Workbench tab's turn):
197
+ // every intermediate text block still NARRATES, but only the final `result`
198
+ // event contributes text — otherwise the same sentences arrive twice, once as
199
+ // they stream and once in the result, and the tab posts the duplicate.
200
+ function handleStreamLine(line, { cwd, emit, onActivity, appendText, answerFromResult }) {
195
201
  let ev;
196
202
  try {
197
203
  ev = JSON.parse(line);
@@ -212,15 +218,23 @@ function handleStreamLine(line, { cwd, emit, onActivity, appendText }) {
212
218
  // marker — enough to show Claude is actively reasoning, not hung.
213
219
  push({ kind: 'think', label: b.thinking ? `thinking: ${oneLine(b.thinking)}` : 'thinking…' });
214
220
  } else if (b.type === 'text' && b.text?.trim()) {
215
- appendText(b.text + '\n');
221
+ if (!answerFromResult) appendText(b.text + '\n');
216
222
  push({ kind: 'say', label: oneLine(b.text) });
217
223
  } else if (b.type === 'tool_use') {
218
224
  push(humanizeToolUse(b.name, b.input || {}, cwd));
219
225
  }
220
226
  }
221
- } else if (ev.type === 'result' && typeof ev.result === 'string') {
227
+ } else if (ev.type === 'result') {
222
228
  // The final assistant text (carries WIKI_DONE / REGROUND_DONE).
223
- appendText(ev.result + '\n');
229
+ if (typeof ev.result === 'string') appendText(ev.result + '\n');
230
+ else if (ev.is_error || ev.subtype) {
231
+ // A result that carries no text is a FAILED turn (a limit, a refused
232
+ // permission, an aborted run). Under `answerFromResult` this is the only
233
+ // stdout that would have said so, and a caller whose `out` is the answer
234
+ // must not report "no output" for a turn that explained itself.
235
+ const msg = ev.error?.message ?? ev.error ?? ev.subtype;
236
+ appendText(`${typeof msg === 'string' ? msg : JSON.stringify(msg)}\n`);
237
+ }
224
238
  }
225
239
  }
226
240
 
@@ -233,7 +247,7 @@ function handleStreamLine(line, { cwd, emit, onActivity, appendText }) {
233
247
  // returned string for sentinel detection, and each activity is handed to
234
248
  // `onActivity` so the caller can forward progress. Build-agent turns leave it
235
249
  // off and keep the raw text passthrough + line sentinels.
236
- export function runTurn({ prompt, resume, system, cwd, mcpConfig, mcpArgs, mcpEnv, runtime = 'claude', label, onSpawn, streamJson, onActivity, onThreadId, wikiPerm, readOnly, planPerm, vaultDir, resultSchemaArgs, model, effort, adoptResumeId, resumeThreadId, resumeConversationId }) {
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 }) {
237
251
  return new Promise((resolve) => {
238
252
  const rt = runtimeById(runtime);
239
253
  if (!rt.args) {
@@ -344,7 +358,8 @@ export function runTurn({ prompt, resume, system, cwd, mcpConfig, mcpArgs, mcpEn
344
358
  };
345
359
  /** One line of the child's stdout, in whichever dialect it speaks. */
346
360
  const onLine = (line) => {
347
- if (!rt.parse) return handleStreamLine(line, { cwd, emit, onActivity, appendText });
361
+ if (!rt.parse)
362
+ return handleStreamLine(line, { cwd, emit, onActivity, appendText, answerFromResult });
348
363
  const ev = rt.parse(line, cwd);
349
364
  if (!ev) return;
350
365
  // The conversation id, when the runtime announces one (codex's
package/bin/lib/fleet.mjs CHANGED
@@ -1213,6 +1213,7 @@ export async function runFleetDaemon() {
1213
1213
  processWorkTurns,
1214
1214
  processShipJobs,
1215
1215
  retireWorkSessions,
1216
+ reportWorktrees,
1216
1217
  shutdownWork,
1217
1218
  } = createWorkManager({
1218
1219
  repoRoot,
@@ -1831,6 +1832,11 @@ export async function runFleetDaemon() {
1831
1832
  // sessions are LIVE, and the guards above (chains, shipping) are populated
1832
1833
  // by the intake this same tick.
1833
1834
  retireWorkSessions(roster.activeWorkSessions);
1835
+ // …and what the SURVIVING ones hold: branch, ahead-of-base, diffstat.
1836
+ // Throttled inside, never awaited — a `git status` the human cannot run
1837
+ // themselves from a browser, relayed. After retirement so a directory that
1838
+ // just went away is not reported as a place.
1839
+ reportWorktrees(roster.activeWorkSessions);
1834
1840
  // Terminal-session presence, throttled + dedup'd inside; never awaited —
1835
1841
  // the daemon's own worktrees are carved out (a session the daemon spawned
1836
1842
  // is already a tab, not something to offer adopting).
package/bin/lib/work.mjs CHANGED
@@ -43,6 +43,7 @@ import {
43
43
  import { materializeInto, scrub as envScrub } from './env.mjs';
44
44
  import { detectRuntimes, canRun, RUNTIMES } from './runtimes.mjs';
45
45
  import { isTerminalSessionLive, isAgyConversationLive } from './localSessions.mjs';
46
+ import { worktreeDiff } from './worktreeDiff.mjs';
46
47
  import { homedir } from 'node:os';
47
48
 
48
49
  /**
@@ -88,6 +89,8 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
88
89
  const WORK_TOKEN_URL = FLEET_URL.replace(/\/agents\/?$/, '/work-token');
89
90
  const WORK_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/work-turn-done');
90
91
  const SHIP_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/ship-done');
92
+ const ACTIVITY_URL = FLEET_URL.replace(/\/agents\/?$/, '/session-activity');
93
+ const WORKTREES_URL = FLEET_URL.replace(/\/agents\/?$/, '/session-worktrees');
91
94
  const workAnswering = new Set(); // turn ids currently queued/running here
92
95
  const workAttempts = new Map(); // turn id -> completed runTurn attempts
93
96
  const MAX_WORK_TRIES = 3;
@@ -168,6 +171,163 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
168
171
  else pendingShipReports.delete(sessionId);
169
172
  return r;
170
173
  };
174
+ /**
175
+ * THE TAB'S LIVE NARRATION — the terminal's own stdout, relayed.
176
+ *
177
+ * A turn used to be a spinner: the tab said "working…" for minutes and the
178
+ * only thing that ever appeared was the finished reply. The CLI is printing
179
+ * the whole time (thinking, reads, greps, commands), so the honest fix is to
180
+ * FORWARD that, not to invent a progress model on the server. Flowviant
181
+ * relays; it does not narrate on its own behalf.
182
+ *
183
+ * Best-effort by construction: throttled to one POST per window (a turn can
184
+ * emit hundreds of lines), never awaited by the turn, and every failure is
185
+ * swallowed. A spinner must never be able to fail a build. The server clears
186
+ * the line at settle, so a daemon killed mid-turn cannot leave one stuck.
187
+ */
188
+ const ACTIVITY_MIN_MS = 1_500;
189
+ const ACTIVITY_KEEP = 4; // the last few lines — a tail, not a log
190
+ const makeNarrator = (sessionId) => {
191
+ const recent = [];
192
+ let lastSent = 0;
193
+ let dirty = false;
194
+ let timer = null;
195
+ let sending = false;
196
+ let stopped = false;
197
+ const send = async () => {
198
+ if (sending || stopped) return;
199
+ sending = true;
200
+ dirty = false;
201
+ lastSent = Date.now();
202
+ const lines = recent.slice(-ACTIVITY_KEEP);
203
+ try {
204
+ await fetch(ACTIVITY_URL, {
205
+ method: 'POST',
206
+ headers: {
207
+ Authorization: `Bearer ${FLEET_TOKEN}`,
208
+ 'User-Agent': USER_AGENT,
209
+ 'Content-Type': 'application/json',
210
+ },
211
+ signal: AbortSignal.timeout(10_000),
212
+ body: JSON.stringify({ sessionId, lines }),
213
+ });
214
+ } catch {
215
+ /* narration is decoration — a dropped line is not an incident */
216
+ }
217
+ sending = false;
218
+ if (dirty && !stopped) schedule();
219
+ };
220
+ const schedule = () => {
221
+ if (timer || stopped) return;
222
+ const wait = Math.max(0, ACTIVITY_MIN_MS - (Date.now() - lastSent));
223
+ timer = setTimeout(() => {
224
+ timer = null;
225
+ void send();
226
+ }, wait);
227
+ timer.unref?.(); // never hold the process open for a spinner
228
+ };
229
+ return {
230
+ line(label) {
231
+ const s = String(label ?? '')
232
+ .replace(/\s+/g, ' ')
233
+ .trim()
234
+ .slice(0, 200);
235
+ if (!s || stopped) return;
236
+ recent.push(s);
237
+ if (recent.length > ACTIVITY_KEEP * 2) recent.shift();
238
+ dirty = true;
239
+ schedule();
240
+ },
241
+ stop() {
242
+ stopped = true;
243
+ if (timer) {
244
+ clearTimeout(timer);
245
+ timer = null;
246
+ }
247
+ },
248
+ };
249
+ };
250
+
251
+ /**
252
+ * WHERE EACH TAB IS STANDING, and what it holds — the readout a human would
253
+ * get by running `git status` in the session's directory, which is the one
254
+ * thing they cannot do from a browser.
255
+ *
256
+ * Two triggers, both cheap: right after a turn settles (the moment the diff
257
+ * changed) and a throttled sweep over every live session (a human editing in
258
+ * the worktree, a build writing files, a ship landing). Best-effort like the
259
+ * narrator: never awaited by a turn, every failure swallowed.
260
+ */
261
+ const WORKTREE_SWEEP_MS = 60_000;
262
+ /** How often the sweep refreshes `origin/<base>` before measuring. The
263
+ * behind-count is the whole point of the readout — "someone pushed while you
264
+ * were working" — and without a fetch it would only ever count what this
265
+ * machine already happened to have. Rarer than the sweep because a fetch is
266
+ * network, and a teammate's push being visible within three minutes is the
267
+ * same promise the rest of the product makes. */
268
+ const WORKTREE_FETCH_MS = 3 * 60_000;
269
+ let lastWorktreeSweep = 0;
270
+ let lastWorktreeFetch = 0;
271
+ let sweepingWorktrees = false;
272
+ const postWorktrees = async (reports) => {
273
+ if (!reports.length) return;
274
+ try {
275
+ await fetch(WORKTREES_URL, {
276
+ method: 'POST',
277
+ headers: {
278
+ Authorization: `Bearer ${FLEET_TOKEN}`,
279
+ 'User-Agent': USER_AGENT,
280
+ 'Content-Type': 'application/json',
281
+ },
282
+ signal: AbortSignal.timeout(20_000),
283
+ body: JSON.stringify({ reports }),
284
+ });
285
+ } catch {
286
+ /* a readout — the next sweep carries it */
287
+ }
288
+ };
289
+ const sessionWorktreeReport = (sessionId) => {
290
+ if (!isSafePathSegment(sessionId)) return null;
291
+ const d = worktreeDiff(join(baseDir, 'sessions', sessionId), baseRef);
292
+ return d ? { sessionId, ...d } : null;
293
+ };
294
+ /** One session, now — called after its turn settles. */
295
+ const reportSessionWorktree = async (sessionId) => {
296
+ const r = sessionWorktreeReport(sessionId);
297
+ if (r) await postWorktrees([r]);
298
+ };
299
+ /** Every live session, throttled — called from the reconcile loop. */
300
+ const reportWorktrees = (activeIds) => {
301
+ if (!Array.isArray(activeIds) || activeIds.length === 0) return;
302
+ if (sweepingWorktrees) return;
303
+ if (Date.now() - lastWorktreeSweep < WORKTREE_SWEEP_MS) return;
304
+ sweepingWorktrees = true;
305
+ lastWorktreeSweep = Date.now();
306
+ void (async () => {
307
+ try {
308
+ // Refresh the base before measuring, so "3 new on main" means what a
309
+ // person thinks it means. Throttled, best-effort, and never fatal: an
310
+ // offline machine reports the counts it can still compute.
311
+ if (Date.now() - lastWorktreeFetch >= WORKTREE_FETCH_MS) {
312
+ lastWorktreeFetch = Date.now();
313
+ try {
314
+ git(['fetch', 'origin', '--quiet'], repoRoot);
315
+ } catch {
316
+ /* offline, or no remote — the numbers just age */
317
+ }
318
+ }
319
+ const reports = [];
320
+ for (const id of activeIds.slice(0, 20)) {
321
+ const r = sessionWorktreeReport(id);
322
+ if (r) reports.push(r);
323
+ }
324
+ await postWorktrees(reports);
325
+ } finally {
326
+ sweepingWorktrees = false;
327
+ }
328
+ })();
329
+ };
330
+
171
331
  let flushingReports = false;
172
332
  const flushWorkReports = async () => {
173
333
  if (flushingReports) return;
@@ -928,6 +1088,7 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
928
1088
  let out;
929
1089
  let seenThreadId = null; // codex's conversation id, off thread.started
930
1090
  const spawned = []; // this turn's children, for the teardown registry
1091
+ const narrator = makeNarrator(job.sessionId);
931
1092
  try {
932
1093
  const message = [job.body, adoptNote, carryNote].filter(Boolean).join('\n\n');
933
1094
  const turnArgs = {
@@ -953,6 +1114,14 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
953
1114
  system: plainTab ? SYSTEM_WORK_PLAIN : SYSTEM_WORK,
954
1115
  // Present only when the tab named one — see brainFor.
955
1116
  ...brain,
1117
+ // The tab watches the CLI work. Claude needs the flag to speak
1118
+ // events at all (codex and agy always do); `answerFromResult`
1119
+ // keeps `out` — which IS the reply posted to the transcript — to
1120
+ // the final result, so streamed prose is narrated once and
1121
+ // posted once. Every line goes to the narrator above, throttled.
1122
+ streamJson: true,
1123
+ answerFromResult: true,
1124
+ onActivity: (a) => narrator.line(a?.label),
956
1125
  cwd: dir.wt,
957
1126
  mcpArgs: mcp.args,
958
1127
  mcpEnv: mcp.env,
@@ -998,6 +1167,10 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
998
1167
  if (!adopting && resume && !(out || '').trim())
999
1168
  out = await runTurn({ ...turnArgs, resume: false });
1000
1169
  } finally {
1170
+ // The CLI has stopped printing, so stop relaying. The LINE itself
1171
+ // is cleared server-side at settle — clearing it here would race
1172
+ // the settle and blank the tab a beat before the reply lands.
1173
+ narrator.stop();
1001
1174
  for (const ch of spawned) workChildren.delete(ch);
1002
1175
  if (lockPath) {
1003
1176
  try {
@@ -1083,6 +1256,12 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
1083
1256
  warn(`session turn failed: ${e?.message ?? e}`);
1084
1257
  } finally {
1085
1258
  workAnswering.delete(job.id);
1259
+ // The turn just changed the directory — say what it looks like now,
1260
+ // whether it succeeded or blew up (a failed turn can still have
1261
+ // written half a file, and the tab should show that honestly). NOT
1262
+ // awaited: this runs inside the session's chain, and a slow POST
1263
+ // would delay the next turn of that tab behind a readout.
1264
+ void reportSessionWorktree(job.sessionId).catch(() => {});
1086
1265
  }
1087
1266
  });
1088
1267
  }
@@ -1420,5 +1599,12 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
1420
1599
  }
1421
1600
  };
1422
1601
 
1423
- return { flushWorkReports, processWorkTurns, processShipJobs, retireWorkSessions, shutdownWork };
1602
+ return {
1603
+ flushWorkReports,
1604
+ processWorkTurns,
1605
+ processShipJobs,
1606
+ retireWorkSessions,
1607
+ reportWorktrees,
1608
+ shutdownWork,
1609
+ };
1424
1610
  }
@@ -0,0 +1,178 @@
1
+ /**
2
+ * What a session's worktree actually holds, measured — not guessed.
3
+ *
4
+ * A Workbench tab IS a directory on this machine, on its own `session/<id>`
5
+ * branch, and the human driving it from a browser cannot run `git status` in
6
+ * it. So the daemon runs it for them: the branch, how far ahead of base it is,
7
+ * and the per-file diffstat — the same numbers `git diff --stat` prints in that
8
+ * directory, relayed rather than interpreted.
9
+ *
10
+ * Measured against the MERGE-BASE with the project's base ref, and against the
11
+ * WORKING TREE rather than HEAD, so one number answers the question a human
12
+ * actually asks ("what has this session changed?") with committed and
13
+ * uncommitted work in the same total. Untracked files count too: git calls them
14
+ * nothing until they are added, and a human calls them new work.
15
+ *
16
+ * Everything here is best-effort and read-only. A worktree mid-rebase, a
17
+ * deleted directory, a file that vanished between listing and reading — each
18
+ * degrades to a smaller answer, never to a thrown error. Nothing about a
19
+ * readout is worth failing a turn over.
20
+ */
21
+
22
+ import { existsSync, statSync, readFileSync } from 'node:fs';
23
+ import { join } from 'node:path';
24
+ import { git } from './git.mjs';
25
+
26
+ /** Rows reported. The rail shows a handful; the totals below cover the rest. */
27
+ const MAX_FILES = 20;
28
+ /** Untracked paths we are willing to open. A stray build directory that isn't
29
+ * gitignored must not turn a 60-second sweep into a disk crawl. */
30
+ const MAX_UNTRACKED_SCAN = 200;
31
+ /** Past this we call a file binary rather than counting its lines. */
32
+ const MAX_COUNT_BYTES = 512 * 1024;
33
+
34
+ /** Lines in a buffer, the way a diff counts them: a trailing newline does not
35
+ * add a line, and a NUL byte anywhere means we are not looking at text. */
36
+ function countLines(buf) {
37
+ if (buf.includes(0)) return null; // binary — git's own heuristic
38
+ const s = buf.toString('utf8');
39
+ if (s === '') return 0;
40
+ const n = s.split('\n').length;
41
+ return s.endsWith('\n') ? n - 1 : n;
42
+ }
43
+
44
+ /**
45
+ * @param {string} wt the worktree directory
46
+ * @param {string} baseRef the project's base ref (e.g. `origin/main`)
47
+ * @returns {null | {branch:string, path:string, ahead:number, behind:number,
48
+ * baseLabel:string, baseCommits:{sha:string, subject:string, author:string}[],
49
+ * dirty:boolean, additions:number, deletions:number, fileCount:number,
50
+ * truncated:number,
51
+ * files:{path:string, added:number, deleted:number, binary?:boolean}[]}}
52
+ */
53
+ export function worktreeDiff(wt, baseRef) {
54
+ if (!wt || !existsSync(wt)) return null;
55
+ let branch = '';
56
+ try {
57
+ branch = git(['rev-parse', '--abbrev-ref', 'HEAD'], wt);
58
+ } catch {
59
+ return null; // not a worktree (or not readable) — report nothing, not zeros
60
+ }
61
+ let base = '';
62
+ try {
63
+ base = git(['merge-base', 'HEAD', baseRef], wt);
64
+ } catch {
65
+ /* a branch with no common ancestor (or an unfetched base) — fall back to
66
+ HEAD below, which still reports the uncommitted half honestly */
67
+ }
68
+
69
+ const files = [];
70
+ let additions = 0;
71
+ let deletions = 0;
72
+ const push = (path, added, deleted, binary = false) => {
73
+ if (!path) return;
74
+ files.push(binary ? { path, added, deleted, binary } : { path, added, deleted });
75
+ additions += added;
76
+ deletions += deleted;
77
+ };
78
+
79
+ // Tracked: working tree vs base. `git diff <base>` (no --cached, no second
80
+ // ref) is exactly "everything this session did", committed or not.
81
+ try {
82
+ const raw = git(['diff', '--numstat', base || 'HEAD'], wt);
83
+ for (const line of raw.split('\n')) {
84
+ if (!line.trim()) continue;
85
+ const [a, d, ...rest] = line.split('\t');
86
+ const path = rest.join('\t');
87
+ const binary = a === '-' || d === '-';
88
+ push(path, binary ? 0 : Number(a) || 0, binary ? 0 : Number(d) || 0, binary);
89
+ }
90
+ } catch {
91
+ /* report what we have */
92
+ }
93
+
94
+ // Untracked, minus everything gitignored — new files are the most visible
95
+ // work a session does and they would otherwise show as nothing at all.
96
+ try {
97
+ const others = git(['ls-files', '--others', '--exclude-standard'], wt)
98
+ .split('\n')
99
+ .filter(Boolean);
100
+ for (const path of others.slice(0, MAX_UNTRACKED_SCAN)) {
101
+ try {
102
+ const full = join(wt, path);
103
+ const st = statSync(full);
104
+ if (!st.isFile()) continue;
105
+ if (st.size > MAX_COUNT_BYTES) {
106
+ push(path, 0, 0, true);
107
+ continue;
108
+ }
109
+ const lines = countLines(readFileSync(full));
110
+ if (lines === null) push(path, 0, 0, true);
111
+ else push(path, lines, 0);
112
+ } catch {
113
+ /* vanished between listing and reading — it wasn't there to report */
114
+ }
115
+ }
116
+ } catch {
117
+ /* report what we have */
118
+ }
119
+
120
+ let ahead = 0;
121
+ try {
122
+ if (base) ahead = Number(git(['rev-list', '--count', `${base}..HEAD`], wt)) || 0;
123
+ } catch {
124
+ /* leave at 0 */
125
+ }
126
+ // WHAT LANDED WHILE YOU WERE WORKING. Not the branch's own history — the
127
+ // commits on BASE that this worktree doesn't have, which is the thing a
128
+ // person cannot see from inside their own session and the reason they end up
129
+ // rebasing onto a surprise. Freshness is the caller's job: these are only as
130
+ // current as the last fetch (reportWorktrees throttles one).
131
+ let behind = 0;
132
+ const baseCommits = [];
133
+ try {
134
+ behind = Number(git(['rev-list', '--count', `HEAD..${baseRef}`], wt)) || 0;
135
+ if (behind > 0) {
136
+ // %x1f is the unit separator — a subject can contain anything a person
137
+ // can type, tabs and pipes included, so the delimiter must be one that
138
+ // cannot appear in it.
139
+ const raw = git(
140
+ ['log', '-n', '3', '--format=%h%x1f%s%x1f%an', `HEAD..${baseRef}`],
141
+ wt
142
+ );
143
+ for (const line of raw.split('\n')) {
144
+ if (!line.trim()) continue;
145
+ const [sha, subject, author] = line.split('\x1f');
146
+ if (sha) baseCommits.push({ sha, subject: subject ?? '', author: author ?? '' });
147
+ }
148
+ }
149
+ } catch {
150
+ /* an unfetched or missing base — say nothing rather than "you're current" */
151
+ }
152
+ let dirty = false;
153
+ try {
154
+ dirty = git(['status', '--porcelain'], wt) !== '';
155
+ } catch {
156
+ /* leave at false */
157
+ }
158
+
159
+ // Biggest first: with a 20-row cap, the rows that survive should be the ones
160
+ // worth looking at. Ties break by path so the list doesn't shuffle per sweep.
161
+ files.sort(
162
+ (x, y) => y.added + y.deleted - (x.added + x.deleted) || (x.path < y.path ? -1 : 1)
163
+ );
164
+ return {
165
+ branch,
166
+ path: wt,
167
+ ahead,
168
+ behind,
169
+ baseLabel: String(baseRef).replace(/^origin\//, ''),
170
+ baseCommits,
171
+ dirty,
172
+ additions,
173
+ deletions,
174
+ fileCount: files.length,
175
+ truncated: Math.max(0, files.length - MAX_FILES),
176
+ files: files.slice(0, MAX_FILES),
177
+ };
178
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.47.2",
3
+ "version": "0.48.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": {