fix(coding-agent): complete Agent Hub inspector metadata

This commit is contained in:
Kyle McCleary
2026-08-04 17:20:00 -07:00
parent 8e5f619502
commit 3180fd3d7f
9 changed files with 161 additions and 49 deletions
+4 -1
View File
@@ -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
@@ -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);
@@ -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 {
@@ -107,35 +107,42 @@ function assistantMetrics(message: Record<string, unknown>): AssistantMetrics {
};
}
async function readPersistedAgentHistory(transcript: PersistedTranscript): Promise<AgentHistorySummary> {
async function readPersistedAgentHistory(
transcript: PersistedTranscript,
shouldContinue: () => boolean,
): Promise<AgentHistorySummary> {
const parents = new Map<string, string | undefined>();
const assistantById = new Map<string, AssistantMetrics>();
const modelChangeById = new Map<string, { model: string; role?: string; resolvedModelIsFallback: boolean }>();
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<PersistedAgentMetadata> {
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<Persiste
// A readable transcript is still useful even when its optional metadata
// prefix is malformed.
}
const file = await stat;
const [file, [hasOutput, hasPatch]] = await Promise.all([stat, artifactFiles]);
return {
activity,
createdAt: createdAt ?? file?.birthtimeMs,
lastActivity: file?.mtimeMs,
history,
history: {
...history,
...(hasOutput ? { outputPath } : {}),
...(hasPatch ? { patchPath } : {}),
},
};
}
async function readPersistedVibeChildIds(sessionFile: string): Promise<Set<string>> {
async function readPersistedVibeChildIds(sessionFile: string, shouldContinue: () => boolean): Promise<Set<string>> {
const ids = new Set<string>();
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<Set<strin
export async function registerPersistedSubagents(
registry: AgentRegistry,
sessionFile: string | null | undefined,
options: { shouldContinue?: () => boolean } = {},
): Promise<void> {
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<string>,
transcripts: PersistedTranscript[],
shouldContinue: () => boolean,
): Promise<void> {
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 `<owner>/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,
);
}
}
+3 -1
View File
@@ -3314,7 +3314,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
const done = await runSubagent();
monitor.finish();
return finalizeRunResult({
const result = await finalizeRunResult({
monitor,
done,
index,
@@ -3335,4 +3335,6 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
sessionFile: subtaskSessionFile,
startTime,
});
AgentRegistry.global().setHistory(id, { outputPath: result.outputPath });
return result;
}
@@ -20,6 +20,7 @@
*/
import * as path from "node:path";
import type * as natives from "@oh-my-pi/pi-natives";
import { AgentRegistry } from "../registry/agent-registry";
import type { ToolSession } from "../tools";
import { generateCommitMessage } from "../utils/commit-message-generator";
import * as git from "../utils/git";
@@ -43,6 +44,15 @@ import {
type IsoBackendKind = natives.IsoBackendKind;
function rememberAgentArtifacts(result: SingleResult): SingleResult {
AgentRegistry.global().setHistory(result.id, {
outputPath: result.outputPath,
patchPath: result.patchPath,
branchName: result.branchName,
});
return result;
}
/** Resolved repo + baseline used by every isolated spawn in a single call. */
export interface IsolationContext {
repoRoot: string;
@@ -165,14 +175,14 @@ export async function runIsolatedSubprocess(opts: IsolatedRunOptions): Promise<S
opts.description,
opts.buildCommitMessage?.(),
);
return {
return rememberAgentArtifacts({
...result,
branchName: commitResult?.branchName,
branchBaseSha: commitResult?.baseSha,
nestedPatches: commitResult?.nestedPatches,
};
});
} catch (mergeErr) {
// Agent succeeded but branch commit failed — clean up stale branch
// Agent succeeded but branch commit failed — clean up stale branch.
const branchName = `omp/task/${opts.agentId}`;
await git.branch.tryDelete(opts.context.repoRoot, branchName);
const msg = mergeErr instanceof Error ? mergeErr.message : String(mergeErr);
@@ -183,34 +193,37 @@ export async function runIsolatedSubprocess(opts: IsolatedRunOptions): Promise<S
opts.artifactsDir,
opts.agentId,
);
return {
return rememberAgentArtifacts({
...result,
patchPath: patchResult.patchPath,
nestedPatches: patchResult.nestedPatches,
error: `Merge failed: ${msg}`,
};
});
} catch (patchErr) {
const patchMsg = patchErr instanceof Error ? patchErr.message : String(patchErr);
return { ...result, error: `Merge failed: ${msg}; patch capture failed: ${patchMsg}` };
return rememberAgentArtifacts({
...result,
error: `Merge failed: ${msg}; patch capture failed: ${patchMsg}`,
});
}
}
}
if (result.exitCode === 0) {
try {
const patchResult = await writeIsolationPatch(isolationDir, taskBaseline, opts.artifactsDir, opts.agentId);
return {
return rememberAgentArtifacts({
...result,
patchPath: patchResult.patchPath,
nestedPatches: patchResult.nestedPatches,
};
});
} catch (patchErr) {
const msg = patchErr instanceof Error ? patchErr.message : String(patchErr);
return { ...result, error: `Patch capture failed: ${msg}` };
return rememberAgentArtifacts({ ...result, error: `Patch capture failed: ${msg}` });
}
}
return result;
return rememberAgentArtifacts(result);
} catch (err) {
return opts.buildFailureResult(err);
return rememberAgentArtifacts(opts.buildFailureResult(err));
} finally {
if (handle) {
await cleanupIsolation(handle);
@@ -144,6 +144,28 @@ describe("Agent hub Enter activation", () => {
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");
@@ -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();
}
@@ -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);
});
});