pi-crew 0.1.32 → 0.1.34

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 (45) hide show
  1. package/docs/architecture.md +3 -3
  2. package/docs/research-phase8-operator-experience-plan.md +819 -0
  3. package/docs/research-phase9-observability-reliability-plan.md +1190 -0
  4. package/docs/research-ui-optimization-plan.md +480 -0
  5. package/package.json +1 -1
  6. package/schema.json +14 -0
  7. package/src/config/config.ts +69 -0
  8. package/src/config/defaults.ts +7 -0
  9. package/src/extension/autonomous-policy.ts +56 -2
  10. package/src/extension/notification-router.ts +116 -0
  11. package/src/extension/notification-sink.ts +51 -0
  12. package/src/extension/register.ts +133 -35
  13. package/src/extension/registration/commands.ts +110 -3
  14. package/src/extension/registration/team-tool.ts +5 -2
  15. package/src/extension/registration/viewers.ts +3 -1
  16. package/src/extension/team-recommendation.ts +16 -8
  17. package/src/runtime/child-pi.ts +1 -0
  18. package/src/runtime/diagnostic-export.ts +107 -0
  19. package/src/runtime/pi-spawn.ts +4 -1
  20. package/src/runtime/task-packet.ts +11 -2
  21. package/src/runtime/task-runner/prompt-builder.ts +3 -0
  22. package/src/schema/config-schema.ts +11 -0
  23. package/src/ui/crew-widget.ts +350 -285
  24. package/src/ui/dashboard-panes/agents-pane.ts +25 -0
  25. package/src/ui/dashboard-panes/health-pane.ts +30 -0
  26. package/src/ui/dashboard-panes/mailbox-pane.ts +10 -0
  27. package/src/ui/dashboard-panes/progress-pane.ts +14 -0
  28. package/src/ui/dashboard-panes/transcript-pane.ts +10 -0
  29. package/src/ui/heartbeat-aggregator.ts +53 -0
  30. package/src/ui/keybinding-map.ts +92 -0
  31. package/src/ui/live-run-sidebar.ts +20 -8
  32. package/src/ui/overlays/agent-picker-overlay.ts +57 -0
  33. package/src/ui/overlays/confirm-overlay.ts +58 -0
  34. package/src/ui/overlays/mailbox-compose-overlay.ts +144 -0
  35. package/src/ui/overlays/mailbox-compose-preview.ts +63 -0
  36. package/src/ui/overlays/mailbox-detail-overlay.ts +122 -0
  37. package/src/ui/pi-ui-compat.ts +57 -0
  38. package/src/ui/powerbar-publisher.ts +128 -94
  39. package/src/ui/render-scheduler.ts +103 -0
  40. package/src/ui/run-action-dispatcher.ts +107 -0
  41. package/src/ui/run-dashboard.ts +418 -372
  42. package/src/ui/run-snapshot-cache.ts +359 -0
  43. package/src/ui/snapshot-types.ts +47 -0
  44. package/src/ui/transcript-cache.ts +94 -0
  45. package/src/ui/transcript-viewer.ts +316 -302
@@ -5,22 +5,61 @@ import { allTeams, discoverTeams } from "../teams/discover-teams.ts";
5
5
  import { allWorkflows, discoverWorkflows } from "../workflows/discover-workflows.ts";
6
6
 
7
7
  const DEFAULT_MAGIC_KEYWORDS: Record<string, string[]> = {
8
- implementation: ["autoteam", "team:", "implementation-team"],
8
+ implementation: ["autoteam", "team:", "implementation-team", "pi-crew", "dùng team", "use team"],
9
9
  review: ["review-team", "security review", "code review"],
10
10
  fastFix: ["fast-fix", "quick fix"],
11
11
  research: ["research-team", "deep research"],
12
12
  };
13
13
 
14
+ const BULLET_OR_NUMBERED_TASK_RE = /^\s*(?:[-*•]|\d+[.)])\s+\S+/;
15
+ const ACTIONABLE_TASK_TERMS: readonly string[] = Array.from(new Set([
16
+ "implement",
17
+ "refactor",
18
+ "migrate",
19
+ "fix",
20
+ "add",
21
+ "update",
22
+ "test",
23
+ "review",
24
+ "research",
25
+ "analyze",
26
+ "document",
27
+ "docs",
28
+ "sửa",
29
+ "thêm",
30
+ "cập nhật",
31
+ "kiểm thử",
32
+ "nghiên cứu",
33
+ "phân tích",
34
+ "viết docs",
35
+ ]));
36
+
14
37
  function mergeMagicKeywords(configured: Record<string, string[]> | undefined): Record<string, string[]> {
15
38
  return { ...DEFAULT_MAGIC_KEYWORDS, ...(configured ?? {}) };
16
39
  }
17
40
 
41
+ function actionableLineCount(prompt: string): number {
42
+ return prompt
43
+ .split(/\r?\n/)
44
+ .map((line) => line.trim())
45
+ .filter((line) => BULLET_OR_NUMBERED_TASK_RE.test(line) && ACTIONABLE_TASK_TERMS.some((term) => line.toLowerCase().includes(term)))
46
+ .length;
47
+ }
48
+
49
+ function hasTaskListSignal(prompt: string): boolean {
50
+ const lower = prompt.toLowerCase();
51
+ const bulletCount = prompt.split(/\r?\n/).filter((line) => BULLET_OR_NUMBERED_TASK_RE.test(line)).length;
52
+ const explicitList = ["các task", "danh sách task", "todo", "tasks sau", "task list", "làm lần lượt"].some((term) => lower.includes(term));
53
+ return bulletCount >= 3 || actionableLineCount(prompt) >= 2 || (explicitList && bulletCount >= 2);
54
+ }
55
+
18
56
  export function detectTeamIntent(prompt: string, config: PiTeamsAutonomousConfig = {}): string[] {
19
57
  const lower = prompt.toLowerCase();
20
58
  const matches: string[] = [];
21
59
  for (const [intent, keywords] of Object.entries(mergeMagicKeywords(config.magicKeywords))) {
22
60
  if (keywords.some((keyword) => lower.includes(keyword.toLowerCase()))) matches.push(intent);
23
61
  }
62
+ if (hasTaskListSignal(prompt) && !matches.includes("taskList")) matches.push("taskList");
24
63
  return matches;
25
64
  }
26
65
 
@@ -39,8 +78,15 @@ export function buildAutonomousPolicy(prompt: string, config: PiTeamsAutonomousC
39
78
  `Autonomy profile: ${effective.profile}.`,
40
79
  "You have access to the `team` tool for coordinated multi-agent work. Use it proactively when the task benefits from specialized roles, planning, review, verification, durable artifacts, async execution, or worktree isolation.",
41
80
  "",
81
+ "Decision framework (not keyword-only):",
82
+ "- Treat a user-supplied task list with 2+ actionable bullets/numbered items as a delegation candidate even when no pi-crew keyword appears.",
83
+ "- Prefer `team` when tasks span multiple files/subsystems, require sequencing, combine implementation + tests/docs/review, or need independent exploration before edits.",
84
+ "- If unsure whether subtasks conflict, call `team` with action='recommend' first instead of manually splitting work.",
85
+ "- For assisted/aggressive autonomy and non-trivial multi-task work, prefer a team run or plan over direct single-agent execution.",
86
+ "",
42
87
  "Use `team` automatically when:",
43
88
  "- The task spans multiple files, subsystems, or unclear code areas.",
89
+ "- The prompt contains a non-trivial task list, roadmap, checklist, migration plan, or ordered implementation plan.",
44
90
  "- The task requires planning before implementation.",
45
91
  "- The task asks for implementation plus tests, review, verification, migration, architecture, security review, or debugging.",
46
92
  "- The task would benefit from explorer/planner/executor/reviewer/verifier roles.",
@@ -48,6 +94,7 @@ export function buildAutonomousPolicy(prompt: string, config: PiTeamsAutonomousC
48
94
  "Do not use `team` when:",
49
95
  "- The user asks a simple factual question or tiny single-file edit.",
50
96
  "- The user explicitly asks you to work directly without delegation.",
97
+ "- The tasks clearly modify the same small file region and can be completed safer by one agent without parallel fanout.",
51
98
  "- The action is destructive (`delete`, `forget`, `prune`, forced cleanup) and the user has not explicitly confirmed it.",
52
99
  "",
53
100
  "Recommended mappings:",
@@ -60,9 +107,16 @@ export function buildAutonomousPolicy(prompt: string, config: PiTeamsAutonomousC
60
107
  "- Before claiming delegated work is complete, inspect the run with action='status' or action='summary'.",
61
108
  "- Unsure or risky work -> action='plan' first, then run the selected team.",
62
109
  "",
110
+ "Conflict-safe task splitting:",
111
+ "- Do not parallelize subtasks that may edit the same file, same symbol, same migration path, package manifest, lockfile, or generated schema unless a planner explicitly sequences them.",
112
+ "- For potential overlap, use plan/recommend first, assign one owner per file/symbol, and require workers to report intended changed files before editing.",
113
+ "- Prefer workspaceMode: 'worktree' for parallel implementation in clean git repositories, but still avoid merging overlapping edits without review.",
114
+ "- If workers discover overlap, blockers, missing requirements, or need leader decisions, they must use mailbox/status artifacts to ask the leader/orchestrator and pause risky edits.",
115
+ "- The leader should resolve conflicts by sequencing, narrowing scope, or reassigning ownership before continuing.",
116
+ "",
63
117
  asyncGuidance,
64
118
  worktreeGuidance,
65
- intents.length > 0 ? `Detected pi-crew routing keywords/intents in the user prompt: ${intents.join(", ")}. Consider the matching team workflow if appropriate.` : "No explicit pi-crew magic keyword was detected; decide based on task complexity and risk.",
119
+ intents.length > 0 ? `Detected pi-crew routing signals/intents in the user prompt: ${intents.join(", ")}. Consider the matching team workflow if appropriate.` : "No explicit pi-crew routing signal was detected; decide based on complexity, risk, task-list structure, and conflict potential.",
66
120
  ].join("\n");
67
121
  }
68
122
 
@@ -0,0 +1,116 @@
1
+ export type Severity = "info" | "warning" | "error" | "critical";
2
+
3
+ export interface NotificationDescriptor {
4
+ id?: string;
5
+ severity: Severity;
6
+ source: string;
7
+ runId?: string;
8
+ title: string;
9
+ body?: string;
10
+ timestamp?: number;
11
+ }
12
+
13
+ export interface NotificationRouterOptions {
14
+ dedupWindowMs?: number;
15
+ batchWindowMs?: number;
16
+ quietHours?: string;
17
+ severityFilter?: Severity[];
18
+ sink?: (notification: NotificationDescriptor) => void;
19
+ now?: () => number;
20
+ }
21
+
22
+ const DEFAULT_SEVERITY_FILTER: Severity[] = ["warning", "error", "critical"];
23
+ const SEVERITY_RANK: Record<Severity, number> = { info: 0, warning: 1, error: 2, critical: 3 };
24
+
25
+ export function parseHHMMRange(range: string): { startMin: number; endMin: number } {
26
+ const match = /^(\d{2}):(\d{2})-(\d{2}):(\d{2})$/.exec(range);
27
+ if (!match) throw new Error(`Invalid quiet-hours range '${range}'. Expected HH:MM-HH:MM.`);
28
+ const [, sh, sm, eh, em] = match;
29
+ const startHour = Number(sh);
30
+ const startMinute = Number(sm);
31
+ const endHour = Number(eh);
32
+ const endMinute = Number(em);
33
+ if (startHour > 23 || endHour > 23 || startMinute > 59 || endMinute > 59) throw new Error(`Invalid quiet-hours range '${range}'.`);
34
+ return { startMin: startHour * 60 + startMinute, endMin: endHour * 60 + endMinute };
35
+ }
36
+
37
+ export function isInQuietHours(range: string, now = new Date()): boolean {
38
+ const { startMin, endMin } = parseHHMMRange(range);
39
+ const current = now.getHours() * 60 + now.getMinutes();
40
+ if (startMin === endMin) return false;
41
+ return startMin <= endMin ? current >= startMin && current < endMin : current >= startMin || current < endMin;
42
+ }
43
+
44
+ function notificationKey(notification: NotificationDescriptor): string {
45
+ return notification.id ?? `${notification.source}:${notification.runId ?? "global"}:${notification.title}`;
46
+ }
47
+
48
+ function batchSeverity(items: NotificationDescriptor[]): Severity {
49
+ return items.reduce((highest, item) => SEVERITY_RANK[item.severity] > SEVERITY_RANK[highest] ? item.severity : highest, "info" as Severity);
50
+ }
51
+
52
+ export class NotificationRouter {
53
+ private readonly opts: NotificationRouterOptions;
54
+ private readonly deliver: (notification: NotificationDescriptor) => void;
55
+ private readonly seen = new Map<string, number>();
56
+ private batch: NotificationDescriptor[] = [];
57
+ private timer: ReturnType<typeof setTimeout> | undefined;
58
+
59
+ constructor(opts: NotificationRouterOptions = {}, deliver: (notification: NotificationDescriptor) => void) {
60
+ this.opts = opts;
61
+ this.deliver = deliver;
62
+ }
63
+
64
+ enqueue(notification: NotificationDescriptor): boolean {
65
+ const now = this.opts.now?.() ?? Date.now();
66
+ const withTime = { ...notification, timestamp: notification.timestamp ?? now };
67
+ try {
68
+ this.opts.sink?.(withTime);
69
+ } catch {
70
+ // Notification delivery must never crash the extension.
71
+ }
72
+ const filter = this.opts.severityFilter ?? DEFAULT_SEVERITY_FILTER;
73
+ if (!filter.includes(withTime.severity)) return false;
74
+ if (this.opts.quietHours && isInQuietHours(this.opts.quietHours, new Date(now))) return false;
75
+ const key = notificationKey(withTime);
76
+ const dedupWindow = this.opts.dedupWindowMs ?? 30_000;
77
+ const previous = this.seen.get(key);
78
+ if (previous !== undefined && now - previous < dedupWindow) return false;
79
+ this.seen.set(key, now);
80
+ const batchWindow = this.opts.batchWindowMs ?? 0;
81
+ if (batchWindow <= 0) {
82
+ this.deliver(withTime);
83
+ return true;
84
+ }
85
+ this.batch.push(withTime);
86
+ if (!this.timer) this.timer = setTimeout(() => this.flush(), batchWindow);
87
+ return true;
88
+ }
89
+
90
+ flush(): void {
91
+ if (this.timer) clearTimeout(this.timer);
92
+ this.timer = undefined;
93
+ if (this.batch.length === 0) return;
94
+ const items = this.batch;
95
+ this.batch = [];
96
+ if (items.length === 1) {
97
+ this.deliver(items[0]!);
98
+ return;
99
+ }
100
+ this.deliver({
101
+ id: `batch:${items.map((item) => notificationKey(item)).join(",")}`,
102
+ severity: batchSeverity(items),
103
+ source: "batch",
104
+ title: `${items.length} pi-crew notifications`,
105
+ body: items.map((item) => `• ${item.title}`).join("\n"),
106
+ timestamp: this.opts.now?.() ?? Date.now(),
107
+ });
108
+ }
109
+
110
+ dispose(): void {
111
+ if (this.timer) clearTimeout(this.timer);
112
+ this.timer = undefined;
113
+ this.batch = [];
114
+ this.seen.clear();
115
+ }
116
+ }
@@ -0,0 +1,51 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import type { NotificationDescriptor } from "./notification-router.ts";
4
+ import { redactSecrets } from "../runtime/diagnostic-export.ts";
5
+ import { logInternalError } from "../utils/internal-error.ts";
6
+
7
+ export interface NotificationSink {
8
+ write(notification: NotificationDescriptor): void;
9
+ dispose(): void;
10
+ }
11
+
12
+ function rotateOldFiles(dir: string, retentionDays: number, now = Date.now()): void {
13
+ if (!fs.existsSync(dir)) return;
14
+ const cutoff = now - retentionDays * 24 * 60 * 60 * 1000;
15
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
16
+ if (!entry.isFile() || !entry.name.endsWith(".jsonl")) continue;
17
+ const filePath = path.join(dir, entry.name);
18
+ try {
19
+ if (fs.statSync(filePath).mtimeMs < cutoff) fs.unlinkSync(filePath);
20
+ } catch (error) {
21
+ logInternalError("notification-sink.rotate", error, filePath);
22
+ }
23
+ }
24
+ }
25
+
26
+ export function createJsonlSink(crewRoot: string, retentionDays = 7): NotificationSink {
27
+ const dir = path.join(crewRoot, "state", "notifications");
28
+ let lastRotateDate = "";
29
+ return {
30
+ write(notification: NotificationDescriptor): void {
31
+ try {
32
+ const timestamp = notification.timestamp ?? Date.now();
33
+ const date = new Date(timestamp).toISOString().slice(0, 10);
34
+ if (date !== lastRotateDate) {
35
+ rotateOldFiles(dir, retentionDays, timestamp);
36
+ lastRotateDate = date;
37
+ }
38
+ fs.mkdirSync(dir, { recursive: true });
39
+ const payload = redactSecrets({ ...notification, timestamp }) as NotificationDescriptor;
40
+ fs.appendFileSync(path.join(dir, `${date}.jsonl`), `${JSON.stringify(payload)}\n`, "utf-8");
41
+ } catch (error) {
42
+ logInternalError("notification-sink.write", error);
43
+ }
44
+ },
45
+ dispose(): void {
46
+ // Synchronous append-only sink has no resources to close.
47
+ },
48
+ };
49
+ }
50
+
51
+ export const __test__ = { rotateOldFiles };
@@ -11,7 +11,7 @@ import { loadRunManifestById, updateRunStatus } from "../state/state-store.ts";
11
11
  import { terminateActiveChildPiProcesses } from "../subagents/spawn.ts";
12
12
  import { SubagentManager } from "../subagents/manager.ts";
13
13
  import { __test__subagentSpawnParams, sendFollowUp } from "./registration/subagent-helpers.ts";
14
- import { DEFAULT_UI } from "../config/defaults.ts";
14
+ import { DEFAULT_NOTIFICATIONS, DEFAULT_UI } from "../config/defaults.ts";
15
15
  import { logInternalError } from "../utils/internal-error.ts";
16
16
  import { createManifestCache } from "../runtime/manifest-cache.ts";
17
17
  import { resetTimings, time } from "../utils/timings.ts";
@@ -20,6 +20,13 @@ import { registerSubagentTools } from "./registration/subagent-tools.ts";
20
20
  import { runArtifactCleanup } from "./registration/artifact-cleanup.ts";
21
21
  import { registerTeamTool } from "./registration/team-tool.ts";
22
22
  import { registerCompactionGuard } from "./registration/compaction-guard.ts";
23
+ import { requestRender, setExtensionWidget, setWorkingIndicator, showCustom } from "../ui/pi-ui-compat.ts";
24
+ import { createRunSnapshotCache } from "../ui/run-snapshot-cache.ts";
25
+ import { RenderScheduler } from "../ui/render-scheduler.ts";
26
+ import { NotificationRouter, type NotificationDescriptor } from "./notification-router.ts";
27
+ import { createJsonlSink, type NotificationSink } from "./notification-sink.ts";
28
+ import { projectCrewRoot } from "../utils/paths.ts";
29
+ import { summarizeHeartbeats } from "../ui/heartbeat-aggregator.ts";
23
30
 
24
31
  export { __test__subagentSpawnParams };
25
32
 
@@ -42,14 +49,58 @@ export function registerPiTeams(pi: ExtensionAPI): void {
42
49
  let rpcHandle: PiCrewRpcHandle | undefined;
43
50
  let cleanedUp = false;
44
51
  let manifestCache = createManifestCache(process.cwd());
52
+ let runSnapshotCache = createRunSnapshotCache(process.cwd());
53
+ let cacheCwd = process.cwd();
45
54
  const getManifestCache = (cwd: string): ReturnType<typeof createManifestCache> => {
46
- if (manifestCache && currentCtx?.cwd === cwd) return manifestCache;
55
+ if (manifestCache && cacheCwd === cwd) return manifestCache;
47
56
  if (manifestCache) manifestCache.dispose();
57
+ if (runSnapshotCache) runSnapshotCache.dispose?.();
58
+ cacheCwd = cwd;
48
59
  manifestCache = createManifestCache(cwd);
60
+ runSnapshotCache = createRunSnapshotCache(cwd);
49
61
  return manifestCache;
50
62
  };
63
+ const getRunSnapshotCache = (cwd: string): ReturnType<typeof createRunSnapshotCache> => {
64
+ if (cacheCwd !== cwd) getManifestCache(cwd);
65
+ return runSnapshotCache;
66
+ };
51
67
  const telemetryEnabled = (): boolean => loadConfig(currentCtx?.cwd ?? process.cwd()).config.telemetry?.enabled !== false;
52
68
  const widgetState: CrewWidgetState = { frame: 0 };
69
+ let notificationSink: NotificationSink | undefined;
70
+ let notificationRouter: NotificationRouter | undefined;
71
+ const configureNotifications = (ctx: ExtensionContext): void => {
72
+ notificationRouter?.dispose();
73
+ notificationSink?.dispose();
74
+ notificationRouter = undefined;
75
+ notificationSink = undefined;
76
+ const config = loadConfig(ctx.cwd).config;
77
+ if (config.notifications?.enabled === false) return;
78
+ if (config.telemetry?.enabled !== false) notificationSink = createJsonlSink(projectCrewRoot(ctx.cwd), config.notifications?.sinkRetentionDays ?? DEFAULT_NOTIFICATIONS.sinkRetentionDays);
79
+ notificationRouter = new NotificationRouter({
80
+ dedupWindowMs: config.notifications?.dedupWindowMs ?? DEFAULT_NOTIFICATIONS.dedupWindowMs,
81
+ batchWindowMs: config.notifications?.batchWindowMs ?? DEFAULT_NOTIFICATIONS.batchWindowMs,
82
+ quietHours: config.notifications?.quietHours,
83
+ severityFilter: config.notifications?.severityFilter ?? [...DEFAULT_NOTIFICATIONS.severityFilter],
84
+ sink: (notification) => notificationSink?.write(notification),
85
+ }, (notification) => {
86
+ widgetState.notificationCount = (widgetState.notificationCount ?? 0) + 1;
87
+ sendFollowUp(pi, [notification.title, notification.body, notification.runId ? `Run: ${notification.runId}` : undefined].filter((line): line is string => Boolean(line)).join("\n"));
88
+ if (currentCtx) {
89
+ const uiConfig = loadConfig(currentCtx.cwd).config.ui;
90
+ updateCrewWidget(currentCtx, widgetState, uiConfig, getManifestCache(currentCtx.cwd), getRunSnapshotCache(currentCtx.cwd));
91
+ updatePiCrewPowerbar(pi.events, currentCtx.cwd, uiConfig, getManifestCache(currentCtx.cwd), getRunSnapshotCache(currentCtx.cwd), currentCtx, widgetState.notificationCount ?? 0);
92
+ }
93
+ });
94
+ };
95
+ const autoRecoveryLast = new Map<string, number>();
96
+ const notifyOperator = (notification: NotificationDescriptor): void => {
97
+ try {
98
+ notificationRouter?.enqueue(notification);
99
+ } catch (error) {
100
+ logInternalError("register.notification", error);
101
+ sendFollowUp(pi, [notification.title, notification.body].filter((line): line is string => Boolean(line)).join("\n"));
102
+ }
103
+ };
53
104
  const subagentManager = new SubagentManager(
54
105
  4,
55
106
  (record) => {
@@ -68,7 +119,7 @@ export function registerPiTeams(pi: ExtensionAPI): void {
68
119
  }
69
120
  if (!record.background || record.resultConsumed) return;
70
121
  if (record.status === "completed" || record.status === "failed" || record.status === "cancelled" || record.status === "blocked" || record.status === "error") {
71
- sendFollowUp(pi, [`pi-crew subagent ${record.id} ${record.status}.`, record.runId ? `Run: ${record.runId}` : undefined, `Use get_subagent_result with agent_id=${record.id} for output.`].filter((line): line is string => Boolean(line)).join("\n"));
122
+ notifyOperator({ id: `subagent:${record.id}:${record.status}`, severity: record.status === "completed" ? "info" : "warning", source: "subagent-completed", runId: record.runId, title: `pi-crew subagent ${record.id} ${record.status}.`, body: `Use get_subagent_result with agent_id=${record.id} for output.` });
72
123
  }
73
124
  },
74
125
  1000,
@@ -77,25 +128,24 @@ export function registerPiTeams(pi: ExtensionAPI): void {
77
128
  const id = typeof payload.id === "string" ? payload.id : "unknown";
78
129
  const runId = typeof payload.runId === "string" ? payload.runId : "unknown";
79
130
  const durationMs = typeof payload.durationMs === "number" ? payload.durationMs : 0;
80
- sendFollowUp(pi, [`pi-crew subagent ${id} may be stuck in blocked state for ${Math.max(1, Math.round(durationMs / 1000))}s.`, `Run: ${runId}`, `Use team status runId=${runId} and investigate.`, "Subagent may need manual intervention."].filter((line): line is string => Boolean(line)).join("\n"));
131
+ notifyOperator({ id: `subagent-stuck:${id}:${runId}`, severity: "warning", source: "subagent-stuck", runId, title: `pi-crew subagent ${id} may be stuck in blocked state for ${Math.max(1, Math.round(durationMs / 1000))}s.`, body: `Use team status runId=${runId} and investigate.\nSubagent may need manual intervention.` });
81
132
  }
82
133
  pi.events?.emit?.(event, payload);
83
134
  },
84
135
  );
85
136
  const foregroundControllers = new Set<AbortController>();
86
137
  let liveSidebarRunId: string | undefined;
87
- let liveSidebarTimer: ReturnType<typeof setInterval> | undefined;
88
- const requestRender = (ctx: ExtensionContext): void => (ctx.ui as { requestRender?: () => void }).requestRender?.();
138
+ let renderScheduler: RenderScheduler | undefined;
89
139
  const stopSessionBoundSubagents = (): void => {
90
140
  for (const controller of foregroundControllers) controller.abort();
91
141
  foregroundControllers.clear();
92
142
  subagentManager.abortAll();
93
143
  terminateActiveChildPiProcesses();
94
- if (liveSidebarTimer) clearInterval(liveSidebarTimer);
95
- liveSidebarTimer = undefined;
144
+ renderScheduler?.dispose();
145
+ renderScheduler = undefined;
96
146
  liveSidebarRunId = undefined;
97
147
  if (currentCtx) stopCrewWidget(currentCtx, widgetState, loadConfig(currentCtx.cwd).config.ui);
98
- clearPiCrewPowerbar(pi.events);
148
+ clearPiCrewPowerbar(pi.events, currentCtx);
99
149
  };
100
150
  const openLiveSidebar = (ctx: ExtensionContext, runId: string): void => {
101
151
  const uiConfig = loadConfig(ctx.cwd).config.ui;
@@ -103,28 +153,28 @@ export function registerPiTeams(pi: ExtensionAPI): void {
103
153
  const foregroundAutoOpen = uiConfig?.autoOpenDashboardForForegroundRuns !== false;
104
154
  if (!ctx.hasUI || !autoOpen || !foregroundAutoOpen || (uiConfig?.dashboardPlacement ?? "right") !== "right") return;
105
155
  if (liveSidebarRunId === runId) return;
106
- if (liveSidebarTimer) clearInterval(liveSidebarTimer);
107
156
  liveSidebarRunId = runId;
108
- ctx.ui.setWidget("pi-crew", undefined, { placement: uiConfig?.widgetPlacement ?? "aboveEditor" });
109
- ctx.ui.setWidget("pi-crew-active", undefined, { placement: uiConfig?.widgetPlacement ?? "aboveEditor" });
157
+ const widgetPlacement = uiConfig?.widgetPlacement ?? "aboveEditor";
158
+ setExtensionWidget(ctx, "pi-crew", undefined, { placement: widgetPlacement });
159
+ setExtensionWidget(ctx, "pi-crew-active", undefined, { placement: widgetPlacement });
160
+ widgetState.lastVisibility = "hidden";
161
+ widgetState.lastPlacement = widgetPlacement;
162
+ widgetState.lastKey = "pi-crew-active";
163
+ widgetState.model = undefined;
110
164
  const width = Math.min(90, Math.max(40, uiConfig?.dashboardWidth ?? 56));
111
- liveSidebarTimer = setInterval(() => requestRender(ctx), uiConfig?.dashboardLiveRefreshMs ?? DEFAULT_UI.refreshMs);
112
- liveSidebarTimer.unref?.();
113
- void ctx.ui.custom<undefined>((_tui, theme, _keybindings, done) => new LiveRunSidebar({ cwd: ctx.cwd, runId, done, theme, config: uiConfig }), {
165
+ void showCustom<undefined>(ctx, (_tui, theme, _keybindings, done) => new LiveRunSidebar({ cwd: ctx.cwd, runId, done, theme, config: uiConfig, snapshotCache: getRunSnapshotCache(ctx.cwd) }), {
114
166
  overlay: true,
115
167
  overlayOptions: { width, minWidth: 40, maxHeight: "100%", anchor: "top-right", offsetX: 0, offsetY: 0, margin: { top: 0, right: 0, bottom: 0, left: 0 }, visible: (termWidth: number) => termWidth >= 100 },
116
168
  }).finally(() => {
117
169
  if (liveSidebarRunId === runId) liveSidebarRunId = undefined;
118
- if (liveSidebarTimer) clearInterval(liveSidebarTimer);
119
- liveSidebarTimer = undefined;
120
- updateCrewWidget(ctx, widgetState, loadConfig(ctx.cwd).config.ui, getManifestCache(ctx.cwd));
170
+ updateCrewWidget(ctx, widgetState, loadConfig(ctx.cwd).config.ui, getManifestCache(ctx.cwd), getRunSnapshotCache(ctx.cwd));
121
171
  });
122
172
  };
123
173
  const startForegroundRun = (ctx: ExtensionContext, runner: (signal?: AbortSignal) => Promise<void>, runId?: string): void => {
124
174
  const controller = new AbortController();
125
175
  foregroundControllers.add(controller);
126
176
  if (ctx.hasUI) {
127
- (ctx.ui as { setWorkingIndicator?: (options?: { frames?: string[]; intervalMs?: number }) => void }).setWorkingIndicator?.({ frames: ["⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"], intervalMs: 80 });
177
+ setWorkingIndicator(ctx, { frames: ["⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"], intervalMs: 80 });
128
178
  ctx.ui.setWorkingMessage(runId ? `pi-crew foreground run ${runId}...` : "pi-crew foreground run...");
129
179
  }
130
180
  setImmediate(() => {
@@ -144,7 +194,7 @@ export function registerPiTeams(pi: ExtensionAPI): void {
144
194
  .finally(() => {
145
195
  foregroundControllers.delete(controller);
146
196
  if (ctx.hasUI) {
147
- (ctx.ui as { setWorkingIndicator?: (options?: { frames?: string[]; intervalMs?: number }) => void }).setWorkingIndicator?.();
197
+ setWorkingIndicator(ctx);
148
198
  ctx.ui.setWorkingMessage();
149
199
  }
150
200
  if (runId) {
@@ -177,8 +227,8 @@ export function registerPiTeams(pi: ExtensionAPI): void {
177
227
  }
178
228
  if (currentCtx) {
179
229
  const config = loadConfig(currentCtx.cwd).config.ui;
180
- updateCrewWidget(currentCtx, widgetState, config, getManifestCache(currentCtx.cwd));
181
- updatePiCrewPowerbar(pi.events, currentCtx.cwd, config, getManifestCache(currentCtx.cwd));
230
+ updateCrewWidget(currentCtx, widgetState, config, getManifestCache(currentCtx.cwd), getRunSnapshotCache(currentCtx.cwd));
231
+ updatePiCrewPowerbar(pi.events, currentCtx.cwd, config, getManifestCache(currentCtx.cwd), getRunSnapshotCache(currentCtx.cwd), currentCtx, widgetState.notificationCount ?? 0);
182
232
  }
183
233
  });
184
234
  });
@@ -194,8 +244,16 @@ export function registerPiTeams(pi: ExtensionAPI): void {
194
244
  stopSessionBoundSubagents();
195
245
  stopAsyncRunNotifier(notifierState);
196
246
  stopCrewWidget(currentCtx, widgetState, currentCtx ? loadConfig(currentCtx.cwd).config.ui : undefined);
197
- clearPiCrewPowerbar(pi.events);
247
+ clearPiCrewPowerbar(pi.events, currentCtx);
198
248
  manifestCache.dispose();
249
+ runSnapshotCache.dispose?.();
250
+ renderScheduler?.dispose();
251
+ renderScheduler = undefined;
252
+ autoRecoveryLast.clear();
253
+ notificationRouter?.dispose();
254
+ notificationSink?.dispose();
255
+ notificationRouter = undefined;
256
+ notificationSink = undefined;
199
257
  rpcHandle?.unsubscribe();
200
258
  rpcHandle = undefined;
201
259
  currentCtx = undefined;
@@ -212,24 +270,57 @@ export function registerPiTeams(pi: ExtensionAPI): void {
212
270
  widgetState.interval = undefined;
213
271
  notifyActiveRuns(ctx);
214
272
  const loadedConfig = loadConfig(ctx.cwd);
273
+ autoRecoveryLast.clear();
274
+ configureNotifications(ctx);
215
275
  registerPiCrewPowerbarSegments(pi.events, loadedConfig.config.ui);
216
276
  startAsyncRunNotifier(ctx, notifierState, loadedConfig.config.notifierIntervalMs ?? DEFAULT_UI.notifierIntervalMs);
217
277
  const cache = getManifestCache(ctx.cwd);
218
- updateCrewWidget(ctx, widgetState, loadedConfig.config.ui, cache);
219
- updatePiCrewPowerbar(pi.events, ctx.cwd, loadedConfig.config.ui, cache);
220
- widgetState.interval = setInterval(() => {
278
+ updateCrewWidget(ctx, widgetState, loadedConfig.config.ui, cache, getRunSnapshotCache(ctx.cwd));
279
+ updatePiCrewPowerbar(pi.events, ctx.cwd, loadedConfig.config.ui, cache, getRunSnapshotCache(ctx.cwd), ctx, widgetState.notificationCount ?? 0);
280
+ renderScheduler?.dispose();
281
+ const renderTick = (): void => {
221
282
  if (!currentCtx) return;
222
283
  const config = loadConfig(currentCtx.cwd).config.ui;
223
- const cache = getManifestCache(currentCtx.cwd);
284
+ const activeCache = getManifestCache(currentCtx.cwd);
224
285
  if (liveSidebarRunId) {
225
- currentCtx.ui.setWidget("pi-crew", undefined, { placement: config?.widgetPlacement ?? "aboveEditor" });
226
- currentCtx.ui.setWidget("pi-crew-active", undefined, { placement: config?.widgetPlacement ?? "aboveEditor" });
286
+ const placement = config?.widgetPlacement ?? "aboveEditor";
287
+ if (widgetState.lastVisibility !== "hidden" || widgetState.lastPlacement !== placement) {
288
+ setExtensionWidget(currentCtx, "pi-crew", undefined, { placement });
289
+ setExtensionWidget(currentCtx, "pi-crew-active", undefined, { placement });
290
+ widgetState.lastVisibility = "hidden";
291
+ widgetState.lastPlacement = placement;
292
+ widgetState.lastKey = "pi-crew-active";
293
+ widgetState.model = undefined;
294
+ }
295
+ requestRender(currentCtx);
227
296
  } else {
228
- updateCrewWidget(currentCtx, widgetState, config, cache);
297
+ updateCrewWidget(currentCtx, widgetState, config, activeCache, getRunSnapshotCache(currentCtx.cwd));
298
+ }
299
+ updatePiCrewPowerbar(pi.events, currentCtx.cwd, config, activeCache, getRunSnapshotCache(currentCtx.cwd), currentCtx, widgetState.notificationCount ?? 0);
300
+ const now = Date.now();
301
+ for (const run of activeCache.list(20)) {
302
+ try {
303
+ const snapshot = getRunSnapshotCache(currentCtx.cwd).refreshIfStale(run.runId);
304
+ const summary = summarizeHeartbeats(snapshot, { now });
305
+ const maybeNotifyHealth = (kind: string, count: number, title: string, body: string): void => {
306
+ if (count <= 0) return;
307
+ const key = `${kind}_${run.runId}`;
308
+ const previous = autoRecoveryLast.get(key);
309
+ if (previous !== undefined && now - previous < 5 * 60_000) return;
310
+ autoRecoveryLast.set(key, now);
311
+ notifyOperator({ id: key, severity: "warning", source: "health", runId: run.runId, title, body });
312
+ };
313
+ maybeNotifyHealth("recovery_dead_workers", summary.dead, `Run ${run.runId} has ${summary.dead} dead worker(s).`, "Open /team-dashboard → 5 health → R recovery / K kill stale / D diagnostic.");
314
+ maybeNotifyHealth("recovery_missing_heartbeat", summary.missing, `Run ${run.runId} has ${summary.missing} worker(s) missing heartbeat.`, "Open /team-dashboard → 5 health → inspect health actions.");
315
+ } catch (error) {
316
+ logInternalError("register.health-notification", error, run.runId);
317
+ }
229
318
  }
230
- updatePiCrewPowerbar(pi.events, currentCtx.cwd, config, cache);
231
- }, DEFAULT_UI.widgetDefaultFrameMs);
232
- widgetState.interval.unref?.();
319
+ };
320
+ renderScheduler = new RenderScheduler(pi.events, renderTick, {
321
+ fallbackMs: loadedConfig.config.ui?.dashboardLiveRefreshMs ?? 750,
322
+ onInvalidate: () => getRunSnapshotCache(ctx.cwd).invalidate(),
323
+ });
233
324
  });
234
325
  pi.on("session_before_switch", () => stopSessionBoundSubagents());
235
326
  pi.on("session_shutdown", () => cleanupRuntime());
@@ -252,9 +343,16 @@ export function registerPiTeams(pi: ExtensionAPI): void {
252
343
  };
253
344
  });
254
345
 
255
- registerTeamTool(pi, { foregroundControllers, startForegroundRun, openLiveSidebar, getManifestCache, widgetState });
346
+ registerTeamTool(pi, { foregroundControllers, startForegroundRun, openLiveSidebar, getManifestCache, getRunSnapshotCache, widgetState });
256
347
  registerSubagentTools(pi, subagentManager);
257
348
  time("register.tools");
258
349
 
259
- registerTeamCommands(pi, { startForegroundRun, openLiveSidebar, getManifestCache });
350
+ registerTeamCommands(pi, { startForegroundRun, openLiveSidebar, getManifestCache, getRunSnapshotCache, dismissNotifications: () => {
351
+ widgetState.notificationCount = 0;
352
+ if (currentCtx) {
353
+ const uiConfig = loadConfig(currentCtx.cwd).config.ui;
354
+ updateCrewWidget(currentCtx, widgetState, uiConfig, getManifestCache(currentCtx.cwd), getRunSnapshotCache(currentCtx.cwd));
355
+ updatePiCrewPowerbar(pi.events, currentCtx.cwd, uiConfig, getManifestCache(currentCtx.cwd), getRunSnapshotCache(currentCtx.cwd), currentCtx, 0);
356
+ }
357
+ } });
260
358
  }