import { complete, completeSimple, getSupportedThinkingLevels, type Message } from "@earendil-works/pi-ai/compat"; import { convertToLlm, serializeConversation, SessionManager, SessionSelectorComponent } from "@earendil-works/pi-coding-agent"; import type { ExtensionAPI, ExtensionContext, SessionEntry, SessionInfo } from "@earendil-works/pi-coding-agent"; import { Text } from "@earendil-works/pi-tui"; import { Type } from "typebox"; import { spawnSync } from "node:child_process"; import { promises as fs } from "node:fs"; import os from "node:os"; import { basename, dirname, join, resolve } from "node:path"; const ENV_STATE_ROOT = "PI_SIDE_AGENTS_ROOT"; const ENV_AGENT_ID = "PI_SIDE_AGENT_ID"; const ENV_PARENT_SESSION = "PI_SIDE_PARENT_SESSION"; const ENV_PARENT_REPO = "PI_SIDE_PARENT_REPO"; const ENV_RUNTIME_DIR = "PI_SIDE_RUNTIME_DIR"; /** Branch checked out in the parent checkout (the merge target of the finish script). */ const ENV_PARENT_BRANCH = "PI_SIDE_PARENT_BRANCH"; /** Agent id of the session that spawned this one; unset when spawned by the main session. */ const ENV_PARENT_AGENT_ID = "PI_SIDE_PARENT_AGENT_ID"; /** Nesting depth: main session = 0, its children = 1, grandchildren = 2. */ const ENV_DEPTH = "PI_SIDE_AGENT_DEPTH"; /** Deepest agent that may exist; agents at this depth cannot spawn further. */ const MAX_AGENT_DEPTH = 2; const STATUS_KEY = "side-agents"; const REGISTRY_VERSION = 1; const CHILD_LINK_ENTRY_TYPE = "side-agent-link"; const STATUS_UPDATE_MESSAGE_TYPE = "side-agent-status"; const PROMPT_UPDATE_MESSAGE_TYPE = "side-agent-prompt"; const RESUME_NOTICE_MESSAGE_TYPE = "side-agent-resume-notice"; /** Runtime-dir file the parent writes when a resumed session lands in a * different directory and/or a reset working tree; the child injects it into * the resumed session on startup (see injectResumeNoticeIfPresent). */ const RESUME_NOTICE_FILE = "resume-notice.json"; const SUMMARY_SYSTEM_PROMPT = `You are writing a minimal handoff summary for a background coding agent. Use the parent conversation only as context. Include only details that are directly relevant to the child task. If the parent conversation is unrelated to the child task, output exactly: NONE Preferred content (but only when relevant): - objective/constraints already established - decisions already made - key files/components to inspect - risks/caveats`; type AgentStatus = | "allocating_worktree" | "spawning_tmux" | "running" | "waiting_user" | "done" | "failed" | "crashed"; const ALL_AGENT_STATUSES: AgentStatus[] = [ "allocating_worktree", "spawning_tmux", "running", "waiting_user", "failed", "crashed", ]; const DEFAULT_WAIT_STATES: AgentStatus[] = ["waiting_user", "failed", "crashed"]; type AgentRecord = { id: string; parentSessionId?: string; /** Agent id of the spawning session; undefined when spawned by the main session. */ parentAgentId?: string; /** Nesting depth (1 = spawned by main). Missing on records from older versions ⇒ 1. */ depth?: number; /** Nested agents only: the parent agent's worktree — the checkout the finish * script merges into. Kept here (not just on the parent record) so the slot * stays reserved even after the parent quits and its record is pruned. */ parentWorktreePath?: string; childSessionId?: string; tmuxSession?: string; tmuxWindowId?: string; tmuxWindowIndex?: number; worktreePath?: string; branch?: string; model?: string; task: string; status: AgentStatus; startedAt: string; updatedAt: string; finishedAt?: string; runtimeDir?: string; logPath?: string; promptPath?: string; exitFile?: string; exitCode?: number; error?: string; warnings?: string[]; }; type RegistryFile = { version: 1; agents: Record; }; type AllocateWorktreeResult = { worktreePath: string; slotIndex: number; branch: string; warnings: string[]; /** Resume mode: the requested existing branch was found and checked out (vs recreated from HEAD). */ branchReattached: boolean; /** Resume mode: the branch was still checked out in the slot and the working * tree was left untouched (vs a slot that was reset --hard / freshly added). */ workingTreePreserved: boolean; }; type StartAgentParams = { task: string; branchHint?: string; model?: string; includeSummary: boolean; /** Resume a previously quit child session instead of starting fresh. */ resume?: { /** Path to the pi session file to resume. */ sessionPath: string; /** The session's recorded cwd; when it matches the allocated worktree the * session is continued in place (--session), otherwise it is forked * (--fork) so pi re-homes it into the new worktree. */ sessionCwd?: string; /** Original agent id from the session's side-agent-link entry, if known. */ agentIdHint?: string; /** Pre-existing side-agent branch to reattach; omit to start a fresh branch. */ branch?: string; }; }; type StartAgentResult = { id: string; parentAgentId?: string; depth: number; tmuxWindowId: string; tmuxWindowIndex: number; worktreePath: string; branch: string; warnings: string[]; prompt: string; }; type PrepareRuntimeDirResult = { runtimeDir: string; archivedRuntimeDir?: string; warning?: string; }; type ExitMarker = { exitCode?: number; finishedAt?: string; }; type CommandResult = { ok: boolean; status: number | null; stdout: string; stderr: string; error?: string; }; type StatusTransitionNotice = { id: string; fromStatus: AgentStatus; toStatus: AgentStatus; tmuxWindowIndex?: number; parentAgentId?: string; /** The agent's parent was pruned from the registry (it quit while this one still ran). */ orphaned?: boolean; /** Main-only: the agent just became an orphan; emitted even without a status * change so main learns it now owns an agent that may already be waiting/failed. */ adopted?: boolean; }; /** * A transition notice buffered for delivery. Consecutive transitions of the same * agent collapse into one entry (`fromStatus` of the first, `toStatus` of the * latest) so a late burst of historical flip-flopping is reported once. */ type PendingStatusTransition = StatusTransitionNotice & { firstObservedAt: number; lastObservedAt: number; coalescedCount: number; /** Statuses entered since `fromStatus`, oldest first (capped). */ path: AgentStatus[]; }; type AgentStatusSnapshot = { status: AgentStatus; tmuxWindowIndex?: number; parentAgentId?: string; orphaned?: boolean; }; /** Identity of the running session within the agent tree. */ type SelfIdentity = { /** Own agent id; undefined for the main session. */ selfId?: string; /** Own parent's agent id; undefined for the main session and its direct children. */ parentAgentId?: string; depth: number; }; function getSelfIdentity(): SelfIdentity { const selfId = process.env[ENV_AGENT_ID] || undefined; if (!selfId) return { depth: 0 }; const rawDepth = Number(process.env[ENV_DEPTH]); // Children launched by older versions carry no depth; they are direct children of main. const depth = Number.isInteger(rawDepth) && rawDepth > 0 ? rawDepth : 1; return { selfId, parentAgentId: process.env[ENV_PARENT_AGENT_ID] || undefined, depth }; } /** * Whether `record` is owned by the current session, i.e. the current session * spawned it (main owns records without a parentAgentId). Orphans — records * whose parent has been pruned from the registry — fall back to main, which * is the only session left that can act on them. */ function isOwnedBySelf(record: { parentAgentId?: string }, self: SelfIdentity, liveIds: ReadonlySet): boolean { if (record.parentAgentId === self.selfId) return true; return isOrphanRecord(record, self, liveIds); } function isOrphanRecord(record: { parentAgentId?: string }, self: SelfIdentity, liveIds: ReadonlySet): boolean { return !self.selfId && record.parentAgentId !== undefined && !liveIds.has(record.parentAgentId); } /** * Whether `record` belongs in the status line of the current session: its own * children plus its siblings (same parent). For the main session both sets * coincide with "direct children"; orphans are shown to main as well. */ function isSiblingRecord(record: { id: string; parentAgentId?: string }, self: SelfIdentity): boolean { if (record.id === self.selfId) return false; return record.parentAgentId === self.parentAgentId; } let statusPollTimer: NodeJS.Timeout | undefined; let statusPollContext: ExtensionContext | undefined; let statusPollApi: ExtensionAPI | undefined; let statusPollInFlight = false; const statusSnapshotsByStateRoot = new Map>(); const pendingTransitionsByStateRoot = new Map>(); // Transitions settle for this long before being reported, so short-lived // intermediate states (spawning_tmux -> running -> waiting_user) coalesce. const TRANSITION_SETTLE_MS = 5000; // Cap on remembered intermediate states of a coalesced notice. const TRANSITION_PATH_LIMIT = 6; let lastRenderedStatusLine: string | undefined; function nowIso() { return new Date().toISOString(); } function sleep(ms: number): Promise { return new Promise((resolveNow) => setTimeout(resolveNow, ms)); } function stringifyError(err: unknown): string { if (err instanceof Error) return err.message; return String(err); } function shellQuote(value: string): string { return `'${value.replace(/'/g, `'"'"'`)}'`; } function emptyRegistry(): RegistryFile { return { version: REGISTRY_VERSION, agents: {}, }; } function isTerminalStatus(status: AgentStatus): boolean { return status === "done" || status === "failed" || status === "crashed"; } const PROMPT_LOG_PREFIX = "[side-agent][prompt]"; const TASK_PREVIEW_MAX_CHARS = 220; const BACKLOG_LINE_MAX_CHARS = 240; const BACKLOG_TOTAL_MAX_CHARS = 2400; const TMUX_BACKLOG_CAPTURE_LINES = 300; const BACKLOG_SEPARATOR_RE = /^[-─—_=]{5,}$/u; const ANSI_CSI_RE = /\x1b\[[0-?]*[ -/]*[@-~]/g; const ANSI_OSC_RE = /\x1b\][^\x07]*(?:\x07|\x1b\\)/g; const CONTROL_RE = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g; const SUMMARY_MAX_LINES = 10; const SUMMARY_MAX_CHARS = 700; const SUMMARY_NONE_RE = /^(?:none|n\/a|no relevant context(?: from parent session)?\.?|unrelated)\s*$/i; function resolveBacklogPathForRecord(stateRoot: string, record: AgentRecord): string { if (record.logPath) return record.logPath; if (record.runtimeDir) return join(record.runtimeDir, "backlog.log"); return join(getRuntimeDir(stateRoot, record.id), "backlog.log"); } async function appendKickoffPromptToBacklog( stateRoot: string, record: AgentRecord, prompt: string, loggedAt = nowIso(), ): Promise { const backlogPath = resolveBacklogPathForRecord(stateRoot, record); const promptLines = prompt.replace(/\r\n?/g, "\n").split("\n"); const body = promptLines .map((line) => `${PROMPT_LOG_PREFIX} ${loggedAt} ${record.id}: ${line}`) .join("\n"); const payload = `${PROMPT_LOG_PREFIX} ${loggedAt} ${record.id}: kickoff prompt begin\n` + `${body}\n` + `${PROMPT_LOG_PREFIX} ${loggedAt} ${record.id}: kickoff prompt end\n`; try { await ensureDir(dirname(backlogPath)); await fs.appendFile(backlogPath, payload, "utf8"); record.logPath = record.logPath ?? backlogPath; record.runtimeDir = record.runtimeDir ?? dirname(backlogPath); } catch { // Best effort only; prompt logging must not block agent startup. } } async function setRecordStatus(_stateRoot: string, record: AgentRecord, nextStatus: AgentStatus): Promise { const previousStatus = record.status; if (previousStatus === nextStatus) return false; record.status = nextStatus; record.updatedAt = nowIso(); return true; } function statusShort(status: AgentStatus): string { switch (status) { case "allocating_worktree": return "alloc"; case "spawning_tmux": return "tmux"; case "running": return "run"; case "waiting_user": return "wait"; case "done": return "done"; case "failed": return "fail"; case "crashed": return "crash"; } } function statusColorRole(status: AgentStatus): "warning" | "muted" | "accent" | "error" { switch (status) { // Rare/transient states: highlight so they stand out. case "allocating_worktree": case "spawning_tmux": return "warning"; // Normal working states: keep low visual weight. case "running": case "done": return "muted"; // Needs user attention. case "waiting_user": return "accent"; // Terminal failure. case "failed": case "crashed": return "error"; } } function stripTerminalNoise(text: string): string { return text.replace(ANSI_CSI_RE, "").replace(ANSI_OSC_RE, "").replace(/\r/g, "").replace(CONTROL_RE, ""); } function truncateWithEllipsis(text: string, maxChars: number): string { if (maxChars <= 0) return ""; if (text.length <= maxChars) return text; if (maxChars === 1) return "…"; return `${text.slice(0, maxChars - 1)}…`; } function normalizeGeneratedSummary(raw: string): string { const cleaned = stripTerminalNoise(raw).trim(); if (!cleaned) return ""; const fenced = cleaned.match(/^```(?:markdown|md)?\s*\n?([\s\S]*?)\n?```$/i); const unfenced = (fenced ? fenced[1] : cleaned).trim(); if (!unfenced) return ""; if (SUMMARY_NONE_RE.test(unfenced)) return ""; const compactLines: string[] = []; let previousBlank = false; for (const rawLine of unfenced.replace(/\r\n?/g, "\n").split("\n")) { const line = rawLine.trimEnd(); const blank = line.trim().length === 0; if (blank) { if (previousBlank) continue; previousBlank = true; } else { previousBlank = false; } compactLines.push(line); if (compactLines.length >= SUMMARY_MAX_LINES) break; } const summary = compactLines.join("\n").trim(); if (!summary || SUMMARY_NONE_RE.test(summary)) return ""; return truncateWithEllipsis(summary, SUMMARY_MAX_CHARS); } function summarizeTask(task: string): string { const collapsed = stripTerminalNoise(task).replace(/\s+/g, " ").trim(); return truncateWithEllipsis(collapsed, TASK_PREVIEW_MAX_CHARS); } function isBacklogSeparatorLine(line: string): boolean { return BACKLOG_SEPARATOR_RE.test(line.trim()); } function splitLines(text: string): string[] { return text .split(/\r?\n/) .filter((line, i, arr) => !(i === arr.length - 1 && line.length === 0)); } function collectRecentBacklogLines(lines: string[], minimumLines: number): string[] { if (minimumLines <= 0) return []; const selected: string[] = []; for (let i = lines.length - 1; i >= 0; i -= 1) { const cleaned = stripTerminalNoise(lines[i]).trimEnd(); if (cleaned.length === 0) continue; if (isBacklogSeparatorLine(cleaned)) continue; selected.push(lines[i]); if (selected.length >= minimumLines) break; } return selected.reverse(); } function selectBacklogTailLines(text: string, minimumLines: number): string[] { return collectRecentBacklogLines(splitLines(text), minimumLines); } function sanitizeBacklogLines(lines: string[]): string[] { const out: string[] = []; let remaining = BACKLOG_TOTAL_MAX_CHARS; for (const raw of lines) { if (remaining <= 0) break; const cleaned = stripTerminalNoise(raw).trimEnd(); if (cleaned.length === 0) continue; if (isBacklogSeparatorLine(cleaned)) continue; const line = truncateWithEllipsis(cleaned, BACKLOG_LINE_MAX_CHARS); if (line.length <= remaining) { out.push(line); remaining -= line.length + 1; continue; } out.push(truncateWithEllipsis(line, remaining)); remaining = 0; break; } return out; } function normalizeWaitStates(input?: string[]): { values: AgentStatus[]; error?: string } { if (!input || input.length === 0) { return { values: DEFAULT_WAIT_STATES }; } const trimmed = [...new Set(input.map((value) => value.trim()).filter(Boolean))]; if (trimmed.length === 0) { return { values: DEFAULT_WAIT_STATES }; } const known = new Set(ALL_AGENT_STATUSES); const invalid = trimmed.filter((value) => !known.has(value as AgentStatus)); if (invalid.length > 0) { return { values: [], error: `Unknown status value(s): ${invalid.join(", ")}`, }; } return { values: trimmed as AgentStatus[], }; } function tailLines(text: string, count: number): string[] { return splitLines(text).slice(-count); } function run(command: string, args: string[], options?: { cwd?: string; input?: string }): CommandResult { const result = spawnSync(command, args, { cwd: options?.cwd, input: options?.input, encoding: "utf8", }); if (result.error) { return { ok: false, status: result.status, stdout: result.stdout ?? "", stderr: result.stderr ?? "", error: result.error.message, }; } return { ok: result.status === 0, status: result.status, stdout: result.stdout ?? "", stderr: result.stderr ?? "", }; } function runOrThrow(command: string, args: string[], options?: { cwd?: string; input?: string }): CommandResult { const result = run(command, args, options); if (!result.ok) { const reason = result.error ? `error=${result.error}` : `exit=${result.status}`; throw new Error(`Command failed: ${command} ${args.join(" ")} (${reason})\n${result.stderr || result.stdout}`.trim()); } return result; } function resolveGitRoot(cwd: string): string { const result = run("git", ["-C", cwd, "rev-parse", "--show-toplevel"]); if (result.ok) { const root = result.stdout.trim(); if (root.length > 0) return resolve(root); } return resolve(cwd); } function getCurrentBranch(cwd: string): string { const result = run("git", ["-C", cwd, "rev-parse", "--abbrev-ref", "HEAD"]); if (!result.ok) return ""; const branch = result.stdout.trim(); if (!branch || branch === "HEAD") return ""; return branch; } function getStateRoot(ctx: ExtensionContext): string { const fromEnv = process.env[ENV_STATE_ROOT]; if (fromEnv) return resolve(fromEnv); return resolveGitRoot(ctx.cwd); } function getMetaDir(stateRoot: string): string { return join(stateRoot, ".pi", "side-agents"); } function getRegistryPath(stateRoot: string): string { return join(getMetaDir(stateRoot), "registry.json"); } function getRegistryLockPath(stateRoot: string): string { return join(getMetaDir(stateRoot), "registry.lock"); } function getWorktreeAllocLockPath(stateRoot: string): string { return join(getMetaDir(stateRoot), "worktree-alloc.lock"); } function getRuntimeDir(stateRoot: string, agentId: string): string { return join(getMetaDir(stateRoot), "runtime", agentId); } function getRuntimeArchiveBaseDir(stateRoot: string, agentId: string): string { return join(getMetaDir(stateRoot), "runtime-archive", agentId); } function runtimeArchiveStamp(): string { return nowIso().replace(/[:.]/g, "-"); } async function prepareFreshRuntimeDir(stateRoot: string, agentId: string): Promise { const runtimeDir = getRuntimeDir(stateRoot, agentId); if (!(await fileExists(runtimeDir))) { await ensureDir(runtimeDir); return { runtimeDir }; } const archiveBaseDir = getRuntimeArchiveBaseDir(stateRoot, agentId); const archiveDir = join( archiveBaseDir, `${runtimeArchiveStamp()}-${process.pid}-${Math.random().toString(16).slice(2, 8)}`, ); try { await ensureDir(archiveBaseDir); await fs.rename(runtimeDir, archiveDir); await ensureDir(runtimeDir); return { runtimeDir, archivedRuntimeDir: archiveDir, }; } catch (archiveErr) { const archiveErrMessage = stringifyError(archiveErr); try { await fs.rm(runtimeDir, { recursive: true, force: true }); await ensureDir(runtimeDir); } catch (cleanupErr) { throw new Error( `Failed to prepare runtime dir ${runtimeDir}: archive failed (${archiveErrMessage}); cleanup failed (${stringifyError(cleanupErr)})`, ); } return { runtimeDir, warning: `Failed to archive existing runtime dir for ${agentId}: ${archiveErrMessage}. Removed stale runtime directory instead.`, }; } } async function fileExists(path: string): Promise { try { await fs.stat(path); return true; } catch { return false; } } async function ensureDir(path: string): Promise { await fs.mkdir(path, { recursive: true }); } async function readJsonFile(path: string): Promise { try { const raw = await fs.readFile(path, "utf8"); return JSON.parse(raw) as T; } catch { return undefined; } } async function atomicWrite(path: string, content: string): Promise { await ensureDir(dirname(path)); const tmp = `${path}.tmp.${process.pid}.${Math.random().toString(16).slice(2)}`; await fs.writeFile(tmp, content, "utf8"); await fs.rename(tmp, path); } // Cross-process mutex on a lock file. Lock files are created atomically with // their full content (write temp + link(2), which fails with EEXIST if the lock // exists), so a lock never exists without its owner pid and token. // // Known limits (no OS-level locking in Node): a lock whose holder pid is dead // is broken under a `.break` side lock; if a breaker itself dies within the // milliseconds it holds that side lock, *and* several waiters then contend for // a dead main lock at once, two of them may still both enter. A finite staleMs // evicts even a live holder, which is only safe for very short critical // sections (the registry lock); the worktree allocation lock never does. type FileLockState = { raw: string; ageMs: number; pid?: number; token?: string }; async function readFileLockState(lockPath: string): Promise { try { const st = await fs.stat(lockPath); const raw = await fs.readFile(lockPath, "utf8"); let pid: number | undefined; let token: string | undefined; try { const data = JSON.parse(raw); if (typeof data?.pid === "number") pid = data.pid; if (typeof data?.token === "string") token = data.token; } catch { // corrupt / legacy lock without metadata } return { raw, ageMs: Date.now() - st.mtimeMs, pid, token }; } catch { return undefined; } } /** Atomically create `lockPath` holding `{pid, token}`; false if it already exists. */ async function createLockFile(lockPath: string, token: string): Promise { const content = JSON.stringify({ pid: process.pid, token, createdAt: nowIso() }) + "\n"; const tmp = `${lockPath}.${token}.tmp`; await fs.writeFile(tmp, content, "utf8"); try { await fs.link(tmp, lockPath); return true; } catch (err: any) { if (err?.code === "EEXIST") return false; if (!["EPERM", "ENOTSUP", "EOPNOTSUPP", "ENOSYS"].includes(err?.code)) throw err; // Filesystem without hard links: fall back to O_EXCL create + write. let handle: Awaited>; try { handle = await fs.open(lockPath, "wx"); } catch (openErr: any) { if (openErr?.code === "EEXIST") return false; throw openErr; } try { await handle.writeFile(content, "utf8"); } finally { await handle.close().catch(() => {}); } return true; } finally { await fs.unlink(tmp).catch(() => {}); } } /** Remove `lockPath` only if it still carries `token`. */ async function removeOwnLockFile(lockPath: string, token: string): Promise { const current = await readFileLockState(lockPath); if (current?.token === token) await fs.unlink(lockPath).catch(() => {}); } function fileLockLooksStale(state: FileLockState, staleMs: number): boolean { if (state.ageMs > staleMs) return true; // Locks are created with their pid in place; a pid-less one is corrupt or // from an older version. Give a possible live writer plenty of time. if (state.pid === undefined) return state.ageMs > 60_000; return !isPidAlive(state.pid); } /** * Remove `lockPath` if it is stale. Breaking is serialized through a `.break` * side lock and the lock is re-read under it, so it is only removed if it is * still the exact instance (by unique token/content) judged stale — otherwise * two waiters that observed the same dead holder could each delete the lock * (the second one deleting the first one's fresh lock) and both enter. */ async function breakStaleFileLock(lockPath: string, staleMs: number): Promise { const observed = await readFileLockState(lockPath); if (!observed || !fileLockLooksStale(observed, staleMs)) return; const breakerPath = `${lockPath}.break`; const breakerToken = `${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`; if (!(await createLockFile(breakerPath, breakerToken))) { // Someone else is breaking it — unless that breaker died mid-break. const breaker = await readFileLockState(breakerPath); if (breaker && fileLockLooksStale(breaker, Number.POSITIVE_INFINITY)) { await fs.unlink(breakerPath).catch(() => {}); } return; } try { const current = await readFileLockState(lockPath); if (current && current.raw === observed.raw && fileLockLooksStale(current, staleMs)) { await fs.unlink(lockPath).catch(() => {}); } } finally { await removeOwnLockFile(breakerPath, breakerToken); } } async function withFileLock( lockPath: string, fn: () => Promise, options: { /** How long to keep retrying before giving up. */ timeoutMs?: number; /** Age after which a lock is broken even if its holder pid is alive. * Infinity = never evict a live holder. */ staleMs?: number; } = {}, ): Promise { const timeoutMs = options.timeoutMs ?? 10_000; const staleMs = options.staleMs ?? 30_000; const token = `${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`; await ensureDir(dirname(lockPath)); // Acquire. Kept separate from running fn so errors thrown by fn (even ones // with code EEXIST) are never mistaken for lock contention. const started = Date.now(); while (!(await createLockFile(lockPath, token))) { await breakStaleFileLock(lockPath, staleMs); if (Date.now() - started > timeoutMs) { const holder = await readFileLockState(lockPath); const holderInfo = holder?.pid !== undefined ? ` (held by pid ${holder.pid})` : ""; throw new Error(`Timed out waiting for lock ${lockPath}${holderInfo}; remove it if no live process holds it`); } await sleep(40 + Math.random() * 80); } try { return await fn(); } finally { // Release only our own lock: if it was evicted (finite staleMs) and // re-acquired meanwhile, deleting it would let a third party in. await removeOwnLockFile(lockPath, token); } } async function loadRegistry(stateRoot: string): Promise { const registryPath = getRegistryPath(stateRoot); const parsed = await readJsonFile(registryPath); if (!parsed || typeof parsed !== "object") return emptyRegistry(); if (parsed.version !== REGISTRY_VERSION || typeof parsed.agents !== "object" || parsed.agents === null) { return emptyRegistry(); } return parsed; } async function saveRegistry(stateRoot: string, registry: RegistryFile): Promise { const registryPath = getRegistryPath(stateRoot); await atomicWrite(registryPath, JSON.stringify(registry, null, 2) + "\n"); } async function mutateRegistry(stateRoot: string, mutator: (registry: RegistryFile) => Promise | void): Promise { const lockPath = getRegistryLockPath(stateRoot); return withFileLock(lockPath, async () => { const registry = await loadRegistry(stateRoot); const before = JSON.stringify(registry); await mutator(registry); const after = JSON.stringify(registry); if (after !== before) { await saveRegistry(stateRoot, registry); } return registry; }); } /** Sanitize a raw string into a kebab-case slug suitable for branch names and agent IDs. */ function sanitizeSlug(raw: string): string { return raw .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, "") .split("-") .filter(Boolean) .slice(0, 3) .join("-"); } /** Turn a task description into a slug by taking the first 3 meaningful words. */ function slugFromTask(task: string): string { const stopWords = new Set(["a", "an", "the", "to", "in", "on", "at", "of", "for", "and", "or", "is", "it", "be", "do", "with"]); const words = task .replace(/[^a-zA-Z0-9\s]/g, " ") .split(/\s+/) .map((w) => w.toLowerCase()) .filter((w) => w.length > 0 && !stopWords.has(w)); const slug = words.slice(0, 3).join("-"); return slug || "agent"; } /** Generate a slug via LLM, falling back to heuristic extraction from task text. */ async function generateSlug(ctx: ExtensionContext, task: string): Promise<{ slug: string; warning?: string }> { if (!ctx.model) { return { slug: slugFromTask(task), warning: "No model available for slug generation; used heuristic fallback." }; } try { const userMessage: Message = { role: "user", content: [ { type: "text", text: task, }, ], timestamp: Date.now(), }; const auth = await ctx.modelRegistry.getApiKeyAndHeaders(ctx.model); if (!auth.ok) { return { slug: slugFromTask(task), warning: `Slug generation auth failed: ${auth.error}. Used heuristic fallback.` }; } // Disable thinking for this trivial task. Models that cannot turn thinking off // (e.g. forced adaptive thinking, thinkingLevelMap.off === null) get minimal // effort instead — hence the generous maxTokens headroom for thinking blocks. // (Some providers ignore the effort hint; the heuristic fallback below covers // any case where the budget is still exhausted before text is emitted.) const supportsOff = !ctx.model.reasoning || getSupportedThinkingLevels(ctx.model).includes("off"); const response = await completeSimple( ctx.model, { systemPrompt: "Generate a 2-3 word kebab-case slug summarizing the given task. Reply with ONLY the slug, nothing else. Examples: fix-auth-leak, add-retry-logic, update-readme", messages: [userMessage], }, { apiKey: auth.apiKey, headers: auth.headers, maxTokens: supportsOff ? 100 : 1500, reasoning: supportsOff ? undefined : "minimal", }, ); const raw = response.content .filter((block): block is { type: "text"; text: string } => block.type === "text") .map((block) => block.text) .join("") .trim(); const slug = sanitizeSlug(raw); if (slug) return { slug }; return { slug: slugFromTask(task), warning: "LLM returned empty slug; used heuristic fallback." }; } catch (err) { return { slug: slugFromTask(task), warning: `Slug generation failed: ${stringifyError(err)}. Used heuristic fallback.`, }; } } /** Collect all agent IDs currently known in the registry, existing as side-agent branch refs, or checked out in worktrees. */ function existingAgentIds(registry: RegistryFile, repoRoot: string): Set { const ids = new Set(Object.keys(registry.agents)); // All side-agent/* refs — including branches parked by /quit that are not // checked out anywhere. Without this, a later agent with the same slug // would `checkout -B` the preserved ref and orphan its commits. Full // refnames: %(refname:short) is ambiguous when a same-named tag exists. const refs = run("git", ["-C", repoRoot, "for-each-ref", "--format=%(refname)", "refs/heads/side-agent/"]); if (refs.ok) { for (const line of refs.stdout.split(/\r?\n/)) { const ref = line.trim(); if (ref.startsWith("refs/heads/side-agent/")) { ids.add(ref.slice("refs/heads/side-agent/".length)); } } } const listed = run("git", ["-C", repoRoot, "worktree", "list", "--porcelain"]); if (listed.ok) { for (const line of listed.stdout.split(/\r?\n/)) { if (!line.startsWith("branch ")) continue; const branchRef = line.slice("branch ".length).trim(); if (!branchRef || branchRef === "(detached)") continue; const branch = branchRef.startsWith("refs/heads/") ? branchRef.slice("refs/heads/".length) : branchRef; if (branch.startsWith("side-agent/")) { ids.add(branch.slice("side-agent/".length)); } } } return ids; } /** Deduplicate a slug against existing IDs by appending -2, -3, etc. */ function deduplicateSlug(slug: string, existing: Set): string { if (!existing.has(slug)) return slug; for (let i = 2; ; i++) { const candidate = `${slug}-${i}`; if (!existing.has(candidate)) return candidate; } } async function writeWorktreeLock(worktreePath: string, payload: Record): Promise { const lockPath = join(worktreePath, ".pi", "active.lock"); await ensureDir(dirname(lockPath)); await atomicWrite(lockPath, JSON.stringify(payload, null, 2) + "\n"); } async function updateWorktreeLock(worktreePath: string, patch: Record): Promise { const lockPath = join(worktreePath, ".pi", "active.lock"); const current = (await readJsonFile>(lockPath)) ?? {}; await writeWorktreeLock(worktreePath, { ...current, ...patch }); } async function cleanupWorktreeLockBestEffort(worktreePath?: string, agentId?: string): Promise { if (!worktreePath) return; const lockPath = join(worktreePath, ".pi", "active.lock"); // If an agentId is provided, verify the lock actually belongs to this agent // before deleting — another agent may have since claimed the same worktree. if (agentId) { try { const lock = await readJsonFile>(lockPath); if (lock && typeof lock.agentId === "string" && lock.agentId !== agentId) { return; } } catch { // If we can't read the lock, proceed with deletion attempt } } await fs.unlink(lockPath).catch(() => {}); } /** Find the worktree path that currently has the given branch checked out, if any. */ function findWorktreeForBranch(repoRoot: string, branch: string): string | undefined { const result = run("git", ["-C", repoRoot, "worktree", "list", "--porcelain"]); if (!result.ok) return undefined; let currentPath: string | undefined; for (const line of result.stdout.split(/\r?\n/)) { if (line.startsWith("worktree ")) { currentPath = line.slice("worktree ".length).trim(); } else if (line.startsWith("branch ")) { const ref = line.slice("branch ".length).trim(); const name = ref.startsWith("refs/heads/") ? ref.slice("refs/heads/".length) : ref; if (name === branch && currentPath) return resolve(currentPath); } } return undefined; } function listRegisteredWorktrees(repoRoot: string): Set { const result = runOrThrow("git", ["-C", repoRoot, "worktree", "list", "--porcelain"]); const set = new Set(); for (const line of result.stdout.split(/\r?\n/)) { if (line.startsWith("worktree ")) { set.add(resolve(line.slice("worktree ".length).trim())); } } return set; } type WorktreeSlot = { index: number; path: string; }; type OrphanWorktreeLock = { worktreePath: string; lockPath: string; lockAgentId?: string; lockPid?: number; lockTmuxWindowId?: string; blockers: string[]; }; type OrphanWorktreeLockScan = { reclaimable: OrphanWorktreeLock[]; blocked: OrphanWorktreeLock[]; }; async function listWorktreeSlots(repoRoot: string): Promise { const parent = dirname(repoRoot); const prefix = `${basename(repoRoot)}-agent-worktree-`; const re = new RegExp(`^${prefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(\\d{4})$`); const entries = await fs.readdir(parent, { withFileTypes: true }); const slots: WorktreeSlot[] = []; for (const entry of entries) { if (!entry.isDirectory()) continue; const match = entry.name.match(re); if (!match) continue; const index = Number(match[1]); if (!Number.isFinite(index)) continue; slots.push({ index, path: join(parent, entry.name), }); } slots.sort((a, b) => a.index - b.index); return slots; } function parseOptionalPid(value: unknown): number | undefined { if (typeof value === "number" && Number.isFinite(value) && Number.isInteger(value) && value > 0) { return value; } if (typeof value === "string" && /^\d+$/.test(value)) { const parsed = Number(value); if (Number.isFinite(parsed) && parsed > 0) return parsed; } return undefined; } function isPidAlive(pid?: number): boolean { if (pid === undefined) return false; try { process.kill(pid, 0); return true; } catch (err: any) { return err?.code === "EPERM"; } } function summarizeOrphanLock(lock: OrphanWorktreeLock): string { const details: string[] = []; if (lock.lockAgentId) details.push(`agent:${lock.lockAgentId}`); if (lock.lockTmuxWindowId) details.push(`tmux:${lock.lockTmuxWindowId}`); if (lock.lockPid !== undefined) details.push(`pid:${lock.lockPid}`); if (details.length === 0) return lock.worktreePath; return `${lock.worktreePath} (${details.join(" ")})`; } async function scanOrphanWorktreeLocks(repoRoot: string, registry: RegistryFile): Promise { const slots = await listWorktreeSlots(repoRoot); const reclaimable: OrphanWorktreeLock[] = []; const blocked: OrphanWorktreeLock[] = []; for (const slot of slots) { const lockPath = join(slot.path, ".pi", "active.lock"); if (!(await fileExists(lockPath))) continue; const raw = (await readJsonFile>(lockPath)) ?? {}; const lockAgentId = typeof raw.agentId === "string" ? raw.agentId : undefined; if (lockAgentId && registry.agents[lockAgentId]) { continue; } const lockPid = parseOptionalPid(raw.pid); const lockTmuxWindowId = typeof raw.tmuxWindowId === "string" ? raw.tmuxWindowId : undefined; const blockers: string[] = []; if (isPidAlive(lockPid)) { blockers.push(`pid ${lockPid} is still alive`); } if (lockTmuxWindowId && tmuxWindowExists(lockTmuxWindowId)) { blockers.push(`tmux window ${lockTmuxWindowId} is active`); } const candidate: OrphanWorktreeLock = { worktreePath: slot.path, lockPath, lockAgentId, lockPid, lockTmuxWindowId, blockers, }; if (blockers.length > 0) { blocked.push(candidate); } else { reclaimable.push(candidate); } } return { reclaimable, blocked }; } async function reclaimOrphanWorktreeLocks(locks: OrphanWorktreeLock[]): Promise<{ removed: string[]; failed: Array<{ lockPath: string; error: string }>; }> { const removed: string[] = []; const failed: Array<{ lockPath: string; error: string }> = []; for (const lock of locks) { try { await fs.unlink(lock.lockPath); removed.push(lock.lockPath); } catch (err: any) { if (err?.code === "ENOENT") continue; failed.push({ lockPath: lock.lockPath, error: stringifyError(err) }); } } return { removed, failed }; } async function syncParallelAgentPiFiles(parentRepoRoot: string, worktreePath: string): Promise { const parentPiDir = join(parentRepoRoot, ".pi"); if (!(await fileExists(parentPiDir))) return; const sourceEntries = await fs.readdir(parentPiDir, { withFileTypes: true }); const names = sourceEntries .filter((entry) => entry.name.startsWith("side-agent-")) .map((entry) => entry.name); if (names.length === 0) return; const worktreePiDir = join(worktreePath, ".pi"); await ensureDir(worktreePiDir); for (const name of names) { const source = join(parentPiDir, name); const target = join(worktreePiDir, name); let shouldLink = true; try { const st = await fs.lstat(target); if (st.isSymbolicLink()) { const existing = await fs.readlink(target); if (resolve(dirname(target), existing) === resolve(source)) { shouldLink = false; } } if (shouldLink) { await fs.rm(target, { recursive: true, force: true }); } } catch { // missing target } if (shouldLink) { await fs.symlink(source, target); } } } type AllocateWorktreeOptions = Parameters[0]; /** * Pick and claim a worktree slot. The free-slot scan and the claim (writing * `.pi/active.lock`) must happen atomically: otherwise concurrent starts — e.g. * parallel agent-start tool calls, or two sessions — each see the same slot as * free and both check out their branch in it, the later one deleting the * earlier one's (freshly created, hence "fully merged") branch. Serialize via a * cross-process file lock held from scan through lock write. Git checkouts can * be slow on big repos, so wait long and only break the lock if its holder pid * is dead — a live (if slow) holder is never evicted. */ async function allocateWorktree(options: AllocateWorktreeOptions): Promise { return withFileLock(getWorktreeAllocLockPath(options.stateRoot), () => allocateWorktreeUnlocked(options), { timeoutMs: 10 * 60_000, staleMs: Number.POSITIVE_INFINITY, }); } async function allocateWorktreeUnlocked(options: { repoRoot: string; stateRoot: string; agentId: string; parentSessionId?: string; /** Resume mode: check out this pre-existing branch instead of creating side-agent/ from HEAD. */ existingBranch?: string; /** Resume mode: the session's original cwd; when it is a free slot, prefer it * over other free slots so the resumed conversation's paths stay valid. */ preferredPath?: string; /** Commit to base a fresh branch on; defaults to the main repo's HEAD. A nested * agent passes its own HEAD so the grandchild's work builds on the child's branch. */ baseHead?: string; }): Promise { const { repoRoot, stateRoot, agentId, parentSessionId, existingBranch, preferredPath } = options; const warnings: string[] = []; const branch = existingBranch ?? `side-agent/${agentId}`; const mainHead = options.baseHead ?? runOrThrow("git", ["-C", repoRoot, "rev-parse", "HEAD"]).stdout.trim(); const registry = await loadRegistry(stateRoot); const slots = await listWorktreeSlots(repoRoot); const registered = listRegisteredWorktrees(repoRoot); if (preferredPath) { // The free-slot scan below takes the first eligible slot; move the // session's original directory to the front so it wins when it is free. const resolvedPreferred = resolve(preferredPath); const idx = slots.findIndex((s) => resolve(s.path) === resolvedPreferred); if (idx > 0) slots.unshift(...slots.splice(idx, 1)); } let chosen: WorktreeSlot | undefined; let maxIndex = 0; // Build a set of worktree paths claimed by active (non-terminal) agents in the registry, // so we can reject slots even if the lock file was inadvertently cleaned up. const claimedByActiveAgent = new Set(); // Worktrees a live nested agent will merge into. Reusing such a slot for a // new agent would let the nested agent's finish script `git checkout` the // (possibly pruned) parent's branch inside the newcomer's worktree. const pinnedAsParentCheckout = new Set(); for (const record of Object.values(registry.agents)) { if (record.id === agentId || isTerminalStatus(record.status)) continue; if (record.worktreePath) claimedByActiveAgent.add(resolve(record.worktreePath)); if (record.parentWorktreePath) pinnedAsParentCheckout.add(resolve(record.parentWorktreePath)); } // Resume mode: if the branch still exists and is still checked out in one of // our slots, prefer resuming in place — this preserves the working tree // exactly (including any uncommitted state left behind at /quit). let resumeBranchExists = false; let resumeInPlace = false; if (existingBranch) { resumeBranchExists = run("git", ["-C", repoRoot, "rev-parse", "--verify", "--quiet", `refs/heads/${branch}`]).ok; if (!resumeBranchExists) { warnings.push(`Branch ${branch} no longer exists (pruned as fully merged); recreating it from current HEAD.`); } else { const holder = findWorktreeForBranch(repoRoot, branch); if (holder) { if (holder === resolve(repoRoot)) { throw new Error(`Branch ${branch} is checked out in the main worktree; cannot resume it in a side-agent slot`); } const slot = slots.find((s) => resolve(s.path) === holder); if (!slot) { throw new Error(`Branch ${branch} is checked out at ${holder}, which is not a side-agent worktree slot`); } if (await fileExists(join(slot.path, ".pi", "active.lock"))) { throw new Error(`Worktree ${slot.path} holding branch ${branch} is locked by another agent`); } if (claimedByActiveAgent.has(resolve(slot.path))) { throw new Error(`Worktree ${slot.path} holding branch ${branch} is claimed by an active agent`); } chosen = slot; resumeInPlace = true; } } } for (const slot of slots) { maxIndex = Math.max(maxIndex, slot.index); if (chosen) continue; const resolvedSlotPath = resolve(slot.path); const lockPath = join(slot.path, ".pi", "active.lock"); // Check 1: lock file on disk. if (await fileExists(lockPath)) { const lock = await readJsonFile>(lockPath); const lockAgentId = typeof lock?.agentId === "string" ? lock.agentId : undefined; if (!lockAgentId || !registry.agents[lockAgentId]) { warnings.push(`Locked worktree is not tracked in registry: ${slot.path}`); } continue; } // Check 2: registry claims this worktree for an active agent (even if lock is missing). if (claimedByActiveAgent.has(resolvedSlotPath)) { warnings.push(`Worktree claimed by active agent in registry (missing lock): ${slot.path}`); continue; } // Check 3: a live nested agent still has to merge into this worktree. if (pinnedAsParentCheckout.has(resolvedSlotPath)) { warnings.push(`Worktree is the merge target of a live nested agent, skipping: ${slot.path}`); continue; } const isRegistered = registered.has(resolve(slot.path)); if (isRegistered) { const status = run("git", ["-C", slot.path, "status", "--porcelain"]); if (!status.ok) { warnings.push(`Could not inspect unlocked worktree, skipping: ${slot.path}`); continue; } if (status.stdout.trim().length > 0) { warnings.push(`Unlocked worktree has local changes, skipping: ${slot.path}`); continue; } } else { const entries = await fs.readdir(slot.path).catch(() => []); if (entries.length > 0) { warnings.push(`Unlocked slot is not a registered worktree and not empty, skipping: ${slot.path}`); continue; } } chosen = slot; } if (!chosen) { const next = maxIndex + 1 || 1; const parent = dirname(repoRoot); const name = `${basename(repoRoot)}-agent-worktree-${String(next).padStart(4, "0")}`; chosen = { index: next, path: join(parent, name) }; } const chosenPath = chosen.path; const chosenRegistered = registered.has(resolve(chosenPath)); if (resumeInPlace) { // The worktree still has the resumed branch checked out; leave the working // tree untouched (it may carry uncommitted state from before the /quit). } else if (chosenRegistered) { // Remember old branch so we can try to clean it up after switching away. const oldBranch = getCurrentBranch(chosenPath); run("git", ["-C", chosenPath, "merge", "--abort"]); // Detach HEAD before resetting: reset --hard with the old branch still // checked out would move the old branch ref to mainHead, orphaning any // unmerged commits and letting the `branch -d` below silently delete a // branch the README promises survives /quit. runOrThrow("git", ["-C", chosenPath, "checkout", "--detach"]); runOrThrow("git", ["-C", chosenPath, "reset", "--hard", mainHead]); runOrThrow("git", ["-C", chosenPath, "clean", "-fd"]); if (existingBranch && resumeBranchExists) { runOrThrow("git", ["-C", chosenPath, "checkout", branch]); } else { runOrThrow("git", ["-C", chosenPath, "checkout", "-B", branch, mainHead]); } // Best-effort cleanup: delete old branch if fully merged (-d, not -D). if (oldBranch && oldBranch !== branch) { run("git", ["-C", repoRoot, "branch", "-d", oldBranch]); } } else { if (await fileExists(chosenPath)) { const entries = await fs.readdir(chosenPath).catch(() => []); if (entries.length > 0) { throw new Error(`Cannot use worktree slot ${chosenPath}: directory exists and is not empty`); } } await ensureDir(dirname(chosenPath)); if (existingBranch && resumeBranchExists) { runOrThrow("git", ["-C", repoRoot, "worktree", "add", chosenPath, branch]); } else { runOrThrow("git", ["-C", repoRoot, "worktree", "add", "-B", branch, chosenPath, mainHead]); } } await ensureDir(join(chosenPath, ".pi")); await syncParallelAgentPiFiles(repoRoot, chosenPath); await writeWorktreeLock(chosenPath, { agentId, sessionId: parentSessionId, parentSessionId, pid: process.pid, branch, startedAt: nowIso(), }); return { worktreePath: chosenPath, slotIndex: chosen.index, branch, warnings, branchReattached: Boolean(existingBranch) && resumeBranchExists, workingTreePreserved: resumeInPlace, }; } async function buildKickoffPrompt(ctx: ExtensionContext, task: string, includeSummary: boolean): Promise<{ prompt: string; warning?: string }> { const parentSession = ctx.sessionManager.getSessionFile(); const sessionSuffix = parentSession ? `\n\nParent Pi session: ${parentSession}` : ""; if (!includeSummary || !ctx.model) { return { prompt: task + sessionSuffix }; } const branch = ctx.sessionManager.getBranch(); const messages = branch .filter((entry): entry is SessionEntry & { type: "message" } => entry.type === "message") .map((entry) => entry.message); if (messages.length === 0) { return { prompt: task }; } try { const llmMessages = convertToLlm(messages); const conversationText = serializeConversation(llmMessages); const userMessage: Message = { role: "user", content: [ { type: "text", text: `## Parent conversation\n\n${conversationText}\n\n## Child task\n\n${task}`, }, ], timestamp: Date.now(), }; const auth = await ctx.modelRegistry.getApiKeyAndHeaders(ctx.model); if (!auth.ok) throw new Error(`Auth failed for summary generation: ${auth.error}`); const response = await complete( ctx.model, { systemPrompt: SUMMARY_SYSTEM_PROMPT, messages: [userMessage] }, { apiKey: auth.apiKey, headers: auth.headers }, ); const summary = normalizeGeneratedSummary( response.content .filter((block): block is { type: "text"; text: string } => block.type === "text") .map((block) => block.text) .join("\n"), ); if (!summary) { return { prompt: task + sessionSuffix }; } const prompt = [ task, "", "## Parent session", parentSession ? `- ${parentSession}` : "- (unknown)", "", "## Relevant parent context", summary, ].join("\n"); return { prompt }; } catch (err) { return { prompt: task + sessionSuffix, warning: `Failed to generate context summary: ${stringifyError(err)}. Started child with raw task only.`, }; } } function buildLaunchScript(params: { agentId: string; parentSessionId?: string; /** The spawning session's checkout (main repo, or the parent agent's worktree when nested). */ parentRepoRoot: string; /** Branch checked out in parentRepoRoot: the merge target for the finish script. */ parentBranch: string; parentAgentId?: string; depth: number; stateRoot: string; worktreePath: string; tmuxWindowId: string; promptPath: string; exitFile: string; modelSpec?: string; runtimeDir: string; /** Resume mode: pi session file to reopen. */ sessionPath?: string; /** "session" continues the file in place (cwd matches); "fork" re-homes it into this worktree. */ sessionMode?: "session" | "fork"; }): string { return `#!/usr/bin/env bash set -euo pipefail AGENT_ID=${shellQuote(params.agentId)} PARENT_SESSION=${shellQuote(params.parentSessionId ?? "")} PARENT_REPO=${shellQuote(params.parentRepoRoot)} PARENT_BRANCH=${shellQuote(params.parentBranch)} PARENT_AGENT_ID=${shellQuote(params.parentAgentId ?? "")} AGENT_DEPTH=${shellQuote(String(params.depth))} STATE_ROOT=${shellQuote(params.stateRoot)} WORKTREE=${shellQuote(params.worktreePath)} WINDOW_ID=${shellQuote(params.tmuxWindowId)} PROMPT_FILE=${shellQuote(params.promptPath)} EXIT_FILE=${shellQuote(params.exitFile)} MODEL_SPEC=${shellQuote(params.modelSpec ?? "")} SESSION_FILE=${shellQuote(params.sessionPath ?? "")} SESSION_FLAG=${shellQuote(params.sessionMode === "session" ? "--session" : "--fork")} RUNTIME_DIR=${shellQuote(params.runtimeDir)} START_SCRIPT=\"$WORKTREE/.pi/side-agent-start.sh\" CHILD_SKILLS_DIR=\"$WORKTREE/.pi/side-agent-skills\" export ${ENV_AGENT_ID}=\"$AGENT_ID\" export ${ENV_PARENT_SESSION}=\"$PARENT_SESSION\" export ${ENV_PARENT_REPO}=\"$PARENT_REPO\" export ${ENV_PARENT_BRANCH}=\"$PARENT_BRANCH\" export ${ENV_PARENT_AGENT_ID}=\"$PARENT_AGENT_ID\" export ${ENV_DEPTH}=\"$AGENT_DEPTH\" export ${ENV_STATE_ROOT}=\"$STATE_ROOT\" export ${ENV_RUNTIME_DIR}=\"$RUNTIME_DIR\" iso_now() { date -u +"%Y-%m-%dT%H:%M:%SZ" } write_exit() { local code="$1" printf '{"exitCode":%d,"finishedAt":"%s"}\n' "$code" "$(iso_now)" > "$EXIT_FILE" } # Safety net: the start script is sourced and may carry its own \`set -e\`, # which can kill this wrapper shell mid-flight (e.g. a failed npm ci) before # the explicit write_exit calls below run. Without a marker the parent can # only report a generic "crashed / window disappeared"; with it, the failure # surfaces as \`failed\` with the real exit code. on_exit() { local code=$? if [[ ! -s "$EXIT_FILE" ]]; then write_exit "$code" fi } trap on_exit EXIT cd "$WORKTREE" if [[ -x "$START_SCRIPT" ]]; then set +e source "$START_SCRIPT" "$PARENT_REPO" "$WORKTREE" "$AGENT_ID" start_exit=$? set -e if [[ "$start_exit" -ne 0 ]]; then echo "[side-agent] start script failed with code $start_exit" write_exit "$start_exit" read -n 1 -s -r -p "[side-agent] Press any key to close this tmux window..." || true echo tmux kill-window -t "$WINDOW_ID" || true exit "$start_exit" fi fi PI_CMD=(pi) if [[ -n "$MODEL_SPEC" ]]; then PI_CMD+=(--model "$MODEL_SPEC") fi if [[ -n "$SESSION_FILE" ]]; then PI_CMD+=("$SESSION_FLAG" "$SESSION_FILE") fi if [[ -d "$CHILD_SKILLS_DIR" ]]; then # agent-setup writes the child-only finish skill here; load it explicitly. PI_CMD+=(--skill "$CHILD_SKILLS_DIR") fi set +e if [[ -s "$PROMPT_FILE" ]]; then "\${PI_CMD[@]}" "$(cat "$PROMPT_FILE")" else "\${PI_CMD[@]}" fi exit_code=$? set -e write_exit "$exit_code" if [[ "$exit_code" -eq 0 ]]; then echo "[side-agent] Agent finished." else echo "[side-agent] Agent exited with code $exit_code." fi read -n 1 -s -r -p "[side-agent] Press any key to close this tmux window..." || true echo tmux kill-window -t "$WINDOW_ID" || true `; } function ensureTmuxReady(): void { const version = run("tmux", ["-V"]); if (!version.ok) { throw new Error("tmux is required for /agent but was not found or is not working"); } const session = run("tmux", ["display-message", "-p", "#S"]); if (!session.ok) { throw new Error("/agent must be run from inside tmux (current tmux session was not detected)"); } } function getCurrentTmuxSession(): string { const result = runOrThrow("tmux", ["display-message", "-p", "#S"]); const value = result.stdout.trim(); if (!value) throw new Error("Failed to determine current tmux session"); return value; } function createTmuxWindow(tmuxSession: string, name: string): { windowId: string; windowIndex: number } { const result = runOrThrow("tmux", [ "new-window", "-d", "-t", `${tmuxSession}:`, "-P", "-F", "#{window_id} #{window_index}", "-n", name, ]); const out = result.stdout.trim(); const [windowId, indexRaw] = out.split(/\s+/); const windowIndex = Number(indexRaw); if (!windowId || !Number.isFinite(windowIndex)) { throw new Error(`Unable to parse tmux window identity: ${out}`); } return { windowId, windowIndex }; } function tmuxWindowExists(windowId: string): boolean { const result = run("tmux", ["display-message", "-p", "-t", windowId, "#{window_id}"]); return result.ok && result.stdout.trim() === windowId; } function tmuxPipePaneToFile(windowId: string, logPath: string): void { runOrThrow("tmux", ["pipe-pane", "-t", windowId, "-o", `cat >> ${shellQuote(logPath)}`]); } function tmuxSendLine(windowId: string, line: string): void { runOrThrow("tmux", ["send-keys", "-t", windowId, line, "C-m"]); } /** Wait for a shell prompt to appear in a newly created tmux window. */ async function tmuxWaitForShellReady(windowId: string, timeoutMs = 5000): Promise { const started = Date.now(); while (Date.now() - started < timeoutMs) { const captured = run("tmux", ["capture-pane", "-p", "-t", windowId]); if (captured.ok) { const lines = captured.stdout.split(/\r?\n/).filter((l) => l.trim().length > 0); // Look for a typical shell prompt ending in $, #, %, or > if (lines.some((l) => /[\$#%>]\s*$/.test(l))) { return; } } await sleep(50); } // Timed out — proceed anyway rather than failing the whole agent start. } function tmuxInterrupt(windowId: string): void { run("tmux", ["send-keys", "-t", windowId, "C-c"]); } function tmuxSendPrompt(windowId: string, prompt: string): void { const loaded = run("tmux", ["load-buffer", "-"], { input: prompt }); if (!loaded.ok) { throw new Error(`Failed to send input to tmux window ${windowId}: ${loaded.stderr || loaded.error || "unknown error"}`); } // -p wraps the paste in bracketed-paste escapes so the child TUI inserts // newlines into its editor instead of submitting each line separately. runOrThrow("tmux", [ "paste-buffer", "-d", "-p", "-t", windowId, ";", "send-keys", "-t", windowId, "Enter", ]); } function tmuxCaptureTail(windowId: string, lines = 10): string[] { const captured = run("tmux", ["capture-pane", "-p", "-t", windowId, "-S", `-${TMUX_BACKLOG_CAPTURE_LINES}`]); if (!captured.ok) return []; return tailLines(captured.stdout, lines); } /** Capture the currently visible tmux pane content (no scrollback). */ function tmuxCaptureVisible(windowId: string): string[] { const captured = run("tmux", ["capture-pane", "-p", "-t", windowId]); if (!captured.ok) return []; return splitLines(captured.stdout); } type RefreshRuntimeResult = { removeFromRegistry: boolean; }; async function refreshOneAgentRuntime(stateRoot: string, record: AgentRecord): Promise { if (record.status === "done") { await cleanupWorktreeLockBestEffort(record.worktreePath, record.id); return { removeFromRegistry: true }; } if (record.exitFile && (await fileExists(record.exitFile))) { const exit = (await readJsonFile(record.exitFile)) ?? {}; if (typeof exit.exitCode === "number") { record.exitCode = exit.exitCode; record.finishedAt = exit.finishedAt ?? record.finishedAt ?? nowIso(); const changed = await setRecordStatus(stateRoot, record, exit.exitCode === 0 ? "done" : "failed"); if (!changed) { record.updatedAt = nowIso(); } if (exit.exitCode === 0) { // Only release worktree lock for successful agents; failed agents // keep it so the workspace is not reused until explicitly cleared. await cleanupWorktreeLockBestEffort(record.worktreePath, record.id); return { removeFromRegistry: true }; } return { removeFromRegistry: false }; } } if (!record.tmuxWindowId) { return { removeFromRegistry: false }; } const live = tmuxWindowExists(record.tmuxWindowId); if (live) { if (record.status === "allocating_worktree" || record.status === "spawning_tmux") { await setRecordStatus(stateRoot, record, "running"); } return { removeFromRegistry: false }; } if (!isTerminalStatus(record.status)) { record.finishedAt = record.finishedAt ?? nowIso(); await setRecordStatus(stateRoot, record, "crashed"); if (!record.error) { record.error = "tmux window disappeared before an exit marker was recorded"; } // Do NOT release worktree lock for crashed agents; the workspace // is blocked from reuse until the agent is explicitly cleared. } return { removeFromRegistry: false }; } async function refreshAgent(stateRoot: string, agentId: string): Promise { let snapshot: AgentRecord | undefined; await mutateRegistry(stateRoot, async (registry) => { const record = registry.agents[agentId]; if (!record) return; const refreshed = await refreshOneAgentRuntime(stateRoot, record); if (refreshed.removeFromRegistry) { delete registry.agents[agentId]; return; } snapshot = JSON.parse(JSON.stringify(record)) as AgentRecord; }); return snapshot; } async function refreshAllAgents(stateRoot: string): Promise { // Don't create the meta dir just to discover there are no agents. if (!(await fileExists(getMetaDir(stateRoot)))) return emptyRegistry(); return mutateRegistry(stateRoot, async (registry) => { for (const [agentId, record] of Object.entries(registry.agents)) { const refreshed = await refreshOneAgentRuntime(stateRoot, record); if (refreshed.removeFromRegistry) { delete registry.agents[agentId]; } } }); } async function getBacklogTail(record: AgentRecord, lines = 10): Promise { // Prefer the visible tmux pane — it shows what's actually on screen // and avoids noise from TUI footer redraws that pollute the backlog file. if (record.tmuxWindowId && tmuxWindowExists(record.tmuxWindowId)) { const visible = tmuxCaptureVisible(record.tmuxWindowId); const result = sanitizeBacklogLines(collectRecentBacklogLines(visible, lines)); if (result.length > 0) return result; } // Fall back to the backlog log file (e.g. tmux window gone but file remains). if (record.logPath && (await fileExists(record.logPath))) { try { const raw = await fs.readFile(record.logPath, "utf8"); const tailed = sanitizeBacklogLines(selectBacklogTailLines(raw, lines)); if (tailed.length > 0) return tailed; } catch { // fall through } } return []; } function renderInfoMessage(pi: ExtensionAPI, ctx: ExtensionContext, title: string, lines: string[]): void { const content = [title, "", ...lines].join("\n"); if (ctx.hasUI) { pi.sendMessage({ customType: "side-agents-report", content, display: true, }); } else { console.log(content); } } type ModeFileSpec = { provider?: string; modelId?: string; thinkingLevel?: string }; type ParsedModesFile = { currentMode?: string; modes?: Record }; /** Read and parse modes.json, checking project-level first, then global. */ async function readModesFile(cwd: string): Promise<{ parsed: ParsedModesFile; path: string } | undefined> { const agentDir = getPiAgentDir(); const candidates = [ join(cwd, ".pi", "modes.json"), join(agentDir, "modes.json"), ]; for (const modesPath of candidates) { try { const raw = await fs.readFile(modesPath, "utf8"); const parsed = JSON.parse(raw) as ParsedModesFile; if (parsed.modes && typeof parsed.modes === "object" && Object.keys(parsed.modes).length > 0) { return { parsed, path: modesPath }; } } catch { continue; } } return undefined; } function modeSpecToModelSpec(spec: ModeFileSpec): string | undefined { if (!spec.provider || !spec.modelId) return undefined; return spec.thinkingLevel ? `${spec.provider}/${spec.modelId}:${spec.thinkingLevel}` : `${spec.provider}/${spec.modelId}`; } /** Resolve a pi-amplike mode name to a model spec by reading modes.json. */ async function resolveModeToModelSpec(cwd: string, modeName: string): Promise<{ modelSpec?: string; warning?: string }> { const file = await readModesFile(cwd); if (!file) return { warning: "Could not read modes.json" }; if (!file.parsed.modes?.[modeName]) { return { warning: `Mode '${modeName}' not found in ${file.path}` }; } const modelSpec = modeSpecToModelSpec(file.parsed.modes[modeName]); if (!modelSpec) { return { warning: `Mode '${modeName}' has no provider/modelId in ${file.path}` }; } return { modelSpec }; } /** * Infer the current mode name by matching the active model+thinking level * against modes.json definitions. Returns the mode's full model spec if found. */ async function inferCurrentModeModelSpec( cwd: string, ctx: ExtensionContext, thinkingLevel: string, ): Promise { if (!ctx.model) return undefined; const file = await readModesFile(cwd); if (!file?.parsed.modes) return undefined; const provider = ctx.model.provider; const modelId = ctx.model.id; for (const spec of Object.values(file.parsed.modes)) { if (spec.provider === provider && spec.modelId === modelId && (spec.thinkingLevel ?? undefined) === (thinkingLevel || undefined)) { return modeSpecToModelSpec(spec); } } return undefined; } function parseAgentCommandArgs(raw: string): { task: string; model?: string; mode?: string } { let rest = raw; let model: string | undefined; let mode: string | undefined; const modelMatch = rest.match(/(?:^|\s)-model\s+(\S+)/); if (modelMatch) { model = modelMatch[1]; rest = rest.replace(modelMatch[0], " "); } const modeMatch = rest.match(/(?:^|\s)-mode\s+(\S+)/); if (modeMatch) { mode = modeMatch[1]; rest = rest.replace(modeMatch[0], " "); } return { task: rest.trim(), model, mode, }; } const THINKING_LEVELS = new Set(["off", "minimal", "low", "medium", "high", "xhigh"]); function splitModelPatternAndThinking(raw: string): { pattern: string; thinking?: string } { const trimmed = raw.trim(); const colon = trimmed.lastIndexOf(":"); if (colon <= 0 || colon === trimmed.length - 1) return { pattern: trimmed }; const suffix = trimmed.slice(colon + 1); if (!THINKING_LEVELS.has(suffix)) return { pattern: trimmed }; return { pattern: trimmed.slice(0, colon), thinking: suffix, }; } function withThinking(modelSpec: string, thinking?: string): string { return thinking ? `${modelSpec}:${thinking}` : modelSpec; } async function resolveModelSpecForChild( ctx: ExtensionContext, requested?: string, thinkingLevel?: string, ): Promise<{ modelSpec?: string; warning?: string }> { const currentModelSpec = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined; if (!requested || requested.trim().length === 0) { // Try to inherit the full mode (model + thinking level) from modes.json if (thinkingLevel !== undefined) { const modeSpec = await inferCurrentModeModelSpec(ctx.cwd, ctx, thinkingLevel); if (modeSpec) return { modelSpec: modeSpec }; } return { modelSpec: currentModelSpec }; } const trimmed = requested.trim(); if (trimmed.includes("/")) { return { modelSpec: trimmed }; } const { pattern, thinking } = splitModelPatternAndThinking(trimmed); if (ctx.model && pattern === ctx.model.id) { return { modelSpec: withThinking(`${ctx.model.provider}/${ctx.model.id}`, thinking), }; } try { const available = (await ctx.modelRegistry.getAvailable()) as Array<{ provider: string; id: string }>; const exact = available.filter((model) => model.id === pattern); if (exact.length === 1) { const match = exact[0]; return { modelSpec: withThinking(`${match.provider}/${match.id}`, thinking), }; } if (exact.length > 1) { if (ctx.model) { const preferred = exact.find((model) => model.provider === ctx.model?.provider); if (preferred) { return { modelSpec: withThinking(`${preferred.provider}/${preferred.id}`, thinking), }; } } const providers = [...new Set(exact.map((model) => model.provider))].sort(); return { modelSpec: trimmed, warning: `Model '${pattern}' matches multiple providers (${providers.join(", ")}); child was started with raw pattern '${trimmed}'. Use provider/model to force a specific provider.`, }; } } catch { // Best effort only; keep raw model pattern. } return { modelSpec: trimmed }; } // Per-file scan bounds: everything we need lives near the start of a session // file (header, kickoff message, initial link entry) or near its end (link // corrections, latest session_info name), so head+tail slices give correct // results while keeping the per-file cost bounded — no candidate cap needed. const SCAN_HEAD_BYTES = 128 * 1024; const SCAN_TAIL_BYTES = 128 * 1024; type ResumableSession = { path: string; /** cwd recorded in the session header (used for in-place vs fork decision). */ sessionCwd?: string; modified: Date; created: Date; messageCount: number; name?: string; firstMessage?: string; agentId: string; /** Branch to reattach on resume; unset when the agent id is owned elsewhere * (a newer candidate session or a currently active agent). */ branch?: string; branchExists: boolean; /** True when the agent id is owned by a newer session or an active agent. */ branchHeldElsewhere: boolean; /** Who owns the branch when branchHeldElsewhere is set. */ branchHeldBy?: "active-agent" | "newer-session"; }; /** Resolve pi's agent dir (respects PI_CODING_AGENT_DIR). */ function getPiAgentDir(): string { const homedir = os.homedir(); return process.env.PI_CODING_AGENT_DIR ? resolve(process.env.PI_CODING_AGENT_DIR.replace(/^~/, homedir)) : join(homedir, ".pi", "agent"); } /** Pi's per-cwd session directory name encoding (mirrors pi's own). */ function encodeSessionDirName(cwd: string): string { return `--${resolve(cwd).replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`; } /** * Session directories of this repo's agent worktree slots — enumerated by the * encoded directory-name pattern in pi's sessions root, NOT by live slot * directories: sessions of since-deleted worktree slots remain resumable * (resume forks re-home them into whatever slot gets allocated). */ async function listSlotSessionDirs(repoRoot: string): Promise { const sessionsRoot = join(getPiAgentDir(), "sessions"); const slotPathPrefix = join(dirname(repoRoot), `${basename(repoRoot)}-agent-worktree-`); const encodedPrefix = encodeSessionDirName(slotPathPrefix).replace(/--$/, ""); // Match slot roots AND subdirectories of slots (pi sessions may be started // deeper in a worktree, e.g. after a ctrl+z cd; they inherit the agent env // and carry link entries). The side-agent-link filter downstream is the // real gate against unrelated sessions. const re = new RegExp(`^${encodedPrefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\d{4}(?:-.+)?--$`); const entries = await fs.readdir(sessionsRoot).catch(() => [] as string[]); return entries.filter((name) => re.test(name)).map((name) => join(sessionsRoot, name)); } type ScannedSession = { sessionCwd?: string; agentId?: string; name?: string; firstMessage?: string; /** Approximate for large (sliced) files. */ messageCount: number; }; function extractTextFromMessageContent(content: unknown): string { if (typeof content === "string") return content; if (!Array.isArray(content)) return ""; return content .map((block) => block && typeof block === "object" && (block as { type?: string }).type === "text" ? String((block as { text?: unknown }).text ?? "") : "", ) .filter(Boolean) .join(" "); } /** * Read a session file's lines for scanning. Small files are read whole; large * files contribute only head+tail slices (partial lines at the cut points are * dropped — a middle-of-file session_info rename may be missed, degrading the * preview to the kickoff message, which is acceptable). */ async function readSessionSliceLines(sessionPath: string): Promise { try { const handle = await fs.open(sessionPath, "r"); try { const { size } = await handle.stat(); if (size <= SCAN_HEAD_BYTES + SCAN_TAIL_BYTES) { return (await handle.readFile({ encoding: "utf8" })).split("\n"); } const head = Buffer.alloc(SCAN_HEAD_BYTES); await handle.read(head, 0, SCAN_HEAD_BYTES, 0); const tail = Buffer.alloc(SCAN_TAIL_BYTES); await handle.read(tail, 0, SCAN_TAIL_BYTES, size - SCAN_TAIL_BYTES); const headLines = head.toString("utf8").split("\n"); headLines.pop(); // partial line at the cut const tailLines = tail.toString("utf8").split("\n"); tailLines.shift(); // partial line at the cut return [...headLines, ...tailLines]; } finally { await handle.close(); } } catch { return undefined; } } /** * Authoritative (whole-file) link-entry read for a single chosen session. * The sliced scan used for listing can miss a corrected link entry buried in * the omitted middle of a large file; before acting on a selection we must * use the true last link, not the sliced approximation. */ async function readSessionAgentIdAuthoritative(sessionPath: string): Promise { let found: string | undefined; try { const raw = await fs.readFile(sessionPath, "utf8"); for (const line of raw.split("\n")) { if (!line.includes(`"${CHILD_LINK_ENTRY_TYPE}"`)) continue; try { const entry = JSON.parse(line) as { customType?: string; data?: { agentId?: unknown } }; if (entry?.customType === CHILD_LINK_ENTRY_TYPE && typeof entry.data?.agentId === "string") { found = entry.data.agentId; } } catch { // skip unparsable lines } } } catch { // unreadable session file } return found; } /** * Single-pass, substring-gated scan of a session file. Unlike * SessionManager.list() this does NOT JSON-parse every line of every session * (which is prohibitively slow across hundreds of MB of session history); * only lines that look relevant are parsed: * - header (`"type":"session"`) → recorded cwd * - first user message → preview text * - side-agent-link entries → agent id (LAST entry wins: a session resumed * under a deduplicated id appends a corrected link after the inherited one) * - session_info entries → display name (last wins, empty clears) */ async function scanSessionFile(sessionPath: string): Promise { const out: ScannedSession = { messageCount: 0 }; const lines = await readSessionSliceLines(sessionPath); if (!lines) return out; for (const line of lines) { try { if (!out.sessionCwd && line.includes('"type":"session"')) { const entry = JSON.parse(line) as { type?: string; cwd?: unknown }; if (entry?.type === "session" && typeof entry.cwd === "string") out.sessionCwd = entry.cwd; } else if (line.includes('"type":"message"')) { out.messageCount += 1; if (out.firstMessage === undefined) { const entry = JSON.parse(line) as { type?: string; message?: { role?: string; content?: unknown } }; if (entry?.type === "message" && entry.message?.role === "user") { const text = extractTextFromMessageContent(entry.message.content).trim(); if (text) out.firstMessage = text; } } } else if (line.includes(`"${CHILD_LINK_ENTRY_TYPE}"`)) { const entry = JSON.parse(line) as { customType?: string; data?: { agentId?: unknown } }; if (entry?.customType === CHILD_LINK_ENTRY_TYPE && typeof entry.data?.agentId === "string") { out.agentId = entry.data.agentId; } } else if (line.includes('"type":"session_info"')) { const entry = JSON.parse(line) as { type?: string; name?: unknown }; if (entry?.type === "session_info") { // Latest entry wins — including an empty/missing name, which pi // defines as clearing the session title. out.name = typeof entry.name === "string" && entry.name ? entry.name : undefined; } } } catch { // skip unparsable lines } } return out; } /** * List resumable child sessions: pi's default session dirs for this repo's * side-agent worktree slots, minus sessions of currently tracked (live) * agents, newest first. All sessions are offered; per-file scan cost is * bounded by head+tail slicing. */ async function listResumableSessions(stateRoot: string, repoRoot: string): Promise { const registry = await refreshAllAgents(stateRoot); const activeSessionPaths = new Set(); for (const record of Object.values(registry.agents)) { if (record.childSessionId) activeSessionPaths.add(resolve(record.childSessionId)); } // Cheap pass: collect candidate files with mtimes only (no content reads). const files: Array<{ path: string; modified: Date }> = []; for (const dir of await listSlotSessionDirs(repoRoot)) { const entries = await fs.readdir(dir).catch(() => [] as string[]); for (const name of entries) { if (!name.endsWith(".jsonl")) continue; const path = join(dir, name); try { const st = await fs.stat(path); files.push({ path, modified: st.mtime }); } catch { // unreadable session file } } } files.sort((a, b) => b.modified.getTime() - a.modified.getTime()); // One ref listing instead of a git spawn per candidate. Full refnames: // %(refname:short) is ambiguous when a same-named tag exists (it would // print heads/side-agent/foo and break the lookup). const branchRefs = new Set(); const refs = run("git", ["-C", repoRoot, "for-each-ref", "--format=%(refname)", "refs/heads/side-agent/"]); if (refs.ok) { for (const line of refs.stdout.split(/\r?\n/)) { const ref = line.trim(); if (ref.startsWith("refs/heads/")) branchRefs.add(ref.slice("refs/heads/".length)); } } const out: ResumableSession[] = []; // Seed with active agents' ids: their sessions are excluded from candidates, // but their branches are live and must not be reattached to older sessions // that happen to share the same agent id. const seenAgentIds = new Set(Object.keys(registry.agents)); for (const file of files) { if (activeSessionPaths.has(resolve(file.path))) continue; const scanned = await scanSessionFile(file.path); // Sessions without a side-agent-link were not created by this extension // (e.g. pi run manually inside a slot); don't offer them as side-agents. if (!scanned.agentId || scanned.messageCount === 0) continue; // An agent id maps to one branch; only the newest inactive session per id // may reattach it — others resume onto a fresh branch instead. const branchHeldElsewhere = seenAgentIds.has(scanned.agentId); seenAgentIds.add(scanned.agentId); const branch = branchHeldElsewhere ? undefined : `side-agent/${scanned.agentId}`; const branchHeldBy = branchHeldElsewhere ? registry.agents[scanned.agentId] ? ("active-agent" as const) : ("newer-session" as const) : undefined; out.push({ path: file.path, sessionCwd: scanned.sessionCwd, modified: file.modified, created: sessionCreatedFromFilename(file.path) ?? file.modified, messageCount: scanned.messageCount, name: scanned.name, firstMessage: scanned.firstMessage, agentId: scanned.agentId, branch, branchExists: branch ? branchRefs.has(branch) : false, branchHeldElsewhere, branchHeldBy, }); } return out; } /** Parse the creation timestamp from pi's session filename (best effort). */ function sessionCreatedFromFilename(sessionPath: string): Date | undefined { const match = basename(sessionPath).match(/^(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})-(\d{2})-(\d{3})Z/); if (!match) return undefined; const date = new Date(`${match[1]}T${match[2]}:${match[3]}:${match[4]}.${match[5]}Z`); return Number.isNaN(date.getTime()) ? undefined : date; } function resumeBranchNote(candidate: ResumableSession): string { if (candidate.branch) { return candidate.branchExists ? "" : "[branch pruned; will recreate from HEAD]"; } return candidate.branchHeldBy === "active-agent" ? "[branch held by ACTIVE agent → fresh branch]" : "[branch owned by newer session → fresh branch]"; } /** The name-or-preview body shown for a candidate (without id/branch decorations). */ function resumePreviewBody(candidate: ResumableSession): string { if (candidate.name) return candidate.name; return truncateWithEllipsis( stripTerminalNoise(candidate.firstMessage ?? "").replace(/\s+/g, " ").trim(), 80, ); } /** * Display string for the picker: the widget shows `name ?? firstMessage` on a * single line, so to keep the agent id and branch state visible we fold the * real name (or kickoff preview) into a synthesized name. The branch note * precedes the body: rows truncate from the right, so a trailing note would * be invisible on narrow terminals. The rename callback strips these * decorations again so they are never persisted as a title. * (Known trade-off: every row counts as "named" for the widget's ctrl+n * filter and named styling.) */ function synthesizedPickerName(candidate: ResumableSession): string { const note = resumeBranchNote(candidate); const body = resumePreviewBody(candidate); const parts = [candidate.agentId]; if (note) parts.push(note); if (body) parts.push(`· ${body}`); return parts.join(" "); } /** * Strip picker-name decorations from a rename submission; returns the title * to persist, or undefined when the rename should be a no-op. Decorations are * removed wherever they appear, not only at the boundaries — the rename * input's cursor may start at position 0, so typing X into the prefill * produces e.g. "Xid [note] · Old", which must sanitize to "XOld". */ function sanitizeRenameTitle(candidate: ResumableSession, input: string): string | undefined { let next = input.trim(); if (!next) return undefined; if (next === synthesizedPickerName(candidate)) return undefined; // untouched prefill const note = resumeBranchNote(candidate); if (note) next = next.split(note).join(" "); next = next.replace(/\s+/g, " ").trim(); const prefix = `${candidate.agentId} · `; if (next.includes(prefix)) next = next.split(prefix).join(""); next = next.replace(/\s+/g, " ").trim(); if (!next || next === candidate.agentId) return undefined; if (candidate.name && next === candidate.name) return undefined; // unchanged real name if (!candidate.name && next === resumePreviewBody(candidate)) return undefined; // preview left as-is return next; } /** Present a resumable session as a SessionInfo for pi's own session-selector widget. */ function toSessionInfo(candidate: ResumableSession): SessionInfo { return { path: candidate.path, id: basename(candidate.path).replace(/\.jsonl$/, ""), cwd: candidate.sessionCwd ?? "", name: synthesizedPickerName(candidate), parentSessionPath: undefined, created: candidate.created, modified: candidate.modified, messageCount: candidate.messageCount, firstMessage: candidate.firstMessage ?? "", allMessagesText: [candidate.agentId, candidate.branch, candidate.name, candidate.firstMessage] .filter(Boolean) .join(" "), }; } function formatRelativeAge(date: Date): string { const minutes = Math.max(0, Math.round((Date.now() - date.getTime()) / 60_000)); if (minutes < 60) return `${minutes}m`; const hours = Math.round(minutes / 60); if (hours < 48) return `${hours}h`; return `${Math.round(hours / 24)}d`; } function formatResumeCandidate(candidate: ResumableSession, index: number): string { const preview = truncateWithEllipsis( stripTerminalNoise(candidate.name || candidate.firstMessage || "(untitled session)").replace(/\s+/g, " ").trim(), 60, ); const note = resumeBranchNote(candidate); return `${index + 1}. ${candidate.agentId}${note ? ` ${note}` : ""} · ${formatRelativeAge(candidate.modified)} ago · ${preview}`; } function normalizeAgentId(raw: string): string { const trimmed = raw.trim(); if (!trimmed) return ""; const firstToken = trimmed.split(/\s+/, 1)[0]; return firstToken ?? ""; } async function startAgent(pi: ExtensionAPI, ctx: ExtensionContext, params: StartAgentParams): Promise { ensureTmuxReady(); const self = getSelfIdentity(); const depth = self.depth + 1; if (depth > MAX_AGENT_DEPTH) { throw new Error( `Nesting limit reached: this session is a side agent at depth ${self.depth} and agents deeper than ${MAX_AGENT_DEPTH} are not allowed. Do the work yourself or ask your parent to delegate.`, ); } const stateRoot = getStateRoot(ctx); // Registry, worktree slots and `git worktree add` always go through the main // repo so ids/slots stay globally unique across nesting levels... const repoRoot = resolveGitRoot(stateRoot); // ...but a nested agent bases the new branch on ITS checkout, and that is // where the child's finish script will fast-forward into. const parentCheckout = self.selfId ? resolveGitRoot(ctx.cwd) : repoRoot; // Only a nested agent gets an explicit merge-target branch; main's children // keep integrating into the branch configured at agent-setup time, whatever // main happens to have checked out right now. const parentBranch = self.selfId ? getCurrentBranch(parentCheckout) : ""; if (self.selfId && !parentBranch) { throw new Error(`Cannot spawn a nested agent: ${parentCheckout} is not on a branch (detached HEAD)`); } const parentSessionId = ctx.sessionManager.getSessionFile(); const now = nowIso(); let agentId = ""; let spawnedWindowId: string | undefined; let allocatedWorktreePath: string | undefined; let allocatedBranch: string | undefined; let aggregatedWarnings: string[] = []; try { await ensureDir(getMetaDir(stateRoot)); let slug: string; if (params.resume?.agentIdHint) { // Reuse the original agent id verbatim (it was already a valid id); // re-sanitizing could truncate it and detach us from the old branch. slug = params.resume.agentIdHint; } else if (params.branchHint) { slug = sanitizeSlug(params.branchHint); if (!slug) slug = slugFromTask(params.task); } else { const generated = await generateSlug(ctx, params.task); slug = generated.slug; if (generated.warning) aggregatedWarnings.push(generated.warning); } let resumeBranch = params.resume?.branch; const reattachId = resumeBranch?.startsWith("side-agent/") ? resumeBranch.slice("side-agent/".length) : undefined; await mutateRegistry(stateRoot, async (registry) => { const existing = existingAgentIds(registry, repoRoot); if (reattachId && !registry.agents[reattachId]) { // The resumed session's own branch (parked by /quit) must not force // a -2 suffix onto its original id. existing.delete(reattachId); } agentId = deduplicateSlug(slug, existing); registry.agents[agentId] = { id: agentId, parentSessionId, parentAgentId: self.selfId, depth, parentWorktreePath: self.selfId ? parentCheckout : undefined, task: params.task, model: params.model, status: "allocating_worktree", startedAt: now, updatedAt: now, }; }); if (params.resume && !resumeBranch) { aggregatedWarnings.push("Resuming on a fresh branch (original branch is owned elsewhere or unknown)."); } // Safety: reattach the branch only if we actually got its agent id — if // dedup had to suffix the id (e.g. an active agent owns it), the branch // belongs to that other agent and must not be hijacked. if (resumeBranch && reattachId !== agentId) { aggregatedWarnings.push( `Agent id ${agentId} does not match branch ${resumeBranch} (owned elsewhere); resuming on a fresh branch instead.`, ); resumeBranch = undefined; } const worktree = await allocateWorktree({ repoRoot, stateRoot, agentId, parentSessionId, existingBranch: resumeBranch, preferredPath: params.resume?.sessionCwd, baseHead: self.selfId ? runOrThrow("git", ["-C", parentCheckout, "rev-parse", "HEAD"]).stdout.trim() : undefined, }); allocatedWorktreePath = worktree.worktreePath; allocatedBranch = worktree.branch; aggregatedWarnings.push(...worktree.warnings); const runtimePrep = await prepareFreshRuntimeDir(stateRoot, agentId); const runtimeDir = runtimePrep.runtimeDir; if (runtimePrep.archivedRuntimeDir) { aggregatedWarnings.push(`Archived existing runtime dir for ${agentId}: ${runtimePrep.archivedRuntimeDir}`); } if (runtimePrep.warning) { aggregatedWarnings.push(runtimePrep.warning); } const promptPath = join(runtimeDir, "kickoff.md"); const logPath = join(runtimeDir, "backlog.log"); const exitFile = join(runtimeDir, "exit.json"); const launchScriptPath = join(runtimeDir, "launch.sh"); await atomicWrite(logPath, ""); await mutateRegistry(stateRoot, async (registry) => { const record = registry.agents[agentId]; if (!record) return; record.worktreePath = worktree.worktreePath; record.branch = worktree.branch; record.runtimeDir = runtimeDir; record.promptPath = promptPath; record.logPath = logPath; record.exitFile = exitFile; await setRecordStatus(stateRoot, record, "spawning_tmux"); record.warnings = [...(record.warnings ?? []), ...worktree.warnings]; }); let kickoffPrompt: string; if (params.resume) { // Resumed sessions carry their own history; they reopen idle. kickoffPrompt = ""; } else { const kickoff = await buildKickoffPrompt(ctx, params.task, params.includeSummary); if (kickoff.warning) aggregatedWarnings.push(kickoff.warning); kickoffPrompt = kickoff.prompt; if (self.selfId) { kickoffPrompt += buildNestedKickoffSuffix(self.selfId, parentBranch, parentCheckout, depth); } } await atomicWrite(promptPath, kickoffPrompt ? kickoffPrompt + "\n" : ""); if (kickoffPrompt) { try { await mutateRegistry(stateRoot, async (registry) => { const record = registry.agents[agentId]; if (!record) return; await appendKickoffPromptToBacklog(stateRoot, record, kickoffPrompt); }); } catch { // Best effort fallback when registry lock/update fails; write directly // to the known backlog path without requiring registry mutation. await appendKickoffPromptToBacklog( stateRoot, { id: agentId, task: params.task, status: "spawning_tmux", startedAt: now, updatedAt: nowIso(), runtimeDir, logPath, }, kickoffPrompt, ); } } // Resumed sessions restore their own recorded model; only pass --model for // fresh agents (or an explicit override). const resolvedModel = params.resume ? { modelSpec: params.model } : await resolveModelSpecForChild(ctx, params.model, pi.getThinkingLevel()); const modelSpec = resolvedModel.modelSpec; if (resolvedModel.warning) aggregatedWarnings.push(resolvedModel.warning); const resumeSessionMode: "session" | "fork" | undefined = params.resume ? params.resume.sessionCwd && resolve(params.resume.sessionCwd) === resolve(worktree.worktreePath) ? "session" : "fork" : undefined; const treeChanged = resumeSessionMode === "fork" || !worktree.workingTreePreserved; if (params.resume && (treeChanged || self.selfId)) { // The conversation history refers to paths/state of the original cwd // and working tree; when either changed, the child injects a prominent // notice on startup so the model stops acting on stale assumptions. // A resume from inside a side agent also (re)parents the session — its // history may name a different parent and merge target. const dirChanged = resumeSessionMode === "fork"; const notice: ResumeNoticeFile = { noticeId: `${agentId}:${now}`, targetCwd: worktree.worktreePath, sourceSessionPath: resolve(params.resume.sessionPath), content: buildResumeNotice({ treeChanged, dirChanged, originalCwd: params.resume.sessionCwd, newCwd: worktree.worktreePath, branch: worktree.branch, branchReattached: worktree.branchReattached, workingTreePreserved: worktree.workingTreePreserved, nested: self.selfId ? { parentAgentId: self.selfId, parentBranch, parentCheckout, depth } : undefined, }), }; await atomicWrite(join(runtimeDir, RESUME_NOTICE_FILE), JSON.stringify(notice, null, 2) + "\n"); if (treeChanged) { aggregatedWarnings.push( dirChanged ? `Resumed in ${worktree.worktreePath}, not the session's original directory ${ params.resume.sessionCwd ?? "(unknown)" }; a directory-change notice is injected into the child session.` : `Resumed in a reset working tree (branch ${worktree.branch}${ worktree.branchReattached ? " reattached" : " recreated from HEAD" }); a notice is injected into the child session.`, ); } if (self.selfId) { aggregatedWarnings.push( `Resumed as a nested agent under ${self.selfId} (merge target ${parentBranch}); a re-parenting notice is injected into the child session.`, ); } } const tmuxSession = getCurrentTmuxSession(); const { windowId, windowIndex } = createTmuxWindow(tmuxSession, `agent-${agentId}`); spawnedWindowId = windowId; await updateWorktreeLock(worktree.worktreePath, { tmuxWindowId: windowId, tmuxWindowIndex: windowIndex, }); const launchScript = buildLaunchScript({ agentId, parentSessionId, parentRepoRoot: parentCheckout, parentBranch, parentAgentId: self.selfId, depth, stateRoot, worktreePath: worktree.worktreePath, tmuxWindowId: windowId, promptPath, exitFile, modelSpec, runtimeDir, sessionPath: params.resume?.sessionPath, sessionMode: resumeSessionMode, }); await atomicWrite(launchScriptPath, launchScript); await fs.chmod(launchScriptPath, 0o755); tmuxPipePaneToFile(windowId, logPath); // Wait for the shell in the new tmux window to be ready before sending // commands — otherwise the keystrokes arrive before bash has initialised // and are silently lost (displayed as text but never executed). await tmuxWaitForShellReady(windowId); // Run cd in the interactive pane shell first so Ctrl+Z in child Pi drops // back to the child worktree prompt (not the parent worktree). // Send both commands together to avoid send-keys within tmuxSendLine // racing to shell so fast that they trigger bracketed paste mode in some // shell configurations (e.g. ble.sh with default accept_line_threshold=5), // which would block launchScript from running until the user intervenes. tmuxSendLine( windowId, `cd ${shellQuote(worktree.worktreePath)} && bash ${shellQuote(launchScriptPath)}`, ); await mutateRegistry(stateRoot, async (registry) => { const record = registry.agents[agentId]; if (!record) return; record.tmuxSession = tmuxSession; record.tmuxWindowId = windowId; record.tmuxWindowIndex = windowIndex; record.worktreePath = worktree.worktreePath; record.branch = worktree.branch; record.runtimeDir = runtimeDir; record.promptPath = promptPath; record.logPath = logPath; record.exitFile = exitFile; record.model = modelSpec; await setRecordStatus(stateRoot, record, "running"); record.warnings = [...(record.warnings ?? []), ...aggregatedWarnings]; }); const started: StartAgentResult = { id: agentId, parentAgentId: self.selfId, depth, tmuxWindowId: windowId, tmuxWindowIndex: windowIndex, worktreePath: worktree.worktreePath, branch: worktree.branch, warnings: aggregatedWarnings, prompt: kickoffPrompt, }; if (kickoffPrompt) { emitKickoffPromptMessage(pi, started); } return started; } catch (err) { if (spawnedWindowId) { run("tmux", ["kill-window", "-t", spawnedWindowId]); } if (agentId) { await mutateRegistry(stateRoot, async (registry) => { const record = registry.agents[agentId]; if (!record) return; record.error = stringifyError(err); record.finishedAt = nowIso(); const changed = await setRecordStatus(stateRoot, record, "failed"); if (!changed) { record.updatedAt = nowIso(); } if (allocatedWorktreePath) record.worktreePath = allocatedWorktreePath; if (allocatedBranch) record.branch = allocatedBranch; record.warnings = [...(record.warnings ?? []), ...aggregatedWarnings]; }); } throw err; } } function buildNestedKickoffSuffix(parentAgentId: string, parentBranch: string, parentCheckout: string, depth: number): string { return [ "", "", "## Nested side agent", `You were spawned by side agent \`${parentAgentId}\` (you are at nesting depth ${depth}), not by the main session. ` + `Your branch is based on its branch \`${parentBranch}\` (checked out at ${parentCheckout}), and that branch — not the project's main branch — is where your finished work is merged. ` + "Report to that agent; it reviews your work and tells you when to merge.", ].join("\n"); } async function agentCheckPayload(stateRoot: string, agentId: string): Promise> { const normalizedId = normalizeAgentId(agentId); if (!normalizedId) { return { ok: false, error: "No agent id was provided", }; } const record = await refreshAgent(stateRoot, normalizedId); if (!record) { return { ok: false, error: `Unknown agent id: ${normalizedId}`, }; } const backlog = await getBacklogTail(record, 10); return { ok: true, agent: { id: record.id, status: record.status, parentAgentId: record.parentAgentId, depth: record.depth ?? 1, tmuxWindowId: record.tmuxWindowId, tmuxWindowIndex: record.tmuxWindowIndex, worktreePath: record.worktreePath, branch: record.branch, task: summarizeTask(record.task), startedAt: record.startedAt, finishedAt: record.finishedAt, exitCode: record.exitCode, error: record.error, warnings: record.warnings ?? [], }, backlog, }; } async function sendToAgent(stateRoot: string, agentId: string, prompt: string): Promise<{ ok: boolean; message: string }> { const normalizedId = normalizeAgentId(agentId); if (!normalizedId) { return { ok: false, message: "No agent id was provided" }; } const record = await refreshAgent(stateRoot, normalizedId); if (!record) { return { ok: false, message: `Unknown agent id: ${normalizedId}` }; } if (!record.tmuxWindowId) { return { ok: false, message: `Agent ${normalizedId} has no tmux window id recorded` }; } if (!tmuxWindowExists(record.tmuxWindowId)) { return { ok: false, message: `Agent ${normalizedId} tmux window is not active` }; } let payload = prompt; if (payload.startsWith("!")) { tmuxInterrupt(record.tmuxWindowId); payload = payload.slice(1).trimStart(); if (payload.length > 0) { // Brief pause so Pi can finish handling the interrupt and return to an // interactive prompt before the follow-up text lands in the pane. await sleep(300); } } if (payload.length > 0) { tmuxSendPrompt(record.tmuxWindowId, payload); } await mutateRegistry(stateRoot, async (registry) => { const current = registry.agents[normalizedId]; if (!current) return; if (!isTerminalStatus(current.status)) { const changed = await setRecordStatus(stateRoot, current, "running"); if (!changed) { current.updatedAt = nowIso(); } } }); return { ok: true, message: `Sent prompt to ${normalizedId}` }; } async function setChildRuntimeStatus(ctx: ExtensionContext, nextStatus: AgentStatus): Promise { const agentId = process.env[ENV_AGENT_ID]; if (!agentId) return; const stateRoot = getStateRoot(ctx); await mutateRegistry(stateRoot, async (registry) => { const record = registry.agents[agentId]; if (!record) return; if (isTerminalStatus(record.status)) return; const changed = await setRecordStatus(stateRoot, record, nextStatus); if (!changed) { record.updatedAt = nowIso(); } }); } async function waitForAny( stateRoot: string, ids: string[], signal?: AbortSignal, waitStatesInput?: string[], ): Promise> { const uniqueIds = [...new Set(ids.map((id) => normalizeAgentId(id)).filter(Boolean))]; if (uniqueIds.length === 0) { return { ok: false, error: "No agent ids were provided" }; } const waitStates = normalizeWaitStates(waitStatesInput); if (waitStates.error) { return { ok: false, error: waitStates.error }; } const waitStateSet = new Set(waitStates.values); let firstPass = true; const knownIds = new Set(); while (true) { if (signal?.aborted) { return { ok: false, error: "agent-wait-any aborted" }; } const unknownOnFirstPass: string[] = []; let knownCount = 0; for (const id of uniqueIds) { const checked = await agentCheckPayload(stateRoot, id); const ok = checked.ok === true; if (!ok) { // Agent was known before but disappeared — it exited successfully // and was auto-pruned from the registry. Report it as done. if (knownIds.has(id)) { return { ok: true, agent: { id, status: "done" }, backlog: [], }; } if (firstPass) unknownOnFirstPass.push(id); continue; } knownIds.add(id); knownCount += 1; const status = (checked.agent as any)?.status as AgentStatus | undefined; if (!status) continue; if (waitStateSet.has(status)) { return checked; } } // Fail immediately if any provided ID was unrecognised on the very first // poll — unknown agents will never become known, so waiting is pointless. if (firstPass && unknownOnFirstPass.length > 0) { return { ok: false, error: `Unknown agent id(s): ${unknownOnFirstPass.join(", ")}`, }; } firstPass = false; await sleep(1000); } } async function ensureChildSessionLinked(pi: ExtensionAPI, ctx: ExtensionContext): Promise { const agentId = process.env[ENV_AGENT_ID]; if (!agentId) return; const stateRoot = getStateRoot(ctx); const childSession = ctx.sessionManager.getSessionFile(); const parentSession = process.env[ENV_PARENT_SESSION]; await mutateRegistry(stateRoot, async (registry) => { const existing = registry.agents[agentId]; if (!existing) { registry.agents[agentId] = { id: agentId, parentSessionId: parentSession, childSessionId: childSession, task: "(child session linked without parent registry record)", status: "running", startedAt: nowIso(), updatedAt: nowIso(), }; return; } existing.childSessionId = childSession; existing.parentSessionId = existing.parentSessionId ?? parentSession; let statusChanged = false; if (!isTerminalStatus(existing.status)) { statusChanged = await setRecordStatus(stateRoot, existing, "running"); } if (!statusChanged) { existing.updatedAt = nowIso(); } }); const lockPath = join(ctx.cwd, ".pi", "active.lock"); if (await fileExists(lockPath)) { const lock = (await readJsonFile>(lockPath)) ?? {}; lock.sessionId = childSession; lock.agentId = agentId; await atomicWrite(lockPath, JSON.stringify(lock, null, 2) + "\n"); } // Determine the agent id recorded by the LAST link entry; a resumed/forked // session may have inherited a link for a different (original) agent id and // then needs a corrected entry appended so later /agent-resume discovery // maps this session to the right branch. let linkedAgentId: string | undefined; for (const entry of ctx.sessionManager.getEntries()) { if (entry.type !== "custom") continue; const customEntry = entry as { customType?: string; data?: { agentId?: unknown } }; if (customEntry.customType !== CHILD_LINK_ENTRY_TYPE) continue; linkedAgentId = typeof customEntry.data?.agentId === "string" ? customEntry.data.agentId : linkedAgentId; } if (linkedAgentId !== agentId) { pi.appendEntry(CHILD_LINK_ENTRY_TYPE, { agentId, parentSession, linkedAt: Date.now(), }); } } function isChildRuntime(): boolean { return Boolean(process.env[ENV_AGENT_ID]); } /** * A nested agent's finish script must merge into the parent agent's branch, * which the agent-setup skill template reads from PI_SIDE_PARENT_BRANCH. Older * setups hardcode the main branch; warn (in the UI and to the model) so the * grandchild does not fast-forward main behind its parent's back. */ async function warnIfFinishScriptIgnoresParentBranch(pi: ExtensionAPI, ctx: ExtensionContext): Promise { const self = getSelfIdentity(); if (self.depth < 2) return; const finishScript = join(ctx.cwd, ".pi", "side-agent-finish.sh"); let raw: string; try { raw = await fs.readFile(finishScript, "utf8"); } catch { return; // no finish script (e.g. PR policy without one, or not set up) — nothing to warn about } if (raw.includes(ENV_PARENT_BRANCH)) return; const parentBranch = process.env[ENV_PARENT_BRANCH] || "(unknown)"; const text = `⚠️ ${finishScript} does not honour ${ENV_PARENT_BRANCH}; it would merge into the project's main branch instead of this agent's parent branch \`${parentBranch}\`. ` + "Do NOT run the finish script as-is. Ask the parent agent/user to upgrade the project's side-agent setup (agent-setup skill) or merge manually into the parent branch."; if (ctx.hasUI) ctx.ui.notify(`side-agent: finish script ignores ${ENV_PARENT_BRANCH}`, "warning"); pi.sendMessage({ customType: STATUS_UPDATE_MESSAGE_TYPE, content: text, display: true }, { triggerTurn: false }); } type ResumeNoticeFile = { /** Unique per resume launch; the child dedups against notices already in the session. */ noticeId: string; /** The worktree the notice is meant for; the child only injects when its cwd matches. */ targetCwd: string; /** The session file that was resumed; the child injects only into that * session (--session) or a direct fork of it (--fork). */ sourceSessionPath: string; content: string; }; function buildResumeNotice(options: { /** Directory and/or working tree differ from what the session last saw. */ treeChanged: boolean; dirChanged: boolean; originalCwd?: string; newCwd: string; branch: string; branchReattached: boolean; workingTreePreserved: boolean; /** Set when the resume was issued from inside a side agent. */ nested?: { parentAgentId: string; parentBranch: string; parentCheckout: string; depth: number }; }): string { const nestedNotice = options.nested ? [ "⚠️ RE-PARENTED — this session was resumed by side agent " + `\`${options.nested.parentAgentId}\` (you are now at nesting depth ${options.nested.depth}), not by the main session.`, "", `Your merge target is now its branch \`${options.nested.parentBranch}\` (checked out at ${options.nested.parentCheckout}), ` + "regardless of what earlier instructions in this conversation said. Report to that agent; it tells you when to merge.", ].join("\n") : ""; if (!options.treeChanged) return nestedNotice; const withNested = (text: string) => (nestedNotice ? `${text}\n\n${nestedNotice}` : text); const original = options.originalCwd ?? "(unknown)"; let branchLine: string; if (options.workingTreePreserved) { branchLine = `Branch \`${options.branch}\` was still checked out here and the working tree was left as it was (including any uncommitted changes).`; } else if (options.branchReattached) { branchLine = `Branch \`${options.branch}\` was reattached with its committed history, but the working tree was reset to it; uncommitted changes from before are NOT present.`; } else { branchLine = `This session now runs on a fresh branch \`${options.branch}\` created from the ${ options.nested ? "parent agent's" : "main worktree's" } HEAD, in a reset working tree; earlier uncommitted or unmerged work from this conversation is NOT present here.`; } if (!options.dirChanged) { return withNested([ "⚠️ WORKING TREE RESET — this session was resumed in its directory, but the working tree is not in the state you left it.", "", branchLine, "", "Re-inspect the working tree (`git status`, `git log`) before continuing; do not assume files are in the state you remember.", ].join("\n")); } return withNested([ "⚠️ WORKING DIRECTORY CHANGED — this session was resumed in a DIFFERENT directory.", "", `- Previous directory (used before this notice): ${original}`, `- Current directory (use this from now on): ${options.newCwd}`, "", branchLine, "", `Resolve any path from earlier in this conversation that pointed under ${original} against ${options.newCwd} instead; such files may be missing or differ here. ` + "Run all commands, reads and edits against the current directory; do NOT read from or modify the previous directory (it may be in use by another agent or no longer exist). " + "Re-inspect the working tree (`git status`, `git log`) before continuing.", ].join("\n")); } /** Child side, on process startup: append the parent's resume notice (if any) * as a visible custom message past the last turn. Idempotent and scoped: only * into the resumed session itself or a direct fork of it, only in the target * worktree, and deduplicated by noticeId against the session entries — so * neither /new, /resume, /reload in the child, nor an unrelated pi started * from the child's shell, nor a re-run of launch.sh (which re-forks the * original session and legitimately needs the notice again) can inject it * twice into one session or into the wrong one. */ async function injectResumeNoticeIfPresent(pi: ExtensionAPI, ctx: ExtensionContext): Promise { const runtimeDir = process.env[ENV_RUNTIME_DIR]; if (!isChildRuntime() || !runtimeDir) return; const noticePath = join(runtimeDir, RESUME_NOTICE_FILE); let raw: string; try { raw = await fs.readFile(noticePath, "utf8"); } catch (err) { if ((err as NodeJS.ErrnoException).code === "ENOENT") return; // ordinary launch throw err; } const notice = JSON.parse(raw) as Partial; if ( !notice || typeof notice.noticeId !== "string" || typeof notice.targetCwd !== "string" || typeof notice.sourceSessionPath !== "string" || typeof notice.content !== "string" || !notice.content.trim() ) { throw new Error(`malformed ${noticePath}`); } if (resolve(notice.targetCwd) !== resolve(ctx.cwd)) return; const sessionFile = ctx.sessionManager.getSessionFile(); const parentSession = ctx.sessionManager.getHeader()?.parentSession; const source = resolve(notice.sourceSessionPath); const isSource = sessionFile !== undefined && resolve(sessionFile) === source; const isForkOfSource = typeof parentSession === "string" && resolve(parentSession) === source; if (!isSource && !isForkOfSource) return; for (const entry of ctx.sessionManager.getEntries()) { if (entry.type !== "custom_message") continue; const existing = entry as { customType?: string; details?: { noticeId?: unknown } }; if (existing.customType === RESUME_NOTICE_MESSAGE_TYPE && existing.details?.noticeId === notice.noticeId) return; } pi.sendMessage( { customType: RESUME_NOTICE_MESSAGE_TYPE, content: notice.content.trimEnd(), display: true, details: { noticeId: notice.noticeId, cwd: ctx.cwd, injectedAt: Date.now() }, }, { triggerTurn: false }, ); } function collectStatusTransitions(stateRoot: string, agents: AgentRecord[]): StatusTransitionNotice[] { const previous = statusSnapshotsByStateRoot.get(stateRoot); const next = new Map(); const transitions: StatusTransitionNotice[] = []; const self = getSelfIdentity(); const liveIds = new Set(agents.map((record) => record.id)); for (const record of agents) { const orphaned = isOrphanRecord(record, self, liveIds); const currentSnapshot: AgentStatusSnapshot = { status: record.status, tmuxWindowIndex: record.tmuxWindowIndex, parentAgentId: record.parentAgentId, orphaned, }; next.set(record.id, currentSnapshot); const previousSnapshot = previous?.get(record.id); if (!previousSnapshot) continue; const adopted = orphaned && !previousSnapshot.orphaned; if (previousSnapshot.status === record.status && !adopted) continue; transitions.push({ id: record.id, fromStatus: previousSnapshot.status, toStatus: record.status, tmuxWindowIndex: record.tmuxWindowIndex ?? previousSnapshot.tmuxWindowIndex, parentAgentId: record.parentAgentId, orphaned, adopted, }); } if (previous) { for (const [agentId, previousSnapshot] of previous.entries()) { if (next.has(agentId)) continue; if (isTerminalStatus(previousSnapshot.status)) continue; transitions.push({ id: agentId, fromStatus: previousSnapshot.status, toStatus: "done", tmuxWindowIndex: previousSnapshot.tmuxWindowIndex, parentAgentId: previousSnapshot.parentAgentId, orphaned: isOrphanRecord(previousSnapshot, self, liveIds), }); } } statusSnapshotsByStateRoot.set(stateRoot, next); if (!previous) return []; // Notification scoping: a session is told only about the agents it spawned // (main additionally adopts orphans whose parent has quit). A grandchild's // transitions reach its parent agent, never the main session. return transitions .filter((transition) => isOwnedBySelf(transition, self, liveIds)) .sort((a, b) => a.id.localeCompare(b.id)); } type ThemeForeground = { fg: (role: "dim" | "warning" | "muted" | "accent" | "error", text: string) => string }; function formatStatusWord(status: AgentStatus, theme?: ThemeForeground): string { if (!theme) return status; return theme.fg(statusColorRole(status), status); } function formatLabelPrefix(prefix: string, theme?: ThemeForeground): string { if (!theme) return prefix; return theme.fg("muted", prefix); } function formatStatusTransitionMessage(transition: PendingStatusTransition, theme?: ThemeForeground): string { // Wall-clock stamp of when the transition was observed: these notices are // delivered as context at the next turn boundary, which may be minutes // later — without a timestamp a late burst of stale transitions reads like // live flip-flopping. const at = new Date(transition.lastObservedAt).toTimeString().slice(0, 8); const parts = [] as string[]; if (transition.tmuxWindowIndex !== undefined) parts.push(`tmux #${transition.tmuxWindowIndex}`); parts.push(at); if (transition.coalescedCount > 1) parts.push(`${transition.coalescedCount} transitions coalesced`); // A cycle (waiting_user -> running -> waiting_user) has identical endpoints // but is real news — the child did another round of work. Spell out the // route in that case so it does not read as a no-op. An adoption notice with // no status change at all just states the current status. const unchanged = transition.path.every((status) => status === transition.fromStatus); const statuses = transition.fromStatus === transition.toStatus ? unchanged ? [transition.toStatus] : [transition.fromStatus, ...transition.path] : [transition.fromStatus, transition.toStatus]; const route = statuses.map((status) => formatStatusWord(status, theme)).join(" -> "); const orphan = transition.orphaned ? ` [orphaned: its parent agent ${transition.parentAgentId ?? "?"} is gone${transition.adopted ? "; now reporting here" : ""}]` : ""; return `side-agent ${transition.id}: ${route} (${parts.join(", ")})${orphan}`; } /** * Fold freshly observed transitions into the pending buffer, collapsing repeated * transitions of the same agent into a single first-from/latest-to notice. */ function mergePendingTransitions( pending: Map, transitions: StatusTransitionNotice[], now: number, ): void { for (const transition of transitions) { const existing = pending.get(transition.id); if (!existing) { pending.set(transition.id, { ...transition, firstObservedAt: now, lastObservedAt: now, coalescedCount: 1, path: [transition.toStatus], }); continue; } existing.toStatus = transition.toStatus; existing.tmuxWindowIndex = transition.tmuxWindowIndex ?? existing.tmuxWindowIndex; existing.orphaned = existing.orphaned || transition.orphaned; existing.adopted = existing.adopted || transition.adopted; existing.lastObservedAt = now; existing.coalescedCount += 1; if (existing.path.length < TRANSITION_PATH_LIMIT) { existing.path.push(transition.toStatus); } else { // Keep the oldest steps and the latest one; the middle of a long // flip-flop carries no extra information (coalescedCount already says // how many steps there really were). existing.path[existing.path.length - 1] = transition.toStatus; } } } /** * Take the pending notices that are ready for delivery: terminal states are * ready at once (nothing can follow a done/failed/crashed agent, so waiting * buys nothing), everything else waits out TRANSITION_SETTLE_MS of quiet. * * Notices whose endpoints match are still reported: collectStatusTransitions() * never produces a no-op, so identical endpoints mean the agent went somewhere * and came back (typically waiting_user -> running -> waiting_user, i.e. it * completed another requested iteration) — dropping those would silently hide * finished work. * * Nothing leaves the buffer while the parent is busy (`idle === false`): the * only delivery mode that both persists the notice and spawns no turn of its * own requires an idle session, and holding entries back keeps coalescing them * for the whole busy stretch instead of piling up a stale backlog. */ function drainSettledTransitions( pending: Map, now: number, idle = true, settleMs = TRANSITION_SETTLE_MS, ): PendingStatusTransition[] { if (!idle) return []; const settled: PendingStatusTransition[] = []; for (const [agentId, entry] of pending.entries()) { const ready = isTerminalStatus(entry.toStatus) || now - entry.lastObservedAt >= settleMs; if (!ready) continue; pending.delete(agentId); settled.push(entry); } return settled.sort((a, b) => a.id.localeCompare(b.id)); } function emitStatusTransitions( pi: ExtensionAPI, ctx: ExtensionContext, stateRoot: string, transitions: StatusTransitionNotice[], settleMs = TRANSITION_SETTLE_MS, ): void { // Runs in every session, nested ones included: collectStatusTransitions() // already narrowed `transitions` to the agents this session spawned. let pending = pendingTransitionsByStateRoot.get(stateRoot); if (!pending) { pending = new Map(); pendingTransitionsByStateRoot.set(stateRoot, pending); } const now = Date.now(); mergePendingTransitions(pending, transitions, now); // Only deliver between turns. Delivering mid-stream would either inject a // status update into the running loop ("steer") or queue one turn per notice // ("followUp") — the latter is what made a finished agent replay its whole // history as a chain of turns. The poller retries every couple of seconds, // so buffered notices land shortly after the current turn ends. // // isIdle() is `!isStreaming`, which is also briefly true in the gaps between // continuations (retry, auto-compaction) inside one logical turn. That is an // acceptable gate: the worst case there is a plain context message landing a // continuation early — no extra turn, no steering. Gating additionally on our // own agent_start/agent_end bookkeeping would risk wedging notifications // forever if an agent_end is ever missed. const deliverable = drainSettledTransitions(pending, now, ctx.isIdle(), settleMs); // Hard failures are actionable right away — toast them live even though the // conversational notice is still settling. if (ctx.hasUI) { for (const transition of transitions) { if (transition.toStatus !== "failed" && transition.toStatus !== "crashed") continue; ctx.ui.notify( `side-agent ${transition.id}: ${transition.fromStatus} -> ${transition.toStatus}`, "error", ); } } for (const transition of deliverable) { pi.sendMessage( { customType: STATUS_UPDATE_MESSAGE_TYPE, content: formatStatusTransitionMessage(transition, ctx.hasUI ? ctx.ui.theme : undefined), display: true, details: { agentId: transition.id, parentAgentId: transition.parentAgentId, orphaned: transition.orphaned ?? false, fromStatus: transition.fromStatus, toStatus: transition.toStatus, tmuxWindowIndex: transition.tmuxWindowIndex, coalescedCount: transition.coalescedCount, firstObservedAt: transition.firstObservedAt, observedAt: transition.lastObservedAt, emittedAt: Date.now(), }, }, { // Idle + no triggerTurn: the runtime appends the notice to the // session and to the message state right away — persisted, shown // in the transcript, visible to the model on the next turn, and // no turn spawned for it. triggerTurn: false, }, ); } } function emitKickoffPromptMessage(pi: ExtensionAPI, started: StartAgentResult): void { const win = started.tmuxWindowIndex !== undefined ? ` (tmux #${started.tmuxWindowIndex})` : ""; const content = `side-agent ${started.id}: kickoff prompt${win}\n\n${started.prompt}`; pi.sendMessage( { customType: PROMPT_UPDATE_MESSAGE_TYPE, content, display: false, details: { agentId: started.id, tmuxWindowId: started.tmuxWindowId, tmuxWindowIndex: started.tmuxWindowIndex, worktreePath: started.worktreePath, branch: started.branch, prompt: started.prompt, emittedAt: Date.now(), }, }, { triggerTurn: false }, ); } async function renderStatusLine(pi: ExtensionAPI, ctx: ExtensionContext, options?: { emitTransitions?: boolean }): Promise { if (!ctx.hasUI) return; const stateRoot = getStateRoot(ctx); const refreshed = await refreshAllAgents(stateRoot); const agents = Object.values(refreshed.agents).sort((a, b) => a.id.localeCompare(b.id)); if (options?.emitTransitions ?? true) { // Always call through, even with no new transitions: buffered notices need // a later tick to settle and flush. emitStatusTransitions(pi, ctx, stateRoot, collectStatusTransitions(stateRoot, agents)); } else if (!statusSnapshotsByStateRoot.has(stateRoot)) { collectStatusTransitions(stateRoot, agents); } const line = formatStatusLine(agents, getSelfIdentity(), ctx.ui.theme); if (line === undefined) { if (lastRenderedStatusLine !== undefined) { ctx.ui.setStatus(STATUS_KEY, undefined); lastRenderedStatusLine = undefined; } return; } if (line === lastRenderedStatusLine) return; ctx.ui.setStatus(STATUS_KEY, line); lastRenderedStatusLine = line; } /** * Status line scope: for a nested agent (parent is another side agent, not * main) a `parent:` entry first, so it is clear whose subtree this session * lives in; then the session's own children, then its siblings — groups * separated by `|`. * * UX purpose: the status line is a quick-travel map for hopping across side * agents one by one (`@N` = tmux window to switch to). Every entry — parent, * children AND siblings — is therefore colored by its status, so you can spot * from any window which neighbour needs you next. Do not unconditionally dim * any group or otherwise drop status colors. Main has no parent/siblings and sees exactly its * direct children plus orphans; a leaf grandchild sees its parent and * siblings. Returns undefined when there is nothing to show. */ function formatStatusLine( agents: AgentRecord[], self: SelfIdentity, theme: ThemeForeground, ): string | undefined { const liveIds = new Set(agents.map((record) => record.id)); const children: AgentRecord[] = []; const siblings: AgentRecord[] = []; for (const record of agents) { if (isOwnedBySelf(record, self, liveIds)) children.push(record); else if (isSiblingRecord(record, self)) siblings.push(record); } const parentId = self.parentAgentId; if (parentId === undefined && children.length === 0 && siblings.length === 0) return undefined; const entry = (record: AgentRecord): string => { const win = record.tmuxWindowIndex !== undefined ? `@${record.tmuxWindowIndex}` : ""; const orphan = isOrphanRecord(record, self, liveIds) ? "[orphan]" : ""; return `${record.id}:${statusShort(record.status)}${win}${orphan}`; }; const groups: string[][] = []; if (parentId !== undefined) { const parent = agents.find((record) => record.id === parentId); const label = theme.fg("muted", "parent:"); groups.push([ parent ? `${label}${theme.fg(statusColorRole(parent.status), entry(parent))}` : `${label}${theme.fg("error", `${parentId}:gone`)}`, ]); } if (children.length > 0) { groups.push(children.map((record) => theme.fg(statusColorRole(record.status), entry(record)))); } if (siblings.length > 0) { groups.push(siblings.map((record) => theme.fg(statusColorRole(record.status), entry(record)))); } return groups.map((group) => group.join(" ")).join(` ${theme.fg("dim", "|")} `); } function ensureStatusPoller(pi: ExtensionAPI, ctx: ExtensionContext): void { statusPollContext = ctx; statusPollApi = pi; if (!ctx.hasUI) return; if (!statusPollTimer) { statusPollTimer = setInterval(() => { if (statusPollInFlight || !statusPollContext || !statusPollApi) return; statusPollInFlight = true; void renderStatusLine(statusPollApi, statusPollContext) .catch(() => {}) .finally(() => { statusPollInFlight = false; }); }, 2500); statusPollTimer.unref(); } void renderStatusLine(pi, ctx).catch(() => {}); } export default function sideAgentsExtension(pi: ExtensionAPI) { pi.registerCommand("agent", { description: "Spawn a background child agent in its own tmux window/worktree: /agent [-model ] [-mode ] ", handler: async (args, ctx) => { const parsed = parseAgentCommandArgs(args); if (!parsed.task) { ctx.hasUI && ctx.ui.notify("Usage: /agent [-model ] [-mode ] ", "error"); return; } // Resolve -mode (pi-amplike modes) to a model spec if no explicit -model was given. let resolvedModel = parsed.model; if (parsed.mode && !parsed.model) { const modeResult = await resolveModeToModelSpec(ctx.cwd, parsed.mode); if (modeResult.modelSpec) { resolvedModel = modeResult.modelSpec; } else if (modeResult.warning) { ctx.hasUI && ctx.ui.notify(modeResult.warning, "warning"); } } try { ctx.hasUI && ctx.ui.notify("Starting side-agent…", "info"); const started = await startAgent(pi, ctx, { task: parsed.task, model: resolvedModel, includeSummary: true, }); const lines = [ `id: ${started.id}`, ...(started.parentAgentId ? [`parent agent: ${started.parentAgentId} (depth ${started.depth})`] : []), `tmux window: ${started.tmuxWindowId} (#${started.tmuxWindowIndex})`, `worktree: ${started.worktreePath}`, `branch: ${started.branch}`, ]; for (const warning of started.warnings) { lines.push(`warning: ${warning}`); } lines.push("", "prompt:"); for (const line of started.prompt.split(/\r?\n/)) { lines.push(` ${line}`); } renderInfoMessage(pi, ctx, "side-agent started", lines); await renderStatusLine(pi, ctx).catch(() => {}); } catch (err) { ctx.hasUI && ctx.ui.notify(`Failed to start agent: ${stringifyError(err)}`, "error"); } }, }); pi.registerCommand("agents", { description: "List tracked side agents", handler: async (_args, ctx) => { const stateRoot = getStateRoot(ctx); const repoRoot = resolveGitRoot(stateRoot); let registry = await refreshAllAgents(stateRoot); const records = Object.values(registry.agents).sort((a, b) => a.id.localeCompare(b.id)); let orphanLocks = await scanOrphanWorktreeLocks(repoRoot, registry); if (records.length === 0 && orphanLocks.reclaimable.length === 0 && orphanLocks.blocked.length === 0) { ctx.hasUI && ctx.ui.notify("No tracked side agents yet.", "info"); return; } const lines: string[] = []; const failedIds: string[] = []; if (records.length === 0) { lines.push("(no tracked agents)"); } else { const theme = ctx.hasUI ? ctx.ui.theme : undefined; const self = getSelfIdentity(); const liveIds = new Set(records.map((record) => record.id)); // Full tree for everyone (this is an explicit command): roots are // main's children plus orphans, nested agents indented under their parent. const byParent = new Map(); for (const record of records) { const key = record.parentAgentId !== undefined && liveIds.has(record.parentAgentId) ? record.parentAgentId : undefined; const bucket = byParent.get(key) ?? []; bucket.push(record); byParent.set(key, bucket); } const ordered: { record: AgentRecord; level: number }[] = []; const visit = (parentId: string | undefined, level: number) => { for (const record of byParent.get(parentId) ?? []) { ordered.push({ record, level }); visit(record.id, level + 1); } }; visit(undefined, 0); for (const [index, { record, level }] of ordered.entries()) { const indent = " ".repeat(level); const win = record.tmuxWindowIndex !== undefined ? `#${record.tmuxWindowIndex}` : "-"; const worktreeName = record.worktreePath ? basename(record.worktreePath) || record.worktreePath : "-"; const statusWord = formatStatusWord(record.status, theme); const winPrefix = formatLabelPrefix("win:", theme); const worktreePrefix = formatLabelPrefix("worktree:", theme); const taskPrefix = formatLabelPrefix("task:", theme); const marks: string[] = []; if (record.id === self.selfId) marks.push("(this session)"); if (level > 0) marks.push(`${formatLabelPrefix("parent:", theme)}${record.parentAgentId}`); if (isOrphanRecord(record, self, liveIds)) marks.push(`orphan (parent ${record.parentAgentId} gone)`); const markText = marks.length > 0 ? ` ${marks.join(" ")}` : ""; lines.push(`${indent}${record.id} ${statusWord} ${winPrefix}${win} ${worktreePrefix}${worktreeName}${markText}`); lines.push(`${indent} ${taskPrefix} ${summarizeTask(record.task)}`); if (record.error) lines.push(`${indent} error: ${record.error}`); // Only offer to clean up what this session is responsible for. if ((record.status === "failed" || record.status === "crashed") && isOwnedBySelf(record, self, liveIds)) { failedIds.push(record.id); } if (index < ordered.length - 1) { lines.push(""); } } } if (orphanLocks.reclaimable.length > 0 || orphanLocks.blocked.length > 0) { if (lines.length > 0) lines.push(""); lines.push("orphan worktree locks:"); for (const lock of orphanLocks.reclaimable) { lines.push(` reclaimable: ${summarizeOrphanLock(lock)}`); } for (const lock of orphanLocks.blocked) { lines.push(` blocked: ${summarizeOrphanLock(lock)} (${lock.blockers.join("; ")})`); } } renderInfoMessage(pi, ctx, "side-agents", lines); if (failedIds.length > 0 && ctx.hasUI) { const confirmed = await ctx.ui.confirm( "Clean up failed agents?", `Remove ${failedIds.length} failed/crashed agent(s) from registry: ${failedIds.join(", ")}`, ); if (confirmed) { // Collect worktree paths + agent IDs before removing records so we can release their locks. const worktreeEntries: { path: string; agentId: string }[] = []; registry = await mutateRegistry(stateRoot, async (next) => { for (const id of failedIds) { const rec = next.agents[id]; if (rec?.worktreePath) worktreeEntries.push({ path: rec.worktreePath, agentId: id }); delete next.agents[id]; } }); // Release worktree locks now that the agents are cleared. for (const entry of worktreeEntries) { await cleanupWorktreeLockBestEffort(entry.path, entry.agentId); } ctx.ui.notify(`Removed ${failedIds.length} agent(s): ${failedIds.join(", ")}`, "info"); } } orphanLocks = await scanOrphanWorktreeLocks(repoRoot, registry); if (orphanLocks.reclaimable.length > 0 && ctx.hasUI) { const preview = orphanLocks.reclaimable.slice(0, 6).map((lock) => `- ${summarizeOrphanLock(lock)}`); if (orphanLocks.reclaimable.length > preview.length) { preview.push(`- ... and ${orphanLocks.reclaimable.length - preview.length} more`); } const confirmed = await ctx.ui.confirm( "Reclaim orphan worktree locks?", [ `Remove ${orphanLocks.reclaimable.length} orphan worktree lock(s)?`, "Only lock files with no tracked registry agent and no live pid/tmux signal are included.", "", ...preview, ].join("\n"), ); if (confirmed) { const reclaimed = await reclaimOrphanWorktreeLocks(orphanLocks.reclaimable); if (reclaimed.failed.length === 0) { ctx.ui.notify(`Reclaimed ${reclaimed.removed.length} orphan worktree lock(s).`, "info"); } else { ctx.ui.notify( `Reclaimed ${reclaimed.removed.length} orphan lock(s); failed ${reclaimed.failed.length}.`, "warning", ); } } } if (orphanLocks.blocked.length > 0 && ctx.hasUI) { ctx.ui.notify( `Found ${orphanLocks.blocked.length} orphan lock(s) that look live; leaving them untouched.`, "warning", ); } }, }); pi.registerCommand("agent-resume", { description: "Resume a previously /quit side-agent session (with its branch) in a worktree/tmux window", handler: async (args, ctx) => { if (args.trim()) { const message = "/agent-resume takes no arguments; resumed sessions reopen idle — steer them in their tmux window or via agent-send."; ctx.hasUI ? ctx.ui.notify(message, "error") : console.log(message); return; } if (!ctx.hasUI) { console.log("/agent-resume requires an interactive UI (session picker)."); return; } const stateRoot = getStateRoot(ctx); const repoRoot = resolveGitRoot(stateRoot); let candidates: ResumableSession[]; try { candidates = await listResumableSessions(stateRoot, repoRoot); } catch (err) { ctx.ui.notify(`Failed to list resumable sessions: ${stringifyError(err)}`, "error"); return; } if (candidates.length === 0) { ctx.ui.notify("No resumable side-agent sessions found in this project's worktrees.", "info"); return; } let chosenPath: string | undefined; if (ctx.mode === "tui") { // Reuse pi's own session-selector widget (same as --resume): paginated, // searchable, sortable — unlike the simple ui.select list. // First load serves the pre-computed candidates; refresh calls (after // the widget's rename/delete mutations) re-run discovery so the list // reflects disk truth instead of a stale snapshot. let firstLoad = true; const loader = async () => { if (firstLoad) { firstLoad = false; return candidates.map((candidate) => toSessionInfo(candidate)); } candidates = await listResumableSessions(stateRoot, repoRoot); return candidates.map((candidate) => toSessionInfo(candidate)); }; chosenPath = await ctx.ui.custom((tui, _theme, keybindings, done) => { return new SessionSelectorComponent( loader, loader, (sessionPath) => done(sessionPath), () => done(undefined), () => done(undefined), () => tui.requestRender(), { renameSession: async (sessionFilePath, nextName) => { // The prefill is our synthesized picker name; without candidate // metadata we cannot strip the decorations — refuse rather than // persist a synthetic title. const cand = candidates.find((c) => c.path === sessionFilePath); if (!cand) return; const title = sanitizeRenameTitle(cand, nextName ?? ""); if (!title) return; SessionManager.open(sessionFilePath).appendSessionInfo(title); }, showRenameHint: true, keybindings, }, ); }); } else { const labels = candidates.map((candidate, index) => formatResumeCandidate(candidate, index)); const choice = await ctx.ui.select("Resume side-agent session", labels); if (choice === undefined) return; chosenPath = candidates[labels.indexOf(choice)]?.path; } if (!chosenPath) return; const candidate = candidates.find((c) => c.path === chosenPath); if (!candidate) return; const taskPreview = candidate.name || candidate.firstMessage || "(resumed session)"; // The sliced listing scan may have seen a stale link entry (a corrected // link can sit in the omitted middle of a large file); re-read the chosen // session in full and act on the authoritative id. let agentIdHint = candidate.agentId; let branch = candidate.branch; const authoritativeId = await readSessionAgentIdAuthoritative(candidate.path); if (authoritativeId && authoritativeId !== candidate.agentId) { agentIdHint = authoritativeId; // Reattach only if the true id is not owned by an active agent nor by // a newer candidate session (allocateWorktree's lock checks remain the // final backstop). const registry = await loadRegistry(stateRoot); const ownedByNewerCandidate = candidates.some( (c) => c.path !== candidate.path && c.agentId === authoritativeId && c.modified > candidate.modified, ); branch = registry.agents[authoritativeId] || ownedByNewerCandidate ? undefined : `side-agent/${authoritativeId}`; } try { ctx.ui.notify("Resuming side-agent…", "info"); const started = await startAgent(pi, ctx, { task: `resume: ${taskPreview}`, includeSummary: false, resume: { sessionPath: candidate.path, sessionCwd: candidate.sessionCwd, agentIdHint, branch, }, }); const lines = [ `id: ${started.id}`, `session: ${candidate.path}`, `tmux window: ${started.tmuxWindowId} (#${started.tmuxWindowIndex})`, `worktree: ${started.worktreePath}`, `branch: ${started.branch}`, ]; for (const warning of started.warnings) { lines.push(`warning: ${warning}`); } renderInfoMessage(pi, ctx, "side-agent resumed", lines); await renderStatusLine(pi, ctx).catch(() => {}); } catch (err) { ctx.ui.notify(`Failed to resume agent: ${stringifyError(err)}`, "error"); } }, }); type ToolTheme = { fg(color: string, text: string): string; bold(text: string): string }; function renderToolTitle(theme: ToolTheme, title: string, accent?: string): string { let out = theme.fg("toolTitle", theme.bold(title + " ")); // Args may be partial while streaming and may contain terminal escapes; // strip noise before writing them to the terminal. if (accent) out += theme.fg("accent", stripTerminalNoise(accent) + " "); return out; } function renderMultilineArg(theme: ToolTheme, text: string | undefined, maxLines = 6): string { const clean = truncateWithEllipsis(stripTerminalNoise(text ?? ""), 2000); const lines = clean.split("\n"); const shown = lines.length > maxLines ? lines.slice(0, maxLines).join("\n") + "\n" + theme.fg("dim", `… (${lines.length - maxLines} more lines)`) : clean; return theme.fg("muted", shown); } pi.registerTool({ name: "agent-start", label: "Agent Start", description: "Start a background side agent in tmux/worktree. Lifecycle: child implements the change or asks for clarification -> wait-state and yield -> parent inspects (agent-check or agent-wait-any), reviews work, reacts -> eventually, parent asks child to wrap up (send 'LGTM, merge'), sends /quit when child is done. Provide a short kebab-case branchHint (max 3 words) for the agent's branch name. Side agents may themselves start agents (nesting is capped at depth 2); a nested agent branches off the caller's branch, merges back into it, and reports its status to the caller only. Returns { ok: true, id, parentAgentId?, depth, task, tmuxWindowId, tmuxWindowIndex, worktreePath, branch, warnings[] } on success, or { ok: false, error } on failure.", parameters: Type.Object({ description: Type.String({ description: "Task description for child agent kickoff prompt (include all necessary context)" }), branchHint: Type.String({ description: "Short kebab-case branch slug, max 3 words (e.g. fix-auth-leak)" }), model: Type.Optional(Type.String({ description: "Model as provider/modelId (optional)" })), }), async execute(_toolCallId, params, _signal, _onUpdate, ctx) { try { const started = await startAgent(pi, ctx, { task: params.description, branchHint: params.branchHint, model: params.model, includeSummary: false, }); return { content: [ { type: "text", text: JSON.stringify( { ok: true, id: started.id, parentAgentId: started.parentAgentId, depth: started.depth, task: params.description.length > 200 ? params.description.slice(0, 200) + "…" : params.description, tmuxWindowId: started.tmuxWindowId, tmuxWindowIndex: started.tmuxWindowIndex, worktreePath: started.worktreePath, branch: started.branch, warnings: started.warnings, }, null, 2, ), }, ], }; } catch (err) { return { content: [{ type: "text", text: JSON.stringify({ ok: false, error: stringifyError(err) }, null, 2) }], }; } }, renderCall(args, theme) { let out = renderToolTitle(theme, "Agent Start", args.branchHint); if (args.model) out += theme.fg("dim", `[${stripTerminalNoise(args.model)}] `); out += renderMultilineArg(theme, args.description); return new Text(out, 0, 0); }, }); pi.registerTool({ name: "agent-check", label: "Agent Check", description: "Check a given side agent status and return compact recent output. Returns { ok: true, agent: { id, status, parentAgentId?, depth, tmuxWindowId, tmuxWindowIndex, worktreePath, branch, task, startedAt, finishedAt?, exitCode?, error?, warnings[] }, backlog: string[] }, or { ok: false, error } if the agent id is unknown or a registry error occurs. backlog is sanitized/truncated for LLM safety; task is a compact preview. Statuses: allocating_worktree | spawning_tmux | running | waiting_user | failed | crashed. Agents that exit with code 0 are auto-removed from registry.", parameters: Type.Object({ id: Type.String({ description: "Agent id" }), }), async execute(_toolCallId, params, _signal, _onUpdate, ctx) { try { const payload = await agentCheckPayload(getStateRoot(ctx), params.id); return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }], }; } catch (err) { return { content: [{ type: "text", text: JSON.stringify({ ok: false, error: stringifyError(err) }, null, 2) }], }; } }, renderCall(args, theme) { return new Text(renderToolTitle(theme, "Agent Check", args.id), 0, 0); }, }); pi.registerTool({ name: "agent-wait-any", label: "Agent Wait Any", description: "Wait for an agent to finish its work. Returns the agent's status payload (same shape as agent-check) once it completes (done), yields (waiting_user), fails, or crashes.", parameters: Type.Object({ ids: Type.Array(Type.String({ description: "Agent id" }), { description: "Agent ids to wait for" }), }), async execute(_toolCallId, params, signal, _onUpdate, ctx) { try { const payload = await waitForAny(getStateRoot(ctx), params.ids, signal); return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }], }; } catch (err) { return { content: [{ type: "text", text: JSON.stringify({ ok: false, error: stringifyError(err) }, null, 2) }], }; } }, renderCall(args, theme) { return new Text(renderToolTitle(theme, "Agent Wait", Array.isArray(args.ids) ? args.ids.join(", ") : undefined), 0, 0); }, }); pi.registerTool({ name: "agent-send", label: "Agent Send", description: "Send a steering/follow-up prompt to a child agent's tmux pane. Returns { ok: boolean, message: string }.", parameters: Type.Object({ id: Type.String({ description: "Agent id" }), prompt: Type.String({ description: "Prompt text to send (prefix with '!' to interrupt first instead of organic steering, '/' for slash commands like /quit)" }), }), async execute(_toolCallId, params, _signal, _onUpdate, ctx) { try { const payload = await sendToAgent(getStateRoot(ctx), params.id, params.prompt); return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }], }; } catch (err) { return { content: [{ type: "text", text: JSON.stringify({ ok: false, error: stringifyError(err) }, null, 2) }], }; } }, renderCall(args, theme) { const out = renderToolTitle(theme, "Agent Send", args.id) + renderMultilineArg(theme, args.prompt, 8); return new Text(out, 0, 0); }, }); pi.on("session_start", async (event, ctx) => { await ensureChildSessionLinked(pi, ctx).catch(() => {}); if (event.reason === "startup") { await injectResumeNoticeIfPresent(pi, ctx).catch((err) => { if (ctx.hasUI) ctx.ui.notify(`side-agent: failed to inject resume notice: ${stringifyError(err)}`, "warning"); }); await warnIfFinishScriptIgnoresParentBranch(pi, ctx).catch(() => {}); } ensureStatusPoller(pi, ctx); }); pi.on("session_shutdown", async (_event, ctx) => { // Last chance to persist buffered notices: skip the settle window so a // still-quiet transition is not lost with the process. emitStatusTransitions(pi, ctx, getStateRoot(ctx), [], 0); }); pi.on("agent_start", async (_event, ctx) => { await setChildRuntimeStatus(ctx, "running").catch(() => {}); }); pi.on("agent_end", async (_event, ctx) => { await setChildRuntimeStatus(ctx, "waiting_user").catch(() => {}); }); pi.on("before_agent_start", async (_event, ctx) => { statusPollContext = ctx; statusPollApi = pi; await renderStatusLine(pi, ctx, { emitTransitions: false }).catch(() => {}); }); } /** Internals exposed for unit tests only (Pi loads just the default export). */ export const __testing = { allocateWorktree, allocateWorktreeUnlocked, withFileLock, formatStatusLine };