import { agentsViewKey, agentsCollapseKey, agentsStopKey } from "../lib/agents-keys.ts"; import { spawn } from "node:child_process"; import { Type } from "typebox"; import { recordReviewMutation } from "../lib/review-reminder-receipt.ts"; import { SESSION_CHANGE_RELAY } from "../lib/session-changes.ts"; import { publishForeignSessionChange } from "../lib/session-change-capture.ts"; import { SESSION_WORKTREE_ENTRY, SESSION_WORKTREE_CHANGED, SessionWorktreeRegistry, nativeRootSpelling, resolveSessionWorktree, type WorktreeResolver } from "../lib/session-worktree-registry.ts"; import { canonicalWriterRoot } from "../lib/writer-surfaces.ts"; import { ForeignTargetGrants } from "../lib/foreign-target-grants.ts"; import { MESSAGING_REASON_MAX_UTF8_BYTES, MESSAGING_REASON_MIN_CHARACTERS, normalizeMessagingReason, SessionMessagingGrants } from "../lib/session-messaging-grants.ts"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync } from "node:fs"; import { randomUUID } from "node:crypto"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import os from "node:os"; import { join, resolve, isAbsolute } from "node:path"; import { fileURLToPath } from "node:url"; import { createLocalBashOperations, keyHint, SettingsManager, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent"; import { createJobRegistry, isStandaloneSleep, type JobExecOperations, type JobRecord } from "../lib/background-jobs.ts"; import { JOB_GLYPH, JOB_NOTICE_TYPE, jobDetails, jobNoticeText, monitorEventsText, monitorStoppedText, registerBackgroundJobTools } from "../lib/background-jobs-tools.ts"; import { createMonitorController, mergeMonitorBatches, type MonitorNotice } from "../lib/background-monitor.ts"; import { JobsView } from "../lib/jobs-view.ts"; import { JOBS_SIDEBAR_EVENT, JOBS_STATUS_KEY } from "../lib/jobs-sidebar-state.ts"; import { Text, type Component, type TUI } from "@earendil-works/pi-tui"; import { invalidateSidebar } from "../lib/shell-sidebar-layout.ts"; import { sidebarPart, VISUAL_SETTINGS_CHANGED } from "../lib/shell-sidebar.ts"; import { NativePointerRegion } from "../lib/native-pointer-region.ts"; import { resolveVisualSettings } from "../lib/visual-customization-policy.ts"; import { createCompletionQueue } from "../lib/agents-completion-delivery.ts"; import { createAgentMessageQueue, type PendingAgentMessage } from "../lib/agents-message-delivery.ts"; import { AGENT_MODE, PROFILE_SOURCE, discoverAgents, formatModelRef, loadAgentsConfig, resolveAgentProfile, unmatchedProfileKeys, withPinnedModelProfiles, type AgentDefinition, type AgentMode, type ProfileSource } from "../lib/agents-config.ts"; import { freezeSessionProfileAtStartup, inheritedProfileDriftNotice, resolveSessionProfile, sessionProfileRoutingAt, type InheritedProfileOptions, type SessionProfileResolution } from "../lib/session-profile-freeze.ts"; import { resolveProfilePin, resolveUnversionedProjectProfile } from "../lib/agent-profile-pin.ts"; import { resolveBackgroundSubagentsPolicy } from "../lib/background-subagents-policy.ts"; import { installBackgroundCacheWarming } from "../lib/background-cache-warming.ts"; import { isFinished, MISSING_TOOLS_NOTE_PREFIX, TASK_EVENT, TASK_STATUS, TaskStore, type AskRequest, type TaskRecord } from "../lib/agents-protocol.ts"; import { AgentRunner, piCommand, abortReasonText, REQUESTED_TOOLS_ENV, type AskAnswer, type RunnerDeps, type TaskRequest } from "../lib/agents-runner.ts"; import { parseChildPackageInjection } from "../lib/child-package-injection.ts"; import { ChildMessenger, type IpcEndpoint } from "../lib/agents-messaging.ts"; import { ActiveSessionClient, ActiveSessionListener, SessionPresenceError, SessionPresenceRegistry, type PresenceRecord, type ReceivedNotification, type SentNotification, type SessionPresenceCandidate } from "../lib/agents-session-transport.ts"; import { WindowsActiveSessionClient, WindowsActiveSessionListener, WindowsSessionPresenceRegistry, type WindowsSessionRegistryPhaseObserver } from "../lib/windows-session-transport.ts"; import { hasReviewSessionPermission, resolveCanonicalGitRepositoryIdentitySync, type ReviewSessionManager } from "../lib/review-session-standing-permission.ts"; import { inheritedUnsafeGitEnvironmentKeys } from "../lib/review-repository.ts"; import { historyDir, loadHistory, loadStoredTask, pruneHistory, saveTask } from "../lib/agents-history.ts"; import { sessionToMarkdown } from "../lib/agents-transcript.ts"; import { AgentsView } from "../lib/agents-view.ts"; import { withOverlayRepaint } from "../lib/overlay-repaint.ts"; import { PresencePublisher, sanitizeDisplayLabel, listPresence, sessionHash } from "../lib/orchestrator-presence.ts"; import { incomingMessageCard, outgoingMessageCall, outgoingMessageResult, type OrchestratorMessageDetails } from "../lib/orchestrator-message-card.ts"; import { orchestratorToolRenderers } from "../lib/orchestrator-tool-card.ts"; import { discoverOrchestrators } from "../lib/orchestrator-discovery.ts"; import { consultPublishedMetadata, unavailableMetadata, type MetadataReceipt } from "../lib/orchestrator-consultation.ts"; import { HelperCostPermission } from "../lib/orchestrator-helper-consent.ts"; import { OrchestratorStateCache } from "../lib/orchestrator-state.ts"; import { TODO_PROGRESS_EVENT, type TodoProgress } from "../lib/shell-todo.ts"; import { normalizeTaskSubject } from "../lib/orchestrator-alias.ts"; import { decodeWorkDescriptor } from "../lib/orchestrator-work.ts"; import { validateWorkFilter, searchPublishedWork } from "../lib/orchestrator-work-search.ts"; import { OrchestratorScopeCache, type RepositoryFact } from "../lib/orchestrator-scope.ts"; import { createRpcActivityPublisher, type RpcActivityPublisher } from "../lib/agents-rpc-publisher.ts"; import { isInteractiveRpcHost } from "../lib/rpc-host.ts"; import { createNativeFullscreenInteraction } from "../lib/native-fullscreen-interaction.ts"; import { AGENTS_GLYPH, agentsCardTone, renderAgentsCard, widgetExpiryMs, widgetRows } from "../lib/agents-widget.ts"; import { CARD_TONE, panelHeaderRow, renderCard } from "../lib/shell-card.ts"; import { openInExternalEditor } from "./gentle-shell.ts"; import { gentlePiConfigHome } from "../lib/agent-home.ts"; import { resolveAgentHomeDirectory } from "../lib/agent-model-resolution.ts"; import { allowedEditSurfaces, inheritAllowedEditSurfaces, isBoundedWriter, isDevelopmentSurface, isGenericBoundedWriter, prepareBoundSessionRepository, rejectUnscopedBoundedWriterDispatch, safeBootstrapDirectory, sessionRepositoryAuthority } from "../lib/bounded-writer-admission.ts"; import { CHILD_METRICS_EVENT, CHILD_METRICS_REVOKED, childEvent, launchSelection, type LaunchSelection } from "../lib/runtime-metrics-children.ts"; import { runtimeMetricsEnvAllows, type RuntimeMetricsPolicyDeps } from "../lib/runtime-metrics-policy.ts"; import { publishTaskStatusChanges } from "../lib/notification-events.ts"; // Gentle Agents: subagents as isolated `pi --mode rpc` children, a task // store that notifies per task, and a Gentle Shell card above the editor. // The tool names match the retired pi-subagents package so prompts, skills, // and gentle-ai's delegation rules keep working unchanged. export const AGENTS_WIDGET_KEY = "gentle-agents"; export const AGENTS_COMMAND_NAME = "gentle:agents"; export const JOBS_COMMAND_NAME = "gentle:jobs"; // How long session shutdown waits for stopped jobs to close their logs. const JOBS_SHUTDOWN_GRACE_MS = 2000; export const AGENTS_RESULT_TYPE = "gentle-agents.result"; export const AGENTS_MESSAGE_TYPE = "gentle-agents.message"; export const AGENTS_ORCHESTRATOR_MESSAGE_TYPE = "gentle-agents.orchestrator-message"; // Display labels are best-effort local snapshots, never routing or authority. function messagePeerLabel(profile: string, sessionId: string): string | undefined { try { const page = listPresence(profile); if (page.unavailable || page.overflow || page.rejected) return undefined; const matches = page.entries.filter(header => header.sessionHash === sessionHash(sessionId)); return matches.length === 1 && matches[0].recent ? sanitizeDisplayLabel(matches[0].label) || undefined : undefined; } catch { return undefined; } } export const AGENTS_STALE_RESULT_TYPE = "gentle-agents.stale-result"; export const AGENTS_STALE_NOTICE_TYPE = "gentle-agents.stale-notice"; const RENDER_COALESCE_MS = 400; const CLOCK_TICK_MS = 1000; const TOOL_PREFIX = "subagent_"; // Wakes an idle parent after child content was stored as a custom message. // It names itself as automated so the model never attributes it to the human. const PARENT_WAKE_PREFIX = "[System-generated Gentle Agents notification, not written by the user]"; type WakeTextKey = "subagent" | "job" | "monitor" | "output"; const PARENT_WAKE_BODIES: Record = { subagent: "Subagent output was delivered to this session above. Review it and continue.", job: "A background job exit notice was delivered to this session above. Review it and continue.", monitor: "A monitor notice was delivered to this session above. Review it and continue.", output: "Output was delivered to this session above. Review it and continue.", }; const parentWakeText = (key: WakeTextKey): string => `${PARENT_WAKE_PREFIX} ${PARENT_WAKE_BODIES[key]}`; const PARENT_WAKE_TYPE = "gentle-agents.wake"; const BRIDGE_WAKE_IDENTITY_TYPE = "gentle-agents.wake-identity"; interface BridgeWakeIdentity { sessionId: string; nonce: string; text: string; } const bridgeWakeText = (nonce: string, key: WakeTextKey = "subagent"): string => `${parentWakeText(key)} [gentle-agents wake: ${nonce}]`; const isBridgeWakeText = (nonce: string, text: string): boolean => (Object.keys(PARENT_WAKE_BODIES) as WakeTextKey[]).some((key) => text === bridgeWakeText(nonce, key)); export type WakeDeliveryKind = "subagent" | "job" | "monitor"; export const WAKE_TEXT_SUBAGENT = "Review the delivered subagent output and continue."; export const WAKE_TEXT_JOB = "Review the background job's exit notice and continue."; export const WAKE_TEXT_MONITOR = "Review the delivered monitor notice and continue."; export const WAKE_TEXT_OUTPUT = "Review the delivered output and continue."; export const NATIVE_PARENT_WAKE_TEXT = WAKE_TEXT_SUBAGENT; /** * Resolves the automated parent wake prompt matching the delivery kinds accumulated * since the last wake, avoiding confusing the model with "subagent output" after a background job. */ export function nativeParentWakeText(kinds: ReadonlySet): string { const key = wakeTextKey(kinds); return key === "job" ? WAKE_TEXT_JOB : key === "monitor" ? WAKE_TEXT_MONITOR : key === "output" ? WAKE_TEXT_OUTPUT : WAKE_TEXT_SUBAGENT; } function wakeTextKey(kinds: ReadonlySet): WakeTextKey { if (kinds.size > 1) return "output"; if (kinds.has("job")) return "job"; if (kinds.has("monitor")) return "monitor"; return "subagent"; } // How long a dispatched wake may take to start a parent run before a later // delivery may send another one. export const PARENT_WAKE_GRACE_MS = 30_000; // A delivery or wake the host rejected while the parent was idle is retried // after the grace at most this many times in a row, so a permanently broken // runtime never retries forever. const PARENT_DELIVERY_RETRY_LIMIT = 3; const retiredSddAgent = (name: string): boolean => /^sdd(?:-|$)/.test(name); export interface SessionTransportRegistry { list(excludeSessionId?: string): Promise; listActivations(excludeSessionId?: string): Promise; close?(): Promise; } export interface SessionTransportListener { readonly record?: PresenceRecord; readonly registry: SessionTransportRegistry; readonly closesRegistry?: boolean; start(): Promise; close(): Promise; } export interface SessionTransportClient { close(): void; sendNotification(recipientSessionId: string, message: string, options?: { id?: string; expectedActivation?: PresenceRecord; beforeConnect?: () => boolean | Promise; signal?: AbortSignal }): Promise; } export interface SessionTransportFactory { createRegistry(agentHome: string, observeWindowsPhase?: WindowsSessionRegistryPhaseObserver): Promise; createListener(registry: SessionTransportRegistry, sessionId: string, onNotification: (notification: ReceivedNotification) => Promise): SessionTransportListener; createClient(registry: SessionTransportRegistry, sessionId: string): SessionTransportClient; } export interface AgentsDeps extends RunnerDeps { home: string; agentHome?: string; childIpc?: IpcEndpoint; sessionTransport?: SessionTransportFactory; env: NodeJS.ProcessEnv; resolveWorktree: WorktreeResolver; runtimeMetricsPolicy?: RuntimeMetricsPolicyDeps; metricsNow?: () => number; metricsSchedule?: RunnerDeps["schedule"]; // Extensions every child loads with --extension (gentle-shell#1587). childExtensionPaths?: string[]; /** Shell for background jobs; defaults to Pi's configured Bash. */ jobShell?: (cwd: string) => { operations: JobExecOperations; commandPrefix?: string }; /** Directory for background job output; defaults to a private temp dir. */ jobOutputDir?: () => string; } // gentle-shell#1587: fallback for a parent without the launcher's package // injection signal (#1690), e.g. a manual `pi -e ` launch. Children // then get context filtering, destructive-command safety, and the nan // provider (gentle-shell#1731 T32: a model routed to nan/* is not found // without it). When the signal is present, children load the whole package // instead. This list is frozen: new child-facing behavior ships inside the // package, never here. Missing files are omitted. export function childContextExtensionPaths(exists: (path: string) => boolean = existsSync): string[] { try { return ["./child-context.ts", "./child-safety.ts", "./nan-provider.ts"] .map((path) => fileURLToPath(new URL(path, import.meta.url))) .filter(exists); } catch { return []; } } // #1690: when the gentle-shell launcher injected the package with -e, the // host env carries that exact set and every child loads it too, which already // includes the curated entries. Without a valid signal (declared package, // regular gentle-pi, malformed value) children keep the curated entries. function childExtensionRequest(env: NodeJS.ProcessEnv, curated: string[] | undefined): Pick { const injection = parseChildPackageInjection(env); if (injection) return { extensionPaths: [...injection.extensionPaths], ...(injection.noExtensions ? { noExtensions: true } : {}) }; return curated && curated.length > 0 ? { extensionPaths: [...curated] } : {}; } // gentle-shell#2180: a short, stable token for the profile layer that supplied // the launch's model, recorded next to the model in the TaskRecord so every // task card shows which layer won and stale disk edits below a frozen session // become visible. Derived from the resolution the request already performed; // the only profile reads are the pin/declaration lookups in the paths where // `sessionProfileRoutingAt` itself reads the target's own layers without // carrying the origin (a foreign target under a session frozen without a // profile, and `follow`/child launches), so no layer is resolved twice to route. function modelSourceToken( source: ProfileSource, resolution: SessionProfileResolution, routingTarget: InheritedProfileOptions & { foreignRepository?: boolean }, ): string | undefined { if (source === PROFILE_SOURCE.DEFINITION) return "definition"; // Unresolved means no layer supplied a model at all: the card already shows // `default`, and naming a layer next to it would only be noise. if (source === PROFILE_SOURCE.UNRESOLVED) return undefined; if (source !== PROFILE_SOURCE.PROFILE) return "default"; if (resolution.kind === "explicit") return "binding"; if (resolution.kind === "inherited") { if (resolution.profile !== undefined) return `frozen:${resolution.profile.origin}`; // Frozen without a profile: a foreign target keeps only its own pin // layers, so a winning pin there is named from the same read // `sessionProfileRoutingAt` just performed; otherwise the session keeps // the materialized config routing. if (routingTarget.foreignRepository === true) { const pin = resolveProfilePin(routingTarget); return pin === undefined ? "profile" : `pin:${pin.source}`; } return "profile"; } // `follow` and live child launches read the target's own defaults // (pin → declaration → global active); name the layer the same read chose. const pin = resolveProfilePin(routingTarget); if (pin !== undefined) return `pin:${pin.source}`; return resolveUnversionedProjectProfile(routingTarget.cwd, routingTarget.configHome) !== undefined ? "declaration" : "profile"; } export function agentRuntimePaths(home: string, agentHome = join(home, ".pi", "agent")): { sessions: string; transcripts: string } { const root = join(agentHome, "gentle-agents"); return { sessions: join(root, "sessions"), transcripts: join(root, "transcripts") }; } const workDescriptorSchema = { type: "object", additionalProperties: false, description: "Explicit non-authoritative classification, not child context. Topic requires area; limits are UTF-8 bytes. No nested tasks.", properties: { area: { type: "string", maxLength: 64 }, topic: { type: "string", maxLength: 64 }, tags: { type: "array", maxItems: 8, uniqueItems: true, items: { type: "string", maxLength: 64 } }, refs: { type: "array", maxItems: 8, uniqueItems: true, items: { type: "object", additionalProperties: false, required: ["kind", "repository", "id"], properties: { kind: { type: "string", enum: ["issue", "pr", "task"] }, repository: { type: "string", maxLength: 256, description: "Explicit public host/owner/repo; not a URL or inferred identity." }, id: { type: "string", maxLength: 256, description: "Canonical positive decimal issue/PR ID or opaque historical task ID; never a peer route." }, }, } }, }, }; interface ToolText { content: Array<{ type: "text"; text: string }>; details: Record; terminate?: boolean; } const defaultDeps = (env: NodeJS.ProcessEnv): AgentsDeps => ({ spawn: (command, args, options) => spawn(command, args, { cwd: options.cwd, env: options.env, stdio: options.stdio ?? ["pipe", "pipe", "pipe"], windowsHide: true, detached: options.detached }), now: () => Date.now(), schedule: (fn, ms) => { const timer = setTimeout(fn, ms); timer.unref?.(); return () => clearTimeout(timer); }, pi: piCommand(), resolvePi: () => piCommand(), home: os.homedir(), resolveWorktree: resolveSessionWorktree, env, childExtensionPaths: childContextExtensionPaths(), }); export function agentsEnabled(env: NodeJS.ProcessEnv = process.env): boolean { if (env.GENTLE_PI_AGENTS_CHILD === "1") return false; const value = env.GENTLE_PI_AGENTS?.trim().toLowerCase(); return !(value === "0" || value === "false" || value === "off"); } // The retired pi-subagents package registers the same tool names. While it // is still installed we stay out of the way and say how to switch. export const LEGACY_SUBAGENTS_PACKAGE = "pi-subagents-j0k3r"; export function legacySubagentsInstalled(home: string): boolean { return legacySubagentsInstalledAt(join(home, ".pi", "agent")); } function legacySubagentsInstalledAt(agentHome: string): boolean { const settingsPath = join(agentHome, "settings.json"); if (!existsSync(settingsPath)) return false; try { const settings = JSON.parse(readFileSync(settingsPath, "utf8")) as { packages?: unknown }; return Array.isArray(settings.packages) && settings.packages.some((entry) => typeof entry === "string" && entry.includes(LEGACY_SUBAGENTS_PACKAGE)); } catch { return false; } } export { agentsViewKey, agentsCollapseKey, agentsStopKey }; function sanitizeTerminalText(value: string): string { return value.replace(/[\x00-\x08\x0B-\x1F\x7F-\x9F]/g, (control) => `\\x${control.charCodeAt(0).toString(16).toUpperCase().padStart(2, "0")}`); } function messageText(content: unknown): string { if (typeof content === "string") return content; if (!Array.isArray(content)) return ""; return content.map((part) => part && typeof part === "object" && (part as { type?: unknown }).type === "text" && typeof (part as { text?: unknown }).text === "string" ? (part as { text: string }).text : "").join("\n"); } function ownedChildIpc(env: NodeJS.ProcessEnv, candidate: IpcEndpoint | undefined): IpcEndpoint | undefined { if (env.GENTLE_PI_AGENTS_CHILD !== "1" || !env.GENTLE_PI_AGENTS_OWNED_IPC || !candidate || typeof candidate.send !== "function" || typeof candidate.on !== "function") return undefined; return candidate; } // Catalog declarations are routing hints, not proof of child availability. // Filter non-name entries such as `"*": false`, but do not claim that a // syntactically valid name is registered. Empty declarations omit --tools and // use Pi defaults. The runner adds parent messaging separately. const TOOL_NAME = /^[A-Za-z0-9_.:-]+$/; function declaredToolInventory(agent: AgentDefinition): string { const usable = agent.tools.filter((name) => TOOL_NAME.test(name)); if (usable.length > 0) return `[declared tools (not verified): ${usable.join(", ")}]`; return agent.tools.length === 0 ? "[declared tools: Pi defaults (no allowlist)]" : "[declared tools: no tool names declared]"; } // Pi drops unknown --tools names without a diagnostic (#1690), so the child // reports them once through the one notify the parent keeps. The check runs // at the first before_agent_start, not session_start: Pi runs session_start // handlers in extension load order, so an extension loaded after this one may // still register its tools there. MCP tools register later still, and entries // that cannot be tool names (the `"*": false` frontmatter entry) are not // checkable. function registerMissingToolsCheck(pi: ExtensionAPI, env: NodeJS.ProcessEnv): void { const requested = (env[REQUESTED_TOOLS_ENV] ?? "").split(",").map((name) => name.trim()) .filter((name) => /^[A-Za-z0-9_.:-]+$/.test(name) && !name.startsWith("mcp__")); if (requested.length === 0) return; let reported = false; pi.on("before_agent_start", (_event, ctx) => { if (reported) return; reported = true; try { const available = new Set(pi.getAllTools().map((tool) => tool.name)); const missing = [...new Set(requested.filter((name) => !available.has(name)))]; if (missing.length > 0) ctx.ui.notify(`${MISSING_TOOLS_NOTE_PREFIX} ${missing.join(", ")}`, "warning"); } catch { /* A diagnostic must never break the child's first prompt. */ } }); } function registerChildMessaging(pi: ExtensionAPI, ipc: IpcEndpoint): void { const messenger = new ChildMessenger(ipc); pi.registerTool({ name: "subagent_parent_message", label: "Agent parent message", description: "Send a bounded notification or correlated query to this subagent's parent.", parameters: { type: "object", additionalProperties: false, required: ["message"], properties: { kind: { type: "string", enum: ["notification", "query"] }, message: { type: "string" } } } as never, async execute(_id, params): Promise { const input = params as { kind?: unknown; message?: unknown }; if (typeof input.message !== "string") throw new Error("parent messages require text"); if (input.kind === undefined || input.kind === "notification") { await messenger.notify(input.message); return { content: [{ type: "text", text: "Notification accepted by the parent." }], details: {} }; } if (input.kind !== "query") throw new Error("parent messages require notification or query kind"); const reply = await messenger.query(input.message); return { content: [{ type: "text", text: reply }], details: { reply } }; }, }); } const posixSessionTransport: SessionTransportFactory = { createRegistry(agentHome) { return SessionPresenceRegistry.create(agentHome); }, createListener(registry: SessionPresenceRegistry, sessionId, onNotification) { return Object.assign(new ActiveSessionListener(registry, sessionId, onNotification), { closesRegistry: false }); }, createClient(registry: SessionPresenceRegistry, sessionId) { return new ActiveSessionClient(registry, sessionId); }, }; const windowsSessionTransport: SessionTransportFactory = { createRegistry(agentHome, observeWindowsPhase) { return WindowsSessionPresenceRegistry.create(agentHome, observeWindowsPhase); }, createListener(registry: WindowsSessionPresenceRegistry, sessionId, onNotification) { return Object.assign(new WindowsActiveSessionListener(registry, sessionId, onNotification), { closesRegistry: true }); }, createClient(registry: WindowsSessionPresenceRegistry, sessionId) { return new WindowsActiveSessionClient(registry, sessionId); }, }; export function createDefaultSessionTransport(platform: NodeJS.Platform = process.platform): SessionTransportFactory { return platform === "win32" ? windowsSessionTransport : posixSessionTransport; } function text(value: string, details: Record = {}, terminate = false): ToolText { return { content: [{ type: "text", text: value }], details, ...(terminate ? { terminate: true } : {}) }; } // gentle-pi#1175: the writer profile the runtime resolved for this task, // carried on its review-mutation receipt so ASSESS never trusts a model // re-declaration. `task.model` is the child's reported model once get_state // answers, or the launch profile's model. An inherited model is recorded as // the unresolved `formatModelRef(undefined)` placeholder; the parent's current // model is not reliable evidence (the child resolves its own default), so it is // omitted and ASSESS treats the writer as unknown, i.e. small. function runtimeWriterProfile(task: TaskRecord): { writerModelId?: string; writerEffort?: string } { const modelId = typeof task.model === "string" && task.model !== formatModelRef(undefined) && task.model.trim() ? task.model : undefined; const effort = typeof task.thinking === "string" && task.thinking.trim() ? task.thinking : undefined; return { ...(modelId === undefined ? {} : { writerModelId: modelId }), ...(effort === undefined ? {} : { writerEffort: effort }), }; } function taskDetails(task: TaskRecord): Record { return { gentleAgents: { taskId: task.id, agent: task.agent, status: task.status, mode: task.mode, cwd: task.cwd } }; } export function describeTask(task: TaskRecord): string { const head = `${task.id} · ${task.agent} · ${task.status} · ${task.mode}`; const detail = task.error ? `\n${task.error}` : ""; return `${head} · cwd: ${task.cwd} · ${task.turns} turns · ${task.toolCalls} tool calls · last: ${task.lastStep}${detail}`; } function finishedText(task: TaskRecord): string { if (task.status === "completed") return task.result ?? "(the subagent returned no text)"; return `Subagent ${task.agent} ${task.status}${task.error ? `: ${task.error}` : ""}${task.result ? `\n\nLast answer:\n${task.result}` : ""}`; } // pi's keybinding hint needs a live theme; outside one (tests, headless) the // plain words still tell the reader what the key does. function expandHint(expanded: boolean): string { try { return keyHint("app.tools.expand", expanded ? "collapse" : "expand"); } catch { return expanded ? "collapse" : "expand"; } } // pi runs `-p` and `--mode json` through one one-shot runner that disposes // the runtime as soon as the prompt returns, so a background result has no // parent session left to reach (gentle-shell#1731 T18). export function isSingleShotMode(mode: string | undefined): boolean { return mode === "print" || mode === "json"; } const SINGLE_SHOT_BACKGROUND_ERROR = "Background subagents are unavailable in single-shot modes: pi -p and pi --mode json exit before a parent session can receive results. Use task mode, RPC mode, or interactive Pi."; // The default mode for a subagent_run request that named neither an explicit // mode nor an agent-defined one. Background is a runtime default only when // the background-subagents policy is on AND the parent can receive results: // single-shot modes exit before a parent session exists to deliver them to // (see the guard in `launch` below), so they keep the configured default // (normally task) even when the policy is on. export function resolveDefaultSubagentMode(input: { configuredDefault: AgentMode; policy: "on" | "off"; parentMode: string | undefined; }): AgentMode { return input.policy === "on" && !isSingleShotMode(input.parentMode) ? AGENT_MODE.BACKGROUND : input.configuredDefault; } // What the model reads when a background task ends: the outcome first, then // the answer itself. The expanded card shows the same text. export function completionText(task: TaskRecord): string { const outcome = task.status === "completed" ? "finished" : task.status.replace("_", " "); return `Subagent ${task.agent} (task ${task.id}, "${task.label}") ${outcome}.\n\n${finishedText(task)}`; } const COMPLETION_HEADER = /^Subagent [^\n]+ \(task [^\n]*\) [^\n]+\.$/; // The collapsed card leads with the answer or error: completionText's // bookkeeping paragraph stays for the model and the expanded card. Entries // without that header (older sessions) preview their full text. export function agentResultPreview(text: string): string { const split = text.indexOf("\n\n"); if (split < 0 || !COMPLETION_HEADER.test(text.slice(0, split))) return text; const rest = text.slice(split + 2); return rest.trim() === "" ? text : rest; } // Host-side answer to a child's dialog: the same ctx.ui the human already // uses, so a subagent's question looks like any other pi dialog. export async function answerThroughUi(ui: ExtensionContext["ui"] | undefined, ask: AskRequest, raw: Record): Promise { if (!ui) return { cancelled: true }; const title = `${AGENTS_GLYPH} ${ask.title}`; switch (ask.method) { case "select": { const options = Array.isArray(raw.options) ? raw.options.map(String) : []; const value = await ui.select(title, options); return value === undefined ? { cancelled: true } : { value }; } case "confirm": return { confirmed: await ui.confirm(title, typeof raw.message === "string" ? raw.message : "") }; case "input": { const value = await ui.input(title, typeof raw.placeholder === "string" ? raw.placeholder : undefined); return value === undefined ? { cancelled: true } : { value }; } case "editor": { const value = await ui.editor(title, typeof raw.prefill === "string" ? raw.prefill : undefined); return value === undefined ? { cancelled: true } : { value }; } default: return { cancelled: true }; } } export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv = process.env, overrides: Partial = {}): void { const childIpc = ownedChildIpc(env, overrides.childIpc ?? (process.send ? process as unknown as IpcEndpoint : undefined)); if (env.GENTLE_PI_AGENTS_CHILD === "1") { // A stale managed-SDD child must never inherit unrestricted ordinary tools. if (env.GENTLE_PI_RESEARCH_TOOLS !== undefined || env.GENTLE_PI_RESEARCH_SELECTION !== undefined || env.GENTLE_PI_RESEARCH_ARTIFACT !== undefined || env.GENTLE_PI_SDD_REMEDIATION_PLAN !== undefined) { pi.on("tool_call", () => ({ block: true, reason: "Retired SDD child launch is not supported." })); return; } if (childIpc) registerChildMessaging(pi, childIpc); registerMissingToolsCheck(pi, env); return; } if (!agentsEnabled(env)) return; const deps: AgentsDeps = { ...defaultDeps(env), ...overrides }; // An explicitly injected pi command wins over the default per-spawn resolver. if (overrides?.pi && !overrides.resolvePi) delete deps.resolvePi; // Freeze the host's root before a child uses a different session cwd. const agentHome = resolveAgentHomeDirectory({ env: deps.env, home: deps.home, agentHome: overrides.agentHome, homeOverridden: overrides.home !== undefined }); const sessionTransport = deps.sessionTransport ?? createDefaultSessionTransport(); if (legacySubagentsInstalledAt(agentHome)) { pi.on("session_start", (_event, ctx) => { if (ctx.hasUI) ctx.ui.notify(`${AGENTS_GLYPH} Gentle Agents is waiting: remove the old package first with "pi remove npm:${LEGACY_SUBAGENTS_PACKAGE}"`, "warning"); }); return; } const collapseKey = agentsCollapseKey(env); const viewKey = agentsViewKey(env); const stopKey = agentsStopKey(env); const store = new TaskStore(); // Attach before any live add/start; restore and unchanged updates are silent. // TaskStore contains bus listener failures so notifications cannot fail tools. const stopStatusNotifications = publishTaskStatusChanges(store, pi.events, () => performance.now()); pi.on("session_shutdown", () => { stopStatusNotifications(); }); const restoredTaskIds = new Set(); const tasksDir = historyDir(deps.home, agentHome); let ui: ExtensionContext["ui"] | undefined; let host: { requestRender(): void } | undefined; let sidebarTui: TUI | undefined; const visualHome = gentlePiConfigHome(env); let agentsVisible = resolveVisualSettings({ gentlePiConfigHome: visualHome }).settings.visibility.agents; let stopVisualUpdates: (() => void) | undefined; let sessions: ExtensionContext["sessionManager"] | undefined; let presence: PresencePublisher | undefined; const scopeCache = new OrchestratorScopeCache(deps.resolveWorktree); const stateCache = new OrchestratorStateCache(); let rpcActivityPublisher: RpcActivityPublisher | undefined; // Messages already surfaced to the user this session through the RPC // activity publisher's `onError`, so a recurring push failure (the // coalescing window retries every burst) notifies at most once per // session instead of flooding the UI. Reset on every `session_start`. let notifiedRpcActivityErrors: Set | undefined; const overlays = new Set(); const startPresence = (manager: ExtensionContext["sessionManager"]) => { scopeCache.clear(); const sessionId = manager.getSessionId() ?? ""; const labelSource = () => { if (sessions !== manager || (manager.getSessionId() ?? "") !== sessionId) throw new Error("stale-session"); return manager.getSessionName?.() || manager.getCwd().split(/[\\/]/).pop() || "Orchestrator"; }; return PresencePublisher.start({ profile: agentHome, sessionId, label: labelSource(), labelSource, activity: [] }); }; const publishActivityNow = (deferred = false) => { if (!sessions) return; try { if (!presence || presence.error) { presence?.dispose(); presence = startPresence(sessions); } const tasks = store.list(activeSessionId()).filter((task) => ownedTaskIds.has(task.id) && !isFinished(task.status) && !restoredTaskIds.has(task.id)); presence?.update(tasks.map((task) => ({ task, thread: store.thread(task.id) }))); const transport = activeSessionTransport; if (transport?.sessionManager === sessions && transport.sessionId === activeSessionId() && transport.listener.record) { // Read the existing durable registry, without roots()'s repeated Git validation. // These are recorded contexts, never authority for admission or messaging. const registered = sessions.getEntries().flatMap(entry => { if (entry.type !== "custom" || entry.customType !== SESSION_WORKTREE_ENTRY) return []; const data = entry.data as { sessionId?: string; root?: string; evidence?: string } | undefined; return data?.sessionId === activeSessionId() && typeof data.root === "string" && typeof data.evidence === "string" ? [data.root] : []; }); presence?.updateDiscovery(transport.listener.record, { workspace: sessions.getCwd(), tasks, registered, // A deferred (render-cadence trailing) publish must never resolve Git: // its timer can land while a peer metadata query is being served, and // that contract adds no caller Git probes. It reuses the snapshot the // last event-time publication projected; the next event-time publish // refreshes the facts. scope: deferred ? scopeCache.cachedScope() : scopeCache.project(sessions.getCwd(), tasks, registered), state: stateCache.get(sessions) }); } } catch { presence?.dispose(); presence = undefined; } }; // Coalesced like requestRender: the first call publishes immediately so an // idle→working transition or a resumed session never waits, and every call // inside the window collapses into one trailing publish, so a burst of child // deltas costs one publication instead of one per event. let activityPublishQueued = false; let activityPublishTrailing = false; let cancelActivityPublish: (() => void) | undefined; const resetActivityPublish = () => { cancelActivityPublish?.(); cancelActivityPublish = undefined; activityPublishQueued = false; activityPublishTrailing = false; }; const flushDeferredPublish = () => { cancelActivityPublish = undefined; activityPublishQueued = false; if (!activityPublishTrailing) return; activityPublishTrailing = false; publishActivityNow(true); }; const publishActivity = () => { if (activityPublishQueued) { activityPublishTrailing = true; return; } publishActivityNow(); activityPublishQueued = true; cancelActivityPublish = deps.schedule(flushDeferredPublish, RENDER_COALESCE_MS); }; // Discrete semantic events publish immediately so peers and readers see them // promptly (a consult binds to the published snapshot); the coalescing window // stays armed so a child-delta burst that follows still collapses into one // trailing publish. const publishActivityPrompt = () => { publishActivityNow(); if (!activityPublishQueued) { activityPublishQueued = true; cancelActivityPublish = deps.schedule(flushDeferredPublish, RENDER_COALESCE_MS); } }; const unsubscribeScope = pi.events.on(SESSION_WORKTREE_CHANGED, (event) => { if ((event as { sessionId?: string } | undefined)?.sessionId !== sessions?.getSessionId()) return; scopeCache.clear(); publishActivityPrompt(); }); // The todo list is the session's progress: publish it for peers so the model // spends no turn on orchestrator_session_id. Merged into the owner's curated // state; a publication that cannot be made never fails the todo write. // Counts only: a todo title is private work text, never published to peers. // The last automatic line survives an owner publication that omits `progress`. let automaticProgress: { sessionId: string; text: string } | undefined; const unsubscribeTodoProgress = pi.events.on(TODO_PROGRESS_EVENT, (event) => { const progress = event as Partial | undefined; if (!sessions || progress?.sessionId !== sessions.getSessionId() || typeof progress.done !== "number" || typeof progress.total !== "number") return; const text = `${progress.done}/${progress.total} done`; try { const owned = stateCache.get(sessions); // A state the owner withdrew stays withdrawn until the owner publishes again. if (owned?.state === null) return; const { progress: previous, ...curated } = owned?.state ?? {}; // A cleared list has no progress to report: drop the line, publish nothing new. if (progress.total === 0 && previous === undefined) return; stateCache.publish(sessions, progress.total === 0 ? curated : { ...curated, progress: text }, (type, data) => pi.appendEntry(type, data)); automaticProgress = progress.total === 0 ? undefined : { sessionId: progress.sessionId, text }; publishActivity(); } catch { /* stale session or over the state budget: keep the previous publication */ } }); let worktrees: SessionWorktreeRegistry | undefined; let worktreeManager: ExtensionContext["sessionManager"] | undefined; let worktreeAuthority: (() => boolean) | undefined; const foreignGrants = new ForeignTargetGrants(); const messagingGrants = new SessionMessagingGrants(); const foreignTasks = new Map(); const foreignRequests = new WeakMap(); const registryFor = (ctx: ExtensionContext) => { if (!worktrees || worktreeManager !== ctx.sessionManager || worktrees.sessionId !== ctx.sessionManager.getSessionId()) { worktrees?.close(); worktreeManager = ctx.sessionManager; worktreeAuthority = sessionRepositoryAuthority(ctx.sessionManager.getCwd(), deps.resolveWorktree); worktrees = new SessionWorktreeRegistry(pi, ctx.sessionManager, ctx.sessionManager.getCwd(), deps.resolveWorktree); } return worktrees; }; let collapsed = false; let renderQueued = false; let cancelClock: (() => void) | undefined; const ownedTaskIds = new Set(); // One diagnostic note per (task, guard): a dropped mutation says why once, // not once per file, so a chatty child cannot flood its own thread. const droppedAttributionGuards = new Set(); const stoppingTaskIds = new Set(); const yieldedTaskIds = new Set(); const metricsNow = deps.metricsNow ?? (() => performance.now()); let metricsOwner = {}; const metricTasks = new Map(); const unsubscribeMetrics = pi.events.on(CHILD_METRICS_REVOKED, id => { if (id === activeSessionId()) { metricsOwner = {}; for (const taskId of metricTasks.keys()) runner.discardResponseObservations(taskId); metricTasks.clear(); } }); const clearTaskMetrics = () => { metricsOwner = {}; for (const taskId of metricTasks.keys()) runner.discardResponseObservations(taskId); metricTasks.clear(); }; pi.on("session_start", clearTaskMetrics); pi.on("session_shutdown", () => { clearTaskMetrics(); unsubscribeMetrics(); unsubscribeScope(); unsubscribeTodoProgress(); scopeCache.clear(); }); let stopAllConfirmation: Promise | undefined; // The card and its clock follow the session pi has open right now; a task // started before /new or /resume stays in the store and comes back with // its session. Before the first session_start there is nothing to scope by. const activeSessionId = (): string | undefined => (sessions === undefined ? undefined : sessions.getSessionId() ?? ""); // Pi 0.86.1 adds this event; the package's pinned 0.85.1 types predate it. installBackgroundCacheWarming(pi as unknown as Parameters[0], () => ({ sessionId: activeSessionId(), ownedTaskIds, tasks: store.list(activeSessionId()), })); const visibleTasks = (): TaskRecord[] => store.list(activeSessionId()); type SessionTransport = { generation: number; sessionId: string; sessionManager: ExtensionContext["sessionManager"]; client: SessionTransportClient; listener: SessionTransportListener; registry: SessionTransportRegistry; close(): Promise }; let transportGeneration = 0; let activeSessionTransport: SessionTransport | undefined; const pendingTransportStartups = new Set>(); const validTransportSessionId = (value: unknown): value is string => typeof value === "string" && /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(value); const closeSessionTransport = async (transport: SessionTransport | undefined) => { if (!transport) return; await transport.close(); }; const trackTransportOperation = (operation: Promise) => { pendingTransportStartups.add(operation); operation.then(() => { pendingTransportStartups.delete(operation); }, () => { pendingTransportStartups.delete(operation); }); return operation; }; const startSessionTransport = (ctx: ExtensionContext) => { const previous = activeSessionTransport; const generation = ++transportGeneration; const sessionManager = ctx.sessionManager; activeSessionTransport = undefined; const operation = (async () => { let registry: SessionTransportRegistry | undefined; let listener: SessionTransportListener | undefined; let client: SessionTransportClient | undefined; let closePromise: Promise | undefined; const closeStartupTransport = () => { if (closePromise) return closePromise; closePromise = Promise.resolve().then(async () => { await Promise.allSettled([ (async () => { try { client?.close(); } catch {} })(), (async () => { try { await listener?.close(); } catch {} })(), (async () => { try { if (registry && (!listener || !listener.closesRegistry)) await registry.close?.(); } catch {} })(), ]); }); return closePromise; }; try { if (previous) trackTransportOperation(closeSessionTransport(previous)); const sessionId = sessionManager.getSessionId(); if (!validTransportSessionId(sessionId) || sessions !== sessionManager || generation !== transportGeneration) return; registry = await sessionTransport.createRegistry(agentHome); if (sessions !== sessionManager || generation !== transportGeneration) { await closeStartupTransport(); return; } listener = sessionTransport.createListener(registry, sessionId, async (notification) => { const active = activeSessionTransport; if (!active || active.generation !== generation || active.sessionManager !== sessionManager || active.sessionId !== sessionId || sessions !== sessionManager || activeSessionId() !== sessionId) throw new Error("stale session transport"); pi.sendMessage({ customType: AGENTS_ORCHESTRATOR_MESSAGE_TYPE, content: `Session message from ${notification.senderSessionId} (correlation ${notification.id}): ${notification.message}`, display: true, details: { gentleAgents: { senderSessionId: notification.senderSessionId, recipientSessionId: sessionId, correlationId: notification.id, direction: "incoming", message: notification.message, senderLabel: messagePeerLabel(agentHome, notification.senderSessionId), recipientLabel: sanitizeDisplayLabel(sessionManager.getSessionName?.() ?? "") } } }, { deliverAs: "followUp", triggerTurn: true }); }); client = sessionTransport.createClient(registry, sessionId); if (sessions !== sessionManager || generation !== transportGeneration || activeSessionId() !== sessionId) { await closeStartupTransport(); return; } const transport = { generation, sessionId, sessionManager, client, listener, registry, close: closeStartupTransport }; // The listener deliberately accepts while publishing. Bind its callback // first so a peer accepted in that interval remains current-session work. activeSessionTransport = transport; await listener.start(); if (sessions !== sessionManager || generation !== transportGeneration || activeSessionId() !== sessionId) { if (activeSessionTransport === transport) activeSessionTransport = undefined; await closeStartupTransport(); return; } // Publish immediately, not on the coalescing trailing edge: this is the // session's first discovery publication, and a peer metadata query binds // to the activation only through it — a 400 ms lag here makes a prompt // consult read publication-unknown-or-stale. publishActivityPrompt(); } catch { if (activeSessionTransport?.generation === generation) activeSessionTransport = undefined; await closeStartupTransport(); } })(); trackTransportOperation(operation); return operation; }; const shutdownSessionTransport = async () => { const active = activeSessionTransport; activeSessionTransport = undefined; transportGeneration++; if (active) await closeSessionTransport(active); while (pendingTransportStartups.size > 0) { await Promise.allSettled([...pendingTransportStartups]); } }; const activeTransportFor = (ctx: ExtensionContext) => { const active = activeSessionTransport; const sessionId = ctx.sessionManager.getSessionId(); return active && active.generation === transportGeneration && active.sessionManager === ctx.sessionManager && active.sessionId === sessionId && sessions === ctx.sessionManager ? active : undefined; }; const requestRender = () => { if (renderQueued) return; renderQueued = true; deps.schedule(() => { renderQueued = false; if (sidebarTui) invalidateSidebar(sidebarTui); host?.requestRender(); }, RENDER_COALESCE_MS); }; // The elapsed column ticks once a second while something runs. Once every // task is done, one frame is due when the next finished row leaves the // card, so an idle terminal still sees it clear. const tickClock = () => { cancelClock?.(); cancelClock = undefined; if (!sessions) return; const tasks = visibleTasks(); if (tasks.some((task) => !isFinished(task.status))) { cancelClock = deps.schedule(() => { requestRender(); tickClock(); }, CLOCK_TICK_MS); return; } const expiry = widgetExpiryMs(tasks, deps.now()); if (expiry === undefined) return; cancelClock = deps.schedule(() => { if (sidebarTui) invalidateSidebar(sidebarTui); host?.requestRender(); tickClock(); }, expiry); }; // A finished task goes to disk once, after its child is gone; the history // is then trimmed to the configured size. Failures never reach the TUI. const persist = (task: TaskRecord) => { void saveTask(tasksDir, task, store.thread(task.id)) .then(() => pruneHistory(tasksDir, loadAgentsConfig({ cwd: task.cwd, home: deps.home, agentHome }).historyMaxTasks)) .catch(() => {}); }; // A background result used to be handed straight to the host as a followUp // message, but the host only drains that queue when the parent agent stops // calling tools entirely, so in a long orchestrator run the notification // could land nearly an hour after the parent pulled the same result (#867). // Gentle Agents now owns the pending completions: they settle here, are // flushed at the next turn boundary, and a stale one never re-enters the // conversation. const completions = createCompletionQueue(); const messages = createAgentMessageQueue(); // A job's exit, or a monitor's line events and harness stop, waiting for // the parent delivery router. type JobNotice = { job: JobRecord; kind: "exit" } | MonitorNotice; const jobNotices: JobNotice[] = []; let jobOutputDir: string | undefined; const jobs = createJobRegistry({ outputDir: deps.jobOutputDir ?? (() => (jobOutputDir ??= mkdtempSync(join(os.tmpdir(), "gentle-jobs-")))), now: deps.now, shell: deps.jobShell ?? ((cwd) => { const settings = SettingsManager.create(cwd, agentHome); return { operations: createLocalBashOperations({ shellPath: settings.getShellPath() }), commandPrefix: settings.getShellCommandPrefix() }; }), onSettled: (job) => settleJob(job), }); const monitors = createMonitorController({ registry: jobs, schedule: (fn, ms) => deps.schedule(fn, ms), now: deps.now, deliver: (notice) => queueJobNotice(notice), }); // A parent that sleeps while background work runs only burns the turn: each // completion already arrives as a session message (#1903). bash_background // and monitor stay allowed because they are the sanctioned way to wait. pi.on("tool_call", (event) => { if (event.toolName !== "bash") return undefined; const command = (event.input as { command?: unknown }).command; if (typeof command !== "string" || !isStandaloneSleep(command)) return undefined; const sessionId = activeSessionId(); // Same active-task predicate as publishActivity. const tasks = store.list(sessionId).filter((task) => ownedTaskIds.has(task.id) && !isFinished(task.status) && !restoredTaskIds.has(task.id)).length; const running = jobs.list(sessionId ?? "").filter((job) => job.status === "running").length; if (tasks + running === 0) return undefined; return { block: true, reason: `Standalone sleep is blocked while background work is active (${tasks} task(s), ${running} job(s)). End the turn now; each completion arrives automatically as a session message and starts a new turn.`, }; }); let activeAgentRuns = 0; // ExtensionAPI has no idle probe; ctx.isIdle() is the only one. It is live // and also reports compaction, which activeAgentRuns cannot see. The // session_start context is kept for it and dropped at shutdown; a stale // context throws instead of answering, so delivery fails closed. let parentCtx: ExtensionContext | undefined; const helperPermission = new HelperCostPermission(() => parentCtx && activeTransportFor(parentCtx) ? parentCtx : undefined); pi.on("session_before_switch", () => helperPermission.clear()); pi.on("session_before_fork", () => helperPermission.clear()); pi.on("session_before_tree", () => helperPermission.clear()); pi.on("model_select", () => helperPermission.clear()); pi.on("resources_discover", () => helperPermission.clear()); let bridgeWakeIdentity: BridgeWakeIdentity | undefined; let wakeVisibilityWarning = false; const restoreBridgeWakeIdentity = (ctx: ExtensionContext | undefined) => { bridgeWakeIdentity = undefined; if (!ctx) return; // Rendering is session-wide, not model/branch state: an identity on an // abandoned branch still owns its generated bubbles in the session tree. for (const entry of ctx.sessionManager.getEntries()) { if (entry.type !== "custom" || entry.customType !== BRIDGE_WAKE_IDENTITY_TYPE) continue; const data = entry.data as Partial | undefined; if (data?.sessionId === ctx.sessionManager.getSessionId() && typeof data.nonce === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(data.nonce) && typeof data.text === "string" && isBridgeWakeText(data.nonce, data.text)) { bridgeWakeIdentity = data as BridgeWakeIdentity; break; } } }; const hasWakeTransformer = typeof pi.registerMarkdownTransformer === "function"; if (hasWakeTransformer) { // Register once in this runtime. Reload replaces the runtime; session // replacement changes the identity, never the transformer registration. pi.registerMarkdownTransformer((markdown, context) => context.messageType === "user" && bridgeWakeIdentity?.sessionId === parentCtx?.sessionManager.getSessionId() && bridgeWakeIdentity !== undefined && isBridgeWakeText(bridgeWakeIdentity.nonce, markdown) ? "" : markdown); } const bridgeWake = (key: WakeTextKey): string => { if (!parentCtx) return parentWakeText(key); try { if (!hasWakeTransformer) throw new Error("Markdown transformer API unavailable"); if (!bridgeWakeIdentity || bridgeWakeIdentity.sessionId !== parentCtx.sessionManager.getSessionId()) { const nonce = randomUUID(); const identity = { sessionId: parentCtx.sessionManager.getSessionId(), nonce, text: bridgeWakeText(nonce) }; // Persist before sending so a restart can reconstruct exact ownership. pi.appendEntry(BRIDGE_WAKE_IDENTITY_TYPE, identity); bridgeWakeIdentity = identity; } return bridgeWakeText(bridgeWakeIdentity.nonce, key); } catch { // Compatibility fallback preserves continuation, not invisibility. if (!wakeVisibilityWarning) { wakeVisibilityWarning = true; try { parentCtx.ui.notify("Gentle Agents cannot hide Claude Bridge continuation on this runtime; the generated user wake remains visible.", "warning"); } catch { /* UI failure must not drop continuation. */ } } return parentWakeText(key); } }; // Mirrors the host's agent run, which spans agent_start through // agent_settled, including post-run retries and in-run compaction. Unlike // activeAgentRuns it stays set between agent_end and agent_settled, where // Pi still streams and still drains steering. let parentRunActive = false; // Idle wake-up state. Child content stored while the parent is idle owes a // wake, and one wake covers everything stored before the run it starts. // While a prompt is starting (a dispatched wake, or any prompt seen at // before_agent_start) no second wake is sent: two prompts racing through // Pi's asynchronous pre-run phase can both reach the agent, and the loser // resets the winner's run state. A prompt that never starts a run (handled // by an input handler, or rejected) emits no extension event, so the // starting state expires after PARENT_WAKE_GRACE_MS and an owed wake is // sent then; it never suppresses later wakes for longer than that. let wakeOwed = false; let wakeQueued = false; const pendingWakeKinds = new Set(); let promptStarting = false; let cancelPromptGrace: (() => void) | undefined; let cancelBoundaryFlush: (() => void) | undefined; // An idle parent has no turn boundary coming, so a failed idle delivery or // wake is retried by one bounded timer that runs a full flush. let cancelDeliveryRetry: (() => void) | undefined; let deliveryRetries = 0; const endDeliveryRetry = () => { cancelDeliveryRetry?.(); cancelDeliveryRetry = undefined; }; const armDeliveryRetry = () => { if (cancelDeliveryRetry || deliveryRetries >= PARENT_DELIVERY_RETRY_LIMIT) return; deliveryRetries += 1; cancelDeliveryRetry = deps.schedule(() => { cancelDeliveryRetry = undefined; flushAll(); }, PARENT_WAKE_GRACE_MS); }; const isTaskLive = (id: string): boolean => { const task = store.get(id); return Boolean(task && ownedTaskIds.has(task.id) && !isFinished(task.status)); }; // Hands model-visible child content to the parent session. // // A busy parent gets "steer" + triggerTurn, which keeps delivery bounded to // the current turn: the host polls steering each turn and injects the // message before the next LLM call. "followUp" is NOT acceptable here // because the host drains the follow-up queue only in the run loop's stop // branch, so a parent that keeps calling tools would see the content only // when the whole run ends — the original #867 delay. // // Idle child content is stored durably without a turn, then a separate // coalesced wake requests continuation without repeating that content. // Claude Bridge needs a user wake through the prompt lifecycle for capture; // native providers can use a hidden custom-message turn instead. // // A parent that is busy without a run (compaction, or a prompt's pre-run // compaction) is not streaming, so steer + triggerTurn would also start a // direct turn. Its content stays queued ("hold") until a later boundary. type ParentRoute = "idle" | "run" | "hold"; // Throws for a missing or stale parent context, so delivery fails closed. const parentRoute = (): ParentRoute => { if (!parentCtx) throw new Error("Gentle Agents has no live parent session context"); if (parentCtx.isIdle()) { // The host is authoritative: an idle parent has no run, even if a // lifecycle event was missed. parentRunActive = false; return "idle"; } return parentRunActive ? "run" : "hold"; }; const endPromptStart = () => { promptStarting = false; cancelPromptGrace?.(); cancelPromptGrace = undefined; }; const beginPromptStart = () => { endPromptStart(); promptStarting = true; cancelPromptGrace = deps.schedule(() => { cancelPromptGrace = undefined; promptStarting = false; requestWake(); }, PARENT_WAKE_GRACE_MS); }; // Every wake requested during one synchronous delivery pass is coalesced // into a single dispatch after it, so the wake follows all stored content. const requestWake = () => { if (!wakeOwed || wakeQueued || promptStarting) return; wakeQueued = true; queueMicrotask(dispatchWake); }; const dispatchWake = () => { wakeQueued = false; if (!wakeOwed || promptStarting) return; let route: ParentRoute; try { route = parentRoute(); } catch { return; } // A run in progress already carries the stored content; a parent busy // without a run keeps the wake owed until a later boundary flush. if (route !== "idle") return; wakeOwed = false; const kinds = new Set(pendingWakeKinds); pendingWakeKinds.clear(); endDeliveryRetry(); beginPromptStart(); try { // "steer" matters only when a run started in between: the wake is then // queued into it instead of being rejected as a concurrent prompt. // Read the live selection at dispatch, not when child content arrived. // Only Claude Bridge is currently evidenced to require prompt capture; // registering a custom provider alone does not make it a bridge. if (parentCtx?.model?.provider === "claude-bridge") { pi.sendUserMessage(bridgeWake(wakeTextKey(kinds)), { deliverAs: "steer" }); } else { pi.sendMessage({ customType: PARENT_WAKE_TYPE, content: nativeParentWakeText(kinds), display: false, }, { deliverAs: "steer", triggerTurn: true }); } deliveryRetries = 0; } catch { // A stale runtime fails closed instead of throwing from a microtask. // The stored content still needs a turn, so the wake stays owed and is // retried once per grace, a bounded number of times in a row. endPromptStart(); wakeOwed = true; for (const kind of kinds) pendingWakeKinds.add(kind); armDeliveryRetry(); } }; const sendToParent = ( message: Parameters[0], route: Exclude, kind: WakeDeliveryKind = "subagent", ) => { if (route === "run") { pi.sendMessage(message, { deliverAs: "steer", triggerTurn: true }); return; } pi.sendMessage(message, { triggerTurn: false }); wakeOwed = true; pendingWakeKinds.add(kind); requestWake(); }; const deliver = (task: TaskRecord, route: Exclude) => { // Ownership is consulted at delivery time, matching onNotification and // onQuery: a completion owned by another session is dropped, not delivered. if (activeSessionId() !== task.parentSessionId) return; sendToParent({ customType: AGENTS_RESULT_TYPE, content: completionText(task), display: true, details: taskDetails(task) }, route); }; // A stale report must not re-enter the LLM conversation, but the parent is // told never to poll: it gets a compact model-facing notice naming the task, // and the human gets durable TUI-only content. const deliverStale = (task: TaskRecord, settledAt: number, route: Exclude) => { if (activeSessionId() !== task.parentSessionId) return; const ageSeconds = Math.max(0, Math.round((deps.now() - settledAt) / 1000)); const status = task.status.replace("_", " "); sendToParent({ customType: AGENTS_STALE_NOTICE_TYPE, content: `Subagent ${task.agent} (task ${task.id}, "${task.label}") ${status} about ${ageSeconds}s ago while you were busy. Its report was not replayed here; call subagent_result with task_id ${task.id} to read it.`, display: false, details: taskDetails(task), }, route); // The notice already reached the parent; a failed card must not resend it. try { pi.appendEntry(AGENTS_STALE_RESULT_TYPE, { taskId: task.id, agent: task.agent, label: task.label, status: task.status, ageSeconds }); } catch { /* TUI-only content. */ } }; const deliverMessage = (msg: PendingAgentMessage, route: Exclude) => { if (activeSessionId() !== msg.parentSessionId) return; sendToParent({ customType: AGENTS_MESSAGE_TYPE, content: msg.content, display: msg.display, details: msg.details }, route); }; // Child content leaves its queue only when the parent can receive it. A // held or undeliverable flush leaves everything queued for a later boundary; // `rethrow` lets a query report a stale parent back to its child. const deliveryRoute = (rethrow: boolean): Exclude | undefined => { try { const route = parentRoute(); return route === "hold" ? undefined : route; } catch (error) { if (rethrow) throw error; return undefined; } }; const flushMessages = (rethrow = false) => { const route = deliveryRoute(rethrow); if (!route) return; for (const msg of messages.takeDeliverable(deps.now(), activeSessionId() ?? "", isTaskLive)) { try { deliverMessage(msg, route); } catch (error) { if (rethrow) throw error; /* Best-effort delivery: at most once, even if forwarding fails. */ } } }; const flushCompletions = () => { const route = deliveryRoute(false); if (!route) return; for (const entry of completions.takeDeliverable(deps.now())) { try { if (entry.stale) deliverStale(entry.task, entry.settledAt, route); else deliver(entry.task, route); } catch { // The parent is told never to poll, so a failed forward must not // lose the completion: it waits for the next boundary instead, and // an idle parent, which has none coming, gets a bounded retry. completions.requeue(entry); if (route === "idle") armDeliveryRetry(); } } }; // A background job's exit notice rides the same router as a completion. It // is compact (no full output), so it has no stale form; a failed forward // requeues it like a completion does. const flushJobNotices = () => { const route = deliveryRoute(false); if (!route) return; const failed: JobNotice[] = []; for (const notice of jobNotices.splice(0)) { const { job } = notice; if (activeSessionId() !== job.ownerSessionId) continue; const content = notice.kind === "events" ? monitorEventsText(job, notice.batch) : notice.kind === "stopped" ? monitorStoppedText(job, notice.reason) : jobNoticeText(job); const event = notice.kind === "events" ? "events" : notice.kind === "stopped" ? notice.reason : undefined; const kind: WakeDeliveryKind = notice.kind === "events" || notice.kind === "stopped" ? "monitor" : "job"; try { sendToParent({ customType: JOB_NOTICE_TYPE, content, display: true, details: jobDetails(job, event) }, route, kind); } catch { failed.push(notice); } } if (failed.length === 0) return; jobNotices.unshift(...failed); if (route === "idle") armDeliveryRetry(); }; const flushAll = () => { flushMessages(); flushCompletions(); flushJobNotices(); // A wake left owed by an earlier held or expired attempt is retried here. requestWake(); }; // Pi still reports compaction while these handlers run (a manual // session_compact, an automatic compaction's session_compact_failed), so // held content is flushed once on the next scheduler tick instead. A parent // still busy then keeps it held for the next boundary; nothing polls. const scheduleBoundaryFlush = () => { if (cancelBoundaryFlush) return; cancelBoundaryFlush = deps.schedule(() => { cancelBoundaryFlush = undefined; flushAll(); }, 0); }; // Session changes discard every pending wake and boundary flush. const resetParentDelivery = (ctx: ExtensionContext | undefined) => { helperPermission.clear(); // Revoke before dropping old callback authority. parentCtx = ctx; restoreBridgeWakeIdentity(ctx); wakeVisibilityWarning = false; parentRunActive = false; wakeOwed = false; pendingWakeKinds.clear(); endPromptStart(); cancelBoundaryFlush?.(); cancelBoundaryFlush = undefined; endDeliveryRetry(); deliveryRetries = 0; }; // Structured descriptions feed Status; native hosts keep the compact count. const refreshJobStatus = () => { const sessionId = activeSessionId(); const running = jobs.list(sessionId ?? "").filter((job) => job.status === "running") .sort((a, b) => b.startedAt - a.startedAt); try { parentCtx?.ui.setStatus(JOBS_STATUS_KEY, running.length === 0 ? undefined : `${JOB_GLYPH} ${running.length} job${running.length === 1 ? "" : "s"}`); } catch { /* Status is cosmetic. */ } if (sessionId) { try { pi.events.emit(JOBS_SIDEBAR_EVENT, { sessionId, snapshot: { jobs: running.map(({ id, label, startedAt }) => ({ id, label, startedAt })) } }); } catch { /* Display only. */ } } }; // A job exit settles like a completion below: flushed at once for an idle // parent, at the next turn boundary for a busy one. // Line events still waiting for a busy parent coalesce per monitor. const queueJobNotice = (notice: JobNotice) => { // Merge into this monitor's latest pending notice when that is still // an events notice, so events never move past its own end. let index = -1; for (let i = jobNotices.length - 1; i >= 0; i--) if (jobNotices[i]!.job === notice.job) { index = i; break; } const pending = index >= 0 ? jobNotices[index]! : undefined; if (notice.kind === "events" && pending?.kind === "events") { jobNotices[index] = { ...pending, batch: mergeMonitorBatches(pending.batch, notice.batch) }; } else { jobNotices.push(notice); } refreshJobStatus(); requestRender(); if (activeAgentRuns === 0) flushAll(); }; const settleJob = (job: JobRecord) => { // A monitor's pending lines are delivered before its end. if (job.kind === "monitor") monitors.finish(job); queueJobNotice({ job, kind: "exit" }); }; // A completion settles into our queue. An idle parent flushes right away so // the wake-up behavior is unchanged; a busy parent flushes at the next turn // boundary, and the steer mode injects it before that turn's next LLM call // instead of parking it behind the whole run. const settleCompletion = (task: TaskRecord) => { messages.invalidateTask(task.id); completions.enqueue(task, deps.now()); if (activeAgentRuns === 0) flushAll(); }; // `agent_start`/`agent_end` bracket a parent agent run; `turn_end` fires at // every turn boundary inside one, so with steering delivery a held // completion is injected before the next LLM call and never outlives the // current turn. `agent_end` stays a flush trigger for runs that end without // a final `turn_end` (an aborted run, or the host's early post-run return // when a run produced no assistant message; the host compensates via // hasQueuedMessages() + continue(), so steering there is still bounded). // `agent_settled` is the final idle boundary after retries: it ends the // host run and flushes anything held, including content held while a // compaction ran inside or right before the run. `before_agent_start` // marks a prompt that is about to start a run, and the compaction events // release content held while the parent compacted without a run. pi.on("before_agent_start", () => { if (!parentRunActive) beginPromptStart(); }); pi.on("agent_start", () => { activeAgentRuns += 1; parentRunActive = true; // The run carries everything stored while the parent was idle. wakeOwed = false; pendingWakeKinds.clear(); endDeliveryRetry(); deliveryRetries = 0; endPromptStart(); }); pi.on("agent_end", () => { activeAgentRuns = Math.max(0, activeAgentRuns - 1); flushAll(); }); pi.on("agent_settled", () => { parentRunActive = false; endPromptStart(); flushAll(); }); pi.on("turn_end", () => flushAll()); pi.on("session_compact", scheduleBoundaryFlush); pi.on("session_compact_failed", scheduleBoundaryFlush); const runner = new AgentRunner(store, loadAgentsConfig({ cwd: process.cwd(), home: deps.home, agentHome }), deps, { askUser: (_taskId, ask, raw) => answerThroughUi(ui, ask, raw), onNotification: (task, message) => { if (activeSessionId() !== task.parentSessionId || isFinished(task.status)) return false; messages.enqueueNotification(task, message, deps.now()); if (activeAgentRuns === 0) flushMessages(); return true; }, onQuery: (task, requestId, message) => { if (activeSessionId() !== task.parentSessionId || isFinished(task.status)) return false; const hadYield = yieldedTaskIds.has(task.id); if (task.mode === AGENT_MODE.TASK) yieldedTaskIds.add(task.id); try { messages.enqueueQuery(task, requestId, message, deps.now()); if (activeAgentRuns === 0) flushMessages(true); return true; } catch (error) { messages.expireQuery(task.id, requestId); if (task.mode === AGENT_MODE.TASK && !hadYield) yieldedTaskIds.delete(task.id); throw error; } }, onQuerySettled: (taskId, requestId, outcome) => { if (outcome === "replied") messages.consumeQuery(taskId, requestId); else messages.expireQuery(taskId, requestId); }, onSuccessfulMutation: (task, tool) => { // Same-clone registry attribution remains unchanged. A foreign task // uses a separately bound, live-grant path only for successful tool evidence. let root: string | undefined; let childRoot: string | undefined; const noteDrop = (guard: string) => { const key = `${task.id}:${guard}`; if (droppedAttributionGuards.has(key)) return; droppedAttributionGuards.add(key); try { store.apply(task.id, { type: TASK_EVENT.NOTE, text: `changes not attributed: ${guard} (root=${root ?? "unknown"}, child=${childRoot ?? "unknown"})` }, deps.now()); } catch { /* A note is best-effort explanation; it must never block the guard it is explaining. */ } }; if (!sessions || !worktrees) return noteDrop("session-inactive"); if (task.parentSessionId !== activeSessionId()) return noteDrop("parent-session-mismatch"); if (!ownedTaskIds.has(task.id)) return noteDrop("not-owned"); root = deps.resolveWorktree(tool.path, task.cwd)?.root; childRoot = deps.resolveWorktree(task.cwd, task.cwd)?.root; if (!root) return noteDrop("root-unresolved"); if (root !== childRoot) return noteDrop("root-mismatch"); const foreignTask = foreignTasks.get(task.id); if (foreignTask) { if (sessions !== foreignTask.manager || root !== foreignTask.root || tool.evidence?.root !== root) return noteDrop("foreign-identity-mismatch"); const identity = resolveSessionWorktree(root, root); if (!identity || identity.commonDir !== foreignTask.commonDir) return noteDrop("foreign-identity-drift"); try { foreignGrants.assertCurrent({ sessionManager: sessions }, identity); } catch { return noteDrop("foreign-grant-lost"); } } else if (!worktrees.roots().includes(root)) return noteDrop("root-not-registered"); if (!tool.evidence) { noteDrop("evidence-missing"); } else if (tool.evidence.root !== root) { noteDrop("evidence-root-mismatch"); } else { let path = tool.path.replace(/^@/, "").replace(/[\u00a0\u2000-\u200a\u202f\u205f\u3000]/g, " "); if (path === "~" || path.startsWith("~/")) path = os.homedir() + path.slice(1); let resolvedPath: string | undefined; try { resolvedPath = realpathSync(resolve(task.cwd, path)); } catch { noteDrop("evidence-path-unreadable"); } if (resolvedPath === resolve(root, tool.evidence.path)) { const evidence = { ...tool.evidence, id: `${task.id}:${tool.toolCallId}` }; if (foreignTask) publishForeignSessionChange(pi, task.parentSessionId, evidence); else pi.events.emit(SESSION_CHANGE_RELAY, { sessionId: task.parentSessionId, evidence }); } else if (resolvedPath !== undefined) noteDrop("evidence-path-mismatch"); } if (!foreignTask) recordReviewMutation(pi, sessions, root, { source: "subagent", taskId: task.id, toolName: tool.toolName, toolCallId: tool.toolCallId, ...runtimeWriterProfile(task) }); }, onFinish: (task, observations) => { // Completion is the only forwarding opportunity. No pending event, policy // query or promise survives this callback; the receiver drops when busy. const { id, parentSessionId, status } = task; const metrics = metricTasks.get(id); metricTasks.delete(id); // Deliver at most once, even if forwarding fails. try { const authorized = metrics?.valid(); if (metrics) metrics.finished = true; if (authorized && metrics?.launched && metrics.selection && observations) { const event = childEvent(parentSessionId, id, metrics.selection, status, observations, metrics.started); if (event && metrics.current()) pi.events.emit(CHILD_METRICS_EVENT, event); } } catch { /* Metrics must never interrupt task finalization. */ } try { ownedTaskIds.delete(task.id); messages.invalidateTask(task.id); requestRender(); persist(task); const yielded = yieldedTaskIds.delete(task.id); if ((task.mode === AGENT_MODE.BACKGROUND && task.status !== TASK_STATUS.CANCELLED) || (yielded && task.status !== TASK_STATUS.CANCELLED && activeSessionId() === task.parentSessionId)) settleCompletion(task); } catch { /* Best-effort completion bookkeeping cannot strand runner waiters. */ } }, }); registerBackgroundJobTools(pi, { registry: jobs, monitors, sessionId: (ctx) => ctx.sessionManager.getSessionId() ?? "", now: deps.now, onChange: () => { refreshJobStatus(); requestRender(); } }); pi.registerMessageRenderer(AGENTS_MESSAGE_TYPE, (message, options, theme) => { const details = (message.details as { gentleAgents?: { taskId?: unknown; agent?: unknown } } | undefined)?.gentleAgents; const taskId = typeof details?.taskId === "string" ? details.taskId : "unknown"; const agent = typeof details?.agent === "string" ? details.agent : "Subagent"; const heading = `${sanitizeTerminalText(agent)} message · Task ${sanitizeTerminalText(taskId)}`; const body = sanitizeTerminalText(messageText(message.content)); return new Text(`${theme.fg("customMessageLabel", heading)}\n${theme.fg("customMessageText", body)}`, options.outputPad, 0); }); pi.registerMessageRenderer(AGENTS_ORCHESTRATOR_MESSAGE_TYPE, (message, options, theme) => { const details = (message.details as { gentleAgents?: OrchestratorMessageDetails } | undefined)?.gentleAgents ?? {}; return incomingMessageCard(details, messageText(message.content), options.expanded, theme, expandHint(options.expanded)); }); pi.registerMessageRenderer(AGENTS_RESULT_TYPE, (message, options, theme) => { const details = (message.details as { gentleAgents?: { agent?: string; status?: string } } | undefined)?.gentleAgents; const content = message.content as string | Array<{ type: string; text?: string }>; const text = typeof content === "string" ? content : content.map((part) => (part.type === "text" ? (part.text ?? "") : "")).join("\n"); const body = options.expanded ? text.split("\n") : agentResultPreview(text).split("\n").filter((line) => line.trim() !== ""); const tone = details?.status === "completed" ? CARD_TONE.SUCCESS : CARD_TONE.ERROR; const hint = expandHint(options.expanded); return { render(width: number) { return renderCard({ title: "Agent result", subtitle: details?.agent, body, tone, glyph: AGENTS_GLYPH }, theme, width, { expanded: options.expanded, previewRows: 3, hint }); }, invalidate() {}, }; }); // A stale completion is appended as a custom entry: durable transcript // content for the human that never participates in the LLM context. pi.registerEntryRenderer(AGENTS_STALE_RESULT_TYPE, (entry, options, theme) => { const data = (entry.data ?? {}) as { taskId?: unknown; agent?: unknown; label?: unknown; status?: unknown; ageSeconds?: unknown }; const taskId = typeof data.taskId === "string" ? data.taskId : "unknown"; const agent = typeof data.agent === "string" ? data.agent : "Subagent"; const label = typeof data.label === "string" ? data.label : ""; const status = typeof data.status === "string" ? data.status.replace("_", " ") : "unknown"; const ageSeconds = typeof data.ageSeconds === "number" && Number.isFinite(data.ageSeconds) ? Math.max(0, Math.round(data.ageSeconds)) : 0; const age = ageSeconds < 90 ? `${ageSeconds}s` : ageSeconds < 3600 ? `${Math.round(ageSeconds / 60)}m` : `${Math.round(ageSeconds / 3600)}h`; const body = [ `Subagent ${sanitizeTerminalText(agent)} (task ${sanitizeTerminalText(taskId)}, "${sanitizeTerminalText(label)}") ${sanitizeTerminalText(status)} about ${age} ago, while the orchestrator was still busy.`, "Marked stale: the result was not replayed into the conversation. It stays available through subagent_status and subagent_result.", ]; return { render(width: number) { return renderCard({ title: "Stale agent result", subtitle: `${agent} · task ${taskId}`, body, tone: CARD_TONE.WARNING, glyph: AGENTS_GLYPH }, theme, width, { expanded: options.expanded, previewRows: 3, hint: expandHint(options.expanded) }); }, invalidate() {}, }; }); const isOwnedActive = (task: TaskRecord | undefined): task is TaskRecord => task !== undefined && ownedTaskIds.has(task.id) && !isFinished(task.status); const stopSelected = async (task: TaskRecord, ctx: ExtensionContext): Promise => { const selected = store.get(task.id); if (!isOwnedActive(selected)) return; if (selected.status === TASK_STATUS.QUEUED) { if (runner.cancel(selected.id, "stopped from the agents panel")) ctx.ui.notify(`Stopped ${selected.agent}.`); else ctx.ui.notify(`Task ${selected.agent} already finished.`, "warning"); return; } if (stoppingTaskIds.has(selected.id)) return; stoppingTaskIds.add(selected.id); try { const message = selected.status === TASK_STATUS.WAITING ? "Its pending question will be dismissed." : "Current work may be incomplete."; if (!await ctx.ui.confirm(`Stop ${selected.agent}?`, message)) return; const current = store.get(selected.id); if (!isOwnedActive(current)) { ctx.ui.notify(`Task ${selected.agent} already finished.`, "warning"); return; } if (runner.cancel(current.id, "stopped from the agents panel")) ctx.ui.notify(`Stopped ${current.agent}.`); else ctx.ui.notify(`Task ${current.agent} already finished.`, "warning"); } finally { stoppingTaskIds.delete(selected.id); } }; const stopAll = (ctx: ExtensionContext): Promise => { if (stopAllConfirmation) return stopAllConfirmation; const active = store.list().filter(isOwnedActive); if (active.length === 0) { ctx.ui.notify("No active subagents to stop."); return Promise.resolve(); } const count = active.length; const noun = count === 1 ? "subagent" : "subagents"; const confirmation = (async () => { try { if (!await ctx.ui.confirm(`Stop ${count} active ${noun}?`, `Only these ${count} ${noun} will stop. Current work may be incomplete.`)) return; const cancelled = active.filter((task) => runner.cancel(task.id, "stopped from the agents panel (stop all)")).length; ctx.ui.notify(`Stopped ${cancelled} ${cancelled === 1 ? "subagent" : "subagents"}.`); } finally { stopAllConfirmation = undefined; } })(); stopAllConfirmation = confirmation; return confirmation; }; // Tasks from earlier sessions come back from disk on demand. const resolveTask = async (id: string): Promise => { const live = store.get(id); if (live) return live; const stored = await loadStoredTask(tasksDir, id); if (stored) { restoredTaskIds.add(stored.task.id); store.restore(stored.task, stored.thread); } return stored?.task; }; // A guessed id ("1") leads back to real ids instead of a dead end, so the // parent retries the same call rather than re-summarizing it (gentle-shell#1713). const unknownTask = (id: unknown, ctx: ExtensionContext | undefined) => { const recent = [...store.list(ctx?.sessionManager?.getSessionId() ?? "")].sort((a, b) => b.createdAt - a.createdAt).slice(0, 5); const hint = recent.length ? ` Recent task ids: ${recent.map((task) => `${task.id} (${task.agent})`).join(", ")}.` : ""; return text(`Error: no task ${String(id)}.${hint}`, { error: "unknown task" }); }; // Restores this exact session's own finished subagents as visible history // on an explicit resume, or on startup into a session that already has // entries -- never on new, fork, or reload (see the session_start handler's // preexisting check below, which is the actual gate). Marked in // restoredTaskIds like any other disk restoration, so it stays // non-cancellable (ownedTaskIds never gained the id either way) and is // skipped if the id is somehow already live. const restoreSessionHistory = async (ctx: ExtensionContext, sessionId: string): Promise => { let history: Awaited>; try { history = await loadHistory(tasksDir); } catch { return; } // Fire-and-forget from session_start: a stale SDK context or throwing // summary subscriber must never surface as an unhandled rejection. try { // The session may have moved on while disk was read; a stale restore // must never land in the wrong session's store. if (ctx.sessionManager.getSessionId() !== sessionId) return; for (const { task, thread } of history) { if (task.parentSessionId !== sessionId) continue; if (store.restore(task, thread)) restoredTaskIds.add(task.id); } } catch { /* Partial history is acceptable; the live session keeps running. */ } }; const openOverlay = async (ctx: ExtensionContext) => { if (!ctx.hasUI) return; if (ctx.mode !== "tui") { ctx.ui.notify("The agents overlay requires TUI mode.", "warning"); return; } let view: AgentsView | undefined; let overlayHost: { requestRender(force?: boolean): void; stop(): void; start(): void } | undefined; const chosen = await ctx.ui.custom( (tui, theme, _keybindings, done) => { const close = withOverlayRepaint(tui, done); overlayHost = tui; view = new AgentsView({ theme, rows: () => Math.max(0, tui.terminal.rows), store, sessionId: ctx.sessionManager.getSessionId() ?? "", presence: { profile: agentHome, get target() { return presence?.target; }, }, now: () => deps.now(), onCancel: (task) => void stopSelected(task, ctx), canCancel: isOwnedActive, isLocalTask: (task) => !restoredTaskIds.has(task.id), onOpen: (task) => close(task), onClose: () => close(null), requestRender: () => tui.requestRender(), }); overlays.add(view); const interaction = createNativeFullscreenInteraction({ keyboardTarget: view, requestRender: () => tui.requestRender(), mouseObserver: view.mouseObserver(), }); interaction.addChild(view); return interaction; }, { overlay: true, overlayOptions: { width: "100%", maxHeight: "100%", margin: 0, anchor: "center" } }, ).finally(() => { view?.dispose(); if (view) overlays.delete(view); }); if (!chosen || !overlayHost) return; if (!chosen.sessionPath) { ctx.ui.notify("This task has no session file yet.", "warning"); return; } // The child's session is JSONL; the reader gets a markdown transcript. let transcriptPath: string; try { transcriptPath = await writeTranscript(chosen); } catch (error) { ctx.ui.notify(`Could not read the task's session: ${error instanceof Error ? error.message : String(error)}`, "warning"); return; } if (!openInExternalEditor(overlayHost, transcriptPath)) ctx.ui.notify("No editor configured. Set $VISUAL or $EDITOR.", "warning"); }; const writeTranscript = async (task: TaskRecord): Promise => { const dir = agentRuntimePaths(deps.home, agentHome).transcripts; await mkdir(dir, { recursive: true }); const markdown = sessionToMarkdown(await readFile(task.sessionPath ?? "", "utf8"), { title: `${task.agent} · ${task.label} · ${task.status}` }); const path = join(dir, `${task.id}.md`); await writeFile(path, markdown, "utf8"); return path; }; // A status change is worth a frame right away; deltas inside a task are // coalesced so a chatty child cannot flood the terminal. store.subscribeSummary(() => { publishActivity(); if (sidebarTui) invalidateSidebar(sidebarTui); host?.requestRender(); tickClock(); }); const showWidget = (ctx: ExtensionContext) => { ui = ctx.hasUI ? ctx.ui : undefined; sessions = ctx.sessionManager; agentsVisible = resolveVisualSettings({ gentlePiConfigHome: visualHome }).settings.visibility.agents; stopVisualUpdates?.(); stopVisualUpdates = pi.events.on(VISUAL_SETTINGS_CHANGED, (event) => { if ((event as { configHome?: unknown } | undefined)?.configHome !== visualHome) return; agentsVisible = resolveVisualSettings({ gentlePiConfigHome: visualHome }).settings.visibility.agents; if (sidebarTui) invalidateSidebar(sidebarTui); host?.requestRender(); }); tickClock(); // Agents follows Todos: the fullscreen rail shows it when the terminal is // wide enough, and sidebarPart suppresses the above-editor copy only // while the rail owns the host; narrower terminals keep the widget. ui?.setWidget(AGENTS_WIDGET_KEY, (tui, theme) => { host = tui; sidebarTui = tui; return sidebarPart(tui, "agents", agentsCard(tui, theme, false), agentsCard(tui, theme, true)); }); }; const toggleCollapsed = () => { collapsed = !collapsed; if (sidebarTui) invalidateSidebar(sidebarTui); host?.requestRender(); }; // One surface of the Agents card. The rail is natively scrollable, so it // lists every task; the above-editor widget keeps its row budget. Only the // header row is the clickable collapse control, exactly like Todos. const agentsCard = (tui: TUI, theme: Parameters[1], rail: boolean): Component & { dispose(): void; digest(): string } => { let hovered = false; let headerRow = 0; const card: Component = { render(width: number) { if (!agentsVisible) return []; const tasks = visibleTasks(); const now = deps.now(); headerRow = panelHeaderRow(theme, width, agentsCardTone(tasks, now)); const lines = renderAgentsCard(tasks, theme, width, now, { collapsed, collapseKey, maxRows: rail ? undefined : widgetRows(tui.terminal?.rows), viewKey, collapseControl: true, hovered }); return lines.length === 0 || rail ? lines : [...lines, ""]; }, invalidate() { hovered = false; }, }; const region = new NativePointerRegion(card, { onHover(event) { const next = event.y === headerRow; if (next === hovered) return { handled: true }; hovered = next; return { handled: true, render: true }; }, onLeave() { if (!hovered) return; hovered = false; host?.requestRender(); }, onClick(event) { if (event.button !== "left" || event.y !== headerRow) return undefined; toggleCollapsed(); return { handled: true, render: true }; }, }); return { // The rail memo repaints a part only when its digest moves: task // state, the local controls, and the elapsed clock (to the second) // only while a task is still running, so an idle or finished card // never forces a rail rebuild every second. digest: () => { const tasks = visibleTasks(); const clock = tasks.some((task) => !isFinished(task.status)) ? Math.floor(deps.now() / 1000) : 0; return JSON.stringify([agentsVisible, collapsed, hovered, tui.terminal?.rows, clock, tasks.map((task) => [task.id, task.status, task.tokens, task.cost, task.label, task.lastStep, task.error])]); }, render: (width) => region.render(width), handleMouse: (event) => region.handleMouse(event), invalidate: () => region.invalidate(), dispose: () => region.dispose(), }; }; const roots = (ctx: ExtensionContext) => ({ cwd: ctx.sessionManager.getCwd(), home: deps.home, agentHome }); // Issue #2400: parent sessions already told about model_profiles keys that // match no agent and suggest none, so the notice fires once per session. const unmatchedKeysNotifiedSessions = new Set(); const buildRequest = async (ctx: ExtensionContext, agent: AgentDefinition, prompt: string, label: string | undefined, context: string | undefined, mode: AgentMode, resume?: string, workspaceRoot?: string, signal?: AbortSignal, repositoryRoot?: string): Promise => { if (retiredSddAgent(agent.name)) throw new Error("Retired SDD agents cannot be dispatched."); if (![AGENT_MODE.TASK, AGENT_MODE.BACKGROUND].includes(mode)) throw new Error("Subagent mode must be task or background."); if (isSingleShotMode(ctx.mode) && mode === AGENT_MODE.BACKGROUND) throw new Error(SINGLE_SHOT_BACKGROUND_ERROR); const scopeDenied = rejectUnscopedBoundedWriterDispatch({ agent: agent.name, task: prompt, context }); if (scopeDenied) throw new Error(scopeDenied.reason); if (signal?.aborted) throw new Error("Subagent launch aborted before authorization."); const registry = registryFor(ctx); const authorityCurrent = worktreeAuthority!; const originalManager = ctx.sessionManager; const originalId = originalManager.getSessionId(); const originalCwd = originalManager.getCwd(); const current = () => sessions === originalManager && originalManager.getSessionId() === originalId && originalManager.getCwd() === originalCwd && !signal?.aborted && authorityCurrent(); if (isGenericBoundedWriter(agent.name) && !current()) throw new Error("Writer session Git authority changed before admission."); let admittedModel: string | undefined; const surfaces = allowedEditSurfaces(prompt, context); // gentle-shell#1064 slice 2: the session profile is resolved once per task // request, before admission, so the admitted model and the launch routing // resolve the same session layer and can never disagree about it (#1558: a // bound session used to kill its own non-git writer mid-preparation because // admission still read only the pin/global layers). Slice 3b-ii: the same // read covers the frozen inherited profile and `follow` mode. const profileConfigHome = gentlePiConfigHome(deps.env); const sessionProfile = resolveSessionProfile({ sessionId: ctx.sessionManager.getSessionId(), cwd: originalCwd, configHome: profileConfigHome, resolveWorktree: deps.resolveWorktree, env: deps.env, }); if (!resume && isGenericBoundedWriter(agent.name) && surfaces?.some(isDevelopmentSurface) && !deps.resolveWorktree(originalCwd, originalCwd) && repositoryRoot === undefined) { const root = safeBootstrapDirectory(originalCwd); if (!root || (workspaceRoot !== undefined && (!isAbsolute(workspaceRoot) || safeBootstrapDirectory(workspaceRoot) !== root))) throw new Error("Writer bootstrap requires the original safe project root."); const config = withPinnedModelProfiles( loadAgentsConfig(roots(ctx)), sessionProfileRoutingAt(sessionProfile, { cwd: root, configHome: profileConfigHome, resolveWorktree: deps.resolveWorktree }), ); const model = resolveAgentProfile(agent, config).model ?? ctx.model; const catalogModel = model?.provider ? ctx.modelRegistry?.find(model.provider, model.id) : ctx.modelRegistry?.getAll().find(candidate => candidate.id === model?.id); if (!catalogModel) throw new Error("Writer bootstrap requires a valid effective model in this session's catalog."); admittedModel = `${catalogModel.provider}/${catalogModel.id}`; if (!current() || !await prepareBoundSessionRepository(originalManager, originalCwd, signal) || !current() || safeBootstrapDirectory(originalCwd) !== root || resolveSessionWorktree(originalCwd, originalCwd)?.root !== root) throw new Error("Writer repository preparation was unavailable or its session/target changed."); } const parentCwd = ctx.sessionManager.getCwd(); // An explicit target is validated before any queue or session-dir writes. const parentIdentity = deps.resolveWorktree(parentCwd, parentCwd); if (repositoryRoot !== undefined && workspaceRoot !== undefined) throw new Error("repository_root and workspace_root are mutually exclusive."); if (repositoryRoot !== undefined && (deps.env.GENTLE_PI_AGENTS_CHILD === "1" || ctx.mode !== "tui" || !ctx.hasUI)) throw new Error("Foreign repository launch requires an interactive parent session."); const selectedRoot = repositoryRoot ?? workspaceRoot; // Preserve ordinary non-Git continuation, without admitting any new root. const sameNonGitContinuation = resume !== undefined && repositoryRoot === undefined && selectedRoot === parentCwd && !parentIdentity; let foreign = false; let foreignIdentity: { root: string; commonDir: string } | undefined; let foreignParent: { root: string; commonDir: string } | undefined; let target: string | undefined; if (selectedRoot !== undefined && !sameNonGitContinuation) { if (repositoryRoot === undefined) target = registry.validate(selectedRoot); else { // Only an explicitly selected canonical foreign Git root can escape the // same-clone registry. Never use a caller-injected resolver for this identity. const identity = resolveSessionWorktree(selectedRoot, parentCwd); const canonicalParent = resolveSessionWorktree(parentCwd, parentCwd); const spelling = nativeRootSpelling(selectedRoot); if (!identity || (canonicalParent && identity.commonDir === canonicalParent.commonDir) || spelling !== identity.root || !isAbsolute(spelling) || realpathSync(spelling) !== spelling) throw new Error("repository_root must select a canonical independent Git repository."); await foreignGrants.authorize(ctx, identity, { continuation: resume !== undefined, signal }); foreignIdentity = identity; foreignParent = canonicalParent; target = identity.root; foreign = true; } } else target = parentIdentity?.root; // A per-repository profile pin replaces subagent routing for this launch without // materialising anything: the global stores stay untouched, so two repositories // worked in parallel stop fighting over one global routing. The child's own // worktree is the repository in question, and the local pin sits in the shared // Git common directory, so it survives every worktree of the clone. When the // launch stays in the session's own worktree the identity already resolved above // is reused instead of asking Git a second time. The orchestrator is out of // scope: only `modelProfiles` is replaced. const pinIdentity: WorktreeResolver = foreign ? resolveSessionWorktree : target !== undefined && parentIdentity !== undefined && target === parentIdentity.root ? () => parentIdentity : deps.resolveWorktree; // gentle-shell#1064: the session profile outranks the shared defaults for // launches from that session (`session → frozen p → P → G`), with the same // wholesale-replacement contract as the pin. Explicit and frozen profiles // belong to the session and apply to every target, a foreign repository // included; only `follow` reads the defaults of the target. The profile is // resolved here, at task-request creation, so queued and running children // keep the routing frozen into their requests even if the session rebinds. // The routing target this launch reads (#1064), reused to name the winning // layer (#2180) so both answers come from one resolution. const routingTarget = { cwd: target ?? parentCwd, configHome: profileConfigHome, resolveWorktree: pinIdentity, foreignRepository: foreign, }; const config = withPinnedModelProfiles( loadAgentsConfig(roots(ctx)), sessionProfileRoutingAt(sessionProfile, routingTarget), ); // Drift is measured against the defaults of the session's own directory, // where the profile was frozen, never against a delegated target. if (ctx.hasUI) { const drift = inheritedProfileDriftNotice({ sessionId: ctx.sessionManager.getSessionId(), cwd: parentCwd, configHome: profileConfigHome, resolveWorktree: deps.resolveWorktree, env: deps.env, }); if (drift !== undefined) ctx.ui.notify(drift, "info"); } const profile = resolveAgentProfile(agent, config); // Issue #2196 (PR #2233 maintainer decision): a model_profiles key that // names no discovered agent was silently ignored while the child inherited // the orchestrator model. When a configured key suggests this agent, name // it at launch; resolution stays exact-match, so an omitted role keeps // inheriting and a matched key never warns. Keys are checked against every // discovered agent, so a key naming another agent is never reported here. if (ctx.hasUI) { const unmatched = unmatchedProfileKeys(config.modelProfiles, discoverAgents(roots(ctx)).agents); const nearMisses = unmatched.filter((entry) => entry.suggestion === agent.name); if (nearMisses.length > 0) { ctx.ui.notify(nearMisses.map((entry) => `"${entry.key}" did not match any agent, did you mean ${agent.name}?`).join(" "), "warning"); } // Issue #2400: a key with no suggestion never names the launched agent, // so the filter above kept it silent. The session's first launch lists // every such key once; later launches in the same session stay quiet. const unsuggested = unmatched.filter((entry) => entry.suggestion === undefined); const parentSession = ctx.sessionManager.getSessionId() ?? ""; if (unsuggested.length > 0 && !unmatchedKeysNotifiedSessions.has(parentSession)) { unmatchedKeysNotifiedSessions.add(parentSession); ctx.ui.notify(unsuggested.map((entry) => `"${sanitizeTerminalText(entry.key)}" did not match any agent and was ignored.`).join(" "), "warning"); } } // #2180: the winning layer is named from the resolution and routing this // request already performed, never by re-resolving them. const modelSource = modelSourceToken(profile.source.model, sessionProfile, routingTarget); if (admittedModel !== undefined) { const model = profile.model ?? ctx.model; const catalogModel = model?.provider ? ctx.modelRegistry?.find(model.provider, model.id) : ctx.modelRegistry?.getAll().find(candidate => candidate.id === model?.id); if (!catalogModel || `${catalogModel.provider}/${catalogModel.id}` !== admittedModel || !current()) throw new Error("Writer effective profile or session changed during preparation."); } const sessionDir = agentRuntimePaths(deps.home, agentHome).sessions; if (foreign && target) { const identity = resolveSessionWorktree(target, parentCwd); if (!identity || identity.root !== target || identity.commonDir !== foreignIdentity?.commonDir) throw new Error("Foreign clone identity changed before launch."); foreignGrants.assertCurrent(ctx, identity); } if (signal?.aborted) throw new Error("Subagent launch aborted before queueing."); mkdirSync(sessionDir, { recursive: true }); const parentSessionManager = ctx.sessionManager as unknown as ReviewSessionManager; const parentSessionId = ctx.sessionManager.getSessionId() ?? ""; const parentWorktreeRoot = ctx.sessionManager.getCwd(); const parentRepositoryIdentity = resolveCanonicalGitRepositoryIdentitySync(parentWorktreeRoot); const childEnv = { ...deps.env }; if (foreign) for (const key of inheritedUnsafeGitEnvironmentKeys(childEnv)) delete childEnv[key]; const request: TaskRequest = { agent, prompt, label, context, mode, cwd: target ?? parentWorktreeRoot, parentSessionId, ...(admittedModel === undefined ? {} : { beforeSpawn: () => { if (!current()) throw new Error("Writer session changed before spawn."); registry.validate(originalCwd); } }), ...(foreign && target ? { beforeSpawn: () => { if (signal?.aborted || sessions !== ctx.sessionManager || ctx.sessionManager.getSessionId() !== registry.sessionId || ctx.sessionManager.getCwd() !== parentCwd) throw new Error("Foreign clone session or tool call changed before spawn."); const identity = resolveSessionWorktree(target, parentCwd); const parent = resolveSessionWorktree(parentCwd, parentCwd); if (!identity || identity.root !== target || identity.commonDir !== foreignIdentity?.commonDir || parent?.root !== foreignParent?.root || parent?.commonDir !== foreignParent?.commonDir) throw new Error("Foreign clone identity changed before spawn."); foreignGrants.assertCurrent(ctx, identity); } } : {}), ...(target === undefined || foreign ? {} : { onLaunch: () => { registry.register(target, "subagent:spawn"); } }), model: profile.model, ...(modelSource === undefined ? {} : { modelSource }), thinking: profile.thinking, sessionDir, resumeSessionPath: resume, ...childExtensionRequest(deps.env, deps.childExtensionPaths), // A writer claims its surfaces in its canonical worktree root for its queued // and running lifetime; a continuation is a new task and claims them again. ...(isBoundedWriter(agent.name) && surfaces ? { writerSurfaces: surfaces, writerRoot: canonicalWriterRoot(target ?? parentWorktreeRoot, foreign ? resolveSessionWorktree : deps.resolveWorktree) } : {}), env: childEnv, ...(foreign || parentRepositoryIdentity === undefined ? {} : { authorizeParentStandingReviewPermission: (repositoryIdentity: string) => { try { return repositoryIdentity === parentRepositoryIdentity && parentSessionManager.getSessionId() === parentSessionId && parentSessionId.length > 0 && hasReviewSessionPermission({ sessionManager: parentSessionManager, sessionId: parentSessionId, worktreeRoot: parentWorktreeRoot, repositoryIdentity: parentRepositoryIdentity, }); } catch { return false; } }, }), }; if (foreign && target && foreignIdentity) foreignRequests.set(request, { root: target, commonDir: foreignIdentity.commonDir, manager: ctx.sessionManager }); return request; }; const launch = async (ctx: ExtensionContext, request: TaskRequest, signal?: AbortSignal, publishWork?: (id: string) => void): Promise => { if (isSingleShotMode(ctx.mode) && request.mode === AGENT_MODE.BACKGROUND) throw new Error(SINGLE_SHOT_BACKGROUND_ERROR); if (retiredSddAgent(request.agent.name)) throw new Error("Retired SDD agents cannot be dispatched."); // Bounded live observation only. Native send owns the fresh policy decision; // child execution never starts a telemetry policy process or renewal timer. const owner = metricsOwner; const metrics = { selection: undefined as LaunchSelection | undefined, started: 0, launched: false, finished: false, current: () => owner === metricsOwner && request.parentSessionId === activeSessionId() && runtimeMetricsEnvAllows(deps.env), valid: () => !metrics.finished && metrics.current() }; const observe = runtimeMetricsEnvAllows(deps.env) && metricTasks.size < 256; let launched = false; let launchedTaskId: string | undefined; const task = runner.run({ ...request, collectResponseObservations: false, onLaunch: () => { metrics.launched = true; request.onLaunch?.(); launched = true; const foreign = foreignRequests.get(request); if (foreign && launchedTaskId) foreignTasks.set(launchedTaskId, foreign); }, ...(observe ? { canCollectResponseObservations: metrics.valid, prepareResponseObservations: async () => { if (metrics.finished || owner !== metricsOwner || request.parentSessionId !== activeSessionId() || !runtimeMetricsEnvAllows(deps.env)) return false; if (!metrics.valid()) return false; metrics.selection = launchSelection(request.agent, request.model, request.thinking); metrics.started = metricsNow(); return metrics.valid(); } } : {}), }); if (observe) metricTasks.set(task.id, metrics); launchedTaskId = task.id; const foreignRequest = foreignRequests.get(request); if (launched && foreignRequest) foreignTasks.set(task.id, foreignRequest); ownedTaskIds.add(task.id); publishWork?.(task.id); publishActivityPrompt(); // Admission's summary notification precedes runtime ownership. store.subscribe(task.id, () => { publishActivity(); requestRender(); }); if (request.mode === AGENT_MODE.BACKGROUND) return text(`Started ${task.agent} in the background as task ${task.id}. Retain that id; completion is pushed automatically. Never sleep or periodically poll subagent_status/subagent_result for completion or cache maintenance. Inspect status only at a real orchestration decision boundary; never relaunch equivalent queued/running work.`, taskDetails(task)); // A tool call aborted by the host (a human interrupting the turn, a timeout) // would otherwise leave the child running and end the call with no result and // no recorded reason. Cancel through the runner so the lifecycle runs and the // record is persisted, and tell the user why. const onAbort = (): void => { if (runner.cancel(task.id, `cancelled: the tool call was aborted${abortReasonText(signal?.reason)}`)) { ctx.ui.notify( `Subagent ${task.agent} cancelled: the tool call was aborted${abortReasonText(signal?.reason)}. The run is recorded as cancelled.`, "warning", ); } }; if (signal?.aborted) onAbort(); else signal?.addEventListener("abort", onAbort, { once: true }); try { const query = await runner.waitForQuery(task.id); if (query) { const live = store.get(task.id) ?? task; messages.consumeQuery(task.id, query.requestId); const questionSuffix = query.message ? `\n\nQuestion:\n${query.message}` : ""; return text( `Subagent ${live.agent} is waiting for your reply to request ${query.requestId}.${questionSuffix}`, { gentleAgents: { taskId: live.id, agent: live.agent, status: live.status, mode: live.mode, requestId: query.requestId, ...(query.message ? { question: query.message } : {}) } }, true, ); } const finished = await runner.waitFor(task.id); completions.consume(finished.id); messages.invalidateTask(finished.id); // The id rides in the text too: details never reach the model, which // otherwise guesses ordinal ids for subagent_continue (#1731 T19). return text(completionText(finished), taskDetails(finished)); } finally { signal?.removeEventListener("abort", onAbort); } }; const tool = (name: string, description: string, parameters: Record, execute: (params: Record, ctx: ExtensionContext, signal?: AbortSignal) => Promise) => { pi.registerTool({ name: `${TOOL_PREFIX}${name}`, renderShell: "self", label: `Agent ${name.replace(/_/g, " ")}`, description, parameters: { type: "object", additionalProperties: false, ...parameters } as never, renderCall(args, theme) { // The result title belongs with the result body so a running poll // can hide both without changing the tool call or its model output. if (name === "result") return { render: () => [], invalidate() {} }; const params = args as { agent?: string; task_id?: string }; return new Text(theme.fg("toolTitle", `${AGENTS_GLYPH} agent ${name.replace(/_/g, " ")}${params.agent ? ` · ${params.agent}` : params.task_id ? ` · ${params.task_id}` : ""}`), 0, 0); }, renderResult(result, options, theme) { const task = (result.details as { gentleAgents?: { status?: string; taskId?: string } } | undefined)?.gentleAgents; if (name === "result" && task?.status === TASK_STATUS.RUNNING) { return { render: () => [], invalidate() {} }; } const body = result.content.map((part) => (part.type === "text" ? part.text : "")).join("\n"); const visibleBody = options.expanded ? body : theme.fg("muted", agentResultPreview(body).split("\n")[0] ?? ""); const title = `${AGENTS_GLYPH} agent result${task?.taskId ? ` · ${sanitizeTerminalText(task.taskId)}` : ""}`; return new Text(name === "result" ? `${theme.fg("toolTitle", title)}\n${visibleBody}` : visibleBody, 0, 0); }, async execute(_id, params, signal, _onUpdate, ctx) { return execute(params as Record, ctx, signal); }, }); }; pi.registerTool({ name: "orchestrator_session_id", label: "Orchestrator session ID", description: "Return this host session's stable routing ID, Initial alias (FIRST TASK), Current alias (CURRENT TASK), and separate human Session name. Explicit subjects update Current even when named; Initial stays the first declared branch topic, unknown for legacy history. Publishing state.work with an explicit topic and no subject also refreshes Current from that topic; keep work.topic current when the task changes. Before delegation or cross-session coordination, declare a short recognizable subject here. Do not require a subject declaration for small direct tasks; do not query all peers. Names never authenticate. Existing Pi names and human renames are preserved. Use a concise non-sensitive label, not a prompt. Progress is published automatically from the todo list; publish state here only for objective, decisions, blockers, or work, never just to report progress. Owner-curated state (2048 UTF-8 bytes total): null withdraws, omission leaves unchanged. Never include credentials, internal instructions, or raw prompts. Historical notes are not consent or an owner reply.", ...orchestratorToolRenderers("session", expandHint), parameters: { type: "object", additionalProperties: false, properties: { subject: { type: "string", maxLength: 120, description: "Optional short task subject; updates task aliases independently of human names. Also names an unnamed Pi session." }, state: { anyOf: [{ type: "null" }, { type: "object", additionalProperties: false, properties: { objective: { type: "string" }, progress: { type: "string" }, decisions: { type: "string" }, blockers: { type: "string" }, work: { type: "object", additionalProperties: false, description: "Optional historical, non-authoritative classification; topic requires area. Text plus work JSON fits 2048 UTF-8 bytes. Exact duplicate tags/refs are rejected; no ref resolution or routing.", properties: { ...workDescriptorSchema.properties, tasks: { type: "object", maxProperties: 8, propertyNames: { type: "string", maxLength: 256 }, additionalProperties: workDescriptorSchema, description: "Exact actual owner-declared task IDs, at most 256 UTF-8 bytes; controls, surrogates and __proto__/prototype/constructor rejected. Root-only; nonempty tasks alone are valid. Replacement omitting tasks clears annotations." }, }, }, } }] }, } } as never, async execute(_id, params, _signal, _onUpdate, ctx) { const transport = activeTransportFor(ctx); if (!transport) return text("Error: session messaging is not ready.", { error: "not ready" }); const { subject, state } = params as { subject?: unknown; state?: unknown }; const declared = normalizeTaskSubject(subject); // An owner object without `progress` keeps the automatic todo progress still on // display; null and an explicit `progress` win, and an over-budget merge does not fail the call. const shown = stateCache.get(ctx.sessionManager)?.state?.progress; const merged = state && typeof state === "object" && !Array.isArray(state) && !Object.hasOwn(state, "progress") && automaticProgress?.sessionId === ctx.sessionManager.getSessionId() && shown === automaticProgress.text ? { ...state, progress: shown } : undefined; const append = (type: string, data: unknown) => pi.appendEntry(type, data); if (merged) { try { stateCache.publish(ctx.sessionManager, merged, append, Date.now(), declared); } catch (error) { if ((error as Error).message !== "invalid-published-state") throw error; stateCache.publish(ctx.sessionManager, state, append, Date.now(), declared); } } else if (state !== undefined || declared) stateCache.publish(ctx.sessionManager, state, append, Date.now(), declared); if (activeTransportFor(ctx) !== transport) throw new Error("stale-published-state"); if (declared && !ctx.sessionManager.getSessionName?.()) pi.setSessionName(declared); const alias = sanitizeDisplayLabel(ctx.sessionManager.getSessionName?.() ?? ""); const aliases = stateCache.get(ctx.sessionManager)?.aliases; if (state !== undefined || declared) publishActivityPrompt(); presence?.refreshLabel(); return text(`Active session ID: ${transport.sessionId}\nCurrent alias (CURRENT TASK): ${aliases?.currentAlias ?? "unknown"}\nInitial alias (FIRST TASK): ${aliases?.initialAlias ?? "unknown"}\nSession name: ${alias || "unnamed"}`, { gentleAgents: { senderSessionId: transport.sessionId, alias, sessionName: alias, initialAlias: aliases?.initialAlias ?? null, currentAlias: aliases?.currentAlias ?? null } }); }, }); pi.registerTool({ name: "orchestrator_consult", label: "Consult published context", ...orchestratorToolRenderers("consult", expandHint), description: "Read published metadata (default), request bounded helper reasoning with explicit UI model-cost permission and a question, or revoke-reasoning for an exact target. Never an owner reply, consent or private context access.", parameters: { type: "object", additionalProperties: false, required: ["recipient_session_id"], properties: { kind: { type: "string", enum: ["metadata", "reasoning", "revoke-reasoning"] }, question: { type: "string", maxLength: 1024 }, recipient_session_id: { type: "string", pattern: "^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$" }, cursor: { type: "string", maxLength: 1024 }, } } as never, async execute(_id, params, _signal, _onUpdate, ctx) { const input = params as { kind?: unknown; recipient_session_id?: unknown; cursor?: unknown; question?: unknown }; const kind = input?.kind === undefined ? "metadata" : input.kind; if (!input || Object.keys(input).some(k => !["kind", "recipient_session_id", "cursor", "question"].includes(k)) || !["metadata", "reasoning", "revoke-reasoning"].includes(kind as string) || !validTransportSessionId(input.recipient_session_id) || (kind === "revoke-reasoning" && input.cursor !== undefined) || (kind !== "reasoning" && input.question !== undefined) || (kind === "reasoning" && (typeof input.question !== "string" || !input.question.trim() || Buffer.byteLength(input.question) > 1024 || /[\p{Cc}\p{Cf}\p{Cs}]/u.test(input.question))) || (input.cursor !== undefined && (typeof input.cursor !== "string" || input.cursor.length > 1024))) throw new Error("Invalid metadata consultation parameters."); const selection = { recipientSessionId: input.recipient_session_id, cursor: input.cursor as string | undefined }; const transport = activeTransportFor(ctx); const result = (receipt: MetadataReceipt) => { const output = kind === "metadata" ? receipt : { status: "unavailable", code: receipt.unknowns[0], source: "helper_advice", ownerReply: false, authority: "none" }; return text(JSON.stringify(output), { gentleAgents: { senderSessionId: transport?.sessionId, receipt: output } }); }; if (!transport) return result(unavailableMetadata(selection.recipientSessionId, "not-ready")); const reply = (receipt: unknown) => text(JSON.stringify(receipt), { gentleAgents: { senderSessionId: transport.sessionId, receipt } }); if (kind === "revoke-reasoning") { helperPermission.revoke(selection.recipientSessionId); return reply({ status: "revoked", source: "helper_advice", ownerReply: false, authority: "none" }); } try { const activations = await transport.listener.registry.listActivations(transport.sessionId); if (activeTransportFor(ctx) !== transport) return result(unavailableMetadata(selection.recipientSessionId, "source-session-changed")); const receipt = consultPublishedMetadata(agentHome, activations, selection); if (kind === "metadata") return result(receipt); const current = () => activeTransportFor(ctx) === transport && parentCtx?.sessionManager === ctx.sessionManager; const readSource = async () => { const peers = await transport.listener.registry.listActivations(transport.sessionId); return current() ? consultPublishedMetadata(agentHome, peers, selection) : unavailableMetadata(selection.recipientSessionId, "source-session-changed"); }; const advice = await helperPermission.run({ receipt, question: input.question as string, signal: _signal, readSource, isSourceCurrent: () => current() && consultPublishedMetadata(agentHome, activations, selection).digest === receipt.digest }); return reply(advice); } catch { return result(unavailableMetadata(selection.recipientSessionId, "discovery-unavailable")); } }, }); pi.registerTool({ name: "orchestrator_list", label: "List orchestrators", ...orchestratorToolRenderers("list", expandHint), description: "List other sessions advertised by the trusted local profile. Optional filter searches only published classified work with bounded, non-exhaustive coverage, no authority and unknown reachability. Omit filter for the existing session list.", parameters: Type.Object({ recipient_session_id: Type.Optional(Type.String({ description: "Exact routing ID of the peer to inspect." })), cursor: Type.Optional(Type.String({ description: "Opaque catalog continuation from that peer; requires recipient_session_id." })), filter: Type.Optional(Type.Object({ area: Type.Optional(Type.String()), topic: Type.Optional(Type.String({ description: "Requires area." })), tag: Type.Optional(Type.String()), text: Type.Optional(Type.String({ description: "Literal label/descriptor search, never state prose or history." })), ref: Type.Optional(Type.Object({ kind: Type.Union([Type.Literal("issue"), Type.Literal("pr"), Type.Literal("task")]), repository: Type.String({ description: "Exact public host/owner/repo scope." }), id: Type.String(), }, { additionalProperties: false })), repository_root: Type.Optional(Type.String({ description: "Recorded absolute Git root; no new Git probe." })), related_to: Type.Optional(Type.Object({ session_id: Type.String({ description: "Exact stable source owner session ID." }), task_id: Type.Optional(Type.String({ description: "Actual task ID on the current catalog page, not child session ID." })), }, { additionalProperties: false })), }, { additionalProperties: false, description: "Explicit {} indexes classified work; criteria combine with AND." })), }, { additionalProperties: false }), async execute(_id, _params, _signal, _onUpdate, ctx) { const params = _params as { recipient_session_id?: string; cursor?: string; filter?: unknown }; let filter; try { if (!params || typeof params !== "object" || Array.isArray(params) || Object.keys(params).some(key => !["recipient_session_id", "cursor", "filter"].includes(key)) || (params.recipient_session_id !== undefined && !validTransportSessionId(params.recipient_session_id)) || (params.cursor !== undefined && (typeof params.cursor !== "string" || params.cursor.length > 1024))) throw new Error(); if (Object.hasOwn(params, "filter")) filter = validateWorkFilter(params.filter); } catch { throw new Error("Invalid orchestrator list parameters."); } if (params.cursor !== undefined && !params.recipient_session_id) return text("Error: cursor requires recipient_session_id.", { error: "invalid-cursor" }); const transport = activeTransportFor(ctx); if (!transport) return text("Error: session discovery is not ready.", { error: "not ready" }); try { const activations = await transport.listener.registry.listActivations(transport.sessionId); if (activeTransportFor(ctx) !== transport) return text("Error: session discovery became unavailable before results were confirmed.", { error: "stale" }); if (filter !== undefined) { const workSearch = searchPublishedWork(agentHome, activations, filter, params.recipient_session_id ? { recipientSessionId: params.recipient_session_id, cursor: params.cursor } : undefined); return text(JSON.stringify(workSearch), { gentleAgents: { workSearch } }); } const peers = discoverOrchestrators(agentHome, activations, Date.now(), params.recipient_session_id ? { recipientSessionId: params.recipient_session_id, cursor: params.cursor } : undefined); const repository = (fact?: RepositoryFact) => fact?.root ? `repository: ${fact.root} · clone: ${fact.cloneHash} · Git resolved at: ${fact.resolvedAt} (${fact.source})` : "repository: unknown"; const rows = peers.map(peer => { const context = peer.freshness === "recent" ? ` · ${peer.aliases?.currentAlias || peer.label || "unnamed"} · Session name: ${peer.label || "unnamed"} · Current alias (CURRENT TASK): ${peer.aliases?.currentAlias ?? "unknown"} · Initial alias (FIRST TASK): ${peer.aliases?.initialAlias ?? "unknown"} · recorded workspace: ${peer.workspace || "unknown"}` : peer.legacyLabel !== undefined ? ` · ${peer.legacyLabel || "unnamed"} · context: legacy (this session predates discovery publication; restart it to publish its workspace and repository)` : " · context: unknown"; const tasks = peer.tasks?.map(task => `\n - ${task.label || task.id} [${task.status}] · launch workspace: ${task.workspace || "unknown"} · ${repository(peer.scope?.tasks.find(t => t.id === task.id)?.repository)}`).join("") ?? ""; const registered = peer.scope?.registered.map(fact => `\n registered: ${repository(fact)}`).join("") ?? ""; const gaps = peer.scope && !peer.scope.complete ? `\n (scope incomplete: ${peer.scope.omittedTasks} tasks, ${peer.scope.omittedRegistered} registered roots omitted)` : ""; const catalog = peer.catalog ? `\n recorded catalog (not Git identity): ${JSON.stringify(peer.catalog)}` : "\n recorded catalog: unknown"; const note = params.recipient_session_id ? `\n owner-curated recorded state (not consent or owner reply; authority none): ${peer.state ? JSON.stringify(peer.state) : "unknown"}` : ""; // A legacy peer never publishes its scope: say so instead of reporting an unknown. const legacy = peer.legacyLabel !== undefined; return `- ${peer.sessionId}${context} · ${legacy ? "repository: not published" : repository(peer.scope?.host)} · metadata: ${legacy ? "legacy" : peer.freshness}${tasks}${registered}${gaps}${catalog}${note}${peer.omitted ? `\n (${peer.omitted} more tasks omitted)` : ""}`; }); return peers.length === 0 ? text("No other sessions are currently advertised. Advertisements have unknown reachability and do not guarantee a live session.") : text(`Advertised sessions (reachability is unknown):\n${rows.join("\n")}`, { gentleAgents: { candidates: peers } }); } catch (error) { // SessionPresenceError codes are a fixed enum with no record content, // so surfacing the reason is safe and keeps diagnosis possible. const reason = error instanceof SessionPresenceError ? error.code : "unknown"; return text(`Error: session discovery is unavailable (${reason}).`, { error: "unavailable", reason }); } }, }); pi.registerTool({ name: "orchestrator_send_message", label: "Send orchestrator message", renderShell: "self", renderCall(args, theme, context) { return outgoingMessageCall(args as { message?: unknown }, theme, context, expandHint(context.expanded)); }, renderResult(result, options, theme, context) { return outgoingMessageResult(result, options.expanded, options.isPartial, theme, context); }, description: "Send a notification to another active session in this trusted local profile. If recipient_session_id is omitted, the sole peer is selected or the user selects one. Acceptance means enqueued, not read or completed.", parameters: { type: "object", additionalProperties: false, required: ["message", "reason"], properties: { recipient_session_id: { type: "string" }, message: { type: "string" }, reason: { type: "string", minLength: MESSAGING_REASON_MIN_CHARACTERS, maxLength: MESSAGING_REASON_MAX_UTF8_BYTES, description: "Required concrete caller-supplied reason: at least 8 characters after trimming and at most 512 UTF-8 bytes." }, }, } as never, async execute(_id, params, signal, _onUpdate, ctx) { const transport = activeTransportFor(ctx); const input = params as { recipient_session_id?: unknown; message?: unknown; reason?: unknown }; if (!transport) return text("Error: session messaging is not ready.", { error: "not ready" }); const message = input.message; if (typeof message !== "string" || Buffer.byteLength(message, "utf8") > 8192) return text("Error: recipient session ID or message is invalid.", { error: "invalid input" }); let reason: string; try { reason = normalizeMessagingReason(input.reason); } catch { return text("Error: recipient session ID or message is invalid; a concrete reason of at least 8 trimmed characters and at most 512 UTF-8 bytes is required.", { error: "invalid input" }); } let recipient: string | undefined; let activation: PresenceRecord | undefined; if (input.recipient_session_id !== undefined) { if (!validTransportSessionId(input.recipient_session_id)) return text("Error: recipient session ID or message is invalid.", { error: "invalid input" }); recipient = input.recipient_session_id; } if (recipient === undefined) { try { const candidates = (await transport.listener.registry.listActivations(transport.sessionId)).filter((candidate) => candidate.sessionId !== transport.sessionId); if (activeTransportFor(ctx) !== transport || signal?.aborted) return text("Message selection was cancelled.", { error: "cancelled" }); if (candidates.length === 0) return text("No other sessions are currently advertised; message delivery is unavailable.", { error: "unavailable" }); if (candidates.length === 1) { recipient = candidates[0].sessionId; activation = candidates[0]; } else if (!ctx.hasUI) return text("Several recipient orchestrators are available. Ask the user to choose a recipient label; do not ask for a session ID.", { gentleAgents: { candidates: candidates.map((candidate) => ({ label: `Orchestrator ${candidate.sessionId}`, sessionId: candidate.sessionId })) } }); else { const labels = candidates.map((candidate) => `Orchestrator ${candidate.sessionId}`); const selected = await ctx.ui.select("Select recipient orchestrator", labels, { signal }); if (selected === undefined || activeTransportFor(ctx) !== transport || signal?.aborted) return text("Message selection was cancelled.", { error: "cancelled" }); const index = labels.indexOf(selected); if (index < 0) return text("Message selection was cancelled.", { error: "cancelled" }); recipient = candidates[index].sessionId; activation = candidates[index]; } } catch (error) { const reason = error instanceof SessionPresenceError ? error.code : "unknown"; return text(`Error: session discovery is unavailable (${reason}).`, { error: "unavailable", reason }); } } if (recipient === undefined || !validTransportSessionId(recipient)) return text("Error: recipient session ID or message is invalid.", { error: "invalid input" }); if (recipient === transport.sessionId) return text("Error: cannot send a message to the active session.", { error: "self" }); try { await messagingGrants.authorize(ctx, recipient, { message, reason, signal }); } catch (error) { if (signal?.aborted) return text("Cross-orchestrator communication was cancelled.", { error: "cancelled" }); return text(`Error: ${error instanceof Error ? error.message : String(error)}`, { error: "denied" }); } try { const accepted = await transport.client.sendNotification(recipient, message, { signal, expectedActivation: activation, beforeConnect: () => activeTransportFor(ctx) === transport }); return activeTransportFor(ctx) === transport ? text(`Message ${accepted.id} from ${transport.sessionId} to ${recipient} accepted for delivery; it is not a delivery or read receipt.`, { gentleAgents: { messageId: accepted.id, senderSessionId: transport.sessionId, recipientSessionId: recipient, state: "accepted", message, reason, senderLabel: sanitizeDisplayLabel(ctx.sessionManager.getSessionName?.() ?? ""), recipientLabel: messagePeerLabel(agentHome, recipient) } }) : text("Error: session messaging is not ready.", { error: "stale" }); } catch { return text("Error: session message was not accepted.", { error: "not accepted" }); } }, }); tool("list_agents", "List the subagents defined for this project and user, with their descriptions and declared tool inventory.", { properties: {} }, async (_params, ctx) => { const { agents, errors } = discoverAgents(roots(ctx)); const lines = agents.map((agent) => `- ${agent.name} (${agent.scope}): ${agent.description || "no description"} ${declaredToolInventory(agent)}`); const problems = errors.map((error) => `! ${error}`); return text(lines.length === 0 ? "No subagents defined." : [...lines, ...problems].join("\n")); }); tool( "run", "Delegate a task to a named subagent. Task mode waits for the answer; background mode returns a task id immediately.", { required: ["agent", "task"], properties: { agent: { type: "string", description: "Subagent name from subagent_list_agents." }, task: { type: "string", description: "What the subagent must do, self-contained." }, label: { type: "string", description: "Three to six words naming the work, shown on the agents card, e.g. 'map footer data sources'." }, context: { type: "string", description: "Optional extra context appended to the task." }, work: workDescriptorSchema, workspace_root: { type: "string", description: "Optional canonical main or linked Git worktree within the parent's same clone only; mutually exclusive with repository_root." }, repository_root: { type: "string", description: "Optional canonical independent Git repository; requires direct interactive session-scoped consent before queueing; mutually exclusive with workspace_root." }, mode: { type: "string", enum: ["task", "background"], description: "task waits for the result (default); background returns immediately." }, }, }, async (params, ctx, signal) => { const work = Object.hasOwn(params, "work") ? decodeWorkDescriptor(params.work) : undefined; const manager = ctx.sessionManager, sessionId = manager.getSessionId(); if (typeof params.agent !== "string" || !params.agent.trim() || typeof params.task !== "string" || !params.task.trim() || (params.context !== undefined && typeof params.context !== "string") || (params.label !== undefined && typeof params.label !== "string")) throw new Error("Subagent dispatch requires a named agent, non-empty task and string context/label."); if (params.mode !== undefined && params.mode !== AGENT_MODE.TASK && params.mode !== AGENT_MODE.BACKGROUND) throw new Error("Subagent mode must be task or background."); // An empty selector names no destination: only real roots are mutually // exclusive, so a blank string must not masquerade as a second target. const hasWorkspaceRoot = Object.hasOwn(params, "workspace_root") && params.workspace_root !== ""; const hasRepositoryRoot = Object.hasOwn(params, "repository_root") && params.repository_root !== ""; if (hasRepositoryRoot && hasWorkspaceRoot) throw new Error("repository_root and workspace_root are mutually exclusive."); if ((Object.hasOwn(params, "repository_root") && typeof params.repository_root !== "string") || (Object.hasOwn(params, "workspace_root") && typeof params.workspace_root !== "string")) throw new Error("Root selectors must be strings."); if (Object.hasOwn(params, "sdd_change") || Object.hasOwn(params, "remediation") || Object.hasOwn(params, "research_selection") || retiredSddAgent(String(params.agent))) return text("Error: retired SDD delegation is not supported.", { error: "retired SDD delegation" }); const { agents } = discoverAgents(roots(ctx)); const agent = agents.find((candidate) => candidate.name === params.agent); if (!agent) return text(`Error: no subagent named "${String(params.agent)}". Known: ${agents.map((candidate) => candidate.name).join(", ") || "none"}`, { error: "unknown agent" }); const mode = (params.mode as AgentMode | undefined) ?? agent.mode ?? resolveDefaultSubagentMode({ configuredDefault: loadAgentsConfig(roots(ctx)).defaultMode, policy: resolveBackgroundSubagentsPolicy(ctx.cwd).policy, parentMode: ctx.mode, }); const workspaceRoot = typeof params.workspace_root === "string" && params.workspace_root !== "" ? params.workspace_root : undefined; const repositoryRoot = typeof params.repository_root === "string" && params.repository_root !== "" ? params.repository_root : undefined; const publication: { status: "recorded" | "unavailable" } = { status: "unavailable" }; const current = () => ctx.sessionManager === manager && manager.getSessionId() === sessionId && !!activeTransportFor(ctx); const result = await launch(ctx, await buildRequest(ctx, agent, String(params.task ?? ""), typeof params.label === "string" ? params.label : undefined, typeof params.context === "string" ? params.context : undefined, mode, undefined, workspaceRoot, signal, repositoryRoot), signal, work ? id => { try { if (!current()) return; const state = stateCache.get(manager)?.state ?? {}; stateCache.publish(manager, { ...state, work: { ...state.work, tasks: { ...state.work?.tasks, [id]: work } } }, (type, data) => pi.appendEntry(type, data)); if (current()) publication.status = "recorded"; } catch { /* Optional metadata cannot invalidate an allocated task. */ } } : undefined); if (!work) return result; if (!current()) publication.status = "unavailable"; const note = publication.status === "recorded" ? "Work recorded in local curated state; peer advertisement is best-effort." : "Work publication unavailable/unknown. Do not relaunch this allocated task. Use orchestrator_session_id with a bounded replacement state and this actual task ID when the owner session is active."; return { ...result, content: [...result.content, { type: "text", text: note }], details: { ...result.details, workPublication: publication } }; }, ); tool("status", "Report the status of one subagent task.", { required: ["task_id"], properties: { task_id: { type: "string" } } }, async (params, ctx) => { const task = await resolveTask(String(params.task_id)); return task ? text(describeTask(task), taskDetails(task)) : unknownTask(params.task_id, ctx); }); tool("result", "Return the final answer of a finished subagent task, or its current state if it is still running.", { required: ["task_id"], properties: { task_id: { type: "string" } } }, async (params, ctx) => { const task = await resolveTask(String(params.task_id)); if (!task) return unknownTask(params.task_id, ctx); // The parent just pulled a finished result; its pending completion must // never be replayed on top of it. if (isFinished(task.status)) { completions.consume(task.id); messages.invalidateTask(task.id); } return text(isFinished(task.status) ? finishedText(task) : `Task ${task.id} is still ${task.status} (last: ${task.lastStep}).`, taskDetails(task)); }); tool("list_tasks", "List the subagent tasks of this session, newest first.", { properties: {} }, async (_params, ctx) => { const tasks = store.list(ctx.sessionManager.getSessionId() ?? ""); return text(tasks.length === 0 ? "No subagent tasks in this session." : tasks.map(describeTask).join("\n")); }); tool("reply", "Reply once to a live query from a child of the current parent session.", { required: ["task_id", "request_id", "message"], properties: { task_id: { type: "string" }, request_id: { type: "string" }, message: { type: "string" } } }, async (params, ctx) => { const taskId = String(params.task_id); const requestId = String(params.request_id); const accepted = await runner.reply(taskId, requestId, typeof params.message === "string" ? params.message : "", ctx.sessionManager.getSessionId() ?? ""); return accepted ? text("Reply accepted for delivery.") : text("Error: query is unavailable.", { error: "query unavailable" }); }); tool("cancel", "Cancel a queued or running subagent task.", { required: ["task_id"], properties: { task_id: { type: "string" } } }, async (params) => { const id = String(params.task_id); messages.invalidateTask(id); return runner.cancel(id, "cancelled by the cancel tool") ? text(`Cancelled task ${id}.`) : text(`Error: task ${id} is not running.`, { error: "not running" }); }); tool("send_message", "Steer a running subagent with a message delivered before its next model call.", { required: ["task_id", "message"], properties: { task_id: { type: "string" }, message: { type: "string" } } }, async (params) => { const id = String(params.task_id); return runner.steer(id, String(params.message ?? "")) ? text(`Message queued for task ${id}.`) : text(`Error: task ${id} is not running.`, { error: "not running" }); }); tool( "continue", "Resume a finished subagent task in its own session with a follow-up prompt.", { required: ["task_id", "prompt"], properties: { task_id: { type: "string" }, prompt: { type: "string" }, label: { type: "string", description: "Three to six words naming the follow-up." }, mode: { type: "string", enum: ["task", "background"] } } }, async (params, ctx, signal) => { if (Object.hasOwn(params, "sdd_change") || Object.hasOwn(params, "remediation") || Object.hasOwn(params, "research_selection")) return text("Error: retired SDD delegation is not supported.", { error: "retired SDD delegation" }); const previous = await resolveTask(String(params.task_id)); if (!previous) return unknownTask(params.task_id, ctx); if (retiredSddAgent(previous.agent)) return text("Error: retired SDD agents cannot be continued.", { error: "retired SDD delegation" }); if (!isFinished(previous.status) || !previous.sessionPath) return text(`Error: task ${previous.id} cannot be continued yet (${previous.status}).`, { error: "not continuable" }); // Continuing acts on the previous result, so any pending completion for // it is already consumed by the parent. completions.consume(previous.id); messages.invalidateTask(previous.id); const agent = discoverAgents(roots(ctx)).agents.find((candidate) => candidate.name === previous.agent); if (!agent) return text(`Error: subagent "${previous.agent}" is no longer defined.`, { error: "unknown agent" }); const mode = (params.mode as AgentMode | undefined) ?? (previous.mode as AgentMode); const foreignContinuation = foreignTasks.has(previous.id); const prompt = inheritAllowedEditSurfaces(previous.agent, String(params.prompt ?? ""), params.context, previous.prompt); return launch(ctx, await buildRequest(ctx, agent, prompt, typeof params.label === "string" ? params.label : undefined, typeof params.context === "string" ? params.context : undefined, mode, previous.sessionPath, foreignContinuation ? undefined : previous.cwd, signal, foreignContinuation ? previous.cwd : undefined), signal); }, ); if (collapseKey) { pi.registerShortcut(collapseKey as Parameters[0], { description: "Collapse or expand the agents card", handler: async () => toggleCollapsed(), }); } pi.registerCommand(JOBS_COMMAND_NAME, { description: "Show background jobs: running first, newest first within each group; status, command, output tail; s stops the selected job.", handler: async (_args, ctx) => { if (!ctx.hasUI) return; if (ctx.mode !== "tui") { ctx.ui.notify("The jobs overlay requires TUI mode.", "warning"); return; } let view: JobsView | undefined; await ctx.ui.custom((tui, theme, _keybindings, done) => { const close = withOverlayRepaint(tui, done); view = new JobsView({ theme, rows: () => Math.max(0, tui.terminal.rows), jobs: () => jobs.list(ctx.sessionManager.getSessionId() ?? ""), now: () => deps.now(), // The agent was promised a notice, so a human stop is reported; // a job_stop needs none because the agent itself stopped it. onStop: (job) => { // Lines already seen are delivered before the stop notice. monitors.finish(job); const stopped = jobs.stop(job.id); if (stopped) queueJobNotice({ job: stopped, kind: "exit" }); }, onClose: () => close(null), requestRender: () => tui.requestRender(), }); return view; }, { overlay: true, overlayOptions: { width: "100%", maxHeight: "100%", margin: 0, anchor: "center" } }).finally(() => view?.dispose()); }, }); pi.registerCommand(AGENTS_COMMAND_NAME, { description: "Show this session's active subagents; a lists open orchestrators in this profile. Peer threads are read-only; o opens a local task's transcript in $EDITOR.", handler: async (_args, ctx) => openOverlay(ctx), }); if (viewKey) { pi.registerShortcut(viewKey as Parameters[0], { description: "Show the subagents overlay", handler: async (ctx) => openOverlay(ctx), }); } if (stopKey) { pi.registerShortcut(stopKey as Parameters[0], { description: "Stop active subagent(s)", handler: async (ctx) => stopAll(ctx), }); } pi.on("session_tree", (_event, ctx) => { helperPermission.clear(); if (sessions !== ctx.sessionManager) return; stateCache.load(ctx.sessionManager); publishActivityPrompt(); }); pi.on("session_start", async (event, ctx) => { // gentle-shell#1064 slice 3b-ii: a parent session that has no explicit // profile binding freezes `p → P → G` now. This handler is parent-only (a // child returns before registering it) and runs for every reason Pi // reports, including `startup` for --resume/--continue/--session. The // freeze is in-memory and idempotent per session id: /reload keeps it, a // new process freezes again until slice 3b-i persists it as an entry. try { freezeSessionProfileAtStartup({ sessionId: ctx.sessionManager.getSessionId(), cwd: ctx.sessionManager.getCwd(), configHome: gentlePiConfigHome(deps.env), resolveWorktree: deps.resolveWorktree, env: deps.env, }); } catch { /* A launch freezes lazily through the same resolver. */ } stateCache.load(ctx.sessionManager); // A resumed, reloaded, or replaced session starts with an empty completion // queue so nothing pending from another session can replay here. completions.dropAll(); messages.dropAll(); jobNotices.length = 0; resetParentDelivery(ctx); presence?.dispose(); registryFor(ctx); showWidget(ctx); // An explicit in-session /resume always brings this session's own // finished subagents back from disk. Pi also reports "startup" (not // "resume") when the CLI is launched directly into an existing session // file, e.g. --continue or the --resume picker (agent-session.js:152 // defaults to "startup"); that case restores too, but only when the // session actually has prior entries -- a brand-new session can also be // announced as "startup", and a fresh session has none. "new", "fork", // and "reload" never restore here, matching the existing on-demand // resolveTask path for anything else. const sessionId = ctx.sessionManager.getSessionId(); const preexisting = event.reason === "resume" || (event.reason === "startup" && ctx.sessionManager.getEntries().length > 0); if (preexisting && sessionId) void restoreSessionHistory(ctx, sessionId); try { presence = startPresence(ctx.sessionManager); resetActivityPublish(); publishActivityPrompt(); } catch { presence = undefined; } void startSessionTransport(ctx); // The desktop app's own pi process: publish live subagent state through // setWidget's RPC-mode string[] path. Plain headless RPC (no variable) and // TUI are untouched -- the TUI card above the editor is showWidget's own // factory push, ignored by pi's RPC transport since it is not an array. rpcActivityPublisher?.stop(); rpcActivityPublisher = undefined; notifiedRpcActivityErrors = new Set(); if (ctx.hasUI && isInteractiveRpcHost(ctx.mode, deps.env)) { rpcActivityPublisher = createRpcActivityPublisher({ store, ui: { setWidget: (key, lines) => ctx.ui.setWidget(key, lines) }, now: deps.now, schedule: deps.schedule, parentSessionId: activeSessionId(), onError: (error) => { const message = `Gentle Agents activity push failed: ${error instanceof Error ? error.message : String(error)}`; if (notifiedRpcActivityErrors?.has(message)) return; notifiedRpcActivityErrors?.add(message); ctx.ui.notify(message, "warning"); }, }); rpcActivityPublisher.start(); } }); pi.on("session_shutdown", async () => { completions.dropAll(); messages.dropAll(); // Jobs never outlive their session: a stopped job sends no notice. monitors.cancelAll(); const jobsStopped = jobs.stopAll(); jobNotices.length = 0; refreshJobStatus(); activeAgentRuns = 0; resetParentDelivery(undefined); presence?.dispose(); presence = undefined; stateCache.clear(); rpcActivityPublisher?.stop(); rpcActivityPublisher = undefined; cancelClock?.(); resetActivityPublish(); for (const view of overlays) { view.handleInput("q"); view.dispose(); } overlays.clear(); sessions = undefined; stopVisualUpdates?.(); stopVisualUpdates = undefined; sidebarTui = undefined; worktrees?.close(); worktrees = undefined; worktreeManager = undefined; const stopped = shutdownSessionTransport(); runner.cancelAll("cancelled: parent session shut down"); await stopped; // The default log directory goes with the session once every log is // closed, but a process that ignores its stop must not hang shutdown. await new Promise((done) => { const cancel = deps.schedule(done, JOBS_SHUTDOWN_GRACE_MS); void jobsStopped.then(() => { cancel(); done(); }); }); if (jobOutputDir) { const dir = jobOutputDir; jobOutputDir = undefined; try { rmSync(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 }); } catch { /* A leftover temp directory is harmless. */ } } }); }