pi-crew 0.5.2 → 0.5.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/docs/bugs/cross-session-notification-leakage.md +82 -0
  3. package/docs/coding-agent-optimization.md +268 -0
  4. package/docs/deep-review-report.md +384 -0
  5. package/docs/distillation/cybersecurity-patterns.md +294 -0
  6. package/docs/migration-v0.4-v0.5.md +191 -0
  7. package/docs/optimization-plan.md +642 -0
  8. package/docs/pi-mono-opportunities.md +969 -0
  9. package/docs/pi-mono-review.md +291 -0
  10. package/docs/skills/REFERENCE.md +144 -0
  11. package/package.json +7 -6
  12. package/skills/artifact-analysis-loop/SKILL.md +302 -0
  13. package/skills/async-worker-recovery/SKILL.md +19 -1
  14. package/skills/child-pi-spawning/SKILL.md +19 -6
  15. package/skills/context-artifact-hygiene/SKILL.md +19 -2
  16. package/skills/delegation-patterns/SKILL.md +68 -3
  17. package/skills/detection-pipeline-design/SKILL.md +285 -0
  18. package/skills/event-log-tracing/SKILL.md +20 -6
  19. package/skills/git-master/SKILL.md +20 -6
  20. package/skills/hunting-investigation-loop/SKILL.md +401 -0
  21. package/skills/incident-playbook-construction/SKILL.md +383 -0
  22. package/skills/live-agent-lifecycle/SKILL.md +20 -6
  23. package/skills/mailbox-interactive/SKILL.md +19 -6
  24. package/skills/model-routing-context/SKILL.md +19 -1
  25. package/skills/multi-perspective-review/SKILL.md +19 -4
  26. package/skills/observability-reliability/SKILL.md +19 -2
  27. package/skills/orchestration/SKILL.md +20 -2
  28. package/skills/ownership-session-security/SKILL.md +20 -2
  29. package/skills/pi-extension-lifecycle/SKILL.md +20 -2
  30. package/skills/post-mortem/SKILL.md +7 -2
  31. package/skills/read-only-explorer/SKILL.md +20 -6
  32. package/skills/requirements-to-task-packet/SKILL.md +23 -3
  33. package/skills/resource-discovery-config/SKILL.md +20 -2
  34. package/skills/runtime-state-reader/SKILL.md +20 -2
  35. package/skills/safe-bash/SKILL.md +21 -6
  36. package/skills/scrutinize/SKILL.md +20 -2
  37. package/skills/secure-agent-orchestration-review/SKILL.md +29 -2
  38. package/skills/security-review/SKILL.md +560 -0
  39. package/skills/state-mutation-locking/SKILL.md +22 -2
  40. package/skills/systematic-debugging/SKILL.md +8 -6
  41. package/skills/threat-hypothesis-framework/SKILL.md +175 -0
  42. package/skills/ui-render-performance/SKILL.md +20 -2
  43. package/skills/verification-before-done/SKILL.md +17 -2
  44. package/skills/widget-rendering/SKILL.md +21 -6
  45. package/skills/workspace-isolation/SKILL.md +20 -6
  46. package/skills/worktree-isolation/SKILL.md +20 -6
  47. package/src/agents/agent-config.ts +40 -1
  48. package/src/config/config.ts +22 -5
  49. package/src/config/role-tools.ts +82 -0
  50. package/src/config/types.ts +4 -0
  51. package/src/extension/crew-cleanup.ts +114 -0
  52. package/src/extension/register.ts +15 -3
  53. package/src/extension/team-tool/run.ts +7 -7
  54. package/src/observability/event-bus.ts +60 -0
  55. package/src/runtime/background-runner.ts +8 -2
  56. package/src/runtime/child-pi.ts +122 -34
  57. package/src/runtime/crew-agent-runtime.ts +1 -0
  58. package/src/runtime/foreground-control.ts +87 -17
  59. package/src/runtime/pi-args.ts +11 -1
  60. package/src/runtime/pi-json-output.ts +31 -0
  61. package/src/runtime/progress-tracker.ts +124 -0
  62. package/src/runtime/skill-effectiveness.ts +473 -0
  63. package/src/runtime/skill-instructions.ts +37 -3
  64. package/src/runtime/task-runner.ts +91 -17
  65. package/src/runtime/team-runner.ts +11 -11
  66. package/src/runtime/tool-progress.ts +10 -3
  67. package/src/runtime/verification-gates.ts +367 -0
  68. package/src/schema/team-tool-schema.ts +7 -0
  69. package/src/state/decision-ledger.ts +92 -43
  70. package/src/state/event-log.ts +136 -10
  71. package/src/state/hook-instinct-bridge.ts +5 -5
  72. package/src/state/state-store.ts +3 -1
  73. package/src/state/types.ts +4 -0
  74. package/src/types/new-api-types.ts +34 -0
  75. package/src/ui/agent-management-overlay.ts +5 -1
  76. package/src/ui/crew-widget.ts +29 -15
  77. package/src/ui/powerbar-publisher.ts +100 -7
  78. package/src/ui/tool-render.ts +15 -15
  79. package/src/utils/session-utils.ts +52 -0
  80. package/src/worktree/worktree-manager.ts +32 -13
@@ -4,6 +4,8 @@ import { appendEvent } from "../state/event-log.ts";
4
4
  import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
5
5
  import { checkProcessLiveness, isActiveRunStatus } from "./process-status.ts";
6
6
  import { readCrewAgents } from "./crew-agent-records.ts";
7
+ import { logInternalError } from "../utils/internal-error.ts";
8
+ import { sleepSync } from "../utils/sleep.ts";
7
9
 
8
10
  export type ForegroundControlRequestType = "interrupt" | "status";
9
11
 
@@ -59,24 +61,92 @@ export function readForegroundControlStatus(manifest: TeamRunManifest, tasks: Te
59
61
 
60
62
  export function writeForegroundInterruptRequest(manifest: TeamRunManifest, reason = "User requested foreground interrupt."): ForegroundControlRequest {
61
63
  const controlPath = foregroundControlPath(manifest);
64
+ const lockDir = `${controlPath}.lock`;
65
+ const pidFile = path.join(lockDir, "pid");
62
66
  let requests: ForegroundControlRequest[] = [];
63
- if (fs.existsSync(controlPath)) {
64
- try {
65
- const parsed = JSON.parse(fs.readFileSync(controlPath, "utf-8")) as { requests?: ForegroundControlRequest[] };
66
- requests = Array.isArray(parsed.requests) ? parsed.requests : [];
67
- } catch {
68
- requests = [];
67
+
68
+ // FIX: Use file locking to prevent race condition in read-modify-write
69
+ // Added stale lock detection similar to event-log.ts
70
+ const acquireLock = (): void => {
71
+ const timeout = 5000;
72
+ const staleMs = 10000;
73
+ const start = Date.now();
74
+ while (true) {
75
+ try {
76
+ fs.mkdirSync(lockDir, { recursive: true });
77
+ try { fs.writeFileSync(pidFile, String(process.pid), "utf-8"); } catch { /* best-effort */ }
78
+ break;
79
+ } catch {
80
+ if (Date.now() - start > timeout) {
81
+ // Check if lock is stale before giving up
82
+ try {
83
+ const raw = fs.readFileSync(pidFile, "utf-8").trim();
84
+ const ownerPid = Number.parseInt(raw, 10);
85
+ if (!Number.isNaN(ownerPid) && ownerPid !== process.pid) {
86
+ let alive = false;
87
+ try { process.kill(ownerPid, 0); alive = true; } catch { /* dead */ }
88
+ if (!alive) {
89
+ // Lock is stale — clear it and retry
90
+ fs.rmSync(lockDir, { recursive: true, force: true });
91
+ continue;
92
+ }
93
+ // Lock held by live process — throw
94
+ const err = new Error(`Foreground control lock timeout for ${controlPath}`);
95
+ logInternalError("foreground-control.lock-timeout", err, `controlPath=${controlPath}`);
96
+ throw err;
97
+ }
98
+ } catch { /* no pid file — continue to throw */ }
99
+ const err = new Error(`Foreground control lock timeout for ${controlPath}`);
100
+ logInternalError("foreground-control.lock-timeout", err, `controlPath=${controlPath}`);
101
+ throw err;
102
+ }
103
+ // Stale detection: if the owning process is dead, remove the stale lock
104
+ try {
105
+ const raw = fs.readFileSync(pidFile, "utf-8").trim();
106
+ const ownerPid = Number.parseInt(raw, 10);
107
+ if (!Number.isNaN(ownerPid) && ownerPid !== process.pid) {
108
+ let alive = false;
109
+ try { process.kill(ownerPid, 0); alive = true; } catch { /* dead */ }
110
+ if (!alive) {
111
+ const stat = fs.statSync(lockDir);
112
+ if (Date.now() - stat.mtimeMs > staleMs) {
113
+ fs.rmSync(lockDir, { recursive: true, force: true });
114
+ continue;
115
+ }
116
+ }
117
+ }
118
+ } catch { /* no pid file — fall through to sleep */ }
119
+ // Brief sleep to avoid CPU spinning
120
+ sleepSync(10);
121
+ }
69
122
  }
70
- }
71
- const request: ForegroundControlRequest = {
72
- id: `fg_${Date.now().toString(36)}_${Math.random().toString(16).slice(2, 10)}`,
73
- type: "interrupt",
74
- createdAt: new Date().toISOString(),
75
- reason,
76
- acknowledged: false,
77
123
  };
78
- fs.mkdirSync(path.dirname(controlPath), { recursive: true });
79
- fs.writeFileSync(controlPath, `${JSON.stringify({ requests: [...requests, request] }, null, 2)}\n`, "utf-8");
80
- appendEvent(manifest.eventsPath, { type: "foreground.interrupt_requested", runId: manifest.runId, message: reason, data: { requestId: request.id, controlPath } });
81
- return request;
124
+ const releaseLock = (): void => {
125
+ try { fs.rmSync(lockDir, { recursive: true, force: true }); } catch { /* best-effort */ }
126
+ };
127
+
128
+ acquireLock();
129
+ try {
130
+ if (fs.existsSync(controlPath)) {
131
+ try {
132
+ const parsed = JSON.parse(fs.readFileSync(controlPath, "utf-8")) as { requests?: ForegroundControlRequest[] };
133
+ requests = Array.isArray(parsed.requests) ? parsed.requests : [];
134
+ } catch {
135
+ requests = [];
136
+ }
137
+ }
138
+ const request: ForegroundControlRequest = {
139
+ id: `fg_${Date.now().toString(36)}_${Math.random().toString(16).slice(2, 10)}`,
140
+ type: "interrupt",
141
+ createdAt: new Date().toISOString(),
142
+ reason,
143
+ acknowledged: false,
144
+ };
145
+ fs.mkdirSync(path.dirname(controlPath), { recursive: true });
146
+ fs.writeFileSync(controlPath, `${JSON.stringify({ requests: [...requests, request] }, null, 2)}\n`, "utf-8");
147
+ appendEvent(manifest.eventsPath, { type: "foreground.interrupt_requested", runId: manifest.runId, message: reason, data: { requestId: request.id, controlPath } });
148
+ return request;
149
+ } finally {
150
+ releaseLock();
151
+ }
82
152
  }
@@ -3,6 +3,7 @@ import * as os from "node:os";
3
3
  import * as path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import type { AgentConfig } from "../agents/agent-config.ts";
6
+ import { getAgentSessionOptions } from "../agents/agent-config.ts";
6
7
 
7
8
  const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"];
8
9
  const PROMPT_RUNTIME_EXTENSION_PATH = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "prompt", "prompt-runtime.ts");
@@ -17,6 +18,8 @@ export interface BuildPiWorkerArgsInput {
17
18
  maxDepth?: number;
18
19
  skillPaths?: string[];
19
20
  env?: NodeJS.ProcessEnv;
21
+ /** Role for tool restrictions (uses role-tools.ts config) */
22
+ role?: string;
20
23
  }
21
24
 
22
25
  export interface BuildPiWorkerArgsResult {
@@ -99,7 +102,14 @@ export function buildPiWorkerArgs(input: BuildPiWorkerArgsInput): BuildPiWorkerA
99
102
  args.push("--thinking", input.agent.thinking);
100
103
  }
101
104
 
102
- if (input.agent.tools?.length) args.push("--tools", input.agent.tools.join(","));
105
+ // Apply role-based tool restrictions (from role-tools.ts)
106
+ // Role-specific config takes precedence over agent-defined tools
107
+ const toolConfig = input.role ? getAgentSessionOptions(input.role) : {};
108
+ const explicitTools = toolConfig.tools ?? input.agent.tools;
109
+ const excludeTools = toolConfig.excludeTools;
110
+
111
+ if (explicitTools?.length) args.push("--tools", explicitTools.join(","));
112
+ if (excludeTools?.length) args.push("--exclude-tools", excludeTools.join(","));
103
113
  // Always add --no-extensions before --extension to prevent user extensions from being auto-loaded.
104
114
  // User extensions in ~/.pi/agent/extensions/ may fail due to missing dependencies.
105
115
  args.push("--no-extensions");
@@ -12,6 +12,8 @@ export interface ParsedPiJsonOutput {
12
12
  textEvents: string[];
13
13
  finalText?: string;
14
14
  usage?: ParsedPiUsage;
15
+ /** Unified patches extracted from tool_result events (edit tool patch field) */
16
+ patches?: string[];
15
17
  }
16
18
 
17
19
  function asRecord(value: unknown): Record<string, unknown> | undefined {
@@ -87,6 +89,7 @@ function extractText(value: unknown): string[] {
87
89
  export function parsePiJsonOutput(stdout: string): ParsedPiJsonOutput {
88
90
  let jsonEvents = 0;
89
91
  const textEvents: string[] = [];
92
+ const patches: string[] = [];
90
93
  let usage: ParsedPiUsage | undefined;
91
94
  for (const line of stdout.split("\n")) {
92
95
  const trimmed = line.trim();
@@ -99,6 +102,8 @@ export function parsePiJsonOutput(stdout: string): ParsedPiJsonOutput {
99
102
  }
100
103
  jsonEvents++;
101
104
  textEvents.push(...extractText(event));
105
+ // Extract unified patches from tool_result events
106
+ extractPatch(event, patches);
102
107
  const eventUsage = extractUsage(event);
103
108
  if (eventUsage) usage = mergeUsage(usage ?? {}, eventUsage);
104
109
  }
@@ -107,5 +112,31 @@ export function parsePiJsonOutput(stdout: string): ParsedPiJsonOutput {
107
112
  textEvents,
108
113
  finalText: textEvents.length > 0 ? textEvents[textEvents.length - 1] : undefined,
109
114
  usage,
115
+ patches: patches.length > 0 ? patches : undefined,
110
116
  };
111
117
  }
118
+
119
+ /**
120
+ * Extract unified patches from a tool_result event.
121
+ * pi's edit tool now includes a `patch` field (standard unified diff format).
122
+ * We detect it by looking for lines starting with "---" or "+++" which indicate
123
+ * unified diff format.
124
+ */
125
+ function extractPatch(event: unknown, patches: string[]): void {
126
+ const obj = asRecord(event);
127
+ if (!obj || obj.type !== "tool_result") return;
128
+
129
+ const content = obj.content;
130
+ if (!Array.isArray(content)) return;
131
+
132
+ for (const item of content) {
133
+ const part = asRecord(item);
134
+ if (!part || part.type !== "text") continue;
135
+ const text = typeof part.text === "string" ? part.text : "";
136
+
137
+ // Check if this looks like a unified patch (starts with "---" or "+++")
138
+ if (text.includes("--- a/") || text.includes("diff ---")) {
139
+ patches.push(text);
140
+ }
141
+ }
142
+ }
@@ -0,0 +1,124 @@
1
+ import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
2
+ import { crewEventBus } from "../observability/event-bus.ts";
3
+
4
+ export interface AgentProgress {
5
+ toolCalls: number;
6
+ currentTool: string | null;
7
+ toolStartTime: number | null;
8
+ errors: string[];
9
+ turns: number;
10
+ tokens: { input: number; output: number };
11
+ status: "idle" | "running" | "completed" | "error";
12
+ }
13
+
14
+ export class ProgressTracker {
15
+ private sessions = new Map<string, {
16
+ unsubscribe: () => void;
17
+ progress: AgentProgress;
18
+ }>();
19
+
20
+ track(
21
+ session: { subscribe: (listener: (event: AgentSessionEvent) => void) => () => void },
22
+ agentId: string,
23
+ runId: string
24
+ ): AgentProgress {
25
+ if (this.sessions.has(agentId)) {
26
+ return this.sessions.get(agentId)!.progress;
27
+ }
28
+
29
+ const progress: AgentProgress = {
30
+ toolCalls: 0,
31
+ currentTool: null,
32
+ toolStartTime: null,
33
+ errors: [],
34
+ turns: 0,
35
+ tokens: { input: 0, output: 0 },
36
+ status: "running",
37
+ };
38
+
39
+ const unsubscribe = session.subscribe((event: AgentSessionEvent) => {
40
+ this.handleEvent(event, progress, agentId, runId);
41
+ });
42
+
43
+ this.sessions.set(agentId, { unsubscribe, progress });
44
+ return progress;
45
+ }
46
+
47
+ private handleEvent(
48
+ event: AgentSessionEvent,
49
+ progress: AgentProgress,
50
+ agentId: string,
51
+ runId: string
52
+ ): void {
53
+ switch (event.type) {
54
+ case "tool_execution_start":
55
+ progress.toolCalls++;
56
+ progress.currentTool = event.toolName;
57
+ progress.toolStartTime = Date.now();
58
+ crewEventBus.emit({
59
+ type: "agent:progress",
60
+ runId,
61
+ agentId,
62
+ payload: { ...progress },
63
+ timestamp: Date.now(),
64
+ });
65
+ break;
66
+
67
+ case "tool_execution_end":
68
+ progress.currentTool = null;
69
+ progress.toolStartTime = null;
70
+ if (event.isError) {
71
+ progress.errors.push(String(event.result ?? "Unknown error"));
72
+ crewEventBus.emit({
73
+ type: "agent:error",
74
+ runId,
75
+ agentId,
76
+ payload: String(event.result ?? "Unknown error"),
77
+ timestamp: Date.now(),
78
+ });
79
+ }
80
+ crewEventBus.emit({
81
+ type: "agent:progress",
82
+ runId,
83
+ agentId,
84
+ payload: { ...progress },
85
+ timestamp: Date.now(),
86
+ });
87
+ break;
88
+
89
+ case "turn_start":
90
+ progress.turns++;
91
+ break;
92
+
93
+ case "agent_end":
94
+ progress.status = "completed";
95
+ crewEventBus.emit({
96
+ type: "agent:complete",
97
+ runId,
98
+ agentId,
99
+ payload: { ...progress },
100
+ timestamp: Date.now(),
101
+ });
102
+ break;
103
+
104
+ case "agent_start":
105
+ progress.status = "running";
106
+ break;
107
+ }
108
+ }
109
+
110
+ untrack(agentId: string): void {
111
+ const tracked = this.sessions.get(agentId);
112
+ if (tracked) {
113
+ tracked.unsubscribe();
114
+ this.sessions.delete(agentId);
115
+ }
116
+ }
117
+
118
+ getProgress(agentId: string): AgentProgress | undefined {
119
+ return this.sessions.get(agentId)?.progress;
120
+ }
121
+ }
122
+
123
+ // Export singleton instance
124
+ export const globalProgressTracker = new ProgressTracker();