Merge remote-tracking branch 'origin/main' into dev

# Conflicts:
#	packages/server/src/server/session.ts
This commit is contained in:
Mohamed Boudra
2026-04-16 19:37:29 +07:00
27 changed files with 823 additions and 138 deletions
+2
View File
@@ -105,6 +105,7 @@ jobs:
run: > run: >
npx vitest run npx vitest run
src/utils/executable.test.ts src/utils/executable.test.ts
src/utils/spawn.launch-regression.test.ts
src/utils/spawn.test.ts src/utils/spawn.test.ts
src/utils/run-git-command.test.ts src/utils/run-git-command.test.ts
src/utils/checkout-git-rev-parse.test.ts src/utils/checkout-git-rev-parse.test.ts
@@ -112,6 +113,7 @@ jobs:
src/server/agent/provider-launch-config.test.ts src/server/agent/provider-launch-config.test.ts
src/server/agent/provider-snapshot-manager.test.ts src/server/agent/provider-snapshot-manager.test.ts
src/server/agent/providers/claude-agent.spawn.test.ts src/server/agent/providers/claude-agent.spawn.test.ts
src/server/agent/providers/provider-windows-launch.test.ts
src/server/agent/providers/provider-availability.test.ts src/server/agent/providers/provider-availability.test.ts
src/server/workspace-registry-model.test.ts src/server/workspace-registry-model.test.ts
src/server/persisted-config.test.ts src/server/persisted-config.test.ts
+28
View File
@@ -1,5 +1,33 @@
# Changelog # Changelog
## 0.1.58 - 2026-04-16
### Added
- Markdown files render as formatted markdown in the file pane. ([#427](https://github.com/getpaseo/paseo/pull/427) by [@aaronflorey](https://github.com/aaronflorey))
- Cmd+L (Ctrl+L on Windows/Linux) focuses the agent message input.
- Provider models refresh on a freshness TTL; Settings shows last-updated time and any fetch errors. ([#426](https://github.com/getpaseo/paseo/pull/426))
- `disallowedTools` option in provider config to block specific tools from an agent.
### Improved
- Windows: agents launch reliably from npm `.cmd` shims, paths with spaces, and JSON config args — fixes `spawn EINVAL` startup errors. ([#454](https://github.com/getpaseo/paseo/pull/454))
- OpenCode permission prompts include the requesting tool's context. ([#398](https://github.com/getpaseo/paseo/pull/398) by [@aaronflorey](https://github.com/aaronflorey))
- OpenCode todo and compaction events render in the timeline. ([#429](https://github.com/getpaseo/paseo/pull/429) by [@aaronflorey](https://github.com/aaronflorey))
- OpenCode sessions archive cleanly when closed. ([#408](https://github.com/getpaseo/paseo/pull/408) by [@aaronflorey](https://github.com/aaronflorey))
- OpenCode slash commands recover from SSE timeouts. ([#407](https://github.com/getpaseo/paseo/pull/407) by [@aaronflorey](https://github.com/aaronflorey))
- Paseo MCP tools work against archived agents, matching the CLI. ([#423](https://github.com/getpaseo/paseo/pull/423))
- Native scrollbars match the active theme across all web views. ([#399](https://github.com/getpaseo/paseo/pull/399) by [@ethersh](https://github.com/ethersh))
### Fixed
- Code file previews can be selected and copied on iOS. ([#447](https://github.com/getpaseo/paseo/pull/447) by [@muzhi1991](https://github.com/muzhi1991))
- File preview no longer shows stale content when reopening the same file. ([#411](https://github.com/getpaseo/paseo/pull/411) by [@muzhi1991](https://github.com/muzhi1991))
- File explorer reinitialises when the client reconnects after a page refresh. ([#442](https://github.com/getpaseo/paseo/pull/442) by [@1996fanrui](https://github.com/1996fanrui))
- Generic ACP providers no longer receive duplicated command arguments. ([#444](https://github.com/getpaseo/paseo/pull/444) by [@edvardchen](https://github.com/edvardchen))
- Workspace headers no longer show a branch icon for non-git workspaces.
- Branch switcher layout is stable on mobile.
- Model names no longer truncate mid-word in the picker rows.
- Messages appear in the correct order after reconnecting on mobile.
- Clearing agent attention no longer throws on timeout.
## 0.1.56 - 2026-04-14 ## 0.1.56 - 2026-04-14
### Fixed ### Fixed
+10
View File
@@ -139,6 +139,16 @@ The changelog is shown on the Paseo homepage. Write it for **end users**, not de
- **Only list changes relative to the previous stable release.** The diff is `v(previous)..HEAD`. If something was introduced and fixed between those two tags, it never shipped — don't mention the fix. - **Only list changes relative to the previous stable release.** The diff is `v(previous)..HEAD`. If something was introduced and fixed between those two tags, it never shipped — don't mention the fix.
- **Cut low-signal entries.** "Toolbar buttons have consistent sizing" is too granular. Combine small polish items or drop them. - **Cut low-signal entries.** "Toolbar buttons have consistent sizing" is too granular. Combine small polish items or drop them.
## Changelog conciseness
Every bullet must be scannable at a glance. The changelog is not release documentation — it's a list.
- **One line per bullet.** If a bullet wraps to three lines in a narrow column, it's too long.
- **Split bullets that pack multiple distinct changes.** If a bullet uses "and", "plus", a comma list, or an em-dash to chain several independent improvements, break them into separate bullets — even when they share a theme or author. One bullet = one user-facing change.
- **Trim qualifying clauses.** Drop "with a hint shown when…", "matching the CLI's behaviour", "across common install shapes". If the detail doesn't change whether a user cares, cut it.
- **Lead with the outcome.** "Windows: agents launch reliably from npm `.cmd` shims…" is better than "Windows: agents launch reliably across common install shapes. Claude, Codex, and OpenCode now start correctly…".
- **Attribution follows the split.** When you split a dense bullet, move each PR/author to the bullet it belongs to. Never duplicate the same PR across multiple bullets.
## Changelog attribution ## Changelog attribution
Every changelog bullet must credit contributors and link to the PR(s) that delivered the change. This is not one-PR-per-line — a single bullet describes a user-facing change and may reference multiple PRs. Every changelog bullet must credit contributors and link to the PR(s) that delivered the change. This is not one-PR-per-line — a single bullet describes a user-facing change and may reference multiple PRs.
+1 -1
View File
@@ -42,7 +42,7 @@ buildNpmPackage rec {
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash. # To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
# CI auto-updates this when package-lock.json changes (see .github/workflows/). # CI auto-updates this when package-lock.json changes (see .github/workflows/).
npmDepsHash = "sha256-Wn7g+VXuUe0LI733MUj+WRX/fJQHOO/HFEkqyHrWTf0="; npmDepsHash = "sha256-rpXng5y99bDt7a3f6vI+EWrVahlXINdHzCf7ml+ZnFs=";
# Prevent onnxruntime-node's install script from running during automatic # Prevent onnxruntime-node's install script from running during automatic
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox). # npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
+19 -19
View File
@@ -1,12 +1,12 @@
{ {
"name": "paseo", "name": "paseo",
"version": "0.1.57-rc.1", "version": "0.1.58",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "paseo", "name": "paseo",
"version": "0.1.57-rc.1", "version": "0.1.58",
"hasInstallScript": true, "hasInstallScript": true,
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"workspaces": [ "workspaces": [
@@ -34854,16 +34854,16 @@
}, },
"packages/app": { "packages/app": {
"name": "@getpaseo/app", "name": "@getpaseo/app",
"version": "0.1.57-rc.1", "version": "0.1.58",
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@expo/vector-icons": "^15.0.2", "@expo/vector-icons": "^15.0.2",
"@floating-ui/react-native": "^0.10.7", "@floating-ui/react-native": "^0.10.7",
"@getpaseo/expo-two-way-audio": "0.1.57-rc.1", "@getpaseo/expo-two-way-audio": "0.1.58",
"@getpaseo/highlight": "0.1.57-rc.1", "@getpaseo/highlight": "0.1.58",
"@getpaseo/server": "0.1.57-rc.1", "@getpaseo/server": "0.1.58",
"@gorhom/bottom-sheet": "^5.2.6", "@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14", "@gorhom/portal": "^1.0.14",
"@react-native-async-storage/async-storage": "2.2.0", "@react-native-async-storage/async-storage": "2.2.0",
@@ -35004,11 +35004,11 @@
}, },
"packages/cli": { "packages/cli": {
"name": "@getpaseo/cli", "name": "@getpaseo/cli",
"version": "0.1.57-rc.1", "version": "0.1.58",
"dependencies": { "dependencies": {
"@clack/prompts": "^1.0.0", "@clack/prompts": "^1.0.0",
"@getpaseo/relay": "0.1.57-rc.1", "@getpaseo/relay": "0.1.58",
"@getpaseo/server": "0.1.57-rc.1", "@getpaseo/server": "0.1.58",
"chalk": "^5.3.0", "chalk": "^5.3.0",
"commander": "^12.0.0", "commander": "^12.0.0",
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
@@ -35049,11 +35049,11 @@
}, },
"packages/desktop": { "packages/desktop": {
"name": "@getpaseo/desktop", "name": "@getpaseo/desktop",
"version": "0.1.57-rc.1", "version": "0.1.58",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"dependencies": { "dependencies": {
"@getpaseo/cli": "0.1.57-rc.1", "@getpaseo/cli": "0.1.58",
"@getpaseo/server": "0.1.57-rc.1", "@getpaseo/server": "0.1.58",
"electron-log": "^5.4.3", "electron-log": "^5.4.3",
"electron-updater": "^6.6.2", "electron-updater": "^6.6.2",
"ws": "^8.14.2" "ws": "^8.14.2"
@@ -35087,7 +35087,7 @@
}, },
"packages/expo-two-way-audio": { "packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio", "name": "@getpaseo/expo-two-way-audio",
"version": "0.1.57-rc.1", "version": "0.1.58",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@biomejs/biome": "1.9.4", "@biomejs/biome": "1.9.4",
@@ -35288,7 +35288,7 @@
}, },
"packages/highlight": { "packages/highlight": {
"name": "@getpaseo/highlight", "name": "@getpaseo/highlight",
"version": "0.1.57-rc.1", "version": "0.1.58",
"dependencies": { "dependencies": {
"@lezer/common": "^1.5.0", "@lezer/common": "^1.5.0",
"@lezer/cpp": "^1.1.5", "@lezer/cpp": "^1.1.5",
@@ -35314,7 +35314,7 @@
}, },
"packages/relay": { "packages/relay": {
"name": "@getpaseo/relay", "name": "@getpaseo/relay",
"version": "0.1.57-rc.1", "version": "0.1.58",
"dependencies": { "dependencies": {
"base64-js": "^1.5.1", "base64-js": "^1.5.1",
"tweetnacl": "^1.0.3", "tweetnacl": "^1.0.3",
@@ -35330,14 +35330,14 @@
}, },
"packages/server": { "packages/server": {
"name": "@getpaseo/server", "name": "@getpaseo/server",
"version": "0.1.57-rc.1", "version": "0.1.58",
"dependencies": { "dependencies": {
"@agentclientprotocol/sdk": "^0.17.1", "@agentclientprotocol/sdk": "^0.17.1",
"@ai-sdk/openai": "2.0.52", "@ai-sdk/openai": "2.0.52",
"@anthropic-ai/claude-agent-sdk": "^0.2.11", "@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@deepgram/sdk": "^3.4.0", "@deepgram/sdk": "^3.4.0",
"@getpaseo/highlight": "0.1.57-rc.1", "@getpaseo/highlight": "0.1.58",
"@getpaseo/relay": "0.1.57-rc.1", "@getpaseo/relay": "0.1.58",
"@isaacs/ttlcache": "^2.1.4", "@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1", "@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.2.6", "@opencode-ai/sdk": "1.2.6",
@@ -35790,7 +35790,7 @@
}, },
"packages/website": { "packages/website": {
"name": "@getpaseo/website", "name": "@getpaseo/website",
"version": "0.1.57-rc.1", "version": "0.1.58",
"dependencies": { "dependencies": {
"@cloudflare/vite-plugin": "^1.20.3", "@cloudflare/vite-plugin": "^1.20.3",
"@cloudflare/workers-types": "^4.20260114.0", "@cloudflare/workers-types": "^4.20260114.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "paseo", "name": "paseo",
"version": "0.1.57-rc.1", "version": "0.1.58",
"private": true, "private": true,
"workspaces": [ "workspaces": [
"packages/expo-two-way-audio", "packages/expo-two-way-audio",
+4 -4
View File
@@ -1,7 +1,7 @@
{ {
"name": "@getpaseo/app", "name": "@getpaseo/app",
"main": "index.ts", "main": "index.ts",
"version": "0.1.57-rc.1", "version": "0.1.58",
"private": true, "private": true,
"scripts": { "scripts": {
"start": "expo start", "start": "expo start",
@@ -31,9 +31,9 @@
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@expo/vector-icons": "^15.0.2", "@expo/vector-icons": "^15.0.2",
"@floating-ui/react-native": "^0.10.7", "@floating-ui/react-native": "^0.10.7",
"@getpaseo/expo-two-way-audio": "0.1.57-rc.1", "@getpaseo/expo-two-way-audio": "0.1.58",
"@getpaseo/highlight": "0.1.57-rc.1", "@getpaseo/highlight": "0.1.58",
"@getpaseo/server": "0.1.57-rc.1", "@getpaseo/server": "0.1.58",
"@gorhom/bottom-sheet": "^5.2.6", "@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14", "@gorhom/portal": "^1.0.14",
"@react-native-async-storage/async-storage": "2.2.0", "@react-native-async-storage/async-storage": "2.2.0",
+3 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/cli", "name": "@getpaseo/cli",
"version": "0.1.57-rc.1", "version": "0.1.58",
"description": "Paseo CLI - control your AI coding agents from the command line", "description": "Paseo CLI - control your AI coding agents from the command line",
"type": "module", "type": "module",
"files": [ "files": [
@@ -24,8 +24,8 @@
}, },
"dependencies": { "dependencies": {
"@clack/prompts": "^1.0.0", "@clack/prompts": "^1.0.0",
"@getpaseo/relay": "0.1.57-rc.1", "@getpaseo/relay": "0.1.58",
"@getpaseo/server": "0.1.57-rc.1", "@getpaseo/server": "0.1.58",
"chalk": "^5.3.0", "chalk": "^5.3.0",
"commander": "^12.0.0", "commander": "^12.0.0",
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
+3 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/desktop", "name": "@getpaseo/desktop",
"version": "0.1.57-rc.1", "version": "0.1.58",
"private": true, "private": true,
"description": "Paseo desktop app (Electron wrapper)", "description": "Paseo desktop app (Electron wrapper)",
"main": "dist/main.js", "main": "dist/main.js",
@@ -13,8 +13,8 @@
"typecheck": "tsc --noEmit -p tsconfig.json" "typecheck": "tsc --noEmit -p tsconfig.json"
}, },
"dependencies": { "dependencies": {
"@getpaseo/cli": "0.1.57-rc.1", "@getpaseo/cli": "0.1.58",
"@getpaseo/server": "0.1.57-rc.1", "@getpaseo/server": "0.1.58",
"electron-log": "^5.4.3", "electron-log": "^5.4.3",
"electron-updater": "^6.6.2", "electron-updater": "^6.6.2",
"ws": "^8.14.2" "ws": "^8.14.2"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/expo-two-way-audio", "name": "@getpaseo/expo-two-way-audio",
"version": "0.1.57-rc.1", "version": "0.1.58",
"description": "Native module for two way audio streaming", "description": "Native module for two way audio streaming",
"main": "build/index.js", "main": "build/index.js",
"types": "build/index.d.ts", "types": "build/index.d.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/highlight", "name": "@getpaseo/highlight",
"version": "0.1.57-rc.1", "version": "0.1.58",
"type": "module", "type": "module",
"publishConfig": { "publishConfig": {
"access": "public" "access": "public"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/relay", "name": "@getpaseo/relay",
"version": "0.1.57-rc.1", "version": "0.1.58",
"description": "Paseo relay for bridging daemon and client connections", "description": "Paseo relay for bridging daemon and client connections",
"type": "module", "type": "module",
"publishConfig": { "publishConfig": {
+3 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/server", "name": "@getpaseo/server",
"version": "0.1.57-rc.1", "version": "0.1.58",
"description": "Paseo backend server", "description": "Paseo backend server",
"type": "module", "type": "module",
"publishConfig": { "publishConfig": {
@@ -60,8 +60,8 @@
"@ai-sdk/openai": "2.0.52", "@ai-sdk/openai": "2.0.52",
"@anthropic-ai/claude-agent-sdk": "^0.2.11", "@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@deepgram/sdk": "^3.4.0", "@deepgram/sdk": "^3.4.0",
"@getpaseo/highlight": "0.1.57-rc.1", "@getpaseo/highlight": "0.1.58",
"@getpaseo/relay": "0.1.57-rc.1", "@getpaseo/relay": "0.1.58",
"@isaacs/ttlcache": "^2.1.4", "@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1", "@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.2.6", "@opencode-ai/sdk": "1.2.6",
+1 -1
View File
@@ -2849,7 +2849,7 @@ export class DaemonClient {
providers: options?.providers, providers: options?.providers,
}, },
responseType: "refresh_providers_snapshot_response", responseType: "refresh_providers_snapshot_response",
timeout: 5000, timeout: 60000,
}); });
} }
@@ -95,7 +95,7 @@ export function toAgentPayload(
model: agent.config.model ?? null, model: agent.config.model ?? null,
thinkingOptionId, thinkingOptionId,
effectiveThinkingOptionId, effectiveThinkingOptionId,
runtimeInfo, ...(runtimeInfo ? { runtimeInfo } : {}),
createdAt: agent.createdAt.toISOString(), createdAt: agent.createdAt.toISOString(),
updatedAt: agent.updatedAt.toISOString(), updatedAt: agent.updatedAt.toISOString(),
lastUserMessageAt: agent.lastUserMessageAt ? agent.lastUserMessageAt.toISOString() : null, lastUserMessageAt: agent.lastUserMessageAt ? agent.lastUserMessageAt.toISOString() : null,
@@ -193,8 +193,6 @@ export function buildStoredAgentPayload(
availableModes: [], availableModes: [],
pendingPermissions: [], pendingPermissions: [],
persistence: toAgentPersistenceHandle(logger, providerRegistry, record.persistence), persistence: toAgentPersistenceHandle(logger, providerRegistry, record.persistence),
lastUsage: undefined,
lastError: undefined,
title: record.title ?? record.config?.title ?? null, title: record.title ?? record.config?.title ?? null,
requiresAttention: record.requiresAttention ?? false, requiresAttention: record.requiresAttention ?? false,
attentionReason: record.attentionReason ?? null, attentionReason: record.attentionReason ?? null,
@@ -2,12 +2,14 @@ import { describe, expect, it, vi } from "vitest";
import { mkdtemp, mkdir, rm } from "node:fs/promises"; import { mkdtemp, mkdir, rm } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { z } from "zod";
import { createTestLogger } from "../../test-utils/test-logger.js"; import { createTestLogger } from "../../test-utils/test-logger.js";
import { createAgentMcpServer } from "./mcp-server.js"; import { createAgentMcpServer } from "./mcp-server.js";
import type { AgentManager, ManagedAgent } from "./agent-manager.js"; import type { AgentManager, ManagedAgent } from "./agent-manager.js";
import type { AgentStorage, StoredAgentRecord } from "./agent-storage.js"; import type { AgentStorage, StoredAgentRecord } from "./agent-storage.js";
import type { ProviderDefinition } from "./provider-registry.js"; import type { ProviderDefinition } from "./provider-registry.js";
import { AgentSnapshotPayloadSchema } from "../../shared/messages.js";
type TestDeps = { type TestDeps = {
agentManager: AgentManager; agentManager: AgentManager;
@@ -702,6 +704,59 @@ describe("agent snapshot MCP serialization", () => {
]); ]);
}); });
it("emits list_agents payloads that satisfy the declared output schema", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
const liveAgent = {
id: "live-agent",
provider: "claude",
cwd: "/tmp/live-project",
config: {},
runtimeInfo: undefined,
createdAt: new Date("2026-04-11T00:00:00.000Z"),
updatedAt: new Date("2026-04-11T00:00:00.000Z"),
lastUserMessageAt: null,
lifecycle: "idle",
capabilities: {
supportsStreaming: false,
supportsSessionPersistence: false,
supportsDynamicModes: false,
supportsMcpServers: true,
supportsReasoningStream: false,
supportsToolInvocations: true,
},
currentModeId: null,
availableModes: [],
features: [],
pendingPermissions: new Map(),
persistence: null,
labels: {},
attention: { requiresAttention: false },
} as unknown as ManagedAgent;
spies.agentManager.listAgents.mockReturnValue([liveAgent]);
spies.agentStorage.list.mockResolvedValue([
createStoredRecord({ id: "stored-non-archived", archivedAt: null }),
createStoredRecord({ id: "stored-archived", archivedAt: "2026-04-12T00:00:00.000Z" }),
]);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
logger,
providerRegistry: {
claude: createProviderDefinition({}),
} as any,
});
const tool = (server as any)._registeredTools["list_agents"];
const response = await tool.callback({ includeArchived: true });
const parsed = z.array(AgentSnapshotPayloadSchema).safeParse(response.structuredContent.agents);
if (!parsed.success) {
throw new Error(
`list_agents response failed AgentSnapshotPayloadSchema: ${JSON.stringify(parsed.error.issues, null, 2)}`,
);
}
});
it("loads archived agents before reading get_agent_activity", async () => { it("loads archived agents before reading get_agent_activity", async () => {
const { agentManager, agentStorage, spies } = createTestDeps(); const { agentManager, agentStorage, spies } = createTestDeps();
const record = createStoredRecord({ id: "archived-activity-agent" }); const record = createStoredRecord({ id: "archived-activity-agent" });
+10 -19
View File
@@ -40,6 +40,7 @@ import {
parseDurationString, parseDurationString,
resolveProviderAndModel, resolveProviderAndModel,
sanitizePermissionRequest, sanitizePermissionRequest,
sendPromptToAgent,
setupFinishNotification, setupFinishNotification,
serializeSnapshotWithMetadata, serializeSnapshotWithMetadata,
startAgentRun, startAgentRun,
@@ -772,30 +773,20 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
}, },
}, },
async ({ agentId, prompt, sessionMode, background = false, notifyOnFinish = false }) => { async ({ agentId, prompt, sessionMode, background = false, notifyOnFinish = false }) => {
const snapshot = agentManager.getAgent(agentId);
if (!snapshot) {
throw new Error(`Agent ${agentId} not found`);
}
if (agentManager.hasInFlightRun(agentId)) { if (agentManager.hasInFlightRun(agentId)) {
waitTracker.cancel(agentId, "Agent run interrupted by new prompt"); waitTracker.cancel(agentId, "Agent run interrupted by new prompt");
} }
if (sessionMode) { await sendPromptToAgent({
await agentManager.setAgentMode(agentId, sessionMode); agentManager,
} agentStorage,
agentId,
try { userMessageText: prompt,
agentManager.recordUserMessage(agentId, prompt, { prompt,
emitState: false, sessionMode,
}); logger: childLogger,
} catch (error) {
childLogger.error({ err: error, agentId }, "Failed to record user message");
}
startAgentRun(agentManager, agentId, prompt, childLogger, {
replaceRunning: true,
}); });
if (notifyOnFinish && callerAgentId) { if (notifyOnFinish && callerAgentId) {
setupFinishNotification({ setupFinishNotification({
agentManager, agentManager,
+96 -3
View File
@@ -1,10 +1,15 @@
import { z } from "zod"; import { z } from "zod";
import type { Logger } from "pino"; import type { Logger } from "pino";
import type { AgentPromptInput, AgentPermissionRequest } from "./agent-sdk-types.js"; import type {
AgentPromptInput,
AgentPermissionRequest,
AgentRunOptions,
} from "./agent-sdk-types.js";
import type { AgentManager, ManagedAgent, WaitForAgentResult } from "./agent-manager.js"; import type { AgentManager, ManagedAgent, WaitForAgentResult } from "./agent-manager.js";
import { curateAgentActivity } from "./activity-curator.js"; import { curateAgentActivity } from "./activity-curator.js";
import type { AgentStorage } from "./agent-storage.js"; import type { AgentStorage } from "./agent-storage.js";
import { ensureAgentLoaded } from "./agent-loading.js";
import { serializeAgentSnapshot } from "../messages.js"; import { serializeAgentSnapshot } from "../messages.js";
import { StoredScheduleSchema } from "../schedule/types.js"; import { StoredScheduleSchema } from "../schedule/types.js";
import type { AgentProvider } from "./agent-sdk-types.js"; import type { AgentProvider } from "./agent-sdk-types.js";
@@ -94,6 +99,7 @@ export function resolveProviderAndModel(params: {
export type StartAgentRunOptions = { export type StartAgentRunOptions = {
replaceRunning?: boolean; replaceRunning?: boolean;
runOptions?: AgentRunOptions;
}; };
/** /**
@@ -171,9 +177,10 @@ export function startAgentRun(
options?: StartAgentRunOptions, options?: StartAgentRunOptions,
): void { ): void {
const shouldReplace = Boolean(options?.replaceRunning && agentManager.hasInFlightRun(agentId)); const shouldReplace = Boolean(options?.replaceRunning && agentManager.hasInFlightRun(agentId));
const runOptions = options?.runOptions;
const iterator = shouldReplace const iterator = shouldReplace
? agentManager.replaceAgentRun(agentId, prompt) ? agentManager.replaceAgentRun(agentId, prompt, runOptions)
: agentManager.streamAgent(agentId, prompt); : agentManager.streamAgent(agentId, prompt, runOptions);
void (async () => { void (async () => {
try { try {
for await (const _ of iterator) { for await (const _ of iterator) {
@@ -185,6 +192,92 @@ export function startAgentRun(
})(); })();
} }
/**
* Clear the archived flag from a stored agent record.
* Shared across Session (app/WS), MCP, and CLI so every surface that acts on
* an archived agent unarchives it the same way.
*/
export async function unarchiveAgentState(
agentStorage: AgentStorage,
agentManager: AgentManager,
agentId: string,
): Promise<boolean> {
const record = await agentStorage.get(agentId);
if (!record || !record.archivedAt) {
return false;
}
const updatedAt = new Date().toISOString();
await agentStorage.upsert({
...record,
archivedAt: null,
updatedAt,
});
agentManager.notifyAgentState(agentId);
return true;
}
export interface SendPromptToAgentParams {
agentManager: AgentManager;
agentStorage: AgentStorage;
agentId: string;
/** Raw user text to record in the timeline. */
userMessageText: string;
/** Prompt to dispatch to the provider (may include image blocks or wrapped text). */
prompt: AgentPromptInput;
messageId?: string;
runOptions?: AgentRunOptions;
/** Optional mode to set on the agent before the run starts. */
sessionMode?: string;
logger: Logger;
}
/**
* Full send-prompt orchestration: unarchive → load → (optional mode change) →
* record user message → start run.
*
* Every surface that sends a prompt to an agent (Session/WS, MCP, CLI-through-MCP)
* MUST go through this so behavior can never drift between them.
*/
export async function sendPromptToAgent(params: SendPromptToAgentParams): Promise<void> {
const {
agentManager,
agentStorage,
agentId,
userMessageText,
prompt,
messageId,
runOptions,
sessionMode,
logger,
} = params;
await unarchiveAgentState(agentStorage, agentManager, agentId);
await ensureAgentLoaded(agentId, {
agentManager,
agentStorage,
logger,
});
if (sessionMode) {
await agentManager.setAgentMode(agentId, sessionMode);
}
try {
agentManager.recordUserMessage(agentId, userMessageText, {
messageId,
emitState: false,
});
} catch (error) {
logger.error({ err: error, agentId }, "Failed to record user message");
}
startAgentRun(agentManager, agentId, prompt, logger, {
replaceRunning: true,
runOptions,
});
}
interface SetupFinishNotificationParams { interface SetupFinishNotificationParams {
agentManager: AgentManager; agentManager: AgentManager;
agentStorage: AgentStorage; agentStorage: AgentStorage;
@@ -1145,6 +1145,7 @@ export class ClaudeAgentClient implements AgentClient {
const resolvedBinary = (await findExecutable("claude")) ?? "not found"; const resolvedBinary = (await findExecutable("claude")) ?? "not found";
const available = await this.isAvailable(); const available = await this.isAvailable();
const version = await resolveClaudeVersion(this.runtimeSettings); const version = await resolveClaudeVersion(this.runtimeSettings);
const auth = available ? await resolveClaudeAuth(this.runtimeSettings) : null;
let modelsValue = "Not checked"; let modelsValue = "Not checked";
let status = formatDiagnosticStatus(available); let status = formatDiagnosticStatus(available);
@@ -1165,6 +1166,7 @@ export class ClaudeAgentClient implements AgentClient {
diagnostic: formatProviderDiagnostic("Claude Code", [ diagnostic: formatProviderDiagnostic("Claude Code", [
{ label: "Binary", value: resolvedBinary }, { label: "Binary", value: resolvedBinary },
...(version ? [{ label: "Version", value: version }] : []), ...(version ? [{ label: "Version", value: version }] : []),
...(auth ? [{ label: "Auth", value: auth }] : []),
{ label: "Models", value: modelsValue }, { label: "Models", value: modelsValue },
{ label: "Status", value: status }, { label: "Status", value: status },
]), ]),
@@ -1211,6 +1213,48 @@ async function resolveClaudeVersion(
} }
} }
async function resolveClaudeAuth(
runtimeSettings?: ProviderRuntimeSettings,
): Promise<string | null> {
const command = runtimeSettings?.command;
const run = async (
executable: string,
args: string[],
): Promise<{ stdout: string; stderr: string }> => {
try {
return await execCommand(executable, args, { timeout: 5_000 });
} catch (error) {
const err = error as { stdout?: string; stderr?: string; message?: string };
return {
stdout: err.stdout ?? "",
stderr: err.stderr ?? err.message ?? "",
};
}
};
try {
let result: { stdout: string; stderr: string };
if (command?.mode === "replace") {
result = await run(command.argv[0]!, [...command.argv.slice(1), "auth", "status"]);
} else {
const executable = await findExecutable("claude");
if (!executable) {
return null;
}
result = await run(executable, ["auth", "status"]);
}
const combined = [result.stdout, result.stderr]
.map((s) => s.trim())
.filter((s) => s.length > 0)
.join("\n");
return combined || null;
} catch {
return null;
}
}
function extractContextWindowSize(modelUsage: unknown): number | undefined { function extractContextWindowSize(modelUsage: unknown): number | undefined {
if (!modelUsage || typeof modelUsage !== "object") { if (!modelUsage || typeof modelUsage !== "object") {
return undefined; return undefined;
@@ -0,0 +1,217 @@
import type { ChildProcess } from "node:child_process";
import { copyFileSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, test } from "vitest";
import { findExecutable } from "../../../utils/executable.js";
import { spawnProcess } from "../../../utils/spawn.js";
type SpawnResult = {
code: number | null;
signal: NodeJS.Signals | null;
stdout: string;
stderr: string;
error: Error | null;
};
type ProviderLaunchCase = {
provider: "claude" | "codex" | "opencode" | "generic-acp";
binaryName: string;
args: string[];
shell?: boolean;
};
const JSON_ARG = '{"mcpServers":{"paseo":{"type":"http","url":"http://127.0.0.1:6767/mcp"}}}';
const tempDirs: string[] = [];
function makeFixture(binaryName: string, expectedArgs: string[]) {
const root = mkdtempSync(path.join(tmpdir(), `paseo ${binaryName} launch `));
tempDirs.push(root);
const fakeDaemonNode = path.join(root, "Fake Paseo.exe");
copyFileSync(process.execPath, fakeDaemonNode);
const assertScript = path.join(root, "assert-argv.js");
writeFileSync(
assertScript,
`
if (process.argv.includes("--version")) {
console.log("fake-provider 1.0.0");
process.exit(0);
}
const expected = JSON.parse(process.env.PASEO_EXPECTED_ARGV_JSON);
const actual = process.argv.slice(2);
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
console.error("ARGV_MISMATCH");
console.error(JSON.stringify({ expected, actual }));
process.exit(42);
}
console.log("ARGV_OK");
`,
);
const shim = path.join(root, `${binaryName}.cmd`);
writeFileSync(
shim,
["@echo off", "setlocal", `\"${fakeDaemonNode}\" \"${assertScript}\" %*`, ""].join("\r\n"),
);
return { root, shim, expectedArgs };
}
function collectChild(child: ChildProcess, timeoutMs = 10_000): Promise<SpawnResult> {
return new Promise((resolve) => {
const stdoutChunks: Buffer[] = [];
const stderrChunks: Buffer[] = [];
let error: Error | null = null;
let settled = false;
const settle = (result: Pick<SpawnResult, "code" | "signal">) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timer);
resolve({
...result,
stdout: Buffer.concat(stdoutChunks).toString("utf8"),
stderr: Buffer.concat(stderrChunks).toString("utf8"),
error,
});
};
const timer = setTimeout(() => {
child.kill("SIGKILL");
settle({ code: null, signal: "SIGKILL" });
}, timeoutMs);
timer.unref?.();
child.stdout?.on("data", (chunk: Buffer | string) => {
stdoutChunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
});
child.stderr?.on("data", (chunk: Buffer | string) => {
stderrChunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
});
child.once("error", (err) => {
error = err;
settle({ code: null, signal: null });
});
child.once("exit", (code, signal) => {
settle({ code, signal });
});
});
}
async function runProviderFixture(params: {
command: string;
args: string[];
expectedArgs: string[];
shell?: boolean;
}): Promise<SpawnResult> {
const child = spawnProcess(params.command, params.args, {
env: {
...process.env,
PASEO_EXPECTED_ARGV_JSON: JSON.stringify(params.expectedArgs),
},
stdio: ["ignore", "pipe", "pipe"],
...(params.shell === undefined ? {} : { shell: params.shell }),
});
return collectChild(child);
}
function withPathEntry<T>(dir: string, run: () => Promise<T>): Promise<T> {
const pathKey =
process.platform === "win32"
? (Object.keys(process.env).find((key) => key.toLowerCase() === "path") ?? "Path")
: "PATH";
const previousPath = process.env[pathKey];
process.env[pathKey] = previousPath ? `${dir}${path.delimiter}${previousPath}` : dir;
return run().finally(() => {
if (previousPath === undefined) {
delete process.env[pathKey];
} else {
process.env[pathKey] = previousPath;
}
});
}
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
const providerLaunchCases: ProviderLaunchCase[] = [
{
provider: "claude",
binaryName: "claude",
args: ["--mcp-config", JSON_ARG],
shell: false,
},
{
provider: "codex",
binaryName: "codex",
args: ["app-server", "--config", JSON_ARG],
},
{
provider: "opencode",
binaryName: "opencode",
args: ["serve", "--port", "49271", "--config", JSON_ARG],
},
{
provider: "generic-acp",
binaryName: "generic-acp",
args: ["--mcp-config", JSON_ARG],
},
];
describe.runIf(process.platform === "win32")("Windows provider launch parity", () => {
test("detected claude.cmd can be launched with Claude's JSON-safe spawn shape", async () => {
const args = ["--mcp-config", JSON_ARG];
const fixture = makeFixture("claude", args);
await withPathEntry(fixture.root, async () => {
const detected = await findExecutable("claude");
expect(detected?.toLowerCase()).toBe(fixture.shim.toLowerCase());
const result = await runProviderFixture({
command: detected!,
args,
expectedArgs: args,
shell: false,
});
expect(result.error).toBeNull();
expect(result.code).toBe(0);
expect(result.signal).toBeNull();
expect(result.stderr).toBe("");
expect(result.stdout.trim()).toBe("ARGV_OK");
});
});
test.each(
providerLaunchCases,
)("$provider launches a cmd shim from a path with spaces through spawnProcess", async ({
binaryName,
args,
shell,
}) => {
const fixture = makeFixture(binaryName, args);
const result = await runProviderFixture({
command: fixture.shim,
args: fixture.expectedArgs,
expectedArgs: fixture.expectedArgs,
shell,
});
expect(result.error).toBeNull();
expect(result.code).toBe(0);
expect(result.signal).toBeNull();
expect(result.stderr).toBe("");
expect(result.stdout.trim()).toBe("ARGV_OK");
});
});
@@ -170,7 +170,7 @@ export function toAgentPersistenceHandle(
return { return {
provider, provider,
sessionId: handle.sessionId, sessionId: handle.sessionId,
nativeHandle: handle.nativeHandle, ...(handle.nativeHandle !== undefined ? { nativeHandle: handle.nativeHandle } : {}),
metadata: handle.metadata, ...(handle.metadata !== undefined ? { metadata: handle.metadata } : {}),
} satisfies AgentPersistenceHandle; } satisfies AgentPersistenceHandle;
} }
+44 -60
View File
@@ -68,6 +68,7 @@ import {
toAgentPersistenceHandle, toAgentPersistenceHandle,
} from "./persistence-hooks.js"; } from "./persistence-hooks.js";
import { ensureAgentLoaded } from "./agent/agent-loading.js"; import { ensureAgentLoaded } from "./agent/agent-loading.js";
import { sendPromptToAgent, unarchiveAgentState } from "./agent/mcp-shared.js";
import { experimental_createMCPClient } from "ai"; import { experimental_createMCPClient } from "ai";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import type { VoiceCallerContext, VoiceSpeakHandler } from "./voice-types.js"; import type { VoiceCallerContext, VoiceSpeakHandler } from "./voice-types.js";
@@ -298,7 +299,7 @@ type ProcessingPhase = "idle" | "transcribing";
type WorkspaceGitWatchTarget = { type WorkspaceGitWatchTarget = {
cwd: string; cwd: string;
watchers: FSWatcher[]; watchers: FSWatcher[];
debounceTimer: NodeJS.Timeout | null; debounceTimer: ReturnType<typeof setTimeout> | null;
refreshPromise: Promise<void> | null; refreshPromise: Promise<void> | null;
refreshQueued: boolean; refreshQueued: boolean;
latestFingerprint: string | null; latestFingerprint: string | null;
@@ -600,7 +601,7 @@ export class Session {
private isVoiceMode = false; private isVoiceMode = false;
private speechInProgress = false; private speechInProgress = false;
private pendingVoiceSpeechStartAt: number | null = null; private pendingVoiceSpeechStartAt: number | null = null;
private pendingVoiceSpeechTimer: NodeJS.Timeout | null = null; private pendingVoiceSpeechTimer: ReturnType<typeof setTimeout> | null = null;
private readonly dictationStreamManager: DictationStreamManager; private readonly dictationStreamManager: DictationStreamManager;
private readonly resolveVoiceTurnDetection: () => TurnDetectionProvider | null; private readonly resolveVoiceTurnDetection: () => TurnDetectionProvider | null;
@@ -611,7 +612,7 @@ export class Session {
// Audio buffering for interruption handling // Audio buffering for interruption handling
private pendingAudioSegments: Array<{ audio: Buffer; format: string }> = []; private pendingAudioSegments: Array<{ audio: Buffer; format: string }> = [];
private bufferTimeout: NodeJS.Timeout | null = null; private bufferTimeout: ReturnType<typeof setTimeout> | null = null;
private audioBuffer: AudioBufferState | null = null; private audioBuffer: AudioBufferState | null = null;
// Optional TTS debug capture (persisted per utterance) // Optional TTS debug capture (persisted per utterance)
@@ -2224,12 +2225,17 @@ export class Session {
}); });
} }
private async unarchiveAgentState(agentId: string): Promise<boolean> {
return this.agentManager.unarchiveSnapshot(agentId);
}
private async unarchiveAgentByHandle(handle: AgentPersistenceHandle): Promise<void> { private async unarchiveAgentByHandle(handle: AgentPersistenceHandle): Promise<void> {
await this.agentManager.unarchiveSnapshotByHandle(handle); const records = await this.agentStorage.list();
const matched = records.find(
(record) =>
record.persistence?.provider === handle.provider &&
record.persistence?.sessionId === handle.sessionId,
);
if (!matched) {
return;
}
await unarchiveAgentState(this.agentStorage, this.agentManager, matched.id);
} }
private async handleUpdateAgentRequest( private async handleUpdateAgentRequest(
@@ -2723,38 +2729,28 @@ export class Session {
}`, }`,
); );
await this.unarchiveAgentState(agentId); const promptText = options?.spokenInput ? wrapSpokenInput(text) : text;
const prompt = this.buildAgentPrompt(promptText, images, attachments);
try { try {
await ensureAgentLoaded(agentId, { await sendPromptToAgent({
agentManager: this.agentManager, agentManager: this.agentManager,
agentStorage: this.agentStorage, agentStorage: this.agentStorage,
agentId,
userMessageText: text,
prompt,
messageId,
runOptions,
logger: this.sessionLogger, logger: this.sessionLogger,
}); });
return { ok: true };
} catch (error) { } catch (error) {
this.handleAgentRunError(agentId, error, "Failed to initialize agent before sending prompt"); this.handleAgentRunError(agentId, error, "Failed to send agent message");
return { return {
ok: false, ok: false,
error: error instanceof Error ? error.message : String(error), error: error instanceof Error ? error.message : String(error),
}; };
} }
try {
this.agentManager.recordUserMessage(agentId, text, {
messageId,
emitState: false,
});
} catch (error) {
this.sessionLogger.error(
{ err: error, agentId },
`Failed to record user message for agent ${agentId}`,
);
}
const promptText = options?.spokenInput ? wrapSpokenInput(text) : text;
const prompt = this.buildAgentPrompt(promptText, images, attachments);
return this.startAgentStream(agentId, prompt, runOptions);
} }
/** /**
@@ -2931,7 +2927,7 @@ export class Session {
try { try {
await this.unarchiveAgentByHandle(handle); await this.unarchiveAgentByHandle(handle);
const snapshot = await this.agentManager.resumeAgentFromPersistence(handle, overrides); const snapshot = await this.agentManager.resumeAgentFromPersistence(handle, overrides);
await this.unarchiveAgentState(snapshot.id); await unarchiveAgentState(this.agentStorage, this.agentManager, snapshot.id);
await this.agentManager.hydrateTimelineFromProvider(snapshot.id); await this.agentManager.hydrateTimelineFromProvider(snapshot.id);
await this.forwardAgentUpdate(snapshot); await this.forwardAgentUpdate(snapshot);
const timelineSize = this.agentManager.getTimeline(snapshot.id).length; const timelineSize = this.agentManager.getTimeline(snapshot.id).length;
@@ -2969,7 +2965,7 @@ export class Session {
this.sessionLogger.info({ agentId }, `Refreshing agent ${agentId} from persistence`); this.sessionLogger.info({ agentId }, `Refreshing agent ${agentId} from persistence`);
try { try {
await this.unarchiveAgentState(agentId); await unarchiveAgentState(this.agentStorage, this.agentManager, agentId);
let snapshot: ManagedAgent; let snapshot: ManagedAgent;
const existing = this.agentManager.getAgent(agentId); const existing = this.agentManager.getAgent(agentId);
if (existing) { if (existing) {
@@ -6751,44 +6747,32 @@ export class Session {
try { try {
const agentId = resolved.agentId; const agentId = resolved.agentId;
await this.unarchiveAgentState(agentId);
await ensureAgentLoaded(agentId, {
agentManager: this.agentManager,
agentStorage: this.agentStorage,
logger: this.sessionLogger,
});
this.sessionLogger.trace(
{ agentId, messageId: msg.messageId, textPrefix: msg.text.slice(0, 80) },
"send_agent_message_request: recording user message",
);
try {
this.agentManager.recordUserMessage(agentId, msg.text, {
messageId: msg.messageId,
emitState: false,
});
} catch (error) {
this.sessionLogger.error(
{ err: error, agentId },
"Failed to record user message for send_agent_message_request",
);
}
const prompt = this.buildAgentPrompt(msg.text, msg.images, msg.attachments); const prompt = this.buildAgentPrompt(msg.text, msg.images, msg.attachments);
this.sessionLogger.trace( this.sessionLogger.trace(
{ agentId, messageId: msg.messageId }, { agentId, messageId: msg.messageId, textPrefix: msg.text.slice(0, 80) },
"send_agent_message_request: starting agent stream", "send_agent_message_request: dispatching shared sendPromptToAgent",
); );
const started = this.startAgentStream(agentId, prompt); try {
if (!started.ok) { await sendPromptToAgent({
agentManager: this.agentManager,
agentStorage: this.agentStorage,
agentId,
userMessageText: msg.text,
prompt,
messageId: msg.messageId,
logger: this.sessionLogger,
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.handleAgentRunError(agentId, error, "Failed to send agent message");
this.emit({ this.emit({
type: "send_agent_message_response", type: "send_agent_message_response",
payload: { payload: {
requestId: msg.requestId, requestId: msg.requestId,
agentId, agentId,
accepted: false, accepted: false,
error: started.error, error: message,
}, },
}); });
return; return;
@@ -7681,9 +7665,9 @@ export class Session {
this.workspaceGitSubscriptions.clear(); this.workspaceGitSubscriptions.clear();
} }
// ============================================================================ // ----------------------------------------------------------------------------
// Terminal Handlers // Terminal Handlers
// ============================================================================ // ----------------------------------------------------------------------------
private ensureTerminalExitSubscription(terminal: TerminalSession): void { private ensureTerminalExitSubscription(terminal: TerminalSession): void {
if (this.terminalExitSubscriptions.has(terminal.id)) { if (this.terminalExitSubscriptions.has(terminal.id)) {
+13 -1
View File
@@ -138,7 +138,7 @@ describe("executableExists", () => {
expect(executableExists("/usr/local/bin/codex", exists)).toBe("/usr/local/bin/codex"); expect(executableExists("/usr/local/bin/codex", exists)).toBe("/usr/local/bin/codex");
}); });
test("on Windows, falls back to .exe, .cmd, then .ps1 for extensionless paths", () => { test("on Windows, falls back to .exe, then .cmd for extensionless paths", () => {
const originalPlatform = process.platform; const originalPlatform = process.platform;
Object.defineProperty(process, "platform", { value: "win32", writable: true }); Object.defineProperty(process, "platform", { value: "win32", writable: true });
try { try {
@@ -150,6 +150,18 @@ describe("executableExists", () => {
} }
}); });
test("on Windows, ignores PowerShell scripts for extensionless paths", () => {
const originalPlatform = process.platform;
Object.defineProperty(process, "platform", { value: "win32", writable: true });
try {
const exists = (candidate: string) => candidate === "C:\\tools\\codex.ps1";
expect(executableExists("C:\\tools\\codex", exists)).toBeNull();
} finally {
Object.defineProperty(process, "platform", { value: originalPlatform, writable: true });
}
});
test("returns null when no matching path exists", () => { test("returns null when no matching path exists", () => {
expect(executableExists("/missing/codex", () => false)).toBeNull(); expect(executableExists("/missing/codex", () => false)).toBeNull();
}); });
+7 -4
View File
@@ -35,7 +35,7 @@ async function enumerateCandidates(name: string): Promise<string[]> {
}); });
} }
function isWindowsCommandScript(executablePath: string): boolean { export function isWindowsCommandScript(executablePath: string): boolean {
const extension = extname(executablePath).toLowerCase(); const extension = extname(executablePath).toLowerCase();
return process.platform === "win32" && (extension === ".cmd" || extension === ".bat"); return process.platform === "win32" && (extension === ".cmd" || extension === ".bat");
} }
@@ -102,7 +102,7 @@ export function executableExists(
): string | null { ): string | null {
if (exists(executablePath)) return executablePath; if (exists(executablePath)) return executablePath;
if (process.platform === "win32" && !extname(executablePath)) { if (process.platform === "win32" && !extname(executablePath)) {
for (const ext of [".exe", ".cmd", ".ps1"]) { for (const ext of [".exe", ".cmd"]) {
const candidate = executablePath + ext; const candidate = executablePath + ext;
if (exists(candidate)) return candidate; if (exists(candidate)) return candidate;
} }
@@ -141,8 +141,11 @@ function escapeWindowsCmdValue(value: string): string {
const unquoted = isQuoted ? value.slice(1, -1) : value; const unquoted = isQuoted ? value.slice(1, -1) : value;
const escaped = unquoted.replace(/%/g, "%%").replace(/([&|^<>()!])/g, "^$1"); const escaped = unquoted.replace(/%/g, "%%").replace(/([&|^<>()!])/g, "^$1");
if (isQuoted || escaped.includes(" ")) { if (isQuoted || /[\s"]/u.test(unquoted)) {
return `"${escaped}"`; const quoted = escaped
.replace(/(\\*)"/g, (_match, slashes: string) => `${slashes}${slashes}\\"`)
.replace(/\\+$/u, (slashes) => `${slashes}${slashes}`);
return `"${quoted}"`;
} }
return escaped; return escaped;
@@ -0,0 +1,244 @@
import { copyFileSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import type { ChildProcess } from "node:child_process";
import { afterEach, describe, expect, test } from "vitest";
import { findExecutable } from "./executable.js";
import { spawnProcess } from "./spawn.js";
type SpawnResult = {
code: number | null;
signal: NodeJS.Signals | null;
stdout: string;
stderr: string;
error: Error | null;
};
const tempDirs: string[] = [];
const JSON_ARG = '{"key":"value with spaces","nested":{"quote":"\\"yes\\""}}';
function makeFixture(options?: { includeCmdShim?: boolean }): {
root: string;
fakeDaemonNode: string;
shim: string;
powerShellShim: string;
assertScript: string;
expectedArgs: string[];
} {
const root = mkdtempSync(path.join(tmpdir(), "paseo spawn regression "));
tempDirs.push(root);
const fakeDaemonNode = path.join(root, "Fake Paseo.exe");
copyFileSync(process.execPath, fakeDaemonNode);
const expectedArgs = ["--config", JSON_ARG];
const assertScript = path.join(root, "assert-argv.js");
writeFileSync(
assertScript,
`
if (process.argv.includes("--version")) {
console.log("fake-provider 1.0.0");
process.exit(0);
}
const expected = JSON.parse(process.env.PASEO_EXPECTED_ARGV_JSON);
const actual = process.argv.slice(2);
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
console.error("ARGV_MISMATCH");
console.error(JSON.stringify({ expected, actual }));
process.exit(42);
}
console.log("ARGV_OK");
`,
);
const shim = path.join(root, "claude.cmd");
if (options?.includeCmdShim !== false) {
writeFileSync(
shim,
["@echo off", "setlocal", `\"${fakeDaemonNode}\" \"${assertScript}\" %*`, ""].join("\r\n"),
);
}
const powerShellShim = path.join(root, "claude.ps1");
writeFileSync(
powerShellShim,
[
"$ErrorActionPreference = 'Stop'",
`& '${fakeDaemonNode.replace(/'/g, "''")}' '${assertScript.replace(/'/g, "''")}' @args`,
"exit $LASTEXITCODE",
"",
].join("\r\n"),
);
return { root, fakeDaemonNode, shim, powerShellShim, assertScript, expectedArgs };
}
function collectChild(child: ChildProcess, timeoutMs = 10_000): Promise<SpawnResult> {
return new Promise((resolve) => {
const stdoutChunks: Buffer[] = [];
const stderrChunks: Buffer[] = [];
let error: Error | null = null;
let settled = false;
const settle = (result: Pick<SpawnResult, "code" | "signal">) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timer);
resolve({
...result,
stdout: Buffer.concat(stdoutChunks).toString("utf8"),
stderr: Buffer.concat(stderrChunks).toString("utf8"),
error,
});
};
const timer = setTimeout(() => {
child.kill("SIGKILL");
settle({ code: null, signal: "SIGKILL" });
}, timeoutMs);
timer.unref?.();
child.stdout?.on("data", (chunk: Buffer | string) => {
stdoutChunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
});
child.stderr?.on("data", (chunk: Buffer | string) => {
stderrChunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
});
child.once("error", (err) => {
error = err;
settle({ code: null, signal: null });
});
child.once("exit", (code, signal) => {
settle({ code, signal });
});
});
}
async function runFixture(params: {
command: string;
args: string[];
shell?: boolean;
}): Promise<SpawnResult> {
const child = spawnProcess(params.command, params.args, {
env: {
...process.env,
PASEO_EXPECTED_ARGV_JSON: JSON.stringify(["--config", JSON_ARG]),
},
stdio: ["ignore", "pipe", "pipe"],
...(params.shell === undefined ? {} : { shell: params.shell }),
});
return collectChild(child);
}
function withWindowsPathEntry<T>(dir: string, run: () => Promise<T>): Promise<T> {
const pathKey =
process.platform === "win32"
? (Object.keys(process.env).find((key) => key.toLowerCase() === "path") ?? "Path")
: "PATH";
const previousPath = process.env[pathKey];
const previousPathExt = process.env.PATHEXT;
process.env[pathKey] = previousPath ? `${dir}${path.delimiter}${previousPath}` : dir;
process.env.PATHEXT = previousPathExt?.toLowerCase().includes(".ps1")
? previousPathExt
: `${previousPathExt ?? ""};.PS1`;
return run().finally(() => {
if (previousPath === undefined) {
delete process.env[pathKey];
} else {
process.env[pathKey] = previousPath;
}
if (previousPathExt === undefined) {
delete process.env.PATHEXT;
} else {
process.env.PATHEXT = previousPathExt;
}
});
}
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
describe.runIf(process.platform === "win32")("Windows spawn launch regression", () => {
test("launches a cmd shim from a path with spaces without corrupting JSON args", async () => {
const fixture = makeFixture();
const result = await runFixture({
command: fixture.shim,
args: fixture.expectedArgs,
});
expect(result.error).toBeNull();
expect(result.code).toBe(0);
expect(result.signal).toBeNull();
expect(result.stderr).toBe("");
expect(result.stdout.trim()).toBe("ARGV_OK");
});
test("launches a cmd shim even when the caller explicitly disables shell", async () => {
const fixture = makeFixture();
const result = await runFixture({
command: fixture.shim,
args: fixture.expectedArgs,
shell: false,
});
expect(result.error).toBeNull();
expect(result.code).toBe(0);
expect(result.signal).toBeNull();
expect(result.stderr).toBe("");
expect(result.stdout.trim()).toBe("ARGV_OK");
});
test("direct launch with a space-containing executable preserves JSON args", async () => {
const fixture = makeFixture();
const result = await runFixture({
command: fixture.fakeDaemonNode,
args: [fixture.assertScript, ...fixture.expectedArgs],
shell: false,
});
expect(result.error).toBeNull();
expect(result.code).toBe(0);
expect(result.signal).toBeNull();
expect(result.stderr).toBe("");
expect(result.stdout.trim()).toBe("ARGV_OK");
});
test("does not detect a PowerShell shim from PATH", async () => {
const fixture = makeFixture({ includeCmdShim: false });
await withWindowsPathEntry(fixture.root, async () => {
const detected = await findExecutable("claude");
expect(detected).toBeNull();
});
});
});
describe.skipIf(process.platform === "win32")("spawn launch regression smoke", () => {
test("direct launch with a space-containing executable works on this platform", async () => {
const fixture = makeFixture();
const result = await runFixture({
command: fixture.fakeDaemonNode,
args: [fixture.assertScript, ...fixture.expectedArgs],
shell: false,
});
expect(result.error).toBeNull();
expect(result.code).toBe(0);
expect(result.stderr).toBe("");
expect(result.stdout.trim()).toBe("ARGV_OK");
});
});
+11 -7
View File
@@ -1,7 +1,7 @@
import { execFile, spawn, type ChildProcess, type SpawnOptions } from "node:child_process"; import { execFile, spawn, type ChildProcess, type SpawnOptions } from "node:child_process";
import { promisify } from "node:util"; import { promisify } from "node:util";
import { quoteWindowsArgument, quoteWindowsCommand } from "./executable.js"; import { isWindowsCommandScript, quoteWindowsArgument, quoteWindowsCommand } from "./executable.js";
const execFileAsync = promisify(execFile); const execFileAsync = promisify(execFile);
@@ -24,13 +24,15 @@ export function spawnProcess(
options?: SpawnOptions, options?: SpawnOptions,
): ChildProcess { ): ChildProcess {
const isWindows = process.platform === "win32"; const isWindows = process.platform === "win32";
const shell = isWindowsCommandScript(command) ? true : (options?.shell ?? isWindows);
const resolvedCommand = isWindows ? quoteWindowsCommand(command) : command; const shouldQuoteForShell = isWindows && shell !== false;
const resolvedArgs = isWindows ? args.map(quoteWindowsArgument) : args; const resolvedCommand = shouldQuoteForShell ? quoteWindowsCommand(command) : command;
const resolvedArgs = shouldQuoteForShell ? args.map(quoteWindowsArgument) : args;
return spawn(resolvedCommand, resolvedArgs, { return spawn(resolvedCommand, resolvedArgs, {
...options, ...options,
shell: options?.shell ?? isWindows, shell,
windowsHide: true, windowsHide: true,
}); });
} }
@@ -41,8 +43,10 @@ export async function execCommand(
options?: ExecCommandOptions, options?: ExecCommandOptions,
): Promise<ExecCommandResult> { ): Promise<ExecCommandResult> {
const isWindows = process.platform === "win32"; const isWindows = process.platform === "win32";
const resolvedCommand = isWindows ? quoteWindowsCommand(command) : command; const shell = isWindowsCommandScript(command) ? true : isWindows;
const resolvedArgs = isWindows ? args.map(quoteWindowsArgument) : args; const shouldQuoteForShell = isWindows && shell !== false;
const resolvedCommand = shouldQuoteForShell ? quoteWindowsCommand(command) : command;
const resolvedArgs = shouldQuoteForShell ? args.map(quoteWindowsArgument) : args;
return execFileAsync(resolvedCommand, resolvedArgs, { return execFileAsync(resolvedCommand, resolvedArgs, {
cwd: options?.cwd, cwd: options?.cwd,
@@ -50,7 +54,7 @@ export async function execCommand(
encoding: options?.encoding ?? "utf8", encoding: options?.encoding ?? "utf8",
timeout: options?.timeout, timeout: options?.timeout,
maxBuffer: options?.maxBuffer, maxBuffer: options?.maxBuffer,
shell: isWindows, shell,
windowsHide: true, windowsHide: true,
}) as Promise<ExecCommandResult>; }) as Promise<ExecCommandResult>;
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/website", "name": "@getpaseo/website",
"version": "0.1.57-rc.1", "version": "0.1.58",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {