Initial commit
This commit is contained in:
@@ -0,0 +1,511 @@
|
||||
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
export type SubagentActivityPhase = "starting" | "active" | "waiting" | "done";
|
||||
export type SubagentActivityScope = "agent" | "turn" | "provider" | "streaming" | "tool";
|
||||
|
||||
export type SubagentActivityEvent =
|
||||
| "session_start"
|
||||
| "input"
|
||||
| "before_agent_start"
|
||||
| "agent_start"
|
||||
| "agent_end"
|
||||
| "turn_start"
|
||||
| "turn_end"
|
||||
| "before_provider_request"
|
||||
| "after_provider_response"
|
||||
| "message_update"
|
||||
| "tool_execution_start"
|
||||
| "tool_call"
|
||||
| "tool_execution_update"
|
||||
| "tool_result"
|
||||
| "tool_execution_end"
|
||||
| "caller_ping"
|
||||
| "subagent_done"
|
||||
| "session_shutdown";
|
||||
|
||||
export interface SubagentActivityState {
|
||||
version: 1;
|
||||
runningChildId: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
sequence: number;
|
||||
latestEvent: SubagentActivityEvent;
|
||||
phase: SubagentActivityPhase;
|
||||
agentActive: boolean;
|
||||
turnActive: boolean;
|
||||
providerActive: boolean;
|
||||
toolActive: boolean;
|
||||
activeScope?: SubagentActivityScope;
|
||||
activeSince?: number;
|
||||
waitingSince?: number;
|
||||
turnIndex?: number;
|
||||
messageEventType?: string;
|
||||
toolCallId?: string;
|
||||
toolName?: string;
|
||||
toolStartedAt?: number;
|
||||
toolEndedAt?: number;
|
||||
}
|
||||
|
||||
export type ActivityReadResult =
|
||||
| { ok: true; activity: SubagentActivityState }
|
||||
| { ok: false; reason: "missing" | "invalid" | "wrong-id"; error?: string };
|
||||
|
||||
export type SubagentShutdownReason = "quit" | "reload" | "new" | "resume" | "fork";
|
||||
|
||||
export interface SubagentActivityRecorder {
|
||||
sessionStart(): void;
|
||||
input(): void;
|
||||
beforeAgentStart(): void;
|
||||
agentStart(): void;
|
||||
agentEndWaiting(): void;
|
||||
agentEndDone(): void;
|
||||
turnStart(turnIndex?: number): void;
|
||||
turnEnd(turnIndex?: number): void;
|
||||
beforeProviderRequest(): void;
|
||||
afterProviderResponse(): void;
|
||||
messageUpdate(messageEventType?: string): void;
|
||||
toolExecutionStart(toolCallId?: string, toolName?: string): void;
|
||||
toolCall(toolCallId?: string, toolName?: string): void;
|
||||
toolExecutionUpdate(toolCallId?: string, toolName?: string): void;
|
||||
toolResult(toolCallId?: string, toolName?: string): void;
|
||||
toolExecutionEnd(toolCallId?: string, toolName?: string): void;
|
||||
callerPing(): void;
|
||||
subagentDone(): void;
|
||||
sessionShutdown(reason: SubagentShutdownReason): void;
|
||||
}
|
||||
|
||||
const ACTIVITY_UPDATE_THROTTLE_MS = 500;
|
||||
const MAX_WRITE_FAILURES = 3;
|
||||
const KNOWN_PHASES = new Set<SubagentActivityPhase>(["starting", "active", "waiting", "done"]);
|
||||
const KNOWN_SCOPES = new Set<SubagentActivityScope>(["agent", "turn", "provider", "streaming", "tool"]);
|
||||
const KNOWN_EVENTS = new Set<SubagentActivityEvent>([
|
||||
"session_start",
|
||||
"input",
|
||||
"before_agent_start",
|
||||
"agent_start",
|
||||
"agent_end",
|
||||
"turn_start",
|
||||
"turn_end",
|
||||
"before_provider_request",
|
||||
"after_provider_response",
|
||||
"message_update",
|
||||
"tool_execution_start",
|
||||
"tool_call",
|
||||
"tool_execution_update",
|
||||
"tool_result",
|
||||
"tool_execution_end",
|
||||
"caller_ping",
|
||||
"subagent_done",
|
||||
"session_shutdown",
|
||||
]);
|
||||
const MAX_ACTIVITY_STRING_LENGTH = 200;
|
||||
|
||||
export function getSubagentActivityFile(artifactDir: string, runningChildId: string): string {
|
||||
return join(artifactDir, "subagent-activity", `${runningChildId}.json`);
|
||||
}
|
||||
|
||||
function requireObject(value: unknown): Record<string, unknown> | null {
|
||||
if (value == null || typeof value !== "object" || Array.isArray(value)) return null;
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function validateFiniteNumber(object: Record<string, unknown>, fieldName: string): string | null {
|
||||
return Number.isFinite(object[fieldName]) ? null : `${fieldName} must be finite`;
|
||||
}
|
||||
|
||||
function validateOptionalFiniteNumber(object: Record<string, unknown>, fieldName: string): string | null {
|
||||
const value = object[fieldName];
|
||||
return value == null || Number.isFinite(value) ? null : `${fieldName} must be finite when present`;
|
||||
}
|
||||
|
||||
function validateInteger(object: Record<string, unknown>, fieldName: string): string | null {
|
||||
return Number.isInteger(object[fieldName]) ? null : `${fieldName} must be an integer`;
|
||||
}
|
||||
|
||||
function validateOptionalInteger(object: Record<string, unknown>, fieldName: string): string | null {
|
||||
const value = object[fieldName];
|
||||
return value == null || Number.isInteger(value) ? null : `${fieldName} must be an integer when present`;
|
||||
}
|
||||
|
||||
function validateBoolean(object: Record<string, unknown>, fieldName: string): string | null {
|
||||
return typeof object[fieldName] === "boolean" ? null : `${fieldName} must be a boolean`;
|
||||
}
|
||||
|
||||
function validateOptionalActivityString(object: Record<string, unknown>, fieldName: string): string | null {
|
||||
const value = object[fieldName];
|
||||
if (value == null) return null;
|
||||
if (typeof value !== "string") return `${fieldName} must be a string when present`;
|
||||
if (/\r|\n/.test(value)) return `${fieldName} must not contain newlines`;
|
||||
return value.length <= MAX_ACTIVITY_STRING_LENGTH ? null : `${fieldName} is too long`;
|
||||
}
|
||||
|
||||
function invalidActivity(error: string): ActivityReadResult {
|
||||
return { ok: false, reason: "invalid", error };
|
||||
}
|
||||
|
||||
function validateActivity(value: unknown, expectedRunningChildId: string): ActivityReadResult {
|
||||
const object = requireObject(value);
|
||||
if (!object) return invalidActivity("activity must be an object");
|
||||
if (object.version !== 1) return invalidActivity("unsupported activity version");
|
||||
if (typeof object.runningChildId !== "string") return invalidActivity("runningChildId must be a string");
|
||||
if (object.runningChildId !== expectedRunningChildId) return { ok: false, reason: "wrong-id" };
|
||||
if (typeof object.latestEvent !== "string" || !KNOWN_EVENTS.has(object.latestEvent as SubagentActivityEvent)) {
|
||||
return invalidActivity("unknown latestEvent");
|
||||
}
|
||||
if (typeof object.phase !== "string" || !KNOWN_PHASES.has(object.phase as SubagentActivityPhase)) {
|
||||
return invalidActivity("unknown activity phase");
|
||||
}
|
||||
if (
|
||||
object.activeScope != null &&
|
||||
(typeof object.activeScope !== "string" || !KNOWN_SCOPES.has(object.activeScope as SubagentActivityScope))
|
||||
) {
|
||||
return invalidActivity("unknown activeScope");
|
||||
}
|
||||
|
||||
const validationError = [
|
||||
validateFiniteNumber(object, "createdAt"),
|
||||
validateFiniteNumber(object, "updatedAt"),
|
||||
validateInteger(object, "sequence"),
|
||||
validateBoolean(object, "agentActive"),
|
||||
validateBoolean(object, "turnActive"),
|
||||
validateBoolean(object, "providerActive"),
|
||||
validateBoolean(object, "toolActive"),
|
||||
validateOptionalFiniteNumber(object, "activeSince"),
|
||||
validateOptionalFiniteNumber(object, "waitingSince"),
|
||||
validateOptionalInteger(object, "turnIndex"),
|
||||
validateOptionalFiniteNumber(object, "toolStartedAt"),
|
||||
validateOptionalFiniteNumber(object, "toolEndedAt"),
|
||||
validateOptionalActivityString(object, "messageEventType"),
|
||||
validateOptionalActivityString(object, "toolCallId"),
|
||||
validateOptionalActivityString(object, "toolName"),
|
||||
].find((error) => error != null);
|
||||
if (validationError) return invalidActivity(validationError);
|
||||
|
||||
return { ok: true, activity: object as unknown as SubagentActivityState };
|
||||
}
|
||||
|
||||
export function readSubagentActivityFile(
|
||||
activityFile: string,
|
||||
expectedRunningChildId: string,
|
||||
): ActivityReadResult {
|
||||
if (!existsSync(activityFile)) return { ok: false, reason: "missing" };
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(readFileSync(activityFile, "utf8"));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return { ok: false, reason: "invalid", error: message };
|
||||
}
|
||||
|
||||
return validateActivity(parsed, expectedRunningChildId);
|
||||
}
|
||||
|
||||
export function writeSubagentActivityFile(activityFile: string, activity: SubagentActivityState): void {
|
||||
const dir = dirname(activityFile);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const tempFile = join(dir, `${activity.runningChildId}.json.${process.pid}.${activity.sequence}.tmp`);
|
||||
|
||||
try {
|
||||
writeFileSync(tempFile, `${JSON.stringify(activity)}\n`, "utf8");
|
||||
renameSync(tempFile, activityFile);
|
||||
} catch (error) {
|
||||
try {
|
||||
unlinkSync(tempFile);
|
||||
} catch (cleanupError) {
|
||||
// Temp cleanup is best effort; preserve the original write/rename failure
|
||||
void cleanupError;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function createNoopRecorder(): SubagentActivityRecorder {
|
||||
return {
|
||||
sessionStart() {},
|
||||
input() {},
|
||||
beforeAgentStart() {},
|
||||
agentStart() {},
|
||||
agentEndWaiting() {},
|
||||
agentEndDone() {},
|
||||
turnStart() {},
|
||||
turnEnd() {},
|
||||
beforeProviderRequest() {},
|
||||
afterProviderResponse() {},
|
||||
messageUpdate() {},
|
||||
toolExecutionStart() {},
|
||||
toolCall() {},
|
||||
toolExecutionUpdate() {},
|
||||
toolResult() {},
|
||||
toolExecutionEnd() {},
|
||||
callerPing() {},
|
||||
subagentDone() {},
|
||||
sessionShutdown() {},
|
||||
};
|
||||
}
|
||||
|
||||
function clearActiveState(activity: SubagentActivityState): void {
|
||||
activity.agentActive = false;
|
||||
activity.turnActive = false;
|
||||
activity.providerActive = false;
|
||||
activity.toolActive = false;
|
||||
delete activity.activeScope;
|
||||
delete activity.activeSince;
|
||||
}
|
||||
|
||||
function refreshActiveScope(activity: SubagentActivityState): void {
|
||||
if (activity.toolActive) {
|
||||
activity.phase = "active";
|
||||
activity.activeScope = "tool";
|
||||
return;
|
||||
}
|
||||
if (activity.providerActive) {
|
||||
activity.phase = "active";
|
||||
activity.activeScope = "provider";
|
||||
return;
|
||||
}
|
||||
if (activity.turnActive) {
|
||||
activity.phase = "active";
|
||||
activity.activeScope = "turn";
|
||||
return;
|
||||
}
|
||||
if (activity.agentActive) {
|
||||
activity.phase = "active";
|
||||
activity.activeScope = "agent";
|
||||
return;
|
||||
}
|
||||
delete activity.activeScope;
|
||||
delete activity.activeSince;
|
||||
}
|
||||
|
||||
function markActive(
|
||||
activity: SubagentActivityState,
|
||||
scope: SubagentActivityScope,
|
||||
now: number,
|
||||
resetActiveSince = false,
|
||||
): void {
|
||||
activity.phase = "active";
|
||||
activity.activeScope = scope;
|
||||
if (activity.activeSince == null || resetActiveSince) activity.activeSince = now;
|
||||
delete activity.waitingSince;
|
||||
}
|
||||
|
||||
export function createSubagentActivityRecorder(params: {
|
||||
runningChildId?: string;
|
||||
activityFile?: string;
|
||||
now?: () => number;
|
||||
}): SubagentActivityRecorder {
|
||||
const runningChildId = params.runningChildId?.trim();
|
||||
const activityFile = params.activityFile?.trim();
|
||||
if (!runningChildId || !activityFile) return createNoopRecorder();
|
||||
|
||||
const now = params.now ?? (() => Date.now());
|
||||
const createdAt = now();
|
||||
const activity: SubagentActivityState = {
|
||||
version: 1,
|
||||
runningChildId,
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
sequence: 0,
|
||||
latestEvent: "session_start",
|
||||
phase: "starting",
|
||||
agentActive: false,
|
||||
turnActive: false,
|
||||
providerActive: false,
|
||||
toolActive: false,
|
||||
};
|
||||
|
||||
let disabled = false;
|
||||
let failureCount = 0;
|
||||
let lastFlushAt = 0;
|
||||
let pendingFlush: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function clearPendingFlush(): void {
|
||||
if (!pendingFlush) return;
|
||||
clearTimeout(pendingFlush);
|
||||
pendingFlush = null;
|
||||
}
|
||||
|
||||
function disable(): void {
|
||||
disabled = true;
|
||||
clearPendingFlush();
|
||||
}
|
||||
|
||||
function flushNow(): void {
|
||||
if (disabled) return;
|
||||
try {
|
||||
writeSubagentActivityFile(activityFile, activity);
|
||||
lastFlushAt = now();
|
||||
failureCount = 0;
|
||||
} catch {
|
||||
failureCount += 1;
|
||||
if (failureCount >= MAX_WRITE_FAILURES) disable();
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleFlush(): void {
|
||||
if (disabled || pendingFlush) return;
|
||||
|
||||
const remainingMs = Math.max(0, ACTIVITY_UPDATE_THROTTLE_MS - (now() - lastFlushAt));
|
||||
if (remainingMs === 0) {
|
||||
flushNow();
|
||||
return;
|
||||
}
|
||||
|
||||
pendingFlush = setTimeout(() => {
|
||||
pendingFlush = null;
|
||||
flushNow();
|
||||
}, remainingMs);
|
||||
}
|
||||
|
||||
function record(
|
||||
latestEvent: SubagentActivityEvent,
|
||||
update: (current: SubagentActivityState, now: number) => void,
|
||||
flush: "immediate" | "throttled",
|
||||
): void {
|
||||
if (disabled) return;
|
||||
if (flush === "immediate") clearPendingFlush();
|
||||
|
||||
const observedAt = now();
|
||||
activity.latestEvent = latestEvent;
|
||||
activity.updatedAt = observedAt;
|
||||
activity.sequence += 1;
|
||||
update(activity, observedAt);
|
||||
|
||||
if (flush === "immediate") flushNow();
|
||||
else scheduleFlush();
|
||||
}
|
||||
|
||||
function markDone(latestEvent: SubagentActivityEvent): void {
|
||||
record(latestEvent, (current) => {
|
||||
current.phase = "done";
|
||||
clearActiveState(current);
|
||||
delete current.waitingSince;
|
||||
}, "immediate");
|
||||
disable();
|
||||
}
|
||||
|
||||
return {
|
||||
sessionStart() {
|
||||
record("session_start", (current) => {
|
||||
current.phase = "starting";
|
||||
clearActiveState(current);
|
||||
delete current.waitingSince;
|
||||
}, "immediate");
|
||||
},
|
||||
input() {
|
||||
record("input", () => {}, "immediate");
|
||||
},
|
||||
beforeAgentStart() {
|
||||
record("before_agent_start", (current, observedAt) => {
|
||||
current.agentActive = true;
|
||||
markActive(current, "agent", observedAt);
|
||||
}, "immediate");
|
||||
},
|
||||
agentStart() {
|
||||
record("agent_start", (current, observedAt) => {
|
||||
current.agentActive = true;
|
||||
markActive(current, "agent", observedAt);
|
||||
}, "immediate");
|
||||
},
|
||||
agentEndWaiting() {
|
||||
record("agent_end", (current, observedAt) => {
|
||||
clearActiveState(current);
|
||||
current.phase = "waiting";
|
||||
current.waitingSince = observedAt;
|
||||
}, "immediate");
|
||||
},
|
||||
agentEndDone() {
|
||||
markDone("agent_end");
|
||||
},
|
||||
turnStart(turnIndex) {
|
||||
record("turn_start", (current, observedAt) => {
|
||||
current.agentActive = true;
|
||||
current.turnActive = true;
|
||||
if (turnIndex != null) current.turnIndex = turnIndex;
|
||||
markActive(current, current.toolActive || current.providerActive ? current.activeScope ?? "turn" : "turn", observedAt);
|
||||
}, "immediate");
|
||||
},
|
||||
turnEnd(turnIndex) {
|
||||
record("turn_end", (current) => {
|
||||
current.turnActive = false;
|
||||
current.providerActive = false;
|
||||
current.toolActive = false;
|
||||
if (turnIndex != null) current.turnIndex = turnIndex;
|
||||
refreshActiveScope(current);
|
||||
}, "immediate");
|
||||
},
|
||||
beforeProviderRequest() {
|
||||
record("before_provider_request", (current, observedAt) => {
|
||||
current.providerActive = true;
|
||||
markActive(current, "provider", observedAt, true);
|
||||
}, "immediate");
|
||||
},
|
||||
afterProviderResponse() {
|
||||
record("after_provider_response", (current) => {
|
||||
current.providerActive = false;
|
||||
refreshActiveScope(current);
|
||||
}, "immediate");
|
||||
},
|
||||
messageUpdate(messageEventType) {
|
||||
record("message_update", (current, observedAt) => {
|
||||
current.agentActive = true;
|
||||
current.turnActive = true;
|
||||
current.messageEventType = messageEventType;
|
||||
if (!current.toolActive) markActive(current, "streaming", observedAt);
|
||||
}, "throttled");
|
||||
},
|
||||
toolExecutionStart(toolCallId, toolName) {
|
||||
record("tool_execution_start", (current, observedAt) => {
|
||||
current.toolActive = true;
|
||||
current.toolCallId = toolCallId;
|
||||
current.toolName = toolName;
|
||||
current.toolStartedAt = observedAt;
|
||||
markActive(current, "tool", observedAt, true);
|
||||
}, "immediate");
|
||||
},
|
||||
toolCall(toolCallId, toolName) {
|
||||
record("tool_call", (current, observedAt) => {
|
||||
current.toolActive = true;
|
||||
current.toolCallId = toolCallId ?? current.toolCallId;
|
||||
current.toolName = toolName ?? current.toolName;
|
||||
markActive(current, "tool", observedAt);
|
||||
}, "immediate");
|
||||
},
|
||||
toolExecutionUpdate(toolCallId, toolName) {
|
||||
record("tool_execution_update", (current, observedAt) => {
|
||||
current.toolActive = true;
|
||||
current.toolCallId = toolCallId ?? current.toolCallId;
|
||||
current.toolName = toolName ?? current.toolName;
|
||||
markActive(current, "tool", observedAt);
|
||||
}, "throttled");
|
||||
},
|
||||
toolResult(toolCallId, toolName) {
|
||||
record("tool_result", (current) => {
|
||||
current.toolCallId = toolCallId ?? current.toolCallId;
|
||||
current.toolName = toolName ?? current.toolName;
|
||||
refreshActiveScope(current);
|
||||
}, "immediate");
|
||||
},
|
||||
toolExecutionEnd(toolCallId, toolName) {
|
||||
record("tool_execution_end", (current, observedAt) => {
|
||||
current.toolActive = false;
|
||||
current.toolCallId = toolCallId ?? current.toolCallId;
|
||||
current.toolName = toolName ?? current.toolName;
|
||||
current.toolEndedAt = observedAt;
|
||||
refreshActiveScope(current);
|
||||
}, "immediate");
|
||||
},
|
||||
callerPing() {
|
||||
markDone("caller_ping");
|
||||
},
|
||||
subagentDone() {
|
||||
markDone("subagent_done");
|
||||
},
|
||||
sessionShutdown(reason) {
|
||||
if (reason === "quit") markDone("session_shutdown");
|
||||
else disable();
|
||||
},
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,203 @@
|
||||
---
|
||||
name: plan
|
||||
description: >
|
||||
Planning workflow. Runs a pre-flight scout, then spawns the planner agent
|
||||
which clarifies WHAT to build and figures out HOW, with the ability to
|
||||
spawn its own scouts/researchers mid-session. Use when asked to "plan",
|
||||
"brainstorm", "I want to build X", or "let's design". Requires the
|
||||
subagents extension and a supported multiplexer (cmux/tmux/zellij).
|
||||
---
|
||||
|
||||
# Plan
|
||||
|
||||
A planning workflow. A scout maps the relevant codebase, then an interactive planner clarifies intent + requirements and designs the technical approach, producing a `plan.md` and todos.
|
||||
|
||||
**Announce at start:** "Let me take a quick look, then I'll send a scout to map the codebase before we start the planning session."
|
||||
|
||||
---
|
||||
|
||||
## The Flow
|
||||
|
||||
```
|
||||
Phase 1: Quick Assessment (main session — 30s orientation)
|
||||
↓
|
||||
Phase 2: Scout (autonomous — codebase context)
|
||||
↓
|
||||
Phase 3: Spawn Planner Agent (interactive — clarifies WHAT, plans HOW, creates todos)
|
||||
↓
|
||||
(Planner may spawn its own scouts/researchers mid-session as needed)
|
||||
↓
|
||||
Phase 4: Review Plan & Todos (main session)
|
||||
↓
|
||||
Phase 5: Execute Todos (workers — receive plan + scout context)
|
||||
↓
|
||||
Phase 6: Review
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Quick Assessment
|
||||
|
||||
Quick orientation — just enough to give the scout a focused mission:
|
||||
|
||||
```bash
|
||||
ls -la
|
||||
find . -type f -name "*.ts" | head -20 # or relevant extension
|
||||
cat package.json 2>/dev/null | head -30
|
||||
```
|
||||
|
||||
Spend ~30 seconds. Tech stack, project shape, and the area relevant to the user's request. This tells you what to ask the scout to focus on.
|
||||
|
||||
---
|
||||
|
||||
## Artifact Paths
|
||||
|
||||
For a planning run, pick a short `<name>` (e.g. `auth-redesign`) and use a shared directory under `.pi/plans/YYYY-MM-DD-<name>/` for every deliverable. Pass explicit paths in each subagent's task and read them back with the plain `read` tool when a subagent finishes.
|
||||
|
||||
Standard filenames:
|
||||
|
||||
- `.pi/plans/YYYY-MM-DD-<name>/scout-context.md`
|
||||
- `.pi/plans/YYYY-MM-DD-<name>/plan.md`
|
||||
- `.pi/plans/YYYY-MM-DD-<name>/review.md` (optional, for reviewer output)
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Scout
|
||||
|
||||
**Always spawn a scout before the planner.** The scout's context feeds into the planning session — it lets the planner skip re-asking questions whose answers live in the code, and gives it a solid base to design from.
|
||||
|
||||
```typescript
|
||||
subagent({
|
||||
name: "🔍 Scout",
|
||||
agent: "scout",
|
||||
task: `Analyze the codebase for [user's request area]. Map file structure, key modules, patterns, conventions, and existing code related to [feature area]. Focus on what a planner would need to understand before designing this feature.
|
||||
|
||||
Save your findings to: .pi/plans/YYYY-MM-DD-<name>/scout-context.md`,
|
||||
});
|
||||
```
|
||||
|
||||
**Wait for the scout to finish.** Read the scout's context file with the `read` tool — you'll pass it to the planner.
|
||||
|
||||
The planner can spawn **additional** scouts or researchers mid-session if it hits a factual gap. That's expected — don't try to pre-scout every possible area.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Spawn Planner Agent
|
||||
|
||||
Spawn the interactive planner with the scout's context and the user's request. The planner handles everything from here: clarifying intent, compact requirements engineering, ISC, approach exploration, design validation, premortem, plan artifact, and todos.
|
||||
|
||||
```typescript
|
||||
subagent({
|
||||
name: "💬 Planner",
|
||||
agent: "planner",
|
||||
interactive: true,
|
||||
task: `Plan: [what the user wants to build]
|
||||
|
||||
Scout context:
|
||||
[paste scout findings here — file structure, conventions, patterns, relevant code]
|
||||
|
||||
Save the final plan to: .pi/plans/YYYY-MM-DD-<name>/plan.md
|
||||
Create todos tagged with: <name>`,
|
||||
});
|
||||
```
|
||||
|
||||
**The user works with the planner.** It will clarify requirements lightly (1-2 rounds of questions, not a deep spec session), propose approaches, validate the design, run a premortem, write the plan, and create todos with mandatory code examples.
|
||||
|
||||
When done, the user presses Ctrl+D and the plan + todos are returned to the main session.
|
||||
|
||||
### The planner may spawn its own specialists
|
||||
|
||||
During the session, the planner can spawn:
|
||||
- **`scout`** — when a design decision depends on existing code it hasn't read
|
||||
- **`researcher`** — when a decision depends on external facts (library tradeoffs, best practices, API behaviors)
|
||||
|
||||
These are internal to the planning session. You'll see them in the multiplexer but don't need to intervene.
|
||||
|
||||
### Optional: extra scout after planning
|
||||
|
||||
If the planner significantly changed scope (new subsystems, areas the original scout didn't cover), spawn another scout targeting the new areas before workers start:
|
||||
|
||||
```typescript
|
||||
subagent({
|
||||
name: "🔍 Scout (updated scope)",
|
||||
agent: "scout",
|
||||
task: "The plan changed scope. Gather context for [new areas]. Read the plan at [plan path]. Focus on [specific files/modules the planner identified that weren't in the original scout].",
|
||||
});
|
||||
```
|
||||
|
||||
Fold the new context into the worker tasks.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Review Plan & Todos
|
||||
|
||||
Once the planner closes, read the plan and list todos:
|
||||
|
||||
```typescript
|
||||
todo({ action: "list" });
|
||||
```
|
||||
|
||||
Review with the user:
|
||||
|
||||
> "Here's what the planner produced: [brief summary]. Ready to execute, or anything to adjust?"
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Execute Todos
|
||||
|
||||
Spawn workers sequentially. Each worker gets the plan path and scout context:
|
||||
|
||||
```typescript
|
||||
// Workers execute todos sequentially — one at a time
|
||||
subagent({
|
||||
name: "🔨 Worker 1/N",
|
||||
agent: "worker",
|
||||
task: "Implement TODO-xxxx. Mark the todo as done. Plan: [plan path]\n\nScout context: [paste scout summary from Phase 2, plus any re-scout from Phase 3]",
|
||||
});
|
||||
|
||||
// Check result, then next todo
|
||||
subagent({
|
||||
name: "🔨 Worker 2/N",
|
||||
agent: "worker",
|
||||
task: "Implement TODO-yyyy. Mark the todo as done. Plan: [plan path]\n\nScout context: [paste scout summary]",
|
||||
});
|
||||
```
|
||||
|
||||
**Always run workers sequentially in the same git repo** — parallel workers will conflict on commits.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Review
|
||||
|
||||
After all todos are complete:
|
||||
|
||||
```typescript
|
||||
subagent({
|
||||
name: "Reviewer",
|
||||
agent: "reviewer",
|
||||
interactive: false,
|
||||
task: "Review the recent changes. Plan: [plan path]",
|
||||
});
|
||||
```
|
||||
|
||||
Triage findings:
|
||||
|
||||
- **P0** — Real bugs, security issues → fix now
|
||||
- **P1** — Genuine traps, maintenance dangers → fix before merging
|
||||
- **P2** — Minor issues → fix if quick, note otherwise
|
||||
- **P3** — Nits → skip
|
||||
|
||||
Create todos for P0/P1, run workers to fix, re-review only if fixes were substantial.
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Completion Checklist
|
||||
|
||||
Before reporting done:
|
||||
|
||||
1. ✅ Scout ran before the planner?
|
||||
2. ✅ Scout context was passed to the planner?
|
||||
3. ✅ All worker todos closed?
|
||||
4. ✅ Every todo has a polished commit (using the `commit` skill)?
|
||||
5. ✅ Reviewer has run?
|
||||
6. ✅ Reviewer findings triaged and addressed?
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "pi-auto-exit",
|
||||
"version": "1.0.0",
|
||||
"description": "Auto-exit plugin for pi-spawned Claude sessions"
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"hooks": {
|
||||
"Stop": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/on-stop.sh",
|
||||
"timeout": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env bash
|
||||
# Stop hook for pi-spawned Claude sessions.
|
||||
# Writes a sentinel file when Claude completes autonomously (no user interjection).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Read JSON input from stdin
|
||||
input=$(cat)
|
||||
|
||||
# Guard: if stop_hook_active is true, we're in a loop — bail out
|
||||
stop_hook_active=$(echo "$input" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('stop_hook_active', False))" 2>/dev/null || echo "False")
|
||||
if [ "$stop_hook_active" = "True" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Guard: only act for pi-spawned sessions
|
||||
if [ -z "${PI_CLAUDE_SENTINEL:-}" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Get transcript path
|
||||
transcript_path=$(echo "$input" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('transcript_path', ''))" 2>/dev/null || echo "")
|
||||
if [ -z "$transcript_path" ] || [ ! -f "$transcript_path" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Count real human messages in transcript (not tool results)
|
||||
# Claude's transcript format:
|
||||
# Human message: {"type": "user", "message": {"role": "user", "content": "..."}}
|
||||
# Tool result: {"type": "user", "message": {"role": "user", "content": [{"type": "tool_result", ...}]}}
|
||||
# We only count entries where content is a string (real human input)
|
||||
user_msg_count=$(python3 - "$transcript_path" <<'EOF'
|
||||
import sys, json
|
||||
|
||||
transcript_path = sys.argv[1]
|
||||
count = 0
|
||||
with open(transcript_path, 'r') as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
entry = json.loads(line)
|
||||
if entry.get('type') != 'user':
|
||||
continue
|
||||
content = entry.get('message', {}).get('content', '')
|
||||
# Real human messages have string content
|
||||
# Tool results have array content with tool_result blocks
|
||||
if isinstance(content, str):
|
||||
count += 1
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
pass
|
||||
print(count)
|
||||
EOF
|
||||
)
|
||||
|
||||
# Always write transcript path so the watcher can copy the session file
|
||||
if [ -n "$transcript_path" ]; then
|
||||
echo "$transcript_path" > "${PI_CLAUDE_SENTINEL}.transcript" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# If exactly 1 user message (the initial prompt), this was autonomous — signal completion
|
||||
if [ "$user_msg_count" -eq 1 ]; then
|
||||
# Write last_assistant_message to sentinel so the watcher gets a clean result
|
||||
echo "$input" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('last_assistant_message', ''))" > "$PI_CLAUDE_SENTINEL" 2>/dev/null || touch "$PI_CLAUDE_SENTINEL"
|
||||
fi
|
||||
|
||||
exit 0
|
||||
@@ -0,0 +1,180 @@
|
||||
import { appendFileSync, copyFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { randomBytes, randomUUID } from "node:crypto";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
export interface SessionEntry {
|
||||
type: string;
|
||||
id: string;
|
||||
parentId?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface MessageEntry extends SessionEntry {
|
||||
type: "message";
|
||||
message: {
|
||||
role: "user" | "assistant" | "toolResult";
|
||||
content: Array<{ type: string; text?: string; [key: string]: unknown }>;
|
||||
};
|
||||
}
|
||||
|
||||
export type SeededSubagentSessionMode = "lineage-only" | "fork";
|
||||
|
||||
function getForkContentLines(parentSessionFile: string): string[] {
|
||||
const raw = readFileSync(parentSessionFile, "utf8");
|
||||
const lines = raw.split("\n").filter((line) => line.trim());
|
||||
|
||||
let truncateAt = lines.length;
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
try {
|
||||
const entry = JSON.parse(lines[i]);
|
||||
if (entry.type === "message" && entry.message?.role === "user") {
|
||||
truncateAt = i;
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed lines
|
||||
}
|
||||
}
|
||||
|
||||
return lines.slice(0, truncateAt).filter((line) => {
|
||||
try {
|
||||
return JSON.parse(line).type !== "session";
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function seedSubagentSessionFile(params: {
|
||||
mode: SeededSubagentSessionMode;
|
||||
parentSessionFile: string;
|
||||
childSessionFile: string;
|
||||
childCwd: string;
|
||||
}): void {
|
||||
const header = {
|
||||
type: "session",
|
||||
version: 3,
|
||||
id: randomUUID(),
|
||||
timestamp: new Date().toISOString(),
|
||||
cwd: params.childCwd,
|
||||
parentSession: params.parentSessionFile,
|
||||
};
|
||||
const contentLines =
|
||||
params.mode === "fork" ? getForkContentLines(params.parentSessionFile) : [];
|
||||
const lines = [JSON.stringify(header), ...contentLines];
|
||||
|
||||
mkdirSync(dirname(params.childSessionFile), { recursive: true });
|
||||
writeFileSync(params.childSessionFile, lines.join("\n") + "\n", "utf8");
|
||||
}
|
||||
|
||||
function readEntries(sessionFile: string): SessionEntry[] {
|
||||
const raw = readFileSync(sessionFile, "utf8");
|
||||
return raw
|
||||
.split("\n")
|
||||
.filter((line) => line.trim())
|
||||
.map((line) => JSON.parse(line) as SessionEntry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the id of the last entry in the session file (current branch point / leaf).
|
||||
*/
|
||||
export function getLeafId(sessionFile: string): string | null {
|
||||
const entries = readEntries(sessionFile);
|
||||
return entries.length > 0 ? entries[entries.length - 1].id : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return entries added after `afterLine` (1-indexed count of existing entries).
|
||||
*/
|
||||
export function getNewEntries(sessionFile: string, afterLine: number): SessionEntry[] {
|
||||
const raw = readFileSync(sessionFile, "utf8");
|
||||
const lines = raw.split("\n").filter((line) => line.trim());
|
||||
return lines.slice(afterLine).map((line) => JSON.parse(line) as SessionEntry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the last assistant message text in a list of entries.
|
||||
*
|
||||
* Falls back to the `errorMessage` field when the last assistant message has
|
||||
* `stopReason: "error"` and no usable text content — this happens when
|
||||
* auto-retry exhausts on a provider overload / rate limit / server error, and
|
||||
* without this fallback the parent would silently see a stale earlier message.
|
||||
*/
|
||||
export function findLastAssistantMessage(entries: SessionEntry[]): string | null {
|
||||
for (let i = entries.length - 1; i >= 0; i--) {
|
||||
const entry = entries[i];
|
||||
if (entry.type !== "message") continue;
|
||||
const msg = entry as MessageEntry;
|
||||
if (msg.message.role !== "assistant") continue;
|
||||
|
||||
const texts = msg.message.content
|
||||
.filter(
|
||||
(block) =>
|
||||
block.type === "text" && typeof block.text === "string" && block.text.trim() !== "",
|
||||
)
|
||||
.map((block) => block.text as string);
|
||||
|
||||
if (texts.length > 0 && texts.join("").trim()) return texts.join("\n");
|
||||
|
||||
const stopReason = (msg.message as { stopReason?: unknown }).stopReason;
|
||||
const errorMessage = (msg.message as { errorMessage?: unknown }).errorMessage;
|
||||
if (
|
||||
stopReason === "error" &&
|
||||
typeof errorMessage === "string" &&
|
||||
errorMessage.trim() !== ""
|
||||
) {
|
||||
return `Subagent error: ${errorMessage.trim()}`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a branch_summary entry to the session file.
|
||||
* Returns the new entry's id.
|
||||
*/
|
||||
export function appendBranchSummary(
|
||||
sessionFile: string,
|
||||
branchPointId: string,
|
||||
fromId: string | null,
|
||||
summary: string,
|
||||
): string {
|
||||
const id = randomBytes(4).toString("hex");
|
||||
const entry = {
|
||||
type: "branch_summary",
|
||||
id,
|
||||
parentId: branchPointId,
|
||||
timestamp: new Date().toISOString(),
|
||||
fromId: fromId ?? branchPointId,
|
||||
summary,
|
||||
};
|
||||
appendFileSync(sessionFile, JSON.stringify(entry) + "\n", "utf8");
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy the session file to destDir for parallel worker isolation.
|
||||
* Returns the path of the copy.
|
||||
*/
|
||||
export function copySessionFile(sessionFile: string, destDir: string): string {
|
||||
const id = randomBytes(4).toString("hex");
|
||||
const dest = join(destDir, `subagent-${id}.jsonl`);
|
||||
copyFileSync(sessionFile, dest);
|
||||
return dest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read new entries from sourceFile (after afterLine), append them to targetFile.
|
||||
* Returns the appended entries.
|
||||
*/
|
||||
export function mergeNewEntries(
|
||||
sourceFile: string,
|
||||
targetFile: string,
|
||||
afterLine: number,
|
||||
): SessionEntry[] {
|
||||
const entries = getNewEntries(sourceFile, afterLine);
|
||||
for (const entry of entries) {
|
||||
appendFileSync(targetFile, JSON.stringify(entry) + "\n", "utf8");
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
@@ -0,0 +1,513 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
export const SNAPSHOT_STALLED_AFTER_MS = 60_000;
|
||||
export const DEFAULT_STATUS_LINE_LIMIT = 4;
|
||||
export const MAX_STATUS_NAME_LENGTH = 72;
|
||||
export const MAX_STATUS_LINE_LENGTH = 120;
|
||||
|
||||
const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const DEFAULT_STATUS_CONFIG_PATH = join(PACKAGE_ROOT, "config.json");
|
||||
const STATUS_CONFIG_EXAMPLE_PATH = join(PACKAGE_ROOT, "config.json.example");
|
||||
|
||||
export type SubagentStatusKind = "starting" | "active" | "waiting" | "stalled" | "running";
|
||||
export type SubagentStatusSource = "pi" | "claude";
|
||||
export type SubagentStatusTransition = "stalled" | "recovered" | null;
|
||||
export type StatusSnapshotState = "unseen" | "present" | "missing" | "invalid" | "wrong-id";
|
||||
export type StatusActivityPhase = "starting" | "active" | "waiting" | "done";
|
||||
|
||||
export interface StatusConfig {
|
||||
enabled: boolean;
|
||||
lineLimit: number;
|
||||
}
|
||||
|
||||
export type StatusObservation =
|
||||
| {
|
||||
snapshot: "present";
|
||||
updatedAt: number;
|
||||
sequence: number;
|
||||
phase: StatusActivityPhase;
|
||||
active?: boolean;
|
||||
activeScope?: string;
|
||||
activeSince?: number;
|
||||
waitingSince?: number;
|
||||
latestEvent?: string;
|
||||
activityLabel?: string;
|
||||
}
|
||||
| {
|
||||
snapshot: "missing" | "invalid" | "wrong-id";
|
||||
snapshotError?: string;
|
||||
};
|
||||
|
||||
export interface SubagentStatusState {
|
||||
source: SubagentStatusSource;
|
||||
startTimeMs: number;
|
||||
firstObservationAtMs: number | null;
|
||||
lastActivityAtMs: number | null;
|
||||
lastActivitySequence: number | null;
|
||||
localOverrideAtMs: number | null;
|
||||
localOverrideSequence: number | null;
|
||||
activeNow: boolean;
|
||||
activeSinceMs: number | null;
|
||||
activeScope: string | null;
|
||||
waitingSinceMs: number | null;
|
||||
phase: StatusActivityPhase | null;
|
||||
latestEvent: string | null;
|
||||
activityLabel: string | null;
|
||||
snapshotState: StatusSnapshotState;
|
||||
snapshotProblemSinceMs: number | null;
|
||||
snapshotError: string | null;
|
||||
currentKind: SubagentStatusKind;
|
||||
}
|
||||
|
||||
export interface StatusSnapshot {
|
||||
kind: SubagentStatusKind;
|
||||
elapsedMs: number;
|
||||
elapsedText: string;
|
||||
activeSinceMs: number | null;
|
||||
activeDurationText: string | null;
|
||||
activeScope: string | null;
|
||||
waitingSinceMs: number | null;
|
||||
waitingDurationText: string | null;
|
||||
latestEvent: string | null;
|
||||
activityLabel: string | null;
|
||||
snapshotState: StatusSnapshotState;
|
||||
snapshotError: string | null;
|
||||
snapshotProblemText: string | null;
|
||||
statusLabel: string | null;
|
||||
}
|
||||
|
||||
export interface CappedStatusLines {
|
||||
visibleLines: string[];
|
||||
overflow: number;
|
||||
}
|
||||
|
||||
function invalidStatusConfig(source: string, message: string): never {
|
||||
throw new Error(`Invalid subagent status config in ${source}: ${message}`);
|
||||
}
|
||||
|
||||
function requireObject(value: unknown, source: string, fieldName: string): Record<string, unknown> {
|
||||
if (value == null || typeof value !== "object" || Array.isArray(value)) {
|
||||
invalidStatusConfig(source, `${fieldName} must be an object`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function requireBoolean(value: unknown, source: string, fieldName: string): boolean {
|
||||
if (typeof value !== "boolean") {
|
||||
invalidStatusConfig(source, `${fieldName} must be a boolean`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function rejectUnsupportedKeys(
|
||||
value: Record<string, unknown>,
|
||||
allowedKeys: string[],
|
||||
source: string,
|
||||
fieldName: string,
|
||||
): void {
|
||||
const unsupportedKeys = Object.keys(value).filter((key) => !allowedKeys.includes(key));
|
||||
if (unsupportedKeys.length > 0) {
|
||||
invalidStatusConfig(source, `${fieldName} has unsupported key(s): ${unsupportedKeys.join(", ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
function truncateText(text: string, maxLength: number): string {
|
||||
if (text.length <= maxLength) return text;
|
||||
if (maxLength <= 1) return text.slice(0, maxLength);
|
||||
return `${text.slice(0, maxLength - 1)}…`;
|
||||
}
|
||||
|
||||
export function normalizeStatusName(name: string): string {
|
||||
const collapsed = name.replace(/\s+/g, " ").trim() || "subagent";
|
||||
return truncateText(collapsed, MAX_STATUS_NAME_LENGTH);
|
||||
}
|
||||
|
||||
function boundStatusLine(line: string): string {
|
||||
return truncateText(line.replace(/\s+/g, " ").trim(), MAX_STATUS_LINE_LENGTH);
|
||||
}
|
||||
|
||||
function snapshotProblemLabel(snapshotState: StatusSnapshotState): string | null {
|
||||
if (snapshotState === "wrong-id") return "wrong activity id";
|
||||
return null;
|
||||
}
|
||||
|
||||
function activityLabel(snapshot: Pick<StatusSnapshot, "activityLabel" | "activeScope">): string | null {
|
||||
return snapshot.activityLabel ?? snapshot.activeScope;
|
||||
}
|
||||
|
||||
export function parseStatusConfig(rawConfig: unknown, source = "config.json"): StatusConfig {
|
||||
const config = requireObject(rawConfig, source, "root");
|
||||
const status = requireObject(config.status, source, "status");
|
||||
rejectUnsupportedKeys(status, ["enabled"], source, "status");
|
||||
const enabled = requireBoolean(status.enabled, source, "status.enabled");
|
||||
|
||||
return {
|
||||
enabled,
|
||||
lineLimit: DEFAULT_STATUS_LINE_LIMIT,
|
||||
};
|
||||
}
|
||||
|
||||
function readStatusConfigFile(configPath: string, examplePath: string): { sourcePath: string; rawConfig: string } {
|
||||
try {
|
||||
return { sourcePath: configPath, rawConfig: readFileSync(configPath, "utf8") };
|
||||
} catch (error) {
|
||||
const errno = error as NodeJS.ErrnoException;
|
||||
if (errno.code !== "ENOENT") throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
return { sourcePath: examplePath, rawConfig: readFileSync(examplePath, "utf8") };
|
||||
} catch (error) {
|
||||
const errno = error as NodeJS.ErrnoException;
|
||||
if (errno.code === "ENOENT") {
|
||||
throw new Error(
|
||||
`Missing subagent status config. Expected ${configPath} or ${examplePath}.`,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function loadStatusConfig(
|
||||
configPath = DEFAULT_STATUS_CONFIG_PATH,
|
||||
examplePath = STATUS_CONFIG_EXAMPLE_PATH,
|
||||
): StatusConfig {
|
||||
const { sourcePath, rawConfig } = readStatusConfigFile(configPath, examplePath);
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(rawConfig) as unknown;
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`Invalid JSON in subagent config ${sourcePath}: ${detail}`);
|
||||
}
|
||||
|
||||
return parseStatusConfig(parsed, sourcePath);
|
||||
}
|
||||
|
||||
export function formatElapsedDuration(ms: number): string {
|
||||
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
|
||||
if (totalSeconds < 60) return `${totalSeconds}s`;
|
||||
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
if (hours > 0) return `${hours}h ${minutes}m`;
|
||||
|
||||
return `${minutes}m`;
|
||||
}
|
||||
|
||||
export function createStatusState(params: {
|
||||
source: SubagentStatusSource;
|
||||
startTimeMs: number;
|
||||
}): SubagentStatusState {
|
||||
const initialKind = params.source === "claude" ? "running" : "starting";
|
||||
return {
|
||||
source: params.source,
|
||||
startTimeMs: params.startTimeMs,
|
||||
firstObservationAtMs: null,
|
||||
lastActivityAtMs: null,
|
||||
lastActivitySequence: null,
|
||||
localOverrideAtMs: null,
|
||||
localOverrideSequence: null,
|
||||
activeNow: false,
|
||||
activeSinceMs: null,
|
||||
activeScope: null,
|
||||
waitingSinceMs: null,
|
||||
phase: null,
|
||||
latestEvent: null,
|
||||
activityLabel: null,
|
||||
snapshotState: params.source === "claude" ? "unseen" : "unseen",
|
||||
snapshotProblemSinceMs: null,
|
||||
snapshotError: null,
|
||||
currentKind: initialKind,
|
||||
};
|
||||
}
|
||||
|
||||
export function observeStatus(
|
||||
state: SubagentStatusState,
|
||||
observation: StatusObservation,
|
||||
now: number,
|
||||
): SubagentStatusState {
|
||||
if (state.source === "claude") return state;
|
||||
|
||||
if (observation.snapshot !== "present") {
|
||||
return {
|
||||
...state,
|
||||
firstObservationAtMs: state.firstObservationAtMs ?? now,
|
||||
snapshotState: observation.snapshot,
|
||||
snapshotProblemSinceMs: state.snapshotProblemSinceMs ?? now,
|
||||
snapshotError: observation.snapshotError ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
const updatedAt = observation.updatedAt;
|
||||
const sequence = observation.sequence;
|
||||
const lastActivityAtMs = state.lastActivityAtMs;
|
||||
const lastActivitySequence = state.lastActivitySequence;
|
||||
const olderThanLastActivity = lastActivityAtMs != null && (
|
||||
updatedAt < lastActivityAtMs ||
|
||||
(updatedAt === lastActivityAtMs && lastActivitySequence != null && sequence < lastActivitySequence)
|
||||
);
|
||||
if (olderThanLastActivity) return state;
|
||||
|
||||
const blockedByLocalOverride = state.localOverrideAtMs != null && (
|
||||
updatedAt < state.localOverrideAtMs ||
|
||||
(updatedAt === state.localOverrideAtMs && state.localOverrideSequence != null && sequence <= state.localOverrideSequence)
|
||||
);
|
||||
if (blockedByLocalOverride) return state;
|
||||
|
||||
const phase = observation.phase;
|
||||
const activeNow = phase === "active" || observation.active === true;
|
||||
const activeSinceMs = activeNow
|
||||
? observation.activeSince ?? state.activeSinceMs ?? updatedAt
|
||||
: null;
|
||||
const waitingSinceMs = phase === "waiting"
|
||||
? observation.waitingSince ?? state.waitingSinceMs ?? updatedAt
|
||||
: null;
|
||||
|
||||
return {
|
||||
...state,
|
||||
firstObservationAtMs: state.firstObservationAtMs ?? now,
|
||||
lastActivityAtMs: updatedAt,
|
||||
lastActivitySequence: sequence,
|
||||
activeNow,
|
||||
activeSinceMs,
|
||||
activeScope: activeNow ? observation.activeScope ?? null : null,
|
||||
waitingSinceMs,
|
||||
phase,
|
||||
latestEvent: observation.latestEvent ?? null,
|
||||
activityLabel: observation.activityLabel ?? null,
|
||||
snapshotState: "present",
|
||||
snapshotProblemSinceMs: null,
|
||||
snapshotError: null,
|
||||
localOverrideAtMs: null,
|
||||
localOverrideSequence: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function forceStatusAfterInterrupt(state: SubagentStatusState, now: number): SubagentStatusState {
|
||||
if (state.source === "claude") return state;
|
||||
|
||||
return {
|
||||
...state,
|
||||
firstObservationAtMs: state.firstObservationAtMs ?? now,
|
||||
lastActivityAtMs: now,
|
||||
localOverrideAtMs: now,
|
||||
localOverrideSequence: state.lastActivitySequence,
|
||||
activeNow: false,
|
||||
activeSinceMs: null,
|
||||
activeScope: null,
|
||||
waitingSinceMs: now,
|
||||
phase: "waiting",
|
||||
latestEvent: "interrupt_requested",
|
||||
activityLabel: "interrupted",
|
||||
snapshotState: "present",
|
||||
snapshotProblemSinceMs: null,
|
||||
snapshotError: null,
|
||||
currentKind: "waiting",
|
||||
};
|
||||
}
|
||||
|
||||
function classifyProblemState(state: SubagentStatusState, now: number): Pick<StatusSnapshot, "kind" | "statusLabel"> {
|
||||
const problemLabel = snapshotProblemLabel(state.snapshotState);
|
||||
const hasValidSnapshot = state.lastActivityAtMs != null;
|
||||
|
||||
if (!hasValidSnapshot) {
|
||||
const referenceMs = state.firstObservationAtMs ?? state.startTimeMs;
|
||||
const elapsedMs = Math.max(0, now - referenceMs);
|
||||
return elapsedMs >= SNAPSHOT_STALLED_AFTER_MS
|
||||
? { kind: "stalled", statusLabel: problemLabel }
|
||||
: { kind: "starting", statusLabel: null };
|
||||
}
|
||||
|
||||
const problemSinceMs = state.snapshotProblemSinceMs ?? now;
|
||||
const problemMs = Math.max(0, now - problemSinceMs);
|
||||
if (problemMs >= SNAPSHOT_STALLED_AFTER_MS) return { kind: "stalled", statusLabel: problemLabel };
|
||||
|
||||
const lastHealthyKind = state.activeNow
|
||||
? "active"
|
||||
: state.waitingSinceMs != null || state.phase === "done"
|
||||
? "waiting"
|
||||
: state.currentKind === "stalled"
|
||||
? "starting"
|
||||
: state.currentKind;
|
||||
return { kind: lastHealthyKind, statusLabel: problemLabel };
|
||||
}
|
||||
|
||||
export function classifyStatus(state: SubagentStatusState, now: number): StatusSnapshot {
|
||||
const elapsedMs = Math.max(0, now - state.startTimeMs);
|
||||
const elapsedText = formatElapsedDuration(elapsedMs);
|
||||
|
||||
if (state.source === "claude") {
|
||||
return {
|
||||
kind: "running",
|
||||
elapsedMs,
|
||||
elapsedText,
|
||||
activeSinceMs: null,
|
||||
activeDurationText: null,
|
||||
activeScope: null,
|
||||
waitingSinceMs: null,
|
||||
waitingDurationText: null,
|
||||
latestEvent: null,
|
||||
activityLabel: null,
|
||||
snapshotState: state.snapshotState,
|
||||
snapshotError: null,
|
||||
snapshotProblemText: null,
|
||||
statusLabel: null,
|
||||
};
|
||||
}
|
||||
|
||||
let kind: SubagentStatusKind;
|
||||
let statusLabel: string | null = null;
|
||||
|
||||
if (state.snapshotState === "present") {
|
||||
if (state.phase === "active" || state.activeNow) {
|
||||
kind = "active";
|
||||
} else if (state.phase === "waiting") {
|
||||
kind = "waiting";
|
||||
} else if (state.phase === "done") {
|
||||
kind = "waiting";
|
||||
statusLabel = "done";
|
||||
} else {
|
||||
const referenceMs = state.firstObservationAtMs ?? state.startTimeMs;
|
||||
const elapsedSinceObservationMs = Math.max(0, now - referenceMs);
|
||||
kind = elapsedSinceObservationMs >= SNAPSHOT_STALLED_AFTER_MS ? "stalled" : "starting";
|
||||
statusLabel = null;
|
||||
}
|
||||
} else {
|
||||
const classified = classifyProblemState(state, now);
|
||||
kind = classified.kind;
|
||||
statusLabel = classified.statusLabel;
|
||||
}
|
||||
|
||||
const activeDurationText = state.activeSinceMs == null
|
||||
? null
|
||||
: formatElapsedDuration(now - state.activeSinceMs);
|
||||
const waitingDurationText = state.waitingSinceMs == null
|
||||
? null
|
||||
: formatElapsedDuration(now - state.waitingSinceMs);
|
||||
const snapshotProblemText = state.snapshotProblemSinceMs == null
|
||||
? null
|
||||
: formatElapsedDuration(now - state.snapshotProblemSinceMs);
|
||||
|
||||
return {
|
||||
kind,
|
||||
elapsedMs,
|
||||
elapsedText,
|
||||
activeSinceMs: state.activeSinceMs,
|
||||
activeDurationText,
|
||||
activeScope: state.activeScope,
|
||||
waitingSinceMs: state.waitingSinceMs,
|
||||
waitingDurationText,
|
||||
latestEvent: state.latestEvent,
|
||||
activityLabel: state.activityLabel,
|
||||
snapshotState: state.snapshotState,
|
||||
snapshotError: state.snapshotError,
|
||||
snapshotProblemText,
|
||||
statusLabel,
|
||||
};
|
||||
}
|
||||
|
||||
export function advanceStatusState(
|
||||
state: SubagentStatusState,
|
||||
now: number,
|
||||
): {
|
||||
nextState: SubagentStatusState;
|
||||
snapshot: StatusSnapshot;
|
||||
transition: SubagentStatusTransition;
|
||||
} {
|
||||
const snapshot = classifyStatus(state, now);
|
||||
const transition =
|
||||
state.currentKind !== "stalled" && snapshot.kind === "stalled"
|
||||
? "stalled"
|
||||
: state.currentKind === "stalled" && (snapshot.kind === "active" || snapshot.kind === "waiting")
|
||||
? "recovered"
|
||||
: null;
|
||||
|
||||
return {
|
||||
snapshot,
|
||||
transition,
|
||||
nextState: {
|
||||
...state,
|
||||
currentKind: snapshot.kind,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function formatActiveDetail(snapshot: StatusSnapshot): string {
|
||||
const label = activityLabel(snapshot);
|
||||
if (!label) return "active";
|
||||
const duration = snapshot.activeDurationText ? ` ${snapshot.activeDurationText}` : "";
|
||||
return `active (${label}${duration})`;
|
||||
}
|
||||
|
||||
function formatWaitingDetail(snapshot: StatusSnapshot): string {
|
||||
const duration = snapshot.waitingDurationText ? ` ${snapshot.waitingDurationText}` : "";
|
||||
return `waiting${duration}`;
|
||||
}
|
||||
|
||||
function formatStalledDetail(snapshot: StatusSnapshot): string {
|
||||
const detail = snapshot.statusLabel ? ` (${snapshot.statusLabel})` : "";
|
||||
const duration = snapshot.snapshotProblemText ? ` ${snapshot.snapshotProblemText}` : "";
|
||||
return `stalled${duration}${detail}`;
|
||||
}
|
||||
|
||||
export function formatStatusLine(name: string, snapshot: StatusSnapshot): string {
|
||||
const boundedName = normalizeStatusName(name);
|
||||
|
||||
if (snapshot.kind === "starting") {
|
||||
const label = snapshot.statusLabel ? ` (${snapshot.statusLabel})` : "";
|
||||
return boundStatusLine(`${boundedName} running ${snapshot.elapsedText}, starting${label}.`);
|
||||
}
|
||||
|
||||
if (snapshot.kind === "running") {
|
||||
return boundStatusLine(`${boundedName} running ${snapshot.elapsedText}.`);
|
||||
}
|
||||
|
||||
if (snapshot.kind === "active") {
|
||||
return boundStatusLine(`${boundedName} running ${snapshot.elapsedText}, ${formatActiveDetail(snapshot)}.`);
|
||||
}
|
||||
|
||||
if (snapshot.kind === "waiting") {
|
||||
const problem = snapshot.statusLabel && snapshot.statusLabel !== "done"
|
||||
? ` (${snapshot.statusLabel})`
|
||||
: snapshot.statusLabel === "done"
|
||||
? " (done)"
|
||||
: "";
|
||||
return boundStatusLine(`${boundedName} running ${snapshot.elapsedText}, ${formatWaitingDetail(snapshot)}${problem}.`);
|
||||
}
|
||||
|
||||
return boundStatusLine(`${boundedName} running ${snapshot.elapsedText}, ${formatStalledDetail(snapshot)}.`);
|
||||
}
|
||||
|
||||
export function formatTransitionLine(
|
||||
name: string,
|
||||
snapshot: StatusSnapshot,
|
||||
transition: Exclude<SubagentStatusTransition, null>,
|
||||
): string {
|
||||
const boundedName = normalizeStatusName(name);
|
||||
|
||||
if (transition === "recovered") {
|
||||
const detail = snapshot.kind === "waiting" ? formatWaitingDetail(snapshot) : formatActiveDetail(snapshot);
|
||||
return boundStatusLine(`${boundedName} running ${snapshot.elapsedText}, recovered; ${detail}.`);
|
||||
}
|
||||
|
||||
return formatStatusLine(boundedName, snapshot);
|
||||
}
|
||||
|
||||
export function capStatusLines(lines: string[], lineLimit: number): CappedStatusLines {
|
||||
const visibleLines = lines.slice(0, lineLimit);
|
||||
return {
|
||||
visibleLines,
|
||||
overflow: Math.max(0, lines.length - visibleLines.length),
|
||||
};
|
||||
}
|
||||
|
||||
export function formatStatusAggregate(lines: string[], lineLimit: number): string {
|
||||
const { visibleLines, overflow } = capStatusLines(lines, lineLimit);
|
||||
const bulletLines = visibleLines.map((line) => `• ${line}`);
|
||||
if (overflow > 0) bulletLines.push(`• +${overflow} more running.`);
|
||||
return `Subagent status:\n${bulletLines.join("\n")}`;
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
/**
|
||||
* Extension loaded into sub-agents.
|
||||
* - Shows agent identity + available tools as a styled widget above the editor (toggle with Ctrl+J)
|
||||
* - Provides a `subagent_done` tool for autonomous agents to self-terminate
|
||||
*/
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
import { Box, Text } from "@mariozechner/pi-tui";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { createSubagentActivityRecorder } from "./activity.ts";
|
||||
|
||||
export function shouldMarkUserTookOver(agentStarted: boolean): boolean {
|
||||
return agentStarted;
|
||||
}
|
||||
|
||||
export function shouldAutoExitOnAgentEnd(
|
||||
_userTookOver: boolean,
|
||||
messages: any[] | undefined,
|
||||
): boolean {
|
||||
// Manual input should not strand an auto-exit subagent. If the latest agent
|
||||
// turn completed normally, close the session. Escape/abort still leaves it
|
||||
// open for inspection or another prompt.
|
||||
//
|
||||
// stopReason: "error" (e.g. exhausted retries on a provider overload) also
|
||||
// returns true — we want to shut down so the parent is woken up — but we
|
||||
// pair this with findLatestAssistantError() so the parent learns it was an
|
||||
// error, not a clean completion.
|
||||
if (messages) {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const msg = messages[i];
|
||||
if (msg?.role === "assistant") {
|
||||
return msg.stopReason !== "aborted";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export interface SubagentErrorInfo {
|
||||
errorMessage: string;
|
||||
stopReason: "error";
|
||||
}
|
||||
|
||||
/**
|
||||
* If the last assistant message in the turn ended with `stopReason: "error"`
|
||||
* (typically auto-retry exhausted on an overload / rate limit / server error),
|
||||
* return its error info so the parent orchestrator can surface a clear
|
||||
* failure instead of silently treating the run as completed.
|
||||
*
|
||||
* Returns `null` when the latest assistant turn completed normally or was
|
||||
* aborted by the user (handled separately by shouldAutoExitOnAgentEnd).
|
||||
*/
|
||||
export function findLatestAssistantError(
|
||||
messages: any[] | undefined,
|
||||
): SubagentErrorInfo | null {
|
||||
if (!messages) return null;
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const msg = messages[i];
|
||||
if (msg?.role !== "assistant") continue;
|
||||
if (msg.stopReason !== "error") return null;
|
||||
const raw = typeof msg.errorMessage === "string" ? msg.errorMessage.trim() : "";
|
||||
return {
|
||||
errorMessage: raw || "Subagent agent loop ended with stopReason=error (no errorMessage field).",
|
||||
stopReason: "error",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseDeniedTools(rawValue: string | undefined): string[] {
|
||||
return (rawValue ?? "")
|
||||
.split(",")
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
let toolNames: string[] = [];
|
||||
let denied: string[] = [];
|
||||
let expanded = false;
|
||||
|
||||
// Read subagent identity from env vars (set by parent orchestrator)
|
||||
const subagentName = process.env.PI_SUBAGENT_NAME ?? "";
|
||||
const subagentAgent = process.env.PI_SUBAGENT_AGENT ?? "";
|
||||
const deniedToolsValue = process.env.PI_DENY_TOOLS;
|
||||
const autoExit = process.env.PI_SUBAGENT_AUTO_EXIT === "1";
|
||||
const recorder = createSubagentActivityRecorder({
|
||||
runningChildId: process.env.PI_SUBAGENT_ID,
|
||||
activityFile: process.env.PI_SUBAGENT_ACTIVITY_FILE,
|
||||
});
|
||||
|
||||
function renderWidget(ctx: { ui: { setWidget: Function } }, _theme: any) {
|
||||
ctx.ui.setWidget(
|
||||
"subagent-tools",
|
||||
(_tui: any, theme: any) => {
|
||||
const box = new Box(1, 0, (text: string) => theme.bg("toolSuccessBg", text));
|
||||
|
||||
const label = subagentAgent || subagentName;
|
||||
const agentTag = label ? theme.bold(theme.fg("accent", `[${label}]`)) : "";
|
||||
|
||||
if (expanded) {
|
||||
// Expanded: full tool list + denied
|
||||
const countInfo = theme.fg("dim", ` — ${toolNames.length} available`);
|
||||
const hint = theme.fg("muted", " (Ctrl+J to collapse)");
|
||||
|
||||
const toolList = toolNames
|
||||
.map((name: string) => theme.fg("dim", name))
|
||||
.join(theme.fg("muted", ", "));
|
||||
|
||||
let deniedLine = "";
|
||||
if (denied.length > 0) {
|
||||
const deniedList = denied
|
||||
.map((name: string) => theme.fg("error", name))
|
||||
.join(theme.fg("muted", ", "));
|
||||
deniedLine = "\n" + theme.fg("muted", "denied: ") + deniedList;
|
||||
}
|
||||
|
||||
const content = new Text(
|
||||
`${agentTag}${countInfo}${hint}\n${toolList}${deniedLine}`,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
box.addChild(content);
|
||||
} else {
|
||||
// Collapsed: one-line summary
|
||||
const countInfo = theme.fg("dim", ` — ${toolNames.length} tools`);
|
||||
const deniedInfo =
|
||||
denied.length > 0
|
||||
? theme.fg("dim", " · ") + theme.fg("error", `${denied.length} denied`)
|
||||
: "";
|
||||
const hint = theme.fg("muted", " (Ctrl+J to expand)");
|
||||
|
||||
const content = new Text(`${agentTag}${countInfo}${deniedInfo}${hint}`, 0, 0);
|
||||
box.addChild(content);
|
||||
}
|
||||
|
||||
return box;
|
||||
},
|
||||
{ placement: "aboveEditor" },
|
||||
);
|
||||
}
|
||||
|
||||
let userTookOver = false;
|
||||
let agentStarted = false;
|
||||
|
||||
// Show widget + status bar on session start
|
||||
pi.on("session_start", (_event, ctx) => {
|
||||
recorder.sessionStart();
|
||||
const tools = pi.getAllTools();
|
||||
toolNames = tools.map((t) => t.name).sort();
|
||||
denied = parseDeniedTools(deniedToolsValue);
|
||||
|
||||
renderWidget(ctx, null);
|
||||
});
|
||||
|
||||
pi.on("input", () => {
|
||||
recorder.input();
|
||||
// Ignore the initial task message that starts an autonomous subagent.
|
||||
// Only inputs after the first agent run has started count as user takeover.
|
||||
if (!shouldMarkUserTookOver(agentStarted)) return;
|
||||
userTookOver = true;
|
||||
});
|
||||
|
||||
pi.on("before_agent_start", () => {
|
||||
recorder.beforeAgentStart();
|
||||
});
|
||||
|
||||
pi.on("agent_start", () => {
|
||||
agentStarted = true;
|
||||
recorder.agentStart();
|
||||
});
|
||||
|
||||
pi.on("agent_end", (event, ctx) => {
|
||||
const messages = (event as any).messages as any[] | undefined;
|
||||
const shouldExit = autoExit && shouldAutoExitOnAgentEnd(userTookOver, messages);
|
||||
|
||||
if (shouldExit) {
|
||||
// Surface stopReason: "error" turns (auto-retry exhausted, provider
|
||||
// overload, etc.) to the parent via the .exit sidecar so the watcher
|
||||
// can report a clear failure with the underlying error message.
|
||||
// Without this the parent would only see exit code 0 and a stale
|
||||
// assistant message, mistaking the crash for a successful completion.
|
||||
const errorInfo = findLatestAssistantError(messages);
|
||||
const sessionFile = process.env.PI_SUBAGENT_SESSION;
|
||||
if (errorInfo && sessionFile) {
|
||||
try {
|
||||
writeFileSync(
|
||||
`${sessionFile}.exit`,
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
errorMessage: errorInfo.errorMessage,
|
||||
stopReason: errorInfo.stopReason,
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
// Best effort — even without the sidecar, watcher's session-file
|
||||
// fallback can still recover the errorMessage.
|
||||
}
|
||||
}
|
||||
|
||||
recorder.agentEndDone();
|
||||
ctx.shutdown();
|
||||
return;
|
||||
}
|
||||
|
||||
recorder.agentEndWaiting();
|
||||
if (autoExit) {
|
||||
// Reset any recorded manual input marker. Auto-exit is decided by whether
|
||||
// the latest agent turn completed normally, not by who initiated it.
|
||||
userTookOver = false;
|
||||
}
|
||||
});
|
||||
|
||||
pi.on("turn_start", (event) => {
|
||||
recorder.turnStart((event as any).turnIndex);
|
||||
});
|
||||
|
||||
pi.on("turn_end", (event) => {
|
||||
recorder.turnEnd((event as any).turnIndex);
|
||||
});
|
||||
|
||||
pi.on("before_provider_request", () => {
|
||||
recorder.beforeProviderRequest();
|
||||
});
|
||||
|
||||
pi.on("after_provider_response", () => {
|
||||
recorder.afterProviderResponse();
|
||||
});
|
||||
|
||||
pi.on("message_update", (event) => {
|
||||
recorder.messageUpdate((event as any).assistantMessageEvent?.type);
|
||||
});
|
||||
|
||||
pi.on("tool_execution_start", (event) => {
|
||||
recorder.toolExecutionStart((event as any).toolCallId, (event as any).toolName);
|
||||
});
|
||||
|
||||
pi.on("tool_call", (event) => {
|
||||
recorder.toolCall((event as any).toolCallId, (event as any).toolName);
|
||||
});
|
||||
|
||||
pi.on("tool_execution_update", (event) => {
|
||||
recorder.toolExecutionUpdate((event as any).toolCallId, (event as any).toolName);
|
||||
});
|
||||
|
||||
pi.on("tool_result", (event) => {
|
||||
recorder.toolResult((event as any).toolCallId, (event as any).toolName);
|
||||
});
|
||||
|
||||
pi.on("tool_execution_end", (event) => {
|
||||
recorder.toolExecutionEnd((event as any).toolCallId, (event as any).toolName);
|
||||
});
|
||||
|
||||
pi.on("session_shutdown", (event) => {
|
||||
recorder.sessionShutdown((event as any).reason);
|
||||
});
|
||||
|
||||
// Toggle expand/collapse with Ctrl+J
|
||||
pi.registerShortcut("ctrl+j", {
|
||||
description: "Toggle subagent tools widget",
|
||||
handler: (ctx) => {
|
||||
expanded = !expanded;
|
||||
renderWidget(ctx, null);
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerTool({
|
||||
name: "caller_ping",
|
||||
label: "Caller Ping",
|
||||
description:
|
||||
"Send a help request to the parent agent and exit this session. " +
|
||||
"The parent will be notified with your message and can resume this session with a response. " +
|
||||
"Use when you're stuck, need clarification, or need the parent to take action.",
|
||||
parameters: Type.Object({
|
||||
message: Type.String({ description: "What you need help with" }),
|
||||
}),
|
||||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||
const sessionFile = process.env.PI_SUBAGENT_SESSION;
|
||||
if (!sessionFile) {
|
||||
throw new Error(
|
||||
"caller_ping is only available in subagent contexts. " +
|
||||
"PI_SUBAGENT_SESSION environment variable is not set.",
|
||||
);
|
||||
}
|
||||
|
||||
recorder.callerPing();
|
||||
const exitData = {
|
||||
type: "ping" as const,
|
||||
name: process.env.PI_SUBAGENT_NAME ?? "subagent",
|
||||
message: params.message,
|
||||
};
|
||||
writeFileSync(`${sessionFile}.exit`, JSON.stringify(exitData));
|
||||
|
||||
ctx.shutdown();
|
||||
return {
|
||||
content: [{ type: "text", text: "Ping sent. Session will exit and parent will be notified." }],
|
||||
details: {},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerTool({
|
||||
name: "subagent_done",
|
||||
label: "Subagent Done",
|
||||
description:
|
||||
"Call this tool when you have completed your task. " +
|
||||
"It will close this session and return your results to the main session. " +
|
||||
"Your LAST assistant message before calling this becomes the summary returned to the caller.",
|
||||
parameters: Type.Object({}),
|
||||
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
|
||||
const sessionFile = process.env.PI_SUBAGENT_SESSION;
|
||||
recorder.subagentDone();
|
||||
if (sessionFile) {
|
||||
writeFileSync(`${sessionFile}.exit`, JSON.stringify({ type: "done" }));
|
||||
}
|
||||
ctx.shutdown();
|
||||
return {
|
||||
content: [{ type: "text", text: "Shutting down subagent session." }],
|
||||
details: {},
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user