refactor(agent): unify timeline events with tool_call schema and add resume/refresh capabilities

This commit is contained in:
Mohamed Boudra
2025-11-15 11:58:41 +01:00
parent 8cf808b14d
commit c37e499562
23 changed files with 2636 additions and 454 deletions
@@ -46,33 +46,51 @@ export function curateAgentActivity(
for (const item of recentItems) {
switch (item.type) {
case "user_message":
flushBuffers(lines, buffers);
lines.push(`[User] ${item.text.trim()}`);
break;
case "assistant_message":
buffers.message = appendText(buffers.message, item.text);
break;
case "reasoning":
buffers.thought = appendText(buffers.thought, item.text);
break;
case "command":
case "tool_call": {
flushBuffers(lines, buffers);
lines.push(`[Command: ${item.command}] ${item.status}`);
break;
case "file_change":
flushBuffers(lines, buffers);
if (item.files.length > 0) {
lines.push("[File Changes]");
for (const file of item.files) {
lines.push(`- (${file.kind}) ${file.path}`);
const label =
item.displayName ??
(item.server && item.tool ? `${item.server}.${item.tool}` : item.tool ?? item.server ?? "Tool");
const status = item.status ? ` ${item.status}` : "";
if (item.kind === "execute" || item.server === "command") {
lines.push(`[Command: ${label}]${status}`);
} else if (item.kind === "edit" || item.server === "file_change") {
const files =
(item.output &&
typeof item.output === "object" &&
item.output !== null &&
Array.isArray((item.output as Record<string, unknown>).files)
? ((item.output as Record<string, unknown>).files as { path: string; kind: string }[])
: []) ?? [];
if (files.length > 0) {
lines.push("[File Changes]");
for (const file of files) {
lines.push(`- (${file.kind}) ${file.path}`);
}
} else {
lines.push(`[Edit] ${label}${status}`);
}
} else if (item.kind === "search" || item.server === "web_search") {
const query =
typeof item.input === "object" && item.input !== null && "query" in (item.input as Record<string, unknown>)
? String((item.input as Record<string, unknown>).query ?? "")
: "";
lines.push(`[Web Search] ${query || label}`);
} else {
lines.push(`[Tool ${item.server}.${item.tool}]${status}`);
}
break;
case "mcp_tool":
flushBuffers(lines, buffers);
lines.push(`[MCP ${item.server}.${item.tool}] ${item.status}`);
break;
case "web_search":
flushBuffers(lines, buffers);
lines.push(`[Web Search] ${item.query}`);
break;
}
case "todo":
flushBuffers(lines, buffers);
lines.push("[Plan]");
@@ -16,6 +16,8 @@ import type {
AgentStreamEvent,
AgentTimelineItem,
AgentUsage,
ListPersistedAgentsOptions,
PersistedAgentDescriptor,
} from "./agent-sdk-types.js";
export type AgentLifecycleStatus =
@@ -53,6 +55,10 @@ export type SubscribeOptions = {
replayState?: boolean;
};
export type PersistedAgentQueryOptions = ListPersistedAgentsOptions & {
provider?: AgentProvider;
};
export type AgentManagerOptions = {
clients?: Partial<Record<AgentProvider, AgentClient>>;
maxTimelineItems?: number;
@@ -143,6 +149,44 @@ export class AgentManager {
);
}
async listPersistedAgents(
options?: PersistedAgentQueryOptions
): Promise<PersistedAgentDescriptor[]> {
if (options?.provider) {
const client = this.requireClient(options.provider);
if (!client.listPersistedAgents) {
return [];
}
return client.listPersistedAgents({ limit: options.limit });
}
const descriptors: PersistedAgentDescriptor[] = [];
for (const [provider, client] of this.clients.entries()) {
if (!client.listPersistedAgents) {
continue;
}
try {
const entries = await client.listPersistedAgents({
limit: options?.limit,
});
descriptors.push(...entries);
} catch (error) {
console.warn(
`[AgentManager] Failed to list persisted agents for provider '${provider}':`,
error
);
}
}
const limit = options?.limit ?? 20;
return descriptors
.sort(
(a, b) =>
b.lastActivityAt.getTime() - a.lastActivityAt.getTime()
)
.slice(0, limit);
}
getAgent(id: string): AgentSnapshot | null {
const agent = this.agents.get(id);
return agent ? this.toSnapshot(agent) : null;
@@ -182,6 +226,37 @@ export class AgentManager {
);
}
async refreshAgentFromPersistence(agentId: string): Promise<AgentSnapshot> {
const existing = this.requireAgent(agentId);
const handle = existing.persistence;
if (!handle) {
throw new Error(
`Agent ${agentId} cannot be refreshed because it has no persistence handle`
);
}
const client = this.requireClient(handle.provider);
const overrides = {
...existing.config,
provider: handle.provider,
};
const session = await client.resumeSession(handle, overrides);
// Remove the existing agent entry before swapping sessions
this.agents.delete(agentId);
try {
await existing.session.close();
} catch (error) {
console.warn(
`[AgentManager] Failed to close previous session for agent ${agentId} during refresh:`,
error
);
}
return this.registerSession(session, overrides, agentId);
}
async closeAgent(agentId: string): Promise<void> {
const agent = this.requireAgent(agentId);
this.agents.delete(agentId);
@@ -229,6 +304,27 @@ export class AgentManager {
};
}
recordUserMessage(
agentId: string,
text: string,
options?: { messageId?: string; raw?: unknown }
): void {
const agent = this.requireAgent(agentId);
const item: AgentTimelineItem = {
type: "user_message",
text,
messageId: options?.messageId,
raw: options?.raw,
};
agent.updatedAt = new Date();
this.recordTimeline(agent, item);
this.dispatchStream(agentId, {
type: "timeline",
item,
provider: agent.provider,
});
}
streamAgent(
agentId: string,
prompt: AgentPromptInput,
@@ -1,7 +1,7 @@
import { describe, expect, test, beforeEach, afterEach } from "vitest";
import os from "node:os";
import path from "node:path";
import { mkdtempSync, rmSync } from "node:fs";
import { mkdtempSync, rmSync, writeFileSync, readFileSync } from "node:fs";
import { AgentRegistry } from "./agent-registry.js";
import type { AgentSnapshot } from "./agent-manager.js";
@@ -113,4 +113,33 @@ describe("AgentRegistry", () => {
const record = await registry.get("agent-3");
expect(record?.lastModeId).toBe("plan");
});
test("recovers from trailing garbage in agents.json", async () => {
const payload = [
{
id: "agent-4",
provider: "claude",
cwd: "/tmp/project",
createdAt: "2024-01-01T00:00:00.000Z",
updatedAt: "2024-01-01T00:00:00.000Z",
title: "Recovered agent",
lastStatus: "idle",
lastModeId: "plan",
config: null,
persistence: null,
},
];
writeFileSync(
filePath,
`${JSON.stringify(payload, null, 2)}\nGARBAGE-TRAILING`
);
const reloaded = new AgentRegistry(filePath);
const result = await reloaded.list();
expect(result).toHaveLength(1);
expect(result[0]?.title).toBe("Recovered agent");
const sanitized = readFileSync(filePath, "utf8");
expect(sanitized.includes("GARBAGE-TRAILING")).toBe(false);
});
});
@@ -1,5 +1,8 @@
import { randomUUID } from "node:crypto";
import { existsSync } from "node:fs";
import { promises as fs } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { z } from "zod";
import type { AgentSnapshot } from "./agent-manager.js";
@@ -51,13 +54,7 @@ export class AgentRegistry {
constructor(filePath?: string) {
this.filePath =
filePath ??
path.join(
process.cwd(),
"packages",
"server",
"agents.json"
);
filePath ?? path.join(resolveServerPackageRoot(), "agents.json");
}
async load(): Promise<StoredAgentRecord[]> {
@@ -66,22 +63,9 @@ export class AgentRegistry {
}
try {
const content = await fs.readFile(this.filePath, "utf8");
const parsed = JSON.parse(content);
if (!Array.isArray(parsed)) {
throw new Error("Invalid agents.json format");
}
const records: StoredAgentRecord[] = [];
for (const entry of parsed) {
try {
const record = STORED_AGENT_SCHEMA.parse(entry);
records.push(record);
this.cache.set(record.id, record);
} catch (error) {
console.error("[AgentRegistry] Skipping invalid record:", error);
}
}
const parsed = await this.parseContent(content);
this.loaded = true;
return records;
return parsed;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
this.loaded = true;
@@ -194,7 +178,63 @@ export class AgentRegistry {
private async flush(): Promise<void> {
const payload = JSON.stringify(Array.from(this.cache.values()), null, 2);
await fs.mkdir(path.dirname(this.filePath), { recursive: true });
await fs.writeFile(this.filePath, payload, "utf8");
await writeFileAtomically(this.filePath, payload);
}
private async parseContent(content: string): Promise<StoredAgentRecord[]> {
try {
return this.parseRecords(content);
} catch (error) {
if (error instanceof SyntaxError) {
const recovered = await this.tryRecoverCorruptedContent(content);
if (recovered) {
return recovered;
}
}
throw error;
}
}
private parseRecords(content: string): StoredAgentRecord[] {
const parsed = JSON.parse(content);
if (!Array.isArray(parsed)) {
throw new Error("Invalid agents.json format");
}
const records: StoredAgentRecord[] = [];
for (const entry of parsed) {
try {
const record = STORED_AGENT_SCHEMA.parse(entry);
records.push(record);
this.cache.set(record.id, record);
} catch (error) {
console.error("[AgentRegistry] Skipping invalid record:", error);
}
}
return records;
}
private async tryRecoverCorruptedContent(
content: string
): Promise<StoredAgentRecord[] | null> {
const start = content.indexOf("[");
const end = content.lastIndexOf("]");
if (start === -1 || end === -1 || end <= start) {
return null;
}
const candidate = content.slice(start, end + 1);
try {
this.cache.clear();
const records = this.parseRecords(candidate);
console.warn(
"[AgentRegistry] Recovered corrupted agents.json payload; rewrote sanitized copy"
);
const sanitizedPayload = JSON.stringify(records, null, 2);
await writeFileAtomically(this.filePath, sanitizedPayload);
return records;
} catch (error) {
this.cache.clear();
return null;
}
}
}
@@ -210,3 +250,29 @@ function sanitizeConfig(
if (config.extra) cleaned.extra = JSON.parse(JSON.stringify(config.extra));
return cleaned;
}
function resolveServerPackageRoot(): string {
let currentDir = path.dirname(fileURLToPath(import.meta.url));
while (true) {
if (existsSync(path.join(currentDir, "package.json"))) {
return currentDir;
}
const parentDir = path.dirname(currentDir);
if (parentDir === currentDir) {
throw new Error(
"[AgentRegistry] Failed to locate server package root for agents.json"
);
}
currentDir = parentDir;
}
}
async function writeFileAtomically(targetPath: string, payload: string) {
const directory = path.dirname(targetPath);
const tempPath = path.join(
directory,
`.agents.json.tmp-${process.pid}-${Date.now()}-${randomUUID()}`
);
await fs.writeFile(tempPath, payload, "utf8");
await fs.rename(tempPath, targetPath);
}
@@ -45,12 +45,22 @@ export type AgentUsage = {
};
export type AgentTimelineItem =
| { type: "user_message"; text: string; messageId?: string; raw?: unknown }
| { type: "assistant_message"; text: string; raw?: unknown }
| { type: "reasoning"; text: string; raw?: unknown }
| { type: "command"; command: string; status: string; raw?: unknown }
| { type: "file_change"; files: { path: string; kind: string }[]; raw?: unknown }
| { type: "mcp_tool"; server: string; tool: string; status: string; raw?: unknown }
| { type: "web_search"; query: string; raw?: unknown }
| {
type: "tool_call";
server: string;
tool: string;
status?: string;
callId?: string;
displayName?: string;
kind?: string;
input?: unknown;
output?: unknown;
error?: unknown;
raw?: unknown;
}
| { type: "todo"; items: { text: string; completed: boolean }[]; raw?: unknown }
| { type: "error"; message: string; raw?: unknown };
@@ -105,6 +115,19 @@ export type AgentRunResult = {
timeline: AgentTimelineItem[];
};
export type ListPersistedAgentsOptions = {
limit?: number;
};
export type PersistedAgentDescriptor = {
provider: AgentProvider;
sessionId: string;
cwd: string;
title: string | null;
lastActivityAt: Date;
persistence: AgentPersistenceHandle;
};
export type AgentSessionConfig = {
provider: AgentProvider;
cwd: string;
@@ -143,4 +166,5 @@ export interface AgentClient {
readonly capabilities: AgentCapabilityFlags;
createSession(config: AgentSessionConfig): Promise<AgentSession>;
resumeSession(handle: AgentPersistenceHandle, overrides?: Partial<AgentSessionConfig>): Promise<AgentSession>;
listPersistedAgents?(options?: ListPersistedAgentsOptions): Promise<PersistedAgentDescriptor[]>;
}
@@ -0,0 +1,27 @@
import { describe, expect, test } from "vitest";
import { extractUserMessageText } from "./claude-agent.js";
describe("extractUserMessageText", () => {
test("returns trimmed string content", () => {
expect(extractUserMessageText(" Hello world ")).toBe("Hello world");
});
test("combines multiple text blocks", () => {
const content = [
{ type: "text", text: "First line" },
{ type: "text", text: "Second line" },
];
expect(extractUserMessageText(content)).toBe("First line\n\nSecond line");
});
test("returns null when no textual content is present", () => {
const content = [
{ type: "image", source: "foo.png" },
{ type: "file", path: "bar.txt" },
];
expect(extractUserMessageText(content)).toBeNull();
});
});
@@ -111,21 +111,33 @@ describe("ClaudeAgentClient (SDK integration)", () => {
}
}
const toolEvent = timeline.find((item) => item.type === "mcp_tool");
const commandEvents = timeline.filter(
(item): item is Extract<AgentTimelineItem, { type: "command" }> =>
item.type === "command" && typeof item.command === "string" && !item.command.startsWith("permission:")
const toolCalls = timeline.filter(
(item): item is Extract<AgentTimelineItem, { type: "tool_call" }> =>
item.type === "tool_call"
);
const fileChangeEvent = timeline.find(
const commandEvents = toolCalls.filter(
(item) =>
item.type === "file_change" &&
item.files.some((file) => file.path.includes("tool-test.txt"))
(item.kind === "execute" || item.server === "command") &&
typeof item.displayName === "string" &&
!item.displayName.startsWith("permission:")
);
const fileChangeEvent = toolCalls.find((item) => {
if (!item.output || typeof item.output !== "object") {
return false;
}
const files = (item.output as Record<string, unknown>).files;
if (!Array.isArray(files)) {
return false;
}
return files.some((file) => typeof file?.path === "string" && file.path.includes("tool-test.txt"));
});
const sawPwdCommand = commandEvents.some((item) => item.command.toLowerCase().includes("pwd") && item.status === "completed");
const sawPwdCommand = commandEvents.some(
(item) => (item.displayName ?? "").toLowerCase().includes("pwd") && item.status === "completed"
);
expect(completed).toBe(true);
expect(toolEvent).toBeTruthy();
expect(toolCalls.length).toBeGreaterThan(0);
expect(sawPwdCommand).toBe(true);
expect(fileChangeEvent).toBeTruthy();
@@ -1,5 +1,5 @@
import { randomUUID } from "node:crypto";
import fs from "node:fs";
import fs, { promises as fsPromises } from "node:fs";
import os from "node:os";
import path from "node:path";
import {
@@ -35,6 +35,8 @@ import type {
AgentStreamEvent,
AgentTimelineItem,
AgentUsage,
ListPersistedAgentsOptions,
PersistedAgentDescriptor,
} from "../agent-sdk-types.js";
const CLAUDE_CAPABILITIES: AgentCapabilityFlags = {
@@ -71,7 +73,43 @@ const DEFAULT_MODES: AgentMode[] = [
type ClaudeAgentConfig = AgentSessionConfig & { provider: "claude" };
type ClaudeContentChunk = { type: string; [key: string]: any };
export type ClaudeContentChunk = { type: string; [key: string]: any };
export function extractUserMessageText(
content: string | ClaudeContentChunk[]
): string | null {
if (typeof content === "string") {
const normalized = content.trim();
return normalized.length > 0 ? normalized : null;
}
if (!Array.isArray(content)) {
return null;
}
const parts: string[] = [];
for (const block of content) {
if (!block || typeof block !== "object") {
continue;
}
const text = typeof block.text === "string" ? block.text : undefined;
if (text && text.trim()) {
parts.push(text.trim());
continue;
}
const input = typeof block.input === "string" ? block.input : undefined;
if (input && input.trim()) {
parts.push(input.trim());
}
}
if (parts.length === 0) {
return null;
}
const combined = parts.join("\n\n").trim();
return combined.length > 0 ? combined : null;
}
type PendingPermission = {
request: AgentPermissionRequest;
@@ -81,6 +119,7 @@ type PendingPermission = {
};
type ToolUseClassification = "generic" | "command" | "file_change";
type ToolCallTimelineItem = Extract<AgentTimelineItem, { type: "tool_call" }>;
type ToolUseCacheEntry = {
id: string;
@@ -90,6 +129,7 @@ type ToolUseCacheEntry = {
started: boolean;
commandText?: string;
files?: { path: string; kind: string }[];
input?: Record<string, unknown> | null;
};
const DEFAULT_PERMISSION_TIMEOUT_MS = 120_000;
@@ -123,6 +163,28 @@ export class ClaudeAgentClient implements AgentClient {
return new ClaudeAgentSession(claudeConfig, this.defaults, handle);
}
async listPersistedAgents(options?: ListPersistedAgentsOptions): Promise<PersistedAgentDescriptor[]> {
const projectsRoot = path.join(os.homedir(), ".claude", "projects");
if (!(await pathExists(projectsRoot))) {
return [];
}
const limit = options?.limit ?? 20;
const candidates = await collectRecentClaudeSessions(projectsRoot, limit * 3);
const descriptors: PersistedAgentDescriptor[] = [];
for (const candidate of candidates) {
const descriptor = await parseClaudeSessionDescriptor(candidate.path, candidate.mtime);
if (descriptor) {
descriptors.push(descriptor);
}
if (descriptors.length >= limit) {
break;
}
}
return descriptors;
}
private assertConfig(config: AgentSessionConfig): ClaudeAgentConfig {
if (config.provider !== "claude") {
throw new Error(`ClaudeAgentClient received config for provider '${config.provider}'`);
@@ -269,10 +331,13 @@ class ClaudeAgentSession implements AgentSession {
if (response.behavior === "allow") {
if (pending.request.kind === "plan") {
await this.setMode("acceptEdits");
this.enqueueTimeline({
type: "command",
command: "plan:approved",
this.pushToolCall({
server: "plan",
tool: "plan_approval",
status: "granted",
callId: pending.request.id,
displayName: "Plan approved",
kind: "plan",
raw: pending.request,
});
}
@@ -282,10 +347,14 @@ class ClaudeAgentSession implements AgentSession {
updatedPermissions: this.normalizePermissionUpdates(response.updatedPermissions),
};
pending.resolve(result);
this.enqueueTimeline({
type: "command",
command: `permission:${pending.request.name}`,
this.pushToolCall({
server: "permission",
tool: pending.request.name,
status: "granted",
callId: pending.request.id,
displayName: pending.request.title ?? pending.request.name,
kind: "permission",
input: pending.request.input,
raw: { request: pending.request, response },
});
} else {
@@ -295,10 +364,14 @@ class ClaudeAgentSession implements AgentSession {
interrupt: response.interrupt,
};
pending.resolve(result);
this.enqueueTimeline({
type: "command",
command: `permission:${pending.request.name}`,
this.pushToolCall({
server: "permission",
tool: pending.request.name,
status: "denied",
callId: pending.request.id,
displayName: pending.request.title ?? pending.request.name,
kind: "permission",
input: pending.request.input,
raw: { request: pending.request, response },
});
}
@@ -551,10 +624,14 @@ class ClaudeAgentSession implements AgentSession {
raw: { toolName, input, options },
};
this.enqueueTimeline({
type: "command",
command: `permission:${toolName}`,
this.pushToolCall({
server: "permission",
tool: toolName,
status: "requested",
callId: requestId,
displayName: request.title ?? toolName,
kind: "permission",
input,
raw: { toolName, input },
});
@@ -576,10 +653,14 @@ class ClaudeAgentSession implements AgentSession {
this.pendingPermissions.delete(requestId);
cleanup();
const error = new Error("Permission request timed out");
this.enqueueTimeline({
type: "command",
command: `permission:${toolName}`,
this.pushToolCall({
server: "permission",
tool: toolName,
status: "denied",
callId: requestId,
displayName: request.title ?? toolName,
kind: "permission",
input,
raw: { reason: "timeout", toolName, input },
});
this.pushEvent({
@@ -634,6 +715,37 @@ class ClaudeAgentSession implements AgentSession {
this.pushEvent({ type: "timeline", item, provider: "claude" });
}
private pushToolCall(
data: Omit<ToolCallTimelineItem, "type">,
target?: AgentTimelineItem[]
) {
const item: AgentTimelineItem = { type: "tool_call", ...data };
if (target) {
target.push(item);
return;
}
this.enqueueTimeline(item);
}
private getToolKind(classification?: ToolUseClassification): string | undefined {
switch (classification) {
case "command":
return "execute";
case "file_change":
return "edit";
case "generic":
default:
return "tool";
}
}
private buildToolDisplayName(entry?: ToolUseCacheEntry): string | undefined {
if (!entry) {
return undefined;
}
return entry.commandText ?? entry.name;
}
private pushEvent(event: AgentStreamEvent) {
if (this.eventQueue) {
this.eventQueue.push(event);
@@ -700,20 +812,39 @@ class ClaudeAgentSession implements AgentSession {
private convertHistoryEntry(entry: any): AgentTimelineItem[] {
const message = entry?.message;
if (!message) {
if (!message || !("content" in message)) {
return [];
}
if (entry.type === "assistant" && message.content) {
return this.mapBlocksToTimeline(message.content as ClaudeContentChunk[]);
const content = message.content as string | ClaudeContentChunk[];
if (entry.type === "assistant" && content) {
return this.mapBlocksToTimeline(content);
}
if (Array.isArray(message.content)) {
const hasToolBlock = message.content.some((block: ClaudeContentChunk) =>
typeof block?.type === "string" && block.type.includes("tool")
if (entry.type === "user") {
const text = extractUserMessageText(content);
if (text) {
return [
{
type: "user_message",
text,
raw: message,
},
];
}
return [];
}
if (Array.isArray(content)) {
const hasToolBlock = content.some(
(block: ClaudeContentChunk) =>
typeof block?.type === "string" && block.type.includes("tool")
);
if (hasToolBlock) {
return this.mapBlocksToTimeline(message.content as ClaudeContentChunk[]);
return this.mapBlocksToTimeline(content);
}
}
return [];
}
@@ -773,28 +904,19 @@ class ClaudeAgentSession implements AgentSession {
}
entry.started = true;
this.toolUseCache.set(entry.id, entry);
this.enqueueTimeline({
type: "command",
command: `permission:${entry.name}`,
status: "granted",
raw: block,
});
if (entry.classification === "command") {
const commandText = entry.commandText ?? entry.name;
items.push({
type: "command",
command: commandText,
status: "running",
this.pushToolCall(
{
server: entry.server,
tool: entry.name,
status: "pending",
callId: entry.id,
displayName: this.buildToolDisplayName(entry),
kind: this.getToolKind(entry.classification),
input: entry.input ?? this.normalizeToolInput(block.input),
raw: block,
});
}
items.push({
type: "mcp_tool",
server: entry.server,
tool: entry.name,
status: "pending",
raw: block,
});
},
items
);
}
private handleToolResult(block: ClaudeContentChunk, items: AgentTimelineItem[]): void {
@@ -802,29 +924,25 @@ class ClaudeAgentSession implements AgentSession {
const server = entry?.server ?? block.server ?? "tool";
const tool = entry?.name ?? block.tool_name ?? "tool";
const status = block.is_error ? "failed" : "completed";
items.push({
type: "mcp_tool",
server,
tool,
status,
raw: block,
});
if (entry?.classification === "command") {
const commandText = entry.commandText ?? entry.name;
items.push({
type: "command",
command: commandText,
const rawPayload =
!block.is_error && entry?.classification === "file_change" && entry.files?.length
? { block, files: entry.files }
: block;
this.pushToolCall(
{
server,
tool,
status,
raw: block,
});
}
if (!block.is_error && entry?.classification === "file_change" && entry.files?.length) {
items.push({
type: "file_change",
files: entry.files,
raw: { block, files: entry.files },
});
}
callId: typeof block.tool_use_id === "string" ? block.tool_use_id : undefined,
displayName: this.buildToolDisplayName(entry),
kind: this.getToolKind(entry?.classification),
input: entry?.input,
output: !block.is_error && entry?.files?.length ? { files: entry.files } : undefined,
error: block.is_error ? block : undefined,
raw: rawPayload,
},
items
);
if (typeof block.tool_use_id === "string") {
this.toolUseCache.delete(block.tool_use_id);
}
@@ -872,6 +990,7 @@ class ClaudeAgentSession implements AgentSession {
if (block.type === "tool_use") {
const input = this.normalizeToolInput(block.input);
existing.input = input;
if (input) {
if (this.isCommandTool(existing.name, input)) {
existing.classification = "command";
@@ -1043,3 +1162,142 @@ class Pushable<T> implements AsyncIterable<T> {
};
}
}
type ClaudeSessionCandidate = {
path: string;
mtime: Date;
};
async function pathExists(target: string): Promise<boolean> {
try {
await fsPromises.access(target);
return true;
} catch {
return false;
}
}
async function collectRecentClaudeSessions(root: string, limit: number): Promise<ClaudeSessionCandidate[]> {
let projectDirs: string[];
try {
projectDirs = await fsPromises.readdir(root);
} catch {
return [];
}
const candidates: ClaudeSessionCandidate[] = [];
for (const dirName of projectDirs) {
const projectPath = path.join(root, dirName);
let stats: fs.Stats;
try {
stats = await fsPromises.stat(projectPath);
} catch {
continue;
}
if (!stats.isDirectory()) {
continue;
}
let files: string[];
try {
files = await fsPromises.readdir(projectPath);
} catch {
continue;
}
for (const file of files) {
if (!file.endsWith(".jsonl")) {
continue;
}
const fullPath = path.join(projectPath, file);
try {
const fileStats = await fsPromises.stat(fullPath);
candidates.push({ path: fullPath, mtime: fileStats.mtime });
} catch {
// ignore stat errors for individual files
}
}
}
return candidates
.sort((a, b) => b.mtime.getTime() - a.mtime.getTime())
.slice(0, limit);
}
async function parseClaudeSessionDescriptor(
filePath: string,
mtime: Date
): Promise<PersistedAgentDescriptor | null> {
let content: string;
try {
content = await fsPromises.readFile(filePath, "utf8");
} catch {
return null;
}
let sessionId: string | null = null;
let cwd: string | null = null;
let title: string | null = null;
for (const rawLine of content.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line) continue;
let entry: any;
try {
entry = JSON.parse(line);
} catch {
continue;
}
if (!sessionId && typeof entry.sessionId === "string") {
sessionId = entry.sessionId;
}
if (!cwd && typeof entry.cwd === "string") {
cwd = entry.cwd;
}
if (!title && entry.type === "user" && entry.message) {
title = extractClaudeUserText(entry.message);
}
if (sessionId && cwd && title) {
break;
}
}
if (!sessionId || !cwd) {
return null;
}
const persistence: AgentPersistenceHandle = {
provider: "claude",
sessionId,
nativeHandle: sessionId,
metadata: {
provider: "claude",
cwd,
},
};
return {
provider: "claude",
sessionId,
cwd,
title: (title ?? "").trim() || `Claude session ${sessionId.slice(0, 8)}`,
lastActivityAt: mtime,
persistence,
};
}
function extractClaudeUserText(message: any): string | null {
if (!message) {
return null;
}
if (typeof message.content === "string") {
return message.content.trim();
}
if (typeof message.text === "string") {
return message.text.trim();
}
if (Array.isArray(message.content)) {
for (const block of message.content) {
if (block && typeof block.text === "string") {
return block.text.trim();
}
}
}
return null;
}
@@ -110,7 +110,7 @@ describe("CodexAgentClient (SDK integration)", () => {
if (
event.type === "timeline" &&
event.provider === "codex" &&
(event.item.type === "file_change" || event.item.type === "command" || event.item.type === "mcp_tool")
event.item.type === "tool_call"
) {
sawActivity = true;
}
@@ -34,6 +34,8 @@ import type {
AgentStreamEvent,
AgentTimelineItem,
AgentUsage,
ListPersistedAgentsOptions,
PersistedAgentDescriptor,
} from "../agent-sdk-types.js";
type CodexAgentConfig = AgentSessionConfig & { provider: "codex" };
@@ -96,6 +98,33 @@ const MODE_PRESETS: Record<
type CodexOptionsOverrides = Partial<ThreadOptions> & { skipGitRepoCheck?: boolean };
const MAX_ROLLOUT_SEARCH_DEPTH = 5;
type ToolCallTimelineItem = Extract<AgentTimelineItem, { type: "tool_call" }>;
function createToolCallTimelineItem(
data: Omit<ToolCallTimelineItem, "type">
): AgentTimelineItem {
return { type: "tool_call", ...data };
}
function buildCommandDisplayName(command?: unknown): string {
if (typeof command === "string") {
const trimmed = command.trim();
if (trimmed.length) {
return trimmed;
}
}
return "Command";
}
function buildFileChangeSummary(files: { path: string; kind: string }[]): string {
if (!files.length) {
return "File change";
}
if (files.length === 1) {
return `${files[0].kind ?? "edit"}: ${files[0].path}`;
}
return `${files.length} file changes`;
}
function coerceSandboxMode(value?: string): SandboxMode | undefined {
if (!value) return undefined;
@@ -166,6 +195,29 @@ export class CodexAgentClient implements AgentClient {
return CodexAgentSession.create(this.codex, codexConfig, handle);
}
async listPersistedAgents(options?: ListPersistedAgentsOptions): Promise<PersistedAgentDescriptor[]> {
const sessionRoot = resolveCodexSessionRoot();
if (!sessionRoot) {
return [];
}
if (!(await fileExists(sessionRoot))) {
return [];
}
const limit = options?.limit ?? 20;
const candidates = await collectRecentRolloutFiles(sessionRoot, limit * 3);
const descriptors: PersistedAgentDescriptor[] = [];
for (const candidate of candidates) {
const descriptor = await parseRolloutDescriptor(candidate.path, candidate.mtime, sessionRoot);
if (descriptor) {
descriptors.push(descriptor);
}
if (descriptors.length >= limit) {
break;
}
}
return descriptors;
}
private assertConfig(config: AgentSessionConfig): CodexAgentConfig {
if (config.provider !== "codex") {
throw new Error(`CodexAgentClient received config for provider '${config.provider}'`);
@@ -323,12 +375,16 @@ class CodexAgentSession implements AgentSession {
this.enqueueHistoryEvent({
type: "timeline",
provider: "codex",
item: {
type: "command",
command: `permission:${request.name}`,
item: createToolCallTimelineItem({
server: "permission",
tool: request.name,
status,
callId: request.id,
displayName: request.title ?? request.name,
kind: "permission",
input: request.input,
raw: { request, response },
},
}),
});
this.enqueueHistoryEvent({
@@ -579,12 +635,16 @@ class CodexAgentSession implements AgentSession {
{
type: "timeline",
provider: "codex",
item: {
type: "command",
command: `permission:${request.name}`,
item: createToolCallTimelineItem({
server: "permission",
tool: request.name,
status: "requested",
callId: request.id,
displayName: request.title ?? request.name,
kind: "permission",
input: request.input,
raw: request.raw,
},
}),
},
{ type: "permission_requested", provider: "codex", request },
];
@@ -698,23 +758,54 @@ class CodexAgentSession implements AgentSession {
case "reasoning":
return { type: "reasoning", text: item.text, raw: item };
case "command_execution":
return { type: "command", command: item.command, status: item.status, raw: item };
case "file_change":
return {
type: "file_change",
files: item.changes.map((change) => ({ path: change.path, kind: change.kind })),
return createToolCallTimelineItem({
server: "command",
tool: "shell",
status: item.status,
callId: (item as any)?.call_id,
displayName: buildCommandDisplayName(item.command),
kind: "execute",
input: { command: item.command, cwd: (item as any)?.cwd },
output: (item as any)?.output,
error: (item as any)?.error,
raw: item,
};
});
case "file_change": {
const files = item.changes.map((change) => ({ path: change.path, kind: change.kind }));
return createToolCallTimelineItem({
server: "file_change",
tool: "apply_patch",
status: "completed",
callId: (item as any)?.call_id,
displayName: buildFileChangeSummary(files),
kind: "edit",
output: { files },
raw: item,
});
}
case "mcp_tool_call":
return {
type: "mcp_tool",
return createToolCallTimelineItem({
server: item.server,
tool: item.tool,
status: item.status,
callId: (item as any)?.call_id,
displayName: `${item.server}.${item.tool}`,
kind: "tool",
input: (item as any)?.input,
output: (item as any)?.output,
raw: item,
};
});
case "web_search":
return { type: "web_search", query: item.query, raw: item };
return createToolCallTimelineItem({
server: "web_search",
tool: "web_search",
status: (item as any)?.status ?? "completed",
callId: (item as any)?.call_id,
displayName: item.query ? `Web search: ${item.query}` : "Web search",
kind: "search",
input: { query: item.query },
raw: item,
});
case "todo_list":
return { type: "todo", items: item.items, raw: item };
case "error":
@@ -894,13 +985,16 @@ function handleRolloutFunctionCall(
events.push({
type: "timeline",
provider: "codex",
item: {
type: "mcp_tool",
item: createToolCallTimelineItem({
server: "codex",
tool: name,
status: payload.status ?? "in_progress",
callId: typeof payload.call_id === "string" ? payload.call_id : undefined,
displayName: `${name}`,
kind: "tool",
input: safeJsonParse(payload.arguments),
raw: payload,
},
}),
});
}
@@ -922,12 +1016,17 @@ function finalizeRolloutFunctionCall(
events.push({
type: "timeline",
provider: "codex",
item: {
type: "command",
command: command.command,
item: createToolCallTimelineItem({
server: "command",
tool: "shell",
status,
callId: payload.call_id,
displayName: buildCommandDisplayName(command.command),
kind: "execute",
input: { command: command.command },
output: result,
raw: { payload, result },
},
}),
});
commandCalls.delete(payload.call_id);
}
@@ -939,11 +1038,15 @@ function handleRolloutCustomToolCall(payload: any, events: AgentStreamEvent[]):
events.push({
type: "timeline",
provider: "codex",
item: {
type: "file_change",
files,
item: createToolCallTimelineItem({
server: "file_change",
tool: "apply_patch",
status: "completed",
displayName: buildFileChangeSummary(files),
kind: "edit",
output: { files },
raw: payload,
},
}),
});
}
return;
@@ -953,17 +1056,147 @@ function handleRolloutCustomToolCall(payload: any, events: AgentStreamEvent[]):
events.push({
type: "timeline",
provider: "codex",
item: {
type: "mcp_tool",
item: createToolCallTimelineItem({
server: "codex",
tool: payload.name,
status: payload.status ?? "completed",
displayName: payload.name,
kind: "tool",
input: payload.input,
output: payload.output,
raw: payload,
},
}),
});
}
}
type RolloutCandidate = {
path: string;
mtime: Date;
};
async function collectRecentRolloutFiles(rootDir: string, limit: number): Promise<RolloutCandidate[]> {
const candidates: RolloutCandidate[] = [];
async function traverse(dir: string): Promise<void> {
let entries: Dirent[];
try {
entries = await fs.readdir(dir, { withFileTypes: true });
} catch {
return;
}
const files = entries.filter(
(entry) =>
entry.isFile() &&
entry.name.startsWith("rollout-") &&
(entry.name.endsWith(".json") || entry.name.endsWith(".jsonl"))
);
for (const entry of files) {
const fullPath = path.join(dir, entry.name);
try {
const stat = await fs.stat(fullPath);
candidates.push({ path: fullPath, mtime: stat.mtime });
} catch {
// ignore stat failures
}
}
const directories = entries
.filter((entry) => entry.isDirectory())
.sort((a, b) => b.name.localeCompare(a.name));
for (const dirEntry of directories) {
await traverse(path.join(dir, dirEntry.name));
}
}
await traverse(rootDir);
return candidates
.sort((a, b) => b.mtime.getTime() - a.mtime.getTime())
.slice(0, limit);
}
async function parseRolloutDescriptor(
filePath: string,
mtime: Date,
sessionRoot: string
): Promise<PersistedAgentDescriptor | null> {
let content: string;
try {
content = await fs.readFile(filePath, "utf8");
} catch {
return null;
}
let sessionId: string | null = null;
let cwd: string | null = null;
let title: string | null = null;
for (const rawLine of content.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line) continue;
let entry: any;
try {
entry = JSON.parse(line);
} catch {
continue;
}
if (!sessionId && entry?.type === "session_meta" && entry.payload) {
sessionId = typeof entry.payload.id === "string" ? entry.payload.id : null;
if (typeof entry.payload.cwd === "string" && entry.payload.cwd.length > 0) {
cwd = entry.payload.cwd;
}
} else if (!title && entry?.type === "response_item" && entry.payload?.role === "user") {
title = extractCodexUserPrompt(entry.payload);
}
if (sessionId && cwd && title) {
break;
}
}
if (!sessionId || !cwd) {
return null;
}
const persistence: AgentPersistenceHandle = {
provider: "codex",
sessionId,
nativeHandle: sessionId,
metadata: sanitizeMetadata({
provider: "codex",
cwd,
codexSessionDir: sessionRoot,
codexRolloutPath: filePath,
}),
};
return {
provider: "codex",
sessionId,
cwd,
title: (title ?? "").trim() || `Codex session ${sessionId.slice(0, 8)}`,
lastActivityAt: mtime,
persistence,
};
}
function extractCodexUserPrompt(payload: any): string | null {
const content = payload?.content;
if (!Array.isArray(content)) {
return null;
}
for (const block of content) {
if (block && typeof block === "object" && typeof block.text === "string") {
return block.text.trim();
}
}
return null;
}
function resolveCodexSessionRoot(): string | null {
if (process.env.CODEX_SESSION_DIR) {
return process.env.CODEX_SESSION_DIR;
}
const codexHome = process.env.CODEX_HOME ?? path.join(os.homedir(), ".codex");
return path.join(codexHome, "sessions");
}
function extractMessageText(content: unknown): string {
if (!Array.isArray(content)) {
return "";
+58 -23
View File
@@ -112,6 +112,12 @@ export const AgentPermissionRequestPayloadSchema: z.ZodType<AgentPermissionReque
export const AgentTimelineItemPayloadSchema: z.ZodType<AgentTimelineItem> =
z.discriminatedUnion("type", [
z.object({
type: z.literal("user_message"),
text: z.string(),
messageId: z.string().optional(),
raw: z.unknown().optional(),
}),
z.object({
type: z.literal("assistant_message"),
text: z.string(),
@@ -123,31 +129,16 @@ export const AgentTimelineItemPayloadSchema: z.ZodType<AgentTimelineItem> =
raw: z.unknown().optional(),
}),
z.object({
type: z.literal("command"),
command: z.string(),
status: z.string(),
raw: z.unknown().optional(),
}),
z.object({
type: z.literal("file_change"),
files: z.array(
z.object({
path: z.string(),
kind: z.string(),
})
),
raw: z.unknown().optional(),
}),
z.object({
type: z.literal("mcp_tool"),
type: z.literal("tool_call"),
server: z.string(),
tool: z.string(),
status: z.string(),
raw: z.unknown().optional(),
}),
z.object({
type: z.literal("web_search"),
query: z.string(),
status: z.string().optional(),
callId: z.string().optional(),
displayName: z.string().optional(),
kind: z.string().optional(),
input: z.unknown().optional(),
output: z.unknown().optional(),
error: z.unknown().optional(),
raw: z.unknown().optional(),
}),
z.object({
@@ -334,6 +325,25 @@ export const CreateAgentRequestMessageSchema = z.object({
requestId: z.string().optional(),
});
export const ResumeAgentRequestMessageSchema = z.object({
type: z.literal("resume_agent_request"),
handle: AgentPersistenceHandleSchema,
overrides: AgentSessionConfigSchema.partial().optional(),
requestId: z.string().optional(),
});
export const RefreshAgentRequestMessageSchema = z.object({
type: z.literal("refresh_agent_request"),
agentId: z.string(),
requestId: z.string().optional(),
});
export const ListPersistedAgentsRequestMessageSchema = z.object({
type: z.literal("list_persisted_agents_request"),
provider: AgentProviderSchema.optional(),
limit: z.number().int().positive().optional(),
});
export const InitializeAgentRequestMessageSchema = z.object({
type: z.literal("initialize_agent_request"),
agentId: z.string(),
@@ -400,11 +410,14 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
SendAgentMessageSchema,
SendAgentAudioSchema,
CreateAgentRequestMessageSchema,
ResumeAgentRequestMessageSchema,
RefreshAgentRequestMessageSchema,
InitializeAgentRequestMessageSchema,
SetAgentModeMessageSchema,
AgentPermissionResponseMessageSchema,
GitDiffRequestSchema,
FileExplorerRequestSchema,
ListPersistedAgentsRequestMessageSchema,
]);
export type SessionInboundMessage = z.infer<typeof SessionInboundMessageSchema>;
@@ -580,6 +593,24 @@ export const AgentPermissionResolvedMessageSchema = z.object({
}),
});
const PersistedAgentDescriptorPayloadSchema = z.object({
provider: AgentProviderSchema,
sessionId: z.string(),
cwd: z.string(),
title: z.string(),
lastActivityAt: z.string(),
persistence: AgentPersistenceHandleSchema,
});
export type PersistedAgentDescriptorPayload = z.infer<typeof PersistedAgentDescriptorPayloadSchema>;
export const ListPersistedAgentsResponseSchema = z.object({
type: z.literal("list_persisted_agents_response"),
payload: z.object({
items: z.array(PersistedAgentDescriptorPayloadSchema),
}),
});
export const GitDiffResponseSchema = z.object({
type: z.literal("git_diff_response"),
payload: z.object({
@@ -618,6 +649,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
DeleteConversationResponseMessageSchema,
AgentPermissionRequestMessageSchema,
AgentPermissionResolvedMessageSchema,
ListPersistedAgentsResponseSchema,
GitDiffResponseSchema,
FileExplorerResponseSchema,
]);
@@ -645,6 +677,7 @@ export type ListConversationsResponseMessage = z.infer<typeof ListConversationsR
export type DeleteConversationResponseMessage = z.infer<typeof DeleteConversationResponseMessageSchema>;
export type AgentPermissionRequestMessage = z.infer<typeof AgentPermissionRequestMessageSchema>;
export type AgentPermissionResolvedMessage = z.infer<typeof AgentPermissionResolvedMessageSchema>;
export type ListPersistedAgentsResponseMessage = z.infer<typeof ListPersistedAgentsResponseSchema>;
// Type exports for payload types
export type ActivityLogPayload = z.infer<typeof ActivityLogPayloadSchema>;
@@ -655,6 +688,8 @@ export type RealtimeAudioChunkMessage = z.infer<typeof RealtimeAudioChunkMessage
export type SendAgentMessage = z.infer<typeof SendAgentMessageSchema>;
export type SendAgentAudio = z.infer<typeof SendAgentAudioSchema>;
export type CreateAgentRequestMessage = z.infer<typeof CreateAgentRequestMessageSchema>;
export type ResumeAgentRequestMessage = z.infer<typeof ResumeAgentRequestMessageSchema>;
export type ListPersistedAgentsRequestMessage = z.infer<typeof ListPersistedAgentsRequestMessageSchema>;
export type InitializeAgentRequestMessage = z.infer<typeof InitializeAgentRequestMessageSchema>;
export type SetAgentModeMessage = z.infer<typeof SetAgentModeMessageSchema>;
export type AgentPermissionResponseMessage = z.infer<typeof AgentPermissionResponseMessageSchema>;
+226 -5
View File
@@ -191,6 +191,40 @@ export class Session {
return `${base}\n\n[Image attachments]\n${attachmentSummary}\n(Actual image bytes omitted; request a screenshot or file if needed.)`;
}
/**
* Interrupt the agent's active run so the next prompt starts a fresh turn.
* Returns once the manager confirms the stream has been cancelled.
*/
private async interruptAgentIfRunning(agentId: string): Promise<void> {
const snapshot = this.agentManager.getAgent(agentId);
if (!snapshot) {
throw new Error(`Agent ${agentId} not found`);
}
if (snapshot.status !== "running") {
return;
}
console.log(
`[Session ${this.clientId}] Interrupting active run for agent ${agentId}`
);
try {
const cancelled = await this.agentManager.cancelAgentRun(agentId);
if (!cancelled) {
console.warn(
`[Session ${this.clientId}] Agent ${agentId} reported running but no active run was cancelled`
);
}
} catch (error) {
console.error(
`[Session ${this.clientId}] Failed to interrupt agent ${agentId}:`,
error
);
throw error;
}
}
/**
* Start streaming an agent run and forward results via the websocket broadcast
*/
@@ -516,6 +550,14 @@ export class Session {
await this.handleCreateAgentRequest(msg);
break;
case "resume_agent_request":
await this.handleResumeAgentRequest(msg);
break;
case "refresh_agent_request":
await this.handleRefreshAgentRequest(msg);
break;
case "initialize_agent_request":
await this.handleInitializeAgentRequest(msg.agentId, msg.requestId);
break;
@@ -539,6 +581,10 @@ export class Session {
case "file_explorer_request":
await this.handleFileExplorerRequest(msg);
break;
case "list_persisted_agents_request":
await this.handleListPersistedAgentsRequest(msg);
break;
}
} catch (error: any) {
console.error(
@@ -657,7 +703,7 @@ export class Session {
private async handleSendAgentMessage(
agentId: string,
text: string,
_messageId?: string,
messageId?: string,
images?: Array<{ data: string; mimeType: string }>
): Promise<void> {
console.log(
@@ -666,7 +712,28 @@ export class Session {
}] Sending text to agent ${agentId}: ${text.substring(0, 50)}...${images && images.length > 0 ? ` with ${images.length} image attachment(s)` : ''}`
);
try {
await this.interruptAgentIfRunning(agentId);
} catch (error) {
this.handleAgentRunError(
agentId,
error,
"Failed to interrupt running agent before sending prompt"
);
return;
}
const prompt = this.buildAgentPrompt(text, images);
try {
this.agentManager.recordUserMessage(agentId, text, { messageId });
} catch (error) {
console.error(
`[Session ${this.clientId}] Failed to record user message for agent ${agentId}:`,
error
);
}
this.startAgentStream(agentId, prompt);
}
@@ -722,6 +789,26 @@ export class Session {
},
});
try {
await this.interruptAgentIfRunning(agentId);
} catch (error) {
this.handleAgentRunError(
agentId,
error,
"Failed to interrupt running agent before sending audio prompt"
);
return;
}
try {
this.agentManager.recordUserMessage(agentId, transcriptText);
} catch (recordError) {
console.error(
`[Session ${this.clientId}] Failed to record transcribed user message for agent ${agentId}:`,
recordError
);
}
// Send transcribed text to agent
this.startAgentStream(agentId, transcriptText);
console.log(
@@ -869,6 +956,103 @@ export class Session {
}
}
private async handleResumeAgentRequest(
msg: Extract<SessionInboundMessage, { type: "resume_agent_request" }>
): Promise<void> {
const { handle, overrides, requestId } = msg;
if (!handle) {
console.warn(
`[Session ${this.clientId}] Resume request missing persistence handle`
);
this.emit({
type: "activity_log",
payload: {
id: uuidv4(),
timestamp: new Date(),
type: "error",
content: "Unable to resume agent: missing persistence handle",
},
});
return;
}
console.log(
`[Session ${this.clientId}] Resuming agent ${handle.sessionId} (${handle.provider})`
);
try {
const snapshot = await this.agentManager.resumeAgent(handle, overrides);
this.setCachedTitle(snapshot.id, null);
await this.forwardAgentState(snapshot);
const timelineSize = this.emitAgentTimelineSnapshot(snapshot);
if (requestId) {
this.emit({
type: "status",
payload: {
status: "agent_resumed",
agentId: snapshot.id,
requestId,
timelineSize,
},
});
}
} catch (error: any) {
console.error(
`[Session ${this.clientId}] Failed to resume agent:`,
error
);
this.emit({
type: "activity_log",
payload: {
id: uuidv4(),
timestamp: new Date(),
type: "error",
content: `Failed to resume agent: ${error.message}`,
},
});
}
}
private async handleRefreshAgentRequest(
msg: Extract<SessionInboundMessage, { type: "refresh_agent_request" }>
): Promise<void> {
const { agentId, requestId } = msg;
console.log(
`[Session ${this.clientId}] Refreshing agent ${agentId} from persistence`
);
try {
const snapshot = await this.agentManager.refreshAgentFromPersistence(
agentId
);
await this.forwardAgentState(snapshot);
const timelineSize = this.emitAgentTimelineSnapshot(snapshot);
if (requestId) {
this.emit({
type: "status",
payload: {
status: "agent_refreshed",
agentId,
requestId,
timelineSize,
},
});
}
} catch (error: any) {
console.error(
`[Session ${this.clientId}] Failed to refresh agent ${agentId}:`,
error
);
this.emit({
type: "activity_log",
payload: {
id: uuidv4(),
timestamp: new Date(),
type: "error",
content: `Failed to refresh agent: ${error.message}`,
},
});
}
}
private async buildAgentSessionConfig(
config: AgentSessionConfig,
worktreeName?: string
@@ -893,6 +1077,47 @@ export class Session {
};
}
private async handleListPersistedAgentsRequest(
msg: Extract<SessionInboundMessage, { type: "list_persisted_agents_request" }>
): Promise<void> {
const { provider, limit } = msg;
try {
const entries = await this.agentManager.listPersistedAgents({
provider,
limit,
});
this.emit({
type: "list_persisted_agents_response",
payload: {
items: entries.map((entry) => ({
provider: entry.provider,
sessionId: entry.sessionId,
cwd: entry.cwd,
title: entry.title ?? `Session ${entry.sessionId.slice(0, 8)}`,
lastActivityAt: entry.lastActivityAt.toISOString(),
persistence: entry.persistence,
})),
},
});
} catch (error) {
console.error(
`[Session ${this.clientId}] Failed to list persisted agents:`,
error
);
this.emit({
type: "activity_log",
payload: {
id: uuidv4(),
timestamp: new Date(),
type: "error",
content: `Failed to list saved agents: ${
(error as Error)?.message ?? error
}`,
},
});
}
}
/**
* Handle set agent mode request
*/
@@ -1154,10 +1379,6 @@ export class Session {
private emitAgentTimelineSnapshot(agent: AgentSnapshot): number {
const timeline = this.agentManager.getTimeline(agent.id);
if (timeline.length === 0) {
return 0;
}
const events = timeline.map((item) => ({
event: serializeAgentStreamEvent({
type: "timeline",