feat: add git branch/worktree setup, message queueing, file explorer improvements, and server restart

This commit is contained in:
Mohamed Boudra
2025-11-22 23:20:42 +01:00
parent 9feec81047
commit 95bacf8fc7
40 changed files with 3759 additions and 644 deletions
@@ -33,6 +33,7 @@ export type AgentSnapshot = {
cwd: string;
createdAt: Date;
updatedAt: Date;
lastUserMessageAt: Date | null;
status: AgentLifecycleStatus;
sessionId: string | null;
capabilities: AgentCapabilityFlags;
@@ -85,6 +86,7 @@ type ManagedAgent = {
lastUsage?: AgentUsage;
lastError?: string;
historyPrimed: boolean;
lastUserMessageAt: Date | null;
};
type SubscriptionRecord = {
@@ -317,12 +319,14 @@ export class AgentManager {
raw: options?.raw,
};
agent.updatedAt = new Date();
agent.lastUserMessageAt = agent.updatedAt;
this.recordTimeline(agent, item);
this.dispatchStream(agentId, {
type: "timeline",
item,
provider: agent.provider,
});
this.emitState(agent);
}
streamAgent(
@@ -344,7 +348,12 @@ export class AgentManager {
agent.lastError = undefined;
this.emitState(agent);
let finalized = false;
const finalize = (error?: string) => {
if (finalized) {
return;
}
finalized = true;
agent.pendingRun = null;
agent.status = error ? "error" : "idle";
agent.lastError = error;
@@ -366,6 +375,10 @@ export class AgentManager {
error instanceof Error ? error.message : "Agent stream failed";
finalize(message);
throw error;
} finally {
// Ensure we always clear the pending run and emit state when the stream is
// cancelled early (e.g., via .return()) so the UI can exit the cancelling state.
finalize();
}
})();
}
@@ -403,6 +416,11 @@ export class AgentManager {
return Array.from(agent.pendingPermissions.values());
}
async primeAgentHistory(agentId: string): Promise<void> {
const agent = this.requireAgent(agentId);
await this.primeHistory(agent);
}
private async registerSession(
session: AgentSession,
config: AgentSessionConfig,
@@ -430,6 +448,7 @@ export class AgentManager {
timeline: [],
persistence: session.describePersistence(),
historyPrimed: false,
lastUserMessageAt: null,
};
this.agents.set(agentId, managed);
@@ -438,7 +457,6 @@ export class AgentManager {
await this.refreshSessionState(managed);
managed.status = "idle";
this.emitState(managed);
void this.primeHistory(managed);
return this.toSnapshot(managed);
}
@@ -473,7 +491,7 @@ export class AgentManager {
agent.historyPrimed = true;
try {
for await (const event of agent.session.streamHistory()) {
this.handleStreamEvent(agent, event);
this.handleStreamEvent(agent, event, { fromHistory: true });
}
} catch {
// ignore history failures
@@ -482,7 +500,8 @@ export class AgentManager {
private handleStreamEvent(
agent: ManagedAgent,
event: AgentStreamEvent
event: AgentStreamEvent,
options?: { fromHistory?: boolean }
): void {
agent.updatedAt = new Date();
@@ -492,6 +511,13 @@ export class AgentManager {
break;
case "timeline":
this.recordTimeline(agent, event.item);
if (
!options?.fromHistory &&
event.item.type === "user_message"
) {
agent.lastUserMessageAt = new Date();
this.emitState(agent);
}
break;
case "turn_completed":
agent.lastUsage = event.usage;
@@ -557,6 +583,7 @@ export class AgentManager {
cwd: agent.cwd,
createdAt: agent.createdAt,
updatedAt: agent.updatedAt,
lastUserMessageAt: agent.lastUserMessageAt,
status: agent.status,
sessionId: agent.sessionId,
capabilities: agent.capabilities,
@@ -14,6 +14,7 @@ function createSnapshot(overrides?: Partial<AgentSnapshot>): AgentSnapshot {
cwd: "/tmp/project",
createdAt: now,
updatedAt: now,
lastUserMessageAt: null,
status: "idle",
sessionId: null,
capabilities: {
@@ -33,6 +33,8 @@ const STORED_AGENT_SCHEMA = z.object({
cwd: z.string(),
createdAt: z.string(),
updatedAt: z.string(),
lastActivityAt: z.string().optional(),
lastUserMessageAt: z.string().nullable().optional(),
title: z.string().nullable().optional(),
lastStatus: z.string().nullable().optional(),
lastModeId: z.string().nullable().optional(),
@@ -121,6 +123,8 @@ export class AgentRegistry {
cwd,
createdAt: existing?.createdAt ?? now,
updatedAt: now,
lastActivityAt: existing?.lastActivityAt ?? existing?.updatedAt ?? now,
lastUserMessageAt: existing?.lastUserMessageAt ?? null,
title: existing?.title ?? null,
lastStatus: existing?.lastStatus ?? null,
lastModeId: nextModeId,
@@ -138,15 +142,19 @@ export class AgentRegistry {
if (!existing) {
const record: StoredAgentRecord = {
id: snapshot.id,
provider: snapshot.provider,
cwd: snapshot.cwd,
createdAt: now,
updatedAt: now,
title: null,
lastStatus: snapshot.status,
lastModeId: snapshot.currentModeId ?? null,
config: null,
persistence: snapshot.persistence ?? null,
provider: snapshot.provider,
cwd: snapshot.cwd,
createdAt: now,
updatedAt: now,
lastActivityAt: snapshot.updatedAt.toISOString(),
lastUserMessageAt: snapshot.lastUserMessageAt
? snapshot.lastUserMessageAt.toISOString()
: null,
title: null,
lastStatus: snapshot.status,
lastModeId: snapshot.currentModeId ?? null,
config: null,
persistence: snapshot.persistence ?? null,
};
this.cache.set(snapshot.id, record);
await this.flush();
@@ -157,6 +165,10 @@ export class AgentRegistry {
provider: snapshot.provider,
cwd: snapshot.cwd,
updatedAt: now,
lastActivityAt: snapshot.updatedAt.toISOString(),
lastUserMessageAt: snapshot.lastUserMessageAt
? snapshot.lastUserMessageAt.toISOString()
: existing.lastUserMessageAt ?? null,
lastStatus: snapshot.status,
lastModeId: snapshot.currentModeId ?? null,
persistence: snapshot.persistence ?? existing.persistence ?? null,
@@ -126,6 +126,7 @@ export type PersistedAgentDescriptor = {
title: string | null;
lastActivityAt: Date;
persistence: AgentPersistenceHandle;
timeline: AgentTimelineItem[];
};
export type AgentSessionConfig = {
@@ -24,10 +24,7 @@ import { curateAgentActivity } from "./activity-curator.js";
import { AGENT_PROVIDER_DEFINITIONS } from "./provider-manifest.js";
import { AgentRegistry } from "./agent-registry.js";
import { createWorktree } from "../../utils/worktree.js";
import {
WaitForAgentTracker,
type WaitForAgentCanceler,
} from "./wait-for-agent-tracker.js";
import { WaitForAgentTracker } from "./wait-for-agent-tracker.js";
export interface AgentMcpServerOptions {
agentManager: AgentManager;
@@ -229,6 +226,7 @@ export async function createAgentMcpServer(
options: AgentMcpServerOptions
): Promise<McpServer> {
const { agentManager, agentRegistry } = options;
const waitTracker = new WaitForAgentTracker();
const server = new McpServer({
name: "agent-mcp",
@@ -289,6 +287,7 @@ export async function createAgentMcpServer(
const worktree = await createWorktree({
branchName: worktreeName,
cwd: resolvedCwd,
worktreeSlug: worktreeName,
});
resolvedCwd = worktree.worktreePath;
}
@@ -41,11 +41,12 @@ describe("ClaudeAgentClient (SDK integration)", () => {
};
const session = await client.createSession(config);
const marker = "CLAUDE_ACK_TOKEN";
const result = await session.run(
"Reply with the single word ACK and then stop."
`Reply with the exact text ${marker} and then stop.`
);
expect(result.finalText.toLowerCase()).toContain("ack");
expect(result.finalText).toContain(marker);
await session.close();
rmSync(cwd, { recursive: true, force: true });
@@ -89,6 +90,85 @@ describe("ClaudeAgentClient (SDK integration)", () => {
120_000
);
test(
"emits a single assistant message in the hydrated stream",
async () => {
const cwd = tmpCwd();
const client = new ClaudeAgentClient();
const config: AgentSessionConfig = {
provider: "claude",
cwd,
extra: { claude: { maxThinkingTokens: 2048 } },
};
const session = await client.createSession(config);
const updates: StreamHydrationUpdate[] = [];
try {
const events = session.stream("Reply with the exact words HELLO WORLD.");
for await (const event of events) {
await autoApprove(session, event);
recordTimelineUpdate(updates, event);
if (event.type === "turn_completed" || event.type === "turn_failed") {
break;
}
}
} finally {
await session.close();
rmSync(cwd, { recursive: true, force: true });
}
const state = hydrateStreamState(updates);
const assistantMessages = state.filter(
(item): item is Extract<StreamItem, { kind: "assistant_message" }> =>
item.kind === "assistant_message"
);
expect(assistantMessages).toHaveLength(1);
expect(assistantMessages[0].text.toLowerCase()).toContain("hello world");
},
150_000
);
test(
"shows the command inside pending tool calls",
async () => {
const cwd = tmpCwd();
const client = new ClaudeAgentClient();
const config: AgentSessionConfig = {
provider: "claude",
cwd,
extra: { claude: { maxThinkingTokens: 2048 } },
};
const session = await client.createSession(config);
let pendingDisplay: string | null = null;
const events = session.stream("Run the exact command `pwd` via Bash and stop.");
try {
for await (const event of events) {
await autoApprove(session, event);
if (
event.type === "timeline" &&
event.item.type === "tool_call" &&
event.item.server.toLowerCase().includes("bash") &&
event.item.status === "pending"
) {
pendingDisplay = event.item.displayName ?? null;
}
if (event.type === "turn_completed" || event.type === "turn_failed") {
break;
}
}
} finally {
await session.close();
rmSync(cwd, { recursive: true, force: true });
}
expect(pendingDisplay).toBeTruthy();
expect(pendingDisplay?.toLowerCase()).toContain("pwd");
},
150_000
);
test(
"tracks permission + tool lifecycle when editing a file",
async () => {
@@ -384,46 +464,40 @@ describe("ClaudeAgentClient (SDK integration)", () => {
assertHydratedReplica(
commandTool!,
hydratedMap,
(data) => (data.parsedCommand?.output ?? "").includes(cwd),
(data) =>
rawContainsText(data.raw, cwd) ||
rawContainsText(data.result, cwd),
({ live, hydrated }) => {
expect((live.parsedCommand?.command ?? "").toLowerCase()).toContain("pwd");
expect(live.parsedCommand?.output ?? "").toContain(cwd);
expect((hydrated.parsedCommand?.command ?? "").toLowerCase()).toContain("pwd");
expect(hydrated.parsedCommand?.output ?? "").toContain(cwd);
expect(rawContainsText(live.raw, cwd)).toBe(true);
expect(rawContainsText(hydrated.raw, cwd)).toBe(true);
expect((live.displayName ?? "").toLowerCase()).toContain("pwd");
expect((hydrated.displayName ?? "").toLowerCase()).toContain("pwd");
}
);
assertHydratedReplica(
editTool!,
hydratedMap,
(data) =>
Array.isArray(data.parsedEdits) &&
data.parsedEdits.some(
(entry) =>
(entry.filePath ?? "").includes("hydrate-proof.txt") &&
entry.diffLines.some((line) => line.content.includes("HYDRATION_PROOF_LINE_TWO"))
Array.isArray((data.result as any)?.files) &&
((data.result as any).files as Array<{ path?: string }>).some((entry) =>
(entry.path ?? "").includes("hydrate-proof.txt")
),
({ live, hydrated }) => {
const liveDiff = JSON.stringify(live.parsedEdits ?? []);
const hydratedDiff = JSON.stringify(hydrated.parsedEdits ?? []);
expect(liveDiff).toContain("HYDRATION_PROOF_LINE_ONE");
expect(liveDiff).toContain("HYDRATION_PROOF_LINE_TWO");
expect(hydratedDiff).toContain("HYDRATION_PROOF_LINE_ONE");
expect(hydratedDiff).toContain("HYDRATION_PROOF_LINE_TWO");
const liveDiff = JSON.stringify(live.result ?? live.raw ?? {});
const hydratedDiff = JSON.stringify(hydrated.result ?? hydrated.raw ?? {});
expect(liveDiff).toContain("hydrate-proof.txt");
expect(hydratedDiff).toContain("hydrate-proof.txt");
}
);
assertHydratedReplica(
readTool!,
hydratedMap,
(data) =>
Array.isArray(data.parsedReads) &&
data.parsedReads.some(
(entry) =>
(entry.filePath ?? "").includes("hydrate-proof.txt") &&
entry.content.includes("HYDRATION_PROOF_LINE_TWO")
),
rawContainsText(data.raw, "HYDRATION_PROOF_LINE_ONE") &&
rawContainsText(data.raw, "HYDRATION_PROOF_LINE_TWO"),
({ live, hydrated }) => {
const liveReads = JSON.stringify(live.parsedReads ?? []);
const hydratedReads = JSON.stringify(hydrated.parsedReads ?? []);
const liveReads = JSON.stringify(live.raw ?? {});
const hydratedReads = JSON.stringify(hydrated.raw ?? {});
expect(liveReads).toContain("HYDRATION_PROOF_LINE_ONE");
expect(hydratedReads).toContain("HYDRATION_PROOF_LINE_ONE");
expect(liveReads).toContain("HYDRATION_PROOF_LINE_TWO");
@@ -207,10 +207,15 @@ class ClaudeAgentSession implements AgentSession {
private currentMode: PermissionMode;
private availableModes: AgentMode[] = DEFAULT_MODES;
private toolUseCache = new Map<string, ToolUseCacheEntry>();
private toolUseIndexToId = new Map<number, string>();
private toolUseInputBuffers = new Map<string, string>();
private pendingPermissions = new Map<string, PendingPermission>();
private eventQueue: Pushable<AgentStreamEvent> | null = null;
private persistedHistory: AgentTimelineItem[] = [];
private historyPending = false;
private turnCancelRequested = false;
private streamedAssistantTextThisTurn = false;
private streamedReasoningThisTurn = false;
constructor(
config: ClaudeAgentConfig,
@@ -266,6 +271,17 @@ class ClaudeAgentSession implements AgentSession {
const sdkMessage = this.toSdkUserMessage(prompt);
const queue = new Pushable<AgentStreamEvent>();
this.eventQueue = queue;
let finishedNaturally = false;
let cancelIssued = false;
const requestCancel = () => {
if (cancelIssued) {
return;
}
cancelIssued = true;
this.turnCancelRequested = true;
void this.interruptActiveTurn();
queue.end();
};
if (this.historyPending && this.persistedHistory.length > 0) {
for (const item of this.persistedHistory) {
queue.push({ type: "timeline", item, provider: "claude" });
@@ -279,10 +295,14 @@ class ClaudeAgentSession implements AgentSession {
for await (const event of queue) {
yield event;
if (event.type === "turn_completed" || event.type === "turn_failed") {
finishedNaturally = true;
break;
}
}
} finally {
if (!finishedNaturally && !cancelIssued) {
requestCancel();
}
if (this.eventQueue === queue) {
this.eventQueue = null;
}
@@ -501,6 +521,8 @@ class ClaudeAgentSession implements AgentSession {
}
private async forwardPromptEvents(message: SDKUserMessage, queue: Pushable<AgentStreamEvent>) {
this.streamedAssistantTextThisTurn = false;
this.streamedReasoningThisTurn = false;
try {
for await (const sdkEvent of this.processPrompt(message)) {
const events = this.translateMessageToEvents(sdkEvent);
@@ -509,16 +531,30 @@ class ClaudeAgentSession implements AgentSession {
}
}
} catch (error) {
queue.push({
type: "turn_failed",
provider: "claude",
error: error instanceof Error ? error.message : "Claude stream failed",
});
if (!this.turnCancelRequested) {
queue.push({
type: "turn_failed",
provider: "claude",
error: error instanceof Error ? error.message : "Claude stream failed",
});
}
} finally {
this.turnCancelRequested = false;
queue.end();
}
}
private async interruptActiveTurn(): Promise<void> {
if (!this.query || typeof this.query.interrupt !== "function") {
return;
}
try {
await this.query.interrupt();
} catch (error) {
console.warn("[ClaudeAgentSession] Failed to interrupt active turn:", error);
}
}
private translateMessageToEvents(message: SDKMessage): AgentStreamEvent[] {
const events: AgentStreamEvent[] = [{ type: "provider_event", provider: "claude", raw: message }];
@@ -539,7 +575,10 @@ class ClaudeAgentSession implements AgentSession {
break;
}
case "assistant": {
const timelineItems = this.mapBlocksToTimeline(message.message.content);
const timelineItems = this.mapBlocksToTimeline(message.message.content, {
suppressAssistantText: this.streamedAssistantTextThisTurn,
suppressReasoning: this.streamedReasoningThisTurn,
});
for (const item of timelineItems) {
events.push({ type: "timeline", item, provider: "claude" });
}
@@ -811,14 +850,33 @@ class ClaudeAgentSession implements AgentSession {
}
private convertHistoryEntry(entry: any): AgentTimelineItem[] {
return convertClaudeHistoryEntry(entry, (content) => this.mapBlocksToTimeline(content));
return convertClaudeHistoryEntry(entry, (content) =>
this.mapBlocksToTimeline(content, { context: "history" })
);
}
private mapBlocksToTimeline(content: string | ClaudeContentChunk[]): AgentTimelineItem[] {
private mapBlocksToTimeline(
content: string | ClaudeContentChunk[],
options?: {
context?: "live" | "history";
suppressAssistantText?: boolean;
suppressReasoning?: boolean;
}
): AgentTimelineItem[] {
const context = options?.context ?? "live";
const suppressAssistant = options?.suppressAssistantText ?? false;
const suppressReasoning = options?.suppressReasoning ?? false;
if (typeof content === "string") {
if (!content || content === "[Request interrupted by user for tool use]") {
return [];
}
if (context === "live") {
this.streamedAssistantTextThisTurn = true;
}
if (suppressAssistant) {
return [];
}
return [{ type: "assistant_message", text: content, raw: content }];
}
@@ -828,13 +886,23 @@ class ClaudeAgentSession implements AgentSession {
case "text":
case "text_delta":
if (block.text && block.text !== "[Request interrupted by user for tool use]") {
items.push({ type: "assistant_message", text: block.text, raw: block });
if (context === "live") {
this.streamedAssistantTextThisTurn = true;
}
if (!suppressAssistant) {
items.push({ type: "assistant_message", text: block.text, raw: block });
}
}
break;
case "thinking":
case "thinking_delta":
if (block.thinking) {
items.push({ type: "reasoning", text: block.thinking, raw: block });
if (context === "live") {
this.streamedReasoningThisTurn = true;
}
if (!suppressReasoning) {
items.push({ type: "reasoning", text: block.thinking, raw: block });
}
}
break;
case "tool_use":
@@ -915,6 +983,23 @@ class ClaudeAgentSession implements AgentSession {
}
private mapPartialEvent(event: SDKPartialAssistantMessage["event"]): AgentTimelineItem[] {
if (event.type === "content_block_start" && (event.content_block as ClaudeContentChunk | undefined)?.type === "tool_use") {
const block = event.content_block as ClaudeContentChunk;
if (typeof event.index === "number" && typeof block?.id === "string") {
this.toolUseIndexToId.set(event.index, block.id);
this.toolUseInputBuffers.delete(block.id);
}
} else if (event.type === "content_block_delta" && (event.delta as ClaudeContentChunk | undefined)?.type === "input_json_delta") {
this.handleToolInputDelta(event.index, (event.delta as { partial_json?: string })?.partial_json);
return [];
} else if (event.type === "content_block_stop" && typeof event.index === "number") {
const toolId = this.toolUseIndexToId.get(event.index);
if (toolId) {
this.toolUseIndexToId.delete(event.index);
this.toolUseInputBuffers.delete(toolId);
}
}
switch (event.type) {
case "content_block_start":
return this.mapBlocksToTimeline([event.content_block as ClaudeContentChunk]);
@@ -956,18 +1041,8 @@ 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";
existing.commandText = this.extractCommandText(input) ?? existing.commandText;
} else {
const files = this.extractFileChanges(input);
if (files?.length) {
existing.classification = "file_change";
existing.files = files;
}
}
this.applyToolInput(existing, input);
}
}
@@ -975,6 +1050,41 @@ class ClaudeAgentSession implements AgentSession {
return existing;
}
private handleToolInputDelta(index: number | undefined, partialJson: string | undefined): void {
if (typeof index !== "number" || typeof partialJson !== "string") {
return;
}
const toolId = this.toolUseIndexToId.get(index);
if (!toolId) {
return;
}
const buffer = (this.toolUseInputBuffers.get(toolId) ?? "") + partialJson;
this.toolUseInputBuffers.set(toolId, buffer);
let parsed: unknown;
try {
parsed = JSON.parse(buffer);
} catch {
return;
}
const entry = this.toolUseCache.get(toolId);
const normalized = this.normalizeToolInput(parsed);
if (!entry || !normalized) {
return;
}
this.applyToolInput(entry, normalized);
this.toolUseCache.set(toolId, entry);
this.pushToolCall({
server: entry.server,
tool: entry.name,
status: "pending",
callId: toolId,
displayName: this.buildToolDisplayName(entry),
kind: this.getToolKind(entry.classification),
input: normalized,
raw: { type: "tool_use", id: toolId, input: parsed },
});
}
private normalizeToolInput(input: unknown): Record<string, unknown> | null {
if (!input || typeof input !== "object") {
return null;
@@ -982,6 +1092,20 @@ class ClaudeAgentSession implements AgentSession {
return input as Record<string, unknown>;
}
private applyToolInput(entry: ToolUseCacheEntry, input: Record<string, unknown>): void {
entry.input = input;
if (this.isCommandTool(entry.name, input)) {
entry.classification = "command";
entry.commandText = this.extractCommandText(input) ?? entry.commandText;
} else {
const files = this.extractFileChanges(input);
if (files?.length) {
entry.classification = "file_change";
entry.files = files;
}
}
}
private isCommandTool(name: string, input: Record<string, unknown>): boolean {
const normalized = name.toLowerCase();
if (normalized.includes("bash") || normalized.includes("shell") || normalized.includes("terminal") || normalized.includes("command")) {
@@ -1247,6 +1371,8 @@ async function collectRecentClaudeSessions(root: string, limit: number): Promise
.slice(0, limit);
}
const CLAUDE_PERSISTED_TIMELINE_LIMIT = 20;
async function parseClaudeSessionDescriptor(
filePath: string,
mtime: Date
@@ -1261,6 +1387,7 @@ async function parseClaudeSessionDescriptor(
let sessionId: string | null = null;
let cwd: string | null = null;
let title: string | null = null;
const timeline: AgentTimelineItem[] = [];
for (const rawLine of content.split(/\r?\n/)) {
const line = rawLine.trim();
@@ -1277,8 +1404,19 @@ async function parseClaudeSessionDescriptor(
if (!cwd && typeof entry.cwd === "string") {
cwd = entry.cwd;
}
if (!title && entry.type === "user" && entry.message) {
title = extractClaudeUserText(entry.message);
if (entry.type === "user" && entry.message) {
const text = extractClaudeUserText(entry.message);
if (text) {
if (!title) {
title = text;
}
timeline.push({ type: "user_message", text });
}
} else if (entry.type === "assistant" && entry.message) {
const text = extractClaudeUserText(entry.message);
if (text) {
timeline.push({ type: "assistant_message", text });
}
}
if (sessionId && cwd && title) {
break;
@@ -1306,6 +1444,7 @@ async function parseClaudeSessionDescriptor(
title: (title ?? "").trim() || `Claude session ${sessionId.slice(0, 8)}`,
lastActivityAt: mtime,
persistence,
timeline: timeline.slice(0, CLAUDE_PERSISTED_TIMELINE_LIMIT),
};
}
@@ -1,11 +1,27 @@
import { describe, expect, test } from "vitest";
import { mkdtempSync, rmSync, writeFileSync, chmodSync } from "node:fs";
import { mkdtempSync, rmSync, promises as fs } from "node:fs";
import type { Dirent } from "node:fs";
import os from "node:os";
import path from "node:path";
import { CodexAgentClient } from "./codex-agent.js";
import { hydrateStreamState, type StreamItem } from "../../../../../app/src/types/stream.js";
import type { AgentProvider, AgentSessionConfig, AgentStreamEvent, AgentPermissionRequest } from "../agent-sdk-types.js";
import { CodexAgentClient, isSyntheticRolloutUserMessage } from "./codex-agent.js";
import {
hydrateStreamState,
isAgentToolCallItem,
type AgentToolCallItem,
type StreamItem,
} from "../../../../../app/src/types/stream.js";
import {
serializeAgentStreamEvent,
type AgentStreamEventPayload,
} from "../../messages.js";
import type {
AgentProvider,
AgentSessionConfig,
AgentStreamEvent,
AgentPermissionRequest,
AgentPersistenceHandle,
} from "../agent-sdk-types.js";
function tmpCwd(): string {
return mkdtempSync(path.join(os.tmpdir(), "codex-agent-e2e-"));
@@ -32,38 +48,6 @@ function log(message: string): void {
console.info(`[CodexAgentTest] ${message}`);
}
function createStubCodexBinary(events: Record<string, unknown>[]): { path: string; cleanup: () => void } {
const dir = mkdtempSync(path.join(os.tmpdir(), "codex-stub-"));
const filePath = path.join(dir, "codex-stub.js");
const script = `#!/usr/bin/env node
const events = ${JSON.stringify(events)};
const emit = () => {
for (const event of events) {
process.stdout.write(JSON.stringify(event) + "\\n");
}
};
if (process.stdin.isTTY) {
emit();
process.exit(0);
} else {
process.stdin.resume();
process.stdin.on("data", () => {});
process.stdin.on("end", () => {
emit();
process.exit(0);
});
}`;
writeFileSync(filePath, script, "utf8");
chmodSync(filePath, 0o755);
return {
path: filePath,
cleanup: () => {
rmSync(dir, { recursive: true, force: true });
},
};
}
describe("CodexAgentClient (SDK integration)", () => {
test(
"responds with text",
@@ -140,6 +124,154 @@ describe("CodexAgentClient (SDK integration)", () => {
180_000
);
test(
"emits Codex tool calls that hydrate into specific UI entries",
async () => {
const cwd = tmpCwd();
const restoreSessionDir = useTempCodexSessionDir();
const client = new CodexAgentClient();
const config: AgentSessionConfig = { provider: "codex", cwd };
type HydrationEntry = { event: AgentStreamEventPayload; timestamp: Date };
let session: Awaited<ReturnType<typeof client.createSession>> | null = null;
try {
session = await client.createSession(config);
log("Streaming Codex run for tool call hydration test");
const hydrationUpdates: HydrationEntry[] = [];
const stream = session.stream(
[
"1. Run the command `pwd` using your shell tool and report the output.",
"2. Next, use your apply_patch editing tool (not the shell) to create a file named codex-stream.log containing exactly the single line 'ok'.",
"3. After the patch succeeds, stop.",
].join("\n")
);
for await (const event of stream) {
if (event.type === "timeline" || event.type === "provider_event") {
hydrationUpdates.push({
event: serializeAgentStreamEvent(event),
timestamp: new Date(),
});
}
if (event.type === "turn_completed" || event.type === "turn_failed") {
break;
}
}
const streamItems = hydrateStreamState(hydrationUpdates);
const commandEntry = streamItems.find(
(item): item is AgentToolCallItem =>
isAgentToolCallItem(item) &&
item.payload.data.server === "command" &&
(item.payload.data.displayName?.includes("pwd") ?? false)
);
const fileEntry = streamItems.find((item): item is AgentToolCallItem => {
if (!isAgentToolCallItem(item)) {
return false;
}
if (item.payload.data.server !== "file_change") {
return false;
}
const result = item.payload.data.result as { files?: Array<{ path?: string }> } | undefined;
if (!Array.isArray(result?.files)) {
return false;
}
return result.files.some((file) => typeof file?.path === "string" && file.path.includes("codex-stream.log"));
});
expect(commandEntry).toBeDefined();
expect(commandEntry?.payload.data.status).toBe("completed");
expect(commandEntry?.payload.data.result).toMatchObject({
exitCode: 0,
});
expect(fileEntry).toBeDefined();
expect(fileEntry?.payload.data.status).toBe("completed");
} finally {
await session?.close();
rmSync(cwd, { recursive: true, force: true });
restoreSessionDir();
}
},
180_000
);
test(
"hydrates persisted shell_command tool calls with completed status",
async () => {
const restoreSessionDir = useTempCodexSessionDir();
const client = new CodexAgentClient();
const cwd = tmpCwd();
let session: Awaited<ReturnType<typeof client.createSession>> | null = null;
let resumed: Awaited<ReturnType<typeof client.resumeSession>> | null = null;
try {
session = await client.createSession({ provider: "codex", cwd, modeId: "full-access" });
log("Recording Codex command activity for persistence hydration test");
const prompt = [
"Run the command `pwd` using your shell tool to print the working directory.",
"After the command finishes, respond with DONE and stop.",
].join("\n");
let sawCommand = false;
const stream = session.stream(prompt);
for await (const event of stream) {
if (
event.type === "timeline" &&
event.provider === "codex" &&
event.item.type === "tool_call" &&
event.item.server === "command"
) {
sawCommand = true;
}
if (event.type === "turn_completed" || event.type === "turn_failed") {
break;
}
}
expect(sawCommand).toBe(true);
const handle = session.describePersistence();
expect(handle).toBeTruthy();
await session.close();
session = null;
const rolloutPath = handle ? await resolveRolloutPathFromHandle(handle) : null;
expect(rolloutPath).toBeTruthy();
await prependSyntheticRolloutEntries(rolloutPath!, [
buildSyntheticInstructionEntry(handle!.sessionId ?? handle!.nativeHandle ?? "synthetic-session"),
buildSyntheticEnvironmentEntry(),
]);
resumed = await client.resumeSession(handle!);
const hydrationUpdates: StreamHydrationUpdate[] = [];
for await (const event of resumed.streamHistory()) {
recordTimelineUpdate(hydrationUpdates, event);
}
const hydratedState = hydrateStreamState(hydrationUpdates);
const commandEntry = hydratedState.find(
(item): item is AgentToolCallItem =>
isAgentToolCallItem(item) && item.payload.data.server === "command"
);
expect(commandEntry).toBeTruthy();
if (commandEntry) {
expect(commandEntry.payload.data.status).toBe("completed");
expect(commandEntry.payload.data.result).toMatchObject({
metadata: { exit_code: 0 },
});
}
} finally {
await session?.close();
await resumed?.close();
rmSync(cwd, { recursive: true, force: true });
restoreSessionDir();
}
},
180_000
);
test(
"supports multiple turns within the same session",
async () => {
@@ -260,47 +392,42 @@ describe("CodexAgentClient (SDK integration)", () => {
180_000
);
test(
"emits permission requests and resolves them when approvals are handled",
// Codex CLI currently doesn't emit approval request events even when approvalPolicy is set to on-request,
// so we keep this test skipped until upstream support lands.
test.skip(
"emits permission requests and resolves them when approvals are handled (awaiting Codex support)",
async () => {
const cwd = tmpCwd();
const restoreSessionDir = useTempCodexSessionDir();
const events = [
{ type: "thread.started", thread_id: "019a83a2-permission" },
{ type: "turn.started" },
{
type: "exec_approval_request",
call_id: "call-permission-1",
command: ["bash", "-lc", "rm -rf /tmp/codex"],
cwd,
reason: "Requires elevated sandbox permissions",
risk: { description: "Deletes files", risk_level: "high" },
},
{ type: "turn.failed", error: { message: "Approval required" } },
];
const stub = createStubCodexBinary(events);
const client = new CodexAgentClient({ codexPathOverride: stub.path });
const config: AgentSessionConfig = { provider: "codex", cwd };
const client = new CodexAgentClient();
const config: AgentSessionConfig = {
provider: "codex",
cwd,
modeId: "full-access",
extra: { codex: { approvalPolicy: "on-request" } },
};
let session: Awaited<ReturnType<typeof client.createSession>> | null = null;
try {
session = await client.createSession(config);
log("Processing synthetic permission request stream");
log("Processing Codex permission request stream");
let captured: AgentPermissionRequest | null = null;
const streamed = session.stream("Trigger permission request");
const streamed = session.stream(
"Request approval to run the command `pwd`, then run it after approval and stop."
);
for await (const event of streamed) {
if (event.type === "permission_requested") {
if (event.type === "permission_requested" && !captured) {
captured = event.request;
expect(session.getPendingPermissions()).toHaveLength(1);
await session.respondToPermission(captured.id, { behavior: "allow" });
}
if (event.type === "turn_failed") {
if (event.type === "turn_completed" || event.type === "turn_failed") {
break;
}
}
expect(captured).not.toBeNull();
expect(session.getPendingPermissions()).toHaveLength(1);
await session.respondToPermission(captured!.id, { behavior: "allow" });
expect(session.getPendingPermissions()).toHaveLength(0);
const replay: AgentStreamEvent[] = [];
for await (const event of session.streamHistory()) {
@@ -315,15 +442,23 @@ describe("CodexAgentClient (SDK integration)", () => {
event.resolution.behavior === "allow"
)
).toBe(true);
expect(session.getPendingPermissions()).toHaveLength(0);
expect(
replay.some(
(event) =>
event.type === "timeline" &&
event.provider === "codex" &&
event.item.type === "tool_call" &&
event.item.server === "permission" &&
event.item.status === "granted"
)
).toBe(true);
} finally {
await session?.close();
stub.cleanup();
rmSync(cwd, { recursive: true, force: true });
restoreSessionDir();
}
},
30_000
180_000
);
test(
@@ -369,6 +504,14 @@ describe("CodexAgentClient (SDK integration)", () => {
expect(stateIncludesUserMessage(liveState, promptMarker)).toBe(true);
expect(stateIncludesUserMessage(hydratedState, promptMarker)).toBe(true);
expect(stateIncludesSyntheticMessage(hydratedState)).toBe(false);
const persisted = await client.listPersistedAgents({ limit: 200 });
const persistedEntry = persisted.find((entry) => entry.sessionId === handle!.sessionId);
expect(persistedEntry).toBeTruthy();
const normalizedTitle = (persistedEntry?.title ?? "").toLowerCase();
expect(normalizedTitle.startsWith("# agents.md instructions for")).toBe(false);
expect(normalizedTitle.startsWith("<environment_context>")).toBe(false);
} finally {
await session?.close();
await resumed?.close();
@@ -380,6 +523,29 @@ describe("CodexAgentClient (SDK integration)", () => {
);
});
describe("isSyntheticRolloutUserMessage", () => {
test("flags AGENTS instruction payloads", () => {
const text = `# AGENTS.md instructions for /Users/test
<INSTRUCTIONS>
Stay safe.
</INSTRUCTIONS>`;
expect(isSyntheticRolloutUserMessage(text)).toBe(true);
});
test("flags environment context payloads", () => {
const text = `<environment_context>
<cwd>/Users/test/project</cwd>
<approval_policy>never</approval_policy>
</environment_context>`;
expect(isSyntheticRolloutUserMessage(text)).toBe(true);
});
test("allows real user prompts", () => {
expect(isSyntheticRolloutUserMessage("investigate flaky tests")).toBe(false);
});
});
type StreamHydrationUpdate = {
event: Extract<AgentStreamEvent, { type: "timeline" }>;
timestamp: Date;
@@ -419,3 +585,118 @@ function stateIncludesUserMessage(state: StreamItem[], marker: string): boolean
(item) => item.kind === "user_message" && item.text.toLowerCase().includes(marker.toLowerCase())
);
}
function stateIncludesSyntheticMessage(state: StreamItem[]): boolean {
return state.some((item) => {
if (item.kind !== "user_message") {
return false;
}
const normalized = item.text.trim().toLowerCase();
return (
normalized.startsWith("# agents.md instructions for") ||
normalized.startsWith("<environment_context>")
);
});
}
async function prependSyntheticRolloutEntries(filePath: string, entries: Array<Record<string, unknown>>): Promise<void> {
const existing = await fs.readFile(filePath, "utf8");
const synthetic = entries.map((entry) => JSON.stringify(entry)).join("\n");
const nextContent = synthetic.length ? `${synthetic}\n${existing}` : existing;
await fs.writeFile(
filePath,
nextContent.endsWith("\n") ? nextContent : `${nextContent}\n`,
"utf8"
);
}
async function resolveRolloutPathFromHandle(handle: AgentPersistenceHandle): Promise<string | null> {
const pathFromMetadata = typeof handle.metadata?.codexRolloutPath === "string" ? handle.metadata.codexRolloutPath : null;
if (pathFromMetadata && (await fileExists(pathFromMetadata))) {
return pathFromMetadata;
}
const sessionDir = typeof handle.metadata?.codexSessionDir === "string" ? handle.metadata.codexSessionDir : null;
const sessionId = handle.sessionId ?? (typeof handle.nativeHandle === "string" ? handle.nativeHandle : null);
if (!sessionDir || !sessionId) {
return null;
}
return findRolloutInDir(sessionDir, sessionId, 5);
}
async function fileExists(filePath: string): Promise<boolean> {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
async function findRolloutInDir(root: string, sessionId: string, maxDepth: number): Promise<string | null> {
const stack: Array<{ dir: string; depth: number }> = [{ dir: root, depth: 0 }];
while (stack.length > 0) {
const { dir, depth } = stack.pop()!;
let entries: Dirent[];
try {
entries = await fs.readdir(dir, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
const entryPath = path.join(dir, entry.name);
if (entry.isFile()) {
if (
entry.name.startsWith("rollout-") &&
entry.name.includes(sessionId) &&
(entry.name.endsWith(".json") || entry.name.endsWith(".jsonl"))
) {
return entryPath;
}
} else if (entry.isDirectory() && depth < maxDepth) {
stack.push({ dir: entryPath, depth: depth + 1 });
}
}
}
return null;
}
function buildSyntheticInstructionEntry(sessionId: string): Record<string, unknown> {
const text = [
`# AGENTS.md instructions for ${sessionId}`,
"",
"<INSTRUCTIONS>",
"Always obey the operator.",
"</INSTRUCTIONS>",
].join("\n");
return buildSyntheticResponseItem(text);
}
function buildSyntheticEnvironmentEntry(): Record<string, unknown> {
const text = [
"<environment_context>",
" <cwd>/tmp/workspace</cwd>",
" <approval_policy>never</approval_policy>",
" <sandbox_mode>danger-full-access</sandbox_mode>",
"</environment_context>",
].join("\n");
return buildSyntheticResponseItem(text);
}
function buildSyntheticResponseItem(text: string): Record<string, unknown> {
return {
timestamp: new Date().toISOString(),
type: "response_item",
payload: {
type: "message",
role: "user",
content: [
{
type: "input_text",
text,
},
],
},
};
}
@@ -98,6 +98,8 @@ const MODE_PRESETS: Record<
type CodexOptionsOverrides = Partial<ThreadOptions> & { skipGitRepoCheck?: boolean };
const MAX_ROLLOUT_SEARCH_DEPTH = 5;
const PERSISTED_TIMELINE_LIMIT = 20;
const SHELL_FUNCTION_NAMES = new Set(["shell", "shell_command"]);
type ToolCallTimelineItem = Extract<AgentTimelineItem, { type: "tool_call" }>;
type ExecPermissionRequestPayload = {
@@ -334,10 +336,26 @@ class CodexAgentSession implements AgentSession {
const turnOptions = this.buildTurnOptions(options);
const { events } = await thread.runStreamed(input, turnOptions);
for await (const rawEvent of events) {
yield* this.translateEvent(rawEvent);
if (rawEvent.type === "turn.completed" || rawEvent.type === "turn.failed" || rawEvent.type === "error") {
break;
let finishedNaturally = false;
try {
for await (const rawEvent of events) {
yield* this.translateEvent(rawEvent);
if (
rawEvent.type === "turn.completed" ||
rawEvent.type === "turn.failed" ||
rawEvent.type === "error"
) {
finishedNaturally = true;
break;
}
}
} finally {
if (!finishedNaturally && typeof events.return === "function") {
try {
await events.return({ type: "turn.completed" } as ThreadEvent);
} catch (error) {
console.warn("[CodexAgentSession] Failed to stop Codex stream:", error);
}
}
}
}
@@ -887,7 +905,7 @@ async function parseRolloutFile(filePath: string, threadId: string): Promise<Age
const content = await fs.readFile(filePath, "utf8");
const lines = content.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
const events: AgentStreamEvent[] = [{ type: "thread_started", provider: "codex", sessionId: threadId }];
const commandCalls = new Map<string, { command: string }>();
const commandCalls = new Map<string, { command: string; cwd?: string }>();
for (const line of lines) {
const entry = parseRolloutEntry(line);
@@ -933,18 +951,10 @@ function isSessionMetaEntry(value: unknown): value is SessionMetaEntry {
return Boolean(value && typeof value === "object" && (value as { type?: unknown }).type === "session_meta");
}
function isResponseItemEntry(value: unknown): value is ResponseItemEntry {
return Boolean(value && typeof value === "object" && (value as { type?: unknown }).type === "response_item");
}
function isRolloutMessagePayload(payload: RolloutResponsePayload | undefined): payload is RolloutMessagePayload {
return Boolean(payload && payload.type === "message");
}
function handleRolloutResponseItem(
payload: RolloutResponsePayload | undefined,
events: AgentStreamEvent[],
commandCalls: Map<string, { command: string }>
commandCalls: Map<string, { command: string; cwd?: string }>
): void {
if (!payload || typeof payload !== "object") {
return;
@@ -957,6 +967,9 @@ function handleRolloutResponseItem(
if (payload.role === "assistant") {
events.push({ type: "timeline", provider: "codex", item: { type: "assistant_message", text, raw: payload } });
} else if (payload.role === "user") {
if (isSyntheticRolloutUserMessage(text)) {
break;
}
events.push({ type: "timeline", provider: "codex", item: { type: "user_message", text, raw: payload } });
}
}
@@ -998,18 +1011,35 @@ function handleRolloutEventMessage(payload: RolloutEventPayload | undefined, eve
function handleRolloutFunctionCall(
payload: RolloutFunctionCallPayload,
events: AgentStreamEvent[],
commandCalls: Map<string, { command: string }>
commandCalls: Map<string, { command: string; cwd?: string }>
): void {
const name = typeof payload.name === "string" ? payload.name : undefined;
if (!name) {
return;
}
if (name === "shell") {
if (SHELL_FUNCTION_NAMES.has(name)) {
const args = safeJsonParse<Record<string, unknown>>(payload.arguments);
const command = formatCommand(args);
const cwd = args && typeof args === "object" && typeof (args as { workdir?: unknown }).workdir === "string"
? ((args as { workdir?: unknown }).workdir as string)
: undefined;
if (command && typeof payload.call_id === "string") {
commandCalls.set(payload.call_id, { command });
commandCalls.set(payload.call_id, { command, cwd });
events.push({
type: "timeline",
provider: "codex",
item: createToolCallTimelineItem({
server: "command",
tool: "shell",
status: payload.status ?? "in_progress",
callId: payload.call_id,
displayName: buildCommandDisplayName(command),
kind: "execute",
input: { command, cwd },
raw: payload,
}),
});
}
return;
}
@@ -1042,7 +1072,7 @@ function handleRolloutFunctionCall(
function finalizeRolloutFunctionCall(
payload: RolloutFunctionCallOutputPayload,
events: AgentStreamEvent[],
commandCalls: Map<string, { command: string }>
commandCalls: Map<string, { command: string; cwd?: string }>
): void {
if (typeof payload.call_id !== "string") {
return;
@@ -1064,7 +1094,7 @@ function finalizeRolloutFunctionCall(
callId: payload.call_id,
displayName: buildCommandDisplayName(command.command),
kind: "execute",
input: { command: command.command },
input: { command: command.command, cwd: command.cwd },
output: result,
raw: { payload, result },
}),
@@ -1196,11 +1226,6 @@ type SessionMetaEntry = {
payload?: SessionMetaPayload;
};
type ResponseItemEntry = {
type: "response_item";
payload?: RolloutResponsePayload;
};
async function collectRecentRolloutFiles(rootDir: string, limit: number): Promise<RolloutCandidate[]> {
const candidates: RolloutCandidate[] = [];
async function traverse(dir: string): Promise<void> {
@@ -1268,15 +1293,8 @@ async function parseRolloutDescriptor(
if (typeof entry.payload.cwd === "string" && entry.payload.cwd.length > 0) {
cwd = entry.payload.cwd;
}
} else if (
!title &&
isResponseItemEntry(entry) &&
isRolloutMessagePayload(entry.payload) &&
entry.payload.role === "user"
) {
title = extractCodexUserPrompt(entry.payload);
}
if (sessionId && cwd && title) {
if (sessionId && cwd) {
break;
}
}
@@ -1285,6 +1303,11 @@ async function parseRolloutDescriptor(
return null;
}
const timeline = await loadCodexPersistedTimeline(filePath, sessionId);
if (!title) {
title = timeline.find((item) => item.type === "user_message")?.text ?? null;
}
const persistence: AgentPersistenceHandle = {
provider: "codex",
sessionId,
@@ -1304,21 +1327,10 @@ async function parseRolloutDescriptor(
title: (title ?? "").trim() || `Codex session ${sessionId.slice(0, 8)}`,
lastActivityAt: mtime,
persistence,
timeline,
};
}
function extractCodexUserPrompt(payload: RolloutMessagePayload): string | null {
if (!Array.isArray(payload.content)) {
return null;
}
for (const block of payload.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;
@@ -1350,6 +1362,24 @@ function extractMessageText(content: unknown): string {
return parts.join("\n").trim();
}
export function isSyntheticRolloutUserMessage(text: string): boolean {
const normalized = text.trim();
if (!normalized) {
return false;
}
const lower = normalized.toLowerCase();
if (lower.startsWith("# agents.md instructions for") && lower.includes("<instructions>")) {
return true;
}
if (lower.startsWith("<environment_context>")) {
return true;
}
return false;
}
function extractReasoningText(payload: RolloutReasoningPayload): string {
if (Array.isArray(payload?.summary)) {
const text = payload.summary
@@ -1367,6 +1397,26 @@ function extractReasoningText(payload: RolloutReasoningPayload): string {
return "";
}
async function loadCodexPersistedTimeline(filePath: string, threadId: string): Promise<AgentTimelineItem[]> {
try {
const events = await parseRolloutFile(filePath, threadId);
const timeline: AgentTimelineItem[] = [];
for (const event of events) {
if (event.type !== "timeline" || event.provider !== "codex") {
continue;
}
timeline.push(event.item);
if (timeline.length >= PERSISTED_TIMELINE_LIMIT) {
break;
}
}
return timeline;
} catch (error) {
console.warn(`[CodexAgentSession] Failed to load persisted timeline for ${threadId}:`, error);
return [];
}
}
function parsePlanItems(args: unknown): { text: string; completed: boolean }[] {
const planValue = typeof args === "object" && args ? (args as { plan?: unknown }).plan : undefined;
const plan = Array.isArray(planValue) ? planValue : [];
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import { WaitForAgentTracker } from "./wait-for-agent-tracker";
import { WaitForAgentTracker } from "./wait-for-agent-tracker.js";
describe("WaitForAgentTracker", () => {
it("registers and cancels waiters per agent", () => {
@@ -7,7 +7,7 @@ describe("WaitForAgentTracker", () => {
const cancelA = vi.fn();
const cancelB = vi.fn();
const unregisterA = tracker.register("agent-a", cancelA);
tracker.register("agent-a", cancelA);
tracker.register("agent-a", cancelB);
expect(tracker.cancel("agent-a", "test")).toBe(true);
+51 -2
View File
@@ -19,10 +19,11 @@ type ProviderEventPayload = Extract<
export type AgentSnapshotPayload = Omit<
AgentSnapshot,
"createdAt" | "updatedAt"
"createdAt" | "updatedAt" | "lastUserMessageAt"
> & {
createdAt: string;
updatedAt: string;
lastUserMessageAt: string | null;
title: string | null;
};
@@ -219,6 +220,7 @@ export const AgentSnapshotPayloadSchema = z.object({
cwd: z.string(),
createdAt: z.string(),
updatedAt: z.string(),
lastUserMessageAt: z.string().nullable(),
status: AgentStatusSchema,
sessionId: z.string().nullable(),
capabilities: AgentCapabilityFlagsSchema,
@@ -239,11 +241,12 @@ export function serializeAgentSnapshot(
snapshot: AgentSnapshot,
options?: { title?: string | null }
): AgentSnapshotPayload {
const { createdAt, updatedAt, ...rest } = snapshot;
const { createdAt, updatedAt, lastUserMessageAt, ...rest } = snapshot;
return {
...rest,
createdAt: createdAt.toISOString(),
updatedAt: updatedAt.toISOString(),
lastUserMessageAt: lastUserMessageAt ? lastUserMessageAt.toISOString() : null,
title: options?.title ?? null,
};
}
@@ -323,10 +326,28 @@ export const SendAgentAudioSchema = z.object({
requestId: z.string().optional(), // Client-provided ID for tracking transcription
});
const GitSetupOptionsSchema = z.object({
baseBranch: z.string().optional(),
createNewBranch: z.boolean().optional(),
newBranchName: z.string().optional(),
createWorktree: z.boolean().optional(),
worktreeSlug: z.string().optional(),
});
export type GitSetupOptions = z.infer<typeof GitSetupOptionsSchema>;
export const CreateAgentRequestMessageSchema = z.object({
type: z.literal("create_agent_request"),
config: AgentSessionConfigSchema,
worktreeName: z.string().optional(),
initialPrompt: z.string().optional(),
git: GitSetupOptionsSchema.optional(),
requestId: z.string().optional(),
});
export const GitRepoInfoRequestMessageSchema = z.object({
type: z.literal("git_repo_info_request"),
cwd: z.string(),
requestId: z.string().optional(),
});
@@ -348,6 +369,11 @@ export const CancelAgentRequestMessageSchema = z.object({
agentId: z.string(),
});
export const RestartServerRequestMessageSchema = z.object({
type: z.literal("restart_server_request"),
reason: z.string().optional(),
});
export const ListPersistedAgentsRequestMessageSchema = z.object({
type: z.literal("list_persisted_agents_request"),
provider: AgentProviderSchema.optional(),
@@ -424,12 +450,14 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
ResumeAgentRequestMessageSchema,
RefreshAgentRequestMessageSchema,
CancelAgentRequestMessageSchema,
RestartServerRequestMessageSchema,
InitializeAgentRequestMessageSchema,
SetAgentModeMessageSchema,
AgentPermissionResponseMessageSchema,
GitDiffRequestSchema,
FileExplorerRequestSchema,
ListPersistedAgentsRequestMessageSchema,
GitRepoInfoRequestMessageSchema,
]);
export type SessionInboundMessage = z.infer<typeof SessionInboundMessageSchema>;
@@ -622,6 +650,7 @@ const PersistedAgentDescriptorPayloadSchema = z.object({
title: z.string(),
lastActivityAt: z.string(),
persistence: AgentPersistenceHandleSchema,
timeline: z.array(AgentTimelineItemPayloadSchema),
});
export type PersistedAgentDescriptorPayload = z.infer<typeof PersistedAgentDescriptorPayloadSchema>;
@@ -654,6 +683,24 @@ export const FileExplorerResponseSchema = z.object({
}),
});
const GitBranchInfoSchema = z.object({
name: z.string(),
isCurrent: z.boolean(),
});
export const GitRepoInfoResponseSchema = z.object({
type: z.literal("git_repo_info_response"),
payload: z.object({
cwd: z.string(),
repoRoot: z.string(),
requestId: z.string().optional(),
branches: z.array(GitBranchInfoSchema).optional(),
currentBranch: z.string().nullable().optional(),
isDirty: z.boolean().optional(),
error: z.string().optional(),
}),
});
export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
ActivityLogMessageSchema,
AssistantChunkMessageSchema,
@@ -675,6 +722,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
ListPersistedAgentsResponseSchema,
GitDiffResponseSchema,
FileExplorerResponseSchema,
GitRepoInfoResponseSchema,
]);
export type SessionOutboundMessage = z.infer<
@@ -722,6 +770,7 @@ export type GitDiffRequest = z.infer<typeof GitDiffRequestSchema>;
export type GitDiffResponse = z.infer<typeof GitDiffResponseSchema>;
export type FileExplorerRequest = z.infer<typeof FileExplorerRequestSchema>;
export type FileExplorerResponse = z.infer<typeof FileExplorerResponseSchema>;
export type RestartServerRequestMessage = z.infer<typeof RestartServerRequestMessageSchema>;
// ============================================================================
// WebSocket Level Messages (wraps session messages)
@@ -15,6 +15,7 @@ function createSnapshot(overrides?: Partial<AgentSnapshot>): AgentSnapshot {
cwd: "/tmp/project",
createdAt: now,
updatedAt: now,
lastUserMessageAt: null,
status: "idle",
sessionId: null,
capabilities: {
@@ -45,7 +45,9 @@ export async function restorePersistedAgents(
return;
}
console.log(`[Agents] Restoring ${records.length} persisted agent(s)`);
let resumed = 0;
let created = 0;
for (const record of records) {
if (!isKnownProvider(record.provider)) {
console.warn(
@@ -54,27 +56,33 @@ export async function restorePersistedAgents(
continue;
}
const handle = buildPersistenceHandle(record);
try {
const persistenceHandle = buildPersistenceHandle(record);
if (persistenceHandle) {
if (handle) {
await agentManager.resumeAgent(
persistenceHandle,
handle,
buildConfigOverrides(record),
record.id
);
resumed += 1;
} else {
await agentManager.createAgent(
buildSessionConfig(record),
record.id
);
created += 1;
}
} catch (error) {
console.error(
`[Agents] Failed to restore agent ${record.id}:`,
`[Agents] Failed to restore agent ${record.id} from registry:`,
error
);
}
}
console.log(
`[Agents] Loaded ${records.length} persisted agent record(s); resumed ${resumed}, created ${created}`
);
}
function isKnownProvider(provider: string): provider is AgentProvider {
+484 -27
View File
@@ -18,6 +18,7 @@ import {
type SessionInboundMessage,
type SessionOutboundMessage,
type FileExplorerRequest,
type GitSetupOptions,
} from "./messages.js";
import { getSystemPrompt } from "./agent/system-prompt.js";
import { getAllTools } from "./agent/llm-openai.js";
@@ -28,6 +29,10 @@ import {
listConversations,
deleteConversation,
} from "./persistence.js";
import {
buildConfigOverrides,
buildSessionConfig,
} from "./persistence-hooks.js";
import { experimental_createMCPClient } from "ai";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { createTerminalMcpServer } from "./terminal-mcp/index.js";
@@ -39,8 +44,10 @@ import type {
AgentPromptInput,
AgentSessionConfig,
AgentStreamEvent,
AgentProvider,
AgentPersistenceHandle,
} from "./agent/agent-sdk-types.js";
import { AgentRegistry } from "./agent/agent-registry.js";
import { AgentRegistry, type StoredAgentRecord } from "./agent/agent-registry.js";
import { expandTilde } from "./terminal-mcp/tmux.js";
import {
listDirectoryEntries,
@@ -51,12 +58,28 @@ import {
isTitleGeneratorInitialized,
} from "../services/agent-title-generator.js";
import type { TerminalManager } from "./terminal-mcp/terminal-manager.js";
import {
createWorktree,
detectRepoInfo,
slugify,
validateBranchSlug,
} from "../utils/worktree.js";
const execAsync = promisify(exec);
const ACTIVE_TITLE_GENERATIONS = new Set<string>();
let restartRequested = false;
const KNOWN_AGENT_PROVIDERS: AgentProvider[] = ["claude", "codex"];
type ProcessingPhase = "idle" | "transcribing" | "llm";
type NormalizedGitOptions = {
baseBranch?: string;
createNewBranch: boolean;
newBranchName?: string;
createWorktree: boolean;
worktreeSlug?: string;
};
const PCM_SAMPLE_RATE = 16000;
const PCM_CHANNELS = 1;
const PCM_BITS_PER_SAMPLE = 16;
@@ -66,6 +89,7 @@ const MIN_STREAMING_SEGMENT_DURATION_MS = 1000;
const MIN_STREAMING_SEGMENT_BYTES = Math.round(
PCM_BYTES_PER_MS * MIN_STREAMING_SEGMENT_DURATION_MS
);
const SAFE_GIT_REF_PATTERN = /^[A-Za-z0-9._\/-]+$/;
/**
* Type for present_artifact tool arguments
@@ -115,6 +139,45 @@ function convertPCMToWavBuffer(
return wavBuffer;
}
function isKnownAgentProvider(value: string): value is AgentProvider {
return KNOWN_AGENT_PROVIDERS.includes(value as AgentProvider);
}
function coerceAgentProvider(value: string, agentId?: string): AgentProvider {
if (isKnownAgentProvider(value)) {
return value;
}
console.warn(
`[Session] Unknown provider '${value}' for agent ${agentId ?? "unknown"}; defaulting to 'claude'`
);
return "claude";
}
function toAgentPersistenceHandle(
handle: StoredAgentRecord["persistence"]
): AgentPersistenceHandle | null {
if (!handle) {
return null;
}
const provider = handle.provider;
if (!isKnownAgentProvider(provider)) {
console.warn(
`[Session] Ignoring persistence handle with unknown provider '${provider}'`
);
return null;
}
if (!handle.sessionId) {
console.warn("[Session] Ignoring persistence handle missing sessionId");
return null;
}
return {
provider,
sessionId: handle.sessionId,
nativeHandle: handle.nativeHandle,
metadata: handle.metadata,
} satisfies AgentPersistenceHandle;
}
/**
* Session represents a single client conversation session.
* It owns all state management, orchestration logic, and message processing.
@@ -460,6 +523,43 @@ export class Session {
return serializeAgentSnapshot(agent, { title });
}
private buildStoredAgentPayload(record: StoredAgentRecord): AgentSnapshotPayload {
const defaultCapabilities = {
supportsStreaming: false,
supportsSessionPersistence: true,
supportsDynamicModes: false,
supportsMcpServers: false,
supportsReasoningStream: false,
supportsToolInvocations: true,
} as const;
const createdAt = new Date(record.createdAt);
const updatedAt = new Date(record.lastActivityAt ?? record.updatedAt);
const lastUserMessageAt = record.lastUserMessageAt
? new Date(record.lastUserMessageAt)
: null;
const provider = coerceAgentProvider(record.provider, record.id);
return {
id: record.id,
provider,
cwd: record.cwd,
createdAt: createdAt.toISOString(),
updatedAt: updatedAt.toISOString(),
lastUserMessageAt: lastUserMessageAt ? lastUserMessageAt.toISOString() : null,
status: (record.lastStatus as any) ?? "closed",
sessionId: null,
capabilities: defaultCapabilities,
currentModeId: record.lastModeId ?? null,
availableModes: [],
pendingPermissions: [],
persistence: toAgentPersistenceHandle(record.persistence),
lastUsage: undefined,
lastError: undefined,
title: record.title ?? null,
};
}
private async forwardAgentState(agent: AgentSnapshot): Promise<void> {
try {
const payload = await this.buildAgentPayload(agent);
@@ -609,13 +709,17 @@ export class Session {
await this.handleRefreshAgentRequest(msg);
break;
case "cancel_agent_request":
await this.handleCancelAgentRequest(msg.agentId);
break;
case "cancel_agent_request":
await this.handleCancelAgentRequest(msg.agentId);
break;
case "initialize_agent_request":
await this.handleInitializeAgentRequest(msg.agentId, msg.requestId);
break;
case "restart_server_request":
await this.handleRestartServerRequest(msg.reason);
break;
case "initialize_agent_request":
await this.handleInitializeAgentRequest(msg.agentId, msg.requestId);
break;
case "set_agent_mode":
await this.handleSetAgentMode(msg.agentId, msg.modeId);
@@ -640,6 +744,10 @@ export class Session {
case "list_persisted_agents_request":
await this.handleListPersistedAgentsRequest(msg);
break;
case "git_repo_info_request":
await this.handleGitRepoInfoRequest(msg);
break;
}
} catch (error: any) {
console.error(
@@ -740,6 +848,34 @@ export class Session {
}
}
private async handleRestartServerRequest(reason?: string): Promise<void> {
if (restartRequested) {
console.log(
`[Session ${this.clientId}] Restart already requested, ignoring duplicate`
);
return;
}
restartRequested = true;
const payload: { status: string } & Record<string, unknown> = {
status: "restart_requested",
clientId: this.clientId,
};
if (reason && reason.trim().length > 0) {
payload.reason = reason;
}
console.warn(`[Session ${this.clientId}] Restart requested via websocket`);
this.emit({
type: "status",
payload,
});
setTimeout(() => {
process.exit(0);
}, 250);
}
private async handleDeleteAgentRequest(agentId: string): Promise<void> {
console.log(
`[Session ${this.clientId}] Deleting agent ${agentId} from registry`
@@ -931,13 +1067,30 @@ export class Session {
);
try {
const snapshot = this.agentManager.getAgent(agentId);
let snapshot = this.agentManager.getAgent(agentId);
if (!snapshot) {
throw new Error(`Agent not found: ${agentId}`);
const record = await this.agentRegistry.get(agentId);
if (!record) {
throw new Error(`Agent not found: ${agentId}`);
}
const handle = toAgentPersistenceHandle(record.persistence);
if (handle) {
snapshot = await this.agentManager.resumeAgent(
handle,
buildConfigOverrides(record),
agentId
);
} else {
const config = buildSessionConfig(record);
snapshot = await this.agentManager.createAgent(config, agentId);
}
}
await this.agentManager.primeAgentHistory(agentId);
await this.forwardAgentState(snapshot);
// Send timeline snapshot after hydration (if any)
const timelineSize = this.emitAgentTimelineSnapshot(snapshot);
if (requestId) {
@@ -978,7 +1131,7 @@ export class Session {
private async handleCreateAgentRequest(
msg: Extract<SessionInboundMessage, { type: "create_agent_request" }>
): Promise<void> {
const { config, worktreeName, requestId } = msg;
const { config, worktreeName, requestId, initialPrompt, git } = msg;
console.log(
`[Session ${this.clientId}] Creating agent in ${config.cwd} (${config.provider})${
worktreeName ? ` with worktree ${worktreeName}` : ""
@@ -988,6 +1141,7 @@ export class Session {
try {
const sessionConfig = await this.buildAgentSessionConfig(
config,
git,
worktreeName
);
const snapshot = await this.agentManager.createAgent(sessionConfig);
@@ -1012,6 +1166,28 @@ export class Session {
await this.forwardAgentState(snapshot);
const trimmedPrompt = initialPrompt?.trim();
if (trimmedPrompt && trimmedPrompt.length > 0) {
try {
this.agentManager.recordUserMessage(snapshot.id, trimmedPrompt);
} catch (recordError) {
console.error(
`[Session ${this.clientId}] Failed to record initial prompt for agent ${snapshot.id}:`,
recordError
);
}
try {
const normalizedPrompt = this.buildAgentPrompt(trimmedPrompt);
this.startAgentStream(snapshot.id, normalizedPrompt);
} catch (promptError) {
console.error(
`[Session ${this.clientId}] Failed to run initial prompt for agent ${snapshot.id}:`,
promptError
);
}
}
if (requestId) {
this.emit({
type: "status",
@@ -1031,6 +1207,16 @@ export class Session {
`[Session ${this.clientId}] Failed to create agent:`,
error
);
if (requestId) {
this.emit({
type: "status",
payload: {
status: "agent_create_failed",
requestId,
error: (error as Error)?.message ?? String(error),
},
});
}
this.emit({
type: "activity_log",
payload: {
@@ -1068,6 +1254,7 @@ export class Session {
try {
const snapshot = await this.agentManager.resumeAgent(handle, overrides);
this.setCachedTitle(snapshot.id, null);
await this.agentManager.primeAgentHistory(snapshot.id);
await this.forwardAgentState(snapshot);
const timelineSize = this.emitAgentTimelineSnapshot(snapshot);
if (requestId) {
@@ -1107,9 +1294,31 @@ export class Session {
);
try {
const snapshot = await this.agentManager.refreshAgentFromPersistence(
agentId
);
let snapshot: AgentSnapshot;
const existing = this.agentManager.getAgent(agentId);
if (existing) {
snapshot = await this.agentManager.refreshAgentFromPersistence(
agentId
);
} else {
const record = await this.agentRegistry.get(agentId);
if (!record) {
throw new Error(`Agent not found: ${agentId}`);
}
const handle = toAgentPersistenceHandle(record.persistence);
if (!handle) {
throw new Error(
`Agent ${agentId} cannot be refreshed because it lacks persistence`
);
}
snapshot = await this.agentManager.resumeAgent(
handle,
buildConfigOverrides(record),
agentId
);
this.setCachedTitle(agentId, null);
}
await this.agentManager.primeAgentHistory(agentId);
await this.forwardAgentState(snapshot);
const timelineSize = this.emitAgentTimelineSnapshot(snapshot);
if (requestId) {
@@ -1158,20 +1367,57 @@ export class Session {
private async buildAgentSessionConfig(
config: AgentSessionConfig,
worktreeName?: string
gitOptions?: GitSetupOptions,
legacyWorktreeName?: string
): Promise<AgentSessionConfig> {
let cwd = expandTilde(config.cwd);
const normalized = this.normalizeGitOptions(gitOptions, legacyWorktreeName);
if (worktreeName) {
const { createWorktree } = await import("../utils/worktree.js");
console.log(
`[Session ${this.clientId}] Creating worktree '${worktreeName}' from ${cwd}`
);
const worktreeConfig = await createWorktree({
branchName: worktreeName,
if (!normalized) {
return {
...config,
cwd,
};
}
if (!normalized.createWorktree) {
await this.ensureCleanWorkingTree(cwd);
}
if (normalized.createWorktree) {
const targetBranch = normalized.createNewBranch
? normalized.newBranchName
: normalized.baseBranch;
if (!targetBranch) {
throw new Error(
"A branch name is required when creating a worktree."
);
}
console.log(
`[Session ${this.clientId}] Creating worktree '${
normalized.worktreeSlug ?? targetBranch
}' for branch ${targetBranch}`
);
const worktreeConfig = await createWorktree({
branchName: targetBranch,
cwd,
baseBranch: normalized.createNewBranch
? normalized.baseBranch
: undefined,
worktreeSlug: normalized.worktreeSlug ?? targetBranch,
});
cwd = worktreeConfig.worktreePath;
} else if (normalized.createNewBranch) {
await this.createBranchFromBase({
cwd,
baseBranch: normalized.baseBranch ?? "HEAD",
newBranchName: normalized.newBranchName!,
});
} else if (normalized.baseBranch) {
await this.checkoutExistingBranch(cwd, normalized.baseBranch);
}
return {
@@ -1180,6 +1426,212 @@ export class Session {
};
}
private async handleGitRepoInfoRequest(
msg: Extract<SessionInboundMessage, { type: "git_repo_info_request" }>
): Promise<void> {
const { cwd, requestId } = msg;
const resolvedCwd = expandTilde(cwd);
try {
const repoInfo = await detectRepoInfo(resolvedCwd);
const { stdout: branchesRaw } = await execAsync(
"git branch --format='%(refname:short)'",
{ cwd: repoInfo.path }
);
const { stdout: currentRaw } = await execAsync(
"git rev-parse --abbrev-ref HEAD",
{ cwd: resolvedCwd }
);
const currentBranch = currentRaw.trim();
const branches = branchesRaw
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0)
.map((name) => ({
name,
isCurrent: name === currentBranch,
}));
const isDirty = await this.isWorkingTreeDirty(resolvedCwd);
this.emit({
type: "git_repo_info_response",
payload: {
cwd: resolvedCwd,
repoRoot: repoInfo.path,
requestId,
branches,
currentBranch: currentBranch || null,
isDirty,
},
});
} catch (error) {
this.emit({
type: "git_repo_info_response",
payload: {
cwd,
repoRoot: cwd,
requestId,
error: (error as Error)?.message ?? String(error),
},
});
}
}
private normalizeGitOptions(
gitOptions?: GitSetupOptions,
legacyWorktreeName?: string
): NormalizedGitOptions | null {
const fallbackOptions: GitSetupOptions | undefined = legacyWorktreeName
? {
createWorktree: true,
createNewBranch: true,
newBranchName: legacyWorktreeName,
worktreeSlug: legacyWorktreeName,
}
: undefined;
const merged = gitOptions ?? fallbackOptions;
if (!merged) {
return null;
}
const baseBranch = merged.baseBranch?.trim() || undefined;
const createWorktree = Boolean(merged.createWorktree);
const createNewBranch = Boolean(merged.createNewBranch);
const normalizedBranchName = merged.newBranchName
? slugify(merged.newBranchName)
: undefined;
const normalizedWorktreeSlug = merged.worktreeSlug
? slugify(merged.worktreeSlug)
: normalizedBranchName;
if (!createWorktree && !createNewBranch && !baseBranch) {
return null;
}
if (baseBranch) {
this.assertSafeGitRef(baseBranch, "base branch");
}
if (createNewBranch) {
if (!normalizedBranchName) {
throw new Error("New branch name is required");
}
const validation = validateBranchSlug(normalizedBranchName);
if (!validation.valid) {
throw new Error(`Invalid branch name: ${validation.error}`);
}
}
if (normalizedWorktreeSlug) {
const validation = validateBranchSlug(normalizedWorktreeSlug);
if (!validation.valid) {
throw new Error(`Invalid worktree name: ${validation.error}`);
}
}
if (createWorktree && !createNewBranch && !baseBranch) {
throw new Error(
"Base branch is required when creating a worktree without a new branch"
);
}
return {
baseBranch,
createNewBranch,
newBranchName: normalizedBranchName,
createWorktree,
worktreeSlug: normalizedWorktreeSlug,
};
}
private assertSafeGitRef(ref: string, label: string): void {
if (!SAFE_GIT_REF_PATTERN.test(ref) || ref.includes("..") || ref.includes("@{")) {
throw new Error(`Invalid ${label}: ${ref}`);
}
}
private async ensureCleanWorkingTree(cwd: string): Promise<void> {
const dirty = await this.isWorkingTreeDirty(cwd);
if (dirty) {
throw new Error(
"Working directory has uncommitted changes. Commit or stash before switching branches."
);
}
}
private async isWorkingTreeDirty(cwd: string): Promise<boolean> {
try {
const { stdout } = await execAsync("git status --porcelain", { cwd });
return stdout.trim().length > 0;
} catch (error) {
throw new Error(
`Unable to inspect git status for ${cwd}: ${(error as Error).message}`
);
}
}
private async checkoutExistingBranch(
cwd: string,
branch: string
): Promise<void> {
this.assertSafeGitRef(branch, "branch");
try {
await execAsync(`git rev-parse --verify ${branch}`, { cwd });
} catch (error) {
throw new Error(`Branch not found: ${branch}`);
}
const { stdout } = await execAsync("git rev-parse --abbrev-ref HEAD", {
cwd,
});
const current = stdout.trim();
if (current === branch) {
return;
}
await execAsync(`git checkout ${branch}`, { cwd });
}
private async createBranchFromBase(params: {
cwd: string;
baseBranch: string;
newBranchName: string;
}): Promise<void> {
const { cwd, baseBranch, newBranchName } = params;
this.assertSafeGitRef(baseBranch, "base branch");
try {
await execAsync(`git rev-parse --verify ${baseBranch}`, { cwd });
} catch (error) {
throw new Error(`Base branch not found: ${baseBranch}`);
}
const exists = await this.doesLocalBranchExist(cwd, newBranchName);
if (exists) {
throw new Error(`Branch already exists: ${newBranchName}`);
}
await execAsync(`git checkout -b ${newBranchName} ${baseBranch}`, {
cwd,
});
}
private async doesLocalBranchExist(
cwd: string,
branch: string
): Promise<boolean> {
try {
await execAsync(`git show-ref --verify --quiet refs/heads/${branch}` , {
cwd,
});
return true;
} catch (error: any) {
return false;
}
}
private async handleListPersistedAgentsRequest(
msg: Extract<SessionInboundMessage, { type: "list_persisted_agents_request" }>
): Promise<void> {
@@ -1199,6 +1651,7 @@ export class Session {
title: entry.title ?? `Session ${entry.sessionId.slice(0, 8)}`,
lastActivityAt: entry.lastActivityAt.toISOString(),
persistence: entry.persistence,
timeline: entry.timeline ?? [],
})),
},
});
@@ -1438,10 +1891,19 @@ export class Session {
try {
// Get live agents with session modes
const agentSnapshots = this.agentManager.listAgents();
const agents = await Promise.all(
const liveAgents = await Promise.all(
agentSnapshots.map((agent) => this.buildAgentPayload(agent))
);
// Add persisted agents that have not been lazily initialized yet
const registryRecords = await this.agentRegistry.list();
const liveIds = new Set(agentSnapshots.map((a) => a.id));
const persistedAgents = registryRecords
.filter((record) => !liveIds.has(record.id))
.map((record) => this.buildStoredAgentPayload(record));
const agents = [...liveAgents, ...persistedAgents];
// Get live commands from terminal manager
let commands: any[] = [];
if (this.terminalManager) {
@@ -1467,11 +1929,6 @@ export class Session {
console.log(
`[Session ${this.clientId}] Sent session state: ${agents.length} agents, ${commands.length} commands`
);
for (const agent of agentSnapshots) {
this.emitAgentTimelineSnapshot(agent);
void this.maybeGenerateAgentTitle(agent.id);
}
} catch (error) {
console.error(
`[Session ${this.clientId}] Failed to send session state:`,
+25 -12
View File
@@ -18,6 +18,13 @@ interface WorktreeConfig {
repoPath: string;
}
interface CreateWorktreeOptions {
branchName: string;
cwd: string;
baseBranch?: string;
worktreeSlug?: string;
}
/**
* Check if current directory is a bare repository
*/
@@ -148,6 +155,11 @@ export function slugify(input: string): string {
.replace(/^-+|-+$/g, "");
}
function sanitizeWorktreeSlug(input: string): string {
const slug = slugify(input);
return slug.length > 0 ? slug : "worktree";
}
/**
* Check if a worktree already exists for a branch
*/
@@ -183,12 +195,12 @@ async function findExistingWorktree(
/**
* Create a git worktree with proper naming conventions
*/
export async function createWorktree(params: {
branchName: string;
cwd: string;
}): Promise<WorktreeConfig> {
const { branchName, cwd } = params;
export async function createWorktree({
branchName,
cwd,
baseBranch,
worktreeSlug,
}: CreateWorktreeOptions): Promise<WorktreeConfig> {
// Validate branch name
const validation = validateBranchSlug(branchName);
if (!validation.valid) {
@@ -200,11 +212,13 @@ export async function createWorktree(params: {
// Determine worktree directory based on repo type
let worktreePath: string;
const desiredSlug = sanitizeWorktreeSlug(worktreeSlug ?? branchName);
if (repoInfo.type === "bare") {
worktreePath = join(repoInfo.path, branchName);
worktreePath = join(repoInfo.path, desiredSlug);
} else {
const parentDir = dirname(repoInfo.path);
const worktreeName = `${repoInfo.name}-${branchName.replace(/\//g, "-")}`;
const worktreeName = `${repoInfo.name}-${desiredSlug}`;
worktreePath = join(parentDir, worktreeName);
}
@@ -238,10 +252,9 @@ export async function createWorktree(params: {
});
} catch (error) {
// Branch doesn't exist, create new branch and worktree
await execAsync(
`git worktree add "${worktreePath}" -b "${branchName}"`,
{ cwd: repoInfo.path }
);
const baseArg = baseBranch ? ` "${baseBranch}"` : "";
const command = `git worktree add "${worktreePath}" -b "${branchName}"${baseArg}`;
await execAsync(command, { cwd: repoInfo.path });
}
// Copy .env file if it exists