pi-crew 0.9.62 → 0.9.65
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/CHANGELOG.md +105 -0
- package/README.md +47 -1
- package/agents/critic.md +1 -1
- package/agents/explorer.md +1 -1
- package/agents/planner.md +1 -1
- package/agents/reviewer.md +1 -1
- package/agents/security-reviewer.md +1 -1
- package/agents/test-engineer.md +1 -1
- package/agents/writer.md +1 -1
- package/dist/index.mjs +478 -366
- package/package.json +7 -3
- package/scripts/analyze-run.mjs +1333 -0
- package/scripts/pty_probe.py +10 -8
- package/scripts/resource-sampler.mjs +482 -0
- package/skills/real-test-pi-crew/SKILL.md +6 -6
- package/src/agents/agent-config.ts +4 -0
- package/src/agents/agent-serializer.ts +1 -0
- package/src/agents/discover-agents.ts +8 -0
- package/src/config/role-tools.ts +49 -1
- package/src/observability/event-to-metric.ts +29 -0
- package/src/observability/metrics-primitives.ts +41 -3
- package/src/prompt/prompt-runtime.ts +6 -0
- package/src/prompt/scratchpad-lifecycle.ts +605 -0
- package/src/runtime/README.md +1 -1
- package/src/runtime/broker/crew-broker.ts +0 -16
- package/src/runtime/child-pi/child-pi-spawn.ts +42 -1
- package/src/runtime/child-pi/child-pi.ts +5 -0
- package/src/runtime/effectiveness.ts +23 -1
- package/src/runtime/merge-gate.ts +202 -0
- package/src/runtime/model/model-fallback.ts +11 -0
- package/src/runtime/model/pi-args.ts +1 -1
- package/src/runtime/model/provider-extensions.ts +31 -12
- package/src/runtime/output/progress-tracker.ts +3 -33
- package/src/runtime/recovery/crash-recovery.ts +1 -1
- package/src/runtime/scratchpad/README.md +184 -0
- package/src/runtime/scratchpad/engine.ts +648 -0
- package/src/runtime/scratchpad/guest.ts +360 -0
- package/src/runtime/scratchpad/index.ts +22 -0
- package/src/runtime/scratchpad/protocol.ts +88 -0
- package/src/runtime/scratchpad/snapshot-hmac.ts +161 -0
- package/src/runtime/scratchpad/snapshot-lookup.ts +74 -0
- package/src/runtime/scratchpad/transform.ts +363 -0
- package/src/runtime/task-runner/child-executor.ts +48 -31
- package/src/runtime/team-runner.ts +128 -203
- package/src/schema/team-tool-schema.ts +2 -0
- package/src/teams/discover-teams.ts +2 -0
- package/src/teams/team-config.ts +7 -0
- package/src/teams/team-serializer.ts +1 -0
- package/src/ui/mascot.ts +1 -14
- package/teams/default.team.md +1 -0
- package/teams/fast-fix.team.md +1 -0
- package/src/observability/event-bus.ts +0 -86
- package/src/plugins/plugin-define.ts +0 -6
- package/src/plugins/plugin-registry.ts +0 -32
- package/src/plugins/plugins/index.ts +0 -3
- package/src/plugins/plugins/nextjs.ts +0 -19
- package/src/plugins/plugins/vite.ts +0 -10
- package/src/plugins/plugins/vitest.ts +0 -9
- package/src/runtime/child-pi/child-pi-pool.ts +0 -68
- package/src/runtime/iteration-hooks.ts +0 -305
package/scripts/pty_probe.py
CHANGED
|
@@ -1,20 +1,22 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""pty_probe.py — bulk-key
|
|
2
|
+
"""pty_probe.py — bulk-key probe for pi-crew TUI components.
|
|
3
3
|
|
|
4
4
|
Spawns a real `pi` session under a pty, sends a sequence of keys with
|
|
5
5
|
short sleeps, captures the resulting output. Useful for verifying that
|
|
6
6
|
keystrokes reached the component's handleInput after a ui/ change.
|
|
7
7
|
|
|
8
|
+
NOTE (2026-08-10): the per-keystroke diag env var (PI_CREW_BROKER_DIAG_UI)
|
|
9
|
+
was REMOVED in e3ee6fe2 (PR-B5/UI-8 — "remove TEMP DIAGNOSTIC from
|
|
10
|
+
run-dashboard"). There is no replacement in src/. Keystroke arrival is now
|
|
11
|
+
proven by SCREEN-CHANGE evidence: capture the output frames before/after
|
|
12
|
+
each key and diff them — a key that changes screen state reached the TUI.
|
|
13
|
+
|
|
8
14
|
Requires Python 3.x on PATH. Unix only (Linux + macOS) — uses POSIX `pty.fork`.
|
|
9
15
|
Does NOT work on native Windows (no `pty` module); use WSL or Tier 5 (tmux) instead.
|
|
10
16
|
|
|
11
17
|
Usage:
|
|
12
18
|
python3 scripts/pty_probe.py [--keys j,k,q] [--cwd /path/to/repo]
|
|
13
19
|
|
|
14
|
-
Env:
|
|
15
|
-
PI_CREW_BROKER_DIAG_UI=1 enable diag stderr writes from run-dashboard
|
|
16
|
-
(only component wired; see src/ui/run-dashboard.ts:831)
|
|
17
|
-
|
|
18
20
|
Examples:
|
|
19
21
|
# Default probe (vim nav + arrow keys + quit)
|
|
20
22
|
python3 scripts/pty_probe.py
|
|
@@ -22,8 +24,8 @@ Examples:
|
|
|
22
24
|
# Custom probe: only arrow keys
|
|
23
25
|
python3 scripts/pty_probe.py --keys '\x1bOA,\x1bOB,\x1bOC,\x1bOD,q,q'
|
|
24
26
|
|
|
25
|
-
# Capture to a file
|
|
26
|
-
python3 scripts/pty_probe.py 2>&1 | tee /tmp/
|
|
27
|
+
# Capture to a file (diff frames for screen-change evidence)
|
|
28
|
+
python3 scripts/pty_probe.py 2>&1 | tee /tmp/pty-probe.log
|
|
27
29
|
"""
|
|
28
30
|
import argparse
|
|
29
31
|
import os
|
|
@@ -99,7 +101,7 @@ def main() -> int:
|
|
|
99
101
|
pass # use as-is if decode fails
|
|
100
102
|
keys.append(k)
|
|
101
103
|
|
|
102
|
-
env =
|
|
104
|
+
env = dict(os.environ) # keystroke diag env var removed (see module docstring)
|
|
103
105
|
|
|
104
106
|
pid, fd = pty.fork()
|
|
105
107
|
if pid == 0:
|
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-crew resource sampler — external process monitor.
|
|
3
|
+
*
|
|
4
|
+
* Samples RSS / heap / CPU% for a PID and all its descendants, writing one
|
|
5
|
+
* JSONL line per PID per tick. Runs as a standalone external process so it
|
|
6
|
+
* works regardless of pi-crew's bundle/runtime (no src/ instrumentation).
|
|
7
|
+
*
|
|
8
|
+
* Modes:
|
|
9
|
+
* --watch-parent <pid> [--run-id <id>] [--interval 2000] [--out <path>]
|
|
10
|
+
* Polls <pid> + children until SIGINT/SIGTERM.
|
|
11
|
+
* --wrap <cmd...> [--run-id <id>] [--interval 2000] [--out <path>]
|
|
12
|
+
* Spawns <cmd>, samples it + children until exit, exits with child code.
|
|
13
|
+
*
|
|
14
|
+
* Output JSONL line: {ts,pid,ppid,label,rssBytes,heapBytes,cpuPct}
|
|
15
|
+
*
|
|
16
|
+
* Linux path uses /proc; non-Linux falls back to `ps -o rss=,pcpu=`.
|
|
17
|
+
*
|
|
18
|
+
* Run: node scripts/resource-sampler.mjs --wrap pi --version
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
22
|
+
import { readFileSync, existsSync, mkdirSync, appendFileSync, readdirSync } from "node:fs";
|
|
23
|
+
import { join } from "node:path";
|
|
24
|
+
|
|
25
|
+
// ---------- CLI parsing ----------
|
|
26
|
+
// Known options may appear ANYWHERE (before or after --wrap). Everything
|
|
27
|
+
// after --wrap that is NOT a known option becomes the wrapped command.
|
|
28
|
+
const KNOWN_FLAGS = new Set(["--watch-parent", "--watch-run", "--crew-root", "--wrap", "--interval", "--run-id", "--out", "--no-live-warn", "-h", "--help"]);
|
|
29
|
+
|
|
30
|
+
function parseArgs(argv) {
|
|
31
|
+
const args = { interval: 2000, mode: null, parentPid: null, wrap: [], runId: null, out: null, watchRun: null, crewRoot: null, liveWarn: true };
|
|
32
|
+
let wrapStarted = false;
|
|
33
|
+
for (let i = 2; i < argv.length; i++) {
|
|
34
|
+
const a = argv[i];
|
|
35
|
+
// Once --wrap is seen, collect non-flag tokens as the command. A known
|
|
36
|
+
// flag is still parsed as an option (so `--wrap sleep 5 --interval 500`
|
|
37
|
+
// works), but an unknown token is treated as part of the command.
|
|
38
|
+
if (wrapStarted && !KNOWN_FLAGS.has(a)) {
|
|
39
|
+
args.wrap.push(a);
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (a === "--watch-parent") {
|
|
43
|
+
args.mode = "watch";
|
|
44
|
+
args.parentPid = Number.parseInt(argv[++i], 10);
|
|
45
|
+
} else if (a === "--watch-run") {
|
|
46
|
+
// auto-resolve the run's leader/runner PID from pi-crew state (no manual
|
|
47
|
+
// pgrep). Reads async.pid / manifest.async.pid / heartbeat.json.
|
|
48
|
+
args.mode = "watch";
|
|
49
|
+
args.watchRun = argv[++i];
|
|
50
|
+
if (!args.runId) args.runId = args.watchRun;
|
|
51
|
+
} else if (a === "--crew-root") {
|
|
52
|
+
args.crewRoot = argv[++i];
|
|
53
|
+
} else if (a === "--wrap") {
|
|
54
|
+
args.mode = "wrap";
|
|
55
|
+
wrapStarted = true;
|
|
56
|
+
} else if (a === "--interval") {
|
|
57
|
+
// R13 (audit): reject NaN and clamp tiny intervals — setInterval(fn, 0)
|
|
58
|
+
// or setInterval(fn, NaN) spins ~60-130×/s, writing huge files and
|
|
59
|
+
// burning CPU (each tick also scans all of /proc).
|
|
60
|
+
const raw = argv[++i];
|
|
61
|
+
const parsed = Number.parseInt(raw, 10);
|
|
62
|
+
if (Number.isNaN(parsed)) {
|
|
63
|
+
process.stderr.write(`Error: --interval must be a number (ms), got "${raw}"\n`);
|
|
64
|
+
process.exit(1);
|
|
65
|
+
}
|
|
66
|
+
if (parsed < 100) {
|
|
67
|
+
process.stderr.write(`[resource-sampler] --interval ${parsed}ms < 100ms; clamping to 100ms\n`);
|
|
68
|
+
args.interval = 100;
|
|
69
|
+
} else {
|
|
70
|
+
args.interval = parsed;
|
|
71
|
+
}
|
|
72
|
+
} else if (a === "--run-id") {
|
|
73
|
+
args.runId = argv[++i];
|
|
74
|
+
} else if (a === "--out") {
|
|
75
|
+
args.out = argv[++i];
|
|
76
|
+
} else if (a === "--no-live-warn") {
|
|
77
|
+
args.liveWarn = false;
|
|
78
|
+
} else if (a === "-h" || a === "--help") {
|
|
79
|
+
printHelp();
|
|
80
|
+
process.exit(0);
|
|
81
|
+
} else if (wrapStarted) {
|
|
82
|
+
// unknown token after --wrap → part of the command
|
|
83
|
+
args.wrap.push(a);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (!args.mode) {
|
|
87
|
+
printHelp();
|
|
88
|
+
process.exit(1);
|
|
89
|
+
}
|
|
90
|
+
return args;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function printHelp() {
|
|
94
|
+
process.stderr.write(
|
|
95
|
+
[
|
|
96
|
+
"Usage:",
|
|
97
|
+
" resource-sampler.mjs --watch-parent <pid> [--run-id <id>] [--interval 2000] [--out <path>]",
|
|
98
|
+
" resource-sampler.mjs --wrap <cmd...> [--run-id <id>] [--interval 2000] [--out <path>]",
|
|
99
|
+
"",
|
|
100
|
+
].join("\n"),
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ---------- /proc readers (Linux) ----------
|
|
105
|
+
const CLK_TCK = 100; // sysconf(_SC_CLK_TCK) on essentially all Linux/x86/arm
|
|
106
|
+
|
|
107
|
+
function readProcStat(pid) {
|
|
108
|
+
// /proc/<pid>/stat — comm (field 2) may contain spaces inside parens.
|
|
109
|
+
let raw;
|
|
110
|
+
try {
|
|
111
|
+
raw = readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
112
|
+
} catch {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
const open = raw.indexOf("(");
|
|
116
|
+
const close = raw.lastIndexOf(")");
|
|
117
|
+
if (open < 0 || close < 0) return null;
|
|
118
|
+
const comm = raw.slice(open + 1, close);
|
|
119
|
+
const rest = raw.slice(close + 2).trim().split(/\s+/);
|
|
120
|
+
// rest[0] = field3 (state), rest[1] = field4 (ppid), rest[11] = utime(f14), rest[12] = stime(f15), rest[19] = starttime(f22)
|
|
121
|
+
return {
|
|
122
|
+
pid,
|
|
123
|
+
comm,
|
|
124
|
+
state: rest[0],
|
|
125
|
+
ppid: Number.parseInt(rest[1], 10) || 0,
|
|
126
|
+
utime: Number.parseInt(rest[11], 10) || 0,
|
|
127
|
+
stime: Number.parseInt(rest[12], 10) || 0,
|
|
128
|
+
starttime: Number.parseInt(rest[19], 10) || 0,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function readProcStatus(pid) {
|
|
133
|
+
let raw;
|
|
134
|
+
try {
|
|
135
|
+
raw = readFileSync(`/proc/${pid}/status`, "utf8");
|
|
136
|
+
} catch {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
let rssKb = 0;
|
|
140
|
+
let dataKb = 0;
|
|
141
|
+
for (const line of raw.split("\n")) {
|
|
142
|
+
if (line.startsWith("VmRSS:")) rssKb = Number.parseInt(line.slice(6).trim(), 10) || 0;
|
|
143
|
+
else if (line.startsWith("VmData:")) dataKb = Number.parseInt(line.slice(7).trim(), 10) || 0;
|
|
144
|
+
}
|
|
145
|
+
return { rssKb, dataKb };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ---------- fallback via ps (non-Linux) ----------
|
|
149
|
+
function readPs(pid) {
|
|
150
|
+
const res = spawnSync("ps", ["-o", "rss=,pcpu=", "-p", String(pid)], { encoding: "utf8" });
|
|
151
|
+
if (res.status !== 0 || !res.stdout.trim()) return null;
|
|
152
|
+
const parts = res.stdout.trim().split(/\s+/);
|
|
153
|
+
return {
|
|
154
|
+
pid,
|
|
155
|
+
ppid: 0,
|
|
156
|
+
rssKb: Number.parseInt(parts[0], 10) || 0,
|
|
157
|
+
dataKb: 0,
|
|
158
|
+
cpuPct: Number.parseFloat(parts[1]) || 0,
|
|
159
|
+
ps: true,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ---------- child discovery ----------
|
|
164
|
+
function findDescendants(rootPid) {
|
|
165
|
+
// BFS over /proc to find all PIDs whose ppid chain leads to rootPid.
|
|
166
|
+
if (!existsSync("/proc")) return [rootPid];
|
|
167
|
+
const all = [];
|
|
168
|
+
try {
|
|
169
|
+
for (const name of readdirSync("/proc")) {
|
|
170
|
+
if (/^\d+$/.test(name)) all.push(Number.parseInt(name, 10));
|
|
171
|
+
}
|
|
172
|
+
} catch {
|
|
173
|
+
return [rootPid];
|
|
174
|
+
}
|
|
175
|
+
const ppidOf = new Map();
|
|
176
|
+
for (const pid of all) {
|
|
177
|
+
const s = readProcStat(pid);
|
|
178
|
+
if (s) ppidOf.set(pid, s.ppid);
|
|
179
|
+
}
|
|
180
|
+
const result = new Set([rootPid]);
|
|
181
|
+
let frontier = [rootPid];
|
|
182
|
+
while (frontier.length) {
|
|
183
|
+
const next = [];
|
|
184
|
+
for (const [pid, ppid] of ppidOf) {
|
|
185
|
+
if (result.has(ppid) && !result.has(pid)) {
|
|
186
|
+
result.add(pid);
|
|
187
|
+
next.push(pid);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
frontier = next;
|
|
191
|
+
}
|
|
192
|
+
return [...result];
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ---------- sampling ----------
|
|
196
|
+
const cpuPrev = new Map(); // pid -> {ticks, ts}
|
|
197
|
+
|
|
198
|
+
// ---- live anomaly warnings (stderr, rate-limited) ----
|
|
199
|
+
// Emitted DURING sampling so the user sees resource anomalies in real time,
|
|
200
|
+
// not only in the post-hoc analyze-run report. Rate-limited per (pid,category)
|
|
201
|
+
// so a sustained spike doesn't spam every tick.
|
|
202
|
+
const rssPrev = new Map(); // pid -> { rss, ts } (for one-interval RSS jump)
|
|
203
|
+
const rssHist = new Map(); // pid -> number[] (recent rssBytes, for slow-leak trend)
|
|
204
|
+
const warnCooldown = new Map(); // `${pid}:${cat}` -> last emit ts
|
|
205
|
+
const LIVE_CPU_PCT = 300; // single-process CPU% (multi-core: 300% = 3 cores)
|
|
206
|
+
const LIVE_RSS_JUMP = 200_000_000; // +200MB in one interval (fast spike)
|
|
207
|
+
const LIVE_RSS_HIGH = 1_000_000_000; // 1GB absolute
|
|
208
|
+
const LIVE_LEAK_WINDOW = 30; // samples to evaluate slow-leak trend (30s at 1s interval)
|
|
209
|
+
const LIVE_LEAK_GROWTH = 100_000_000; // +100MB net over the window = leak
|
|
210
|
+
const LIVE_LEAK_MONOTONIC = 0.75; // ≥75% of pairs increasing = monotonic-ish
|
|
211
|
+
const WARN_COOLDOWN_MS = 10_000;
|
|
212
|
+
let liveWarnEnabled = true;
|
|
213
|
+
function liveWarn(pid, label, cat, msg) {
|
|
214
|
+
if (!liveWarnEnabled) return;
|
|
215
|
+
const key = `${pid}:${cat}`;
|
|
216
|
+
const now = Date.now();
|
|
217
|
+
if (now - (warnCooldown.get(key) || 0) < WARN_COOLDOWN_MS) return;
|
|
218
|
+
warnCooldown.set(key, now);
|
|
219
|
+
process.stderr.write(`[resource-sampler] ⚠️ LIVE ${new Date(now).toISOString().slice(11, 19)} ${cat} pid=${pid}${label ? ` (${label})` : ""}: ${msg}\n`);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function samplePid(pid, rootPid) {
|
|
223
|
+
// Linux /proc path
|
|
224
|
+
if (existsSync(`/proc/${pid}/stat`)) {
|
|
225
|
+
const stat = readProcStat(pid);
|
|
226
|
+
const status = readProcStatus(pid);
|
|
227
|
+
if (!stat) return null;
|
|
228
|
+
const now = Date.now();
|
|
229
|
+
const ticks = stat.utime + stat.stime;
|
|
230
|
+
let cpuPct = 0;
|
|
231
|
+
const prev = cpuPrev.get(pid);
|
|
232
|
+
// firstSample: no usable baseline yet (first-ever sample OR PID reused).
|
|
233
|
+
// cpuPct is 0 here by construction — consumers should exclude it from CPU
|
|
234
|
+
// averages so short-lived subagents aren't dragged down by the first tick.
|
|
235
|
+
const firstSample = !prev || prev.starttime !== stat.starttime;
|
|
236
|
+
// PID-reuse guard: if starttime changed, a NEW process recycled this PID
|
|
237
|
+
// (common under respawn churn) — the old cpuPrev ticks are stale and would
|
|
238
|
+
// underreport. Treat as a first sample (cpuPct=0) and reset the baseline.
|
|
239
|
+
if (!firstSample) {
|
|
240
|
+
const dtick = ticks - prev.ticks;
|
|
241
|
+
const dsec = (now - prev.ts) / 1000;
|
|
242
|
+
if (dsec > 0) cpuPct = (dtick / CLK_TCK / dsec) * 100;
|
|
243
|
+
}
|
|
244
|
+
cpuPrev.set(pid, { ticks, ts: now, starttime: stat.starttime });
|
|
245
|
+
return {
|
|
246
|
+
ts: now,
|
|
247
|
+
pid,
|
|
248
|
+
ppid: stat.ppid,
|
|
249
|
+
label: pid === rootPid ? "root" : "child",
|
|
250
|
+
rssBytes: status ? status.rssKb * 1024 : 0,
|
|
251
|
+
heapBytes: status ? status.dataKb * 1024 : 0,
|
|
252
|
+
cpuPct: Math.max(0, Math.round(cpuPct * 10) / 10),
|
|
253
|
+
firstSample,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
// fallback: ps
|
|
257
|
+
const ps = readPs(pid);
|
|
258
|
+
if (!ps) return null;
|
|
259
|
+
return {
|
|
260
|
+
ts: Date.now(),
|
|
261
|
+
pid,
|
|
262
|
+
ppid: ps.ppid,
|
|
263
|
+
label: pid === rootPid ? "root" : "child",
|
|
264
|
+
rssBytes: ps.rssKb * 1024,
|
|
265
|
+
heapBytes: 0,
|
|
266
|
+
cpuPct: Math.round(ps.cpuPct * 10) / 10,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function tick(rootPid, outPath) {
|
|
271
|
+
const pids = findDescendants(rootPid);
|
|
272
|
+
const seen = new Set(pids);
|
|
273
|
+
for (const pid of pids) {
|
|
274
|
+
const sample = samplePid(pid, rootPid);
|
|
275
|
+
if (sample) {
|
|
276
|
+
appendFileSync(outPath, JSON.stringify(sample) + "\n");
|
|
277
|
+
// live anomaly checks (rate-limited, stderr) — real-time observability
|
|
278
|
+
// zombie/dead child still in /proc (parent not reaping) — flag it live
|
|
279
|
+
if (pid !== rootPid && !isAlive(pid)) liveWarn(pid, sample.label, "proc_zombie", "process zombie/dead (state Z/X) — parent not reaping child");
|
|
280
|
+
if (sample.cpuPct >= LIVE_CPU_PCT) liveWarn(pid, sample.label, "high_cpu", `CPU ${sample.cpuPct}% (≥${LIVE_CPU_PCT}%)`);
|
|
281
|
+
const rp = rssPrev.get(pid);
|
|
282
|
+
if (rp) {
|
|
283
|
+
const jump = sample.rssBytes - rp.rss;
|
|
284
|
+
if (jump >= LIVE_RSS_JUMP) liveWarn(pid, sample.label, "rss_jump", `RSS +${(jump / 1024 / 1024).toFixed(0)}MB (${(rp.rss / 1024 / 1024).toFixed(0)}→${(sample.rssBytes / 1024 / 1024).toFixed(0)}MB) in one interval`);
|
|
285
|
+
}
|
|
286
|
+
rssPrev.set(pid, { rss: sample.rssBytes, ts: sample.ts });
|
|
287
|
+
if (sample.rssBytes >= LIVE_RSS_HIGH) liveWarn(pid, sample.label, "rss_high", `RSS ${(sample.rssBytes / 1024 / 1024).toFixed(0)}MB (≥1GB)`);
|
|
288
|
+
// slow-leak trend: gradual monotonic growth that no single-interval jump
|
|
289
|
+
// catches (e.g. +5MB/interval × 50). LONG window (30 samples) so warmup
|
|
290
|
+
// growth (V8 heap fill ~10-15s then plateau) does NOT fire — only
|
|
291
|
+
// SUSTAINED growth (30s+) is a leak signal. Validated on real e2e data:
|
|
292
|
+
// subagent warmup plateaus (no fire), long-lived session growth fires.
|
|
293
|
+
const h = rssHist.get(pid) || [];
|
|
294
|
+
h.push(sample.rssBytes);
|
|
295
|
+
if (h.length > LIVE_LEAK_WINDOW) h.shift();
|
|
296
|
+
rssHist.set(pid, h);
|
|
297
|
+
if (h.length >= LIVE_LEAK_WINDOW) {
|
|
298
|
+
const growth = h[h.length - 1] - h[0];
|
|
299
|
+
let inc = 0;
|
|
300
|
+
for (let i = 1; i < h.length; i++) if (h[i] > h[i - 1]) inc++;
|
|
301
|
+
if (growth >= LIVE_LEAK_GROWTH && inc / (h.length - 1) >= LIVE_LEAK_MONOTONIC) {
|
|
302
|
+
liveWarn(pid, sample.label, "rss_leak", `slow RSS leak +${(growth / 1024 / 1024).toFixed(0)}MB over ${h.length} samples (${(h[0] / 1024 / 1024).toFixed(0)}→${(h[h.length - 1] / 1024 / 1024).toFixed(0)}MB, monotonic) — possible memory leak`);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
} else {
|
|
306
|
+
rssPrev.delete(pid);
|
|
307
|
+
rssHist.delete(pid);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
// F4 (audit): prune cpuPrev entries for PIDs no longer alive — prevents the
|
|
311
|
+
// Map from accumulating stale entries for short-lived children over a long
|
|
312
|
+
// sampling session.
|
|
313
|
+
for (const pid of cpuPrev.keys()) {
|
|
314
|
+
if (!seen.has(pid)) {
|
|
315
|
+
cpuPrev.delete(pid);
|
|
316
|
+
rssPrev.delete(pid);
|
|
317
|
+
rssHist.delete(pid);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
// prune warnCooldown for gone PIDs (keep rootPid entry)
|
|
321
|
+
for (const key of warnCooldown.keys()) {
|
|
322
|
+
const pid = Number(key.split(":")[0]);
|
|
323
|
+
if (!seen.has(pid) && pid !== rootPid) warnCooldown.delete(key);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// R4/sampler-test (audit): robust liveness — a process that exited but whose
|
|
328
|
+
// parent can't reap it yet is a ZOMBIE, and /proc/<pid>/stat still exists for
|
|
329
|
+
// zombies. Checking only file existence would never detect death in that
|
|
330
|
+
// case. Treat state 'Z' (zombie) and 'X' (dead) as not-alive.
|
|
331
|
+
function isAlive(pid) {
|
|
332
|
+
if (existsSync("/proc")) {
|
|
333
|
+
const stat = readProcStat(pid);
|
|
334
|
+
if (!stat) return false;
|
|
335
|
+
return stat.state !== "Z" && stat.state !== "X";
|
|
336
|
+
}
|
|
337
|
+
return readPs(pid) != null;
|
|
338
|
+
}
|
|
339
|
+
// Resolve a run's leader/runner PID from pi-crew state. Tries async.pid (a
|
|
340
|
+
// dedicated JSON file), manifest.async.pid, and heartbeat.json.pid. Polls
|
|
341
|
+
// briefly (the file may not exist the instant the run starts).
|
|
342
|
+
function resolveRunnerPid(runDir) {
|
|
343
|
+
const readJsonSafe = (p) => {
|
|
344
|
+
try {
|
|
345
|
+
return JSON.parse(readFileSync(p, "utf8"));
|
|
346
|
+
} catch {
|
|
347
|
+
return null;
|
|
348
|
+
}
|
|
349
|
+
};
|
|
350
|
+
for (let i = 0; i < 20; i++) {
|
|
351
|
+
const asyncPid = readJsonSafe(join(runDir, "async.pid"));
|
|
352
|
+
if (asyncPid && typeof asyncPid.pid === "number") return asyncPid.pid;
|
|
353
|
+
const manifest = readJsonSafe(join(runDir, "manifest.json"));
|
|
354
|
+
if (manifest && manifest.async && typeof manifest.async.pid === "number") return manifest.async.pid;
|
|
355
|
+
const hb = readJsonSafe(join(runDir, "heartbeat.json"));
|
|
356
|
+
if (hb && typeof hb.pid === "number") return hb.pid;
|
|
357
|
+
// R7 (audit): block-wait instead of spawning a throwaway node process
|
|
358
|
+
// every poll. Atomics.wait on the main thread is permitted in Node.
|
|
359
|
+
const waitBuf = new Int32Array(new SharedArrayBuffer(4));
|
|
360
|
+
Atomics.wait(waitBuf, 0, 0, 500);
|
|
361
|
+
}
|
|
362
|
+
return null;
|
|
363
|
+
}
|
|
364
|
+
function main() {
|
|
365
|
+
const args = parseArgs(process.argv);
|
|
366
|
+
liveWarnEnabled = args.liveWarn;
|
|
367
|
+
const outDir = join(process.cwd(), "bench", "results");
|
|
368
|
+
mkdirSync(outDir, { recursive: true });
|
|
369
|
+
const outFile =
|
|
370
|
+
args.out ||
|
|
371
|
+
join(outDir, `${args.runId || "wrap-" + Date.now()}.resources.jsonl`);
|
|
372
|
+
process.stderr.write(`[resource-sampler] writing → ${outFile}\n`);
|
|
373
|
+
|
|
374
|
+
if (args.mode === "wrap") {
|
|
375
|
+
if (args.wrap.length === 0) {
|
|
376
|
+
process.stderr.write("--wrap requires a command\n");
|
|
377
|
+
process.exit(1);
|
|
378
|
+
}
|
|
379
|
+
const child = spawn(args.wrap[0], args.wrap.slice(1), { stdio: "inherit" });
|
|
380
|
+
const rootPid = child.pid;
|
|
381
|
+
let exited = false;
|
|
382
|
+
// sample immediately, then on interval
|
|
383
|
+
tick(rootPid, outFile);
|
|
384
|
+
const handle = setInterval(() => tick(rootPid, outFile), args.interval);
|
|
385
|
+
const cleanup = () => {
|
|
386
|
+
clearInterval(handle);
|
|
387
|
+
// F3 (audit): kill the wrapped child so it is not orphaned when the
|
|
388
|
+
// sampler is signalled (SIGINT/SIGTERM). tryKill is best-effort.
|
|
389
|
+
try {
|
|
390
|
+
if (!exited) child.kill("SIGTERM");
|
|
391
|
+
} catch {
|
|
392
|
+
/* already gone */
|
|
393
|
+
}
|
|
394
|
+
};
|
|
395
|
+
process.on("SIGINT", () => {
|
|
396
|
+
cleanup();
|
|
397
|
+
process.exit(130);
|
|
398
|
+
});
|
|
399
|
+
process.on("SIGTERM", () => {
|
|
400
|
+
cleanup();
|
|
401
|
+
process.exit(143);
|
|
402
|
+
});
|
|
403
|
+
child.on("exit", (code, signal) => {
|
|
404
|
+
exited = true;
|
|
405
|
+
clearInterval(handle);
|
|
406
|
+
// final sample
|
|
407
|
+
tick(rootPid, outFile);
|
|
408
|
+
process.stderr.write(`[resource-sampler] child exited code=${code} signal=${signal}\n`);
|
|
409
|
+
process.exit(code ?? 1);
|
|
410
|
+
});
|
|
411
|
+
} else {
|
|
412
|
+
// watch-parent (or --watch-run, which resolves the runner PID from state)
|
|
413
|
+
let rootPid = args.parentPid;
|
|
414
|
+
if (args.watchRun) {
|
|
415
|
+
const crew = args.crewRoot || join(process.env.HOME || "/home/bom", ".crew");
|
|
416
|
+
const runDir = join(crew, "state", "runs", args.watchRun);
|
|
417
|
+
rootPid = resolveRunnerPid(runDir);
|
|
418
|
+
if (!rootPid) {
|
|
419
|
+
process.stderr.write(`--watch-run: could not resolve runner PID for ${args.watchRun} in ${runDir} (async.pid / manifest.async.pid / heartbeat.json)\n`);
|
|
420
|
+
process.exit(1);
|
|
421
|
+
}
|
|
422
|
+
process.stderr.write(`[resource-sampler] --watch-run ${args.watchRun} → runner PID ${rootPid}\n`);
|
|
423
|
+
}
|
|
424
|
+
if (!rootPid) {
|
|
425
|
+
process.stderr.write("--watch-parent requires a PID\n");
|
|
426
|
+
process.exit(1);
|
|
427
|
+
}
|
|
428
|
+
// R4 (audit): auto-stop when the watched tree dies. Previously the sampler
|
|
429
|
+
// kept ticking forever (writing nothing) after rootPid exited, until the
|
|
430
|
+
// user remembered to Ctrl-C. Now: if rootPid is not alive for 3
|
|
431
|
+
// consecutive ticks, stop cleanly. rootPid (the team leader) outlives the
|
|
432
|
+
// whole run, so its death = run over.
|
|
433
|
+
// PLUS (perf-obs): in --watch-run mode the runner PID may be the
|
|
434
|
+
// long-lived foreground pi process (sync runs) — it stays alive after the
|
|
435
|
+
// run, so ALSO stop when the run's manifest.json reaches a terminal
|
|
436
|
+
// status (completed/failed/cancelled/blocked).
|
|
437
|
+
const runDir = args.watchRun ? join(args.crewRoot || join(process.env.HOME || "/home/bom", ".crew"), "state", "runs", args.watchRun) : null;
|
|
438
|
+
const runIsTerminal = () => {
|
|
439
|
+
if (!runDir) return false;
|
|
440
|
+
try {
|
|
441
|
+
const m = JSON.parse(readFileSync(join(runDir, "manifest.json"), "utf8"));
|
|
442
|
+
return ["completed", "failed", "cancelled", "blocked"].includes(m.status);
|
|
443
|
+
} catch {
|
|
444
|
+
return false;
|
|
445
|
+
}
|
|
446
|
+
};
|
|
447
|
+
let deadTicks = 0;
|
|
448
|
+
const tickWatch = () => {
|
|
449
|
+
const alive = isAlive(rootPid);
|
|
450
|
+
if (alive) {
|
|
451
|
+
deadTicks = 0;
|
|
452
|
+
if (runIsTerminal()) {
|
|
453
|
+
if (handle) clearInterval(handle);
|
|
454
|
+
process.stderr.write(`[resource-sampler] run ${args.watchRun} reached terminal status — stopping\n`);
|
|
455
|
+
process.exit(0);
|
|
456
|
+
}
|
|
457
|
+
tick(rootPid, outFile);
|
|
458
|
+
} else {
|
|
459
|
+
deadTicks++;
|
|
460
|
+
if (deadTicks === 1) liveWarn(rootPid, "root", "proc_died", `watched PID ${rootPid} no longer alive`);
|
|
461
|
+
if (deadTicks >= 3) {
|
|
462
|
+
clearInterval(handle);
|
|
463
|
+
process.stderr.write(`[resource-sampler] watched PID ${rootPid} gone — stopping\n`);
|
|
464
|
+
process.exit(0);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
};
|
|
468
|
+
let handle;
|
|
469
|
+
tickWatch();
|
|
470
|
+
handle = setInterval(tickWatch, args.interval);
|
|
471
|
+
const shutdown = () => {
|
|
472
|
+
clearInterval(handle);
|
|
473
|
+
tick(rootPid, outFile);
|
|
474
|
+
process.stderr.write("[resource-sampler] stopped\n");
|
|
475
|
+
process.exit(0);
|
|
476
|
+
};
|
|
477
|
+
process.on("SIGINT", shutdown);
|
|
478
|
+
process.on("SIGTERM", shutdown);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
main();
|
|
@@ -303,7 +303,7 @@ tmux capture-pane -t pi -p > /tmp/screen-after-up.txt
|
|
|
303
303
|
import os, sys, time
|
|
304
304
|
|
|
305
305
|
CMD = ['pi']
|
|
306
|
-
ENV =
|
|
306
|
+
ENV = dict(os.environ) # keystroke diag env var REMOVED (see note below)
|
|
307
307
|
|
|
308
308
|
pid, fd = pty.fork()
|
|
309
309
|
if pid == 0:
|
|
@@ -331,14 +331,14 @@ else:
|
|
|
331
331
|
> ```
|
|
332
332
|
> The inline code works for a quick one-off but **leaks a zombie `pi` process** on exit.
|
|
333
333
|
|
|
334
|
-
|
|
334
|
+
**Keystroke diag env var REMOVED (2026-08-10)**: `PI_CREW_BROKER_DIAG_UI=1` made `run-dashboard`'s `handleInput` write a `[PI-CREW-DIAG]` line to stderr per keystroke. It was removed in `e3ee6fe2` (PR-B5: remove TEMP DIAGNOSTIC from run-dashboard, UI-8) — there is no replacement in `src/`. **To prove keystroke arrival now, rely on screen-change evidence** (Tier 5 tmux `capture-pane` before/after each key, or the pty output diff): a key that changes screen state reached the TUI; a key that does not was consumed or never arrived. Capture the probe output to a file with `2>&1 | tee /tmp/pty-probe.log` and diff the rendered frames.
|
|
335
335
|
|
|
336
336
|
**References**:
|
|
337
337
|
|
|
338
338
|
| What | Where |
|
|
339
339
|
|---|---|
|
|
340
|
-
|
|
|
341
|
-
| Reduced-noise commit | `00e8ba0 chore(broker): strip diagnostic noise from focused-field fix` — diag calls left in but no longer noisy |
|
|
340
|
+
| Keystroke diag env var | **REMOVED** — `e3ee6fe2` (PR-B5/UI-8). No replacement; use screen-change evidence |
|
|
341
|
+
| Reduced-noise commit | `00e8ba0 chore(broker): strip diagnostic noise from focused-field fix` — diag calls left in but no longer noisy (pre-removal) |
|
|
342
342
|
| Original probe | `84944f7 test(probe): add invalidate() to control object so typecheck passes` |
|
|
343
343
|
|
|
344
344
|
---
|
|
@@ -637,7 +637,7 @@ The skill mentions specific commits, line numbers, and version pins. As the code
|
|
|
637
637
|
|---|---|---|
|
|
638
638
|
| Verify line refs after each `src/` commit | Every commit touching the cited file | `git log -p -- src/extension/registration/lifecycle-handlers.ts \| grep effectiveEnabled` — if line moved, update the skill |
|
|
639
639
|
| Verify commit hashes still exist | Quarterly or before major edits | `git log --oneline -1 <hash>` — if gone, find the equivalent newer commit |
|
|
640
|
-
| Verify version pins (v0.9.46, etc.) | Each release | `git log --oneline -- src/ui/run-dashboard.ts \| head -5` —
|
|
640
|
+
| Verify version pins (v0.9.46, etc.) | Each release | `git log --oneline -- src/ui/run-dashboard.ts \| head -5` — confirm diag removal history (e3ee6fe2) still accurate |
|
|
641
641
|
| Verify `test:critical` still has 14 files | Each `src/runtime/crew-broker*.ts` edit | `cat package.json \| grep test:critical` — adjust the file list |
|
|
642
642
|
| Verify Tier 7 verifier prompts still say `test:critical` | Each workflow file edit | `grep "Run FAST checks" workflows/*.workflow.md` |
|
|
643
643
|
|
|
@@ -662,7 +662,7 @@ readlink ../node_modules/pi-crew # dev: → ../pi-crew
|
|
|
662
662
|
readlink "$(npm root -g)"/pi-crew # global install
|
|
663
663
|
# Tier 5 (tmux probe)
|
|
664
664
|
tmux -S /tmp/sock new-session -d -x 160 -y 50 -s pi \
|
|
665
|
-
"cd ${PWD} &&
|
|
665
|
+
"cd ${PWD} && exec pi 2>&1"
|
|
666
666
|
tmux send-keys -t pi '<key>' ; sleep 0.5
|
|
667
667
|
tmux capture-pane -t pi -p
|
|
668
668
|
# Tier 6 (pty probe)
|
|
@@ -60,6 +60,10 @@ export interface AgentConfig {
|
|
|
60
60
|
fallbackModels?: string[];
|
|
61
61
|
thinking?: string;
|
|
62
62
|
tools?: string[];
|
|
63
|
+
/** Phase 1 scratchpad opt-in (execute tool). `false` wins over a
|
|
64
|
+
* role default (F6 kill-switch); read-only roles are gated out regardless
|
|
65
|
+
* (S-6) by `isScratchpadEnabledForRole`. Parsed from frontmatter `scratchpad:`. */
|
|
66
|
+
scratchpad?: boolean;
|
|
63
67
|
extensions?: string[];
|
|
64
68
|
/**
|
|
65
69
|
* F1 (v0.7.9): extension denylist (case-insensitive plain names). Applied
|
|
@@ -15,6 +15,7 @@ export function serializeAgent(agent: AgentConfig): string {
|
|
|
15
15
|
line("fallbackModels", agent.fallbackModels),
|
|
16
16
|
line("thinking", agent.thinking),
|
|
17
17
|
line("tools", agent.tools),
|
|
18
|
+
line("scratchpad", agent.scratchpad),
|
|
18
19
|
agent.extensions !== undefined ? (line("extensions", agent.extensions) ?? "extensions:") : undefined,
|
|
19
20
|
line("skills", agent.skills),
|
|
20
21
|
line("systemPromptMode", agent.systemPromptMode),
|
|
@@ -427,6 +427,14 @@ function parseAgentFile(filePath: string, source: ResourceSource): AgentConfig |
|
|
|
427
427
|
fallbackModels: parseCsv(frontmatter.fallbackModels),
|
|
428
428
|
thinking: frontmatter.thinking === "false" ? undefined : frontmatter.thinking || undefined,
|
|
429
429
|
tools: parseToolsField(frontmatter.tools),
|
|
430
|
+
// Phase 1 scratchpad opt-in (Q3: pi ignores unknown frontmatter keys; pi-crew
|
|
431
|
+
// is the sole consumer in the worker path — task arrives via -p, agent file
|
|
432
|
+
// is not re-read by pi). 3-STATE parse (NOT `=== "true"` like
|
|
433
|
+
// inheritProjectContext): omitted/malformed → undefined so the F6 kill-switch
|
|
434
|
+
// (`agent.scratchpad === false`) only fires on an EXPLICIT `scratchpad: false`,
|
|
435
|
+
// not on every agent without the key (which would wrongly kill role
|
|
436
|
+
// default-on). Only the literal "true"/"false" are honored.
|
|
437
|
+
scratchpad: frontmatter.scratchpad === "true" ? true : frontmatter.scratchpad === "false" ? false : undefined,
|
|
430
438
|
// SEC-1: Strip extensions/excludeExtensions for untrusted project-sourced
|
|
431
439
|
// agents (RCE prevention). Both `project` (.crew/agents/) and
|
|
432
440
|
// `project-pi` (.pi/agents/) are repo-adjacent / untrusted sources —
|