flowviant 0.97.0 → 0.100.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/README.md +15 -13
- package/bin/cli.mjs +108 -28
- 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 +36 -9
- package/bin/lib/credentials.mjs +29 -5
- package/bin/lib/deploy.mjs +364 -30
- package/bin/lib/desktopContract.mjs +227 -0
- package/bin/lib/env.mjs +130 -13
- package/bin/lib/fleet.mjs +121 -30
- package/bin/lib/git.mjs +131 -3
- package/bin/lib/hooks/readGuard.mjs +24 -0
- package/bin/lib/instance.mjs +129 -14
- package/bin/lib/knowledge.mjs +180 -60
- package/bin/lib/landed.mjs +38 -30
- package/bin/lib/launchCommand.mjs +23 -0
- package/bin/lib/listeners.mjs +211 -0
- package/bin/lib/localSessions.mjs +29 -4
- package/bin/lib/login.mjs +36 -12
- package/bin/lib/machines.mjs +24 -14
- package/bin/lib/mcp-cli.mjs +4 -3
- package/bin/lib/preflight.mjs +2 -1
- 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 +114 -25
- 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 +1151 -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 -3
package/bin/lib/mcp-cli.mjs
CHANGED
|
@@ -18,15 +18,16 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import { FLEET_TOKEN, USER_AGENT, MCP_URL, FLEET_URL } from './config.mjs';
|
|
21
|
+
import { terminalCommand } from './launchCommand.mjs';
|
|
21
22
|
|
|
22
23
|
const CLI_TOKEN_URL = FLEET_URL.replace(/\/agents\/?$/, '/cli-token');
|
|
23
24
|
|
|
24
25
|
export async function runMcpCommand(args = []) {
|
|
25
26
|
if (!FLEET_TOKEN) {
|
|
26
27
|
console.error(
|
|
27
|
-
|
|
28
|
+
`error: no credential resolves here. Run \`${terminalCommand('login')}\` first — or, with\n` +
|
|
28
29
|
'several projects connected on this box, run this inside the project\'s own\n' +
|
|
29
|
-
|
|
30
|
+
`repo or pass \`--project <name|id>\` (\`${terminalCommand('projects')}\` lists them).`
|
|
30
31
|
);
|
|
31
32
|
process.exit(1);
|
|
32
33
|
}
|
|
@@ -50,7 +51,7 @@ export async function runMcpCommand(args = []) {
|
|
|
50
51
|
console.error(
|
|
51
52
|
`error: could not mint a CLI credential (${res.status}). ` +
|
|
52
53
|
(res.status === 401 || res.status === 403
|
|
53
|
-
?
|
|
54
|
+
? `Your credential may have been revoked — try \`${terminalCommand('login')}\` again.`
|
|
54
55
|
: 'Try again in a moment.')
|
|
55
56
|
);
|
|
56
57
|
process.exit(1);
|
package/bin/lib/preflight.mjs
CHANGED
|
@@ -8,6 +8,7 @@ import { execFileSync } from 'node:child_process';
|
|
|
8
8
|
import { ok, warn, info, c } from './ui.mjs';
|
|
9
9
|
import { addLocalBinToPath, promptYesNo, installClaude, installGh } from './install.mjs';
|
|
10
10
|
import { RUNTIMES, detectRuntimes, mediatedSafeGap } from './runtimes.mjs';
|
|
11
|
+
import { terminalCommand } from './launchCommand.mjs';
|
|
11
12
|
|
|
12
13
|
function present(cmd) {
|
|
13
14
|
try {
|
|
@@ -106,7 +107,7 @@ export async function preflight({ needGit = true } = {}) {
|
|
|
106
107
|
if (await installGh((m) => info(m))) gh = present('gh');
|
|
107
108
|
}
|
|
108
109
|
gh
|
|
109
|
-
? ok(
|
|
110
|
+
? ok(`gh installed to ~/.flowviant/bin — authenticate with: ${terminalCommand('gh-auth')}`)
|
|
110
111
|
: info(c.dim('or install it later: https://cli.github.com, then: gh auth login'));
|
|
111
112
|
}
|
|
112
113
|
|
package/bin/lib/preview.mjs
CHANGED
|
@@ -46,7 +46,7 @@ import {
|
|
|
46
46
|
import { join } from 'node:path';
|
|
47
47
|
import { homedir, platform, arch } from 'node:os';
|
|
48
48
|
import { startAuthProxy } from './authproxy.mjs';
|
|
49
|
-
import { forgetInfraPid, isListening, noteInfraPid } from './listeners.mjs';
|
|
49
|
+
import { forgetInfraPid, isListening, noteInfraPid, originFor } from './listeners.mjs';
|
|
50
50
|
|
|
51
51
|
// ── cloudflared: pinned, verified, or not fetched at all ───────────────────
|
|
52
52
|
|
|
@@ -138,7 +138,19 @@ async function ensureCloudflared(log) {
|
|
|
138
138
|
}
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
-
|
|
141
|
+
/**
|
|
142
|
+
* The hostname cloudflared ASSIGNED, and nothing else it happens to print.
|
|
143
|
+
*
|
|
144
|
+
* `[a-z0-9-]+\.trycloudflare\.com` also matched cloudflared's own FAILURE line
|
|
145
|
+
* — `failed to request quick Tunnel: Post "https://api.trycloudflare.com/tunnel":
|
|
146
|
+
* dial tcp: lookup api.trycloudflare.com: …` — so on any box that could not
|
|
147
|
+
* reach the quick-tunnel API (offline, DNS failure, a proxy blocking it) the
|
|
148
|
+
* share was reported LIVE at Cloudflare's own API host, and cloudflared's real
|
|
149
|
+
* sentence, which the tail exists to relay, was replaced by a wrong one (audit
|
|
150
|
+
* 2026-09-24). `api.` is the service's endpoint and never an assigned name, and
|
|
151
|
+
* the trailing guard stops a longer hostname matching on its prefix.
|
|
152
|
+
*/
|
|
153
|
+
export const TUNNEL_RE = /https:\/\/(?!api\.)[a-z0-9-]+\.trycloudflare\.com(?![a-z0-9.-])/i;
|
|
142
154
|
|
|
143
155
|
// ── Orphan reaping ─────────────────────────────────────────────────────────
|
|
144
156
|
// cloudflared is detached so we can kill its whole group — which also means it
|
|
@@ -228,6 +240,39 @@ function mutateRegistry(fn) {
|
|
|
228
240
|
}
|
|
229
241
|
}
|
|
230
242
|
|
|
243
|
+
/**
|
|
244
|
+
* WHEN a process started, as the kernel reports it — or null when this box
|
|
245
|
+
* will not say. Paired with a pid it names ONE process: a pid alone is
|
|
246
|
+
* recycled, and the owner check below trusted signal 0 on a bare pid.
|
|
247
|
+
*/
|
|
248
|
+
export function processStartOf(pid) {
|
|
249
|
+
if (!Number.isInteger(pid) || pid <= 0) return null;
|
|
250
|
+
if (platform() === 'linux') {
|
|
251
|
+
try {
|
|
252
|
+
const stat = readFileSync(`/proc/${pid}/stat`, 'utf8');
|
|
253
|
+
// Field 22 (starttime), counted after the LAST ')' — the comm field in
|
|
254
|
+
// parentheses may itself hold spaces and parentheses.
|
|
255
|
+
const rest = stat.slice(stat.lastIndexOf(')') + 2).split(' ');
|
|
256
|
+
return rest[19] ? `l:${rest[19]}` : null;
|
|
257
|
+
} catch {
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
if (platform() === 'darwin') {
|
|
262
|
+
try {
|
|
263
|
+
const out = execFileSync('ps', ['-p', String(pid), '-o', 'lstart='], {
|
|
264
|
+
encoding: 'utf8',
|
|
265
|
+
timeout: 5_000,
|
|
266
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
267
|
+
}).trim();
|
|
268
|
+
return out ? `d:${out}` : null;
|
|
269
|
+
} catch {
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
|
|
231
276
|
/** Signal-0 liveness (EPERM = alive and not ours), for the OWNER check below. */
|
|
232
277
|
function processAlive(pid) {
|
|
233
278
|
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
@@ -246,7 +291,15 @@ function recordPreviewPid(pid, sig) {
|
|
|
246
291
|
// starting daemon reaped its PEER's live tunnels: killed them, wiped their
|
|
247
292
|
// entries, and the peer kept heartbeating a URL that 530s (its probe watches
|
|
248
293
|
// the origin port, which was still alive).
|
|
249
|
-
|
|
294
|
+
//
|
|
295
|
+
// `ownerStart` pins the owner to ONE process (audit 2026-09-24). A daemon that
|
|
296
|
+
// is SIGKILLed with a share open leaves cloudflared up; if its pid is reused
|
|
297
|
+
// by the next start — any process, another user's included, since EPERM
|
|
298
|
+
// counts as alive — a pid-only check read the entry as a live peer's and
|
|
299
|
+
// skipped it forever, leaving a public hostname aimed at a dead gate's
|
|
300
|
+
// ephemeral port for anything that binds it next.
|
|
301
|
+
const ownerStart = processStartOf(process.pid);
|
|
302
|
+
mutateRegistry((list) => [...list, { pid, sig, owner: process.pid, ...(ownerStart ? { ownerStart } : {}) }]);
|
|
250
303
|
}
|
|
251
304
|
|
|
252
305
|
function forgetPreviewPid(pid) {
|
|
@@ -311,13 +364,24 @@ function stillOurs(pid, sig) {
|
|
|
311
364
|
* own teardown handles it) — killing those and wiping their entries was a
|
|
312
365
|
* peer daemon's startup silently breaking every live share on the box. Only
|
|
313
366
|
* the entries this pass handled are removed; a peer's records survive. */
|
|
367
|
+
/** Is the daemon that recorded an entry still the process at that pid? With
|
|
368
|
+
* a recorded start time, only when the pid's start time still matches (a
|
|
369
|
+
* recycled pid is an orphaned entry); where this box cannot report a start
|
|
370
|
+
* time now, or the entry predates the field, signal 0 is all there is. */
|
|
371
|
+
function ownerStillRunning(owner, ownerStart) {
|
|
372
|
+
if (!processAlive(owner)) return false;
|
|
373
|
+
if (typeof ownerStart !== 'string') return true;
|
|
374
|
+
const now = processStartOf(owner);
|
|
375
|
+
return now == null ? true : now === ownerStart;
|
|
376
|
+
}
|
|
377
|
+
|
|
314
378
|
export function reapOrphanPreviews(log) {
|
|
315
379
|
const list = readRegistry();
|
|
316
380
|
if (list.length === 0) return;
|
|
317
381
|
let killed = 0;
|
|
318
382
|
const handled = new Set();
|
|
319
|
-
for (const { pid, sig, owner } of list) {
|
|
320
|
-
if (Number.isInteger(owner) && owner !== process.pid &&
|
|
383
|
+
for (const { pid, sig, owner, ownerStart } of list) {
|
|
384
|
+
if (Number.isInteger(owner) && owner !== process.pid && ownerStillRunning(owner, ownerStart)) continue;
|
|
321
385
|
const ours = stillOurs(pid, sig);
|
|
322
386
|
/**
|
|
323
387
|
* THE RECORD OUTLIVES A REAP THAT COULD NOT LOOK. `handled.add` ran BEFORE
|
|
@@ -396,6 +460,11 @@ export async function openTunnel({
|
|
|
396
460
|
onAbuse,
|
|
397
461
|
onTunnelGone,
|
|
398
462
|
stillServing,
|
|
463
|
+
// The worktree the port was attributed to. When given, the gate dials the
|
|
464
|
+
// address the ATTRIBUTED socket holds (`originFor`) rather than assuming
|
|
465
|
+
// 127.0.0.1, and every liveness check re-derives it — so a neighbour on the
|
|
466
|
+
// other loopback family is never what the tunnel publishes.
|
|
467
|
+
worktree,
|
|
399
468
|
probeMs = 20_000,
|
|
400
469
|
// The members-gate triple, all three or none. Absent = an older server, or a
|
|
401
470
|
// password-mode share: the gate runs exactly as it always has.
|
|
@@ -406,8 +475,15 @@ export async function openTunnel({
|
|
|
406
475
|
// ONE predicate for every liveness question this function asks. Attribution
|
|
407
476
|
// when the caller gave it, a bare TCP connect only when it did not; an
|
|
408
477
|
// attribution check that errors is not a "yes".
|
|
478
|
+
// `origin` is decided once, at the gate's start; after that a change of
|
|
479
|
+
// address is a different process and reads as "not serving".
|
|
480
|
+
let origin = null;
|
|
409
481
|
const serving = async () => {
|
|
410
482
|
try {
|
|
483
|
+
if (worktree) {
|
|
484
|
+
const o = originFor(worktree, port);
|
|
485
|
+
if (o.error || (origin && o.host !== origin)) return false;
|
|
486
|
+
}
|
|
411
487
|
return stillServing ? await stillServing() : await isListening(port);
|
|
412
488
|
} catch {
|
|
413
489
|
return false;
|
|
@@ -455,8 +531,14 @@ export async function openTunnel({
|
|
|
455
531
|
|
|
456
532
|
// The gate comes up FIRST and the tunnel points at it, never at the origin —
|
|
457
533
|
// so there is no window in which the public hostname is un-gated.
|
|
534
|
+
if (worktree) {
|
|
535
|
+
const o = originFor(worktree, port);
|
|
536
|
+
if (o.error) return { error: o.error };
|
|
537
|
+
origin = o.host;
|
|
538
|
+
}
|
|
458
539
|
gate = await startAuthProxy({
|
|
459
540
|
targetPort: port,
|
|
541
|
+
...(origin ? { targetHost: origin } : {}),
|
|
460
542
|
log,
|
|
461
543
|
// Never logged, never written to previews.json, never in the reap
|
|
462
544
|
// signature, never in argv or a child env — /proc/<pid>/cmdline is
|
|
@@ -529,7 +611,14 @@ export async function openTunnel({
|
|
|
529
611
|
const onOut = (d) => {
|
|
530
612
|
const s = d.toString();
|
|
531
613
|
tail = (tail + s).slice(-TAIL_BYTES);
|
|
532
|
-
|
|
614
|
+
// ONE URL, ONE PROBE. A second match after we settled (cloudflared
|
|
615
|
+
// repeats the banner, or a later line names another host) used to start
|
|
616
|
+
// a second probe interval and a second close listener on a tunnel whose
|
|
617
|
+
// answer had already been given.
|
|
618
|
+
if (settled) return;
|
|
619
|
+
// Matched over the TAIL, not the chunk: a pipe read can split the
|
|
620
|
+
// hostname across two chunks, and a half never matches.
|
|
621
|
+
const m = TUNNEL_RE.exec(tail);
|
|
533
622
|
if (!m) return;
|
|
534
623
|
|
|
535
624
|
// Watch the ORIGIN — with the caller's ATTRIBUTION check when it gave
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PRINTABLE(): the CLI's own belt against a server-supplied string it is
|
|
3
|
+
* about to write to a TTY (2026-09-24, the audit's A3 CROSS 8a).
|
|
4
|
+
*
|
|
5
|
+
* `safeName` (credentials.mjs) already stripped C0/DEL/C1 for the same reason
|
|
6
|
+
* the server's own `parseBoxName`/`parseCheckoutPath` did — an escape
|
|
7
|
+
* sequence in a project or box name can clear the screen or, via OSC 52,
|
|
8
|
+
* rewrite the terminal's clipboard — but it stopped at C1 and never scrubbed
|
|
9
|
+
* the BIDI/format controls (U+200E/U+200F, U+202A–U+202E, U+2066–U+2069) that
|
|
10
|
+
* reorder a line to hide or forge part of it: a box named
|
|
11
|
+
* `eviltxt.crt` reads backwards on a bidi-aware terminal, so
|
|
12
|
+
* "evil" can visually swap places with an extension that was never there.
|
|
13
|
+
*
|
|
14
|
+
* THIS IS A LEAF MODULE ON PURPOSE. `machines.mjs` and `fleet.mjs` both need
|
|
15
|
+
* it, `machines.mjs` deliberately imports nothing from `fleet.mjs`'s world
|
|
16
|
+
* (its own header explains why: it runs before the auth gate, like `stop` and
|
|
17
|
+
* `projects`, and must not pull in the daemon's whole CLI/worktree
|
|
18
|
+
* machinery), and `credentials.mjs` is where `safeName` already lived. A leaf
|
|
19
|
+
* with zero imports is the one shape both can depend on without adding an
|
|
20
|
+
* edge neither wants.
|
|
21
|
+
*
|
|
22
|
+
* THE CHARACTER CLASS IS THE SERVER'S OWN (`stripDisplayControl`,
|
|
23
|
+
* machineBoxes.ts, 2026-09-24) — copied rather than re-derived, because the
|
|
24
|
+
* point of a BELT is that the two ends agree about what counts as printable
|
|
25
|
+
* without either one trusting the other to have applied it. The server scrub
|
|
26
|
+
* is the primary fix (it is what a stale stored row, upserted before this
|
|
27
|
+
* belt existed, is read back through); this is defence in depth, because the
|
|
28
|
+
* terminal is where the harm happens and the CLI should not trust that every
|
|
29
|
+
* string on the wire already passed through it — including a project's own
|
|
30
|
+
* `name`, which reaches every terminal that connects a machine (`login`, the
|
|
31
|
+
* banner, the picker, `projects`, `machines`) and had no scrub at all before
|
|
32
|
+
* `safeName`.
|
|
33
|
+
*/
|
|
34
|
+
// eslint-disable-next-line no-control-regex
|
|
35
|
+
const DISPLAY_CONTROL = /[\u0000-\u001f\u007f-\u009f--]/g;
|
|
36
|
+
|
|
37
|
+
/** Strip the class above. Non-strings pass through unchanged — this is a
|
|
38
|
+
* scrub, not a coercion, and a caller that hands it `undefined` should get
|
|
39
|
+
* `undefined` back rather than the string `"undefined"`. */
|
|
40
|
+
export function printable(raw) {
|
|
41
|
+
return typeof raw === 'string' ? raw.replace(DISPLAY_CONTROL, '') : raw;
|
|
42
|
+
}
|
package/bin/lib/procRegistry.mjs
CHANGED
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
* does not become a file nobody can read.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
+
import { execFileSync } from 'node:child_process';
|
|
21
|
+
import { platform, uptime } from 'node:os';
|
|
20
22
|
import {
|
|
21
23
|
closeSync,
|
|
22
24
|
mkdirSync,
|
|
@@ -95,6 +97,69 @@ export function writeRegistry(dir, path, list) {
|
|
|
95
97
|
}
|
|
96
98
|
}
|
|
97
99
|
|
|
100
|
+
/**
|
|
101
|
+
* WHICH BOOT THIS IS — so a pid or process-group id read back off disk is only
|
|
102
|
+
* ever believed in the boot that wrote it.
|
|
103
|
+
*
|
|
104
|
+
* A registry outlives a reboot and pids do not: after one, the number a file
|
|
105
|
+
* remembers belongs to whatever the kernel handed it to next — another user's
|
|
106
|
+
* service, the operator's shell — and signal-0 says "alive" about a stranger.
|
|
107
|
+
* Linux names the boot outright (`boot_id`); elsewhere the boot INSTANT stands
|
|
108
|
+
* in, derived from the uptime and compared with slack, because two reads a
|
|
109
|
+
* second apart disagree by the rounding.
|
|
110
|
+
*/
|
|
111
|
+
const BOOT_SLACK_S = 120;
|
|
112
|
+
export function bootMark() {
|
|
113
|
+
try {
|
|
114
|
+
const id = readFileSync('/proc/sys/kernel/random/boot_id', 'utf8').trim();
|
|
115
|
+
if (id) return id;
|
|
116
|
+
} catch {
|
|
117
|
+
/* not Linux, or /proc is not mounted */
|
|
118
|
+
}
|
|
119
|
+
return `bt:${Math.round(Date.now() / 1000 - uptime())}`;
|
|
120
|
+
}
|
|
121
|
+
/** Was `mark` written during this boot? An absent mark is NOT this boot —
|
|
122
|
+
* an entry that cannot say when it was written cannot be trusted across one. */
|
|
123
|
+
export function sameBoot(mark, now = bootMark()) {
|
|
124
|
+
if (typeof mark !== 'string' || !mark) return false;
|
|
125
|
+
if (mark.startsWith('bt:') && now.startsWith('bt:'))
|
|
126
|
+
return Math.abs(Number(mark.slice(3)) - Number(now.slice(3))) <= BOOT_SLACK_S;
|
|
127
|
+
return mark === now;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* WHEN A PROCESS STARTED, as an opaque token — so a lock that remembers a pid
|
|
132
|
+
* can tell the process it meant from one that inherited the number.
|
|
133
|
+
*
|
|
134
|
+
* Linux: field 22 of `/proc/<pid>/stat` (start time in clock ticks since boot),
|
|
135
|
+
* read after the LAST `)` because the comm field may itself contain one.
|
|
136
|
+
* macOS: `ps -o lstart=`. Null when it cannot be read, which callers treat as
|
|
137
|
+
* "cannot tell" — never as a mismatch.
|
|
138
|
+
*/
|
|
139
|
+
export function processStartTime(pid) {
|
|
140
|
+
if (!Number.isInteger(pid) || pid <= 0) return null;
|
|
141
|
+
try {
|
|
142
|
+
const stat = readFileSync(`/proc/${pid}/stat`, 'utf8');
|
|
143
|
+
const rest = stat.slice(stat.lastIndexOf(')') + 2).split(' ');
|
|
144
|
+
// `rest[0]` is field 3 (state), so field 22 is rest[19].
|
|
145
|
+
const st = rest[19];
|
|
146
|
+
return st && /^\d+$/.test(st) ? `${bootMark()}:${st}` : null;
|
|
147
|
+
} catch {
|
|
148
|
+
/* not Linux, or the pid is gone */
|
|
149
|
+
}
|
|
150
|
+
if (platform() !== 'darwin') return null;
|
|
151
|
+
try {
|
|
152
|
+
const out = execFileSync('ps', ['-o', 'lstart=', '-p', String(pid)], {
|
|
153
|
+
encoding: 'utf8',
|
|
154
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
155
|
+
timeout: 5_000,
|
|
156
|
+
}).trim();
|
|
157
|
+
return out || null;
|
|
158
|
+
} catch {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
98
163
|
/** Signal-0 liveness. EPERM means alive and not ours, which is still alive. */
|
|
99
164
|
export function processAlive(pid) {
|
|
100
165
|
if (!Number.isInteger(pid) || pid <= 0) return false;
|
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
|
+
}
|