flowviant 0.115.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 +99 -145
  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
package/bin/lib/git.mjs CHANGED
@@ -3,9 +3,49 @@
3
3
  import { execFileSync, spawn } from 'node:child_process';
4
4
  import { appendFileSync, mkdirSync, readFileSync } from 'node:fs';
5
5
  import { dirname, join, resolve } from 'node:path';
6
+ import { killGroup } from './processGroup.mjs';
6
7
 
7
- export function git(args, cwd) {
8
- return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
8
+ /**
9
+ * ONE LOCAL GIT CALL, trimmed. Throws on a non-zero exit (with git's stderr on
10
+ * the error), like `execFileSync` itself.
11
+ *
12
+ * `opts` SPREADS OVER THE DEFAULTS (audit 2026-10-10, F107): with none, the
13
+ * call is exactly what it always was — utf8, stderr piped, no timeout, Node's
14
+ * own 1 MiB `maxBuffer`. A caller that needs a wider buffer, a timeout, a
15
+ * quiet stderr (`stdio: ['ignore', 'pipe', 'ignore']`) or an `env` asks for it
16
+ * here instead of re-spelling `execFileSync('git', …)` with its own drifted
17
+ * options — nine modules did, one of them under this function's own name.
18
+ * Buffer output (`encoding: 'buffer'`) goes through `gitRaw`: there is nothing
19
+ * to trim.
20
+ */
21
+ export function git(args, cwd, opts = {}) {
22
+ return gitRaw(args, cwd, opts).trim();
23
+ }
24
+
25
+ /**
26
+ * THE WIDE BUFFER — 8 MiB, for a read whose output grows with the repository
27
+ * (a catch-up range's commit list, a patch, the branch report). Node's 1 MiB
28
+ * default threw ENOBUFS mid-read in three lanes, each of which then learned the
29
+ * number on its own (landed.mjs, repoState.mjs, workDiffs.mjs); one name now.
30
+ */
31
+ export const GIT_WIDE_BUFFER = 8 * 1024 * 1024;
32
+
33
+ /**
34
+ * A git fact, or NULL when git could not answer — a non-zero exit (a detached
35
+ * HEAD for `symbolic-ref`, an unborn branch for `rev-parse HEAD`, a range
36
+ * naming a ref that is not there) or empty output. For the reads that are
37
+ * OPTIONAL to their caller: the lanes each re-learned that `git()` throws
38
+ * rather than returning a non-string, and wrote the same try/catch → null ten
39
+ * times, one of them left out (audit 2026-10-10, F144). A caller that must
40
+ * tell one failure from another (the begun-guard's exit status) does not use
41
+ * this.
42
+ */
43
+ export function gitOrNull(args, cwd) {
44
+ try {
45
+ return git(args, cwd) || null;
46
+ } catch {
47
+ return null;
48
+ }
9
49
  }
10
50
 
11
51
  /**
@@ -37,12 +77,10 @@ export function gitNetEnv(cwd, env = process.env) {
37
77
  if (env.GIT_SSH_COMMAND || env.GIT_SSH) return out;
38
78
  let configured = '';
39
79
  try {
40
- configured = execFileSync('git', ['config', '--get', 'core.sshCommand'], {
41
- cwd,
42
- encoding: 'utf8',
80
+ configured = git(['config', '--get', 'core.sshCommand'], cwd, {
43
81
  stdio: ['ignore', 'pipe', 'ignore'],
44
82
  timeout: 5_000,
45
- }).trim();
83
+ });
46
84
  } catch {
47
85
  /* unset (exit 1) or unreadable — ours, then */
48
86
  }
@@ -62,14 +100,7 @@ export function gitNetEnv(cwd, env = process.env) {
62
100
  * throws with `code: 'ETIMEDOUT'`).
63
101
  */
64
102
  export function gitNet(args, cwd, ms = GIT_NET_TIMEOUT_MS) {
65
- return execFileSync('git', args, {
66
- cwd,
67
- encoding: 'utf8',
68
- stdio: ['ignore', 'pipe', 'pipe'],
69
- timeout: ms,
70
- killSignal: 'SIGKILL',
71
- env: gitNetEnv(cwd),
72
- });
103
+ return gitRaw(args, cwd, { timeout: ms, killSignal: 'SIGKILL', env: gitNetEnv(cwd) });
73
104
  }
74
105
 
75
106
  /**
@@ -103,15 +134,7 @@ export function gitNetAsync(args, cwd, ms = GIT_NET_TIMEOUT_MS) {
103
134
  fn(v);
104
135
  };
105
136
  const timer = setTimeout(() => {
106
- try {
107
- process.kill(-child.pid, 'SIGKILL');
108
- } catch {
109
- try {
110
- child.kill('SIGKILL');
111
- } catch {
112
- /* already gone */
113
- }
114
- }
137
+ killGroup(child, 'SIGKILL');
115
138
  const e = new Error(`git ${args[0]} timed out after ${ms}ms`);
116
139
  e.code = 'ETIMEDOUT';
117
140
  finish(reject, e);
@@ -147,8 +170,8 @@ export function gitNetAsync(args, cwd, ms = GIT_NET_TIMEOUT_MS) {
147
170
  * has an accent in it. For the patch collision check, "silently stops matching"
148
171
  * means "overwrites the edits it exists to protect".
149
172
  */
150
- export function gitRaw(args, cwd) {
151
- return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
173
+ export function gitRaw(args, cwd, opts = {}) {
174
+ return execFileSync('git', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], ...opts, cwd });
152
175
  }
153
176
 
154
177
  /** Split NUL-separated git output into entries. */
@@ -166,10 +189,10 @@ export function repoRootOrDie() {
166
189
  }
167
190
 
168
191
  // ── Server-value validation ────────────────────────────────────────────────
169
- // prUrl / branch / agentId arrive from the fleet server. execFileSync blocks
170
- // SHELL injection but NOT git/gh option injection (a leading '-' becomes a
171
- // flag) or cross-repo/cross-path abuse. These guards make a malicious or buggy
172
- // server unable to touch a repo/branch/path outside the expected scope.
192
+ // Shas and ids arrive from the fleet server. execFileSync blocks SHELL
193
+ // injection but NOT git/gh option injection (a leading '-' becomes a flag) or
194
+ // cross-path abuse. These guards make a malicious or buggy server unable to
195
+ // touch a revision/path outside the expected scope.
173
196
 
174
197
  /** The `owner/repo` the daemon is running inside, from origin's URL. Null if
175
198
  * origin isn't a github remote. */
@@ -183,32 +206,27 @@ export function originSlug(repoRoot) {
183
206
  }
184
207
  }
185
208
 
186
- /** A PR URL is accepted only if it's an https github.com PR in THIS repo. */
187
- export function isValidPrUrl(prUrl, slug) {
188
- if (typeof prUrl !== 'string' || !slug) return false;
189
- const m = prUrl.match(/^https:\/\/github\.com\/([^/]+\/[^/]+)\/pull\/\d+$/);
190
- return !!m && m[1].toLowerCase() === slug.toLowerCase();
191
- }
192
-
193
- /** A branch name is accepted only if git considers it a well-formed ref, it's
194
- * not the base branch, and it doesn't start with '-' (option injection). */
195
- export function isValidBranch(branch, repoRoot, baseRef) {
196
- if (typeof branch !== 'string' || !branch || branch.startsWith('-')) return false;
197
- if (baseRef && (branch === baseRef || `origin/${branch}` === baseRef)) return false;
198
- try {
199
- git(['check-ref-format', '--branch', branch], repoRoot);
200
- return true;
201
- } catch {
202
- return false;
203
- }
204
- }
209
+ /**
210
+ * THE TWO SHA SHAPES THE DAEMON CHECKS, named in one place (audit 2026-10-10,
211
+ * F749) so the split between them is visible instead of spelled five ways:
212
+ * `SHA1_ABBREV_RE` — lowercase, 7 to 40 hex, a SHA-1 id or its abbreviation
213
+ * (`isValidSha`, and the publish lease's `isSha`, which is this same guard);
214
+ * `OBJECT_ID_RE` — either case, 7 to 64 hex, which also admits a SHA-256
215
+ * repository's ids (the landed observer, the commit-diff lane). Each site
216
+ * keeps the shape it already checked; unifying on 64 is a behaviour change on
217
+ * SHA-256 repositories and an owner's call. Neither admits a leading `-` or a
218
+ * range. Anchored and flag-free of `g`, so `.test` holds no state.
219
+ */
220
+ export const SHA1_ABBREV_RE = /^[0-9a-f]{7,40}$/;
221
+ export const OBJECT_ID_RE = /^[0-9a-f]{7,64}$/i;
205
222
 
206
- /** A commit sha from the server, before it reaches `git revert` argv. Server
207
- * values reaching git are validated here by convention (see isValidBranch,
208
- * isValidPrUrl) — a revision RANGE ("HEAD~10..HEAD") or a leading-dash option
209
- * must never pass, whatever the roster says. */
223
+ /** A commit sha from the server, before it reaches git argv. Server values
224
+ * reaching git are validated here by convention — a revision RANGE
225
+ * ("HEAD~10..HEAD") or a leading-dash option must never pass, whatever the
226
+ * roster says. (The PR-URL and branch guards beside it served only the
227
+ * retired cleanup lane and went with it, audit 2026-10-10, F083.) */
210
228
  export function isValidSha(sha) {
211
- return typeof sha === 'string' && /^[0-9a-f]{7,40}$/.test(sha);
229
+ return typeof sha === 'string' && SHA1_ABBREV_RE.test(sha);
212
230
  }
213
231
 
214
232
  /** A roster agent id used as a filesystem path segment — strict allowlist so
@@ -217,6 +235,45 @@ export function isSafePathSegment(id) {
217
235
  return typeof id === 'string' && /^[A-Za-z0-9_-]{1,128}$/.test(id);
218
236
  }
219
237
 
238
+ /**
239
+ * THE BRANCH A PLACE IS CUT ON — `session/<id>`, for a tab's own worktree, a
240
+ * person's `u-` place and an agent's `a-` place alike. One definition, beside
241
+ * the segment rule that guards it (audit 2026-10-10, F122): the cutter, the
242
+ * begun-guard's rev-parse, the publish push and fetch, ship's and the PR
243
+ * lane's fallback, and the merged-branch sweep all name the branch through
244
+ * here, so a change to the scheme or to the id allowlist lands once instead of
245
+ * at nine template strings that could drift into cutting one branch and
246
+ * checking for another.
247
+ *
248
+ * Null for an id that is not a safe path segment: every caller treats that
249
+ * exactly as it treats a branch that is not there.
250
+ */
251
+ export const SESSION_BRANCH_PREFIX = 'session/';
252
+ export function sessionBranch(id) {
253
+ return isSafePathSegment(id) ? `${SESSION_BRANCH_PREFIX}${id}` : null;
254
+ }
255
+ /** The same branch as a full ref, for argv that must not let git guess. */
256
+ export function sessionBranchRef(id) {
257
+ const name = sessionBranch(id);
258
+ return name ? `refs/heads/${name}` : null;
259
+ }
260
+ /** A ref Flowviant itself cut for a place. The ONLY thing that makes a branch
261
+ * "ours" — the reader beside the writer, so the two cannot disagree. */
262
+ export function isSessionBranch(name) {
263
+ return typeof name === 'string' && name.startsWith(SESSION_BRANCH_PREFIX);
264
+ }
265
+
266
+ /**
267
+ * A REMOTE URL'S USERINFO, REMOVED. `//user:token@host` is how a credential
268
+ * rides a remote, and git or gh can print that URL in the first line of a
269
+ * failure that is about to be stored where the whole project reads it. Shared
270
+ * by every relay of a push or `gh` failure (publishErrorText, ghFirstLine) so
271
+ * the rule is one regex, not two copies (audit 2026-10-10, F153).
272
+ */
273
+ export function stripUrlUserinfo(text) {
274
+ return String(text ?? '').replace(/\/\/[^/@\s]+@/g, '//');
275
+ }
276
+
220
277
  export function detectBaseRef(repoRoot) {
221
278
  try {
222
279
  return git(['rev-parse', '--abbrev-ref', 'origin/HEAD'], repoRoot); // e.g. origin/main
@@ -346,14 +403,7 @@ export function excludeInWorktree(wt, relPaths) {
346
403
  try {
347
404
  let gitdir;
348
405
  try {
349
- gitdir = resolve(
350
- wt,
351
- execFileSync('git', ['rev-parse', '--git-common-dir'], {
352
- cwd: wt,
353
- encoding: 'utf8',
354
- stdio: ['ignore', 'pipe', 'ignore'],
355
- }).trim()
356
- );
406
+ gitdir = resolve(wt, git(['rev-parse', '--git-common-dir'], wt, { stdio: ['ignore', 'pipe', 'ignore'] }));
357
407
  } catch {
358
408
  return; // not a repo — there is no exclude file to write
359
409
  }
@@ -12,8 +12,7 @@
12
12
  * are their own.
13
13
  */
14
14
 
15
- import { execFileSync } from 'node:child_process';
16
- import { git } from './git.mjs';
15
+ import { git, gitRaw } from './git.mjs';
17
16
 
18
17
  export const DAEMON_GIT_IDENTITY = Object.freeze({
19
18
  GIT_AUTHOR_NAME: 'Flowviant',
@@ -41,11 +40,6 @@ export function mergeIdentityEnv(repoRoot) {
41
40
  export function gitMergeIn(repoRoot) {
42
41
  return (args, cwd) => {
43
42
  const idEnv = mergeIdentityEnv(repoRoot);
44
- return execFileSync('git', args, {
45
- cwd,
46
- encoding: 'utf8',
47
- stdio: ['ignore', 'pipe', 'pipe'],
48
- ...(idEnv ? { env: { ...process.env, ...idEnv } } : {}),
49
- });
43
+ return gitRaw(args, cwd, idEnv ? { env: { ...process.env, ...idEnv } } : {});
50
44
  };
51
45
  }
package/bin/lib/grant.mjs CHANGED
@@ -15,10 +15,13 @@
15
15
  * hostname is un-gated. It also means a network blip cannot turn somebody's
16
16
  * working preview into a 502.
17
17
  *
18
- * THIS FILE IS ONE HALF OF A WIRE CONTRACT. The other half is
19
- * `apps/api/src/lib/agent-runner/previewGrant.ts` in the Flowviant repo, and
20
- * the two are tested against the same vectors. A change here that the server
21
- * does not follow shows up as a preview nobody can open.
18
+ * THIS FILE IS ONE HALF OF A WIRE CONTRACT. The other half is the app's ONE
19
+ * minter, `mintGrant` in `packages/shared/src/previewGrant.ts`, which both the
20
+ * API's `/authorize` and the preview relay sign with. The release gate
21
+ * (`scripts/check-app-parity.mjs`, the grant case) mints with that function and
22
+ * verifies here, so a change on either side that the other does not follow
23
+ * fails a release instead of shipping as a preview nobody can open.
24
+ * (`grant.test.mjs` tests this half alone, against its own minter.)
22
25
  *
23
26
  * NO CANONICAL-ENCODING CONTRACT, deliberately: the signature covers the
24
27
  * RECEIVED payload string bytes and the JSON is parsed only afterwards. Field
@@ -30,7 +33,9 @@
30
33
  import { createHash, createHmac, timingSafeEqual } from 'node:crypto';
31
34
 
32
35
  export const GRANT_VERSION = 1;
33
- const DOMAIN = `fvgrant.v${GRANT_VERSION}.`;
36
+ /** Domain separation: the signed message is `fvgrant.v1.<body>`. Exported for
37
+ * the release gate's comparison with the app's `GRANT_DOMAIN`. */
38
+ export const GRANT_DOMAIN = `fvgrant.v${GRANT_VERSION}.`;
34
39
 
35
40
  /** `__Host-` is required rather than decorative: every quick tunnel is a
36
41
  * hostname under the shared `trycloudflare.com` parent, so without the prefix
@@ -81,7 +86,7 @@ export function verifyGrant(raw, { secret, shareId, nowMs, skewMs } = {}) {
81
86
  return { ok: false, reason: 'shape' };
82
87
  }
83
88
 
84
- const expected = b64url(createHmac('sha256', String(secret)).update(DOMAIN + body).digest());
89
+ const expected = b64url(createHmac('sha256', String(secret)).update(GRANT_DOMAIN + body).digest());
85
90
  if (!sameSecret(sig, expected)) return { ok: false, reason: 'sig' };
86
91
 
87
92
  let payload;
@@ -185,6 +190,15 @@ export function safePathname(url) {
185
190
  * the cheap first line; the origin comparison is the guarantee. Ported from
186
191
  * the server twins (previewAuthorize.routes.ts, preview-relay util.ts), which
187
192
  * closed the same hole the same way.
193
+ *
194
+ * …AND THE OUTPUT IS CHECKED, NOT ONLY THE INPUT (2026-10-10, audit F618). The
195
+ * parser resolves dot segments, so `/.//evil.com`, `/..//evil.com`,
196
+ * `/%2e%2e//evil.com` and `/./\evil.com` pass the lexical test above (none
197
+ * starts with two slashes) and then re-serialise to the pathname
198
+ * `//evil.com` — protocol-relative in Location, though the parse stayed on the
199
+ * dummy origin. The value returned must itself be a single-slash path. No
200
+ * version floor: the app's /authorize already re-cleans every `to` it hands
201
+ * this callback, so only a hand-crafted callback URL ever met the hole.
188
202
  */
189
203
  export function safeRelative(raw) {
190
204
  if (!raw) return '/';
@@ -198,7 +212,9 @@ export function safeRelative(raw) {
198
212
  try {
199
213
  const u = new URL(s, 'https://x.invalid');
200
214
  if (u.origin !== 'https://x.invalid') return '/';
201
- return u.pathname + u.search + u.hash;
215
+ const out = u.pathname + u.search + u.hash;
216
+ if (/^\/[/\\]/.test(out)) return '/';
217
+ return out;
202
218
  } catch {
203
219
  return '/';
204
220
  }
@@ -16,8 +16,23 @@
16
16
 
17
17
  import { safeName } from './credentials.mjs';
18
18
  import { emitMachineEvent } from './daemonLogging.mjs';
19
+ import { printableLine } from './printable.mjs';
19
20
  import { note, warn } from './ui.mjs';
20
- import { sleep } from './claude.mjs';
21
+
22
+ /** A bound on a wedged uplink, and never a reason to hang: the timer is
23
+ * unref'd, so the only thing that keeps this process alive is the work.
24
+ * The one copy every stand-down below waits behind (2026-10-10): the
25
+ * commanded stop used to race a plain `sleep(5)` imported from claude.mjs —
26
+ * a timer that held the loop, and an import that dragged the whole runtime
27
+ * registry into a module that is pure or injected throughout. */
28
+ const bounded = (p, seconds) =>
29
+ Promise.race([
30
+ p,
31
+ new Promise((resolve) => {
32
+ const t = setTimeout(resolve, seconds * 1000);
33
+ t.unref?.();
34
+ }),
35
+ ]);
21
36
 
22
37
  /**
23
38
  * A STOP COMMANDED BY FLOWVIANT, read off the roster poll.
@@ -53,11 +68,11 @@ export function shouldStop(rosterDaemon) {
53
68
  // on, and an unbounded one would bury the line that matters. The WORDING is
54
69
  // untouched — the operator's own sentence is the whole point of the field,
55
70
  // and paraphrasing it would leave the person at the keyboard guessing.
56
- const reason = String(stop.reason ?? '')
57
- .replace(/[\u0000-\u001f\u007f]/g, ' ')
58
- .replace(/\s+/g, ' ')
59
- .trim()
60
- .slice(0, 300);
71
+ //
72
+ // The SHARED class (printable.mjs, 2026-10-10, F084): this copy used to stop
73
+ // at C0 and DEL, so the 8-bit CSI `\u009b` and every bidi override reached
74
+ // the terminal whenever the server's own scrub was bypassed.
75
+ const reason = printableLine(String(stop.reason ?? ''), 300);
61
76
  return { stop: true, reason };
62
77
  }
63
78
 
@@ -263,7 +278,21 @@ export function removedTurnSentence() {
263
278
  }
264
279
 
265
280
  /**
266
- * THE MACHINE MOVED. STAND DOWN.
281
+ * …AND FOR THE THIRD: Flowviant STOPPED this machine (2026-10-10, F091).
282
+ *
283
+ * The commanded stop used to leave its agent turns unsettled: they sat pending
284
+ * until the server's six-hour expiry while the board showed an agent working
285
+ * on a machine that had gone dark. Settled now with `nothing` and this
286
+ * sentence — never delivered, so the card goes to Stuck rather than an
287
+ * optimistic Review. It names no box and no reason: nothing took over, and
288
+ * the operator's reason is said on this console, where the stop is read.
289
+ */
290
+ export function stoppedTurnSentence() {
291
+ return 'This machine was stopped by Flowviant while this turn was running.';
292
+ }
293
+
294
+ /**
295
+ * THE ROSTER ENDED THIS DAEMON. STAND DOWN.
267
296
  *
268
297
  * The server sends `displaced` only when it is aimed at THIS box (it matches the
269
298
  * poll's own `envpub`) and only inside a short window, so there is nothing to
@@ -276,11 +305,15 @@ export function removedTurnSentence() {
276
305
  * board shows an agent working on a machine that has gone. `nothing` is the
277
306
  * honest outcome and the sentence says what happened.
278
307
  *
279
- * EXIT 0, for the reason the two existing terminal paths (the commanded stop,
280
- * and the revoked credential above it) both document: under `Restart=on-failure`
281
- * a nonzero code has systemd relaunch this daemon immediately, where it would
282
- * poll, be told again that it is not the machine, and stand down again — a
283
- * restart loop fighting a decision somebody made on purpose.
308
+ * EXIT 0, and this is load-bearing: every kind of stand-down was ASKED FOR, so
309
+ * it is not a failure. Under `Restart=on-failure` a nonzero code has systemd
310
+ * relaunch this daemon immediately — a stopped machine fighting the very
311
+ * command that stopped it, a displaced or removed one polling, being told
312
+ * again, and standing down again: a restart loop fighting a decision somebody
313
+ * made on purpose. Exit 0 reads as "the job is done" and leaves it down. For a
314
+ * stop, the server's honor window is what makes the other half work — a
315
+ * deliberate relaunch minutes later comes up clean instead of stopping itself
316
+ * forever.
284
317
  *
285
318
  * Every dependency is injected so the whole stand-down can be proved without a
286
319
  * server, a repo or a process to kill.
@@ -288,6 +321,7 @@ export function removedTurnSentence() {
288
321
  export async function standDownDisplaced({
289
322
  by,
290
323
  kind = 'moved',
324
+ reason,
291
325
  project,
292
326
  settleAgentTurns,
293
327
  flushReports,
@@ -295,69 +329,83 @@ export async function standDownDisplaced({
295
329
  exit,
296
330
  log = { warn: () => {}, note: () => {} },
297
331
  }) {
298
- /** A bound on a wedged uplink, and never a reason to hang: the timer is
299
- * unref'd, so the only thing that keeps this process alive is the work. */
300
- const bounded = (p, seconds) =>
301
- Promise.race([
302
- p,
303
- new Promise((resolve) => {
304
- const t = setTimeout(resolve, seconds * 1000);
305
- t.unref?.();
306
- }),
307
- ]);
308
332
  // Scrubbed before it reaches this box's own console (2026-09-24, the
309
333
  // audit) — see printable.mjs.
310
334
  const name = safeName(by)?.slice(0, 64) ?? null;
311
335
  /**
312
- * TWO KINDS, ONE CHOREOGRAPHY (2026-09-21).
336
+ * THREE KINDS, ONE CHOREOGRAPHY (2026-09-21; the third 2026-10-10, F091).
313
337
  *
314
338
  * `moved` is the original: another box took the machine, and it is the
315
339
  * DEFAULT so the displaced call site reads exactly as it always did.
316
340
  * `removed` is the project saying this box is not its machine at all any
317
- * more — somebody pressed a button in the app — and the only thing that
318
- * differs is the WORDS. Everything below this point is the same, and it must
319
- * be: settle the turns first, flush the reports, tear down the detached
320
- * children, keep the worktrees, exit 0.
341
+ * more — somebody pressed a button in the app. `stopped` is Flowviant
342
+ * commanding this machine to stop (`shouldStop`; `reason` is its already
343
+ * scrubbed sentence). Only the WORDS differ. Everything below the words is
344
+ * the same, and it must be: settle the turns first, flush the reports, tear
345
+ * down the detached children, keep the worktrees, exit 0.
346
+ *
347
+ * The commanded stop was a third, hand-written copy of these steps until
348
+ * 2026-10-10, and it had drifted: it never settled its agent turns, which
349
+ * then sat pending until the server's expiry while the board showed agents
350
+ * working on a machine somebody had just stopped.
321
351
  *
322
- * The sentences are picked HERE rather than passed in, so the two cannot
323
- * drift and neither call site can invent a third.
352
+ * The sentences are picked HERE rather than passed in, so the kinds cannot
353
+ * drift and no call site can invent a fourth.
324
354
  */
325
355
  const removed = kind === 'removed';
326
- const projectName = safeName(project)?.slice(0, 64) ?? 'this project';
327
- log.warn(
328
- removed
329
- ? `removed from ${projectName} in the app — stopping.`
330
- : name
331
- ? `this project's machine moved to ${name} — standing down.`
332
- : "this project's machine moved to another box — standing down."
333
- );
334
- if (removed) emitMachineEvent({ event: 'stopped', reason: 'removed' });
335
- else emitMachineEvent({ event: 'displaced', message: displacedTurnSentence(by) });
356
+ const stopped = kind === 'stopped';
357
+ if (stopped) {
358
+ // The stop's own words and event, as they always were.
359
+ emitMachineEvent({ event: 'stopped', reason: reason ?? null });
360
+ log.warn(reason ? `stopped by Flowviant — ${reason}` : 'stopped by Flowviant — no reason given.');
361
+ // "Worktrees are kept" and no more: this note used to add "in-flight work
362
+ // resumes next run", which a settled agent turn — Stuck, waiting for a
363
+ // person — no longer does.
364
+ log.note('shutting down — stopping workers. Worktrees are kept.');
365
+ } else {
366
+ const projectName = safeName(project)?.slice(0, 64) ?? 'this project';
367
+ log.warn(
368
+ removed
369
+ ? `removed from ${projectName} in the app — stopping.`
370
+ : name
371
+ ? `this project's machine moved to ${name} — standing down.`
372
+ : "this project's machine moved to another box — standing down."
373
+ );
374
+ if (removed) emitMachineEvent({ event: 'stopped', reason: 'removed' });
375
+ else emitMachineEvent({ event: 'displaced', message: displacedTurnSentence(by) });
376
+ }
336
377
  // FIRST, and awaited: an unsettled turn is the one thing here that no later
337
378
  // poll from anybody can fix — this process holds the only copy of the fact
338
379
  // that it was running.
339
380
  try {
340
381
  await bounded(
341
- settleAgentTurns(removed ? removedTurnSentence() : displacedTurnSentence(by)),
382
+ settleAgentTurns(
383
+ stopped ? stoppedTurnSentence() : removed ? removedTurnSentence() : displacedTurnSentence(by)
384
+ ),
342
385
  10
343
386
  );
344
387
  } catch {
345
388
  /* an unsettled turn expires server-side with words of its own */
346
389
  }
347
- // THEN the queued settles, bounded exactly as the commanded stop bounds them:
348
- // a queued report is a COMPLETED turn whose side effects already happened, and
349
- // dropping it re-runs the whole turn somewhere else.
390
+ // THEN the queued settles, bounded: a queued-but-undelivered report is a
391
+ // COMPLETED turn whose side effects already happened, and dropping it re-runs
392
+ // the whole turn — quota spent twice and every card write doubled. This path
393
+ // is async (unlike the signal handlers, which cannot await), so it can afford
394
+ // five seconds of delivery before it obeys.
350
395
  try {
351
396
  await bounded(flushReports(), 5);
352
397
  } catch {
353
398
  /* undelivered reports re-run; delivering them was best-effort */
354
399
  }
355
- // NOT optional, for the reason the commanded stop states: detached preview
356
- // tunnels survive this process by design, and a public hostname pointed into a
357
- // worktree on a box that no longer serves the project is the worst thing this
358
- // path can leave behind.
400
+ // teardown() is NOT optional. Detached preview tunnels survive this process
401
+ // BY DESIGN, so exiting without it strands a public hostname pointed into a
402
+ // worktree — on a box that no longer serves the project, or until somebody
403
+ // reboots a stopped one, which is precisely the state a remote stop is
404
+ // usually being used to end. It also kills the session CLIs and the wiki
405
+ // Claude, which would otherwise keep editing worktrees and burning quota for
406
+ // a machine nobody is watching any more.
359
407
  teardown();
360
- log.note('worktrees are kept — the branches here are the only copy of this box\'s work.');
408
+ if (!stopped) log.note('worktrees are kept — the branches here are the only copy of this box\'s work.');
361
409
  exit(0);
362
410
  }
363
411
 
@@ -382,39 +430,18 @@ export async function obeyRosterCommands(
382
430
  // going away.
383
431
  const stopSignal = shouldStop(roster.daemon);
384
432
  if (stopSignal) {
385
- emitMachineEvent({ event: 'stopped', reason: stopSignal.reason ?? null });
386
- log.warn(
387
- stopSignal.reason
388
- ? `stopped by Flowviant — ${stopSignal.reason}`
389
- : 'stopped by Flowviant — no reason given.'
390
- );
391
- log.note('shutting down — stopping workers. Worktrees are kept: in-flight work resumes next run.');
392
- // FLUSH the settle queue first, bounded: a queued-but-undelivered report
393
- // is a COMPLETED turn whose side effects already happened, and dropping
394
- // it re-runs the whole turn on the next start — quota spent twice and
395
- // every card write doubled. This path is async (unlike the signal
396
- // handlers, which cannot await), so the stop can afford five seconds of
397
- // delivery before it obeys.
398
- try {
399
- await Promise.race([flushWorkReports(), sleep(5)]);
400
- } catch {
401
- /* undelivered reports re-run; delivering them was best-effort */
402
- }
403
- // teardown() is NOT optional on this path. Detached preview tunnels
404
- // survive this process BY DESIGN, so exiting without it strands a public
405
- // hostname pointed into a worktree until somebody reboots the box — which
406
- // is precisely the state a remote stop is usually being used to end. It
407
- // also kills the session CLIs and the wiki Claude, which would otherwise
408
- // keep editing worktrees and burning quota for a machine nobody is
409
- // watching any more.
410
- teardown();
411
- // EXIT 0, and this is load-bearing: the stop was ASKED FOR, so it is not
412
- // a failure. Under `Restart=on-failure` a nonzero code has systemd
413
- // relaunch the daemon immediately, fighting the very command that stopped
414
- // it; exit 0 reads as "the job is done" and leaves it down. The server's
415
- // honor window is what makes the other half work — a deliberate relaunch
416
- // minutes later comes up clean instead of stopping itself forever.
417
- exit(0);
433
+ // The same choreography as a displacement (`standDownDisplaced`, kind
434
+ // `stopped`): its own words and event, then settle the agent turns, flush
435
+ // the queued reports, tear down, exit 0.
436
+ await standDownDisplaced({
437
+ kind: 'stopped',
438
+ reason: stopSignal.reason,
439
+ settleAgentTurns,
440
+ flushReports: flushWorkReports,
441
+ teardown,
442
+ exit,
443
+ log,
444
+ });
418
445
  return true;
419
446
  }
420
447
  /**