pi-squad 0.17.1 → 0.18.0

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.
@@ -0,0 +1,164 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { setupSquadWidget } from "./panel/squad-widget.js";
3
+ import { activateSquadView, openPanel } from "./panel-runtime.js";
4
+ import { buildOrchestratorReviewGate } from "./review.js";
5
+ import { reviveScheduler } from "./scheduler-runtime.js";
6
+ import * as store from "./store.js";
7
+ import type { Squad } from "./types.js";
8
+ import { DISABLED_GUIDANCE, focusSquad, formatTaskProgress, runtime } from "./runtime.js";
9
+
10
+ export function registerLifecycle(pi: ExtensionAPI, squadSkillPaths: string[]): void {
11
+ /** Register a dynamically gated Ctrl+Q path; disabled mode can only show guidance. */
12
+ const registerTerminalPanelToggle = (ctx: ExtensionContext): void => {
13
+ if (!ctx.hasUI) return;
14
+ ctx.ui.onTerminalInput((data) => {
15
+ if (data !== "\x11") return undefined;
16
+ if (!runtime.squadEnabled) {
17
+ ctx.ui.notify(DISABLED_GUIDANCE, "warning");
18
+ return { consume: true };
19
+ }
20
+ // If overlay is already open, let the panel's own handler deal with it.
21
+ if (runtime.overlayOpen) return undefined;
22
+ if (!runtime.activeSquadId) {
23
+ const latest = store.findLatestSquad(ctx.cwd)
24
+ || store.listSquads().map((id) => store.loadSquad(id)).filter((s): s is Squad => s !== null).sort((a, b) => b.created.localeCompare(a.created))[0];
25
+ if (latest) activateSquadView(latest.id, ctx);
26
+ else {
27
+ ctx.ui.notify("No squads found. Use /squad or the squad tool.", "info");
28
+ return { consume: true };
29
+ }
30
+ }
31
+ if (runtime.activeSquadId) {
32
+ openPanel(pi, ctx, reviveScheduler(pi, runtime.activeSquadId, squadSkillPaths), runtime.activeSquadId, squadSkillPaths);
33
+ }
34
+ return { consume: true };
35
+ });
36
+ };
37
+
38
+ // =========================================================================
39
+ // Session Lifecycle
40
+ // =========================================================================
41
+
42
+ pi.on("session_start", async (_event, ctx) => {
43
+ // Re-read before touching focus, widgets, runtime.schedulers, or persisted work.
44
+ runtime.squadEnabled = store.loadSquadSettings().enabled;
45
+ runtime.uiCtx = ctx;
46
+
47
+ // A session replacement may not deliver shutdown first. Never carry a
48
+ // focused squad across projects, and never seed a new widget from stale state.
49
+ runtime.widgetControls?.dispose();
50
+ runtime.widgetControls = null;
51
+ registerTerminalPanelToggle(ctx);
52
+ if (!runtime.squadEnabled) {
53
+ runtime.widgetState.enabled = false;
54
+ focusSquad(null);
55
+ return;
56
+ }
57
+ runtime.widgetState.enabled = true;
58
+ const focused = runtime.activeSquadId ? store.loadSquad(runtime.activeSquadId) : null;
59
+ focusSquad(focused?.cwd === ctx.cwd ? runtime.activeSquadId : null);
60
+
61
+ // Install component-based widget
62
+ if (ctx.hasUI) {
63
+ runtime.widgetControls = setupSquadWidget(ctx, runtime.widgetState);
64
+ }
65
+
66
+ // Clean up orphaned squads from crashed sessions:
67
+ // If a squad is "running" but has no live scheduler, its parent died.
68
+ // Suspend in-progress tasks and mark the squad as paused so it doesn't
69
+ // block new squads or trigger confusing followUp messages.
70
+ const orphaned = store.findActiveSquads()
71
+ .filter((s) => s.cwd === ctx.cwd && s.status === "running");
72
+ for (const squad of orphaned) {
73
+ const tasks = store.loadAllTasks(squad.id);
74
+ let hadInProgress = false;
75
+ for (const task of tasks) {
76
+ if (task.status === "in_progress") {
77
+ store.updateTaskStatus(squad.id, task.id, "suspended");
78
+ hadInProgress = true;
79
+ }
80
+ }
81
+ if (hadInProgress) {
82
+ squad.status = "paused";
83
+ store.saveSquad(squad);
84
+ }
85
+ }
86
+
87
+ // Audit file-spec evidence on restart. Review/running/failed work is reopened;
88
+ // an explicitly paused squad remains paused and suspended/cancelled tasks stay untouched.
89
+ for (const squad of store.listSquadsForProject(ctx.cwd).filter((candidate) => candidate.spec && ["running", "failed", "paused", "review"].includes(candidate.status))) {
90
+ const scheduler = reviveScheduler(pi, squad.id, squadSkillPaths);
91
+ const invalid = await scheduler.auditSpecAttestations();
92
+ if (invalid.length > 0 && squad.status !== "paused") await scheduler.start();
93
+ }
94
+
95
+ // Reconstruct runtime.schedulers for explicit-suspension stalls without resuming any
96
+ // task. Reconcile derives/persists a missing outbox record and emits only a
97
+ // pending fingerprint; delivered attention remains durable and silent.
98
+ const suspensionCandidates = store.listSquadsForProject(ctx.cwd)
99
+ .filter((squad) => Boolean(squad.suspendedStallAttention) || store.loadAllTasks(squad.id).some((task) => task.status === "suspended"));
100
+ for (const squad of suspensionCandidates) {
101
+ const scheduler = reviveScheduler(pi, squad.id, squadSkillPaths);
102
+ await scheduler.start();
103
+ }
104
+
105
+ // Mailbox recovery is automatic after extension/main-process restart. Scan
106
+ // every project squad (including accepted done squads) because a crash can
107
+ // occur after the mailbox-first write but before the squad/task is reopened.
108
+ const pendingMailSquads = store.listSquadsForProject(ctx.cwd)
109
+ .filter((squad) => store.loadAllTasks(squad.id)
110
+ .some((task) => task.status !== "cancelled" && store.loadPendingTaskMessages(squad.id, task.id).length > 0))
111
+ .sort((a, b) => b.created.localeCompare(a.created));
112
+ for (const squad of pendingMailSquads) {
113
+ const scheduler = reviveScheduler(pi, squad.id, squadSkillPaths);
114
+ await scheduler.start();
115
+ }
116
+ if (pendingMailSquads.length > 0) focusSquad(pendingMailSquads[0].id);
117
+
118
+ // Notify about paused squads only if they have real completed work
119
+ const paused = store.findActiveSquads()
120
+ .filter((s) => s.cwd === ctx.cwd && s.status === "paused");
121
+ if (paused.length > 0) {
122
+ const squad = paused[0];
123
+ const tasks = store.loadAllTasks(squad.id);
124
+ const done = tasks.filter(t => t.status === "done").length;
125
+ // Only notify if at least 1 task completed — worth resuming
126
+ if (done > 0) {
127
+ pi.sendMessage({
128
+ customType: "squad-paused",
129
+ content: `[squad] Found paused squad "${squad.id}" (${squad.spec ? `file spec sha256=${squad.spec.sha256}` : squad.goal}) — ${formatTaskProgress(tasks)}. ` +
130
+ `Use squad_modify with action "resume" to continue, or start a new squad.`,
131
+ display: true,
132
+ });
133
+ }
134
+ }
135
+
136
+ // Restore pending acceptance gates after a main-session restart. Review is
137
+ // persisted state, not a one-shot completion message that can be missed.
138
+ const pendingReviews = store.findActiveSquads()
139
+ .filter((s) => s.cwd === ctx.cwd && s.status === "review");
140
+ if (pendingReviews.length > 0) {
141
+ const squad = pendingReviews.sort((a, b) => b.created.localeCompare(a.created))[0];
142
+ focusSquad(squad.id);
143
+ const tasks = store.loadAllTasks(squad.id);
144
+ pi.sendMessage({
145
+ customType: "squad-review-required",
146
+ content: `[squad] Restored mandatory orchestrator review for "${squad.id}" after session restart. The work remains untrusted and not accepted.\n\n${buildOrchestratorReviewGate(squad, tasks)}`,
147
+ display: true,
148
+ });
149
+ }
150
+
151
+ });
152
+
153
+ pi.on("session_shutdown", async () => {
154
+ focusSquad(null);
155
+ runtime.widgetControls?.dispose();
156
+ runtime.widgetControls = null;
157
+ for (const [id, sched] of runtime.schedulers) {
158
+ await sched.stop();
159
+ }
160
+ runtime.schedulers.clear();
161
+ runtime.uiCtx = null;
162
+ });
163
+
164
+ }
@@ -41,6 +41,8 @@ export class SquadPanel implements Component, Focusable {
41
41
  private done: (result: SquadPanelResult) => void;
42
42
  private scheduler: Scheduler;
43
43
  private squadId: string;
44
+ private isEnabled: () => boolean;
45
+ private onDisabled: () => void;
44
46
 
45
47
  /** Callback for sending messages — set by the extension */
46
48
  onSendMessage?: (taskId: string, message: string) => Promise<void>;
@@ -69,12 +71,16 @@ export class SquadPanel implements Component, Focusable {
69
71
  scheduler: Scheduler,
70
72
  squadId: string,
71
73
  done: (result: SquadPanelResult) => void,
74
+ isEnabled: () => boolean = () => true,
75
+ onDisabled: () => void = () => {},
72
76
  ) {
73
77
  this.tui = tui;
74
78
  this.theme = theme;
75
79
  this.scheduler = scheduler;
76
80
  this.squadId = squadId;
77
81
  this.done = done;
82
+ this.isEnabled = isEnabled;
83
+ this.onDisabled = onDisabled;
78
84
 
79
85
  this.taskListView = new TaskListView(theme, squadId);
80
86
  this.messageView = new MessageView(theme, squadId);
@@ -112,6 +118,12 @@ export class SquadPanel implements Component, Focusable {
112
118
  // =========================================================================
113
119
 
114
120
  handleInput(data: string): void {
121
+ if (!this.isEnabled()) {
122
+ this.onDisabled();
123
+ this.finish({ action: "close" });
124
+ return;
125
+ }
126
+
115
127
  // Ctrl+Q or q: close panel
116
128
  if (data === "\x11") {
117
129
  this.finish({ action: "close" });
@@ -316,6 +328,10 @@ export class SquadPanel implements Component, Focusable {
316
328
  // Lifecycle
317
329
  // =========================================================================
318
330
 
331
+ close(): void {
332
+ this.finish({ action: "close" });
333
+ }
334
+
319
335
  dispose(): void {
320
336
  if (this.renderTimeout) {
321
337
  clearTimeout(this.renderTimeout);
@@ -0,0 +1,157 @@
1
+ import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { SquadPanel, type SquadPanelResult } from "./panel/squad-panel.js";
3
+ import { getReviewPresentation } from "./presentation.js";
4
+ import type { Scheduler } from "./scheduler.js";
5
+ import * as store from "./store.js";
6
+ import type { Squad } from "./types.js";
7
+ import { DISABLED_GUIDANCE, focusSquad, forceWidgetUpdate, formatTaskProgress, runtime } from "./runtime.js";
8
+ import { reviveScheduler } from "./scheduler-runtime.js";
9
+
10
+ // ============================================================================
11
+ // Squad Selection & Activation
12
+ // ============================================================================
13
+
14
+ /**
15
+ * Show an interactive selector to pick a squad.
16
+ * Returns the selected squad or undefined if cancelled.
17
+ */
18
+ export async function pickSquad(
19
+ ctx: import("@earendil-works/pi-coding-agent").ExtensionContext | import("@earendil-works/pi-coding-agent").ExtensionCommandContext,
20
+ squads: Squad[],
21
+ showProject = false,
22
+ ): Promise<Squad | undefined> {
23
+ if (squads.length === 0) return undefined;
24
+
25
+ const options = squads.map((s) => {
26
+ const tasks = store.loadAllTasks(s.id);
27
+ const cost = tasks.reduce((sum, t) => sum + t.usage.cost, 0);
28
+ const review = getReviewPresentation(s);
29
+ const icon = review?.icon ?? (s.status === "done" ? "✓" : s.status === "running" ? "⏳" : s.status === "failed" ? "✗" : "·");
30
+ const acceptance = review ? ` ${review.label}` : ` [${s.status}]`;
31
+ const project = showProject ? ` — ${s.cwd.split("/").pop()}` : "";
32
+ return `${icon} ${s.id}${acceptance} · ${formatTaskProgress(tasks)} $${cost.toFixed(2)}${project}`;
33
+ });
34
+
35
+ const choice = await ctx.ui.select("Select a squad", options);
36
+ if (choice === undefined) return undefined;
37
+
38
+ const idx = options.indexOf(choice);
39
+ return idx >= 0 ? squads[idx] : undefined;
40
+ }
41
+
42
+ /**
43
+ * Activate a squad for viewing in this session.
44
+ * Sets runtime.activeSquadId, starts widget, shows notification.
45
+ * Does NOT start a scheduler (view-only unless squad needs resuming).
46
+ */
47
+ export function activateSquadView(squadId: string, ctx: import("@earendil-works/pi-coding-agent").ExtensionContext | import("@earendil-works/pi-coding-agent").ExtensionCommandContext): void {
48
+ if (!runtime.squadEnabled) {
49
+ ctx.ui.notify(DISABLED_GUIDANCE, "warning");
50
+ return;
51
+ }
52
+ const squad = store.loadSquad(squadId);
53
+ if (!squad) {
54
+ ctx.ui.notify(`Squad '${squadId}' not found`, "error");
55
+ return;
56
+ }
57
+
58
+ // Selection is one atomic focus operation: panel, widget, status, and tools
59
+ // must all target this exact squad before control returns to the caller.
60
+ focusSquad(squadId);
61
+
62
+ // Compact notification — widget already shows full task details.
63
+ // Avoid large multi-line notifications that can break TUI layout.
64
+ const tasks = store.loadAllTasks(squadId);
65
+ const cost = tasks.reduce((sum, t) => sum + t.usage.cost, 0);
66
+ ctx.ui.notify(`Viewing: ${squad.id} [${squad.status}] ${formatTaskProgress(tasks)} $${cost.toFixed(2)}`, "info");
67
+ }
68
+
69
+ // ============================================================================
70
+ // Panel — overlay via ctx.ui.custom() with proper done() lifecycle
71
+ // ============================================================================
72
+
73
+ /**
74
+ * Open the squad panel overlay.
75
+ * Uses the pi-interactive-shell pattern: ctx.ui.custom() returns a Promise
76
+ * that resolves when done() is called. The panel calls done() on close.
77
+ */
78
+ export function openPanel(
79
+ pi: ExtensionAPI,
80
+ ctx: import("@earendil-works/pi-coding-agent").ExtensionContext,
81
+ scheduler: Scheduler,
82
+ squadId: string,
83
+ skillPaths: string[],
84
+ ): void {
85
+ if (!runtime.squadEnabled) {
86
+ ctx.ui.notify(DISABLED_GUIDANCE, "warning");
87
+ return;
88
+ }
89
+ if (runtime.overlayOpen) return;
90
+ // Opening details also establishes authoritative focus. This covers callers
91
+ // that reconstructed or targeted a scheduler without going through selector UI.
92
+ focusSquad(squadId);
93
+ runtime.overlayOpen = true;
94
+
95
+ // The promise resolves when the panel calls done()
96
+ const panelPromise = ctx.ui.custom<SquadPanelResult>(
97
+ (tui, theme, _kb, done) => {
98
+ const panel = new SquadPanel(
99
+ tui,
100
+ theme,
101
+ scheduler,
102
+ squadId,
103
+ done,
104
+ () => runtime.squadEnabled,
105
+ () => ctx.ui.notify(DISABLED_GUIDANCE, "warning"),
106
+ );
107
+ runtime.closeOverlay = () => panel.close();
108
+
109
+ // Wire up message sending from panel
110
+ panel.onSendMessage = async (taskId: string, _prefill: string) => {
111
+ if (!runtime.squadEnabled) {
112
+ ctx.ui.notify(DISABLED_GUIDANCE, "warning");
113
+ return;
114
+ }
115
+ const task = store.loadTask(squadId, taskId);
116
+ const agentName = task?.agent || taskId;
117
+ const input = await ctx.ui.input(`Message to ${agentName}`, "Type your message...");
118
+ if (!runtime.squadEnabled) {
119
+ ctx.ui.notify(DISABLED_GUIDANCE, "warning");
120
+ return;
121
+ }
122
+ if (input) {
123
+ const panelSched = reviveScheduler(pi, squadId, skillPaths);
124
+ await panelSched.start();
125
+ const delivered = await panelSched.sendHumanMessage(taskId, input);
126
+ ctx.ui.notify(
127
+ delivered ? `Sent to ${agentName}: "${input}"` : `Queued durably for ${taskId}`,
128
+ "info",
129
+ );
130
+ }
131
+ tui.requestRender();
132
+ };
133
+
134
+ return panel;
135
+ },
136
+ {
137
+ overlay: true,
138
+ overlayOptions: {
139
+ anchor: "center" as const,
140
+ width: "80%" as const,
141
+ maxHeight: "80%" as const,
142
+ margin: 2,
143
+ },
144
+ },
145
+ );
146
+
147
+ // When panel closes (done() called), clean up
148
+ panelPromise.then(() => {
149
+ runtime.overlayOpen = false;
150
+ runtime.closeOverlay = null;
151
+ forceWidgetUpdate();
152
+ }).catch(() => {
153
+ runtime.overlayOpen = false;
154
+ runtime.closeOverlay = null;
155
+ });
156
+ }
157
+
package/src/protocol.ts CHANGED
@@ -6,12 +6,11 @@
6
6
  * 2. Agent identity (role, custom prompt)
7
7
  * 3. Chain context (completed dependency outputs)
8
8
  * 4. Sibling awareness (parallel tasks, file map)
9
- * 5. Knowledge entries (decisions, conventions, findings)
10
- * 6. Queued messages (received while agent wasn't running)
9
+ * 5. Queued messages (received while agent wasn't running)
11
10
  */
12
11
 
13
- import type { AgentDef, KnowledgeEntry, Squad, Task, TaskMessage } from "./types.js";
14
- import { loadAllKnowledge, loadAllTasks, loadMessages } from "./store.js";
12
+ import type { AgentDef, Squad, Task, TaskMessage } from "./types.js";
13
+ import { loadAllTasks, loadMessages } from "./store.js";
15
14
 
16
15
  // ============================================================================
17
16
  // Squad Protocol (injected into every agent)
@@ -186,47 +185,6 @@ function buildSiblingAwareness(
186
185
  return lines.join("\n") + "\n";
187
186
  }
188
187
 
189
- // ============================================================================
190
- // Knowledge
191
- // ============================================================================
192
-
193
- function buildKnowledgeSection(squadId: string): string {
194
- const entries = loadAllKnowledge(squadId);
195
- if (entries.length === 0) return "";
196
-
197
- const decisions = entries.filter((e) => e.type === "decision");
198
- const conventions = entries.filter((e) => e.type === "convention");
199
- const findings = entries.filter((e) => e.type === "finding");
200
-
201
- const lines: string[] = ["# Squad Knowledge\n"];
202
-
203
- if (decisions.length > 0) {
204
- lines.push("## Decisions");
205
- for (const d of decisions) {
206
- lines.push(`- ${d.text} (${d.from})`);
207
- }
208
- lines.push("");
209
- }
210
-
211
- if (conventions.length > 0) {
212
- lines.push("## Project Conventions");
213
- for (const c of conventions) {
214
- lines.push(`- ${c.text} (${c.from})`);
215
- }
216
- lines.push("");
217
- }
218
-
219
- if (findings.length > 0) {
220
- lines.push("## Findings");
221
- for (const f of findings) {
222
- lines.push(`- ${f.text} (${f.from})`);
223
- }
224
- lines.push("");
225
- }
226
-
227
- return lines.join("\n");
228
- }
229
-
230
188
  // ============================================================================
231
189
  // Queued Messages
232
190
  // ============================================================================
@@ -326,7 +284,6 @@ export function buildAgentSystemPrompt(options: ProtocolBuildOptions): string {
326
284
  buildAgentIdentity(agentDef),
327
285
  ...(task.fileSpecDelta ? [buildTaskSection(task), buildReworkContext(task, squadId)] : []),
328
286
  buildChainContext(task, allTasks, squadId),
329
- buildKnowledgeSection(squadId),
330
287
  buildQueuedMessages(queuedMessages),
331
288
  ].filter((section) => section.length > 0);
332
289
  return fileSections.join("\n---\n\n");
@@ -339,7 +296,6 @@ export function buildAgentSystemPrompt(options: ProtocolBuildOptions): string {
339
296
  buildReworkContext(task, squadId),
340
297
  buildChainContext(task, allTasks, squadId),
341
298
  buildSiblingAwareness(task, allTasks, modifiedFiles),
342
- buildKnowledgeSection(squadId),
343
299
  buildQueuedMessages(queuedMessages),
344
300
  ].filter((s) => s.length > 0);
345
301
 
package/src/report.ts CHANGED
@@ -1,10 +1,45 @@
1
+ import { execFileSync, type ExecFileSyncOptionsWithStringEncoding } from "node:child_process";
1
2
  import type { Task } from "./types.js";
2
3
 
4
+ const SNAPSHOT_COMMAND_OPTIONS: ExecFileSyncOptionsWithStringEncoding = {
5
+ encoding: "utf8",
6
+ timeout: 2_000,
7
+ maxBuffer: 256 * 1024,
8
+ windowsHide: true,
9
+ stdio: ["ignore", "pipe", "ignore"],
10
+ };
11
+
12
+ function buildWorkingTreeSnapshot(cwd: string): string {
13
+ try {
14
+ const insideWorkTree = execFileSync("git", ["rev-parse", "--is-inside-work-tree"], {
15
+ ...SNAPSHOT_COMMAND_OPTIONS,
16
+ cwd,
17
+ }).trim();
18
+ if (insideWorkTree !== "true") return "";
19
+
20
+ const diffStat = execFileSync("git", ["diff", "--stat"], {
21
+ ...SNAPSHOT_COMMAND_OPTIONS,
22
+ cwd,
23
+ }).trimEnd();
24
+ const status = execFileSync("git", ["status", "--short"], {
25
+ ...SNAPSHOT_COMMAND_OPTIONS,
26
+ cwd,
27
+ });
28
+ const untrackedCount = status.match(/^\?\? /gm)?.length ?? 0;
29
+
30
+ return ["Working Tree Snapshot", diffStat, `Untracked files: ${untrackedCount}`]
31
+ .filter(Boolean)
32
+ .join("\n");
33
+ } catch {
34
+ return "";
35
+ }
36
+ }
37
+
3
38
  /**
4
39
  * Build the full task handoff included in squad completion notifications.
5
40
  * This is durable report data, not a UI preview: never shorten task output.
6
41
  */
7
- export function buildCompletionSummary(tasks: Task[]): string {
42
+ export function buildCompletionSummary(tasks: Task[], cwd?: string): string {
8
43
  const completed = tasks
9
44
  .filter((task) => task.status === "done")
10
45
  .map((task) => `- ${task.id} (${task.agent}): ${task.output || "done"}`)
@@ -14,9 +49,12 @@ export function buildCompletionSummary(tasks: Task[]): string {
14
49
  .map((task) => `- ${task.id} (${task.agent}): cancelled`)
15
50
  .join("\n");
16
51
 
17
- return [completed, cancelled ? `CANCELLED TASKS (neutral; not successful output)\n${cancelled}` : ""]
52
+ const legacySummary = [completed, cancelled ? `CANCELLED TASKS (neutral; not successful output)\n${cancelled}` : ""]
18
53
  .filter(Boolean)
19
54
  .join("\n\n");
55
+ const snapshot = cwd ? buildWorkingTreeSnapshot(cwd) : "";
56
+
57
+ return [legacySummary, snapshot].filter(Boolean).join("\n\n");
20
58
  }
21
59
 
22
60
  /** Build the full failure handoff without shortening diagnostics. */