flowviant 0.68.0 → 0.70.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.
@@ -74,6 +74,47 @@ function transcriptTitle(file, mtimeMs) {
74
74
  return title;
75
75
  }
76
76
 
77
+ /**
78
+ * THE TITLE CLAUDE GAVE ITS OWN CONVERSATION, for one session we already know
79
+ * the id of.
80
+ *
81
+ * `scanLocalSessions` finds transcripts the hard way — walk every project
82
+ * directory, read each file's own `cwd` — because it is answering "what is in
83
+ * this repo" and knows no ids. This asks the same store a narrower question:
84
+ * the tab pinned the id the CLI reported at `system.init`, and the directory is
85
+ * the place the turn was spawned in, so the file is one munge away (Claude Code
86
+ * names the directory after the cwd with `/` and `.` replaced by `-`).
87
+ *
88
+ * The realpath fallback is not defensive padding: a place under a symlinked
89
+ * home munges to a DIFFERENT directory name depending on which form the CLI was
90
+ * handed, and the daemon does not control which that was.
91
+ *
92
+ * Returns null for anything it cannot read. A missing title is honest silence —
93
+ * the tab keeps whatever name it has.
94
+ */
95
+ export function titleForSession(cwd, sessionId) {
96
+ if (!cwd || typeof sessionId !== 'string' || !/^[A-Za-z0-9_-]{8,64}$/.test(sessionId)) {
97
+ return null;
98
+ }
99
+ const projects = join(homedir(), '.claude', 'projects');
100
+ const dirs = [String(cwd)];
101
+ try {
102
+ const real = realpathSync(String(cwd));
103
+ if (real !== String(cwd)) dirs.push(real);
104
+ } catch {
105
+ /* the place is gone — nothing to read */
106
+ }
107
+ for (const dir of dirs) {
108
+ const file = join(projects, dir.replace(/[/.]/g, '-'), `${sessionId}.jsonl`);
109
+ try {
110
+ return transcriptTitle(file, statSync(file).mtimeMs);
111
+ } catch {
112
+ /* not this munge — try the other */
113
+ }
114
+ }
115
+ return null;
116
+ }
117
+
77
118
  /** Path-prefix containment on already-realpath'd absolute paths. */
78
119
  const inside = (p, root) => p === root || p.startsWith(root.endsWith('/') ? root : `${root}/`);
79
120
 
@@ -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
@@ -59,7 +59,11 @@ import { detectRuntimes, canRun, recordSkills, RUNTIMES } from './runtimes.mjs';
59
59
  /** The place id meaning "the checkout", not a worktree. Must match the
60
60
  * server's REPO_PLACE — it is a wire value, not a local convention. */
61
61
  const REPO_PLACE = 'repo';
62
- import { isTerminalSessionLive, isAgyConversationLive } from './localSessions.mjs';
62
+ import {
63
+ isTerminalSessionLive,
64
+ isAgyConversationLive,
65
+ titleForSession,
66
+ } from './localSessions.mjs';
63
67
  import { worktreeDiff } from './worktreeDiff.mjs';
64
68
  import { homedir } from 'node:os';
65
69
 
@@ -398,10 +402,21 @@ export function createWorkManager({ repoRoot, baseDir, getBaseRef, getMcpUrl, ge
398
402
  const learnPlaces = (map) => {
399
403
  if (!map || typeof map !== 'object') return;
400
404
  for (const [sid, place] of Object.entries(map)) {
401
- if (typeof place === 'string' && place) sessionPlaces.set(sid, place);
402
- // null / '' / anything else: the server is telling us this tab is in its
403
- // OWN worktree. Falling back to the default requires forgetting, not
404
- // ignoring.
405
+ // VALIDATE AT THE TRUST BOUNDARY. `placeDir` joins this value straight
406
+ // into `sessions/<place>` and it is the directory a turn is SPAWNED in
407
+ // and a preview port is measured against — the security boundary for the
408
+ // whole preview feature. The server resolves place to an enum (never a
409
+ // path), but a server bug or compromise sending `../../etc` would
410
+ // otherwise point a session's measured directory anywhere on the box and
411
+ // defeat the port attribution. `sessionWorktreeReport` and `placeWtFor`
412
+ // already reject an unsafe segment; doing it here covers every consumer
413
+ // (the preview-claim `placeDir` did not re-check). REPO_PLACE resolves to
414
+ // repoRoot, so it is allowed through despite not being a path segment.
415
+ if (typeof place === 'string' && place && (place === REPO_PLACE || isSafePathSegment(place)))
416
+ sessionPlaces.set(sid, place);
417
+ // null / '' / an unsafe value: the server is telling us this tab is in its
418
+ // OWN worktree (or is malformed). Falling back to the default requires
419
+ // forgetting, not ignoring.
405
420
  else sessionPlaces.delete(sid);
406
421
  }
407
422
  };
@@ -414,6 +429,8 @@ export function createWorkManager({ repoRoot, baseDir, getBaseRef, getMcpUrl, ge
414
429
  };
415
430
 
416
431
  let lastWorktreeSweep = 0;
432
+ /** Sessions this process has already tried to measure. See `reportWorktrees`. */
433
+ const worktreeSeen = new Set();
417
434
  let lastWorktreeFetch = 0;
418
435
  let sweepingWorktrees = false;
419
436
  const postWorktrees = async (reports) => {
@@ -463,6 +480,29 @@ export function createWorkManager({ repoRoot, baseDir, getBaseRef, getMcpUrl, ge
463
480
  // `processesSupported` keeps "cannot look" (Windows) apart from "looked and
464
481
  // found none", which renders differently.
465
482
  const processes = sessionProcesses(sessionId);
483
+ // THE NAME CLAUDE ALREADY GAVE THIS CONVERSATION, relayed.
484
+ //
485
+ // Claude Code titles its own sessions; a Flowviant tab was born "session 3"
486
+ // and stayed that way unless somebody renamed it by hand, so a strip of
487
+ // eight tabs said nothing about any of them. The title is not ours to
488
+ // invent — reading it is the same relay this whole file does, and asking
489
+ // a model for one would be a second brain, which the product forbids.
490
+ //
491
+ // Only CLAUDE tabs have one here: the id is the one the CLI reported at
492
+ // `system.init` and pinned per tab, so a codex or agy tab simply has no
493
+ // marker and reports no title. No runtime check is needed for that — the
494
+ // absent marker IS the check.
495
+ //
496
+ // No version floor: a daemon→server report on an endpoint that already
497
+ // exists, so an older daemon sends no key and the server leaves the name
498
+ // exactly as it found it.
499
+ let title = null;
500
+ try {
501
+ const marker = sessionMetaPath(wt, 'flowviant-claude-session', sessionId);
502
+ if (marker) title = titleForSession(wt, readFileSync(marker, 'utf8').trim());
503
+ } catch {
504
+ /* no marker yet — this tab has not spoken, or is not Claude */
505
+ }
466
506
  return {
467
507
  sessionId,
468
508
  ...d,
@@ -470,6 +510,7 @@ export function createWorkManager({ repoRoot, baseDir, getBaseRef, getMcpUrl, ge
470
510
  listeningSupported: listenersSupported(),
471
511
  ...(processes === null ? {} : { processes }),
472
512
  processesSupported: processesSupported(),
513
+ ...(title ? { title } : {}),
473
514
  };
474
515
  };
475
516
  /** One session, now — called after its turn settles. */
@@ -478,10 +519,34 @@ export function createWorkManager({ repoRoot, baseDir, getBaseRef, getMcpUrl, ge
478
519
  if (r) await postWorktrees([r]);
479
520
  };
480
521
  /** Every live session, throttled — called from the reconcile loop. */
522
+ /**
523
+ * A SESSION NOBODY HAS MEASURED YET JUMPS THE SWEEP (2026-08-26).
524
+ *
525
+ * The throttle is GLOBAL, not per-session, so a tab opened one second after a
526
+ * sweep waited the remaining fifty-nine for its first measurement — and until
527
+ * it lands there is no branch, no directory and no listeners anywhere in the
528
+ * product, because every one of those readouts is gated on a measurement and
529
+ * renders nothing rather than inventing a state. Asked directly: "how come it
530
+ * takes a while for a new session to show branch and worktree and listeners
531
+ * after i create a new tab."
532
+ *
533
+ * ATTEMPTED, never MEASURED, is what is remembered. A session whose directory
534
+ * cannot be read yet — a pre-places daemon that has not cut one, a worktree
535
+ * mid-creation — would otherwise be "unmeasured" on every poll and force a
536
+ * full sweep each time. Recording the attempt bounds it at exactly one extra
537
+ * sweep per session, ever, after which the normal cadence carries it.
538
+ */
481
539
  const reportWorktrees = (activeIds) => {
482
540
  if (!Array.isArray(activeIds) || activeIds.length === 0) return;
483
541
  if (sweepingWorktrees) return;
484
- if (Date.now() - lastWorktreeSweep < WORKTREE_SWEEP_MS) return;
542
+ const firstSight = activeIds.some((id) => !worktreeSeen.has(id));
543
+ if (!firstSight && Date.now() - lastWorktreeSweep < WORKTREE_SWEEP_MS) return;
544
+ // Bounded to LIVE sessions: a long-running daemon must not accumulate a
545
+ // uuid per tab anyone has ever opened. Pruning also means a reopened tab is
546
+ // measured immediately again, which is the same answer for the same reason.
547
+ const live = new Set(activeIds);
548
+ for (const id of worktreeSeen) if (!live.has(id)) worktreeSeen.delete(id);
549
+ for (const id of activeIds) worktreeSeen.add(id);
485
550
  sweepingWorktrees = true;
486
551
  lastWorktreeSweep = Date.now();
487
552
  void (async () => {
@@ -1152,9 +1217,23 @@ export function createWorkManager({ repoRoot, baseDir, getBaseRef, getMcpUrl, ge
1152
1217
  * working tree itself would show up as an untracked path and block every
1153
1218
  * ship of an otherwise-clean session.
1154
1219
  */
1155
- const sessionMetaPath = (wt, name) => {
1220
+ /**
1221
+ * A file beside the worktree's git dir, holding something about ONE TAB.
1222
+ *
1223
+ * `scope` is the session id and is NOT optional for anything per-tab. These
1224
+ * markers were named bare — `flowviant-codex-thread`, `flowviant-agy-
1225
+ * conversation` — which was unambiguous while one directory meant one tab.
1226
+ * The day tabs moved into their driver's project folder, every tab there
1227
+ * started reading and writing ONE marker: tab B would resume tab A's codex
1228
+ * thread, and the last turn to finish would overwrite the id for both.
1229
+ *
1230
+ * The turn LOCK is deliberately still un-scoped — it guards the directory
1231
+ * against a second CLI, which is a property of the place and not of a tab.
1232
+ */
1233
+ const sessionMetaPath = (wt, name, scope) => {
1156
1234
  try {
1157
- return join(git(['rev-parse', '--absolute-git-dir'], wt), name);
1235
+ const safe = scope && /^[A-Za-z0-9_-]{1,64}$/.test(String(scope)) ? `-${scope}` : '';
1236
+ return join(git(['rev-parse', '--absolute-git-dir'], wt), `${name}${safe}`);
1158
1237
  } catch {
1159
1238
  return null;
1160
1239
  }
@@ -1172,7 +1251,7 @@ export function createWorkManager({ repoRoot, baseDir, getBaseRef, getMcpUrl, ge
1172
1251
  * the AGENT's to explain to the user, never a reason to fail the adoption —
1173
1252
  * the conversation is the thing being adopted, and it resumes either way.
1174
1253
  */
1175
- const carryDirtyState = (srcCwd, wt) => {
1254
+ const carryDirtyState = (srcCwd, wt, sessionId) => {
1176
1255
  const problems = [];
1177
1256
  try {
1178
1257
  // A Buffer, not utf8: a `--binary` patch (and a hunk from a non-UTF-8
@@ -1185,7 +1264,7 @@ export function createWorkManager({ repoRoot, baseDir, getBaseRef, getMcpUrl, ge
1185
1264
  maxBuffer: 64 * 1024 * 1024,
1186
1265
  });
1187
1266
  if (patch.length) {
1188
- const patchPath = sessionMetaPath(wt, 'flowviant-adopt.patch');
1267
+ const patchPath = sessionMetaPath(wt, 'flowviant-adopt.patch', sessionId);
1189
1268
  if (!patchPath) throw new Error('no private git dir to stage the patch in');
1190
1269
  try {
1191
1270
  writeFileSync(patchPath, patch);
@@ -1270,8 +1349,10 @@ export function createWorkManager({ repoRoot, baseDir, getBaseRef, getMcpUrl, ge
1270
1349
  */
1271
1350
  const sessionCapable = (rid) =>
1272
1351
  (Boolean(RUNTIMES[rid]?.mcp) || rid === 'antigravity') && canRun(RUNTIMES[rid], 'build');
1273
- const sessionRuntime = (wt, jobRuntime) => {
1274
- const marker = sessionMetaPath(wt, 'flowviant-runtime');
1352
+ const sessionRuntime = (wt, jobRuntime, sessionId) => {
1353
+ // SCOPED: two tabs standing in one directory may run different CLIs, and an
1354
+ // unscoped pin would hand the second one the first one's runtime.
1355
+ const marker = sessionMetaPath(wt, 'flowviant-runtime', sessionId);
1275
1356
  let pinned = null;
1276
1357
  if (marker && existsSync(marker)) {
1277
1358
  try {
@@ -1690,7 +1771,7 @@ export function createWorkManager({ repoRoot, baseDir, getBaseRef, getMcpUrl, ge
1690
1771
  // which is what every tab ran on until now) — honored by
1691
1772
  // sessionRuntime: on a first turn a named runtime IS the pick, and a
1692
1773
  // named runtime that disagrees with the pin settles below.
1693
- const rt = sessionRuntime(dir.wt, job.runtime || null);
1774
+ const rt = sessionRuntime(dir.wt, job.runtime || null, job.sessionId);
1694
1775
  if (rt.mismatch) {
1695
1776
  // Something upstream changed this tab's identity mid-life. A held
1696
1777
  // context must never be answered by a different brain — say so.
@@ -1783,9 +1864,43 @@ export function createWorkManager({ repoRoot, baseDir, getBaseRef, getMcpUrl, ge
1783
1864
  // persisted below, beside the runtime pin; absent, the turn runs
1784
1865
  // FRESH in the same worktree — the dirty state is most of the held
1785
1866
  // context, and a machine-global guess is someone else's conversation.
1867
+ /**
1868
+ * CLAUDE RESUMES BY THE CONVERSATION ID THIS TAB SPOKE UNDER.
1869
+ *
1870
+ * `--continue` is CWD-KEYED. That was unambiguous while one directory
1871
+ * meant one tab, and it stopped being true the day tabs moved into
1872
+ * their driver's project folder: every tab there said `--continue`
1873
+ * and every one of them resumed whichever conversation had spoken
1874
+ * most recently in that directory. Tab B inherited tab A's entire
1875
+ * context, and each turn afterwards ping-ponged between them — the
1876
+ * exact failure the codex note two blocks down warns about for
1877
+ * `resume --last`, arriving for Claude by a different route.
1878
+ *
1879
+ * The id comes from the CLI's own `system.init` event, which the
1880
+ * stream parser already surfaces, and is pinned per session so it is
1881
+ * unambiguous wherever the tab is standing.
1882
+ *
1883
+ * NO ID, NO `--continue`: a tab whose place is shared starts FRESH
1884
+ * rather than guessing, because in a shared directory the guess is
1885
+ * someone else's conversation. `--continue` survives only where the
1886
+ * directory belongs to this tab alone, which is the one case it was
1887
+ * ever right for.
1888
+ */
1889
+ let claudeResumeId = null;
1890
+ if (rt.id === 'claude') {
1891
+ const convMarker = sessionMetaPath(dir.wt, 'flowviant-claude-session', job.sessionId);
1892
+ if (convMarker && existsSync(convMarker)) {
1893
+ try {
1894
+ const v = readFileSync(convMarker, 'utf8').trim();
1895
+ if (/^[A-Za-z0-9_-]{8,64}$/.test(v)) claudeResumeId = v;
1896
+ } catch {
1897
+ /* unreadable marker — run fresh */
1898
+ }
1899
+ }
1900
+ }
1786
1901
  let codexResumeId = null;
1787
1902
  if (rt.id === 'codex') {
1788
- const threadMarker = sessionMetaPath(dir.wt, 'flowviant-codex-thread');
1903
+ const threadMarker = sessionMetaPath(dir.wt, 'flowviant-codex-thread', job.sessionId);
1789
1904
  if (threadMarker && existsSync(threadMarker)) {
1790
1905
  try {
1791
1906
  const v = readFileSync(threadMarker, 'utf8').trim();
@@ -1803,7 +1918,7 @@ export function createWorkManager({ repoRoot, baseDir, getBaseRef, getMcpUrl, ge
1803
1918
  // dispatch sharing the machine could overwrite that between turns.
1804
1919
  let agyConvId = null;
1805
1920
  if (rt.id === 'antigravity' && !adopting) {
1806
- const convMarker = sessionMetaPath(dir.wt, 'flowviant-agy-conversation');
1921
+ const convMarker = sessionMetaPath(dir.wt, 'flowviant-agy-conversation', job.sessionId);
1807
1922
  if (convMarker && existsSync(convMarker)) {
1808
1923
  try {
1809
1924
  const v = readFileSync(convMarker, 'utf8').trim();
@@ -1826,19 +1941,23 @@ export function createWorkManager({ repoRoot, baseDir, getBaseRef, getMcpUrl, ge
1826
1941
  // it (cwd-keyed; measured safe), so a lost marker degrades to the
1827
1942
  // weaker resume instead of silently starting over.
1828
1943
  const spokeHere = !dir.fresh && Boolean(job.sessionRef) && job.sessionRef === dir.wt;
1944
+ // A place this tab does NOT have to itself: `--continue` there is a
1945
+ // guess at somebody else's conversation, so it is withheld and only
1946
+ // a pinned id may resume.
1947
+ const placeIsMine = !placeOf(job.sessionId) || placeOf(job.sessionId) === job.sessionId;
1829
1948
  const resume =
1830
1949
  rt.id === 'codex'
1831
1950
  ? Boolean(codexResumeId)
1832
1951
  : rt.id === 'antigravity'
1833
- ? Boolean(agyConvId) || spokeHere
1834
- : spokeHere;
1952
+ ? Boolean(agyConvId) || (placeIsMine && spokeHere)
1953
+ : Boolean(claudeResumeId) || (placeIsMine && spokeHere);
1835
1954
  // The dirty carry, on the adopt worktree's FIRST life only: a
1836
1955
  // re-attempted adoption (the directory already exists) carried what
1837
1956
  // it could the first time, and re-applying would double it. A carry
1838
1957
  // problem never fails the adoption — it becomes one bracketed line
1839
1958
  // in the prompt, so the AGENT tells the user what stayed behind.
1840
1959
  let carryNote = '';
1841
- if (adopting && dir.fresh && adoptSrc) carryNote = carryDirtyState(adoptSrc, dir.wt);
1960
+ if (adopting && dir.fresh && adoptSrc) carryNote = carryDirtyState(adoptSrc, dir.wt, job.sessionId);
1842
1961
  // The tab's transcript starts EMPTY on adoption (scrollback is
1843
1962
  // disposable, the held context is the brain — never import an
1844
1963
  // archive), so the first reply opens with a recap: the human sees
@@ -1858,6 +1977,7 @@ export function createWorkManager({ repoRoot, baseDir, getBaseRef, getMcpUrl, ge
1858
1977
  workAttempts.set(job.id, tries + 1);
1859
1978
  let out;
1860
1979
  let seenThreadId = null; // codex's conversation id, off thread.started
1980
+ let seenClaudeSession = null; // claude's own conversation id, off system.init
1861
1981
  const spawned = []; // this turn's children, for the teardown registry
1862
1982
  const narrator = makeNarrator(job.sessionId, job.id);
1863
1983
 
@@ -1896,7 +2016,14 @@ export function createWorkManager({ repoRoot, baseDir, getBaseRef, getMcpUrl, ge
1896
2016
  };
1897
2017
  const auditCommand = (a) => {
1898
2018
  if (a?.kind !== 'bash' || !a.command) return;
1899
- auditBatch.push({ command: a.command, at: new Date().toISOString() });
2019
+ // Scrubbed like every other string that leaves this box (the
2020
+ // narrator label, the settle answer, commit subjects, ship/merge
2021
+ // lines, the per-tab process report). A command line is exactly
2022
+ // where a secret leaks — `curl -H "authorization: <token>"`,
2023
+ // `PGPASSWORD=… psql` — and the audit is stored 30 days and rendered
2024
+ // in the admin view, so the one uplink that omitted scrub was the
2025
+ // one most likely to carry a plaintext secret.
2026
+ auditBatch.push({ command: envScrub(a.command), at: new Date().toISOString() });
1900
2027
  if (auditBatch.length >= 25) flushAudit();
1901
2028
  };
1902
2029
  try {
@@ -1951,7 +2078,16 @@ export function createWorkManager({ repoRoot, baseDir, getBaseRef, getMcpUrl, ge
1951
2078
  // no extra spawn — and reported on the next roster poll so the
1952
2079
  // composer can autocomplete a `/`. See runtimes.mjs for why it is
1953
2080
  // learned from a turn rather than looked up.
1954
- onInit: (i) => recordSkills(i.skills),
2081
+ onInit: (i) => {
2082
+ recordSkills(i.skills);
2083
+ // The conversation this turn is actually speaking under. Held
2084
+ // and persisted after the turn ends, so the NEXT one resumes
2085
+ // this exact thread rather than whatever the directory saw
2086
+ // last. Last write wins on purpose: a resume that fell back to
2087
+ // fresh reports the fresh id, healing the marker.
2088
+ if (typeof i.sessionId === 'string' && i.sessionId.trim())
2089
+ seenClaudeSession = i.sessionId.trim();
2090
+ },
1955
2091
  cwd: dir.wt,
1956
2092
  mcpArgs: mcp.args,
1957
2093
  mcpEnv: mcp.env,
@@ -1986,7 +2122,7 @@ export function createWorkManager({ repoRoot, baseDir, getBaseRef, getMcpUrl, ge
1986
2122
  out = await runTurn({
1987
2123
  ...turnArgs,
1988
2124
  resume,
1989
- resumeThreadId: codexResumeId || undefined,
2125
+ resumeThreadId: codexResumeId || claudeResumeId || undefined,
1990
2126
  resumeConversationId: agyConvId || undefined,
1991
2127
  });
1992
2128
  // A resume that produced NOTHING usually means the held
@@ -2023,8 +2159,26 @@ export function createWorkManager({ repoRoot, baseDir, getBaseRef, getMcpUrl, ge
2023
2159
  // before it ever touches disk — it later rides in argv as
2024
2160
  // `resume <id>` — and best-effort, like the runtime pin: an
2025
2161
  // unwritable marker just means the tab runs fresh next turn.
2162
+ // The conversation THIS TAB just spoke under, pinned so the next
2163
+ // turn resumes it by id rather than asking the directory. Written
2164
+ // after the turn for the same reason codex's is: an id learned
2165
+ // mid-turn is only true once the turn that learned it finished.
2166
+ if (
2167
+ rt.id === 'claude' &&
2168
+ seenClaudeSession &&
2169
+ /^[A-Za-z0-9_-]{8,64}$/.test(seenClaudeSession)
2170
+ ) {
2171
+ const convMarker = sessionMetaPath(dir.wt, 'flowviant-claude-session', job.sessionId);
2172
+ if (convMarker) {
2173
+ try {
2174
+ writeFileSync(convMarker, seenClaudeSession);
2175
+ } catch {
2176
+ /* best-effort — the next turn re-learns it */
2177
+ }
2178
+ }
2179
+ }
2026
2180
  if (rt.id === 'codex' && seenThreadId && CODEX_THREAD_RE.test(seenThreadId)) {
2027
- const threadMarker = sessionMetaPath(dir.wt, 'flowviant-codex-thread');
2181
+ const threadMarker = sessionMetaPath(dir.wt, 'flowviant-codex-thread', job.sessionId);
2028
2182
  if (threadMarker) {
2029
2183
  try {
2030
2184
  writeFileSync(threadMarker, seenThreadId);
@@ -2056,7 +2210,7 @@ export function createWorkManager({ repoRoot, baseDir, getBaseRef, getMcpUrl, ge
2056
2210
  // cwd registry. From here on the marker is the tab's identity and
2057
2211
  // the registry is never trusted again.
2058
2212
  if (rt.id === 'antigravity' && answer.length > 0) {
2059
- const convMarker = sessionMetaPath(dir.wt, 'flowviant-agy-conversation');
2213
+ const convMarker = sessionMetaPath(dir.wt, 'flowviant-agy-conversation', job.sessionId);
2060
2214
  if (convMarker && !existsSync(convMarker)) {
2061
2215
  const learned = adopting ? job.adopt.id : agyRegistryLookup(dir.wt);
2062
2216
  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.70.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": {