flowviant 0.117.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.
- package/README.md +41 -0
- package/bin/cli.mjs +44 -0
- package/bin/lib/agentAsk.mjs +123 -0
- package/bin/lib/agentBrief.mjs +36 -0
- package/bin/lib/agentFootprint.mjs +76 -0
- package/bin/lib/agentInternalWait.mjs +4 -3
- package/bin/lib/agentPlan.mjs +86 -7
- package/bin/lib/agentPrerequisiteContract.mjs +43 -0
- package/bin/lib/agentRepositories.mjs +48 -0
- package/bin/lib/agentTaskKinds.mjs +1 -1
- package/bin/lib/artifactContracts.mjs +91 -20
- package/bin/lib/artifactIteration.mjs +182 -0
- package/bin/lib/artifactRecovery.mjs +36 -0
- package/bin/lib/artifactReporter.mjs +183 -19
- package/bin/lib/artifacts.mjs +6 -3
- package/bin/lib/checkoutIdentity.mjs +25 -0
- package/bin/lib/claudeAuth.mjs +15 -5
- package/bin/lib/claudeInput.mjs +32 -0
- package/bin/lib/claudePosture.mjs +110 -14
- package/bin/lib/claudeSessions.mjs +20 -0
- package/bin/lib/claudeStream.mjs +20 -2
- package/bin/lib/codexProjectRead.mjs +63 -0
- package/bin/lib/codexResearch.mjs +16 -3
- package/bin/lib/commandSpecs.mjs +6 -0
- package/bin/lib/credentials.mjs +52 -17
- package/bin/lib/daemonLocks.mjs +26 -6
- package/bin/lib/desktopContract.mjs +3 -0
- package/bin/lib/discoveryContract.mjs +19 -0
- package/bin/lib/fleet.mjs +65 -10
- package/bin/lib/fleetReports.mjs +44 -3
- package/bin/lib/fleetRoster.mjs +1 -89
- package/bin/lib/fleetStartup.mjs +11 -14
- package/bin/lib/holder.mjs +4 -37
- package/bin/lib/hooks/mcpAllow.mjs +60 -0
- package/bin/lib/hooks/readGuard.mjs +13 -4
- package/bin/lib/imageArtifact.mjs +74 -0
- package/bin/lib/instance.mjs +20 -7
- package/bin/lib/landed.mjs +2 -2
- package/bin/lib/login.mjs +39 -4
- package/bin/lib/maintenance.mjs +47 -0
- package/bin/lib/maintenanceInventory.mjs +39 -0
- package/bin/lib/mcpInstall.mjs +163 -0
- package/bin/lib/mcpInventory.mjs +119 -0
- package/bin/lib/placeLock.mjs +9 -7
- package/bin/lib/planDependencies.mjs +70 -0
- package/bin/lib/postureTools.mjs +85 -0
- package/bin/lib/prWorkflow.mjs +15 -0
- package/bin/lib/projectLanded.mjs +16 -0
- package/bin/lib/projectRepositories.mjs +60 -0
- package/bin/lib/projectToolRuntimes.mjs +227 -3
- package/bin/lib/projectTools.mjs +63 -5
- package/bin/lib/projectWorktreeHome.mjs +25 -0
- package/bin/lib/prompts.mjs +170 -42
- package/bin/lib/repositoryBaseline.mjs +68 -0
- package/bin/lib/repositoryDelivery.mjs +64 -0
- package/bin/lib/repositoryMerge.mjs +116 -0
- package/bin/lib/repositoryPlaces.mjs +179 -0
- package/bin/lib/repositorySetup.mjs +58 -0
- package/bin/lib/repositoryTool.mjs +71 -0
- package/bin/lib/runTurn.mjs +58 -18
- package/bin/lib/runtimeAntigravity.mjs +2 -1
- package/bin/lib/runtimeCapabilities.mjs +77 -3
- package/bin/lib/runtimeClaude.mjs +31 -10
- package/bin/lib/runtimeCodex.mjs +28 -59
- package/bin/lib/runtimeDetection.mjs +6 -0
- package/bin/lib/runtimeEvents.mjs +12 -1
- package/bin/lib/runtimeOfferings.mjs +75 -3
- package/bin/lib/shipMerge.mjs +5 -2
- package/bin/lib/startProject.mjs +4 -4
- package/bin/lib/trace.mjs +8 -3
- package/bin/lib/turnDeclaration.mjs +88 -0
- package/bin/lib/turnDiagnostics.mjs +93 -0
- package/bin/lib/turnLock.mjs +48 -8
- package/bin/lib/turnProfile.mjs +15 -3
- package/bin/lib/work.mjs +48 -29
- package/bin/lib/workAgentBrief.mjs +92 -0
- package/bin/lib/workAgentBriefRunner.mjs +59 -0
- package/bin/lib/workAgentCheck.mjs +6 -5
- package/bin/lib/workAgentMerges.mjs +19 -2
- package/bin/lib/workAgentPlans.mjs +21 -70
- package/bin/lib/workAgentPrecheck.mjs +10 -5
- package/bin/lib/workAgentPublish.mjs +18 -5
- package/bin/lib/workAgentRecovery.mjs +85 -0
- package/bin/lib/workAgentReview.mjs +5 -3
- package/bin/lib/workAgentTurnBegun.mjs +6 -4
- package/bin/lib/workAgentTurnExecution.mjs +167 -29
- package/bin/lib/workAgentTurnOutcome.mjs +20 -5
- package/bin/lib/workAgentTurns.mjs +92 -12
- package/bin/lib/workArtifactReconcile.mjs +79 -0
- package/bin/lib/workCappedTurn.mjs +10 -0
- package/bin/lib/workCommandAudit.mjs +8 -1
- package/bin/lib/workDelivery.mjs +21 -0
- package/bin/lib/workDiffs.mjs +18 -11
- package/bin/lib/workLiveMessages.mjs +129 -0
- package/bin/lib/workPlaceReports.mjs +5 -0
- package/bin/lib/workPlaces.mjs +24 -3
- package/bin/lib/workRetire.mjs +5 -0
- package/bin/lib/workSessionTurns.mjs +219 -52
- package/bin/lib/workShip.mjs +17 -16
- package/bin/lib/workToolLog.mjs +70 -3
- package/bin/lib/workTurnShape.mjs +6 -4
- package/package.json +7 -3
- package/npm-shrinkwrap.json +0 -59
|
@@ -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
|
+
}
|
package/bin/lib/runTurn.mjs
CHANGED
|
@@ -22,6 +22,8 @@ import { learnCodexLimits, recordClaudeRateLimit } from './runtimeLimits.mjs';
|
|
|
22
22
|
import { initHarvestFor, recordMcpServers, recordSkills } from './runtimeCapabilities.mjs';
|
|
23
23
|
import { codexTurnModel } from './turnModel.mjs';
|
|
24
24
|
import { printableText } from './printable.mjs';
|
|
25
|
+
import { startTurnDiagnostics } from './turnDiagnostics.mjs';
|
|
26
|
+
import { createClaudeInput } from './claudeInput.mjs';
|
|
25
27
|
|
|
26
28
|
/** How long a Codex turn's answer may wait on its rollout's model line. */
|
|
27
29
|
const CODEX_MODEL_READ_MS = 1_500;
|
|
@@ -166,6 +168,8 @@ function runPlainTurn(opts) {
|
|
|
166
168
|
// which posture a turn is running under. There is no precedence chain left
|
|
167
169
|
// to get wrong: the caller named one profile (turnProfile.mjs).
|
|
168
170
|
const args = rt.args({
|
|
171
|
+
streamInput: rt.id === 'claude' && Boolean(opts.liveMessages),
|
|
172
|
+
writableDirs: opts.writableDirs,
|
|
169
173
|
personal,
|
|
170
174
|
researchIsolation: opts.researchIsolation,
|
|
171
175
|
prompt,
|
|
@@ -195,8 +199,8 @@ function runPlainTurn(opts) {
|
|
|
195
199
|
// prompt hands an absolute path OUTSIDE the cwd — and a curated Claude
|
|
196
200
|
// profile (the capture chat's read-only list, `FLOWVIANT_SAFE=1`) may
|
|
197
201
|
// refuse a read there. `--add-dir` says the directory is one it may read.
|
|
198
|
-
//
|
|
199
|
-
//
|
|
202
|
+
// Claude adds it as a directory; Codex includes only this library in
|
|
203
|
+
// its project read boundary; agy's `--add-dir` is spent on the vault.
|
|
200
204
|
knowledgeDir,
|
|
201
205
|
// Structured-output flags for the MEDIATED path. Handed to the adapter
|
|
202
206
|
// rather than appended here for the same reason `mcp` is: Codex takes its
|
|
@@ -206,7 +210,16 @@ function runPlainTurn(opts) {
|
|
|
206
210
|
// PLAN MODE REPLACES THE POSTURE, never joins it: beside
|
|
207
211
|
// `--dangerously-skip-permissions` the bypass wins silently (measured —
|
|
208
212
|
// see PLAN_MODE_PERM).
|
|
209
|
-
|
|
213
|
+
//
|
|
214
|
+
// …AND THE PROJECT SERVERS IT MAY CALL (2026-10-10): the names of the
|
|
215
|
+
// base `.mcp.json` servers the caller mounted, each allowed by name on a
|
|
216
|
+
// profile that mounts them (`projectMcp`, turnProfile.mjs) — never on
|
|
217
|
+
// one that does not, whatever a caller passes.
|
|
218
|
+
perm: claudePermFor(turnProfile.name, knowledgeDir, vaultDir, turnProfile.projectMcp ? (opts.projectMcpServers ?? []) : []),
|
|
219
|
+
// …AND THE MACHINE'S OWN MCP SERVERS (2026-10-11): the `.mcp.json`
|
|
220
|
+
// server names an agent lane disables beside them, on a profile that
|
|
221
|
+
// mounts project servers — never on one that does not.
|
|
222
|
+
machineMcp: turnProfile.projectMcp ? opts.machineMcp : undefined,
|
|
210
223
|
// Handed to the adapter rather than appended here, because WHERE these go
|
|
211
224
|
// is a property of the CLI: Codex reads its prompt as a trailing
|
|
212
225
|
// positional, so a flag after it is a flag in the wrong place.
|
|
@@ -253,14 +266,21 @@ function runPlainTurn(opts) {
|
|
|
253
266
|
};
|
|
254
267
|
let browser = null;
|
|
255
268
|
let child;
|
|
269
|
+
let finishDiagnostics = () => {};
|
|
256
270
|
try {
|
|
257
271
|
// A turn nobody is sitting at keeps its browsers in a home of its own —
|
|
258
272
|
// the lane's kept one (an agent's), else this spawn's, removed when the
|
|
259
273
|
// CLI closes. See `cliEnv`.
|
|
260
274
|
browser = display === true ? null : openBrowserHome(browserHome);
|
|
275
|
+
const childEnv = cliEnv(personal?.env ? { ...personal.env, ...(mcpEnv ?? {}) } : mcpEnv, {
|
|
276
|
+
display: display === true,
|
|
277
|
+
browserHome: browser?.dir,
|
|
278
|
+
});
|
|
279
|
+
finishDiagnostics = startTurnDiagnostics({ runtime, bin: rt.bin, env: childEnv,
|
|
280
|
+
cwd, args, profile: turnProfile.name, planId: opts.diagnosticPlanId });
|
|
261
281
|
child = spawn(rt.bin, args, {
|
|
262
282
|
cwd,
|
|
263
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
283
|
+
stdio: [rt.id === 'claude' && opts.liveMessages ? 'pipe' : 'ignore', 'pipe', 'pipe'],
|
|
264
284
|
/**
|
|
265
285
|
* ITS OWN PROCESS GROUP, so what the agent starts stays attributable.
|
|
266
286
|
*
|
|
@@ -287,12 +307,10 @@ function runPlainTurn(opts) {
|
|
|
287
307
|
// sitting at, every way to a window — see `cliEnv`.
|
|
288
308
|
// The login's literal secrets first, so nothing of the lane's (its MCP
|
|
289
309
|
// token, its CODEX_HOME) can be overwritten by one.
|
|
290
|
-
env:
|
|
291
|
-
display: display === true,
|
|
292
|
-
browserHome: browser?.dir,
|
|
293
|
-
}),
|
|
310
|
+
env: childEnv,
|
|
294
311
|
});
|
|
295
312
|
} catch (error) {
|
|
313
|
+
finishDiagnostics({ spawnError: typeof error?.code === 'string' ? error.code : 'SPAWN_THROW' });
|
|
296
314
|
// A spawn that throws (an argument it refuses), or anything on the way
|
|
297
315
|
// to it, still takes what the adapter wrote with it — and this spawn's
|
|
298
316
|
// own browser home.
|
|
@@ -309,6 +327,7 @@ function runPlainTurn(opts) {
|
|
|
309
327
|
// than this is a previous turn's (turnModel.mjs).
|
|
310
328
|
const spawnedAt = Date.now();
|
|
311
329
|
let out = '';
|
|
330
|
+
const input = rt.id === 'claude' && opts.liveMessages ? createClaudeInput(child, { onTaken: opts.liveMessages.onTaken }) : null;
|
|
312
331
|
const pfx = label ? `${label} ` : '';
|
|
313
332
|
/**
|
|
314
333
|
* THE ONE SINK FOR WHAT A CLI OR A MODEL WROTE (2026-10-10, the audit's
|
|
@@ -378,19 +397,28 @@ function runPlainTurn(opts) {
|
|
|
378
397
|
const harvest = initHarvestFor(rt, args);
|
|
379
398
|
const onInitAll = (i) => {
|
|
380
399
|
if (harvest.skills) recordSkills(i?.skills);
|
|
381
|
-
if (harvest.mcp) recordMcpServers(i?.mcpServers);
|
|
400
|
+
if (harvest.mcp) recordMcpServers(i?.mcpServers, i?.tools);
|
|
382
401
|
onInit?.(i);
|
|
383
402
|
};
|
|
384
403
|
/** One line of the child's stdout, in whichever dialect it speaks. */
|
|
404
|
+
let cliFailed = false;
|
|
385
405
|
const onLine = (line) => {
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
406
|
+
try {
|
|
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
|
+
}
|
|
416
|
+
if ((event.type === 'result' && event.is_error === true) ||
|
|
417
|
+
event.type === 'error' || event.type === 'turn.failed') {
|
|
418
|
+
cliFailed = true;
|
|
419
|
+
opts.onCliFailure?.();
|
|
420
|
+
}
|
|
421
|
+
} catch { /* ordinary non-JSON CLI output */ }
|
|
394
422
|
if (!rt.parse)
|
|
395
423
|
return handleStreamLine(line, { cwd, emit, onActivity, onToolEvent, appendText, answerFromResult, onInit: onInitAll, onUsage, onRateLimit: onRate, onModel: onRanOn });
|
|
396
424
|
const ev = rt.parse(line, cwd);
|
|
@@ -405,6 +433,7 @@ function runPlainTurn(opts) {
|
|
|
405
433
|
// The runtime's own token count (codex's `turn.completed`) — the same
|
|
406
434
|
// `onUsage` Claude's result event feeds, so a caller charges a turn
|
|
407
435
|
// identically whichever CLI ran it.
|
|
436
|
+
if (ev.toolEvent) onToolEvent?.(...ev.toolEvent);
|
|
408
437
|
if (ev.usage) onUsage?.(ev.usage);
|
|
409
438
|
// One agent MESSAGE, alone — see parseCodexLine's `answer`. Last call
|
|
410
439
|
// wins at the caller, which is what "the final answer" means.
|
|
@@ -450,6 +479,9 @@ function runPlainTurn(opts) {
|
|
|
450
479
|
* resolves what was captured so far.
|
|
451
480
|
*/
|
|
452
481
|
const onSpawnError = (e) => {
|
|
482
|
+
input?.close();
|
|
483
|
+
if (opts.liveMessages) void opts.liveMessages.stop();
|
|
484
|
+
finishDiagnostics({ pid: child.pid, spawnError: typeof e.code === 'string' ? e.code : 'SPAWN_ERROR' });
|
|
453
485
|
releaseArgs();
|
|
454
486
|
if (e.code === 'ENOENT') {
|
|
455
487
|
console.error(`\nerror: '${rt.bin}' CLI not found on PATH. Install ${rt.label} first: ${rt.install}`);
|
|
@@ -461,10 +493,13 @@ function runPlainTurn(opts) {
|
|
|
461
493
|
};
|
|
462
494
|
child.on('error', onSpawnError);
|
|
463
495
|
child.on('close', (code, signal) => {
|
|
496
|
+
input?.close();
|
|
464
497
|
releaseArgs();
|
|
465
498
|
opts.onExit?.(code, signal);
|
|
466
499
|
if (buf.trim()) onLine(buf);
|
|
467
|
-
|
|
500
|
+
finishDiagnostics({ code, signal, pid: child.pid, output: out, cliFailed });
|
|
501
|
+
const handBack = async () => {
|
|
502
|
+
if (opts.liveMessages) await opts.liveMessages.stop();
|
|
468
503
|
resolve(out);
|
|
469
504
|
// After the answer is handed back, never before it: see `onRate`.
|
|
470
505
|
if (codexThread) {
|
|
@@ -495,5 +530,10 @@ function runPlainTurn(opts) {
|
|
|
495
530
|
}
|
|
496
531
|
handBack();
|
|
497
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
|
+
}
|
|
498
538
|
});
|
|
499
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') {
|