fix(coding-agent): complete Agent Hub inspector metadata
This commit is contained in:
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user