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
@@ -7,16 +7,108 @@ import { loadRunManifestById } from "../../state/state-store.ts";
7
7
  import type { TeamRunManifest } from "../../state/types.ts";
8
8
  import { readCrewAgents } from "../../runtime/crew-agent-records.ts";
9
9
  import { AnimatedMascot } from "../../ui/mascot.ts";
10
+ import * as path from "node:path";
10
11
  import { RunDashboard, type RunDashboardSelection } from "../../ui/run-dashboard.ts";
11
12
  import { DurableTextViewer } from "../../ui/transcript-viewer.ts";
13
+ import { ConfirmOverlay, type ConfirmOptions } from "../../ui/overlays/confirm-overlay.ts";
14
+ import { MailboxDetailOverlay, type MailboxAction } from "../../ui/overlays/mailbox-detail-overlay.ts";
15
+ import { MailboxComposeOverlay, type MailboxComposeResult } from "../../ui/overlays/mailbox-compose-overlay.ts";
16
+ import { AgentPickerOverlay } from "../../ui/overlays/agent-picker-overlay.ts";
17
+ import { dispatchDiagnosticExport, dispatchHealthRecovery, dispatchKillStaleWorkers, dispatchMailboxAck, dispatchMailboxAckAll, dispatchMailboxCompose, dispatchMailboxNudge } from "../../ui/run-action-dispatcher.ts";
18
+ import { listRecentDiagnostic } from "../../runtime/diagnostic-export.ts";
12
19
  import { commandText, notifyCommandResult, parseRunArgs, parseScalar, pushUnset, setNestedConfig } from "./command-utils.ts";
13
20
  import { openTranscriptViewer, selectAgentTask } from "./viewers.ts";
14
21
  import { printTimings, time } from "../../utils/timings.ts";
22
+ import { requestRenderTarget } from "../../ui/pi-ui-compat.ts";
23
+ import type { createRunSnapshotCache } from "../../ui/run-snapshot-cache.ts";
15
24
 
16
25
  export interface RegisterTeamCommandsDeps {
17
26
  startForegroundRun: (ctx: ExtensionContext, runner: (signal?: AbortSignal) => Promise<void>, runId?: string) => void;
18
27
  openLiveSidebar: (ctx: ExtensionContext, runId: string) => void;
19
28
  getManifestCache: (cwd: string) => { list(max?: number): TeamRunManifest[] };
29
+ getRunSnapshotCache?: (cwd: string) => ReturnType<typeof createRunSnapshotCache>;
30
+ dismissNotifications?: () => void;
31
+ }
32
+
33
+ async function openConfirm(ctx: ExtensionCommandContext, options: ConfirmOptions): Promise<boolean> {
34
+ if (!ctx.hasUI) return false;
35
+ return await ctx.ui.custom<boolean>((_tui, theme, _keybindings, done) => new ConfirmOverlay(options, done, theme), { overlay: true, overlayOptions: { width: 64, maxHeight: "70%", anchor: "center" } });
36
+ }
37
+
38
+ async function handleMailboxDashboardAction(ctx: ExtensionCommandContext, runId: string): Promise<void> {
39
+ if (!ctx.hasUI) return;
40
+ const action = await ctx.ui.custom<MailboxAction | undefined>((_tui, theme, _keybindings, done) => new MailboxDetailOverlay({ runId, cwd: ctx.cwd, done, theme }), { overlay: true, overlayOptions: { width: "90%", maxHeight: "85%", anchor: "center" } });
41
+ if (!action || action.type === "close") return;
42
+ let resultMessage: string | undefined;
43
+ let ok = true;
44
+ if (action.type === "ack") {
45
+ const result = await dispatchMailboxAck(ctx as ExtensionContext, runId, action.messageId);
46
+ ok = result.ok;
47
+ resultMessage = result.message;
48
+ } else if (action.type === "ackAll") {
49
+ const confirmed = await openConfirm(ctx, { title: "Acknowledge all unread messages?", body: "This cannot be undone. Y=ack all, N=cancel.", dangerLevel: "medium", defaultAction: "cancel" });
50
+ if (!confirmed) return;
51
+ const result = await dispatchMailboxAckAll(ctx as ExtensionContext, runId);
52
+ ok = result.ok;
53
+ resultMessage = result.message;
54
+ } else if (action.type === "compose") {
55
+ const compose = await ctx.ui.custom<MailboxComposeResult>((_tui, theme, _keybindings, done) => new MailboxComposeOverlay({ done, theme }), { overlay: true, overlayOptions: { width: "90%", maxHeight: "85%", anchor: "center" } });
56
+ if (compose.type === "cancel") return;
57
+ const result = await dispatchMailboxCompose(ctx as ExtensionContext, runId, compose.payload);
58
+ ok = result.ok;
59
+ resultMessage = result.message;
60
+ } else if (action.type === "nudge") {
61
+ let agentId = action.agentId;
62
+ if (!agentId) {
63
+ const picked = await ctx.ui.custom<{ agentId: string } | undefined>((_tui, theme, _keybindings, done) => new AgentPickerOverlay({ cwd: ctx.cwd, runId, done, theme }), { overlay: true, overlayOptions: { width: 72, maxHeight: "75%", anchor: "center" } });
64
+ agentId = picked?.agentId;
65
+ }
66
+ if (!agentId) return;
67
+ const result = await dispatchMailboxNudge(ctx as ExtensionContext, runId, agentId, "Please report your current status, blocker, or smallest next step.");
68
+ ok = result.ok;
69
+ resultMessage = result.message;
70
+ }
71
+ depsNotify(ctx, resultMessage ?? "Mailbox action complete.", ok ? "info" : "error");
72
+ }
73
+
74
+ function depsNotify(ctx: ExtensionCommandContext, message: string, level: "info" | "warning" | "error"): void {
75
+ ctx.ui.notify(message, level);
76
+ }
77
+
78
+ async function handleHealthDashboardAction(ctx: ExtensionCommandContext, selection: RunDashboardSelection): Promise<void> {
79
+ const loaded = loadRunManifestById(ctx.cwd, selection.runId);
80
+ if (!loaded) {
81
+ depsNotify(ctx, `Run '${selection.runId}' not found.`, "error");
82
+ return;
83
+ }
84
+ if (selection.action === "health-recovery") {
85
+ if (loaded.manifest.async) {
86
+ depsNotify(ctx, "Recovery is only available for foreground runs.", "warning");
87
+ return;
88
+ }
89
+ const confirmed = await openConfirm(ctx, { title: "Interrupt foreground run?", body: "Tasks may be marked failed. Y=interrupt, N=cancel.", dangerLevel: "high", defaultAction: "cancel" });
90
+ if (!confirmed) return;
91
+ const result = await dispatchHealthRecovery(ctx as ExtensionContext, selection.runId);
92
+ depsNotify(ctx, result.message, result.ok ? "info" : "error");
93
+ return;
94
+ }
95
+ if (selection.action === "health-kill-stale") {
96
+ const confirmed = await openConfirm(ctx, { title: "Mark stale workers dead?", body: "This updates worker heartbeat state. Y=mark dead, N=cancel.", dangerLevel: "medium", defaultAction: "cancel" });
97
+ if (!confirmed) return;
98
+ const result = await dispatchKillStaleWorkers(ctx as ExtensionContext, selection.runId);
99
+ depsNotify(ctx, result.message, result.ok ? "info" : "error");
100
+ return;
101
+ }
102
+ if (selection.action === "health-diagnostic-export") {
103
+ const diagDir = path.join(loaded.manifest.artifactsRoot, "diagnostic");
104
+ const recent = listRecentDiagnostic(diagDir, 60_000);
105
+ if (recent) {
106
+ const confirmed = await openConfirm(ctx, { title: "Recent diagnostic exists", body: `File ${recent} was created <1min ago. Export another diagnostic?`, defaultAction: "cancel" });
107
+ if (!confirmed) return;
108
+ }
109
+ const result = await dispatchDiagnosticExport(ctx as ExtensionContext, selection.runId);
110
+ depsNotify(ctx, result.message, result.ok ? "info" : "error");
111
+ }
20
112
  }
21
113
 
22
114
  export function registerTeamCommands(pi: ExtensionAPI, deps: RegisterTeamCommandsDeps): void {
@@ -133,11 +225,26 @@ export function registerTeamCommands(pi: ExtensionAPI, deps: RegisterTeamCommand
133
225
  const uiConfig = loadConfig(ctx.cwd).config.ui;
134
226
  const rightPanel = uiConfig?.dashboardPlacement !== "center";
135
227
  const width = rightPanel ? Math.min(90, Math.max(40, uiConfig?.dashboardWidth ?? 56)) : "90%";
136
- const selection = await ctx.ui.custom<RunDashboardSelection | undefined>((_tui, theme, _keybindings, done) => new RunDashboard(runs, done, theme, { placement: rightPanel ? "right" : "center", showModel: uiConfig?.showModel, showTokens: uiConfig?.showTokens, showTools: uiConfig?.showTools }), { overlay: true, overlayOptions: rightPanel ? { width, minWidth: 40, maxHeight: "100%", anchor: "top-right", offsetX: 0, offsetY: 0, margin: { top: 0, right: 0, bottom: 0, left: 0 } } : { width, maxHeight: "90%", anchor: "center", margin: 2 } });
228
+ const selection = await ctx.ui.custom<RunDashboardSelection | undefined>((_tui, theme, _keybindings, done) => new RunDashboard(runs, done, theme, { placement: rightPanel ? "right" : "center", showModel: uiConfig?.showModel, showTokens: uiConfig?.showTokens, showTools: uiConfig?.showTools, snapshotCache: deps.getRunSnapshotCache?.(ctx.cwd), runProvider: () => deps.getManifestCache(ctx.cwd).list(50) }), { overlay: true, overlayOptions: rightPanel ? { width, minWidth: 40, maxHeight: "100%", anchor: "top-right", offsetX: 0, offsetY: 0, margin: { top: 0, right: 0, bottom: 0, left: 0 } } : { width, maxHeight: "90%", anchor: "center", margin: 2 } });
137
229
  if (!selection) return;
138
230
  if (selection.action === "reload") continue;
231
+ if (selection.action === "notifications-dismiss") {
232
+ deps.dismissNotifications?.();
233
+ ctx.ui.notify("pi-crew notifications dismissed.", "info");
234
+ continue;
235
+ }
236
+ if (selection.action === "mailbox-detail") {
237
+ await handleMailboxDashboardAction(ctx, selection.runId);
238
+ deps.getRunSnapshotCache?.(ctx.cwd).invalidate(selection.runId);
239
+ continue;
240
+ }
241
+ if (selection.action === "health-recovery" || selection.action === "health-kill-stale" || selection.action === "health-diagnostic-export") {
242
+ await handleHealthDashboardAction(ctx, selection);
243
+ deps.getRunSnapshotCache?.(ctx.cwd).invalidate(selection.runId);
244
+ continue;
245
+ }
139
246
  if (selection.action === "agent-transcript" && await openTranscriptViewer(ctx, selection.runId)) continue;
140
- const result = selection.action === "api" ? await handleTeamTool({ action: "api", runId: selection.runId, config: { operation: "read-manifest" } }, ctx) : selection.action === "agents" ? await handleTeamTool({ action: "api", runId: selection.runId, config: { operation: "agent-dashboard" } }, ctx) : selection.action === "agent-events" ? await handleTeamTool({ action: "api", runId: selection.runId, config: { operation: "read-agent-events", limit: 50 } }, ctx) : selection.action === "agent-output" ? await handleTeamTool({ action: "api", runId: selection.runId, config: { operation: "read-agent-output", maxBytes: 32_000 } }, ctx) : selection.action === "agent-transcript" ? await handleTeamTool({ action: "api", runId: selection.runId, config: { operation: "read-agent-transcript" } }, ctx) : await handleTeamTool({ action: selection.action, runId: selection.runId }, ctx);
247
+ const result = selection.action === "api" ? await handleTeamTool({ action: "api", runId: selection.runId, config: { operation: "read-manifest" } }, ctx) : selection.action === "agents" ? await handleTeamTool({ action: "api", runId: selection.runId, config: { operation: "agent-dashboard" } }, ctx) : selection.action === "mailbox" ? await handleTeamTool({ action: "api", runId: selection.runId, config: { operation: "read-mailbox" } }, ctx) : selection.action === "agent-events" ? await handleTeamTool({ action: "api", runId: selection.runId, config: { operation: "read-agent-events", limit: 50 } }, ctx) : selection.action === "agent-output" ? await handleTeamTool({ action: "api", runId: selection.runId, config: { operation: "read-agent-output", maxBytes: 32_000 } }, ctx) : selection.action === "agent-transcript" ? await handleTeamTool({ action: "api", runId: selection.runId, config: { operation: "read-agent-transcript" } }, ctx) : await handleTeamTool({ action: selection.action, runId: selection.runId }, ctx);
141
248
  await notifyCommandResult(ctx, commandText(result));
142
249
  return;
143
250
  }
@@ -151,7 +258,7 @@ export function registerTeamCommands(pi: ExtensionAPI, deps: RegisterTeamCommand
151
258
  const effectArg = tokens.find((t) => ["random", "none", "typewriter", "scanline", "rain", "fade", "crt", "glitch", "dissolve"].includes(t));
152
259
  const style = (styleArg as "cat" | "armin" | undefined) ?? uiConfig?.mascotStyle ?? "cat";
153
260
  const effect = (effectArg as "random" | "none" | "typewriter" | "scanline" | "rain" | "fade" | "crt" | "glitch" | "dissolve" | undefined) ?? uiConfig?.mascotEffect ?? "random";
154
- await ctx.ui.custom<undefined>((tui, theme, _keybindings, done) => new AnimatedMascot(theme, () => done(undefined), { frameIntervalMs: style === "armin" ? 33 : 180, autoCloseMs: 7000, requestRender: () => (tui as { requestRender?: () => void }).requestRender?.(), style, effect }), { overlay: true, overlayOptions: { width: style === "armin" ? 48 : 62, maxHeight: "85%", anchor: "center" } });
261
+ await ctx.ui.custom<undefined>((tui, theme, _keybindings, done) => new AnimatedMascot(theme, () => done(undefined), { frameIntervalMs: style === "armin" ? 33 : 180, autoCloseMs: 7000, requestRender: () => requestRenderTarget(tui), style, effect }), { overlay: true, overlayOptions: { width: style === "armin" ? 48 : 62, maxHeight: "85%", anchor: "center" } });
155
262
  } });
156
263
 
157
264
  pi.registerCommand("team-init", { description: "Initialize project-local pi-crew directories and gitignore entries", handler: async (args: string, ctx: ExtensionCommandContext) => {
@@ -5,6 +5,7 @@ import type { CrewWidgetState } from "../../ui/crew-widget.ts";
5
5
  import { updateCrewWidget } from "../../ui/crew-widget.ts";
6
6
  import { updatePiCrewPowerbar } from "../../ui/powerbar-publisher.ts";
7
7
  import type { createManifestCache } from "../../runtime/manifest-cache.ts";
8
+ import type { createRunSnapshotCache } from "../../ui/run-snapshot-cache.ts";
8
9
  import { handleTeamTool } from "../team-tool.ts";
9
10
 
10
11
  export interface RegisterTeamToolDeps {
@@ -12,6 +13,7 @@ export interface RegisterTeamToolDeps {
12
13
  startForegroundRun: (ctx: ExtensionContext, runner: (signal?: AbortSignal) => Promise<void>, runId?: string) => void;
13
14
  openLiveSidebar: (ctx: ExtensionContext, runId: string) => void;
14
15
  getManifestCache: (cwd: string) => ReturnType<typeof createManifestCache>;
16
+ getRunSnapshotCache?: (cwd: string) => ReturnType<typeof createRunSnapshotCache>;
15
17
  widgetState: CrewWidgetState;
16
18
  }
17
19
 
@@ -48,8 +50,9 @@ export function registerTeamTool(pi: ExtensionAPI, deps: RegisterTeamToolDeps):
48
50
  }
49
51
  const config = loadConfig(ctx.cwd).config.ui;
50
52
  const cache = deps.getManifestCache(ctx.cwd);
51
- updateCrewWidget(ctx, deps.widgetState, config, cache);
52
- updatePiCrewPowerbar(pi.events, ctx.cwd, config, cache);
53
+ const snapshotCache = deps.getRunSnapshotCache?.(ctx.cwd);
54
+ updateCrewWidget(ctx, deps.widgetState, config, cache, snapshotCache);
55
+ updatePiCrewPowerbar(pi.events, ctx.cwd, config, cache, snapshotCache, ctx);
53
56
  return output;
54
57
  } finally {
55
58
  signal?.removeEventListener("abort", abort);
@@ -1,6 +1,7 @@
1
1
  import type { ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
2
2
  import { loadRunManifestById } from "../../state/state-store.ts";
3
3
  import { readCrewAgents } from "../../runtime/crew-agent-records.ts";
4
+ import { loadConfig } from "../../config/config.ts";
4
5
  import { DurableTranscriptViewer } from "../../ui/transcript-viewer.ts";
5
6
 
6
7
  export async function selectAgentTask(ctx: ExtensionCommandContext, runId: string | undefined, taskId?: string): Promise<{ runId: string; taskId?: string } | undefined> {
@@ -24,7 +25,8 @@ export async function openTranscriptViewer(ctx: ExtensionCommandContext, initial
24
25
  if (!runId || !ctx.hasUI) return false;
25
26
  const loaded = loadRunManifestById(ctx.cwd, runId);
26
27
  if (!loaded) return false;
27
- await ctx.ui.custom<undefined>((_tui, theme, _keybindings, done) => new DurableTranscriptViewer(loaded.manifest, theme, done, taskId), {
28
+ const uiConfig = loadConfig(ctx.cwd).config.ui;
29
+ await ctx.ui.custom<undefined>((_tui, theme, _keybindings, done) => new DurableTranscriptViewer(loaded.manifest, theme, done, taskId, { maxTailBytes: uiConfig?.transcriptTailBytes }), {
28
30
  overlay: true,
29
31
  overlayOptions: { width: "90%", maxHeight: "85%", anchor: "center" },
30
32
  });
@@ -26,8 +26,8 @@ const REVIEW_TERMS = ["review", "audit", "security", "vulnerability", "diff", "p
26
26
  const RESEARCH_TERMS = ["research", "investigate", "compare", "analyze", "document", "docs", "explain", "architecture", "đọc sâu", "source", "projects"];
27
27
  const PARALLEL_RESEARCH_RE = /(?:đọc sâu|deep read|deep research|source audit|multiple projects|các project|pi-\*|source\/|@source)/i;
28
28
  const FAST_FIX_TERMS = ["quick fix", "fast-fix", "small bug", "typo", "one-line", "minor", "lint"];
29
- const IMPLEMENTATION_TERMS = ["implement", "refactor", "migrate", "feature", "tests", "test", "integration", "upgrade", "build", "create", "add"];
30
- const RISKY_TERMS = ["migration", "refactor", "large", "multiple", "parallel", "concurrent", "risky", "critical"];
29
+ const IMPLEMENTATION_TERMS = ["implement", "refactor", "migrate", "feature", "tests", "test", "integration", "upgrade", "build", "create", "add", "fix", "update", "sửa", "thêm", "cập nhật", "kiểm thử"];
30
+ const RISKY_TERMS = ["migration", "refactor", "large", "multiple", "parallel", "concurrent", "risky", "critical", "nhiều file", "nhiều task"];
31
31
  const NUMBERED_LINE_RE = /^\s*\d+[.)]\s+(.+)$/;
32
32
  const BULLETED_LINE_RE = /^\s*[-*•]\s+(.+)$/;
33
33
  const CONJUNCTION_SPLIT_RE = /\s+(?:and|,\s*and|,)\s+/i;
@@ -67,12 +67,14 @@ export function decomposeGoal(goal: string): { strategy: DecompositionStrategy;
67
67
  const subtask = makeSubtask(goal);
68
68
  return { strategy: "atomic", subtasks: [subtask], fanout: 1 };
69
69
  }
70
- if (lines.length >= 2 && lines.every((line) => NUMBERED_LINE_RE.test(line))) {
71
- const subtasks = lines.map((line) => makeSubtask(line.match(NUMBERED_LINE_RE)?.[1] ?? line));
70
+ const numberedLines = lines.map((line) => line.match(NUMBERED_LINE_RE)?.[1]).filter((line): line is string => line !== undefined);
71
+ if (numberedLines.length >= 2 && numberedLines.length >= lines.length - 1) {
72
+ const subtasks = numberedLines.map((line) => makeSubtask(line));
72
73
  return { strategy: "numbered", subtasks, fanout: subtasks.length };
73
74
  }
74
- if (lines.length >= 2 && lines.every((line) => BULLETED_LINE_RE.test(line))) {
75
- const subtasks = lines.map((line) => makeSubtask(line.match(BULLETED_LINE_RE)?.[1] ?? line));
75
+ const bulletedLines = lines.map((line) => line.match(BULLETED_LINE_RE)?.[1]).filter((line): line is string => line !== undefined);
76
+ if (bulletedLines.length >= 2 && bulletedLines.length >= lines.length - 1) {
77
+ const subtasks = bulletedLines.map((line) => makeSubtask(line));
76
78
  return { strategy: "bulleted", subtasks, fanout: subtasks.length };
77
79
  }
78
80
  if (lines.length === 1) {
@@ -94,6 +96,7 @@ function metadataMatches(goal: string, values: string[] | undefined): string[] {
94
96
  export function recommendTeam(goal: string, config: PiTeamsAutonomousConfig = {}, resources?: { teams?: TeamConfig[]; agents?: AgentConfig[] }): TeamRecommendation {
95
97
  const normalized = goal.toLowerCase();
96
98
  const intents = detectTeamIntent(goal, config);
99
+ const decomposition = decomposeGoal(goal);
97
100
  const reasons: string[] = [];
98
101
  let team: TeamRecommendation["team"] = "default";
99
102
  let workflow: TeamRecommendation["workflow"] = "default";
@@ -138,10 +141,15 @@ export function recommendTeam(goal: string, config: PiTeamsAutonomousConfig = {}
138
141
  workflow = "fast-fix";
139
142
  confidence = "high";
140
143
  reasons.push(`Small fix terms detected: ${fastFixMatches.join(", ") || "fast-fix intent"}.`);
144
+ } else if (intents.includes("taskList")) {
145
+ team = "implementation";
146
+ workflow = "implementation";
147
+ confidence = "high";
148
+ reasons.push(`Actionable multi-item task list detected (${decomposition.fanout} bullet${decomposition.fanout === 1 ? "" : "s"}); use coordinated implementation planning.`);
141
149
  } else if (intents.includes("implementation") || implementationMatches.length > 0) {
142
150
  team = "implementation";
143
151
  workflow = "implementation";
144
- confidence = implementationMatches.length >= 2 || riskyMatches.length > 0 ? "high" : "medium";
152
+ confidence = implementationMatches.length >= 2 || riskyMatches.length > 0 || decomposition.fanout >= 2 ? "high" : "medium";
145
153
  reasons.push(`Implementation terms detected: ${implementationMatches.join(", ") || "implementation intent"}.`);
146
154
  } else {
147
155
  action = "plan";
@@ -149,7 +157,7 @@ export function recommendTeam(goal: string, config: PiTeamsAutonomousConfig = {}
149
157
  reasons.push("No strong team-specific intent detected; start with planning/default discovery.");
150
158
  }
151
159
 
152
- const decomposition = decomposeGoal(goal);
160
+
153
161
  if (decomposition.strategy !== "atomic") reasons.push(`Goal decomposes into ${decomposition.subtasks.length} subtasks using ${decomposition.strategy} parsing.`);
154
162
  const async = config.preferAsyncForLongTasks === true && (wordCount(goal) > 24 || riskyMatches.length > 0 || implementationMatches.length >= 2 || decomposition.fanout >= 3);
155
163
  const workspaceMode = config.allowWorktreeSuggestion === false ? "single" : (riskyMatches.length > 0 && team === "implementation" ? "worktree" : "single");
@@ -399,6 +399,7 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
399
399
 
400
400
  input.signal?.addEventListener("abort", abort, { once: true });
401
401
  child.stdout?.on("data", (chunk: Buffer) => {
402
+ restartNoResponseTimer();
402
403
  lineObserver.observe(chunk.toString("utf-8"));
403
404
  });
404
405
  child.stderr?.on("data", (chunk: Buffer) => {
@@ -0,0 +1,107 @@
1
+ import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ import { readCrewAgents } from "./crew-agent-records.ts";
5
+ import { readEvents, type TeamEvent } from "../state/event-log.ts";
6
+ import { loadRunManifestById } from "../state/state-store.ts";
7
+ import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
8
+ import { summarizeHeartbeats, type HeartbeatSummary } from "../ui/heartbeat-aggregator.ts";
9
+ import type { RunUiSnapshot } from "../ui/snapshot-types.ts";
10
+
11
+ export interface DiagnosticReport {
12
+ runId: string;
13
+ exportedAt: string;
14
+ manifest: TeamRunManifest;
15
+ tasks: TeamTaskState[];
16
+ recentEvents: TeamEvent[];
17
+ heartbeat: HeartbeatSummary;
18
+ agents: unknown[];
19
+ envRedacted: Record<string, string>;
20
+ }
21
+
22
+ const SECRET_KEY_PATTERN = /(token|key|password|secret|credential|auth)/i;
23
+
24
+ function isRecord(value: unknown): value is Record<string, unknown> {
25
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
26
+ }
27
+
28
+ export function redactSecrets(value: unknown, keyName = ""): unknown {
29
+ if (SECRET_KEY_PATTERN.test(keyName)) return "***";
30
+ if (typeof value === "string") return value.replace(/((?:token|key|password|secret|credential|auth)[\w.-]*\s*[=:]\s*)[^\s,;]+/gi, "$1***");
31
+ if (Array.isArray(value)) return value.map((item) => redactSecrets(item));
32
+ if (isRecord(value)) {
33
+ const output: Record<string, unknown> = {};
34
+ for (const [key, entry] of Object.entries(value)) output[key] = redactSecrets(entry, key);
35
+ return output;
36
+ }
37
+ return value;
38
+ }
39
+
40
+ function envRedacted(): Record<string, string> {
41
+ const output: Record<string, string> = {};
42
+ for (const [key, value] of Object.entries(process.env)) {
43
+ if (SECRET_KEY_PATTERN.test(key)) output[key] = "***";
44
+ else if (typeof value === "string") output[key] = value;
45
+ }
46
+ return output;
47
+ }
48
+
49
+ function buildSnapshot(manifest: TeamRunManifest, tasks: TeamTaskState[]): RunUiSnapshot {
50
+ const agents = readCrewAgents(manifest);
51
+ return {
52
+ runId: manifest.runId,
53
+ cwd: manifest.cwd,
54
+ fetchedAt: Date.now(),
55
+ signature: `${manifest.runId}:${manifest.updatedAt}`,
56
+ manifest,
57
+ tasks,
58
+ agents,
59
+ progress: {
60
+ total: tasks.length,
61
+ completed: tasks.filter((task) => task.status === "completed").length,
62
+ running: tasks.filter((task) => task.status === "running").length,
63
+ failed: tasks.filter((task) => task.status === "failed").length,
64
+ queued: tasks.filter((task) => task.status === "queued").length,
65
+ },
66
+ usage: { tokensIn: 0, tokensOut: 0, toolUses: 0 },
67
+ mailbox: { inboxUnread: 0, outboxPending: 0, needsAttention: 0 },
68
+ recentEvents: [],
69
+ recentOutputLines: [],
70
+ };
71
+ }
72
+
73
+ export async function exportDiagnostic(ctx: Pick<ExtensionContext, "cwd">, runId: string): Promise<{ path: string; report: DiagnosticReport }> {
74
+ const loaded = loadRunManifestById(ctx.cwd, runId);
75
+ if (!loaded) throw new Error(`Run '${runId}' not found.`);
76
+ const exportedAt = new Date().toISOString();
77
+ const safeTimestamp = exportedAt.replace(/[:.]/g, "-");
78
+ const recentEvents = readEvents(loaded.manifest.eventsPath).slice(-200);
79
+ const report: DiagnosticReport = {
80
+ runId,
81
+ exportedAt,
82
+ manifest: redactSecrets(loaded.manifest) as TeamRunManifest,
83
+ tasks: redactSecrets(loaded.tasks) as TeamTaskState[],
84
+ recentEvents: redactSecrets(recentEvents) as TeamEvent[],
85
+ heartbeat: summarizeHeartbeats(buildSnapshot(loaded.manifest, loaded.tasks)),
86
+ agents: redactSecrets(readCrewAgents(loaded.manifest)) as unknown[],
87
+ envRedacted: envRedacted(),
88
+ };
89
+ const dir = path.join(loaded.manifest.artifactsRoot, "diagnostic");
90
+ fs.mkdirSync(dir, { recursive: true });
91
+ const filePath = path.join(dir, `diagnostic-${safeTimestamp}.json`);
92
+ fs.writeFileSync(filePath, `${JSON.stringify(report, null, 2)}\n`, "utf-8");
93
+ return { path: filePath, report };
94
+ }
95
+
96
+ export function listRecentDiagnostic(dir: string, windowMs: number, now = Date.now()): string | undefined {
97
+ try {
98
+ if (!fs.existsSync(dir)) return undefined;
99
+ return fs.readdirSync(dir)
100
+ .filter((file) => file.startsWith("diagnostic-") && file.endsWith(".json"))
101
+ .map((file) => ({ file, mtimeMs: fs.statSync(path.join(dir, file)).mtimeMs }))
102
+ .filter((entry) => now - entry.mtimeMs < windowMs)
103
+ .sort((a, b) => b.mtimeMs - a.mtimeMs)[0]?.file;
104
+ } catch {
105
+ return undefined;
106
+ }
107
+ }
@@ -87,7 +87,10 @@ function resolvePiCliScript(): string | undefined {
87
87
 
88
88
  export function getPiSpawnCommand(args: string[]): PiSpawnCommand {
89
89
  const explicit = process.env.PI_TEAMS_PI_BIN?.trim();
90
- if (explicit && fs.existsSync(explicit) && !isRunnableNodeScript(explicit)) return { command: explicit, args };
90
+ if (explicit && fs.existsSync(explicit)) {
91
+ if (isRunnableNodeScript(explicit)) return { command: process.execPath, args: [explicit, ...args] };
92
+ return { command: explicit, args };
93
+ }
91
94
  if (process.platform === "win32") {
92
95
  const script = resolvePiCliScript();
93
96
  if (script) return { command: process.execPath, args: [script, ...args] };
@@ -43,12 +43,13 @@ export function buildTaskPacket(input: BuildTaskPacketInput): TaskPacket {
43
43
  branchPolicy: input.manifest.workspaceMode === "worktree" ? "Use the assigned task worktree and avoid modifying the leader checkout." : "Use the current checkout; do not create branches unless explicitly requested.",
44
44
  acceptanceTests: [],
45
45
  commitPolicy: "Do not commit unless explicitly requested by the user or workflow.",
46
- reportingContract: "Report changed files, verification evidence, blockers, and next recommended action.",
47
- escalationPolicy: "Stop and report if scope is ambiguous, destructive action is needed, permissions are missing, or verification cannot be completed.",
46
+ reportingContract: "Report intended/changed files, verification evidence, blockers, conflict risks, and next recommended action.",
47
+ escalationPolicy: "Stop and report if scope is ambiguous, destructive action is needed, permissions are missing, verification cannot be completed, or edits may overlap with another worker/task.",
48
48
  constraints: [
49
49
  "Stay within the assigned task scope.",
50
50
  "Do not claim completion without verification evidence.",
51
51
  "Use mailbox/API state for coordination when available.",
52
+ "Do not make overlapping edits to the same file/symbol without explicit leader sequencing or ownership guidance.",
52
53
  ],
53
54
  expectedArtifacts: ["prompt", "result", "verification"],
54
55
  verification: defaultVerificationContract(input.step),
@@ -66,6 +67,14 @@ export function validateTaskPacket(packet: TaskPacket): TaskPacketValidationResu
66
67
  if ((packet.scope === "module" || packet.scope === "single_file" || packet.scope === "custom") && !packet.scopePath?.trim()) {
67
68
  errors.push(`scopePath is required for scope '${packet.scope}'`);
68
69
  }
70
+ if (packet.constraints.length === 0) errors.push("constraints must contain at least one entry");
71
+ for (const [index, constraint] of packet.constraints.entries()) {
72
+ if (!constraint.trim()) errors.push(`constraints contains an empty value at index ${index}`);
73
+ }
74
+ if (packet.expectedArtifacts.length === 0) errors.push("expectedArtifacts must contain at least one entry");
75
+ for (const [index, artifact] of packet.expectedArtifacts.entries()) {
76
+ if (!artifact.trim()) errors.push(`expectedArtifacts contains an empty value at index ${index}`);
77
+ }
69
78
  for (const [index, test] of packet.acceptanceTests.entries()) {
70
79
  if (!test.trim()) errors.push(`acceptanceTests contains an empty value at index ${index}`);
71
80
  }
@@ -23,6 +23,9 @@ export function coordinationBridgeInstructions(task: TeamTaskState): string {
23
23
  `Mailbox target for this task: ${task.id}`,
24
24
  "Use the run mailbox contract for coordination with the leader/orchestrator:",
25
25
  "- If blocked or uncertain, report the blocker in your final result and, when mailbox tools/API are available, send an inbox/outbox message addressed to the leader.",
26
+ "- Ask the leader before editing when scope is ambiguous, requirements conflict, destructive action is needed, or you discover likely overlap with another task.",
27
+ "- Before making non-trivial edits, state intended changed files in your notes/result; if another worker may touch the same file/symbol, pause and request sequencing/ownership guidance.",
28
+ "- Do not resolve cross-worker conflicts silently. Escalate via mailbox/result with: file/symbol, conflicting task if known, proposed owner, and safest next step.",
26
29
  "- If nudged, answer with current status, blocker, or smallest next step.",
27
30
  "- Treat inherited/dependency context as reference-only; do not continue the parent conversation directly.",
28
31
  "- Completion handoff should include: DONE/FAILED, summary, changed/read files, verification evidence, and remaining risks.",
@@ -72,6 +72,15 @@ export const PiTeamsTelemetryConfigSchema = Type.Object({
72
72
  enabled: Type.Optional(Type.Boolean()),
73
73
  });
74
74
 
75
+ export const PiTeamsNotificationsConfigSchema = Type.Object({
76
+ enabled: Type.Optional(Type.Boolean()),
77
+ severityFilter: Type.Optional(Type.Array(Type.Union([Type.Literal("info"), Type.Literal("warning"), Type.Literal("error"), Type.Literal("critical")]))),
78
+ dedupWindowMs: Type.Optional(Type.Integer({ minimum: 1000 })),
79
+ batchWindowMs: Type.Optional(Type.Integer({ minimum: 0 })),
80
+ quietHours: Type.Optional(Type.String({ pattern: "^\\d{2}:\\d{2}-\\d{2}:\\d{2}$" })),
81
+ sinkRetentionDays: Type.Optional(Type.Integer({ minimum: 1, maximum: 90 })),
82
+ });
83
+
75
84
  export const PiTeamsUiConfigSchema = Type.Object({
76
85
  widgetPlacement: Type.Optional(Type.Union([Type.Literal("aboveEditor"), Type.Literal("belowEditor")])),
77
86
  widgetMaxLines: Type.Optional(Type.Integer({ minimum: 1 })),
@@ -84,6 +93,7 @@ export const PiTeamsUiConfigSchema = Type.Object({
84
93
  showModel: Type.Optional(Type.Boolean()),
85
94
  showTokens: Type.Optional(Type.Boolean()),
86
95
  showTools: Type.Optional(Type.Boolean()),
96
+ transcriptTailBytes: Type.Optional(Type.Number({ minimum: 1024 })),
87
97
  mascotStyle: Type.Optional(Type.Union([Type.Literal("cat"), Type.Literal("armin")])),
88
98
  mascotEffect: Type.Optional(Type.Union([Type.Literal("random"), Type.Literal("none"), Type.Literal("typewriter"), Type.Literal("scanline"), Type.Literal("rain"), Type.Literal("fade"), Type.Literal("crt"), Type.Literal("glitch"), Type.Literal("dissolve")])),
89
99
  });
@@ -101,5 +111,6 @@ export const PiTeamsConfigSchema = Type.Object({
101
111
  agents: Type.Optional(PiTeamsAgentsConfigSchema),
102
112
  tools: Type.Optional(PiTeamsToolsConfigSchema),
103
113
  telemetry: Type.Optional(PiTeamsTelemetryConfigSchema),
114
+ notifications: Type.Optional(PiTeamsNotificationsConfigSchema),
104
115
  ui: Type.Optional(PiTeamsUiConfigSchema),
105
116
  });