flowviant 0.116.0 → 0.117.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/README.md +31 -12
  2. package/bin/cli.mjs +13 -17
  3. package/bin/lib/admission.mjs +7 -14
  4. package/bin/lib/agentInternalWait.mjs +83 -0
  5. package/bin/lib/agentPlan.mjs +56 -24
  6. package/bin/lib/agentPublish.mjs +12 -13
  7. package/bin/lib/agentTaskKinds.mjs +4 -7
  8. package/bin/lib/agySessions.mjs +3 -3
  9. package/bin/lib/artifactPreview.mjs +92 -18
  10. package/bin/lib/artifactReporter.mjs +16 -8
  11. package/bin/lib/artifacts.mjs +64 -60
  12. package/bin/lib/authproxy.mjs +2 -1
  13. package/bin/lib/childEnv.mjs +9 -4
  14. package/bin/lib/claude.mjs +10 -20
  15. package/bin/lib/claudePersonal.mjs +71 -6
  16. package/bin/lib/claudePosture.mjs +119 -42
  17. package/bin/lib/claudeSessions.mjs +7 -7
  18. package/bin/lib/claudeStream.mjs +7 -11
  19. package/bin/lib/cliLimit.mjs +2 -2
  20. package/bin/lib/codexHome.mjs +17 -0
  21. package/bin/lib/codexPersonal.mjs +44 -3
  22. package/bin/lib/codexRelay.mjs +21 -5
  23. package/bin/lib/codexResearch.mjs +180 -0
  24. package/bin/lib/config.mjs +37 -24
  25. package/bin/lib/conversationIds.mjs +51 -0
  26. package/bin/lib/credentials.mjs +2 -7
  27. package/bin/lib/daemonLocks.mjs +88 -0
  28. package/bin/lib/daemonState.mjs +5 -5
  29. package/bin/lib/deploy.mjs +6 -9
  30. package/bin/lib/deployCheckout.mjs +26 -22
  31. package/bin/lib/deployRunner.mjs +54 -18
  32. package/bin/lib/deployWire.mjs +32 -23
  33. package/bin/lib/desktopContract.mjs +3 -5
  34. package/bin/lib/env.mjs +104 -35
  35. package/bin/lib/fleet.mjs +7 -9
  36. package/bin/lib/fleetPost.mjs +172 -25
  37. package/bin/lib/fleetReports.mjs +130 -170
  38. package/bin/lib/fleetStartup.mjs +6 -6
  39. package/bin/lib/fleetWire.mjs +42 -0
  40. package/bin/lib/git.mjs +111 -61
  41. package/bin/lib/gitMergeIdentity.mjs +2 -8
  42. package/bin/lib/grant.mjs +23 -7
  43. package/bin/lib/holder.mjs +108 -81
  44. package/bin/lib/hooks/readGuard.mjs +41 -7
  45. package/bin/lib/install.mjs +80 -33
  46. package/bin/lib/instance.mjs +36 -83
  47. package/bin/lib/intakeDraft.mjs +4 -1
  48. package/bin/lib/knowledgeFileSync.mjs +31 -0
  49. package/bin/lib/knowledgeFiles.mjs +28 -83
  50. package/bin/lib/landed.mjs +22 -29
  51. package/bin/lib/launchCommand.mjs +91 -0
  52. package/bin/lib/listeners.mjs +0 -5
  53. package/bin/lib/localSessions.mjs +52 -39
  54. package/bin/lib/login.mjs +22 -12
  55. package/bin/lib/loginPath.mjs +22 -17
  56. package/bin/lib/machineListing.mjs +6 -3
  57. package/bin/lib/mcp-cli.mjs +9 -10
  58. package/bin/lib/noWindowEnv.mjs +23 -3
  59. package/bin/lib/ownedDirs.mjs +99 -0
  60. package/bin/lib/pinnedCapture.mjs +383 -0
  61. package/bin/lib/prWorkflow.mjs +94 -17
  62. package/bin/lib/preflight.mjs +8 -25
  63. package/bin/lib/preview.mjs +19 -25
  64. package/bin/lib/previewGatePolicy.mjs +34 -9
  65. package/bin/lib/previewRegistry.mjs +4 -11
  66. package/bin/lib/printable.mjs +70 -7
  67. package/bin/lib/procRegistry.mjs +33 -13
  68. package/bin/lib/procScan.mjs +2 -3
  69. package/bin/lib/procStat.mjs +76 -0
  70. package/bin/lib/processGroup.mjs +48 -0
  71. package/bin/lib/processes.mjs +10 -24
  72. package/bin/lib/projectToolRuntimes.mjs +101 -16
  73. package/bin/lib/projectTools.mjs +50 -32
  74. package/bin/lib/prompts.mjs +41 -19
  75. package/bin/lib/repoPath.mjs +19 -0
  76. package/bin/lib/repoState.mjs +17 -19
  77. package/bin/lib/runTurn.mjs +268 -210
  78. package/bin/lib/runtimeAntigravity.mjs +16 -5
  79. package/bin/lib/runtimeCapabilities.mjs +140 -20
  80. package/bin/lib/runtimeClaude.mjs +55 -5
  81. package/bin/lib/runtimeCodex.mjs +245 -205
  82. package/bin/lib/runtimeDetection.mjs +2 -2
  83. package/bin/lib/runtimeEvents.mjs +16 -10
  84. package/bin/lib/runtimeLimits.mjs +5 -6
  85. package/bin/lib/runtimeOfferings.mjs +24 -11
  86. package/bin/lib/runtimes.mjs +10 -0
  87. package/bin/lib/shipSweep.mjs +5 -3
  88. package/bin/lib/shot.mjs +91 -17
  89. package/bin/lib/sleep.mjs +11 -0
  90. package/bin/lib/startProject.mjs +5 -5
  91. package/bin/lib/statePaths.mjs +40 -0
  92. package/bin/lib/tty.mjs +25 -15
  93. package/bin/lib/turnLock.mjs +7 -14
  94. package/bin/lib/turnProfile.mjs +1 -1
  95. package/bin/lib/ui.mjs +0 -3
  96. package/bin/lib/uninstall.mjs +30 -26
  97. package/bin/lib/update.mjs +79 -27
  98. package/bin/lib/uplinkScrub.mjs +296 -31
  99. package/bin/lib/vault.mjs +78 -10
  100. package/bin/lib/vaultDiff.mjs +5 -2
  101. package/bin/lib/views.mjs +37 -12
  102. package/bin/lib/wikiRunner.mjs +40 -61
  103. package/bin/lib/work.mjs +17 -7
  104. package/bin/lib/workAdoptCarry.mjs +2 -4
  105. package/bin/lib/workAgentCheck.mjs +104 -57
  106. package/bin/lib/workAgentMerges.mjs +62 -57
  107. package/bin/lib/workAgentPlans.mjs +71 -139
  108. package/bin/lib/workAgentPrecheck.mjs +71 -138
  109. package/bin/lib/workAgentPublish.mjs +6 -8
  110. package/bin/lib/workAgentTurnBegun.mjs +15 -11
  111. package/bin/lib/workAgentTurnExecution.mjs +46 -53
  112. package/bin/lib/workAgentTurnReports.mjs +9 -37
  113. package/bin/lib/workAgentTurns.mjs +4 -3
  114. package/bin/lib/workAttachments.mjs +66 -11
  115. package/bin/lib/workBrain.mjs +9 -4
  116. package/bin/lib/workCappedTurn.mjs +109 -0
  117. package/bin/lib/workChildren.mjs +5 -5
  118. package/bin/lib/workCommandAudit.mjs +11 -2
  119. package/bin/lib/workDiffs.mjs +52 -6
  120. package/bin/lib/workIntake.mjs +35 -60
  121. package/bin/lib/workPlaceReports.mjs +7 -4
  122. package/bin/lib/workPlaces.mjs +96 -30
  123. package/bin/lib/workPreviews.mjs +47 -112
  124. package/bin/lib/workProcesses.mjs +8 -35
  125. package/bin/lib/workPullRequests.mjs +37 -56
  126. package/bin/lib/workReportQueue.mjs +100 -148
  127. package/bin/lib/workRetire.mjs +2 -1
  128. package/bin/lib/workSessionRuntime.mjs +38 -37
  129. package/bin/lib/workSessionTurns.mjs +111 -189
  130. package/bin/lib/workShip.mjs +43 -23
  131. package/bin/lib/workTurnShape.mjs +163 -0
  132. package/npm-shrinkwrap.json +59 -0
  133. package/package.json +3 -2
  134. package/bin/lib/fleetJobs.mjs +0 -120
  135. package/bin/lib/patchRevert.mjs +0 -60
@@ -91,12 +91,22 @@ function fencedReads(knowledgeDir) {
91
91
  ];
92
92
  }
93
93
 
94
- /** The shell readers CONSULT and PLAN keep. Each one is PATH-VALIDATED by the
95
- * CLI itself — measured on 2.1.281 (2026-09-24): with `Bash(cat:*)`,
96
- * `Bash(head:*)` and `Bash(ls:*)` allowed, `cat`/`ls`/`head` on a file outside
97
- * the working directories were each refused ("Claude Code may only
98
- * concatenate files from the allowed working directories") — so it is the
99
- * Read/Grep/Glob TOOLS, not these, that needed the fence above. */
94
+ /** The shell readers CONSULT and PLAN keep. `cat`, `ls` and `head` are
95
+ * PATH-VALIDATED by the CLI itself — measured on 2.1.281 (2026-09-24): with
96
+ * `Bash(cat:*)`, `Bash(head:*)` and `Bash(ls:*)` allowed, each was refused a
97
+ * file outside the working directories ("Claude Code may only concatenate
98
+ * files from the allowed working directories") — so it is the Read/Grep/Glob
99
+ * TOOLS, not these, that needed the fence above.
100
+ *
101
+ * THE GIT READERS WERE NEVER MEASURED THAT WAY (2026-10-10, the audit), and
102
+ * `git diff` reads any file on the box: `--no-index` compares two arbitrary
103
+ * paths, and git switches to it on its own when a path lies outside the work
104
+ * tree (`git diff ~/.ssh/id_ed25519 /dev/null` prints the key). So the read
105
+ * guard is their path fence: it refuses `--no-index`, and a git argument that
106
+ * is absolute, `~`-rooted or climbs a `..` segment (revision ranges `a..b`
107
+ * still pass). It also refuses every one of these readers the checkout's
108
+ * secret files by name — the shell's half of CHECKOUT_SECRETS below, since a
109
+ * Read deny says nothing about `cat`. See hooks/readGuard.mjs. */
100
110
  const CONSULT_BASH = [
101
111
  'Bash(ls:*)',
102
112
  'Bash(wc:*)',
@@ -108,6 +118,14 @@ const CONSULT_BASH = [
108
118
  'Bash(git rev-parse:*)',
109
119
  ];
110
120
 
121
+ /** The checkout's own secrets, denied BY NAME on the postures that read the
122
+ * repo and hand what they read to someone (2026-10-10, the audit): consult
123
+ * (intake's brief lands with no press), plan and the wiki (every page is
124
+ * uploaded). Research's measured pair — `Read(./**)` read `.env` without it —
125
+ * plus Wrangler's `.dev.vars`, which no root `.env*` scan sees. The read guard
126
+ * refuses the same files to the shell readers (hooks/readGuard.mjs). */
127
+ const CHECKOUT_SECRETS = ['Read(./.env*)', 'Read(./**/.env*)', 'Read(./**/.dev.vars)'];
128
+
111
129
  /**
112
130
  * PLAN — read the repo, write the plan, never the code.
113
131
  *
@@ -120,10 +138,11 @@ const CONSULT_BASH = [
120
138
  * hijacked turn's most destructive reachable act is dropping a slice from the
121
139
  * plan it is already in, which a human can see and undo in the thread.
122
140
  *
123
- * Note what is absent versus WIKI_PERM: Write, Edit, mkdir and rm. The
124
- * cartographer needs those because it authors files; a planner authors records
125
- * through an API, and there is no file on this machine it has any business
126
- * touching.
141
+ * Note what is absent versus the wiki's list: Edit, mkdir and rm. The
142
+ * cartographer needs those because it authors files (in the vault, and only
143
+ * there); a planner authors records through an API, and there is no file on
144
+ * this machine it has any business touching. The checkout's secrets are denied
145
+ * by name (CHECKOUT_SECRETS), as on consult.
127
146
  */
128
147
  export function planPermFor(knowledgeDir) {
129
148
  return [
@@ -132,6 +151,8 @@ export function planPermFor(knowledgeDir) {
132
151
  'mcp__flowviant',
133
152
  ...fencedReads(knowledgeDir),
134
153
  ...CONSULT_BASH,
154
+ '--disallowedTools',
155
+ ...CHECKOUT_SECRETS,
135
156
  ];
136
157
  }
137
158
 
@@ -153,24 +174,13 @@ const PERM = SAFE
153
174
  ]
154
175
  : ['--dangerously-skip-permissions'];
155
176
 
156
- // Wiki turns are read-the-repo + write-the-vault ONLY — always curated, never
157
- // --dangerously-skip-permissions: no gh, no push-capable git, no package
158
- // managers, and nothing that can EXECUTE arbitrary commands — no `find`
159
- // (-exec/-delete) and no `git grep` (-O<pager> runs a shell; the Grep tool
160
- // covers search). Command execution is the line: it enables network exfil,
161
- // which plain file writes never do. `rm` IS allowed: pruning a stale vault
162
- // page requires a real file deletion (that's how the sync protocol learns of
163
- // it), and the blast radius is bounded — the daemon resets the repo worktree
164
- // after every wiki turn, and the vault has its own git history.
165
- // (Write/Edit can't be path-scoped here; the worktree reset is the backstop.)
166
- const WIKI_PERM = [
167
- ...READ_GUARD,
168
- '--allowedTools',
169
- 'Read',
170
- 'Grep',
171
- 'Glob',
172
- 'Edit',
173
- 'Write',
177
+ /** The cartographer's shell: the consult readers, plus `mkdir` and `rm` for
178
+ * the vault (pruning a stale page needs a real deletion — that is how the
179
+ * sync protocol learns of it) and `git status`. The CLI path-validates
180
+ * `mkdir`/`rm` to the working directories, which is why the vault is handed
181
+ * over by `--add-dir` (runtimeClaude.mjs). No `find` (-exec/-delete) and no
182
+ * `git grep` (-O<pager> runs a shell; the Grep tool covers search). */
183
+ const WIKI_BASH = [
174
184
  'Bash(ls:*)',
175
185
  'Bash(wc:*)',
176
186
  'Bash(head:*)',
@@ -184,19 +194,86 @@ const WIKI_PERM = [
184
194
  'Bash(git rev-parse:*)',
185
195
  ];
186
196
 
197
+ /**
198
+ * THE CARTOGRAPHER — read the repo, write the vault, and nothing else
199
+ * (2026-10-10, the audit: it joins the fenced postures of 2026-09-24).
200
+ *
201
+ * It ran on bare Read/Grep/Glob/Edit/Write, and its comment said file writes
202
+ * "never" exfiltrate and the worktree reset was the backstop. Neither held.
203
+ * The vault is an UPLINK — every page is synced to the project's wiki — so a
204
+ * bare Read of `~/.flowviant/credentials.json` copied into a page leaves the
205
+ * box; and the reset cleans the wiki worktree only, never a write outside it:
206
+ * `<vault>/.git/config` with a `core.fsmonitor` the daemon's own `git add`
207
+ * then ran, the checkout's `.flowviant/check.json`, a shell rc file. This turn
208
+ * runs UNATTENDED after every merge, on a member-written card title and on
209
+ * repo text, so the list is the enforcement and the prompt is only prose.
210
+ *
211
+ * Reads are the read fence (`fencedReads`: the worktree, the knowledge library
212
+ * when one is passed) plus the vault by its absolute path; Grep carries no
213
+ * allow rule, as in consult. The ONE write is `Edit(//<vault>/**)`, the vault's
214
+ * `.git` is denied both ways, and the checkout's secrets are denied by name.
215
+ *
216
+ * PROBED on Claude Code 2.1.296 before relying on it (2026-10-10), this exact
217
+ * list as the adapter builds it (`--add-dir <vault>`, stated `default` mode),
218
+ * cwd a detached worktree, the vault under `<home>/.flowviant/vaults/<id>`, a
219
+ * `-p` turn told to try each in turn:
220
+ * · ALLOWED: Write of a new `<vault>/pages/x.md` and Edit of `<vault>/index.md`
221
+ * (the Edit rule governs Write too; where ARTIFACT_WRITE's 2.1.281 note
222
+ * below reads anchored spellings as no write, this `//`-anchored Edit rule
223
+ * scoped as written on 2.1.296); `ls`, `rm` and `mkdir` in the vault; Read
224
+ * and Glob in the vault and the worktree.
225
+ * · REFUSED: Write of a canary outside both ("Claude requested permissions to
226
+ * write to …, but you haven't granted it yet"), of the checkout's
227
+ * `.flowviant/check.json`, of a file in the worktree itself, and of
228
+ * `<vault>/.git/config` ("File is in a directory that is denied by your
229
+ * permission settings"); Read of `<home>/.flowviant/credentials.json`, of the
230
+ * checkout's `.git/config`, and of the worktree's `.env` (denied by name).
231
+ * Every refused write left the disk unchanged.
232
+ * · CONTROLS: without the Edit rule the vault page was refused too — so
233
+ * `--add-dir` admits reads, never writes, and the rule is load-bearing;
234
+ * without the `.git` deny, the `.git/config` write was still refused by the
235
+ * CLI's own check ("… which is a sensitive file") — so the deny is a belt
236
+ * on this CLI, kept because that check is the vendor's and unpinned.
237
+ *
238
+ * A vault no permission rule can name (not absolute, or carrying glob or rule
239
+ * syntax) gets NO list: `claudePermFor` answers null and Claude's adapter
240
+ * refuses the turn in words, rather than running it unfenced or unable to
241
+ * write.
242
+ */
243
+ export function wikiPermFor(knowledgeDir, vaultDir) {
244
+ const vault = ruleSafeAbsolute(vaultDir);
245
+ if (!vault) return null;
246
+ return [
247
+ ...READ_GUARD,
248
+ '--allowedTools',
249
+ ...fencedReads(knowledgeDir),
250
+ `Read(/${vault}/**)`,
251
+ `Glob(/${vault}/**)`,
252
+ `Edit(/${vault}/**)`,
253
+ ...WIKI_BASH,
254
+ '--disallowedTools',
255
+ ...CHECKOUT_SECRETS,
256
+ `Read(/${vault}/.git/**)`,
257
+ `Edit(/${vault}/.git/**)`,
258
+ ];
259
+ }
260
+
187
261
  // A CONSULT reads and answers. Nothing else.
188
262
  //
189
- // It used to run on WIKI_PERM, whose comment two blocks up says the quiet part:
190
- // Write/Edit "can't be path-scoped here; the worktree reset is the backstop".
191
- // That is a fine trade for the cartographer, which exists to author files and
192
- // gets reset after every turn. It is the wrong trade for a consult, whose prompt
193
- // is steered by a question ANY project editor can write and which had no reset
194
- // behind it — so a sentence in a chat box could reach Write, rm and mkdir on
195
- // someone else's machine. The permission list is the enforcement; the prompt's
196
- // "do not change anything" is only an instruction, and instructions are exactly
197
- // what an injected question competes with.
263
+ // It used to run on the cartographer's old list, whose comment said the quiet
264
+ // part: Write/Edit "can't be path-scoped here; the worktree reset is the
265
+ // backstop". That was the wrong trade for a consult, whose prompt is steered
266
+ // by a question ANY project editor can write and which had no reset behind it
267
+ // — so a sentence in a chat box could reach Write, rm and mkdir on someone
268
+ // else's machine (and it was the wrong trade for the cartographer too: see
269
+ // `wikiPermFor`). The permission list is the enforcement; the prompt's "do not
270
+ // change anything" is only an instruction, and instructions are exactly what
271
+ // an injected question competes with.
272
+ //
273
+ // The checkout's secrets are denied by name (2026-10-10): a consult's answer
274
+ // leaves the box, and intake's drafted brief lands on the board with no press.
198
275
  export function consultPermFor(knowledgeDir) {
199
- return [...READ_GUARD, '--allowedTools', ...fencedReads(knowledgeDir), ...CONSULT_BASH];
276
+ return [...READ_GUARD, '--allowedTools', ...fencedReads(knowledgeDir), ...CONSULT_BASH, '--disallowedTools', ...CHECKOUT_SECRETS];
200
277
  }
201
278
 
202
279
  /**
@@ -306,7 +383,7 @@ const ARTIFACT_WRITE = 'Edit(.flowviant/artifacts/**)';
306
383
  * `readOnlyHint: true`, and none of the session tools (stream_session_turn,
307
384
  * log_work, file_card …) are read-only or annotated so. So a plan turn runs
308
385
  * PLAIN — no MCP, the plain tab's contract — rather than mounting a control
309
- * plane whose every call the CLI refuses. See workSessionTurns.mjs.
386
+ * plane whose every call the CLI refuses. See workTurnShape.mjs.
310
387
  */
311
388
  export const PLAN_MODE_PERM = ['--permission-mode', 'plan'];
312
389
  export function designPermFor(knowledgeDir) {
@@ -355,7 +432,7 @@ export const RESEARCH_PERM = researchPerm(null);
355
432
  */
356
433
  const CLAUDE_PERM = {
357
434
  build: () => PERM,
358
- wiki: () => WIKI_PERM,
435
+ wiki: (kd, vaultDir) => wikiPermFor(kd, vaultDir),
359
436
  consult: (kd) => consultPermFor(kd),
360
437
  plan: (kd) => planPermFor(kd),
361
438
  'plan-mode': () => PLAN_MODE_PERM,
@@ -370,8 +447,8 @@ const CLAUDE_PERM = {
370
447
  image: (kd) => consultPermFor(kd),
371
448
  };
372
449
  export const CLAUDE_PERM_PROFILES = Object.freeze(Object.keys(CLAUDE_PERM));
373
- export function claudePermFor(profileName, knowledgeDir) {
450
+ export function claudePermFor(profileName, knowledgeDir, vaultDir) {
374
451
  const build = Object.hasOwn(CLAUDE_PERM, profileName) ? CLAUDE_PERM[profileName] : null;
375
452
  if (!build) throw new Error(`no Claude permission list for profile '${profileName}'`);
376
- return build(knowledgeDir);
453
+ return build(knowledgeDir, vaultDir);
377
454
  }
@@ -24,6 +24,8 @@ import {
24
24
  import { homedir } from 'node:os';
25
25
  import { join } from 'node:path';
26
26
  import { execFileSync } from 'node:child_process';
27
+ import { parseProcStat } from './procStat.mjs';
28
+ import { CLAUDE_SESSION_RE } from './conversationIds.mjs';
27
29
 
28
30
  // ENDED sessions are the adoptable inventory, and the useful ones are FRESH:
29
31
  // newest per directory, few. (The window itself is the coordinator's.)
@@ -87,7 +89,7 @@ function transcriptTitle(file, mtimeMs) {
87
89
  * the tab keeps whatever name it has.
88
90
  */
89
91
  export function titleForSession(cwd, sessionId) {
90
- if (!cwd || typeof sessionId !== 'string' || !/^[A-Za-z0-9_-]{8,64}$/.test(sessionId)) {
92
+ if (!cwd || typeof sessionId !== 'string' || !CLAUDE_SESSION_RE.test(sessionId)) {
91
93
  return null;
92
94
  }
93
95
  const projects = join(homedir(), '.claude', 'projects');
@@ -137,8 +139,8 @@ export function claudeProjectDir(path) {
137
139
  * else, the file stays. `/proc/<pid>` existing only proves A process; the
138
140
  * starttime (field 22 of /proc/<pid>/stat) proves it is THAT process. The comm
139
141
  * field (parenthesised, may itself contain spaces and parens) makes naive
140
- * whitespace-splitting wrong, so fields are counted from after the LAST ')':
141
- * the first post-comm field is field 3, which puts starttime at index 19.
142
+ * whitespace-splitting wrong, so the line is read by procStat.mjs, the one
143
+ * parser that knows where `comm` ends.
142
144
  */
143
145
  function pidAlive(pid, procStart) {
144
146
  if (!Number.isInteger(pid) || pid <= 0) return false;
@@ -174,10 +176,8 @@ function pidAlive(pid, procStart) {
174
176
  return false; // /proc is real and has no entry — the process is gone
175
177
  }
176
178
  if (procStart == null) return true; // nothing recorded to compare against
177
- const close = stat.lastIndexOf(')');
178
- if (close === -1) return false;
179
- const fields = stat.slice(close + 1).trim().split(/\s+/);
180
- return fields[19] === String(procStart);
179
+ const fields = parseProcStat(stat);
180
+ return fields !== null && String(fields.starttime) === String(procStart);
181
181
  }
182
182
 
183
183
  /**
@@ -12,16 +12,12 @@
12
12
  import { humanizeClaudeTool, THINK_MARKER } from './runtimeEvents.mjs';
13
13
  import { turnModelOf } from './turnModel.mjs';
14
14
 
15
- // Turn one Claude tool_use into a compact activity {kind, label}, or null for
16
- // tools not worth surfacing. `kind:'read'` is what the file counter counts; a
17
- // Write/Edit of a vault page is the "writing" signal. Used by wiki turns to
18
- // stream exactly which files Claude is touching (daemon console + app cover).
19
- //
20
- // The body moved to runtimeEvents.mjs, beside Codex's equivalent, because they are
21
- // the same job for two vendors and keeping them apart is how the two activity
22
- // vocabularies drift. Re-exported under its original name: a dozen call sites
23
- // know it, and none of them care where it lives.
24
- export const humanizeToolUse = humanizeClaudeTool;
15
+ // One Claude tool_use becomes a compact activity {kind, label}, or null for
16
+ // tools not worth surfacing, through `humanizeClaudeTool` — in runtimeEvents.mjs,
17
+ // beside Codex's equivalent, because they are the same job for two vendors and
18
+ // keeping them apart is how the two activity vocabularies drift. (Its old
19
+ // alias here, `humanizeToolUse`, claimed a dozen call sites; the stream loop
20
+ // below was the only one, and it calls the real name.)
25
21
 
26
22
  // Collapse whitespace + clip so a narration/thinking snippet is one tidy feed line.
27
23
  const oneLine = (s, n = 160) => String(s).replace(/\s+/g, ' ').trim().slice(0, n);
@@ -141,7 +137,7 @@ export function handleStreamLine(line, { cwd, emit, onActivity, onToolEvent, app
141
137
  // the page to read.
142
138
  push({ kind: 'say', label: oneLine(b.text), full: b.text });
143
139
  } else if (b.type === 'tool_use') {
144
- push(humanizeToolUse(b.name, b.input || {}, cwd));
140
+ push(humanizeClaudeTool(b.name, b.input || {}, cwd));
145
141
  // The STRUCTURED form of the same event, for the transcript's tool
146
142
  // cards — raw name + input, so the collector can keep what the
147
143
  // one-line humanizer drops (an Edit's counts, the plan's items).
@@ -5,7 +5,7 @@
5
5
  * which parks on it, and the pre-review, which only skips) and neither owns
6
6
  * it: it is a fact about what the CLIs print.
7
7
  */
8
- import { scrub as envScrub } from './uplinkScrub.mjs';
8
+ import { scrubCut } from './uplinkScrub.mjs';
9
9
 
10
10
  /**
11
11
  * DID THE ACCOUNT HIT A LIMIT?
@@ -28,7 +28,7 @@ const LIMIT_PHRASES = [
28
28
  export const limitLine = (text) => {
29
29
  for (const line of String(text ?? '').split('\n')) {
30
30
  const t = line.trim();
31
- if (t && LIMIT_PHRASES.some((re) => re.test(t))) return envScrub(t).slice(0, 300);
31
+ if (t && LIMIT_PHRASES.some((re) => re.test(t))) return scrubCut(t, 300);
32
32
  }
33
33
  return null;
34
34
  };
@@ -0,0 +1,17 @@
1
+ /**
2
+ * WHERE THIS ENVIRONMENT'S CODEX KEEPS ITS FILES (audit 2026-10-10, F126) —
3
+ * `$CODEX_HOME`, else `~/.codex`, with `~` resolved the way the CLI resolves
4
+ * it: the environment's own HOME first, the process's home only without one.
5
+ *
6
+ * A LEAF: node imports only. The login reader (codexPersonal.mjs), the
7
+ * rollout reader behind plan limits and "which model ran" (runtimeLimits.mjs),
8
+ * the offerings readout (runtimeOfferings.mjs) and an agent's isolated home
9
+ * (projectToolRuntimes.mjs) each spelled this rule, one of them differently;
10
+ * every one of them is a relay that must look where the CLI wrote.
11
+ */
12
+ import { homedir } from 'node:os';
13
+ import { join } from 'node:path';
14
+
15
+ export function codexHome(env = process.env) {
16
+ return env.CODEX_HOME || join(env.HOME || homedir(), '.codex');
17
+ }
@@ -55,9 +55,9 @@
55
55
  * config read "Not logged in").
56
56
  */
57
57
  import { readFileSync, statSync } from 'node:fs';
58
- import { homedir } from 'node:os';
59
58
  import { join } from 'node:path';
60
59
  import { toml } from './projectToolRuntimes.mjs';
60
+ import { codexHome } from './codexHome.mjs';
61
61
 
62
62
  /** Where the login lives and which endpoint it signs into — top-level keys,
63
63
  * each confirmed in codex-cli 0.156.1 (`preferred_auth_method` is not one:
@@ -365,8 +365,7 @@ export function parseCodexToml(text) {
365
365
  * not a document this reader can read whole.
366
366
  */
367
367
  export function readPersonalCodexConfig(env = process.env) {
368
- const home = env.CODEX_HOME || join(env.HOME || homedir(), '.codex');
369
- const path = join(home, 'config.toml');
368
+ const path = join(codexHome(env), 'config.toml');
370
369
  try {
371
370
  if (statSync(path).size > CONFIG_MAX_BYTES) return null;
372
371
  return parseCodexToml(readFileSync(path, 'utf8'));
@@ -461,6 +460,48 @@ export function codexPersonalCarry(config, { model = null, effort = null, env: t
461
460
  return { args, env };
462
461
  }
463
462
 
463
+ /**
464
+ * THE PERSON'S CODEX PROVIDER SECRETS, FOR THE UPLINK SCRUBBER ONLY
465
+ * (2026-10-10, the audit's F073) — `[{ name, value }]`, never a report.
466
+ *
467
+ * The header above says why the turn's own model can read these (`env` lists
468
+ * the carried literals; the read-only sandbox reads config.toml), and nothing
469
+ * taught the scrubber any of them, so a turn that printed one relayed it to
470
+ * every member of the project. The SELECTED provider's
471
+ * `experimental_bearer_token` and literal `http_headers` values, named as the
472
+ * carry names them (`CODEX_BEARER_ENV`, `CODEX_HEADER_ENV_PREFIX` + n), plus
473
+ * the present values of the variables its `env_key` and `env_http_headers`
474
+ * name, under those names. Whatever the posture: a build turn's Codex reads
475
+ * the same file. An unreadable or provider-less config gives `[]`, which the
476
+ * scrubber treats as "nothing new", never as "forget".
477
+ */
478
+ export function codexSecretsOf(config, env = {}) {
479
+ const out = [];
480
+ if (!isTable(config)) return out;
481
+ const id = config.model_provider;
482
+ const table = isString(id) && id && isTable(config.model_providers) ? config.model_providers[id] : undefined;
483
+ if (!isTable(table)) return out;
484
+ if (nonEmpty(table.experimental_bearer_token)) {
485
+ out.push({ name: CODEX_BEARER_ENV, value: table.experimental_bearer_token });
486
+ }
487
+ if (isTable(table.http_headers)) {
488
+ let n = 0;
489
+ for (const literal of Object.values(table.http_headers)) {
490
+ if (nonEmpty(literal)) out.push({ name: `${CODEX_HEADER_ENV_PREFIX}${n++}`, value: literal });
491
+ }
492
+ }
493
+ const named = [table.env_key, ...(isTable(table.env_http_headers) ? Object.values(table.env_http_headers) : [])];
494
+ for (const name of named) {
495
+ if (nonEmpty(name) && nonEmpty(env[name])) out.push({ name, value: env[name] });
496
+ }
497
+ return out;
498
+ }
499
+
500
+ /** `codexSecretsOf` over the config this process's Codex would read. */
501
+ export function readPersonalCodexSecrets(env = process.env) {
502
+ return codexSecretsOf(readPersonalCodexConfig(env), env);
503
+ }
504
+
464
505
  /** The carry for one turn: the config its Codex would read, reduced. */
465
506
  export function codexPersonalTurn({ env = process.env, model = null, effort = null } = {}) {
466
507
  return codexPersonalCarry(readPersonalCodexConfig(env), { model, effort, env });
@@ -20,7 +20,7 @@
20
20
  * classifier. The sentence names what was measured and the one remedy the
21
21
  * machine's own CLI offers; Codex's own words stay beside it.
22
22
  */
23
- import { scrub as envScrub } from './uplinkScrub.mjs';
23
+ import { scrub as envScrub, scrubCut } from './uplinkScrub.mjs';
24
24
  import { CODEX_RUNTIME } from './runtimeCodex.mjs';
25
25
 
26
26
  const NO_LOGIN = /unexpected status 401 Unauthorized: Missing bearer or basic authentication/;
@@ -55,9 +55,25 @@ export function codexApprovalSentence(refused) {
55
55
  return `Codex on the machine refused ${n} tool call${n === 1 ? '' : 's'} in this turn${which} — "MCP tool call requires approval, but approval policy is never" — so nothing ${n === 1 ? 'it' : 'they'} would have done was done.`;
56
56
  }
57
57
 
58
+ /**
59
+ * WHAT A TERMINAL OR NEW TASK REPLY MAY CARRY BACK (2026-10-10, the audit's
60
+ * final cleanup; F042's daemon half). The server's `SESSION_ANSWER_MAX`
61
+ * (shared `fieldCaps.ts`): the settle route (`/fleet/work-turn-done`) cuts an
62
+ * answer there and the session writer clamps every message to it. The daemon
63
+ * cuts there too before it sends — `withRelay` below and the plain reply in
64
+ * `workSessionTurns.mjs` — so nothing past it crosses the wire. It was a bare
65
+ * `16000` in both; the release gate (`scripts/check-app-parity.mjs`, rule 29)
66
+ * holds this copy equal to the shared one.
67
+ */
68
+ export const SESSION_ANSWER_MAX = 16_000;
69
+
58
70
  /** A reply with the sentence first and Codex's own words kept under it,
59
- * scrubbed like every reply and cut to the lane's cap. */
60
- export function withRelay(sentence, raw, cap = 16000) {
61
- const words = envScrub(String(raw ?? '')).trim();
62
- return (words ? `${sentence}\n\n${words}` : sentence).slice(0, cap);
71
+ * scrubbed like every reply and cut to the lane's cap — the words scrubbed
72
+ * whole before they are trimmed, and the reply cut only after (`scrubCut`). */
73
+ export function withRelay(sentence, raw, cap = SESSION_ANSWER_MAX) {
74
+ const reply = (s) => {
75
+ const words = envScrub(s).trim();
76
+ return words ? `${sentence}\n\n${words}` : sentence;
77
+ };
78
+ return scrubCut(raw, cap, reply);
63
79
  }
@@ -0,0 +1,180 @@
1
+ /** Codex research reads a copied view, never the host checkout or login store.
2
+ * Linux/WSL only: the CLI process retains its login outside its tool sandbox;
3
+ * tools get root-denied reads, a clean environment and no shell snapshots.
4
+ * Web search is a CLI tool, not direct shell network access.
5
+ */
6
+ import { execFileSync } from 'node:child_process';
7
+ import { git } from './git.mjs';
8
+ import { ensureRealSubdir, realDirState } from './ownedDirs.mjs';
9
+ import { closeSync, constants, copyFileSync, existsSync, fstatSync, openSync, readFileSync, readSync, lstatSync, mkdirSync, readdirSync, realpathSync, rmSync, unlinkSync, writeFileSync } from 'node:fs';
10
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
11
+ import { createRequire } from 'node:module';
12
+
13
+ const MAX_FILES = 20_000;
14
+ const MAX_BYTES = 128 * 1024 * 1024;
15
+ const omitted = (name) => /^(\.git|\.env.*|\.claude|\.codex|\.mcp\.json|node_modules|credentials(?:\..*)?|auth\.json)$/i.test(name) || /\.(pem|key|p12|pfx)$/i.test(name);
16
+
17
+ /** Fresh copies also break hardlinks. Symlinks, devices and sockets never ride.
18
+ * Refuse over the bound instead of presenting a silently incomplete repo.
19
+ */
20
+ export function copyResearchView(source, destination, budget = { files: 0, bytes: 0 }) {
21
+ mkdirSync(destination, { recursive: true, mode: 0o700 });
22
+ for (const entry of readdirSync(source, { withFileTypes: true })) {
23
+ if (omitted(entry.name)) continue;
24
+ const from = join(source, entry.name);
25
+ const to = join(destination, entry.name);
26
+ const stat = lstatSync(from);
27
+ if (stat.isSymbolicLink()) continue;
28
+ if (stat.isDirectory()) {
29
+ // Only the card's files and kept library are inputs from .flowviant.
30
+ if (entry.name === '.flowviant') {
31
+ for (const input of ['uploads', 'knowledge', 'artifacts']) {
32
+ const dir = join(from, input);
33
+ if (realDirState(dir) === 'dir')
34
+ copyResearchView(dir, join(to, input), budget);
35
+ }
36
+ } else copyResearchView(from, to, budget);
37
+ } else if (stat.isFile()) {
38
+ budget.files += 1;
39
+ budget.bytes += stat.size;
40
+ if (budget.files > MAX_FILES || budget.bytes > MAX_BYTES)
41
+ throw new Error('The isolated research copy exceeds 20,000 files or 128 MB. Narrow the repository inputs before trying again.');
42
+ // A file replaced by a symlink between enumeration and copy must not
43
+ // bring host data into the readable view. Copy through a no-follow fd.
44
+ const fd = openSync(from, constants.O_RDONLY | constants.O_NOFOLLOW);
45
+ try {
46
+ if (!fstatSync(fd).isFile()) throw new Error('A research input changed while copying.');
47
+ copyFileSync(`/proc/self/fd/${fd}`, to);
48
+ } finally { closeSync(fd); }
49
+ }
50
+ }
51
+ }
52
+
53
+ /** Resolve the actual sandbox helper without granting the person's bin/home.
54
+ * Support native installs and the npm wrapper's platform package. Unknown
55
+ * installations refuse; granting the whole home to find a helper is no fence.
56
+ */
57
+ export function codexResearchHelper(env = process.env) {
58
+ const command = (env.PATH ?? '').split(':').map((dir) => join(dir, 'codex')).find((file) => existsSync(file));
59
+ if (!command) throw new Error('Codex is not installed on PATH.');
60
+ const executable = realpathSync(command);
61
+ const fd = openSync(executable, 'r');
62
+ const magic = Buffer.alloc(4);
63
+ try { readSync(fd, magic, 0, 4, 0); } finally { closeSync(fd); }
64
+ if (magic.equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46]))) return executable;
65
+ const target = process.arch === 'arm64' ? 'aarch64-unknown-linux-musl' : 'x86_64-unknown-linux-musl';
66
+ const platform = `@openai/codex-linux-${process.arch === 'arm64' ? 'arm64' : 'x64'}`;
67
+ let root;
68
+ try { root = dirname(createRequire(executable).resolve(`${platform}/package.json`)); }
69
+ catch { root = resolve(dirname(executable), '..'); }
70
+ const binary = join(root, 'vendor', target, 'bin', 'codex');
71
+ if (!existsSync(binary)) throw new Error('This Codex installation has no measured Linux research sandbox helper.');
72
+ return realpathSync(binary);
73
+ }
74
+
75
+ /** The research posture's own pairs: the copied view's filesystem table, no
76
+ * shell network, live web, a clean shell environment. runtimeCodex.mjs ends it
77
+ * in the shared fenced tail (sub-agents, browser, image generation, the
78
+ * person's config and rules), with the web left on: it is this posture's tool.
79
+ */
80
+ export function researchFence({ cwd, helper }) {
81
+ if (process.platform !== 'linux' || !isAbsolute(cwd ?? '') || !isAbsolute(helper ?? ''))
82
+ throw new Error('Isolated Codex research requires Linux or WSL and absolute sandbox paths.');
83
+ const artifacts = join(cwd, '.flowviant/artifacts');
84
+ const scratch = join(cwd, '.flowviant/tmp');
85
+ const rules = { ':root': 'deny', ':minimal': 'read', [helper]: 'read', [cwd]: 'read', [artifacts]: 'write', [scratch]: 'write' };
86
+ const table = '{' + Object.entries(rules).map(([path, access]) => `${JSON.stringify(path)}=${JSON.stringify(access)}`).join(',') + '}';
87
+ return [
88
+ '-c', `permissions.flowviantresearch.filesystem=${table}`,
89
+ '-c', 'permissions.flowviantresearch.network.enabled=false',
90
+ '-c', 'default_permissions="flowviantresearch"',
91
+ '-c', 'approval_policy="never"',
92
+ '-c', 'web_search="live"', '-c', 'tools.web_search=true',
93
+ '-c', 'features.shell_snapshot=false', '-c', 'allow_login_shell=false',
94
+ '-c', 'shell_environment_policy.inherit="none"',
95
+ '-c', `shell_environment_policy.set={PATH="/usr/bin:/bin",HOME=${JSON.stringify(scratch)},TMPDIR=${JSON.stringify(scratch)},BROWSER="none"}`,
96
+ '--disable', 'view_image',
97
+ '--skip-git-repo-check',
98
+ ];
99
+ }
100
+
101
+ /** Copy only regular Markdown outputs back. Never follow a model-written link,
102
+ * nor one standing in the worktree: a destination directory must be a real one
103
+ * or absent (ownedDirs.mjs, the daemon's one link rule), and a file is written
104
+ * only where nothing or a regular file stands, created exclusively so a link
105
+ * swapped in after the look is never followed. A write-up already holding
106
+ * these exact bytes is left alone: the copied view carried it in, and its
107
+ * mtime is the artifact scan's evidence that the turn did not write it
108
+ * (artifacts.mjs `writtenSince`), so a turn that wrote nothing delivers
109
+ * nothing. Artifact publication stays in the original worktree for the
110
+ * existing uploader.
111
+ */
112
+ export function publishResearch(source, destination) {
113
+ if (realDirState(destination) === 'none') mkdirSync(destination, { recursive: true });
114
+ if (realDirState(destination) !== 'dir') throw new Error('The research artifact destination is not a real directory.');
115
+ for (const entry of readdirSync(source, { withFileTypes: true })) {
116
+ const from = join(source, entry.name);
117
+ const to = join(destination, entry.name);
118
+ const stat = lstatSync(from);
119
+ if (stat.isSymbolicLink()) throw new Error('An isolated research output is a symlink; nothing was accepted through it.');
120
+ if (stat.isDirectory()) publishResearch(from, to);
121
+ else if (stat.isFile() && /\.md$/i.test(entry.name)) {
122
+ if (stat.size > 2 * 1024 * 1024) throw new Error('The research Markdown output exceeds 2 MB.');
123
+ let there = null;
124
+ try {
125
+ there = lstatSync(to);
126
+ } catch (e) {
127
+ if (e?.code !== 'ENOENT') throw e;
128
+ }
129
+ if (there && !there.isFile()) throw new Error('The research output would overwrite a non-file.');
130
+ const bytes = readFileSync(from);
131
+ if (there && there.size === bytes.length && readFileSync(to).equals(bytes)) continue;
132
+ if (there) unlinkSync(to);
133
+ writeFileSync(to, bytes, { flag: 'wx' });
134
+ }
135
+ }
136
+ }
137
+
138
+ export async function runIsolatedResearch(opts, run, {
139
+ resolveHelper = codexResearchHelper,
140
+ readVersion = (helper) => execFileSync(helper, ['--version'], { encoding: 'utf8', timeout: 10_000 }),
141
+ } = {}) {
142
+ if (process.platform !== 'linux') throw new Error('Isolated Codex research requires Linux or WSL.');
143
+ const original = realpathSync(opts.cwd);
144
+ const helper = resolveHelper();
145
+ const version = readVersion(helper).match(/codex-cli (\d+)\.(\d+)\.(\d+)/);
146
+ if (!version || version.slice(1).map(Number).reduce((n, v) => n * 1000 + v, 0) < 159002)
147
+ throw new Error('Isolated research needs Codex CLI 0.159.2 or newer. Restart after updating Codex.');
148
+ // Both publication parents must be real directories in this checkout, or
149
+ // absent — never a link, dangling or not (ownedDirs.mjs).
150
+ for (const dir of [join(original, '.flowviant'), join(original, '.flowviant/artifacts')])
151
+ if (realDirState(dir) === 'other')
152
+ throw new Error('The research artifact destination is not a real directory.');
153
+ // Kept path across resumes, outside the source tree and separate from Codex's
154
+ // login/thread home. The caller's agent lock serializes turns in this place.
155
+ const gitDir = git(['rev-parse', '--absolute-git-dir'], original);
156
+ const isolation = join(gitDir, 'flowviant-research');
157
+ if (existsSync(isolation) && lstatSync(isolation).isSymbolicLink()) throw new Error('The research isolation directory is a symlink.');
158
+ rmSync(isolation, { recursive: true, force: true });
159
+ const snapshot = join(isolation, 'repo');
160
+ try {
161
+ copyResearchView(original, snapshot);
162
+ let knowledge = null;
163
+ if (opts.knowledgeDir) {
164
+ knowledge = join(snapshot, '.flowviant/knowledge');
165
+ copyResearchView(realpathSync(opts.knowledgeDir), knowledge);
166
+ }
167
+ for (const dir of ['artifacts', 'tmp']) mkdirSync(join(snapshot, '.flowviant', dir), { recursive: true, mode: 0o700 });
168
+ const rewrite = (text) => typeof text === 'string'
169
+ ? text.replaceAll(original, snapshot).replaceAll(opts.knowledgeDir || '\0', knowledge || '\0') : text;
170
+ const intro = 'This fresh research turn uses an isolated read-only copy. Prior reports are available under .flowviant/artifacts/; previous CLI conversations are not resumed because they may contain host secrets. Git metadata, .env files, credential files, symlinks and personal CLI settings are omitted. Write the Markdown report only under .flowviant/artifacts/. Web search is available; shell networking is disabled. Use exec_command with shell=/bin/bash and login=false; personal shells are outside the fence.\n\n';
171
+ const result = await run({ ...opts, resume: false, resumeThreadId: null, resumeConversationId: null, cwd: snapshot, knowledgeDir: knowledge,
172
+ prompt: intro + rewrite(opts.prompt), system: rewrite(opts.system),
173
+ researchIsolation: { cwd: snapshot, helper } });
174
+ // Made, or found, real at publication too: every component checked again.
175
+ if (!ensureRealSubdir(original, '.flowviant/artifacts'))
176
+ throw new Error('The research artifact destination is not a real directory.');
177
+ publishResearch(join(snapshot, '.flowviant/artifacts'), join(original, '.flowviant/artifacts'));
178
+ return typeof result === 'string' ? result.replaceAll(snapshot, original) : result;
179
+ } finally { rmSync(isolation, { recursive: true, force: true }); }
180
+ }