diff --git a/hosts/neogaia/default.nix b/hosts/neogaia/default.nix index e04b61c..bb25b69 100644 --- a/hosts/neogaia/default.nix +++ b/hosts/neogaia/default.nix @@ -62,12 +62,9 @@ tasksMax = 512; }; - modules.agents.claude-code.enable = true; modules.agents.herdr.enable = true; modules.agents.tools.gitea-axi.enable = true; modules.agents.pi.enable = true; - modules.agents.pi.subagents.maxConcurrent = 8; - modules.agents.pi.subagents.recentTerminalTtlMs = 15 * 60 * 1000; modules.desktop.enable = true; modules.desktop.obsidian.enable = true; diff --git a/modules/agents/pi/extensions/subagents/agents.ts b/modules/agents/pi/extensions/subagents/agents.ts deleted file mode 100644 index f56c1c8..0000000 --- a/modules/agents/pi/extensions/subagents/agents.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { existsSync, readdirSync, readFileSync } from "node:fs"; -import { homedir } from "node:os"; -import { basename, join } from "node:path"; -import type { ContextMode } from "./types.ts"; -import type { Diagnostics } from "./config.ts"; - -export interface AgentDefinition { - name: string; - description: string; - body: string; - context?: ContextMode; - model?: string; - thinking?: string; - tools?: string; - allowedContexts?: ContextMode[]; - hidden?: boolean; - source: string; -} - -export function loadAgents(cwd: string, projectTrusted: boolean, diagnostics: Diagnostics, agentDir = defaultAgentDir()): Map { - const user = loadTier(join(agentDir, "agents"), "user", diagnostics); - const project = projectTrusted ? loadTier(join(cwd, ".pi", "agents"), "project", diagnostics) : new Map(); - return new Map([...user, ...project]); -} - -function loadTier(dir: string, tier: string, diagnostics: Diagnostics): Map { - const agents = new Map(); - if (!existsSync(dir)) return agents; - for (const entry of readdirSync(dir, { withFileTypes: true })) { - if (!entry.isFile() || !entry.name.endsWith(".md")) continue; - const path = join(dir, entry.name); - const parsed = parseAgent(path, diagnostics); - if (!parsed) continue; - if (agents.has(parsed.name)) { - diagnostics.warnings.push(`Duplicate ${tier} agent '${parsed.name}' ignored at ${path}`); - continue; - } - const stem = basename(entry.name, ".md"); - if (stem !== parsed.name) diagnostics.warnings.push(`${tier} agent file '${entry.name}' name '${parsed.name}' does not match filename`); - agents.set(parsed.name, parsed); - } - return agents; -} - -export function parseAgent(path: string, diagnostics: Diagnostics): AgentDefinition | undefined { - try { - const text = readFileSync(path, "utf8"); - const match = /^---\n([\s\S]*?)\n---\n?([\s\S]*)$/u.exec(text); - if (!match) { - diagnostics.warnings.push(`Agent ${path} missing YAML frontmatter`); - return undefined; - } - const frontmatter = parseFrontmatter(match[1]); - const name = stringField(frontmatter, "name"); - const description = stringField(frontmatter, "description"); - if (!name || !/^[a-z0-9-]+$/.test(name)) { - diagnostics.warnings.push(`Agent ${path} has invalid name`); - return undefined; - } - if (!description) { - diagnostics.warnings.push(`Agent ${path} has invalid description`); - return undefined; - } - const context = contextField(frontmatter.context); - const allowedContexts = contextsField(frontmatter.allowedContexts); - if (frontmatter.context !== undefined && !context) diagnostics.warnings.push(`Agent ${path} has invalid context`); - if (frontmatter.allowedContexts !== undefined && !allowedContexts) diagnostics.warnings.push(`Agent ${path} has invalid allowedContexts`); - if (context && allowedContexts && !allowedContexts.includes(context)) diagnostics.warnings.push(`Agent ${path} context is outside allowedContexts`); - return { - name, - description, - body: match[2].trim(), - context, - model: stringField(frontmatter, "model"), - thinking: stringField(frontmatter, "thinking"), - tools: stringField(frontmatter, "tools"), - allowedContexts, - hidden: booleanField(frontmatter, "hidden"), - source: path, - }; - } catch (error) { - diagnostics.warnings.push(`Failed to load agent ${path}: ${error instanceof Error ? error.message : String(error)}`); - return undefined; - } -} - -function parseFrontmatter(text: string): Record { - const result: Record = {}; - const lines = text.split(/\r?\n/u); - for (let i = 0; i < lines.length; i += 1) { - const line = lines[i]; - if (!line.trim() || line.trimStart().startsWith("#")) continue; - const scalar = /^(\w+):\s*(.*?)\s*$/u.exec(line); - if (!scalar) continue; - const [, key, raw] = scalar; - if (raw !== "") { - result[key] = parseScalar(raw); - continue; - } - const values: string[] = []; - while (i + 1 < lines.length) { - const item = /^\s+-\s*(.*?)\s*$/u.exec(lines[i + 1]); - if (!item) break; - values.push(String(parseScalar(item[1]))); - i += 1; - } - result[key] = values; - } - return result; -} - -function parseScalar(raw: string): string | boolean { - const unquoted = raw.replace(/^['"]|['"]$/gu, ""); - if (unquoted === "true") return true; - if (unquoted === "false") return false; - return unquoted; -} - -function stringField(record: Record, key: string): string | undefined { - const value = record[key]; - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - -function booleanField(record: Record, key: string): boolean | undefined { - const value = record[key]; - return typeof value === "boolean" ? value : undefined; -} - -function contextField(value: unknown): ContextMode | undefined { - return value === "independent" || value === "fork" ? value : undefined; -} - -function contextsField(value: unknown): ContextMode[] | undefined { - if (!Array.isArray(value)) return undefined; - const contexts = value.map(contextField); - return contexts.every(Boolean) ? (contexts as ContextMode[]) : undefined; -} - -function defaultAgentDir(): string { - return process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent"); -} diff --git a/modules/agents/pi/extensions/subagents/config.test.ts b/modules/agents/pi/extensions/subagents/config.test.ts deleted file mode 100644 index 2acda97..0000000 --- a/modules/agents/pi/extensions/subagents/config.test.ts +++ /dev/null @@ -1,139 +0,0 @@ -import assert from "node:assert/strict"; -import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import test from "node:test"; -import { loadAgents } from "./agents.ts"; -import { BUILT_IN_TOOL_PROFILES, loadConfig, resolveSpawn, type Diagnostics } from "./config.ts"; - -function fixture() { - const root = mkdtempSync(join(tmpdir(), "subagents-config-")); - const agentDir = join(root, "agent"); - const cwd = join(root, "project"); - mkdirSync(agentDir, { recursive: true }); - mkdirSync(cwd, { recursive: true }); - return { root, agentDir, cwd }; -} - -function diagnostics(): Diagnostics { - return { warnings: [] }; -} - -test("missing config files and agent directories are normal", () => { - const { cwd, agentDir } = fixture(); - const diag = diagnostics(); - - const config = loadConfig(cwd, true, diag, agentDir); - const agents = loadAgents(cwd, true, diag, agentDir); - - assert.equal(config.defaultContext, "independent"); - assert.equal(config.defaultTools, "read-only"); - assert.equal(config.recentTerminalTtlMs, 300000); - assert.equal(agents.size, 0); - assert.deepEqual(diag.warnings, []); -}); - -test("global and trusted project config merge in order", () => { - const { cwd, agentDir } = fixture(); - mkdirSync(join(cwd, ".pi"), { recursive: true }); - writeFileSync(join(agentDir, "subagents.json"), JSON.stringify({ defaultTools: "global-profile", recentTerminalTtlMs: 1000, toolProfiles: { "global-profile": { activeTools: ["read"] } } })); - writeFileSync(join(cwd, ".pi", "subagents.json"), JSON.stringify({ defaultTools: "project-profile", recentTerminalTtlMs: 2000, toolProfiles: { "project-profile": { activeTools: ["ls"] } } })); - - const config = loadConfig(cwd, true, diagnostics(), agentDir); - - assert.equal(config.defaultTools, "project-profile"); - assert.equal(config.recentTerminalTtlMs, 2000); - assert.deepEqual(config.toolProfiles["global-profile"].activeTools, ["read"]); - assert.deepEqual(config.toolProfiles["project-profile"].activeTools, ["ls"]); -}); - -test("recent terminal ttl preserves zero and rejects invalid values", () => { - const { cwd, agentDir } = fixture(); - writeFileSync(join(agentDir, "subagents.json"), JSON.stringify({ recentTerminalTtlMs: 0 })); - const zeroDiag = diagnostics(); - - const zeroConfig = loadConfig(cwd, true, zeroDiag, agentDir); - - assert.equal(zeroConfig.recentTerminalTtlMs, 0); - assert.deepEqual(zeroDiag.warnings, []); - - writeFileSync(join(agentDir, "subagents.json"), JSON.stringify({ recentTerminalTtlMs: -1 })); - const invalidDiag = diagnostics(); - - const invalidConfig = loadConfig(cwd, true, invalidDiag, agentDir); - - assert.equal(invalidConfig.recentTerminalTtlMs, 300000); - assert.ok(invalidDiag.warnings.some((warning) => warning.includes("Invalid global recentTerminalTtlMs ignored"))); -}); - -test("project config is ignored when project is untrusted", () => { - const { cwd, agentDir } = fixture(); - mkdirSync(join(cwd, ".pi"), { recursive: true }); - writeFileSync(join(cwd, ".pi", "subagents.json"), JSON.stringify({ defaultTools: "project-profile", toolProfiles: { "project-profile": { activeTools: ["ls"] } } })); - - const config = loadConfig(cwd, false, diagnostics(), agentDir); - - assert.equal(config.defaultTools, "read-only"); - assert.equal(config.toolProfiles["project-profile"], undefined); -}); - -test("agents load with project precedence over user", () => { - const { cwd, agentDir } = fixture(); - mkdirSync(join(agentDir, "agents"), { recursive: true }); - mkdirSync(join(cwd, ".pi", "agents"), { recursive: true }); - writeFileSync(join(agentDir, "agents", "review.md"), "---\nname: review\ndescription: User review\ntools: read-only\n---\nuser body\n"); - writeFileSync(join(cwd, ".pi", "agents", "review.md"), "---\nname: review\ndescription: Project review\ntools: full-tools\n---\nproject body\n"); - - const agents = loadAgents(cwd, true, diagnostics(), agentDir); - - assert.equal(agents.get("review")?.description, "Project review"); - assert.equal(agents.get("review")?.body, "project body"); -}); - -test("duplicate same-tier definitions and invalid frontmatter produce diagnostics", () => { - const { cwd, agentDir } = fixture(); - const dir = join(agentDir, "agents"); - mkdirSync(dir, { recursive: true }); - writeFileSync(join(dir, "one.md"), "---\nname: same\ndescription: One\n---\none\n"); - writeFileSync(join(dir, "two.md"), "---\nname: same\ndescription: Two\n---\ntwo\n"); - writeFileSync(join(dir, "bad.md"), "---\nname: Bad Name\n---\nbad\n"); - const diag = diagnostics(); - - const agents = loadAgents(cwd, true, diag, agentDir); - - assert.equal(agents.size, 1); - assert.ok(diag.warnings.some((warning) => warning.includes("Duplicate user agent 'same'"))); - assert.ok(diag.warnings.some((warning) => warning.includes("invalid name"))); -}); - -test("named spawn resolves overrides, frontmatter, config, and defaults", () => { - const { cwd, agentDir } = fixture(); - mkdirSync(join(agentDir, "agents"), { recursive: true }); - writeFileSync(join(agentDir, "subagents.json"), JSON.stringify({ defaultTools: "local-review", toolProfiles: { "local-review": { activeTools: ["read"] } } })); - writeFileSync(join(agentDir, "agents", "review.md"), "---\nname: review\ndescription: Review\ncontext: independent\nmodel: inherit\nthinking: high\ntools: local-review\n---\nagent body\n"); - const diag = diagnostics(); - const config = loadConfig(cwd, true, diag, agentDir); - const agents = loadAgents(cwd, true, diag, agentDir); - - const resolved = resolveSpawn({ agent: "review", prompt: "check this", label: "Review migration", thinking: "low" }, config, agents); - - assert.equal(resolved.prompt, "check this"); - assert.equal(resolved.label, "Review migration"); - assert.equal(resolved.context, "independent"); - assert.equal(resolved.model, "inherit"); - assert.equal(resolved.thinking, "low"); - assert.equal(resolved.tools, "local-review"); - assert.deepEqual(resolved.toolProfile.activeTools, ["read"]); - assert.equal(resolved.agentBody, "agent body"); -}); - -test("built-in tool profile names cannot be overridden", () => { - const { cwd, agentDir } = fixture(); - writeFileSync(join(agentDir, "subagents.json"), JSON.stringify({ toolProfiles: { "read-only": { activeTools: ["bash"] } } })); - const diag = diagnostics(); - - const config = loadConfig(cwd, true, diag, agentDir); - - assert.deepEqual(config.toolProfiles["read-only"], BUILT_IN_TOOL_PROFILES["read-only"]); - assert.ok(diag.warnings.some((warning) => warning.includes("Ignoring global override for built-in tool profile 'read-only'"))); -}); diff --git a/modules/agents/pi/extensions/subagents/config.ts b/modules/agents/pi/extensions/subagents/config.ts deleted file mode 100644 index 36d2785..0000000 --- a/modules/agents/pi/extensions/subagents/config.ts +++ /dev/null @@ -1,182 +0,0 @@ -import { existsSync, readFileSync } from "node:fs"; -import { homedir } from "node:os"; -import { join } from "node:path"; -import type { ContextMode, SpawnRequest, ToolProfile } from "./types.ts"; -import type { AgentDefinition } from "./agents.ts"; - -export interface Diagnostics { - warnings: string[]; -} - -export interface SubagentsConfig { - defaultContext: ContextMode; - defaultTools: string; - maxConcurrent: number; - recentTerminalTtlMs: number; - ui: { - enabled: boolean; - defaultExpanded: boolean; - }; - toolProfiles: Record; -} - -export interface ResolvedSpawnRequest extends SpawnRequest { - prompt: string; - context: ContextMode; - tools: string; - toolProfile: ToolProfile; - agentBody?: string; -} - -export const BUILT_IN_TOOL_PROFILES: Record = { - none: { activeTools: [] }, - "read-only": { activeTools: ["read", "grep", "find", "ls"] }, - "read-only-with-safe-bash": { activeTools: ["read", "grep", "find", "ls", "bash"] }, - "full-tools": { activeTools: null }, -}; - -const DEFAULT_CONFIG: SubagentsConfig = { - defaultContext: "independent", - defaultTools: "read-only", - maxConcurrent: 3, - recentTerminalTtlMs: 5 * 60 * 1000, - ui: { enabled: true, defaultExpanded: false }, - toolProfiles: { ...BUILT_IN_TOOL_PROFILES }, -}; - -export function loadConfig(cwd: string, projectTrusted: boolean, diagnostics: Diagnostics, agentDir = defaultAgentDir()): SubagentsConfig { - let config = cloneConfig(DEFAULT_CONFIG); - config = mergeConfig(config, readConfig(join(agentDir, "subagents.json"), diagnostics, "global"), diagnostics, "global"); - if (projectTrusted) { - config = mergeConfig(config, readConfig(join(cwd, ".pi", "subagents.json"), diagnostics, "project"), diagnostics, "project"); - } - if (!config.toolProfiles[config.defaultTools]) { - diagnostics.warnings.push(`Unknown defaultTools profile '${config.defaultTools}', using read-only`); - config.defaultTools = "read-only"; - } - return config; -} - -export function resolveSpawn(request: SpawnRequest, config: SubagentsConfig, agents: Map): ResolvedSpawnRequest { - const prompt = typeof request.prompt === "string" ? request.prompt.trim() : ""; - if (!prompt) throw new Error("prompt is required"); - const agent = request.agent ? agents.get(request.agent) : undefined; - if (request.agent && !agent) throw new Error(`unknown subagent agent: ${request.agent}`); - - const context = request.context ?? agent?.context ?? config.defaultContext; - if (context !== "independent" && context !== "fork") throw new Error(`unsupported context: ${context}`); - if (agent?.allowedContexts && !agent.allowedContexts.includes(context)) { - throw new Error(`agent '${agent.name}' does not allow ${context} context`); - } - - const tools = request.tools ?? agent?.tools ?? config.defaultTools; - const toolProfile = config.toolProfiles[tools]; - if (!toolProfile) throw new Error(`unknown tool profile: ${tools}`); - - return { - ...request, - prompt, - agent: agent?.name ?? request.agent, - context, - model: request.model ?? agent?.model, - thinking: request.thinking ?? agent?.thinking, - tools, - toolProfile, - agentBody: agent?.body, - }; -} - -function readConfig(path: string, diagnostics: Diagnostics, label: string): Partial | undefined { - if (!existsSync(path)) return undefined; - try { - const parsed = JSON.parse(readFileSync(path, "utf8")); - return normalizeConfig(parsed, diagnostics, label); - } catch (error) { - diagnostics.warnings.push(`Invalid ${label} subagents.json: ${error instanceof Error ? error.message : String(error)}`); - return undefined; - } -} - -function normalizeConfig(raw: unknown, diagnostics: Diagnostics, label: string): Partial | undefined { - if (!raw || typeof raw !== "object" || Array.isArray(raw)) { - diagnostics.warnings.push(`Invalid ${label} subagents.json: root must be an object`); - return undefined; - } - const input = raw as Record; - const config: Partial = {}; - if (input.defaultContext === "independent" || input.defaultContext === "fork") config.defaultContext = input.defaultContext; - else if (input.defaultContext !== undefined) diagnostics.warnings.push(`Invalid ${label} defaultContext ignored`); - if (typeof input.defaultTools === "string") config.defaultTools = input.defaultTools; - else if (input.defaultTools !== undefined) diagnostics.warnings.push(`Invalid ${label} defaultTools ignored`); - if (typeof input.maxConcurrent === "number" && Number.isInteger(input.maxConcurrent) && input.maxConcurrent > 0) config.maxConcurrent = input.maxConcurrent; - else if (input.maxConcurrent !== undefined) diagnostics.warnings.push(`Invalid ${label} maxConcurrent ignored`); - if (typeof input.recentTerminalTtlMs === "number" && Number.isInteger(input.recentTerminalTtlMs) && input.recentTerminalTtlMs >= 0) { - config.recentTerminalTtlMs = input.recentTerminalTtlMs; - } else if (input.recentTerminalTtlMs !== undefined) diagnostics.warnings.push(`Invalid ${label} recentTerminalTtlMs ignored`); - if (input.ui !== undefined) config.ui = normalizeUi(input.ui, diagnostics, label); - if (input.toolProfiles !== undefined) config.toolProfiles = normalizeProfiles(input.toolProfiles, diagnostics, label); - return config; -} - -function normalizeUi(raw: unknown, diagnostics: Diagnostics, label: string): SubagentsConfig["ui"] | undefined { - if (!raw || typeof raw !== "object" || Array.isArray(raw)) { - diagnostics.warnings.push(`Invalid ${label} ui ignored`); - return undefined; - } - const input = raw as Record; - return { - enabled: typeof input.enabled === "boolean" ? input.enabled : DEFAULT_CONFIG.ui.enabled, - defaultExpanded: typeof input.defaultExpanded === "boolean" ? input.defaultExpanded : DEFAULT_CONFIG.ui.defaultExpanded, - }; -} - -function normalizeProfiles(raw: unknown, diagnostics: Diagnostics, label: string): Record { - const profiles: Record = {}; - if (!raw || typeof raw !== "object" || Array.isArray(raw)) { - diagnostics.warnings.push(`Invalid ${label} toolProfiles ignored`); - return profiles; - } - for (const [name, value] of Object.entries(raw as Record)) { - if (name in BUILT_IN_TOOL_PROFILES) { - diagnostics.warnings.push(`Ignoring ${label} override for built-in tool profile '${name}'`); - continue; - } - const profile = normalizeProfile(value); - if (!profile) { - diagnostics.warnings.push(`Invalid ${label} tool profile '${name}' ignored`); - continue; - } - profiles[name] = profile; - } - return profiles; -} - -function normalizeProfile(raw: unknown): ToolProfile | undefined { - if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined; - const activeTools = (raw as { activeTools?: unknown }).activeTools; - if (!Array.isArray(activeTools) || !activeTools.every((tool) => typeof tool === "string")) return undefined; - return { activeTools }; -} - -function mergeConfig(base: SubagentsConfig, override: Partial | undefined, diagnostics: Diagnostics, label: string): SubagentsConfig { - if (!override) return base; - const merged = cloneConfig(base); - if (override.defaultContext) merged.defaultContext = override.defaultContext; - if (override.defaultTools) merged.defaultTools = override.defaultTools; - if (override.maxConcurrent) merged.maxConcurrent = override.maxConcurrent; - if (override.recentTerminalTtlMs !== undefined) merged.recentTerminalTtlMs = override.recentTerminalTtlMs; - if (override.ui) merged.ui = { ...merged.ui, ...override.ui }; - if (override.toolProfiles) merged.toolProfiles = { ...merged.toolProfiles, ...override.toolProfiles }; - for (const key of Object.keys(merged.toolProfiles)) { - if (key in BUILT_IN_TOOL_PROFILES) merged.toolProfiles[key] = BUILT_IN_TOOL_PROFILES[key]; - } - return merged; -} - -function cloneConfig(config: SubagentsConfig): SubagentsConfig { - return { ...config, ui: { ...config.ui }, toolProfiles: { ...config.toolProfiles } }; -} - -function defaultAgentDir(): string { - return process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent"); -} diff --git a/modules/agents/pi/extensions/subagents/index.ts b/modules/agents/pi/extensions/subagents/index.ts deleted file mode 100644 index ea73bca..0000000 --- a/modules/agents/pi/extensions/subagents/index.ts +++ /dev/null @@ -1,326 +0,0 @@ -import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; -import { Type } from "typebox"; -import { loadAgents } from "./agents.ts"; -import { loadConfig, resolveSpawn, type Diagnostics } from "./config.ts"; -import { SubprocessRpcRunner } from "./runner.ts"; -import { Supervisor } from "./supervisor.ts"; -import { milestoneNotification } from "./status.ts"; -import type { SpawnRequest, SubagentStatus } from "./types.ts"; -import { widget } from "./ui.ts"; - -let supervisor: Supervisor | undefined; -let lastDiagnostics: Diagnostics = { warnings: [] }; -let lastStatuses: SubagentStatus[] = []; -let uiExpanded = false; - -export default function subagents(pi: ExtensionAPI) { - const getSupervisor = (ctx: ExtensionContext): Supervisor => { - if (supervisor) return supervisor; - const diagnostics: Diagnostics = { warnings: [] }; - const cwd = cwdOf(ctx); - const config = loadConfig(cwd, isProjectTrusted(ctx), diagnostics); - lastDiagnostics = diagnostics; - uiExpanded = config.ui.defaultExpanded; - supervisor = new Supervisor(new SubprocessRpcRunner(), cwd, { - maxConcurrent: config.maxConcurrent, - recentTerminalTtlMs: config.recentTerminalTtlMs, - onMilestone: (status, event) => { - pi.appendEntry("subagent_milestone", { event, status }); - const notification = milestoneNotification(status, event); - if (notification) ctx.ui?.notify?.(notification.message, notification.level); - }, - onChange: (statuses) => { - lastStatuses = statuses; - updateUi(ctx, config.ui.enabled); - }, - }); - updateUi(ctx, config.ui.enabled); - return supervisor; - }; - - const resolve = (ctx: ExtensionContext, request: SpawnRequest): SpawnRequest => { - const diagnostics: Diagnostics = { warnings: [] }; - const cwd = cwdOf(ctx); - const trusted = isProjectTrusted(ctx); - const config = loadConfig(cwd, trusted, diagnostics); - const agents = loadAgents(cwd, trusted, diagnostics); - lastDiagnostics = diagnostics; - const resolved = resolveSpawn(request, config, agents); - if (resolved.context === "fork") resolved.parentSessionFile = ctx.sessionManager.getSessionFile(); - return resolved; - }; - - pi.registerTool({ - name: "subagent_spawn", - label: "Spawn subagent", - description: "Start one ad hoc independent subagent and return immediately with its child id", - parameters: Type.Object({ - prompt: Type.String({ description: "Prompt for the delegated subagent" }), - label: Type.Optional(Type.String({ description: "Human-readable label for this work item" })), - agent: Type.Optional(Type.String({ description: "Named agent definition to use" })), - context: Type.Optional(Type.Union([Type.Literal("independent"), Type.Literal("fork")])), - model: Type.Optional(Type.String({ description: "Optional model selector for the child" })), - thinking: Type.Optional(Type.String({ description: "Optional thinking level for the child" })), - tools: Type.Optional(Type.String({ description: "Tool profile name" })), - }), - async execute(_toolCallId, params, _signal, _onUpdate, ctx) { - const accepted = getSupervisor(ctx).spawn(resolve(ctx, params as SpawnRequest)); - ctx.ui?.notify?.(`Started subagent ${accepted.label}`, "info"); - return textResult(accepted); - }, - }); - - pi.registerTool({ - name: "subagent_batch", - label: "Spawn subagent batch", - description: "Start multiple subagents and return immediately with accepted child ids and per-entry failures", - parameters: Type.Object({ - subagents: Type.Array( - Type.Object({ - prompt: Type.String({ description: "Prompt for the delegated subagent" }), - label: Type.Optional(Type.String({ description: "Human-readable label for this work item" })), - agent: Type.Optional(Type.String({ description: "Named agent definition to use" })), - context: Type.Optional(Type.Union([Type.Literal("independent"), Type.Literal("fork")])), - model: Type.Optional(Type.String({ description: "Optional model selector for the child" })), - thinking: Type.Optional(Type.String({ description: "Optional thinking level for the child" })), - tools: Type.Optional(Type.String({ description: "Tool profile name" })), - }), - ), - }), - async execute(_toolCallId, params, _signal, _onUpdate, ctx) { - const requests = Array.isArray((params as { subagents?: unknown }).subagents) ? ((params as { subagents: SpawnRequest[] }).subagents) : []; - const accepted: SpawnRequest[] = []; - const failed: Array<{ index: number; error: string }> = []; - requests.forEach((request, index) => { - try { - accepted.push(resolve(ctx, request)); - } catch (error) { - failed.push({ index, error: error instanceof Error ? error.message : String(error) }); - } - }); - const result = getSupervisor(ctx).spawnBatch(accepted); - return textResult({ accepted: result.accepted, failed: [...failed, ...result.failed] }); - }, - }); - - pi.registerTool({ - name: "subagent_list", - label: "List subagents", - description: "List active and terminal subagents for this parent session until terminal entries are cleared", - parameters: Type.Object({}), - async execute(_toolCallId, _params, _signal, _onUpdate, ctx) { - return textResult(getSupervisor(ctx).list()); - }, - }); - - pi.registerTool({ - name: "subagent_status", - label: "Get subagent status", - description: "Get current lifecycle status for one subagent", - parameters: Type.Object({ - id: Type.String({ description: "Subagent id returned by subagent_spawn" }), - }), - async execute(_toolCallId, params, _signal, _onUpdate, ctx) { - return textResult(getSupervisor(ctx).status(String((params as { id: unknown }).id))); - }, - }); - - pi.registerTool({ - name: "subagent_result", - label: "Get subagent result", - description: "Return still-running before completion and the final answer after completion", - parameters: Type.Object({ - id: Type.String({ description: "Subagent id returned by subagent_spawn" }), - }), - async execute(_toolCallId, params, _signal, _onUpdate, ctx) { - return textResult(getSupervisor(ctx).result(String((params as { id: unknown }).id))); - }, - }); - - pi.registerTool({ - name: "subagent_wait", - label: "Wait for subagents", - description: "Block until multiple subagents are terminal or a timeout expires. Prefer setting timeoutMs so the parent turn cannot hang forever", - parameters: Type.Object({ - ids: Type.Array(Type.String({ description: "Subagent id returned by subagent_spawn or subagent_batch" })), - timeoutMs: Type.Optional(Type.Number({ description: "Maximum milliseconds to wait. Omit or use 0 to wait indefinitely" })), - mode: Type.Optional(Type.Union([Type.Literal("all"), Type.Literal("any")], { description: "Wait for all ids by default, or return after any id is terminal" })), - }), - async execute(_toolCallId, params, signal, _onUpdate, ctx) { - const input = params as { ids?: unknown; timeoutMs?: unknown; mode?: unknown }; - const ids = Array.isArray(input.ids) ? input.ids.map(String) : []; - const timeoutMs = typeof input.timeoutMs === "number" && Number.isFinite(input.timeoutMs) ? input.timeoutMs : undefined; - const mode = input.mode === "any" ? "any" : "all"; - return textResult(await getSupervisor(ctx).wait(ids, { timeoutMs, mode, signal })); - }, - }); - - pi.registerTool({ - name: "subagent_cancel", - label: "Cancel subagent", - description: "Cancel a running subagent", - parameters: Type.Object({ - id: Type.String({ description: "Subagent id returned by subagent_spawn" }), - }), - async execute(_toolCallId, params, _signal, _onUpdate, ctx) { - return textResult(await getSupervisor(ctx).cancel(String((params as { id: unknown }).id))); - }, - }); - - pi.registerTool({ - name: "subagent_clear", - label: "Clear terminal subagents", - description: "Remove terminal subagents from the current-session visible work set. Omitting ids clears all terminal children", - parameters: Type.Object({ - ids: Type.Optional(Type.Array(Type.String({ description: "Subagent id returned by subagent_spawn or subagent_batch" }))), - }), - async execute(_toolCallId, params, _signal, _onUpdate, ctx) { - const input = params as { ids?: unknown }; - const ids = Array.isArray(input.ids) ? input.ids.map(String) : undefined; - return textResult({ cleared: getSupervisor(ctx).clearTerminal(ids) }); - }, - }); - - pi.registerCommand("subagent-spawn", { - description: "Start an ad hoc independent subagent", - handler: async (args, ctx) => { - const accepted = getSupervisor(ctx).spawn(resolve(ctx, parseSpawnArgs(args))); - ctx.ui.notify(`Started subagent ${accepted.label}`, "info"); - }, - }); - - pi.registerCommand("subagent-batch", { - description: "Start ad hoc independent subagents split by |", - handler: async (args, ctx) => { - const requests = args - .split("|") - .map((prompt) => prompt.trim()) - .filter(Boolean) - .map((prompt) => resolve(ctx, { prompt })); - ctx.ui.notify(JSON.stringify(getSupervisor(ctx).spawnBatch(requests), null, 2), "info"); - }, - }); - - pi.registerCommand("subagent-list", { - description: "Show subagent status records", - handler: async (_args, ctx) => { - ctx.ui.notify(JSON.stringify(getSupervisor(ctx).list(), null, 2), "info"); - }, - }); - - pi.registerCommand("subagent-clear", { - description: "Clear terminal subagent records. Pass ids to clear selected terminal records only", - handler: async (args, ctx) => { - const ids = args.trim().split(/\s+/u).filter(Boolean); - ctx.ui.notify(JSON.stringify({ cleared: getSupervisor(ctx).clearTerminal(ids.length > 0 ? ids : undefined) }, null, 2), "info"); - }, - }); - - pi.registerCommand("subagent-status", { - description: "Show a subagent status by id", - handler: async (args, ctx) => { - ctx.ui.notify(JSON.stringify(getSupervisor(ctx).status(args.trim()), null, 2), "info"); - }, - }); - - pi.registerCommand("subagent-result", { - description: "Show a subagent result by id", - handler: async (args, ctx) => { - ctx.ui.notify(JSON.stringify(getSupervisor(ctx).result(args.trim()), null, 2), "info"); - }, - }); - - pi.registerCommand("subagent-wait", { - description: "Wait for subagent ids separated by spaces", - handler: async (args, ctx) => { - const { ids, timeoutMs, mode } = parseWaitArgs(args); - ctx.ui.notify(JSON.stringify(await getSupervisor(ctx).wait(ids, { timeoutMs, mode }), null, 2), "info"); - }, - }); - - pi.registerCommand("subagent-ui", { - description: "Toggle the bundled subagent status inspector", - handler: async (_args, ctx) => { - uiExpanded = !uiExpanded; - updateUi(ctx, true); - ctx.ui.notify(`Subagent inspector ${uiExpanded ? "expanded" : "collapsed"}`, "info"); - }, - }); - - pi.registerCommand("subagent-diagnostics", { - description: "Show subagent configuration diagnostics from the last load", - handler: async (_args, ctx) => { - ctx.ui.notify(JSON.stringify(lastDiagnostics, null, 2), "info"); - }, - }); - - pi.registerCommand("subagent-cancel", { - description: "Cancel a running subagent by id", - handler: async (args, ctx) => { - ctx.ui.notify(JSON.stringify(await getSupervisor(ctx).cancel(args.trim()), null, 2), "info"); - }, - }); - - pi.on("session_shutdown", async () => { - await supervisor?.shutdown(); - supervisor = undefined; - }); -} - -function updateUi(ctx: ExtensionContext, enabled: boolean) { - if (!ctx.hasUI) return; - ctx.ui.setWidget("subagents", enabled ? widget(lastStatuses, uiExpanded) : undefined); -} - -function parseSpawnArgs(args: string): SpawnRequest { - const parts = args.trim().split(/\s+/u); - const request: Partial = {}; - while (parts.length >= 2 && parts[0].startsWith("--")) { - const flag = parts.shift(); - const value = parts.shift(); - if (flag === "--agent") request.agent = value; - else if (flag === "--label") request.label = value; - else if (flag === "--context" && (value === "independent" || value === "fork")) request.context = value; - else if (flag === "--tools") request.tools = value; - else if (flag === "--model") request.model = value; - else if (flag === "--thinking") request.thinking = value; - } - return { ...request, prompt: parts.join(" ") || args } as SpawnRequest; -} - -function parseWaitArgs(args: string): { ids: string[]; timeoutMs?: number; mode?: "all" | "any" } { - const parts = args.trim().split(/\s+/u).filter(Boolean); - let timeoutMs: number | undefined; - let mode: "all" | "any" | undefined; - const ids: string[] = []; - while (parts.length > 0) { - const part = parts.shift(); - if (!part) continue; - if (part === "--timeout-ms" && parts[0]) { - const parsed = Number(parts.shift()); - if (Number.isFinite(parsed)) timeoutMs = parsed; - } else if (part === "--mode" && (parts[0] === "all" || parts[0] === "any")) { - mode = parts.shift() as "all" | "any"; - } else { - ids.push(part); - } - } - return { ids, timeoutMs, mode }; -} - -function isProjectTrusted(ctx: ExtensionContext): boolean { - const value = (ctx as unknown as { isProjectTrusted?: () => boolean }).isProjectTrusted?.(); - return value === true; -} - -function cwdOf(ctx: ExtensionContext): string { - const sessionCwd = (ctx as unknown as { sessionManager?: { getCwd?: () => string }; cwd?: string }).sessionManager?.getCwd?.(); - return sessionCwd ?? (ctx as unknown as { cwd?: string }).cwd ?? process.cwd(); -} - -function textResult(value: unknown) { - return { - content: [{ type: "text" as const, text: JSON.stringify(value, null, 2) }], - details: value, - }; -} diff --git a/modules/agents/pi/extensions/subagents/runner.test.ts b/modules/agents/pi/extensions/subagents/runner.test.ts deleted file mode 100644 index 55c54e4..0000000 --- a/modules/agents/pi/extensions/subagents/runner.test.ts +++ /dev/null @@ -1,118 +0,0 @@ -import assert from "node:assert/strict"; -import childProcess from "node:child_process"; -import { EventEmitter } from "node:events"; -import { fileURLToPath } from "node:url"; -import test from "node:test"; -import type { RunnerEvents } from "./types.ts"; - -class FakeStream extends EventEmitter { - setEncoding(_encoding: BufferEncoding): void {} - - write(_chunk: string, callback?: (error?: Error | null) => void): boolean { - callback?.(); - return true; - } - - end(): void {} -} - -function events(): RunnerEvents { - return { - accepted: () => {}, - running: () => {}, - settling: () => {}, - completed: () => {}, - failed: () => {}, - }; -} - -test("child RPC process forwards structured activity before collecting the final result", async (t) => { - const running: unknown[] = []; - const completed: Array<{ result: string; stopReason?: string }> = []; - const fakeChild = new EventEmitter() as EventEmitter & { - stdout: FakeStream; - stderr: FakeStream; - stdin: FakeStream; - killed: boolean; - pid?: number; - kill(signal?: NodeJS.Signals): boolean; - }; - fakeChild.stdout = new FakeStream(); - fakeChild.stderr = new FakeStream(); - fakeChild.stdin = new FakeStream(); - fakeChild.killed = false; - fakeChild.kill = () => { - fakeChild.killed = true; - return true; - }; - t.mock.method(fakeChild.stdin, "write", (chunk, callback?: (error?: Error | null) => void) => { - const request = JSON.parse(String(chunk)) as { id: string; type: string }; - callback?.(); - if (request.type === "get_last_assistant_text") { - queueMicrotask(() => { - fakeChild.stdout.emit("data", `${JSON.stringify({ id: request.id, type: "response", success: true, data: { text: "final answer" } })}\n`); - }); - } - return true; - }); - t.mock.method(childProcess, "spawn", () => fakeChild as unknown as childProcess.ChildProcessWithoutNullStreams); - - const { SubprocessRpcRunner } = await import("./runner.ts"); - const runner = new SubprocessRpcRunner(); - await runner.start("child-1", { prompt: "work", label: "Review migration" }, "/tmp", { - ...events(), - running: (event) => running.push(event), - completed: (result, stopReason) => completed.push({ result, stopReason }), - }); - - const firstActivity = { type: "message_start", role: "assistant", message: { id: "msg-1" } }; - const secondActivity = { type: "tool_execution_start", tool: "read", input: { path: "runner.ts" } }; - const settledActivity = { type: "agent_settled" }; - fakeChild.stdout.emit("data", `${JSON.stringify(firstActivity)}\n${JSON.stringify(secondActivity)}\n${JSON.stringify(settledActivity)}\n`); - await new Promise((resolve) => setImmediate(resolve)); - - assert.deepEqual(running, [firstActivity, secondActivity, settledActivity]); - assert.deepEqual(completed, [{ result: "final answer", stopReason: "agent_settled" }]); -}); - -test("child RPC process disables discovery while explicitly loading subagents extension", async (t) => { - const calls: Array<{ command: string; args: string[] }> = []; - const fakeChild = new EventEmitter() as EventEmitter & { - stdout: FakeStream; - stderr: FakeStream; - stdin: FakeStream; - killed: boolean; - pid?: number; - kill(signal?: NodeJS.Signals): boolean; - }; - fakeChild.stdout = new FakeStream(); - fakeChild.stderr = new FakeStream(); - fakeChild.stdin = new FakeStream(); - fakeChild.killed = false; - fakeChild.kill = () => { - fakeChild.killed = true; - return true; - }; - const spawn = t.mock.method(childProcess, "spawn", (command, args) => { - calls.push({ command: String(command), args: Array.isArray(args) ? args.map(String) : [] }); - return fakeChild as unknown as childProcess.ChildProcessWithoutNullStreams; - }); - - const { SubprocessRpcRunner } = await import("./runner.ts"); - const runner = new SubprocessRpcRunner(); - await runner.start("child-1", { prompt: "work", label: "Review migration" }, "/tmp", events()); - - assert.equal(spawn.mock.callCount(), 1); - const args = calls[0].args; - const noExtensionsIndex = args.indexOf("--no-extensions"); - const extensionIndex = args.indexOf("--extension"); - - const nameIndex = args.indexOf("--name"); - - assert.notEqual(noExtensionsIndex, -1, "child args keep automatic extension discovery disabled"); - assert.notEqual(nameIndex, -1, "child args include a process name"); - assert.equal(args[nameIndex + 1], "subagent Review migration"); - assert.notEqual(extensionIndex, -1, "child args explicitly load the subagents extension entry"); - assert.equal(args[extensionIndex + 1], fileURLToPath(new URL("./index.ts", import.meta.url))); - assert.ok(noExtensionsIndex < extensionIndex); -}); diff --git a/modules/agents/pi/extensions/subagents/runner.ts b/modules/agents/pi/extensions/subagents/runner.ts deleted file mode 100644 index c0db926..0000000 --- a/modules/agents/pi/extensions/subagents/runner.ts +++ /dev/null @@ -1,218 +0,0 @@ -import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; -import { fileURLToPath } from "node:url"; -import type { ChildHandle, ChildRunner, RunnerEvents, SpawnRequest } from "./types.ts"; - -interface PendingResponse { - resolve(value: unknown): void; - reject(error: Error): void; - command: string; -} - -interface RpcLine { - id?: string; - type?: string; - command?: string; - success?: boolean; - data?: unknown; - error?: string; - message?: string; -} - -class RpcChildHandle implements ChildHandle { - private buffer = ""; - private nextRequest = 0; - private settled = false; - private finishing = false; - private cancelling = false; - private killed = false; - private readonly pending = new Map(); - - constructor( - private readonly child: ChildProcessWithoutNullStreams, - private readonly events: RunnerEvents, - ) { - child.stdout.setEncoding("utf8"); - child.stderr.setEncoding("utf8"); - child.stdout.on("data", (chunk) => this.onStdout(chunk)); - child.stderr.on("data", (chunk) => this.events.running(`stderr: ${String(chunk).trim().slice(0, 200)}`)); - child.on("error", (error) => this.fail(error.message)); - child.on("close", (code, signal) => { - for (const pending of this.pending.values()) { - pending.reject(new Error(`RPC process closed before ${pending.command} response`)); - } - this.pending.clear(); - if (!this.settled) this.fail(`RPC process closed with code ${code ?? "null"} signal ${signal ?? "null"}`); - }); - } - - async prompt(message: string): Promise { - await this.send("prompt", { message }); - } - - async cancel(): Promise { - if (this.cancelling) return; - this.cancelling = true; - try { - await Promise.race([this.send("abort", {}), delay(200)]); - } catch {} - this.terminate(); - } - - private onStdout(chunk: string) { - this.buffer += chunk; - while (true) { - const newline = this.buffer.indexOf("\n"); - if (newline === -1) return; - const line = this.buffer.slice(0, newline).replace(/\r$/, ""); - this.buffer = this.buffer.slice(newline + 1); - if (line.trim() === "") continue; - this.onLine(line); - } - } - - private onLine(line: string) { - let payload: RpcLine; - try { - payload = JSON.parse(line); - } catch { - this.events.running(`non-json rpc output: ${line.slice(0, 200)}`); - return; - } - - if (payload.type === "response" && payload.id) { - const pending = this.pending.get(payload.id); - if (!pending) return; - this.pending.delete(payload.id); - if (payload.success) pending.resolve(payload.data); - else pending.reject(new Error(payload.error ?? payload.message ?? `${pending.command} failed`)); - return; - } - - if (payload.type === "agent_started") { - this.events.running(payload as Record); - return; - } - - if (payload.type === "agent_settled") { - this.events.running(payload as Record); - this.finish().catch((error) => this.fail(error instanceof Error ? error.message : String(error))); - return; - } - - if (payload.type) this.events.running(payload as Record); - } - - private async finish() { - if (this.settled || this.finishing) return; - this.finishing = true; - this.events.settling(); - const result = await this.send("get_last_assistant_text", {}); - const text = typeof result === "string" ? result : result && typeof result === "object" && "text" in result ? String((result as { text: unknown }).text) : ""; - this.settled = true; - this.events.completed(text, "agent_settled"); - this.terminate(); - } - - private terminate() { - if (this.killed) return; - this.killed = true; - this.child.stdin.end(); - if (this.child.killed) return; - if (process.platform !== "win32" && this.child.pid) { - try { - process.kill(-this.child.pid, "SIGTERM"); - } catch { - this.child.kill("SIGTERM"); - } - setTimeout(() => { - if (this.child.killed || !this.child.pid) return; - try { - process.kill(-this.child.pid, "SIGKILL"); - } catch { - this.child.kill("SIGKILL"); - } - }, 2_000).unref(); - return; - } - this.child.kill("SIGTERM"); - } - - private fail(error: string) { - if (this.settled) return; - this.settled = true; - this.events.failed(error); - } - - private send(command: string, body: Record): Promise { - const id = `subagent-${++this.nextRequest}`; - return new Promise((resolve, reject) => { - this.pending.set(id, { resolve, reject, command }); - this.child.stdin.write(`${JSON.stringify({ id, type: command, ...body })}\n`, (error) => { - if (!error) return; - this.pending.delete(id); - reject(error); - }); - }); - } -} - -export class SubprocessRpcRunner implements ChildRunner { - async start(id: string, request: SpawnRequest, cwd: string, events: RunnerEvents): Promise { - const args = [process.argv[1], "--mode", "rpc", "--no-extensions", "--extension", subagentsExtensionPath(), "--name", `subagent ${request.label ?? id}`, ...contextArgs(request), ...toolArgs(request), ...modelArgs(request)]; - const child = spawn(process.execPath, args, { - cwd, - env: childEnvironment(), - stdio: ["pipe", "pipe", "pipe"], - detached: process.platform !== "win32", - }); - const handle = new RpcChildHandle(child, events); - events.accepted(); - void handle.prompt(independentPrompt(request)).catch((error) => events.failed(error instanceof Error ? error.message : String(error))); - return handle; - } -} - -function delay(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -function subagentsExtensionPath(): string { - return fileURLToPath(new URL("./index.ts", import.meta.url)); -} - -function contextArgs(request: SpawnRequest): string[] { - if (request.context !== "fork" || !request.parentSessionFile) return []; - return ["--fork", request.parentSessionFile]; -} - -function toolArgs(request: SpawnRequest): string[] { - const activeTools = request.toolProfile?.activeTools; - if (activeTools === undefined || activeTools === null) return []; - if (activeTools.length === 0) return ["--no-tools"]; - return ["--tools", activeTools.join(",")]; -} - -function modelArgs(request: SpawnRequest): string[] { - const args: string[] = []; - if (request.model && request.model !== "inherit") args.push("--model", request.model); - if (request.thinking) args.push("--thinking", request.thinking); - return args; -} - -function childEnvironment(): NodeJS.ProcessEnv { - const env = { ...process.env }; - delete env.PI_SESSION_ID; - delete env.PI_SESSION_FILE; - delete env.PI_PROVIDER; - delete env.PI_MODEL; - delete env.PI_REASONING_LEVEL; - return env; -} - -function independentPrompt(request: SpawnRequest): string { - const base = request.agentBody ? `${request.agentBody}\n\n` : ""; - if (request.context === "fork") { - return `${base}You are running as a delegated subagent in fork context.\nUse the inherited parent session context, then return a concise final answer for the parent agent.\n\nTask:\n${request.prompt}`; - } - return `${base}You are running as a delegated subagent in independent context.\nDo not assume access to the parent conversation transcript.\nReturn a concise final answer for the parent agent.\n\nTask:\n${request.prompt}`; -} diff --git a/modules/agents/pi/extensions/subagents/status.ts b/modules/agents/pi/extensions/subagents/status.ts deleted file mode 100644 index 68f8481..0000000 --- a/modules/agents/pi/extensions/subagents/status.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { SUBAGENT_STATES, SUBAGENT_TERMINAL_STATES } from "./types.ts"; -import type { ChildRecord, SpawnAccepted, SubagentResult, SubagentState, SubagentStatus } from "./types.ts"; - -export function toAccepted(status: SubagentStatus): SpawnAccepted { - return { - id: status.id, - label: status.label, - context: status.context, - tools: status.tools, - state: status.state, - hint: `Use subagent_status or subagent_result with id ${status.id}`, - }; -} - -export function cloneStatus(status: SubagentStatus): SubagentStatus { - return { - ...status, - currentActivity: status.currentActivity ? { ...status.currentActivity } : undefined, - activityHistory: status.activityHistory.map((event) => ({ ...event })), - elapsedMs: elapsedMs(status), - }; -} - -export function cloneResult(record: ChildRecord): SubagentResult { - const status = cloneStatus(record.status); - const terminal = isTerminalState(status.state); - return { - id: status.id, - label: status.label, - state: status.state, - running: !terminal, - resultAvailable: status.resultAvailable, - result: record.result, - error: status.error, - completedAt: status.completedAt, - elapsedMs: status.elapsedMs, - }; -} - -export function isTerminalState(state: SubagentState): boolean { - return (SUBAGENT_TERMINAL_STATES as readonly string[]).includes(state); -} - -export function milestoneNotification(status: SubagentStatus, event: string): { message: string; level: "info" | "error" } | undefined { - if (!isSubagentState(event) || !isTerminalState(event)) return undefined; - return { message: `Subagent ${status.label} ${event}`, level: event === "completed" ? "info" : "error" }; -} - -export function isSubagentState(value: string): value is SubagentState { - return (SUBAGENT_STATES as readonly string[]).includes(value); -} - -export function elapsedMs(status: Pick): number { - const start = Date.parse(status.startedAt); - const end = status.completedAt ? Date.parse(status.completedAt) : Date.now(); - if (!Number.isFinite(start) || !Number.isFinite(end)) return 0; - return Math.max(0, end - start); -} diff --git a/modules/agents/pi/extensions/subagents/supervisor.test.ts b/modules/agents/pi/extensions/subagents/supervisor.test.ts deleted file mode 100644 index 626ed3e..0000000 --- a/modules/agents/pi/extensions/subagents/supervisor.test.ts +++ /dev/null @@ -1,469 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; -import { milestoneNotification } from "./status.ts"; -import { Supervisor } from "./supervisor.ts"; -import type { ChildHandle, ChildRunner, RunnerEvents, SpawnRequest } from "./types.ts"; -import { widget } from "./ui.ts"; - -class FakeHandle implements ChildHandle { - cancelCalls = 0; - - async cancel(): Promise { - this.cancelCalls += 1; - } -} - -class FakeRunner implements ChildRunner { - starts: Array<{ id: string; request: SpawnRequest; events: RunnerEvents; handle: FakeHandle }> = []; - autoAccept = true; - - async start(id: string, request: SpawnRequest, _cwd: string, events: RunnerEvents): Promise { - const handle = new FakeHandle(); - this.starts.push({ id, request, events, handle }); - if (this.autoAccept) events.accepted(`session-${id}`); - return handle; - } -} - -const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - -async function spawnStarted(supervisor: Supervisor, prompt = "work") { - const accepted = supervisor.spawn({ prompt }); - await sleep(0); - return accepted; -} - -test("cancel is idempotent and reaches cancelled", async () => { - const runner = new FakeRunner(); - const supervisor = new Supervisor(runner, "/tmp"); - const accepted = await spawnStarted(supervisor); - - const first = await supervisor.cancel(accepted.id); - const second = await supervisor.cancel(accepted.id); - - assert.equal(first.state, "cancelled"); - assert.equal(second.state, "cancelled"); - assert.equal(runner.starts[0].handle.cancelCalls, 1); -}); - -test("startup timeout reaches timed_out", async () => { - const runner = new FakeRunner(); - runner.autoAccept = false; - const supervisor = new Supervisor(runner, "/tmp", { timeouts: { startMs: 5 } }); - const accepted = await spawnStarted(supervisor); - - await sleep(20); - - const status = supervisor.status(accepted.id); - assert.equal(status.state, "timed_out"); - assert.equal(status.stopReason, "start_timeout"); - assert.equal(runner.starts[0].handle.cancelCalls, 1); -}); - -test("runtime timeout reaches timed_out", async () => { - const runner = new FakeRunner(); - const supervisor = new Supervisor(runner, "/tmp", { timeouts: { runMs: 5 } }); - const accepted = await spawnStarted(supervisor); - - await sleep(20); - - const status = supervisor.status(accepted.id); - assert.equal(status.state, "timed_out"); - assert.equal(status.stopReason, "run_timeout"); - assert.equal(runner.starts[0].handle.cancelCalls, 1); -}); - -test("activity exposes ordered transcript events while status and list keep only summaries", async () => { - const runner = new FakeRunner(); - const supervisor = new Supervisor(runner, "/tmp"); - const accepted = await spawnStarted(supervisor); - - runner.starts[0].events.running({ type: "message_started", role: "assistant" }); - runner.starts[0].events.running({ - type: "message_delta", - role: "assistant", - assistantMessageEvent: { type: "content_delta", delta: "private transcript body" }, - }); - runner.starts[0].events.running({ type: "tool_started", tool: "read", input: { path: "secret-notes.md" } }); - runner.starts[0].events.running({ type: "tool_completed", tool: "read", output: "secret file contents" }); - - type ActivityStatus = ReturnType & { - activityHistory: Array<{ type: string; summary: string }>; - currentActivity: { summary: string }; - }; - const activity = supervisor.activity(accepted.id); - const status = supervisor.status(accepted.id) as ActivityStatus; - const listed = supervisor.list().find((item) => item.id === accepted.id) as ActivityStatus | undefined; - - assert.deepEqual( - activity.map((event) => event.type), - ["queued", "starting", "prompt accepted", "message_started", "message_delta", "tool_started", "tool_completed"], - ); - assert.deepEqual(activity[4], { - type: "message_delta", - summary: "assistant message content_delta", - at: activity[4].at, - role: "assistant", - tool: undefined, - phase: "content_delta", - text: "private transcript body", - input: undefined, - output: undefined, - error: undefined, - payload: { - type: "message_delta", - role: "assistant", - assistantMessageEvent: { type: "content_delta", delta: "private transcript body" }, - }, - }); - assert.deepEqual(activity[5], { - type: "tool_started", - summary: "read secret-notes.md", - at: activity[5].at, - role: undefined, - tool: "read", - phase: "started", - text: undefined, - input: { path: "secret-notes.md" }, - output: undefined, - error: undefined, - payload: { type: "tool_started", tool: "read", input: { path: "secret-notes.md" } }, - }); - assert.equal(activity[6].output, "secret file contents"); - - assert.ok(Array.isArray(status.activityHistory), "status should expose structured activityHistory"); - assert.deepEqual(status.activityHistory.map((event) => event.type), activity.map((event) => event.type)); - assert.deepEqual(status.activityHistory.map((event) => event.summary), activity.map((event) => event.summary)); - assert.equal(status.currentActivity.summary, "read"); - assert.equal(listed?.currentActivity.summary, "read"); - assert.doesNotMatch(JSON.stringify(status), /private transcript body|secret file contents/u); - assert.doesNotMatch(JSON.stringify(listed), /private transcript body|secret file contents/u); -}); - -test("status activity history keeps only the 100 most recent summaries", async () => { - const runner = new FakeRunner(); - const supervisor = new Supervisor(runner, "/tmp"); - const accepted = await spawnStarted(supervisor); - - for (let index = 0; index < 150; index += 1) { - runner.starts[0].events.running(`tick ${index}`); - } - - const history = supervisor.status(accepted.id).activityHistory; - - assert.equal(history.length, 100); - assert.equal(history[0].summary, "tick 50"); - assert.equal(history[99].summary, "tick 149"); -}); - -test("process failure reaches failed with diagnostics", async () => { - const runner = new FakeRunner(); - const supervisor = new Supervisor(runner, "/tmp"); - const accepted = await spawnStarted(supervisor); - - runner.starts[0].events.failed("process closed with code 1"); - - const status = supervisor.status(accepted.id); - assert.equal(status.state, "failed"); - assert.equal(status.error, "process closed with code 1"); -}); - -test("shutdown cancels running children", async () => { - const runner = new FakeRunner(); - const supervisor = new Supervisor(runner, "/tmp"); - const accepted = await spawnStarted(supervisor); - - await supervisor.shutdown(); - - const status = supervisor.status(accepted.id); - assert.equal(status.state, "cancelled"); - assert.equal(status.stopReason, "shutdown"); - assert.equal(runner.starts[0].handle.cancelCalls, 1); -}); - -test("completed children ignore later cancel", async () => { - const runner = new FakeRunner(); - const supervisor = new Supervisor(runner, "/tmp"); - const accepted = await spawnStarted(supervisor); - - runner.starts[0].events.completed("done", "agent_settled"); - await supervisor.cancel(accepted.id); - - const result = supervisor.result(accepted.id); - assert.equal(result.state, "completed"); - assert.equal(result.result, "done"); - assert.equal(runner.starts[0].handle.cancelCalls, 0); -}); - -test("explicit labels are reused across accepted status list and result surfaces", async () => { - const runner = new FakeRunner(); - const supervisor = new Supervisor(runner, "/tmp"); - const label = "Review risky migration"; - - const accepted = supervisor.spawn({ prompt: "inspect the migration plan", label } as SpawnRequest & { label: string }); - await sleep(0); - runner.starts[0].events.completed("done", "agent_settled"); - - assert.deepEqual( - { - accepted: accepted.label, - status: supervisor.status(accepted.id).label, - list: supervisor.list().find((status) => status.id === accepted.id)?.label, - result: (supervisor.result(accepted.id) as { label?: string }).label, - }, - { - accepted: label, - status: label, - list: label, - result: label, - }, - ); -}); - -test("ad hoc fallback labels are prompt-derived and reused by widget and result surfaces", async () => { - const runner = new FakeRunner(); - const supervisor = new Supervisor(runner, "/tmp"); - const prompt = " Audit\n\tguest enablement plan "; - const label = "Audit guest enablement plan"; - - const accepted = supervisor.spawn({ prompt }); - await sleep(0); - runner.starts[0].events.completed("done", "agent_settled"); - const statuses = supervisor.list(); - const inspectorLines = widget(statuses, true)().render(240); - - assert.deepEqual( - { - accepted: accepted.label, - childRequest: runner.starts[0].request.label, - status: supervisor.status(accepted.id).label, - list: statuses.find((status) => status.id === accepted.id)?.label, - result: supervisor.result(accepted.id).label, - }, - { - accepted: label, - childRequest: label, - status: label, - list: label, - result: label, - }, - ); - assert.ok(inspectorLines.some((line) => line.includes(`completed 0s ${label} result: available`)), inspectorLines.join("\n")); - assert.doesNotMatch(accepted.label, /^ad-hoc sg-/u); -}); - -test("milestone notifications use the stored label", async () => { - const runner = new FakeRunner(); - const supervisor = new Supervisor(runner, "/tmp"); - const accepted = supervisor.spawn({ prompt: "work", label: "Review migration" }); - await sleep(0); - runner.starts[0].events.completed("done", "agent_settled"); - - assert.deepEqual(milestoneNotification(supervisor.status(accepted.id), "completed"), { - message: "Subagent Review migration completed", - level: "info", - }); - assert.equal(milestoneNotification(supervisor.status(accepted.id), "running"), undefined); -}); - -test("shutdown clears recent terminal expiry timer", async () => { - const runner = new FakeRunner(); - let changes = 0; - const supervisor = new Supervisor(runner, "/tmp", { - recentTerminalTtlMs: 5, - onChange: () => { - changes += 1; - }, - }); - await spawnStarted(supervisor); - - await supervisor.shutdown(); - const afterShutdown = changes; - await sleep(15); - - assert.equal(changes, afterShutdown); -}); - -test("batch spawn returns explicit labels on accepted child requests and statuses while preserving failures", async () => { - const runner = new FakeRunner(); - const supervisor = new Supervisor(runner, "/tmp"); - - const result = supervisor.spawnBatch([ - { prompt: "one", label: "Review docs" }, - { prompt: "" }, - { prompt: "two", label: "Check tests" }, - ]); - await sleep(0); - - assert.deepEqual(result.accepted.map((accepted) => accepted.label), ["Review docs", "Check tests"]); - assert.equal(result.failed.length, 1); - assert.equal(result.failed[0].index, 1); - assert.deepEqual(runner.starts.map((start) => start.request.label), ["Review docs", "Check tests"]); - assert.deepEqual(result.accepted.map((accepted) => supervisor.status(accepted.id).label), ["Review docs", "Check tests"]); -}); - -test("maxConcurrent preserves queued records", async () => { - const runner = new FakeRunner(); - const supervisor = new Supervisor(runner, "/tmp", { maxConcurrent: 1 }); - - const result = supervisor.spawnBatch([{ prompt: "one" }, { prompt: "two" }]); - await sleep(0); - - assert.equal(result.accepted.length, 2); - assert.equal(runner.starts.length, 1); - assert.equal(supervisor.status(result.accepted[1].id).state, "queued"); - - runner.starts[0].events.completed("done", "agent_settled"); - await sleep(0); - - assert.equal(runner.starts.length, 2); -}); - -test("clearTerminal returns only removed terminal ids", async () => { - const runner = new FakeRunner(); - const supervisor = new Supervisor(runner, "/tmp"); - const first = await spawnStarted(supervisor, "one"); - const second = await spawnStarted(supervisor, "two"); - const running = await spawnStarted(supervisor, "three"); - - runner.starts[0].events.completed("one done", "agent_settled"); - runner.starts[1].events.completed("two done", "agent_settled"); - - assert.deepEqual(supervisor.clearTerminal(), [first.id, second.id]); - assert.throws(() => supervisor.status(first.id), /unknown subagent id/); - assert.throws(() => supervisor.status(second.id), /unknown subagent id/); - assert.equal(supervisor.status(running.id).state, "running"); -}); - -test("terminal records expire after ttl while active children remain", async () => { - const runner = new FakeRunner(); - const supervisor = new Supervisor(runner, "/tmp", { recentTerminalTtlMs: 5 }); - const completed = await spawnStarted(supervisor, "one"); - const failed = await spawnStarted(supervisor, "two"); - const running = await spawnStarted(supervisor, "three"); - - runner.starts[0].events.completed("one done", "agent_settled"); - runner.starts[1].events.failed("two failed"); - - assert.equal(supervisor.result(completed.id).result, "one done"); - assert.equal(supervisor.result(failed.id).error, "two failed"); - assert.equal(supervisor.status(running.id).state, "running"); - - await sleep(20); - - const listedIds = supervisor.list().map((status) => status.id); - assert.equal(listedIds.includes(completed.id), false); - assert.equal(listedIds.includes(failed.id), false); - assert.equal(listedIds.includes(running.id), true); - assert.throws(() => supervisor.status(completed.id), /unknown subagent id/); - assert.throws(() => supervisor.status(failed.id), /unknown subagent id/); - assert.throws(() => supervisor.result(completed.id), /unknown subagent id/); - assert.throws(() => supervisor.result(failed.id), /unknown subagent id/); - assert.equal(supervisor.status(running.id).state, "running"); -}); - -test("zero recent terminal ttl does not hide terminal statuses", async () => { - const runner = new FakeRunner(); - const supervisor = new Supervisor(runner, "/tmp", { recentTerminalTtlMs: 0 }); - const accepted = await spawnStarted(supervisor); - - runner.starts[0].events.completed("done", "agent_settled"); - - assert.equal(supervisor.list().some((status) => status.id === accepted.id), true); - assert.equal(supervisor.result(accepted.id).result, "done"); -}); - -test("wait blocks until multiple subagents are terminal", async () => { - const runner = new FakeRunner(); - const supervisor = new Supervisor(runner, "/tmp"); - const first = await spawnStarted(supervisor, "one"); - const second = await spawnStarted(supervisor, "two"); - - const waiting = supervisor.wait([first.id, second.id], { timeoutMs: 100 }); - runner.starts[0].events.completed("one done", "agent_settled"); - await sleep(0); - - assert.equal(await Promise.race([waiting.then(() => "done"), sleep(10).then(() => "pending")]), "pending"); - - runner.starts[1].events.failed("two failed"); - const result = await waiting; - - assert.equal(result.timedOut, false); - assert.equal(result.ready, true); - assert.deepEqual(result.ids, [first.id, second.id]); - assert.equal(result.pending.length, 0); - assert.deepEqual(result.results.map((item) => item.state), ["completed", "failed"]); - assert.equal(result.results[0].result, "one done"); - assert.equal(result.results[1].error, "two failed"); -}); - -test("wait returns pending statuses on timeout", async () => { - const runner = new FakeRunner(); - const supervisor = new Supervisor(runner, "/tmp"); - const first = await spawnStarted(supervisor, "one"); - const second = await spawnStarted(supervisor, "two"); - - runner.starts[0].events.completed("one done", "agent_settled"); - const result = await supervisor.wait([first.id, second.id], { timeoutMs: 5 }); - - assert.equal(result.timedOut, true); - assert.equal(result.ready, false); - assert.deepEqual(result.results.map((item) => item.state), ["completed", "running"]); - assert.deepEqual(result.pending.map((item) => item.id), [second.id]); -}); - -test("wait any returns after the first terminal subagent", async () => { - const runner = new FakeRunner(); - const supervisor = new Supervisor(runner, "/tmp"); - const first = await spawnStarted(supervisor, "one"); - const second = await spawnStarted(supervisor, "two"); - - const waiting = supervisor.wait([first.id, second.id], { mode: "any", timeoutMs: 100 }); - runner.starts[1].events.completed("two done", "agent_settled"); - const result = await waiting; - - assert.equal(result.timedOut, false); - assert.equal(result.ready, true); - assert.deepEqual(result.results.map((item) => item.state), ["running", "completed"]); - assert.deepEqual(result.pending.map((item) => item.id), [first.id]); -}); - -test("wait rejects unknown and empty id sets", async () => { - const runner = new FakeRunner(); - const supervisor = new Supervisor(runner, "/tmp"); - - await assert.rejects(() => supervisor.wait([]), /at least one subagent id is required/); - await assert.rejects(() => supervisor.wait(["missing"]), /unknown subagent id: missing/); -}); - -test("wait abort rejects without cancelling child", async () => { - const runner = new FakeRunner(); - const supervisor = new Supervisor(runner, "/tmp"); - const accepted = await spawnStarted(supervisor, "one"); - const controller = new AbortController(); - - const waiting = supervisor.wait([accepted.id], { signal: controller.signal }); - controller.abort(); - - await assert.rejects(waiting, /subagent wait aborted/); - assert.equal(runner.starts[0].handle.cancelCalls, 0); -}); - -test("wait follows queued subagents through queue start and completion", async () => { - const runner = new FakeRunner(); - const supervisor = new Supervisor(runner, "/tmp", { maxConcurrent: 1 }); - const batch = supervisor.spawnBatch([{ prompt: "one" }, { prompt: "two" }]); - await sleep(0); - - const waiting = supervisor.wait([batch.accepted[1].id], { timeoutMs: 100 }); - assert.equal(await Promise.race([waiting.then(() => "done"), sleep(10).then(() => "pending")]), "pending"); - - runner.starts[0].events.completed("one done", "agent_settled"); - await sleep(0); - runner.starts[1].events.completed("two done", "agent_settled"); - const result = await waiting; - - assert.equal(result.timedOut, false); - assert.equal(result.ready, true); - assert.deepEqual(result.results.map((item) => item.result), ["two done"]); -}); diff --git a/modules/agents/pi/extensions/subagents/supervisor.ts b/modules/agents/pi/extensions/subagents/supervisor.ts deleted file mode 100644 index c803beb..0000000 --- a/modules/agents/pi/extensions/subagents/supervisor.ts +++ /dev/null @@ -1,558 +0,0 @@ -import type { - ChildHandle, - ChildRecord, - ChildRunner, - ContextMode, - RunnerActivity, - RunnerEvents, - SpawnAccepted, - SpawnRequest, - SubagentResult, - SubagentStatus, - SubagentWaitMode, - SubagentWaitResult, -} from "./types.ts"; -import { cloneResult, cloneStatus, isTerminalState, toAccepted } from "./status.ts"; - -interface RunningChild { - record: ChildRecord; - request: SpawnRequest; - handle?: ChildHandle; - startTimer?: ReturnType; - runTimer?: ReturnType; - expiryTimer?: ReturnType; -} - -interface SupervisorOptions { - maxConcurrent?: number; - recentTerminalLimit?: number; - recentTerminalTtlMs?: number; - timeouts?: { - startMs?: number; - runMs?: number; - }; - onMilestone?: (status: SubagentStatus, event: string) => void; - onChange?: (statuses: SubagentStatus[]) => void; -} - -export interface BatchSpawnResult { - accepted: SpawnAccepted[]; - failed: Array<{ index: number; error: string }>; -} - -const DEFAULT_TIMEOUTS = { - startMs: 30_000, - runMs: 0, -}; - -const MAX_ACTIVITY_HISTORY = 100; - -export class Supervisor { - private nextChild = 0; - private readonly children = new Map(); - private readonly queue: RunningChild[] = []; - private readonly waiters = new Set<() => void>(); - - constructor( - private readonly runner: ChildRunner, - private readonly cwd: string, - private readonly options: SupervisorOptions = {}, - ) {} - - spawn(request: SpawnRequest): SpawnAccepted { - return this.createChild(request); - } - - spawnBatch(requests: SpawnRequest[]): BatchSpawnResult { - const accepted: SpawnAccepted[] = []; - const failed: Array<{ index: number; error: string }> = []; - requests.forEach((request, index) => { - try { - accepted.push(this.createChild(request)); - } catch (error) { - failed.push({ index, error: error instanceof Error ? error.message : String(error) }); - } - }); - return { accepted, failed }; - } - - list(): SubagentStatus[] { - const statuses = [...this.children.values()].map((child) => cloneStatus(child.record.status)); - const active = statuses.filter((status) => !isTerminal(status.state)); - const terminal = statuses - .filter((status) => isTerminal(status.state)) - .sort((a, b) => Date.parse(b.completedAt ?? b.startedAt) - Date.parse(a.completedAt ?? a.startedAt)); - return [...active, ...terminal]; - } - - status(id: string): SubagentStatus { - return cloneStatus(this.require(id).record.status); - } - - result(id: string): SubagentResult { - return cloneResult(this.require(id).record); - } - - clearTerminal(ids?: string[]): string[] { - const selectedIds = ids ? [...new Set(ids.map((id) => id.trim()).filter(Boolean))] : undefined; - if (selectedIds) for (const id of selectedIds) this.require(id); - const cleared: string[] = []; - for (const [id, child] of this.children) { - if (selectedIds && !selectedIds.includes(id)) continue; - if (!isTerminal(child.record.status.state)) continue; - this.clearTimer(child, "expiryTimer"); - cleared.push(id); - this.children.delete(id); - } - if (cleared.length > 0) this.emitChange(); - return cleared; - } - - async wait( - ids: string[], - options: { timeoutMs?: number; signal?: AbortSignal; mode?: SubagentWaitMode } = {}, - ): Promise { - const uniqueIds = [...new Set(ids.map((id) => id.trim()).filter(Boolean))]; - if (uniqueIds.length === 0) throw new Error("at least one subagent id is required"); - for (const id of uniqueIds) this.require(id); - - const startedAt = Date.now(); - const mode = options.mode ?? "all"; - if (mode !== "all" && mode !== "any") throw new Error(`unknown wait mode: ${mode}`); - const deadline = options.timeoutMs && options.timeoutMs > 0 ? startedAt + options.timeoutMs : undefined; - let timedOut = false; - - while (!this.waitReady(uniqueIds, mode)) { - if (options.signal?.aborted) throw new Error("subagent wait aborted"); - const remainingMs = deadline === undefined ? undefined : deadline - Date.now(); - if (remainingMs !== undefined && remainingMs <= 0) { - timedOut = true; - break; - } - await this.nextChange(remainingMs, options.signal).catch((error) => { - if (error instanceof Error && error.message === "subagent wait timed out") timedOut = true; - else throw error; - }); - if (timedOut) break; - } - - const results = uniqueIds.map((id) => this.result(id)); - const pending = uniqueIds - .map((id) => this.status(id)) - .filter((status) => !isTerminal(status.state)); - return { ids: uniqueIds, mode, ready: this.waitReady(uniqueIds, mode), results, pending, timedOut, elapsedMs: Date.now() - startedAt }; - } - - async cancel(id: string): Promise { - const child = this.require(id); - if (isTerminal(child.record.status.state)) return cloneStatus(child.record.status); - await child.handle?.cancel(); - this.completeWithoutResult(child, "cancelled", "cancelled"); - this.pumpQueue(); - return cloneStatus(child.record.status); - } - - async shutdown(): Promise { - await Promise.allSettled( - [...this.children.values()].map(async (child) => { - if (!isTerminal(child.record.status.state)) { - await child.handle?.cancel(); - this.completeWithoutResult(child, "cancelled", "shutdown"); - } - }), - ); - for (const child of this.children.values()) this.clearTimer(child, "expiryTimer"); - } - - private createChild(request: SpawnRequest): SpawnAccepted { - const prompt = typeof request.prompt === "string" ? request.prompt.trim() : ""; - if (!prompt) throw new Error("prompt is required"); - - const id = this.allocateId(); - const now = new Date().toISOString(); - const status: SubagentStatus = { - id, - label: deriveLabel(request, id), - agent: request.agent, - adHoc: !request.agent, - context: this.resolveContext(request.context), - state: "queued", - cwd: this.cwd, - model: request.model, - thinking: request.thinking, - tools: request.tools ?? "read-only", - startedAt: now, - elapsedMs: 0, - lastEvent: "queued", - lastEventAt: now, - currentActivity: { type: "queued", summary: "queued", at: now }, - activityHistory: [{ type: "queued", summary: "queued", at: now }], - resultAvailable: false, - }; - const child: RunningChild = { record: { status, activityEvents: [{ type: "queued", summary: "queued", at: now }] }, request: { ...request, prompt, label: status.label, context: status.context, tools: status.tools } }; - this.children.set(id, child); - this.emitMilestone(child, "accepted"); - this.queue.push(child); - this.pumpQueue(); - return toAccepted(cloneStatus(status)); - } - - private pumpQueue() { - while (this.runningCount() < this.maxConcurrent()) { - const child = this.queue.shift(); - if (!child) break; - if (isTerminal(child.record.status.state)) continue; - this.start(child); - } - this.emitChange(); - } - - private start(child: RunningChild) { - this.setState(child.record.status, "starting", "starting"); - this.armStartTimer(child); - setTimeout(() => { - if (isTerminal(child.record.status.state)) return; - void this.runner - .start(child.record.status.id, child.request, this.cwd, this.eventsFor(child.record)) - .then((handle) => { - child.handle = handle; - if (isTerminal(child.record.status.state)) void handle.cancel(); - }) - .catch((error) => { - this.fail(child.record, error instanceof Error ? error.message : String(error)); - }); - }, 0); - } - - private eventsFor(record: ChildRecord): RunnerEvents { - return { - accepted: (childSession) => { - const child = this.findChild(record); - if (child) { - this.clearTimer(child, "startTimer"); - this.armRunTimer(child); - } - if (childSession) record.status.childSession = childSession; - this.setState(record.status, "running", "prompt accepted"); - }, - running: (event) => { - if (!isTerminal(record.status.state)) this.setState(record.status, "running", event); - }, - settling: () => { - if (!isTerminal(record.status.state)) this.setState(record.status, "settling", "agent_settled"); - }, - completed: (result, stopReason) => { - const now = new Date().toISOString(); - const child = this.findChild(record); - if (child) this.clearTimers(child); - record.result = result; - record.status.state = "completed"; - record.status.completedAt = now; - record.status.lastEvent = "completed"; - record.status.lastEventAt = now; - this.recordActivity(record, "completed", now); - record.status.stopReason = stopReason; - record.status.resultAvailable = true; - if (child) { - this.armTerminalExpiry(child); - this.emitMilestone(child, "completed"); - } - this.pumpQueue(); - }, - failed: (error) => this.fail(record, error), - }; - } - - private fail(record: ChildRecord, error: string) { - if (isTerminal(record.status.state)) return; - const child = this.findChild(record); - if (child) this.clearTimers(child); - const now = new Date().toISOString(); - record.status.state = "failed"; - record.status.completedAt = now; - record.status.lastEvent = "failed"; - record.status.lastEventAt = now; - this.recordActivity(record, "failed", now); - record.status.error = error; - record.status.stopReason = "failed"; - if (child) { - this.armTerminalExpiry(child); - this.emitMilestone(child, "failed"); - } - this.pumpQueue(); - } - - private completeWithoutResult(child: RunningChild, state: "cancelled" | "timed_out", reason: string) { - if (isTerminal(child.record.status.state)) return; - this.clearTimers(child); - const now = new Date().toISOString(); - child.record.status.state = state; - child.record.status.completedAt = now; - child.record.status.lastEvent = state; - child.record.status.lastEventAt = now; - this.recordActivity(child.record, state, now); - child.record.status.stopReason = reason; - this.armTerminalExpiry(child); - this.emitMilestone(child, state); - } - - private armStartTimer(child: RunningChild) { - const timeout = this.options.timeouts?.startMs ?? DEFAULT_TIMEOUTS.startMs; - if (timeout <= 0) return; - child.startTimer = setTimeout(() => { - this.timeout(child, "start_timeout"); - }, timeout); - } - - private armRunTimer(child: RunningChild) { - const timeout = this.options.timeouts?.runMs ?? DEFAULT_TIMEOUTS.runMs; - if (timeout <= 0) return; - child.runTimer = setTimeout(() => { - this.timeout(child, "run_timeout"); - }, timeout); - } - - private timeout(child: RunningChild, reason: string) { - if (isTerminal(child.record.status.state)) return; - void child.handle?.cancel(); - this.completeWithoutResult(child, "timed_out", reason); - this.pumpQueue(); - } - - private armTerminalExpiry(child: RunningChild) { - const ttl = this.options.recentTerminalTtlMs; - if (ttl === undefined || ttl <= 0) return; - this.clearTimer(child, "expiryTimer"); - child.expiryTimer = setTimeout(() => { - child.expiryTimer = undefined; - const id = child.record.status.id; - if (this.children.get(id) !== child || !isTerminal(child.record.status.state)) return; - this.children.delete(id); - this.emitChange(); - }, ttl); - child.expiryTimer.unref?.(); - } - - private clearTimers(child: RunningChild) { - this.clearTimer(child, "startTimer"); - this.clearTimer(child, "runTimer"); - } - - private clearTimer(child: RunningChild, key: "startTimer" | "runTimer" | "expiryTimer") { - const timer = child[key]; - if (!timer) return; - clearTimeout(timer); - child[key] = undefined; - } - - private findChild(record: ChildRecord): RunningChild | undefined { - return [...this.children.values()].find((child) => child.record === record); - } - - activity(id: string) { - return this.require(id).record.activityEvents.map((event) => ({ ...event })); - } - - private setState(status: SubagentStatus, state: SubagentStatus["state"], event: RunnerActivity) { - if (isTerminal(status.state)) return; - const record = this.require(status.id).record; - const now = new Date().toISOString(); - const activity = this.recordActivity(record, event, now); - status.state = state; - status.lastEvent = activity.type; - status.lastEventAt = now; - this.emitChange(); - } - - private recordActivity(record: ChildRecord, event: RunnerActivity, at: string) { - const activity = normalizeActivity(event, at); - record.activityEvents.push(activity); - const summary = summarizeActivity(activity); - record.status.currentActivity = summary; - record.status.activityHistory.push(summary); - if (record.status.activityHistory.length > MAX_ACTIVITY_HISTORY) { - record.status.activityHistory.splice(0, record.status.activityHistory.length - MAX_ACTIVITY_HISTORY); - } - return activity; - } - - private require(id: string): RunningChild { - const child = this.children.get(id); - if (!child) throw new Error(`unknown subagent id: ${id}`); - return child; - } - - private resolveContext(context: ContextMode | undefined): ContextMode { - if (context === undefined) return "independent"; - if (context !== "independent" && context !== "fork") throw new Error(`unknown context: ${context}`); - return context; - } - - private maxConcurrent(): number { - return Math.max(1, this.options.maxConcurrent ?? 3); - } - - private runningCount(): number { - return [...this.children.values()].filter((child) => ["starting", "running", "settling"].includes(child.record.status.state)).length; - } - - private emitMilestone(child: RunningChild, event: string) { - this.options.onMilestone?.(cloneStatus(child.record.status), event); - this.emitChange(); - } - - private emitChange() { - this.options.onChange?.(this.list()); - for (const waiter of this.waiters) waiter(); - } - - private waitReady(ids: string[], mode: SubagentWaitMode): boolean { - const terminal = (id: string) => isTerminal(this.require(id).record.status.state); - return mode === "all" ? ids.every(terminal) : ids.some(terminal); - } - - private nextChange(timeoutMs: number | undefined, signal: AbortSignal | undefined): Promise { - return new Promise((resolve, reject) => { - let timer: ReturnType | undefined; - const cleanup = () => { - this.waiters.delete(resolveOnce); - if (timer) clearTimeout(timer); - signal?.removeEventListener("abort", abort); - }; - const resolveOnce = () => { - cleanup(); - resolve(); - }; - const abort = () => { - cleanup(); - reject(new Error("subagent wait aborted")); - }; - this.waiters.add(resolveOnce); - signal?.addEventListener("abort", abort, { once: true }); - if (timeoutMs !== undefined) { - timer = setTimeout(() => { - cleanup(); - reject(new Error("subagent wait timed out")); - }, timeoutMs); - } - }); - } - - private allocateId(): string { - this.nextChild += 1; - return `sg-${Date.now().toString(36)}-${this.nextChild.toString(36)}`; - } -} - -function deriveLabel(request: SpawnRequest, id: string): string { - const explicit = normalizeLabel(request.label); - if (explicit) return explicit; - const agent = normalizeLabel(request.agent); - if (agent) return agent; - return promptLabel(request.prompt) ?? `ad-hoc ${id}`; -} - -function promptLabel(prompt: string): string | undefined { - const normalized = normalizeLabel(prompt); - if (!normalized) return undefined; - return truncateLabel(normalized); -} - -function normalizeLabel(value: unknown): string | undefined { - if (typeof value !== "string") return undefined; - const normalized = value.replace(/\s+/gu, " ").trim(); - return normalized || undefined; -} - -function truncateLabel(label: string): string { - const maxLength = 80; - if (label.length <= maxLength) return label; - return `${label.slice(0, maxLength - 1).trimEnd()}…`; -} - -function isTerminal(state: SubagentStatus["state"]): boolean { - return isTerminalState(state); -} - -function normalizeActivity(event: RunnerActivity, at: string) { - if (typeof event === "string") return { type: event, summary: event, at }; - const type = typeof event.type === "string" ? event.type : "activity"; - const role = typeof event.role === "string" ? event.role : undefined; - const tool = toolFromActivity(event); - const phase = typeof event.phase === "string" ? event.phase : phaseFromType(type, event); - const text = textFromActivity(event); - const input = inputFromActivity(event); - const output = "output" in event ? event.output : "result" in event ? event.result : "partialResult" in event ? event.partialResult : undefined; - const error = typeof event.error === "string" ? event.error : undefined; - return { type, summary: summaryFor({ type, role, tool, phase, input, output, error }), at, role, tool, phase, text, input, output, error, payload: { ...event } }; -} - -function summarizeActivity(activity: ReturnType) { - const { type, summary, at, role, tool, phase } = activity; - return { type, summary, at, role, tool, phase }; -} - -function toolFromActivity(event: Record): string | undefined { - for (const key of ["tool", "toolName", "name"]) { - const value = event[key]; - if (typeof value === "string") return value; - } - return undefined; -} - -function phaseFromType(type: string, event: Record): string | undefined { - const assistantEvent = event.assistantMessageEvent; - if (assistantEvent && typeof assistantEvent === "object" && !Array.isArray(assistantEvent)) { - const assistantType = (assistantEvent as { type?: unknown }).type; - if (typeof assistantType === "string") return assistantType; - } - if (type.endsWith("_start")) return "started"; - if (type.endsWith("_started")) return "started"; - if (type.endsWith("_update")) return "update"; - if (type.endsWith("_delta")) return "delta"; - if (type.endsWith("_end")) return "completed"; - if (type.endsWith("_completed")) return "completed"; - if (type.endsWith("_failed")) return "failed"; - return undefined; -} - -function textFromActivity(event: Record): string | undefined { - for (const key of ["text", "body", "content", "delta"]) { - const value = event[key]; - if (typeof value === "string") return value; - } - const assistantEvent = event.assistantMessageEvent; - if (assistantEvent && typeof assistantEvent === "object" && !Array.isArray(assistantEvent)) { - for (const key of ["delta", "content"]) { - const value = (assistantEvent as Record)[key]; - if (typeof value === "string") return value; - } - } - return undefined; -} - -function inputFromActivity(event: Record): unknown { - if ("input" in event) return event.input; - if ("args" in event) return event.args; - return undefined; -} - -function summaryFor(activity: { type: string; role?: string; tool?: string; phase?: string; input?: unknown; output?: unknown; error?: string }): string { - if (activity.error) return `${activity.tool ?? activity.type} failed: ${activity.error}`; - if (activity.tool) return `${activity.tool}${inputHint(activity.input)}`; - if (activity.type.startsWith("message")) return `${activity.role ?? "assistant"} message${activity.phase ? ` ${activity.phase}` : ""}`; - return activity.type; -} - -function inputHint(input: unknown): string { - if (!input || typeof input !== "object" || Array.isArray(input)) return ""; - const path = (input as { path?: unknown }).path; - if (typeof path === "string" && path.trim()) return ` ${path.trim()}`; - const command = (input as { command?: unknown }).command; - if (typeof command === "string" && command.trim()) return ` ${truncateActivityHint(command.trim())}`; - return ""; -} - -function truncateActivityHint(value: string): string { - return value.length <= 80 ? value : `${value.slice(0, 79).trimEnd()}…`; -} diff --git a/modules/agents/pi/extensions/subagents/types.ts b/modules/agents/pi/extensions/subagents/types.ts deleted file mode 100644 index db36bb3..0000000 --- a/modules/agents/pi/extensions/subagents/types.ts +++ /dev/null @@ -1,123 +0,0 @@ -export type ContextMode = "independent" | "fork"; - -export const SUBAGENT_STATES = ["queued", "starting", "running", "settling", "completed", "failed", "cancelled", "timed_out", "orphaned"] as const; -export const SUBAGENT_TERMINAL_STATES = ["completed", "failed", "cancelled", "timed_out", "orphaned"] as const; - -export type SubagentState = (typeof SUBAGENT_STATES)[number]; - -export interface ToolProfile { - activeTools: string[] | null; -} - -export interface SpawnRequest { - prompt: string; - label?: string; - context?: ContextMode; - agent?: string; - model?: string; - thinking?: string; - tools?: string; - toolProfile?: ToolProfile; - agentBody?: string; - parentSessionFile?: string; -} - -export interface SpawnAccepted { - id: string; - label: string; - context: ContextMode; - tools: string; - state: SubagentState; - hint: string; -} - -export interface SubagentActivitySummary { - type: string; - summary: string; - at: string; - role?: string; - tool?: string; - phase?: string; -} - -export interface SubagentActivityEvent extends SubagentActivitySummary { - text?: string; - input?: unknown; - output?: unknown; - error?: string; - payload?: Record; -} - -export interface SubagentCurrentActivity extends SubagentActivitySummary {} - -export type RunnerActivity = string | Record; - -export interface SubagentStatus { - id: string; - label: string; - agent?: string; - adHoc: boolean; - context: ContextMode; - state: SubagentState; - cwd: string; - model?: string; - thinking?: string; - tools: string; - startedAt: string; - completedAt?: string; - elapsedMs: number; - lastEvent?: string; - lastEventAt?: string; - currentActivity?: SubagentCurrentActivity; - activityHistory: SubagentActivitySummary[]; - stopReason?: string; - resultAvailable: boolean; - childSession?: string; - error?: string; -} - -export interface SubagentResult { - id: string; - label: string; - state: SubagentState; - running: boolean; - resultAvailable: boolean; - result?: string; - error?: string; - completedAt?: string; - elapsedMs: number; -} - -export type SubagentWaitMode = "all" | "any"; - -export interface SubagentWaitResult { - ids: string[]; - mode: SubagentWaitMode; - ready: boolean; - results: SubagentResult[]; - pending: SubagentStatus[]; - timedOut: boolean; - elapsedMs: number; -} - -export interface ChildRecord { - status: SubagentStatus; - activityEvents: SubagentActivityEvent[]; - result?: string; -} - -export interface RunnerEvents { - accepted(childSession?: string): void; - running(event: RunnerActivity): void; - settling(): void; - completed(result: string, stopReason?: string): void; - failed(error: string): void; -} - -export interface ChildHandle { - cancel(): Promise; -} - -export interface ChildRunner { - start(id: string, request: SpawnRequest, cwd: string, events: RunnerEvents): Promise; -} diff --git a/modules/agents/pi/extensions/subagents/ui.test.ts b/modules/agents/pi/extensions/subagents/ui.test.ts deleted file mode 100644 index b659e64..0000000 --- a/modules/agents/pi/extensions/subagents/ui.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; -import type { SubagentState, SubagentStatus } from "./types.ts"; -import { renderInspector, renderSummary, widget } from "./ui.ts"; - -function status(overrides: Partial & { id: string; label: string; state: SubagentState }): SubagentStatus { - return { - adHoc: true, - context: "independent", - cwd: "/tmp", - elapsedMs: 0, - activityHistory: [], - resultAvailable: false, - startedAt: "2026-08-01T00:00:00.000Z", - tools: "inherit", - ...overrides, - }; -} - -test("compact monitor aggregates visible children by actionable lifecycle group", () => { - assert.deepEqual(renderSummary([]), []); - - assert.deepEqual( - renderSummary([ - status({ id: "queued", label: "Queued", state: "queued" }), - status({ id: "starting", label: "Starting", state: "starting" }), - status({ id: "running", label: "Running", state: "running" }), - status({ id: "settling", label: "Settling", state: "settling" }), - status({ id: "completed", label: "Completed", state: "completed", resultAvailable: true }), - status({ id: "failed", label: "Failed", state: "failed", error: "boom" }), - status({ id: "timed-out", label: "Timed out", state: "timed_out" }), - status({ id: "cancelled", label: "Cancelled", state: "cancelled" }), - ]), - ["subagents: queued 1 · running 2 · settling 1 · completed 1 · failed 1 · timed out 1 · cancelled 1"], - ); -}); - -test("expanded monitor shows concise current activity summaries instead of raw event types", () => { - const rendered = widget([ - status({ - id: "sg-reading", - label: "Audit guest enablement plan", - state: "running", - elapsedMs: 12_000, - lastEvent: "message_update", - currentActivity: { - type: "message_update", - summary: "read secret-notes.md", - at: "2026-08-01T00:00:12.000Z", - }, - }), - ], true)().render(240); - - assert.deepEqual(rendered, ["▶ running 12s Audit guest enablement plan last: read secret-notes.md"]); - assert.doesNotMatch(rendered.join("\n"), /message_update|private transcript body/u); -}); - -test("expanded monitor renders one truncated row per child with state, elapsed time, and activity marker", () => { - const lines = renderInspector([ - status({ - id: "sg-running", - label: "Audit unusually verbose guest enablement migration plan", - state: "running", - elapsedMs: 65_000, - lastEvent: "message_update", - }), - status({ - id: "sg-completed", - label: "Summarize review", - state: "completed", - elapsedMs: 3_600_000, - lastEvent: "completed", - resultAvailable: true, - }), - status({ id: "sg-failed", label: "Run risky test", state: "failed", elapsedMs: 2_000, error: "exit 1" }), - ]); - - assert.equal(lines.length, 3); - assert.match(lines[0], /^▶ running +1m05s +Audit unusually verbose guest enablement migration plan +last: message_update$/u); - assert.equal(lines[1], "✓ completed 1h00m00s Summarize review result: available"); - assert.equal(lines[2], "✗ failed 2s Run risky test error: exit 1"); - - const rendered = widget([ - status({ id: "sg-running", label: "Audit unusually verbose guest enablement migration plan", state: "running", elapsedMs: 65_000, lastEvent: "message_update" }), - ], true)().render(32); - - assert.deepEqual(rendered, ["▶ running 1m05s Audit unusual…"]); - assert.ok(rendered.every((line) => line.length <= 32)); -}); diff --git a/modules/agents/pi/extensions/subagents/ui.ts b/modules/agents/pi/extensions/subagents/ui.ts deleted file mode 100644 index 25907c4..0000000 --- a/modules/agents/pi/extensions/subagents/ui.ts +++ /dev/null @@ -1,81 +0,0 @@ -import type { SubagentState, SubagentStatus } from "./types.ts"; - -const COMPACT_GROUPS: Array<{ label: string; states: SubagentState[] }> = [ - { label: "queued", states: ["queued"] }, - { label: "running", states: ["starting", "running"] }, - { label: "settling", states: ["settling"] }, - { label: "completed", states: ["completed"] }, - { label: "failed", states: ["failed"] }, - { label: "timed out", states: ["timed_out"] }, - { label: "cancelled", states: ["cancelled"] }, - { label: "orphaned", states: ["orphaned"] }, -]; - -const STATE_PRESENTATION: Record = { - queued: { icon: "…", label: "queued" }, - starting: { icon: "◌", label: "starting" }, - running: { icon: "▶", label: "running" }, - settling: { icon: "◒", label: "settling" }, - completed: { icon: "✓", label: "completed" }, - failed: { icon: "✗", label: "failed" }, - cancelled: { icon: "■", label: "cancelled" }, - timed_out: { icon: "⏱", label: "timed out" }, - orphaned: { icon: "?", label: "orphaned" }, -}; - -export function renderSummary(statuses: SubagentStatus[]): string[] { - const groups = COMPACT_GROUPS.map((group) => ({ - label: group.label, - count: statuses.filter((status) => group.states.includes(status.state)).length, - })).filter((group) => group.count > 0); - - if (groups.length === 0) return []; - return [`subagents: ${groups.map((group) => `${group.label} ${group.count}`).join(" · ")}`]; -} - -export function renderInspector(statuses: SubagentStatus[]): string[] { - return statuses.map((status) => renderStatusRow(status)); -} - -export function widget(statuses: SubagentStatus[], expanded: boolean) { - return () => ({ - invalidate() {}, - render(width: number) { - return (expanded ? renderInspector(statuses) : renderSummary(statuses)).map((line) => truncateLine(line, width)); - }, - }); -} - -function renderStatusRow(status: SubagentStatus): string { - const presentation = STATE_PRESENTATION[status.state]; - const marker = statusMarker(status); - return `${presentation.icon} ${presentation.label.padEnd(9)} ${formatDuration(status.elapsedMs)} ${status.label}${marker ? ` ${marker}` : ""}`; -} - -function statusMarker(status: SubagentStatus): string | undefined { - if (status.error) return `error: ${status.error}`; - if (status.resultAvailable) return "result: available"; - if (status.currentActivity) return `last: ${status.currentActivity.summary}`; - if (status.lastEvent) return `last: ${status.lastEvent}`; - if (status.state === "queued") return "waiting"; - if (status.state === "settling") return "settling"; - return undefined; -} - -function formatDuration(elapsedMs: number): string { - const totalSeconds = Math.max(0, Math.round(elapsedMs / 1000)); - const hours = Math.floor(totalSeconds / 3600); - const minutes = Math.floor((totalSeconds % 3600) / 60); - const seconds = totalSeconds % 60; - - if (hours > 0) return `${hours}h${String(minutes).padStart(2, "0")}m${String(seconds).padStart(2, "0")}s`; - if (minutes > 0) return `${minutes}m${String(seconds).padStart(2, "0")}s`; - return `${seconds}s`; -} - -function truncateLine(line: string, width: number): string { - if (width <= 0) return ""; - if (line.length <= width) return line; - if (width === 1) return "…"; - return `${line.slice(0, width - 1)}…`; -} diff --git a/modules/agents/pi/pi.nix b/modules/agents/pi/pi.nix index 1fc552b..2a0d2c6 100644 --- a/modules/agents/pi/pi.nix +++ b/modules/agents/pi/pi.nix @@ -11,40 +11,6 @@ let cfg = config.modules.agents.pi; user = config.user.name; piDir = "${config.users.users.${user}.home}/.pi/agent"; - reservedToolProfiles = [ - "none" - "read-only" - "read-only-with-safe-bash" - "full-tools" - ]; - subagentsConfig = - lib.optionalAttrs (cfg.subagents.defaultContext != null) { - defaultContext = cfg.subagents.defaultContext; - } - // lib.optionalAttrs (cfg.subagents.defaultTools != null) { - defaultTools = cfg.subagents.defaultTools; - } - // lib.optionalAttrs (cfg.subagents.maxConcurrent != null) { - maxConcurrent = cfg.subagents.maxConcurrent; - } - // lib.optionalAttrs (cfg.subagents.recentTerminalTtlMs != null) { - recentTerminalTtlMs = cfg.subagents.recentTerminalTtlMs; - } - // lib.optionalAttrs ( - cfg.subagents.ui.enabled != null || cfg.subagents.ui.defaultExpanded != null - ) { - ui = - lib.optionalAttrs (cfg.subagents.ui.enabled != null) { - enabled = cfg.subagents.ui.enabled; - } - // lib.optionalAttrs (cfg.subagents.ui.defaultExpanded != null) { - defaultExpanded = cfg.subagents.ui.defaultExpanded; - }; - } - // lib.optionalAttrs (cfg.subagents.toolProfiles != { }) { - toolProfiles = cfg.subagents.toolProfiles; - }; - subagentsJson = (pkgs.formats.json { }).generate "pi-subagents.json" subagentsConfig; patchedPi = pkgs.pi-coding-agent.overrideAttrs (old: { patches = (old.patches or [ ]) ++ [ ./patches/pi-flex-spacer.patch @@ -76,109 +42,9 @@ in options.modules.agents.pi = { enable = lib.mkEnableOption '' Pi, a terminal coding agent, configured via home-manager''; - - subagents = { - defaultContext = lib.mkOption { - type = lib.types.nullOr (lib.types.enum [ - "independent" - "fork" - ]); - default = null; - description = '' - Default context mode for subagents. - Left null, the extension keeps its in-code default. - ''; - }; - - defaultTools = lib.mkOption { - type = lib.types.nullOr lib.types.str; - default = null; - example = "read-only-with-safe-bash"; - description = '' - Default tool profile for subagents. - Left null, the extension keeps its in-code default. - ''; - }; - - maxConcurrent = lib.mkOption { - type = lib.types.nullOr lib.types.ints.positive; - default = null; - example = 4; - description = '' - Maximum number of child processes allowed to run concurrently. - Left null, the extension keeps its in-code default. - ''; - }; - - recentTerminalTtlMs = lib.mkOption { - type = lib.types.nullOr lib.types.ints.unsigned; - default = null; - example = 600000; - description = '' - Milliseconds to retain terminal subagents in the recent work set. - Zero disables time-based retention. - Left null, the extension keeps its in-code default. - ''; - }; - - ui = { - enabled = lib.mkOption { - type = lib.types.nullOr lib.types.bool; - default = null; - description = '' - Whether the extension renders its built-in subagent monitor. - Left null, the extension keeps its in-code default. - ''; - }; - - defaultExpanded = lib.mkOption { - type = lib.types.nullOr lib.types.bool; - default = null; - description = '' - Whether the built-in subagent monitor starts expanded. - Left null, the extension keeps its in-code default. - ''; - }; - }; - - toolProfiles = lib.mkOption { - type = lib.types.attrsOf ( - lib.types.submodule { - options.activeTools = lib.mkOption { - type = lib.types.listOf lib.types.str; - description = "Pi tools made available to a child using this profile."; - }; - } - ); - default = { }; - example = { - review = { - activeTools = [ - "read" - "grep" - "find" - "ls" - ]; - }; - }; - description = '' - Custom named tool profiles for subagents. - The extension's reserved built-in profile names cannot be redefined. - ''; - }; - }; }; config = lib.mkIf cfg.enable { - assertions = [ - { - assertion = lib.intersectLists reservedToolProfiles ( - builtins.attrNames cfg.subagents.toolProfiles - ) == [ ]; - message = "modules.agents.pi.subagents.toolProfiles may not redefine the reserved profiles: ${lib.concatStringsSep ", " reservedToolProfiles}."; - } - ]; - home-manager.users.${user} = { programs.pi-coding-agent = { enable = true; @@ -209,13 +75,6 @@ in source = ./prompts; recursive = true; }; - } - // lib.optionalAttrs (subagentsConfig != { }) { - # Declaring any global override makes Nix the owner of the runtime file. - "${piDir}/subagents.json" = { - source = subagentsJson; - force = true; - }; }; }; };