refactor(server): integrate session MCP command stack (#893)

* Extract permission response command

* Extract create agent command

* Extract agent lifecycle commands

* refactor(server): extract worktree archive command

* refactor(server): share worktree create list commands

* test(server): update close items lifecycle fakes

* Fix MCP command stack CI regressions

* Restore MCP update_agent no-op success semantics

After the rebase, MCP `update_agent` was returning `success: false` for empty/no-op calls (no name, no labels, no settings). Origin/main returned `success: true` unconditionally. The audit flagged the change as out of scope for this stack — restore the old behavior at the MCP boundary.

Session WS path keeps its accepted/rejected semantics (it surfaces an error when nothing was provided so the client can prompt the user).

* Fix agent metadata delegation and mode persistence

* Update MCP update_agent test for metadata delegation

* Fix stored agent metadata timestamps
This commit is contained in:
Mohamed Boudra
2026-05-27 22:43:03 +08:00
committed by GitHub
parent 5a56835db3
commit 5696cdb455
14 changed files with 1926 additions and 734 deletions
@@ -1638,6 +1638,40 @@ test("setTitle bumps updatedAt and persists title in the same snapshot write", a
expect(live!.updatedAt.getTime()).toBeGreaterThan(Date.parse(before!.updatedAt));
});
test("updateAgentMetadata bumps updatedAt for stored agents", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-stored-metadata-updated-at-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
const manager = new AgentManager({
clients: {
codex: new TestAgentClient(),
},
registry: storage,
logger,
idFactory: () => "00000000-0000-4000-8000-000000000128",
});
const snapshot = await manager.createAgent({
provider: "codex",
cwd: workdir,
});
await manager.closeAgent(snapshot.id);
const before = await storage.get(snapshot.id);
expect(before).not.toBeNull();
expect(manager.getAgent(snapshot.id)).toBeNull();
await manager.updateAgentMetadata(snapshot.id, {
title: "Stored title",
labels: { role: "worker" },
});
const after = await storage.get(snapshot.id);
expect(after?.title).toBe("Stored title");
expect(after?.labels).toEqual({ role: "worker" });
expect(Date.parse(after!.updatedAt)).toBeGreaterThan(Date.parse(before!.updatedAt));
});
test("setGeneratedTitle persists generated title when no title exists", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-generated-title-empty-"));
const storagePath = join(workdir, "agents");
@@ -539,6 +539,13 @@ export class AgentManager {
return next;
}
private nextStoredUpdatedAt(record: StoredAgentRecord): string {
const previousMs = Date.parse(record.updatedAt);
const nowMs = Date.now();
const nextMs = nowMs > previousMs ? nowMs : previousMs + 1;
return new Date(nextMs).toISOString();
}
hasInFlightRun(agentId: string): boolean {
const agent = this.agents.get(agentId);
if (!agent) {
@@ -1360,6 +1367,7 @@ export class AgentManager {
...existing,
...(updates.title ? { title: updates.title } : {}),
...(updates.labels ? { labels: { ...existing.labels, ...updates.labels } } : {}),
updatedAt: this.nextStoredUpdatedAt(existing),
});
}
@@ -5,13 +5,18 @@ import type { AgentManager, ManagedAgent } from "./agent-manager.js";
import type { AgentStorage } from "./agent-storage.js";
import { ensureAgentLoaded } from "./agent-loading.js";
export type AgentRunController = Pick<
AgentManager,
"getAgent" | "tryRunOutOfBand" | "hasInFlightRun" | "replaceAgentRun" | "streamAgent"
>;
export interface StartAgentRunOptions {
replaceRunning?: boolean;
runOptions?: AgentRunOptions;
}
export function startAgentRun(
agentManager: AgentManager,
agentManager: AgentRunController,
agentId: string,
prompt: AgentPromptInput,
logger: Logger,
@@ -0,0 +1,611 @@
import type { Logger } from "pino";
import { PARENT_AGENT_ID_LABEL } from "../../../shared/agent-labels.js";
import type { TerminalManager } from "../../../terminal/terminal-manager.js";
import type { CreatePaseoWorktreeInput } from "../../paseo-worktree-service.js";
import { expandUserPath, resolvePathFromBase } from "../../path-utils.js";
import { toWorktreeRequestError } from "../../worktree-errors.js";
import type { WorkspaceGitService } from "../../workspace-git-service.js";
import type {
AgentWorktreeSetupContinuation,
CreatePaseoWorktreeSetupContinuationInput,
CreatePaseoWorktreeWorkflowFn,
CreatePaseoWorktreeWorkflowResult,
} from "../../worktree-session.js";
import type { AgentAttachment, FirstAgentContext, GitSetupOptions } from "../../messages.js";
import type { AgentManager, ManagedAgent } from "../agent-manager.js";
import { scheduleAgentMetadataGeneration } from "../agent-metadata-generator.js";
import type {
AgentProvider,
AgentPromptContentBlock,
AgentPromptInput,
AgentRunOptions,
AgentSessionConfig,
} from "../agent-sdk-types.js";
import type { AgentStorage } from "../agent-storage.js";
import { getAgentProviderDefinition } from "../provider-manifest.js";
import type { ProviderDefinition } from "../provider-registry.js";
import { setupFinishNotification, startCreatedAgentInitialPrompt } from "../agent-prompt.js";
import { resolveAndValidateCreateAgentMode } from "../create-agent-mode.js";
import { resolveClientMessageId } from "../../client-message-id.js";
import { resolveRequiredProviderModel } from "../mcp-shared.js";
import {
appendTimelineItemIfAgentKnown,
emitLiveTimelineItemIfAgentKnown,
} from "../timeline-append.js";
const OPENCODE_PROVIDER_ID = "opencode";
const OPENCODE_BUILD_MODE_ID = "build";
const OPENCODE_LEGACY_FULL_ACCESS_MODE_ID = "full-access";
const OPENCODE_AUTO_ACCEPT_FEATURE_ID = "auto_accept";
function isOpenCodeLegacyFullAccessMode(
provider: AgentProvider,
modeId: string | undefined,
): boolean {
return provider === OPENCODE_PROVIDER_ID && modeId === OPENCODE_LEGACY_FULL_ACCESS_MODE_ID;
}
function withOpenCodeAutoAcceptFeature(
features: Record<string, unknown> | undefined,
enabled: boolean,
): Record<string, unknown> {
return {
...features,
[OPENCODE_AUTO_ACCEPT_FEATURE_ID]: enabled,
};
}
function hasOpenCodeAutoAcceptFeature(agent: ManagedAgent): boolean {
if (agent.provider !== OPENCODE_PROVIDER_ID) {
return false;
}
return (
agent.features?.some(
(feature) =>
feature.id === OPENCODE_AUTO_ACCEPT_FEATURE_ID &&
(feature.value === true || feature.value === "true"),
) === true || agent.config.featureValues?.[OPENCODE_AUTO_ACCEPT_FEATURE_ID] === true
);
}
function isAgentInUnattendedState(
dependencies: CreateAgentCommandDependencies,
agent: ManagedAgent,
): boolean {
return (
isParentInUnattendedMode(dependencies, agent.provider, agent.currentModeId) ||
hasOpenCodeAutoAcceptFeature(agent)
);
}
export interface CreateAgentWorkspace {
workspaceId: string;
}
export interface CreateAgentSessionWorktreeResult {
sessionConfig: AgentSessionConfig;
setupContinuation?: AgentWorktreeSetupContinuation;
}
interface CreateAgentCommandDependencies {
agentManager: AgentManager;
agentStorage: AgentStorage;
logger: Logger;
paseoHome?: string;
workspaceGitService?: Pick<
WorkspaceGitService,
"getSnapshot" | "listWorktrees" | "resolveRepoRoot"
>;
terminalManager?: TerminalManager | null;
providerRegistry?: Record<AgentProvider, ProviderDefinition> | null;
createPaseoWorktree?: CreatePaseoWorktreeWorkflowFn;
}
export interface CreateAgentFromSessionInput {
kind: "session";
config: AgentSessionConfig;
workspaceId?: string;
worktreeName?: string;
initialPrompt?: string;
clientMessageId?: string;
outputSchema?: Record<string, unknown>;
images?: Array<{ data: string; mimeType: string }>;
attachments?: AgentAttachment[];
git?: GitSetupOptions;
labels: Record<string, string>;
env?: Record<string, string>;
provisionalTitle: string | null;
explicitTitle: string | null;
firstAgentContext: FirstAgentContext;
buildSessionConfig: (
config: AgentSessionConfig,
gitOptions?: GitSetupOptions,
legacyWorktreeName?: string,
firstAgentContext?: FirstAgentContext,
) => Promise<CreateAgentSessionWorktreeResult>;
resolveWorkspace: (input: { cwd: string; workspaceId?: string }) => Promise<CreateAgentWorkspace>;
}
export interface CreateAgentFromMcpInput {
kind: "mcp";
provider: string;
title: string;
initialPrompt: string;
cwd?: string;
thinking?: string;
features?: Record<string, unknown>;
labels?: Record<string, string>;
mode?: string;
background: boolean;
notifyOnFinish: boolean;
callerAgentId?: string;
callerContext?: {
lockedCwd?: string;
allowCustomCwd?: boolean;
childAgentDefaultLabels?: Record<string, string>;
} | null;
worktree?: {
worktreeName?: string;
baseBranch?: string;
refName?: string;
action?: "branch-off" | "checkout";
githubPrNumber?: number;
};
}
export type CreateAgentCommandInput = CreateAgentFromSessionInput | CreateAgentFromMcpInput;
export interface CreateAgentCommandResult {
snapshot: ManagedAgent;
liveSnapshot: ManagedAgent;
background: boolean;
initialPromptStarted: boolean;
}
interface ResolvedCreateAgent {
config: AgentSessionConfig;
createOptions?: AgentCreateOptions;
metadataInitialPrompt?: string;
prompt?: AgentPromptInput;
runOptions?: AgentRunOptions;
explicitTitle: string | null;
setupContinuation?: AgentWorktreeSetupContinuation;
background: boolean;
promptFailure: "throw" | "log";
promptLogger?: Logger;
}
interface AgentCreateOptions {
labels?: Record<string, string>;
workspaceId?: string;
initialPrompt?: string;
env?: Record<string, string>;
initialTitle?: string | null;
}
export async function createAgentCommand(
dependencies: CreateAgentCommandDependencies,
input: CreateAgentCommandInput,
): Promise<CreateAgentCommandResult> {
const resolved =
input.kind === "session"
? await resolveSessionCreateAgent(dependencies, input)
: await resolveMcpCreateAgent(dependencies, input);
const snapshot = await dependencies.agentManager.createAgent(
resolved.config,
undefined,
resolved.createOptions,
);
resolved.setupContinuation?.startAfterAgentCreate({
agentId: snapshot.id,
});
let liveSnapshot = snapshot;
let initialPromptStarted = false;
if (resolved.prompt !== undefined) {
const sendResult = await sendInitialPrompt(dependencies, resolved, snapshot);
initialPromptStarted = sendResult.started;
liveSnapshot = sendResult.liveSnapshot;
}
if (input.kind === "mcp" && input.notifyOnFinish && input.callerAgentId && initialPromptStarted) {
setupFinishNotification({
agentManager: dependencies.agentManager,
agentStorage: dependencies.agentStorage,
childAgentId: snapshot.id,
callerAgentId: input.callerAgentId,
logger: dependencies.logger,
});
}
return {
snapshot,
liveSnapshot,
background: resolved.background,
initialPromptStarted,
};
}
async function resolveSessionCreateAgent(
dependencies: CreateAgentCommandDependencies,
input: CreateAgentFromSessionInput,
): Promise<ResolvedCreateAgent> {
const trimmedPrompt = input.initialPrompt?.trim();
const { sessionConfig, setupContinuation } = await input.buildSessionConfig(
input.config,
input.git,
input.worktreeName,
input.firstAgentContext,
);
const workspace = await input.resolveWorkspace({
cwd: sessionConfig.cwd,
workspaceId: input.workspaceId,
});
const prompt = buildAgentPrompt(trimmedPrompt ?? "", input.images, input.attachments);
const hasPromptContent = Array.isArray(prompt) ? prompt.length > 0 : prompt.length > 0;
return {
config: sessionConfig,
createOptions: {
labels: input.labels,
workspaceId: workspace.workspaceId,
initialPrompt: trimmedPrompt,
env: input.env,
initialTitle: input.provisionalTitle,
},
metadataInitialPrompt: trimmedPrompt,
prompt: hasPromptContent ? prompt : undefined,
runOptions: input.outputSchema ? { outputSchema: input.outputSchema } : undefined,
explicitTitle: input.explicitTitle,
setupContinuation,
background: true,
promptFailure: "throw",
promptLogger: dependencies.logger.child({
clientMessageId: resolveClientMessageId(input.clientMessageId),
}),
};
}
async function resolveMcpCreateAgent(
dependencies: CreateAgentCommandDependencies,
input: CreateAgentFromMcpInput,
): Promise<ResolvedCreateAgent> {
const resolvedProviderModel = resolveRequiredProviderModel(input.provider);
const provider = resolvedProviderModel.provider;
const parentAgent = input.callerAgentId
? requireParentAgent(dependencies.agentManager, input.callerAgentId)
: null;
const cwd = parentAgent
? resolveChildAgentCwd({
parentCwd: parentAgent.cwd,
requestedCwd: input.cwd,
lockedCwd: input.callerContext?.lockedCwd,
allowCustomCwd: input.callerContext?.allowCustomCwd ?? true,
})
: expandUserPath(input.cwd ?? process.cwd());
const { resolvedCwd, setupContinuation } = await resolveMcpCwd({
dependencies,
cwd,
worktree: input.worktree,
initialPrompt: input.initialPrompt,
});
const parentForResolve = parentAgent
? {
provider: parentAgent.provider,
modeId: parentAgent.currentModeId,
isUnattended: isAgentInUnattendedState(dependencies, parentAgent),
}
: null;
const { mode: resolvedMode, features: resolvedFeatures } = resolveCreateModeAndFeatures(
dependencies,
{
provider,
requestedMode: input.mode,
parent: parentForResolve,
features: input.features,
},
);
const labels = mergeLabels(
input.callerAgentId,
input.callerContext?.childAgentDefaultLabels,
input.labels,
);
const trimmedPrompt = input.initialPrompt.trim();
return {
config: {
provider,
cwd: resolvedCwd,
modeId: resolvedMode,
title: input.title.trim(),
model: resolvedProviderModel.model,
thinkingOptionId: input.thinking,
...(resolvedFeatures ? { featureValues: resolvedFeatures } : {}),
},
createOptions: labels ? { labels } : undefined,
metadataInitialPrompt: trimmedPrompt,
prompt: trimmedPrompt,
explicitTitle: input.title.trim(),
setupContinuation,
background: input.background,
promptFailure: "log",
};
}
function resolveCreateModeAndFeatures(
dependencies: CreateAgentCommandDependencies,
input: {
provider: AgentProvider;
requestedMode: string | undefined;
parent: { provider: AgentProvider; modeId: string | null; isUnattended: boolean } | null;
features: Record<string, unknown> | undefined;
},
): { mode: string | undefined; features: Record<string, unknown> | undefined } {
const legacyOpenCodeFullAccess = isOpenCodeLegacyFullAccessMode(
input.provider,
input.requestedMode,
);
const inheritsOpenCodeUnattended =
input.provider === OPENCODE_PROVIDER_ID &&
input.requestedMode === undefined &&
input.parent?.isUnattended === true;
const inheritsOpenCodeAutoAccept =
inheritsOpenCodeUnattended && input.features?.[OPENCODE_AUTO_ACCEPT_FEATURE_ID] === undefined;
const requestedMode = legacyOpenCodeFullAccess ? OPENCODE_BUILD_MODE_ID : input.requestedMode;
const features =
legacyOpenCodeFullAccess || inheritsOpenCodeAutoAccept
? withOpenCodeAutoAcceptFeature(input.features, true)
: input.features;
const mode =
inheritsOpenCodeUnattended && requestedMode === undefined
? OPENCODE_BUILD_MODE_ID
: resolveAndValidateCreateAgentMode({
requestedMode,
targetProvider: input.provider,
parent: input.parent,
availableModes: getAvailableModeIds(dependencies, input.provider),
targetUnattendedMode: getUnattendedModeId(dependencies, input.provider),
});
return { mode, features };
}
async function sendInitialPrompt(
dependencies: CreateAgentCommandDependencies,
resolved: ResolvedCreateAgent,
snapshot: ManagedAgent,
): Promise<{ started: boolean; liveSnapshot: ManagedAgent }> {
scheduleAgentMetadataGeneration({
agentManager: dependencies.agentManager,
agentId: snapshot.id,
cwd: snapshot.cwd,
workspaceGitService: dependencies.workspaceGitService,
initialPrompt: resolved.metadataInitialPrompt,
explicitTitle: resolved.explicitTitle,
paseoHome: dependencies.paseoHome,
logger: dependencies.logger,
});
try {
const prompt = resolved.prompt;
if (prompt === undefined) {
return { started: false, liveSnapshot: snapshot };
}
const liveSnapshot = await startCreatedAgentInitialPrompt({
agentManager: dependencies.agentManager,
agentId: snapshot.id,
snapshot,
prompt,
runOptions: resolved.runOptions,
logger: resolved.promptLogger ?? dependencies.logger,
});
return { started: true, liveSnapshot };
} catch (error) {
if (resolved.promptFailure === "throw") {
throw error;
}
dependencies.logger.error({ err: error, agentId: snapshot.id }, "Failed to run initial prompt");
return { started: false, liveSnapshot: snapshot };
}
}
function buildAgentPrompt(
text: string,
images?: Array<{ data: string; mimeType: string }>,
attachments?: AgentAttachment[],
): AgentPromptInput {
const normalized = text.trim();
const hasImages = (images?.length ?? 0) > 0;
const hasAttachments = (attachments?.length ?? 0) > 0;
if (!hasImages && !hasAttachments) {
return normalized;
}
const blocks: AgentPromptContentBlock[] = [];
if (normalized.length > 0) {
blocks.push({ type: "text", text: normalized });
}
for (const image of images ?? []) {
blocks.push({ type: "image", data: image.data, mimeType: image.mimeType });
}
for (const attachment of attachments ?? []) {
blocks.push(attachment);
}
return blocks;
}
function requireParentAgent(agentManager: AgentManager, parentAgentId: string): ManagedAgent {
const parentAgent = agentManager.getAgent(parentAgentId);
if (!parentAgent) {
throw new Error(`Parent agent ${parentAgentId} not found`);
}
return parentAgent;
}
function resolveChildAgentCwd(params: {
parentCwd: string;
requestedCwd?: string;
lockedCwd?: string;
allowCustomCwd: boolean;
}): string {
const lockedCwd = params.lockedCwd?.trim();
if (lockedCwd) {
return expandUserPath(lockedCwd);
}
const requestedCwd = params.requestedCwd?.trim();
if (!requestedCwd || !params.allowCustomCwd) {
return params.parentCwd;
}
return resolvePathFromBase(params.parentCwd, requestedCwd);
}
async function resolveMcpCwd(params: {
dependencies: CreateAgentCommandDependencies;
cwd: string;
initialPrompt: string;
worktree: CreateAgentFromMcpInput["worktree"];
}): Promise<{ resolvedCwd: string; setupContinuation?: AgentWorktreeSetupContinuation }> {
const { dependencies, worktree } = params;
if (!worktree) {
return { resolvedCwd: params.cwd };
}
const shouldCreateWorktree = Boolean(
worktree.worktreeName || worktree.refName || worktree.action || worktree.githubPrNumber,
);
if (!shouldCreateWorktree) {
return { resolvedCwd: params.cwd };
}
if (
worktree.worktreeName &&
!worktree.baseBranch &&
!worktree.refName &&
!worktree.action &&
worktree.githubPrNumber === undefined
) {
throw new Error("baseBranch is required when creating a worktree");
}
const baseBranch = worktree.baseBranch;
const createdWorktree = await createMcpWorktree({
input: {
cwd: params.cwd,
worktreeSlug: worktree.worktreeName,
refName: worktree.refName,
action: worktree.action,
githubPrNumber: worktree.githubPrNumber,
...(params.initialPrompt ? { firstAgentContext: { prompt: params.initialPrompt } } : {}),
runSetup: false,
paseoHome: dependencies.paseoHome,
},
createPaseoWorktree: dependencies.createPaseoWorktree,
resolveDefaultBranch: baseBranch ? async () => baseBranch : undefined,
setupContinuation: {
kind: "agent",
terminalManager: dependencies.terminalManager ?? null,
appendTimelineItem: ({ agentId, item }) =>
appendTimelineItemIfAgentKnown({
agentManager: dependencies.agentManager,
agentId,
item,
}),
emitLiveTimelineItem: ({ agentId, item }) =>
emitLiveTimelineItemIfAgentKnown({
agentManager: dependencies.agentManager,
agentId,
item,
}),
logger: dependencies.logger,
},
});
return {
resolvedCwd: createdWorktree.worktree.worktreePath,
setupContinuation: createdWorktree.setupContinuation,
};
}
interface CreateMcpWorktreeOptions {
input: CreatePaseoWorktreeInput;
createPaseoWorktree: CreatePaseoWorktreeWorkflowFn | undefined;
resolveDefaultBranch?: (repoRoot: string) => Promise<string>;
setupContinuation?: CreatePaseoWorktreeSetupContinuationInput;
}
async function createMcpWorktree(
options: CreateMcpWorktreeOptions,
): Promise<CreatePaseoWorktreeWorkflowResult> {
try {
if (!options.createPaseoWorktree) {
throw new Error("Paseo worktree service is not configured");
}
return await options.createPaseoWorktree(options.input, {
...(options.resolveDefaultBranch
? { resolveDefaultBranch: options.resolveDefaultBranch }
: {}),
...(options.setupContinuation ? { setupContinuation: options.setupContinuation } : {}),
});
} catch (error) {
throw toWorktreeRequestError(error);
}
}
function mergeLabels(
callerAgentId: string | undefined,
childAgentDefaultLabels: Record<string, string> | undefined,
labels: Record<string, string> | undefined,
): Record<string, string> | undefined {
const mergedLabels = {
...(callerAgentId ? { [PARENT_AGENT_ID_LABEL]: callerAgentId } : {}),
...childAgentDefaultLabels,
...labels,
};
return Object.keys(mergedLabels).length > 0 ? mergedLabels : undefined;
}
function getProviderModes(
dependencies: CreateAgentCommandDependencies,
provider: AgentProvider,
): ProviderDefinition["modes"] | undefined {
const fromRegistry = dependencies.providerRegistry?.[provider];
if (fromRegistry) {
return fromRegistry.modes;
}
try {
return getAgentProviderDefinition(provider).modes;
} catch {
return undefined;
}
}
function getAvailableModeIds(
dependencies: CreateAgentCommandDependencies,
provider: AgentProvider,
): string[] | undefined {
return getProviderModes(dependencies, provider)?.map((mode) => mode.id);
}
function getUnattendedModeId(
dependencies: CreateAgentCommandDependencies,
provider: AgentProvider,
): string | undefined {
return getProviderModes(dependencies, provider)?.find((mode) => mode.isUnattended)?.id;
}
function isParentInUnattendedMode(
dependencies: CreateAgentCommandDependencies,
provider: AgentProvider,
modeId: string | null,
): boolean {
if (modeId === null) {
return false;
}
const modes = getProviderModes(dependencies, provider);
if (!modes) {
return false;
}
return modes.some((mode) => mode.id === modeId && mode.isUnattended === true);
}
@@ -0,0 +1,245 @@
import { describe, expect, test } from "vitest";
import { createTestLogger } from "../../test-utils/test-logger.js";
import type { StoredAgentRecord } from "./agent-storage.js";
import {
archiveAgentCommand,
cancelAgentRunCommand,
setAgentModeCommand,
updateAgentCommand,
type LifecycleAgentSnapshot,
type LifecycleAgentManager,
type LifecycleAgentStorage,
} from "./lifecycle-command.js";
class FakeLifecycleAgentStorage implements LifecycleAgentStorage {
readonly records = new Map<string, StoredAgentRecord>();
readonly upserts: StoredAgentRecord[] = [];
async get(agentId: string): Promise<StoredAgentRecord | null> {
return this.records.get(agentId) ?? null;
}
async upsert(record: StoredAgentRecord): Promise<void> {
this.upserts.push(record);
this.records.set(record.id, record);
}
}
class FakeLifecycleAgentManager implements LifecycleAgentManager {
readonly liveAgents = new Map<string, LifecycleAgentSnapshot>();
readonly cancelledAgentIds: string[] = [];
readonly clearedAttentionAgentIds: string[] = [];
readonly archivedAgentIds: string[] = [];
readonly closedAgentIds: string[] = [];
readonly metadataUpdates: Array<{
agentId: string;
updates: { title?: string; labels?: Record<string, string> };
}> = [];
readonly labelUpdates: Array<{ agentId: string; labels: Record<string, string> }> = [];
readonly notifiedAgentIds: string[] = [];
readonly modeUpdates: Array<{ agentId: string; modeId: string }> = [];
inFlightAgentIds = new Set<string>();
constructor(private readonly storage: FakeLifecycleAgentStorage) {}
getAgent(agentId: string): LifecycleAgentSnapshot | null {
return this.liveAgents.get(agentId) ?? null;
}
hasInFlightRun(agentId: string): boolean {
return this.inFlightAgentIds.has(agentId);
}
async cancelAgentRun(agentId: string): Promise<boolean> {
this.cancelledAgentIds.push(agentId);
return this.inFlightAgentIds.delete(agentId);
}
async clearAgentAttention(agentId: string): Promise<void> {
this.clearedAttentionAgentIds.push(agentId);
}
async archiveAgent(agentId: string): Promise<{ archivedAt: string }> {
this.archivedAgentIds.push(agentId);
this.liveAgents.delete(agentId);
const archivedAt = "2026-05-10T10:00:00.000Z";
const existing = this.storage.records.get(agentId) ?? storedAgent(agentId);
this.storage.records.set(agentId, {
...existing,
archivedAt,
});
return { archivedAt };
}
async archiveSnapshot(agentId: string, archivedAt: string): Promise<StoredAgentRecord> {
const existing = this.storage.records.get(agentId);
if (!existing) {
throw new Error(`Agent not found: ${agentId}`);
}
const archived = {
...existing,
archivedAt,
};
this.storage.records.set(agentId, archived);
return archived;
}
async closeAgent(agentId: string): Promise<void> {
this.closedAgentIds.push(agentId);
this.liveAgents.delete(agentId);
}
async setLabels(agentId: string, labels: Record<string, string>): Promise<void> {
this.labelUpdates.push({ agentId, labels });
}
notifyAgentState(agentId: string): void {
this.notifiedAgentIds.push(agentId);
}
async setAgentMode(agentId: string, modeId: string): Promise<void> {
this.modeUpdates.push({ agentId, modeId });
}
async updateAgentMetadata(
agentId: string,
updates: {
title?: string;
labels?: Record<string, string>;
},
): Promise<void> {
this.metadataUpdates.push({ agentId, updates });
}
}
const logger = createTestLogger();
describe("agent lifecycle commands", () => {
test("cancels only when the agent has an in-flight run", async () => {
const storage = new FakeLifecycleAgentStorage();
const manager = new FakeLifecycleAgentManager(storage);
manager.liveAgents.set("agent-1", managedAgent("agent-1", "running"));
manager.inFlightAgentIds.add("agent-1");
const result = await cancelAgentRunCommand({ agentManager: manager, logger }, "agent-1");
expect(result).toEqual({
agent: manager.liveAgents.get("agent-1"),
cancelled: true,
});
expect(manager.cancelledAgentIds).toEqual(["agent-1"]);
});
test("archives a live agent after canceling and clearing attention", async () => {
const storage = new FakeLifecycleAgentStorage();
const manager = new FakeLifecycleAgentManager(storage);
manager.liveAgents.set("agent-1", managedAgent("agent-1", "running"));
manager.inFlightAgentIds.add("agent-1");
storage.records.set("agent-1", storedAgent("agent-1"));
const result = await archiveAgentCommand(
{ agentManager: manager, agentStorage: storage, logger },
"agent-1",
);
expect(result).toEqual({
agentId: "agent-1",
archivedAt: "2026-05-10T10:00:00.000Z",
record: {
...storedAgent("agent-1"),
archivedAt: "2026-05-10T10:00:00.000Z",
},
});
expect(manager.cancelledAgentIds).toEqual(["agent-1"]);
expect(manager.clearedAttentionAgentIds).toEqual(["agent-1"]);
expect(manager.archivedAgentIds).toEqual(["agent-1"]);
});
test("archives a stored agent when no live agent exists", async () => {
const storage = new FakeLifecycleAgentStorage();
const manager = new FakeLifecycleAgentManager(storage);
storage.records.set("agent-1", storedAgent("agent-1"));
const result = await archiveAgentCommand(
{ agentManager: manager, agentStorage: storage, logger },
"agent-1",
);
expect(result.agentId).toBe("agent-1");
expect(result.archivedAt).toEqual(expect.any(String));
expect(result.record.archivedAt).toBe(result.archivedAt);
expect(manager.archivedAgentIds).toEqual([]);
});
test("normalizes metadata updates and rejects empty updates", async () => {
const storage = new FakeLifecycleAgentStorage();
storage.records.set("agent-1", storedAgent("agent-1"));
const manager = new FakeLifecycleAgentManager(storage);
await expect(
updateAgentCommand(
{ agentManager: manager },
{
agentId: "agent-1",
name: " Renamed agent ",
labels: { team: "infra" },
},
),
).resolves.toEqual({ accepted: true, error: null });
await expect(
updateAgentCommand({ agentManager: manager }, { agentId: "agent-1", name: " " }),
).resolves.toEqual({
accepted: false,
error: "Nothing to update (provide name and/or labels)",
});
expect(storage.upserts).toHaveLength(0);
expect(manager.metadataUpdates).toEqual([
{
agentId: "agent-1",
updates: {
title: "Renamed agent",
labels: { team: "infra" },
},
},
]);
});
test("sets an agent mode and returns the accepted mode", async () => {
const storage = new FakeLifecycleAgentStorage();
const manager = new FakeLifecycleAgentManager(storage);
await expect(
setAgentModeCommand({ agentManager: manager }, { agentId: "agent-1", modeId: "plan" }),
).resolves.toEqual({ modeId: "plan" });
expect(manager.modeUpdates).toEqual([{ agentId: "agent-1", modeId: "plan" }]);
});
});
function managedAgent(
id: string,
lifecycle: LifecycleAgentSnapshot["lifecycle"],
): LifecycleAgentSnapshot {
return {
id,
cwd: "/workspace/project",
lifecycle,
};
}
function storedAgent(id: string): StoredAgentRecord {
return {
id,
provider: "codex",
cwd: "/workspace/project",
createdAt: "2026-05-10T09:00:00.000Z",
updatedAt: "2026-05-10T09:00:00.000Z",
labels: {},
lastStatus: "closed",
config: null,
persistence: null,
archivedAt: null,
};
}
@@ -0,0 +1,189 @@
import type { Logger } from "pino";
import type { ManagedAgent } from "./agent-manager.js";
import type { StoredAgentRecord } from "./agent-storage.js";
export type LifecycleAgentSnapshot = Pick<ManagedAgent, "id" | "cwd" | "lifecycle">;
export interface LifecycleAgentManager {
getAgent(agentId: string): LifecycleAgentSnapshot | null;
hasInFlightRun(agentId: string): boolean;
cancelAgentRun(agentId: string): Promise<boolean>;
clearAgentAttention(agentId: string): Promise<void>;
archiveAgent(agentId: string): Promise<{ archivedAt: string }>;
archiveSnapshot(agentId: string, archivedAt: string): Promise<StoredAgentRecord>;
closeAgent(agentId: string): Promise<void>;
setLabels(agentId: string, labels: Record<string, string>): Promise<void>;
notifyAgentState(agentId: string): void;
setAgentMode(agentId: string, modeId: string): Promise<void>;
updateAgentMetadata(
agentId: string,
updates: {
title?: string;
labels?: Record<string, string>;
},
): Promise<void>;
}
export interface LifecycleAgentStorage {
get(agentId: string): Promise<StoredAgentRecord | null>;
upsert(record: StoredAgentRecord): Promise<void>;
}
export interface AgentLifecycleCommandDependencies {
agentManager: LifecycleAgentManager;
agentStorage: LifecycleAgentStorage;
logger: Logger;
}
export interface CancelAgentRunResult {
agent: LifecycleAgentSnapshot;
cancelled: boolean;
}
export async function cancelAgentRunCommand(
dependencies: Pick<AgentLifecycleCommandDependencies, "agentManager" | "logger">,
agentId: string,
): Promise<CancelAgentRunResult> {
const { agentManager, logger } = dependencies;
const agent = agentManager.getAgent(agentId);
if (!agent) {
logger.trace({ agentId }, "cancelAgentRunCommand: agent not found");
throw new Error(`Agent ${agentId} not found`);
}
const hasInFlightRun = agentManager.hasInFlightRun(agentId);
if (!hasInFlightRun) {
logger.trace(
{ agentId, lifecycle: agent.lifecycle, hasInFlightRun },
"cancelAgentRunCommand: skipping because agent is not running",
);
return { agent, cancelled: false };
}
logger.debug(
{ agentId, lifecycle: agent.lifecycle, hasInFlightRun },
"cancelAgentRunCommand: interrupting",
);
const startedAt = Date.now();
const cancelled = await agentManager.cancelAgentRun(agentId);
logger.debug(
{ agentId, cancelled, durationMs: Date.now() - startedAt },
"cancelAgentRunCommand: cancelAgentRun completed",
);
if (!cancelled) {
logger.warn(
{ agentId },
"cancelAgentRunCommand: reported running but no active run was cancelled",
);
}
return {
agent,
cancelled,
};
}
export interface ArchiveAgentResult {
agentId: string;
archivedAt: string;
record: StoredAgentRecord;
}
export async function archiveAgentCommand(
dependencies: AgentLifecycleCommandDependencies,
agentId: string,
): Promise<ArchiveAgentResult> {
const liveAgent = dependencies.agentManager.getAgent(agentId);
if (liveAgent) {
await cancelAgentRunCommand(dependencies, agentId);
await dependencies.agentManager.clearAgentAttention(agentId).catch(() => undefined);
await dependencies.agentManager.archiveAgent(agentId);
} else {
await archiveStoredAgent(dependencies, agentId);
}
const record = await dependencies.agentStorage.get(agentId);
if (!record) {
throw new Error(`Agent not found in storage after archive: ${agentId}`);
}
if (!record.archivedAt) {
throw new Error(`Agent missing archivedAt after archive: ${agentId}`);
}
return {
agentId,
archivedAt: record.archivedAt,
record,
};
}
export async function closeAgentCommand(
dependencies: Pick<AgentLifecycleCommandDependencies, "agentManager">,
agentId: string,
): Promise<void> {
await dependencies.agentManager.closeAgent(agentId);
}
export interface UpdateAgentResult {
accepted: boolean;
error: string | null;
}
export async function updateAgentCommand(
dependencies: Pick<AgentLifecycleCommandDependencies, "agentManager">,
input: {
agentId: string;
name?: string;
labels?: Record<string, string>;
},
): Promise<UpdateAgentResult> {
const title = input.name?.trim();
const labels = input.labels && Object.keys(input.labels).length > 0 ? input.labels : undefined;
if (!title && !labels) {
return {
accepted: false,
error: "Nothing to update (provide name and/or labels)",
};
}
await dependencies.agentManager.updateAgentMetadata(input.agentId, {
...(title ? { title } : {}),
...(labels ? { labels } : {}),
});
return {
accepted: true,
error: null,
};
}
export async function setAgentModeCommand(
dependencies: Pick<AgentLifecycleCommandDependencies, "agentManager">,
input: {
agentId: string;
modeId: string;
},
): Promise<{ modeId: string }> {
await dependencies.agentManager.setAgentMode(input.agentId, input.modeId);
return { modeId: input.modeId };
}
async function archiveStoredAgent(
dependencies: Pick<AgentLifecycleCommandDependencies, "agentManager" | "agentStorage">,
agentId: string,
): Promise<void> {
const existing = await dependencies.agentStorage.get(agentId);
if (!existing) {
throw new Error(`Agent not found: ${agentId}`);
}
if (existing.archivedAt) {
return;
}
const archivedAt = new Date().toISOString();
await dependencies.agentManager.archiveSnapshot(agentId, archivedAt);
}
@@ -1,7 +1,7 @@
import { execFileSync } from "node:child_process";
import { describe, expect, it, vi } from "vitest";
import { realpathSync } from "node:fs";
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
import { access, mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
import { join, resolve as resolvePath } from "node:path";
import { tmpdir } from "node:os";
import Ajv from "ajv";
@@ -146,6 +146,7 @@ function buildAgentManagerSpies() {
setAgentFeature: vi.fn().mockResolvedValue(undefined),
setLabels: vi.fn().mockResolvedValue(undefined),
setTitle: vi.fn().mockResolvedValue(undefined),
updateAgentMetadata: vi.fn().mockResolvedValue(undefined),
archiveAgent: vi.fn().mockResolvedValue({ archivedAt: new Date().toISOString() }),
notifyAgentState: vi.fn(),
getAgent: vi.fn(),
@@ -560,6 +561,7 @@ describe("create_agent MCP tool", () => {
const { agentManager, agentStorage, spies } = createTestDeps();
spies.agentManager.createAgent.mockResolvedValue({
id: "mode-agent",
provider: "codex",
cwd: REPO_CWD,
lifecycle: "idle",
currentModeId: "build",
@@ -1282,6 +1284,82 @@ describe("create_agent MCP tool", () => {
}
});
it("archives a worktree by slug", async () => {
const { agentManager, agentStorage } = createTestDeps();
const tempDir = realpathSync.native(
await mkdtemp(join(tmpdir(), "paseo-mcp-archive-worktree-slug-")),
);
const repoDir = join(tempDir, "repo");
const paseoHome = join(tempDir, ".paseo");
try {
execFileSync("git", ["init", repoDir], { stdio: "pipe" });
execFileSync("git", ["config", "user.email", "test@example.com"], {
cwd: repoDir,
stdio: "pipe",
});
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["config", "commit.gpgsign", "false"], {
cwd: repoDir,
stdio: "pipe",
});
await writeFile(join(repoDir, "README.md"), "hello\n");
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" });
const workspaceGitService = {
getSnapshot: vi.fn(async () => null),
listWorktrees: vi.fn(async () => []),
resolveRepoRoot: vi.fn(async () => repoDir),
};
const server = await createAgentMcpServer({
agentManager,
agentStorage,
paseoHome,
createPaseoWorktree: createPaseoWorktreeForMcpTest({ paseoHome, broadcasts: [] }),
workspaceGitService: workspaceGitService as unknown as Pick<
WorkspaceGitService,
"getSnapshot" | "listWorktrees" | "resolveRepoRoot"
>,
archiveWorkspaceRecord: vi.fn(async () => undefined),
emitWorkspaceUpdatesForWorkspaceIds: vi.fn(async () => undefined),
markWorkspaceArchiving: vi.fn(),
clearWorkspaceArchiving: vi.fn(),
emitSessionMessage: vi.fn(),
github: createGitHubServiceStub(),
logger,
});
const createTool = registeredTool(server, "create_worktree");
const archiveTool = registeredTool(server, "archive_worktree");
const created = await createTool.handler({
cwd: repoDir,
target: { mode: "branch-off", newBranch: "archive-slug-worktree", base: "main" },
});
const response = await archiveTool.handler({
cwd: repoDir,
worktreeSlug: "archive-slug-worktree",
});
expect(response.structuredContent).toEqual({ success: true });
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith(repoDir, {
force: true,
reason: "archive-worktree",
});
expect(workspaceGitService.resolveRepoRoot).toHaveBeenCalledWith(repoDir);
expect(workspaceGitService.listWorktrees).toHaveBeenCalledWith(repoDir, {
force: true,
reason: "mcp:archive-worktree",
});
await expect(
access(z.string().parse(created.structuredContent.worktreePath)),
).rejects.toThrow();
} finally {
await rm(tempDir, { recursive: true, force: true });
}
});
it("routes list_worktrees through WorkspaceGitService", async () => {
const { agentManager, agentStorage } = createTestDeps();
const workspaceGitService = {
@@ -1677,7 +1755,6 @@ describe("update_agent MCP tool", () => {
it("updates runtime settings before metadata", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
spies.agentStorage.get.mockResolvedValue(createStoredRecord({ id: "agent-1" }));
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
const tool = registeredTool(server, "update_agent");
const input = {
@@ -1701,16 +1778,28 @@ describe("update_agent MCP tool", () => {
expect(spies.agentManager.setAgentModel).toHaveBeenCalledWith("agent-1", "gpt-5.4");
expect(spies.agentManager.setAgentThinkingOption).toHaveBeenCalledWith("agent-1", "high");
expect(spies.agentManager.setAgentFeature).toHaveBeenCalledWith("agent-1", "fast_mode", true);
expect(spies.agentStorage.upsert).toHaveBeenCalledWith(
expect.objectContaining({
id: "agent-1",
title: "Updated agent",
}),
);
expect(spies.agentManager.setLabels).toHaveBeenCalledWith("agent-1", { role: "worker" });
expect(spies.agentManager.updateAgentMetadata).toHaveBeenCalledWith("agent-1", {
title: "Updated agent",
labels: { role: "worker" },
});
expect(response.structuredContent).toEqual({ success: true });
});
it("reports success for a no-op update with neither metadata nor settings", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
const tool = registeredTool(server, "update_agent");
const response = await tool.handler({ agentId: "agent-1" });
expect(response.structuredContent).toEqual({ success: true });
expect(spies.agentManager.updateAgentMetadata).not.toHaveBeenCalled();
expect(spies.agentManager.setAgentMode).not.toHaveBeenCalled();
expect(spies.agentManager.setAgentModel).not.toHaveBeenCalled();
expect(spies.agentManager.setAgentThinkingOption).not.toHaveBeenCalled();
expect(spies.agentManager.setAgentFeature).not.toHaveBeenCalled();
});
it("does not update metadata when runtime settings fail", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
spies.agentManager.setAgentFeature.mockRejectedValue(new Error("unsupported feature"));
@@ -1727,8 +1816,7 @@ describe("update_agent MCP tool", () => {
).rejects.toThrow("unsupported feature");
expect(spies.agentStorage.get).not.toHaveBeenCalled();
expect(spies.agentStorage.upsert).not.toHaveBeenCalled();
expect(spies.agentManager.setLabels).not.toHaveBeenCalled();
expect(spies.agentManager.updateAgentMetadata).not.toHaveBeenCalled();
});
});
+211 -460
View File
@@ -10,7 +10,7 @@ import type {
} from "@modelcontextprotocol/sdk/types.js";
import type { AgentProvider } from "./agent-sdk-types.js";
import type { AgentManager, ManagedAgent, WaitForAgentResult } from "./agent-manager.js";
import type { AgentManager, WaitForAgentResult } from "./agent-manager.js";
import {
AgentFeatureSchema,
AgentPermissionRequestPayloadSchema,
@@ -30,27 +30,15 @@ import type { AgentStorage } from "./agent-storage.js";
import { ensureAgentLoaded } from "./agent-loading.js";
import { isStoredAgentProviderAvailable } from "../persistence-hooks.js";
import {
appendTimelineItemIfAgentKnown,
emitLiveTimelineItemIfAgentKnown,
} from "./timeline-append.js";
import { getPaseoWorktreesRoot } from "../../utils/worktree.js";
import {
archivePaseoWorktree,
killTerminalsUnderPath,
type ArchivePaseoWorktreeDependencies,
} from "../paseo-worktree-archive-service.js";
import { WaitForAgentTracker } from "./wait-for-agent-tracker.js";
import { scheduleAgentMetadataGeneration } from "./agent-metadata-generator.js";
import { createAgentCommand } from "./create-agent/create.js";
import type { VoiceCallerContext, VoiceSpeakHandler } from "../voice-types.js";
import { expandUserPath, isSameOrDescendantPath, resolvePathFromBase } from "../path-utils.js";
import { PARENT_AGENT_ID_LABEL } from "../../shared/agent-labels.js";
import type { TerminalManager } from "../../terminal/terminal-manager.js";
import type {
AgentWorktreeSetupContinuation,
CreatePaseoWorktreeSetupContinuationInput,
CreatePaseoWorktreeWorkflowFn,
CreatePaseoWorktreeWorkflowResult,
} from "../worktree-session.js";
import type { CreatePaseoWorktreeWorkflowFn } from "../worktree-session.js";
import type { ScheduleService } from "../schedule/service.js";
import {
ScheduleRunSchema,
@@ -59,8 +47,6 @@ import {
} from "../schedule/types.js";
import type { ScheduleCadence, UpdateScheduleInput } from "../schedule/types.js";
import type { ProviderDefinition } from "./provider-registry.js";
import { getAgentProviderDefinition } from "./provider-manifest.js";
import { resolveAndValidateCreateAgentMode } from "./create-agent-mode.js";
import { resolveSnapshotCwd } from "./provider-snapshot-manager.js";
import {
AgentModelSchema,
@@ -75,16 +61,25 @@ import {
toScheduleSummary,
waitForAgentWithTimeout,
} from "./mcp-shared.js";
import { sendPromptToAgent, setupFinishNotification } from "./agent-prompt.js";
import { respondToAgentPermission } from "./permission-response.js";
import {
sendPromptToAgent,
setupFinishNotification,
startCreatedAgentInitialPrompt,
} from "./agent-prompt.js";
archiveAgentCommand,
cancelAgentRunCommand,
closeAgentCommand,
setAgentModeCommand,
updateAgentCommand,
} from "./lifecycle-command.js";
import type { GitHubService } from "../../services/github-service.js";
import type { WorkspaceGitService } from "../workspace-git-service.js";
import type { CreatePaseoWorktreeInput } from "../paseo-worktree-service.js";
import { toWorktreeRequestError } from "../worktree-errors.js";
import { join } from "node:path";
import { WorktreeRequestError } from "../worktree-errors.js";
import {
archivePaseoWorktreeCommand,
type ArchivePaseoWorktreeCommandDependencies,
createPaseoWorktreeCommand,
type CreatePaseoWorktreeCommandInput,
listPaseoWorktreesCommand,
} from "../worktree/commands.js";
export interface AgentMcpServerOptions {
agentManager: AgentManager;
@@ -134,42 +129,6 @@ const CODEX_TO_CLAUDE_MODE: Record<string, string> = {
"full-access": "bypassPermissions",
};
const OPENCODE_PROVIDER_ID = "opencode";
const OPENCODE_BUILD_MODE_ID = "build";
const OPENCODE_LEGACY_FULL_ACCESS_MODE_ID = "full-access";
const OPENCODE_AUTO_ACCEPT_FEATURE_ID = "auto_accept";
function isOpenCodeLegacyFullAccessMode(
provider: AgentProvider,
modeId: string | undefined,
): boolean {
return provider === OPENCODE_PROVIDER_ID && modeId === OPENCODE_LEGACY_FULL_ACCESS_MODE_ID;
}
function withOpenCodeAutoAcceptFeature(
features: Record<string, unknown> | undefined,
enabled: boolean,
): Record<string, unknown> {
return {
...features,
[OPENCODE_AUTO_ACCEPT_FEATURE_ID]: enabled,
};
}
function hasOpenCodeAutoAcceptFeature(agent: ManagedAgent): boolean {
if (agent.provider !== OPENCODE_PROVIDER_ID) {
return false;
}
return (
agent.features?.some(
(feature) =>
feature.id === OPENCODE_AUTO_ACCEPT_FEATURE_ID &&
feature.type === "toggle" &&
feature.value === true,
) === true || agent.config.featureValues?.[OPENCODE_AUTO_ACCEPT_FEATURE_ID] === true
);
}
function mapModeAcrossProviders(
sourceMode: string,
sourceProvider: AgentProvider,
@@ -894,6 +853,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
"Draft provider settings used to compute available features.",
),
};
type TopLevelCreateAgentArgs = z.infer<typeof topLevelCreateAgentArgsSchema>;
if (options.voiceOnly || options.enableVoiceTools || callerContext?.enableVoiceTools) {
registerTool(
@@ -938,208 +898,6 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
return server;
}
interface ResolvedCreateAgentArgs {
provider: AgentProvider;
initialPrompt: string;
background: boolean;
normalizedTitle: string | null;
model: string | undefined;
thinkingOptionId: string | undefined;
features: Record<string, unknown> | undefined;
labels: Record<string, string> | undefined;
notifyOnFinish: boolean;
resolvedCwd: string;
resolvedMode: string | undefined;
setupContinuation: AgentWorktreeSetupContinuation | undefined;
}
const getProviderModes = (provider: AgentProvider) => {
const fromRegistry = providerRegistry?.[provider];
if (fromRegistry) {
return fromRegistry.modes;
}
try {
return getAgentProviderDefinition(provider).modes;
} catch {
return undefined;
}
};
const getAvailableModeIds = (provider: AgentProvider): string[] | undefined => {
return getProviderModes(provider)?.map((mode) => mode.id);
};
const getUnattendedModeId = (provider: AgentProvider): string | undefined => {
return getProviderModes(provider)?.find((mode) => mode.isUnattended)?.id;
};
const isParentInUnattendedMode = (provider: AgentProvider, modeId: string | null): boolean => {
if (modeId === null) return false;
const modes = getProviderModes(provider);
if (!modes) return false;
return modes.some((mode) => mode.id === modeId && mode.isUnattended === true);
};
const isAgentInUnattendedState = (agent: ManagedAgent): boolean => {
return (
isParentInUnattendedMode(agent.provider, agent.currentModeId) ||
hasOpenCodeAutoAcceptFeature(agent)
);
};
const resolveCreateModeAndFeatures = (input: {
provider: AgentProvider;
requestedMode: string | undefined;
parent: { provider: AgentProvider; modeId: string | null; isUnattended: boolean } | null;
features: Record<string, unknown> | undefined;
}): { mode: string | undefined; features: Record<string, unknown> | undefined } => {
const legacyOpenCodeFullAccess = isOpenCodeLegacyFullAccessMode(
input.provider,
input.requestedMode,
);
const inheritsOpenCodeUnattended =
input.provider === OPENCODE_PROVIDER_ID &&
input.requestedMode === undefined &&
input.parent?.isUnattended === true;
const inheritsOpenCodeAutoAccept =
inheritsOpenCodeUnattended && input.features?.[OPENCODE_AUTO_ACCEPT_FEATURE_ID] === undefined;
const requestedMode = legacyOpenCodeFullAccess ? OPENCODE_BUILD_MODE_ID : input.requestedMode;
const features =
legacyOpenCodeFullAccess || inheritsOpenCodeAutoAccept
? withOpenCodeAutoAcceptFeature(input.features, true)
: input.features;
const mode =
inheritsOpenCodeUnattended && requestedMode === undefined
? OPENCODE_BUILD_MODE_ID
: resolveAndValidateCreateAgentMode({
requestedMode,
targetProvider: input.provider,
parent: input.parent,
availableModes: getAvailableModeIds(input.provider),
targetUnattendedMode: getUnattendedModeId(input.provider),
});
return { mode, features };
};
const resolveCallerCreateAgentArgs = (
args: unknown,
parentAgentId: string,
): ResolvedCreateAgentArgs => {
const callerArgs = agentToAgentCreateAgentArgsSchema.parse(args);
const resolvedProviderModel = resolveRequiredProviderModel(callerArgs.provider);
const parentAgent = agentManager.getAgent(parentAgentId);
if (!parentAgent) {
throw new Error(`Parent agent ${parentAgentId} not found`);
}
const provider = resolvedProviderModel.provider;
const settings = callerArgs.settings;
const resolvedCwd = resolveChildAgentCwd({
parentCwd: parentAgent.cwd,
requestedCwd: callerArgs.cwd,
lockedCwd: callerContext?.lockedCwd,
allowCustomCwd: callerContext?.allowCustomCwd ?? true,
});
const resolvedRuntime = resolveCreateModeAndFeatures({
provider,
requestedMode: settings?.modeId,
parent: {
provider: parentAgent.provider,
modeId: parentAgent.currentModeId,
isUnattended: isAgentInUnattendedState(parentAgent),
},
features: settings?.features,
});
return {
provider,
initialPrompt: callerArgs.initialPrompt,
background: callerArgs.background ?? false,
normalizedTitle: callerArgs.title.trim(),
model: resolvedProviderModel.model,
thinkingOptionId: settings?.thinkingOptionId,
features: resolvedRuntime.features,
labels: callerArgs.labels,
notifyOnFinish: callerArgs.notifyOnFinish ?? false,
resolvedCwd,
resolvedMode: resolvedRuntime.mode,
setupContinuation: undefined,
};
};
const resolveTopLevelCreateAgentArgs = async (
args: unknown,
): Promise<ResolvedCreateAgentArgs> => {
const topLevelArgs = topLevelCreateAgentArgsSchema.parse(args);
const resolvedProviderModel = resolveRequiredProviderModel(topLevelArgs.provider);
const { cwd, settings, worktreeName, baseBranch, refName, action, githubPrNumber } =
topLevelArgs;
const resolvedRuntime = resolveCreateModeAndFeatures({
provider: resolvedProviderModel.provider,
requestedMode: settings?.modeId,
parent: null,
features: settings?.features,
});
let resolvedCwd = expandUserPath(cwd);
let setupContinuation: AgentWorktreeSetupContinuation | undefined;
const shouldCreateWorktree = Boolean(worktreeName || refName || action || githubPrNumber);
if (shouldCreateWorktree) {
if (worktreeName && !baseBranch && !refName && !action && githubPrNumber === undefined) {
throw new Error("baseBranch is required when creating a worktree");
}
const createdWorktree = await createMcpWorktree({
input: {
cwd: resolvedCwd,
worktreeSlug: worktreeName,
refName,
action,
githubPrNumber,
...(topLevelArgs.initialPrompt
? { firstAgentContext: { prompt: topLevelArgs.initialPrompt } }
: {}),
runSetup: false,
paseoHome: options.paseoHome,
},
createPaseoWorktree: options.createPaseoWorktree,
resolveDefaultBranch: baseBranch ? async () => baseBranch : undefined,
setupContinuation: {
kind: "agent",
terminalManager: terminalManager ?? null,
appendTimelineItem: ({ agentId, item }) =>
appendTimelineItemIfAgentKnown({
agentManager,
agentId,
item,
}),
emitLiveTimelineItem: ({ agentId, item }) =>
emitLiveTimelineItemIfAgentKnown({
agentManager,
agentId,
item,
}),
logger: childLogger,
},
});
resolvedCwd = createdWorktree.worktree.worktreePath;
setupContinuation = createdWorktree.setupContinuation;
}
return {
provider: resolvedProviderModel.provider,
initialPrompt: topLevelArgs.initialPrompt,
background: topLevelArgs.background ?? false,
normalizedTitle: topLevelArgs.title.trim(),
model: resolvedProviderModel.model,
thinkingOptionId: settings?.thinkingOptionId,
features: resolvedRuntime.features,
labels: topLevelArgs.labels,
notifyOnFinish: topLevelArgs.notifyOnFinish ?? false,
resolvedCwd,
resolvedMode: resolvedRuntime.mode,
setupContinuation,
};
};
registerTool(
"create_agent",
{
@@ -1159,88 +917,46 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
},
async (args: unknown) => {
const resolved = callerAgentId
? resolveCallerCreateAgentArgs(args, callerAgentId)
: await resolveTopLevelCreateAgentArgs(args);
const {
provider,
initialPrompt,
background,
normalizedTitle,
model,
thinkingOptionId,
features,
labels,
notifyOnFinish,
resolvedCwd,
resolvedMode,
setupContinuation,
} = resolved;
const childAgentDefaultLabels = callerContext?.childAgentDefaultLabels;
const mergedLabels = {
...(callerAgentId ? { [PARENT_AGENT_ID_LABEL]: callerAgentId } : {}),
...childAgentDefaultLabels,
...labels,
};
const snapshot = await agentManager.createAgent(
const { parsedArgs, worktree } = resolveCreateAgentToolArgs(args);
const { snapshot, background, initialPromptStarted } = await createAgentCommand(
{
provider,
cwd: resolvedCwd,
modeId: resolvedMode,
title: normalizedTitle ?? undefined,
model,
thinkingOptionId,
featureValues: features,
agentManager,
agentStorage,
logger: childLogger,
paseoHome: options.paseoHome,
workspaceGitService: options.workspaceGitService,
terminalManager,
providerRegistry,
createPaseoWorktree: options.createPaseoWorktree,
},
{
kind: "mcp",
provider: parsedArgs.provider,
title: parsedArgs.title,
initialPrompt: parsedArgs.initialPrompt,
cwd: parsedArgs.cwd,
thinking: parsedArgs.settings?.thinkingOptionId,
features: parsedArgs.settings?.features,
labels: parsedArgs.labels,
mode: parsedArgs.settings?.modeId,
background: parsedArgs.background ?? false,
notifyOnFinish: parsedArgs.notifyOnFinish ?? false,
callerAgentId,
callerContext,
worktree,
},
undefined,
Object.keys(mergedLabels).length > 0 ? { labels: mergedLabels } : undefined,
);
setupContinuation?.startAfterAgentCreate({
agentId: snapshot.id,
});
const trimmedPrompt = initialPrompt.trim();
scheduleAgentMetadataGeneration({
agentManager,
agentId: snapshot.id,
cwd: snapshot.cwd,
workspaceGitService: options.workspaceGitService,
initialPrompt: trimmedPrompt,
explicitTitle: snapshot.config.title,
paseoHome: options.paseoHome,
logger: childLogger,
});
let liveSnapshot = snapshot;
try {
liveSnapshot = await startCreatedAgentInitialPrompt({
agentManager,
agentId: snapshot.id,
snapshot,
prompt: trimmedPrompt,
logger: childLogger,
});
if (notifyOnFinish && callerAgentId) {
setupFinishNotification({
agentManager,
agentStorage,
childAgentId: snapshot.id,
callerAgentId,
logger: childLogger,
});
}
// If not running in background, wait for completion
if (!background) {
if (!background && initialPromptStarted) {
const result = await waitForAgentWithTimeout(agentManager, snapshot.id, {
waitForActive: true,
});
const liveSnapshot = agentManager.getAgent(snapshot.id) ?? snapshot;
const responseData = {
agentId: liveSnapshot.id,
type: provider,
agentId: snapshot.id,
type: snapshot.provider,
status: result.status,
cwd: liveSnapshot.cwd,
currentModeId: liveSnapshot.currentModeId,
@@ -1262,12 +978,12 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
}
// Return immediately if background=true
const currentSnapshot = agentManager.getAgent(snapshot.id) ?? liveSnapshot;
const currentSnapshot = agentManager.getAgent(snapshot.id) ?? snapshot;
const response = {
content: [],
structuredContent: ensureValidJson({
agentId: currentSnapshot.id,
type: provider,
type: snapshot.provider,
status: currentSnapshot.lifecycle,
cwd: currentSnapshot.cwd,
currentModeId: currentSnapshot.currentModeId,
@@ -1280,6 +996,43 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
);
function resolveCreateAgentToolArgs(args: unknown): {
parsedArgs:
| z.infer<typeof agentToAgentCreateAgentArgsSchema>
| z.infer<typeof topLevelCreateAgentArgsSchema>;
worktree: ReturnType<typeof resolveTopLevelCreateAgentWorktree>;
} {
if (callerAgentId) {
return {
parsedArgs: agentToAgentCreateAgentArgsSchema.parse(args),
worktree: undefined,
};
}
const parsedArgs = topLevelCreateAgentArgsSchema.parse(args);
return {
parsedArgs,
worktree: resolveTopLevelCreateAgentWorktree(parsedArgs),
};
}
function resolveTopLevelCreateAgentWorktree(args: TopLevelCreateAgentArgs):
| {
worktreeName?: string;
baseBranch?: string;
refName?: string;
action?: "branch-off" | "checkout";
githubPrNumber?: number;
}
| undefined {
return {
worktreeName: args.worktreeName,
baseBranch: args.baseBranch,
refName: args.refName,
action: args.action,
githubPrNumber: args.githubPrNumber,
};
}
registerTool(
"wait_for_agent",
{
@@ -1579,13 +1332,16 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
},
async ({ agentId }) => {
const success = await agentManager.cancelAgentRun(agentId);
if (success) {
const { cancelled } = await cancelAgentRunCommand(
{ agentManager, logger: childLogger },
agentId,
);
if (cancelled) {
waitTracker.cancel(agentId, "Agent run cancelled");
}
return {
content: [],
structuredContent: ensureValidJson({ success }),
structuredContent: ensureValidJson({ success: cancelled }),
};
},
);
@@ -1604,7 +1360,14 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
},
async ({ agentId }) => {
await agentManager.archiveAgent(agentId);
await archiveAgentCommand(
{
agentManager,
agentStorage,
logger: childLogger,
},
agentId,
);
waitTracker.cancel(agentId, "Agent archived");
return {
content: [],
@@ -1626,7 +1389,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
},
async ({ agentId }) => {
await agentManager.closeAgent(agentId);
await closeAgentCommand({ agentManager }, agentId);
waitTracker.cancel(agentId, "Agent terminated");
return {
content: [],
@@ -1668,23 +1431,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
}
}
const trimmedName = name?.trim();
if (trimmedName) {
const record = await agentStorage.get(agentId);
if (!record) {
throw new Error(`Agent ${agentId} not found`);
}
await agentStorage.upsert({
...record,
title: trimmedName,
updatedAt: new Date().toISOString(),
});
agentManager.notifyAgentState(agentId);
}
if (labels) {
await agentManager.setLabels(agentId, labels);
}
await updateAgentCommand({ agentManager }, { agentId, name, labels });
return {
content: [],
@@ -2326,9 +2073,13 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
if (!options.workspaceGitService) {
throw new Error("WorkspaceGitService is required to list worktrees");
}
const worktrees = await options.workspaceGitService.listWorktrees(resolvedCwd, {
reason: "mcp:list-worktrees",
});
const worktrees = await listPaseoWorktreesCommand(
{ workspaceGitService: options.workspaceGitService },
{
cwd: resolvedCwd,
reason: "mcp:list-worktrees",
},
);
return {
content: [],
@@ -2380,13 +2131,17 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
async ({ cwd, target }) => {
const repoRoot = resolveScopedCwd(cwd, { required: true });
const mcpInput = mcpCreateWorktreeInput(repoRoot, target, options.paseoHome);
const createdWorktree = await createMcpWorktree({
input: mcpInput.input,
createPaseoWorktree: options.createPaseoWorktree,
resolveDefaultBranch: mcpInput.resolveDefaultBranch,
});
const { worktree } = createdWorktree;
const commandResult = await createPaseoWorktreeCommand(
{
paseoHome: options.paseoHome,
createPaseoWorktreeWorkflow: options.createPaseoWorktree,
},
createMcpWorktreeCommandInput(repoRoot, target),
);
if (!commandResult.ok) {
throw new WorktreeRequestError(commandResult.error);
}
const { worktree } = commandResult.createdWorktree;
await options.workspaceGitService?.listWorktrees?.(repoRoot, {
force: true,
reason: "mcp:create-worktree",
@@ -2424,64 +2179,28 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
if (!worktreePath && !worktreeSlug) {
throw new Error("worktreePath or worktreeSlug is required");
}
if (!options.github) {
throw new Error("GitHub service is required to archive worktrees");
}
if (!options.workspaceGitService) {
throw new Error("WorkspaceGitService is required to archive worktrees");
}
if (!options.archiveWorkspaceRecord) {
throw new Error("Workspace registry archiver is required to archive worktrees");
}
if (!options.emitWorkspaceUpdatesForWorkspaceIds) {
throw new Error("Workspace update emitter is required to archive worktrees");
}
if (!options.markWorkspaceArchiving) {
throw new Error("Workspace archiving marker is required to archive worktrees");
}
if (!options.clearWorkspaceArchiving) {
throw new Error("Workspace archiving clearer is required to archive worktrees");
}
if (!options.emitSessionMessage) {
throw new Error("Session message emitter is required to archive worktrees");
}
const repoRoot = await options.workspaceGitService.resolveRepoRoot(resolvedCwd);
const targetPath =
worktreePath ??
join(await getPaseoWorktreesRoot(repoRoot, options.paseoHome), worktreeSlug!);
await archivePaseoWorktree(
{
paseoHome: options.paseoHome,
github: options.github,
workspaceGitService: options.workspaceGitService,
const result = await archivePaseoWorktreeCommand(
archiveWorktreeDependencies(options, {
agentManager,
agentStorage,
archiveWorkspaceRecord: options.archiveWorkspaceRecord,
emit: options.emitSessionMessage,
emitWorkspaceUpdatesForWorkspaceIds: options.emitWorkspaceUpdatesForWorkspaceIds,
markWorkspaceArchiving: options.markWorkspaceArchiving,
clearWorkspaceArchiving: options.clearWorkspaceArchiving,
isPathWithinRoot: isSameOrDescendantPath,
killTerminalsUnderPath: (rootPath) =>
killTerminalsUnderPath(
{
terminalManager: terminalManager ?? null,
isPathWithinRoot: isSameOrDescendantPath,
killTrackedTerminal: () => {},
sessionLogger: childLogger,
},
rootPath,
),
sessionLogger: childLogger,
},
terminalManager: terminalManager ?? null,
logger: childLogger,
}),
{
targetPath,
repoRoot,
requestId: "mcp:archive_worktree",
repoRoot,
worktreePath,
worktreeSlug,
},
);
if (!result.ok) {
throw new Error(result.message);
}
await options.workspaceGitService.listWorktrees(repoRoot, {
force: true,
reason: "mcp:archive-worktree",
@@ -2566,10 +2285,10 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
},
async ({ agentId, modeId }) => {
await agentManager.setAgentMode(agentId, modeId);
const result = await setAgentModeCommand({ agentManager }, { agentId, modeId });
return {
content: [],
structuredContent: ensureValidJson({ success: true, newMode: modeId }),
structuredContent: ensureValidJson({ success: true, newMode: result.modeId }),
};
},
);
@@ -2624,7 +2343,13 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
},
async ({ agentId, requestId, response }) => {
await agentManager.respondToPermission(agentId, requestId, response);
await respondToAgentPermission({
agentManager,
agentId,
requestId,
response,
logger: childLogger,
});
return {
content: [],
structuredContent: ensureValidJson({ success: true }),
@@ -2640,57 +2365,83 @@ type McpCreateWorktreeTarget =
| { mode: "checkout-branch"; branch: string }
| { mode: "checkout-pr"; prNumber: number };
function mcpCreateWorktreeInput(
interface ArchiveWorktreeCommandContext {
agentManager: AgentManager;
agentStorage: AgentStorage;
terminalManager: TerminalManager | null;
logger: Logger;
}
function archiveWorktreeDependencies(
options: AgentMcpServerOptions,
context: ArchiveWorktreeCommandContext,
): ArchivePaseoWorktreeCommandDependencies {
if (!options.github) {
throw new Error("GitHub service is required to archive worktrees");
}
if (!options.workspaceGitService) {
throw new Error("WorkspaceGitService is required to archive worktrees");
}
if (!options.archiveWorkspaceRecord) {
throw new Error("Workspace registry archiver is required to archive worktrees");
}
if (!options.emitWorkspaceUpdatesForWorkspaceIds) {
throw new Error("Workspace update emitter is required to archive worktrees");
}
if (!options.markWorkspaceArchiving) {
throw new Error("Workspace archiving marker is required to archive worktrees");
}
if (!options.clearWorkspaceArchiving) {
throw new Error("Workspace archiving clearer is required to archive worktrees");
}
if (!options.emitSessionMessage) {
throw new Error("Session message emitter is required to archive worktrees");
}
return {
paseoHome: options.paseoHome,
github: options.github,
workspaceGitService: options.workspaceGitService,
agentManager: context.agentManager,
agentStorage: context.agentStorage,
archiveWorkspaceRecord: options.archiveWorkspaceRecord,
emit: options.emitSessionMessage,
emitWorkspaceUpdatesForWorkspaceIds: options.emitWorkspaceUpdatesForWorkspaceIds,
markWorkspaceArchiving: options.markWorkspaceArchiving,
clearWorkspaceArchiving: options.clearWorkspaceArchiving,
isPathWithinRoot: isSameOrDescendantPath,
killTerminalsUnderPath: (rootPath: string) =>
killTerminalsUnderPath(
{
terminalManager: context.terminalManager,
isPathWithinRoot: isSameOrDescendantPath,
killTrackedTerminal: () => {},
sessionLogger: context.logger,
},
rootPath,
),
sessionLogger: context.logger,
};
}
function createMcpWorktreeCommandInput(
repoRoot: string,
target: McpCreateWorktreeTarget,
paseoHome: string | undefined,
): { input: CreatePaseoWorktreeInput; resolveDefaultBranch?: (root: string) => Promise<string> } {
const base = { cwd: repoRoot, runSetup: false, paseoHome } as const;
): CreatePaseoWorktreeCommandInput {
const base = { cwd: repoRoot } as const;
switch (target.mode) {
case "branch-off":
return {
input: {
...base,
worktreeSlug: target.newBranch,
action: "branch-off",
...(target.base ? { refName: target.base } : {}),
},
...base,
worktreeSlug: target.newBranch,
action: "branch-off",
...(target.base ? { refName: target.base } : {}),
};
case "checkout-branch":
return {
input: { ...base, action: "checkout", refName: target.branch },
};
return { ...base, action: "checkout", refName: target.branch };
case "checkout-pr":
return {
input: { ...base, action: "checkout", githubPrNumber: target.prNumber },
};
return { ...base, action: "checkout", githubPrNumber: target.prNumber };
default:
throw new Error("unreachable");
}
}
interface CreateMcpWorktreeOptions {
input: CreatePaseoWorktreeInput;
createPaseoWorktree: CreatePaseoWorktreeWorkflowFn | undefined;
resolveDefaultBranch?: (repoRoot: string) => Promise<string>;
setupContinuation?: CreatePaseoWorktreeSetupContinuationInput;
}
async function createMcpWorktree(
options: CreateMcpWorktreeOptions,
): Promise<CreatePaseoWorktreeWorkflowResult> {
try {
if (!options.createPaseoWorktree) {
throw new Error("Paseo worktree service is not configured");
}
const result = await options.createPaseoWorktree(options.input, {
...(options.resolveDefaultBranch
? { resolveDefaultBranch: options.resolveDefaultBranch }
: {}),
...(options.setupContinuation ? { setupContinuation: options.setupContinuation } : {}),
});
return result;
} catch (error) {
throw toWorktreeRequestError(error);
}
}
@@ -0,0 +1,142 @@
import { describe, expect, test } from "vitest";
import { createTestLogger } from "../../test-utils/test-logger.js";
import type {
AgentPromptInput,
AgentPermissionResult,
AgentRunOptions,
AgentPermissionResponse,
} from "./agent-sdk-types.js";
import type { AgentStreamEvent } from "../messages.js";
import { respondToAgentPermission } from "./permission-response.js";
class FakePermissionAgentManager {
permissionResult: AgentPermissionResult | void;
hasRunInFlight = false;
outOfBandHandled = false;
permissionResponses: Array<{
agentId: string;
requestId: string;
response: AgentPermissionResponse;
}> = [];
streamRuns: Array<{ agentId: string; prompt: AgentPromptInput; options?: AgentRunOptions }> = [];
replacementRuns: Array<{ agentId: string; prompt: AgentPromptInput; options?: AgentRunOptions }> =
[];
async respondToPermission(
agentId: string,
requestId: string,
response: AgentPermissionResponse,
): Promise<AgentPermissionResult | void> {
this.permissionResponses.push({ agentId, requestId, response });
return this.permissionResult;
}
tryRunOutOfBand(): boolean {
return this.outOfBandHandled;
}
getAgent() {
return undefined;
}
hasInFlightRun(): boolean {
return this.hasRunInFlight;
}
streamAgent(
agentId: string,
prompt: AgentPromptInput,
options?: AgentRunOptions,
): AsyncGenerator<AgentStreamEvent> {
this.streamRuns.push({ agentId, prompt, options });
return emptyAgentStream();
}
replaceAgentRun(
agentId: string,
prompt: AgentPromptInput,
options?: AgentRunOptions,
): AsyncGenerator<AgentStreamEvent> {
this.replacementRuns.push({ agentId, prompt, options });
return emptyAgentStream();
}
}
async function* emptyAgentStream(): AsyncGenerator<AgentStreamEvent> {}
describe("respondToAgentPermission", () => {
const logger = createTestLogger();
test("starts a follow-up run returned by the provider permission response", async () => {
const agentManager = new FakePermissionAgentManager();
agentManager.permissionResult = { followUpPrompt: "implement the approved plan" };
await respondToAgentPermission({
agentManager,
agentId: "agent-1",
requestId: "permission-1",
response: { behavior: "allow" },
logger,
});
expect(agentManager.permissionResponses).toEqual([
{
agentId: "agent-1",
requestId: "permission-1",
response: { behavior: "allow" },
},
]);
expect(agentManager.streamRuns).toEqual([
{
agentId: "agent-1",
prompt: "implement the approved plan",
},
]);
expect(agentManager.replacementRuns).toEqual([]);
});
test("does not start a run when the permission response has no follow-up prompt", async () => {
const agentManager = new FakePermissionAgentManager();
await respondToAgentPermission({
agentManager,
agentId: "agent-1",
requestId: "permission-1",
response: { behavior: "deny", message: "not now" },
logger,
});
expect(agentManager.permissionResponses).toEqual([
{
agentId: "agent-1",
requestId: "permission-1",
response: { behavior: "deny", message: "not now" },
},
]);
expect(agentManager.streamRuns).toEqual([]);
expect(agentManager.replacementRuns).toEqual([]);
});
test("replaces an in-flight run for follow-up prompts", async () => {
const agentManager = new FakePermissionAgentManager();
agentManager.hasRunInFlight = true;
agentManager.permissionResult = { followUpPrompt: "continue after approval" };
await respondToAgentPermission({
agentManager,
agentId: "agent-1",
requestId: "permission-1",
response: { behavior: "allow" },
logger,
});
expect(agentManager.streamRuns).toEqual([]);
expect(agentManager.replacementRuns).toEqual([
{
agentId: "agent-1",
prompt: "continue after approval",
},
]);
});
});
@@ -0,0 +1,40 @@
import type { Logger } from "pino";
import type { AgentPermissionResponse, AgentPermissionResult } from "./agent-sdk-types.js";
import { startAgentRun, type AgentRunController } from "./agent-prompt.js";
export interface PermissionResponseAgentManager extends AgentRunController {
respondToPermission(
agentId: string,
requestId: string,
response: AgentPermissionResponse,
): Promise<AgentPermissionResult | void>;
}
export interface RespondToAgentPermissionParams {
agentManager: PermissionResponseAgentManager;
agentId: string;
requestId: string;
response: AgentPermissionResponse;
logger: Logger;
}
export async function respondToAgentPermission(
params: RespondToAgentPermissionParams,
): Promise<void> {
const { agentManager, agentId, requestId, response, logger } = params;
logger.debug(
{ agentId, requestId },
`Handling permission response for agent ${agentId}, request ${requestId}`,
);
const result = await agentManager.respondToPermission(agentId, requestId, response);
logger.debug({ agentId }, `Permission response forwarded to agent ${agentId}`);
if (result?.followUpPrompt) {
logger.debug({ agentId }, "Permission response requires follow-up turn, starting agent stream");
startAgentRun(agentManager, agentId, result.followUpPrompt, logger, {
replaceRunning: true,
});
}
}
+92 -201
View File
@@ -68,10 +68,11 @@ import { ensureAgentLoaded } from "./agent/agent-loading.js";
import {
formatSystemNotificationPrompt,
sendPromptToAgent,
startCreatedAgentInitialPrompt,
waitForAgentRunStartWithTimeout,
unarchiveAgentState,
} from "./agent/agent-prompt.js";
import { resolveCreateAgentTitles } from "./agent/create-agent-title.js";
import { respondToAgentPermission } from "./agent/permission-response.js";
import { experimental_createMCPClient } from "ai";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import type { VoiceCallerContext, VoiceSpeakHandler } from "./voice-types.js";
@@ -106,8 +107,14 @@ import type {
AgentTimelineFetchDirection,
ManagedAgent,
} from "./agent/agent-manager.js";
import { scheduleAgentMetadataGeneration } from "./agent/agent-metadata-generator.js";
import { resolveCreateAgentTitles } from "./agent/create-agent-title.js";
import { createAgentCommand } from "./agent/create-agent/create.js";
import {
archiveAgentCommand,
cancelAgentRunCommand,
closeAgentCommand,
setAgentModeCommand,
updateAgentCommand,
} from "./agent/lifecycle-command.js";
import {
buildStoredAgentPayload,
resolveEffectiveThinkingOptionId,
@@ -138,7 +145,6 @@ import {
type AgentPromptInput,
type AgentRunOptions,
type AgentSessionConfig,
type AgentStreamEvent,
type ProviderSnapshotEntry,
} from "./agent/agent-sdk-types.js";
import type { StoredAgentRecord } from "./agent/agent-storage.js";
@@ -213,7 +219,6 @@ import type { LocalSpeechModelId } from "./speech/providers/local/models.js";
import { toResolver, type Resolvable } from "./speech/provider-resolver.js";
import type { SpeechReadinessSnapshot, SpeechReadinessState } from "./speech/speech-runtime.js";
import type pino from "pino";
import { resolveClientMessageId } from "./client-message-id.js";
import {
ChatServiceError,
FileBackedChatService,
@@ -1170,52 +1175,6 @@ export class Session {
return this.agentManager.hasInFlightRun(agentId);
}
/**
* Start streaming an agent run and forward results via the websocket broadcast
*/
private startAgentStream(
agentId: string,
prompt: AgentPromptInput,
runOptions?: AgentRunOptions,
): { ok: true } | { ok: false; error: string } {
this.sessionLogger.trace(
{
agentId,
promptType: typeof prompt === "string" ? "string" : "structured",
hasRunOptions: Boolean(runOptions),
},
"agent.session.start_stream.request",
);
let iterator: AsyncGenerator<AgentStreamEvent>;
try {
const shouldReplace = this.agentManager.hasInFlightRun(agentId);
iterator = shouldReplace
? this.agentManager.replaceAgentRun(agentId, prompt, runOptions)
: this.agentManager.streamAgent(agentId, prompt, runOptions);
this.sessionLogger.trace(
{ agentId, shouldReplace },
"agent.session.start_stream.iterator_returned",
);
} catch (error) {
this.handleAgentRunError(agentId, error, "Failed to start agent run");
return { ok: false, error: errorToFriendlyMessage(error) };
}
void (async () => {
try {
for await (const _ of iterator) {
// Events are forwarded via the session's AgentManager subscription.
}
this.sessionLogger.trace({ agentId }, "agent.session.iterator.drained");
} catch (error) {
this.sessionLogger.trace({ agentId, err: error }, "agent.session.iterator.error");
this.handleAgentRunError(agentId, error, "Agent stream failed");
}
})();
return { ok: true };
}
private handleAgentRunError(agentId: string, error: unknown, context: string): void {
const message = errorToFriendlyMessage(error);
this.sessionLogger.error({ err: error, agentId, context }, `${context} for agent ${agentId}`);
@@ -2357,7 +2316,7 @@ export class Session {
beginAgentDeleteIfSupported(this.agentStorage, agentId);
try {
await this.agentManager.closeAgent(agentId);
await closeAgentCommand({ agentManager: this.agentManager }, agentId);
} catch (error) {
this.sessionLogger.warn(
{ err: error, agentId },
@@ -2411,43 +2370,17 @@ export class Session {
});
}
private async archiveStoredAgentForClose(
agentId: string,
): Promise<{ agentId: string; archivedAt: string }> {
const existing = await this.agentStorage.get(agentId);
if (!existing) {
throw new Error(`Agent not found: ${agentId}`);
}
if (existing.archivedAt) {
return {
agentId,
archivedAt: existing.archivedAt,
};
}
const archivedAt = new Date().toISOString();
await this.agentManager.archiveSnapshot(agentId, archivedAt);
return { agentId, archivedAt };
}
private async archiveAgentForClose(
agentId: string,
): Promise<{ agentId: string; archivedAt: string }> {
const liveAgent = this.agentManager.getAgent(agentId);
if (liveAgent) {
await this.interruptAgentIfRunning(agentId);
await this.agentManager.clearAgentAttention(agentId).catch(() => undefined);
await this.agentManager.archiveAgent(agentId);
} else {
await this.archiveStoredAgentForClose(agentId);
}
const archivedRecord = await this.agentStorage.get(agentId);
if (!archivedRecord) {
throw new Error(`Agent not found in storage after archive: ${agentId}`);
}
const { archivedAt, record: archivedRecord } = await archiveAgentCommand(
{
agentManager: this.agentManager,
agentStorage: this.agentStorage,
logger: this.sessionLogger,
},
agentId,
);
if (this.agentUpdatesSubscription) {
const payload = this.buildStoredAgentPayload(archivedRecord);
@@ -2480,11 +2413,7 @@ export class Session {
await this.emitWorkspaceUpdateForCwd(payload.cwd);
}
if (!archivedRecord.archivedAt) {
throw new Error(`Agent missing archivedAt after archive: ${agentId}`);
}
return { agentId, archivedAt: archivedRecord.archivedAt };
return { agentId, archivedAt };
}
private async handleCloseItemsRequest(msg: CloseItemsRequest): Promise<void> {
@@ -2559,27 +2488,24 @@ export class Session {
"session: update_agent_request",
);
const normalizedName = name?.trim();
const normalizedLabels = labels && Object.keys(labels).length > 0 ? labels : undefined;
if (!normalizedName && !normalizedLabels) {
this.emit({
type: "update_agent_response",
payload: {
requestId,
agentId,
accepted: false,
error: "Nothing to update (provide name and/or labels)",
},
});
return;
}
try {
await this.agentManager.updateAgentMetadata(agentId, {
...(normalizedName ? { title: normalizedName } : {}),
...(normalizedLabels ? { labels: normalizedLabels } : {}),
});
const result = await updateAgentCommand(
{ agentManager: this.agentManager },
{ agentId, name, labels },
);
if (!result.accepted) {
this.emit({
type: "update_agent_response",
payload: {
requestId,
agentId,
accepted: false,
error: result.error,
},
});
return;
}
this.emit({
type: "update_agent_response",
@@ -3173,7 +3099,6 @@ export class Session {
configTitle: config.title,
initialPrompt: trimmedPrompt,
});
const resolvedConfig: AgentSessionConfig = config;
const firstAgentContext: FirstAgentContext = {
...(trimmedPrompt ? { prompt: trimmedPrompt } : {}),
@@ -3187,28 +3112,39 @@ export class Session {
});
createdWorktreeForCleanup = createdWorktree;
const createAgentConfig: AgentSessionConfig = createdWorktree
? { ...resolvedConfig, cwd: createdWorktree.worktree.worktreePath }
: resolvedConfig;
const { sessionConfig, setupContinuation } = await this.buildAgentSessionConfig(
createAgentConfig,
git,
worktreeName,
firstAgentContext,
? { ...config, cwd: createdWorktree.worktree.worktreePath }
: config;
const { snapshot, liveSnapshot } = await createAgentCommand(
{
agentManager: this.agentManager,
agentStorage: this.agentStorage,
logger: this.sessionLogger,
paseoHome: this.paseoHome,
workspaceGitService: this.workspaceGitService,
},
{
kind: "session",
config: createAgentConfig,
workspaceId: msg.workspaceId,
worktreeName,
initialPrompt,
clientMessageId,
outputSchema,
images,
attachments,
git,
labels,
env,
provisionalTitle,
explicitTitle,
firstAgentContext,
buildSessionConfig: (sessionConfig, gitOptions, legacyWorktreeName, ctx) =>
this.buildAgentSessionConfig(sessionConfig, gitOptions, legacyWorktreeName, ctx),
resolveWorkspace: ({ cwd, workspaceId }) =>
this.resolveCreateAgentWorkspace(cwd, workspaceId),
},
);
let resolvedWorkspace = msg.workspaceId
? await this.workspaceRegistry.get(msg.workspaceId)
: ((await this.findWorkspaceByDirectory(sessionConfig.cwd)) ??
(await this.findOrCreateWorkspaceForDirectory(sessionConfig.cwd)));
if (!resolvedWorkspace) {
throw new Error(`Workspace not found: ${msg.workspaceId}`);
}
const snapshot = await this.agentManager.createAgent(sessionConfig, undefined, {
labels,
workspaceId: resolvedWorkspace.workspaceId,
initialPrompt: trimmedPrompt,
env,
initialTitle: provisionalTitle,
});
createdAgentId = snapshot.id;
await this.forwardAgentUpdate(snapshot);
this.createAgentLifecycleDispatch.registerAutoArchiveIfRequested({
@@ -3217,16 +3153,6 @@ export class Session {
createdWorktree,
});
const liveSnapshot = await this.sendInitialCreateAgentPrompt({
snapshot,
trimmedPrompt,
images,
attachments,
clientMessageId,
outputSchema,
explicitTitle,
});
if (requestId) {
const agentPayload = await this.buildAgentPayload(liveSnapshot);
this.emit({
@@ -3240,10 +3166,6 @@ export class Session {
});
}
setupContinuation?.startAfterAgentCreate({
agentId: snapshot.id,
});
this.sessionLogger.info(
{ agentId: snapshot.id, provider: snapshot.provider },
`Created agent ${snapshot.id} (${snapshot.provider})`,
@@ -3278,44 +3200,18 @@ export class Session {
}
}
private async sendInitialCreateAgentPrompt(params: {
snapshot: ManagedAgent;
trimmedPrompt: string | undefined;
images: Array<{ data: string; mimeType: string }> | undefined;
attachments: AgentAttachment[] | undefined;
clientMessageId: string | undefined;
outputSchema: Record<string, unknown> | undefined;
explicitTitle: string | null;
}): Promise<ManagedAgent> {
const { snapshot, trimmedPrompt, images, attachments, clientMessageId, outputSchema } = params;
const hasPrompt = Boolean(trimmedPrompt);
const hasImages = (images?.length ?? 0) > 0;
const hasAttachments = (attachments?.length ?? 0) > 0;
if (!hasPrompt && !hasImages && !hasAttachments) {
return snapshot;
private async resolveCreateAgentWorkspace(
cwd: string,
workspaceId?: string,
): Promise<{ workspaceId: string }> {
const resolvedWorkspace = workspaceId
? await this.workspaceRegistry.get(workspaceId)
: ((await this.findWorkspaceByDirectory(cwd)) ??
(await this.findOrCreateWorkspaceForDirectory(cwd)));
if (!resolvedWorkspace) {
throw new Error(`Workspace not found: ${workspaceId}`);
}
scheduleAgentMetadataGeneration({
agentManager: this.agentManager,
agentId: snapshot.id,
cwd: snapshot.cwd,
workspaceGitService: this.workspaceGitService,
initialPrompt: trimmedPrompt,
explicitTitle: params.explicitTitle,
paseoHome: this.paseoHome,
logger: this.sessionLogger,
});
const prompt = this.buildAgentPrompt(trimmedPrompt || "", images, attachments);
return await startCreatedAgentInitialPrompt({
agentManager: this.agentManager,
agentId: snapshot.id,
snapshot,
prompt,
runOptions: outputSchema ? { outputSchema } : undefined,
logger: this.sessionLogger.child({
clientMessageId: resolveClientMessageId(clientMessageId),
}),
});
return { workspaceId: resolvedWorkspace.workspaceId };
}
private async handleResumeAgentRequest(
@@ -3541,7 +3437,10 @@ export class Session {
this.sessionLogger.info({ agentId }, `Cancel request received for agent ${agentId}`);
try {
await this.interruptAgentIfRunning(agentId);
await cancelAgentRunCommand(
{ agentManager: this.agentManager, logger: this.sessionLogger },
agentId,
);
if (requestId) {
const agent = this.agentManager.getAgent(agentId);
const payload = agent ? await this.buildAgentPayload(agent) : null;
@@ -4411,7 +4310,7 @@ export class Session {
this.sessionLogger.info({ agentId, modeId, requestId }, "session: set_agent_mode_request");
try {
await this.agentManager.setAgentMode(agentId, modeId);
await setAgentModeCommand({ agentManager: this.agentManager }, { agentId, modeId });
this.sessionLogger.info(
{ agentId, modeId, requestId },
"session: set_agent_mode_request success",
@@ -4732,22 +4631,14 @@ export class Session {
requestId: string,
response: AgentPermissionResponse,
): Promise<void> {
this.sessionLogger.debug(
{ agentId, requestId },
`Handling permission response for agent ${agentId}, request ${requestId}`,
);
try {
const result = await this.agentManager.respondToPermission(agentId, requestId, response);
this.sessionLogger.debug({ agentId }, `Permission response forwarded to agent ${agentId}`);
if (result?.followUpPrompt) {
this.sessionLogger.debug(
{ agentId },
"Permission response requires follow-up turn, starting agent stream",
);
this.startAgentStream(agentId, result.followUpPrompt);
}
await respondToAgentPermission({
agentManager: this.agentManager,
agentId,
requestId,
response,
logger: this.sessionLogger,
});
} catch (error) {
this.sessionLogger.error(
{ err: error, agentId, requestId },
@@ -1071,6 +1071,7 @@ test("close_items_request archives agents and kills terminals in one batch", asy
archivedAt: null,
};
const killTerminal = vi.fn();
const cancelAgentRun = vi.fn(async () => true);
const session = asTestSession(
new Session({
clientId: "test-client",
@@ -1083,6 +1084,8 @@ test("close_items_request archives agents and kills terminals in one batch", asy
subscribe: () => () => {},
listAgents: () => [],
getAgent: (agentId: string) => (agentId === "agent-1" ? { id: agentId } : null),
hasInFlightRun: (agentId: string) => agentId === "agent-1",
cancelAgentRun,
archiveAgent: async () => ({ archivedAt }),
clearAgentAttention: async () => {},
notifyAgentState: () => {},
@@ -1175,8 +1178,6 @@ test("close_items_request archives agents and kills terminals in one batch", asy
isBootstrapping: false,
pendingUpdatesByAgentId: new Map(),
};
const interruptAgentIfRunning = vi.fn();
session.interruptAgentIfRunning = interruptAgentIfRunning;
await session.handleMessage({
type: "close_items_request",
@@ -1185,7 +1186,7 @@ test("close_items_request archives agents and kills terminals in one batch", asy
requestId: "req-close-items",
});
expect(interruptAgentIfRunning).toHaveBeenCalledWith("agent-1");
expect(cancelAgentRun).toHaveBeenCalledWith("agent-1");
expect(killTerminal).toHaveBeenCalledWith("term-1");
expect(emitted.find((message) => message.type === "close_items_response")?.payload).toEqual({
agents: [{ agentId: "agent-1", archivedAt }],
@@ -1254,6 +1255,7 @@ test("close_items_request archives stored agents that are not currently loaded",
subscribe: () => () => {},
listAgents: () => [],
getAgent: (agentId: string) => (agentId === "agent-live" ? { id: agentId } : null),
hasInFlightRun: () => false,
archiveAgent: async (agentId: string) => {
if (agentId !== "agent-live") {
throw new Error(`Unexpected live archive: ${agentId}`);
@@ -1361,7 +1363,6 @@ test("close_items_request archives stored agents that are not currently loaded",
isBootstrapping: false,
pendingUpdatesByAgentId: new Map(),
};
session.interruptAgentIfRunning = vi.fn();
await session.handleMessage({
type: "close_items_request",
@@ -1416,6 +1417,7 @@ test("close_items_request continues after an archive failure", async () => {
listAgents: () => [],
getAgent: (agentId: string) =>
agentId === "agent-bad" || agentId === "agent-good" ? { id: agentId } : null,
hasInFlightRun: () => false,
archiveAgent: async (agentId: string) => {
if (agentId === "agent-bad") {
throw new Error("archive failed");
@@ -1513,8 +1515,6 @@ test("close_items_request continues after an archive failure", async () => {
isBootstrapping: false,
pendingUpdatesByAgentId: new Map(),
};
const interruptAgentIfRunningBestEffort = vi.fn();
session.interruptAgentIfRunning = interruptAgentIfRunningBestEffort;
await session.handleMessage({
type: "close_items_request",
@@ -1523,8 +1523,6 @@ test("close_items_request continues after an archive failure", async () => {
requestId: "req-close-best-effort",
});
expect(interruptAgentIfRunningBestEffort).toHaveBeenCalledWith("agent-bad");
expect(interruptAgentIfRunningBestEffort).toHaveBeenCalledWith("agent-good");
expect(killTerminalBestEffort).toHaveBeenCalledWith("term-1");
expect(emitted.find((message) => message.type === "close_items_response")?.payload).toEqual({
agents: [{ agentId: "agent-good", archivedAt }],
+54 -53
View File
@@ -27,7 +27,6 @@ import type { CheckoutExistingBranchResult } from "../utils/checkout-git.js";
import { expandTilde } from "../utils/path.js";
import {
getWorktreeSetupCommands,
isPaseoOwnedWorktreeCwd,
resolveWorktreeRuntimeEnv,
runWorktreeSetupCommands,
slugify,
@@ -41,11 +40,13 @@ import type {
CreatePaseoWorktreeInput,
CreatePaseoWorktreeResult,
} from "./paseo-worktree-service.js";
import {
archivePaseoWorktree,
type ArchivePaseoWorktreeDependencies,
} from "./paseo-worktree-archive-service.js";
import type { ArchivePaseoWorktreeDependencies } from "./paseo-worktree-archive-service.js";
import { toWorktreeWireError } from "./worktree-errors.js";
import {
archivePaseoWorktreeCommand,
createPaseoWorktreeCommand,
listPaseoWorktreesCommand,
} from "./worktree/commands.js";
const SAFE_GIT_REF_PATTERN = /^[A-Za-z0-9._/-]+$/;
@@ -395,7 +396,10 @@ export async function handlePaseoWorktreeListRequest(
}
try {
const worktrees = await dependencies.workspaceGitService.listWorktrees(cwd);
const worktrees = await listPaseoWorktreesCommand(
{ workspaceGitService: dependencies.workspaceGitService },
{ cwd },
);
dependencies.emit({
type: "paseo_worktree_list_response",
payload: {
@@ -433,37 +437,23 @@ export async function handlePaseoWorktreeArchiveRequest(
msg: Extract<SessionInboundMessage, { type: "paseo_worktree_archive_request" }>,
): Promise<void> {
const { requestId } = msg;
let targetPath = msg.worktreePath;
let repoRoot = msg.repoRoot ?? null;
try {
if (!targetPath) {
if (!repoRoot || !msg.branchName) {
throw new Error("worktreePath or repoRoot+branchName is required");
}
const worktrees = await dependencies.workspaceGitService.listWorktrees(repoRoot);
const match = worktrees.find((entry) => entry.branchName === msg.branchName);
if (!match) {
throw new Error(`Paseo worktree not found for branch ${msg.branchName}`);
}
targetPath = match.path;
}
if (!targetPath) {
throw new Error("worktreePath could not be resolved");
}
const ownership = await isPaseoOwnedWorktreeCwd(targetPath, {
paseoHome: dependencies.paseoHome,
const result = await archivePaseoWorktreeCommand(dependencies, {
requestId,
worktreePath: msg.worktreePath,
repoRoot: msg.repoRoot,
branchName: msg.branchName,
});
if (!ownership.allowed) {
if (!result.ok) {
dependencies.emit({
type: "paseo_worktree_archive_response",
payload: {
success: false,
removedAgents: [],
removedAgents: result.removedAgents,
error: {
code: "NOT_ALLOWED",
message: "Worktree is not a Paseo-owned worktree",
code: result.code,
message: result.message,
},
requestId,
},
@@ -471,23 +461,11 @@ export async function handlePaseoWorktreeArchiveRequest(
return;
}
// repoRoot is best-effort: if git has forgotten about the worktree we
// still proceed using the path-derived worktreesRoot, since the ownership
// check already proved the path lives under $PASEO_HOME/worktrees.
repoRoot = ownership.repoRoot ?? repoRoot ?? null;
const removedAgents = await archivePaseoWorktree(dependencies, {
targetPath,
repoRoot,
worktreesRoot: ownership.worktreeRoot,
requestId,
});
dependencies.emit({
type: "paseo_worktree_archive_response",
payload: {
success: true,
removedAgents,
removedAgents: result.removedAgents,
error: null,
requestId,
},
@@ -510,18 +488,41 @@ export async function handleCreatePaseoWorktreeRequest(
request: Extract<SessionInboundMessage, { type: "create_paseo_worktree_request" }>,
): Promise<void> {
try {
const createdWorktree = await dependencies.createPaseoWorktreeWorkflow({
cwd: request.cwd,
projectId: request.projectId,
worktreeSlug: request.worktreeSlug,
firstAgentContext: normalizeFirstAgentContext(request),
refName: request.refName,
action: request.action,
githubPrNumber: request.githubPrNumber,
runSetup: false,
paseoHome: dependencies.paseoHome,
});
const commandResult = await createPaseoWorktreeCommand(
{
paseoHome: dependencies.paseoHome,
createPaseoWorktreeWorkflow: dependencies.createPaseoWorktreeWorkflow,
},
{
cwd: request.cwd,
projectId: request.projectId,
worktreeSlug: request.worktreeSlug,
firstAgentContext: normalizeFirstAgentContext(request),
refName: request.refName,
action: request.action,
githubPrNumber: request.githubPrNumber,
},
);
if (!commandResult.ok) {
dependencies.sessionLogger.error(
{ err: commandResult.cause, cwd: request.cwd, worktreeSlug: request.worktreeSlug },
"Failed to create worktree",
);
dependencies.emit({
type: "create_paseo_worktree_response",
payload: {
workspace: null,
error: commandResult.error.message,
errorCode: commandResult.error.code,
setupTerminalId: null,
requestId: request.requestId,
},
});
return;
}
const createdWorktree = commandResult.createdWorktree;
const descriptor = await dependencies.describeWorkspaceRecord(createdWorktree);
dependencies.emit({
type: "create_paseo_worktree_response",
@@ -0,0 +1,189 @@
import { join } from "node:path";
import { getPaseoWorktreesRoot, isPaseoOwnedWorktreeCwd } from "../../utils/worktree.js";
import {
archivePaseoWorktree,
type ArchivePaseoWorktreeDependencies,
} from "../paseo-worktree-archive-service.js";
import type {
CreatePaseoWorktreeInput,
CreatePaseoWorktreeResult,
} from "../paseo-worktree-service.js";
import { toWorktreeWireError, type WorktreeWireError } from "../worktree-errors.js";
import type { WorkspaceGitService, WorkspaceGitWorktreeInfo } from "../workspace-git-service.js";
export interface ListPaseoWorktreesCommandDependencies {
workspaceGitService: Pick<WorkspaceGitService, "listWorktrees">;
}
export interface ListPaseoWorktreesCommandInput {
cwd: string;
reason?: string;
}
export async function listPaseoWorktreesCommand(
dependencies: ListPaseoWorktreesCommandDependencies,
input: ListPaseoWorktreesCommandInput,
): Promise<WorkspaceGitWorktreeInfo[]> {
if (input.reason) {
return dependencies.workspaceGitService.listWorktrees(input.cwd, { reason: input.reason });
}
return dependencies.workspaceGitService.listWorktrees(input.cwd);
}
type CreatePaseoWorktreeWorkflow<Result extends CreatePaseoWorktreeResult> = (
input: CreatePaseoWorktreeInput,
) => Promise<Result>;
export interface CreatePaseoWorktreeCommandDependencies<
Result extends CreatePaseoWorktreeResult = CreatePaseoWorktreeResult,
> {
paseoHome?: string;
createPaseoWorktreeWorkflow?: CreatePaseoWorktreeWorkflow<Result>;
}
export type CreatePaseoWorktreeCommandInput = Omit<
CreatePaseoWorktreeInput,
"paseoHome" | "runSetup"
> & {
paseoHome?: string;
};
export type CreatePaseoWorktreeCommandResult<Result extends CreatePaseoWorktreeResult> =
| {
ok: true;
createdWorktree: Result;
}
| {
ok: false;
error: WorktreeWireError;
cause: unknown;
};
export async function createPaseoWorktreeCommand<Result extends CreatePaseoWorktreeResult>(
dependencies: CreatePaseoWorktreeCommandDependencies<Result>,
input: CreatePaseoWorktreeCommandInput,
): Promise<CreatePaseoWorktreeCommandResult<Result>> {
try {
if (!dependencies.createPaseoWorktreeWorkflow) {
throw new Error("Paseo worktree service is not configured");
}
const createdWorktree = await dependencies.createPaseoWorktreeWorkflow({
...input,
runSetup: false,
paseoHome: input.paseoHome ?? dependencies.paseoHome,
});
return { ok: true, createdWorktree };
} catch (error) {
return {
ok: false,
error: toWorktreeWireError(error),
cause: error,
};
}
}
export interface ArchivePaseoWorktreeCommandDependencies extends Omit<
ArchivePaseoWorktreeDependencies,
"workspaceGitService"
> {
workspaceGitService: Pick<WorkspaceGitService, "getSnapshot" | "listWorktrees">;
}
export interface ArchivePaseoWorktreeCommandInput {
requestId: string;
repoRoot?: string | null;
worktreePath?: string;
worktreeSlug?: string;
branchName?: string;
}
export type ArchivePaseoWorktreeCommandResult =
| {
ok: true;
removedAgents: string[];
}
| {
ok: false;
code: "NOT_ALLOWED";
message: string;
removedAgents: [];
};
export async function archivePaseoWorktreeCommand(
dependencies: ArchivePaseoWorktreeCommandDependencies,
input: ArchivePaseoWorktreeCommandInput,
): Promise<ArchivePaseoWorktreeCommandResult> {
const resolvedTarget = await resolveArchiveTarget(dependencies, input);
const ownership = await isPaseoOwnedWorktreeCwd(resolvedTarget.targetPath, {
paseoHome: dependencies.paseoHome,
});
if (!ownership.allowed) {
return {
ok: false,
code: "NOT_ALLOWED",
message: "Worktree is not a Paseo-owned worktree",
removedAgents: [],
};
}
const repoRoot = ownership.repoRoot ?? resolvedTarget.repoRoot ?? null;
const removedAgents = await archivePaseoWorktree(dependencies, {
targetPath: resolvedTarget.targetPath,
repoRoot,
worktreesRoot: ownership.worktreeRoot,
requestId: input.requestId,
});
return {
ok: true,
removedAgents,
};
}
interface ResolvedArchiveTarget {
targetPath: string;
repoRoot: string | null;
}
async function resolveArchiveTarget(
dependencies: ArchivePaseoWorktreeCommandDependencies,
input: ArchivePaseoWorktreeCommandInput,
): Promise<ResolvedArchiveTarget> {
const repoRoot = input.repoRoot ?? null;
if (input.worktreePath) {
return { targetPath: input.worktreePath, repoRoot };
}
if (input.worktreeSlug) {
if (!repoRoot) {
throw new Error("repoRoot is required when worktreeSlug is supplied");
}
return {
targetPath: await resolveWorktreeSlugPath(dependencies, repoRoot, input.worktreeSlug),
repoRoot,
};
}
if (repoRoot && input.branchName) {
const worktrees = await dependencies.workspaceGitService.listWorktrees(repoRoot);
const match = worktrees.find((entry) => entry.branchName === input.branchName);
if (!match) {
throw new Error(`Paseo worktree not found for branch ${input.branchName}`);
}
return { targetPath: match.path, repoRoot };
}
throw new Error("worktreePath, worktreeSlug, or repoRoot+branchName is required");
}
async function resolveWorktreeSlugPath(
dependencies: ArchivePaseoWorktreeCommandDependencies,
repoRoot: string,
worktreeSlug: string,
): Promise<string> {
const worktreesRoot = await getPaseoWorktreesRoot(repoRoot, dependencies.paseoHome);
return join(worktreesRoot, worktreeSlug);
}