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
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { readDeliveryState, readMailbox, type MailboxMessage } from "../../state/mailbox.ts";
|
|
2
|
+
import { loadRunManifestById } from "../../state/state-store.ts";
|
|
3
|
+
import { pad, truncate } from "../../utils/visual.ts";
|
|
4
|
+
import { asCrewTheme, type CrewTheme } from "../theme-adapter.ts";
|
|
5
|
+
|
|
6
|
+
export type MailboxAction =
|
|
7
|
+
| { type: "ack"; messageId: string }
|
|
8
|
+
| { type: "nudge"; agentId?: string }
|
|
9
|
+
| { type: "compose" }
|
|
10
|
+
| { type: "ackAll" }
|
|
11
|
+
| { type: "close" };
|
|
12
|
+
|
|
13
|
+
export class MailboxDetailOverlay {
|
|
14
|
+
private readonly runId: string;
|
|
15
|
+
private readonly cwd: string;
|
|
16
|
+
private readonly done: (action: MailboxAction | undefined) => void;
|
|
17
|
+
private readonly theme: CrewTheme;
|
|
18
|
+
private inbox: MailboxMessage[] = [];
|
|
19
|
+
private outbox: MailboxMessage[] = [];
|
|
20
|
+
private side: "inbox" | "outbox" = "inbox";
|
|
21
|
+
private selected = 0;
|
|
22
|
+
private expanded = false;
|
|
23
|
+
|
|
24
|
+
constructor(opts: { runId: string; cwd: string; done: (action: MailboxAction | undefined) => void; theme?: unknown }) {
|
|
25
|
+
this.runId = opts.runId;
|
|
26
|
+
this.cwd = opts.cwd;
|
|
27
|
+
this.done = opts.done;
|
|
28
|
+
this.theme = asCrewTheme(opts.theme ?? {});
|
|
29
|
+
this.refresh();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
private refresh(): void {
|
|
33
|
+
const loaded = loadRunManifestById(this.cwd, this.runId);
|
|
34
|
+
if (!loaded) return;
|
|
35
|
+
const delivery = readDeliveryState(loaded.manifest).messages;
|
|
36
|
+
const applyDelivery = (message: MailboxMessage): MailboxMessage => ({ ...message, status: delivery[message.id] ?? message.status });
|
|
37
|
+
const taskIds = loaded.tasks.map((task) => task.id);
|
|
38
|
+
this.inbox = [...readMailbox(loaded.manifest, "inbox"), ...taskIds.flatMap((taskId) => readMailbox(loaded.manifest, "inbox", taskId))].map(applyDelivery).reverse();
|
|
39
|
+
this.outbox = [...readMailbox(loaded.manifest, "outbox"), ...taskIds.flatMap((taskId) => readMailbox(loaded.manifest, "outbox", taskId))].map(applyDelivery).reverse();
|
|
40
|
+
this.selected = Math.min(this.selected, Math.max(0, this.current().length - 1));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
private current(): MailboxMessage[] {
|
|
44
|
+
return this.side === "inbox" ? this.inbox : this.outbox;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
private selectedMessage(): MailboxMessage | undefined {
|
|
48
|
+
return this.current()[this.selected];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
invalidate(): void {
|
|
52
|
+
this.refresh();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
render(width: number): string[] {
|
|
56
|
+
this.refresh();
|
|
57
|
+
const inner = Math.max(40, width - 4);
|
|
58
|
+
const col = Math.max(18, Math.floor((inner - 3) / 2));
|
|
59
|
+
const lines = [
|
|
60
|
+
this.theme.bold(`Mailbox detail · ${this.runId}`),
|
|
61
|
+
"Tab side · ↑/↓ select · Enter expand · A ack · N nudge · C compose · X ack all · ESC close",
|
|
62
|
+
`${pad(this.theme.bold("Inbox"), col)} │ ${pad(this.theme.bold("Outbox"), col)}`,
|
|
63
|
+
];
|
|
64
|
+
const max = Math.max(this.inbox.length, this.outbox.length, 1);
|
|
65
|
+
for (let index = 0; index < Math.min(max, 12); index += 1) {
|
|
66
|
+
lines.push(`${this.row(this.inbox[index], "inbox", index, col)} │ ${this.row(this.outbox[index], "outbox", index, col)}`);
|
|
67
|
+
}
|
|
68
|
+
const selected = this.selectedMessage();
|
|
69
|
+
if (this.expanded && selected) {
|
|
70
|
+
lines.push("─".repeat(Math.min(inner, 72)));
|
|
71
|
+
lines.push(`${selected.from} → ${selected.to}${selected.taskId ? ` (${selected.taskId})` : ""} · ${selected.status}`);
|
|
72
|
+
lines.push(...selected.body.split(/\r?\n/).map((line) => truncate(line, inner)));
|
|
73
|
+
}
|
|
74
|
+
if (!this.inbox.length && !this.outbox.length) lines.push("Mailbox is empty.");
|
|
75
|
+
return lines.map((line) => truncate(line, inner));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
private row(message: MailboxMessage | undefined, side: "inbox" | "outbox", index: number, width: number): string {
|
|
79
|
+
if (!message) return pad("", width);
|
|
80
|
+
const marker = this.side === side && this.selected === index ? "›" : " ";
|
|
81
|
+
const status = message.status === "acknowledged" ? "✓" : "!";
|
|
82
|
+
return pad(truncate(`${marker}${status} ${message.from}->${message.to}: ${message.body.replace(/\s+/g, " ")}`, width), width);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
handleInput(data: string): void {
|
|
86
|
+
if (data === "\u001b" || data === "q") {
|
|
87
|
+
this.done({ type: "close" });
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (data === "\t") {
|
|
91
|
+
this.side = this.side === "inbox" ? "outbox" : "inbox";
|
|
92
|
+
this.selected = Math.min(this.selected, Math.max(0, this.current().length - 1));
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (data === "k" || data === "\u001b[A") {
|
|
96
|
+
this.selected = Math.max(0, this.selected - 1);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (data === "j" || data === "\u001b[B") {
|
|
100
|
+
this.selected = Math.min(Math.max(0, this.current().length - 1), this.selected + 1);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (data === "\r" || data === "\n") {
|
|
104
|
+
this.expanded = !this.expanded;
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (data === "A") {
|
|
108
|
+
const message = this.selectedMessage();
|
|
109
|
+
if (message) this.done({ type: "ack", messageId: message.id });
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (data === "N") {
|
|
113
|
+
this.done({ type: "nudge", agentId: this.selectedMessage()?.taskId });
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (data === "C") {
|
|
117
|
+
this.done({ type: "compose" });
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (data === "X") this.done({ type: "ackAll" });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
export interface WorkingIndicatorOptions {
|
|
4
|
+
frames?: string[];
|
|
5
|
+
intervalMs?: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
type UiContext = Pick<ExtensionContext, "ui">;
|
|
9
|
+
type ExtensionUi = ExtensionContext["ui"];
|
|
10
|
+
type WidgetContent = string[] | ((tui: unknown, theme: unknown) => unknown);
|
|
11
|
+
type WidgetOptions = Parameters<ExtensionUi["setWidget"]>[2];
|
|
12
|
+
type WidgetOptionsWithPersist = WidgetOptions & { persist?: boolean };
|
|
13
|
+
|
|
14
|
+
type CustomOptions = Parameters<ExtensionUi["custom"]>[1];
|
|
15
|
+
|
|
16
|
+
type CustomFactory<T> = (
|
|
17
|
+
tui: unknown,
|
|
18
|
+
theme: unknown,
|
|
19
|
+
keybindings: unknown,
|
|
20
|
+
done: (result: T) => void,
|
|
21
|
+
) => unknown;
|
|
22
|
+
type GenericCustom = <T>(factory: CustomFactory<T>, options?: CustomOptions) => Promise<T>;
|
|
23
|
+
|
|
24
|
+
function maybeRecord(value: unknown): Record<string, unknown> | undefined {
|
|
25
|
+
return value && typeof value === "object" ? (value as Record<string, unknown>) : undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function requestRender(ctx: UiContext): void {
|
|
29
|
+
requestRenderTarget(ctx.ui);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function requestRenderTarget(target: unknown): void {
|
|
33
|
+
const record = maybeRecord(target);
|
|
34
|
+
const fn = record?.requestRender;
|
|
35
|
+
if (typeof fn === "function") fn.call(target);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function setWorkingIndicator(ctx: UiContext, options?: WorkingIndicatorOptions): void {
|
|
39
|
+
const record = maybeRecord(ctx.ui);
|
|
40
|
+
const fn = record?.setWorkingIndicator;
|
|
41
|
+
if (typeof fn === "function") fn.call(ctx.ui, options);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function setExtensionWidget(ctx: UiContext, key: string, content: WidgetContent | undefined, options?: WidgetOptionsWithPersist): void {
|
|
45
|
+
const { persist: _persist, ...widgetOptions } = options ?? {};
|
|
46
|
+
ctx.ui.setWidget(key, content as never, widgetOptions as WidgetOptions);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function showCustom<T>(ctx: UiContext, factory: CustomFactory<T>, options?: CustomOptions): Promise<T> {
|
|
50
|
+
const custom = ctx.ui.custom as unknown as GenericCustom;
|
|
51
|
+
return custom<T>(factory, options);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function setStatusFallback(ctx: UiContext, key: string, lines: string | readonly string[] | undefined, segment?: string): void {
|
|
55
|
+
const text = typeof lines === "string" ? lines : lines ? [...lines].join("\n") : undefined;
|
|
56
|
+
ctx.ui.setStatus(segment ? `${key}:${segment}` : key, text);
|
|
57
|
+
}
|
|
@@ -1,94 +1,128 @@
|
|
|
1
|
-
import * as fs from "node:fs";
|
|
2
|
-
import { listRecentRuns } from "../extension/run-index.ts";
|
|
3
|
-
import type { CrewUiConfig } from "../config/config.ts";
|
|
4
|
-
import { readCrewAgents } from "../runtime/crew-agent-records.ts";
|
|
5
|
-
import { readJsonFileCoalesced } from "../utils/file-coalescer.ts";
|
|
6
|
-
import type { TeamTaskState, TeamRunManifest } from "../state/types.ts";
|
|
7
|
-
import { aggregateUsage } from "../state/usage.ts";
|
|
8
|
-
import { isDisplayActiveRun } from "../runtime/process-status.ts";
|
|
9
|
-
import { logInternalError } from "../utils/internal-error.ts";
|
|
10
|
-
import type { ManifestCache } from "../runtime/manifest-cache.ts";
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
const
|
|
69
|
-
const
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import { listRecentRuns } from "../extension/run-index.ts";
|
|
3
|
+
import type { CrewUiConfig } from "../config/config.ts";
|
|
4
|
+
import { readCrewAgents } from "../runtime/crew-agent-records.ts";
|
|
5
|
+
import { readJsonFileCoalesced } from "../utils/file-coalescer.ts";
|
|
6
|
+
import type { TeamTaskState, TeamRunManifest } from "../state/types.ts";
|
|
7
|
+
import { aggregateUsage } from "../state/usage.ts";
|
|
8
|
+
import { isDisplayActiveRun } from "../runtime/process-status.ts";
|
|
9
|
+
import { logInternalError } from "../utils/internal-error.ts";
|
|
10
|
+
import type { ManifestCache } from "../runtime/manifest-cache.ts";
|
|
11
|
+
import type { RunSnapshotCache, RunUiSnapshot } from "./snapshot-types.ts";
|
|
12
|
+
import { notificationBadge } from "./crew-widget.ts";
|
|
13
|
+
|
|
14
|
+
type EventBus = { emit?: (event: string, data: unknown) => void; listenerCount?: (event: string) => number } | undefined;
|
|
15
|
+
type StatusContext = { hasUI?: boolean; ui?: { setStatus?: (key: string, text: string | undefined) => void } } | undefined;
|
|
16
|
+
|
|
17
|
+
const TASK_READ_TTL_MS = 200;
|
|
18
|
+
|
|
19
|
+
function hasPowerbarConsumer(events: EventBus): boolean {
|
|
20
|
+
try {
|
|
21
|
+
return (events?.listenerCount?.("powerbar:register-segment") ?? 0) > 0 || (events?.listenerCount?.("powerbar:update") ?? 0) > 0;
|
|
22
|
+
} catch {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function setStatusFallback(ctx: StatusContext, text: string | undefined): void {
|
|
28
|
+
try {
|
|
29
|
+
if (ctx?.hasUI) ctx.ui?.setStatus?.("pi-crew", text);
|
|
30
|
+
} catch (error) {
|
|
31
|
+
logInternalError("powerbar.statusFallback", error);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function safeEmit(events: EventBus, event: string, data: unknown): void {
|
|
36
|
+
try {
|
|
37
|
+
events?.emit?.(event, data);
|
|
38
|
+
} catch (error) {
|
|
39
|
+
logInternalError("powerbar.safeEmit", error, `event=${event}`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function readTasks(tasksPath: string): TeamTaskState[] {
|
|
44
|
+
try {
|
|
45
|
+
const parse = () => {
|
|
46
|
+
const parsed = JSON.parse(fs.readFileSync(tasksPath, "utf-8"));
|
|
47
|
+
return Array.isArray(parsed) ? (parsed as TeamTaskState[]) : [];
|
|
48
|
+
};
|
|
49
|
+
return readJsonFileCoalesced(tasksPath, TASK_READ_TTL_MS, parse);
|
|
50
|
+
} catch (error) {
|
|
51
|
+
logInternalError("powerbar.readTasks", error, tasksPath);
|
|
52
|
+
return [];
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function compactTokens(total: number): string {
|
|
57
|
+
return total >= 1000 ? `${Math.round(total / 1000)}k` : `${total}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function registerPiCrewPowerbarSegments(events: EventBus, config?: CrewUiConfig): void {
|
|
61
|
+
if (config?.powerbar === false) return;
|
|
62
|
+
safeEmit(events, "powerbar:register-segment", { id: "pi-crew-active", label: "pi-crew active agents" });
|
|
63
|
+
safeEmit(events, "powerbar:register-segment", { id: "pi-crew-progress", label: "pi-crew run progress" });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function updatePiCrewPowerbar(events: EventBus, cwd: string, config?: CrewUiConfig, manifestCache?: ManifestCache, snapshotCache?: RunSnapshotCache, ctx?: StatusContext, notificationCount = 0): void {
|
|
67
|
+
if (config?.powerbar === false) return;
|
|
68
|
+
const useStatusFallback = !hasPowerbarConsumer(events);
|
|
69
|
+
const runs = manifestCache ? manifestCache.list(20) : listRecentRuns(cwd, 20);
|
|
70
|
+
const active = runs.map((run) => {
|
|
71
|
+
let snapshot: RunUiSnapshot | undefined;
|
|
72
|
+
try {
|
|
73
|
+
snapshot = snapshotCache?.refreshIfStale(run.runId);
|
|
74
|
+
} catch (error) {
|
|
75
|
+
logInternalError("powerbar.snapshot", error, run.runId);
|
|
76
|
+
}
|
|
77
|
+
if (snapshot) return { run: snapshot.manifest, agents: snapshot.agents, tasks: snapshot.tasks, snapshot };
|
|
78
|
+
let agents: ReturnType<typeof readCrewAgents> = [];
|
|
79
|
+
try {
|
|
80
|
+
agents = readCrewAgents(run);
|
|
81
|
+
} catch (error) {
|
|
82
|
+
logInternalError("powerbar.readCrewAgents", error, run.runId);
|
|
83
|
+
}
|
|
84
|
+
return { run, agents, tasks: readTasks(run.tasksPath), snapshot };
|
|
85
|
+
}).filter((item) => isDisplayActiveRun(item.run, item.agents));
|
|
86
|
+
if (!active.length) {
|
|
87
|
+
safeEmit(events, "powerbar:update", { id: "pi-crew-active" });
|
|
88
|
+
safeEmit(events, "powerbar:update", { id: "pi-crew-progress" });
|
|
89
|
+
if (useStatusFallback) setStatusFallback(ctx, undefined);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const agents = active.flatMap((item) => item.agents);
|
|
93
|
+
const tasks = active.flatMap((item) => item.tasks);
|
|
94
|
+
const running = agents.filter((agent) => agent.status === "running").length;
|
|
95
|
+
const waiting = active.reduce((sum, item) => sum + (item.snapshot?.progress.queued ?? item.tasks.filter((task) => task.status === "queued").length), 0);
|
|
96
|
+
const completed = active.reduce((sum, item) => sum + (item.snapshot?.progress.completed ?? item.tasks.filter((task) => task.status === "completed").length), 0);
|
|
97
|
+
const total = Math.max(1, active.reduce((sum, item) => sum + (item.snapshot?.progress.total ?? item.tasks.length), 0) || agents.length);
|
|
98
|
+
const usage = aggregateUsage(tasks);
|
|
99
|
+
const snapshotTokens = active.reduce((sum, item) => sum + (item.snapshot ? item.snapshot.usage.tokensIn + item.snapshot.usage.tokensOut : 0), 0);
|
|
100
|
+
const tokenTotal = usage ? (usage.input ?? 0) + (usage.output ?? 0) + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0) : snapshotTokens;
|
|
101
|
+
const model = config?.showModel === false ? undefined : agents.find((agent) => agent.model)?.model?.split("/").at(-1);
|
|
102
|
+
const tokenText = config?.showTokens === false || !tokenTotal ? undefined : compactTokens(tokenTotal);
|
|
103
|
+
const activeText = `crew ${running}a/${waiting}w${notificationBadge(notificationCount)}`;
|
|
104
|
+
const activeSuffix = [model, tokenText].filter(Boolean).join(" · ") || undefined;
|
|
105
|
+
const progressSuffix = `${completed}/${total}${tokenText ? ` · ${tokenText}` : ""}`;
|
|
106
|
+
safeEmit(events, "powerbar:update", {
|
|
107
|
+
id: "pi-crew-active",
|
|
108
|
+
icon: "⚙",
|
|
109
|
+
text: activeText,
|
|
110
|
+
suffix: activeSuffix,
|
|
111
|
+
color: running ? "accent" : "warning",
|
|
112
|
+
});
|
|
113
|
+
safeEmit(events, "powerbar:update", {
|
|
114
|
+
id: "pi-crew-progress",
|
|
115
|
+
text: (active[0]?.run as TeamRunManifest)?.team ?? "crew",
|
|
116
|
+
bar: Math.round((completed / total) * 100),
|
|
117
|
+
suffix: progressSuffix,
|
|
118
|
+
color: completed === total ? "success" : "accent",
|
|
119
|
+
barSegments: 8,
|
|
120
|
+
});
|
|
121
|
+
if (useStatusFallback) setStatusFallback(ctx, `${activeText}${activeSuffix ? ` · ${activeSuffix}` : ""} · ${progressSuffix}`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function clearPiCrewPowerbar(events: EventBus, ctx?: StatusContext): void {
|
|
125
|
+
safeEmit(events, "powerbar:update", { id: "pi-crew-active" });
|
|
126
|
+
safeEmit(events, "powerbar:update", { id: "pi-crew-progress" });
|
|
127
|
+
setStatusFallback(ctx, undefined);
|
|
128
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { logInternalError } from "../utils/internal-error.ts";
|
|
2
|
+
|
|
3
|
+
export interface RenderSchedulerEventBus {
|
|
4
|
+
on?: (event: string, handler: (payload: unknown) => void) => (() => void) | void;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface RenderSchedulerOptions {
|
|
8
|
+
debounceMs?: number;
|
|
9
|
+
fallbackMs?: number;
|
|
10
|
+
events?: string[];
|
|
11
|
+
onInvalidate?: (payload: unknown) => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const DEFAULT_EVENTS = [
|
|
15
|
+
"crew.run.created",
|
|
16
|
+
"crew.run.completed",
|
|
17
|
+
"crew.run.failed",
|
|
18
|
+
"crew.run.cancelled",
|
|
19
|
+
"crew.subagent.completed",
|
|
20
|
+
"crew.subagent.failed",
|
|
21
|
+
"crew.mailbox.updated",
|
|
22
|
+
"crew.mailbox.message",
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
export class RenderScheduler {
|
|
26
|
+
private readonly render: () => void;
|
|
27
|
+
private readonly onInvalidate?: (payload: unknown) => void;
|
|
28
|
+
private readonly debounceMs: number;
|
|
29
|
+
private readonly fallbackMs: number;
|
|
30
|
+
private debounceTimer: ReturnType<typeof setTimeout> | undefined;
|
|
31
|
+
private fallbackTimer: ReturnType<typeof setInterval> | undefined;
|
|
32
|
+
private disposed = false;
|
|
33
|
+
private lastEventAt = 0;
|
|
34
|
+
private readonly unsubs: Array<() => void> = [];
|
|
35
|
+
|
|
36
|
+
constructor(events: RenderSchedulerEventBus | undefined, render: () => void, options: RenderSchedulerOptions = {}) {
|
|
37
|
+
this.render = render;
|
|
38
|
+
this.onInvalidate = options.onInvalidate;
|
|
39
|
+
this.debounceMs = options.debounceMs ?? 75;
|
|
40
|
+
this.fallbackMs = options.fallbackMs ?? 750;
|
|
41
|
+
for (const event of options.events ?? DEFAULT_EVENTS) this.subscribe(events, event);
|
|
42
|
+
this.fallbackTimer = setInterval(() => this.fallback(), this.fallbackMs);
|
|
43
|
+
this.fallbackTimer.unref?.();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
private subscribe(events: RenderSchedulerEventBus | undefined, event: string): void {
|
|
47
|
+
if (!events?.on) return;
|
|
48
|
+
const handler = (payload: unknown): void => this.schedule(payload);
|
|
49
|
+
try {
|
|
50
|
+
const unsub = events.on(event, handler);
|
|
51
|
+
if (typeof unsub === "function") this.unsubs.push(unsub);
|
|
52
|
+
} catch (error) {
|
|
53
|
+
logInternalError("render-scheduler.subscribe", error, event);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
private fallback(): void {
|
|
58
|
+
if (this.disposed) return;
|
|
59
|
+
if (Date.now() - this.lastEventAt < this.fallbackMs) return;
|
|
60
|
+
this.flush();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
schedule(payload?: unknown): void {
|
|
64
|
+
if (this.disposed) return;
|
|
65
|
+
this.lastEventAt = Date.now();
|
|
66
|
+
try {
|
|
67
|
+
this.onInvalidate?.(payload);
|
|
68
|
+
} catch (error) {
|
|
69
|
+
logInternalError("render-scheduler.invalidate", error);
|
|
70
|
+
}
|
|
71
|
+
if (this.debounceTimer) clearTimeout(this.debounceTimer);
|
|
72
|
+
this.debounceTimer = setTimeout(() => {
|
|
73
|
+
this.debounceTimer = undefined;
|
|
74
|
+
this.flush();
|
|
75
|
+
}, this.debounceMs);
|
|
76
|
+
this.debounceTimer.unref?.();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
flush(): void {
|
|
80
|
+
if (this.disposed) return;
|
|
81
|
+
try {
|
|
82
|
+
this.render();
|
|
83
|
+
} catch (error) {
|
|
84
|
+
logInternalError("render-scheduler.render", error);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
dispose(): void {
|
|
89
|
+
if (this.disposed) return;
|
|
90
|
+
this.disposed = true;
|
|
91
|
+
if (this.debounceTimer) clearTimeout(this.debounceTimer);
|
|
92
|
+
if (this.fallbackTimer) clearInterval(this.fallbackTimer);
|
|
93
|
+
this.debounceTimer = undefined;
|
|
94
|
+
this.fallbackTimer = undefined;
|
|
95
|
+
for (const unsub of this.unsubs.splice(0)) {
|
|
96
|
+
try {
|
|
97
|
+
unsub();
|
|
98
|
+
} catch (error) {
|
|
99
|
+
logInternalError("render-scheduler.unsubscribe", error);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import { handleTeamTool } from "../extension/team-tool.ts";
|
|
3
|
+
import { isToolError, textFromToolResult } from "../extension/tool-result.ts";
|
|
4
|
+
import { loadRunManifestById, saveRunTasks } from "../state/state-store.ts";
|
|
5
|
+
import { appendEvent } from "../state/event-log.ts";
|
|
6
|
+
import { readCrewAgents } from "../runtime/crew-agent-records.ts";
|
|
7
|
+
import { exportDiagnostic } from "../runtime/diagnostic-export.ts";
|
|
8
|
+
import type { MailboxDirection, MailboxMessage } from "../state/mailbox.ts";
|
|
9
|
+
|
|
10
|
+
export interface RunActionResult {
|
|
11
|
+
ok: boolean;
|
|
12
|
+
message: string;
|
|
13
|
+
data?: unknown;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function okFromTool(result: Awaited<ReturnType<typeof handleTeamTool>>): RunActionResult {
|
|
17
|
+
return { ok: !isToolError(result), message: textFromToolResult(result), data: result };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function err(error: unknown): RunActionResult {
|
|
21
|
+
return { ok: false, message: error instanceof Error ? error.message : String(error) };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function dispatchApi(ctx: ExtensionContext, runId: string, config: Record<string, unknown>): Promise<RunActionResult> {
|
|
25
|
+
try {
|
|
26
|
+
return okFromTool(await handleTeamTool({ action: "api", runId, config }, ctx));
|
|
27
|
+
} catch (error) {
|
|
28
|
+
return err(error);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function parseMailboxMessages(text: string): MailboxMessage[] {
|
|
33
|
+
try {
|
|
34
|
+
const parsed = JSON.parse(text) as unknown;
|
|
35
|
+
if (!Array.isArray(parsed)) return [];
|
|
36
|
+
return parsed.filter((item): item is MailboxMessage => Boolean(item) && typeof item === "object" && !Array.isArray(item) && typeof (item as { id?: unknown }).id === "string");
|
|
37
|
+
} catch {
|
|
38
|
+
return [];
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function dispatchMailboxAck(ctx: ExtensionContext, runId: string, messageId: string): Promise<RunActionResult> {
|
|
43
|
+
return dispatchApi(ctx, runId, { operation: "ack-message", messageId });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function dispatchMailboxNudge(ctx: ExtensionContext, runId: string, agentId: string, message: string): Promise<RunActionResult> {
|
|
47
|
+
return dispatchApi(ctx, runId, { operation: "nudge-agent", agentId, message });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function dispatchMailboxCompose(ctx: ExtensionContext, runId: string, payload: { from: string; to: string; body: string; taskId?: string; direction: MailboxDirection }): Promise<RunActionResult> {
|
|
51
|
+
return dispatchApi(ctx, runId, { operation: "send-message", ...payload });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function dispatchMailboxAckAll(ctx: ExtensionContext, runId: string): Promise<RunActionResult> {
|
|
55
|
+
const listed = await dispatchApi(ctx, runId, { operation: "read-mailbox", direction: "inbox" });
|
|
56
|
+
if (!listed.ok) return listed;
|
|
57
|
+
const messages = parseMailboxMessages(listed.message).filter((message) => message.status !== "acknowledged");
|
|
58
|
+
let count = 0;
|
|
59
|
+
for (const message of messages) {
|
|
60
|
+
const acked = await dispatchMailboxAck(ctx, runId, message.id);
|
|
61
|
+
if (!acked.ok) return { ok: false, message: `Acknowledged ${count}/${messages.length}; failed ${message.id}: ${acked.message}` };
|
|
62
|
+
count += 1;
|
|
63
|
+
}
|
|
64
|
+
return { ok: true, message: `Acknowledged ${count} messages.`, data: { count } };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function dispatchHealthRecovery(ctx: ExtensionContext, runId: string): Promise<RunActionResult> {
|
|
68
|
+
return dispatchApi(ctx, runId, { operation: "foreground-interrupt", reason: "operator health recovery" });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function dispatchKillStaleWorkers(ctx: ExtensionContext, runId: string): Promise<RunActionResult> {
|
|
72
|
+
try {
|
|
73
|
+
const loaded = loadRunManifestById(ctx.cwd, runId);
|
|
74
|
+
if (!loaded) return { ok: false, message: `Run '${runId}' not found.` };
|
|
75
|
+
const currentMs = Date.now();
|
|
76
|
+
const staleMs = 60_000;
|
|
77
|
+
const now = new Date(currentMs).toISOString();
|
|
78
|
+
let count = 0;
|
|
79
|
+
const tasks = loaded.tasks.map((task) => {
|
|
80
|
+
if ((task.status !== "running" && task.status !== "queued") || !task.heartbeat || task.heartbeat.alive === false) return task;
|
|
81
|
+
const lastSeenMs = Date.parse(task.heartbeat.lastSeenAt);
|
|
82
|
+
if (!Number.isFinite(lastSeenMs) || currentMs - lastSeenMs <= staleMs) return task;
|
|
83
|
+
count += 1;
|
|
84
|
+
return { ...task, heartbeat: { ...task.heartbeat, alive: false, lastSeenAt: now } };
|
|
85
|
+
});
|
|
86
|
+
saveRunTasks(loaded.manifest, tasks);
|
|
87
|
+
appendEvent(loaded.manifest.eventsPath, { type: "worker.kill_stale", runId, message: `Marked ${count} stale worker heartbeat(s) dead.`, data: { count } });
|
|
88
|
+
return { ok: true, message: `Marked ${count} stale worker heartbeat(s) dead.`, data: { count } };
|
|
89
|
+
} catch (error) {
|
|
90
|
+
return err(error);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function dispatchDiagnosticExport(ctx: ExtensionContext, runId: string): Promise<RunActionResult> {
|
|
95
|
+
try {
|
|
96
|
+
const exported = await exportDiagnostic(ctx, runId);
|
|
97
|
+
return { ok: true, message: `Diagnostic exported to ${exported.path}`, data: exported.path };
|
|
98
|
+
} catch (error) {
|
|
99
|
+
return err(error);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function defaultNudgeAgentId(ctx: Pick<ExtensionContext, "cwd">, runId: string): string | undefined {
|
|
104
|
+
const loaded = loadRunManifestById(ctx.cwd, runId);
|
|
105
|
+
if (!loaded) return undefined;
|
|
106
|
+
return readCrewAgents(loaded.manifest).find((agent) => agent.status === "running" || agent.status === "queued")?.taskId;
|
|
107
|
+
}
|