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.
- package/docs/architecture.md +3 -3
- package/docs/research-phase8-operator-experience-plan.md +819 -0
- package/docs/research-phase9-observability-reliability-plan.md +1190 -0
- package/docs/research-ui-optimization-plan.md +480 -0
- package/package.json +1 -1
- package/schema.json +14 -0
- package/src/config/config.ts +69 -0
- package/src/config/defaults.ts +7 -0
- package/src/extension/autonomous-policy.ts +56 -2
- package/src/extension/notification-router.ts +116 -0
- package/src/extension/notification-sink.ts +51 -0
- package/src/extension/register.ts +133 -35
- package/src/extension/registration/commands.ts +110 -3
- package/src/extension/registration/team-tool.ts +5 -2
- package/src/extension/registration/viewers.ts +3 -1
- package/src/extension/team-recommendation.ts +16 -8
- package/src/runtime/child-pi.ts +1 -0
- package/src/runtime/diagnostic-export.ts +107 -0
- package/src/runtime/pi-spawn.ts +4 -1
- package/src/runtime/task-packet.ts +11 -2
- package/src/runtime/task-runner/prompt-builder.ts +3 -0
- package/src/schema/config-schema.ts +11 -0
- package/src/ui/crew-widget.ts +350 -285
- package/src/ui/dashboard-panes/agents-pane.ts +25 -0
- package/src/ui/dashboard-panes/health-pane.ts +30 -0
- package/src/ui/dashboard-panes/mailbox-pane.ts +10 -0
- package/src/ui/dashboard-panes/progress-pane.ts +14 -0
- package/src/ui/dashboard-panes/transcript-pane.ts +10 -0
- package/src/ui/heartbeat-aggregator.ts +53 -0
- package/src/ui/keybinding-map.ts +92 -0
- package/src/ui/live-run-sidebar.ts +20 -8
- package/src/ui/overlays/agent-picker-overlay.ts +57 -0
- package/src/ui/overlays/confirm-overlay.ts +58 -0
- package/src/ui/overlays/mailbox-compose-overlay.ts +144 -0
- package/src/ui/overlays/mailbox-compose-preview.ts +63 -0
- package/src/ui/overlays/mailbox-detail-overlay.ts +122 -0
- package/src/ui/pi-ui-compat.ts +57 -0
- package/src/ui/powerbar-publisher.ts +128 -94
- package/src/ui/render-scheduler.ts +103 -0
- package/src/ui/run-action-dispatcher.ts +107 -0
- package/src/ui/run-dashboard.ts +418 -372
- package/src/ui/run-snapshot-cache.ts +359 -0
- package/src/ui/snapshot-types.ts +47 -0
- package/src/ui/transcript-cache.ts +94 -0
- 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
|
|
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 &&
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
95
|
-
|
|
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
|
-
|
|
109
|
-
ctx
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
284
|
+
const activeCache = getManifestCache(currentCtx.cwd);
|
|
224
285
|
if (liveSidebarRunId) {
|
|
225
|
-
|
|
226
|
-
|
|
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,
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
}
|