From ef9c538842f02cf1e30737592e06dab820c2c000 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 22 Jan 2026 14:01:57 +0700 Subject: [PATCH] feat(server): self-identification tools --- .../agent-self-identification.e2e.test.ts | 289 ++++++++++++++++++ .../src/server/agent/mcp-server.test.ts | 22 ++ .../server/src/server/agent/mcp-server.ts | 147 ++++++++- .../server/agent/providers/claude-agent.ts | 8 +- .../server/agent/providers/codex-mcp-agent.ts | 11 +- .../agent/self-identification-instructions.ts | 9 + packages/server/src/server/bootstrap.ts | 2 - packages/server/src/server/session.ts | 75 +---- .../src/services/agent-title-generator.ts | 76 ----- packages/server/src/utils/checkout-git.ts | 20 ++ packages/server/src/utils/worktree.ts | 73 +++-- 11 files changed, 557 insertions(+), 175 deletions(-) create mode 100644 packages/server/src/server/agent/agent-self-identification.e2e.test.ts create mode 100644 packages/server/src/server/agent/self-identification-instructions.ts delete mode 100644 packages/server/src/services/agent-title-generator.ts diff --git a/packages/server/src/server/agent/agent-self-identification.e2e.test.ts b/packages/server/src/server/agent/agent-self-identification.e2e.test.ts new file mode 100644 index 000000000..4cd4f10b5 --- /dev/null +++ b/packages/server/src/server/agent/agent-self-identification.e2e.test.ts @@ -0,0 +1,289 @@ +import { describe, expect, test } from "vitest"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { execSync } from "node:child_process"; +import { randomUUID } from "node:crypto"; + +import { createTestLogger } from "../../test-utils/test-logger.js"; +import { AgentManager } from "./agent-manager.js"; +import { AgentRegistry } from "./agent-registry.js"; +import { createAgentMcpServer } from "./mcp-server.js"; +import { createWorktree } from "../../utils/worktree.js"; +import type { + AgentClient, + AgentRunResult, + AgentSession, + AgentSessionConfig, + AgentStreamEvent, +} from "./agent-sdk-types.js"; + +const TEST_CAPABILITIES = { + supportsStreaming: false, + supportsSessionPersistence: false, + supportsDynamicModes: false, + supportsMcpServers: false, + supportsReasoningStream: false, + supportsToolInvocations: false, +} as const; + +class TestAgentClient implements AgentClient { + readonly provider = "codex" as const; + readonly capabilities = TEST_CAPABILITIES; + + async createSession(config: AgentSessionConfig): Promise { + return new TestAgentSession(config); + } + + async resumeSession(config?: Partial): Promise { + return new TestAgentSession({ + provider: "codex", + cwd: config?.cwd ?? process.cwd(), + }); + } +} + +class TestAgentSession implements AgentSession { + readonly provider = "codex" as const; + readonly capabilities = TEST_CAPABILITIES; + readonly id = randomUUID(); + + constructor(private readonly config: AgentSessionConfig) {} + + async run(): Promise { + return { + sessionId: this.id ?? this.config.provider, + finalText: "", + timeline: [], + }; + } + + async *stream(): AsyncGenerator { + yield { type: "turn_started", provider: this.provider }; + yield { type: "turn_completed", provider: this.provider }; + } + + async *streamHistory(): AsyncGenerator {} + + async getRuntimeInfo() { + return { + provider: this.provider, + sessionId: this.id, + model: this.config.model ?? null, + modeId: this.config.modeId ?? null, + }; + } + + async getAvailableModes() { + return []; + } + + async getCurrentMode() { + return null; + } + + async setMode(): Promise {} + + getPendingPermissions() { + return []; + } + + async respondToPermission(): Promise {} + + describePersistence() { + return { + provider: this.provider, + sessionId: this.id, + }; + } + + async interrupt(): Promise {} + + async close(): Promise {} +} + +function initGitRepo(repoDir: string): void { + execSync("git init -b main", { cwd: repoDir, stdio: "ignore" }); + execSync('git config user.email "paseo-test@example.com"', { + cwd: repoDir, + stdio: "ignore", + }); + execSync('git config user.name "Paseo Test"', { + cwd: repoDir, + stdio: "ignore", + }); + writeFileSync(path.join(repoDir, "README.md"), "init\n"); + execSync("git add README.md", { cwd: repoDir, stdio: "ignore" }); + execSync('git commit -m "init"', { cwd: repoDir, stdio: "ignore" }); +} + +describe("self-identification MCP tools", () => { + const logger = createTestLogger(); + + test("set_branch renames branch for Paseo worktree", async () => { + const repoDir = mkdtempSync(path.join(tmpdir(), "paseo-self-ident-")); + + try { + initGitRepo(repoDir); + const worktree = await createWorktree({ + branchName: "self-ident", + cwd: repoDir, + worktreeSlug: "self-ident", + }); + + const registryPath = path.join(repoDir, "agents.json"); + const registry = new AgentRegistry(registryPath, logger); + const manager = new AgentManager({ + clients: { codex: new TestAgentClient() }, + registry, + logger, + idFactory: () => "agent-self-ident", + }); + + const agent = await manager.createAgent({ + provider: "codex", + cwd: worktree.worktreePath, + }); + + const server = await createAgentMcpServer({ + agentManager: manager, + agentRegistry: registry, + callerAgentId: agent.id, + logger, + }); + const tool = (server as any)._registeredTools["set_branch"]; + + await tool.callback({ name: "self-ident-ready" }); + + const branch = execSync("git rev-parse --abbrev-ref HEAD", { + cwd: worktree.worktreePath, + stdio: "pipe", + }) + .toString() + .trim(); + + expect(branch).toBe("self-ident-ready"); + } finally { + rmSync(repoDir, { recursive: true, force: true }); + } + }); + + test("set_branch allows agents running in a subdirectory of a Paseo worktree", async () => { + const repoDir = mkdtempSync(path.join(tmpdir(), "paseo-self-ident-")); + + try { + initGitRepo(repoDir); + const worktree = await createWorktree({ + branchName: "self-ident-subdir", + cwd: repoDir, + worktreeSlug: "self-ident-subdir", + }); + const nestedDir = path.join(worktree.worktreePath, "nested"); + execSync(`mkdir -p "${nestedDir}"`, { stdio: "ignore" }); + + const registryPath = path.join(repoDir, "agents.json"); + const registry = new AgentRegistry(registryPath, logger); + const manager = new AgentManager({ + clients: { codex: new TestAgentClient() }, + registry, + logger, + idFactory: () => "agent-self-ident-subdir", + }); + + const agent = await manager.createAgent({ + provider: "codex", + cwd: nestedDir, + }); + + const server = await createAgentMcpServer({ + agentManager: manager, + agentRegistry: registry, + callerAgentId: agent.id, + logger, + }); + const tool = (server as any)._registeredTools["set_branch"]; + + await tool.callback({ name: "self-ident-subdir-ready" }); + + const branch = execSync("git rev-parse --abbrev-ref HEAD", { + cwd: nestedDir, + stdio: "pipe", + }) + .toString() + .trim(); + + expect(branch).toBe("self-ident-subdir-ready"); + } finally { + rmSync(repoDir, { recursive: true, force: true }); + } + }); + + test("set_branch rejects non-Paseo checkouts", async () => { + const repoDir = mkdtempSync(path.join(tmpdir(), "paseo-self-ident-")); + + try { + initGitRepo(repoDir); + const registryPath = path.join(repoDir, "agents.json"); + const registry = new AgentRegistry(registryPath, logger); + const manager = new AgentManager({ + clients: { codex: new TestAgentClient() }, + registry, + logger, + idFactory: () => "agent-non-worktree", + }); + + const agent = await manager.createAgent({ + provider: "codex", + cwd: repoDir, + }); + + const server = await createAgentMcpServer({ + agentManager: manager, + agentRegistry: registry, + callerAgentId: agent.id, + logger, + }); + const tool = (server as any)._registeredTools["set_branch"]; + + await expect(tool.callback({ name: "should-fail" })).rejects.toMatchObject({ + code: "NOT_ALLOWED", + }); + } finally { + rmSync(repoDir, { recursive: true, force: true }); + } + }); + + test("set_branch rejects non-git directories", async () => { + const repoDir = mkdtempSync(path.join(tmpdir(), "paseo-self-ident-")); + + try { + const registryPath = path.join(repoDir, "agents.json"); + const registry = new AgentRegistry(registryPath, logger); + const manager = new AgentManager({ + clients: { codex: new TestAgentClient() }, + registry, + logger, + idFactory: () => "agent-non-git", + }); + + const agent = await manager.createAgent({ + provider: "codex", + cwd: repoDir, + }); + + const server = await createAgentMcpServer({ + agentManager: manager, + agentRegistry: registry, + callerAgentId: agent.id, + logger, + }); + const tool = (server as any)._registeredTools["set_branch"]; + + await expect(tool.callback({ name: "nope" })).rejects.toMatchObject({ + code: "NOT_GIT_REPO", + }); + } finally { + rmSync(repoDir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts index 4bcdf9ced..fd8421899 100644 --- a/packages/server/src/server/agent/mcp-server.test.ts +++ b/packages/server/src/server/agent/mcp-server.test.ts @@ -101,4 +101,26 @@ describe("create_agent MCP tool", () => { }) ); }); + + it("set_title trims and persists titles for caller agent", async () => { + const { agentManager, agentRegistry, spies } = createTestDeps(); + spies.agentManager.getAgent.mockReturnValue({ + id: "agent-1", + } as ManagedAgent); + + const server = await createAgentMcpServer({ + agentManager, + agentRegistry, + logger, + callerAgentId: "agent-1", + }); + const tool = (server as any)._registeredTools["set_title"]; + + await tool.callback({ title: " Fix auth " }); + + expect(spies.agentRegistry.setTitle).toHaveBeenCalledWith( + "agent-1", + "Fix auth" + ); + }); }); diff --git a/packages/server/src/server/agent/mcp-server.ts b/packages/server/src/server/agent/mcp-server.ts index 8584fb659..303bf8786 100644 --- a/packages/server/src/server/agent/mcp-server.ts +++ b/packages/server/src/server/agent/mcp-server.ts @@ -25,8 +25,13 @@ import { toAgentPayload } from "./agent-projections.js"; import { curateAgentActivity } from "./activity-curator.js"; import { AGENT_PROVIDER_DEFINITIONS } from "./provider-registry.js"; import { AgentRegistry } from "./agent-registry.js"; -import { createWorktree } from "../../utils/worktree.js"; +import { + createWorktree, + isPaseoOwnedWorktreeCwd, + validateBranchSlug, +} from "../../utils/worktree.js"; import { WaitForAgentTracker } from "./wait-for-agent-tracker.js"; +import { NotGitRepoError, renameCurrentBranch } from "../../utils/checkout-git.js"; export interface AgentMcpServerOptions { agentManager: AgentManager; @@ -105,6 +110,18 @@ function expandPath(path: string): string { return resolve(path); } +type ToolErrorCode = "NOT_ALLOWED" | "NOT_GIT_REPO" | "INVALID_BRANCH"; + +class AgentMcpToolError extends Error { + readonly code: ToolErrorCode; + + constructor(code: ToolErrorCode, message: string) { + super(message); + this.name = "AgentMcpToolError"; + this.code = code; + } +} + /** * Wraps agentManager.waitForAgentEvent with a self-imposed timeout. * Returns a friendly message when timeout occurs, rather than letting @@ -880,6 +897,134 @@ export async function createAgentMcpServer( } ); + server.registerTool( + "set_title", + { + title: "Set Agent Title", + description: "Update the agent's title in the registry.", + inputSchema: { + title: z + .string() + .min(1) + .max(40) + .describe("Short descriptive title (<= 40 chars)."), + }, + outputSchema: { + success: z.boolean(), + title: z.string(), + }, + }, + async ({ title }) => { + if (!callerAgentId) { + throw new AgentMcpToolError( + "NOT_ALLOWED", + "set_title can only be called by a managed agent" + ); + } + + const agent = agentManager.getAgent(callerAgentId); + if (!agent) { + throw new Error(`Agent ${callerAgentId} not found`); + } + + const normalizedTitle = title.trim(); + if (!normalizedTitle) { + throw new AgentMcpToolError("NOT_ALLOWED", "Title cannot be empty"); + } + if (normalizedTitle.length > 40) { + throw new AgentMcpToolError( + "NOT_ALLOWED", + "Title must be 40 characters or fewer" + ); + } + + await agentRegistry.setTitle(agent.id, normalizedTitle); + return { + content: [], + structuredContent: ensureValidJson({ + success: true, + title: normalizedTitle, + }), + }; + } + ); + + server.registerTool( + "set_branch", + { + title: "Set Agent Branch", + description: + "Rename the current git branch. Allowed only inside Paseo-owned worktrees.", + inputSchema: { + name: z + .string() + .min(1) + .describe("Git branch name (lowercase letters, numbers, hyphens, slashes)."), + }, + outputSchema: { + success: z.boolean(), + branch: z.string(), + }, + }, + async ({ name }) => { + if (!callerAgentId) { + throw new AgentMcpToolError( + "NOT_ALLOWED", + "set_branch can only be called by a managed agent" + ); + } + + const agent = agentManager.getAgent(callerAgentId); + if (!agent) { + throw new Error(`Agent ${callerAgentId} not found`); + } + + const validation = validateBranchSlug(name); + if (!validation.valid) { + throw new AgentMcpToolError( + "INVALID_BRANCH", + validation.error ?? "Invalid branch name" + ); + } + + let ownership; + try { + ownership = await isPaseoOwnedWorktreeCwd(agent.cwd); + } catch (error) { + const notGitError = + error instanceof NotGitRepoError + ? error + : new NotGitRepoError(agent.cwd); + throw new AgentMcpToolError( + "NOT_GIT_REPO", + notGitError.message + ); + } + + if (!ownership.allowed) { + throw new AgentMcpToolError( + "NOT_ALLOWED", + "Branch renames are only allowed inside Paseo-owned worktrees" + ); + } + + const result = await renameCurrentBranch(agent.cwd, name); + if (result.currentBranch !== name) { + throw new Error( + `Branch rename failed (expected ${name}, got ${result.currentBranch ?? "unknown"})` + ); + } + + return { + content: [], + structuredContent: ensureValidJson({ + success: true, + branch: name, + }), + }; + } + ); + server.registerTool( "set_agent_mode", { diff --git a/packages/server/src/server/agent/providers/claude-agent.ts b/packages/server/src/server/agent/providers/claude-agent.ts index c0f05f45a..9609f779a 100644 --- a/packages/server/src/server/agent/providers/claude-agent.ts +++ b/packages/server/src/server/agent/providers/claude-agent.ts @@ -48,6 +48,7 @@ import type { PersistedAgentDescriptor, } from "../agent-sdk-types.js"; import { getOrchestratorModeInstructions } from "../orchestrator-instructions.js"; +import { getSelfIdentificationInstructions } from "../self-identification-instructions.js"; const fsPromises = promises; @@ -801,7 +802,12 @@ class ClaudeAgentSession implements AgentSession { systemPrompt: { type: "preset", preset: "claude_code", - append: getOrchestratorModeInstructions(), + append: [ + getOrchestratorModeInstructions(), + this.currentMode === "plan" ? "" : getSelfIdentificationInstructions(), + ] + .filter(Boolean) + .join("\n"), }, settingSources: ["user", "project"], stderr: (data: string) => { diff --git a/packages/server/src/server/agent/providers/codex-mcp-agent.ts b/packages/server/src/server/agent/providers/codex-mcp-agent.ts index e2e1f8437..4656e71e0 100644 --- a/packages/server/src/server/agent/providers/codex-mcp-agent.ts +++ b/packages/server/src/server/agent/providers/codex-mcp-agent.ts @@ -39,6 +39,7 @@ import type { ListPersistedAgentsOptions, PersistedAgentDescriptor, } from "../agent-sdk-types.js"; +import { getSelfIdentificationInstructions } from "../self-identification-instructions.js"; type CodexMcpAgentConfig = AgentSessionConfig & { provider: "codex" }; @@ -2757,6 +2758,7 @@ function buildCodexMcpConfig( developerInstructions = history; } } + const selfIdentificationInstructions = getSelfIdentificationInstructions(); // Build MCP servers configuration const mcpServers: Record = {}; @@ -2830,9 +2832,12 @@ function buildCodexMcpConfig( } - // Add developer instructions for session resume context - if (developerInstructions) { - configPayload["developer-instructions"] = developerInstructions; + const combinedDeveloperInstructions = [developerInstructions, selfIdentificationInstructions] + .filter(Boolean) + .join("\n\n"); + + if (combinedDeveloperInstructions) { + configPayload["developer-instructions"] = combinedDeveloperInstructions; } return configPayload; diff --git a/packages/server/src/server/agent/self-identification-instructions.ts b/packages/server/src/server/agent/self-identification-instructions.ts new file mode 100644 index 000000000..046bb71f4 --- /dev/null +++ b/packages/server/src/server/agent/self-identification-instructions.ts @@ -0,0 +1,9 @@ +export function getSelfIdentificationInstructions(): string { + return [ + "Once you understand the task and are about to execute multi-step work (e.g., edits, tests, or repo changes), call set_title and set_branch exactly once to self-identify.", + "Skip these calls in plan/read-only modes or when running a slash command.", + "Do not call set_title or set_branch for short, fixed, or single-response requests (e.g., “say X exactly”, yes/no, one-liners).", + "Only call set_branch when you are certain you are running inside a Paseo-owned worktree under .paseo/worktrees.", + "If set_branch fails due to permissions or not being in a Paseo worktree, do not retry.", + ].join("\n"); +} diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index cc006f38e..c9f476b27 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -43,7 +43,6 @@ import { OpenAISTT, type STTConfig } from "./agent/stt-openai.js"; import { OpenAITTS, type TTSConfig } from "./agent/tts-openai.js"; import { AgentManager } from "./agent/agent-manager.js"; import { AgentRegistry } from "./agent/agent-registry.js"; -import { initializeTitleGenerator } from "../services/agent-title-generator.js"; import { attachAgentRegistryPersistence } from "./persistence-hooks.js"; import { createAgentMcpServer } from "./agent/mcp-server.js"; import { createAllClients, shutdownProviders } from "./agent/provider-registry.js"; @@ -367,7 +366,6 @@ export async function createPaseoDaemon( ); } - initializeTitleGenerator(logger.child({ module: "agent-title-generator" }), openaiApiKey); } else { logger.warn("OPENAI_API_KEY not set - LLM, STT, and TTS features will not work"); } diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 4cca704c1..9dbf7c80b 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -63,10 +63,6 @@ import { } from "./file-explorer/service.js"; import { DownloadTokenStore } from "./file-download/token-store.js"; import { PushTokenStore } from "./push/token-store.js"; -import { - generateAgentTitle, - isTitleGeneratorInitialized, -} from "../services/agent-title-generator.js"; import { createWorktree, slugify, validateBranchSlug } from "../utils/worktree.js"; import { getCheckoutDiff, @@ -86,7 +82,6 @@ const READ_ONLY_GIT_ENV: NodeJS.ProcessEnv = { ...process.env, GIT_OPTIONAL_LOCKS: "0", }; -const ACTIVE_TITLE_GENERATIONS = new Set(); const pendingAgentInitializations = new Map>(); let restartRequested = false; const DEFAULT_AGENT_PROVIDER = AGENT_PROVIDER_IDS[0]; @@ -249,7 +244,6 @@ export class Session { private readonly downloadTokenStore: DownloadTokenStore; private readonly pushTokenStore: PushTokenStore; private readonly providerRegistry: ReturnType; - private agentTitleCache: Map = new Map(); private unsubscribeAgentEvents: (() => void) | null = null; private clientActivity: { deviceType: "web" | "mobile"; @@ -529,12 +523,7 @@ export class Session { }); } - if ( - event.event.type === "timeline" || - event.event.type === "turn_completed" - ) { - void this.maybeGenerateAgentTitle(event.agentId); - } + // Title updates are now handled by set_title MCP tool calls. }, { replayState: false } ); @@ -646,14 +635,9 @@ export class Session { } private async getStoredAgentTitle(agentId: string): Promise { - if (this.agentTitleCache.has(agentId)) { - return this.agentTitleCache.get(agentId) ?? null; - } - try { const record = await this.agentRegistry.get(agentId); const title = record?.title ?? null; - this.agentTitleCache.set(agentId, title); return title; } catch (error) { this.sessionLogger.error( @@ -664,59 +648,6 @@ export class Session { } } - private setCachedTitle(agentId: string, title: string | null): void { - this.agentTitleCache.set(agentId, title); - } - - private async maybeGenerateAgentTitle(agentId: string): Promise { - if (!isTitleGeneratorInitialized()) { - return; - } - - const existingTitle = await this.getStoredAgentTitle(agentId); - if (existingTitle) { - return; - } - - if (ACTIVE_TITLE_GENERATIONS.has(agentId)) { - return; - } - - const timeline = this.agentManager.getTimeline(agentId); - if (timeline.length === 0) { - return; - } - - const snapshot = this.agentManager.getAgent(agentId); - if (!snapshot) { - return; - } - - ACTIVE_TITLE_GENERATIONS.add(agentId); - try { - this.sessionLogger.debug( - { agentId }, - `Generating title for agent ${agentId}` - ); - const title = await generateAgentTitle( - this.sessionLogger.child({ module: "agent-title-generator" }), - timeline, - snapshot.cwd - ); - await this.agentRegistry.setTitle(agentId, title); - this.setCachedTitle(agentId, title); - const latest = this.agentManager.getAgent(agentId) ?? snapshot; - await this.forwardAgentState(latest); - } catch (error) { - this.sessionLogger.error( - { err: error, agentId }, - `Failed to generate title for agent ${agentId}` - ); - } finally { - ACTIVE_TITLE_GENERATIONS.delete(agentId); - } - } - /** * Main entry point for processing session messages */ @@ -1071,7 +1002,6 @@ export class Session { ); } - this.agentTitleCache.delete(agentId); this.emit({ type: "agent_deleted", payload: { @@ -1327,7 +1257,6 @@ export class Session { worktreeName ); const snapshot = await this.agentManager.createAgent(sessionConfig); - this.setCachedTitle(snapshot.id, null); await this.forwardAgentState(snapshot); const trimmedPrompt = initialPrompt?.trim(); @@ -1424,7 +1353,6 @@ export class Session { handle, overrides ); - this.setCachedTitle(snapshot.id, null); await this.agentManager.primeAgentHistory(snapshot.id); await this.forwardAgentState(snapshot); const timelineSize = this.emitAgentTimelineSnapshot(snapshot); @@ -1493,7 +1421,6 @@ export class Session { buildConfigOverrides(record), agentId ); - this.setCachedTitle(agentId, null); } await this.agentManager.primeAgentHistory(agentId); await this.forwardAgentState(snapshot); diff --git a/packages/server/src/services/agent-title-generator.ts b/packages/server/src/services/agent-title-generator.ts deleted file mode 100644 index e0576a05a..000000000 --- a/packages/server/src/services/agent-title-generator.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { generateObject } from "ai"; -import { createOpenAI } from "@ai-sdk/openai"; -import { z } from "zod"; -import type { AgentTimelineItem } from "../server/agent/agent-sdk-types.js"; -import { curateAgentActivity } from "../server/agent/activity-curator.js"; -import type pino from "pino"; - -let openai: ReturnType | null = null; - -export function initializeTitleGenerator(logger: pino.Logger, apiKey: string): void { - openai = createOpenAI({ apiKey }); - logger.child({ action: "initialize" }).info("Agent title generator initialized"); -} - -export function isTitleGeneratorInitialized(): boolean { - return openai !== null; -} - -/** - * Generate a concise title for an agent based on its activity - * Returns a 3-5 word title similar to ChatGPT/Claude.ai - */ -export async function generateAgentTitle( - logger: pino.Logger, - timeline: AgentTimelineItem[], - cwd: string -): Promise { - const titleLogger = logger.child({ action: "generate" }); - if (!openai) { - throw new Error("Title generator not initialized"); - } - - if (timeline.length === 0) { - return "New Agent"; - } - - const activityContext = curateAgentActivity(timeline); - - if (!activityContext.trim() || activityContext === "No activity to display.") { - return "New Agent"; - } - - try { - const titleSchema = z.object({ - title: z.string().describe("A concise 3-5 word title describing what the agent is working on"), - }); - - const model = openai("gpt-4o-mini"); - const prompt = `Generate a concise title for this agent session. The title should describe what the user asked the agent to do. - -IMPORTANT: Focus primarily on [User] messages to understand the task. User messages contain the actual request - use their words and intent to name the chat. Tool calls and assistant messages are just implementation details and should NOT drive the title. - -Be specific but brief (3-5 words). Examples: "Fix Authentication Bug", "Build Dashboard Component", "Refactor API Routes". - -Working directory: ${cwd} - -Activity: -${activityContext}`; - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const result = await (generateObject as any)({ - model, - schema: titleSchema, - prompt, - temperature: 0.7, - }); - const object = result.object as z.infer; - - titleLogger.debug({ title: object.title }, "Generated agent title"); - - return object.title; - } catch (err) { - titleLogger.error({ err }, "Failed to generate agent title"); - return "New Agent"; - } -} diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index 4893d582b..ae64fc5c0 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -13,6 +13,7 @@ const READ_ONLY_GIT_ENV: NodeJS.ProcessEnv = { export class NotGitRepoError extends Error { readonly cwd: string; + readonly code = "NOT_GIT_REPO"; constructor(cwd: string) { super(`Not a git repository: ${cwd}`); @@ -96,6 +97,25 @@ async function getCurrentBranch(cwd: string): Promise { return branch.length > 0 ? branch : null; } +export async function renameCurrentBranch( + cwd: string, + newName: string +): Promise<{ previousBranch: string | null; currentBranch: string | null }> { + await requireRepoInfo(cwd); + + const previousBranch = await getCurrentBranch(cwd); + if (!previousBranch || previousBranch === "HEAD") { + throw new Error("Cannot rename branch in detached HEAD state"); + } + + await execAsync(`git branch -m "${newName}"`, { + cwd, + }); + + const currentBranch = await getCurrentBranch(cwd); + return { previousBranch, currentBranch }; +} + async function isWorkingTreeDirty(cwd: string, repoType: "bare" | "normal"): Promise { if (repoType === "bare") { return false; diff --git a/packages/server/src/utils/worktree.ts b/packages/server/src/utils/worktree.ts index 1ffa5d377..8916797cf 100644 --- a/packages/server/src/utils/worktree.ts +++ b/packages/server/src/utils/worktree.ts @@ -35,6 +35,13 @@ export interface PaseoWorktreeInfo { head?: string; } +export type PaseoWorktreeOwnership = { + allowed: boolean; + repoRoot?: string; + worktreeRoot?: string; + worktreePath?: string; +}; + interface CreateWorktreeOptions { branchName: string; cwd: string; @@ -99,26 +106,26 @@ export async function detectRepoInfo(cwd: string): Promise { } } - // Check if we're in a normal git repository - const gitDirPath = join(cwd, ".git"); - if (existsSync(gitDirPath)) { - try { - const { stdout } = await execAsync("git rev-parse --show-toplevel", { - cwd, - env: READ_ONLY_GIT_ENV, - }); - const repoRoot = stdout.trim(); - return { - type: "normal", - path: repoRoot, - name: basename(repoRoot), - }; - } catch (error) { - throw new Error("Failed to determine git repository root"); + // Fallback: allow running from any subdirectory inside a git checkout/worktree. + // Use git's common dir (shared .git) to find the repo root even when cwd has no .git entry. + try { + const { stdout } = await execAsync( + "git rev-parse --path-format=absolute --git-common-dir", + { cwd, env: READ_ONLY_GIT_ENV } + ); + const commonDir = stdout.trim(); + if (!commonDir) { + throw new Error("git-common-dir was empty"); } + const repoRoot = basename(commonDir) === ".git" ? dirname(commonDir) : dirname(commonDir); + return { + type: "normal", + path: repoRoot, + name: basename(repoRoot), + }; + } catch { + throw new Error("Not in a git repository"); } - - throw new Error("Not in a git repository"); } /** @@ -195,6 +202,36 @@ function getPaseoWorktreesRoot(repoRoot: string): string { return join(repoRoot, ".paseo", "worktrees"); } +export async function isPaseoOwnedWorktreeCwd( + cwd: string +): Promise { + const repoInfo = await detectRepoInfo(cwd); + const worktreesRoot = getPaseoWorktreesRoot(repoInfo.path); + const resolvedRoot = resolve(worktreesRoot) + sep; + const resolvedCwd = resolve(cwd); + + if (!resolvedCwd.startsWith(resolvedRoot)) { + return { + allowed: false, + repoRoot: repoInfo.path, + worktreeRoot: worktreesRoot, + worktreePath: resolvedCwd, + }; + } + + const worktrees = await listPaseoWorktrees({ cwd: repoInfo.path }); + const allowed = worktrees.some((entry) => { + const worktreePath = resolve(entry.path); + return resolvedCwd === worktreePath || resolvedCwd.startsWith(worktreePath + sep); + }); + return { + allowed, + repoRoot: repoInfo.path, + worktreeRoot: worktreesRoot, + worktreePath: resolvedCwd, + }; +} + function ensurePaseoIgnoredForRepo(repoInfo: RepoInfo): { updated: boolean; skipped: boolean;