mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Merge remote-tracking branch 'origin/main' into dev
# Conflicts: # packages/server/src/server/session.ts
This commit is contained in:
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -105,6 +105,7 @@ jobs:
|
||||
run: >
|
||||
npx vitest run
|
||||
src/utils/executable.test.ts
|
||||
src/utils/spawn.launch-regression.test.ts
|
||||
src/utils/spawn.test.ts
|
||||
src/utils/run-git-command.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-snapshot-manager.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/workspace-registry-model.test.ts
|
||||
src/server/persisted-config.test.ts
|
||||
|
||||
28
CHANGELOG.md
28
CHANGELOG.md
@@ -1,5 +1,33 @@
|
||||
# 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
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -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.
|
||||
- **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
|
||||
|
||||
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.
|
||||
|
||||
@@ -42,7 +42,7 @@ buildNpmPackage rec {
|
||||
|
||||
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
|
||||
# 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
|
||||
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
|
||||
|
||||
38
package-lock.json
generated
38
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.57-rc.1",
|
||||
"version": "0.1.58",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.1.57-rc.1",
|
||||
"version": "0.1.58",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"workspaces": [
|
||||
@@ -34854,16 +34854,16 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.57-rc.1",
|
||||
"version": "0.1.58",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
"@floating-ui/react-native": "^0.10.7",
|
||||
"@getpaseo/expo-two-way-audio": "0.1.57-rc.1",
|
||||
"@getpaseo/highlight": "0.1.57-rc.1",
|
||||
"@getpaseo/server": "0.1.57-rc.1",
|
||||
"@getpaseo/expo-two-way-audio": "0.1.58",
|
||||
"@getpaseo/highlight": "0.1.58",
|
||||
"@getpaseo/server": "0.1.58",
|
||||
"@gorhom/bottom-sheet": "^5.2.6",
|
||||
"@gorhom/portal": "^1.0.14",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
@@ -35004,11 +35004,11 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.57-rc.1",
|
||||
"version": "0.1.58",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/relay": "0.1.57-rc.1",
|
||||
"@getpaseo/server": "0.1.57-rc.1",
|
||||
"@getpaseo/relay": "0.1.58",
|
||||
"@getpaseo/server": "0.1.58",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -35049,11 +35049,11 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.57-rc.1",
|
||||
"version": "0.1.58",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "0.1.57-rc.1",
|
||||
"@getpaseo/server": "0.1.57-rc.1",
|
||||
"@getpaseo/cli": "0.1.58",
|
||||
"@getpaseo/server": "0.1.58",
|
||||
"electron-log": "^5.4.3",
|
||||
"electron-updater": "^6.6.2",
|
||||
"ws": "^8.14.2"
|
||||
@@ -35087,7 +35087,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.57-rc.1",
|
||||
"version": "0.1.58",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "1.9.4",
|
||||
@@ -35288,7 +35288,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.57-rc.1",
|
||||
"version": "0.1.58",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@lezer/cpp": "^1.1.5",
|
||||
@@ -35314,7 +35314,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.57-rc.1",
|
||||
"version": "0.1.58",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -35330,14 +35330,14 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.57-rc.1",
|
||||
"version": "0.1.58",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@ai-sdk/openai": "2.0.52",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
||||
"@deepgram/sdk": "^3.4.0",
|
||||
"@getpaseo/highlight": "0.1.57-rc.1",
|
||||
"@getpaseo/relay": "0.1.57-rc.1",
|
||||
"@getpaseo/highlight": "0.1.58",
|
||||
"@getpaseo/relay": "0.1.58",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@opencode-ai/sdk": "1.2.6",
|
||||
@@ -35790,7 +35790,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.1.57-rc.1",
|
||||
"version": "0.1.58",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.20.3",
|
||||
"@cloudflare/workers-types": "^4.20260114.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.57-rc.1",
|
||||
"version": "0.1.58",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"packages/expo-two-way-audio",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"main": "index.ts",
|
||||
"version": "0.1.57-rc.1",
|
||||
"version": "0.1.58",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
@@ -31,9 +31,9 @@
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
"@floating-ui/react-native": "^0.10.7",
|
||||
"@getpaseo/expo-two-way-audio": "0.1.57-rc.1",
|
||||
"@getpaseo/highlight": "0.1.57-rc.1",
|
||||
"@getpaseo/server": "0.1.57-rc.1",
|
||||
"@getpaseo/expo-two-way-audio": "0.1.58",
|
||||
"@getpaseo/highlight": "0.1.58",
|
||||
"@getpaseo/server": "0.1.58",
|
||||
"@gorhom/bottom-sheet": "^5.2.6",
|
||||
"@gorhom/portal": "^1.0.14",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"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",
|
||||
"type": "module",
|
||||
"files": [
|
||||
@@ -24,8 +24,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/relay": "0.1.57-rc.1",
|
||||
"@getpaseo/server": "0.1.57-rc.1",
|
||||
"@getpaseo/relay": "0.1.58",
|
||||
"@getpaseo/server": "0.1.58",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.57-rc.1",
|
||||
"version": "0.1.58",
|
||||
"private": true,
|
||||
"description": "Paseo desktop app (Electron wrapper)",
|
||||
"main": "dist/main.js",
|
||||
@@ -13,8 +13,8 @@
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "0.1.57-rc.1",
|
||||
"@getpaseo/server": "0.1.57-rc.1",
|
||||
"@getpaseo/cli": "0.1.58",
|
||||
"@getpaseo/server": "0.1.58",
|
||||
"electron-log": "^5.4.3",
|
||||
"electron-updater": "^6.6.2",
|
||||
"ws": "^8.14.2"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.57-rc.1",
|
||||
"version": "0.1.58",
|
||||
"description": "Native module for two way audio streaming",
|
||||
"main": "build/index.js",
|
||||
"types": "build/index.d.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.57-rc.1",
|
||||
"version": "0.1.58",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.57-rc.1",
|
||||
"version": "0.1.58",
|
||||
"description": "Paseo relay for bridging daemon and client connections",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.57-rc.1",
|
||||
"version": "0.1.58",
|
||||
"description": "Paseo backend server",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
@@ -60,8 +60,8 @@
|
||||
"@ai-sdk/openai": "2.0.52",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
||||
"@deepgram/sdk": "^3.4.0",
|
||||
"@getpaseo/highlight": "0.1.57-rc.1",
|
||||
"@getpaseo/relay": "0.1.57-rc.1",
|
||||
"@getpaseo/highlight": "0.1.58",
|
||||
"@getpaseo/relay": "0.1.58",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@opencode-ai/sdk": "1.2.6",
|
||||
|
||||
@@ -2849,7 +2849,7 @@ export class DaemonClient {
|
||||
providers: options?.providers,
|
||||
},
|
||||
responseType: "refresh_providers_snapshot_response",
|
||||
timeout: 5000,
|
||||
timeout: 60000,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -95,7 +95,7 @@ export function toAgentPayload(
|
||||
model: agent.config.model ?? null,
|
||||
thinkingOptionId,
|
||||
effectiveThinkingOptionId,
|
||||
runtimeInfo,
|
||||
...(runtimeInfo ? { runtimeInfo } : {}),
|
||||
createdAt: agent.createdAt.toISOString(),
|
||||
updatedAt: agent.updatedAt.toISOString(),
|
||||
lastUserMessageAt: agent.lastUserMessageAt ? agent.lastUserMessageAt.toISOString() : null,
|
||||
@@ -193,8 +193,6 @@ export function buildStoredAgentPayload(
|
||||
availableModes: [],
|
||||
pendingPermissions: [],
|
||||
persistence: toAgentPersistenceHandle(logger, providerRegistry, record.persistence),
|
||||
lastUsage: undefined,
|
||||
lastError: undefined,
|
||||
title: record.title ?? record.config?.title ?? null,
|
||||
requiresAttention: record.requiresAttention ?? false,
|
||||
attentionReason: record.attentionReason ?? null,
|
||||
|
||||
@@ -2,12 +2,14 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import { mkdtemp, mkdir, rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createTestLogger } from "../../test-utils/test-logger.js";
|
||||
import { createAgentMcpServer } from "./mcp-server.js";
|
||||
import type { AgentManager, ManagedAgent } from "./agent-manager.js";
|
||||
import type { AgentStorage, StoredAgentRecord } from "./agent-storage.js";
|
||||
import type { ProviderDefinition } from "./provider-registry.js";
|
||||
import { AgentSnapshotPayloadSchema } from "../../shared/messages.js";
|
||||
|
||||
type TestDeps = {
|
||||
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 () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
const record = createStoredRecord({ id: "archived-activity-agent" });
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
parseDurationString,
|
||||
resolveProviderAndModel,
|
||||
sanitizePermissionRequest,
|
||||
sendPromptToAgent,
|
||||
setupFinishNotification,
|
||||
serializeSnapshotWithMetadata,
|
||||
startAgentRun,
|
||||
@@ -772,30 +773,20 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
},
|
||||
},
|
||||
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)) {
|
||||
waitTracker.cancel(agentId, "Agent run interrupted by new prompt");
|
||||
}
|
||||
|
||||
if (sessionMode) {
|
||||
await agentManager.setAgentMode(agentId, sessionMode);
|
||||
}
|
||||
|
||||
try {
|
||||
agentManager.recordUserMessage(agentId, prompt, {
|
||||
emitState: false,
|
||||
});
|
||||
} catch (error) {
|
||||
childLogger.error({ err: error, agentId }, "Failed to record user message");
|
||||
}
|
||||
|
||||
startAgentRun(agentManager, agentId, prompt, childLogger, {
|
||||
replaceRunning: true,
|
||||
await sendPromptToAgent({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
agentId,
|
||||
userMessageText: prompt,
|
||||
prompt,
|
||||
sessionMode,
|
||||
logger: childLogger,
|
||||
});
|
||||
|
||||
if (notifyOnFinish && callerAgentId) {
|
||||
setupFinishNotification({
|
||||
agentManager,
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { z } from "zod";
|
||||
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 { curateAgentActivity } from "./activity-curator.js";
|
||||
import type { AgentStorage } from "./agent-storage.js";
|
||||
import { ensureAgentLoaded } from "./agent-loading.js";
|
||||
import { serializeAgentSnapshot } from "../messages.js";
|
||||
import { StoredScheduleSchema } from "../schedule/types.js";
|
||||
import type { AgentProvider } from "./agent-sdk-types.js";
|
||||
@@ -94,6 +99,7 @@ export function resolveProviderAndModel(params: {
|
||||
|
||||
export type StartAgentRunOptions = {
|
||||
replaceRunning?: boolean;
|
||||
runOptions?: AgentRunOptions;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -171,9 +177,10 @@ export function startAgentRun(
|
||||
options?: StartAgentRunOptions,
|
||||
): void {
|
||||
const shouldReplace = Boolean(options?.replaceRunning && agentManager.hasInFlightRun(agentId));
|
||||
const runOptions = options?.runOptions;
|
||||
const iterator = shouldReplace
|
||||
? agentManager.replaceAgentRun(agentId, prompt)
|
||||
: agentManager.streamAgent(agentId, prompt);
|
||||
? agentManager.replaceAgentRun(agentId, prompt, runOptions)
|
||||
: agentManager.streamAgent(agentId, prompt, runOptions);
|
||||
void (async () => {
|
||||
try {
|
||||
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 {
|
||||
agentManager: AgentManager;
|
||||
agentStorage: AgentStorage;
|
||||
|
||||
@@ -1145,6 +1145,7 @@ export class ClaudeAgentClient implements AgentClient {
|
||||
const resolvedBinary = (await findExecutable("claude")) ?? "not found";
|
||||
const available = await this.isAvailable();
|
||||
const version = await resolveClaudeVersion(this.runtimeSettings);
|
||||
const auth = available ? await resolveClaudeAuth(this.runtimeSettings) : null;
|
||||
let modelsValue = "Not checked";
|
||||
let status = formatDiagnosticStatus(available);
|
||||
|
||||
@@ -1165,6 +1166,7 @@ export class ClaudeAgentClient implements AgentClient {
|
||||
diagnostic: formatProviderDiagnostic("Claude Code", [
|
||||
{ label: "Binary", value: resolvedBinary },
|
||||
...(version ? [{ label: "Version", value: version }] : []),
|
||||
...(auth ? [{ label: "Auth", value: auth }] : []),
|
||||
{ label: "Models", value: modelsValue },
|
||||
{ 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 {
|
||||
if (!modelUsage || typeof modelUsage !== "object") {
|
||||
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 {
|
||||
provider,
|
||||
sessionId: handle.sessionId,
|
||||
nativeHandle: handle.nativeHandle,
|
||||
metadata: handle.metadata,
|
||||
...(handle.nativeHandle !== undefined ? { nativeHandle: handle.nativeHandle } : {}),
|
||||
...(handle.metadata !== undefined ? { metadata: handle.metadata } : {}),
|
||||
} satisfies AgentPersistenceHandle;
|
||||
}
|
||||
|
||||
@@ -68,6 +68,7 @@ import {
|
||||
toAgentPersistenceHandle,
|
||||
} from "./persistence-hooks.js";
|
||||
import { ensureAgentLoaded } from "./agent/agent-loading.js";
|
||||
import { sendPromptToAgent, unarchiveAgentState } from "./agent/mcp-shared.js";
|
||||
import { experimental_createMCPClient } from "ai";
|
||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
||||
import type { VoiceCallerContext, VoiceSpeakHandler } from "./voice-types.js";
|
||||
@@ -298,7 +299,7 @@ type ProcessingPhase = "idle" | "transcribing";
|
||||
type WorkspaceGitWatchTarget = {
|
||||
cwd: string;
|
||||
watchers: FSWatcher[];
|
||||
debounceTimer: NodeJS.Timeout | null;
|
||||
debounceTimer: ReturnType<typeof setTimeout> | null;
|
||||
refreshPromise: Promise<void> | null;
|
||||
refreshQueued: boolean;
|
||||
latestFingerprint: string | null;
|
||||
@@ -600,7 +601,7 @@ export class Session {
|
||||
private isVoiceMode = false;
|
||||
private speechInProgress = false;
|
||||
private pendingVoiceSpeechStartAt: number | null = null;
|
||||
private pendingVoiceSpeechTimer: NodeJS.Timeout | null = null;
|
||||
private pendingVoiceSpeechTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
private readonly dictationStreamManager: DictationStreamManager;
|
||||
private readonly resolveVoiceTurnDetection: () => TurnDetectionProvider | null;
|
||||
@@ -611,7 +612,7 @@ export class Session {
|
||||
|
||||
// Audio buffering for interruption handling
|
||||
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;
|
||||
|
||||
// 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> {
|
||||
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(
|
||||
@@ -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 {
|
||||
await ensureAgentLoaded(agentId, {
|
||||
await sendPromptToAgent({
|
||||
agentManager: this.agentManager,
|
||||
agentStorage: this.agentStorage,
|
||||
agentId,
|
||||
userMessageText: text,
|
||||
prompt,
|
||||
messageId,
|
||||
runOptions,
|
||||
logger: this.sessionLogger,
|
||||
});
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
this.handleAgentRunError(agentId, error, "Failed to initialize agent before sending prompt");
|
||||
this.handleAgentRunError(agentId, error, "Failed to send agent message");
|
||||
return {
|
||||
ok: false,
|
||||
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 {
|
||||
await this.unarchiveAgentByHandle(handle);
|
||||
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.forwardAgentUpdate(snapshot);
|
||||
const timelineSize = this.agentManager.getTimeline(snapshot.id).length;
|
||||
@@ -2969,7 +2965,7 @@ export class Session {
|
||||
this.sessionLogger.info({ agentId }, `Refreshing agent ${agentId} from persistence`);
|
||||
|
||||
try {
|
||||
await this.unarchiveAgentState(agentId);
|
||||
await unarchiveAgentState(this.agentStorage, this.agentManager, agentId);
|
||||
let snapshot: ManagedAgent;
|
||||
const existing = this.agentManager.getAgent(agentId);
|
||||
if (existing) {
|
||||
@@ -6751,44 +6747,32 @@ export class Session {
|
||||
|
||||
try {
|
||||
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);
|
||||
this.sessionLogger.trace(
|
||||
{ agentId, messageId: msg.messageId },
|
||||
"send_agent_message_request: starting agent stream",
|
||||
{ agentId, messageId: msg.messageId, textPrefix: msg.text.slice(0, 80) },
|
||||
"send_agent_message_request: dispatching shared sendPromptToAgent",
|
||||
);
|
||||
const started = this.startAgentStream(agentId, prompt);
|
||||
if (!started.ok) {
|
||||
try {
|
||||
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({
|
||||
type: "send_agent_message_response",
|
||||
payload: {
|
||||
requestId: msg.requestId,
|
||||
agentId,
|
||||
accepted: false,
|
||||
error: started.error,
|
||||
error: message,
|
||||
},
|
||||
});
|
||||
return;
|
||||
@@ -7681,9 +7665,9 @@ export class Session {
|
||||
this.workspaceGitSubscriptions.clear();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ----------------------------------------------------------------------------
|
||||
// Terminal Handlers
|
||||
// ============================================================================
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
private ensureTerminalExitSubscription(terminal: TerminalSession): void {
|
||||
if (this.terminalExitSubscriptions.has(terminal.id)) {
|
||||
|
||||
@@ -138,7 +138,7 @@ describe("executableExists", () => {
|
||||
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;
|
||||
Object.defineProperty(process, "platform", { value: "win32", writable: true });
|
||||
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", () => {
|
||||
expect(executableExists("/missing/codex", () => false)).toBeNull();
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
return process.platform === "win32" && (extension === ".cmd" || extension === ".bat");
|
||||
}
|
||||
@@ -102,7 +102,7 @@ export function executableExists(
|
||||
): string | null {
|
||||
if (exists(executablePath)) return executablePath;
|
||||
if (process.platform === "win32" && !extname(executablePath)) {
|
||||
for (const ext of [".exe", ".cmd", ".ps1"]) {
|
||||
for (const ext of [".exe", ".cmd"]) {
|
||||
const candidate = executablePath + ext;
|
||||
if (exists(candidate)) return candidate;
|
||||
}
|
||||
@@ -141,8 +141,11 @@ function escapeWindowsCmdValue(value: string): string {
|
||||
const unquoted = isQuoted ? value.slice(1, -1) : value;
|
||||
const escaped = unquoted.replace(/%/g, "%%").replace(/([&|^<>()!])/g, "^$1");
|
||||
|
||||
if (isQuoted || escaped.includes(" ")) {
|
||||
return `"${escaped}"`;
|
||||
if (isQuoted || /[\s"]/u.test(unquoted)) {
|
||||
const quoted = escaped
|
||||
.replace(/(\\*)"/g, (_match, slashes: string) => `${slashes}${slashes}\\"`)
|
||||
.replace(/\\+$/u, (slashes) => `${slashes}${slashes}`);
|
||||
return `"${quoted}"`;
|
||||
}
|
||||
|
||||
return escaped;
|
||||
|
||||
244
packages/server/src/utils/spawn.launch-regression.test.ts
Normal file
244
packages/server/src/utils/spawn.launch-regression.test.ts
Normal file
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { execFile, spawn, type ChildProcess, type SpawnOptions } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { quoteWindowsArgument, quoteWindowsCommand } from "./executable.js";
|
||||
import { isWindowsCommandScript, quoteWindowsArgument, quoteWindowsCommand } from "./executable.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
@@ -24,13 +24,15 @@ export function spawnProcess(
|
||||
options?: SpawnOptions,
|
||||
): ChildProcess {
|
||||
const isWindows = process.platform === "win32";
|
||||
const shell = isWindowsCommandScript(command) ? true : (options?.shell ?? isWindows);
|
||||
|
||||
const resolvedCommand = isWindows ? quoteWindowsCommand(command) : command;
|
||||
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 spawn(resolvedCommand, resolvedArgs, {
|
||||
...options,
|
||||
shell: options?.shell ?? isWindows,
|
||||
shell,
|
||||
windowsHide: true,
|
||||
});
|
||||
}
|
||||
@@ -41,8 +43,10 @@ export async function execCommand(
|
||||
options?: ExecCommandOptions,
|
||||
): Promise<ExecCommandResult> {
|
||||
const isWindows = process.platform === "win32";
|
||||
const resolvedCommand = isWindows ? quoteWindowsCommand(command) : command;
|
||||
const resolvedArgs = isWindows ? args.map(quoteWindowsArgument) : args;
|
||||
const shell = isWindowsCommandScript(command) ? true : isWindows;
|
||||
const shouldQuoteForShell = isWindows && shell !== false;
|
||||
const resolvedCommand = shouldQuoteForShell ? quoteWindowsCommand(command) : command;
|
||||
const resolvedArgs = shouldQuoteForShell ? args.map(quoteWindowsArgument) : args;
|
||||
|
||||
return execFileAsync(resolvedCommand, resolvedArgs, {
|
||||
cwd: options?.cwd,
|
||||
@@ -50,7 +54,7 @@ export async function execCommand(
|
||||
encoding: options?.encoding ?? "utf8",
|
||||
timeout: options?.timeout,
|
||||
maxBuffer: options?.maxBuffer,
|
||||
shell: isWindows,
|
||||
shell,
|
||||
windowsHide: true,
|
||||
}) as Promise<ExecCommandResult>;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.1.57-rc.1",
|
||||
"version": "0.1.58",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
Reference in New Issue
Block a user