flowviant 0.116.0 → 0.117.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 (135) hide show
  1. package/README.md +31 -12
  2. package/bin/cli.mjs +13 -17
  3. package/bin/lib/admission.mjs +7 -14
  4. package/bin/lib/agentInternalWait.mjs +83 -0
  5. package/bin/lib/agentPlan.mjs +56 -24
  6. package/bin/lib/agentPublish.mjs +12 -13
  7. package/bin/lib/agentTaskKinds.mjs +4 -7
  8. package/bin/lib/agySessions.mjs +3 -3
  9. package/bin/lib/artifactPreview.mjs +92 -18
  10. package/bin/lib/artifactReporter.mjs +16 -8
  11. package/bin/lib/artifacts.mjs +64 -60
  12. package/bin/lib/authproxy.mjs +2 -1
  13. package/bin/lib/childEnv.mjs +9 -4
  14. package/bin/lib/claude.mjs +10 -20
  15. package/bin/lib/claudePersonal.mjs +71 -6
  16. package/bin/lib/claudePosture.mjs +119 -42
  17. package/bin/lib/claudeSessions.mjs +7 -7
  18. package/bin/lib/claudeStream.mjs +7 -11
  19. package/bin/lib/cliLimit.mjs +2 -2
  20. package/bin/lib/codexHome.mjs +17 -0
  21. package/bin/lib/codexPersonal.mjs +44 -3
  22. package/bin/lib/codexRelay.mjs +21 -5
  23. package/bin/lib/codexResearch.mjs +180 -0
  24. package/bin/lib/config.mjs +37 -24
  25. package/bin/lib/conversationIds.mjs +51 -0
  26. package/bin/lib/credentials.mjs +2 -7
  27. package/bin/lib/daemonLocks.mjs +88 -0
  28. package/bin/lib/daemonState.mjs +5 -5
  29. package/bin/lib/deploy.mjs +6 -9
  30. package/bin/lib/deployCheckout.mjs +26 -22
  31. package/bin/lib/deployRunner.mjs +54 -18
  32. package/bin/lib/deployWire.mjs +32 -23
  33. package/bin/lib/desktopContract.mjs +3 -5
  34. package/bin/lib/env.mjs +104 -35
  35. package/bin/lib/fleet.mjs +7 -9
  36. package/bin/lib/fleetPost.mjs +172 -25
  37. package/bin/lib/fleetReports.mjs +130 -170
  38. package/bin/lib/fleetStartup.mjs +6 -6
  39. package/bin/lib/fleetWire.mjs +42 -0
  40. package/bin/lib/git.mjs +111 -61
  41. package/bin/lib/gitMergeIdentity.mjs +2 -8
  42. package/bin/lib/grant.mjs +23 -7
  43. package/bin/lib/holder.mjs +108 -81
  44. package/bin/lib/hooks/readGuard.mjs +41 -7
  45. package/bin/lib/install.mjs +80 -33
  46. package/bin/lib/instance.mjs +36 -83
  47. package/bin/lib/intakeDraft.mjs +4 -1
  48. package/bin/lib/knowledgeFileSync.mjs +31 -0
  49. package/bin/lib/knowledgeFiles.mjs +28 -83
  50. package/bin/lib/landed.mjs +22 -29
  51. package/bin/lib/launchCommand.mjs +91 -0
  52. package/bin/lib/listeners.mjs +0 -5
  53. package/bin/lib/localSessions.mjs +52 -39
  54. package/bin/lib/login.mjs +22 -12
  55. package/bin/lib/loginPath.mjs +22 -17
  56. package/bin/lib/machineListing.mjs +6 -3
  57. package/bin/lib/mcp-cli.mjs +9 -10
  58. package/bin/lib/noWindowEnv.mjs +23 -3
  59. package/bin/lib/ownedDirs.mjs +99 -0
  60. package/bin/lib/pinnedCapture.mjs +383 -0
  61. package/bin/lib/prWorkflow.mjs +94 -17
  62. package/bin/lib/preflight.mjs +8 -25
  63. package/bin/lib/preview.mjs +19 -25
  64. package/bin/lib/previewGatePolicy.mjs +34 -9
  65. package/bin/lib/previewRegistry.mjs +4 -11
  66. package/bin/lib/printable.mjs +70 -7
  67. package/bin/lib/procRegistry.mjs +33 -13
  68. package/bin/lib/procScan.mjs +2 -3
  69. package/bin/lib/procStat.mjs +76 -0
  70. package/bin/lib/processGroup.mjs +48 -0
  71. package/bin/lib/processes.mjs +10 -24
  72. package/bin/lib/projectToolRuntimes.mjs +101 -16
  73. package/bin/lib/projectTools.mjs +50 -32
  74. package/bin/lib/prompts.mjs +41 -19
  75. package/bin/lib/repoPath.mjs +19 -0
  76. package/bin/lib/repoState.mjs +17 -19
  77. package/bin/lib/runTurn.mjs +268 -210
  78. package/bin/lib/runtimeAntigravity.mjs +16 -5
  79. package/bin/lib/runtimeCapabilities.mjs +140 -20
  80. package/bin/lib/runtimeClaude.mjs +55 -5
  81. package/bin/lib/runtimeCodex.mjs +245 -205
  82. package/bin/lib/runtimeDetection.mjs +2 -2
  83. package/bin/lib/runtimeEvents.mjs +16 -10
  84. package/bin/lib/runtimeLimits.mjs +5 -6
  85. package/bin/lib/runtimeOfferings.mjs +24 -11
  86. package/bin/lib/runtimes.mjs +10 -0
  87. package/bin/lib/shipSweep.mjs +5 -3
  88. package/bin/lib/shot.mjs +91 -17
  89. package/bin/lib/sleep.mjs +11 -0
  90. package/bin/lib/startProject.mjs +5 -5
  91. package/bin/lib/statePaths.mjs +40 -0
  92. package/bin/lib/tty.mjs +25 -15
  93. package/bin/lib/turnLock.mjs +7 -14
  94. package/bin/lib/turnProfile.mjs +1 -1
  95. package/bin/lib/ui.mjs +0 -3
  96. package/bin/lib/uninstall.mjs +30 -26
  97. package/bin/lib/update.mjs +79 -27
  98. package/bin/lib/uplinkScrub.mjs +296 -31
  99. package/bin/lib/vault.mjs +78 -10
  100. package/bin/lib/vaultDiff.mjs +5 -2
  101. package/bin/lib/views.mjs +37 -12
  102. package/bin/lib/wikiRunner.mjs +40 -61
  103. package/bin/lib/work.mjs +17 -7
  104. package/bin/lib/workAdoptCarry.mjs +2 -4
  105. package/bin/lib/workAgentCheck.mjs +104 -57
  106. package/bin/lib/workAgentMerges.mjs +62 -57
  107. package/bin/lib/workAgentPlans.mjs +71 -139
  108. package/bin/lib/workAgentPrecheck.mjs +71 -138
  109. package/bin/lib/workAgentPublish.mjs +6 -8
  110. package/bin/lib/workAgentTurnBegun.mjs +15 -11
  111. package/bin/lib/workAgentTurnExecution.mjs +46 -53
  112. package/bin/lib/workAgentTurnReports.mjs +9 -37
  113. package/bin/lib/workAgentTurns.mjs +4 -3
  114. package/bin/lib/workAttachments.mjs +66 -11
  115. package/bin/lib/workBrain.mjs +9 -4
  116. package/bin/lib/workCappedTurn.mjs +109 -0
  117. package/bin/lib/workChildren.mjs +5 -5
  118. package/bin/lib/workCommandAudit.mjs +11 -2
  119. package/bin/lib/workDiffs.mjs +52 -6
  120. package/bin/lib/workIntake.mjs +35 -60
  121. package/bin/lib/workPlaceReports.mjs +7 -4
  122. package/bin/lib/workPlaces.mjs +96 -30
  123. package/bin/lib/workPreviews.mjs +47 -112
  124. package/bin/lib/workProcesses.mjs +8 -35
  125. package/bin/lib/workPullRequests.mjs +37 -56
  126. package/bin/lib/workReportQueue.mjs +100 -148
  127. package/bin/lib/workRetire.mjs +2 -1
  128. package/bin/lib/workSessionRuntime.mjs +38 -37
  129. package/bin/lib/workSessionTurns.mjs +111 -189
  130. package/bin/lib/workShip.mjs +43 -23
  131. package/bin/lib/workTurnShape.mjs +163 -0
  132. package/npm-shrinkwrap.json +59 -0
  133. package/package.json +3 -2
  134. package/bin/lib/fleetJobs.mjs +0 -120
  135. package/bin/lib/patchRevert.mjs +0 -60
@@ -20,7 +20,15 @@ import {
20
20
  unlinkSync,
21
21
  } from 'node:fs';
22
22
  import { join } from 'node:path';
23
- import { isCurrent, localSha, syncVerifiedFile, writeAtomic } from './knowledgeFileSync.mjs';
23
+ import {
24
+ isCurrent,
25
+ localSha,
26
+ recordSyncState,
27
+ syncVerifiedFile,
28
+ writeAtomic,
29
+ writeTextIfChanged,
30
+ } from './knowledgeFileSync.mjs';
31
+ import { ensureOwnDir, flowviantParentState, realDirState } from './ownedDirs.mjs';
24
32
  import {
25
33
  INSTRUCTIONS_FILE,
26
34
  KNOWLEDGE_DIR,
@@ -50,29 +58,15 @@ const MARKER = '.flowviant/knowledge.rev';
50
58
  * So the parent is checked, never repaired.
51
59
  */
52
60
  function flowviantDirOk(checkoutDir) {
53
- try {
54
- const st = lstatSync(join(checkoutDir, '.flowviant'));
55
- return st.isDirectory() && !st.isSymbolicLink();
56
- } catch {
57
- return true; // absent — ours to create
58
- }
61
+ // Absent is ours to create; the three states are ownedDirs.mjs's (2026-10-10).
62
+ return flowviantParentState(checkoutDir) !== 'other';
59
63
  }
60
64
 
61
65
  /** The library directory, as a real directory. The PARENT is checked by the
62
66
  * caller; the library path itself is OURS, so a symlink or a file planted
63
- * there is removed (unlinked — the link, never its target) rather than
64
- * followed. */
65
- function ensureDir(checkoutDir) {
66
- const dir = join(checkoutDir, KNOWLEDGE_DIR);
67
- try {
68
- const st = lstatSync(dir);
69
- if (st.isSymbolicLink() || !st.isDirectory()) unlinkSync(dir);
70
- } catch {
71
- /* absent */
72
- }
73
- mkdirSync(dir, { recursive: true });
74
- return dir;
75
- }
67
+ * there is removed (the link, never its target) rather than followed —
68
+ * `ensureOwnDir`, ownedDirs.mjs. */
69
+ const ensureDir = (checkoutDir) => ensureOwnDir(join(checkoutDir, KNOWLEDGE_DIR));
76
70
 
77
71
  /**
78
72
  * THE MARKER SAYS WHICH REV WAS WRITTEN, AND WHETHER THE LIBRARY WAS PART OF
@@ -109,6 +103,10 @@ export function knowledgeMarkerHasLibrary(checkoutDir) {
109
103
  }
110
104
 
111
105
  export function writeKnowledgeMarker(checkoutDir, rev, lib = false) {
106
+ // The sync's own parent rule, here too (2026-10-10, the audit's F100): its
107
+ // one caller runs after a sync that refused a linked `.flowviant`, but the
108
+ // marker is a write, and a write checks for itself.
109
+ if (!flowviantDirOk(checkoutDir)) return;
112
110
  try {
113
111
  mkdirSync(join(checkoutDir, '.flowviant'), { recursive: true });
114
112
  writeAtomic(join(checkoutDir, MARKER), `${rev}${lib ? ':lib' : ''}\n`);
@@ -120,17 +118,7 @@ export function writeKnowledgeMarker(checkoutDir, rev, lib = false) {
120
118
 
121
119
  /** A library subdirectory as a real directory — ours, like the library's own
122
120
  * path: a symlink or a file planted there is removed, never followed. */
123
- function ensureSubdir(dir, name) {
124
- const path = join(dir, name);
125
- try {
126
- const st = lstatSync(path);
127
- if (st.isSymbolicLink() || !st.isDirectory()) unlinkSync(path);
128
- } catch {
129
- /* absent */
130
- }
131
- mkdirSync(path, { recursive: true });
132
- return path;
133
- }
121
+ const ensureSubdir = (dir, name) => ensureOwnDir(join(dir, name));
134
122
 
135
123
  function ensureLibraryParents(dir, relativePath) {
136
124
  const parts = relativePath.split('/');
@@ -231,34 +219,19 @@ export async function syncKnowledge({ checkoutDir, manifest, fetchFile, maxBytes
231
219
  maxBytes: knowledgeMaxFor(f.name, maxBytes),
232
220
  fetch: () => fetchFile(f.id),
233
221
  });
234
- if (state === 'refused') {
222
+ // Stale or failed clears `ok` and is retried (a stale file is better than
223
+ // a missing one until the retry lands); a refusal is listed as one.
224
+ if (recordSyncState(result, state, f.local) === 'refused') {
235
225
  // Permanent for this rev, and NOT a reason to delete the rest. Any stale
236
226
  // local copy under this name goes, because it is not the file the
237
227
  // manifest now names.
238
- result.refused.push(f.local);
239
228
  keep.delete(f.local);
240
- } else if (state === 'wrote') {
241
- result.wrote.push(f.local);
242
- } else if (!isCurrent(state)) {
243
- // A stale file is better than a missing one until the retry lands.
244
- result.ok = false;
245
- result.failed.push(f.local);
246
229
  }
247
230
  }
248
231
 
249
232
  if (instructions) {
250
- const path = join(dir, INSTRUCTIONS_FILE);
251
233
  const body = instructions.endsWith('\n') ? instructions : `${instructions}\n`;
252
- let same = false;
253
- try {
254
- same = lstatSync(path).isFile() && readFileSync(path, 'utf8') === body;
255
- } catch {
256
- /* absent */
257
- }
258
- if (!same) {
259
- writeAtomic(path, body);
260
- result.wrote.push(INSTRUCTIONS_FILE);
261
- }
234
+ if (writeTextIfChanged(join(dir, INSTRUCTIONS_FILE), body)) result.wrote.push(INSTRUCTIONS_FILE);
262
235
  }
263
236
 
264
237
  if (library !== null) {
@@ -329,16 +302,10 @@ async function syncLibrary({ dir, items, fetchFile, maxBytes, keep, result }) {
329
302
  fetch: () => fetchFile(it.id, { library: true }),
330
303
  beforeWrite: () => ensureLibraryParents(dir, it.path),
331
304
  });
332
- if (entryState === 'refused') {
333
- result.refused.push(it.path);
305
+ if (recordSyncState(result, entryState, it.path) === 'refused') {
334
306
  wanted[sub].delete(file);
335
307
  continue;
336
308
  }
337
- if (entryState === 'wrote') result.wrote.push(it.path);
338
- else if (!isCurrent(entryState)) {
339
- result.ok = false;
340
- result.failed.push(it.path);
341
- }
342
309
  let complete = isCurrent(entryState);
343
310
  // THE PREVIEW: measured by the server, ours to cap — an oversized one is
344
311
  // a failure to retry, never a refusal. Not required for the catalog: the
@@ -353,11 +320,7 @@ async function syncLibrary({ dir, items, fetchFile, maxBytes, keep, result }) {
353
320
  fetch: () => fetchFile(it.id, { library: true, preview: true }),
354
321
  beforeWrite: () => ensureLibraryParents(dir, it.previewPath),
355
322
  });
356
- if (previewState === 'wrote') result.wrote.push(it.previewPath);
357
- else if (!isCurrent(previewState)) {
358
- result.ok = false;
359
- result.failed.push(it.previewPath);
360
- }
323
+ recordSyncState(result, previewState, it.previewPath);
361
324
  }
362
325
  // THE BUNDLE'S SIDECARS are REQUIRED: a design whose model failed to land
363
326
  // is not a complete current design, so it is not catalogued as one. The hash
@@ -375,14 +338,7 @@ async function syncLibrary({ dir, items, fetchFile, maxBytes, keep, result }) {
375
338
  fetch: () => fetchFile(it.id, { library: true, fileIndex: f.index }),
376
339
  beforeWrite: () => ensureLibraryParents(dir, f.path),
377
340
  });
378
- if (fileState === 'refused') {
379
- result.refused.push(f.path);
380
- wanted[sub].delete(rel);
381
- } else if (fileState === 'wrote') result.wrote.push(f.path);
382
- else if (!isCurrent(fileState)) {
383
- result.ok = false;
384
- result.failed.push(f.path);
385
- }
341
+ if (recordSyncState(result, fileState, f.path) === 'refused') wanted[sub].delete(rel);
386
342
  // A REFUSED sidecar is permanent for this rev and said in `refused`, the
387
343
  // way `libraryStale` already stops expecting it — blocking the catalog on
388
344
  // it would leave LIBRARY.md missing and re-sync the rev every poll.
@@ -401,17 +357,7 @@ async function syncLibrary({ dir, items, fetchFile, maxBytes, keep, result }) {
401
357
  const catalog = renderLibraryCatalog(onDisk);
402
358
  if (catalog) {
403
359
  keep.add(LIBRARY_FILE);
404
- const path = join(dir, LIBRARY_FILE);
405
- let same = false;
406
- try {
407
- same = lstatSync(path).isFile() && readFileSync(path, 'utf8') === catalog;
408
- } catch {
409
- /* absent */
410
- }
411
- if (!same) {
412
- writeAtomic(path, catalog);
413
- result.wrote.push(LIBRARY_FILE);
414
- }
360
+ if (writeTextIfChanged(join(dir, LIBRARY_FILE), catalog)) result.wrote.push(LIBRARY_FILE);
415
361
  }
416
362
  }
417
363
 
@@ -474,8 +420,7 @@ export function libraryStale(checkoutDir, manifest, { synced, refused }) {
474
420
  let parent = dir;
475
421
  for (const part of p.slice(dir.length + 1).split('/').slice(0, -1)) {
476
422
  parent = join(parent, part);
477
- const st = lstatSync(parent);
478
- if (st.isSymbolicLink() || !st.isDirectory()) return false;
423
+ if (realDirState(parent) !== 'dir') return false;
479
424
  }
480
425
  return lstatSync(p).isFile();
481
426
  } catch {
@@ -23,16 +23,16 @@
23
23
  * report nothing — ignorance is never turned into a state.
24
24
  */
25
25
 
26
- import { execFileSync } from 'node:child_process';
27
26
  import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
28
27
  import { join } from 'node:path';
29
28
  import { homedir } from 'node:os';
30
29
  import { createHash } from 'node:crypto';
31
- import { git, baseBranchName } from './git.mjs';
30
+ import { git, baseBranchName, GIT_WIDE_BUFFER, OBJECT_ID_RE } from './git.mjs';
32
31
  import { commitRecord, stripDelims, taskIdsFromMessage } from './worktreeDiff.mjs';
33
32
  import { warn } from './ui.mjs';
34
- import { FLEET_URL, FLEET_TOKEN, USER_AGENT } from './config.mjs';
35
- import { fleetEndpoint } from './fleetWire.mjs';
33
+ import { FLEET_URL } from './config.mjs';
34
+ import { fleetEndpoint, isConsideredRefusal, isNotNow } from './fleetWire.mjs';
35
+ import { postFleetJson } from './fleetPost.mjs';
36
36
 
37
37
  const LANDED_URL = fleetEndpoint('base-landed', FLEET_URL);
38
38
  /** The server accepts 50 per report. A bigger range walks OLDEST-FIRST in
@@ -40,11 +40,15 @@ const LANDED_URL = fleetEndpoint('base-landed', FLEET_URL);
40
40
  * so the remainder is picked up on the next beat rather than skipped forever
41
41
  * — a trailered card in commit 51 of a big catch-up still closes. */
42
42
  const MAX_COMMITS = 50;
43
- const SHA_RE = /^[0-9a-f]{7,64}$/i;
43
+ /** The shape a tip or a walked sha must have — git.mjs's named object-id
44
+ * shape (7–64 hex, either case), the one this module always used. */
45
+ const SHA_RE = OBJECT_ID_RE;
44
46
 
45
47
  export function createLandedObserver({ repoRoot, baseRef }) {
46
- // Keyed like the worktree base dir: one state file per checkout, so two
47
- // repos on one box never share a tip.
48
+ // One state file per checkout, so two repos on one box never share a tip.
49
+ // Its own 8-hex key — NOT the worktree home's `<basename>-<8 hex>`
50
+ // (statePaths.mjs) — and it stays as it is, so a persisted tip is never
51
+ // orphaned by a rename.
48
52
  const key = createHash('sha256').update(String(repoRoot)).digest('hex').slice(0, 8);
49
53
  const stateFile = join(homedir(), '.flowviant', `landed-${key}.json`);
50
54
  let unsupported = false; // 404 once → an older server; quiet until restart
@@ -79,16 +83,10 @@ export function createLandedObserver({ repoRoot, baseRef }) {
79
83
  /** git with the buffer the WALK needs. git.mjs's call takes execFileSync's
80
84
  * default 1MiB cap, and a catch-up range's `%B` bodies blew through it —
81
85
  * the throw landed in the reseed catch below, which skipped the whole range
82
- * and silently lost every trailer in it. 8MB is repoState's number for the
83
- * same reason; rev-list output at 41 bytes a commit clears ~200k commits
84
- * before it matters. */
85
- const gitWide = (args) =>
86
- execFileSync('git', args, {
87
- cwd: repoRoot,
88
- encoding: 'utf8',
89
- stdio: ['ignore', 'pipe', 'pipe'],
90
- maxBuffer: 8 * 1024 * 1024,
91
- }).trim();
86
+ * and silently lost every trailer in it. GIT_WIDE_BUFFER (8MB, git.mjs) is
87
+ * the shared number for the same reason; rev-list output at 41 bytes a
88
+ * commit clears ~200k commits before it matters. */
89
+ const gitWide = (args) => git(args, repoRoot, { maxBuffer: GIT_WIDE_BUFFER });
92
90
 
93
91
  /** New non-merge commits in from..to, OLDEST FIRST — the next batch of at
94
92
  * most MAX_COMMITS, plus the tip the state should advance to when the range
@@ -175,16 +173,11 @@ export function createLandedObserver({ repoRoot, baseRef }) {
175
173
  const reportedTip = walked.tipAfter ?? tip;
176
174
  inFlight = true;
177
175
  try {
178
- const res = await fetch(LANDED_URL, {
179
- method: 'POST',
180
- headers: {
181
- Authorization: `Bearer ${FLEET_TOKEN}`,
182
- 'User-Agent': USER_AGENT,
183
- 'Content-Type': 'application/json',
184
- },
185
- signal: AbortSignal.timeout(20_000),
186
- body: JSON.stringify({ base: baseBranchName(ref), tip: reportedTip, commits }),
187
- });
176
+ const res = await postFleetJson(
177
+ LANDED_URL,
178
+ { base: baseBranchName(ref), tip: reportedTip, commits },
179
+ { timeoutMs: 20_000 }
180
+ );
188
181
  if (res.status === 404) {
189
182
  unsupported = true;
190
183
  return;
@@ -201,14 +194,14 @@ export function createLandedObserver({ repoRoot, baseRef }) {
201
194
  for (const r of j?.data?.deployRefused ?? []) {
202
195
  warn(`deploy-on-merge refused for target "${r?.targetId}": ${r?.reason}`);
203
196
  }
204
- } else if (res.status === 408 || res.status === 429) {
197
+ } else if (isNotNow(res.status)) {
205
198
  // A TIMEOUT OR A RATE LIMIT IS "NOT NOW", NOT "NEVER". Both used to
206
199
  // land in the drop below, so one 429 — a busy operator IP past the
207
200
  // limiter — skipped the range for good: its trailers and receipt shas
208
201
  // never closed their cards (in PR mode the observer is the ONLY road
209
202
  // to Done) and deploy-on-merge for that tip was never queued. Keep the
210
203
  // stored tip, exactly as a 5xx does; the next beat re-walks it.
211
- } else if (res.status >= 400 && res.status < 500) {
204
+ } else if (isConsideredRefusal(res.status)) {
212
205
  // A persistent 4xx (deploy skew, a payload this server refuses) would
213
206
  // otherwise re-send the same poison range on every beat forever.
214
207
  // Drop THIS BATCH — to `reportedTip`, the last commit it carried, never
@@ -1,4 +1,6 @@
1
+ import { spawn as nodeSpawn } from 'node:child_process';
1
2
  import { fileURLToPath } from 'node:url';
3
+ import { terminalForeground } from './tty.mjs';
2
4
 
3
5
  /** The updater and terminal advice share one detection of the launch channel. */
4
6
  export function runningViaNpx() {
@@ -13,6 +15,17 @@ export function runningViaNpx() {
13
15
  return /\bnpx\b/.test(ua) || /[\\/]_npx[\\/]/.test(argv1) || /[\\/]_npx[\\/]/.test(self);
14
16
  }
15
17
 
18
+ /** Bun marks compiled entrypoints with a virtual /$bunfs/ script path. */
19
+ export function runningCompiledBinary() {
20
+ return Boolean(process.versions.bun && process.argv[1]?.startsWith('/$bunfs/'));
21
+ }
22
+
23
+ /** How this copy was installed — the tray's `installChannel` and the updater's
24
+ * branch read the same answer. */
25
+ export function installChannel() {
26
+ return runningCompiledBinary() ? 'binary' : runningViaNpx() ? 'npx' : 'npm-g';
27
+ }
28
+
16
29
  /** Name the command that launched this process when giving a person a next step. */
17
30
  export function launchCommand() {
18
31
  return 'flowviant';
@@ -21,3 +34,81 @@ export function launchCommand() {
21
34
  export function terminalCommand(args = '') {
22
35
  return `${launchCommand()}${args ? ` ${args}` : ''}`;
23
36
  }
37
+
38
+ /**
39
+ * The argv that starts THIS flowviant again with `args`: the script path under
40
+ * node (`process.execPath` is node), nothing under the compiled binary (where
41
+ * `process.execPath` IS flowviant and argv[1] is Bun's virtual /$bunfs/ path).
42
+ */
43
+ export function selfArgv(args = []) {
44
+ return [...(runningCompiledBinary() ? [] : [process.argv[1]]), ...args];
45
+ }
46
+
47
+ /** The signals a relaunch proxy passes on to the process it stands in for. */
48
+ export const RELAY_SIGNALS = Object.freeze(['SIGINT', 'SIGTERM', 'SIGHUP']);
49
+
50
+ /**
51
+ * RELAUNCH, AND STAND IN FOR THE CHILD UNTIL IT EXITS (2026-10-10, F068/F124).
52
+ *
53
+ * Four paths restart flowviant as a child and keep this process alive as a
54
+ * thin foreground proxy, so the person's shell (or the tray, or a service
55
+ * manager) keeps watching one process: login then start, the start path's
56
+ * inline login, the Bun PATH adoption (loginPath.mjs) and the self-update
57
+ * (update.mjs). They were four hand copies, and only one of them passed
58
+ * signals on or reported a signal death as a failure.
59
+ *
60
+ * EXIT `status ?? (signal ? 1 : 0)`. A child killed by a signal (the OOM
61
+ * killer, a SIGKILL) is a FAILURE: the daemon's exit codes are argued against
62
+ * `Restart=on-failure` (holder.mjs, fleet.mjs — 0 means asked for, nonzero
63
+ * means relaunch), and a proxy that read a killed successor as 0 left the
64
+ * machine dark after any auto-update.
65
+ *
66
+ * SIGINT, SIGTERM AND SIGHUP REACH THE CHILD. A tray or supervisor that
67
+ * signals only this pid would otherwise leave the child running orphaned. Every
68
+ * listener already on those signals is removed first: on the self-update path
69
+ * the fleet's own handlers are still attached, and they would run a second
70
+ * stand-down and exit here without ever telling the child.
71
+ *
72
+ * …EXCEPT A Ctrl+C THE TERMINAL ALREADY DELIVERED. The child shares this
73
+ * process group, so when this proxy is MEASURED in its terminal's foreground
74
+ * group, a terminal SIGINT reached the child directly; relaying it as well
75
+ * would be a SECOND Ctrl+C, which the daemon reads as "leave now, without the
76
+ * deploy's outcome" (standDownExit.mjs). No terminal, a background job, or an
77
+ * unknown answer relays it. SIGTERM and SIGHUP are always relayed: a repeated
78
+ * SIGTERM changes nothing in the daemon, and SIGHUP ends it on the first.
79
+ *
80
+ * Every seam is injectable for the tests; nothing in the daemon passes them.
81
+ */
82
+ export function relaunchSelf({
83
+ cmd = process.execPath,
84
+ argv,
85
+ env = process.env,
86
+ cwd,
87
+ forwardSignals = true,
88
+ spawn = nodeSpawn,
89
+ exit = (code) => process.exit(code),
90
+ signals = process,
91
+ terminalDelivers = () => terminalForeground() === true,
92
+ } = {}) {
93
+ const child = spawn(cmd, argv, { stdio: 'inherit', env, ...(cwd ? { cwd } : {}) });
94
+ if (forwardSignals) {
95
+ for (const signal of RELAY_SIGNALS) {
96
+ signals.removeAllListeners(signal);
97
+ signals.on(signal, () => {
98
+ if (signal === 'SIGINT' && terminalDelivers()) return;
99
+ try {
100
+ child.kill(signal);
101
+ } catch {
102
+ /* gone */
103
+ }
104
+ });
105
+ }
106
+ }
107
+ return new Promise((resolve) => {
108
+ child.on('exit', (status, signal) => {
109
+ const code = status ?? (signal ? 1 : 0);
110
+ exit(code);
111
+ resolve(code);
112
+ });
113
+ });
114
+ }
@@ -449,11 +449,6 @@ export function measureListeners(worktree, io) {
449
449
  };
450
450
  }
451
451
 
452
- /** The capped rows alone, for callers with nowhere to put a total. */
453
- export function listenersIn(worktree) {
454
- return measureListeners(worktree).rows;
455
- }
456
-
457
452
  /** Does this platform measure listeners at all? The web must render no preview
458
453
  * affordance where the answer is no, rather than an empty one. */
459
454
  export function listenersSupported() {
@@ -20,9 +20,11 @@
20
20
 
21
21
  import { readdirSync, readFileSync, realpathSync } from 'node:fs';
22
22
  import { join } from 'node:path';
23
- import { execFileSync } from 'node:child_process';
24
23
  import { scanClaudeSessions } from './claudeSessions.mjs';
25
24
  import { scanAgyConversations } from './agySessions.mjs';
25
+ import { isPathInside } from './repoPath.mjs';
26
+ import { SESSION_MARKERS } from './conversationIds.mjs';
27
+ import { git } from './git.mjs';
26
28
 
27
29
  const REPORT_CAP = 30;
28
30
  // ENDED sessions are the adoptable inventory, and the useful ones are FRESH:
@@ -32,21 +34,47 @@ const REPORT_CAP = 30;
32
34
  // and the strip read as session history instead of presence.)
33
35
  const ENDED_WINDOW_MS = 48 * 60 * 60 * 1000;
34
36
 
35
- /** Path-prefix containment on already-realpath'd absolute paths. */
36
- const inside = (p, root) => p === root || p.startsWith(root.endsWith('/') ? root : `${root}/`);
37
-
38
37
  /**
39
- * THE MARKER NAMES THE DAEMON PINS A TAB'S CONVERSATION UNDER.
38
+ * THE ADOPT FENCE — inside the repo, outside the daemon's own worktrees — on
39
+ * BOTH sides of an adoption: this scan OFFERS what passes it, and the session
40
+ * lane re-checks the cwd the server hands back before it forks anything
41
+ * (workSessionTurns.mjs). The two used to be written separately, each with
42
+ * its own realpath-or-literal resolution and its own containment predicate,
43
+ * agreeing only by hand; a drift would offer sessions the machine then
44
+ * refuses, or accept one it would never have offered (audit 2026-10-10, F106).
40
45
  *
41
- * One owner, because two things read them and a drift here is silent: `workSessionTurns.mjs`
42
- * WRITES them (`sessionMetaPath(wt, <name>, sessionId)`) and this file READS
43
- * them to know which conversations are its own.
46
+ * `repoRoot` and each excluded directory are realpath'd ONCE here, falling
47
+ * back to the literal spelling when they cannot be resolved (`resolved` says
48
+ * which, for the agy reader whose cwd registry is global). `check(p)` takes an
49
+ * already-resolved path and answers:
50
+ * 'outside' — not inside the repo (or the repo has no usable root)
51
+ * 'ours' — inside the repo AND inside one of the daemon's own directories
52
+ * 'ok' — a human's checkout in this repo: adoptable
44
53
  */
45
- export const SESSION_MARKERS = [
46
- 'flowviant-claude-session',
47
- 'flowviant-codex-thread',
48
- 'flowviant-agy-conversation',
49
- ];
54
+ export function adoptFence({ repoRoot, excludeDirs = [] }) {
55
+ let realRoot;
56
+ let resolved = true;
57
+ try {
58
+ realRoot = realpathSync(repoRoot);
59
+ } catch {
60
+ resolved = false;
61
+ realRoot = String(repoRoot ?? '');
62
+ }
63
+ const excludes = [];
64
+ for (const d of excludeDirs) {
65
+ if (!d) continue;
66
+ try {
67
+ excludes.push(realpathSync(d));
68
+ } catch {
69
+ excludes.push(String(d)); // not on disk yet — keep the literal fence
70
+ }
71
+ }
72
+ const check = (p) => {
73
+ if (!realRoot || !isPathInside(p, realRoot)) return 'outside';
74
+ return excludes.some((e) => isPathInside(p, e)) ? 'ours' : 'ok';
75
+ };
76
+ return { realRoot, resolved, check };
77
+ }
50
78
 
51
79
  /**
52
80
  * EVERY CONVERSATION THIS DAEMON STARTED, so the adopt strip never offers you
@@ -65,19 +93,19 @@ export const SESSION_MARKERS = [
65
93
  * be offered at all.
66
94
  *
67
95
  * Excluded by conversation ID rather than by directory, because the directory
68
- * is shared with exactly the sessions we want to keep offering.
96
+ * is shared with exactly the sessions we want to keep offering. The ids are
97
+ * read off the markers the session lane pins each tab's conversation under —
98
+ * `SESSION_MARKERS`, the one list the writer's names come from too
99
+ * (conversationIds.mjs, audit 2026-10-10 F157), so the two cannot drift.
69
100
  */
70
101
  export function ourConversationIds(repoRoot, gitDirs = []) {
71
102
  const ids = new Set();
72
103
  const dirs = new Set(gitDirs.filter(Boolean));
73
104
  try {
74
- dirs.add(
75
- execFileSync('git', ['rev-parse', '--absolute-git-dir'], {
76
- cwd: repoRoot,
77
- encoding: 'utf8',
78
- timeout: 5_000,
79
- }).trim()
80
- );
105
+ // Bounded, and QUIET since 2026-10-10 (F107): this used to set no
106
+ // `stdio`, so git's "not a git repository" for a vanished cwd landed on
107
+ // the daemon's terminal from inside the poll loop's best-effort tail.
108
+ dirs.add(git(['rev-parse', '--absolute-git-dir'], repoRoot, { timeout: 5_000, stdio: ['ignore', 'pipe', 'ignore'] }));
81
109
  } catch {
82
110
  /* not a repo, or git unavailable — the fence is simply empty */
83
111
  }
@@ -119,25 +147,10 @@ export function scanLocalSessions({ repoRoot, excludeDirs = [], excludeIds }) {
119
147
  const mine = excludeIds instanceof Set ? excludeIds : new Set(excludeIds ?? []);
120
148
  // Claude falls back to the literal root when it cannot be resolved; agy,
121
149
  // whose cwd registry is global, reports nothing for an unresolvable root.
122
- let realRoot;
123
- let resolved = true;
124
- try {
125
- realRoot = realpathSync(repoRoot);
126
- } catch {
127
- resolved = false;
128
- realRoot = String(repoRoot ?? '');
129
- }
150
+ const fence = adoptFence({ repoRoot, excludeDirs });
151
+ const { realRoot, resolved } = fence;
130
152
  if (!realRoot) return [];
131
- const excludes = [];
132
- for (const d of excludeDirs) {
133
- if (!d) continue;
134
- try {
135
- excludes.push(realpathSync(d));
136
- } catch {
137
- excludes.push(String(d)); // not on disk yet — keep the literal fence
138
- }
139
- }
140
- const ours = (p) => inside(p, realRoot) && !excludes.some((e) => inside(p, e));
153
+ const ours = (p) => fence.check(p) === 'ok';
141
154
  const cutoff = Date.now() - ENDED_WINDOW_MS;
142
155
  const claude = scanClaudeSessions({ realRoot, ours, mine, cutoff, cap: REPORT_CAP });
143
156
  // agy rides in whatever room the cap leaves — Claude sessions first, they
package/bin/lib/login.mjs CHANGED
@@ -6,18 +6,17 @@
6
6
  * plain `flowviant` just runs — no token, no env var.
7
7
  */
8
8
 
9
- import { FLEET_URL, USER_AGENT, VERSION } from './config.mjs';
9
+ import { APP_URL, FLEET_URL, USER_AGENT, VERSION } from './config.mjs';
10
10
  import { saveLogin, detectRepoRoot, projectLabel, safeName, listStoredProjects, boundElsewhere, directoryTakenRefusal } from './credentials.mjs';
11
11
  import { c, info, ok, warn, fail } from './ui.mjs';
12
- import { sleep } from './claude.mjs';
12
+ import { sleep } from './sleep.mjs';
13
13
  import { launchCommand, terminalCommand } from './launchCommand.mjs';
14
14
  import { fleetEndpoint } from './fleetWire.mjs';
15
15
 
16
16
  const DEVICE_START = fleetEndpoint('device/start', FLEET_URL);
17
17
  const DEVICE_POLL = fleetEndpoint('device/poll', FLEET_URL);
18
- const APP_URL = process.env.FLOWVIANT_APP_URL || 'https://app.flowviant.com';
19
18
 
20
- async function post(url, body) {
19
+ async function postJson(url, body) {
21
20
  const res = await fetch(url, {
22
21
  method: 'POST',
23
22
  headers: { 'Content-Type': 'application/json', 'User-Agent': USER_AGENT },
@@ -29,7 +28,9 @@ async function post(url, body) {
29
28
  return j.data ?? j;
30
29
  }
31
30
 
32
- export async function runLogin({ thenStart = false, json = false, dir = process.cwd() } = {}) {
31
+ /** `post` is injectable so the device-code protocol can be driven without a
32
+ * server (login.test.mjs); every caller leaves it at the real one. */
33
+ export async function runLogin({ thenStart = false, json = false, dir = process.cwd(), post = postJson } = {}) {
33
34
  const event = (value) => { if (json) process.stdout.write(`${JSON.stringify(value)}\n`); };
34
35
  const selectedRepo = json ? detectRepoRoot(dir) : null;
35
36
  if (json && !selectedRepo) { event({ event: 'error', message: `No git repository found in ${dir}.` }); return { saved: false }; }
@@ -42,10 +43,18 @@ export async function runLogin({ thenStart = false, json = false, dir = process.
42
43
  fail(`couldn't reach Flowviant (${e.message}).`);
43
44
  process.exit(1);
44
45
  }
45
- const { deviceCode, userCode, intervalSeconds = 5, expiresInSeconds = 600 } = start;
46
- if (json && (typeof deviceCode !== 'string' || typeof userCode !== 'string' || userCode.length < 8)) {
47
- event({ event: 'error', message: 'Flowviant returned an invalid login code.' });
48
- return { saved: false };
46
+ const { deviceCode, userCode, intervalSeconds = 5, expiresInSeconds = 600 } = start ?? {};
47
+ // BOTH PRESENTERS CHECK THE SERVER'S ANSWER (2026-10-10, F109). The shape
48
+ // checks here and on the approval below were written for the tray's JSON
49
+ // path only, so the terminal path sliced an absent code into a TypeError
50
+ // stack and saved an approval with no project — `projects["undefined"]`,
51
+ // with the legacy mirror's working token overwritten by nothing. A
52
+ // malformed answer is a sentence and exit 1 in the terminal, as an
53
+ // unreachable server already was.
54
+ if (typeof deviceCode !== 'string' || typeof userCode !== 'string' || userCode.length < 8) {
55
+ if (json) { event({ event: 'error', message: 'Flowviant returned an invalid login code.' }); return { saved: false }; }
56
+ fail('Flowviant returned an invalid login code.');
57
+ process.exit(1);
49
58
  }
50
59
  const pretty = `${userCode.slice(0, 4)}-${userCode.slice(4)}`;
51
60
  // Where the control ACTUALLY is. It was "the Agents panel", a settings
@@ -73,9 +82,10 @@ export async function runLogin({ thenStart = false, json = false, dir = process.
73
82
  continue; // transient — keep polling
74
83
  }
75
84
  if (poll.status === 'approved') {
76
- if (json && (!poll.projectId || !(poll.machineToken ?? poll.fleetToken))) {
77
- event({ event: 'error', message: 'Flowviant did not return a project credential.' });
78
- return { saved: false };
85
+ if (!poll.projectId || !(poll.machineToken ?? poll.fleetToken)) {
86
+ if (json) { event({ event: 'error', message: 'Flowviant did not return a project credential.' }); return { saved: false }; }
87
+ fail('Flowviant did not return a project credential.');
88
+ process.exit(1);
79
89
  }
80
90
  // `machineToken` is the wire's new name; `fleetToken` is the one every
81
91
  // published daemon reads. The server dual-sends until DAEMON_MIN clears