|
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
|
|
mkdirSync,
|
|
|
|
|
copyFileSync,
|
|
|
|
|
unlinkSync,
|
|
|
|
|
rmSync,
|
|
|
|
|
} from "node:fs";
|
|
|
|
|
import { homedir } from "node:os";
|
|
|
|
|
import {
|
|
|
|
@@ -22,6 +23,7 @@ import {
|
|
|
|
|
sendLongCommand,
|
|
|
|
|
pollForExit,
|
|
|
|
|
closeSurface,
|
|
|
|
|
isSurfaceAlive,
|
|
|
|
|
shellEscape,
|
|
|
|
|
readScreen,
|
|
|
|
|
getMuxBackend,
|
|
|
|
@@ -350,10 +352,11 @@ function discoverAgentDefinitions(): ListedAgentDefinition[] {
|
|
|
|
|
function resolveSubagentPaths(
|
|
|
|
|
params: Static<typeof SubagentParams>,
|
|
|
|
|
agentDefs: AgentDefaults | null,
|
|
|
|
|
parentCwd = process.cwd(),
|
|
|
|
|
): { effectiveCwd: string | null; localAgentDir: string | null; effectiveAgentDir: string } {
|
|
|
|
|
const rawCwd = params.cwd ?? agentDefs?.cwd ?? null;
|
|
|
|
|
const cwdIsFromAgent = !params.cwd && agentDefs?.cwd != null;
|
|
|
|
|
const cwdBase = cwdIsFromAgent ? getAgentConfigDir() : process.cwd();
|
|
|
|
|
const cwdBase = cwdIsFromAgent ? getAgentConfigDir() : parentCwd;
|
|
|
|
|
const effectiveCwd = rawCwd
|
|
|
|
|
? rawCwd.startsWith("/")
|
|
|
|
|
? rawCwd
|
|
|
|
@@ -400,22 +403,37 @@ function resolveLaunchBehavior(
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Fire-and-forget launches must terminate without a human closing their pane.
|
|
|
|
|
* Profiles can opt into a user-driven session with `auto-exit: false`.
|
|
|
|
|
*/
|
|
|
|
|
function resolveEffectiveAutoExit(agentDefs: AgentDefaults | null): boolean {
|
|
|
|
|
return agentDefs?.autoExit ?? true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function autoExitEnvAssignment(autoExit: boolean): string {
|
|
|
|
|
return `PI_SUBAGENT_AUTO_EXIT=${autoExit ? "1" : "0"}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function resolveClaudeSentinelFile(id: string, autoExit: boolean): string | undefined {
|
|
|
|
|
return autoExit ? `/tmp/pi-claude-${id}-done` : undefined;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Decide whether a subagent is interactive (user-driven, long-running).
|
|
|
|
|
*
|
|
|
|
|
* Resolution order:
|
|
|
|
|
* 1. Explicit `interactive` frontmatter field on the agent.
|
|
|
|
|
* 2. Default: the inverse of `auto-exit`. Agents that auto-exit are
|
|
|
|
|
* autonomous (scout, researcher) and the parent session should be
|
|
|
|
|
* woken on stall/recovery transitions. Agents that don't auto-exit are
|
|
|
|
|
* driven by the user in their own pane (worker) and stall pings are noise.
|
|
|
|
|
* 2. Default: the inverse of the effective `auto-exit` behavior. Autonomous
|
|
|
|
|
* agents wake the parent on stall/recovery transitions; explicitly
|
|
|
|
|
* user-driven agents stay quiet because the user is already in their pane.
|
|
|
|
|
*/
|
|
|
|
|
function resolveEffectiveInteractive(
|
|
|
|
|
_params: Static<typeof SubagentParams>,
|
|
|
|
|
agentDefs: AgentDefaults | null,
|
|
|
|
|
): boolean {
|
|
|
|
|
if (agentDefs?.interactive != null) return agentDefs.interactive;
|
|
|
|
|
return !(agentDefs?.autoExit ?? false);
|
|
|
|
|
return !resolveEffectiveAutoExit(agentDefs);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function loadAgentDefaults(agentName: string): AgentDefaults | null {
|
|
|
|
@@ -687,7 +705,10 @@ interface RunningSubagent {
|
|
|
|
|
cli?: string;
|
|
|
|
|
sentinelFile?: string;
|
|
|
|
|
completionFile?: string;
|
|
|
|
|
completionObserved?: boolean;
|
|
|
|
|
statusState: SubagentStatusState;
|
|
|
|
|
/** Parent-session generation. Reload preserves it; replacement increments it. */
|
|
|
|
|
parentGeneration: number;
|
|
|
|
|
/**
|
|
|
|
|
* When true, status transitions (stalled/recovered) do not wake the parent
|
|
|
|
|
* session via a steer message. The widget still updates locally. Used for
|
|
|
|
@@ -731,13 +752,16 @@ function isStatusWidgetClaim(value: unknown): value is StatusWidgetClaim {
|
|
|
|
|
|
|
|
|
|
interface SharedSubagentRuntime {
|
|
|
|
|
runningSubagents: Map<string, RunningSubagent>;
|
|
|
|
|
resumingNames: Set<string>;
|
|
|
|
|
latestCtx: ExtensionContext | null;
|
|
|
|
|
latestPi: ExtensionAPI | null;
|
|
|
|
|
widgetInterval: ReturnType<typeof setInterval> | null;
|
|
|
|
|
statusInterval: ReturnType<typeof setInterval> | null;
|
|
|
|
|
standaloneWidgetSyncScheduled: boolean;
|
|
|
|
|
pendingDeliveries: PendingDelivery[];
|
|
|
|
|
resultDeliveryStates: Map<string, "queued" | "delivered">;
|
|
|
|
|
deliveryFlushScheduled: boolean;
|
|
|
|
|
parentGeneration: number;
|
|
|
|
|
activeRuntimeToken: symbol | null;
|
|
|
|
|
registeredRuntimeToken: symbol | null;
|
|
|
|
|
statusWidgetClaim: StatusWidgetClaim | null;
|
|
|
|
@@ -748,13 +772,16 @@ interface SharedSubagentRuntime {
|
|
|
|
|
function createSharedRuntime(): SharedSubagentRuntime {
|
|
|
|
|
return {
|
|
|
|
|
runningSubagents: new Map(),
|
|
|
|
|
resumingNames: new Set(),
|
|
|
|
|
latestCtx: null,
|
|
|
|
|
latestPi: null,
|
|
|
|
|
widgetInterval: null,
|
|
|
|
|
statusInterval: null,
|
|
|
|
|
standaloneWidgetSyncScheduled: false,
|
|
|
|
|
pendingDeliveries: [],
|
|
|
|
|
resultDeliveryStates: new Map(),
|
|
|
|
|
deliveryFlushScheduled: false,
|
|
|
|
|
parentGeneration: 0,
|
|
|
|
|
activeRuntimeToken: null,
|
|
|
|
|
registeredRuntimeToken: null,
|
|
|
|
|
statusWidgetClaim: null,
|
|
|
|
@@ -783,9 +810,36 @@ if (sharedRuntime.activeRuntimeToken !== null && typeof sharedRuntime.activeRunt
|
|
|
|
|
if (typeof sharedRuntime.standaloneWidgetSyncScheduled !== "boolean") {
|
|
|
|
|
sharedRuntime.standaloneWidgetSyncScheduled = false;
|
|
|
|
|
}
|
|
|
|
|
if (!(sharedRuntime.resultDeliveryStates instanceof Map)) {
|
|
|
|
|
sharedRuntime.resultDeliveryStates = new Map();
|
|
|
|
|
}
|
|
|
|
|
if (!(sharedRuntime.resumingNames instanceof Set)) {
|
|
|
|
|
sharedRuntime.resumingNames = new Set();
|
|
|
|
|
}
|
|
|
|
|
if (!Number.isSafeInteger(sharedRuntime.parentGeneration)) {
|
|
|
|
|
sharedRuntime.parentGeneration = 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** All currently running subagents, preserved across /reload. */
|
|
|
|
|
const runningSubagents = sharedRuntime.runningSubagents;
|
|
|
|
|
const resumingNames = sharedRuntime.resumingNames;
|
|
|
|
|
|
|
|
|
|
function reserveResumeName(name: string): boolean {
|
|
|
|
|
if (resumingNames.has(name)) return false;
|
|
|
|
|
resumingNames.add(name);
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function clearStaleSessionSignals(sessionFile: string): void {
|
|
|
|
|
rmSync(`${sessionFile}.exit`, { force: true });
|
|
|
|
|
rmSync(`${sessionFile}.ask`, { force: true });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const running of runningSubagents.values()) {
|
|
|
|
|
if (!Number.isSafeInteger(running.parentGeneration)) {
|
|
|
|
|
running.parentGeneration = sharedRuntime.parentGeneration;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// When this extension is loaded inside a subagent that itself spawns children
|
|
|
|
|
// (e.g. a worker delegating to scout/researcher), `subagent-done.ts` runs in the
|
|
|
|
@@ -798,22 +852,83 @@ const RUNNING_CHILDREN_COUNT_KEY = Symbol.for("pi-subagents/running-children-cou
|
|
|
|
|
|
|
|
|
|
// ── Widget and runtime delivery management ──
|
|
|
|
|
|
|
|
|
|
const MAX_RESULT_DELIVERY_TOMBSTONES = 1_024;
|
|
|
|
|
const AUTOMATIC_DELIVERY_OPTIONS = { triggerTurn: true, deliverAs: "followUp" as const };
|
|
|
|
|
|
|
|
|
|
function getReadyRuntimePi(): ExtensionAPI | null {
|
|
|
|
|
if (
|
|
|
|
|
sharedRuntime.latestPi
|
|
|
|
|
&& sharedRuntime.activeRuntimeToken !== null
|
|
|
|
|
&& sharedRuntime.activeRuntimeToken === sharedRuntime.registeredRuntimeToken
|
|
|
|
|
) {
|
|
|
|
|
return sharedRuntime.latestPi;
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function sendWhenRuntimeReady(delivery: PendingDelivery): void {
|
|
|
|
|
const pi = sharedRuntime.latestPi;
|
|
|
|
|
const pi = getReadyRuntimePi();
|
|
|
|
|
if (pi) {
|
|
|
|
|
delivery(pi);
|
|
|
|
|
return;
|
|
|
|
|
try {
|
|
|
|
|
delivery(pi);
|
|
|
|
|
return;
|
|
|
|
|
} catch {
|
|
|
|
|
// Retry against the next bound runtime. A failed notification must not
|
|
|
|
|
// prevent sibling callbacks from being processed.
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
sharedRuntime.pendingDeliveries.push(delivery);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function pruneResultDeliveryTombstones(): void {
|
|
|
|
|
if (sharedRuntime.resultDeliveryStates.size <= MAX_RESULT_DELIVERY_TOMBSTONES) return;
|
|
|
|
|
for (const [id, state] of sharedRuntime.resultDeliveryStates) {
|
|
|
|
|
if (state !== "delivered") continue;
|
|
|
|
|
sharedRuntime.resultDeliveryStates.delete(id);
|
|
|
|
|
if (sharedRuntime.resultDeliveryStates.size <= MAX_RESULT_DELIVERY_TOMBSTONES) return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Queue or deliver one terminal result for a child id, rejecting duplicates. */
|
|
|
|
|
function sendResultWhenRuntimeReady(id: string, delivery: PendingDelivery): boolean {
|
|
|
|
|
if (sharedRuntime.resultDeliveryStates.has(id)) return false;
|
|
|
|
|
sharedRuntime.resultDeliveryStates.set(id, "queued");
|
|
|
|
|
|
|
|
|
|
const deliverOnce = (pi: ExtensionAPI) => {
|
|
|
|
|
if (sharedRuntime.resultDeliveryStates.get(id) === "delivered") return;
|
|
|
|
|
delivery(pi);
|
|
|
|
|
// Mark only after sendMessage succeeds so a stale runtime can be retried.
|
|
|
|
|
sharedRuntime.resultDeliveryStates.delete(id);
|
|
|
|
|
sharedRuntime.resultDeliveryStates.set(id, "delivered");
|
|
|
|
|
pruneResultDeliveryTombstones();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const pi = getReadyRuntimePi();
|
|
|
|
|
if (pi) {
|
|
|
|
|
try {
|
|
|
|
|
deliverOnce(pi);
|
|
|
|
|
return true;
|
|
|
|
|
} catch {
|
|
|
|
|
// Keep the queued state and retry after a fresh runtime binds.
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
sharedRuntime.pendingDeliveries.push(deliverOnce);
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function flushPendingDeliveries(): void {
|
|
|
|
|
sharedRuntime.deliveryFlushScheduled = false;
|
|
|
|
|
const pi = sharedRuntime.latestPi;
|
|
|
|
|
const pi = getReadyRuntimePi();
|
|
|
|
|
if (!pi || sharedRuntime.pendingDeliveries.length === 0) return;
|
|
|
|
|
|
|
|
|
|
const pending = sharedRuntime.pendingDeliveries.splice(0);
|
|
|
|
|
for (const delivery of pending) delivery(pi);
|
|
|
|
|
for (const delivery of pending) {
|
|
|
|
|
try {
|
|
|
|
|
delivery(pi);
|
|
|
|
|
} catch {
|
|
|
|
|
sharedRuntime.pendingDeliveries.push(delivery);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function schedulePendingDeliveryFlush(): void {
|
|
|
|
@@ -1366,6 +1481,28 @@ function steerSubagent(
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function hasTerminalCompletionSignal(running: RunningSubagent): boolean {
|
|
|
|
|
return !!(
|
|
|
|
|
running.completionObserved
|
|
|
|
|
|| (running.completionFile && existsSync(running.completionFile))
|
|
|
|
|
|| existsSync(`${running.sessionFile}.exit`)
|
|
|
|
|
|| (running.sentinelFile && existsSync(running.sentinelFile))
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function waitForWatcherCleanup(
|
|
|
|
|
running: RunningSubagent,
|
|
|
|
|
signal: AbortSignal | undefined,
|
|
|
|
|
timeoutMs = 2_000,
|
|
|
|
|
): Promise<boolean> {
|
|
|
|
|
const deadline = Date.now() + timeoutMs;
|
|
|
|
|
while (runningSubagents.has(running.id) && Date.now() < deadline) {
|
|
|
|
|
if (signal?.aborted) return false;
|
|
|
|
|
await new Promise<void>((resolve) => setTimeout(resolve, 25));
|
|
|
|
|
}
|
|
|
|
|
return !runningSubagents.has(running.id);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function handleSubagentSteer(
|
|
|
|
|
params: { name?: string; message?: string },
|
|
|
|
|
send: (surface: string, command: string) => void = sendCommand,
|
|
|
|
@@ -1466,12 +1603,26 @@ function startStatusRefresh() {
|
|
|
|
|
|
|
|
|
|
// Resuming a finished session is always autonomous: the relaunched agent runs
|
|
|
|
|
// its follow-up task to completion and the harness delivers the result as a
|
|
|
|
|
// steer message (fire-and-forget). An interactive resume would park the pane
|
|
|
|
|
// queued follow-up notification (fire-and-forget). An interactive resume would park the pane
|
|
|
|
|
// waiting for the user, contradicting that result-delivery model.
|
|
|
|
|
function resolveResumeLaunchBehavior(): { autoExit: boolean; interactive: boolean } {
|
|
|
|
|
return { autoExit: true, interactive: false };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function shouldDeliverRunningResult(running: RunningSubagent): boolean {
|
|
|
|
|
return !running.suppressDelivery && running.parentGeneration === sharedRuntime.parentGeneration;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function assertLaunchActive(signal: AbortSignal | undefined, runtimeToken: symbol): void {
|
|
|
|
|
if (
|
|
|
|
|
signal?.aborted
|
|
|
|
|
|| sharedRuntime.activeRuntimeToken !== runtimeToken
|
|
|
|
|
|| sharedRuntime.registeredRuntimeToken !== runtimeToken
|
|
|
|
|
) {
|
|
|
|
|
throw new Error("Parent session ended or was replaced before the subagent launch completed.");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const __test__ = {
|
|
|
|
|
borderLine,
|
|
|
|
|
defaultShellReadyDelayMs,
|
|
|
|
@@ -1481,8 +1632,12 @@ export const __test__ = {
|
|
|
|
|
updateWidget,
|
|
|
|
|
loadAgentDefaults,
|
|
|
|
|
discoverAgentDefinitions,
|
|
|
|
|
resolveSubagentPaths,
|
|
|
|
|
resolveEffectiveSessionMode,
|
|
|
|
|
resolveLaunchBehavior,
|
|
|
|
|
resolveEffectiveAutoExit,
|
|
|
|
|
autoExitEnvAssignment,
|
|
|
|
|
resolveClaudeSentinelFile,
|
|
|
|
|
resolveEffectiveInteractive,
|
|
|
|
|
buildSubagentToolAllowlist,
|
|
|
|
|
applySandboxToParts,
|
|
|
|
@@ -1493,10 +1648,18 @@ export const __test__ = {
|
|
|
|
|
resolveRunningByName,
|
|
|
|
|
uniqueRunningName,
|
|
|
|
|
reservedNames,
|
|
|
|
|
resumingNames,
|
|
|
|
|
reserveResumeName,
|
|
|
|
|
clearStaleSessionSignals,
|
|
|
|
|
hasTerminalCompletionSignal,
|
|
|
|
|
waitForWatcherCleanup,
|
|
|
|
|
deliverPendingQuestion,
|
|
|
|
|
steerSubagent,
|
|
|
|
|
handleSubagentSteer,
|
|
|
|
|
resolveResultPresentation,
|
|
|
|
|
resolveResumeLaunchBehavior,
|
|
|
|
|
shouldDeliverRunningResult,
|
|
|
|
|
assertLaunchActive,
|
|
|
|
|
wrapWithCompletionFile,
|
|
|
|
|
runningSubagents,
|
|
|
|
|
formatElapsed,
|
|
|
|
@@ -1506,6 +1669,8 @@ export const __test__ = {
|
|
|
|
|
formatUsageSegments,
|
|
|
|
|
widgetIcon,
|
|
|
|
|
sendWhenRuntimeReady,
|
|
|
|
|
sendResultWhenRuntimeReady,
|
|
|
|
|
AUTOMATIC_DELIVERY_OPTIONS,
|
|
|
|
|
flushPendingDeliveries,
|
|
|
|
|
sharedRuntime,
|
|
|
|
|
};
|
|
|
|
@@ -1534,8 +1699,9 @@ function startWidgetRefresh() {
|
|
|
|
|
async function launchSubagent(
|
|
|
|
|
params: Static<typeof SubagentParams>,
|
|
|
|
|
ctx: { sessionManager: { getSessionFile(): string | null | undefined; getSessionId(): string; getSessionDir(): string }; cwd: string },
|
|
|
|
|
options?: { surface?: string },
|
|
|
|
|
options?: { surface?: string; ensureActive?: () => void },
|
|
|
|
|
): Promise<RunningSubagent> {
|
|
|
|
|
options?.ensureActive?.();
|
|
|
|
|
const startTime = Date.now();
|
|
|
|
|
const id = Math.random().toString(16).slice(2, 10);
|
|
|
|
|
const name = params.name?.trim() || params.agent;
|
|
|
|
@@ -1545,6 +1711,7 @@ async function launchSubagent(
|
|
|
|
|
const effectiveTools = agentDefs?.tools;
|
|
|
|
|
const effectiveSkills = agentDefs?.skills;
|
|
|
|
|
const effectiveThinking = agentDefs?.thinking;
|
|
|
|
|
const effectiveAutoExit = resolveEffectiveAutoExit(agentDefs);
|
|
|
|
|
const effectiveInteractive = resolveEffectiveInteractive(params, agentDefs);
|
|
|
|
|
|
|
|
|
|
const sessionFile = ctx.sessionManager.getSessionFile();
|
|
|
|
@@ -1554,7 +1721,11 @@ async function launchSubagent(
|
|
|
|
|
const completionFile = join(artifactDir, "subagent-exits", `${id}.exit-code`);
|
|
|
|
|
mkdirSync(dirname(completionFile), { recursive: true });
|
|
|
|
|
|
|
|
|
|
const { effectiveCwd, localAgentDir, effectiveAgentDir } = resolveSubagentPaths(params, agentDefs);
|
|
|
|
|
const { effectiveCwd, localAgentDir, effectiveAgentDir } = resolveSubagentPaths(
|
|
|
|
|
params,
|
|
|
|
|
agentDefs,
|
|
|
|
|
ctx.cwd,
|
|
|
|
|
);
|
|
|
|
|
const targetCwdForSession = effectiveCwd ?? ctx.cwd;
|
|
|
|
|
const sessionDir = getDefaultSessionDirFor(targetCwdForSession, effectiveAgentDir);
|
|
|
|
|
|
|
|
|
@@ -1579,6 +1750,7 @@ async function launchSubagent(
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
options?.ensureActive?.();
|
|
|
|
|
const launchBehavior = resolveLaunchBehavior(params, agentDefs);
|
|
|
|
|
|
|
|
|
|
if (launchBehavior.seededSessionMode) {
|
|
|
|
@@ -1597,10 +1769,10 @@ async function launchSubagent(
|
|
|
|
|
// Build the task message
|
|
|
|
|
// Only full-context fork mode inherits prior conversation state.
|
|
|
|
|
// Blank-session modes need the wrapper instructions and artifact-backed handoff.
|
|
|
|
|
const modeHint = agentDefs?.autoExit
|
|
|
|
|
const modeHint = effectiveAutoExit
|
|
|
|
|
? "Complete your task autonomously. When you are finished, simply stop — your session ends automatically."
|
|
|
|
|
: "Complete your task. The user can interact with you at any time, and the session ends when the user exits the pane.";
|
|
|
|
|
const summaryInstruction = agentDefs?.autoExit
|
|
|
|
|
const summaryInstruction = effectiveAutoExit
|
|
|
|
|
? "Your FINAL assistant message should summarize what you accomplished."
|
|
|
|
|
: "Your FINAL assistant message (before the user exits) should summarize what you accomplished.";
|
|
|
|
|
// An agent with a non-empty subagent_agents list is granted the spawning
|
|
|
|
@@ -1615,11 +1787,13 @@ async function launchSubagent(
|
|
|
|
|
: `${roleBlock}\n\n${modeHint}\n\n${params.task}\n\n${summaryInstruction}`;
|
|
|
|
|
// ── Claude Code CLI path ──
|
|
|
|
|
if (agentDefs?.cli === "claude") {
|
|
|
|
|
const sentinelFile = `/tmp/pi-claude-${id}-done`;
|
|
|
|
|
const sentinelFile = resolveClaudeSentinelFile(id, effectiveAutoExit);
|
|
|
|
|
const pluginDir = join(SUBAGENTS_DIR, "plugin");
|
|
|
|
|
|
|
|
|
|
const cmdParts: string[] = [];
|
|
|
|
|
cmdParts.push(`PI_CLAUDE_SENTINEL=${shellEscape(sentinelFile)}`);
|
|
|
|
|
if (sentinelFile) {
|
|
|
|
|
cmdParts.push(`PI_CLAUDE_SENTINEL=${shellEscape(sentinelFile)}`);
|
|
|
|
|
}
|
|
|
|
|
cmdParts.push("claude");
|
|
|
|
|
cmdParts.push("--dangerously-skip-permissions");
|
|
|
|
|
|
|
|
|
@@ -1651,14 +1825,17 @@ async function launchSubagent(
|
|
|
|
|
.replace(/^-|-$/g, "") || "subagent"}-${id}.sh`;
|
|
|
|
|
const launchScriptFile = join(artifactDir, "subagent-scripts", launchScriptName);
|
|
|
|
|
|
|
|
|
|
options?.ensureActive?.();
|
|
|
|
|
sendLongCommand(surface, command, {
|
|
|
|
|
scriptPath: launchScriptFile,
|
|
|
|
|
replaceShell: true,
|
|
|
|
|
scriptPreamble: [
|
|
|
|
|
`# Claude Code subagent launch script for ${name}`,
|
|
|
|
|
`# Generated: ${new Date().toISOString()}`,
|
|
|
|
|
`# Surface: ${surface}`,
|
|
|
|
|
].join("\n"),
|
|
|
|
|
});
|
|
|
|
|
options?.ensureActive?.();
|
|
|
|
|
|
|
|
|
|
const running: RunningSubagent = {
|
|
|
|
|
id,
|
|
|
|
@@ -1677,6 +1854,7 @@ async function launchSubagent(
|
|
|
|
|
source: "claude",
|
|
|
|
|
startTimeMs: startTime,
|
|
|
|
|
}),
|
|
|
|
|
parentGeneration: sharedRuntime.parentGeneration,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
runningSubagents.set(id, running);
|
|
|
|
@@ -1717,7 +1895,7 @@ async function launchSubagent(
|
|
|
|
|
systemPromptMode: systemPromptMode ?? null,
|
|
|
|
|
identity: identityInSystemPrompt ? identity : null,
|
|
|
|
|
spawnable: agentDefs?.subagentAgents ?? null,
|
|
|
|
|
autoExit: agentDefs?.autoExit ?? false,
|
|
|
|
|
autoExit: effectiveAutoExit,
|
|
|
|
|
cwd: effectiveCwd ?? null,
|
|
|
|
|
agentDir: resolvedAgentDir,
|
|
|
|
|
};
|
|
|
|
@@ -1741,9 +1919,7 @@ async function launchSubagent(
|
|
|
|
|
if (params.agent) {
|
|
|
|
|
envParts.push(`PI_SUBAGENT_AGENT=${shellEscape(params.agent)}`);
|
|
|
|
|
}
|
|
|
|
|
if (agentDefs?.autoExit) {
|
|
|
|
|
envParts.push(`PI_SUBAGENT_AUTO_EXIT=1`);
|
|
|
|
|
}
|
|
|
|
|
envParts.push(autoExitEnvAssignment(effectiveAutoExit));
|
|
|
|
|
envParts.push(`PI_SUBAGENT_SESSION=${shellEscape(subagentSessionFile)}`);
|
|
|
|
|
envParts.push(`PI_SUBAGENT_ID=${shellEscape(id)}`);
|
|
|
|
|
envParts.push(`PI_SUBAGENT_ACTIVITY_FILE=${shellEscape(activityFile)}`);
|
|
|
|
@@ -1793,8 +1969,10 @@ async function launchSubagent(
|
|
|
|
|
.replace(/-+/g, "-")
|
|
|
|
|
.replace(/^-|-$/g, "") || "subagent"}-${id}.sh`;
|
|
|
|
|
const launchScriptFile = join(artifactDir, "subagent-scripts", launchScriptName);
|
|
|
|
|
options?.ensureActive?.();
|
|
|
|
|
sendLongCommand(surface, command, {
|
|
|
|
|
scriptPath: launchScriptFile,
|
|
|
|
|
replaceShell: true,
|
|
|
|
|
scriptPreamble: [
|
|
|
|
|
`# Subagent launch script for ${name}`,
|
|
|
|
|
`# Generated: ${new Date().toISOString()}`,
|
|
|
|
@@ -1802,6 +1980,7 @@ async function launchSubagent(
|
|
|
|
|
`# Surface: ${surface}`,
|
|
|
|
|
].join("\n"),
|
|
|
|
|
});
|
|
|
|
|
options?.ensureActive?.();
|
|
|
|
|
|
|
|
|
|
const running: RunningSubagent = {
|
|
|
|
|
id,
|
|
|
|
@@ -1819,6 +1998,7 @@ async function launchSubagent(
|
|
|
|
|
source: "pi",
|
|
|
|
|
startTimeMs: startTime,
|
|
|
|
|
}),
|
|
|
|
|
parentGeneration: sharedRuntime.parentGeneration,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
runningSubagents.set(id, running);
|
|
|
|
@@ -1870,13 +2050,15 @@ function copyClaudeSession(sentinelFile: string): string | null {
|
|
|
|
|
* delivery so it fires once per question (a subagent may ask again later).
|
|
|
|
|
*/
|
|
|
|
|
function deliverPendingQuestion(running: RunningSubagent): void {
|
|
|
|
|
if (!shouldDeliverRunningResult(running)) return;
|
|
|
|
|
const askFile = `${running.sessionFile}.ask`;
|
|
|
|
|
let payload: any = null;
|
|
|
|
|
try {
|
|
|
|
|
if (!existsSync(askFile)) return;
|
|
|
|
|
payload = JSON.parse(readFileSync(askFile, "utf-8"));
|
|
|
|
|
} catch {
|
|
|
|
|
// Malformed/partway-written file — drop it and move on.
|
|
|
|
|
// A writer may still be replacing the signal. Leave it for the next tick.
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
unlinkSync(askFile);
|
|
|
|
@@ -1889,6 +2071,7 @@ function deliverPendingQuestion(running: RunningSubagent): void {
|
|
|
|
|
const replyHint = `\n\nReply with subagent_message({ name: "${name}", message: "…" }) — the same name works whether it is still running or has since exited. It stays open until you reply.`;
|
|
|
|
|
|
|
|
|
|
sendWhenRuntimeReady((pi) => {
|
|
|
|
|
if (!shouldDeliverRunningResult(running)) return;
|
|
|
|
|
pi.sendMessage(
|
|
|
|
|
{
|
|
|
|
|
customType: "subagent_question",
|
|
|
|
@@ -1901,7 +2084,7 @@ function deliverPendingQuestion(running: RunningSubagent): void {
|
|
|
|
|
...(sessionId ? { sessionId } : {}),
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
{ triggerTurn: true, deliverAs: "steer" },
|
|
|
|
|
AUTOMATIC_DELIVERY_OPTIONS,
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
@@ -1922,6 +2105,9 @@ async function watchSubagent(
|
|
|
|
|
observeRunningSubagent(running);
|
|
|
|
|
deliverPendingQuestion(running);
|
|
|
|
|
},
|
|
|
|
|
onExitDetected() {
|
|
|
|
|
running.completionObserved = true;
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
|
|
|
@@ -2026,6 +2212,19 @@ async function watchSubagent(
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function invalidateParentLifecycle(): void {
|
|
|
|
|
sharedRuntime.pendingDeliveries.length = 0;
|
|
|
|
|
sharedRuntime.resultDeliveryStates.clear();
|
|
|
|
|
sharedRuntime.parentGeneration += 1;
|
|
|
|
|
resumingNames.clear();
|
|
|
|
|
for (const agent of runningSubagents.values()) {
|
|
|
|
|
agent.suppressDelivery = true;
|
|
|
|
|
agent.abortController?.abort();
|
|
|
|
|
}
|
|
|
|
|
runningSubagents.clear();
|
|
|
|
|
clearRefreshIntervals();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
|
|
const runtimeToken = Symbol("pi-subagents/runtime");
|
|
|
|
|
// The newest registered extension instance is the only one allowed to bind
|
|
|
|
@@ -2036,9 +2235,18 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
|
|
// Bind only after Pi has installed the fresh runtime. During /reload the old
|
|
|
|
|
// session_shutdown handler clears these references first, so watcher results
|
|
|
|
|
// cannot enqueue a turn through stale extension APIs.
|
|
|
|
|
pi.on("session_start", (_event, ctx) => {
|
|
|
|
|
pi.on("session_start", (event, ctx) => {
|
|
|
|
|
if (sharedRuntime.registeredRuntimeToken !== runtimeToken) return;
|
|
|
|
|
|
|
|
|
|
// Session replacement can start before the old runtime's shutdown event.
|
|
|
|
|
// Invalidate the old parent's children and queued callbacks at the start
|
|
|
|
|
// boundary too; reload is the only transition that preserves them.
|
|
|
|
|
if (event.reason === "new" || event.reason === "resume" || event.reason === "fork") {
|
|
|
|
|
clearStandaloneWidget();
|
|
|
|
|
sharedRuntime.statusWidgetClaim = null;
|
|
|
|
|
invalidateParentLifecycle();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// A fresh runtime may start before the old runtime emits shutdown. Drop
|
|
|
|
|
// old UI ownership and intervals before binding the fresh context.
|
|
|
|
|
if (sharedRuntime.activeRuntimeToken !== runtimeToken) {
|
|
|
|
@@ -2085,12 +2293,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
|
|
// Real session replacement/quit owns a different lifecycle. Cancel those
|
|
|
|
|
// children and suppress the synthetic cancellation result; there is no
|
|
|
|
|
// valid destination session to receive it.
|
|
|
|
|
sharedRuntime.pendingDeliveries.length = 0;
|
|
|
|
|
for (const agent of runningSubagents.values()) {
|
|
|
|
|
agent.suppressDelivery = true;
|
|
|
|
|
agent.abortController?.abort();
|
|
|
|
|
}
|
|
|
|
|
runningSubagents.clear();
|
|
|
|
|
invalidateParentLifecycle();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// The spawning tools are always registered here. Whether a child process can
|
|
|
|
@@ -2105,14 +2308,14 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
|
|
description:
|
|
|
|
|
"Spawn a sub-agent in a dedicated terminal multiplexer pane. " +
|
|
|
|
|
"This is a fire-and-forget async tool: the call returns immediately with only an acknowledgement. " +
|
|
|
|
|
"When the sub-agent finishes, the harness AUTOMATICALLY delivers its result as a steer message that wakes you up and starts a new turn — you do not need to do anything to receive it. " +
|
|
|
|
|
"When the sub-agent finishes, the harness AUTOMATICALLY delivers its result as a follow-up notification that wakes you up and starts a new turn — you do not need to do anything to receive it. " +
|
|
|
|
|
"DO NOT write polling loops, sleep/wait commands, tail/watch scripts, or repeatedly read session/log files to detect completion. DO NOT call subagents_list or any other tool to 'check' status. All of that is wasted work — the harness handles delivery for you. " +
|
|
|
|
|
"DO NOT fabricate, assume, or summarize results after calling this tool. " +
|
|
|
|
|
"After spawning, either end your turn immediately, or work on other independent tasks (including spawning more subagents in parallel). The harness will wake you with the result when it is ready.",
|
|
|
|
|
promptSnippet:
|
|
|
|
|
"Spawn a sub-agent in a dedicated terminal multiplexer pane. " +
|
|
|
|
|
"This is a fire-and-forget async tool: the call returns immediately with only an acknowledgement. " +
|
|
|
|
|
"When the sub-agent finishes, the harness AUTOMATICALLY delivers its result as a steer message that wakes you up and starts a new turn — you do not need to do anything to receive it. " +
|
|
|
|
|
"When the sub-agent finishes, the harness AUTOMATICALLY delivers its result as a follow-up notification that wakes you up and starts a new turn — you do not need to do anything to receive it. " +
|
|
|
|
|
"DO NOT write polling loops, sleep/wait commands, tail/watch scripts, or repeatedly read session/log files to detect completion. DO NOT call subagents_list or any other tool to 'check' status. All of that is wasted work — the harness handles delivery for you. " +
|
|
|
|
|
"DO NOT fabricate, assume, or summarize results after calling this tool. " +
|
|
|
|
|
"After spawning, either end your turn immediately, or work on other independent tasks (including spawning more subagents in parallel). The harness will wake you with the result when it is ready.",
|
|
|
|
@@ -2201,27 +2404,33 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
|
|
ctx.sessionManager.getSessionId(),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Default the cosmetic pane label to the agent name when omitted,
|
|
|
|
|
// disambiguating against running subagents, in-flight reservations, and
|
|
|
|
|
// every name already in the registry — so names stay unique across the
|
|
|
|
|
// whole session, running or finished. Reserve the chosen name
|
|
|
|
|
// synchronously (before any await) so parallel spawns don't collide.
|
|
|
|
|
let reservedName: string | null = null;
|
|
|
|
|
if (!params.name?.trim()) {
|
|
|
|
|
const registryNames = new Set(Object.keys(readNameRegistry(parentArtifactDir)));
|
|
|
|
|
params.name = uniqueRunningName(params.agent, registryNames);
|
|
|
|
|
reservedName = params.name;
|
|
|
|
|
reservedNames.add(reservedName);
|
|
|
|
|
}
|
|
|
|
|
const ensureActive = () => assertLaunchActive(_signal, runtimeToken);
|
|
|
|
|
ensureActive();
|
|
|
|
|
|
|
|
|
|
// Disambiguate both defaulted and explicit cosmetic names against
|
|
|
|
|
// running children, parallel launch reservations, and the persistent
|
|
|
|
|
// registry. Reserve synchronously before the first await.
|
|
|
|
|
const registryNames = new Set(Object.keys(readNameRegistry(parentArtifactDir)));
|
|
|
|
|
params.name = uniqueRunningName(params.name?.trim() || params.agent, registryNames);
|
|
|
|
|
const reservedName = params.name;
|
|
|
|
|
reservedNames.add(reservedName);
|
|
|
|
|
|
|
|
|
|
// Launch the subagent (creates pane, sends command). Release the name
|
|
|
|
|
// reservation once it registers in runningSubagents (or launch fails) —
|
|
|
|
|
// from then on uniqueRunningName tracks it via the running map.
|
|
|
|
|
let running;
|
|
|
|
|
let running: RunningSubagent | undefined;
|
|
|
|
|
try {
|
|
|
|
|
running = await launchSubagent(params, ctx);
|
|
|
|
|
running = await launchSubagent(params, ctx, { ensureActive });
|
|
|
|
|
ensureActive();
|
|
|
|
|
} catch (error) {
|
|
|
|
|
if (running) {
|
|
|
|
|
running.suppressDelivery = true;
|
|
|
|
|
runningSubagents.delete(running.id);
|
|
|
|
|
try { closeSurface(running.surface); } catch {}
|
|
|
|
|
}
|
|
|
|
|
throw error;
|
|
|
|
|
} finally {
|
|
|
|
|
if (reservedName) reservedNames.delete(reservedName);
|
|
|
|
|
reservedNames.delete(reservedName);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Persist name → session so subagent_message({ name }) can resume this
|
|
|
|
@@ -2247,10 +2456,10 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
|
|
watchSubagent(running, watcherAbort.signal)
|
|
|
|
|
.then((result) => {
|
|
|
|
|
updateWidget(); // reflect removal from Map immediately
|
|
|
|
|
if (running.suppressDelivery) return;
|
|
|
|
|
if (!shouldDeliverRunningResult(running)) return;
|
|
|
|
|
|
|
|
|
|
const presentation = resolveResultPresentation(result, running.name);
|
|
|
|
|
sendWhenRuntimeReady((currentPi) => {
|
|
|
|
|
sendResultWhenRuntimeReady(running.id, (currentPi) => {
|
|
|
|
|
currentPi.sendMessage(
|
|
|
|
|
{
|
|
|
|
|
customType: "subagent_result",
|
|
|
|
@@ -2269,14 +2478,14 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
|
|
...(result.stats ? { stats: result.stats } : {}),
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
{ triggerTurn: true, deliverAs: "steer" },
|
|
|
|
|
AUTOMATIC_DELIVERY_OPTIONS,
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
})
|
|
|
|
|
.catch((err) => {
|
|
|
|
|
updateWidget();
|
|
|
|
|
if (running.suppressDelivery) return;
|
|
|
|
|
sendWhenRuntimeReady((currentPi) => {
|
|
|
|
|
if (!shouldDeliverRunningResult(running)) return;
|
|
|
|
|
sendResultWhenRuntimeReady(running.id, (currentPi) => {
|
|
|
|
|
currentPi.sendMessage(
|
|
|
|
|
{
|
|
|
|
|
customType: "subagent_result",
|
|
|
|
@@ -2284,7 +2493,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
|
|
display: true,
|
|
|
|
|
details: { name: running.name, task: running.task, error: err?.message },
|
|
|
|
|
},
|
|
|
|
|
{ triggerTurn: true, deliverAs: "steer" },
|
|
|
|
|
AUTOMATIC_DELIVERY_OPTIONS,
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
@@ -2297,7 +2506,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
|
|
text:
|
|
|
|
|
`Sub-agent "${params.name}" launched and is now running in the background. ` +
|
|
|
|
|
`Do NOT generate or assume any results — you have no idea what the sub-agent will do or produce. ` +
|
|
|
|
|
`The results will be delivered to you automatically as a steer message when the sub-agent finishes. ` +
|
|
|
|
|
`The results will be delivered to you automatically as a follow-up notification when the sub-agent finishes. ` +
|
|
|
|
|
`Until then, move on to other work or tell the user you're waiting.`,
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
@@ -2440,12 +2649,12 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
|
|
"if it has finished, your message resumes that session and continues it. " +
|
|
|
|
|
"`name` and `message` are both required. " +
|
|
|
|
|
"Steering a running subagent returns immediately with a local acknowledgement and does NOT, by itself, emit a new result. " +
|
|
|
|
|
"Resuming is a fire-and-forget async call: when the resumed sub-agent finishes, the harness AUTOMATICALLY delivers its result as a steer message that wakes you up. " +
|
|
|
|
|
"Resuming is a fire-and-forget async call: when the resumed sub-agent finishes, the harness AUTOMATICALLY delivers its result as a follow-up notification that wakes you up. " +
|
|
|
|
|
"DO NOT poll, sleep, tail logs, or read session files to detect completion — the harness handles delivery. " +
|
|
|
|
|
"DO NOT fabricate or assume results. After calling, either end your turn or work on other independent tasks.",
|
|
|
|
|
promptSnippet:
|
|
|
|
|
"Message a subagent by name: steers it if running, resumes it if finished (same name either way). " +
|
|
|
|
|
"`name` and `message` are required. Steering returns immediately; resuming delivers its result later as a steer message. " +
|
|
|
|
|
"`name` and `message` are required. Steering returns immediately; resuming delivers its result later as a follow-up notification. " +
|
|
|
|
|
"Do not poll or fabricate results.",
|
|
|
|
|
parameters: Type.Object({
|
|
|
|
|
name: Type.String({
|
|
|
|
@@ -2509,11 +2718,37 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
|
|
return muxUnavailableResult();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const ensureActive = () => assertLaunchActive(_signal, runtimeToken);
|
|
|
|
|
ensureActive();
|
|
|
|
|
|
|
|
|
|
// ── Steer a running subagent ──
|
|
|
|
|
// A name that matches a currently-running subagent always steers it.
|
|
|
|
|
const runningMatch = Array.from(runningSubagents.values()).find((r) => r.name === requestedName);
|
|
|
|
|
// A completion signal can arrive just before the watcher removes the
|
|
|
|
|
// running entry. Let that cleanup settle so a late message resumes the
|
|
|
|
|
// finished session instead of being typed into a dead pane's shell.
|
|
|
|
|
let runningMatch = Array.from(runningSubagents.values()).find((r) => r.name === requestedName);
|
|
|
|
|
if (runningMatch && hasTerminalCompletionSignal(runningMatch)) {
|
|
|
|
|
await waitForWatcherCleanup(runningMatch, _signal);
|
|
|
|
|
ensureActive();
|
|
|
|
|
runningMatch = Array.from(runningSubagents.values()).find((r) => r.name === requestedName);
|
|
|
|
|
}
|
|
|
|
|
if (runningMatch) {
|
|
|
|
|
return handleSubagentSteer({ name: requestedName, message: params.message });
|
|
|
|
|
if (hasTerminalCompletionSignal(runningMatch)) {
|
|
|
|
|
const err = `Subagent "${requestedName}" is finishing. Retry the message after its completion result arrives.`;
|
|
|
|
|
return { content: [{ type: "text" as const, text: err }], details: { error: err } };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const steerResult = handleSubagentSteer({ name: requestedName, message: params.message });
|
|
|
|
|
if (!steerResult.details.error || await isSurfaceAlive(runningMatch.surface, _signal)) {
|
|
|
|
|
return steerResult;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The child exited between the completion check and terminal send.
|
|
|
|
|
// Wait for its watcher to release the session, then fall through to
|
|
|
|
|
// the normal finished-session resume path.
|
|
|
|
|
await waitForWatcherCleanup(runningMatch, _signal);
|
|
|
|
|
ensureActive();
|
|
|
|
|
runningMatch = Array.from(runningSubagents.values()).find((r) => r.name === requestedName);
|
|
|
|
|
if (runningMatch) return steerResult;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Resume a finished session by name ──
|
|
|
|
@@ -2569,6 +2804,12 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
|
|
return { content: [{ type: "text" as const, text: err }], details: { error: err } };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!reserveResumeName(name)) {
|
|
|
|
|
const err = `Subagent "${name}" is already being resumed. Wait for its launch acknowledgement before messaging it again.`;
|
|
|
|
|
return { content: [{ type: "text" as const, text: err }], details: { error: err } };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
clearStaleSessionSignals(sessionPath);
|
|
|
|
|
const resumedSessionId = entry.sessionId ?? getSessionId(sessionPath) ?? requestedName;
|
|
|
|
|
|
|
|
|
|
// Record entry count before resuming so we can extract new messages.
|
|
|
|
@@ -2576,10 +2817,11 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
|
|
// transcript doesn't block the UI.
|
|
|
|
|
const entryCountBefore = countSessionEntryLines(sessionPath);
|
|
|
|
|
|
|
|
|
|
const surface = createSurface(name);
|
|
|
|
|
await new Promise<void>((resolve) => setTimeout(resolve, getShellReadyDelayMs()));
|
|
|
|
|
|
|
|
|
|
let surface: string | undefined;
|
|
|
|
|
try {
|
|
|
|
|
surface = createSurface(name);
|
|
|
|
|
await new Promise<void>((resolve) => setTimeout(resolve, getShellReadyDelayMs()));
|
|
|
|
|
ensureActive();
|
|
|
|
|
// Build pi resume command
|
|
|
|
|
const parts = ["pi", "--session", shellEscape(sessionPath)];
|
|
|
|
|
|
|
|
|
@@ -2656,8 +2898,10 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
|
|
.replace(/-+/g, "-")
|
|
|
|
|
.replace(/^-|-$/g, "") || "resume"}-resume-${Date.now()}.sh`,
|
|
|
|
|
);
|
|
|
|
|
ensureActive();
|
|
|
|
|
sendLongCommand(surface, command, {
|
|
|
|
|
scriptPath: launchScriptFile,
|
|
|
|
|
replaceShell: true,
|
|
|
|
|
scriptPreamble: [
|
|
|
|
|
`# Subagent resume script for ${name}`,
|
|
|
|
|
`# Generated: ${new Date().toISOString()}`,
|
|
|
|
@@ -2666,6 +2910,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
|
|
...(resumeMsgFile ? [`# Resume message file: ${resumeMsgFile}`] : []),
|
|
|
|
|
].join("\n"),
|
|
|
|
|
});
|
|
|
|
|
ensureActive();
|
|
|
|
|
|
|
|
|
|
// Register as a running subagent for widget tracking
|
|
|
|
|
const running: RunningSubagent = {
|
|
|
|
@@ -2683,6 +2928,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
|
|
source: "pi",
|
|
|
|
|
startTimeMs: startTime,
|
|
|
|
|
}),
|
|
|
|
|
parentGeneration: sharedRuntime.parentGeneration,
|
|
|
|
|
};
|
|
|
|
|
runningSubagents.set(id, running);
|
|
|
|
|
startWidgetRefresh();
|
|
|
|
@@ -2695,7 +2941,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
|
|
watchSubagent(running, watcherAbort.signal)
|
|
|
|
|
.then((result) => {
|
|
|
|
|
updateWidget();
|
|
|
|
|
if (running.suppressDelivery) return;
|
|
|
|
|
if (!shouldDeliverRunningResult(running)) return;
|
|
|
|
|
|
|
|
|
|
const allEntries = getNewEntries(sessionPath, entryCountBefore);
|
|
|
|
|
const summary = findLastAssistantMessage(allEntries) ??
|
|
|
|
@@ -2709,7 +2955,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
|
|
name,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
sendWhenRuntimeReady((currentPi) => {
|
|
|
|
|
sendResultWhenRuntimeReady(running.id, (currentPi) => {
|
|
|
|
|
currentPi.sendMessage(
|
|
|
|
|
{
|
|
|
|
|
customType: "subagent_result",
|
|
|
|
@@ -2725,14 +2971,14 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
|
|
...(result.errorMessage ? { errorMessage: result.errorMessage } : {}),
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
{ triggerTurn: true, deliverAs: "steer" },
|
|
|
|
|
AUTOMATIC_DELIVERY_OPTIONS,
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
})
|
|
|
|
|
.catch((err) => {
|
|
|
|
|
updateWidget();
|
|
|
|
|
if (running.suppressDelivery) return;
|
|
|
|
|
sendWhenRuntimeReady((currentPi) => {
|
|
|
|
|
if (!shouldDeliverRunningResult(running)) return;
|
|
|
|
|
sendResultWhenRuntimeReady(running.id, (currentPi) => {
|
|
|
|
|
currentPi.sendMessage(
|
|
|
|
|
{
|
|
|
|
|
customType: "subagent_result",
|
|
|
|
@@ -2740,7 +2986,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
|
|
display: true,
|
|
|
|
|
details: { name, error: err?.message },
|
|
|
|
|
},
|
|
|
|
|
{ triggerTurn: true, deliverAs: "steer" },
|
|
|
|
|
AUTOMATIC_DELIVERY_OPTIONS,
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
@@ -2758,12 +3004,16 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
|
|
};
|
|
|
|
|
} catch (error) {
|
|
|
|
|
runningSubagents.delete(id);
|
|
|
|
|
try {
|
|
|
|
|
closeSurface(surface);
|
|
|
|
|
} catch {
|
|
|
|
|
// Preserve the resume error; cleanup is best-effort.
|
|
|
|
|
if (surface) {
|
|
|
|
|
try {
|
|
|
|
|
closeSurface(surface);
|
|
|
|
|
} catch {
|
|
|
|
|
// Preserve the resume error; cleanup is best-effort.
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
throw error;
|
|
|
|
|
} finally {
|
|
|
|
|
resumingNames.delete(name);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|