flowviant 0.116.0 → 0.124.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 (161) hide show
  1. package/README.md +66 -12
  2. package/bin/cli.mjs +50 -17
  3. package/bin/lib/admission.mjs +7 -14
  4. package/bin/lib/agentAsk.mjs +123 -0
  5. package/bin/lib/agentBrief.mjs +36 -0
  6. package/bin/lib/agentFootprint.mjs +76 -0
  7. package/bin/lib/agentInternalWait.mjs +83 -0
  8. package/bin/lib/agentPlan.mjs +134 -31
  9. package/bin/lib/agentPrerequisiteContract.mjs +43 -0
  10. package/bin/lib/agentPublish.mjs +12 -13
  11. package/bin/lib/agentTaskKinds.mjs +5 -8
  12. package/bin/lib/agySessions.mjs +3 -3
  13. package/bin/lib/artifactContracts.mjs +91 -20
  14. package/bin/lib/artifactIteration.mjs +182 -0
  15. package/bin/lib/artifactPreview.mjs +92 -18
  16. package/bin/lib/artifactRecovery.mjs +36 -0
  17. package/bin/lib/artifactReporter.mjs +194 -22
  18. package/bin/lib/artifacts.mjs +70 -63
  19. package/bin/lib/authproxy.mjs +2 -1
  20. package/bin/lib/childEnv.mjs +9 -4
  21. package/bin/lib/claude.mjs +10 -20
  22. package/bin/lib/claudeAuth.mjs +15 -5
  23. package/bin/lib/claudePersonal.mjs +71 -6
  24. package/bin/lib/claudePosture.mjs +227 -54
  25. package/bin/lib/claudeSessions.mjs +7 -7
  26. package/bin/lib/claudeStream.mjs +27 -13
  27. package/bin/lib/cliLimit.mjs +2 -2
  28. package/bin/lib/codexHome.mjs +17 -0
  29. package/bin/lib/codexPersonal.mjs +44 -3
  30. package/bin/lib/codexProjectRead.mjs +63 -0
  31. package/bin/lib/codexRelay.mjs +21 -5
  32. package/bin/lib/codexResearch.mjs +193 -0
  33. package/bin/lib/commandSpecs.mjs +6 -0
  34. package/bin/lib/config.mjs +37 -24
  35. package/bin/lib/conversationIds.mjs +51 -0
  36. package/bin/lib/credentials.mjs +2 -7
  37. package/bin/lib/daemonLocks.mjs +90 -0
  38. package/bin/lib/daemonState.mjs +5 -5
  39. package/bin/lib/deploy.mjs +6 -9
  40. package/bin/lib/deployCheckout.mjs +26 -22
  41. package/bin/lib/deployRunner.mjs +54 -18
  42. package/bin/lib/deployWire.mjs +32 -23
  43. package/bin/lib/desktopContract.mjs +6 -5
  44. package/bin/lib/discoveryContract.mjs +19 -0
  45. package/bin/lib/env.mjs +104 -35
  46. package/bin/lib/fleet.mjs +53 -13
  47. package/bin/lib/fleetPost.mjs +172 -25
  48. package/bin/lib/fleetReports.mjs +172 -171
  49. package/bin/lib/fleetStartup.mjs +6 -6
  50. package/bin/lib/fleetWire.mjs +42 -0
  51. package/bin/lib/git.mjs +111 -61
  52. package/bin/lib/gitMergeIdentity.mjs +2 -8
  53. package/bin/lib/grant.mjs +23 -7
  54. package/bin/lib/holder.mjs +108 -81
  55. package/bin/lib/hooks/mcpAllow.mjs +60 -0
  56. package/bin/lib/hooks/readGuard.mjs +54 -11
  57. package/bin/lib/imageArtifact.mjs +74 -0
  58. package/bin/lib/install.mjs +80 -33
  59. package/bin/lib/instance.mjs +36 -83
  60. package/bin/lib/intakeDraft.mjs +4 -1
  61. package/bin/lib/knowledgeFileSync.mjs +31 -0
  62. package/bin/lib/knowledgeFiles.mjs +28 -83
  63. package/bin/lib/landed.mjs +22 -29
  64. package/bin/lib/launchCommand.mjs +91 -0
  65. package/bin/lib/listeners.mjs +0 -5
  66. package/bin/lib/localSessions.mjs +52 -39
  67. package/bin/lib/login.mjs +22 -12
  68. package/bin/lib/loginPath.mjs +22 -17
  69. package/bin/lib/machineListing.mjs +6 -3
  70. package/bin/lib/maintenance.mjs +47 -0
  71. package/bin/lib/maintenanceInventory.mjs +39 -0
  72. package/bin/lib/mcp-cli.mjs +9 -10
  73. package/bin/lib/mcpInstall.mjs +163 -0
  74. package/bin/lib/mcpInventory.mjs +119 -0
  75. package/bin/lib/noWindowEnv.mjs +23 -3
  76. package/bin/lib/ownedDirs.mjs +99 -0
  77. package/bin/lib/pinnedCapture.mjs +383 -0
  78. package/bin/lib/planDependencies.mjs +70 -0
  79. package/bin/lib/postureTools.mjs +85 -0
  80. package/bin/lib/prWorkflow.mjs +94 -17
  81. package/bin/lib/preflight.mjs +8 -25
  82. package/bin/lib/preview.mjs +19 -25
  83. package/bin/lib/previewGatePolicy.mjs +34 -9
  84. package/bin/lib/previewRegistry.mjs +4 -11
  85. package/bin/lib/printable.mjs +70 -7
  86. package/bin/lib/procRegistry.mjs +33 -13
  87. package/bin/lib/procScan.mjs +2 -3
  88. package/bin/lib/procStat.mjs +76 -0
  89. package/bin/lib/processGroup.mjs +48 -0
  90. package/bin/lib/processes.mjs +10 -24
  91. package/bin/lib/projectToolRuntimes.mjs +328 -19
  92. package/bin/lib/projectTools.mjs +109 -34
  93. package/bin/lib/prompts.mjs +205 -60
  94. package/bin/lib/repoPath.mjs +19 -0
  95. package/bin/lib/repoState.mjs +17 -19
  96. package/bin/lib/repositoryBaseline.mjs +68 -0
  97. package/bin/lib/runTurn.mjs +286 -209
  98. package/bin/lib/runtimeAntigravity.mjs +16 -5
  99. package/bin/lib/runtimeCapabilities.mjs +215 -21
  100. package/bin/lib/runtimeClaude.mjs +78 -10
  101. package/bin/lib/runtimeCodex.mjs +217 -209
  102. package/bin/lib/runtimeDetection.mjs +8 -2
  103. package/bin/lib/runtimeEvents.mjs +28 -11
  104. package/bin/lib/runtimeLimits.mjs +5 -6
  105. package/bin/lib/runtimeOfferings.mjs +99 -14
  106. package/bin/lib/runtimes.mjs +10 -0
  107. package/bin/lib/shipSweep.mjs +5 -3
  108. package/bin/lib/shot.mjs +91 -17
  109. package/bin/lib/sleep.mjs +11 -0
  110. package/bin/lib/startProject.mjs +5 -5
  111. package/bin/lib/statePaths.mjs +40 -0
  112. package/bin/lib/tty.mjs +25 -15
  113. package/bin/lib/turnDiagnostics.mjs +93 -0
  114. package/bin/lib/turnLock.mjs +7 -14
  115. package/bin/lib/turnProfile.mjs +16 -4
  116. package/bin/lib/ui.mjs +0 -3
  117. package/bin/lib/uninstall.mjs +30 -26
  118. package/bin/lib/update.mjs +79 -27
  119. package/bin/lib/uplinkScrub.mjs +296 -31
  120. package/bin/lib/vault.mjs +78 -10
  121. package/bin/lib/vaultDiff.mjs +5 -2
  122. package/bin/lib/views.mjs +37 -12
  123. package/bin/lib/wikiRunner.mjs +40 -61
  124. package/bin/lib/work.mjs +44 -13
  125. package/bin/lib/workAdoptCarry.mjs +2 -4
  126. package/bin/lib/workAgentBrief.mjs +92 -0
  127. package/bin/lib/workAgentBriefRunner.mjs +59 -0
  128. package/bin/lib/workAgentCheck.mjs +104 -57
  129. package/bin/lib/workAgentMerges.mjs +62 -57
  130. package/bin/lib/workAgentPlans.mjs +82 -199
  131. package/bin/lib/workAgentPrecheck.mjs +76 -138
  132. package/bin/lib/workAgentPublish.mjs +6 -8
  133. package/bin/lib/workAgentTurnBegun.mjs +15 -11
  134. package/bin/lib/workAgentTurnExecution.mjs +131 -67
  135. package/bin/lib/workAgentTurnOutcome.mjs +11 -2
  136. package/bin/lib/workAgentTurnReports.mjs +9 -37
  137. package/bin/lib/workAgentTurns.mjs +64 -7
  138. package/bin/lib/workArtifactReconcile.mjs +79 -0
  139. package/bin/lib/workAttachments.mjs +66 -11
  140. package/bin/lib/workBrain.mjs +9 -4
  141. package/bin/lib/workCappedTurn.mjs +119 -0
  142. package/bin/lib/workChildren.mjs +5 -5
  143. package/bin/lib/workCommandAudit.mjs +18 -2
  144. package/bin/lib/workDiffs.mjs +52 -6
  145. package/bin/lib/workIntake.mjs +35 -60
  146. package/bin/lib/workPlaceReports.mjs +7 -4
  147. package/bin/lib/workPlaces.mjs +113 -31
  148. package/bin/lib/workPreviews.mjs +47 -112
  149. package/bin/lib/workProcesses.mjs +8 -35
  150. package/bin/lib/workPullRequests.mjs +37 -56
  151. package/bin/lib/workReportQueue.mjs +100 -148
  152. package/bin/lib/workRetire.mjs +2 -1
  153. package/bin/lib/workSessionRuntime.mjs +38 -37
  154. package/bin/lib/workSessionTurns.mjs +256 -199
  155. package/bin/lib/workShip.mjs +43 -23
  156. package/bin/lib/workToolLog.mjs +70 -3
  157. package/bin/lib/workTurnShape.mjs +165 -0
  158. package/npm-shrinkwrap.json +69 -0
  159. package/package.json +9 -4
  160. package/bin/lib/fleetJobs.mjs +0 -120
  161. package/bin/lib/patchRevert.mjs +0 -60
@@ -0,0 +1,63 @@
1
+ /** Actual project files and Git history, with no host-wide read fallback.
2
+ * The CLI retains its provider/MCP login outside the tool sandbox. Shells
3
+ * receive neither that environment nor native image reads/shell snapshots.
4
+ */
5
+ import { execFileSync } from 'node:child_process';
6
+ import { lstatSync, readdirSync, realpathSync } from 'node:fs';
7
+ import { isAbsolute, join, dirname } from 'node:path';
8
+ import { git } from './git.mjs';
9
+ import { codexResearchHelper } from './codexResearch.mjs';
10
+ import { unattendedEnv } from './noWindowEnv.mjs';
11
+
12
+ export function projectReadBoundary({ cwd, knowledgeDir, env = process.env }) {
13
+ if (process.platform !== 'linux') throw new Error('Project-scoped Codex reads require Linux or WSL.');
14
+ if (!isAbsolute(cwd ?? '')) throw new Error('Project-scoped Codex reads require an absolute project directory.');
15
+ const helper = codexResearchHelper(env);
16
+ const version = execFileSync(helper, ['--version'], { encoding: 'utf8', timeout: 10000, env: unattendedEnv(env) }).match(/^codex-cli (\d+)\.(\d+)\.(\d+)/);
17
+ if (!version || version.slice(1).map(Number).reduce((n, v) => n * 1000 + v, 0) < 159002)
18
+ throw new Error('Project-scoped reads need Codex CLI 0.159.2 or newer. Update Codex before retrying.');
19
+ const project = realpathSync(cwd);
20
+ // Resolve both: linked worktrees have their own HEAD/index and shared
21
+ // objects/refs in the common directory. Never grant the parent checkout.
22
+ const gitOpts = { timeout: 10000, env: { PATH: env.PATH, HOME: '/nonexistent', GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null' } };
23
+ const local = realpathSync(git(['rev-parse', '--absolute-git-dir'], project, gitOpts));
24
+ const common = realpathSync(git(['rev-parse', '--path-format=absolute', '--git-common-dir'], project, gitOpts));
25
+ const paths = [project, local, common];
26
+ // Git directories also hold daemon login/thread/browser stores. They are
27
+ // not history. Mask non-Git directories, including other worktree stores.
28
+ const metadata = new Set(['HEAD', 'index', 'commondir', 'gitdir', 'config', 'config.worktree', 'objects', 'refs', 'logs', 'packed-refs', 'shallow', 'description', 'info', 'branches']);
29
+ const denied = [];
30
+ for (const dir of new Set([local, common])) {
31
+ for (const name of readdirSync(dir)) {
32
+ if (dir === common && name === 'worktrees' && local !== common) {
33
+ for (const sibling of readdirSync(join(common, name))) {
34
+ const path = join(common, name, sibling);
35
+ if (path !== local && path !== dirname(local)) denied.push(path);
36
+ }
37
+ } else if (!metadata.has(name) && lstatSync(join(dir, name)).isDirectory()) denied.push(join(dir, name));
38
+ }
39
+ }
40
+ if (knowledgeDir) paths.push(realpathSync(knowledgeDir));
41
+ return { paths: [...new Set(paths)], denied, helper };
42
+ }
43
+
44
+ export function projectReadFence({ paths, denied = [], helper }) {
45
+ const rules = { ':root': 'deny', ':minimal': 'read', [helper]: 'read' };
46
+ for (const path of paths) {
47
+ if (!isAbsolute(path) || path === '/') throw new Error('Invalid project read boundary.');
48
+ rules[path] = 'read';
49
+ }
50
+ for (const path of denied) rules[path] = 'deny';
51
+ const table = '{' + Object.entries(rules).map(([path, access]) => `${JSON.stringify(path)}=${JSON.stringify(access)}`).join(',') + '}';
52
+ return [
53
+ '-c', `permissions.flowviantproject.filesystem=${table}`,
54
+ '-c', 'permissions.flowviantproject.network.enabled=false',
55
+ '-c', 'default_permissions="flowviantproject"',
56
+ '-c', 'approval_policy="never"',
57
+ '-c', 'project_doc_max_bytes=0',
58
+ '-c', 'features.shell_snapshot=false', '-c', 'allow_login_shell=false',
59
+ '-c', 'shell_environment_policy.inherit="none"',
60
+ '-c', 'shell_environment_policy.set={PATH="/usr/bin:/bin",HOME="/nonexistent",GIT_CONFIG_NOSYSTEM="1",GIT_CONFIG_GLOBAL="/dev/null",GIT_PAGER="cat",BROWSER="none"}',
61
+ '--disable', 'view_image',
62
+ ];
63
+ }
@@ -20,7 +20,7 @@
20
20
  * classifier. The sentence names what was measured and the one remedy the
21
21
  * machine's own CLI offers; Codex's own words stay beside it.
22
22
  */
23
- import { scrub as envScrub } from './uplinkScrub.mjs';
23
+ import { scrub as envScrub, scrubCut } from './uplinkScrub.mjs';
24
24
  import { CODEX_RUNTIME } from './runtimeCodex.mjs';
25
25
 
26
26
  const NO_LOGIN = /unexpected status 401 Unauthorized: Missing bearer or basic authentication/;
@@ -55,9 +55,25 @@ export function codexApprovalSentence(refused) {
55
55
  return `Codex on the machine refused ${n} tool call${n === 1 ? '' : 's'} in this turn${which} — "MCP tool call requires approval, but approval policy is never" — so nothing ${n === 1 ? 'it' : 'they'} would have done was done.`;
56
56
  }
57
57
 
58
+ /**
59
+ * WHAT A TERMINAL OR NEW TASK REPLY MAY CARRY BACK (2026-10-10, the audit's
60
+ * final cleanup; F042's daemon half). The server's `SESSION_ANSWER_MAX`
61
+ * (shared `fieldCaps.ts`): the settle route (`/fleet/work-turn-done`) cuts an
62
+ * answer there and the session writer clamps every message to it. The daemon
63
+ * cuts there too before it sends — `withRelay` below and the plain reply in
64
+ * `workSessionTurns.mjs` — so nothing past it crosses the wire. It was a bare
65
+ * `16000` in both; the release gate (`scripts/check-app-parity.mjs`, rule 29)
66
+ * holds this copy equal to the shared one.
67
+ */
68
+ export const SESSION_ANSWER_MAX = 16_000;
69
+
58
70
  /** A reply with the sentence first and Codex's own words kept under it,
59
- * scrubbed like every reply and cut to the lane's cap. */
60
- export function withRelay(sentence, raw, cap = 16000) {
61
- const words = envScrub(String(raw ?? '')).trim();
62
- return (words ? `${sentence}\n\n${words}` : sentence).slice(0, cap);
71
+ * scrubbed like every reply and cut to the lane's cap — the words scrubbed
72
+ * whole before they are trimmed, and the reply cut only after (`scrubCut`). */
73
+ export function withRelay(sentence, raw, cap = SESSION_ANSWER_MAX) {
74
+ const reply = (s) => {
75
+ const words = envScrub(s).trim();
76
+ return words ? `${sentence}\n\n${words}` : sentence;
77
+ };
78
+ return scrubCut(raw, cap, reply);
63
79
  }
@@ -0,0 +1,193 @@
1
+ /** Codex research reads a copied view, never the host checkout or login store.
2
+ * Linux/WSL only: the CLI process retains its login outside its tool sandbox;
3
+ * tools get root-denied reads, a clean environment and no shell snapshots.
4
+ * Web search is a CLI tool, not direct shell network access.
5
+ */
6
+ import { execFileSync } from 'node:child_process';
7
+ import { codexHome } from './codexHome.mjs';
8
+ import { git } from './git.mjs';
9
+ import { ensureRealSubdir, realDirState } from './ownedDirs.mjs';
10
+ import { closeSync, constants, copyFileSync, existsSync, fstatSync, openSync, readFileSync, readSync, lstatSync, mkdirSync, readdirSync, realpathSync, rmSync, unlinkSync, writeFileSync } from 'node:fs';
11
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
12
+ import { createRequire } from 'node:module';
13
+
14
+ const MAX_FILES = 20_000;
15
+ const MAX_BYTES = 128 * 1024 * 1024;
16
+ const omitted = (name) => /^(\.git|\.env.*|\.claude|\.codex|\.mcp\.json|node_modules|credentials(?:\..*)?|auth\.json)$/i.test(name) || /\.(pem|key|p12|pfx)$/i.test(name);
17
+
18
+ /** Fresh copies also break hardlinks. Symlinks, devices and sockets never ride.
19
+ * Refuse over the bound instead of presenting a silently incomplete repo.
20
+ */
21
+ export function copyResearchView(source, destination, budget = { files: 0, bytes: 0 }) {
22
+ mkdirSync(destination, { recursive: true, mode: 0o700 });
23
+ for (const entry of readdirSync(source, { withFileTypes: true })) {
24
+ if (omitted(entry.name)) continue;
25
+ const from = join(source, entry.name);
26
+ const to = join(destination, entry.name);
27
+ const stat = lstatSync(from);
28
+ if (stat.isSymbolicLink()) continue;
29
+ if (stat.isDirectory()) {
30
+ // Only the card's files and kept library are inputs from .flowviant.
31
+ if (entry.name === '.flowviant') {
32
+ for (const input of ['uploads', 'knowledge', 'artifacts']) {
33
+ const dir = join(from, input);
34
+ if (realDirState(dir) === 'dir')
35
+ copyResearchView(dir, join(to, input), budget);
36
+ }
37
+ } else copyResearchView(from, to, budget);
38
+ } else if (stat.isFile()) {
39
+ budget.files += 1;
40
+ budget.bytes += stat.size;
41
+ if (budget.files > MAX_FILES || budget.bytes > MAX_BYTES)
42
+ throw new Error('The isolated research copy exceeds 20,000 files or 128 MB. Narrow the repository inputs before trying again.');
43
+ // A file replaced by a symlink between enumeration and copy must not
44
+ // bring host data into the readable view. Copy through a no-follow fd.
45
+ const fd = openSync(from, constants.O_RDONLY | constants.O_NOFOLLOW);
46
+ try {
47
+ if (!fstatSync(fd).isFile()) throw new Error('A research input changed while copying.');
48
+ copyFileSync(`/proc/self/fd/${fd}`, to);
49
+ } finally { closeSync(fd); }
50
+ }
51
+ }
52
+ }
53
+
54
+ /** Resolve the actual sandbox helper without granting the person's bin/home.
55
+ * Support native installs and the npm wrapper's platform package. Unknown
56
+ * installations refuse; granting the whole home to find a helper is no fence.
57
+ */
58
+ export function codexResearchHelper(env = process.env) {
59
+ const command = (env.PATH ?? '').split(':').map((dir) => join(dir, 'codex')).find((file) => existsSync(file));
60
+ if (!command) throw new Error('Codex is not installed on PATH.');
61
+ const executable = realpathSync(command);
62
+ const fd = openSync(executable, 'r');
63
+ const magic = Buffer.alloc(4);
64
+ try { readSync(fd, magic, 0, 4, 0); } finally { closeSync(fd); }
65
+ if (magic.equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46]))) return executable;
66
+ const target = process.arch === 'arm64' ? 'aarch64-unknown-linux-musl' : 'x86_64-unknown-linux-musl';
67
+ const platform = `@openai/codex-linux-${process.arch === 'arm64' ? 'arm64' : 'x64'}`;
68
+ let root;
69
+ try { root = dirname(createRequire(executable).resolve(`${platform}/package.json`)); }
70
+ catch { root = resolve(dirname(executable), '..'); }
71
+ const binary = join(root, 'vendor', target, 'bin', 'codex');
72
+ if (!existsSync(binary)) throw new Error('This Codex installation has no measured Linux research sandbox helper.');
73
+ return realpathSync(binary);
74
+ }
75
+
76
+ /** The research posture's own pairs: the copied view's filesystem table, no
77
+ * shell network, live web, a clean shell environment. runtimeCodex.mjs ends it
78
+ * in the shared fenced tail (sub-agents, browser, image generation, the
79
+ * person's config and rules), with the web left on: it is this posture's tool.
80
+ */
81
+ export function researchFence({ cwd, helper, generatedImages }) {
82
+ if (process.platform !== 'linux' || !isAbsolute(cwd ?? '') || !isAbsolute(helper ?? ''))
83
+ throw new Error('Isolated Codex research requires Linux or WSL and absolute sandbox paths.');
84
+ const artifacts = join(cwd, '.flowviant/artifacts');
85
+ const scratch = join(cwd, '.flowviant/tmp');
86
+ const rules = { ':root': 'deny', ':minimal': 'read', [helper]: 'read', [cwd]: 'read', [artifacts]: 'write', [scratch]: 'write' };
87
+ if (generatedImages) {
88
+ if (!isAbsolute(generatedImages)) throw new Error('Generated image directory must be absolute.');
89
+ rules[generatedImages] = 'read';
90
+ }
91
+ const table = '{' + Object.entries(rules).map(([path, access]) => `${JSON.stringify(path)}=${JSON.stringify(access)}`).join(',') + '}';
92
+ return [
93
+ '-c', `permissions.flowviantresearch.filesystem=${table}`,
94
+ '-c', 'permissions.flowviantresearch.network.enabled=false',
95
+ '-c', 'default_permissions="flowviantresearch"',
96
+ '-c', 'approval_policy="never"',
97
+ '-c', 'web_search="live"', '-c', 'tools.web_search=true',
98
+ '-c', 'features.shell_snapshot=false', '-c', 'allow_login_shell=false',
99
+ '-c', 'shell_environment_policy.inherit="none"',
100
+ '-c', `shell_environment_policy.set={PATH="/usr/bin:/bin",HOME=${JSON.stringify(scratch)},TMPDIR=${JSON.stringify(scratch)},BROWSER="none"}`,
101
+ '--disable', 'view_image', // native host-file reads bypass the tool fence
102
+ '--skip-git-repo-check',
103
+ ];
104
+ }
105
+
106
+ /** Copy only regular Markdown outputs back. Never follow a model-written link,
107
+ * nor one standing in the worktree: a destination directory must be a real one
108
+ * or absent (ownedDirs.mjs, the daemon's one link rule), and a file is written
109
+ * only where nothing or a regular file stands, created exclusively so a link
110
+ * swapped in after the look is never followed. A write-up already holding
111
+ * these exact bytes is left alone: the copied view carried it in, and its
112
+ * mtime is the artifact scan's evidence that the turn did not write it
113
+ * (artifacts.mjs `writtenSince`), so a turn that wrote nothing delivers
114
+ * nothing. Artifact publication stays in the original worktree for the
115
+ * existing uploader.
116
+ */
117
+ export function publishResearch(source, destination) {
118
+ if (realDirState(destination) === 'none') mkdirSync(destination, { recursive: true });
119
+ if (realDirState(destination) !== 'dir') throw new Error('The research artifact destination is not a real directory.');
120
+ for (const entry of readdirSync(source, { withFileTypes: true })) {
121
+ const from = join(source, entry.name);
122
+ const to = join(destination, entry.name);
123
+ const stat = lstatSync(from);
124
+ if (stat.isSymbolicLink()) throw new Error('An isolated research output is a symlink; nothing was accepted through it.');
125
+ if (stat.isDirectory()) publishResearch(from, to);
126
+ else if (stat.isFile() && /\.md$/i.test(entry.name)) {
127
+ if (stat.size > 2 * 1024 * 1024) throw new Error('The research Markdown output exceeds 2 MB.');
128
+ let there = null;
129
+ try {
130
+ there = lstatSync(to);
131
+ } catch (e) {
132
+ if (e?.code !== 'ENOENT') throw e;
133
+ }
134
+ if (there && !there.isFile()) throw new Error('The research output would overwrite a non-file.');
135
+ const bytes = readFileSync(from);
136
+ if (there && there.size === bytes.length && readFileSync(to).equals(bytes)) continue;
137
+ if (there) unlinkSync(to);
138
+ writeFileSync(to, bytes, { flag: 'wx' });
139
+ }
140
+ }
141
+ }
142
+
143
+ export async function runIsolatedResearch(opts, run, {
144
+ resolveHelper = codexResearchHelper,
145
+ readVersion = (helper) => execFileSync(helper, ['--version'], { encoding: 'utf8', timeout: 10_000 }),
146
+ } = {}) {
147
+ if (process.platform !== 'linux') throw new Error('Isolated Codex research requires Linux or WSL.');
148
+ const original = realpathSync(opts.cwd);
149
+ const helper = resolveHelper();
150
+ const version = readVersion(helper).match(/codex-cli (\d+)\.(\d+)\.(\d+)/);
151
+ if (!version || version.slice(1).map(Number).reduce((n, v) => n * 1000 + v, 0) < 159002)
152
+ throw new Error('Isolated research needs Codex CLI 0.159.2 or newer. Restart after updating Codex.');
153
+ // Both publication parents must be real directories in this checkout, or
154
+ // absent — never a link, dangling or not (ownedDirs.mjs).
155
+ for (const dir of [join(original, '.flowviant'), join(original, '.flowviant/artifacts')])
156
+ if (realDirState(dir) === 'other')
157
+ throw new Error('The research artifact destination is not a real directory.');
158
+ // Kept path across resumes, outside the source tree and separate from Codex's
159
+ // login/thread home. The caller's agent lock serializes turns in this place.
160
+ const gitDir = git(['rev-parse', '--absolute-git-dir'], original);
161
+ const isolation = join(gitDir, 'flowviant-research');
162
+ if (existsSync(isolation) && lstatSync(isolation).isSymbolicLink()) throw new Error('The research isolation directory is a symlink.');
163
+ rmSync(isolation, { recursive: true, force: true });
164
+ const snapshot = join(isolation, 'repo');
165
+ try {
166
+ copyResearchView(original, snapshot);
167
+ let knowledge = null;
168
+ if (opts.knowledgeDir) {
169
+ knowledge = join(snapshot, '.flowviant/knowledge');
170
+ copyResearchView(realpathSync(opts.knowledgeDir), knowledge);
171
+ }
172
+ for (const dir of ['artifacts', 'tmp']) mkdirSync(join(snapshot, '.flowviant', dir), { recursive: true, mode: 0o700 });
173
+ const rewrite = (text) => typeof text === 'string'
174
+ ? text.replaceAll(original, snapshot).replaceAll(opts.knowledgeDir || '\0', knowledge || '\0') : text;
175
+ const intro = 'This fresh research turn uses an isolated read-only copy. Prior reports are available under .flowviant/artifacts/; previous CLI conversations are not resumed because they may contain host secrets. Git metadata, .env files, credential files, symlinks and personal CLI settings are omitted. Write the Markdown report only under .flowviant/artifacts/. Web search is available; shell networking is disabled. Use exec_command with shell=/bin/bash and login=false; personal shells are outside the fence.\n\n';
176
+ // Only native output pixels, never the surrounding login/config home.
177
+ let generatedImages;
178
+ if (opts.mcpEnv?.CODEX_HOME) {
179
+ const imageHome = realpathSync(codexHome({ ...process.env, ...opts.mcpEnv }));
180
+ if (!ensureRealSubdir(imageHome, 'generated_images'))
181
+ throw new Error('The native generated image directory is not a real directory.');
182
+ generatedImages = join(imageHome, 'generated_images');
183
+ }
184
+ const result = await run({ ...opts, resume: false, resumeThreadId: null, resumeConversationId: null, cwd: snapshot, knowledgeDir: knowledge,
185
+ prompt: intro + rewrite(opts.prompt), system: rewrite(opts.system),
186
+ researchIsolation: { cwd: snapshot, helper, generatedImages } });
187
+ // Made, or found, real at publication too: every component checked again.
188
+ if (!ensureRealSubdir(original, '.flowviant/artifacts'))
189
+ throw new Error('The research artifact destination is not a real directory.');
190
+ publishResearch(join(snapshot, '.flowviant/artifacts'), join(original, '.flowviant/artifacts'));
191
+ return typeof result === 'string' ? result.replaceAll(snapshot, original) : result;
192
+ } finally { rmSync(isolation, { recursive: true, force: true }); }
193
+ }
@@ -21,6 +21,12 @@
21
21
  */
22
22
 
23
23
  export const COMMANDS = [
24
+ {
25
+ name: 'maintenance', usesCheckout: false, hidden: true,
26
+ usage: 'flowviant maintenance <request|release> <token>',
27
+ summary: 'Hold new runs for a manual WSL restart; existing work finishes.',
28
+ group: 'manage',
29
+ },
24
30
  {
25
31
  name: 'start',
26
32
  usesCheckout: true,
@@ -69,6 +69,10 @@ function argFlag(name) {
69
69
  */
70
70
  const API_BASE = process.env.FLOWVIANT_API_URL || 'https://api.flowviant.com/api';
71
71
  export const MCP_URL = process.env.FLOWVIANT_MCP_URL || `${API_BASE}/mcp`;
72
+ /** The web app a terminal view or a device login sends a person to. One
73
+ * declaration (audit 2026-10-10, F753): login.mjs and views.mjs each held a
74
+ * copy of this line. */
75
+ export const APP_URL = process.env.FLOWVIANT_APP_URL || 'https://app.flowviant.com';
72
76
  export const FLEET_URL = process.env.FLOWVIANT_FLEET_URL || `${API_BASE}/fleet/agents`;
73
77
  // Push channel: the daemon holds this WebSocket open and the server nudges it
74
78
  // the instant a job lands, so dispatch is ~a round-trip instead of a full poll.
@@ -76,7 +80,6 @@ export const FLEET_URL = process.env.FLOWVIANT_FLEET_URL || `${API_BASE}/fleet/a
76
80
  export const STREAM_URL =
77
81
  process.env.FLOWVIANT_STREAM_URL ||
78
82
  FLEET_URL.replace(/\/agents(\/?)$/, '/stream$1').replace(/^http/, 'ws');
79
- export const POLL_SECONDS = Number(process.env.POLL_SECONDS || 20);
80
83
 
81
84
  // What this machine can actually be given — the cgroup's limits before the
82
85
  // host's. The rule and its reader live in machineLimits.mjs; this is the
@@ -147,16 +150,42 @@ export const MACHINE = measureMachineLimits();
147
150
  * every agent. `cores * 2` lets the scheduler do what it is for, and memory
148
151
  * stays the bound that actually refuses.
149
152
  *
150
- * `min(32, …)` and the env override are untouched: the hard 32 is a runaway
151
- * bound on a box, not a product decision, and an operator who typed a number at
152
- * the machine gets exactly that number.
153
+ * `min(TURN_CEILING_MAX, …)` and the env override are untouched: the hard 32 is
154
+ * a runaway bound on a box, not a product decision, and an operator who typed
155
+ * a number at the machine gets exactly that number.
153
156
  */
154
157
  export function deriveMaxConcurrent(memBytes, cores) {
155
158
  const byMem = Math.floor(memBytes / 2 ** 30 - 2);
156
159
  const byCpu = Number(cores) * 2;
157
- return Math.max(1, Math.min(32, byMem, byCpu));
160
+ return Math.max(1, Math.min(TURN_CEILING_MAX, byMem, byCpu));
158
161
  }
159
162
 
163
+ /**
164
+ * THE RUNAWAY BOUND ON TURNS AT ONCE, AND THE ONE READING OF A CEILING
165
+ * (2026-10-10, audit F070).
166
+ *
167
+ * Every author of the ceiling — the env var, the server's dial, the
168
+ * derivation and an admission's own read — goes through `clampTurnCeiling`,
169
+ * which was five hand copies of one rule across this file and admission.mjs:
170
+ * a usable ceiling is a finite number ≥ 1, floored and capped at
171
+ * `TURN_CEILING_MAX`; anything else is ABSENT (null), never 0 or NaN — a 0
172
+ * refuses every spawn forever, and `live >= NaN` is no ceiling at all.
173
+ *
174
+ * 32 is the server's own bound on the dial (`machineTurnsSchema.maxTurns`,
175
+ * `max(32)` in the shared agent-runner schema): the release parity gate
176
+ * (`scripts/check-app-parity.mjs`) holds this copy to it, so the app never
177
+ * stores a dial no machine honours, and no machine caps one it would.
178
+ */
179
+ export const TURN_CEILING_MAX = 32;
180
+ export function clampTurnCeiling(raw) {
181
+ const v = Number(raw);
182
+ return Number.isFinite(v) && v >= 1 ? Math.min(Math.floor(v), TURN_CEILING_MAX) : null;
183
+ }
184
+
185
+ /** The operator's `FLOWVIANT_MAX_CONCURRENT`, read once through the one clamp:
186
+ * null when unset or unusable (the derivation stands). */
187
+ const MAX_CONCURRENT_ASKED = clampTurnCeiling(process.env.FLOWVIANT_MAX_CONCURRENT);
188
+
160
189
  /**
161
190
  * WAS THE ENV VAR THE SOURCE? — the one fact the precedence rule cannot
162
191
  * reconstruct afterwards.
@@ -166,20 +195,10 @@ export function deriveMaxConcurrent(memBytes, cores) {
166
195
  * last word and must outrank the app's dial, the other is a default the app may
167
196
  * override. See `pickMaxTurns` in admission.mjs for what is done with it.
168
197
  */
169
- export const MAX_CONCURRENT_FROM_ENV = (() => {
170
- const asked = Number(process.env.FLOWVIANT_MAX_CONCURRENT);
171
- return Number.isFinite(asked) && asked >= 1;
172
- })();
198
+ export const MAX_CONCURRENT_FROM_ENV = MAX_CONCURRENT_ASKED !== null;
173
199
 
174
- export const MAX_CONCURRENT = (() => {
175
- const asked = Number(process.env.FLOWVIANT_MAX_CONCURRENT);
176
- if (Number.isFinite(asked) && asked >= 1) return Math.min(Math.floor(asked), 32);
177
- return deriveMaxConcurrent(MACHINE.memBytes, MACHINE.cores);
178
- })();
179
- export const IDLE_SECONDS = Number(process.env.IDLE_SECONDS || 30);
180
- // Live mode: after this long idle-parked on a blocker, tear the session down to
181
- // free the Claude process (the intent stays claimed; it resumes when answered).
182
- export const PARK_TIMEOUT_SECONDS = Number(process.env.PARK_TIMEOUT_SECONDS || 900);
200
+ export const MAX_CONCURRENT =
201
+ MAX_CONCURRENT_ASKED ?? deriveMaxConcurrent(MACHINE.memBytes, MACHINE.cores);
183
202
  export const RECONCILE_SECONDS = Number(process.env.RECONCILE_SECONDS || 10);
184
203
  // Proactively refresh a worker token this many seconds before its lease lapses,
185
204
  // so a long-lived daemon never silently 401s on an expired token.
@@ -190,12 +209,6 @@ export const SAFE = process.env.FLOWVIANT_SAFE === '1';
190
209
  // keeps it nag-only (it still tells you to update, never installs). Below the
191
210
  // server's MIN version it updates regardless, since live mode won't work.
192
211
  export const AUTO_UPDATE = process.env.FLOWVIANT_NO_UPDATE !== '1';
193
- // Live mode (DEFAULT since 0.8.0): persistent Agent-SDK session per task —
194
- // streams into the task channel, injectable mid-task, blocker-parks in place,
195
- // delivery card on complete, branch preview tunnels. The legacy poll/sentinel
196
- // path (one-shot `claude -p` turns) survives behind FLOWVIANT_POLL=1 as the
197
- // escape hatch; FLOWVIANT_LIVE=1 is still honored for old scripts.
198
- export const LIVE = process.env.FLOWVIANT_POLL !== '1';
199
212
  // Sent on the daemon's own HTTP calls so Cloudflare Bot Fight Mode doesn't 403
200
213
  // them (Node's default UA is treated as a bot). Claude Code sends its own UA.
201
214
  export const USER_AGENT = `flowviant/${VERSION}`;
@@ -0,0 +1,51 @@
1
+ /**
2
+ * WHICH CONVERSATION A TAB RESUMES — the shape each CLI's conversation id must
3
+ * have, and the marker each tab pins it under (audit 2026-10-10, F157).
4
+ *
5
+ * A LEAF ON PURPOSE: no imports. These regexes are argv-injection and
6
+ * path-traversal guards — an id rides argv as `--resume <id>`, `resume <id>`
7
+ * or `--conversation <id>`, and Claude's is joined into a transcript path the
8
+ * probe cleanup then REMOVES — and they were written out by hand: Claude's
9
+ * four times, Antigravity's twice. A CLI that changes its id format would
10
+ * have had someone loosen one copy and miss the one guarding `rmSync`. The
11
+ * readers (claudeSessions, agySessions, runtimeCapabilities, runtimeLimits)
12
+ * sit below the work lanes, so the shapes cannot live in a lane module.
13
+ *
14
+ * THE MARKER NAMES ARE HERE FOR THE SAME REASON. `workSessionTurns.mjs`
15
+ * WRITES them (`sessionMetaPath(wt, <name>, sessionId)`), workPlaceReports
16
+ * reads Claude's for the tab's title, and localSessions READS all of them to
17
+ * know which conversations are the daemon's own — a drift between the writer
18
+ * and that fence is silent, and offers somebody their own tab back as an
19
+ * adoptable terminal session.
20
+ */
21
+
22
+ /**
23
+ * Claude Code's session id, as `system.init` reports it. Conservative: word
24
+ * characters and dash, 8–64 of them, so it can neither climb a path nor
25
+ * carry whitespace into argv.
26
+ */
27
+ export const CLAUDE_SESSION_RE = /^[A-Za-z0-9_-]{8,64}$/;
28
+
29
+ /**
30
+ * The shape a codex thread id must have before it is written to disk or —
31
+ * decisive — pushed into argv as `resume <id>`. Conservative on purpose:
32
+ * alphanumeric plus dash/underscore, never a leading dash (an argv that
33
+ * parses as a flag), never whitespace. Anything else is dropped and the
34
+ * session simply runs fresh in its own worktree.
35
+ */
36
+ export const CODEX_THREAD_RE = /^[0-9a-zA-Z][0-9a-zA-Z_-]{7,63}$/;
37
+
38
+ /** agy conversation ids are plain UUIDs (the db filename IS the identity —
39
+ * measured: a renamed copy fails "trajectory not found"). Guarded the same
40
+ * way as the codex id: it rides in argv as `--conversation <id>`. */
41
+ export const AGY_CONV_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
42
+
43
+ /** Each runtime's per-tab conversation marker (scoped by the session id). */
44
+ export const RESUME_MARKERS = Object.freeze({
45
+ claude: 'flowviant-claude-session',
46
+ codex: 'flowviant-codex-thread',
47
+ antigravity: 'flowviant-agy-conversation',
48
+ });
49
+
50
+ /** Every tab's conversation marker — what the adopt fence reads back. */
51
+ export const SESSION_MARKERS = Object.freeze(Object.values(RESUME_MARKERS));
@@ -44,7 +44,6 @@
44
44
  * mistake permanent.
45
45
  */
46
46
 
47
- import { execFileSync } from 'node:child_process';
48
47
  import { mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from 'node:fs';
49
48
  import { homedir } from 'node:os';
50
49
  import { join } from 'node:path';
@@ -52,6 +51,7 @@ import { printable } from './printable.mjs';
52
51
  import { connectedOn } from './credentialDate.mjs';
53
52
  import { terminalCommand } from './launchCommand.mjs';
54
53
  import { samePath } from './repoPath.mjs';
54
+ import { git } from './git.mjs';
55
55
 
56
56
  const CRED_DIR = join(homedir(), '.flowviant');
57
57
  const CRED_FILE = join(CRED_DIR, 'credentials.json');
@@ -76,12 +76,7 @@ function writeFile(v) {
76
76
  /** The repo root of `cwd`, realpath'd, or null when not inside a git repo. */
77
77
  export function detectRepoRoot(cwd = process.cwd()) {
78
78
  try {
79
- const root = execFileSync('git', ['rev-parse', '--show-toplevel'], {
80
- cwd,
81
- encoding: 'utf8',
82
- stdio: ['ignore', 'pipe', 'ignore'],
83
- timeout: 5000,
84
- }).trim();
79
+ const root = git(['rev-parse', '--show-toplevel'], cwd, { stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000 });
85
80
  return root ? realpathSync(root) : null;
86
81
  } catch {
87
82
  return null;
@@ -0,0 +1,90 @@
1
+ /**
2
+ * THE DAEMON LOCK FILE — its name, its record, and the one scan of them
3
+ * (audit 2026-10-10, F090/F123).
4
+ *
5
+ * instance.mjs arbitrates with these files; the status readout
6
+ * (daemonState.mjs, desktopContract.mjs) and `flowviant uninstall` only READ
7
+ * them. Each of those readers used to keep its own copy of the filename
8
+ * pattern or the record parse, and they had drifted: uninstall carried its own
9
+ * regex and pid read, the readout returned a pid that was not a positive
10
+ * integer, and `daemonRunningFor` re-implemented `readHolder` line for line. The
11
+ * FILENAME PATTERN is the thing that must not drift between them: it is what
12
+ * separates our locks from anything else living in ~/.flowviant, and a scan
13
+ * that stops matching sees an empty machine — so `uninstall --others` would
14
+ * remove the copy a live daemon runs from.
15
+ *
16
+ * A leaf (node builtins only), so a reader never has to load instance.mjs's
17
+ * takeover machinery to look at a file; instance.mjs re-exports all of it.
18
+ */
19
+
20
+ import * as nodeFs from 'node:fs';
21
+ import { createHash } from 'node:crypto';
22
+ import { homedir } from 'node:os';
23
+ import { join } from 'node:path';
24
+
25
+ /** Every daemon lock's file name: `daemon-` + 12 hex of the credential's hash. */
26
+ export const DAEMON_LOCK_RE = /^daemon-[0-9a-f]{12}\.lock$/;
27
+
28
+ /** Deliberately a HASH: a credential must never become a filename. */
29
+ export function instanceLockPath(fleetToken, home = homedir()) {
30
+ const key = createHash('sha256').update(String(fleetToken || 'anon')).digest('hex').slice(0, 12);
31
+ return join(home, '.flowviant', `daemon-${key}.lock`);
32
+ }
33
+
34
+ /**
35
+ * A lock record, or null: absent, truncated, half-written, or naming no
36
+ * positive integer pid (a pid of '12' is not a pid). Everything else the
37
+ * record carries — repoRoot, startedAt, entry, version, draining — is the
38
+ * caller's to read; none of it is required.
39
+ */
40
+ export function parseLockRecord(text) {
41
+ try {
42
+ const v = JSON.parse(text);
43
+ return v && Number.isInteger(v.pid) && v.pid > 0 ? v : null;
44
+ } catch {
45
+ return null;
46
+ }
47
+ }
48
+
49
+ /** The record in the lock file at `path`, or null (see parseLockRecord). */
50
+ export function readLockFile(path, { fs = nodeFs } = {}) {
51
+ try {
52
+ return parseLockRecord(fs.readFileSync(path, 'utf8'));
53
+ } catch {
54
+ return null;
55
+ }
56
+ }
57
+
58
+ /** The record in this credential's lock, or null. */
59
+ export function readLockHolder(fleetToken) {
60
+ return readLockFile(instanceLockPath(fleetToken));
61
+ }
62
+
63
+ /**
64
+ * Every daemon lock on this machine, sorted by name for a stable read:
65
+ * `[{ path, holder }]`, where `holder` is null for a file with no readable
66
+ * record (no caller reads that as a daemon). ONE scan, because three callers
67
+ * walk this directory for different reasons — instance.mjs looking for a
68
+ * neighbour in the same repo, `flowviant stop` sweeping the lot, uninstall
69
+ * protecting the copy a live daemon runs from. An unreadable (or absent)
70
+ * directory is not an error here, it is an empty machine. `fs` and `home` are
71
+ * injectable for uninstall's test seams. The manual WSL restart asks for
72
+ * `strict`: only a missing directory is empty; other scan failures throw.
73
+ */
74
+ export function listDaemonLocks({ fs = nodeFs, home = homedir(), strict = false } = {}) {
75
+ const dir = join(home, '.flowviant');
76
+ let names;
77
+ try {
78
+ names = fs.readdirSync(dir);
79
+ } catch (error) {
80
+ if (strict && error.code !== 'ENOENT') throw error;
81
+ return [];
82
+ }
83
+ return names
84
+ .filter((name) => DAEMON_LOCK_RE.test(name))
85
+ .sort()
86
+ .map((name) => {
87
+ const path = join(dir, name);
88
+ return { path, holder: readLockFile(path, { fs }) };
89
+ });
90
+ }
@@ -13,7 +13,7 @@ import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
13
13
  import { homedir } from 'node:os';
14
14
  import { join } from 'node:path';
15
15
  import { emitMachineEvent } from './daemonLogging.mjs';
16
- import { instanceLockPath } from './instance.mjs';
16
+ import { readLockHolder } from './daemonLocks.mjs';
17
17
 
18
18
  const store = () => join(homedir(), '.flowviant');
19
19
  const key = (projectId) => createHash('sha256').update(String(projectId)).digest('hex').slice(0, 16);
@@ -56,9 +56,9 @@ export function setRuntimeLimit(projectId, runtime, words) {
56
56
  return true;
57
57
  }
58
58
 
59
- /** The pid in the instance lock for this credential, or null. */
59
+ /** The pid in the instance lock for this credential, or null — read through
60
+ * the lock's one record parse, so a pid that is not a positive integer is no
61
+ * pid (2026-10-10, F090). */
60
62
  export function daemonLockPid(token) {
61
- try {
62
- return JSON.parse(readFileSync(instanceLockPath(token), 'utf8')).pid;
63
- } catch { return null; }
63
+ return readLockHolder(token)?.pid ?? null;
64
64
  }
@@ -16,13 +16,13 @@
16
16
  * - deployRunner.mjs the commands, their environment and log scrubbing,
17
17
  * the health check (and why a deploy's secrets are
18
18
  * the machine's own environment)
19
- * - deployWire.mjs the one `/fleet/deploy-*` POST
19
+ * - deployWire.mjs the lane's `/fleet/deploy-*` wire (post, claim)
20
20
  */
21
21
 
22
22
  import { DAEMON_INSTANCE } from './config.mjs';
23
23
  import { c, note, ok, warn } from './ui.mjs';
24
24
  import { scrub } from './uplinkScrub.mjs';
25
- import { post } from './deployWire.mjs';
25
+ import { claim, post } from './deployWire.mjs';
26
26
  import { runDeploy } from './deployRunner.mjs';
27
27
  import { workLanesStopped } from './standDownGate.mjs';
28
28
 
@@ -99,16 +99,13 @@ export function processDeployJobs(jobs, ctx) {
99
99
  void (async () => {
100
100
  let beat = null;
101
101
  try {
102
- // `instance` names THIS PROCESS. The pubkey cannot: it is the env
102
+ // `instance` names THIS PROCESS — the shared lease claim stamps it
103
+ // (fleetPost.mjs's `claimFleetLease`). The pubkey cannot: it is the env
103
104
  // keypair read from one file per home directory, so two daemons on one
104
105
  // box share it and a pubkey-only read-back let both "win" the claim
105
106
  // and run the same deploy twice concurrently.
106
- const claimed = await post('deploy-claim', {
107
- jobId: job.id,
108
- pubkey: ctx.myPubB64(),
109
- instance: DAEMON_INSTANCE,
110
- }).catch(() => null);
111
- if (!claimed?.claimed) return; // another daemon won the claim
107
+ const claimed = await claim('deploy-claim', { jobId: job.id, pubkey: ctx.myPubB64() });
108
+ if (!claimed) return; // another daemon won the claim, or none was made
112
109
  /**
113
110
  * A CLAIM THAT COMES BACK AFTER THE STAND-DOWN BEGAN IS NOT RUN. The
114
111
  * loop gate cannot see it (the claim was already on the wire), and the