flowviant 0.125.18 → 0.125.21
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 +11 -1
- package/bin/cli.mjs +13 -0
- package/bin/lib/artifactCache.mjs +336 -0
- package/bin/lib/artifactContracts.mjs +87 -2
- package/bin/lib/artifactIteration.mjs +3 -2
- package/bin/lib/artifactPolicy.mjs +5 -1
- package/bin/lib/artifactReporter.mjs +91 -9
- package/bin/lib/artifacts.mjs +12 -6
- package/bin/lib/claudeStream.mjs +7 -0
- package/bin/lib/codexFencedMount.mjs +2 -2
- package/bin/lib/fleet.mjs +9 -0
- package/bin/lib/intermediateArtifacts.mjs +40 -8
- package/bin/lib/knowledgeLibrary.mjs +2 -1
- package/bin/lib/localCleanupReport.mjs +34 -0
- package/bin/lib/localCleanupState.mjs +68 -0
- package/bin/lib/logReplay.mjs +6 -0
- package/bin/lib/managedLocalCleanup.mjs +158 -0
- package/bin/lib/pinnedCapture.mjs +19 -8
- package/bin/lib/postureTools.mjs +2 -2
- package/bin/lib/publicReferenceBrowser.mjs +180 -0
- package/bin/lib/publicReferenceNetwork.mjs +131 -0
- package/bin/lib/publicReferenceProtocol.mjs +55 -0
- package/bin/lib/publicReferenceRuntime.mjs +65 -0
- package/bin/lib/publicReferenceTool.mjs +89 -0
- package/bin/lib/referenceCaptureProtocol.mjs +12 -0
- package/bin/lib/referenceCaptures.mjs +140 -0
- package/bin/lib/resultInputs.mjs +4 -2
- package/bin/lib/runTurn.mjs +8 -0
- package/bin/lib/runtimeCodex.mjs +1 -1
- package/bin/lib/runtimeEvents.mjs +24 -2
- package/bin/lib/uiReview.mjs +305 -0
- package/bin/lib/uiReviewBrowser.mjs +212 -0
- package/bin/lib/uiReviewContract.mjs +51 -0
- package/bin/lib/uiReviewTurn.mjs +15 -0
- package/bin/lib/websiteAccess.mjs +68 -0
- package/bin/lib/websiteTrace.mjs +36 -0
- package/bin/lib/work.mjs +46 -3
- package/bin/lib/workAgentPlans.mjs +3 -0
- package/bin/lib/workAgentPrecheck.mjs +16 -15
- package/bin/lib/workAgentReview.mjs +2 -2
- package/bin/lib/workAgentTools.mjs +3 -2
- package/bin/lib/workAgentTurnExecution.mjs +69 -4
- package/bin/lib/workAgentTurns.mjs +3 -1
- package/bin/lib/workLocalCleanup.mjs +125 -0
- package/bin/lib/workMergeBatch.mjs +19 -4
- package/bin/lib/workSessionGroups.mjs +20 -1
- package/bin/lib/workToolLog.mjs +24 -3
- package/package.json +1 -1
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/** Startup/idle runner for local-only cleanup. Recovery I/O is outside the
|
|
2
|
+
* writer lock; only a fresh prepared version may enter the short unlink phase. */
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { cleanupState } from './localCleanupState.mjs';
|
|
5
|
+
import { agentIdFromPlace } from './workPlaces.mjs';
|
|
6
|
+
|
|
7
|
+
export const LOCAL_CLEANUP_INTERVAL_MS = 15_000;
|
|
8
|
+
export const LOCAL_CLEANUP_BACKOFF_MS = 60_000;
|
|
9
|
+
const MAX_BACKOFF = 60 * 60_000;
|
|
10
|
+
const keyOf = ref => JSON.stringify([ref.kind, ref.name, ref.localVersion]);
|
|
11
|
+
|
|
12
|
+
export function createWorkLocalCleanup({ placeLocks, inPlace, targetsFor, eligible, permission = () => false,
|
|
13
|
+
now = Date.now, log = () => {}, onResult = () => {}, onIncomplete = () => {} }) {
|
|
14
|
+
let snapshot = null, sweeping = null, lastAt = -Infinity, placeCursor = 0;
|
|
15
|
+
const targetCursors = new Map();
|
|
16
|
+
const allowed = (place, locked = false) => {
|
|
17
|
+
if (!snapshot || snapshot.serving !== true || snapshot.draining !== false ||
|
|
18
|
+
!Array.isArray(snapshot.activeIds) || !Array.isArray(snapshot.heldElsewhere) ||
|
|
19
|
+
now() < snapshot.at || now() - snapshot.at > 15_000 || permission() !== true ||
|
|
20
|
+
!snapshot.activeIds.includes(place) || snapshot.heldElsewhere.includes(place)) return false;
|
|
21
|
+
const lock = placeLocks.get(place);
|
|
22
|
+
if (locked ? lock?.waiters?.length > 0 : placeLocks.has(place)) return false;
|
|
23
|
+
return eligible(place) === true;
|
|
24
|
+
};
|
|
25
|
+
const attemptTarget = async (place, target) => {
|
|
26
|
+
const identity = JSON.stringify({ place, placeDir: target.placeDir, agentId: target.agentId, projectId: target.projectId });
|
|
27
|
+
const store = cleanupState(target.statePath, identity), release = store.claim();
|
|
28
|
+
if (!release) return;
|
|
29
|
+
let locked = false;
|
|
30
|
+
const guard = () => allowed(place, locked);
|
|
31
|
+
const memo = new Map(); let preparedOnly = false;
|
|
32
|
+
try {
|
|
33
|
+
if (!guard()) return;
|
|
34
|
+
const state = store.load();
|
|
35
|
+
// Replay durable outcomes before selecting work, including when every
|
|
36
|
+
// version is already evicted. Stable ids make replay retry-safe.
|
|
37
|
+
const publish = () => {
|
|
38
|
+
let changed = false;
|
|
39
|
+
for (const entry of state.results) {
|
|
40
|
+
if (entry.reported === true) continue;
|
|
41
|
+
if (onResult(entry) !== false) { entry.reported = true; changed = true; }
|
|
42
|
+
}
|
|
43
|
+
if (changed) store.save(state);
|
|
44
|
+
};
|
|
45
|
+
publish();
|
|
46
|
+
for (const attempt of Object.values(state.attempts)) {
|
|
47
|
+
if (attempt.state !== 'running' || attempt.interruptionReported) continue;
|
|
48
|
+
if (onIncomplete({ id: `interrupted:${attempt.id}`, status: 'retained',
|
|
49
|
+
reason: 'interrupted-attempt-completion-unknown', incomplete: true,
|
|
50
|
+
projectId: target.projectId, agentId: target.agentId, placeDir: target.placeDir,
|
|
51
|
+
place, at: now(), nextAt: attempt.nextAt, tries: attempt.tries,
|
|
52
|
+
resumedAttempt: attempt.id, previousCompletion: 'unknown' }) !== false) {
|
|
53
|
+
attempt.interruptionReported = true; store.save(state);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const operations = target.operations({ guard, recoveryMemo: memo, preparedOnly: () => preparedOnly });
|
|
57
|
+
if (!operations.length) return;
|
|
58
|
+
let chosen = null;
|
|
59
|
+
for (let i = 0; i < Math.min(32, operations.length); i++) {
|
|
60
|
+
const index = (state.cursor + i) % operations.length, op = operations[index], key = keyOf(op.ref);
|
|
61
|
+
const previous = state.attempts[key];
|
|
62
|
+
if (op.ref.state === 'discarded' || (op.ref.state === 'evicted' && previous?.state !== 'running')) continue;
|
|
63
|
+
if (previous && previous.nextAt > now()) continue;
|
|
64
|
+
chosen = { op, key, previous }; state.cursor = (index + 1) % operations.length; break;
|
|
65
|
+
}
|
|
66
|
+
if (!chosen) { state.cursor = (state.cursor + 32) % operations.length; store.save(state); return; }
|
|
67
|
+
const { op, key, previous } = chosen, id = randomUUID();
|
|
68
|
+
const tries = Math.min(100, (previous?.tries ?? 0) + 1);
|
|
69
|
+
state.attempts[key] = { id, state: 'running', tries, nextAt: now() + LOCAL_CLEANUP_BACKOFF_MS, startedAt: now() };
|
|
70
|
+
// Persist before remote verification/unlink. A restart honours backoff
|
|
71
|
+
// and re-runs the prerequisite's current version/pin/recovery checks.
|
|
72
|
+
store.save(state);
|
|
73
|
+
let result = await op.prepare();
|
|
74
|
+
if (result.status === 'ready') {
|
|
75
|
+
if (!guard() || placeLocks.has(place)) result = { status: 'retained', reason: 'lifecycle-changed' };
|
|
76
|
+
else await inPlace(place, true, async () => {
|
|
77
|
+
locked = true; preparedOnly = true;
|
|
78
|
+
try {
|
|
79
|
+
if (!guard()) { result = { status: 'retained', reason: 'lifecycle-changed' }; return; }
|
|
80
|
+
const current = target.operations({ guard, recoveryMemo: memo, preparedOnly: () => preparedOnly })
|
|
81
|
+
.find(candidate => keyOf(candidate.ref) === key);
|
|
82
|
+
result = current ? await current.run() : { status: 'retained', reason: 'local-version-changed' };
|
|
83
|
+
} finally { locked = false; }
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
const retryable = result.status === 'failed' || result.incomplete === true || /remote|recovery|unreadable|unlink-failed/.test(result.reason ?? '');
|
|
87
|
+
const nextAt = now() + (retryable ? Math.min(MAX_BACKOFF, LOCAL_CLEANUP_BACKOFF_MS * 2 ** Math.min(tries - 1, 6)) : LOCAL_CLEANUP_BACKOFF_MS);
|
|
88
|
+
const entry = { ...result, id, kind: op.ref.kind, name: op.ref.name, localVersion: op.ref.localVersion,
|
|
89
|
+
projectId: target.projectId, agentId: target.agentId, placeDir: target.placeDir, place, at: now(),
|
|
90
|
+
bytes: result.status === 'reclaimed' ? result.bytes : 0, nextAt, tries,
|
|
91
|
+
...(previous?.state === 'running' ? { resumedAttempt: previous.id, previousCompletion: 'unknown' } : {}) };
|
|
92
|
+
state.attempts[key] = { id, state: result.incomplete ? 'incomplete' : result.status, tries: retryable ? tries : 0, nextAt, result: entry };
|
|
93
|
+
state.results.push(entry); state.results = state.results.slice(-128);
|
|
94
|
+
const liveKeys = new Set(operations.map(candidate => keyOf(candidate.ref)));
|
|
95
|
+
for (const old of Object.keys(state.attempts)) if (!liveKeys.has(old)) delete state.attempts[old];
|
|
96
|
+
store.save(state);
|
|
97
|
+
publish();
|
|
98
|
+
} finally { memo.clear(); release(); }
|
|
99
|
+
};
|
|
100
|
+
const reconcileLocalCleanup = evidence => {
|
|
101
|
+
// Refresh even while an older verification is in flight. Lost serving
|
|
102
|
+
// permission, new foreground work and peer leases invalidate its guard.
|
|
103
|
+
snapshot = { ...evidence, at: now() };
|
|
104
|
+
if (sweeping || now() - lastAt < LOCAL_CLEANUP_INTERVAL_MS || permission() !== true ||
|
|
105
|
+
evidence?.serving !== true || evidence?.draining !== false || !Array.isArray(evidence.activeIds) || !Array.isArray(evidence.heldElsewhere)) return sweeping;
|
|
106
|
+
const places = evidence.activeIds.filter(place => agentIdFromPlace(place) && /^[A-Za-z0-9_-]+$/.test(place));
|
|
107
|
+
for (const place of targetCursors.keys()) if (!places.includes(place)) targetCursors.delete(place);
|
|
108
|
+
if (!places.length) return null;
|
|
109
|
+
lastAt = now(); const place = places[placeCursor++ % places.length];
|
|
110
|
+
if (!allowed(place)) return null;
|
|
111
|
+
sweeping = (async () => {
|
|
112
|
+
try {
|
|
113
|
+
const targets = targetsFor(place);
|
|
114
|
+
if (!targets.length) return;
|
|
115
|
+
const cursor = targetCursors.get(place) ?? 0;
|
|
116
|
+
const target = targets[cursor % targets.length];
|
|
117
|
+
targetCursors.set(place, cursor + 1);
|
|
118
|
+
await attemptTarget(place, target);
|
|
119
|
+
} catch (error) { log(`local cleanup incomplete: ${error.message}`); }
|
|
120
|
+
finally { sweeping = null; }
|
|
121
|
+
})();
|
|
122
|
+
return sweeping;
|
|
123
|
+
};
|
|
124
|
+
return { reconcileLocalCleanup, cleanupBusy: () => sweeping !== null };
|
|
125
|
+
}
|
|
@@ -9,7 +9,7 @@ import { mergeBatchPrompt, MERGE_BATCH_SYSTEM } from './repositoryPrompts.mjs';
|
|
|
9
9
|
import { runTurn } from './runTurn.mjs';
|
|
10
10
|
import { runCappedTurn } from './workCappedTurn.mjs';
|
|
11
11
|
import { candidateObjects } from './modelJson.mjs';
|
|
12
|
-
import {
|
|
12
|
+
import { brainFor } from './workBrain.mjs';
|
|
13
13
|
import { fleetRequest } from './fleetPost.mjs';
|
|
14
14
|
import { fleetEndpoint } from './fleetWire.mjs';
|
|
15
15
|
import { FLEET_URL, DAEMON_INSTANCE } from './config.mjs';
|
|
@@ -20,6 +20,22 @@ import { isSafePathSegment, stripUrlUserinfo } from './git.mjs';
|
|
|
20
20
|
import { killGroup } from './processGroup.mjs';
|
|
21
21
|
import { readConfiguredCheck } from './workAgentCheck.mjs';
|
|
22
22
|
|
|
23
|
+
/** A joint repair has no unique originating brain when its members differ. */
|
|
24
|
+
export function mergeBatchBrain(members, coordinatorAgentId) {
|
|
25
|
+
if (!members?.length) throw new Error('The selected merge has no originating agent.');
|
|
26
|
+
if (coordinatorAgentId) {
|
|
27
|
+
const selected = members.find(member => member.agentId === coordinatorAgentId);
|
|
28
|
+
if (!selected) throw new Error('The selected merge coordinator is not part of this merge.');
|
|
29
|
+
const runtime = selected.runtime || 'claude';
|
|
30
|
+
return { runtime, ...brainFor(selected, runtime) };
|
|
31
|
+
}
|
|
32
|
+
const picks = members.map(member => ({ runtime: member.runtime || 'claude', model: member.model || null, effort: member.effort || null }));
|
|
33
|
+
const first = picks[0];
|
|
34
|
+
if (picks.some(pick => pick.runtime !== first.runtime || pick.model !== first.model || pick.effort !== first.effort))
|
|
35
|
+
throw new Error('The selected agents use different CLI/model/effort choices. Merge them separately so repair uses each agent’s own choice.');
|
|
36
|
+
return { runtime: first.runtime, ...brainFor(first, first.runtime) };
|
|
37
|
+
}
|
|
38
|
+
|
|
23
39
|
export function createWorkMergeBatch({ repositoryPlaces, repoRoot, baseDir, inPlace, runCheck, workChildren, groupKillChildren, admit, onRepoChanged, landed, runCli = runTurn }) {
|
|
24
40
|
const request = async (route, body) => {
|
|
25
41
|
const response = await fleetRequest(fleetEndpoint(route, FLEET_URL), { body: { ...body, instance: DAEMON_INSTANCE } });
|
|
@@ -64,15 +80,14 @@ export function createWorkMergeBatch({ repositoryPlaces, repoRoot, baseDir, inPl
|
|
|
64
80
|
if (!result?.ok) throw new Error('Batch ownership or progress changed. Retry will remeasure base.');
|
|
65
81
|
},
|
|
66
82
|
coordinate: async (context) => {
|
|
67
|
-
const
|
|
68
|
-
if (!runtime) throw new Error('No coding CLI is installed on this machine to coordinate the batch.');
|
|
83
|
+
const brain = mergeBatchBrain(spec.members, spec.coordinatorAgentId);
|
|
69
84
|
let child;
|
|
70
85
|
try {
|
|
71
86
|
const result = await runCappedTurn({ runTurn: runCli, workChildren, releaseSlot: release,
|
|
72
87
|
transcriptCwd: context.repositories[0].integrationWorktree, capMs: 20 * 60_000, killChild: killGroup,
|
|
73
88
|
onSpawn: (ch) => { child = ch; groupKillChildren?.add(ch); },
|
|
74
89
|
turnArgs: { prompt: mergeBatchPrompt(context), system: MERGE_BATCH_SYSTEM,
|
|
75
|
-
|
|
90
|
+
...brain, profile: 'build', knowledgeDir: knowledgeDirFor(repoRoot),
|
|
76
91
|
cwd: context.repositories[0].integrationWorktree, writableDirs: context.repositories.filter((r) => !r.alreadyLanded).map((r) => r.integrationWorktree),
|
|
77
92
|
answerFromResult: true, work } });
|
|
78
93
|
if (result.wedged) throw new Error('The coordinating CLI ran out of time. Retry the batch to continue with its saved integration work.');
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* manager against a real registry file.
|
|
12
12
|
*/
|
|
13
13
|
import { join } from 'node:path';
|
|
14
|
+
import { constants, openSync, closeSync, fstatSync, readFileSync } from 'node:fs';
|
|
14
15
|
import { homedir } from 'node:os';
|
|
15
16
|
import { createHash } from 'node:crypto';
|
|
16
17
|
import { measureProcesses, liveGroups, processesSupported } from './processes.mjs';
|
|
@@ -162,5 +163,23 @@ export function createWorkSessionGroups({ repoRoot }) {
|
|
|
162
163
|
return measureProcesses(alive, { scrub: envScrub });
|
|
163
164
|
};
|
|
164
165
|
|
|
165
|
-
|
|
166
|
+
/** Cleanup needs stronger evidence than a display: reload the durable
|
|
167
|
+
* registry to observe peer/orphan groups, and retain on missing/bad reads. */
|
|
168
|
+
const cleanupProcessesIdle = place => {
|
|
169
|
+
if (!processesSupported()) return null;
|
|
170
|
+
let fd;
|
|
171
|
+
try {
|
|
172
|
+
fd = openSync(GROUPS_FILE, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
173
|
+
const st = fstatSync(fd);
|
|
174
|
+
if (!st.isFile() || st.size > 256 * 1024) return null;
|
|
175
|
+
const entries = JSON.parse(readFileSync(fd, 'utf8'));
|
|
176
|
+
if (!Array.isArray(entries) || entries.length >= 32 || entries.some(e =>
|
|
177
|
+
typeof e?.sessionId !== 'string' || !Number.isInteger(e.pid) || e.pid <= 1 || typeof e.boot !== 'string' || !e.boot)) return null;
|
|
178
|
+
const groups = new Set(sessionGroups.get(place) ?? []);
|
|
179
|
+
for (const entry of entries) if (entry.sessionId === place && sameBoot(entry.boot, BOOT)) groups.add(entry.pid);
|
|
180
|
+
return liveGroups(groups).size === 0;
|
|
181
|
+
} catch { return null; }
|
|
182
|
+
finally { if (fd !== undefined) closeSync(fd); }
|
|
183
|
+
};
|
|
184
|
+
return { sessionGroups, noteSessionGroup, pruneSessionGroups, sessionProcesses, cleanupProcessesIdle };
|
|
166
185
|
}
|
package/bin/lib/workToolLog.mjs
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { toolEventOf } from './runtimeEvents.mjs';
|
|
11
11
|
import { scrubCut, scrub as envScrub } from './uplinkScrub.mjs';
|
|
12
|
+
import { observeWebsite } from './websiteAccess.mjs';
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* THE TURN'S TOOL LOG — the structured relay behind the transcript's
|
|
@@ -47,7 +48,15 @@ export function createToolLog(cwd) {
|
|
|
47
48
|
e.outcome = !meta.result ? 'unknown' : failed ? 'failed' :
|
|
48
49
|
meta.runtime === 'codex' && code === undefined ? 'unknown' : 'success';
|
|
49
50
|
if (code !== undefined) e.exitCode = code;
|
|
50
|
-
if (
|
|
51
|
+
if (e.website) {
|
|
52
|
+
observeWebsite(e, meta, envScrub);
|
|
53
|
+
if (e.errorTruncated) toolLog.state = 'partial';
|
|
54
|
+
}
|
|
55
|
+
if (e.outcome === 'failed') {
|
|
56
|
+
if (e.website) {
|
|
57
|
+
if (!toolLog.firstFailure) { toolLog.firstFailure = structuredClone(e); toolLog.recovery = []; }
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
51
60
|
const output = scrubCut(meta.output ?? '', 16_384);
|
|
52
61
|
const guardAt = output.indexOf('flowviant: this turn is read-only, so ');
|
|
53
62
|
const guard = guardAt >= 0 ? output.slice(guardAt).split('\n', 1)[0] : undefined;
|
|
@@ -67,8 +76,19 @@ export function createToolLog(cwd) {
|
|
|
67
76
|
};
|
|
68
77
|
const pushToolEvent = (name, input, meta = {}) => {
|
|
69
78
|
const id = typeof meta.id === 'string' && meta.id && meta.id.length <= 160 ? meta.id : undefined;
|
|
70
|
-
const
|
|
79
|
+
const key = id && JSON.stringify([meta.agentId ?? null, id]);
|
|
80
|
+
const existing = key && correlated.get(key);
|
|
71
81
|
if (existing) {
|
|
82
|
+
// Codex may only name the web action/URL on completion. Enrich that
|
|
83
|
+
// same native request instead of keeping its provisional search label.
|
|
84
|
+
if (existing.website && name) {
|
|
85
|
+
const request = toolEventOf(name, input, cwd, envScrub)?.website;
|
|
86
|
+
if (request) {
|
|
87
|
+
existing.website.method = request.method;
|
|
88
|
+
existing.website.tool = request.tool;
|
|
89
|
+
if (request.requestedUrl) existing.website.requestedUrl = request.requestedUrl;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
72
92
|
if (meta.result) observe(existing, meta);
|
|
73
93
|
refresh();
|
|
74
94
|
return existing;
|
|
@@ -87,7 +107,7 @@ export function createToolLog(cwd) {
|
|
|
87
107
|
if (typeof meta.agentId === 'string' && meta.agentId) e.agentId = scrubCut(meta.agentId, 160);
|
|
88
108
|
if (e.agentId && typeof meta.agentTitle === 'string' && meta.agentTitle) e.agentTitle = scrubCut(meta.agentTitle, 120);
|
|
89
109
|
if (e.t !== 'plan') {
|
|
90
|
-
if (id) { e.id = scrubCut(id, 160); correlated.set(
|
|
110
|
+
if (id) { e.id = scrubCut(id, 160); correlated.set(key, e); }
|
|
91
111
|
if (name === 'Read') for (const key of ['offset', 'limit']) {
|
|
92
112
|
if (Number.isSafeInteger(input?.[key]) && input[key] >= 0) e[key] = input[key];
|
|
93
113
|
}
|
|
@@ -115,6 +135,7 @@ export function createToolLog(cwd) {
|
|
|
115
135
|
last.errorCategory === e.errorCategory &&
|
|
116
136
|
last.offset === e.offset && last.limit === e.limit &&
|
|
117
137
|
last.t === e.t &&
|
|
138
|
+
!e.website && !last.website &&
|
|
118
139
|
last.p === e.p &&
|
|
119
140
|
last.q === e.q &&
|
|
120
141
|
last.c === e.c;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flowviant",
|
|
3
|
-
"version": "0.125.
|
|
3
|
+
"version": "0.125.21",
|
|
4
4
|
"description": "Run your own coding CLIs as build agents for Flowviant \u2014 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": {
|