flowviant 0.97.0 → 0.99.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 (45) hide show
  1. package/bin/cli.mjs +13 -3
  2. package/bin/lib/agentPlan.mjs +159 -37
  3. package/bin/lib/artifacts.mjs +136 -26
  4. package/bin/lib/authReject.mjs +32 -0
  5. package/bin/lib/authproxy.mjs +52 -16
  6. package/bin/lib/childEnv.mjs +33 -1
  7. package/bin/lib/claude.mjs +166 -71
  8. package/bin/lib/config.mjs +24 -1
  9. package/bin/lib/credentials.mjs +27 -4
  10. package/bin/lib/deploy.mjs +364 -30
  11. package/bin/lib/env.mjs +130 -13
  12. package/bin/lib/fleet.mjs +99 -28
  13. package/bin/lib/git.mjs +131 -3
  14. package/bin/lib/hooks/readGuard.mjs +24 -0
  15. package/bin/lib/instance.mjs +115 -14
  16. package/bin/lib/knowledge.mjs +180 -60
  17. package/bin/lib/landed.mjs +38 -30
  18. package/bin/lib/listeners.mjs +211 -0
  19. package/bin/lib/localSessions.mjs +29 -4
  20. package/bin/lib/login.mjs +2 -2
  21. package/bin/lib/machines.mjs +16 -7
  22. package/bin/lib/preview.mjs +95 -6
  23. package/bin/lib/printable.mjs +42 -0
  24. package/bin/lib/procRegistry.mjs +65 -0
  25. package/bin/lib/processes.mjs +20 -7
  26. package/bin/lib/projectTools.mjs +248 -0
  27. package/bin/lib/prompts.mjs +16 -11
  28. package/bin/lib/resources.mjs +46 -4
  29. package/bin/lib/runtimeOfferings.mjs +75 -0
  30. package/bin/lib/runtimes.mjs +71 -9
  31. package/bin/lib/shipMerge.mjs +8 -4
  32. package/bin/lib/trace.mjs +16 -6
  33. package/bin/lib/update.mjs +5 -0
  34. package/bin/lib/work.mjs +1192 -4584
  35. package/bin/lib/workAgentMerges.mjs +504 -0
  36. package/bin/lib/workAgentPlans.mjs +447 -0
  37. package/bin/lib/workAgentReview.mjs +702 -0
  38. package/bin/lib/workAgentTurns.mjs +1148 -0
  39. package/bin/lib/workDiffs.mjs +107 -0
  40. package/bin/lib/workPreviews.mjs +351 -0
  41. package/bin/lib/workProcesses.mjs +236 -0
  42. package/bin/lib/workPullRequests.mjs +313 -0
  43. package/bin/lib/workShip.mjs +385 -0
  44. package/bin/lib/worktreeDiff.mjs +99 -43
  45. package/package.json +2 -2
@@ -47,7 +47,13 @@ import { execFileSync } from 'node:child_process';
47
47
  import { readFileSync, readdirSync } from 'node:fs';
48
48
  import { platform } from 'node:os';
49
49
 
50
- /** A box with more processes than this is not one we walk per sweep. */
50
+ /** Matched rows collected per scan before the walk stops early — a bound on
51
+ * the WORK done for one pathological group, never on which part of /proc is
52
+ * looked at. It used to cut the pid LIST before filtering, and readdir answers
53
+ * in string order, so a busy box kept '1', '10', '100'… and dropped a tab's
54
+ * watcher at pid 912345 while `liveGroups` (which has no cap) still called its
55
+ * group alive: the tab read "looked, found none" and a Stop on it answered
56
+ * not_found. */
51
57
  const MAX_PIDS = 4000;
52
58
  /** Rows reported per session. A watcher, a dev server and its child is three;
53
59
  * twenty is somebody's compose stack and the extra rows say nothing. */
@@ -113,25 +119,32 @@ function cmdlineOf(pid) {
113
119
  }
114
120
  }
115
121
 
116
- function scanLinux(pgids) {
122
+ /** Exported with its readers injectable ONLY so the scan-order property can be
123
+ * pinned without a box that runs four thousand processes. Nothing in the
124
+ * daemon passes `io`. */
125
+ export function scanLinux(pgids, io = {}) {
126
+ const list = io.list ?? (() => readdirSync('/proc'));
127
+ const pgrpFor = io.pgrpOf ?? pgrpOf;
128
+ const cmdFor = io.cmdlineOf ?? cmdlineOf;
129
+ const rssFor = io.rssBytes ?? rssBytes;
117
130
  let pids;
118
131
  try {
119
- pids = readdirSync('/proc').filter((d) => /^\d+$/.test(d));
132
+ pids = list().filter((d) => /^\d+$/.test(d));
120
133
  } catch {
121
134
  return [];
122
135
  }
123
- if (pids.length > MAX_PIDS) pids = pids.slice(0, MAX_PIDS);
124
136
 
125
137
  const out = [];
126
138
  for (const raw of pids) {
139
+ if (out.length >= MAX_PIDS) break;
127
140
  const pid = Number(raw);
128
- const pgrp = pgrpOf(raw);
141
+ const pgrp = pgrpFor(raw);
129
142
  if (pgrp === null || !pgids.has(pgrp)) continue;
130
143
  // The CLI itself — its argv is the prompt. See the header.
131
144
  if (pid === pgrp) continue;
132
- const cmd = cmdlineOf(raw);
145
+ const cmd = cmdFor(raw);
133
146
  if (!cmd) continue;
134
- const rss = rssBytes(raw);
147
+ const rss = rssFor(raw);
135
148
  out.push({ pid, pgid: pgrp, cmd, ...(rss != null ? { rss } : {}) });
136
149
  }
137
150
  return out;
@@ -0,0 +1,248 @@
1
+ /** Repo-owned tool configuration, read from the base ref for every agent turn. */
2
+ import { execFileSync } from 'node:child_process';
3
+ import { accessSync, constants, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
4
+ import { delimiter, isAbsolute, join, resolve } from 'node:path';
5
+ import { homedir, tmpdir } from 'node:os';
6
+
7
+ const MAX_TOOLS = 40;
8
+ const MAX_REPORT_TOOLS = 80; // 40 MCP servers plus 40 instruction directories
9
+ const MAX_INSTRUCTION_FILES = 80;
10
+ const MAX_INSTRUCTION_BYTES = 512 * 1024;
11
+ const nameOk = (name) => /^[A-Za-z0-9_-]{1,80}$/.test(name) && name !== 'flowviant';
12
+ const toml = (value) => JSON.stringify(String(value));
13
+ const show = (root, ref, path) => {
14
+ try {
15
+ return execFileSync('git', ['show', `${ref}:${path}`], {
16
+ cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 1024 * 1024,
17
+ });
18
+ } catch { return null; }
19
+ };
20
+ const showBytes = (root, ref, path) => {
21
+ try {
22
+ return execFileSync('git', ['show', `${ref}:${path}`], {
23
+ cwd: root, stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 1024 * 1024,
24
+ });
25
+ } catch { return null; }
26
+ };
27
+
28
+ function onPath(command, env) {
29
+ if (isAbsolute(command)) {
30
+ try { accessSync(command, constants.X_OK); return true; } catch { return false; }
31
+ }
32
+ if (command.includes('/') || command.includes('\\')) return false;
33
+ return (env.PATH ?? '').split(delimiter).some((dir) => {
34
+ try { accessSync(join(dir, command), constants.X_OK); return true; } catch { return false; }
35
+ });
36
+ }
37
+
38
+ function referenced(value) {
39
+ return [...String(value).matchAll(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g)].map((m) => m[1]);
40
+ }
41
+
42
+ /** Pure mapping used before a future tool-attach gesture is offered. */
43
+ export function runnerToolCapabilities(runner) {
44
+ return ({ claude: { mcp: true, skills: true }, codex: { mcp: true, skills: true }, antigravity: { mcp: false, skills: false } })[runner]
45
+ ?? { mcp: false, skills: false };
46
+ }
47
+
48
+ export function readBaseTools(root, ref, env = process.env) {
49
+ // Resolve once: a base branch may advance while the turn is being prepared.
50
+ const commit = execFileSync('git', ['rev-parse', '--verify', ref], {
51
+ cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
52
+ }).trim();
53
+ const raw = show(root, commit, '.mcp.json');
54
+ let servers = {};
55
+ if (raw != null) {
56
+ try { servers = JSON.parse(raw).mcpServers ?? {}; } catch { servers = {}; }
57
+ }
58
+ const entries = Object.entries(servers).filter(([name, value]) => nameOk(name) && value && typeof value === 'object').slice(0, MAX_TOOLS);
59
+ const skills = [];
60
+ const skillFiles = [];
61
+ try {
62
+ const paths = execFileSync('git', ['ls-tree', '-r', '--name-only', commit, '--', '.claude/skills'], {
63
+ cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 1024 * 1024,
64
+ }).split('\n');
65
+ let bytes = 0;
66
+ for (const path of paths) {
67
+ if (!/^\.claude\/skills\/[A-Za-z0-9_-]+\/(?:[A-Za-z0-9_.-]+\/)*[A-Za-z0-9_.-]+$/.test(path)) continue;
68
+ if (skillFiles.length >= 200 || bytes >= 4 * 1024 * 1024) break;
69
+ const body = showBytes(root, commit, path);
70
+ if (body == null) continue;
71
+ bytes += body.length;
72
+ if (bytes > 4 * 1024 * 1024) break;
73
+ skillFiles.push({ path, body });
74
+ if (path.endsWith('/SKILL.md') && path.split('/').length === 4 && skills.length < MAX_TOOLS) skills.push({ path, body: body.toString('utf8') });
75
+ }
76
+ } catch { /* no skills at this ref */ }
77
+ const tools = entries.map(([name, config]) => {
78
+ const vars = new Set();
79
+ for (const value of [config.command, config.url, ...(Array.isArray(config.args) ? config.args : []), ...Object.values(config.env ?? {}), ...Object.values(config.headers ?? {})]) {
80
+ for (const key of referenced(value)) vars.add(key);
81
+ }
82
+ const missing = [...vars].filter((key) => !env[key]).sort();
83
+ const commandMissing = typeof config.command === 'string' && !onPath(expand(config.command, env), env);
84
+ const unsupported = typeof config.command !== 'string' && typeof config.url !== 'string';
85
+ const reason = missing.length ? `env ${missing.join(', ')} unset` : commandMissing ? `command ${config.command} not on PATH` : unsupported ? 'MCP configuration unsupported' : null;
86
+ return { name, config, state: reason ? 'missing' : 'ready', reason };
87
+ });
88
+ // Nested instructions are normally discovered when a CLI enters that path.
89
+ // Their worktree copies must not regain authority through that discovery.
90
+ const instructions = [];
91
+ const paths = execFileSync('git', ['ls-tree', '-r', '--name-only', commit], {
92
+ cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 1024 * 1024,
93
+ }).split('\n').filter((path) => /(^|\/)(CLAUDE|AGENTS)\.md$/.test(path));
94
+ if (paths.length > MAX_INSTRUCTION_FILES) throw new Error('Base branch has too many agent instruction files');
95
+ let instructionBytes = 0;
96
+ for (const path of paths) {
97
+ const body = show(root, commit, path);
98
+ if (body == null) throw new Error(`Cannot read base instruction ${path}`);
99
+ instructionBytes += Buffer.byteLength(body);
100
+ if (instructionBytes > MAX_INSTRUCTION_BYTES) throw new Error('Base branch agent instructions exceed the turn limit');
101
+ instructions.push({ path, body });
102
+ }
103
+ return { tools, skills, skillFiles, instructions };
104
+ }
105
+
106
+ const expand = (value, env) => String(value).replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_, key) => env[key] ?? '');
107
+
108
+ /** Keep personal Codex files, but do not copy its project trust grants. An
109
+ * untrusted worktree's .codex/config.toml is ignored by Codex 0.156.1. */
110
+ function isolatedCodexHome(dir, env, worktree) {
111
+ const source = resolve(env.CODEX_HOME || join(homedir(), '.codex'));
112
+ const dest = join(dir, 'codex-home');
113
+ mkdirSync(dest, { mode: 0o700 });
114
+ if (existsSync(source)) {
115
+ for (const name of readdirSync(source)) {
116
+ if (name === 'config.toml') continue;
117
+ symlinkSync(join(source, name), join(dest, name));
118
+ }
119
+ }
120
+ const configPath = join(source, 'config.toml');
121
+ const sourceConfig = existsSync(configPath) ? readFileSync(configPath, 'utf8') : '';
122
+ const topLevel = sourceConfig.split(/^\s*\[/m, 1)[0];
123
+ const personalDeveloperInstructions = /^\s*developer_instructions\s*=/m.test(topLevel);
124
+ // These sections alone grant a checkout permission to supply project
125
+ // settings. Keep every other personal setting, skill and session store.
126
+ const lines = sourceConfig.split('\n');
127
+ let inProject = false;
128
+ let config = lines.filter((line) => {
129
+ const header = /^\s*(?:\[\[([^\]]+)\]\]|\[([^\]]+)\])\s*(?:#.*)?$/.exec(line);
130
+ if (header) {
131
+ const section = header[1] ?? header[2];
132
+ inProject = section === 'projects' || section.startsWith('projects.');
133
+ }
134
+ return !inProject;
135
+ }).join('\n');
136
+ if (worktree) {
137
+ // Codex discovers these even in an untrusted project. Disable exactly the
138
+ // worktree skill paths; the person's ~/.codex/skills and config remain.
139
+ const worktreeSkills = [];
140
+ for (const sourceDir of ['.agents/skills', '.codex/skills', '.claude/skills']) {
141
+ const skillsDir = join(worktree, sourceDir);
142
+ if (!existsSync(skillsDir)) continue;
143
+ for (const name of readdirSync(skillsDir)) {
144
+ const skill = join(skillsDir, name, 'SKILL.md');
145
+ if (existsSync(skill)) worktreeSkills.push(skill);
146
+ }
147
+ }
148
+ if (worktreeSkills.length && /^\s*skills\.config\s*=/m.test(config))
149
+ throw new Error('Cannot isolate Codex project skills with inline skills.config');
150
+ for (const skill of worktreeSkills) config += `\n[[skills.config]]\npath = ${toml(skill)}\nenabled = false\n`;
151
+ }
152
+ writeFileSync(join(dest, 'config.toml'), config, { mode: 0o600 });
153
+ return { path: dest, personalDeveloperInstructions };
154
+ }
155
+
156
+ /** Temporary per-turn files are removed after the last retry. No personal CLI config is written. */
157
+ export function prepareAgentTools(snapshot, runner, env = process.env, worktree = null) {
158
+ const dir = mkdtempSync(join(tmpdir(), 'flowviant-agent-tools-'));
159
+ const caps = runnerToolCapabilities(runner);
160
+ const usable = snapshot.tools.filter((t) => t.state === 'ready' && caps.mcp && !(runner === 'codex' && t.config.type === 'sse'));
161
+ const mcpServers = {};
162
+ const codexArgs = [];
163
+ for (const { name, config } of usable) {
164
+ if (typeof config.command === 'string') {
165
+ const command = expand(config.command, env);
166
+ const args = Array.isArray(config.args) ? config.args.map((v) => expand(v, env)) : [];
167
+ const vars = Object.fromEntries(Object.entries(config.env ?? {}).map(([k, v]) => [k, expand(v, env)]));
168
+ mcpServers[name] = { command, args, env: vars };
169
+ codexArgs.push('-c', `mcp_servers.${name}.command=${toml(command)}`);
170
+ codexArgs.push('-c', `mcp_servers.${name}.args=${JSON.stringify(args)}`);
171
+ for (const [key, value] of Object.entries(vars)) {
172
+ if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) codexArgs.push('-c', `mcp_servers.${name}.env.${key}=${toml(value)}`);
173
+ }
174
+ } else if (typeof config.url === 'string') {
175
+ const url = expand(config.url, env);
176
+ const headers = Object.fromEntries(Object.entries(config.headers ?? {}).map(([k, v]) => [k, expand(v, env)]));
177
+ mcpServers[name] = { type: config.type ?? 'http', url, ...(Object.keys(headers).length ? { headers } : {}) };
178
+ codexArgs.push('-c', `mcp_servers.${name}.url=${toml(url)}`);
179
+ if (Object.keys(headers).length) {
180
+ const inline = Object.entries(headers).map(([key, value]) => `${toml(key)}=${toml(value)}`).join(',');
181
+ codexArgs.push('-c', `mcp_servers.${name}.http_headers={${inline}}`);
182
+ }
183
+ }
184
+ }
185
+ const mcpPath = join(dir, 'mcp.json');
186
+ writeFileSync(mcpPath, JSON.stringify({ mcpServers }), { mode: 0o600 });
187
+ const pointers = [];
188
+ for (const { path, body } of [...snapshot.instructions, ...(caps.skills ? snapshot.skillFiles : [])]) {
189
+ const dest = join(dir, path);
190
+ mkdirSync(join(dest, '..'), { recursive: true });
191
+ writeFileSync(dest, body, { mode: 0o600 });
192
+ if (!path.startsWith('.claude/skills/') || path.endsWith('/SKILL.md')) pointers.push(`${path}: ${dest}`);
193
+ }
194
+ const baseInstructions = snapshot.instructions.map(({ path, body }) => `Base ${path}:\n${body}`).join('\n\n');
195
+ const instructions = [baseInstructions,
196
+ pointers.length ? `Base-branch instruction and skill copies (read the relevant nested files and skill files before acting):\n${pointers.join('\n')}` : '',
197
+ ].filter(Boolean).join('\n\n');
198
+ const pluginDir = runner === 'claude' && snapshot.skillFiles.length ? join(dir, 'reviewed-plugin') : null;
199
+ if (pluginDir) {
200
+ mkdirSync(join(pluginDir, '.claude-plugin'), { recursive: true });
201
+ writeFileSync(join(pluginDir, '.claude-plugin/plugin.json'), JSON.stringify({ name: 'flowviant-reviewed', version: '1.0.0', description: 'Base-branch agent skills' }), { mode: 0o600 });
202
+ for (const { path, body } of snapshot.skillFiles) {
203
+ const dest = join(pluginDir, path.replace(/^\.claude\//, ''));
204
+ mkdirSync(join(dest, '..'), { recursive: true });
205
+ writeFileSync(dest, body, { mode: 0o600 });
206
+ }
207
+ }
208
+ let codexHome = null;
209
+ let personalDeveloperInstructions = false;
210
+ try {
211
+ if (runner === 'codex') {
212
+ const isolated = isolatedCodexHome(dir, env, worktree);
213
+ codexHome = isolated.path;
214
+ personalDeveloperInstructions = isolated.personalDeveloperInstructions;
215
+ }
216
+ } catch (error) {
217
+ rmSync(dir, { recursive: true, force: true });
218
+ throw error;
219
+ }
220
+ return {
221
+ mcpPath,
222
+ codexArgs,
223
+ instructions,
224
+ pluginDir,
225
+ codexHome,
226
+ personalDeveloperInstructions,
227
+ cleanup: () => rmSync(dir, { recursive: true, force: true }),
228
+ };
229
+ }
230
+
231
+ export function toolReadout(snapshot, runner) {
232
+ const caps = runnerToolCapabilities(runner);
233
+ const short = (reason) => reason == null ? null : String(reason).slice(0, 200);
234
+ const mcp = snapshot.tools.map(({ name, config, state, reason }) => {
235
+ const unsupported = runner === 'codex' && config.type === 'sse';
236
+ return {
237
+ name, kind: 'mcp',
238
+ state: caps.mcp && !unsupported ? state : 'missing',
239
+ reason: short(!caps.mcp ? runner === 'none' ? 'no installed CLI can take MCP' : `${runner} cannot take MCP`
240
+ : unsupported ? 'Codex cannot take SSE MCP' : reason),
241
+ };
242
+ });
243
+ const instructions = snapshot.skills.map(({ path }) => ({
244
+ name: path.split('/')[2], kind: 'instruction', state: caps.skills ? 'ready' : 'missing',
245
+ reason: short(caps.skills ? null : runner === 'none' ? 'no installed CLI can take instructions' : `${runner} cannot take instructions`),
246
+ }));
247
+ return [...mcp, ...instructions].slice(0, MAX_REPORT_TOOLS);
248
+ }
@@ -492,9 +492,10 @@ const kickoff = ({ message, askedByName, head, tail }) => {
492
492
  * A CAPTURE TAB (server: work_session.kind='capture', flagged on the job as
493
493
  * `capture: true`) — the board's "New task" conversation. Its whole job is
494
494
  * turning what the person says into STAGED cards a human lands; it edits
495
- * nothing, and the permission profile enforces that (PLAN_PERM: read-only +
496
- * MCP — the same fence the scratch planner runs behind). This prompt is the
497
- * QUALITY half; the token scope and the permission list are the safety.
495
+ * nothing, and the permission profile enforces that (`planPermFor`:
496
+ * read-only + MCP — the same fence the scratch planner runs behind). This
497
+ * prompt is the QUALITY half; the token scope and the permission list are
498
+ * the safety.
498
499
  *
499
500
  * THE CHAT ASKS IN CHIPS, AND SAYS WHAT IT ASSUMED (2026-09-23, 0.97.0). Rule
500
501
  * 5 used to be one sentence — "clarify before staging" — and the owner asked
@@ -600,7 +601,9 @@ MECHANICS OF THIS CHAT:
600
601
  something they said.
601
602
  10. REFERENCE WHAT THE PROJECT KEPT. When the ask names a kept design or
602
603
  write-up ("implement design A"), call list_library and pass its id as
603
- \`references\` on the card you stage, so the agent reads it.`;
604
+ \`references\` on the card you stage, so the agent reads it. When the person's
605
+ message includes attached library items, pass those ids as \`references\` on
606
+ the cards you stage from that message.`;
604
607
 
605
608
  export const WORK_TURN_KICKOFF = ({ sessionId, sessionName, message, askedByName }) =>
606
609
  kickoff({
@@ -684,11 +687,11 @@ export const REGROUND_KICKOFF = ({ sha, title, files, vaultDir, predictedPages =
684
687
  * no code, edits no files and starts nothing: a person reads what it proposes,
685
688
  * edits it on the board, and accepting is what spawns anything.
686
689
  *
687
- * It runs READ-ONLY IN THE CHECKOUT under CONSULT_PERM — no Write, no Edit, no
688
- * mkdir, no rm, and no MCP at all. The proposal comes back as its final
689
- * message, not through a tool, which is what lets that permission set be this
690
- * narrow. A planner authors a decision, and there is no file on this machine it
691
- * has any business touching.
690
+ * It runs READ-ONLY IN THE CHECKOUT under `consultPermFor` — no Write, no
691
+ * Edit, no mkdir, no rm, and no MCP at all. The proposal comes back as its
692
+ * final message, not through a tool, which is what lets that permission set
693
+ * be this narrow. A planner authors a decision, and there is no file on this
694
+ * machine it has any business touching.
692
695
  *
693
696
  * The two facts it is asked to weigh are the only two that are actually
694
697
  * knowable here: what the cards SAY, and what each live agent has already
@@ -861,13 +864,15 @@ export const AGENT_PLAN_KICKOFF = ({ tasks, liveAgents, agentCap }) => {
861
864
  * a person one reply; a turn that guesses costs them a review, a rejection and
862
865
  * a second run — and the guess arrives wearing a confident summary.
863
866
  */
864
- export const SYSTEM_AGENT = `You are the human's own Claude, working one task in a git worktree of their
867
+ export const SYSTEM_AGENT = `You are the human's own coding agent, working one task in a git worktree of their
865
868
  repository. Nobody is watching this run. You have the repo and nothing else —
866
869
  no project tools, no board, no chat.
867
870
 
868
871
  WHAT TO DO:
869
872
 
870
- 1. Do the card you are given. Read whatever you need first. Follow the
873
+ 1. Do the card you are given. Read whatever you need first — including
874
+ CLAUDE.md and AGENTS.md at the repository root, when either exists: they are
875
+ the repository's own instructions, whichever CLI you are. Follow the
871
876
  repository's own conventions over anything you would do by default.
872
877
 
873
878
  2. COMMIT YOUR WORK before you finish. Small, real commit messages. Uncommitted
@@ -46,7 +46,7 @@
46
46
 
47
47
  import { execFileSync } from 'node:child_process';
48
48
  import { readFileSync, readdirSync, statfsSync } from 'node:fs';
49
- import { freemem, loadavg, platform } from 'node:os';
49
+ import { cpus, freemem, loadavg, platform } from 'node:os';
50
50
  import { MACHINE } from './config.mjs';
51
51
 
52
52
  const MiB = 1024 * 1024;
@@ -71,12 +71,38 @@ const readFile = (p) => {
71
71
  function memoryUsed() {
72
72
  const cur = readFile('/sys/fs/cgroup/memory.current');
73
73
  if (cur !== null) {
74
- const n = Number(cur);
74
+ const n = cgroupWorkingSet(Number(cur), readFile);
75
75
  if (Number.isFinite(n) && n > 0) return n;
76
76
  }
77
77
  return Math.max(0, MACHINE.memBytes - freemem());
78
78
  }
79
79
 
80
+ /**
81
+ * THE CGROUP'S WORKING SET, NOT ITS CHARGE.
82
+ *
83
+ * `memory.current` counts the page cache charged to the cgroup — every file a
84
+ * worktree add, an npm install or a repo read touched. On a container that has
85
+ * been up a while it sits just under `memory.max` while almost all of it is
86
+ * reclaimable, and the kernel reclaims only when something ALLOCATES. A guard
87
+ * reading the raw charge therefore defers every unattended turn, which spawns
88
+ * nothing, which allocates nothing, which never reclaims: a permanent refusal
89
+ * relaying a "low memory" figure that is false. This is the same trap
90
+ * `memAvailableBytes` rejects `freemem()` for, one layer down.
91
+ *
92
+ * So subtract `inactive_file` from `memory.stat`, which is what kubelet and
93
+ * `docker stats` call the working set. An unreadable or malformed stat leaves
94
+ * the charge as it was — the conservative direction, and never a guess.
95
+ */
96
+ function cgroupWorkingSet(current, read) {
97
+ if (!Number.isFinite(current)) return current;
98
+ const stat = read('/sys/fs/cgroup/memory.stat');
99
+ const m = stat && stat.match(/^inactive_file\s+(\d+)$/m);
100
+ if (!m) return current;
101
+ const inactive = Number(m[1]);
102
+ if (!Number.isFinite(inactive) || inactive < 0) return current;
103
+ return Math.max(0, current - inactive);
104
+ }
105
+
80
106
  /**
81
107
  * Resident memory of a process AND everything it spawned.
82
108
  *
@@ -176,7 +202,8 @@ export function memAvailableBytes(read = readFile) {
176
202
  const max = read('/sys/fs/cgroup/memory.max');
177
203
  if (max && max !== 'max') {
178
204
  const limit = Number(max);
179
- const cur = Number(read('/sys/fs/cgroup/memory.current'));
205
+ const raw = read('/sys/fs/cgroup/memory.current');
206
+ const cur = raw === null ? NaN : cgroupWorkingSet(Number(raw), read);
180
207
  if (Number.isFinite(limit) && limit > 0 && Number.isFinite(cur)) {
181
208
  const room = Math.max(0, limit - cur);
182
209
  avail = avail === null ? room : Math.min(avail, room);
@@ -248,6 +275,21 @@ const sizeWord = (n) =>
248
275
  const PRESSURE_CACHE_MS = 2_000;
249
276
  let lastMeasure = { at: 0, m: null };
250
277
 
278
+ /**
279
+ * THE CORES THE LOAD AVERAGE IS A LOAD ON.
280
+ *
281
+ * `/proc/loadavg` is not namespaced: inside a container it reports the whole
282
+ * HOST. `MACHINE.cores` is clamped to the cgroup's `cpu.max`, which is right
283
+ * for sizing how many turns this box runs and wrong as the divisor of a host
284
+ * figure — a `--cpus=2` container on a busy 32-core host read "load 10 on 2
285
+ * cores" and deferred every unattended turn over load it did not cause. Compare
286
+ * like with like: a host figure against the host's core count. Exported so the
287
+ * pairing can be pinned.
288
+ */
289
+ export function loadCores(hostCores = cpus().length) {
290
+ return Number.isFinite(hostCores) && hostCores > 0 ? hostCores : MACHINE.cores;
291
+ }
292
+
251
293
  /** What the box says about itself, cached briefly. Exported so a caller can
252
294
  * hold one reading across several verdicts — and so tests can pass their own
253
295
  * instead of depending on the machine they run on. */
@@ -260,7 +302,7 @@ export function measurePressure() {
260
302
  // Unix only; Windows reports zeroes, which are sent as null rather than as
261
303
  // a very calm-looking 0.00 — the same rule the snapshot keeps.
262
304
  load1: loadavg()[0] || null,
263
- cores: MACHINE.cores,
305
+ cores: loadCores(),
264
306
  };
265
307
  lastMeasure = { at: now, m };
266
308
  return m;
@@ -0,0 +1,75 @@
1
+ /** Local, bounded Codex readouts. No agent turn is started for these reports. */
2
+ import { execFileSync } from 'node:child_process';
3
+ import { readFileSync, readdirSync, statSync } from 'node:fs';
4
+ import { homedir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { readBaseTools } from './projectTools.mjs';
7
+
8
+ const TTL = 5 * 60_000;
9
+ let cachedAt = 0;
10
+ let cached = null;
11
+ let repoCached = null;
12
+ const home = (env) => env.CODEX_HOME || join(homedir(), '.codex');
13
+ const nameOk = (name) => typeof name === 'string' && /^[A-Za-z0-9_.:-]{1,80}$/.test(name) && name !== 'flowviant';
14
+ const run = (args, env) => execFileSync('codex', args, {
15
+ encoding: 'utf8', timeout: 5000, maxBuffer: 128 * 1024,
16
+ stdio: ['ignore', 'pipe', 'ignore'], env,
17
+ });
18
+
19
+ export function localCodexOfferings({ env = process.env, runCodex = run, read = readFileSync, list = readdirSync } = {}) {
20
+ const result = {};
21
+ try {
22
+ const cachePath = join(home(env), 'models_cache.json');
23
+ if (read === readFileSync && statSync(cachePath).size > 2 * 1024 * 1024) throw Error('model cache too large');
24
+ const data = JSON.parse(read(cachePath, 'utf8'));
25
+ if (Array.isArray(data.models)) result.models = data.models
26
+ .filter((m) => m?.visibility === 'list' && nameOk(m.slug))
27
+ .slice(0, 60)
28
+ .map((m) => ({ slug: m.slug, displayName: String(m.display_name || m.slug).slice(0, 100),
29
+ efforts: Array.isArray(m.supported_reasoning_levels) ? m.supported_reasoning_levels
30
+ .map((e) => e?.effort).filter((e) => typeof e === 'string' && /^[a-z]{1,20}$/.test(e)).slice(0, 8) : [] }));
31
+ } catch { /* absent or unreadable is unknown */ }
32
+ try {
33
+ const lines = runCodex(['features', 'list'], env).split('\n');
34
+ result.capabilities = lines.some((line) => /^image_generation\s+\S+\s+true\s*$/.test(line)) ? ['image'] : [];
35
+ } catch { /* a failed local probe is unknown */ }
36
+ try {
37
+ result.skills = list(join(home(env), 'skills'), { withFileTypes: true })
38
+ .filter((entry) => entry.isDirectory() && nameOk(entry.name))
39
+ .map((entry) => entry.name).slice(0, 80);
40
+ } catch (error) { if (error?.code === 'ENOENT') result.skills = []; }
41
+ try {
42
+ const servers = JSON.parse(runCodex(['mcp', 'list', '--json'], env));
43
+ if (Array.isArray(servers)) result.mcp = servers
44
+ // Unknown/unsupported auth is not proof of disconnection. Keep their
45
+ // names local, just as connected servers stay local.
46
+ .filter((s) => nameOk(s?.name) && ['needs_auth', 'failed'].includes(s.auth_status))
47
+ .slice(0, 80)
48
+ .map((s) => ({ n: s.name, s: s.auth_status === 'needs_auth' ? 'needs-auth' : 'failed' }));
49
+ } catch { /* no read is unknown */ }
50
+ return result;
51
+ }
52
+
53
+ export function runtimeOfferings(repoRoot, baseRef, { refresh = false } = {}) {
54
+ if (!cached || refresh || Date.now() - cachedAt >= TTL) {
55
+ cached = localCodexOfferings();
56
+ cachedAt = Date.now();
57
+ }
58
+ const out = { rtm: {}, rtc: {}, rts: {}, rtp: {} };
59
+ if (cached.models) out.rtm.codex = cached.models;
60
+ if (cached.capabilities) out.rtc.codex = cached.capabilities;
61
+ if (cached.skills) out.rts.codex = cached.skills;
62
+ if (cached.mcp) out.rtp.codex = cached.mcp;
63
+ // Repo skills and MCP are read from the base commit, like the turn itself.
64
+ try {
65
+ if (!repoCached || repoCached.root !== repoRoot || repoCached.ref !== baseRef || refresh || Date.now() - repoCached.at >= TTL) {
66
+ repoCached = { root: repoRoot, ref: baseRef, at: Date.now(), snapshot: readBaseTools(repoRoot, baseRef) };
67
+ }
68
+ const snapshot = repoCached.snapshot;
69
+ if (out.rts.codex) out.rts.codex = [...new Set([...out.rts.codex, ...snapshot.skills.map((s) => s.path.split('/')[2])])].slice(0, 80);
70
+ if (out.rtp.codex) out.rtp.codex = [...new Map([...out.rtp.codex, ...snapshot.tools
71
+ .filter((t) => t.state !== 'ready' || t.config.type === 'sse')
72
+ .map((t) => ({ n: t.name, s: 'failed' }))].map((server) => [server.n, server])).values()].slice(0, 80);
73
+ } catch { /* keep local measurements */ }
74
+ return out;
75
+ }