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.
- package/bin/lib/localSessions.mjs +41 -0
- package/bin/lib/runtimes.mjs +23 -9
- package/bin/lib/work.mjs +177 -23
- package/package.json +1 -1
|
@@ -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
|
|
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
|
@@ -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 {
|
|
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
|
-
|
|
402
|
-
//
|
|
403
|
-
//
|
|
404
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) =>
|
|
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.
|
|
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": {
|