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.
- package/bin/lib/runtimes.mjs +23 -9
- package/bin/lib/work.mjs +125 -17
- package/package.json +1 -1
package/bin/lib/runtimes.mjs
CHANGED
|
@@ -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
|
-
//
|
|
343
|
-
//
|
|
344
|
-
//
|
|
345
|
-
//
|
|
346
|
-
//
|
|
347
|
-
//
|
|
348
|
-
//
|
|
349
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) =>
|
|
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.
|
|
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": {
|