pi-crew 0.9.61 → 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 +99 -0
- package/README.md +1 -0
- package/agents/critic.md +1 -1
- package/agents/explorer.md +1 -1
- package/agents/planner.md +1 -1
- package/agents/reviewer.md +1 -1
- package/agents/security-reviewer.md +1 -1
- package/agents/test-engineer.md +1 -1
- package/agents/writer.md +1 -1
- package/dist/index.mjs +256 -81
- package/package.json +5 -2
- package/src/agents/agent-config.ts +4 -0
- package/src/agents/agent-serializer.ts +1 -0
- package/src/agents/discover-agents.ts +8 -0
- package/src/config/role-tools.ts +49 -1
- package/src/extension/notification-router.ts +25 -0
- package/src/extension/registration/lifecycle-handlers.ts +47 -6
- package/src/extension/registration/lifecycle.ts +13 -7
- package/src/prompt/prompt-runtime.ts +6 -0
- package/src/prompt/scratchpad-lifecycle.ts +605 -0
- package/src/runtime/child-pi/child-pi-spawn.ts +42 -1
- package/src/runtime/child-pi/child-pi.ts +5 -0
- package/src/runtime/live-session/live-session-runtime.ts +12 -1
- package/src/runtime/model/pi-args.ts +1 -1
- package/src/runtime/model/session-model.ts +76 -1
- package/src/runtime/recovery/crash-recovery.ts +1 -1
- package/src/runtime/scratchpad/README.md +184 -0
- package/src/runtime/scratchpad/engine.ts +610 -0
- package/src/runtime/scratchpad/guest.ts +360 -0
- package/src/runtime/scratchpad/index.ts +22 -0
- package/src/runtime/scratchpad/protocol.ts +88 -0
- package/src/runtime/scratchpad/snapshot-lookup.ts +74 -0
- package/src/runtime/scratchpad/transform.ts +363 -0
- package/src/runtime/task-runner/child-executor.ts +48 -31
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-crew",
|
|
3
|
-
"version": "0.9.
|
|
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",
|
|
@@ -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 —
|
package/src/config/role-tools.ts
CHANGED
|
@@ -3,11 +3,17 @@
|
|
|
3
3
|
* Uses the excludeTools option from pi v0.77.0.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import { permissionForRole } from "../runtime/role-permission.ts";
|
|
7
|
+
|
|
6
8
|
export interface RoleToolConfig {
|
|
7
9
|
/** Explicit list of tools to use (if undefined, use all default tools) */
|
|
8
10
|
tools?: string[];
|
|
9
11
|
/** Tools to exclude from the default set */
|
|
10
12
|
excludeTools?: string[];
|
|
13
|
+
/** Phase 1 scratchpad: opt-in the persistent Bun-free JS evaluator (execute tool)
|
|
14
|
+
* for this role. Only effective on WRITE roles — read-only roles are gated
|
|
15
|
+
* out by `isScratchpadEnabledForRole` (S-6, privilege-elevation guard). */
|
|
16
|
+
scratchpad?: boolean;
|
|
11
17
|
}
|
|
12
18
|
|
|
13
19
|
export const ROLE_TOOL_CONFIGS: Record<string, RoleToolConfig> = {
|
|
@@ -44,9 +50,11 @@ export const ROLE_TOOL_CONFIGS: Record<string, RoleToolConfig> = {
|
|
|
44
50
|
excludeTools: ["edit", "write", "bash", "web"],
|
|
45
51
|
},
|
|
46
52
|
|
|
47
|
-
// Executor - Full access (default)
|
|
53
|
+
// Executor - Full access (default). Phase 1 scratchpad-enabled (stateful
|
|
54
|
+
// evaluator compounds intermediate results across execute calls).
|
|
48
55
|
executor: {
|
|
49
56
|
// No restrictions - full tool access
|
|
57
|
+
scratchpad: true,
|
|
50
58
|
},
|
|
51
59
|
|
|
52
60
|
// Reviewer - Read and review, no write
|
|
@@ -78,12 +86,16 @@ export const ROLE_TOOL_CONFIGS: Record<string, RoleToolConfig> = {
|
|
|
78
86
|
verifier: {
|
|
79
87
|
tools: ["read", "grep", "find", "ls", "bash"],
|
|
80
88
|
excludeTools: ["edit", "write", "web"],
|
|
89
|
+
// Phase 1 scratchpad: multi-cell test/verify flows reuse parsed state.
|
|
90
|
+
scratchpad: true,
|
|
81
91
|
},
|
|
82
92
|
|
|
83
93
|
// Test Engineer - Can write tests (F1: hyphenated key)
|
|
84
94
|
"test-engineer": {
|
|
85
95
|
tools: ["read", "edit", "write", "bash", "ls"],
|
|
86
96
|
excludeTools: ["web"],
|
|
97
|
+
// Phase 1 scratchpad: build/run test suites with state across cells.
|
|
98
|
+
scratchpad: true,
|
|
87
99
|
},
|
|
88
100
|
};
|
|
89
101
|
|
|
@@ -116,3 +128,39 @@ export function getRestrictedRoles(): string[] {
|
|
|
116
128
|
.filter(([, config]) => config.tools !== undefined || config.excludeTools !== undefined)
|
|
117
129
|
.map(([role]) => role);
|
|
118
130
|
}
|
|
131
|
+
|
|
132
|
+
/** Agent frontmatter shape consumed by the scratchpad opt-in check. */
|
|
133
|
+
export interface ScratchpadAgentOption {
|
|
134
|
+
scratchpad?: boolean;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Phase 1: is the scratchpad (execute tool) enabled for this role/agent?
|
|
139
|
+
*
|
|
140
|
+
* Decision order (load-bearing — do not reorder):
|
|
141
|
+
* 1. S-6 (SECURITY, privilege-elevation guard): read-only roles NEVER enable,
|
|
142
|
+
* regardless of any agent frontmatter opt-in. A `scratchpad: true` on a
|
|
143
|
+
* read-only role (e.g. security-reviewer, whose tool-set is read/grep/find
|
|
144
|
+
* with NO bash) would grant full-trust JS execution (fs/network/env/
|
|
145
|
+
* child_process) inside a role designed to have no execution — a real
|
|
146
|
+
* elevation. `permissionForRole` is default-deny (unknown → read_only), so
|
|
147
|
+
* typos/underscore/case-drift cannot bypass this.
|
|
148
|
+
* 2. F6 (kill-switch): `agent.scratchpad === false` WINS over the role default,
|
|
149
|
+
* giving an emergency off-switch for an experimental full-trust feature.
|
|
150
|
+
* 3. agent explicit opt-in (`scratchpad: true`) OR role default (`scratchpad:
|
|
151
|
+
* true` in ROLE_TOOL_CONFIGS).
|
|
152
|
+
*
|
|
153
|
+
* F10: `permissionForRole` and `getToolConfig` both expect HYPHENATED role
|
|
154
|
+
* strings (runtime convention); a raw underscore role would default-deny
|
|
155
|
+
* wrongly, so normalize FIRST.
|
|
156
|
+
*/
|
|
157
|
+
export function isScratchpadEnabledForRole(role: string, agent?: ScratchpadAgentOption): boolean {
|
|
158
|
+
// F10: normalize underscore→hyphen before every downstream lookup.
|
|
159
|
+
const normalized = role.includes("_") ? role.replaceAll("_", "-") : role;
|
|
160
|
+
// S-6 (điều kiện tiên quyết): read-only gate trước mọi agent flag.
|
|
161
|
+
if (permissionForRole(normalized) === "read_only") return false;
|
|
162
|
+
// F6: explicit-false agent override (kill-switch) wins over role default.
|
|
163
|
+
if (agent?.scratchpad === false) return false;
|
|
164
|
+
// agent explicit opt-in OR role default.
|
|
165
|
+
return agent?.scratchpad === true || getToolConfig(normalized).scratchpad === true;
|
|
166
|
+
}
|
|
@@ -10,6 +10,8 @@ export interface NotificationDescriptor {
|
|
|
10
10
|
title: string;
|
|
11
11
|
body?: string;
|
|
12
12
|
timestamp?: number;
|
|
13
|
+
/** When true, this is a dismiss/clear request for a previously-emitted notification (same id). Bypasses dedup/severity/quiet-hours; the deliver callback drops the matching notification. */
|
|
14
|
+
clear?: boolean;
|
|
13
15
|
}
|
|
14
16
|
|
|
15
17
|
export interface NotificationRouterOptions {
|
|
@@ -99,6 +101,29 @@ export class NotificationRouter {
|
|
|
99
101
|
...notification,
|
|
100
102
|
timestamp: notification.timestamp ?? now,
|
|
101
103
|
};
|
|
104
|
+
// Clear path: remove the id from `seen` so a future genuine re-occurrence
|
|
105
|
+
// can re-notify. Delivery is guarded on whether the id was actually in
|
|
106
|
+
// `seen` to keep the clear idempotent (bypassing dedup / severity filter /
|
|
107
|
+
// quiet hours when it does deliver — a clear must go through so the
|
|
108
|
+
// dashboard can drop the previously-emitted notification).
|
|
109
|
+
if (withTime.clear) {
|
|
110
|
+
const clearKey = notificationKey(withTime);
|
|
111
|
+
// Idempotent: only deliver the clear if the id was actually in `seen`
|
|
112
|
+
// (i.e., a notification for it was previously emitted and not yet
|
|
113
|
+
// cleared). This prevents multi-fire drift when the render loop calls
|
|
114
|
+
// clearHealthNotifications on consecutive ticks for the same terminal
|
|
115
|
+
// run — each clear after the first is a silent no-op that returns true.
|
|
116
|
+
const wasInSeen = this.seen.delete(clearKey);
|
|
117
|
+
if (wasInSeen) {
|
|
118
|
+
try {
|
|
119
|
+
this.opts.sink?.(withTime);
|
|
120
|
+
} catch (sinkError) {
|
|
121
|
+
logInternalError("notification-sink", sinkError);
|
|
122
|
+
}
|
|
123
|
+
this.deliver(withTime);
|
|
124
|
+
}
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
102
127
|
try {
|
|
103
128
|
this.opts.sink?.(withTime);
|
|
104
129
|
} catch (sinkError) {
|
|
@@ -25,10 +25,9 @@ import { CrewBroker } from "../../runtime/broker/crew-broker.ts";
|
|
|
25
25
|
import { terminateActiveChildPiProcesses } from "../../runtime/child-pi/child-pi.ts";
|
|
26
26
|
import { listLiveAgents } from "../../runtime/live-session/live-agent-manager.ts";
|
|
27
27
|
import type { createManifestCache } from "../../runtime/manifest-cache.ts";
|
|
28
|
-
import { providerOfModelRef } from "../../runtime/model/model-fallback.ts";
|
|
29
28
|
import { cleanupLegacyOrphanTempDirs, cleanupOrphanTempDirs, currentCrewDepth } from "../../runtime/model/pi-args.ts";
|
|
30
29
|
import { clearProviderQuotaCache, noteProviderResponse } from "../../runtime/model/provider-quota.ts";
|
|
31
|
-
import {
|
|
30
|
+
import { noteSessionModel, noteSessionThinking, resolveProviderForResponse } from "../../runtime/model/session-model.ts";
|
|
32
31
|
import { cleanupOrphanWorkers } from "../../runtime/orphan-worker-registry.ts";
|
|
33
32
|
import { reconcileAllStaleRuns } from "../../runtime/recovery/crash-recovery.ts";
|
|
34
33
|
import { CrewScheduler, type ScheduledJob } from "../../runtime/scheduling/scheduler.ts";
|
|
@@ -93,8 +92,7 @@ function installModelTrackingHandlers(pi: ExtensionAPI): void {
|
|
|
93
92
|
// providers. The event doesn't carry a provider field, so we attribute it
|
|
94
93
|
// to the currently tracked session model's provider.
|
|
95
94
|
pi.on("after_provider_response", (event) => {
|
|
96
|
-
const
|
|
97
|
-
const provider = model ? providerOfModelRef(model) : undefined;
|
|
95
|
+
const provider = resolveProviderForResponse();
|
|
98
96
|
if (provider) noteProviderResponse(provider, event.status, event.headers);
|
|
99
97
|
});
|
|
100
98
|
}
|
|
@@ -666,12 +664,55 @@ function setupRenderLoop(
|
|
|
666
664
|
const currentSessionId = ctx.currentCtx?.sessionManager?.getSessionId();
|
|
667
665
|
const sessionManifests = filterManifestsForHealthNotifications(manifests, currentSessionId);
|
|
668
666
|
const now = Date.now();
|
|
667
|
+
// FIX #2: clear path — when a run is detected terminal, dismiss any
|
|
668
|
+
// previously-emitted health notification for it AND drop its cooldown
|
|
669
|
+
// (autoRecoveryLast) so a future genuine re-occurrence can re-notify.
|
|
670
|
+
// Keeps the dashboard clean and stops the 5-min re-fire cycle.
|
|
671
|
+
const clearHealthNotifications = (runId: string): void => {
|
|
672
|
+
for (const kind of ["recovery_dead_workers", "recovery_missing_heartbeat"]) {
|
|
673
|
+
const key = `${kind}_${runId}`;
|
|
674
|
+
ctx.autoRecoveryLast.delete(key);
|
|
675
|
+
ctx.notifyOperator({
|
|
676
|
+
id: key,
|
|
677
|
+
clear: true,
|
|
678
|
+
severity: "info",
|
|
679
|
+
source: "health",
|
|
680
|
+
runId,
|
|
681
|
+
title: `Cleared ${kind} for ${runId}`,
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
};
|
|
669
685
|
for (const run of sessionManifests) {
|
|
670
|
-
if (run.status !== "running")
|
|
686
|
+
if (run.status !== "running") {
|
|
687
|
+
// GATE 1 — preloaded manifest says terminal. Purge any stale snapshot
|
|
688
|
+
// and clear previously-emitted health notifications so the dashboard
|
|
689
|
+
// stays clean (belt-and-suspenders with the FIX #1 fresh-read gate).
|
|
690
|
+
snapshotCache.invalidate(run.runId);
|
|
691
|
+
clearHealthNotifications(run.runId);
|
|
692
|
+
continue;
|
|
693
|
+
}
|
|
671
694
|
try {
|
|
695
|
+
// FIX #1: re-verify against a FRESH manifest read. The preloaded `run`
|
|
696
|
+
// (from lastPreloadedManifests) can lag the on-disk terminal
|
|
697
|
+
// transition; the manifest cache has a 500ms TTL + file watcher so it
|
|
698
|
+
// is the source of truth. A terminal run must NEVER reach
|
|
699
|
+
// maybeNotifyHealth. Also purge the stale snapshot + clear any
|
|
700
|
+
// previously-emitted health notification for this run.
|
|
701
|
+
const freshManifest = ctx.getManifestCache(extensionCtx.cwd).get(run.runId);
|
|
702
|
+
if (freshManifest?.status !== "running") {
|
|
703
|
+
snapshotCache.invalidate(run.runId);
|
|
704
|
+
clearHealthNotifications(run.runId);
|
|
705
|
+
continue;
|
|
706
|
+
}
|
|
672
707
|
const snapshot = snapshotCache.get(run.runId);
|
|
673
708
|
if (!snapshot) continue;
|
|
674
|
-
if (snapshot.manifest.status !== "running")
|
|
709
|
+
if (snapshot.manifest.status !== "running") {
|
|
710
|
+
// GATE 2 — a running snapshot paired with a now-terminal manifest is
|
|
711
|
+
// stale. Purge it so subsequent ticks get a fresh view, and clear.
|
|
712
|
+
snapshotCache.invalidate(run.runId);
|
|
713
|
+
clearHealthNotifications(run.runId);
|
|
714
|
+
continue;
|
|
715
|
+
}
|
|
675
716
|
const summary = summarizeHeartbeats(snapshot, { now });
|
|
676
717
|
const maybeNotifyHealth = (kind: string, count: number, title: string, body: string): void => {
|
|
677
718
|
if (count <= 0) return;
|
|
@@ -148,13 +148,19 @@ export async function configureNotifications(ctx: ExtensionContext, state: Lifec
|
|
|
148
148
|
sink: (notification) => state.notificationSink?.write(notification),
|
|
149
149
|
},
|
|
150
150
|
(notification: NotificationDescriptor) => {
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
151
|
+
if (notification.clear) {
|
|
152
|
+
// Clear/dismiss: decrement the counter (floor 0) and skip the
|
|
153
|
+
// follow-up message — this is a dismissal, not a new alert.
|
|
154
|
+
deps.widgetState.notificationCount = Math.max(0, (deps.widgetState.notificationCount ?? 0) - 1);
|
|
155
|
+
} else {
|
|
156
|
+
deps.widgetState.notificationCount = (deps.widgetState.notificationCount ?? 0) + 1;
|
|
157
|
+
sendFollowUp(
|
|
158
|
+
deps.pi,
|
|
159
|
+
[notification.title, notification.body, notification.runId ? `Run: ${notification.runId}` : undefined]
|
|
160
|
+
.filter((line): line is string => Boolean(line))
|
|
161
|
+
.join("\n"),
|
|
162
|
+
);
|
|
163
|
+
}
|
|
158
164
|
const currentCtx = deps.getCurrentCtx();
|
|
159
165
|
if (currentCtx) {
|
|
160
166
|
const uiConfig = loadConfig(currentCtx.cwd).config.ui;
|
|
@@ -4,6 +4,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
|
4
4
|
import { startChildBrokerClient } from "../runtime/broker/crew-broker-child.ts";
|
|
5
5
|
import { logInternalError } from "../utils/internal-error.ts";
|
|
6
6
|
import { resolveRealContainedPath } from "../utils/safe-paths.ts";
|
|
7
|
+
import { registerScratchpadLifecycle } from "./scratchpad-lifecycle.ts";
|
|
7
8
|
|
|
8
9
|
export const PI_TEAMS_INHERIT_PROJECT_CONTEXT_ENV = "PI_TEAMS_INHERIT_PROJECT_CONTEXT";
|
|
9
10
|
export const PI_TEAMS_INHERIT_SKILLS_ENV = "PI_TEAMS_INHERIT_SKILLS";
|
|
@@ -377,4 +378,9 @@ export default function registerPiTeamsPromptRuntime(pi: ExtensionAPI): void {
|
|
|
377
378
|
if (rewritten === event.systemPrompt) return;
|
|
378
379
|
return { systemPrompt: rewritten };
|
|
379
380
|
});
|
|
381
|
+
|
|
382
|
+
// ── Phase 1 scratchpad (T6): conditional `execute` tool + EngineManager
|
|
383
|
+
// lifecycle. Registers nothing when PI_CREW_SCRATCHPAD !== "1" (D3) and
|
|
384
|
+
// only flushes+kills the engine on session_shutdown reason "quit" (F3).
|
|
385
|
+
registerScratchpadLifecycle(pi);
|
|
380
386
|
}
|