pi-squad 0.17.2 → 0.19.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.
- package/CHANGELOG.md +89 -0
- package/package.json +3 -1
- package/src/commands.ts +585 -0
- package/src/index.ts +14 -2013
- package/src/inline-input.ts +139 -0
- package/src/lifecycle.ts +164 -0
- package/src/panel-runtime.ts +157 -0
- package/src/planner.ts +2 -3
- package/src/protocol.ts +3 -47
- package/src/report.ts +40 -2
- package/src/runtime.ts +196 -0
- package/src/scheduler-runtime.ts +117 -0
- package/src/scheduler.ts +149 -135
- package/src/skills/squad-plan/SKILL.md +103 -0
- package/src/skills/squad-plan/validate-spec.mjs +65 -0
- package/src/skills/squad-supervisor/SKILL.md +1 -0
- package/src/start-squad.ts +176 -0
- package/src/store.ts +0 -43
- package/src/tools-registration.ts +626 -0
- package/src/types.ts +0 -26
- package/src/supervisor.ts +0 -142
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* inline-input.ts — tolerant decoding for inline squad-start arguments.
|
|
3
|
+
*
|
|
4
|
+
* Some tool transports (MCP bridges, JSON-over-JSON encoders) deliver the
|
|
5
|
+
* structured `tasks`/`agents`/`config` fields as JSON-encoded strings instead
|
|
6
|
+
* of arrays/objects. The squad tool accepts both and coerces strings back to
|
|
7
|
+
* structures with precise errors, so a correct plan never fails on transport
|
|
8
|
+
* shape alone. Semantic plan validation (ids, dependencies, cycles, agents)
|
|
9
|
+
* still happens in plan-rules/startSquad after coercion.
|
|
10
|
+
*/
|
|
11
|
+
import type { InlineSquadStart } from "./runtime.js";
|
|
12
|
+
|
|
13
|
+
export type InlineCoercion =
|
|
14
|
+
| { ok: true; value: InlineSquadStart }
|
|
15
|
+
| { ok: false; error: string };
|
|
16
|
+
|
|
17
|
+
type InlineTask = NonNullable<InlineSquadStart["tasks"]>[number];
|
|
18
|
+
type InlineAgents = NonNullable<InlineSquadStart["agents"]>;
|
|
19
|
+
type InlineConfig = NonNullable<InlineSquadStart["config"]>;
|
|
20
|
+
|
|
21
|
+
function parseIfString(value: unknown, field: string): { ok: true; value: unknown } | { ok: false; error: string } {
|
|
22
|
+
if (typeof value !== "string") return { ok: true, value };
|
|
23
|
+
try {
|
|
24
|
+
return { ok: true, value: JSON.parse(value) };
|
|
25
|
+
} catch (error) {
|
|
26
|
+
return { ok: false, error: `${field} arrived as a JSON string that is not valid JSON (${(error as Error).message}). Send a real ${field === "tasks" ? "array" : "object"}, or a JSON-encoded one.` };
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
31
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function coerceTasks(input: unknown): { ok: true; value: InlineTask[] | undefined } | { ok: false; error: string } {
|
|
35
|
+
if (input === undefined) return { ok: true, value: undefined };
|
|
36
|
+
if (!Array.isArray(input)) return { ok: false, error: "tasks must be an array of task objects (or a JSON-encoded array)." };
|
|
37
|
+
const tasks: InlineTask[] = [];
|
|
38
|
+
for (let i = 0; i < input.length; i++) {
|
|
39
|
+
const raw = input[i];
|
|
40
|
+
if (!isPlainObject(raw)) return { ok: false, error: `tasks[${i}] must be an object.` };
|
|
41
|
+
for (const key of ["id", "title", "agent"] as const) {
|
|
42
|
+
if (typeof raw[key] !== "string" || raw[key].length === 0) {
|
|
43
|
+
return { ok: false, error: `tasks[${i}].${key} must be a nonempty string.` };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (raw.description !== undefined && typeof raw.description !== "string") {
|
|
47
|
+
return { ok: false, error: `tasks[${i}].description must be a string when present.` };
|
|
48
|
+
}
|
|
49
|
+
if (raw.depends !== undefined && (!Array.isArray(raw.depends) || !raw.depends.every((d) => typeof d === "string"))) {
|
|
50
|
+
return { ok: false, error: `tasks[${i}].depends must be an array of task-id strings when present.` };
|
|
51
|
+
}
|
|
52
|
+
if (raw.inheritContext !== undefined && typeof raw.inheritContext !== "boolean") {
|
|
53
|
+
return { ok: false, error: `tasks[${i}].inheritContext must be a boolean when present.` };
|
|
54
|
+
}
|
|
55
|
+
tasks.push({
|
|
56
|
+
id: raw.id as string,
|
|
57
|
+
title: raw.title as string,
|
|
58
|
+
agent: raw.agent as string,
|
|
59
|
+
...(raw.description !== undefined ? { description: raw.description as string } : {}),
|
|
60
|
+
...(raw.depends !== undefined ? { depends: raw.depends as string[] } : {}),
|
|
61
|
+
...(raw.inheritContext !== undefined ? { inheritContext: raw.inheritContext as boolean } : {}),
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
return { ok: true, value: tasks };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function coerceAgents(input: unknown): { ok: true; value: InlineAgents | undefined } | { ok: false; error: string } {
|
|
68
|
+
if (input === undefined) return { ok: true, value: undefined };
|
|
69
|
+
if (!isPlainObject(input)) return { ok: false, error: "agents must be an object mapping agent names to overrides (or a JSON-encoded object)." };
|
|
70
|
+
const agents: InlineAgents = {};
|
|
71
|
+
for (const [name, raw] of Object.entries(input)) {
|
|
72
|
+
if (!isPlainObject(raw)) return { ok: false, error: `agents.${name} must be an object.` };
|
|
73
|
+
if (raw.model !== undefined && typeof raw.model !== "string") return { ok: false, error: `agents.${name}.model must be a string when present.` };
|
|
74
|
+
if (raw.thinking !== undefined && typeof raw.thinking !== "string") return { ok: false, error: `agents.${name}.thinking must be a string when present.` };
|
|
75
|
+
agents[name] = {
|
|
76
|
+
...(raw.model !== undefined ? { model: raw.model as string } : {}),
|
|
77
|
+
...(raw.thinking !== undefined ? { thinking: raw.thinking as string } : {}),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
return { ok: true, value: agents };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function coerceConfig(input: unknown): { ok: true; value: InlineConfig | undefined } | { ok: false; error: string } {
|
|
84
|
+
if (input === undefined) return { ok: true, value: undefined };
|
|
85
|
+
if (!isPlainObject(input)) return { ok: false, error: "config must be an object (or a JSON-encoded object)." };
|
|
86
|
+
if (input.maxConcurrency !== undefined && typeof input.maxConcurrency !== "number") {
|
|
87
|
+
return { ok: false, error: "config.maxConcurrency must be a number when present." };
|
|
88
|
+
}
|
|
89
|
+
if (input.autoUnblock !== undefined && typeof input.autoUnblock !== "boolean") {
|
|
90
|
+
return { ok: false, error: "config.autoUnblock must be a boolean when present." };
|
|
91
|
+
}
|
|
92
|
+
if (input.maxRetries !== undefined && typeof input.maxRetries !== "number") {
|
|
93
|
+
return { ok: false, error: "config.maxRetries must be a number when present." };
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
ok: true,
|
|
97
|
+
value: {
|
|
98
|
+
...(input.maxConcurrency !== undefined ? { maxConcurrency: input.maxConcurrency as number } : {}),
|
|
99
|
+
...(input.autoUnblock !== undefined ? { autoUnblock: input.autoUnblock as boolean } : {}),
|
|
100
|
+
...(input.maxRetries !== undefined ? { maxRetries: input.maxRetries as number } : {}),
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Decode inline squad-start input, accepting JSON-encoded structured fields. */
|
|
106
|
+
export function coerceInlineSquadStart(raw: {
|
|
107
|
+
goal: string;
|
|
108
|
+
agents?: unknown;
|
|
109
|
+
tasks?: unknown;
|
|
110
|
+
config?: unknown;
|
|
111
|
+
}): InlineCoercion {
|
|
112
|
+
if (typeof raw.goal !== "string" || raw.goal.trim().length === 0) {
|
|
113
|
+
return { ok: false, error: "goal must be a nonempty string." };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const tasksParsed = parseIfString(raw.tasks, "tasks");
|
|
117
|
+
if (!tasksParsed.ok) return tasksParsed;
|
|
118
|
+
const agentsParsed = parseIfString(raw.agents, "agents");
|
|
119
|
+
if (!agentsParsed.ok) return agentsParsed;
|
|
120
|
+
const configParsed = parseIfString(raw.config, "config");
|
|
121
|
+
if (!configParsed.ok) return configParsed;
|
|
122
|
+
|
|
123
|
+
const tasks = coerceTasks(tasksParsed.value);
|
|
124
|
+
if (!tasks.ok) return tasks;
|
|
125
|
+
const agents = coerceAgents(agentsParsed.value);
|
|
126
|
+
if (!agents.ok) return agents;
|
|
127
|
+
const config = coerceConfig(configParsed.value);
|
|
128
|
+
if (!config.ok) return config;
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
ok: true,
|
|
132
|
+
value: {
|
|
133
|
+
goal: raw.goal,
|
|
134
|
+
...(agents.value !== undefined ? { agents: agents.value } : {}),
|
|
135
|
+
...(tasks.value !== undefined ? { tasks: tasks.value } : {}),
|
|
136
|
+
...(config.value !== undefined ? { config: config.value } : {}),
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
}
|
package/src/lifecycle.ts
ADDED
|
@@ -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
|
+
}
|
|
@@ -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/planner.ts
CHANGED
|
@@ -251,8 +251,7 @@ Respond with a JSON object (and nothing else outside the JSON):
|
|
|
251
251
|
\`\`\`json
|
|
252
252
|
{
|
|
253
253
|
"agents": {
|
|
254
|
-
"agent-name": {}
|
|
255
|
-
"agent-name": { "model": "override-model-if-needed", "thinking": "high" }
|
|
254
|
+
"agent-name": {}
|
|
256
255
|
},
|
|
257
256
|
"tasks": [
|
|
258
257
|
{
|
|
@@ -269,7 +268,7 @@ Respond with a JSON object (and nothing else outside the JSON):
|
|
|
269
268
|
## Rules
|
|
270
269
|
${PLAN_STRUCTURE_RULES}
|
|
271
270
|
- Only reference agents that exist in the Available Agents list
|
|
272
|
-
- Agent
|
|
271
|
+
- Agent entries stay empty ({}) so each agent uses its configured model/thinking (agent definition, then squad defaults). Set "model" (a pi model id) or "thinking" (off, minimal, low, medium, high, xhigh, max) ONLY when the goal explicitly requests a specific model or thinking level
|
|
273
272
|
|
|
274
273
|
## Task Descriptions
|
|
275
274
|
${TASK_DESCRIPTION_GUIDE}
|
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.
|
|
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,
|
|
14
|
-
import {
|
|
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
|
-
|
|
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. */
|