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.
- package/bin/lib/runtimes.mjs +23 -9
- package/bin/lib/shipMerge.mjs +132 -0
- package/bin/lib/work.mjs +143 -46
- 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);
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) =>
|
|
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
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
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.
|
|
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": {
|