flowviant 0.124.0 → 0.125.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +6 -0
  2. package/bin/cli.mjs +7 -0
  3. package/bin/lib/agentInternalWait.mjs +2 -1
  4. package/bin/lib/agentPlan.mjs +9 -0
  5. package/bin/lib/agentRepositories.mjs +48 -0
  6. package/bin/lib/agentTaskKinds.mjs +2 -0
  7. package/bin/lib/checkoutIdentity.mjs +25 -0
  8. package/bin/lib/claudeInput.mjs +32 -0
  9. package/bin/lib/claudeSessions.mjs +20 -0
  10. package/bin/lib/credentials.mjs +52 -17
  11. package/bin/lib/daemonLocks.mjs +21 -3
  12. package/bin/lib/fleet.mjs +19 -6
  13. package/bin/lib/fleetRoster.mjs +1 -89
  14. package/bin/lib/fleetStartup.mjs +11 -14
  15. package/bin/lib/holder.mjs +4 -37
  16. package/bin/lib/instance.mjs +20 -7
  17. package/bin/lib/landed.mjs +2 -2
  18. package/bin/lib/login.mjs +39 -4
  19. package/bin/lib/mergeBatch.mjs +172 -0
  20. package/bin/lib/placeLock.mjs +9 -7
  21. package/bin/lib/prWorkflow.mjs +15 -0
  22. package/bin/lib/projectLanded.mjs +16 -0
  23. package/bin/lib/projectRepositories.mjs +60 -0
  24. package/bin/lib/projectTools.mjs +2 -1
  25. package/bin/lib/projectWorktreeHome.mjs +25 -0
  26. package/bin/lib/prompts.mjs +128 -48
  27. package/bin/lib/repositoryDelivery.mjs +64 -0
  28. package/bin/lib/repositoryMerge.mjs +116 -0
  29. package/bin/lib/repositoryPlaces.mjs +179 -0
  30. package/bin/lib/repositorySetup.mjs +58 -0
  31. package/bin/lib/repositoryTool.mjs +71 -0
  32. package/bin/lib/runTurn.mjs +23 -2
  33. package/bin/lib/runtimeAntigravity.mjs +2 -1
  34. package/bin/lib/runtimeClaude.mjs +5 -2
  35. package/bin/lib/runtimeCodex.mjs +2 -1
  36. package/bin/lib/shipMerge.mjs +5 -2
  37. package/bin/lib/startProject.mjs +4 -4
  38. package/bin/lib/trace.mjs +8 -3
  39. package/bin/lib/turnDeclaration.mjs +88 -0
  40. package/bin/lib/turnLock.mjs +48 -8
  41. package/bin/lib/work.mjs +23 -24
  42. package/bin/lib/workAgentCheck.mjs +6 -5
  43. package/bin/lib/workAgentMerges.mjs +24 -4
  44. package/bin/lib/workAgentPrecheck.mjs +5 -5
  45. package/bin/lib/workAgentPublish.mjs +18 -5
  46. package/bin/lib/workAgentRecovery.mjs +85 -0
  47. package/bin/lib/workAgentReview.mjs +5 -3
  48. package/bin/lib/workAgentTurnBegun.mjs +6 -4
  49. package/bin/lib/workAgentTurnExecution.mjs +79 -9
  50. package/bin/lib/workAgentTurnOutcome.mjs +20 -5
  51. package/bin/lib/workAgentTurns.mjs +33 -9
  52. package/bin/lib/workCappedTurn.mjs +6 -4
  53. package/bin/lib/workDelivery.mjs +21 -0
  54. package/bin/lib/workDiffs.mjs +18 -11
  55. package/bin/lib/workLiveMessages.mjs +129 -0
  56. package/bin/lib/workMergeBatch.mjs +104 -0
  57. package/bin/lib/workPlaceReports.mjs +5 -0
  58. package/bin/lib/workPlaces.mjs +6 -1
  59. package/bin/lib/workRetire.mjs +5 -0
  60. package/bin/lib/workSessionTurns.mjs +72 -40
  61. package/bin/lib/workShip.mjs +17 -16
  62. package/package.json +1 -1
  63. package/npm-shrinkwrap.json +0 -69
@@ -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, rootFor: (id) => repositories.list().find((r) => r.id === id)?.repoRoot };
179
+ }
@@ -0,0 +1,58 @@
1
+ /** Fresh multi-repository worktrees each get their own environment and install.
2
+ * A checked-in .flowviant/setup.sh replaces the default dependency install. */
3
+ import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, readdirSync } from 'node:fs';
4
+ import { join, dirname } from 'node:path';
5
+ import { spawn } from 'node:child_process';
6
+ import { killGroup } from './processGroup.mjs';
7
+ import { git } from './git.mjs';
8
+ import { unattendedEnv } from './noWindowEnv.mjs';
9
+ import { workLanesStopped } from './standDownGate.mjs';
10
+
11
+ export async function setupRepository(root, wt, { signal, onSpawn = () => {}, onClose = () => {} } = {}) {
12
+ signal?.throwIfAborted();
13
+ if (workLanesStopped()) throw new Error('The daemon is stopping. Repository setup has not started.');
14
+ const copyEnv = (relative = '') => {
15
+ for (const name of readdirSync(join(root, relative))) {
16
+ if (['.git', 'node_modules', '.flowviant', 'dist', 'target', '.next', '.cache', '.venv'].includes(name)) continue;
17
+ const rel = join(relative, name), src = join(root, rel), dest = join(wt, rel);
18
+ const st = lstatSync(src);
19
+ if (st.isSymbolicLink()) continue;
20
+ if (st.isDirectory()) {
21
+ if (existsSync(dest)) copyEnv(rel);
22
+ } else if (/^\.env(?:\.|$)/.test(name) && !existsSync(dest)) {
23
+ try { git(['check-ignore', '--quiet', '--', rel], wt); } catch { continue; }
24
+ mkdirSync(dirname(dest), { recursive: true });
25
+ copyFileSync(src, dest);
26
+ chmodSync(dest, 0o600);
27
+ }
28
+ }
29
+ };
30
+ copyEnv();
31
+ const command = existsSync(join(wt, '.flowviant/setup.sh')) ? ['sh', '.flowviant/setup.sh'] :
32
+ ['bun.lock', 'bun.lockb'].some((f) => existsSync(join(wt, f))) ? ['bun', 'install', '--frozen-lockfile'] :
33
+ existsSync(join(wt, 'pnpm-lock.yaml')) ? ['pnpm', 'install', '--frozen-lockfile'] :
34
+ existsSync(join(wt, 'package-lock.json')) ? ['npm', 'ci'] :
35
+ existsSync(join(wt, 'yarn.lock')) ? ['yarn', 'install', '--frozen-lockfile'] : null;
36
+ if (!command) return;
37
+ await new Promise((resolve, reject) => {
38
+ const child = spawn(command[0], command.slice(1), { cwd: wt, env: unattendedEnv({ ...process.env, GIT_TERMINAL_PROMPT: '0' }), detached: true, stdio: ['ignore', 'ignore', 'pipe'] });
39
+ const abort = () => killGroup(child);
40
+ signal?.addEventListener('abort', abort, { once: true });
41
+ if (signal?.aborted) abort();
42
+ onSpawn(child);
43
+ let tail = '';
44
+ child.stderr.on('data', (s) => { tail = (tail + s).slice(-2000); });
45
+ const timer = setTimeout(() => { killGroup(child); }, 10 * 60_000);
46
+ child.once('error', (e) => { clearTimeout(timer); reject(e); });
47
+ child.once('close', (code) => {
48
+ clearTimeout(timer); signal?.removeEventListener('abort', abort); onClose(child);
49
+ code === 0 ? resolve() : reject(new Error(`${command.join(' ')} failed in ${wt}: ${tail}`));
50
+ });
51
+ });
52
+ }
53
+
54
+ /** Installs are daemon children too: shutdown must drain their process groups. */
55
+ export const trackedRepositorySetup = (children, groups) => (root, wt, options) => setupRepository(root, wt, {
56
+ ...options, onSpawn: (child) => { children.set(child, null); groups.add(child); },
57
+ onClose: (child) => { children.delete(child); groups.delete(child); },
58
+ });
@@ -0,0 +1,71 @@
1
+ /** A held turn's local capability. The CLI never receives the machine token. */
2
+ import { sameSecret } from './grant.mjs';
3
+ import { createServer } from 'node:http';
4
+ import { randomBytes } from 'node:crypto';
5
+ import { createInterface } from 'node:readline';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { runningCompiledBinary } from './launchCommand.mjs';
8
+
9
+ export async function createRepositoryTool(add) {
10
+ const capability = randomBytes(32).toString('hex');
11
+ let serial = Promise.resolve(), closed = false;
12
+ const server = createServer(async (req, res) => {
13
+ if (closed || req.method !== 'POST' || req.url !== '/' || !sameSecret(req.headers.authorization, `Bearer ${capability}`)) {
14
+ res.writeHead(403).end(); return;
15
+ }
16
+ try {
17
+ let body = '';
18
+ for await (const data of req) { body += data; if (body.length > 1024) throw new Error('Repository request is too large.'); }
19
+ const { name } = JSON.parse(body);
20
+ if (typeof name !== 'string' || !name.length || name.length > 64) throw new Error('Name a project repository.');
21
+ // A current turn already owns its place's writer lock. Serialize its
22
+ // acquisitions here instead of recursively waiting for that same lock.
23
+ const pending = serial.then(() => { if (closed) throw new Error('This turn has ended.'); return add(name); });
24
+ serial = pending.catch(() => {});
25
+ const result = await pending;
26
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }).end(JSON.stringify(result));
27
+ } catch (error) {
28
+ res.writeHead(409, { 'Content-Type': 'application/json' }).end(JSON.stringify({ error: String(error.message).slice(0, 2000) }));
29
+ }
30
+ });
31
+ server.requestTimeout = 15_000;
32
+ await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); });
33
+ const env = { FLOWVIANT_REPOSITORY_URL: `http://127.0.0.1:${server.address().port}/`, FLOWVIANT_REPOSITORY_CAPABILITY: capability };
34
+ const args = runningCompiledBinary() ? ['__repository-mcp'] : [fileURLToPath(new URL('../cli.mjs', import.meta.url)), '__repository-mcp'];
35
+ return { env, config: { command: process.execPath, args, env },
36
+ async close() { closed = true; server.close(); server.closeIdleConnections(); await serial; server.closeAllConnections(); } };
37
+ }
38
+
39
+ async function callRepository(name) {
40
+ const url = new URL(process.env.FLOWVIANT_REPOSITORY_URL);
41
+ if (url.hostname !== '127.0.0.1' || url.protocol !== 'http:') throw new Error('No held card repository tool is available.');
42
+ const res = await fetch(url, { method: 'POST', headers: { Authorization: `Bearer ${process.env.FLOWVIANT_REPOSITORY_CAPABILITY}`, 'Content-Type': 'application/json' },
43
+ body: JSON.stringify({ name }), signal: AbortSignal.timeout(12 * 60_000) });
44
+ const result = await res.json();
45
+ if (!res.ok) throw new Error(result.error ?? 'Repository could not be added.');
46
+ return result;
47
+ }
48
+
49
+ export async function repositoryToolMain(mcp) {
50
+ if (!mcp) {
51
+ try { process.stdout.write(`${JSON.stringify(await callRepository(process.argv[3]))}\n`); }
52
+ catch (error) { process.stderr.write(`${error.message}\n`); process.exitCode = 1; }
53
+ return;
54
+ }
55
+ for await (const line of createInterface({ input: process.stdin })) {
56
+ let request;
57
+ try {
58
+ request = JSON.parse(line);
59
+ if (request.id === undefined) continue;
60
+ let result;
61
+ if (request.method === 'initialize') result = { protocolVersion: request.params?.protocolVersion ?? '2024-11-05', capabilities: { tools: {} }, serverInfo: { name: 'card-repositories', version: '1.0.0' } };
62
+ else if (request.method === 'ping') result = {};
63
+ else if (request.method === 'tools/list') result = { tools: [{ name: 'add_repository', description: 'Add a project repository to the held card and create its sibling worktree on the same session branch. No human confirmation is needed.', inputSchema: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'], additionalProperties: false } }] };
64
+ else if (request.method === 'tools/call' && request.params?.name === 'add_repository') {
65
+ try { result = { content: [{ type: 'text', text: JSON.stringify(await callRepository(request.params.arguments?.name)) }] }; }
66
+ catch (error) { result = { isError: true, content: [{ type: 'text', text: error.message }] }; }
67
+ } else { process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id: request.id, error: { code: -32601, message: 'Unknown method' } })}\n`); continue; }
68
+ process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id: request.id, result })}\n`);
69
+ } catch { if (request?.id !== undefined) process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id: request.id, error: { code: -32602, message: 'Invalid request' } })}\n`); }
70
+ }
71
+ }
@@ -23,6 +23,7 @@ import { initHarvestFor, recordMcpServers, recordSkills } from './runtimeCapabil
23
23
  import { codexTurnModel } from './turnModel.mjs';
24
24
  import { printableText } from './printable.mjs';
25
25
  import { startTurnDiagnostics } from './turnDiagnostics.mjs';
26
+ import { createClaudeInput } from './claudeInput.mjs';
26
27
 
27
28
  /** How long a Codex turn's answer may wait on its rollout's model line. */
28
29
  const CODEX_MODEL_READ_MS = 1_500;
@@ -167,6 +168,8 @@ function runPlainTurn(opts) {
167
168
  // which posture a turn is running under. There is no precedence chain left
168
169
  // to get wrong: the caller named one profile (turnProfile.mjs).
169
170
  const args = rt.args({
171
+ streamInput: rt.id === 'claude' && Boolean(opts.liveMessages),
172
+ writableDirs: opts.writableDirs,
170
173
  personal,
171
174
  researchIsolation: opts.researchIsolation,
172
175
  prompt,
@@ -277,7 +280,7 @@ function runPlainTurn(opts) {
277
280
  cwd, args, profile: turnProfile.name, planId: opts.diagnosticPlanId });
278
281
  child = spawn(rt.bin, args, {
279
282
  cwd,
280
- stdio: ['ignore', 'pipe', 'pipe'],
283
+ stdio: [rt.id === 'claude' && opts.liveMessages ? 'pipe' : 'ignore', 'pipe', 'pipe'],
281
284
  /**
282
285
  * ITS OWN PROCESS GROUP, so what the agent starts stays attributable.
283
286
  *
@@ -324,6 +327,7 @@ function runPlainTurn(opts) {
324
327
  // than this is a previous turn's (turnModel.mjs).
325
328
  const spawnedAt = Date.now();
326
329
  let out = '';
330
+ const input = rt.id === 'claude' && opts.liveMessages ? createClaudeInput(child, { onTaken: opts.liveMessages.onTaken }) : null;
327
331
  const pfx = label ? `${label} ` : '';
328
332
  /**
329
333
  * THE ONE SINK FOR WHAT A CLI OR A MODEL WROTE (2026-10-10, the audit's
@@ -401,6 +405,14 @@ function runPlainTurn(opts) {
401
405
  const onLine = (line) => {
402
406
  try {
403
407
  const event = JSON.parse(line);
408
+ if (input) {
409
+ input.event(event);
410
+ if (event.type === 'system' && event.subtype === 'init' && event.session_id) opts.liveMessages.onInit(event.session_id);
411
+ // A message written just before result may become a second native
412
+ // turn in this process. Return its final result alone, never two
413
+ // structured objects joined together; every result still reports spend.
414
+ if (event.type === 'result') out = '';
415
+ }
404
416
  if ((event.type === 'result' && event.is_error === true) ||
405
417
  event.type === 'error' || event.type === 'turn.failed') {
406
418
  cliFailed = true;
@@ -467,6 +479,8 @@ function runPlainTurn(opts) {
467
479
  * resolves what was captured so far.
468
480
  */
469
481
  const onSpawnError = (e) => {
482
+ input?.close();
483
+ if (opts.liveMessages) void opts.liveMessages.stop();
470
484
  finishDiagnostics({ pid: child.pid, spawnError: typeof e.code === 'string' ? e.code : 'SPAWN_ERROR' });
471
485
  releaseArgs();
472
486
  if (e.code === 'ENOENT') {
@@ -479,11 +493,13 @@ function runPlainTurn(opts) {
479
493
  };
480
494
  child.on('error', onSpawnError);
481
495
  child.on('close', (code, signal) => {
496
+ input?.close();
482
497
  releaseArgs();
483
498
  opts.onExit?.(code, signal);
484
499
  if (buf.trim()) onLine(buf);
485
500
  finishDiagnostics({ code, signal, pid: child.pid, output: out, cliFailed });
486
- const handBack = () => {
501
+ const handBack = async () => {
502
+ if (opts.liveMessages) await opts.liveMessages.stop();
487
503
  resolve(out);
488
504
  // After the answer is handed back, never before it: see `onRate`.
489
505
  if (codexThread) {
@@ -514,5 +530,10 @@ function runPlainTurn(opts) {
514
530
  }
515
531
  handBack();
516
532
  });
533
+ if (input) {
534
+ const initial = opts.liveMessages.initialPrompt(prompt, resumeThreadId, resume, opts.liveContinuation === true);
535
+ input.send(opts.liveMessages.initialId || opts.liveMessages.turnId, initial);
536
+ opts.liveMessages.connect(input.send);
537
+ }
517
538
  });
518
539
  }
@@ -147,7 +147,7 @@ export const ANTIGRAVITY_RUNTIME = {
147
147
  planTurns: false,
148
148
  adopt: 'move',
149
149
  mcp: null,
150
- args({ prompt, system, model, effort, resume, resumeConversationId, profile = 'build', vaultDir, resultSchemaArgs = [], adoptResumeId }) {
150
+ args({ writableDirs = [], prompt, system, model, effort, resume, resumeConversationId, profile = 'build', vaultDir, resultSchemaArgs = [], adoptResumeId }) {
151
151
  const a = [];
152
152
  // WHICH CONVERSATION IS THIS — one decision, answered one way (the same
153
153
  // rule as Claude's --resume/--continue). By id when the caller knows:
@@ -212,6 +212,7 @@ export const ANTIGRAVITY_RUNTIME = {
212
212
  // So a SAFE-mode operator gets an agy build that is not actually
213
213
  // narrowed — which is why `mediatedSafeGap` is surfaced rather than
214
214
  // quietly ignored.
215
+ for (const dir of writableDirs) a.push('--add-dir', dir);
215
216
  a.push('--dangerously-skip-permissions');
216
217
  a.push('--print-timeout', '60m');
217
218
  } else if (profile === 'consult') {
@@ -101,7 +101,7 @@ export const CLAUDE_RUNTIME = {
101
101
  * strongest form of it available anywhere: `--append-system-prompt` sits
102
102
  * above the conversation rather than inside it.
103
103
  */
104
- args({ prompt, system, model, effort, resume, perm, profile = 'build', mcp = [], resultSchemaArgs = [], adoptResumeId, resumeThreadId, knowledgeDir, vaultDir, agentTools, personalSettings, machineMcp }) {
104
+ args({ writableDirs = [], prompt, system, model, effort, resume, perm, profile = 'build', mcp = [], resultSchemaArgs = [], adoptResumeId, resumeThreadId, knowledgeDir, vaultDir, agentTools, personalSettings, machineMcp, streamInput = false }) {
105
105
  // THE CARTOGRAPHER'S FENCE NAMES ITS VAULT (2026-10-10): its one write rule
106
106
  // is the vault's absolute path (claudePosture.mjs `wikiPermFor`), so a turn
107
107
  // with no vault, or one no rule can name, has no list — refused here in
@@ -138,7 +138,9 @@ export const CLAUDE_RUNTIME = {
138
138
  if (adoptResumeId) a.push('--resume', adoptResumeId, '--fork-session');
139
139
  else if (resumeThreadId) a.push('--resume', resumeThreadId);
140
140
  else if (resume) a.push('--continue');
141
- a.push('-p', prompt, '--append-system-prompt', system);
141
+ if (streamInput) a.push('-p', '--input-format', 'stream-json', '--replay-user-messages');
142
+ else a.push('-p', prompt);
143
+ a.push('--append-system-prompt', system);
142
144
  /**
143
145
  * WHOSE SETTINGS A TURN READS (2026-09-29, claudePersonal.mjs).
144
146
  *
@@ -205,6 +207,7 @@ export const CLAUDE_RUNTIME = {
205
207
  // worktree's cwd: named as a readable directory so a curated profile
206
208
  // does not refuse the path the prompt just handed it. Before `perm`,
207
209
  // because `--allowedTools` is variadic and would swallow the flag.
210
+ if (profile === 'build') for (const dir of writableDirs) a.push('--add-dir', dir);
208
211
  if (knowledgeDir) a.push('--add-dir', knowledgeDir);
209
212
  // THE VAULT, likewise (2026-10-10): it lives under ~/.flowviant/vaults,
210
213
  // outside the wiki worktree, and its `mkdir`/`rm` are path-validated to the
@@ -233,7 +233,7 @@ export const CODEX_RUNTIME = {
233
233
  * placed before it. Appending them after the positional is the kind of argv
234
234
  * that parses today and stops parsing on some future clap upgrade.
235
235
  */
236
- args({ prompt, system, model, effort, resume, resumeThreadId, profile = 'build', vaultDir, cwd, mcp = [], resultSchemaArgs = [], adoptResumeId, agentTools, personal = null, researchIsolation, knowledgeDir, projectReads }) {
236
+ args({ writableDirs = [], prompt, system, model, effort, resume, resumeThreadId, profile = 'build', vaultDir, cwd, mcp = [], resultSchemaArgs = [], adoptResumeId, agentTools, personal = null, researchIsolation, knowledgeDir, projectReads }) {
237
237
  // Adoption resumes a conversation in ITS OWN CLI's store (claude forks,
238
238
  // agy moves) — codex has no adoptable store wired yet. Reaching here
239
239
  // with an adopt id is a wiring mistake upstream, and it fails loudly on
@@ -277,6 +277,7 @@ export const CODEX_RUNTIME = {
277
277
  // `--sandbox` flag (codex-cli 0.156.1 lists only `-c`/`--enable`/…), so
278
278
  // every resumed turn — an agent's second card, an answer — was refused
279
279
  // before it started. `-c sandbox_mode=` means the same on both.
280
+ if (writableDirs.length) a.push('-c', `sandbox_workspace_write.writable_roots=${JSON.stringify(writableDirs)}`);
280
281
  a.push('-c', `sandbox_mode="${SAFE ? 'workspace-write' : 'danger-full-access'}"`);
281
282
  }
282
283
 
@@ -26,12 +26,14 @@ export function mergeOutward({
26
26
  repoRoot,
27
27
  tmpDir,
28
28
  baseRef,
29
+ baseTip,
29
30
  workingTree,
30
31
  warn,
31
32
  // NETWORK calls only (push, fetch) — timed and non-interactive, so a
32
33
  // stale credential or a half-open connection cannot freeze the ship
33
34
  // path's writer lock. Injectable for tests; defaults to the real one.
34
35
  gitNet = gitNetDefault,
36
+ retryRace = true,
35
37
  }) {
36
38
  const dropTmp = () => {
37
39
  try {
@@ -56,6 +58,7 @@ export function mergeOutward({
56
58
  const base = baseBranchName(baseRef());
57
59
  const merged = String(git(['rev-parse', 'HEAD'], tmpDir)).trim();
58
60
  const before = String(git(['rev-parse', `refs/heads/${base}`], repoRoot)).trim();
61
+ if (baseTip && before !== baseTip) throw new Error(`base moved to ${before}, but expected ${baseTip}`);
59
62
  let current = null;
60
63
  try { current = String(git(['symbolic-ref', '--quiet', '--short', 'HEAD'], repoRoot)).trim(); } catch { /* detached */ }
61
64
  if (current === base) {
@@ -76,7 +79,7 @@ export function mergeOutward({
76
79
  };
77
80
  const attempt = () => {
78
81
  dropTmp();
79
- git(['worktree', 'add', '--detach', tmpDir, baseRef()], repoRoot);
82
+ git(['worktree', 'add', '--detach', tmpDir, baseTip ?? baseRef()], repoRoot);
80
83
  gitMerge(
81
84
  ['merge', '--no-ff', tip, '-m', `ship(${label}): ${count} commit${count === 1 ? '' : 's'}`],
82
85
  tmpDir
@@ -110,7 +113,7 @@ export function mergeOutward({
110
113
  * re-pushing a stale merge. The TIP is untouched, so the receipts still
111
114
  * name exactly the same commits.
112
115
  */
113
- if (!isRaceRejection(e)) throw e;
116
+ if (!retryRace || !isRaceRejection(e)) throw e;
114
117
  warn?.('ship: base moved under us — refetching and merging again');
115
118
  try {
116
119
  gitNet(['fetch', 'origin', '--quiet'], repoRoot, 60_000);
@@ -188,8 +188,8 @@ export async function resolveStartProject({ dirGiven }) {
188
188
  }
189
189
  // An answered question is consent: adopt it, and BIND it to this repo so
190
190
  // the next start needs no prompt. Repointing is legitimate and said aloud.
191
- if (repoRoot && picked.repoRoot && picked.repoRoot !== repoRoot) {
192
- console.log(`note: ${creds.projectLabel(picked)} was connected for ${picked.repoRoot} — now serving ${repoRoot} instead.`);
191
+ if (repoRoot && picked.repoRoot && !creds.isStoredRepo(picked, repoRoot)) {
192
+ console.log(`note: adding ${repoRoot} to ${creds.projectLabel(picked)}. Run ${terminalCommand('login')} here to register its repository in project settings.`);
193
193
  }
194
194
  adoptStoredCredential(picked);
195
195
  // BOUND ONLY ONCE THE START HAS THE LOCK. Written here, the answer moved
@@ -266,10 +266,10 @@ export async function resolveStartProject({ dirGiven }) {
266
266
  }
267
267
 
268
268
  if (process.argv.includes('--json-events') && dirGiven && CREDENTIAL?.entry) {
269
- const { detectRepoRoot, listStoredProjects, boundElsewhere } = await import('./credentials.mjs');
269
+ const { detectRepoRoot, listStoredProjects, boundElsewhere, isStoredRepo } = await import('./credentials.mjs');
270
270
  const repo = detectRepoRoot();
271
271
  const entry = CREDENTIAL.entry;
272
- if (!repo || entry.repoRoot !== repo || boundElsewhere(listStoredProjects(), repo, entry.projectId).length) {
272
+ if (!repo || !isStoredRepo(entry, repo) || boundElsewhere(listStoredProjects(), repo, entry.projectId).length) {
273
273
  console.error('error: this project is not connected to this repository. Run `flowviant login` for the selected folder.');
274
274
  process.exit(1);
275
275
  }
package/bin/lib/trace.mjs CHANGED
@@ -60,6 +60,7 @@
60
60
  */
61
61
 
62
62
  import { THINK_MARKER } from './runtimeEvents.mjs';
63
+ import { withoutTurnDeclaration } from './turnDeclaration.mjs';
63
64
 
64
65
  /** One batch every two seconds — the discipline the tab's narrator keeps, for
65
66
  * the same reason: a turn emits hundreds of entries and nobody is reading them
@@ -207,7 +208,8 @@ export function makeTraceRelay({ agentId, turnId, post, scrub = (s) => s, run =
207
208
  * did when a thought was 300 characters.
208
209
  */
209
210
  prose(kind, text) {
210
- const t = scrub(String(text ?? ''))
211
+ const readable = kind === 'think' ? String(text ?? '') : withoutTurnDeclaration(text);
212
+ const t = scrub(readable)
211
213
  // One line ending, whatever the CLI printed.
212
214
  .replace(/\r\n?/g, '\n')
213
215
  // Spaces and tabs collapse; `\n` is deliberately excluded from the class.
@@ -216,7 +218,7 @@ export function makeTraceRelay({ agentId, turnId, post, scrub = (s) => s, run =
216
218
  .replace(/\n{3,}/g, '\n\n')
217
219
  .trim()
218
220
  .slice(0, TRACE_PROSE_CAP);
219
- if (!t) return;
221
+ if (!t) return null;
220
222
  const k = kind === 'think' ? 'think' : kind === 'say' ? 'say' : 'note';
221
223
  /**
222
224
  * A RUN OF BARE "thinking…" IS ONE STEP, not forty.
@@ -238,9 +240,12 @@ export function makeTraceRelay({ agentId, turnId, post, scrub = (s) => s, run =
238
240
  * steps are not shown" count stays a count of steps that existed.
239
241
  */
240
242
  if (k === 'think' && t === THINK_MARKER && lastEntry?.k === 'think' && lastEntry.t === t) {
241
- return;
243
+ return readable;
242
244
  }
243
245
  push({ k, t });
246
+ // The activity pulse uses this same filtering; its original compact
247
+ // label stands unless protocol text was removed from the full message.
248
+ return readable;
244
249
  },
245
250
  /** One structured tool event, exactly as `toolEventOf` built it. A tool this
246
251
  * builder does not know returns null there and nothing is pushed here — a
@@ -0,0 +1,88 @@
1
+ /** A final outcome is protocol, not conversation prose. Filter before trace
2
+ * normalization/caps; the unmodified answer still goes to parseTurnResult.
3
+ * Only standalone objects and JSON/unlabelled fences count. Code examples
4
+ * inside prose or another language's fence stay as the CLI wrote them. */
5
+ import { jsonObjectClosers } from './agentPlan.mjs';
6
+
7
+ const statuses = new Set(['delivered', 'blocked', 'prerequisite', 'nothing']);
8
+ function declaration(body) {
9
+ const raw = body.trim();
10
+ if (!raw.startsWith('{')) return false;
11
+ try {
12
+ const value = JSON.parse(raw);
13
+ return value && typeof value === 'object' && !Array.isArray(value)
14
+ && ((Object.hasOwn(value, 'summary') && Object.hasOwn(value, 'progress'))
15
+ || (typeof value.status === 'string'
16
+ && (statuses.has(value.status) || ['summary', 'progress', 'question'].some(key => Object.hasOwn(value, key)))));
17
+ } catch {
18
+ // A truncated/invalid declaration must not fall through as an answer.
19
+ // Require a protocol key at the root's opening and the declared status;
20
+ // arbitrary malformed JSON remains useful diagnostic output.
21
+ return /^\{\s*"(?:status|summary|progress|question)"\s*:/.test(raw)
22
+ && /"status"\s*:\s*"(?:delivered|blocked|prerequisite|nothing)"/.test(raw);
23
+ }
24
+ }
25
+
26
+ /** Objects on their own lines, including a final object after a preamble.
27
+ * Skip a whole outer span before looking again; nested examples are data. */
28
+ function bareDeclarations(raw) {
29
+ const closes = jsonObjectClosers(raw);
30
+ let kept = '', from = 0, removed = false;
31
+ for (let i = 0; i < raw.length; i++) {
32
+ if (raw[i] !== '{') continue;
33
+ const end = closes.get(i) ?? -1;
34
+ const lineStart = raw.lastIndexOf('\n', i - 1) + 1;
35
+ const nextLine = end < 0 ? -1 : raw.indexOf('\n', end + 1);
36
+ const alone = !raw.slice(lineStart, i).trim()
37
+ && (end < 0 || !raw.slice(end + 1, nextLine < 0 ? raw.length : nextLine).trim());
38
+ const finish = end < 0 ? raw.length : end + 1;
39
+ if (alone && declaration(raw.slice(i, finish))) {
40
+ kept += raw.slice(from, i); from = finish; removed = true;
41
+ }
42
+ if (end >= 0) i = end;
43
+ else {
44
+ // An unclosed outer object owns the remaining candidate. For a stray
45
+ // brace in prose, skip its line instead of rescanning a brace wall.
46
+ const newline = raw.indexOf('\n', i);
47
+ if (alone || newline < 0) break;
48
+ i = newline;
49
+ }
50
+ }
51
+ return removed ? kept + raw.slice(from) : null;
52
+ }
53
+
54
+ export function withoutTurnDeclaration(text) {
55
+ const raw = String(text ?? '');
56
+ const kept = [];
57
+ let block = [], fence = null, json = false, removed = false;
58
+ let prose = [];
59
+ const flushProse = () => {
60
+ const plain = bareDeclarations(prose.join('\n'));
61
+ if (plain !== null) { removed = true; kept.push(plain); }
62
+ else kept.push(...prose);
63
+ prose = [];
64
+ };
65
+ // Consume every fence, including other languages, in one forward pass.
66
+ // A nested JSON example is part of its outer code block, never a result.
67
+ for (const line of raw.split('\n')) {
68
+ if (fence) {
69
+ if (line.trim() === fence) {
70
+ if (json && declaration(block.slice(1).join('\n'))) removed = true;
71
+ else kept.push(...block, line);
72
+ block = []; fence = null;
73
+ } else block.push(line);
74
+ } else {
75
+ const open = line.trim().match(/^(`{3,}|~{3,})[ \t]*([\w-]*)$/);
76
+ if (open) {
77
+ flushProse();
78
+ fence = open[1]; json = !open[2] || open[2].toLowerCase() === 'json'; block = [line];
79
+ } else prose.push(line);
80
+ }
81
+ }
82
+ flushProse();
83
+ if (fence) {
84
+ if (json && declaration(block.slice(1).join('\n'))) removed = true;
85
+ else kept.push(...block);
86
+ }
87
+ return removed ? kept.join('\n').replace(/\n{3,}/g, '\n\n').trim() : raw;
88
+ }