Compare commits

..
Author SHA1 Message Date
Mohamed Boudra a7533e8330 fix(codex): honor full-access MCP elicitations
Codex's never-ask policy accepts empty MCP forms, but Paseo routed those app-server requests into its permission channel. Mirror that provider contract while declining data-bearing forms that Paseo cannot answer.
2026-08-10 17:58:56 +02:00
Mohamed Boudra a98d154b6b Search workspace files from the command center (#3059)
* feat(command-center): add workspace file search

Use the existing workspace-aware fuzzy search so file lookup follows the file explorer's ignore and hidden-directory rules. Cmd/Ctrl+P opens a removable Files scope while the normal command center also includes file matches.

* fix(command-center): stabilize file search results

* fix(shortcuts): preserve project picker command

* fix(command-center): keep file results while searching

* feat(command-center): integrate the files scope into the search row

The scope was a 24px pill with 12px text sitting inside the 16px input line, so
it read as a badge dropped into the query rather than the scope of the search.

Make it a leading cell of the search row instead: folder icon, input-sized label,
and a hairline divider before the caret. It stretches to the row's cross size and
bleeds through the header padding with negative margins, so its height is driven
entirely by the input and the panel's own radius clips its corner. Toggling the
scope cannot resize the header, which the new spec pins.

* fix(command-center): restore the files scope as a chip

The previous commit turned the scope into a flush leading cell of the header. Put
the chip back: rounded, bordered, folder icon and 14px label, no divider.

It stretches to the row instead of carrying a height, so it is exactly as tall as
the input's line box and cannot resize the header.

* refactor(shortcuts): regroup and reorder the shortcut help sections

Panels was a junk drawer: the command center, file search, the shortcuts sheet
itself, new terminal, the sidebars, settings, focus mode and theme cycling. The
two shortcuts people reach for most, Cmd+K and Cmd+P, sat inside it below three
other sections. Navigation held both workspace and tab movement.

Sections are now General, Projects & Workspaces, Tabs & Panes, Layout, and Agent
Input. General leads with the command center and file search. Tab movement joins
Tabs & Panes, new terminal joins the tabs it creates, and Layout keeps only the
sidebar and focus toggles.

Row order is now data rather than a side effect of binding-definition order, so
the row someone opens the sheet to find sits at the top of its section. A test
fails when a new shortcut has no place in that order instead of letting it sink
to the bottom unnoticed.

* Keep file search failures in view
2026-08-10 17:02:35 +02:00
Mohamed Boudra 42f1d0b289 docs(hub): align public docs with v0.3 (#3152)
* docs(hub): align public docs with v0.3

Bring the public workflow, API, authority, activity, and self-hosting guidance in line with the released Hub contracts. Clarify the public HTTPS boundary required by provider events while keeping Hub and PostgreSQL private.

* docs(hub): clarify daemon logout scope

* docs(hub): restore Compose path and simplify TLS guidance

* docs(hub): link the single-repo-team-bot example
2026-08-10 16:13:51 +02:00
Mohamed Boudra 48e65b407f Replace Parcel Watcher with Custom Watcher (#3107)
* fix(server): replace wedging file observation

Native watcher teardown could block the daemon main thread during workspace archive. Own recursive observation behind an awaited lifecycle boundary, retain an isolated comparison harness, and gate correctness, resources, and sustained CPU across platforms.

* test(server): accept root watcher evidence

Native recursive watchers may coalesce ignored descendant changes into a root event. Assert that ignored paths stay excluded while a nonignored sentinel proves delivery remains live.

* test(server): preserve watcher comparison evidence

Record Parcel delivery misses without weakening the production observer gate, and allow the Windows stress test to use its declared polling window.

* fix(server): reconcile native watcher omissions

Back macOS and Windows recursive watching with a bounded namespace inventory so coalesced create and delete events converge without unbounded rescans.

* test(server): compare worktree filesystem identity

Windows can return the same worktree through 8.3 and long path spellings. Preserve cardinality and branch checks while comparing the real path.

* perf(server): bound native inventory audits

Reduce full native inventory cadence while preserving a six-second convergence bound, and gate setup readiness and event-loop p99 against platform-appropriate budgets.

* build: refresh Nix npm dependency hash

* perf(server): scope native file reconciliation

* perf(server): defer native safety audits until quiet

* fix(server): move native safety deadline during audits

* perf(server): audit native inventories after settled churn

* fix(server): reconcile native root evidence promptly

* perf(server): deduplicate native audit scheduling

* perf(server): coalesce native event clusters

* perf(server): suppress fresh native change scans

* test(server): separate burst recovery from sustained churn

* test(server): stabilize observer performance gates

* perf(server): skip fresh native change scans

* fix(server): preserve nested native change audits

* perf(server): coalesce native repair scans

* fix(server): reconcile native directory moves promptly

* test(server): bound observer burst repair cost

* refactor(server): own file observer lifecycle

* build: refresh Nix npm dependency hash

* ci: fold observer coverage into server suite

* fix(server): await file observer shutdown
2026-08-10 13:43:48 +02:00
Mohamed Boudra 7954babca6 Publish the TypeScript SDK and integration guides (#3141)
* feat(client): publish the TypeScript SDK surface

Make the high-level client API usable for external integrations and document complete agent, workspace, provider, event, and recipe flows.

* fix(client): correlate provider snapshots by daemon cwd

Return the daemon's canonical snapshot identity so waitForReady can match updates for relative, tilde, and otherwise non-canonical paths without duplicating filesystem rules in the SDK.

* test(server): make snapshot cwd assertion portable

* fix(client): gate canonical provider snapshot waits

Older daemons do not expose the canonical snapshot identity needed to correlate workspace updates safely. Require the advertised capability and fail with an update-host message instead of guessing across workspaces.
2026-08-10 13:22:20 +02:00
Mohamed Boudra 05a0cabc07 Deploy multi-file Hub workflows (#3128)
* feat(hub): deploy canonical workflow bundles

Keep CLI deployment aligned with Hub's authored bundle boundary so validation and activation receive the same deterministic source files. Public documentation now teaches that boundary as the only configuration format.

* test(hub): assert validation diagnostics directly

* refactor(hub): deepen HTTP client module

* feat(hub): validate named agent configurations

* docs(hub): align Claude mode example
2026-08-10 12:32:59 +02:00
b7f137c2a0 Render SVG project icons on native and fall back for ICO (#2579)
* Render SVG project icons on native and fall back for ICO

React Native's <Image> silently decodes neither SVG nor ICO, so any
project whose discovered icon was one of those formats showed a blank
slot on iOS/Android while web/Electron rendered it fine. Route SVG data
URIs through react-native-svg on native and let ICO fall through to the
initial-letter avatar instead of rendering nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Import Buffer explicitly so SVG icons actually decode on device

React Native has no global Buffer and the app installs no global polyfill,
so projectIconSvgXml threw ReferenceError inside its try/catch and returned
null on every SVG — the exact blank-slot bug the previous commit meant to
fix. Vitest runs in Node where Buffer is global, which is why the tests
passed anyway; the new regression test deletes globalThis.Buffer to keep
the import honest.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Prefer renderable icon formats and unwrap PNG-bearing ICOs

favicon.ico ranked first in ICON_PATTERNS, so any repo with a standard
public/ layout served ICO — which native <Image> cannot decode — even when
an SVG or PNG sat in the same directory. Rank SVG and PNG above ICO for
each name, sniff magic bytes instead of trusting the extension (renamed
PNGs shipped as image/x-icon are common), and extract the largest
PNG-encoded frame from real ICO containers so native clients get a
decodable image instead of falling back to the letter avatar.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Render SVG icons with CSS support and fall back on parse failure

SvgXml ignores <style> blocks, which favicon SVGs commonly use, leaving
paths with a default black fill — invisible on dark themes. SvgCss inlines
the styles before rendering. Pass the letter avatar as the fallback so a
malformed SVG degrades to it instead of an empty slot, and fold the
workspace setup dialog's hand-rolled icon cascade into ProjectIconView so
the format handling lives in one place.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Take the platform as an explicit parameter in icon source helpers

The tests previously pinned isNative to true with a module-level vi.mock,
which coupled them to import initialization and left the web pass-through
paths uncovered. Defaulting an explicit `native` parameter to the real
constant keeps call sites unchanged while letting tests exercise both
variants directly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(app): isolate native project icon rendering

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-08-10 12:01:09 +02:00
Mohamed Boudra 02c4bc76a6 Keep Add Project method selection path-free (#3099)
* fix(app): remove search from project method selection

The fixed method list should not accept text because it can be mistaken for the directory path step that follows.

* fix(app): preserve native project picker keys

Keep hardware-keyboard delivery owned by the Add Project flow even when method selection has no visible text field.

* test(app): update add project overlay expectation
2026-08-09 21:46:58 +02:00
Mohamed Boudra ccb4e444ed Rework Hub workflow docs around explicit delivery and GitHub authority (#3086)
* docs(hub): name the Hub tools in workflow prompts

Every Hub workflow example now tells the agent which tool to call:
hub.reply for the user-facing response, hub.finish_execution to
complete the step and record structured output. Granting a capability
through allow_outputs makes the tool available; it does not make an
agent use it, and an agent that is not told answers in its own
transcript and leaves the step to its idle timeout. Explicit
instructions are what worked against real agents.

Classifier and other intermediate steps name hub.finish_execution
alone, so the docs do not imply every step must reply.

Drops the github.reply allow_outputs blocks from the GitHub examples.
The GitHub provider registers no output capabilities, so that
declaration never materializes a tool; those agents comment through
their scoped GH_TOKEN with gh, which the reference and trigger pages
already said. The read-only answer branch has no network authority for
that, so it only finishes.

Also quotes the "Request: ${{ paseo.prompt }}" prompt block in the
reference, which was invalid YAML as written.

* docs(hub): wrap triggering input in its own prompt block

Every Hub YAML example now ends its prompt with the raw triggering
message wrapped in <user-prompt> tags, as a separate block after the
workflow instructions. The agent can see where your instructions stop
and the request starts, which matters most on the steps that read
external Slack, Discord, and GitHub text.

The first workflow explains the convention in two sentences and points
at Hub security for the controls that actually limit an agent. Tags
label the input; they are not containment, and the docs do not claim
otherwise.

Replaces the earlier quoting fix in the prompt-blocks reference: the
message now lives in a block scalar, so the colon needs no quoting.

* docs: base worktree examples on origin/main

Every branch-off example now passes a remote-tracking ref. Paseo
fetches remote refs in the background, so origin/main is current while
the local main is whatever was last pulled, and base resolution tries
refs/heads first: an unqualified main silently branches off stale
history. The gotcha is explained once, next to the first branch-off
example in the worktrees guide; the other pages just use the ref, and
the Hub environment reference links there.

Also points the safety-gate classifier at the structured-output
section, which shows the output wrapper the finish call takes.

* docs(hub): configure Codex read-only through provider options

Hub examples described a read-only Codex step as mode: read-only. That
Paseo mode preset pairs the read-only sandbox with approval_policy
on-request, so an unattended Hub step stops to ask a question nobody is
there to answer. It is also not one of the modes the Codex provider
advertises.

Every read-only step now uses the provider-native passthrough proven by
the real-provider evidence in #3025: approval_policy never,
sandbox_mode read-only, web_search disabled. The security guide already
used this block; the workflow guide, examples, and reference now agree
with it, and the reference says plainly that a Paseo mode ID is a
separate surface from these options.

mode: full-access is unchanged. It maps to approval_policy never, so it
does not stall an unattended step.

* docs(hub): show a classifier choosing the repository and the model

Both routing jobs were only documented as caller-supplied inputs, so
there was no example of the thing people actually want: one read-only
classifier that reads the request, picks the repository it belongs to
and the model it needs, and hands both to a worker. Replaces the
caller-only model example, which the new one covers through its
override values.

The two axes are not symmetric and the example says so. A model is a
value, so values.selected_model goes straight into agent.model. An
environment cannot come from an expression at all: the compiler accepts
a literal name or paseo.inputs.<name> and nothing else, so each
repository needs its own environment and its own conditional step. One
classifier, exactly one worker.

Corrects the routing section, which listed environment among the fields
an agent output may select. It never could.

Also puts a blank line between adjacent steps and environments in the
deadline and repository-routing examples, and bases the daemon worktree
example on origin/main, which the earlier sweep missed.

* fix(website): keep development requests local

Canonical host redirects were applied to LAN-addressed development traffic, including inspector WebSockets. The redirected socket retained the Vite port and caused the development proxy to terminate when the public host timed out.

* docs(hub): order the workflow scenarios from simple to hard

The Hub pages were arranged by the shape of the configuration schema
rather than by what a reader is trying to do, which showed up three
ways: the overview opened on the storage hierarchy, workflow
configurations were split between workflows.md and a separate examples
page, and the examples that existed started with the hardest one.

workflows.md is now a ladder. One Slack trigger answering in a thread
stays inline as the first workflow, and nine further scenarios sit
under it as collapsible blocks, each a complete configuration adding
one idea to the one above: full access, repeated replies, a GitHub
trigger, two ordered steps, a typed input, two triggers, a classifier
choosing a model, a classifier choosing a repository, and last the
override composed with ??. configuration/examples.md is removed and its
configurations live there now.

Two claims in the old material were wrong and are corrected. Nothing
carries between steps except a declared output, so the planning
scenario passes its plan through output.schema instead of telling the
second agent to follow a plan it cannot see, and a worktree environment
creates a worktree per execution rather than sharing one. GH_TOKEN
authenticates the gh CLI but does not configure git, so the pages no
longer imply a step can push without a commit identity and credential
helper on the daemon host.

The overview drops the organization/connections/daemons/projects tree,
which concepts.md already owns, in favour of the smallest configuration
that does something. Hosting is now a link from the product pages
rather than a section in them.

* docs(hub): make GitHub access an explicit step grant

GitHub authority gets its own page. A step holds a token only when it
declares a github block — connection, repositories, permissions,
duration — and Hub supplies GH_TOKEN plus environment-only git
configuration, revoked when the step ends. No credential is ever
implied by the trigger source.

Rewrites the issue-to-pull-request scenario onto the block, repoints
the trigger, reference, FAQ, and concepts pages, and sweeps the Hub
pages for announcing sentences and compressed paragraphs.

* docs(hub): lighten dense reference prose

Restructure paragraph walls into short paragraphs, lists, and code
blocks: logout behavior in daemons, deploy paths and precedence in
configuration, install limits and dispatch fields in the API page,
required outputs and partial resolution in the reference, and the
policy translation and secrets guidance in security. Fixes two
remaining trigger-derived credential claims in triggers/index.

* fix(nix): update npm dependency hash

* docs(hub): document shared prompts and authority defaults
2026-08-09 16:59:00 +02:00
paseo-ai[bot] b0a7d493aa fix: update lockfile signatures and Nix hash [skip ci] 2026-08-09 11:39:14 +00:00
155 changed files with 9008 additions and 3549 deletions
+1
View File
@@ -46,6 +46,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
| [docs/protocol-compatibility.md](docs/protocol-compatibility.md) | Why app/daemon versions drift, protocol vs feature contract, capability gating, COMPAT tagging |
| [docs/protocol-validation.md](docs/protocol-validation.md) | zod-aot generated inbound WebSocket validation, patched compiler regressions, schema-purity rules |
| [docs/terminal-performance.md](docs/terminal-performance.md) | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage |
| [docs/file-observation.md](docs/file-observation.md) | Recursive watcher ownership, Linux constraints, teardown invariants, and Parcel comparison |
| [docs/testing.md](docs/testing.md) | TDD workflow, determinism, real dependencies over mocks, test organization |
| [docs/qa.md](docs/qa.md) | QA evidence bar for pull requests — platform matrix, version drift, performance, UI proof |
| [docs/mobile-testing.md](docs/mobile-testing.md) | Maestro and mobile test workflows |
+25 -1
View File
@@ -105,7 +105,7 @@ Everything you can do in the app, you can do from the terminal.
```bash
paseo run --provider claude/opus-4.6 "implement user authentication"
paseo run --provider codex/gpt-5.4 --worktree feature-x "implement feature X"
paseo run --provider codex/gpt-5.5 --worktree feature-x "implement feature X"
paseo ls # list running agents
paseo attach abc123 # stream live output
@@ -117,6 +117,30 @@ paseo --host workstation.local:6767 run "run the full test suite"
See the [full CLI reference](https://paseo.sh/docs/cli) for more.
## TypeScript SDK
Build issue integrations, dashboards, and orchestration services with `@getpaseo/client`:
```ts
import { createPaseoClient } from "@getpaseo/client";
const client = createPaseoClient({ url: "ws://127.0.0.1:6767/ws" });
await client.connect();
const agent = await client.agents.create({
config: { provider: "codex/gpt-5.5" },
cwd: "/Users/me/dev/storefront",
prompt: "Review the current diff and name the riskiest change.",
});
const result = await agent.waitForFinish();
console.log(result.lastMessage);
await client.close();
```
See the [SDK quickstart](https://paseo.sh/docs/sdk/quickstart), [recipes](https://paseo.sh/docs/sdk/recipes), and [API reference](https://paseo.sh/docs/sdk/reference).
## Skills
Skills teach your agent to use Paseo to orchestrate other agents.
+19
View File
@@ -0,0 +1,19 @@
# File observation
Use `packages/server/src/server/file-observer` for recursive filesystem observation. Create one observer service at the owning service boundary, subscribe roots through it, and close it during owner shutdown. Keep platform watchers, directory discovery, exclusion reconciliation, event batching, failure cleanup, diagnostics, and teardown inside the module. A consumer supplies a root and excluded subtrees; it must not branch on the platform or manage child watchers.
Do not replace the Linux implementation with `fs.watch({ recursive: true })`. Node 22 implements Linux recursion in JavaScript by walking the tree and watching every file and directory. Paseo's directory-only Linux watcher already exhausted resources before ignored-tree pruning and a watcher cap were added in [#794](https://github.com/getpaseo/paseo/pull/794).
Closing an observation must remain safe after its root is renamed or removed. Workspace archive removes owned worktrees while releasing observation references, so teardown cannot assume the watched path still exists.
`unsubscribe()` is an awaited barrier. Once it resolves, no scan, reconciliation, queued event, native handle, or callback may remain. `updateIgnore()` is the equivalent barrier for excluded paths: events queued below a newly excluded root cannot be delivered after it resolves. Treat path disappearance while scanning, attaching, or receiving a watcher error as rename/archive churn. Surface permission and resource errors so WorkspaceGit can enter bounded polling.
Linux topology repair is trailing-debounced and scoped to the renamed path. A file rename needs one classification stat; a new or moved directory scans only its subtree. Full-tree scans are reserved for startup and ignore-set replacement. The initial Linux scan conservatively emits existing files discovered before their directory watcher was installed; WorkspaceGit batches these into one refresh. Benchmark this startup cost with pre-populated trees.
Windows and macOS use one native recursive watcher plus an ignored-pruned, directory-indexed inventory. Native events deliver immediately. Named events trigger serialized shallow scans of their parent directories; known directory events also reconcile that subtree. This recovers concrete create and delete paths omitted by coalescing without walking the whole root for each edit. Startup and exclusion changes use an awaited full inventory barrier. Activity arms a full safety audit 30 seconds after it settles, with a 30-second minimum interval and a five-minute starvation bound; pathless native events also trigger an immediate shallow root scan. Observation fails into polling rather than retaining more than 250,000 file and directory entries per root.
Read aggregate health from the owning observer service. Runtime metrics include active observations, native handles, tracked native files, pending events and immediate reconciliation work, scoped and full reconciliation activity and latency, and failure counts. Delayed safety audits do not count as pending work. Closing the service releases every subscription and clears its diagnostics. Do not add path lists, watcher handles, or platform-specific controls to this interface.
Git owns Git-ignore evaluation. The observer accepts absolute excluded roots and applies updates without replacing the observation or exposing its watcher topology. This keeps tracked files inside otherwise ignored directories observable and keeps Git policy out of the filesystem module.
The real-filesystem contracts and daemon auto-archive lifecycle run in the normal server test suite. Use the scripts only for manual performance and soak work: `npm run measure:file-observer --workspace=@getpaseo/server` measures burst and sustained-create behavior, and `npm run repro:file-observer-teardown --workspace=@getpaseo/server` runs the teardown soak.
+5
View File
@@ -37,6 +37,11 @@ Each provider definition owns its option schema and exact MCP preapproval mappin
must fail closed for Hub unattended execution until it can approve one exact injected MCP server
and tool identity without approving native tools.
Codex **Full Access** sends `approvalPolicy: "never"` and `danger-full-access` to the app server.
If a permission-only MCP form still reaches the provider, Paseo accepts it when its schema has no
properties. Forms with fields and URL/auth flows are declined because Paseo cannot supply user data
or credentials. Restricted Codex modes keep their existing approval behavior.
## Two Integration Patterns
### ACP (Agent Client Protocol) -- recommended
+1 -1
View File
@@ -1 +1 @@
sha256-RCp5Ogd8AETLmJCZaUebcgSPRk+is26nkUY7+blDb/g=
sha256-tB0WZG6F6VFMS5zFqo73giSAn7SqfkJISF5fMjDioJQ=
+59 -275
View File
@@ -9515,280 +9515,6 @@
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@parcel/watcher": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.6.0.tgz",
"integrity": "sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"detect-libc": "^2.0.3",
"is-glob": "^4.0.3",
"node-addon-api": "^7.0.0",
"picomatch": "^4.0.4"
},
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
"@parcel/watcher-android-arm64": "2.6.0",
"@parcel/watcher-darwin-arm64": "2.6.0",
"@parcel/watcher-darwin-x64": "2.6.0",
"@parcel/watcher-freebsd-x64": "2.6.0",
"@parcel/watcher-linux-arm-glibc": "2.6.0",
"@parcel/watcher-linux-arm-musl": "2.6.0",
"@parcel/watcher-linux-arm64-glibc": "2.6.0",
"@parcel/watcher-linux-arm64-musl": "2.6.0",
"@parcel/watcher-linux-x64-glibc": "2.6.0",
"@parcel/watcher-linux-x64-musl": "2.6.0",
"@parcel/watcher-win32-arm64": "2.6.0",
"@parcel/watcher-win32-x64": "2.6.0"
}
},
"node_modules/@parcel/watcher-android-arm64": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.6.0.tgz",
"integrity": "sha512-trgpLSCKRC/huFjXX/Smh+0sWe4+YtKfktIToiMl59ghz7z+qkH6kMvNnUbLyRs9N11t8l4svSCs1+5B3rOAhA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-darwin-arm64": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.6.0.tgz",
"integrity": "sha512-Y3QV0gl7Q1zbfueunkWIERICbEojQFCgpyG7YqOGNFLsckXyI1xu9mAIUpKY9QBYzBtSkN8dBPwd3yiAO9ovMw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-darwin-x64": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.6.0.tgz",
"integrity": "sha512-Ohv6OpzhUfKYD7Beb8kDvG0jbIxORCYY1JRdZnaBtnjjkJxgD7ZVL0nw2sCYd0yTMKTvz3nnTnOF3cDifK+kvw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-freebsd-x64": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.6.0.tgz",
"integrity": "sha512-5HmXvDgs8VK+74jF9y9/2FE3/OnlcKmc56tjmSrEuZjpSZOGL+fvAu+HKJBdPs9uwoP2hE6TlSUpXZ/C5jUFmQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-arm-glibc": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.6.0.tgz",
"integrity": "sha512-Ps/hui3A+vMbjdqlqAowK2ZL8+BO8dBjxeWXj6npTBs3jx4wWmbPpaLuqwrQrSqIVMCnpWo238bJ1U37GhQOYg==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-arm-musl": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.6.0.tgz",
"integrity": "sha512-9c6AUHgHoG+IY88MRIHupztQiQnrbqHYQjkM2btA+Bf/wQnQMuiD0Wfk1EVv3TlNT3x41uU71rn6E4xh/+zvkw==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-arm64-glibc": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.6.0.tgz",
"integrity": "sha512-yHRqS2owEXe6Hic9z6Mh1ECsCd+ODVOGvZDyciqRd21+v+o+DnXMOrw50DSpIG2sb8GPEaPPmfeCAWKPJdq46g==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-arm64-musl": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.6.0.tgz",
"integrity": "sha512-WhB2e/V7rqdHHWZusBSPuy5Ei8S6lSz6FE5TKKQz5h3a0O+C+mhY7vxU9b/stqvMb8beLnPY82ZrFTLKs+SrKA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-x64-glibc": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.6.0.tgz",
"integrity": "sha512-ulGE6x6Oz6iAwg75T8YQSoguBWasniIbX+QWpaYPcCnDOpdWX3k+4xbEYPZVLxOuoJI+svJJPD3sEj8G7lrQ3A==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-x64-musl": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.6.0.tgz",
"integrity": "sha512-tkBYKt7YQrjIJWYDnto2YgO8MRkjlMTSNoRHzsXinBqbLdeOM3L32wPZJvIZxqaLMfSlS/4sUjH/6STVP/XDLw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-win32-arm64": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.6.0.tgz",
"integrity": "sha512-gIZAP23jaHjGWasY/TY6yL7NHFClf0Ga7FN+iINvk+KN94rhm94lYZhFsbYFNcA04/onvGD9kKmiJLJB2HbNwQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-win32-x64": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.6.0.tgz",
"integrity": "sha512-cA+/pXV2YkfxlIcXOQ5fSWqAzzPyD78/x5qbK/I0vUkrlYHA8TIz+MXjAbGouguKVSI4bOmkTSJ1/poVSsgt+A==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@pinojs/redact": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
@@ -23278,6 +23004,7 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -23331,6 +23058,7 @@
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-extglob": "^2.1.1"
@@ -25579,6 +25307,27 @@
"is-buffer": "~1.1.6"
}
},
"node_modules/mdast-util-directive": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/mdast-util-directive/-/mdast-util-directive-3.1.0.tgz",
"integrity": "sha512-I3fNFt+DHmpWCYAT7quoM6lHf9wuqtI+oCOfvILnoicNIqjh5E3dEJWiXuYME2gNe8vl1iMQwyUHa7bgFmak6Q==",
"license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"@types/unist": "^3.0.0",
"ccount": "^2.0.0",
"devlop": "^1.0.0",
"mdast-util-from-markdown": "^2.0.0",
"mdast-util-to-markdown": "^2.0.0",
"parse-entities": "^4.0.0",
"stringify-entities": "^4.0.0",
"unist-util-visit-parents": "^6.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-find-and-replace": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz",
@@ -26414,6 +26163,25 @@
"micromark-util-types": "^2.0.0"
}
},
"node_modules/micromark-extension-directive": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/micromark-extension-directive/-/micromark-extension-directive-4.0.0.tgz",
"integrity": "sha512-/C2nqVmXXmiseSSuCdItCMho7ybwwop6RrrRPk0KbOHW21JKoCldC+8rFOaundDoRBUWBnJJcxeA/Kvi34WQXg==",
"license": "MIT",
"dependencies": {
"devlop": "^1.0.0",
"micromark-factory-space": "^2.0.0",
"micromark-factory-whitespace": "^2.0.0",
"micromark-util-character": "^2.0.0",
"micromark-util-symbol": "^2.0.0",
"micromark-util-types": "^2.0.0",
"parse-entities": "^4.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/micromark-extension-gfm": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz",
@@ -30588,6 +30356,22 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/remark-directive": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/remark-directive/-/remark-directive-4.0.0.tgz",
"integrity": "sha512-7sxn4RfF1o3izevPV1DheyGDD6X4c9hrGpfdUpm7uC++dqrnJxIZVkk7CoKqcLm0VUMAuOol7Mno3m6g8cfMuA==",
"license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"mdast-util-directive": "^3.0.0",
"micromark-extension-directive": "^4.0.0",
"unified": "^11.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/remark-gfm": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz",
@@ -38233,7 +38017,6 @@
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.14.46",
"@parcel/watcher": "^2.6.0",
"@xterm/headless": "^6.0.0",
"ai": "5.0.78",
"ajv": "^8.20.0",
@@ -38793,6 +38576,7 @@
"rehype-autolink-headings": "^7.1.0",
"rehype-pretty-code": "^0.14.3",
"rehype-slug": "^6.0.0",
"remark-directive": "^4.0.0",
"remark-gfm": "^4.0.1",
"shiki": "^4.0.2",
"wrangler": "^4.75.0"
@@ -13,6 +13,7 @@ import {
expectAddProjectPage,
expectNewWorkspaceForAddedProject,
openAddProjectFlow,
openAddProjectHostSelection,
} from "../support/helpers/add-project-flow";
import { gotoAppShell } from "../support/helpers/app";
import {
@@ -65,12 +66,14 @@ async function expectProjectHasNoWorkspaces(projectId: string): Promise<void> {
test.describe("Add Project command-center flow", () => {
test.describe.configure({ timeout: 180_000 });
test("a single connected host opens directly on method selection", async ({ page }) => {
test("method selection has no search field", async ({ page }) => {
await gotoAppShell(page);
await openAddProjectFlow(page);
await expect(addProjectFlowMethod(page, "directory-search")).toBeVisible();
await expect(addProjectFlowInput(page)).toHaveCount(0);
await expect(addProjectFlow(page).getByRole("textbox")).toHaveCount(0);
await expect(page.getByTestId("add-project-flow-page-host")).toHaveCount(0);
});
@@ -146,7 +149,7 @@ test.describe("Add Project command-center flow", () => {
serverId: SECONDARY_HOST_ID,
endpoint: `localhost:${secondaryHost.port}`,
});
await openAddProjectFlow(page, "host");
await openAddProjectHostSelection(page);
await page.keyboard.press("ArrowDown");
await page.keyboard.press("Enter");
@@ -155,7 +158,7 @@ test.describe("Add Project command-center flow", () => {
await expect(addProjectFlow(page)).toContainText(SECONDARY_HOST_LABEL);
});
test("Escape and Back restore page input and active selection before closing at the root", async ({
test("Escape and Back restore searchable page input before closing at the root", async ({
page,
}) => {
await gotoAppShell(page);
@@ -168,21 +171,20 @@ test.describe("Add Project command-center flow", () => {
serverId: SECONDARY_HOST_ID,
endpoint: `localhost:${secondaryHost.port}`,
});
await openAddProjectFlow(page, "host");
await openAddProjectHostSelection(page);
await addProjectFlowInput(page).fill("o");
await page.keyboard.press("ArrowDown");
await page.keyboard.press("Enter");
await expectAddProjectPage(page, "method");
await addProjectFlowInput(page).fill("new");
await page.keyboard.press("Enter");
await chooseAddProjectMethod(page, "new-directory");
await expectAddProjectPage(page, "new-directory-parent");
await page.keyboard.press("Escape");
await expectAddProjectPage(page, "method");
await expect(addProjectFlowInput(page)).toHaveValue("new");
await page.keyboard.press("Enter");
await expect(addProjectFlowInput(page)).toHaveCount(0);
await chooseAddProjectMethod(page, "new-directory");
await expectAddProjectPage(page, "new-directory-parent");
await addProjectFlowBack(page).click();
@@ -216,7 +218,7 @@ test.describe("Add Project command-center flow", () => {
serverId: SECONDARY_HOST_ID,
endpoint: `localhost:${secondaryHost.port}`,
});
await openAddProjectFlow(page, "host");
await openAddProjectHostSelection(page);
await addProjectFlowHost(page, SECONDARY_HOST_ID).click();
await expectAddProjectPage(page, "method");
@@ -0,0 +1,139 @@
import { expect, test } from "../support/fixtures";
import { openCommandCenter } from "../support/helpers/command-center";
import {
delayDirectorySuggestionResponses,
expectOneLineTruncatedFileResult,
expectStableCommandCenterLayout,
failDirectorySuggestionRequests,
startCommandCenterLayoutObservation,
} from "../support/helpers/command-center-file-search";
import { expectFileTabOpen } from "../support/helpers/file-explorer";
import { gotoWorkspace } from "../support/helpers/launcher";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
const FILE_PATH =
"packages/app/src/command-center/features/workspace-file-search/layout-stability/needle-layout-stability-command-center.tsx";
const FILE_NAME = "needle-layout-stability-command-center.tsx";
const FILE_DIRECTORY =
"packages/app/src/command-center/features/workspace-file-search/layout-stability";
function paseoSizedFiles(): Array<{ path: string; content: string }> {
const files = Array.from({ length: 120 }, (_, index) => ({
path: `packages/app/src/features/feature-${String(index).padStart(3, "0")}/index.ts`,
content: `export const feature${index} = ${index};\n`,
}));
files.push({ path: FILE_PATH, content: "export const layoutNeedle = true;\n" });
return files;
}
async function seedMatchingWorkspaces(count: number): Promise<SeededWorkspace[]> {
return Promise.all(
Array.from({ length: count }, (_, index) =>
seedWorkspace({
repoPrefix: `command-center-file-search-error-${String(index).padStart(2, "0")}-`,
title: `Home failure ${String(index).padStart(2, "0")}`,
}),
),
);
}
test.use({
userAgent:
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/145.0 Safari/537.36",
});
test("workspace file search stays geometrically stable through delayed loading and results", async ({
page,
}) => {
test.setTimeout(120_000);
const seeded = await seedWorkspace({
repoPrefix: "command-center-file-search-",
title: "Paseo-shaped file search",
repo: { files: paseoSizedFiles() },
});
try {
await delayDirectorySuggestionResponses(page, 800);
await gotoWorkspace(page, seeded.workspaceId);
await page.keyboard.press("Meta+P");
const panel = page.getByTestId("command-center-panel");
await expect(panel).toBeVisible({ timeout: 30_000 });
await expect(panel.getByRole("button", { name: "Files" })).toBeVisible();
await startCommandCenterLayoutObservation(page);
await panel.getByTestId("command-center-input").fill(FILE_NAME);
const row = panel.getByRole("button", {
name: `${FILE_NAME} ${FILE_DIRECTORY}`,
});
await expect(row).toBeVisible({ timeout: 30_000 });
await expectOneLineTruncatedFileResult(row, {
filename: FILE_NAME,
path: FILE_DIRECTORY,
});
await panel.getByTestId("command-center-input").fill("needle-layout");
await page.waitForTimeout(150);
expect(await row.isVisible()).toBe(true);
await expect(panel.getByTestId("command-center-file-search-loading")).toBeVisible();
await expect(panel.getByText("Searching files...", { exact: true })).toHaveCount(0);
await expect(panel.getByTestId("command-center-file-search-loading")).toBeHidden();
await expectStableCommandCenterLayout(page);
await row.click();
await expectFileTabOpen(page, FILE_PATH);
} finally {
await seeded.cleanup();
}
});
test("dropping the files scope leaves the search row the same height", async ({ page }) => {
const seeded = await seedWorkspace({
repoPrefix: "command-center-file-search-scope-",
title: "Scope chip height",
});
try {
await gotoWorkspace(page, seeded.workspaceId);
await page.keyboard.press("Meta+P");
const panel = page.getByTestId("command-center-panel");
await expect(panel).toBeVisible({ timeout: 30_000 });
const header = panel.getByTestId("command-center-header");
const scoped = await header.boundingBox();
await page.getByTestId("command-center-files-scope").click();
await expect(page.getByTestId("command-center-files-scope")).toHaveCount(0);
const unscoped = await header.boundingBox();
expect(scoped?.height).toBe(unscoped?.height);
} finally {
await seeded.cleanup();
}
});
test("unscoped command search keeps commands visible and reports file-search failures", async ({
page,
}) => {
test.setTimeout(120_000);
const seeded = await seedMatchingWorkspaces(9);
try {
await failDirectorySuggestionRequests(page);
await gotoWorkspace(page, seeded[0].workspaceId);
const panel = await openCommandCenter(page);
await panel.getByTestId("command-center-input").fill("home");
await expect(panel.getByRole("button", { name: "Home", exact: true })).toBeVisible();
await expect(panel.getByRole("button", { name: /Home failure 08/ })).toBeVisible();
await expect(panel.getByTestId("command-center-file-search-error")).toHaveText(
"Error: Test file search transport failure.",
);
await expect(panel.getByTestId("command-center-file-search-error")).toBeInViewport({
ratio: 1,
});
await expect(panel.getByText("No matches", { exact: true })).toHaveCount(0);
} finally {
await Promise.allSettled(seeded.map((workspace) => workspace.cleanup()));
}
});
@@ -160,7 +160,6 @@ test.describe("provider settings overlay stack", () => {
await commandCenter.getByText("Add project", { exact: true }).click();
const addProject = page.getByTestId("add-project-flow");
await expect(addProject).toBeVisible({ timeout: 10_000 });
await expect(addProject.getByTestId("add-project-flow-input")).toBeFocused();
await page.keyboard.press("Escape");
await expect(addProject).not.toBeVisible({ timeout: 10_000 });
await expect(selector).toBeVisible();
@@ -43,13 +43,21 @@ export async function expectAddProjectPage(page: Page, kind: AddProjectFlowPage)
return currentPage;
}
export async function openAddProjectFlow(
async function openAddProjectFlowSurface(
page: Page,
expectedPage: "host" | "method" = "method",
expectedPage: "host" | "method",
): Promise<void> {
await page.getByTestId("sidebar-add-project").click();
await expect(addProjectFlow(page)).toBeVisible({ timeout: 30_000 });
await expectAddProjectPage(page, expectedPage);
}
export async function openAddProjectFlow(page: Page): Promise<void> {
await openAddProjectFlowSurface(page, "method");
}
export async function openAddProjectHostSelection(page: Page): Promise<void> {
await openAddProjectFlowSurface(page, "host");
await expect(addProjectFlowInput(page)).toBeFocused();
}
@@ -0,0 +1,211 @@
import { expect, type Locator, type Page } from "@playwright/test";
import { daemonWsRoutePattern } from "./daemon-port";
type WebSocketMessage = string | Buffer;
interface SurfaceRect {
height: number;
width: number;
x: number;
y: number;
}
interface LayoutObservation {
active: boolean;
layoutShift: number;
samples: Record<string, SurfaceRect[]>;
}
type LayoutOracleWindow = Window & {
__commandCenterLayoutObservation?: LayoutObservation;
};
const SURFACE_SELECTORS = {
panel: '[data-testid="command-center-panel"]',
header: '[data-testid="command-center-header"]',
input: '[data-testid="command-center-input"]',
fileSearchStatus: '[data-testid="command-center-file-search-status"]',
results: '[data-testid="command-center-results"]',
scope: '[data-testid="command-center-files-scope"]',
fileRow: '[data-testid^="command-center-file-row-"]',
fileIcon: '[data-testid="command-center-file-icon"]',
fileLine: '[data-testid="command-center-file-line"]',
} as const;
function parseSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
const raw = typeof message === "string" ? message : message.toString("utf8");
try {
const envelope = JSON.parse(raw) as { type?: unknown; message?: unknown };
return envelope.type === "session" && envelope.message && typeof envelope.message === "object"
? (envelope.message as Record<string, unknown>)
: null;
} catch {
return null;
}
}
export async function delayDirectorySuggestionResponses(page: Page, delayMs = 400): Promise<void> {
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
ws.onMessage((message) => server.send(message));
server.onMessage((message) => {
const sessionMessage = parseSessionMessage(message);
if (sessionMessage?.type === "directory_suggestions_response") {
setTimeout(() => ws.send(message), delayMs);
return;
}
ws.send(message);
});
});
}
export async function failDirectorySuggestionRequests(page: Page): Promise<void> {
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
ws.onMessage((message) => {
const sessionMessage = parseSessionMessage(message);
if (sessionMessage?.type === "directory_suggestions_request") {
const requestId = sessionMessage.requestId;
if (typeof requestId === "string") {
ws.send(
JSON.stringify({
type: "session",
message: {
type: "rpc_error",
payload: {
requestId,
requestType: "directory_suggestions_request",
error: "Test file search transport failure.",
code: "transport",
},
},
}),
);
}
return;
}
server.send(message);
});
server.onMessage((message) => ws.send(message));
});
}
export async function startCommandCenterLayoutObservation(page: Page): Promise<void> {
await page.evaluate((selectors) => {
const oracleWindow = window as LayoutOracleWindow;
const observation: LayoutObservation = { active: true, layoutShift: 0, samples: {} };
oracleWindow.__commandCenterLayoutObservation = observation;
const observedElements = new WeakSet<Element>();
const resizeObserver = new ResizeObserver(record);
const mutationObserver = new MutationObserver(discover);
function record(): void {
if (!observation.active) return;
for (const [name, selector] of Object.entries(selectors)) {
const element = document.querySelector<HTMLElement>(selector);
if (!element) continue;
const rect = element.getBoundingClientRect();
(observation.samples[name] ??= []).push({
height: rect.height,
width: rect.width,
x: rect.x,
y: rect.y,
});
}
}
function discover(): void {
for (const selector of Object.values(selectors)) {
const element = document.querySelector(selector);
if (!element || observedElements.has(element)) continue;
observedElements.add(element);
resizeObserver.observe(element);
}
record();
}
mutationObserver.observe(document.body, { attributes: true, childList: true, subtree: true });
discover();
const sampleFrame = () => {
if (!observation.active) {
mutationObserver.disconnect();
resizeObserver.disconnect();
return;
}
record();
requestAnimationFrame(sampleFrame);
};
requestAnimationFrame(sampleFrame);
if (typeof PerformanceObserver !== "undefined") {
try {
const performanceObserver = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
observation.layoutShift += (entry as PerformanceEntry & { value?: number }).value ?? 0;
}
});
performanceObserver.observe({ type: "layout-shift", buffered: false });
} catch {
// LayoutShift is diagnostic-only on engines that do not expose it.
}
}
}, SURFACE_SELECTORS);
}
export async function expectStableCommandCenterLayout(page: Page): Promise<void> {
await page.waitForTimeout(500);
const observation = await page.evaluate(() => {
const state = (window as LayoutOracleWindow).__commandCenterLayoutObservation;
if (!state) throw new Error("Command center layout observer was not installed");
state.active = false;
return state;
});
for (const surface of Object.keys(SURFACE_SELECTORS)) {
const samples = observation.samples[surface] ?? [];
expect(samples.length, `${surface}: ${JSON.stringify(observation)}`).toBeGreaterThan(0);
for (const dimension of ["height", "width", "x", "y"] as const) {
const values = samples.map((sample) => sample[dimension]);
expect(
Math.max(...values) - Math.min(...values),
`${surface}.${dimension}: ${JSON.stringify(samples)}`,
).toBeLessThanOrEqual(1);
}
}
expect(observation.layoutShift, JSON.stringify(observation)).toBeLessThanOrEqual(0.001);
}
export async function expectOneLineTruncatedFileResult(
row: Locator,
input: { filename: string; path: string },
): Promise<void> {
await expect(row).toContainText(`${input.filename} ${input.path}`);
await expect(row.getByTestId("command-center-file-icon")).toBeVisible();
const line = row.getByTestId("command-center-file-line");
const filename = row.getByTestId("command-center-file-name");
const filePath = row.getByTestId("command-center-file-path");
const geometry = await Promise.all([
row.boundingBox(),
line.boundingBox(),
filename.boundingBox(),
filePath.boundingBox(),
line.evaluate((element) => ({
clientWidth: element.clientWidth,
overflow: getComputedStyle(element).overflow,
scrollWidth: element.scrollWidth,
whiteSpace: getComputedStyle(element).whiteSpace,
})),
]);
const [rowBox, lineBox, filenameBox, pathBox, overflow] = geometry;
expect(rowBox).not.toBeNull();
expect(lineBox).not.toBeNull();
expect(filenameBox).not.toBeNull();
expect(pathBox).not.toBeNull();
expect(rowBox?.height).toBe(36);
expect(Math.abs((filenameBox?.y ?? 0) - (pathBox?.y ?? 0))).toBeLessThanOrEqual(1);
expect(overflow.scrollWidth).toBeGreaterThan(overflow.clientWidth);
expect(overflow.overflow).toBe("hidden");
expect(overflow.whiteSpace).toBe("nowrap");
}
@@ -39,9 +39,9 @@ describe("Add Project navigation", () => {
expect(currentAddProjectPage(state)).toEqual({
kind: "method",
hostId: "host-1",
query: "",
activeIndex: 0,
error: null,
isSubmitting: false,
});
expect(backAddProjectPage(state)).toBeNull();
});
+11 -5
View File
@@ -17,15 +17,18 @@ export interface GithubRepositoryChoice {
updatedAt: string | null;
}
interface SearchPageState {
query: string;
interface PageState {
activeIndex: number;
error: string | null;
}
interface SearchPageState extends PageState {
query: string;
}
export type AddProjectPage =
| ({ kind: "host" } & SearchPageState)
| ({ kind: "method"; hostId: string } & SearchPageState)
| ({ kind: "method"; hostId: string; isSubmitting: boolean } & PageState)
| ({ kind: "directory-search"; hostId: string; isSubmitting: boolean } & SearchPageState)
| ({ kind: "github-search"; hostId: string } & SearchPageState)
| ({
@@ -57,12 +60,14 @@ export interface OpenAddProjectFlowInput {
preferredHostId?: string;
}
function searchPage<TKind extends AddProjectPage["kind"]>(kind: TKind) {
type SearchPageKind = Extract<AddProjectPage, { query: string }>["kind"];
function searchPage<TKind extends SearchPageKind>(kind: TKind) {
return { kind, query: "", activeIndex: 0, error: null } as const;
}
function methodPage(hostId: string): AddProjectPage {
return { ...searchPage("method"), hostId };
return { kind: "method", hostId, activeIndex: 0, error: null, isSubmitting: false };
}
export function openAddProjectFlow(input: OpenAddProjectFlowInput): AddProjectFlowState {
@@ -209,6 +214,7 @@ export function setAddProjectPageInput(
if (current.kind === "new-directory-name") {
return { ...current, name: value, activeIndex: 0, error: null };
}
if (current.kind === "method") return current;
return { ...current, query: value, activeIndex: 0, error: null };
});
if (page.kind !== "github-location") return updated;
@@ -13,7 +13,7 @@ import {
} from "react-native";
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Check, ChevronRight, Folder } from "lucide-react-native";
import { Check, ChevronRight, Folder, X } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import {
BottomSheetBackdrop,
@@ -22,6 +22,8 @@ import {
type BottomSheetFlatListMethods,
} from "@gorhom/bottom-sheet";
import { AgentStatusDot } from "@/components/agent-status-dot";
import { MaterialFileIcon } from "@/components/material-file-icon";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { Shortcut } from "@/components/ui/shortcut";
import {
IsolatedBottomSheetModal,
@@ -37,7 +39,10 @@ import {
useWebOverlayRegistration,
} from "@/lib/overlay-root";
import { useHosts } from "@/runtime/host-runtime";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import {
useKeyboardShortcutsStore,
type CommandCenterScope,
} from "@/stores/keyboard-shortcuts-store";
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
import {
@@ -56,11 +61,13 @@ import {
preserveActiveResultId,
projectCommandCenterRows,
type CommandCenterAgentResult,
type CommandCenterFileResult,
type CommandCenterListRow,
type CommandCenterResult,
type CommandCenterResultSection,
type CommandCenterWorkspaceResult,
} from "./results";
import { useWorkspaceFileSearch } from "./workspace-file-search";
const ThemedBottomSheetTextInput = withUnistyles(BottomSheetTextInput, (theme) => ({
placeholderTextColor: theme.colors.foregroundMuted,
@@ -73,6 +80,10 @@ const ThemedCheck = withUnistyles(Check, (theme) => ({ color: theme.colors.foreg
const ThemedChevronRight = withUnistyles(ChevronRight, (theme) => ({
color: theme.colors.foregroundMuted,
}));
const ThemedX = withUnistyles(X, (theme) => ({ color: theme.colors.foregroundMuted }));
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner, (theme) => ({
color: theme.colors.foregroundMuted,
}));
const COMMAND_CENTER_SNAP_POINTS = ["60%", "90%"];
const KEYBOARD_SHOULD_PERSIST_TAPS = "always" as const;
const DEFAULT_CATEGORY_RESULT_LIMIT = 5;
@@ -191,6 +202,8 @@ function useBuiltInSections(open: boolean, query: string): CommandCenterResultSe
interface CommandCenterState {
open: boolean;
scope: CommandCenterScope;
clearScope(): void;
query: string;
setQuery(query: string): void;
activeId: string | null;
@@ -199,27 +212,58 @@ interface CommandCenterState {
rowIndexByResultId: ReadonlyMap<string, number>;
offsets: readonly number[];
inputRef: React.RefObject<TextInput | null>;
fileSearchLoading: boolean;
fileSearchError: string | null;
close(): void;
select(result: CommandCenterResult): void;
key(key: string): boolean;
}
function useCommandCenterState(): CommandCenterState {
const { t } = useTranslation();
const open = useKeyboardShortcutsStore((state) => state.commandCenterOpen);
const scope = useKeyboardShortcutsStore((state) => state.commandCenterScope);
const setOpen = useKeyboardShortcutsStore((state) => state.setCommandCenterOpen);
const setScope = useKeyboardShortcutsStore((state) => state.setCommandCenterScope);
const snapshot = useCommandCenterContributions();
const inputRef = useRef<TextInput>(null);
const previousOpenRef = useRef(open);
const [query, setQuery] = useState("");
const [activeId, setActiveId] = useState<string | null>(null);
const builtInSections = useBuiltInSections(open, query);
const {
entries: fileSearchEntries,
loading: fileSearchLoading,
error: fileSearchError,
openFile,
} = useWorkspaceFileSearch({
enabled: open && (scope === "files" || Boolean(query.trim())),
query,
});
const fileSections = useMemo<CommandCenterResultSection[]>(() => {
const results = fileSearchEntries.map<CommandCenterFileResult>((entry) => ({
kind: "file",
id: `file:${entry.path}`,
filePath: entry.path,
title: entry.name,
subtitle: entry.directory,
searchText: entry.path.toLowerCase(),
run: () => openFile(entry.path),
}));
return [{ id: "files", rank: 4, title: t("shell.commandCenter.files"), results }];
}, [fileSearchEntries, openFile, t]);
const contributionSections = useMemo(
() => buildContributionSections(snapshot.contributions, query),
[query, snapshot.contributions],
);
const projection = useMemo(
() => projectCommandCenterRows([...contributionSections, ...builtInSections]),
[builtInSections, contributionSections],
() =>
projectCommandCenterRows(
scope === "files"
? fileSections
: [...contributionSections, ...fileSections, ...builtInSections],
),
[builtInSections, contributionSections, fileSections, scope],
);
const resolvedActiveId = preserveActiveResultId(activeId, projection.selectableResults);
@@ -239,6 +283,10 @@ function useCommandCenterState(): CommandCenterState {
close();
return true;
}
if (pressed === "Backspace" && !query.trim() && scope) {
setScope(null);
return true;
}
if (pressed === "Enter") {
const selected = results.find((result) => result.id === resolvedActiveId);
if (!selected) return false;
@@ -251,7 +299,7 @@ function useCommandCenterState(): CommandCenterState {
setActiveId(moveActiveResultId(resolvedActiveId, results, direction));
return true;
},
[close, open, projection.selectableResults, resolvedActiveId, select],
[close, open, projection.selectableResults, query, resolvedActiveId, scope, select, setScope],
);
useEffect(() => {
@@ -277,6 +325,8 @@ function useCommandCenterState(): CommandCenterState {
return {
open,
scope,
clearScope: () => setScope(null),
query,
setQuery,
activeId: resolvedActiveId,
@@ -285,6 +335,8 @@ function useCommandCenterState(): CommandCenterState {
rowIndexByResultId: projection.rowIndexByResultId,
offsets: projection.offsets,
inputRef,
fileSearchLoading,
fileSearchError,
close,
select,
key,
@@ -303,7 +355,10 @@ const ResultRow = memo(function ResultRow({ result, active, onSelect }: ResultRo
result.kind === "contribution" && result.contribution.presentation.kind === "choice"
? result.contribution.presentation
: null;
const accessibilityLabel = choice?.path.join(" › ");
const accessibilityLabel =
result.kind === "file"
? [result.title, result.subtitle].filter(Boolean).join(" ")
: choice?.path.join(" › ");
const accessibilityState = useMemo(
() => (isNative && choice ? { selected: choice.selected } : undefined),
[choice],
@@ -329,7 +384,9 @@ const ResultRow = memo(function ResultRow({ result, active, onSelect }: ResultRo
accessibilityLabel={accessibilityLabel}
accessibilityState={accessibilityState}
aria-pressed={isWeb ? choice?.selected : undefined}
testID={choice?.testId}
testID={
result.kind === "file" ? `command-center-file-row-${result.filePath}` : choice?.testId
}
>
<ResultContent result={result} />
</Pressable>
@@ -337,6 +394,28 @@ const ResultRow = memo(function ResultRow({ result, active, onSelect }: ResultRo
});
function ResultContent({ result }: { result: CommandCenterResult }) {
if (result.kind === "file") {
return (
<View style={styles.rowContent}>
<View style={styles.rowMain}>
<View style={styles.iconSlot} testID="command-center-file-icon">
<MaterialFileIcon fileName={result.title} size={16} />
</View>
<Text style={styles.fileLine} numberOfLines={1} testID="command-center-file-line">
<Text style={styles.fileName} testID="command-center-file-name">
{result.title}
</Text>
{result.subtitle ? (
<Text style={styles.filePath} testID="command-center-file-path">
{" "}
{result.subtitle}
</Text>
) : null}
</Text>
</View>
</View>
);
}
if (result.kind === "agent") {
const agent = result.agent;
return (
@@ -545,8 +624,24 @@ export function CommandCenter() {
);
const keyExtractor = useCallback((row: CommandCenterListRow) => row.key, []);
const empty = useMemo(
() => <Text style={styles.emptyText}>{t("shell.commandCenter.noMatches")}</Text>,
[t],
() =>
state.fileSearchError || state.fileSearchLoading ? null : (
<Text style={styles.emptyText}>{t("shell.commandCenter.noMatches")}</Text>
),
[state.fileSearchError, state.fileSearchLoading, t],
);
const fileSearchError = useMemo(
() =>
state.fileSearchError ? (
<Text
accessibilityLiveRegion="polite"
style={styles.errorText}
testID="command-center-file-search-error"
>
{t("common.errors.error")}: {state.fileSearchError}
</Text>
) : null,
[state.fileSearchError, t],
);
const commonListProps = {
data: state.rows,
@@ -554,6 +649,8 @@ export function CommandCenter() {
keyExtractor,
getItemLayout,
ListEmptyComponent: empty,
style: styles.results,
testID: "command-center-results",
keyboardShouldPersistTaps: KEYBOARD_SHOULD_PERSIST_TAPS,
showsVerticalScrollIndicator: false,
initialNumToRender: 12,
@@ -606,7 +703,10 @@ export function CommandCenter() {
keyboardBlurBehavior="restore"
accessible={false}
>
<View style={styles.bottomSheetHeader}>
<View style={[styles.bottomSheetHeader, styles.searchRow]} testID="command-center-header">
{state.scope === "files" ? (
<ScopeChip label={t("shell.commandCenter.files")} onRemove={state.clearScope} />
) : null}
<ThemedBottomSheetTextInput
testID="command-center-input"
ref={bottomSheetInputRef}
@@ -614,13 +714,22 @@ export function CommandCenter() {
onChangeText={state.setQuery}
onKeyPress={keyPress}
onSubmitEditing={submit}
placeholder={t("shell.commandCenter.placeholder")}
style={styles.input}
placeholder={
state.scope === "files"
? t("shell.commandCenter.filePlaceholder")
: t("shell.commandCenter.placeholder")
}
style={[styles.input, styles.growingInput]}
autoCapitalize="none"
autoCorrect={false}
autoFocus
/>
<FileSearchLoadingIndicator
loading={state.fileSearchLoading}
label={t("shell.commandCenter.searchingFiles")}
/>
</View>
{fileSearchError}
<BottomSheetFlatList ref={bottomSheetListRef} {...commonListProps} />
</IsolatedBottomSheetModal>
);
@@ -632,20 +741,32 @@ export function CommandCenter() {
<View style={styles.overlay}>
<Pressable style={styles.backdrop} onPress={state.close} />
<View ref={setWebOverlayScope} testID="command-center-panel" style={styles.panel}>
<View style={styles.header}>
<View style={[styles.header, styles.searchRow]} testID="command-center-header">
{state.scope === "files" ? (
<ScopeChip label={t("shell.commandCenter.files")} onRemove={state.clearScope} />
) : null}
<ThemedTextInput
testID="command-center-input"
ref={state.inputRef}
value={state.query}
onChangeText={state.setQuery}
placeholder={t("shell.commandCenter.placeholder")}
style={styles.input}
placeholder={
state.scope === "files"
? t("shell.commandCenter.filePlaceholder")
: t("shell.commandCenter.placeholder")
}
style={[styles.input, styles.growingInput]}
autoCapitalize="none"
autoCorrect={false}
autoFocus
/>
<FileSearchLoadingIndicator
loading={state.fileSearchLoading}
label={t("shell.commandCenter.searchingFiles")}
/>
</View>
<FlatList ref={listRef} style={styles.results} {...commonListProps} />
{fileSearchError}
<FlatList ref={listRef} {...commonListProps} />
</View>
</View>
</Modal>
@@ -653,6 +774,41 @@ export function CommandCenter() {
);
}
function FileSearchLoadingIndicator({ loading, label }: { loading: boolean; label: string }) {
return (
<View style={styles.fileSearchStatus} testID="command-center-file-search-status">
{loading ? (
<View
accessibilityLabel={label}
accessibilityRole="progressbar"
testID="command-center-file-search-loading"
>
<ThemedLoadingSpinner size={14} />
</View>
) : null}
</View>
);
}
// The chip sits at the input's own height and type size so it reads as the scope of the field
// rather than a badge dropped into the query. Its 28px matches the input's line box, so showing or
// dropping the scope cannot resize the header.
function ScopeChip({ label, onRemove }: { label: string; onRemove(): void }) {
return (
<Pressable
accessibilityRole="button"
accessibilityLabel={label}
onPress={onRemove}
style={styles.scopeChip}
testID="command-center-files-scope"
>
<ThemedFolder size={14} strokeWidth={2.2} />
<Text style={styles.scopeChipLabel}>{label}</Text>
<ThemedX size={12} strokeWidth={2.2} />
</Pressable>
);
}
const styles = StyleSheet.create((theme) => ({
overlay: {
flex: 1,
@@ -663,6 +819,7 @@ const styles = StyleSheet.create((theme) => ({
backdrop: { ...StyleSheet.absoluteFillObject, backgroundColor: "rgba(0, 0, 0, 0.5)" },
panel: {
width: 640,
height: 560,
maxWidth: "92%",
maxHeight: "80%",
borderWidth: 1,
@@ -690,7 +847,29 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foreground,
outlineWidth: 0,
},
results: { flexGrow: 0 },
growingInput: { flex: 1, minWidth: 0 },
searchRow: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2] },
fileSearchStatus: { width: 14, height: 14, alignItems: "center", justifyContent: "center" },
scopeChip: {
// Stretching to the row rather than setting a height keeps the chip exactly as tall as the
// input's line box, so showing or dropping the scope can never resize the header.
alignSelf: "stretch",
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1.5],
marginRight: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius.md,
borderWidth: 1,
borderColor: theme.colors.borderAccent,
backgroundColor: theme.colors.surface2,
},
scopeChipLabel: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: "500",
},
results: { flex: 1 },
sectionLabel: {
paddingHorizontal: theme.spacing[4],
paddingBottom: theme.spacing[2],
@@ -726,6 +905,9 @@ const styles = StyleSheet.create((theme) => ({
lineHeight: 18,
flexShrink: 1,
},
fileLine: { flex: 1, minWidth: 0, fontSize: theme.fontSize.sm, lineHeight: 20 },
fileName: { color: theme.colors.foreground },
filePath: { color: theme.colors.foregroundMuted },
subtitle: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, lineHeight: 16 },
iconSlot: { width: 16, height: 20, alignItems: "center", justifyContent: "center" },
rowShortcut: { flexShrink: 0 },
@@ -755,6 +937,14 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
errorText: {
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
color: theme.colors.statusDanger,
fontSize: theme.fontSize.sm,
},
sheetBackground: { backgroundColor: theme.colors.surface0 },
sheetHandle: { backgroundColor: theme.colors.palette.zinc[600] },
titledSection: { height: 32, justifyContent: "flex-end" },
@@ -20,6 +20,16 @@ export interface CommandCenterAgentResult {
run(): void;
}
export interface CommandCenterFileResult {
kind: "file";
id: string;
filePath: string;
title: string;
subtitle: string;
searchText: string;
run(): void;
}
export interface CommandCenterContributionResult {
kind: "contribution";
id: string;
@@ -31,6 +41,7 @@ export interface CommandCenterContributionResult {
export type CommandCenterResult =
| CommandCenterWorkspaceResult
| CommandCenterAgentResult
| CommandCenterFileResult
| CommandCenterContributionResult;
export interface CommandCenterResultSection {
@@ -78,6 +89,7 @@ function contributionSearchText(contribution: CommandCenterContribution): string
function resultHeight(result: CommandCenterResult): number {
if (result.kind === "workspace" || result.kind === "agent") return 56;
if (result.kind === "file") return 36;
if (result.contribution.presentation.kind === "action") {
return result.contribution.presentation.subtitle ? 56 : 36;
}
@@ -0,0 +1,15 @@
export interface WorkspaceFileSearchEntry {
path: string;
name: string;
directory: string;
}
export function describeWorkspaceFilePath(path: string): WorkspaceFileSearchEntry {
const normalized = path.replace(/\\/g, "/");
const separator = normalized.lastIndexOf("/");
return {
path: normalized,
name: separator >= 0 ? normalized.slice(separator + 1) : normalized,
directory: separator >= 0 ? normalized.slice(0, separator) : "",
};
}
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { describeWorkspaceFilePath } from "./workspace-file-search-model";
describe("describeWorkspaceFilePath", () => {
it("separates a workspace-relative file into its row labels", () => {
expect(describeWorkspaceFilePath("src/components/message.tsx")).toEqual({
path: "src/components/message.tsx",
name: "message.tsx",
directory: "src/components",
});
});
it("normalizes Windows separators before opening or presenting a file", () => {
expect(describeWorkspaceFilePath("src\\components\\message.tsx")).toEqual({
path: "src/components/message.tsx",
name: "message.tsx",
directory: "src/components",
});
});
it("keeps root files free of a redundant directory label", () => {
expect(describeWorkspaceFilePath("package.json")).toEqual({
path: "package.json",
name: "package.json",
directory: "",
});
});
});
@@ -0,0 +1,148 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { openWorkspaceFileFromExplorer } from "@/screens/workspace/workspace-file-open-command";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { usePanelStore } from "@/stores/panel-store";
import { useSessionStore } from "@/stores/session-store";
import { useWorkspaceDirectory } from "@/stores/session-store-hooks";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import { clearCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
import {
describeWorkspaceFilePath,
type WorkspaceFileSearchEntry,
} from "./workspace-file-search-model";
interface DirectorySuggestionEntry {
path: string;
}
const FILE_SEARCH_DEBOUNCE_MS = 100;
const FILE_SEARCH_LIMIT = 100;
interface WorkspaceFileSearchState {
sourceKey: string | null;
requestKey: string | null;
entries: readonly WorkspaceFileSearchEntry[];
loading: boolean;
error: string | null;
}
const EMPTY_STATE: WorkspaceFileSearchState = {
sourceKey: null,
requestKey: null,
entries: [],
loading: false,
error: null,
};
function errorMessage(error: unknown): string {
if (!(error instanceof Error)) return String(error);
if (error.name !== "DaemonRpcError") return error.message;
return error.message.replace(/ requestType=\S+(?: code=\S+)?$/, "");
}
function describeFileEntries(
entries: readonly DirectorySuggestionEntry[],
): WorkspaceFileSearchEntry[] {
return entries.map(({ path }) => describeWorkspaceFilePath(path));
}
export function useWorkspaceFileSearch(input: { enabled: boolean; query: string }): {
entries: readonly WorkspaceFileSearchEntry[];
loading: boolean;
error: string | null;
openFile(path: string): void;
} {
const selection = useActiveWorkspaceSelection();
const serverId = selection?.serverId ?? null;
const workspaceId = selection?.workspaceId ?? null;
const cwd = useWorkspaceDirectory(serverId, workspaceId);
const client = useSessionStore((state) =>
serverId ? (state.sessions[serverId]?.client ?? null) : null,
);
const [state, setState] = useState<WorkspaceFileSearchState>(EMPTY_STATE);
const sourceKey = useMemo(
() => (serverId && workspaceId && cwd && client ? `${serverId}\0${workspaceId}\0${cwd}` : null),
[client, cwd, serverId, workspaceId],
);
const requestKey = useMemo(
() => (input.enabled && sourceKey ? `${sourceKey}\0${input.query}` : null),
[input.enabled, input.query, sourceKey],
);
useEffect(() => {
if (!requestKey || !client || !cwd) {
setState(EMPTY_STATE);
return;
}
const activeClient = client;
const activeCwd = cwd;
let cancelled = false;
setState((previous) => ({
sourceKey,
requestKey,
entries: previous.sourceKey === sourceKey ? previous.entries : [],
loading: true,
error: null,
}));
async function search(): Promise<void> {
try {
const payload = await activeClient.getDirectorySuggestions({
cwd: activeCwd,
query: input.query,
includeFiles: true,
includeDirectories: false,
limit: FILE_SEARCH_LIMIT,
});
if (cancelled) return;
setState({
sourceKey,
requestKey,
entries: payload.error ? [] : describeFileEntries(payload.entries),
loading: false,
error: payload.error ?? null,
});
} catch (error) {
if (!cancelled) {
setState({
sourceKey,
requestKey,
entries: [],
loading: false,
error: errorMessage(error),
});
}
}
}
const timer = setTimeout(() => void search(), input.query.trim() ? FILE_SEARCH_DEBOUNCE_MS : 0);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [client, cwd, input.query, requestKey, sourceKey]);
const openFile = useCallback(
(path: string) => {
if (!serverId || !workspaceId) return;
clearCommandCenterFocusRestoreElement();
openWorkspaceFileFromExplorer({
filePath: path,
persistenceKey: buildWorkspaceTabPersistenceKey({ serverId, workspaceId }),
showMobileAgent: usePanelStore.getState().showMobileAgent,
openWorkspaceTabFocused: useWorkspaceLayoutStore.getState().openTabFocused,
focusWorkspaceTab: useWorkspaceLayoutStore.getState().focusTab,
});
},
[serverId, workspaceId],
);
return {
entries: requestKey && state.sourceKey === sourceKey ? state.entries : [],
loading: Boolean(requestKey) && (state.requestKey !== requestKey || state.loading),
error: state.requestKey === requestKey ? state.error : null,
openFile,
};
}
@@ -69,7 +69,7 @@ import {
import { Shortcut } from "@/components/ui/shortcut";
import { useKeyboardShortcutsAvailable } from "@/keyboard/availability";
import { getIsElectronRuntime } from "@/constants/layout";
import { isWeb } from "@/constants/platform";
import { isNative, isWeb } from "@/constants/platform";
import { pickDirectory } from "@/desktop/pick-directory";
import { useFetchQuery } from "@/data/query";
import { getOpenProjectFailureReason, registerProjectDescriptor } from "@/hooks/open-project";
@@ -222,12 +222,12 @@ function pageTitle(page: AddProjectPage): string {
}
}
function pagePlaceholder(page: AddProjectPage): string {
type AddProjectInputPage = Exclude<AddProjectPage, { kind: "method" }>;
function pagePlaceholder(page: AddProjectInputPage): string {
switch (page.kind) {
case "host":
return "Search hosts...";
case "method":
return "Search methods...";
case "directory-search":
return "Search directories or enter a path...";
case "github-search":
@@ -240,7 +240,7 @@ function pagePlaceholder(page: AddProjectPage): string {
}
}
function pageInput(page: AddProjectPage): string {
function pageInput(page: AddProjectInputPage): string {
return page.kind === "new-directory-name" ? page.name : page.query;
}
@@ -374,7 +374,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
const inputRef = useRef<TextInput>(null);
const submissionInFlightRef = useRef(false);
const browseInFlightRef = useRef(false);
const query = page.kind === "new-directory-name" ? "" : page.query;
const query = page.kind === "new-directory-name" || page.kind === "method" ? "" : page.query;
const [debouncedQuery, setDebouncedQuery] = useState(query);
useEffect(() => {
@@ -599,23 +599,15 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
}
if (page.kind === "method") {
if (!host) return [];
const normalized = page.query.trim().toLowerCase();
return buildAddProjectMethods(host)
.filter(
(method) =>
!normalized ||
method.label.toLowerCase().includes(normalized) ||
method.description.toLowerCase().includes(normalized),
)
.map((method) => ({
id: method.id,
title: method.label,
subtitle: method.description,
icon: methodIcon(method.id),
disabled: method.disabled,
testID: `add-project-flow-method-${method.id}`,
select: () => selectMethod(method.id),
}));
return buildAddProjectMethods(host).map((method) => ({
id: method.id,
title: method.label,
subtitle: method.description,
icon: methodIcon(method.id),
disabled: method.disabled,
testID: `add-project-flow-method-${method.id}`,
select: () => selectMethod(method.id),
}));
}
if (page.kind === "directory-search") {
return pathOptions.map((option) => {
@@ -863,21 +855,41 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
) : null}
</View>
</View>
<ThemedTextInput
key={page.kind}
ref={inputRef}
value={pageInput(page)}
onChangeText={handleInputChange}
onKeyPress={isWeb ? undefined : handleNativeKeyPress}
onSubmitEditing={isWeb ? undefined : submitActive}
placeholder={pagePlaceholder(page)}
style={styles.input}
autoCapitalize="none"
autoCorrect={false}
editable={!isSubmitting}
returnKeyType="go"
testID="add-project-flow-input"
/>
{page.kind === "method" && isNative ? (
// Native hardware-keyboard events need a focused responder even without a visible field.
<TextInput
key={page.kind}
ref={inputRef}
onKeyPress={handleNativeKeyPress}
onSubmitEditing={submitActive}
showSoftInputOnFocus={false}
caretHidden
contextMenuHidden
accessible={false}
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
pointerEvents="none"
style={styles.keyboardCapture}
testID="add-project-flow-keyboard-capture"
/>
) : null}
{page.kind !== "method" ? (
<ThemedTextInput
key={page.kind}
ref={inputRef}
value={pageInput(page)}
onChangeText={handleInputChange}
onKeyPress={isWeb ? undefined : handleNativeKeyPress}
onSubmitEditing={isWeb ? undefined : submitActive}
placeholder={pagePlaceholder(page)}
style={styles.input}
autoCapitalize="none"
autoCorrect={false}
editable={!isSubmitting}
returnKeyType="go"
testID="add-project-flow-input"
/>
) : null}
</View>
<ScrollView
style={styles.results}
@@ -1012,6 +1024,12 @@ const styles = StyleSheet.create((theme) => ({
paddingVertical: theme.spacing[1],
outlineStyle: "none",
} as object,
keyboardCapture: {
position: "absolute",
width: 1,
height: 1,
opacity: 0,
},
results: { flexGrow: 0, flexShrink: 1, minHeight: 0 },
resultsContent: { paddingVertical: theme.spacing[2] },
row: {
@@ -0,0 +1,55 @@
import { Buffer } from "buffer";
import { type ReactElement, useMemo } from "react";
import { Image, type ImageStyle, type StyleProp, View } from "react-native";
import { SvgCss } from "react-native-svg/css";
const SVG_MIME_TYPE = "image/svg+xml";
const ICO_MIME_TYPES = new Set(["image/x-icon", "image/vnd.microsoft.icon"]);
const SVG_CONTAINER = { overflow: "hidden" } as const;
export interface ProjectIconImageProps {
dataUri: string;
fallback: ReactElement;
style: StyleProp<ImageStyle>;
}
function parseDataUri(dataUri: string): { mimeType: string; payload: string } | null {
const match = /^data:([^;,]+);base64,(.*)$/s.exec(dataUri);
if (!match) return null;
const [, mimeType, payload] = match;
return mimeType && payload ? { mimeType: mimeType.toLowerCase(), payload } : null;
}
function ignoreSvgParseError() {
// Invalid repository SVGs use the same fallback as unsupported icon formats.
}
export function ProjectIconImage({ dataUri, fallback, style }: ProjectIconImageProps) {
const source = useMemo(() => ({ uri: dataUri }), [dataUri]);
const parts = useMemo(() => parseDataUri(dataUri), [dataUri]);
const svgXml = useMemo(
() =>
parts?.mimeType === SVG_MIME_TYPE
? Buffer.from(parts.payload, "base64").toString("utf8")
: null,
[parts],
);
const svgContainerStyle = useMemo(() => [style, SVG_CONTAINER], [style]);
if (svgXml) {
return (
<View style={svgContainerStyle}>
<SvgCss
xml={svgXml}
width="100%"
height="100%"
fallback={fallback}
onError={ignoreSvgParseError}
/>
</View>
);
}
if (parts && ICO_MIME_TYPES.has(parts.mimeType)) return fallback;
return <Image source={source} style={style} />;
}
@@ -0,0 +1,8 @@
import { useMemo } from "react";
import { Image } from "react-native";
import type { ProjectIconImageProps } from "@/components/project-icon-image";
export function ProjectIconImage({ dataUri, style }: ProjectIconImageProps) {
const source = useMemo(() => ({ uri: dataUri }), [dataUri]);
return <Image source={source} style={style} />;
}
@@ -1,5 +1,6 @@
import { useMemo } from "react";
import { Image, type StyleProp, Text, type TextStyle, View } from "react-native";
import { type StyleProp, Text, type TextStyle, View } from "react-native";
import { ProjectIconImage } from "@/components/project-icon-image";
import { deriveIdentityColorName, identityColor } from "@/styles/identity-colors";
const WHITE_TEXT = { color: "#ffffff" } as const;
@@ -28,6 +29,7 @@ export function projectIconRadius(size: number): number {
* width/height/radius/centering block, which is how the radius drifted apart in the first
* place — pass a `size` and the shape follows.
*/
export function ProjectIconView({
iconDataUri,
initial,
@@ -41,7 +43,6 @@ export function ProjectIconView({
size: number;
textStyle: StyleProp<TextStyle>;
}) {
const imageSource = useMemo(() => ({ uri: iconDataUri ?? "" }), [iconDataUri]);
// The uploaded image is sized but never clipped — see projectIconRadius.
const box = useMemo(() => ({ width: size, height: size }), [size]);
const fallbackStyles = useMemo(
@@ -55,12 +56,18 @@ export function ProjectIconView({
);
const textStyles = useMemo(() => [textStyle, WHITE_TEXT], [textStyle]);
if (iconDataUri) {
return <Image source={imageSource} style={box} />;
}
return (
<View style={fallbackStyles}>
<Text style={textStyles}>{initial}</Text>
</View>
const fallback = useMemo(
() => (
<View style={fallbackStyles}>
<Text style={textStyles}>{initial}</Text>
</View>
),
[fallbackStyles, initial, textStyles],
);
return iconDataUri ? (
<ProjectIconImage dataUri={iconDataUri} fallback={fallback} style={box} />
) : (
fallback
);
}
@@ -1,11 +1,13 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Image, Text, View } from "react-native";
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { createNameId } from "mnemonic-id";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { FileDropZone } from "@/components/file-drop/file-drop-zone";
import { Composer } from "@/composer";
import { ProjectIconView } from "@/components/project-icon-view";
import { ICON_SIZE } from "@/styles/theme";
import { useToast } from "@/contexts/toast-context";
import { useAgentInputDraft } from "@/composer/draft/input-draft";
import { useProjectIcon } from "@/projects/icons";
@@ -29,7 +31,6 @@ import { requireWorkspaceDirectory } from "@/utils/workspace-directory";
import { navigateToAgent } from "@/utils/navigate-to-agent";
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
import type { MessagePayload } from "@/composer/types";
import { projectIconRadius } from "@/components/project-icon-view";
function toProjectIconDataUri(icon: { mimeType: string; data: string } | null): string | null {
if (!icon) {
@@ -381,7 +382,6 @@ export function WorkspaceSetupDialog() {
const placeholderLabel = projectIconPlaceholderLabelFromDisplayName(workspaceTitle);
const placeholderInitial = placeholderLabel.charAt(0).toUpperCase();
const iconSource = useMemo(() => (iconDataUri ? { uri: iconDataUri } : null), [iconDataUri]);
const agentControlsWithDisabled = useMemo(
() =>
composerState
@@ -396,19 +396,19 @@ export function WorkspaceSetupDialog() {
const subtitleContent = useMemo(
() => (
<View style={styles.subtitleRow}>
{iconSource ? (
<Image source={iconSource} style={styles.projectIcon} />
) : (
<View style={styles.projectIconFallback}>
<Text style={styles.projectIconFallbackText}>{placeholderInitial}</Text>
</View>
)}
<ProjectIconView
iconDataUri={iconDataUri}
initial={placeholderInitial}
projectViewKey={sourceDirectory}
size={ICON_SIZE.md}
textStyle={styles.projectIconFallbackText}
/>
<Text style={styles.projectTitle} numberOfLines={1}>
{workspaceTitle}
</Text>
</View>
),
[iconSource, placeholderInitial, workspaceTitle],
[iconDataUri, placeholderInitial, sourceDirectory, workspaceTitle],
);
const sheetHeader = useMemo<SheetHeader>(
@@ -461,22 +461,7 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center",
gap: theme.spacing[2],
},
// A user's uploaded mark is sized, never clipped — see projectIconRadius.
projectIcon: {
width: theme.iconSize.md,
height: theme.iconSize.md,
},
projectIconFallback: {
width: theme.iconSize.md,
height: theme.iconSize.md,
borderRadius: projectIconRadius(theme.iconSize.md),
borderWidth: 1,
borderColor: theme.colors.border,
alignItems: "center",
justifyContent: "center",
},
projectIconFallbackText: {
color: theme.colors.foregroundMuted,
fontSize: 9,
},
projectTitle: {
@@ -201,7 +201,9 @@ export function useKeyboardShortcuts({
setCommandCenterFocusRestoreElement(browserFocusRestoreElement);
}
}
useKeyboardShortcutsStore.getState().setCommandCenterOpen(action.nextOpen);
useKeyboardShortcutsStore
.getState()
.setCommandCenterOpen(action.nextOpen, action.scope ?? null);
return true;
}
case "shortcuts-dialog-toggle":
+5 -1
View File
@@ -190,7 +190,11 @@ describe("translation resources", () => {
expect(en.shell.menu.toggleSidebar).toBe("Toggle sidebar");
expect(en.shell.menu.open).toBe("Open menu");
expect(en.shell.menu.close).toBe("Close menu");
expect(en.shell.commandCenter.placeholder).toBe("Search commands, workspaces, and agents...");
expect(en.shell.commandCenter.placeholder).toBe(
"Search commands, files, workspaces, and agents...",
);
expect(en.shell.commandCenter.filePlaceholder).toBe("Search files...");
expect(en.shell.commandCenter.files).toBe("Files");
expect(en.shell.commandCenter.noMatches).toBe("No matches");
expect(en.shell.commandCenter.actions).toBe("Actions");
expect(en.shell.commandCenter.workspaces).toBe("Workspaces");
+8 -4
View File
@@ -54,9 +54,12 @@ export const ar: TranslationResources = {
close: "إغلاق القائمة",
},
commandCenter: {
placeholder: "ابحث في الأوامر ومساحات العمل والوكلاء...",
placeholder: "ابحث في الأوامر والملفات ومساحات العمل والوكلاء...",
filePlaceholder: "البحث في الملفات...",
searchingFiles: "جارٍ البحث في الملفات...",
noMatches: "لا توجد مباريات",
actions: "الإجراءات",
files: "الملفات",
workspaces: "مساحات العمل",
agents: "الوكلاء",
newAgent: "وكيل جديد",
@@ -1912,10 +1915,10 @@ export const ar: TranslationResources = {
resetAll: "إعادة ضبط الكل",
},
sections: {
navigation: "ملاحة",
general: "عام",
workspaces: "المشاريع ومساحات العمل",
tabsPanes: "علامات التبويب والأجزاء",
projects: "المشاريع",
panels: "لوحات",
layout: "التخطيط",
agentInput: "إدخال Agent",
},
help: {
@@ -1943,6 +1946,7 @@ export const ar: TranslationResources = {
moveTabDown: "حرك علامة التبويب لأسفل",
closePane: "إغلاق الجزء",
newTerminal: "محطة جديدة",
searchFiles: "البحث في الملفات",
toggleCommandCenter: "تبديل مركز القيادة",
showKeyboardShortcuts: "إظهار اختصارات لوحة المفاتيح",
toggleLeftSidebar: "تبديل الشريط الجانبي الأيسر",
+8 -4
View File
@@ -52,9 +52,12 @@ export const en = {
close: "Close menu",
},
commandCenter: {
placeholder: "Search commands, workspaces, and agents...",
placeholder: "Search commands, files, workspaces, and agents...",
filePlaceholder: "Search files...",
searchingFiles: "Searching files...",
noMatches: "No matches",
actions: "Actions",
files: "Files",
workspaces: "Workspaces",
agents: "Agents",
newAgent: "New agent",
@@ -1922,10 +1925,10 @@ export const en = {
resetAll: "Reset all",
},
sections: {
navigation: "Navigation",
general: "General",
workspaces: "Projects & Workspaces",
tabsPanes: "Tabs & Panes",
projects: "Projects",
panels: "Panels",
layout: "Layout",
agentInput: "Agent Input",
},
help: {
@@ -1953,6 +1956,7 @@ export const en = {
moveTabDown: "Move tab down",
closePane: "Close pane",
newTerminal: "New terminal",
searchFiles: "Search files",
toggleCommandCenter: "Toggle command center",
showKeyboardShortcuts: "Show keyboard shortcuts",
toggleLeftSidebar: "Toggle left sidebar",
+8 -4
View File
@@ -54,9 +54,12 @@ export const es: TranslationResources = {
close: "Cerrar menú",
},
commandCenter: {
placeholder: "Buscar comandos, espacios de trabajo y agentes...",
placeholder: "Buscar comandos, archivos, espacios de trabajo y agentes...",
filePlaceholder: "Buscar archivos...",
searchingFiles: "Buscando archivos...",
noMatches: "No hay coincidencias",
actions: "Comportamiento",
files: "Archivos",
workspaces: "Espacios de trabajo",
agents: "Agentes",
newAgent: "Nuevo agente",
@@ -1960,10 +1963,10 @@ export const es: TranslationResources = {
resetAll: "Restablecer todo",
},
sections: {
navigation: "Navegación",
general: "General",
workspaces: "Proyectos y espacios de trabajo",
tabsPanes: "Pestañas y paneles",
projects: "Proyectos",
panels: "Paneles",
layout: "Diseño",
agentInput: "EntradaAgent",
},
help: {
@@ -1991,6 +1994,7 @@ export const es: TranslationResources = {
moveTabDown: "Mover pestaña hacia abajo",
closePane: "Cerrar panel",
newTerminal: "Nueva terminal",
searchFiles: "Buscar archivos",
toggleCommandCenter: "Alternar centro de comando",
showKeyboardShortcuts: "Mostrar atajos de teclado",
toggleLeftSidebar: "Alternar barra lateral izquierda",
+8 -4
View File
@@ -55,9 +55,12 @@ export const fr: TranslationResources = {
close: "Fermer le menu",
},
commandCenter: {
placeholder: "Rechercher des commandes, espaces de travail et agents...",
placeholder: "Rechercher des commandes, fichiers, espaces de travail et agents...",
filePlaceholder: "Rechercher des fichiers...",
searchingFiles: "Recherche de fichiers...",
noMatches: "Aucune correspondance",
actions: "Actes",
files: "Fichiers",
workspaces: "Espaces de travail",
agents: "Agents",
newAgent: "Nouvel agent",
@@ -1963,10 +1966,10 @@ export const fr: TranslationResources = {
resetAll: "Tout réinitialiser",
},
sections: {
navigation: "Navigation",
general: "Général",
workspaces: "Projets et espaces de travail",
tabsPanes: "Onglets et volets",
projects: "Projets",
panels: "Panneaux",
layout: "Disposition",
agentInput: "EntréeAgent",
},
help: {
@@ -1994,6 +1997,7 @@ export const fr: TranslationResources = {
moveTabDown: "Déplacer l'onglet vers le bas",
closePane: "Fermer le volet",
newTerminal: "Nouvelle borne",
searchFiles: "Rechercher des fichiers",
toggleCommandCenter: "Basculer le centre de commande",
showKeyboardShortcuts: "Afficher les raccourcis clavier",
toggleLeftSidebar: "Basculer la barre latérale gauche",
+8 -4
View File
@@ -54,9 +54,12 @@ export const ja: TranslationResources = {
close: "メニューを閉じる",
},
commandCenter: {
placeholder: "コマンド、ワークスペース、エージェントを検索...",
placeholder: "コマンド、ファイル、ワークスペース、エージェントを検索...",
filePlaceholder: "ファイルを検索...",
searchingFiles: "ファイルを検索中...",
noMatches: "一致なし",
actions: "アクション",
files: "ファイル",
workspaces: "ワークスペース",
agents: "エージェント",
newAgent: "新しいエージェント",
@@ -1928,10 +1931,10 @@ export const ja: TranslationResources = {
resetAll: "すべてリセット",
},
sections: {
navigation: "ナビゲーション",
general: "一般",
workspaces: "プロジェクト&ワークスペース",
tabsPanes: "タブ&ペイン",
projects: "プロジェクト",
panels: "パネル",
layout: "レイアウト",
agentInput: "エージェント入力",
},
help: {
@@ -1959,6 +1962,7 @@ export const ja: TranslationResources = {
moveTabDown: "タブを下に移動",
closePane: "ペインを閉じる",
newTerminal: "新しいターミナル",
searchFiles: "ファイルを検索",
toggleCommandCenter: "コマンドセンターを切り替え",
showKeyboardShortcuts: "キーボードショートカットを表示",
toggleLeftSidebar: "左サイドバーを切り替え",
+8 -4
View File
@@ -54,9 +54,12 @@ export const ko: TranslationResources = {
close: "메뉴 닫기",
},
commandCenter: {
placeholder: "명령, 워크스페이스 및 에이전트 검색...",
placeholder: "명령, 파일, 워크스페이스 및 에이전트 검색...",
filePlaceholder: "파일 검색...",
searchingFiles: "파일 검색 중...",
noMatches: "일치 항목 없음",
actions: "작업",
files: "파일",
workspaces: "워크스페이스",
agents: "에이전트",
newAgent: "새 에이전트",
@@ -1923,10 +1926,10 @@ export const ko: TranslationResources = {
resetAll: "모두 재설정",
},
sections: {
navigation: "탐색",
general: "일반",
workspaces: "프로젝트 및 워크스페이스",
tabsPanes: "탭 및 창",
projects: "프로젝트",
panels: "패널",
layout: "레이아웃",
agentInput: "에이전트 입력",
},
help: {
@@ -1954,6 +1957,7 @@ export const ko: TranslationResources = {
moveTabDown: "탭을 아래로 이동",
closePane: "창 닫기",
newTerminal: "새 터미널",
searchFiles: "파일 검색",
toggleCommandCenter: "명령 센터 토글",
showKeyboardShortcuts: "키보드 단축키 표시",
toggleLeftSidebar: "왼쪽 사이드바 토글",
+8 -4
View File
@@ -54,9 +54,12 @@ export const ptBR: TranslationResources = {
close: "Fechar menu",
},
commandCenter: {
placeholder: "Buscar comandos, espaços de trabalho e agentes...",
placeholder: "Buscar comandos, arquivos, espaços de trabalho e agentes...",
filePlaceholder: "Pesquisar arquivos...",
searchingFiles: "Pesquisando arquivos...",
noMatches: "Nenhuma correspondência",
actions: "Ações",
files: "Arquivos",
workspaces: "Espaços de trabalho",
agents: "Agentes",
newAgent: "Novo agente",
@@ -1943,10 +1946,10 @@ export const ptBR: TranslationResources = {
resetAll: "Redefinir tudo",
},
sections: {
navigation: "Navegação",
general: "Geral",
workspaces: "Projetos e workspaces",
tabsPanes: "Abas e painéis",
projects: "Projetos",
panels: "Painéis",
layout: "Layout",
agentInput: "Entrada do agente",
},
help: {
@@ -1974,6 +1977,7 @@ export const ptBR: TranslationResources = {
moveTabDown: "Mover aba para baixo",
closePane: "Fechar painel",
newTerminal: "Novo terminal",
searchFiles: "Pesquisar arquivos",
toggleCommandCenter: "Alternar central de comandos",
showKeyboardShortcuts: "Mostrar atalhos de teclado",
toggleLeftSidebar: "Alternar barra lateral esquerda",
+8 -4
View File
@@ -54,9 +54,12 @@ export const ru: TranslationResources = {
close: "Закрыть меню",
},
commandCenter: {
placeholder: "Поиск команд, рабочих пространств и агентов...",
placeholder: "Поиск команд, файлов, рабочих пространств и агентов...",
filePlaceholder: "Поиск файлов...",
searchingFiles: "Поиск файлов...",
noMatches: "Нет совпадений",
actions: "Действия",
files: "Файлы",
workspaces: "Рабочие пространства",
agents: "Агенты",
newAgent: "Новый агент",
@@ -1950,10 +1953,10 @@ export const ru: TranslationResources = {
resetAll: "Сбросить все",
},
sections: {
navigation: "Навигация",
general: "Общие",
workspaces: "Проекты и рабочие пространства",
tabsPanes: "Вкладки и панели",
projects: "Проекты",
panels: "Панели",
layout: "Макет",
agentInput: "Вход Agent",
},
help: {
@@ -1981,6 +1984,7 @@ export const ru: TranslationResources = {
moveTabDown: "Переместить вкладку вниз",
closePane: "Закрыть панель",
newTerminal: "Новый терминал",
searchFiles: "Поиск файлов",
toggleCommandCenter: "Переключить командный центр",
showKeyboardShortcuts: "Показать сочетания клавиш",
toggleLeftSidebar: "Переключить левую боковую панель",
+8 -4
View File
@@ -54,9 +54,12 @@ export const zhCN: TranslationResources = {
close: "关闭菜单",
},
commandCenter: {
placeholder: "搜索命令、工作区和 Agent...",
placeholder: "搜索命令、文件、工作区和 Agent...",
filePlaceholder: "搜索文件...",
searchingFiles: "正在搜索文件...",
noMatches: "没有匹配项",
actions: "操作",
files: "文件",
workspaces: "工作区",
agents: "Agents",
newAgent: "新建 Agent",
@@ -1890,10 +1893,10 @@ export const zhCN: TranslationResources = {
resetAll: "全部重置",
},
sections: {
navigation: "导航",
general: "通用",
workspaces: "项目和工作区",
tabsPanes: "标签和窗格",
projects: "项目",
panels: "面板",
layout: "布局",
agentInput: "Agent 输入",
},
help: {
@@ -1921,6 +1924,7 @@ export const zhCN: TranslationResources = {
moveTabDown: "向下移动标签",
closePane: "关闭窗格",
newTerminal: "新建终端",
searchFiles: "搜索文件",
toggleCommandCenter: "切换命令中心",
showKeyboardShortcuts: "显示键盘快捷键",
toggleLeftSidebar: "切换左侧边栏",
+1
View File
@@ -41,6 +41,7 @@ export type KeyboardActionId =
| "sidebar.toggle.both"
| "settings.toggle"
| "command-center.toggle"
| "command-center.files"
| "shortcuts.dialog.toggle"
| "workspace.terminal.new"
| "workspace.new"
@@ -9,6 +9,7 @@ import {
parseBindingChord,
resolveKeyboardShortcut,
resolveShortcutKeysForAction,
SHORTCUT_HELP_ROW_ORDER,
UNASSIGNED_COMBO,
type ChordState,
type KeyboardShortcutContext,
@@ -149,16 +150,16 @@ describe("keyboard-shortcuts", () => {
action: "workspace.new",
},
{
name: "matches Cmd+P to switch project on mac",
name: "matches Cmd+P to search workspace files on mac",
event: { key: "p", code: "KeyP", metaKey: true },
context: { isMac: true, commandCenterOpen: false },
action: "workspace.project.pick",
action: "command-center.files",
},
{
name: "matches Ctrl+P to switch project on non-mac",
name: "matches Ctrl+P to search workspace files on non-mac",
event: { key: "p", code: "KeyP", ctrlKey: true },
context: { isMac: false, commandCenterOpen: false, focusScope: "other" },
action: "workspace.project.pick",
action: "command-center.files",
},
{
name: "matches question-mark shortcut to toggle the shortcuts dialog",
@@ -781,20 +782,63 @@ describe("keyboard-shortcut help sections", () => {
it("returns stable i18n keys for section titles and help rows", () => {
const sections = buildKeyboardShortcutHelpSections({ isMac: true, isDesktop: true });
const projects = sections.find((section) => section.id === "projects");
const panels = sections.find((section) => section.id === "panels");
const workspaces = sections.find((section) => section.id === "workspaces");
const layout = sections.find((section) => section.id === "layout");
const openProject = findRow(sections, "new-agent");
const cycleAgentMode = findRow(sections, "cycle-agent-mode");
const showShortcuts = findRow(sections, "show-shortcuts");
expect(projects?.titleKey).toBe("settings.shortcuts.sections.projects");
expect(panels?.titleKey).toBe("settings.shortcuts.sections.panels");
expect(workspaces?.titleKey).toBe("settings.shortcuts.sections.workspaces");
expect(layout?.titleKey).toBe("settings.shortcuts.sections.layout");
expect(openProject?.labelKey).toBe("settings.shortcuts.help.openProject");
expect(openProject?.label).toBe("Open project");
expect(cycleAgentMode?.labelKey).toBe("settings.shortcuts.help.cycleAgentMode");
expect(showShortcuts?.noteKey).toBe("settings.shortcuts.helpNotes.showKeyboardShortcuts");
});
it("gives every help row an explicit place in its section's order", () => {
const platforms = [
{ isMac: true, isDesktop: true },
{ isMac: false, isDesktop: true },
{ isMac: true, isDesktop: false },
{ isMac: false, isDesktop: false },
];
const unplaced: string[] = [];
for (const platform of platforms) {
for (const section of buildKeyboardShortcutHelpSections(platform)) {
for (const row of section.rows) {
if (SHORTCUT_HELP_ROW_ORDER[section.id].includes(row.id)) continue;
unplaced.push(`${section.id}:${row.id}`);
}
}
}
expect(unplaced).toEqual([]);
});
it("leads the general section with the command center and file search", () => {
const sections = buildKeyboardShortcutHelpSections({ isMac: true, isDesktop: true });
expect(sections[0]?.id).toBe("general");
expect(sections[0]?.rows.slice(0, 2).map((row) => row.id)).toEqual([
"toggle-command-center",
"search-files",
]);
});
it("reuses the project-picker binding ids for rebindable file search", () => {
expect(getBindingIdForAction("search-files", { isMac: true, isDesktop: true })).toBe(
"workspace-project-pick-cmd-p-mac",
);
expect(getBindingIdForAction("search-files", { isMac: false, isDesktop: true })).toBe(
"workspace-project-pick-ctrl-p-non-mac",
);
expect(
findRow(buildKeyboardShortcutHelpSections({ isMac: true, isDesktop: true }), "search-files")
?.chord,
).not.toBeNull();
});
it("does not expose Enter send behavior as rebindable shortcut rows", () => {
const sections = buildKeyboardShortcutHelpSections({ isMac: true, isDesktop: true });
+125 -71
View File
@@ -49,7 +49,7 @@ export interface KeyboardShortcutHelpRow {
noteKey?: string;
}
export type ShortcutSectionId = "navigation" | "tabs-panes" | "projects" | "panels" | "agent-input";
export type ShortcutSectionId = "general" | "workspaces" | "tabs-panes" | "layout" | "agent-input";
export interface KeyboardShortcutHelpSection {
id: ShortcutSectionId;
@@ -124,22 +124,81 @@ export interface ChordState {
// --- Constants ---
// Sections are listed in this order, most-reached-for first.
const SHORTCUT_HELP_SECTION_ORDER: readonly ShortcutSectionId[] = [
"general",
"workspaces",
"tabs-panes",
"layout",
"agent-input",
];
const SHORTCUT_HELP_SECTION_TITLES: Record<ShortcutSectionId, string> = {
navigation: "Navigation",
general: "General",
workspaces: "Projects & Workspaces",
"tabs-panes": "Tabs & Panes",
projects: "Projects",
panels: "Panels",
layout: "Layout",
"agent-input": "Agent Input",
};
const SHORTCUT_HELP_SECTION_LABEL_KEYS: Record<ShortcutSectionId, string> = {
navigation: "settings.shortcuts.sections.navigation",
general: "settings.shortcuts.sections.general",
workspaces: "settings.shortcuts.sections.workspaces",
"tabs-panes": "settings.shortcuts.sections.tabsPanes",
projects: "settings.shortcuts.sections.projects",
panels: "settings.shortcuts.sections.panels",
layout: "settings.shortcuts.sections.layout",
"agent-input": "settings.shortcuts.sections.agentInput",
};
// Rows render in this order rather than in binding-definition order, so the shortcut someone opens
// this sheet to find sits at the top of its section. Every help id must appear here; the unit test
// fails when a new shortcut is added without a place, instead of silently sinking to the bottom.
export const SHORTCUT_HELP_ROW_ORDER: Record<ShortcutSectionId, readonly string[]> = {
general: [
"toggle-command-center",
"search-files",
"show-shortcuts",
"toggle-settings",
"cycle-theme",
],
workspaces: [
"new-agent",
"new-workspace",
"workspace-jump-index",
"workspace-prev",
"workspace-next",
"pin-workspace",
"archive-workspace",
],
"tabs-panes": [
"workspace-tab-new",
"workspace-terminal-new",
"workspace-tab-close-current",
"workspace-tab-jump-index",
"workspace-tab-prev",
"workspace-tab-next",
"workspace-pane-split-right",
"workspace-pane-split-down",
"workspace-pane-focus-left",
"workspace-pane-focus-right",
"workspace-pane-focus-up",
"workspace-pane-focus-down",
"workspace-pane-move-tab-left",
"workspace-pane-move-tab-right",
"workspace-pane-move-tab-up",
"workspace-pane-move-tab-down",
"workspace-pane-close",
],
layout: ["toggle-left-sidebar", "toggle-right-sidebar", "toggle-both-sidebars", "toggle-focus"],
"agent-input": [
"focus-message-input",
"cycle-agent-mode",
"voice-toggle",
"dictation-toggle",
"agent-interrupt",
"voice-mute-toggle",
],
};
const SHORTCUT_HELP_LABEL_KEYS: Record<string, string> = {
"new-agent": "settings.shortcuts.help.openProject",
"new-workspace": "settings.shortcuts.help.newWorkspace",
@@ -165,6 +224,7 @@ const SHORTCUT_HELP_LABEL_KEYS: Record<string, string> = {
"workspace-pane-move-tab-down": "settings.shortcuts.help.moveTabDown",
"workspace-pane-close": "settings.shortcuts.help.closePane",
"workspace-terminal-new": "settings.shortcuts.help.newTerminal",
"search-files": "settings.shortcuts.help.searchFiles",
"toggle-command-center": "settings.shortcuts.help.toggleCommandCenter",
"show-shortcuts": "settings.shortcuts.help.showKeyboardShortcuts",
"toggle-left-sidebar": "settings.shortcuts.help.toggleLeftSidebar",
@@ -200,7 +260,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: true },
help: {
id: "new-agent",
section: "projects",
section: "workspaces",
label: "Open project",
},
},
@@ -211,7 +271,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: false, terminal: false },
help: {
id: "new-agent",
section: "projects",
section: "workspaces",
label: "Open project",
},
},
@@ -224,7 +284,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: true, commandCenter: false },
help: {
id: "new-workspace",
section: "projects",
section: "workspaces",
label: "New workspace",
},
},
@@ -235,32 +295,32 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: false, commandCenter: false, terminal: false },
help: {
id: "new-workspace",
section: "projects",
section: "workspaces",
label: "New workspace",
},
},
// --- Switch project (New Workspace screen) ---
// --- Search files (switch project on the New Workspace screen) ---
{
id: "workspace-project-pick-cmd-p-mac",
action: "workspace.project.pick",
action: "command-center.files",
combo: "Cmd+P",
when: { mac: true, commandCenter: false },
help: {
id: "switch-project",
section: "projects",
label: "Switch project",
id: "search-files",
section: "general",
label: "Search files",
},
},
{
id: "workspace-project-pick-ctrl-p-non-mac",
action: "workspace.project.pick",
action: "command-center.files",
combo: "Ctrl+P",
when: { mac: false, commandCenter: false, terminal: false },
help: {
id: "switch-project",
section: "projects",
label: "Switch project",
id: "search-files",
section: "general",
label: "Search files",
},
},
@@ -274,7 +334,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: true, commandCenter: false },
help: {
id: "archive-workspace",
section: "projects",
section: "workspaces",
label: "Archive workspace",
},
},
@@ -287,7 +347,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: false, commandCenter: false, terminal: false },
help: {
id: "archive-workspace",
section: "projects",
section: "workspaces",
label: "Archive workspace",
},
},
@@ -300,7 +360,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: true, commandCenter: false },
help: {
id: "pin-workspace",
section: "projects",
section: "workspaces",
label: "Pin chat",
},
},
@@ -311,7 +371,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: false, commandCenter: false, terminal: false },
help: {
id: "pin-workspace",
section: "projects",
section: "workspaces",
label: "Pin chat",
},
},
@@ -382,7 +442,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
payload: { type: "index" },
help: {
id: "workspace-jump-index",
section: "navigation",
section: "workspaces",
label: "Jump to workspace",
defaultDisplayKeys: ["mod", "1-9"],
},
@@ -395,7 +455,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
payload: { type: "index" },
help: {
id: "workspace-jump-index",
section: "navigation",
section: "workspaces",
label: "Jump to workspace",
defaultDisplayKeys: ["mod", "1-9"],
},
@@ -408,7 +468,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
payload: { type: "index" },
help: {
id: "workspace-jump-index",
section: "navigation",
section: "workspaces",
label: "Jump to workspace",
defaultDisplayKeys: ["alt", "1-9"],
},
@@ -423,7 +483,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
payload: { type: "index" },
help: {
id: "workspace-tab-jump-index",
section: "navigation",
section: "tabs-panes",
label: "Jump to tab",
defaultDisplayKeys: ["mod", "alt", "1-9"],
},
@@ -436,7 +496,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
payload: { type: "index" },
help: {
id: "workspace-tab-jump-index",
section: "navigation",
section: "tabs-panes",
label: "Jump to tab",
defaultDisplayKeys: ["alt", "1-9"],
},
@@ -449,7 +509,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
payload: { type: "index" },
help: {
id: "workspace-tab-jump-index",
section: "navigation",
section: "tabs-panes",
label: "Jump to tab",
defaultDisplayKeys: ["alt", "shift", "1-9"],
},
@@ -464,7 +524,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
payload: { type: "delta", delta: -1 },
help: {
id: "workspace-prev",
section: "navigation",
section: "workspaces",
label: "Previous workspace",
},
},
@@ -476,7 +536,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
payload: { type: "delta", delta: -1 },
help: {
id: "workspace-prev",
section: "navigation",
section: "workspaces",
label: "Previous workspace",
},
},
@@ -488,7 +548,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
payload: { type: "delta", delta: 1 },
help: {
id: "workspace-next",
section: "navigation",
section: "workspaces",
label: "Next workspace",
},
},
@@ -500,7 +560,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
payload: { type: "delta", delta: 1 },
help: {
id: "workspace-next",
section: "navigation",
section: "workspaces",
label: "Next workspace",
},
},
@@ -512,7 +572,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
payload: { type: "delta", delta: -1 },
help: {
id: "workspace-prev",
section: "navigation",
section: "workspaces",
label: "Previous workspace",
},
},
@@ -524,7 +584,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
payload: { type: "delta", delta: 1 },
help: {
id: "workspace-next",
section: "navigation",
section: "workspaces",
label: "Next workspace",
},
},
@@ -538,7 +598,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
payload: { type: "delta", delta: -1 },
help: {
id: "workspace-tab-prev",
section: "navigation",
section: "tabs-panes",
label: "Previous tab",
},
},
@@ -550,7 +610,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
payload: { type: "delta", delta: 1 },
help: {
id: "workspace-tab-next",
section: "navigation",
section: "tabs-panes",
label: "Next tab",
},
},
@@ -686,7 +746,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: true, commandCenter: false },
help: {
id: "workspace-terminal-new",
section: "panels",
section: "tabs-panes",
label: "New terminal",
},
},
@@ -697,7 +757,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: false, commandCenter: false, terminal: false },
help: {
id: "workspace-terminal-new",
section: "panels",
section: "tabs-panes",
label: "New terminal",
},
},
@@ -710,7 +770,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: true },
help: {
id: "toggle-command-center",
section: "panels",
section: "general",
label: "Toggle command center",
},
},
@@ -721,7 +781,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: false, terminal: false },
help: {
id: "toggle-command-center",
section: "panels",
section: "general",
label: "Toggle command center",
},
},
@@ -735,7 +795,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { focusScope: "other" },
help: {
id: "show-shortcuts",
section: "panels",
section: "general",
label: "Show keyboard shortcuts",
defaultDisplayKeys: ["?"],
note: "Available when focus is not in a text field or terminal.",
@@ -750,7 +810,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: true },
help: {
id: "toggle-left-sidebar",
section: "panels",
section: "layout",
label: "Toggle left sidebar",
},
},
@@ -761,7 +821,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: false, commandCenter: false, terminal: false },
help: {
id: "toggle-left-sidebar",
section: "panels",
section: "layout",
label: "Toggle left sidebar",
},
},
@@ -772,7 +832,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: true, commandCenter: false },
help: {
id: "toggle-right-sidebar",
section: "panels",
section: "layout",
label: "Toggle right sidebar",
},
},
@@ -783,7 +843,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: false, commandCenter: false, terminal: false },
help: {
id: "toggle-right-sidebar",
section: "panels",
section: "layout",
label: "Toggle right sidebar",
},
},
@@ -802,7 +862,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: true, commandCenter: false },
help: {
id: "toggle-both-sidebars",
section: "panels",
section: "layout",
label: "Toggle both sidebars",
},
},
@@ -813,7 +873,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: false, commandCenter: false, terminal: false },
help: {
id: "toggle-both-sidebars",
section: "panels",
section: "layout",
label: "Toggle both sidebars",
},
},
@@ -826,7 +886,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: true, commandCenter: false },
help: {
id: "toggle-settings",
section: "panels",
section: "general",
label: "Toggle settings",
},
},
@@ -837,7 +897,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: false, commandCenter: false, terminal: false },
help: {
id: "toggle-settings",
section: "panels",
section: "general",
label: "Toggle settings",
},
},
@@ -850,7 +910,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: true, commandCenter: false },
help: {
id: "toggle-focus",
section: "panels",
section: "layout",
label: "Toggle focus mode",
},
},
@@ -861,7 +921,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: false, commandCenter: false, terminal: false },
help: {
id: "toggle-focus",
section: "panels",
section: "layout",
label: "Toggle focus mode",
},
},
@@ -874,7 +934,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: true, commandCenter: false },
help: {
id: "cycle-theme",
section: "panels",
section: "general",
label: "Cycle theme",
},
},
@@ -885,7 +945,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: false, commandCenter: false, terminal: false },
help: {
id: "cycle-theme",
section: "panels",
section: "general",
label: "Cycle theme",
},
},
@@ -1504,13 +1564,9 @@ export function buildKeyboardShortcutHelpSections(
bindings: readonly ParsedShortcutBinding[] = DEFAULT_BINDINGS,
): KeyboardShortcutHelpSection[] {
const seenRows = new Set<string>();
const rowsBySection = new Map<ShortcutSectionId, KeyboardShortcutHelpRow[]>([
["navigation", []],
["tabs-panes", []],
["projects", []],
["panels", []],
["agent-input", []],
]);
const rowsBySection = new Map<ShortcutSectionId, KeyboardShortcutHelpRow[]>(
SHORTCUT_HELP_SECTION_ORDER.map((sectionId) => [sectionId, []]),
);
for (const binding of bindings) {
const help = binding.help;
@@ -1540,19 +1596,17 @@ export function buildKeyboardShortcutHelpSections(
});
}
const sectionOrder: ShortcutSectionId[] = [
"navigation",
"tabs-panes",
"projects",
"panels",
"agent-input",
];
return sectionOrder.flatMap((sectionId) => {
return SHORTCUT_HELP_SECTION_ORDER.flatMap((sectionId) => {
const rows = rowsBySection.get(sectionId) ?? [];
if (rows.length === 0) {
return [];
}
const order = SHORTCUT_HELP_ROW_ORDER[sectionId];
const rank = (row: KeyboardShortcutHelpRow) => {
const index = order.indexOf(row.id);
return index === -1 ? order.length : index;
};
rows.sort((left, right) => rank(left) - rank(right));
return [
{
id: sectionId,
@@ -351,6 +351,24 @@ describe("routeKeyboardShortcut — callbacks and pickers", () => {
});
describe("routeKeyboardShortcut — toggle dialogs", () => {
it("opens the command center scoped to files from a workspace", () => {
expect(
routeKeyboardShortcut({ action: "command-center.files", payload: null }, makeCtx()),
).toEqual<ShortcutAction>({ kind: "command-center-toggle", nextOpen: true, scope: "files" });
});
it("leaves the file-search shortcut to the project-picker host outside a workspace", () => {
expect(
routeKeyboardShortcut(
{ action: "command-center.files", payload: null },
makeCtx({ pathname: "/settings" }),
),
).toEqual<ShortcutAction>({
kind: "dispatch",
action: { id: "workspace.project.pick", scope: "workspace" },
});
});
it("opens the command center when closed", () => {
expect(
routeKeyboardShortcut(
+6 -1
View File
@@ -32,7 +32,7 @@ export type ShortcutAction =
| { kind: "router-push"; route: string }
| { kind: "open-project-picker" }
| { kind: "callback"; name: ShortcutCallbackName }
| { kind: "command-center-toggle"; nextOpen: boolean }
| { kind: "command-center-toggle"; nextOpen: boolean; scope?: "files" }
| { kind: "shortcuts-dialog-toggle"; nextOpen: boolean };
const NONE: ShortcutAction = { kind: "none" };
@@ -198,6 +198,11 @@ export function routeKeyboardShortcut(
return routeSettingsToggle(ctx);
case "command-center.toggle":
return { kind: "command-center-toggle", nextOpen: !ctx.commandCenterOpen };
case "command-center.files":
if (parseHostWorkspaceRouteFromPathname(ctx.pathname)) {
return { kind: "command-center-toggle", nextOpen: true, scope: "files" };
}
return dispatch({ id: "workspace.project.pick", scope: "workspace" });
case "shortcuts.dialog.toggle":
return { kind: "shortcuts-dialog-toggle", nextOpen: !ctx.shortcutsDialogOpen };
default:
@@ -99,12 +99,12 @@ describe("filterShortcutHelpSections", () => {
});
it("keeps every row of a section whose own title matches", () => {
const projects = filter("projects").find((section) => section.id === "projects");
const allProjects = buildKeyboardShortcutHelpSections(MAC_DESKTOP).find(
(section) => section.id === "projects",
const matched = filter("layout").find((section) => section.id === "layout");
const all = buildKeyboardShortcutHelpSections(MAC_DESKTOP).find(
(section) => section.id === "layout",
);
expect(projects?.rows).toHaveLength(allProjects?.rows.length ?? 0);
expect(matched?.rows).toHaveLength(all?.rows.length ?? 0);
});
it("drops a section whose rows all miss", () => {
@@ -4,6 +4,7 @@ import { useKeyboardShortcutsStore } from "./keyboard-shortcuts-store";
beforeEach(() => {
useKeyboardShortcutsStore.setState({
commandCenterOpen: false,
commandCenterScope: null,
shortcutsDialogOpen: false,
capturingShortcut: false,
altDown: false,
@@ -19,6 +20,20 @@ describe("keyboard-shortcuts-store", () => {
expect(useKeyboardShortcutsStore.getState().commandCenterOpen).toBe(true);
});
it("opens the command center with a scope and clears it when closed", () => {
useKeyboardShortcutsStore.getState().setCommandCenterOpen(true, "files");
expect(useKeyboardShortcutsStore.getState()).toMatchObject({
commandCenterOpen: true,
commandCenterScope: "files",
});
useKeyboardShortcutsStore.getState().setCommandCenterOpen(false);
expect(useKeyboardShortcutsStore.getState()).toMatchObject({
commandCenterOpen: false,
commandCenterScope: null,
});
});
it("toggles shortcut capture state", () => {
expect(useKeyboardShortcutsStore.getState().capturingShortcut).toBe(false);
useKeyboardShortcutsStore.getState().setCapturingShortcut(true);
@@ -3,8 +3,11 @@ import type { SidebarShortcutWorkspaceTarget } from "@/utils/sidebar-shortcuts";
const SHORTCUT_BADGE_DELAY_MS = 150;
export type CommandCenterScope = "files" | null;
interface KeyboardShortcutsState {
commandCenterOpen: boolean;
commandCenterScope: CommandCenterScope;
shortcutsDialogOpen: boolean;
capturingShortcut: boolean;
altDown: boolean;
@@ -13,7 +16,8 @@ interface KeyboardShortcutsState {
/** Sidebar-visible workspace targets (up to 9), in top-to-bottom visual order. */
sidebarShortcutWorkspaceTargets: SidebarShortcutWorkspaceTarget[];
setCommandCenterOpen: (open: boolean) => void;
setCommandCenterOpen: (open: boolean, scope?: CommandCenterScope) => void;
setCommandCenterScope: (scope: CommandCenterScope) => void;
setShortcutsDialogOpen: (open: boolean) => void;
setCapturingShortcut: (capturing: boolean) => void;
setAltDown: (down: boolean) => void;
@@ -47,6 +51,7 @@ function updateBadgeTimer(
export const useKeyboardShortcutsStore = create<KeyboardShortcutsState>((set, get) => ({
commandCenterOpen: false,
commandCenterScope: null,
shortcutsDialogOpen: false,
capturingShortcut: false,
altDown: false,
@@ -54,7 +59,9 @@ export const useKeyboardShortcutsStore = create<KeyboardShortcutsState>((set, ge
showShortcutBadges: false,
sidebarShortcutWorkspaceTargets: [],
setCommandCenterOpen: (open) => set({ commandCenterOpen: open }),
setCommandCenterOpen: (open, scope = null) =>
set({ commandCenterOpen: open, commandCenterScope: open ? scope : null }),
setCommandCenterScope: (scope) => set({ commandCenterScope: scope }),
setShortcutsDialogOpen: (open) => set({ shortcutsDialogOpen: open }),
setCapturingShortcut: (capturing) => set({ capturingShortcut: capturing }),
setAltDown: (down) => {
+50 -2
View File
@@ -2,7 +2,8 @@ import { strict as assert } from "node:assert";
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import { afterEach, describe, it } from "vitest";
import { HubHttpClient } from "./client.js";
import { HubHttpClient } from "./hub-client/index.js";
import { HubCommandError } from "./error.js";
const servers: Array<ReturnType<typeof createServer>> = [];
@@ -89,11 +90,56 @@ describe("Hub HTTP client", () => {
["/api/v1/projects", "/api/v1/daemons/enrollment-tokens"],
);
});
it("renders file-aware Hub validation issues without exposing credentials or response bodies", async () => {
const requests: Array<{ url: string | undefined; body: string }> = [];
const origin = await startServer(
() => ({
status: 422,
contentType: "application/problem+json",
body: {
type: "https://hub.test/problems/invalid-configuration-bundle",
title: "Invalid configuration bundle",
status: 422,
detail: "Correct the canonical Hub bundle files. operator-secret",
code: "invalid_configuration_bundle",
requestId: "request-1",
issues: [
{
path: [".paseo/workflows/answer.yml", "steps", "work", "agent"],
message: "unknown named agent operator-secret",
},
],
},
}),
requests,
);
const hub = new HubHttpClient();
await assert.rejects(
hub.validateConfiguration({
origin,
apiKey: "operator-secret",
projectSlug: "studio",
files: [{ path: ".paseo/hub.yml", content: "sensitive bundle content" }],
}),
(error: unknown) => {
assert.ok(error instanceof HubCommandError);
assert.equal(error.message.includes("operator-secret"), false);
assert.equal(error.details?.includes("operator-secret"), false);
assert.equal(error.message.includes("sensitive bundle content"), false);
assert.equal(error.details?.includes("sensitive bundle content"), false);
assert.match(error.details ?? "", /\.paseo\/workflows\/answer\.yml\.steps\.work\.agent/u);
return true;
},
);
});
});
interface TestResponse {
status: number;
body: unknown;
contentType?: string;
}
async function startServer(
@@ -109,7 +155,9 @@ async function startServer(
request.on("end", () => {
requests.push({ url: request.url, body });
const configured = responseFor(request.url);
response.writeHead(configured.status, { "content-type": "application/json" });
response.writeHead(configured.status, {
"content-type": configured.contentType ?? "application/json",
});
response.end(JSON.stringify(configured.body));
});
});
-296
View File
@@ -1,296 +0,0 @@
import { z } from "zod";
import type { HubDeployPartial } from "./deploy-input.js";
import { HubCommandError } from "./error.js";
const activationUrlSchema = z.url({ protocol: /^https?$/u });
const authorizationSchema = z
.object({
deviceCode: z.string().min(32),
userCode: z.string().min(1),
verificationUri: activationUrlSchema,
verificationUriComplete: activationUrlSchema,
expiresAt: z.string().datetime(),
interval: z.number().int().min(1),
})
.strict();
const authorizationPollSchema = z.discriminatedUnion("status", [
z.object({ status: z.literal("pending"), interval: z.number().int().min(1) }).strict(),
z.object({ status: z.literal("slow_down"), interval: z.number().int().min(1) }).strict(),
z
.object({
status: z.literal("authorized"),
interval: z.number().int().min(1),
credential: z.string().min(32),
organizationId: z.string().min(1),
})
.strict(),
z.object({ status: z.literal("denied"), interval: z.number().int().min(1) }).strict(),
z.object({ status: z.literal("expired"), interval: z.number().int().min(1) }).strict(),
z.object({ status: z.literal("disclosed"), interval: z.number().int().min(1) }).strict(),
z.object({ status: z.literal("retry_later") }).strict(),
]);
const projectSchema = z
.object({ id: z.string().uuid(), slug: z.string().min(1), name: z.string().min(1) })
.strict();
const projectsResponseSchema = z.object({ projects: z.array(projectSchema) }).strict();
const installResponseSchema = z
.object({
projectSlug: z.string().min(1),
version: z.number().int().positive(),
versionId: z.string().uuid(),
active: z.literal(true),
})
.strict();
const validationResponseSchema = z
.object({ projectSlug: z.string().min(1), valid: z.literal(true) })
.strict();
const enrollmentTokenSchema = z
.object({ token: z.string().min(32), expiresAt: z.string().datetime() })
.strict();
const issuePathSchema = z.union([z.string(), z.array(z.union([z.string(), z.number()]))]);
const fieldIssueSchema = z.object({
field: z.string().optional(),
path: issuePathSchema.optional(),
message: z.string(),
});
const problemSchema = z.object({
type: z.string().optional(),
title: z.string().optional(),
status: z.number().int().optional(),
detail: z.string().optional(),
instance: z.string().optional(),
errors: z
.union([z.array(fieldIssueSchema), z.record(z.string(), z.array(z.string()))])
.optional(),
issues: z.array(fieldIssueSchema).optional(),
});
export type CliAuthorization = z.infer<typeof authorizationSchema>;
export type CliAuthorizationPoll = z.infer<typeof authorizationPollSchema>;
export type HubProject = z.infer<typeof projectSchema>;
export type HubInstallResult = z.infer<typeof installResponseSchema>;
export type HubValidationResult = z.infer<typeof validationResponseSchema>;
interface HubConfigurationInput {
origin: string;
apiKey: string;
projectSlug: string;
yaml: string;
partials?: readonly HubDeployPartial[];
}
export class HubHttpClient {
async startCliAuthorization(origin: string): Promise<CliAuthorization> {
return this.request({
origin,
path: "/api/v1/cli-authorizations",
method: "POST",
body: {},
successStatus: 201,
schema: authorizationSchema,
failureMessage: "Hub CLI login could not be started",
});
}
async pollCliAuthorization(
origin: string,
deviceCode: string,
timeoutMilliseconds: number,
): Promise<CliAuthorizationPoll> {
try {
return await this.request({
origin,
path: "/api/v1/cli-authorizations/poll",
method: "POST",
body: { deviceCode },
successStatus: 200,
schema: authorizationPollSchema,
timeoutMilliseconds,
failureMessage: "Hub CLI login polling failed",
});
} catch (error) {
if (error instanceof HubCommandError && error.code === "HUB_NETWORK_ERROR") {
return { status: "retry_later" };
}
throw error;
}
}
async listProjects(origin: string, apiKey: string): Promise<HubProject[]> {
const response = await this.request({
origin,
path: "/api/v1/projects",
method: "GET",
apiKey,
successStatus: 200,
schema: projectsResponseSchema,
failureMessage: "Hub project listing failed",
});
return response.projects;
}
installConfiguration(input: HubConfigurationInput): Promise<HubInstallResult> {
return this.request({
origin: input.origin,
path: "/api/v1/configurations/install",
method: "POST",
apiKey: input.apiKey,
body: configurationBody(input),
successStatus: 201,
schema: installResponseSchema,
failureMessage: "Hub deployment failed",
});
}
validateConfiguration(input: HubConfigurationInput): Promise<HubValidationResult> {
return this.request({
origin: input.origin,
path: "/api/v1/configurations/validate",
method: "POST",
apiKey: input.apiKey,
body: configurationBody(input),
successStatus: 200,
schema: validationResponseSchema,
failureMessage: "Hub configuration validation failed",
});
}
async issueEnrollmentToken(origin: string, apiKey: string): Promise<string> {
const response = await this.request({
origin,
path: "/api/v1/daemons/enrollment-tokens",
method: "POST",
apiKey,
successStatus: 201,
schema: enrollmentTokenSchema,
failureMessage: "Hub daemon enrollment authorization failed",
});
return response.token;
}
private async request<T>(input: RequestInput<T>): Promise<T> {
const signal = AbortSignal.timeout(input.timeoutMilliseconds ?? 15_000);
let response: Response;
try {
response = await fetch(`${input.origin}${input.path}`, {
method: input.method,
headers: {
...(input.apiKey === undefined ? {} : { authorization: `Bearer ${input.apiKey}` }),
...(input.body === undefined ? {} : { "content-type": "application/json" }),
},
...(input.body === undefined ? {} : { body: JSON.stringify(input.body) }),
signal,
});
} catch {
throw new HubCommandError(
"HUB_NETWORK_ERROR",
`Could not reach Paseo Hub at ${input.origin}. Check the Hub URL and network connection.`,
);
}
if (response.status !== input.successStatus) {
throw await requestFailure(response, input.failureMessage, input.apiKey);
}
try {
return input.schema.parse(await response.json());
} catch {
throw new HubCommandError("HUB_INVALID_RESPONSE", "Hub returned a malformed response.");
}
}
}
interface RequestInput<T> {
origin: string;
path: string;
method: "GET" | "POST";
apiKey?: string;
body?: unknown;
successStatus: number;
schema: z.ZodType<T>;
timeoutMilliseconds?: number;
failureMessage: string;
}
function configurationBody(input: HubConfigurationInput): object {
return {
projectSlug: input.projectSlug,
yaml: input.yaml,
...(input.partials === undefined || input.partials.length === 0
? {}
: { partials: input.partials }),
};
}
async function requestFailure(
response: Response,
failureMessage: string,
apiKey: string | undefined,
): Promise<HubCommandError> {
const contentType = response.headers.get("content-type") ?? "";
if (!contentType.toLowerCase().includes("application/problem+json")) {
return new HubCommandError(
"HUB_REQUEST_FAILED",
`${failureMessage} with HTTP ${response.status}.`,
);
}
let body: unknown;
try {
body = await response.json();
} catch {
return new HubCommandError(
"HUB_INVALID_RESPONSE",
`Hub returned malformed problem details for HTTP ${response.status}.`,
);
}
const parsed = problemSchema.safeParse(body);
if (
!parsed.success ||
(parsed.data.status !== undefined && parsed.data.status !== response.status)
) {
return new HubCommandError(
"HUB_INVALID_RESPONSE",
`Hub returned nonconforming problem details for HTTP ${response.status}.`,
);
}
const title = parsed.data.title ?? `${failureMessage} with HTTP ${response.status}`;
const message = parsed.data.detail === undefined ? title : `${title}: ${parsed.data.detail}`;
const details = formatFieldIssues(parsed.data.errors, parsed.data.issues);
const code = response.status === 422 ? "HUB_VALIDATION_FAILED" : "HUB_REQUEST_FAILED";
return new HubCommandError(
code,
redactSecret(message, apiKey),
details === undefined ? undefined : redactSecret(details, apiKey),
);
}
function formatFieldIssues(
errors: z.infer<typeof problemSchema>["errors"],
issues: z.infer<typeof problemSchema>["issues"],
): string | undefined {
const fieldIssues = Array.isArray(errors) ? errors : issues;
if (fieldIssues !== undefined) {
const lines = fieldIssues.map((issue) => {
const field = issue.field ?? formatIssuePath(issue.path);
return field === undefined ? issue.message : `${field}: ${issue.message}`;
});
return lines.length === 0 ? undefined : lines.join("\n");
}
if (errors === undefined) return undefined;
const lines = Object.entries(errors).flatMap(([field, messages]) =>
messages.map((message: string) => `${field}: ${message}`),
);
return lines.length === 0 ? undefined : lines.join("\n");
}
function formatIssuePath(path: z.infer<typeof issuePathSchema> | undefined): string | undefined {
if (path === undefined || typeof path === "string") return path;
let formatted = "";
for (const segment of path) {
if (typeof segment === "number") formatted += `[${segment}]`;
else formatted += formatted.length === 0 ? segment : `.${segment}`;
}
return formatted || undefined;
}
function redactSecret(value: string, secret: string | undefined): string {
return secret === undefined ? value : value.split(secret).join("[redacted]");
}
+1 -1
View File
@@ -2,7 +2,7 @@ import type { Command } from "commander";
import { withOutput } from "../../output/index.js";
import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js";
import { resolveHubCredential, resolveHubOrigin } from "./authority.js";
import type { HubHttpClient } from "./client.js";
import type { HubHttpClient } from "./hub-client/index.js";
import type { HubCredentialStore } from "./credentials.js";
import type { HubDaemonConnection } from "./daemon-client.js";
import { withHubDaemon } from "./daemon-client.js";
@@ -0,0 +1,220 @@
import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { discoverHubBundle } from "./deploy-bundle.js";
const temporaryDirectories: string[] = [];
afterEach(async () => {
await Promise.all(
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })),
);
});
describe("Hub deployment bundle discovery", () => {
it("discovers the canonical bundle in deterministic path order", async () => {
const cwd = await canonicalProject();
await writeFile(
path.join(cwd, ".paseo", "workflows", "z-last.yml"),
workflow("z-last", "codex-safe"),
);
await expect(discoverHubBundle({ cwd, project: "studio-api" })).resolves.toEqual({
projectSlug: "studio-api",
workflowCount: 2,
files: [
{ path: ".paseo/hub.yml", content: hubResource },
{
path: ".paseo/workflows/answer.yml",
content: workflow("answer", "${{ paseo.inputs.agent }}", true),
},
{
path: ".paseo/workflows/partials/safety.md",
content: "Keep the request in paseo.prompt and evidence in paseo.context.\n",
},
{ path: ".paseo/workflows/z-last.yml", content: workflow("z-last", "codex-safe") },
],
});
});
it.each([
{
name: "missing hub.yml",
arrange: async (cwd: string) => {
await mkdir(path.join(cwd, ".paseo", "workflows"), { recursive: true });
},
code: "HUB_RESOURCE_MISSING",
message: ".paseo/hub.yml does not exist",
},
{
name: "missing workflow directory",
arrange: async (cwd: string) => {
await mkdir(path.join(cwd, ".paseo"), { recursive: true });
await writeFile(path.join(cwd, ".paseo", "hub.yml"), hubResource);
},
code: "HUB_WORKFLOW_DIRECTORY_MISSING",
message: ".paseo/workflows does not exist",
},
{
name: "empty workflow directory",
arrange: async (cwd: string) => {
await mkdir(path.join(cwd, ".paseo", "workflows"), { recursive: true });
await writeFile(path.join(cwd, ".paseo", "hub.yml"), hubResource);
},
code: "HUB_WORKFLOW_MISSING",
message: ".paseo/workflows must contain at least one direct-child .yml workflow",
},
{
name: "unsupported workflow extension",
arrange: async (cwd: string) => {
await mkdir(path.join(cwd, ".paseo", "workflows"), { recursive: true });
await writeFile(path.join(cwd, ".paseo", "hub.yml"), hubResource);
await writeFile(path.join(cwd, ".paseo", "workflows", "answer.yaml"), "name: answer\n");
},
code: "HUB_WORKFLOW_EXTENSION_UNSUPPORTED",
message: ".paseo/workflows/answer.yaml must use the .yml extension",
},
])("rejects $name before contacting Hub", async ({ arrange, code, message }) => {
const cwd = await temporaryDirectory();
await arrange(cwd);
await expect(discoverHubBundle({ cwd, project: "studio-api" })).rejects.toMatchObject({
code,
message: expect.stringContaining(message),
});
});
it("rejects prompt partial traversal with a path-specific diagnostic", async () => {
const cwd = await canonicalProject();
await writeFile(
path.join(cwd, ".paseo", "workflows", "answer.yml"),
workflow("answer", "codex-safe").replace(
" - text: ${{ paseo.prompt }}",
" - include: ../secret.md",
),
);
await expect(discoverHubBundle({ cwd, project: "studio-api" })).rejects.toMatchObject({
code: "HUB_PARTIAL_PATH_INVALID",
message: expect.stringContaining(".paseo/workflows/answer.yml"),
});
});
it("reports a referenced partial that is missing from the bundle", async () => {
const cwd = await canonicalProject();
await writeFile(
path.join(cwd, ".paseo", "workflows", "answer.yml"),
workflow("answer", "codex-safe").replace(
" - text: ${{ paseo.prompt }}",
" - include: partials/missing.md",
),
);
await expect(discoverHubBundle({ cwd, project: "studio-api" })).rejects.toMatchObject({
code: "HUB_BUNDLE_FILE_MISSING",
message: expect.stringContaining(".paseo/workflows/partials/missing.md"),
});
});
it("rejects nested workflow files instead of discovering a second layout", async () => {
const cwd = await canonicalProject();
await mkdir(path.join(cwd, ".paseo", "workflows", "nested"));
await writeFile(
path.join(cwd, ".paseo", "workflows", "nested", "other.yml"),
workflow("other", "codex-safe"),
);
await expect(discoverHubBundle({ cwd, project: "studio-api" })).rejects.toMatchObject({
code: "HUB_WORKFLOW_PATH_UNSUPPORTED",
message: expect.stringContaining(".paseo/workflows/nested"),
});
});
it("rejects symlinked workflow and partial paths without reading their targets", async () => {
const cwd = await canonicalProject();
const outside = path.join(cwd, "outside.yml");
await writeFile(outside, workflow("linked", "codex-safe"));
await symlink(outside, path.join(cwd, ".paseo", "workflows", "linked.yml"));
await expect(discoverHubBundle({ cwd, project: "studio-api" })).rejects.toMatchObject({
code: "HUB_BUNDLE_UNSAFE_PATH",
message: expect.stringContaining(".paseo/workflows/linked.yml"),
});
});
it("requires an explicit valid project slug outside the authored bundle", async () => {
const cwd = await canonicalProject();
await expect(discoverHubBundle({ cwd })).rejects.toMatchObject({
code: "HUB_PROJECT_REQUIRED",
});
await expect(discoverHubBundle({ cwd, project: "Studio API" })).rejects.toMatchObject({
code: "HUB_INVALID_PROJECT",
});
});
});
const hubResource = [
"environments:",
" studio:",
" kind: daemon",
" daemon: local",
" cwd: /workspace/studio",
"agents:",
" codex-safe:",
" provider: codex",
" model: gpt-5.5",
" thinkingOptionId: xhigh",
" options:",
" sandbox_workspace_write:",
" writable_roots: [/var/cache/npm]",
" network_access: false",
" claude:",
" provider: claude",
" mode: bypassPermissions",
"",
].join("\n");
function workflow(name: string, agent: string, include = false): string {
return [
`name: ${name}`,
"on: manual.run",
"max_runtime: 1h",
...(agent.includes("paseo.inputs")
? ["inputs:", " agent:", " type: string", " choices: [codex-safe, claude]"]
: []),
"steps:",
" - id: work",
" environment: studio",
" max_runtime: 30m",
" idle_timeout: 5m",
` agent: ${agent}`,
" prompt:",
...(include ? [" - include: partials/safety.md"] : []),
" - text: ${{ paseo.prompt }}",
"",
].join("\n");
}
async function canonicalProject(): Promise<string> {
const cwd = await temporaryDirectory();
const workflows = path.join(cwd, ".paseo", "workflows");
await mkdir(path.join(workflows, "partials"), { recursive: true });
await writeFile(path.join(cwd, ".paseo", "hub.yml"), hubResource);
await writeFile(
path.join(workflows, "answer.yml"),
workflow("answer", "${{ paseo.inputs.agent }}", true),
);
await writeFile(
path.join(workflows, "partials", "safety.md"),
"Keep the request in paseo.prompt and evidence in paseo.context.\n",
);
return cwd;
}
async function temporaryDirectory(): Promise<string> {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-hub-bundle-"));
temporaryDirectories.push(directory);
return directory;
}
@@ -0,0 +1,305 @@
import { lstat, readFile, readdir } from "node:fs/promises";
import path from "node:path";
import YAML from "yaml";
import { HubCommandError } from "./error.js";
const HUB_RESOURCE_PATH = ".paseo/hub.yml";
const LEGACY_TOML_PATH = ".paseo/hub.toml";
const WORKFLOW_DIRECTORY = ".paseo/workflows";
const PARTIAL_DIRECTORY = `${WORKFLOW_DIRECTORY}/partials`;
const PROJECT_SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
export interface HubBundleFile {
path: string;
content: string;
}
export interface HubDeployBundle {
projectSlug: string;
files: readonly HubBundleFile[];
workflowCount: number;
}
interface DiscoverHubBundleInput {
cwd: string;
project?: string;
}
export async function discoverHubBundle(input: DiscoverHubBundleInput): Promise<HubDeployBundle> {
const projectSlug = requireProjectSlug(input.project);
const root = path.resolve(input.cwd);
await rejectLegacyToml(root);
const resource = await readBundleFile(root, HUB_RESOURCE_PATH, {
missingCode: "HUB_RESOURCE_MISSING",
missingMessage: `${HUB_RESOURCE_PATH} does not exist. Run this command from the project root.`,
});
const workflowNames = await discoverWorkflowNames(root);
const workflows = await Promise.all(
workflowNames.map((name) => readBundleFile(root, `${WORKFLOW_DIRECTORY}/${name}`)),
);
const partialPaths = collectPartialPaths(workflows);
const partials = await Promise.all(partialPaths.map((file) => readBundleFile(root, file)));
const files = [resource, ...workflows, ...partials].toSorted(compareBundleFiles);
assertUniqueBundlePaths(files);
return { projectSlug, files, workflowCount: workflows.length };
}
function requireProjectSlug(project: string | undefined): string {
if (project === undefined) {
throw new HubCommandError(
"HUB_PROJECT_REQUIRED",
"Project is required. Pass --project <slug>.",
);
}
if (!PROJECT_SLUG_PATTERN.test(project)) {
throw new HubCommandError(
"HUB_INVALID_PROJECT",
"Project must be a bare slug such as my-project.",
);
}
return project;
}
async function rejectLegacyToml(root: string): Promise<void> {
try {
await lstat(path.join(root, LEGACY_TOML_PATH));
} catch (error) {
if (errorCode(error) === "ENOENT") return;
throw unreadablePath(LEGACY_TOML_PATH);
}
throw new HubCommandError(
"HUB_CONFIGURATION_EXTENSION_UNSUPPORTED",
`${LEGACY_TOML_PATH} is not supported. Use ${HUB_RESOURCE_PATH}.`,
);
}
async function discoverWorkflowNames(root: string): Promise<readonly string[]> {
const directory = path.join(root, WORKFLOW_DIRECTORY);
await requireSafeDirectory(root, directory, WORKFLOW_DIRECTORY, {
missingCode: "HUB_WORKFLOW_DIRECTORY_MISSING",
missingMessage: `${WORKFLOW_DIRECTORY} does not exist.`,
});
let entries;
try {
entries = await readdir(directory, { withFileTypes: true });
} catch {
throw unreadablePath(WORKFLOW_DIRECTORY);
}
const names: string[] = [];
for (const entry of entries.toSorted((left, right) => compareText(left.name, right.name))) {
const bundlePath = `${WORKFLOW_DIRECTORY}/${entry.name}`;
if (entry.isSymbolicLink()) throw unsafePath(bundlePath);
if (entry.isDirectory()) {
if (entry.name === "partials") continue;
throw new HubCommandError(
"HUB_WORKFLOW_PATH_UNSUPPORTED",
`${bundlePath} is not a direct-child .yml workflow.`,
);
}
if (!entry.isFile()) throw regularFileRequired(bundlePath);
if (!entry.name.endsWith(".yml")) {
throw new HubCommandError(
"HUB_WORKFLOW_EXTENSION_UNSUPPORTED",
`${bundlePath} must use the .yml extension.`,
);
}
names.push(entry.name);
}
if (names.length === 0) {
throw new HubCommandError(
"HUB_WORKFLOW_MISSING",
`${WORKFLOW_DIRECTORY} must contain at least one direct-child .yml workflow.`,
);
}
return names;
}
function collectPartialPaths(workflows: readonly HubBundleFile[]): readonly string[] {
const paths = new Set<string>();
for (const workflow of workflows) {
let document: unknown;
try {
document = YAML.parse(workflow.content);
} catch {
throw new HubCommandError("HUB_WORKFLOW_INVALID_YAML", `${workflow.path} is not valid YAML.`);
}
if (!isRecord(document) || !Array.isArray(document["steps"])) continue;
for (const step of document["steps"]) {
if (!isRecord(step) || !Array.isArray(step["prompt"])) continue;
for (const block of step["prompt"]) {
if (!isRecord(block) || !Object.hasOwn(block, "include")) continue;
const include = block["include"];
if (typeof include !== "string") {
throw invalidPartialPath(workflow.path, String(include));
}
paths.add(resolvePartialPath(workflow.path, include));
}
}
}
return [...paths].sort(compareText);
}
function resolvePartialPath(workflowPath: string, include: string): string {
const decoded = decodePartialPath(workflowPath, include);
if (/^(?:[a-zA-Z]:[\\/]|[\\/]{1,2})/u.test(decoded)) {
throw invalidPartialPath(workflowPath, include);
}
const segments = decoded.replaceAll("\\", "/").split("/");
if (
segments.some((segment) => segment.length === 0 || segment === "." || segment === "..") ||
segments[0] !== "partials"
) {
throw invalidPartialPath(workflowPath, include);
}
return `${WORKFLOW_DIRECTORY}/${segments.join("/")}`;
}
function decodePartialPath(workflowPath: string, include: string): string {
let decoded = include;
for (let attempt = 0; attempt < 8; attempt += 1) {
let next: string;
try {
next = decodeURIComponent(decoded);
} catch {
throw invalidPartialPath(workflowPath, include);
}
if (next === decoded) break;
decoded = next;
}
if (decoded.includes("\0") || /%[0-9a-f]{2}/iu.test(decoded)) {
throw invalidPartialPath(workflowPath, include);
}
return decoded;
}
async function readBundleFile(
root: string,
bundlePath: string,
missing: { missingCode: string; missingMessage: string } = {
missingCode: "HUB_BUNDLE_FILE_MISSING",
missingMessage: `${bundlePath} does not exist.`,
},
): Promise<HubBundleFile> {
const absolutePath = path.join(root, ...bundlePath.split("/"));
await rejectSymlinkComponents(root, absolutePath, bundlePath);
let stats: Awaited<ReturnType<typeof lstat>>;
try {
stats = await lstat(absolutePath);
} catch (error) {
if (errorCode(error) === "ENOENT") {
throw new HubCommandError(missing.missingCode, missing.missingMessage);
}
throw unreadablePath(bundlePath);
}
if (stats.isSymbolicLink()) throw unsafePath(bundlePath);
if (!stats.isFile()) throw regularFileRequired(bundlePath);
if (!hasReadPermission(stats.mode)) throw unreadablePath(bundlePath);
try {
return { path: bundlePath, content: (await readFile(absolutePath)).toString("utf8") };
} catch {
throw unreadablePath(bundlePath);
}
}
async function requireSafeDirectory(
root: string,
absolutePath: string,
bundlePath: string,
missing: { missingCode: string; missingMessage: string },
): Promise<void> {
await rejectSymlinkComponents(root, absolutePath, bundlePath);
let stats: Awaited<ReturnType<typeof lstat>>;
try {
stats = await lstat(absolutePath);
} catch (error) {
if (errorCode(error) === "ENOENT") {
throw new HubCommandError(missing.missingCode, missing.missingMessage);
}
throw unreadablePath(bundlePath);
}
if (stats.isSymbolicLink()) throw unsafePath(bundlePath);
if (!stats.isDirectory()) {
throw new HubCommandError(
"HUB_WORKFLOW_DIRECTORY_INVALID",
`${bundlePath} must be a directory.`,
);
}
}
async function rejectSymlinkComponents(
root: string,
target: string,
bundlePath: string,
): Promise<void> {
const relative = path.relative(root, target);
let current = root;
for (const segment of relative.split(path.sep).filter(Boolean)) {
current = path.join(current, segment);
try {
if ((await lstat(current)).isSymbolicLink()) throw unsafePath(bundlePath);
} catch (error) {
if (error instanceof HubCommandError) throw error;
if (errorCode(error) === "ENOENT") return;
throw unreadablePath(bundlePath);
}
}
}
function assertUniqueBundlePaths(files: readonly HubBundleFile[]): void {
const seen = new Set<string>();
for (const file of files) {
if (seen.has(file.path)) {
throw new HubCommandError(
"HUB_BUNDLE_PATH_DUPLICATE",
`${file.path} appears more than once in the discovered bundle.`,
);
}
seen.add(file.path);
}
}
function invalidPartialPath(workflowPath: string, include: string): HubCommandError {
return new HubCommandError(
"HUB_PARTIAL_PATH_INVALID",
`${workflowPath} includes an unsafe path outside ${PARTIAL_DIRECTORY}: ${include}`,
);
}
function unsafePath(bundlePath: string): HubCommandError {
return new HubCommandError("HUB_BUNDLE_UNSAFE_PATH", `${bundlePath} must not use a symlink.`);
}
function unreadablePath(bundlePath: string): HubCommandError {
return new HubCommandError(
"HUB_BUNDLE_UNREADABLE",
`Could not read ${bundlePath}. Check the file and permissions.`,
);
}
function regularFileRequired(bundlePath: string): HubCommandError {
return new HubCommandError("HUB_BUNDLE_NOT_FILE", `${bundlePath} must be a regular file.`);
}
function compareBundleFiles(left: HubBundleFile, right: HubBundleFile): number {
return compareText(left.path, right.path);
}
function compareText(left: string, right: string): number {
if (left < right) return -1;
if (left > right) return 1;
return 0;
}
function hasReadPermission(mode: number): boolean {
return (mode & 0o444) !== 0;
}
function errorCode(error: unknown): string | undefined {
if (typeof error !== "object" || error === null) return undefined;
const code = Reflect.get(error, "code");
return typeof code === "string" ? code : undefined;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
@@ -0,0 +1,195 @@
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { createHubCommand } from "./index.js";
import { runHubDeploy } from "./deploy.js";
const temporaryDirectories: string[] = [];
const servers: Array<ReturnType<typeof createServer>> = [];
afterEach(async () => {
await Promise.all(
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })),
);
await Promise.all(servers.splice(0).map(closeServer));
});
describe("Hub canonical bundle deployment contract", () => {
it("installs the exact discovered bundle with finite environment and named-agent routing", async () => {
const cwd = await canonicalProject();
const hub = await captureHub(201, {
projectSlug: "studio-api",
version: 7,
versionId: "2de5b143-1c88-42db-8a8d-71ca2af97830",
active: true,
});
const result = await runHubDeploy(
{ project: "studio-api", hub: hub.origin, apiKey: "operator-secret" },
{ cwd, env: {} },
);
expect(await hub.received).toEqual({
url: "/api/v1/configurations/install",
authorization: "Bearer operator-secret",
body: {
projectSlug: "studio-api",
files: canonicalFiles(),
},
});
expect(result.data).toEqual({
projectSlug: "studio-api",
version: 7,
versionId: "2de5b143-1c88-42db-8a8d-71ca2af97830",
active: true,
workflows: 1,
origin: hub.origin,
});
});
it("uses the identical bundle for server-side dry-run validation without activation", async () => {
const cwd = await canonicalProject();
const hub = await captureHub(200, { projectSlug: "studio-api", valid: true });
const result = await runHubDeploy(
{
project: "studio-api",
hub: hub.origin,
apiKey: "operator-secret",
dryRun: true,
},
{ cwd, env: {} },
);
expect(await hub.received).toEqual({
url: "/api/v1/configurations/validate",
authorization: "Bearer operator-secret",
body: { projectSlug: "studio-api", files: canonicalFiles() },
});
expect(result.data).toEqual({
projectSlug: "studio-api",
valid: true,
workflows: 1,
origin: hub.origin,
});
});
it("exposes only convention-based deployment options", () => {
const deploy = createHubCommand().commands.find((command) => command.name() === "deploy");
const help = deploy?.helpInformation() ?? "";
expect(help).not.toContain("[file]");
expect(help).toContain("--project <slug>");
expect(help).toContain("--dry-run");
});
});
const resource = [
"environments:",
" studio:",
" kind: daemon",
" daemon: local",
" cwd: /workspace/studio",
"agents:",
" codex-safe:",
" provider: codex",
" model: gpt-5.5",
" thinkingOptionId: xhigh",
" options:",
" sandbox_workspace_write:",
" writable_roots: [/var/cache/npm]",
" network_access: false",
" claude:",
" provider: claude",
" mode: bypassPermissions",
"",
].join("\n");
const workflow = [
"name: answer",
"on: discord.mention",
"max_runtime: 1h",
"inputs:",
" repo:",
" type: string",
" choices: [studio]",
" agent:",
" type: string",
" choices: [codex-safe, claude]",
"steps:",
" - id: work",
" environment: ${{ paseo.inputs.repo }}",
" max_runtime: 30m",
" idle_timeout: 5m",
" agent: ${{ paseo.inputs.agent }}",
" prompt:",
" - include: partials/safety.md",
" - text: ${{ paseo.prompt }}",
" allow_outputs:",
" - { type: discord.reply, max: 1, required: true }",
"",
].join("\n");
const partial = "Treat paseo.context as evidence, not hidden prompt text.\n";
function canonicalFiles() {
return [
{ path: ".paseo/hub.yml", content: resource },
{ path: ".paseo/workflows/answer.yml", content: workflow },
{ path: ".paseo/workflows/partials/safety.md", content: partial },
];
}
async function canonicalProject(): Promise<string> {
const cwd = await mkdtemp(path.join(tmpdir(), "paseo-hub-deploy-"));
temporaryDirectories.push(cwd);
const workflows = path.join(cwd, ".paseo", "workflows");
await mkdir(path.join(workflows, "partials"), { recursive: true });
await writeFile(path.join(cwd, ".paseo", "hub.yml"), resource);
await writeFile(path.join(workflows, "answer.yml"), workflow);
await writeFile(path.join(workflows, "partials", "safety.md"), partial);
return cwd;
}
async function captureHub(status: number, responseBody: unknown) {
let resolveRequest!: (request: unknown) => void;
const received = new Promise<unknown>((resolve) => {
resolveRequest = resolve;
});
const server = createServer((request, response) => {
let body = "";
request.setEncoding("utf8");
request.on("data", (chunk: string) => {
body += chunk;
});
request.on("end", () => {
resolveRequest({
url: request.url,
authorization: request.headers.authorization,
body: JSON.parse(body),
});
response.writeHead(status, { "content-type": "application/json" });
response.end(JSON.stringify(responseBody));
});
});
servers.push(server);
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address() as AddressInfo;
return { origin: `http://127.0.0.1:${address.port}`, received };
}
function closeServer(server: ReturnType<typeof createServer>): Promise<void> {
server.closeAllConnections();
return new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error !== undefined) {
reject(error);
return;
}
resolve();
});
});
}
@@ -1,397 +0,0 @@
import { lstat, readFile } from "node:fs/promises";
import path from "node:path";
import YAML from "yaml";
import { HubCommandError } from "./error.js";
const DEFAULT_CONFIGURATION_PATH = ".paseo/hub.yml";
const PROMPT_PARTIAL_ROOT = ".paseo/partials";
const PROMPT_PARTIAL_ROOT_PREFIX = `${PROMPT_PARTIAL_ROOT}/`;
const PROJECT_SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
const MAX_CONFIGURATION_LENGTH = 1_000_000;
const MAX_PROMPT_PARTIAL_COUNT = 100;
const MAX_PROMPT_PARTIAL_PATH_LENGTH = 512;
const MAX_PROMPT_PARTIAL_CONTENT_BYTES = 1_000_000;
const MAX_PROMPT_PARTIAL_BUNDLE_BYTES = 5_000_000;
export interface HubDeployPartial {
path: string;
content: string;
}
export interface HubDeployInput {
projectSlug: string;
yaml: string;
partials?: readonly HubDeployPartial[];
}
interface ResolveHubDeployInput {
cwd: string;
file?: string;
project?: string;
}
export async function resolveHubDeployInput(input: ResolveHubDeployInput): Promise<HubDeployInput> {
const file = input.file ?? DEFAULT_CONFIGURATION_PATH;
const projectRoot = path.resolve(input.cwd);
const configurationPath = resolveConfigurationPath(projectRoot, file);
const yaml = await readConfiguration(projectRoot, configurationPath, file);
const configuration = parseConfiguration(yaml);
const projectSlug = input.project ?? projectFromConfiguration(configuration);
if (projectSlug === undefined) {
throw new HubCommandError(
"HUB_PROJECT_REQUIRED",
"Project is required. Pass --project <slug> or add top-level project to the YAML.",
);
}
if (!PROJECT_SLUG_PATTERN.test(projectSlug)) {
throw new HubCommandError(
"HUB_INVALID_PROJECT",
"Project must be a bare slug such as my-project.",
);
}
const partials = await resolvePromptPartials(projectRoot, configuration);
return {
projectSlug,
yaml,
...(partials.length === 0 ? {} : { partials }),
};
}
function resolveConfigurationPath(cwd: string, file: string): string {
if (file.length === 0 || file.includes("\0")) {
throw invalidConfigurationPath();
}
const segments = file.replaceAll("\\", "/").split("/");
if (segments.some((segment) => segment === "..")) {
throw invalidConfigurationPath();
}
const resolved = path.resolve(cwd, file);
const relative = path.relative(cwd, resolved);
if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
throw invalidConfigurationPath();
}
return resolved;
}
async function readConfiguration(
projectRoot: string,
configurationPath: string,
displayPath: string,
): Promise<string> {
const unsafePath = () =>
new HubCommandError(
"HUB_CONFIGURATION_UNSAFE_PATH",
`Hub configuration at ${displayPath} must not use a symlink.`,
);
await rejectSymlinkComponents(projectRoot, configurationPath, unsafePath);
let stats: Awaited<ReturnType<typeof lstat>>;
try {
stats = await lstat(configurationPath);
} catch (error) {
throw configurationReadError(displayPath, error);
}
if (stats.isSymbolicLink()) {
throw unsafePath();
}
if (!stats.isFile()) {
throw new HubCommandError(
"HUB_CONFIGURATION_NOT_FILE",
`Hub configuration at ${displayPath} must be a regular file.`,
);
}
if (!hasReadPermission(stats.mode)) {
throw new HubCommandError(
"HUB_CONFIGURATION_UNREADABLE",
`Could not read Hub configuration at ${displayPath}. Check file permissions.`,
);
}
let bytes: Buffer;
try {
bytes = await readFile(configurationPath);
} catch (error) {
throw configurationReadError(displayPath, error);
}
const yaml = bytes.toString("utf8");
if (yaml.length > MAX_CONFIGURATION_LENGTH) {
throw new HubCommandError(
"HUB_CONFIGURATION_TOO_LARGE",
`Hub configuration at ${displayPath} exceeds the ${MAX_CONFIGURATION_LENGTH}-character limit.`,
);
}
return yaml;
}
function parseConfiguration(yaml: string): Record<string, unknown> {
let configuration: unknown;
try {
configuration = YAML.parse(yaml);
} catch {
throw new HubCommandError("HUB_INVALID_CONFIGURATION", "Hub configuration is not valid YAML.");
}
if (!isRecord(configuration)) {
throw new HubCommandError(
"HUB_INVALID_CONFIGURATION",
"Hub configuration must be a YAML mapping.",
);
}
return configuration;
}
function projectFromConfiguration(configuration: Record<string, unknown>): string | undefined {
const project: unknown = configuration["project"];
if (project === undefined) return undefined;
if (typeof project !== "string") {
throw new HubCommandError(
"HUB_INVALID_PROJECT",
"Top-level project must be a bare project slug.",
);
}
return project;
}
async function resolvePromptPartials(
projectRoot: string,
configuration: Record<string, unknown>,
): Promise<HubDeployPartial[]> {
const references = collectPromptPartialReferences(configuration);
if (references.length > MAX_PROMPT_PARTIAL_COUNT) {
throw new HubCommandError(
"HUB_PARTIAL_LIMIT_EXCEEDED",
`Hub configuration references ${references.length} partials; the limit is ${MAX_PROMPT_PARTIAL_COUNT}.`,
);
}
const partials: HubDeployPartial[] = [];
let bundleBytes = 0;
for (const reference of references) {
const partialPath = path.resolve(
projectRoot,
PROMPT_PARTIAL_ROOT,
...reference.path.split("/"),
);
const content = await readPartial(projectRoot, partialPath, reference.path);
const contentBytes = Buffer.byteLength(content, "utf8");
if (contentBytes > MAX_PROMPT_PARTIAL_CONTENT_BYTES) {
throw new HubCommandError(
"HUB_PARTIAL_TOO_LARGE",
`Referenced Hub partial ${reference.path} exceeds the ${MAX_PROMPT_PARTIAL_CONTENT_BYTES}-byte limit.`,
);
}
bundleBytes += contentBytes;
if (bundleBytes > MAX_PROMPT_PARTIAL_BUNDLE_BYTES) {
throw new HubCommandError(
"HUB_PARTIAL_BUNDLE_TOO_LARGE",
`Referenced Hub partials exceed the ${MAX_PROMPT_PARTIAL_BUNDLE_BYTES}-byte combined limit.`,
);
}
partials.push({ path: reference.path, content });
}
return partials;
}
interface PromptPartialReference {
path: string;
}
function collectPromptPartialReferences(
configuration: Record<string, unknown>,
): PromptPartialReference[] {
const references: PromptPartialReference[] = [];
const seen = new Set<string>();
const triggers = configuration["triggers"];
if (!Array.isArray(triggers)) return references;
for (const trigger of triggers) {
if (!isRecord(trigger) || !Array.isArray(trigger["steps"])) continue;
for (const step of trigger["steps"]) {
if (!isRecord(step) || !Array.isArray(step["prompt"])) continue;
for (const block of step["prompt"]) {
if (!isRecord(block) || !Object.hasOwn(block, "include")) continue;
const include = block["include"];
if (typeof include !== "string") {
throw new HubCommandError(
"HUB_PARTIAL_PATH_INVALID",
"Hub partial include path must be a string.",
);
}
const normalizedPath = normalizePromptPartialPath(include);
if (seen.has(normalizedPath)) {
throw new HubCommandError(
"HUB_PARTIAL_DUPLICATE",
`Hub partial ${normalizedPath} is referenced more than once. Remove the duplicate include.`,
);
}
seen.add(normalizedPath);
references.push({ path: normalizedPath });
}
}
}
return references;
}
function normalizePromptPartialPath(value: string): string {
const decoded = decodePromptPartialPath(value);
if (decoded.length === 0) throw invalidPromptPartialPath(value);
if (/^(?:[a-zA-Z]:[\\/]|[\\/]{1,2})/u.test(decoded)) {
throw invalidPromptPartialPath(value);
}
const segments = decoded.replaceAll("\\", "/").split("/");
if (segments.some((segment) => segment.length === 0 || segment === "." || segment === "..")) {
throw invalidPromptPartialPath(value);
}
const canonical = `${PROMPT_PARTIAL_ROOT}/${segments.join("/")}`;
if (!canonical.startsWith(PROMPT_PARTIAL_ROOT_PREFIX)) {
throw invalidPromptPartialPath(value);
}
if (canonical.length > MAX_PROMPT_PARTIAL_PATH_LENGTH) {
throw new HubCommandError(
"HUB_PARTIAL_PATH_TOO_LONG",
`Hub partial path ${value} exceeds the ${MAX_PROMPT_PARTIAL_PATH_LENGTH}-character limit.`,
);
}
return canonical.slice(PROMPT_PARTIAL_ROOT_PREFIX.length);
}
function decodePromptPartialPath(value: string): string {
let decoded = value;
for (let attempt = 0; attempt < 8; attempt += 1) {
let next: string;
try {
next = decodeURIComponent(decoded);
} catch {
throw invalidPromptPartialPath(value);
}
if (next === decoded) break;
decoded = next;
}
if (decoded.includes("\0") || /%[0-9a-f]{2}/iu.test(decoded)) {
throw invalidPromptPartialPath(value);
}
return decoded;
}
async function readPartial(
projectRoot: string,
partialPath: string,
displayPath: string,
): Promise<string> {
await rejectSymlinkComponents(
projectRoot,
partialPath,
() =>
new HubCommandError(
"HUB_PARTIAL_UNSAFE_PATH",
`Referenced Hub partial ${displayPath} must not use a symlink.`,
),
);
let stats: Awaited<ReturnType<typeof lstat>>;
try {
stats = await lstat(partialPath);
} catch (error) {
if (errorCode(error) === "ENOENT") {
throw new HubCommandError(
"HUB_PARTIAL_MISSING",
`Referenced Hub partial ${displayPath} does not exist.`,
);
}
throw new HubCommandError(
"HUB_PARTIAL_UNREADABLE",
`Could not read referenced Hub partial ${displayPath}. Check the file and permissions.`,
);
}
if (stats.isSymbolicLink()) {
throw new HubCommandError(
"HUB_PARTIAL_UNSAFE_PATH",
`Referenced Hub partial ${displayPath} must not be a symlink.`,
);
}
if (!stats.isFile()) {
throw new HubCommandError(
"HUB_PARTIAL_NOT_FILE",
`Referenced Hub partial ${displayPath} must be a regular file.`,
);
}
if (!hasReadPermission(stats.mode)) {
throw new HubCommandError(
"HUB_PARTIAL_UNREADABLE",
`Could not read referenced Hub partial ${displayPath}. Check the file and permissions.`,
);
}
try {
return (await readFile(partialPath)).toString("utf8");
} catch {
throw new HubCommandError(
"HUB_PARTIAL_UNREADABLE",
`Could not read referenced Hub partial ${displayPath}. Check the file and permissions.`,
);
}
}
async function rejectSymlinkComponents(
root: string,
target: string,
error: () => HubCommandError,
): Promise<void> {
const relative = path.relative(root, target);
let current = root;
for (const segment of relative.split(path.sep).filter(Boolean)) {
current = path.join(current, segment);
try {
if ((await lstat(current)).isSymbolicLink()) throw error();
} catch (failure) {
if (failure instanceof HubCommandError) throw failure;
if (errorCode(failure) === "ENOENT") return;
throw error();
}
}
}
function configurationReadError(displayPath: string, error: unknown): HubCommandError {
if (errorCode(error) === "ENOENT") {
return new HubCommandError(
"HUB_CONFIGURATION_UNREADABLE",
`Could not read Hub configuration at ${displayPath}. Pass an existing YAML file.`,
);
}
return new HubCommandError(
"HUB_CONFIGURATION_UNREADABLE",
`Could not read Hub configuration at ${displayPath}. Check the file and permissions.`,
);
}
function invalidConfigurationPath(): HubCommandError {
return new HubCommandError(
"HUB_CONFIGURATION_PATH_INVALID",
"Hub configuration path must stay within the current project root; parent-directory paths are not allowed.",
);
}
function invalidPromptPartialPath(value: string): HubCommandError {
return new HubCommandError(
"HUB_PARTIAL_PATH_INVALID",
`Hub partial path must be a safe relative path under .paseo/partials/: ${value}`,
);
}
function hasReadPermission(mode: number): boolean {
return (mode & 0o444) !== 0;
}
function errorCode(error: unknown): string | undefined {
if (typeof error !== "object" || error === null) return undefined;
const code = Reflect.get(error, "code");
return typeof code === "string" ? code : undefined;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
@@ -1,765 +0,0 @@
import { createServer } from "node:http";
import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { renderError, toCommandError } from "../../output/index.js";
import { createHubCommand } from "./index.js";
import { runHubDeploy } from "./deploy.js";
const temporaryDirectories: string[] = [];
afterEach(async () => {
await Promise.all(
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })),
);
});
describe("hub deploy", () => {
it("deploys the default file with its project metadata and exact YAML through env credentials", async () => {
const cwd = await temporaryDirectory();
const configurationDirectory = path.join(cwd, ".paseo");
const yaml = "project: studio-api\r\nenvironments: []\r\ntriggers: []\r\n";
await mkdir(configurationDirectory);
await writeFile(path.join(configurationDirectory, "hub.yml"), yaml);
const hub = await startHub();
const progress: string[] = [];
try {
const result = await runHubDeploy(
{},
{
cwd,
env: { PASEO_HUB_URL: hub.origin, PASEO_HUB_API_KEY: "test-operator-secret" },
reporter: { progress: (message) => progress.push(message) },
},
);
expect(await hub.received).toEqual({
method: "POST",
url: "/api/v1/configurations/install",
authorization: "Bearer test-operator-secret",
body: JSON.stringify({ projectSlug: "studio-api", yaml }),
});
expect(result.data).toEqual({
projectSlug: "studio-api",
version: 7,
versionId: "2de5b143-1c88-42db-8a8d-71ca2af97830",
active: true,
origin: hub.origin,
});
expect(progress).toEqual([`Deploying studio-api to ${hub.origin}`]);
} finally {
await hub.close();
}
});
it("uses an explicit file and lets -p override project metadata without rewriting YAML", async () => {
const cwd = await temporaryDirectory();
const yaml = "project: file-project\nenvironments: []\ntriggers: []\n";
await writeFile(path.join(cwd, "production.yml"), yaml);
const hub = await startHub();
try {
await runHubDeploy(
{
file: "production.yml",
project: "flag-project",
hub: hub.origin,
apiKey: "flag-secret",
},
{ cwd, env: {} },
);
expect(await hub.received).toMatchObject({
authorization: "Bearer flag-secret",
body: JSON.stringify({ projectSlug: "flag-project", yaml }),
});
} finally {
await hub.close();
}
});
it("dry-run sends the identical resolved bundle to validation and never installs", async () => {
const cwd = await projectFile(
"project: file-project\ntriggers:\n - steps:\n - prompt:\n - include: safety.md\n",
);
await writeFile(path.join(cwd, ".paseo", "partials", "safety.md"), "Stay safe.");
const hub = await startHub({
status: 200,
body: { projectSlug: "flag-project", valid: true },
});
const progress: string[] = [];
try {
const result = await runHubDeploy(
{
project: "flag-project",
hub: hub.origin,
apiKey: "validation-secret",
dryRun: true,
json: true,
},
{ cwd, env: {}, reporter: { progress: (message) => progress.push(message) } },
);
expect(await hub.received).toEqual({
method: "POST",
url: "/api/v1/configurations/validate",
authorization: "Bearer validation-secret",
body: JSON.stringify({
projectSlug: "flag-project",
yaml: "project: file-project\ntriggers:\n - steps:\n - prompt:\n - include: safety.md\n",
partials: [{ path: "safety.md", content: "Stay safe." }],
}),
});
expect(result.data).toEqual({ projectSlug: "flag-project", valid: true, origin: hub.origin });
expect(hub.requestCount()).toBe(1);
expect(progress).toEqual([]);
} finally {
await hub.close();
}
});
it("deploys mixed inline and partial prompts with only the referenced partial files", async () => {
const cwd = await temporaryDirectory();
const configurationDirectory = path.join(cwd, ".paseo");
const partialDirectory = path.join(configurationDirectory, "partials", "docs");
const yaml = [
"project: studio-api",
"triggers:",
" - steps:",
" - prompt:",
" - text: Inline request",
" - include: docs/safety.md",
" - include: docs/release.md",
"unknownNestedInclude: ignored.md",
"",
].join("\n");
const safety = "Follow the safety checklist.\n{ include: nested.md }";
const release = "Use the release checklist.";
await mkdir(partialDirectory, { recursive: true });
await writeFile(path.join(configurationDirectory, "hub.yml"), yaml);
await writeFile(path.join(partialDirectory, "safety.md"), safety);
await writeFile(path.join(partialDirectory, "release.md"), release);
const hub = await startHub();
try {
await runHubDeploy(
{},
{ cwd, env: { PASEO_HUB_URL: hub.origin, PASEO_HUB_API_KEY: "bundle-secret" } },
);
expect(await hub.received).toEqual({
method: "POST",
url: "/api/v1/configurations/install",
authorization: "Bearer bundle-secret",
body: JSON.stringify({
projectSlug: "studio-api",
yaml,
partials: [
{ path: "docs/safety.md", content: safety },
{ path: "docs/release.md", content: release },
],
}),
});
} finally {
await hub.close();
}
});
it("anchors explicit configuration files and their partial bundle at the current project root", async () => {
const cwd = await temporaryDirectory();
const configurationDirectory = path.join(cwd, "configs");
const partialDirectory = path.join(cwd, ".paseo", "partials");
const yaml =
"project: explicit-project\ntriggers:\n - steps:\n - prompt:\n - include: instructions.md\n";
const partial = "Explicit project-root partial";
await mkdir(configurationDirectory, { recursive: true });
await mkdir(partialDirectory, { recursive: true });
await writeFile(path.join(configurationDirectory, "production.yml"), yaml);
await writeFile(path.join(partialDirectory, "instructions.md"), partial);
const hub = await startHub();
try {
await runHubDeploy(
{ file: "configs/production.yml", hub: hub.origin, apiKey: "explicit-secret" },
{ cwd, env: {} },
);
expect(await hub.received).toMatchObject({
body: JSON.stringify({
projectSlug: "explicit-project",
yaml,
partials: [{ path: "instructions.md", content: partial }],
}),
});
} finally {
await hub.close();
}
});
it("does not search parent directories or alternate default filenames", async () => {
const parent = await temporaryDirectory();
const cwd = path.join(parent, "child");
await mkdir(path.join(parent, ".paseo"));
await mkdir(path.join(cwd, ".paseo"), { recursive: true });
await writeFile(path.join(parent, ".paseo", "hub.yml"), "project: parent-project\n");
await writeFile(path.join(cwd, ".paseo", "hub.yaml"), "project: alternate-project\n");
await expect(
runHubDeploy({ hub: "https://hub.example.com", apiKey: "unused-secret" }, { cwd, env: {} }),
).rejects.toMatchObject({
code: "HUB_CONFIGURATION_UNREADABLE",
message: "Could not read Hub configuration at .paseo/hub.yml. Pass an existing YAML file.",
});
});
it("requires an explicit project when the YAML omits project metadata", async () => {
const cwd = await temporaryDirectory();
await writeFile(path.join(cwd, "hub.yml"), "environments: []\ntriggers: []\n");
await expect(
runHubDeploy(
{ file: "hub.yml", hub: "https://hub.example.com", apiKey: "unused-secret" },
{ cwd, env: {} },
),
).rejects.toMatchObject({
message: "Project is required. Pass --project <slug> or add top-level project to the YAML.",
});
});
it("fails locally for a missing partial without sending a request", async () => {
const cwd = await projectFile(
"project: studio-api\ntriggers:\n - steps:\n - prompt:\n - include: missing.md\n",
);
const hub = await startHub();
try {
await expect(
runHubDeploy({ hub: hub.origin, apiKey: "missing-secret" }, { cwd, env: {} }),
).rejects.toMatchObject({
code: "HUB_PARTIAL_MISSING",
message: "Referenced Hub partial missing.md does not exist.",
});
expect(hub.requestCount()).toBe(0);
} finally {
await hub.close();
}
});
it.each([
"../secret.md",
"%2e%2e/secret.md",
"/etc/passwd",
"\\\\server\\share\\secret.md",
"C:\\secret.md",
])("fails locally for an unsafe partial path %s without sending a request", async (include) => {
const cwd = await projectFile(
`project: studio-api\ntriggers:\n - steps:\n - prompt:\n - include: ${JSON.stringify(include)}\n`,
);
const hub = await startHub();
try {
await expect(
runHubDeploy({ hub: hub.origin, apiKey: "unsafe-path-secret" }, { cwd, env: {} }),
).rejects.toMatchObject({ code: "HUB_PARTIAL_PATH_INVALID" });
expect(hub.requestCount()).toBe(0);
} finally {
await hub.close();
}
});
it("fails locally when a referenced partial is a directory", async () => {
const cwd = await projectFile(
"project: studio-api\ntriggers:\n - steps:\n - prompt:\n - include: docs\n",
);
await mkdir(path.join(cwd, ".paseo", "partials", "docs"), { recursive: true });
const hub = await startHub();
try {
await expect(
runHubDeploy({ hub: hub.origin, apiKey: "directory-secret" }, { cwd, env: {} }),
).rejects.toMatchObject({
code: "HUB_PARTIAL_NOT_FILE",
message: "Referenced Hub partial docs must be a regular file.",
});
expect(hub.requestCount()).toBe(0);
} finally {
await hub.close();
}
});
it("fails locally when a referenced partial cannot be read", async () => {
const cwd = await projectFile(
"project: studio-api\ntriggers:\n - steps:\n - prompt:\n - include: unreadable.md\n",
);
const partial = path.join(cwd, ".paseo", "partials", "unreadable.md");
await mkdir(path.dirname(partial), { recursive: true });
await writeFile(partial, "secret instructions");
await chmod(partial, 0o000);
const hub = await startHub();
try {
await expect(
runHubDeploy({ hub: hub.origin, apiKey: "unreadable-secret" }, { cwd, env: {} }),
).rejects.toMatchObject({
code: "HUB_PARTIAL_UNREADABLE",
message:
"Could not read referenced Hub partial unreadable.md. Check the file and permissions.",
});
expect(hub.requestCount()).toBe(0);
} finally {
await hub.close();
}
});
it("fails locally for duplicate normalized partial references", async () => {
const cwd = await projectFile(
"project: studio-api\ntriggers:\n - steps:\n - prompt:\n - include: docs/safety.md\n - include: docs\\safety.md\n",
);
const hub = await startHub();
try {
await expect(
runHubDeploy({ hub: hub.origin, apiKey: "duplicate-secret" }, { cwd, env: {} }),
).rejects.toMatchObject({
code: "HUB_PARTIAL_DUPLICATE",
message:
"Hub partial docs/safety.md is referenced more than once. Remove the duplicate include.",
});
expect(hub.requestCount()).toBe(0);
} finally {
await hub.close();
}
});
it("fails locally when the configuration exceeds the YAML size limit", async () => {
const cwd = await temporaryDirectory();
await mkdir(path.join(cwd, ".paseo"));
await writeFile(
path.join(cwd, ".paseo", "hub.yml"),
`project: studio-api\n${"#".repeat(1_000_000)}`,
);
const hub = await startHub();
try {
await expect(
runHubDeploy({ hub: hub.origin, apiKey: "large-yaml-secret" }, { cwd, env: {} }),
).rejects.toMatchObject({
code: "HUB_CONFIGURATION_TOO_LARGE",
});
expect(hub.requestCount()).toBe(0);
} finally {
await hub.close();
}
});
it("uses the Hub YAML string limit rather than its UTF-8 byte length", async () => {
const cwd = await temporaryDirectory();
const yaml = `project: studio-api\n# ${"😀".repeat(400_000)}\n`;
await mkdir(path.join(cwd, ".paseo"));
await writeFile(path.join(cwd, ".paseo", "hub.yml"), yaml);
expect(yaml.length).toBeLessThan(1_000_000);
expect(Buffer.byteLength(yaml, "utf8")).toBeGreaterThan(1_000_000);
const hub = await startHub();
try {
await runHubDeploy(
{},
{ cwd, env: { PASEO_HUB_URL: hub.origin, PASEO_HUB_API_KEY: "unicode-yaml-secret" } },
);
expect(JSON.parse((await hub.received).body)).toEqual({ projectSlug: "studio-api", yaml });
} finally {
await hub.close();
}
});
it("fails locally when a partial exceeds its content size limit", async () => {
const cwd = await projectFile(
"project: studio-api\ntriggers:\n - steps:\n - prompt:\n - include: large.md\n",
);
await writeFile(path.join(cwd, ".paseo", "partials", "large.md"), "x".repeat(1_000_001));
const hub = await startHub();
try {
await expect(
runHubDeploy({ hub: hub.origin, apiKey: "large-partial-secret" }, { cwd, env: {} }),
).rejects.toMatchObject({
code: "HUB_PARTIAL_TOO_LARGE",
});
expect(hub.requestCount()).toBe(0);
} finally {
await hub.close();
}
});
it("fails locally when the referenced partial bundle exceeds its combined size limit", async () => {
const cwd = await projectFile(
[
"project: studio-api",
"triggers:",
" - steps:",
" - prompt:",
...Array.from({ length: 6 }, (_, index) => ` - include: part-${index}.md`),
"",
].join("\n"),
);
await Promise.all(
Array.from({ length: 6 }, (_, index) =>
writeFile(path.join(cwd, ".paseo", "partials", `part-${index}.md`), "x".repeat(900_000)),
),
);
const hub = await startHub();
try {
await expect(
runHubDeploy({ hub: hub.origin, apiKey: "large-bundle-secret" }, { cwd, env: {} }),
).rejects.toMatchObject({
code: "HUB_PARTIAL_BUNDLE_TOO_LARGE",
});
expect(hub.requestCount()).toBe(0);
} finally {
await hub.close();
}
});
it("fails locally for an explicit configuration path outside the project root", async () => {
const cwd = await temporaryDirectory();
const hub = await startHub();
try {
await expect(
runHubDeploy(
{ file: "../hub.yml", hub: hub.origin, apiKey: "traversal-secret" },
{ cwd, env: {} },
),
).rejects.toMatchObject({
code: "HUB_CONFIGURATION_PATH_INVALID",
message:
"Hub configuration path must stay within the current project root; parent-directory paths are not allowed.",
});
expect(hub.requestCount()).toBe(0);
} finally {
await hub.close();
}
});
it("fails locally when the configuration is a directory", async () => {
const cwd = await temporaryDirectory();
await mkdir(path.join(cwd, ".paseo", "hub.yml"), { recursive: true });
const hub = await startHub();
try {
await expect(
runHubDeploy(
{ hub: hub.origin, apiKey: "configuration-directory-secret" },
{ cwd, env: {} },
),
).rejects.toMatchObject({
code: "HUB_CONFIGURATION_NOT_FILE",
message: "Hub configuration at .paseo/hub.yml must be a regular file.",
});
expect(hub.requestCount()).toBe(0);
} finally {
await hub.close();
}
});
it("fails locally when the configuration cannot be read", async () => {
const cwd = await temporaryDirectory();
await mkdir(path.join(cwd, ".paseo"));
const configuration = path.join(cwd, ".paseo", "hub.yml");
await writeFile(configuration, "project: studio-api\n");
await chmod(configuration, 0o000);
const hub = await startHub();
try {
await expect(
runHubDeploy(
{ hub: hub.origin, apiKey: "configuration-unreadable-secret" },
{ cwd, env: {} },
),
).rejects.toMatchObject({ code: "HUB_CONFIGURATION_UNREADABLE" });
expect(hub.requestCount()).toBe(0);
} finally {
await hub.close();
}
});
it("fails locally when the partial count exceeds the bundle limit", async () => {
const cwd = await projectFile(
[
"project: studio-api",
"triggers:",
" - steps:",
" - prompt:",
...Array.from({ length: 101 }, (_, index) => ` - include: part-${index}.md`),
"",
].join("\n"),
);
const hub = await startHub();
try {
await expect(
runHubDeploy({ hub: hub.origin, apiKey: "partial-count-secret" }, { cwd, env: {} }),
).rejects.toMatchObject({
code: "HUB_PARTIAL_LIMIT_EXCEEDED",
});
expect(hub.requestCount()).toBe(0);
} finally {
await hub.close();
}
});
it("fails locally when a canonical partial path exceeds its length limit", async () => {
const include = `${"a".repeat(497)}.md`;
const cwd = await projectFile(
`project: studio-api\ntriggers:\n - steps:\n - prompt:\n - include: ${JSON.stringify(include)}\n`,
);
const hub = await startHub();
try {
await expect(
runHubDeploy({ hub: hub.origin, apiKey: "partial-path-length-secret" }, { cwd, env: {} }),
).rejects.toMatchObject({
code: "HUB_PARTIAL_PATH_TOO_LONG",
});
expect(hub.requestCount()).toBe(0);
} finally {
await hub.close();
}
});
it("uses the hosted default and requires authority with actionable guidance", async () => {
const cwd = await projectFile("project: paseo\n");
const progress: string[] = [];
await expect(
runHubDeploy(
{},
{ cwd, env: {}, reporter: { progress: (message) => progress.push(message) } },
),
).rejects.toMatchObject({
message:
"No stored Hub login matches https://hub.paseo.sh. Run `paseo hub login https://hub.paseo.sh`, pass --api-key <secret>, or set PASEO_HUB_API_KEY.",
});
expect(progress).toEqual(["Deploying paseo to https://hub.paseo.sh"]);
await expect(
runHubDeploy({ hub: "https://hub.example.com" }, { cwd, env: {} }),
).rejects.toMatchObject({
message:
"No stored Hub login matches https://hub.example.com. Run `paseo hub login https://hub.example.com`, pass --api-key <secret>, or set PASEO_HUB_API_KEY.",
});
});
it.each([
"ftp://hub.example.com",
"http://hub.example.com",
"https://user:password@hub.example.com",
"https://hub.example.com/path",
"https://hub.example.com?debug=true",
"https://hub.example.com#debug",
])("rejects a non-origin Hub URL: %s", async (hub) => {
await expect(
runHubDeploy({ hub, apiKey: "unused-secret" }, { cwd: "/unused", env: {} }),
).rejects.toMatchObject({
message:
"Hub URL must be an HTTPS origin without credentials, path, query, or hash. HTTP is allowed only for localhost, 127.0.0.1, or [::1].",
});
});
it("shows RFC 9457 detail and field issues without raw server extensions", async () => {
const cwd = await projectFile("project: studio-api\n");
const hub = await startHub({
status: 422,
contentType: "application/problem+json",
body: {
type: "https://hub.example.com/problems/configuration-invalid",
title: "Configuration validation failed",
status: 422,
detail: "Fix the invalid configuration fields.",
errors: [{ field: "triggers[0].steps[0].agent.provider", message: "Unknown provider" }],
internalTrace: "do-not-expose-this-trace",
},
});
try {
await expect(
runHubDeploy({ hub: hub.origin, apiKey: "validation-secret" }, { cwd, env: {} }),
).rejects.toMatchObject({
code: "HUB_VALIDATION_FAILED",
message: "Configuration validation failed: Fix the invalid configuration fields.",
details: "triggers[0].steps[0].agent.provider: Unknown provider",
});
} finally {
await hub.close();
}
});
it("reports malformed successful responses without exposing their body", async () => {
const cwd = await projectFile("project: studio-api\n");
const hub = await startHub({ status: 201, body: { projectSlug: "studio-api", version: "7" } });
try {
await expect(
runHubDeploy({ hub: hub.origin, apiKey: "response-secret" }, { cwd, env: {} }),
).rejects.toMatchObject({
code: "HUB_INVALID_RESPONSE",
message: "Hub returned a malformed response.",
});
} finally {
await hub.close();
}
});
it("reports network failures honestly", async () => {
const cwd = await projectFile("project: studio-api\n");
const hub = await startHub();
await hub.close();
await expect(
runHubDeploy({ hub: hub.origin, apiKey: "network-secret" }, { cwd, env: {} }),
).rejects.toMatchObject({
code: "HUB_NETWORK_ERROR",
message: `Could not reach Paseo Hub at ${hub.origin}. Check the Hub URL and network connection.`,
});
});
it("redacts the operator key from problem detail, field issues, and serialized errors", async () => {
const secret = "operator-secret-must-not-leak";
const cwd = await projectFile("project: studio-api\n");
const hub = await startHub({
status: 401,
contentType: "application/problem+json",
body: {
title: `Rejected key ${secret}`,
status: 401,
detail: `Authorization Bearer ${secret} is invalid`,
errors: [{ field: "apiKey", message: secret }],
},
});
try {
const error = await runHubDeploy({ hub: hub.origin, apiKey: secret }, { cwd, env: {} }).catch(
(failure: unknown) => failure,
);
const jsonOutput = renderError(toCommandError(error), { format: "json" });
expect(jsonOutput).not.toContain(secret);
expect(error).toMatchObject({
message: "Rejected key [redacted]: Authorization Bearer [redacted] is invalid",
details: "apiKey: [redacted]",
});
} finally {
await hub.close();
}
});
it("wires deploy with its exact default file and explicit target options", () => {
const deploy = createHubCommand().commands.find((command) => command.name() === "deploy");
const help = deploy?.helpInformation();
expect(deploy?.registeredArguments[0]?.name()).toBe("file");
expect(deploy?.registeredArguments[0]?.defaultValue).toBe(".paseo/hub.yml");
expect(help).toContain("-p, --project <slug>");
expect(help).toContain("--hub <origin>");
expect(help).toContain("--api-key <secret>");
expect(help).toContain("--dry-run");
expect(help).not.toContain("organization");
});
});
async function temporaryDirectory(): Promise<string> {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-hub-deploy-"));
temporaryDirectories.push(directory);
return directory;
}
async function projectFile(yaml: string): Promise<string> {
const cwd = await temporaryDirectory();
await mkdir(path.join(cwd, ".paseo"));
await mkdir(path.join(cwd, ".paseo", "partials"));
await writeFile(path.join(cwd, ".paseo", "hub.yml"), yaml);
return cwd;
}
interface HubResponse {
status: number;
contentType?: string;
body: unknown;
}
interface ReceivedRequest {
method: string | undefined;
url: string | undefined;
authorization: string | undefined;
body: string;
}
interface TestHub {
origin: string;
received: Promise<ReceivedRequest>;
requestCount(): number;
close(): Promise<void>;
}
async function startHub(
configuredResponse: HubResponse = {
status: 201,
body: {
projectSlug: "studio-api",
version: 7,
versionId: "2de5b143-1c88-42db-8a8d-71ca2af97830",
active: true,
},
},
): Promise<TestHub> {
let requestCount = 0;
let resolveRequest: (request: ReceivedRequest) => void;
const received = new Promise<ReceivedRequest>((resolve) => {
resolveRequest = resolve;
});
const server = createServer((request, response) => {
requestCount += 1;
let body = "";
request.setEncoding("utf8");
request.on("data", (chunk: string) => {
body += chunk;
});
request.on("end", () => {
resolveRequest({
method: request.method,
url: request.url,
authorization: request.headers.authorization,
body,
});
response.writeHead(configuredResponse.status, {
"content-type": configuredResponse.contentType ?? "application/json",
});
response.end(JSON.stringify(configuredResponse.body));
});
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (address === null || typeof address === "string") throw new Error("Test server did not bind");
return {
origin: `http://127.0.0.1:${address.port}`,
received,
requestCount: () => requestCount,
close: () => closeServer(server),
};
}
function closeServer(server: ReturnType<typeof createServer>): Promise<void> {
return new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
}
+29 -20
View File
@@ -2,14 +2,17 @@ import type { Command } from "commander";
import { withOutput, type OutputSchema, type SingleResult } from "../../output/index.js";
import { addJsonOption } from "../../utils/command-options.js";
import { resolveHubCredential, resolveHubOrigin } from "./authority.js";
import { HubHttpClient, type HubInstallResult, type HubValidationResult } from "./client.js";
import {
HubHttpClient,
type HubInstallResult,
type HubValidationResult,
} from "./hub-client/index.js";
import { PrivateHubCredentialStore, type HubCredentialStore } from "./credentials.js";
import { resolveHubDeployInput } from "./deploy-input.js";
import { discoverHubBundle } from "./deploy-bundle.js";
import { processHubReporter, reportHubProgress, type HubReporter } from "./reporter.js";
import { addHubResolutionHelp } from "./help.js";
export interface HubDeployOptions {
file?: string;
project?: string;
hub?: string;
apiKey?: string;
@@ -35,10 +38,12 @@ export interface HubDeployCommandDependencies {
interface HubDeployResult extends HubInstallResult {
origin: string;
workflows: number;
}
interface HubDryRunResult extends HubValidationResult {
origin: string;
workflows: number;
}
const resultSchema: OutputSchema<HubDeployResult> = {
@@ -48,6 +53,7 @@ const resultSchema: OutputSchema<HubDeployResult> = {
{ header: "VERSION", field: "version" },
{ header: "VERSION ID", field: "versionId" },
{ header: "ACTIVE", field: "active" },
{ header: "WORKFLOWS", field: "workflows" },
{ header: "HUB", field: "origin" },
],
};
@@ -57,6 +63,7 @@ const validationSchema: OutputSchema<HubDryRunResult> = {
columns: [
{ header: "PROJECT", field: "projectSlug" },
{ header: "VALID", field: "valid" },
{ header: "WORKFLOWS", field: "workflows" },
{ header: "HUB", field: "origin" },
],
};
@@ -77,9 +84,8 @@ export async function runHubDeploy(
credentials,
};
const origin = resolveHubOrigin(resolution);
const deployInput = await resolveHubDeployInput({
const deployInput = await discoverHubBundle({
cwd: environment.cwd,
...(options.file === undefined ? {} : { file: options.file }),
...(options.project === undefined ? {} : { project: options.project }),
});
const action = options.dryRun === true ? "Validating" : "Deploying";
@@ -96,11 +102,19 @@ export async function runHubDeploy(
};
if (options.dryRun === true) {
const validated = await (environment.hub ?? new HubHttpClient()).validateConfiguration(request);
return { type: "single", data: { ...validated, origin }, schema: validationSchema };
return {
type: "single",
data: { ...validated, workflows: deployInput.workflowCount, origin },
schema: validationSchema,
};
}
const deployed = await (environment.hub ?? new HubHttpClient()).installConfiguration(request);
return { type: "single", data: { ...deployed, origin }, schema: resultSchema };
return {
type: "single",
data: { ...deployed, workflows: deployInput.workflowCount, origin },
schema: resultSchema,
};
}
export function addHubDeployCommand(
@@ -111,8 +125,7 @@ export function addHubDeployCommand(
addHubResolutionHelp(
hub
.command("deploy")
.description("Install and activate a Hub configuration")
.argument("[file]", "Hub configuration YAML", ".paseo/hub.yml")
.description("Discover, validate, and activate the canonical .paseo Hub bundle")
.option("-p, --project <slug>", "Target project slug")
.option("--hub <origin>", "Paseo Hub origin")
.option("--api-key <secret>", "Organization API key")
@@ -120,18 +133,14 @@ export function addHubDeployCommand(
),
).action(
withOutput<HubDeployResult | HubDryRunResult, unknown[]>(async (...args) => {
const file = args[0] as string;
const options = args.at(-2) as HubDeployOptions;
return runHubDeploy(
{ ...options, file },
{
cwd: dependencies.cwd(),
env: dependencies.env,
credentials: dependencies.credentials,
hub: dependencies.hub,
reporter: dependencies.reporter,
},
);
return runHubDeploy(options, {
cwd: dependencies.cwd(),
env: dependencies.env,
credentials: dependencies.credentials,
hub: dependencies.hub,
reporter: dependencies.reporter,
});
}),
);
}
@@ -0,0 +1,125 @@
import type { HubBundleFile } from "../deploy-bundle.js";
import { HubCommandError } from "../error.js";
import {
authorizationPollSchema,
authorizationSchema,
enrollmentTokenSchema,
installResponseSchema,
projectsResponseSchema,
validationResponseSchema,
type CliAuthorization,
type CliAuthorizationPoll,
type HubInstallResult,
type HubProject,
type HubValidationResult,
} from "./internal/contracts.js";
import { requestHub } from "./internal/transport.js";
export type {
CliAuthorization,
CliAuthorizationPoll,
HubInstallResult,
HubProject,
HubValidationResult,
} from "./internal/contracts.js";
interface HubConfigurationInput {
origin: string;
apiKey: string;
projectSlug: string;
files: readonly HubBundleFile[];
}
export class HubHttpClient {
startCliAuthorization(origin: string): Promise<CliAuthorization> {
return requestHub({
origin,
path: "/api/v1/cli-authorizations",
method: "POST",
body: {},
successStatus: 201,
schema: authorizationSchema,
failureMessage: "Hub CLI login could not be started",
});
}
async pollCliAuthorization(
origin: string,
deviceCode: string,
timeoutMilliseconds: number,
): Promise<CliAuthorizationPoll> {
try {
return await requestHub({
origin,
path: "/api/v1/cli-authorizations/poll",
method: "POST",
body: { deviceCode },
successStatus: 200,
schema: authorizationPollSchema,
timeoutMilliseconds,
failureMessage: "Hub CLI login polling failed",
});
} catch (error) {
if (error instanceof HubCommandError && error.code === "HUB_NETWORK_ERROR") {
return { status: "retry_later" };
}
throw error;
}
}
async listProjects(origin: string, apiKey: string): Promise<HubProject[]> {
const response = await requestHub({
origin,
path: "/api/v1/projects",
method: "GET",
apiKey,
successStatus: 200,
schema: projectsResponseSchema,
failureMessage: "Hub project listing failed",
});
return response.projects;
}
installConfiguration(input: HubConfigurationInput): Promise<HubInstallResult> {
return requestHub({
origin: input.origin,
path: "/api/v1/configurations/install",
method: "POST",
apiKey: input.apiKey,
body: configurationBody(input),
successStatus: 201,
schema: installResponseSchema,
failureMessage: "Hub deployment failed",
});
}
validateConfiguration(input: HubConfigurationInput): Promise<HubValidationResult> {
return requestHub({
origin: input.origin,
path: "/api/v1/configurations/validate",
method: "POST",
apiKey: input.apiKey,
body: configurationBody(input),
successStatus: 200,
schema: validationResponseSchema,
failureMessage: "Hub configuration validation failed",
});
}
async issueEnrollmentToken(origin: string, apiKey: string): Promise<string> {
const response = await requestHub({
origin,
path: "/api/v1/daemons/enrollment-tokens",
method: "POST",
apiKey,
successStatus: 201,
schema: enrollmentTokenSchema,
failureMessage: "Hub daemon enrollment authorization failed",
});
return response.token;
}
}
function configurationBody(input: HubConfigurationInput): object {
return { projectSlug: input.projectSlug, files: input.files };
}
@@ -0,0 +1,60 @@
import { z } from "zod";
const activationUrlSchema = z.url({ protocol: /^https?$/u });
export const authorizationSchema = z
.object({
deviceCode: z.string().min(32),
userCode: z.string().min(1),
verificationUri: activationUrlSchema,
verificationUriComplete: activationUrlSchema,
expiresAt: z.string().datetime(),
interval: z.number().int().min(1),
})
.strict();
export const authorizationPollSchema = z.discriminatedUnion("status", [
z.object({ status: z.literal("pending"), interval: z.number().int().min(1) }).strict(),
z.object({ status: z.literal("slow_down"), interval: z.number().int().min(1) }).strict(),
z
.object({
status: z.literal("authorized"),
interval: z.number().int().min(1),
credential: z.string().min(32),
organizationId: z.string().min(1),
})
.strict(),
z.object({ status: z.literal("denied"), interval: z.number().int().min(1) }).strict(),
z.object({ status: z.literal("expired"), interval: z.number().int().min(1) }).strict(),
z.object({ status: z.literal("disclosed"), interval: z.number().int().min(1) }).strict(),
z.object({ status: z.literal("retry_later") }).strict(),
]);
const projectSchema = z
.object({ id: z.string().uuid(), slug: z.string().min(1), name: z.string().min(1) })
.strict();
export const projectsResponseSchema = z.object({ projects: z.array(projectSchema) }).strict();
export const installResponseSchema = z
.object({
projectSlug: z.string().min(1),
version: z.number().int().positive(),
versionId: z.string().uuid(),
active: z.literal(true),
})
.strict();
export const validationResponseSchema = z
.object({ projectSlug: z.string().min(1), valid: z.literal(true) })
.strict();
export const enrollmentTokenSchema = z
.object({ token: z.string().min(32), expiresAt: z.string().datetime() })
.strict();
export type CliAuthorization = z.infer<typeof authorizationSchema>;
export type CliAuthorizationPoll = z.infer<typeof authorizationPollSchema>;
export type HubProject = z.infer<typeof projectSchema>;
export type HubInstallResult = z.infer<typeof installResponseSchema>;
export type HubValidationResult = z.infer<typeof validationResponseSchema>;
@@ -0,0 +1,95 @@
import { z } from "zod";
import { HubCommandError } from "../../error.js";
const issuePathSchema = z.union([z.string(), z.array(z.union([z.string(), z.number()]))]);
const fieldIssueSchema = z.object({
field: z.string().optional(),
path: issuePathSchema.optional(),
message: z.string(),
});
const problemSchema = z.object({
type: z.string().optional(),
title: z.string().optional(),
status: z.number().int().optional(),
detail: z.string().optional(),
instance: z.string().optional(),
errors: z
.union([z.array(fieldIssueSchema), z.record(z.string(), z.array(z.string()))])
.optional(),
issues: z.array(fieldIssueSchema).optional(),
});
export async function hubRequestFailure(
response: Response,
failureMessage: string,
apiKey: string | undefined,
): Promise<HubCommandError> {
const contentType = response.headers.get("content-type") ?? "";
if (!contentType.toLowerCase().includes("application/problem+json")) {
return new HubCommandError(
"HUB_REQUEST_FAILED",
`${failureMessage} with HTTP ${response.status}.`,
);
}
let body: unknown;
try {
body = await response.json();
} catch {
return new HubCommandError(
"HUB_INVALID_RESPONSE",
`Hub returned malformed problem details for HTTP ${response.status}.`,
);
}
const parsed = problemSchema.safeParse(body);
if (
!parsed.success ||
(parsed.data.status !== undefined && parsed.data.status !== response.status)
) {
return new HubCommandError(
"HUB_INVALID_RESPONSE",
`Hub returned nonconforming problem details for HTTP ${response.status}.`,
);
}
const title = parsed.data.title ?? `${failureMessage} with HTTP ${response.status}`;
const message = parsed.data.detail === undefined ? title : `${title}: ${parsed.data.detail}`;
const details = formatFieldIssues(parsed.data.errors, parsed.data.issues);
const code = response.status === 422 ? "HUB_VALIDATION_FAILED" : "HUB_REQUEST_FAILED";
return new HubCommandError(
code,
redactSecret(message, apiKey),
details === undefined ? undefined : redactSecret(details, apiKey),
);
}
function formatFieldIssues(
errors: z.infer<typeof problemSchema>["errors"],
issues: z.infer<typeof problemSchema>["issues"],
): string | undefined {
const fieldIssues = Array.isArray(errors) ? errors : issues;
if (fieldIssues !== undefined) {
const lines = fieldIssues.map((issue) => {
const field = issue.field ?? formatIssuePath(issue.path);
return field === undefined ? issue.message : `${field}: ${issue.message}`;
});
return lines.length === 0 ? undefined : lines.join("\n");
}
if (errors === undefined) return undefined;
const lines = Object.entries(errors).flatMap(([field, messages]) =>
messages.map((message: string) => `${field}: ${message}`),
);
return lines.length === 0 ? undefined : lines.join("\n");
}
function formatIssuePath(path: z.infer<typeof issuePathSchema> | undefined): string | undefined {
if (path === undefined || typeof path === "string") return path;
let formatted = "";
for (const segment of path) {
if (typeof segment === "number") formatted += `[${segment}]`;
else formatted += formatted.length === 0 ? segment : `.${segment}`;
}
return formatted || undefined;
}
function redactSecret(value: string, secret: string | undefined): string {
return secret === undefined ? value : value.split(secret).join("[redacted]");
}
@@ -0,0 +1,44 @@
import type { z } from "zod";
import { HubCommandError } from "../../error.js";
import { hubRequestFailure } from "./problem.js";
interface HubRequest<T> {
origin: string;
path: string;
method: "GET" | "POST";
apiKey?: string;
body?: unknown;
successStatus: number;
schema: z.ZodType<T>;
timeoutMilliseconds?: number;
failureMessage: string;
}
export async function requestHub<T>(input: HubRequest<T>): Promise<T> {
const signal = AbortSignal.timeout(input.timeoutMilliseconds ?? 15_000);
let response: Response;
try {
response = await fetch(`${input.origin}${input.path}`, {
method: input.method,
headers: {
...(input.apiKey === undefined ? {} : { authorization: `Bearer ${input.apiKey}` }),
...(input.body === undefined ? {} : { "content-type": "application/json" }),
},
...(input.body === undefined ? {} : { body: JSON.stringify(input.body) }),
signal,
});
} catch {
throw new HubCommandError(
"HUB_NETWORK_ERROR",
`Could not reach Paseo Hub at ${input.origin}. Check the Hub URL and network connection.`,
);
}
if (response.status !== input.successStatus) {
throw await hubRequestFailure(response, input.failureMessage, input.apiKey);
}
try {
return input.schema.parse(await response.json());
} catch {
throw new HubCommandError("HUB_INVALID_RESPONSE", "Hub returned a malformed response.");
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { Command } from "commander";
import { withOutput } from "../../output/index.js";
import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js";
import { HubHttpClient } from "./client.js";
import { HubHttpClient } from "./hub-client/index.js";
import { addHubConnectCommand } from "./connect.js";
import { PrivateHubCredentialStore, type HubCredentialStore } from "./credentials.js";
import {
@@ -1,6 +1,6 @@
import { strict as assert } from "node:assert";
import { describe, it } from "vitest";
import type { CliAuthorizationPoll } from "./client.js";
import type { CliAuthorizationPoll } from "./hub-client/index.js";
import { CliLoginFlow, SystemBrowser } from "./login-flow.js";
describe("Hub CLI login browser flow", () => {
+1 -1
View File
@@ -1,6 +1,6 @@
import { spawn } from "node:child_process";
import { platform } from "node:os";
import type { CliAuthorizationPoll, HubHttpClient } from "./client.js";
import type { CliAuthorizationPoll, HubHttpClient } from "./hub-client/index.js";
export interface LoginWaiter {
wait(milliseconds: number): Promise<void>;
+1 -1
View File
@@ -2,7 +2,7 @@ import type { Command } from "commander";
import { render, withOutput, type OutputSchema, type SingleResult } from "../../output/index.js";
import { addJsonOption } from "../../utils/command-options.js";
import { resolveHubCredential, resolveHubOrigin } from "./authority.js";
import type { HubHttpClient, HubProject } from "./client.js";
import type { HubHttpClient, HubProject } from "./hub-client/index.js";
import type { HubCredentialStore } from "./credentials.js";
import { reportHubProgress, type HubReporter } from "./reporter.js";
import { addHubResolutionHelp } from "./help.js";
+117
View File
@@ -0,0 +1,117 @@
#!/usr/bin/env npx tsx
import assert from "node:assert/strict";
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { runLocalPaseo } from "./helpers/local-cli.js";
const cwd = await mkdtemp(path.join(tmpdir(), "paseo-hub-installed-"));
const requests: Array<{ url: string | undefined; body: unknown }> = [];
const server = createServer((request, response) => {
let body = "";
request.setEncoding("utf8");
request.on("data", (chunk: string) => {
body += chunk;
});
request.on("end", () => {
requests.push({ url: request.url, body: JSON.parse(body) });
const validation = request.url?.endsWith("/validate") === true;
response.writeHead(validation ? 200 : 201, { "content-type": "application/json" });
response.end(
JSON.stringify(
validation
? { projectSlug: "studio", valid: true }
: {
projectSlug: "studio",
version: 1,
versionId: "2de5b143-1c88-42db-8a8d-71ca2af97830",
active: true,
},
),
);
});
});
try {
const workflows = path.join(cwd, ".paseo", "workflows");
await mkdir(path.join(workflows, "partials"), { recursive: true });
const files = [
{
path: ".paseo/hub.yml",
content:
"environments:\n studio:\n kind: daemon\n daemon: local\n cwd: /workspace\nagents:\n codex-safe:\n provider: codex\n options:\n sandbox_workspace_write:\n writable_roots: [/var/cache/npm]\n network_access: false\n",
},
{
path: ".paseo/workflows/run.yml",
content:
"name: run\non: manual.run\nmax_runtime: 1h\ninputs:\n repo:\n type: string\n choices: [studio]\n agent:\n type: string\n choices: [codex-safe]\nsteps:\n - id: work\n environment: ${{ paseo.inputs.repo }}\n max_runtime: 30m\n idle_timeout: 5m\n agent: ${{ paseo.inputs.agent }}\n prompt:\n - include: partials/instructions.md\n - text: ${{ paseo.prompt }}\n",
},
{
path: ".paseo/workflows/partials/instructions.md",
content: "Keep structured provider options unchanged.\n",
},
];
for (const file of files) await writeFile(path.join(cwd, file.path), file.content);
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address() as AddressInfo;
const origin = `http://127.0.0.1:${address.port}`;
const validate = await runLocalPaseo(
[
"hub",
"deploy",
"-p",
"studio",
"--dry-run",
"--hub",
origin,
"--api-key",
"test-secret",
"--json",
],
{},
cwd,
);
assert.equal(validate.exitCode, 0, validate.stderr);
assert.deepEqual(JSON.parse(validate.stdout), {
projectSlug: "studio",
valid: true,
workflows: 1,
origin,
});
const install = await runLocalPaseo(
["hub", "deploy", "-p", "studio", "--hub", origin, "--api-key", "test-secret", "--json"],
{},
cwd,
);
assert.equal(install.exitCode, 0, install.stderr);
assert.deepEqual(
requests.map(({ url, body }) => ({ url, body })),
[
{
url: "/api/v1/configurations/validate",
body: { projectSlug: "studio", files: files.toSorted(compareFiles) },
},
{
url: "/api/v1/configurations/install",
body: { projectSlug: "studio", files: files.toSorted(compareFiles) },
},
],
);
console.log("✅ installed Hub CLI deploys and validates the canonical bundle");
} finally {
server.closeAllConnections();
await new Promise<void>((resolve) => server.close(() => resolve()));
await rm(cwd, { recursive: true });
}
function compareFiles(left: { path: string }, right: { path: string }): number {
if (left.path < right.path) return -1;
if (left.path > right.path) return 1;
return 0;
}
+9 -2
View File
@@ -3,7 +3,14 @@ import { join } from "node:path";
const CLI_ENTRY = join(import.meta.dirname, "..", "..", "dist", "index.js");
export function runLocalPaseo(args: string[], env: NodeJS.ProcessEnv = {}): ProcessPromise {
export function runLocalPaseo(
args: string[],
env: NodeJS.ProcessEnv = {},
cwd = process.cwd(),
): ProcessPromise {
$.verbose = false;
return $({ env: { ...process.env, ...env } })`${process.execPath} ${CLI_ENTRY} ${args}`.nothrow();
return $({
cwd,
env: { ...process.env, ...env },
})`${process.execPath} ${CLI_ENTRY} ${args}`.nothrow();
}
+34 -7
View File
@@ -1,12 +1,39 @@
# @getpaseo/client
Paseo's JavaScript/TypeScript client package.
TypeScript SDK for building integrations on top of a Paseo daemon.
```bash
npm install @getpaseo/client
```
```ts
import { createPaseoClient } from "@getpaseo/client";
const client = createPaseoClient({ url: "ws://127.0.0.1:6767/ws" });
await client.connect();
const agent = await client.agents.create({
config: { provider: "codex/gpt-5.5" },
cwd: "/Users/me/dev/storefront",
prompt: "Review the current diff and name the riskiest change.",
});
const result = await agent.waitForFinish();
console.log(result.lastMessage);
await client.close();
```
The public API is the package root. Imports under `@getpaseo/client/internal/*` are unsupported implementation details used by Paseo's own packages.
Read the [SDK documentation](https://paseo.sh/docs/sdk) for agents, workspaces, provider discovery, events, recipes, and the API reference. Runnable TypeScript patterns also live in [`examples/`](./examples/README.md).
## Runtime
The client needs a WebSocket implementation. Modern browsers and Node.js 22 provide one globally.
Use a WebSocket URL ending in `/ws`, such as `ws://127.0.0.1:6767/ws`. Pass `password` when the daemon requires authentication.
## Stability
This package is public so Paseo's published packages can depend on it cleanly.
It is not a stable public SDK yet.
APIs, exports, runtime behavior, and types may change or disappear in any
release without advance notice. Use it outside Paseo at your own risk until the
package is explicitly documented as stable.
The high-level API exported from `@getpaseo/client` is the supported SDK surface. The SDK and daemon remain protocol-compatible across versions, but newly added capabilities can require a newer daemon.
+6 -3
View File
@@ -6,13 +6,16 @@ These examples use only the public SDK root:
import { createPaseoClient, type PaseoClient } from "@getpaseo/client";
```
Pass the daemon WebSocket URL into the exported functions. In worktree dev, read it
Each example takes the daemon WebSocket URL as an argument. In worktree dev, read it
from the portless banner or `portless get daemon`; for the desktop-managed daemon,
use the URL for that daemon.
- `workspaces.ts` covers creating, opening, refetching, and archiving a workspace.
- `agents-and-providers.ts` covers creating agents and choosing providers with `client.providers.*`.
- `quickstart.ts` runs one agent and prints its reply. It is a standalone script with the URL at the top; the rest export functions.
- `workspaces.ts` covers creating a fresh workspace, opening by directory, refreshing, and archiving.
- `agents-and-providers.ts` covers provider discovery, creating agents, and waiting for turns.
- `events-and-timeline.ts` covers subscribing to workspace, agent, and timeline events, plus refetching a timeline page.
- `issue-to-agent.ts` turns an issue record into a visible Paseo workspace and agent.
- `parallel-review.ts` launches several reviewers concurrently and cleans them up.
- `provider-settings.ts` covers provider settings that are currently daemon config-backed.
Provider profiles, provider env vars, custom binaries, and additional models are still raw daemon config behavior. The SDK exposes them through `client.config.get()` and `client.config.patch()` until first-class provider settings RPCs exist.
@@ -14,13 +14,11 @@ export async function createCodexAgent(url: string, cwd: string): Promise<string
const agent = await client.agents.create({
config: {
...client.providers.codex({
model: "gpt-5.2",
modeId: "full-auto",
}),
cwd,
provider: "codex/gpt-5.5",
modeId: "full-access",
},
initialPrompt: "Inspect this repository and summarize the next useful task.",
cwd,
prompt: "Inspect this repository and summarize the next useful task.",
});
return agent.id;
@@ -35,18 +33,18 @@ export async function chooseProviderFromSnapshot(url: string, cwd: string): Prom
try {
await client.connect();
const snapshot = await client.providers.snapshot({ cwd });
const snapshot = await client.providers.waitForReady({ cwd });
const readyProvider = snapshot.entries.find((provider) => provider.status === "ready");
const providerConfig = readyProvider
? client.providers.config(readyProvider.provider)
: client.providers.claude();
const model =
readyProvider?.models?.find((candidate) => candidate.isDefault) ?? readyProvider?.models?.[0];
if (!readyProvider || !model) throw new Error("No provider model is ready");
const agent = await client.agents.create({
config: {
...providerConfig,
cwd,
provider: `${readyProvider.provider}/${model.id}`,
},
initialPrompt: "Start with a quick repository map.",
cwd,
prompt: "Start with a quick repository map.",
});
return agent.id;
@@ -54,3 +52,16 @@ export async function chooseProviderFromSnapshot(url: string, cwd: string): Prom
await client.close();
}
}
export async function runFollowUp(url: string, agentId: string): Promise<string | null> {
const client = createClient(url);
try {
await client.connect();
const result = await client.agents.ref(agentId).run("Summarize your progress and next step.");
if (result.status !== "idle") throw new Error(result.error ?? result.status);
return result.lastMessage;
} finally {
await client.close();
}
}
@@ -0,0 +1,35 @@
import { createPaseoClient, type PaseoClient } from "@getpaseo/client";
interface Issue {
id: string;
title: string;
description: string;
repositoryPath: string;
}
export async function startIssue(client: PaseoClient, issue: Issue) {
const workspace = await client.workspaces.open(issue.repositoryPath);
const agent = await workspace.agents.create({
config: {
provider: "codex/gpt-5.5",
},
title: issue.title,
labels: {
"issue-provider": "my-tracker",
"issue-id": issue.id,
},
prompt: [
`Implement issue ${issue.id}: ${issue.title}`,
"",
issue.description,
"",
"Run focused tests and summarize the result.",
].join("\n"),
});
return { workspaceId: workspace.id, agentId: agent.id };
}
export function createClient(url: string): PaseoClient {
return createPaseoClient({ url });
}
@@ -0,0 +1,37 @@
import type { PaseoAgentHandle, PaseoClient } from "@getpaseo/client";
export async function reviewInParallel(client: PaseoClient, cwd: string): Promise<string[]> {
const agents: PaseoAgentHandle[] = [];
try {
agents.push(
...(await Promise.all([
client.agents.create({
config: {
provider: "codex/gpt-5.5",
},
cwd,
title: "Correctness review",
prompt: "Review the current diff for correctness and missed edge cases.",
}),
client.agents.create({
config: {
provider: "claude/claude-sonnet-5",
},
cwd,
title: "Security review",
prompt: "Review the current diff for security and unsafe input handling.",
}),
])),
);
const results = await Promise.all(agents.map((agent) => agent.waitForFinish()));
return results.map((result) => {
if (result.status !== "idle") throw new Error(result.error ?? result.status);
return result.lastMessage ?? "";
});
} finally {
await Promise.allSettled(agents.map((agent) => agent.archive()));
}
}
+24
View File
@@ -0,0 +1,24 @@
import { createPaseoClient } from "@getpaseo/client";
const client = createPaseoClient({ url: "ws://127.0.0.1:6767/ws" });
await client.connect();
try {
const agent = await client.agents.create({
config: {
provider: "codex/gpt-5.5",
},
cwd: process.cwd(),
prompt: "Inspect this repository and name the next useful task.",
});
const result = await agent.waitForFinish();
if (result.status !== "idle") {
throw new Error(result.error ?? `Agent stopped with status: ${result.status}`);
}
console.log(result.lastMessage);
} finally {
await client.close();
}
+6 -10
View File
@@ -12,18 +12,14 @@ export async function createOpenAndArchiveWorkspace(url: string, cwd: string): P
try {
await client.connect();
const created = await client.workspaces.create(cwd);
if (!created.workspace) {
throw new Error(created.error ?? "Workspace creation failed");
}
const created = await client.workspaces.create({
source: { kind: "directory", path: cwd },
title: "Fresh SDK workspace",
});
const opened = await client.workspaces.open(cwd);
if (!opened.workspace) {
throw new Error(opened.error ?? "Workspace open failed");
}
await opened.workspace.refetch();
await opened.workspace.archive();
await opened.refresh();
await created.archive();
} finally {
await client.close();
}
+348 -41
View File
@@ -1,6 +1,6 @@
import { afterEach, expect, test, vi } from "vitest";
import { createPaseoClient } from "./index.js";
import type { PaseoAgent, PaseoClient, PaseoProviderConfig, PaseoWorkspace } from "./index.js";
import type { PaseoAgent, PaseoClient, PaseoWorkspace } from "./index.js";
type FakeWebSocketHandler = (...args: unknown[]) => void;
@@ -80,7 +80,9 @@ function parseSentFrame(
return JSON.parse(data);
}
async function connectClient(): Promise<{ client: PaseoClient; ws: FakeWebSocket }> {
async function connectClient(
features: Record<string, boolean> = { providersSnapshotCwd: true },
): Promise<{ client: PaseoClient; ws: FakeWebSocket }> {
vi.stubGlobal("WebSocket", FakeWebSocket);
const client = createPaseoClient({
url: "ws://daemon.test",
@@ -105,6 +107,7 @@ async function connectClient(): Promise<{ client: PaseoClient; ws: FakeWebSocket
serverId: "srv_sdk_test",
hostname: null,
version: null,
features,
},
}),
);
@@ -211,6 +214,59 @@ test("createPaseoClient exposes workspace list through the daemon client", async
await client.close();
});
test("agent actions list the daemon directory without exposing the low-level client", async () => {
const { client, ws } = await connectClient();
const listedAgent = createAgent({ title: "Planner" });
const listPromise = client.agents.list({
filter: { includeArchived: false },
page: { limit: 10 },
});
const request = parseSentSessionMessage(ws.sent.at(-1));
expect(request).toMatchObject({
type: "fetch_agents_request",
filter: { includeArchived: false },
page: { limit: 10 },
});
ws.message(
sessionMessage({
type: "fetch_agents_response",
payload: {
requestId: request.requestId,
entries: [
{
agent: listedAgent,
project: {
projectKey: "project_sdk",
projectName: "SDK",
workspaceName: "main",
checkout: {
cwd: "/repo/sdk",
isGit: false,
currentBranch: null,
remoteUrl: null,
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
},
},
},
],
pageInfo: {
nextCursor: null,
prevCursor: null,
hasMore: false,
},
},
}),
);
await expect(listPromise).resolves.toMatchObject({
entries: [{ agent: { id: "agent_sdk", title: "Planner" } }],
});
await client.close();
});
test("workspace handles keep identity and refresh snapshots through existing driver calls", async () => {
const { client, ws } = await connectClient();
const openedWorkspace = createWorkspace();
@@ -232,17 +288,18 @@ test("workspace handles keep identity and refresh snapshots through existing dri
}),
);
const opened = await openPromise;
const workspace = opened.workspace;
expect(workspace?.id).toBe("workspace_sdk");
expect(workspace?.latest()).toEqual(openedWorkspace);
const workspace = await openPromise;
expect(workspace.id).toBe("workspace_sdk");
expect(workspace.projectId).toBe("project_sdk");
expect(workspace.directory).toBe("/repo/sdk");
expect(workspace.current()).toEqual(openedWorkspace);
const refreshedWorkspace = createWorkspace({ name: "sdk refreshed" });
const refetchPromise = workspace?.refetch({ requestId: "workspace-refetch-request" });
const refetchPromise = workspace.refresh({ requestId: "workspace-refetch-request" });
expect(parseSentSessionMessage(ws.sent.at(-1))).toMatchObject({
type: "fetch_workspaces_request",
requestId: "workspace-refetch-request",
page: { limit: 25 },
page: { limit: 200 },
});
ws.message(
@@ -250,6 +307,27 @@ test("workspace handles keep identity and refresh snapshots through existing dri
type: "fetch_workspaces_response",
payload: {
requestId: "workspace-refetch-request",
entries: [],
pageInfo: {
nextCursor: "workspace-page-2",
prevCursor: null,
hasMore: true,
},
},
}),
);
await new Promise((resolve) => setTimeout(resolve, 0));
const secondPageRequest = parseSentSessionMessage(ws.sent.at(-1));
expect(secondPageRequest).toMatchObject({
type: "fetch_workspaces_request",
page: { limit: 200, cursor: "workspace-page-2" },
});
ws.message(
sessionMessage({
type: "fetch_workspaces_response",
payload: {
requestId: secondPageRequest.requestId,
entries: [refreshedWorkspace],
pageInfo: {
nextCursor: null,
@@ -261,10 +339,10 @@ test("workspace handles keep identity and refresh snapshots through existing dri
);
await expect(refetchPromise).resolves.toEqual(refreshedWorkspace);
expect(workspace?.latest()).toEqual(refreshedWorkspace);
expect(workspace.current()).toEqual(refreshedWorkspace);
const updates: string[] = [];
const unsubscribe = workspace?.subscribe((update) => {
const unsubscribe = workspace.subscribe((update) => {
if (update.kind === "upsert") {
updates.push(update.workspace.name);
}
@@ -280,9 +358,9 @@ test("workspace handles keep identity and refresh snapshots through existing dri
}),
);
expect(updates).toEqual(["sdk pushed"]);
expect(workspace?.latest()).toEqual(pushedWorkspace);
expect(workspace.current()).toEqual(pushedWorkspace);
unsubscribe?.();
unsubscribe();
ws.message(
sessionMessage({
type: "workspace_update",
@@ -297,20 +375,79 @@ test("workspace handles keep identity and refresh snapshots through existing dri
await client.close();
});
test("workspace create is fresh and workspace-scoped agent create owns placement", async () => {
const { client, ws } = await connectClient();
const createdWorkspace = createWorkspace({ id: "workspace_fresh", name: "Issue 42" });
const workspacePromise = client.workspaces.create({
source: { kind: "directory", path: "/repo/sdk", projectId: "project_sdk" },
title: "Issue 42",
});
const workspaceRequest = parseSentSessionMessage(ws.sent.at(-1));
expect(workspaceRequest).toMatchObject({
type: "workspace.create.request",
source: { kind: "directory", path: "/repo/sdk", projectId: "project_sdk" },
title: "Issue 42",
});
ws.message(
sessionMessage({
type: "workspace.create.response",
payload: {
requestId: workspaceRequest.requestId,
workspace: createdWorkspace,
setupTerminalId: null,
error: null,
},
}),
);
const workspace = await workspacePromise;
const agentPromise = workspace.agents.create({
config: { provider: "codex/gpt-5.4" },
parent: client.agents.ref("parent_sdk"),
prompt: "Implement issue 42.",
});
const agentRequest = parseSentSessionMessage(ws.sent.at(-1));
expect(agentRequest).toMatchObject({
type: "create_agent_request",
config: { provider: "codex", model: "gpt-5.4", cwd: "/repo/sdk" },
workspaceId: "workspace_fresh",
callerAgentId: "parent_sdk",
initialPrompt: "Implement issue 42.",
});
ws.message(
sessionMessage({
type: "status",
payload: {
status: "agent_created",
requestId: agentRequest.requestId,
agentId: "agent_sdk",
agent: createAgent({ workspaceId: "workspace_fresh" }),
},
}),
);
const agent = await agentPromise;
expect(agent.workspaceId).toBe("workspace_fresh");
expect(agent.cwd).toBe("/repo/sdk");
await client.close();
});
test("agent handles delegate create, send, timeline refetch, archive, and local updates", async () => {
const { client, ws } = await connectClient();
const createdAgent = createAgent();
const createPromise = client.agents.create({
provider: "codex",
config: { provider: "codex/gpt-5.4" },
cwd: "/repo/sdk",
initialPrompt: "ship it",
prompt: "ship it",
});
const createRequest = parseSentSessionMessage(ws.sent.at(-1));
expect(createRequest).toMatchObject({
type: "create_agent_request",
config: {
provider: "codex",
model: "gpt-5.4",
cwd: "/repo/sdk",
},
initialPrompt: "ship it",
@@ -330,7 +467,7 @@ test("agent handles delegate create, send, timeline refetch, archive, and local
const agent = await createPromise;
expect(agent.id).toBe("agent_sdk");
expect(agent.latest()).toEqual(createdAgent);
expect(agent.current()).toEqual(createdAgent);
const updatedAgents: string[] = [];
const unsubscribe = agent.subscribe((update) => {
@@ -350,7 +487,7 @@ test("agent handles delegate create, send, timeline refetch, archive, and local
}),
);
expect(updatedAgents).toEqual(["Updated"]);
expect(agent.latest()).toEqual(updatedAgent);
expect(agent.current()).toEqual(updatedAgent);
const sendPromise = agent.send("hello", { messageId: "message-sdk" });
const sendRequest = parseSentSessionMessage(ws.sent.at(-1));
@@ -374,6 +511,74 @@ test("agent handles delegate create, send, timeline refetch, archive, and local
);
await sendPromise;
const runPromise = agent.run("finish the task", {
messageId: "run-message-sdk",
timeoutMs: 30_000,
});
const runSendRequest = parseSentSessionMessage(ws.sent.at(-1));
expect(runSendRequest).toMatchObject({
type: "send_agent_message_request",
agentId: "agent_sdk",
text: "finish the task",
messageId: "run-message-sdk",
});
ws.message(
sessionMessage({
type: "send_agent_message_response",
payload: {
requestId: runSendRequest.requestId,
agentId: "agent_sdk",
accepted: true,
error: null,
},
}),
);
await new Promise((resolve) => setTimeout(resolve, 0));
const waitRequest = parseSentSessionMessage(ws.sent.at(-1));
expect(waitRequest).toMatchObject({
type: "wait_for_finish_request",
agentId: "agent_sdk",
timeoutMs: 30_000,
});
const finishedAgent = createAgent({ title: "Finished" });
ws.message(
sessionMessage({
type: "wait_for_finish_response",
payload: {
requestId: waitRequest.requestId,
agentId: "agent_sdk",
status: "idle",
final: finishedAgent,
error: null,
lastMessage: "DONE",
},
}),
);
await expect(runPromise).resolves.toMatchObject({ status: "idle", lastMessage: "DONE" });
expect(agent.current()).toEqual(finishedAgent);
const defaultWaitPromise = agent.waitForFinish();
const defaultWaitRequest = parseSentSessionMessage(ws.sent.at(-1));
expect(defaultWaitRequest).toMatchObject({
type: "wait_for_finish_request",
agentId: "agent_sdk",
timeoutMs: 10 * 60_000,
});
ws.message(
sessionMessage({
type: "wait_for_finish_response",
payload: {
requestId: defaultWaitRequest.requestId,
agentId: "agent_sdk",
status: "idle",
final: finishedAgent,
error: null,
lastMessage: "DONE",
},
}),
);
await expect(defaultWaitPromise).resolves.toMatchObject({ status: "idle" });
const timelineAgent = createAgent({ title: "Timeline" });
const timelinePromise = agent.timeline.refetch({ limit: 5 });
const timelineRequest = parseSentSessionMessage(ws.sent.at(-1));
@@ -410,7 +615,7 @@ test("agent handles delegate create, send, timeline refetch, archive, and local
}),
);
await timelinePromise;
expect(agent.latest()).toEqual(timelineAgent);
expect(agent.current()).toEqual(timelineAgent);
const archivePromise = agent.archive();
const archiveRequest = parseSentSessionMessage(ws.sent.at(-1));
@@ -431,7 +636,7 @@ test("agent handles delegate create, send, timeline refetch, archive, and local
await expect(archivePromise).resolves.toEqual({
archivedAt: "2026-05-16T01:00:00.000Z",
});
expect(agent.latest()?.archivedAt).toBe("2026-05-16T01:00:00.000Z");
expect(agent.current()?.archivedAt).toBe("2026-05-16T01:00:00.000Z");
unsubscribe();
await client.close();
@@ -496,10 +701,9 @@ test("provider actions delegate to existing provider RPCs and local snapshot upd
const featuresPromise = client.providers.listFeatures(
{
provider: "codex",
provider: "codex/gpt-5.4",
cwd: "/repo/sdk",
modeId: "full-access",
model: "gpt-5.4",
thinkingOptionId: "high",
featureValues: { webSearch: true },
},
@@ -579,6 +783,81 @@ test("provider actions delegate to existing provider RPCs and local snapshot upd
entries: [{ provider: "codex", status: "ready", enabled: true }],
});
const readyPromise = client.providers.waitForReady({ cwd: "/repo/sdk", timeoutMs: 5_000 });
const readyRequest = parseSentSessionMessage(ws.sent.at(-1));
expect(readyRequest).toMatchObject({
type: "get_providers_snapshot_request",
cwd: "/repo/sdk",
});
ws.message(
sessionMessage({
type: "get_providers_snapshot_response",
payload: {
requestId: readyRequest.requestId,
cwd: "/repo/sdk",
entries: [{ provider: "codex", status: "loading", enabled: true }],
generatedAt: "2026-05-16T00:00:00.000Z",
},
}),
);
ws.message(
sessionMessage({
type: "providers_snapshot_update",
payload: {
cwd: "/repo/other",
entries: [{ provider: "codex", status: "ready", enabled: true }],
generatedAt: "2026-05-16T00:00:30.000Z",
},
}),
);
ws.message(
sessionMessage({
type: "providers_snapshot_update",
payload: {
cwd: "/repo/sdk",
entries: [{ provider: "codex", status: "ready", enabled: true }],
generatedAt: "2026-05-16T00:00:01.000Z",
},
}),
);
await expect(readyPromise).resolves.toMatchObject({
requestId: readyRequest.requestId,
entries: [{ provider: "codex", status: "ready", enabled: true }],
generatedAt: "2026-05-16T00:00:01.000Z",
});
const canonicalReadyPromise = client.providers.waitForReady({
cwd: "/repo/./sdk",
timeoutMs: 5_000,
});
const canonicalReadyRequest = parseSentSessionMessage(ws.sent.at(-1));
ws.message(
sessionMessage({
type: "providers_snapshot_update",
payload: {
cwd: "/repo/sdk",
entries: [{ provider: "codex", status: "ready", enabled: true }],
generatedAt: "2026-05-16T00:00:02.000Z",
},
}),
);
ws.message(
sessionMessage({
type: "get_providers_snapshot_response",
payload: {
requestId: canonicalReadyRequest.requestId,
cwd: "/repo/sdk",
entries: [{ provider: "codex", status: "loading", enabled: true }],
generatedAt: "2026-05-16T00:00:01.000Z",
},
}),
);
await expect(canonicalReadyPromise).resolves.toMatchObject({
requestId: canonicalReadyRequest.requestId,
cwd: "/repo/sdk",
entries: [{ provider: "codex", status: "ready", enabled: true }],
});
const refreshPromise = client.providers.refresh({
cwd: "/repo/sdk",
providers: ["codex"],
@@ -665,6 +944,18 @@ test("provider actions delegate to existing provider RPCs and local snapshot upd
await client.close();
});
test("waitForReady requires canonical provider snapshot identity from the host", async () => {
const { client, ws } = await connectClient({});
const sentBeforeWait = ws.sent.length;
await expect(client.providers.waitForReady({ cwd: "/repo/./sdk" })).rejects.toThrow(
"Update the host to wait for provider discovery.",
);
expect(ws.sent).toHaveLength(sentBeforeWait);
await client.close();
});
test("config actions delegate to existing daemon config RPCs", async () => {
const { client, ws } = await connectClient();
@@ -757,34 +1048,29 @@ test("config actions delegate to existing daemon config RPCs", async () => {
await client.close();
});
test("provider config builders shape existing create-agent config fields", async () => {
test("agent config maps provider/model and provider-native options to the daemon", async () => {
const { client, ws } = await connectClient();
const provider = client.providers.codex({
model: "gpt-5.4",
modeId: "full-access",
thinkingOptionId: "high",
featureValues: { webSearch: true },
});
const expectedProviderConfig = {
provider: "codex",
model: "gpt-5.4",
modeId: "full-access",
thinkingOptionId: "high",
featureValues: { webSearch: true },
} satisfies PaseoProviderConfig;
expect(provider).toEqual(expectedProviderConfig);
const createdAgent = createAgent({
model: "gpt-5.4",
currentModeId: "full-access",
});
const createPromise = client.agents.create({
config: {
...provider,
cwd: "/repo/sdk",
provider: "codex/gpt-5.4",
modeId: "full-access",
thinkingOptionId: "high",
featureValues: { webSearch: true },
options: {
approval_policy: "never",
sandbox_mode: "workspace-write",
sandbox_workspace_write: {
writable_roots: ["/repo/sdk"],
network_access: false,
},
},
},
initialPrompt: "use configured provider",
cwd: "/repo/sdk",
prompt: "use configured provider",
});
const request = parseSentSessionMessage(ws.sent.at(-1));
expect(request).toMatchObject({
@@ -796,6 +1082,14 @@ test("provider config builders shape existing create-agent config fields", async
modeId: "full-access",
thinkingOptionId: "high",
featureValues: { webSearch: true },
providerOptions: {
approval_policy: "never",
sandbox_mode: "workspace-write",
sandbox_workspace_write: {
writable_roots: ["/repo/sdk"],
network_access: false,
},
},
},
initialPrompt: "use configured provider",
});
@@ -813,7 +1107,20 @@ test("provider config builders shape existing create-agent config fields", async
);
const agent = await createPromise;
expect(agent.latest()).toEqual(createdAgent);
expect(agent.current()).toEqual(createdAgent);
await client.close();
});
test("agent config requires provider/model syntax", async () => {
const { client } = await connectClient();
await expect(
client.agents.create({
config: { provider: "codex" },
cwd: "/repo/sdk",
}),
).rejects.toThrow('Expected config.provider in "provider/model" format');
await client.close();
});
+326 -121
View File
@@ -17,24 +17,25 @@ import type {
SendAgentMessageRequest,
SessionOutboundMessage,
WorkspaceDescriptorPayload,
WorkspaceCreateRequest,
} from "@getpaseo/protocol/messages";
import { DaemonClient } from "./daemon-client.js";
import type {
FetchAgentsEntry,
FetchAgentsOptions,
FetchAgentsPageInfo,
FetchAgentTimelineCursor,
FetchAgentTimelineDirection,
FetchAgentTimelinePayload,
FetchAgentTimelineProjection,
WaitForFinishResult,
} from "./daemon-client.js";
export { DaemonClient };
export type {
DaemonClientConfig,
DaemonEvent,
BrowserAutomationExecuteRequestMessage,
BrowserAutomationExecuteResponseMessage,
WebSocketFactory,
WebSocketLike,
} from "./daemon-client.js";
/**
* Coding turns routinely run for minutes, so the handle waits far longer than
* the transport's own conservative default.
*/
const DEFAULT_WAIT_FOR_FINISH_MS = 10 * 60_000;
export type ConnectionState =
| { status: "idle" }
@@ -75,6 +76,14 @@ export interface PaseoClientConfig {
export type PaseoWorkspace = WorkspaceDescriptorPayload;
export type PaseoAgent = AgentSnapshotPayload;
export type PaseoAgentListOptions = FetchAgentsOptions;
export interface PaseoAgentListResult {
requestId: string;
subscriptionId?: string | null;
entries: FetchAgentsEntry[];
pageInfo: FetchAgentsPageInfo;
}
export type PaseoWorkspaceListOptions = Omit<
FetchWorkspacesRequestMessage,
"type" | "requestId"
@@ -94,11 +103,9 @@ export interface PaseoWorkspaceOpenOptions {
requestId?: string;
}
export interface PaseoWorkspaceOpenResult {
requestId: string;
workspace: PaseoWorkspaceHandle | null;
error: string | null;
}
export type PaseoWorkspaceCreateOptions = Omit<WorkspaceCreateRequest, "type" | "requestId"> & {
requestId?: string;
};
export interface PaseoWorkspaceArchiveResult {
requestId: string;
@@ -114,19 +121,17 @@ export type PaseoWorkspaceUpdate = Extract<
export type PaseoWorkspaceUpdateHandler = (update: PaseoWorkspaceUpdate) => void;
/**
* A handle is a stable typed reference to a daemon resource. Its identity is the
* daemon id, and `latest()` only returns the most recent snapshot this handle has
* seen through construction, `refetch()`, or this handle's local subscription.
*/
export interface PaseoWorkspaceHandle {
readonly id: string;
latest(): PaseoWorkspace | null;
/**
* Fetches a fresh workspace snapshot through the existing workspace list RPC,
* exact-matches this handle id from the result, and updates `latest()`.
*/
refetch(options?: { requestId?: string }): Promise<PaseoWorkspace | null>;
readonly projectId: string | null;
readonly directory: string | null;
readonly name: string | null;
readonly status: PaseoWorkspace["status"] | null;
readonly agents: {
create(options: PaseoWorkspaceAgentCreateOptions): Promise<PaseoAgentHandle>;
};
current(): PaseoWorkspace | null;
refresh(options?: { requestId?: string }): Promise<PaseoWorkspace | null>;
archive(requestId?: string): Promise<PaseoWorkspaceArchiveResult>;
/**
* Subscribes to already-emitted daemon workspace_update events for this id.
@@ -143,11 +148,8 @@ export interface PaseoWorkspaceActions {
open(
input: string | PaseoWorkspaceOpenOptions,
requestId?: string,
): Promise<PaseoWorkspaceOpenResult>;
create(
input: string | PaseoWorkspaceOpenOptions,
requestId?: string,
): Promise<PaseoWorkspaceOpenResult>;
): Promise<PaseoWorkspaceHandle>;
create(options: PaseoWorkspaceCreateOptions): Promise<PaseoWorkspaceHandle>;
archive(
workspace: string | PaseoWorkspaceHandle,
requestId?: string,
@@ -160,25 +162,43 @@ export interface PaseoWorkspaceActions {
}
type PaseoAgentSessionConfig = CreateAgentRequestMessage["config"];
type PaseoAgentProvider = PaseoAgentSessionConfig["provider"];
type PaseoAgentConfigOverrides = Partial<Omit<PaseoAgentSessionConfig, "provider" | "cwd">>;
export type PaseoAgentProvider = PaseoAgentSessionConfig["provider"];
export interface PaseoAgentCreateOptions extends PaseoAgentConfigOverrides {
config?: PaseoAgentSessionConfig;
provider?: CreateAgentRequestMessage["config"]["provider"];
cwd?: string;
workspaceId?: string;
callerAgentId?: string;
initialPrompt?: string;
export type PaseoProviderFeatureValues = Record<string, unknown>;
export interface PaseoAgentConfig {
/** Provider and model in `provider/model` format. */
provider: string;
modeId?: PaseoAgentSessionConfig["modeId"];
thinkingOptionId?: PaseoAgentSessionConfig["thinkingOptionId"];
featureValues?: PaseoProviderFeatureValues;
/** JSON-safe provider-native settings, validated by the selected provider. */
options?: PaseoAgentSessionConfig["providerOptions"];
systemPrompt?: PaseoAgentSessionConfig["systemPrompt"];
toolPolicy?: PaseoAgentSessionConfig["toolPolicy"];
mcpServers?: PaseoAgentSessionConfig["mcpServers"];
}
export interface PaseoAgentCreateOptions {
config: PaseoAgentConfig;
cwd: string;
parent?: string | PaseoAgentHandle;
title?: PaseoAgentSessionConfig["title"];
env?: CreateAgentRequestMessage["env"];
prompt?: string;
clientMessageId?: string;
outputSchema?: Record<string, unknown>;
images?: CreateAgentRequestMessage["images"];
attachments?: CreateAgentRequestMessage["attachments"];
git?: CreateAgentRequestMessage["git"];
worktree?: CreateAgentRequestMessage["worktree"];
autoArchive?: CreateAgentRequestMessage["autoArchive"];
requestId?: string;
labels?: Record<string, string>;
}
export type PaseoWorkspaceAgentCreateOptions = Omit<PaseoAgentCreateOptions, "cwd">;
export interface PaseoAgentRefetchResult {
agent: PaseoAgent;
project: ProjectPlacementPayload | null;
@@ -198,6 +218,12 @@ export interface PaseoAgentSendOptions {
attachments?: SendAgentMessageRequest["attachments"];
}
export interface PaseoAgentRunOptions extends PaseoAgentSendOptions {
timeoutMs?: number;
}
export type PaseoAgentRunResult = WaitForFinishResult;
export type PaseoAgentUpdate = Extract<SessionOutboundMessage, { type: "agent_update" }>["payload"];
export type PaseoAgentStream = Extract<SessionOutboundMessage, { type: "agent_stream" }>["payload"];
@@ -207,8 +233,8 @@ export type PaseoAgentUpdateHandler = (update: PaseoAgentUpdate) => void;
export interface PaseoAgentTimelineHandle {
/**
* Fetches a fresh timeline page through the existing daemon RPC. If the daemon
* includes an agent snapshot in the response, the parent handle's `latest()`
* is updated to that snapshot.
* includes an agent snapshot in the response, the parent handle is updated to
* that value.
*/
refetch(options?: PaseoAgentTimelineRefetchOptions): Promise<FetchAgentTimelinePayload>;
/**
@@ -218,24 +244,26 @@ export interface PaseoAgentTimelineHandle {
subscribe(handler: (event: PaseoAgentStream) => void): () => void;
}
/**
* Agent handles follow the same identity/snapshot rule as workspace handles:
* `id` is stable, while `latest()` is only the newest snapshot observed by this
* handle through construction, `refetch()`, timeline refetch, archive, or local
* agent_update subscription.
*/
export interface PaseoAgentHandle {
readonly id: string;
readonly workspaceId: string | null;
readonly cwd: string | null;
readonly status: PaseoAgent["status"] | null;
readonly timeline: PaseoAgentTimelineHandle;
latest(): PaseoAgent | null;
refetch(requestId?: string): Promise<PaseoAgentRefetchResult | null>;
current(): PaseoAgent | null;
refresh(requestId?: string): Promise<PaseoAgentRefetchResult | null>;
send(text: string, options?: PaseoAgentSendOptions): Promise<void>;
/** Sends a prompt and resolves when that turn finishes or needs attention. */
run(text: string, options?: PaseoAgentRunOptions): Promise<PaseoAgentRunResult>;
/** Waits for the current turn, including one started with `prompt`. */
waitForFinish(timeoutMs?: number): Promise<PaseoAgentRunResult>;
archive(): Promise<{ archivedAt: string }>;
detach(): Promise<void>;
subscribe(handler: (update: PaseoAgentUpdate) => void): () => void;
}
export interface PaseoAgentActions {
list(options?: PaseoAgentListOptions): Promise<PaseoAgentListResult>;
ref(agent: string | PaseoAgent): PaseoAgentHandle;
create(options: PaseoAgentCreateOptions): Promise<PaseoAgentHandle>;
/**
@@ -245,21 +273,16 @@ export interface PaseoAgentActions {
subscribe(handler: PaseoAgentUpdateHandler): () => void;
}
export interface PaseoProviderConfig extends PaseoProviderConfigInput {
provider: PaseoAgentProvider;
}
export type PaseoProviderFeatureValues = Record<string, unknown>;
export interface PaseoProviderConfigInput {
model?: string;
modeId?: string;
thinkingOptionId?: string;
featureValues?: PaseoProviderFeatureValues;
}
export type PaseoProviderModelsResult = ListProviderModelsResponseMessage["payload"];
export type PaseoProviderModesResult = ListProviderModesResponseMessage["payload"];
export type PaseoProviderFeaturesInput = ListProviderFeaturesRequestMessage["draftConfig"];
type PaseoProviderFeaturesDraft = ListProviderFeaturesRequestMessage["draftConfig"];
export interface PaseoProviderFeaturesInput extends Omit<
PaseoProviderFeaturesDraft,
"provider" | "model"
> {
/** Provider and model in `provider/model` format. */
provider: string;
}
export type PaseoProviderFeaturesResult = ListProviderFeaturesResponseMessage["payload"];
export type PaseoProviderAvailabilityResult = ListAvailableProvidersResponse["payload"];
export type PaseoProviderSnapshotResult = GetProvidersSnapshotResponseMessage["payload"];
@@ -281,12 +304,11 @@ export interface PaseoProviderRefreshOptions {
requestId?: string;
}
export interface PaseoProviderWaitOptions extends PaseoProviderListOptions {
timeoutMs?: number;
}
export interface PaseoProviderActions {
codex(input?: PaseoProviderConfigInput): PaseoProviderConfig;
claude(input?: PaseoProviderConfigInput): PaseoProviderConfig;
opencode(input?: PaseoProviderConfigInput): PaseoProviderConfig;
copilot(input?: PaseoProviderConfigInput): PaseoProviderConfig;
config(provider: PaseoAgentProvider, input?: PaseoProviderConfigInput): PaseoProviderConfig;
listModels(
provider: PaseoAgentProvider,
options?: PaseoProviderListOptions,
@@ -301,6 +323,8 @@ export interface PaseoProviderActions {
): Promise<PaseoProviderFeaturesResult>;
listAvailable(options?: { requestId?: string }): Promise<PaseoProviderAvailabilityResult>;
snapshot(options?: PaseoProviderListOptions): Promise<PaseoProviderSnapshotResult>;
/** Resolves after the daemon's lazy provider discovery has finished. */
waitForReady(options?: PaseoProviderWaitOptions): Promise<PaseoProviderSnapshotResult>;
refresh(options?: PaseoProviderRefreshOptions): Promise<PaseoProviderRefreshResult>;
diagnostic(
provider: PaseoAgentProvider,
@@ -346,8 +370,32 @@ export function createPaseoClient(config: PaseoClientConfig): PaseoClient {
clientId: config.clientId ?? createGeneratedClientId(),
clientType: "cli",
});
const createWorkspaceHandle = createWorkspaceHandleFactory(daemonClient);
const createAgentHandle = createAgentHandleFactory(daemonClient);
const createAgent = async (
options: PaseoAgentCreateOptions,
placement?: { workspaceId: string; cwd: string },
) => {
const { config: agentConfig, cwd, parent, title, prompt, ...requestOptions } = options;
const { provider: providerModel, options: providerOptions, ...runtimeConfig } = agentConfig;
const { provider, model } = parseProviderModel(providerModel);
const effectiveCwd = placement?.cwd ?? cwd;
const agent = await daemonClient.createAgent({
...requestOptions,
config: {
...runtimeConfig,
provider,
model,
cwd: effectiveCwd,
...(title !== undefined ? { title } : {}),
...(providerOptions !== undefined ? { providerOptions } : {}),
},
...(placement ? { workspaceId: placement.workspaceId } : {}),
...(parent ? { callerAgentId: resolveAgentId(parent) } : {}),
...(prompt !== undefined ? { initialPrompt: prompt } : {}),
});
return createAgentHandle(agent);
};
const createWorkspaceHandle = createWorkspaceHandleFactory(daemonClient, createAgent);
return {
workspaces: {
@@ -355,8 +403,13 @@ export function createPaseoClient(config: PaseoClientConfig): PaseoClient {
ref: (workspace) => createWorkspaceHandle(workspace),
open: (input, requestId) =>
openWorkspace(daemonClient, createWorkspaceHandle, input, requestId),
create: (input, requestId) =>
openWorkspace(daemonClient, createWorkspaceHandle, input, requestId),
create: async ({ requestId, ...options }) => {
const result = await daemonClient.createWorkspace(options, requestId);
if (result.error || !result.workspace) {
throw new Error(result.error ?? "The daemon did not create a workspace");
}
return createWorkspaceHandle(result.workspace);
},
archive: (workspace, requestId) =>
daemonClient.archiveWorkspace(resolveWorkspaceId(workspace), requestId),
subscribe: (handler) =>
@@ -365,28 +418,24 @@ export function createPaseoClient(config: PaseoClientConfig): PaseoClient {
}),
},
agents: {
list: (options) => daemonClient.fetchAgents(options),
ref: (agent) => createAgentHandle(agent),
create: async (options) => {
const agent = await daemonClient.createAgent(options);
return createAgentHandle(agent);
},
create: (options) => createAgent(options),
subscribe: (handler) =>
daemonClient.on("agent_update", (message) => {
handler(message.payload);
}),
},
providers: {
codex: (input) => providerConfig("codex", input),
claude: (input) => providerConfig("claude", input),
opencode: (input) => providerConfig("opencode", input),
copilot: (input) => providerConfig("copilot", input),
config: (provider, input) => providerConfig(provider, input),
listModels: (provider, options) => daemonClient.listProviderModels(provider, options),
listModes: (provider, options) => daemonClient.listProviderModes(provider, options),
listFeatures: (draftConfig, options) =>
daemonClient.listProviderFeatures(draftConfig, options),
listFeatures: ({ provider: providerModel, ...draftConfig }, options) => {
const { provider, model } = parseProviderModel(providerModel);
return daemonClient.listProviderFeatures({ ...draftConfig, provider, model }, options);
},
listAvailable: (options) => daemonClient.listAvailableProviders(options),
snapshot: (options) => daemonClient.getProvidersSnapshot(options),
waitForReady: (options) => waitForProvidersReady(daemonClient, options),
refresh: (options) => daemonClient.refreshProvidersSnapshot(options),
diagnostic: (provider, options) => daemonClient.getProviderDiagnostic(provider, options),
subscribe: (handler) =>
@@ -407,30 +456,71 @@ export function createPaseoClient(config: PaseoClientConfig): PaseoClient {
type WorkspaceHandleFactory = (workspace: string | PaseoWorkspace) => PaseoWorkspaceHandle;
type AgentHandleFactory = (agent: string | PaseoAgent) => PaseoAgentHandle;
type CreateAgent = (
options: PaseoAgentCreateOptions,
placement?: { workspaceId: string; cwd: string },
) => Promise<PaseoAgentHandle>;
function createWorkspaceHandleFactory(daemonClient: DaemonClient): WorkspaceHandleFactory {
function createWorkspaceHandleFactory(
daemonClient: DaemonClient,
createAgent: CreateAgent,
): WorkspaceHandleFactory {
return (workspace) => {
const id = typeof workspace === "string" ? workspace : workspace.id;
let latest = typeof workspace === "string" ? null : workspace;
let current = typeof workspace === "string" ? null : workspace;
const refresh = async (options?: { requestId?: string }) => {
let cursor: string | undefined;
let requestId = options?.requestId;
do {
const result = await daemonClient.fetchWorkspaces({
requestId,
page: { limit: 200, ...(cursor ? { cursor } : {}) },
});
const match = result.entries.find((entry) => entry.id === id);
if (match) {
current = match;
return current;
}
cursor = result.pageInfo.nextCursor ?? undefined;
requestId = undefined;
} while (cursor);
current = null;
return current;
};
return {
id,
latest: () => latest,
refetch: async (options) => {
// Best-effort: fetches one page and matches by id client-side, so a workspace beyond
// the first page won't be found. TODO: add a "get workspace by id" lookup and resolve
// by exact id instead of paging.
const result = await daemonClient.fetchWorkspaces({
requestId: options?.requestId,
page: { limit: 25 },
});
latest = result.entries.find((entry) => entry.id === id) ?? null;
return latest;
get projectId() {
return current?.projectId ?? null;
},
get directory() {
return current?.workspaceDirectory ?? null;
},
get name() {
return current?.name ?? null;
},
get status() {
return current?.status ?? null;
},
agents: {
create: async (options) => {
const snapshot = current ?? (await refresh());
if (!snapshot?.workspaceDirectory) {
throw new Error(`Workspace ${id} has no available directory`);
}
return createAgent(
{ ...options, cwd: snapshot.workspaceDirectory },
{ workspaceId: id, cwd: snapshot.workspaceDirectory },
);
},
},
current: () => current,
refresh,
archive: async (requestId) => {
const result = await daemonClient.archiveWorkspace(id, requestId);
if (latest) {
latest = { ...latest, archivingAt: result.archivedAt };
if (current) {
current = { ...current, archivingAt: result.archivedAt };
}
return result;
},
@@ -438,11 +528,11 @@ function createWorkspaceHandleFactory(daemonClient: DaemonClient): WorkspaceHand
daemonClient.on("workspace_update", (message) => {
const update = message.payload;
if (update.kind === "upsert" && update.workspace.id === id) {
latest = update.workspace;
current = update.workspace;
handler(update);
}
if (update.kind === "remove" && update.id === id) {
latest = null;
current = null;
handler(update);
}
}),
@@ -453,7 +543,7 @@ function createWorkspaceHandleFactory(daemonClient: DaemonClient): WorkspaceHand
function createAgentHandleFactory(daemonClient: DaemonClient): AgentHandleFactory {
return (agent) => {
const id = typeof agent === "string" ? agent : agent.id;
let latest = typeof agent === "string" ? null : agent;
let current = typeof agent === "string" ? null : agent;
const handle: PaseoAgentHandle = {
id,
@@ -461,7 +551,7 @@ function createAgentHandleFactory(daemonClient: DaemonClient): AgentHandleFactor
refetch: async (options) => {
const result = await daemonClient.fetchAgentTimeline(id, options);
if (result.agent) {
latest = result.agent;
current = result.agent;
}
return result;
},
@@ -472,19 +562,50 @@ function createAgentHandleFactory(daemonClient: DaemonClient): AgentHandleFactor
}
}),
},
latest: () => latest,
refetch: async (requestId) => {
get workspaceId() {
return current?.workspaceId ?? null;
},
get cwd() {
return current?.cwd ?? null;
},
get status() {
return current?.status ?? null;
},
current: () => current,
refresh: async (requestId) => {
const result = await daemonClient.fetchAgent({ agentId: id, requestId });
latest = result?.agent ?? null;
current = result?.agent ?? null;
return result;
},
send: async (text, options) => {
await daemonClient.sendAgentMessage(id, text, options);
},
run: async (text, options) => {
const { timeoutMs, ...sendOptions } = options ?? {};
await daemonClient.sendAgentMessage(id, text, sendOptions);
const result = await daemonClient.waitForFinish(
id,
timeoutMs ?? DEFAULT_WAIT_FOR_FINISH_MS,
);
if (result.final) {
current = result.final;
}
return result;
},
waitForFinish: async (timeoutMs) => {
const result = await daemonClient.waitForFinish(
id,
timeoutMs ?? DEFAULT_WAIT_FOR_FINISH_MS,
);
if (result.final) {
current = result.final;
}
return result;
},
archive: async () => {
const result = await daemonClient.archiveAgent(id);
if (latest) {
latest = { ...latest, archivedAt: result.archivedAt };
if (current) {
current = { ...current, archivedAt: result.archivedAt };
}
return result;
},
@@ -495,11 +616,11 @@ function createAgentHandleFactory(daemonClient: DaemonClient): AgentHandleFactor
daemonClient.on("agent_update", (message) => {
const update = message.payload;
if (update.kind === "upsert" && update.agent.id === id) {
latest = update.agent;
current = update.agent;
handler(update);
}
if (update.kind === "remove" && update.agentId === id) {
latest = null;
current = null;
handler(update);
}
}),
@@ -514,32 +635,116 @@ async function openWorkspace(
createWorkspaceHandle: WorkspaceHandleFactory,
input: string | PaseoWorkspaceOpenOptions,
requestId?: string,
): Promise<PaseoWorkspaceOpenResult> {
): Promise<PaseoWorkspaceHandle> {
const options = typeof input === "string" ? { cwd: input, requestId } : input;
const result = await daemonClient.openProject(options.cwd, options.requestId);
return {
...result,
workspace: result.workspace ? createWorkspaceHandle(result.workspace) : null,
};
if (result.error || !result.workspace) {
throw new Error(result.error ?? `The daemon did not open a workspace for ${options.cwd}`);
}
return createWorkspaceHandle(result.workspace);
}
function resolveWorkspaceId(workspace: string | PaseoWorkspaceHandle): string {
return typeof workspace === "string" ? workspace : workspace.id;
}
function providerConfig(
provider: PaseoAgentProvider,
input: PaseoProviderConfigInput = {},
): PaseoProviderConfig {
function resolveAgentId(agent: string | PaseoAgentHandle): string {
return typeof agent === "string" ? agent : agent.id;
}
function parseProviderModel(selection: string): { provider: string; model: string } {
const separator = selection.indexOf("/");
if (separator <= 0 || separator === selection.length - 1) {
throw new Error('Expected config.provider in "provider/model" format');
}
return {
provider,
...(input.model !== undefined ? { model: input.model } : {}),
...(input.modeId !== undefined ? { modeId: input.modeId } : {}),
...(input.thinkingOptionId !== undefined ? { thinkingOptionId: input.thinkingOptionId } : {}),
...(input.featureValues !== undefined ? { featureValues: input.featureValues } : {}),
provider: selection.slice(0, separator),
model: selection.slice(separator + 1),
};
}
function waitForProvidersReady(
daemonClient: DaemonClient,
options: PaseoProviderWaitOptions = {},
): Promise<PaseoProviderSnapshotResult> {
// COMPAT(providersSnapshotCwd): added in v0.3.2, remove gate after 2027-02-10.
if (daemonClient.getLastServerInfoMessage()?.features?.providersSnapshotCwd !== true) {
return Promise.reject(new Error("Update the host to wait for provider discovery."));
}
const { timeoutMs = 60_000, ...snapshotOptions } = options;
return new Promise((resolve, reject) => {
let settled = false;
let requestId: string | null = null;
let snapshotCwd: string | undefined;
const pendingUpdates = new Map<string | undefined, PaseoProviderSnapshotUpdate>();
let latestEntries: PaseoProviderSnapshotResult["entries"] = [];
const cleanup = () => {
clearTimeout(timeout);
unsubscribe();
};
const finish = (snapshot: PaseoProviderSnapshotResult) => {
if (settled) return;
settled = true;
cleanup();
resolve(snapshot);
};
const fail = (error: unknown) => {
if (settled) return;
settled = true;
cleanup();
reject(error instanceof Error ? error : new Error(String(error)));
};
const updateMatches = (update: PaseoProviderSnapshotUpdate) => update.cwd === snapshotCwd;
const unsubscribe = daemonClient.on("providers_snapshot_update", (message) => {
const update = message.payload;
if (!requestId) {
pendingUpdates.set(update.cwd, update);
return;
}
if (!updateMatches(update)) return;
latestEntries = update.entries;
if (update.entries.some((entry) => entry.status === "loading")) return;
finish({ ...update, requestId });
});
const timeout = setTimeout(() => {
const loading = latestEntries
.filter((entry) => entry.status === "loading")
.map((entry) => entry.provider)
.join(", ");
fail(
new Error(
loading
? `Timed out waiting for providers: ${loading}`
: "Timed out waiting for provider discovery",
),
);
}, timeoutMs);
void daemonClient
.getProvidersSnapshot(snapshotOptions)
.then((snapshot) => {
requestId = snapshot.requestId;
snapshotCwd = snapshot.cwd;
latestEntries = snapshot.entries;
if (!snapshot.entries.some((entry) => entry.status === "loading")) {
finish(snapshot);
return;
}
const pendingUpdate = pendingUpdates.get(snapshotCwd);
if (pendingUpdate && !pendingUpdate.entries.some((entry) => entry.status === "loading")) {
finish({ ...pendingUpdate, requestId });
}
return undefined;
})
.catch(fail);
});
}
function createGeneratedClientId(): string {
const randomId =
typeof globalThis.crypto?.randomUUID === "function"
@@ -66,6 +66,34 @@ const PreviousHubAgentCreateWithAutoArchiveRequestSchema =
});
describe("Hub session protocol", () => {
test("round-trips named-agent validation", () => {
const request = {
type: "hub.execution.agent.validate.request" as const,
requestId: "validate-codex",
provider: "codex",
model: "gpt-5.5",
thinkingOptionId: "xhigh",
providerOptions: {
sandbox_workspace_write: {
writable_roots: ["/var/cache/npm"],
network_access: false,
},
},
};
const response = {
type: "hub.execution.agent.validate.response" as const,
payload: {
requestId: request.requestId,
valid: true,
issues: [],
error: null,
},
};
expect(SessionInboundMessageSchema.parse(request)).toEqual(request);
expect(SessionOutboundMessageSchema.parse(response)).toEqual(response);
});
test("accepts the Hub execution create request", () => {
const message = {
type: "hub.execution.agent.create.request",
+42
View File
@@ -2530,6 +2530,20 @@ export const HubExecutionAgentCreateRequestSchema = z.object({
export type HubExecutionAgentCreateRequest = z.infer<typeof HubExecutionAgentCreateRequestSchema>;
export const HubExecutionAgentValidateRequestSchema = z.object({
type: z.literal("hub.execution.agent.validate.request"),
requestId: z.string(),
provider: z.string(),
model: z.string().optional(),
modeId: z.string().optional(),
thinkingOptionId: z.string().optional(),
providerOptions: ProviderOptionsSchema.optional(),
});
export type HubExecutionAgentValidateRequest = z.infer<
typeof HubExecutionAgentValidateRequestSchema
>;
const HubExecutionAgentCreateErrorSchema = z.discriminatedUnion("code", [
z.object({
code: z.literal("provider_options_invalid"),
@@ -2569,6 +2583,7 @@ export type HubExecutionControlRequest = z.infer<typeof HubExecutionControlReque
export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
HubExecutionAgentCreateRequestSchema,
HubExecutionAgentValidateRequestSchema,
HubExecutionControlRequestSchema,
BrowserAutomationExecuteResponseSchema,
VoiceAudioChunkMessageSchema,
@@ -2895,6 +2910,8 @@ export const ServerInfoStatusPayloadSchema = z
features: z
.object({
providersSnapshot: z.boolean().optional(),
// COMPAT(providersSnapshotCwd): added in v0.3.2, remove gate after 2027-02-10.
providersSnapshotCwd: z.boolean().optional(),
// COMPAT(checkoutForgeSetAutoMerge): added in v0.1.106, remove old
// checkoutGithubSetAutoMerge fallback after 2026-12-28.
checkoutForgeSetAutoMerge: z.boolean().optional(),
@@ -5049,6 +5066,7 @@ export const ListAvailableProvidersResponseSchema = z.object({
export const GetProvidersSnapshotResponseMessageSchema = z.object({
type: z.literal("get_providers_snapshot_response"),
payload: z.object({
cwd: z.string().optional(),
entries: z.array(ProviderSnapshotEntrySchema),
compactSnapshot: CompactProviderSnapshotSchema.optional(),
snapshotHash: z.string().optional(),
@@ -5340,6 +5358,25 @@ export const HubExecutionAgentCreateResponseSchema = z.object({
}),
});
export const HubExecutionAgentValidationIssueSchema = z.object({
path: z.array(z.union([z.string(), z.number()])),
message: z.string(),
});
export type HubExecutionAgentValidationIssue = z.infer<
typeof HubExecutionAgentValidationIssueSchema
>;
export const HubExecutionAgentValidateResponseSchema = z.object({
type: z.literal("hub.execution.agent.validate.response"),
payload: z.object({
requestId: z.string(),
valid: z.boolean(),
issues: z.array(HubExecutionAgentValidationIssueSchema),
error: z.string().nullable(),
}),
});
export const HubExecutionControlResponseSchema = z.object({
type: z.literal("hub.execution.control.response"),
payload: z.object({
@@ -5370,12 +5407,16 @@ export const HubExecutionAgentStreamSchema = z.object({
});
export type HubExecutionAgentCreateResponse = z.infer<typeof HubExecutionAgentCreateResponseSchema>;
export type HubExecutionAgentValidateResponse = z.infer<
typeof HubExecutionAgentValidateResponseSchema
>;
export type HubExecutionControlResponse = z.infer<typeof HubExecutionControlResponseSchema>;
export type HubExecutionAgentUpdate = z.infer<typeof HubExecutionAgentUpdateSchema>;
export type HubExecutionAgentStream = z.infer<typeof HubExecutionAgentStreamSchema>;
export const HubExecutionOutboundMessageSchema = z.discriminatedUnion("type", [
HubExecutionAgentCreateResponseSchema,
HubExecutionAgentValidateResponseSchema,
HubExecutionControlResponseSchema,
HubExecutionAgentUpdateSchema,
HubExecutionAgentStreamSchema,
@@ -5409,6 +5450,7 @@ export type DaemonUpdateProgressMessage = z.infer<typeof DaemonUpdateProgressMes
export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
HubExecutionAgentCreateResponseSchema,
HubExecutionAgentValidateResponseSchema,
HubExecutionControlResponseSchema,
HubExecutionAgentUpdateSchema,
HubExecutionAgentStreamSchema,
+3 -2
View File
@@ -46,12 +46,14 @@
"generate:config-schema": "tsx scripts/generate-config-schema.ts",
"measure:agent-tools": "tsx scripts/measure-agent-tools-context.ts",
"measure:git-observation": "tsx scripts/measure-git-observation.ts",
"measure:file-observer": "tsx scripts/measure-file-observer.ts",
"repro:file-observer-teardown": "tsx scripts/reproduce-file-observer-teardown.ts",
"speech:models": "tsx scripts/list-speech-models.ts",
"speech:download": "tsx scripts/download-speech-models.ts",
"speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts",
"test": "npm run test:unit && npm run test:integration",
"test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"",
"test:integration": "vitest run --maxWorkers=1 src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts",
"test:integration": "vitest run --maxWorkers=1 src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts && vitest run --maxWorkers=1 src/server/session.create-agent-worktree-autoarchive.e2e.test.ts -t \"creates a worktree and auto-archives both\"",
"test:hub-cli-contract": "vitest run --maxWorkers=1 src/server/hub/hub-cli-contract.test.ts",
"test:integration:all": "npm run test:e2e",
"test:integration:real": "vitest run real.e2e.test.ts",
@@ -76,7 +78,6 @@
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.14.46",
"@parcel/watcher": "^2.6.0",
"@xterm/headless": "^6.0.0",
"ai": "5.0.78",
"ajv": "^8.20.0",
@@ -0,0 +1,403 @@
import { execFile } from "node:child_process";
import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { monitorEventLoopDelay, performance } from "node:perf_hooks";
import { promisify } from "node:util";
import { createFileObserver, type FileChange } from "../src/server/file-observer/index.js";
const DIRECTORY_COUNT = readPositiveInteger("PASEO_WATCH_BENCH_DIRS", 500);
const ROOT_COUNT = readPositiveInteger("PASEO_WATCH_BENCH_ROOTS", 1);
const IGNORED_FILE_COUNT = readPositiveInteger("PASEO_WATCH_BENCH_IGNORED_FILES", 2_000);
const PREEXISTING_FILE_COUNT = readPositiveInteger("PASEO_WATCH_BENCH_PREEXISTING_FILES", 1_000);
const EDIT_COUNT = readPositiveInteger("PASEO_WATCH_BENCH_EDITS", 100);
const SUSTAINED_EDIT_COUNT = readPositiveInteger("PASEO_WATCH_BENCH_SUSTAINED_EDITS", 20);
const SUSTAINED_EDIT_INTERVAL_MS = readPositiveInteger(
"PASEO_WATCH_BENCH_SUSTAINED_INTERVAL_MS",
500,
);
const REPETITIONS = readPositiveInteger("PASEO_WATCH_BENCH_REPETITIONS", 1);
const execFileAsync = promisify(execFile);
interface Measurement {
backend: string;
run: number;
setupMs: number;
editLatencyMs: number;
editLatencyP50Ms: number;
editLatencyP95Ms: number;
editLatencyP99Ms: number;
burstDurationMs: number;
burstCpuMs: number;
sustainedDurationMs: number;
sustainedCpuMs: number;
sustainedScopedReconciliationCount: number | null;
sustainedFullReconciliationCount: number | null;
sustainedNativeEventCount: number | null;
sustainedNativeChangeEventCount: number | null;
sustainedNativeRenameEventCount: number | null;
sustainedNativePathlessEventCount: number | null;
sustainedNativeClassificationCount: number | null;
sustainedNativeShallowScanCount: number | null;
sustainedMissedPaths: number;
teardownMs: number;
startupEvents: number;
deliveredEvents: number;
ignoredEvents: number;
missedTrackedPaths: number;
cpuUserMs: number;
cpuSystemMs: number;
rssDeltaMiB: number;
eventLoopDelayP99Ms: number;
eventLoopDelayMaxMs: number;
kernelWatchCount: number | null;
scopedReconciliationCount: number | null;
fullReconciliationCount: number | null;
}
interface FixtureRoot {
root: string;
ignoredRoot: string;
trackedRoot: string;
}
async function main(): Promise<void> {
const isChild = process.argv.includes("--child");
if (isChild) {
process.stdout.write(`${JSON.stringify(await measure(1))}\n`);
return;
}
const results: Measurement[] = [];
for (let run = 1; run <= REPETITIONS; run += 1) {
results.push(await measureInChild(run));
}
const evaluation = evaluate(results);
process.stdout.write(
`${JSON.stringify({ fixture: fixtureDescription(), evaluation, results }, null, 2)}\n`,
);
if (!evaluation.passed) process.exitCode = 1;
}
async function measureInChild(run: number): Promise<Measurement> {
const { stdout } = await execFileAsync(
process.execPath,
[...process.execArgv, process.argv[1], "--child"],
{ env: process.env, maxBuffer: 1024 * 1024 },
);
return { ...(JSON.parse(stdout.trim()) as Measurement), run };
}
async function measure(run: number): Promise<Measurement> {
const base = await mkdtemp(join(tmpdir(), "paseo-watch-"));
const observer = createFileObserver();
const roots: FixtureRoot[] = [];
for (let rootIndex = 0; rootIndex < ROOT_COUNT; rootIndex += 1) {
const root = join(base, `worktree-${rootIndex}`);
const ignoredRoot = join(root, "ignored");
const trackedRoot = join(root, "tracked");
await Promise.all([
mkdir(ignoredRoot, { recursive: true }),
mkdir(trackedRoot, { recursive: true }),
]);
for (let index = 0; index < DIRECTORY_COUNT; index += 1) {
await mkdir(join(trackedRoot, `directory-${index}`));
}
await Promise.all(
Array.from({ length: PREEXISTING_FILE_COUNT }, (_, index) =>
writeFile(
join(trackedRoot, `directory-${index % DIRECTORY_COUNT}`, `existing-${index}.txt`),
`${index}\n`,
),
),
);
roots.push({ root, ignoredRoot, trackedRoot });
}
const rssBefore = process.memoryUsage().rss;
const cpuBefore = process.cpuUsage();
const diagnosticsBefore = observer.getDiagnostics();
const eventLoopDelay = monitorEventLoopDelay({ resolution: 10 });
eventLoopDelay.enable();
const delivered: FileChange[] = [];
const firstObservedAt = new Map<string, number>();
let error: Error | null = null;
const setupStarted = performance.now();
const subscriptions = await Promise.all(
roots.map(({ root, ignoredRoot }) =>
observer.subscribe(
root,
(nextError, events) => {
error ??= nextError;
delivered.push(...events);
for (const event of events) {
if (!firstObservedAt.has(event.path))
firstObservedAt.set(event.path, performance.now());
}
},
{ ignore: [ignoredRoot] },
),
),
);
const setupMs = performance.now() - setupStarted;
await new Promise((resolve) => setTimeout(resolve, 25));
const startupEvents = delivered.length;
const kernelWatchCount = await readKernelWatchCount();
try {
for (let index = 0; index < IGNORED_FILE_COUNT; index += 1) {
const target = roots[index % roots.length];
await writeFile(join(target.ignoredRoot, `artifact-${index}.txt`), `${index}\n`);
}
const trackedPaths: string[] = [];
const writtenAt = new Map<string, number>();
const burstCpuBefore = process.cpuUsage();
const editStarted = performance.now();
for (let index = 0; index < EDIT_COUNT; index += 1) {
const target = roots[index % roots.length];
const path = join(
target.trackedRoot,
`directory-${Math.floor(index / roots.length) % DIRECTORY_COUNT}`,
`edit-${index}.txt`,
);
trackedPaths.push(path);
await writeFile(path, `${index}\n`);
writtenAt.set(path, performance.now());
}
await waitFor(
() => trackedPaths.every((path) => delivered.some((event) => event.path === path)),
10_000,
);
const editLatencyMs = performance.now() - editStarted;
const observedLatencies = trackedPaths.map(
(path) =>
(firstObservedAt.get(path) ?? performance.now()) - (writtenAt.get(path) ?? editStarted),
);
if (error) throw error;
const burstSettled = await waitFor(() => {
const diagnostics = observer.getDiagnostics();
return (
diagnostics.pendingEventCount === 0 &&
diagnostics.pendingReconciliationWorkCount === 0 &&
diagnostics.reconciliationInFlightCount === 0
);
}, 30_000);
if (!burstSettled) throw new Error("Timed out waiting for observer reconciliation to settle");
const burstDurationMs = performance.now() - editStarted;
const burstCpu = process.cpuUsage(burstCpuBefore);
const burstCpuMs = (burstCpu.user + burstCpu.system) / 1_000;
const sustainedPaths: string[] = [];
const diagnosticsBeforeSustained = observer.getDiagnostics();
const sustainedCpuBefore = process.cpuUsage();
const sustainedStarted = performance.now();
for (let index = 0; index < SUSTAINED_EDIT_COUNT; index += 1) {
const target = roots[index % roots.length];
const path = join(target.trackedRoot, `sustained-${index}.txt`);
sustainedPaths.push(path);
await writeFile(path, `${index}\n`);
await new Promise((resolve) => setTimeout(resolve, SUSTAINED_EDIT_INTERVAL_MS));
}
await waitFor(
() => sustainedPaths.every((path) => delivered.some((event) => event.path === path)),
10_000,
);
const sustainedSettled = await waitFor(() => {
const diagnostics = observer.getDiagnostics();
return (
diagnostics.pendingEventCount === 0 &&
diagnostics.pendingReconciliationWorkCount === 0 &&
diagnostics.reconciliationInFlightCount === 0
);
}, 30_000);
if (!sustainedSettled) {
throw new Error("Timed out waiting for sustained observer reconciliation to settle");
}
const sustainedDurationMs = performance.now() - sustainedStarted;
const sustainedCpu = process.cpuUsage(sustainedCpuBefore);
const sustainedCpuMs = (sustainedCpu.user + sustainedCpu.system) / 1_000;
const diagnosticsAfterSustained = observer.getDiagnostics();
const teardownStarted = performance.now();
await Promise.all(subscriptions.map((subscription) => subscription.unsubscribe()));
const teardownMs = performance.now() - teardownStarted;
const diagnosticsAfter = observer.getDiagnostics();
const cpu = process.cpuUsage(cpuBefore);
eventLoopDelay.disable();
return {
backend: "production",
run,
setupMs,
editLatencyMs,
editLatencyP50Ms: percentile(observedLatencies, 0.5),
editLatencyP95Ms: percentile(observedLatencies, 0.95),
editLatencyP99Ms: percentile(observedLatencies, 0.99),
burstDurationMs,
burstCpuMs,
sustainedDurationMs,
sustainedCpuMs,
sustainedScopedReconciliationCount:
diagnosticsAfterSustained.scopedReconciliationCount -
diagnosticsBeforeSustained.scopedReconciliationCount,
sustainedFullReconciliationCount:
diagnosticsAfterSustained.fullReconciliationCount -
diagnosticsBeforeSustained.fullReconciliationCount,
sustainedNativeEventCount:
diagnosticsAfterSustained.nativeEventCount - diagnosticsBeforeSustained.nativeEventCount,
sustainedNativeChangeEventCount:
diagnosticsAfterSustained.nativeChangeEventCount -
diagnosticsBeforeSustained.nativeChangeEventCount,
sustainedNativeRenameEventCount:
diagnosticsAfterSustained.nativeRenameEventCount -
diagnosticsBeforeSustained.nativeRenameEventCount,
sustainedNativePathlessEventCount:
diagnosticsAfterSustained.nativePathlessEventCount -
diagnosticsBeforeSustained.nativePathlessEventCount,
sustainedNativeClassificationCount:
diagnosticsAfterSustained.nativeClassificationCount -
diagnosticsBeforeSustained.nativeClassificationCount,
sustainedNativeShallowScanCount:
diagnosticsAfterSustained.nativeShallowScanCount -
diagnosticsBeforeSustained.nativeShallowScanCount,
sustainedMissedPaths: sustainedPaths.filter(
(path) => !delivered.some((event) => event.path === path),
).length,
teardownMs,
startupEvents,
deliveredEvents: delivered.length,
ignoredEvents: delivered.filter((event) =>
roots.some(({ ignoredRoot }) => event.path.startsWith(ignoredRoot)),
).length,
missedTrackedPaths: trackedPaths.filter(
(path) => !delivered.some((event) => event.path === path),
).length,
cpuUserMs: cpu.user / 1_000,
cpuSystemMs: cpu.system / 1_000,
rssDeltaMiB: (process.memoryUsage().rss - rssBefore) / 1024 / 1024,
eventLoopDelayP99Ms: eventLoopDelay.percentile(99) / 1_000_000,
eventLoopDelayMaxMs: eventLoopDelay.max / 1_000_000,
kernelWatchCount,
scopedReconciliationCount:
diagnosticsAfter.scopedReconciliationCount - diagnosticsBefore.scopedReconciliationCount,
fullReconciliationCount:
diagnosticsAfter.fullReconciliationCount - diagnosticsBefore.fullReconciliationCount,
};
} finally {
await Promise.all(subscriptions.map((subscription) => subscription.unsubscribe()));
await observer.close();
await rm(base, { recursive: true, force: true });
}
}
async function readKernelWatchCount(): Promise<number | null> {
if (process.platform !== "linux") return null;
let count = 0;
for (const entry of await readdir("/proc/self/fdinfo")) {
const contents = await readFile(join("/proc/self/fdinfo", entry), "utf8").catch(() => "");
count += contents.split("\n").filter((line) => line.startsWith("inotify wd:")).length;
}
return count;
}
function fixtureDescription() {
return {
directoriesPerRoot: DIRECTORY_COUNT,
totalDirectories: DIRECTORY_COUNT * ROOT_COUNT,
roots: ROOT_COUNT,
ignoredFiles: IGNORED_FILE_COUNT,
preexistingFilesPerRoot: PREEXISTING_FILE_COUNT,
trackedEdits: EDIT_COUNT,
sustainedEdits: SUSTAINED_EDIT_COUNT,
sustainedEditIntervalMs: SUSTAINED_EDIT_INTERVAL_MS,
repetitions: REPETITIONS,
platform: process.platform,
arch: process.arch,
node: process.version,
};
}
function evaluate(results: Measurement[]): {
passed: boolean;
failures: string[];
} {
const failures: string[] = [];
for (const result of results) {
evaluateStandaloneResult(result, failures);
}
return { passed: failures.length === 0, failures };
}
function evaluateStandaloneResult(result: Measurement, failures: string[]): void {
if (result.missedTrackedPaths !== 0) {
failures.push(`observer missed ${result.missedTrackedPaths} tracked paths`);
}
if (result.ignoredEvents !== 0) {
failures.push(`observer emitted ${result.ignoredEvents} excluded events`);
}
if (result.sustainedMissedPaths !== 0) {
failures.push(`observer missed ${result.sustainedMissedPaths} sustained paths`);
}
if (result.teardownMs >= 1_000) {
failures.push(`observer teardown took ${result.teardownMs.toFixed(1)}ms`);
}
if ((result.sustainedFullReconciliationCount ?? 0) > 0) {
failures.push(
`node ran ${result.sustainedFullReconciliationCount} full reconciliations during sustained writes`,
);
}
const setupLimitMs = process.platform === "win32" ? 8_000 : 1_000;
if (result.setupMs > setupLimitMs) {
failures.push(
`node setup ${result.setupMs.toFixed(1)}ms exceeded the ${setupLimitMs}ms per-run limit on run ${result.run}`,
);
}
if (result.burstDurationMs > 60_000) {
failures.push(
`node burst recovery ${result.burstDurationMs.toFixed(1)}ms exceeded the 60000ms per-run limit on run ${result.run}`,
);
}
// The production observer repairs omitted native siblings with one shallow
// directory diff per touched directory; keep that bounded allowance isolated
// to burst recovery rather than weakening steady-state CPU or correctness.
const burstCpuLimitMs = Math.max(6_000, result.burstDurationMs * 0.5);
if (result.burstCpuMs > burstCpuLimitMs) {
failures.push(
`node burst CPU ${result.burstCpuMs.toFixed(1)}ms exceeded the ${burstCpuLimitMs.toFixed(1)}ms per-run limit on run ${result.run}`,
);
}
if (result.eventLoopDelayP99Ms > 250) {
failures.push(
`node event-loop p99 ${result.eventLoopDelayP99Ms.toFixed(1)}ms exceeded the 250ms per-run limit on run ${result.run}`,
);
}
const sustainedCpuLimitMs = result.sustainedDurationMs * 0.25;
if (result.sustainedCpuMs > sustainedCpuLimitMs) {
failures.push(
`node sustained-create CPU ${result.sustainedCpuMs.toFixed(1)}ms exceeded the ${sustainedCpuLimitMs.toFixed(1)}ms per-run limit on run ${result.run}`,
);
}
}
function percentile(values: number[], quantile: number): number {
if (values.length === 0) return 0;
const sorted = [...values].sort((left, right) => left - right);
return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * quantile) - 1)] ?? 0;
}
async function waitFor(predicate: () => boolean, timeoutMs: number): Promise<boolean> {
const deadline = performance.now() + timeoutMs;
while (!predicate()) {
if (performance.now() >= deadline) return false;
await new Promise((resolve) => setTimeout(resolve, 10));
}
return true;
}
function readPositiveInteger(name: string, fallback: number): number {
const value = Number(process.env[name] ?? fallback);
if (!Number.isInteger(value) || value <= 0) throw new Error(`${name} must be a positive integer`);
return value;
}
await main();
@@ -3,7 +3,6 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import parcelWatcher from "@parcel/watcher";
import type pino from "pino";
import {
getCheckoutDiff,
@@ -16,11 +15,15 @@ import {
type GitCommandMetricsSnapshot,
} from "../src/utils/run-git-command.js";
import { CheckoutDiffManager } from "../src/server/checkout-diff-manager.js";
import { createFileObserver } from "../src/server/file-observer/index.js";
import {
subscribeToWorkspaceFileChanges,
WorkspaceGitServiceImpl,
type WorkspaceGitRuntimeSnapshot,
} from "../src/server/workspace-git-service.js";
import type {
FileObserverSubscription,
SubscribeToFileChanges,
} from "../src/server/file-observer/index.js";
interface ProducerCounts {
structural: number;
@@ -90,21 +93,23 @@ function snapshotCounts(counts: ProducerCounts): ProducerCounts {
}
function createTrackedSubscriber(failWorktreeRoot: string | null) {
const subscriptions = new Set<parcelWatcher.AsyncSubscription>();
const subscribe = async (...args: Parameters<typeof parcelWatcher.subscribe>) => {
const observer = createFileObserver();
const subscriptions = new Set<FileObserverSubscription>();
const subscribe: SubscribeToFileChanges = async (...args) => {
if (failWorktreeRoot && path.resolve(args[0]) === failWorktreeRoot) {
throw new Error("measurement watcher setup failure");
}
const subscription = await subscribeToWorkspaceFileChanges(...args);
const subscription = await observer.subscribe(...args);
subscriptions.add(subscription);
return {
updateIgnore: (paths) => subscription.updateIgnore(paths),
unsubscribe: async () => {
await subscription.unsubscribe();
subscriptions.delete(subscription);
},
};
};
return { subscribe, subscriptions };
return { observer, subscribe, subscriptions };
}
function createMeasuredService(input: {
@@ -175,10 +180,12 @@ async function measurePhase(input: {
async function closeMeasuredService(input: {
service: WorkspaceGitServiceImpl;
subscriptions: Set<parcelWatcher.AsyncSubscription>;
observer: ReturnType<typeof createFileObserver>;
subscriptions: Set<FileObserverSubscription>;
}): Promise<void> {
input.service.dispose();
await input.service.dispose();
await waitFor(() => input.subscriptions.size === 0, "watchers to close");
await input.observer.close();
}
async function main(): Promise<void> {
@@ -396,12 +403,14 @@ async function main(): Promise<void> {
if (healthy) {
await closeMeasuredService({
service: healthy.service,
observer: healthy.watcher.observer,
subscriptions: healthy.watcher.subscriptions,
}).catch(() => {});
}
if (degraded) {
await closeMeasuredService({
service: degraded.service,
observer: degraded.watcher.observer,
subscriptions: degraded.watcher.subscriptions,
}).catch(() => {});
}
@@ -0,0 +1,169 @@
import { execFile } from "node:child_process";
import { mkdir, mkdtemp, readdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { performance } from "node:perf_hooks";
import { promisify } from "node:util";
import { createFileObserver } from "../src/server/file-observer/index.js";
const execFileAsync = promisify(execFile);
const ITERATIONS = readPositiveInteger("PASEO_WATCH_REPRO_ITERATIONS", 200);
const CHILD_TIMEOUT_MS = readPositiveInteger(
"PASEO_WATCH_REPRO_TIMEOUT_MS",
Math.max(45_000, ITERATIONS * 150),
);
interface ChildResult {
backend: string;
iterations: number;
completed: number;
teardownErrors: string[];
elapsedMs: number;
teardownP99Ms: number;
callbacksAfterClose: number;
fileDescriptorsBefore: number | null;
fileDescriptorsAfter: number | null;
rssGrowthMiB: number;
observerBaselineRestored: boolean;
assertionsPassed: boolean;
}
async function main(): Promise<void> {
if (process.argv.includes("--child")) {
process.stdout.write(`${JSON.stringify(await runChild())}\n`);
return;
}
const startedAt = performance.now();
let result;
try {
const { stdout } = await execFileAsync(
process.execPath,
[...process.execArgv, process.argv[1], "--child"],
{ env: process.env, maxBuffer: 1024 * 1024, timeout: CHILD_TIMEOUT_MS },
);
result = { ...JSON.parse(stdout.trim()), wedged: false };
} catch (error) {
const processError = error as Error & { killed?: boolean; signal?: string };
result = {
backend: "production",
iterations: ITERATIONS,
completed: 0,
teardownErrors: [processError.message],
elapsedMs: performance.now() - startedAt,
wedged: processError.killed === true || processError.signal === "SIGTERM",
};
}
process.stdout.write(
`${JSON.stringify({ platform: process.platform, node: process.version, result }, null, 2)}\n`,
);
const productionPassed =
"assertionsPassed" in result && result.assertionsPassed && result.wedged === false;
if (!productionPassed) process.exitCode = 1;
}
async function runChild(): Promise<ChildResult> {
const base = await mkdtemp(join(tmpdir(), "paseo-watch-repro-"));
const observer = createFileObserver();
const teardownErrors: string[] = [];
const teardownDurations: number[] = [];
const startedAt = performance.now();
const warmupRoot = join(base, "warmup");
await mkdir(warmupRoot);
const warmupSubscription = await observer.subscribe(warmupRoot, () => undefined);
await warmupSubscription.unsubscribe();
await rm(warmupRoot, { recursive: true, force: true });
const rssBefore = process.memoryUsage().rss;
const fileDescriptorsBefore = await readFileDescriptorCount();
const diagnosticsBefore = observer.getDiagnostics();
let completed = 0;
let callbacksAfterClose = 0;
const sharedSubscription = await observer.subscribe(base, () => undefined);
try {
for (let index = 0; index < ITERATIONS; index += 1) {
const root = join(base, `worktree-${index}`);
await mkdir(join(root, "nested"), { recursive: true });
let closed = false;
const subscription = await observer.subscribe(root, () => {
if (closed) callbacksAfterClose += 1;
});
const command = process.platform === "win32" ? "cmd.exe" : "true";
const commandArgs = process.platform === "win32" ? ["/c", "exit", "0"] : [];
await Promise.all([
writeFile(join(root, "nested", `edit-${index}.txt`), `${index}\n`),
execFileAsync(command, commandArgs),
]);
await writeFile(join(root, "archive-trigger"), "archive\n");
const teardownStartedAt = performance.now();
try {
await rm(root, { recursive: true, force: true });
} catch (error) {
teardownErrors.push(error instanceof Error ? error.message : String(error));
}
await subscription.unsubscribe().catch((error: unknown) => {
teardownErrors.push(error instanceof Error ? error.message : String(error));
});
closed = true;
teardownDurations.push(performance.now() - teardownStartedAt);
completed += 1;
}
} finally {
await sharedSubscription.unsubscribe().catch((error: unknown) => {
teardownErrors.push(error instanceof Error ? error.message : String(error));
});
await observer.close();
await rm(base, { recursive: true, force: true });
}
await new Promise((resolve) => setTimeout(resolve, 25));
const diagnosticsAfter = observer.getDiagnostics();
const fileDescriptorsAfter = await readFileDescriptorCount();
const rssGrowthMiB = (process.memoryUsage().rss - rssBefore) / 1024 / 1024;
const teardownP99Ms = percentile(teardownDurations, 0.99);
const observerBaselineRestored =
diagnosticsAfter.activeObservationCount === diagnosticsBefore.activeObservationCount &&
diagnosticsAfter.nativeHandleCount === diagnosticsBefore.nativeHandleCount &&
diagnosticsAfter.pendingEventCount === diagnosticsBefore.pendingEventCount &&
diagnosticsAfter.reconciliationInFlightCount === diagnosticsBefore.reconciliationInFlightCount;
const assertionsPassed =
completed === ITERATIONS &&
teardownErrors.length === 0 &&
callbacksAfterClose === 0 &&
observerBaselineRestored &&
(fileDescriptorsBefore === null || fileDescriptorsAfter === fileDescriptorsBefore) &&
rssGrowthMiB < 128 &&
teardownP99Ms < 1_000;
return {
backend: "production",
iterations: ITERATIONS,
completed,
teardownErrors: [...new Set(teardownErrors)],
elapsedMs: performance.now() - startedAt,
teardownP99Ms,
callbacksAfterClose,
fileDescriptorsBefore,
fileDescriptorsAfter,
rssGrowthMiB,
observerBaselineRestored,
assertionsPassed,
};
}
async function readFileDescriptorCount(): Promise<number | null> {
if (process.platform !== "linux") return null;
return (await readdir("/proc/self/fd")).length;
}
function percentile(values: number[], quantile: number): number {
if (values.length === 0) return 0;
const sorted = [...values].sort((left, right) => left - right);
return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * quantile) - 1)] ?? 0;
}
function readPositiveInteger(name: string, fallback: number): number {
const value = Number(process.env[name] ?? fallback);
if (!Number.isInteger(value) || value <= 0) throw new Error(`${name} must be a positive integer`);
return value;
}
await main();
@@ -0,0 +1,69 @@
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
import type { HubExecutionAgentValidationIssue } from "@getpaseo/protocol/messages";
import type { ProviderSnapshotEntry } from "./agent-sdk-types.js";
import { filterSelectableAgentModels } from "./agent-sdk-types.js";
import { ProviderOptionsValidationError } from "./provider-options.js";
export interface AgentConfigurationValidationInput {
provider: string;
model?: string;
modeId?: string;
thinkingOptionId?: string;
providerOptions?: ProviderOptions;
}
interface AgentConfigurationValidationContext {
input: AgentConfigurationValidationInput;
provider: ProviderSnapshotEntry;
validateOptions(options: ProviderOptions | undefined): ProviderOptions | undefined;
}
export function validateAgentConfigurationAgainstProvider({
input,
provider,
validateOptions,
}: AgentConfigurationValidationContext): HubExecutionAgentValidationIssue[] {
const issues: HubExecutionAgentValidationIssue[] = [];
const models = filterSelectableAgentModels(provider.models);
const requestedModel = input.model;
const selectedModel = requestedModel
? models.find((model) => model.id === requestedModel || model.aliases?.includes(requestedModel))
: (models.find((model) => model.isDefault) ?? models[0]);
if (requestedModel && !selectedModel) {
issues.push({
path: ["model"],
message: `Model '${requestedModel}' is not available for provider '${input.provider}'`,
});
}
if (input.modeId && !provider.modes?.some((mode) => mode.id === input.modeId)) {
issues.push({
path: ["modeId"],
message: `Mode '${input.modeId}' is not available for provider '${input.provider}'`,
});
}
if (
input.thinkingOptionId &&
!selectedModel?.thinkingOptions?.some((option) => option.id === input.thinkingOptionId)
) {
issues.push({
path: ["thinkingOptionId"],
message: `Thinking option '${input.thinkingOptionId}' is not available for provider '${input.provider}'`,
});
}
try {
validateOptions(input.providerOptions);
} catch (error) {
if (!(error instanceof ProviderOptionsValidationError)) throw error;
issues.push(
...error.issues.map((issue) => ({
path: ["providerOptions", ...issue.path],
message: issue.message,
})),
);
}
return issues;
}
@@ -69,6 +69,84 @@ async function withEnv(key: string, value: string, run: () => Promise<void>): Pr
}
describe("ProviderSnapshotManager public surface", () => {
test("validates complete Hub agent configurations through the current provider contract", async () => {
const manager = new ProviderSnapshotManager({
logger: createTestLogger(),
extraClients: {
codex: createExtraClient("codex", {
isAvailable: async () => true,
fetchCatalog: async () => ({
models: [
{
provider: "codex",
id: "gpt-5.5",
aliases: ["gpt-latest"],
label: "GPT 5.5",
thinkingOptions: [{ id: "xhigh", label: "Extra high" }],
},
],
modes: [{ id: "auto-review", label: "Auto review" }],
}),
}),
},
});
try {
await expect(
manager.validateAgentConfiguration({
provider: "codex",
model: "gpt-latest",
modeId: "auto-review",
thinkingOptionId: "xhigh",
providerOptions: {
sandbox_workspace_write: {
writable_roots: ["/var/cache/npm"],
network_access: false,
},
},
}),
).resolves.toEqual([]);
await expect(
manager.validateAgentConfiguration({
provider: "codex",
model: "missing",
modeId: "missing",
thinkingOptionId: "missing",
providerOptions: {
sandbox_workspace_write: { network_access: "sometimes" },
},
}),
).resolves.toEqual([
{ path: ["model"], message: "Model 'missing' is not available for provider 'codex'" },
{ path: ["modeId"], message: "Mode 'missing' is not available for provider 'codex'" },
{
path: ["thinkingOptionId"],
message: "Thinking option 'missing' is not available for provider 'codex'",
},
{
path: ["providerOptions", "sandbox_workspace_write", "network_access"],
message: "Invalid input: expected boolean, received string",
},
]);
} finally {
manager.destroy();
}
});
test("reports an unavailable Hub agent provider at the authored provider field", async () => {
const manager = new ProviderSnapshotManager({ logger: createTestLogger() });
try {
await expect(
manager.validateAgentConfiguration({ provider: "not-installed" }),
).resolves.toEqual([
{ path: ["provider"], message: "Provider 'not-installed' is not configured" },
]);
} finally {
manager.destroy();
}
});
test("listRegisteredProviderIds includes the built-in providers", () => {
const manager = new ProviderSnapshotManager({ logger: createTestLogger() });
try {
@@ -35,6 +35,11 @@ import {
formatProviderDiagnosticError,
} from "./providers/diagnostic-utils.js";
import type { MutableDaemonConfig } from "../daemon-config-store.js";
import type { HubExecutionAgentValidationIssue } from "@getpaseo/protocol/messages";
import {
type AgentConfigurationValidationInput,
validateAgentConfigurationAgainstProvider,
} from "./agent-configuration-validator.js";
const DEFAULT_REFRESH_TIMEOUT_MS = 60_000;
const DEFAULT_DIAGNOSTIC_TIMEOUT_MS = 120_000;
@@ -325,6 +330,45 @@ export class ProviderSnapshotManager {
return entry;
}
async validateAgentConfiguration(
input: AgentConfigurationValidationInput,
): Promise<HubExecutionAgentValidationIssue[]> {
if (!this.hasProvider(input.provider)) {
return [
{
path: ["provider"],
message: `Provider '${input.provider}' is not configured`,
},
];
}
const provider = await this.getProvider({
provider: input.provider,
wait: true,
});
if (!provider.enabled) {
return [{ path: ["provider"], message: `Provider '${input.provider}' is disabled` }];
}
if (provider.status !== "ready") {
return [
{
path: ["provider"],
message:
provider.status === "error" && provider.error
? provider.error
: `Provider '${input.provider}' is not available`,
},
];
}
const definition = this.requireProvider(input.provider);
return validateAgentConfigurationAgainstProvider({
input,
provider,
validateOptions: definition.validateOptions,
});
}
async listModels(input: ProviderSnapshotProviderOptions): Promise<AgentModelDefinition[]> {
const entry = await this.getReadyProvider(input);
return filterSelectableAgentModels(entry.models);
@@ -564,6 +564,32 @@ describe("Codex app-server provider", () => {
}
});
test("launches full-access turns with Codex's no-approval unrestricted policy", async () => {
const appServer = createFakeCodexAppServer();
const session = new CodexAppServerAgentSession(
createConfig({ cwd: "/workspace/project", modeId: "full-access" }),
null,
createTestLogger(),
async () => appServer.child,
);
try {
await session.connect();
await session.startTurn("run an ordinary command");
await expect(appServer.waitForThreadStart()).resolves.toMatchObject({
approvalPolicy: "never",
sandbox: "danger-full-access",
});
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
approvalPolicy: "never",
sandboxPolicy: { type: "dangerFullAccess" },
});
} finally {
await session.close();
}
});
test("preapproves only granted tools on the injected Codex MCP server", async () => {
const session = createSession({
modeId: undefined,
@@ -1024,6 +1050,80 @@ describe("Codex app-server provider", () => {
await session.close();
});
test("allows permission-only MCP elicitations in full-access mode", async () => {
const appServer = createFakeCodexAppServer();
const session = new CodexAppServerAgentSession(
createConfig({ cwd: "/workspace/project", modeId: "full-access" }),
null,
createTestLogger(),
async () => appServer.child,
);
const events: AgentStreamEvent[] = [];
await session.connect();
session.subscribe((event) => events.push(event));
try {
appServer.requestMcpElicitation({
threadId: "thread-1",
turnId: "turn-1",
serverName: "github",
message: "Allow Codex to use the GitHub connector?",
requestedSchema: {
type: "object",
properties: {},
},
});
await expect(appServer.waitForMcpElicitationDecision()).resolves.toEqual({
action: "accept",
content: {},
_meta: null,
});
expect(events).not.toContainEqual(expect.objectContaining({ type: "permission_requested" }));
} finally {
await session.close();
}
});
test("does not fabricate data for a data-bearing full-access MCP elicitation", async () => {
const appServer = createFakeCodexAppServer();
const session = new CodexAppServerAgentSession(
createConfig({ cwd: "/workspace/project", modeId: "full-access" }),
null,
createTestLogger(),
async () => appServer.child,
);
const events: AgentStreamEvent[] = [];
await session.connect();
session.subscribe((event) => events.push(event));
try {
appServer.requestMcpElicitation({
threadId: "thread-1",
turnId: "turn-1",
serverName: "github",
message: "Enter the repository name to use.",
requestedSchema: {
type: "object",
properties: {
repository: { type: "string" },
},
},
});
await expect(appServer.waitForMcpElicitationDecision()).resolves.toEqual({
action: "decline",
content: null,
_meta: null,
});
expect(events).not.toContainEqual(expect.objectContaining({ type: "permission_requested" }));
} finally {
await session.close();
}
});
test("initializes Codex app-server without making Paseo the request originator", async () => {
let initializeParams: unknown;
const appServer = createFakeCodexAppServer({
@@ -830,6 +830,22 @@ function toObjectRecord(value: unknown): Record<string, unknown> | undefined {
return isRecord(value) ? value : undefined;
}
function isPermissionOnlyMcpElicitationSchema(schema: unknown): boolean {
const schemaRecord = toObjectRecord(schema);
const properties = toObjectRecord(schemaRecord?.properties);
return properties !== undefined && Object.keys(properties).length === 0;
}
function isCodexFullAccessMode(modeId: string, providerOptions: CodexProviderOptions): boolean {
return (
modeId === "full-access" &&
(providerOptions.approval_policy === undefined ||
providerOptions.approval_policy === "never") &&
(providerOptions.sandbox_mode === undefined ||
providerOptions.sandbox_mode === "danger-full-access")
);
}
// Codex app-server API response types
interface CodexReasoningEffortEntry {
reasoningEffort?: string;
@@ -6414,6 +6430,16 @@ export class CodexAppServerAgentSession implements AgentSession {
if (parsed.mode === "url") {
return Promise.resolve({ action: "decline", content: null, _meta: null });
}
if (isCodexFullAccessMode(this.currentMode, this.providerOptions)) {
// Codex's never-ask policy accepts only empty MCP forms and declines
// forms that need data. Keep that provider contract when an app-server
// request still reaches Paseo; never fabricate form or auth values.
return Promise.resolve(
isPermissionOnlyMcpElicitationSchema(parsed.requestedSchema)
? { action: "accept", content: {}, _meta: null }
: { action: "decline", content: null, _meta: null },
);
}
const requiredFields = toObjectRecord(parsed.requestedSchema)?.required;
if (Array.isArray(requiredFields) && requiredFields.length > 0) {
return Promise.resolve({ action: "decline", content: null, _meta: null });
@@ -48,6 +48,7 @@ export interface FakeCodexAppServer {
readonly child: CodexAppServerChildProcess;
readonly recordedRollbacks: JsonObject[];
assertNoErrors(): void;
waitForThreadStart(): Promise<JsonObject>;
waitForTurnStart(): Promise<JsonObject>;
nextResponse(): Promise<string>;
startsTurn(params: { threadId: string; turnId?: string }): void;
@@ -297,6 +298,13 @@ export function createFakeCodexAppServer(
throw errors[0];
}
},
async waitForThreadStart() {
const message = await waitForMessage(
(candidate) => candidate.method === "thread/start",
"thread start request",
);
return toJsonObject(message.params);
},
async waitForTurnStart() {
const message = await waitForMessage(
(candidate) => candidate.method === "turn/start",
@@ -0,0 +1,362 @@
import { mkdir, mkdtemp, rename, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, sep } from "node:path";
import { afterEach, expect, test, vi } from "vitest";
import {
createFileObserver,
type FileChange,
type FileObserver,
type FileObserverCallback,
type FileObserverOptions,
type FileObserverSubscription,
} from "./index.js";
const roots = new Set<string>();
const observers = new Set<FileObserver>();
afterEach(async () => {
vi.useRealTimers();
await Promise.all([...observers].map((observer) => observer.close()));
observers.clear();
await Promise.all([...roots].map((root) => rm(root, { recursive: true, force: true })));
roots.clear();
});
test("observes nested files while pruning excluded directories", async () => {
const root = await createRoot();
const ignored = join(root, "ignored");
const observed = join(root, "nested");
await mkdir(ignored);
await mkdir(observed);
const events: FileChange[] = [];
const subscription = await subscribeToFileChanges(
root,
(error, batch) => {
expect(error).toBeNull();
events.push(...batch);
},
{ ignore: [ignored] },
);
await writeFile(join(ignored, "ignored.txt"), "ignored");
const observedPath = join(observed, "observed.txt");
await writeFile(observedPath, "observed");
await expect.poll(() => events.map((event) => event.path)).toContain(observedPath);
expect(events.map((event) => event.path)).not.toContain(join(ignored, "ignored.txt"));
await subscription.unsubscribe();
});
test("covers files populated immediately inside a newly created directory", async () => {
const root = await createRoot();
const events: FileChange[] = [];
const subscription = await subscribeToFileChanges(root, (error, batch) => {
expect(error).toBeNull();
events.push(...batch);
});
const createdDirectory = join(root, "new", "nested");
await mkdir(createdDirectory, { recursive: true });
const firstPath = join(createdDirectory, "first.txt");
await writeFile(firstPath, "first");
await expect.poll(() => events.map((event) => event.path)).toContain(firstPath);
const secondPath = join(createdDirectory, "second.txt");
await writeFile(secondPath, "second");
await expect.poll(() => events.map((event) => event.path)).toContain(secondPath);
await subscription.unsubscribe();
});
test("re-admits an excluded directory without replacing the subscription", async () => {
const root = await createRoot();
const ignored = join(root, "ignored");
await mkdir(ignored);
const events: FileChange[] = [];
const subscription = await subscribeToFileChanges(
root,
(error, batch) => {
expect(error).toBeNull();
events.push(...batch);
},
{ ignore: [ignored] },
);
await subscription.updateIgnore([]);
const observedPath = join(ignored, "observed.txt");
await writeFile(observedPath, "observed");
await expect.poll(() => events.map((event) => event.path)).toContain(observedPath);
await subscription.unsubscribe();
});
test("an ignore update is a barrier for later delivery", async () => {
const root = await createRoot();
const ignored = join(root, "generated");
await mkdir(ignored);
const delivered: FileChange[] = [];
const deliveredPaths = new Set<string>();
const subscription = await subscribeToFileChanges(root, (error, events) => {
expect(error).toBeNull();
delivered.push(...events);
for (const event of events) deliveredPaths.add(event.path);
});
await Promise.all(
Array.from({ length: 100 }, (_, index) =>
writeFile(join(ignored, `before-${index}.txt`), `${index}`),
),
);
const outsidePaths = Array.from({ length: 1_000 }, (_, index) =>
join(root, `outside-${index}.txt`),
);
await Promise.all([
Promise.all(outsidePaths.map((path, index) => writeFile(path, `${index}`))),
subscription.updateIgnore([ignored]),
]);
await expect
.poll(() => outsidePaths.filter((path) => !deliveredPaths.has(path)), { timeout: 10_000 })
.toEqual([]);
const deliveredAtBarrier = delivered.length;
await Promise.all(
Array.from({ length: 100 }, (_, index) =>
writeFile(join(ignored, `after-${index}.txt`), `${index}`),
),
);
const sentinel = join(root, "still-observed.txt");
await writeFile(sentinel, "observed");
await expect.poll(() => deliveredPaths.has(sentinel)).toBe(true);
expect(
delivered
.slice(deliveredAtBarrier)
.filter((event) => event.path === ignored || event.path.startsWith(`${ignored}${sep}`)),
).toEqual([]);
await subscription.unsubscribe();
}, 20_000);
test("survives atomic replacement and remains observable", async () => {
const root = await createRoot();
const target = join(root, "target.txt");
await writeFile(target, "before");
const events: FileChange[] = [];
const subscription = await subscribeToFileChanges(root, (error, batch) => {
expect(error).toBeNull();
events.push(...batch);
});
const replacement = join(root, "replacement.txt");
await writeFile(replacement, "after");
await rename(replacement, target);
await expect.poll(() => events.map((event) => event.path)).toContain(target);
events.length = 0;
await writeFile(target, "again");
await expect.poll(() => events.map((event) => event.path)).toContain(target);
await subscription.unsubscribe();
});
test("classifies a removed file as deleted", async () => {
const root = await createRoot();
const target = join(root, "removed.txt");
await writeFile(target, "before");
const events: FileChange[] = [];
const subscription = await subscribeToFileChanges(root, (error, batch) => {
expect(error).toBeNull();
events.push(...batch);
});
await rm(target);
await expect.poll(() => events.find((event) => event.path === target)?.type).toBe("delete");
await subscription.unsubscribe();
});
test("observes files moved into the tree with their directory", async () => {
const root = await createRoot();
const outside = await createRoot();
const movedFrom = join(outside, "prepared");
await mkdir(movedFrom);
const movedFile = join(movedFrom, "already-written.txt");
await writeFile(movedFile, "ready");
const events: FileChange[] = [];
const subscription = await subscribeToFileChanges(root, (error, batch) => {
expect(error).toBeNull();
events.push(...batch);
});
const movedTo = join(root, "prepared");
await rename(movedFrom, movedTo);
await expect
.poll(() => events.map((event) => event.path))
.toContain(join(movedTo, "already-written.txt"));
await subscription.unsubscribe();
});
test("delivers no callbacks after unsubscribe resolves", async () => {
const root = await createRoot();
const callback = vi.fn();
const subscription = await subscribeToFileChanges(root, callback);
await subscription.unsubscribe();
await writeFile(join(root, "after-close.txt"), "closed");
await new Promise((resolve) => setTimeout(resolve, 50));
expect(callback).not.toHaveBeenCalled();
});
test("deleting a watched root cannot wedge idempotent unsubscription", async () => {
const root = await createRoot();
const subscription = await subscribeToFileChanges(root, () => undefined);
await rm(root, { recursive: true, force: true });
await expect(subscription.unsubscribe()).resolves.toBeUndefined();
await expect(subscription.unsubscribe()).resolves.toBeUndefined();
});
test("observes a thousand concurrent writes and remains healthy after delete and rename churn", async () => {
const root = await createRoot();
const directories = Array.from({ length: 20 }, (_, index) => join(root, `dir-${index}`));
await Promise.all(directories.map((directory) => mkdir(directory)));
const observed = new Map<string, Set<FileChange["type"]>>();
const subscription = await subscribeToFileChanges(root, (error, events) => {
expect(error).toBeNull();
for (const event of events) {
const types = observed.get(event.path) ?? new Set();
types.add(event.type);
observed.set(event.path, types);
}
});
const paths = Array.from({ length: 1_000 }, (_, index) =>
join(directories[index % directories.length], `file-${index}.txt`),
);
const populatedDirectory = join(root, "populated", "nested");
await mkdir(populatedDirectory, { recursive: true });
const populatedPaths = Array.from({ length: 200 }, (_, index) =>
join(populatedDirectory, `nested-${index}.txt`),
);
await Promise.all(populatedPaths.map((path, index) => writeFile(path, `${index}`)));
await expect
.poll(() => populatedPaths.filter((path) => !observed.has(path)), { timeout: 10_000 })
.toEqual([]);
await Promise.all(paths.map((path, index) => writeFile(path, `${index}`)));
await expect
.poll(() => paths.filter((path) => !observed.has(path)), { timeout: 10_000 })
.toEqual([]);
const removedPaths = paths.slice(0, 100);
await Promise.all(removedPaths.map((path) => rm(path)));
await expect
.poll(() => removedPaths.filter((path) => !observed.get(path)?.has("delete")), {
timeout: 10_000,
})
.toEqual([]);
for (let index = 0; index < 10; index += 1) {
const from = directories[index];
const to = join(root, `renamed-${index}`);
await rename(from, to);
await rm(to, { recursive: true, force: true });
}
const sentinel = join(directories[15], "still-observed.txt");
await writeFile(sentinel, "alive");
await expect.poll(() => observed.has(sentinel)).toBe(true);
await subscription.unsubscribe();
}, 30_000);
test("unsubscribe cancels reconciliation queued by a write burst", async () => {
const root = await createRoot();
const callback = vi.fn();
const subscription = await subscribeToFileChanges(root, callback);
await Promise.all(
Array.from({ length: 1_000 }, (_, index) =>
writeFile(join(root, `closing-${index}.txt`), `${index}`),
),
);
await expect(subscription.unsubscribe()).resolves.toBeUndefined();
const callsAtClose = callback.mock.calls.length;
await writeFile(join(root, "after-burst-close.txt"), "closed");
await new Promise((resolve) => setTimeout(resolve, 50));
expect(callback).toHaveBeenCalledTimes(callsAtClose);
}, 20_000);
test("aggregate diagnostics return to their lifecycle baseline", async () => {
const root = await createRoot();
const observer = createObserver();
const subscription = await observer.subscribe(root, () => undefined);
expect(observer.getDiagnostics()).toMatchObject({
activeObservationCount: 1,
});
expect(observer.getDiagnostics().nativeHandleCount).toBeGreaterThan(0);
await subscription.unsubscribe();
expect(observer.getDiagnostics()).toMatchObject({
activeObservationCount: 0,
nativeHandleCount: 0,
nativeTrackedFileCount: 0,
pendingEventCount: 0,
reconciliationInFlightCount: 0,
});
});
test("closing the observer service releases every subscription and rejects reuse", async () => {
const firstRoot = await createRoot();
const secondRoot = await createRoot();
const observer = createObserver();
await observer.subscribe(firstRoot, () => undefined);
await observer.subscribe(secondRoot, () => undefined);
await observer.close();
expect(observer.getDiagnostics()).toEqual({
activeObservationCount: 0,
nativeHandleCount: 0,
nativeTrackedFileCount: 0,
pendingEventCount: 0,
pendingReconciliationWorkCount: 0,
reconciliationInFlightCount: 0,
reconciliationCount: 0,
scopedReconciliationCount: 0,
fullReconciliationCount: 0,
reconciliationFailureCount: 0,
observerFailureCount: 0,
directoryLimitFailureCount: 0,
nativeEventCount: 0,
nativeChangeEventCount: 0,
nativeRenameEventCount: 0,
nativePathlessEventCount: 0,
nativeClassificationCount: 0,
nativeShallowScanCount: 0,
lastReconciliationDurationMs: 0,
maxReconciliationDurationMs: 0,
});
await expect(observer.subscribe(firstRoot, () => undefined)).rejects.toThrow(
"File observer is closed",
);
});
async function subscribeToFileChanges(
directory: string,
callback: FileObserverCallback,
options?: FileObserverOptions,
): Promise<FileObserverSubscription> {
const observer = createObserver();
const subscription = await observer.subscribe(directory, callback, options);
return {
updateIgnore: (paths) => subscription.updateIgnore(paths),
unsubscribe: async () => {
await subscription.unsubscribe();
await observer.close();
},
};
}
function createObserver(): FileObserver {
const observer = createFileObserver();
observers.add(observer);
return observer;
}
async function createRoot(): Promise<string> {
const root = await mkdtemp(join(tmpdir(), "paseo-file-observer-"));
roots.add(root);
return root;
}
@@ -0,0 +1,184 @@
import { resolve } from "node:path";
import type { CreateObservationBackend, ObserverMetrics } from "./internal/contracts.js";
import { createLinuxBackend } from "./internal/linux.js";
import { createNativeRecursiveBackend } from "./internal/native-recursive.js";
import { createManagedObservation, type ManagedObservation } from "./internal/observation.js";
import { createObserverPaths } from "./internal/paths.js";
export type FileChangeType = "create" | "update" | "delete";
export interface FileChange {
path: string;
type: FileChangeType;
}
export interface FileObserverOptions {
ignore?: string[];
}
export type FileObserverCallback = (error: Error | null, events: FileChange[]) => void;
export interface FileObserverSubscription {
updateIgnore(paths: string[]): Promise<void>;
unsubscribe(): Promise<void>;
}
export interface FileObserverDiagnostics {
activeObservationCount: number;
nativeHandleCount: number;
nativeTrackedFileCount: number;
pendingEventCount: number;
pendingReconciliationWorkCount: number;
reconciliationInFlightCount: number;
reconciliationCount: number;
scopedReconciliationCount: number;
fullReconciliationCount: number;
reconciliationFailureCount: number;
observerFailureCount: number;
directoryLimitFailureCount: number;
nativeEventCount: number;
nativeChangeEventCount: number;
nativeRenameEventCount: number;
nativePathlessEventCount: number;
nativeClassificationCount: number;
nativeShallowScanCount: number;
lastReconciliationDurationMs: number;
maxReconciliationDurationMs: number;
}
export interface FileObserver {
subscribe(
directory: string,
callback: FileObserverCallback,
options?: FileObserverOptions,
): Promise<FileObserverSubscription>;
getDiagnostics(): FileObserverDiagnostics;
close(): Promise<void>;
}
export type SubscribeToFileChanges = FileObserver["subscribe"];
export function createFileObserver(): FileObserver {
const platform = process.platform;
const paths = createObserverPaths(platform);
const createBackend: CreateObservationBackend =
platform === "darwin" || platform === "win32"
? (host) => createNativeRecursiveBackend(host, paths)
: (host) => createLinuxBackend(host, paths);
return new FileObserverService(createBackend, paths);
}
class FileObserverService implements FileObserver {
private state:
| {
status: "open";
observations: Set<ManagedObservation>;
metrics: ObserverMetrics;
}
| { status: "closed" } = {
status: "open",
observations: new Set(),
metrics: createObserverMetrics(),
};
private closePromise: Promise<void> | null = null;
constructor(
private readonly createBackend: CreateObservationBackend,
private readonly paths: ReturnType<typeof createObserverPaths>,
) {}
async subscribe(
directory: string,
callback: FileObserverCallback,
options: FileObserverOptions = {},
): Promise<FileObserverSubscription> {
if (this.state.status === "closed") throw new Error("File observer is closed");
const state = this.state;
let observation!: ManagedObservation;
observation = createManagedObservation({
root: resolve(directory),
callback,
options,
paths: this.paths,
metrics: state.metrics,
createBackend: this.createBackend,
onClosed: () => state.observations.delete(observation),
});
await observation.start();
if (this.closePromise) {
await observation.close();
throw new Error("File observer is closed");
}
state.observations.add(observation);
return observation.subscription();
}
getDiagnostics(): FileObserverDiagnostics {
if (this.state.status === "closed") return emptyDiagnostics();
const { observations, metrics } = this.state;
let nativeHandleCount = 0;
let nativeTrackedFileCount = 0;
let pendingEventCount = 0;
let pendingReconciliationWorkCount = 0;
let reconciliationInFlightCount = 0;
for (const observation of observations) {
const diagnostics = observation.getDiagnostics();
nativeHandleCount += diagnostics.nativeHandleCount;
nativeTrackedFileCount += diagnostics.nativeTrackedFileCount;
pendingEventCount += diagnostics.pendingEventCount;
pendingReconciliationWorkCount += diagnostics.pendingReconciliationWorkCount;
reconciliationInFlightCount += Number(diagnostics.reconciliationInFlight);
}
return {
activeObservationCount: observations.size,
nativeHandleCount,
nativeTrackedFileCount,
pendingEventCount,
pendingReconciliationWorkCount,
reconciliationInFlightCount,
...metrics,
};
}
close(): Promise<void> {
if (this.closePromise) return this.closePromise;
if (this.state.status === "closed") return Promise.resolve();
const observations = [...this.state.observations];
this.state = { status: "closed" };
this.closePromise = Promise.allSettled(
observations.map((observation) => observation.close()),
).then(() => undefined);
return this.closePromise;
}
}
function createObserverMetrics(): ObserverMetrics {
return {
reconciliationCount: 0,
scopedReconciliationCount: 0,
fullReconciliationCount: 0,
reconciliationFailureCount: 0,
observerFailureCount: 0,
directoryLimitFailureCount: 0,
nativeEventCount: 0,
nativeChangeEventCount: 0,
nativeRenameEventCount: 0,
nativePathlessEventCount: 0,
nativeClassificationCount: 0,
nativeShallowScanCount: 0,
lastReconciliationDurationMs: 0,
maxReconciliationDurationMs: 0,
};
}
function emptyDiagnostics(): FileObserverDiagnostics {
return {
activeObservationCount: 0,
nativeHandleCount: 0,
nativeTrackedFileCount: 0,
pendingEventCount: 0,
pendingReconciliationWorkCount: 0,
reconciliationInFlightCount: 0,
...createObserverMetrics(),
};
}
@@ -0,0 +1,37 @@
import type { FileChangeType, FileObserverDiagnostics } from "../index.js";
export interface ObserverMetrics extends Omit<
FileObserverDiagnostics,
| "activeObservationCount"
| "nativeHandleCount"
| "nativeTrackedFileCount"
| "pendingEventCount"
| "pendingReconciliationWorkCount"
| "reconciliationInFlightCount"
> {}
export interface BackendDiagnostics {
nativeHandleCount: number;
nativeTrackedFileCount: number;
pendingReconciliationWorkCount: number;
reconciliationInFlight: boolean;
}
export interface ObservationBackend {
start(): Promise<void>;
updateIgnore(): Promise<void>;
close(): Promise<void>;
getDiagnostics(): BackendDiagnostics;
}
export interface ObservationHost {
root: string;
metrics: ObserverMetrics;
isActive(): boolean;
isIgnored(path: string): boolean;
isPathInside(root: string, path: string): boolean;
queueEvent(type: FileChangeType, path: string): void;
fail(error: Error): void;
}
export type CreateObservationBackend = (host: ObservationHost) => ObservationBackend;
@@ -0,0 +1,270 @@
import { type Dirent, type FSWatcher, watch } from "node:fs";
import { readdir, stat } from "node:fs/promises";
import { resolve } from "node:path";
import type { BackendDiagnostics, ObservationBackend, ObservationHost } from "./contracts.js";
import type { ObserverPaths } from "./paths.js";
import { isMissingPathError, toError } from "./paths.js";
const MAX_WATCHED_DIRECTORIES = 5_000;
const RECONCILIATION_DELAY_MS = 50;
interface DirectorySnapshot {
directories: Set<string>;
filesDiscoveredBeforeCoverage: Set<string>;
}
export function createLinuxBackend(
host: ObservationHost,
paths: ObserverPaths,
): ObservationBackend {
return new LinuxBackend(host, paths);
}
class LinuxBackend implements ObservationBackend {
private readonly watchers = new Map<string, FSWatcher>();
private readonly classifications = new Set<Promise<void>>();
private readonly pendingScopes = new Set<string>();
private reconcileTail: Promise<void> = Promise.resolve();
private queueDepth = 0;
private inFlight = false;
private timer: NodeJS.Timeout | null = null;
private closePromise: Promise<void> | null = null;
constructor(
private readonly host: ObservationHost,
private readonly paths: ObserverPaths,
) {}
async start(): Promise<void> {
this.watchDirectory(this.host.root);
await this.enqueue([this.host.root]);
}
async updateIgnore(): Promise<void> {
for (const [directory, watcher] of this.watchers) {
if (directory !== this.host.root && this.host.isIgnored(directory)) {
watcher.close();
this.watchers.delete(directory);
}
}
this.cancelQueued();
await this.enqueue([this.host.root]);
}
close(): Promise<void> {
if (this.closePromise) return this.closePromise;
this.cancelQueued();
for (const watcher of this.watchers.values()) watcher.close();
this.watchers.clear();
this.closePromise = this.finishClose();
return this.closePromise;
}
getDiagnostics(): BackendDiagnostics {
return {
nativeHandleCount: this.watchers.size,
nativeTrackedFileCount: 0,
pendingReconciliationWorkCount:
this.pendingScopes.size +
this.classifications.size +
this.queueDepth +
Number(this.timer !== null),
reconciliationInFlight: this.inFlight,
};
}
private async finishClose(): Promise<void> {
await this.reconcileTail;
await Promise.allSettled(this.classifications);
for (const watcher of this.watchers.values()) watcher.close();
this.watchers.clear();
}
private watchDirectory(directory: string): void {
if (!this.host.isActive() || this.watchers.has(directory) || this.host.isIgnored(directory)) {
return;
}
if (this.watchers.size >= MAX_WATCHED_DIRECTORIES) {
this.host.metrics.directoryLimitFailureCount += 1;
throw new Error(
`Recursive file observation exceeded ${MAX_WATCHED_DIRECTORIES} directories under ${this.host.root}`,
);
}
const watcher = watch(directory, (eventType, filename) => {
if (!this.host.isActive()) return;
const path = filename ? resolve(directory, filename.toString()) : directory;
if (!this.host.isIgnored(path)) {
if (eventType === "change") this.host.queueEvent("update", path);
else this.classify(path);
}
if (eventType === "rename" || !filename) this.requestReconcile(path);
});
this.attachWatcher(directory, watcher);
}
private attachWatcher(directory: string, watcher: FSWatcher): void {
watcher.on("error", (error) => {
if (!this.host.isActive()) return;
if (this.paths.isExpectedWatchDisappearance(error)) {
watcher.close();
if (this.watchers.get(directory) === watcher) this.watchers.delete(directory);
if (directory === this.host.root) {
this.host.fail(toError(error));
return;
}
this.requestReconcile(directory);
return;
}
this.host.fail(toError(error));
});
this.watchers.set(directory, watcher);
}
private requestReconcile(scope: string): void {
if (!this.host.isActive()) return;
this.pendingScopes.add(scope);
if (this.timer) return;
this.timer = setTimeout(() => {
this.timer = null;
const scopes = [...this.pendingScopes];
this.pendingScopes.clear();
void this.enqueue(scopes).catch((error: unknown) => this.host.fail(toError(error)));
}, RECONCILIATION_DELAY_MS);
this.timer.unref();
}
private enqueue(scopes: string[]): Promise<void> {
this.queueDepth += 1;
const result = this.reconcileTail
.then(() => this.run(scopes))
.finally(() => {
this.queueDepth -= 1;
});
this.reconcileTail = result.catch(() => undefined);
return result;
}
private async run(scopes: string[]): Promise<void> {
if (!this.host.isActive()) return;
this.inFlight = true;
const startedAt = performance.now();
try {
await this.reconcile(this.paths.collapse(scopes));
} catch (error) {
this.host.metrics.reconciliationFailureCount += 1;
throw error;
} finally {
const durationMs = performance.now() - startedAt;
this.host.metrics.reconciliationCount += 1;
this.host.metrics.scopedReconciliationCount += 1;
this.host.metrics.lastReconciliationDurationMs = durationMs;
this.host.metrics.maxReconciliationDurationMs = Math.max(
this.host.metrics.maxReconciliationDurationMs,
durationMs,
);
this.inFlight = false;
}
}
private async reconcile(scopes: string[]): Promise<void> {
for (const scope of scopes) {
const snapshot = await this.scan(scope);
if (!this.host.isActive()) return;
for (const directory of snapshot.directories) {
if (!this.watchSnapshotDirectory(directory)) snapshot.directories.delete(directory);
}
for (const path of snapshot.filesDiscoveredBeforeCoverage) {
this.host.queueEvent("create", path);
}
for (const [directory, watcher] of this.watchers) {
const disappeared =
directory !== this.host.root &&
this.host.isPathInside(scope, directory) &&
!snapshot.directories.has(directory);
if (!disappeared) continue;
watcher.close();
this.watchers.delete(directory);
this.host.queueEvent("delete", directory);
}
}
}
private watchSnapshotDirectory(directory: string): boolean {
try {
this.watchDirectory(directory);
return true;
} catch (error) {
if (!this.paths.isExpectedWatchDisappearance(error) || directory === this.host.root) {
throw error;
}
return false;
}
}
private async scan(scope: string): Promise<DirectorySnapshot> {
let scopeStats;
try {
scopeStats = await stat(scope);
} catch (error) {
if (!isMissingPathError(error) || scope === this.host.root) throw error;
return { directories: new Set(), filesDiscoveredBeforeCoverage: new Set() };
}
if (!scopeStats.isDirectory()) {
return { directories: new Set(), filesDiscoveredBeforeCoverage: new Set() };
}
const directories = new Set<string>([scope]);
const filesDiscoveredBeforeCoverage = new Set<string>();
const pending = [scope];
while (pending.length > 0 && this.host.isActive()) {
const directory = pending.pop();
if (!directory) continue;
let entries: Dirent[];
try {
entries = await readdir(directory, { withFileTypes: true });
} catch (error) {
if (!isMissingPathError(error)) throw error;
if (directory === this.host.root && this.host.isActive()) throw error;
continue;
}
for (const entry of entries) {
const child = resolve(directory, entry.name);
if (this.host.isIgnored(child)) continue;
if (!entry.isDirectory()) {
if (!this.watchers.has(directory)) filesDiscoveredBeforeCoverage.add(child);
continue;
}
directories.add(child);
if (directories.size > MAX_WATCHED_DIRECTORIES) {
this.host.metrics.directoryLimitFailureCount += 1;
throw new Error(
`Recursive file observation exceeded ${MAX_WATCHED_DIRECTORIES} directories under ${this.host.root}`,
);
}
pending.push(child);
}
}
return { directories, filesDiscoveredBeforeCoverage };
}
private classify(path: string): void {
this.host.metrics.nativeClassificationCount += 1;
let classification!: Promise<void>;
classification = stat(path)
.then(() => this.host.queueEvent("create", path))
.catch((error: unknown) => {
if (isMissingPathError(error)) {
this.host.queueEvent("delete", path);
return;
}
this.host.fail(toError(error));
})
.finally(() => this.classifications.delete(classification));
this.classifications.add(classification);
}
private cancelQueued(): void {
if (this.timer) clearTimeout(this.timer);
this.timer = null;
this.pendingScopes.clear();
}
}
@@ -0,0 +1,588 @@
import { type Dirent, type FSWatcher, watch } from "node:fs";
import { readdir, stat } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import type { BackendDiagnostics, ObservationBackend, ObservationHost } from "./contracts.js";
import type { ObserverPaths } from "./paths.js";
import { isMissingPathError, toError } from "./paths.js";
const MAX_TRACKED_ENTRIES = 250_000;
const AUDIT_QUIET_MS = 500;
const AUDIT_MAX_DIRTY_MS = 5_000;
const OPTIONAL_AUDIT_QUIET_MS = 8_000;
const OPTIONAL_AUDIT_MAX_DIRTY_MS = 8_000;
const FULL_AUDIT_QUIET_MS = 30_000;
const FULL_AUDIT_MIN_INTERVAL_MS = 30_000;
const FULL_AUDIT_MAX_DIRTY_MS = 5 * 60_000;
interface DirectoryEntry {
directories: Set<string>;
files: Set<string>;
}
interface Inventory {
directories: Set<string>;
entries: Map<string, DirectoryEntry>;
files: Set<string>;
}
export function createNativeRecursiveBackend(
host: ObservationHost,
paths: ObserverPaths,
): ObservationBackend {
return new NativeRecursiveBackend(host, paths);
}
class NativeRecursiveBackend implements ObservationBackend {
private watcher: FSWatcher | null = null;
private files = new Set<string>();
private directories = new Set<string>();
private entries = new Map<string, DirectoryEntry>();
private readonly classifications = new Set<Promise<void>>();
private readonly localScopes = new Set<string>();
private readonly changeScopes = new Set<string>();
private readonly recursiveScopes = new Set<string>();
private reconcileTail: Promise<void> = Promise.resolve();
private queueDepth = 0;
private inFlight = false;
private auditTimer: NodeJS.Timeout | null = null;
private fullAuditTimer: NodeJS.Timeout | null = null;
private fullAuditRequested = false;
private auditDirty = false;
private auditDirtySince: number | null = null;
private auditLastDirtyAt: number | null = null;
private auditQueued = false;
private auditRunning = false;
private safetyAuditPending = false;
private safetyAuditDirtySince: number | null = null;
private safetyAuditLastDirtyAt: number | null = null;
private lastFullAuditAt = Number.NEGATIVE_INFINITY;
private generation = 0;
private closePromise: Promise<void> | null = null;
constructor(
private readonly host: ObservationHost,
private readonly paths: ObserverPaths,
) {}
async start(): Promise<void> {
this.watchRoot();
this.fullAuditRequested = true;
this.auditQueued = true;
await this.enqueueAudit(false, this.generation);
}
async updateIgnore(): Promise<void> {
this.generation += 1;
this.cancelAudits();
this.fullAuditRequested = true;
this.auditQueued = true;
await this.enqueueAudit(true, this.generation);
}
close(): Promise<void> {
if (this.closePromise) return this.closePromise;
this.cancelAudits();
this.watcher?.close();
this.watcher = null;
this.closePromise = this.finishClose();
return this.closePromise;
}
getDiagnostics(): BackendDiagnostics {
return {
nativeHandleCount: this.watcher ? 1 : 0,
nativeTrackedFileCount: this.files.size,
pendingReconciliationWorkCount:
this.localScopes.size +
this.changeScopes.size +
this.recursiveScopes.size +
this.classifications.size +
this.queueDepth +
Number(this.auditTimer !== null || this.auditQueued || this.auditDirty),
reconciliationInFlight: this.inFlight,
};
}
private async finishClose(): Promise<void> {
await this.reconcileTail;
await Promise.allSettled(this.classifications);
this.watcher?.close();
this.watcher = null;
this.files.clear();
this.directories.clear();
this.entries.clear();
}
private watchRoot(): void {
const watcher = watch(this.host.root, { recursive: true }, (eventType, filename) => {
if (!this.host.isActive()) return;
this.host.metrics.nativeEventCount += 1;
if (!filename) {
this.host.metrics.nativePathlessEventCount += 1;
this.host.queueEvent("update", this.host.root);
this.requestAudit(this.host.root);
return;
}
const path = resolve(this.host.root, filename.toString());
if (this.host.isIgnored(path)) return;
const scope = path === this.host.root ? this.host.root : dirname(path);
if (eventType === "change") {
this.host.metrics.nativeChangeEventCount += 1;
this.host.queueEvent("update", path);
this.requestChangeAudit(scope);
if (this.directories.has(path)) this.requestAudit(path, true);
return;
}
this.host.metrics.nativeRenameEventCount += 1;
const knownDirectory = this.directories.has(path);
this.classify(path, (isDirectory) => {
if (!isDirectory) return;
if (knownDirectory) this.requestAudit(path, true);
else this.requestAudit(scope);
});
this.requestChangeAudit(scope);
if (knownDirectory) this.requestAudit(scope);
});
watcher.on("error", (error) => {
if (this.host.isActive()) this.host.fail(toError(error));
});
this.watcher = watcher;
}
private enqueueAudit(emitDiff: boolean, generation: number): Promise<void> {
this.queueDepth += 1;
const result = this.reconcileTail
.then(() => this.runAudit(emitDiff, generation))
.finally(() => {
this.queueDepth -= 1;
});
this.reconcileTail = result.catch(() => undefined);
return result;
}
private async runAudit(emitDiff: boolean, generation: number): Promise<void> {
this.auditQueued = false;
if (!this.canCommit(generation)) return;
const fullAudit = this.fullAuditRequested;
const localScopes = [...this.localScopes];
const changeScopes = [...this.changeScopes];
const recursiveScopes = [...this.recursiveScopes];
this.fullAuditRequested = false;
this.localScopes.clear();
this.changeScopes.clear();
this.recursiveScopes.clear();
if (fullAudit) this.clearSafetyAudit();
this.auditRunning = true;
this.auditDirty = false;
this.auditDirtySince = null;
this.auditLastDirtyAt = null;
this.inFlight = true;
const startedAt = performance.now();
try {
if (fullAudit) {
const next = await this.scanTree(this.host.root);
if (!this.canCommit(generation)) return;
if (emitDiff) this.queueDiff(next.files, this.files);
this.replaceInventory(next);
this.lastFullAuditAt = performance.now();
this.host.metrics.fullReconciliationCount += 1;
} else {
await this.reconcileScopes(localScopes, changeScopes, recursiveScopes, generation);
this.host.metrics.scopedReconciliationCount += 1;
}
} catch (error) {
this.host.metrics.reconciliationFailureCount += 1;
throw error;
} finally {
const durationMs = performance.now() - startedAt;
this.host.metrics.reconciliationCount += 1;
this.host.metrics.lastReconciliationDurationMs = durationMs;
this.host.metrics.maxReconciliationDurationMs = Math.max(
this.host.metrics.maxReconciliationDurationMs,
durationMs,
);
this.inFlight = false;
this.auditRunning = false;
if (this.safetyAuditPending && this.host.isActive()) this.scheduleFullAudit();
if (this.auditDirty && this.host.isActive()) this.scheduleAudit();
}
}
private async scanTree(root: string): Promise<Inventory> {
const files = new Set<string>();
const directories = new Set<string>();
const entries = new Map<string, DirectoryEntry>();
const pending = [root];
while (pending.length > 0 && this.host.isActive()) {
const directory = pending.pop();
if (!directory) continue;
let children: Dirent[];
try {
children = await readdir(directory, { withFileTypes: true });
} catch (error) {
if (isMissingPathError(error) && !this.host.isActive()) {
return { directories, entries, files };
}
if (!isMissingPathError(error) || directory === this.host.root) throw error;
continue;
}
directories.add(directory);
this.assertWithinLimit(files.size + directories.size);
const entry: DirectoryEntry = { directories: new Set(), files: new Set() };
for (const child of children) {
const path = join(directory, child.name);
if (this.host.isIgnored(path)) continue;
if (child.isDirectory()) {
entry.directories.add(path);
pending.push(path);
} else {
entry.files.add(path);
files.add(path);
this.assertWithinLimit(files.size + directories.size);
}
}
entries.set(directory, entry);
}
return { directories, entries, files };
}
private async reconcileScopes(
localScopes: string[],
changeScopes: string[],
recursiveScopes: string[],
generation: number,
): Promise<void> {
const forcedLocalScopes = new Set(localScopes);
const forcedRecursiveScopes = this.paths.collapse(recursiveScopes);
for (const directory of new Set(localScopes)) {
await this.reconcileDirectory(directory, generation);
if (!this.canCommit(generation)) return;
}
for (const directory of new Set(recursiveScopes)) {
await this.reconcileSubtree(directory, generation);
if (!this.canCommit(generation)) return;
}
for (const directory of this.paths.collapse(changeScopes)) {
const alreadyCovered =
forcedLocalScopes.has(directory) ||
forcedRecursiveScopes.some((scope) => this.host.isPathInside(scope, directory));
if (alreadyCovered) continue;
await this.reconcileDirectory(directory, generation);
if (!this.canCommit(generation)) return;
}
}
private async reconcileDirectory(directory: string, generation: number): Promise<void> {
if (this.host.isIgnored(directory)) {
this.removeSubtree(directory, false);
return;
}
if (directory !== this.host.root && !this.directories.has(directory)) {
await this.reconcileUnknownSubtree(directory, generation);
return;
}
const next = await this.readDirectory(directory);
if (!this.canCommit(generation)) return;
if (!next) {
this.removeSubtree(directory, true);
return;
}
const previous = this.entries.get(directory) ?? {
directories: new Set<string>(),
files: new Set<string>(),
};
for (const path of previous.files) {
if (!next.files.has(path)) {
this.files.delete(path);
this.host.queueEvent("delete", path);
}
}
for (const path of previous.directories) {
if (!next.directories.has(path)) this.removeSubtree(path, true);
}
for (const path of next.files) {
if (!previous.files.has(path)) {
this.files.add(path);
this.host.queueEvent("create", path);
}
}
for (const path of next.directories) {
if (previous.directories.has(path)) continue;
const inventory = await this.scanTree(path);
if (!this.canCommit(generation)) return;
if (!inventory.directories.has(path)) {
next.directories.delete(path);
continue;
}
this.mergeInventory(inventory, true);
}
this.directories.add(directory);
this.entries.set(directory, next);
this.assertWithinLimit(this.files.size + this.directories.size);
}
private async reconcileUnknownSubtree(directory: string, generation: number): Promise<void> {
const inventory = await this.scanTree(directory);
if (!this.canCommit(generation)) return;
if (!inventory.directories.has(directory)) {
this.removeSubtree(directory, true);
return;
}
this.entries.get(dirname(directory))?.directories.add(directory);
this.mergeInventory(inventory, true);
}
private async readDirectory(directory: string): Promise<DirectoryEntry | null> {
this.host.metrics.nativeShallowScanCount += 1;
let children: Dirent[];
try {
children = await readdir(directory, { withFileTypes: true });
} catch (error) {
if (!isMissingPathError(error) || directory === this.host.root) throw error;
return null;
}
const entry: DirectoryEntry = { directories: new Set(), files: new Set() };
for (const child of children) {
const path = join(directory, child.name);
if (this.host.isIgnored(path)) continue;
if (child.isDirectory()) entry.directories.add(path);
else entry.files.add(path);
}
return entry;
}
private async reconcileSubtree(directory: string, generation: number): Promise<void> {
if (!this.directories.has(directory) || this.host.isIgnored(directory)) return;
const inventory = await this.scanTree(directory);
if (!this.canCommit(generation)) return;
const previousFiles = new Set(
[...this.files].filter((path) => this.host.isPathInside(directory, path)),
);
this.queueDiff(inventory.files, previousFiles);
this.removeSubtree(directory, false);
this.mergeInventory(inventory, false);
}
private queueDiff(nextFiles: Set<string>, previousFiles: Set<string>): void {
for (const path of nextFiles) {
if (!previousFiles.has(path)) this.host.queueEvent("create", path);
}
for (const path of previousFiles) {
if (!nextFiles.has(path)) this.host.queueEvent("delete", path);
}
}
private replaceInventory(inventory: Inventory): void {
this.files = inventory.files;
this.directories = inventory.directories;
this.entries = inventory.entries;
}
private mergeInventory(inventory: Inventory, emitCreates: boolean): void {
for (const directory of inventory.directories) this.directories.add(directory);
for (const [directory, entry] of inventory.entries) this.entries.set(directory, entry);
for (const path of inventory.files) {
const added = !this.files.has(path);
this.files.add(path);
if (emitCreates && added) this.host.queueEvent("create", path);
}
this.assertWithinLimit(this.files.size + this.directories.size);
}
private removeSubtree(root: string, emitDeletes: boolean): void {
for (const path of this.files) {
if (!this.host.isPathInside(root, path)) continue;
this.files.delete(path);
if (emitDeletes) this.host.queueEvent("delete", path);
}
for (const directory of this.directories) {
if (!this.host.isPathInside(root, directory)) continue;
this.directories.delete(directory);
this.entries.delete(directory);
}
const parent = this.entries.get(dirname(root));
parent?.directories.delete(root);
parent?.files.delete(root);
}
private requestAudit(scope: string, recursive = false): void {
if (!this.host.isActive()) return;
if (recursive) this.recursiveScopes.add(scope);
else this.localScopes.add(scope);
this.markAuditDirty();
}
private requestChangeAudit(scope: string): void {
if (!this.host.isActive()) return;
this.changeScopes.add(scope);
this.markAuditDirty();
}
private markAuditDirty(): void {
this.requestSafetyAudit();
const now = performance.now();
this.auditDirty = true;
this.auditDirtySince ??= now;
this.auditLastDirtyAt = now;
if (this.auditQueued || this.auditRunning) return;
if (this.auditTimer) clearTimeout(this.auditTimer);
this.auditTimer = null;
this.scheduleAudit();
}
private scheduleAudit(): void {
if (
!this.host.isActive() ||
!this.auditDirty ||
this.auditQueued ||
this.auditRunning ||
this.auditTimer
) {
return;
}
const now = performance.now();
const optionalOnly = this.isOptionalOnly();
const quietMs = optionalOnly ? OPTIONAL_AUDIT_QUIET_MS : AUDIT_QUIET_MS;
const maxDirtyMs = optionalOnly ? OPTIONAL_AUDIT_MAX_DIRTY_MS : AUDIT_MAX_DIRTY_MS;
const deadline = Math.min(
(this.auditLastDirtyAt ?? now) + quietMs,
(this.auditDirtySince ?? now) + maxDirtyMs,
);
this.auditTimer = setTimeout(() => this.runScheduledAudit(), Math.max(0, deadline - now));
this.auditTimer.unref();
}
private runScheduledAudit(): void {
this.auditTimer = null;
if (!this.host.isActive()) return;
const now = performance.now();
const optionalOnly = this.isOptionalOnly();
const quietMs = optionalOnly ? OPTIONAL_AUDIT_QUIET_MS : AUDIT_QUIET_MS;
const maxDirtyMs = optionalOnly ? OPTIONAL_AUDIT_MAX_DIRTY_MS : AUDIT_MAX_DIRTY_MS;
const deadline = Math.min(
(this.auditLastDirtyAt ?? now) + quietMs,
(this.auditDirtySince ?? now) + maxDirtyMs,
);
if (now < deadline) {
this.scheduleAudit();
return;
}
this.auditQueued = true;
void this.enqueueAudit(true, this.generation).catch((error: unknown) =>
this.host.fail(toError(error)),
);
}
private isOptionalOnly(): boolean {
return (
!this.fullAuditRequested &&
this.localScopes.size === 0 &&
this.recursiveScopes.size === 0 &&
this.changeScopes.size > 0
);
}
private requestSafetyAudit(): void {
const now = performance.now();
this.safetyAuditPending = true;
this.safetyAuditDirtySince ??= now;
this.safetyAuditLastDirtyAt = now;
this.scheduleFullAudit();
}
private scheduleFullAudit(): void {
if (
!this.host.isActive() ||
!this.safetyAuditPending ||
this.auditRunning ||
this.fullAuditTimer
) {
return;
}
const now = performance.now();
const deadline = this.fullAuditDeadline(now);
this.fullAuditTimer = setTimeout(
() => this.runScheduledFullAudit(),
Math.max(0, deadline - now),
);
this.fullAuditTimer.unref();
}
private runScheduledFullAudit(): void {
this.fullAuditTimer = null;
if (!this.host.isActive() || !this.safetyAuditPending || this.auditRunning) return;
const now = performance.now();
if (now < this.fullAuditDeadline(now)) {
this.scheduleFullAudit();
return;
}
this.safetyAuditPending = false;
this.fullAuditRequested = true;
this.auditDirty = true;
this.auditDirtySince ??= now;
this.scheduleAudit();
}
private fullAuditDeadline(now: number): number {
const quietDeadline = (this.safetyAuditLastDirtyAt ?? now) + FULL_AUDIT_QUIET_MS;
const intervalDeadline = this.lastFullAuditAt + FULL_AUDIT_MIN_INTERVAL_MS;
const starvationDeadline = (this.safetyAuditDirtySince ?? now) + FULL_AUDIT_MAX_DIRTY_MS;
return Math.min(starvationDeadline, Math.max(intervalDeadline, quietDeadline));
}
private clearSafetyAudit(): void {
this.safetyAuditPending = false;
this.safetyAuditDirtySince = null;
this.safetyAuditLastDirtyAt = null;
if (this.fullAuditTimer) clearTimeout(this.fullAuditTimer);
this.fullAuditTimer = null;
}
private cancelAudits(): void {
if (this.auditTimer) clearTimeout(this.auditTimer);
if (this.fullAuditTimer) clearTimeout(this.fullAuditTimer);
this.auditTimer = null;
this.fullAuditTimer = null;
this.localScopes.clear();
this.changeScopes.clear();
this.recursiveScopes.clear();
this.fullAuditRequested = false;
this.auditDirty = false;
this.auditDirtySince = null;
this.auditLastDirtyAt = null;
this.safetyAuditPending = false;
this.safetyAuditDirtySince = null;
this.safetyAuditLastDirtyAt = null;
}
private classify(path: string, onPresent: (isDirectory: boolean) => void): void {
this.host.metrics.nativeClassificationCount += 1;
let classification!: Promise<void>;
classification = stat(path)
.then((stats) => {
this.host.queueEvent("create", path);
if (this.host.isActive() && !this.host.isIgnored(path)) onPresent(stats.isDirectory());
return undefined;
})
.catch((error: unknown) => {
if (isMissingPathError(error)) {
this.host.queueEvent("delete", path);
return;
}
this.host.fail(toError(error));
})
.finally(() => this.classifications.delete(classification));
this.classifications.add(classification);
}
private assertWithinLimit(entryCount: number): void {
if (entryCount <= MAX_TRACKED_ENTRIES) return;
throw new Error(
`Recursive file observation exceeded ${MAX_TRACKED_ENTRIES} entries under ${this.host.root}`,
);
}
private canCommit(generation: number): boolean {
return this.host.isActive() && generation === this.generation;
}
}
@@ -0,0 +1,174 @@
import { stat } from "node:fs/promises";
import type {
FileChange,
FileChangeType,
FileObserverCallback,
FileObserverOptions,
FileObserverSubscription,
} from "../index.js";
import type {
BackendDiagnostics,
CreateObservationBackend,
ObservationBackend,
ObservationHost,
ObserverMetrics,
} from "./contracts.js";
import type { ObserverPaths } from "./paths.js";
import { toError } from "./paths.js";
const EVENT_BATCH_DELAY_MS = 10;
export interface ManagedObservation {
start(): Promise<void>;
subscription(): FileObserverSubscription;
close(): Promise<void>;
getDiagnostics(): BackendDiagnostics & { pendingEventCount: number };
}
export function createManagedObservation(input: {
root: string;
callback: FileObserverCallback;
options: FileObserverOptions;
paths: ObserverPaths;
metrics: ObserverMetrics;
createBackend: CreateObservationBackend;
onClosed: () => void;
}): ManagedObservation {
return new Observation(input);
}
class Observation implements ManagedObservation, ObservationHost {
readonly root: string;
readonly metrics: ObserverMetrics;
private ignoredRoots: string[];
private status: "open" | "failed" | "closed" = "open";
private readonly backend: ObservationBackend;
private readonly pendingEvents = new Map<string, FileChange>();
private flushTimer: NodeJS.Timeout | null = null;
private closePromise: Promise<void> | null = null;
constructor(
private readonly input: {
root: string;
callback: FileObserverCallback;
options: FileObserverOptions;
paths: ObserverPaths;
metrics: ObserverMetrics;
createBackend: CreateObservationBackend;
onClosed: () => void;
},
) {
this.root = input.root;
this.metrics = input.metrics;
this.ignoredRoots = input.paths.normalizeIgnoredRoots(input.root, input.options.ignore ?? []);
this.backend = input.createBackend(this);
}
async start(): Promise<void> {
const rootStats = await stat(this.root);
if (!rootStats.isDirectory()) {
throw new Error(`File observation requires a directory: ${this.root}`);
}
try {
await this.backend.start();
} catch (error) {
await this.close();
throw error;
}
}
subscription(): FileObserverSubscription {
return {
updateIgnore: (paths) => this.updateIgnore(paths),
unsubscribe: () => this.close(),
};
}
async updateIgnore(paths: string[]): Promise<void> {
if (!this.isActive()) return;
const next = this.input.paths.normalizeIgnoredRoots(this.root, paths);
if (samePaths(this.ignoredRoots, next)) return;
this.ignoredRoots = next;
for (const path of this.pendingEvents.keys()) {
if (this.isIgnored(path)) this.pendingEvents.delete(path);
}
try {
await this.backend.updateIgnore();
} catch (error) {
this.fail(toError(error));
throw error;
}
}
close(): Promise<void> {
if (this.closePromise) return this.closePromise;
this.status = "closed";
this.cancelDelivery();
this.closePromise = this.backend.close().finally(this.input.onClosed);
return this.closePromise;
}
getDiagnostics(): BackendDiagnostics & { pendingEventCount: number } {
return {
...this.backend.getDiagnostics(),
pendingEventCount: this.pendingEvents.size,
};
}
isActive(): boolean {
return this.status === "open";
}
isIgnored(path: string): boolean {
return this.ignoredRoots.some((root) => this.input.paths.isInside(root, path));
}
isPathInside(root: string, path: string): boolean {
return this.input.paths.isInside(root, path);
}
queueEvent(type: FileChangeType, path: string): void {
if (!this.isActive() || this.isIgnored(path)) return;
const previous = this.pendingEvents.get(path);
this.pendingEvents.set(path, mergeChange(previous, { path, type }));
if (this.flushTimer) return;
this.flushTimer = setTimeout(() => {
this.flushTimer = null;
if (!this.isActive() || this.pendingEvents.size === 0) return;
const events = [...this.pendingEvents.values()];
this.pendingEvents.clear();
this.input.callback(null, events);
}, EVENT_BATCH_DELAY_MS);
}
fail(error: Error): void {
if (!this.isActive()) return;
this.status = "failed";
this.metrics.observerFailureCount += 1;
this.cancelDelivery();
this.beginFailureClose();
this.input.callback(error, []);
}
private beginFailureClose(): void {
this.closePromise = this.backend.close().finally(this.input.onClosed);
}
private cancelDelivery(): void {
if (this.flushTimer) clearTimeout(this.flushTimer);
this.flushTimer = null;
this.pendingEvents.clear();
}
}
function samePaths(left: string[], right: string[]): boolean {
return left.length === right.length && left.every((path, index) => path === right[index]);
}
function mergeChange(previous: FileChange | undefined, next: FileChange): FileChange {
if (!previous) return next;
if (previous.type === "create" && next.type === "delete") return { ...next, type: "update" };
if (previous.type === "delete" && next.type === "create") return { ...next, type: "update" };
if (previous.type === "create") return previous;
return next;
}
@@ -0,0 +1,55 @@
import { resolve, sep } from "node:path";
export interface ObserverPaths {
isInside(root: string, path: string): boolean;
normalizeIgnoredRoots(root: string, paths: string[]): string[];
collapse(paths: string[]): string[];
isExpectedWatchDisappearance(error: unknown): boolean;
}
export function createObserverPaths(platform: NodeJS.Platform): ObserverPaths {
function comparable(path: string): string {
return platform === "win32" ? path.toLowerCase() : path;
}
function isInside(root: string, path: string): boolean {
const comparedRoot = comparable(root);
const comparedPath = comparable(path);
return comparedPath === comparedRoot || comparedPath.startsWith(`${comparedRoot}${sep}`);
}
function collapse(paths: string[]): string[] {
return [...new Set(paths)]
.sort((left, right) => left.length - right.length)
.filter((path, index, all) => !all.slice(0, index).some((parent) => isInside(parent, path)));
}
return {
isInside,
collapse,
normalizeIgnoredRoots(root, paths) {
const inside = paths
.map((path) => resolve(path))
.filter((path) => path !== root && isInside(root, path));
return collapse(inside);
},
isExpectedWatchDisappearance(error) {
const code = getErrorCode(error);
return code === "ENOENT" || (platform === "win32" && code === "EPERM");
},
};
}
export function isMissingPathError(error: unknown): boolean {
const code = getErrorCode(error);
return code === "ENOENT" || code === "ENOTDIR";
}
export function toError(value: unknown): Error {
return value instanceof Error ? value : new Error(String(value));
}
function getErrorCode(error: unknown): string | null {
if (typeof error !== "object" || error === null || !("code" in error)) return null;
return typeof error.code === "string" ? error.code : null;
}
@@ -2,6 +2,7 @@ import { describe, expect, test } from "vitest";
import type {
AgentSnapshotPayload,
HubExecutionAgentCreateRequest,
HubExecutionAgentValidateRequest,
SessionOutboundMessage,
} from "@getpaseo/protocol/messages";
@@ -81,11 +82,44 @@ class RejectingHubExecutionAgents implements HubExecutionAgents {
}
describe("HubExecutionController", () => {
test("validates a named agent through the daemon provider registry", async () => {
const messages: SessionOutboundMessage[] = [];
const validateAgentConfiguration = async (
message: Omit<HubExecutionAgentValidateRequest, "type" | "requestId">,
) =>
message.model === "missing" ? [{ path: ["model"], message: "Model is unavailable" }] : [];
const controller = new HubExecutionController({
agents: new ControlledHubExecutionAgents(),
validateAgentConfiguration,
send: (message) => messages.push(message),
});
await controller.validateAgent({
type: "hub.execution.agent.validate.request",
requestId: "validate-agent",
provider: "codex",
model: "missing",
});
expect(messages).toEqual([
{
type: "hub.execution.agent.validate.response",
payload: {
requestId: "validate-agent",
valid: false,
issues: [{ path: ["model"], message: "Model is unavailable" }],
error: null,
},
},
]);
});
test("cleanup fences in-flight creates before the dead session can receive a response", async () => {
const agents = new ControlledHubExecutionAgents();
const messages: SessionOutboundMessage[] = [];
const controller = new HubExecutionController({
agents,
validateAgentConfiguration: async () => [],
send: (message) => messages.push(message),
});
@@ -111,6 +145,7 @@ describe("HubExecutionController", () => {
const messages: SessionOutboundMessage[] = [];
const controller = new HubExecutionController({
agents,
validateAgentConfiguration: async () => [],
send: (message) => messages.push(message),
});
@@ -165,6 +200,7 @@ describe("HubExecutionController", () => {
const messages: SessionOutboundMessage[] = [];
const controller = new HubExecutionController({
agents: new RejectingHubExecutionAgents(error),
validateAgentConfiguration: async () => [],
send: (message) => messages.push(message),
});
@@ -2,6 +2,8 @@ import { isAbsolute } from "node:path";
import type {
HubExecutionAgentCreateError,
HubExecutionAgentCreateRequest,
HubExecutionAgentValidateRequest,
HubExecutionAgentValidationIssue,
HubExecutionControlRequest,
SessionOutboundMessage,
} from "@getpaseo/protocol/messages";
@@ -14,20 +16,26 @@ import type { HubExecutionAgents, OwnedAgentEvent } from "./daemon-executions.js
interface HubExecutionControllerOptions {
agents: HubExecutionAgents;
validateAgentConfiguration: (
input: Omit<HubExecutionAgentValidateRequest, "type" | "requestId">,
) => Promise<HubExecutionAgentValidationIssue[]>;
send: (message: SessionOutboundMessage) => void;
}
export class HubExecutionController {
private readonly agents: HubExecutionAgents;
private readonly send: (message: SessionOutboundMessage) => void;
private readonly validateAgentConfiguration: HubExecutionControllerOptions["validateAgentConfiguration"];
private readonly unsubscribe: () => void;
private readonly pendingCreates = new Set<Promise<void>>();
private readonly pendingControls = new Set<Promise<void>>();
private readonly pendingValidations = new Set<Promise<void>>();
private cleanupPromise: Promise<void> | null = null;
private closed = false;
constructor(options: HubExecutionControllerOptions) {
this.agents = options.agents;
this.validateAgentConfiguration = options.validateAgentConfiguration;
this.send = options.send;
this.unsubscribe = this.agents.subscribe((event) => this.sendOwnedEvent(event));
}
@@ -40,7 +48,50 @@ export class HubExecutionController {
private async cleanupOnce(): Promise<void> {
this.closed = true;
this.unsubscribe();
await Promise.allSettled([...this.pendingCreates, ...this.pendingControls]);
await Promise.allSettled([
...this.pendingCreates,
...this.pendingControls,
...this.pendingValidations,
]);
}
async validateAgent(message: HubExecutionAgentValidateRequest): Promise<void> {
if (this.closed) return;
const validation = this.validateAgentWithResponse(message);
this.pendingValidations.add(validation);
try {
await validation;
} finally {
this.pendingValidations.delete(validation);
}
}
private async validateAgentWithResponse(
message: HubExecutionAgentValidateRequest,
): Promise<void> {
let issues: HubExecutionAgentValidationIssue[] = [];
let error: string | null = null;
try {
issues = await this.validateAgentConfiguration({
provider: message.provider,
model: message.model,
modeId: message.modeId,
thinkingOptionId: message.thinkingOptionId,
providerOptions: message.providerOptions,
});
} catch (validationError) {
error = validationError instanceof Error ? validationError.message : String(validationError);
}
if (this.closed) return;
this.send({
type: "hub.execution.agent.validate.response",
payload: {
requestId: message.requestId,
valid: error === null && issues.length === 0,
issues,
error,
},
});
}
async controlExecution(message: HubExecutionControlRequest): Promise<void> {
@@ -1,5 +1,5 @@
import { createHubCommand } from "../../../../../cli/src/commands/hub/index.js";
import { HubHttpClient } from "../../../../../cli/src/commands/hub/client.js";
import { HubHttpClient } from "../../../../../cli/src/commands/hub/hub-client/index.js";
const CONTRACT_API_KEY_PREFIX = "hub-contract-api-key:";
@@ -141,12 +141,12 @@ test("create_agent_request creates a worktree and auto-archives both after the f
expect(created.cwd).not.toBe(repoDir);
const listedWithWorktree = await ctx.client.getPaseoWorktreeList({ cwd: repoDir });
expect(listedWithWorktree.worktrees).toEqual([
expect.objectContaining({
worktreePath: created.cwd,
branchName: "agent-lifecycle-dispatch-test",
}),
]);
expect(listedWithWorktree.worktrees).toHaveLength(1);
const listedWorktree = listedWithWorktree.worktrees[0];
expect(listedWorktree?.branchName).toBe("agent-lifecycle-dispatch-test");
expect(createRealpathAwarePathMatcher(created.cwd)(listedWorktree?.worktreePath ?? "")).toBe(
true,
);
await ctx.client.waitForFinish(created.id, 10000);
@@ -154,6 +154,18 @@ test("create_agent_request creates a worktree and auto-archives both after the f
// last-reference worktree directory is gone.
await expectAgentAbsentFromActiveList(created.id);
await expect.poll(() => existsSync(created.cwd), { timeout: 10000, interval: 100 }).toBe(false);
// Archived tabs can continue asking for history. These reads must not recreate
// the removed workspace observation or compromise the next agent lifecycle.
const staleTimelineReads = await Promise.allSettled(
Array.from({ length: 10 }, () => ctx.client.fetchAgentTimeline(created.id, { limit: 20 })),
);
expect(staleTimelineReads.every((result) => result.status === "rejected")).toBe(true);
const subsequent = await ctx.client.createAgent({
config: { ...getFullAccessConfig("codex"), cwd: repoDir },
initialPrompt: "Say done.",
});
await ctx.client.waitForFinish(subsequent.id, 10_000);
await expectAgentPresentInActiveList(subsequent.id);
}, 30000);
test("create_agent_request auto-archives a nested workspace from an existing Paseo worktree", async () => {
@@ -310,6 +310,7 @@ interface SessionForTestOptions {
getDaemonTcpPort?: () => number | null;
getDaemonTcpHost?: () => string | null;
providerSnapshotManager?: ProviderSnapshotManager;
hubExecutionAgents?: SessionOptions["hubExecutionAgents"];
stt?: SessionOptions["stt"];
voice?: SessionOptions["voice"];
paseoHome?: string;
@@ -412,6 +413,7 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session {
terminalManager: options.terminalManager ?? null,
providerSnapshotManager:
options.providerSnapshotManager ?? createProviderSnapshotManagerStub().manager,
hubExecutionAgents: options.hubExecutionAgents,
serviceProxy: options.serviceProxy,
scriptRuntimeStore: options.scriptRuntimeStore,
getDaemonTcpPort: options.getDaemonTcpPort,
@@ -426,6 +428,44 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session {
}
describe("session authorization scopes", () => {
test("routes named-agent validation through the session source", async () => {
const messages: SessionOutboundMessage[] = [];
const providers = createProviderSnapshotManagerStub();
providers.validateAgentConfiguration.mockResolvedValue([
{ path: ["model"], message: "Model is unavailable" },
]);
const session = createSessionForTest({
messages,
providerSnapshotManager: providers.manager,
hubExecutionAgents: {
create: vi.fn(),
control: vi.fn(),
subscribe: vi.fn(() => () => undefined),
invalidateAuthority: vi.fn(),
},
});
await session.handleMessage({
type: "hub.execution.agent.validate.request",
requestId: "validate-agent",
provider: "codex",
model: "missing",
});
expect(providers.validateAgentConfiguration).toHaveBeenCalledWith(
expect.objectContaining({ provider: "codex", model: "missing" }),
);
expect(messages).toContainEqual({
type: "hub.execution.agent.validate.response",
payload: {
requestId: "validate-agent",
valid: false,
issues: [{ path: ["model"], message: "Model is unavailable" }],
error: null,
},
});
});
test("rejects an RPC outside an exact grant with the generic RPC error", async () => {
const messages: SessionOutboundMessage[] = [];
const session = createSessionForTest({
+5
View File
@@ -918,6 +918,8 @@ export class Session {
this.hubExecutionController = options.hubExecutionAgents
? new HubExecutionController({
agents: options.hubExecutionAgents,
validateAgentConfiguration: (input) =>
providerSnapshotManager.validateAgentConfiguration(input),
send: (message) => this.emit(message),
})
: null;
@@ -1968,6 +1970,9 @@ export class Session {
if (msg.type === "hub.execution.agent.create.request") {
return this.hubExecutionController?.createAgent(msg);
}
if (msg.type === "hub.execution.agent.validate.request") {
return this.hubExecutionController?.validateAgent(msg);
}
if (msg.type === "hub.execution.control.request") {
return this.hubExecutionController?.controlExecution(msg);
}
@@ -116,6 +116,23 @@ describe("ProviderCatalogSession", () => {
expect(pull?.payload.entries).toEqual(push?.payload.entries);
});
it("returns the canonical cwd used by snapshot updates", async () => {
const getSnapshot = vi.fn((_cwd?: string) => makeEntries());
const { subsystem, emitted } = makeSubsystem({
snapshot: { getSnapshot },
});
await subsystem.handleGetProvidersSnapshotRequest({
type: "get_providers_snapshot_request",
requestId: "canonical-cwd",
cwd: "/repo/./sdk",
});
const canonicalCwd = getSnapshot.mock.calls[0]?.[0];
expect(canonicalCwd).toEqual(expect.any(String));
expect(findByType(emitted, "get_providers_snapshot_response")?.payload.cwd).toBe(canonicalCwd);
});
it("pushes the compact encoding to capable clients", () => {
const { subsystem, emitted, pushSnapshotChange } = makeSubsystem({
supportsCustomModeIcons: true,
@@ -8,6 +8,7 @@ import {
import type { SessionInboundMessage, SessionOutboundMessage } from "../../messages.js";
import {
isGlobalProviderSnapshotKey,
resolveSnapshotCwd,
type ProviderSnapshotManager,
} from "../../agent/provider-snapshot-manager.js";
import {
@@ -391,8 +392,9 @@ export class ProviderCatalogSession {
msg: Extract<SessionInboundMessage, { type: "get_providers_snapshot_request" }>,
): Promise<void> {
// COMPAT(providersSnapshot): keep legacy provider-list RPCs alongside snapshot flow.
const snapshotCwd = msg.cwd?.trim() ? resolveSnapshotCwd(expandTilde(msg.cwd)) : undefined;
const entries = this.providerSnapshotManager
.getSnapshot(msg.cwd ? expandTilde(msg.cwd) : undefined)
.getSnapshot(snapshotCwd)
.filter((entry) => this.host.isProviderVisibleToClient(entry.provider));
const clientEntries = this.downgradeEntryModesForClient(entries);
@@ -402,6 +404,7 @@ export class ProviderCatalogSession {
this.host.emit({
type: "get_providers_snapshot_response",
payload: {
...(snapshotCwd ? { cwd: snapshotCwd } : {}),
entries: [],
...(!notModified ? { compactSnapshot: encoded.compactSnapshot } : {}),
snapshotHash: encoded.snapshotHash,
@@ -416,6 +419,7 @@ export class ProviderCatalogSession {
this.host.emit({
type: "get_providers_snapshot_response",
payload: {
...(snapshotCwd ? { cwd: snapshotCwd } : {}),
entries: clientEntries,
generatedAt: new Date().toISOString(),
requestId: msg.requestId,
@@ -15,7 +15,10 @@ import type {
} from "../agent/provider-snapshot-manager.js";
import { ProviderSnapshotManager } from "../agent/provider-snapshot-manager.js";
import type { SessionOptions } from "../session.js";
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
import type {
HubExecutionAgentValidationIssue,
SessionOutboundMessage,
} from "@getpaseo/protocol/messages";
import { asInternals, createStub } from "./class-mocks.js";
// ---------------------------------------------------------------------------
@@ -155,6 +158,9 @@ export interface ProviderSnapshotManagerSpies {
getAgentManagerProviderState: ReturnType<typeof vi.fn<[], AgentManagerProviderState>>;
listProviders: ReturnType<typeof vi.fn<[unknown], Promise<ProviderSnapshotEntry[]>>>;
getProvider: ReturnType<typeof vi.fn<[unknown], Promise<ProviderSnapshotEntry>>>;
validateAgentConfiguration: ReturnType<
typeof vi.fn<[unknown], Promise<HubExecutionAgentValidationIssue[]>>
>;
listModels: ReturnType<typeof vi.fn<[unknown], Promise<AgentModelDefinition[]>>>;
listModes: ReturnType<typeof vi.fn<[unknown], Promise<AgentMode[]>>>;
resolveCreateConfig: ReturnType<typeof vi.fn<[unknown], Promise<ResolvedProviderCreateConfig>>>;
@@ -190,6 +196,9 @@ export function createProviderSnapshotManagerStub(): {
const getProvider = vi.fn<[unknown], Promise<ProviderSnapshotEntry>>(async () => {
throw new Error("createProviderSnapshotManagerStub: getProvider not stubbed");
});
const validateAgentConfiguration = vi.fn<[unknown], Promise<HubExecutionAgentValidationIssue[]>>(
async () => [],
);
const listModels = vi.fn<[unknown], Promise<AgentModelDefinition[]>>(async () => []);
const listModes = vi.fn<[unknown], Promise<AgentMode[]>>(async () => []);
const resolveCreateConfig = vi.fn<[unknown], Promise<ResolvedProviderCreateConfig>>(async () => ({
@@ -218,6 +227,7 @@ export function createProviderSnapshotManagerStub(): {
getAgentManagerProviderState,
listProviders,
getProvider,
validateAgentConfiguration,
listModels,
listModes,
resolveCreateConfig,
@@ -243,6 +253,7 @@ export function createProviderSnapshotManagerStub(): {
getAgentManagerProviderState,
listProviders,
getProvider,
validateAgentConfiguration,
listModels,
listModes,
resolveCreateConfig,
@@ -89,8 +89,30 @@ export function createNoopWorkspaceGitService(
fetchInFlightCount: 0,
snapshotUpdatedListenerCount: 0,
watcherErrorCallbackCount: 0,
fileObserver: {
activeObservationCount: 0,
nativeHandleCount: 0,
nativeTrackedFileCount: 0,
pendingEventCount: 0,
pendingReconciliationWorkCount: 0,
reconciliationInFlightCount: 0,
reconciliationCount: 0,
scopedReconciliationCount: 0,
fullReconciliationCount: 0,
reconciliationFailureCount: 0,
observerFailureCount: 0,
directoryLimitFailureCount: 0,
nativeEventCount: 0,
nativeChangeEventCount: 0,
nativeRenameEventCount: 0,
nativePathlessEventCount: 0,
nativeClassificationCount: 0,
nativeShallowScanCount: 0,
lastReconciliationDurationMs: 0,
maxReconciliationDurationMs: 0,
},
}),
dispose: () => {},
dispose: async () => {},
...overrides,
};
@@ -933,6 +933,7 @@ describe("relay external socket reconnect behavior", () => {
expect(serverInfo.features?.stableProjectIdentity).toBe(true);
expect(serverInfo.features?.canonicalSubmittedPrompts).toBe(true);
expect(serverInfo.features?.providersSnapshotCwd).toBe(true);
expect(serverInfo.features?.["terminal-input-mode-replay"]).toBe(true);
expect(serverInfo.features?.["terminal-size-ownership"]).toBe(true);
expect(serverInfo.features?.agentTurnIdentity).toBeUndefined();

Some files were not shown because too many files have changed in this diff Show More