flowviant 0.124.0 → 0.124.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +6 -0
  2. package/bin/cli.mjs +7 -0
  3. package/bin/lib/agentInternalWait.mjs +2 -1
  4. package/bin/lib/agentPlan.mjs +8 -0
  5. package/bin/lib/agentRepositories.mjs +48 -0
  6. package/bin/lib/checkoutIdentity.mjs +25 -0
  7. package/bin/lib/claudeInput.mjs +32 -0
  8. package/bin/lib/claudeSessions.mjs +20 -0
  9. package/bin/lib/credentials.mjs +52 -17
  10. package/bin/lib/daemonLocks.mjs +21 -3
  11. package/bin/lib/fleet.mjs +19 -6
  12. package/bin/lib/fleetRoster.mjs +1 -89
  13. package/bin/lib/fleetStartup.mjs +11 -14
  14. package/bin/lib/holder.mjs +4 -37
  15. package/bin/lib/instance.mjs +20 -7
  16. package/bin/lib/landed.mjs +2 -2
  17. package/bin/lib/login.mjs +39 -4
  18. package/bin/lib/placeLock.mjs +9 -7
  19. package/bin/lib/prWorkflow.mjs +15 -0
  20. package/bin/lib/projectLanded.mjs +16 -0
  21. package/bin/lib/projectRepositories.mjs +60 -0
  22. package/bin/lib/projectTools.mjs +2 -1
  23. package/bin/lib/projectWorktreeHome.mjs +25 -0
  24. package/bin/lib/prompts.mjs +6 -1
  25. package/bin/lib/repositoryDelivery.mjs +64 -0
  26. package/bin/lib/repositoryMerge.mjs +116 -0
  27. package/bin/lib/repositoryPlaces.mjs +179 -0
  28. package/bin/lib/repositorySetup.mjs +58 -0
  29. package/bin/lib/repositoryTool.mjs +71 -0
  30. package/bin/lib/runTurn.mjs +23 -2
  31. package/bin/lib/runtimeAntigravity.mjs +2 -1
  32. package/bin/lib/runtimeClaude.mjs +5 -2
  33. package/bin/lib/runtimeCodex.mjs +2 -1
  34. package/bin/lib/shipMerge.mjs +5 -2
  35. package/bin/lib/startProject.mjs +4 -4
  36. package/bin/lib/trace.mjs +8 -3
  37. package/bin/lib/turnDeclaration.mjs +88 -0
  38. package/bin/lib/turnLock.mjs +48 -8
  39. package/bin/lib/work.mjs +22 -24
  40. package/bin/lib/workAgentCheck.mjs +6 -5
  41. package/bin/lib/workAgentMerges.mjs +19 -2
  42. package/bin/lib/workAgentPrecheck.mjs +5 -5
  43. package/bin/lib/workAgentPublish.mjs +18 -5
  44. package/bin/lib/workAgentRecovery.mjs +85 -0
  45. package/bin/lib/workAgentReview.mjs +5 -3
  46. package/bin/lib/workAgentTurnBegun.mjs +6 -4
  47. package/bin/lib/workAgentTurnExecution.mjs +76 -9
  48. package/bin/lib/workAgentTurnOutcome.mjs +9 -3
  49. package/bin/lib/workAgentTurns.mjs +33 -9
  50. package/bin/lib/workDelivery.mjs +21 -0
  51. package/bin/lib/workDiffs.mjs +18 -11
  52. package/bin/lib/workLiveMessages.mjs +129 -0
  53. package/bin/lib/workPlaceReports.mjs +5 -0
  54. package/bin/lib/workPlaces.mjs +6 -1
  55. package/bin/lib/workRetire.mjs +5 -0
  56. package/bin/lib/workSessionTurns.mjs +72 -40
  57. package/bin/lib/workShip.mjs +17 -16
  58. package/package.json +1 -1
  59. package/npm-shrinkwrap.json +0 -69
package/README.md CHANGED
@@ -37,6 +37,12 @@ flowviant
37
37
 
38
38
  It shows a short code. Open your project's **Workbench** in Flowviant and enter the code where it offers to connect a machine. The credential is stored at `~/.flowviant/credentials.json`, and from then on `flowviant` just runs. The installer prints a PATH hint if needed; `FLOWVIANT_INSTALL_DIR` changes its destination. It verifies SHA-256 and never uses sudo.
39
39
 
40
+ Run `flowviant login` from another checkout and choose the same project in the
41
+ browser to add its repository to that board. Project settings → General lists
42
+ the repositories, their remotes and base branches. The project's merge mode
43
+ applies to all of them. Credentials keep the named checkout paths together;
44
+ each directory still belongs to only one project on this machine.
45
+
40
46
  Prefer an explicit token? Create a machine credential in the app and pass it directly:
41
47
 
42
48
  ```bash
package/bin/cli.mjs CHANGED
@@ -80,6 +80,11 @@
80
80
  // answered here before anything else loads: no config, no update check, no
81
81
  // output but the hook's. An unknown hook exits 2, which Claude Code reads as a
82
82
  // refusal — the read guard's own rule for a hook that cannot judge.
83
+ if (['__repository', '__repository-mcp'].includes(process.argv[2])) {
84
+ await (await import('./lib/repositoryTool.mjs')).repositoryToolMain(process.argv[2] === '__repository-mcp');
85
+ process.exit(process.exitCode ?? 0);
86
+ }
87
+
83
88
  if (process.argv[2] === '__hook') {
84
89
  const HOOKS = {
85
90
  'read-guard': () => import('./lib/hooks/readGuard.mjs'),
@@ -279,6 +284,8 @@ async function cleanCommand() {
279
284
  /* du unavailable — skip the size */
280
285
  }
281
286
  console.log('note: stop any running flowviant daemon first — in-flight local work is discarded.');
287
+ const { unlockRepositorySnapshots } = await import('./lib/repositoryPlaces.mjs');
288
+ unlockRepositorySnapshots(dir);
282
289
  rmSync(dir, { recursive: true, force: true });
283
290
  console.log(`cleaned ~/.flowviant/worktrees${size}.`);
284
291
  process.exit(0);
@@ -5,6 +5,7 @@
5
5
  * measured failure for the lane's existing one-shot settlement. */
6
6
  import { parseTurnResult } from './agentPlan.mjs';
7
7
  import { limitLine } from './cliLimit.mjs';
8
+ import { addUsage } from './turnUsage.mjs';
8
9
 
9
10
  export const INTERNAL_WAIT_RECOVERIES = 2;
10
11
  export const INTERNAL_WAIT_CONTRACT = `Your own command or test still running is internal work, never a human blocker.
@@ -58,7 +59,7 @@ export async function runAgentWithInternalWaits(run, options) {
58
59
  options.onAnswer?.(text);
59
60
  },
60
61
  onUsage: (usage) => {
61
- currentUsage = usage;
62
+ currentUsage = addUsage(currentUsage, usage);
62
63
  options.onUsage?.(combine());
63
64
  },
64
65
  onToolEvent: (name, input, meta) => {
@@ -170,6 +170,8 @@ function fencedBody(text) {
170
170
  * so the nested object ends where it ends — and if that brace never closes,
171
171
  * neither does this one. What is left for each walk is its own top level.
172
172
  */
173
+ // The conversation-prose filter uses the same brace/string boundaries.
174
+ export { closers as jsonObjectClosers };
173
175
  function closers(s) {
174
176
  const close = new Map();
175
177
  for (let i = s.lastIndexOf('{'); i >= 0; i = i > 0 ? s.lastIndexOf('{', i - 1) : -1) {
@@ -260,6 +262,8 @@ export function parseProposal(text, scrub = envScrub) {
260
262
  const collisions = Array.isArray(parsed.collisions) ? parsed.collisions.map(c => ({ taskIds: c?.taskIds, files: lines(c?.files), rationale: scrubCut(c?.rationale, 1000, scrub) })) : parsed.collisions;
261
263
  const grouped = groupDependencies(agents, parsed.dependencies, collisions);
262
264
  if (!grouped) return null;
265
+ if (parsed.repos !== undefined && (!Array.isArray(parsed.repos) || parsed.repos.length > 1200 || parsed.repos.some(r => !ids.has(r?.taskId) || !Array.isArray(r.repos) || !r.repos.length || r.repos.length > 100 || r.repos.some(id => typeof id !== 'string' || !id || id.length > 64)))) return null;
266
+ const repos = parsed.repos?.map(r => ({ taskId: r.taskId, repos: [...new Set(r.repos)] }));
263
267
  const dependencies = parsed.dependencies?.map(e => ({ taskId: e.taskId, requires: e.requires }));
264
268
  return {
265
269
  agents: uniqueTempIds(grouped).map(g => ({ ...g, ...((evidence !== undefined || collisions !== undefined || parsed.dependencies !== undefined) ? { evidence: {
@@ -267,6 +271,7 @@ export function parseProposal(text, scrub = envScrub) {
267
271
  dependencies: (dependencies ?? []).filter(e => g.taskIds.includes(e.taskId)),
268
272
  collisions: (collisions ?? []).filter(c => c.taskIds.some(id => g.taskIds.includes(id))),
269
273
  } } : {}) })),
274
+ ...(repos !== undefined ? { repos } : {}),
270
275
  ...(parsed.dependencies !== undefined ? { dependencies } : {}),
271
276
  ...(collisions !== undefined ? { collisions } : {}),
272
277
  ...(evidence !== undefined ? { evidence } : {}),
@@ -417,6 +422,9 @@ export function parseTurnResult(text, scrub = envScrub) {
417
422
  if (v.status === 'delivered') {
418
423
  return {
419
424
  outcome: 'delivered',
425
+ ...(Array.isArray(v.mergeOrder) ? { mergeOrder: v.mergeOrder.filter((id) => typeof id === 'string').slice(0, 64) } : {}),
426
+ ...(typeof v.mergeOrderReason === 'string' ? { mergeOrderReason: clean(v.mergeOrderReason, 1000) } : {}),
427
+ ...(Array.isArray(v.mergeOrders) ? { mergeOrders: v.mergeOrders.slice(0, 60) } : {}),
420
428
  answer: typeof v.summary === 'string' ? clean(v.summary, 8000) : '',
421
429
  ...progressOf(v, scrub),
422
430
  raised: Array.isArray(v.raised)
@@ -0,0 +1,48 @@
1
+ /** Repository acquisition belongs to the daemon, inside the held card turn. */
2
+ import { gitOrNull, sessionBranch } from './git.mjs';
3
+ import { FLEET_URL, DAEMON_INSTANCE } from './config.mjs';
4
+ import { fleetEndpoint } from './fleetWire.mjs';
5
+ import { fleetRequest } from './fleetPost.mjs';
6
+ import { createRepositoryTool } from './repositoryTool.mjs';
7
+
8
+ export function createAgentRepositories({ repositories, places, report = async (body) => {
9
+ const r = await fleetRequest(fleetEndpoint('agent-repositories', FLEET_URL), { body: { ...body, instance: DAEMON_INSTANCE } });
10
+ if (r.error || r.json?.data?.ok !== true) throw new Error('The worktree exists, but its card repository update was not acknowledged. Retry add_repository before editing it.');
11
+ } }) {
12
+ const prepare = async (job, place, setupOptions) => {
13
+ if (!repositories.multiple() && !places.stateFor(place)) return null;
14
+ const ids = repositories.targets(job);
15
+ const dir = await places.ensure(place, ids, setupOptions);
16
+ const before = new Map(places.entries(place).filter((r) => r.writable).map((r) => [r.id, gitOrNull(['rev-parse', 'HEAD'], r.wt)]));
17
+ const cardId = job.task?.id ?? job.repositoryTaskId;
18
+ // A crash after creation or an unacknowledged report must not grant a
19
+ // repository silently on restart. Retry the durable card update first.
20
+ for (const repoId of cardId ? places.pending(place, cardId) : []) {
21
+ await report({ agentId: job.agentId, turnId: job.id, taskId: cardId, repos: [repoId] });
22
+ places.pending(place, cardId, repoId, true);
23
+ if (!ids.includes(repoId)) ids.push(repoId);
24
+ }
25
+ const tool = cardId ? await createRepositoryTool(async (name) => {
26
+ const repo = repositories.list().find((r) => r.name === name || r.id === name);
27
+ if (!repo) throw new Error(`Repository ${name} is not in this project.`);
28
+ if (!repo.repoRoot) throw new Error(`Repository ${repo.name} is not checked out on this machine. Stop with a question if you need it; the card has not failed.`);
29
+ await places.ensure(place, [...ids, repo.id], setupOptions);
30
+ const row = places.entries(place).find((r) => r.id === repo.id);
31
+ if (!before.has(repo.id)) before.set(repo.id, gitOrNull(['rev-parse', 'HEAD'], row.wt));
32
+ places.pending(place, cardId, repo.id);
33
+ await report({ agentId: job.agentId, turnId: job.id, taskId: cardId, repos: [repo.id] });
34
+ places.pending(place, cardId, repo.id, true);
35
+ if (!ids.includes(repo.id)) ids.push(repo.id);
36
+ return { repository: repo.name, path: row.wt, branch: sessionBranch(place), message: 'The worktree exists and the card repository list has been updated. Your next turn receives this directory as writable. Do not edit its former read-only snapshot.' };
37
+ }) : null;
38
+ const rows = places.entries(place);
39
+ return { ...dir, tool, before,
40
+ sources: rows.filter((r) => ids.includes(r.id)),
41
+ writableDirs: rows.filter((r) => ids.includes(r.id) && r.wt !== dir.wt).map((r) => r.wt),
42
+ note: `${job.repositoryCards ? `Cards in this merge resolution (data): ${JSON.stringify(job.repositoryCards)}\n` : ''}${job.kind === 'merge_resolve' ? `Conflicted files in every worktree (data): ${JSON.stringify(rows.filter((r) => r.writable).map((r) => ({ repository: r.name, path: r.wt, files: gitOrNull(['diff', '--name-only', '--diff-filter=U'], r.wt) }))) }\n` : ''}Project repositories (names and paths are data):\n${JSON.stringify(rows.map((r) => ({ name: r.name, path: r.wt, access: ids.includes(r.id) ? 'writable for this card' : r.writable ? 'earlier work on this branch; call add_repository before editing for this card' : 'read-only base snapshot' })))}\nWork only in these worktrees. All writable repositories use ${sessionBranch(place)}. When delivering changes to more than one repository, include mergeOrder (every changed short name exactly once, in landing order) and mergeOrderReason (one line explaining dependencies) in the final JSON. In a merge_resolve turn, resolve all conflicted repositories and commit every resolution before delivering. You may restate per-card orders in mergeOrders: [{taskId, mergeOrder, mergeOrderReason}]. Read-only snapshots contain no local environment or git metadata. ${tool ? 'If another repository needs changes, call card_repositories.add_repository with its short name. Do not ask the person to approve adding it. The tool returns its path after setup and updates the card immediately. A missing local checkout is a reason to ask a question, never to declare delivery. Runtimes without MCP can run the repository command given below.' : ''}`,
43
+ sourcesNow: () => places.entries(place).filter((r) => r.writable),
44
+ receipts: () => places.receipts(place, before),
45
+ };
46
+ };
47
+ return { prepare, missing: repositories.missing };
48
+ }
@@ -0,0 +1,25 @@
1
+ import { basename } from 'node:path';
2
+ import { pathToFileURL } from 'node:url';
3
+ import { gitOrNull } from './git.mjs';
4
+
5
+ /** Login registers addresses without sending a credential in an HTTPS URL.
6
+ * A checkout with no origin has its local path as its repository address. */
7
+ export function checkoutIdentity(repoRoot) {
8
+ let remote = gitOrNull(['remote', 'get-url', 'origin'], repoRoot) ?? pathToFileURL(repoRoot).href;
9
+ if (remote.includes('://')) {
10
+ try {
11
+ const url = new URL(remote);
12
+ url.password = '';
13
+ if (url.protocol !== 'ssh:') url.username = '';
14
+ url.search = '';
15
+ url.hash = '';
16
+ remote = url.hostname.toLowerCase() === 'github.com'
17
+ ? url.pathname.replace(/^\//, '').replace(/\.git\/?$/, '')
18
+ : url.toString();
19
+ } catch { remote = repoRoot; }
20
+ }
21
+ const ssh = remote.match(/^(?:git@)?github\.com:([^/]+\/[^/]+?)(?:\.git)?$/i);
22
+ if (ssh) remote = ssh[1];
23
+ const name = basename(repoRoot).toLowerCase().replace(/[^a-z0-9_-]/g, '-').replace(/^[^a-z0-9]+/, '').slice(0, 40) || 'app';
24
+ return { name, remote };
25
+ }
@@ -0,0 +1,32 @@
1
+ /** Claude's stream-json stdin. A write is only an attempt; its replay echo is
2
+ * the receipt. Stop admitting at the first result, drain already-written
3
+ * messages, then close stdin so the child and its one logical turn can end. */
4
+ export function createClaudeInput(child, { onTaken = () => {} } = {}) {
5
+ const sent = new Set();
6
+ const pending = new Set();
7
+ let accepting = true;
8
+ let ended = false;
9
+ let resultSeen = false;
10
+ child.stdin.on('error', () => { accepting = false; }); // EPIPE leaves the message queued
11
+ const send = (id, prompt) => {
12
+ if (!accepting || ended || sent.has(id) || child.stdin.destroyed || child.stdin.writableEnded) return false;
13
+ const message = { type: 'user', uuid: id, session_id: '', message: { role: 'user', content: prompt }, parent_tool_use_id: null };
14
+ sent.add(id);
15
+ pending.add(id);
16
+ try { child.stdin.write(JSON.stringify(message) + '\n'); return true; }
17
+ catch { pending.delete(id); accepting = false; return false; }
18
+ };
19
+ return {
20
+ send,
21
+ event(event) {
22
+ if (event.type === 'user' && event.isReplay === true && pending.delete(event.uuid)) onTaken(event.uuid);
23
+ if (event.type === 'result') {
24
+ resultSeen = true;
25
+ accepting = false;
26
+ if ((pending.size === 0 || event.is_error === true) && !ended) { ended = true; child.stdin.end(); }
27
+ }
28
+ },
29
+ close() { accepting = false; ended = true; },
30
+ get resultSeen() { return resultSeen; },
31
+ };
32
+ }
@@ -111,6 +111,26 @@ export function titleForSession(cwd, sessionId) {
111
111
  return null;
112
112
  }
113
113
 
114
+ /** Native user UUIDs are delivery evidence after a crash before the replay
115
+ * receipt reached the API. Unknown/unreadable is null, never an empty claim. */
116
+ export function messageIdsForSession(cwd, sessionId) {
117
+ if (!cwd || !CLAUDE_SESSION_RE.test(sessionId ?? '')) return null;
118
+ const dirs = [String(cwd)];
119
+ try { const real = realpathSync(cwd); if (real !== cwd) dirs.push(real); } catch { /* gone */ }
120
+ for (const dir of dirs) {
121
+ const file = join(process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude'), 'projects', claudeProjectDir(dir), `${sessionId}.jsonl`);
122
+ try {
123
+ if (statSync(file).size > 64 * 1024 * 1024) continue;
124
+ const ids = new Set();
125
+ for (const line of readFileSync(file, 'utf8').split('\n')) {
126
+ try { const row = JSON.parse(line); if (row.type === 'user' && typeof row.uuid === 'string') ids.add(row.uuid); } catch { /* torn tail */ }
127
+ }
128
+ return ids;
129
+ } catch { /* try real cwd */ }
130
+ }
131
+ return null;
132
+ }
133
+
114
134
  /**
115
135
  * THE DIRECTORY CLAUDE CODE FILES A CWD'S TRANSCRIPTS UNDER — its own rule,
116
136
  * copied exactly (2.1.281): every character that is not an ASCII letter or
@@ -15,7 +15,9 @@
15
15
  * {
16
16
  * fleetToken, projectId, mcpUrl, // the LEGACY MIRROR
17
17
  * projects: { // v2: every connected project
18
- * [projectId]: { fleetToken, mcpUrl, name, repoRoot, savedAt }
18
+ * [projectId]: { fleetToken, mcpUrl, name, repoRoot,
19
+ * repositories: { app: '/code/app', cli: '/code/cli' },
20
+ * repositoriesRegistered, savedAt }
19
21
  * }
20
22
  * }
21
23
  *
@@ -27,7 +29,7 @@
27
29
  * WHICH CREDENTIAL A START USES — resolution, in order:
28
30
  * --fleet / FLOWVIANT_FLEET the operator said, verbatim (config.mjs).
29
31
  * --project <name|id> an entry, named without a prompt.
30
- * the entry BOUND to this repo repoRoot recorded at login/confirm, compared
32
+ * the entry BOUND to this repo any named root recorded at login/confirm, compared
31
33
  * by realpath — the no-ambiguity path.
32
34
  * one unbound entry served, with a one-time TTY confirm that
33
35
  * binds it (headless keeps the old behaviour —
@@ -36,7 +38,7 @@
36
38
  * stored project and asks (startProject.mjs), or — with
37
39
  * no TTY — refuses in words that name them all.
38
40
  *
39
- * The repoRoot binding is a SAFETY line, not bookkeeping: serving project X
41
+ * Each repository binding is a SAFETY line, not bookkeeping: serving project X
40
42
  * from a repo that is not X's checkout materializes X's session worktrees and
41
43
  * decrypted env inside the wrong repository. Binding happens only at moments a
42
44
  * HUMAN was present (login, an answered confirm, an explicit pick) — never
@@ -46,7 +48,7 @@
46
48
 
47
49
  import { mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from 'node:fs';
48
50
  import { homedir } from 'node:os';
49
- import { join } from 'node:path';
51
+ import { basename, join } from 'node:path';
50
52
  import { printable } from './printable.mjs';
51
53
  import { connectedOn } from './credentialDate.mjs';
52
54
  import { terminalCommand } from './launchCommand.mjs';
@@ -101,6 +103,8 @@ export function listStoredProjects() {
101
103
  mcpUrl: e.mcpUrl ?? null,
102
104
  name: typeof e.name === 'string' && e.name ? e.name : null,
103
105
  repoRoot: typeof e.repoRoot === 'string' && e.repoRoot ? e.repoRoot : null,
106
+ repositories: storedRepositories(e),
107
+ repositoriesRegistered: e.repositoriesRegistered === true,
104
108
  savedAt: e.savedAt ?? null,
105
109
  });
106
110
  }
@@ -111,6 +115,7 @@ export function listStoredProjects() {
111
115
  mcpUrl: f.mcpUrl ?? null,
112
116
  name: null,
113
117
  repoRoot: null,
118
+ repositories: {},
114
119
  savedAt: null,
115
120
  });
116
121
  }
@@ -202,10 +207,11 @@ export function projectRowLabel(e, entries) {
202
207
  export function repoCollisions(entries) {
203
208
  const groups = [];
204
209
  for (const e of entries) {
205
- if (!e?.repoRoot) continue;
206
- const g = groups.find((x) => samePath(x.repoRoot, e.repoRoot));
207
- if (g) g.entries.push(e);
208
- else groups.push({ repoRoot: e.repoRoot, entries: [e] });
210
+ for (const repoRoot of Object.values(storedRepositories(e))) {
211
+ const g = groups.find((x) => samePath(x.repoRoot, repoRoot));
212
+ if (g) { if (!g.entries.includes(e)) g.entries.push(e); }
213
+ else groups.push({ repoRoot, entries: [e] });
214
+ }
209
215
  }
210
216
  return groups.filter((g) => g.entries.length > 1);
211
217
  }
@@ -228,7 +234,7 @@ export function repoCollisions(entries) {
228
234
  */
229
235
  export function boundElsewhere(entries, repoRoot, projectId) {
230
236
  if (!repoRoot) return [];
231
- return entries.filter((e) => e.projectId !== projectId && samePath(e.repoRoot, repoRoot));
237
+ return entries.filter((e) => e.projectId !== projectId && isStoredRepo(e, repoRoot));
232
238
  }
233
239
 
234
240
  /**
@@ -313,17 +319,45 @@ function mutate(fn) {
313
319
  return f;
314
320
  }
315
321
 
322
+ /** v2 repoRoot remains the primary mirror; v3 retains every named checkout. */
323
+ function storedRepositories(e) {
324
+ const entries = e?.repositories && typeof e.repositories === 'object' && !Array.isArray(e.repositories)
325
+ ? Object.entries(e.repositories).filter(([name, root]) => /^[a-z0-9][a-z0-9_-]*$/.test(name) && typeof root === 'string' && root)
326
+ : [];
327
+ if (e?.repoRoot && !entries.some(([, root]) => samePath(root, e.repoRoot))) entries.push(['app', e.repoRoot]);
328
+ return Object.fromEntries(entries);
329
+ }
330
+
331
+ export function isStoredRepo(entry, repoRoot) {
332
+ return Boolean(repoRoot && Object.values(storedRepositories(entry)).some((root) => samePath(root, repoRoot)));
333
+ }
334
+
335
+ function bindEntry(entry, repoRoot, repositoryName, isPrimary = false) {
336
+ const repos = storedRepositories(entry);
337
+ const existing = Object.entries(repos).find(([, root]) => samePath(root, repoRoot));
338
+ const name = repositoryName ?? existing?.[0] ?? (Object.keys(repos).length ? basename(repoRoot).toLowerCase().replace(/[^a-z0-9_-]/g, '-') : 'app');
339
+ if (Object.hasOwn(repos, name) && !samePath(repos[name], repoRoot)) {
340
+ throw new Error(`Repository ${name} is already connected at ${repos[name]}.`);
341
+ }
342
+ // A settings rename keeps the path while replacing the old name.
343
+ if (existing && existing[0] !== name) delete repos[existing[0]];
344
+ entry.repositories = { ...repos, [name]: repoRoot };
345
+ if (!entry.repoRoot || isPrimary) entry.repoRoot = repoRoot;
346
+ }
347
+
316
348
  /** A fresh login: upsert the entry AND point the legacy mirror at it. */
317
- export function saveLogin({ fleetToken, projectId, mcpUrl, name, repoRoot }) {
349
+ export function saveLogin({ fleetToken, projectId, mcpUrl, name, repoRoot, repositoryName, isPrimary, repositoryBindings = [], repositoriesRegistered }) {
318
350
  mutate((f) => {
319
351
  f.projects[projectId] = {
320
352
  ...(f.projects[projectId] ?? {}),
321
353
  fleetToken,
322
354
  mcpUrl: mcpUrl ?? null,
323
355
  ...(safeName(name) ? { name: safeName(name) } : {}),
324
- ...(repoRoot ? { repoRoot } : {}),
325
356
  savedAt: new Date().toISOString(),
357
+ ...(repositoriesRegistered !== undefined ? { repositoriesRegistered } : {}),
326
358
  };
359
+ for (const binding of repositoryBindings) bindEntry(f.projects[projectId], binding.repoRoot, binding.name, binding.isPrimary);
360
+ if (repoRoot) bindEntry(f.projects[projectId], repoRoot, repositoryName, isPrimary);
327
361
  f.fleetToken = fleetToken;
328
362
  f.projectId = projectId;
329
363
  f.mcpUrl = mcpUrl ?? null;
@@ -337,7 +371,7 @@ export function selectStoredProject(projectId, { bindRepoRoot } = {}) {
337
371
  mutate((f) => {
338
372
  const e = f.projects[projectId];
339
373
  if (!e) return;
340
- if (bindRepoRoot) e.repoRoot = bindRepoRoot;
374
+ if (bindRepoRoot) bindEntry(e, bindRepoRoot);
341
375
  f.fleetToken = e.fleetToken;
342
376
  f.projectId = projectId;
343
377
  f.mcpUrl = e.mcpUrl ?? null;
@@ -397,10 +431,10 @@ export function forgetStoredProject(ref) {
397
431
  return { entry: gone };
398
432
  }
399
433
 
400
- export function bindStoredRepo(projectId, repoRoot) {
434
+ export function bindStoredRepo(projectId, repoRoot, repositoryName) {
401
435
  if (!repoRoot) return;
402
436
  mutate((f) => {
403
- if (f.projects[projectId]) f.projects[projectId].repoRoot = repoRoot;
437
+ if (f.projects[projectId]) bindEntry(f.projects[projectId], repoRoot, repositoryName);
404
438
  });
405
439
  }
406
440
 
@@ -454,15 +488,16 @@ export function resolveStoredCredential(argv = process.argv, cwd = process.cwd()
454
488
  const projectArg = i >= 0 ? argv[i + 1] : undefined;
455
489
  if (projectArg !== undefined) {
456
490
  const m = matchStoredProject(projectArg);
457
- return m.entry ? { entry: m.entry, source: 'project-flag' } : { error: m.error };
491
+ const repoRoot = detectRepoRoot(cwd);
492
+ return m.entry ? { entry: isStoredRepo(m.entry, repoRoot) ? { ...m.entry, repoRoot } : m.entry, source: 'project-flag' } : { error: m.error };
458
493
  }
459
494
  const entries = listStoredProjects();
460
495
  if (entries.length === 0) return { none: true };
461
496
  const repoRoot = detectRepoRoot(cwd);
462
497
 
463
498
  if (repoRoot) {
464
- const bound = entries.filter((e) => samePath(e.repoRoot, repoRoot));
465
- if (bound.length === 1) return { entry: bound[0], source: 'repo', repoRoot };
499
+ const bound = entries.filter((e) => isStoredRepo(e, repoRoot));
500
+ if (bound.length === 1) return { entry: { ...bound[0], repoRoot }, source: 'repo', repoRoot };
466
501
  if (bound.length > 1) return { choices: bound, reason: 'multiple-bound', repoRoot };
467
502
  }
468
503
 
@@ -22,12 +22,28 @@ import { createHash } from 'node:crypto';
22
22
  import { homedir } from 'node:os';
23
23
  import { join } from 'node:path';
24
24
 
25
- /** Every daemon lock's file name: `daemon-` + 12 hex of the credential's hash. */
25
+ /** Every daemon lock's file name: `daemon-` + 12 hex of its identity hash. */
26
26
  export const DAEMON_LOCK_RE = /^daemon-[0-9a-f]{12}\.lock$/;
27
27
 
28
28
  /** Deliberately a HASH: a credential must never become a filename. */
29
29
  export function instanceLockPath(fleetToken, home = homedir()) {
30
- const key = createHash('sha256').update(String(fleetToken || 'anon')).digest('hex').slice(0, 12);
30
+ const projectId = projectForCredential(fleetToken, home);
31
+ const key = createHash('sha256').update(projectId ? `project:${projectId}` : String(fleetToken || 'anon')).digest('hex').slice(0, 12);
32
+ return join(home, '.flowviant', `daemon-${key}.lock`);
33
+ }
34
+
35
+ /** Keep status, stop, draining and startup on the same project identity. */
36
+ export function projectForCredential(token, home = homedir()) {
37
+ try {
38
+ const stored = JSON.parse(nodeFs.readFileSync(join(home, '.flowviant', 'credentials.json'), 'utf8'));
39
+ return Object.entries(stored.projects ?? {}).find(([, entry]) => entry?.fleetToken === token)?.[0] ??
40
+ (stored.fleetToken === token ? stored.projectId : null);
41
+ } catch { return null; }
42
+ }
43
+
44
+ /** An old daemon may still hold the credential-keyed lock. */
45
+ export function legacyInstanceLockPath(token, home = homedir()) {
46
+ const key = createHash('sha256').update(String(token || 'anon')).digest('hex').slice(0, 12);
31
47
  return join(home, '.flowviant', `daemon-${key}.lock`);
32
48
  }
33
49
 
@@ -57,7 +73,9 @@ export function readLockFile(path, { fs = nodeFs } = {}) {
57
73
 
58
74
  /** The record in this credential's lock, or null. */
59
75
  export function readLockHolder(fleetToken) {
60
- return readLockFile(instanceLockPath(fleetToken));
76
+ const current = readLockFile(instanceLockPath(fleetToken));
77
+ const legacy = readLockFile(legacyInstanceLockPath(fleetToken));
78
+ return current && legacy?.pid === current.pid && legacy.draining ? legacy : current ?? legacy;
61
79
  }
62
80
 
63
81
  /**
package/bin/lib/fleet.mjs CHANGED
@@ -18,6 +18,7 @@
18
18
  * that order. Moving the order out would move the reconciliation itself.
19
19
  */
20
20
 
21
+ import { existsSync } from 'node:fs';
21
22
  import {
22
23
  VERSION,
23
24
  FLEET_URL,
@@ -59,6 +60,7 @@ import { markLockDraining } from './instance.mjs';
59
60
  import { reportDeployConfig } from './deployConfig.mjs';
60
61
  import { mcpInstallsInFlight, processMcpInstallJobs } from './mcpInstall.mjs';
61
62
  import { probeSkillsOnce } from './runtimeCapabilities.mjs';
63
+ import { createProjectRepositories, storedProject } from './projectRepositories.mjs';
62
64
  import { createWorkManager } from './work.mjs';
63
65
  import { INTAKE_ROSTER_KEY } from './workIntake.mjs';
64
66
  import { createKnowledgeSync } from './knowledge.mjs';
@@ -90,7 +92,10 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
90
92
  console.log('');
91
93
  console.log(` ${c.bold(c.cyan('◣ flowviant'))} ${c.dim(`machine daemon · v${VERSION}`)}`);
92
94
  console.log(` ${c.dim('──────────────────────────────────────────────')}`);
93
- const repoRoot = repoRootOrDie();
95
+ const launchRoot = repoRootOrDie();
96
+ const projectEntry = storedProject(FLEET_TOKEN);
97
+ const repoRoot = projectEntry?.repoRoot && existsSync(projectEntry.repoRoot) ? projectEntry.repoRoot : launchRoot;
98
+ const repositories = createProjectRepositories({ entry: projectEntry, repoRoot, getEntry: () => storedProject(FLEET_TOKEN) });
94
99
  /**
95
100
  * WHERE SHIP LANDS. Detected at startup, then OVERRIDDEN by the roster when a
96
101
  * human has chosen one (`projects.baseBranch`).
@@ -135,7 +140,7 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
135
140
  info(`server · ${FLEET_URL}`);
136
141
  console.log('');
137
142
 
138
- takeInstanceLockOrExit(repoRoot);
143
+ takeInstanceLockOrExit(launchRoot, projectEntry?.projectId);
139
144
  // The repo binding the start path's picker or confirm was answered with.
140
145
  // Persisted HERE, after the lock, so a refused start moves nothing: moving it
141
146
  // first stranded the daemon already serving that project's own checkout at
@@ -176,7 +181,7 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
176
181
  // running (detached children survive an ungraceful exit) before we start fresh.
177
182
  reapOrphanPreviews((m) => info(m));
178
183
 
179
- const { repoKey, baseDir } = worktreeHome(repoRoot);
184
+ const { repoKey, baseDir } = worktreeHome(projectEntry?.repoRoot ?? repoRoot, projectEntry?.projectId, Object.values(projectEntry?.repositories ?? {}));
180
185
 
181
186
  // WHAT THE DELETED VAULT LEFT ON THIS DISK — once, here, because this is the
182
187
  // first point at which both directories it wrote into are known. Deleting the
@@ -260,6 +265,7 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
260
265
  processAgentPlanJobs,
261
266
  processIntakeJobs,
262
267
  processAgentTurnJobs,
268
+ processAgentRecoveryJobs,
263
269
  interruptAgentTurns,
264
270
  processAgentMergeJobs,
265
271
  freshenManualPlaces,
@@ -279,6 +285,7 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
279
285
  liveTurns,
280
286
  } = createWorkManager({
281
287
  repoRoot,
288
+ repositories,
282
289
  baseDir,
283
290
  getBaseRef,
284
291
  getMcpUrl: () => mcpUrl,
@@ -538,6 +545,8 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
538
545
  // WHERE each live tab works, BEFORE anything measures one: the sweep below
539
546
  // and every preview check ask `placeOf`, and a tab nobody has typed into
540
547
  // yet has taught it nothing.
548
+ repositories.learn(roster.repositories);
549
+ for (const repository of repositories.list()) if (repository.repoRoot) scanEnvForScrub(repository.repoRoot);
541
550
  learnPlaces(roster.sessionPlaces);
542
551
  processWorkTurns(roster.workTurnJobs);
543
552
  // The roster's live-session list rides along: an ENDED session's ship
@@ -564,8 +573,10 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
564
573
  * Announced on change, because a silent switch of merge target is the one
565
574
  * thing worse than not offering the choice at all.
566
575
  */
567
- if (typeof roster.baseBranch === 'string' && roster.baseBranch.trim()) {
568
- const want = usableBaseRef(repoRoot, `origin/${baseBranchName(roster.baseBranch.trim())}`);
576
+ const servedBaseBranch = Array.isArray(roster.repositories)
577
+ ? repositories.list().find((r) => r.repoRoot === repoRoot)?.baseBranch : roster.baseBranch;
578
+ if (typeof servedBaseBranch === 'string' && servedBaseBranch.trim()) {
579
+ const want = usableBaseRef(repoRoot, `origin/${baseBranchName(servedBaseBranch.trim())}`);
569
580
  if (want !== baseRef) {
570
581
  note(`base · ${want} ${c.dim('(set for this project)')}`);
571
582
  baseRef = want;
@@ -611,7 +622,9 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
611
622
  processIntakeJobs(roster[INTAKE_ROSTER_KEY]);
612
623
  // …and an agent's next card. After the plan jobs because a press becoming
613
624
  // agents is the thing that produces these.
614
- processAgentTurnJobs(roster.agentTurnJobs);
625
+ const recovering = await processAgentRecoveryJobs(roster.agentRecoveryJobs);
626
+ processAgentTurnJobs(Array.isArray(roster.agentTurnJobs)
627
+ ? roster.agentTurnJobs.filter((job) => !recovering.has(job.agentId)) : roster.agentTurnJobs);
615
628
  // …and a branch somebody approved. After the turns: a merge takes the
616
629
  // place's WRITER lock, and writer preference means it goes ahead of any
617
630
  // reader queued behind it anyway.
@@ -45,65 +45,6 @@ let warnedEnvIdentity = false;
45
45
  */
46
46
  const POLL_URL_BUDGET = 14_000;
47
47
 
48
- /**
49
- * HAS THIS PROCESS ALREADY ASKED FOR THE MACHINE? (0.91.0)
50
- *
51
- * The owner's ruling on two boxes running one project: "it should kill the
52
- * first one and take over". So a daemon asks on the FIRST poll of its life and
53
- * never again, and this pair is the whole of that "never again".
54
- *
55
- * ONE ASK PER PROCESS IS THE SAFETY, NOT A POLITENESS. A param that kept asking
56
- * would be two boxes trading a machine back and forth every ten seconds. Asked
57
- * once, the story ends at the first exchange, because the box that loses
58
- * SETTLES ITS TURNS AND EXITS rather than restarting — and a process that has
59
- * exited sends no more first polls.
60
- *
61
- * THAT LAST SENTENCE IS TRUE OF THE PROCESS AND NOT OF THE UNIT, which is why
62
- * the server stopped relying on it (2026-09-19). Under `Restart=always`, pm2 or
63
- * docker, exit 0 is a relaunch and the relaunch's first poll is a genuine one.
64
- * `takeMachineHolderNow` refuses a take by the box it just displaced for the
65
- * length of the displaced window, so the ping-pong is bounded on the side that
66
- * can see both boxes. This module still keeps its half of the bargain.
67
- *
68
- * SPENT ON A DELIVERED POLL, NOT ON AN ATTEMPTED ONE, which is why this is two
69
- * functions instead of one consuming read. A daemon that starts while the wire
70
- * is down retries every ten seconds; spending the ask on the failed attempt
71
- * would mean the box you walked over to and started never takes the machine,
72
- * and nothing anywhere would say why.
73
- *
74
- * IT IS NOT THE DELETED FLAG COMING BACK. There is still nothing to type: the
75
- * terminal surface is `npx flowviant` and `npx flowviant login`, and what
76
- * claims here is starting the daemon, which is already one of those two.
77
- *
78
- * Exported so both properties can be proved without a server, a credential or a
79
- * second box — they are the contract, and nothing else in the file states them.
80
- */
81
- /**
82
- * AND A RESTART IS NOT A PERSON (2026-09-19, the review).
83
- *
84
- * `update.mjs` re-execs the daemon with `FLOWVIANT_REEXEC='1'` after an
85
- * UNATTENDED auto-update, which is on by default. Born `false`, the new process
86
- * would then spend a fresh ask and TAKE THE PROJECT'S MACHINE — so a standby
87
- * sitting quietly on somebody's second computer would seize the machine off the
88
- * live holder in the middle of the night, settling its running turns as moved,
89
- * because npm published a patch. Nobody was at either keyboard, and nothing on
90
- * any surface would say why.
91
- *
92
- * The ask belongs to the GESTURE, not to the process: what claims a machine is
93
- * a person typing `npx flowviant`, and a re-exec is the same start continuing.
94
- * So a re-executed daemon is born with the ask already spent and behaves
95
- * exactly as it did before the update — it keeps the machine if it had it (arm
96
- * (a) stamps it), and stands by if it did not.
97
- */
98
- let machineAskSpent = process.env.FLOWVIANT_REEXEC === '1';
99
- export function machineAskPending() {
100
- return !machineAskSpent;
101
- }
102
- export function spendMachineAsk() {
103
- machineAskSpent = true;
104
- }
105
-
106
-
107
48
  export async function fetchRoster(
108
49
  livePreviewSessionIds = [],
109
50
  heldSessionIds = [],
@@ -170,30 +111,7 @@ export async function fetchRoster(
170
111
  if (repoRoot) url.searchParams.set('cp', String(repoRoot).slice(0, 256));
171
112
  url.searchParams.set('pid', String(process.pid));
172
113
  url.searchParams.set('st', PROCESS_STARTED_AT);
173
- /**
174
- * THE FIRST POLL OF THIS PROCESS ASKS FOR THE MACHINE (`claim`, 0.91.0).
175
- *
176
- * The owner, on two boxes running one project: "it should kill the first one
177
- * and take over." Starting the daemon on the computer in front of you IS the
178
- * gesture — there is no flag, no env var and no third command, so the ruling
179
- * that deleted the old machine-moving flag ("i dont intend to run or do
180
- * anything in the terminal besides npx flowviant or npx flowviant login")
181
- * stands untouched.
182
- *
183
- * SENT ONCE PER PROCESS, and that is the safety rather than a politeness: see
184
- * `askForMachineOnce`. The server takes holdership there and then, the box it
185
- * displaces learns through the existing named-and-windowed `displaced` signal
186
- * and exits 0, and nothing restarts to ask again.
187
- *
188
- * AN OLDER SERVER IGNORES IT and the old standby behaviour stands, which is
189
- * why this needs no floor of its own — the server applies one (it will not
190
- * take a machine from a box too old to be told it lost it).
191
- */
192
- if (machineAskPending()) url.searchParams.set('claim', '1');
193
- // A poll reports what this box IS. Moving the project's machine onto it, when
194
- // this process has already spent its one ask above, is a gesture a person
195
- // makes in the app — and the daemon learns that outcome on its next poll like
196
- // every other holder fact.
114
+ // Only Make it the machine in the app moves an existing holder.
197
115
  // Which shares this machine is still serving. It rides the poll rather than
198
116
  // taking an endpoint of its own: one beat, no floor, and the stale window is
199
117
  // the reconcile interval instead of minutes — which matters, because a share
@@ -430,12 +348,6 @@ export async function fetchRoster(
430
348
  throw new Error(`fleet poll refused by something in front of the API (${res.status})`);
431
349
  }
432
350
  if (!res.ok) throw new Error(`fleet poll failed (${res.status})`);
433
- // THE ASK IS SPENT HERE AND NOWHERE ELSE — on a poll the server actually
434
- // answered. Spending it where the param is SET would lose the claim of a
435
- // daemon that started while the wire was down: it would retry, silently
436
- // without `claim`, and the box somebody walked over to and started would
437
- // never take the machine.
438
- spendMachineAsk();
439
351
  const body = await res.json();
440
352
  // Validate the shape here so a malformed 200 (deploy hiccup, error envelope)
441
353
  // throws a NORMAL retryable error inside the loop's try/catch, instead of a