Compare commits

..

10 Commits

Author SHA1 Message Date
Mohamed Boudra
283390e6d5 chore(release): cut 0.1.64 2026-04-28 20:09:12 +07:00
Mohamed Boudra
dcb04a4802 docs: 0.1.64 changelog and rollout docs
Add 0.1.64 release notes and document the instant-admit release path
(release:patch + immediate desktop-rollout.yml dispatch) so future
fast-rollout releases follow a single canonical flow.
2026-04-28 20:07:25 +07:00
Mohamed Boudra
0dfd909646 fix(opencode): reorder default modes to Build -> Plan -> Full Access 2026-04-28 19:53:27 +07:00
Mohamed Boudra
00219cfd04 fix(server): isolate paseo.json parse failures so they don't block fetch_workspaces
A malformed paseo.json (e.g. git-conflict markers) in any registered
workspace caused readPaseoConfig to throw, which propagated through
getScriptConfigs -> buildWorkspaceScriptPayloads ->
Session.describeWorkspaceRecord and failed the entire
fetch_workspaces_request with code=fetch_workspaces_failed. The client
showed no workspaces at all.

Reshape: separate loading (mechanism) from policy (per-caller).

- readPaseoConfig now returns a discriminated result
  ({ ok: true; config } | { ok: false; configPath; error }). Pure load,
  no logging, no opinions.
- Workspace projection owns its own policy via readPaseoConfigForProjection
  in script-status-projection.ts: warn via pino with configPath +
  workspaceDirectory + err, treat workspace as having no scripts, keep the
  list alive.
- spawnWorkspaceScript throws with cause so the existing
  handleStartWorkspaceScriptRequest catch logs it once with full context.
- The setup/teardown/terminal wrappers retain their pre-existing silent
  degradation on missing/broken config.
- getScriptConfigs takes a parsed PaseoConfig | null instead of repoRoot
  so the projection can pass the result through without double-reading.

Closes https://github.com/getpaseo/paseo/issues/598
2026-04-28 19:48:24 +07:00
Thanh Minh
fbf10ba4c6 fix(server): support OpenCode full-access approvals (#595)
* fix(server): support OpenCode full-access approvals

* fix(opencode): unconditionally auto-approve tool permissions in full-access mode

OpenCode permissions that resolve to allow do not surface prompts in the first place, so checking runtime permission rules before replying made Paseo full-access misleading. Permissions that default to ask, like external_directory and doom_loop, would still surface even in full-access.

Make full-access match OpenCode's own --dangerously-skip-permissions behavior by replying "once" to every tool permission prompt, while still letting reply failures fall back to surfacing the permission to the user.

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-04-28 20:47:12 +08:00
Yurui Zhou
2a4945ad0b fix: keep @ file mention responsive on very large projects (#600)
Typing @ in the chat input to reference a file froze the app on huge
workspaces. Two compounding causes:

1. The autocomplete hook fed fileFilterQuery straight into the React
   Query key, so every keystroke fired a new directory_suggestions
   request and a fresh BFS scan on the daemon. Added a 180 ms debounce
   that mirrors the existing pattern in new-workspace-screen so rapid
   typing coalesces into one request.

2. searchWorkspaceEntries only ignored node_modules, so dist, build,
   target, out, coverage, vendor and __pycache__ each consumed the
   5,000-entry scan budget before any real source file was reached.
   Extended WORKSPACE_IGNORED_DIRECTORY_NAMES with those names and
   added a regression test that proves a deep source file is still
   reachable when those directories are full of bait entries.

Refs #599

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 20:43:38 +08:00
Thanh Minh
3ba4d09294 fix(opencode): support executable slash commands (#597) 2026-04-28 20:25:04 +08:00
Mohamed Boudra
013e53881b fix(app): prevent infinite loop in @-mention scan when @ is at index 0
`findActiveFileMention` walked back through `@` candidates with
`lastIndexOf("@", atIndex - 1)`, but per spec a negative `position`
clamps to 0 — so once `atIndex` hit 0 with an invalid query (e.g. a
trailing space), the loop stepped from 0 back to 0 forever, freezing
the JS thread on every keystroke and forcing a quit.

Refs #596, #425
2026-04-28 19:16:04 +07:00
Mohamed Boudra
9fb87b9d86 ci(deploy-app): install all workspaces so server/relay sources resolve
Typecheck pulls in @server/* sources via tsconfig path alias, but the
prior `npm install --workspace=@getpaseo/app` skipped server/relay deps,
leaving zod, @anthropic-ai/claude-agent-sdk, and @getpaseo/relay/e2ee
unresolvable on CI.
2026-04-28 18:05:27 +07:00
github-actions[bot]
b557688ab3 fix: update lockfile signatures and Nix hash 2026-04-28 10:45:31 +00:00
32 changed files with 814 additions and 87 deletions

View File

@@ -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 }}

View File

@@ -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

View File

@@ -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.

View File

@@ -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
View File

@@ -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",

View File

@@ -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": [

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.1.63",
"version": "0.1.64",
"private": true,
"main": "index.ts",
"scripts": {

View File

@@ -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,

View 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 });
});
});

View File

@@ -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),

View File

@@ -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", () => {

View File

@@ -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)) {

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.1.63",
"version": "0.1.64",
"files": [
"dist"
],

View File

@@ -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"

View File

@@ -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",

View File

@@ -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",

View File

@@ -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();
});
});

View File

@@ -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>();

View File

@@ -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 {

View File

@@ -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({

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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,

View File

@@ -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`);

View File

@@ -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);
}
});
});

View File

@@ -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,

View File

@@ -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" }],

View File

@@ -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();

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/website",
"version": "0.1.63",
"version": "0.1.64",
"private": true,
"type": "module",
"scripts": {