Merge branch 'plan/structured-agent-self-id'

This commit is contained in:
Mohamed Boudra
2026-02-02 14:15:55 +07:00
32 changed files with 503 additions and 3287 deletions

View File

@@ -95,34 +95,6 @@ async function waitForAssistantText(page: Page, text: string) {
return assistantMessage;
}
async function waitForAssistantTextWithPermissions(
page: Page,
text: string,
timeoutMs = 60000
) {
const start = Date.now();
const assistantMessage = page
.getByTestId('assistant-message')
.filter({ hasText: text })
.last();
while (Date.now() - start < timeoutMs) {
if (await assistantMessage.isVisible()) {
return assistantMessage;
}
const allowButton = page.getByText('Allow', { exact: true }).first();
if (await allowButton.isVisible()) {
try {
await allowButton.click({ force: true, timeout: 1000 });
} catch {
// Button can detach during animation; retry on next loop.
}
continue;
}
await page.waitForTimeout(500);
}
throw new Error(`Timed out waiting for assistant text: ${text}`);
}
async function createAgentAndWait(page: Page, message: string) {
const input = page.getByRole('textbox', { name: 'Message agent...' });
await expect(input).toBeEditable();
@@ -250,18 +222,8 @@ test('checkout-first Changes panel ship loop', async ({ page }) => {
const secondCwd = await requestCwd(page);
expect(secondCwd).toBe(firstCwd);
await sendPrompt(
page,
'Only call MCP tools set_title("E2E Ship Loop") and set_branch("feat/e2e-ship-loop"). Do not run bash or other tools. Then respond with exactly: OK'
);
await waitForAssistantTextWithPermissions(page, 'OK', 60000);
await expect(page.getByText('E2E Ship Loop', { exact: true }).first()).toBeVisible();
await openChangesPanel(page);
await expect.poll(
async () => (await getChangesScope(page).getByTestId('changes-branch').innerText()).trim(),
{ timeout: 60000 }
).toBe('feat/e2e-ship-loop');
await sendPrompt(page, "Respond with exactly: OK");
await waitForAssistantText(page, "OK");
const readmePath = path.join(firstCwd, 'README.md');
await appendFile(readmePath, '\nFirst change\n');

View File

@@ -1163,8 +1163,6 @@ const TOOL_NAME_MAP: Record<string, string> = {
read_file: "Read",
apply_patch: "Edit",
paseo_worktree_setup: "Setup",
set_title: "Set title",
set_branch: "Set branch",
thinking: "Thinking",
};

View File

@@ -1,6 +1,4 @@
import { Command } from 'commander'
import { homedir } from 'node:os'
import { join } from 'node:path'
import { createAgentCommand } from './commands/agent/index.js'
import { createDaemonCommand } from './commands/daemon/index.js'
import { createPermitCommand } from './commands/permit/index.js'
@@ -15,7 +13,6 @@ import { runInspectCommand } from './commands/agent/inspect.js'
import { runWaitCommand } from './commands/agent/wait.js'
import { runAttachCommand } from './commands/agent/attach.js'
import { withOutput } from './output/index.js'
import { runSelfIdBridge } from '@paseo/server/self-id-bridge'
const VERSION = '0.1.0'
@@ -140,20 +137,5 @@ export function createCli(): Command {
// Worktree commands
program.addCommand(createWorktreeCommand())
// Self-ID bridge command (for internal use by agents to call set_title/set_branch)
program
.command('self-id-bridge')
.description('Stdio-to-HTTP bridge for Agent Self-ID MCP (internal use)')
.option('--socket <path>', 'Unix socket path', join(process.env.PASEO_HOME ?? join(homedir(), '.paseo'), 'self-id-mcp.sock'))
.option('--agent-id <id>', 'Caller agent ID')
.option('--debug', 'Enable debug logging to stderr')
.action(async (options) => {
await runSelfIdBridge({
socketPath: options.socket,
agentId: options.agentId,
debug: options.debug,
})
})
return program
}

View File

@@ -5,8 +5,7 @@
"type": "module",
"exports": {
".": "./src/server/exports.ts",
"./utils/tool-call-parsers": "./src/utils/tool-call-parsers.ts",
"./self-id-bridge": "./src/self-id-bridge/index.ts"
"./utils/tool-call-parsers": "./src/utils/tool-call-parsers.ts"
},
"scripts": {
"dev": "NODE_ENV=development tsx scripts/dev-runner.ts",

View File

@@ -1,275 +0,0 @@
/**
* Agent Self-ID Bridge
*
* Bridges stdio MCP transport to HTTP-over-Unix-socket transport.
* This allows coding agents (which only support stdio or HTTP MCP) to
* call set_title and set_branch on the Paseo daemon.
*
* Architecture:
* Coding Agent (Claude Code / Codex)
* |
* | stdio (newline-delimited JSON-RPC)
* v
* paseo self-id-bridge (this module)
* |
* | HTTP over Unix socket (${PASEO_HOME}/self-id-mcp.sock)
* v
* Paseo Daemon (Agent Self-ID MCP Server)
*/
import { createInterface } from "node:readline";
import http from "node:http";
export interface SelfIdBridgeOptions {
socketPath: string;
agentId?: string;
debug?: boolean;
}
interface JsonRpcRequest {
jsonrpc: "2.0";
method: string;
params?: unknown;
id?: string | number | null;
}
interface JsonRpcResponse {
jsonrpc: "2.0";
result?: unknown;
error?: { code: number; message: string; data?: unknown };
id: string | number | null;
}
function log(debug: boolean, ...args: unknown[]): void {
if (debug) {
console.error("[self-id-bridge]", ...args);
}
}
function makeHttpRequest(
socketPath: string,
urlPath: string,
body: string,
headers: Record<string, string>
): Promise<{ status: number; headers: http.IncomingHttpHeaders; body: string }> {
return new Promise((resolve, reject) => {
const req = http.request(
{
socketPath,
path: urlPath,
method: "POST",
headers: {
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(body),
...headers,
},
},
(res) => {
const chunks: Buffer[] = [];
res.on("data", (chunk) => chunks.push(chunk));
res.on("end", () => {
resolve({
status: res.statusCode ?? 500,
headers: res.headers,
body: Buffer.concat(chunks).toString("utf-8"),
});
});
res.on("error", reject);
}
);
req.on("error", reject);
req.write(body);
req.end();
});
}
function writeResponse(response: JsonRpcResponse): void {
const line = JSON.stringify(response);
process.stdout.write(line + "\n");
}
function writeError(id: string | number | null, code: number, message: string): void {
writeResponse({
jsonrpc: "2.0",
error: { code, message },
id,
});
}
export async function runSelfIdBridge(options: SelfIdBridgeOptions): Promise<void> {
const { socketPath, agentId, debug = false } = options;
log(debug, `Starting Self-ID bridge to ${socketPath}`);
if (agentId) {
log(debug, `Agent ID: ${agentId}`);
}
let mcpSessionId: string | null = null;
let protocolVersion: string | null = null;
const rl = createInterface({
input: process.stdin,
crlfDelay: Infinity,
});
for await (const line of rl) {
if (!line.trim()) {
continue;
}
let request: JsonRpcRequest;
try {
request = JSON.parse(line);
} catch {
log(debug, "Failed to parse JSON:", line);
writeError(null, -32700, "Parse error");
continue;
}
log(debug, "Request:", request.method, request.id);
// Build headers
const headers: Record<string, string> = {
"Accept": "application/json, text/event-stream",
};
if (mcpSessionId) {
headers["mcp-session-id"] = mcpSessionId;
}
if (protocolVersion && request.method !== "initialize") {
headers["mcp-protocol-version"] = protocolVersion;
}
// Build URL with callerAgentId if provided
let path = "/";
if (agentId) {
path = `/?callerAgentId=${encodeURIComponent(agentId)}`;
}
try {
const response = await makeHttpRequest(
socketPath,
path,
JSON.stringify(request),
headers
);
log(debug, "Response status:", response.status);
// Check for session ID in response headers
const newSessionId = response.headers["mcp-session-id"];
if (typeof newSessionId === "string" && newSessionId !== mcpSessionId) {
mcpSessionId = newSessionId;
log(debug, "Session ID:", mcpSessionId);
}
// Handle content type
const contentType = response.headers["content-type"] ?? "";
if (contentType.includes("text/event-stream")) {
// SSE response - parse events and write each as a line
const events = parseSSE(response.body);
for (const event of events) {
if (event.data) {
process.stdout.write(event.data + "\n");
}
}
} else {
// JSON response - write as-is
const jsonResponse = JSON.parse(response.body) as JsonRpcResponse;
// Extract protocol version from initialize response
if (request.method === "initialize" && jsonResponse.result) {
const result = jsonResponse.result as { protocolVersion?: string };
if (result.protocolVersion) {
protocolVersion = result.protocolVersion;
log(debug, "Protocol version:", protocolVersion);
}
}
writeResponse(jsonResponse);
}
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
log(debug, "HTTP error:", message);
// Check if it's a connection error
if (message.includes("ENOENT") || message.includes("ECONNREFUSED")) {
writeError(
request.id ?? null,
-32603,
`Paseo daemon unreachable at ${socketPath}. Is the daemon running?`
);
} else {
writeError(request.id ?? null, -32603, `Internal error: ${message}`);
}
}
}
log(debug, "stdin closed, exiting");
}
interface SSEEvent {
event?: string;
data?: string;
id?: string;
}
function parseSSE(body: string): SSEEvent[] {
const events: SSEEvent[] = [];
let currentEvent: SSEEvent = {};
let dataLines: string[] = [];
for (const line of body.split("\n")) {
if (line === "") {
// End of event
if (dataLines.length > 0) {
currentEvent.data = dataLines.join("\n");
}
if (Object.keys(currentEvent).length > 0) {
events.push(currentEvent);
}
currentEvent = {};
dataLines = [];
continue;
}
if (line.startsWith(":")) {
// Comment, ignore
continue;
}
const colonIndex = line.indexOf(":");
if (colonIndex === -1) {
// Field with no value
continue;
}
const field = line.slice(0, colonIndex);
let value = line.slice(colonIndex + 1);
if (value.startsWith(" ")) {
value = value.slice(1);
}
switch (field) {
case "event":
currentEvent.event = value;
break;
case "data":
dataLines.push(value);
break;
case "id":
currentEvent.id = value;
break;
}
}
// Handle final event if no trailing newline
if (dataLines.length > 0) {
currentEvent.data = dataLines.join("\n");
}
if (Object.keys(currentEvent).length > 0) {
events.push(currentEvent);
}
return events;
}

View File

@@ -50,7 +50,7 @@ import { AGENT_PROVIDER_DEFINITIONS } from "./provider-registry.js";
import { AgentStorage } from "./agent-storage.js";
import { createWorktree } from "../../utils/worktree.js";
import { WaitForAgentTracker } from "./wait-for-agent-tracker.js";
import { injectLeadingPaseoInstructionTag } from "./paseo-instructions-tag.js";
import { scheduleAgentMetadataGeneration } from "./agent-metadata-generator.js";
export interface AgentManagementMcpOptions {
agentManager: AgentManager;
@@ -345,16 +345,20 @@ export async function createAgentManagementMcpServer(
title: normalizedTitle ?? undefined,
});
if (initialPrompt) {
const initialPromptWithInstructions = injectLeadingPaseoInstructionTag(
initialPrompt,
snapshot.config.paseoPromptInstructions
);
const trimmedPrompt = initialPrompt?.trim();
if (trimmedPrompt) {
scheduleAgentMetadataGeneration({
agentManager,
agentId: snapshot.id,
cwd: snapshot.cwd,
initialPrompt: trimmedPrompt,
explicitTitle: normalizedTitle ?? undefined,
paseoHome: options.paseoHome,
logger: childLogger,
});
try {
agentManager.recordUserMessage(
snapshot.id,
initialPromptWithInstructions
);
agentManager.recordUserMessage(snapshot.id, trimmedPrompt);
} catch (error) {
childLogger.error(
{ err: error, agentId: snapshot.id },
@@ -363,12 +367,7 @@ export async function createAgentManagementMcpServer(
}
try {
startAgentRun(
agentManager,
snapshot.id,
initialPromptWithInstructions,
childLogger
);
startAgentRun(agentManager, snapshot.id, trimmedPrompt, childLogger);
if (!background) {
const result = await waitForAgentWithTimeout(

View File

@@ -5,7 +5,6 @@ import {
type AgentLifecycleStatus,
} from "../../shared/agent-lifecycle.js";
import type { Logger } from "pino";
import { getSelfIdentificationInstructions } from "./self-identification-instructions.js";
import type {
AgentCapabilityFlags,
@@ -59,8 +58,6 @@ export type AgentManagerOptions = {
registry?: AgentStorage;
onAgentAttention?: AgentAttentionCallback;
logger: Logger;
/** Path to the Self-ID MCP Unix socket for UI agent injection */
selfIdMcpSocketPath?: string;
};
export type WaitForAgentOptions = {
@@ -213,7 +210,6 @@ export class AgentManager {
private readonly registry?: AgentStorage;
private readonly previousStatuses = new Map<string, AgentLifecycleStatus>();
private readonly backgroundTasks = new Set<Promise<void>>();
private readonly selfIdMcpSocketPath?: string;
private onAgentAttention?: AgentAttentionCallback;
private logger: Logger;
@@ -222,7 +218,6 @@ export class AgentManager {
options?.maxTimelineItems ?? DEFAULT_MAX_TIMELINE_ITEMS;
this.idFactory = options?.idFactory ?? (() => randomUUID());
this.registry = options?.registry;
this.selfIdMcpSocketPath = options?.selfIdMcpSocketPath;
this.onAgentAttention = options?.onAgentAttention;
this.logger = options.logger.child({ module: "agent", component: "agent-manager" });
if (options?.clients) {
@@ -1261,7 +1256,7 @@ export class AgentManager {
private async normalizeConfig(
config: AgentSessionConfig,
options?: { labels?: Record<string, string>; agentId?: string }
_options?: { labels?: Record<string, string>; agentId?: string }
): Promise<AgentSessionConfig> {
const normalized: AgentSessionConfig = { ...config };
@@ -1275,31 +1270,6 @@ export class AgentManager {
normalized.model = trimmed.length > 0 ? trimmed : undefined;
}
// Inject paseoPromptInstructions and MCP config for UI agents (with ui=true label)
const isUiAgent = options?.labels?.ui === "true";
if (isUiAgent) {
normalized.paseoPromptInstructions = getSelfIdentificationInstructions({
cwd: normalized.cwd,
});
// Inject Self-ID MCP server config (stdio bridge to self-id-mcp.sock)
if (this.selfIdMcpSocketPath && options?.agentId) {
const existingMcpServers = normalized.mcpServers ?? {};
normalized.mcpServers = {
...existingMcpServers,
"paseo-self-id": {
type: "stdio",
command: "paseo",
args: [
"self-id-bridge",
"--socket", this.selfIdMcpSocketPath,
"--agent-id", options.agentId,
],
},
};
}
}
return normalized;
}

View File

@@ -83,7 +83,6 @@ describe("agent MCP end-to-end (offline)", () => {
const daemonConfig: PaseoDaemonConfig = {
listen: `127.0.0.1:${port}`,
paseoHome,
selfIdMcpSocketPath: path.join(paseoHome, "self-id-mcp.sock"),
corsAllowedOrigins: [],
agentMcpRoute: "/mcp/agents",
agentMcpAllowedHosts: [`127.0.0.1:${port}`, `localhost:${port}`],
@@ -150,4 +149,3 @@ describe("agent MCP end-to-end (offline)", () => {
30_000
);
});

View File

@@ -0,0 +1,148 @@
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { execSync } from "child_process";
import { mkdtempSync, rmSync, writeFileSync, realpathSync } from "fs";
import { tmpdir } from "os";
import path from "path";
import pino from "pino";
import { AgentManager } from "./agent-manager.js";
import { AgentStorage } from "./agent-storage.js";
import { createAllClients, shutdownProviders } from "./provider-registry.js";
import { generateAndApplyAgentMetadata } from "./agent-metadata-generator.js";
import { createWorktree, validateBranchSlug } from "../../utils/worktree.js";
const CODEX_TEST_MODEL = "gpt-5.1-codex-mini";
const CODEX_TEST_REASONING_EFFORT = "low";
function tmpCwd(prefix: string): string {
return realpathSync(mkdtempSync(path.join(tmpdir(), prefix)));
}
function initGitRepo(repoDir: string): void {
execSync("git init -b main", { cwd: repoDir, stdio: "pipe" });
execSync("git config user.email 'paseo-test@example.com'", {
cwd: repoDir,
stdio: "pipe",
});
execSync("git config user.name 'Paseo Test'", {
cwd: repoDir,
stdio: "pipe",
});
writeFileSync(path.join(repoDir, "README.md"), "init\n");
execSync("git add README.md", { cwd: repoDir, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'Initial commit'", {
cwd: repoDir,
stdio: "pipe",
});
}
describe("agent metadata generation (real agents)", () => {
const logger = pino({ level: "silent" });
let repoDir: string;
let paseoHome: string;
let storagePath: string;
let manager: AgentManager;
let storage: AgentStorage;
let codexSessionDir: string;
let previousCodexSessionDir: string | undefined;
beforeEach(() => {
repoDir = tmpCwd("metadata-repo-");
initGitRepo(repoDir);
paseoHome = tmpCwd("metadata-paseo-home-");
storagePath = path.join(paseoHome, "agents");
storage = new AgentStorage(storagePath, logger);
manager = new AgentManager({
clients: createAllClients(logger),
registry: storage,
logger,
});
codexSessionDir = tmpCwd("codex-sessions-");
previousCodexSessionDir = process.env.CODEX_SESSION_DIR;
process.env.CODEX_SESSION_DIR = codexSessionDir;
});
afterEach(async () => {
process.env.CODEX_SESSION_DIR = previousCodexSessionDir;
await shutdownProviders(logger);
rmSync(repoDir, { recursive: true, force: true });
rmSync(paseoHome, { recursive: true, force: true });
rmSync(codexSessionDir, { recursive: true, force: true });
}, 60000);
test(
"generates a title using a real Codex agent",
async () => {
const agent = await manager.createAgent({
provider: "codex",
model: CODEX_TEST_MODEL,
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
modeId: "auto",
cwd: repoDir,
title: "Main Agent",
}, "metadata-title-agent");
await generateAndApplyAgentMetadata({
agentManager: manager,
agentId: agent.id,
cwd: repoDir,
initialPrompt: "Use the exact title 'Metadata Title E2E'.",
explicitTitle: null,
paseoHome,
logger,
});
await storage.flush();
const record = await storage.get(agent.id);
expect(record?.title).toBe("Metadata Title E2E");
await manager.closeAgent(agent.id);
},
180000
);
test(
"renames the worktree branch using a real Codex agent",
async () => {
const worktreeSlug = "metadata-worktree";
const worktree = await createWorktree({
branchName: worktreeSlug,
cwd: repoDir,
baseBranch: "main",
worktreeSlug,
paseoHome,
});
const agent = await manager.createAgent({
provider: "codex",
model: CODEX_TEST_MODEL,
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
modeId: "auto",
cwd: worktree.worktreePath,
title: "Worktree Agent",
}, "metadata-branch-agent");
await generateAndApplyAgentMetadata({
agentManager: manager,
agentId: agent.id,
cwd: worktree.worktreePath,
initialPrompt: "Use the exact branch 'feat/metadata-worktree'.",
explicitTitle: "Explicit Title",
paseoHome,
logger,
});
const currentBranch = execSync("git rev-parse --abbrev-ref HEAD", {
cwd: worktree.worktreePath,
stdio: "pipe",
}).toString().trim();
const validation = validateBranchSlug(currentBranch);
expect(validation.valid).toBe(true);
expect(currentBranch).toBe("feat/metadata-worktree");
await manager.closeAgent(agent.id);
},
180000
);
});

View File

@@ -0,0 +1,248 @@
import { basename } from "path";
import { z } from "zod";
import type { Logger } from "pino";
import type { AgentManager } from "./agent-manager.js";
import {
StructuredAgentResponseError,
generateStructuredAgentResponse,
} from "./agent-response-loop.js";
import { validateBranchSlug } from "../../utils/worktree.js";
import {
getCheckoutStatus,
renameCurrentBranch,
type CheckoutStatusResult,
} from "../../utils/checkout-git.js";
const AUTO_GEN_PROVIDER = "codex" as const;
const AUTO_GEN_MODEL = "gpt-5.1-codex-mini";
const AUTO_GEN_REASONING_EFFORT = "low";
export type AgentMetadataGeneratorDeps = {
generateStructuredAgentResponse?: typeof generateStructuredAgentResponse;
getCheckoutStatus?: typeof getCheckoutStatus;
renameCurrentBranch?: typeof renameCurrentBranch;
};
export type AgentMetadataGenerationOptions = {
agentManager: AgentManager;
agentId: string;
cwd: string;
initialPrompt?: string | null;
explicitTitle?: string | null;
paseoHome?: string;
logger: Logger;
deps?: AgentMetadataGeneratorDeps;
};
type AgentMetadataNeeds = {
prompt: string | null;
needsTitle: boolean;
needsBranch: boolean;
};
function hasExplicitTitle(title?: string | null): boolean {
return Boolean(title && title.trim().length > 0);
}
async function canRenameBranch(
cwd: string,
paseoHome: string | undefined,
getCheckoutStatusImpl: typeof getCheckoutStatus
): Promise<boolean> {
let status: CheckoutStatusResult;
try {
status = await getCheckoutStatusImpl(cwd, { paseoHome });
} catch {
return false;
}
if (!status.isGit || !status.isPaseoOwnedWorktree) {
return false;
}
if (!status.currentBranch) {
return false;
}
const worktreeDirName = basename(status.repoRoot);
return status.currentBranch === worktreeDirName;
}
export async function determineAgentMetadataNeeds(
options: Pick<AgentMetadataGenerationOptions, "initialPrompt" | "explicitTitle" | "cwd" | "paseoHome" | "deps">
): Promise<AgentMetadataNeeds> {
const prompt = options.initialPrompt?.trim();
if (!prompt) {
return { prompt: null, needsTitle: false, needsBranch: false };
}
const needsTitle = !hasExplicitTitle(options.explicitTitle);
const getCheckoutStatusImpl = options.deps?.getCheckoutStatus ?? getCheckoutStatus;
const needsBranch = await canRenameBranch(
options.cwd,
options.paseoHome,
getCheckoutStatusImpl
);
return {
prompt,
needsTitle,
needsBranch,
};
}
function buildMetadataSchema(needs: AgentMetadataNeeds): z.ZodObject<any> | null {
if (!needs.needsTitle && !needs.needsBranch) {
return null;
}
const shape: Record<string, z.ZodTypeAny> = {};
if (needs.needsTitle) {
shape.title = z.string().min(1).max(60);
}
if (needs.needsBranch) {
shape.branch = z.string().min(1).max(100);
}
return z.object(shape);
}
function buildPrompt(needs: AgentMetadataNeeds): string {
const fields = [needs.needsTitle ? "title" : null, needs.needsBranch ? "branch" : null].filter(
Boolean
) as string[];
const instructions: string[] = [
"Generate metadata for a coding agent based on the user prompt.",
];
if (needs.needsTitle) {
instructions.push("Title: short descriptive label (<= 60 chars).");
}
if (needs.needsBranch) {
instructions.push(
"Branch: lowercase slug using letters, numbers, hyphens, and slashes only; no spaces, no uppercase, no leading/trailing hyphen, no consecutive hyphens."
);
}
if (fields.length === 1) {
instructions.push(`Return JSON only with a single field '${fields[0]}'.`);
} else {
instructions.push(`Return JSON only with fields '${fields.join("' and '")}'.`);
}
instructions.push("", "User prompt:", needs.prompt ?? "");
return instructions.join("\n");
}
export async function generateAndApplyAgentMetadata(
options: AgentMetadataGenerationOptions
): Promise<void> {
const needs = await determineAgentMetadataNeeds(options);
if (!needs.prompt) {
return;
}
const schema = buildMetadataSchema(needs);
if (!schema) {
return;
}
const generator = options.deps?.generateStructuredAgentResponse ?? generateStructuredAgentResponse;
const getCheckoutStatusImpl = options.deps?.getCheckoutStatus ?? getCheckoutStatus;
const renameCurrentBranchImpl = options.deps?.renameCurrentBranch ?? renameCurrentBranch;
let result: { title?: string; branch?: string };
try {
result = await generator({
manager: options.agentManager,
agentConfig: {
provider: AUTO_GEN_PROVIDER,
model: AUTO_GEN_MODEL,
reasoningEffort: AUTO_GEN_REASONING_EFFORT,
cwd: options.cwd,
title: "Agent metadata generator",
internal: true,
},
prompt: buildPrompt(needs),
schema,
schemaName: "AgentMetadata",
maxRetries: 2,
});
} catch (error) {
if (error instanceof StructuredAgentResponseError) {
options.logger.warn(
{ err: error, agentId: options.agentId },
"Structured metadata generation failed"
);
return;
}
options.logger.error(
{ err: error, agentId: options.agentId },
"Agent metadata generation failed"
);
return;
}
if (needs.needsTitle && typeof result.title === "string") {
const normalizedTitle = result.title.trim();
if (normalizedTitle.length > 0) {
await options.agentManager.setTitle(options.agentId, normalizedTitle);
}
}
if (needs.needsBranch && typeof result.branch === "string") {
const normalizedBranch = result.branch.trim();
const validation = validateBranchSlug(normalizedBranch);
if (!validation.valid) {
options.logger.warn(
{ agentId: options.agentId, branch: normalizedBranch, error: validation.error },
"Generated branch name is invalid"
);
return;
}
let status: CheckoutStatusResult;
try {
status = await getCheckoutStatusImpl(options.cwd, { paseoHome: options.paseoHome });
} catch (error) {
options.logger.warn(
{ err: error, agentId: options.agentId },
"Failed to re-check branch eligibility"
);
return;
}
if (!status.isGit || !status.isPaseoOwnedWorktree || !status.currentBranch) {
return;
}
const worktreeDirName = basename(status.repoRoot);
if (status.currentBranch !== worktreeDirName) {
return;
}
try {
await renameCurrentBranchImpl(options.cwd, normalizedBranch);
} catch (error) {
options.logger.warn(
{ err: error, agentId: options.agentId, branch: normalizedBranch },
"Failed to rename branch"
);
}
}
}
export function scheduleAgentMetadataGeneration(
options: AgentMetadataGenerationOptions
): void {
queueMicrotask(() => {
void generateAndApplyAgentMetadata(options).catch((error) => {
options.logger.error(
{ err: error, agentId: options.agentId },
"Agent metadata generation crashed"
);
});
});
}

View File

@@ -234,14 +234,6 @@ export type AgentSessionConfig = {
networkAccess?: boolean;
webSearch?: boolean;
reasoningEffort?: string;
/**
* Paseo-owned instructions injected into the first user prompt via
* <paseo-instructions>...</paseo-instructions>.
*
* These MUST NOT be sent via provider system/developer instructions (those are
* reserved for provider/session behaviors like resuming).
*/
paseoPromptInstructions?: string;
extra?: {
codex?: AgentMetadata;
claude?: Partial<ClaudeAgentOptions>;

View File

@@ -1,204 +0,0 @@
/**
* Agent Self-ID MCP Server
*
* Purpose: Agents identifying themselves (title, branch)
* Transport: Stdio bridge → Unix socket (${PASEO_HOME}/self-id-mcp.sock)
* Server name: "paseo-agent-self-id"
*
* Tools:
* - set_title - Set agent's display title
* - set_branch - Rename git branch (Paseo worktrees only)
*
* Requires callerAgentId - must know which agent is calling.
*/
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { basename } from "path";
import { z } from "zod";
import { ensureValidJson } from "../json-utils.js";
import type { Logger } from "pino";
import type { AgentManager } from "./agent-manager.js";
import {
isPaseoOwnedWorktreeCwd,
validateBranchSlug,
} from "../../utils/worktree.js";
import {
NotGitRepoError,
renameCurrentBranch,
getCheckoutStatus,
} from "../../utils/checkout-git.js";
export interface AgentSelfIdMcpOptions {
agentManager: AgentManager;
paseoHome?: string;
/**
* ID of the agent that is connecting to this MCP server.
* Required - this server only works for managed agents.
*/
callerAgentId: string;
logger: Logger;
}
type ToolErrorCode = "NOT_ALLOWED" | "NOT_GIT_REPO" | "INVALID_BRANCH";
class AgentSelfIdToolError extends Error {
readonly code: ToolErrorCode;
constructor(code: ToolErrorCode, message: string) {
super(message);
this.name = "AgentSelfIdToolError";
this.code = code;
}
}
export async function createAgentSelfIdMcpServer(
options: AgentSelfIdMcpOptions
): Promise<McpServer> {
const { agentManager, callerAgentId, logger } = options;
const childLogger = logger.child({
module: "agent",
component: "agent-self-id-mcp",
callerAgentId,
});
const server = new McpServer({
name: "paseo-agent-self-id",
version: "1.0.0",
});
server.registerTool(
"set_title",
{
title: "Set Agent Title",
description: "Update the agent's title in the registry.",
inputSchema: {
title: z
.string()
.min(1)
.max(60)
.describe("Short descriptive title (<= 60 chars)."),
},
outputSchema: {
success: z.boolean(),
title: z.string(),
},
},
async ({ title }) => {
const agent = agentManager.getAgent(callerAgentId);
if (!agent) {
throw new Error(`Agent ${callerAgentId} not found`);
}
const normalizedTitle = title.trim();
if (!normalizedTitle) {
throw new AgentSelfIdToolError("NOT_ALLOWED", "Title cannot be empty");
}
if (normalizedTitle.length > 60) {
throw new AgentSelfIdToolError(
"NOT_ALLOWED",
"Title must be 60 characters or fewer"
);
}
childLogger.debug({ title: normalizedTitle }, "Setting agent title");
await agentManager.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 }) => {
const agent = agentManager.getAgent(callerAgentId);
if (!agent) {
throw new Error(`Agent ${callerAgentId} not found`);
}
const validation = validateBranchSlug(name);
if (!validation.valid) {
throw new AgentSelfIdToolError(
"INVALID_BRANCH",
validation.error ?? "Invalid branch name"
);
}
let ownership;
try {
ownership = await isPaseoOwnedWorktreeCwd(agent.cwd, {
paseoHome: options.paseoHome,
});
} catch (error) {
const notGitError =
error instanceof NotGitRepoError
? error
: new NotGitRepoError(agent.cwd);
throw new AgentSelfIdToolError("NOT_GIT_REPO", notGitError.message);
}
if (!ownership.allowed) {
throw new AgentSelfIdToolError(
"NOT_ALLOWED",
"Branch renames are only allowed inside Paseo-owned worktrees"
);
}
const status = await getCheckoutStatus(agent.cwd, {
paseoHome: options.paseoHome,
});
if (!status.isGit || !status.currentBranch) {
throw new AgentSelfIdToolError("NOT_GIT_REPO", "Unable to determine current branch");
}
const worktreeDirName = basename(status.repoRoot);
if (status.currentBranch !== worktreeDirName) {
throw new AgentSelfIdToolError(
"NOT_ALLOWED",
"Branch has already been renamed. Use git commands for subsequent renames."
);
}
childLogger.debug({ branch: name }, "Renaming branch");
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,
}),
};
}
);
return server;
}

View File

@@ -1,359 +0,0 @@
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 { AgentStorage } from "./agent-storage.js";
import { createAgentSelfIdMcpServer } from "./agent-self-id-mcp.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<AgentSession> {
return new TestAgentSession(config);
}
async resumeSession(config?: Partial<AgentSessionConfig>): Promise<AgentSession> {
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<AgentRunResult> {
return {
sessionId: this.id ?? this.config.provider,
finalText: "",
timeline: [],
};
}
async *stream(): AsyncGenerator<AgentStreamEvent> {
yield { type: "turn_started", provider: this.provider };
yield { type: "turn_completed", provider: this.provider };
}
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {}
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<void> {}
getPendingPermissions() {
return [];
}
async respondToPermission(): Promise<void> {}
describePersistence() {
return {
provider: this.provider,
sessionId: this.id,
};
}
async interrupt(): Promise<void> {}
async close(): Promise<void> {}
}
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-"));
const paseoHome = path.join(repoDir, "paseo-home");
try {
initGitRepo(repoDir);
const worktree = await createWorktree({
branchName: "self-ident",
cwd: repoDir,
baseBranch: "main",
worktreeSlug: "self-ident",
paseoHome,
});
const storagePath = path.join(repoDir, "agents");
const storage = new AgentStorage(storagePath, logger);
const manager = new AgentManager({
clients: { codex: new TestAgentClient() },
registry: storage,
logger,
idFactory: () => "agent-self-ident",
});
const agent = await manager.createAgent({
provider: "codex",
cwd: worktree.worktreePath,
});
const server = await createAgentSelfIdMcpServer({
agentManager: manager,
paseoHome,
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-"));
const paseoHome = path.join(repoDir, "paseo-home");
try {
initGitRepo(repoDir);
const worktree = await createWorktree({
branchName: "self-ident-subdir",
cwd: repoDir,
baseBranch: "main",
worktreeSlug: "self-ident-subdir",
paseoHome,
});
const nestedDir = path.join(worktree.worktreePath, "nested");
execSync(`mkdir -p "${nestedDir}"`, { stdio: "ignore" });
const storagePath = path.join(repoDir, "agents");
const storage = new AgentStorage(storagePath, logger);
const manager = new AgentManager({
clients: { codex: new TestAgentClient() },
registry: storage,
logger,
idFactory: () => "agent-self-ident-subdir",
});
const agent = await manager.createAgent({
provider: "codex",
cwd: nestedDir,
});
const server = await createAgentSelfIdMcpServer({
agentManager: manager,
paseoHome,
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 subsequent renames after initial rename", async () => {
const repoDir = mkdtempSync(path.join(tmpdir(), "paseo-self-ident-"));
const paseoHome = path.join(repoDir, "paseo-home");
try {
initGitRepo(repoDir);
const worktree = await createWorktree({
branchName: "initial-branch",
cwd: repoDir,
baseBranch: "main",
worktreeSlug: "initial-branch",
paseoHome,
});
const storagePath = path.join(repoDir, "agents");
const storage = new AgentStorage(storagePath, logger);
const manager = new AgentManager({
clients: { codex: new TestAgentClient() },
registry: storage,
logger,
idFactory: () => "agent-subsequent-rename",
});
const agent = await manager.createAgent({
provider: "codex",
cwd: worktree.worktreePath,
});
const server = await createAgentSelfIdMcpServer({
agentManager: manager,
paseoHome,
callerAgentId: agent.id,
logger,
});
const tool = (server as any)._registeredTools["set_branch"];
// First rename should succeed
await tool.callback({ name: "first-rename" });
const branchAfterFirst = execSync("git rev-parse --abbrev-ref HEAD", {
cwd: worktree.worktreePath,
stdio: "pipe",
})
.toString()
.trim();
expect(branchAfterFirst).toBe("first-rename");
// Second rename should fail
await expect(tool.callback({ name: "second-rename" })).rejects.toMatchObject({
code: "NOT_ALLOWED",
message: expect.stringContaining("already been renamed"),
});
// Branch should still be first-rename
const branchAfterSecond = execSync("git rev-parse --abbrev-ref HEAD", {
cwd: worktree.worktreePath,
stdio: "pipe",
})
.toString()
.trim();
expect(branchAfterSecond).toBe("first-rename");
} 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 storagePath = path.join(repoDir, "agents");
const storage = new AgentStorage(storagePath, logger);
const manager = new AgentManager({
clients: { codex: new TestAgentClient() },
registry: storage,
logger,
idFactory: () => "agent-non-worktree",
});
const agent = await manager.createAgent({
provider: "codex",
cwd: repoDir,
});
const server = await createAgentSelfIdMcpServer({
agentManager: manager,
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 storagePath = path.join(repoDir, "agents");
const storage = new AgentStorage(storagePath, logger);
const manager = new AgentManager({
clients: { codex: new TestAgentClient() },
registry: storage,
logger,
idFactory: () => "agent-non-git",
});
const agent = await manager.createAgent({
provider: "codex",
cwd: repoDir,
});
const server = await createAgentSelfIdMcpServer({
agentManager: manager,
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 });
}
});
});

View File

@@ -2,7 +2,6 @@ import { describe, expect, it, vi } from "vitest";
import { createTestLogger } from "../../test-utils/test-logger.js";
import { createAgentMcpServer } from "./mcp-server.js";
import { createAgentSelfIdMcpServer } from "./agent-self-id-mcp.js";
import type { AgentManager, ManagedAgent } from "./agent-manager.js";
import type { AgentStorage } from "./agent-storage.js";
@@ -104,24 +103,27 @@ describe("create_agent MCP tool", () => {
);
});
it("set_title trims and persists titles for caller agent", async () => {
const { agentManager, spies } = createTestDeps();
spies.agentManager.getAgent.mockReturnValue({
id: "agent-1",
it("trims caller-provided titles before createAgent", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
spies.agentManager.createAgent.mockResolvedValue({
id: "agent-456",
cwd: "/tmp/repo",
lifecycle: "idle",
currentModeId: null,
availableModes: [],
} as ManagedAgent);
const server = await createAgentSelfIdMcpServer({
agentManager,
logger,
callerAgentId: "agent-1",
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
const tool = (server as any)._registeredTools["create_agent"];
await tool.callback({
cwd: "/tmp/repo",
title: " Fix auth ",
});
const tool = (server as any)._registeredTools["set_title"];
await tool.callback({ title: " Fix auth " });
expect(spies.agentManager.setTitle).toHaveBeenCalledWith(
"agent-1",
"Fix auth"
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
expect.objectContaining({
title: "Fix auth",
})
);
});
});

View File

@@ -27,7 +27,7 @@ import { AGENT_PROVIDER_DEFINITIONS } from "./provider-registry.js";
import { AgentStorage } from "./agent-storage.js";
import { createWorktree } from "../../utils/worktree.js";
import { WaitForAgentTracker } from "./wait-for-agent-tracker.js";
import { injectLeadingPaseoInstructionTag } from "./paseo-instructions-tag.js";
import { scheduleAgentMetadataGeneration } from "./agent-metadata-generator.js";
export interface AgentMcpServerOptions {
agentManager: AgentManager;
@@ -440,13 +440,20 @@ export async function createAgentMcpServer(
title: normalizedTitle ?? undefined,
});
if (initialPrompt) {
const initialPromptWithInstructions = injectLeadingPaseoInstructionTag(
initialPrompt,
snapshot.config.paseoPromptInstructions
);
const trimmedPrompt = initialPrompt?.trim();
if (trimmedPrompt) {
scheduleAgentMetadataGeneration({
agentManager,
agentId: snapshot.id,
cwd: snapshot.cwd,
initialPrompt: trimmedPrompt,
explicitTitle: snapshot.config.title,
paseoHome: options.paseoHome,
logger: childLogger,
});
try {
agentManager.recordUserMessage(snapshot.id, initialPromptWithInstructions);
agentManager.recordUserMessage(snapshot.id, trimmedPrompt);
} catch (error) {
childLogger.error(
{ err: error, agentId: snapshot.id },
@@ -455,7 +462,7 @@ export async function createAgentMcpServer(
}
try {
startAgentRun(agentManager, snapshot.id, initialPromptWithInstructions, childLogger);
startAgentRun(agentManager, snapshot.id, trimmedPrompt, childLogger);
// If not running in background, wait for completion
if (!background) {

View File

@@ -1,45 +0,0 @@
import { describe, expect, test } from "vitest";
import {
formatPaseoInstructionTag,
hasLeadingPaseoInstructionTag,
injectLeadingPaseoInstructionTag,
stripLeadingPaseoInstructionTag,
} from "./paseo-instructions-tag.js";
describe("paseo instruction tags", () => {
test("formatPaseoInstructionTag wraps content", () => {
expect(formatPaseoInstructionTag("hello")).toBe(
"<paseo-instructions>\nhello\n</paseo-instructions>"
);
});
test("hasLeadingPaseoInstructionTag detects leading tag", () => {
expect(hasLeadingPaseoInstructionTag("<paseo-instructions>\nX\n</paseo-instructions>")).toBe(
true
);
expect(hasLeadingPaseoInstructionTag("nope <paseo-instructions>")).toBe(false);
});
test("stripLeadingPaseoInstructionTag strips leading tag content", () => {
const input = [
"<paseo-instructions>",
"do the thing",
"</paseo-instructions>",
"",
"Hello world",
].join("\n");
expect(stripLeadingPaseoInstructionTag(input)).toBe("Hello world");
});
test("injectLeadingPaseoInstructionTag prepends instructions when missing", () => {
expect(injectLeadingPaseoInstructionTag("Hello", "do the thing")).toBe(
"<paseo-instructions>\ndo the thing\n</paseo-instructions>\n\nHello"
);
});
test("injectLeadingPaseoInstructionTag is idempotent when tag already present", () => {
const input = "<paseo-instructions>\nX\n</paseo-instructions>\n\nHello";
expect(injectLeadingPaseoInstructionTag(input, "do the thing")).toBe(input);
});
});

View File

@@ -1,48 +0,0 @@
const OPEN_TAG = "<paseo-instructions>";
const CLOSE_TAG = "</paseo-instructions>";
export function formatPaseoInstructionTag(instructions: string): string {
return `${OPEN_TAG}\n${instructions}\n${CLOSE_TAG}`;
}
export function hasLeadingPaseoInstructionTag(text: string): boolean {
return /^\s*<paseo-instructions>/.test(text);
}
/**
* Prepend paseo instructions to a prompt exactly once (idempotent by content).
* This is intended for agent creation / initial prompt only.
*/
export function injectLeadingPaseoInstructionTag(
prompt: string,
instructions: string | null | undefined
): string {
const normalizedInstructions = instructions?.trim() ?? "";
if (!normalizedInstructions) {
return prompt;
}
if (hasLeadingPaseoInstructionTag(prompt)) {
return prompt;
}
return `${formatPaseoInstructionTag(normalizedInstructions)}\n\n${prompt}`;
}
/**
* Remove a leading <paseo-instructions>...</paseo-instructions> block, if present.
* The content is treated as internal metadata and is discarded.
*/
export function stripLeadingPaseoInstructionTag(text: string): string {
const leadingMatch = text.match(/^\s*<paseo-instructions>/);
if (!leadingMatch || leadingMatch.index !== 0) {
return text;
}
const openEnd = leadingMatch[0].length;
const closeStart = text.indexOf(CLOSE_TAG, openEnd);
if (closeStart === -1) {
return text;
}
const closeEnd = closeStart + CLOSE_TAG.length;
return text.slice(closeEnd).trimStart();
}

View File

@@ -147,8 +147,6 @@ describe("Codex app-server provider (integration)", () => {
cwd,
modeId: "auto",
approvalPolicy: "on-request",
paseoPromptInstructions:
"You must use the shell tool for command execution tasks. Do not answer without running the command.",
model: CODEX_TEST_MODEL,
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
});
@@ -229,8 +227,6 @@ describe("Codex app-server provider (integration)", () => {
cwd,
modeId: "full-access",
approvalPolicy: "on-request",
paseoPromptInstructions:
"You must use shell for commands and apply_patch for file edits. Do not skip tool usage.",
model: CODEX_TEST_MODEL,
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
});
@@ -552,8 +548,6 @@ describe("Codex app-server provider (integration)", () => {
cwd,
modeId: "full-access",
approvalPolicy: "on-request",
paseoPromptInstructions:
"You must use the apply_patch tool for file edits. Do not use the shell tool for file changes.",
model: CODEX_TEST_MODEL,
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
});
@@ -616,9 +610,6 @@ describe("Codex app-server provider (integration)", () => {
expect(sawPermissionResolved).toBe(true);
}
expect(sawPermission || timelineItems.length > 0).toBe(true);
expect(
timelineItems.some((item) => item.type === "tool_call" && item.name === "apply_patch")
).toBe(true);
expect(readFileSync(targetPath, "utf8").trim()).toBe("ok");
} finally {
cleanup();

View File

@@ -33,7 +33,6 @@ import os from "node:os";
import path from "node:path";
import readline from "node:readline";
import { injectLeadingPaseoInstructionTag } from "../paseo-instructions-tag.js";
const DEFAULT_TIMEOUT_MS = 14 * 24 * 60 * 60 * 1000;
const CODEX_PROVIDER = "codex" as const;
@@ -1335,37 +1334,15 @@ class CodexAppServerAgentSession implements AgentSession {
private buildUserInput(prompt: AgentPromptInput): unknown[] {
if (typeof prompt === "string") {
const text = this.paseoInstructionsInjected
? prompt
: injectLeadingPaseoInstructionTag(prompt, this.config.paseoPromptInstructions);
this.paseoInstructionsInjected = true;
return [{ type: "text", text }];
return [{ type: "text", text: prompt }];
}
const blocks = prompt as AgentPromptContentBlock[];
if (this.paseoInstructionsInjected) {
return blocks;
}
this.paseoInstructionsInjected = true;
if (blocks.length === 0) {
return blocks;
}
const first = blocks[0];
if (first && typeof first === "object" && (first as { type?: string }).type === "text") {
const textBlock = first as { type: "text"; text: string };
const text = injectLeadingPaseoInstructionTag(
textBlock.text ?? "",
this.config.paseoPromptInstructions
);
return [{ ...textBlock, text }, ...blocks.slice(1)];
}
const injected = injectLeadingPaseoInstructionTag(
"",
this.config.paseoPromptInstructions
);
if (injected.trim().length === 0) {
return blocks;
}
return [{ type: "text", text: injected }, ...blocks];
return blocks;
}
private emitEvent(event: AgentStreamEvent): void {

View File

@@ -1,47 +0,0 @@
import { describe, expect, test } from "vitest";
import { mkdtempSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { __test__ } from "./codex-mcp-agent.js";
import type { AgentSessionConfig } from "../agent-sdk-types.js";
describe("codex developer-instructions vs paseo prompt instructions", () => {
test("does not inject Paseo self-identification into developer-instructions", () => {
const dir = mkdtempSync(path.join(os.tmpdir(), "codex-rollout-"));
const rolloutPath = path.join(dir, "rollout.jsonl");
const entry = {
type: "response_item",
payload: {
type: "message",
role: "user",
content: [{ input_text: "hello from history" }],
},
};
writeFileSync(rolloutPath, JSON.stringify(entry) + "\n", "utf8");
const config: AgentSessionConfig = {
provider: "codex",
cwd: dir,
modeId: "auto",
};
const payload = __test__.buildCodexMcpConfig(
config,
"Hello world",
"auto",
undefined,
rolloutPath
);
const dev = payload["developer-instructions"] ?? "";
expect(dev).toContain("<previous_conversation>");
expect(dev).toContain("hello from history");
expect(dev.toLowerCase()).not.toContain("set_title");
expect(dev.toLowerCase()).not.toContain("set_branch");
expect(dev.toLowerCase()).not.toContain("you are running under paseo");
});
});

View File

@@ -1,28 +0,0 @@
export interface SelfIdentificationContext {
cwd?: string;
}
function looksLikePaseoWorktree(cwd?: string): boolean {
if (!cwd) return false;
// Simple heuristic: if cwd contains .paseo/worktrees, it's likely a Paseo worktree
return cwd.includes(".paseo/worktrees") || cwd.includes(".paseo\\worktrees");
}
export function getSelfIdentificationInstructions(
context?: SelfIdentificationContext
): string {
const inWorktree = looksLikePaseoWorktree(context?.cwd);
const lines = [
"You are running under Paseo, an agent orchestration tool.",
"You MUST call set_title immediately after understanding the task. Call it exactly once per task—do not repeat.",
];
if (inWorktree) {
lines.push(
"You are running inside a Paseo-owned worktree. Call set_branch once (alongside set_title) to name your branch."
);
}
return lines.join("\n");
}

View File

@@ -46,7 +46,6 @@ import { AgentManager } from "./agent/agent-manager.js";
import { AgentStorage } from "./agent/agent-storage.js";
import { attachAgentStoragePersistence } from "./persistence-hooks.js";
import { createAgentMcpServer } from "./agent/mcp-server.js";
import { createAgentSelfIdMcpServer } from "./agent/agent-self-id-mcp.js";
import { createAllClients, shutdownProviders } from "./agent/provider-registry.js";
import { createTerminalManager, type TerminalManager } from "../terminal/terminal-manager.js";
import {
@@ -73,7 +72,6 @@ export type PaseoOpenAIConfig = {
export type PaseoDaemonConfig = {
listen: string;
paseoHome: string;
selfIdMcpSocketPath: string;
corsAllowedOrigins: string[];
agentMcpRoute: string;
agentMcpAllowedHosts: string[];
@@ -214,7 +212,6 @@ export async function createPaseoDaemon(
...config.agentClients,
},
registry: agentStorage,
selfIdMcpSocketPath: config.selfIdMcpSocketPath,
logger,
});
@@ -231,7 +228,6 @@ export async function createPaseoDaemon(
);
const agentMcpTransports: AgentMcpTransportMap = new Map();
const selfIdMcpTransports: AgentMcpTransportMap = new Map();
const allowedHosts = config.agentMcpAllowedHosts;
const createAgentMcpTransport = async (callerAgentId?: string) => {
@@ -357,116 +353,6 @@ export async function createPaseoDaemon(
app.delete(agentMcpRoute, handleAgentMcpRequest);
logger.info({ route: agentMcpRoute }, "Agent MCP server mounted on main app");
// Create dedicated Self-ID MCP server on Unix socket for agent self-identification
// This only provides set_title and set_branch tools for coding agents
// Host validation is disabled since Unix sockets don't have HTTP hosts
const selfIdMcpSocketPath = config.selfIdMcpSocketPath;
const createSelfIdMcpTransport = async (callerAgentId: string) => {
const selfIdMcpServer = await createAgentSelfIdMcpServer({
agentManager,
paseoHome: config.paseoHome,
callerAgentId,
logger,
});
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (sessionId) => {
selfIdMcpTransports.set(sessionId, transport);
logger.debug({ sessionId, callerAgentId }, "Self-ID MCP session initialized");
},
onsessionclosed: (sessionId) => {
selfIdMcpTransports.delete(sessionId);
logger.debug({ sessionId }, "Self-ID MCP session closed");
},
// Disable host validation for Unix socket
enableDnsRebindingProtection: false,
});
transport.onclose = () => {
if (transport.sessionId) {
selfIdMcpTransports.delete(transport.sessionId);
}
};
transport.onerror = (err) => {
logger.error({ err }, "Self-ID MCP transport error");
};
await selfIdMcpServer.connect(transport);
return transport;
};
const handleSelfIdMcpRequest: express.RequestHandler = async (req, res) => {
if (config.mcpDebug) {
logger.debug(
{
method: req.method,
url: req.originalUrl,
sessionId: req.header("mcp-session-id"),
body: req.body,
},
"Self-ID MCP request"
);
}
try {
const sessionId = req.header("mcp-session-id");
let transport = sessionId ? selfIdMcpTransports.get(sessionId) : undefined;
if (!transport) {
if (req.method !== "POST") {
res.status(400).json({
jsonrpc: "2.0",
error: { code: -32000, message: "Missing or invalid MCP session" },
id: null,
});
return;
}
if (!isInitializeRequest(req.body)) {
res.status(400).json({
jsonrpc: "2.0",
error: { code: -32000, message: "Initialization request expected" },
id: null,
});
return;
}
const callerAgentIdRaw = req.query.callerAgentId;
const callerAgentId =
typeof callerAgentIdRaw === "string"
? callerAgentIdRaw
: Array.isArray(callerAgentIdRaw) && typeof callerAgentIdRaw[0] === "string"
? callerAgentIdRaw[0]
: undefined;
if (!callerAgentId) {
res.status(400).json({
jsonrpc: "2.0",
error: { code: -32000, message: "callerAgentId query parameter is required for Self-ID MCP" },
id: null,
});
return;
}
transport = await createSelfIdMcpTransport(callerAgentId);
}
await transport.handleRequest(req as any, res as any, req.body);
} catch (err) {
logger.error({ err }, "Failed to handle Self-ID MCP request");
if (!res.headersSent) {
res.status(500).json({
jsonrpc: "2.0",
error: { code: -32603, message: "Internal MCP server error" },
id: null,
});
}
}
};
const selfIdMcpApp = express();
selfIdMcpApp.use(express.json());
selfIdMcpApp.post("/", handleSelfIdMcpRequest);
selfIdMcpApp.get("/", handleSelfIdMcpRequest);
selfIdMcpApp.delete("/", handleSelfIdMcpRequest);
const selfIdMcpSocketServer = createHTTPServer(selfIdMcpApp);
let sttService: OpenAISTT | null = null;
let ttsService: OpenAITTS | null = null;
@@ -525,28 +411,7 @@ export async function createPaseoDaemon(
const start = async () => {
// Acquire PID lock
await acquirePidLock(config.paseoHome, selfIdMcpSocketPath);
// Start Self-ID MCP socket server first
await new Promise<void>((resolve, reject) => {
const onError = (err: Error) => {
selfIdMcpSocketServer.off("listening", onListening);
reject(err);
};
const onListening = () => {
selfIdMcpSocketServer.off("error", onError);
logger.info({ path: selfIdMcpSocketPath }, `Self-ID MCP server listening on ${selfIdMcpSocketPath}`);
resolve();
};
selfIdMcpSocketServer.once("error", onError);
selfIdMcpSocketServer.once("listening", onListening);
// Remove stale socket file if it exists
if (existsSync(selfIdMcpSocketPath)) {
unlinkSync(selfIdMcpSocketPath);
}
selfIdMcpSocketServer.listen(selfIdMcpSocketPath);
});
await acquirePidLock(config.paseoHome, config.listen);
// Start main HTTP server
await new Promise<void>((resolve, reject) => {
@@ -626,16 +491,10 @@ export async function createPaseoDaemon(
await new Promise<void>((resolve) => {
httpServer.close(() => resolve());
});
await new Promise<void>((resolve) => {
selfIdMcpSocketServer.close(() => resolve());
});
// Clean up socket files
if (listenTarget.type === "socket" && existsSync(listenTarget.path)) {
unlinkSync(listenTarget.path);
}
if (existsSync(selfIdMcpSocketPath)) {
unlinkSync(selfIdMcpSocketPath);
}
// Release PID lock
await releasePidLock(config.paseoHome);
};

View File

@@ -15,9 +15,6 @@ function getDefaultListen(): string {
return `127.0.0.1:${DEFAULT_PORT}`;
}
function getSelfIdMcpSocketPath(paseoHome: string): string {
return path.join(paseoHome, "self-id-mcp.sock");
}
function parseOpenAIConfig(env: NodeJS.ProcessEnv) {
const apiKey = env.OPENAI_API_KEY;
@@ -82,7 +79,6 @@ export function loadConfig(
// Default is TCP at 127.0.0.1:6767
const listen = env.PASEO_LISTEN ?? persisted.listen ?? getDefaultListen();
const mcpListen = getListenForMcp(listen);
const selfIdMcpSocketPath = getSelfIdMcpSocketPath(paseoHome);
const envCorsOrigins = env.PASEO_CORS_ORIGINS
? env.PASEO_CORS_ORIGINS.split(",").map((s) => s.trim())
@@ -91,7 +87,6 @@ export function loadConfig(
return {
listen,
paseoHome,
selfIdMcpSocketPath,
corsAllowedOrigins: [...persisted.cors.allowedOrigins, ...envCorsOrigins],
agentMcpRoute: DEFAULT_AGENT_MCP_ROUTE,
agentMcpAllowedHosts: [mcpListen, `localhost:${mcpListen.split(":")[1]}`],

View File

@@ -3,8 +3,6 @@ import { mkdtempSync, writeFileSync, rmSync, existsSync, realpathSync } from "fs
import { tmpdir } from "os";
import path from "path";
import { execSync } from "child_process";
import { experimental_createMCPClient } from "ai";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import {
createDaemonTestContext,
@@ -30,63 +28,6 @@ function hasGitHubCliAuth(): boolean {
const testWithGitHubCliAuth = hasGitHubCliAuth() ? test : test.skip;
type McpToolResult = {
structuredContent?: Record<string, unknown>;
content?: Array<{ structuredContent?: Record<string, unknown> } | Record<string, unknown>>;
toolResult?: unknown;
isError?: boolean;
};
type McpClient = {
callTool: (input: { name: string; args?: Record<string, unknown> }) => Promise<unknown>;
close: () => Promise<void>;
};
function getStructuredContent(result: McpToolResult): Record<string, unknown> | null {
if (result.structuredContent && typeof result.structuredContent === "object") {
return result.structuredContent;
}
const content = result.content?.[0];
if (content && "structuredContent" in content && content.structuredContent) {
return content.structuredContent;
}
if (content && typeof content === "object") {
return content;
}
return null;
}
function getToolResultText(result: McpToolResult): string {
const chunks: string[] = [];
if (result.structuredContent) {
chunks.push(JSON.stringify(result.structuredContent));
}
if (result.toolResult !== undefined) {
chunks.push(JSON.stringify(result.toolResult));
}
for (const entry of result.content ?? []) {
if (entry && typeof entry === "object") {
if ("text" in entry && typeof (entry as { text?: unknown }).text === "string") {
chunks.push(String((entry as { text?: unknown }).text));
}
if (
"structuredContent" in entry &&
(entry as { structuredContent?: unknown }).structuredContent
) {
chunks.push(JSON.stringify((entry as { structuredContent?: unknown }).structuredContent));
}
}
}
return chunks.join(" ").trim();
}
async function createMcpClient(port: number, agentId: string): Promise<McpClient> {
const url = new URL(`http://127.0.0.1:${port}/mcp/agents`);
url.searchParams.set("callerAgentId", agentId);
const transport = new StreamableHTTPClientTransport(url);
return (await experimental_createMCPClient({ transport })) as McpClient;
}
function initGitRepo(repoDir: string): void {
execSync("git init -b main", { cwd: repoDir, stdio: "pipe" });
execSync("git config user.email 'paseo-test@example.com'", {
@@ -439,49 +380,4 @@ describe("daemon checkout ship loop", () => {
60000
);
test(
"set_branch is rejected outside Paseo-owned worktrees",
async () => {
const repoDir = tmpCwd("checkout-ship-non-paseo-");
let agentId: string | null = null;
let mcpClient: McpClient | null = null;
try {
initGitRepo(repoDir);
const agent = await ctx.client.createAgent({
provider: "codex",
model: CODEX_TEST_MODEL,
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
cwd: repoDir,
title: "Checkout Non-Paseo",
});
agentId = agent.id;
mcpClient = await createMcpClient(ctx.daemon.port, agent.id);
let errorMessage = "";
try {
const result = (await mcpClient.callTool({
name: "set_branch",
args: { name: "not-allowed" },
})) as McpToolResult;
errorMessage = getToolResultText(result);
} catch (error) {
errorMessage = error instanceof Error ? error.message : String(error);
}
expect(errorMessage).toMatch(
/NOT_ALLOWED|Branch renames are only allowed|Tool set_branch|MCP error -32602/
);
} finally {
if (mcpClient) {
await mcpClient.close().catch(() => undefined);
}
if (agentId) {
await ctx.client.deleteAgent(agentId).catch(() => undefined);
}
rmSync(repoDir, { recursive: true, force: true });
}
},
60000
);
});

View File

@@ -1,52 +0,0 @@
import { describe, test, expect, beforeEach, afterEach } from "vitest";
import {
createDaemonTestContext,
type DaemonTestContext,
} from "../test-utils/index.js";
describe("self-id MCP e2e", () => {
let ctx: DaemonTestContext;
beforeEach(async () => {
ctx = await createDaemonTestContext();
});
afterEach(async () => {
await ctx.cleanup();
}, 60000);
test("UI agent can call set_title to change its title", async () => {
// Create a Claude agent with ui=true label (triggers MCP injection)
const agent = await ctx.client.createAgent({
provider: "claude",
cwd: "/tmp",
title: "Initial Title",
labels: { ui: "true" },
});
expect(agent.id).toBeTruthy();
expect(agent.title).toBe("Initial Title");
// Send a message asking the agent to call set_title
await ctx.client.sendMessage(
agent.id,
"Use the set_title MCP tool to change your title to 'Updated via MCP'. Only call set_title, nothing else."
);
// Wait for permission request (default mode requires permission for MCP tools)
const state = await ctx.client.waitForFinish(agent.id, 60000);
expect(state.status).toBe("permission");
expect(state.final?.pendingPermissions?.length).toBeGreaterThan(0);
expect(state.final?.pendingPermissions?.[0]?.name).toBe("mcp__paseo-self-id__set_title");
// Approve the permission
await ctx.client.respondToPermission(agent.id, state.final!.pendingPermissions![0]!.id, {
behavior: "allow",
});
// Wait for agent to complete
const finalState = await ctx.client.waitForFinish(agent.id, 60000);
expect(finalState.status).toBe("idle");
expect(finalState.final?.title).toBe("Updated via MCP");
}, 180000);
});

View File

@@ -1,3 +1,4 @@
import "dotenv/config";
import { beforeAll, afterAll } from "vitest";
import { mkdtempSync } from "fs";
import { tmpdir } from "os";

View File

@@ -3,7 +3,7 @@ import { describe, expect, test } from "vitest";
import { serializeAgentStreamEvent } from "./messages.js";
describe("serializeAgentStreamEvent", () => {
test("strips leading paseo-instructions from user_message timeline items", () => {
test("preserves user_message text as-is", () => {
const event = {
type: "timeline",
provider: "claude",
@@ -15,22 +15,7 @@ describe("serializeAgentStreamEvent", () => {
} as any;
const serialized = serializeAgentStreamEvent(event) as any;
expect(serialized.item.text).toBe("Hello");
expect(serialized.item.text).toBe(event.item.text);
expect(serialized.item.messageId).toBe("m1");
});
test("does not strip non-leading tags", () => {
const event = {
type: "timeline",
provider: "claude",
item: {
type: "user_message",
text: "Hello <paseo-instructions>\nX\n</paseo-instructions>",
},
} as any;
const serialized = serializeAgentStreamEvent(event) as any;
expect(serialized.item.text).toBe(event.item.text);
});
});

View File

@@ -1,7 +1,6 @@
import type { ManagedAgent } from "./agent/agent-manager.js";
import { toAgentPayload } from "./agent/agent-projections.js";
import type { AgentStreamEvent } from "./agent/agent-sdk-types.js";
import { stripLeadingPaseoInstructionTag } from "./agent/paseo-instructions-tag.js";
import type {
AgentSnapshotPayload,
AgentStreamEventPayload,
@@ -34,15 +33,5 @@ export function serializeAgentStreamEvent(
if (event.item.type !== "user_message") {
return event as AgentStreamEventPayload;
}
const stripped = stripLeadingPaseoInstructionTag(event.item.text);
if (stripped === event.item.text) {
return event as AgentStreamEventPayload;
}
return {
...event,
item: {
...event.item,
text: stripped,
},
} as AgentStreamEventPayload;
return event as AgentStreamEventPayload;
}

View File

@@ -53,7 +53,7 @@ export type AgentMcpTransportFactory = () => Promise<Transport>;
import { buildProviderRegistry } from "./agent/provider-registry.js";
import { AgentManager } from "./agent/agent-manager.js";
import type { ManagedAgent } from "./agent/agent-manager.js";
import { injectLeadingPaseoInstructionTag } from "./agent/paseo-instructions-tag.js";
import { scheduleAgentMetadataGeneration } from "./agent/agent-metadata-generator.js";
import { toAgentPayload } from "./agent/agent-projections.js";
import {
StructuredAgentResponseError,
@@ -578,7 +578,7 @@ export class Session {
});
}
// Title updates are now handled by set_title MCP tool calls.
// Title updates may be applied asynchronously after agent creation.
},
{ replayState: false }
);
@@ -1402,14 +1402,20 @@ export class Session {
const trimmedPrompt = initialPrompt?.trim();
if (trimmedPrompt) {
scheduleAgentMetadataGeneration({
agentManager: this.agentManager,
agentId: snapshot.id,
cwd: snapshot.cwd,
initialPrompt: trimmedPrompt,
explicitTitle: snapshot.config.title,
paseoHome: this.paseoHome,
logger: this.sessionLogger,
});
try {
const initialPromptWithInstructions = injectLeadingPaseoInstructionTag(
trimmedPrompt,
snapshot.config.paseoPromptInstructions
);
await this.handleSendAgentMessage(
snapshot.id,
initialPromptWithInstructions,
trimmedPrompt,
uuidv4(),
images
);

View File

@@ -3,7 +3,6 @@ import { readFileSync, writeFileSync, rmSync, readdirSync } from "node:fs";
import { appendFile, mkdir, readFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import http from "node:http";
import type {
AgentCapabilityFlags,
AgentClient,
@@ -32,141 +31,6 @@ const TEST_CAPABILITIES: AgentCapabilityFlags = {
supportsToolInvocations: true,
};
type UnixHttpResponse = {
status: number;
headers: http.IncomingHttpHeaders;
body: string;
};
function parseSseDataFrames(body: string): string[] {
const frames: string[] = [];
const parts = body.split(/\n\n+/g);
for (const part of parts) {
const lines = part.split("\n");
const dataLines: string[] = [];
for (const line of lines) {
if (line.startsWith("data:")) {
dataLines.push(line.slice("data:".length).trimStart());
}
}
if (dataLines.length > 0) {
frames.push(dataLines.join("\n"));
}
}
return frames;
}
function extractJsonRpcBody(res: UnixHttpResponse): unknown {
const contentType = String(res.headers["content-type"] ?? "");
if (contentType.includes("text/event-stream")) {
const frames = parseSseDataFrames(res.body);
if (frames.length === 0) {
throw new Error("Empty SSE response from Self-ID MCP server");
}
return JSON.parse(frames[frames.length - 1]!);
}
return JSON.parse(res.body);
}
async function unixSocketJsonRpcRequest(params: {
socketPath: string;
path: string;
headers?: Record<string, string>;
body: unknown;
}): Promise<UnixHttpResponse> {
const bodyText = JSON.stringify(params.body);
return await new Promise<UnixHttpResponse>((resolve, reject) => {
const req = http.request(
{
socketPath: params.socketPath,
path: params.path,
method: "POST",
headers: {
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(bodyText),
Accept: "application/json, text/event-stream",
...(params.headers ?? {}),
},
},
(res) => {
const chunks: Buffer[] = [];
res.on("data", (chunk) => chunks.push(chunk));
res.on("end", () => {
resolve({
status: res.statusCode ?? 500,
headers: res.headers,
body: Buffer.concat(chunks).toString("utf-8"),
});
});
res.on("error", reject);
}
);
req.on("error", reject);
req.write(bodyText);
req.end();
});
}
async function callSelfIdMcpTool(params: {
socketPath: string;
callerAgentId: string;
toolName: "set_title";
args: { title: string };
}): Promise<void> {
// Minimal MCP-over-HTTP (Unix socket) client, modeled after packages/server/src/self-id-bridge.
const urlPath = `/?callerAgentId=${encodeURIComponent(params.callerAgentId)}`;
const initReq = {
jsonrpc: "2.0",
id: 1,
method: "initialize",
params: {
protocolVersion: "2024-11-05",
capabilities: {},
clientInfo: { name: "fake-agent", version: "0.0.0" },
},
};
const initRes = await unixSocketJsonRpcRequest({
socketPath: params.socketPath,
path: urlPath,
body: initReq,
});
const mcpSessionId = typeof initRes.headers["mcp-session-id"] === "string" ? initRes.headers["mcp-session-id"] : null;
let protocolVersion: string | null = null;
const initParsed = extractJsonRpcBody(initRes) as { result?: { protocolVersion?: string }; error?: { message?: string } };
if (initParsed.error) {
throw new Error(initParsed.error.message ?? "Self-ID MCP initialize failed");
}
protocolVersion = initParsed.result?.protocolVersion ?? null;
const toolReq = {
jsonrpc: "2.0",
id: 2,
method: "tools/call",
params: {
name: params.toolName,
arguments: params.args,
},
};
const headers: Record<string, string> = {};
if (mcpSessionId) headers["mcp-session-id"] = mcpSessionId;
if (protocolVersion) headers["mcp-protocol-version"] = protocolVersion;
const toolRes = await unixSocketJsonRpcRequest({
socketPath: params.socketPath,
path: urlPath,
headers,
body: toolReq,
});
const parsed = extractJsonRpcBody(toolRes) as { error?: { message?: string } };
if (parsed.error) {
throw new Error(parsed.error.message ?? "Self-ID MCP tool call failed");
}
}
type Deferred<T> = {
promise: Promise<T>;
@@ -228,7 +92,7 @@ function buildToolCallForPrompt(provider: string, prompt: string) {
const text = prompt.toLowerCase();
if (provider === "claude") {
if (text.includes("read") && text.includes("/etc/hosts")) {
return { name: "Read", input: { path: "/etc/hosts" }, output: { lines: 7 } };
return { name: "Read", input: { path: "/etc/hosts" }, output: undefined };
}
if (text.includes("rm -f permission.txt")) {
return { name: "Bash", input: { command: "rm -f permission.txt" }, output: { ok: true } };
@@ -242,9 +106,6 @@ function buildToolCallForPrompt(provider: string, prompt: string) {
if (text.includes("edit") && text.includes(".txt")) {
return { name: "Edit", input: { file: "test.txt" }, output: { applied: true } };
}
if (text.includes("set_title") && text.includes("mcp")) {
return { name: "mcp__paseo-self-id__set_title", input: { title: "Updated via MCP" }, output: { ok: true } };
}
return null;
}
@@ -253,10 +114,16 @@ function buildToolCallForPrompt(provider: string, prompt: string) {
return { name: "shell", input: { command: "echo hello" }, output: { stdout: "hello\n" } };
}
if (text.includes("read") && text.includes("/etc/hosts")) {
return { name: "read_file", input: { path: "/etc/hosts" }, output: { lines: 7 } };
return { name: "read_file", input: { path: "/etc/hosts" }, output: undefined };
}
if (text.includes("read") && text.includes("tool-create.txt")) {
return { name: "read_file", input: { path: "tool-create.txt" }, output: undefined };
}
if (text.includes("edit") && text.includes(".txt")) {
return { name: "apply_patch", input: { patch: "*** Begin Patch\n*** End Patch\n" }, output: { applied: true } };
const output = text.includes("tool-create.txt")
? { applied: true, file: "tool-create.txt" }
: { applied: true };
return { name: "apply_patch", input: { patch: "*** Begin Patch\n*** End Patch\n" }, output };
}
const printfMatch =
/printf\s+\"ok\"\s*>\s*([^\s`]+)/i.exec(text) ??
@@ -415,6 +282,21 @@ class FakeAgentSession implements AgentSession {
await this.applyToolSideEffects(tool.name, tool.input ?? {}, textPrompt);
let toolOutput: unknown = tool.output;
if (!toolOutput && (tool.name === "Read" || tool.name === "read_file")) {
const pathInput =
typeof tool.input?.path === "string" ? tool.input.path : "/etc/hosts";
const resolvedPath = path.isAbsolute(pathInput)
? pathInput
: path.join(this.config.cwd ?? process.cwd(), pathInput);
try {
const content = readFileSync(resolvedPath, "utf8");
toolOutput = { path: pathInput, content };
} catch {
toolOutput = { path: pathInput, content: "" };
}
}
const toolCompleted: AgentStreamEvent = {
type: "timeline",
provider: this.providerName,
@@ -424,7 +306,7 @@ class FakeAgentSession implements AgentSession {
callId,
status: "completed",
input: tool.input ?? undefined,
output: tool.output ?? { ok: true },
output: toolOutput ?? { ok: true },
},
};
await this.appendHistoryEvent(toolCompleted);
@@ -695,36 +577,21 @@ class FakeAgentSession implements AgentSession {
return;
}
if (toolName === "mcp__paseo-self-id__set_title") {
const title = typeof toolInput.title === "string" ? toolInput.title : null;
const server = (this.config.mcpServers as Record<string, any> | undefined)?.["paseo-self-id"];
const args = Array.isArray(server?.args) ? (server.args as string[]) : [];
const socketIndex = args.indexOf("--socket");
const agentIndex = args.indexOf("--agent-id");
const socketPath = socketIndex >= 0 ? args[socketIndex + 1] : null;
const callerAgentId = agentIndex >= 0 ? args[agentIndex + 1] : null;
if (!title || !socketPath || !callerAgentId) {
throw new Error("FakeAgentSession missing paseo-self-id MCP config");
}
await callSelfIdMcpTool({
socketPath,
callerAgentId,
toolName: "set_title",
args: { title },
});
return;
}
if (toolName === "Edit" || toolName === "apply_patch") {
const lowerPrompt = prompt.toLowerCase();
const match = /edit the file\s+([^\s]+)\s+and change/i.exec(prompt);
const filePath = match?.[1];
const filePath = match?.[1] ?? (lowerPrompt.includes("tool-create.txt") ? "tool-create.txt" : null);
if (filePath) {
try {
const before = readFileSync(filePath, "utf8");
const after = before.replace(/hello/g, "goodbye");
writeFileSync(filePath, after);
const resolved = path.isAbsolute(filePath)
? filePath
: path.join(this.config.cwd ?? process.cwd(), filePath);
const before = readFileSync(resolved, "utf8");
let after = before.replace(/hello/g, "goodbye");
if (lowerPrompt.includes("alpha") && lowerPrompt.includes("beta")) {
after = after.replace(/alpha/g, "beta");
}
writeFileSync(resolved, after);
} catch {
// ignore
}

View File

@@ -65,7 +65,6 @@ export async function createTestPaseoDaemon(
const config: PaseoDaemonConfig = {
listen: `${listenHost}:${port}`,
paseoHome,
selfIdMcpSocketPath: path.join(paseoHome, "self-id-mcp.sock"),
corsAllowedOrigins: options.corsAllowedOrigins ?? [],
agentMcpRoute: "/mcp/agents",
agentMcpAllowedHosts: [`127.0.0.1:${port}`, `localhost:${port}`, `${listenHost}:${port}`],