flowviant 0.124.0 → 0.124.1

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.
Files changed (59) hide show
  1. package/README.md +6 -0
  2. package/bin/cli.mjs +7 -0
  3. package/bin/lib/agentInternalWait.mjs +2 -1
  4. package/bin/lib/agentPlan.mjs +8 -0
  5. package/bin/lib/agentRepositories.mjs +48 -0
  6. package/bin/lib/checkoutIdentity.mjs +25 -0
  7. package/bin/lib/claudeInput.mjs +32 -0
  8. package/bin/lib/claudeSessions.mjs +20 -0
  9. package/bin/lib/credentials.mjs +52 -17
  10. package/bin/lib/daemonLocks.mjs +21 -3
  11. package/bin/lib/fleet.mjs +19 -6
  12. package/bin/lib/fleetRoster.mjs +1 -89
  13. package/bin/lib/fleetStartup.mjs +11 -14
  14. package/bin/lib/holder.mjs +4 -37
  15. package/bin/lib/instance.mjs +20 -7
  16. package/bin/lib/landed.mjs +2 -2
  17. package/bin/lib/login.mjs +39 -4
  18. package/bin/lib/placeLock.mjs +9 -7
  19. package/bin/lib/prWorkflow.mjs +15 -0
  20. package/bin/lib/projectLanded.mjs +16 -0
  21. package/bin/lib/projectRepositories.mjs +60 -0
  22. package/bin/lib/projectTools.mjs +2 -1
  23. package/bin/lib/projectWorktreeHome.mjs +25 -0
  24. package/bin/lib/prompts.mjs +6 -1
  25. package/bin/lib/repositoryDelivery.mjs +64 -0
  26. package/bin/lib/repositoryMerge.mjs +116 -0
  27. package/bin/lib/repositoryPlaces.mjs +179 -0
  28. package/bin/lib/repositorySetup.mjs +58 -0
  29. package/bin/lib/repositoryTool.mjs +71 -0
  30. package/bin/lib/runTurn.mjs +23 -2
  31. package/bin/lib/runtimeAntigravity.mjs +2 -1
  32. package/bin/lib/runtimeClaude.mjs +5 -2
  33. package/bin/lib/runtimeCodex.mjs +2 -1
  34. package/bin/lib/shipMerge.mjs +5 -2
  35. package/bin/lib/startProject.mjs +4 -4
  36. package/bin/lib/trace.mjs +8 -3
  37. package/bin/lib/turnDeclaration.mjs +88 -0
  38. package/bin/lib/turnLock.mjs +48 -8
  39. package/bin/lib/work.mjs +22 -24
  40. package/bin/lib/workAgentCheck.mjs +6 -5
  41. package/bin/lib/workAgentMerges.mjs +19 -2
  42. package/bin/lib/workAgentPrecheck.mjs +5 -5
  43. package/bin/lib/workAgentPublish.mjs +18 -5
  44. package/bin/lib/workAgentRecovery.mjs +85 -0
  45. package/bin/lib/workAgentReview.mjs +5 -3
  46. package/bin/lib/workAgentTurnBegun.mjs +6 -4
  47. package/bin/lib/workAgentTurnExecution.mjs +76 -9
  48. package/bin/lib/workAgentTurnOutcome.mjs +9 -3
  49. package/bin/lib/workAgentTurns.mjs +33 -9
  50. package/bin/lib/workDelivery.mjs +21 -0
  51. package/bin/lib/workDiffs.mjs +18 -11
  52. package/bin/lib/workLiveMessages.mjs +129 -0
  53. package/bin/lib/workPlaceReports.mjs +5 -0
  54. package/bin/lib/workPlaces.mjs +6 -1
  55. package/bin/lib/workRetire.mjs +5 -0
  56. package/bin/lib/workSessionTurns.mjs +72 -40
  57. package/bin/lib/workShip.mjs +17 -16
  58. package/package.json +1 -1
  59. package/npm-shrinkwrap.json +0 -69
@@ -0,0 +1,116 @@
1
+ /** A place lands as a coordinated set. Positive landings are never undone. */
2
+ import { join } from 'node:path';
3
+ import { git, gitNet as network, gitOrNull, baseBranchName, usableBaseRef, detectBaseRef } from './git.mjs';
4
+ import { gitMergeIn } from './gitMergeIdentity.mjs';
5
+ import { mergeOutward, isRaceRejection } from './shipMerge.mjs';
6
+ import { ensureOpenPr, mergeAndVerifyTip, ghReady, linkRepositoryPrs, relayedFailure } from './prWorkflow.mjs';
7
+
8
+ export function repositoryMergeOrder(cards, changed) {
9
+ const edges = new Map(changed.map((id) => [id, new Set()]));
10
+ for (const card of cards) {
11
+ const ids = card.repos.filter((id) => changed.includes(id));
12
+ if (ids.length > 1 && (!card.mergeOrderReason?.trim() || card.mergeOrder.length !== card.repos.length || new Set(card.mergeOrder).size !== card.repos.length || card.repos.some((id) => !card.mergeOrder.includes(id)))) throw new Error(`Card ${card.id} needs a merge order covering every changed repository and a reason.`);
13
+ const order = card.mergeOrder.filter((id) => ids.includes(id));
14
+ for (let i = 1; i < order.length; i++) edges.get(order[i - 1]).add(order[i]);
15
+ }
16
+ const result = [];
17
+ while (result.length < changed.length) {
18
+ const next = changed.find((id) => !result.includes(id) && ![...edges].some(([from, to]) => !result.includes(from) && to.has(id)));
19
+ if (!next) throw new Error('The delivered cards specify conflicting merge orders. Restate a consistent order for these cards.');
20
+ result.push(next);
21
+ }
22
+ return result;
23
+ }
24
+
25
+ export async function mergeRepositoryPlace({ job, entries, baseDir, progress, check, gitNet = network,
26
+ land = mergeOutward, pr = { ghReady, ensureOpenPr, mergeAndVerifyTip, linkRepositoryPrs } }) {
27
+ const spec = job.repositoryMerge;
28
+ const ids = [...new Set(spec.cards.flatMap((c) => c.repos))];
29
+ const rows = ids.map((id) => {
30
+ const row = entries.find((r) => r.id === id && r.writable);
31
+ if (!row?.repoRoot) throw new Error(`Repository ${spec.repositories.find((r) => r.id === id)?.name ?? id} has no writable worktree on this machine.`);
32
+ const configured = spec.repositories.find((r) => r.id === id);
33
+ return { ...row, name: configured?.name ?? row.name, configuredBase: configured?.baseBranch };
34
+ });
35
+ const states = structuredClone(spec.progress ?? {});
36
+ const report = async (retry = false) => progress(states, retry);
37
+ const ancestor = (a, b, wt) => { try { git(['merge-base', '--is-ancestor', a, b], wt); return true; } catch { return false; } };
38
+ const fetch = (r) => {
39
+ if (gitOrNull(['remote', 'get-url', 'origin'], r.repoRoot)) gitNet(['fetch', 'origin', '--quiet'], r.repoRoot, 60_000);
40
+ r.baseRef = r.configuredBase ? usableBaseRef(r.repoRoot, `origin/${baseBranchName(r.configuredBase)}`) : detectBaseRef(r.repoRoot);
41
+ r.baseTip = git(['rev-parse', r.baseRef], r.repoRoot);
42
+ };
43
+ let order;
44
+ try { order = repositoryMergeOrder(spec.cards, ids); }
45
+ catch (e) { return { ok: false, conflict: true, detail: e.message }; }
46
+ if (job.prMode && pr.ghReady()) return { ok: false, fix: 'person', detail: 'GitHub CLI is not installed or signed in on this machine.' };
47
+ // Yield the lease after bounded races, retaining approval and all observations.
48
+ for (let attempt = 0; attempt < 4; attempt++) {
49
+ const pending = [], conflicts = [];
50
+ for (const r of rows) {
51
+ fetch(r);
52
+ const head = git(['rev-parse', 'HEAD'], r.wt);
53
+ if (ancestor(head, r.baseTip, r.wt)) { states[r.id] = { ...states[r.id], head, landed: head }; continue; }
54
+ pending.push(r);
55
+ try { gitMergeIn(r.repoRoot)(['merge', '--no-edit', r.baseTip], r.wt); }
56
+ catch (e) {
57
+ const files = git(['diff', '--name-only', '--diff-filter=U'], r.wt).trim();
58
+ if (files || gitOrNull(['rev-parse', '--verify', 'MERGE_HEAD'], r.wt)) conflicts.push(`${r.name} (${r.wt}):\n${files || relayedFailure(e)}`);
59
+ else throw e;
60
+ }
61
+ states[r.id] = { ...states[r.id], head: git(['rev-parse', 'HEAD'], r.wt) };
62
+ }
63
+ await report();
64
+ if (conflicts.length) return { ok: false, conflict: true, detail: `Resolve and commit every conflicted repository in this place before any remaining repository lands:\n${conflicts.join('\n\n')}` };
65
+ // All branches have caught up. A failing repository prevents every new landing.
66
+ const failed = [];
67
+ for (const r of pending) {
68
+ await report();
69
+ const result = await check(r);
70
+ await report();
71
+ if (result?.status !== 'passed' && result?.status !== 'none') failed.push(r.name);
72
+ else if (git(['rev-parse', 'HEAD'], r.wt) !== states[r.id].head) failed.push(`${r.name} (branch changed during its check)`);
73
+ }
74
+ if (failed.length) return { ok: false, fix: 'person', detail: `Repository checks failed or could not be measured: ${failed.join(', ')}. Review the checks before approving again.` };
75
+ if (job.prMode) {
76
+ for (const r of pending) {
77
+ const branch = git(['symbolic-ref', '--quiet', '--short', 'HEAD'], r.wt);
78
+ if (branch === baseBranchName(r.baseRef)) throw new Error(`Refusing to publish ${r.name}'s base as a PR branch.`);
79
+ gitNet(['push', '-u', 'origin', branch], r.wt, 120_000);
80
+ const opened = pr.ensureOpenPr(branch, baseBranchName(r.baseRef), { cwd: r.wt });
81
+ if (opened.error || opened.wrongBase || !opened.url) return { ok: false, fix: 'person', detail: opened.error || `No verified pull request for ${r.name}.` };
82
+ states[r.id].prUrl = opened.url;
83
+ }
84
+ await report();
85
+ for (const r of pending) await pr.linkRepositoryPrs(states[r.id].prUrl, { cwd: r.wt, cards: spec.cards, order, repositories: spec.repositories, states });
86
+ }
87
+ let raced = false;
88
+ for (const id of order) {
89
+ const r = pending.find((r) => r.id === id);
90
+ if (!r) continue;
91
+ // Refuse a changed base before landing. Another writer can still win the push.
92
+ fetch(r);
93
+ if (!ancestor(r.baseTip, 'HEAD', r.wt)) { raced = true; break; }
94
+ const head = git(['rev-parse', 'HEAD'], r.wt);
95
+ const branch = git(['symbolic-ref', '--quiet', '--short', 'HEAD'], r.wt);
96
+ try {
97
+ if (job.prMode) {
98
+ const merged = await pr.mergeAndVerifyTip(branch, { cwd: r.wt, tip: head, baseRef: () => r.baseRef, repoRoot: r.repoRoot, fetchOrigin: () => fetch(r) });
99
+ if (!merged.ok) { fetch(r); if (merged.notOnBase || !ancestor(r.baseTip, 'HEAD', r.wt)) { raced = true; break; } throw new Error(merged.error || `Pull request for ${r.name} has not merged.`); }
100
+ } else land({ tip: head, count: Number(git(['rev-list', '--count', `${r.baseRef}..HEAD`], r.wt)), branch,
101
+ label: job.agentName || job.placeId, git, gitMerge: gitMergeIn(r.repoRoot), repoRoot: r.repoRoot,
102
+ tmpDir: join(baseDir, 'ship', `${job.placeId}-${r.id}`), baseRef: () => r.baseRef, baseTip: r.baseTip, workingTree: r.wt, gitNet, retryRace: false });
103
+ } catch (e) {
104
+ if (isRaceRejection(e)) { raced = true; break; }
105
+ return { ok: false, fix: e?.fix === 'person' || job.prMode ? 'person' : 'agent', detail: `${r.name}: ${relayedFailure(e)}` };
106
+ }
107
+ fetch(r);
108
+ if (!ancestor(head, r.baseTip, r.wt)) { raced = true; break; }
109
+ states[r.id] = { ...states[r.id], head, landed: head };
110
+ await report();
111
+ }
112
+ if (!raced) return { ok: true, sha: states[order.at(-1)]?.head ?? git(['rev-parse', 'HEAD'], entries.find((r) => r.writable).wt) };
113
+ }
114
+ await report(true);
115
+ return { retry: true };
116
+ }
@@ -0,0 +1,179 @@
1
+ /** One durable place manifest; all targeted worktrees share session/<place>.
2
+ * Untargeted siblings are base snapshots, not links to the operator's checkout.
3
+ * Read-only file modes prevent ordinary writes; build CLIs still run as the
4
+ * operator and this is not a security boundary against chmod or privileged code.
5
+ */
6
+ import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
7
+ import { basename, join } from 'node:path';
8
+ import { execFileSync } from 'node:child_process';
9
+ import { git, gitRaw, gitOrNull, isSafePathSegment, sessionBranch } from './git.mjs';
10
+ import { setupRepository } from './repositorySetup.mjs';
11
+
12
+ export const PLACE_MANIFEST = '.flowviant-place.json';
13
+ export function readRepositoryPlace(home) {
14
+ try {
15
+ const row = JSON.parse(readFileSync(join(home, PLACE_MANIFEST), 'utf8'));
16
+ if (row.version !== 1 || !Array.isArray(row.repositories) ||
17
+ row.repositories.some((r) => !isSafePathSegment(r.folder) || typeof r.id !== 'string') ||
18
+ !(row.preparing === true || row.repositories.some((r) => r.id === row.primary && r.writable))) return null;
19
+ return row;
20
+ } catch { return null; }
21
+ }
22
+ function modeTree(dir, writable) {
23
+ const st = lstatSync(dir);
24
+ if (st.isSymbolicLink()) return;
25
+ if (writable) chmodSync(dir, st.isDirectory() ? 0o700 : 0o600 | (st.mode & 0o111));
26
+ if (st.isDirectory()) for (const name of readdirSync(dir)) modeTree(join(dir, name), writable);
27
+ if (!writable) chmodSync(dir, st.isDirectory() ? 0o500 : 0o400 | (st.mode & 0o111));
28
+ }
29
+ /** The explicit `clean` command also owns these snapshots. Never chmod links. */
30
+ export function unlockRepositorySnapshots(root) {
31
+ for (const project of readdirSync(root, { withFileTypes: true })) {
32
+ if (!project.isDirectory()) continue;
33
+ const sessions = join(root, project.name, 'sessions');
34
+ if (!existsSync(sessions)) continue;
35
+ for (const place of readdirSync(sessions)) {
36
+ if (!isSafePathSegment(place)) continue;
37
+ const home = join(sessions, place), state = readRepositoryPlace(home);
38
+ for (const repo of state?.repositories ?? []) if (!repo.writable && existsSync(join(home, repo.folder))) modeTree(join(home, repo.folder), true);
39
+ }
40
+ }
41
+ }
42
+ export function createRepositoryPlaces({ repoRoot, baseDir, repositories, setup = setupRepository, changed = () => {} }) {
43
+ const homeFor = (place) => {
44
+ if (!isSafePathSegment(place)) throw new Error('Invalid place.');
45
+ return join(baseDir, 'sessions', place);
46
+ };
47
+ const save = (home, state) => {
48
+ const path = join(home, PLACE_MANIFEST);
49
+ writeFileSync(`${path}.tmp`, JSON.stringify(state), { mode: 0o600 });
50
+ renameSync(`${path}.tmp`, path);
51
+ };
52
+ const stateFor = (place) => readRepositoryPlace(homeFor(place));
53
+ const entries = (place) => {
54
+ const state = stateFor(place);
55
+ return (state?.repositories ?? []).map((r) => ({ ...r, wt: join(homeFor(place), r.folder),
56
+ repoRoot: repositories.list().find((repo) => repo.id === r.id)?.repoRoot }));
57
+ };
58
+ const ensure = async (place, ids, setupOptions = {}) => {
59
+ const home = homeFor(place), catalog = repositories.list();
60
+ for (const id of ids) if (!catalog.find((r) => r.id === id)?.repoRoot)
61
+ throw new Error(`Repository ${catalog.find((r) => r.id === id)?.name ?? id} is not checked out on this machine.`);
62
+ const folders = catalog.filter((r) => r.repoRoot).map((r) => basename(r.repoRoot));
63
+ if (new Set(folders).size !== folders.length || folders.some((f) => !isSafePathSegment(f)))
64
+ throw new Error('Project checkouts need distinct directory names to create sibling worktrees.');
65
+ let state = stateFor(place);
66
+ if (!state) {
67
+ const primary = catalog.find((r) => r.isPrimary && ids.includes(r.id))?.id ?? catalog.find((r) => ids.includes(r.id))?.id;
68
+ const old = existsSync(join(home, '.git'));
69
+ // Upgrade a pre-existing single-repo place without resetting its branch,
70
+ // private git metadata or dirty files. git moves the registration too.
71
+ if (old || existsSync(join(`${home}.moving`, '.git'))) {
72
+ const first = catalog.find((r) => r.repoRoot && git(['worktree', 'list', '--porcelain'], r.repoRoot).split('\n')
73
+ .some((line) => line === `worktree ${home}` || line === `worktree ${home}.moving`));
74
+ if (!first?.repoRoot) throw new Error('The checkout owning this worktree is needed to upgrade this place.');
75
+ const temporary = `${home}.moving`;
76
+ if (old) git(['worktree', 'move', home, temporary], first.repoRoot);
77
+ mkdirSync(home, { recursive: true });
78
+ git(['worktree', 'move', temporary, join(home, basename(first.repoRoot))], first.repoRoot);
79
+ state = { version: 1, primary: first.id, repositories: [{ id: first.id, name: first.name, folder: basename(first.repoRoot), baseRef: first.baseRef, writable: true, setup: true }] };
80
+ } else {
81
+ mkdirSync(home, { recursive: true });
82
+ state = { version: 1, primary, preparing: true, repositories: [] };
83
+ }
84
+ save(home, state);
85
+ }
86
+ for (const repo of catalog.filter((r) => r.repoRoot)) {
87
+ setupOptions.signal?.throwIfAborted();
88
+ let item = state.repositories.find((r) => r.id === repo.id);
89
+ let writable = ids.includes(repo.id) || item?.writable === true;
90
+ const folder = item?.folder ?? basename(repo.repoRoot), wt = join(home, folder);
91
+ if (existsSync(join(wt, '.git'))) {
92
+ const registered = git(['worktree', 'list', '--porcelain'], repo.repoRoot).split('\n').includes(`worktree ${wt}`);
93
+ if (!registered) throw new Error(`The git directory at ${wt} is not this repository's worktree.`);
94
+ writable = true; // promotion or legacy move completed before its save
95
+ if (item && !item.writable) { item.writable = true; item.setup = false; }
96
+ }
97
+ if (!item) {
98
+ if (existsSync(wt)) {
99
+ // Recover the crash window after git moved a legacy place but
100
+ // before its manifest was saved. Never adopt an unrelated folder.
101
+ const registered = git(['worktree', 'list', '--porcelain'], repo.repoRoot).split('\n').includes(`worktree ${wt}`);
102
+ if (!registered) throw new Error(`A directory already exists at ${wt}; it is not this repository's worktree.`);
103
+ }
104
+ item = { id: repo.id, name: repo.name, folder, baseRef: repo.baseRef, writable, setup: false };
105
+ state.repositories.push(item);
106
+ save(home, state);
107
+ }
108
+ item.baseRef = repo.baseRef;
109
+ item.name = repo.name;
110
+ const head = gitOrNull(['rev-parse', repo.baseRef], repo.repoRoot);
111
+ if (item && item.writable === writable && existsSync(wt) && (writable ? item.setup : item.head === head)) continue;
112
+ if (item && !item.writable && writable && existsSync(wt)) { modeTree(wt, true); rmSync(wt, { recursive: true }); }
113
+ if (item && !writable && existsSync(wt)) { modeTree(wt, true); rmSync(wt, { recursive: true }); }
114
+ if (!existsSync(wt)) {
115
+ if (writable) {
116
+ const branch = sessionBranch(place);
117
+ git(['worktree', 'prune'], repo.repoRoot);
118
+ if (gitOrNull(['show-ref', '--verify', `refs/heads/${branch}`], repo.repoRoot))
119
+ git(['worktree', 'add', wt, branch], repo.repoRoot);
120
+ else git(['worktree', 'add', '-b', branch, wt, repo.baseRef], repo.repoRoot);
121
+ } else {
122
+ mkdirSync(wt, { recursive: true });
123
+ // tar extracts only git's archive, never ignored files or credentials.
124
+ const archive = gitRaw(['archive', '--format=tar', repo.baseRef], repo.repoRoot, { encoding: 'buffer', maxBuffer: 256 * 1024 * 1024 });
125
+ execFileSync('tar', ['-xf', '-', '-C', wt], { input: archive });
126
+ modeTree(wt, false);
127
+ }
128
+ }
129
+ item.writable = writable;
130
+ item.head = head;
131
+ // Create every sibling before any install. Local file dependencies and
132
+ // setup hooks must resolve ../cli to this place, never a main checkout.
133
+ save(home, state);
134
+ }
135
+ for (const item of state.repositories.filter((r) => r.writable && !r.setup)) {
136
+ const repo = catalog.find((r) => r.id === item.id);
137
+ if (!repo?.repoRoot) throw new Error(`Repository ${item.name} is not checked out on this machine.`);
138
+ setupOptions.signal?.throwIfAborted();
139
+ await setup(repo.repoRoot, join(home, item.folder), setupOptions);
140
+ item.setup = true;
141
+ save(home, state);
142
+ }
143
+ state.preparing = false;
144
+ save(home, state);
145
+ changed();
146
+ return { wt: join(home, state.repositories.find((r) => r.id === state.primary).folder), fresh: false };
147
+ };
148
+ const remove = (place) => {
149
+ const rows = entries(place);
150
+ if (!rows.length) return false;
151
+ // Measure every writable repo BEFORE removing any of them.
152
+ if (rows.some((r) => r.writable && existsSync(r.wt) && (!r.repoRoot || git(['status', '--porcelain'], r.wt)))) return false;
153
+ for (const row of rows) {
154
+ if (!existsSync(row.wt)) continue;
155
+ if (row.writable) git(['worktree', 'remove', row.wt], row.repoRoot);
156
+ else { modeTree(row.wt, true); rmSync(row.wt, { recursive: true }); }
157
+ }
158
+ rmSync(homeFor(place), { recursive: true });
159
+ changed();
160
+ return true;
161
+ };
162
+ const receipts = (place, before) => entries(place).filter((r) => r.writable).flatMap((r) => {
163
+ const from = before.get(r.id);
164
+ if (!from) return [];
165
+ const out = gitOrNull(['log', '--format=%H', '--no-merges', `${from}..HEAD`, '--not', r.baseRef], r.wt);
166
+ return out?.split('\n').filter(Boolean).slice(0, 50).map((sha) => ({ sha, repo: r.id })) ?? [];
167
+ });
168
+ const pending = (place, taskId, repoId, clear = false) => {
169
+ const state = stateFor(place);
170
+ const rows = state.pending ?? [];
171
+ if (repoId) {
172
+ state.pending = rows.filter((r) => r.taskId !== taskId || r.repoId !== repoId);
173
+ if (!clear) state.pending.push({ taskId, repoId });
174
+ save(homeFor(place), state);
175
+ }
176
+ return (state.pending ?? []).filter((r) => r.taskId === taskId).map((r) => r.repoId);
177
+ };
178
+ return { ensure, entries, stateFor, remove, receipts, pending };
179
+ }
@@ -0,0 +1,58 @@
1
+ /** Fresh multi-repository worktrees each get their own environment and install.
2
+ * A checked-in .flowviant/setup.sh replaces the default dependency install. */
3
+ import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, readdirSync } from 'node:fs';
4
+ import { join, dirname } from 'node:path';
5
+ import { spawn } from 'node:child_process';
6
+ import { killGroup } from './processGroup.mjs';
7
+ import { git } from './git.mjs';
8
+ import { unattendedEnv } from './noWindowEnv.mjs';
9
+ import { workLanesStopped } from './standDownGate.mjs';
10
+
11
+ export async function setupRepository(root, wt, { signal, onSpawn = () => {}, onClose = () => {} } = {}) {
12
+ signal?.throwIfAborted();
13
+ if (workLanesStopped()) throw new Error('The daemon is stopping. Repository setup has not started.');
14
+ const copyEnv = (relative = '') => {
15
+ for (const name of readdirSync(join(root, relative))) {
16
+ if (['.git', 'node_modules', '.flowviant', 'dist', 'target', '.next', '.cache', '.venv'].includes(name)) continue;
17
+ const rel = join(relative, name), src = join(root, rel), dest = join(wt, rel);
18
+ const st = lstatSync(src);
19
+ if (st.isSymbolicLink()) continue;
20
+ if (st.isDirectory()) {
21
+ if (existsSync(dest)) copyEnv(rel);
22
+ } else if (/^\.env(?:\.|$)/.test(name) && !existsSync(dest)) {
23
+ try { git(['check-ignore', '--quiet', '--', rel], wt); } catch { continue; }
24
+ mkdirSync(dirname(dest), { recursive: true });
25
+ copyFileSync(src, dest);
26
+ chmodSync(dest, 0o600);
27
+ }
28
+ }
29
+ };
30
+ copyEnv();
31
+ const command = existsSync(join(wt, '.flowviant/setup.sh')) ? ['sh', '.flowviant/setup.sh'] :
32
+ ['bun.lock', 'bun.lockb'].some((f) => existsSync(join(wt, f))) ? ['bun', 'install', '--frozen-lockfile'] :
33
+ existsSync(join(wt, 'pnpm-lock.yaml')) ? ['pnpm', 'install', '--frozen-lockfile'] :
34
+ existsSync(join(wt, 'package-lock.json')) ? ['npm', 'ci'] :
35
+ existsSync(join(wt, 'yarn.lock')) ? ['yarn', 'install', '--frozen-lockfile'] : null;
36
+ if (!command) return;
37
+ await new Promise((resolve, reject) => {
38
+ const child = spawn(command[0], command.slice(1), { cwd: wt, env: unattendedEnv({ ...process.env, GIT_TERMINAL_PROMPT: '0' }), detached: true, stdio: ['ignore', 'ignore', 'pipe'] });
39
+ const abort = () => killGroup(child);
40
+ signal?.addEventListener('abort', abort, { once: true });
41
+ if (signal?.aborted) abort();
42
+ onSpawn(child);
43
+ let tail = '';
44
+ child.stderr.on('data', (s) => { tail = (tail + s).slice(-2000); });
45
+ const timer = setTimeout(() => { killGroup(child); }, 10 * 60_000);
46
+ child.once('error', (e) => { clearTimeout(timer); reject(e); });
47
+ child.once('close', (code) => {
48
+ clearTimeout(timer); signal?.removeEventListener('abort', abort); onClose(child);
49
+ code === 0 ? resolve() : reject(new Error(`${command.join(' ')} failed in ${wt}: ${tail}`));
50
+ });
51
+ });
52
+ }
53
+
54
+ /** Installs are daemon children too: shutdown must drain their process groups. */
55
+ export const trackedRepositorySetup = (children, groups) => (root, wt, options) => setupRepository(root, wt, {
56
+ ...options, onSpawn: (child) => { children.set(child, null); groups.add(child); },
57
+ onClose: (child) => { children.delete(child); groups.delete(child); },
58
+ });
@@ -0,0 +1,71 @@
1
+ /** A held turn's local capability. The CLI never receives the machine token. */
2
+ import { sameSecret } from './grant.mjs';
3
+ import { createServer } from 'node:http';
4
+ import { randomBytes } from 'node:crypto';
5
+ import { createInterface } from 'node:readline';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { runningCompiledBinary } from './launchCommand.mjs';
8
+
9
+ export async function createRepositoryTool(add) {
10
+ const capability = randomBytes(32).toString('hex');
11
+ let serial = Promise.resolve(), closed = false;
12
+ const server = createServer(async (req, res) => {
13
+ if (closed || req.method !== 'POST' || req.url !== '/' || !sameSecret(req.headers.authorization, `Bearer ${capability}`)) {
14
+ res.writeHead(403).end(); return;
15
+ }
16
+ try {
17
+ let body = '';
18
+ for await (const data of req) { body += data; if (body.length > 1024) throw new Error('Repository request is too large.'); }
19
+ const { name } = JSON.parse(body);
20
+ if (typeof name !== 'string' || !name.length || name.length > 64) throw new Error('Name a project repository.');
21
+ // A current turn already owns its place's writer lock. Serialize its
22
+ // acquisitions here instead of recursively waiting for that same lock.
23
+ const pending = serial.then(() => { if (closed) throw new Error('This turn has ended.'); return add(name); });
24
+ serial = pending.catch(() => {});
25
+ const result = await pending;
26
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }).end(JSON.stringify(result));
27
+ } catch (error) {
28
+ res.writeHead(409, { 'Content-Type': 'application/json' }).end(JSON.stringify({ error: String(error.message).slice(0, 2000) }));
29
+ }
30
+ });
31
+ server.requestTimeout = 15_000;
32
+ await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); });
33
+ const env = { FLOWVIANT_REPOSITORY_URL: `http://127.0.0.1:${server.address().port}/`, FLOWVIANT_REPOSITORY_CAPABILITY: capability };
34
+ const args = runningCompiledBinary() ? ['__repository-mcp'] : [fileURLToPath(new URL('../cli.mjs', import.meta.url)), '__repository-mcp'];
35
+ return { env, config: { command: process.execPath, args, env },
36
+ async close() { closed = true; server.close(); server.closeIdleConnections(); await serial; server.closeAllConnections(); } };
37
+ }
38
+
39
+ async function callRepository(name) {
40
+ const url = new URL(process.env.FLOWVIANT_REPOSITORY_URL);
41
+ if (url.hostname !== '127.0.0.1' || url.protocol !== 'http:') throw new Error('No held card repository tool is available.');
42
+ const res = await fetch(url, { method: 'POST', headers: { Authorization: `Bearer ${process.env.FLOWVIANT_REPOSITORY_CAPABILITY}`, 'Content-Type': 'application/json' },
43
+ body: JSON.stringify({ name }), signal: AbortSignal.timeout(12 * 60_000) });
44
+ const result = await res.json();
45
+ if (!res.ok) throw new Error(result.error ?? 'Repository could not be added.');
46
+ return result;
47
+ }
48
+
49
+ export async function repositoryToolMain(mcp) {
50
+ if (!mcp) {
51
+ try { process.stdout.write(`${JSON.stringify(await callRepository(process.argv[3]))}\n`); }
52
+ catch (error) { process.stderr.write(`${error.message}\n`); process.exitCode = 1; }
53
+ return;
54
+ }
55
+ for await (const line of createInterface({ input: process.stdin })) {
56
+ let request;
57
+ try {
58
+ request = JSON.parse(line);
59
+ if (request.id === undefined) continue;
60
+ let result;
61
+ if (request.method === 'initialize') result = { protocolVersion: request.params?.protocolVersion ?? '2024-11-05', capabilities: { tools: {} }, serverInfo: { name: 'card-repositories', version: '1.0.0' } };
62
+ else if (request.method === 'ping') result = {};
63
+ else if (request.method === 'tools/list') result = { tools: [{ name: 'add_repository', description: 'Add a project repository to the held card and create its sibling worktree on the same session branch. No human confirmation is needed.', inputSchema: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'], additionalProperties: false } }] };
64
+ else if (request.method === 'tools/call' && request.params?.name === 'add_repository') {
65
+ try { result = { content: [{ type: 'text', text: JSON.stringify(await callRepository(request.params.arguments?.name)) }] }; }
66
+ catch (error) { result = { isError: true, content: [{ type: 'text', text: error.message }] }; }
67
+ } else { process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id: request.id, error: { code: -32601, message: 'Unknown method' } })}\n`); continue; }
68
+ process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id: request.id, result })}\n`);
69
+ } catch { if (request?.id !== undefined) process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id: request.id, error: { code: -32602, message: 'Invalid request' } })}\n`); }
70
+ }
71
+ }
@@ -23,6 +23,7 @@ import { initHarvestFor, recordMcpServers, recordSkills } from './runtimeCapabil
23
23
  import { codexTurnModel } from './turnModel.mjs';
24
24
  import { printableText } from './printable.mjs';
25
25
  import { startTurnDiagnostics } from './turnDiagnostics.mjs';
26
+ import { createClaudeInput } from './claudeInput.mjs';
26
27
 
27
28
  /** How long a Codex turn's answer may wait on its rollout's model line. */
28
29
  const CODEX_MODEL_READ_MS = 1_500;
@@ -167,6 +168,8 @@ function runPlainTurn(opts) {
167
168
  // which posture a turn is running under. There is no precedence chain left
168
169
  // to get wrong: the caller named one profile (turnProfile.mjs).
169
170
  const args = rt.args({
171
+ streamInput: rt.id === 'claude' && Boolean(opts.liveMessages),
172
+ writableDirs: opts.writableDirs,
170
173
  personal,
171
174
  researchIsolation: opts.researchIsolation,
172
175
  prompt,
@@ -277,7 +280,7 @@ function runPlainTurn(opts) {
277
280
  cwd, args, profile: turnProfile.name, planId: opts.diagnosticPlanId });
278
281
  child = spawn(rt.bin, args, {
279
282
  cwd,
280
- stdio: ['ignore', 'pipe', 'pipe'],
283
+ stdio: [rt.id === 'claude' && opts.liveMessages ? 'pipe' : 'ignore', 'pipe', 'pipe'],
281
284
  /**
282
285
  * ITS OWN PROCESS GROUP, so what the agent starts stays attributable.
283
286
  *
@@ -324,6 +327,7 @@ function runPlainTurn(opts) {
324
327
  // than this is a previous turn's (turnModel.mjs).
325
328
  const spawnedAt = Date.now();
326
329
  let out = '';
330
+ const input = rt.id === 'claude' && opts.liveMessages ? createClaudeInput(child, { onTaken: opts.liveMessages.onTaken }) : null;
327
331
  const pfx = label ? `${label} ` : '';
328
332
  /**
329
333
  * THE ONE SINK FOR WHAT A CLI OR A MODEL WROTE (2026-10-10, the audit's
@@ -401,6 +405,14 @@ function runPlainTurn(opts) {
401
405
  const onLine = (line) => {
402
406
  try {
403
407
  const event = JSON.parse(line);
408
+ if (input) {
409
+ input.event(event);
410
+ if (event.type === 'system' && event.subtype === 'init' && event.session_id) opts.liveMessages.onInit(event.session_id);
411
+ // A message written just before result may become a second native
412
+ // turn in this process. Return its final result alone, never two
413
+ // structured objects joined together; every result still reports spend.
414
+ if (event.type === 'result') out = '';
415
+ }
404
416
  if ((event.type === 'result' && event.is_error === true) ||
405
417
  event.type === 'error' || event.type === 'turn.failed') {
406
418
  cliFailed = true;
@@ -467,6 +479,8 @@ function runPlainTurn(opts) {
467
479
  * resolves what was captured so far.
468
480
  */
469
481
  const onSpawnError = (e) => {
482
+ input?.close();
483
+ if (opts.liveMessages) void opts.liveMessages.stop();
470
484
  finishDiagnostics({ pid: child.pid, spawnError: typeof e.code === 'string' ? e.code : 'SPAWN_ERROR' });
471
485
  releaseArgs();
472
486
  if (e.code === 'ENOENT') {
@@ -479,11 +493,13 @@ function runPlainTurn(opts) {
479
493
  };
480
494
  child.on('error', onSpawnError);
481
495
  child.on('close', (code, signal) => {
496
+ input?.close();
482
497
  releaseArgs();
483
498
  opts.onExit?.(code, signal);
484
499
  if (buf.trim()) onLine(buf);
485
500
  finishDiagnostics({ code, signal, pid: child.pid, output: out, cliFailed });
486
- const handBack = () => {
501
+ const handBack = async () => {
502
+ if (opts.liveMessages) await opts.liveMessages.stop();
487
503
  resolve(out);
488
504
  // After the answer is handed back, never before it: see `onRate`.
489
505
  if (codexThread) {
@@ -514,5 +530,10 @@ function runPlainTurn(opts) {
514
530
  }
515
531
  handBack();
516
532
  });
533
+ if (input) {
534
+ const initial = opts.liveMessages.initialPrompt(prompt, resumeThreadId, resume, opts.liveContinuation === true);
535
+ input.send(opts.liveMessages.initialId || opts.liveMessages.turnId, initial);
536
+ opts.liveMessages.connect(input.send);
537
+ }
517
538
  });
518
539
  }
@@ -147,7 +147,7 @@ export const ANTIGRAVITY_RUNTIME = {
147
147
  planTurns: false,
148
148
  adopt: 'move',
149
149
  mcp: null,
150
- args({ prompt, system, model, effort, resume, resumeConversationId, profile = 'build', vaultDir, resultSchemaArgs = [], adoptResumeId }) {
150
+ args({ writableDirs = [], prompt, system, model, effort, resume, resumeConversationId, profile = 'build', vaultDir, resultSchemaArgs = [], adoptResumeId }) {
151
151
  const a = [];
152
152
  // WHICH CONVERSATION IS THIS — one decision, answered one way (the same
153
153
  // rule as Claude's --resume/--continue). By id when the caller knows:
@@ -212,6 +212,7 @@ export const ANTIGRAVITY_RUNTIME = {
212
212
  // So a SAFE-mode operator gets an agy build that is not actually
213
213
  // narrowed — which is why `mediatedSafeGap` is surfaced rather than
214
214
  // quietly ignored.
215
+ for (const dir of writableDirs) a.push('--add-dir', dir);
215
216
  a.push('--dangerously-skip-permissions');
216
217
  a.push('--print-timeout', '60m');
217
218
  } else if (profile === 'consult') {
@@ -101,7 +101,7 @@ export const CLAUDE_RUNTIME = {
101
101
  * strongest form of it available anywhere: `--append-system-prompt` sits
102
102
  * above the conversation rather than inside it.
103
103
  */
104
- args({ prompt, system, model, effort, resume, perm, profile = 'build', mcp = [], resultSchemaArgs = [], adoptResumeId, resumeThreadId, knowledgeDir, vaultDir, agentTools, personalSettings, machineMcp }) {
104
+ args({ writableDirs = [], prompt, system, model, effort, resume, perm, profile = 'build', mcp = [], resultSchemaArgs = [], adoptResumeId, resumeThreadId, knowledgeDir, vaultDir, agentTools, personalSettings, machineMcp, streamInput = false }) {
105
105
  // THE CARTOGRAPHER'S FENCE NAMES ITS VAULT (2026-10-10): its one write rule
106
106
  // is the vault's absolute path (claudePosture.mjs `wikiPermFor`), so a turn
107
107
  // with no vault, or one no rule can name, has no list — refused here in
@@ -138,7 +138,9 @@ export const CLAUDE_RUNTIME = {
138
138
  if (adoptResumeId) a.push('--resume', adoptResumeId, '--fork-session');
139
139
  else if (resumeThreadId) a.push('--resume', resumeThreadId);
140
140
  else if (resume) a.push('--continue');
141
- a.push('-p', prompt, '--append-system-prompt', system);
141
+ if (streamInput) a.push('-p', '--input-format', 'stream-json', '--replay-user-messages');
142
+ else a.push('-p', prompt);
143
+ a.push('--append-system-prompt', system);
142
144
  /**
143
145
  * WHOSE SETTINGS A TURN READS (2026-09-29, claudePersonal.mjs).
144
146
  *
@@ -205,6 +207,7 @@ export const CLAUDE_RUNTIME = {
205
207
  // worktree's cwd: named as a readable directory so a curated profile
206
208
  // does not refuse the path the prompt just handed it. Before `perm`,
207
209
  // because `--allowedTools` is variadic and would swallow the flag.
210
+ if (profile === 'build') for (const dir of writableDirs) a.push('--add-dir', dir);
208
211
  if (knowledgeDir) a.push('--add-dir', knowledgeDir);
209
212
  // THE VAULT, likewise (2026-10-10): it lives under ~/.flowviant/vaults,
210
213
  // outside the wiki worktree, and its `mkdir`/`rm` are path-validated to the
@@ -233,7 +233,7 @@ export const CODEX_RUNTIME = {
233
233
  * placed before it. Appending them after the positional is the kind of argv
234
234
  * that parses today and stops parsing on some future clap upgrade.
235
235
  */
236
- args({ prompt, system, model, effort, resume, resumeThreadId, profile = 'build', vaultDir, cwd, mcp = [], resultSchemaArgs = [], adoptResumeId, agentTools, personal = null, researchIsolation, knowledgeDir, projectReads }) {
236
+ args({ writableDirs = [], prompt, system, model, effort, resume, resumeThreadId, profile = 'build', vaultDir, cwd, mcp = [], resultSchemaArgs = [], adoptResumeId, agentTools, personal = null, researchIsolation, knowledgeDir, projectReads }) {
237
237
  // Adoption resumes a conversation in ITS OWN CLI's store (claude forks,
238
238
  // agy moves) — codex has no adoptable store wired yet. Reaching here
239
239
  // with an adopt id is a wiring mistake upstream, and it fails loudly on
@@ -277,6 +277,7 @@ export const CODEX_RUNTIME = {
277
277
  // `--sandbox` flag (codex-cli 0.156.1 lists only `-c`/`--enable`/…), so
278
278
  // every resumed turn — an agent's second card, an answer — was refused
279
279
  // before it started. `-c sandbox_mode=` means the same on both.
280
+ if (writableDirs.length) a.push('-c', `sandbox_workspace_write.writable_roots=${JSON.stringify(writableDirs)}`);
280
281
  a.push('-c', `sandbox_mode="${SAFE ? 'workspace-write' : 'danger-full-access'}"`);
281
282
  }
282
283
 
@@ -26,12 +26,14 @@ export function mergeOutward({
26
26
  repoRoot,
27
27
  tmpDir,
28
28
  baseRef,
29
+ baseTip,
29
30
  workingTree,
30
31
  warn,
31
32
  // NETWORK calls only (push, fetch) — timed and non-interactive, so a
32
33
  // stale credential or a half-open connection cannot freeze the ship
33
34
  // path's writer lock. Injectable for tests; defaults to the real one.
34
35
  gitNet = gitNetDefault,
36
+ retryRace = true,
35
37
  }) {
36
38
  const dropTmp = () => {
37
39
  try {
@@ -56,6 +58,7 @@ export function mergeOutward({
56
58
  const base = baseBranchName(baseRef());
57
59
  const merged = String(git(['rev-parse', 'HEAD'], tmpDir)).trim();
58
60
  const before = String(git(['rev-parse', `refs/heads/${base}`], repoRoot)).trim();
61
+ if (baseTip && before !== baseTip) throw new Error(`base moved to ${before}, but expected ${baseTip}`);
59
62
  let current = null;
60
63
  try { current = String(git(['symbolic-ref', '--quiet', '--short', 'HEAD'], repoRoot)).trim(); } catch { /* detached */ }
61
64
  if (current === base) {
@@ -76,7 +79,7 @@ export function mergeOutward({
76
79
  };
77
80
  const attempt = () => {
78
81
  dropTmp();
79
- git(['worktree', 'add', '--detach', tmpDir, baseRef()], repoRoot);
82
+ git(['worktree', 'add', '--detach', tmpDir, baseTip ?? baseRef()], repoRoot);
80
83
  gitMerge(
81
84
  ['merge', '--no-ff', tip, '-m', `ship(${label}): ${count} commit${count === 1 ? '' : 's'}`],
82
85
  tmpDir
@@ -110,7 +113,7 @@ export function mergeOutward({
110
113
  * re-pushing a stale merge. The TIP is untouched, so the receipts still
111
114
  * name exactly the same commits.
112
115
  */
113
- if (!isRaceRejection(e)) throw e;
116
+ if (!retryRace || !isRaceRejection(e)) throw e;
114
117
  warn?.('ship: base moved under us — refetching and merging again');
115
118
  try {
116
119
  gitNet(['fetch', 'origin', '--quiet'], repoRoot, 60_000);
@@ -188,8 +188,8 @@ export async function resolveStartProject({ dirGiven }) {
188
188
  }
189
189
  // An answered question is consent: adopt it, and BIND it to this repo so
190
190
  // the next start needs no prompt. Repointing is legitimate and said aloud.
191
- if (repoRoot && picked.repoRoot && picked.repoRoot !== repoRoot) {
192
- console.log(`note: ${creds.projectLabel(picked)} was connected for ${picked.repoRoot} — now serving ${repoRoot} instead.`);
191
+ if (repoRoot && picked.repoRoot && !creds.isStoredRepo(picked, repoRoot)) {
192
+ console.log(`note: adding ${repoRoot} to ${creds.projectLabel(picked)}. Run ${terminalCommand('login')} here to register its repository in project settings.`);
193
193
  }
194
194
  adoptStoredCredential(picked);
195
195
  // BOUND ONLY ONCE THE START HAS THE LOCK. Written here, the answer moved
@@ -266,10 +266,10 @@ export async function resolveStartProject({ dirGiven }) {
266
266
  }
267
267
 
268
268
  if (process.argv.includes('--json-events') && dirGiven && CREDENTIAL?.entry) {
269
- const { detectRepoRoot, listStoredProjects, boundElsewhere } = await import('./credentials.mjs');
269
+ const { detectRepoRoot, listStoredProjects, boundElsewhere, isStoredRepo } = await import('./credentials.mjs');
270
270
  const repo = detectRepoRoot();
271
271
  const entry = CREDENTIAL.entry;
272
- if (!repo || entry.repoRoot !== repo || boundElsewhere(listStoredProjects(), repo, entry.projectId).length) {
272
+ if (!repo || !isStoredRepo(entry, repo) || boundElsewhere(listStoredProjects(), repo, entry.projectId).length) {
273
273
  console.error('error: this project is not connected to this repository. Run `flowviant login` for the selected folder.');
274
274
  process.exit(1);
275
275
  }