flowviant 0.125.27 → 0.125.29
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 +9 -6
- package/bin/cli.mjs +5 -0
- package/bin/lib/authproxy.mjs +51 -2
- package/bin/lib/browserHandoff.mjs +25 -0
- package/bin/lib/desktopContract.mjs +8 -6
- package/bin/lib/knowledge.mjs +2 -0
- package/bin/lib/knowledgeFiles.mjs +9 -2
- package/bin/lib/knowledgeLibrary.mjs +3 -0
- package/bin/lib/knowledgeVaults.mjs +148 -0
- package/bin/lib/managedBrowser.mjs +292 -0
- package/bin/lib/managedBrowserNetwork.mjs +34 -0
- package/bin/lib/managedBrowserPage.mjs +209 -0
- package/bin/lib/prompts.mjs +40 -4
- package/bin/lib/runTurn.mjs +1 -0
- package/bin/lib/runtimeAuth.mjs +56 -16
- package/bin/lib/runtimeClaude.mjs +2 -2
- package/bin/lib/runtimeCodex.mjs +6 -2
- package/bin/lib/vault.mjs +2 -0
- package/bin/lib/vaultDiff.mjs +22 -0
- package/bin/lib/wikiDraft.mjs +105 -0
- package/bin/lib/wikiOwnership.mjs +19 -0
- package/bin/lib/wikiRunner.mjs +22 -12
- package/bin/lib/workAgentTools.mjs +6 -3
- package/bin/lib/workAgentTurnExecution.mjs +46 -10
- package/package.json +1 -1
- package/npm-shrinkwrap.json +0 -69
package/bin/lib/runTurn.mjs
CHANGED
|
@@ -203,6 +203,7 @@ function runPlainTurn(opts) {
|
|
|
203
203
|
// one profile (turnProfile.mjs).
|
|
204
204
|
const stdinText = rt.stdin?.({ prompt, system, profile: turnProfile.adapterProfile }) ?? null;
|
|
205
205
|
const args = rt.args({
|
|
206
|
+
managedBrowser: opts.managedBrowser === true,
|
|
206
207
|
cardTurn: opts.cardTurn === true,
|
|
207
208
|
verifiedWorktree: opts.verifiedWorktree === true,
|
|
208
209
|
orchestration: opts.orchestration === true,
|
package/bin/lib/runtimeAuth.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/** Coding tools' own auth verdicts for the local desktop contract.
|
|
2
2
|
*
|
|
3
3
|
* The credential file is only a context clue (claudeAuth.mjs): on some boxes
|
|
4
4
|
* Claude stores a working login in a keychain. Only `claude auth status --json`
|
|
@@ -15,6 +15,28 @@ const UNKNOWN = Object.freeze({ signedIn: null, billing: null, subscriptionType:
|
|
|
15
15
|
const TTL_MS = 90_000;
|
|
16
16
|
const SUBSCRIPTIONS = new Set(['pro', 'max', 'team', 'enterprise']);
|
|
17
17
|
|
|
18
|
+
function cachedAuth(cachePath, bin, now, refresh) {
|
|
19
|
+
if (refresh) return null;
|
|
20
|
+
try {
|
|
21
|
+
const cached = JSON.parse(readFileSync(cachePath, 'utf8'));
|
|
22
|
+
if (cached.bin === bin && Number.isFinite(cached.at) && now >= cached.at && now - cached.at < TTL_MS &&
|
|
23
|
+
cached.result?.checkedAt === new Date(cached.at).toISOString() &&
|
|
24
|
+
[true, false, null].includes(cached.result?.signedIn) &&
|
|
25
|
+
[null, 'api', 'subscription'].includes(cached.result?.billing)) return cached.result;
|
|
26
|
+
} catch { /* No usable cache: ask the CLI, including after an upgrade. */ }
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function retainAuth(cachePath, bin, now, result) {
|
|
31
|
+
const measured = { ...result, checkedAt: new Date(now).toISOString(),
|
|
32
|
+
expiresAt: result.expiresAt ?? null, expiryKind: result.expiryKind ?? null };
|
|
33
|
+
try {
|
|
34
|
+
mkdirSync(dirname(cachePath), { recursive: true, mode: 0o700 });
|
|
35
|
+
writeFileSync(cachePath, JSON.stringify({ bin, at: now, result: measured }), { mode: 0o600 });
|
|
36
|
+
} catch { /* Caching is optional; never break status. */ }
|
|
37
|
+
return measured;
|
|
38
|
+
}
|
|
39
|
+
|
|
18
40
|
export function parseClaudeAuthStatus(raw) {
|
|
19
41
|
let value;
|
|
20
42
|
try { value = JSON.parse(raw); } catch { return UNKNOWN; }
|
|
@@ -31,14 +53,8 @@ export function parseClaudeAuthStatus(raw) {
|
|
|
31
53
|
|
|
32
54
|
export function readClaudeAuthStatus({ bin = 'claude', cachePath = join(homedir(), '.flowviant', 'claude-auth-status.json'),
|
|
33
55
|
refresh = false, timeoutMs = 3_000, now = Date.now(), spawn = spawnSync, contextFor = claudeAuthContext } = {}) {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
const cached = JSON.parse(readFileSync(cachePath, 'utf8'));
|
|
37
|
-
if (cached.bin === bin && Number.isFinite(cached.at) && now >= cached.at && now - cached.at < TTL_MS &&
|
|
38
|
-
[true, false, null].includes(cached.result?.signedIn) &&
|
|
39
|
-
[null, 'api', 'subscription'].includes(cached.result?.billing)) return cached.result;
|
|
40
|
-
} catch { /* no cache, or unreadable cache: ask the CLI */ }
|
|
41
|
-
}
|
|
56
|
+
const cached = cachedAuth(cachePath, bin, now, refresh);
|
|
57
|
+
if (cached) return cached;
|
|
42
58
|
let result = UNKNOWN;
|
|
43
59
|
try {
|
|
44
60
|
const child = spawn(bin, ['auth', 'status', '--json'], {
|
|
@@ -46,16 +62,40 @@ export function readClaudeAuthStatus({ bin = 'claude', cachePath = join(homedir(
|
|
|
46
62
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
47
63
|
});
|
|
48
64
|
if (!child.error && (child.status === 0 || child.status === 1)) result = parseClaudeAuthStatus(child.stdout);
|
|
49
|
-
if (result.signedIn === true && result.billing === 'subscription'
|
|
65
|
+
if (result.signedIn === true && result.billing === 'subscription') {
|
|
50
66
|
const context = contextFor();
|
|
51
|
-
if (context.source === 'file'
|
|
52
|
-
result
|
|
67
|
+
if (context.source === 'file') {
|
|
68
|
+
if (result.subscriptionType === null && SUBSCRIPTIONS.has(context.subscriptionType)) {
|
|
69
|
+
result = { ...result, subscriptionType: context.subscriptionType };
|
|
70
|
+
}
|
|
71
|
+
// Access-token expiry is renewable. Only the store's explicit refresh
|
|
72
|
+
// expiry can describe the end of this login; absent stays unknown.
|
|
73
|
+
const expiry = Date.parse(context.refreshExpiresAt ?? '');
|
|
74
|
+
if (Number.isFinite(expiry)) result = { ...result, expiresAt: new Date(expiry).toISOString(), expiryKind: 'refresh' };
|
|
53
75
|
}
|
|
54
76
|
}
|
|
55
77
|
} catch { /* A failed probe means unknown, not signed out. */ }
|
|
78
|
+
return retainAuth(cachePath, bin, now, result);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** `codex login status` writes its verdict to stderr. Keep only the verdict,
|
|
82
|
+
* never its output: API-key mode can include credential material. */
|
|
83
|
+
export function readCodexAuthStatus({ bin = 'codex', cachePath = join(homedir(), '.flowviant', 'codex-auth-status.json'),
|
|
84
|
+
refresh = false, timeoutMs = 3_000, now = Date.now(), spawn = spawnSync } = {}) {
|
|
85
|
+
const cached = cachedAuth(cachePath, bin, now, refresh);
|
|
86
|
+
if (cached) return cached;
|
|
87
|
+
let result = UNKNOWN;
|
|
56
88
|
try {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
89
|
+
const child = spawn(bin, ['login', 'status'], {
|
|
90
|
+
encoding: 'utf8', timeout: timeoutMs, maxBuffer: 64 * 1024,
|
|
91
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
92
|
+
});
|
|
93
|
+
const words = `${child.stdout ?? ''}\n${child.stderr ?? ''}`.trim();
|
|
94
|
+
if (!child.error && child.status === 0 && /^logged in\b/im.test(words)) {
|
|
95
|
+
result = { signedIn: true, billing: /api.?key/i.test(words) ? 'api' : /chatgpt/i.test(words) ? 'subscription' : null, subscriptionType: null };
|
|
96
|
+
} else if (!child.error && child.status === 1 && /^not logged in\b/im.test(words)) {
|
|
97
|
+
result = { ...UNKNOWN, signedIn: false };
|
|
98
|
+
}
|
|
99
|
+
} catch { /* Unknown, never a fabricated sign-out. */ }
|
|
100
|
+
return retainAuth(cachePath, bin, now, result);
|
|
61
101
|
}
|
|
@@ -123,7 +123,7 @@ export const CLAUDE_RUNTIME = {
|
|
|
123
123
|
* strongest form of it available anywhere: `--append-system-prompt` sits
|
|
124
124
|
* above the conversation rather than inside it.
|
|
125
125
|
*/
|
|
126
|
-
args({ writableDirs = [], prompt, system, model, effort, resume, perm: postureList, profile = 'build', mcp = [], resultSchemaArgs = [], adoptResumeId, resumeThreadId, knowledgeDir, vaultDir, agentTools, personalSettings, machineMcp, baseMcp = [], mcpCheckout = null, streamInput = false, orchestration = false, readOnlyCoordination = false, claudeVersion = null }) {
|
|
126
|
+
args({ managedBrowser = false, writableDirs = [], prompt, system, model, effort, resume, perm: postureList, profile = 'build', mcp = [], resultSchemaArgs = [], adoptResumeId, resumeThreadId, knowledgeDir, vaultDir, agentTools, personalSettings, machineMcp, baseMcp = [], mcpCheckout = null, streamInput = false, orchestration = false, readOnlyCoordination = false, claudeVersion = null }) {
|
|
127
127
|
// THE CARTOGRAPHER'S FENCE NAMES ITS VAULT (2026-10-10): its one write rule
|
|
128
128
|
// is the vault's absolute path (claudePosture.mjs `wikiPermFor`), so a turn
|
|
129
129
|
// with no vault, or one no rule can name, has no list — refused here in
|
|
@@ -135,7 +135,7 @@ export const CLAUDE_RUNTIME = {
|
|
|
135
135
|
if (profile === 'wiki' && !Array.isArray(postureList)) {
|
|
136
136
|
throw new Error(`a wiki turn's vault must be a path a Claude Code permission rule can name — ${JSON.stringify(vaultDir)} is not`);
|
|
137
137
|
}
|
|
138
|
-
const a = [];
|
|
138
|
+
const a = managedBrowser ? ['--no-chrome'] : [];
|
|
139
139
|
// THREE ANSWERS TO ONE QUESTION — "what conversation is this?" — and they
|
|
140
140
|
// are mutually exclusive, never combined.
|
|
141
141
|
//
|
package/bin/lib/runtimeCodex.mjs
CHANGED
|
@@ -66,7 +66,8 @@ function codexMcp(token, mcpUrl) {
|
|
|
66
66
|
* browser, `computer_use` and `apps` switched ON; a read-only consult, a
|
|
67
67
|
* plan, a capture chat, a wiki or an image turn asked for none of them, and
|
|
68
68
|
* a browser or the desktop is a way around a filesystem fence. A build turn
|
|
69
|
-
* keeps the person's own config
|
|
69
|
+
* keeps the person's own config, except that a mounted managed browser
|
|
70
|
+
* replaces native browser/computer tools on that code turn.
|
|
70
71
|
*/
|
|
71
72
|
export const NO_REACH_OUT = Object.freeze([
|
|
72
73
|
'-c', 'features.browser_use=false',
|
|
@@ -280,7 +281,7 @@ export const CODEX_RUNTIME = {
|
|
|
280
281
|
* limit even when its argv plus environment is far below ARG_MAX. Both exec
|
|
281
282
|
* and exec resume support `-` (verified on Codex 0.160.0, 2026-10-16).
|
|
282
283
|
*/
|
|
283
|
-
args({ streamInput = false, writableDirs = [], prompt, system, model, effort, resume, resumeThreadId, profile = 'build', vaultDir, cwd, mcp = [], resultSchemaArgs = [], adoptResumeId, agentTools, personal = null, researchIsolation, knowledgeDir, projectReads, cardTurn = false, verifiedWorktree = false, codexVersion, orchestration = false, readOnlyCoordination = false }) {
|
|
284
|
+
args({ managedBrowser = false, streamInput = false, writableDirs = [], prompt, system, model, effort, resume, resumeThreadId, profile = 'build', vaultDir, cwd, mcp = [], resultSchemaArgs = [], adoptResumeId, agentTools, personal = null, researchIsolation, knowledgeDir, projectReads, cardTurn = false, verifiedWorktree = false, codexVersion, orchestration = false, readOnlyCoordination = false }) {
|
|
284
285
|
// Adoption resumes a conversation in ITS OWN CLI's store (claude forks,
|
|
285
286
|
// agy moves) — codex has no adoptable store wired yet. Reaching here
|
|
286
287
|
// with an adopt id is a wiring mistake upstream, and it fails loudly on
|
|
@@ -348,6 +349,9 @@ export const CODEX_RUNTIME = {
|
|
|
348
349
|
*/
|
|
349
350
|
if (a.includes('--ignore-user-config')) a.push(...(personal?.args ?? []));
|
|
350
351
|
|
|
352
|
+
if (managedBrowser && profile === 'build') {
|
|
353
|
+
for (const feature of ['browser_use', 'browser_use_external', 'in_app_browser', 'computer_use']) a.push('-c', `features.${feature}=false`);
|
|
354
|
+
}
|
|
351
355
|
a.push(...mcp, ...resultSchemaArgs);
|
|
352
356
|
if (gated) a.push(...codexOrchestratorArgs(codexVersion));
|
|
353
357
|
a.push(streamInput ? '-' : codexPrompt({ system, prompt, profile }));
|
package/bin/lib/vault.mjs
CHANGED
|
@@ -29,6 +29,7 @@ import { execFileSync } from 'node:child_process';
|
|
|
29
29
|
import { join, relative, sep } from 'node:path';
|
|
30
30
|
import { gitRaw } from './git.mjs';
|
|
31
31
|
import { planVaultSync } from './vaultDiff.mjs';
|
|
32
|
+
import { realDirState } from './ownedDirs.mjs';
|
|
32
33
|
|
|
33
34
|
const SYNC_STATE = '.flowviant-sync.json';
|
|
34
35
|
|
|
@@ -96,6 +97,7 @@ export function vaultConfigForeignKey(dir) {
|
|
|
96
97
|
* of its own, which `git init` copies in (measured, git 2.55) — and which the
|
|
97
98
|
* check above would then refuse on every sync. A vault needs none of it. */
|
|
98
99
|
export function ensureVault(dir) {
|
|
100
|
+
if (realDirState(dir) === 'other') throw new Error('wiki vault must be a real directory');
|
|
99
101
|
mkdirSync(dir, { recursive: true });
|
|
100
102
|
if (!existsSync(join(dir, '.git'))) {
|
|
101
103
|
try {
|
package/bin/lib/vaultDiff.mjs
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import { createHash } from 'node:crypto';
|
|
21
|
+
import { protectedWikiPage } from './wikiOwnership.mjs';
|
|
21
22
|
|
|
22
23
|
// Mirror the server contract (shared schema) — a page that violates it is
|
|
23
24
|
// skipped with a warning (and carried forward if previously synced), never
|
|
@@ -44,6 +45,18 @@ export const isSafePath = (p) =>
|
|
|
44
45
|
|
|
45
46
|
const SKIPPED = Object.freeze({ pages: 0, uploaded: 0, deleted: 0, skipped: true });
|
|
46
47
|
|
|
48
|
+
/** Draft publication uses exactly the uplink's page/count limits. Keep the
|
|
49
|
+
* constants here so pre-publication validation cannot drift from sync. */
|
|
50
|
+
export function assertWikiDraftFits(changes, beforeCount, resultingCount) {
|
|
51
|
+
for (const [path, content] of changes) {
|
|
52
|
+
if (!isSafePath(path) || (content !== null && Buffer.byteLength(content) > MAX_FILE_BYTES)) {
|
|
53
|
+
throw new Error(`wiki draft page exceeds sync contract: ${path}`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (resultingCount === 0 && beforeCount > 0) throw new Error('wiki draft would empty the vault');
|
|
57
|
+
if (resultingCount > MAX_FILES && resultingCount > beforeCount) throw new Error('wiki draft exceeds page ceiling');
|
|
58
|
+
}
|
|
59
|
+
|
|
47
60
|
/**
|
|
48
61
|
* Plan one sync.
|
|
49
62
|
*
|
|
@@ -97,6 +110,10 @@ export function planVaultSync({ found, walkErrors = 0, prev = {}, read, scrub =
|
|
|
97
110
|
carry(p, 'unreadable');
|
|
98
111
|
continue;
|
|
99
112
|
}
|
|
113
|
+
if (protectedWikiPage(p, text)) {
|
|
114
|
+
carry(p, 'human/reference source, outside generated output');
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
100
117
|
// Uplink scrub: the cartographer quotes real repo files, and a repo file
|
|
101
118
|
// can contain a synced secret — redact known values before upload. The
|
|
102
119
|
// hash is computed on the SCRUBBED text so the diff state stays coherent.
|
|
@@ -122,6 +139,11 @@ export function planVaultSync({ found, walkErrors = 0, prev = {}, read, scrub =
|
|
|
122
139
|
if (!(p in current)) current[p] = prev[p];
|
|
123
140
|
}
|
|
124
141
|
}
|
|
142
|
+
// A generated-output sync has no authority to delete sources, including
|
|
143
|
+
// reserved legacy pages that are no longer present in this local snapshot.
|
|
144
|
+
for (const p of Object.keys(prev)) {
|
|
145
|
+
if (!(p in current) && protectedWikiPage(p)) current[p] = prev[p];
|
|
146
|
+
}
|
|
125
147
|
|
|
126
148
|
// A readable vault that suddenly presents ZERO pages while the server holds
|
|
127
149
|
// many is almost always a broken/moved dir, not an intentional wipe — refuse
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/** A cartographer edits a disposable draft, never the persistent wiki or the
|
|
2
|
+
* Knowledge store. Publish only a complete turn; failures leave last-good bytes.
|
|
3
|
+
* Portable page metadata is an assertion by the CLI, not a sync-time attestation. */
|
|
4
|
+
import { lstatSync, readdirSync, readFileSync, writeFileSync, mkdtempSync, rmSync, renameSync } from 'node:fs';
|
|
5
|
+
import { randomUUID } from 'node:crypto';
|
|
6
|
+
import { join, dirname, basename } from 'node:path';
|
|
7
|
+
import { ensureRealSubdir } from './ownedDirs.mjs';
|
|
8
|
+
import { isSafePath, assertWikiDraftFits } from './vaultDiff.mjs';
|
|
9
|
+
|
|
10
|
+
import { wikiScalars as scalars, protectedWikiPage } from './wikiOwnership.mjs';
|
|
11
|
+
|
|
12
|
+
/** Never follow links into an imported vault. A partial read cannot be a draft
|
|
13
|
+
* basis: fail before the CLI starts rather than infer missing pages as deleted. */
|
|
14
|
+
function pages(dir, prefix = '', out = new Map(), rejectLinks = false) {
|
|
15
|
+
if (!lstatSync(dir).isDirectory()) throw new Error('wiki directory must be a real directory');
|
|
16
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
17
|
+
if (entry.name.startsWith('.')) continue;
|
|
18
|
+
const path = prefix + entry.name;
|
|
19
|
+
if (entry.isSymbolicLink()) {
|
|
20
|
+
if (rejectLinks) throw new Error(`wiki draft contains a symlink: ${path}`);
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
if (entry.isDirectory()) pages(join(dir, entry.name), `${path}/`, out, rejectLinks);
|
|
24
|
+
else if (entry.isFile() && entry.name.endsWith('.md')) out.set(path, readFileSync(join(dir, entry.name), 'utf8'));
|
|
25
|
+
}
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function put(dir, path, text) {
|
|
30
|
+
if (!isSafePath(path)) throw new Error(`unsafe wiki page: ${path}`);
|
|
31
|
+
const parent = dirname(path);
|
|
32
|
+
if (parent !== '.' && !ensureRealSubdir(dir, parent)) throw new Error(`wiki parent is not a real directory: ${parent}`);
|
|
33
|
+
try {
|
|
34
|
+
if (!lstatSync(join(dir, path)).isFile()) throw new Error(`wiki destination is not a file: ${path}`);
|
|
35
|
+
} catch (e) {
|
|
36
|
+
if (e.code !== 'ENOENT') throw e;
|
|
37
|
+
}
|
|
38
|
+
const target = join(dir, path);
|
|
39
|
+
const temporary = join(dirname(target), `.${basename(target)}.${randomUUID()}`);
|
|
40
|
+
try {
|
|
41
|
+
writeFileSync(temporary, text, { flag: 'wx' });
|
|
42
|
+
renameSync(temporary, target);
|
|
43
|
+
} finally {
|
|
44
|
+
rmSync(temporary, { force: true });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function beginWikiDraft(vaultDir) {
|
|
49
|
+
if (lstatSync(vaultDir).isSymbolicLink()) throw new Error('wiki vault must not be a symlink');
|
|
50
|
+
const before = pages(vaultDir);
|
|
51
|
+
const dir = mkdtempSync(join(dirname(vaultDir), '.wiki-draft-'));
|
|
52
|
+
try {
|
|
53
|
+
for (const [path, text] of before) put(dir, path, text);
|
|
54
|
+
} catch (e) {
|
|
55
|
+
rmSync(dir, { recursive: true, force: true });
|
|
56
|
+
throw e;
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
dir,
|
|
60
|
+
discard: () => rmSync(dir, { recursive: true, force: true }),
|
|
61
|
+
publish({ sha, repoFullName }) {
|
|
62
|
+
const candidate = pages(dir, '', new Map(), true);
|
|
63
|
+
const changes = new Map();
|
|
64
|
+
for (const [path, text] of candidate) {
|
|
65
|
+
const old = before.get(path);
|
|
66
|
+
if (old === text || protectedWikiPage(path, old ?? text)) continue;
|
|
67
|
+
const meta = scalars(text);
|
|
68
|
+
// Changed prose carrying a previous commit must not masquerade as
|
|
69
|
+
// verified. Preserve other frontmatter bytes and unknown custom fields.
|
|
70
|
+
const verified = meta('wiki-verified-at');
|
|
71
|
+
let content = text;
|
|
72
|
+
if (verified && (verified.toLowerCase() !== sha?.toLowerCase() || !/^[a-f0-9]{40}$/i.test(verified) ||
|
|
73
|
+
meta('wiki-origin') !== 'generated' || meta('wiki-repo') !== (repoFullName ?? ''))) {
|
|
74
|
+
content = text.replace(/^(---\r?\n[\s\S]*?\r?\n)---/, (fm) => fm.replace(/^wiki-verified-at:.*\r?\n/gm, ''));
|
|
75
|
+
}
|
|
76
|
+
changes.set(path, content);
|
|
77
|
+
}
|
|
78
|
+
for (const [path, text] of before) {
|
|
79
|
+
if (!candidate.has(path) && !protectedWikiPage(path, text)) changes.set(path, null);
|
|
80
|
+
}
|
|
81
|
+
const resulting = new Set(before.keys());
|
|
82
|
+
for (const [p, text] of changes) text === null ? resulting.delete(p) : resulting.add(p);
|
|
83
|
+
assertWikiDraftFits(changes, before.size, resulting.size);
|
|
84
|
+
// Human edits made during the turn win. Validate the complete read set
|
|
85
|
+
// before any write, including deletions and untouched source pages.
|
|
86
|
+
const current = pages(vaultDir);
|
|
87
|
+
if (current.size !== before.size || [...before].some(([p, text]) => current.get(p) !== text)) throw new Error('wiki changed during generation; draft not published');
|
|
88
|
+
const applied = [];
|
|
89
|
+
try {
|
|
90
|
+
for (const [p, text] of changes) {
|
|
91
|
+
if (text === null) rmSync(join(vaultDir, p));
|
|
92
|
+
else put(vaultDir, p, text);
|
|
93
|
+
applied.push(p);
|
|
94
|
+
}
|
|
95
|
+
} catch (e) {
|
|
96
|
+
for (const p of applied.reverse()) {
|
|
97
|
+
if (before.has(p)) put(vaultDir, p, before.get(p));
|
|
98
|
+
else rmSync(join(vaultDir, p), { force: true });
|
|
99
|
+
}
|
|
100
|
+
throw e;
|
|
101
|
+
}
|
|
102
|
+
return changes.size;
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Generated wiki ownership; never grants writes to imported context. */
|
|
2
|
+
export const wikiScalars = (text) => {
|
|
3
|
+
const fm = text.replace(/\r\n/g, '\n').match(/^---\n([\s\S]*?)\n---/);
|
|
4
|
+
return (key) => {
|
|
5
|
+
const lines = (fm?.[1] ?? '').split('\n').filter((line) => line.startsWith(`${key}:`));
|
|
6
|
+
return lines.length === 1 ? lines[0].slice(key.length + 1).trim().replace(/^["']|["']$/g, '') : '';
|
|
7
|
+
};
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/** Reserved namespaces are never generated output, even for legacy pages.
|
|
11
|
+
* Legacy decisions are human-owned unless explicitly marked generated. */
|
|
12
|
+
export function protectedWikiPage(path, text = '') {
|
|
13
|
+
const p = path.toLowerCase();
|
|
14
|
+
const origin = wikiScalars(text)('wiki-origin');
|
|
15
|
+
return p.split('/').at(-1) === 'instructions.md' ||
|
|
16
|
+
/^(imports|references|sources)\//.test(p) ||
|
|
17
|
+
(p.startsWith('decisions/') && origin !== 'generated') ||
|
|
18
|
+
(origin !== '' && origin !== 'generated');
|
|
19
|
+
}
|
package/bin/lib/wikiRunner.mjs
CHANGED
|
@@ -30,6 +30,7 @@ import { sawSentinel } from './claude.mjs';
|
|
|
30
30
|
import { runTurn as defaultRunTurn } from './runTurn.mjs';
|
|
31
31
|
import { SYSTEM_WIKI, WIKI_KICKOFF, SYSTEM_REGROUND, REGROUND_KICKOFF } from './prompts.mjs';
|
|
32
32
|
import { ensureVault, syncVault } from './vault.mjs';
|
|
33
|
+
import { beginWikiDraft } from './wikiDraft.mjs';
|
|
33
34
|
import { scrub as envScrub } from './uplinkScrub.mjs';
|
|
34
35
|
import { runtimeLabel } from './runtimes.mjs';
|
|
35
36
|
import { pickRuntimeFor as defaultPickRuntimeFor } from './runtimeDetection.mjs';
|
|
@@ -238,7 +239,8 @@ export function createWikiRunner({
|
|
|
238
239
|
// always taken (audit 2026-10-10, F749).
|
|
239
240
|
if (!SHA1_ABBREV_RE.test(String(sha).toLowerCase())) continue;
|
|
240
241
|
try {
|
|
241
|
-
|
|
242
|
+
// Report both sides of a rename so old files: citations are affected.
|
|
243
|
+
const out = gitRaw(['show', '--no-renames', '--name-only', '--pretty=format:', String(sha)], repoRoot);
|
|
242
244
|
for (const line of out.split('\n')) {
|
|
243
245
|
const f = line.trim();
|
|
244
246
|
if (f) files.add(f);
|
|
@@ -374,6 +376,7 @@ export function createWikiRunner({
|
|
|
374
376
|
* readout. Null (and absent from the frame) when no CLI ran.
|
|
375
377
|
*/
|
|
376
378
|
let usage = null;
|
|
379
|
+
let draft = null;
|
|
377
380
|
const onUsage = (rt) => (u) => {
|
|
378
381
|
usage = addUsage(usage, { ...u, runtime: rt });
|
|
379
382
|
};
|
|
@@ -394,20 +397,23 @@ export function createWikiRunner({
|
|
|
394
397
|
resetWorktree(wikiWt, getBaseRef());
|
|
395
398
|
// No HEAD → '' — still writes the map, just ungrounded.
|
|
396
399
|
const sha = gitOrNull(['rev-parse', 'HEAD'], wikiWt) ?? '';
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
+
const repoFullName = originSlug(repoRoot) || undefined;
|
|
401
|
+
const persistentVaultDir = vaultDir;
|
|
402
|
+
draft = beginWikiDraft(persistentVaultDir);
|
|
403
|
+
vaultDir = draft.dir;
|
|
404
|
+
// Only a complete draft can replace last-good pages or prune them.
|
|
405
|
+
// The CLI's write fence names the draft, never Knowledge or sources.
|
|
400
406
|
const runSync = async (finalize) => {
|
|
401
407
|
try {
|
|
402
408
|
const r = await syncVault({
|
|
403
|
-
dir:
|
|
409
|
+
dir: persistentVaultDir,
|
|
404
410
|
url: WIKI_VAULT_URL,
|
|
405
411
|
token: FLEET_TOKEN,
|
|
406
412
|
userAgent: USER_AGENT,
|
|
407
413
|
finalize,
|
|
408
414
|
groundedAtSha: sha || undefined,
|
|
409
415
|
// Powers the GitHub blob links behind every cited file path.
|
|
410
|
-
repoFullName
|
|
416
|
+
repoFullName,
|
|
411
417
|
warn,
|
|
412
418
|
// Redact synced secrets a page may have quoted from the repo.
|
|
413
419
|
scrub: envScrub,
|
|
@@ -435,7 +441,7 @@ export function createWikiRunner({
|
|
|
435
441
|
sweepAttempts++;
|
|
436
442
|
note(`${c.cyan('wiki')} ${c.dim(`— regenerating: your ${wikiLabel} is reading the repo…`)}`);
|
|
437
443
|
const out = await runTurn({
|
|
438
|
-
prompt: WIKI_KICKOFF(sha, vaultDir),
|
|
444
|
+
prompt: WIKI_KICKOFF(sha, vaultDir, repoFullName),
|
|
439
445
|
resume: false,
|
|
440
446
|
system: SYSTEM_WIKI(vaultDir),
|
|
441
447
|
cwd: wikiWt,
|
|
@@ -454,12 +460,13 @@ export function createWikiRunner({
|
|
|
454
460
|
});
|
|
455
461
|
const complete = sawSentinel(out, 'WIKI_DONE');
|
|
456
462
|
if (complete) {
|
|
463
|
+
draft.publish({ sha, repoFullName });
|
|
457
464
|
sweepCompleted = true;
|
|
458
465
|
ok(`${c.cyan('wiki')} ${c.dim('— vault regenerated from your code.')}`);
|
|
459
466
|
} else {
|
|
460
|
-
warn('wiki sweep ended without WIKI_DONE —
|
|
467
|
+
warn('wiki sweep ended without WIKI_DONE — last good pages retained');
|
|
461
468
|
}
|
|
462
|
-
await runSync(
|
|
469
|
+
if (complete) await runSync(true);
|
|
463
470
|
} else {
|
|
464
471
|
const files = changedFilesForShas(task.shas);
|
|
465
472
|
if (files === null) {
|
|
@@ -484,6 +491,7 @@ export function createWikiRunner({
|
|
|
484
491
|
title: task.title,
|
|
485
492
|
files,
|
|
486
493
|
vaultDir,
|
|
494
|
+
repoFullName,
|
|
487
495
|
predictedPages: task.dirtiesPages ?? [],
|
|
488
496
|
}),
|
|
489
497
|
resume: false,
|
|
@@ -499,10 +507,11 @@ export function createWikiRunner({
|
|
|
499
507
|
wikiChild = ch;
|
|
500
508
|
},
|
|
501
509
|
});
|
|
502
|
-
if (sawSentinel(out, 'REGROUND_DONE'))
|
|
510
|
+
if (sawSentinel(out, 'REGROUND_DONE')) {
|
|
511
|
+
draft.publish({ sha, repoFullName });
|
|
503
512
|
ok(`${c.cyan('wiki')} ${c.dim(`— vault updated for "${task.title}".`)}`);
|
|
504
|
-
|
|
505
|
-
|
|
513
|
+
await runSync(false);
|
|
514
|
+
} else warn(`wiki re-ground for "${task.title}" ended without REGROUND_DONE — last good pages retained.`);
|
|
506
515
|
}
|
|
507
516
|
// Consume the durable job: attempted = done (success or not — the
|
|
508
517
|
// sync is idempotent and a failed turn heals on the next full
|
|
@@ -521,6 +530,7 @@ export function createWikiRunner({
|
|
|
521
530
|
} catch (e) {
|
|
522
531
|
warn(`wiki ${task.type} failed: ${e.message}`);
|
|
523
532
|
} finally {
|
|
533
|
+
draft?.discard();
|
|
524
534
|
wikiChild = null;
|
|
525
535
|
if (heartbeat) clearInterval(heartbeat);
|
|
526
536
|
/**
|
|
@@ -27,13 +27,15 @@ import { postureToolsNote } from './postureTools.mjs';
|
|
|
27
27
|
import { machineMcpServers } from './claudeMachineMcp.mjs';
|
|
28
28
|
import { ORCHESTRATOR_SERVER } from './orchestratorProtocol.mjs';
|
|
29
29
|
import { VIDEO_SERVER } from './videoRender.mjs';
|
|
30
|
+
import { BROWSER_SERVER } from './managedBrowser.mjs';
|
|
30
31
|
|
|
31
32
|
/** The servers the daemon itself mounts on a card turn, by the names the
|
|
32
33
|
* agent calls them: a multi-repository card's add_repository, an
|
|
33
34
|
* orchestrated card's routing, a build's UI review and a video card's
|
|
34
|
-
* render_video (2026-10-26)
|
|
35
|
+
* render_video (2026-10-26), and a code turn's managed browser (2026-10-30).
|
|
36
|
+
* Never a project server's, whatever the base
|
|
35
37
|
* `.mcp.json` names. */
|
|
36
|
-
export const CARD_TURN_SERVERS = Object.freeze({ repositories: 'card_repositories', orchestrator: ORCHESTRATOR_SERVER, uiReview: 'card_ui_review', video: VIDEO_SERVER });
|
|
38
|
+
export const CARD_TURN_SERVERS = Object.freeze({ repositories: 'card_repositories', orchestrator: ORCHESTRATOR_SERVER, uiReview: 'card_ui_review', video: VIDEO_SERVER, browser: BROWSER_SERVER });
|
|
37
39
|
|
|
38
40
|
/**
|
|
39
41
|
* The card turn's tools, prepared, and what it is told of them.
|
|
@@ -46,7 +48,7 @@ export const CARD_TURN_SERVERS = Object.freeze({ repositories: 'card_repositorie
|
|
|
46
48
|
* from here), the note, the mount's part of the CLI's `mcpEnv` and whether it
|
|
47
49
|
* needs one, and the keys the runner takes the mount as.
|
|
48
50
|
*/
|
|
49
|
-
export function prepareCardTools({ rt, posture, wt, repoRoot, baseRef, sources = [], repositoryTool = null, orchestratorConfig = null, uiReviewConfig = null, videoConfig = null, orchestrated = false, codexHome = null, env = process.env, machineServers = machineMcpServers }) {
|
|
51
|
+
export function prepareCardTools({ rt, posture, wt, repoRoot, baseRef, sources = [], repositoryTool = null, orchestratorConfig = null, uiReviewConfig = null, videoConfig = null, browserConfig = null, orchestrated = false, codexHome = null, env = process.env, machineServers = machineMcpServers }) {
|
|
50
52
|
// A turn reads the base ref afresh. Changes the agent has made to its
|
|
51
53
|
// own worktree cannot silently change the next turn's tools.
|
|
52
54
|
const source = sources.find((r) => r.wt === wt);
|
|
@@ -59,6 +61,7 @@ export function prepareCardTools({ rt, posture, wt, repoRoot, baseRef, sources =
|
|
|
59
61
|
...(repositoryTool ? { [CARD_TURN_SERVERS.repositories]: repositoryTool.config } : {}),
|
|
60
62
|
...(orchestratorConfig ? { [CARD_TURN_SERVERS.orchestrator]: orchestratorConfig } : {}),
|
|
61
63
|
...(posture === 'build' && uiReviewConfig ? { [CARD_TURN_SERVERS.uiReview]: uiReviewConfig } : {}),
|
|
64
|
+
...(posture === 'build' && browserConfig ? { [CARD_TURN_SERVERS.browser]: browserConfig } : {}),
|
|
62
65
|
// A video card's render tool rides its fenced posture (videoRender.mjs).
|
|
63
66
|
...(videoConfig ? { [CARD_TURN_SERVERS.video]: videoConfig } : {}),
|
|
64
67
|
} });
|
|
@@ -70,6 +70,8 @@ import { measuredRuntimeVersion } from './runtimeDetection.mjs';
|
|
|
70
70
|
import { createIntermediateArtifacts, IntermediateArtifactCapacityError } from './intermediateArtifacts.mjs';
|
|
71
71
|
import { createUiReview } from './uiReview.mjs';
|
|
72
72
|
import { createVideoRender } from './videoRender.mjs';
|
|
73
|
+
import { createManagedBrowser } from './managedBrowser.mjs';
|
|
74
|
+
import { completeBrowserReview, browserHandoffPrompt } from './browserHandoff.mjs';
|
|
73
75
|
import { withUiReview, appendUiReviewEvidence } from './uiReviewTurn.mjs';
|
|
74
76
|
|
|
75
77
|
export function createAgentTurnExecution({
|
|
@@ -102,6 +104,7 @@ export function createAgentTurnExecution({
|
|
|
102
104
|
exportModel,
|
|
103
105
|
makeUiReview = createUiReview,
|
|
104
106
|
makeVideoRender = createVideoRender,
|
|
107
|
+
makeManagedBrowser = createManagedBrowser,
|
|
105
108
|
}) {
|
|
106
109
|
const reportDelivery = createDeliveryReporter();
|
|
107
110
|
/** How long the final flush may hold the settle. Bounded because the settle
|
|
@@ -410,6 +413,8 @@ export function createAgentTurnExecution({
|
|
|
410
413
|
/** The CLI, unless a person already asked to stop this turn (Interrupt
|
|
411
414
|
* now): then nothing starts — no fresh retry, no continuation. */
|
|
412
415
|
let uiReview = null;
|
|
416
|
+
let managedBrowser = null;
|
|
417
|
+
let browserNote = '';
|
|
413
418
|
let uiReviewNote = '';
|
|
414
419
|
let uiReviewEvidence = '';
|
|
415
420
|
// A video card's render tool (videoRender.mjs), and the words a turn
|
|
@@ -424,6 +429,8 @@ export function createAgentTurnExecution({
|
|
|
424
429
|
args.verifiedWorktree = true;
|
|
425
430
|
args = withUiReview(args, uiReviewNote, uiReview?.env);
|
|
426
431
|
args = withUiReview(args, videoNote);
|
|
432
|
+
args = withUiReview(args, browserNote, managedBrowser?.env);
|
|
433
|
+
args.managedBrowser = Boolean(managedBrowser);
|
|
427
434
|
return runTurnImpl(args);
|
|
428
435
|
};
|
|
429
436
|
/**
|
|
@@ -473,6 +480,15 @@ export function createAgentTurnExecution({
|
|
|
473
480
|
let orchestrator = null;
|
|
474
481
|
let intermediateDelivery = null;
|
|
475
482
|
try {
|
|
483
|
+
if (posture === 'build' && job.kind !== 'merge_resolve' && ['claude', 'codex'].includes(rt)) {
|
|
484
|
+
try {
|
|
485
|
+
managedBrowser = await makeManagedBrowser({ wt, turnId, signal: setupController.signal, onChild: ch => {
|
|
486
|
+
workChildren.set(ch, agentId); groupKillChildren?.add(ch);
|
|
487
|
+
return () => { workChildren.delete(ch); groupKillChildren?.delete(ch); };
|
|
488
|
+
} });
|
|
489
|
+
browserNote = managedBrowser.instructions;
|
|
490
|
+
} catch (error) { browserNote = `The managed browser could not start: ${envScrub(error.message)}. Report interactive browser checks unavailable.`; }
|
|
491
|
+
}
|
|
476
492
|
if (posture === 'build' && job.kind !== 'merge_resolve') {
|
|
477
493
|
try {
|
|
478
494
|
uiReview = await makeUiReview({ wt, signal: setupController.signal, onChild: ch => {
|
|
@@ -499,10 +515,11 @@ export function createAgentTurnExecution({
|
|
|
499
515
|
notify: message => liveInput ? run.live?.inject(message) === true : false,
|
|
500
516
|
taken: id => run.live?.onTaken(id) });
|
|
501
517
|
}
|
|
502
|
-
const tools = prepareCardTools({ rt, posture, wt, repoRoot, baseRef, sources: repositoryRun?.sources ?? [], repositoryTool: repositoryRun?.tool ?? null, orchestratorConfig: orchestrator?.config ?? null, uiReviewConfig: uiReview?.config, videoConfig: videoRender?.config ?? null, orchestrated: Boolean(orchestrator), codexHome: agentResume.codexHome });
|
|
518
|
+
const tools = prepareCardTools({ rt, posture, wt, repoRoot, baseRef, sources: repositoryRun?.sources ?? [], repositoryTool: repositoryRun?.tool ?? null, orchestratorConfig: orchestrator?.config ?? null, uiReviewConfig: uiReview?.config, videoConfig: videoRender?.config ?? null, browserConfig: managedBrowser?.config, orchestrated: Boolean(orchestrator), codexHome: agentResume.codexHome });
|
|
503
519
|
projectTools = tools.projectTools;
|
|
504
520
|
localCleanup?.begin(turnId);
|
|
505
521
|
const { toolsNote } = tools;
|
|
522
|
+
let browserThread = agentResume.resumeThreadId;
|
|
506
523
|
const agentTurnArgs = {
|
|
507
524
|
...((liveInput || orchestrator) ? { liveMessages: (run.live = makeLiveMessages({
|
|
508
525
|
scope: 'agent', turnId, cwd: wt, sessionMetaPath, interrupted: () => run.interrupted,
|
|
@@ -550,12 +567,14 @@ export function createAgentTurnExecution({
|
|
|
550
567
|
resume: agentResume.resume,
|
|
551
568
|
resumeThreadId: agentResume.resumeThreadId,
|
|
552
569
|
onInit: (init) => {
|
|
570
|
+
if (rt === 'claude' && typeof init.sessionId === 'string') browserThread = init.sessionId;
|
|
553
571
|
orchestrator?.root(init.sessionId);
|
|
554
572
|
// A server that did not connect, as the CLI said (unknown: nothing).
|
|
555
573
|
trace.mcpStatus(init.mcpServers);
|
|
556
574
|
agentResume.sawSession(init.sessionId);
|
|
557
575
|
},
|
|
558
576
|
onThreadId: (id) => {
|
|
577
|
+
browserThread = id;
|
|
559
578
|
orchestrator?.root(id);
|
|
560
579
|
agentResume.sawThread(id);
|
|
561
580
|
},
|
|
@@ -726,6 +745,7 @@ export function createAgentTurnExecution({
|
|
|
726
745
|
runtime: rt,
|
|
727
746
|
beforeFresh: () => {
|
|
728
747
|
agentResume.forgetThread();
|
|
748
|
+
browserThread = undefined;
|
|
729
749
|
lastAnswer = null;
|
|
730
750
|
model = null;
|
|
731
751
|
},
|
|
@@ -736,19 +756,34 @@ export function createAgentTurnExecution({
|
|
|
736
756
|
void postAgentActivity(agentId, 'resuming after an internal wait');
|
|
737
757
|
} }
|
|
738
758
|
);
|
|
739
|
-
out = await
|
|
740
|
-
|
|
741
|
-
resume: async (
|
|
759
|
+
out = await completeBrowserReview(out, {
|
|
760
|
+
browser: managedBrowser, signal: setupController.signal,
|
|
761
|
+
resume: async (handoff) => {
|
|
762
|
+
const thread = agentResume.threadNow() ?? browserThread;
|
|
763
|
+
if (!thread) throw new Error('The browser handoff could not resume: the CLI reported no conversation id. Interactive verification is incomplete.');
|
|
742
764
|
lastAnswer = null;
|
|
743
765
|
const counted = usage;
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
writableDirs: repositoryRun.sourcesNow().filter(
|
|
748
|
-
|
|
749
|
-
resumeThreadId: agentResume.threadNow() ?? agentTurnArgs.resumeThreadId,
|
|
766
|
+
trace.prose('text', handoff.reason === 'returned' ? 'Browser control returned. Resuming verification.' : 'The browser handoff ended. Resuming to report incomplete interactive checks.');
|
|
767
|
+
return runAgentWithInternalWaits(args => runCli(args), {
|
|
768
|
+
...agentTurnArgs, prompt: browserHandoffPrompt(handoff), resume: true, resumeThreadId: thread, liveContinuation: true,
|
|
769
|
+
...(repositoryRun ? { writableDirs: repositoryRun.sourcesNow().filter(r => r.wt !== wt).map(r => r.wt) } : {}),
|
|
770
|
+
onUsage: next => agentTurnArgs.onUsage(addUsage(counted, next)),
|
|
750
771
|
});
|
|
751
772
|
},
|
|
773
|
+
finish: current => finishRepositoryDelivery({
|
|
774
|
+
job, run: repositoryRun, out: current, answer: () => lastAnswer,
|
|
775
|
+
resume: async (prompt) => {
|
|
776
|
+
lastAnswer = null;
|
|
777
|
+
const counted = usage;
|
|
778
|
+
return runCli({
|
|
779
|
+
...agentTurnArgs, prompt, liveContinuation: true,
|
|
780
|
+
onUsage: (next) => agentTurnArgs.onUsage(addUsage(counted, next)),
|
|
781
|
+
writableDirs: repositoryRun.sourcesNow().filter((r) => r.wt !== wt).map((r) => r.wt),
|
|
782
|
+
resume: true,
|
|
783
|
+
resumeThreadId: agentResume.threadNow() ?? browserThread,
|
|
784
|
+
});
|
|
785
|
+
},
|
|
786
|
+
}),
|
|
752
787
|
});
|
|
753
788
|
} finally {
|
|
754
789
|
// The loopback capability stays registered until every retained CLI
|
|
@@ -759,6 +794,7 @@ export function createAgentTurnExecution({
|
|
|
759
794
|
// owned by its review tool, then bind evidence to the final source
|
|
760
795
|
// before the existing artifact upload and delivery settlement.
|
|
761
796
|
try { await videoRender?.close(); } catch { /* a closed listener */ }
|
|
797
|
+
try { await managedBrowser?.close(); } catch { /* turn teardown continues */ }
|
|
762
798
|
try { await uiReview?.close(); }
|
|
763
799
|
finally {
|
|
764
800
|
try { uiReviewEvidence = uiReview?.finalize() ?? uiReviewEvidence; }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flowviant",
|
|
3
|
-
"version": "0.125.
|
|
3
|
+
"version": "0.125.29",
|
|
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": {
|