flowviant 0.58.0 → 0.60.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.
package/bin/lib/fleet.mjs CHANGED
@@ -86,6 +86,7 @@ import {
86
86
  } from './runtimes.mjs';
87
87
  import { createWorkManager } from './work.mjs';
88
88
  import { scanLocalSessions } from './localSessions.mjs';
89
+ import { repoState } from './repoState.mjs';
89
90
 
90
91
  async function fetchRoster(
91
92
  haveIds,
@@ -298,6 +299,62 @@ async function maybeReportLocalSessions({ repoRoot, excludeDirs }) {
298
299
  }
299
300
  }
300
301
 
302
+ /**
303
+ * EVERY BRANCH AND WORKTREE ON THIS MACHINE, pushed on the same beat as
304
+ * presence — the answer to "is my Claude leaving a mess in here?".
305
+ *
306
+ * Same three economies as the presence report above and for the same reasons:
307
+ * scanned at most once a minute, not re-sent while identical (repoState orders
308
+ * deterministically so the string only moves when the repo does), and silent
309
+ * for the rest of the process once an older server 404s.
310
+ *
311
+ * ONE DIFFERENCE, deliberate: there is no re-send heartbeat window. Presence
312
+ * expires in the UI because a session that ENDED must stop reading as live;
313
+ * a branch list is not presence — a branch that existed a minute ago still
314
+ * exists — so re-posting an unchanged list would be a write per machine per
315
+ * five minutes to say nothing at all.
316
+ */
317
+ const REPO_STATE_URL = FLEET_URL.replace(/\/agents\/?$/, '/repo-state');
318
+ const REPO_STATE_SCAN_MS = 60_000;
319
+ let repoStateUnsupported = false; // the server 404'd — quiet until restart
320
+ let repoStateScanAt = 0;
321
+ let repoStateSent = null; // last payload the server ACCEPTED, stringified
322
+ async function maybeReportRepoState({ repoRoot, baseRef }) {
323
+ if (repoStateUnsupported) return;
324
+ if (Date.now() - repoStateScanAt < REPO_STATE_SCAN_MS) return;
325
+ repoStateScanAt = Date.now();
326
+ let payload;
327
+ try {
328
+ const state = repoState(repoRoot, baseRef);
329
+ if (!state) return; // not readable — say nothing rather than say "none"
330
+ payload = JSON.stringify(state);
331
+ } catch {
332
+ return; // a readout must never throw into the poll loop
333
+ }
334
+ if (payload === repoStateSent) return;
335
+ try {
336
+ const res = await fetch(REPO_STATE_URL, {
337
+ method: 'POST',
338
+ headers: {
339
+ Authorization: `Bearer ${FLEET_TOKEN}`,
340
+ 'User-Agent': USER_AGENT,
341
+ 'Content-Type': 'application/json',
342
+ },
343
+ signal: AbortSignal.timeout(15_000),
344
+ body: payload,
345
+ });
346
+ if (res.status === 404) {
347
+ repoStateUnsupported = true; // older server — nothing was replaced here
348
+ return;
349
+ }
350
+ // Only an ACCEPTED report counts: anything else forgets it so the next
351
+ // pass retries rather than dedup-suppressing a report nobody received.
352
+ repoStateSent = res.ok ? payload : null;
353
+ } catch {
354
+ repoStateSent = null;
355
+ }
356
+ }
357
+
301
358
  /**
302
359
  * A STOP COMMANDED BY FLOWVIANT, read off the roster poll.
303
360
  *
@@ -1544,6 +1601,9 @@ export async function runFleetDaemon() {
1544
1601
  // the daemon's own worktrees are carved out (a session the daemon spawned
1545
1602
  // is already a tab, not something to offer adopting).
1546
1603
  void maybeReportLocalSessions({ repoRoot, excludeDirs: [baseDir] });
1604
+ // …and the repo itself: every worktree and every branch, ours and not.
1605
+ // Never awaited, throttled inside, and silent on an older server.
1606
+ void maybeReportRepoState({ repoRoot, baseRef });
1547
1607
  // WHAT `/` CAN OFFER, on a machine no turn has taught yet. One-shot and
1548
1608
  // self-cancelling (it returns immediately if a turn has already reported),
1549
1609
  // never awaited, and it lands in the cache that the NEXT poll reads — so
@@ -0,0 +1,183 @@
1
+ /**
2
+ * EVERY WORKTREE AND EVERY BRANCH ON THIS MACHINE — including the ones
3
+ * Flowviant did not make.
4
+ *
5
+ * WHY THIS EXISTS, in the user's words: "can we show all branches or worktrees
6
+ * so we know if claude is polluting the branches or worktrees or not". The
7
+ * Workbench already reports the branch a TAB is standing on, but only for
8
+ * sessions the server knows about — so a branch your Claude cut mid-turn, a
9
+ * worktree left behind by a crash, or anything you made yourself at the
10
+ * keyboard was invisible from the browser. That is the same gap the Changes
11
+ * block was built to close, one level up: a browser has no `git branch` to run,
12
+ * so the machine runs it.
13
+ *
14
+ * IT IS A RELAY, NOT A JUDGEMENT. Nothing here decides what "pollution" is —
15
+ * it reports what git says and marks which refs Flowviant itself created
16
+ * (`session/<id>`), because that is a FACT about who made them and it is the
17
+ * distinction the question is actually asking about. No cleanup, no warnings,
18
+ * no "you have too many branches": the surface counts what is there, and a
19
+ * person decides.
20
+ *
21
+ * BOUNDED AT THE MACHINE, like every other report in this daemon. A repo with
22
+ * eight hundred branches must not put eight hundred rows on the wire every
23
+ * minute; the newest are kept, the rest are counted, and the caller says so
24
+ * rather than letting a short list read as the whole repo.
25
+ *
26
+ * DETERMINISTIC ORDER, for the same reason `recordSkills` sorts: the report is
27
+ * dedupe-compared against the last one that was accepted, and an unstable order
28
+ * would post a "change" every single minute forever.
29
+ *
30
+ * NOTHING HERE THROWS. It runs inside the poll loop's best-effort tail, and a
31
+ * repo mid-rebase or an unborn HEAD is a field to omit, not an error to raise.
32
+ */
33
+
34
+ import { execFileSync } from 'node:child_process';
35
+ import { listenersIn, listenersSupported } from './listeners.mjs';
36
+
37
+ /** Same cap the session diffstat uses: enough to see the shape, small enough
38
+ * that one machine cannot flood a row. */
39
+ const MAX_BRANCHES = 60;
40
+ const MAX_WORKTREES = 40;
41
+
42
+ function git(args, cwd) {
43
+ return execFileSync('git', args, {
44
+ cwd,
45
+ encoding: 'utf8',
46
+ stdio: ['ignore', 'pipe', 'ignore'],
47
+ timeout: 10_000,
48
+ maxBuffer: 8 * 1024 * 1024,
49
+ }).trim();
50
+ }
51
+
52
+ /** A ref Flowviant itself cut for a tab. The ONLY thing that makes a branch
53
+ * "ours", and the distinction the whole report exists to draw. */
54
+ export function isSessionBranch(name) {
55
+ return /^session\//.test(name);
56
+ }
57
+
58
+ /**
59
+ * `git worktree list --porcelain` → rows. The porcelain form is parsed rather
60
+ * than the human one because the human one aligns columns with spaces and a
61
+ * path containing a space silently splits into the wrong fields.
62
+ */
63
+ function readWorktrees(repoRoot) {
64
+ let out;
65
+ try {
66
+ out = git(['worktree', 'list', '--porcelain'], repoRoot);
67
+ } catch {
68
+ return null; // not a git repo, or git is unhappy — say nothing
69
+ }
70
+ const rows = [];
71
+ let cur = null;
72
+ for (const line of out.split('\n')) {
73
+ if (line.startsWith('worktree ')) {
74
+ if (cur) rows.push(cur);
75
+ cur = { path: line.slice(9), branch: null, detached: false, locked: false, prunable: false };
76
+ } else if (!cur) {
77
+ continue;
78
+ } else if (line.startsWith('branch ')) {
79
+ cur.branch = line.slice(7).replace(/^refs\/heads\//, '');
80
+ } else if (line === 'detached') {
81
+ cur.detached = true;
82
+ } else if (line.startsWith('locked')) {
83
+ cur.locked = true;
84
+ } else if (line.startsWith('prunable')) {
85
+ // A directory git still lists but that is gone from disk — exactly the
86
+ // "left behind" case somebody looking for mess wants to see.
87
+ cur.prunable = true;
88
+ }
89
+ }
90
+ if (cur) rows.push(cur);
91
+ return rows;
92
+ }
93
+
94
+ /**
95
+ * Local branches with their distance from base.
96
+ *
97
+ * `for-each-ref` does the whole thing in ONE process — a `rev-list` per branch
98
+ * would be sixty spawns a minute on a busy repo. `%(ahead-behind:<ref>)` needs
99
+ * git 2.41+; when it is missing the counts are simply absent and the surface
100
+ * shows names without numbers, which is still the answer to "what is here".
101
+ */
102
+ function readBranches(repoRoot, baseRef) {
103
+ const fmt = '%(refname:short)%09%(committerdate:unix)%09%(ahead-behind:' + baseRef + ')';
104
+ let out;
105
+ try {
106
+ out = git(['for-each-ref', '--sort=-committerdate', `--format=${fmt}`, 'refs/heads'], repoRoot);
107
+ } catch {
108
+ // No ahead-behind on this git. Names and dates still answer most of it.
109
+ try {
110
+ out = git(
111
+ ['for-each-ref', '--sort=-committerdate', '--format=%(refname:short)%09%(committerdate:unix)', 'refs/heads'],
112
+ repoRoot
113
+ );
114
+ } catch {
115
+ return null;
116
+ }
117
+ }
118
+ const rows = [];
119
+ for (const line of out.split('\n')) {
120
+ if (!line.trim()) continue;
121
+ const [name, when, ab] = line.split('\t');
122
+ if (!name) continue;
123
+ const row = { name, at: Number(when) || 0, session: isSessionBranch(name) };
124
+ // `ahead-behind` prints "N M" — ahead of base, behind base, in that order.
125
+ if (ab) {
126
+ const [a, b] = ab.trim().split(/\s+/).map((n) => parseInt(n, 10));
127
+ if (Number.isFinite(a)) row.ahead = a;
128
+ if (Number.isFinite(b)) row.behind = b;
129
+ }
130
+ rows.push(row);
131
+ }
132
+ return rows;
133
+ }
134
+
135
+ /**
136
+ * The whole picture, or null if this is not a repo we can read.
137
+ *
138
+ * `truncated` is not decoration: a list silently cut at sixty reads as the
139
+ * whole repo, and the one question this report exists to answer is "how much is
140
+ * in here". Same rule the session diffstat's own `truncated` keeps.
141
+ */
142
+ export function repoState(repoRoot, baseRef) {
143
+ const worktrees = readWorktrees(repoRoot);
144
+ const branches = readBranches(repoRoot, baseRef);
145
+ if (!worktrees && !branches) return null;
146
+ /**
147
+ * WHAT IS LISTENING IN THE CHECKOUT ITSELF.
148
+ *
149
+ * `listenersIn` has always taken any directory, and had only ever been asked
150
+ * about SESSION WORKTREES — so somebody running `npm run dev` in their normal
151
+ * checkout, which is what "just testing or playing around in dev" actually
152
+ * looks like, was invisible to every surface in the product. The measurement
153
+ * was there; nobody was pointing it at the repo.
154
+ *
155
+ * THE ATTRIBUTION RULE IS UNCHANGED, and it is the reason this widens to the
156
+ * repo root and no further: a port is attributed by the CWD OF THE PROCESS
157
+ * HOLDING THE SOCKET, so this reports servers running inside THIS PROJECT'S
158
+ * checkout and nothing else. Postgres on 5432 has its own cwd and does not
159
+ * appear here — which is the whole point, and why "just show every port on
160
+ * the box" is not what this does.
161
+ *
162
+ * Reported, not offered: this is a readout of what is up. Sharing one is a
163
+ * separate act with its own gates (see previewJobs).
164
+ */
165
+ const listening = listenersIn(repoRoot);
166
+ const wt = worktrees ?? [];
167
+ const br = branches ?? [];
168
+ return {
169
+ base: baseRef,
170
+ worktrees: wt.slice(0, MAX_WORKTREES),
171
+ worktreesTotal: wt.length,
172
+ // Newest first (for-each-ref already sorted), so a truncated list is the
173
+ // part somebody is actually working in.
174
+ branches: br.slice(0, MAX_BRANCHES),
175
+ branchesTotal: br.length,
176
+ sessionBranches: br.filter((b) => b.session).length,
177
+ listening,
178
+ // "Nothing is listening" and "this machine cannot look" (Windows, a failed
179
+ // scan) are the same empty array without this — and the second must never
180
+ // render as the first. Same field, same reason, as the session report.
181
+ listeningSupported: listenersSupported(),
182
+ };
183
+ }
package/bin/lib/work.mjs CHANGED
@@ -51,6 +51,10 @@ import {
51
51
  } from './prompts.mjs';
52
52
  import { materializeInto, hasMaterialized, excludeInWorktree, scrub as envScrub } from './env.mjs';
53
53
  import { detectRuntimes, canRun, recordSkills, RUNTIMES } from './runtimes.mjs';
54
+
55
+ /** The place id meaning "the checkout", not a worktree. Must match the
56
+ * server's REPO_PLACE — it is a wire value, not a local convention. */
57
+ const REPO_PLACE = 'repo';
54
58
  import { isTerminalSessionLive, isAgyConversationLive } from './localSessions.mjs';
55
59
  import { worktreeDiff } from './worktreeDiff.mjs';
56
60
  import { homedir } from 'node:os';
@@ -119,8 +123,22 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
119
123
  * while that session's turn has a live CLI in it.
120
124
  */
121
125
  const workChains = new Map(); // sessionId -> settled-safe tail promise
122
- const chainFor = (sessionId, fn) => {
123
- const prev = workChains.get(sessionId) ?? Promise.resolve();
126
+ /**
127
+ * Serialize work by PLACE — the directory — not by session.
128
+ *
129
+ * It was keyed by session id, which was the same thing right up until a place
130
+ * could be shared: two sessions pointed at one worktree had independent
131
+ * chains, so their turns would run at the same time in the same directory and
132
+ * edit each other's files mid-edit. Keying on the place is what makes "two
133
+ * tabs in one repo" behave the way two terminal tabs in one repo behave —
134
+ * they take turns.
135
+ *
136
+ * The cross-PROCESS half was already right and needed no change: the turn
137
+ * lock is a file inside the worktree (`flowviant-turn.lock`), so two sessions
138
+ * sharing a place already share the lock by construction.
139
+ */
140
+ const chainFor = (placeId, fn) => {
141
+ const prev = workChains.get(placeId) ?? Promise.resolve();
124
142
  // `.then(fn, fn)`, like withWikiLock: one rejected link must never wedge
125
143
  // every later turn of the tab.
126
144
  const run = prev.then(fn, fn);
@@ -128,11 +146,11 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
128
146
  () => {},
129
147
  () => {}
130
148
  );
131
- workChains.set(sessionId, stored);
149
+ workChains.set(placeId, stored);
132
150
  // Release the entry when the chain drains, so the map cannot grow for the
133
151
  // process lifetime and `workChains.has()` means "busy right now".
134
152
  stored.then(() => {
135
- if (workChains.get(sessionId) === stored) workChains.delete(sessionId);
153
+ if (workChains.get(placeId) === stored) workChains.delete(placeId);
136
154
  });
137
155
  return run;
138
156
  };
@@ -319,6 +337,32 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
319
337
  * network, and a teammate's push being visible within three minutes is the
320
338
  * same promise the rest of the product makes. */
321
339
  const WORKTREE_FETCH_MS = 3 * 60_000;
340
+ /**
341
+ * WHERE EACH SESSION WORKS, learned from the turns we are handed.
342
+ *
343
+ * Every other beat — the worktree sweep, ship, the preview re-check — has to
344
+ * ask the SAME directory the turn ran in, and only the turn job carries
345
+ * `place`. Caching it here is what keeps them agreeing without a second
346
+ * server→daemon field: a session absent from this map has never run a turn,
347
+ * and its own id is the right answer for that case anyway (it is the default
348
+ * place, and a session with no turn has no worktree either).
349
+ *
350
+ * A tab standing in the CHECKOUT is the case this exists for: its directory
351
+ * is not `sessions/<id>` and never will be, so a sweep that assumed the
352
+ * default would measure a directory that does not exist and report nothing —
353
+ * which is exactly why "I still do not see a preview URL" was true of a tab
354
+ * opened in the checkout.
355
+ */
356
+ const sessionPlaces = new Map();
357
+ const placeOf = (sessionId) => sessionPlaces.get(sessionId) ?? sessionId;
358
+ /** The DIRECTORY a session works in. Every path that used to build
359
+ * `sessions/<id>` by hand goes through here, or a tab in the checkout gets
360
+ * measured against a directory that does not exist. */
361
+ const placeDir = (sessionId) => {
362
+ const place = placeOf(sessionId);
363
+ return place === REPO_PLACE ? repoRoot : join(baseDir, 'sessions', place);
364
+ };
365
+
322
366
  let lastWorktreeSweep = 0;
323
367
  let lastWorktreeFetch = 0;
324
368
  let sweepingWorktrees = false;
@@ -340,8 +384,11 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
340
384
  }
341
385
  };
342
386
  const sessionWorktreeReport = (sessionId) => {
343
- if (!isSafePathSegment(sessionId)) return null;
344
- const wt = join(baseDir, 'sessions', sessionId);
387
+ // The session's PLACE, not its name: a tab in the checkout is measured in
388
+ // the checkout, and a tab sharing another tab's worktree is measured there.
389
+ const place = placeOf(sessionId);
390
+ if (place !== REPO_PLACE && !isSafePathSegment(place)) return null;
391
+ const wt = placeDir(sessionId);
345
392
  const d = worktreeDiff(wt, baseRef);
346
393
  if (!d) return null;
347
394
  // WHAT IS LISTENING in this worktree, attributed by the CWD of the process
@@ -567,7 +614,7 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
567
614
  void (async () => {
568
615
  try {
569
616
  if (!(await claimPreview(sessionId))) return; // somebody else has it
570
- const wt = join(baseDir, 'sessions', sessionId);
617
+ const wt = placeDir(sessionId);
571
618
  // RE-VALIDATE the attribution here, not just the liveness. The server
572
619
  // checked this port against a report up to a minute old; more
573
620
  // importantly, checking `listenersIn` again is what keeps the answer
@@ -755,7 +802,7 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
755
802
  void (async () => {
756
803
  try {
757
804
  if (!(await claimDevRun(sessionId))) return; // somebody else has it
758
- const wt = join(baseDir, 'sessions', sessionId);
805
+ const wt = placeDir(sessionId);
759
806
  const r = await startDevServer({
760
807
  sessionId,
761
808
  worktree: wt,
@@ -827,7 +874,7 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
827
874
  const adoptDevRuns = (activeIds) => {
828
875
  let adopted = 0;
829
876
  for (const entry of reapOrphanDevRuns(activeIds, note)) {
830
- const wt = join(baseDir, 'sessions', entry.sessionId);
877
+ const wt = placeDir(entry.sessionId);
831
878
  // The recorded cwd must still be this session's worktree. A recycled pid
832
879
  // pointing anywhere else is somebody else's process.
833
880
  if (entry.cwd !== wt) continue;
@@ -1159,8 +1206,34 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
1159
1206
  * is the point. If the directory was retired but the branch survives, the
1160
1207
  * worktree re-attaches to the branch and the committed work is still there.
1161
1208
  */
1162
- const sessionWtFor = (sessionId, baseAt) => {
1163
- if (!isSafePathSegment(sessionId)) return null;
1209
+ /**
1210
+ * WHERE A SESSION WORKS — its PLACE, which is a directory on a branch.
1211
+ *
1212
+ * A session used to BE a worktree: one tab, one directory, cut at birth and
1213
+ * retired at close. That binding was never an isolation guarantee — a turn
1214
+ * runs with permissions skipped, so the worktree is a starting directory and
1215
+ * not a fence, and any agent could always `cd` into another one. The product
1216
+ * was asserting an invariant it did not have.
1217
+ *
1218
+ * So a session now REFERENCES a place rather than being one. Many sessions
1219
+ * may name the same place; a session may name the repo checkout itself; and
1220
+ * `session/<own-id>` is simply the DEFAULT place, cut fresh at first turn,
1221
+ * which is why an absent `place` behaves exactly as every existing tab does.
1222
+ *
1223
+ * `'repo'` IS NOT A DIRECTORY NAME AND MUST NOT BECOME ONE. It resolves to
1224
+ * the checkout the daemon already serves — never created, never retired,
1225
+ * because it is not ours to remove. The value reaching here is a server-side
1226
+ * enum, never a browser-supplied path: `sessions.routes.ts` resolves it the
1227
+ * same way adoption resolves a cwd, and for the same reason.
1228
+ */
1229
+ const placeWtFor = (placeId, baseAt) => {
1230
+ if (placeId === REPO_PLACE) {
1231
+ // The checkout. `fresh: false` on purpose — nothing was opened, so no
1232
+ // caller may treat this as a newly-cut branch.
1233
+ return { wt: repoRoot, fresh: false };
1234
+ }
1235
+ if (!isSafePathSegment(placeId)) return null;
1236
+ const sessionId = placeId;
1164
1237
  const wt = join(baseDir, 'sessions', sessionId);
1165
1238
  const fresh = !existsSync(wt);
1166
1239
  if (fresh) {
@@ -1538,6 +1611,10 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
1538
1611
  }
1539
1612
  };
1540
1613
 
1614
+ /** The pre-places name, kept so every existing caller reads unchanged: a
1615
+ * session's own id IS its default place. */
1616
+ const sessionWtFor = (sessionId, baseAt) => placeWtFor(sessionId, baseAt);
1617
+
1541
1618
  const retireWorkSessions = (activeIds, heldElsewhere) => {
1542
1619
  if (!Array.isArray(activeIds)) return;
1543
1620
  // Sessions ANOTHER daemon on this credential is serving. They are absent
@@ -1596,7 +1673,13 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
1596
1673
  // run it again while the report is merely undelivered.
1597
1674
  if (pendingWorkReports.has(job.id)) continue;
1598
1675
  workAnswering.add(job.id);
1599
- chainFor(job.sessionId, async () => {
1676
+ // Serialized by PLACE: two tabs sharing a worktree take turns in it
1677
+ // rather than editing the same files at the same time.
1678
+ const place = job.place || job.sessionId;
1679
+ // Remembered for every other beat — the sweep, ship, the preview
1680
+ // re-check — so they all ask the same directory this turn runs in.
1681
+ sessionPlaces.set(job.sessionId, place);
1682
+ chainFor(place, async () => {
1600
1683
  try {
1601
1684
  const tries = workAttempts.get(job.id) ?? 0;
1602
1685
  if (tries >= MAX_WORK_TRIES) {
@@ -1711,7 +1794,10 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
1711
1794
  }
1712
1795
  // Based at the SOURCE's HEAD when adopting — the resumed
1713
1796
  // conversation was had against those commits, not the project base.
1714
- const dir = sessionWtFor(job.sessionId, adopting ? srcHead : undefined);
1797
+ // The PLACE this tab works in its own worktree unless the server
1798
+ // named another. An older server sends no `place` and the default is
1799
+ // the session's own id, which is what every tab has always done.
1800
+ const dir = placeWtFor(place, adopting ? srcHead : undefined);
1715
1801
  if (!dir) {
1716
1802
  await settleWorkTurn(job.id, {
1717
1803
  ok: false,
@@ -2193,8 +2279,55 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
2193
2279
  return;
2194
2280
  }
2195
2281
  note(`${c.cyan('ship')} ${c.dim(`— "${job.sessionName || job.sessionId}"`)}`);
2196
- const branch = `session/${job.sessionId}`;
2197
- const wt = join(baseDir, 'sessions', job.sessionId);
2282
+ /**
2283
+ * WHAT IS ACTUALLY CHECKED OUT — not what we named it at birth.
2284
+ *
2285
+ * Ship used to compute `session/<id>` and then REFUSE if HEAD had
2286
+ * moved: "ask it to return to its session branch, then ship again".
2287
+ * That refusal is the thing this product says it never does — it had
2288
+ * no reason of its own beyond bookkeeping, and in a terminal
2289
+ * `git checkout -b` breaks nothing, which is the whole standard this
2290
+ * surface is held to.
2291
+ *
2292
+ * The bug it was written for was real and is fixed properly here
2293
+ * rather than frozen out: ship once merged the branch NAME while
2294
+ * logging HEAD, so receipts named commits that never landed on main.
2295
+ * That was TWO SOURCES OF TRUTH, not branch switching. There is one
2296
+ * now, and it is the worktree's own HEAD.
2297
+ *
2298
+ * Resolved BEFORE the idempotency check below, and that ordering is
2299
+ * load-bearing: `session/<id>` can still exist, stale and already an
2300
+ * ancestor of base, while the real work sits on the branch that was
2301
+ * checked out afterwards. Asking the old name first would answer
2302
+ * "already merged — nothing new to ship" over unshipped commits.
2303
+ *
2304
+ * A directory that is gone (a retired or closed tab) cannot be asked,
2305
+ * so the recorded name is the fallback — the one case where the name
2306
+ * is the only thing there is.
2307
+ */
2308
+ // The session's PLACE — the directory it actually works in.
2309
+ const shipPlace = placeOf(job.sessionId);
2310
+ const wt = shipPlace === REPO_PLACE ? repoRoot : join(baseDir, 'sessions', shipPlace);
2311
+ let branch = `session/${job.sessionId}`;
2312
+ let detached = false;
2313
+ if (existsSync(wt)) {
2314
+ try {
2315
+ branch = git(['symbolic-ref', '--short', 'HEAD'], wt);
2316
+ } catch {
2317
+ detached = true;
2318
+ }
2319
+ }
2320
+ // THE ONE REFUSAL LEFT, and it is not policy. A detached HEAD names
2321
+ // no branch, so there is nothing to merge and nothing to record —
2322
+ // that is an ambiguity in git, not a rule of ours.
2323
+ if (detached) {
2324
+ await done({
2325
+ ok: false,
2326
+ error:
2327
+ 'this session is on a detached HEAD — no branch to ship. Ask it to check out a branch, then ship again',
2328
+ });
2329
+ return;
2330
+ }
2198
2331
  let branchExists = true;
2199
2332
  try {
2200
2333
  git(['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`], repoRoot);
@@ -2360,7 +2493,7 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
2360
2493
  ok(`${c.cyan('ship')} ${c.dim(`— ${commits.length} commit${commits.length === 1 ? '' : 's'} on main`)}`);
2361
2494
  return;
2362
2495
  }
2363
- const dir = sessionWtFor(job.sessionId);
2496
+ const dir = placeWtFor(shipPlace);
2364
2497
  if (!dir) {
2365
2498
  await done({
2366
2499
  ok: false,
@@ -2388,23 +2521,11 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
2388
2521
  });
2389
2522
  return;
2390
2523
  }
2391
- // What is checked out here must BE the session branch. Sessions may
2392
- // create branches when asked but then "ship" is ambiguous, and
2393
- // folding+logging HEAD while merging the stale branch NAME once
2394
- // shipped receipts for commits that never landed on main.
2395
- let head = null;
2396
- try {
2397
- head = git(['symbolic-ref', '--short', 'HEAD'], dir.wt);
2398
- } catch {
2399
- /* detached */
2400
- }
2401
- if (head !== branch) {
2402
- await done({
2403
- ok: false,
2404
- error: `the session is on ${head ? `branch '${head}'` : 'a detached HEAD'}, not its own '${branch}' — ask it to return to its session branch, then ship again`,
2405
- });
2406
- return;
2407
- }
2524
+ // NO "return to your session branch" GUARD. `branch` was read from
2525
+ // this worktree's HEAD above, so the fold, the tip and the receipts
2526
+ // below all name the same thing by construction — which is what the
2527
+ // old guard was really protecting, and it protected it by refusing
2528
+ // instead of by measuring.
2408
2529
  // Fold main into the branch FIRST: conflicts land here, in the
2409
2530
  // session's own worktree, where the next turn can resolve them.
2410
2531
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.58.0",
3
+ "version": "0.60.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": {