flowviant 0.102.2 → 0.104.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 (141) hide show
  1. package/bin/cli.mjs +87 -474
  2. package/bin/lib/admission.mjs +19 -3
  3. package/bin/lib/agentPlan.mjs +2 -2
  4. package/bin/lib/agentTaskKinds.mjs +93 -0
  5. package/bin/lib/agySessions.mjs +158 -0
  6. package/bin/lib/artifactPolicy.mjs +44 -0
  7. package/bin/lib/artifactPreview.mjs +52 -0
  8. package/bin/lib/artifactReporter.mjs +163 -0
  9. package/bin/lib/artifacts.mjs +47 -227
  10. package/bin/lib/authReject.mjs +16 -4
  11. package/bin/lib/authproxy.mjs +47 -304
  12. package/bin/lib/boxIdentity.mjs +245 -0
  13. package/bin/lib/childEnv.mjs +11 -9
  14. package/bin/lib/claude.mjs +14 -851
  15. package/bin/lib/claudeAuth.mjs +3 -3
  16. package/bin/lib/claudePosture.mjs +367 -0
  17. package/bin/lib/claudeSessions.mjs +411 -0
  18. package/bin/lib/claudeStream.mjs +207 -0
  19. package/bin/lib/cliLimit.mjs +34 -0
  20. package/bin/lib/commandSpecs.mjs +173 -0
  21. package/bin/lib/config.mjs +11 -42
  22. package/bin/lib/credentialDate.mjs +22 -0
  23. package/bin/lib/credentials.mjs +21 -26
  24. package/bin/lib/daemonLogging.mjs +55 -0
  25. package/bin/lib/daemonState.mjs +64 -0
  26. package/bin/lib/deploy.mjs +148 -647
  27. package/bin/lib/deployCheckout.mjs +265 -0
  28. package/bin/lib/deployConfig.mjs +142 -0
  29. package/bin/lib/deployRunner.mjs +311 -0
  30. package/bin/lib/deployWire.mjs +41 -0
  31. package/bin/lib/desktopContract.mjs +56 -105
  32. package/bin/lib/env.mjs +49 -484
  33. package/bin/lib/fleet.mjs +184 -2396
  34. package/bin/lib/fleetJobs.mjs +120 -0
  35. package/bin/lib/fleetPost.mjs +40 -0
  36. package/bin/lib/fleetReports.mjs +392 -0
  37. package/bin/lib/fleetRoster.mjs +470 -0
  38. package/bin/lib/fleetStartup.mjs +196 -0
  39. package/bin/lib/fleetWake.mjs +45 -0
  40. package/bin/lib/fleetWire.mjs +46 -0
  41. package/bin/lib/git.mjs +34 -3
  42. package/bin/lib/gitMergeIdentity.mjs +51 -0
  43. package/bin/lib/grant.mjs +5 -3
  44. package/bin/lib/help.mjs +3 -125
  45. package/bin/lib/holder.mjs +482 -0
  46. package/bin/lib/instance.mjs +124 -46
  47. package/bin/lib/knowledge.mjs +16 -799
  48. package/bin/lib/knowledgeFetch.mjs +38 -0
  49. package/bin/lib/knowledgeFileSync.mjs +112 -0
  50. package/bin/lib/knowledgeFiles.mjs +487 -0
  51. package/bin/lib/knowledgeLibrary.mjs +249 -0
  52. package/bin/lib/landed.mjs +2 -1
  53. package/bin/lib/listeners.mjs +99 -141
  54. package/bin/lib/localSessions.mjs +30 -555
  55. package/bin/lib/login.mjs +3 -2
  56. package/bin/lib/loginPath.mjs +3 -5
  57. package/bin/lib/machineDisconnect.mjs +203 -0
  58. package/bin/lib/machineEnv.mjs +22 -0
  59. package/bin/lib/machineLimits.mjs +61 -0
  60. package/bin/lib/machineListing.mjs +338 -0
  61. package/bin/lib/machines.mjs +78 -455
  62. package/bin/lib/machinesCommand.mjs +214 -0
  63. package/bin/lib/mcp-cli.mjs +2 -1
  64. package/bin/lib/patchRevert.mjs +60 -0
  65. package/bin/lib/prWorkflow.mjs +157 -0
  66. package/bin/lib/preflight.mjs +2 -1
  67. package/bin/lib/pressurePolicy.mjs +104 -0
  68. package/bin/lib/preview.mjs +2 -272
  69. package/bin/lib/previewFramePolicy.mjs +112 -0
  70. package/bin/lib/previewGatePolicy.mjs +236 -0
  71. package/bin/lib/previewRegistry.mjs +235 -0
  72. package/bin/lib/printable.mjs +2 -2
  73. package/bin/lib/procRegistry.mjs +28 -18
  74. package/bin/lib/procScan.mjs +146 -0
  75. package/bin/lib/processes.mjs +29 -37
  76. package/bin/lib/projectToolRuntimes.mjs +138 -0
  77. package/bin/lib/projectTools.mjs +13 -88
  78. package/bin/lib/prompts.mjs +28 -42
  79. package/bin/lib/repoPath.mjs +26 -0
  80. package/bin/lib/resources.mjs +7 -69
  81. package/bin/lib/resumeLost.mjs +107 -0
  82. package/bin/lib/runTurn.mjs +324 -0
  83. package/bin/lib/runtimeAntigravity.mjs +230 -0
  84. package/bin/lib/runtimeCapabilities.mjs +334 -0
  85. package/bin/lib/runtimeClaude.mjs +126 -0
  86. package/bin/lib/runtimeCodex.mjs +217 -0
  87. package/bin/lib/runtimeDetection.mjs +121 -0
  88. package/bin/lib/runtimeEvents.mjs +482 -0
  89. package/bin/lib/runtimeOfferings.mjs +4 -1
  90. package/bin/lib/runtimes.mjs +42 -1402
  91. package/bin/lib/safeFileName.mjs +74 -0
  92. package/bin/lib/shipMerge.mjs +33 -2
  93. package/bin/lib/standDownExit.mjs +181 -0
  94. package/bin/lib/standDownGate.mjs +48 -0
  95. package/bin/lib/startProject.mjs +279 -0
  96. package/bin/lib/trace.mjs +3 -3
  97. package/bin/lib/turnLock.mjs +68 -0
  98. package/bin/lib/turnProfile.mjs +77 -0
  99. package/bin/lib/uninstall.mjs +55 -45
  100. package/bin/lib/uninstallCopies.mjs +112 -0
  101. package/bin/lib/update.mjs +30 -17
  102. package/bin/lib/uplinkScrub.mjs +265 -0
  103. package/bin/lib/vault.mjs +22 -151
  104. package/bin/lib/vaultDiff.mjs +183 -0
  105. package/bin/lib/versionOrder.mjs +42 -0
  106. package/bin/lib/views.mjs +35 -12
  107. package/bin/lib/wikiRunner.mjs +630 -0
  108. package/bin/lib/work.mjs +188 -3037
  109. package/bin/lib/workAdoptCarry.mjs +94 -0
  110. package/bin/lib/workAgentCheck.mjs +278 -0
  111. package/bin/lib/workAgentMerges.mjs +54 -142
  112. package/bin/lib/workAgentPlans.mjs +14 -12
  113. package/bin/lib/workAgentPrecheck.mjs +393 -0
  114. package/bin/lib/workAgentPublish.mjs +177 -0
  115. package/bin/lib/workAgentReview.mjs +8 -666
  116. package/bin/lib/workAgentTurnBegun.mjs +109 -0
  117. package/bin/lib/workAgentTurnExecution.mjs +604 -0
  118. package/bin/lib/workAgentTurnOutcome.mjs +207 -0
  119. package/bin/lib/workAgentTurnReports.mjs +189 -0
  120. package/bin/lib/workAgentTurns.mjs +92 -897
  121. package/bin/lib/workAttachments.mjs +93 -0
  122. package/bin/lib/workBrain.mjs +53 -0
  123. package/bin/lib/workChildren.mjs +110 -0
  124. package/bin/lib/workCommandAudit.mjs +62 -0
  125. package/bin/lib/workDiffs.mjs +2 -1
  126. package/bin/lib/workNarration.mjs +152 -0
  127. package/bin/lib/workPlaceReports.mjs +426 -0
  128. package/bin/lib/workPlaces.mjs +228 -0
  129. package/bin/lib/workPreviews.mjs +31 -9
  130. package/bin/lib/workProcesses.mjs +4 -5
  131. package/bin/lib/workPullRequests.mjs +52 -126
  132. package/bin/lib/workReportQueue.mjs +254 -0
  133. package/bin/lib/workRetire.mjs +185 -0
  134. package/bin/lib/workSessionGroups.mjs +166 -0
  135. package/bin/lib/workSessionRuntime.mjs +164 -0
  136. package/bin/lib/workSessionTokens.mjs +68 -0
  137. package/bin/lib/workSessionTurns.mjs +892 -0
  138. package/bin/lib/workShip.mjs +9 -29
  139. package/bin/lib/workToolLog.mjs +76 -0
  140. package/package.json +3 -2
  141. package/bin/lib/patch.mjs +0 -363
@@ -0,0 +1,74 @@
1
+ /**
2
+ * THE EXTENSION-PRESERVING 80-CHARACTER NAME, daemon side (2026-09-26, split
3
+ * out of knowledge.mjs, artifacts.mjs and work.mjs — SOLID F043).
4
+ *
5
+ * The rule is the server's `safeFileName` (apps/api/src/routes/
6
+ * sessionsAttachments.routes.ts), and it had THREE hand-kept daemon copies —
7
+ * one per caller — each re-stating the FNV-1a hash and the stem cut. A copy
8
+ * that drifted by one character would store a file under one name while the
9
+ * server re-derived another, which is exactly the disagreement the rule was
10
+ * adopted to end (2026-09-24: a bare `slice(0, 80)` cut an 84-character
11
+ * `….html` mockup to `….` and its bytes were discarded).
12
+ *
13
+ * Two layers, because the callers need different context rules around one
14
+ * common cut:
15
+ * - `cutKeepingExtension(clean)` — the cut alone, over an ALREADY sanitised
16
+ * name. Artifacts use only this: a bundle path is cut per component and
17
+ * keeps its safe spaces (see `safeArtifactName`).
18
+ * - `safeFileName(raw, fallback)` — the server's whole rule: last path
19
+ * component, `[^A-Za-z0-9._-]` → `_`, no leading dot or dash, then the cut.
20
+ * Each caller names its own fallback for an empty name ('file' for the
21
+ * knowledge library, 'attachment' for turn uploads — the server's).
22
+ *
23
+ * The server keeps its own independent validation (the daemon is published
24
+ * separately and a server deploy cannot upgrade it); `safeFileName.test.mjs`
25
+ * and the API's `sessionsAttachments.routes.test.ts` pin the SAME table of
26
+ * canonical outputs, so a change on one side fails the other's release check.
27
+ */
28
+
29
+ /** The longest name either function returns. */
30
+ export const SAFE_NAME_MAX = 80;
31
+ /** What counts as an extension worth keeping through a cut. */
32
+ const SAFE_EXT_RE = /^[A-Za-z0-9]{1,10}$/;
33
+
34
+ /** FNV-1a, 32-bit, as 8 hex — deterministic, so the same long name always maps
35
+ * to the same stored name. Verbatim against the server's `fnv1a8`. */
36
+ export function fnv1a8(s) {
37
+ let h = 0x811c9dc5;
38
+ for (let i = 0; i < s.length; i++) {
39
+ h ^= s.charCodeAt(i);
40
+ h = Math.imul(h, 0x01000193);
41
+ }
42
+ return (h >>> 0).toString(16).padStart(8, '0');
43
+ }
44
+
45
+ /**
46
+ * An over-long name is its stem, cut, plus an 8-hex hash of the WHOLE name,
47
+ * plus the extension: stable across calls, at most 80 characters, and a name
48
+ * that already fits is returned unchanged — so it is idempotent over its own
49
+ * output and the server's re-application is a no-op.
50
+ */
51
+ export function cutKeepingExtension(clean) {
52
+ if (clean.length <= SAFE_NAME_MAX) return clean;
53
+ const dot = clean.lastIndexOf('.');
54
+ const ext = dot > 0 && SAFE_EXT_RE.test(clean.slice(dot + 1)) ? clean.slice(dot + 1) : '';
55
+ const stem = ext ? clean.slice(0, dot) : clean;
56
+ const tag = `-${fnv1a8(clean)}`;
57
+ const room = SAFE_NAME_MAX - tag.length - (ext ? ext.length + 1 : 0);
58
+ return `${stem.slice(0, room)}${tag}${ext ? `.${ext}` : ''}`;
59
+ }
60
+
61
+ /**
62
+ * The server's `safeFileName`: drops every path separator, never starts with a
63
+ * dot or a dash, keeps the extension through a cut. `fallback` answers a name
64
+ * that sanitises to nothing.
65
+ */
66
+ export function safeFileName(raw, fallback) {
67
+ const clean = String(raw ?? '')
68
+ .split(/[\\/]/)
69
+ .pop()
70
+ .replace(/[^A-Za-z0-9._-]/g, '_')
71
+ .replace(/^[.-]+/, '');
72
+ if (!clean) return fallback;
73
+ return cutKeepingExtension(clean);
74
+ }
@@ -40,6 +40,34 @@ export function mergeOutward({
40
40
  /* not there — fine */
41
41
  }
42
42
  };
43
+ const hasOrigin = () => {
44
+ try { git(['remote', 'get-url', 'origin'], repoRoot); return true; } catch { return false; }
45
+ };
46
+ /**
47
+ * A REPOSITORY WITH NO REMOTE (2026-09-25). There is nowhere to push, so the
48
+ * merge lands on the local base branch itself. If base is checked out in the
49
+ * project folder it FAST-FORWARDS there, and only over a tree with no
50
+ * uncommitted tracked changes (refused in words otherwise: moving a branch
51
+ * under someone's edits is not a merge anybody approved); if it is not
52
+ * checked out, the ref moves by compare-and-swap. Either way nothing is
53
+ * rewritten, and `--no-ff` above kept every receipt's commit.
54
+ */
55
+ const landLocally = () => {
56
+ const base = baseBranchName(baseRef());
57
+ const merged = String(git(['rev-parse', 'HEAD'], tmpDir)).trim();
58
+ const before = String(git(['rev-parse', `refs/heads/${base}`], repoRoot)).trim();
59
+ let current = null;
60
+ try { current = String(git(['symbolic-ref', '--quiet', '--short', 'HEAD'], repoRoot)).trim(); } catch { /* detached */ }
61
+ if (current === base) {
62
+ const dirty = String(git(['status', '--porcelain', '--untracked-files=no'], repoRoot)).trim();
63
+ if (dirty) {
64
+ throw new Error(`this repository has no remote, so the merge lands on ${base} in ${repoRoot} itself, and that checkout has uncommitted changes. Commit or stash them, then approve again.`);
65
+ }
66
+ git(['merge', '--ff-only', merged], repoRoot);
67
+ } else {
68
+ git(['update-ref', `refs/heads/${base}`, merged, before], repoRoot);
69
+ }
70
+ };
43
71
  const attempt = () => {
44
72
  dropTmp();
45
73
  git(['worktree', 'add', '--detach', tmpDir, baseRef()], repoRoot);
@@ -47,7 +75,8 @@ export function mergeOutward({
47
75
  ['merge', '--no-ff', tip, '-m', `ship(${label}): ${count} commit${count === 1 ? '' : 's'}`],
48
76
  tmpDir
49
77
  );
50
- gitNet(['push', 'origin', `HEAD:${baseBranchName(baseRef())}`], tmpDir, 120_000);
78
+ if (hasOrigin()) gitNet(['push', 'origin', `HEAD:${baseBranchName(baseRef())}`], tmpDir, 120_000);
79
+ else landLocally();
51
80
  };
52
81
  try {
53
82
  try {
@@ -132,5 +161,7 @@ export function mergeOutward({
132
161
  */
133
162
  export function isRaceRejection(e) {
134
163
  const d = `${e?.stdout ?? ''}\n${e?.stderr ?? ''}\n${e?.message ?? ''}`;
135
- return /non-fast-forward|\[rejected\]|fetch first|stale info/i.test(d);
164
+ // `but expected` is update-ref's compare-and-swap losing (a repo with no
165
+ // remote, where the base ref moved between our read and our write).
166
+ return /non-fast-forward|\[rejected\]|fetch first|stale info|but expected/i.test(d);
136
167
  }
@@ -0,0 +1,181 @@
1
+ /**
2
+ * THE ONE WAY A STANDING-DOWN DAEMON LEAVES: AFTER ITS DEPLOYS HAVE REPORTED.
3
+ *
4
+ * ── A STAND-DOWN LETS AN IN-FLIGHT DEPLOY FINISH AND REPORT (owner ruling
5
+ * 2026-09-26) ──
6
+ *
7
+ * The stand-downs are Ctrl+C (SIGINT), a service manager's or a takeover's
8
+ * SIGTERM, a commanded stop, a displaced box, a removed box and a revoked
9
+ * credential. Every one of them used to reach `process.exit` straight after
10
+ * the teardown. The deploy's command survived (it runs in its own process
11
+ * group, deployRunner.mjs, and only its timeout kills that group), but the
12
+ * process that held the claim did not — so its outcome was never posted, the
13
+ * heartbeat stopped, and three minutes later the server handed the same
14
+ * irreversible job out again.
15
+ *
16
+ * So the exit waits. The teardown still runs first and in full (tabs, the
17
+ * wiki, previews — none of those is a deploy), and it closes the work lanes
18
+ * before it kills anything (standDownGate.mjs): no CLI turn or check starts
19
+ * again, and a turn it killed never hands its output back, so it settles
20
+ * nothing and retries nothing, exactly as when `process.exit` followed on the
21
+ * same tick. The roster loop stops taking work (fleet.mjs checks
22
+ * `leave.leaving()` at the top of every tick and after every await in it, so a
23
+ * signal mid-tick ends the tick too), and a deploy claim answered after the
24
+ * stand-down began is not run (deploy.mjs). What stays alive is only the
25
+ * deploy lane: the command, its heartbeat and its report. While it waits:
26
+ *
27
+ * - the instance lock stays HELD and is marked draining, so a takeover waits
28
+ * for it and a `flowviant stop` says so — neither signals it (instance.mjs
29
+ * reads the mark BEFORE it signals);
30
+ * - `busy` keeps being written to the daemon state file, and the lock's mark
31
+ * is read into `status --json` as `draining`, so the tray shows the box as
32
+ * working until the outcome lands. A `draining` machine event says so at
33
+ * once: the `stopped` event the stand-down already sent must not leave the
34
+ * tray reading the box as idle;
35
+ * - the report still goes out if the credential was rotated under it (a
36
+ * Disconnect, which the app now refuses while a deploy is in flight): the
37
+ * API's own refusal is said in words by the lease, never thrown.
38
+ *
39
+ * A SIGTERM while waiting changes nothing — a service manager, a takeover or
40
+ * `flowviant stop` asking again is the same stand-down asked twice. Only a
41
+ * person at the terminal can insist: a SECOND Ctrl+C leaves at once, after
42
+ * one best-effort post that settles each deploy as `unknown` ("outcome
43
+ * unknown: operator left mid-deploy"), so the server marks it terminal and
44
+ * never hands it out again. Nothing here ever kills the deploy; that is the
45
+ * timeout's alone.
46
+ *
47
+ * STATED RESIDUALS (no code answers them):
48
+ * - A service manager. No unit file is shipped or documented (checked
49
+ * 2026-09-26). A unit that runs this daemon must set `KillMode=mixed` (the
50
+ * stop's SIGTERM goes to the daemon alone, not to the deploy command in its
51
+ * cgroup) and `TimeoutStopSec=2100` (35 minutes, above the deploy's
52
+ * 30-minute timeout); systemd's defaults SIGTERM the command itself and
53
+ * SIGKILL the whole group after 90 s.
54
+ * - An older daemon's takeover or `flowviant stop` does not read the draining
55
+ * mark and SIGKILLs a newer draining daemon after its 20 s grace; the
56
+ * report is then lost and the server requeues the job, as before this
57
+ * ruling, until the release carrying this has spread.
58
+ * - A SIGKILL (or a second Ctrl+C whose `unknown` post does not land) loses
59
+ * the outcome the same way.
60
+ * - A deploy claim still on the wire when a second Ctrl+C leaves gets no
61
+ * `unknown` post (nothing was run, and this process has no job to settle);
62
+ * if the server granted it, its stale sweep hands it out again after three
63
+ * minutes, which is right, because nothing ran.
64
+ *
65
+ * Every dependency is injected, so the wait can be driven without a process to
66
+ * exit; fleet.mjs builds the live one.
67
+ */
68
+
69
+ export const DRAIN_BUSY_BEAT_MS = 30_000;
70
+ /** How long a second Ctrl+C waits for its `unknown` post before it leaves
71
+ * anyway — a person insisting is not kept waiting on the network. */
72
+ export const ABANDON_POST_MS = 5_000;
73
+
74
+ /**
75
+ * @param {object} deps
76
+ * @param {() => number} deps.inFlight how many deploys this process holds
77
+ * @param {() => string[]} deps.labels what they deploy, `target → env`
78
+ * @param {() => Promise<void>} deps.settled resolves once every one reported
79
+ * @param {() => Promise<void>} deps.abandon settle each as `unknown` (deploy.mjs)
80
+ * @param {(what: string) => void} deps.markDraining mark the instance lock
81
+ * @param {(what: string) => void} [deps.announce] the `draining` machine event
82
+ * @param {() => void} deps.reportBusy write `busy` to the state file
83
+ * @param {(code: number) => void} deps.exit
84
+ * @param {{ note: (m: string) => void, warn: (m: string) => void }} deps.log
85
+ * @returns {((code: number, opts?: { signal?: 'SIGINT' | 'SIGTERM' | null }) => Promise<void>)
86
+ * & { leaving: () => boolean, drained: () => Promise<void> }}
87
+ */
88
+ export function createLeave({
89
+ inFlight,
90
+ labels,
91
+ settled,
92
+ abandon = async () => {},
93
+ markDraining,
94
+ announce = () => {},
95
+ reportBusy,
96
+ exit,
97
+ log,
98
+ beatMs = DRAIN_BUSY_BEAT_MS,
99
+ abandonMs = ABANDON_POST_MS,
100
+ }) {
101
+ let waiting = null;
102
+ let interrupts = 0;
103
+ let gone = false;
104
+ const exitOnce = (code) => {
105
+ if (gone) return;
106
+ gone = true;
107
+ exit(code);
108
+ };
109
+ leave.leaving = () => waiting != null;
110
+ /** Resolves when the stand-down has finished waiting (at once when none began). */
111
+ leave.drained = () => waiting ?? Promise.resolve();
112
+ return leave;
113
+ function leave(code, { signal = null } = {}) {
114
+ if (signal === 'SIGINT') interrupts++;
115
+ if (waiting) {
116
+ // A SIGTERM or a second roster command while draining is the same
117
+ // stand-down asked again and changes nothing. Only a person at the
118
+ // terminal insists, and only with a SECOND Ctrl+C.
119
+ if (signal !== 'SIGINT' || gone) return waiting;
120
+ if (interrupts < 2) {
121
+ log.note('still waiting for the deploy to report. Ctrl+C again leaves now, without its outcome.');
122
+ return waiting;
123
+ }
124
+ log.warn(
125
+ 'leaving without seeing the deploy end — its command keeps running on this box; ' +
126
+ 'the app is told its outcome is unknown, so it is never run again.'
127
+ );
128
+ const bounded = new Promise((r) => {
129
+ const t = setTimeout(r, abandonMs);
130
+ t.unref?.();
131
+ });
132
+ Promise.race([Promise.resolve().then(abandon).catch(() => {}), bounded]).then(() => exitOnce(code));
133
+ return waiting;
134
+ }
135
+ if (inFlight() === 0) {
136
+ waiting = Promise.resolve();
137
+ exitOnce(code);
138
+ return waiting;
139
+ }
140
+ const n = inFlight();
141
+ const what = labels().join(', ');
142
+ log.note(
143
+ `waiting for ${n === 1 ? 'a deploy' : `${n} deploys`} (${what}) to finish and report before stopping — ` +
144
+ `the command is not stopped.${signal === 'SIGINT' ? ' Ctrl+C again leaves now, without its outcome.' : ''}`
145
+ );
146
+ try {
147
+ markDraining(what);
148
+ } catch {
149
+ /* a lock we cannot mark still drains; an older daemon's takeover may force it */
150
+ }
151
+ try {
152
+ announce(what);
153
+ } catch {
154
+ /* a local readout must never interrupt a stand-down */
155
+ }
156
+ const beat = () => {
157
+ try {
158
+ reportBusy();
159
+ } catch {
160
+ /* a local readout must never interrupt a stand-down */
161
+ }
162
+ };
163
+ beat();
164
+ const timer = setInterval(beat, beatMs);
165
+ timer.unref?.();
166
+ waiting = settled().then(
167
+ () => {
168
+ clearInterval(timer);
169
+ if (gone) return;
170
+ beat();
171
+ log.note(`${n === 1 ? 'the deploy is' : 'the deploys are'} done — stopping.`);
172
+ exitOnce(code);
173
+ },
174
+ () => {
175
+ clearInterval(timer);
176
+ exitOnce(code);
177
+ }
178
+ );
179
+ return waiting;
180
+ }
181
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * THE WORK LANES STOP AT THE TEARDOWN, EVEN WHILE THE PROCESS LIVES ON (second
3
+ * review of ruling 2026-09-26: deploy during stand-down).
4
+ *
5
+ * Before the drain, every stand-down ran the teardown and then `process.exit`
6
+ * on the same tick. The teardown SIGTERMed the live CLIs, and the code waiting
7
+ * on them never ran again, so a killed turn never settled: it stayed pending on
8
+ * the server and resumed in place on the next run ("Worktrees are kept:
9
+ * in-flight work resumes next run").
10
+ *
11
+ * The drain keeps the process alive after the teardown for as long as a
12
+ * deploy is in flight (standDownExit.mjs). Every continuation that was waiting
13
+ * on a killed CLI would then run. A tab turn's resume-fresh retry would spawn a
14
+ * new CLI that nobody kills. An agent turn would settle as `nothing` and send
15
+ * the agent to Stuck. The wiki would sync a half-written vault.
16
+ *
17
+ * So the teardown closes this gate FIRST, and the two places the daemon spawns
18
+ * a lane's work ask it: runTurn.mjs (every CLI turn: tab, agent, plan,
19
+ * pre-review, wiki) and workAgentCheck.mjs (the project check). Once it is
20
+ * closed, a lane's spawn never starts, and a lane's child that ends never hands
21
+ * back its output. The promise the lane awaits stays pending, which is exactly
22
+ * where `process.exit` used to leave it: no settle, no retry, no sync. A pending
23
+ * promise holds no handle, so it never keeps the process up on its own.
24
+ *
25
+ * The deploy lane runs past the gate: it is what the drain exists for. It asks
26
+ * the gate once, when a claim's answer comes back after the stand-down began,
27
+ * and then does not run the deploy (deploy.mjs).
28
+ *
29
+ * Process-wide by design: there is one teardown per process, and it closes
30
+ * once and for good.
31
+ */
32
+
33
+ let stopped = false;
34
+
35
+ /** The teardown's first act (fleet.mjs). Idempotent; never reopens. */
36
+ export function stopWorkLanes() {
37
+ stopped = true;
38
+ }
39
+
40
+ /** Whether the teardown has run in this process. */
41
+ export function workLanesStopped() {
42
+ return stopped;
43
+ }
44
+
45
+ /** What a lane awaits once the gate is closed: an answer that never comes. */
46
+ export function never() {
47
+ return new Promise(() => {});
48
+ }
@@ -0,0 +1,279 @@
1
+ /**
2
+ * THE START PATH'S PROJECT: which stored credential a bare `flowviant` serves
3
+ * here, said, asked, or refused — never guessed — and the repo binding the
4
+ * person consented to, handed back DEFERRED.
5
+ *
6
+ * SPLIT OUT OF cli.mjs (SOLID audit 2026-09-26, F053): the entrypoint changed
7
+ * for terminal dispatch, for the `machines` menu, and for this policy. The
8
+ * picker, the single-unbound confirm, every refusal and the tray folder check
9
+ * are one decision with one reason to change, so they live here and cli.mjs
10
+ * asks `resolveStartProject` once and passes its answer to runFleetDaemon.
11
+ * Moved verbatim; the refusals still end the process in the same words.
12
+ *
13
+ * THE BINDING IS NEVER WRITTEN HERE. What this returns is `afterLock` — the
14
+ * store write, or null — and runFleetDaemon runs it only once the instance
15
+ * lock refused nothing (startBind.test.mjs pins the order and that this is
16
+ * the one home of both writes).
17
+ */
18
+ import { FLEET_TOKEN, CREDENTIAL, adoptStoredCredential } from './config.mjs';
19
+ import { runLogin } from './login.mjs';
20
+ import { terminalCommand } from './launchCommand.mjs';
21
+ import { canPrompt, askWithTimeout, selectMenu, menuSupported } from './tty.mjs';
22
+
23
+ /** Re-exec a plain `flowviant` after an inline login — the login command's own
24
+ * pattern: config.mjs read the store at IMPORT time, before the credential
25
+ * existed, so this process cannot serve; the child can. */
26
+ async function reexecAfterLogin() {
27
+ const login = await runLogin({ thenStart: false });
28
+ if (!login?.saved) process.exit(1); // refused: the directory already serves another project
29
+ const { spawn } = await import('node:child_process');
30
+ const child = spawn(process.execPath, process.argv[1]?.startsWith('/$bunfs/') ? [] : [process.argv[1]], { stdio: 'inherit', env: process.env });
31
+ process.exit(await new Promise((resolve) => child.on('exit', (code) => resolve(code ?? 0))));
32
+ }
33
+
34
+ // TWO ROWS THAT READ ALIKE ARE NOT A CHOICE. `projectRowLabel` adds the id and
35
+ // the connected date to the COLLIDING rows only — production held two projects
36
+ // both called "BRIF AI", both bound to the same checkout, so this picker
37
+ // offered two identical lines and the only way to answer was to guess. See
38
+ // credentials.mjs for why the suffix is not on every row. (That exact case is
39
+ // a refusal now rather than a picker — a directory serves one project — but
40
+ // two same-named projects can still meet in the no-match picker.)
41
+ function listLines(entries, { projectRowLabel }) {
42
+ return entries
43
+ .map(
44
+ (e, i) =>
45
+ ` ${i + 1}. ${projectRowLabel(e, entries)}` +
46
+ (e.repoRoot ? ` — connected for ${e.repoRoot}` : ' — not tied to a repo yet')
47
+ )
48
+ .join('\n');
49
+ }
50
+
51
+ /**
52
+ * Resolve the project this start serves. Returns the deferred binding (or
53
+ * null); every refusal exits the process in words, as the entrypoint did.
54
+ * `dirGiven`: the start named its checkout with `--dir` (the tray's form).
55
+ */
56
+ export async function resolveStartProject({ dirGiven }) {
57
+ // ── WHICH PROJECT THIS START SERVES — said, asked, or refused; never guessed.
58
+ //
59
+ // The store holds many projects since 0.55.0 and resolution is BY REPO
60
+ // (credentials.mjs). What is left here is the human half: an ambiguous store
61
+ // on a TTY becomes a PICKER, a single unbound credential gets ONE confirm that
62
+ // binds it, and a headless start with no unambiguous answer refuses in words
63
+ // that name every stored project and every way out. The one thing this block
64
+ // must never do is serve a project the resolution did not name — "it said
65
+ // skadooble in my calendar repo" is the confusion this exists to end.
66
+ // A RESTART IS NOT A PERSON. `reexec` (update.mjs) inherits stdio, so an
67
+ // auto-updated daemon's child sees two TTYs; without this it would stop on the
68
+ // binding confirm below and the machine would stay dark until somebody typed a
69
+ // key. Same reasoning as the headless case, and the same answer.
70
+ // `canPrompt()`, not a bare isTTY pair: a BACKGROUNDED job (`flowviant &`) has
71
+ // two TTYs and cannot be asked anything — the first read raises SIGTTIN and the
72
+ // kernel STOPS the process, which is why 0.55.2's timeout did not save it (a
73
+ // stopped process runs no timers). See tty.mjs.
74
+ const interactive = canPrompt() && process.env.FLOWVIANT_REEXEC !== '1';
75
+
76
+ /** NO ANSWER TIME LIMIT on the start path (2026-09-20, the owner: "why is
77
+ * there an answer time limit, remove that"). The confirm below carried 20s
78
+ * and the picker 60s, each arguing that a restart nobody is watching must
79
+ * not sit dark on a prompt — but `interactive` already excludes exactly that
80
+ * case (no foreground terminal, or a self-update re-exec, and nothing is
81
+ * asked). What was left was a person reading a list being told "no answer
82
+ * in 60s — nothing started". A prompt a person can see waits for the
83
+ * person; tty.mjs's header carries the argument. */
84
+ const externalToken =
85
+ process.argv.includes('--fleet') ||
86
+ Boolean(process.env.FLOWVIANT_MACHINE_TOKEN) ||
87
+ Boolean(process.env.FLOWVIANT_FLEET);
88
+
89
+ /** A repo binding the person just consented to, persisted by runFleetDaemon
90
+ * only after the instance lock is taken — never before a refusal. */
91
+ let afterLock = null;
92
+
93
+ if (!FLEET_TOKEN) {
94
+ if (CREDENTIAL.error) {
95
+ console.error(`error: ${CREDENTIAL.error}. \`${terminalCommand('projects')}\` lists what is stored.`);
96
+ process.exit(1);
97
+ }
98
+ // A DIRECTORY SERVES ONE PROJECT (2026-09-23, the owner, verbatim: "a
99
+ // directory cannot have more than one project on flowviant. if one already
100
+ // exists, it would warn the user and ask them to delete the project on
101
+ // flowviant first. because 2 projects shouldnt be able to edit a directory
102
+ // at the same time."). This used to be a PICKER — two projects bound to one
103
+ // checkout, choose which this daemon serves — which is a control offering to
104
+ // do the forbidden thing politely. It is a refusal now, on a TTY and
105
+ // headless alike, naming both projects and the remedy. `credentialRefusal`
106
+ // is the same sentence login prints when it declines to bind a second one.
107
+ if (CREDENTIAL.reason === 'multiple-bound') {
108
+ const { directoryTakenRefusal } = await import('./credentials.mjs');
109
+ console.error(directoryTakenRefusal(CREDENTIAL.choices, CREDENTIAL.repoRoot));
110
+ process.exit(1);
111
+ }
112
+ if (CREDENTIAL.choices?.length && interactive) {
113
+ const creds = await import('./credentials.mjs');
114
+ const { originSlug } = await import('./git.mjs');
115
+ const { basename } = await import('node:path');
116
+ const { choices, repoRoot } = CREDENTIAL;
117
+ console.log(
118
+ CREDENTIAL.reason === 'outside-repo'
119
+ ? 'flowviant is not inside a git repo, and more than one project is connected here.'
120
+ : `This repo (${repoRoot}) is not connected to any project yet. Connected on this machine:`
121
+ );
122
+
123
+ const loginLabel = `connect ${repoRoot ? 'this repo' : 'a repo'} to a different project (${terminalCommand('login')})`;
124
+ // WHICH ONE LOOKS RIGHT — a pre-selection, never an auto-serve. The resolver
125
+ // refuses to serve a project the repo PATH did not name (the skadooble law);
126
+ // this only decides which row the cursor starts on, using the repo's folder
127
+ // name and its github repo-name against the stored project names. A unique
128
+ // match becomes ONE keypress; a wrong guess costs nothing, because the human
129
+ // still confirms. (Two projects bound to THIS repo never reach here: that
130
+ // is a refusal above, since 2026-09-23.)
131
+ const slug = repoRoot ? originSlug(repoRoot) : null;
132
+ const likely = creds.likelyChoiceIndex(choices, {
133
+ repoBasename: repoRoot ? basename(repoRoot) : null,
134
+ repoSlugName: slug ? slug.split('/')[1] : null,
135
+ });
136
+
137
+ // Bounded like the confirm below, and for the same reason — but silence
138
+ // means something DIFFERENT here and the difference is load-bearing. There
139
+ // is a real ambiguity to resolve; serving a guess is the skadooble bug.
140
+ // So no answer REFUSES, which is exactly what this branch already does
141
+ // headless, and the message says how to answer without being present.
142
+ let chosen = null; // 0-based into [...choices, login]
143
+ if (menuSupported()) {
144
+ const rowLabel = (e) =>
145
+ creds.projectRowLabel(e, choices) +
146
+ (e.repoRoot ? ` — connected for ${e.repoRoot}` : ' — not tied to a repo yet');
147
+ const options = [...choices.map(rowLabel), loginLabel];
148
+ // Say WHY the cursor starts where it does — "intuitive" made visible.
149
+ if (likely >= 0) options[likely] += ' ← looks like this repo';
150
+ const res = await selectMenu({
151
+ options,
152
+ defaultIndex: likely >= 0 ? likely : 0,
153
+ });
154
+ if (res.cancelled) {
155
+ console.error('nothing chosen — nothing started.');
156
+ process.exit(1);
157
+ }
158
+ if (!res.unsupported) chosen = res.index;
159
+ }
160
+ if (chosen === null) {
161
+ // Numeric fallback — a pipe, or a terminal without raw mode. Empty Enter
162
+ // takes the likely default when there is one, so it is one keystroke here
163
+ // too.
164
+ console.log(listLines(choices, creds));
165
+ console.log(` ${choices.length + 1}. ${loginLabel}`);
166
+ const hint = likely >= 0 ? ` (enter for ${creds.projectLabel(choices[likely])})` : '';
167
+ const raw = await askWithTimeout(
168
+ `Which project should this daemon serve? [1-${choices.length + 1}]${hint} `
169
+ );
170
+ if (raw === null) {
171
+ // The read itself failed — stdin closed, or the terminal went away
172
+ // mid-question. Not a timeout: there is none.
173
+ console.error(
174
+ `\ncould not read an answer — nothing started. ` +
175
+ `Name one with \`--project <name|id>\`, or run \`flowviant\` here in the foreground and pick.`
176
+ );
177
+ process.exit(1);
178
+ }
179
+ const n = raw === '' && likely >= 0 ? likely + 1 : Number.parseInt(raw, 10);
180
+ chosen = Number.isInteger(n) ? n - 1 : -1;
181
+ }
182
+
183
+ if (chosen === choices.length) await reexecAfterLogin();
184
+ const picked = chosen >= 0 ? choices[chosen] : undefined;
185
+ if (!picked) {
186
+ console.error('nothing chosen — nothing started.');
187
+ process.exit(1);
188
+ }
189
+ // An answered question is consent: adopt it, and BIND it to this repo so
190
+ // the next start needs no prompt. Repointing is legitimate and said aloud.
191
+ if (repoRoot && picked.repoRoot && picked.repoRoot !== repoRoot) {
192
+ console.log(`note: ${creds.projectLabel(picked)} was connected for ${picked.repoRoot} — now serving ${repoRoot} instead.`);
193
+ }
194
+ adoptStoredCredential(picked);
195
+ // BOUND ONLY ONCE THE START HAS THE LOCK. Written here, the answer moved
196
+ // the binding even when the instance lock then refused the start — a live
197
+ // daemon for the same project in its own checkout — and that daemon's next
198
+ // unattended self-update re-exec'd into a store that no longer bound its
199
+ // repo, found no match headless, and exited with the machine dark.
200
+ afterLock = () => creds.selectStoredProject(picked.projectId, { bindRepoRoot: repoRoot ?? undefined });
201
+ console.log(`serving ${creds.projectLabel(picked)}${repoRoot ? ` from ${repoRoot}` : ''}.`);
202
+ } else if (CREDENTIAL.choices?.length) {
203
+ const creds = await import('./credentials.mjs');
204
+ console.error(
205
+ 'error: more than one project is connected on this machine and this repo is not bound to any of them:\n' +
206
+ listLines(CREDENTIAL.choices, creds) +
207
+ `\nPick one with \`--project <name|id>\`, bind this repo by running \`${terminalCommand()}\` here in a terminal once,\n` +
208
+ `or connect this repo to its own project with \`${terminalCommand('login')}\`.`
209
+ );
210
+ process.exit(1);
211
+ } else {
212
+ console.error(
213
+ 'error: no credential found. Easiest:\n' +
214
+ ` ${terminalCommand('login')} (approve in the app — recommended)\n` +
215
+ 'Or set:\n' +
216
+ ' FLOWVIANT_FLEET=fva_… (machine token, from the app)'
217
+ );
218
+ process.exit(1);
219
+ }
220
+ } else if (!externalToken && CREDENTIAL.needsConfirm && interactive) {
221
+ // ONE stored project, never tied to a repo — the pre-0.55.0 world. Ask once;
222
+ // yes binds and every later start is silent. This is the exact question
223
+ // whose absence had a calendar checkout serving skadooble.
224
+ //
225
+ // AND IT TIMES OUT, because a prompt on a start path is a way for a machine
226
+ // to go dark. `FLOWVIANT_REEXEC` above covers the restart THIS version
227
+ // performs, but it cannot cover the one that matters most: the hop that
228
+ // installs a fixed daemon is spawned by the OLD one, which never sets it.
229
+ // 0.55.0 → 0.55.1 was exactly that — an auto-update landing unattended would
230
+ // stop here with the machine serving nothing. A guard that only works once
231
+ // everyone already has it is not a guard.
232
+ //
233
+ // IF THE READ FAILS WE SERVE, AND WE DO NOT BIND. (This used to say "on
234
+ // timeout"; there is no timeout since 2026-09-20 — a person at the prompt
235
+ // is waited for. `null` now means stdin closed under the question.) Those
236
+ // are two decisions:
237
+ // · SERVE, because it is what every version before 0.55.0 did with this
238
+ // exact store, so the silent path is the status quo rather than a new
239
+ // risk — and a daemon that answers is strictly better than one that does
240
+ // not, which is the whole reason this product has exactly one refusal.
241
+ // · DO NOT BIND, because binding is the thing the question was FOR. Nobody
242
+ // answered, so nothing is cemented; the next human start asks again. That
243
+ // keeps the skadooble case fixed for the person who is actually looking,
244
+ // which is the only person it could ever have been fixed for.
245
+ const creds = await import('./credentials.mjs');
246
+ const label = creds.projectLabel(CREDENTIAL.entry);
247
+ const answered = await askWithTimeout(
248
+ `This machine's one connected project is ${label}. Serve this repo (${CREDENTIAL.repoRoot}) as ${label}? [Y/n] `
249
+ );
250
+ const raw = answered === null ? null : answered.toLowerCase(); // null = the read failed
251
+ if (raw === null) {
252
+ console.log(
253
+ `\n could not read an answer — serving ${label} for this run ` +
254
+ `without tying it to this repo. Run \`${terminalCommand()}\` here and answer to make it stick.`
255
+ );
256
+ } else if (raw === '' || raw === 'y' || raw === 'yes') {
257
+ // After the lock, for the reason the picker's bind gives above.
258
+ afterLock = () => creds.bindStoredRepo(CREDENTIAL.entry.projectId, CREDENTIAL.repoRoot);
259
+ } else {
260
+ console.error(
261
+ `nothing started. Connect this repo to its own project with \`${terminalCommand('login')}\`, ` +
262
+ `or see what is stored with \`${terminalCommand('projects')}\`.`
263
+ );
264
+ process.exit(1);
265
+ }
266
+ }
267
+
268
+ if (process.argv.includes('--json-events') && dirGiven && CREDENTIAL?.entry) {
269
+ const { detectRepoRoot, listStoredProjects, boundElsewhere } = await import('./credentials.mjs');
270
+ const repo = detectRepoRoot();
271
+ const entry = CREDENTIAL.entry;
272
+ if (!repo || entry.repoRoot !== repo || boundElsewhere(listStoredProjects(), repo, entry.projectId).length) {
273
+ console.error('error: this project is not connected to this repository. Run `flowviant login` for the selected folder.');
274
+ process.exit(1);
275
+ }
276
+ }
277
+
278
+ return afterLock;
279
+ }
package/bin/lib/trace.mjs CHANGED
@@ -59,7 +59,7 @@
59
59
  * tail of a trace, never the settle behind it.
60
60
  */
61
61
 
62
- import { THINK_MARKER } from './runtimes.mjs';
62
+ import { THINK_MARKER } from './runtimeEvents.mjs';
63
63
 
64
64
  /** One batch every two seconds — the discipline the tab's narrator keeps, for
65
65
  * the same reason: a turn emits hundreds of entries and nobody is reading them
@@ -190,7 +190,7 @@ export function makeTraceRelay({ agentId, turnId, post, scrub = (s) => s, run =
190
190
  return {
191
191
  /**
192
192
  * A prose line from the stream. `kind` is the daemon's activity vocabulary
193
- * (runtimes.mjs); anything that is not thinking or the model speaking is a
193
+ * (runtimeEvents.mjs); anything that is not thinking or the model speaking is a
194
194
  * `note` — the honest bucket for a codex error line or an agy tool name,
195
195
  * rather than a wire value invented per runtime.
196
196
  *
@@ -225,7 +225,7 @@ export function makeTraceRelay({ agentId, turnId, post, scrub = (s) => s, run =
225
225
  * real transcripts: 93 blocks, all of them empty) carries no text in any
226
226
  * of them — so the trace filled with the identical marker repeated, which
227
227
  * is noise standing exactly where the thought would have been. The wiki
228
- * feed collapses the same run for the same reason (fleet.mjs: "Collapse
228
+ * feed collapses the same run for the same reason (wikiRunner.mjs: "Collapse
229
229
  * runs of bare 'thinking…' so the feed doesn't fill with it").
230
230
  *
231
231
  * ONLY THE BARE MARKER, and that is the whole safety of it: a think WITH