pi-crew 0.9.62 → 0.9.64

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 CHANGED
@@ -2,6 +2,98 @@
2
2
 
3
3
  > **Note:** `atomic-write-v2.ts` / `AtomicWriter` mentioned in historical entries below was consolidated into `atomic-write.ts` as of v0.9.42. This changelog is preserved as historical record — the migration was completed (the v2 class was never adopted; v1 won on simplicity + symlink-safety + link+unlink atomicity). See `docs/migration/atomic-write-v2-migration.md` for the decision rationale.
4
4
 
5
+ ## [0.9.64] — pi-rlm→pi-crew pattern transfer: worker scratchpad + crash-resume + cancellation + quick wins (2026-08-09)
6
+
7
+ ### Quick Wins (patterns 17/19/20/11 + spike CI)
8
+
9
+ ### Features
10
+ - **Schema-driven docs (QW17)**: `agents/*.md` frontmatter `tools:` now matches the
11
+ enforced `ROLE_TOOL_CONFIGS` (4 drifts fixed); a sync-test pins the derivation;
12
+ `scripts/gen-role-tools-docs.mjs` renders `docs/role-tools.md` from the source.
13
+ - **Retry-resume contract suite (QW19)**: pins `executeWithRetry` (`?`-glob, empty-
14
+ retryableErrors, maxAttempts:0, abort-during-sleep, default attemptId),
15
+ `FileCheckpointStore` (corrupt-file quarantine, list-skip, wrong-runId delete),
16
+ and exports `shouldRecoverTask` for direct testing.
17
+ - **Failure-mode inventory (QW20)**: `docs/failure-mode-inventory.md` maps the 7
18
+ pi-rlm failure modes to pi-crew handlers (wedge gap closed by Phase 1
19
+ ping-before-execute; EPIPE/timeout interplay declared as gaps).
20
+ - **Error-as-data contract (QW11)**: `evidenceStatusFor` + `attemptErrorFor`
21
+ extracted as pure functions from `runChildProcessTask`; contract test pins the
22
+ precedence (cancelled > failed > completed; E007 timedOut override; 429 gated).
23
+ - **`test:spike` script**: wires the scratchpad spike tests into CI.
24
+
25
+
26
+ ### Phase 3 cancellation hardening (experimental)
27
+
28
+ ### Features
29
+
30
+ - **Atomic snapshot write (D2')**: `engine.snapshotState` now writes via
31
+ `temp + rename` (same directory), eliminating the theoretical torn-write race
32
+ between the debounce timer and the F3 quit flush.
33
+ - **Kill-and-restore verified**: the existing SIGTERM → pi print-mode dispose →
34
+ `session_shutdown` reason:"quit" → F3 flush chain already captures a fresh
35
+ snapshot on worker kill (no new handler required). Documented + pinned by a
36
+ gated real-worker test.
37
+ - **EngineBusyError: deliberately skipped** — ping-before-execute already
38
+ detects a wedged guest; the FIFO execute queue makes a busy-reject redundant.
39
+
40
+
41
+ ### Phase 2 crash-resume (experimental)
42
+
43
+ ### Features
44
+
45
+ - **Cross-attempt restore (crash-resume)**: a scratchpad worker attempt N+1 (retry
46
+ / crash-recovery re-queue / manual re-run) automatically revives the namespace
47
+ from the previous attempt's redacted snapshot artifact — via a single
48
+ spawn-time lookup (`findLatestScratchpadSnapshot`), no retry-loop / recovery
49
+ changes needed. Restores lazily on the first `execute` call (D7 lazy invariant
50
+ kept) with a one-line model-visible notice.
51
+ - Lookup: latest mtime (model-fallback index resets each retry round → number
52
+ is not write-order); tie-break lowest attempt = newest round.
53
+ - Security: read-time re-validation (containment + filename pattern + lstat +
54
+ size + mtime hint, D10); fail-open (D11) never breaks the worker; strict scan
55
+ (D12); base64 round-trip (D13); redacted secret → literal `"***"` (D4).
56
+ - **Guest zombie backstop (SEC-1)**: the engine now overrides
57
+ `PI_CREW_PARENT_PID=worker pid` for the guest (D5), so an orphaned guest
58
+ (worker SIGKILL'd) is flagged by the zombie scanner — pure inheritance left
59
+ guests LIVE forever holding provider keys + broker token.
60
+ - **Snapshot cap (D6)**: 4 MiB two-sided (write-side raw byteLength trim, read-
61
+ side file + per-var 256 KiB) bounds v8.deserialize amplification.
62
+
63
+
64
+ ### Phase 1 worker scratchpad (experimental)
65
+
66
+ ### Features
67
+
68
+ - **Worker stateful scratchpad (experimental)**: opt-in persistent Bun-free JS
69
+ evaluator (`execute` tool) for workers, ported from the pi-rlm pattern.
70
+ State (variables, parsed data) compounds across `execute` calls within a task
71
+ attempt — intermediate results live in a namespace instead of being re-derived
72
+ from transcript text. Snapshot per-attempt into the run artifact store
73
+ (redacted + atomic) prepares the ground for Phase 2 crash-resume.
74
+ - Opt-in per role: `executor`, `test-engineer`, `verifier` (default on); other
75
+ roles via agent frontmatter `scratchpad: true` (write roles only).
76
+ - Security: S-6 read-only roles are gated out regardless of frontmatter
77
+ (privilege-elevation guard); F6 `scratchpad: false` is an explicit kill-switch;
78
+ raw snapshots never land in the artifact root (temp dir → redacted
79
+ `writeArtifact`).
80
+ - Dormant by default: only active when `PI_CREW_SCRATCHPAD=1` is set by the
81
+ spawner; zero behavior change for non-opt-in workers.
82
+
83
+
84
+ ## [0.9.63] — built-in performance observability + local-path provider-extension discovery (2026-08-08)
85
+
86
+ ### Features
87
+
88
+ - **Built-in performance observability (byte-built-in, always-on).** Every team run now auto-attaches a detached resource sampler and auto-generates a detailed performance report on completion — no separate benchmark harness needed. Toggle per-team via frontmatter `observability: true|false` (default `true` for parsed team files; direct-object `TeamConfig` fixtures stay unset for test isolation).
89
+ - **Live resource sampler** (`scripts/resource-sampler.mjs`): samples CPU/RSS per-PID every 2s via ppid-tree attribution (root runner + all child workers, including respawns), with PID-reuse guard (`/proc` starttime), first-sample CPU exclusion, and **6 live warning categories** — `high_cpu` (≥300%), `rss_jump` (+200MB/interval), `rss_high` (≥1GB), `rss_leak` (window-30 monotonic +100MB), `proc_died`, `proc_zombie`. Rate-limited (10s/pid/category); `--no-live-warn` flag to silence.
90
+ - **Post-hoc analyzer** (`scripts/analyze-run.mjs`): combines `events.jsonl` + transcripts + `resources.jsonl` into a markdown report at `docs/perf-report-<runId>.md` with **22 anomaly categories** (task_failed, model_retry/model_cascade, large_gap, slow_phase, launch_delay, drain_stall, token_imbalance, no_cache, worker_respawn_churn, api_error_storm, zero_output_completion, sustained_cpu, transient_cpu/rss_spike, rss_growth, run_not_completed, high_failure_rate, run_idle, cost_unreported, missing_transcript, sampler_gap, tool_churn), a per-subagent timeline (launch / respawn / startup / active-work / drain / finalize), and token/cost/model attribution. Optional `--agents` flag emits per-agent breakdowns.
91
+ - **Runtime wiring** (`src/runtime/team-runner.ts`): `startPerfSampler` spawns the sampler detached + `unref`'d (death never affects the run); `schedulePerfAnalyze` runs the analyzer +3s after `after_run_complete` via an `unref`'d `setTimeout` (never blocks run completion). Strict `observability !== true` keeps test fixtures from spawning. The sampler auto-stops when the run manifest reaches a terminal status (`--watch-run` mode).
92
+ - **Overhead ≈ 0** (measured A/B): sampler ~0.05% of one core, 56MB RSS fixed; analyzer ~72ms one-shot after run; ~32KB artifacts/run. Verified end-to-end on real runs — see `docs/real-test/reports/real-test-2026-08-07-perf-obs-overhead.md`.
93
+
94
+ ### Bug fixes
95
+
96
+ - **Local-path provider extensions were not discovered for child workers (oc-go and any `pi install <local-path>` provider went "Model not found").** `discoverProviderExtensions` only resolved `npm:` specs from `~/.pi/agent/settings.json` `packages`, skipping local-path specs (e.g. `../../source/my_pi/source/pi-other-provider`) on the assumption that local paths were the pi-crew extension itself. That assumption was wrong for local provider extensions: `--no-extensions` in `buildPiWorkerArgs` stripped the provider from every child spawn → every model from that provider hit `Error: Model "…" not found` → the fallback chain burned 5 spawn-fails (~10s) per task before landing on a builtin provider. Fix: resolve local-path specs (`./`, `../`, absolute) relative to the settings.json dir — same trust level as `npm:` (settings packages are a sanctioned channel written by `pi install`); skip the pi-crew package itself via `packageRoot()` (a worker must not re-load the orchestrator). **SEC-1 preserved** (project/project-pi AGENT extensions in `.crew/agents/*.md` frontmatter stay gated — that is a separate, untrusted channel). Tests in `test/unit/runtime/model/provider-extensions.test.ts`. Investigation + correction of the earlier "hidden models" mis-attribution in `docs/real-test/reports/real-test-2026-08-08-provider-ext-local-path.md`.
5
97
 
6
98
  ## [0.9.62] — provider-quota attribution per live-session agent + dead-worker alert re-fire fix (2026-08-06)
7
99
 
package/README.md CHANGED
@@ -69,6 +69,7 @@ repo: https://github.com/baphuongna/pi-crew
69
69
  - **Durable event replay** (L1, v0.9.8) — `RunEventBus.onWithReplay()` catches up a re-subscribing dashboard/overlay with events it missed during transient absence (toggle, reconnect), replaying from the durable JSONL log with seq-based dedup. No information loss even if the live subscriber was briefly gone.
70
70
  - **Lossless-by-default output handling** (L4, v0.9.8) — worker output thresholds sized from measured data (100% of real outputs fit without compaction); when compaction is unavoidable it keeps head+tail (preserves closing code fences/headings) instead of head-only truncation. No more `[pi-crew compacted N chars]` markers eating the end of a worker's result.
71
71
  - **Inter-pi broker** (v0.9.47, default-on) — a Unix-domain-socket message bus that lets concurrently-running Pi sessions pass messages, steering notes, and task-status events to each other. **On by default** on Linux + macOS; auto-disabled on native Windows (no unix socket). Three independent kill switches: `broker.enabled: false` (config), `PI_CREW_BROKER=0` (env, always wins), Windows auto-disable. See [docs/decisions/2026-07-22-broker-phase4-gated-on.md](docs/decisions/2026-07-22-broker-phase4-gated-on.md).
72
+ - **Worker stateful scratchpad** (experimental, opt-in per role) — `executor` / `test-engineer` / `verifier` workers get a `scratchpad` tool: a persistent Bun-free JS evaluator whose namespace **compounds across calls within a task attempt** (variables set in one cell are visible in the next), so intermediate results live in memory instead of being re-derived from the transcript. Snapshots are flushed (redacted, atomic) per-attempt into the artifact store; the next attempt (retry / crash-recovery re-queue / re-run) **automatically revives the namespace** from the latest snapshot. Dormant by default (armed only when the spawner sets `PI_CREW_SCRATCHPAD=1`); zero behavior change for non-opt-in workers. Ported from the `@shift-labs/pi-rlm` pattern. See [src/runtime/scratchpad/README.md](src/runtime/scratchpad/README.md) (Phase 1-3 design, env keys, guards, threat model).
72
73
  - **`test:critical` + `real-test-pi-crew` skill** (v0.9.47) — a curated 14-file / 97-test subset (`npm run test:critical`, ~20s) for fast in-loop verification, plus a bundled skill distilling the full 8-tier end-to-end verification discipline (unit → 3-path kill-switch proof → typecheck/bundle → live TUI probing → smoke team run). Prevents the verifier-worker hang that full `npm test` (>4 min) caused against the 300s worker timeout.
73
74
  - **Provider extensions in subagents** (v0.9.57) — pi-crew spawns child-pi workers with `--no-extensions` (security posture), which made extension-registered providers (e.g. `pi-commandcode-provider`) unresolvable inside subagents. pi-crew now **auto-discovers provider packages** from `~/.pi/agent/settings.json` `packages` (npm: specs) and loads them via `--extension` in every builtin/user subagent — so **all provider models work in subagents**. An explicit `runtime.agentExtensions: string[]` config is an optional extra allowlist on top of auto-discovery. **SEC-1 preserved:** project/project-pi agents never receive these (env-gate unchanged).
74
75
 
package/agents/critic.md CHANGED
@@ -5,7 +5,7 @@ model: false
5
5
  systemPromptMode: replace
6
6
  inheritProjectContext: true
7
7
  inheritSkills: false
8
- tools: read, grep, find, ls
8
+ tools: read, grep, find, ls, glob
9
9
  ---
10
10
 
11
11
  You are a critical reviewer. Find flaws, missing steps, unsafe assumptions, overengineering, underengineering, and verification gaps. Return concrete fixes to the plan.
@@ -5,7 +5,7 @@ model: false
5
5
  systemPromptMode: replace
6
6
  inheritProjectContext: true
7
7
  inheritSkills: false
8
- tools: read, grep, find, ls
8
+ tools: read, grep, find, ls, glob, bash
9
9
  ---
10
10
 
11
11
  You are a fast codebase explorer. Map relevant files, symbols, data flow, and constraints. Do not modify files. Return concise findings with paths and evidence.
package/agents/planner.md CHANGED
@@ -5,7 +5,7 @@ model: false
5
5
  systemPromptMode: replace
6
6
  inheritProjectContext: true
7
7
  inheritSkills: false
8
- tools: read, grep, find, ls
8
+ tools: read, grep, find, ls, glob
9
9
  ---
10
10
 
11
11
  You are a planning specialist. Convert the goal and discovery notes into a concrete, ordered plan. Identify dependencies, risks, validation steps, and handoff instructions for implementers.
@@ -5,7 +5,7 @@ model: false
5
5
  systemPromptMode: replace
6
6
  inheritProjectContext: true
7
7
  inheritSkills: false
8
- tools: read, grep, find, ls, bash
8
+ tools: read, grep, find, ls, glob, bash
9
9
  ---
10
10
 
11
11
  You are a code reviewer. Review the implementation for bugs, regressions, maintainability issues, missing tests, and project-rule violations. Return prioritized findings with evidence.
@@ -5,7 +5,7 @@ model: false
5
5
  systemPromptMode: replace
6
6
  inheritProjectContext: true
7
7
  inheritSkills: false
8
- tools: read, grep, find, ls, bash
8
+ tools: read, grep, find
9
9
  ---
10
10
 
11
11
  You are a security reviewer. Look for injection, authn/authz flaws, insecure defaults, secret exposure, unsafe filesystem/network behavior, and dependency risks. Return severity and remediation.
@@ -5,7 +5,7 @@ model: false
5
5
  systemPromptMode: replace
6
6
  inheritProjectContext: true
7
7
  inheritSkills: false
8
- tools: read, grep, find, ls, bash, edit, write
8
+ tools: read, edit, write, bash, ls
9
9
  ---
10
10
 
11
11
  You are a test engineer. Identify the right test level, add or adjust tests when asked, detect flaky assumptions, and report exact validation commands and results.
package/agents/writer.md CHANGED
@@ -5,7 +5,7 @@ model: false
5
5
  systemPromptMode: replace
6
6
  inheritProjectContext: true
7
7
  inheritSkills: false
8
- tools: read, grep, find, ls, edit, write
8
+ tools: read, edit, write, ls
9
9
  ---
10
10
 
11
11
  You are a documentation specialist. Produce clear, concise, maintainable docs and summaries. Preserve technical accuracy and avoid marketing fluff.
package/dist/index.mjs CHANGED
@@ -11602,15 +11602,51 @@ var init_frontmatter = __esm({
11602
11602
  }
11603
11603
  });
11604
11604
 
11605
+ // src/runtime/role-permission.ts
11606
+ function permissionForRole(role) {
11607
+ if (READ_ONLY_ROLES.has(role)) return "read_only";
11608
+ if (WRITE_ROLES.has(role)) return "workspace_write";
11609
+ return "read_only";
11610
+ }
11611
+ function currentCrewRole(env = process.env) {
11612
+ return env.PI_CREW_ROLE?.trim() || env.PI_TEAMS_ROLE?.trim() || void 0;
11613
+ }
11614
+ function checkSubagentSpawnPermission(role) {
11615
+ if (!role) return { allowed: true, mode: "workspace_write" };
11616
+ const mode = permissionForRole(role);
11617
+ if (mode === "read_only")
11618
+ return {
11619
+ allowed: false,
11620
+ mode,
11621
+ reason: `Role '${role}' is read-only and cannot spawn additional subagents.`
11622
+ };
11623
+ return { allowed: true, mode };
11624
+ }
11625
+ var READ_ONLY_ROLES, WRITE_ROLES;
11626
+ var init_role_permission = __esm({
11627
+ "src/runtime/role-permission.ts"() {
11628
+ "use strict";
11629
+ READ_ONLY_ROLES = /* @__PURE__ */ new Set(["explorer", "reviewer", "security-reviewer", "analyst", "critic", "planner"]);
11630
+ WRITE_ROLES = /* @__PURE__ */ new Set(["executor", "test-engineer", "writer", "verifier", "agent", "cold-verifier", "chain-executor", "worker"]);
11631
+ }
11632
+ });
11633
+
11605
11634
  // src/config/role-tools.ts
11606
11635
  function getToolConfig(role) {
11607
11636
  const key = role.includes("_") ? role.replaceAll("_", "-") : role;
11608
11637
  return ROLE_TOOL_CONFIGS[key] ?? ROLE_TOOL_CONFIGS[role] ?? {};
11609
11638
  }
11639
+ function isScratchpadEnabledForRole(role, agent) {
11640
+ const normalized = role.includes("_") ? role.replaceAll("_", "-") : role;
11641
+ if (permissionForRole(normalized) === "read_only") return false;
11642
+ if (agent?.scratchpad === false) return false;
11643
+ return agent?.scratchpad === true || getToolConfig(normalized).scratchpad === true;
11644
+ }
11610
11645
  var ROLE_TOOL_CONFIGS;
11611
11646
  var init_role_tools = __esm({
11612
11647
  "src/config/role-tools.ts"() {
11613
11648
  "use strict";
11649
+ init_role_permission();
11614
11650
  ROLE_TOOL_CONFIGS = {
11615
11651
  // Explorer - Read-only exploration; bash is included for git log/show
11616
11652
  // (decisions stream needs commit-history mining) but edit/write stay
@@ -11641,9 +11677,11 @@ var init_role_tools = __esm({
11641
11677
  tools: ["read", "grep", "find", "ls", "glob"],
11642
11678
  excludeTools: ["edit", "write", "bash", "web"]
11643
11679
  },
11644
- // Executor - Full access (default)
11680
+ // Executor - Full access (default). Phase 1 scratchpad-enabled (stateful
11681
+ // evaluator compounds intermediate results across execute calls).
11645
11682
  executor: {
11646
11683
  // No restrictions - full tool access
11684
+ scratchpad: true
11647
11685
  },
11648
11686
  // Reviewer - Read and review, no write
11649
11687
  reviewer: {
@@ -11670,12 +11708,16 @@ var init_role_tools = __esm({
11670
11708
  // integrity is preserved during verification. Mirrors cold-verifier behavior.
11671
11709
  verifier: {
11672
11710
  tools: ["read", "grep", "find", "ls", "bash"],
11673
- excludeTools: ["edit", "write", "web"]
11711
+ excludeTools: ["edit", "write", "web"],
11712
+ // Phase 1 scratchpad: multi-cell test/verify flows reuse parsed state.
11713
+ scratchpad: true
11674
11714
  },
11675
11715
  // Test Engineer - Can write tests (F1: hyphenated key)
11676
11716
  "test-engineer": {
11677
11717
  tools: ["read", "edit", "write", "bash", "ls"],
11678
- excludeTools: ["web"]
11718
+ excludeTools: ["web"],
11719
+ // Phase 1 scratchpad: build/run test suites with state across cells.
11720
+ scratchpad: true
11679
11721
  }
11680
11722
  };
11681
11723
  }
@@ -11891,6 +11933,14 @@ function parseAgentFile(filePath, source) {
11891
11933
  fallbackModels: parseCsv(frontmatter.fallbackModels),
11892
11934
  thinking: frontmatter.thinking === "false" ? void 0 : frontmatter.thinking || void 0,
11893
11935
  tools: parseToolsField(frontmatter.tools),
11936
+ // Phase 1 scratchpad opt-in (Q3: pi ignores unknown frontmatter keys; pi-crew
11937
+ // is the sole consumer in the worker path — task arrives via -p, agent file
11938
+ // is not re-read by pi). 3-STATE parse (NOT `=== "true"` like
11939
+ // inheritProjectContext): omitted/malformed → undefined so the F6 kill-switch
11940
+ // (`agent.scratchpad === false`) only fires on an EXPLICIT `scratchpad: false`,
11941
+ // not on every agent without the key (which would wrongly kill role
11942
+ // default-on). Only the literal "true"/"false" are honored.
11943
+ scratchpad: frontmatter.scratchpad === "true" ? true : frontmatter.scratchpad === "false" ? false : void 0,
11894
11944
  // SEC-1: Strip extensions/excludeExtensions for untrusted project-sourced
11895
11945
  // agents (RCE prevention). Both `project` (.crew/agents/) and
11896
11946
  // `project-pi` (.pi/agents/) are repo-adjacent / untrusted sources —
@@ -14860,6 +14910,55 @@ var init_env_filter = __esm({
14860
14910
  }
14861
14911
  });
14862
14912
 
14913
+ // src/runtime/scratchpad/snapshot-lookup.ts
14914
+ import { lstatSync as lstatSync5, readdirSync as readdirSync8 } from "node:fs";
14915
+ import { join as join18 } from "node:path";
14916
+ function findLatestScratchpadSnapshot(artifactsRoot, agentId) {
14917
+ const scratchpadDir = join18(artifactsRoot, "scratchpad");
14918
+ let dirStat;
14919
+ try {
14920
+ dirStat = lstatSync5(scratchpadDir);
14921
+ } catch {
14922
+ return null;
14923
+ }
14924
+ if (dirStat.isSymbolicLink() || !dirStat.isDirectory()) return null;
14925
+ let entries;
14926
+ try {
14927
+ entries = readdirSync8(scratchpadDir, { withFileTypes: true });
14928
+ } catch {
14929
+ return null;
14930
+ }
14931
+ const prefix = `${agentId}.attempt-`;
14932
+ let best = null;
14933
+ for (const dirent of entries) {
14934
+ if (dirent.isSymbolicLink() || !dirent.isFile()) continue;
14935
+ const name = dirent.name;
14936
+ if (!name.startsWith(prefix) || !name.endsWith(SNAPSHOT_SUFFIX)) continue;
14937
+ const attemptPart = name.slice(prefix.length, name.length - SNAPSHOT_SUFFIX.length);
14938
+ if (!/^\d+$/.test(attemptPart)) continue;
14939
+ const attempt = Number.parseInt(attemptPart, 10);
14940
+ let stat2;
14941
+ try {
14942
+ stat2 = lstatSync5(join18(scratchpadDir, name));
14943
+ } catch {
14944
+ continue;
14945
+ }
14946
+ if (!stat2.isFile()) continue;
14947
+ const hit = { path: join18(scratchpadDir, name), attempt, mtimeMs: stat2.mtimeMs };
14948
+ if (best === null || hit.mtimeMs > best.mtimeMs || hit.mtimeMs === best.mtimeMs && hit.attempt < best.attempt) {
14949
+ best = hit;
14950
+ }
14951
+ }
14952
+ return best;
14953
+ }
14954
+ var SNAPSHOT_SUFFIX;
14955
+ var init_snapshot_lookup = __esm({
14956
+ "src/runtime/scratchpad/snapshot-lookup.ts"() {
14957
+ "use strict";
14958
+ SNAPSHOT_SUFFIX = ".snapshot.json";
14959
+ }
14960
+ });
14961
+
14863
14962
  // src/runtime/child-pi/child-pi-spawn.ts
14864
14963
  import * as fs18 from "node:fs";
14865
14964
  import * as path18 from "node:path";
@@ -14956,6 +15055,21 @@ function prepareSpawnContext(input, effectiveTask) {
14956
15055
  if (input.runId) built.env.PI_CREW_BROKER_RUN_ID = input.runId;
14957
15056
  if (input.agentId) built.env.PI_CREW_BROKER_TASK_ID = input.agentId;
14958
15057
  }
15058
+ if (input.agentId && isScratchpadEnabledForRole(input.role ?? input.agent.name, input.agent)) {
15059
+ built.env.PI_CREW_SCRATCHPAD = "1";
15060
+ built.env.PI_CREW_TASK_ID = input.agentId;
15061
+ built.env.PI_CREW_ATTEMPT = String(input.attempt ?? 0);
15062
+ if (input.artifactsRoot) {
15063
+ built.env.PI_CREW_ARTIFACTS_ROOT = input.artifactsRoot;
15064
+ }
15065
+ const scratchTempDir = built.tempDir ?? createSafeTempDir(getPiTempBase(), "pi-crew-scratchpad-");
15066
+ built.env.PI_CREW_SCRATCHPAD_SNAPSHOT = resolveRealContainedPath(scratchTempDir, `${input.agentId}.snapshot.json`);
15067
+ const restoreHit = input.artifactsRoot ? findLatestScratchpadSnapshot(input.artifactsRoot, input.agentId) : null;
15068
+ if (restoreHit) {
15069
+ built.env.PI_CREW_SCRATCHPAD_RESTORE = restoreHit.path;
15070
+ built.env.PI_CREW_SCRATCHPAD_RESTORE_MTIME = String(restoreHit.mtimeMs);
15071
+ }
15072
+ }
14959
15073
  if (input.signal?.aborted) {
14960
15074
  return {
14961
15075
  kind: "aborted",
@@ -14983,11 +15097,14 @@ var BASE_ALLOWLIST;
14983
15097
  var init_child_pi_spawn = __esm({
14984
15098
  "src/runtime/child-pi/child-pi-spawn.ts"() {
14985
15099
  "use strict";
15100
+ init_role_tools();
14986
15101
  init_env_allowlist();
14987
15102
  init_env_filter();
14988
15103
  init_internal_error();
15104
+ init_safe_paths();
14989
15105
  init_pi_args();
14990
15106
  init_pi_spawn();
15107
+ init_snapshot_lookup();
14991
15108
  BASE_ALLOWLIST = [
14992
15109
  "PATH",
14993
15110
  "HOME",
@@ -23005,12 +23122,12 @@ var init_crew_hooks = __esm({
23005
23122
 
23006
23123
  // src/runtime/skill-effectiveness.ts
23007
23124
  import { existsSync as existsSync23, mkdirSync as mkdirSync14, readFileSync as readFileSync23, writeFileSync as writeFileSync4 } from "node:fs";
23008
- import { dirname as dirname16, join as join29 } from "node:path";
23125
+ import { dirname as dirname16, join as join30 } from "node:path";
23009
23126
  function getSkillMetricsPath(cwd, runId) {
23010
- return join29(projectCrewRoot(cwd), `state/runs/${runId}/skill-metrics.jsonl`);
23127
+ return join30(projectCrewRoot(cwd), `state/runs/${runId}/skill-metrics.jsonl`);
23011
23128
  }
23012
23129
  function getSkillActivationsPath(cwd, runId) {
23013
- return join29(projectCrewRoot(cwd), `state/runs/${runId}/skill-activations.jsonl`);
23130
+ return join30(projectCrewRoot(cwd), `state/runs/${runId}/skill-activations.jsonl`);
23014
23131
  }
23015
23132
  function ensureSkillMetricsDir(cwd, runId) {
23016
23133
  const dir = dirname16(getSkillMetricsPath(cwd, runId));
@@ -33743,35 +33860,6 @@ var init_live_session_runtime = __esm({
33743
33860
  }
33744
33861
  });
33745
33862
 
33746
- // src/runtime/role-permission.ts
33747
- function permissionForRole(role) {
33748
- if (READ_ONLY_ROLES.has(role)) return "read_only";
33749
- if (WRITE_ROLES.has(role)) return "workspace_write";
33750
- return "read_only";
33751
- }
33752
- function currentCrewRole(env = process.env) {
33753
- return env.PI_CREW_ROLE?.trim() || env.PI_TEAMS_ROLE?.trim() || void 0;
33754
- }
33755
- function checkSubagentSpawnPermission(role) {
33756
- if (!role) return { allowed: true, mode: "workspace_write" };
33757
- const mode = permissionForRole(role);
33758
- if (mode === "read_only")
33759
- return {
33760
- allowed: false,
33761
- mode,
33762
- reason: `Role '${role}' is read-only and cannot spawn additional subagents.`
33763
- };
33764
- return { allowed: true, mode };
33765
- }
33766
- var READ_ONLY_ROLES, WRITE_ROLES;
33767
- var init_role_permission = __esm({
33768
- "src/runtime/role-permission.ts"() {
33769
- "use strict";
33770
- READ_ONLY_ROLES = /* @__PURE__ */ new Set(["explorer", "reviewer", "security-reviewer", "analyst", "critic", "planner"]);
33771
- WRITE_ROLES = /* @__PURE__ */ new Set(["executor", "test-engineer", "writer", "verifier", "agent", "cold-verifier", "chain-executor", "worker"]);
33772
- }
33773
- });
33774
-
33775
33863
  // src/state/coordination/task-claims.ts
33776
33864
  import { randomUUID as randomUUID4, timingSafeEqual as timingSafeEqual2 } from "node:crypto";
33777
33865
  function createTaskClaim(owner, leaseMs = 5 * 6e4, now = /* @__PURE__ */ new Date()) {
@@ -39426,6 +39514,7 @@ function serializeAgent(agent) {
39426
39514
  line("fallbackModels", agent.fallbackModels),
39427
39515
  line("thinking", agent.thinking),
39428
39516
  line("tools", agent.tools),
39517
+ line("scratchpad", agent.scratchpad),
39429
39518
  agent.extensions !== void 0 ? line("extensions", agent.extensions) ?? "extensions:" : void 0,
39430
39519
  line("skills", agent.skills),
39431
39520
  line("systemPromptMode", agent.systemPromptMode),
@@ -40772,9 +40861,9 @@ var init_handle_settings = __esm({
40772
40861
 
40773
40862
  // src/extension/team-tool/workflow-manage.ts
40774
40863
  import { existsSync as existsSync41, readFileSync as readFileSync40, rmSync as rmSync14, writeFileSync as writeFileSync7 } from "node:fs";
40775
- import { dirname as dirname29, join as join45 } from "node:path";
40864
+ import { dirname as dirname29, join as join46 } from "node:path";
40776
40865
  function allowedWorkflowDirs(cwd) {
40777
- return [join45(projectCrewRoot(cwd), "workflows"), join45(userPiRoot(), "workflows"), join45(packageRoot(), "workflows")];
40866
+ return [join46(projectCrewRoot(cwd), "workflows"), join46(userPiRoot(), "workflows"), join46(packageRoot(), "workflows")];
40778
40867
  }
40779
40868
  function validateScriptContent(content) {
40780
40869
  for (const pattern of FORBIDDEN_PATTERNS) {
@@ -40786,7 +40875,7 @@ function validateScriptContent(content) {
40786
40875
  }
40787
40876
  function resolveWorkflowWritePath(cwd, name, scope = "project") {
40788
40877
  assertSafePathId("workflowName", name);
40789
- const base = scope === "user" ? join45(userPiRoot(), "workflows") : join45(projectCrewRoot(cwd), "workflows");
40878
+ const base = scope === "user" ? join46(userPiRoot(), "workflows") : join46(projectCrewRoot(cwd), "workflows");
40790
40879
  return resolveRealContainedPath(base, `${name}.dwf.ts`);
40791
40880
  }
40792
40881
  function handleWorkflowCreate(params, ctx) {
@@ -41640,7 +41729,7 @@ var init_async_runner = __esm({
41640
41729
  });
41641
41730
 
41642
41731
  // src/runtime/goal-workflow/goal-state-store.ts
41643
- import { closeSync as closeSync10, existsSync as existsSync44, mkdirSync as mkdirSync26, openSync as openSync10, readdirSync as readdirSync20, readFileSync as readFileSync42, statSync as statSync33, unlinkSync as unlinkSync7 } from "node:fs";
41732
+ import { closeSync as closeSync10, existsSync as existsSync44, mkdirSync as mkdirSync26, openSync as openSync10, readdirSync as readdirSync21, readFileSync as readFileSync42, statSync as statSync33, unlinkSync as unlinkSync7 } from "node:fs";
41644
41733
  import { dirname as dirname32 } from "node:path";
41645
41734
  function resolveGoalsRoot(cwd) {
41646
41735
  const crewRoot = projectCrewRoot(cwd) ?? userCrewRoot();
@@ -41800,7 +41889,7 @@ var init_goal_state_store = __esm({
41800
41889
  try {
41801
41890
  const root = resolveGoalsRoot(this.cwd);
41802
41891
  if (!existsSync44(root)) return [];
41803
- const entries = readdirSync20(root);
41892
+ const entries = readdirSync21(root);
41804
41893
  const goals = [];
41805
41894
  for (const entry of entries) {
41806
41895
  if (!entry.endsWith(".json")) continue;
@@ -41864,7 +41953,7 @@ var init_verification_integrity = __esm({
41864
41953
 
41865
41954
  // src/runtime/workspace-lock.ts
41866
41955
  import { createHash as createHash7 } from "node:crypto";
41867
- import { closeSync as closeSync11, existsSync as existsSync45, mkdirSync as mkdirSync27, openSync as openSync11, readdirSync as readdirSync21, readFileSync as readFileSync44, statSync as statSync35, unlinkSync as unlinkSync8, writeFileSync as writeFileSync8 } from "node:fs";
41956
+ import { closeSync as closeSync11, existsSync as existsSync45, mkdirSync as mkdirSync27, openSync as openSync11, readdirSync as readdirSync22, readFileSync as readFileSync44, statSync as statSync35, unlinkSync as unlinkSync8, writeFileSync as writeFileSync8 } from "node:fs";
41868
41957
  import * as path50 from "node:path";
41869
41958
  function workspaceLockPath(cwd) {
41870
41959
  const absCwd = path50.resolve(cwd);
@@ -45069,7 +45158,8 @@ __export(crash_recovery_exports, {
45069
45158
  detectInterruptedRuns: () => detectInterruptedRuns,
45070
45159
  purgeStaleActiveRunIndex: () => purgeStaleActiveRunIndex,
45071
45160
  readManifestWithTransientRetry: () => readManifestWithTransientRetry,
45072
- reconcileAllStaleRuns: () => reconcileAllStaleRuns
45161
+ reconcileAllStaleRuns: () => reconcileAllStaleRuns,
45162
+ shouldRecoverTask: () => shouldRecoverTask
45073
45163
  });
45074
45164
  import * as fs63 from "node:fs";
45075
45165
  import * as path52 from "node:path";
@@ -53188,6 +53278,20 @@ function detectRetryableModelFailureFromOutput(parsed) {
53188
53278
  }
53189
53279
  return void 0;
53190
53280
  }
53281
+ function evidenceStatusFor(childResult) {
53282
+ return childResult.exitStatus?.cancelled ? "cancelled" : childResult.error || childResult.exitCode && childResult.exitCode !== 0 ? "failed" : "completed";
53283
+ }
53284
+ function attemptErrorFor(childResult, parsedOutput, taskId) {
53285
+ let err2 = childResult.error || (childResult.exitCode && childResult.exitCode !== 0 ? childResult.stderr || `Child Pi exited with ${childResult.exitCode}` : void 0);
53286
+ if (childResult.exitStatus?.timedOut) {
53287
+ err2 = errors.childTimeout({ taskId, stderr: childResult.stderr }).message;
53288
+ }
53289
+ if (!err2 && parsedOutput) {
53290
+ const rateLimitErr = detectRetryableModelFailureFromOutput(parsedOutput);
53291
+ if (rateLimitErr) err2 = rateLimitErr;
53292
+ }
53293
+ return err2;
53294
+ }
53191
53295
  async function runChildProcessTask(ctx) {
53192
53296
  const input = ctx.input;
53193
53297
  const manifest = ctx.manifest;
@@ -53371,6 +53475,7 @@ async function runChildProcessTask(ctx) {
53371
53475
  runId: manifest.runId,
53372
53476
  agentId: task.id,
53373
53477
  artifactsRoot: manifest.artifactsRoot,
53478
+ attempt: i,
53374
53479
  steeringFile: resolveRealContainedPath(`${manifest.artifactsRoot}/steering`, `${task.id}.jsonl`),
53375
53480
  onSpawn: (pid) => {
53376
53481
  try {
@@ -53459,7 +53564,7 @@ async function runChildProcessTask(ctx) {
53459
53564
  input.signal.removeEventListener("abort", externalAbortListener);
53460
53565
  }
53461
53566
  }
53462
- const evidenceStatus = childResult.exitStatus?.cancelled ? "cancelled" : childResult.error || childResult.exitCode && childResult.exitCode !== 0 ? "failed" : "completed";
53567
+ const evidenceStatus = evidenceStatusFor(childResult);
53463
53568
  terminalEvidence = [
53464
53569
  ...terminalEvidence,
53465
53570
  {
@@ -53505,17 +53610,7 @@ async function runChildProcessTask(ctx) {
53505
53610
  parsedOutput = parsePiJsonOutput(transcriptText2);
53506
53611
  rawFinalText = childResult.rawFinalText;
53507
53612
  intermediateFindings = childResult.intermediateFindings;
53508
- error = childResult.error || (childResult.exitCode && childResult.exitCode !== 0 ? childResult.stderr || `Child Pi exited with ${childResult.exitCode}` : void 0);
53509
- if (childResult.exitStatus?.timedOut) {
53510
- error = errors.childTimeout({
53511
- taskId: task.id,
53512
- stderr: childResult.stderr
53513
- }).message;
53514
- }
53515
- if (!error && parsedOutput) {
53516
- const rateLimitErr = detectRetryableModelFailureFromOutput(parsedOutput);
53517
- if (rateLimitErr) error = rateLimitErr;
53518
- }
53613
+ error = attemptErrorFor(childResult, parsedOutput, task.id);
53519
53614
  persistHeartbeat(true);
53520
53615
  persistChildProgress({ type: "attempt_finished" }, true);
53521
53616
  const attempt = {
@@ -61073,7 +61168,7 @@ __export(dynamic_workflow_runner_exports, {
61073
61168
  runDynamicWorkflow: () => runDynamicWorkflow
61074
61169
  });
61075
61170
  import { readFileSync as readFileSync67 } from "node:fs";
61076
- import { join as join71 } from "node:path";
61171
+ import { join as join72 } from "node:path";
61077
61172
  import { transformSync } from "esbuild";
61078
61173
  function assertStructuredCloneable(value, name) {
61079
61174
  try {
@@ -61085,7 +61180,7 @@ function assertStructuredCloneable(value, name) {
61085
61180
  }
61086
61181
  function resolveScriptPath(workflow, cwd) {
61087
61182
  const crewRoot = projectCrewRoot(cwd);
61088
- const allowedBases = [join71(projectCrewRoot(cwd), "workflows"), join71(userPiRoot(), "workflows"), join71(packageRoot(), "workflows")];
61183
+ const allowedBases = [join72(projectCrewRoot(cwd), "workflows"), join72(userPiRoot(), "workflows"), join72(packageRoot(), "workflows")];
61089
61184
  for (const base of allowedBases) {
61090
61185
  try {
61091
61186
  const real = resolveRealContainedPath(base, workflow.filePath);
@@ -72238,20 +72333,20 @@ init_internal_error();
72238
72333
 
72239
72334
  // src/extension/crew-vibes/config.ts
72240
72335
  import { existsSync as existsSync75, mkdirSync as mkdirSync42, readFileSync as readFileSync74, writeFileSync as writeFileSync9 } from "node:fs";
72241
- import { dirname as dirname39, join as join77 } from "node:path";
72336
+ import { dirname as dirname39, join as join78 } from "node:path";
72242
72337
 
72243
72338
  // src/extension/crew-vibes/font-detect.ts
72244
72339
  import { existsSync as existsSync74, readFileSync as readFileSync73 } from "node:fs";
72245
72340
  import { homedir as homedir11, platform } from "node:os";
72246
- import { join as join76 } from "node:path";
72341
+ import { join as join77 } from "node:path";
72247
72342
  function fontPath() {
72248
72343
  const os18 = platform();
72249
72344
  const home = homedir11();
72250
- if (os18 === "darwin") return join76(home, "Library", "Fonts", "crew-vibes.ttf");
72251
- if (os18 === "linux") return join76(home, ".local", "share", "fonts", "crew-vibes.ttf");
72345
+ if (os18 === "darwin") return join77(home, "Library", "Fonts", "crew-vibes.ttf");
72346
+ if (os18 === "linux") return join77(home, ".local", "share", "fonts", "crew-vibes.ttf");
72252
72347
  if (os18 === "win32") {
72253
- const local = process.env.LOCALAPPDATA ?? join76(home, "AppData", "Local");
72254
- return join76(local, "Microsoft", "Windows", "Fonts", "crew-vibes.ttf");
72348
+ const local = process.env.LOCALAPPDATA ?? join77(home, "AppData", "Local");
72349
+ return join77(local, "Microsoft", "Windows", "Fonts", "crew-vibes.ttf");
72255
72350
  }
72256
72351
  return "";
72257
72352
  }
@@ -72300,7 +72395,7 @@ function resolveHome() {
72300
72395
  return (process.env.PI_TEAMS_HOME ?? process.env.PI_CREW_HOME)?.trim() || process.env.HOME || process.env.USERPROFILE || "";
72301
72396
  }
72302
72397
  function configPath2() {
72303
- return join77(resolveHome(), ".pi", "agent", "pi-crew-vibes.json");
72398
+ return join78(resolveHome(), ".pi", "agent", "pi-crew-vibes.json");
72304
72399
  }
72305
72400
  var DEFAULT_CONFIG2 = {
72306
72401
  enabled: true,
@@ -72800,14 +72895,14 @@ function createCrewVibesFooter(deps) {
72800
72895
  // src/extension/crew-vibes/provider-usage.ts
72801
72896
  import { readFileSync as readFileSync75 } from "node:fs";
72802
72897
  import { homedir as homedir12 } from "node:os";
72803
- import { join as join78 } from "node:path";
72898
+ import { join as join79 } from "node:path";
72804
72899
  function withTimeout(ms, fn) {
72805
72900
  const controller = new AbortController();
72806
72901
  const timeoutId = setTimeout(() => controller.abort(), ms);
72807
72902
  return fn(controller.signal).finally(() => clearTimeout(timeoutId));
72808
72903
  }
72809
72904
  function piAuthPath() {
72810
- return join78(homedir12(), ".pi", "agent", "auth.json");
72905
+ return join79(homedir12(), ".pi", "agent", "auth.json");
72811
72906
  }
72812
72907
  function loadAnthropicToken() {
72813
72908
  const envToken = process.env.ANTHROPIC_OAUTH_TOKEN?.trim();
@@ -72852,8 +72947,8 @@ function tokenFromHostEntry(entry) {
72852
72947
  return void 0;
72853
72948
  }
72854
72949
  function loadLegacyCopilotToken() {
72855
- const configHome = process.env.XDG_CONFIG_HOME?.trim() || join78(homedir12(), ".config");
72856
- const candidates = [join78(configHome, "github-copilot", "hosts.json"), join78(homedir12(), ".github-copilot", "hosts.json")];
72950
+ const configHome = process.env.XDG_CONFIG_HOME?.trim() || join79(homedir12(), ".config");
72951
+ const candidates = [join79(configHome, "github-copilot", "hosts.json"), join79(homedir12(), ".github-copilot", "hosts.json")];
72857
72952
  for (const hostsPath of candidates) {
72858
72953
  try {
72859
72954
  const data = JSON.parse(readFileSync75(hostsPath, "utf8"));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-crew",
3
- "version": "0.9.62",
3
+ "version": "0.9.64",
4
4
  "description": "Pi extension for coordinated AI teams, workflows, worktrees, and async task orchestration",
5
5
  "author": "baphuongna",
6
6
  "license": "MIT",
@@ -61,7 +61,9 @@
61
61
  "types/",
62
62
  "CHANGELOG.md",
63
63
  "LICENSE",
64
- "NOTICE.md"
64
+ "NOTICE.md",
65
+ "scripts/resource-sampler.mjs",
66
+ "scripts/analyze-run.mjs"
65
67
  ],
66
68
  "scripts": {
67
69
  "check": "npm run ci",
@@ -79,6 +81,7 @@
79
81
  "test:watch": "tsx --watch --test --test-concurrency=4 --test-timeout=30000 --test-force-exit 'test/unit/**/*.test.ts'",
80
82
  "test:integration": "node scripts/test-runner.mjs --test-concurrency=1 --test-timeout=300000 test/integration/*.test.ts",
81
83
  "test:smoke": "node scripts/test-runner.mjs --test-concurrency=1 --test-timeout=180000 test/smoke/*.smoke.ts",
84
+ "test:spike": "node scripts/test-runner.mjs --test-concurrency=2 --test-timeout=120000 --test-force-exit test/runtime/scratchpad/*.test.ts",
82
85
  "build:bundle": "node scripts/build-bundle.mjs",
83
86
  "test:bundle": "node --experimental-strip-types --test --test-force-exit test/unit/bundle-load.test.ts",
84
87
  "watch:bundle": "node scripts/watch-bundle.mjs",