flowviant 0.67.0 → 0.69.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.
@@ -337,17 +337,31 @@ export const RUNTIMES = {
337
337
  * strongest form of it available anywhere: `--append-system-prompt` sits
338
338
  * above the conversation rather than inside it.
339
339
  */
340
- args({ prompt, system, model, effort, resume, streamJson, perm, mcp = [], resultSchemaArgs = [], adoptResumeId }) {
340
+ args({ prompt, system, model, effort, resume, streamJson, perm, mcp = [], resultSchemaArgs = [], adoptResumeId, resumeThreadId }) {
341
341
  const a = [];
342
- // ADOPTION the first turn of a tab born from a terminal session.
343
- // `--resume <id> --fork-session` finds the session globally (any cwd),
344
- // carries its full context, and writes the FORK natively into THIS cwd's
345
- // own store, leaving the original transcript untouched (measured on
346
- // 2.1.234). From turn 2 on the plain `--continue` below resumes the fork
347
- // where it now lives, so nothing downstream knows the tab was adopted.
348
- // INSTEAD of --continue, never alongside it: they are the same decision
349
- // ("what conversation is this?") answered two different ways.
342
+ // THREE ANSWERS TO ONE QUESTION "what conversation is this?" and they
343
+ // are mutually exclusive, never combined.
344
+ //
345
+ // ADOPTION: `--resume <id> --fork-session` finds the session globally
346
+ // (any cwd), carries its full context, and writes the FORK natively into
347
+ // THIS cwd's own store, leaving the original transcript untouched
348
+ // (measured on 2.1.234).
349
+ //
350
+ // BY ID: the conversation THIS TAB spoke under last time, learned from
351
+ // the CLI's own `system.init` event and pinned per session. It exists
352
+ // because `--continue` is CWD-KEYED, and a directory stopped being one
353
+ // tab the day tabs moved into their driver's project folder — two tabs
354
+ // sharing a directory both said `--continue` and both resumed whichever
355
+ // conversation spoke most recently there, so tab B inherited tab A's
356
+ // entire context and every turn after that ping-ponged between them.
357
+ // Exactly the failure codex's own note warns about for `resume --last`,
358
+ // arriving for Claude by a different route. An id is unambiguous wherever
359
+ // the tab is standing.
360
+ //
361
+ // `--continue` is the LAST resort, and only where nothing better is
362
+ // known.
350
363
  if (adoptResumeId) a.push('--resume', adoptResumeId, '--fork-session');
364
+ else if (resumeThreadId) a.push('--resume', resumeThreadId);
351
365
  else if (resume) a.push('--continue');
352
366
  a.push('-p', prompt, '--append-system-prompt', system);
353
367
  a.push(...mcp, ...resultSchemaArgs);
@@ -0,0 +1,132 @@
1
+ import { baseBranchName } from './git.mjs';
2
+
3
+ /**
4
+ * CARRY A SHIPPED TIP OUT ONTO BASE AND PUSH IT.
5
+ *
6
+ * Through a THROWAWAY DETACHED WORKTREE, which is the whole shape of this: the
7
+ * merge commit has to be made somewhere, and making it in anybody's checkout
8
+ * moves a directory somebody is working in. A detached worktree at base is
9
+ * nobody's, so the merge lands, the push goes, and the directory dies in the
10
+ * `finally` — on success, on conflict, on throw. It must die, or the next ship
11
+ * of this session trips over its corpse.
12
+ *
13
+ * `--no-ff`, NEVER squash: every delivered card carries commit shas as its
14
+ * receipts, and squashing would point all of them at commits that no longer
15
+ * exist on base.
16
+ *
17
+ * Two behaviours beyond that, and both arrived with per-person worktrees.
18
+ */
19
+ export function mergeOutward({
20
+ tip,
21
+ count,
22
+ branch,
23
+ label,
24
+ git,
25
+ gitMerge,
26
+ repoRoot,
27
+ tmpDir,
28
+ baseRef,
29
+ workingTree,
30
+ warn,
31
+ }) {
32
+ const dropTmp = () => {
33
+ try {
34
+ git(['worktree', 'remove', '--force', tmpDir], repoRoot);
35
+ } catch {
36
+ /* not there — fine */
37
+ }
38
+ };
39
+ const attempt = () => {
40
+ dropTmp();
41
+ git(['worktree', 'add', '--detach', tmpDir, baseRef()], repoRoot);
42
+ gitMerge(
43
+ ['merge', '--no-ff', tip, '-m', `ship(${label}): ${count} commit${count === 1 ? '' : 's'}`],
44
+ tmpDir
45
+ );
46
+ git(['push', 'origin', `HEAD:${baseBranchName(baseRef())}`], tmpDir);
47
+ };
48
+ try {
49
+ try {
50
+ attempt();
51
+ } catch (e) {
52
+ /**
53
+ * TWO PEOPLE SHIPPED AT ONCE — retry exactly once.
54
+ *
55
+ * Ship takes a write lock on a PLACE, and since every person works in a
56
+ * directory of their own, two teammates shipping hold two DIFFERENT
57
+ * locks and nothing serializes them. Both fetch, both merge onto the same
58
+ * base in their own throwaway, and whoever pushes second is rejected
59
+ * non-fast-forward. The window is fetch-to-push, and it did not exist
60
+ * while everyone shared one directory — it arrived with the split.
61
+ *
62
+ * WITHOUT THIS the loser is told their ship FAILED, in raw git, over a
63
+ * race that resolves itself by looking again. That breaks the promise
64
+ * this path exists to keep: nobody may be left believing their work is
65
+ * or is not on base when the opposite is true.
66
+ *
67
+ * ONCE, not a loop. A second rejection is no longer a race — it is a repo
68
+ * something else is writing to continuously, and the honest answer there
69
+ * is the error. The retry re-fetches and rebuilds the throwaway from the
70
+ * NEW base, so it merges against what the winner just landed rather than
71
+ * re-pushing a stale merge. The TIP is untouched, so the receipts still
72
+ * name exactly the same commits.
73
+ */
74
+ if (!isRaceRejection(e)) throw e;
75
+ warn?.('ship: base moved under us — refetching and merging again');
76
+ try {
77
+ git(['fetch', 'origin', '--quiet'], repoRoot);
78
+ } catch {
79
+ /* offline — the retry fails honestly on the same push */
80
+ }
81
+ attempt();
82
+ }
83
+ /**
84
+ * AND BRING THE SHIP PLACE'S OWN BRANCH UP, when that branch IS base.
85
+ *
86
+ * Which is every tab belonging to the machine's OPERATOR: their place is
87
+ * the project folder, and it sits on main. Without this, main is left one
88
+ * commit behind `origin/main` the instant it ships, because the `--no-ff`
89
+ * merge exists only on the remote — and `worktreeDiff` computes `behind` as
90
+ * `HEAD..origin/main` without filtering merges, so the rail immediately
91
+ * reported "1 new on main since you branched" and listed the operator's OWN
92
+ * ship commit back to them. That inverts the entire point of that block,
93
+ * which is to show the one thing a session cannot see from inside itself.
94
+ *
95
+ * Safe by construction and never a surprise: ship already required a clean
96
+ * tree, the fold already moved this same directory under the same exclusive
97
+ * lock, and `--ff-only` can neither conflict nor write a commit.
98
+ *
99
+ * NOBODY ELSE'S CHECKOUT MOVES. A teammate's branch is not base, so this
100
+ * does nothing for them — and their branch being genuinely behind base is a
101
+ * fact the rail should keep telling them.
102
+ */
103
+ if (workingTree && branch && branch === baseBranchName(baseRef())) {
104
+ try {
105
+ git(['fetch', 'origin', '--quiet'], repoRoot);
106
+ git(['merge', '--ff-only', baseRef()], workingTree);
107
+ } catch {
108
+ /* a readout, not the ship — the merge already landed, and the next
109
+ fold picks this up either way */
110
+ }
111
+ }
112
+ } finally {
113
+ try {
114
+ dropTmp();
115
+ git(['worktree', 'prune'], repoRoot);
116
+ } catch {
117
+ /* best effort */
118
+ }
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Is this push failure a LOST RACE rather than a broken repo?
124
+ *
125
+ * Matched on git's own words. Deliberately narrow: anything unrecognised is
126
+ * rethrown, because retrying an unknown failure is how a real problem gets
127
+ * reported twice and understood never.
128
+ */
129
+ export function isRaceRejection(e) {
130
+ const d = `${e?.stdout ?? ''}\n${e?.stderr ?? ''}\n${e?.message ?? ''}`;
131
+ return /non-fast-forward|\[rejected\]|fetch first|stale info/i.test(d);
132
+ }
package/bin/lib/work.mjs CHANGED
@@ -43,6 +43,7 @@ import { listenersIn, listenersSupported } from './listeners.mjs';
43
43
  import { processesInGroups, liveGroups, processesSupported } from './processes.mjs';
44
44
  import { createPlaceLock } from './placeLock.mjs';
45
45
  import { sweepMergedBranch } from './shipSweep.mjs';
46
+ import { mergeOutward as shipMergeOutward } from './shipMerge.mjs';
46
47
  import { openTunnel } from './preview.mjs';
47
48
  import { c, note, ok, warn } from './ui.mjs';
48
49
  import { mcpFor, runTurn } from './claude.mjs';
@@ -413,6 +414,8 @@ export function createWorkManager({ repoRoot, baseDir, getBaseRef, getMcpUrl, ge
413
414
  };
414
415
 
415
416
  let lastWorktreeSweep = 0;
417
+ /** Sessions this process has already tried to measure. See `reportWorktrees`. */
418
+ const worktreeSeen = new Set();
416
419
  let lastWorktreeFetch = 0;
417
420
  let sweepingWorktrees = false;
418
421
  const postWorktrees = async (reports) => {
@@ -477,10 +480,34 @@ export function createWorkManager({ repoRoot, baseDir, getBaseRef, getMcpUrl, ge
477
480
  if (r) await postWorktrees([r]);
478
481
  };
479
482
  /** Every live session, throttled — called from the reconcile loop. */
483
+ /**
484
+ * A SESSION NOBODY HAS MEASURED YET JUMPS THE SWEEP (2026-08-26).
485
+ *
486
+ * The throttle is GLOBAL, not per-session, so a tab opened one second after a
487
+ * sweep waited the remaining fifty-nine for its first measurement — and until
488
+ * it lands there is no branch, no directory and no listeners anywhere in the
489
+ * product, because every one of those readouts is gated on a measurement and
490
+ * renders nothing rather than inventing a state. Asked directly: "how come it
491
+ * takes a while for a new session to show branch and worktree and listeners
492
+ * after i create a new tab."
493
+ *
494
+ * ATTEMPTED, never MEASURED, is what is remembered. A session whose directory
495
+ * cannot be read yet — a pre-places daemon that has not cut one, a worktree
496
+ * mid-creation — would otherwise be "unmeasured" on every poll and force a
497
+ * full sweep each time. Recording the attempt bounds it at exactly one extra
498
+ * sweep per session, ever, after which the normal cadence carries it.
499
+ */
480
500
  const reportWorktrees = (activeIds) => {
481
501
  if (!Array.isArray(activeIds) || activeIds.length === 0) return;
482
502
  if (sweepingWorktrees) return;
483
- if (Date.now() - lastWorktreeSweep < WORKTREE_SWEEP_MS) return;
503
+ const firstSight = activeIds.some((id) => !worktreeSeen.has(id));
504
+ if (!firstSight && Date.now() - lastWorktreeSweep < WORKTREE_SWEEP_MS) return;
505
+ // Bounded to LIVE sessions: a long-running daemon must not accumulate a
506
+ // uuid per tab anyone has ever opened. Pruning also means a reopened tab is
507
+ // measured immediately again, which is the same answer for the same reason.
508
+ const live = new Set(activeIds);
509
+ for (const id of worktreeSeen) if (!live.has(id)) worktreeSeen.delete(id);
510
+ for (const id of activeIds) worktreeSeen.add(id);
484
511
  sweepingWorktrees = true;
485
512
  lastWorktreeSweep = Date.now();
486
513
  void (async () => {
@@ -1151,9 +1178,23 @@ export function createWorkManager({ repoRoot, baseDir, getBaseRef, getMcpUrl, ge
1151
1178
  * working tree itself would show up as an untracked path and block every
1152
1179
  * ship of an otherwise-clean session.
1153
1180
  */
1154
- const sessionMetaPath = (wt, name) => {
1181
+ /**
1182
+ * A file beside the worktree's git dir, holding something about ONE TAB.
1183
+ *
1184
+ * `scope` is the session id and is NOT optional for anything per-tab. These
1185
+ * markers were named bare — `flowviant-codex-thread`, `flowviant-agy-
1186
+ * conversation` — which was unambiguous while one directory meant one tab.
1187
+ * The day tabs moved into their driver's project folder, every tab there
1188
+ * started reading and writing ONE marker: tab B would resume tab A's codex
1189
+ * thread, and the last turn to finish would overwrite the id for both.
1190
+ *
1191
+ * The turn LOCK is deliberately still un-scoped — it guards the directory
1192
+ * against a second CLI, which is a property of the place and not of a tab.
1193
+ */
1194
+ const sessionMetaPath = (wt, name, scope) => {
1155
1195
  try {
1156
- return join(git(['rev-parse', '--absolute-git-dir'], wt), name);
1196
+ const safe = scope && /^[A-Za-z0-9_-]{1,64}$/.test(String(scope)) ? `-${scope}` : '';
1197
+ return join(git(['rev-parse', '--absolute-git-dir'], wt), `${name}${safe}`);
1157
1198
  } catch {
1158
1199
  return null;
1159
1200
  }
@@ -1171,7 +1212,7 @@ export function createWorkManager({ repoRoot, baseDir, getBaseRef, getMcpUrl, ge
1171
1212
  * the AGENT's to explain to the user, never a reason to fail the adoption —
1172
1213
  * the conversation is the thing being adopted, and it resumes either way.
1173
1214
  */
1174
- const carryDirtyState = (srcCwd, wt) => {
1215
+ const carryDirtyState = (srcCwd, wt, sessionId) => {
1175
1216
  const problems = [];
1176
1217
  try {
1177
1218
  // A Buffer, not utf8: a `--binary` patch (and a hunk from a non-UTF-8
@@ -1184,7 +1225,7 @@ export function createWorkManager({ repoRoot, baseDir, getBaseRef, getMcpUrl, ge
1184
1225
  maxBuffer: 64 * 1024 * 1024,
1185
1226
  });
1186
1227
  if (patch.length) {
1187
- const patchPath = sessionMetaPath(wt, 'flowviant-adopt.patch');
1228
+ const patchPath = sessionMetaPath(wt, 'flowviant-adopt.patch', sessionId);
1188
1229
  if (!patchPath) throw new Error('no private git dir to stage the patch in');
1189
1230
  try {
1190
1231
  writeFileSync(patchPath, patch);
@@ -1269,8 +1310,10 @@ export function createWorkManager({ repoRoot, baseDir, getBaseRef, getMcpUrl, ge
1269
1310
  */
1270
1311
  const sessionCapable = (rid) =>
1271
1312
  (Boolean(RUNTIMES[rid]?.mcp) || rid === 'antigravity') && canRun(RUNTIMES[rid], 'build');
1272
- const sessionRuntime = (wt, jobRuntime) => {
1273
- const marker = sessionMetaPath(wt, 'flowviant-runtime');
1313
+ const sessionRuntime = (wt, jobRuntime, sessionId) => {
1314
+ // SCOPED: two tabs standing in one directory may run different CLIs, and an
1315
+ // unscoped pin would hand the second one the first one's runtime.
1316
+ const marker = sessionMetaPath(wt, 'flowviant-runtime', sessionId);
1274
1317
  let pinned = null;
1275
1318
  if (marker && existsSync(marker)) {
1276
1319
  try {
@@ -1689,7 +1732,7 @@ export function createWorkManager({ repoRoot, baseDir, getBaseRef, getMcpUrl, ge
1689
1732
  // which is what every tab ran on until now) — honored by
1690
1733
  // sessionRuntime: on a first turn a named runtime IS the pick, and a
1691
1734
  // named runtime that disagrees with the pin settles below.
1692
- const rt = sessionRuntime(dir.wt, job.runtime || null);
1735
+ const rt = sessionRuntime(dir.wt, job.runtime || null, job.sessionId);
1693
1736
  if (rt.mismatch) {
1694
1737
  // Something upstream changed this tab's identity mid-life. A held
1695
1738
  // context must never be answered by a different brain — say so.
@@ -1782,9 +1825,43 @@ export function createWorkManager({ repoRoot, baseDir, getBaseRef, getMcpUrl, ge
1782
1825
  // persisted below, beside the runtime pin; absent, the turn runs
1783
1826
  // FRESH in the same worktree — the dirty state is most of the held
1784
1827
  // context, and a machine-global guess is someone else's conversation.
1828
+ /**
1829
+ * CLAUDE RESUMES BY THE CONVERSATION ID THIS TAB SPOKE UNDER.
1830
+ *
1831
+ * `--continue` is CWD-KEYED. That was unambiguous while one directory
1832
+ * meant one tab, and it stopped being true the day tabs moved into
1833
+ * their driver's project folder: every tab there said `--continue`
1834
+ * and every one of them resumed whichever conversation had spoken
1835
+ * most recently in that directory. Tab B inherited tab A's entire
1836
+ * context, and each turn afterwards ping-ponged between them — the
1837
+ * exact failure the codex note two blocks down warns about for
1838
+ * `resume --last`, arriving for Claude by a different route.
1839
+ *
1840
+ * The id comes from the CLI's own `system.init` event, which the
1841
+ * stream parser already surfaces, and is pinned per session so it is
1842
+ * unambiguous wherever the tab is standing.
1843
+ *
1844
+ * NO ID, NO `--continue`: a tab whose place is shared starts FRESH
1845
+ * rather than guessing, because in a shared directory the guess is
1846
+ * someone else's conversation. `--continue` survives only where the
1847
+ * directory belongs to this tab alone, which is the one case it was
1848
+ * ever right for.
1849
+ */
1850
+ let claudeResumeId = null;
1851
+ if (rt.id === 'claude') {
1852
+ const convMarker = sessionMetaPath(dir.wt, 'flowviant-claude-session', job.sessionId);
1853
+ if (convMarker && existsSync(convMarker)) {
1854
+ try {
1855
+ const v = readFileSync(convMarker, 'utf8').trim();
1856
+ if (/^[A-Za-z0-9_-]{8,64}$/.test(v)) claudeResumeId = v;
1857
+ } catch {
1858
+ /* unreadable marker — run fresh */
1859
+ }
1860
+ }
1861
+ }
1785
1862
  let codexResumeId = null;
1786
1863
  if (rt.id === 'codex') {
1787
- const threadMarker = sessionMetaPath(dir.wt, 'flowviant-codex-thread');
1864
+ const threadMarker = sessionMetaPath(dir.wt, 'flowviant-codex-thread', job.sessionId);
1788
1865
  if (threadMarker && existsSync(threadMarker)) {
1789
1866
  try {
1790
1867
  const v = readFileSync(threadMarker, 'utf8').trim();
@@ -1802,7 +1879,7 @@ export function createWorkManager({ repoRoot, baseDir, getBaseRef, getMcpUrl, ge
1802
1879
  // dispatch sharing the machine could overwrite that between turns.
1803
1880
  let agyConvId = null;
1804
1881
  if (rt.id === 'antigravity' && !adopting) {
1805
- const convMarker = sessionMetaPath(dir.wt, 'flowviant-agy-conversation');
1882
+ const convMarker = sessionMetaPath(dir.wt, 'flowviant-agy-conversation', job.sessionId);
1806
1883
  if (convMarker && existsSync(convMarker)) {
1807
1884
  try {
1808
1885
  const v = readFileSync(convMarker, 'utf8').trim();
@@ -1825,19 +1902,23 @@ export function createWorkManager({ repoRoot, baseDir, getBaseRef, getMcpUrl, ge
1825
1902
  // it (cwd-keyed; measured safe), so a lost marker degrades to the
1826
1903
  // weaker resume instead of silently starting over.
1827
1904
  const spokeHere = !dir.fresh && Boolean(job.sessionRef) && job.sessionRef === dir.wt;
1905
+ // A place this tab does NOT have to itself: `--continue` there is a
1906
+ // guess at somebody else's conversation, so it is withheld and only
1907
+ // a pinned id may resume.
1908
+ const placeIsMine = !placeOf(job.sessionId) || placeOf(job.sessionId) === job.sessionId;
1828
1909
  const resume =
1829
1910
  rt.id === 'codex'
1830
1911
  ? Boolean(codexResumeId)
1831
1912
  : rt.id === 'antigravity'
1832
- ? Boolean(agyConvId) || spokeHere
1833
- : spokeHere;
1913
+ ? Boolean(agyConvId) || (placeIsMine && spokeHere)
1914
+ : Boolean(claudeResumeId) || (placeIsMine && spokeHere);
1834
1915
  // The dirty carry, on the adopt worktree's FIRST life only: a
1835
1916
  // re-attempted adoption (the directory already exists) carried what
1836
1917
  // it could the first time, and re-applying would double it. A carry
1837
1918
  // problem never fails the adoption — it becomes one bracketed line
1838
1919
  // in the prompt, so the AGENT tells the user what stayed behind.
1839
1920
  let carryNote = '';
1840
- if (adopting && dir.fresh && adoptSrc) carryNote = carryDirtyState(adoptSrc, dir.wt);
1921
+ if (adopting && dir.fresh && adoptSrc) carryNote = carryDirtyState(adoptSrc, dir.wt, job.sessionId);
1841
1922
  // The tab's transcript starts EMPTY on adoption (scrollback is
1842
1923
  // disposable, the held context is the brain — never import an
1843
1924
  // archive), so the first reply opens with a recap: the human sees
@@ -1857,6 +1938,7 @@ export function createWorkManager({ repoRoot, baseDir, getBaseRef, getMcpUrl, ge
1857
1938
  workAttempts.set(job.id, tries + 1);
1858
1939
  let out;
1859
1940
  let seenThreadId = null; // codex's conversation id, off thread.started
1941
+ let seenClaudeSession = null; // claude's own conversation id, off system.init
1860
1942
  const spawned = []; // this turn's children, for the teardown registry
1861
1943
  const narrator = makeNarrator(job.sessionId, job.id);
1862
1944
 
@@ -1950,7 +2032,16 @@ export function createWorkManager({ repoRoot, baseDir, getBaseRef, getMcpUrl, ge
1950
2032
  // no extra spawn — and reported on the next roster poll so the
1951
2033
  // composer can autocomplete a `/`. See runtimes.mjs for why it is
1952
2034
  // learned from a turn rather than looked up.
1953
- onInit: (i) => recordSkills(i.skills),
2035
+ onInit: (i) => {
2036
+ recordSkills(i.skills);
2037
+ // The conversation this turn is actually speaking under. Held
2038
+ // and persisted after the turn ends, so the NEXT one resumes
2039
+ // this exact thread rather than whatever the directory saw
2040
+ // last. Last write wins on purpose: a resume that fell back to
2041
+ // fresh reports the fresh id, healing the marker.
2042
+ if (typeof i.sessionId === 'string' && i.sessionId.trim())
2043
+ seenClaudeSession = i.sessionId.trim();
2044
+ },
1954
2045
  cwd: dir.wt,
1955
2046
  mcpArgs: mcp.args,
1956
2047
  mcpEnv: mcp.env,
@@ -1985,7 +2076,7 @@ export function createWorkManager({ repoRoot, baseDir, getBaseRef, getMcpUrl, ge
1985
2076
  out = await runTurn({
1986
2077
  ...turnArgs,
1987
2078
  resume,
1988
- resumeThreadId: codexResumeId || undefined,
2079
+ resumeThreadId: codexResumeId || claudeResumeId || undefined,
1989
2080
  resumeConversationId: agyConvId || undefined,
1990
2081
  });
1991
2082
  // A resume that produced NOTHING usually means the held
@@ -2022,8 +2113,26 @@ export function createWorkManager({ repoRoot, baseDir, getBaseRef, getMcpUrl, ge
2022
2113
  // before it ever touches disk — it later rides in argv as
2023
2114
  // `resume <id>` — and best-effort, like the runtime pin: an
2024
2115
  // unwritable marker just means the tab runs fresh next turn.
2116
+ // The conversation THIS TAB just spoke under, pinned so the next
2117
+ // turn resumes it by id rather than asking the directory. Written
2118
+ // after the turn for the same reason codex's is: an id learned
2119
+ // mid-turn is only true once the turn that learned it finished.
2120
+ if (
2121
+ rt.id === 'claude' &&
2122
+ seenClaudeSession &&
2123
+ /^[A-Za-z0-9_-]{8,64}$/.test(seenClaudeSession)
2124
+ ) {
2125
+ const convMarker = sessionMetaPath(dir.wt, 'flowviant-claude-session', job.sessionId);
2126
+ if (convMarker) {
2127
+ try {
2128
+ writeFileSync(convMarker, seenClaudeSession);
2129
+ } catch {
2130
+ /* best-effort — the next turn re-learns it */
2131
+ }
2132
+ }
2133
+ }
2025
2134
  if (rt.id === 'codex' && seenThreadId && CODEX_THREAD_RE.test(seenThreadId)) {
2026
- const threadMarker = sessionMetaPath(dir.wt, 'flowviant-codex-thread');
2135
+ const threadMarker = sessionMetaPath(dir.wt, 'flowviant-codex-thread', job.sessionId);
2027
2136
  if (threadMarker) {
2028
2137
  try {
2029
2138
  writeFileSync(threadMarker, seenThreadId);
@@ -2055,7 +2164,7 @@ export function createWorkManager({ repoRoot, baseDir, getBaseRef, getMcpUrl, ge
2055
2164
  // cwd registry. From here on the marker is the tab's identity and
2056
2165
  // the registry is never trusted again.
2057
2166
  if (rt.id === 'antigravity' && answer.length > 0) {
2058
- const convMarker = sessionMetaPath(dir.wt, 'flowviant-agy-conversation');
2167
+ const convMarker = sessionMetaPath(dir.wt, 'flowviant-agy-conversation', job.sessionId);
2059
2168
  if (convMarker && !existsSync(convMarker)) {
2060
2169
  const learned = adopting ? job.adopt.id : agyRegistryLookup(dir.wt);
2061
2170
  if (learned && AGY_CONV_RE.test(learned)) {
@@ -2269,35 +2378,23 @@ export function createWorkManager({ repoRoot, baseDir, getBaseRef, getMcpUrl, ge
2269
2378
  // Merge outward through a throwaway worktree so no checkout moves.
2270
2379
  // The throwaway dies on EVERY exit — success, conflict or throw —
2271
2380
  // or the next ship of this session trips over its corpse.
2272
- const mergeOutward = (tip, count) => {
2273
- const tmp = join(baseDir, 'ship', job.sessionId);
2274
- try {
2275
- try {
2276
- git(['worktree', 'remove', '--force', tmp], repoRoot);
2277
- } catch {
2278
- /* not there — fine */
2279
- }
2280
- git(['worktree', 'add', '--detach', tmp, baseRef()], repoRoot);
2281
- gitMerge(
2282
- [
2283
- 'merge',
2284
- '--no-ff',
2285
- tip,
2286
- '-m',
2287
- `ship(${job.sessionName || job.sessionId.slice(0, 8)}): ${count} commit${count === 1 ? '' : 's'}`,
2288
- ],
2289
- tmp
2290
- );
2291
- git(['push', 'origin', `HEAD:${baseBranchName(baseRef())}`], tmp);
2292
- } finally {
2293
- try {
2294
- git(['worktree', 'remove', '--force', tmp], repoRoot);
2295
- git(['worktree', 'prune'], repoRoot);
2296
- } catch {
2297
- /* best effort */
2298
- }
2299
- }
2300
- };
2381
+ // Carry the tip out onto base and push it. See `shipMerge.mjs` for
2382
+ // the throwaway-worktree shape, the one retry when two people ship at
2383
+ // once, and why the operator's own branch is fast-forwarded after.
2384
+ const mergeOutward = (tip, count) =>
2385
+ shipMergeOutward({
2386
+ tip,
2387
+ count,
2388
+ branch,
2389
+ label: job.sessionName || job.sessionId.slice(0, 8),
2390
+ git,
2391
+ gitMerge,
2392
+ repoRoot,
2393
+ tmpDir: join(baseDir, 'ship', job.sessionId),
2394
+ baseRef,
2395
+ workingTree: placeWtFor(shipPlace)?.wt ?? null,
2396
+ warn,
2397
+ });
2301
2398
  // Idempotency: base already contains the branch tip. A re-offered
2302
2399
  // job after a lost report lands here — never a re-merge, and never
2303
2400
  // "nothing to ship" AS A FAILURE for work that in fact shipped. The
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.67.0",
3
+ "version": "0.69.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": {