mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
283390e6d5 | ||
|
|
dcb04a4802 | ||
|
|
0dfd909646 | ||
|
|
00219cfd04 | ||
|
|
fbf10ba4c6 | ||
|
|
2a4945ad0b | ||
|
|
3ba4d09294 | ||
|
|
013e53881b | ||
|
|
9fb87b9d86 | ||
|
|
b557688ab3 |
2
.github/workflows/deploy-app.yml
vendored
2
.github/workflows/deploy-app.yml
vendored
@@ -24,7 +24,7 @@ jobs:
|
||||
scope: "@boudra"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install --workspace=@getpaseo/app --include-workspace-root
|
||||
run: npm ci
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
|
||||
15
CHANGELOG.md
15
CHANGELOG.md
@@ -1,5 +1,20 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.64 - 2026-04-28
|
||||
|
||||
### Added
|
||||
|
||||
- OpenCode now has a Full Access mode that auto-approves tool calls. ([#595](https://github.com/getpaseo/paseo/pull/595) by [@tmih06](https://github.com/tmih06))
|
||||
- OpenCode supports executable slash commands. ([#597](https://github.com/getpaseo/paseo/pull/597) by [@tmih06](https://github.com/tmih06))
|
||||
|
||||
### Improved
|
||||
|
||||
- `@`-mention stays responsive on very large projects. ([#600](https://github.com/getpaseo/paseo/pull/600) by [@yuruiz](https://github.com/yuruiz))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Workspaces still load when `paseo.json` has a parse error.
|
||||
|
||||
## 0.1.63 - 2026-04-28
|
||||
|
||||
### Added
|
||||
|
||||
@@ -69,11 +69,31 @@ The rollout is driven by a `rolloutHours` field stamped into the GitHub Release
|
||||
|
||||
`npm run release:patch` → tag push → 24h ramp. No extra action needed.
|
||||
|
||||
The `rollout_hours` input on `desktop-release.yml` is **only read on `workflow_dispatch`** — tag-push runs always default to 24. To get any other rollout duration on a fresh release, use the post-publish flip below.
|
||||
|
||||
### Instant-admit release (rollout_hours=0 from publish)
|
||||
|
||||
For a fresh release that should admit everyone immediately (low-risk change, doc-only, hotfix, or just a release you want out fast), cut the release normally and queue the rollout flip immediately after:
|
||||
|
||||
```bash
|
||||
# 1. Cut and publish (default 24h ramp from tag push).
|
||||
npm run release:patch
|
||||
|
||||
# 2. Immediately queue the flip — runs as soon as finalize-rollout completes.
|
||||
gh workflow run desktop-rollout.yml \
|
||||
-f tag=v0.1.64 \
|
||||
-f rollout_hours=0
|
||||
```
|
||||
|
||||
**Why this is gap-free:** `desktop-release.yml`'s `finalize-rollout` job and `desktop-rollout.yml` share the concurrency group `desktop-rollout-<tag>`. Dispatching `desktop-rollout.yml` while the tag-push pipeline is still running queues it safely behind `finalize-rollout`. The release manifest goes from `rolloutHours=24` to `rolloutHours=0` within ~30s of publish, and the renderer polls every 30 minutes — so no stable user can admit during the gap.
|
||||
|
||||
Run the dispatch right after `release:patch` returns. Don't wait for the tag-push CI to finish.
|
||||
|
||||
### Adjusting an already-published release
|
||||
|
||||
To change the rollout duration on a release that's already shipped — e.g. flip a hotfix to instant admit, or slow a release down — use the dedicated `desktop-rollout.yml` workflow. It edits the manifests in place on the GitHub release without rebuilding anything. It only rewrites `rolloutHours`; `releaseDate` is preserved, so the rollout clock keeps ticking from the original publish time.
|
||||
|
||||
**Hotfix (instant admit):**
|
||||
**Hotfix (instant admit) on an already-shipped release:**
|
||||
|
||||
```bash
|
||||
gh workflow run desktop-rollout.yml \
|
||||
@@ -93,11 +113,11 @@ gh workflow run desktop-rollout.yml \
|
||||
|
||||
`rollout_hours` is **total duration since the original release date**, not "extend by N more hours from now." If `v0.1.42` was published 2h ago and you set `rollout_hours=72`, the ramp finishes 70h from now.
|
||||
|
||||
The dispatch is idempotent and shares a concurrency group with `desktop-release.yml`'s `finalize-rollout` job keyed on the tag, so it serializes safely against an in-flight tag-push pipeline targeting the same release.
|
||||
The dispatch is idempotent and shares the `desktop-rollout-<tag>` concurrency group with `desktop-release.yml`'s `finalize-rollout` job, so it serializes safely against an in-flight tag-push pipeline targeting the same release.
|
||||
|
||||
### Faster ramp at release time
|
||||
### Custom ramp on a manually-dispatched build
|
||||
|
||||
For low-risk changes (doc-only, dependency bumps with no behavior change), trigger `desktop-release.yml` manually with a shorter rollout window so the build itself stamps a smaller `rolloutHours`:
|
||||
`desktop-release.yml` accepts `rollout_hours` only on `workflow_dispatch`, which is the path used to **rebuild an existing tag** (retry a failed release, force a rebuild on a different ref). When you go that route, you can stamp a non-default ramp directly:
|
||||
|
||||
```bash
|
||||
gh workflow run desktop-release.yml \
|
||||
@@ -105,6 +125,8 @@ gh workflow run desktop-release.yml \
|
||||
-f rollout_hours=6
|
||||
```
|
||||
|
||||
This does **not** apply to fresh releases cut via `npm run release:patch` — that path always tag-pushes and stamps 24. For a fresh release with a custom ramp, cut normally and then dispatch `desktop-rollout.yml` (same pattern as the instant-admit flow above, with your chosen `rollout_hours`).
|
||||
|
||||
### Releasing during an active rollout
|
||||
|
||||
If you ship N+1 while N is still ramping, N+1 starts a fresh rollout from its own publish timestamp. N's rollout effectively ends — the newer manifest supersedes it.
|
||||
|
||||
@@ -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-d87dMrQ2iY0sl0ddOapdEBGtnpbewowZh0DMg/smAb0=";
|
||||
npmDepsHash = "sha256-BEZxYUs3aqHTToxljhmDdDkw7udMDOLBwyPK08JA9w8=";
|
||||
|
||||
# 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).
|
||||
|
||||
26
package-lock.json
generated
26
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.63",
|
||||
"version": "0.1.64",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.1.63",
|
||||
"version": "0.1.64",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"workspaces": [
|
||||
@@ -38355,7 +38355,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.63",
|
||||
"version": "0.1.64",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
@@ -38481,10 +38481,10 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.63",
|
||||
"version": "0.1.64",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/server": "0.1.63",
|
||||
"@getpaseo/server": "0.1.64",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -38526,7 +38526,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.63",
|
||||
"version": "0.1.64",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -38575,7 +38575,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.63",
|
||||
"version": "0.1.64",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.0.25",
|
||||
@@ -38611,7 +38611,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.63",
|
||||
"version": "0.1.64",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@lezer/cpp": "^1.1.5",
|
||||
@@ -38637,7 +38637,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.63",
|
||||
"version": "0.1.64",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -38652,12 +38652,12 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.63",
|
||||
"version": "0.1.64",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
||||
"@getpaseo/highlight": "0.1.63",
|
||||
"@getpaseo/relay": "0.1.63",
|
||||
"@getpaseo/highlight": "0.1.64",
|
||||
"@getpaseo/relay": "0.1.64",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@mariozechner/pi-agent-core": "^0.70.2",
|
||||
"@mariozechner/pi-ai": "^0.70.2",
|
||||
@@ -39108,7 +39108,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.1.63",
|
||||
"version": "0.1.64",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.20.3",
|
||||
"@cloudflare/workers-types": "^4.20260114.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.63",
|
||||
"version": "0.1.64",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.63",
|
||||
"version": "0.1.64",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import type { AutocompleteOption } from "@/components/ui/autocomplete";
|
||||
import { useAgentCommandsQuery, type DraftCommandConfig } from "./use-agent-commands-query";
|
||||
@@ -185,6 +185,12 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
|
||||
);
|
||||
const showFileAutocomplete = activeFileMention !== null;
|
||||
const fileFilterQuery = activeFileMention?.query ?? "";
|
||||
const [debouncedFileFilterQuery, setDebouncedFileFilterQuery] = useState(fileFilterQuery);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebouncedFileFilterQuery(fileFilterQuery), 180);
|
||||
return () => clearTimeout(timer);
|
||||
}, [fileFilterQuery]);
|
||||
|
||||
const normalizedDraftConfig = useMemo(
|
||||
() => normalizeDraftCommandConfig(draftConfig),
|
||||
@@ -229,14 +235,21 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
|
||||
});
|
||||
|
||||
const fileSuggestionsQuery = useQuery({
|
||||
queryKey: ["directorySuggestions", serverId, autocompleteCwd, fileFilterQuery, true, true],
|
||||
queryKey: [
|
||||
"directorySuggestions",
|
||||
serverId,
|
||||
autocompleteCwd,
|
||||
debouncedFileFilterQuery,
|
||||
true,
|
||||
true,
|
||||
],
|
||||
queryFn: async (): Promise<DirectorySuggestionEntry[]> => {
|
||||
if (!client) {
|
||||
throw new Error("Daemon client unavailable");
|
||||
}
|
||||
const response = await client.getDirectorySuggestions({
|
||||
cwd: autocompleteCwd,
|
||||
query: fileFilterQuery,
|
||||
query: debouncedFileFilterQuery,
|
||||
limit: 50,
|
||||
includeFiles: true,
|
||||
includeDirectories: true,
|
||||
|
||||
79
packages/app/src/hooks/use-agent-commands-query.test.ts
Normal file
79
packages/app/src/hooks/use-agent-commands-query.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import React from "react";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { useAgentCommandsQuery } from "./use-agent-commands-query";
|
||||
|
||||
const { mockClient, mockRuntime } = vi.hoisted(() => {
|
||||
const hoistedClient = {
|
||||
listCommands: vi.fn(),
|
||||
};
|
||||
return {
|
||||
mockClient: hoistedClient,
|
||||
mockRuntime: {
|
||||
client: hoistedClient,
|
||||
isConnected: true,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/runtime/host-runtime", () => ({
|
||||
useHostRuntimeClient: () => mockRuntime.client,
|
||||
useHostRuntimeIsConnected: () => mockRuntime.isConnected,
|
||||
}));
|
||||
|
||||
function createQueryClient(): QueryClient {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function renderCommandsHook(input: Parameters<typeof useAgentCommandsQuery>[0]) {
|
||||
const queryClient = createQueryClient();
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(QueryClientProvider, { client: queryClient }, children);
|
||||
|
||||
return renderHook(() => useAgentCommandsQuery(input), { wrapper });
|
||||
}
|
||||
|
||||
describe("useAgentCommandsQuery", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockRuntime.client = mockClient;
|
||||
mockRuntime.isConnected = true;
|
||||
});
|
||||
|
||||
it("loads commands for a draft composer without an agent id", async () => {
|
||||
mockClient.listCommands.mockResolvedValue({
|
||||
commands: [{ name: "compact", description: "Compact context", argumentHint: "" }],
|
||||
});
|
||||
|
||||
const draftConfig = {
|
||||
provider: "opencode" as const,
|
||||
cwd: "/repo",
|
||||
modeId: "build",
|
||||
};
|
||||
|
||||
const { result } = renderCommandsHook({
|
||||
serverId: "server-1",
|
||||
agentId: "",
|
||||
draftConfig,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.commands).toEqual([
|
||||
{ name: "compact", description: "Compact context", argumentHint: "" },
|
||||
]);
|
||||
});
|
||||
|
||||
expect(mockClient.listCommands).toHaveBeenCalledWith("", { draftConfig });
|
||||
});
|
||||
});
|
||||
@@ -58,7 +58,7 @@ export function useAgentCommandsQuery({
|
||||
const response = await client.listCommands(agentId, { draftConfig });
|
||||
return response.commands as AgentSlashCommand[];
|
||||
},
|
||||
enabled: enabled && !!client && isConnected && !!agentId,
|
||||
enabled: enabled && !!client && isConnected && (!!agentId || !!draftConfig),
|
||||
staleTime: COMMANDS_STALE_TIME,
|
||||
retry: 3,
|
||||
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 5000),
|
||||
|
||||
@@ -36,6 +36,14 @@ describe("findActiveFileMention", () => {
|
||||
});
|
||||
expect(mention).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when @ at start is followed by a delimiter", () => {
|
||||
const mention = findActiveFileMention({
|
||||
text: "@ ",
|
||||
cursorIndex: 2,
|
||||
});
|
||||
expect(mention).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyFileMentionReplacement", () => {
|
||||
|
||||
@@ -24,7 +24,7 @@ export function findActiveFileMention(input: FindActiveFileMentionInput): FileMe
|
||||
for (
|
||||
let atIndex = beforeCursor.lastIndexOf("@");
|
||||
atIndex >= 0;
|
||||
atIndex = beforeCursor.lastIndexOf("@", atIndex - 1)
|
||||
atIndex = atIndex === 0 ? -1 : beforeCursor.lastIndexOf("@", atIndex - 1)
|
||||
) {
|
||||
const query = beforeCursor.slice(atIndex + 1);
|
||||
if (INVALID_MENTION_QUERY_CHARS.test(query)) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.63",
|
||||
"version": "0.1.64",
|
||||
"description": "Paseo CLI - control your AI coding agents from the command line",
|
||||
"bin": {
|
||||
"paseo": "bin/paseo"
|
||||
@@ -24,7 +24,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/server": "0.1.63",
|
||||
"@getpaseo/server": "0.1.64",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.63",
|
||||
"version": "0.1.64",
|
||||
"private": true,
|
||||
"description": "Paseo desktop app (Electron wrapper)",
|
||||
"homepage": "https://paseo.sh",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.63",
|
||||
"version": "0.1.64",
|
||||
"description": "Native module for two way audio streaming",
|
||||
"keywords": [
|
||||
"ExpoTwoWayAudio",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.63",
|
||||
"version": "0.1.64",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.63",
|
||||
"version": "0.1.64",
|
||||
"description": "Paseo relay for bridging daemon and client connections",
|
||||
"files": [
|
||||
"dist"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.63",
|
||||
"version": "0.1.64",
|
||||
"description": "Paseo backend server",
|
||||
"files": [
|
||||
"dist/server",
|
||||
@@ -58,8 +58,8 @@
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
||||
"@getpaseo/highlight": "0.1.63",
|
||||
"@getpaseo/relay": "0.1.63",
|
||||
"@getpaseo/highlight": "0.1.64",
|
||||
"@getpaseo/relay": "0.1.64",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@mariozechner/pi-agent-core": "^0.70.2",
|
||||
"@mariozechner/pi-ai": "^0.70.2",
|
||||
|
||||
@@ -107,6 +107,13 @@ const OPENCODE_MODES: AgentProviderModeDefinition[] = [
|
||||
icon: "ShieldCheck",
|
||||
colorTier: "moderate",
|
||||
},
|
||||
{
|
||||
id: "full-access",
|
||||
label: "Full Access",
|
||||
description: "Automatically approves all tool permission prompts for the session",
|
||||
icon: "ShieldAlert",
|
||||
colorTier: "dangerous",
|
||||
},
|
||||
{
|
||||
id: "plan",
|
||||
label: "Plan",
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
vi.mock("@opencode-ai/sdk/v2/client", () => ({
|
||||
createOpencodeClient: vi.fn(),
|
||||
}));
|
||||
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client";
|
||||
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import type { AgentStreamEvent } from "../agent-sdk-types.js";
|
||||
import { OpenCodeAgentClient, OpenCodeServerManager } from "./opencode-agent.js";
|
||||
|
||||
interface MockOpenCodeClientOptions {
|
||||
agents?: unknown[];
|
||||
events?: unknown[];
|
||||
}
|
||||
|
||||
function createEventStream(events: unknown[]): AsyncGenerator<never> {
|
||||
return (async function* () {
|
||||
for (const event of events) {
|
||||
yield event as never;
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
function mockServerManager(): void {
|
||||
vi.spyOn(OpenCodeServerManager, "getInstance").mockReturnValue({
|
||||
acquire: vi.fn().mockResolvedValue({
|
||||
server: { port: 1234, url: "http://127.0.0.1:1234" },
|
||||
release: vi.fn(),
|
||||
}),
|
||||
} as never);
|
||||
}
|
||||
|
||||
function mockOpenCodeClient(options: MockOpenCodeClientOptions = {}) {
|
||||
const promptAsync = vi.fn().mockResolvedValue({});
|
||||
const permissionReply = vi.fn().mockResolvedValue({});
|
||||
const questionReply = vi.fn().mockResolvedValue({});
|
||||
const questionReject = vi.fn().mockResolvedValue({});
|
||||
const appAgents = vi.fn().mockResolvedValue({ data: options.agents ?? [] });
|
||||
const events = options.events ?? [idleEvent()];
|
||||
|
||||
vi.mocked(createOpencodeClient).mockReturnValue({
|
||||
session: {
|
||||
create: vi.fn().mockResolvedValue({ data: { id: "session-1" } }),
|
||||
promptAsync,
|
||||
abort: vi.fn().mockResolvedValue({}),
|
||||
update: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
provider: {
|
||||
list: vi.fn().mockResolvedValue({ data: { connected: [], all: [] } }),
|
||||
},
|
||||
event: {
|
||||
subscribe: vi.fn().mockResolvedValue({ stream: createEventStream(events) }),
|
||||
},
|
||||
command: {
|
||||
list: vi.fn().mockResolvedValue({ data: [] }),
|
||||
},
|
||||
app: {
|
||||
agents: appAgents,
|
||||
},
|
||||
permission: {
|
||||
reply: permissionReply,
|
||||
},
|
||||
question: {
|
||||
reply: questionReply,
|
||||
reject: questionReject,
|
||||
},
|
||||
} as never);
|
||||
|
||||
return { appAgents, permissionReply, promptAsync, questionReject, questionReply };
|
||||
}
|
||||
|
||||
function idleEvent(): unknown {
|
||||
return {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: "session-1" },
|
||||
};
|
||||
}
|
||||
|
||||
function toolPermissionEvent(): unknown {
|
||||
return {
|
||||
type: "permission.asked",
|
||||
properties: {
|
||||
id: "permission-1",
|
||||
sessionID: "session-1",
|
||||
permission: "bash",
|
||||
patterns: [],
|
||||
metadata: {
|
||||
command: "npm test",
|
||||
reason: "Run verification",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function questionEvent(): unknown {
|
||||
return {
|
||||
type: "question.asked",
|
||||
properties: {
|
||||
id: "question-1",
|
||||
sessionID: "session-1",
|
||||
questions: [
|
||||
{
|
||||
question: "Which option should OpenCode use?",
|
||||
header: "Decision",
|
||||
options: [{ label: "Proceed", description: "Continue with the change" }],
|
||||
},
|
||||
],
|
||||
tool: {
|
||||
messageID: "message-1",
|
||||
callID: "call-1",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("OpenCode full-access mode", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test("includes virtual full-access mode with dynamic OpenCode agents", async () => {
|
||||
mockServerManager();
|
||||
mockOpenCodeClient({
|
||||
agents: [
|
||||
{ name: "build", mode: "primary", hidden: false, description: "Build agent" },
|
||||
{ name: "paseo-custom", mode: "primary", hidden: false, description: "Custom agent" },
|
||||
],
|
||||
});
|
||||
|
||||
const client = new OpenCodeAgentClient(createTestLogger());
|
||||
const modes = await client.listModes({ cwd: "/tmp/project", force: false });
|
||||
|
||||
expect(modes.map((mode) => mode.id)).toEqual(["build", "full-access", "plan", "paseo-custom"]);
|
||||
expect(modes.find((mode) => mode.id === "full-access")).toMatchObject({
|
||||
label: "Full Access",
|
||||
description: "Automatically approves all tool permission prompts for the session",
|
||||
});
|
||||
});
|
||||
|
||||
test("reports full-access but sends prompts through OpenCode build agent", async () => {
|
||||
mockServerManager();
|
||||
const { promptAsync } = mockOpenCodeClient();
|
||||
|
||||
const client = new OpenCodeAgentClient(createTestLogger());
|
||||
const session = await client.createSession({
|
||||
provider: "opencode",
|
||||
cwd: "/tmp/project",
|
||||
modeId: "full-access",
|
||||
});
|
||||
|
||||
expect(await session.getCurrentMode()).toBe("full-access");
|
||||
|
||||
await session.run("Implement the change");
|
||||
|
||||
expect(promptAsync).toHaveBeenCalledTimes(1);
|
||||
expect(promptAsync).toHaveBeenCalledWith(expect.objectContaining({ agent: "build" }));
|
||||
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("auto-approves tool permissions in full-access without surfacing them", async () => {
|
||||
mockServerManager();
|
||||
const { permissionReply } = mockOpenCodeClient({
|
||||
events: [toolPermissionEvent(), idleEvent()],
|
||||
});
|
||||
const receivedEvents: AgentStreamEvent[] = [];
|
||||
|
||||
const client = new OpenCodeAgentClient(createTestLogger());
|
||||
const session = await client.createSession({
|
||||
provider: "opencode",
|
||||
cwd: "/tmp/project",
|
||||
modeId: "full-access",
|
||||
});
|
||||
session.subscribe((event) => receivedEvents.push(event));
|
||||
|
||||
await session.run("Run verification");
|
||||
|
||||
expect(permissionReply).toHaveBeenCalledTimes(1);
|
||||
expect(permissionReply).toHaveBeenCalledWith({
|
||||
requestID: "permission-1",
|
||||
directory: "/tmp/project",
|
||||
reply: "once",
|
||||
});
|
||||
expect(receivedEvents.filter((event) => event.type === "permission_requested")).toEqual([]);
|
||||
expect(session.getPendingPermissions()).toEqual([]);
|
||||
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("keeps questions separate from full-access tool auto-approval", async () => {
|
||||
mockServerManager();
|
||||
const { permissionReply, questionReply } = mockOpenCodeClient({
|
||||
events: [questionEvent(), idleEvent()],
|
||||
});
|
||||
const receivedEvents: AgentStreamEvent[] = [];
|
||||
|
||||
const client = new OpenCodeAgentClient(createTestLogger());
|
||||
const session = await client.createSession({
|
||||
provider: "opencode",
|
||||
cwd: "/tmp/project",
|
||||
modeId: "full-access",
|
||||
});
|
||||
session.subscribe((event) => receivedEvents.push(event));
|
||||
|
||||
await session.run("Ask a question");
|
||||
|
||||
expect(receivedEvents.filter((event) => event.type === "permission_requested")).toEqual([
|
||||
expect.objectContaining({
|
||||
request: expect.objectContaining({
|
||||
id: "question-1",
|
||||
kind: "question",
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
expect(session.getPendingPermissions()).toHaveLength(1);
|
||||
|
||||
await session.respondToPermission("question-1", {
|
||||
behavior: "allow",
|
||||
updatedInput: { answers: { Decision: "Proceed" } },
|
||||
});
|
||||
|
||||
expect(questionReply).toHaveBeenCalledTimes(1);
|
||||
expect(questionReply).toHaveBeenCalledWith({
|
||||
requestID: "question-1",
|
||||
directory: "/tmp/project",
|
||||
answers: [["Proceed"]],
|
||||
});
|
||||
expect(permissionReply).not.toHaveBeenCalled();
|
||||
expect(session.getPendingPermissions()).toEqual([]);
|
||||
|
||||
await session.close();
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,100 @@ describe("OpenCodeAgentSession slash command timeout handling", () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test("lists only OpenCode built-in slash commands Paseo can execute", async () => {
|
||||
vi.mocked(createOpencodeClient).mockReturnValue({
|
||||
session: {
|
||||
create: vi.fn().mockResolvedValue({ data: { id: "session-1" } }),
|
||||
},
|
||||
provider: {
|
||||
list: vi.fn().mockResolvedValue({
|
||||
data: {
|
||||
connected: ["openai"],
|
||||
all: [{ id: "openai", name: "OpenAI", models: {} }],
|
||||
},
|
||||
}),
|
||||
},
|
||||
command: {
|
||||
list: vi.fn().mockResolvedValue({ data: [] }),
|
||||
},
|
||||
app: {
|
||||
agents: vi.fn().mockResolvedValue({ data: [] }),
|
||||
},
|
||||
} as never);
|
||||
|
||||
vi.spyOn(OpenCodeServerManager, "getInstance").mockReturnValue({
|
||||
acquire: vi.fn().mockResolvedValue({
|
||||
server: { port: 1234, url: "http://127.0.0.1:1234" },
|
||||
release: vi.fn(),
|
||||
}),
|
||||
} as never);
|
||||
|
||||
const client = new OpenCodeAgentClient(createTestLogger());
|
||||
const session = await client.createSession({ provider: "opencode", cwd: "/tmp" });
|
||||
|
||||
await expect(session.listCommands?.()).resolves.toEqual(
|
||||
expect.arrayContaining([
|
||||
{ name: "compact", description: "Compact the current session", argumentHint: "" },
|
||||
]),
|
||||
);
|
||||
await expect(session.listCommands?.()).resolves.not.toEqual(
|
||||
expect.arrayContaining([
|
||||
{ name: "models", description: expect.any(String), argumentHint: "" },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test("executes compact through the OpenCode summarize endpoint", async () => {
|
||||
const command = vi.fn();
|
||||
const summarize = vi.fn().mockResolvedValue({ data: {} });
|
||||
|
||||
vi.mocked(createOpencodeClient).mockReturnValue({
|
||||
session: {
|
||||
create: vi.fn().mockResolvedValue({ data: { id: "session-1" } }),
|
||||
command,
|
||||
summarize,
|
||||
},
|
||||
provider: {
|
||||
list: vi.fn().mockResolvedValue({
|
||||
data: {
|
||||
connected: ["openai"],
|
||||
all: [{ id: "openai", name: "OpenAI", models: {} }],
|
||||
},
|
||||
}),
|
||||
},
|
||||
event: {
|
||||
subscribe: vi.fn().mockResolvedValue({
|
||||
stream: (async function* () {})(),
|
||||
}),
|
||||
},
|
||||
command: {
|
||||
list: vi.fn().mockResolvedValue({ data: [] }),
|
||||
},
|
||||
app: {
|
||||
agents: vi.fn().mockResolvedValue({ data: [] }),
|
||||
},
|
||||
} as never);
|
||||
|
||||
vi.spyOn(OpenCodeServerManager, "getInstance").mockReturnValue({
|
||||
acquire: vi.fn().mockResolvedValue({
|
||||
server: { port: 1234, url: "http://127.0.0.1:1234" },
|
||||
release: vi.fn(),
|
||||
}),
|
||||
} as never);
|
||||
|
||||
const client = new OpenCodeAgentClient(createTestLogger());
|
||||
const session = await client.createSession({ provider: "opencode", cwd: "/tmp" });
|
||||
|
||||
await expect(session.run("/compact")).resolves.toMatchObject({
|
||||
sessionId: "session-1",
|
||||
finalText: "",
|
||||
timeline: [],
|
||||
usage: undefined,
|
||||
});
|
||||
expect(summarize).toHaveBeenCalledWith({ sessionID: "session-1", directory: "/tmp" });
|
||||
expect(command).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("waits for SSE completion when slash commands hit a header timeout", async () => {
|
||||
const idleEventGate = createDeferred<void>();
|
||||
|
||||
|
||||
@@ -67,9 +67,12 @@ const OPENCODE_CAPABILITIES: AgentCapabilityFlags = {
|
||||
supportsToolInvocations: true,
|
||||
};
|
||||
|
||||
const OPENCODE_BUILD_MODE_ID = "build";
|
||||
const OPENCODE_FULL_ACCESS_MODE_ID = "full-access";
|
||||
|
||||
const DEFAULT_MODES: AgentMode[] = [
|
||||
{
|
||||
id: "build",
|
||||
id: OPENCODE_BUILD_MODE_ID,
|
||||
label: "Build",
|
||||
description: "Allows edits and tool execution for implementation work",
|
||||
},
|
||||
@@ -78,6 +81,11 @@ const DEFAULT_MODES: AgentMode[] = [
|
||||
label: "Plan",
|
||||
description: "Read-only planning mode that avoids file edits",
|
||||
},
|
||||
{
|
||||
id: OPENCODE_FULL_ACCESS_MODE_ID,
|
||||
label: "Full Access",
|
||||
description: "Automatically approves all tool permission prompts for the session",
|
||||
},
|
||||
];
|
||||
|
||||
type OpenCodeAgentConfig = AgentSessionConfig & { provider: "opencode" };
|
||||
@@ -99,6 +107,10 @@ type OpenCodeMcpConfig =
|
||||
|
||||
const MCP_ALREADY_PRESENT_ERROR_TOKENS = ["already", "exists", "connected"] as const;
|
||||
const OPENCODE_PROVIDER_LIST_TIMEOUT_MS = 30_000;
|
||||
const OPENCODE_HANDLED_BUILTIN_SLASH_COMMANDS: AgentSlashCommand[] = [
|
||||
{ name: "compact", description: "Compact the current session", argumentHint: "" },
|
||||
{ name: "summarize", description: "Compact the current session", argumentHint: "" },
|
||||
];
|
||||
const OPENCODE_FATAL_RETRY_MESSAGE_TOKENS = [
|
||||
"insufficient balance",
|
||||
"no resource package",
|
||||
@@ -400,11 +412,26 @@ function resolvePartDedupeKey(
|
||||
function normalizeOpenCodeModeId(modeId: string | null | undefined): string {
|
||||
const trimmed = typeof modeId === "string" ? modeId.trim() : "";
|
||||
if (!trimmed || trimmed === "default") {
|
||||
return "build";
|
||||
return OPENCODE_BUILD_MODE_ID;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function resolveOpenCodeRuntimeAgentId(modeId: string | null | undefined): string {
|
||||
const normalizedModeId = normalizeOpenCodeModeId(modeId);
|
||||
return normalizedModeId === OPENCODE_FULL_ACCESS_MODE_ID
|
||||
? OPENCODE_BUILD_MODE_ID
|
||||
: normalizedModeId;
|
||||
}
|
||||
|
||||
function mergeOpenCodeModes(discoveredModes: AgentMode[]): AgentMode[] {
|
||||
const modesById = new Map(DEFAULT_MODES.map((mode) => [mode.id, mode]));
|
||||
for (const mode of discoveredModes) {
|
||||
modesById.set(mode.id, mode);
|
||||
}
|
||||
return sortOpenCodeModes(Array.from(modesById.values()));
|
||||
}
|
||||
|
||||
function sortOpenCodeModes(modes: AgentMode[]): AgentMode[] {
|
||||
const order = new Map(DEFAULT_MODES.map((mode, index) => [mode.id, index]));
|
||||
return [...modes].sort((left, right) => {
|
||||
@@ -1172,7 +1199,7 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
: DEFAULT_MODES.find((mode) => mode.id === agent.name)?.description,
|
||||
}));
|
||||
|
||||
return discovered.length > 0 ? sortOpenCodeModes(discovered) : DEFAULT_MODES;
|
||||
return mergeOpenCodeModes(discovered);
|
||||
} finally {
|
||||
acquisition.release();
|
||||
}
|
||||
@@ -2027,7 +2054,7 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
const model = this.parseModel(this.config.model);
|
||||
const thinkingOptionId = this.config.thinkingOptionId;
|
||||
const effectiveVariant = thinkingOptionId ?? undefined;
|
||||
const effectiveMode = normalizeOpenCodeModeId(this.currentMode);
|
||||
const effectiveMode = resolveOpenCodeRuntimeAgentId(this.currentMode);
|
||||
|
||||
const turnId = this.createTurnId();
|
||||
this.activeForegroundTurnId = turnId;
|
||||
@@ -2035,6 +2062,44 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
|
||||
const slashCommand = await this.resolveSlashCommandInvocation(prompt);
|
||||
if (slashCommand) {
|
||||
if (slashCommand.commandName === "compact" || slashCommand.commandName === "summarize") {
|
||||
void this.client.session
|
||||
.summarize({
|
||||
sessionID: this.sessionId,
|
||||
directory: this.config.cwd,
|
||||
...(model ? { providerID: model.providerID, modelID: model.modelID } : {}),
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.error) {
|
||||
this.finishForegroundTurn(
|
||||
{
|
||||
type: "turn_failed",
|
||||
provider: "opencode",
|
||||
error: toDiagnosticErrorMessage(response.error),
|
||||
},
|
||||
turnId,
|
||||
);
|
||||
} else {
|
||||
this.finishForegroundTurn(
|
||||
{ type: "turn_completed", provider: "opencode", usage: undefined },
|
||||
turnId,
|
||||
);
|
||||
}
|
||||
return;
|
||||
})
|
||||
.catch((error) => {
|
||||
this.finishForegroundTurn(
|
||||
{
|
||||
type: "turn_failed",
|
||||
provider: "opencode",
|
||||
error: toDiagnosticErrorMessage(error),
|
||||
},
|
||||
turnId,
|
||||
);
|
||||
});
|
||||
return { turnId };
|
||||
}
|
||||
|
||||
// command() blocks until the server finishes processing. OpenCode's SSE
|
||||
// endpoint does NOT replay past events, so if the command completes before
|
||||
// our SSE reader connects, we miss `session.idle` and the turn hangs.
|
||||
@@ -2163,7 +2228,7 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
break;
|
||||
}
|
||||
|
||||
const translated = this.translateEvent(event);
|
||||
const translated = await this.translateEvent(event);
|
||||
for (const e of translated) {
|
||||
if (this.activeForegroundTurnId !== turnId) {
|
||||
return;
|
||||
@@ -2358,23 +2423,20 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
const response = await this.client.app.agents({
|
||||
directory: this.config.cwd,
|
||||
});
|
||||
const agents = response.error || !response.data ? [] : response.data;
|
||||
|
||||
const discoveredModes =
|
||||
response.error || !response.data
|
||||
? []
|
||||
: response.data
|
||||
.filter((agent) => agent.mode === "primary" && agent.hidden !== true)
|
||||
.map((agent) => ({
|
||||
id: agent.name,
|
||||
label: agent.name.charAt(0).toUpperCase() + agent.name.slice(1),
|
||||
description:
|
||||
typeof agent.description === "string" && agent.description.trim().length > 0
|
||||
? agent.description.trim()
|
||||
: DEFAULT_MODES.find((mode) => mode.id === agent.name)?.description,
|
||||
}));
|
||||
const discoveredModes = agents
|
||||
.filter((agent) => agent.mode === "primary" && agent.hidden !== true)
|
||||
.map((agent) => ({
|
||||
id: agent.name,
|
||||
label: agent.name.charAt(0).toUpperCase() + agent.name.slice(1),
|
||||
description:
|
||||
typeof agent.description === "string" && agent.description.trim().length > 0
|
||||
? agent.description.trim()
|
||||
: DEFAULT_MODES.find((mode) => mode.id === agent.name)?.description,
|
||||
}));
|
||||
|
||||
this.availableModesCache =
|
||||
discoveredModes.length > 0 ? sortOpenCodeModes(discoveredModes) : DEFAULT_MODES;
|
||||
this.availableModesCache = mergeOpenCodeModes(discoveredModes);
|
||||
return this.availableModesCache;
|
||||
}
|
||||
|
||||
@@ -2386,14 +2448,22 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
const result = await this.client.command.list({
|
||||
directory: this.config.cwd,
|
||||
});
|
||||
|
||||
const commandsByName = new Map(
|
||||
OPENCODE_HANDLED_BUILTIN_SLASH_COMMANDS.map((command) => [command.name, command]),
|
||||
);
|
||||
if (result.error || !result.data) {
|
||||
return [];
|
||||
return Array.from(commandsByName.values());
|
||||
}
|
||||
return result.data.map((cmd) => ({
|
||||
name: cmd.name,
|
||||
description: cmd.description ?? "",
|
||||
argumentHint: cmd.hints?.length ? cmd.hints.join(" ") : "",
|
||||
}));
|
||||
|
||||
for (const cmd of result.data) {
|
||||
commandsByName.set(cmd.name, {
|
||||
name: cmd.name,
|
||||
description: cmd.description ?? "",
|
||||
argumentHint: cmd.hints?.length ? cmd.hints.join(" ") : "",
|
||||
});
|
||||
}
|
||||
return Array.from(commandsByName.values());
|
||||
}
|
||||
|
||||
async setMode(modeId: string): Promise<void> {
|
||||
@@ -2599,7 +2669,7 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
);
|
||||
}
|
||||
|
||||
private translateEvent(event: OpenCodeEvent): AgentStreamEvent[] {
|
||||
private async translateEvent(event: OpenCodeEvent): Promise<AgentStreamEvent[]> {
|
||||
const translated = translateOpenCodeEvent(event, {
|
||||
sessionId: this.sessionId,
|
||||
messageRoles: this.messageRoles,
|
||||
@@ -2616,8 +2686,14 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
},
|
||||
});
|
||||
|
||||
const events: AgentStreamEvent[] = [];
|
||||
|
||||
for (const translatedEvent of translated) {
|
||||
if (translatedEvent.type === "permission_requested") {
|
||||
const autoApproved = await this.tryAutoApproveToolPermission(translatedEvent.request);
|
||||
if (autoApproved) {
|
||||
continue;
|
||||
}
|
||||
this.pendingPermissions.set(translatedEvent.request.id, translatedEvent.request);
|
||||
}
|
||||
if (translatedEvent.type === "turn_completed") {
|
||||
@@ -2628,9 +2704,31 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
this.accumulatedUsage =
|
||||
contextWindowMaxTokens !== undefined ? { contextWindowMaxTokens } : {};
|
||||
}
|
||||
events.push(translatedEvent);
|
||||
}
|
||||
|
||||
return translated;
|
||||
return events;
|
||||
}
|
||||
|
||||
private async tryAutoApproveToolPermission(request: AgentPermissionRequest): Promise<boolean> {
|
||||
if (this.currentMode !== OPENCODE_FULL_ACCESS_MODE_ID || request.kind !== "tool") {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.client.permission.reply({
|
||||
requestID: request.id,
|
||||
directory: this.config.cwd,
|
||||
reply: "once",
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
{ err: error, requestId: request.id },
|
||||
"Failed to auto-approve OpenCode tool permission",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private resolveSelectedModelContextWindowMaxTokens(): number | undefined {
|
||||
|
||||
@@ -269,6 +269,7 @@ export async function createPaseoDaemon(
|
||||
daemonPort: () => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null),
|
||||
resolveWorkspaceDirectory: async (workspaceId) =>
|
||||
(await workspaceRegistry?.get(workspaceId))?.cwd ?? null,
|
||||
logger,
|
||||
}),
|
||||
});
|
||||
const handleBranchChange = createBranchChangeRouteHandler({
|
||||
|
||||
@@ -11,6 +11,9 @@ import {
|
||||
import { WorkspaceScriptPayloadSchema } from "../shared/messages.js";
|
||||
import type { ScriptHealthState } from "./script-health-monitor.js";
|
||||
import { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
|
||||
import { readPaseoConfig } from "../utils/worktree.js";
|
||||
import type { PaseoConfig } from "../utils/paseo-config-schema.js";
|
||||
import { createTestLogger } from "../test-utils/test-logger.js";
|
||||
|
||||
function createWorkspaceRepo(options?: {
|
||||
branchName?: string;
|
||||
@@ -41,13 +44,21 @@ function createWorkspaceRepo(options?: {
|
||||
function buildPayloads(input: {
|
||||
workspaceId: string;
|
||||
workspaceDirectory: string;
|
||||
paseoConfig?: PaseoConfig | null;
|
||||
routeStore: ScriptRouteStore;
|
||||
runtimeStore: WorkspaceScriptRuntimeStore;
|
||||
daemonPort: number | null;
|
||||
gitMetadata?: { projectSlug: string; currentBranch: string | null };
|
||||
resolveHealth?: (hostname: string) => ScriptHealthState | null;
|
||||
}) {
|
||||
return buildWorkspaceScriptPayloads(input);
|
||||
const paseoConfig =
|
||||
input.paseoConfig !== undefined ? input.paseoConfig : loadConfig(input.workspaceDirectory);
|
||||
return buildWorkspaceScriptPayloads({ ...input, paseoConfig });
|
||||
}
|
||||
|
||||
function loadConfig(repoRoot: string): PaseoConfig | null {
|
||||
const result = readPaseoConfig(repoRoot);
|
||||
return result.ok ? result.config : null;
|
||||
}
|
||||
|
||||
describe("script-status-projection", () => {
|
||||
@@ -369,6 +380,67 @@ describe("script-status-projection", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("readPaseoConfig fails with configPath and error when paseo.json is malformed", () => {
|
||||
const workspace = createWorkspaceRepo();
|
||||
const configPath = path.join(workspace.repoDir, "paseo.json");
|
||||
writeFileSync(
|
||||
configPath,
|
||||
'{\n<<<<<<< HEAD\n "scripts": {}\n=======\n "scripts": {}\n>>>>>>> origin/main\n}\n',
|
||||
);
|
||||
|
||||
try {
|
||||
const result = readPaseoConfig(workspace.repoDir);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("unreachable");
|
||||
expect(result.configPath).toBe(configPath);
|
||||
expect(result.error).toBeInstanceOf(SyntaxError);
|
||||
} finally {
|
||||
workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("buildWorkspaceScriptPayloads given paseoConfig=null still surfaces orphaned runtime scripts", () => {
|
||||
const workspaceId = "workspace-null-config";
|
||||
const workspace = createWorkspaceRepo();
|
||||
const routeStore = new ScriptRouteStore();
|
||||
const runtimeStore = new WorkspaceScriptRuntimeStore();
|
||||
runtimeStore.set({
|
||||
workspaceId,
|
||||
scriptName: "typecheck",
|
||||
type: "script",
|
||||
lifecycle: "running",
|
||||
terminalId: "term-typecheck",
|
||||
exitCode: null,
|
||||
});
|
||||
|
||||
try {
|
||||
expect(
|
||||
buildPayloads({
|
||||
workspaceId,
|
||||
workspaceDirectory: workspace.repoDir,
|
||||
paseoConfig: null,
|
||||
routeStore,
|
||||
runtimeStore,
|
||||
daemonPort: 6767,
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
scriptName: "typecheck",
|
||||
type: "script",
|
||||
hostname: "typecheck",
|
||||
port: null,
|
||||
proxyUrl: null,
|
||||
lifecycle: "running",
|
||||
health: null,
|
||||
exitCode: null,
|
||||
terminalId: "term-typecheck",
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("createScriptStatusEmitter overlays health onto the projected workspace script list", async () => {
|
||||
const workspaceId = "workspace-emitter";
|
||||
const workspace = createWorkspaceRepo({
|
||||
@@ -405,6 +477,7 @@ describe("script-status-projection", () => {
|
||||
daemonPort: 6767,
|
||||
resolveWorkspaceDirectory: async (requestedWorkspaceId) =>
|
||||
requestedWorkspaceId === "workspace-emitter" ? workspace.repoDir : null,
|
||||
logger: createTestLogger(),
|
||||
});
|
||||
|
||||
try {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import type { Logger } from "pino";
|
||||
import type {
|
||||
ScriptStatusUpdateMessage,
|
||||
SessionOutboundMessage,
|
||||
WorkspaceScriptPayload,
|
||||
} from "../shared/messages.js";
|
||||
import type { PaseoConfig } from "../utils/paseo-config-schema.js";
|
||||
import { buildScriptHostname } from "../utils/script-hostname.js";
|
||||
import { getScriptConfigs, isServiceScript } from "../utils/worktree.js";
|
||||
import { getScriptConfigs, isServiceScript, readPaseoConfig } from "../utils/worktree.js";
|
||||
import { deriveProjectSlug } from "./workspace-git-metadata.js";
|
||||
import type { ScriptHealthEntry, ScriptHealthState } from "./script-health-monitor.js";
|
||||
import type { ScriptRouteStore } from "./script-proxy.js";
|
||||
@@ -17,6 +19,7 @@ interface SessionEmitter {
|
||||
interface BuildWorkspaceScriptPayloadsOptions {
|
||||
workspaceId: string;
|
||||
workspaceDirectory: string;
|
||||
paseoConfig: PaseoConfig | null;
|
||||
routeStore: ScriptRouteStore;
|
||||
runtimeStore: WorkspaceScriptRuntimeStore;
|
||||
daemonPort: number | null;
|
||||
@@ -27,6 +30,21 @@ interface BuildWorkspaceScriptPayloadsOptions {
|
||||
resolveHealth?: (hostname: string) => ScriptHealthState | null;
|
||||
}
|
||||
|
||||
export function readPaseoConfigForProjection(
|
||||
workspaceDirectory: string,
|
||||
logger: Logger,
|
||||
): PaseoConfig | null {
|
||||
const result = readPaseoConfig(workspaceDirectory);
|
||||
if (result.ok) {
|
||||
return result.config;
|
||||
}
|
||||
logger.warn(
|
||||
{ configPath: result.configPath, workspaceDirectory, err: result.error },
|
||||
"Failed to parse paseo.json; treating workspace as having no scripts",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveDaemonPort(daemonPort: number | null | (() => number | null)): number | null {
|
||||
if (typeof daemonPort === "function") {
|
||||
return daemonPort();
|
||||
@@ -136,7 +154,7 @@ export function buildWorkspaceScriptPayloads(
|
||||
const workspaceDirectory = options.workspaceDirectory;
|
||||
const projectSlug = options.gitMetadata?.projectSlug ?? deriveProjectSlug(workspaceDirectory);
|
||||
const branchName = options.gitMetadata?.currentBranch ?? null;
|
||||
const scriptConfigs = getScriptConfigs(workspaceDirectory);
|
||||
const scriptConfigs = getScriptConfigs(options.paseoConfig);
|
||||
const runtimeEntries = new Map(
|
||||
options.runtimeStore
|
||||
.listForWorkspace(workspaceId)
|
||||
@@ -193,12 +211,14 @@ export function createScriptStatusEmitter({
|
||||
runtimeStore,
|
||||
daemonPort,
|
||||
resolveWorkspaceDirectory,
|
||||
logger,
|
||||
}: {
|
||||
sessions: () => SessionEmitter[];
|
||||
routeStore: ScriptRouteStore;
|
||||
runtimeStore: WorkspaceScriptRuntimeStore;
|
||||
daemonPort: number | null | (() => number | null);
|
||||
resolveWorkspaceDirectory: (workspaceId: string) => string | null | Promise<string | null>;
|
||||
logger: Logger;
|
||||
}): (workspaceId: string, scripts: ScriptHealthEntry[]) => void {
|
||||
return (workspaceId, scripts) => {
|
||||
void (async () => {
|
||||
@@ -215,6 +235,7 @@ export function createScriptStatusEmitter({
|
||||
const projected = buildWorkspaceScriptPayloads({
|
||||
workspaceId,
|
||||
workspaceDirectory,
|
||||
paseoConfig: readPaseoConfigForProjection(workspaceDirectory, logger),
|
||||
routeStore,
|
||||
runtimeStore,
|
||||
daemonPort: resolvedDaemonPort,
|
||||
|
||||
@@ -77,7 +77,10 @@ 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";
|
||||
import { buildWorkspaceScriptPayloads } from "./script-status-projection.js";
|
||||
import {
|
||||
buildWorkspaceScriptPayloads,
|
||||
readPaseoConfigForProjection,
|
||||
} from "./script-status-projection.js";
|
||||
import { deriveProjectSlug } from "./workspace-git-metadata.js";
|
||||
import type { ScriptHealthState } from "./script-health-monitor.js";
|
||||
import { spawnWorkspaceScript } from "./worktree-bootstrap.js";
|
||||
@@ -6069,6 +6072,7 @@ export class Session {
|
||||
? buildWorkspaceScriptPayloads({
|
||||
workspaceId: workspace.workspaceId,
|
||||
workspaceDirectory: workspace.cwd,
|
||||
paseoConfig: readPaseoConfigForProjection(workspace.cwd, this.sessionLogger),
|
||||
routeStore: this.scriptRouteStore,
|
||||
runtimeStore: this.scriptRuntimeStore,
|
||||
daemonPort: this.getDaemonTcpPort?.() ?? null,
|
||||
@@ -7133,6 +7137,7 @@ export class Session {
|
||||
return buildWorkspaceScriptPayloads({
|
||||
workspaceId,
|
||||
workspaceDirectory,
|
||||
paseoConfig: readPaseoConfigForProjection(workspaceDirectory, this.sessionLogger),
|
||||
routeStore: this.scriptRouteStore,
|
||||
runtimeStore: this.scriptRuntimeStore,
|
||||
daemonPort: this.getDaemonTcpPort?.() ?? null,
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
getWorktreeTerminalSpecs,
|
||||
isServiceScript,
|
||||
processCarriageReturns,
|
||||
readPaseoConfig,
|
||||
resolveWorktreeRuntimeEnv,
|
||||
runWorktreeSetupCommands,
|
||||
WorktreeSetupError,
|
||||
@@ -832,7 +833,13 @@ export async function spawnWorkspaceScript(
|
||||
logger,
|
||||
onLifecycleChanged,
|
||||
} = options;
|
||||
const scriptConfigs = getScriptConfigs(repoRoot);
|
||||
const configResult = readPaseoConfig(repoRoot);
|
||||
if (!configResult.ok) {
|
||||
throw new Error(`Failed to parse ${configResult.configPath}`, {
|
||||
cause: configResult.error,
|
||||
});
|
||||
}
|
||||
const scriptConfigs = getScriptConfigs(configResult.config);
|
||||
const config = scriptConfigs.get(scriptName);
|
||||
if (!config) {
|
||||
throw new Error(`Script '${scriptName}' is not configured in paseo.json`);
|
||||
|
||||
@@ -265,4 +265,34 @@ describe("searchWorkspaceEntries", () => {
|
||||
});
|
||||
expect(results.some((entry) => entry.path.startsWith("node_modules/"))).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores common build/cache directories so large generated trees do not exhaust scan budget", async () => {
|
||||
mkdirSync(path.join(workspaceDir, "packages", "app", "src"), { recursive: true });
|
||||
writeFileSync(path.join(workspaceDir, "packages", "app", "src", "needle.ts"), "");
|
||||
|
||||
const heavyDirs = ["dist", "build", "target", "out", "coverage", "vendor", "__pycache__"];
|
||||
for (const heavyDir of heavyDirs) {
|
||||
for (let index = 0; index < 30; index += 1) {
|
||||
mkdirSync(path.join(workspaceDir, heavyDir, `bundle-${index}`), { recursive: true });
|
||||
writeFileSync(path.join(workspaceDir, heavyDir, `bundle-${index}`, "needle.ts"), "");
|
||||
}
|
||||
}
|
||||
|
||||
const results = await searchWorkspaceEntries({
|
||||
cwd: workspaceDir,
|
||||
query: "needle.ts",
|
||||
limit: 20,
|
||||
includeFiles: true,
|
||||
includeDirectories: true,
|
||||
maxEntriesScanned: 80,
|
||||
});
|
||||
|
||||
expect(results).toContainEqual({
|
||||
path: "packages/app/src/needle.ts",
|
||||
kind: "file",
|
||||
});
|
||||
for (const heavyDir of heavyDirs) {
|
||||
expect(results.some((entry) => entry.path.startsWith(`${heavyDir}/`))).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -73,7 +73,16 @@ const directoryListCache = new Map<string, DirectoryListCacheEntry>();
|
||||
const workspaceEntryListCache = new Map<string, WorkspaceEntryListCacheEntry>();
|
||||
const NO_SEGMENT_INDEX = Number.MAX_SAFE_INTEGER;
|
||||
const NO_MATCH_OFFSET = Number.MAX_SAFE_INTEGER;
|
||||
const WORKSPACE_IGNORED_DIRECTORY_NAMES = new Set(["node_modules"]);
|
||||
const WORKSPACE_IGNORED_DIRECTORY_NAMES = new Set([
|
||||
"node_modules",
|
||||
"dist",
|
||||
"build",
|
||||
"target",
|
||||
"out",
|
||||
"coverage",
|
||||
"vendor",
|
||||
"__pycache__",
|
||||
]);
|
||||
|
||||
export async function searchHomeDirectories(
|
||||
options: SearchHomeDirectoriesOptions,
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
isServiceScript,
|
||||
isPaseoOwnedWorktreeCwd,
|
||||
listPaseoWorktrees,
|
||||
readPaseoConfig,
|
||||
resolveWorktreeRuntimeEnv,
|
||||
type WorktreeSetupCommandProgressEvent,
|
||||
runWorktreeSetupCommands,
|
||||
@@ -18,6 +19,12 @@ import {
|
||||
type CreateWorktreeOptions,
|
||||
type WorktreeConfig,
|
||||
} from "./worktree";
|
||||
import type { PaseoConfig } from "./paseo-config-schema.js";
|
||||
|
||||
function loadConfigForTest(repoRoot: string): PaseoConfig | null {
|
||||
const result = readPaseoConfig(repoRoot);
|
||||
return result.ok ? result.config : null;
|
||||
}
|
||||
import { getPaseoWorktreeMetadataPath } from "./worktree-metadata.js";
|
||||
import { execSync } from "child_process";
|
||||
import {
|
||||
@@ -707,7 +714,7 @@ describe.skipIf(process.platform === "win32")("createWorktree", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const scriptConfigs = getScriptConfigs(repoDir);
|
||||
const scriptConfigs = getScriptConfigs(loadConfigForTest(repoDir));
|
||||
const typecheck = scriptConfigs.get("typecheck");
|
||||
|
||||
expect(typecheck).toEqual({
|
||||
@@ -731,7 +738,7 @@ describe.skipIf(process.platform === "win32")("createWorktree", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const scriptConfigs = getScriptConfigs(repoDir);
|
||||
const scriptConfigs = getScriptConfigs(loadConfigForTest(repoDir));
|
||||
const server = scriptConfigs.get("server");
|
||||
|
||||
expect(server).toEqual({
|
||||
@@ -771,7 +778,7 @@ describe.skipIf(process.platform === "win32")("createWorktree", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(getScriptConfigs(repoDir)).toEqual(
|
||||
expect(getScriptConfigs(loadConfigForTest(repoDir))).toEqual(
|
||||
new Map([
|
||||
["valid", { command: "npm run valid" }],
|
||||
["invalidType", { command: "npm run worker" }],
|
||||
|
||||
@@ -8,7 +8,7 @@ import { createHash } from "node:crypto";
|
||||
import * as pty from "node-pty";
|
||||
import stripAnsi from "strip-ansi";
|
||||
import { buildStringCommandShellInvocation } from "./string-command-shell.js";
|
||||
import { readPaseoConfigJson } from "./paseo-config-file.js";
|
||||
import { readPaseoConfigJson, resolvePaseoConfigPath } from "./paseo-config-file.js";
|
||||
export {
|
||||
PaseoConfigRawSchema,
|
||||
PaseoLifecycleCommandRawSchema,
|
||||
@@ -197,31 +197,35 @@ export class UnknownBranchError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export function readPaseoConfig(repoRoot: string): PaseoConfig | null {
|
||||
export type ReadPaseoConfigResult =
|
||||
| { ok: true; config: PaseoConfig | null }
|
||||
| { ok: false; configPath: string; error: unknown };
|
||||
|
||||
export function readPaseoConfig(repoRoot: string): ReadPaseoConfigResult {
|
||||
try {
|
||||
const json = readPaseoConfigJson(repoRoot);
|
||||
if (json === null) {
|
||||
return null;
|
||||
return { ok: true, config: null };
|
||||
}
|
||||
return PaseoConfigSchema.parse(json);
|
||||
} catch {
|
||||
throw new Error(`Failed to parse paseo.json`);
|
||||
return { ok: true, config: PaseoConfigSchema.parse(json) };
|
||||
} catch (error) {
|
||||
return { ok: false, configPath: resolvePaseoConfigPath(repoRoot), error };
|
||||
}
|
||||
}
|
||||
|
||||
export function getWorktreeSetupCommands(repoRoot: string): string[] {
|
||||
const config = readPaseoConfig(repoRoot);
|
||||
return config?.worktree?.setup ?? [];
|
||||
const result = readPaseoConfig(repoRoot);
|
||||
return result.ok ? (result.config?.worktree?.setup ?? []) : [];
|
||||
}
|
||||
|
||||
export function getWorktreeTeardownCommands(repoRoot: string): string[] {
|
||||
const config = readPaseoConfig(repoRoot);
|
||||
return config?.worktree?.teardown ?? [];
|
||||
const result = readPaseoConfig(repoRoot);
|
||||
return result.ok ? (result.config?.worktree?.teardown ?? []) : [];
|
||||
}
|
||||
|
||||
export function getWorktreeTerminalSpecs(repoRoot: string): WorktreeTerminalConfig[] {
|
||||
const config = readPaseoConfig(repoRoot);
|
||||
const terminals = config?.worktree?.terminals;
|
||||
const result = readPaseoConfig(repoRoot);
|
||||
const terminals = result.ok ? result.config?.worktree?.terminals : undefined;
|
||||
if (!Array.isArray(terminals) || terminals.length === 0) {
|
||||
return [];
|
||||
}
|
||||
@@ -254,8 +258,7 @@ export function getWorktreeTerminalSpecs(repoRoot: string): WorktreeTerminalConf
|
||||
return specs;
|
||||
}
|
||||
|
||||
export function getScriptConfigs(repoRoot: string): Map<string, ScriptConfig> {
|
||||
const config = readPaseoConfig(repoRoot);
|
||||
export function getScriptConfigs(config: PaseoConfig | null): Map<string, ScriptConfig> {
|
||||
const scripts = config?.scripts;
|
||||
if (!scripts || typeof scripts !== "object") {
|
||||
return new Map();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.1.63",
|
||||
"version": "0.1.64",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
Reference in New Issue
Block a user