Initial commit

This commit is contained in:
Amos
2026-06-26 22:25:19 +03:00
commit c6c00f89a7
29 changed files with 14358 additions and 0 deletions
+511
View File
@@ -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
+203
View File
@@ -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
View File
@@ -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
+180
View File
@@ -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;
}
+513
View File
@@ -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")}`;
}
+324
View File
@@ -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: {},
};
},
});
}