flowviant 0.116.0 → 0.124.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (161) hide show
  1. package/README.md +66 -12
  2. package/bin/cli.mjs +50 -17
  3. package/bin/lib/admission.mjs +7 -14
  4. package/bin/lib/agentAsk.mjs +123 -0
  5. package/bin/lib/agentBrief.mjs +36 -0
  6. package/bin/lib/agentFootprint.mjs +76 -0
  7. package/bin/lib/agentInternalWait.mjs +83 -0
  8. package/bin/lib/agentPlan.mjs +134 -31
  9. package/bin/lib/agentPrerequisiteContract.mjs +43 -0
  10. package/bin/lib/agentPublish.mjs +12 -13
  11. package/bin/lib/agentTaskKinds.mjs +5 -8
  12. package/bin/lib/agySessions.mjs +3 -3
  13. package/bin/lib/artifactContracts.mjs +91 -20
  14. package/bin/lib/artifactIteration.mjs +182 -0
  15. package/bin/lib/artifactPreview.mjs +92 -18
  16. package/bin/lib/artifactRecovery.mjs +36 -0
  17. package/bin/lib/artifactReporter.mjs +194 -22
  18. package/bin/lib/artifacts.mjs +70 -63
  19. package/bin/lib/authproxy.mjs +2 -1
  20. package/bin/lib/childEnv.mjs +9 -4
  21. package/bin/lib/claude.mjs +10 -20
  22. package/bin/lib/claudeAuth.mjs +15 -5
  23. package/bin/lib/claudePersonal.mjs +71 -6
  24. package/bin/lib/claudePosture.mjs +227 -54
  25. package/bin/lib/claudeSessions.mjs +7 -7
  26. package/bin/lib/claudeStream.mjs +27 -13
  27. package/bin/lib/cliLimit.mjs +2 -2
  28. package/bin/lib/codexHome.mjs +17 -0
  29. package/bin/lib/codexPersonal.mjs +44 -3
  30. package/bin/lib/codexProjectRead.mjs +63 -0
  31. package/bin/lib/codexRelay.mjs +21 -5
  32. package/bin/lib/codexResearch.mjs +193 -0
  33. package/bin/lib/commandSpecs.mjs +6 -0
  34. package/bin/lib/config.mjs +37 -24
  35. package/bin/lib/conversationIds.mjs +51 -0
  36. package/bin/lib/credentials.mjs +2 -7
  37. package/bin/lib/daemonLocks.mjs +90 -0
  38. package/bin/lib/daemonState.mjs +5 -5
  39. package/bin/lib/deploy.mjs +6 -9
  40. package/bin/lib/deployCheckout.mjs +26 -22
  41. package/bin/lib/deployRunner.mjs +54 -18
  42. package/bin/lib/deployWire.mjs +32 -23
  43. package/bin/lib/desktopContract.mjs +6 -5
  44. package/bin/lib/discoveryContract.mjs +19 -0
  45. package/bin/lib/env.mjs +104 -35
  46. package/bin/lib/fleet.mjs +53 -13
  47. package/bin/lib/fleetPost.mjs +172 -25
  48. package/bin/lib/fleetReports.mjs +172 -171
  49. package/bin/lib/fleetStartup.mjs +6 -6
  50. package/bin/lib/fleetWire.mjs +42 -0
  51. package/bin/lib/git.mjs +111 -61
  52. package/bin/lib/gitMergeIdentity.mjs +2 -8
  53. package/bin/lib/grant.mjs +23 -7
  54. package/bin/lib/holder.mjs +108 -81
  55. package/bin/lib/hooks/mcpAllow.mjs +60 -0
  56. package/bin/lib/hooks/readGuard.mjs +54 -11
  57. package/bin/lib/imageArtifact.mjs +74 -0
  58. package/bin/lib/install.mjs +80 -33
  59. package/bin/lib/instance.mjs +36 -83
  60. package/bin/lib/intakeDraft.mjs +4 -1
  61. package/bin/lib/knowledgeFileSync.mjs +31 -0
  62. package/bin/lib/knowledgeFiles.mjs +28 -83
  63. package/bin/lib/landed.mjs +22 -29
  64. package/bin/lib/launchCommand.mjs +91 -0
  65. package/bin/lib/listeners.mjs +0 -5
  66. package/bin/lib/localSessions.mjs +52 -39
  67. package/bin/lib/login.mjs +22 -12
  68. package/bin/lib/loginPath.mjs +22 -17
  69. package/bin/lib/machineListing.mjs +6 -3
  70. package/bin/lib/maintenance.mjs +47 -0
  71. package/bin/lib/maintenanceInventory.mjs +39 -0
  72. package/bin/lib/mcp-cli.mjs +9 -10
  73. package/bin/lib/mcpInstall.mjs +163 -0
  74. package/bin/lib/mcpInventory.mjs +119 -0
  75. package/bin/lib/noWindowEnv.mjs +23 -3
  76. package/bin/lib/ownedDirs.mjs +99 -0
  77. package/bin/lib/pinnedCapture.mjs +383 -0
  78. package/bin/lib/planDependencies.mjs +70 -0
  79. package/bin/lib/postureTools.mjs +85 -0
  80. package/bin/lib/prWorkflow.mjs +94 -17
  81. package/bin/lib/preflight.mjs +8 -25
  82. package/bin/lib/preview.mjs +19 -25
  83. package/bin/lib/previewGatePolicy.mjs +34 -9
  84. package/bin/lib/previewRegistry.mjs +4 -11
  85. package/bin/lib/printable.mjs +70 -7
  86. package/bin/lib/procRegistry.mjs +33 -13
  87. package/bin/lib/procScan.mjs +2 -3
  88. package/bin/lib/procStat.mjs +76 -0
  89. package/bin/lib/processGroup.mjs +48 -0
  90. package/bin/lib/processes.mjs +10 -24
  91. package/bin/lib/projectToolRuntimes.mjs +328 -19
  92. package/bin/lib/projectTools.mjs +109 -34
  93. package/bin/lib/prompts.mjs +205 -60
  94. package/bin/lib/repoPath.mjs +19 -0
  95. package/bin/lib/repoState.mjs +17 -19
  96. package/bin/lib/repositoryBaseline.mjs +68 -0
  97. package/bin/lib/runTurn.mjs +286 -209
  98. package/bin/lib/runtimeAntigravity.mjs +16 -5
  99. package/bin/lib/runtimeCapabilities.mjs +215 -21
  100. package/bin/lib/runtimeClaude.mjs +78 -10
  101. package/bin/lib/runtimeCodex.mjs +217 -209
  102. package/bin/lib/runtimeDetection.mjs +8 -2
  103. package/bin/lib/runtimeEvents.mjs +28 -11
  104. package/bin/lib/runtimeLimits.mjs +5 -6
  105. package/bin/lib/runtimeOfferings.mjs +99 -14
  106. package/bin/lib/runtimes.mjs +10 -0
  107. package/bin/lib/shipSweep.mjs +5 -3
  108. package/bin/lib/shot.mjs +91 -17
  109. package/bin/lib/sleep.mjs +11 -0
  110. package/bin/lib/startProject.mjs +5 -5
  111. package/bin/lib/statePaths.mjs +40 -0
  112. package/bin/lib/tty.mjs +25 -15
  113. package/bin/lib/turnDiagnostics.mjs +93 -0
  114. package/bin/lib/turnLock.mjs +7 -14
  115. package/bin/lib/turnProfile.mjs +16 -4
  116. package/bin/lib/ui.mjs +0 -3
  117. package/bin/lib/uninstall.mjs +30 -26
  118. package/bin/lib/update.mjs +79 -27
  119. package/bin/lib/uplinkScrub.mjs +296 -31
  120. package/bin/lib/vault.mjs +78 -10
  121. package/bin/lib/vaultDiff.mjs +5 -2
  122. package/bin/lib/views.mjs +37 -12
  123. package/bin/lib/wikiRunner.mjs +40 -61
  124. package/bin/lib/work.mjs +44 -13
  125. package/bin/lib/workAdoptCarry.mjs +2 -4
  126. package/bin/lib/workAgentBrief.mjs +92 -0
  127. package/bin/lib/workAgentBriefRunner.mjs +59 -0
  128. package/bin/lib/workAgentCheck.mjs +104 -57
  129. package/bin/lib/workAgentMerges.mjs +62 -57
  130. package/bin/lib/workAgentPlans.mjs +82 -199
  131. package/bin/lib/workAgentPrecheck.mjs +76 -138
  132. package/bin/lib/workAgentPublish.mjs +6 -8
  133. package/bin/lib/workAgentTurnBegun.mjs +15 -11
  134. package/bin/lib/workAgentTurnExecution.mjs +131 -67
  135. package/bin/lib/workAgentTurnOutcome.mjs +11 -2
  136. package/bin/lib/workAgentTurnReports.mjs +9 -37
  137. package/bin/lib/workAgentTurns.mjs +64 -7
  138. package/bin/lib/workArtifactReconcile.mjs +79 -0
  139. package/bin/lib/workAttachments.mjs +66 -11
  140. package/bin/lib/workBrain.mjs +9 -4
  141. package/bin/lib/workCappedTurn.mjs +119 -0
  142. package/bin/lib/workChildren.mjs +5 -5
  143. package/bin/lib/workCommandAudit.mjs +18 -2
  144. package/bin/lib/workDiffs.mjs +52 -6
  145. package/bin/lib/workIntake.mjs +35 -60
  146. package/bin/lib/workPlaceReports.mjs +7 -4
  147. package/bin/lib/workPlaces.mjs +113 -31
  148. package/bin/lib/workPreviews.mjs +47 -112
  149. package/bin/lib/workProcesses.mjs +8 -35
  150. package/bin/lib/workPullRequests.mjs +37 -56
  151. package/bin/lib/workReportQueue.mjs +100 -148
  152. package/bin/lib/workRetire.mjs +2 -1
  153. package/bin/lib/workSessionRuntime.mjs +38 -37
  154. package/bin/lib/workSessionTurns.mjs +256 -199
  155. package/bin/lib/workShip.mjs +43 -23
  156. package/bin/lib/workToolLog.mjs +70 -3
  157. package/bin/lib/workTurnShape.mjs +165 -0
  158. package/npm-shrinkwrap.json +69 -0
  159. package/package.json +9 -4
  160. package/bin/lib/fleetJobs.mjs +0 -120
  161. package/bin/lib/patchRevert.mjs +0 -60
@@ -78,6 +78,7 @@ import { constants, closeSync, fstatSync, lstatSync, mkdirSync, openSync, readdi
78
78
  import { createHash } from 'node:crypto';
79
79
  import { join } from 'node:path';
80
80
  import { cutKeepingExtension } from './safeFileName.mjs';
81
+ import { ensureRealSubdir, flowviantParentState, realDirState } from './ownedDirs.mjs';
81
82
  import {
82
83
  ARTIFACT_BINARY_MODEL_EXTS,
83
84
  ARTIFACT_BINARY_MODEL_MAX_BYTES,
@@ -119,23 +120,9 @@ export const ARTIFACT_MAX_FILES = ARTIFACT_MAX_PER_OWNER;
119
120
  * refuses the turn in words.
120
121
  */
121
122
  export function ensureArtifactDir(placeDir) {
122
- const parent = join(placeDir, '.flowviant');
123
- const dir = join(placeDir, ARTIFACT_DIR);
124
- const real = (p) => {
125
- try {
126
- const st = lstatSync(p);
127
- return st.isDirectory() && !st.isSymbolicLink() ? true : false;
128
- } catch (e) {
129
- return e?.code === 'ENOENT' ? null : false;
130
- }
131
- };
132
- if (real(parent) === false || real(dir) === false) return false;
133
- try {
134
- mkdirSync(dir, { recursive: true });
135
- } catch {
136
- return false;
137
- }
138
- return real(parent) === true && real(dir) === true;
123
+ // The rule's one home since 2026-10-10 (ownedDirs.mjs, the audit's F100):
124
+ // refused, never replaced, and both components checked again after the make.
125
+ return ensureRealSubdir(placeDir, ARTIFACT_DIR);
139
126
  }
140
127
 
141
128
  /**
@@ -159,25 +146,16 @@ export function ensureArtifactDir(placeDir) {
159
146
  */
160
147
  export const FENCE_TMP_DIR = '.flowviant/tmp';
161
148
  export function ensureFenceScratch(placeDir) {
162
- const parent = join(placeDir, '.flowviant');
163
149
  const dir = join(placeDir, FENCE_TMP_DIR);
164
- const kind = (p) => {
165
- try {
166
- const st = lstatSync(p);
167
- return st.isDirectory() && !st.isSymbolicLink() ? 'dir' : 'other';
168
- } catch (e) {
169
- return e?.code === 'ENOENT' ? 'none' : 'other';
170
- }
171
- };
172
- if (kind(parent) !== 'dir' || kind(dir) === 'other') return false;
150
+ if (flowviantParentState(placeDir) !== 'dir' || realDirState(dir) === 'other') return false;
173
151
  try {
174
152
  // A real directory (checked above): its contents go, never through a link.
175
- if (kind(dir) === 'dir') rmSync(dir, { recursive: true, force: true });
153
+ if (realDirState(dir) === 'dir') rmSync(dir, { recursive: true, force: true });
176
154
  mkdirSync(dir);
177
155
  } catch {
178
156
  return false;
179
157
  }
180
- return kind(parent) === 'dir' && kind(dir) === 'dir';
158
+ return flowviantParentState(placeDir) === 'dir' && realDirState(dir) === 'dir';
181
159
  }
182
160
 
183
161
  export function artifactTypeFor(name) {
@@ -187,46 +165,72 @@ export function artifactTypeFor(name) {
187
165
  }
188
166
 
189
167
  /**
190
- * The directory's regular files, newest first, at most `ARTIFACT_MAX_FILES`.
191
- * `[]` for a directory that does not exist, is not a directory, or is a
192
- * symlink — nothing to report is the only honest reading of any of those.
168
+ * HOW DEEP A BUNDLE GOES: files are listed to depth four (a path of up to five
169
+ * components), so a directory found AT depth four is never looked inside.
193
170
  */
194
- export function scanArtifacts(placeDir) {
171
+ export const ARTIFACT_MAX_DEPTH = 4;
172
+
173
+ /**
174
+ * THE ONE WALK OF THE ARTIFACTS DIRECTORY (2026-10-10, the audit's F067) —
175
+ * what the scan lists and what the check's fence keeps are read off the same
176
+ * entries, so the two cannot disagree about what an artifact is again. They
177
+ * did: the scan learned folders in 0.99.0 (a bundle's sidecars, the 3D-model
178
+ * page's `<name>/index.html`) and the fence went on removing every
179
+ * subdirectory whole, so a model agent whose repo declared a check lost its
180
+ * page the moment its queue emptied — and a send-back turn that did not
181
+ * rewrite it settled "nothing".
182
+ *
183
+ * `visit({ name, path, kind, st })` is called for every entry, depth first.
184
+ * `name` is the relative path; `kind` is one of:
185
+ * 'dir' — a real directory within depth: walked next
186
+ * 'file' — a regular file (any type: the allowlist is each caller's)
187
+ * 'hidden' — a dot-name, whatever it is; never looked at further
188
+ * 'deep' — a real directory past the depth: nothing in it is ever listed
189
+ * 'other' — a symlink, a fifo, a socket, a device: never followed or read
190
+ *
191
+ * `lstat` and never followed, and BOTH components checked first — `lstat` on
192
+ * `.flowviant/artifacts` refuses a symlink AT that name, but it resolves
193
+ * THROUGH a symlinked `.flowviant`, and git commits symlinks: a cloned repo
194
+ * carrying `.flowviant -> /some/dir` once had the scan listing, reading and
195
+ * uploading `/some/dir/artifacts/*` (2026-09-23). False, having visited
196
+ * nothing, when either is not a real directory.
197
+ */
198
+ export function walkArtifactDir(placeDir, visit) {
195
199
  const dir = join(placeDir, ARTIFACT_DIR);
196
- /**
197
- * BOTH components are checked, not only the last (2026-09-23). `lstat` on
198
- * `.flowviant/artifacts` refuses a symlink AT that name, but it resolves
199
- * THROUGH a symlinked `.flowviant` — and git commits symlinks, so a cloned
200
- * repo carrying `.flowviant -> /some/dir` would have had this scan listing,
201
- * reading and uploading `/some/dir/artifacts/*`, a directory outside the
202
- * place the turn stood in. The knowledge sync already refuses a `.flowviant`
203
- * that is not a real directory (`flowviantDirOk`); this is the same check on
204
- * the read side.
205
- */
206
- try {
207
- const parent = lstatSync(join(placeDir, '.flowviant'));
208
- if (parent.isSymbolicLink() || !parent.isDirectory()) return [];
209
- if (!lstatSync(dir).isDirectory()) return [];
210
- } catch {
211
- return [];
212
- }
213
- const out = [];
214
- const walk = (at, prefix = '', depth = 0) => {
215
- if (depth > 4) return;
200
+ if (flowviantParentState(placeDir) !== 'dir' || realDirState(dir) !== 'dir') return false;
201
+ const walk = (at, prefix, depth) => {
216
202
  let names;
217
203
  try { names = readdirSync(at); } catch { return; }
218
204
  for (const part of names) {
219
- if (part.startsWith('.')) continue;
220
205
  const name = prefix ? `${prefix}/${part}` : part;
221
206
  const path = join(at, part);
222
- try {
223
- const st = lstatSync(path);
224
- if (st.isDirectory() && !st.isSymbolicLink()) walk(path, name, depth + 1);
225
- else if (st.isFile() && !st.isSymbolicLink()) out.push({ name, path, size: st.size, mtimeMs: st.mtimeMs });
226
- } catch { /* vanished */ }
207
+ let st = null;
208
+ let kind = 'hidden';
209
+ if (!part.startsWith('.')) {
210
+ try { st = lstatSync(path); } catch { continue; /* vanished */ }
211
+ kind = st.isSymbolicLink() ? 'other'
212
+ : st.isDirectory() ? (depth < ARTIFACT_MAX_DEPTH ? 'dir' : 'deep')
213
+ : st.isFile() ? 'file'
214
+ : 'other';
215
+ }
216
+ visit({ name, path, kind, st });
217
+ if (kind === 'dir') walk(path, name, depth + 1);
227
218
  }
228
219
  };
229
- walk(dir);
220
+ walk(dir, '', 0);
221
+ return true;
222
+ }
223
+
224
+ /**
225
+ * The directory's regular files, newest first, at most `ARTIFACT_MAX_FILES`.
226
+ * `[]` for a directory that does not exist, is not a directory, or is a
227
+ * symlink — nothing to report is the only honest reading of any of those.
228
+ */
229
+ export function scanArtifacts(placeDir) {
230
+ const out = [];
231
+ walkArtifactDir(placeDir, ({ name, path, kind, st }) => {
232
+ if (kind === 'file') out.push({ name, path, size: st.size, mtimeMs: st.mtimeMs });
233
+ });
230
234
  out.sort((a, b) => b.mtimeMs - a.mtimeMs || a.name.localeCompare(b.name));
231
235
  return out.slice(0, ARTIFACT_MAX_FILES);
232
236
  }
@@ -239,8 +243,8 @@ export function snapshotArtifacts(placeDir) {
239
243
  }
240
244
 
241
245
  /** What the turn wrote: every entry whose (name, size, mtime) is not what the
242
- * snapshot held. A deleted file is not reported — the server's copy is
243
- * scrollback and goes with the session or the agent. */
246
+ * snapshot held. Deletions and replacement relationships are reported by
247
+ * artifactIteration.mjs separately from upload bodies. */
244
248
  export function changedArtifacts(before, entries) {
245
249
  return entries.filter((e) => before?.get(e.name) !== sigOf(e));
246
250
  }
@@ -289,6 +293,9 @@ function safeArtifactName(raw) {
289
293
  return clean ? cutKeepingExtension(clean) : 'artifact';
290
294
  }
291
295
 
296
+ /** The stored name is also the key for iteration and queued upload cancellation. */
297
+ export const artifactStoredName = (name) => name.split('/').map(safeArtifactName).join('/');
298
+
292
299
  /**
293
300
  * ONE UPLOAD BODY, as data — fields plus optional bytes — so a held body is
294
301
  * re-sent exactly as it was first built (scrubbed once, never re-read from a
@@ -299,7 +306,7 @@ export function buildArtifactUpload(entry, { sessionId, agentId, turnId }, scrub
299
306
  const fields = {
300
307
  ...(sessionId ? { sessionId } : { agentId }),
301
308
  ...(turnId ? { turnId } : {}),
302
- name: entry.name.split('/').map(safeArtifactName).join('/'),
309
+ name: artifactStoredName(entry.name),
303
310
  bytes: String(entry.size),
304
311
  };
305
312
  if (!type) return { fields, bytes: null }; // reported by name only
@@ -230,7 +230,8 @@ export function startAuthProxy({
230
230
  return;
231
231
  }
232
232
  // 4. The grant is a cookie, and a cookie can be ridden (header rule 4a's
233
- // backstop). Never a bounce — the caller HAS a credential; the request
233
+ // backstop) — and so can a cached Basic password, by a cross-site write
234
+ // (2026-10-10). Never a bounce — the caller HAS a credential; the request
234
235
  // SHAPE is what is refused.
235
236
  if (verdict === 'cross-site') {
236
237
  res.writeHead(403, { 'Content-Type': 'text/plain', ...noStore });
@@ -28,6 +28,9 @@
28
28
  * surface may describe anything here as "sandboxed" or "isolated".
29
29
  */
30
30
  import { MACHINE_CREDENTIAL_ENV } from './machineEnv.mjs';
31
+ // The variables that can carry Claude's auth in from the environment — the
32
+ // one list claudeAuth.mjs reads presence from (2026-10-10, the audit's F073).
33
+ import { AUTH_ENV_VARS } from './claudeAuth.mjs';
31
34
 
32
35
  /**
33
36
  * The complete kept set. Two groups, and both are here for a reason that bit
@@ -203,7 +206,8 @@ export const DEPLOY_KEEP_NAMES = [...DEPLOY_KEEP];
203
206
  * other half. Deriving it FROM that array is what makes forgetting impossible,
204
207
  * and it is why the merge happens inside `learnSecrets` rather than at a
205
208
  * call site somebody has to remember. The dependency direction is the only one
206
- * that is acyclic besides: this module imports nothing at all.
209
+ * that is acyclic besides: this module imports only two leaves (the name lists
210
+ * in machineEnv.mjs and claudeAuth.mjs), neither of which imports back.
207
211
  *
208
212
  * TWO NAMES ARE HERE THAT ARE NOT IN `DEPLOY_KEEP`, and the asymmetry is the
209
213
  * point. `CF_API_TOKEN` and `CF_ACCOUNT_ID` are wrangler's older spellings — it
@@ -249,9 +253,10 @@ const REDACT_ONLY = [
249
253
  // of (machineEnv.mjs): a name removed from children is redacted by
250
254
  // construction, never by a second hand-kept copy.
251
255
  ...MACHINE_CREDENTIAL_ENV,
252
- 'ANTHROPIC_API_KEY',
253
- 'ANTHROPIC_AUTH_TOKEN',
254
- 'CLAUDE_CODE_OAUTH_TOKEN',
256
+ // Every Claude auth variable, from the list claudeAuth.mjs reports presence
257
+ // from — derived, not re-listed, for the same reason as the line above
258
+ // (2026-10-10, the audit's F073).
259
+ ...AUTH_ENV_VARS,
255
260
  'OPENAI_API_KEY',
256
261
  'GH_TOKEN',
257
262
  'GITHUB_TOKEN',
@@ -1,9 +1,8 @@
1
1
  /**
2
2
  * The Flowviant side of driving a coding CLI that is not a posture, a stream
3
- * or a process: the sentinel protocol (a line the contract prompts require a
4
- * turn to end on) and the hand-over of the flowviant MCP server to whichever
5
- * runtime runs the turn. The hard rule baked into the prompts: there is no
6
- * interactive user — the only channel to a human is the blocker loop.
3
+ * or a process: the sentinel protocol (a line the wiki prompts require a turn
4
+ * to end on — WIKI_DONE, REGROUND_DONE) and the hand-over of the flowviant MCP
5
+ * server to whichever runtime runs the turn.
7
6
  *
8
7
  * This file used to hold everything about a turn. It was split by reason to
9
8
  * change (SOLID 2026-09-26): the contract prompts live in prompts.mjs, the
@@ -11,22 +10,21 @@
11
10
  * claudeStream.mjs, and the spawn-and-supervise of one turn in runTurn.mjs.
12
11
  * The argv, the binary and the event shape of each vendor come from each
13
12
  * CLI's registry row (runtime{Claude,Codex,Antigravity}.mjs, assembled in
14
- * runtimes.mjs; the stream's meaning in runtimeEvents.mjs).
13
+ * runtimes.mjs; the stream's meaning in runtimeEvents.mjs). The generic
14
+ * `sleep` that used to live here is a leaf of its own (sleep.mjs, 2026-10-10):
15
+ * importing it from here loaded the whole runtime registry into `login`.
15
16
  */
16
17
 
17
18
  import { runtimeById } from './runtimes.mjs';
18
19
 
19
- export const sleep = (s) => new Promise((r) => setTimeout(r, s * 1000));
20
-
21
20
  // Sentinels must appear on their OWN line (the prompts require it). Substring
22
21
  // matching falsely fired when an agent merely *mentioned* the word in prose
23
- // (e.g. "I won't fabricate a BLOCKED:<id> line"), trapping the worker in a fake
24
- // blocked loop. Anchor to a full line instead.
22
+ // (e.g. "I won't print WIKI_DONE until the pages are written"), ending a turn
23
+ // that had not finished. Anchor to a full line instead.
25
24
  //
26
25
  // LINE-SPLIT, NEVER A `^...$` REGEX WITH A `\s*` ON BOTH ENDS (audit
27
- // 2026-09-24). `new RegExp('^\\s*NAME\\s*$', 'm')` and
28
- // `/^\s*BLOCKED:(\S+)\s*$/m` are QUADRATIC against a long run of
29
- // whitespace on one line — the same shape `taskIdsFromMessage` already had
26
+ // 2026-09-24). `new RegExp('^\\s*NAME\\s*$', 'm')` is QUADRATIC against a
27
+ // long run of whitespace on one line — the same shape `taskIdsFromMessage` already had
30
28
  // to fix, measured ~0.5s at 40KB here — and a CLI's own stdout is exactly
31
29
  // the kind of text a stray control sequence or a pasted blob can grow past
32
30
  // that. A length cap first, then plain string work, same as there.
@@ -35,14 +33,6 @@ export const sawSentinel = (out, name) =>
35
33
  String(out ?? '')
36
34
  .split('\n')
37
35
  .some((l) => l.length < SENTINEL_MAX_LINE && l.trim() === name);
38
- export const blockedId = (out) => {
39
- for (const l of String(out ?? '').split('\n')) {
40
- if (l.length > SENTINEL_MAX_LINE) continue;
41
- const t = l.trim();
42
- if (t.startsWith('BLOCKED:') && /^BLOCKED:\S+$/.test(t)) return t.slice(8);
43
- }
44
- return null;
45
- };
46
36
 
47
37
  /**
48
38
  * Hand a runtime the flowviant MCP server, however that runtime wants it.
@@ -26,8 +26,9 @@
26
26
  * daemon started, so a variable exported weeks ago is still in there long
27
27
  * after it left your shell. A stale one fails in a way a fresh `claude`
28
28
  * never reproduces.
29
- * · THE HOME. The CLI reads its store from $HOME. A daemon under systemd, a
30
- * different user, or a container has a different one than the shell you
29
+ * · THE STORE. The CLI uses $CLAUDE_CONFIG_DIR, else $HOME/.claude. A daemon
30
+ * under systemd, a different user, or a container has a different one than
31
+ * the shell you
31
32
  * tested in, so "I am logged in" was measured on the wrong file.
32
33
  *
33
34
  * ── WHAT THIS IS NOT ──
@@ -75,11 +76,19 @@ export const AUTH_ENV_VARS = ['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'CLAU
75
76
  /** Claude Code's OAuth store, relative to the HOME the CLI will be handed. */
76
77
  const CRED_REL = ['.claude', '.credentials.json'];
77
78
 
79
+ /** The same location Claude's personal settings reader uses. */
80
+ export function claudeCredentialPath(env = process.env, home = null) {
81
+ const root = home ?? env.HOME ?? env.USERPROFILE ?? null;
82
+ return env.CLAUDE_CONFIG_DIR
83
+ ? join(env.CLAUDE_CONFIG_DIR, '.credentials.json')
84
+ : root ? join(root, ...CRED_REL) : null;
85
+ }
86
+
78
87
 
79
88
  /**
80
89
  * What the CLI on this machine will resolve, as far as it can be seen without
81
90
  * spending anything: no process spawned, no request made, no quota touched.
82
- * Just this process's own environment and one file's metadata.
91
+ * Just this process's own environment and the CLI-selected file's metadata.
83
92
  *
84
93
  * `env` and `home` are parameters rather than reads of `process` so the tests
85
94
  * can drive every branch — and so the caller passes the SAME environment the
@@ -98,9 +107,10 @@ export function claudeAuthContext({ env = process.env, home = null } = {}) {
98
107
  let refreshExpiresAt = null;
99
108
  let accessExpiresAt = null;
100
109
  let subscriptionType = null;
101
- if (HOME) {
110
+ const credentialPath = claudeCredentialPath(env, HOME);
111
+ if (credentialPath) {
102
112
  try {
103
- const raw = readFileSync(join(HOME, ...CRED_REL), 'utf8');
113
+ const raw = readFileSync(credentialPath, 'utf8');
104
114
  const oauth = JSON.parse(raw)?.claudeAiOauth;
105
115
  if (oauth && typeof oauth === 'object') {
106
116
  hasFile = true;
@@ -33,10 +33,22 @@
33
33
  * later CLI adds — a sandbox auto-allow, a permission mode — never rides by
34
34
  * default. It rides INSIDE the posture's own `--settings`, because the CLI
35
35
  * reads one: given two, only the last one's hook fired (measured).
36
+ *
37
+ * …AND NEVER ON THE COMMAND LINE (2026-10-10, the audit). What is kept is
38
+ * exactly where a person puts ANTHROPIC_API_KEY, an auth token or Bedrock's
39
+ * AWS keys (`env`, `apiKeyHelper`), and argv is every local user's to read
40
+ * (`ps`, /proc/<pid>/cmdline) for the life of the turn. So the merged object
41
+ * is written to a file only this user can read — 0600, in a 0700
42
+ * `~/.flowviant/run/` — and `--settings` names its path, which the CLI reads
43
+ * as a settings file ("--settings <file-or-json>"). Still exactly one
44
+ * `--settings`. The file goes when the turn settles (runTurn.mjs asks the
45
+ * adapter's `releaseArgs`), and a leftover from a process that is gone is
46
+ * swept on the next write.
36
47
  */
37
- import { readFileSync, statSync } from 'node:fs';
48
+ import { randomBytes } from 'node:crypto';
49
+ import { chmodSync, lstatSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
38
50
  import { homedir } from 'node:os';
39
- import { join } from 'node:path';
51
+ import { basename, dirname, join } from 'node:path';
40
52
  import { MACHINE_CREDENTIAL_ENV } from './machineEnv.mjs';
41
53
  import { WINDOW_ENV_REMOVED, withoutWindows } from './noWindowEnv.mjs';
42
54
 
@@ -129,17 +141,70 @@ export function personalClaudeSettings(env = process.env) {
129
141
  return out;
130
142
  }
131
143
 
144
+ /** Where a fenced turn's settings file lives — the daemon's own state
145
+ * directory, private to this user. */
146
+ export const turnSettingsDir = () => join(homedir(), '.flowviant', 'run');
147
+ /** A settings file of ours: the writing process's pid, then a random tag. */
148
+ const TURN_SETTINGS_FILE = /^settings-(\d+)-[0-9a-f]{16}\.json$/;
149
+
150
+ const pidAlive = (pid) => {
151
+ try {
152
+ process.kill(pid, 0);
153
+ return true;
154
+ } catch (error) {
155
+ return error?.code === 'EPERM';
156
+ }
157
+ };
158
+
159
+ /**
160
+ * Write `settings` to a fresh 0600 file in the run directory and return its
161
+ * path. The directory is made 0700 (and tightened if it was not), and must be
162
+ * a real directory — a link standing there is refused, never written through.
163
+ * A file another process left behind is removed first if that process is gone;
164
+ * this process's own files are live turns'. THROWS when it cannot write: the
165
+ * turn then fails in words rather than carrying the person's settings on argv.
166
+ */
167
+ export function writeTurnSettings(settings, { dir = turnSettingsDir() } = {}) {
168
+ try {
169
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
170
+ if (!lstatSync(dir).isDirectory()) throw new Error(`${dir} is not a directory`);
171
+ chmodSync(dir, 0o700);
172
+ for (const name of readdirSync(dir)) {
173
+ const m = TURN_SETTINGS_FILE.exec(name);
174
+ if (!m || Number(m[1]) === process.pid || pidAlive(Number(m[1]))) continue;
175
+ rmSync(join(dir, name), { force: true });
176
+ }
177
+ const path = join(dir, `settings-${process.pid}-${randomBytes(8).toString('hex')}.json`);
178
+ writeFileSync(path, JSON.stringify(settings), { mode: 0o600, flag: 'wx' });
179
+ return path;
180
+ } catch (error) {
181
+ throw new Error(`cannot write this turn's Claude settings file under ${dir} (${error?.message ?? error}) — the person's settings never ride the command line`);
182
+ }
183
+ }
184
+
185
+ /** Remove the settings file a turn's argv names, when it is one of ours. */
186
+ export function releaseTurnSettings(args, { dir = turnSettingsDir() } = {}) {
187
+ if (!Array.isArray(args)) return;
188
+ args.forEach((a, i) => {
189
+ if (i === 0 || args[i - 1] !== '--settings' || typeof a !== 'string') return;
190
+ if (dirname(a) !== dir || !TURN_SETTINGS_FILE.test(basename(a))) return;
191
+ rmSync(a, { force: true });
192
+ });
193
+ }
194
+
132
195
  /**
133
196
  * `perm` with `kept` folded into its `--settings` — the posture's keys win, so
134
197
  * a kept key a posture ever names is the fence's word. A posture with no
135
198
  * `--settings` gets one before its lists (`--allowedTools` is variadic). With
136
- * nothing kept, `perm` is returned as it was.
199
+ * nothing kept, `perm` is returned as it was (the posture's own read guard
200
+ * names no secret). With something kept, the one `--settings` names the
201
+ * merged object's file (`write`, writeTurnSettings) and never carries it.
137
202
  */
138
- export function withPersonalSettings(perm, kept) {
203
+ export function withPersonalSettings(perm, kept, { write = writeTurnSettings } = {}) {
139
204
  const personal = kept && typeof kept === 'object' && !Array.isArray(kept) ? kept : {};
140
205
  if (!Object.keys(personal).length) return [...perm];
141
206
  const at = perm.indexOf('--settings');
142
- if (at < 0) return ['--settings', JSON.stringify(personal), ...perm];
207
+ if (at < 0) return ['--settings', write(personal), ...perm];
143
208
  const posture = JSON.parse(perm[at + 1]);
144
- return [...perm.slice(0, at), '--settings', JSON.stringify({ ...personal, ...posture }), ...perm.slice(at + 2)];
209
+ return [...perm.slice(0, at), '--settings', write({ ...personal, ...posture }), ...perm.slice(at + 2)];
145
210
  }