diff --git a/docs/pi-web-extensions.md b/docs/pi-web-extensions.md index 99c335e..adc7083 100644 --- a/docs/pi-web-extensions.md +++ b/docs/pi-web-extensions.md @@ -188,6 +188,88 @@ Clear a Git panel tab by passing `undefined`: ctx.ui.web.setGitTab("github", undefined); ``` +## Settings API + +`ctx.ui.web.registerSettings(schema)` contributes a settings panel to pi-web's +settings drawer. Core pi-web owns storage, validation, and rendering; the +extension owns the schema and reacts to changes. Values are **global** (shared by +every session) while the schema registration is **per session**, and they persist +after the extension unloads. + +```ts +pi.on("session_start", async (_event, ctx) => { + await ctx.ui.web.registerSettings({ + id: "my-ext.prefs", // namespaced: . + title: "My extension", + schemaVersion: 1, + fields: [ + { key: "enabled", type: "toggle", label: "Enabled", default: true }, + { key: "model", type: "select", label: "Model", optionsSource: "models" }, + ], + onChange: (values, info) => applyPreferences(values, info.sessionId), + }); + + const { values } = await ctx.ui.web.getSettings("my-ext.prefs"); +}); +``` + +Field types are `toggle`, `text`, `textarea`, `number`, `select`, and `list` +(a repeater with `itemFields`). Constraints include `required`, `min`/`max`, +`minLength`/`maxLength`/`pattern`, `minItems`/`maxItems`, and +`uniqueCaseInsensitive`. Validation errors render inline and are announced to +screen readers. + +`select` options come from static `options`, from the live model registry with +`optionsSource: "models"`, or from another field with +`optionsFromField: "."`. When a top-level `select` references a +list column this way, pi-web renders it as a per-row "default" star on that list +instead of a separate dropdown. List rows carry a stable hidden `__id`, so +renaming a row keeps references intact. + +Storage notes: + +- Values are stored under `extensions[id]` in pi-web settings as + `{ schemaVersion, revision, values, backup? }` and are carried through + verbatim, so an unloaded extension never loses its configuration. +- Writes are validated against the live schema and use an optimistic `revision` + guard, so concurrent edits from two browsers cannot silently drop fields. +- Bump `schemaVersion` and supply `migrate(oldValues, oldVersion)` to upgrade + stored values. A failed migration falls back to defaults and keeps a one-slot + `backup`. +- Owners with stored values but no live registration render as a read-only + "data retained" card. Their stored values can still be reset (reset needs only + stored data, not a live schema); editing requires the schema. +- Every live registrant of an id is notified on change, each with its own + `onChange` and its own `info.sessionId`. The first registrant's descriptor is + canonical; a divergent schema for the same id is rejected. +- If a migration fails, the stored values fall back to defaults, the previous + values are kept in `backup`, and the schema is published with a + `migrationError` so the UI can surface it. + +## Referencing sessions from extension output + +Extension output can point at other sessions, and pi-web renders those as links. +This works the same way for custom messages and for tool results: put an explicit +reference LIST in `details`. + +```ts +pi.sendMessage({ + customType: "my-ext", + content: "Background job finished", + details: { sessions: [{ sessionId, name: "job runner", status: "ok" }] }, +}); +``` + +- Supported keys are `sessions`, `sessionRefs`, and `workers`; each entry is + `{ sessionId, name?, status? }`, where `status` may be `error` or `aborted` to + change the chip's glyph. +- A **bare** `details.sessionId` is treated as incidental metadata and renders no + link, so a tool that merely echoes the session it acted on stays quiet. Linking + is opt-in. +- Core caps rendering at 8 references per card, truncates labels, and requires + plausible session ids, because `details` is untrusted persisted input. No + extension or tool name is special-cased. + ## Example: GitHub PRs and issues tab The repo includes an opt-in GitHub extension example at [`examples/pi-web-extensions/github-repo-panel.ts`](../examples/pi-web-extensions/github-repo-panel.ts). It adds a **GitHub** tab to the built-in Git drawer for repositories with GitHub remotes. The extension uses the `gh` CLI to list and view pull requests and issues. @@ -247,3 +329,37 @@ cp examples/pi-web-extensions/git-footer.ts ~/.pi/web/extensions/git-footer.ts ``` Reload pi-web resources with `/reload`, or restart pi-web if you are adding the extension while sessions are already live. + +## Example: multi-agent session orchestration + +The repo includes a session-orchestration extension at +[`examples/pi-web-extensions/session-orchestrator.ts`](../examples/pi-web-extensions/session-orchestrator.ts). +It lets one session spawn, monitor, steer, and interrupt other sessions, turning +pi-web into a multi-agent workspace where each worker is a **normal, fully +visible session** in the sidebar rather than a hidden subagent. + +It registers five tools — `sessions_spawn`, `sessions_status`, `sessions_read`, +`sessions_prompt`, `sessions_abort` — and a zero-token background poller that +delivers a wakeup message when a worker goes idle, so the parent never polls. +Worker models are chosen from user-authored **categories** (name + "when to use" +prose + a model) configured through the Settings API above; the concrete model +mapping stays private to the config and the spawn tool resolves it fail-closed. + +pi-web renders the orchestration state generically: spawned sessions are +indented under their parent in the session drawer, a waiting indicator shows +while a session's workers run, wakeups render as notification cards, and both the +spawn tool card and wakeup card link to the worker session. + +Install the extension into a pi-web extension directory, and the companion skill +into a pi skills directory: + +```bash +cp examples/pi-web-extensions/session-orchestrator.ts .pi/web/extensions/session-orchestrator.ts +cp -r examples/pi-web-skills/session-orchestration ~/.pi/agent/skills/session-orchestration +``` + +The skill at +[`examples/pi-web-skills/session-orchestration/SKILL.md`](../examples/pi-web-skills/session-orchestration/SKILL.md) +teaches the delegation loop: what to delegate, how to write self-contained worker +tasks, how to pick a category, and why ending your turn while workers run is +correct. diff --git a/examples/pi-web-extensions/git-footer.ts b/examples/pi-web-extensions/git-footer.ts index 54ab988..fc3f709 100644 --- a/examples/pi-web-extensions/git-footer.ts +++ b/examples/pi-web-extensions/git-footer.ts @@ -40,7 +40,6 @@ async function runGit(args: string[], cwd: string): Promise { const { stdout } = await execFileAsync("git", ["--no-optional-locks", ...args], { cwd, encoding: "utf8", - stdio: ["ignore", "pipe", "ignore"], timeout: GIT_TIMEOUT_MS, killSignal: "SIGKILL", }); diff --git a/examples/pi-web-extensions/recap.ts b/examples/pi-web-extensions/recap.ts index c1639ac..130970e 100644 --- a/examples/pi-web-extensions/recap.ts +++ b/examples/pi-web-extensions/recap.ts @@ -1,4 +1,5 @@ import { generateSummary } from "@earendil-works/pi-coding-agent"; +import { buildSessionContext } from "@earendil-works/pi-coding-agent"; import type { PiWebExtensionAPI, PiWebExtensionContext } from "@ashwin-pc/pi-web/extensions"; const RECAP_INSTRUCTIONS = [ @@ -13,9 +14,10 @@ const RECAP_INSTRUCTIONS = [ async function buildRecap(ctx: PiWebExtensionContext) { const model = ctx.model; if (!model) throw new Error("No model is configured for this session"); - const messages = ctx.sessionManager.buildSessionContext().messages; + const messages = buildSessionContext(ctx.sessionManager.getBranch()).messages; if (!messages.length) throw new Error("This session has no messages to recap yet"); const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model); + if (!auth.ok) throw new Error(auth.error); const markdown = await generateSummary(messages, model, 4096, auth.apiKey, auth.headers, ctx.signal, RECAP_INSTRUCTIONS); return { markdown: markdown.trim() }; } diff --git a/examples/pi-web-extensions/session-orchestrator.ts b/examples/pi-web-extensions/session-orchestrator.ts new file mode 100644 index 0000000..22c7a20 --- /dev/null +++ b/examples/pi-web-extensions/session-orchestrator.ts @@ -0,0 +1,1082 @@ +/** + * session-orchestrator — experimental pi-web extension + * + * Gives every pi-web session the same orchestration verbs a human has in the + * UI: spawn sibling sessions, check on them, read their transcripts, steer or + * interrupt them, and abort them. Workers are ordinary first-class pi-web + * sessions (full history, visible in the sidebar, resumable). + * + * Wakeups: after spawning/prompting a worker, the parent does NOT block. A + * background watcher (plain JS polling — zero tokens) injects a user message + * into the parent session when a worker goes idle, which starts a new parent + * turn. The parent can end its turn and "sleep" while workers run. + * + * Everything goes through the same local HTTP API the web UI uses. + * + * Reversible install: this lives at .pi/web/extensions/session-orchestrator.ts — + * delete that file (and ~/.pi/agent/skills/session-orchestration/) to remove + * the feature entirely. No AGENTS.md or server changes. + */ + +import { Type } from "typebox"; +import type { PiWebExtensionAPI, PiWebExtensionContext, PiWebSettingsSchema } from "@ashwin-pc/pi-web/extensions"; + +const WORKER_MARKER = "[pi-web orchestrated worker]"; +const EXT_VERSION = "v9"; +const WAKEUP_CUSTOM_TYPE = "session-orchestrator"; +// Durable watch ledger: appended to the parent's session file so a freshly +// re-materialized parent (server restart, /reload, idle disposal) can re-arm +// its watchers or deliver catch-up wakeups. Resolved = wakeup was DELIVERED. +const WATCH_ENTRY = "orchestrator-watch"; +const RESOLVED_ENTRY = "orchestrator-watch-resolved"; +const MAX_WORKERS = 4; +const POLL_MS = 2500; +const WAKEUP_SUMMARY_CHARS = 2000; + +// Generic extension-settings schema: user-authored worker model categories. +// The `description` prose IS the routing guidance the orchestrator reads; the +// concrete model stays private to config (never shown to the LLM). Empty config +// is a virtual, unwritten "Default" resolved live to the worker's own model. +const SETTINGS_ID = "session-orchestrator.workerModelCategories"; +const SETTINGS_SCHEMA: PiWebSettingsSchema = { + id: SETTINGS_ID, + title: "Worker model categories", + schemaVersion: 1, + fields: [ + { + key: "categories", + type: "list", + label: "Categories", + description: + 'Named model tiers the orchestrator can spawn workers on. Write the "When to use" prose as absolute guidance — it is the routing policy.', + minItems: 0, + maxItems: 4, + itemFields: [ + { key: "name", type: "text", label: "Name", required: true, maxLength: 24, uniqueCaseInsensitive: true }, + { key: "model", type: "select", label: "Model", optionsSource: "models", required: true }, + { key: "description", type: "textarea", label: 'When to use', maxLength: 400 }, + ], + }, + { + key: "defaultCategory", + type: "select", + label: "Default category", + description: "Used when a spawn omits an explicit category.", + optionsFromField: "categories.name", + }, + ], +}; + +const PORT = Number(process.env.PORT || 8787); +const TOKEN = process.env.PI_WEB_TOKEN || ""; +const BASE = `http://127.0.0.1:${PORT}`; + +// --------------------------------------------------------------------------- +// Global helpers: token parsing, resolution +// --------------------------------------------------------------------------- + +/** Parse ":" into { provider, id }. Split on FIRST colon only. */ +export function parseToken(token: string): { provider: string; id: string } | null { + const idx = token.indexOf(":"); + if (idx < 0) return null; + return { provider: token.slice(0, idx), id: token.slice(idx + 1) }; +} + +/** Determine if normalized id base matches (Bedrock inference-profile case). */ +export function normalizeBedrockId(id: string): string { + // Extract base (e.g. "us.amazon.nova-2-lite-v1" from "us.amazon.nova-2-lite-v1:0") + return id.split(":")[0]; +} + +/** Resolution order per Amendment 6. Returns { match, substituted }. */ +export function resolveModel( + canonicalToken: string, + registryModels: any[], + parentRegionPrefix: string, +): { match: any; substituted: boolean } | null { + const canonical = parseToken(canonicalToken); + if (!canonical) return null; + + const { provider, id } = canonical; + + // 1. Exact {provider, id} match. + const exact = registryModels.find((m: any) => m.provider === provider && m.id === id); + if (exact) return { match: exact, substituted: false }; + + // 2. For an unprefixed configured id only, add the parent's region prefix + // (Bedrock inference-profile case). An explicit configured region is a + // residency choice and must never be silently replaced with another one. + const baseId = normalizeBedrockId(id); + if (parentRegionPrefix && !/^(us|eu|au|apac|global)\./.test(baseId)) { + const withPrefix = parentRegionPrefix + baseId; + const candidate = registryModels.find( + (m: any) => m.provider === provider && normalizeBedrockId(m.id) === normalizeBedrockId(withPrefix), + ); + if (candidate) return { match: candidate, substituted: true }; + } + + // 3. Else failure. + return null; +} + +// --------------------------------------------------------------------------- +// Small HTTP client for the pi-web API (same API the browser UI uses) +// --------------------------------------------------------------------------- + +class ApiError extends Error { + constructor(message: string, readonly status: number) { + super(message); + this.name = "ApiError"; + } +} + +async function api(method: string, path: string, body?: unknown): Promise { + const res = await fetch(`${BASE}${path}`, { + method, + headers: { + ...(TOKEN ? { Authorization: `Bearer ${TOKEN}` } : {}), + ...(body !== undefined ? { "Content-Type": "application/json" } : {}), + }, + ...(body !== undefined ? { body: JSON.stringify(body) } : {}), + signal: AbortSignal.timeout(20_000), + }); + const json = await res.json().catch(() => ({})); + if (!res.ok || json?.ok === false) { + throw new ApiError(`${method} ${path} failed (${res.status}): ${json?.error || "unknown error"}`, res.status); + } + return json; +} + +function trunc(value: unknown, max: number): string { + const text = String(value ?? "").trim(); + if (text.length <= max) return text; + return `${text.slice(0, max - 1)}…`; +} + +function shortId(id: string): string { + return id.length > 8 ? id.slice(-8) : id; +} + +// --------------------------------------------------------------------------- +// Transcript helpers (uses /api/messages simplified message shape) +// --------------------------------------------------------------------------- + +async function fetchMessages(sessionId: string): Promise { + const json = await api("GET", `/api/messages?sessionId=${encodeURIComponent(sessionId)}`); + return Array.isArray(json.messages) ? json.messages : []; +} + +function lastAssistantText(messages: any[]): { text: string; isError: boolean } { + for (let i = messages.length - 1; i >= 0; i--) { + const m = messages[i]; + if (m?.role === "assistant" && typeof m.text === "string" && m.text.trim()) { + return { text: m.text.trim(), isError: Boolean(m.isError) }; + } + } + return { text: "", isError: false }; +} + +function shortArgs(args: Record | undefined): string { + if (!args || typeof args !== "object") return ""; + const parts: string[] = []; + for (const [key, value] of Object.entries(args)) { + parts.push(`${key}: ${trunc(typeof value === "string" ? value : JSON.stringify(value), 60)}`); + if (parts.join(", ").length > 140) break; + } + return trunc(parts.join(", "), 160); +} + +function formatTranscript(messages: any[], tail: number): string { + const slice = messages.slice(-tail); + const lines: string[] = []; + if (messages.length > slice.length) lines.push(`… (${messages.length - slice.length} earlier entries omitted; increase tail to see more)`); + for (const m of slice) { + if (!m || typeof m !== "object") continue; + if (m.role === "user") { + lines.push(`[user] ${trunc(m.text, 400)}`); + } else if (m.role === "assistant") { + if (m.text) lines.push(`[assistant] ${trunc(m.text, 700)}`); + for (const call of m.toolCalls || []) { + lines.push(` → ${call.toolName}(${shortArgs(call.args)})`); + } + } else if (m.role === "toolResult") { + lines.push(` ${m.isError ? "✗" : "✓"} ${m.toolName}: ${trunc(m.text, 200)}`); + } else if (m.role === "bashExecution") { + lines.push(` $ ${trunc(m.command, 160)}`); + if (m.output) lines.push(` ${trunc(m.output, 200)}`); + } + } + return lines.join("\n") || "(no messages)"; +} + +// --------------------------------------------------------------------------- +// Extension +// --------------------------------------------------------------------------- + +export default function sessionOrchestrator(pi: PiWebExtensionAPI) { + type Watched = { + id: string; + name: string; + categoryName: string; + sawRunning: boolean; + idlePolls: number; + errorPolls: number; + aborted: boolean; + }; + + const watched = new Map(); + // The spawn cap applies only to workers created by sessions_spawn. Sessions + // added to the watcher by sessions_prompt do not consume spawn capacity. + const spawnedWorkers = new Set(); + let reservedSpawnSlots = 0; + let cachedConfig: { categories: any[]; defaultCategory: string } = { categories: [], defaultCategory: "" }; + let timer: ReturnType | undefined; + let pollInFlight = false; + let selfSessionId = ""; + let disposed = false; + let generation = 0; + + function isActive(expectedGeneration = generation): boolean { + return !disposed && generation === expectedGeneration; + } + + // Capture our own session id whenever context is available; used to route + // wakeups through /api/prompt (the same battle-tested path the web UI uses + // for steering), which works both when the parent is idle and mid-turn. + function captureSelf(ctx: PiWebExtensionContext) { + const id = ownSessionId(ctx); + if (id && id !== "unknown") selfSessionId = id; + } + + function isWorkerSession(ctx: PiWebExtensionContext): boolean { + try { + const entries = ctx.sessionManager?.getBranch?.() || []; + for (const entry of entries) { + if (entry?.type !== "message") continue; + const message = entry.message; + if (message?.role !== "user") continue; + const content = message.content; + const text = typeof content === "string" + ? content + : Array.isArray(content) + ? content.map((part: any) => (part?.type === "text" ? part.text : "")).join(" ") + : ""; + return text.includes(WORKER_MARKER); + } + } catch { + // If we cannot tell, err on the side of allowing. + } + return false; + } + + function ownSessionId(ctx: PiWebExtensionContext): string { + try { + return String(ctx.sessionManager?.getSessionId?.() ?? "unknown"); + } catch { + return "unknown"; + } + } + + function ownCwd(ctx: PiWebExtensionContext): string | undefined { + try { + const cwd = ctx.sessionManager?.getCwd?.(); + return typeof cwd === "string" && cwd.trim() ? cwd : undefined; + } catch { + return undefined; + } + } + + function ensureTimer() { + if (!isActive() || timer || watched.size === 0) return; + const timerGeneration = generation; + timer = setInterval(() => { + if (isActive(timerGeneration)) void poll(timerGeneration); + }, POLL_MS); + } + + function stopTimerIfIdle() { + if (timer && watched.size === 0) { + clearInterval(timer); + timer = undefined; + } + } + + function watch(id: string, name: string, categoryName = "Unknown") { + if (!isActive()) return; + watched.set(id, { id, name, categoryName, sawRunning: false, idlePolls: 0, errorPolls: 0, aborted: false }); + ensureTimer(); + } + + function unwatch(id: string) { + watched.delete(id); + spawnedWorkers.delete(id); + stopTimerIfIdle(); + } + + function appendLedger(customType: string, childId: string, name?: string, categoryName?: string, spawned?: boolean) { + try { + pi.appendEntry(customType, { childId, ...(name ? { name } : {}), ...(categoryName ? { categoryName } : {}), ...(spawned !== undefined ? { spawned } : {}) }); + } catch (error) { + console.error(`[session-orchestrator] could not append ${customType} entry: ${error instanceof Error ? error.message : error}`); + } + } + + function markChildRead(childId: string) { + api("POST", "/api/session-ui-state/read", { sessionId: childId }) + .catch((error) => console.error(`[session-orchestrator] could not mark child read: ${error instanceof Error ? error.message : error}`)); + } + + async function cleanupCreatedSession(sessionId: string): Promise { + try { + await api("POST", "/api/sessions/delete", { sessionId }); + return true; + } catch (error) { + console.error(`[session-orchestrator] could not clean up worker ${sessionId}: ${error instanceof Error ? error.message : error}`); + return false; + } + } + + function cleanupReport(sessionId: string, cleanedUp: boolean): string { + return cleanedUp + ? `Session ${sessionId} was created but then deleted (no orphan).` + : `Session ${sessionId} could not be deleted and may remain — please remove it.`; + } + + function outstandingWatches(ctx: any): Map { + const watches = new Map(); + try { + for (const entry of ctx.sessionManager?.getBranch?.() || []) { + if (entry?.type !== "custom") continue; + const childId = typeof entry.data?.childId === "string" ? entry.data.childId : ""; + if (!childId) continue; + if (entry.customType === WATCH_ENTRY) { + watches.set(childId, { + name: typeof entry.data?.name === "string" ? entry.data.name : childId.slice(-8), + categoryName: typeof entry.data?.categoryName === "string" ? entry.data.categoryName : "Unknown", + // Legacy entries did not distinguish spawn from prompt watches; + // count them conservatively until a new typed entry is written. + spawned: typeof entry.data?.spawned === "boolean" ? entry.data.spawned : true, + }); + } + else if (entry.customType === RESOLVED_ENTRY) watches.delete(childId); + } + } catch (error) { + console.error(`[session-orchestrator] could not read watch ledger: ${error instanceof Error ? error.message : error}`); + } + return watches; + } + + // ========================================================================= + // Tool builder and settings registration + // ========================================================================= + + function buildSpawnTool(ctx: PiWebExtensionContext): any { + let description = [ + "Spawn a new pi-web worker session and give it a task. The worker runs in the background as a normal, fully visible pi-web session.", + "Returns immediately with the worker's session id. When the worker goes idle you will receive a '🔔 [orchestrator]' user message containing its final output — do NOT poll for completion; do other, non-overlapping work or end your turn and wait.", + "Do not redo what you just delegated: after spawning, use judgement about whether you need to do work yourself at all. Doing it yourself is right when you need the answer to plan the next step, when the worker may fail or is slow and the check is cheap, or when there is genuinely complementary work (design, drafting, scaffolding, verifying a worker's claims). Otherwise end your turn — idling costs nothing and keeps the noisy exploration out of your context.", + "Write the task so it is self-contained (the worker has none of your context): include relevant file paths, constraints, and what evidence to report back (diffs, test output, findings with file:line).", + ]; + + let categoryDescription = ""; + let hasConfig = false; + { + const values = cachedConfig || { categories: [], defaultCategory: "" }; + const categories = Array.isArray(values.categories) ? values.categories : []; + const defaultCategory = values.defaultCategory || ""; + if (categories.length > 0) { + hasConfig = true; + const categoryLines: string[] = []; + for (const cat of categories) { + if (cat && typeof cat === "object") { + const name = String(cat.name || "").trim(); + const desc = String(cat.description || "").trim(); + const isDefault = name === defaultCategory ? " (default)" : ""; + const line = desc ? `• **${name}**${isDefault}: ${desc}` : `• **${name}**${isDefault}`; + categoryLines.push(line); + } + } + if (categoryLines.length > 0) { + categoryDescription = `Configured categories:\n${categoryLines.join("\n")}`; + } + } + } + + if (!hasConfig) { + categoryDescription = "No categories are configured. The worker will use the session's default model. Configure categories in extension settings to choose specific models."; + } + + description.push(""); + description.push(categoryDescription); + + const parameterDescription = + "Category name (from settings) that selects the worker's model. Defaults to the configured default category, or the session default if no config exists. Unknown category → error, nothing created; valid names are listed in the tool description."; + + return { + name: "sessions_spawn", + label: "Spawn worker session", + description: description.join(" "), + promptSnippet: "Spawn a background worker session for a delegated task; completion arrives as a wakeup message", + promptGuidelines: [ + "Use sessions_spawn to delegate noisy or parallelizable work (exploration, running tests, an isolated implementation step) to a worker session instead of doing it inline. After spawning, either do complementary work that does not duplicate what you delegated, or end your turn — a wakeup message arrives when the worker is done. Use judgement: re-doing a worker's task yourself is only worth it when you need the result to proceed, or the check is cheap and the worker may be wrong.", + ], + parameters: Type.Object({ + name: Type.String({ description: "Short human-readable worker name, e.g. 'scout: auth flow' or 'fix lint'" }), + task: Type.String({ description: "Self-contained task prompt for the worker, including what to report back" }), + cwd: Type.Optional(Type.String({ description: "Working directory for the worker (defaults to this session's cwd)" })), + category: Type.Optional(Type.String({ description: parameterDescription })), + }), + execute: async (_toolCallId: string, params: any, _signal: any, _onUpdate: any, ctx: PiWebExtensionContext) => { + if (isWorkerSession(ctx)) { + return { + content: [ + { + type: "text", + text: "Refused: this session is itself an orchestrated worker (depth cap is 1). Report back to your parent instead of spawning sub-workers.", + }, + ], + isError: true, + details: {}, + }; + } + const spawnSlotsInUse = spawnedWorkers.size + reservedSpawnSlots; + if (spawnSlotsInUse >= MAX_WORKERS) { + return { + content: [{ type: "text", text: `Refused: already running or starting ${spawnSlotsInUse} spawned workers (spawn cap ${MAX_WORKERS}; sessions watched after sessions_prompt do not count). Wait for a wakeup or abort one first.` }], + isError: true, + details: {}, + }; + } + + // Reserve synchronously, before category/settings/API awaits, so + // concurrent tool calls cannot all pass the cap check. + reservedSpawnSlots += 1; + try { + const parentId = ownSessionId(ctx); + const parentCwd = ownCwd(ctx); + + // ==================================================================== + // Phase 1: Resolve category → canonical {provider, id} from config + // ==================================================================== + + let resolvedToken: { provider: string; id: string } | null = null; + const explicitCategory = typeof params.category === "string" && params.category.trim().length > 0; + let categoryName = explicitCategory ? params.category.trim() : ""; + let validCategories: string[] = []; + + try { + const web = ctx?.ui?.web; + const settings = web?.getSettings ? await web.getSettings(SETTINGS_ID) : null; + if (settings && typeof settings === "object") { + const values = settings.values || {}; + cachedConfig = { categories: Array.isArray(values.categories) ? values.categories : [], defaultCategory: String(values.defaultCategory || "") }; + const categories = Array.isArray(values.categories) ? values.categories : []; + const defaultCategory = String(values.defaultCategory || ""); + + for (const cat of categories) { + if (cat && typeof cat === "object" && cat.name) validCategories.push(String(cat.name)); + } + + // An explicit category must resolve even when the configured list + // is empty. Only an omitted category gets the virtual Default. + if (categories.length === 0) { + if (explicitCategory) { + return { + content: [{ type: "text", text: `ERROR: category "${categoryName}" not found. Valid categories: (none configured)` }], + isError: true, + details: { validCategories, categoryName }, + }; + } + categoryName = "Default"; + } else { + if (!categoryName) categoryName = defaultCategory; + if (!categoryName) { + return { + content: [{ type: "text", text: `ERROR: category omitted and no default is configured. Valid categories: ${validCategories.join(", ") || "(none configured)"}. Set a default in extension settings or pass an explicit category name.` }], + isError: true, + details: { validCategories }, + }; + } + + const matching = categories.find( + (cat: any) => cat && typeof cat === "object" && cat.name && String(cat.name).toLowerCase() === String(categoryName).toLowerCase(), + ); + if (!matching || !matching.model) { + return { + content: [{ type: "text", text: `ERROR: category "${categoryName}" not found or has no model configured. Valid categories: ${validCategories.join(", ") || "(none configured)"}` }], + isError: true, + details: { validCategories, categoryName }, + }; + } + + resolvedToken = parseToken(String(matching.model)); + if (!resolvedToken) { + return { + content: [{ type: "text", text: `ERROR: the model configured for category "${categoryName}" is malformed. Check extension settings. Valid categories: ${validCategories.join(", ") || "(none configured)"}` }], + isError: true, + details: { validCategories }, + }; + } + } + } else if (explicitCategory) { + return { + content: [{ type: "text", text: `ERROR: category "${categoryName}" cannot be resolved because category settings are unavailable. Valid categories: (none configured)` }], + isError: true, + details: { validCategories, categoryName }, + }; + } else { + categoryName = "Default"; + } + } catch (error) { + if (explicitCategory) { + return { + content: [{ type: "text", text: `ERROR: failed to read category config: ${error instanceof Error ? error.message : error}. Category "${categoryName}" cannot be resolved; valid categories are unavailable.` }], + isError: true, + details: { validCategories, categoryName }, + }; + } + // With no explicit category, an unreadable settings API may safely + // fall back to the new session's default model. + categoryName = "Default"; + } + + // ==================================================================== + // Phase 2: Create unprompted session + // ==================================================================== + + let sessionId = ""; + try { + const created = await api("POST", "/api/new-chat", { + cwd: params.cwd || parentCwd, + ...(parentId && parentId !== "unknown" ? { origin: { sessionId: parentId, kind: "spawn" } } : {}), + }); + sessionId = String(created.sessionId || ""); + if (!sessionId) throw new Error("new-chat did not return a sessionId"); + } catch (error) { + return { + content: [ + { + type: "text", + text: `ERROR: failed to create worker session: ${error instanceof Error ? error.message : error}`, + }, + ], + isError: true, + details: {}, + }; + } + + const displayName = params.name; + await api("POST", "/api/session/name", { sessionId, name: displayName }).catch(() => {}); + + let regionSubstituted = false; + + // ==================================================================== + // Phase 3: Resolve against worker's registry (if config provided) + // ==================================================================== + + if (resolvedToken) { + try { + const { models } = await api("GET", `/api/models?sessionId=${encodeURIComponent(sessionId)}`); + const registryModels = Array.isArray(models) ? models : []; + + // Get parent region prefix for fallback. + let parentRegionPrefix = ""; + try { + const parentState = await api("GET", `/api/state?sessionId=${encodeURIComponent(parentId)}`); + parentRegionPrefix = String(parentState?.model?.id || "").match(/^(us|eu|au|apac|global)\./) ?.[0] || ""; + } catch { /* best effort */ } + + const resolution = resolveModel( + `${resolvedToken.provider}:${resolvedToken.id}`, + registryModels, + parentRegionPrefix, + ); + + if (!resolution) { + // Resolution failed → delete session and error. + const cleanedUp = await cleanupCreatedSession(sessionId); + return { + content: [ + { + type: "text", + text: `ERROR: the model configured for category "${categoryName}" is not available in this worker's registry. Valid categories: ${validCategories.join(", ") || "(none configured)"}. ${cleanupReport(sessionId, cleanedUp)}`, + }, + ], + isError: true, + details: { sessionId, validCategories, categoryName, cleanedUp }, + }; + } + + const { match, substituted } = resolution; + + // ================================================================ + // Phase 4: Set model on worker + // ================================================================ + + await api("POST", "/api/model", { sessionId, provider: match.provider, id: match.id }); + regionSubstituted = substituted; + } catch (error) { + // Model resolution error → delete session and error. Keep the + // configured provider/id private from the model-facing result. + console.error(`[session-orchestrator] failed to configure worker category "${categoryName}": ${error instanceof Error ? error.message : error}`); + const cleanedUp = await cleanupCreatedSession(sessionId); + return { + content: [ + { + type: "text", + text: `ERROR: failed to configure the model for category "${categoryName}". Valid categories: ${validCategories.join(", ") || "(none configured)"}. ${cleanupReport(sessionId, cleanedUp)}`, + }, + ], + isError: true, + details: { sessionId, validCategories, categoryName, cleanedUp }, + }; + } + } + + // ==================================================================== + // Phase 5: Dispatch task + // ==================================================================== + + const prompt = [ + `${WORKER_MARKER} You are a worker session spawned by session ${parentId} to do one task. Work autonomously; the spawner cannot answer questions mid-task, so make reasonable assumptions and note them.`, + `When finished, end your final message with a concise report: what you did/found, files touched (with paths), and how you verified it. Do not spawn other sessions.`, + ``, + `TASK: ${params.task}`, + ].join("\n"); + try { + await api("POST", "/api/prompt", { sessionId, message: prompt }); + } catch (error) { + console.error(`[session-orchestrator] failed to dispatch worker ${sessionId}: ${error instanceof Error ? error.message : error}`); + const cleanedUp = await cleanupCreatedSession(sessionId); + return { + content: [{ type: "text", text: `ERROR: failed to dispatch the task to worker session ${sessionId}; no work was dispatched. ${cleanupReport(sessionId, cleanedUp)}` }], + isError: true, + details: { sessionId, categoryName, cleanedUp }, + }; + } + + spawnedWorkers.add(sessionId); + watch(sessionId, params.name, categoryName || "Default"); + appendLedger(WATCH_ENTRY, sessionId, params.name, categoryName || "Default", true); + + return { + content: [ + { + type: "text", + text: `Spawned worker "${params.name}" (session ${sessionId}, category "${categoryName || "Default"}"). It is running in the background as a normal pi-web session named "${displayName}".${regionSubstituted ? " A region prefix was substituted." : ""} You'll receive a 🔔 wakeup message when it goes idle — do not poll. Continue other work, spawn more workers, or end your turn to wait.`, + }, + ], + details: { + sessionId, + name: params.name, + sessions: [{ sessionId, name: params.name }], + cwd: params.cwd || parentCwd, + categoryName: categoryName || "Default", + regionSubstituted, + } as Record, + }; + } finally { + reservedSpawnSlots -= 1; + } + }, + }; + } + + async function registerTools(ctx: PiWebExtensionContext) { + try { + const web = ctx?.ui?.web; + if (web?.getSettings) { + const s = await web.getSettings(SETTINGS_ID); + if (s && typeof s === "object" && s.values) { + cachedConfig = { + categories: Array.isArray(s.values.categories) ? s.values.categories : [], + defaultCategory: String(s.values.defaultCategory || ""), + }; + } + } + } catch {} + pi.registerTool(buildSpawnTool(ctx)); + } + + pi.on("session_start", async (_event: unknown, ctx: PiWebExtensionContext) => { + captureSelf(ctx); + void rearmFromLedger(ctx); + try { + const web = ctx?.ui?.web; + if (web?.registerSettings) { + await registerTools(ctx); + const result = await web.registerSettings({ + ...SETTINGS_SCHEMA, + onChange: () => { + // Category config changed: rebuild tool to update description. + void registerTools(ctx); + }, + }); + if (result && result.registered === false && result.error) { + console.warn(`[session-orchestrator ${EXT_VERSION}] settings registration rejected: ${result.error}`); + } + } + } catch (error) { + console.warn(`[session-orchestrator ${EXT_VERSION}] settings registration failed:`, error); + } + }); + pi.on("turn_start", (_event: any, ctx: any) => captureSelf(ctx)); + + /** + * On session (re)load: resume watching children that are still running, and + * deliver catch-up wakeups for children that finished while this session was + * not in memory (server restart, /reload, idle disposal). + */ + async function rearmFromLedger(ctx: any, expectedGeneration = generation) { + if (!isActive(expectedGeneration)) return; + const outstanding = outstandingWatches(ctx); + for (const id of watched.keys()) outstanding.delete(id); + if (outstanding.size === 0) return; + for (const [id, worker] of outstanding) { + if (worker.spawned) spawnedWorkers.add(id); + } + + const finished: { id: string; name: string; categoryName: string; summary: { text: string; isError: boolean } }[] = []; + for (const [childId, worker] of outstanding) { + if (!isActive(expectedGeneration)) return; + try { + const state = await api("GET", `/api/state?sessionId=${encodeURIComponent(childId)}`); + if (!isActive(expectedGeneration)) return; + const running = Boolean(state?.runtime?.isRunning) || Number(state?.runtime?.pendingMessageCount || 0) > 0; + if (running) { + watch(childId, worker.name, worker.categoryName); + continue; + } + let summary = { text: "", isError: false }; + try { + const messages = await fetchMessages(childId); + if (!isActive(expectedGeneration)) return; + summary = lastAssistantText(messages); + } catch { + if (!isActive(expectedGeneration)) return; + // Transcript fetch is best effort. + } + finished.push({ id: childId, name: worker.name, categoryName: worker.categoryName, summary }); + } catch (error) { + if (!isActive(expectedGeneration)) return; + if (error instanceof ApiError && error.status === 404) { + // A definitive not-found means the child is genuinely gone. + spawnedWorkers.delete(childId); + appendLedger(RESOLVED_ENTRY, childId); + } else { + // Timeouts, network failures, and 5xx responses are transient. Hand + // the outstanding ledger entry to the normal paced poller, which + // retries and eventually emits its existing "lost track" wakeup. + watch(childId, worker.name, worker.categoryName); + } + } + } + if (!isActive(expectedGeneration) || finished.length === 0) return; + + const details = { + kind: "wakeup", + catchUp: true, + workers: finished.map((f) => ({ sessionId: f.id, name: f.name, status: f.summary.isError ? "error" : "idle" })), + stillRunning: Array.from(watched.values()).map((o) => ({ sessionId: o.id, name: o.name })), + }; + const sections = finished.map((f) => [ + `Worker "${f.name}" (session ${f.id}) finished while this session was offline.`, + f.summary.text + ? `${f.summary.isError ? "⚠️ Its last turn ENDED WITH AN ERROR (task likely incomplete):\n" : "Final message:\n"}${trunc(f.summary.text, WAKEUP_SUMMARY_CHARS)}` + : `(no final message captured)`, + ].join("\n")); + const ok = await deliverWakeup([ + `🔔 [orchestrator] Catch-up: ${finished.length === 1 ? "a worker" : `${finished.length} workers`} finished while this session was not loaded.`, + ``, + sections.join("\n\n---\n\n"), + ``, + `You can inspect details with sessions_read, follow up or redirect with sessions_prompt, or continue with your task.`, + ].join("\n"), details, expectedGeneration); + if (!isActive(expectedGeneration)) return; + if (ok) { + for (const f of finished) { + spawnedWorkers.delete(f.id); + appendLedger(RESOLVED_ENTRY, f.id); + markChildRead(f.id); + } + } else { + // Keep completed workers live in the watcher until a later poll can + // successfully deliver their wakeup. + for (const f of finished) watch(f.id, f.name, f.categoryName); + } + } + + /** Deliver a wakeup to this session. Returns true only if delivery succeeded. */ + async function deliverWakeup(text: string, details?: Record, expectedGeneration = generation): Promise { + if (!isActive(expectedGeneration)) return false; + // Preferred: pi custom message — typed, persisted, reaches the LLM as a + // user message, and carries structured details for the pi-web UI card. + try { + await pi.sendMessage( + { customType: WAKEUP_CUSTOM_TYPE, content: text, display: true, details }, + { triggerTurn: true, deliverAs: "steer" }, + ); + if (!isActive(expectedGeneration)) return false; + return true; + } catch (error) { + if (!isActive(expectedGeneration)) return false; + const message = error instanceof Error ? error.message : String(error); + if (/stale/i.test(message)) { + // This extension instance was replaced (e.g. /reload). The new + // instance re-arms from the persisted watch ledger and owns delivery + // now — falling back here would double-deliver. Go silent and stop. + console.error("[session-orchestrator] instance is stale after reload; stopping watcher (ledger hands off to the new instance)"); + disposed = true; + generation += 1; + if (timer) clearInterval(timer); + timer = undefined; + watched.clear(); + return false; + } + console.error(`[session-orchestrator] custom-message wakeup failed, falling back to /api/prompt: ${message}`); + } + if (!isActive(expectedGeneration)) return false; + if (selfSessionId) { + try { + await api("POST", "/api/prompt", { sessionId: selfSessionId, message: text, mode: "steer" }); + if (!isActive(expectedGeneration)) return false; + return true; + } catch (error) { + if (!isActive(expectedGeneration)) return false; + console.error(`[session-orchestrator] wakeup via /api/prompt failed: ${error instanceof Error ? error.message : error}`); + } + } + if (!isActive(expectedGeneration)) return false; + try { + pi.sendUserMessage(text); + return true; + } catch { + try { + pi.sendUserMessage(text, { deliverAs: "steer" }); + return true; + } catch (error) { + console.error(`[session-orchestrator] wakeup delivery failed entirely: ${error instanceof Error ? error.message : error}`); + } + } + return false; + } + + async function poll(expectedGeneration = generation) { + if (!isActive(expectedGeneration) || pollInFlight) return; + pollInFlight = true; + try { + const completed: { w: Watched; summary: { text: string; isError: boolean } }[] = []; + for (const w of Array.from(watched.values())) { + if (!isActive(expectedGeneration)) return; + try { + const state = await api("GET", `/api/state?sessionId=${encodeURIComponent(w.id)}`); + if (!isActive(expectedGeneration)) return; + const running = Boolean(state?.runtime?.isRunning ?? (state?.isStreaming || state?.isCompacting)); + const pending = Number(state?.runtime?.pendingMessageCount || 0); + w.errorPolls = 0; + if (running || pending > 0) { + w.sawRunning = true; + w.idlePolls = 0; + continue; + } + w.idlePolls += 1; + // Fast tasks may finish between polls; if we never saw it running, + // wait a few polls before concluding it is done. + const settled = w.sawRunning ? w.idlePolls >= 1 : w.idlePolls >= 4; + if (!settled) continue; + + let summary = { text: "", isError: false }; + try { + const messages = await fetchMessages(w.id); + if (!isActive(expectedGeneration)) return; + summary = lastAssistantText(messages); + } catch { + if (!isActive(expectedGeneration)) return; + // Transcript fetch is best-effort. + } + completed.push({ w, summary }); + } catch { + if (!isActive(expectedGeneration)) return; + w.errorPolls += 1; + if (w.errorPolls >= 20) { + const ok = await deliverWakeup( + `🔔 [orchestrator] Lost track of worker "${w.name}" (session ${w.id}): status polling kept failing (it may have been deleted). Check it with sessions_status or in the sidebar.`, + { kind: "wakeup", workers: [{ sessionId: w.id, name: w.name, status: "error" }] }, + expectedGeneration, + ); + if (!isActive(expectedGeneration)) return; + if (ok) { + unwatch(w.id); + appendLedger(RESOLVED_ENTRY, w.id); + } + } + } + } + + // Batch all completions from this poll cycle into ONE message. Sending + // two user messages back-to-back while the parent is idle races the + // turn-start and can drop the second message. + if (completed.length > 0) { + const completedIds = new Set(completed.map(({ w }) => w.id)); + const activeWorkers = Array.from(watched.values()).filter((w) => !completedIds.has(w.id)); + const stillRunning = activeWorkers.map((o) => `"${o.name}"`).join(", "); + const details = { + kind: "wakeup", + workers: completed.map(({ w, summary }) => ({ + sessionId: w.id, + name: w.name, + status: w.aborted ? "aborted" : summary.isError ? "error" : "idle", + })), + stillRunning: activeWorkers.map((o) => ({ sessionId: o.id, name: o.name })), + }; + const sections = completed.map(({ w, summary }) => [ + `Worker "${w.name}" (session ${w.id}) is now ${w.aborted ? "stopped (aborted)" : "idle"}.`, + summary.text + ? `${summary.isError ? "⚠️ Its last turn ENDED WITH AN ERROR (task likely incomplete — inspect with sessions_read, then retry or fix):\n" : "Final message:\n"}${trunc(summary.text, WAKEUP_SUMMARY_CHARS)}` + : `(no final message captured)`, + ].join("\n")); + const ok = await deliverWakeup([ + `🔔 [orchestrator] ${completed.length === 1 ? "A worker finished." : `${completed.length} workers finished.`}`, + ``, + sections.join("\n\n---\n\n"), + ``, + stillRunning ? `Still running: ${stillRunning}.` : `No other workers are running.`, + `You can inspect details with sessions_read, follow up or redirect with sessions_prompt, or continue with your task.`, + ].join("\n"), details, expectedGeneration); + if (!isActive(expectedGeneration)) return; + if (ok) { + for (const { w } of completed) { + unwatch(w.id); + appendLedger(RESOLVED_ENTRY, w.id); + // The report was consumed by this session on the user's behalf — + // clear the child's unread dot via the normal read endpoint. + markChildRead(w.id); + } + } + // On total delivery failure, leave every completed worker watched. The + // next normal poll interval retries without creating a tight loop. + } + } finally { + pollInFlight = false; + } + } + + // ========================================================================= + // Other tools (unchanged from original) + // ========================================================================= + + // ------------------------------------------------------------------------- + // sessions_status + // ------------------------------------------------------------------------- + pi.registerTool({ + name: "sessions_status", + label: "Worker session status", + description: "Get a one-line status (running/idle, category, cost, message counts) for worker sessions. With no ids, reports all workers spawned from this session that are still tracked. Prefer waiting for wakeup messages over calling this in a loop.", + promptSnippet: "Check status of spawned worker sessions", + parameters: Type.Object({ + ids: Type.Optional(Type.Array(Type.String(), { description: "Session ids to check (defaults to all tracked workers)" })), + }), + async execute(_toolCallId: string, params: any) { + const ids: string[] = params.ids?.length ? params.ids : Array.from(watched.keys()); + if (ids.length === 0) { + return { content: [{ type: "text", text: "No tracked workers. (Workers you already received a wakeup for are untracked; pass their session id explicitly to check them.)" }], details: {} }; + } + const lines: string[] = []; + for (const id of ids) { + try { + const state = await api("GET", `/api/state?sessionId=${encodeURIComponent(id)}`); + const running = Boolean(state?.runtime?.isRunning); + const stats = state?.stats || {}; + const name = state?.sessionName || state?.sessionTitle || shortId(id); + const categoryName = watched.get(id)?.categoryName || "Unknown"; + lines.push(`${running ? "⏳ RUNNING" : "✔ idle"} — "${name}" (${id}) — category "${categoryName}" — $${Number(stats.cost || 0).toFixed(2)} — ${Number(stats.assistantMessages || 0)} assistant msgs${state?.runtime?.pendingMessageCount ? ` — ${state.runtime.pendingMessageCount} queued` : ""}`); + } catch (error) { + lines.push(`? — ${id} — status unavailable (${error instanceof Error ? error.message : error})`); + } + } + return { content: [{ type: "text", text: lines.join("\n") }], details: {} }; + }, + }); + + // ------------------------------------------------------------------------- + // sessions_read + // ------------------------------------------------------------------------- + pi.registerTool({ + name: "sessions_read", + label: "Read worker transcript", + description: "Read the tail of a session's transcript (compact rendering: user/assistant text, tool calls one-line each). Use to review a worker's work or diagnose one that's going down the wrong path. Keep tails small — don't pull a worker's full process back into your context.", + promptSnippet: "Read the recent transcript of another session", + parameters: Type.Object({ + id: Type.String({ description: "Session id" }), + tail: Type.Optional(Type.Number({ description: "How many trailing entries to include (default 20)" })), + }), + async execute(_toolCallId: string, params: any) { + const messages = await fetchMessages(params.id); + const text = formatTranscript(messages, Math.max(1, Math.min(200, params.tail || 20))); + return { content: [{ type: "text", text }], details: { sessionId: params.id, totalMessages: messages.length } }; + }, + }); + + // ------------------------------------------------------------------------- + // sessions_prompt + // ------------------------------------------------------------------------- + pi.registerTool({ + name: "sessions_prompt", + label: "Message worker session", + description: "Send a message to another session, exactly like a user typing into it. If it is mid-turn the message is delivered as steering after the current tool calls; set interrupt=true to abort its current turn first (use when it's going down the wrong path). You'll receive a wakeup when it next goes idle.", + promptSnippet: "Send a follow-up or steering message to a worker session (interrupt optional)", + parameters: Type.Object({ + id: Type.String({ description: "Session id" }), + message: Type.String({ description: "The message to deliver" }), + interrupt: Type.Optional(Type.Boolean({ description: "Abort the session's current turn before delivering (default false)" })), + }), + async execute(_toolCallId: string, params: any) { + if (params.interrupt) { + await api("POST", "/api/abort", { sessionId: params.id }); + await new Promise((resolve) => setTimeout(resolve, 500)); + } + await api("POST", "/api/prompt", { sessionId: params.id, message: params.message, mode: "steer" }); + if (!watched.has(params.id)) { + let name = shortId(params.id); + try { + const state = await api("GET", `/api/state?sessionId=${encodeURIComponent(params.id)}`); + name = String(state?.sessionName || state?.sessionTitle || name).replace(/^[⑂⤑]\s*/, ""); + } catch { /* best effort */ } + watch(params.id, name); + appendLedger(WATCH_ENTRY, params.id, name, undefined, false); + } + return { content: [{ type: "text", text: `${params.interrupt ? "Interrupted and redirected" : "Message delivered to"} session ${params.id}. You'll get a 🔔 wakeup when it goes idle.` }], details: {} }; + }, + }); + + // ------------------------------------------------------------------------- + // sessions_abort + // ------------------------------------------------------------------------- + pi.registerTool({ + name: "sessions_abort", + label: "Abort worker session", + description: "Abort another session's current turn and stop tracking it. The session itself remains in the sidebar and can be resumed later with sessions_prompt.", + promptSnippet: "Abort a worker session's current turn", + parameters: Type.Object({ + id: Type.String({ description: "Session id" }), + }), + async execute(_toolCallId: string, params: any) { + await api("POST", "/api/abort", { sessionId: params.id }); + unwatch(params.id); + appendLedger(RESOLVED_ENTRY, params.id); + return { content: [{ type: "text", text: `Aborted session ${params.id} and stopped tracking it.` }], details: {} }; + }, + }); + + // ------------------------------------------------------------------------- + // Lifecycle + // ------------------------------------------------------------------------- + pi.on("session_shutdown", () => { + disposed = true; + generation += 1; + if (timer) clearInterval(timer); + timer = undefined; + watched.clear(); + spawnedWorkers.clear(); + }); +} diff --git a/examples/pi-web-skills/session-orchestration/SKILL.md b/examples/pi-web-skills/session-orchestration/SKILL.md new file mode 100644 index 0000000..9556a11 --- /dev/null +++ b/examples/pi-web-skills/session-orchestration/SKILL.md @@ -0,0 +1,121 @@ +--- +name: session-orchestration +description: Orchestrate pi-web worker sessions with the sessions_spawn / sessions_status / sessions_read / sessions_prompt / sessions_abort tools. Use when a task has noisy or parallelizable parts worth delegating to background worker sessions (exploration, running test suites, isolated implementation steps), or when the user asks you to delegate, parallelize, or spawn workers. +--- + +# Session orchestration + +You have tools that give you the same powers a human has in the pi-web UI: +spawn sessions, watch them, message them, interrupt them. Workers are ordinary +pi-web sessions — fully visible in the sidebar (indented under this session), with +complete transcripts the user can open, watch, and even type into. + +## The core loop + +1. `sessions_spawn { name, task, category?, cwd? }` — returns immediately with a session id. +2. Do other useful work that **does not overlap what you just delegated**, spawn more + workers, or **end your turn**. +3. When a worker goes idle, a `🔔 [orchestrator]` user message arrives with its + final output. This starts a new turn for you if you were idle. +4. Review, then either finish, `sessions_prompt` a follow-up, or spawn the next phase. + +Wakeups are durable: watches are persisted in your session file, so if this +session is reloaded or the server restarts, watchers re-arm on load and any +workers that finished in the meantime produce catch-up wakeups. + +**Never poll for completion.** Do not call `sessions_status` in a loop and do +not sleep in bash while waiting. Wakeups are pushed to you. Ending your turn +while workers run is correct and costs nothing; you will be woken. + +**Don't re-do what you just delegated.** The most common failure is spawning +scouts and then immediately running the same greps, reads and inventories +yourself — which burns tokens twice and dumps into your context exactly the +noise delegation was meant to keep out. Use judgement about whether you should +be working at all while workers run: + +- *Work yourself* when: you need a result now to plan the next step or write the + next worker's task; there is genuinely complementary work (design decisions, + drafting the deliverable, scaffolding files, setting up a worktree); or a + worker's claim is load-bearing and cheap to verify (one command, one file). +- *End your turn* when: your only remaining moves are the tasks you handed out, + or your "parallel work" would mostly be reading things a worker will summarize + for you anyway. Waiting is a legitimate, cheap action. + +When in doubt, prefer ending the turn: you can always do the work after the +wakeup, with the worker's findings in hand. + +## What to delegate (and what not to) + +Delegate when the *process* is much bigger than the *conclusion*: + +- Codebase exploration ("where is X handled? report file:line + 5-line summary") +- Running test suites / builds / lint and triaging output +- An isolated, well-specified implementation step +- Anything you'd hate to have polluting your context afterwards + +Keep for yourself: decisions, design, anything needing the context you've +accumulated with the user, and small quick edits (spawning has ~seconds of +overhead and a worker starts with zero context). + +## Writing good worker tasks + +Workers know NOTHING about your conversation. In `task`, include: + +- Concrete goal, relevant paths, constraints, and what NOT to touch. +- What evidence to report: findings with file:line, diffs/files touched, exact + test output. Ask for a compact report — you want conclusions, not narrative. +- For risky/overlapping edits, give each worker a separate git worktree via + `cwd`, or make edits disjoint by construction. + +Choose the worker's model by **category**, deliberately. Categories are +user-authored (name + "when to use" prose) and listed in the `sessions_spawn` +tool description — pick by the sub-task's nature: a cheap/fast category for +scouting and mechanical chores; a stronger category for real implementation. +The prose in each category is the routing guidance — follow it. Omit `category` +to use the configured default. If no categories are configured, the worker uses +the session's default model. Never guess model ids; pass a category **name**. + +## Steering and reviewing + +- `sessions_status` — quick glance (running/idle, cost). Fine occasionally, + e.g. before ending a message to the user. +- `sessions_read { id, tail }` — compact transcript tail. Use it to review + evidence or diagnose a struggling worker. Keep tails small; do not import a + worker's whole process into your context. +- `sessions_prompt { id, message, interrupt? }` — follow up, or with + `interrupt: true` stop a worker that is going down the wrong path and + redirect it. Also works to re-engage a worker that already went idle. +- `sessions_abort { id }` — stop a worker you no longer need. + +Trust evidence, not claims: a worker saying "done" is not done. Check its +diff/test output (from the wakeup or `sessions_read`), or verify cheaply +yourself. + +## Etiquette and limits + +- Depth cap: workers must not spawn sub-workers (`sessions_spawn` refuses). +- At most 4 workers spawned by `sessions_spawn` may be running/tracked at once; + sessions merely watched after `sessions_prompt` do not consume spawn slots. + Prefer 2–3 focused workers over a swarm. +- The user sees every worker in the sidebar and may type into one directly — + that's fine and expected. Their instructions to a worker take precedence. +- In your reply to the user after spawning, say which workers you started and + that you'll report when they finish. +- If a wakeup arrives while you're mid-task, you may briefly acknowledge it + and defer handling until your current step is done. + +## Concrete example + +``` +sessions_spawn { name: "scout: session storage", category: "Fast", + task: "In , find where session files are written and rotated. + Report file:line for each write path plus a 5-line summary. + Read-only: do not modify anything." } +sessions_spawn { name: "tests: baseline", category: "Fast", + task: "Run `npm test` in . Report pass/fail counts and the full + failure output for any failing test, nothing else." } +-- end turn; wakeups arrive; then -- +sessions_spawn { name: "implement: rotation fix", category: "Smart", + task: ". + Run typecheck + affected tests; report the diff and test output." } +``` diff --git a/index.html b/index.html index 65bb76d..cd67131 100644 --- a/index.html +++ b/index.html @@ -326,6 +326,7 @@

Extensions

+