feat: improve Claude agent model normalization and tool mapping
This commit is contained in:
@@ -19,6 +19,14 @@
|
|||||||
"env": {
|
"env": {
|
||||||
"APP_VARIANT": "production"
|
"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": {
|
"submit": {
|
||||||
|
|||||||
@@ -429,8 +429,9 @@ export default function SettingsScreen() {
|
|||||||
const isMountedRef = useRef(true);
|
const isMountedRef = useRef(true);
|
||||||
const lastHandledEditHostRef = useRef<string | null>(null);
|
const lastHandledEditHostRef = useRef<string | null>(null);
|
||||||
const appVersion = Constants.expoConfig?.version ?? (Constants as any).manifest?.version ?? "0.1.0";
|
const appVersion = Constants.expoConfig?.version ?? (Constants as any).manifest?.version ?? "0.1.0";
|
||||||
const editingDaemonLive = editingDaemon
|
const editingServerId = editingDaemon?.serverId ?? null;
|
||||||
? daemons.find((daemon) => daemon.serverId === editingDaemon.serverId) ?? null
|
const editingDaemonLive = editingServerId
|
||||||
|
? daemons.find((daemon) => daemon.serverId === editingServerId) ?? null
|
||||||
: null;
|
: null;
|
||||||
const pendingNameHostname = useSessionStore(
|
const pendingNameHostname = useSessionStore(
|
||||||
useCallback(
|
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(
|
const waitForCondition = useCallback(
|
||||||
async (predicate: () => boolean, timeoutMs: number, intervalMs = 250) => {
|
async (predicate: () => boolean, timeoutMs: number, intervalMs = 250) => {
|
||||||
const deadline = Date.now() + timeoutMs;
|
const deadline = Date.now() + timeoutMs;
|
||||||
@@ -516,7 +524,7 @@ export default function SettingsScreen() {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const handleSaveEditDaemon = useCallback(async (nextLabelRaw: string) => {
|
const handleSaveEditDaemon = useCallback(async (nextLabelRaw: string) => {
|
||||||
if (!editingDaemon) return;
|
if (!editingServerId) return;
|
||||||
if (isSavingEdit) return;
|
if (isSavingEdit) return;
|
||||||
|
|
||||||
const nextLabel = nextLabelRaw.trim();
|
const nextLabel = nextLabelRaw.trim();
|
||||||
@@ -527,7 +535,7 @@ export default function SettingsScreen() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
setIsSavingEdit(true);
|
setIsSavingEdit(true);
|
||||||
await updateHost(editingDaemon.serverId, { label: nextLabel });
|
await updateHost(editingServerId, { label: nextLabel });
|
||||||
handleCloseEditDaemon();
|
handleCloseEditDaemon();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[Settings] Failed to rename host", error);
|
console.error("[Settings] Failed to rename host", error);
|
||||||
@@ -535,7 +543,7 @@ export default function SettingsScreen() {
|
|||||||
} finally {
|
} finally {
|
||||||
setIsSavingEdit(false);
|
setIsSavingEdit(false);
|
||||||
}
|
}
|
||||||
}, [editingDaemon, handleCloseEditDaemon, isSavingEdit, updateHost]);
|
}, [editingServerId, handleCloseEditDaemon, isSavingEdit, updateHost]);
|
||||||
|
|
||||||
const handleRemoveConnection = useCallback(
|
const handleRemoveConnection = useCallback(
|
||||||
async (serverId: string, connectionId: string) => {
|
async (serverId: string, connectionId: string) => {
|
||||||
@@ -550,13 +558,13 @@ export default function SettingsScreen() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleAddConnectionFromModal = useCallback(() => {
|
const handleAddConnectionFromModal = useCallback(() => {
|
||||||
if (!editingDaemon) return;
|
if (!editingServerId) return;
|
||||||
const serverId = editingDaemon.serverId;
|
const serverId = editingServerId;
|
||||||
setEditingDaemon(null);
|
setEditingDaemon(null);
|
||||||
setAddConnectionTargetServerId(serverId);
|
setAddConnectionTargetServerId(serverId);
|
||||||
setPendingEditReopenServerId(serverId);
|
setPendingEditReopenServerId(serverId);
|
||||||
setIsAddHostMethodVisible(true);
|
setIsAddHostMethodVisible(true);
|
||||||
}, [editingDaemon]);
|
}, [editingServerId]);
|
||||||
|
|
||||||
const handleThemeChange = useCallback(
|
const handleThemeChange = useCallback(
|
||||||
(newTheme: AppSettings["theme"]) => {
|
(newTheme: AppSettings["theme"]) => {
|
||||||
@@ -763,11 +771,11 @@ export default function SettingsScreen() {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<HostDetailModal
|
<HostDetailModal
|
||||||
visible={Boolean(editingDaemon)}
|
visible={Boolean(editingDaemonLive)}
|
||||||
host={editingDaemonLive ?? editingDaemon}
|
host={editingDaemonLive}
|
||||||
connectionStatus={editingDaemon ? (connectionStates.get(editingDaemon.serverId)?.status ?? "idle") : "idle"}
|
connectionStatus={editingServerId ? (connectionStates.get(editingServerId)?.status ?? "idle") : "idle"}
|
||||||
activeConnection={editingDaemon ? (connectionStates.get(editingDaemon.serverId)?.activeConnection ?? null) : null}
|
activeConnection={editingServerId ? (connectionStates.get(editingServerId)?.activeConnection ?? null) : null}
|
||||||
lastError={editingDaemon ? (connectionStates.get(editingDaemon.serverId)?.lastError ?? null) : null}
|
lastError={editingServerId ? (connectionStates.get(editingServerId)?.lastError ?? null) : null}
|
||||||
isSaving={isSavingEdit}
|
isSaving={isSavingEdit}
|
||||||
onClose={handleCloseEditDaemon}
|
onClose={handleCloseEditDaemon}
|
||||||
onSave={(label) => void handleSaveEditDaemon(label)}
|
onSave={(label) => void handleSaveEditDaemon(label)}
|
||||||
@@ -873,6 +881,7 @@ function HostDetailModal({
|
|||||||
}: HostDetailModalProps) {
|
}: HostDetailModalProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const [draftLabel, setDraftLabel] = useState("");
|
const [draftLabel, setDraftLabel] = useState("");
|
||||||
|
const [isDraftLabelDirty, setIsDraftLabelDirty] = useState(false);
|
||||||
const activeServerIdRef = useRef<string | null>(null);
|
const activeServerIdRef = useRef<string | null>(null);
|
||||||
const [pendingRemoveConnection, setPendingRemoveConnection] = useState<{ serverId: string; connectionId: string; title: string } | null>(null);
|
const [pendingRemoveConnection, setPendingRemoveConnection] = useState<{ serverId: string; connectionId: string; title: string } | null>(null);
|
||||||
const [isRemovingConnection, setIsRemovingConnection] = useState(false);
|
const [isRemovingConnection, setIsRemovingConnection] = useState(false);
|
||||||
@@ -1018,22 +1027,30 @@ function HostDetailModal({
|
|||||||
})();
|
})();
|
||||||
const connectionError = typeof lastError === "string" && lastError.trim().length > 0 ? lastError.trim() : null;
|
const connectionError = typeof lastError === "string" && lastError.trim().length > 0 ? lastError.trim() : null;
|
||||||
|
|
||||||
|
const handleDraftLabelChange = useCallback((nextValue: string) => {
|
||||||
|
setDraftLabel(nextValue);
|
||||||
|
setIsDraftLabelDirty(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!visible || !host) return;
|
if (!visible || !host) return;
|
||||||
if (activeServerIdRef.current !== host.serverId) {
|
const hostChanged = activeServerIdRef.current !== host.serverId;
|
||||||
|
if (hostChanged) {
|
||||||
setDraftLabel(host.label ?? "");
|
setDraftLabel(host.label ?? "");
|
||||||
|
setIsDraftLabelDirty(false);
|
||||||
activeServerIdRef.current = host.serverId;
|
activeServerIdRef.current = host.serverId;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!draftLabel.trim()) {
|
if (!isDraftLabelDirty) {
|
||||||
setDraftLabel(host.label ?? "");
|
setDraftLabel(host.label ?? "");
|
||||||
}
|
}
|
||||||
}, [visible, host, draftLabel]);
|
}, [visible, host?.serverId, host?.label, isDraftLabelDirty]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!visible) {
|
if (!visible) {
|
||||||
activeServerIdRef.current = null;
|
activeServerIdRef.current = null;
|
||||||
setIsRestarting(false);
|
setIsRestarting(false);
|
||||||
|
setIsDraftLabelDirty(false);
|
||||||
}
|
}
|
||||||
}, [visible]);
|
}, [visible]);
|
||||||
|
|
||||||
@@ -1072,7 +1089,7 @@ function HostDetailModal({
|
|||||||
<AdaptiveTextInput
|
<AdaptiveTextInput
|
||||||
style={styles.input}
|
style={styles.input}
|
||||||
value={draftLabel}
|
value={draftLabel}
|
||||||
onChangeText={setDraftLabel}
|
onChangeText={handleDraftLabelChange}
|
||||||
placeholder="My Host"
|
placeholder="My Host"
|
||||||
placeholderTextColor={defaultTheme.colors.mutedForeground}
|
placeholderTextColor={defaultTheme.colors.mutedForeground}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -91,6 +91,92 @@ function normalizeClaudeModelLabel(model: ModelInfo): string {
|
|||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type NormalizeClaudeRuntimeModelIdOptions = {
|
||||||
|
runtimeModelId: string;
|
||||||
|
supportedModelIds: ReadonlySet<string> | 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<string>,
|
||||||
|
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 = {
|
const CLAUDE_CAPABILITIES: AgentCapabilityFlags = {
|
||||||
supportsStreaming: true,
|
supportsStreaming: true,
|
||||||
supportsSessionPersistence: true,
|
supportsSessionPersistence: true,
|
||||||
@@ -583,6 +669,7 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
private activeTurnPromise: Promise<void> | null = null;
|
private activeTurnPromise: Promise<void> | null = null;
|
||||||
private cachedRuntimeInfo: AgentRuntimeInfo | null = null;
|
private cachedRuntimeInfo: AgentRuntimeInfo | null = null;
|
||||||
private lastOptionsModel: string | null = null;
|
private lastOptionsModel: string | null = null;
|
||||||
|
private selectableModelIds: Set<string> | null = null;
|
||||||
private activeSidechains = new Map<string, string>();
|
private activeSidechains = new Map<string, string>();
|
||||||
private compacting = false;
|
private compacting = false;
|
||||||
private queryRestartNeeded = false;
|
private queryRestartNeeded = false;
|
||||||
@@ -726,13 +813,6 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
queue.end();
|
queue.end();
|
||||||
};
|
};
|
||||||
this.cancelCurrentTurn = requestCancel;
|
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
|
// Start forwarding events and track the promise so future turns can wait for completion
|
||||||
const forwardPromise = this.forwardPromptEvents(sdkMessage, queue, turnId);
|
const forwardPromise = this.forwardPromptEvents(sdkMessage, queue, turnId);
|
||||||
@@ -974,6 +1054,20 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async primeSelectableModelIds(query: Query): Promise<void> {
|
||||||
|
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<Query> {
|
private async ensureQuery(): Promise<Query> {
|
||||||
if (this.query && !this.queryRestartNeeded) {
|
if (this.query && !this.queryRestartNeeded) {
|
||||||
return this.query;
|
return this.query;
|
||||||
@@ -992,6 +1086,7 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
this.logger.debug({ options }, "claude query");
|
this.logger.debug({ options }, "claude query");
|
||||||
this.input = input;
|
this.input = input;
|
||||||
this.query = query({ prompt: input, options });
|
this.query = query({ prompt: input, options });
|
||||||
|
await this.primeSelectableModelIds(this.query);
|
||||||
await this.query.setPermissionMode(this.currentMode);
|
await this.query.setPermissionMode(this.currentMode);
|
||||||
return this.query;
|
return this.query;
|
||||||
}
|
}
|
||||||
@@ -1469,8 +1564,17 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
this.persistence = null;
|
this.persistence = null;
|
||||||
// Capture actual model from SDK init message (not just the configured model)
|
// Capture actual model from SDK init message (not just the configured model)
|
||||||
if (message.model) {
|
if (message.model) {
|
||||||
this.logger.debug({ model: message.model }, "Captured model from SDK init");
|
const normalizedModel = normalizeClaudeRuntimeModelId({
|
||||||
this.lastOptionsModel = message.model;
|
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
|
// Invalidate cached runtime info so it picks up the new model
|
||||||
this.cachedRuntimeInfo = null;
|
this.cachedRuntimeInfo = null;
|
||||||
}
|
}
|
||||||
|
|||||||
+209
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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", () => {
|
it("drops tool calls when callId is missing", () => {
|
||||||
const item = mapClaudeCompletedToolCall({
|
const item = mapClaudeCompletedToolCall({
|
||||||
callId: null,
|
callId: null,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import type { ToolCallTimelineItem } from "../../agent-sdk-types.js";
|
import type { ToolCallTimelineItem } from "../../agent-sdk-types.js";
|
||||||
|
import { isSpeakToolName } from "../../tool-name-normalization.js";
|
||||||
import { deriveClaudeToolDetail } from "./tool-call-detail-parser.js";
|
import { deriveClaudeToolDetail } from "./tool-call-detail-parser.js";
|
||||||
|
|
||||||
type MapperParams = {
|
type MapperParams = {
|
||||||
@@ -43,6 +44,53 @@ const ClaudeToolCallPass1Schema = ClaudeRawToolCallSchema.transform((raw) => ({
|
|||||||
status: raw.status,
|
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({
|
const ClaudeToolCallPass2BaseSchema = z.object({
|
||||||
callId: z.string().min(1),
|
callId: z.string().min(1),
|
||||||
name: 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(),
|
metadata: z.record(z.string(), z.unknown()).optional(),
|
||||||
error: z.unknown().nullable(),
|
error: z.unknown().nullable(),
|
||||||
status: ClaudeToolCallStatusSchema,
|
status: ClaudeToolCallStatusSchema,
|
||||||
toolKind: z.enum(["speak", "other"]),
|
toolKind: ClaudeToolKindSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
const ClaudeToolCallPass2InputSchema = ClaudeToolCallPass2BaseSchema.omit({
|
const ClaudeToolCallPass2InputSchema = ClaudeToolCallPass2BaseSchema.omit({
|
||||||
@@ -60,26 +108,81 @@ const ClaudeToolCallPass2InputSchema = ClaudeToolCallPass2BaseSchema.omit({
|
|||||||
|
|
||||||
const ClaudeToolCallPass2EnvelopeSchema = z.union([
|
const ClaudeToolCallPass2EnvelopeSchema = z.union([
|
||||||
ClaudeToolCallPass2InputSchema.extend({
|
ClaudeToolCallPass2InputSchema.extend({
|
||||||
name: z.literal("mcp__paseo__speak"),
|
name: ClaudeShellToolNameSchema,
|
||||||
}).transform((normalized) => ({
|
}).transform((normalized) => ({
|
||||||
...normalized,
|
...normalized,
|
||||||
name: normalized.name.trim(),
|
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,
|
toolKind: "speak" as const,
|
||||||
})),
|
})),
|
||||||
ClaudeToolCallPass2InputSchema.transform((normalized) => ({
|
ClaudeToolCallPass2InputSchema.transform((normalized) => ({
|
||||||
...normalized,
|
...normalized,
|
||||||
name: normalized.name.trim(),
|
name: normalized.name.trim(),
|
||||||
toolKind: "other" as const,
|
toolKind: "unknown" as const,
|
||||||
})),
|
})),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const ClaudeToolCallPass2Schema = z.discriminatedUnion("toolKind", [
|
const ClaudeToolCallPass2Schema = z.discriminatedUnion("toolKind", [
|
||||||
ClaudeToolCallPass2BaseSchema.extend({
|
ClaudeToolCallPass2BaseSchema.extend({
|
||||||
toolKind: z.literal("speak"),
|
toolKind: z.literal("shell"),
|
||||||
name: z.literal("mcp__paseo__speak"),
|
name: ClaudeShellToolNameSchema,
|
||||||
}),
|
}),
|
||||||
ClaudeToolCallPass2BaseSchema.extend({
|
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<typeof ClaudeToolCallPass2Schema>;
|
|||||||
|
|
||||||
function toToolCallTimelineItem(normalized: ClaudeToolCallPass2): ToolCallTimelineItem {
|
function toToolCallTimelineItem(normalized: ClaudeToolCallPass2): ToolCallTimelineItem {
|
||||||
const name = normalized.toolKind === "speak" ? ("speak" as const) : normalized.name;
|
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") {
|
if (normalized.status === "failed") {
|
||||||
return {
|
return {
|
||||||
type: "tool_call",
|
type: "tool_call",
|
||||||
|
|||||||
@@ -174,6 +174,7 @@ describe("createWorktree", () => {
|
|||||||
'echo "root=$PASEO_ROOT_PATH" > setup.log',
|
'echo "root=$PASEO_ROOT_PATH" > setup.log',
|
||||||
'echo "worktree=$PASEO_WORKTREE_PATH" >> setup.log',
|
'echo "worktree=$PASEO_WORKTREE_PATH" >> setup.log',
|
||||||
'echo "branch=$PASEO_BRANCH_NAME" >> 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(`root=${repoDir}`);
|
||||||
expect(setupLog).toContain(`worktree=${result.worktreePath}`);
|
expect(setupLog).toContain(`worktree=${result.worktreePath}`);
|
||||||
expect(setupLog).toContain("branch=setup-test");
|
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 () => {
|
it("does not run setup commands when runSetup=false", async () => {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { exec } from "child_process";
|
|||||||
import { promisify } from "util";
|
import { promisify } from "util";
|
||||||
import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync } from "fs";
|
import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync } from "fs";
|
||||||
import { join, basename, dirname, resolve, sep } from "path";
|
import { join, basename, dirname, resolve, sep } from "path";
|
||||||
|
import net from "node:net";
|
||||||
import { createNameId } from "mnemonic-id";
|
import { createNameId } from "mnemonic-id";
|
||||||
import { normalizeBaseRefName, writePaseoWorktreeMetadata } from "./worktree-metadata.js";
|
import { normalizeBaseRefName, writePaseoWorktreeMetadata } from "./worktree-metadata.js";
|
||||||
import { resolvePaseoHome } from "../server/paseo-home.js";
|
import { resolvePaseoHome } from "../server/paseo-home.js";
|
||||||
@@ -136,6 +137,27 @@ async function execSetupCommand(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getAvailablePort(): Promise<number> {
|
||||||
|
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<string> {
|
async function inferRepoRootPathFromWorktreePath(worktreePath: string): Promise<string> {
|
||||||
try {
|
try {
|
||||||
const commonDir = await getGitCommonDir(worktreePath);
|
const commonDir = await getGitCommonDir(worktreePath);
|
||||||
@@ -178,6 +200,7 @@ export async function runWorktreeSetupCommands(options: {
|
|||||||
|
|
||||||
const repoRootPath =
|
const repoRootPath =
|
||||||
options.repoRootPath ?? (await inferRepoRootPathFromWorktreePath(options.worktreePath));
|
options.repoRootPath ?? (await inferRepoRootPathFromWorktreePath(options.worktreePath));
|
||||||
|
const worktreePort = await getAvailablePort();
|
||||||
|
|
||||||
const setupEnv = {
|
const setupEnv = {
|
||||||
...process.env,
|
...process.env,
|
||||||
@@ -186,6 +209,7 @@ export async function runWorktreeSetupCommands(options: {
|
|||||||
PASEO_ROOT_PATH: repoRootPath,
|
PASEO_ROOT_PATH: repoRootPath,
|
||||||
PASEO_WORKTREE_PATH: options.worktreePath,
|
PASEO_WORKTREE_PATH: options.worktreePath,
|
||||||
PASEO_BRANCH_NAME: options.branchName,
|
PASEO_BRANCH_NAME: options.branchName,
|
||||||
|
PASEO_WORKTREE_PORT: String(worktreePort),
|
||||||
};
|
};
|
||||||
|
|
||||||
const results: WorktreeSetupCommandResult[] = [];
|
const results: WorktreeSetupCommandResult[] = [];
|
||||||
|
|||||||
@@ -138,6 +138,9 @@ function Worktrees() {
|
|||||||
<li>
|
<li>
|
||||||
<code className="font-mono">$PASEO_BRANCH_NAME</code> — the branch name created
|
<code className="font-mono">$PASEO_BRANCH_NAME</code> — the branch name created
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<code className="font-mono">$PASEO_WORKTREE_PORT</code> — an available local port for setup scripts
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p className="text-white/60 leading-relaxed">
|
<p className="text-white/60 leading-relaxed">
|
||||||
Use <code className="font-mono">$PASEO_ROOT_PATH</code> to copy files that shouldn't
|
Use <code className="font-mono">$PASEO_ROOT_PATH</code> to copy files that shouldn't
|
||||||
|
|||||||
Reference in New Issue
Block a user