Commit Graph
5446 Commits
Author SHA1 Message Date
paseo-bot[bot] 8768500fc0 Stop a replica cache read from spinning when the store rejects its writes (#5290)
* Stop a replica cache read from spinning when the store rejects its writes

A replica cache read waits for accepted commits to reach the row store
before it answers, and retries until the store agrees. When the store
rejects the write deterministically, that retry never makes progress: the
read loops on flush and read with no delay, burning a core and never
returning. The read now fails closed on a rejected write, so callers fall
back to the daemon instead of the loop saturating the renderer.

* Fail a read closed only when its own host has unwritten changes

A rejected write carries whatever was pending across every host, so
giving up the read on any rejection hid stored rows from hosts that had
nothing pending. A host whose rows are already in the store stays
readable while another host's write keeps failing.

* Bound the read count in the storage fake instead of the test body

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-23 20:45:06 +02:00
paseo-bot[bot] 89073d4c5c Keep a Claude rewind anchor off subagent messages (#5289)
Task subagent frames stream on the same query as the conversation, and
rememberTranscriptProgress recorded any assistant frame as the current turn's
rewind anchor. When a turn's last assistant frame came from a subagent, the
anchor held a uuid that lives on the subagent's sidechain rather than on the
session's own chain, so forkSession rejected it with "Message <uuid> not found
in session" and every later rewind in that conversation failed.

Skip frames that carry a parent_tool_use_id when recording anchors. The
persisted history path already skips sidechain entries; the live stream now
applies the same rule.

Closes #5284

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-23 19:45:11 +02:00
paseo-bot[bot] ec43e90670 Let a rebound pane-focus shortcut fire while typing (#5287)
The pane-focus defaults carry `editable: false` so that Cmd+Shift+Arrow
keeps selecting text in a field instead of moving pane focus. That guard
describes the default combo, not the action, but `buildEffectiveBindings`
carried it onto a user's override: rebinding focus-pane to Alt+H/J/K/L in
Settings produced a shortcut that fired everywhere except the composer,
and since switching panes focuses that pane's composer, navigation
stopped after one hop.

Drop the guard when an override replaces the combo, the same way the
default display keys are dropped. The default Cmd+Shift+Arrow still
yields to text selection.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-23 18:46:26 +02:00
paseo-bot[bot] 897a0abb1a Keep one copy of the timeline when an agent is refreshed (#5286)
* Clear the committed timeline when an agent refresh rehydrates from disk

Refreshing a loaded agent reloads it with `rehydrateFromDisk`, which wipes
the in-memory timeline so provider history can be re-streamed. It left the
committed timeline in place, and that is what `getTimelineRows` reads, so
every refresh appended a second full copy of the history: N refreshes, N
copies of every message.

Wipe both timelines on that path, the way the force-hydrate path already
does, so the re-streamed history replaces the old rows instead of stacking
on them.

* Replace the committed timeline from the replay instead of wiping it first

Deleting the committed rows on the reload path meant a history replay that
failed halfway left the agent with no transcript, and a retry appended a
fresh replay to whatever partial rows it had written.

Collect the whole replay before touching either store, then drop the rows a
previous hydration committed and write the new ones. A failed stream now
leaves the committed timeline exactly as it was, and this covers the replay
that runs when an agent is loaded after a daemon restart, which duplicated
the same way a refresh did.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-23 18:46:20 +02:00
paseo-bot[bot] c4771ca467 Rewind a Claude conversation past a turn that got no response (#5285)
A turn that dies before the model emits its first token leaves its rewind
anchor without an assistant message id. resolveConversationRewindTarget read
only the anchor immediately before the target, so that one turn made every
later rewind in the conversation throw "assistant response id was not
observed" for good.

Such a turn carries no conversation state, so walk back to the most recent
turn that did answer and fork there, falling back to a fresh session when
none did.

The rewind SDK now reaches the session as an option so the regression test
can drive revertConversation without forking a real Claude session.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-23 17:46:00 +02:00
paseo-bot[bot] 05bfffad41 Keep the voice thinking tone out of the gaps inside a spoken reply (#5281)
Closes #5061
2026-09-23 16:49:10 +02:00
paseo-bot[bot] faee1cd952 Start the daemon after a reboot hands its old PID to another process (#5277)
Closes #5257
2026-09-23 16:49:05 +02:00
paseo-bot[bot]andgengjiawen ba4595d4e8 Start a Cursor agent on a model that has no Fast variant (#5274)
* Start an ACP session when a stored feature value no longer applies

Replaying stored config onto a new ACP session already tolerates a model
the session cannot select, warning and keeping the provider default.
Feature values were the exception: they were written unconditionally, and
either Paseo's own "does not expose ACP feature" guard or the provider's
-32602 rejection escaped out of initializeNewSession, so no agent was
created.

Cursor's `fast` is a per-model option, so switching a draft from a
fast-capable model to one without a fast variant left a stored value the
new session cannot take, and agent creation failed.

Apply stored feature values best-effort at session start, matching the
model path. A user-initiated setFeature still reports the failure.

Co-authored-by: gengjiawen <technicalcute@gmail.com>

* Tolerate a provider's invalid-params only after a model switch

A stale option snapshot is only possible when the replay switched the
model, since the model write answers with an empty response. Without a
switch the options came straight from session/new, so a rejected write
is a real failure the user needs to see.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: gengjiawen <technicalcute@gmail.com>
2026-09-23 15:45:26 +02:00
34c9fd03c9 fix(codex): expose Fast for GPT-6 Sol and GPT-6 Luna (#5273)
The Codex Fast allowlist covered only gpt-6-astra in the GPT-6 family, so
selecting GPT-6 Sol or GPT-6 Luna published no fast_mode feature and the
composer had no Fast control to render. OpenAI documents Fast for Astra,
Sol, and Luna, and the app server's model/list advertises the fast speed
tier for all three.

Add both model IDs to the allowlist and to the parameterized feature test
covering the toggle, the restore path, and the serviceTier sent on turn
start.

The same change was proposed independently in #5220 and #5268.

Closes #5271

Co-authored-by: Ivan Malison <IvanMalison@gmail.com>
Co-authored-by: basilkhowaja <bk08432@st.habib.edu.pk>
2026-09-23 14:46:37 +02:00
paseo-bot[bot] 9a3f3a0dc6 fix(app): complete a multi-step shortcut whose second step holds a modifier (#5272)
A recorded sequence like Ctrl+K Ctrl+J never fired. Pressing a modifier
emits a keydown for the modifier key itself before the combo that holds
it, so a chord waiting on its second step saw a bare `Control` first.
That keydown matches no combo, and the resolver's fall-through reset the
chord to step 0, leaving the following Ctrl+J to resolve as a first step
and match nothing. A second step with no modifier was unaffected, which
is why Ctrl+K J worked.

A modifier keydown is the prefix of a combo, never a combo of its own, so
the resolver now ignores it and leaves the chord where it is. The capture
path already held that rule in `keyboardEventToComboString`; this exports
the predicate behind it rather than adding a second notion of what a
modifier key is.

Closes #5265
2026-09-23 14:46:33 +02:00
paseo-bot[bot]andIvan Malison d6861f81e5 Keep a multi-key shortcut alive when the app re-renders between its keys (#5255)
### Linked issue

Closes #4821

### Type of change

- [x] Bug fix

### Reasoning

A key sequence recorded in Settings, for example `Ctrl+K` then `J`, silently did nothing whenever the app re-rendered between the two keys. Resizing the window, toggling the sidebar, or an agent turn starting or finishing is enough, so a rebound sequence looks broken at random and the second key lands in the composer as text.

The global `keydown`/`keyup`/`blur`/`visibilitychange` listeners, and on desktop the `browser-shortcut-input` subscription, were registered by an effect that depended on eighteen render-scoped values. One of them, `useActiveWorkspaceSelection()`, returns a new object every render, so the effect tore down and re-subscribed on every render of `AppContainer`. Its cleanup resets the in-progress sequence, so the second key was resolved from step 0, matched nothing, and fell through as ordinary input.

The key handlers now read the latest render through `useStableEvent`, so the listener effect re-subscribes only when shortcuts are enabled or disabled. Clearing held badge modifiers when the workspace-index jump binding changes moved to its own effect keyed on that modifier. The cleanup still resets the sequence, which is now correct, because it only runs when shortcuts genuinely go away.

### Goals

- A recorded key sequence completes across a root re-render between its keys.
- The global shortcut listeners are registered once while shortcuts are enabled, instead of on every render.
- A regression spec that fails on the current `main` and passes with this change.

### Non-goals

- `useActiveWorkspaceSelection()` still returns a new object per render. This PR stops the shortcut listeners from depending on that identity, it does not change the hook.
- A sequence whose second step also carries a modifier (`Ctrl+K` `Ctrl+J`) is still cancelled when the modifier is re-pressed, because the bare `Ctrl` keydown does not match the next step. That is a separate defect and is not fixed here.
- The Electron `browser-shortcut-input` path is unchanged in shape and was not exercised in a desktop build.

### QA

New spec `packages/app/e2e/browser/keyboard-shortcut-sequence.spec.ts`. It seeds `Ctrl+K J` as the "Toggle command center" binding, opens a workspace, and presses the sequence twice: once with nothing in between, once with a viewport resize settled over two frames between the keys.

Before, on `main`, run twice:

```
✘ multi-key shortcut sequences › a sequence completes when the window resizes between its keys
  expect(getByTestId('command-center-panel')).toBeVisible() failed
```

The control assertion in the same test passed both times, so the sequence itself works and only the re-render breaks it. Instrumenting the hook locally showed the mechanism directly:

```
key Control -> chord step 0
key K       -> chord step 1
effect cleanup at chord step 1      <- the resize re-render
effect setup; changed deps: activeWorkspaceSelection
key j       -> chord step 0, match null
```

After, the same spec passes, and `activeWorkspaceSelection` was the only dependency that changed on those renders.

Also run locally on this branch:

- `packages/app` unit suite: 603 files, 5319 tests, all passing.
- Playwright `browser` project, keyboard and command-center specs: `keyboard-shortcut-sequence`, `empty-pane-keyboard`, `changes-shortcut-placement`, `sidebar-workspace-pin-shortcut`, `workspace-archive-shortcut`, `command-center-agent-controls`. 17 passed.
- `npm run typecheck --workspace=@getpaseo/app`, `oxlint`, and `oxfmt --check` on the changed files: clean.

Web only, in Chromium on Linux. Keyboard shortcuts are off on mobile, and the desktop build was not exercised.

### Supersedes

- [PR #4834 — fix(app): keep multi-key shortcuts alive across root re-renders](https://github.com/getpaseo/paseo/pull/4834) — same fix for the same bug. I diagnosed #4821 independently, reached the same cause, and built on that commit, so its author is credited with a `Co-authored-by` trailer. This PR replaces its spec with one that drives the app through the keyboard only, without patching `window.addEventListener` to count registrations.

### Checklist

- [x] One focused change
- [x] `npm run typecheck` passes
- [x] `npm run lint` passes
- [x] `npm run format` passes
- [x] QA evidence
- [x] Tests added or updated where it made sense

Co-authored-by: Ivan Malison <IvanMalison@gmail.com>
2026-09-23 12:47:26 +02:00
paseo-bot[bot]andMatteo Pietro Dazzi 3054f80050 Show MiniMax usage as unavailable when the account has no token plan (#5258)
### Linked issue

Closes #5247

### Type of change

- [x] Bug fix
- [ ] New feature
- [ ] Enhancement
- [ ] Refactor
- [ ] Docs

### Reasoning

A user whose MiniMax account has no active token plan opened the Usage page and saw a red **Error** badge on the MiniMax card with a block of raw JSON as its body: `[{"expected":"array","code":"invalid_type","path":["model_remains"],...}]`.

MiniMax reports application-level failures inside an HTTP 200 body — `base_resp.status_code` is non-zero and `model_remains` comes back as `null`. Two things went wrong with that response:

- `MiniMaxQuotaResponseSchema` declared `model_remains` as `z.array(...).optional()`, which admits a *missing* key but not `null`, so `.parse()` threw on a perfectly ordinary no-subscription response.
- `fetchUsage()` only checked `res.ok`, so an API-level error returned with HTTP 200 was never inspected in the first place.

The rejection propagated to `ProviderUsageService`, which stores `reason.message` as the card's error text. For a `ZodError`, that message *is* the serialized issue list — which is how the JSON blob reached the card.

This accepts `null` for `model_remains`, parses `base_resp`, and returns the plain unavailable result when the status code is non-zero, matching how a failed HTTP response is already handled in the same method. The API's own `status_msg` goes to the debug log rather than to the card: an account without a token plan is a normal state, not an error, so it should read as "Unavailable" like an unconfigured provider does — not as a red Error.

### Goals

- A MiniMax account with no active token plan renders as **Unavailable** with no error text.
- `model_remains: null` is accepted wherever it appears, with or without a `base_resp`.
- Application-level errors carried in an HTTP 200 body are detected rather than treated as success.
- Accounts that do have a plan keep rendering their interval and weekly windows unchanged.

### Non-goals

- **Generic error rendering in the Usage card.** The issue also asks that structured errors (ZodError and friends) be mapped to a concise message in the UI. That changes what every provider's card shows on schema drift, so it is a design decision for the maintainer rather than part of this fix. With the producer corrected, no ZodError arises on this path; if another provider's response shape drifts, raw issue JSON can still reach the card. Left open for the maintainer.
- Surfacing MiniMax's `status_msg` in the UI. It is logged at debug instead, so the card does not turn red for a normal no-subscription account.

### QA

Reproduced and verified through the real `ProviderUsageService` with the exact payload from the report, in `packages/server/src/services/quota-fetcher/service.test.ts`.

**Before** — `reports MiniMax accounts with no active token plan as unavailable` fails, and the failure output is the reported symptom verbatim:

```
- Expected                          + Received
-   "status": "unavailable",        +   "status": "error",
-   "error": null,                  +   "error": "[
                                    +   {
                                    +     \"expected\": \"array\",
                                    +     \"code\": \"invalid_type\",
                                    +     \"path\": [ \"model_remains\" ],
                                    +     \"message\": \"Invalid input: expected array, received null\"
                                    +   }
                                    + ]",
```

**After** — the full quota-fetcher suite passes: `Tests 71 passed (71)`.

Three tests were added, two of which are red without the provider change (confirmed by stashing only `minimax.ts`):

- `reports MiniMax accounts with no active token plan as unavailable` — the reported response (`model_remains: null` + `base_resp` 2062) → `status: "unavailable"`, `error: null`.
- `reports a null MiniMax token plan as unavailable even without base_resp` — `model_remains: null` alone.
- `still reads MiniMax windows when base_resp reports success` — guards against the new status check rejecting healthy accounts.

Also run locally: `npm run typecheck --workspace=@getpaseo/server`, `npx oxlint packages/server/src/services/quota-fetcher/`, `npx oxfmt --check packages/server/src/services/quota-fetcher/` — all clean.

No UI change, so no screenshots: with the provider fixed, the card takes its existing "Unavailable" rendering, the same one an unconfigured provider already gets.

### Checklist

- [x] Plugin changes follow the SDK import boundaries (if applicable)
- [x] One focused change
- [x] `npm run typecheck` passes
- [x] `npm run lint` passes
- [x] `npm run format` passes
- [x] QA evidence
- [x] Tests added or updated where it made sense

## Supersedes

- [PR #5252 — fix(quota): handle MiniMax accounts with no active token plan (#5247)](https://github.com/getpaseo/paseo/pull/5252) — Same diagnosis and same reported workflow. That PR returns `status: "error"` carrying MiniMax's `status_msg`, which keeps the red Error badge on the card for an account that is simply unsubscribed; this branch returns the plain unavailable result the issue asks for. It also adds cross-provider `ZodError` summarizing in `ProviderUsageService`, which is the design decision left as a non-goal above.

Co-authored-by: Matteo Pietro Dazzi <ilteoood@gmail.com>
2026-09-23 12:46:21 +02:00
paseo-bot[bot] 6d37f7fd9f Stop plugins after the agents they serve are closed (#5253)
* Close agents before stopping the plugins that serve them

A clean daemon shutdown stopped every plugin before it closed any agent.
Stopping a plugin withdraws its provider registrations, which pushes an
empty registration set through the agent provider registry, shuts the
plugin provider client down, and closes each still-open session with
`connectionClosed()`. That is reported as `session.runtime_failed` and
translated to `turn_failed`, so an agent whose last turn had completed
successfully was persisted with `lastError: "Provider connection closed"`
and came back after a restart carrying a failure it never had.

Stop tracking plugin provider registrations first so plugin teardown can
no longer withdraw a provider from under an open agent, and stop the
plugins themselves after `closeAllAgents()` and the provider runtime
shutdown have already made every session terminal. Closing the sessions
while their plugin is still running also means `session.close` now
reaches the provider.

Fixes #4877

* Bound each agent close so a silent provider cannot stall shutdown

Closing an agent asks its provider to close the session and waits for the
answer, and that request is only rejected when the provider runtime shuts
down. With agents now closing before the runtime does, a provider that
never answers `session.close` would leave `closeAllAgents()` waiting with
nothing left to unblock it.

Give each close a deadline. Whatever outlives it is abandoned and logged,
and the provider runtime shutdown that runs next rejects the request that
was still pending.

Also type the test fixture's `send` input as `ProviderInput`.

* Move the shutdown tests into the plugin lifecycle suite

Both tests exercise plugin lifecycle against a real daemon, which
lifecycle.e2e.test.ts already covers, so they go there rather than into a
suite of their own.

Drop the wall-clock assertion on how long stop() took. What the test
establishes is that stop() returns at all, and the suite timeout is what
enforces that; measuring elapsed time only added a second bound that a
loaded machine could trip.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-23 11:47:59 +02:00
paseo-bot[bot] 94ab368e4e Give daemon-spawned ACP terminals the agent's identity (#5248)
* fix(acp): give daemon-spawned terminals the agent's launch environment

A terminal the daemon opens for an ACP agent is a sibling of the agent
process, not a child, so it inherits nothing from it. createTerminal built
its environment from the requested terminal env alone, while the transport
spawn a hundred lines down already overlays the session's launchEnv. The
terminal therefore ran without PASEO_AGENT_ID or PASEO_AGENT_CWD, so
`paseo heartbeat create` refused to run and `paseo run` lost the caller,
creating child agents with no parent. When the daemon itself was started
from inside another Paseo agent, the terminal saw that agent's id instead.

Overlay launchEnv beneath the requested terminal env in both the argv and
the single-string shell branch, matching the transport spawn's precedence.

Fixes #5016

* test(acp): take createSession options as an object

The helper had grown a second optional positional parameter, making
callers read createSession(undefined, launchEnv).

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-23 09:44:02 +02:00
paseo-bot[bot] d161b5987d Dismiss an Android bottom sheet with Back instead of the screen (#5245)
* Dismiss an Android bottom sheet with Back instead of the screen

Bottom sheets render through a portal inside the app's own view tree
rather than a native modal window, so Android offered Back to the
navigator underneath them. On the model selector that meant one Back
press left the chat and the app, with the sheet still open.

`IsolatedBottomSheetModal` now claims `hardwareBackPress` for as long as
the sheet is on screen and dismisses itself instead. Listeners are
offered the press newest-first, so a stacked sheet answers before the one
below it, and Back falls back through to the navigator once no sheet is
left.

* Move the Back press onto the sheet visibility tracker

The first version held the press in `IsolatedBottomSheetModal` with its own
`isOnScreen` state and a merged ref, duplicating the phase the visibility
tracker already keeps, and it could only be tested through JSDOM with
`vi.mock` and `vi.hoisted`, which docs/testing.md rules out.

The tracker already owns presentation state and the sheet controller, so it
takes a `BackPressSource` port and holds the press for exactly the window it
is in the `presented` phase. `IsolatedBottomSheetModal` goes back to what it
was on main. Every sheet call site reaches the tracker through
`useIsolatedBottomSheetVisibility`, so coverage is unchanged.

The real source registers nothing off Android, where there is no such press
and `react-native-web`'s `BackHandler` logs an error on subscribe.

The test is now a plain unit test against a fake adapter, with no mocks,
no JSDOM, and no component mounting.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-23 08:45:00 +02:00
paseo-bot[bot] 90737e1dec fix(omp): show a stopped OMP turn as canceled, not failed (#5243)
Pressing Stop on an OMP turn could land in the thread as a failed turn
with OMP's interrupt text as the error, because OMP ends the turn with a
terminal response that carries "Interrupted by user" in errorMessage and
"aborted" in stopReason, and completeTurn classified any errorMessage as
a failure.

A terminal response whose stop reason is aborted now ends the turn as
turn_canceled and terminalizes the turn's in-flight tool calls and
subagents, matching what interrupt() already emits when it wins the race
with the provider's teardown.

A provider-idle state check that resolves after the turn already settled
no longer completes that turn, so a stop can no longer emit a second
lifecycle event behind the cancellation.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-23 07:45:48 +02:00
3eaf0be0b2 Send a Claude slash command as the last content block (#5240)
Claude Code expands a slash command only when it is the last content
block of the user message. buildAgentPrompt orders blocks as chat
history, typed text, images, then rendered attachments, so a "/command"
sent alongside a pasted issue link or a screenshot reached the model as
literal text: the command never ran, and the model either called the
Skill tool on its own or answered without it.

toSdkUserMessage now moves the typed block to the end when it parses as
a slash command. Attachments rendered as text always carry a mimeType,
so a quoted "/command" inside chat history is never mistaken for the
invocation. Message order is unchanged when the typed text is not a
slash command.

Co-authored-by: paseo-bot[bot] <paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Joe Corkerton <joecorkerton@users.noreply.github.com>
2026-09-23 06:45:57 +02:00
paseo-bot[bot]andHMWCS 8269a0ea78 Keep approvals with the user in every Codex mode but Auto-review (#5239)
A Codex mode preset named its approvals reviewer only for Auto-review.
Every other mode left the field off the thread/start and turn/start
request, so the Codex app-server fell back to `approvals_reviewer` from
the user's global config, or to whatever reviewer the live thread was
already using. Default Permissions and Read-only then behaved like
Auto-review: escalations were approved by Codex, with no approval card
and no notification.

The reviewer belongs to the Paseo mode, so the preset now always names
it and the request always carries it. An agent with no Paseo mode still
inherits the Codex configuration, unchanged.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: HMWCS <121323913+HMWCS@users.noreply.github.com>
2026-09-23 05:46:12 +02:00
paseo-bot[bot] 5303313632 Keep an archived worktree restorable after a failed re-import (#5238)
* fix(server): keep a workspace's placement when its directory is gone

Resolving a workspace read the checkout at its cwd and reconciled the
recorded placement against it. A git read answers "not a checkout" for a
plain directory and for a directory that is not there, so an archived
worktree whose directory had been removed came back as kind "directory"
with branch, worktreeRoot and mainRepoRoot null.

That rewrite happens before the import operation runs, and the failure
path only rolls back a workspace the import created, so a failed
re-import left the record erased. Workspace recovery can only recreate a
missing directory while the record still says "worktree" with a branch,
so the archived worktree stopped being restorable from Paseo and
reported workspace_directory_missing instead.

Placement is now reconciled only against a directory that is there. The
absence is resolved once, where the workspace directory is read, and
reconcileWorkspacePlacement refuses to rewrite from it, so the active
refresh path is covered by the same rule. The workspace reconciliation
pass already skipped missing directories for this reason.

Closes #5111

* fix(server): treat a directory lost mid-read as an absence

A worktree removed or unmounted while the git read is in flight makes
that read report "not a checkout", which erased the placement the same
way a directory that was already gone did. The read now counts as
evidence only while the directory is there on both sides of it.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-23 05:46:08 +02:00
paseo-bot[bot]andpaseo-bot[bot] 81ad00790f Let Stop settle an agent whose Pi or OMP runtime has exited (#5235)
* Let Stop settle an agent whose Pi or OMP runtime has exited

AgentSession.interrupt() is documented to reject "only when foreground
ownership is still uncertain", and to be a successful no-op when the
session is already idle. The Pi and OMP sessions broke that: interrupt()
issues `abort` (and `clear_queue` for Pi) over the JSONL RPC transport,
and once the child process is gone every request rejects with
"<name> process is closed".

AgentManager reads that rejection as "the provider still owns the turn",
returns `refused`, and leaves the tracked run in place. The user gets
"Cannot stop agent <id> because its active run cancellation was not
acknowledged", the agent stays pinned at `running`, and pressing Stop
again repeats it forever — only a daemon restart clears it.

A dead runtime owns nothing: no turn is running and no prompt is queued,
which is exactly the postcondition abort and clear_queue promise. Route
both through JsonlRpcProcess.requestStopWork, which resolves when the
process is gone and still surfaces real failures from a live process.
The check covers the process dying while the request is in flight, so a
race cannot reintroduce the wedge.

* Require an observed child exit before treating stop work as done

`disposed` marks the transport finished, not the child gone: `close()`
disposes before termination completes, a spawn error disposes without
touching the child, and a child can outlive SIGKILL. Keying
requestStopWork on it could report a turn canceled while the child was
still working.

Track the child's exit event instead, and resolve only on that. A
disposed transport whose child has not exited still rejects, so the
caller keeps refusing a cancellation it cannot prove.

Also move the in-flight tests' command detection into named
`exitOnCommand` helpers, and reuse the extracted `onOmpCommand` for
`replyToCommands` so the OMP file matches the Pi one.

* Let a stop survive an stdin failure that precedes the child's exit

A dying child breaks its stdin pipe a fraction before it emits `exit`.
The broken pipe fails the in-flight abort and starts a termination, and
deciding at that moment saw `exited` still false, so the stop was
refused for a runtime that was already on its way out. That is the
reported wedge again, reached through a narrower race.

close() now records the termination it starts, and requestStopWork waits
for it before deciding. A child that goes on to exit resolves the stop; a
child that outlives SIGKILL still rejects it.

---------

Co-authored-by: paseo-bot[bot] <paseo-bot[bot]@users.noreply.github.com>
2026-09-23 04:45:08 +02:00
paseo-bot[bot] 290306fd16 Read an archived agent's logs after its worktree is removed (#5229)
* Read an archived agent's history without its working directory

Loading an archived agent for history rejected when its working
directory no longer existed, so `paseo logs <archived-agent>` and
`get_agent_activity` failed with "Working directory does not exist"
once the workspace archive removed the worktree the agent ran in.

Resuming a persisted session already distinguishes an interactive
resume from a history load, but residency was settled after the
session config was prepared, so the directory check ran for both.
Settle residency first and require the directory only for a session
that will actually run in it.

* Assert the archived transcript is readable, not just loadable

* Let archived timeline reads succeed without resurrecting the workspace

The auto-archive test asserted that history reads for an archived agent
in a removed worktree are rejected. That was the old behavior, not the
invariant its comment describes. Assert the invariant instead: the
reads succeed, the removed worktree stays removed, no workspace
observation comes back, and the next agent lifecycle is unaffected.

* Archive the agent only after its turn is finalized

The regression test dispatched a run and archived immediately. Dispatch
resolves before the provider appends the reply to its history, so the
transcript the test reads back could be written after the archive, and
the read could find nothing.

Wait for the turn to be finalized first. The provider appends the
assistant message before it emits turn_completed, so once the turn is
finalized the transcript is already on disk.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-23 02:45:28 +02:00
paseo-bot[bot] 3a36cc33c6 Keep workspaces on a disk or share that is not mounted (#5227)
* fix(server): keep workspaces whose project root is unreachable

Reconciliation archived every active workspace whose cwd returned ENOENT on
a single stat, including the pass that runs at daemon startup. An external
disk that has not mounted yet, or a network share that is briefly down, made
every workspace on it disappear from the sidebar along with its sessions, and
nothing brought them back when the directory returned.

A missing path is only evidence that a workspace was removed when the project
it belongs to is still readable. When the project root is missing too, the
whole location is unavailable, and projects already persist through that.
Workspaces now follow the same rule.

Closes #4982

* fix(server): read project roots after workspace directories

Reading the project roots first left a window where a volume could go away
between the two reads, marking a project reachable while its workspace looked
missing. Reading them after inverts the skew: a volume that disappears mid-pass
makes the project unreachable, which withholds an archive instead of causing
one.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-23 00:45:12 +02:00
paseo-bot[bot] 17c5050216 Show a fork checkout's pull request in the workspace (#5221)
* Resolve a fork checkout's pull request by its head repository

A fork checkout's pull request lives in the parent repository, and its
head repository is the only thing that tells it apart from every other
fork's pull request on the same branch name. The per-checkout lookup
asked `gh pr list --repo <parent> --head <forkOwner>:<branch>`, which
`gh` cannot answer: it forwards the whole value as a head branch name,
so the query always came back empty. Every synchronous refresh then
overwrote the status the background poll had resolved, and the workspace
showed no pull request.

Fork checkouts now resolve through the REST pulls endpoint, the one
lookup that takes a head repository, and read each resulting pull
request through the ordinary view path. That also removes the
disambiguation the bare branch name would have needed: `--head main`
already returns thirty pull requests upstream, far past the ten-item
candidate page, so a fork checkout on a common branch name would still
have lost its own pull request.

The three existing fork tests asserted the unanswerable invocation while
their fake runner answered it regardless of the head value. They now run
against a runner that matches head refs the way `gh` does.

* Let a failed fork lookup surface, and keep the checked-out attempt

Two corrections to the fork pull request lookup.

An operational failure on the REST lookup, a rate limit, a timeout, a
DNS failure, was caught and returned as an empty list, so a refresh
reported "no pull request" for a checkout that has one. The lookup now
only handles what it can act on and lets the rest reach the caller, the
same way the non-fork list path does.

A fork branch reused for several attempts can carry the checkout's head
commit on an older pull request than the three the lookup reads. The
page is now requested in full and the attempts carrying the checked-out
commit are read first, so the bound on full reads no longer decides
which attempt the workspace can see.

* Use a function declaration for the head-commit predicate

* Use a function declaration for the test pull request helper

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-22 23:44:49 +02:00
morvenandMohamed Boudra 5db3bc82fd fix(app): reattach workspace labels when a connection is restored (#5079)
* fix(app): reattach workspace labels when a connection is restored

connectionChanged() drops the workspace label replica on every connection
change but never puts it back: the only caller of connectWorkspaceLabels()
is refreshAll(), which is one of the two branches of requestDemandRefresh().
Both branches mark the connection epoch as satisfied, so when route-only
demand refreshes first, the later full-directory demand early-returns and the
label channel stays detached for the whole connection.

The picker then renders rows merged from other online hosts, every row
disabled and no explanation, since the offline case has no MenuHint.

Attach the replica where the subscriptions are restored, so it no longer
depends on which demand branch wins the epoch.

* fix(app): keep the label replica attached across refreshes

Reattaching on every online connection meant refreshAll() could reconnect a
replica that was already attached, and connect() begins by disconnecting,
which publishes an offline status. The picker disables every row while a host
is offline, so a plain refresh made the menu flicker disabled.

Reuse the live connection when the same client is already attached and just
refresh the catalog. Cover both the flicker and the reconnect path, which is
the case the fix is named after.

* test(app): verify workspace labels after route refresh

The route-first regression must expose the restored label catalog used by sidebar chips, and the test client must accept the directory query passed by the public observer.

* fix(app): show label catalog load errors in picker

An attachment failure reaches the label host snapshot, but the picker only showed mutation errors. Surface the host error beside the disabled or empty label list so the failure is visible in the affected workspace.

* test(app): keep label attachment coverage in directory suite

The directory fake already owns the connection and demand setup. Reuse it for the route-first, full-demand, refresh, and reconnect cases so this regression follows the existing suite and cleanup.

* fix(app): localize label catalog load errors in picker

The host snapshot retains diagnostic details, while the workspace picker gives users the translated catalog-load message.

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-09-22 23:38:50 +02:00
Mohamed Boudra 3dbbbb535e Sponsor page rework with paid homepage sponsor spots (#5219)
* feat(website): rework sponsorship with homepage sponsor spots

The founder note now states how the project is funded (sponsorship and
Paseo Hub) instead of describing the product, and the same note and
backing links render on the homepage and /sponsor from one module so the
two cannot drift.

Companies can sponsor one of four homepage logo spots at $500 a month
through Stripe rather than GitHub Sponsors, because they need invoices
and a vendor relationship. The spots render as placeholders until sold;
approving a sponsor is an entry in src/data/sponsors.ts. The checkout
URL is a placeholder until the Stripe Payment Link exists.

* feat(website): point sponsor spot checkout at the Stripe payment link

* docs(readme): add a sponsors section

* docs(readme): add the sponsors section to the translated READMEs

* docs(website): document the sponsor approval and Stripe cap workflow
2026-09-22 22:54:11 +02:00
a3fc59e818 Keep a project in the sidebar when its folder name ends in a space (#5205)
* fix(app): keep sidebar order keys that end in whitespace

A project view key embeds the project's path, so a directory whose name
ends in a space produces a key that ends in a space. The sidebar order
store trimmed every key on write, so the stored key could never match
the key the sidebar looks up. computeSidebarOrderUpdates saw the project
as missing from the order on every pass, appended it, and the store
trimmed it away again, setting a fresh array each time. The effect that
applies those updates re-ran on every render until React gave up with
error #185, and the app failed to load until the folder was renamed.

Keep keys verbatim on the runtime setters, dropping only blank keys and
duplicates. Migration of persisted state still trims, where a stray
space is an artifact of an older format rather than part of the key.

Fixes #4880

* test(app): give AsyncStorage a real in-memory stub instead of mocking it

`docs/testing.md` bans `vi.mock`, and the test added here mocked
`@react-native-async-storage/async-storage` only because the module has no
Node implementation: its default export reaches for the native module, so
every write from the persist middleware rejects and a test that touches a
persisted store drowns in unhandled rejections.

That is exactly the case `test-stubs/` exists for, reached through the
resolve.alias in `vitest.config.ts` the same way react-native-unistyles,
react-native-svg and expo-linking are. The stub is a real in-memory
key-value store with the same contract, so a test can read back what a
store wrote rather than inspect a recording double.

`sidebar-view-store.test.ts` carried the same mock for the same reason and
no longer needs it either.

* test(app): give the AsyncStorage stub the multi-key calls the app uses

`provider-snapshot-cache` declares its own `ProviderSnapshotStorage` port and
is handed the real AsyncStorage, so it calls `multiGet` and `multiRemove`
during reconciliation and cleanup. The stub stopped at the single-key calls,
which is fine until a test reaches the provider cache and then fails with
`storage.multiGet is not a function`.

`multiSet` comes along with them so the three multi-key calls behave as a set
rather than leaving the next caller to notice the gap the same way.

* test(app): cover a project folder name ending in a space end to end

Adds a browser regression for #4880: the daemon accepts a directory whose
name ends in a space, and the sidebar has to render its project row instead
of tearing the app down with "Maximum update depth exceeded" on every load.

* test(app): move the trailing-space spec setup behind a seeded-project helper

docs/testing.md asks for E2E bodies that read like plain English with the
setup in helpers, and for helpers that own the project record until cleanup.
The spec did its directory creation, daemon seeding, and removal inline.

seedProjectNamedWithTrailingSpace owns the directory and the daemon project
and returns a cleanup, the same shape seedWorkspace returns, which cannot be
used here because mkdtemp appends the random suffix and so cannot produce a
name that ends in a space. The body is now goto, assert the row, assert no
error screen.

The row is still located by its sidebar-project-row test id, which is how
every other sidebar spec and the shared sidebar helpers address a project row.

---------

Co-authored-by: L4XB <lukas.buck@e-mail.de>
Co-authored-by: paseo-bot[bot] <paseo-bot[bot]@users.noreply.github.com>
2026-09-22 22:06:24 +02:00
paseo-ai[bot] bc5bc969c8 fix: update lockfile signatures and Nix hash [skip ci] 2026-09-22 18:44:27 +00:00
Mohamed Boudra 8186585201 chore(release): cut 0.9.1 v0.9.1 2026-09-22 20:34:13 +02:00
Mohamed Boudra eb665a55a8 docs(changelog): add 0.9.1 entry 2026-09-22 20:30:04 +02:00
Mohamed Boudra d22737c77a chore: keep heap snapshots out of git
The daemon dumps a heap snapshot next to the server package when it nears
its heap limit. Those files are multi-gigabyte debugging artifacts we want
to keep on disk, not stage.
2026-09-22 20:30:04 +02:00
aeb98f813a feat(claude): add Opus 5.5 with its model capabilities (#5200)
feat(claude): add Opus 5.5 with its model capabilities

Combine the catalog and runtime identity work from #5191 with the
per-model effort default from #5197. Claude Code 2.1.280 introduces
Opus 5.5 with 1M context, medium effort, and always-on thinking.
Resolve prefixed minor model releases before their major version so
Opus 5.5 retains its identity and capabilities after a turn.

Co-authored-by: Leonardo G. Urci <leonardoguimaraesurci@gmail.com>
Co-authored-by: Sebastian Galindo <seb@jexhq.com>
Co-authored-by: Rodrigo <rodrigo.parisusao25201@gmail.com>
2026-09-22 20:18:00 +02:00
Mohamed BoudraandBag Raul Cristian c8a7667a03 fix(app): keep conversations in the sidebar across a sequenced catch-up (#5189)
When the client reconnects with a live sync cursor, the daemon answers
with only the agents that changed, and the replica rebuilds the merged
directory from what it already holds. That rebuild went through
projectAgentDirectoryEntry, which returned null for any agent without a
projectPlacement, so those members never reached the merged map,
commitSnapshot treated them as absent, and removeFromDirectory deleted
them from the store.

An agent hydrated from its timeline or restored from the replica cache
has no placement: neither normalizeAgentSnapshot nor deserializeAgent
sets one. Every other path in the same flow derives a placement from the
working directory instead of dropping the agent, so the projection does
the same now via resolveProjectPlacement, and the function is total.

The conversations were always intact on the daemon; only the client lost
them, which is why a full restart brought them back until the next
reconnect drained the sidebar again.

Co-authored-by: Bag Raul Cristian <17720135+bagutzu@users.noreply.github.com>
2026-09-22 18:56:05 +02:00
4c051388e1 Reach every Codex conversation in Import session (#5174)
* fix(codex): page through Codex threads when listing importable sessions

Codex answers thread/list with at most 100 rows per response whatever
limit is requested, and returns a cursor for the rest. The adapter read
the first response and returned, so Import session could only ever offer
the newest 100 Codex threads: on a store of 456 threads, a host-wide
search that asks for a 500-thread scan saw 100.

Follow nextCursor until the caller's window is full, and ask Codex to
page by last use so the scanned window matches how the import sheet ranks
candidates. Unknown keys are ignored by Codex, so older builds keep their
creation-time order.

Closes #5073

Co-authored-by: 3ae3ae <83870598+3ae3ae@users.noreply.github.com>

* fix(codex): end the thread scan on a page that carries no threads

The previous guard only caught a cursor that repeated immediately, so a
Codex cursor cycle across two or more pages (A to B to A) kept paging
for ever behind a caller that had already timed out.

Every page that continues the scan now has to carry rows, which bounds
the loop by the requested window whatever cursors Codex hands back.

* fix(codex): keep a thread once when the scan sees it on two pages

Paging by last use means a thread touched while the scan is running
sorts to the front and comes back on a later page, so the same
conversation could be offered twice and displace another one inside the
window.

Identify each thread as it arrives and keep it once. A page that brings
nothing new now ends the scan, which also covers a cursor that cycles
while still serving rows.

---------

Co-authored-by: paseo-bot <paseo-bot@users.noreply.github.com>
Co-authored-by: 3ae3ae <83870598+3ae3ae@users.noreply.github.com>
2026-09-22 17:20:31 +02:00
paseo-ai[bot] 91d9cf1dbd fix: update lockfile signatures and Nix hash [skip ci] 2026-09-22 10:36:31 +00:00
Mohamed Boudra 7f7e60bcbb chore(release): cut 0.9.0 v0.9.0 2026-09-22 12:25:40 +02:00
Mohamed Boudra 8b6e9447f4 docs(changelog): promote 0.9.0 to stable
Collapses the 0.9.0-beta.1 and 0.9.0-beta.2 entries into one stable entry
covering v0.8.0..83f9fba20. Adds #4413; the other post-beta.2 fixes correct
regressions introduced inside this same range.
2026-09-22 12:20:28 +02:00
Mohamed Boudra 83f9fba209 Keep the draft-create handoff until the new chat is current (#5168)
* fix(app): keep the draft-create handoff until the new chat is current

Creating an agent from the draft composer on web flashed the "Updating
messages" panel toast for 40-200ms. That toast means "this chat may be out
of date, we are catching up" — a chat this client just created cannot be.

The timeline reducer released the create handoff at the first synchronized
page, but the visibility catch-up flag only clears once catch-up settles,
and a fresh chat always has a parked follow-up "after" page because live
rows arrive during the tail fetch. Between the two releases the state
machine saw a hydrated chat with no handoff and a pending catch-up, which
resolves to the resume toast. #4863 turned that branch from silent to
status, which is what made the window visible.

Both signals now flip in one synchronous call: createViewedTimelineSync
gains an onTimelineCurrent port, called from setVisibilityCatchUpReady —
the single place the sync declares a chat current, already covering the
parked follow-up and the fetchLatestTail overflow path. The owner
implements it by clearing the handoff. The optimistic "running" override
therefore lasts one extra round trip.

The state machine and sync-status rules are unchanged; they were correct.

* fix(app): release the create handoff when a chat leaves timeline demand

Closing a new chat's tab before catch-up settled left its sent attempt in
useCreateFlowStore forever. Reopening it in the same session then supplied
optimistic-create continuity for an authoritative chat: the panel showed it
as running and suppressed "Updating messages" while the real catch-up ran —
the state this fix exists to protect. An offline or slow host widens that
window from milliseconds to minutes.

Leaving the demanded set ends the owner's obligation to a chat exactly as
reaching current does, so onTimelineCurrent becomes onCatchUpEnded and fires
on both. It reports after the membership commit, so the sync never describes
itself mid-transition. Disconnect and backgrounding preserve demand and
therefore preserve the handoff.
2026-09-22 11:57:45 +02:00
Mohamed Boudra b2ce2bcb83 Count chat Find matches across the whole chat (#5167)
* fix(app): count chat Find matches across the whole chat

Chat Find searched the whole chat but its counter read "1 of N in
message", where N was the occurrences inside the selected message. The
per-message split is an implementation detail of how the client verifies
and highlights a hit; a reader counting matches is counting the scope
they searched. The counter now reads "3 of 7" for the position across
every message that holds a hit, and Next/Previous walk that sequence,
crossing message boundaries and wrapping at the ends. Per-message
occurrence stays inside the model as navigation state.

The total needs a per-message count for messages that are not on screen,
so the daemon now reports one. It counts with the same matcher the
browser uses on rendered text — query words joined by any whitespace,
case-insensitive, never across a Markdown block boundary — which also
stops it from reporting locations the client would reject. The client
replaces the estimate with the rendered count as each message is
revealed, so the total is exact for everything that has been on screen.

`count` is optional on the wire and tagged COMPAT(timelineSearchCount):
a host that omits it is worth one match per located message until a
reveal corrects it.

* fix(server): stop chat search reporting matches the browser cannot show

Falling back to the whole Markdown source when the per-block scan found
nothing reported a match whenever the query words straddled a block
boundary — "one target" across "target one\n\ntarget two" — and on
syntax the reader never sees. The client always rejected those
locations, so before the whole-chat counter the cost was a wasted round
trip. Now that the total sums the host's per-message counts, a rejected
location inflates the number the reader sees until navigation reaches
that message and drops it. The block scan alone is the whole rule.

The model's Location derives from the wire type again, with count
narrowed to required: the search boundary fills in the COMPAT default,
so the interior never re-checks it.
2026-09-22 10:36:13 +02:00
Mohamed Boudra 135a3b4c9e Find text in assistant messages that streamed in the open chat (#5146)
* fix(app): find text in assistant messages that streamed in the open chat

Chat find failed with the generic "check the host connection" text
whenever the oldest hit sat in a response that streamed while the chat
was open and had more than one Markdown block. Presentation rendered
such a message as one row per block with ids `<messageId>:block:n`,
while hydrated history stayed one row with the raw message id, and every
message-addressed DOM lookup (find reveal, scroll-to-message, history
reveal, the render-cap expansion) only knew the raw id. The reveal step
polled for a row that never existed and gave up after five seconds.

The defect was the dual rendering, so this removes it: every assistant
message is a block group however it arrived, single-paragraph messages
included, so a row id is never a message id. Rows carry
`data-message-id`, `getStreamItemMessageId` is the one way from a row to
its message, and find, scroll-to-message, history reveal and selection
copy address messages through it. Chat find force-mounts every row of
the selected message under virtualization so counts span the whole
message, and pins the chosen occurrence to its row while the scroll
settles. Link reference definitions fold into their neighbouring block
so history never paints an empty row. Find failures now say what went
wrong (connection, history changed, could not show the match) instead
of blaming the connection for everything.

* fix(app): let the Markdown parser decide which blocks are link definitions

A hand-rolled CommonMark regex rejected destinations markdown-it accepts,
such as escaped spaces, so those definitions still became their own empty
row and the neighbouring paragraph lost its link. The block splitter now
parses the block with the same parser: no tokens plus registered
references means definitions only. The two new chat find journeys move
their mechanics into named helpers so the test bodies read as intent.
2026-09-21 22:31:08 +02:00
Mohamed Boudra 2c8e8a8268 fix(app): use Command+F for Find on macOS across chat, file, and terminal (#5129)
Control+F moves the caret forward on macOS, so chat Find and file pane Find
were stealing a keystroke the text editor owns. Chat Find listens on
`document` in the capture phase, so the composer received an already-cancelled
Control+F and the caret never moved.

Each Find surface carried its own copy of the shortcut predicate and only the
terminal's was platform-aware. There is now one predicate, `isFindShortcut` in
`@/pane-find`, that takes the platform as a parameter: Command without Control
on macOS, Control without Command elsewhere, no Shift or Alt. Chat Find, file
pane Find, the Find widget's own query box, terminal Find, and the terminal
emulator runtime all call it, and `terminal-find-shortcut.ts` is gone.

App surfaces source the platform from `findShortcutPlatform()`, which wraps the
app's existing `getShortcutOs()`. The predicate stays free of React Native
imports because the terminal emulator runtime is bundled on its own into the
terminal WebView; its default platform and `getShortcutOs()`'s browser branch
now share one user-agent read in `@/utils/mac-user-agent`.

Closes #5097
2026-09-21 12:34:41 +02:00
ee7949ae25 fix(pi): honor per-model thinking configuration (#4413)
Derive Pi thinking options from each model's thinkingLevelMap, read Pi's effective level back after create, resume, set_model and set_thinking_level, surface failed state reads instead of persisting the requested level, and show the thinking control for single-level models.

Closes #4382

Co-authored-by: therainisme <therainisme@qq.com>
Co-authored-by: trim21 <i@trim21.me>
2026-09-21 11:53:52 +02:00
paseo-ai[bot] d636abd7a4 fix: update lockfile signatures and Nix hash [skip ci] 2026-09-18 09:49:37 +00:00
Mohamed Boudra e9d32a17d6 chore(release): cut 0.9.0-beta.2 v0.9.0-beta.2 2026-09-18 11:41:13 +02:00
Mohamed Boudra 0fd1db5748 docs(changelog): add 0.9.0-beta.2 entry
Covers v0.9.0-beta.1..9696f4226. Only #5040 is user-facing; the other
commits in range are CI and Nix hash changes.
2026-09-18 11:36:22 +02:00
Mohamed Boudra 9696f42264 Subscribe only the chats this session opened (#5040)
* fix(app): subscribe only the chats this session opened

Open-chat membership was sourced from the persisted workspace layout, which is
restored from disk at launch and carries a tab for every chat the user has ever
opened on the host. Every member got an authoritative timeline catch-up at
session start, and each of those fetches resumed its agent on the daemon: one
provider session per historical tab, every launch.

Two things followed from each resume. Registration stamped `updatedAt` over the
restored timestamp, and the resumed agent started with no attention because the
resume path never read the stored flag. Both were persisted, so restarting Paseo
rewrote every workspace's last-used time in the sidebar and cleared Ready to
review on chats the user never looked at. The timestamp loss was permanent:
workspace `statusEnteredAt` is re-derived from `updatedAt` on every daemon start.

Keep the open set on the session instead. A chat joins it the first time the user
opens it and stays until its tab closes or the session ends, so hiding, switching
workspaces, evicting a retained view, backgrounding and reconnect all preserve the
subscription, with no recent-agent limit. Layout keeps its release role and loses
its source role.

Harden the producer as well, so a resume from any caller stays inert: registration
adopts restored timestamps rather than stamping now, and resume carries the stored
attention through.

* fix(agents): keep a resumed agent's updatedAt from moving backwards

Dropping the registration stamp exposed a skew the stamp had been erasing.
`extractTimestamps` read `lastActivityAt ?? updatedAt`, but the two diverge:
renaming, labelling, restoring from archive, marking unread, and clearing
attention all move `updatedAt` on an unloaded agent without touching
`lastActivityAt`. Eight of 854 agent records on one dev machine carry that skew,
up to eight days of it.

A resume then handed clients a timestamp older than one they already held, and
`acceptAgentDirectoryUpdate` drops any state update that goes backwards. Marking
a chat unread without opening it, then opening it, left the unread dot stuck in
History, the agent list, Command Center and the tab: the daemon cleared attention
and the client refused the update.

Read both fields and take the newest, which is already the rule
`resolveStoredAgentPayloadUpdatedAt` applies to the list payload. Move that
function beside the other stored-record readers as `resolveStoredAgentUpdatedAt`
so one rule covers both paths.

Also replace the `!options?.updatedAt` inference in `registerSession` with an
explicit `restoring` flag. The inference was correct for all four call sites, but
it made a second meaning out of a field, and the next caller to seed a timestamp
would have opted out of activity stamping silently.

* test(app): assert a reloaded layout subscribes only the chat it restores into

No e2e reloaded the page with a populated workspace layout and asserted which
agents the daemon acknowledged, so the regression this branch fixes had no
end-to-end guard. Opening four chats across two workspaces leaves a tab per chat
in persisted layout; after a reload only the restored chat may be subscribed,
because subscribing resumes that agent on the daemon.

Verified it fails when session-context's layout callback is pointed back at
replaceVisibleAgentIds: the three restored tabs nobody opened join the
acknowledged set.
2026-09-18 09:12:43 +00:00
Mohamed Boudra 3cc4ae2866 ci(desktop): retry release asset uploads
uploads.github.com returned HTTP 500 five times during the v0.9.0-beta.1
release, each costing a ~50 minute rebuild. The upload step passed every
artifact to one gh invocation, so a single failed asset discarded the rest
and failed the job.

Upload each asset separately with bounded retries, shared by the macOS,
Linux and Windows jobs.
2026-09-17 22:50:13 +02:00
Mohamed Boudra 0e965bcd7a ci(desktop): raise Node heap for macOS release builds
Metro's jest workers OOM transforming the generated ws-outbound.aot.js
validator, the last module in the web bundle. Two consecutive v0.9.0-beta.1
macOS x64 builds died at 5081/5082 modules on that file.

Set max-old-space-size per runner: 8192 on the 14 GB Intel runner, 4096 on
the 7 GB arm64 runner.
2026-09-17 21:58:02 +02:00
paseo-ai[bot] 2f3272490e fix: update lockfile signatures and Nix hash [skip ci] 2026-09-17 17:49:56 +00:00
Mohamed Boudra 7c1958f5b0 chore(release): cut 0.9.0-beta.1 v0.9.0-beta.1 2026-09-17 19:39:33 +02:00
Mohamed Boudra ed9b51f94e docs(changelog): add 0.9.0-beta.1 entry
Covers v0.8.0..5d70ab2ab (57 PRs). Streaming-fade work (#4859) is omitted:
it landed and was reverted by #5013 within this range.
2026-09-17 19:30:13 +02:00