pi-crew 0.5.2 → 0.5.6
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 +183 -0
- package/README.md +17 -1
- package/docs/architecture.md +2 -0
- package/docs/bugs/cross-session-notification-leakage.md +82 -0
- package/docs/coding-agent-optimization.md +268 -0
- package/docs/deep-review-report.md +384 -0
- package/docs/distillation/cybersecurity-patterns.md +294 -0
- package/docs/migration-v0.4-v0.5.md +208 -0
- package/docs/optimization-plan.md +642 -0
- package/docs/pi-crew-v0.5.5-audit-fix-plan.md +133 -0
- package/docs/pi-mono-opportunities.md +969 -0
- package/docs/pi-mono-review.md +291 -0
- package/docs/skills/REFERENCE.md +144 -0
- package/package.json +12 -9
- package/skills/artifact-analysis-loop/SKILL.md +302 -0
- package/skills/async-worker-recovery/SKILL.md +19 -1
- package/skills/child-pi-spawning/SKILL.md +19 -6
- package/skills/context-artifact-hygiene/SKILL.md +19 -2
- package/skills/delegation-patterns/SKILL.md +68 -3
- package/skills/detection-pipeline-design/SKILL.md +285 -0
- package/skills/event-log-tracing/SKILL.md +20 -6
- package/skills/git-master/SKILL.md +20 -6
- package/skills/hunting-investigation-loop/SKILL.md +401 -0
- package/skills/incident-playbook-construction/SKILL.md +383 -0
- package/skills/live-agent-lifecycle/SKILL.md +20 -6
- package/skills/mailbox-interactive/SKILL.md +19 -6
- package/skills/model-routing-context/SKILL.md +19 -1
- package/skills/multi-perspective-review/SKILL.md +19 -4
- package/skills/observability-reliability/SKILL.md +19 -2
- package/skills/orchestration/SKILL.md +20 -2
- package/skills/ownership-session-security/SKILL.md +20 -2
- package/skills/pi-extension-lifecycle/SKILL.md +20 -2
- package/skills/post-mortem/SKILL.md +7 -2
- package/skills/read-only-explorer/SKILL.md +20 -6
- package/skills/requirements-to-task-packet/SKILL.md +23 -3
- package/skills/resource-discovery-config/SKILL.md +20 -2
- package/skills/runtime-state-reader/SKILL.md +20 -2
- package/skills/safe-bash/SKILL.md +21 -6
- package/skills/scrutinize/SKILL.md +20 -2
- package/skills/secure-agent-orchestration-review/SKILL.md +29 -2
- package/skills/security-review/SKILL.md +560 -0
- package/skills/state-mutation-locking/SKILL.md +22 -2
- package/skills/systematic-debugging/SKILL.md +8 -6
- package/skills/threat-hypothesis-framework/SKILL.md +175 -0
- package/skills/ui-render-performance/SKILL.md +20 -2
- package/skills/verification-before-done/SKILL.md +17 -2
- package/skills/widget-rendering/SKILL.md +21 -6
- package/skills/workspace-isolation/SKILL.md +20 -6
- package/skills/worktree-isolation/SKILL.md +20 -6
- package/src/agents/agent-config.ts +40 -1
- package/src/benchmark/benchmark-runner.ts +45 -0
- package/src/benchmark/feedback-loop.ts +5 -0
- package/src/config/config.ts +32 -5
- package/src/config/role-tools.ts +82 -0
- package/src/config/suggestions.ts +8 -0
- package/src/config/types.ts +4 -0
- package/src/extension/async-notifier.ts +10 -1
- package/src/extension/crew-cleanup.ts +114 -0
- package/src/extension/cross-extension-rpc.ts +1 -1
- package/src/extension/notification-router.ts +18 -0
- package/src/extension/register.ts +27 -19
- package/src/extension/registration/subagent-tools.ts +1 -1
- package/src/extension/team-tool/anchor.ts +201 -0
- package/src/extension/team-tool/api.ts +2 -1
- package/src/extension/team-tool/auto-summarize.ts +154 -0
- package/src/extension/team-tool/run.ts +42 -7
- package/src/extension/team-tool.ts +44 -2
- package/src/hooks/registry.ts +1 -3
- package/src/observability/event-bus.ts +69 -0
- package/src/observability/event-to-metric.ts +0 -2
- package/src/runtime/anchor-manager.ts +473 -0
- package/src/runtime/async-runner.ts +8 -4
- package/src/runtime/auto-summarize.ts +350 -0
- package/src/runtime/background-runner.ts +10 -3
- package/src/runtime/budget-tracker.ts +354 -0
- package/src/runtime/chain-runner.ts +507 -0
- package/src/runtime/child-pi.ts +123 -35
- package/src/runtime/crash-recovery.ts +5 -4
- package/src/runtime/crew-agent-runtime.ts +1 -0
- package/src/runtime/custom-tools/irc-tool.ts +13 -0
- package/src/runtime/custom-tools/submit-result-tool.ts +3 -2
- package/src/runtime/delivery-coordinator.ts +10 -3
- package/src/runtime/dynamic-script-runner.ts +482 -0
- package/src/runtime/foreground-control.ts +87 -17
- package/src/runtime/handoff-manager.ts +589 -0
- package/src/runtime/hidden-handoff.ts +424 -0
- package/src/runtime/live-agent-manager.ts +20 -4
- package/src/runtime/live-session-runtime.ts +39 -4
- package/src/runtime/manifest-cache.ts +2 -1
- package/src/runtime/model-resolver.ts +16 -4
- package/src/runtime/phase-tracker.ts +373 -0
- package/src/runtime/pi-args.ts +11 -1
- package/src/runtime/pi-json-output.ts +31 -0
- package/src/runtime/pipeline-runner.ts +514 -0
- package/src/runtime/progress-tracker.ts +124 -0
- package/src/runtime/retry-runner.ts +354 -0
- package/src/runtime/sandbox.ts +252 -0
- package/src/runtime/scheduler.ts +7 -2
- package/src/runtime/skill-effectiveness.ts +473 -0
- package/src/runtime/skill-instructions.ts +37 -3
- package/src/runtime/subagent-manager.ts +1 -1
- package/src/runtime/task-graph.ts +11 -1
- package/src/runtime/task-runner.ts +92 -18
- package/src/runtime/team-runner.ts +13 -12
- package/src/runtime/tool-progress.ts +10 -3
- package/src/runtime/verification-gates.ts +367 -0
- package/src/schema/team-tool-schema.ts +37 -0
- package/src/skills/discover-skills.ts +5 -0
- package/src/state/active-run-registry.ts +9 -2
- package/src/state/contracts.ts +9 -0
- package/src/state/crew-init.ts +3 -3
- package/src/state/decision-ledger.ts +98 -55
- package/src/state/event-log-rotation.ts +2 -2
- package/src/state/event-log.ts +144 -10
- package/src/state/hook-instinct-bridge.ts +5 -5
- package/src/state/mailbox.ts +10 -0
- package/src/state/run-cache.ts +18 -8
- package/src/state/state-store.ts +3 -1
- package/src/state/types.ts +4 -0
- package/src/tools/safe-bash-extension.ts +1 -0
- package/src/tools/safe-bash.ts +152 -20
- package/src/types/new-api-types.ts +34 -0
- package/src/ui/agent-management-overlay.ts +5 -1
- package/src/ui/crew-widget.ts +29 -15
- package/src/ui/overlays/mailbox-detail-overlay.ts +13 -2
- package/src/ui/powerbar-publisher.ts +101 -7
- package/src/ui/tool-render.ts +15 -15
- package/src/ui/transcript-cache.ts +13 -0
- package/src/utils/bm25-search.ts +16 -8
- package/src/utils/env-filter.ts +8 -5
- package/src/utils/redaction.ts +169 -15
- package/src/utils/session-utils.ts +52 -0
- package/src/utils/sse-parser.ts +10 -1
- package/src/worktree/cleanup.ts +6 -1
- package/src/worktree/worktree-manager.ts +32 -13
- package/workflows/chain.workflow.md +252 -0
- package/workflows/pipeline.workflow.md +27 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Role-based tool configurations for pi-crew agents.
|
|
3
|
+
* Uses the excludeTools option from pi v0.77.0.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export interface RoleToolConfig {
|
|
7
|
+
/** Explicit list of tools to use (if undefined, use all default tools) */
|
|
8
|
+
tools?: string[];
|
|
9
|
+
/** Tools to exclude from the default set */
|
|
10
|
+
excludeTools?: string[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const ROLE_TOOL_CONFIGS: Record<string, RoleToolConfig> = {
|
|
14
|
+
// Explorer - Read-only, no write or execute
|
|
15
|
+
explorer: {
|
|
16
|
+
tools: ["read", "grep", "find", "ls", "glob"],
|
|
17
|
+
excludeTools: ["edit", "write", "bash", "web"],
|
|
18
|
+
},
|
|
19
|
+
|
|
20
|
+
// Analyst - Read and analyze, limited execution
|
|
21
|
+
analyst: {
|
|
22
|
+
excludeTools: ["edit", "write", "ask_question"],
|
|
23
|
+
},
|
|
24
|
+
|
|
25
|
+
// Planner - Planning and documentation
|
|
26
|
+
planner: {
|
|
27
|
+
excludeTools: ["ask_question"],
|
|
28
|
+
},
|
|
29
|
+
|
|
30
|
+
// Executor - Full access (default)
|
|
31
|
+
executor: {
|
|
32
|
+
// No restrictions - full tool access
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
// Reviewer - Read and review, no write
|
|
36
|
+
reviewer: {
|
|
37
|
+
tools: ["read", "grep", "find", "ls", "glob", "bash"],
|
|
38
|
+
excludeTools: ["edit", "write"],
|
|
39
|
+
},
|
|
40
|
+
|
|
41
|
+
// Writer - Documentation focused
|
|
42
|
+
writer: {
|
|
43
|
+
tools: ["read", "edit", "write", "ls"],
|
|
44
|
+
excludeTools: ["bash", "web", "ask_question"],
|
|
45
|
+
},
|
|
46
|
+
|
|
47
|
+
// Security Reviewer - Strict restrictions
|
|
48
|
+
security_reviewer: {
|
|
49
|
+
tools: ["read", "grep", "find"],
|
|
50
|
+
excludeTools: ["edit", "write", "bash", "web", "ask_question"],
|
|
51
|
+
},
|
|
52
|
+
|
|
53
|
+
// Test Engineer - Can write tests
|
|
54
|
+
test_engineer: {
|
|
55
|
+
tools: ["read", "edit", "write", "bash", "ls"],
|
|
56
|
+
excludeTools: ["web"],
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Get tool configuration for a specific role.
|
|
62
|
+
*/
|
|
63
|
+
export function getToolConfig(role: string): RoleToolConfig {
|
|
64
|
+
return ROLE_TOOL_CONFIGS[role] ?? {};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Check if a role has any tool restrictions.
|
|
69
|
+
*/
|
|
70
|
+
export function hasToolRestrictions(role: string): boolean {
|
|
71
|
+
const config = getToolConfig(role);
|
|
72
|
+
return (config.tools !== undefined) || (config.excludeTools !== undefined);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Get all restricted roles.
|
|
77
|
+
*/
|
|
78
|
+
export function getRestrictedRoles(): string[] {
|
|
79
|
+
return Object.entries(ROLE_TOOL_CONFIGS)
|
|
80
|
+
.filter(([, config]) => (config.tools !== undefined) || (config.excludeTools !== undefined))
|
|
81
|
+
.map(([role]) => role);
|
|
82
|
+
}
|
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Fuzzy config key suggestions — Levenshtein-based typo correction for pi-crew config keys.
|
|
3
|
+
*
|
|
4
|
+
* SECURITY NOTE: The levenshtein() function processes user-supplied input and compares
|
|
5
|
+
* against a known list. While the timing variance across edit distances is minimal
|
|
6
|
+
* and the input is typically config key names (not secrets), there is a theoretical
|
|
7
|
+
* timing attack risk if an attacker could measure response times for different inputs.
|
|
8
|
+
* Risk level: LOW — mitigated by the small alphabet (config key names only) and
|
|
9
|
+
* the fixed-size DP array used in this implementation.
|
|
3
10
|
*/
|
|
4
11
|
|
|
5
12
|
/**
|
|
6
13
|
* Classic Levenshtein edit distance between two strings.
|
|
14
|
+
* See security note above regarding timing attack considerations.
|
|
7
15
|
*/
|
|
8
16
|
export function levenshtein(a: string, b: string): number {
|
|
9
17
|
const la = a.length;
|
package/src/config/types.ts
CHANGED
|
@@ -19,6 +19,8 @@ export interface PiTeamsAutonomousConfig {
|
|
|
19
19
|
preferAsyncForLongTasks?: boolean;
|
|
20
20
|
allowWorktreeSuggestion?: boolean;
|
|
21
21
|
magicKeywords?: Record<string, string[]>;
|
|
22
|
+
/** Mark certain bash commands as excludeFromContext to reduce context tokens. Default: false */
|
|
23
|
+
excludeContextBash?: boolean;
|
|
22
24
|
}
|
|
23
25
|
|
|
24
26
|
export interface CrewLimitsConfig {
|
|
@@ -66,6 +68,8 @@ export interface CrewRuntimeConfig {
|
|
|
66
68
|
/** Default runtime for roles not in isolatedRoles. Default: "live-session" (uses live-session). */
|
|
67
69
|
defaultRuntime?: "live-session" | "child-process";
|
|
68
70
|
};
|
|
71
|
+
/** Mark certain bash commands as excludeFromContext to reduce context tokens. Default: false */
|
|
72
|
+
excludeContextBash?: boolean;
|
|
69
73
|
}
|
|
70
74
|
|
|
71
75
|
export interface CrewControlConfig {
|
|
@@ -12,6 +12,7 @@ export interface AsyncNotifierState {
|
|
|
12
12
|
interval?: ReturnType<typeof setInterval>;
|
|
13
13
|
generation?: number;
|
|
14
14
|
lastStoppedAtMs?: number;
|
|
15
|
+
lastListRunsMs?: number;
|
|
15
16
|
}
|
|
16
17
|
|
|
17
18
|
export interface AsyncNotifierOptions {
|
|
@@ -80,6 +81,8 @@ export function markDeadAsyncRunIfNeeded(run: TeamRunManifest, now = Date.now(),
|
|
|
80
81
|
});
|
|
81
82
|
}
|
|
82
83
|
|
|
84
|
+
const LIST_RUNS_DEBOUNCE_MS = 30_000;
|
|
85
|
+
|
|
83
86
|
export function startAsyncRunNotifier(ctx: ExtensionContext, state: AsyncNotifierState, intervalMs = 5000, options: AsyncNotifierOptions = {}): void {
|
|
84
87
|
if (state.interval) clearInterval(state.interval);
|
|
85
88
|
const generation = options.generation ?? ((state.generation ?? 0) + 1);
|
|
@@ -93,10 +96,16 @@ export function startAsyncRunNotifier(ctx: ExtensionContext, state: AsyncNotifie
|
|
|
93
96
|
const updatedAtMs = timeMs(run.updatedAt) ?? 0;
|
|
94
97
|
if (isFinished(run.status) && updatedAtMs < staleBeforeMs) state.seenFinishedRunIds.add(run.runId);
|
|
95
98
|
}
|
|
99
|
+
let cachedRuns: TeamRunManifest[] | undefined;
|
|
96
100
|
state.interval = setInterval(() => {
|
|
97
101
|
try {
|
|
98
102
|
if (options.isCurrent && !options.isCurrent(generation)) return;
|
|
99
|
-
|
|
103
|
+
const nowMs = Date.now();
|
|
104
|
+
if (cachedRuns === undefined || nowMs - (state.lastListRunsMs ?? 0) > LIST_RUNS_DEBOUNCE_MS) {
|
|
105
|
+
cachedRuns = listRuns(ctx.cwd).slice(0, 20);
|
|
106
|
+
state.lastListRunsMs = nowMs;
|
|
107
|
+
}
|
|
108
|
+
for (const run of cachedRuns) {
|
|
100
109
|
const current = markDeadAsyncRunIfNeeded(run) ?? run;
|
|
101
110
|
if (!isFinished(current.status) || state.seenFinishedRunIds.has(current.runId)) continue;
|
|
102
111
|
state.seenFinishedRunIds.add(current.runId);
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
// NOTE: globalProgressTracker import kept for documentation but not directly used
|
|
3
|
+
// since we don't have agent IDs to untrack. Actual progress clearing should be
|
|
4
|
+
// handled by the progress tracker itself on shutdown.
|
|
5
|
+
// import { globalProgressTracker } from "../runtime/progress-tracker.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Registers cleanup handlers for graceful shutdown.
|
|
9
|
+
* Handles session_shutdown and SIGTERM/SIGHUP signals.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
interface ChildProcessInfo {
|
|
13
|
+
pid: number;
|
|
14
|
+
runId: string;
|
|
15
|
+
agentId: string;
|
|
16
|
+
startedAt: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
class ChildProcessRegistry {
|
|
20
|
+
private processes = new Map<number, ChildProcessInfo>();
|
|
21
|
+
|
|
22
|
+
register(pid: number, runId: string, agentId: string): void {
|
|
23
|
+
this.processes.set(pid, { pid, runId, agentId, startedAt: Date.now() });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
unregister(pid: number): void {
|
|
27
|
+
this.processes.delete(pid);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
getAllPids(): number[] {
|
|
31
|
+
return Array.from(this.processes.keys());
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
getInfo(pid: number): ChildProcessInfo | undefined {
|
|
35
|
+
return this.processes.get(pid);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
clear(): void {
|
|
39
|
+
this.processes.clear();
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const childProcessRegistry = new ChildProcessRegistry();
|
|
44
|
+
|
|
45
|
+
export function registerCleanupHandler(pi: ExtensionAPI): void {
|
|
46
|
+
// Handle session_shutdown event
|
|
47
|
+
pi.on("session_shutdown", async () => {
|
|
48
|
+
console.log("[pi-crew] Session shutdown - cleaning up resources");
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
// Kill all child-pi processes
|
|
52
|
+
await cleanupChildProcesses();
|
|
53
|
+
|
|
54
|
+
// Cleanup temp directories
|
|
55
|
+
await cleanupTempDirectories();
|
|
56
|
+
|
|
57
|
+
console.log("[pi-crew] Cleanup complete");
|
|
58
|
+
} catch (error) {
|
|
59
|
+
console.error("[pi-crew] Cleanup error:", error);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// Handle SIGTERM/SIGHUP signals
|
|
64
|
+
const handleSignal = async (signal: string): Promise<void> => {
|
|
65
|
+
console.log(`[pi-crew] Received ${signal} - starting cleanup`);
|
|
66
|
+
await cleanupChildProcesses();
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
process.on("SIGTERM", () => { void handleSignal("SIGTERM"); });
|
|
70
|
+
process.on("SIGHUP", () => { void handleSignal("SIGHUP"); });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function cleanupChildProcesses(): Promise<void> {
|
|
74
|
+
const pids = childProcessRegistry.getAllPids();
|
|
75
|
+
|
|
76
|
+
for (const pid of pids) {
|
|
77
|
+
try {
|
|
78
|
+
process.kill(pid, "SIGTERM");
|
|
79
|
+
console.log(`[pi-crew] Sent SIGTERM to child process ${pid}`);
|
|
80
|
+
} catch (error: unknown) {
|
|
81
|
+
// Process may already be dead or not exist
|
|
82
|
+
const err = error as NodeJS.ErrnoException;
|
|
83
|
+
if (err.code !== "ESRCH" && err.code !== "ENOENT") {
|
|
84
|
+
console.error(`[pi-crew] Error killing process ${pid}:`, err.message);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
childProcessRegistry.unregister(pid);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Clear progress tracker
|
|
91
|
+
// Note: Can't call untrack on all because we don't track agent IDs here
|
|
92
|
+
// The progress tracker should clear itself on shutdown via session_dispose
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function cleanupTempDirectories(): Promise<void> {
|
|
96
|
+
// NOTE: getTempDir is not available in paths.ts.
|
|
97
|
+
// For now, just log that cleanup is pending.
|
|
98
|
+
// Actual temp directory cleanup should be implemented by the run-graph
|
|
99
|
+
// or the specific code that creates temporary workspaces.
|
|
100
|
+
try {
|
|
101
|
+
console.log(`[pi-crew] Temp directory cleanup deferred to run-graph`);
|
|
102
|
+
} catch (error) {
|
|
103
|
+
console.error("[pi-crew] Temp cleanup error:", error);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Export for child-pi.ts to register processes
|
|
108
|
+
export function registerChildProcess(pid: number, runId: string, agentId: string): void {
|
|
109
|
+
childProcessRegistry.register(pid, runId, agentId);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function unregisterChildProcess(pid: number): void {
|
|
113
|
+
childProcessRegistry.unregister(pid);
|
|
114
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import type { TeamToolParamsValue } from "../schema/team-tool-schema.ts";
|
|
3
|
+
import { resolveContainedPath } from "../utils/safe-paths.ts";
|
|
3
4
|
// Lazy-loaded to avoid pulling team-tool.ts (and its entire runtime chain) into module load.
|
|
4
5
|
import type { handleTeamTool as HandleTeamToolFn } from "./team-tool.ts";
|
|
5
6
|
let _cachedHandleTeamTool: typeof HandleTeamToolFn | undefined;
|
|
@@ -69,7 +70,6 @@ function isAllowedRpcRunParams(params: TeamToolParamsValue): { ok: boolean; erro
|
|
|
69
70
|
// SECURITY: Validate cwd is within the project directory if provided.
|
|
70
71
|
if (params.cwd && typeof params.cwd === "string") {
|
|
71
72
|
try {
|
|
72
|
-
const { resolveContainedPath } = require("../utils/safe-paths.ts");
|
|
73
73
|
resolveContainedPath(params.cwd, ".");
|
|
74
74
|
} catch {
|
|
75
75
|
return { ok: false, error: "RPC run config.cwd must be within the project directory" };
|
|
@@ -55,12 +55,29 @@ export class NotificationRouter {
|
|
|
55
55
|
private readonly seen = new Map<string, number>();
|
|
56
56
|
private batch: NotificationDescriptor[] = [];
|
|
57
57
|
private timer: ReturnType<typeof setTimeout> | undefined;
|
|
58
|
+
private seenCleanupCounter = 0;
|
|
59
|
+
private static readonly SEEN_MAP_MAX_SIZE = 10000;
|
|
58
60
|
|
|
59
61
|
constructor(opts: NotificationRouterOptions = {}, deliver: (notification: NotificationDescriptor) => void) {
|
|
60
62
|
this.opts = opts;
|
|
61
63
|
this.deliver = deliver;
|
|
62
64
|
}
|
|
63
65
|
|
|
66
|
+
/**
|
|
67
|
+
* Evict oldest entries from seen Map if it exceeds MAX_SIZE.
|
|
68
|
+
* This prevents unbounded memory growth from notifications without TTL.
|
|
69
|
+
*/
|
|
70
|
+
private evictSeenIfNeeded(): void {
|
|
71
|
+
if (this.seen.size > NotificationRouter.SEEN_MAP_MAX_SIZE) {
|
|
72
|
+
// Sort by timestamp (oldest first) and keep only half
|
|
73
|
+
const entries = [...this.seen.entries()].sort((a, b) => a[1] - b[1]);
|
|
74
|
+
const keepCount = Math.floor(NotificationRouter.SEEN_MAP_MAX_SIZE / 2);
|
|
75
|
+
for (const [key] of entries.slice(0, entries.length - keepCount)) {
|
|
76
|
+
this.seen.delete(key);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
64
81
|
enqueue(notification: NotificationDescriptor): boolean {
|
|
65
82
|
const now = this.opts.now?.() ?? Date.now();
|
|
66
83
|
const withTime = { ...notification, timestamp: notification.timestamp ?? now };
|
|
@@ -77,6 +94,7 @@ export class NotificationRouter {
|
|
|
77
94
|
const previous = this.seen.get(key);
|
|
78
95
|
if (previous !== undefined && now - previous < dedupWindow) return false;
|
|
79
96
|
this.seen.set(key, now);
|
|
97
|
+
this.evictSeenIfNeeded();
|
|
80
98
|
const batchWindow = this.opts.batchWindowMs ?? 0;
|
|
81
99
|
if (batchWindow <= 0) {
|
|
82
100
|
this.deliver(withTime);
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
stopAsyncRunNotifier,
|
|
18
18
|
} from "./async-notifier.ts";
|
|
19
19
|
import { registerAutonomousPolicy } from "./autonomous-policy.ts";
|
|
20
|
+
import { registerCleanupHandler } from "./crew-cleanup.ts";
|
|
20
21
|
import { notifyActiveRuns } from "./session-summary.ts";
|
|
21
22
|
|
|
22
23
|
let _cachedLiveRunSidebar: typeof LiveRunSidebarType | undefined;
|
|
@@ -703,7 +704,7 @@ export function registerPiTeams(pi: ExtensionAPI): void {
|
|
|
703
704
|
widgetState,
|
|
704
705
|
loadConfig(currentCtx.cwd).config.ui,
|
|
705
706
|
);
|
|
706
|
-
clearPiCrewPowerbar(pi.events
|
|
707
|
+
clearPiCrewPowerbar(pi.events);
|
|
707
708
|
};
|
|
708
709
|
const openLiveSidebar = (ctx: ExtensionContext, runId: string): void => {
|
|
709
710
|
const uiConfig = loadConfig(ctx.cwd).config.ui;
|
|
@@ -859,7 +860,7 @@ export function registerPiTeams(pi: ExtensionAPI): void {
|
|
|
859
860
|
.then(({ stopWatchdog }) => {
|
|
860
861
|
stopWatchdog(runId);
|
|
861
862
|
})
|
|
862
|
-
.catch(() => {});
|
|
863
|
+
.catch((error) => logInternalError("register.foreground-watchdog", error, `runId=${runId}`));
|
|
863
864
|
}
|
|
864
865
|
const ownerCurrent = isContextCurrent(ctx, ownerGeneration);
|
|
865
866
|
if (ctx.hasUI) {
|
|
@@ -942,9 +943,11 @@ export function registerPiTeams(pi: ExtensionAPI): void {
|
|
|
942
943
|
function getPiEvents():
|
|
943
944
|
| Parameters<typeof registerPiCrewRpc>[0]
|
|
944
945
|
| undefined {
|
|
945
|
-
if (pi && typeof pi === "object" && "events" in pi)
|
|
946
|
-
|
|
947
|
-
|
|
946
|
+
if (pi && typeof pi === "object" && "events" in pi) {
|
|
947
|
+
// pi.events may not be typed in the original pi type, so cast through unknown
|
|
948
|
+
const events = (pi as { events?: Parameters<typeof registerPiCrewRpc>[0] }).events;
|
|
949
|
+
return events;
|
|
950
|
+
}
|
|
948
951
|
return undefined;
|
|
949
952
|
}
|
|
950
953
|
rpcHandle = registerPiCrewRpc(getPiEvents(), () => currentCtx);
|
|
@@ -1084,7 +1087,7 @@ export function registerPiTeams(pi: ExtensionAPI): void {
|
|
|
1084
1087
|
widgetState,
|
|
1085
1088
|
currentCtx ? loadConfig(currentCtx.cwd).config.ui : undefined,
|
|
1086
1089
|
);
|
|
1087
|
-
clearPiCrewPowerbar(pi.events
|
|
1090
|
+
clearPiCrewPowerbar(pi.events);
|
|
1088
1091
|
disposePowerbarCoalescer();
|
|
1089
1092
|
heartbeatWatcher?.dispose();
|
|
1090
1093
|
if (autoRepairTimer) {
|
|
@@ -1496,8 +1499,17 @@ export function registerPiTeams(pi: ExtensionAPI): void {
|
|
|
1496
1499
|
manifests,
|
|
1497
1500
|
);
|
|
1498
1501
|
// Health notifications: only warn about genuinely running runs
|
|
1502
|
+
// Filter to only current session's runs to prevent cross-session notification leakage
|
|
1503
|
+
const currentSessionGen = sessionGeneration;
|
|
1504
|
+
const currentSessionId = currentCtx ? (currentCtx as { sessionId?: string }).sessionId : undefined;
|
|
1505
|
+
const sessionManifests = manifests.filter(
|
|
1506
|
+
(run) =>
|
|
1507
|
+
!run.ownerSessionId ||
|
|
1508
|
+
run.ownerSessionId === currentSessionId ||
|
|
1509
|
+
(run as unknown as Record<string, unknown>).ownerSessionGeneration === currentSessionGen,
|
|
1510
|
+
);
|
|
1499
1511
|
const now = Date.now();
|
|
1500
|
-
for (const run of
|
|
1512
|
+
for (const run of sessionManifests) {
|
|
1501
1513
|
if (run.status !== "running") continue;
|
|
1502
1514
|
try {
|
|
1503
1515
|
const snapshot = snapshotCache.get(run.runId);
|
|
@@ -1749,19 +1761,13 @@ export function registerPiTeams(pi: ExtensionAPI): void {
|
|
|
1749
1761
|
// AGENTS.md requires confirm=true for management deletes.
|
|
1750
1762
|
pi.on("tool_call", async (event, ctx) => {
|
|
1751
1763
|
if (event.toolName !== "team") return;
|
|
1752
|
-
const
|
|
1753
|
-
if (!
|
|
1754
|
-
const action
|
|
1755
|
-
|
|
1756
|
-
const destructiveActions = new Set([
|
|
1757
|
-
"delete",
|
|
1758
|
-
"forget",
|
|
1759
|
-
"prune",
|
|
1760
|
-
"cleanup",
|
|
1761
|
-
]);
|
|
1764
|
+
const rawInput = event.input;
|
|
1765
|
+
if (!rawInput || typeof rawInput !== "object") return;
|
|
1766
|
+
const input = rawInput as { action?: unknown; confirm?: unknown; force?: unknown };
|
|
1767
|
+
const action = typeof input.action === "string" ? input.action : undefined;
|
|
1768
|
+
const destructiveActions = new Set(["delete", "forget", "prune", "cleanup"]);
|
|
1762
1769
|
if (!action || !destructiveActions.has(action)) return;
|
|
1763
|
-
const forceBypassesReferenceChecks =
|
|
1764
|
-
action === "delete" && input.force === true;
|
|
1770
|
+
const forceBypassesReferenceChecks = action === "delete" && input.force === true;
|
|
1765
1771
|
if (input.confirm === true || forceBypassesReferenceChecks) return;
|
|
1766
1772
|
return {
|
|
1767
1773
|
block: true,
|
|
@@ -1792,6 +1798,8 @@ export function registerPiTeams(pi: ExtensionAPI): void {
|
|
|
1792
1798
|
});
|
|
1793
1799
|
time("register.tools");
|
|
1794
1800
|
|
|
1801
|
+
registerCleanupHandler(pi);
|
|
1802
|
+
|
|
1795
1803
|
registerTeamCommands(pi, {
|
|
1796
1804
|
startForegroundRun,
|
|
1797
1805
|
abortForegroundRun,
|
|
@@ -56,7 +56,7 @@ export function registerSubagentTools(pi: ExtensionAPI, subagentManager: Subagen
|
|
|
56
56
|
async execute(_id, params, signal, onUpdate, ctx) {
|
|
57
57
|
// Diagnostic: detect pre-aborted signal before spawn
|
|
58
58
|
if (signal?.aborted) {
|
|
59
|
-
logInternalError("subagent-tools.pre-aborted-signal", undefined, `
|
|
59
|
+
logInternalError("subagent-tools.pre-aborted-signal", undefined, `aborted=true paramsKeys=${Object.keys(params as object).join(",")}`);
|
|
60
60
|
return subagentToolResult("Agent tool signal was already aborted before execution started. This usually means Pi cancelled the tool call before it ran.", { action: "agent", status: "error" }, true);
|
|
61
61
|
}
|
|
62
62
|
const currentRole = currentCrewRole();
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Anchor commands for team tool.
|
|
3
|
+
* Provides set/clear/status commands for anchor points.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { TeamToolParamsValue } from "../../schema/team-tool-schema.ts";
|
|
7
|
+
import type { PiTeamsToolResult } from "../tool-result.ts";
|
|
8
|
+
import { result, type TeamContext } from "./context.ts";
|
|
9
|
+
import {
|
|
10
|
+
AnchorManager,
|
|
11
|
+
createAnchorManager,
|
|
12
|
+
AnchorNotFoundError,
|
|
13
|
+
NoHandoffsError,
|
|
14
|
+
} from "../../runtime/anchor-manager.ts";
|
|
15
|
+
import type { HandoffSummary } from "../../runtime/handoff-manager.ts";
|
|
16
|
+
|
|
17
|
+
// Global anchor manager instance for CLI usage
|
|
18
|
+
let globalAnchorManager: AnchorManager | null = null;
|
|
19
|
+
|
|
20
|
+
function getAnchorManager(): AnchorManager {
|
|
21
|
+
if (!globalAnchorManager) {
|
|
22
|
+
globalAnchorManager = createAnchorManager();
|
|
23
|
+
}
|
|
24
|
+
return globalAnchorManager;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Get the session ID from context or generate a default.
|
|
29
|
+
*/
|
|
30
|
+
function getSessionId(ctx: TeamContext): string {
|
|
31
|
+
return ctx.sessionId ?? "default";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function handleAnchorSet(
|
|
35
|
+
params: TeamToolParamsValue,
|
|
36
|
+
ctx: TeamContext,
|
|
37
|
+
): PiTeamsToolResult {
|
|
38
|
+
const manager = getAnchorManager();
|
|
39
|
+
const sessionId = getSessionId(ctx);
|
|
40
|
+
const cfg = params.config ?? {};
|
|
41
|
+
|
|
42
|
+
// Parse context from config
|
|
43
|
+
const context: Record<string, unknown> = {};
|
|
44
|
+
if (cfg.context && typeof cfg.context === "object") {
|
|
45
|
+
Object.assign(context, cfg.context as Record<string, unknown>);
|
|
46
|
+
}
|
|
47
|
+
if (cfg.key) {
|
|
48
|
+
// Single key shorthand
|
|
49
|
+
context.key = cfg.key;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const anchorId = manager.setAnchor(sessionId, context);
|
|
53
|
+
|
|
54
|
+
return result(
|
|
55
|
+
[
|
|
56
|
+
`Anchor set successfully.`,
|
|
57
|
+
`Anchor ID: ${anchorId}`,
|
|
58
|
+
`Session: ${sessionId}`,
|
|
59
|
+
context && Object.keys(context).length > 0
|
|
60
|
+
? `Context: ${JSON.stringify(context)}`
|
|
61
|
+
: "",
|
|
62
|
+
].filter(Boolean).join("\n"),
|
|
63
|
+
{ action: "anchor", status: "ok" },
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function handleAnchorClear(
|
|
68
|
+
params: TeamToolParamsValue,
|
|
69
|
+
ctx: TeamContext,
|
|
70
|
+
): PiTeamsToolResult {
|
|
71
|
+
const manager = getAnchorManager();
|
|
72
|
+
const sessionId = getSessionId(ctx);
|
|
73
|
+
const cfg = params.config ?? {};
|
|
74
|
+
|
|
75
|
+
let anchorId: string | undefined;
|
|
76
|
+
if (cfg.anchorId) {
|
|
77
|
+
anchorId = cfg.anchorId as string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
let accumulated: HandoffSummary;
|
|
81
|
+
try {
|
|
82
|
+
if (anchorId) {
|
|
83
|
+
accumulated = manager.clearAnchor(anchorId);
|
|
84
|
+
} else {
|
|
85
|
+
const anchorResult = manager.clearAnchorBySession(sessionId);
|
|
86
|
+
if (!anchorResult) {
|
|
87
|
+
return result(
|
|
88
|
+
"No anchor found for this session.",
|
|
89
|
+
{ action: "anchor", status: "error" },
|
|
90
|
+
true,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
accumulated = anchorResult;
|
|
94
|
+
}
|
|
95
|
+
} catch (error) {
|
|
96
|
+
if (error instanceof AnchorNotFoundError) {
|
|
97
|
+
return result(
|
|
98
|
+
`Anchor not found: ${error.anchorId}`,
|
|
99
|
+
{ action: "anchor", status: "error" },
|
|
100
|
+
true,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
if (error instanceof NoHandoffsError) {
|
|
104
|
+
return result(
|
|
105
|
+
"No handoffs have been accumulated to this anchor.",
|
|
106
|
+
{ action: "anchor", status: "error" },
|
|
107
|
+
true,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
throw error;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return result(
|
|
114
|
+
[
|
|
115
|
+
`Anchor cleared successfully.`,
|
|
116
|
+
`Accumulated summary:`,
|
|
117
|
+
``,
|
|
118
|
+
`Task: ${accumulated.task}`,
|
|
119
|
+
`Outcome: ${accumulated.outcome}`,
|
|
120
|
+
``,
|
|
121
|
+
`Metrics:`,
|
|
122
|
+
` Tokens: ${accumulated.metrics.tokensUsed}`,
|
|
123
|
+
` Duration: ${Math.round(accumulated.metrics.duration / 1000)}s`,
|
|
124
|
+
` Iterations: ${accumulated.metrics.iterations}`,
|
|
125
|
+
` Tools: ${accumulated.metrics.toolsUsed.join(", ") || "(none)"}`,
|
|
126
|
+
``,
|
|
127
|
+
`Files created: ${accumulated.filesCreated.join(", ") || "(none)"}`,
|
|
128
|
+
`Files modified: ${accumulated.filesModified.join(", ") || "(none)"}`,
|
|
129
|
+
`Files deleted: ${accumulated.filesDeleted.join(", ") || "(none)"}`,
|
|
130
|
+
accumulated.decisions.length > 0
|
|
131
|
+
? `\nDecisions:\n${accumulated.decisions.map((d: { rationale: string; outcome: string }) => ` - ${d.rationale}: ${d.outcome}`).join("\n")}`
|
|
132
|
+
: "",
|
|
133
|
+
accumulated.blockers.length > 0
|
|
134
|
+
? `\nBlockers: ${accumulated.blockers.join("; ")}`
|
|
135
|
+
: "",
|
|
136
|
+
accumulated.nextSteps.length > 0
|
|
137
|
+
? `\nNext steps: ${accumulated.nextSteps.join("; ")}`
|
|
138
|
+
: "",
|
|
139
|
+
].filter(Boolean).join("\n"),
|
|
140
|
+
{ action: "anchor", status: "ok" },
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function handleAnchorStatus(
|
|
145
|
+
params: TeamToolParamsValue,
|
|
146
|
+
ctx: TeamContext,
|
|
147
|
+
): PiTeamsToolResult {
|
|
148
|
+
const manager = getAnchorManager();
|
|
149
|
+
const sessionId = getSessionId(ctx);
|
|
150
|
+
const cfg = params.config ?? {};
|
|
151
|
+
|
|
152
|
+
let anchorId: string | undefined;
|
|
153
|
+
if (cfg.anchorId) {
|
|
154
|
+
anchorId = cfg.anchorId as string;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
let status;
|
|
158
|
+
if (anchorId) {
|
|
159
|
+
status = manager.getAnchorStatus(anchorId);
|
|
160
|
+
} else {
|
|
161
|
+
status = manager.getAnchorStatusBySession(sessionId);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (!status) {
|
|
165
|
+
return result(
|
|
166
|
+
anchorId
|
|
167
|
+
? `No anchor found with ID: ${anchorId}`
|
|
168
|
+
: `No anchor set for session: ${sessionId}`,
|
|
169
|
+
{ action: "anchor", status: "ok" },
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return result(
|
|
174
|
+
[
|
|
175
|
+
`Anchor Status`,
|
|
176
|
+
`─────────────`,
|
|
177
|
+
`Anchor ID: ${status.anchorId}`,
|
|
178
|
+
`Session ID: ${status.sessionId}`,
|
|
179
|
+
`Created: ${new Date(status.createdAt).toISOString()}`,
|
|
180
|
+
`Handoffs: ${status.handoffCount}`,
|
|
181
|
+
`Total tokens: ${status.totalTokens}`,
|
|
182
|
+
`Total duration: ${Math.round(status.totalDuration / 1000)}s`,
|
|
183
|
+
status.context && Object.keys(status.context).length > 0
|
|
184
|
+
? `\nContext: ${JSON.stringify(status.context, null, 2)}`
|
|
185
|
+
: "",
|
|
186
|
+
].filter(Boolean).join("\n"),
|
|
187
|
+
{ action: "anchor", status: "ok" },
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function handleAnchorAccumulate(
|
|
192
|
+
params: TeamToolParamsValue,
|
|
193
|
+
ctx: TeamContext,
|
|
194
|
+
): PiTeamsToolResult {
|
|
195
|
+
// This would be used to manually accumulate a handoff to the current anchor
|
|
196
|
+
// In practice, this is called internally by HandoffManager when anchor is set
|
|
197
|
+
return result(
|
|
198
|
+
"Use handleAnchorSet to set an anchor, then run tasks normally. Handoffs will be accumulated automatically.",
|
|
199
|
+
{ action: "anchor", status: "ok" },
|
|
200
|
+
);
|
|
201
|
+
}
|
|
@@ -18,6 +18,7 @@ import { readForegroundControlStatus, writeForegroundInterruptRequest } from "..
|
|
|
18
18
|
import { followUpLiveAgent, getLiveAgent, listActiveLiveAgents, resumeLiveAgent, steerLiveAgent, stopLiveAgent } from "../../subagents/live/manager.ts";
|
|
19
19
|
import { appendLiveAgentControlRequest } from "../../subagents/live/control.ts";
|
|
20
20
|
import { liveControlRealtimeMessage, publishLiveControlRealtime } from "../../subagents/live/realtime.ts";
|
|
21
|
+
import { logInternalError } from "../../utils/internal-error.ts";
|
|
21
22
|
import { buildCapabilityInventory } from "../../runtime/capability-inventory.ts";
|
|
22
23
|
import { resolveRealContainedPath } from "../../utils/safe-paths.ts";
|
|
23
24
|
import type { PiTeamsToolResult } from "../tool-result.ts";
|
|
@@ -125,7 +126,7 @@ export async function handleApi(params: TeamToolParamsValue, ctx: TeamContext):
|
|
|
125
126
|
saveRunTasks(manifest, tasks);
|
|
126
127
|
appendEvent(manifest.eventsPath, { type: "plan.cancelled", runId: manifest.runId, taskId: approval.planTaskId, message: "Adaptive implementation plan was cancelled.", metadata: { provenance: "api" } });
|
|
127
128
|
manifest = updateRunStatus(manifest, "cancelled", "Plan approval was cancelled.");
|
|
128
|
-
void terminateLiveAgentsForRun(manifest.runId, "cancelled", appendEvent, manifest.eventsPath).catch(() => {});
|
|
129
|
+
void terminateLiveAgentsForRun(manifest.runId, "cancelled", appendEvent, manifest.eventsPath).catch((error) => logInternalError("team-tool.cancel-plan.terminate", error, `runId=${manifest.runId}`));
|
|
129
130
|
return result(JSON.stringify({ planApproval: manifest.planApproval, cancelledTasks: tasks.filter((task) => task.status === "cancelled").map((task) => task.id) }, null, 2), { action: "api", status: "ok", runId: manifest.runId, artifactsRoot: manifest.artifactsRoot });
|
|
130
131
|
});
|
|
131
132
|
} catch (error) {
|