From 837d0fb387b05384c593b02d1091be0cfa23f84a Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 13 Feb 2026 00:00:57 +0700 Subject: [PATCH] feat: improve Claude agent model normalization and tool mapping --- packages/app/eas.json | 8 + packages/app/src/app/settings.tsx | 51 +++-- .../claude-agent.model-normalization.test.ts | 60 +++++ .../server/agent/providers/claude-agent.ts | 122 +++++++++- ...ude-agent.voice-history-regression.test.ts | 209 ++++++++++++++++++ .../providers/claude/tool-call-mapper.test.ts | 18 ++ .../providers/claude/tool-call-mapper.ts | 131 ++++++++++- packages/server/src/utils/worktree.test.ts | 8 + packages/server/src/utils/worktree.ts | 24 ++ .../website/src/routes/docs/worktrees.tsx | 3 + 10 files changed, 601 insertions(+), 33 deletions(-) create mode 100644 packages/server/src/server/agent/providers/claude-agent.model-normalization.test.ts create mode 100644 packages/server/src/server/agent/providers/claude-agent.voice-history-regression.test.ts diff --git a/packages/app/eas.json b/packages/app/eas.json index cdb4855bf..b12790095 100644 --- a/packages/app/eas.json +++ b/packages/app/eas.json @@ -19,6 +19,14 @@ "env": { "APP_VARIANT": "production" } + }, + "production-apk": { + "extends": "production", + "distribution": "internal", + "android": { + "buildType": "apk", + "gradleCommand": ":app:assembleRelease -x lint -x lintVitalAnalyzeRelease -x lintVitalRelease -x generateReleaseLintModel -x generateReleaseLintVitalModel" + } } }, "submit": { diff --git a/packages/app/src/app/settings.tsx b/packages/app/src/app/settings.tsx index a9f748952..d75b6c588 100644 --- a/packages/app/src/app/settings.tsx +++ b/packages/app/src/app/settings.tsx @@ -429,8 +429,9 @@ export default function SettingsScreen() { const isMountedRef = useRef(true); const lastHandledEditHostRef = useRef(null); const appVersion = Constants.expoConfig?.version ?? (Constants as any).manifest?.version ?? "0.1.0"; - const editingDaemonLive = editingDaemon - ? daemons.find((daemon) => daemon.serverId === editingDaemon.serverId) ?? null + const editingServerId = editingDaemon?.serverId ?? null; + const editingDaemonLive = editingServerId + ? daemons.find((daemon) => daemon.serverId === editingServerId) ?? null : null; const pendingNameHostname = useSessionStore( useCallback( @@ -448,6 +449,13 @@ export default function SettingsScreen() { }; }, []); + // Keep the edit modal bound to live registry state. + useEffect(() => { + if (!editingServerId) return; + if (editingDaemonLive) return; + setEditingDaemon(null); + }, [editingDaemonLive, editingServerId]); + const waitForCondition = useCallback( async (predicate: () => boolean, timeoutMs: number, intervalMs = 250) => { const deadline = Date.now() + timeoutMs; @@ -516,7 +524,7 @@ export default function SettingsScreen() { ]); const handleSaveEditDaemon = useCallback(async (nextLabelRaw: string) => { - if (!editingDaemon) return; + if (!editingServerId) return; if (isSavingEdit) return; const nextLabel = nextLabelRaw.trim(); @@ -527,7 +535,7 @@ export default function SettingsScreen() { try { setIsSavingEdit(true); - await updateHost(editingDaemon.serverId, { label: nextLabel }); + await updateHost(editingServerId, { label: nextLabel }); handleCloseEditDaemon(); } catch (error) { console.error("[Settings] Failed to rename host", error); @@ -535,7 +543,7 @@ export default function SettingsScreen() { } finally { setIsSavingEdit(false); } - }, [editingDaemon, handleCloseEditDaemon, isSavingEdit, updateHost]); + }, [editingServerId, handleCloseEditDaemon, isSavingEdit, updateHost]); const handleRemoveConnection = useCallback( async (serverId: string, connectionId: string) => { @@ -550,13 +558,13 @@ export default function SettingsScreen() { }, []); const handleAddConnectionFromModal = useCallback(() => { - if (!editingDaemon) return; - const serverId = editingDaemon.serverId; + if (!editingServerId) return; + const serverId = editingServerId; setEditingDaemon(null); setAddConnectionTargetServerId(serverId); setPendingEditReopenServerId(serverId); setIsAddHostMethodVisible(true); - }, [editingDaemon]); + }, [editingServerId]); const handleThemeChange = useCallback( (newTheme: AppSettings["theme"]) => { @@ -763,11 +771,11 @@ export default function SettingsScreen() { ) : null} void handleSaveEditDaemon(label)} @@ -873,6 +881,7 @@ function HostDetailModal({ }: HostDetailModalProps) { const { theme } = useUnistyles(); const [draftLabel, setDraftLabel] = useState(""); + const [isDraftLabelDirty, setIsDraftLabelDirty] = useState(false); const activeServerIdRef = useRef(null); const [pendingRemoveConnection, setPendingRemoveConnection] = useState<{ serverId: string; connectionId: string; title: string } | null>(null); const [isRemovingConnection, setIsRemovingConnection] = useState(false); @@ -1018,22 +1027,30 @@ function HostDetailModal({ })(); const connectionError = typeof lastError === "string" && lastError.trim().length > 0 ? lastError.trim() : null; + const handleDraftLabelChange = useCallback((nextValue: string) => { + setDraftLabel(nextValue); + setIsDraftLabelDirty(true); + }, []); + useEffect(() => { if (!visible || !host) return; - if (activeServerIdRef.current !== host.serverId) { + const hostChanged = activeServerIdRef.current !== host.serverId; + if (hostChanged) { setDraftLabel(host.label ?? ""); + setIsDraftLabelDirty(false); activeServerIdRef.current = host.serverId; return; } - if (!draftLabel.trim()) { + if (!isDraftLabelDirty) { setDraftLabel(host.label ?? ""); } - }, [visible, host, draftLabel]); + }, [visible, host?.serverId, host?.label, isDraftLabelDirty]); useEffect(() => { if (!visible) { activeServerIdRef.current = null; setIsRestarting(false); + setIsDraftLabelDirty(false); } }, [visible]); @@ -1072,7 +1089,7 @@ function HostDetailModal({ diff --git a/packages/server/src/server/agent/providers/claude-agent.model-normalization.test.ts b/packages/server/src/server/agent/providers/claude-agent.model-normalization.test.ts new file mode 100644 index 000000000..b6b399bc4 --- /dev/null +++ b/packages/server/src/server/agent/providers/claude-agent.model-normalization.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test } from "vitest"; +import { normalizeClaudeRuntimeModelId } from "./claude-agent.js"; + +describe("normalizeClaudeRuntimeModelId", () => { + const supportedModelIds = new Set(["default", "opus", "haiku"]); + + test("maps runtime Sonnet IDs to default alias", () => { + const normalized = normalizeClaudeRuntimeModelId({ + runtimeModelId: "claude-sonnet-4-5-20250929", + supportedModelIds, + }); + expect(normalized).toBe("default"); + }); + + test("prefers alias even when runtime versioned Sonnet ID appears in supported list", () => { + const normalized = normalizeClaudeRuntimeModelId({ + runtimeModelId: "claude-sonnet-4-5-20250929", + supportedModelIds: new Set([ + "default", + "opus", + "haiku", + "claude-sonnet-4-5-20250929", + ]), + }); + expect(normalized).toBe("default"); + }); + + test("maps runtime Opus IDs to opus alias", () => { + const normalized = normalizeClaudeRuntimeModelId({ + runtimeModelId: "claude-opus-4-5-20251101", + supportedModelIds, + }); + expect(normalized).toBe("opus"); + }); + + test("maps runtime Haiku IDs to haiku alias", () => { + const normalized = normalizeClaudeRuntimeModelId({ + runtimeModelId: "claude-haiku-4-5-20251001", + supportedModelIds, + }); + expect(normalized).toBe("haiku"); + }); + + test("uses configured model when runtime ID is unknown", () => { + const normalized = normalizeClaudeRuntimeModelId({ + runtimeModelId: "claude-custom-unknown", + supportedModelIds, + configuredModelId: "opus", + }); + expect(normalized).toBe("opus"); + }); + + test("preserves runtime model when mapping is not possible", () => { + const normalized = normalizeClaudeRuntimeModelId({ + runtimeModelId: "claude-custom-unknown", + supportedModelIds: new Set(["x", "y"]), + }); + expect(normalized).toBe("claude-custom-unknown"); + }); +}); diff --git a/packages/server/src/server/agent/providers/claude-agent.ts b/packages/server/src/server/agent/providers/claude-agent.ts index b35063d8f..3162a09bf 100644 --- a/packages/server/src/server/agent/providers/claude-agent.ts +++ b/packages/server/src/server/agent/providers/claude-agent.ts @@ -91,6 +91,92 @@ function normalizeClaudeModelLabel(model: ModelInfo): string { return fallback; } +type NormalizeClaudeRuntimeModelIdOptions = { + runtimeModelId: string; + supportedModelIds: ReadonlySet | null; + configuredModelId?: string | null; + currentModelId?: string | null; +}; + +function normalizeModelIdCandidate(modelId: string | null | undefined): string | null { + if (typeof modelId !== "string") { + return null; + } + const trimmed = modelId.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function pickSupportedModelId( + supportedModelIds: ReadonlySet, + candidate: string | null | undefined +): string | null { + const normalizedCandidate = normalizeModelIdCandidate(candidate); + if (!normalizedCandidate) { + return null; + } + return supportedModelIds.has(normalizedCandidate) ? normalizedCandidate : null; +} + +export function normalizeClaudeRuntimeModelId( + options: NormalizeClaudeRuntimeModelIdOptions +): string { + const runtimeModel = options.runtimeModelId.trim(); + if (!runtimeModel) { + return runtimeModel; + } + + const supportedModelIds = options.supportedModelIds; + if (!supportedModelIds || supportedModelIds.size === 0) { + return runtimeModel; + } + + const lowerRuntimeModel = runtimeModel.toLowerCase(); + if (lowerRuntimeModel.includes("sonnet")) { + const explicitSonnet = pickSupportedModelId(supportedModelIds, "sonnet"); + if (explicitSonnet) { + return explicitSonnet; + } + const defaultAlias = pickSupportedModelId(supportedModelIds, "default"); + if (defaultAlias) { + return defaultAlias; + } + } + if (lowerRuntimeModel.includes("opus")) { + const alias = pickSupportedModelId(supportedModelIds, "opus"); + if (alias) { + return alias; + } + } + if (lowerRuntimeModel.includes("haiku")) { + const alias = pickSupportedModelId(supportedModelIds, "haiku"); + if (alias) { + return alias; + } + } + + if (supportedModelIds.has(runtimeModel)) { + return runtimeModel; + } + + const configuredModelId = pickSupportedModelId( + supportedModelIds, + options.configuredModelId + ); + if (configuredModelId) { + return configuredModelId; + } + + const currentModelId = pickSupportedModelId( + supportedModelIds, + options.currentModelId + ); + if (currentModelId) { + return currentModelId; + } + + return runtimeModel; +} + const CLAUDE_CAPABILITIES: AgentCapabilityFlags = { supportsStreaming: true, supportsSessionPersistence: true, @@ -583,6 +669,7 @@ class ClaudeAgentSession implements AgentSession { private activeTurnPromise: Promise | null = null; private cachedRuntimeInfo: AgentRuntimeInfo | null = null; private lastOptionsModel: string | null = null; + private selectableModelIds: Set | null = null; private activeSidechains = new Map(); private compacting = false; private queryRestartNeeded = false; @@ -726,13 +813,6 @@ class ClaudeAgentSession implements AgentSession { queue.end(); }; this.cancelCurrentTurn = requestCancel; - if (this.historyPending && this.persistedHistory.length > 0) { - for (const item of this.persistedHistory) { - queue.push({ type: "timeline", item, provider: "claude" }); - } - this.historyPending = false; - this.persistedHistory = []; - } // Start forwarding events and track the promise so future turns can wait for completion const forwardPromise = this.forwardPromptEvents(sdkMessage, queue, turnId); @@ -974,6 +1054,20 @@ class ClaudeAgentSession implements AgentSession { }; } + private async primeSelectableModelIds(query: Query): Promise { + try { + const models = await query.supportedModels(); + const ids = models + .map((model) => model.value?.trim()) + .filter((id): id is string => typeof id === "string" && id.length > 0); + this.selectableModelIds = new Set(ids); + this.logger.debug({ modelIds: ids }, "Primed Claude selectable model IDs"); + } catch (error) { + this.selectableModelIds = null; + this.logger.warn({ err: error }, "Failed to prime Claude selectable model IDs"); + } + } + private async ensureQuery(): Promise { if (this.query && !this.queryRestartNeeded) { return this.query; @@ -992,6 +1086,7 @@ class ClaudeAgentSession implements AgentSession { this.logger.debug({ options }, "claude query"); this.input = input; this.query = query({ prompt: input, options }); + await this.primeSelectableModelIds(this.query); await this.query.setPermissionMode(this.currentMode); return this.query; } @@ -1469,8 +1564,17 @@ class ClaudeAgentSession implements AgentSession { this.persistence = null; // Capture actual model from SDK init message (not just the configured model) if (message.model) { - this.logger.debug({ model: message.model }, "Captured model from SDK init"); - this.lastOptionsModel = message.model; + const normalizedModel = normalizeClaudeRuntimeModelId({ + runtimeModelId: message.model, + supportedModelIds: this.selectableModelIds, + configuredModelId: this.config.model ?? null, + currentModelId: this.lastOptionsModel, + }); + this.logger.debug( + { model: message.model, normalizedModel }, + "Captured model from SDK init" + ); + this.lastOptionsModel = normalizedModel; // Invalidate cached runtime info so it picks up the new model this.cachedRuntimeInfo = null; } diff --git a/packages/server/src/server/agent/providers/claude-agent.voice-history-regression.test.ts b/packages/server/src/server/agent/providers/claude-agent.voice-history-regression.test.ts new file mode 100644 index 000000000..fd6e2a3f4 --- /dev/null +++ b/packages/server/src/server/agent/providers/claude-agent.voice-history-regression.test.ts @@ -0,0 +1,209 @@ +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +import { createTestLogger } from "../../../test-utils/test-logger.js"; +import { ClaudeAgentClient } from "./claude-agent.js"; +import type { + AgentPersistenceHandle, + AgentStreamEvent, +} from "../agent-sdk-types.js"; + +const sdkMocks = vi.hoisted(() => ({ + query: vi.fn(), +})); + +vi.mock("@anthropic-ai/claude-agent-sdk", () => ({ + query: sdkMocks.query, +})); + +const LIVE_REPLY_MARKER = "LIVE_ONLY_REPLY_MARKER"; +const HISTORY_USER_MARKER = "HISTORY_ONLY_USER_MARKER"; +const HISTORY_ASSISTANT_MARKER = "HISTORY_ONLY_ASSISTANT_MARKER"; + +function buildSdkQueryMock() { + const events = [ + { + type: "system", + subtype: "init", + session_id: "history-session", + permissionMode: "default", + model: "opus", + }, + { + type: "assistant", + message: { + content: LIVE_REPLY_MARKER, + }, + }, + { + type: "result", + subtype: "success", + usage: { + input_tokens: 1, + cache_read_input_tokens: 0, + output_tokens: 1, + }, + total_cost_usd: 0, + }, + ]; + + let index = 0; + return { + next: vi.fn(async () => { + if (index >= events.length) { + return { done: true, value: undefined }; + } + const value = events[index]; + index += 1; + return { done: false, value }; + }), + interrupt: vi.fn(async () => undefined), + return: vi.fn(async () => undefined), + setPermissionMode: vi.fn(async () => undefined), + setModel: vi.fn(async () => undefined), + supportedModels: vi.fn(async () => [{ value: "opus", displayName: "Opus" }]), + supportedCommands: vi.fn(async () => []), + }; +} + +function collectTimelineText(events: AgentStreamEvent[]): string { + const chunks: string[] = []; + for (const event of events) { + if (event.type !== "timeline") { + continue; + } + if (event.item.type === "user_message") { + chunks.push(event.item.text); + } + if (event.item.type === "assistant_message") { + chunks.push(event.item.text); + } + } + return chunks.join("\n"); +} + +describe("ClaudeAgentSession history replay regression", () => { + let tempRoot: string; + let cwd: string; + let configDir: string; + let previousClaudeConfigDir: string | undefined; + + beforeEach(() => { + sdkMocks.query.mockImplementation(() => buildSdkQueryMock()); + + tempRoot = mkdtempSync(path.join(os.tmpdir(), "claude-history-regression-")); + cwd = path.join(tempRoot, "repo"); + configDir = path.join(tempRoot, "claude-config"); + mkdirSync(cwd, { recursive: true }); + + const sanitized = cwd.replace(/[\\/\.]/g, "-").replace(/_/g, "-"); + const historyDir = path.join(configDir, "projects", sanitized); + mkdirSync(historyDir, { recursive: true }); + const historyPath = path.join(historyDir, "history-session.jsonl"); + writeFileSync( + historyPath, + [ + JSON.stringify({ + type: "user", + sessionId: "history-session", + cwd, + message: { + role: "user", + content: HISTORY_USER_MARKER, + }, + }), + JSON.stringify({ + type: "assistant", + sessionId: "history-session", + cwd, + message: { + role: "assistant", + content: HISTORY_ASSISTANT_MARKER, + }, + }), + ].join("\n"), + "utf8" + ); + + previousClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR; + process.env.CLAUDE_CONFIG_DIR = configDir; + }); + + afterEach(() => { + sdkMocks.query.mockReset(); + if (previousClaudeConfigDir === undefined) { + delete process.env.CLAUDE_CONFIG_DIR; + } else { + process.env.CLAUDE_CONFIG_DIR = previousClaudeConfigDir; + } + rmSync(tempRoot, { recursive: true, force: true }); + }); + + test("does not replay persisted history during the first live stream turn", async () => { + const logger = createTestLogger(); + const client = new ClaudeAgentClient({ logger }); + const handle: AgentPersistenceHandle = { + provider: "claude", + sessionId: "history-session", + nativeHandle: "history-session", + metadata: { + provider: "claude", + cwd, + }, + }; + + const session = await client.resumeSession(handle, { cwd }); + const events: AgentStreamEvent[] = []; + + try { + for await (const event of session.stream("Say hello")) { + events.push(event); + if ( + event.type === "turn_completed" || + event.type === "turn_failed" || + event.type === "turn_canceled" + ) { + break; + } + } + } finally { + await session.close(); + } + + const timelineText = collectTimelineText(events); + expect(timelineText).toContain(LIVE_REPLY_MARKER); + expect(timelineText).not.toContain(HISTORY_USER_MARKER); + expect(timelineText).not.toContain(HISTORY_ASSISTANT_MARKER); + }); + + test("still exposes persisted history through streamHistory", async () => { + const logger = createTestLogger(); + const client = new ClaudeAgentClient({ logger }); + const handle: AgentPersistenceHandle = { + provider: "claude", + sessionId: "history-session", + nativeHandle: "history-session", + metadata: { + provider: "claude", + cwd, + }, + }; + + const session = await client.resumeSession(handle, { cwd }); + const historyEvents: AgentStreamEvent[] = []; + + try { + for await (const event of session.streamHistory()) { + historyEvents.push(event); + } + } finally { + await session.close(); + } + + const timelineText = collectTimelineText(historyEvents); + expect(timelineText).toContain(HISTORY_USER_MARKER); + expect(timelineText).toContain(HISTORY_ASSISTANT_MARKER); + }); +}); diff --git a/packages/server/src/server/agent/providers/claude/tool-call-mapper.test.ts b/packages/server/src/server/agent/providers/claude/tool-call-mapper.test.ts index c957617f7..79b6bdf04 100644 --- a/packages/server/src/server/agent/providers/claude/tool-call-mapper.test.ts +++ b/packages/server/src/server/agent/providers/claude/tool-call-mapper.test.ts @@ -244,6 +244,24 @@ describe("claude tool-call mapper", () => { }); }); + it("normalizes namespaced voice MCP speak tools", () => { + const item = expectMapped( + mapClaudeCompletedToolCall({ + callId: "claude-speak-2", + name: "mcp__paseo_voice__speak", + input: { text: "Hey! I can hear you." }, + output: { ok: true }, + }) + ); + + expect(item.name).toBe("speak"); + expect(item.detail).toEqual({ + type: "unknown", + input: "Hey! I can hear you.", + output: null, + }); + }); + it("drops tool calls when callId is missing", () => { const item = mapClaudeCompletedToolCall({ callId: null, diff --git a/packages/server/src/server/agent/providers/claude/tool-call-mapper.ts b/packages/server/src/server/agent/providers/claude/tool-call-mapper.ts index 990b64e2e..4d178121c 100644 --- a/packages/server/src/server/agent/providers/claude/tool-call-mapper.ts +++ b/packages/server/src/server/agent/providers/claude/tool-call-mapper.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import type { ToolCallTimelineItem } from "../../agent-sdk-types.js"; +import { isSpeakToolName } from "../../tool-name-normalization.js"; import { deriveClaudeToolDetail } from "./tool-call-detail-parser.js"; type MapperParams = { @@ -43,6 +44,53 @@ const ClaudeToolCallPass1Schema = ClaudeRawToolCallSchema.transform((raw) => ({ status: raw.status, })); +const ClaudeShellToolNameSchema = z.union([ + z.literal("Bash"), + z.literal("bash"), + z.literal("shell"), + z.literal("exec_command"), +]); +const ClaudeReadToolNameSchema = z.union([ + z.literal("Read"), + z.literal("read"), + z.literal("read_file"), + z.literal("view_file"), +]); +const ClaudeWriteToolNameSchema = z.union([ + z.literal("Write"), + z.literal("write"), + z.literal("write_file"), + z.literal("create_file"), +]); +const ClaudeEditToolNameSchema = z.union([ + z.literal("Edit"), + z.literal("MultiEdit"), + z.literal("multi_edit"), + z.literal("edit"), + z.literal("apply_patch"), + z.literal("apply_diff"), + z.literal("str_replace_editor"), +]); +const ClaudeSearchToolNameSchema = z.union([ + z.literal("WebSearch"), + z.literal("web_search"), + z.literal("search"), +]); +const ClaudeSpeakToolNameSchema = z + .string() + .min(1) + .refine((name) => isSpeakToolName(name.trim())); + +const ClaudeToolKindSchema = z.enum([ + "shell", + "read", + "write", + "edit", + "search", + "speak", + "unknown", +]); + const ClaudeToolCallPass2BaseSchema = z.object({ callId: z.string().min(1), name: z.string().min(1), @@ -51,7 +99,7 @@ const ClaudeToolCallPass2BaseSchema = z.object({ metadata: z.record(z.string(), z.unknown()).optional(), error: z.unknown().nullable(), status: ClaudeToolCallStatusSchema, - toolKind: z.enum(["speak", "other"]), + toolKind: ClaudeToolKindSchema, }); const ClaudeToolCallPass2InputSchema = ClaudeToolCallPass2BaseSchema.omit({ @@ -60,26 +108,81 @@ const ClaudeToolCallPass2InputSchema = ClaudeToolCallPass2BaseSchema.omit({ const ClaudeToolCallPass2EnvelopeSchema = z.union([ ClaudeToolCallPass2InputSchema.extend({ - name: z.literal("mcp__paseo__speak"), + name: ClaudeShellToolNameSchema, }).transform((normalized) => ({ ...normalized, name: normalized.name.trim(), + toolKind: "shell" as const, + })), + ClaudeToolCallPass2InputSchema.extend({ + name: ClaudeReadToolNameSchema, + }).transform((normalized) => ({ + ...normalized, + name: normalized.name.trim(), + toolKind: "read" as const, + })), + ClaudeToolCallPass2InputSchema.extend({ + name: ClaudeWriteToolNameSchema, + }).transform((normalized) => ({ + ...normalized, + name: normalized.name.trim(), + toolKind: "write" as const, + })), + ClaudeToolCallPass2InputSchema.extend({ + name: ClaudeEditToolNameSchema, + }).transform((normalized) => ({ + ...normalized, + name: normalized.name.trim(), + toolKind: "edit" as const, + })), + ClaudeToolCallPass2InputSchema.extend({ + name: ClaudeSearchToolNameSchema, + }).transform((normalized) => ({ + ...normalized, + name: normalized.name.trim(), + toolKind: "search" as const, + })), + ClaudeToolCallPass2InputSchema.extend({ + name: ClaudeSpeakToolNameSchema, + }).transform((normalized) => ({ + ...normalized, + name: "speak" as const, toolKind: "speak" as const, })), ClaudeToolCallPass2InputSchema.transform((normalized) => ({ ...normalized, name: normalized.name.trim(), - toolKind: "other" as const, + toolKind: "unknown" as const, })), ]); const ClaudeToolCallPass2Schema = z.discriminatedUnion("toolKind", [ ClaudeToolCallPass2BaseSchema.extend({ - toolKind: z.literal("speak"), - name: z.literal("mcp__paseo__speak"), + toolKind: z.literal("shell"), + name: ClaudeShellToolNameSchema, }), ClaudeToolCallPass2BaseSchema.extend({ - toolKind: z.literal("other"), + toolKind: z.literal("read"), + name: ClaudeReadToolNameSchema, + }), + ClaudeToolCallPass2BaseSchema.extend({ + toolKind: z.literal("write"), + name: ClaudeWriteToolNameSchema, + }), + ClaudeToolCallPass2BaseSchema.extend({ + toolKind: z.literal("edit"), + name: ClaudeEditToolNameSchema, + }), + ClaudeToolCallPass2BaseSchema.extend({ + toolKind: z.literal("search"), + name: ClaudeSearchToolNameSchema, + }), + ClaudeToolCallPass2BaseSchema.extend({ + toolKind: z.literal("speak"), + name: z.literal("speak"), + }), + ClaudeToolCallPass2BaseSchema.extend({ + toolKind: z.literal("unknown"), }), ]); @@ -87,7 +190,21 @@ type ClaudeToolCallPass2 = z.infer; function toToolCallTimelineItem(normalized: ClaudeToolCallPass2): ToolCallTimelineItem { const name = normalized.toolKind === "speak" ? ("speak" as const) : normalized.name; - const detail = deriveClaudeToolDetail(name, normalized.input, normalized.output); + const detailName = + normalized.toolKind === "shell" + ? "shell" + : normalized.toolKind === "read" + ? "read_file" + : normalized.toolKind === "write" + ? "write_file" + : normalized.toolKind === "edit" + ? "apply_patch" + : normalized.toolKind === "search" + ? "search" + : normalized.toolKind === "speak" + ? "speak" + : normalized.name; + const detail = deriveClaudeToolDetail(detailName, normalized.input, normalized.output); if (normalized.status === "failed") { return { type: "tool_call", diff --git a/packages/server/src/utils/worktree.test.ts b/packages/server/src/utils/worktree.test.ts index 56a2b6a6b..48672af21 100644 --- a/packages/server/src/utils/worktree.test.ts +++ b/packages/server/src/utils/worktree.test.ts @@ -174,6 +174,7 @@ describe("createWorktree", () => { 'echo "root=$PASEO_ROOT_PATH" > setup.log', 'echo "worktree=$PASEO_WORKTREE_PATH" >> setup.log', 'echo "branch=$PASEO_BRANCH_NAME" >> setup.log', + 'echo "port=$PASEO_WORKTREE_PORT" >> setup.log', ], }, }; @@ -195,6 +196,13 @@ describe("createWorktree", () => { expect(setupLog).toContain(`root=${repoDir}`); expect(setupLog).toContain(`worktree=${result.worktreePath}`); expect(setupLog).toContain("branch=setup-test"); + const portLine = setupLog + .split("\n") + .find((line) => line.startsWith("port=")); + expect(portLine).toBeDefined(); + const portValue = Number(portLine?.slice("port=".length)); + expect(Number.isInteger(portValue)).toBe(true); + expect(portValue).toBeGreaterThan(0); }); it("does not run setup commands when runSetup=false", async () => { diff --git a/packages/server/src/utils/worktree.ts b/packages/server/src/utils/worktree.ts index 03175e400..c4ff7051b 100644 --- a/packages/server/src/utils/worktree.ts +++ b/packages/server/src/utils/worktree.ts @@ -2,6 +2,7 @@ import { exec } from "child_process"; import { promisify } from "util"; import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync } from "fs"; import { join, basename, dirname, resolve, sep } from "path"; +import net from "node:net"; import { createNameId } from "mnemonic-id"; import { normalizeBaseRefName, writePaseoWorktreeMetadata } from "./worktree-metadata.js"; import { resolvePaseoHome } from "../server/paseo-home.js"; @@ -136,6 +137,27 @@ async function execSetupCommand( } } +async function getAvailablePort(): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.once("error", reject); + server.listen(0, () => { + const address = server.address(); + if (!address || typeof address === "string") { + server.close(() => reject(new Error("Failed to acquire available port"))); + return; + } + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(address.port); + }); + }); + }); +} + async function inferRepoRootPathFromWorktreePath(worktreePath: string): Promise { try { const commonDir = await getGitCommonDir(worktreePath); @@ -178,6 +200,7 @@ export async function runWorktreeSetupCommands(options: { const repoRootPath = options.repoRootPath ?? (await inferRepoRootPathFromWorktreePath(options.worktreePath)); + const worktreePort = await getAvailablePort(); const setupEnv = { ...process.env, @@ -186,6 +209,7 @@ export async function runWorktreeSetupCommands(options: { PASEO_ROOT_PATH: repoRootPath, PASEO_WORKTREE_PATH: options.worktreePath, PASEO_BRANCH_NAME: options.branchName, + PASEO_WORKTREE_PORT: String(worktreePort), }; const results: WorktreeSetupCommandResult[] = []; diff --git a/packages/website/src/routes/docs/worktrees.tsx b/packages/website/src/routes/docs/worktrees.tsx index 4d867dc0d..34fa5130a 100644 --- a/packages/website/src/routes/docs/worktrees.tsx +++ b/packages/website/src/routes/docs/worktrees.tsx @@ -138,6 +138,9 @@ function Worktrees() {
  • $PASEO_BRANCH_NAME — the branch name created
  • +
  • + $PASEO_WORKTREE_PORT — an available local port for setup scripts +
  • Use $PASEO_ROOT_PATH to copy files that shouldn't