Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/agents/subagents.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
- The global `builtInEnabled` in `~/.pi/agent/agents/settings.json` is off when the file or field is absent and when the file is malformed (fail closed); writes are atomic and keep unknown fields. The inline extension factory is always present, so a reload can apply the switch, but registers no tools while off; after changing it the user must reload the session explicitly, and `Agent` dispatch re-checks the setting so a stale call cannot start a subagent. When on, only a recognized legacy `pi-subagents` extension registering a reserved tool (`Agent`, `get_subagent_result`, `steer_subagent`) is removed, along with the conflict errors it caused; unrelated extensions stay. ADR 0003.
- The three tools register with `exposure: "model-only"`, so `ctx.executeTool()` (a codemode script) cannot start, collect or steer a subagent: a script-started run would record the nested call id (`<codemode call>/<n>`) as its `parentToolCallId`, which no transcript entry carries, and the chat would lose its link to the child session. `lib/subagent-extension.integration.test.mjs` pins this against a real `AgentSession`.
- A run stays in `getSubagentRuns()` from dispatch until its result entry is written, so `get()` reports a persisted `running` / `queued` status with no result and no running wrapper as `interrupted`: its process stopped and nothing will finish it (otherwise `get_subagent_result({ wait: true })` polls forever after a restart). `start()` writes the child's session file at dispatch (`persistChildSession()`): pi creates one only once a user or assistant message exists, so a run stopped while queued or connecting MCP servers, or refused for missing credentials, kept its entries in memory only and was gone (from `get()`, `resume` and the sidebar) once its idle wrapper was released. `setSessionFile()` reloads the written file, so pi appends every later entry; a queued run a restart cuts off reads as `interrupted` like a running one. The file is the only record of a settled run: one kept in memory as well would outlive a deleted child, and `resume` would reopen its missing file as a new, unrestricted session in the server's cwd.
- The session list settles that status the same way (`settleOrphanedSubagentStatus()` in `mapScannedSession` and `getRpcSessionInfos()`), and a live wrapper's runtime row overlays it in `mergeSessionLists()`: only the live run map (`getLiveSubagentStatus()`) can tell a queued child from an orphan, so the list never spins on a run a restart cut off.
- Built-in profiles (`general-purpose`, `explore`, `plan`) are switched off by name in that file's `disabledBuiltIns`, never by copying them to a `.md` file (ADR 0005). `builtInProfiles()` stamps `enabled` onto them so the panel, the `Agent` tool description and `resolveSubagentProfile` agree. Writes are minimal edits that keep names they did not touch, including ones no built-in claims; reading fails *open* (the feature switch has already failed closed). `PATCH /api/subagents/profiles` with `scope: "builtin"` writes it; `PUT`/`DELETE` refuse that scope. A same-name file replaces the built-in outright and is switched off through its own frontmatter. Only the switch is live for a built-in; the rest of the form is read-only.
- A positive `max_turns` allows one wrap-up turn only when the SDK would otherwise continue. The limiter chains the SDK's awaited `finishTurn` hook and ends unfinished work without cancelling a completed answer. The wrap-up turn sets `steeringMode` to `all` so a steer queued earlier cannot hold the instruction back. An `end` decision alone does not stop the run: AgentSession starts another run for anything still queued, so the limiter clears the queues, lists those messages in the turn-limit error, and refuses `steer_subagent` from then on. Exhaustion is a failed run with that error and any partial output. A request that ends with `stopReason: "aborted"` is `aborted` even when the controller did not ask for it (Stop in the child's own chat); a provider error is `failed`.
- `notifyParent` skips a background run's completion notification when the parent already collected that result with `get_subagent_result`, which marks it consumed. The mark names the run (`sessionId` + `completedAt`), never just the session, since `resume` reuses the session id; `resume` keeps the mark. While the parent `isRunning()` (often still inside that poll) the notification is held and the mark re-checked; an idle parent is notified at once.
Expand Down
11 changes: 9 additions & 2 deletions lib/rpc-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,12 @@ import {
hasSubagentResourceSnapshot,
readSubagentRun,
readSubagentSessionResources,
settleOrphanedSubagentStatus,
subagentExtensionLoaderOptions,
subagentToolOptions,
SUBAGENT_CONTROL_TOOL_NAMES,
} from "./subagents";
import { createSubagentController } from "./subagent-runtime";
import { createSubagentController, getLiveSubagentStatus } from "./subagent-runtime";
import { isBuiltInSubagentsEnabled } from "./subagent-settings";
import { resolveShellTools } from "./powershell-settings";
import { CHAT_ONLY_RESOURCE_LOADER_OPTIONS, contextFilesSystemPrompt } from "./chat-only";
Expand Down Expand Up @@ -2358,6 +2359,12 @@ export function getRpcSessionInfos(options: { includeTransient?: boolean } = {})
const sessionFile = manager.getSessionFile() ?? session.sessionFile;
const persisted = Boolean(sessionFile && existsSync(sessionFile));
const subagent = readSubagentRun(entries as unknown as SessionEntry[], header?.id ?? session.sessionId, sessionFile ?? "");
// A persisted `running` / `queued` status with no live run was left by a process that
// stopped mid-run, so the row settles it instead of spinning on a dead run.
const relationStatus = session.isRunning()
? "running" as const
: getLiveSubagentStatus(header?.id ?? session.sessionId)
?? (subagent ? settleOrphanedSubagentStatus(subagent.status) : undefined);

// An ensure_session call creates an idle, empty runtime while the composer
// loads commands. Do not leak it into history before a prompt is accepted.
Expand Down Expand Up @@ -2389,7 +2396,7 @@ export function getRpcSessionInfos(options: { includeTransient?: boolean } = {})
parentSessionId: subagent.parentSessionId,
profile: subagent.profile,
description: subagent.description,
status: session.isRunning() ? "running" as const : subagent.status,
status: relationStatus ?? subagent.status,
},
} : {}),
transient: !persisted,
Expand Down
43 changes: 43 additions & 0 deletions lib/rpc-session-info.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,49 @@ test("lists an accepted new prompt before its session file exists", (t) => {
assert.equal(infos[0].transient, true);
});

test("settles a runtime row whose child was left running by a stopped process", (t) => {
const previousRegistry = globalThis.__piSessions;
const previousRuns = globalThis.__piSubagentRuns;
const dir = mkdtempSync(join(tmpdir(), "pi-web-orphan-runtime-"));
const filePath = join(dir, "session.jsonl");
writeFileSync(filePath, "persisted\n");
const entries = [
{
type: "custom",
customType: "pi-web:subagent",
id: "meta",
parentId: null,
timestamp: "2026-08-12T01:02:03.000Z",
data: { version: 1, parentSessionId: "parent-id", parentSessionPath: "/tmp/parent.jsonl", profile: "Explore", description: "Inspect" },
},
{
type: "custom",
customType: "pi-web:subagent-status",
id: "status",
parentId: "meta",
timestamp: "2026-08-12T01:02:04.000Z",
data: { version: 1, status: "running" },
},
];
globalThis.__piSessions = new Map([["orphaned-runtime", makeRuntimeSession({
id: "orphaned-runtime",
filePath,
running: false,
entries,
})]]);
globalThis.__piSubagentRuns = new Map();
t.after(() => {
globalThis.__piSessions = previousRegistry;
globalThis.__piSubagentRuns = previousRuns;
rmSync(dir, { recursive: true, force: true });
});

const infos = getRpcSessionInfos();

assert.equal(infos.length, 1);
assert.equal(infos[0].relation.status, "interrupted");
});

test("keeps an idle runtime visible once its JSONL file exists", (t) => {
const previousRegistry = globalThis.__piSessions;
const dir = mkdtempSync(join(tmpdir(), "pi-web-runtime-session-"));
Expand Down
63 changes: 63 additions & 0 deletions lib/session-reader.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -657,6 +657,69 @@ test("session listing reads a subagent file again only when its scanned row chan
assert.equal((await listAllSessions({ force: true }))[0].relation.status, "failed");
});

test("session listing reads a subagent left running by a stopped process as interrupted", async (t) => {
const dir = mkdtempSync(join(tmpdir(), "pi-web-relation-orphan-"));
const filePath = join(dir, "child.jsonl");
const parentPath = join(dir, "parent.jsonl");
const childId = "orphaned-relation-child";
writeFileSync(filePath, [
{ type: "session", version: 3, id: childId, timestamp: "2026-01-01T00:00:00.000Z", cwd: dir, parentSession: parentPath },
{
type: "custom", customType: "pi-web:subagent", id: "meta", parentId: null, timestamp: "2026-01-01T00:00:00.000Z",
data: { version: 1, parentSessionId: "parent-id", parentSessionPath: parentPath, profile: "Explore", description: "Inspect" },
},
{
type: "custom", customType: "pi-web:subagent-status", id: "status", parentId: "meta", timestamp: "2026-01-01T00:00:01.000Z",
data: { version: 1, status: "running" },
},
].map((entry) => JSON.stringify(entry)).join("\n"));

const originalListSessions = listSessions;
listSessions = async () => [{
path: filePath,
id: childId,
cwd: dir,
created: new Date("2026-01-01T00:00:00.000Z"),
modified: new Date("2026-01-01T00:00:01.000Z"),
messageCount: 0,
firstMessage: "(no messages)",
allMessagesText: "",
parentSessionPath: parentPath,
}];
resetSessionListState();
t.after(() => {
listSessions = originalListSessions;
invalidateSessionPathCache(childId);
resetSessionListState();
rmSync(dir, { recursive: true, force: true });
});

assert.equal((await listAllSessions({ force: true }))[0].relation.status, "interrupted");
});

test("a live runtime row keeps its status over the settled disk row", () => {
const base = {
path: "/tmp/child.jsonl",
id: "child",
cwd: "/tmp",
created: "2026-01-01T00:00:00.000Z",
modified: "2026-01-01T00:00:01.000Z",
messageCount: 2,
firstMessage: "task",
};
const relation = (status) => ({
kind: "subagent",
parentSessionId: "parent",
profile: "Explore",
description: "Inspect",
status,
});

const merged = mergeSessionLists([{ ...base, relation: relation("interrupted") }], [{ ...base, relation: relation("queued") }]);

assert.equal(merged[0].relation.status, "queued");
});

test("keeps forward and reverse session path caches in sync", async () => {
const sessionId = "cache-test-session";
const filePath = join(tmpdir(), "pi-web-cache-test", "..", "cache-test", "session.jsonl");
Expand Down
15 changes: 12 additions & 3 deletions lib/session-reader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { sessionPathKey } from "./session-path";
import { skillExpansionToCommand } from "./slash-display";
import { MAX_TOOL_RESULT_IMAGE_BYTES, TOOL_RESULT_IMAGE_MIMES } from "./tool-result-images";
import { resolveProject, type ProjectInfo } from "./worktree";
import { readSubagentRun, SUBAGENT_META_TYPE } from "./subagents";
import { readSubagentRun, settleOrphanedSubagentStatus, SUBAGENT_META_TYPE } from "./subagents";
import { checkSessionMembership, listSessionsIncremental, scanSessionFileInfo, type ScannedSessionInfo } from "./session-list-scanner";

export { getAgentDir };
Expand Down Expand Up @@ -181,7 +181,16 @@ export function mergeSessionLists(
const byId = new Map(supplementalSessions.map((session) => [session.id, session]));
// A disk scan is authoritative once the JSONL exists. In particular, this
// replaces a transient registry snapshot without briefly rendering two rows.
for (const session of persistedSessions) byId.set(session.id, session);
for (const session of persistedSessions) {
const live = byId.get(session.id);
// The file's status is settled (a restart's `running` reads as interrupted), so a
// live wrapper's row is the only place that still knows a queued child is queued.
if (live?.relation?.kind === "subagent" && session.relation?.kind === "subagent") {
byId.set(session.id, { ...session, relation: { ...session.relation, status: live.relation.status } });
continue;
}
byId.set(session.id, session);
}
return [...byId.values()].sort((a, b) => b.modified.localeCompare(a.modified));
}

Expand Down Expand Up @@ -255,7 +264,7 @@ function mapScannedSession(
: catalogFirstMessage(scanned.firstMessage || "(no messages)"),
parentSessionId: originSessionId,
...(subagent
? { relation: { kind: "subagent" as const, parentSessionId: subagent.parentSessionId, profile: subagent.profile, description: subagent.description, status: subagent.status } }
? { relation: { kind: "subagent" as const, parentSessionId: subagent.parentSessionId, profile: subagent.profile, description: subagent.description, status: settleOrphanedSubagentStatus(subagent.status) } }
: scanned.parentSessionPath
? { relation: { kind: "fork" as const, ...(originSessionId ? { originSessionId } : {}) } }
: {}),
Expand Down
8 changes: 7 additions & 1 deletion lib/subagent-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
SUBAGENT_STATUS_TYPE,
SUBAGENT_RESULT_TYPE,
selectSubagentExtensionTools,
settleOrphanedSubagentStatus,
subagentExtensionLoaderOptions,
subagentToolOptions,
withSubagentExtensionTools,
Expand Down Expand Up @@ -223,7 +224,12 @@ function takeResultConsumed(run: SubagentRunIdentity): boolean {
* `resume` can pick the session up again.
*/
function settleOrphanedRun(run: SubagentRunInfo): SubagentRunInfo {
return run.status === "running" || run.status === "queued" ? { ...run, status: "interrupted" } : run;
return { ...run, status: settleOrphanedSubagentStatus(run.status) };
}

/** The status of a run this process still holds, so a listing does not have to guess from its file. */
export function getLiveSubagentStatus(sessionId: string): SubagentRunInfo["status"] | undefined {
return getSubagentRuns().get(sessionId)?.run.status;
}

function findSubagentModel(runtime: ModelRuntime, reference: string) {
Expand Down
8 changes: 8 additions & 0 deletions lib/subagents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -961,6 +961,14 @@ export function subagentExtensionLoaderOptions(resources: { loadExtensions: bool
};
}

/**
* A persisted `running` / `queued` status with no live child was left by a process that
* stopped mid-run. Every listing must settle it, or a restart leaves a spinner forever.
*/
export function settleOrphanedSubagentStatus(status: SubagentStatus): SubagentStatus {
return status === "running" || status === "queued" ? "interrupted" : status;
}

export function readSubagentRun(entries: readonly SessionEntry[], sessionId: string, sessionPath: string): SubagentRunInfo | null {
const data = subagentMetadataData(entries);
if (!data) return null;
Expand Down
Loading