pi-crew 0.9.5 → 0.9.8

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 (37) hide show
  1. package/CHANGELOG.md +556 -0
  2. package/README.md +10 -3
  3. package/docs/HARNESS_BACKLOG.md +51 -3
  4. package/docs/dynamic-workflows.md +315 -2
  5. package/docs/fix-plan-disabletools-exit-null.md +219 -0
  6. package/docs/troubleshooting.md +76 -0
  7. package/package.json +10 -3
  8. package/src/config/defaults.ts +8 -4
  9. package/src/extension/team-tool/doctor.ts +14 -0
  10. package/src/extension/team-tool/run.ts +2 -0
  11. package/src/runtime/background-runner.ts +1 -1
  12. package/src/runtime/capability-inventory.ts +20 -1
  13. package/src/runtime/child-pi.ts +109 -11
  14. package/src/runtime/deterministic-ast.ts +161 -0
  15. package/src/runtime/dwf-state-store.ts +97 -0
  16. package/src/runtime/dynamic-workflow-context.ts +381 -7
  17. package/src/runtime/dynamic-workflow-runner.ts +93 -2
  18. package/src/runtime/pi-args.ts +11 -0
  19. package/src/runtime/result-extractor.ts +72 -7
  20. package/src/runtime/task-output-context.ts +25 -9
  21. package/src/runtime/team-runner.ts +8 -3
  22. package/src/runtime/zombie-scanner.ts +297 -0
  23. package/src/schema/team-tool-schema.ts +28 -0
  24. package/src/skills/discover-skills.ts +61 -8
  25. package/src/skills/validate.ts +267 -0
  26. package/src/state/contracts.ts +1 -0
  27. package/src/state/state-store.ts +3 -0
  28. package/src/state/types.ts +9 -0
  29. package/src/ui/dashboard-panes/progress-pane.ts +5 -0
  30. package/src/ui/dwf-phase-display.ts +151 -0
  31. package/src/ui/keybinding-map.ts +128 -41
  32. package/src/ui/run-event-bus.ts +83 -0
  33. package/src/ui/run-snapshot-cache.ts +4 -0
  34. package/src/ui/snapshot-types.ts +3 -0
  35. package/src/workflows/workflow-config.ts +3 -0
  36. package/src/worktree/worktree-manager.ts +94 -0
  37. package/types/dwf.d.ts +187 -0
@@ -0,0 +1,297 @@
1
+ /**
2
+ * zombie-scanner.ts — safely detect orphaned pi-crew sub-agent processes.
3
+ *
4
+ * LESSON (learned the hard way): a heuristic like "old `pi` process + high RSS +
5
+ * orphaned (ppid=1/bash)" will match a user's interactive MAIN session just as
6
+ * readily as a real zombie. The result is a live main session being killed by
7
+ * accident. This module replaces that heuristic with an authoritative signal.
8
+ *
9
+ * Authoritative marker (set by buildPiWorkerArgs on every child-pi spawn):
10
+ * - argv: `--crew-subagent` is the first positional arg
11
+ * - env: `PI_CREW_KIND=subagent` is the machine-readable signal
12
+ *
13
+ * A process is a "pi-crew sub-agent" ONLY IF it carries `PI_CREW_KIND=subagent`
14
+ * in its environment. The user's main `pi` session NEVER has this var, so it can
15
+ * never be matched here — by construction.
16
+ *
17
+ * A sub-agent is a "zombie" ONLY IF its `PI_CREW_PARENT_PID` points at a PID that
18
+ * is no longer alive (parent crashed/exited without reaping the child). A sub-agent
19
+ * whose parent is still running is NOT a zombie — it's a legitimate in-flight task.
20
+ *
21
+ * This module is READ-ONLY. It never kills anything. The caller (doctor --zombies)
22
+ * prints the list and asks for explicit confirmation before any kill.
23
+ */
24
+
25
+ import * as fs from "node:fs";
26
+
27
+ export interface ZombieSubagent {
28
+ pid: number;
29
+ ppid: number;
30
+ /** PID recorded in PI_CREW_PARENT_PID (may differ from ppid if re-parented to init/bash). */
31
+ crewParentPid: number;
32
+ /** Whether the recorded crew parent PID is still alive. */
33
+ parentAlive: boolean;
34
+ role: string | undefined;
35
+ rssKb: number;
36
+ elapsedSec: number | undefined;
37
+ cmd: string;
38
+ }
39
+
40
+ export interface ZombieScanResult {
41
+ zombies: ZombieSubagent[];
42
+ /** Sub-agents whose parent is still alive — shown for transparency, never killed. */
43
+ live: ZombieSubagent[];
44
+ /** Errors encountered while scanning (per-pid). Never aborts the whole scan. */
45
+ errors: string[];
46
+ }
47
+
48
+ /** Read /proc/<pid>/environ as a key=value record. Returns {} if unreadable. */
49
+ function readProcEnviron(pid: number): Record<string, string> {
50
+ try {
51
+ // /proc/<pid>/environ is NUL-separated key=value pairs.
52
+ const raw = fs.readFileSync(`/proc/${pid}/environ`, "utf-8");
53
+ const out: Record<string, string> = {};
54
+ for (const entry of raw.split("\0")) {
55
+ const eq = entry.indexOf("=");
56
+ if (eq > 0) out[entry.slice(0, eq)] = entry.slice(eq + 1);
57
+ }
58
+ return out;
59
+ } catch {
60
+ return {};
61
+ }
62
+ }
63
+
64
+ /** Read /proc/<pid>/stat to get ppid + elapsed. Returns undefined if unreadable. */
65
+ function readProcStat(pid: number): { ppid: number; elapsedSec: number | undefined } | undefined {
66
+ try {
67
+ const stat = fs.readFileSync(`/proc/${pid}/stat`, "utf-8");
68
+ // stat format: pid (comm) state ppid ... starttime ...
69
+ // comm may contain spaces/parens, so parse from the LAST ')' backwards.
70
+ const closeParen = stat.lastIndexOf(")");
71
+ if (closeParen < 0) return undefined;
72
+ const rest = stat.slice(closeParen + 2).trim().split(/\s+/);
73
+ // rest[0] = state, rest[1] = ppid
74
+ const ppid = Number.parseInt(rest[1] ?? "", 10);
75
+ // starttime (clock ticks since boot) is field 22 in the full stat → index 19 in `rest`
76
+ const starttimeTicksRaw = Number.parseInt(rest[19] ?? "", 10);
77
+ const starttimeTicks = Number.isFinite(starttimeTicksRaw) ? starttimeTicksRaw : undefined;
78
+ const elapsedSec = computeElapsedSec(starttimeTicks);
79
+ return { ppid: Number.isFinite(ppid) ? ppid : 0, elapsedSec };
80
+ } catch {
81
+ return undefined;
82
+ }
83
+ }
84
+
85
+ function computeElapsedSec(starttimeTicks: number | undefined): number | undefined {
86
+ if (starttimeTicks === undefined || !Number.isFinite(starttimeTicks)) return undefined;
87
+ try {
88
+ // Linux CLK_TCK is virtually always 100 (sysconf(_SC_CLK_TCK)). Reading it
89
+ // portably from Node requires a native addon; hardcoding 100 matches every
90
+ // mainstream Linux distro and keeps this dependency-free.
91
+ const ticksPerSec = 100;
92
+ // /proc/uptime: first field is seconds since boot.
93
+ const uptimeRaw = fs.readFileSync("/proc/uptime", "utf-8");
94
+ const uptimeSec = Number.parseFloat(uptimeRaw.split(" ")[0] ?? "");
95
+ if (!Number.isFinite(uptimeSec)) return undefined;
96
+ // starttime (ticks since boot) → process age in seconds = uptime - starttime/ticksPerSec.
97
+ const startAgeSec = starttimeTicks / ticksPerSec;
98
+ return Math.max(0, uptimeSec - startAgeSec);
99
+ } catch {
100
+ return undefined;
101
+ }
102
+ }
103
+
104
+ function isPidAlive(pid: number): boolean {
105
+ if (!Number.isFinite(pid) || pid <= 0) return false;
106
+ try {
107
+ // process.kill(pid, 0) throws if the pid is not alive (or not ours).
108
+ process.kill(pid, 0);
109
+ return true;
110
+ } catch {
111
+ return false;
112
+ }
113
+ }
114
+
115
+ function readProcCmdline(pid: number): string {
116
+ try {
117
+ // /proc/<pid>/cmdline is NUL-separated argv.
118
+ const raw = fs.readFileSync(`/proc/${pid}/cmdline`, "utf-8");
119
+ return raw.split("\0").filter(Boolean).join(" ").trim() || `pid ${pid}`;
120
+ } catch {
121
+ return `pid ${pid}`;
122
+ }
123
+ }
124
+
125
+ function readProcRssKb(pid: number): number {
126
+ try {
127
+ const status = fs.readFileSync(`/proc/${pid}/status`, "utf-8");
128
+ const match = status.match(/^VmRSS:\s+(\d+)\s+kB/m);
129
+ return match ? Number.parseInt(match[1] ?? "", 10) : 0;
130
+ } catch {
131
+ return 0;
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Enumerate candidate pi-crew sub-agent PIDs under the current uid.
137
+ *
138
+ * Reads /proc directly (Linux only) — no shelling out to pgrep/ps, so the
139
+ * result is deterministic and unaffected by shell quoting or locale. On
140
+ * non-Linux platforms the scanner returns an empty result with a note in
141
+ * `errors` (zombie detection is best-effort; the doctor report still renders).
142
+ */
143
+ function listCandidatePids(): number[] {
144
+ if (process.platform !== "linux") return [];
145
+ const pids: number[] = [];
146
+ try {
147
+ for (const entry of fs.readdirSync("/proc")) {
148
+ if (/^\d+$/.test(entry)) pids.push(Number.parseInt(entry, 10));
149
+ }
150
+ } catch {
151
+ // /proc unreadable (e.g. sandboxed). Caller surfaces via errors[].
152
+ }
153
+ return pids;
154
+ }
155
+
156
+ /**
157
+ * Scan for orphaned pi-crew sub-agent processes. READ-ONLY — never kills.
158
+ *
159
+ * Returns the full picture: zombies (parent dead), live (parent alive), and
160
+ * any scan errors. Callers decide what to do with the result; this module
161
+ * has no side effects.
162
+ */
163
+ export function scanZombieSubagents(): ZombieScanResult {
164
+ const result: ZombieScanResult = { zombies: [], live: [], errors: [] };
165
+ if (process.platform !== "linux") {
166
+ result.errors.push("zombie scan is Linux-only (/proc required); skipping on " + process.platform);
167
+ return result;
168
+ }
169
+
170
+ const myUid = tryGetUid();
171
+ for (const pid of listCandidatePids()) {
172
+ try {
173
+ // Cheap rejection first: only inspect processes we own (avoid scanning system procs).
174
+ if (myUid !== undefined && getProcUid(pid) !== myUid) continue;
175
+
176
+ const environ = readProcEnviron(pid);
177
+ // AUTHORITATIVE GATE: a process is a pi-crew sub-agent ONLY if it carries
178
+ // PI_CREW_KIND=subagent. The user's main session never sets this, so it can
179
+ // never be matched — this is the fix for accidentally killing main sessions.
180
+ if (environ.PI_CREW_KIND !== "subagent") continue;
181
+
182
+ const crewParentPid = Number.parseInt(environ.PI_CREW_PARENT_PID ?? "", 10);
183
+ const stat = readProcStat(pid);
184
+ const entry: ZombieSubagent = {
185
+ pid,
186
+ ppid: stat?.ppid ?? 0,
187
+ crewParentPid: Number.isFinite(crewParentPid) ? crewParentPid : 0,
188
+ parentAlive: Number.isFinite(crewParentPid) && isPidAlive(crewParentPid),
189
+ role: environ.PI_CREW_ROLE,
190
+ rssKb: readProcRssKb(pid),
191
+ elapsedSec: stat?.elapsedSec,
192
+ cmd: readProcCmdline(pid),
193
+ };
194
+
195
+ if (entry.parentAlive) {
196
+ result.live.push(entry);
197
+ } else {
198
+ result.zombies.push(entry);
199
+ }
200
+ } catch (error) {
201
+ // Race: process may have exited between readdir and read. Don't abort the scan.
202
+ result.errors.push(`pid ${pid}: ${error instanceof Error ? error.message : String(error)}`);
203
+ }
204
+ }
205
+
206
+ // Sort: zombies first by descending RSS (biggest leaks first), live by pid.
207
+ result.zombies.sort((a, b) => b.rssKb - a.rssKb);
208
+ result.live.sort((a, b) => a.pid - b.pid);
209
+ return result;
210
+ }
211
+
212
+ function tryGetUid(): number | undefined {
213
+ try {
214
+ return process.getuid?.();
215
+ } catch {
216
+ return undefined;
217
+ }
218
+ }
219
+
220
+ function getProcUid(pid: number): number | undefined {
221
+ try {
222
+ // /proc/<pid>/status has Uid: <real> <eff> <sav> <fs>
223
+ const status = fs.readFileSync(`/proc/${pid}/status`, "utf-8");
224
+ const match = status.match(/^Uid:\s+(\d+)/m);
225
+ return match ? Number.parseInt(match[1] ?? "", 10) : undefined;
226
+ } catch {
227
+ return undefined;
228
+ }
229
+ }
230
+
231
+ /**
232
+ * Render a ZombieScanResult as human-readable text for the doctor report.
233
+ * Explicitly labels main-session safety and never suggests killing live parents.
234
+ */
235
+ export function formatZombieReport(scan: ZombieScanResult): string {
236
+ const lines: string[] = [];
237
+ lines.push("## Zombie sub-agent scan (read-only — nothing killed)");
238
+ lines.push("");
239
+ lines.push(
240
+ `Sub-agents identified by PI_CREW_KIND=subagent marker. Main sessions (no marker) are never listed.`,
241
+ );
242
+ lines.push("");
243
+
244
+ if (scan.zombies.length === 0 && scan.live.length === 0) {
245
+ lines.push("No pi-crew sub-agent processes found.");
246
+ if (scan.errors.length > 0) {
247
+ lines.push("");
248
+ lines.push(`Scan notes (${scan.errors.length}):`);
249
+ for (const err of scan.errors.slice(0, 5)) lines.push(` - ${err}`);
250
+ }
251
+ return lines.join("\n");
252
+ }
253
+
254
+ if (scan.zombies.length > 0) {
255
+ lines.push(`### Zombies — parent dead (${scan.zombies.length})`);
256
+ lines.push("These sub-agents are orphaned. Safe to kill after review:");
257
+ lines.push("");
258
+ lines.push(" PID PARENT RSS ROLE CMD");
259
+ for (const z of scan.zombies) {
260
+ lines.push(
261
+ ` ${String(z.pid).padEnd(9)}${String(z.crewParentPid).padEnd(8)}${formatRss(z.rssKb).padEnd(10)}${(z.role ?? "?").padEnd(14)}${z.cmd.slice(0, 60)}`,
262
+ );
263
+ }
264
+ lines.push("");
265
+ }
266
+
267
+ if (scan.live.length > 0) {
268
+ lines.push(`### Live — parent still running (${scan.live.length})`);
269
+ lines.push("NOT zombies. Do not kill (parent PID is alive and may still reap them).");
270
+ lines.push("");
271
+ lines.push(" PID PARENT RSS ROLE CMD");
272
+ for (const l of scan.live) {
273
+ lines.push(
274
+ ` ${String(l.pid).padEnd(9)}${String(l.crewParentPid).padEnd(8)}${formatRss(l.rssKb).padEnd(10)}${(l.role ?? "?").padEnd(14)}${l.cmd.slice(0, 60)}`,
275
+ );
276
+ }
277
+ lines.push("");
278
+ }
279
+
280
+ if (scan.errors.length > 0) {
281
+ lines.push(`Scan errors (${scan.errors.length}, first 5 shown):`);
282
+ for (const err of scan.errors.slice(0, 5)) lines.push(` - ${err}`);
283
+ lines.push("");
284
+ }
285
+
286
+ lines.push("To kill a zombie: `kill <PID>` (the OS will reap it). This tool never kills.");
287
+ return lines.join("\n");
288
+ }
289
+
290
+ function formatRss(kb: number): string {
291
+ if (kb >= 1024 * 1024) return `${(kb / 1024 / 1024).toFixed(1)}G`;
292
+ if (kb >= 1024) return `${(kb / 1024).toFixed(0)}M`;
293
+ return `${kb}K`;
294
+ }
295
+
296
+ // Re-export for tests + callers that want to inspect proc helpers in isolation.
297
+ export const __test = { readProcEnviron, isPidAlive, computeElapsedSec };
@@ -289,6 +289,26 @@ export const TeamToolParams = Type.Object({
289
289
  },
290
290
  ),
291
291
  ),
292
+ tokenBudget: Type.Optional(
293
+ Type.Number({
294
+ description:
295
+ "Per-workflow token budget for dynamic-workflow runs. When set, ctx.agent() auto-rejects with ok:false once exhausted. Accumulated from each agent run's reported usage. Overrides workflow.maxTokenBudget.",
296
+ minimum: 0,
297
+ }),
298
+ ),
299
+ args: Type.Optional(
300
+ // round-14 P1-5: typed workflow arguments. Type.Any() generates an empty {} schema
301
+ // (matches any JSON value) which is strict-provider friendly — no array type union.
302
+ // Description lives in the JSDoc / TeamToolParamsValue below to avoid the
303
+ // "description-only schema" strict-provider check.
304
+ Type.Any(),
305
+ ),
306
+ focus: Type.Optional(
307
+ Type.String({
308
+ description:
309
+ "Sub-focus for the doctor action. 'zombies' runs a READ-ONLY scan for orphaned pi-crew sub-agent processes (identified by PI_CREW_KIND=subagent); it never kills and never matches the user's interactive main session.",
310
+ }),
311
+ ),
292
312
  });
293
313
 
294
314
  export interface TeamToolParamsValue {
@@ -365,6 +385,10 @@ export interface TeamToolParamsValue {
365
385
  skill?: string | string[] | boolean;
366
386
  scope?: "user" | "project" | "both";
367
387
  config?: Record<string, unknown>;
388
+ /** Sub-focus for the `doctor` action. `"zombies"` runs a READ-ONLY scan for
389
+ * orphaned pi-crew sub-agent processes (identified by PI_CREW_KIND=subagent);
390
+ * it never kills and never matches the user's interactive main session. */
391
+ focus?: string;
368
392
  dryRun?: boolean;
369
393
  confirm?: boolean;
370
394
  force?: boolean;
@@ -393,4 +417,8 @@ export interface TeamToolParamsValue {
393
417
  budgetAbort?: number;
394
418
  /** Background dispatch discriminator. Default "team-run". "goal-loop"/"dynamic-workflow" dispatch to their runners (P0/P2). */
395
419
  runKind?: "team-run" | "goal-loop" | "dynamic-workflow";
420
+ /** Per-workflow token budget for dynamic-workflow runs (round-14 P1-2). */
421
+ tokenBudget?: number;
422
+ /** Typed workflow arguments for .dwf.ts scripts, accessible via ctx.args<T>() (round-14 P1-5). */
423
+ args?: unknown;
396
424
  }
@@ -7,6 +7,7 @@ import { fileURLToPath } from "node:url";
7
7
  import { getAgentDir } from "../runtime/peer-dep.ts";
8
8
  import { logInternalError } from "../utils/internal-error.ts";
9
9
  import { isSafePathId, resolveContainedPath, resolveRealContainedPath } from "../utils/safe-paths.ts";
10
+ import { parseSkillFrontmatter, validateSkillFrontmatter, type SkillValidationError } from "./validate.ts";
10
11
 
11
12
  const PACKAGE_SKILLS_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "skills");
12
13
 
@@ -54,16 +55,44 @@ function listSkillDirs(cwd: string): Array<{ root: string; source: SkillDescript
54
55
  ];
55
56
  }
56
57
 
57
- function frontmatterDescription(content: string): string | undefined {
58
- const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(content);
59
- if (!match) return undefined;
60
- const line = match[1].split(/\r?\n/).find((entry) => entry.startsWith("description:"));
61
- return line?.slice("description:".length).trim();
58
+ // ── Diagnostics (L3) ──────────────────────────────────────────────────────────
59
+ // Module-level buffer populated each `discoverSkills()` call. Cleared at the
60
+ // start of every call so callers see only the most recent run's diagnostics.
61
+ // Surfaced via `getLastDiscoveryDiagnostics()` so capability-inventory and
62
+ // other consumers can convert silent exclusions into visible feedback.
63
+ let lastDiagnostics: SkillValidationError[] = [];
64
+
65
+ export function getLastDiscoveryDiagnostics(): SkillValidationError[] {
66
+ return lastDiagnostics;
67
+ }
68
+
69
+ /**
70
+ * Parse frontmatter defensively. Falls back to the legacy line-prefix match
71
+ * if YAML parsing fails — preserves back-compat for malformed but readable
72
+ * SKILL.md files that pre-date the validator (we record a diagnostic in that
73
+ * case but still return the description we could salvage).
74
+ */
75
+ function readDescription(content: string): { description: string; parseError: string | null } {
76
+ const parsed = parseSkillFrontmatter(content);
77
+ if (parsed.ok) {
78
+ const d = parsed.data.description;
79
+ return { description: typeof d === "string" ? d : "", parseError: null };
80
+ }
81
+ // YAML parse failed — fall back to legacy line-prefix match so we don't
82
+ // regress existing skills whose frontmatter the old parser could read.
83
+ const legacyMatch = /^---\r?\n([\s\S]*?)\r?\n---/.exec(content);
84
+ if (legacyMatch) {
85
+ const line = legacyMatch[1].split(/\r?\n/).find((entry) => entry.startsWith("description:"));
86
+ const fallback = line?.slice("description:".length).trim() ?? "";
87
+ return { description: fallback, parseError: parsed.error };
88
+ }
89
+ return { description: "", parseError: parsed.error };
62
90
  }
63
91
 
64
92
  export function discoverSkills(cwd: string): SkillDescriptor[] {
65
93
  if (cache && cache.cwd === cwd && Date.now() - cache.cachedAt < CACHE_TTL_MS) return cache.skills;
66
94
  const results: SkillDescriptor[] = [];
95
+ const diagnostics: SkillValidationError[] = [];
67
96
  for (const dir of listSkillDirs(cwd)) {
68
97
  if (!fs.existsSync(dir.root)) continue;
69
98
  try {
@@ -94,7 +123,16 @@ export function discoverSkills(cwd: string): SkillDescriptor[] {
94
123
  // (e.g. macOS /var → /private/var). Fall through with un-resolved path.
95
124
  }
96
125
  const content = fs.readFileSync(readPath, "utf-8");
97
- description = frontmatterDescription(content) ?? "";
126
+ const { description: desc, parseError } = readDescription(content);
127
+ description = desc;
128
+ if (parseError) {
129
+ diagnostics.push({
130
+ path: path.dirname(skillMdPath),
131
+ field: "frontmatter",
132
+ reason: parseError,
133
+ severity: "error",
134
+ });
135
+ }
98
136
  } catch (error) {
99
137
  logInternalError("discoverSkills.readSkill", error, `skill=${entry.name}`);
100
138
  }
@@ -104,6 +142,21 @@ export function discoverSkills(cwd: string): SkillDescriptor[] {
104
142
  logInternalError("discoverSkills.readdir", error, `root=${dir.root}`);
105
143
  }
106
144
  }
107
- cache = { skills: results, cachedAt: Date.now(), cwd };
108
- return results;
145
+ // L3: strict validation pass after we've collected every (skill, source)
146
+ // candidate. Excludes malformed skills (HYBRID policy: missing/malformed
147
+ // `name`/`description` hard-fail; unknown props warn). Diagnostics are
148
+ // always recorded, including for skills that PASSED validation but had
149
+ // unknown-prop warnings.
150
+ const filtered: SkillDescriptor[] = [];
151
+ for (const skill of results) {
152
+ const validation = validateSkillFrontmatter(path.dirname(skill.path));
153
+ if (validation.ok) {
154
+ filtered.push(skill);
155
+ } else {
156
+ diagnostics.push(...validation.errors);
157
+ }
158
+ }
159
+ lastDiagnostics = diagnostics;
160
+ cache = { skills: filtered, cachedAt: Date.now(), cwd };
161
+ return filtered;
109
162
  }