From 3180fd3d7fc272c8eeaedb96d96f92f34713d77d Mon Sep 17 00:00:00 2001 From: Kyle McCleary Date: Tue, 4 Aug 2026 17:20:00 -0700 Subject: [PATCH] fix(coding-agent): complete Agent Hub inspector metadata --- packages/coding-agent/CHANGELOG.md | 5 +- .../src/modes/components/agent-hub.ts | 10 +- .../src/registry/agent-registry.ts | 6 + .../src/registry/persisted-agents.ts | 109 ++++++++++++------ packages/coding-agent/src/task/executor.ts | 4 +- .../coding-agent/src/task/isolation-runner.ts | 35 ++++-- .../test/agent-hub-activate.test.ts | 22 ++++ .../test/agent-hub-ordering.test.ts | 9 ++ .../test/task/isolation-runner.test.ts | 10 ++ 9 files changed, 161 insertions(+), 49 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 47d583e0f..da5b2de80 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,13 +2,16 @@ ## [Unreleased] +### Changed + +- Reworked the Ctrl+S Agent Hub into a responsive fullscreen roster and selected-agent inspector with aggregate status/usage, per-agent task/model/activity/usage/lineage details, roster and spawn-tree views, stable ordering, bounded large-roster rendering, asynchronous persisted-session discovery, restored task/timestamp metadata for historical agents, and consistent keyboard and mouse navigation. + ## [17.2.7] - 2026-08-03 ### Changed - Replaced arktype with @oh-my-pi/omptype for tool parameter and config schemas, significantly improving startup performance with ~100x faster schema construction. Config schema errors are now reported via OmpErrors using the same path/problem structure. - Replaced arktype with `@oh-my-pi/omptype` across all tool parameter and config schemas: ~100x faster schema construction removes the arktype startup tax (the `scope({}, { jitless: true })` workarounds are gone). Config schema errors now report via `OmpErrors` entries with the same `path`/`problem` shape. -- Reworked the Ctrl+S Agent Hub into a responsive fullscreen roster and selected-agent inspector with aggregate status/usage, per-agent task/model/activity/usage/lineage details, roster and spawn-tree views, stable ordering, bounded large-roster rendering, asynchronous persisted-session discovery, restored task/timestamp metadata for historical agents, and consistent keyboard and mouse navigation. ### Fixed diff --git a/packages/coding-agent/src/modes/components/agent-hub.ts b/packages/coding-agent/src/modes/components/agent-hub.ts index ae9c31440..06dbdfd76 100644 --- a/packages/coding-agent/src/modes/components/agent-hub.ts +++ b/packages/coding-agent/src/modes/components/agent-hub.ts @@ -35,7 +35,7 @@ import { AgentLifecycleManager } from "../../registry/agent-lifecycle"; import { type AgentRef, AgentRegistry, type AgentStatus, MAIN_AGENT_ID } from "../../registry/agent-registry"; import { registerPersistedSubagents } from "../../registry/persisted-agents"; import { USER_INTERRUPT_LABEL } from "../../session/messages"; -import { truncateToWidth } from "../../tools/render-utils"; +import { shortenPath, truncateToWidth } from "../../tools/render-utils"; import type { ObservableSession, SessionObserverRegistry } from "../session-observer-registry"; import { theme } from "../theme/theme"; import { matchesSelectDown, matchesSelectUp } from "../utils/keybinding-matchers"; @@ -257,7 +257,9 @@ export class AgentHubOverlayComponent extends Container implements SelectListMou this.persistedSubagentsReady = this.#remote ? Promise.resolve() - : registerPersistedSubagents(this.#registry, deps.sessionFile) + : registerPersistedSubagents(this.#registry, deps.sessionFile, { + shouldContinue: () => !this.#disposed, + }) .then(() => { if (!this.#disposed) this.#refreshRows(); }) @@ -829,6 +831,10 @@ export class AgentHubOverlayComponent extends Container implements SelectListMou : "Shared workspace · per-agent LoC not attributable", ), ); + const artifacts = ref.history; + if (artifacts?.outputPath) addWrapped(`Output ${shortenPath(artifacts.outputPath)}`); + if (artifacts?.patchPath) addWrapped(`Patch ${shortenPath(artifacts.patchPath)}`); + if (artifacts?.branchName) addWrapped(`Worktree branch ${artifacts.branchName}`); const maxScroll = Math.max(0, lines.length - rows); this.#detailScrollOffset = Math.min(this.#detailScrollOffset, maxScroll); diff --git a/packages/coding-agent/src/registry/agent-registry.ts b/packages/coding-agent/src/registry/agent-registry.ts index af2574150..2b5929184 100644 --- a/packages/coding-agent/src/registry/agent-registry.ts +++ b/packages/coding-agent/src/registry/agent-registry.ts @@ -60,6 +60,12 @@ export interface AgentHistorySummary { resolvedModelIsFallback?: boolean; metrics?: AgentMetricsSummary; readOnly?: boolean; + /** Durable task output artifact, when the executor wrote one. */ + outputPath?: string; + /** Captured isolated-worktree patch, when patch capture succeeded. */ + patchPath?: string; + /** Isolated branch identity, when branch-mode capture succeeded. */ + branchName?: string; } export interface AgentRef { diff --git a/packages/coding-agent/src/registry/persisted-agents.ts b/packages/coding-agent/src/registry/persisted-agents.ts index e94446551..79b19a417 100644 --- a/packages/coding-agent/src/registry/persisted-agents.ts +++ b/packages/coding-agent/src/registry/persisted-agents.ts @@ -107,35 +107,42 @@ function assistantMetrics(message: Record): AssistantMetrics { }; } -async function readPersistedAgentHistory(transcript: PersistedTranscript): Promise { +async function readPersistedAgentHistory( + transcript: PersistedTranscript, + shouldContinue: () => boolean, +): Promise { const parents = new Map(); const assistantById = new Map(); const modelChangeById = new Map(); let leafId: string | undefined; let leafTimestamp: number | undefined; try { - await visitEntriesFromFileStream(transcript.sessionFile, entry => { - const record = recordOf(entry); - if (!record) return; - const id = typeof record.id === "string" ? record.id : undefined; - if (!id) return; - const parentId = typeof record.parentId === "string" ? record.parentId : undefined; - parents.set(id, parentId); - leafId = id; - const parsedTimestamp = timestampOf(record.timestamp); - if (parsedTimestamp !== undefined) leafTimestamp = parsedTimestamp; - if (record.type === "model_change" && typeof record.model === "string") { - modelChangeById.set(id, { - model: record.model, - role: typeof record.role === "string" ? record.role : undefined, - resolvedModelIsFallback: record.resolvedModelIsFallback === true, - }); - return; - } - if (record.type !== "message") return; - const message = recordOf(record.message); - if (message?.role === "assistant") assistantById.set(id, assistantMetrics(message)); - }); + await visitEntriesFromFileStream( + transcript.sessionFile, + entry => { + const record = recordOf(entry); + if (!record) return; + const id = typeof record.id === "string" ? record.id : undefined; + if (!id) return; + const parentId = typeof record.parentId === "string" ? record.parentId : undefined; + parents.set(id, parentId); + leafId = id; + const parsedTimestamp = timestampOf(record.timestamp); + if (parsedTimestamp !== undefined) leafTimestamp = parsedTimestamp; + if (record.type === "model_change" && typeof record.model === "string") { + modelChangeById.set(id, { + model: record.model, + role: typeof record.role === "string" ? record.role : undefined, + resolvedModelIsFallback: record.resolvedModelIsFallback === true, + }); + return; + } + if (record.type !== "message") return; + const message = recordOf(record.message); + if (message?.role === "assistant") assistantById.set(id, assistantMetrics(message)); + }, + { shouldContinue }, + ); } catch { return {}; } @@ -195,6 +202,10 @@ async function readPersistedAgentHistory(transcript: PersistedTranscript): Promi */ async function readPersistedAgentMetadata(sessionFile: string): Promise { const stat = fs.promises.stat(sessionFile).catch(() => undefined); + const artifactBase = sessionFile.slice(0, -".jsonl".length); + const outputPath = `${artifactBase}.md`; + const patchPath = `${artifactBase}.patch`; + const artifactFiles = Promise.all([Bun.file(outputPath).exists(), Bun.file(patchPath).exists()]); let createdAt: number | undefined; let activity: string | undefined; let history: AgentHistorySummary = {}; @@ -239,21 +250,29 @@ async function readPersistedAgentMetadata(sessionFile: string): Promise> { +async function readPersistedVibeChildIds(sessionFile: string, shouldContinue: () => boolean): Promise> { const ids = new Set(); try { - await visitEntriesFromFileStream(sessionFile, entry => { - for (const id of persistedVibeChildIds([entry])) ids.add(id); - }); + await visitEntriesFromFileStream( + sessionFile, + entry => { + for (const id of persistedVibeChildIds([entry])) ids.add(id); + }, + { shouldContinue }, + ); return ids; } catch { return new Set(); @@ -264,19 +283,26 @@ async function readPersistedVibeChildIds(sessionFile: string): Promise boolean } = {}, ): Promise { if (!sessionFile?.endsWith(".jsonl")) return; - const vibeOwnedIds = await readPersistedVibeChildIds(sessionFile); + const shouldContinue = options.shouldContinue ?? (() => true); + if (!shouldContinue()) return; + const vibeOwnedIds = await readPersistedVibeChildIds(sessionFile, shouldContinue); + if (!shouldContinue()) return; const root = sessionFile.slice(0, -6); const transcripts: PersistedTranscript[] = []; - await registerPersistedSubagentsFromDir(registry, root, undefined, vibeOwnedIds, transcripts); + await registerPersistedSubagentsFromDir(registry, root, undefined, vibeOwnedIds, transcripts, shouldContinue); + if (!shouldContinue()) return; let nextTranscript = 0; const workers = Array.from({ length: Math.min(4, transcripts.length) }, async () => { for (;;) { + if (!shouldContinue()) return; const index = nextTranscript++; const transcript = transcripts[index]; if (!transcript) return; - const history = await readPersistedAgentHistory(transcript); + const history = await readPersistedAgentHistory(transcript, shouldContinue); + if (!shouldContinue()) return; registry.setHistory(transcript.id, history, transcript.sessionFile); } }); @@ -289,19 +315,24 @@ async function registerPersistedSubagentsFromDir( parentId: string | undefined, vibeOwnedIds: ReadonlySet, transcripts: PersistedTranscript[], + shouldContinue: () => boolean, ): Promise { + if (!shouldContinue()) return; let entries: fs.Dirent[]; try { entries = await fs.promises.readdir(dir, { withFileTypes: true }); } catch { return; } + if (!shouldContinue()) return; let entriesSinceYield = 0; for (const entry of entries) { + if (!shouldContinue()) return; if (++entriesSinceYield >= 16) { entriesSinceYield = 0; await Bun.sleep(0); } + if (!shouldContinue()) return; if (!entry.isFile() || !entry.name.endsWith(".jsonl") || entry.name.includes(".bak")) continue; const sessionFile = path.join(dir, entry.name); // The advisor transcript is observability-only: register it as a non-peer @@ -320,9 +351,10 @@ async function registerPersistedSubagentsFromDir( // user task literally named `/advisor`): leave it, skip the advisor. if (existing && existing.kind !== "advisor") continue; if (existing?.sessionFile !== sessionFile) { + const metadata = await readPersistedAgentMetadata(sessionFile); + if (!shouldContinue()) return; // The id is reused across `/new`; refresh it to the current session's file. if (existing) registry.unregister(advisorId); - const metadata = await readPersistedAgentMetadata(sessionFile); registry.register({ id: advisorId, displayName, @@ -354,8 +386,10 @@ async function registerPersistedSubagentsFromDir( } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") continue; } + if (!shouldContinue()) return; if (!registry.get(id)) { const metadata = await readPersistedAgentMetadata(sessionFile); + if (!shouldContinue()) return; registry.register({ id, displayName: id, @@ -377,6 +411,13 @@ async function registerPersistedSubagentsFromDir( lastActivity: ref?.lastActivity, }); } - await registerPersistedSubagentsFromDir(registry, path.join(dir, id), id, vibeOwnedIds, transcripts); + await registerPersistedSubagentsFromDir( + registry, + path.join(dir, id), + id, + vibeOwnedIds, + transcripts, + shouldContinue, + ); } } diff --git a/packages/coding-agent/src/task/executor.ts b/packages/coding-agent/src/task/executor.ts index 94b34854a..cae1147a0 100644 --- a/packages/coding-agent/src/task/executor.ts +++ b/packages/coding-agent/src/task/executor.ts @@ -3314,7 +3314,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise { hub.dispose(); }); + it("stops persisted discovery when the Hub is disposed", async () => { + using tempDir = TempDir.createSync("@omp-agent-hub-disposed-scan-"); + const sessionFile = path.join(tempDir.path(), "main.jsonl"); + await Bun.write(sessionFile, ""); + await Bun.write(path.join(tempDir.path(), "main", "Worker.jsonl"), ""); + const agents = new AgentRegistry(); + const hub = new AgentHubOverlayComponent({ + settings: Settings.isolated(), + observers: new SessionObserverRegistry(), + hubKeys: [], + onDone: () => {}, + requestRender: () => {}, + registry: agents, + irc: new IrcBus(agents), + sessionFile, + }); + + hub.dispose(); + await hub.persistedSubagentsReady; + + expect(agents.get("Worker")).toBeUndefined(); + }); it("restores nested parent lineage after restart", async () => { using tempDir = TempDir.createSync("@omp-agent-hub-persisted-tree-"); const sessionFile = path.join(tempDir.path(), "main.jsonl"); diff --git a/packages/coding-agent/test/agent-hub-ordering.test.ts b/packages/coding-agent/test/agent-hub-ordering.test.ts index de4ab9d95..a04c4f96c 100644 --- a/packages/coding-agent/test/agent-hub-ordering.test.ts +++ b/packages/coding-agent/test/agent-hub-ordering.test.ts @@ -437,6 +437,11 @@ describe("Agent hub row ordering", () => { kind: "sub", parentId: "Main", session: null, + history: { + outputPath: "/tmp/Reviewer.md", + patchPath: "/tmp/Reviewer.patch", + branchName: "omp/task/Reviewer", + }, }); const observers = new SessionObserverRegistry(); vi.spyOn(observers, "getSessions").mockReturnValue([ @@ -482,6 +487,10 @@ describe("Agent hub row ordering", () => { expect(rendered).toContain("31K/128K 24%"); expect(rendered).toContain("Registered "); expect(rendered).toContain("Shared workspace · per-agent LoC not attributable"); + expect(rendered).toContain("Output /tmp/Reviewer.md"); + expect(rendered).toContain("Patch /tmp/Reviewer.patch"); + hub.handleInput("\x1b[6~"); + expect(Bun.stripANSI(hub.render(140).join("\n"))).toContain("Worktree branch omp/task/Reviewer"); } finally { hub.dispose(); } diff --git a/packages/coding-agent/test/task/isolation-runner.test.ts b/packages/coding-agent/test/task/isolation-runner.test.ts index 6a9a3c242..f29dc52de 100644 --- a/packages/coding-agent/test/task/isolation-runner.test.ts +++ b/packages/coding-agent/test/task/isolation-runner.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; +import { AgentRegistry } from "@oh-my-pi/pi-coding-agent/registry/agent-registry"; import * as executorModule from "@oh-my-pi/pi-coding-agent/task/executor"; import { applyEligibleNestedPatches, @@ -73,6 +74,7 @@ async function seedFooRepo(finalContent: string): Promise<{ repoRoot: string; pa describe("runIsolatedSubprocess", () => { afterEach(async () => { vi.restoreAllMocks(); + AgentRegistry.resetGlobalForTests(); await Promise.all(tempRoots.splice(0).map(tempRoot => fs.rm(tempRoot, { force: true, recursive: true }))); }); @@ -107,6 +109,13 @@ describe("runIsolatedSubprocess", () => { nestedPatches: [], }); const cleanupSpy = vi.spyOn(worktreeModule, "cleanupIsolation").mockResolvedValue(); + AgentRegistry.global().register({ + id: "PreserveBranchFailure", + displayName: "PreserveBranchFailure", + kind: "sub", + session: null, + status: "parked", + }); const deleteSpy = vi.spyOn(gitModule.branch, "tryDelete").mockResolvedValue(true); const outcome = await runIsolatedSubprocess({ @@ -138,6 +147,7 @@ describe("runIsolatedSubprocess", () => { expect(captureSpy).toHaveBeenCalledWith(isolationDir, baseline); expect(deleteSpy).toHaveBeenCalledWith(repoRoot, "omp/task/PreserveBranchFailure"); expect(cleanupSpy).toHaveBeenCalledTimes(1); + expect(AgentRegistry.global().get("PreserveBranchFailure")?.history?.patchPath).toBe(patchPath); }); });