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.
- package/bin/cli.mjs +13 -3
- package/bin/lib/agentPlan.mjs +159 -37
- package/bin/lib/artifacts.mjs +136 -26
- package/bin/lib/authReject.mjs +32 -0
- package/bin/lib/authproxy.mjs +52 -16
- package/bin/lib/childEnv.mjs +33 -1
- package/bin/lib/claude.mjs +166 -71
- package/bin/lib/config.mjs +24 -1
- package/bin/lib/credentials.mjs +27 -4
- package/bin/lib/deploy.mjs +364 -30
- package/bin/lib/env.mjs +130 -13
- package/bin/lib/fleet.mjs +99 -28
- package/bin/lib/git.mjs +131 -3
- package/bin/lib/hooks/readGuard.mjs +24 -0
- package/bin/lib/instance.mjs +115 -14
- package/bin/lib/knowledge.mjs +180 -60
- package/bin/lib/landed.mjs +38 -30
- package/bin/lib/listeners.mjs +211 -0
- package/bin/lib/localSessions.mjs +29 -4
- package/bin/lib/login.mjs +2 -2
- package/bin/lib/machines.mjs +16 -7
- package/bin/lib/preview.mjs +95 -6
- package/bin/lib/printable.mjs +42 -0
- package/bin/lib/procRegistry.mjs +65 -0
- package/bin/lib/processes.mjs +20 -7
- package/bin/lib/projectTools.mjs +248 -0
- package/bin/lib/prompts.mjs +16 -11
- package/bin/lib/resources.mjs +46 -4
- package/bin/lib/runtimeOfferings.mjs +75 -0
- package/bin/lib/runtimes.mjs +71 -9
- package/bin/lib/shipMerge.mjs +8 -4
- package/bin/lib/trace.mjs +16 -6
- package/bin/lib/update.mjs +5 -0
- package/bin/lib/work.mjs +1192 -4584
- package/bin/lib/workAgentMerges.mjs +504 -0
- package/bin/lib/workAgentPlans.mjs +447 -0
- package/bin/lib/workAgentReview.mjs +702 -0
- package/bin/lib/workAgentTurns.mjs +1148 -0
- package/bin/lib/workDiffs.mjs +107 -0
- package/bin/lib/workPreviews.mjs +351 -0
- package/bin/lib/workProcesses.mjs +236 -0
- package/bin/lib/workPullRequests.mjs +313 -0
- package/bin/lib/workShip.mjs +385 -0
- package/bin/lib/worktreeDiff.mjs +99 -43
- package/package.json +2 -2
package/bin/lib/processes.mjs
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
145
|
+
const cmd = cmdFor(raw);
|
|
133
146
|
if (!cmd) continue;
|
|
134
|
-
const rss =
|
|
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
|
+
}
|
package/bin/lib/prompts.mjs
CHANGED
|
@@ -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 (
|
|
496
|
-
* MCP — the same fence the scratch planner runs behind). This
|
|
497
|
-
* QUALITY half; the token scope and the permission list are
|
|
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
|
|
688
|
-
* mkdir, no rm, and no MCP at all. The proposal comes back as its
|
|
689
|
-
* message, not through a tool, which is what lets that permission set
|
|
690
|
-
* narrow. A planner authors a decision, and there is no file on this
|
|
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
|
|
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
|
|
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
|
package/bin/lib/resources.mjs
CHANGED
|
@@ -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
|
|
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:
|
|
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
|
+
}
|