Compare commits

...

30 Commits

Author SHA1 Message Date
Mohamed Boudra
72c46e5f8b Merge branch 'main' into refactor-test-iter12 2026-05-10 14:15:35 +08:00
Mohamed Boudra
90cb20ce79 ci: remove duplicate server-ci workflow (#870)
The matrix in CI / server-tests (ci.yml) covers what Server CI / test was
doing, on Linux and Windows instead of Linux only. Two workflows running
the same test suite on every server-touching PR is wasted runner time.
2026-05-10 06:05:27 +00:00
Mohamed Boudra
10fdc4330d refactor(server): extract codex app-server test peer 2026-05-10 13:03:02 +07:00
Mohamed Boudra
bd6feac235 test(codex): regression coverage for app-server JSON-RPC dispatch (#869)
* test(codex): regression test for server-initiated JSON-RPC approval round-trip

Verified this test fails against the pre-#866 classifier shape from #758.

* test(codex): cover all server-initiated request methods in transport
2026-05-10 13:46:10 +08:00
Mohamed Boudra
9f3256a254 refactor(server): inject opencode runtime (#868) 2026-05-10 12:57:19 +08:00
Mohamed Boudra
4165dbe8e9 refactor(server): extract Codex app-server transport (#866) 2026-05-10 12:45:13 +08:00
Mohamed Boudra
15e15b525f refactor(server): extract opencode server manager (#865)
* refactor(server): extract opencode server manager

* test(server): replace opencode server manager mocks
2026-05-10 12:36:00 +08:00
Mohamed Boudra
371c29fecd chore: issue forms, PR template, contributor guidance (#864)
* chore: add issue forms, PR template, contributor guidance

- bug report and feature request issue forms with structured fields for surface, version, provider, logs, screenshots
- issue config disables blank issues and routes casual questions to Discord
- single PR template with explicit verification section and AI-assisted contribution stance
- CONTRIBUTING.md: soften drive-by stance for objective bug fixes, codify expectations on AI-assisted reports and PRs (full logs over summaries, proof of testing over prose)

* chore: drop BDFL framing for one-person team

* chore: tighten CONTRIBUTING, drop duplication
2026-05-10 12:32:22 +08:00
Mohamed Boudra
7d823fd3ec fix: close Pi sessions through runtime owner (#863) 2026-05-10 12:28:29 +08:00
Mohamed Boudra
ab4525db5b refactor(server): extract import session boundary (#854) 2026-05-10 12:08:14 +08:00
Mohamed Boudra
73d27bd5fe refactor(app): centralize tool call presentation (#853)
* refactor(app): centralize tool call presentation

* test(app): inject tool call presentation icons
2026-05-10 12:07:54 +08:00
github-actions[bot]
6db5200890 fix: update lockfile signatures and Nix hash 2026-05-10 03:49:18 +00:00
João Sousa Andrade
9dd5c6e5f3 Patch production dependency advisories (#856)
* Patch production dependency advisories

* ci: harden dependency installs with npm ci --ignore-scripts

- Replace npm install with npm ci --ignore-scripts across all workflows
  to enforce lockfile parity and block dependency postinstall scripts.
- Run npm run postinstall explicitly after install so our root patch
  script still applies (the only legitimate postinstall use in this
  repo); dep lifecycle scripts stay blocked.
- Add lockfile-lint and npm audit signatures to the lint job to catch
  registry-host tampering and verify cryptographic signatures.
- Regenerate package-lock.json to match bumped package.json versions.

* ci: drop --ignore-scripts; rely on npm ci + lockfile-lint + audit signatures

Lavamoat allow-scripts only traverses root deps; it can't see workspace
deps like electron, esbuild, sharp. Without proper monorepo support, an
allowlist would be incomplete and electron's binary download breaks.

Keep the rest of the security stack:
- npm ci (strict lockfile parity)
- lockfile-lint (resolved-host check)
- npm audit signatures (cryptographic verification)

Real script-blocking would need pnpm 10+ migration. Tracking separately.

* ci(nix-build): use PR head SHA so fork checkouts work

The previous `ref: ${{ github.head_ref || github.ref }}` made
actions/checkout fetch a branch name that only exists on the fork,
not origin. Use the PR head SHA instead — origin mirrors PR commits
via refs/pull/N/head, so this works for fork PRs without changing
the push-to-main auto-commit behavior.

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-05-10 11:45:24 +08:00
Mohamed Boudra
407c4ca554 fix(app): enable image drop on new workspace screen (#850)
Wrap the screen in FileDropZone so dragged images are forwarded into the
composer, matching the new-draft screen.
2026-05-09 23:29:00 +08:00
Mohamed Boudra
47f26e99db fix(app): redirect optimistically when archiving worktree from toolbar (#852)
The toolbar archive deferred its redirect until the server confirmed
the archive, so users briefly landed on the "workspace not found" gate
and were then sent to the main checkout's workspace route. Mirror the
sidebar pattern: synchronously router.replace to the project's new
workspace screen up front and let archive reconcile only update the
store.
2026-05-09 23:28:46 +08:00
Mohamed Boudra
263886fc1c refactor(app): move agent tab visibility policy (#851) 2026-05-09 23:28:35 +08:00
João Sousa Andrade
bb5ac5b1d0 Fix relay encryption docs (#849) 2026-05-09 23:16:27 +08:00
Mohamed Boudra
9c1c2cea76 refactor(app): extract workspace archive transaction (#848) 2026-05-09 23:15:27 +08:00
Mohamed Boudra
c0037e56af refactor: share agent state priority (#846) 2026-05-09 22:52:06 +08:00
Mohamed Boudra
4f16056578 refactor(app): unified navigateToAgent (#841)
* refactor(app): unify agent navigation

* fix(app): resolve archived agent navigation
2026-05-09 22:42:03 +08:00
Mohamed Boudra
a4bcc7b169 refactor(app): centralize checkout query keys (#844) 2026-05-09 22:40:26 +08:00
Mohamed Boudra
f17fb77013 refactor: share github remote parsing (#843) 2026-05-09 22:40:05 +08:00
Mohamed Boudra
0079f2a875 test(app): don't crash playwright global-setup when speech creds missing
Fork PRs (e.g. #845) run without OPENAI_API_KEY and without local
speech models, so global-setup.ts:420 threw and blew up the entire
Playwright job before any test could run.

Soften the gate: when neither path is available, warn and run with
dictation/voice disabled. Daemon already honors PASEO_DICTATION_ENABLED=0
and PASEO_VOICE_MODE_ENABLED=0. No spec currently exercises dictation,
so nothing else needs to change; future dictation tests should gate on
PASEO_DICTATION_ENABLED.
2026-05-09 21:37:38 +07:00
Mohamed Boudra
0abcd21cd3 refactor(server): centralize task graph readiness (#840) 2026-05-09 22:06:56 +08:00
Mohamed Boudra
5fb6e0e74d test: defang two flaky tests on main CI
agent.integration "creates an autonomous live turn" — the prompt asked
Claude to use a `Task` tool that doesn't exist in the SDK (Paseo
synthesizes Task entries from sidechain notifications, but Claude can't
invoke it). Haiku non-deterministically refused or complied. Switch to
Bash with run_in_background, matching the deterministic
claude-autonomous-wake-simple.real.e2e variant — same autonomous-wake
mechanism is exercised.

schedule/service "defaults new-agent modeId to provider's unattended
mode" — afterEach raced AgentStorage's background snapshot persist,
producing intermittent ENOTEMPTY rmdirs. Drain pending writes with
agentStorage.flush() before rm.
2026-05-09 20:46:20 +07:00
Mohamed Boudra
d88af28e93 fix(server): suffix-fallback auto-named branches and rotate opencode waterfall
Auto-namer now probes refs/heads/<name> and appends -2, -3, … (up to 50)
when the desired slug is taken, instead of failing with "branch already
exists" and leaving the worktree on its placeholder name.

Structured-generation waterfall replaces opencode/gpt-5-nano with
opencode/minimax-m2.5-free and opencode/nemotron-3-super-free so users
without nano configured fall through to free models they actually have.
2026-05-09 20:44:03 +07:00
Mohamed Boudra
73f35537f9 refactor(server): extract shared task graph utilities (#839)
* refactor(server): extract checkout status projection

* refactor(server): extract shared task graph utilities
2026-05-09 21:28:40 +08:00
Mohamed Boudra
e5deb90986 refactor(server): extract checkout status projection (#838) 2026-05-09 21:28:15 +08:00
Mohamed Boudra
d5ffc51f07 docs(changelog): rewrite 0.1.71 entry in user voice 2026-05-09 20:24:01 +07:00
github-actions[bot]
861917836a fix: update lockfile signatures and Nix hash 2026-05-09 13:17:25 +00:00
97 changed files with 4444 additions and 2837 deletions

121
.github/ISSUE_TEMPLATE/bug-report.yml vendored Normal file
View File

@@ -0,0 +1,121 @@
name: Bug report
description: Something is broken or doesn't behave the way it should.
title: "bug: "
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
Before opening, please:
- search existing issues for the same symptom
- try to reproduce on the latest version
- if it's a UI bug, capture a screenshot or short video. text descriptions of UI bugs almost always lose detail.
- type: textarea
id: description
attributes:
label: What's broken
description: What happened, and what did you expect to happen instead?
placeholder: |
I tried to X, expected Y, got Z.
validations:
required: true
- type: textarea
id: repro
attributes:
label: Steps to reproduce
description: The shortest sequence that triggers the bug. If you can't reproduce on demand, say so.
placeholder: |
1. Open the desktop app
2. Pair a daemon
3. Click X
4. ...
validations:
required: true
- type: dropdown
id: surface
attributes:
label: Where did this happen
description: The surface you saw the bug on. Pick the closest match.
options:
- iOS app
- Android app
- Web (browser)
- Desktop (Electron)
- CLI
- Daemon
- Other
validations:
required: true
- type: input
id: paseo-version
attributes:
label: Paseo version
description: Settings → About in the app, or `paseo --version` from the CLI.
placeholder: "0.1.71"
validations:
required: true
- type: input
id: os-version
attributes:
label: OS version
description: Only relevant for desktop, CLI, or daemon issues. Skip for mobile or web.
placeholder: "macOS 15.2, Windows 11, Ubuntu 24.04"
- type: dropdown
id: provider
attributes:
label: Agent provider
description: If the bug involves a specific agent provider, pick which one.
options:
- Not relevant
- Claude Code
- Codex
- OpenCode
- Custom provider
- type: textarea
id: provider-details
attributes:
label: Provider configuration
description: |
If the bug involves an agent, what version are you on and what API are you using? Provider behavior changes a lot across versions and API backends.
placeholder: |
Claude Code v1.2.3 with Anthropic API
Codex CLI v0.5.0 with OpenAI API
OpenCode v0.3.1
(or paste the relevant section of ~/.paseo/config.json for custom providers)
- type: textarea
id: logs
attributes:
label: Logs
description: |
Paste relevant log output. Strongly preferred for crashes and daemon issues.
- **Daemon log:** `~/.paseo/daemon.log` (override with `$PASEO_HOME`)
- **Electron log (macOS):** `~/Library/Logs/Paseo/main.log`
- **Electron log (Windows):** `%APPDATA%\Paseo\logs\main.log`
- **Electron log (Linux):** `~/.config/Paseo/logs/main.log`
Paste the **full log around the time of the bug**, not a summary. If you used an AI to investigate, paste the raw log it read, not the AI's interpretation. AI summaries skew the signal and waste my time.
render: text
- type: textarea
id: screenshots
attributes:
label: Screenshots or video
description: |
**Required for UI bugs.** Drag and drop directly into this field. Short videos beat screenshots for anything involving interaction or animation.
- type: markdown
attributes:
value: |
---
**A note on AI-assisted reports.** Using an agent to gather information (logs, repro steps, version checks) is fine and useful. Using an agent to *diagnose* the bug and then submitting only that diagnosis is not. Agents routinely correlate adjacent log lines as cause-and-effect when they aren't related, and once a report is filtered through an AI summary I lose the signal I need to actually fix the bug. Paste the raw inputs.

5
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: Discord
url: https://discord.gg/jz8T2uahpH
about: Quick questions, sharing a video of a bug, or anything that's better as a chat. A lot of issues start better here.

View File

@@ -0,0 +1,43 @@
name: Feature request
description: Propose a new feature or a change to existing behavior.
title: "feat: "
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: |
Paseo is opinionated and maintained by one person. Feature requests are welcome, but they get evaluated against product fit, not just usefulness, and the bar is whether the change keeps the product lean enough for one person to maintain.
Big ideas are better discussed in [Discord](https://discord.gg/jz8T2uahpH) first. And please don't open a feature request and a PR at the same time, get alignment on the idea before writing code.
- type: checkboxes
id: prior-search
attributes:
label: Prior search
options:
- label: I searched existing issues and discussions, and this isn't already proposed.
required: true
- type: textarea
id: problem
attributes:
label: What's the problem
description: What are you actually trying to do, and why is the current behavior in the way?
placeholder: |
When I'm doing X, I want to Y, but Paseo currently Z.
validations:
required: true
- type: textarea
id: proposal
attributes:
label: What would solve it
description: A rough sketch of the change. Mockups, screenshots from other apps, or a short video are very welcome, especially for UI proposals.
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives you considered
description: Optional. What else did you try, and why doesn't it work?

43
.github/PULL_REQUEST_TEMPLATE.md vendored Normal file
View File

@@ -0,0 +1,43 @@
<!--
Please follow this template. The PR template applies whether you opened the PR via the web UI, `gh pr create`, or any other tool.
If you're fixing an objective bug or a small focused issue, this should be quick. Big PRs without a prior issue or design discussion are likely to be closed or scoped down. See CONTRIBUTING.md.
-->
### Linked issue
Closes #
<!-- Bug fixes and behavior changes should reference an issue. Pure docs and refactors can skip this. -->
### Type of change
- [ ] Bug fix
- [ ] New feature (with prior issue + design alignment)
- [ ] Refactor / code improvement
- [ ] Docs
### What does this PR do
<!-- A short description of the change in your own words. What was wrong, what you changed, why it works. If you can't explain this briefly, the PR is probably too big. -->
### How did you verify it
<!--
This is the section I read most carefully. I need to see that *you* tested this, not that the diff looks plausible.
- For UI changes: a screenshot or short video on every affected platform (mobile, web, desktop). UI claims without visual proof are not enough.
- For behavior changes: the actual steps you ran, and what you observed.
- For bug fixes: how you reproduced the bug before, and confirmed it's fixed after.
AI-generated PR descriptions are fine in principle. AI-generated *verification claims* with no actual testing behind them are not, and they're easy to spot.
-->
### Checklist
- [ ] One focused change. Unrelated cleanups split out.
- [ ] `npm run typecheck` passes
- [ ] `npm run lint` passes
- [ ] `npm run format` ran (Biome)
- [ ] UI changes include screenshots or video for every affected platform
- [ ] Tests added or updated where it made sense

View File

@@ -19,7 +19,7 @@ jobs:
cache: "npm"
- name: Install dependencies
run: npm install
run: npm ci
- name: Check formatting
run: npx oxfmt --check .
@@ -35,7 +35,13 @@ jobs:
cache: "npm"
- name: Install dependencies
run: npm install
run: npm ci
- name: Lint lockfile
run: npx --yes lockfile-lint --path package-lock.json --type npm --allowed-hosts npm --validate-https --validate-integrity
- name: Verify dependency signatures
run: npm audit signatures
- name: Lint
run: npm run lint
@@ -51,7 +57,7 @@ jobs:
cache: "npm"
- name: Install dependencies
run: npm install
run: npm ci
- name: Build highlight dependency
run: npm run build --workspace=@getpaseo/highlight
@@ -86,7 +92,7 @@ jobs:
run: git fetch --no-tags origin main:refs/remotes/origin/main
- name: Install dependencies
run: npm install
run: npm ci
- name: Install Claude Code CLI for provider tests
run: npm install -g @anthropic-ai/claude-code
@@ -118,7 +124,7 @@ jobs:
cache: "npm"
- name: Install dependencies
run: npm install
run: npm ci
- name: Build highlight dependency
run: npm run build --workspace=@getpaseo/highlight
@@ -143,7 +149,7 @@ jobs:
cache: "npm"
- name: Install dependencies
run: npm install
run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
@@ -165,7 +171,7 @@ jobs:
cache: "npm"
- name: Install dependencies
run: npm install
run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
@@ -208,7 +214,7 @@ jobs:
cache: "npm"
- name: Install dependencies
run: npm install
run: npm ci
- name: Build relay
run: npm run build --workspace=@getpaseo/relay
@@ -232,7 +238,7 @@ jobs:
cache: "npm"
- name: Install dependencies
run: npm install
run: npm ci
- name: Install agent CLIs for provider tests
run: npm install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai

View File

@@ -21,7 +21,7 @@ jobs:
cache: "npm"
- name: Install dependencies
run: npm install --workspace=@getpaseo/relay --include-workspace-root
run: npm ci --workspace=@getpaseo/relay --include-workspace-root
- name: Typecheck
run: npm run typecheck --workspace=@getpaseo/relay

View File

@@ -28,7 +28,7 @@ jobs:
cache: "npm"
- name: Install dependencies
run: npm install --workspace=@getpaseo/website --include-workspace-root
run: npm ci --workspace=@getpaseo/website --include-workspace-root
- name: Typecheck
run: npm run typecheck --workspace=@getpaseo/website

View File

@@ -43,7 +43,7 @@ jobs:
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.head_ref || github.ref }}
ref: ${{ github.event.pull_request.head.sha || github.ref }}
token: ${{ secrets.GITHUB_TOKEN }}
- uses: actions/setup-node@v4

View File

@@ -1,55 +0,0 @@
name: Server CI
on:
push:
branches: [main]
paths:
- "packages/server/**"
- "package.json"
- "package-lock.json"
- ".github/workflows/server-ci.yml"
pull_request:
branches: [main]
paths:
- "packages/server/**"
- "package.json"
- "package-lock.json"
- ".github/workflows/server-ci.yml"
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
- name: Fetch origin/main (worktree tests)
run: git fetch --no-tags origin main:refs/remotes/origin/main
- name: Install server dependencies
run: npm install --workspace=@getpaseo/server --include-workspace-root
- name: Install Claude Code CLI for provider tests
run: npm install -g @anthropic-ai/claude-code
- name: Build highlight dependency
run: npm run build --workspace=@getpaseo/highlight
- name: Build relay dependency
run: npm run build --workspace=@getpaseo/relay
- name: Typecheck
run: npm run typecheck --workspace=@getpaseo/server
- name: Test
run: npm run test --workspace=@getpaseo/server
env:
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}

View File

@@ -4,36 +4,29 @@
### Added
- **Import existing Claude, Codex, and OpenCode sessions** straight into Paseo — pick up a conversation you started in the terminal and keep going from the app, with full timeline rehydration. ([#766](https://github.com/getpaseo/paseo/pull/766))
- **Subagents now appear in a collapsible section above the composer** so you can see and jump into the agents your main agent has spawned, without leaving the workspace. ([#532](https://github.com/getpaseo/paseo/pull/532))
- **Merge PR action in the checkout pane** — merge a pull request directly from Paseo, with a real-GitHub end-to-end test backing it. ([#814](https://github.com/getpaseo/paseo/pull/814))
- **Project-level prompts for metadata generation** — customize how titles and summaries are generated per project. ([#836](https://github.com/getpaseo/paseo/pull/836))
- **Grouped project settings** with inline links to the relevant docs. ([#837](https://github.com/getpaseo/paseo/pull/837))
- **Create an empty workspace without writing a prompt** — useful for opening a project just to browse or import a session. ([#834](https://github.com/getpaseo/paseo/pull/834))
- **Import-agent pill above the draft composer** so you can quickly resume a recent terminal session as a Paseo agent. ([#833](https://github.com/getpaseo/paseo/pull/833))
- **Rich context menu on desktop** — copy link, copy image, and spellcheck suggestions in the Electron desktop app.
- **Codex `/archive` integration** — archiving a Codex-backed agent now archives the underlying native Codex thread too. ([#827](https://github.com/getpaseo/paseo/pull/827) by [@32r4](https://github.com/32r4))
- **Import existing Claude, Codex, and OpenCode sessions** into Paseo — pick up a conversation you started in the terminal and keep going from the app, with the full timeline. ([#766](https://github.com/getpaseo/paseo/pull/766), [#833](https://github.com/getpaseo/paseo/pull/833))
- **Subagents now appear in a collapsible section above the composer** so you can jump into agents your main agent spawned. ([#532](https://github.com/getpaseo/paseo/pull/532))
- Merge a pull request directly from the checkout pane. ([#814](https://github.com/getpaseo/paseo/pull/814))
- Customize the per-project prompts Paseo uses to auto-generate agent titles, branch names, commit messages, and pull request descriptions. ([#836](https://github.com/getpaseo/paseo/pull/836))
- Open an empty workspace without typing a prompt first. ([#834](https://github.com/getpaseo/paseo/pull/834))
- Project settings are now grouped with inline links to the relevant docs. ([#837](https://github.com/getpaseo/paseo/pull/837))
- Rich context menu on desktop — copy link, copy image, and spellcheck suggestions.
- Archiving a Codex-backed agent now archives the underlying native Codex thread too. ([#827](https://github.com/getpaseo/paseo/pull/827) by [@32r4](https://github.com/32r4))
### Improved
- Workspace navigation auto-focuses the agent that needs your attention. ([#828](https://github.com/getpaseo/paseo/pull/828))
- MCP `create_agent` inherits the caller's unattended mode when launching an agent on a different provider, instead of falling back to attended.
- Subagent task notifications no longer flicker on archive, and the subagents track displays correctly across reloads.
- Opening a workspace auto-focuses the agent that needs your attention. ([#828](https://github.com/getpaseo/paseo/pull/828))
- An unattended agent that spawns a sub-agent on a different provider via MCP now starts the sub-agent in unattended mode too.
### Fixed
- **Reload now properly rehydrates the timeline from disk** under a fresh epoch, so a reloaded imported agent picks up new entries instead of getting stuck on the previous view.
- **Chat fanout and system-injected agent prompts unified** — fixes inconsistent delivery and formatting of system messages routed to multiple agents. ([#830](https://github.com/getpaseo/paseo/pull/830))
- iOS project picker now submits the typed path correctly. ([#829](https://github.com/getpaseo/paseo/issues/829), [#831](https://github.com/getpaseo/paseo/pull/831))
- The composer's working directory now sources from the agent's actual state, not the draft hook, so it stays in sync after reloads.
- Workspace open no longer auto-focuses subagents.
- Ephemeral Claude session transcripts are deleted on close so internal generator runs don't pollute the import sheet.
- Markdown link clicks no longer trigger the underlying anchor's default behavior.
- `QueryProvider` is hoisted above `PortalProvider` so portaled sheets can read from the React Query client.
- iOS project picker now submits the typed path. ([#831](https://github.com/getpaseo/paseo/pull/831))
- System messages and chat mentions routed to multiple agents now reach every recipient consistently. ([#830](https://github.com/getpaseo/paseo/pull/830))
- Clicking a Markdown link in agent output no longer reloads the desktop app on top of opening the link.
### Security
- **Local state file permissions are now restricted** so daemon keypairs, persisted config, and token stores are readable only by the owner. ([#825](https://github.com/getpaseo/paseo/pull/825) by [@joaosa](https://github.com/joaosa))
- Local state files (daemon keypair, stored credentials, persisted config) are now readable only by the owning user. ([#825](https://github.com/getpaseo/paseo/pull/825) by [@joaosa](https://github.com/joaosa))
## 0.1.70 - 2026-05-08

View File

@@ -1,31 +1,24 @@
# Contributing to Paseo
Thanks for taking the time to contribute.
## How this project works
Paseo is a BDFL project. Product direction, scope, and what ships are the maintainer's call.
Paseo is opinionated and maintained by one person. I read every issue and PR myself, so review cost is real.
This means:
- **Feature requests are welcome.** Open an issue describing the problem. Get a thumbs up before writing code. Big ideas are better discussed in [Discord](https://discord.gg/jz8T2uahpH) first.
- **Objective bug fixes don't need a prior issue.** Reference what's broken, keep the diff narrow, open the PR.
- **The product stays lean.** I'll close, scope down, or rewrite PRs that add surface area I don't want to maintain, even if the code is fine.
- PRs submitted without prior discussion will likely be rejected, heavily modified, or scoped down.
- The maintainer may rewrite, split, cherry-pick from, or close any PR at their discretion.
- There is no obligation to merge a PR as-submitted, regardless of code quality.
## Reporting bugs
This is not meant to discourage contributions. It is meant to set clear expectations so nobody wastes their time.
Fill in the bug report form. The fields are there because asking back for the surface, version, provider config, and logs is where most of my time on a bad report goes.
## How to contribute
1. **Open an issue first.** Describe the problem or improvement. Get a thumbs up before writing code.
2. **Keep it small.** One bug, one flow, one focused change.
3. **Open a PR** once there is alignment on scope.
If you want to propose a direction change, start a conversation.
- **Full logs, not AI summaries.** Use an agent to grab the relevant log section if you want, but paste the raw log. Agents routinely correlate adjacent lines as cause-and-effect when they aren't, and once a report is filtered through that the signal I need is gone.
- **Agents for information gathering, not diagnosis.** A bot that grabs your daemon log, version, and OS is helpful. A bot that submits its own theory of the bug is noise 99% of the time.
- **Screenshots or video for UI bugs.** A 10-second recording beats a paragraph.
- **One bug per issue.** Three findings, three issues.
## Before you start
Please read these first:
- [README.md](README.md)
- [docs/architecture.md](docs/architecture.md)
- [docs/development.md](docs/development.md)
@@ -35,134 +28,36 @@ Please read these first:
## What is most helpful
The most useful contributions right now are:
- bug fixes
- windows and linux specific fixes
- bug fixes (especially Windows and Linux)
- regression fixes
- doc improvements
- packaging / platform fixes
- focused UX improvements that fit the existing product direction
- packaging and platform fixes
- focused UX improvements that fit the product direction
- tests that lock down important behavior
## Scope expectations
Please keep PRs narrow.
Good:
- fix one bug
- improve one flow
- add one focused panel or command
- tighten one piece of UI
Bad:
- combine multiple product ideas in one PR
- bundle unrelated refactors with a feature
- sneak in roadmap decisions
If a contribution contains multiple ideas, split it up.
## Product fit matters
Paseo is an opinionated product.
When reviewing contributions, the bar is not just:
- is this useful?
- is this well implemented?
It is also:
- does this fit Paseo?
- does this add product surface that will be hard to maintain?
- does the value justify the maintenance surface it adds?
- does this solve a common need or over-serve an edge case?
- does this preserve the product's current direction?
## Development setup
### Prerequisites
- Node.js matching `.tool-versions`
- npm workspaces
### Start local development
```bash
# runs both daemon and expo app
npm run dev
```
Useful commands:
```bash
npm run dev # daemon + expo
npm run dev:server
npm run dev:app
npm run dev:desktop
npm run dev:website
npm run cli -- ls -a -g
```
Read [docs/development.md](docs/development.md) for build-sync gotchas, local state, ports, and daemon details.
[docs/development.md](docs/development.md) covers build sync, local state, and ports. Coding rules live in [docs/coding-standards.md](docs/coding-standards.md).
## Multi-platform testing
## Pull requests
Paseo ships to mobile (iOS/Android), web, and desktop (Electron). Every UI change must be tested on mobile and web at minimum, and desktop if relevant. Things that look fine on one surface regularly break on another.
- One focused change per PR. Split unrelated cleanups out.
- Reference the issue you're fixing, unless it's a small objective bug.
- UI changes need screenshots or video on every affected platform (mobile, web, desktop). Things that look fine on one surface regularly break on another.
- `npm run typecheck` and `npm run lint` must pass.
- Don't make breaking WebSocket or protocol changes. Old apps and old daemons coexist in the wild.
- The PR template applies whether you used the web UI or `gh pr create`. Don't strip it out.
Common checks:
```bash
npm run typecheck
npm run test --workspaces --if-present
```
Important rules:
- always run `npm run typecheck` after changes
- tests should be deterministic
- prefer real dependencies over mocks when possible
- do not make breaking WebSocket / protocol changes
- app and daemon versions in the wild lag each other, so compatibility matters
If you touch protocol or shared client/server behavior, read the compatibility notes in [CLAUDE.md](CLAUDE.md).
## Coding standards
Paseo has explicit standards. Follow them.
The full guide lives in [docs/coding-standards.md](docs/coding-standards.md).
## PR checklist
Before opening a PR, make sure:
- there was prior discussion and alignment on scope (issue or conversation)
- the change is focused, one idea per PR
- the PR description explains what changed and why
- **UI changes include screenshots or videos** for every affected platform (mobile, web, desktop)
- UI changes have been tested on mobile and web at minimum
- typecheck passes
- tests pass, or you clearly explain what could not be run
- relevant docs were updated if needed
## Communication
If you are unsure whether something fits, ask first.
That is especially true for:
- new core UX
- naming / terminology changes
- new extension points
- new orchestration models
- anything that would be hard to remove later
Early alignment saves everyone time.
**On AI-assisted PRs.** AI in the loop is fine. The bar is whether _you_ tested the change and can explain why it works. A confident wall of AI prose with no evidence of testing is a red flag and usually gets closed. If you don't fully understand why your fix works, say so directly. "Here's the repro before and after, not sure why this fixes it" is much better than a fabricated explanation.
## Forks are fine
If you want to explore a different product direction, a fork is completely fine.
Paseo is open source on purpose. Not every idea needs to land in the main repo to be valuable.
If you want to explore a different product direction, fork. Paseo is open source on purpose. Not every idea needs to land here to be valuable.

View File

@@ -103,7 +103,7 @@ Communicates with the daemon via the same WebSocket protocol as the app.
Enables remote access when the daemon is behind a firewall.
- ECDH key exchange + AES-256-GCM encryption
- Curve25519 ECDH key exchange + XSalsa20-Poly1305 (NaCl `box`) encryption
- Relay server is zero-knowledge — it routes encrypted bytes, cannot read content
- Client and daemon channels with identical API (`createClientChannel`, `createDaemonChannel`)
- Pairing via QR code transfers the daemon's public key to the client

View File

@@ -42,7 +42,7 @@ buildNpmPackage rec {
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
npmDepsHash = "sha256-ocBSTgfzPlucgcCaPRi3iYhxpZD6hTzD3b2lphSTtb4=";
npmDepsHash = "sha256-qXCfTM7Q1PyXL53C+AFgFA5b99uznKaKomwmX2UcZHo=";
# Prevent onnxruntime-node's install script from running during automatic
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).

873
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -414,15 +414,19 @@ async function resolveDictationConfig(): Promise<DictationConfig> {
);
const hasDefaultLocalModelsDir =
defaultLocalModelsDir.trim().length > 0 && existsSync(defaultLocalModelsDir);
const dictationProvider = openAiUsable ? "openai" : "local";
if (dictationProvider === "local" && !hasDefaultLocalModelsDir) {
throw new Error(
"OpenAI key is not usable and local speech models are unavailable at ~/.paseo/models/local-speech. " +
"Either provide a valid OPENAI_API_KEY or install local speech models before running app e2e tests.",
// Fork PRs run without secrets and usually without local models. Don't crash
// the whole Playwright run — disable dictation/voice and let tests that need
// them gate on PASEO_DICTATION_ENABLED.
if (!openAiUsable && !hasDefaultLocalModelsDir) {
console.warn(
"[e2e] Neither OPENAI_API_KEY nor local speech models found — running with dictation/voice disabled. " +
"Tests that require dictation should gate on PASEO_DICTATION_ENABLED.",
);
return { openAiUsable: false, localModelsDir: null };
}
const dictationProvider = openAiUsable ? "openai" : "local";
const localModelsDir = dictationProvider === "local" ? defaultLocalModelsDir : null;
console.log(
`[e2e] Dictation STT provider: ${dictationProvider}${openAiUsable ? "" : " (OpenAI probe failed)"}`,

View File

@@ -115,7 +115,7 @@
"playwright": "^1.56.1",
"typescript": "~5.9.2",
"vitest": "^3.2.4",
"wrangler": "^4.59.1",
"wrangler": "^4.75.0",
"ws": "^8.20.0"
}
}

View File

@@ -59,7 +59,6 @@ import { useLatchedBoolean } from "@/hooks/use-latched-boolean";
import { useOpenProject } from "@/hooks/use-open-project";
import { useAppSettings } from "@/hooks/use-settings";
import { useStableEvent } from "@/hooks/use-stable-event";
import { navigateToWorkspace } from "@/hooks/use-workspace-navigation";
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
import { polyfillCrypto } from "@/polyfills/crypto";
import { queryClient } from "@/query/query-client";
@@ -72,7 +71,6 @@ import {
} from "@/runtime/host-runtime";
import { getDaemonStartService } from "@/runtime/daemon-start-service";
import { usePanelStore } from "@/stores/panel-store";
import { useSessionStore } from "@/stores/session-store";
import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme";
import type { HostProfile } from "@/types/host-connection";
import { resolveActiveHost } from "@/utils/active-host";
@@ -85,13 +83,12 @@ import {
parseWorkspaceOpenIntent,
} from "@/utils/host-routes";
import { buildNotificationRoute, resolveNotificationTarget } from "@/utils/notification-routing";
import { navigateToAgent } from "@/utils/navigate-to-agent";
import {
ensureOsNotificationPermission,
WEB_NOTIFICATION_CLICK_EVENT,
type WebNotificationClickDetail,
} from "@/utils/os-notifications";
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
polyfillCrypto();
@@ -118,25 +115,8 @@ function PushNotificationRouter() {
const serverId = target.serverId;
const agentId = target.agentId;
if (serverId && agentId) {
const session = useSessionStore.getState().sessions[serverId];
const agent = session?.agents.get(agentId);
const workspaceId =
target.workspaceId ??
resolveWorkspaceIdByExecutionDirectory({
workspaces: session?.workspaces.values(),
workspaceDirectory: agent?.cwd,
});
if (workspaceId) {
prepareWorkspaceTab({
serverId,
workspaceId,
target: { kind: "agent", agentId },
pin: true,
});
navigateToWorkspace(serverId, workspaceId, { currentPathname: pathname });
return;
}
navigateToAgent({ serverId, agentId, currentPathname: pathname, pin: true });
return;
}
router.navigate(buildNotificationRoute(data));

View File

@@ -10,7 +10,6 @@ import {
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useCallback, useMemo, useState, type ReactElement } from "react";
import { router, type Href } from "expo-router";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { formatTimeAgo } from "@/utils/time";
@@ -19,9 +18,7 @@ import { type AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { useSessionStore } from "@/stores/session-store";
import { Archive } from "lucide-react-native";
import { getProviderIcon } from "@/components/provider-icons";
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
import { navigateToAgent } from "@/utils/navigate-to-agent";
import type { Agent } from "@/stores/session-store";
import { useArchiveAgent } from "@/hooks/use-archive-agent";
@@ -312,23 +309,13 @@ export function AgentList({
const serverId = agent.serverId;
const agentId = agent.id;
const workspaceId = resolveWorkspaceIdByExecutionDirectory({
workspaces: useSessionStore.getState().sessions[serverId]?.workspaces?.values(),
workspaceDirectory: agent.cwd,
});
onAgentSelect?.();
if (!workspaceId) {
router.navigate(buildHostAgentDetailRoute(serverId, agentId) as Href);
return;
}
rememberArchivedAgentDetail(agent);
navigateToPreparedWorkspaceTab({
navigateToAgent({
serverId,
workspaceId,
target: { kind: "agent", agentId },
agentId,
pin: Boolean(agent.archivedAt),
});
},

View File

@@ -66,13 +66,8 @@ import * as Clipboard from "expo-clipboard";
import type { TodoEntry, UserMessageImageAttachment } from "@/types/stream";
import type { AgentAttachment } from "@server/shared/messages";
import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types";
import { buildToolCallDisplayModel } from "@/utils/tool-call-display";
import { buildToolCallPresentation } from "@/tool-calls/presentation";
import { resolveToolCallIcon } from "@/utils/tool-call-icon";
import { extractToolCallFilePath } from "@/utils/extract-tool-call-file-path";
import {
hasMeaningfulToolCallDetail,
isPendingToolCallDetail,
} from "@/utils/tool-call-detail-state";
import {
parseAssistantFileLink,
parseInlinePathToken,
@@ -2858,7 +2853,6 @@ export const ToolCall = memo(function ToolCall({
const { openToolCall } = useToolCallSheet();
const [isExpanded, setIsExpanded] = useState(false);
// Check if we're on mobile (use bottom sheet) or desktop (inline expand)
const isMobile = useIsCompactFormFactor();
const effectiveDetail = useMemo<ToolCallDetail | undefined>(() => {
@@ -2875,63 +2869,36 @@ export const ToolCall = memo(function ToolCall({
return undefined;
}, [detail, args, result]);
const displayDetail = useMemo<ToolCallDetail>(
const presentation = useMemo(
() =>
effectiveDetail ?? {
type: "unknown",
input: null,
output: null,
},
[effectiveDetail],
);
const displayModel = useMemo(
() =>
buildToolCallDisplayModel({
name: toolName,
status: status === "executing" ? "running" : status,
buildToolCallPresentation({
toolName,
status,
error: error ?? null,
detail: displayDetail,
detail: effectiveDetail,
metadata,
cwd,
resolveIcon: resolveToolCallIcon,
}),
[toolName, status, error, displayDetail, metadata, cwd],
);
const displayName = displayModel.displayName;
const summary = displayModel.summary;
const errorText = displayModel.errorText;
const IconComponent = resolveToolCallIcon(toolName, effectiveDetail);
const isLoadingDetails = isPendingToolCallDetail({
detail: effectiveDetail,
status,
error,
});
const secondaryLabel = summary;
// Check if there's any content to display
const hasDetails = Boolean(error) || hasMeaningfulToolCallDetail(effectiveDetail);
const canOpenDetails = hasDetails || isLoadingDetails;
const extractedFilePath = useMemo(
() => extractToolCallFilePath(effectiveDetail),
[effectiveDetail],
[toolName, status, error, effectiveDetail, metadata, cwd],
);
const handleOpenFile = useMemo(() => {
if (!extractedFilePath || !onOpenFilePath) {
const openFilePath = presentation.openFilePath;
if (!openFilePath || !onOpenFilePath) {
return undefined;
}
return () => onOpenFilePath(extractedFilePath);
}, [extractedFilePath, onOpenFilePath]);
return () => onOpenFilePath(openFilePath);
}, [presentation.openFilePath, onOpenFilePath]);
const handleToggle = useCallback(() => {
if (isMobile) {
openToolCall({
toolName,
displayName,
summary: secondaryLabel,
displayName: presentation.displayName,
summary: presentation.summary,
detail: effectiveDetail,
errorText,
showLoadingSkeleton: isLoadingDetails,
errorText: presentation.errorText,
icon: presentation.icon,
showLoadingSkeleton: presentation.isLoadingDetails,
});
} else {
setIsExpanded((prev) => !prev);
@@ -2939,12 +2906,12 @@ export const ToolCall = memo(function ToolCall({
}, [
isMobile,
openToolCall,
toolName,
displayName,
secondaryLabel,
presentation.displayName,
presentation.summary,
presentation.errorText,
presentation.icon,
presentation.isLoadingDetails,
effectiveDetail,
errorText,
isLoadingDetails,
]);
useEffect(() => {
@@ -2980,14 +2947,14 @@ export const ToolCall = memo(function ToolCall({
return (
<ToolCallDetailsContent
detail={effectiveDetail}
errorText={errorText}
errorText={presentation.errorText}
maxHeight={400}
showLoadingSkeleton={isLoadingDetails}
showLoadingSkeleton={presentation.isLoadingDetails}
/>
);
}, [isMobile, effectiveDetail, errorText, isLoadingDetails]);
}, [isMobile, effectiveDetail, presentation.errorText, presentation.isLoadingDetails]);
if (effectiveDetail?.type === "plan") {
if (presentation.isPlan && effectiveDetail?.type === "plan") {
return (
<PlanCard
title="Plan"
@@ -3001,13 +2968,13 @@ export const ToolCall = memo(function ToolCall({
return (
<ExpandableBadge
testID="tool-call-badge"
label={displayName}
secondaryLabel={secondaryLabel}
icon={IconComponent}
label={presentation.displayName}
secondaryLabel={presentation.summary}
icon={presentation.icon}
isExpanded={!isMobile && isExpanded}
onToggle={canOpenDetails ? handleToggle : undefined}
onToggle={presentation.canOpenDetails ? handleToggle : undefined}
onOpenFile={handleOpenFile}
renderDetails={canOpenDetails && !isMobile ? renderDetails : undefined}
renderDetails={presentation.canOpenDetails && !isMobile ? renderDetails : undefined}
isLoading={status === "running" || status === "executing"}
isError={status === "failed"}
isLastInSequence={isLastInSequence}

View File

@@ -101,16 +101,15 @@ import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspac
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
import { useWorkspaceFields } from "@/stores/session-store-hooks";
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
import {
clearWorkspaceArchivePending,
markWorkspaceArchivePending,
} from "@/contexts/session-workspace-upserts";
import { openExternalUrl } from "@/utils/open-external-url";
import {
requireWorkspaceExecutionDirectory,
resolveWorkspaceMapKeyByIdentity,
resolveWorkspaceExecutionDirectory,
} from "@/utils/workspace-execution";
import {
archiveWorkspaceOptimistically,
archiveWorkspacesOptimistically,
} from "@/workspace/workspace-archive";
import { WorkspaceHoverCard } from "@/components/workspace-hover-card";
import { GitHubIcon } from "@/components/icons/github-icon";
import { isWeb as platformIsWeb, isNative as platformIsNative } from "@/constants/platform";
@@ -126,35 +125,6 @@ const workspaceKeyExtractor = (workspace: SidebarWorkspaceEntry) => workspace.wo
const projectKeyExtractor = (project: SidebarProjectEntry) => project.projectKey;
function hideWorkspaceOptimistically(workspace: SidebarWorkspaceEntry): WorkspaceDescriptor | null {
const workspaces = useSessionStore.getState().sessions[workspace.serverId]?.workspaces;
const workspaceKey = resolveWorkspaceMapKeyByIdentity({
workspaces,
workspaceId: workspace.workspaceId,
});
const snapshot = workspaceKey ? (workspaces?.get(workspaceKey) ?? null) : null;
markWorkspaceArchivePending({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.workspaceDirectory,
});
useSessionStore.getState().removeWorkspace(workspace.serverId, workspace.workspaceId);
return snapshot;
}
function restoreOptimisticallyHiddenWorkspace(input: {
serverId: string;
workspaceId: string;
snapshot: WorkspaceDescriptor | null;
}): void {
clearWorkspaceArchivePending({
serverId: input.serverId,
workspaceId: input.workspaceId,
});
if (input.snapshot) {
useSessionStore.getState().mergeWorkspaces(input.serverId, [input.snapshot]);
}
}
const WORKSPACE_STATUS_DOT_WIDTH = 14;
const DEFAULT_STATUS_DOT_SIZE = 7;
const EMPHASIZED_STATUS_DOT_SIZE = 9;
@@ -1574,21 +1544,14 @@ function WorkspaceRowWithMenu({
}
setIsArchivingWorkspace(true);
const snapshot = hideWorkspaceOptimistically(workspace);
redirectAfterArchive();
void (async () => {
try {
const payload = await client.archiveWorkspace(workspace.workspaceId);
if (payload.error) {
throw new Error(payload.error);
}
} catch (error) {
restoreOptimisticallyHiddenWorkspace({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
snapshot,
await archiveWorkspaceOptimistically({
client,
workspace,
afterHide: redirectAfterArchive,
});
} catch (error) {
toast.error(error instanceof Error ? error.message : "Failed to hide workspace");
} finally {
setIsArchivingWorkspace(false);
@@ -1719,21 +1682,14 @@ function NonGitProjectRowWithMenuContent({
}
setIsArchivingWorkspace(true);
const snapshot = hideWorkspaceOptimistically(workspace);
redirectAfterArchive();
void (async () => {
try {
const payload = await client.archiveWorkspace(workspace.workspaceId);
if (payload.error) {
throw new Error(payload.error);
}
} catch (error) {
restoreOptimisticallyHiddenWorkspace({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
snapshot,
await archiveWorkspaceOptimistically({
client,
workspace,
afterHide: redirectAfterArchive,
});
} catch (error) {
toast.error(error instanceof Error ? error.message : "Failed to hide workspace");
} finally {
setIsArchivingWorkspace(false);
@@ -2145,33 +2101,11 @@ function ProjectBlock({
}
setIsRemovingProject(true);
const snapshots = new Map(
project.workspaces.map((workspace) => [
workspace.workspaceId,
hideWorkspaceOptimistically(workspace),
]),
);
const isRejected = (r: PromiseSettledResult<unknown>) => r.status === "rejected";
void Promise.allSettled(
project.workspaces.map(async (ws) => {
try {
const payload = await client.archiveWorkspace(ws.workspaceId);
if (payload.error) {
throw new Error(payload.error);
}
} catch (error) {
restoreOptimisticallyHiddenWorkspace({
serverId,
workspaceId: ws.workspaceId,
snapshot: snapshots.get(ws.workspaceId) ?? null,
});
throw error;
}
}),
).then((results) => {
const failed = results.filter(isRejected);
if (failed.length > 0) {
void archiveWorkspacesOptimistically({
client,
workspaces: project.workspaces,
}).then((failures) => {
if (failures.length > 0) {
toast.error("Failed to remove some workspaces");
}
setIsRemovingProject(false);

View File

@@ -14,17 +14,17 @@ import {
IsolatedBottomSheetModal,
useIsolatedBottomSheetVisibility,
} from "@/components/ui/isolated-bottom-sheet-modal";
import { resolveToolCallIcon } from "@/utils/tool-call-icon";
import type { ToolCallIconComponent } from "@/utils/tool-call-icon";
import { ToolCallDetailsContent } from "./tool-call-details";
// ----- Types -----
export interface ToolCallSheetData {
toolName: string;
displayName: string;
summary?: string;
detail?: ToolCallDetail;
errorText?: string;
icon: ToolCallIconComponent;
showLoadingSkeleton?: boolean;
}
@@ -139,9 +139,7 @@ interface ToolCallSheetContentProps {
function ToolCallSheetContent({ data, onClose }: ToolCallSheetContentProps) {
const { theme } = useUnistyles();
const { toolName, displayName, detail, errorText, showLoadingSkeleton } = data;
const IconComponent = resolveToolCallIcon(toolName, detail);
const { displayName, detail, errorText, icon: IconComponent, showLoadingSkeleton } = data;
return (
<View style={styles.container}>

View File

@@ -17,6 +17,7 @@ import { splitComposerAttachmentsForSubmit } from "@/components/composer-attachm
import type { CreateAgentRequestOptions, DaemonClient } from "@server/client/daemon-client";
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
import { requireWorkspaceExecutionAuthority } from "@/utils/workspace-execution";
import { navigateToAgent } from "@/utils/navigate-to-agent";
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
import type { ImageAttachment, MessagePayload } from "./message-input";
@@ -190,6 +191,14 @@ export function WorkspaceSetupDialog() {
}
clearWorkspaceSetup();
if (target.kind === "agent") {
navigateToAgent({
serverId: pendingWorkspaceSetup.serverId,
agentId: target.agentId,
});
return;
}
navigateToPreparedWorkspaceTab({
serverId: pendingWorkspaceSetup.serverId,
workspaceId,

View File

@@ -1,12 +1,10 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { QueryClient } from "@tanstack/react-query";
import type { DaemonClient } from "@server/client/daemon-client";
import { queryClient as appQueryClient } from "@/query/query-client";
import { useSessionStore } from "@/stores/session-store";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import {
__resetCheckoutGitActionsStoreForTests,
invalidateCheckoutGitQueriesForClient,
isLocalWorktreeArchivePending,
useCheckoutGitActionsStore,
} from "@/git/actions-store";
@@ -172,32 +170,6 @@ describe("checkout-git-actions-store", () => {
).toBe("idle");
});
it("invalidates checkout PR status and every PR pane timeline for a checkout", async () => {
const queryClient = new QueryClient();
queryClient.setQueryData(["checkoutPrStatus", serverId, cwd], { status: { number: 12 } });
queryClient.setQueryData(["prPaneTimeline", serverId, cwd, 12], { items: [] });
queryClient.setQueryData(["prPaneTimeline", serverId, cwd, 13], { items: [] });
queryClient.setQueryData(["prPaneTimeline", serverId, "/tmp/other", 12], { items: [] });
await invalidateCheckoutGitQueriesForClient(queryClient, { serverId, cwd });
expect(queryClient.getQueryState(["checkoutPrStatus", serverId, cwd])?.isInvalidated).toBe(
true,
);
expect(queryClient.getQueryState(["prPaneTimeline", serverId, cwd, 12])?.isInvalidated).toBe(
true,
);
expect(queryClient.getQueryState(["prPaneTimeline", serverId, cwd, 13])?.isInvalidated).toBe(
true,
);
expect(
queryClient.getQueryState(["prPaneTimeline", serverId, "/tmp/other", 12])?.isInvalidated,
).toBe(false);
queryClient.clear();
});
it("hides an archived worktree optimistically while the archive RPC is in flight", async () => {
const deferred = createDeferred<Record<string, never>>();
const client = {

View File

@@ -1,4 +1,4 @@
import type { QueryClient, QueryKey } from "@tanstack/react-query";
import type { QueryKey } from "@tanstack/react-query";
import type { CheckoutPrMergeMethod } from "@server/shared/messages";
import { create } from "zustand";
import { queryClient as appQueryClient } from "@/query/query-client";
@@ -17,6 +17,7 @@ import {
resolveWorkspaceIdByExecutionDirectory,
resolveWorkspaceMapKeyByIdentity,
} from "@/utils/workspace-execution";
import { invalidateCheckoutGitQueriesForClient } from "@/git/query-keys";
const SUCCESS_DISPLAY_MS = 1000;
@@ -74,44 +75,6 @@ function setStatus(
});
}
export async function invalidateCheckoutGitQueriesForClient(
queryClient: QueryClient,
{ serverId, cwd }: { serverId: string; cwd: string },
) {
await Promise.all([
queryClient.invalidateQueries({
queryKey: ["checkoutStatus", serverId, cwd],
}),
queryClient.invalidateQueries({
predicate: (query) => {
const key = query.queryKey;
return (
Array.isArray(key) && key[0] === "checkoutDiff" && key[1] === serverId && key[2] === cwd
);
},
}),
queryClient.invalidateQueries({
predicate: (query) => {
const key = query.queryKey;
return (
Array.isArray(key) &&
key[0] === "checkoutPrStatus" &&
key[1] === serverId &&
key[2] === cwd
);
},
}),
queryClient.invalidateQueries({
predicate: (query) => {
const key = query.queryKey;
return (
Array.isArray(key) && key[0] === "prPaneTimeline" && key[1] === serverId && key[2] === cwd
);
},
}),
]);
}
function invalidateCheckoutGitQueries(serverId: string, cwd: string) {
return invalidateCheckoutGitQueriesForClient(appQueryClient, { serverId, cwd });
}

View File

@@ -5,8 +5,10 @@ describe("parseGitHubRepoFromRemote", () => {
it.each([
["https://github.com/acme/repo.git", "acme/repo"],
["https://github.com/acme/repo", "acme/repo"],
["http://github.com/acme/repo.git", "acme/repo"],
["git@github.com:acme/repo.git", "acme/repo"],
["ssh://git@github.com/acme/repo.git", "acme/repo"],
["ssh://git@ssh.github.com/acme/repo.git", "acme/repo"],
["https://github.com/acme/repo/", "acme/repo"],
])("extracts the repo from %s", (remoteUrl, expected) => {
expect(parseGitHubRepoFromRemote(remoteUrl)).toBe(expected);

View File

@@ -1,42 +1,11 @@
// TODO: this duplicates parseGitHubRepoFromRemote in packages/server/src/services/github-service.ts.
// Consolidate into a shared package once we have a third caller.
import { parseGitHubRemoteUrl } from "@server/shared/git-remote";
// Note: SSH host aliases (e.g. `git@github-work:acme/repo.git` resolved via ~/.ssh/config)
// are not detected here, so the GitHub action will silently not appear for those remotes.
export function parseGitHubRepoFromRemote(remoteUrl: string | null | undefined): string | null {
const trimmed = remoteUrl?.trim();
if (!trimmed) {
return null;
}
let cleaned = trimmed;
if (cleaned.startsWith("git@github.com:")) {
cleaned = cleaned.slice("git@github.com:".length);
} else {
let parsed: URL;
try {
parsed = new URL(cleaned);
} catch {
return null;
}
if (parsed.hostname !== "github.com") {
return null;
}
try {
cleaned = decodeURIComponent(parsed.pathname.replace(/^\/+/, ""));
} catch {
return null;
}
}
cleaned = cleaned.replace(/\/+$/, "");
if (cleaned.endsWith(".git")) {
cleaned = cleaned.slice(0, -".git".length);
}
if (!cleaned.includes("/")) {
return null;
}
return cleaned;
return parseGitHubRemoteUrl(trimmed)?.repo ?? null;
}
export function buildGitHubBranchTreeUrl(input: {

View File

@@ -0,0 +1,62 @@
import { QueryClient } from "@tanstack/react-query";
import { describe, expect, it } from "vitest";
import {
checkoutDiffQueryKey,
checkoutPrStatusQueryKey,
checkoutStatusQueryKey,
invalidateCheckoutGitQueriesForClient,
prPaneTimelineQueryKey,
} from "@/git/query-keys";
describe("checkout query keys", () => {
const serverId = "server-1";
const cwd = "/tmp/repo";
it("invalidates every query for a checkout without touching other checkouts", async () => {
const queryClient = new QueryClient();
queryClient.setQueryData(checkoutStatusQueryKey(serverId, cwd), { isGit: true });
queryClient.setQueryData(checkoutDiffQueryKey(serverId, cwd, "base", "main", true), {
files: [],
});
queryClient.setQueryData(checkoutPrStatusQueryKey(serverId, cwd), { status: { number: 12 } });
queryClient.setQueryData(prPaneTimelineQueryKey({ serverId, cwd, prNumber: 12 }), {
items: [],
});
queryClient.setQueryData(prPaneTimelineQueryKey({ serverId, cwd, prNumber: 13 }), {
items: [],
});
queryClient.setQueryData(
prPaneTimelineQueryKey({ serverId, cwd: "/tmp/other", prNumber: 12 }),
{ items: [] },
);
await invalidateCheckoutGitQueriesForClient(queryClient, { serverId, cwd });
expect(queryClient.getQueryState(checkoutStatusQueryKey(serverId, cwd))?.isInvalidated).toBe(
true,
);
expect(
queryClient.getQueryState(checkoutDiffQueryKey(serverId, cwd, "base", "main", true))
?.isInvalidated,
).toBe(true);
expect(queryClient.getQueryState(checkoutPrStatusQueryKey(serverId, cwd))?.isInvalidated).toBe(
true,
);
expect(
queryClient.getQueryState(prPaneTimelineQueryKey({ serverId, cwd, prNumber: 12 }))
?.isInvalidated,
).toBe(true);
expect(
queryClient.getQueryState(prPaneTimelineQueryKey({ serverId, cwd, prNumber: 13 }))
?.isInvalidated,
).toBe(true);
expect(
queryClient.getQueryState(
prPaneTimelineQueryKey({ serverId, cwd: "/tmp/other", prNumber: 12 }),
)?.isInvalidated,
).toBe(false);
queryClient.clear();
});
});

View File

@@ -0,0 +1,82 @@
import type { Query, QueryClient } from "@tanstack/react-query";
interface CheckoutQueryIdentity {
serverId: string;
cwd: string;
}
type CheckoutQueryKey = readonly unknown[];
export function checkoutStatusQueryKey(serverId: string, cwd: string) {
return ["checkoutStatus", serverId, cwd] as const;
}
export function checkoutDiffQueryKey(
serverId: string,
cwd: string,
mode: "uncommitted" | "base",
baseRef?: string,
ignoreWhitespace?: boolean,
) {
return ["checkoutDiff", serverId, cwd, mode, baseRef ?? "", ignoreWhitespace === true] as const;
}
export function checkoutPrStatusQueryKey(serverId: string, cwd: string) {
return ["checkoutPrStatus", serverId, cwd] as const;
}
export function prPaneTimelineQueryKey({
serverId,
cwd,
prNumber,
}: {
serverId: string;
cwd: string;
prNumber: number | null;
}) {
return ["prPaneTimeline", serverId, cwd, prNumber] as const;
}
export async function invalidateCheckoutGitQueriesForClient(
queryClient: QueryClient,
identity: CheckoutQueryIdentity,
) {
await Promise.all([
queryClient.invalidateQueries({
queryKey: checkoutStatusQueryKey(identity.serverId, identity.cwd),
}),
queryClient.invalidateQueries({
predicate: checkoutQueryPredicate("checkoutDiff", identity),
}),
queryClient.invalidateQueries({
predicate: checkoutQueryPredicate("checkoutPrStatus", identity),
}),
queryClient.invalidateQueries({
predicate: checkoutQueryPredicate("prPaneTimeline", identity),
}),
]);
}
function checkoutQueryPredicate(
queryKind: CheckoutQueryKey[0],
identity: CheckoutQueryIdentity,
): (query: Query) => boolean {
return (query) => {
const key = query.queryKey;
return (
isCheckoutQueryKey(key) &&
key[0] === queryKind &&
key[1] === identity.serverId &&
key[2] === identity.cwd
);
};
}
function isCheckoutQueryKey(key: readonly unknown[]): key is CheckoutQueryKey {
return (
key.length >= 3 &&
typeof key[0] === "string" &&
typeof key[1] === "string" &&
typeof key[2] === "string"
);
}

View File

@@ -1,14 +1,16 @@
import { useState, useCallback, useEffect, useMemo, type ReactElement } from "react";
import { router, type Href } from "expo-router";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { type CheckoutGitActionStatus, useCheckoutGitActionsStore } from "@/git/actions-store";
import { type CheckoutStatusPayload, useCheckoutStatusQuery } from "@/git/use-status-query";
import { type CheckoutPrStatusPayload, useCheckoutPrStatusQuery } from "@/git/use-pr-status-query";
import { buildGitActions, narrowPullRequestState, type GitActions } from "@/git/policy";
import type { CheckoutPrMergeMethod } from "@server/shared/messages";
import { resolveNewAgentWorkingDir } from "@/utils/new-agent-routing";
import { openExternalUrl } from "@/utils/open-external-url";
import { useToast } from "@/contexts/toast-context";
import { navigateToWorkspace } from "@/hooks/use-workspace-navigation";
import { useSessionStore } from "@/stores/session-store";
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
import { buildWorkspaceArchiveRedirectRoute } from "@/utils/workspace-archive-navigation";
export type { GitActionId, GitAction, GitActions } from "@/git/policy";
@@ -393,15 +395,22 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
toast.error("Worktree path unavailable");
return;
}
const targetWorkingDir = resolveNewAgentWorkingDir(cwd, status ?? null);
void runArchiveWorktree({ serverId, cwd, worktreePath })
.then(() => {
navigateToWorkspace(serverId, targetWorkingDir);
return;
})
.catch((err) => {
toastActionError(err, "Failed to archive worktree");
});
const workspaces = useSessionStore.getState().sessions[serverId]?.workspaces;
const archivedWorkspaceId =
resolveWorkspaceIdByExecutionDirectory({
workspaces: workspaces?.values(),
workspaceDirectory: worktreePath,
}) ?? worktreePath;
router.replace(
buildWorkspaceArchiveRedirectRoute({
serverId,
archivedWorkspaceId,
workspaces: workspaces?.values() ?? [],
}) as Href,
);
void runArchiveWorktree({ serverId, cwd, worktreePath }).catch((err) => {
toastActionError(err, "Failed to archive worktree");
});
}, [cwd, runArchiveWorktree, serverId, status, toast, toastActionError]);
const baseRefLabel = useMemo(() => formatBaseRefLabel(baseRef), [baseRef]);

View File

@@ -3,16 +3,7 @@ import { useEffect, useId, useMemo } from "react";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import type { SubscribeCheckoutDiffResponse } from "@server/shared/messages";
import { orderCheckoutDiffFiles } from "@/git/diff-order";
function checkoutDiffQueryKey(
serverId: string,
cwd: string,
mode: "uncommitted" | "base",
baseRef?: string,
ignoreWhitespace?: boolean,
) {
return ["checkoutDiff", serverId, cwd, mode, baseRef ?? "", ignoreWhitespace === true] as const;
}
import { checkoutDiffQueryKey } from "@/git/query-keys";
interface UseCheckoutDiffQueryOptions {
serverId: string;

View File

@@ -2,10 +2,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect } from "react";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import type { CheckoutPrStatusResponse } from "@server/shared/messages";
export function checkoutPrStatusQueryKey(serverId: string, cwd: string) {
return ["checkoutPrStatus", serverId, cwd] as const;
}
import { checkoutPrStatusQueryKey } from "@/git/query-keys";
interface UseCheckoutPrStatusQueryOptions {
serverId: string;

View File

@@ -9,7 +9,8 @@ import {
} from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { CheckoutStatusResponse } from "@server/shared/messages";
import { checkoutStatusQueryKey, useCheckoutStatusQuery } from "./use-status-query";
import { checkoutStatusQueryKey } from "@/git/query-keys";
import { useCheckoutStatusQuery } from "./use-status-query";
type CheckoutStatusPayload = CheckoutStatusResponse["payload"];

View File

@@ -2,13 +2,10 @@ import { type QueryClient, useQuery, useQueryClient } from "@tanstack/react-quer
import { useEffect } from "react";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import type { CheckoutStatusResponse } from "@server/shared/messages";
import { checkoutStatusQueryKey } from "@/git/query-keys";
export const CHECKOUT_STATUS_STALE_TIME = 15_000;
export function checkoutStatusQueryKey(serverId: string, cwd: string) {
return ["checkoutStatus", serverId, cwd] as const;
}
interface UseCheckoutStatusQueryOptions {
serverId: string;
cwd: string;

View File

@@ -3,7 +3,7 @@ import { useQuery, type QueryClient } from "@tanstack/react-query";
import type { DaemonClient } from "@server/client/daemon-client";
import type { ComboboxOption } from "@/components/ui/combobox";
import type { ToastApi } from "@/components/toast-host";
import { invalidateCheckoutGitQueriesForClient } from "@/git/actions-store";
import { invalidateCheckoutGitQueriesForClient } from "@/git/query-keys";
import { confirmDialog } from "@/utils/confirm-dialog";
interface UseBranchSwitcherInput {

View File

@@ -2,7 +2,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { TextInput } from "react-native";
import { router, usePathname, type Href } from "expo-router";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { useSessionStore } from "@/stores/session-store";
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
import { useAllAgentsList } from "@/hooks/use-all-agents-list";
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
@@ -11,15 +10,14 @@ import {
clearCommandCenterFocusRestoreElement,
takeCommandCenterFocusRestoreElement,
} from "@/utils/command-center-focus-restore";
import { buildHostAgentDetailRoute, buildSettingsRoute } from "@/utils/host-routes";
import { buildSettingsRoute } from "@/utils/host-routes";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { chordStringToShortcutKeys } from "@/keyboard/shortcut-string";
import { getBindingIdForAction, getDefaultKeysForAction } from "@/keyboard/keyboard-shortcuts";
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
import { getShortcutOs } from "@/utils/shortcut-platform";
import { getIsElectronRuntime } from "@/constants/layout";
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
import { navigateToAgent } from "@/utils/navigate-to-agent";
import { focusWithRetries } from "@/utils/web-focus";
import { useActiveServerId } from "@/hooks/use-active-server-id";
@@ -204,18 +202,9 @@ export function useCommandCenter() {
// Don't restore focus back to the prior element after we navigate.
clearCommandCenterFocusRestoreElement();
setOpen(false);
const workspaceId = resolveWorkspaceIdByExecutionDirectory({
workspaces: useSessionStore.getState().sessions[agent.serverId]?.workspaces?.values(),
workspaceDirectory: agent.cwd,
});
if (!workspaceId) {
router.navigate(buildHostAgentDetailRoute(agent.serverId, agent.id) as Href);
return;
}
navigateToPreparedWorkspaceTab({
navigateToAgent({
serverId: agent.serverId,
workspaceId,
target: { kind: "agent", agentId: agent.id },
agentId: agent.id,
currentPathname: pathname,
});
},

View File

@@ -12,11 +12,8 @@ import type {
CheckoutPrStatusResponse,
PullRequestTimelineResponse,
} from "@server/shared/messages";
import {
prPaneTimelineQueryKey,
usePrPaneData,
type UsePrPaneDataResult,
} from "./use-pr-pane-data";
import { checkoutPrStatusQueryKey, prPaneTimelineQueryKey } from "@/git/query-keys";
import { usePrPaneData, type UsePrPaneDataResult } from "./use-pr-pane-data";
import { useWorkspacePrHint } from "@/git/use-pr-status-query";
type CheckoutPrStatus = NonNullable<CheckoutPrStatusResponse["payload"]["status"]>;
@@ -335,7 +332,7 @@ describe("usePrPaneData", () => {
).toBeUndefined();
mockClient.checkoutPrStatus.mockResolvedValue(statusPayload());
hook.queryClient.invalidateQueries({ queryKey: ["checkoutPrStatus", serverId, cwd] });
hook.queryClient.invalidateQueries({ queryKey: checkoutPrStatusQueryKey(serverId, cwd) });
await waitForExpectation(() => {
expect(mockClient.pullRequestTimeline).toHaveBeenCalledWith({
@@ -434,7 +431,7 @@ describe("usePrPaneData", () => {
await waitForExpectation(() => {
expect(hook.latest.data?.checks[0]?.status).toBe("success");
});
expect(queryClient.getQueryData(["checkoutPrStatus", serverId, cwd])).toEqual(
expect(queryClient.getQueryData(checkoutPrStatusQueryKey(serverId, cwd))).toEqual(
statusPayload({
requestId: "server-push",
status: status({
@@ -474,8 +471,10 @@ describe("usePrPaneData", () => {
}),
);
expect(queryClient.getQueryData(["checkoutPrStatus", serverId, cwd])).toEqual(initial);
expect(queryClient.getQueryData(["checkoutPrStatus", serverId, "/other-repo"])).toBeUndefined();
expect(queryClient.getQueryData(checkoutPrStatusQueryKey(serverId, cwd))).toEqual(initial);
expect(
queryClient.getQueryData(checkoutPrStatusQueryKey(serverId, "/other-repo")),
).toBeUndefined();
});
it("passes repoOwner and repoName to the timeline request when present", async () => {
@@ -719,7 +718,7 @@ describe("usePrPaneData", () => {
const refreshDeferred = createDeferred<CheckoutPrStatusPayload>();
mockClient.checkoutPrStatus.mockReturnValue(refreshDeferred.promise);
hook.queryClient.invalidateQueries({ queryKey: ["checkoutPrStatus", serverId, cwd] });
hook.queryClient.invalidateQueries({ queryKey: checkoutPrStatusQueryKey(serverId, cwd) });
await waitForExpectation(() => {
expect(hook.latest.isLoading).toBe(false);

View File

@@ -6,6 +6,7 @@ import type {
} from "@server/shared/messages";
import { mapPrPaneData, type PrPaneData } from "@/git/pr-pane-data";
import { useCheckoutPrStatusQuery } from "@/git/use-pr-status-query";
import { prPaneTimelineQueryKey } from "@/git/query-keys";
type CheckoutPrStatusPayloadError = CheckoutPrStatusResponse["payload"]["error"];
type PullRequestTimeline = PullRequestTimelineResponse["payload"];
@@ -152,18 +153,6 @@ export function usePrPaneData({
};
}
export function prPaneTimelineQueryKey({
serverId,
cwd,
prNumber,
}: {
serverId: string;
cwd: string;
prNumber: number | null;
}) {
return ["prPaneTimeline", serverId, cwd, prNumber] as const;
}
function firstNonSuppressedError({
statusPayloadError,
statusError,

View File

@@ -58,6 +58,7 @@ import type { StreamItem } from "@/types/stream";
import { getInitDeferred, getInitKey } from "@/utils/agent-initialization";
import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import { mergePendingCreateImages } from "@/utils/pending-create-images";
import { navigateToAgent } from "@/utils/navigate-to-agent";
import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state";
interface ChatAgentStateShape {
@@ -1258,14 +1259,14 @@ function ActiveAgentComposer({
const paneContext = usePaneContext();
const { workspaceId } = paneContext;
const subagentRows = useSubagentsForParent({
serverId: paneContext.serverId,
serverId,
parentAgentId: agentId,
});
const handleOpenSubagent = useCallback(
(subagentId: string) => {
paneContext.openTab({ kind: "agent", agentId: subagentId });
navigateToAgent({ serverId, agentId: subagentId });
},
[paneContext],
[serverId],
);
const handleArchiveSubagent = useArchiveSubagent({ serverId });
const agentInputDraft = useAgentInputDraft({

View File

@@ -9,6 +9,7 @@ import { useQuery } from "@tanstack/react-query";
import { ChevronDown, GitBranch, GitPullRequest } from "lucide-react-native";
import { Composer } from "@/components/composer";
import { splitComposerAttachmentsForSubmit } from "@/components/composer-attachments";
import { FileDropZone } from "@/components/file-drop-zone";
import { Combobox, ComboboxItem } from "@/components/ui/combobox";
import type { ComboboxOption as ComboboxOptionType } from "@/components/ui/combobox";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
@@ -639,6 +640,9 @@ export function NewWorkspaceScreen({
const handleAddImagesCallback = useCallback((addImages: (images: ImageAttachment[]) => void) => {
addImagesRef.current = addImages;
}, []);
const handleFilesDropped = useCallback((files: ImageAttachment[]) => {
addImagesRef.current?.(files);
}, []);
const renderPickerOption = useCallback(
({
@@ -713,82 +717,84 @@ export function NewWorkspaceScreen({
: "No matching refs.";
return (
<View style={styles.container}>
<ScreenHeader
left={
<>
<SidebarMenuToggle />
<View style={styles.headerTitleContainer}>
<Text style={styles.headerTitle} numberOfLines={1}>
New workspace
</Text>
<Text style={styles.headerProjectTitle} numberOfLines={1}>
{workspaceTitle}
</Text>
</View>
</>
}
leftStyle={styles.headerLeft}
borderless
/>
<View style={contentStyle}>
<TitlebarDragRegion />
<View style={styles.centered}>
<Composer
agentId={`new-workspace:${serverId}:${sourceDirectory}`}
serverId={serverId}
isPaneFocused={true}
onSubmitMessage={handleSubmitNewWorkspace}
allowEmptySubmit={true}
submitButtonAccessibilityLabel="Create"
submitIcon="return"
isSubmitLoading={pendingAction !== null}
submitBehavior="preserve-and-lock"
blurOnSubmit={true}
value={chatDraft.text}
onChangeText={chatDraft.setText}
attachments={chatDraft.attachments}
onChangeAttachments={chatDraft.setAttachments}
cwd={sourceDirectory}
clearDraft={handleClearDraft}
autoFocus
commandDraftConfig={composerState?.commandDraftConfig}
statusControls={statusControlsWithDisabled}
onAddImages={handleAddImagesCallback}
/>
<Animated.View testID="new-workspace-ref-picker-row" style={optionsRowStyle}>
<View>
<RefPickerTrigger
pickerAnchorRef={pickerAnchorRef}
onPress={openPicker}
disabled={isPending}
badgePressableStyle={badgePressableStyle}
selectedItem={selectedItem}
triggerLabel={triggerLabel}
iconColor={theme.colors.foregroundMuted}
iconSize={theme.iconSize.sm}
/>
<Combobox
options={options}
value={selectedOptionId}
onSelect={handleSelectOption}
searchable
searchPlaceholder="Search branches and PRs"
title="Start from"
open={pickerOpen}
onOpenChange={handlePickerOpenChange}
onSearchQueryChange={setPickerSearchQuery}
desktopPlacement="bottom-start"
anchorRef={pickerAnchorRef}
emptyText={pickerEmptyText}
renderOption={renderPickerOption}
/>
</View>
</Animated.View>
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
<FileDropZone onFilesDropped={handleFilesDropped}>
<View style={styles.container}>
<ScreenHeader
left={
<>
<SidebarMenuToggle />
<View style={styles.headerTitleContainer}>
<Text style={styles.headerTitle} numberOfLines={1}>
New workspace
</Text>
<Text style={styles.headerProjectTitle} numberOfLines={1}>
{workspaceTitle}
</Text>
</View>
</>
}
leftStyle={styles.headerLeft}
borderless
/>
<View style={contentStyle}>
<TitlebarDragRegion />
<View style={styles.centered}>
<Composer
agentId={`new-workspace:${serverId}:${sourceDirectory}`}
serverId={serverId}
isPaneFocused={true}
onSubmitMessage={handleSubmitNewWorkspace}
allowEmptySubmit={true}
submitButtonAccessibilityLabel="Create"
submitIcon="return"
isSubmitLoading={pendingAction !== null}
submitBehavior="preserve-and-lock"
blurOnSubmit={true}
value={chatDraft.text}
onChangeText={chatDraft.setText}
attachments={chatDraft.attachments}
onChangeAttachments={chatDraft.setAttachments}
cwd={sourceDirectory}
clearDraft={handleClearDraft}
autoFocus
commandDraftConfig={composerState?.commandDraftConfig}
statusControls={statusControlsWithDisabled}
onAddImages={handleAddImagesCallback}
/>
<Animated.View testID="new-workspace-ref-picker-row" style={optionsRowStyle}>
<View>
<RefPickerTrigger
pickerAnchorRef={pickerAnchorRef}
onPress={openPicker}
disabled={isPending}
badgePressableStyle={badgePressableStyle}
selectedItem={selectedItem}
triggerLabel={triggerLabel}
iconColor={theme.colors.foregroundMuted}
iconSize={theme.iconSize.sm}
/>
<Combobox
options={options}
value={selectedOptionId}
onSelect={handleSelectOption}
searchable
searchPlaceholder="Search branches and PRs"
title="Start from"
open={pickerOpen}
onOpenChange={handlePickerOpenChange}
onSearchQueryChange={setPickerSearchQuery}
desktopPlacement="bottom-start"
anchorRef={pickerAnchorRef}
emptyText={pickerEmptyText}
renderOption={renderPickerOption}
/>
</View>
</Animated.View>
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
</View>
</View>
</View>
</View>
</FileDropZone>
);
}

View File

@@ -92,7 +92,8 @@ import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { shouldShowWorkspaceSetup, useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
import { useWorkspace } from "@/stores/session-store-hooks";
import { useWorkspaceTerminalSessionRetention } from "@/terminal/hooks/use-workspace-terminal-session-retention";
import { checkoutStatusQueryKey, type CheckoutStatusPayload } from "@/git/use-status-query";
import type { CheckoutStatusPayload } from "@/git/use-status-query";
import { checkoutStatusQueryKey } from "@/git/query-keys";
import type { ListTerminalsResponse } from "@server/shared/messages";
import { upsertTerminalListEntry } from "@/utils/terminal-list";
import { confirmDialog } from "@/utils/confirm-dialog";
@@ -132,9 +133,10 @@ import {
} from "@/screens/workspace/workspace-route-state";
import { renderWorkspaceRouteGate } from "@/screens/workspace/workspace-route-state-views";
import {
buildWorkspaceTabSnapshot,
deriveWorkspaceAgentVisibility,
workspaceAgentVisibilityEqual,
} from "@/screens/workspace/workspace-agent-visibility";
} from "@/workspace-tabs/agent-visibility";
import { deriveWorkspacePaneState } from "@/screens/workspace/workspace-pane-state";
import {
buildWorkspacePaneContentModel,
@@ -1918,16 +1920,17 @@ function WorkspaceScreenContent({
return pending?.serverId === normalizedServerId && pending.lifecycle === "active";
});
reconcileWorkspaceTabs(persistenceKey, {
agentsHydrated: hasHydratedAgents,
terminalsHydrated: terminalsQuery.isSuccess,
activeAgentIds: Array.from(workspaceAgentVisibility.activeAgentIds),
autoOpenAgentIds: Array.from(workspaceAgentVisibility.autoOpenAgentIds),
knownAgentIds: Array.from(workspaceAgentVisibility.knownAgentIds),
knownTerminalIds,
standaloneTerminalIds,
hasActivePendingDraftCreate: hasActivePendingDraftCreateInWorkspace,
});
reconcileWorkspaceTabs(
persistenceKey,
buildWorkspaceTabSnapshot({
agentVisibility: workspaceAgentVisibility,
agentsHydrated: hasHydratedAgents,
terminalsHydrated: terminalsQuery.isSuccess,
knownTerminalIds,
standaloneTerminalIds,
hasActivePendingDraftCreate: hasActivePendingDraftCreateInWorkspace,
}),
);
}, [
hasHydratedAgents,
hasHydratedWorkspaceLayoutStore,

View File

@@ -1,9 +1,10 @@
import type { DaemonClient } from "@server/client/daemon-client";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
buildWorkspaceTabSnapshot,
deriveWorkspaceAgentVisibility,
type WorkspaceAgentVisibility,
} from "@/screens/workspace/workspace-agent-visibility";
} from "@/workspace-tabs/agent-visibility";
import { selectSubagentsForParent } from "@/subagents";
import { buildWorkspaceTabPersistenceKey, useWorkspaceLayoutStore } from "./workspace-layout-store";
import { useSessionStore, type Agent } from "./session-store";
@@ -151,15 +152,17 @@ function deriveVisibilityFromSession(): WorkspaceAgentVisibility {
}
function reconcileWorkspaceTabs(workspaceKey: string, visibility: WorkspaceAgentVisibility): void {
useWorkspaceLayoutStore.getState().reconcileTabs(workspaceKey, {
agentsHydrated: true,
terminalsHydrated: true,
activeAgentIds: visibility.activeAgentIds,
autoOpenAgentIds: visibility.autoOpenAgentIds,
knownAgentIds: visibility.knownAgentIds,
standaloneTerminalIds: [],
hasActivePendingDraftCreate: false,
});
useWorkspaceLayoutStore.getState().reconcileTabs(
workspaceKey,
buildWorkspaceTabSnapshot({
agentVisibility: visibility,
agentsHydrated: true,
terminalsHydrated: true,
knownTerminalIds: [],
standaloneTerminalIds: [],
hasActivePendingDraftCreate: false,
}),
);
}
function getWorkspaceTabIds(workspaceKey: string): string[] {

View File

@@ -1,5 +1,5 @@
// Pure-data entry point for callers that don't want React Native deps in
// their dependency graph (e.g. workspace-agent-visibility.ts is plain JS
// their dependency graph (e.g. workspace-tabs/agent-visibility.ts is plain
// data derivation and its tests run without an RN environment).
//
// The full module entry `@/subagents` re-exports these too, alongside the

View File

@@ -0,0 +1,94 @@
import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types";
import { describe, expect, it } from "vitest";
import { buildToolCallPresentation, type ToolCallPresentationIcon } from "./presentation";
const fakeIcons = {
brain: (() => null) as ToolCallPresentationIcon,
eye: (() => null) as ToolCallPresentationIcon,
wrench: (() => null) as ToolCallPresentationIcon,
};
function fakeResolveIcon(
toolName: string,
detail: ToolCallDetail | undefined,
): ToolCallPresentationIcon {
if (detail?.type === "plan") {
return fakeIcons.brain;
}
if (detail?.type === "read") {
return fakeIcons.eye;
}
if (toolName === "exec_command") {
return fakeIcons.wrench;
}
return fakeIcons.wrench;
}
describe("tool-call presentation", () => {
it("builds badge, detail, icon, and file-open policy in one model", () => {
const presentation = buildToolCallPresentation({
toolName: "read_file",
status: "completed",
error: null,
cwd: "/tmp/repo",
detail: {
type: "read",
filePath: "/tmp/repo/src/index.ts",
content: "console.log('hi');",
},
resolveIcon: fakeResolveIcon,
});
expect(presentation).toMatchObject({
displayName: "Read",
summary: "src/index.ts",
icon: fakeIcons.eye,
isLoadingDetails: false,
hasDetails: true,
canOpenDetails: true,
openFilePath: "/tmp/repo/src/index.ts",
isPlan: false,
});
});
it("marks running calls without meaningful detail as loading details", () => {
const presentation = buildToolCallPresentation({
toolName: "exec_command",
status: "running",
error: null,
detail: {
type: "unknown",
input: {},
output: null,
},
resolveIcon: fakeResolveIcon,
});
expect(presentation).toMatchObject({
displayName: "Exec Command",
icon: fakeIcons.wrench,
isLoadingDetails: true,
hasDetails: false,
canOpenDetails: true,
openFilePath: null,
isPlan: false,
});
});
it("keeps plan calls out of the expandable badge path", () => {
const presentation = buildToolCallPresentation({
toolName: "ExitPlanMode",
status: "completed",
error: null,
detail: {
type: "plan",
text: "1. Do the thing",
},
resolveIcon: fakeResolveIcon,
});
expect(presentation.isPlan).toBe(true);
expect(presentation.icon).toBe(fakeIcons.brain);
});
});

View File

@@ -0,0 +1,79 @@
import type { ComponentType } from "react";
import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types";
import type { ToolCallDisplayInput } from "@/utils/tool-call-display";
import { buildToolCallDisplayModel } from "@/utils/tool-call-display";
import { extractToolCallFilePath } from "@/utils/extract-tool-call-file-path";
import {
hasMeaningfulToolCallDetail,
isPendingToolCallDetail,
} from "@/utils/tool-call-detail-state";
type ToolCallStatus = "executing" | "running" | "completed" | "failed" | "canceled";
export type ToolCallPresentationIcon = ComponentType<{ size?: number; color?: string }>;
interface BuildToolCallPresentationInput {
toolName: string;
status: ToolCallStatus;
error: unknown;
detail?: ToolCallDetail;
cwd?: string;
metadata?: Record<string, unknown>;
resolveIcon: ToolCallIconResolver;
}
export interface ToolCallPresentation {
displayName: string;
summary?: string;
errorText?: string;
icon: ToolCallPresentationIcon;
isLoadingDetails: boolean;
hasDetails: boolean;
canOpenDetails: boolean;
openFilePath: string | null;
isPlan: boolean;
}
export type ToolCallIconResolver = (
toolName: string,
detail: ToolCallDetail | undefined,
) => ToolCallPresentationIcon;
function displayStatus(status: ToolCallStatus): ToolCallDisplayInput["status"] {
return status === "executing" ? "running" : status;
}
function displayDetail(detail: ToolCallDetail | undefined): ToolCallDetail {
return detail ?? { type: "unknown", input: null, output: null };
}
export function buildToolCallPresentation(
input: BuildToolCallPresentationInput,
): ToolCallPresentation {
const detailForDisplay = displayDetail(input.detail);
const displayModel = buildToolCallDisplayModel({
name: input.toolName,
status: displayStatus(input.status),
error: input.error ?? null,
detail: detailForDisplay,
metadata: input.metadata,
cwd: input.cwd,
});
const isLoadingDetails = isPendingToolCallDetail({
detail: input.detail,
status: input.status,
error: input.error,
});
const hasDetails = Boolean(input.error) || hasMeaningfulToolCallDetail(input.detail);
return {
displayName: displayModel.displayName,
summary: displayModel.summary,
errorText: displayModel.errorText,
icon: input.resolveIcon(input.toolName, input.detail),
isLoadingDetails,
hasDetails,
canOpenDetails: hasDetails || isLoadingDetails,
openFilePath: extractToolCallFilePath(input.detail),
isPlan: input.detail?.type === "plan",
};
}

View File

@@ -0,0 +1,133 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { routerMock } = vi.hoisted(() => ({
routerMock: {
dismissTo: vi.fn(),
navigate: vi.fn(),
replace: vi.fn(),
},
}));
vi.mock("expo-router", () => ({
router: routerMock,
useLocalSearchParams: () => ({}),
usePathname: () => "/",
}));
vi.mock("@react-native-async-storage/async-storage", () => {
const storage = new Map<string, string>();
return {
default: {
getItem: vi.fn(async (key: string) => storage.get(key) ?? null),
setItem: vi.fn(async (key: string, value: string) => {
storage.set(key, value);
}),
removeItem: vi.fn(async (key: string) => {
storage.delete(key);
}),
},
};
});
import type { DaemonClient } from "@server/client/daemon-client";
import { useSessionStore, type Agent, type WorkspaceDescriptor } from "@/stores/session-store";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import { navigateToAgent } from "@/utils/navigate-to-agent";
const SERVER_ID = "server-1";
const WORKSPACE_ID = "workspace-1";
const AGENT_ID = "agent-1";
function createWorkspace(): WorkspaceDescriptor {
return {
id: WORKSPACE_ID,
projectId: "project-1",
projectDisplayName: "Project",
projectRootPath: "/repo",
workspaceDirectory: "/repo/worktree",
projectKind: "git",
workspaceKind: "local_checkout",
name: "worktree",
status: "done",
archivingAt: null,
diffStat: null,
scripts: [],
};
}
function createAgent(input: Partial<Agent> = {}): Agent {
return {
serverId: SERVER_ID,
id: AGENT_ID,
provider: "codex",
status: "closed",
createdAt: new Date("2026-05-09T00:00:00.000Z"),
updatedAt: new Date("2026-05-09T00:00:00.000Z"),
lastUserMessageAt: null,
lastActivityAt: new Date("2026-05-09T00:00:00.000Z"),
capabilities: {
supportsStreaming: false,
supportsSessionPersistence: false,
supportsDynamicModes: false,
supportsMcpServers: false,
supportsReasoningStream: false,
supportsToolInvocations: false,
},
currentModeId: null,
availableModes: [],
pendingPermissions: [],
persistence: null,
title: "Archived agent",
cwd: "/repo/worktree",
model: null,
thinkingOptionId: null,
archivedAt: new Date("2026-05-09T00:00:00.000Z"),
parentAgentId: null,
labels: {},
...input,
};
}
describe("navigateToAgent", () => {
beforeEach(() => {
routerMock.dismissTo.mockReset();
routerMock.navigate.mockReset();
routerMock.replace.mockReset();
useSessionStore.getState().clearSession(SERVER_ID);
useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient);
useSessionStore
.getState()
.setWorkspaces(SERVER_ID, new Map([[WORKSPACE_ID, createWorkspace()]]));
useWorkspaceLayoutStore.setState({
layoutByWorkspace: {},
splitSizesByWorkspace: {},
pinnedAgentIdsByWorkspace: {},
hiddenAgentIdsByWorkspace: {},
});
});
it("opens archived agent details through the resolved workspace", () => {
useSessionStore.getState().setAgentDetails(SERVER_ID, new Map([[AGENT_ID, createAgent()]]));
const route = navigateToAgent({ serverId: SERVER_ID, agentId: AGENT_ID, pin: true });
expect(route).toBe("/h/server-1/workspace/workspace-1");
expect(routerMock.navigate).not.toHaveBeenCalled();
expect(routerMock.dismissTo).toHaveBeenCalledWith("/h/server-1/workspace/workspace-1");
const key = `${SERVER_ID}:${WORKSPACE_ID}`;
expect(useWorkspaceLayoutStore.getState().getWorkspaceTabs(key)).toEqual([
expect.objectContaining({ target: { kind: "agent", agentId: AGENT_ID } }),
]);
expect(useWorkspaceLayoutStore.getState().pinnedAgentIdsByWorkspace[key]).toEqual(
new Set([AGENT_ID]),
);
});
it("falls back to the host agent route when the workspace is unknown", () => {
const route = navigateToAgent({ serverId: SERVER_ID, agentId: "missing-agent" });
expect(route).toBe("/h/server-1/agent/missing-agent");
expect(routerMock.navigate).toHaveBeenCalledWith("/h/server-1/agent/missing-agent");
expect(routerMock.dismissTo).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,35 @@
import { router, type Href } from "expo-router";
import { useSessionStore } from "@/stores/session-store";
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
interface NavigateToAgentInput {
serverId: string;
agentId: string;
currentPathname?: string | null;
pin?: boolean;
}
export function navigateToAgent(input: NavigateToAgentInput): string {
const session = useSessionStore.getState().sessions[input.serverId];
const agent = session?.agents.get(input.agentId) ?? session?.agentDetails.get(input.agentId);
const workspaceId = resolveWorkspaceIdByExecutionDirectory({
workspaces: session?.workspaces.values(),
workspaceDirectory: agent?.cwd,
});
if (!workspaceId) {
const route = buildHostAgentDetailRoute(input.serverId, input.agentId);
router.navigate(route as Href);
return route;
}
return navigateToPreparedWorkspaceTab({
serverId: input.serverId,
workspaceId,
target: { kind: "agent", agentId: input.agentId },
currentPathname: input.currentPathname,
pin: input.pin,
});
}

View File

@@ -1,41 +1,16 @@
import type { AgentLifecycleStatus } from "@server/shared/agent-lifecycle";
export type SidebarAttentionReason = "finished" | "error" | "permission" | null | undefined;
import {
deriveAgentStateBucket,
type AgentAttentionReason,
type AgentStateBucketInput,
} from "@server/shared/agent-state-bucket";
export type SidebarStateBucket = "needs_input" | "failed" | "running" | "attention" | "done";
export type SidebarAttentionReason = AgentAttentionReason;
export function deriveSidebarStateBucket(input: {
status: AgentLifecycleStatus;
pendingPermissionCount?: number;
requiresAttention?: boolean;
attentionReason?: SidebarAttentionReason;
}): SidebarStateBucket {
if ((input.pendingPermissionCount ?? 0) > 0) {
return "needs_input";
}
// Legacy fallback for snapshots persisted before permission state was decoupled
// from unread attention.
if (input.attentionReason === "permission") {
return "needs_input";
}
if (input.status === "error" || input.attentionReason === "error") {
return "failed";
}
if (input.status === "running") {
return "running";
}
if (input.requiresAttention) {
// Unread/attention-needed completed agents are active in sidebar logic.
return "attention";
}
return "done";
export function deriveSidebarStateBucket(input: AgentStateBucketInput): SidebarStateBucket {
return deriveAgentStateBucket(input);
}
export function isSidebarActiveAgent(input: {
status: AgentLifecycleStatus;
pendingPermissionCount?: number;
requiresAttention?: boolean;
attentionReason?: SidebarAttentionReason;
}): boolean {
export function isSidebarActiveAgent(input: AgentStateBucketInput): boolean {
return deriveSidebarStateBucket(input) !== "done";
}

View File

@@ -1,10 +1,11 @@
import { describe, expect, it } from "vitest";
import type { Agent } from "@/stores/session-store";
import {
buildWorkspaceTabSnapshot,
deriveWorkspaceAgentVisibility,
shouldPruneWorkspaceAgentTab,
workspaceAgentVisibilityEqual,
} from "@/screens/workspace/workspace-agent-visibility";
} from "@/workspace-tabs/agent-visibility";
function makeAgent(input: {
id: string;
@@ -265,6 +266,34 @@ describe("workspace agent visibility", () => {
expect(result.knownAgentIds).toEqual(new Set(["recent-agent"]));
});
it("builds the tab reconciliation snapshot without callers unpacking agent visibility", () => {
const agentVisibility = {
activeAgentIds: new Set(["active-agent"]),
autoOpenAgentIds: new Set(["root-agent"]),
knownAgentIds: new Set(["active-agent", "archived-agent"]),
};
expect(
buildWorkspaceTabSnapshot({
agentVisibility,
agentsHydrated: true,
terminalsHydrated: true,
knownTerminalIds: ["terminal-1", "script-terminal"],
standaloneTerminalIds: ["terminal-1"],
hasActivePendingDraftCreate: false,
}),
).toEqual({
agentsHydrated: true,
terminalsHydrated: true,
activeAgentIds: agentVisibility.activeAgentIds,
autoOpenAgentIds: agentVisibility.autoOpenAgentIds,
knownAgentIds: agentVisibility.knownAgentIds,
knownTerminalIds: ["terminal-1", "script-terminal"],
standaloneTerminalIds: ["terminal-1"],
hasActivePendingDraftCreate: false,
});
});
describe("workspaceAgentVisibilityEqual", () => {
it("returns true for identical sets", () => {
const a = {

View File

@@ -1,4 +1,5 @@
import type { Agent } from "@/stores/session-store";
import type { WorkspaceTabSnapshot } from "@/stores/workspace-layout-actions";
import { shouldAutoOpenAgentTab } from "@/subagents/policies";
import { normalizeWorkspacePath } from "@/utils/workspace-identity";
@@ -52,6 +53,26 @@ export function deriveWorkspaceAgentVisibility(input: {
return { activeAgentIds, autoOpenAgentIds, knownAgentIds };
}
export function buildWorkspaceTabSnapshot(input: {
agentVisibility: WorkspaceAgentVisibility;
agentsHydrated: boolean;
terminalsHydrated: boolean;
knownTerminalIds: Iterable<string>;
standaloneTerminalIds: Iterable<string>;
hasActivePendingDraftCreate: boolean;
}): WorkspaceTabSnapshot {
return {
agentsHydrated: input.agentsHydrated,
terminalsHydrated: input.terminalsHydrated,
activeAgentIds: input.agentVisibility.activeAgentIds,
autoOpenAgentIds: input.agentVisibility.autoOpenAgentIds,
knownAgentIds: input.agentVisibility.knownAgentIds,
knownTerminalIds: input.knownTerminalIds,
standaloneTerminalIds: input.standaloneTerminalIds,
hasActivePendingDraftCreate: input.hasActivePendingDraftCreate,
};
}
export function workspaceAgentVisibilityEqual(
a: WorkspaceAgentVisibility,
b: WorkspaceAgentVisibility,

View File

@@ -0,0 +1,191 @@
import type { DaemonClient } from "@server/client/daemon-client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
clearWorkspaceArchivePending,
isWorkspaceArchivePending,
} from "@/contexts/session-workspace-upserts";
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
import {
archiveWorkspaceOptimistically,
archiveWorkspacesOptimistically,
type WorkspaceArchiveTarget,
} from "@/workspace/workspace-archive";
const SERVER_ID = "workspace-archive-test";
type ArchiveWorkspacePayload = Awaited<ReturnType<DaemonClient["archiveWorkspace"]>>;
function archivePayload(input: {
workspaceId: string;
error?: string | null;
}): ArchiveWorkspacePayload {
return {
requestId: "request",
workspaceId: input.workspaceId,
archivedAt: null,
error: input.error ?? null,
};
}
function workspace(input?: Partial<WorkspaceDescriptor>): WorkspaceDescriptor {
return {
id: "workspace-1",
projectId: "project-1",
projectDisplayName: "Project",
projectRootPath: "/repo/project",
workspaceDirectory: "/repo/project/workspace-1",
projectKind: "git",
workspaceKind: "worktree",
name: "workspace-1",
status: "done",
archivingAt: null,
diffStat: null,
scripts: [],
...input,
};
}
function target(input?: Partial<WorkspaceArchiveTarget>): WorkspaceArchiveTarget {
const base = workspace();
return {
serverId: SERVER_ID,
workspaceId: base.id,
workspaceDirectory: base.workspaceDirectory,
...input,
};
}
function createClient(
archiveWorkspace: DaemonClient["archiveWorkspace"],
): Pick<DaemonClient, "archiveWorkspace"> {
return { archiveWorkspace };
}
function deferred<T>(): {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (error: unknown) => void;
} {
let resolve: (value: T) => void = () => {};
let reject: (error: unknown) => void = () => {};
const promise = new Promise<T>((promiseResolve, promiseReject) => {
resolve = promiseResolve;
reject = promiseReject;
});
return { promise, resolve, reject };
}
function storedWorkspace(id: string): WorkspaceDescriptor | undefined {
return useSessionStore.getState().sessions[SERVER_ID]?.workspaces.get(id);
}
beforeEach(() => {
useSessionStore.getState().initializeSession(SERVER_ID, {} as DaemonClient);
});
afterEach(() => {
clearWorkspaceArchivePending({ serverId: SERVER_ID, workspaceId: "workspace-1" });
clearWorkspaceArchivePending({ serverId: SERVER_ID, workspaceId: "workspace-2" });
useSessionStore.setState((state) => ({ ...state, sessions: {} }));
});
describe("archiveWorkspaceOptimistically", () => {
it("hides the workspace and marks the archive pending while the daemon call runs", async () => {
const archived = workspace();
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [archived]);
const releaseArchive = deferred<ArchiveWorkspacePayload>();
const client = createClient(vi.fn(async () => releaseArchive.promise));
const archive = archiveWorkspaceOptimistically({
client,
workspace: target(),
});
expect(storedWorkspace(archived.id)).toBeUndefined();
expect(
isWorkspaceArchivePending({
serverId: SERVER_ID,
workspaceId: archived.id,
workspaceDirectory: archived.workspaceDirectory,
}),
).toBe(true);
releaseArchive.resolve(archivePayload({ workspaceId: archived.id }));
await archive;
expect(storedWorkspace(archived.id)).toBeUndefined();
});
it("restores the workspace and clears pending state when the daemon rejects the archive", async () => {
const archived = workspace();
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [archived]);
const client = createClient(
vi.fn(async () => archivePayload({ workspaceId: archived.id, error: "nope" })),
);
await expect(
archiveWorkspaceOptimistically({
client,
workspace: target(),
}),
).rejects.toThrow("nope");
expect(storedWorkspace(archived.id)).toEqual(archived);
expect(
isWorkspaceArchivePending({
serverId: SERVER_ID,
workspaceId: archived.id,
}),
).toBe(false);
});
it("runs the after-hide hook after local state is hidden", async () => {
const archived = workspace();
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [archived]);
const client = createClient(vi.fn(async () => archivePayload({ workspaceId: archived.id })));
const afterHide = vi.fn(() => {
expect(storedWorkspace(archived.id)).toBeUndefined();
});
await archiveWorkspaceOptimistically({
client,
workspace: target(),
afterHide,
});
expect(afterHide).toHaveBeenCalledOnce();
});
});
describe("archiveWorkspacesOptimistically", () => {
it("returns failures and restores only the workspaces whose archive failed", async () => {
const first = workspace({ id: "workspace-1" });
const second = workspace({
id: "workspace-2",
workspaceDirectory: "/repo/project/workspace-2",
name: "workspace-2",
});
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [first, second]);
const client = createClient(
vi.fn(async (workspaceId) =>
archivePayload({
workspaceId,
error: workspaceId === second.id ? "failed" : null,
}),
),
);
const failures = await archiveWorkspacesOptimistically({
client,
workspaces: [
target({ workspaceId: first.id, workspaceDirectory: first.workspaceDirectory }),
target({ workspaceId: second.id, workspaceDirectory: second.workspaceDirectory }),
],
});
expect(failures).toHaveLength(1);
expect(failures[0]?.workspaceId).toBe(second.id);
expect(storedWorkspace(first.id)).toBeUndefined();
expect(storedWorkspace(second.id)).toEqual(second);
});
});

View File

@@ -0,0 +1,122 @@
import {
clearWorkspaceArchivePending,
markWorkspaceArchivePending,
} from "@/contexts/session-workspace-upserts";
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-execution";
export interface WorkspaceArchiveTarget {
serverId: string;
workspaceId: string;
workspaceDirectory?: string | null;
}
interface WorkspaceArchiveClient {
archiveWorkspace: (workspaceId: string) => Promise<{ error: string | null }>;
}
interface OptimisticWorkspaceArchiveSnapshot {
workspace: WorkspaceDescriptor | null;
}
export interface WorkspaceArchiveFailure {
workspaceId: string;
error: unknown;
}
function isWorkspaceArchiveFailure(error: unknown): error is WorkspaceArchiveFailure {
return (
typeof error === "object" &&
error !== null &&
"workspaceId" in error &&
typeof error.workspaceId === "string" &&
"error" in error
);
}
function hideWorkspaceOptimistically(
workspace: WorkspaceArchiveTarget,
): OptimisticWorkspaceArchiveSnapshot {
const workspaces = useSessionStore.getState().sessions[workspace.serverId]?.workspaces;
const workspaceKey = resolveWorkspaceMapKeyByIdentity({
workspaces,
workspaceId: workspace.workspaceId,
});
const snapshot = workspaceKey ? (workspaces?.get(workspaceKey) ?? null) : null;
markWorkspaceArchivePending({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.workspaceDirectory,
});
useSessionStore.getState().removeWorkspace(workspace.serverId, workspace.workspaceId);
return { workspace: snapshot };
}
function restoreOptimisticallyHiddenWorkspace(input: {
serverId: string;
workspaceId: string;
snapshot: OptimisticWorkspaceArchiveSnapshot;
}): void {
clearWorkspaceArchivePending({
serverId: input.serverId,
workspaceId: input.workspaceId,
});
if (input.snapshot.workspace) {
useSessionStore.getState().mergeWorkspaces(input.serverId, [input.snapshot.workspace]);
}
}
async function archiveWorkspaceOrThrow(input: {
client: WorkspaceArchiveClient;
workspaceId: string;
}): Promise<void> {
const payload = await input.client.archiveWorkspace(input.workspaceId);
if (payload.error) {
throw new Error(payload.error);
}
}
export async function archiveWorkspaceOptimistically(input: {
client: WorkspaceArchiveClient;
workspace: WorkspaceArchiveTarget;
afterHide?: () => void;
}): Promise<void> {
const snapshot = hideWorkspaceOptimistically(input.workspace);
input.afterHide?.();
try {
await archiveWorkspaceOrThrow({
client: input.client,
workspaceId: input.workspace.workspaceId,
});
} catch (error) {
restoreOptimisticallyHiddenWorkspace({
serverId: input.workspace.serverId,
workspaceId: input.workspace.workspaceId,
snapshot,
});
throw error;
}
}
export async function archiveWorkspacesOptimistically(input: {
client: WorkspaceArchiveClient;
workspaces: WorkspaceArchiveTarget[];
}): Promise<WorkspaceArchiveFailure[]> {
const results = await Promise.allSettled(
input.workspaces.map(async (workspace) => {
try {
await archiveWorkspaceOptimistically({
client: input.client,
workspace,
});
} catch (error) {
throw { workspaceId: workspace.workspaceId, error } satisfies WorkspaceArchiveFailure;
}
}),
);
return results.flatMap((result) =>
result.status === "rejected" && isWorkspaceArchiveFailure(result.reason) ? [result.reason] : [],
);
}

View File

@@ -30,7 +30,7 @@
"mime-types": "^2.1.35",
"tree-kill": "^1.2.2",
"ws": "^8.14.2",
"yaml": "^2.8.2"
"yaml": "^2.8.4"
},
"devDependencies": {
"@types/mime-types": "^3.0.1",

View File

@@ -69,7 +69,7 @@
"@sctg/sentencepiece-js": "^1.1.0",
"@xterm/headless": "^6.0.0",
"ai": "5.0.78",
"ajv": "^8.17.1",
"ajv": "^8.20.0",
"bcryptjs": "^3.0.3",
"dotenv": "^17.2.3",
"express": "^4.18.2",

View File

@@ -10,7 +10,7 @@ import { AgentStorage } from "./agent-storage.js";
import { createAllClients, shutdownProviders } from "./provider-registry.js";
import { generateAndApplyAgentMetadata } from "./agent-metadata-generator.js";
import { isProviderAvailable } from "../daemon-e2e/agent-configs.js";
import { OpenCodeServerManager } from "./providers/opencode-agent.js";
import { OpenCodeServerManager } from "./providers/opencode/server-manager.js";
const CODEX_TEST_MODEL = "gpt-5.4-mini";
const CODEX_TEST_THINKING_OPTION_ID = "low";

View File

@@ -96,7 +96,8 @@ export interface StructuredAgentGenerationWithFallbackOptions<T> {
export const DEFAULT_STRUCTURED_GENERATION_PROVIDERS: readonly StructuredGenerationProvider[] = [
{ provider: "claude", model: "haiku" },
{ provider: "codex", model: "gpt-5.4-mini", thinkingOptionId: "low" },
{ provider: "opencode", model: "opencode/gpt-5-nano" },
{ provider: "opencode", model: "opencode/minimax-m2.5-free" },
{ provider: "opencode", model: "opencode/nemotron-3-super-free" },
] as const;
interface SchemaValidator<T> {

View File

@@ -0,0 +1,37 @@
import { MAX_EXPLICIT_AGENT_TITLE_CHARS } from "./agent-title-limits.js";
const MAX_INITIAL_AGENT_TITLE_CHARS = Math.min(60, MAX_EXPLICIT_AGENT_TITLE_CHARS);
function deriveInitialAgentTitle(prompt: string): string | null {
const firstContentLine = prompt
.split(/\r?\n/)
.map((line) => line.trim())
.find((line) => line.length > 0);
if (!firstContentLine) {
return null;
}
const normalized = firstContentLine.replace(/\s+/g, " ").trim();
if (!normalized) {
return null;
}
const clamped = normalized.slice(0, MAX_INITIAL_AGENT_TITLE_CHARS).trim();
return clamped.length > 0 ? clamped : null;
}
export function resolveCreateAgentTitles(options: {
configTitle?: string | null;
initialPrompt?: string | null;
}): { explicitTitle: string | null; provisionalTitle: string | null } {
const explicitTitle =
typeof options.configTitle === "string" && options.configTitle.trim().length > 0
? options.configTitle.trim()
: null;
const trimmedPrompt = options.initialPrompt?.trim();
const provisionalTitle =
explicitTitle ?? (trimmedPrompt ? deriveInitialAgentTitle(trimmedPrompt) : null);
return {
explicitTitle,
provisionalTitle,
};
}

View File

@@ -1,14 +1,28 @@
import { expect, test, vi } from "vitest";
import { beforeEach, expect, test, vi } from "vitest";
import type { AgentManager, ManagedAgent } from "./agent-manager.js";
import type { AgentStorage, StoredAgentRecord } from "./agent-storage.js";
import type { FetchRecentProviderSessionsRequestMessage } from "../../shared/messages.js";
import type { PersistedAgentDescriptor } from "./agent-sdk-types.js";
import type { AgentTimelineItem, PersistedAgentDescriptor } from "./agent-sdk-types.js";
import {
ImportSessionsRequestError,
importProviderSession,
listImportableProviderSessions,
normalizeImportAgentRequest,
} from "./import-sessions.js";
const TEST_CAPABILITIES = {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: false,
supportsMcpServers: false,
supportsReasoningStream: false,
supportsToolInvocations: true,
};
beforeEach(() => {
vi.clearAllMocks();
});
function makeDescriptor(args: {
provider?: string;
sessionId: string;
@@ -40,6 +54,49 @@ function makeDescriptor(args: {
};
}
function makeManagedAgent(args: {
id?: string;
provider?: string;
cwd: string;
sessionId: string;
nativeHandle?: string;
title?: string | null;
}): ManagedAgent {
const provider = args.provider ?? "codex";
return {
id: args.id ?? "00000000-0000-4000-8000-000000000632",
provider,
cwd: args.cwd,
capabilities: TEST_CAPABILITIES,
config: { provider, cwd: args.cwd, title: args.title },
createdAt: new Date("2026-04-30T00:00:00.000Z"),
updatedAt: new Date("2026-04-30T00:00:00.000Z"),
availableModes: [],
currentModeId: null,
pendingPermissions: new Map(),
bufferedPermissionResolutions: new Map(),
inFlightPermissionResponses: new Set(),
pendingReplacement: false,
persistence: {
provider,
sessionId: args.sessionId,
...(args.nativeHandle ? { nativeHandle: args.nativeHandle } : {}),
metadata: { provider, cwd: args.cwd },
},
historyPrimed: true,
lastUserMessageAt: null,
attention: { requiresAttention: false },
foregroundTurnWaiters: new Set(),
finalizedForegroundTurnIds: new Set(),
unsubscribeSession: null,
internal: false,
labels: {},
lifecycle: "closed",
session: null,
activeForegroundTurnId: null,
} satisfies ManagedAgent;
}
function makeRequest(
overrides: Partial<FetchRecentProviderSessionsRequestMessage> = {},
): FetchRecentProviderSessionsRequestMessage {
@@ -263,3 +320,144 @@ test("normalizeImportAgentRequest accepts new and legacy import handle shapes",
providerHandleId: "thread-2",
});
});
test("importProviderSession resumes by provider handle, hydrates the timeline, and applies title metadata", async () => {
const cwd = "/tmp/imported-agent";
const timeline: AgentTimelineItem[] = [
{ type: "user_message", text: "Trace recent provider sessions\n\nkeep it tight" },
{ type: "assistant_message", text: "I will inspect the provider listing." },
];
const snapshot = makeManagedAgent({
id: "00000000-0000-4000-8000-000000000633",
provider: "custom-codex",
cwd,
sessionId: "thread-imported",
nativeHandle: "provider-thread-imported",
title: null,
});
const descriptor = makeDescriptor({
provider: "custom-codex",
sessionId: "thread-imported",
nativeHandle: "provider-thread-imported",
cwd,
title: null,
firstPrompt: "Trace recent provider sessions",
lastActivityAt: "2026-04-30T00:00:00.000Z",
});
const agentManager = {
findPersistedAgent: vi.fn().mockResolvedValue(descriptor),
resumeAgentFromPersistence: vi.fn().mockResolvedValue(snapshot),
hydrateTimelineFromProvider: vi.fn().mockResolvedValue(undefined),
getTimeline: vi.fn().mockReturnValue(timeline),
setTitle: vi.fn().mockResolvedValue(undefined),
notifyAgentState: vi.fn(),
} as unknown as AgentManager;
const agentStorage = {
list: vi.fn().mockResolvedValue([]),
get: vi.fn().mockResolvedValue(null),
} as unknown as AgentStorage;
const scheduleAgentMetadataGeneration = vi.fn();
const result = await importProviderSession({
request: {
requestId: "import-thread",
provider: "custom-codex",
providerHandleId: "provider-thread-imported",
cwd,
},
agentManager,
agentStorage,
logger: { warn: vi.fn(), error: vi.fn() } as never,
deps: { scheduleAgentMetadataGeneration },
});
expect(agentManager.findPersistedAgent).toHaveBeenCalledWith(
"custom-codex",
"provider-thread-imported",
);
expect(agentManager.resumeAgentFromPersistence).toHaveBeenCalledWith(
descriptor.persistence,
{ cwd },
undefined,
{ labels: undefined },
);
expect(agentManager.hydrateTimelineFromProvider).toHaveBeenCalledWith(snapshot.id);
expect(agentManager.setTitle).toHaveBeenCalledWith(snapshot.id, "Trace recent provider sessions");
expect(scheduleAgentMetadataGeneration).toHaveBeenCalledWith(
expect.objectContaining({
agentManager,
agentId: snapshot.id,
cwd,
initialPrompt: "Trace recent provider sessions\n\nkeep it tight",
explicitTitle: null,
}),
);
expect(result).toEqual({ snapshot, timelineSize: 2 });
});
test("importProviderSession builds a fallback handle when a non-OpenCode provider has no descriptor", async () => {
const cwd = "/tmp/imported-agent";
const snapshot = makeManagedAgent({
provider: "codex",
cwd,
sessionId: "thread-imported",
nativeHandle: "thread-imported",
});
const agentManager = {
findPersistedAgent: vi.fn().mockResolvedValue(null),
resumeAgentFromPersistence: vi.fn().mockResolvedValue(snapshot),
hydrateTimelineFromProvider: vi.fn().mockResolvedValue(undefined),
getTimeline: vi.fn().mockReturnValue([]),
setTitle: vi.fn().mockResolvedValue(undefined),
notifyAgentState: vi.fn(),
} as unknown as AgentManager;
const agentStorage = {
list: vi.fn().mockResolvedValue([]),
get: vi.fn().mockResolvedValue(null),
} as unknown as AgentStorage;
await importProviderSession({
request: {
requestId: "import-thread",
provider: "codex",
providerHandleId: "thread-imported",
cwd,
},
agentManager,
agentStorage,
logger: { warn: vi.fn(), error: vi.fn() } as never,
});
expect(agentManager.resumeAgentFromPersistence).toHaveBeenCalledWith(
{
provider: "codex",
sessionId: "thread-imported",
nativeHandle: "thread-imported",
metadata: { provider: "codex", cwd },
},
{ cwd },
undefined,
{ labels: undefined },
);
});
test("importProviderSession requires cwd for missing OpenCode descriptors", async () => {
const agentManager = {
findPersistedAgent: vi.fn().mockResolvedValue(null),
} as unknown as AgentManager;
await expect(
importProviderSession({
request: {
requestId: "import-thread",
provider: "opencode",
providerHandleId: "thread-imported",
},
agentManager,
agentStorage: { list: vi.fn() } as unknown as AgentStorage,
logger: { warn: vi.fn(), error: vi.fn() } as never,
}),
).rejects.toThrow(
"OpenCode sessions require --cwd when the session cannot be found in persisted agents",
);
});

View File

@@ -1,18 +1,25 @@
import type { z } from "zod";
import type { Logger } from "pino";
import type { ProviderDefinition } from "./provider-registry.js";
import type { AgentManager } from "./agent-manager.js";
import type { AgentManager, ManagedAgent } from "./agent-manager.js";
import type { AgentStorage, StoredAgentRecord } from "./agent-storage.js";
import type {
AgentPersistenceHandle,
AgentProvider,
AgentSessionConfig,
AgentTimelineItem,
PersistedAgentDescriptor,
} from "./agent-sdk-types.js";
import { scheduleAgentMetadataGeneration } from "./agent-metadata-generator.js";
import { resolveCreateAgentTitles } from "./create-agent-title.js";
import { unarchiveAgentState } from "./agent-prompt.js";
import { toRecentProviderSessionDescriptorPayload } from "./agent-projections.js";
import type {
FetchRecentProviderSessionsRequestMessage,
ImportAgentRequestMessageSchema,
RecentProviderSessionDescriptorPayload,
} from "../../shared/messages.js";
import type { WorkspaceGitService } from "../workspace-git-service.js";
type ImportAgentRequestMessage = z.infer<typeof ImportAgentRequestMessageSchema>;
@@ -49,6 +56,23 @@ export interface ListImportableProviderSessionsResult {
filteredAlreadyImportedCount: number;
}
export interface ImportProviderSessionInput {
request: NormalizedImportAgentRequest;
agentManager: AgentManager;
agentStorage: AgentStorage;
workspaceGitService?: Pick<WorkspaceGitService, "resolveRepoRoot">;
paseoHome?: string;
logger: Logger;
deps?: {
scheduleAgentMetadataGeneration?: typeof scheduleAgentMetadataGeneration;
};
}
export interface ImportProviderSessionResult {
snapshot: ManagedAgent;
timelineSize: number;
}
// COMPAT(import-agent-request-v1): accept legacy {provider, sessionId} shape
// alongside the new {providerId, providerHandleId} shape. Old clients
// (< target daemon floor) send the legacy fields. Drop the fallbacks and the
@@ -118,6 +142,99 @@ export async function listImportableProviderSessions(
return { entries, filteredAlreadyImportedCount };
}
export async function importProviderSession(
input: ImportProviderSessionInput,
): Promise<ImportProviderSessionResult> {
const { provider, providerHandleId, cwd, labels } = input.request;
const descriptor = await input.agentManager.findPersistedAgent(provider, providerHandleId);
if (!descriptor && provider === "opencode" && !cwd) {
throw new Error(
"OpenCode sessions require --cwd when the session cannot be found in persisted agents",
);
}
const handle = descriptor
? applyImportCwdOverride(descriptor.persistence, cwd)
: buildImportPersistenceHandle({ provider, providerHandleId, cwd });
const overrides = cwd ? ({ cwd } satisfies Partial<AgentSessionConfig>) : undefined;
await unarchiveAgentByHandle(input.agentStorage, input.agentManager, handle);
const snapshot = await input.agentManager.resumeAgentFromPersistence(
handle,
overrides,
undefined,
{
labels,
},
);
await unarchiveAgentState(input.agentStorage, input.agentManager, snapshot.id);
await input.agentManager.hydrateTimelineFromProvider(snapshot.id);
await applyImportedAgentTitle({
snapshot,
agentManager: input.agentManager,
workspaceGitService: input.workspaceGitService,
paseoHome: input.paseoHome,
logger: input.logger,
scheduleAgentMetadataGeneration:
input.deps?.scheduleAgentMetadataGeneration ?? scheduleAgentMetadataGeneration,
});
return {
snapshot,
timelineSize: input.agentManager.getTimeline(snapshot.id).length,
};
}
async function unarchiveAgentByHandle(
agentStorage: AgentStorage,
agentManager: AgentManager,
handle: AgentPersistenceHandle,
): Promise<void> {
const records = await agentStorage.list();
const matched = records.find(
(record) =>
record.persistence?.provider === handle.provider &&
record.persistence?.sessionId === handle.sessionId,
);
if (!matched) {
return;
}
await unarchiveAgentState(agentStorage, agentManager, matched.id);
}
async function applyImportedAgentTitle(input: {
snapshot: ManagedAgent;
agentManager: AgentManager;
workspaceGitService?: Pick<WorkspaceGitService, "resolveRepoRoot">;
paseoHome?: string;
logger: Logger;
scheduleAgentMetadataGeneration: typeof scheduleAgentMetadataGeneration;
}): Promise<void> {
const initialPrompt = getFirstUserMessageText(input.agentManager.getTimeline(input.snapshot.id));
if (!initialPrompt) {
return;
}
const { explicitTitle, provisionalTitle } = resolveCreateAgentTitles({
configTitle: input.snapshot.config.title,
initialPrompt,
});
if (!explicitTitle && provisionalTitle) {
await input.agentManager.setTitle(input.snapshot.id, provisionalTitle);
}
input.scheduleAgentMetadataGeneration({
agentManager: input.agentManager,
agentId: input.snapshot.id,
cwd: input.snapshot.cwd,
workspaceGitService: input.workspaceGitService,
initialPrompt,
explicitTitle,
paseoHome: input.paseoHome,
logger: input.logger,
});
}
function parseRecentProviderSessionsSince(since: string | undefined): number | null {
if (!since) {
return null;
@@ -129,6 +246,54 @@ function parseRecentProviderSessionsSince(since: string | undefined): number | n
return timestamp;
}
function buildImportPersistenceHandle(input: {
provider: AgentProvider;
providerHandleId: string;
cwd?: string;
}): AgentPersistenceHandle {
const cwd = input.cwd ?? process.cwd();
return {
provider: input.provider,
sessionId: input.providerHandleId,
nativeHandle: input.providerHandleId,
metadata: {
provider: input.provider,
cwd,
},
};
}
function applyImportCwdOverride(
handle: AgentPersistenceHandle,
cwd: string | undefined,
): AgentPersistenceHandle {
if (!cwd) {
return handle;
}
return {
...handle,
metadata: {
...handle.metadata,
provider: handle.provider,
cwd,
},
};
}
function getFirstUserMessageText(timeline: readonly AgentTimelineItem[]): string | null {
for (const item of timeline) {
if (item.type !== "user_message") {
continue;
}
const text = item.text.trim();
if (text) {
return text;
}
}
return null;
}
async function collectImportedProviderSessionHandles(
agentManager: Pick<AgentManager, "listAgents">,
agentStorage: Pick<AgentStorage, "list">,

View File

@@ -13,7 +13,6 @@ const mockState = vi.hoisted(() => {
claude: [] as ConstructorEntry[],
codex: [] as ConstructorEntry[],
copilot: [] as ConstructorEntry[],
opencode: [] as ConstructorEntry[],
pi: [] as ConstructorEntry[],
genericAcp: [] as Array<{
command: string[];
@@ -26,7 +25,6 @@ const mockState = vi.hoisted(() => {
this.constructorArgs.claude = [];
this.constructorArgs.codex = [];
this.constructorArgs.copilot = [];
this.constructorArgs.opencode = [];
this.constructorArgs.pi = [];
this.constructorArgs.genericAcp = [];
this.isCommandAvailable.mockReset();
@@ -188,51 +186,6 @@ vi.mock("./providers/copilot-acp-agent.js", () => ({
},
}));
vi.mock("./providers/opencode-agent.js", () => ({
OpenCodeAgentClient: class OpenCodeAgentClient {
readonly capabilities = {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
};
readonly provider = "opencode";
readonly runtimeSettings?: unknown;
constructor(_logger: unknown, runtimeSettings?: unknown) {
this.runtimeSettings = runtimeSettings;
mockState.constructorArgs.opencode.push({ runtimeSettings });
}
async createSession(): Promise<never> {
throw new Error("not implemented");
}
async resumeSession(): Promise<never> {
throw new Error("not implemented");
}
async listModels(): Promise<AgentModelDefinition[]> {
return mockState.runtimeModels.get(this.provider) ?? [];
}
async listModes(): Promise<[]> {
return [];
}
async isAvailable(): Promise<boolean> {
return true;
}
},
OpenCodeServerManager: {
getInstance: vi.fn(() => ({
shutdown: vi.fn(),
})),
},
}));
vi.mock("./providers/pi-direct-agent.js", () => ({
PiDirectAgentClient: class PiDirectAgentClient {
readonly capabilities = {

View File

@@ -25,7 +25,8 @@ import { ClaudeAgentClient } from "./providers/claude/agent.js";
import { CodexAppServerAgentClient } from "./providers/codex-app-server-agent.js";
import { CopilotACPAgentClient } from "./providers/copilot-acp-agent.js";
import { GenericACPAgentClient } from "./providers/generic-acp-agent.js";
import { OpenCodeAgentClient, OpenCodeServerManager } from "./providers/opencode-agent.js";
import { OpenCodeAgentClient } from "./providers/opencode-agent.js";
import { OpenCodeServerManager } from "./providers/opencode/server-manager.js";
import { PiDirectAgentClient } from "./providers/pi-direct-agent.js";
import { MockLoadTestAgentClient } from "./providers/mock-load-test-agent.js";
import {

View File

@@ -378,9 +378,9 @@ describe("ClaudeAgentSession integration", () => {
streamSession(
handle.session,
[
"Use the Task tool to start a background sub-agent.",
"In that task, run the Bash command exactly: sleep 3 && echo BACKGROUND_DONE",
"Do not wait for task completion.",
"Use the Bash tool with run_in_background.",
"Run exactly: sleep 3",
"Do not wait for the task result.",
"Reply immediately with exactly: SPAWNED",
`When the background task completes later, reply with exactly: ${autonomousWakeToken}`,
].join(" "),

View File

@@ -1,11 +1,9 @@
import { describe, expect, test, vi } from "vitest";
import type { ChildProcessWithoutNullStreams } from "node:child_process";
import { EventEmitter } from "node:events";
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { PassThrough } from "node:stream";
import type {
AgentLaunchContext,
@@ -18,6 +16,10 @@ import {
CodexAppServerAgentClient,
codexAppServerTurnInputFromPrompt,
} from "./codex-app-server-agent.js";
import {
createCodexAppServerChildProcessStub,
TestCodexAppServerPeer,
} from "./codex/test-utils/test-app-server-peer.js";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import { asInternals as castInternals, createStub } from "../../test-utils/class-mocks.js";
@@ -98,6 +100,34 @@ function markdownImageSource(markdown: string): string {
return match[1].replace(/\\\)/g, ")");
}
function waitForNextPermission(
session: AgentSession,
events: AgentStreamEvent[],
): Promise<Extract<AgentStreamEvent, { type: "permission_requested" }>> {
const existing = events.find(
(event): event is Extract<AgentStreamEvent, { type: "permission_requested" }> =>
event.type === "permission_requested",
);
if (existing) {
return Promise.resolve(existing);
}
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
unsubscribe();
reject(new Error("Timed out waiting for permission_requested"));
}, 1000);
const unsubscribe = session.subscribe((event) => {
if (event.type !== "permission_requested") {
return;
}
clearTimeout(timeout);
unsubscribe();
resolve(event);
});
});
}
describe("Codex app-server provider", () => {
test("passes ephemeral: true to thread/start when constructed as ephemeral", async () => {
const requests: Array<{ method: string; params: unknown }> = [];
@@ -161,21 +191,15 @@ describe("Codex app-server provider", () => {
test("disposes an unresponsive app-server child with SIGKILL", async () => {
vi.useFakeTimers();
const child = new EventEmitter() as ChildProcessWithoutNullStreams;
child.stdin = new PassThrough() as ChildProcessWithoutNullStreams["stdin"];
child.stdout = new PassThrough() as ChildProcessWithoutNullStreams["stdout"];
child.stderr = new PassThrough() as ChildProcessWithoutNullStreams["stderr"];
child.exitCode = null;
child.signalCode = null;
child.kill = vi.fn(() => true) as ChildProcessWithoutNullStreams["kill"];
const child = createCodexAppServerChildProcessStub({ exitOnKill: false });
const client = new __codexAppServerInternals.CodexAppServerClient(child, createTestLogger());
try {
const disposePromise = client.dispose();
expect(child.kill).toHaveBeenCalledWith("SIGTERM");
expect(child.killSignals).toEqual(["SIGTERM"]);
await vi.advanceTimersByTimeAsync(2_000);
expect(child.kill).toHaveBeenCalledWith("SIGKILL");
expect(child.killSignals).toEqual(["SIGTERM", "SIGKILL"]);
await vi.advanceTimersByTimeAsync(1_000);
await expect(disposePromise).resolves.toBeUndefined();
@@ -184,6 +208,68 @@ describe("Codex app-server provider", () => {
}
});
test("round-trips server-initiated command approvals through the real app-server transport", async () => {
const peer = new TestCodexAppServerPeer({
initialize: () => ({}),
"collaborationMode/list": () => ({ data: [] }),
"skills/list": () => ({ data: [] }),
});
const session = new __codexAppServerInternals.CodexAppServerAgentSession(
createConfig({ cwd: "/workspace/project" }),
null,
createTestLogger(),
async () => peer.child,
);
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
await session.connect();
peer.assertNoErrors();
const permissionRequested = waitForNextPermission(session, events);
peer.writeRequest(
"item/commandExecution/requestApproval",
{
itemId: "exec-approval-1",
threadId: "thread-1",
turnId: "turn-1",
command: "git restore README.md",
cwd: "/workspace/project",
reason: "requires escalated permissions",
},
41,
);
const permissionEvent = await permissionRequested;
expect(permissionEvent.request).toMatchObject({
id: "permission-exec-approval-1",
provider: "codex",
name: "CodexBash",
kind: "tool",
title: "Run command: git restore README.md",
description: "requires escalated permissions",
input: {
command: "git restore README.md",
cwd: "/workspace/project",
},
metadata: {
itemId: "exec-approval-1",
threadId: "thread-1",
turnId: "turn-1",
},
});
await session.respondToPermission(permissionEvent.request.id, { behavior: "allow" });
await expect(peer.waitForResponse(41, { decision: "accept" })).resolves.toMatchObject({
id: 41,
result: { decision: "accept" },
});
peer.assertNoErrors();
await session.close();
});
test("lists repo skills using WorkspaceGitService repo-root resolution", async () => {
const tempDir = await mkdtemp(path.join(tmpdir(), "codex-skills-"));
const cwd = path.join(tempDir, "repo", "packages", "app");
@@ -194,8 +280,12 @@ describe("Codex app-server provider", () => {
path.join(repoSkillDir, "SKILL.md"),
"---\nname: shipper\ndescription: Ship changes carefully.\n---\n",
);
const resolvedRepoRoots: string[] = [];
const workspaceGitService = {
resolveRepoRoot: vi.fn().mockResolvedValue(path.join(tempDir, "repo")),
resolveRepoRoot: async (pathToResolve: string) => {
resolvedRepoRoots.push(pathToResolve);
return path.join(tempDir, "repo");
},
};
try {
@@ -206,7 +296,7 @@ describe("Codex app-server provider", () => {
description: "Ship changes carefully.",
argumentHint: "",
});
expect(workspaceGitService.resolveRepoRoot).toHaveBeenCalledWith(cwd);
expect(resolvedRepoRoots).toEqual([cwd]);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
@@ -1700,14 +1790,7 @@ describe("Codex persisted sessions", () => {
castInternals<{ spawnAppServer: () => Promise<ChildProcessWithoutNullStreams> }>(
provider,
).spawnAppServer = async () => {
const child = new EventEmitter() as ChildProcessWithoutNullStreams;
child.exitCode = 0;
child.signalCode = null;
child.stdin = new PassThrough();
child.stdout = new PassThrough();
child.stderr = new PassThrough();
child.kill = vi.fn(() => true) as ChildProcessWithoutNullStreams["kill"];
return child;
return createCodexAppServerChildProcessStub();
};
const descriptors = await provider.listPersistedAgents({ cwd: "/workspace/project-a" });

View File

@@ -38,7 +38,6 @@ import * as fsSync from "node:fs";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import readline from "node:readline";
import { z } from "zod";
import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
import { curateAgentActivity } from "../activity-curator.js";
@@ -53,10 +52,10 @@ import {
type ProviderRuntimeSettings,
} from "../provider-launch-config.js";
import { findExecutable, isCommandAvailable } from "../../../utils/executable.js";
import { terminateWithTreeKill } from "../../../utils/tree-kill.js";
import { spawnProcess } from "../../../utils/spawn.js";
import { extractCodexTerminalSessionId, nonEmptyString } from "./tool-call-mapper-utils.js";
import { buildCodexFeatures, codexModelSupportsFastMode } from "./codex-feature-definitions.js";
import { CodexAppServerClient } from "./codex/app-server-transport.js";
import {
renderProviderImageOutputAsAssistantMarkdown,
type ProviderImageOutput,
@@ -83,11 +82,8 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return value != null && typeof value === "object" && !Array.isArray(value);
}
const DEFAULT_TIMEOUT_MS = 14 * 24 * 60 * 60 * 1000;
const TURN_START_TIMEOUT_MS = 90 * 1000;
const INTERRUPT_TIMEOUT_MS = 2_000;
const APP_SERVER_GRACEFUL_SHUTDOWN_TIMEOUT_MS = 2_000;
const APP_SERVER_FORCE_SHUTDOWN_TIMEOUT_MS = 1_000;
const CODEX_PROVIDER = "codex" as const;
const CODEX_IMAGE_ATTACHMENT_DIR = "paseo-attachments";
const ASSISTANT_MESSAGE_BOUNDARY_MARKDOWN = "\n\n---\n\n";
@@ -627,53 +623,11 @@ function toCodexMcpConfig(config: McpServerConfig): CodexMcpServerConfig {
}
}
}
interface JsonRpcRequest {
id: number;
method: string;
params?: unknown;
}
interface JsonRpcResponse {
id: number;
result?: unknown;
error?: { code?: number; message: string };
}
interface JsonRpcNotification {
method: string;
params?: unknown;
}
function isJsonRpcResponse(msg: unknown): msg is JsonRpcResponse {
if (!isRecord(msg)) return false;
if (typeof msg.id !== "number") return false;
return msg.result !== undefined || !!msg.error;
}
function isJsonRpcRequest(msg: unknown): msg is JsonRpcRequest {
if (!isRecord(msg)) return false;
return typeof msg.id === "number" && typeof msg.method === "string";
}
function isJsonRpcNotification(msg: unknown): msg is JsonRpcNotification {
if (!isRecord(msg)) return false;
return typeof msg.method === "string" && typeof msg.id !== "number";
}
function toObjectRecord(value: unknown): Record<string, unknown> | undefined {
return isRecord(value) ? value : undefined;
}
interface PendingRequest {
resolve: (value: unknown) => void;
reject: (error: Error) => void;
timer: ReturnType<typeof setTimeout>;
}
type RequestHandler = (params: unknown) => unknown;
type NotificationHandler = (method: string, params: unknown) => void;
// Codex app-server API response types
interface CodexReasoningEffortEntry {
reasoningEffort?: string;
@@ -727,171 +681,6 @@ function filterCodexThreadsByCwd(
return threads.filter((thread) => typeof thread.cwd === "string" && thread.cwd === cwd);
}
class CodexAppServerClient {
private readonly rl: readline.Interface;
private readonly pending = new Map<number, PendingRequest>();
private readonly requestHandlers = new Map<string, RequestHandler>();
private notificationHandler: NotificationHandler | null = null;
private nextId = 1;
private disposed = false;
private stderrBuffer = "";
constructor(
private readonly child: ChildProcessWithoutNullStreams,
private readonly logger: Logger,
) {
this.rl = readline.createInterface({ input: child.stdout });
this.rl.on("line", (line) => this.handleLine(line));
child.stderr.on("data", (chunk) => {
this.stderrBuffer += chunk.toString();
if (this.stderrBuffer.length > 8192) {
this.stderrBuffer = this.stderrBuffer.slice(-8192);
}
});
child.on("error", (err) => {
this.logger.error({ err }, "Codex app-server child process error");
for (const pending of this.pending.values()) {
clearTimeout(pending.timer);
pending.reject(err);
}
this.pending.clear();
this.disposed = true;
});
child.on("exit", (code, signal) => {
const message =
code === 0 && !signal
? "Codex app-server exited"
: `Codex app-server exited with code ${code ?? "null"} and signal ${signal ?? "null"}`;
const error = new Error(`${message}\n${this.stderrBuffer}`.trim());
for (const pending of this.pending.values()) {
clearTimeout(pending.timer);
pending.reject(error);
}
this.pending.clear();
this.disposed = true;
});
}
setNotificationHandler(handler: NotificationHandler): void {
this.notificationHandler = handler;
}
setRequestHandler(method: string, handler: RequestHandler): void {
this.requestHandlers.set(method, handler);
}
request(method: string, params?: unknown, timeoutMs = DEFAULT_TIMEOUT_MS): Promise<unknown> {
if (this.disposed) {
return Promise.reject(new Error("Codex app-server client is closed"));
}
const id = this.nextId++;
const payload: JsonRpcRequest = { id, method, params };
const serialized = JSON.stringify(payload);
this.child.stdin.write(`${serialized}\n`);
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
this.pending.delete(id);
reject(new Error(`Codex app-server request timed out for ${method}`));
}, timeoutMs);
this.pending.set(id, { resolve, reject, timer });
});
}
notify(method: string, params?: unknown): void {
if (this.disposed) {
return;
}
const payload: JsonRpcNotification = { method, params };
this.child.stdin.write(`${JSON.stringify(payload)}\n`);
}
private writeJsonRpcResponse(response: JsonRpcResponse): void {
if (this.disposed || this.child.stdin.destroyed || !this.child.stdin.writable) {
return;
}
try {
this.child.stdin.write(`${JSON.stringify(response)}\n`);
} catch (error) {
this.logger.debug({ error }, "Failed to write Codex app-server JSON-RPC response");
}
}
async dispose(): Promise<void> {
if (this.disposed) return;
this.disposed = true;
this.rl.close();
try {
this.child.stdin.end();
} catch {
// ignore
}
const result = await terminateWithTreeKill(this.child, {
gracefulTimeoutMs: APP_SERVER_GRACEFUL_SHUTDOWN_TIMEOUT_MS,
forceTimeoutMs: APP_SERVER_FORCE_SHUTDOWN_TIMEOUT_MS,
onForceSignal: () => {
this.logger.warn(
{ timeoutMs: APP_SERVER_GRACEFUL_SHUTDOWN_TIMEOUT_MS },
"Codex app-server did not exit after SIGTERM; sending SIGKILL",
);
},
});
if (result === "kill-timeout") {
this.logger.warn(
{ timeoutMs: APP_SERVER_FORCE_SHUTDOWN_TIMEOUT_MS },
"Codex app-server did not report exit after SIGKILL",
);
}
}
private async handleLine(line: string): Promise<void> {
if (!line.trim()) return;
const raw: unknown = JSON.parse(line);
if (!isRecord(raw)) {
this.logger.warn({ line }, "Parsed JSON is not an object");
return;
}
if (isJsonRpcResponse(raw)) {
const id = raw.id;
if (raw.result !== undefined || raw.error) {
const pending = this.pending.get(id);
if (!pending) return;
clearTimeout(pending.timer);
this.pending.delete(id);
if (raw.error) {
pending.reject(new Error(raw.error.message ?? "Unknown error"));
} else {
pending.resolve(raw.result);
}
return;
}
// Server-initiated request
if (isJsonRpcRequest(raw)) {
const request = raw;
const handler = this.requestHandlers.get(request.method);
try {
const result = handler ? await handler(request.params) : {};
this.writeJsonRpcResponse({ id: request.id, result });
} catch (error) {
this.writeJsonRpcResponse({
id: request.id,
error: { message: error instanceof Error ? error.message : String(error) },
});
}
return;
}
}
if (isJsonRpcNotification(raw)) {
this.notificationHandler?.(raw.method, raw.params);
}
}
}
function toAgentUsage(tokenUsage: unknown): AgentUsage | undefined {
const usage = toObjectRecord(tokenUsage);
if (!usage) return undefined;

View File

@@ -0,0 +1,41 @@
import { describe, expect, test } from "vitest";
import { createTestLogger } from "../../../../test-utils/test-logger.js";
import { CodexAppServerClient } from "./app-server-transport.js";
import { TestCodexAppServerPeer } from "./test-utils/test-app-server-peer.js";
describe("Codex app-server transport", () => {
test("ignores non-JSON stdout lines without dropping pending requests", async () => {
const peer = new TestCodexAppServerPeer();
const client = new CodexAppServerClient(peer.child, createTestLogger());
const request = client.request("model/list", {});
peer.writeNonJsonStdout("Codex ha iniciado en modo localizado");
peer.writeResponse(1, { data: [] });
await expect(request).resolves.toEqual({ data: [] });
peer.close();
});
test.each([
"item/commandExecution/requestApproval",
"item/fileChange/requestApproval",
"item/tool/requestUserInput",
"tool/requestUserInput",
])("answers server-initiated %s requests through registered handlers", async (method) => {
const peer = new TestCodexAppServerPeer();
const client = new CodexAppServerClient(peer.child, createTestLogger());
const handlerCalls: unknown[] = [];
client.setRequestHandler(method, async (params) => {
handlerCalls.push(params);
return { ok: true };
});
const response = peer.nextPaseoOutputLine();
peer.writeRequest(method);
await expect(response).resolves.toBe('{"id":7,"result":{"ok":true}}\n');
expect(handlerCalls).toEqual([{}]);
peer.close();
});
});

View File

@@ -0,0 +1,230 @@
import type { ChildProcessWithoutNullStreams } from "node:child_process";
import readline from "node:readline";
import type { Logger } from "pino";
import { terminateWithTreeKill } from "../../../../utils/tree-kill.js";
const DEFAULT_TIMEOUT_MS = 14 * 24 * 60 * 60 * 1000;
const APP_SERVER_GRACEFUL_SHUTDOWN_TIMEOUT_MS = 2_000;
const APP_SERVER_FORCE_SHUTDOWN_TIMEOUT_MS = 1_000;
const STDERR_BUFFER_LIMIT = 8192;
interface JsonRpcRequest {
id: number;
method: string;
params?: unknown;
}
interface JsonRpcResponse {
id: number;
result?: unknown;
error?: { message?: string };
}
interface JsonRpcNotification {
method: string;
params?: unknown;
}
interface PendingRequest {
resolve: (value: unknown) => void;
reject: (error: Error) => void;
timer: NodeJS.Timeout;
}
type RequestHandler = (params: unknown) => unknown;
type NotificationHandler = (method: string, params: unknown) => void;
function isRecord(value: unknown): value is Record<string, unknown> {
return value != null && typeof value === "object" && !Array.isArray(value);
}
function isJsonRpcResponse(msg: unknown): msg is JsonRpcResponse {
if (!isRecord(msg)) return false;
return typeof msg.id === "number";
}
function isJsonRpcRequest(msg: unknown): msg is JsonRpcRequest {
if (!isRecord(msg)) return false;
return typeof msg.id === "number" && typeof msg.method === "string";
}
function isJsonRpcNotification(msg: unknown): msg is JsonRpcNotification {
if (!isRecord(msg)) return false;
return typeof msg.method === "string" && msg.id === undefined;
}
export class CodexAppServerClient {
private readonly rl: readline.Interface;
private readonly pending = new Map<number, PendingRequest>();
private readonly requestHandlers = new Map<string, RequestHandler>();
private notificationHandler: NotificationHandler | null = null;
private nextId = 1;
private disposed = false;
private stderrBuffer = "";
constructor(
private readonly child: ChildProcessWithoutNullStreams,
private readonly logger: Logger,
) {
this.rl = readline.createInterface({ input: child.stdout });
this.rl.on("line", (line) => {
void this.handleLine(line).catch((error) => {
this.logger.warn({ error, line }, "Failed to handle Codex app-server stdout line");
});
});
child.stderr.on("data", (chunk) => {
this.stderrBuffer += chunk.toString();
if (this.stderrBuffer.length > STDERR_BUFFER_LIMIT) {
this.stderrBuffer = this.stderrBuffer.slice(-STDERR_BUFFER_LIMIT);
}
});
child.on("error", (err) => {
this.logger.error({ err }, "Codex app-server child process error");
for (const pending of this.pending.values()) {
clearTimeout(pending.timer);
pending.reject(err);
}
this.pending.clear();
this.disposed = true;
});
child.on("exit", (code, signal) => {
const message =
code === 0 && !signal
? "Codex app-server exited"
: `Codex app-server exited with code ${code ?? "null"} and signal ${signal ?? "null"}`;
const error = new Error(`${message}\n${this.stderrBuffer}`.trim());
for (const pending of this.pending.values()) {
clearTimeout(pending.timer);
pending.reject(error);
}
this.pending.clear();
this.disposed = true;
});
}
setNotificationHandler(handler: NotificationHandler): void {
this.notificationHandler = handler;
}
setRequestHandler(method: string, handler: RequestHandler): void {
this.requestHandlers.set(method, handler);
}
request(method: string, params?: unknown, timeoutMs = DEFAULT_TIMEOUT_MS): Promise<unknown> {
if (this.disposed) {
return Promise.reject(new Error("Codex app-server client is closed"));
}
const id = this.nextId++;
const payload: JsonRpcRequest = { id, method, params };
const serialized = JSON.stringify(payload);
this.child.stdin.write(`${serialized}\n`);
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
this.pending.delete(id);
reject(new Error(`Codex app-server request timed out for ${method}`));
}, timeoutMs);
this.pending.set(id, { resolve, reject, timer });
});
}
notify(method: string, params?: unknown): void {
if (this.disposed) {
return;
}
const payload: JsonRpcNotification = { method, params };
this.child.stdin.write(`${JSON.stringify(payload)}\n`);
}
async dispose(): Promise<void> {
if (this.disposed) return;
this.disposed = true;
this.rl.close();
try {
this.child.stdin.end();
} catch {
// ignore
}
const result = await terminateWithTreeKill(this.child, {
gracefulTimeoutMs: APP_SERVER_GRACEFUL_SHUTDOWN_TIMEOUT_MS,
forceTimeoutMs: APP_SERVER_FORCE_SHUTDOWN_TIMEOUT_MS,
onForceSignal: () => {
this.logger.warn(
{ timeoutMs: APP_SERVER_GRACEFUL_SHUTDOWN_TIMEOUT_MS },
"Codex app-server did not exit after SIGTERM; sending SIGKILL",
);
},
});
if (result === "kill-timeout") {
this.logger.warn(
{ timeoutMs: APP_SERVER_FORCE_SHUTDOWN_TIMEOUT_MS },
"Codex app-server did not report exit after SIGKILL",
);
}
}
private writeJsonRpcResponse(response: JsonRpcResponse): void {
if (this.disposed || this.child.stdin.destroyed || !this.child.stdin.writable) {
return;
}
try {
this.child.stdin.write(`${JSON.stringify(response)}\n`);
} catch (error) {
this.logger.debug({ error }, "Failed to write Codex app-server JSON-RPC response");
}
}
private async handleLine(line: string): Promise<void> {
if (!line.trim()) return;
let raw: unknown;
try {
raw = JSON.parse(line);
} catch (error) {
this.logger.warn({ error, line }, "Ignoring non-JSON Codex app-server stdout line");
return;
}
if (!isRecord(raw)) {
this.logger.warn({ line }, "Parsed JSON is not an object");
return;
}
if (isJsonRpcResponse(raw)) {
const id = raw.id;
if (raw.result !== undefined || raw.error) {
const pending = this.pending.get(id);
if (!pending) return;
clearTimeout(pending.timer);
this.pending.delete(id);
if (raw.error) {
pending.reject(new Error(raw.error.message ?? "Unknown error"));
} else {
pending.resolve(raw.result);
}
return;
}
if (isJsonRpcRequest(raw)) {
const request = raw;
const handler = this.requestHandlers.get(request.method);
try {
const result = handler ? await handler(request.params) : {};
this.writeJsonRpcResponse({ id: request.id, result });
} catch (error) {
this.writeJsonRpcResponse({
id: request.id,
error: { message: error instanceof Error ? error.message : String(error) },
});
}
return;
}
}
if (isJsonRpcNotification(raw)) {
this.notificationHandler?.(raw.method, raw.params);
}
}
}

View File

@@ -0,0 +1,183 @@
import type { ChildProcessWithoutNullStreams } from "node:child_process";
import { EventEmitter } from "node:events";
import { PassThrough } from "node:stream";
import { isDeepStrictEqual } from "node:util";
interface JsonRecord {
[key: string]: unknown;
}
type RequestHandler = (params: unknown) => unknown;
type StubbedCodexAppServerChildProcess = ChildProcessWithoutNullStreams & {
stdin: PassThrough;
stdout: PassThrough;
stderr: PassThrough;
killSignals: Array<NodeJS.Signals | number | null>;
};
function isJsonRecord(value: unknown): value is JsonRecord {
return value != null && typeof value === "object" && !Array.isArray(value);
}
function parseJsonLine(line: string): JsonRecord | null {
const parsed: unknown = JSON.parse(line);
return isJsonRecord(parsed) ? parsed : null;
}
export function createCodexAppServerChildProcessStub(
options: { exitOnKill?: boolean } = {},
): StubbedCodexAppServerChildProcess {
const child = new EventEmitter() as StubbedCodexAppServerChildProcess;
child.stdin = new PassThrough();
child.stdout = new PassThrough();
child.stderr = new PassThrough();
child.killSignals = [];
Object.defineProperty(child, "exitCode", { value: null, configurable: true });
Object.defineProperty(child, "signalCode", { value: null, configurable: true });
child.kill = ((signal?: NodeJS.Signals | number) => {
child.killSignals.push(signal ?? null);
if (options.exitOnKill !== false) {
queueMicrotask(() => child.emit("exit", null, signal ?? null));
}
return true;
}) as ChildProcessWithoutNullStreams["kill"];
return child;
}
export class TestCodexAppServerPeer {
readonly child: StubbedCodexAppServerChildProcess;
private readonly messages: JsonRecord[] = [];
private readonly errors: Error[] = [];
private readonly waiters = new Set<{
predicate: (message: JsonRecord) => boolean;
resolve: (message: JsonRecord) => void;
}>();
private buffer = "";
constructor(handlers: Record<string, RequestHandler> = {}) {
this.child = createCodexAppServerChildProcessStub();
this.child.stdin.on("data", (chunk) => {
this.acceptPaseoOutput(chunk.toString(), handlers);
});
}
writeNonJsonStdout(line: string): void {
this.child.stdout.write(`${line}\n`);
}
writeResponse(id: number, result: unknown): void {
this.child.stdout.write(`${JSON.stringify({ id, result })}\n`);
}
writeRequest(method: string, params: unknown = {}, id = 7): void {
this.child.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`);
}
nextPaseoOutputLine(): Promise<string> {
return new Promise((resolve) => {
this.child.stdin.once("data", (chunk) => resolve(chunk.toString()));
});
}
async waitForResponse(id: number, result: unknown): Promise<JsonRecord> {
return this.waitForMessage(
(message) =>
message.id === id && !("method" in message) && isDeepStrictEqual(message.result, result),
`response ${id}`,
);
}
assertNoErrors(): void {
if (this.errors.length > 0) {
throw this.errors[0];
}
}
close(): void {
this.child.stdout.end();
this.child.stderr.end();
this.child.stdin.end();
}
private acceptPaseoOutput(chunk: string, handlers: Record<string, RequestHandler>): void {
this.buffer += chunk;
for (;;) {
const newlineIndex = this.buffer.indexOf("\n");
if (newlineIndex === -1) {
break;
}
const line = this.buffer.slice(0, newlineIndex).trim();
this.buffer = this.buffer.slice(newlineIndex + 1);
if (!line) {
continue;
}
try {
const message = parseJsonLine(line);
if (message) {
this.processPaseoMessage(message, handlers);
}
} catch (error) {
this.errors.push(error instanceof Error ? error : new Error(String(error)));
}
}
}
private processPaseoMessage(message: JsonRecord, handlers: Record<string, RequestHandler>): void {
this.messages.push(message);
for (const waiter of Array.from(this.waiters)) {
if (waiter.predicate(message)) {
this.waiters.delete(waiter);
waiter.resolve(message);
}
}
if (typeof message.id !== "number" || typeof message.method !== "string") {
return;
}
const handler = handlers[message.method];
if (!handler) {
this.errors.push(new Error(`Unexpected Codex app-server request: ${message.method}`));
return;
}
Promise.resolve(handler(message.params))
.then((result) => {
this.writeResponse(message.id as number, result);
return undefined;
})
.catch((error) => {
this.child.stdout.write(
`${JSON.stringify({
id: message.id,
error: { message: error instanceof Error ? error.message : String(error) },
})}\n`,
);
return undefined;
});
}
private waitForMessage(
predicate: (message: JsonRecord) => boolean,
label: string,
): Promise<JsonRecord> {
const existing = this.messages.find(predicate);
if (existing) {
return Promise.resolve(existing);
}
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
this.waiters.delete(waiter);
reject(new Error(`Timed out waiting for ${label}`));
}, 1000);
const waiter = {
predicate,
resolve: (message: JsonRecord) => {
clearTimeout(timeout);
resolve(message);
},
};
this.waiters.add(waiter);
});
}
}

View File

@@ -1,81 +1,28 @@
import { afterEach, describe, expect, test, vi } from "vitest";
vi.mock("@opencode-ai/sdk/v2/client", () => ({
createOpencodeClient: vi.fn(),
}));
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client";
import { describe, expect, test } from "vitest";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import type { AgentStreamEvent } from "../agent-sdk-types.js";
import { OpenCodeAgentClient, OpenCodeServerManager } from "./opencode-agent.js";
import { OpenCodeAgentClient } from "./opencode-agent.js";
import {
createEventStream,
idleEvent,
TestOpenCodeClient,
TestOpenCodeRuntime,
} from "./opencode/test-utils/test-opencode-runtime.js";
interface MockOpenCodeClientOptions {
agents?: unknown[];
events?: unknown[];
}
function createEventStream(events: unknown[]): AsyncGenerator<never> {
return (async function* () {
for (const event of events) {
yield event as never;
}
})();
}
function mockServerManager(): void {
vi.spyOn(OpenCodeServerManager, "getInstance").mockReturnValue({
acquire: vi.fn().mockResolvedValue({
server: { port: 1234, url: "http://127.0.0.1:1234" },
release: vi.fn(),
}),
} as never);
}
function mockOpenCodeClient(options: MockOpenCodeClientOptions = {}) {
const promptAsync = vi.fn().mockResolvedValue({});
const permissionReply = vi.fn().mockResolvedValue({});
const questionReply = vi.fn().mockResolvedValue({});
const questionReject = vi.fn().mockResolvedValue({});
const appAgents = vi.fn().mockResolvedValue({ data: options.agents ?? [] });
const events = options.events ?? [idleEvent()];
const runtime = new TestOpenCodeRuntime();
const openCodeClient = new TestOpenCodeClient();
openCodeClient.appAgentsResponse = { data: options.agents ?? [] };
openCodeClient.eventStream = createEventStream(options.events ?? [idleEvent()]);
runtime.enqueueClient(openCodeClient);
vi.mocked(createOpencodeClient).mockReturnValue({
session: {
create: vi.fn().mockResolvedValue({ data: { id: "session-1" } }),
promptAsync,
abort: vi.fn().mockResolvedValue({}),
update: vi.fn().mockResolvedValue({}),
},
provider: {
list: vi.fn().mockResolvedValue({ data: { connected: [], all: [] } }),
},
event: {
subscribe: vi.fn().mockResolvedValue({ stream: createEventStream(events) }),
},
command: {
list: vi.fn().mockResolvedValue({ data: [] }),
},
app: {
agents: appAgents,
},
permission: {
reply: permissionReply,
},
question: {
reply: questionReply,
reject: questionReject,
},
} as never);
return { appAgents, permissionReply, promptAsync, questionReject, questionReply };
}
function idleEvent(): unknown {
return {
type: "session.idle",
properties: { sessionID: "session-1" },
};
return { openCodeClient, runtime };
}
function toolPermissionEvent(): unknown {
@@ -116,20 +63,15 @@ function questionEvent(): unknown {
}
describe("OpenCode full-access mode", () => {
afterEach(() => {
vi.restoreAllMocks();
});
test("includes virtual full-access mode with dynamic OpenCode agents", async () => {
mockServerManager();
mockOpenCodeClient({
const { runtime } = mockOpenCodeClient({
agents: [
{ name: "build", mode: "primary", hidden: false, description: "Build agent" },
{ name: "paseo-custom", mode: "primary", hidden: false, description: "Custom agent" },
],
});
const client = new OpenCodeAgentClient(createTestLogger());
const client = new OpenCodeAgentClient(createTestLogger(), undefined, undefined, { runtime });
const modes = await client.listModes({ cwd: "/tmp/project", force: false });
expect(modes.map((mode) => mode.id)).toEqual(["build", "plan", "full-access", "paseo-custom"]);
@@ -140,10 +82,9 @@ describe("OpenCode full-access mode", () => {
});
test("reports full-access but sends prompts through OpenCode build agent", async () => {
mockServerManager();
const { promptAsync } = mockOpenCodeClient();
const { openCodeClient, runtime } = mockOpenCodeClient();
const client = new OpenCodeAgentClient(createTestLogger());
const client = new OpenCodeAgentClient(createTestLogger(), undefined, undefined, { runtime });
const session = await client.createSession({
provider: "opencode",
cwd: "/tmp/project",
@@ -154,20 +95,21 @@ describe("OpenCode full-access mode", () => {
await session.run("Implement the change");
expect(promptAsync).toHaveBeenCalledTimes(1);
expect(promptAsync).toHaveBeenCalledWith(expect.objectContaining({ agent: "build" }));
expect(openCodeClient.calls.sessionPromptAsync).toHaveLength(1);
expect(openCodeClient.calls.sessionPromptAsync[0]).toEqual(
expect.objectContaining({ agent: "build" }),
);
await session.close();
});
test("auto-approves tool permissions in full-access without surfacing them", async () => {
mockServerManager();
const { permissionReply } = mockOpenCodeClient({
const { openCodeClient, runtime } = mockOpenCodeClient({
events: [toolPermissionEvent(), idleEvent()],
});
const receivedEvents: AgentStreamEvent[] = [];
const client = new OpenCodeAgentClient(createTestLogger());
const client = new OpenCodeAgentClient(createTestLogger(), undefined, undefined, { runtime });
const session = await client.createSession({
provider: "opencode",
cwd: "/tmp/project",
@@ -177,8 +119,8 @@ describe("OpenCode full-access mode", () => {
await session.run("Run verification");
expect(permissionReply).toHaveBeenCalledTimes(1);
expect(permissionReply).toHaveBeenCalledWith({
expect(openCodeClient.calls.permissionReply).toHaveLength(1);
expect(openCodeClient.calls.permissionReply[0]).toEqual({
requestID: "permission-1",
directory: "/tmp/project",
reply: "once",
@@ -190,13 +132,12 @@ describe("OpenCode full-access mode", () => {
});
test("keeps questions separate from full-access tool auto-approval", async () => {
mockServerManager();
const { permissionReply, questionReply } = mockOpenCodeClient({
const { openCodeClient, runtime } = mockOpenCodeClient({
events: [questionEvent(), idleEvent()],
});
const receivedEvents: AgentStreamEvent[] = [];
const client = new OpenCodeAgentClient(createTestLogger());
const client = new OpenCodeAgentClient(createTestLogger(), undefined, undefined, { runtime });
const session = await client.createSession({
provider: "opencode",
cwd: "/tmp/project",
@@ -221,13 +162,13 @@ describe("OpenCode full-access mode", () => {
updatedInput: { answers: { Decision: "Proceed" } },
});
expect(questionReply).toHaveBeenCalledTimes(1);
expect(questionReply).toHaveBeenCalledWith({
expect(openCodeClient.calls.questionReply).toHaveLength(1);
expect(openCodeClient.calls.questionReply[0]).toEqual({
requestID: "question-1",
directory: "/tmp/project",
answers: [["Proceed"]],
});
expect(permissionReply).not.toHaveBeenCalled();
expect(openCodeClient.calls.permissionReply).toEqual([]);
expect(session.getPendingPermissions()).toEqual([]);
await session.close();

View File

@@ -1,61 +1,46 @@
import { afterEach, expect, test, vi } from "vitest";
vi.mock("@opencode-ai/sdk/v2/client", () => ({
createOpencodeClient: vi.fn(),
}));
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import { OpenCodeAgentClient, OpenCodeServerManager } from "./opencode-agent.js";
import { OpenCodeAgentClient } from "./opencode-agent.js";
import {
TestOpenCodeClient,
TestOpenCodeRuntime,
} from "./opencode/test-utils/test-opencode-runtime.js";
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
test("allows a slow provider.list call to succeed instead of failing after 10 seconds", async () => {
vi.useFakeTimers();
const providerList = vi.fn(
() =>
new Promise((resolve) => {
setTimeout(() => {
resolve({
data: {
connected: ["zai"],
all: [
{
id: "zai",
name: "Z.AI",
models: {
"glm-5.1": {
name: "GLM 5.1",
limit: { context: 128_000 },
},
const runtime = new TestOpenCodeRuntime();
const openCodeClient = new TestOpenCodeClient();
openCodeClient.providerListImplementation = () =>
new Promise((resolve) => {
setTimeout(() => {
resolve({
data: {
connected: ["zai"],
all: [
{
id: "zai",
name: "Z.AI",
models: {
"glm-5.1": {
name: "GLM 5.1",
limit: { context: 128_000 },
},
},
],
},
});
}, 15_000);
}),
);
},
],
},
});
}, 15_000);
});
runtime.enqueueClient(openCodeClient);
vi.mocked(createOpencodeClient).mockReturnValue({
provider: {
list: providerList,
},
} as never);
vi.spyOn(OpenCodeServerManager, "getInstance").mockReturnValue({
acquire: vi.fn().mockResolvedValue({
server: { port: 1234, url: "http://127.0.0.1:1234" },
release: vi.fn(),
}),
} as never);
const client = new OpenCodeAgentClient(createTestLogger());
const client = new OpenCodeAgentClient(createTestLogger(), undefined, undefined, { runtime });
const modelsPromise = client.listModels({ cwd: "/tmp/opencode-models", force: false });
await vi.advanceTimersByTimeAsync(15_000);
@@ -67,28 +52,23 @@ test("allows a slow provider.list call to succeed instead of failing after 10 se
label: "GLM 5.1",
},
]);
expect(openCodeClient.calls.providerList).toHaveLength(1);
});
test("passes explicit refresh force through server acquisition", async () => {
vi.mocked(createOpencodeClient).mockReturnValue({
provider: {
list: vi.fn().mockResolvedValue({
data: {
connected: ["openai"],
all: [{ id: "openai", name: "OpenAI", models: {} }],
},
}),
const runtime = new TestOpenCodeRuntime();
const openCodeClient = new TestOpenCodeClient();
openCodeClient.providerListResponse = {
data: {
connected: ["openai"],
all: [{ id: "openai", name: "OpenAI", models: {} }],
},
} as never);
const acquire = vi.fn().mockResolvedValue({
server: { port: 1234, url: "http://127.0.0.1:1234" },
release: vi.fn(),
});
vi.spyOn(OpenCodeServerManager, "getInstance").mockReturnValue({ acquire } as never);
};
runtime.enqueueClient(openCodeClient);
const client = new OpenCodeAgentClient(createTestLogger());
const client = new OpenCodeAgentClient(createTestLogger(), undefined, undefined, { runtime });
await client.listModels({ cwd: "/tmp/opencode-models", force: true });
expect(acquire).toHaveBeenCalledWith({ force: true });
expect(runtime.acquisitions).toEqual([{ force: true, releaseCount: 1 }]);
});

View File

@@ -1,13 +1,11 @@
import { afterEach, describe, expect, test, vi } from "vitest";
vi.mock("@opencode-ai/sdk/v2/client", () => ({
createOpencodeClient: vi.fn(),
}));
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client";
import { describe, expect, test } from "vitest";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import { OpenCodeAgentClient, OpenCodeServerManager } from "./opencode-agent.js";
import { OpenCodeAgentClient } from "./opencode-agent.js";
import {
TestOpenCodeClient,
TestOpenCodeRuntime,
} from "./opencode/test-utils/test-opencode-runtime.js";
function createDeferred<T>(): {
promise: Promise<T>;
@@ -24,39 +22,12 @@ function createDeferred<T>(): {
}
describe("OpenCodeAgentSession slash command timeout handling", () => {
afterEach(() => {
vi.restoreAllMocks();
});
test("lists only OpenCode built-in slash commands Paseo can execute", async () => {
vi.mocked(createOpencodeClient).mockReturnValue({
session: {
create: vi.fn().mockResolvedValue({ data: { id: "session-1" } }),
},
provider: {
list: vi.fn().mockResolvedValue({
data: {
connected: ["openai"],
all: [{ id: "openai", name: "OpenAI", models: {} }],
},
}),
},
command: {
list: vi.fn().mockResolvedValue({ data: [] }),
},
app: {
agents: vi.fn().mockResolvedValue({ data: [] }),
},
} as never);
const runtime = new TestOpenCodeRuntime();
const openCodeClient = createOpenCodeClientWithConnectedProvider();
runtime.enqueueClient(openCodeClient);
vi.spyOn(OpenCodeServerManager, "getInstance").mockReturnValue({
acquire: vi.fn().mockResolvedValue({
server: { port: 1234, url: "http://127.0.0.1:1234" },
release: vi.fn(),
}),
} as never);
const client = new OpenCodeAgentClient(createTestLogger());
const client = new OpenCodeAgentClient(createTestLogger(), undefined, undefined, { runtime });
const session = await client.createSession({ provider: "opencode", cwd: "/tmp" });
await expect(session.listCommands?.()).resolves.toEqual(
@@ -72,49 +43,11 @@ describe("OpenCodeAgentSession slash command timeout handling", () => {
});
test("executes compact through the OpenCode summarize endpoint", async () => {
const command = vi.fn();
const summarize = vi.fn().mockResolvedValue({ data: {} });
const runtime = new TestOpenCodeRuntime();
const openCodeClient = createOpenCodeClientWithConnectedProvider();
runtime.enqueueClient(openCodeClient);
vi.mocked(createOpencodeClient).mockReturnValue({
session: {
create: vi.fn().mockResolvedValue({ data: { id: "session-1" } }),
command,
summarize,
},
provider: {
list: vi.fn().mockResolvedValue({
data: {
connected: ["openai"],
all: [{ id: "openai", name: "OpenAI", models: {} }],
},
}),
},
event: {
subscribe: vi.fn().mockResolvedValue({
stream: (async function* () {
yield {
type: "session.idle",
properties: { sessionID: "session-1" },
};
})(),
}),
},
command: {
list: vi.fn().mockResolvedValue({ data: [] }),
},
app: {
agents: vi.fn().mockResolvedValue({ data: [] }),
},
} as never);
vi.spyOn(OpenCodeServerManager, "getInstance").mockReturnValue({
acquire: vi.fn().mockResolvedValue({
server: { port: 1234, url: "http://127.0.0.1:1234" },
release: vi.fn(),
}),
} as never);
const client = new OpenCodeAgentClient(createTestLogger());
const client = new OpenCodeAgentClient(createTestLogger(), undefined, undefined, { runtime });
const session = await client.createSession({ provider: "opencode", cwd: "/tmp" });
await expect(session.run("/compact")).resolves.toMatchObject({
@@ -123,55 +56,30 @@ describe("OpenCodeAgentSession slash command timeout handling", () => {
timeline: [],
usage: undefined,
});
expect(summarize).toHaveBeenCalledWith({ sessionID: "session-1", directory: "/tmp" });
expect(command).not.toHaveBeenCalled();
expect(openCodeClient.calls.sessionSummarize).toEqual([
{ sessionID: "session-1", directory: "/tmp" },
]);
expect(openCodeClient.calls.sessionCommand).toEqual([]);
});
test("waits for SSE completion when slash commands hit a header timeout", async () => {
const idleEventGate = createDeferred<void>();
const runtime = new TestOpenCodeRuntime();
const openCodeClient = createOpenCodeClientWithConnectedProvider();
openCodeClient.sessionCommandError = new Error("fetch failed: Headers Timeout Error");
openCodeClient.commandListResponse = {
data: [{ name: "help", description: "Show help", hints: [] }],
};
openCodeClient.eventStream = (async function* () {
await idleEventGate.promise;
yield {
type: "session.idle",
properties: { sessionID: "session-1" },
};
})();
runtime.enqueueClient(openCodeClient);
vi.mocked(createOpencodeClient).mockReturnValue({
session: {
create: vi.fn().mockResolvedValue({ data: { id: "session-1" } }),
command: vi.fn().mockRejectedValue(new Error("fetch failed: Headers Timeout Error")),
},
provider: {
list: vi.fn().mockResolvedValue({
data: {
connected: ["openai"],
all: [{ id: "openai", name: "OpenAI", models: {} }],
},
}),
},
event: {
subscribe: vi.fn().mockResolvedValue({
stream: (async function* () {
await idleEventGate.promise;
yield {
type: "session.idle",
properties: { sessionID: "session-1" },
};
})(),
}),
},
command: {
list: vi.fn().mockResolvedValue({
data: [{ name: "help", description: "Show help", hints: [] }],
}),
},
app: {
agents: vi.fn().mockResolvedValue({ data: [] }),
},
} as never);
vi.spyOn(OpenCodeServerManager, "getInstance").mockReturnValue({
acquire: vi.fn().mockResolvedValue({
server: { port: 1234, url: "http://127.0.0.1:1234" },
release: vi.fn(),
}),
} as never);
const client = new OpenCodeAgentClient(createTestLogger());
const client = new OpenCodeAgentClient(createTestLogger(), undefined, undefined, { runtime });
const session = await client.createSession({ provider: "opencode", cwd: "/tmp" });
const runPromise = session.run("/help");
@@ -186,3 +94,14 @@ describe("OpenCodeAgentSession slash command timeout handling", () => {
});
});
});
function createOpenCodeClientWithConnectedProvider(): TestOpenCodeClient {
const openCodeClient = new TestOpenCodeClient();
openCodeClient.providerListResponse = {
data: {
connected: ["openai"],
all: [{ id: "openai", name: "OpenAI", models: {} }],
},
};
return openCodeClient;
}

View File

@@ -1,9 +1,7 @@
import type { ChildProcess } from "node:child_process";
import { readdir, readFile } from "node:fs/promises";
import { homedir } from "node:os";
import path from "node:path";
import {
createOpencodeClient,
type AssistantMessage as OpenCodeAssistantMessage,
type Event as OpenCodeEvent,
type FilePartInput as OpenCodeFilePartInput,
@@ -11,7 +9,7 @@ import {
type Part as OpenCodePart,
type TextPartInput as OpenCodeTextPartInput,
} from "@opencode-ai/sdk/v2/client";
import net from "node:net";
import { findExecutable, isCommandAvailable } from "../../../utils/executable.js";
import type { Logger } from "pino";
import { z } from "zod";
@@ -43,17 +41,12 @@ import type {
ToolCallDetail,
ToolCallTimelineItem,
} from "../agent-sdk-types.js";
import {
createProviderEnvSpec,
resolveProviderCommandPrefix,
type ProviderRuntimeSettings,
} from "../provider-launch-config.js";
import { findExecutable, isCommandAvailable } from "../../../utils/executable.js";
import { terminateWithTreeKill } from "../../../utils/tree-kill.js";
import { createProviderEnvSpec, type ProviderRuntimeSettings } from "../provider-launch-config.js";
import { withTimeout } from "../../../utils/promise-timeout.js";
import { execCommand, spawnProcess } from "../../../utils/spawn.js";
import { execCommand } from "../../../utils/spawn.js";
import { buildToolCallDisplayModel } from "../../../shared/tool-call-display.js";
import { mapOpencodeToolCall } from "./opencode/tool-call-mapper.js";
import { OpenCodeServerManager } from "./opencode/server-manager.js";
import {
formatDiagnosticStatus,
formatProviderDiagnostic,
@@ -63,6 +56,11 @@ import {
} from "./diagnostic-utils.js";
import { runProviderTurn } from "./provider-runner.js";
import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
import {
createSdkOpenCodeClient,
type OpenCodeRuntime,
type OpenCodeServerAcquisition,
} from "./opencode/runtime.js";
const OPENCODE_CAPABILITIES: AgentCapabilityFlags = {
supportsStreaming: true,
@@ -159,8 +157,6 @@ type OpenCodeMcpConfig =
const MCP_ALREADY_PRESENT_ERROR_TOKENS = ["already", "exists", "connected"] as const;
const OPENCODE_PROVIDER_LIST_TIMEOUT_MS = 30_000;
const OPENCODE_SERVER_GRACEFUL_SHUTDOWN_TIMEOUT_MS = 5_000;
const OPENCODE_SERVER_FORCE_SHUTDOWN_TIMEOUT_MS = 1_000;
const OPENCODE_HANDLED_BUILTIN_SLASH_COMMANDS: AgentSlashCommand[] = [
{ name: "compact", description: "Compact the current session", argumentHint: "" },
{ name: "summarize", description: "Compact the current session", argumentHint: "" },
@@ -251,16 +247,6 @@ const OpencodeToolPartToTimelineItemSchema = OpencodeToolPartTimelineEnvelopeSch
}),
);
async function resolveOpenCodeBinary(): Promise<string> {
const found = await findExecutable("opencode");
if (found) {
return found;
}
throw new Error(
"OpenCode binary not found. Install OpenCode (https://github.com/opencode-ai/opencode) and ensure it is available in your shell PATH.",
);
}
function toOpenCodeMcpConfig(config: McpServerConfig): OpenCodeMcpConfig {
if (config.type === "stdio") {
return {
@@ -414,22 +400,6 @@ function isAlreadyPresentMcpError(error: unknown): boolean {
return MCP_ALREADY_PRESENT_ERROR_TOKENS.some((token) => normalized.includes(token));
}
async function findAvailablePort(): Promise<number> {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.listen(0, () => {
const address = server.address();
if (address && typeof address === "object") {
const port = address.port;
server.close(() => resolve(port));
} else {
server.close(() => reject(new Error("Failed to get port")));
}
});
server.on("error", reject);
});
}
function resolvePartDedupeKey(
part: { id: string; messageID: string },
partType: "text" | "reasoning",
@@ -928,299 +898,54 @@ export const __openCodeInternals = {
},
};
export class OpenCodeServerManager {
private static instance: OpenCodeServerManager | null = null;
private static exitHandlerRegistered = false;
private currentServer: OpenCodeServerGeneration | null = null;
private retiredServers = new Set<OpenCodeServerGeneration>();
private startPromise: Promise<OpenCodeServerGeneration> | null = null;
private forcedRefreshPromise: Promise<OpenCodeServerGeneration> | null = null;
private readonly logger: Logger;
private readonly runtimeSettings?: ProviderRuntimeSettings;
private readonly runtimeSettingsKey: string;
interface OpenCodeAgentClientDeps {
runtime?: OpenCodeRuntime;
}
private constructor(logger: Logger, runtimeSettings?: ProviderRuntimeSettings) {
this.logger = logger;
this.runtimeSettings = runtimeSettings;
this.runtimeSettingsKey = JSON.stringify(runtimeSettings ?? {});
class ProductionOpenCodeRuntime implements OpenCodeRuntime {
constructor(private readonly serverManager: OpenCodeServerManager) {}
async acquireServer(options: { force: boolean }): Promise<OpenCodeServerAcquisition> {
return this.serverManager.acquire(options);
}
static getInstance(
logger: Logger,
runtimeSettings?: ProviderRuntimeSettings,
): OpenCodeServerManager {
const nextSettingsKey = JSON.stringify(runtimeSettings ?? {});
if (!OpenCodeServerManager.instance) {
OpenCodeServerManager.instance = new OpenCodeServerManager(logger, runtimeSettings);
OpenCodeServerManager.registerExitHandler();
} else if (OpenCodeServerManager.instance.runtimeSettingsKey !== nextSettingsKey) {
logger.warn(
{
existingRuntimeSettings: OpenCodeServerManager.instance.runtimeSettingsKey,
requestedRuntimeSettings: nextSettingsKey,
},
"OpenCode server manager already initialized with different runtime settings",
);
}
return OpenCodeServerManager.instance;
async ensureServerRunning(): Promise<{ port: number; url: string }> {
return this.serverManager.ensureRunning();
}
private static registerExitHandler(): void {
if (OpenCodeServerManager.exitHandlerRegistered) {
return;
}
OpenCodeServerManager.exitHandlerRegistered = true;
const cleanup = () => {
const instance = OpenCodeServerManager.instance;
void instance?.shutdown();
};
process.on("exit", cleanup);
process.on("SIGTERM", cleanup);
process.on("SIGINT", cleanup);
}
async ensureRunning(): Promise<{ port: number; url: string }> {
const acquisition = await this.acquire({ force: false });
acquisition.release();
return acquisition.server;
}
async acquire(options: { force: boolean }): Promise<{
server: { port: number; url: string };
release: () => void;
}> {
const server = options.force
? await this.getForcedRefreshServer()
: await this.getCurrentServer();
server.refCount += 1;
let released = false;
return {
server: { port: server.port, url: server.url },
release: () => {
if (released) {
return;
}
released = true;
server.refCount -= 1;
this.cleanupRetiredServers();
},
};
}
private async getForcedRefreshServer(): Promise<OpenCodeServerGeneration> {
if (this.forcedRefreshPromise) {
return this.forcedRefreshPromise;
}
this.forcedRefreshPromise = Promise.resolve()
.then(async () => {
await this.rotateCurrentServer();
return this.getCurrentServer();
})
.finally(() => {
this.forcedRefreshPromise = null;
});
return this.forcedRefreshPromise;
}
private async getCurrentServer(): Promise<OpenCodeServerGeneration> {
if (this.startPromise) {
return this.startPromise;
}
if (this.currentServer && !this.currentServer.process.killed) {
return this.currentServer;
}
this.startPromise = this.startServer();
try {
const result = await this.startPromise;
if (!result.retired) {
this.currentServer = result;
}
return result;
} finally {
this.startPromise = null;
}
}
private async rotateCurrentServer(): Promise<void> {
const existing = this.currentServer;
if (existing) {
existing.retired = true;
this.retiredServers.add(existing);
this.currentServer = null;
this.cleanupRetiredServers();
}
if (this.startPromise) {
const pending = await this.startPromise;
pending.retired = true;
this.retiredServers.add(pending);
this.currentServer = null;
this.cleanupRetiredServers();
}
}
private async startServer(): Promise<OpenCodeServerGeneration> {
const port = await findAvailablePort();
const url = `http://127.0.0.1:${port}`;
const launchPrefix = await resolveProviderCommandPrefix(
this.runtimeSettings?.command,
resolveOpenCodeBinary,
);
return new Promise((resolve, reject) => {
const serverProcess = spawnProcess(
launchPrefix.command,
[...launchPrefix.args, "serve", "--port", String(port)],
{
detached: process.platform !== "win32",
stdio: ["ignore", "pipe", "pipe"],
...createProviderEnvSpec({ runtimeSettings: this.runtimeSettings }),
},
);
let started = false;
let stderrBuffer = "";
let stdoutBuffer = "";
const STARTUP_BUFFER_CAP = 8192;
const appendCapped = (current: string, chunk: string): string => {
if (current.length >= STARTUP_BUFFER_CAP) {
return current;
}
const remaining = STARTUP_BUFFER_CAP - current.length;
return current + chunk.slice(0, remaining);
};
const buildStartupErrorMessage = (headline: string): string => {
const sections = [headline];
const stderrTrimmed = stderrBuffer.trim();
if (stderrTrimmed.length > 0) {
sections.push(`stderr: ${stderrTrimmed}`);
}
const stdoutTrimmed = stdoutBuffer.trim();
if (stdoutTrimmed.length > 0) {
sections.push(`stdout: ${stdoutTrimmed}`);
}
return sections.join("\n");
};
const timeout = setTimeout(() => {
if (!started) {
reject(new Error(buildStartupErrorMessage("OpenCode server startup timeout")));
}
}, 30_000);
serverProcess.stdout?.on("data", (data: Buffer) => {
const output = data.toString();
stdoutBuffer = appendCapped(stdoutBuffer, output);
if (output.includes("listening on") && !started) {
started = true;
clearTimeout(timeout);
resolve({
process: serverProcess,
port,
url,
refCount: 0,
retired: false,
});
}
});
serverProcess.stderr?.on("data", (data: Buffer) => {
const output = data.toString();
stderrBuffer = appendCapped(stderrBuffer, output);
this.logger.error({ stderr: output.trim() }, "OpenCode server stderr");
});
serverProcess.on("error", (error) => {
clearTimeout(timeout);
const headline = error instanceof Error ? error.message : String(error);
reject(new Error(buildStartupErrorMessage(headline)));
});
serverProcess.on("exit", (code) => {
if (!started) {
clearTimeout(timeout);
reject(new Error(buildStartupErrorMessage(`OpenCode server exited with code ${code}`)));
}
if (this.currentServer?.process === serverProcess) {
this.currentServer = null;
}
for (const retired of Array.from(this.retiredServers)) {
if (retired.process === serverProcess) {
this.retiredServers.delete(retired);
}
}
});
});
createClient(options: { baseUrl: string; directory: string }): OpencodeClient {
return createSdkOpenCodeClient(options);
}
async shutdown(): Promise<void> {
const servers = [
...(this.currentServer ? [this.currentServer] : []),
...Array.from(this.retiredServers),
];
await Promise.all(servers.map((server) => this.killServer(server)));
this.currentServer = null;
this.retiredServers.clear();
await this.serverManager.shutdown();
}
private cleanupRetiredServers(): void {
for (const server of Array.from(this.retiredServers)) {
if (server.refCount === 0) {
this.retiredServers.delete(server);
void this.killServer(server);
}
}
}
private async killServer(server: OpenCodeServerGeneration): Promise<void> {
if (server.process.killed) {
return;
}
const result = await terminateWithTreeKill(server.process, {
gracefulTimeoutMs: OPENCODE_SERVER_GRACEFUL_SHUTDOWN_TIMEOUT_MS,
forceTimeoutMs: OPENCODE_SERVER_FORCE_SHUTDOWN_TIMEOUT_MS,
onForceSignal: () => {
this.logger.warn(
{ timeoutMs: OPENCODE_SERVER_GRACEFUL_SHUTDOWN_TIMEOUT_MS },
"OpenCode server did not exit after SIGTERM; sending SIGKILL",
);
},
});
if (result === "kill-timeout") {
this.logger.warn(
{ timeoutMs: OPENCODE_SERVER_FORCE_SHUTDOWN_TIMEOUT_MS },
"OpenCode server did not report exit after SIGKILL",
);
}
}
}
interface OpenCodeServerGeneration {
process: ChildProcess;
port: number;
url: string;
refCount: number;
retired: boolean;
}
export class OpenCodeAgentClient implements AgentClient {
readonly provider = "opencode" as const;
readonly capabilities = OPENCODE_CAPABILITIES;
private readonly serverManager: OpenCodeServerManager;
private readonly runtime: OpenCodeRuntime;
private readonly logger: Logger;
private readonly runtimeSettings?: ProviderRuntimeSettings;
private readonly modelContextWindows = new Map<string, number>();
private readonly storageRoot: string;
constructor(logger: Logger, runtimeSettings?: ProviderRuntimeSettings, storageRoot?: string) {
constructor(
logger: Logger,
runtimeSettings?: ProviderRuntimeSettings,
storageRoot?: string,
deps: OpenCodeAgentClientDeps = {},
) {
this.logger = logger.child({ module: "agent", provider: "opencode" });
this.runtimeSettings = runtimeSettings;
this.storageRoot = storageRoot ?? resolveOpenCodeStorageRoot();
this.serverManager = OpenCodeServerManager.getInstance(this.logger, runtimeSettings);
this.runtime =
deps.runtime ??
new ProductionOpenCodeRuntime(
OpenCodeServerManager.getInstance(this.logger, runtimeSettings),
);
}
async createSession(
@@ -1229,9 +954,9 @@ export class OpenCodeAgentClient implements AgentClient {
options?: AgentCreateSessionOptions,
): Promise<AgentSession> {
const openCodeConfig = this.assertConfig(config);
const acquisition = await this.serverManager.acquire({ force: false });
const acquisition = await this.runtime.acquireServer({ force: false });
const { url } = acquisition.server;
const client = createOpencodeClient({
const client = this.runtime.createClient({
baseUrl: url,
directory: openCodeConfig.cwd,
});
@@ -1285,9 +1010,9 @@ export class OpenCodeAgentClient implements AgentClient {
...overrides,
};
const openCodeConfig = this.assertConfig(config);
const acquisition = await this.serverManager.acquire({ force: false });
const acquisition = await this.runtime.acquireServer({ force: false });
const { url } = acquisition.server;
const client = createOpencodeClient({
const client = this.runtime.createClient({
baseUrl: url,
directory: openCodeConfig.cwd,
});
@@ -1310,9 +1035,9 @@ export class OpenCodeAgentClient implements AgentClient {
}
async listModels(options: ListModelsOptions): Promise<AgentModelDefinition[]> {
const acquisition = await this.serverManager.acquire({ force: options.force });
const acquisition = await this.runtime.acquireServer({ force: options.force });
const { url } = acquisition.server;
const client = createOpencodeClient({
const client = this.runtime.createClient({
baseUrl: url,
directory: options.cwd,
});
@@ -1373,10 +1098,10 @@ export class OpenCodeAgentClient implements AgentClient {
}
async listModes(options: ListModesOptions): Promise<AgentMode[]> {
const acquisition = await this.serverManager.acquire({ force: options.force });
const acquisition = await this.runtime.acquireServer({ force: options.force });
const { url } = acquisition.server;
const directory = options.cwd;
const client = createOpencodeClient({ baseUrl: url, directory });
const client = this.runtime.createClient({ baseUrl: url, directory });
try {
const response = await withTimeout(
@@ -1427,7 +1152,7 @@ export class OpenCodeAgentClient implements AgentClient {
let status = formatDiagnosticStatus(available);
try {
const { url } = await this.serverManager.ensureRunning();
const { url } = await this.runtime.ensureServerRunning();
serverStatus = `Running (${url})`;
} catch (error) {
serverStatus = `Unavailable (${toDiagnosticErrorMessage(error)})`;

View File

@@ -2,20 +2,14 @@ import { EventEmitter } from "node:events";
import { describe, expect, test, vi } from "vitest";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import { OpenCodeServerManager } from "./opencode-agent.js";
import { OpenCodeServerManager, type OpenCodeServerGeneration } from "./opencode/server-manager.js";
type FakeServerProcess = EventEmitter & {
killed: boolean;
kill: ReturnType<typeof vi.fn>;
};
interface FakeGeneration {
process: FakeServerProcess;
port: number;
url: string;
refCount: number;
retired: boolean;
}
type FakeGeneration = OpenCodeServerGeneration & { process: FakeServerProcess };
describe("OpenCodeServerManager generations", () => {
test("rotation creates a new current server without killing a referenced old server", async () => {

View File

@@ -0,0 +1,24 @@
import {
createOpencodeClient,
type OpencodeClient,
type OpencodeClientConfig,
} from "@opencode-ai/sdk/v2/client";
export interface OpenCodeServerAcquisition {
server: { port: number; url: string };
release: () => void;
}
export interface OpenCodeRuntime {
acquireServer(options: { force: boolean }): Promise<OpenCodeServerAcquisition>;
ensureServerRunning(): Promise<{ port: number; url: string }>;
createClient(options: { baseUrl: string; directory: string }): OpencodeClient;
shutdown(): Promise<void>;
}
export function createSdkOpenCodeClient(options: {
baseUrl: string;
directory: string;
}): OpencodeClient {
return createOpencodeClient(options satisfies OpencodeClientConfig & { directory: string });
}

View File

@@ -0,0 +1,327 @@
import type { ChildProcess } from "node:child_process";
import net from "node:net";
import type { Logger } from "pino";
import { findExecutable } from "../../../../utils/executable.js";
import { spawnProcess } from "../../../../utils/spawn.js";
import { terminateWithTreeKill } from "../../../../utils/tree-kill.js";
import {
createProviderEnvSpec,
resolveProviderCommandPrefix,
type ProviderRuntimeSettings,
} from "../../provider-launch-config.js";
const OPENCODE_SERVER_GRACEFUL_SHUTDOWN_TIMEOUT_MS = 5_000;
const OPENCODE_SERVER_FORCE_SHUTDOWN_TIMEOUT_MS = 1_000;
export interface OpenCodeServerAcquisition {
server: { port: number; url: string };
release: () => void;
}
export interface OpenCodeServerManagerLike {
ensureRunning(): Promise<{ port: number; url: string }>;
acquire(options: { force: boolean }): Promise<OpenCodeServerAcquisition>;
}
export interface OpenCodeServerGeneration {
process: ChildProcess;
port: number;
url: string;
refCount: number;
retired: boolean;
}
export class OpenCodeServerManager implements OpenCodeServerManagerLike {
private static instance: OpenCodeServerManager | null = null;
private static exitHandlerRegistered = false;
private currentServer: OpenCodeServerGeneration | null = null;
private retiredServers = new Set<OpenCodeServerGeneration>();
private startPromise: Promise<OpenCodeServerGeneration> | null = null;
private forcedRefreshPromise: Promise<OpenCodeServerGeneration> | null = null;
private readonly logger: Logger;
private readonly runtimeSettings?: ProviderRuntimeSettings;
private readonly runtimeSettingsKey: string;
private constructor(logger: Logger, runtimeSettings?: ProviderRuntimeSettings) {
this.logger = logger;
this.runtimeSettings = runtimeSettings;
this.runtimeSettingsKey = JSON.stringify(runtimeSettings ?? {});
}
static getInstance(
logger: Logger,
runtimeSettings?: ProviderRuntimeSettings,
): OpenCodeServerManager {
const nextSettingsKey = JSON.stringify(runtimeSettings ?? {});
if (!OpenCodeServerManager.instance) {
OpenCodeServerManager.instance = new OpenCodeServerManager(logger, runtimeSettings);
OpenCodeServerManager.registerExitHandler();
} else if (OpenCodeServerManager.instance.runtimeSettingsKey !== nextSettingsKey) {
logger.warn(
{
existingRuntimeSettings: OpenCodeServerManager.instance.runtimeSettingsKey,
requestedRuntimeSettings: nextSettingsKey,
},
"OpenCode server manager already initialized with different runtime settings",
);
}
return OpenCodeServerManager.instance;
}
private static registerExitHandler(): void {
if (OpenCodeServerManager.exitHandlerRegistered) {
return;
}
OpenCodeServerManager.exitHandlerRegistered = true;
const cleanup = () => {
const instance = OpenCodeServerManager.instance;
void instance?.shutdown();
};
process.on("exit", cleanup);
process.on("SIGTERM", cleanup);
process.on("SIGINT", cleanup);
}
async ensureRunning(): Promise<{ port: number; url: string }> {
const acquisition = await this.acquire({ force: false });
acquisition.release();
return acquisition.server;
}
async acquire(options: { force: boolean }): Promise<OpenCodeServerAcquisition> {
const server = options.force
? await this.getForcedRefreshServer()
: await this.getCurrentServer();
server.refCount += 1;
let released = false;
return {
server: { port: server.port, url: server.url },
release: () => {
if (released) {
return;
}
released = true;
server.refCount -= 1;
this.cleanupRetiredServers();
},
};
}
private async getForcedRefreshServer(): Promise<OpenCodeServerGeneration> {
if (this.forcedRefreshPromise) {
return this.forcedRefreshPromise;
}
this.forcedRefreshPromise = Promise.resolve()
.then(async () => {
await this.rotateCurrentServer();
return this.getCurrentServer();
})
.finally(() => {
this.forcedRefreshPromise = null;
});
return this.forcedRefreshPromise;
}
private async getCurrentServer(): Promise<OpenCodeServerGeneration> {
if (this.startPromise) {
return this.startPromise;
}
if (this.currentServer && !this.currentServer.process.killed) {
return this.currentServer;
}
this.startPromise = this.startServer();
try {
const result = await this.startPromise;
if (!result.retired) {
this.currentServer = result;
}
return result;
} finally {
this.startPromise = null;
}
}
private async rotateCurrentServer(): Promise<void> {
const existing = this.currentServer;
if (existing) {
existing.retired = true;
this.retiredServers.add(existing);
this.currentServer = null;
this.cleanupRetiredServers();
}
if (this.startPromise) {
const pending = await this.startPromise;
pending.retired = true;
this.retiredServers.add(pending);
this.currentServer = null;
this.cleanupRetiredServers();
}
}
private async startServer(): Promise<OpenCodeServerGeneration> {
const port = await findAvailablePort();
const url = `http://127.0.0.1:${port}`;
const launchPrefix = await resolveProviderCommandPrefix(
this.runtimeSettings?.command,
resolveOpenCodeBinary,
);
return new Promise((resolve, reject) => {
const serverProcess = spawnProcess(
launchPrefix.command,
[...launchPrefix.args, "serve", "--port", String(port)],
{
detached: process.platform !== "win32",
stdio: ["ignore", "pipe", "pipe"],
...createProviderEnvSpec({ runtimeSettings: this.runtimeSettings }),
},
);
let started = false;
let stderrBuffer = "";
let stdoutBuffer = "";
const STARTUP_BUFFER_CAP = 8192;
const appendCapped = (current: string, chunk: string): string => {
if (current.length >= STARTUP_BUFFER_CAP) {
return current;
}
const remaining = STARTUP_BUFFER_CAP - current.length;
return current + chunk.slice(0, remaining);
};
const buildStartupErrorMessage = (headline: string): string => {
const sections = [headline];
const stderrTrimmed = stderrBuffer.trim();
if (stderrTrimmed.length > 0) {
sections.push(`stderr: ${stderrTrimmed}`);
}
const stdoutTrimmed = stdoutBuffer.trim();
if (stdoutTrimmed.length > 0) {
sections.push(`stdout: ${stdoutTrimmed}`);
}
return sections.join("\n");
};
const timeout = setTimeout(() => {
if (!started) {
reject(new Error(buildStartupErrorMessage("OpenCode server startup timeout")));
}
}, 30_000);
serverProcess.stdout?.on("data", (data: Buffer) => {
const output = data.toString();
stdoutBuffer = appendCapped(stdoutBuffer, output);
if (output.includes("listening on") && !started) {
started = true;
clearTimeout(timeout);
resolve({
process: serverProcess,
port,
url,
refCount: 0,
retired: false,
});
}
});
serverProcess.stderr?.on("data", (data: Buffer) => {
const output = data.toString();
stderrBuffer = appendCapped(stderrBuffer, output);
this.logger.error({ stderr: output.trim() }, "OpenCode server stderr");
});
serverProcess.on("error", (error) => {
clearTimeout(timeout);
const headline = error instanceof Error ? error.message : String(error);
reject(new Error(buildStartupErrorMessage(headline)));
});
serverProcess.on("exit", (code) => {
if (!started) {
clearTimeout(timeout);
reject(new Error(buildStartupErrorMessage(`OpenCode server exited with code ${code}`)));
}
if (this.currentServer?.process === serverProcess) {
this.currentServer = null;
}
for (const retired of Array.from(this.retiredServers)) {
if (retired.process === serverProcess) {
this.retiredServers.delete(retired);
}
}
});
});
}
async shutdown(): Promise<void> {
const servers = [
...(this.currentServer ? [this.currentServer] : []),
...Array.from(this.retiredServers),
];
await Promise.all(servers.map((server) => this.killServer(server)));
this.currentServer = null;
this.retiredServers.clear();
}
private cleanupRetiredServers(): void {
for (const server of Array.from(this.retiredServers)) {
if (server.refCount === 0) {
this.retiredServers.delete(server);
void this.killServer(server);
}
}
}
private async killServer(server: OpenCodeServerGeneration): Promise<void> {
if (server.process.killed) {
return;
}
const result = await terminateWithTreeKill(server.process, {
gracefulTimeoutMs: OPENCODE_SERVER_GRACEFUL_SHUTDOWN_TIMEOUT_MS,
forceTimeoutMs: OPENCODE_SERVER_FORCE_SHUTDOWN_TIMEOUT_MS,
onForceSignal: () => {
this.logger.warn(
{ timeoutMs: OPENCODE_SERVER_GRACEFUL_SHUTDOWN_TIMEOUT_MS },
"OpenCode server did not exit after SIGTERM; sending SIGKILL",
);
},
});
if (result === "kill-timeout") {
this.logger.warn(
{ timeoutMs: OPENCODE_SERVER_FORCE_SHUTDOWN_TIMEOUT_MS },
"OpenCode server did not report exit after SIGKILL",
);
}
}
}
async function resolveOpenCodeBinary(): Promise<string> {
const found = await findExecutable("opencode");
if (found) {
return found;
}
throw new Error(
"OpenCode binary not found. Install OpenCode (https://github.com/opencode-ai/opencode) and ensure it is available in your shell PATH.",
);
}
function findAvailablePort(): Promise<number> {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.listen(0, "127.0.0.1", () => {
const address = server.address();
server.close(() => {
if (typeof address === "object" && address) {
resolve(address.port);
} else {
reject(new Error("Failed to allocate port"));
}
});
});
server.on("error", reject);
});
}

View File

@@ -0,0 +1,35 @@
import type { OpenCodeServerAcquisition, OpenCodeServerManagerLike } from "./server-manager.js";
export interface TestOpenCodeServerAcquisition {
force: boolean;
released: boolean;
}
export class TestOpenCodeServerManager implements OpenCodeServerManagerLike {
readonly acquisitions: TestOpenCodeServerAcquisition[] = [];
readonly server = { port: 1234, url: "http://127.0.0.1:1234" };
ensureRunningCount = 0;
async ensureRunning(): Promise<{ port: number; url: string }> {
this.ensureRunningCount += 1;
return this.server;
}
async acquire(options: { force: boolean }): Promise<OpenCodeServerAcquisition> {
const acquisition: TestOpenCodeServerAcquisition = {
force: options.force,
released: false,
};
this.acquisitions.push(acquisition);
return {
server: this.server,
release: () => {
acquisition.released = true;
},
};
}
}
export function createTestOpenCodeServerManager(): TestOpenCodeServerManager {
return new TestOpenCodeServerManager();
}

View File

@@ -0,0 +1,184 @@
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client";
import type { OpenCodeRuntime, OpenCodeServerAcquisition } from "../runtime.js";
interface OpenCodeResponse {
data?: unknown;
error?: unknown;
}
export class TestOpenCodeRuntime implements OpenCodeRuntime {
readonly acquisitions: Array<{ force: boolean; releaseCount: number }> = [];
readonly clientCreations: Array<{ baseUrl: string; directory: string }> = [];
private readonly clients: TestOpenCodeClient[] = [];
server = { port: 1234, url: "http://127.0.0.1:1234" };
enqueueClient(client: TestOpenCodeClient): void {
this.clients.push(client);
}
async acquireServer(options: { force: boolean }): Promise<OpenCodeServerAcquisition> {
const acquisition = { force: options.force, releaseCount: 0 };
this.acquisitions.push(acquisition);
return {
server: this.server,
release: () => {
acquisition.releaseCount += 1;
},
};
}
async ensureServerRunning(): Promise<{ port: number; url: string }> {
return this.server;
}
createClient(options: { baseUrl: string; directory: string }): OpencodeClient {
this.clientCreations.push(options);
const client = this.clients.shift() ?? new TestOpenCodeClient();
return client.asSdkClient();
}
async shutdown(): Promise<void> {}
}
export class TestOpenCodeClient {
readonly calls = {
appAgents: [] as unknown[],
commandList: [] as unknown[],
eventSubscribe: [] as unknown[],
permissionReply: [] as unknown[],
providerList: [] as unknown[],
questionReject: [] as unknown[],
questionReply: [] as unknown[],
sessionAbort: [] as unknown[],
sessionCommand: [] as unknown[],
sessionCreate: [] as unknown[],
sessionDelete: [] as unknown[],
sessionMessages: [] as unknown[],
sessionPromptAsync: [] as unknown[],
sessionSummarize: [] as unknown[],
sessionUpdate: [] as unknown[],
};
appAgentsResponse: OpenCodeResponse = { data: [] };
commandListResponse: OpenCodeResponse = { data: [] };
eventStream: AsyncIterable<unknown> = createEventStream([idleEvent()]);
permissionReplyResponse: OpenCodeResponse = {};
providerListResponse: OpenCodeResponse = { data: { connected: [], all: [] } };
providerListImplementation: (() => Promise<OpenCodeResponse>) | null = null;
questionRejectResponse: OpenCodeResponse = {};
questionReplyResponse: OpenCodeResponse = {};
sessionAbortResponse: OpenCodeResponse = {};
sessionCommandError: unknown = null;
sessionCommandResponse: OpenCodeResponse = {};
sessionCreateResponse: OpenCodeResponse = { data: { id: "session-1" } };
sessionDeleteResponse: OpenCodeResponse = {};
sessionMessagesResponse: OpenCodeResponse = { data: [] };
sessionPromptAsyncResponse: OpenCodeResponse = {};
sessionSummarizeResponse: OpenCodeResponse = { data: {} };
sessionUpdateResponse: OpenCodeResponse = {};
asSdkClient(): OpencodeClient {
return {
app: {
agents: async (parameters: unknown) => {
this.calls.appAgents.push(parameters);
return this.appAgentsResponse;
},
},
command: {
list: async (parameters: unknown) => {
this.calls.commandList.push(parameters);
return this.commandListResponse;
},
},
event: {
subscribe: async (parameters: unknown, options: unknown) => {
this.calls.eventSubscribe.push({ parameters, options });
return { stream: this.eventStream };
},
},
mcp: {
add: async () => ({}),
connect: async () => ({}),
},
permission: {
reply: async (parameters: unknown) => {
this.calls.permissionReply.push(parameters);
return this.permissionReplyResponse;
},
},
provider: {
list: async (parameters: unknown) => {
this.calls.providerList.push(parameters);
return this.providerListImplementation
? await this.providerListImplementation()
: this.providerListResponse;
},
},
question: {
reject: async (parameters: unknown) => {
this.calls.questionReject.push(parameters);
return this.questionRejectResponse;
},
reply: async (parameters: unknown) => {
this.calls.questionReply.push(parameters);
return this.questionReplyResponse;
},
},
session: {
abort: async (parameters: unknown) => {
this.calls.sessionAbort.push(parameters);
return this.sessionAbortResponse;
},
command: async (parameters: unknown) => {
this.calls.sessionCommand.push(parameters);
if (this.sessionCommandError) {
throw this.sessionCommandError;
}
return this.sessionCommandResponse;
},
create: async (parameters: unknown) => {
this.calls.sessionCreate.push(parameters);
return this.sessionCreateResponse;
},
delete: async (parameters: unknown) => {
this.calls.sessionDelete.push(parameters);
return this.sessionDeleteResponse;
},
messages: async (parameters: unknown) => {
this.calls.sessionMessages.push(parameters);
return this.sessionMessagesResponse;
},
promptAsync: async (parameters: unknown) => {
this.calls.sessionPromptAsync.push(parameters);
return this.sessionPromptAsyncResponse;
},
summarize: async (parameters: unknown) => {
this.calls.sessionSummarize.push(parameters);
return this.sessionSummarizeResponse;
},
update: async (parameters: unknown) => {
this.calls.sessionUpdate.push(parameters);
return this.sessionUpdateResponse;
},
},
} as unknown as OpencodeClient;
}
}
export function createEventStream(events: unknown[]): AsyncGenerator<unknown> {
return (async function* () {
for (const event of events) {
yield event;
}
})();
}
export function idleEvent(): unknown {
return {
type: "session.idle",
properties: { sessionID: "session-1" },
};
}

View File

@@ -1,4 +1,4 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { describe, expect, test, vi } from "vitest";
@@ -9,6 +9,7 @@ import type { AgentStreamEvent } from "../agent-sdk-types.js";
import {
PiDirectAgentClient,
PiDirectAgentSession,
type PiDirectSessionRuntimeAdapter,
type PiDirectSessionAdapter,
} from "./pi-direct-agent.js";
@@ -43,6 +44,16 @@ function createPiSession(prompt: () => Promise<void>): PiDirectSessionAdapter {
};
}
function createPiRuntime(
session: PiDirectSessionAdapter,
dispose: () => Promise<void> = vi.fn(async () => undefined),
): PiDirectSessionRuntimeAdapter {
return {
session,
dispose,
};
}
function createPiModel(provider: string, id: string): Model<Api> {
return {
provider,
@@ -61,7 +72,7 @@ function createPiModel(provider: string, id: string): Model<Api> {
describe("PiDirectAgentSession", () => {
test("treats SDK request abort rejections as turn cancellations", async () => {
const session = new PiDirectAgentSession(
createPiSession(() => Promise.reject(new Error("Request was aborted."))),
createPiRuntime(createPiSession(() => Promise.reject(new Error("Request was aborted.")))),
{ find: vi.fn(), getAll: vi.fn(() => []) },
{
provider: "pi",
@@ -87,7 +98,7 @@ describe("PiDirectAgentSession", () => {
test("setModel creates a minimal model for new ids under a known provider", async () => {
const sdkSession = createPiSession(async () => undefined);
const session = new PiDirectAgentSession(
sdkSession,
createPiRuntime(sdkSession),
{
find: vi.fn(() => undefined),
getAll: vi.fn(() => [createPiModel("openrouter", "known-model")]),
@@ -196,12 +207,15 @@ export default function(pi) {
const agentDir = join(testRoot, "agent");
const cwd = join(testRoot, "project");
const extensionDir = join(cwd, ".pi", "extensions");
const shutdownMarker = join(testRoot, "shutdown.txt");
process.env.PI_CODING_AGENT_DIR = agentDir;
await mkdir(extensionDir, { recursive: true });
await writeFile(
join(extensionDir, "dummy-command.ts"),
`
import { writeFileSync } from "node:fs";
export default function(pi) {
pi.registerProvider("paseo-dummy", {
baseUrl: "https://example.invalid/v1",
@@ -224,6 +238,10 @@ export default function(pi) {
description: "Dummy extension command",
handler: async () => {}
});
pi.on("session_shutdown", async () => {
writeFileSync(${JSON.stringify(shutdownMarker)}, "closed");
});
}
`,
"utf-8",
@@ -237,6 +255,7 @@ export default function(pi) {
cwd,
model: "paseo-dummy/extension-model",
});
let closed = false;
try {
await expect(session.listCommands()).resolves.toContainEqual({
@@ -244,8 +263,13 @@ export default function(pi) {
description: "Dummy extension command",
argumentHint: "",
});
} finally {
await session.close();
closed = true;
await expect(readFile(shutdownMarker, "utf-8")).resolves.toBe("closed");
} finally {
if (!closed) {
await session.close();
}
}
} finally {
if (previousAgentDir === undefined) {

View File

@@ -8,11 +8,14 @@ import {
ModelRegistry,
SessionManager,
createAgentSessionFromServices,
createAgentSessionRuntime,
createAgentSessionServices,
getAgentDir,
type AgentSession as PiAgentSession,
type AgentSessionEvent,
type AgentSessionServices,
type BashToolInput,
type CreateAgentSessionRuntimeFactory,
type EditToolInput,
type FindToolInput,
type GrepToolInput,
@@ -104,7 +107,7 @@ interface StartTurnResult {
}
interface PiDirectSessionResult {
session: PiAgentSession;
runtime: PiDirectSessionRuntimeAdapter;
modelRegistry: ModelRegistry;
}
@@ -128,6 +131,11 @@ export type PiDirectSessionAdapter = Pick<
| "thinkingLevel"
>;
export interface PiDirectSessionRuntimeAdapter {
readonly session: PiDirectSessionAdapter;
dispose(): Promise<void>;
}
type PiDirectModelRegistry = Pick<ModelRegistry, "find" | "getAll">;
interface PiToolResultObject {
@@ -835,18 +843,23 @@ export class PiDirectAgentSession implements AgentSession {
private latestUsage: AgentUsage | undefined;
constructor(
private readonly session: PiDirectSessionAdapter,
private readonly runtime: PiDirectSessionRuntimeAdapter,
private readonly modelRegistry: PiDirectModelRegistry,
private readonly config: AgentSessionConfig,
) {
const session = this.session;
this.lastKnownThinkingOptionId =
normalizePiThinkingOption(config.thinkingOptionId) ?? session.thinkingLevel ?? null;
this.session.subscribe((event) => {
session.subscribe((event) => {
this.handleSessionEvent(event);
});
}
private get session(): PiDirectSessionAdapter {
return this.runtime.session;
}
get id(): string | null {
return this.session.sessionId;
}
@@ -1307,7 +1320,7 @@ export class PiDirectAgentSession implements AgentSession {
}
async close(): Promise<void> {
this.session.dispose();
await this.runtime.dispose();
}
async listCommands(): Promise<AgentSlashCommand[]> {
@@ -1350,9 +1363,10 @@ export class PiDirectAgentClient implements AgentClient {
this.runtimeSettings = options.runtimeSettings;
}
private async getSessionServices(cwd: string): Promise<AgentSessionServices> {
private async getSessionServices(cwd: string, agentDir?: string): Promise<AgentSessionServices> {
return createAgentSessionServices({
cwd,
...(agentDir ? { agentDir } : {}),
...(this.modelRegistry ? { modelRegistry: this.modelRegistry } : {}),
});
}
@@ -1369,29 +1383,57 @@ export class PiDirectAgentClient implements AgentClient {
return findModelInRegistry(registry, parsedReference);
}
private async createSdkSession(config: AgentSessionConfig): Promise<PiDirectSessionResult> {
const thinkingLevel =
normalizePiThinkingOption(config.thinkingOptionId) ?? DEFAULT_PI_THINKING_LEVEL;
const services = await this.getSessionServices(config.cwd);
const model = this.resolveConfiguredModel(services.modelRegistry, config.model);
private async createSdkRuntime(
config: AgentSessionConfig,
sessionManager: SessionManager,
options: { defaultThinkingLevel?: ThinkingLevel } = {},
): Promise<PiDirectSessionResult> {
const createRuntime: CreateAgentSessionRuntimeFactory = async ({
cwd,
agentDir,
sessionManager: runtimeSessionManager,
sessionStartEvent,
}) => {
const thinkingLevel =
normalizePiThinkingOption(config.thinkingOptionId) ?? options.defaultThinkingLevel;
const services = await this.getSessionServices(cwd, agentDir);
const model = this.resolveConfiguredModel(services.modelRegistry, config.model);
const { session } = await createAgentSessionFromServices({
services,
sessionManager: SessionManager.create(config.cwd),
thinkingLevel,
...(model ? { model } : {}),
return {
...(await createAgentSessionFromServices({
services,
sessionManager: runtimeSessionManager,
sessionStartEvent,
...(thinkingLevel ? { thinkingLevel } : {}),
...(model ? { model } : {}),
})),
services,
diagnostics: services.diagnostics,
};
};
const runtime = await createAgentSessionRuntime(createRuntime, {
cwd: sessionManager.getCwd(),
agentDir: getAgentDir(),
sessionManager,
});
await session.bindExtensions({});
applySystemPrompt(session, config.systemPrompt);
return { session, modelRegistry: services.modelRegistry };
await runtime.session.bindExtensions({});
applySystemPrompt(runtime.session, config.systemPrompt);
return { runtime, modelRegistry: runtime.services.modelRegistry };
}
async createSession(
config: AgentSessionConfig,
_launchContext?: AgentLaunchContext,
): Promise<AgentSession> {
const { session, modelRegistry } = await this.createSdkSession(config);
return new PiDirectAgentSession(session, modelRegistry, config);
const { runtime, modelRegistry } = await this.createSdkRuntime(
config,
SessionManager.create(config.cwd),
{
defaultThinkingLevel: DEFAULT_PI_THINKING_LEVEL,
},
);
return new PiDirectAgentSession(runtime, modelRegistry, config);
}
async resumeSession(
@@ -1404,9 +1446,10 @@ export class PiDirectAgentClient implements AgentClient {
throw new Error("Pi resume requires a native session file handle");
}
const resumedManager = SessionManager.open(sessionFile);
const initialManager = SessionManager.open(sessionFile);
const persistenceMetadata = parsePersistenceMetadata(handle.metadata);
const cwd = overrides?.cwd ?? persistenceMetadata.cwd ?? resumedManager.getCwd();
const cwd = overrides?.cwd ?? persistenceMetadata.cwd ?? initialManager.getCwd();
const resumedManager = SessionManager.open(sessionFile, undefined, cwd);
const mergedConfig: AgentSessionConfig = {
provider: PI_PROVIDER,
cwd,
@@ -1425,18 +1468,8 @@ export class PiDirectAgentClient implements AgentClient {
modeId: overrides?.modeId,
};
const services = await this.getSessionServices(mergedConfig.cwd);
const model = this.resolveConfiguredModel(services.modelRegistry, mergedConfig.model);
const thinkingLevel = normalizePiThinkingOption(mergedConfig.thinkingOptionId);
const { session } = await createAgentSessionFromServices({
services,
sessionManager: resumedManager,
...(model ? { model } : {}),
...(thinkingLevel ? { thinkingLevel } : {}),
});
await session.bindExtensions({});
applySystemPrompt(session, mergedConfig.systemPrompt);
return new PiDirectAgentSession(session, services.modelRegistry, mergedConfig);
const { runtime, modelRegistry } = await this.createSdkRuntime(mergedConfig, resumedManager);
return new PiDirectAgentSession(runtime, modelRegistry, mergedConfig);
}
async listModels(options: ListModelsOptions): Promise<AgentModelDefinition[]> {

View File

@@ -0,0 +1,38 @@
import { describe, expect, test } from "vitest";
import { CheckoutPrStatusSchema } from "../../shared/messages.js";
import { normalizeCheckoutPrStatusPayload } from "./status-projection.js";
describe("checkout status projection", () => {
test("includes repository identity fields on the PR status wire payload", () => {
const payload = normalizeCheckoutPrStatusPayload({
number: 123,
repoOwner: "internal-owner",
repoName: "internal-repo",
url: "https://github.com/getpaseo/paseo/pull/123",
title: "Ship PR pane",
state: "open",
baseRefName: "main",
headRefName: "feature/pr-pane",
isMerged: false,
isDraft: true,
mergeable: "MERGEABLE",
checks: [
{
name: "typecheck",
status: "success",
url: "https://github.com/getpaseo/paseo/actions/runs/1",
workflow: "CI",
duration: "1m 20s",
},
],
checksStatus: "success",
reviewDecision: "approved",
});
expect(payload).toHaveProperty("repoOwner", "internal-owner");
expect(payload).toHaveProperty("repoName", "internal-repo");
expect(payload).toHaveProperty("mergeable", "MERGEABLE");
expect(CheckoutPrStatusSchema.parse(payload)).toEqual(payload);
});
});

View File

@@ -0,0 +1,134 @@
import type {
CheckoutPrStatusResponse,
CheckoutStatusResponse,
SessionOutboundMessage,
} from "../../shared/messages.js";
import type { WorkspaceGitRuntimeSnapshot } from "../workspace-git-service.js";
type CheckoutPrStatusPayload = Extract<
SessionOutboundMessage,
{ type: "checkout_pr_status_response" }
>["payload"];
type CheckoutPrStatusPayloadStatus = NonNullable<CheckoutPrStatusPayload["status"]>;
export function buildCheckoutStatusPayloadFromSnapshot({
cwd,
requestId,
snapshot,
}: {
cwd: string;
requestId: string;
snapshot: WorkspaceGitRuntimeSnapshot;
}): CheckoutStatusResponse["payload"] {
if (!snapshot.git.isGit) {
return {
cwd,
isGit: false,
repoRoot: null,
currentBranch: null,
isDirty: null,
baseRef: null,
aheadBehind: null,
aheadOfOrigin: null,
behindOfOrigin: null,
hasRemote: false,
remoteUrl: null,
isPaseoOwnedWorktree: false,
error: null,
requestId,
};
}
if (snapshot.git.repoRoot === null || snapshot.git.isDirty === null) {
throw new Error("Workspace git snapshot is missing required checkout status fields");
}
if (snapshot.git.isPaseoOwnedWorktree) {
if (snapshot.git.mainRepoRoot === null || snapshot.git.baseRef === null) {
throw new Error("Workspace git snapshot is missing required worktree status fields");
}
return {
cwd,
isGit: true,
repoRoot: snapshot.git.repoRoot,
mainRepoRoot: snapshot.git.mainRepoRoot,
currentBranch: snapshot.git.currentBranch ?? null,
isDirty: snapshot.git.isDirty,
baseRef: snapshot.git.baseRef,
aheadBehind: snapshot.git.aheadBehind ?? null,
aheadOfOrigin: snapshot.git.aheadOfOrigin ?? null,
behindOfOrigin: snapshot.git.behindOfOrigin ?? null,
hasRemote: snapshot.git.hasRemote,
remoteUrl: snapshot.git.remoteUrl,
isPaseoOwnedWorktree: true,
error: null,
requestId,
};
}
return {
cwd,
isGit: true,
repoRoot: snapshot.git.repoRoot,
mainRepoRoot: snapshot.git.mainRepoRoot,
currentBranch: snapshot.git.currentBranch ?? null,
isDirty: snapshot.git.isDirty,
baseRef: snapshot.git.baseRef ?? null,
aheadBehind: snapshot.git.aheadBehind ?? null,
aheadOfOrigin: snapshot.git.aheadOfOrigin ?? null,
behindOfOrigin: snapshot.git.behindOfOrigin ?? null,
hasRemote: snapshot.git.hasRemote,
remoteUrl: snapshot.git.remoteUrl,
isPaseoOwnedWorktree: false,
error: null,
requestId,
};
}
export function buildCheckoutPrStatusPayloadFromSnapshot({
cwd,
requestId,
snapshot,
}: {
cwd: string;
requestId: string;
snapshot: WorkspaceGitRuntimeSnapshot;
}): CheckoutPrStatusResponse["payload"] {
return {
cwd,
status: normalizeCheckoutPrStatusPayload(snapshot.github.pullRequest),
githubFeaturesEnabled: snapshot.github.featuresEnabled,
error: snapshot.github.error
? {
code: "UNKNOWN",
message: snapshot.github.error.message,
}
: null,
requestId,
};
}
export function normalizeCheckoutPrStatusPayload(
status: WorkspaceGitRuntimeSnapshot["github"]["pullRequest"],
): CheckoutPrStatusPayloadStatus | null {
if (!status) {
return null;
}
return {
number: status.number,
url: status.url,
title: status.title,
state: status.state,
repoOwner: status.repoOwner,
repoName: status.repoName,
baseRefName: status.baseRefName,
headRefName: status.headRefName,
isMerged: status.isMerged,
isDraft: status.isDraft ?? false,
mergeable: status.mergeable ?? "UNKNOWN",
checks: status.checks ?? [],
checksStatus: status.checksStatus,
reviewDecision: status.reviewDecision,
};
}

View File

@@ -173,6 +173,44 @@ test("renames an eligible unnamed branch-off worktree once on first agent contex
expect(branchAfterSecond).toBe("renamed-from-agent-context");
});
test("falls back to a numeric suffix when the desired branch name already exists", async () => {
const { repoDir, tempDir } = createGitRepo();
cleanupPaths.push(tempDir);
execFileSync("git", ["branch", "renamed-from-agent-context"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["branch", "renamed-from-agent-context-2"], { cwd: repoDir, stdio: "pipe" });
const created = await createPaseoWorktree(
{
cwd: repoDir,
worktreeSlug: "dazzling-yak",
runSetup: false,
paseoHome: path.join(tempDir, ".paseo"),
},
createDeps(),
);
const result = await attemptFirstAgentBranchAutoName({
cwd: created.worktree.worktreePath,
firstAgentContext: { prompt: "Build the agent context name" },
generateBranchNameFromContext: async () => "renamed-from-agent-context",
});
expect(result).toEqual({
attempted: true,
renamed: true,
branchName: "renamed-from-agent-context-3",
});
expect(
execFileSync("git", ["branch", "--show-current"], {
cwd: created.worktree.worktreePath,
stdio: "pipe",
})
.toString()
.trim(),
).toBe("renamed-from-agent-context-3");
});
test("renames the branch even when the app supplies a random placeholder slug", async () => {
const { repoDir, tempDir } = createGitRepo();
cleanupPaths.push(tempDir);

View File

@@ -13,7 +13,7 @@ import {
type CreateWorktreeCoreInput,
} from "./worktree-core.js";
import { validateBranchSlug, type WorktreeConfig } from "../utils/worktree.js";
import { getCurrentBranch, renameCurrentBranch } from "../utils/checkout-git.js";
import { getCurrentBranch, localBranchExists, renameCurrentBranch } from "../utils/checkout-git.js";
import {
markPaseoWorktreeFirstAgentBranchAutoNameAttempted,
readPaseoWorktreeMetadata,
@@ -85,6 +85,7 @@ export async function attemptFirstAgentBranchAutoName(options: {
}) => Promise<string | null>;
getCurrentBranch?: typeof getCurrentBranch;
renameCurrentBranch?: typeof renameCurrentBranch;
localBranchExists?: typeof localBranchExists;
}): Promise<AttemptFirstAgentBranchAutoNameResult> {
const firstAgentContext = options.firstAgentContext;
if (!firstAgentContext || !buildAgentBranchNameSeed(firstAgentContext)) {
@@ -129,15 +130,50 @@ export async function attemptFirstAgentBranchAutoName(options: {
return { attempted: true, renamed: false, branchName: null };
}
const localBranchExistsImpl = options.localBranchExists ?? localBranchExists;
const targetName = await findAvailableBranchName({
cwd: options.cwd,
desiredName: branchName,
placeholderBranchName,
localBranchExists: localBranchExistsImpl,
});
if (!targetName) {
return { attempted: true, renamed: false, branchName: null };
}
const renameCurrentBranchImpl = options.renameCurrentBranch ?? renameCurrentBranch;
const renamedBranch = await renameCurrentBranchImpl(options.cwd, branchName);
const renamedBranch = await renameCurrentBranchImpl(options.cwd, targetName);
return {
attempted: true,
renamed: true,
branchName: renamedBranch.currentBranch ?? branchName,
branchName: renamedBranch.currentBranch ?? targetName,
};
}
const MAX_BRANCH_NAME_SUFFIX_ATTEMPTS = 50;
async function findAvailableBranchName(options: {
cwd: string;
desiredName: string;
placeholderBranchName: string;
localBranchExists: (cwd: string, branchName: string) => Promise<boolean>;
}): Promise<string | null> {
const { cwd, desiredName, placeholderBranchName } = options;
if (!(await options.localBranchExists(cwd, desiredName))) {
return desiredName;
}
for (let suffix = 2; suffix <= MAX_BRANCH_NAME_SUFFIX_ATTEMPTS; suffix++) {
const candidate = `${desiredName}-${suffix}`;
if (candidate === placeholderBranchName) {
continue;
}
if (!(await options.localBranchExists(cwd, candidate))) {
return candidate;
}
}
return null;
}
function maybeMarkFirstAgentBranchAutoNameEligible(options: {
createdWorktree: Awaited<ReturnType<typeof createWorktreeCore>>;
}): void {

View File

@@ -27,6 +27,9 @@ describe("ScheduleService", () => {
});
afterEach(async () => {
// Drain pending background persists before deleting the dir to avoid
// ENOTEMPTY races when AgentManager flushes a snapshot mid-cleanup.
await agentStorage.flush();
await rm(tempDir, { recursive: true, force: true });
});

View File

@@ -1,6 +1,6 @@
import { describe, expect, test } from "vitest";
import { resolveCreateAgentTitles } from "./session.js";
import { resolveCreateAgentTitles } from "./agent/create-agent-title.js";
describe("resolveCreateAgentTitles", () => {
test("derives a provisional title from prompt when explicit title is absent", () => {

View File

@@ -6,21 +6,17 @@ import { join } from "path";
import pino from "pino";
import { afterEach, describe, expect, test, vi } from "vitest";
import { CheckoutPrStatusSchema } from "../shared/messages.js";
import type { WorkspaceDescriptorPayload } from "../shared/messages.js";
import { decodeFileTransferFrame, FileTransferOpcode } from "../shared/binary-frames/index.js";
import { normalizeCheckoutPrStatusPayload, Session } from "./session.js";
import { Session } from "./session.js";
import { StructuredAgentFallbackError } from "./agent/agent-response-loop.js";
import type {
AgentClient,
AgentMode,
AgentModelDefinition,
AgentTimelineItem,
ListModesOptions,
ListModelsOptions,
PersistedAgentDescriptor,
} from "./agent/agent-sdk-types.js";
import type { ManagedAgent } from "./agent/agent-manager.js";
import type { ProviderDefinition } from "./agent/provider-registry.js";
import { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js";
import type { SessionOptions } from "./session.js";
@@ -71,8 +67,6 @@ interface SessionHandlerInternals {
handleCheckoutPullRequest(params: unknown): Promise<unknown>;
handleCheckoutPushRequest(params: unknown): Promise<unknown>;
handleCheckoutStatusRequest(params: unknown): Promise<unknown>;
handleFetchRecentProviderSessions(params: unknown): Promise<unknown>;
handleImportAgentRequest(params: unknown): Promise<unknown>;
describeWorkspaceRecord(...args: unknown[]): Promise<WorkspaceDescriptorPayload>;
describeWorkspaceRecordWithGitData(...args: unknown[]): Promise<WorkspaceDescriptorPayload>;
handleValidateBranchRequest(params: unknown): Promise<unknown>;
@@ -935,296 +929,6 @@ afterEach(() => {
vi.clearAllMocks();
});
describe("session agent import", () => {
test("filters already imported provider sessions by native handle", async () => {
const messages: unknown[] = [];
const cwd = "/tmp/import-filter";
const importedDescriptor: PersistedAgentDescriptor = {
provider: "claude",
sessionId: "provider-session-imported",
cwd,
title: "Already imported",
lastActivityAt: new Date("2026-04-30T12:00:00.000Z"),
persistence: {
provider: "claude",
sessionId: "provider-session-imported",
nativeHandle: "provider-native-imported",
},
timeline: [{ type: "user_message", text: "already imported" }],
};
const visibleDescriptor: PersistedAgentDescriptor = {
provider: "claude",
sessionId: "provider-session-visible",
cwd,
title: "Still importable",
lastActivityAt: new Date("2026-04-30T11:00:00.000Z"),
persistence: {
provider: "claude",
sessionId: "provider-session-visible",
nativeHandle: "provider-native-visible",
},
timeline: [{ type: "user_message", text: "still importable" }],
};
const agentManager = {
listAgents: vi.fn(() => [
{
provider: "claude",
persistence: {
provider: "claude",
sessionId: "stored-session-id",
nativeHandle: "provider-native-imported",
},
} as ManagedAgent,
]),
listImportablePersistedAgents: vi
.fn()
.mockResolvedValue([importedDescriptor, visibleDescriptor]),
subscribe: vi.fn(() => () => {}),
};
const agentStorage = {
list: vi.fn().mockResolvedValue([]),
};
const session = createSessionForTest({ messages });
Object.assign(session, { agentManager, agentStorage });
await asSessionInternals(session).handleFetchRecentProviderSessions({
type: "fetch_recent_provider_sessions_request",
requestId: "recent-provider-sessions",
cwd,
providers: ["claude"],
limit: 15,
});
expect(agentManager.listImportablePersistedAgents).toHaveBeenCalledWith({
limit: 200,
providerFilter: new Set(["claude"]),
cwd,
});
expect(messages).toContainEqual(
expect.objectContaining({
type: "fetch_recent_provider_sessions_response",
payload: expect.objectContaining({
requestId: "recent-provider-sessions",
filteredAlreadyImportedCount: 1,
entries: [
expect.objectContaining({
providerId: "claude",
providerHandleId: "provider-native-visible",
title: "Still importable",
}),
],
}),
}),
);
});
test("imports by provider handle id and hydrates title metadata from the first user message", async () => {
const messages: unknown[] = [];
const cwd = "/tmp/imported-agent";
const timeline: AgentTimelineItem[] = [
{ type: "user_message", text: "Trace recent provider sessions\n\nkeep it tight" },
{ type: "assistant_message", text: "I will inspect the provider listing." },
];
const snapshot = {
id: "00000000-0000-4000-8000-000000000633",
provider: "custom-codex",
cwd,
capabilities: TEST_CAPABILITIES,
config: { provider: "custom-codex", cwd },
createdAt: new Date("2026-04-30T00:00:00.000Z"),
updatedAt: new Date("2026-04-30T00:00:00.000Z"),
availableModes: [],
currentModeId: null,
pendingPermissions: new Map(),
bufferedPermissionResolutions: new Map(),
inFlightPermissionResponses: new Set(),
pendingReplacement: false,
persistence: {
provider: "custom-codex",
sessionId: "thread-imported",
nativeHandle: "provider-thread-imported",
metadata: { provider: "custom-codex", cwd },
},
historyPrimed: true,
lastUserMessageAt: null,
attention: { requiresAttention: false },
foregroundTurnWaiters: new Set(),
finalizedForegroundTurnIds: new Set(),
unsubscribeSession: null,
internal: false,
labels: {},
lifecycle: "closed",
session: null,
activeForegroundTurnId: null,
} satisfies ManagedAgent;
const agentManager = {
listAgents: vi.fn(() => []),
subscribe: vi.fn(() => () => {}),
findPersistedAgent: vi.fn().mockResolvedValue({
provider: "custom-codex",
sessionId: "thread-imported",
cwd,
title: null,
firstPromptPreview: "Trace recent provider sessions",
lastPromptPreview: "Trace recent provider sessions",
lastActivityAt: new Date("2026-04-30T00:00:00.000Z"),
persistence: snapshot.persistence,
}),
resumeAgentFromPersistence: vi.fn().mockResolvedValue(snapshot),
hydrateTimelineFromProvider: vi.fn().mockResolvedValue(undefined),
getTimeline: vi.fn().mockReturnValue(timeline),
setTitle: vi.fn().mockResolvedValue(undefined),
notifyAgentState: vi.fn(),
};
const agentStorage = {
list: vi.fn().mockResolvedValue([]),
get: vi.fn().mockResolvedValue(null),
};
const session = createSessionForTest({ messages });
Object.assign(session, { agentManager, agentStorage });
await asSessionInternals(session).handleImportAgentRequest({
type: "import_agent_request",
providerId: "custom-codex",
providerHandleId: "provider-thread-imported",
cwd,
requestId: "import-thread",
});
expect(agentManager.findPersistedAgent).toHaveBeenCalledWith(
"custom-codex",
"provider-thread-imported",
);
expect(agentManager.hydrateTimelineFromProvider).toHaveBeenCalledWith(snapshot.id);
expect(agentManager.setTitle).toHaveBeenCalledWith(
snapshot.id,
"Trace recent provider sessions",
);
expect(agentMetadataMocks.scheduleAgentMetadataGeneration).toHaveBeenCalledWith(
expect.objectContaining({
agentManager,
agentId: snapshot.id,
cwd,
initialPrompt: "Trace recent provider sessions\n\nkeep it tight",
explicitTitle: null,
}),
);
});
test("sets a provisional title and schedules auto-title generation from the first hydrated user message", async () => {
const messages: unknown[] = [];
const cwd = "/tmp/imported-agent";
const timeline: AgentTimelineItem[] = [
{ type: "user_message", text: "Investigate flaky checkout status\n\ninclude logs" },
{ type: "assistant_message", text: "I will inspect the checkout flow." },
];
const snapshot = {
id: "00000000-0000-4000-8000-000000000632",
provider: "codex",
cwd,
capabilities: TEST_CAPABILITIES,
config: { provider: "codex", cwd },
createdAt: new Date("2026-04-30T00:00:00.000Z"),
updatedAt: new Date("2026-04-30T00:00:00.000Z"),
availableModes: [],
currentModeId: null,
pendingPermissions: new Map(),
bufferedPermissionResolutions: new Map(),
inFlightPermissionResponses: new Set(),
pendingReplacement: false,
persistence: {
provider: "codex",
sessionId: "thread-imported",
nativeHandle: "thread-imported",
metadata: { provider: "codex", cwd },
},
historyPrimed: true,
lastUserMessageAt: null,
attention: { requiresAttention: false },
foregroundTurnWaiters: new Set(),
finalizedForegroundTurnIds: new Set(),
unsubscribeSession: null,
internal: false,
labels: {},
lifecycle: "closed",
session: null,
activeForegroundTurnId: null,
} satisfies ManagedAgent;
const agentManager = {
listAgents: vi.fn(() => []),
subscribe: vi.fn(() => () => {}),
findPersistedAgent: vi.fn().mockResolvedValue(null),
resumeAgentFromPersistence: vi.fn().mockResolvedValue(snapshot),
hydrateTimelineFromProvider: vi.fn().mockResolvedValue(undefined),
getTimeline: vi.fn().mockReturnValue(timeline),
setTitle: vi.fn().mockResolvedValue(undefined),
notifyAgentState: vi.fn(),
};
const agentStorage = {
list: vi.fn().mockResolvedValue([]),
get: vi.fn().mockResolvedValue(null),
};
const session = createSessionForTest({ messages });
Object.assign(session, { agentManager, agentStorage });
await asSessionInternals(session).handleImportAgentRequest({
type: "import_agent_request",
provider: "codex",
sessionId: "thread-imported",
cwd,
requestId: "import-thread",
});
expect(agentManager.setTitle).toHaveBeenCalledWith(
snapshot.id,
"Investigate flaky checkout status",
);
expect(agentMetadataMocks.scheduleAgentMetadataGeneration).toHaveBeenCalledWith(
expect.objectContaining({
agentManager,
agentId: snapshot.id,
cwd,
initialPrompt: "Investigate flaky checkout status\n\ninclude logs",
explicitTitle: null,
}),
);
});
});
describe("session PR status payload normalization", () => {
test("includes repository identity fields on the wire", () => {
const payload = normalizeCheckoutPrStatusPayload({
number: 123,
repoOwner: "internal-owner",
repoName: "internal-repo",
url: "https://github.com/getpaseo/paseo/pull/123",
title: "Ship PR pane",
state: "open",
baseRefName: "main",
headRefName: "feature/pr-pane",
isMerged: false,
isDraft: true,
mergeable: "MERGEABLE",
checks: [
{
name: "typecheck",
status: "success",
url: "https://github.com/getpaseo/paseo/actions/runs/1",
workflow: "CI",
duration: "1m 20s",
},
],
checksStatus: "success",
reviewDecision: "approved",
});
expect(payload).toHaveProperty("repoOwner", "internal-owner");
expect(payload).toHaveProperty("repoName", "internal-repo");
expect(payload).toHaveProperty("mergeable", "MERGEABLE");
expect(CheckoutPrStatusSchema.parse(payload)).toEqual(payload);
});
});
describe("session provider refresh cwd routing", () => {
test("routes no-cwd provider snapshot refreshes through settings refresh", async () => {
const {

View File

@@ -20,8 +20,6 @@ import {
type FileExplorerRequest,
type FileDownloadTokenRequest,
type GitSetupOptions,
type CheckoutPrStatusResponse,
type CheckoutStatusResponse,
type StartWorkspaceScriptRequest,
type CloseItemsRequest,
type SubscribeCheckoutDiffRequest,
@@ -83,6 +81,7 @@ import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-sto
import type { DaemonConfigStore } from "./daemon-config-store.js";
import { applyMutableProviderConfigToOverrides } from "./daemon-config-store.js";
import { getErrorMessage, getErrorMessageOr } from "../shared/error-utils.js";
import { getAgentStatusPriority } from "../shared/agent-state-bucket.js";
import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService } from "./workspace-git-service.js";
import { buildProviderRegistry } from "./agent/provider-registry.js";
@@ -98,13 +97,13 @@ import type {
ManagedAgent,
} from "./agent/agent-manager.js";
import { scheduleAgentMetadataGeneration } from "./agent/agent-metadata-generator.js";
import { resolveCreateAgentTitles } from "./agent/create-agent-title.js";
import {
buildStoredAgentPayload,
resolveEffectiveThinkingOptionId,
resolveStoredAgentPayloadUpdatedAt,
toAgentPayload,
} from "./agent/agent-projections.js";
import { MAX_EXPLICIT_AGENT_TITLE_CHARS } from "./agent/agent-title-limits.js";
import {
appendTimelineItemIfAgentKnown,
emitLiveTimelineItemIfAgentKnown,
@@ -129,13 +128,13 @@ import type {
AgentRunOptions,
AgentSessionConfig,
AgentStreamEvent,
AgentTimelineItem,
ProviderSnapshotEntry,
} from "./agent/agent-sdk-types.js";
import type { StoredAgentRecord } from "./agent/agent-storage.js";
import type { AgentStorage } from "./agent/agent-storage.js";
import {
ImportSessionsRequestError,
importProviderSession,
listImportableProviderSessions,
normalizeImportAgentRequest,
} from "./agent/import-sessions.js";
@@ -192,6 +191,10 @@ import { expandTilde } from "../utils/path.js";
import { searchHomeDirectories, searchWorkspaceEntries } from "../utils/directory-suggestions.js";
import { toCheckoutError } from "./checkout-git-utils.js";
import { CheckoutDiffManager } from "./checkout-diff-manager.js";
import {
buildCheckoutPrStatusPayloadFromSnapshot,
buildCheckoutStatusPayloadFromSnapshot,
} from "./checkout/status-projection.js";
import type { LocalSpeechModelId } from "./speech/providers/local/models.js";
import { toResolver, type Resolvable } from "./speech/provider-resolver.js";
import type { SpeechReadinessSnapshot, SpeechReadinessState } from "./speech/speech-runtime.js";
@@ -236,7 +239,6 @@ import {
} from "./worktree-session.js";
import { toWorktreeWireError } from "./worktree-errors.js";
const MAX_INITIAL_AGENT_TITLE_CHARS = Math.min(60, MAX_EXPLICIT_AGENT_TITLE_CHARS);
const WORKSPACE_GIT_WATCH_REMOVED_STATE_KEY = "__removed__";
interface ResolveKnownProjectRootForConfigInput {
@@ -393,53 +395,6 @@ function beginAgentDeleteIfSupported(agentStorage: AgentStorage, agentId: string
}
}
function deriveInitialAgentTitle(prompt: string): string | null {
const firstContentLine = prompt
.split(/\r?\n/)
.map((line) => line.trim())
.find((line) => line.length > 0);
if (!firstContentLine) {
return null;
}
const normalized = firstContentLine.replace(/\s+/g, " ").trim();
if (!normalized) {
return null;
}
const clamped = normalized.slice(0, MAX_INITIAL_AGENT_TITLE_CHARS).trim();
return clamped.length > 0 ? clamped : null;
}
export function resolveCreateAgentTitles(options: {
configTitle?: string | null;
initialPrompt?: string | null;
}): { explicitTitle: string | null; provisionalTitle: string | null } {
const explicitTitle =
typeof options.configTitle === "string" && options.configTitle.trim().length > 0
? options.configTitle.trim()
: null;
const trimmedPrompt = options.initialPrompt?.trim();
const provisionalTitle =
explicitTitle ?? (trimmedPrompt ? deriveInitialAgentTitle(trimmedPrompt) : null);
return {
explicitTitle,
provisionalTitle,
};
}
function getFirstUserMessageText(timeline: readonly AgentTimelineItem[]): string | null {
for (const item of timeline) {
if (item.type !== "user_message") {
continue;
}
const text = item.text.trim();
if (text) {
return text;
}
}
return null;
}
const FETCH_AGENTS_SORT_KEYS = ["status_priority", "created_at", "updated_at", "title"] as const;
export function resolveWaitForFinishError(options: {
@@ -636,11 +591,6 @@ export type SessionLifecycleIntent =
reason?: string;
};
type CheckoutPrStatusPayload = Extract<
SessionOutboundMessage,
{ type: "checkout_pr_status_response" }
>["payload"];
type CheckoutPrStatusPayloadStatus = NonNullable<CheckoutPrStatusPayload["status"]>;
type PullRequestTimelinePayload = Extract<
SessionOutboundMessage,
{ type: "pull_request_timeline_response" }
@@ -674,45 +624,6 @@ class VoiceFeatureUnavailableError extends Error {
}
}
interface BuildImportPersistenceHandleInput {
provider: AgentProvider;
providerHandleId: string;
cwd?: string;
}
function buildImportPersistenceHandle(
input: BuildImportPersistenceHandleInput,
): AgentPersistenceHandle {
const cwd = input.cwd ?? process.cwd();
return {
provider: input.provider,
sessionId: input.providerHandleId,
nativeHandle: input.providerHandleId,
metadata: {
provider: input.provider,
cwd,
},
};
}
function applyImportCwdOverride(
handle: AgentPersistenceHandle,
cwd: string | undefined,
): AgentPersistenceHandle {
if (!cwd) {
return handle;
}
return {
...handle,
metadata: {
...handle.metadata,
provider: handle.provider,
cwd,
},
};
}
function convertPCMToWavBuffer(
pcmBuffer: Buffer,
sampleRate: number,
@@ -3201,39 +3112,22 @@ export class Session {
});
return;
}
const { provider, providerHandleId, cwd, labels, requestId } = normalized;
const { provider, providerHandleId, requestId } = normalized;
this.sessionLogger.info(
{ providerHandleId, provider },
`Importing agent ${providerHandleId} (${provider})`,
);
try {
const descriptor = await this.agentManager.findPersistedAgent(provider, providerHandleId);
if (!descriptor && provider === "opencode" && !cwd) {
throw new Error(
"OpenCode sessions require --cwd when the session cannot be found in persisted agents",
);
}
const handle = descriptor
? applyImportCwdOverride(descriptor.persistence, cwd)
: buildImportPersistenceHandle({ provider, providerHandleId, cwd });
const overrides = cwd ? ({ cwd } satisfies Partial<AgentSessionConfig>) : undefined;
await this.unarchiveAgentByHandle(handle);
const snapshot = await this.agentManager.resumeAgentFromPersistence(
handle,
overrides,
undefined,
{
labels,
},
);
await unarchiveAgentState(this.agentStorage, this.agentManager, snapshot.id);
await this.agentManager.hydrateTimelineFromProvider(snapshot.id);
await this.applyImportedAgentTitle(snapshot);
const { snapshot, timelineSize } = await importProviderSession({
request: normalized,
agentManager: this.agentManager,
agentStorage: this.agentStorage,
workspaceGitService: this.workspaceGitService,
paseoHome: this.paseoHome,
logger: this.sessionLogger,
});
await this.forwardAgentUpdate(snapshot);
const timelineSize = this.agentManager.getTimeline(snapshot.id).length;
const agentPayload = await this.buildAgentPayload(snapshot);
this.emit({
type: "status",
@@ -3268,32 +3162,6 @@ export class Session {
}
}
private async applyImportedAgentTitle(snapshot: ManagedAgent): Promise<void> {
const initialPrompt = getFirstUserMessageText(this.agentManager.getTimeline(snapshot.id));
if (!initialPrompt) {
return;
}
const { explicitTitle, provisionalTitle } = resolveCreateAgentTitles({
configTitle: snapshot.config.title,
initialPrompt,
});
if (!explicitTitle && provisionalTitle) {
await this.agentManager.setTitle(snapshot.id, provisionalTitle);
}
scheduleAgentMetadataGeneration({
agentManager: this.agentManager,
agentId: snapshot.id,
cwd: snapshot.cwd,
workspaceGitService: this.workspaceGitService,
initialPrompt,
explicitTitle,
paseoHome: this.paseoHome,
logger: this.sessionLogger,
});
}
private async handleRefreshAgentRequest(
msg: Extract<SessionInboundMessage, { type: "refresh_agent_request" }>,
): Promise<void> {
@@ -4489,7 +4357,7 @@ export class Session {
const snapshot = await this.workspaceGitService.getSnapshot(resolvedCwd);
this.emit({
type: "checkout_status_response",
payload: this.buildCheckoutStatusPayloadFromSnapshot({
payload: buildCheckoutStatusPayloadFromSnapshot({
cwd,
requestId,
snapshot,
@@ -4838,116 +4706,18 @@ export class Session {
this.checkoutDiffSubscriptions.delete(msg.subscriptionId);
}
private buildCheckoutStatusPayloadFromSnapshot({
cwd,
requestId,
snapshot,
}: {
cwd: string;
requestId: string;
snapshot: WorkspaceGitRuntimeSnapshot;
}): CheckoutStatusResponse["payload"] {
if (!snapshot.git.isGit) {
return {
cwd,
isGit: false,
repoRoot: null,
currentBranch: null,
isDirty: null,
baseRef: null,
aheadBehind: null,
aheadOfOrigin: null,
behindOfOrigin: null,
hasRemote: false,
remoteUrl: null,
isPaseoOwnedWorktree: false,
error: null,
requestId,
};
}
if (snapshot.git.repoRoot === null || snapshot.git.isDirty === null) {
throw new Error("Workspace git snapshot is missing required checkout status fields");
}
if (snapshot.git.isPaseoOwnedWorktree) {
if (snapshot.git.mainRepoRoot === null || snapshot.git.baseRef === null) {
throw new Error("Workspace git snapshot is missing required worktree status fields");
}
return {
cwd,
isGit: true,
repoRoot: snapshot.git.repoRoot,
mainRepoRoot: snapshot.git.mainRepoRoot,
currentBranch: snapshot.git.currentBranch ?? null,
isDirty: snapshot.git.isDirty,
baseRef: snapshot.git.baseRef,
aheadBehind: snapshot.git.aheadBehind ?? null,
aheadOfOrigin: snapshot.git.aheadOfOrigin ?? null,
behindOfOrigin: snapshot.git.behindOfOrigin ?? null,
hasRemote: snapshot.git.hasRemote,
remoteUrl: snapshot.git.remoteUrl,
isPaseoOwnedWorktree: true,
error: null,
requestId,
};
}
return {
cwd,
isGit: true,
repoRoot: snapshot.git.repoRoot,
mainRepoRoot: snapshot.git.mainRepoRoot,
currentBranch: snapshot.git.currentBranch ?? null,
isDirty: snapshot.git.isDirty,
baseRef: snapshot.git.baseRef ?? null,
aheadBehind: snapshot.git.aheadBehind ?? null,
aheadOfOrigin: snapshot.git.aheadOfOrigin ?? null,
behindOfOrigin: snapshot.git.behindOfOrigin ?? null,
hasRemote: snapshot.git.hasRemote,
remoteUrl: snapshot.git.remoteUrl,
isPaseoOwnedWorktree: false,
error: null,
requestId,
};
}
private buildCheckoutPrStatusPayloadFromSnapshot({
cwd,
requestId,
snapshot,
}: {
cwd: string;
requestId: string;
snapshot: WorkspaceGitRuntimeSnapshot;
}): CheckoutPrStatusResponse["payload"] {
return {
cwd,
status: normalizeCheckoutPrStatusPayload(snapshot.github.pullRequest),
githubFeaturesEnabled: snapshot.github.featuresEnabled,
error: snapshot.github.error
? {
code: "UNKNOWN",
message: snapshot.github.error.message,
}
: null,
requestId,
};
}
private emitCheckoutStatusUpdate(cwd: string, snapshot: WorkspaceGitRuntimeSnapshot): void {
try {
const requestId = `subscription:${cwd}`;
this.emit({
type: "checkout_status_update",
payload: {
...this.buildCheckoutStatusPayloadFromSnapshot({
...buildCheckoutStatusPayloadFromSnapshot({
cwd,
requestId,
snapshot,
}),
prStatus: this.buildCheckoutPrStatusPayloadFromSnapshot({
prStatus: buildCheckoutPrStatusPayloadFromSnapshot({
cwd,
requestId,
snapshot,
@@ -5382,18 +5152,11 @@ export class Session {
const snapshot = await this.workspaceGitService.getSnapshot(cwd);
this.emit({
type: "checkout_pr_status_response",
payload: {
payload: buildCheckoutPrStatusPayloadFromSnapshot({
cwd,
status: normalizeCheckoutPrStatusPayload(snapshot.github.pullRequest),
githubFeaturesEnabled: snapshot.github.featuresEnabled,
error: snapshot.github.error
? {
code: "UNKNOWN",
message: snapshot.github.error.message,
}
: null,
requestId,
},
snapshot,
}),
});
} catch (error) {
this.emit({
@@ -5862,24 +5625,6 @@ export class Session {
return this.isProviderVisibleToClient(payload.provider) ? payload : null;
}
private getStatusPriority(agent: AgentSnapshotPayload): number {
const attentionReason = agent.attentionReason ?? null;
const hasPendingPermission = (agent.pendingPermissions?.length ?? 0) > 0;
if (hasPendingPermission || attentionReason === "permission") {
return 0;
}
if (agent.status === "error" || attentionReason === "error") {
return 1;
}
if (agent.status === "running") {
return 2;
}
if (agent.status === "initializing") {
return 3;
}
return 4;
}
private async buildActiveProjectPlacementsByWorkspaceCwd(): Promise<
Map<string, ProjectPlacementPayload>
> {
@@ -6043,7 +5788,12 @@ export class Session {
getSortValue: (agent, key): number | string => {
switch (key) {
case "status_priority":
return this.getStatusPriority(agent);
return getAgentStatusPriority({
status: agent.status,
pendingPermissionCount: agent.pendingPermissions?.length ?? 0,
requiresAttention: agent.requiresAttention,
attentionReason: agent.attentionReason ?? null,
});
case "created_at":
return Date.parse(agent.createdAt);
case "updated_at":
@@ -8895,30 +8645,6 @@ export class Session {
}
}
export function normalizeCheckoutPrStatusPayload(
status: WorkspaceGitRuntimeSnapshot["github"]["pullRequest"],
): CheckoutPrStatusPayloadStatus | null {
if (!status) {
return null;
}
return {
number: status.number,
url: status.url,
title: status.title,
state: status.state,
repoOwner: status.repoOwner,
repoName: status.repoName,
baseRefName: status.baseRefName,
headRefName: status.headRefName,
isMerged: status.isMerged,
isDraft: status.isDraft ?? false,
mergeable: status.mergeable ?? "UNKNOWN",
checks: status.checks ?? [],
checksStatus: status.checksStatus,
reviewDecision: status.reviewDecision,
};
}
function isValidPullRequestTimelineIdentity(options: {
prNumber: number;
repoOwner: string;

View File

@@ -6,8 +6,11 @@ import type {
SessionInboundMessage,
SessionOutboundMessage,
WorkspaceDescriptorPayload,
WorkspaceStateBucket,
} from "./messages.js";
import {
deriveAgentStateBucket,
getWorkspaceStateBucketPriority,
} from "../shared/agent-state-bucket.js";
import { SortablePager } from "./pagination/sortable-pager.js";
import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "./workspace-registry.js";
import { normalizeWorkspaceId } from "./workspace-registry-model.js";
@@ -90,14 +93,6 @@ export function summarizeFetchWorkspacesEntries(entries: Iterable<FetchWorkspace
export class WorkspaceDirectory {
private readonly archivingByWorkspaceId = new Map<string, string>();
private readonly workspaceStatePriority: Record<WorkspaceStateBucket, number> = {
needs_input: 0,
failed: 1,
running: 2,
attention: 3,
done: 4,
};
private readonly pager = new SortablePager<
WorkspaceDescriptorPayload,
FetchWorkspacesRequestSort["key"]
@@ -109,7 +104,7 @@ export class WorkspaceDirectory {
getSortValue: (workspace, key) => {
switch (key) {
case "status_priority":
return this.workspaceStatePriority[workspace.status];
return getWorkspaceStateBucketPriority(workspace.status);
case "activity_at":
return workspace.activityAt ? Date.parse(workspace.activityAt) : null;
case "name":
@@ -202,8 +197,15 @@ export class WorkspaceDirectory {
continue;
}
const bucket = this.deriveStateBucket(agent);
if (this.workspaceStatePriority[bucket] < this.workspaceStatePriority[existing.status]) {
const bucket = deriveAgentStateBucket({
status: agent.status,
pendingPermissionCount: agent.pendingPermissions?.length ?? 0,
requiresAttention: agent.requiresAttention,
attentionReason: agent.attentionReason ?? null,
});
if (
getWorkspaceStateBucketPriority(bucket) < getWorkspaceStateBucketPriority(existing.status)
) {
existing.status = bucket;
}
}
@@ -329,21 +331,4 @@ export class WorkspaceDirectory {
},
};
}
private deriveStateBucket(agent: AgentSnapshotPayload): WorkspaceStateBucket {
const pendingPermissionCount = agent.pendingPermissions?.length ?? 0;
if (pendingPermissionCount > 0 || agent.attentionReason === "permission") {
return "needs_input";
}
if (agent.status === "error" || agent.attentionReason === "error") {
return "failed";
}
if (agent.status === "running") {
return "running";
}
if (agent.requiresAttention) {
return "attention";
}
return "done";
}
}

View File

@@ -0,0 +1,92 @@
import { describe, expect, it } from "vitest";
import {
deriveAgentStateBucket,
getAgentStatusPriority,
getWorkspaceStateBucketPriority,
} from "./agent-state-bucket.js";
describe("deriveAgentStateBucket", () => {
it("prioritizes pending permissions as needs_input", () => {
expect(
deriveAgentStateBucket({
status: "idle",
pendingPermissionCount: 1,
requiresAttention: false,
attentionReason: null,
}),
).toBe("needs_input");
});
it("keeps legacy permission attention in needs_input", () => {
expect(
deriveAgentStateBucket({
status: "idle",
pendingPermissionCount: 0,
requiresAttention: true,
attentionReason: "permission",
}),
).toBe("needs_input");
});
it("prioritizes error attention before running status", () => {
expect(
deriveAgentStateBucket({
status: "running",
pendingPermissionCount: 0,
requiresAttention: true,
attentionReason: "error",
}),
).toBe("failed");
});
it("treats unread finished agents as attention", () => {
expect(
deriveAgentStateBucket({
status: "idle",
pendingPermissionCount: 0,
requiresAttention: true,
attentionReason: "finished",
}),
).toBe("attention");
});
it("treats initializing agents as done for workspace buckets", () => {
expect(
deriveAgentStateBucket({
status: "initializing",
pendingPermissionCount: 0,
requiresAttention: false,
attentionReason: null,
}),
).toBe("done");
});
});
describe("getWorkspaceStateBucketPriority", () => {
it("orders active buckets before done", () => {
expect(
["done", "attention", "running", "failed", "needs_input"].sort(
(left, right) =>
getWorkspaceStateBucketPriority(left) - getWorkspaceStateBucketPriority(right),
),
).toEqual(["needs_input", "failed", "running", "attention", "done"]);
});
});
describe("getAgentStatusPriority", () => {
it("keeps initializing agents ahead of completed agents in agent lists", () => {
expect(getAgentStatusPriority({ status: "initializing" })).toBeLessThan(
getAgentStatusPriority({ status: "idle" }),
);
});
it("prioritizes pending permissions before errors and running agents", () => {
const permission = getAgentStatusPriority({ status: "running", pendingPermissionCount: 1 });
expect(permission).toBeLessThan(
getAgentStatusPriority({ status: "error", pendingPermissionCount: 0 }),
);
expect(permission).toBeLessThan(
getAgentStatusPriority({ status: "running", pendingPermissionCount: 0 }),
);
});
});

View File

@@ -0,0 +1,55 @@
import type { AgentLifecycleStatus } from "./agent-lifecycle.js";
import type { WorkspaceStateBucket } from "./messages.js";
export type AgentAttentionReason = "finished" | "error" | "permission" | null | undefined;
export interface AgentStateBucketInput {
status: AgentLifecycleStatus;
pendingPermissionCount?: number;
requiresAttention?: boolean;
attentionReason?: AgentAttentionReason;
}
const WORKSPACE_STATE_BUCKET_PRIORITY = {
needs_input: 0,
failed: 1,
running: 2,
attention: 3,
done: 4,
} as const satisfies Record<WorkspaceStateBucket, number>;
export function deriveAgentStateBucket(input: AgentStateBucketInput): WorkspaceStateBucket {
if ((input.pendingPermissionCount ?? 0) > 0 || input.attentionReason === "permission") {
return "needs_input";
}
if (input.status === "error" || input.attentionReason === "error") {
return "failed";
}
if (input.status === "running") {
return "running";
}
if (input.requiresAttention) {
return "attention";
}
return "done";
}
export function getWorkspaceStateBucketPriority(bucket: WorkspaceStateBucket): number {
return WORKSPACE_STATE_BUCKET_PRIORITY[bucket];
}
export function getAgentStatusPriority(input: AgentStateBucketInput): number {
if ((input.pendingPermissionCount ?? 0) > 0 || input.attentionReason === "permission") {
return 0;
}
if (input.status === "error" || input.attentionReason === "error") {
return 1;
}
if (input.status === "running") {
return 2;
}
if (input.status === "initializing") {
return 3;
}
return 4;
}

View File

@@ -0,0 +1,86 @@
const GITHUB_HOSTS = new Set(["github.com", "ssh.github.com"]);
const TRANSPORT_BY_PROTOCOL: Record<string, GitRemoteLocation["transport"]> = {
"https:": "https",
"http:": "http",
"ssh:": "ssh",
};
export interface GitRemoteLocation {
transport: "scp" | "ssh" | "http" | "https";
host: string;
path: string;
}
export interface GitHubRemoteIdentity {
owner: string;
name: string;
repo: string;
}
export function parseGitHubRemoteUrl(remoteUrl: string): GitHubRemoteIdentity | null {
const location = parseGitRemoteLocation(remoteUrl);
if (!location || !isGitHubHost(location.host)) return null;
return parseGitHubRemoteIdentity(location.path);
}
export function parseGitRemoteLocation(remoteUrl: string): GitRemoteLocation | null {
const trimmed = remoteUrl.trim();
if (!trimmed) return null;
const scpLike = trimmed.match(/^[^@]+@([^:]+):(.+)$/u);
if (scpLike) {
const host = normalizeHost(scpLike[1] ?? "");
const path = normalizeRemotePath(scpLike[2] ?? "");
if (!isValidRemoteHost(host) || !path) return null;
return { transport: "scp", host, path };
}
let parsed: URL;
try {
parsed = new URL(trimmed);
} catch {
return null;
}
const transport = TRANSPORT_BY_PROTOCOL[parsed.protocol.toLowerCase()];
if (!transport) return null;
const host = normalizeHost(parsed.hostname);
let path: string;
try {
path = decodeURIComponent(parsed.pathname);
} catch {
return null;
}
const normalizedPath = normalizeRemotePath(path);
if (!isValidRemoteHost(host) || !normalizedPath) return null;
return { transport, host, path: normalizedPath };
}
export function parseGitHubRemoteIdentity(path: string): GitHubRemoteIdentity | null {
const segments = path.split("/").filter(Boolean);
if (segments.length !== 2) return null;
const [owner, name] = segments;
if (!owner || !name) return null;
return { owner, name, repo: `${owner}/${name}` };
}
export function isGitHubHost(host: string): boolean {
return GITHUB_HOSTS.has(host);
}
export function normalizeHost(host: string): string {
return host.trim().replace(/\.+$/u, "").toLowerCase();
}
function normalizeRemotePath(path: string): string | null {
let normalized = path.trim().replace(/^\/+|\/+$/gu, "");
if (normalized.endsWith(".git")) normalized = normalized.slice(0, -4);
return normalized || null;
}
function isValidRemoteHost(host: string): boolean {
return /^[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?$/u.test(host);
}

View File

@@ -1,13 +1,11 @@
import type { Task, TaskStore } from "./types.js";
function sortByPriorityThenCreated(a: Task, b: Task): number {
if (a.priority !== undefined && b.priority === undefined) return -1;
if (a.priority === undefined && b.priority !== undefined) return 1;
if (a.priority !== undefined && b.priority !== undefined) {
if (a.priority !== b.priority) return a.priority - b.priority;
}
return a.created.localeCompare(b.created);
}
import {
buildChildrenMap,
getTasksById,
isTaskExecutableInOrder,
loadScopedTaskGraph,
sortByPriorityThenCreated,
} from "./task-graph.js";
export interface ExecutionOrderResult {
/** Tasks in execution order (done first, then pending) */
@@ -30,55 +28,20 @@ export async function computeExecutionOrder(
store: TaskStore,
scopeId?: string,
): Promise<ExecutionOrderResult> {
const allTasks = await store.list();
const taskMap = new Map(allTasks.map((t) => [t.id, t]));
const graph = await loadScopedTaskGraph(store, scopeId);
// Get scoped tasks
let candidates: Task[];
if (scopeId) {
const scopeTask = await store.get(scopeId);
const descendants = await store.getDescendants(scopeId);
candidates = scopeTask ? [scopeTask, ...descendants] : descendants;
} else {
candidates = allTasks;
}
// Build children map
const childrenMap = new Map<string, Task[]>();
for (const t of allTasks) {
if (t.parentId) {
const siblings = childrenMap.get(t.parentId) ?? [];
siblings.push(t);
childrenMap.set(t.parentId, siblings);
}
}
const candidateIds = new Set(candidates.map((t) => t.id));
// Simulate execution: track done status
// Include all done tasks (not just in scope) for dep resolution
const simDone = new Set(allTasks.filter((t) => t.status === "done").map((t) => t.id));
const simDone = new Set(graph.doneTaskIds);
const remaining = new Set(
candidates.filter((t) => t.status === "open" || t.status === "in_progress").map((t) => t.id),
graph.candidates
.filter((t) => t.status === "open" || t.status === "in_progress")
.map((t) => t.id),
);
const isReady = (taskId: string): boolean => {
const task = taskMap.get(taskId);
if (!task) return false;
// All deps done (deps can be outside scope)
const depsOk = task.deps.every((depId) => simDone.has(depId));
// All children done (only consider children in scope)
const children = (childrenMap.get(taskId) ?? []).filter((c) => candidateIds.has(c.id));
const childrenOk = children.every((c) => simDone.has(c.id));
return depsOk && childrenOk;
};
const timeline: Task[] = [];
const orderMap = new Map<string, number>();
let orderIdx = 0;
// Done tasks first (by created date = historical execution order)
const done = candidates
const done = graph.candidates
.filter((t) => t.status === "done")
.sort((a, b) => a.created.localeCompare(b.created));
for (const t of done) {
@@ -88,12 +51,11 @@ export async function computeExecutionOrder(
// Then pending tasks in execution order
while (remaining.size > 0) {
const readyNow = [...remaining]
.filter(isReady)
.map((tid) => taskMap.get(tid)!)
const readyNow = getTasksById(graph, remaining)
.filter((task) => isTaskExecutableInOrder(graph, task.id, simDone))
.sort(sortByPriorityThenCreated);
if (readyNow.length === 0) break; // No more can be done (cycle or blocked)
if (readyNow.length === 0) break;
const next = readyNow[0];
timeline.push(next);
@@ -115,17 +77,8 @@ export function buildSortedChildrenMap(
tasks: Task[],
orderMap: Map<string, number>,
): Map<string, Task[]> {
const childrenMap = new Map<string, Task[]>();
const childrenMap = buildChildrenMap(tasks);
for (const t of tasks) {
if (t.parentId) {
const siblings = childrenMap.get(t.parentId) ?? [];
siblings.push(t);
childrenMap.set(t.parentId, siblings);
}
}
// Sort each group by execution order
for (const [parentId, children] of childrenMap) {
children.sort((a, b) => {
const orderA = orderMap.get(a.id) ?? Infinity;

View File

@@ -0,0 +1,131 @@
import type { Task, TaskStore } from "./types.js";
export interface TaskGraph {
allTasks: Task[];
candidates: Task[];
taskMap: Map<string, Task>;
childrenMap: Map<string, Task[]>;
candidateIds: Set<string>;
doneTaskIds: Set<string>;
}
type TaskGraphStore = Pick<TaskStore, "list" | "get" | "getDescendants">;
export function sortByPriorityThenCreated(a: Task, b: Task): number {
if (a.priority !== undefined && b.priority === undefined) return -1;
if (a.priority === undefined && b.priority !== undefined) return 1;
if (a.priority !== undefined && b.priority !== undefined) {
if (a.priority !== b.priority) return a.priority - b.priority;
}
return a.created.localeCompare(b.created);
}
export function buildTaskMap(tasks: Task[]): Map<string, Task> {
return new Map(tasks.map((task) => [task.id, task]));
}
export function buildChildrenMap(tasks: Task[]): Map<string, Task[]> {
const childrenMap = new Map<string, Task[]>();
for (const task of tasks) {
if (task.parentId) {
const siblings = childrenMap.get(task.parentId) ?? [];
siblings.push(task);
childrenMap.set(task.parentId, siblings);
}
}
return childrenMap;
}
export function isReadyTask(graph: TaskGraph, task: Task): boolean {
return (
task.status === "open" &&
areTaskDepsDone(graph, task, graph.doneTaskIds) &&
areTaskChildrenDone(graph, task.id, graph.doneTaskIds)
);
}
export function isBlockedTask(graph: TaskGraph, task: Task): boolean {
return (
task.status !== "draft" &&
task.status !== "done" &&
task.deps.length > 0 &&
!areTaskDepsDone(graph, task, graph.doneTaskIds)
);
}
export function isTaskExecutableInOrder(
graph: TaskGraph,
taskId: string,
completedTaskIds: Set<string>,
): boolean {
const task = graph.taskMap.get(taskId);
return (
task !== undefined &&
areTaskDepsDone(graph, task, completedTaskIds) &&
areTaskChildrenDone(graph, task.id, completedTaskIds, { scoped: true })
);
}
export function getTasksById(graph: TaskGraph, taskIds: Iterable<string>): Task[] {
const tasks: Task[] = [];
for (const taskId of taskIds) {
const task = graph.taskMap.get(taskId);
if (task) {
tasks.push(task);
}
}
return tasks;
}
export async function loadScopedTaskGraph(
store: TaskGraphStore,
scopeId?: string,
): Promise<TaskGraph> {
const allTasks = await store.list();
const candidates = await loadScopedCandidates(store, allTasks, scopeId);
return {
allTasks,
candidates,
taskMap: buildTaskMap(allTasks),
childrenMap: buildChildrenMap(allTasks),
candidateIds: new Set(candidates.map((task) => task.id)),
doneTaskIds: new Set(allTasks.filter((task) => task.status === "done").map((task) => task.id)),
};
}
function areTaskDepsDone(graph: TaskGraph, task: Task, completedTaskIds: Set<string>): boolean {
return task.deps.every((depId) => {
const dep = graph.taskMap.get(depId);
return dep !== undefined && completedTaskIds.has(depId);
});
}
function areTaskChildrenDone(
graph: TaskGraph,
taskId: string,
completedTaskIds: Set<string>,
options?: { scoped: boolean },
): boolean {
const children = graph.childrenMap.get(taskId) ?? [];
return children.every((child) => {
if (options?.scoped === true && !graph.candidateIds.has(child.id)) {
return true;
}
return completedTaskIds.has(child.id);
});
}
async function loadScopedCandidates(
store: TaskGraphStore,
allTasks: Task[],
scopeId?: string,
): Promise<Task[]> {
if (!scopeId) {
return allTasks;
}
const scopeTask = await store.get(scopeId);
const descendants = await store.getDescendants(scopeId);
return scopeTask ? [scopeTask, ...descendants] : descendants;
}

View File

@@ -2,27 +2,17 @@ import { readdir, readFile, writeFile, mkdir, unlink } from "node:fs/promises";
import { join } from "node:path";
import { randomBytes } from "node:crypto";
import type { Task, TaskStore, CreateTaskOptions, TaskStatus } from "./types.js";
import {
isBlockedTask,
isReadyTask,
loadScopedTaskGraph,
sortByPriorityThenCreated,
} from "./task-graph.js";
function generateId(): string {
return randomBytes(4).toString("hex");
}
function sortByPriorityThenCreated(a: Task, b: Task): number {
// Tasks with priority come before tasks without
if (a.priority !== undefined && b.priority === undefined) return -1;
if (a.priority === undefined && b.priority !== undefined) return 1;
// If both have priority, lower number = higher priority
if (a.priority !== undefined && b.priority !== undefined) {
if (a.priority !== b.priority) {
return a.priority - b.priority;
}
}
// Fall back to created date (oldest first)
return a.created.localeCompare(b.created);
}
function serializeTask(task: Task): string {
const frontmatterLines = [
"---",
@@ -267,82 +257,20 @@ export class FileTaskStore implements TaskStore {
}
async getReady(scopeId?: string): Promise<Task[]> {
const allTasks = await this.list();
const taskMap = new Map(allTasks.map((t) => [t.id, t]));
let candidates: Task[];
if (scopeId) {
// Include the scoped task itself and all its descendants (children tree)
const scopeTask = await this.get(scopeId);
const descendants = await this.getDescendants(scopeId);
candidates = scopeTask ? [scopeTask, ...descendants] : descendants;
} else {
candidates = allTasks;
}
// Build children map for quick lookup
const childrenMap = new Map<string, Task[]>();
for (const t of allTasks) {
if (t.parentId) {
const siblings = childrenMap.get(t.parentId) ?? [];
siblings.push(t);
childrenMap.set(t.parentId, siblings);
}
}
const isReady = (task: Task): boolean => {
if (task.status !== "open") return false;
// All deps must be done
const depsReady = task.deps.every((depId) => {
const dep = taskMap.get(depId);
return dep?.status === "done";
});
if (!depsReady) return false;
// All children must be done (if any exist)
const children = childrenMap.get(task.id) ?? [];
return children.every((c) => c.status === "done");
};
// Sort by priority first (lower = higher priority), then created date
return candidates.filter(isReady).sort(sortByPriorityThenCreated);
const graph = await loadScopedTaskGraph(this, scopeId);
return graph.candidates
.filter((task) => isReadyTask(graph, task))
.sort(sortByPriorityThenCreated);
}
async getBlocked(scopeId?: string): Promise<Task[]> {
const allTasks = await this.list();
const taskMap = new Map(allTasks.map((t) => [t.id, t]));
let candidates: Task[];
if (scopeId) {
const scopeTask = await this.get(scopeId);
const descendants = await this.getDescendants(scopeId);
candidates = scopeTask ? [scopeTask, ...descendants] : descendants;
} else {
candidates = allTasks;
}
const isBlocked = (task: Task): boolean => {
if (task.status === "draft" || task.status === "done") return false;
if (task.deps.length === 0) return false;
return task.deps.some((depId) => {
const dep = taskMap.get(depId);
return dep?.status !== "done";
});
};
return candidates.filter(isBlocked);
const graph = await loadScopedTaskGraph(this, scopeId);
return graph.candidates.filter((task) => isBlockedTask(graph, task));
}
async getClosed(scopeId?: string): Promise<Task[]> {
let candidates: Task[];
if (scopeId) {
const scopeTask = await this.get(scopeId);
const descendants = await this.getDescendants(scopeId);
candidates = scopeTask ? [scopeTask, ...descendants] : descendants;
} else {
candidates = await this.list();
}
const { candidates } = await loadScopedTaskGraph(this, scopeId);
// Sort by created date (most recent first) for consistent ordering
return candidates
.filter((t) => t.status === "done")
.sort((a, b) => b.created.localeCompare(a.created));

View File

@@ -870,6 +870,10 @@ async function getWorktreePathForBranch(cwd: string, branchName: string): Promis
}
}
export async function localBranchExists(cwd: string, branchName: string): Promise<boolean> {
return doesGitRefExist(cwd, `refs/heads/${branchName}`);
}
export async function renameCurrentBranch(
cwd: string,
newName: string,

View File

@@ -1,49 +1,32 @@
import {
isGitHubHost,
normalizeHost,
parseGitHubRemoteIdentity,
parseGitRemoteLocation,
type GitHubRemoteIdentity as ResolvedGitHubRemoteIdentity,
} from "../shared/git-remote.js";
import { findExecutable } from "./executable.js";
import { execCommand } from "./spawn.js";
const GITHUB_HOSTS = new Set(["github.com", "ssh.github.com"]);
const TRANSPORT_BY_PROTOCOL: Record<string, GitRemoteLocation["transport"]> = {
"https:": "https",
"http:": "http",
"ssh:": "ssh",
};
let sshExecutableLookup: Promise<string | null> | null = null;
const sshHostnameResolutionCache = new Map<string, Promise<string | null>>();
interface GitRemoteLocation {
transport: "scp" | "ssh" | "http" | "https";
host: string;
path: string;
}
export interface GitHubRemoteIdentity {
owner: string;
name: string;
repo: string;
}
export type SshHostnameResolver = (host: string) => Promise<string | null>;
export function parseGitHubRemoteUrl(remoteUrl: string): GitHubRemoteIdentity | null {
const location = parseGitRemoteLocation(remoteUrl);
if (!location || !GITHUB_HOSTS.has(location.host)) return null;
return parseGitHubRemoteIdentity(location.path);
}
export { parseGitHubRemoteUrl, type GitHubRemoteIdentity } from "../shared/git-remote.js";
export async function resolveGitHubRemote(input: {
remoteUrl: string;
resolveSshHostname?: SshHostnameResolver;
}): Promise<GitHubRemoteIdentity | null> {
}): Promise<ResolvedGitHubRemoteIdentity | null> {
const location = parseGitRemoteLocation(input.remoteUrl);
if (!location) return null;
if (GITHUB_HOSTS.has(location.host)) return parseGitHubRemoteIdentity(location.path);
if (isGitHubHost(location.host)) return parseGitHubRemoteIdentity(location.path);
if (location.transport !== "scp" && location.transport !== "ssh") return null;
const resolve = input.resolveSshHostname ?? resolveSshHostname;
const resolvedHost = await resolve(location.host);
if (!resolvedHost || !GITHUB_HOSTS.has(resolvedHost)) return null;
if (!resolvedHost || !isGitHubHost(resolvedHost)) return null;
return parseGitHubRemoteIdentity(location.path);
}
@@ -86,60 +69,3 @@ function parseSshHostname(stdout: string): string | null {
}
return null;
}
function parseGitRemoteLocation(remoteUrl: string): GitRemoteLocation | null {
const trimmed = remoteUrl.trim();
if (!trimmed) return null;
const scpLike = trimmed.match(/^[^@]+@([^:]+):(.+)$/u);
if (scpLike) {
const host = normalizeHost(scpLike[1] ?? "");
const path = normalizeRemotePath(scpLike[2] ?? "");
if (!isValidRemoteHost(host) || !path) return null;
return { transport: "scp", host, path };
}
let parsed: URL;
try {
parsed = new URL(trimmed);
} catch {
return null;
}
const transport = TRANSPORT_BY_PROTOCOL[parsed.protocol.toLowerCase()];
if (!transport) return null;
const host = normalizeHost(parsed.hostname);
let path: string;
try {
path = decodeURIComponent(parsed.pathname);
} catch {
return null;
}
const normalizedPath = normalizeRemotePath(path);
if (!isValidRemoteHost(host) || !normalizedPath) return null;
return { transport, host, path: normalizedPath };
}
function parseGitHubRemoteIdentity(path: string): GitHubRemoteIdentity | null {
const segments = path.split("/").filter(Boolean);
if (segments.length !== 2) return null;
const [owner, name] = segments;
if (!owner || !name) return null;
return { owner, name, repo: `${owner}/${name}` };
}
function normalizeRemotePath(path: string): string | null {
let normalized = path.trim().replace(/^\/+|\/+$/gu, "");
if (normalized.endsWith(".git")) normalized = normalized.slice(0, -4);
return normalized || null;
}
function normalizeHost(host: string): string {
return host.trim().replace(/\.+$/u, "").toLowerCase();
}
function isValidRemoteHost(host: string): boolean {
return /^[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?$/u.test(host);
}

View File

@@ -11,10 +11,10 @@
"deploy": "npm run build && wrangler deploy"
},
"dependencies": {
"@cloudflare/vite-plugin": "^1.20.3",
"@cloudflare/workers-types": "^4.20260114.0",
"@tanstack/react-router": "^1.120.3",
"@tanstack/react-start": "^1.120.3",
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
"@tanstack/react-router": "^1.166.4",
"@tanstack/react-start": "^1.166.4",
"framer-motion": "^12.35.2",
"lucide-react": "^1.7.0",
"react": "^19.1.4",
@@ -25,7 +25,7 @@
"rehype-slug": "^6.0.0",
"remark-gfm": "^4.0.1",
"shiki": "^4.0.2",
"wrangler": "^4.59.1"
"wrangler": "^4.75.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.8",
@@ -34,7 +34,7 @@
"@types/react-dom": "^19.1.5",
"@vitejs/plugin-react": "^4.5.1",
"typescript": "^5.9.3",
"vite": "^7.0.0",
"vite": "^7.3.3",
"vite-tsconfig-paths": "^5.1.4"
}
}

View File

@@ -40,8 +40,9 @@ function Privacy() {
<li>Session IDs</li>
</ul>
<p>
All messages between your phone and daemon are end-to-end encrypted with AES-256-GCM.
The relay cannot read your messages, see your code, or decrypt your traffic.
All messages between your phone and daemon are end-to-end encrypted with
XSalsa20-Poly1305. The relay cannot read your messages, see your code, or decrypt your
traffic.
</p>
</section>

View File

@@ -31,7 +31,8 @@ The relay is the simplest way to connect from your phone. It requires no VPN set
1. The daemon generates a persistent ECDH keypair and stores it in `$PASEO_HOME/daemon-keypair.json`
2. When you scan the QR code or click the pairing link, your phone receives the daemon's public key
3. Your phone sends a handshake message with its own public key. The daemon will not accept any commands until this handshake completes.
4. Both sides perform an ECDH key exchange to derive a shared secret. All subsequent messages are encrypted with AES-256-GCM.
4. Both sides perform a Curve25519 ECDH key exchange to derive a shared key. All subsequent
messages are encrypted with XSalsa20-Poly1305 (NaCl `box`).
The relay sees only: IP addresses, timing, message sizes, and session IDs. It cannot read message contents, forge messages, or derive encryption keys from observing the handshake.
@@ -40,8 +41,8 @@ The relay sees only: IP addresses, timing, message sizes, and session IDs. It ca
The daemon requires a valid cryptographic handshake before processing any commands. A compromised relay cannot:
- **Send commands**, Without your phone's private key, it cannot complete the handshake
- **Read your traffic**, All messages are encrypted with AES-256-GCM after the handshake
- **Forge messages**, GCM provides authenticated encryption; tampered messages are rejected
- **Read your traffic**, All messages are encrypted with XSalsa20-Poly1305 (NaCl `box`) after the handshake
- **Forge messages**, NaCl `box` provides authenticated encryption; tampered messages are rejected
- **Replay old messages**, Each session derives fresh encryption keys
### Trust model