flowviant 0.68.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);
package/bin/lib/work.mjs CHANGED
@@ -414,6 +414,8 @@ export function createWorkManager({ repoRoot, baseDir, getBaseRef, getMcpUrl, ge
414
414
  };
415
415
 
416
416
  let lastWorktreeSweep = 0;
417
+ /** Sessions this process has already tried to measure. See `reportWorktrees`. */
418
+ const worktreeSeen = new Set();
417
419
  let lastWorktreeFetch = 0;
418
420
  let sweepingWorktrees = false;
419
421
  const postWorktrees = async (reports) => {
@@ -478,10 +480,34 @@ export function createWorkManager({ repoRoot, baseDir, getBaseRef, getMcpUrl, ge
478
480
  if (r) await postWorktrees([r]);
479
481
  };
480
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
+ */
481
500
  const reportWorktrees = (activeIds) => {
482
501
  if (!Array.isArray(activeIds) || activeIds.length === 0) return;
483
502
  if (sweepingWorktrees) return;
484
- 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);
485
511
  sweepingWorktrees = true;
486
512
  lastWorktreeSweep = Date.now();
487
513
  void (async () => {
@@ -1152,9 +1178,23 @@ export function createWorkManager({ repoRoot, baseDir, getBaseRef, getMcpUrl, ge
1152
1178
  * working tree itself would show up as an untracked path and block every
1153
1179
  * ship of an otherwise-clean session.
1154
1180
  */
1155
- 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) => {
1156
1195
  try {
1157
- 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}`);
1158
1198
  } catch {
1159
1199
  return null;
1160
1200
  }
@@ -1172,7 +1212,7 @@ export function createWorkManager({ repoRoot, baseDir, getBaseRef, getMcpUrl, ge
1172
1212
  * the AGENT's to explain to the user, never a reason to fail the adoption —
1173
1213
  * the conversation is the thing being adopted, and it resumes either way.
1174
1214
  */
1175
- const carryDirtyState = (srcCwd, wt) => {
1215
+ const carryDirtyState = (srcCwd, wt, sessionId) => {
1176
1216
  const problems = [];
1177
1217
  try {
1178
1218
  // A Buffer, not utf8: a `--binary` patch (and a hunk from a non-UTF-8
@@ -1185,7 +1225,7 @@ export function createWorkManager({ repoRoot, baseDir, getBaseRef, getMcpUrl, ge
1185
1225
  maxBuffer: 64 * 1024 * 1024,
1186
1226
  });
1187
1227
  if (patch.length) {
1188
- const patchPath = sessionMetaPath(wt, 'flowviant-adopt.patch');
1228
+ const patchPath = sessionMetaPath(wt, 'flowviant-adopt.patch', sessionId);
1189
1229
  if (!patchPath) throw new Error('no private git dir to stage the patch in');
1190
1230
  try {
1191
1231
  writeFileSync(patchPath, patch);
@@ -1270,8 +1310,10 @@ export function createWorkManager({ repoRoot, baseDir, getBaseRef, getMcpUrl, ge
1270
1310
  */
1271
1311
  const sessionCapable = (rid) =>
1272
1312
  (Boolean(RUNTIMES[rid]?.mcp) || rid === 'antigravity') && canRun(RUNTIMES[rid], 'build');
1273
- const sessionRuntime = (wt, jobRuntime) => {
1274
- 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);
1275
1317
  let pinned = null;
1276
1318
  if (marker && existsSync(marker)) {
1277
1319
  try {
@@ -1690,7 +1732,7 @@ export function createWorkManager({ repoRoot, baseDir, getBaseRef, getMcpUrl, ge
1690
1732
  // which is what every tab ran on until now) — honored by
1691
1733
  // sessionRuntime: on a first turn a named runtime IS the pick, and a
1692
1734
  // named runtime that disagrees with the pin settles below.
1693
- const rt = sessionRuntime(dir.wt, job.runtime || null);
1735
+ const rt = sessionRuntime(dir.wt, job.runtime || null, job.sessionId);
1694
1736
  if (rt.mismatch) {
1695
1737
  // Something upstream changed this tab's identity mid-life. A held
1696
1738
  // context must never be answered by a different brain — say so.
@@ -1783,9 +1825,43 @@ export function createWorkManager({ repoRoot, baseDir, getBaseRef, getMcpUrl, ge
1783
1825
  // persisted below, beside the runtime pin; absent, the turn runs
1784
1826
  // FRESH in the same worktree — the dirty state is most of the held
1785
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
+ }
1786
1862
  let codexResumeId = null;
1787
1863
  if (rt.id === 'codex') {
1788
- const threadMarker = sessionMetaPath(dir.wt, 'flowviant-codex-thread');
1864
+ const threadMarker = sessionMetaPath(dir.wt, 'flowviant-codex-thread', job.sessionId);
1789
1865
  if (threadMarker && existsSync(threadMarker)) {
1790
1866
  try {
1791
1867
  const v = readFileSync(threadMarker, 'utf8').trim();
@@ -1803,7 +1879,7 @@ export function createWorkManager({ repoRoot, baseDir, getBaseRef, getMcpUrl, ge
1803
1879
  // dispatch sharing the machine could overwrite that between turns.
1804
1880
  let agyConvId = null;
1805
1881
  if (rt.id === 'antigravity' && !adopting) {
1806
- const convMarker = sessionMetaPath(dir.wt, 'flowviant-agy-conversation');
1882
+ const convMarker = sessionMetaPath(dir.wt, 'flowviant-agy-conversation', job.sessionId);
1807
1883
  if (convMarker && existsSync(convMarker)) {
1808
1884
  try {
1809
1885
  const v = readFileSync(convMarker, 'utf8').trim();
@@ -1826,19 +1902,23 @@ export function createWorkManager({ repoRoot, baseDir, getBaseRef, getMcpUrl, ge
1826
1902
  // it (cwd-keyed; measured safe), so a lost marker degrades to the
1827
1903
  // weaker resume instead of silently starting over.
1828
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;
1829
1909
  const resume =
1830
1910
  rt.id === 'codex'
1831
1911
  ? Boolean(codexResumeId)
1832
1912
  : rt.id === 'antigravity'
1833
- ? Boolean(agyConvId) || spokeHere
1834
- : spokeHere;
1913
+ ? Boolean(agyConvId) || (placeIsMine && spokeHere)
1914
+ : Boolean(claudeResumeId) || (placeIsMine && spokeHere);
1835
1915
  // The dirty carry, on the adopt worktree's FIRST life only: a
1836
1916
  // re-attempted adoption (the directory already exists) carried what
1837
1917
  // it could the first time, and re-applying would double it. A carry
1838
1918
  // problem never fails the adoption — it becomes one bracketed line
1839
1919
  // in the prompt, so the AGENT tells the user what stayed behind.
1840
1920
  let carryNote = '';
1841
- if (adopting && dir.fresh && adoptSrc) carryNote = carryDirtyState(adoptSrc, dir.wt);
1921
+ if (adopting && dir.fresh && adoptSrc) carryNote = carryDirtyState(adoptSrc, dir.wt, job.sessionId);
1842
1922
  // The tab's transcript starts EMPTY on adoption (scrollback is
1843
1923
  // disposable, the held context is the brain — never import an
1844
1924
  // archive), so the first reply opens with a recap: the human sees
@@ -1858,6 +1938,7 @@ export function createWorkManager({ repoRoot, baseDir, getBaseRef, getMcpUrl, ge
1858
1938
  workAttempts.set(job.id, tries + 1);
1859
1939
  let out;
1860
1940
  let seenThreadId = null; // codex's conversation id, off thread.started
1941
+ let seenClaudeSession = null; // claude's own conversation id, off system.init
1861
1942
  const spawned = []; // this turn's children, for the teardown registry
1862
1943
  const narrator = makeNarrator(job.sessionId, job.id);
1863
1944
 
@@ -1951,7 +2032,16 @@ export function createWorkManager({ repoRoot, baseDir, getBaseRef, getMcpUrl, ge
1951
2032
  // no extra spawn — and reported on the next roster poll so the
1952
2033
  // composer can autocomplete a `/`. See runtimes.mjs for why it is
1953
2034
  // learned from a turn rather than looked up.
1954
- 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
+ },
1955
2045
  cwd: dir.wt,
1956
2046
  mcpArgs: mcp.args,
1957
2047
  mcpEnv: mcp.env,
@@ -1986,7 +2076,7 @@ export function createWorkManager({ repoRoot, baseDir, getBaseRef, getMcpUrl, ge
1986
2076
  out = await runTurn({
1987
2077
  ...turnArgs,
1988
2078
  resume,
1989
- resumeThreadId: codexResumeId || undefined,
2079
+ resumeThreadId: codexResumeId || claudeResumeId || undefined,
1990
2080
  resumeConversationId: agyConvId || undefined,
1991
2081
  });
1992
2082
  // A resume that produced NOTHING usually means the held
@@ -2023,8 +2113,26 @@ export function createWorkManager({ repoRoot, baseDir, getBaseRef, getMcpUrl, ge
2023
2113
  // before it ever touches disk — it later rides in argv as
2024
2114
  // `resume <id>` — and best-effort, like the runtime pin: an
2025
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
+ }
2026
2134
  if (rt.id === 'codex' && seenThreadId && CODEX_THREAD_RE.test(seenThreadId)) {
2027
- const threadMarker = sessionMetaPath(dir.wt, 'flowviant-codex-thread');
2135
+ const threadMarker = sessionMetaPath(dir.wt, 'flowviant-codex-thread', job.sessionId);
2028
2136
  if (threadMarker) {
2029
2137
  try {
2030
2138
  writeFileSync(threadMarker, seenThreadId);
@@ -2056,7 +2164,7 @@ export function createWorkManager({ repoRoot, baseDir, getBaseRef, getMcpUrl, ge
2056
2164
  // cwd registry. From here on the marker is the tab's identity and
2057
2165
  // the registry is never trusted again.
2058
2166
  if (rt.id === 'antigravity' && answer.length > 0) {
2059
- const convMarker = sessionMetaPath(dir.wt, 'flowviant-agy-conversation');
2167
+ const convMarker = sessionMetaPath(dir.wt, 'flowviant-agy-conversation', job.sessionId);
2060
2168
  if (convMarker && !existsSync(convMarker)) {
2061
2169
  const learned = adopting ? job.adopt.id : agyRegistryLookup(dir.wt);
2062
2170
  if (learned && AGY_CONV_RE.test(learned)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.68.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": {