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.
Files changed (60) hide show
  1. package/CHANGELOG.md +105 -0
  2. package/README.md +47 -1
  3. package/agents/critic.md +1 -1
  4. package/agents/explorer.md +1 -1
  5. package/agents/planner.md +1 -1
  6. package/agents/reviewer.md +1 -1
  7. package/agents/security-reviewer.md +1 -1
  8. package/agents/test-engineer.md +1 -1
  9. package/agents/writer.md +1 -1
  10. package/dist/index.mjs +478 -366
  11. package/package.json +7 -3
  12. package/scripts/analyze-run.mjs +1333 -0
  13. package/scripts/pty_probe.py +10 -8
  14. package/scripts/resource-sampler.mjs +482 -0
  15. package/skills/real-test-pi-crew/SKILL.md +6 -6
  16. package/src/agents/agent-config.ts +4 -0
  17. package/src/agents/agent-serializer.ts +1 -0
  18. package/src/agents/discover-agents.ts +8 -0
  19. package/src/config/role-tools.ts +49 -1
  20. package/src/observability/event-to-metric.ts +29 -0
  21. package/src/observability/metrics-primitives.ts +41 -3
  22. package/src/prompt/prompt-runtime.ts +6 -0
  23. package/src/prompt/scratchpad-lifecycle.ts +605 -0
  24. package/src/runtime/README.md +1 -1
  25. package/src/runtime/broker/crew-broker.ts +0 -16
  26. package/src/runtime/child-pi/child-pi-spawn.ts +42 -1
  27. package/src/runtime/child-pi/child-pi.ts +5 -0
  28. package/src/runtime/effectiveness.ts +23 -1
  29. package/src/runtime/merge-gate.ts +202 -0
  30. package/src/runtime/model/model-fallback.ts +11 -0
  31. package/src/runtime/model/pi-args.ts +1 -1
  32. package/src/runtime/model/provider-extensions.ts +31 -12
  33. package/src/runtime/output/progress-tracker.ts +3 -33
  34. package/src/runtime/recovery/crash-recovery.ts +1 -1
  35. package/src/runtime/scratchpad/README.md +184 -0
  36. package/src/runtime/scratchpad/engine.ts +648 -0
  37. package/src/runtime/scratchpad/guest.ts +360 -0
  38. package/src/runtime/scratchpad/index.ts +22 -0
  39. package/src/runtime/scratchpad/protocol.ts +88 -0
  40. package/src/runtime/scratchpad/snapshot-hmac.ts +161 -0
  41. package/src/runtime/scratchpad/snapshot-lookup.ts +74 -0
  42. package/src/runtime/scratchpad/transform.ts +363 -0
  43. package/src/runtime/task-runner/child-executor.ts +48 -31
  44. package/src/runtime/team-runner.ts +128 -203
  45. package/src/schema/team-tool-schema.ts +2 -0
  46. package/src/teams/discover-teams.ts +2 -0
  47. package/src/teams/team-config.ts +7 -0
  48. package/src/teams/team-serializer.ts +1 -0
  49. package/src/ui/mascot.ts +1 -14
  50. package/teams/default.team.md +1 -0
  51. package/teams/fast-fix.team.md +1 -0
  52. package/src/observability/event-bus.ts +0 -86
  53. package/src/plugins/plugin-define.ts +0 -6
  54. package/src/plugins/plugin-registry.ts +0 -32
  55. package/src/plugins/plugins/index.ts +0 -3
  56. package/src/plugins/plugins/nextjs.ts +0 -19
  57. package/src/plugins/plugins/vite.ts +0 -10
  58. package/src/plugins/plugins/vitest.ts +0 -9
  59. package/src/runtime/child-pi/child-pi-pool.ts +0 -68
  60. package/src/runtime/iteration-hooks.ts +0 -305
@@ -1,20 +1,22 @@
1
1
  #!/usr/bin/env python3
2
- """pty_probe.py — bulk-key + diag probe for pi-crew TUI components.
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/diag.log
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 = {**os.environ, "PI_CREW_BROKER_DIAG_UI": "1"}
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 = {**os.environ, 'PI_CREW_BROKER_DIAG_UI': '1'}
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
- **`PI_CREW_BROKER_DIAG_UI=1`** makes `run-dashboard`'s `handleInput` write a `[PI-CREW-DIAG]` line to stderr for every keystroke. (The diag is currently wired only in `run-dashboard`, not in `settings-overlay` or other overlays — if you need diag in another overlay, port the `process.env.PI_CREW_BROKER_DIAG_UI === "1"` check from `src/ui/run-dashboard.ts:831`.) Pair with `2>&1 | tee /tmp/diag.log`.
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
- | Diag env var | `PI_CREW_BROKER_DIAG_UI=1` — checked at `src/ui/run-dashboard.ts:831` |
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` — find when diag was wired |
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} && PI_CREW_BROKER_DIAG_UI=1 exec pi 2>&1"
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 —