Compare commits

..
Author SHA1 Message Date
paseo-bot[bot]andGeorge 59e63fbb5e test(muse): exercise child history replay without a live setup turn
Seed a historical child after closing an empty session so the replay
regression reaches resume even when live subagent projection is broken.
Both paths now fail independently with the reported negotiation error.

Co-authored-by: George <90179633+georgepullen@users.noreply.github.com>
2026-10-28 11:41:11 +00:00
George 51e844b540 test(plugins): launch fake muse through the provider command override 2026-10-28 11:38:22 +00:00
George 367a9d908b test(plugins): cover muse subsession negotiation through PluginRuntime 2026-10-28 11:38:22 +00:00
George eef309ec06 docs(plugins): require session.subsession negotiation before opening a child 2026-10-28 11:38:22 +00:00
George d75f60ebcc test(muse-provider): reach the subsession gate with a child-bearing fixture 2026-10-28 11:38:22 +00:00
George 6a4c05ce82 fix(muse-provider): negotiate session.subsession and gate child projection 2026-10-28 11:38:21 +00:00
163 changed files with 1696 additions and 4567 deletions
+6 -17
View File
@@ -1,17 +1,6 @@
# Changelog
Not released yet. These changes are on `main` but not in 0.14.0-beta.2; the next release entry includes them.
Added:
- Copy gitignored files selected by `.worktreeinclude` into new worktrees before setup and agents, using the built-in `worktree-include` plugin ([#8100](https://github.com/getpaseo/paseo/pull/8100))
- Let plugins choose worktree paths and creation options through `worktree.create.before`, with listing, archive, deletion, and recovery at custom locations; includes a sibling-directory placement example ([#8117](https://github.com/getpaseo/paseo/pull/8117))
Improved:
- Added `server.addHook` with `subject.operation.position` names and the awaited `worktree.create.after` hook before setup and agents; deprecated the previous plugin hook registration methods without changing their behavior ([#8081](https://github.com/getpaseo/paseo/pull/8081))
## 0.14.0-beta.2 - 2026-10-28
## 0.14.0-beta.1 - 2026-10-27
Plugins can now add tools to individual agents and configure their MCP servers when sessions open.
@@ -21,8 +10,9 @@ Voice settings now let you configure speech providers and download local models
### Added
- Copy gitignored files selected by `.worktreeinclude` into new worktrees before setup and agents, using the built-in `worktree-include` plugin ([#8100](https://github.com/getpaseo/paseo/pull/8100))
- Let plugins give tools to specific agents ([#7938](https://github.com/getpaseo/paseo/pull/7938))
- Added Chat display settings that group tool calls or collapse completed conversation turns ([#7785](https://github.com/getpaseo/paseo/pull/7785), [#7990](https://github.com/getpaseo/paseo/pull/7990), [#8026](https://github.com/getpaseo/paseo/pull/8026), [#8042](https://github.com/getpaseo/paseo/pull/8042), [#8109](https://github.com/getpaseo/paseo/pull/8109) by [@akaready](https://github.com/akaready), [@Dey11](https://github.com/Dey11), [@Sebeb](https://github.com/Sebeb), [@Waishnav](https://github.com/Waishnav))
- Added collapse and expand controls for completed conversation turns ([#7785](https://github.com/getpaseo/paseo/pull/7785), [#7990](https://github.com/getpaseo/paseo/pull/7990), [#8026](https://github.com/getpaseo/paseo/pull/8026) by [@akaready](https://github.com/akaready), [@Dey11](https://github.com/Dey11), [@Sebeb](https://github.com/Sebeb), [@Waishnav](https://github.com/Waishnav))
- Let plugins add MCP servers whenever an agent session opens ([#7983](https://github.com/getpaseo/paseo/pull/7983))
- Added downloadable local speech models in Voice settings, including SenseVoice and Whisper for Chinese dictation ([#7295](https://github.com/getpaseo/paseo/pull/7295))
- Added a choice of Enter or ⌘/Ctrl+Enter to send messages ([#5503](https://github.com/getpaseo/paseo/pull/5503) by [@lukelabonte](https://github.com/lukelabonte))
@@ -32,9 +22,10 @@ Voice settings now let you configure speech providers and download local models
### Improved
- Added `server.addHook` with `subject.operation.position` names and the awaited `worktree.create.after` hook before setup and agents; deprecated the previous plugin hook registration methods without changing their behavior ([#8081](https://github.com/getpaseo/paseo/pull/8081))
- Release eligible agent runtimes after one idle hour, resuming the saved session on the next prompt ([#7782](https://github.com/getpaseo/paseo/pull/7782))
- Added a link back to the parent in subagent panes ([#7784](https://github.com/getpaseo/paseo/pull/7784))
- Opened subagents in place of the subagent list on phones, with Back returning to the list ([#8093](https://github.com/getpaseo/paseo/pull/8093))
- Added clickable links and selectable descriptions to question cards ([#7419](https://github.com/getpaseo/paseo/pull/7419) by [@NoSync](https://github.com/NoSync))
- Kept large Import session lists responsive on web ([#7872](https://github.com/getpaseo/paseo/pull/7872) by [@royenheart](https://github.com/royenheart))
- Enlarged project and workspace menu buttons on phones ([#7928](https://github.com/getpaseo/paseo/pull/7928))
@@ -60,12 +51,10 @@ Voice settings now let you configure speech providers and download local models
- Fixed outside-folder links replacing Files with an access error ([#7726](https://github.com/getpaseo/paseo/pull/7726) by [@jtomaszewski](https://github.com/jtomaszewski))
- Fixed command suggestions overlapping a growing Composer on phones ([#7910](https://github.com/getpaseo/paseo/pull/7910))
- Fixed bare Markdown URLs losing ports or stopping at colons in paths ([#7859](https://github.com/getpaseo/paseo/pull/7859) by [@mariotaku](https://github.com/mariotaku))
- Fixed inline code sizing inside emphasis, links and headings ([#7889](https://github.com/getpaseo/paseo/pull/7889), [#8088](https://github.com/getpaseo/paseo/pull/8088))
- Fixed compact navigation sizes and a cramped Settings detail pane in docked windows 600–719 wide ([#8052](https://github.com/getpaseo/paseo/pull/8052) by [@zeyugao](https://github.com/zeyugao))
- Fixed inline code sizing inside emphasis, links and headings ([#7889](https://github.com/getpaseo/paseo/pull/7889))
- Fixed Settings losing the selected host or sidebar scroll position between sections ([#7893](https://github.com/getpaseo/paseo/pull/7893), [#7899](https://github.com/getpaseo/paseo/pull/7899) by [@bingqilinweimaotai](https://github.com/bingqilinweimaotai), [@wangnan0916](https://github.com/wangnan0916))
- Fixed OpenCode v2 subagent rows missing model and token details ([#7799](https://github.com/getpaseo/paseo/pull/7799) by [@Davnn1](https://github.com/Davnn1))
- Fixed file-link tooltips staying over the preview after leaving a chat ([#7842](https://github.com/getpaseo/paseo/pull/7842))
- Fixed model discovery errors touching the model picker edges ([#8039](https://github.com/getpaseo/paseo/pull/8039) by [@ilteoood](https://github.com/ilteoood))
- Fixed model selectors staying open over Settings ([#7783](https://github.com/getpaseo/paseo/pull/7783) by [@ragokan](https://github.com/ragokan))
### Notes
+5 -17
View File
@@ -1,17 +1,6 @@
# 更新日志
尚未发布。以下变更已合入 `main`,但不包含在 0.14.0-beta.2 中;下一个版本的条目会包含它们。
新增:
- 在 setup 和 Agent 启动前,使用内置的 `worktree-include` 插件将 `.worktreeinclude` 选中的被 gitignore 忽略的文件复制到新工作树 ([#8100](https://github.com/getpaseo/paseo/pull/8100))
- 插件可通过 `worktree.create.before` 选择工作树路径和创建选项,自定义位置的工作树支持列出、归档、删除和恢复;附带同级目录放置示例 ([#8117](https://github.com/getpaseo/paseo/pull/8117))
改进:
- 新增采用 `subject.operation.position` 命名的 `server.addHook` 和在 setup 及 Agent 之前等待的 `worktree.create.after` 钩子,弃用原有的插件钩子注册方法并保留原有行为 ([#8081](https://github.com/getpaseo/paseo/pull/8081))
## 0.14.0-beta.2 - 2026-10-28
## 0.14.0-beta.1 - 2026-10-27
插件现在可以为单个 Agent 添加工具,并在会话打开时配置其 MCP 服务器。
@@ -22,7 +11,7 @@
### 新增
- 插件现在可以为特定 Agent 提供工具 ([#7938](https://github.com/getpaseo/paseo/pull/7938))
- 新增“聊天显示”设置,可将工具调用分组或折叠已完成的对话轮次 ([#7785](https://github.com/getpaseo/paseo/pull/7785), [#7990](https://github.com/getpaseo/paseo/pull/7990), [#8026](https://github.com/getpaseo/paseo/pull/8026), [#8042](https://github.com/getpaseo/paseo/pull/8042), [#8109](https://github.com/getpaseo/paseo/pull/8109) by [@akaready](https://github.com/akaready), [@Dey11](https://github.com/Dey11), [@Sebeb](https://github.com/Sebeb), [@Waishnav](https://github.com/Waishnav))
- 新增已完成对话轮次的折叠和展开功能 ([#7785](https://github.com/getpaseo/paseo/pull/7785), [#7990](https://github.com/getpaseo/paseo/pull/7990), [#8026](https://github.com/getpaseo/paseo/pull/8026) by [@akaready](https://github.com/akaready), [@Dey11](https://github.com/Dey11), [@Sebeb](https://github.com/Sebeb), [@Waishnav](https://github.com/Waishnav))
- 插件现在可以在每次打开 Agent 会话时添加 MCP 服务器 ([#7983](https://github.com/getpaseo/paseo/pull/7983))
- 语音设置新增可下载的本地语音模型,支持使用 SenseVoice 和 Whisper 进行中文听写 ([#7295](https://github.com/getpaseo/paseo/pull/7295))
- 新增 Enter 或 ⌘/Ctrl+Enter 发送消息的快捷键选项 ([#5503](https://github.com/getpaseo/paseo/pull/5503) by [@lukelabonte](https://github.com/lukelabonte))
@@ -32,9 +21,10 @@
### 改进
- 新增采用 `subject.operation.position` 命名的 `server.addHook` 和在 setup 及 Agent 之前等待的 `worktree.create.after` 钩子,弃用原有的插件钩子注册方法并保留原有行为 ([#8081](https://github.com/getpaseo/paseo/pull/8081))
- 符合条件的 Agent 运行时空闲一小时后释放,下次发送提示词时恢复已保存的会话 ([#7782](https://github.com/getpaseo/paseo/pull/7782))
- 在子 Agent 窗格中新增返回父 Agent 的链接 ([#7784](https://github.com/getpaseo/paseo/pull/7784))
- 在手机上从子 Agent 列表打开子 Agent 时直接替换列表,点按“返回”可回到列表 ([#8093](https://github.com/getpaseo/paseo/pull/8093))
- 问题卡片新增可点击链接和可选中的描述文本 ([#7419](https://github.com/getpaseo/paseo/pull/7419) by [@NoSync](https://github.com/NoSync))
- 减少网页端长会话导入列表的操作卡顿 ([#7872](https://github.com/getpaseo/paseo/pull/7872) by [@royenheart](https://github.com/royenheart))
- 增大手机上项目和工作区菜单按钮的点击区域 ([#7928](https://github.com/getpaseo/paseo/pull/7928))
@@ -60,12 +50,10 @@
- 修复工作区外的文件夹链接使 Files 显示访问错误的问题 ([#7726](https://github.com/getpaseo/paseo/pull/7726) by [@jtomaszewski](https://github.com/jtomaszewski))
- 修复手机上命令建议与不断增高的 Composer 重叠的问题 ([#7910](https://github.com/getpaseo/paseo/pull/7910))
- 修复 Markdown 裸链接丢失端口或在路径冒号处截断的问题 ([#7859](https://github.com/getpaseo/paseo/pull/7859) by [@mariotaku](https://github.com/mariotaku))
- 修复强调、链接和标题中行内代码的字号问题 ([#7889](https://github.com/getpaseo/paseo/pull/7889), [#8088](https://github.com/getpaseo/paseo/pull/8088))
- 修复宽度 600–719 的停靠布局使用紧凑导航尺寸、设置详情面板过窄的问题 ([#8052](https://github.com/getpaseo/paseo/pull/8052) by [@zeyugao](https://github.com/zeyugao))
- 修复强调、链接和标题中行内代码的字号问题 ([#7889](https://github.com/getpaseo/paseo/pull/7889))
- 修复切换设置页面时丢失已选主机或侧栏滚动位置的问题 ([#7893](https://github.com/getpaseo/paseo/pull/7893), [#7899](https://github.com/getpaseo/paseo/pull/7899) by [@bingqilinweimaotai](https://github.com/bingqilinweimaotai), [@wangnan0916](https://github.com/wangnan0916))
- 修复 OpenCode v2 子 Agent 行缺少模型和 token 详情的问题 ([#7799](https://github.com/getpaseo/paseo/pull/7799) by [@Davnn1](https://github.com/Davnn1))
- 修复离开聊天后文件链接提示仍遮挡预览的问题 ([#7842](https://github.com/getpaseo/paseo/pull/7842))
- 修复模型发现错误信息紧贴模型选择器边缘的问题 ([#8039](https://github.com/getpaseo/paseo/pull/8039) by [@ilteoood](https://github.com/ilteoood))
- 修复模型选择器在打开设置后仍然显示的问题 ([#7783](https://github.com/getpaseo/paseo/pull/7783) by [@ragokan](https://github.com/ragokan))
### 说明
+2 -24
View File
@@ -12,26 +12,6 @@ initializing → idle → running → idle (or error → closed)
Each live agent in `AgentManager` carries a `lastStatus` of `initializing`, `idle`, `running`, or `error`. `closed` is the persisted, resumable state for an agent record that has no live provider runtime. State transitions persist to disk and stream to subscribed clients via WebSocket.
## Daemon restart recovery
Hosts resume interrupted agents by default after graceful shutdown or a crash. The daemon
reopens the saved provider conversation and settings, then sends `Resume working.`. Change
`agents.resumePrompt` or disable `agents.resumeOnStartup` in host config or Host settings → Agents.
Disabling recovery consumes pending intent; enabling it later does not revive old work.
Only agents recorded as running qualify. Idle and initializing agents stay inactive. Archived
agents or workspaces, private helpers and Hub-owned executions retain their own lifecycle.
Explicit creation environment overrides are saved with the agent and reapplied when reopening.
Startup chooses candidates before anything else reads or repairs persisted running state, then
opens connections and resumes every candidate in the background. A provider that is slow to reopen
holds back only its own agent; a failed reopen is logged. Graceful shutdown records intent before
closing runtimes.
This is best-effort continuation. A crash between saving intent and dispatching a prompt can lose
or repeat a continuation. Provider resume owns conversation restoration; there is no recovery retry
loop or exactly-once turn guarantee.
## Runtime residency
An unarchived agent may be `closed` without being deleted or archived. Closing releases its provider
@@ -159,10 +139,8 @@ caller, child, and host into one watch.
Bootstrap restores these watches after repairing stale running records to `closed` and before opening
ingress. Reading a closed child's result does not load its provider. A finish notification reports that
the child's run has settled; the parent decides whether its task is complete. A child chosen for restart
recovery is observed only after its resume was sent, so the parent never hears that it was closed
in between. Archived or deleted parents suppress delivery;
parent-owned creation watches also stop
the child's run has settled; the parent decides whether its task is complete. Recovery never resumes
child work. Archived or deleted parents suppress delivery; parent-owned creation watches also stop
reporting after detachment.
Intent remains stored until the parent's notification dispatch is acknowledged. Dispatch failure
+1 -1
View File
@@ -203,7 +203,7 @@ Inline errors are a single sentence in `palette.red[300]` `xs`, sitting under th
Page-level alerts — informational notices, success confirmations, warnings, or recoverable errors that need a small visible block on the page — use `<Alert>` (`packages/app/src/components/ui/alert.tsx`). Variants: `default`, `info`, `success`, `warning`, `error`. The chrome is quiet by design: a 1px tinted border, transparent background, a small variant-tinted icon, the title in the variant accent, the description in `foregroundMuted`. Actions go in the `children` slot as `<Button variant="outline" size="sm">` — recovery actions are low-frequency and outline keeps them quiet alongside the alert's accent (`packages/app/src/screens/project-settings-screen.tsx`). One `<Alert>` at a time per region.
Sidebar callouts — cross-cutting alerts that apply across the whole app, like worktree setup, Rosetta install, and desktop update available — register through `useSidebarCallouts()` and render in the left sidebar via `<SidebarCallout>` (`packages/app/src/components/sidebar-callout.tsx`). The chrome (top-border-only, full-width action buttons) is tuned for that ~280px column. Canonical sources: `packages/app/src/components/worktree-setup-callout-source.tsx`, `packages/app/src/desktop/updates/rosetta-callout-source.tsx`, `packages/app/src/desktop/updates/update-callout-source.tsx`. Never import `<SidebarCallout>` into a page — that's what `<Alert>` is for.
Sidebar callouts — cross-cutting alerts that apply across the whole app, like worktree setup, Rosetta install, and desktop update available — register through `useSidebarCallouts()` and render in the left sidebar via `<SidebarCallout>` (`packages/app/src/components/sidebar-callout.tsx`). The chrome (top-border-only, full-width action buttons) is tuned for that ~280px column. Native apps and every compact sidebar show only callouts registered with `native: true`; leave it unset for callouts about the desktop app. Canonical sources: `packages/app/src/components/worktree-setup-callout-source.tsx`, `packages/app/src/desktop/updates/rosetta-callout-source.tsx`, `packages/app/src/desktop/updates/update-callout-source.tsx`. Never import `<SidebarCallout>` into a page — that's what `<Alert>` is for.
Imperative errors are `Alert.alert("Error", "Unable to ...")` (the React Native `Alert` API, not this component) for failures that interrupt the flow and have no place on the page.
-1
View File
@@ -38,7 +38,6 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **Usage source** — Plugin registration that discovers accounts and fetches plan usage. Code: `UsageSourceRegistration` (`packages/plugin/src/server/usage.ts`).
- **Report ID** — Stable daemon-composed identity for one source account: `<sourceId>:<accountKey>`. The source owns the account key; clients treat the whole ID as opaque. Code: `UsageReportEntry.id` (`packages/protocol/src/messages.ts`).
- **Usage report** — A source's normalized usage for one account, including status and optional windows, balances, and details. Its entry carries the Report ID, account label, and fetch time. Each report renders as one card in the Usage modal, in host Settings, and in an agent's context window details (a hover card on desktop web, a tooltip on native wide screens, a sheet on compact ones), which show only the account that agent runs under, without pins. The hover card and the sheet have Refresh; the native tooltip takes no presses. Code: `UsageReport` (`packages/plugin/src/server/usage.ts`), `UsageCard` (`packages/app/src/usage/card.tsx`), `AgentUsage` (`packages/app/src/usage/agent-usage.tsx`).
- **Check usage** — Host-wide switch in Usage Settings, on by default. Turning it off stops login discovery and vendor requests on that host for every device. Usage shows an off state with **Turn on**; the sidebar summary and agent usage sections disappear.
- **Usage (sidebar item)** — The sidebar footer item showing each pinned window's source icon and percent, hidden while no pinned window has data (the footer's Usage icon remains available); both entry points open the Usage modal over the current screen: a dialog on wide layouts, a bottom sheet on compact ones. Usage has no route of its own. Off until the user turns on **Summary in sidebar** (Usage Settings) or the Usage row in Settings > Sidebar; window rows pin only while it is on, and saved pins come back when it is turned on again. A pin is `{ sourceId, windowId }` and shows once per account of that source. Pins and the used/remaining choice are one per-device preference (`AppSettings.usage`) that every usage surface follows. It reads the active workspace's host, else the first connected host with usage sources. Code: `packages/app/src/usage/sidebar-item.tsx`, `usage/usage-modal.tsx`, `usage/pinned.ts`, `usage/preferences.ts`.
- **Model** — A specific LLM offered by a provider. UI: "Model" / "Select model". Code: `AgentModelDefinition` (`packages/protocol/src/messages.ts:187`).
- **Tab** — UI surface representing one session inside a workspace. Not a conceptual unit; use **Agent session** when talking about the model. Code: `WorkspaceTabDescriptor` (`packages/app/src/screens/workspace/workspace-tabs-types.ts`).
+1 -7
View File
@@ -447,12 +447,6 @@ owns payloads, ordering, failure behavior, and examples. `agent.create.before` e
configuration; `agent.open.before` contributes launch-only `env`, `tools`, and `mcpServers` on
every open. Executable tool definitions stay in the plugin process; only metadata crosses the boundary.
Worktree placement is resolved before Git source planning, so a hook can change the branch and base
without undoing fetched refs. Restores use the recorded destination and bypass creation hooks.
Workspace records decide ownership, including explicit non-ownership after recovery. Without a record,
ownership checks prefer the marker-verified worktree root over the legacy
path shape: a plugin can place a worktree several directories below the default worktrees root.
Emit from the operation owner, not a client subscription. Provider history replay must not trigger
live hooks. Notifications must not be awaited inside agent mutations: a handler can send a prompt
or answer a permission through its own daemon session. Awaiting it there deadlocks that command.
@@ -550,7 +544,7 @@ string is never inline SVG or a URL.
Register a usage source from `index.server.ts` with `server.registerUsageSource()`. Import `UsageSourceRegistration` and normalization helpers from `@getpaseo/plugin/server/usage`. The plugin owns account discovery, harness labels, and credential-store reads; the daemon owns account grouping and the fetch cache. Agent popovers use only the agent's own login. The host-wide Usage modal falls back across an account's logins; any success shows usage only, and all failures show every login's labeled error and remedy. Follow the [usage source discovery contract](../public-docs/plugins/reference.md#usage-sources). The resolved launch environment crosses into the trusted, unsandboxed plugin subprocess for session discovery. Usage queries never run lifecycle hooks. Inputs are validated in the plugin process and remain daemon-side. `icon` uses the same SVG file rules as provider icons.
When the host’s **Check usage** switch is off, the daemon rejects usage requests before discovery, without reading logins or calling vendors. Otherwise, the daemon calls discovery for `usage.list_reports`; the client gates this RPC on `server_info.features.usageSources`. The old `provider.usage.list` RPC maps discovered reports for older clients.
The daemon calls discovery for `usage.list_reports`; the client gates this RPC on `server_info.features.usageSources`. The old `provider.usage.list` RPC maps discovered reports for older clients.
## Contribute sidebar items
-5
View File
@@ -10,11 +10,6 @@ then replaces the previous tool state or merges into the current text item. Inte
are never retained for history or catch-up. Provider history is the durable transcript authority
and rebuilds the projection when an agent resumes.
Pagination bounds each read, not total resident history. Active and idle agents retain their
projected transcript; archive releases it and later history reads rebuild it from the provider.
Streaming overhead must remain proportional to the retained transcript. Cumulative text snapshots
must be released after their deltas have been consumed.
The invariants are:
> A continuously subscribed client applies every committed row in order. Opening or resuming an
+1 -1
View File
@@ -1 +1 @@
sha256-AmMp/4ugSUKXTL6H0oJaGJMztVGiYCPXeWteQaaaguw=
sha256-5/wat91m8GWxkGVk211lu9KFLhDQNEF/dlMhHMGf8Q0=
+28 -28
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.14.0-beta.2",
"version": "0.14.0-beta.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.14.0-beta.2",
"version": "0.14.0-beta.1",
"hasInstallScript": true,
"license": "Apache-2.0",
"workspaces": [
@@ -37590,7 +37590,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.14.0-beta.2",
"version": "0.14.0-beta.1",
"dependencies": {
"@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4",
@@ -39793,12 +39793,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.14.0-beta.2",
"version": "0.14.0-beta.1",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.14.0-beta.2",
"@getpaseo/protocol": "0.14.0-beta.2",
"@getpaseo/server": "0.14.0-beta.2",
"@getpaseo/client": "0.14.0-beta.1",
"@getpaseo/protocol": "0.14.0-beta.1",
"@getpaseo/server": "0.14.0-beta.1",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -40045,10 +40045,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.14.0-beta.2",
"version": "0.14.0-beta.1",
"dependencies": {
"@getpaseo/protocol": "0.14.0-beta.2",
"@getpaseo/relay": "0.14.0-beta.2",
"@getpaseo/protocol": "0.14.0-beta.1",
"@getpaseo/relay": "0.14.0-beta.1",
"ws": "^8.20.0",
"zod": "^4.4.3"
},
@@ -40060,7 +40060,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.14.0-beta.2",
"version": "0.14.0-beta.1",
"license": "Apache-2.0",
"dependencies": {
"@getpaseo/cli": "*",
@@ -40304,7 +40304,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.14.0-beta.2",
"version": "0.14.0-beta.1",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -40350,7 +40350,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.14.0-beta.2",
"version": "0.14.0-beta.1",
"dependencies": {
"@codemirror/language": "6.12.4",
"@codemirror/legacy-modes": "^6.5.3",
@@ -40379,15 +40379,15 @@
},
"packages/plugin": {
"name": "@getpaseo/plugin",
"version": "0.14.0-beta.2",
"version": "0.14.0-beta.1",
"dependencies": {
"@agentclientprotocol/sdk": "^1.4.0",
"i18next": "^26.3.0",
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.14.0-beta.2",
"@getpaseo/protocol": "0.14.0-beta.2",
"@getpaseo/client": "0.14.0-beta.1",
"@getpaseo/protocol": "0.14.0-beta.1",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.2.3",
@@ -40397,8 +40397,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.14.0-beta.2",
"@getpaseo/protocol": "0.14.0-beta.2",
"@getpaseo/client": "0.14.0-beta.1",
"@getpaseo/protocol": "0.14.0-beta.1",
"react": ">=19.1.0 <20",
"react-native": ">=0.81.5",
"zod": "^4.4.3"
@@ -40420,7 +40420,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.14.0-beta.2",
"version": "0.14.0-beta.1",
"dependencies": {
"semver": "^7.8.5",
"zod": "^4.4.3"
@@ -40447,7 +40447,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.14.0-beta.2",
"version": "0.14.0-beta.1",
"dependencies": {
"@noble/ciphers": "^2.4.0",
"@noble/curves": "^2.4.0",
@@ -40668,16 +40668,16 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.14.0-beta.2",
"version": "0.14.0-beta.1",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.14.0-beta.2",
"@getpaseo/highlight": "0.14.0-beta.2",
"@getpaseo/plugin": "0.14.0-beta.2",
"@getpaseo/protocol": "0.14.0-beta.2",
"@getpaseo/relay": "0.14.0-beta.2",
"@getpaseo/client": "0.14.0-beta.1",
"@getpaseo/highlight": "0.14.0-beta.1",
"@getpaseo/plugin": "0.14.0-beta.1",
"@getpaseo/protocol": "0.14.0-beta.1",
"@getpaseo/relay": "0.14.0-beta.1",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -41598,7 +41598,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.14.0-beta.2",
"version": "0.14.0-beta.1",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
@@ -43208,7 +43208,7 @@
},
"plugins": {
"name": "@getpaseo/builtin-plugins",
"version": "0.14.0-beta.2",
"version": "0.14.0-beta.1",
"dependencies": {
"@getpaseo/plugin": "*",
"zod": "^4.4.3"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.14.0-beta.2",
"version": "0.14.0-beta.1",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -0,0 +1,69 @@
import { randomUUID } from "node:crypto";
import type { Page } from "@playwright/test";
import { expect, metroTest as test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { buildSeededHost } from "../support/helpers/daemon-registry";
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
// 0.8.0 predates terminalsSurviveRestart and supports daemonSelfUpdate.
const PUBLISHED_DAEMON_VERSION = "0.8.0";
const COMPACT = { width: 390, height: 844 };
// Opens the app against a published 0.8.0 daemon, with the developer daemon blocked.
async function withOutdatedDaemon(page: Page, run: () => Promise<void>): Promise<void> {
const serverId = `srv_daemon_outdated_${randomUUID().replaceAll("-", "").slice(0, 12)}`;
const daemon = await startIsolatedHostDaemon(serverId, {
publishedVersion: PUBLISHED_DAEMON_VERSION,
});
try {
const host = buildSeededHost({
serverId,
endpoint: `127.0.0.1:${daemon.port}`,
nowIso: new Date().toISOString(),
});
await page.route(/:6767\b/, (route) => route.abort());
await page.routeWebSocket(/:6767\b/, async (webSocket) => {
await webSocket.close({ code: 1008, reason: "Blocked developer daemon during e2e." });
});
await page.addInitScript(
({ seededHost }) => {
localStorage.setItem("@paseo:e2e", "1");
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededHost]));
},
{ seededHost: host },
);
await gotoAppShell(page);
await run();
} finally {
await daemon.close();
}
}
async function expectOutdatedNotice(page: Page): Promise<void> {
const callout = page.getByTestId("daemon-outdated-callout").first();
await expect(callout).toBeVisible({ timeout: 30_000 });
await expect(callout).toContainText("Daemon out of date");
await expect(callout).toContainText(`daemon v${PUBLISHED_DAEMON_VERSION}`);
await expect(callout).toContainText("Updating restarts the daemon and closes open terminals.");
const update = page.getByTestId("daemon-outdated-callout-update").first();
await expect(update).toBeVisible();
await expect(update).toBeEnabled();
}
test("sidebar shows an update notice when the active host's daemon is older than the app", async ({
page,
}) => {
test.setTimeout(180_000);
await withOutdatedDaemon(page, () => expectOutdatedNotice(page));
});
test("compact sidebar shows the update notice, which is a native callout", async ({ page }) => {
test.setTimeout(180_000);
await page.setViewportSize(COMPACT);
await withOutdatedDaemon(page, async () => {
await page.getByRole("button", { name: "Open menu", exact: true }).first().click();
await expectOutdatedNotice(page);
});
});
@@ -204,26 +204,19 @@ test.describe("Projects settings", () => {
}) => {
const repo = await createTempGitRepo("projects-settings-empty-");
const client = await connectSeedClient();
let projectViewKey: string | null = null;
let projectId: string | null = null;
try {
await gotoAppShell(page);
projectViewKey = await addProjectFromSidebar(page, repo.path);
await openProjectSettingsFromSidebar(page, projectViewKey);
projectId = await addProjectFromSidebar(page, repo.path);
await openProjectSettingsFromSidebar(page, projectId);
await expectProjectSettingsFormVisible(page);
await expect(page.getByTestId("project-settings-back-button")).not.toBeVisible();
} finally {
if (projectViewKey) {
const project = (await client.listProjects()).projects.find(
(entry) => entry.projectKey === projectViewKey,
);
expect(project).toBeDefined();
await client.removeProject(project!.projectId);
expect(
(await client.listProjects()).projects.map((entry) => entry.projectKey),
).not.toContain(projectViewKey);
if (projectId) {
await client.removeProject(projectId).catch(() => undefined);
}
await client.close().catch(() => undefined);
await repo.cleanup().catch(() => undefined);
@@ -30,10 +30,6 @@ import {
addDirectHostFromSettings,
expectHostOnlineWithoutError,
expectIdleAgentRelease,
editRestartRecoveryPrompt,
expectRestartRecoveryEnabled,
expectRestartRecoveryPrompt,
setRestartRecoveryEnabled,
setIdleAgentRelease,
} from "../support/helpers/settings";
@@ -256,20 +252,3 @@ test.describe("Settings host page", () => {
await expectHostActionCards(page, serverId);
});
});
test("restart recovery host controls persist the opt-out and custom prompt", async ({ page }) => {
const serverId = getServerId();
await gotoAppShell(page);
await openSettings(page);
await openSettingsHost(page, serverId);
await openHostSection(page, serverId, "agents");
await expectRestartRecoveryEnabled(page, true);
await expectRestartRecoveryPrompt(page, "Resume working.");
await editRestartRecoveryPrompt(page, "Continue the interrupted work.");
await setRestartRecoveryEnabled(page, false);
await page.reload();
await expectRestartRecoveryEnabled(page, false);
await setRestartRecoveryEnabled(page, true);
await expectRestartRecoveryPrompt(page, "Continue the interrupted work.");
await editRestartRecoveryPrompt(page, "Resume working.");
});
@@ -415,8 +415,19 @@ test.describe("Settings — compact master-detail", () => {
const workspace = await withWorkspace({ prefix: "host-picker-settings-back-" });
const workspaceRoute = buildHostWorkspaceRoute(getServerId(), workspace.workspaceId);
await openWorkspace(page, workspace);
const worktreeSetupCallout = page.locator('[data-testid^="worktree-setup-callout-"]');
await test.step("the wide sidebar shows the worktree setup prompt for this workspace", async () => {
await page.setViewportSize({ width: 1280, height: 900 });
await openWorkspace(page, workspace);
await expect(worktreeSetupCallout.first()).toBeVisible({ timeout: 30_000 });
await page.setViewportSize({ width: 390, height: 844 });
});
await page.getByRole("button", { name: "Open menu", exact: true }).click();
// The compact sidebar shows only native callouts, so the prompt stays out.
await expect(page.getByTestId("sidebar-hosts-trigger")).toBeVisible();
await expect(worktreeSetupCallout).toHaveCount(0);
await page.getByTestId("sidebar-hosts-trigger").click();
await page.getByRole("button", { name: /Open .* settings/ }).click();
@@ -53,18 +53,11 @@ async function quickDragFirstRowAfterSecond(
await page.mouse.move(source.x, source.y + 7);
await expect(trailingScrim).toHaveCount(0);
await page.mouse.move(target.x, target.y, { steps: 4 });
await finishSidebarDrag(page);
await page.mouse.up();
await expect.poll(() => rowTestIds(rows)).toEqual([before[1], before[0]]);
}
async function finishSidebarDrag(page: Page): Promise<void> {
await page.mouse.up();
// dnd-kit's pointer sensor keeps its document click blocker for 50 ms after drop.
// Row order updates before that blocker is removed; wait before the next user action.
await page.waitForTimeout(60);
}
async function dragWithoutReordering(rows: Locator): Promise<void> {
const before = await rowTestIds(rows);
const source = await visibleBoundingBox(rows.nth(0));
@@ -73,7 +66,7 @@ async function dragWithoutReordering(rows: Locator): Promise<void> {
await page.mouse.move(source.x + source.width / 2, source.y + source.height / 2);
await page.mouse.down();
await page.mouse.move(target.x + target.width / 2, target.y + target.height / 2, { steps: 5 });
await finishSidebarDrag(page);
await page.mouse.up();
await expect.poll(() => rowTestIds(rows)).toEqual(before);
}
+4 -4
View File
@@ -717,15 +717,15 @@ test("open usage clocks advance without refreshing or reopening", async ({ page
await openCompactUsageWithClock(page, now);
const card = page.getByTestId("usage-report-login-journey:account");
await expect(card.getByTestId("usage-freshness")).toHaveText("Updated just now");
await expect(card.getByText("resets in 30m", { exact: false }).first()).toBeVisible();
await expect(card.getByText("resets 30m", { exact: false }).first()).toBeVisible();
// The shared minute clock is wall-aligned, and the daemon timestamps use real time.
// After two browser minutes the latest displayed tick is one or two minutes old.
await advanceUsageClock(page, 120_000);
await expect(card.getByTestId("usage-freshness")).toHaveText(/^Updated [12]m ago$/);
await expect(card.getByText(/resets in (28|29)m/).first()).toBeVisible();
await expect(card.getByText(/runs out in (28|29)m/)).toBeVisible();
await expect(card.getByText(/resets (28|29)m/).first()).toBeVisible();
await expect(card.getByText(/runs out (28|29)m/)).toBeVisible();
await expect(card.getByTestId("provider-usage-balance-credits-reset")).toHaveText(
/^resets in (28|29)m\s·\s/,
/^resets (28|29)m$/,
);
} finally {
await login.cleanup();
@@ -1,69 +0,0 @@
import { expect, test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { seedSidebarFooterPreferences } from "../support/helpers/sidebar-nav-settings";
import { installUsageReportsFixture } from "../support/helpers/usage-reports";
import {
claudeAndCodexReports,
openUsageFromIcon,
openUsageOptions,
closeUsageOptions,
closeUsage,
expectNoUsageItem,
expectPinnedUsage,
} from "../support/helpers/usage-sidebar-item";
test("Check usage applies to the host and Turn on restores cards", async ({
page,
e2eWorkerClient,
}) => {
test.setTimeout(180_000);
const previous = (await e2eWorkerClient.getDaemonConfig()).config.usage ?? { enabled: true };
try {
const usage = await installUsageReportsFixture(page, {
lists: [() => claudeAndCodexReports()],
});
await page.setViewportSize({ width: 1440, height: 1000 });
await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]);
await gotoAppShell(page);
await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]);
await openUsageFromIcon(page);
await usage.waitForListRequests(1);
await openUsageOptions(page);
await page.getByRole("switch", { name: "Check usage", exact: true }).click();
await closeUsageOptions(page);
await expect(page.getByRole("button", { name: "Turn on", exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Refresh all", exact: true })).toHaveCount(0);
expect((await e2eWorkerClient.getDaemonConfig()).config.usage).toEqual({ enabled: false });
await expectNoUsageItem(page);
const before = usage.listRequests().length;
await closeUsage(page);
await page.reload();
await openUsageFromIcon(page);
await expect(page.getByRole("button", { name: "Turn on", exact: true })).toBeVisible();
expect(usage.listRequests()).toHaveLength(before);
await page.getByRole("button", { name: "Turn on", exact: true }).click();
await expect(page.getByText("Claude", { exact: true })).toBeVisible();
expect((await e2eWorkerClient.getDaemonConfig()).config.usage).toEqual({ enabled: true });
// A separate client changes the policy, as another device would.
await e2eWorkerClient.patchDaemonConfig({ usage: { enabled: false } });
await expect(page.getByRole("button", { name: "Turn on", exact: true })).toBeVisible();
await e2eWorkerClient.patchDaemonConfig({ usage: { enabled: true } });
await expect(page.getByText("Claude", { exact: true })).toBeVisible();
} finally {
await e2eWorkerClient.patchDaemonConfig({ usage: previous });
}
});
test("older hosts keep usage without a Check usage switch", async ({ page }) => {
test.setTimeout(180_000);
await installUsageReportsFixture(page, {
usageSettingSupported: false,
lists: [() => claudeAndCodexReports()],
});
await page.setViewportSize({ width: 1440, height: 1000 });
await gotoAppShell(page);
await openUsageFromIcon(page);
await expect(page.getByText("Claude", { exact: true })).toBeVisible();
await openUsageOptions(page);
await expect(page.getByRole("switch", { name: "Check usage", exact: true })).toHaveCount(0);
});
@@ -441,31 +441,3 @@ test("released hosts supply source logos through the client conversion", async (
).toHaveAttribute("d", codexPath);
await qaScreenshot(page, "released-host-usage-modal");
});
test("hidden usage sends no requests until opened", async ({ page }) => {
test.setTimeout(180_000);
const usage = await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] });
await page.setViewportSize(WIDE);
await gotoAppShell(page);
await expectNoUsageItem(page);
await expect(page.getByRole("button", { name: "Usage", exact: true })).toBeVisible({
timeout: 90_000,
});
await openSidebarNavSettings(page);
await expectFooterItemSetting(page, "usage", false);
await leaveSettings(page);
expect(usage.listRequests()).toHaveLength(0);
await openUsageFromIcon(page);
await usage.waitForListRequests(1);
expect(usage.listRequests()).toHaveLength(1);
});
test("visible usage summary requests reports on load", async ({ page }) => {
test.setTimeout(180_000);
const usage = await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] });
await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]);
await page.setViewportSize(WIDE);
await gotoAppShell(page);
await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]);
expect(usage.listRequests()).toHaveLength(1);
});
@@ -0,0 +1,35 @@
# Native daemon-outdated notice
The flow needs the development app connected to an isolated daemon older than the app. Install a published daemon that predates `terminalsSurviveRestart`, start it in its own home, and bundle the app against it. `<checkout>` is the root of this repository; `<port>`, `<metro-port>` and `<udid>` are a free daemon port, a free Metro port and the booted simulator.
Run this once, in any directory. It installs into `/tmp/old-daemon`, not globally:
```sh
npm install --prefix /tmp/old-daemon --no-package-lock @getpaseo/server@0.8.0
```
Terminal 1, the old daemon. It runs in the foreground; keep it running:
```sh
cd /tmp/old-daemon/node_modules/@getpaseo/server
PASEO_HOME=/tmp/old-daemon-home PASEO_LISTEN=0.0.0.0:<port> PASEO_RELAY_ENABLED=0 node dist/scripts/supervisor-entrypoint.js
```
Terminal 2, Metro. It runs in the foreground; keep it running:
```sh
cd <checkout>/packages/app
APP_VARIANT=development EXPO_PUBLIC_LOCAL_DAEMON=localhost:<port> npx expo start --port <metro-port> --clear
```
Terminal 3, the flow, while terminals 1 and 2 are still running:
```sh
cd <checkout>
xcrun simctl openurl <udid> "paseo://expo-development-client/?url=http%3A%2F%2F127.0.0.1%3A<metro-port>"
maestro --udid <udid> test packages/app/e2e/mobile/agent-device/native-daemon-outdated-notice.ios.yaml --env DAEMON_VERSION=0.8.0
```
Start with the sidebar closed. The flow opens it, then asserts the notice, its daemon version, the terminals line for a daemon without `terminalsSurviveRestart`, and an enabled Update button. Do not tap Update: on 0.8.0 it runs a global `npm install -g @getpaseo/cli@latest`.
When you are done, stop terminals 1 and 2 and remove `/tmp/old-daemon` and `/tmp/old-daemon-home`.
@@ -0,0 +1,26 @@
appId: sh.paseo.debug
---
# The app must be connected to a daemon older than the app that predates
# terminalsSurviveRestart (0.8.0). See native-daemon-outdated-notice-preconditions.md.
- launchApp
- runFlow:
when:
visible: ".*getRegistrationInfoAsync.*"
commands:
- tapOn: ".*getRegistrationInfoAsync.*"
- tapOn: Dismiss
- extendedWaitUntil:
visible: "Open menu"
timeout: 60000
- tapOn: "Open menu"
- extendedWaitUntil:
visible:
id: daemon-outdated-callout
timeout: 30000
- assertVisible: "Daemon out of date"
- assertVisible: ".*daemon v${DAEMON_VERSION}.*"
- assertVisible: "Updating restarts the daemon and closes open terminals."
- assertVisible:
id: daemon-outdated-callout-update
enabled: true
- takeScreenshot: native-daemon-outdated-notice
@@ -28,10 +28,6 @@ interface SeedProjectDescriptor {
* prefer those wrappers over reaching for this client directly.
*/
export interface SeedDaemonClient {
patchDaemonConfig(
config: import("@getpaseo/protocol/messages").MutableDaemonConfigPatch,
): Promise<{ config: import("@getpaseo/protocol/messages").MutableDaemonConfig }>;
getDaemonConfig(): Promise<{ config: import("@getpaseo/protocol/messages").MutableDaemonConfig }>;
connect(): Promise<void>;
close(): Promise<void>;
addProject(cwd: string): Promise<{
@@ -560,41 +560,3 @@ export async function expectHostOnlineWithoutError(page: Page): Promise<void> {
await expect(page.getByTestId("host-page-identity")).toContainText("Online");
await expect(page.getByTestId("host-connection-error")).toHaveCount(0);
}
function restartRecoverySwitch(page: Page) {
return page.getByRole("switch", { name: "Resume agents after restart", exact: true });
}
function restartRecoveryEditButton(page: Page) {
return page.getByRole("button", { name: "Edit prompt", exact: true });
}
export async function setRestartRecoveryEnabled(page: Page, enabled: boolean) {
const toggle = restartRecoverySwitch(page);
await toggle.click();
await expectRestartRecoveryEnabled(page, enabled);
}
// The prompt is only editable while restart recovery is on.
export async function expectRestartRecoveryEnabled(page: Page, enabled: boolean) {
await expect(restartRecoverySwitch(page)).toBeChecked({ checked: enabled });
await expect(restartRecoveryEditButton(page)).toBeEnabled({ enabled });
}
export async function editRestartRecoveryPrompt(page: Page, prompt: string) {
await restartRecoveryEditButton(page).click();
const sheet = page.getByTestId("host-page-restart-recovery-sheet");
await sheet.getByRole("textbox", { name: "Resume prompt", exact: true }).fill(prompt);
await sheet.getByRole("button", { name: "Save", exact: true }).click();
await expect(sheet).toHaveCount(0);
}
export async function expectRestartRecoveryPrompt(page: Page, prompt: string) {
await restartRecoveryEditButton(page).click();
const sheet = page.getByTestId("host-page-restart-recovery-sheet");
await expect(sheet.getByRole("textbox", { name: "Resume prompt", exact: true })).toHaveValue(
prompt,
);
await sheet.getByRole("button", { name: "Cancel", exact: true }).click();
await expect(sheet).toHaveCount(0);
}
@@ -25,7 +25,6 @@ interface UsageReportsFixtureOptions {
* app connects, so a reload can switch it.
*/
usageSupported?: boolean | (() => boolean);
usageSettingSupported?: boolean;
/** Released hosts expose provider.usage.list with no source icons. */
providerUsageListOnly?: boolean;
/** The host daemon's port; defaults to the E2E daemon. */
@@ -64,7 +63,6 @@ function withUsageSupportFeature(
message: WebSocketMessage,
enabled: boolean,
providerUsageListOnly: boolean,
usageSettingSupported: boolean,
): string | null {
const envelope = parseJson(message) as {
type?: unknown;
@@ -88,7 +86,6 @@ function withUsageSupportFeature(
...payload,
features: {
...features,
usageSetting: usageSettingSupported ? true : undefined,
usageSources: enabled && !providerUsageListOnly,
providerUsageList: enabled,
},
@@ -232,12 +229,7 @@ export async function installUsageReportsFixture(
server.onMessage((message) => {
const serverInfo =
typeof message === "string"
? withUsageSupportFeature(
message,
isUsageSupported(),
providerUsageListOnly,
options.usageSettingSupported ?? true,
)
? withUsageSupportFeature(message, isUsageSupported(), providerUsageListOnly)
: null;
ws.send(serverInfo ?? message);
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.14.0-beta.2",
"version": "0.14.0-beta.1",
"private": true,
"main": "index.ts",
"scripts": {
+2
View File
@@ -84,6 +84,7 @@ import { getDesktopHost } from "@/desktop/host";
import { loadDesktopSettings } from "@/desktop/settings/desktop-settings";
import { RosettaCalloutSource } from "@/desktop/updates/rosetta-callout-source";
import { UpdateCalloutSource } from "@/desktop/updates/update-callout-source";
import { DaemonOutdatedCalloutSource } from "@/components/daemon-outdated-callout-source";
import { useActiveWorktreeNewAction } from "@/hooks/use-active-worktree-new-action";
import { useGlobalNewWorkspaceAction } from "@/hooks/use-global-new-workspace-action";
import { useLatchedBoolean } from "@/hooks/use-latched-boolean";
@@ -486,6 +487,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
<DownloadToast />
<RosettaCalloutSource />
<UpdateCalloutSource />
<DaemonOutdatedCalloutSource />
<LegacyAgentSkillsMigration />
<WorktreeSetupCalloutSource />
<CommandCenterRootActions />
@@ -0,0 +1,182 @@
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { TFunction } from "i18next";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
type SidebarCalloutAction,
SidebarCalloutDescriptionText,
} from "@/components/sidebar-callout";
import {
type DaemonOutdatedUpdateState,
resolveDaemonOutdatedCallout,
} from "@/components/resolve-daemon-outdated-callout";
import { useSidebarCallouts } from "@/contexts/sidebar-callout-context";
import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
import { useStableEvent } from "@/hooks/use-stable-event";
import { useHostRuntimeClient, useHostRuntimeIsConnected, useHosts } from "@/runtime/host-runtime";
import { updateDaemonFromSettings } from "@/screens/settings/daemon-lifecycle";
import { useLastWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { useSessionStore } from "@/stores/session-store";
import { orderHostsLocalFirst, resolveActiveHostServerId } from "@/types/host-connection";
import { resolveAppVersion } from "@/utils/app-version";
import { confirmDialog } from "@/utils/confirm-dialog";
interface HostUpdateState {
serverId: string;
update: DaemonOutdatedUpdateState;
}
const IDLE: DaemonOutdatedUpdateState = { status: "idle" };
function DaemonOutdatedDescription({ lines }: { lines: string[] }) {
return (
<>
{lines.map((line) => (
<SidebarCalloutDescriptionText key={line}>{line}</SidebarCalloutDescriptionText>
))}
</>
);
}
interface ConfirmAndUpdateDaemonInput {
serverId: string;
hostLabel: string;
client: DaemonClient;
t: TFunction;
onState: (next: DaemonOutdatedUpdateState) => void;
}
async function confirmAndUpdateDaemon(input: ConfirmAndUpdateDaemonInput): Promise<void> {
const { serverId, hostLabel, client, t, onState } = input;
let confirmed: boolean;
try {
confirmed = await confirmDialog({
title: t("settings.host.daemon.update.confirmTitle", { name: hostLabel }),
message: t("settings.host.daemon.update.confirmMessage"),
confirmLabel: t("settings.host.daemon.update.confirm"),
cancelLabel: t("common.actions.cancel"),
destructive: false,
});
} catch (error) {
console.error("[DaemonOutdatedCallout] Failed to open update confirmation", error);
onState({ status: "failed", message: t("settings.host.daemon.update.dialogFailedMessage") });
return;
}
if (!confirmed) return;
const phaseLabels = {
starting: t("settings.host.daemon.update.phaseStarting"),
downloading: t("settings.host.daemon.update.phaseDownloading"),
installing: t("settings.host.daemon.update.phaseInstalling"),
complete: t("settings.host.daemon.update.phaseComplete"),
};
onState({ status: "updating", phase: phaseLabels.starting });
const requestId = `sidebar_daemon_update_${serverId}`;
const unsubscribe = client.on("daemon.update.progress", (message) => {
if (message.payload.requestId !== requestId) return;
onState({ status: "updating", phase: phaseLabels[message.payload.phase] });
});
try {
await updateDaemonFromSettings(serverId, {
updateDaemon: () => client.updateDaemon(requestId),
getStatus: async () => ({
...(await client.getDaemonStatus()),
serverId: client.getLastServerInfoMessage()?.serverId ?? "",
version: client.getLastServerInfoMessage()?.version ?? null,
}),
});
onState({ status: "idle" });
} catch (error) {
console.error(`[DaemonOutdatedCallout] Failed to update daemon ${hostLabel}`, error);
onState({
status: "failed",
message: t("settings.host.daemon.update.requestFailedMessage", {
error: error instanceof Error ? error.message : String(error),
}),
});
} finally {
unsubscribe();
}
}
/** Sidebar notice when the active host's daemon is older than this app. */
export function DaemonOutdatedCalloutSource() {
const { t } = useTranslation();
const callouts = useSidebarCallouts();
const hosts = useHosts();
const localServerId = useLocalDaemonServerId();
const lastWorkspaceSelection = useLastWorkspaceSelection();
const orderedHosts = useMemo(
() => orderHostsLocalFirst(hosts, localServerId),
[hosts, localServerId],
);
// Same host resolution as settings: the last workspace's host, else the local daemon, else the first.
const serverId = resolveActiveHostServerId({
selectedServerId: lastWorkspaceSelection?.serverId ?? null,
localServerId,
hosts,
orderedHosts,
});
const hostLabel = hosts.find((host) => host.serverId === serverId)?.label ?? "";
const daemonClient = useHostRuntimeClient(serverId ?? "");
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
const serverInfo = useSessionStore((state) =>
serverId ? (state.sessions[serverId]?.serverInfo ?? null) : null,
);
const [hostUpdate, setHostUpdate] = useState<HostUpdateState | null>(null);
const update = hostUpdate && hostUpdate.serverId === serverId ? hostUpdate.update : IDLE;
const runUpdate = useStableEvent(() => {
if (!serverId || !daemonClient) return;
void confirmAndUpdateDaemon({
serverId,
hostLabel,
client: daemonClient,
t,
onState: (next) => setHostUpdate({ serverId, update: next }),
});
});
useEffect(() => {
if (!serverId || !serverInfo) return;
const descriptor = resolveDaemonOutdatedCallout({
serverId,
hostLabel,
appVersion: resolveAppVersion(),
daemonVersion: serverInfo.version,
supportsSelfUpdate: serverInfo.features?.daemonSelfUpdate === true,
desktopManaged: serverInfo.desktopManaged === true,
// COMPAT(terminalsSurviveRestart): added in v0.12.0, remove gate after 2027-04-21.
terminalsSurviveRestart: serverInfo.features?.terminalsSurviveRestart === true,
isConnected,
update,
});
if (!descriptor) return;
const actions: SidebarCalloutAction[] = descriptor.updateAction
? [
{
label: descriptor.updateAction.label,
onPress: runUpdate,
variant: "primary",
disabled: descriptor.updateAction.disabled,
testID: "daemon-outdated-callout-update",
},
]
: [];
return callouts.show({
id: descriptor.id,
dismissalKey: descriptor.dismissalKey,
priority: descriptor.priority,
title: descriptor.title,
description: <DaemonOutdatedDescription lines={descriptor.description} />,
variant: descriptor.variant,
actions,
testID: descriptor.testID,
native: true,
});
// t: the resolver reads the active language through i18n, so a language change rebuilds the copy.
}, [callouts, hostLabel, isConnected, runUpdate, serverId, serverInfo, t, update]);
return null;
}
+13 -5
View File
@@ -40,6 +40,7 @@ import { SidebarSectionHeader } from "@/components/sidebar/sidebar-section-heade
import { Shortcut } from "@/components/ui/shortcut";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout";
import { isNative } from "@/constants/platform";
import { useOpenAddProject } from "@/hooks/use-open-add-project";
import { useImportSession } from "@/hooks/use-import-session";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
@@ -64,7 +65,12 @@ import { useCloseAgentListGesture } from "@/mobile-panels/gestures";
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
import { buildSettingsAddHostRoute, buildSettingsRoute } from "@/utils/host-routes";
import { openHostOverview } from "@/navigation/settings-navigation";
import { UsageSidebarItem, UsageSidebarRoot, useUsageSummary, useOpenSidebarUsage } from "@/usage";
import {
UsageSidebarItem,
UsageSidebarRoot,
useHasUsageSummary,
useOpenSidebarUsage,
} from "@/usage";
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
import { SidebarCalloutSlot } from "./sidebar-callout-slot";
import { SidebarWorkspaceList } from "./sidebar-workspace-list";
@@ -475,10 +481,10 @@ function SidebarUsageIcon({ label }: { label: string }) {
*/
function SidebarFooterRows({ onBeforeNavigate }: { onBeforeNavigate?: () => void }) {
const { items } = useSidebarNavItems("footer");
const usageSummary = useUsageSummary();
const hasUsageSummary = useHasUsageSummary();
const rowsRef = useRef<View | null>(null);
const visibleItems = items.filter(
(item) => item.visible && (item.kind === "plugin" || usageSummary.length > 0),
(item) => item.visible && (item.kind === "plugin" || hasUsageSummary),
);
if (visibleItems.length === 0) return null;
return (
@@ -494,7 +500,7 @@ function SidebarFooterRows({ onBeforeNavigate }: { onBeforeNavigate?: () => void
onBeforeNavigate={onBeforeNavigate}
/>
) : (
<UsageSidebarItem key={item.key} sources={usageSummary} />
<UsageSidebarItem key={item.key} />
),
)}
</View>
@@ -600,6 +606,8 @@ function MobileSidebar({
/>
)}
<SidebarCalloutSlot nativeOnly />
<SidebarFooter
handleOpenProject={handleOpenProject}
handleSettings={handleSettings}
@@ -779,7 +787,7 @@ function DesktopSidebar({
/>
)}
<SidebarCalloutSlot />
<SidebarCalloutSlot nativeOnly={isNative} />
<SidebarFooter
handleOpenProject={handleOpenProject}
@@ -0,0 +1,195 @@
import { describe, expect, it } from "vitest";
import { i18n } from "@/i18n/i18next";
import {
resolveDaemonOutdatedCallout,
type ResolveDaemonOutdatedCalloutInput,
} from "./resolve-daemon-outdated-callout";
function input(
overrides: Partial<ResolveDaemonOutdatedCalloutInput> = {},
): ResolveDaemonOutdatedCalloutInput {
return {
serverId: "srv_alpha",
hostLabel: "Workstation",
appVersion: "0.12.0",
daemonVersion: "0.11.4",
supportsSelfUpdate: true,
desktopManaged: false,
terminalsSurviveRestart: true,
isConnected: true,
update: { status: "idle" },
...overrides,
};
}
describe("resolveDaemonOutdatedCallout", () => {
it("shows an update notice when the daemon is older than the app", () => {
expect(resolveDaemonOutdatedCallout(input())).toEqual({
id: "daemon-outdated",
dismissalKey: "daemon-outdated:srv_alpha:0.11.4",
priority: 250,
title: "Daemon out of date",
description: [
"Workstation runs daemon v0.11.4. This app is v0.12.0.",
"Updating restarts the daemon. Terminals stay open.",
],
variant: "default",
updateAction: { label: "Update", disabled: false },
testID: "daemon-outdated-callout",
});
});
it("hides when the daemon matches or is newer than the app", () => {
expect(resolveDaemonOutdatedCallout(input({ daemonVersion: "0.12.0" }))).toBeNull();
expect(resolveDaemonOutdatedCallout(input({ daemonVersion: "v0.12.0" }))).toBeNull();
expect(resolveDaemonOutdatedCallout(input({ daemonVersion: "0.12.1" }))).toBeNull();
expect(resolveDaemonOutdatedCallout(input({ daemonVersion: "1.0.0" }))).toBeNull();
});
it("compares numeric components numerically, not as strings", () => {
expect(
resolveDaemonOutdatedCallout(input({ appVersion: "0.10.0", daemonVersion: "0.9.9" })),
).not.toBeNull();
expect(
resolveDaemonOutdatedCallout(input({ appVersion: "0.9.9", daemonVersion: "0.10.0" })),
).toBeNull();
});
it("orders prereleases before their release", () => {
expect(
resolveDaemonOutdatedCallout(input({ appVersion: "0.12.0", daemonVersion: "0.12.0-beta.1" })),
).not.toBeNull();
expect(
resolveDaemonOutdatedCallout(input({ appVersion: "0.12.0-beta.1", daemonVersion: "0.12.0" })),
).toBeNull();
expect(
resolveDaemonOutdatedCallout(
input({ appVersion: "0.12.0-beta.10", daemonVersion: "0.12.0-beta.2" }),
),
).not.toBeNull();
expect(
resolveDaemonOutdatedCallout(
input({ appVersion: "0.12.0-beta.2", daemonVersion: "0.12.0-beta.2" }),
),
).toBeNull();
expect(
resolveDaemonOutdatedCallout(input({ appVersion: "0.12.0-rc.1", daemonVersion: "0.11.9" })),
).not.toBeNull();
});
it("hides when either version is missing or unparseable", () => {
expect(resolveDaemonOutdatedCallout(input({ daemonVersion: null }))).toBeNull();
expect(resolveDaemonOutdatedCallout(input({ appVersion: null }))).toBeNull();
expect(resolveDaemonOutdatedCallout(input({ daemonVersion: "dev" }))).toBeNull();
expect(resolveDaemonOutdatedCallout(input({ daemonVersion: "0.11" }))).toBeNull();
expect(resolveDaemonOutdatedCallout(input({ appVersion: "unknown" }))).toBeNull();
});
it("offers no button for a desktop-managed daemon and points to Paseo on that computer", () => {
const descriptor = resolveDaemonOutdatedCallout(input({ desktopManaged: true }));
expect(descriptor?.updateAction).toBeNull();
expect(descriptor?.description).toEqual([
"Workstation runs daemon v0.11.4. This app is v0.12.0.",
"Paseo Desktop manages this daemon. Update Paseo on that computer.",
"Updating Paseo Desktop keeps terminals open on macOS and Linux, and closes them on Windows.",
]);
});
it("never promises terminals survive a desktop update, which closes them on Windows", () => {
const descriptor = resolveDaemonOutdatedCallout(
input({ desktopManaged: true, terminalsSurviveRestart: true }),
);
expect(descriptor?.description).not.toContain(
"Updating restarts the daemon. Terminals stay open.",
);
expect(descriptor?.description.at(-1)).toContain("closes them on Windows");
});
it("says a desktop update closes terminals when the daemon does not keep them", () => {
const descriptor = resolveDaemonOutdatedCallout(
input({ desktopManaged: true, terminalsSurviveRestart: false }),
);
expect(descriptor?.description.at(-1)).toBe(
"Updating restarts the daemon and closes open terminals.",
);
});
it("offers no button when the daemon does not support self-update", () => {
const descriptor = resolveDaemonOutdatedCallout(input({ supportsSelfUpdate: false }));
expect(descriptor?.updateAction).toBeNull();
expect(descriptor?.description).toContain(
"This daemon can't update itself. Update Paseo on that computer.",
);
});
it("says the update closes terminals when the daemon does not keep them", () => {
const descriptor = resolveDaemonOutdatedCallout(input({ terminalsSurviveRestart: false }));
expect(descriptor?.description).toEqual([
"Workstation runs daemon v0.11.4. This app is v0.12.0.",
"Updating restarts the daemon and closes open terminals.",
]);
});
it("disables the button while the host is disconnected", () => {
expect(resolveDaemonOutdatedCallout(input({ isConnected: false }))?.updateAction).toEqual({
label: "Update",
disabled: true,
});
});
it("shows the update phase on a disabled button while updating", () => {
const descriptor = resolveDaemonOutdatedCallout(
input({ update: { status: "updating", phase: "Downloading packages..." } }),
);
expect(descriptor?.variant).toBe("default");
expect(descriptor?.updateAction).toEqual({ label: "Downloading packages...", disabled: true });
});
it("surfaces a failed update as an error with the button available again", () => {
const descriptor = resolveDaemonOutdatedCallout(
input({ update: { status: "failed", message: "Failed to update the daemon: boom" } }),
);
expect(descriptor?.title).toBe("Daemon update failed");
expect(descriptor?.variant).toBe("error");
expect(descriptor?.description).toEqual(["Failed to update the daemon: boom"]);
expect(descriptor?.updateAction).toEqual({ label: "Update", disabled: false });
});
it("keys dismissal by host and daemon version", () => {
const key = resolveDaemonOutdatedCallout(input())?.dismissalKey;
expect(resolveDaemonOutdatedCallout(input({ daemonVersion: "v0.11.4" }))?.dismissalKey).toBe(
key,
);
expect(resolveDaemonOutdatedCallout(input({ appVersion: "0.13.0" }))?.dismissalKey).toBe(key);
expect(resolveDaemonOutdatedCallout(input({ daemonVersion: "0.11.5" }))?.dismissalKey).toBe(
"daemon-outdated:srv_alpha:0.11.5",
);
expect(resolveDaemonOutdatedCallout(input({ serverId: "srv_beta" }))?.dismissalKey).toBe(
"daemon-outdated:srv_beta:0.11.4",
);
});
it("uses the active app language", async () => {
await i18n.changeLanguage("es");
try {
const descriptor = resolveDaemonOutdatedCallout(input({ terminalsSurviveRestart: false }));
expect(descriptor?.title).toBe("Daemon desactualizado");
expect(descriptor?.description).toEqual([
"Workstation ejecuta el daemon v0.11.4. Esta app es v0.12.0.",
"Actualizar reinicia el daemon y cierra las terminales abiertas.",
]);
expect(descriptor?.updateAction?.label).toBe("Actualizar");
} finally {
await i18n.changeLanguage("en");
}
});
});
@@ -0,0 +1,147 @@
import { i18n } from "@/i18n/i18next";
export type DaemonOutdatedUpdateState =
| { status: "idle" }
| { status: "updating"; phase: string }
| { status: "failed"; message: string };
export interface ResolveDaemonOutdatedCalloutInput {
serverId: string;
hostLabel: string;
appVersion: string | null;
daemonVersion: string | null;
supportsSelfUpdate: boolean;
desktopManaged: boolean;
terminalsSurviveRestart: boolean;
isConnected: boolean;
update: DaemonOutdatedUpdateState;
}
export interface DaemonOutdatedUpdateAction {
label: string;
disabled: boolean;
}
export interface DaemonOutdatedCalloutDescriptor {
id: "daemon-outdated";
dismissalKey: string;
priority: number;
title: string;
description: string[];
variant: "default" | "error";
updateAction: DaemonOutdatedUpdateAction | null;
testID: "daemon-outdated-callout";
}
interface ParsedVersion {
core: [number, number, number];
prerelease: string[];
}
const VERSION_PATTERN =
/^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z.-]+)?$/i;
const NUMERIC_IDENTIFIER = /^\d+$/;
function parseVersion(value: string | null): ParsedVersion | null {
const match = value?.trim().match(VERSION_PATTERN);
if (!match) return null;
return {
core: [Number(match[1]), Number(match[2]), Number(match[3])],
prerelease: match[4] ? match[4].split(".") : [],
};
}
function compareIdentifiers(left: string, right: string): number {
const isLeftNumeric = NUMERIC_IDENTIFIER.test(left);
const isRightNumeric = NUMERIC_IDENTIFIER.test(right);
if (isLeftNumeric && isRightNumeric) return Number(left) - Number(right);
if (isLeftNumeric) return -1;
if (isRightNumeric) return 1;
if (left < right) return -1;
if (left > right) return 1;
return 0;
}
// Semver precedence: a prerelease sorts before its release (0.12.0-beta.1 < 0.12.0).
function compareVersions(left: ParsedVersion, right: ParsedVersion): number {
for (let index = 0; index < 3; index += 1) {
const difference = left.core[index] - right.core[index];
if (difference !== 0) return difference;
}
if (left.prerelease.length === 0 || right.prerelease.length === 0) {
return right.prerelease.length - left.prerelease.length;
}
const sharedLength = Math.min(left.prerelease.length, right.prerelease.length);
for (let index = 0; index < sharedLength; index += 1) {
const difference = compareIdentifiers(left.prerelease[index], right.prerelease[index]);
if (difference !== 0) return difference;
}
return left.prerelease.length - right.prerelease.length;
}
function resolveUpdateAction(input: ResolveDaemonOutdatedCalloutInput): DaemonOutdatedUpdateAction {
if (input.update.status === "updating") {
return { label: input.update.phase, disabled: true };
}
return { label: i18n.t("daemonOutdated.update"), disabled: !input.isConnected };
}
function formatVersion(version: ParsedVersion): string {
const core = version.core.join(".");
return version.prerelease.length > 0 ? `${core}-${version.prerelease.join(".")}` : core;
}
// A daemon that keeps terminals across restarts does not keep them across a Paseo Desktop update
// on Windows, which closes them. server_info does not name the host's platform, so the line says
// both.
function resolveTerminalsLine(input: ResolveDaemonOutdatedCalloutInput): string {
if (!input.terminalsSurviveRestart) return i18n.t("daemonOutdated.terminalsClose");
if (input.desktopManaged) return i18n.t("daemonOutdated.desktopUpdateTerminals");
return i18n.t("daemonOutdated.terminalsStayOpen");
}
function resolveDescription(
input: ResolveDaemonOutdatedCalloutInput,
versions: { daemon: ParsedVersion; app: ParsedVersion },
): string[] {
if (input.update.status === "failed") return [input.update.message];
const lines = [
i18n.t("daemonOutdated.versions", {
host: input.hostLabel,
daemonVersion: `v${formatVersion(versions.daemon)}`,
appVersion: `v${formatVersion(versions.app)}`,
}),
];
if (input.desktopManaged) {
lines.push(i18n.t("daemonOutdated.desktopManaged"));
} else if (!input.supportsSelfUpdate) {
lines.push(i18n.t("daemonOutdated.selfUpdateUnsupported"));
}
lines.push(resolveTerminalsLine(input));
return lines;
}
export function resolveDaemonOutdatedCallout(
input: ResolveDaemonOutdatedCalloutInput,
): DaemonOutdatedCalloutDescriptor | null {
const app = parseVersion(input.appVersion);
const daemon = parseVersion(input.daemonVersion);
if (!app || !daemon) return null;
if (compareVersions(daemon, app) >= 0) return null;
// The update RPC refuses desktop-managed daemons; those update with Paseo Desktop on that computer.
const canSelfUpdate = input.supportsSelfUpdate && !input.desktopManaged;
const isFailed = input.update.status === "failed";
return {
id: "daemon-outdated",
dismissalKey: `daemon-outdated:${input.serverId}:${formatVersion(daemon)}`,
priority: 250,
title: isFailed ? i18n.t("daemonOutdated.updateFailedTitle") : i18n.t("daemonOutdated.title"),
description: resolveDescription(input, { daemon, app }),
variant: isFailed ? "error" : "default",
updateAction: canSelfUpdate ? resolveUpdateAction(input) : null,
testID: "daemon-outdated-callout",
};
}
-1
View File
@@ -1 +0,0 @@
export * from "./sidebar-callout-slot.native";
@@ -1,3 +0,0 @@
export function SidebarCalloutSlot() {
return null;
}
@@ -91,7 +91,7 @@ describe("SidebarCalloutSlot", () => {
root?.render(
<SidebarCalloutProvider>
<RegisterCallout />
<SidebarCalloutSlot />
<SidebarCalloutSlot nativeOnly={false} />
</SidebarCalloutProvider>,
);
await Promise.resolve();
@@ -105,7 +105,7 @@ describe("SidebarCalloutSlot", () => {
await act(async () => {
root?.render(
<SidebarCalloutProvider>
<SidebarCalloutSlot />
<SidebarCalloutSlot nativeOnly={false} />
</SidebarCalloutProvider>,
);
await Promise.resolve();
@@ -2,10 +2,14 @@ import { View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { SidebarCalloutViewport } from "@/contexts/sidebar-callout-context";
export function SidebarCalloutSlot() {
/**
* `nativeOnly` limits the slot to callouts marked `native`. Native apps and every compact
* sidebar pass it: the other callouts are about the desktop app.
*/
export function SidebarCalloutSlot({ nativeOnly }: { nativeOnly: boolean }) {
return (
<View style={styles.slot} collapsable={false}>
<SidebarCalloutViewport />
<SidebarCalloutViewport nativeOnly={nativeOnly} />
</View>
);
}
@@ -21,6 +21,7 @@ import {
showSidebarCallout,
type SidebarCalloutEntry,
type SidebarCalloutOptions,
type SidebarCalloutSelectionState,
type SidebarCalloutState,
unregisterSidebarCallout,
} from "./sidebar-callout-state";
@@ -37,7 +38,7 @@ export interface SidebarCalloutsApi {
const DISMISSED_CALLOUTS_STORAGE_KEY = "@paseo:sidebar-callout-dismissals";
const SidebarCalloutApiContext = createContext<SidebarCalloutsApi | null>(null);
const SidebarCalloutStateContext = createContext<SidebarCalloutEntry | null>(null);
const SidebarCalloutStateContext = createContext<SidebarCalloutSelectionState | null>(null);
function persistDismissedCalloutKeys(keys: ReadonlySet<string>): void {
void AsyncStorage.setItem(
@@ -113,11 +114,10 @@ export function SidebarCalloutProvider({ children }: { children: ReactNode }) {
});
const api = useMemo<SidebarCalloutsApi>(() => ({ show, dismiss, clear }), [clear, dismiss, show]);
const activeCallout = useMemo(() => selectActiveSidebarCallout(state), [state]);
return (
<SidebarCalloutApiContext.Provider value={api}>
<SidebarCalloutStateContext.Provider value={activeCallout}>
<SidebarCalloutStateContext.Provider value={state}>
{children}
</SidebarCalloutStateContext.Provider>
</SidebarCalloutApiContext.Provider>
@@ -132,12 +132,20 @@ export function useSidebarCallouts(): SidebarCalloutsApi {
return api;
}
export function useActiveSidebarCallout(): SidebarCalloutEntry | null {
return useContext(SidebarCalloutStateContext);
export function useActiveSidebarCallout(
options: { nativeOnly?: boolean } = {},
): SidebarCalloutEntry | null {
const state = useContext(SidebarCalloutStateContext);
const nativeOnly = options.nativeOnly === true;
return useMemo(
() => (state ? selectActiveSidebarCallout(state, { nativeOnly }) : null),
[nativeOnly, state],
);
}
export function SidebarCalloutViewport() {
const activeCallout = useActiveSidebarCallout();
/** The active callout. `nativeOnly` limits it to callouts marked `native`, for native apps and compact sidebars. */
export function SidebarCalloutViewport({ nativeOnly = false }: { nativeOnly?: boolean }) {
const activeCallout = useActiveSidebarCallout({ nativeOnly });
const api = useSidebarCallouts();
if (!activeCallout) {
return null;
@@ -31,6 +31,30 @@ describe("sidebar callout state", () => {
expect(selectActiveSidebarCallout(state)?.title).toBe("Set up scripts");
});
it("selects only callouts marked native for the native sidebar", () => {
let state = createSidebarCalloutState();
state = showSidebarCallout(state, {
id: "desktop-update",
priority: 300,
title: "Update available",
}).state;
state = showSidebarCallout(state, {
id: "daemon-outdated",
priority: 250,
title: "Daemon out of date",
native: true,
}).state;
expect(selectActiveSidebarCallout(state)?.title).toBe("Update available");
expect(selectActiveSidebarCallout(state, { nativeOnly: true })?.title).toBe(
"Daemon out of date",
);
state = dismissSidebarCallout(state, "daemon-outdated").state;
expect(selectActiveSidebarCallout(state, { nativeOnly: true })).toBeNull();
});
it("replaces a callout by id without duplicating the queue item", () => {
let state = createSidebarCalloutState();
state = showSidebarCallout(state, {
@@ -14,6 +14,8 @@ export interface SidebarCalloutOptions {
priority?: number;
onDismiss?: () => void;
testID?: string;
/** Also shown in the iOS and Android sidebar and every compact sidebar. Callouts about the desktop app leave it unset. */
native?: boolean;
}
export interface SidebarCalloutEntry extends SidebarCalloutOptions {
@@ -129,10 +131,19 @@ export function clearSidebarCallouts(state: SidebarCalloutState): SidebarCallout
return { ...state, callouts: [] };
}
export type SidebarCalloutSelectionState = Pick<
SidebarCalloutState,
"callouts" | "dismissedKeys" | "dismissalStorageLoaded"
>;
export function selectActiveSidebarCallout(
state: Pick<SidebarCalloutState, "callouts" | "dismissedKeys" | "dismissalStorageLoaded">,
state: SidebarCalloutSelectionState,
options: { nativeOnly?: boolean } = {},
): SidebarCalloutEntry | null {
const visibleCallouts = state.callouts.filter((entry) => {
if (options.nativeOnly && entry.native !== true) {
return false;
}
const dismissalKey = normalizeDismissalKey(entry.dismissalKey);
if (!dismissalKey) {
return true;
+12 -16
View File
@@ -2058,6 +2058,18 @@ export const ar: TranslationResources = {
sidebarCallout: {
dismiss: "رفض",
},
daemonOutdated: {
title: "الخدمة قديمة",
versions: "يشغّل {{host}} الخدمة {{daemonVersion}}. إصدار هذا التطبيق {{appVersion}}.",
terminalsStayOpen: "يعيد التحديث تشغيل الخدمة. تبقى الطرفيات مفتوحة.",
terminalsClose: "يعيد التحديث تشغيل الخدمة ويغلق الطرفيات المفتوحة.",
desktopUpdateTerminals:
"يُبقي تحديث Paseo Desktop الطرفيات مفتوحة على macOS وLinux، ويغلقها على Windows.",
desktopManaged: "يدير Paseo Desktop هذه الخدمة. حدّث Paseo على ذلك الحاسوب.",
selfUpdateUnsupported: "لا يمكن لهذه الخدمة تحديث نفسها. حدّث Paseo على ذلك الحاسوب.",
update: "تحديث",
updateFailedTitle: "فشل تحديث الخدمة",
},
contextWindow: {
noData: "لا توجد بيانات للسياق",
accessibilityNoData: "نافذة السياق: لا توجد بيانات للسياق",
@@ -2092,11 +2104,6 @@ export const ar: TranslationResources = {
},
},
providerUsage: {
checkUsage: "التحقق من الاستخدام",
checkUsageHint: "ينطبق على {{host}} على جميع الأجهزة",
off: "التحقق من الاستخدام متوقف على {{host}}.",
turnOn: "تشغيل",
options: "الإعدادات",
refreshFailed: "تعذّر تحديث الاستخدام",
noHosts: "لا توجد مضيفات متصلة",
@@ -2162,7 +2169,6 @@ export const ar: TranslationResources = {
minutes_few: "{{value}} دقائق",
minutes_many: "{{value}} دقيقة",
minutes_other: "{{value}} دقيقة",
pair: "{{first}} و{{second}}",
},
timing: {
lastUpdated: "آخر تحديث {{relativeTime}}",
@@ -2170,8 +2176,6 @@ export const ar: TranslationResources = {
resetTimePassed: "مضى وقت إعادة التعيين",
resettingNow: "تتم إعادة التعيين الآن",
resetsIn: "تتم إعادة التعيين خلال {{duration}}",
resetsAtWeekday: "{{weekday}} {{time}}",
resetsAtDate: "{{date}}، {{time}}",
runsOutNow: "ينفد الآن",
runsOutIn: "ينفد خلال {{duration}}",
justNow: "الآن",
@@ -2786,14 +2790,6 @@ export const ar: TranslationResources = {
},
agents: {
unavailable: "Connect to this host to manage agents",
resumeOnStartup: {
title: "استئناف الوكلاء بعد إعادة التشغيل",
info: "إعادة فتح الوكلاء الذين كانوا يعملون عند توقف المضيف وإرسال طلب الاستئناف. ينطبق ذلك بعد إعادة التشغيل العادية والتعطّل.",
hint: "متابعة العمل المنقطع تلقائيًا.",
prompt: "طلب الاستئناف",
edit: "تحرير",
editAccessibilityLabel: "تعديل الطلب",
},
idleRuntimeCleanup: {
title: "تحرير الوكلاء الخاملين",
hint: "تحرير الذاكرة بعد ساعة من الخمول.",
+14 -18
View File
@@ -2083,6 +2083,18 @@ export const en = {
sidebarCallout: {
dismiss: "Dismiss",
},
daemonOutdated: {
title: "Daemon out of date",
versions: "{{host}} runs daemon {{daemonVersion}}. This app is {{appVersion}}.",
terminalsStayOpen: "Updating restarts the daemon. Terminals stay open.",
terminalsClose: "Updating restarts the daemon and closes open terminals.",
desktopUpdateTerminals:
"Updating Paseo Desktop keeps terminals open on macOS and Linux, and closes them on Windows.",
desktopManaged: "Paseo Desktop manages this daemon. Update Paseo on that computer.",
selfUpdateUnsupported: "This daemon can't update itself. Update Paseo on that computer.",
update: "Update",
updateFailedTitle: "Daemon update failed",
},
contextWindow: {
noData: "No context data",
accessibilityNoData: "Context window: No context data",
@@ -2117,11 +2129,6 @@ export const en = {
},
},
providerUsage: {
checkUsage: "Check usage",
checkUsageHint: "Applies to {{host}} on every device",
off: "Usage is off on {{host}}.",
turnOn: "Turn on",
options: "Settings",
refreshFailed: "Unable to refresh usage",
noHosts: "No connected hosts",
@@ -2169,18 +2176,15 @@ export const en = {
days: "{{value}}d",
hours: "{{value}}h",
minutes: "{{value}}m",
pair: "{{first}} {{second}}",
},
timing: {
lastUpdated: "Last updated {{relativeTime}}",
noSuccessfulUpdate: "No successful update",
resetTimePassed: "Reset time passed",
resettingNow: "resetting now",
resetsIn: "resets in {{duration}}",
resetsAtWeekday: "{{weekday}} {{time}}",
resetsAtDate: "{{date}}, {{time}}",
resetsIn: "resets {{duration}}",
runsOutNow: "runs out now",
runsOutIn: "runs out in {{duration}}",
runsOutIn: "runs out {{duration}}",
justNow: "just now",
daysAgo: "{{value}}d ago",
hoursAgo: "{{value}}h ago",
@@ -2878,14 +2882,6 @@ export const en = {
},
agents: {
unavailable: "Connect to this host to manage agents",
resumeOnStartup: {
title: "Resume agents after restart",
info: "Reopen agents that were working when the host stopped and send the resume prompt. Applies after graceful restarts and crashes.",
hint: "Continue interrupted work automatically.",
prompt: "Resume prompt",
edit: "Edit",
editAccessibilityLabel: "Edit prompt",
},
idleRuntimeCleanup: {
title: "Release idle agents",
hint: "Free up memory after an hour of inactivity.",
+13 -16
View File
@@ -2106,6 +2106,19 @@ export const es: TranslationResources = {
sidebarCallout: {
dismiss: "Despedir",
},
daemonOutdated: {
title: "Daemon desactualizado",
versions: "{{host}} ejecuta el daemon {{daemonVersion}}. Esta app es {{appVersion}}.",
terminalsStayOpen: "Actualizar reinicia el daemon. Las terminales siguen abiertas.",
terminalsClose: "Actualizar reinicia el daemon y cierra las terminales abiertas.",
desktopUpdateTerminals:
"Actualizar Paseo Desktop mantiene las terminales abiertas en macOS y Linux, y las cierra en Windows.",
desktopManaged: "Paseo Desktop administra este daemon. Actualiza Paseo en esa computadora.",
selfUpdateUnsupported:
"Este daemon no puede actualizarse solo. Actualiza Paseo en esa computadora.",
update: "Actualizar",
updateFailedTitle: "No se pudo actualizar el daemon",
},
contextWindow: {
noData: "No hay datos de contexto",
accessibilityNoData: "Ventana de contexto: No hay datos de contexto",
@@ -2140,11 +2153,6 @@ export const es: TranslationResources = {
},
},
providerUsage: {
checkUsage: "Consultar uso",
checkUsageHint: "Se aplica a {{host}} en todos los dispositivos",
off: "El uso está desactivado en {{host}}.",
turnOn: "Activar",
options: "Configuración",
refreshFailed: "No se pudo actualizar el uso",
noHosts: "No hay hosts conectados",
@@ -2192,7 +2200,6 @@ export const es: TranslationResources = {
days: "{{value}} d",
hours: "{{value}} h",
minutes: "{{value}} min",
pair: "{{first}} {{second}}",
},
timing: {
lastUpdated: "Última actualización {{relativeTime}}",
@@ -2200,8 +2207,6 @@ export const es: TranslationResources = {
resetTimePassed: "Pasó la hora de restablecimiento",
resettingNow: "se restablece ahora",
resetsIn: "se restablece en {{duration}}",
resetsAtWeekday: "{{weekday}} {{time}}",
resetsAtDate: "{{date}}, {{time}}",
runsOutNow: "se agota ahora",
runsOutIn: "se agota en {{duration}}",
justNow: "ahora mismo",
@@ -2808,14 +2813,6 @@ export const es: TranslationResources = {
},
agents: {
unavailable: "Connect to this host to manage agents",
resumeOnStartup: {
title: "Reanudar agentes tras reiniciar",
info: "Vuelve a abrir los agentes que estaban trabajando cuando se detuvo el host y envía el mensaje de reanudación. Se aplica tras reinicios normales y fallos.",
hint: "Continuar automáticamente el trabajo interrumpido.",
prompt: "Mensaje de reanudación",
edit: "Editar",
editAccessibilityLabel: "Editar mensaje",
},
idleRuntimeCleanup: {
title: "Liberar agentes inactivos",
hint: "Libera memoria tras una hora de inactividad.",
+13 -16
View File
@@ -2105,6 +2105,19 @@ export const fr: TranslationResources = {
sidebarCallout: {
dismiss: "Ignorer",
},
daemonOutdated: {
title: "Daemon obsolète",
versions: "{{host}} exécute le daemon {{daemonVersion}}. Cette app est en {{appVersion}}.",
terminalsStayOpen: "La mise à jour redémarre le daemon. Les terminaux restent ouverts.",
terminalsClose: "La mise à jour redémarre le daemon et ferme les terminaux ouverts.",
desktopUpdateTerminals:
"La mise à jour de Paseo Desktop garde les terminaux ouverts sur macOS et Linux, et les ferme sur Windows.",
desktopManaged: "Paseo Desktop gère ce daemon. Mettez à jour Paseo sur cet ordinateur.",
selfUpdateUnsupported:
"Ce daemon ne peut pas se mettre à jour lui-même. Mettez à jour Paseo sur cet ordinateur.",
update: "Mettre à jour",
updateFailedTitle: "Échec de la mise à jour du daemon",
},
contextWindow: {
noData: "Aucune donnée de contexte",
accessibilityNoData: "Fenêtre de contexte : aucune donnée de contexte",
@@ -2140,11 +2153,6 @@ export const fr: TranslationResources = {
},
},
providerUsage: {
checkUsage: "Vérifier l’utilisation",
checkUsageHint: "S’applique à {{host}} sur tous les appareils",
off: "L’utilisation est désactivée sur {{host}}.",
turnOn: "Activer",
options: "Paramètres",
refreshFailed: "Impossible d’actualiser l’utilisation",
noHosts: "Aucun hôte connecté",
@@ -2192,7 +2200,6 @@ export const fr: TranslationResources = {
days: "{{value}} j",
hours: "{{value}} h",
minutes: "{{value}} min",
pair: "{{first}} {{second}}",
},
timing: {
lastUpdated: "Dernière mise à jour {{relativeTime}}",
@@ -2200,8 +2207,6 @@ export const fr: TranslationResources = {
resetTimePassed: "Heure de réinitialisation dépassée",
resettingNow: "réinitialisation en cours",
resetsIn: "se réinitialise dans {{duration}}",
resetsAtWeekday: "{{weekday}} {{time}}",
resetsAtDate: "{{date}}, {{time}}",
runsOutNow: "s’épuise maintenant",
runsOutIn: "s’épuise dans {{duration}}",
justNow: "à l’instant",
@@ -2811,14 +2816,6 @@ export const fr: TranslationResources = {
},
agents: {
unavailable: "Connectez-vous à cet hôte pour gérer les agents",
resumeOnStartup: {
title: "Reprendre les agents après un redémarrage",
info: "Rouvre les agents qui travaillaient lorsque l’hôte s’est arrêté et envoie le message de reprise. S’applique après un redémarrage normal ou un plantage.",
hint: "Continuer automatiquement le travail interrompu.",
prompt: "Message de reprise",
edit: "Modifier",
editAccessibilityLabel: "Modifier le message",
},
idleRuntimeCleanup: {
title: "Libérer les agents inactifs",
hint: "Libère de la mémoire après une heure d’inactivité.",
+14 -16
View File
@@ -2078,6 +2078,20 @@ export const ja: TranslationResources = {
sidebarCallout: {
dismiss: "閉じる",
},
daemonOutdated: {
title: "デーモンが古くなっています",
versions: "{{host}}のデーモンは{{daemonVersion}}です。このアプリは{{appVersion}}です。",
terminalsStayOpen: "更新するとデーモンが再起動します。ターミナルは開いたままです。",
terminalsClose: "更新するとデーモンが再起動し、開いているターミナルは閉じられます。",
desktopUpdateTerminals:
"Paseo Desktop を更新すると、macOS と Linux ではターミナルは開いたままですが、Windows では閉じられます。",
desktopManaged:
"このデーモンはPaseo Desktopによって管理されています。そのコンピューターでPaseoを更新してください。",
selfUpdateUnsupported:
"このデーモンは自身を更新できません。そのコンピューターでPaseoを更新してください。",
update: "更新",
updateFailedTitle: "デーモンを更新できませんでした",
},
contextWindow: {
noData: "コンテキストデータがありません",
accessibilityNoData: "コンテキストウィンドウ:コンテキストデータがありません",
@@ -2113,11 +2127,6 @@ export const ja: TranslationResources = {
},
},
providerUsage: {
checkUsage: "使用状況を確認",
checkUsageHint: "すべてのデバイスで {{host}} に適用",
off: "{{host}} の使用状況確認はオフです。",
turnOn: "オンにする",
options: "設定",
refreshFailed: "使用状況を更新できません",
noHosts: "接続中のホストがありません",
@@ -2165,7 +2174,6 @@ export const ja: TranslationResources = {
days: "{{value}}日",
hours: "{{value}}時間",
minutes: "{{value}}分",
pair: "{{first}}{{second}}",
},
timing: {
lastUpdated: "最終更新: {{relativeTime}}",
@@ -2173,8 +2181,6 @@ export const ja: TranslationResources = {
resetTimePassed: "リセット時刻を過ぎています",
resettingNow: "まもなくリセット",
resetsIn: "{{duration}}後にリセット",
resetsAtWeekday: "{{weekday}} {{time}}",
resetsAtDate: "{{date}} {{time}}",
runsOutNow: "まもなく上限に到達",
runsOutIn: "{{duration}}後に上限に到達",
justNow: "たった今",
@@ -2775,14 +2781,6 @@ export const ja: TranslationResources = {
},
agents: {
unavailable: "エージェントを管理するにはこのホストに接続してください",
resumeOnStartup: {
title: "再起動後にエージェントを再開",
info: "ホストの停止時に作業中だったエージェントを開き直し、再開メッセージを送信します。通常の再起動とクラッシュの両方に適用されます。",
hint: "中断された作業を自動的に続行します。",
prompt: "再開メッセージ",
edit: "編集",
editAccessibilityLabel: "メッセージを編集",
},
idleRuntimeCleanup: {
title: "アイドル状態のエージェントを解放",
hint: "1 時間のアイドル状態の後にメモリを解放します。",
+14 -16
View File
@@ -2068,6 +2068,20 @@ export const ko: TranslationResources = {
sidebarCallout: {
dismiss: "닫기",
},
daemonOutdated: {
title: "데몬이 오래되었습니다",
versions: "{{host}}의 데몬은 {{daemonVersion}}입니다. 이 앱은 {{appVersion}}입니다.",
terminalsStayOpen: "업데이트하면 데몬이 다시 시작됩니다. 터미널은 열린 상태로 유지됩니다.",
terminalsClose: "업데이트하면 데몬이 다시 시작되고 열려 있는 터미널이 닫힙니다.",
desktopUpdateTerminals:
"Paseo Desktop을 업데이트하면 macOS와 Linux에서는 터미널이 열린 상태로 유지되고, Windows에서는 닫힙니다.",
desktopManaged:
"이 데몬은 Paseo Desktop에서 관리됩니다. 해당 컴퓨터에서 Paseo를 업데이트하세요.",
selfUpdateUnsupported:
"이 데몬은 스스로 업데이트할 수 없습니다. 해당 컴퓨터에서 Paseo를 업데이트하세요.",
update: "업데이트",
updateFailedTitle: "데몬 업데이트 실패",
},
contextWindow: {
noData: "컨텍스트 데이터 없음",
accessibilityNoData: "컨텍스트 창: 컨텍스트 데이터 없음",
@@ -2102,11 +2116,6 @@ export const ko: TranslationResources = {
},
},
providerUsage: {
checkUsage: "사용량 확인",
checkUsageHint: "모든 기기에서 {{host}}에 적용",
off: "{{host}}의 사용량 확인이 꺼져 있습니다.",
turnOn: "켜기",
options: "설정",
refreshFailed: "사용량을 새로 고칠 수 없습니다",
noHosts: "연결된 호스트가 없습니다",
@@ -2154,7 +2163,6 @@ export const ko: TranslationResources = {
days: "{{value}}일",
hours: "{{value}}시간",
minutes: "{{value}}분",
pair: "{{first}} {{second}}",
},
timing: {
lastUpdated: "마지막 업데이트: {{relativeTime}}",
@@ -2162,8 +2170,6 @@ export const ko: TranslationResources = {
resetTimePassed: "초기화 시간이 지났습니다",
resettingNow: "지금 재설정 중",
resetsIn: "{{duration}} 후 재설정",
resetsAtWeekday: "{{weekday}} {{time}}",
resetsAtDate: "{{date}} {{time}}",
runsOutNow: "지금 한도에 도달",
runsOutIn: "{{duration}} 후 한도에 도달",
justNow: "방금",
@@ -2763,14 +2769,6 @@ export const ko: TranslationResources = {
},
agents: {
unavailable: "에이전트를 관리하려면 이 호스트에 연결하세요",
resumeOnStartup: {
title: "재시작 후 에이전트 재개",
info: "호스트가 중지될 때 작업 중이던 에이전트를 다시 열고 재개 메시지를 보냅니다. 정상 재시작과 비정상 종료 후 모두 적용됩니다.",
hint: "중단된 작업을 자동으로 계속합니다.",
prompt: "재개 메시지",
edit: "편집",
editAccessibilityLabel: "메시지 편집",
},
idleRuntimeCleanup: {
title: "유휴 에이전트 해제",
hint: "1시간 동안 활동이 없으면 메모리를 확보합니다.",
+13 -16
View File
@@ -2091,6 +2091,19 @@ export const ptBR: TranslationResources = {
sidebarCallout: {
dismiss: "Dispensar",
},
daemonOutdated: {
title: "Daemon desatualizado",
versions: "{{host}} executa o daemon {{daemonVersion}}. Este app é {{appVersion}}.",
terminalsStayOpen: "Atualizar reinicia o daemon. Os terminais continuam abertos.",
terminalsClose: "Atualizar reinicia o daemon e fecha os terminais abertos.",
desktopUpdateTerminals:
"Atualizar o Paseo Desktop mantém os terminais abertos no macOS e no Linux, e os fecha no Windows.",
desktopManaged: "O Paseo Desktop gerencia este daemon. Atualize o Paseo nesse computador.",
selfUpdateUnsupported:
"Este daemon não consegue se atualizar sozinho. Atualize o Paseo nesse computador.",
update: "Atualizar",
updateFailedTitle: "Falha ao atualizar o daemon",
},
contextWindow: {
noData: "Sem dados de contexto",
accessibilityNoData: "Janela de contexto: sem dados de contexto",
@@ -2125,11 +2138,6 @@ export const ptBR: TranslationResources = {
},
},
providerUsage: {
checkUsage: "Consultar uso",
checkUsageHint: "Aplica-se a {{host}} em todos os dispositivos",
off: "O uso está desativado em {{host}}.",
turnOn: "Ativar",
options: "Configurações",
refreshFailed: "Não foi possível atualizar o uso",
noHosts: "Nenhum host conectado",
@@ -2177,7 +2185,6 @@ export const ptBR: TranslationResources = {
days: "{{value}} d",
hours: "{{value}} h",
minutes: "{{value}} min",
pair: "{{first}} {{second}}",
},
timing: {
lastUpdated: "Última atualização {{relativeTime}}",
@@ -2185,8 +2192,6 @@ export const ptBR: TranslationResources = {
resetTimePassed: "O horário de redefinição passou",
resettingNow: "redefinindo agora",
resetsIn: "redefine em {{duration}}",
resetsAtWeekday: "{{weekday}} {{time}}",
resetsAtDate: "{{date}}, {{time}}",
runsOutNow: "esgota agora",
runsOutIn: "esgota em {{duration}}",
justNow: "agora mesmo",
@@ -2790,14 +2795,6 @@ export const ptBR: TranslationResources = {
},
agents: {
unavailable: "Conecte-se a este host para gerenciar agentes",
resumeOnStartup: {
title: "Retomar agentes após reiniciar",
info: "Reabre os agentes que estavam trabalhando quando o host parou e envia a mensagem de retomada. Aplica-se após reinicializações normais e falhas.",
hint: "Continuar automaticamente o trabalho interrompido.",
prompt: "Mensagem de retomada",
edit: "Editar",
editAccessibilityLabel: "Editar mensagem",
},
idleRuntimeCleanup: {
title: "Liberar agentes ociosos",
hint: "Libera memória após uma hora de inatividade.",
+12 -16
View File
@@ -2091,6 +2091,18 @@ export const ru: TranslationResources = {
sidebarCallout: {
dismiss: "Закрыть",
},
daemonOutdated: {
title: "Демон устарел",
versions: "На {{host}} работает демон {{daemonVersion}}. Версия приложения: {{appVersion}}.",
terminalsStayOpen: "Обновление перезапустит демон. Терминалы останутся открытыми.",
terminalsClose: "Обновление перезапустит демон и закроет открытые терминалы.",
desktopUpdateTerminals:
"Обновление Paseo Desktop оставляет терминалы открытыми в macOS и Linux и закрывает их в Windows.",
desktopManaged: "Этим демоном управляет Paseo Desktop. Обновите Paseo на том компьютере.",
selfUpdateUnsupported: "Этот демон не может обновиться сам. Обновите Paseo на том компьютере.",
update: "Обновить",
updateFailedTitle: "Не удалось обновить демон",
},
contextWindow: {
noData: "Нет данных о контексте",
accessibilityNoData: "Контекстное окно: нет данных о контексте",
@@ -2125,11 +2137,6 @@ export const ru: TranslationResources = {
},
},
providerUsage: {
checkUsage: "Проверять использование",
checkUsageHint: "Применяется к {{host}} на всех устройствах",
off: "Проверка использования отключена на {{host}}.",
turnOn: "Включить",
options: "Настройки",
refreshFailed: "Не удалось обновить использование",
noHosts: "Нет подключённых хостов",
@@ -2177,7 +2184,6 @@ export const ru: TranslationResources = {
days: "{{value}} дн.",
hours: "{{value}} ч",
minutes: "{{value}} мин",
pair: "{{first}} {{second}}",
},
timing: {
lastUpdated: "Последнее обновление {{relativeTime}}",
@@ -2185,8 +2191,6 @@ export const ru: TranslationResources = {
resetTimePassed: "Время сброса прошло",
resettingNow: "сброс сейчас",
resetsIn: "сброс через {{duration}}",
resetsAtWeekday: "{{weekday}} {{time}}",
resetsAtDate: "{{date}}, {{time}}",
runsOutNow: "закончится сейчас",
runsOutIn: "закончится через {{duration}}",
justNow: "только что",
@@ -2800,14 +2804,6 @@ export const ru: TranslationResources = {
},
agents: {
unavailable: "Подключитесь к этому хосту, чтобы управлять агентами",
resumeOnStartup: {
title: "Возобновлять агентов после перезапуска",
info: "Открывает агентов, которые работали при остановке хоста, и отправляет сообщение для продолжения. Применяется после обычного перезапуска и сбоя.",
hint: "Автоматически продолжать прерванную работу.",
prompt: "Сообщение для продолжения",
edit: "Редактировать",
editAccessibilityLabel: "Изменить сообщение",
},
idleRuntimeCleanup: {
title: "Освобождать неактивных агентов",
hint: "Освобождает память после часа бездействия.",
+12 -16
View File
@@ -2036,6 +2036,18 @@ export const zhCN: TranslationResources = {
sidebarCallout: {
dismiss: "关闭",
},
daemonOutdated: {
title: "守护进程版本过旧",
versions: "{{host}} 运行的守护进程为 {{daemonVersion}},此应用为 {{appVersion}}。",
terminalsStayOpen: "更新会重启守护进程,终端将保持打开。",
terminalsClose: "更新会重启守护进程,并关闭已打开的终端。",
desktopUpdateTerminals:
"更新 Paseo Desktop 时,macOS 和 Linux 上的终端保持打开,Windows 上的终端会被关闭。",
desktopManaged: "此守护进程由 Paseo Desktop 管理。请在那台电脑上更新 Paseo。",
selfUpdateUnsupported: "此守护进程无法自行更新。请在那台电脑上更新 Paseo。",
update: "更新",
updateFailedTitle: "守护进程更新失败",
},
contextWindow: {
noData: "暂无上下文数据",
accessibilityNoData: "上下文窗口:暂无上下文数据",
@@ -2070,11 +2082,6 @@ export const zhCN: TranslationResources = {
},
},
providerUsage: {
checkUsage: "检查用量",
checkUsageHint: "在所有设备上应用于 {{host}}",
off: "{{host}} 的用量检查已关闭。",
turnOn: "开启",
options: "设置",
refreshFailed: "无法刷新用量",
noHosts: "没有已连接的主机",
@@ -2122,7 +2129,6 @@ export const zhCN: TranslationResources = {
days: "{{value}}天",
hours: "{{value}}小时",
minutes: "{{value}}分钟",
pair: "{{first}}{{second}}",
},
timing: {
lastUpdated: "上次更新于 {{relativeTime}}",
@@ -2130,8 +2136,6 @@ export const zhCN: TranslationResources = {
resetTimePassed: "重置时间已过",
resettingNow: "正在重置",
resetsIn: "将在 {{duration}}后重置",
resetsAtWeekday: "{{weekday}} {{time}}",
resetsAtDate: "{{date}} {{time}}",
runsOutNow: "即将用尽",
runsOutIn: "将在 {{duration}}后用尽",
justNow: "刚刚",
@@ -2721,14 +2725,6 @@ export const zhCN: TranslationResources = {
},
agents: {
unavailable: "连接到这个 Host 以管理 Agent",
resumeOnStartup: {
title: "重启后恢复智能体",
info: "重新打开主机停止时正在工作的智能体,并发送恢复提示。适用于正常重启和崩溃后的恢复。",
hint: "自动继续被中断的工作。",
prompt: "恢复提示",
edit: "编辑",
editAccessibilityLabel: "编辑提示",
},
idleRuntimeCleanup: {
title: "释放空闲 Agent",
hint: "空闲一小时后释放内存。",
@@ -65,7 +65,6 @@ import { ICON_SIZE } from "@/styles/theme";
import type { Theme } from "@/styles/theme";
import { useProviderIcon } from "@/components/provider-icons";
import { BrowserToolsOptInCard } from "./browser-tools-card";
import { RestartRecoveryCard } from "./restart-recovery-card";
import { IdleRuntimeCleanupCard } from "./idle-runtime-cleanup-card";
import { restartDaemonFromSettings, updateDaemonFromSettings } from "./daemon-lifecycle";
@@ -293,7 +292,6 @@ export function HostAgentsPage({ serverId }: { serverId: string }) {
<InjectPaseoToolsCard serverId={serverId} />
<BrowserToolsOptInCard serverId={serverId} />
<AppendSystemPromptCard serverId={serverId} />
<RestartRecoveryCard serverId={serverId} />
<IdleRuntimeCleanupCard serverId={serverId} />
</SettingsSection>
) : (
@@ -1,199 +0,0 @@
import { useCallback, useMemo, useState } from "react";
import { Text, View } from "react-native";
import { useMutation } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { StyleSheet } from "react-native-unistyles";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { SettingsTextAreaCard } from "@/components/settings-textarea";
import { SettingsInfoTip } from "@/components/settings/headings/settings-info-tip";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { useDaemonConfig } from "@/hooks/use-daemon-config";
import { useHostFeature } from "@/runtime/host-features";
import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { settingsStyles } from "@/styles/settings";
const DEFAULT_RESUME_PROMPT = "Resume working.";
// COMPAT(restartRecovery): added in v0.14.0, remove gate after 2027-04-28.
export function RestartRecoveryCard({ serverId }: { serverId: string }) {
const { t } = useTranslation();
const isConnected = useHostRuntimeIsConnected(serverId);
const isSupported = useHostFeature(serverId, "resumeOnStartupSetting");
const { config, patchConfig } = useDaemonConfig(serverId);
const [isEditing, setIsEditing] = useState(false);
const mutation = useMutation({
mutationFn: async (next: { resumeOnStartup?: boolean; resumePrompt?: string }) => {
const result = await patchConfig(next);
if (!result) {
throw new Error(t("workspace.terminal.hostDisconnected"));
}
return result;
},
});
const handleValueChange = useCallback(
(next: boolean) => {
mutation.mutate({ resumeOnStartup: next });
},
[mutation],
);
const handleOpen = useCallback(() => {
mutation.reset();
setIsEditing(true);
}, [mutation]);
const handleClose = useCallback(() => {
if (mutation.isPending) return;
mutation.reset();
setIsEditing(false);
}, [mutation]);
const handleSave = useCallback(
(prompt: string) => {
mutation.mutate({ resumePrompt: prompt }, { onSuccess: () => setIsEditing(false) });
},
[mutation],
);
if (!isConnected || !isSupported || !config) return null;
const isEnabled = config.resumeOnStartup !== false;
return (
<>
<View style={settingsStyles.card} testID="host-page-restart-recovery-card">
<View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}>
<View style={styles.titleRow}>
<Text style={settingsStyles.rowTitle}>
{t("settings.host.agents.resumeOnStartup.title")}
</Text>
<SettingsInfoTip
title={t("settings.host.agents.resumeOnStartup.title")}
info={t("settings.host.agents.resumeOnStartup.info")}
/>
</View>
<Text style={settingsStyles.rowHint}>
{t("settings.host.agents.resumeOnStartup.hint")}
</Text>
{mutation.error && !isEditing ? (
<Text style={settingsStyles.rowError} testID="host-page-restart-recovery-error">
{mutation.error.message}
</Text>
) : null}
</View>
<View style={styles.trailing}>
<Button
variant="outline"
size="sm"
onPress={handleOpen}
disabled={!isEnabled || mutation.isPending}
accessibilityLabel={t("settings.host.agents.resumeOnStartup.editAccessibilityLabel")}
testID="host-page-restart-recovery-edit"
>
{t("settings.host.agents.resumeOnStartup.edit")}
</Button>
<Switch
value={isEnabled}
onValueChange={handleValueChange}
disabled={mutation.isPending}
accessibilityLabel={t("settings.host.agents.resumeOnStartup.title")}
testID="host-page-restart-recovery-switch"
/>
</View>
</View>
</View>
{isEditing ? (
<ResumePromptSheet
initialPrompt={config.resumePrompt ?? DEFAULT_RESUME_PROMPT}
isSaving={mutation.isPending}
error={mutation.error?.message ?? null}
onSave={handleSave}
onClose={handleClose}
/>
) : null}
</>
);
}
function ResumePromptSheet({
initialPrompt,
isSaving,
error,
onSave,
onClose,
}: {
initialPrompt: string;
isSaving: boolean;
error: string | null;
onSave: (prompt: string) => void;
onClose: () => void;
}) {
const { t } = useTranslation();
const [draft, setDraft] = useState(initialPrompt);
const header = useMemo<SheetHeader>(
() => ({ title: t("settings.host.agents.resumeOnStartup.prompt") }),
[t],
);
const canSave = draft !== initialPrompt && draft.trim().length > 0 && !isSaving;
const handleSave = useCallback(() => onSave(draft), [draft, onSave]);
return (
<AdaptiveModalSheet
header={header}
visible
onClose={onClose}
testID="host-page-restart-recovery-sheet"
desktopMaxWidth={560}
>
<SettingsTextAreaCard
testID="host-page-restart-recovery-prompt-input"
accessibilityLabel={t("settings.host.agents.resumeOnStartup.prompt")}
value={draft}
onChangeText={setDraft}
placeholder={DEFAULT_RESUME_PROMPT}
/>
{error ? (
<Text style={settingsStyles.rowError} testID="host-page-restart-recovery-error">
{error}
</Text>
) : null}
<View style={styles.sheetActions}>
<Button variant="ghost" size="sm" onPress={onClose} disabled={isSaving}>
{t("common.actions.cancel")}
</Button>
<Button
variant="default"
size="sm"
onPress={handleSave}
disabled={!canSave}
testID="host-page-restart-recovery-save"
>
{t("common.actions.save")}
</Button>
</View>
</AdaptiveModalSheet>
);
}
const styles = StyleSheet.create((theme) => ({
titleRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
trailing: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
},
sheetActions: {
flexDirection: "row",
justifyContent: "flex-end",
gap: theme.spacing[2],
},
}));
+71 -28
View File
@@ -1,16 +1,10 @@
import { useMemo } from "react";
import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import { Text, View } from "react-native";
import { Text, View, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import {
formatAmount,
formatProviderUsageLabel,
formatResetLabel,
formatResetTime,
} from "./format";
import { UsageMeter } from "./meter";
import { UsageResetLine } from "./reset-line";
import type { UsageBalance } from "./types";
import { clampPct, formatAmount, formatProviderUsageLabel, formatResetLabel } from "./format";
import type { UsageBalance, UsageTone } from "./types";
import { useRelativeTimeTick } from "./use-relative-time-tick";
interface ResolvedBalance {
@@ -47,6 +41,19 @@ function resolveBalance({
return { amountText: "—", usedPct: null };
}
function fillToneStyle(tone: UsageTone) {
switch (tone) {
case "ok":
return styles.fillOk;
case "warning":
return styles.fillWarning;
case "danger":
return styles.fillDanger;
default:
return styles.fillDefault;
}
}
export function UsageBalanceBar({
balance,
retained = false,
@@ -59,7 +66,11 @@ export function UsageBalanceBar({
const { amountText, usedPct } = resolveBalance({ balance, t, locale: i18n.resolvedLanguage });
const tone = balance.tone ?? "default";
const resetLabel = formatResetLabel(balance.resetsAt, retained, now, t);
const resetTime = formatResetTime(balance.resetsAt, now, t);
const fillStyle = useMemo<StyleProp<ViewStyle>>(
() => [styles.fill, fillToneStyle(tone), { width: `${clampPct(usedPct ?? 0)}%` }],
[usedPct, tone],
);
return (
<View style={styles.container}>
@@ -67,21 +78,22 @@ export function UsageBalanceBar({
<Text style={styles.label} numberOfLines={1}>
{formatProviderUsageLabel(balance.id, balance.label, t)}
</Text>
<Text
style={styles.amount}
numberOfLines={1}
ellipsizeMode="tail"
testID={`provider-usage-balance-${balance.id}-value`}
>
{amountText}
</Text>
<View style={styles.value} testID={`provider-usage-balance-${balance.id}-value`}>
<Text style={styles.amount} numberOfLines={1} ellipsizeMode="tail">
{amountText}
</Text>
{resetLabel ? (
<Text style={styles.reset} testID={`provider-usage-balance-${balance.id}-reset`}>
{resetLabel}
</Text>
) : null}
</View>
</View>
{usedPct != null ? <UsageMeter percent={usedPct} tone={tone} /> : null}
<UsageResetLine
countdown={resetLabel}
exactTime={resetTime}
testID={`provider-usage-balance-${balance.id}-reset`}
/>
{usedPct != null ? (
<View style={styles.track}>
<View style={fillStyle} />
</View>
) : null}
</View>
);
}
@@ -98,13 +110,44 @@ const styles = StyleSheet.create((theme) => ({
},
label: {
flexShrink: 1,
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
value: {
flexShrink: 1,
alignItems: "flex-end",
},
amount: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
},
amount: {
flexShrink: 1,
color: theme.colors.foreground,
reset: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
textAlign: "right",
},
track: {
height: 4,
borderRadius: 2,
backgroundColor: theme.colors.surface3,
overflow: "hidden",
},
fill: {
height: 4,
borderRadius: 2,
},
fillDefault: {
backgroundColor: theme.colors.foregroundMuted,
},
fillOk: {
backgroundColor: theme.colors.statusSuccess,
},
fillWarning: {
backgroundColor: theme.colors.statusWarning,
},
fillDanger: {
backgroundColor: theme.colors.statusDanger,
},
}));
+3 -10
View File
@@ -21,12 +21,10 @@ export interface UsageHostSelection {
*/
export function UsageControls({
view,
serverId,
onRefresh,
hostSelection,
}: {
view: UsageView;
serverId: string;
onRefresh: () => void;
hostSelection?: UsageHostSelection;
}) {
@@ -45,8 +43,8 @@ export function UsageControls({
) : null}
{view.kind === "unavailable" ? null : (
<>
{view.kind === "off" ? null : <UsageRefreshButton busy={busy} onRefresh={onRefresh} />}
<UsageOptions serverId={serverId} />
<UsageRefreshButton busy={busy} onRefresh={onRefresh} />
<UsageOptions />
</>
)}
</View>
@@ -86,12 +84,7 @@ export function useHostUsageWithControls(hostSelection: UsageHostSelection): {
const controls = useMemo(
() =>
hasControls ? (
<UsageControls
serverId={hostSelection.serverId}
view={view}
onRefresh={refresh}
hostSelection={hostSelection}
/>
<UsageControls view={view} onRefresh={refresh} hostSelection={hostSelection} />
) : null,
[hasControls, hostSelection, refresh, view],
);
+8
View File
@@ -1,5 +1,7 @@
import { useCallback, useMemo } from "react";
import { useAppSettings } from "@/hooks/use-settings";
import { useUsageHostId } from "./hosts";
import { useUsageHostReports } from "./queries";
import {
isUsagePinned,
setUsageDisplayAs,
@@ -23,6 +25,12 @@ interface UsagePreferencesState {
display: UsageDisplay;
}
/** The device's usage preferences, with pins resolved against a host's reports. */
export function useUsagePreferences(serverId?: string): UsagePreferencesState {
const defaultServerId = useUsageHostId();
return useUsageDisplay(useUsageHostReports(serverId ?? defaultServerId));
}
/** The device's usage preferences, with pins resolved against the given reports. */
export function useUsageDisplay(reports: readonly UsageReportEntry[]): UsagePreferencesState {
const { settings, updateSettings } = useAppSettings();
+2 -67
View File
@@ -7,7 +7,6 @@ import {
formatPct,
formatProviderUsageLabel,
formatResetLabel,
formatResetTime,
formatRunsOutLabel,
} from "./format";
@@ -37,8 +36,8 @@ describe("formatAmount", () => {
const threeDaysAgo = new Date(NOW - 3 * 24 * 60 * 60 * 1000).toISOString();
expect(formatPct(7, "en")).toBe("7%");
expect(formatResetLabel(twoHoursFromNow, false, NOW)).toBe("resets in 2h");
expect(formatRunsOutLabel(twoHoursFromNow, NOW)).toBe("runs out in 2h");
expect(formatResetLabel(twoHoursFromNow, false, NOW)).toBe("resets 2h");
expect(formatRunsOutLabel(twoHoursFromNow, NOW)).toBe("runs out 2h");
await i18n.changeLanguage("ja");
expect(formatResetLabel(twoHoursFromNow, false, NOW)).toBe("2時間後にリセット");
@@ -95,70 +94,6 @@ describe("formatAmount", () => {
});
});
describe("reset countdown", () => {
const MINUTE = 60_000;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
const countdown = (ms: number) => formatResetLabel(new Date(NOW + ms).toISOString(), false, NOW);
beforeEach(async () => {
await i18n.changeLanguage("en");
});
it("keeps counting down inside a day instead of sitting on the rounded-down day", () => {
expect(countdown(2 * DAY + 23 * HOUR + 10 * MINUTE)).toBe("resets in 2d 23h");
expect(countdown(2 * DAY + 22 * HOUR + 10 * MINUTE)).toBe("resets in 2d 22h");
});
it("says two units, dropping a unit that is zero", () => {
expect(countdown(DAY + 30 * MINUTE)).toBe("resets in 1d");
expect(countdown(4 * HOUR + 59 * MINUTE)).toBe("resets in 4h 59m");
expect(countdown(HOUR)).toBe("resets in 1h");
expect(countdown(37 * MINUTE)).toBe("resets in 37m");
expect(countdown(59 * 1000)).toBe("resets in 0m");
});
it("joins the at-risk countdown the same way", () => {
const iso = new Date(NOW + 3 * HOUR + 5 * MINUTE).toISOString();
expect(formatRunsOutLabel(iso, NOW)).toBe("runs out in 3h 5m");
});
});
describe("exact reset time", () => {
// Local-time dates, so the thresholds hold in any timezone the tests run in.
const now = new Date(2026, 9, 29, 12, 0).getTime();
const at = (...args: [number, number, number, number, number]) =>
formatResetTime(new Date(...args).toISOString(), now)?.replace(/\s/g, " ");
beforeEach(async () => {
await i18n.changeLanguage("en");
});
it("shows only the time within 24 hours", () => {
expect(at(2026, 9, 29, 15, 0)).toMatch(/^(3:00 PM|15:00)$/);
expect(at(2026, 9, 30, 11, 59)).toMatch(/^(11:59 AM|11:59)$/);
});
it("adds the weekday within 7 days", () => {
expect(at(2026, 9, 30, 12, 0)).toMatch(/^Fri (12:00 PM|12:00)$/);
expect(at(2026, 10, 5, 11, 59)).toMatch(/^Thu (11:59 AM|11:59)$/);
});
it("adds the date after 7 days", () => {
expect(at(2026, 10, 5, 12, 0)).toMatch(/^Nov 5, (12:00 PM|12:00)$/);
});
it("names the weekday in the app language", async () => {
await i18n.changeLanguage("fr");
expect(at(2026, 9, 31, 15, 0)).toMatch(/^sam\.? /);
});
it("says nothing for a time that has passed", () => {
expect(formatResetTime(new Date(now - 1).toISOString(), now)).toBeNull();
expect(formatResetTime(null, now)).toBeNull();
});
});
it("retained reset times that have passed do not claim the quota is resetting", () => {
const past = "2000-01-01T00:00:00.000Z";
expect(formatResetLabel(past, true)).toBe("Reset time passed");
+35 -68
View File
@@ -1,6 +1,5 @@
import type { TFunction } from "i18next";
import { i18n } from "@/i18n/i18next";
import { formatTimeOfDay } from "@/utils/time";
import type { UsageDisplayAs } from "./preferences";
import type { UsageBalanceUnit } from "./types";
@@ -51,17 +50,7 @@ export function formatDisplayPct(
: t("providerUsage.values.remaining", { amount: percentage });
}
type DurationUnit = "minutes" | "hours" | "days";
interface DurationPart {
unit: DurationUnit;
count: number;
}
/** Two units, floored, so a countdown keeps moving: "2d 23h", "4h 59m", "37m". */
type RelativeDuration = { kind: "now" } | { kind: "remaining"; parts: DurationPart[] };
const MINUTE_MS = 60_000;
const HOUR_MS = 60 * MINUTE_MS;
const DAY_MS = 24 * HOUR_MS;
type RelativeDuration = { unit: "now" } | { unit: "minutes" | "hours" | "days"; count: number };
function formatCount(value: number): string {
return new Intl.NumberFormat(i18n.resolvedLanguage).format(value);
@@ -70,34 +59,35 @@ function formatCount(value: number): string {
function relativeDuration(iso: string, now: number): RelativeDuration | null {
const diffMs = new Date(iso).getTime() - now;
if (!Number.isFinite(diffMs)) return null;
if (diffMs <= 0) return { kind: "now" };
const days = Math.floor(diffMs / DAY_MS);
const hours = Math.floor((diffMs % DAY_MS) / HOUR_MS);
const minutes = Math.floor((diffMs % HOUR_MS) / MINUTE_MS);
if (days > 0) return remaining({ unit: "days", count: days }, { unit: "hours", count: hours });
if (hours > 0)
return remaining({ unit: "hours", count: hours }, { unit: "minutes", count: minutes });
return remaining({ unit: "minutes", count: minutes });
if (diffMs <= 0) return { unit: "now" };
const diffMinutes = Math.floor(diffMs / 60_000);
const diffHours = Math.floor(diffMinutes / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffDays > 0) return { unit: "days", count: diffDays };
if (diffHours > 0) return { unit: "hours", count: diffHours };
return { unit: "minutes", count: diffMinutes };
}
/** The major unit, plus the minor one unless it is zero. */
function remaining(major: DurationPart, minor?: DurationPart): RelativeDuration {
return { kind: "remaining", parts: minor && minor.count > 0 ? [major, minor] : [major] };
}
function formatDurationPart(part: DurationPart, t: TFunction): string {
return t(`providerUsage.duration.${part.unit}`, {
count: part.count,
value: formatCount(part.count),
});
}
function formatRelativeDuration(
duration: Extract<RelativeDuration, { kind: "remaining" }>,
t: TFunction,
): string {
const [first, second] = duration.parts.map((part) => formatDurationPart(part, t));
return second ? t("providerUsage.duration.pair", { first, second }) : first;
function formatRelativeDuration(duration: RelativeDuration, t: TFunction): string | null {
switch (duration.unit) {
case "now":
return null;
case "days":
return t("providerUsage.duration.days", {
count: duration.count,
value: formatCount(duration.count),
});
case "hours":
return t("providerUsage.duration.hours", {
count: duration.count,
value: formatCount(duration.count),
});
case "minutes":
return t("providerUsage.duration.minutes", {
count: duration.count,
value: formatCount(duration.count),
});
}
}
export function formatResetLabel(
@@ -109,34 +99,10 @@ export function formatResetLabel(
if (!iso) return null;
const rel = relativeDuration(iso, now);
if (!rel) return null;
if (rel.kind === "now" && retained) return t("providerUsage.timing.resetTimePassed");
if (rel.kind === "now") return t("providerUsage.timing.resettingNow");
return t("providerUsage.timing.resetsIn", { duration: formatRelativeDuration(rel, t) });
}
/**
* When a window resets on the clock: "3:00 PM" within a day, "Fri 3:00 PM" within a week,
* "Oct 31, 3:00 PM" after that. Names follow the app language; 12/24h follows the device.
* Nothing for a time that has passed, where the countdown already says so.
*/
export function formatResetTime(
iso: string | null | undefined,
now = Date.now(),
t: TFunction = i18n.t,
): string | null {
if (!iso) return null;
const at = new Date(iso);
const diffMs = at.getTime() - now;
if (!Number.isFinite(diffMs) || diffMs <= 0) return null;
const time = formatTimeOfDay(at);
const locale = i18n.resolvedLanguage;
if (diffMs < DAY_MS) return time;
if (diffMs < 7 * DAY_MS) {
const weekday = at.toLocaleDateString(locale, { weekday: "short" });
return t("providerUsage.timing.resetsAtWeekday", { weekday, time });
}
const date = at.toLocaleDateString(locale, { month: "short", day: "numeric" });
return t("providerUsage.timing.resetsAtDate", { date, time });
if (rel.unit === "now" && retained) return t("providerUsage.timing.resetTimePassed");
if (rel.unit === "now") return t("providerUsage.timing.resettingNow");
const duration = formatRelativeDuration(rel, t);
return duration ? t("providerUsage.timing.resetsIn", { duration }) : null;
}
export function formatRunsOutLabel(
@@ -147,8 +113,9 @@ export function formatRunsOutLabel(
if (!iso) return null;
const rel = relativeDuration(iso, now);
if (!rel) return null;
if (rel.kind === "now") return t("providerUsage.timing.runsOutNow");
return t("providerUsage.timing.runsOutIn", { duration: formatRelativeDuration(rel, t) });
if (rel.unit === "now") return t("providerUsage.timing.runsOutNow");
const duration = formatRelativeDuration(rel, t);
return duration ? t("providerUsage.timing.runsOutIn", { duration }) : null;
}
export function formatAgo(
@@ -1,5 +1,5 @@
import { useTranslation } from "react-i18next";
import { useUsageDisplay } from "./display";
import { useUsagePreferences } from "./display";
import { useHostUsage } from "./queries";
import { UsageSection } from "./usage-section";
@@ -7,7 +7,7 @@ import { UsageSection } from "./usage-section";
export function HostUsageSection({ serverId }: { serverId: string }) {
const { t } = useTranslation();
const { view, refresh } = useHostUsage(serverId);
const { display } = useUsageDisplay(view.kind === "ready" ? view.reports : []);
const { display } = useUsagePreferences(serverId);
return (
<UsageSection
serverId={serverId}
+1 -1
View File
@@ -4,6 +4,6 @@ export { useHostReportsUsage } from "./queries";
export {
UsageSidebarItem,
UsageSidebarRoot,
useUsageSummary,
useHasUsageSummary,
useOpenSidebarUsage,
} from "./sidebar-item";
+2 -2
View File
@@ -46,8 +46,8 @@ export function UsageMeter({
const styles = StyleSheet.create((theme) => ({
track: {
height: 6,
borderRadius: 3,
height: 4,
borderRadius: 2,
backgroundColor: theme.colors.surface3,
overflow: "hidden",
},
-12
View File
@@ -405,15 +405,3 @@ it("available and retained usage show the same successful completion time", () =
);
expect(upsertReport([failure], success)).toEqual([failure]);
});
it("disabled host shows off even with cached reports", () => {
expect(
resolveUsageView({
hostLabel: "Laptop",
isConnected: true,
supportsUsage: true,
enabled: false,
query: { data: [], error: null, isFetching: false },
}),
).toMatchObject({ kind: "off" });
});
-3
View File
@@ -106,7 +106,6 @@ export interface UsageQueryState {
export function resolveUsageView(
input: {
enabled?: boolean;
hostLabel: string;
isConnected: boolean;
supportsUsage: boolean;
@@ -126,8 +125,6 @@ export function resolveUsageView(
message: t("providerUsage.hostUpgradeRequired", { host: hostLabel }),
};
}
if (input.enabled === false)
return { kind: "off", message: t("providerUsage.off", { host: hostLabel }) };
if (query?.data) {
return { kind: "ready", reports: query.data, isRefreshing: query.isFetching };
}
+10 -27
View File
@@ -8,20 +8,15 @@ import { ToolbarButton, paneContentToolbarIconSize } from "@/components/ui/pane-
import { useIsCompactFormFactor } from "@/constants/layout";
import { SettingsRow, SettingsSwitch } from "@/components/settings";
import { SegmentedControl, type SegmentedControlOption } from "@/components/ui/segmented-control";
import { Alert } from "@/components/ui/alert";
import { useHosts } from "@/runtime/host-runtime";
import { settingsStyles } from "@/styles/settings";
import { useUsageSetting } from "./setting";
import { useUsageDisplay } from "./display";
import { useUsagePreferences } from "./display";
import { useUsageInSidebar } from "./in-sidebar";
import type { UsageDisplayAs } from "./preferences";
const ThemedSettings = withUnistyles(Settings);
/** The shared usage preferences, opened from a cog as a popover or compact sheet. */
export function UsageOptions({ serverId }: { serverId: string | null }) {
const setting = useUsageSetting(serverId);
const hostLabel = useHosts().find((host) => host.serverId === serverId)?.label ?? serverId;
export function UsageOptions() {
const { t } = useTranslation();
const displayAsOptions: SegmentedControlOption<UsageDisplayAs>[] = [
{ value: "used", label: t("providerUsage.displayUsed"), testID: "usage-display-used" },
@@ -32,7 +27,7 @@ export function UsageOptions({ serverId }: { serverId: string | null }) {
},
];
const compact = useIsCompactFormFactor();
const { display } = useUsageDisplay([]);
const { display } = useUsagePreferences();
const { inSidebar, setInSidebar } = useUsageInSidebar();
return (
<DropdownMenu compactMode="sheet">
@@ -54,25 +49,13 @@ export function UsageOptions({ serverId }: { serverId: string | null }) {
testID="usage-options-surface"
>
<View testID="usage-options-fields">
{setting.error ? <Alert variant="error" description={setting.error.message} /> : null}
{setting.supported ? (
<SettingsSwitch
label={t("providerUsage.checkUsage")}
hint={t("providerUsage.checkUsageHint", { host: hostLabel })}
disabled={!setting.ready}
value={setting.enabled}
onValueChange={setting.setEnabled}
/>
) : null}
<View style={setting.supported ? settingsStyles.rowBorder : undefined}>
<SettingsSwitch
label={t("providerUsage.showInSidebar")}
hint={t("providerUsage.showInSidebarHint")}
value={inSidebar}
onValueChange={setInSidebar}
testID="usage-show-in-sidebar"
/>
</View>
<SettingsSwitch
label={t("providerUsage.showInSidebar")}
hint={t("providerUsage.showInSidebarHint")}
value={inSidebar}
onValueChange={setInSidebar}
testID="usage-show-in-sidebar"
/>
<View style={settingsStyles.rowBorder}>
<SettingsRow label={t("providerUsage.displayAs")}>
<SegmentedControl
+3 -9
View File
@@ -17,7 +17,6 @@ import {
useHosts,
} from "@/runtime/host-runtime";
import { useSessionStore, type SessionState } from "@/stores/session-store";
import { useUsageSetting } from "./setting";
import { usageCopy } from "./copy";
import {
replaceReport,
@@ -133,14 +132,13 @@ function toQueryState(query: {
/** Usage reports for one host, as shown on its settings page. */
export function useHostUsage(serverId: string): { view: UsageView; refresh: () => void } {
const setting = useUsageSetting(serverId);
const queryClient = useQueryClient();
const isConnected = useHostRuntimeIsConnected(serverId);
const isSupported = useSessionStore((state) => supportsUsage(state.sessions[serverId]));
const query = useFetchQuery({
queryKey: usageReportsQueryKey(serverId),
queryFn: () => listReports(queryClient, serverId),
enabled: isConnected && isSupported && setting.enabled,
enabled: isConnected && isSupported,
dataShape: "list",
staleTimeMs: REPORTS_STALE_TIME_MS,
});
@@ -152,7 +150,6 @@ export function useHostUsage(serverId: string): { view: UsageView; refresh: () =
const view = resolveUsageView(
{
hostLabel,
enabled: setting.ready ? setting.enabled : undefined,
isConnected,
supportsUsage: isSupported,
query: toQueryState(query),
@@ -169,16 +166,14 @@ const NO_REPORTS: UsageReportEntry[] = [];
* load, or without a host.
*/
export function useUsageHostReports(serverId: string | null): UsageReportEntry[] {
const setting = useUsageSetting(serverId);
const queryClient = useQueryClient();
const query = useFetchQuery({
enabled: setting.enabled,
queryKey: usageReportsQueryKey(serverId ?? ""),
queryFn: serverId ? () => listReports(queryClient, serverId) : skipToken,
dataShape: "list",
staleTimeMs: REPORTS_STALE_TIME_MS,
});
return setting.enabled ? (query.data ?? NO_REPORTS) : NO_REPORTS;
return query.data ?? NO_REPORTS;
}
/**
@@ -214,8 +209,7 @@ export function useAgentUsage(serverId: string, agentId: string): AgentUsageView
export function useHostReportsUsage(serverId: string): boolean {
const isConnected = useHostRuntimeIsConnected(serverId);
const isSupported = useSessionStore((state) => supportsUsage(state.sessions[serverId]));
const setting = useUsageSetting(serverId);
return isConnected && isSupported && setting.enabled;
return isConnected && isSupported;
}
/** Every host with whether it is connected and reports usage, in host order. */
-48
View File
@@ -1,48 +0,0 @@
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
/**
* The reset data of a window or balance: the countdown first, then the exact time. When the line
* is too narrow the exact time gives way (truncates) and the countdown stays whole.
*/
export function UsageResetLine({
countdown,
exactTime,
atRisk = false,
testID,
}: {
countdown: string | null;
exactTime: string | null;
/** The countdown is a runs-out warning rather than a reset. */
atRisk?: boolean;
testID?: string;
}) {
if (!countdown) return null;
return (
<View style={styles.line} testID={testID}>
<Text style={atRisk ? styles.atRisk : styles.text}>{countdown}</Text>
{exactTime ? (
<Text style={[styles.text, styles.exactTime]} numberOfLines={1} ellipsizeMode="tail">
{`\u00a0·\u00a0${exactTime}`}
</Text>
) : null}
</View>
);
}
const styles = StyleSheet.create((theme) => ({
line: { flexDirection: "row", alignItems: "center" },
text: {
flexShrink: 0,
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
},
atRisk: {
flexShrink: 0,
color: theme.colors.statusDanger,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
},
exactTime: { flexShrink: 1 },
}));
-26
View File
@@ -1,26 +0,0 @@
import { useMutation } from "@tanstack/react-query";
import { useCallback } from "react";
import { useDaemonConfig } from "@/hooks/use-daemon-config";
import { useSessionStore } from "@/stores/session-store";
/** Host policy comes from the live daemon config replica. */
export function useUsageSetting(serverId: string | null) {
const supported = useSessionStore((state) =>
Boolean(serverId && state.sessions[serverId]?.serverInfo?.features?.usageSetting),
);
const { config, patchConfig } = useDaemonConfig(supported ? serverId : null);
const mutation = useMutation({
mutationFn: (enabled: boolean) => patchConfig({ usage: { enabled } }),
});
const setEnabled = mutation.mutate;
const turnOn = useCallback(() => setEnabled(true), [setEnabled]);
return {
supported,
error: mutation.error,
// COMPAT(usageSetting): added in v0.14, remove after 2027-05-01.
enabled: !supported || (config !== null && config.usage?.enabled !== false),
ready: !supported || config !== null,
setEnabled,
turnOn,
};
}
+21 -9
View File
@@ -17,8 +17,7 @@ import {
} from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { builtinSidebarNavLabelKey } from "@/sidebar-nav/model";
import { useUsageDisplay } from "./display";
import { useUsageInSidebar } from "./in-sidebar";
import { useUsagePreferences } from "./display";
import { useUsageHostId } from "./hosts";
import { useUsageHostReports } from "./queries";
import { UsageSourceIcon } from "./source-icon";
@@ -34,24 +33,28 @@ import {
import { UsageModal } from "./usage-modal";
/** Each summary window with data on the usage host, under its source; empty while none has. */
export function useUsageSummary(): readonly PinnedUsageSource[] {
function useUsageSummary(): readonly PinnedUsageSource[] {
const { t } = useTranslation();
const { inSidebar } = useUsageInSidebar();
const serverId = useUsageHostId();
const reports = useUsageHostReports(inSidebar ? serverId : null);
const { preferences } = useUsageDisplay(reports);
const { preferences } = useUsagePreferences();
const reports = useUsageHostReports(useUsageHostId());
return useMemo(() => resolvePinnedUsage(reports, preferences, t), [preferences, reports, t]);
}
/** Whether the sidebar Usage item has anything to show. */
export function useHasUsageSummary(): boolean {
return useUsageSummary().length > 0;
}
/**
* The sidebar footer's usage entry: each summary window's source icon and percent, and nothing
* while no summary window has data, since the footer's Usage icon already opens Usage.
* Pressing it opens the Usage modal.
*/
export function UsageSidebarItem({ sources }: { sources: readonly PinnedUsageSource[] }) {
export function UsageSidebarItem() {
const { t } = useTranslation();
const label = t(builtinSidebarNavLabelKey("usage"));
const openUsage = useOpenSidebarUsage();
const sources = useUsageSummary();
if (sources.length === 0) return null;
return <PinnedUsageTrigger label={label} sources={sources} onPress={openUsage} />;
}
@@ -189,7 +192,12 @@ function PinnedUsageLine({
<Fragment key={window.key}>
{layout === "meters" ? (
<View style={[styles.meterSlot, leadStyle]}>
<UsageMeter percent={window.percent} tone={window.tone} quiet />
<UsageMeter
percent={window.percent}
tone={window.tone}
quiet
style={styles.meter}
/>
</View>
) : null}
<Text
@@ -261,6 +269,10 @@ const styles = StyleSheet.create((theme) => ({
minWidth: MIN_METER_WIDTH,
marginRight: METER_GAP,
},
meter: {
height: 6,
borderRadius: 3,
},
percent: {
flexShrink: 0,
color: theme.colors.foreground,
-1
View File
@@ -10,7 +10,6 @@ export type UsageBalanceUnit = UsageBalance["unit"];
/** What a host's usage surface shows. `unavailable` is a host state, not a failed request. */
export type UsageView =
| { kind: "off"; message: string }
| { kind: "unavailable"; message: string }
| { kind: "loading" }
| { kind: "error"; message: string }
+2 -2
View File
@@ -4,7 +4,7 @@ import { View } from "react-native";
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
import { builtinSidebarNavLabelKey } from "@/sidebar-nav/model";
import { useHostUsageWithControls } from "./controls";
import { useUsageDisplay } from "./display";
import { useUsagePreferences } from "./display";
import { useUsageHostSelection } from "./hosts";
import type { UsageHost } from "./model";
import { UsageBody, UsageMessage } from "./usage-section";
@@ -52,12 +52,12 @@ function HostUsageModal({
hosts: UsageHost[];
onSelectHost: (serverId: string) => void;
}) {
const { display } = useUsagePreferences();
const hostSelection = useMemo(
() => ({ hosts, serverId, onSelect: onSelectHost }),
[hosts, onSelectHost, serverId],
);
const { view, refresh, controls } = useHostUsageWithControls(hostSelection);
const { display } = useUsageDisplay(view.kind === "ready" ? view.reports : []);
return (
<UsageModalFrame {...props} actions={controls}>
<View testID={`usage-host-${serverId}`}>
+2 -12
View File
@@ -7,7 +7,6 @@ import { Button } from "@/components/ui/button";
import { settingsStyles } from "@/styles/settings";
import { SettingsSection } from "@/components/settings/headings/settings-section";
import type { UsageDisplay } from "./display";
import { useUsageSetting } from "./setting";
import { UsageControls } from "./controls";
import { UsageList } from "./list";
import type { UsageView } from "./types";
@@ -28,8 +27,8 @@ export function UsageSection({
testID?: string;
}) {
const trailing = useMemo(
() => <UsageControls serverId={serverId} view={view} onRefresh={onRefresh} />,
[onRefresh, view, serverId],
() => <UsageControls view={view} onRefresh={onRefresh} />,
[onRefresh, view],
);
return (
@@ -52,15 +51,6 @@ export function UsageBody({
onRefresh: () => void;
}) {
const { t } = useTranslation();
const setting = useUsageSetting(serverId);
if (view.kind === "off")
return (
<View>
<UsageMessage text={view.message} />
{setting.error ? <Alert variant="error" description={setting.error.message} /> : null}
<Button onPress={setting.turnOn}>{t("providerUsage.turnOn")}</Button>
</View>
);
if (view.kind === "unavailable") {
return <UsageMessage text={view.message} />;
}
+86 -38
View File
@@ -1,24 +1,29 @@
import { Pin } from "lucide-react-native";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Pressable, Text, View } from "react-native";
import { Pressable, Text, View, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import {
formatDisplayPct,
formatProviderUsageLabel,
formatResetLabel,
formatResetTime,
formatRunsOutLabel,
} from "./format";
import { UsageMeter } from "./meter";
import { displayPercent, usageWindowRowLabel } from "./model";
import { UsageResetLine } from "./reset-line";
import type { UsageDisplayAs } from "./preferences";
import { windowTone } from "./tone";
import type { UsageTone, UsageWindow } from "./types";
import { useRelativeTimeTick } from "./use-relative-time-tick";
// Pinned rows carry the pinned surface; hovering an unpinned row previews it at half strength,
// so a hover never reads as the selection. Pinned rows do not react to hover.
function highlightStyle(pinned: boolean, hovered: boolean) {
if (pinned) return styles.highlightPinned;
return hovered ? styles.highlightHovered : styles.highlightNone;
}
export function UsageWindowBar({
window,
displayAs,
@@ -49,12 +54,9 @@ export function UsageWindowBar({
window.runsOutAt != null &&
window.shortfallPct != null &&
(!retained || Date.parse(window.runsOutAt) > now);
const countdown = isAtRisk
const trailing = isAtRisk
? formatRunsOutLabel(window.runsOutAt, now, t)
: formatResetLabel(window.resetsAt, retained, now, t);
// The exact time belongs to the reset; beside a runs-out warning it would read as the moment
// the quota runs out.
const exactTime = isAtRisk ? null : formatResetTime(window.resetsAt, now, t);
const value =
shownPct != null ? formatDisplayPct(shownPct, displayAs, i18n.resolvedLanguage, t) : "—";
@@ -62,57 +64,60 @@ export function UsageWindowBar({
const content = {
label: formatProviderUsageLabel(window.id, window.label, t),
value,
countdown,
exactTime,
trailing,
isAtRisk,
percent: shownPct ?? 0,
tone,
pinned,
};
// Same padding as the pinnable row, so bars line up in both modes.
if (!pinnable) {
return (
<View style={styles.row}>
<WindowRowContent {...content} pinnable={false} />
<WindowRowContent {...content} highlight={styles.highlightNone} pinnable={false} />
</View>
);
}
// The whole row pins the window to the sidebar Usage item; the pin glyph is the only sign of it.
// The whole row pins the window to the sidebar Usage item. Pinned or not, it keeps the same
// padding so toggling only changes the background.
return (
<Pressable
onPress={onTogglePin}
accessibilityRole="checkbox"
accessibilityLabel={usageWindowRowLabel({
pinLabel,
value,
trailing: [countdown, exactTime].filter(Boolean).join(" · "),
})}
accessibilityLabel={usageWindowRowLabel({ pinLabel, value, trailing })}
accessibilityState={accessibilityState}
aria-checked={pinned}
style={styles.row}
testID={pinTestID}
>
<WindowRowContent {...content} pinnable />
{({ hovered }: { hovered?: boolean }) => (
<WindowRowContent
{...content}
highlight={highlightStyle(pinned, Boolean(hovered))}
pinnable
/>
)}
</Pressable>
);
}
function WindowRowContent({
highlight,
label,
value,
countdown,
exactTime,
trailing,
isAtRisk,
percent,
tone,
pinnable,
pinned,
}: {
highlight: StyleProp<ViewStyle>;
label: string;
value: string;
countdown: string | null;
exactTime: string | null;
trailing: string | null | undefined;
isAtRisk: boolean;
percent: number;
tone: UsageTone;
@@ -121,17 +126,24 @@ function WindowRowContent({
}) {
return (
<>
<View style={styles.labelRow}>
<Text style={styles.label} numberOfLines={1}>
{label}
</Text>
<Text style={styles.value} numberOfLines={1}>
{value}
</Text>
<View style={highlight} pointerEvents="none" />
<View style={styles.contentRow}>
<View style={styles.windowContent}>
<View style={styles.labelRow}>
<Text style={styles.label} numberOfLines={1}>
{label}
</Text>
<Text style={styles.value} numberOfLines={1} ellipsizeMode="tail">
{value}
{trailing ? (
<Text style={isAtRisk ? styles.atRisk : styles.reset}>{` · ${trailing}`}</Text>
) : null}
</Text>
</View>
<UsageMeter percent={percent} tone={tone} />
</View>
{pinnable ? <UsagePinGlyph pinned={pinned} /> : null}
</View>
<UsageMeter percent={percent} tone={tone} />
<UsageResetLine countdown={countdown} exactTime={exactTime} atRisk={isAtRisk} />
</>
);
}
@@ -141,9 +153,9 @@ const ThemedPin = withUnistyles(Pin);
function UsagePinGlyph({ pinned }: { pinned: boolean }) {
const { t } = useTranslation();
const iconMapping = useMemo(
() => (theme: { colors: { foreground: string; foregroundMuted: string } }) => ({
color: pinned ? theme.colors.foreground : theme.colors.foregroundMuted,
fill: pinned ? theme.colors.foreground : "none",
() => (theme: { colors: { foregroundMuted: string } }) => ({
color: theme.colors.foregroundMuted,
fill: pinned ? theme.colors.foregroundMuted : "none",
}),
[pinned],
);
@@ -167,23 +179,59 @@ function UsagePinGlyph({ pinned }: { pinned: boolean }) {
}
const styles = StyleSheet.create((theme) => ({
row: { gap: 3, paddingVertical: theme.spacing[1.5] },
contentRow: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2] },
windowContent: { flex: 1, gap: 3 },
pin: { width: 12, alignItems: "center" },
tooltipText: { color: theme.colors.popoverForeground, fontSize: theme.fontSize.sm },
row: {
gap: 3,
// The highlight bleeds into the card padding so the label and bar stay on the card's rail.
marginHorizontal: -theme.spacing[2],
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[1.5],
// Its own stacking context, so the highlight layer paints above the card and below the text.
zIndex: 0,
},
highlightNone: {
display: "none",
},
highlightPinned: {
...StyleSheet.absoluteFill,
zIndex: -1,
borderRadius: theme.borderRadius.md,
backgroundColor: theme.colors.surface2,
},
// The pinned surface at half strength: a separate layer, so the text keeps full opacity.
highlightHovered: {
...StyleSheet.absoluteFill,
zIndex: -1,
borderRadius: theme.borderRadius.md,
backgroundColor: theme.colors.surface2,
opacity: theme.opacity[50],
},
labelRow: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
gap: theme.spacing[2],
},
label: {
flex: 1,
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
value: {
flexShrink: 1,
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
},
value: {
flexShrink: 0,
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
reset: {
color: theme.colors.foregroundMuted,
fontWeight: theme.fontWeight.normal,
},
atRisk: {
color: theme.colors.statusDanger,
fontWeight: theme.fontWeight.normal,
},
}));
+1 -6
View File
@@ -125,11 +125,6 @@ function getTimeFormatter(): Intl.DateTimeFormat {
return cachedTimeFormatter;
}
/** A clock time in the device's 12h/24h preference: "3:00 PM" or "15:00". */
export function formatTimeOfDay(date: Date): string {
return getTimeFormatter().format(date);
}
/**
* Format a chat-message timestamp for hover-revealed UI.
* - Same day: "10:11 PM" or "22:11" depending on user preference
@@ -137,7 +132,7 @@ export function formatTimeOfDay(date: Date): string {
* - Older, including today's weekday last week: "14 May 2026, 10:11 PM"
*/
export function formatMessageTimestamp(date: Date, now: Date = new Date()): string {
const time = formatTimeOfDay(date);
const time = getTimeFormatter().format(date);
const daysAgo = localCalendarDaysBetween(date, now);
if (daysAgo === 0) {
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.14.0-beta.2",
"version": "0.14.0-beta.1",
"description": "Paseo CLI - control your AI coding agents from the command line",
"bin": {
"paseo": "bin/paseo"
@@ -28,9 +28,9 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.14.0-beta.2",
"@getpaseo/protocol": "0.14.0-beta.2",
"@getpaseo/server": "0.14.0-beta.2",
"@getpaseo/client": "0.14.0-beta.1",
"@getpaseo/protocol": "0.14.0-beta.1",
"@getpaseo/server": "0.14.0-beta.1",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/client",
"version": "0.14.0-beta.2",
"version": "0.14.0-beta.1",
"description": "Paseo client SDK package",
"files": [
"dist",
@@ -47,8 +47,8 @@
"test": "vitest run"
},
"dependencies": {
"@getpaseo/protocol": "0.14.0-beta.2",
"@getpaseo/relay": "0.14.0-beta.2",
"@getpaseo/protocol": "0.14.0-beta.1",
"@getpaseo/relay": "0.14.0-beta.1",
"ws": "^8.20.0",
"zod": "^4.4.3"
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.14.0-beta.2",
"version": "0.14.0-beta.1",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"homepage": "https://paseo.sh",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
"version": "0.14.0-beta.2",
"version": "0.14.0-beta.1",
"description": "Native module for two way audio streaming",
"keywords": [
"ExpoTwoWayAudio",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.14.0-beta.2",
"version": "0.14.0-beta.1",
"files": [
"dist",
"!dist/**/*.map",
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/plugin",
"version": "0.14.0-beta.2",
"version": "0.14.0-beta.1",
"description": "Paseo plugin SDK package",
"files": [
"dist",
@@ -77,8 +77,8 @@
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.14.0-beta.2",
"@getpaseo/protocol": "0.14.0-beta.2",
"@getpaseo/client": "0.14.0-beta.1",
"@getpaseo/protocol": "0.14.0-beta.1",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.2.3",
@@ -88,8 +88,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.14.0-beta.2",
"@getpaseo/protocol": "0.14.0-beta.2",
"@getpaseo/client": "0.14.0-beta.1",
"@getpaseo/protocol": "0.14.0-beta.1",
"react": ">=19.1.0 <20",
"react-native": ">=0.81.5",
"zod": "^4.4.3"
-18
View File
@@ -255,24 +255,6 @@ describe("lifecycle hook SDK types", () => {
typeErrors(`
import type { PluginServerContext, PluginHooks, PluginHookPayload, PluginHookResult } from "@getpaseo/plugin/server";
export default function contribute(server: PluginServerContext) {
server.addHook("worktree.create.before", async ({ request }) => {
if (request.kind === "checkout-branch") {
// @ts-expect-error Branch checkout has no branch-off base.
request.baseRef;
} else {
const base: string = request.baseRef;
}
const branch: string = request.branch;
// @ts-expect-error Repository identity is read-only.
request.repoRoot = "/other";
// @ts-expect-error Source checkout identity is read-only.
request.sourceCheckout = "/other";
if (request.kind === "checkout-change-request") {
const number: number = request.changeRequestNumber;
}
return { ...request, branch: "feature", baseRef: "main", slug: "feature", path: "/work/feature" };
});
server.addHook("worktree.create.before", async () => {});
server.addHook("worktree.create.after", async ({ worktree }) => {
const path: string = worktree.path;
const branch: string = worktree.branch;
-1
View File
@@ -21,7 +21,6 @@ export type {
PluginHookContext,
PluginHookWorkspace,
PluginHookWorktree,
PluginWorktreeCreateRequest,
PluginHookAgent,
PluginSessionOpenRequest,
PluginTurnOutcome,
-36
View File
@@ -27,40 +27,6 @@ export interface PluginHookWorktree {
sourceCheckout: string;
}
/** Resolved creation options before Git fetches refs or chooses a unique branch name. */
export type PluginWorktreeCreateRequest = {
readonly repoRoot: string;
readonly sourceCheckout: string;
branch: string;
slug: string;
path: string;
} & (
| { kind: "branch-off"; baseRef: string }
| { kind: "checkout-branch" }
| {
kind: "checkout-change-request";
baseRef: string;
forge: string;
changeRequestNumber: number;
headRef: string;
headRepositoryOwner?: string;
headRepository?: string;
checkoutRefs?: { remoteName?: string; remoteRef: string }[];
pushRemoteUrl?: string;
trackOriginHead?: boolean;
}
| {
kind: "checkout-github-pr";
baseRef: string;
githubPrNumber: number;
headRef: string;
headRepositoryOwner?: string;
checkoutRefs?: { remoteName?: string; remoteRef: string }[];
pushRemoteUrl?: string;
trackOriginHead?: boolean;
}
);
export interface PluginHookAgent {
id: string;
workspaceId: string | null;
@@ -115,7 +81,6 @@ export interface PluginHookEvents {
}
export interface PluginHookRequests {
"worktree.create.before": PluginWorktreeCreateRequest;
"agent.create.before": { config: AgentSessionConfig; env?: Record<string, string> };
"agent.open.before": PluginSessionOpenRequest;
"workspace.create.before": Omit<WorkspaceCreateRequest, "type" | "requestId">;
@@ -175,7 +140,6 @@ export const pluginHookRegistry = {
"workspace.create.before": { waits: true },
"workspace.create.after": { waits: false },
"workspace.archive.after": { waits: false },
"worktree.create.before": { waits: true },
"worktree.create.after": { waits: true },
"agent.create.before": { waits: true },
"agent.create.after": { waits: false },
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/protocol",
"version": "0.14.0-beta.2",
"version": "0.14.0-beta.1",
"description": "Paseo shared protocol schemas and wire types",
"files": [
"dist",
-12
View File
@@ -229,8 +229,6 @@ export const MutableDaemonConfigSchema = z
.optional(),
app: z.object({ baseUrl: z.string() }).optional(),
catalogRefreshTimeoutMs: z.number().int().positive().optional(),
// COMPAT(usageSetting): added in v0.14, remove after 2027-05-01.
usage: z.object({ enabled: z.boolean() }).optional(),
browserTools: MutableBrowserToolsConfigSchema.default({ enabled: false }),
providers: z.record(z.string(), MutableDaemonProviderConfigSchema).default({}),
metadataGeneration: MutableMetadataGenerationConfigSchema.default({ providers: [] }),
@@ -239,9 +237,6 @@ export const MutableDaemonConfigSchema = z
// COMPAT(idleRuntimeCleanupSetting): added in v0.13.0, keep optional while old daemons are supported.
// Persisted host-wide value, applied live by the daemon.
idleRuntimeCleanup: z.boolean().optional(),
// COMPAT(restartRecovery): added in v0.14.0, keep optional for older hosts.
resumeOnStartup: z.boolean().optional(),
resumePrompt: z.string().optional(),
appendSystemPrompt: z.string().default(""),
terminalProfiles: z.array(TerminalProfileSchema).optional(),
agentProfiles: z.array(AgentProfileSchema).optional(),
@@ -258,7 +253,6 @@ export const MutableDaemonConfigPatchSchema = z
.object({
relay: MutableRelayConfigSchema.partial().optional(),
mcp: z.object({ injectIntoAgents: z.boolean().optional() }).passthrough().optional(),
usage: z.object({ enabled: z.boolean() }).optional(),
browserTools: MutableBrowserToolsConfigSchema.partial().optional(),
providers: z
.record(z.string(), MutableDaemonProviderConfigSchema.partial().passthrough())
@@ -268,8 +262,6 @@ export const MutableDaemonConfigPatchSchema = z
autoArchiveAfterMerge: z.boolean().optional(),
enableTerminalAgentHooks: z.boolean().optional(),
idleRuntimeCleanup: z.boolean().optional(),
resumeOnStartup: z.boolean().optional(),
resumePrompt: z.string().optional(),
appendSystemPrompt: z.string().optional(),
terminalProfiles: z.array(TerminalProfileSchema).optional(),
agentProfiles: z.array(AgentProfileSchema).optional(),
@@ -3676,8 +3668,6 @@ export const ServerInfoStatusPayloadSchema = z
providersSnapshot: z.boolean().optional(),
connectedHosts: z.boolean().optional(),
hostMultiplexing: z.boolean().optional(),
// COMPAT(usageSetting): added in v0.14, remove after 2027-05-01.
usageSetting: z.boolean().optional(),
usageSources: z.boolean().optional(),
// COMPAT(providersSnapshotCwd): added in v0.3.2, remove gate after 2027-02-10.
providersSnapshotCwd: z.boolean().optional(),
@@ -3741,8 +3731,6 @@ export const ServerInfoStatusPayloadSchema = z
skillManagement: z.boolean().optional(),
// COMPAT(idleRuntimeCleanupSetting): added in v0.13.0, remove gate after 2027-04-26.
idleRuntimeCleanupSetting: z.boolean().optional(),
// COMPAT(restartRecovery): added in v0.14.0, remove gate after 2027-04-28.
resumeOnStartupSetting: z.boolean().optional(),
// COMPAT(terminalRestoreModes): added in v0.1.81, remove gate after 2026-11-23.
"terminal-restore-modes": z.boolean().optional(),
// COMPAT(terminalInputModeReplay): added in v0.2.6, remove gate after 2027-02-02.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.14.0-beta.2",
"version": "0.14.0-beta.1",
"description": "Paseo relay for bridging daemon and client connections",
"files": [
"dist",
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.14.0-beta.2",
"version": "0.14.0-beta.1",
"description": "Paseo backend server",
"files": [
"dist/server",
@@ -107,11 +107,11 @@
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.14.0-beta.2",
"@getpaseo/highlight": "0.14.0-beta.2",
"@getpaseo/plugin": "0.14.0-beta.2",
"@getpaseo/protocol": "0.14.0-beta.2",
"@getpaseo/relay": "0.14.0-beta.2",
"@getpaseo/client": "0.14.0-beta.1",
"@getpaseo/highlight": "0.14.0-beta.1",
"@getpaseo/plugin": "0.14.0-beta.1",
"@getpaseo/protocol": "0.14.0-beta.1",
"@getpaseo/relay": "0.14.0-beta.1",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -135,7 +135,6 @@ export async function ensureAgentLoaded(
labels: record.labels,
workspaceId: record.workspaceId,
owner: record.owner,
env: record.launchEnv,
});
deps.logger.info({ agentId, provider: record.provider }, "Agent created from stored config");
}
@@ -2929,92 +2929,6 @@ test("createAgent passes daemon launch env through the provider launch context",
});
});
test.each([
{ provider: "codex", hasHandle: true },
{ provider: "opencode", hasHandle: true },
{ provider: "codex", hasHandle: false },
{ provider: "opencode", hasHandle: false },
])(
"$provider preserves explicit launch env across reloads and storage-backed resumes (handle: $hasHandle)",
async ({ provider, hasHandle }) => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-launch-env-"));
const storagePath = join(workdir, "agents");
class LaunchEnvClient extends TestAgentClient {
lastLaunchContext: AgentLaunchContext | undefined;
override async createSession(
config: AgentSessionConfig,
context?: AgentLaunchContext,
): Promise<AgentSession> {
this.lastLaunchContext = context;
const session = await super.createSession(config);
session.describePersistence = () => ({ provider, sessionId: session.id });
return session;
}
override async resumeSession(
handle: AgentPersistenceHandle,
config?: Partial<AgentSessionConfig>,
context?: AgentLaunchContext,
): Promise<AgentSession> {
this.lastLaunchContext = context;
const session = await super.resumeSession(handle, config, context);
session.describePersistence = () => ({ provider, sessionId: handle.sessionId });
return session;
}
}
const client = new LaunchEnvClient(provider);
let storage = new AgentStorage(storagePath, logger);
let manager = new AgentManager({ clients: { [provider]: client }, registry: storage, logger });
const env = { XDG_DATA_HOME: join(workdir, "agent-data"), CUSTOM_EMPTY: "" };
const expectedEnv = { ...env };
const agent = await manager.createAgent({ provider, cwd: workdir }, undefined, {
workspaceId: undefined,
env,
});
onTestFinished(async () => {
await manager.closeAgent(agent.id);
await manager.flush();
rmSync(workdir, { recursive: true, force: true });
});
env.XDG_DATA_HOME = "caller-mutated";
await manager.closeAgent(agent.id);
await manager.flush();
if (!hasHandle) {
const record = await storage.get(agent.id);
if (!record) throw new Error("Created agent was not persisted");
await storage.upsert({ ...record, persistence: null });
}
// Fresh storage and manager instances must read the environment from disk.
for (let restart = 0; restart < 2; restart += 1) {
storage = new AgentStorage(storagePath, logger);
manager = new AgentManager({ clients: { [provider]: client }, registry: storage, logger });
const resumed = await ensureAgentLoaded(agent.id, {
agentManager: manager,
agentStorage: storage,
logger,
});
expect(client.lastLaunchContext).toEqual({
agentId: agent.id,
env: { ...expectedEnv, PASEO_AGENT_ID: agent.id, PASEO_AGENT_CWD: workdir },
});
await manager.reloadAgentSession(agent.id);
expect(client.lastLaunchContext).toEqual({
agentId: agent.id,
env: { ...expectedEnv, PASEO_AGENT_ID: agent.id, PASEO_AGENT_CWD: workdir },
});
expect(toAgentPayload(resumed)).not.toHaveProperty("launchEnv");
expect(toAgentPayload(resumed).persistence?.metadata).not.toHaveProperty("launchEnv");
await manager.closeAgent(agent.id);
await manager.flush();
}
},
);
test("createAgent passes persistSession to provider create options", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const storagePath = join(workdir, "agents");
@@ -421,8 +421,6 @@ interface ManagedAgentBase {
owner?: AgentOwner;
capabilities: AgentCapabilityFlags;
config: AgentSessionConfig;
/** Explicit launch overrides, before per-opening hooks and daemon-owned variables. */
launchEnv?: Record<string, string>;
runtimeInfo?: AgentRuntimeInfo;
createdAt: Date;
updatedAt: Date;
@@ -1401,7 +1399,6 @@ export class AgentManager {
initialTitle: options.initialTitle,
workspaceId: options.workspaceId,
owner: options.owner,
launchEnv: options.env ? { ...options.env } : undefined,
historyPrimed: true,
});
if (!agent.internal) {
@@ -1490,11 +1487,10 @@ export class AgentManager {
: resumeOptions;
const purpose = currentResumeOptions?.purpose ?? "interactive";
const launchEnv = record?.launchEnv;
const { storedConfig, launchConfig, paseoToolPolicy } = await this.prepareSessionConfig(
mergedConfig,
resolvedAgentId,
{ purpose, env: launchEnv },
{ purpose },
);
const client = this.requireClient(handle.provider);
const available = await client.isAvailable();
@@ -1509,7 +1505,7 @@ export class AgentManager {
client,
storedConfig.cwd,
paseoToolPolicy,
launchEnv,
undefined,
{
reason: "resume",
purpose,
@@ -1528,7 +1524,6 @@ export class AgentManager {
await this.requireExternalMcpSupport(session, providerLaunchConfig, launchContext);
return this.registerSession(session, storedConfig, resolvedAgentId, {
...options,
launchEnv,
persistence: handle,
restoring: true,
});
@@ -1666,7 +1661,6 @@ export class AgentManager {
const { storedConfig, launchConfig, paseoToolPolicy } = await this.prepareSessionConfig(
refreshConfig,
agentId,
{ env: existing.launchEnv },
);
const hadPreviousPaseoToolPolicy = this.paseoToolPolicies.has(agentId);
const previousPaseoToolPolicy = this.paseoToolPolicies.get(agentId);
@@ -1675,7 +1669,7 @@ export class AgentManager {
client,
storedConfig.cwd,
paseoToolPolicy,
existing.launchEnv,
undefined,
{
reason: "refresh",
purpose: "interactive",
@@ -1726,7 +1720,6 @@ export class AgentManager {
labels: existing.labels,
workspaceId: existing.workspaceId,
owner: existing.owner,
launchEnv: existing.launchEnv,
createdAt: existing.createdAt,
updatedAt: existing.updatedAt,
lastUserMessageAt: existing.lastUserMessageAt,
@@ -3770,7 +3763,6 @@ export class AgentManager {
publishWhenReady?: boolean;
workspaceId?: string;
owner?: AgentOwner;
launchEnv?: Record<string, string>;
},
): Promise<ManagedAgent> {
let registered = false;
@@ -3956,7 +3948,6 @@ export class AgentManager {
persistence?: AgentPersistenceHandle;
workspaceId?: string;
owner?: AgentOwner;
launchEnv?: Record<string, string>;
}
| undefined;
}): ActiveManagedAgent {
@@ -3967,7 +3958,6 @@ export class AgentManager {
cwd: config.cwd,
workspaceId: options?.workspaceId,
owner: options?.owner,
launchEnv: options?.launchEnv,
session,
capabilities: session.capabilities,
config,
@@ -95,7 +95,6 @@ export function toStoredAgentRecord(
lastStatus: agent.lifecycle,
lastModeId: agent.currentModeId ?? config?.modeId ?? null,
config: config ?? null,
launchEnv: agent.launchEnv ? { ...agent.launchEnv } : undefined,
runtimeInfo,
features: normalizeFeatures(agent.features),
persistence,
@@ -54,10 +54,8 @@ const STORED_AGENT_SCHEMA = z.object({
title: z.string().nullable().optional(),
labels: z.record(z.string(), z.string()).default({}),
lastStatus: AgentStatusSchema.default("closed"),
resumeOnStartup: z.boolean().optional(),
lastModeId: z.string().nullable().optional(),
config: SERIALIZABLE_CONFIG_SCHEMA,
launchEnv: z.record(z.string(), z.string()).optional(),
runtimeInfo: z
.object({
provider: z.string(),
@@ -261,8 +259,6 @@ export class AgentStorage {
if (existing && existing.archivedAt !== undefined) {
record.archivedAt = existing.archivedAt;
}
record.resumeOnStartup =
existing?.resumeOnStartup && agent.lifecycle !== "idle" && agent.lifecycle !== "error";
return record;
});
}
@@ -13,8 +13,6 @@ import type { FinishObservation } from "../index.js";
export function createFinishObservation(input: {
agentManager: AgentManager;
agentStorage: AgentStorage;
/** A child chosen for restart recovery reports its state only after its resume was sent. */
waitForResume(agentId: string, signal: AbortSignal): Promise<void>;
messages(): AgentMessageDelivery;
hosts(): ToolHosts;
serverId: string;
@@ -26,7 +24,6 @@ export function createFinishObservation(input: {
hosts: input.hosts,
local: {
async wait(agentId, signal) {
await input.waitForResume(agentId, signal);
await input.messages().waitForDispatch(agentId, signal);
signal.throwIfAborted();
const live = agentManager.getAgent(agentId);
@@ -55,7 +52,6 @@ export function createFinishObservation(input: {
};
},
async observe(agentId, handler, signal, onError) {
await input.waitForResume(agentId, signal);
const stored = await agentStorage.get(agentId);
let updates = Promise.resolve();
const publish = (snapshot: AgentSnapshotPayload | null, event?: AgentStreamEvent) => {
@@ -881,7 +881,6 @@ async function createAgentMcpServer(
logger: options.logger,
...createFinishObservation({
...options,
waitForResume: async () => {},
messages: () => messages,
serverId: options.serverId ?? "self",
hosts: () => {
@@ -4,8 +4,6 @@ import { join } from "node:path";
import { AgentStorage } from "./agent-storage.js";
import { createIdleRuntimeCleanup } from "./idle-runtime-cleanup/index.js";
import { relative } from "node:path";
import { execFileSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import type {
ProviderConnection,
ProviderLaunch,
@@ -27,21 +25,6 @@ import {
StaleProviderSessionError,
} from "./stale-provider-session-error.js";
test("long concurrent plugin and child streams retain text-sized history and release it on archive", () => {
const fixture = fileURLToPath(new URL("./test-utils/plugin-stream-memory.ts", import.meta.url));
const output = execFileSync(
process.execPath,
["--expose-gc", "--max-old-space-size=512", "--import", "tsx", fixture],
{
encoding: "utf8",
timeout: 20_000,
},
);
const memory = JSON.parse(output) as { retained: number; closed: number };
expect(memory.retained).toBeLessThan(24 * 1024 * 1024);
expect(memory.closed).toBeLessThan(6 * 1024 * 1024);
});
const CAPABILITIES = [
"prompt.message",
"session.configure",
@@ -309,99 +292,6 @@ function expectNestedChildren(events: AgentStreamEvent[]) {
}
describe("PluginAgentClientRegistry", () => {
test("history keeps the tool's first position when completion follows later text", async () => {
const harness = createProviderHarness();
const registry = new PluginAgentClientRegistry(createTestLogger());
registry.replace([harness.registration]);
const client = registry.clients()[harness.registration.id]!;
const session = await client.createSession({ provider: client.provider, cwd: "/tmp" });
const opened = harness.inputs.find((input) => input.type === "session.open");
if (opened?.type !== "session.open") throw new Error("missing session open");
const tool = {
type: "tool_call" as const,
id: "tool-position",
callId: "call-position",
name: "read",
detail: { type: "unknown" as const, input: null, output: null },
error: null,
};
try {
harness.emit({
type: "timeline.item",
sessionId: opened.sessionId,
item: { ...tool, status: "running" },
});
harness.emit({
type: "timeline.item",
sessionId: opened.sessionId,
item: { type: "assistant_message", id: "later-text", text: "Working" },
});
harness.emit({
type: "timeline.item",
sessionId: opened.sessionId,
item: { ...tool, status: "completed" },
});
const items = [];
for await (const event of session.streamHistory())
if (event.type === "timeline") items.push(event.item);
expect(items).toEqual([
{
type: "tool_call",
callId: "call-position",
name: "read",
detail: tool.detail,
error: null,
status: "completed",
},
{ type: "assistant_message", messageId: "later-text", text: "Working" },
]);
} finally {
await session.close();
await registry.shutdown();
}
});
test("detached text deltas preserve split surrogates and history replays one complete message", async () => {
const harness = createProviderHarness();
const registry = new PluginAgentClientRegistry(createTestLogger());
registry.replace([harness.registration]);
const client = registry.clients()[harness.registration.id]!;
const session = await client.createSession({ provider: client.provider, cwd: "/tmp" });
const opened = harness.inputs.find((input) => input.type === "session.open");
if (opened?.type !== "session.open") throw new Error("missing session open");
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
const prefix = "long prefix ".repeat(10);
const first = prefix + "a\uD83D";
const complete = first + "\uDE00b";
try {
for (const text of [first, complete, complete]) {
harness.emit({
type: "timeline.item",
sessionId: opened.sessionId,
item: { type: "assistant_message", id: "split-message", text },
});
}
const deltas = events.flatMap((event) =>
event.type === "timeline" && event.item.type === "assistant_message"
? [event.item.text]
: [],
);
expect(deltas).toEqual([first, "\uDE00b"]);
expect(deltas.join("")).toBe(prefix + "a😀b");
const history: AgentStreamEvent[] = [];
for await (const event of session.streamHistory()) history.push(event);
expect(
history.flatMap((event) =>
event.type === "timeline" && event.item.type === "assistant_message" ? [event.item] : [],
),
).toEqual([{ type: "assistant_message", messageId: "split-message", text: complete }]);
} finally {
await session.close();
await registry.shutdown();
}
});
test("stores only agent options while the plugin receives merged defaults", async () => {
const logger = createTestLogger();
const harness = createProviderHarness();
@@ -1,5 +1,4 @@
import { randomUUID } from "node:crypto";
import { PluginSessionHistory } from "./plugin-session-history.js";
import { resolve as resolvePath } from "node:path";
import type { Logger } from "pino";
import type { JsonValue } from "@getpaseo/protocol/agent-types";
@@ -593,7 +592,6 @@ class ProviderRuntime {
class ProviderRuntimeSession {
readonly history: ProviderEvent[] = [];
private historyClaimed = false;
private readonly listeners = new Set<(event: ProviderEvent) => void>();
private capabilities: readonly string[] = [];
private openRequest: { requestId: string; deferred: Deferred<void> } | null = null;
@@ -624,10 +622,6 @@ class ProviderRuntimeSession {
}
onEvent(listener: (event: ProviderEvent) => void): () => void {
// This buffer bridges session.open and adapter attachment. Once replayed,
// the adapter owns history; retaining live cumulative snapshots is quadratic.
this.historyClaimed = true;
this.history.length = 0;
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
@@ -719,7 +713,6 @@ class ProviderRuntimeSession {
}
async close(): Promise<void> {
this.history.length = 0;
if (this.restoration === "parent") {
this.runtime.removeSession(this.id, this.providerSessionId);
return;
@@ -833,7 +826,7 @@ class ProviderRuntimeSession {
if (event.type === "session.persistence" && this.restoration === "core") {
this.persistence = event.persistence;
}
if (!this.historyClaimed) this.history.push(event);
this.history.push(event);
for (const listener of this.listeners) listener(event);
}
}
@@ -1135,30 +1128,17 @@ class PluginAgentClient implements AgentClient {
}
}
interface TimelineTextSnapshot {
type: "assistant_message" | "reasoning" | "user_message";
text: string;
}
function isTextSnapshotItem(
item: ProviderTimelineItem,
): item is Extract<ProviderTimelineItem, { type: TimelineTextSnapshot["type"] }> {
return (
item.type === "assistant_message" || item.type === "reasoning" || item.type === "user_message"
);
}
class PluginAgentSession implements AgentSession {
readonly initialTimeline: ImportedProviderSession["timeline"];
private readonly listeners = new Set<(event: AgentStreamEvent) => void>();
private readonly history = new PluginSessionHistory();
private readonly history: AgentStreamEvent[] = [];
private readonly pendingPermissions = new Map<string, AgentPermissionRequest>();
private readonly permissionResponses = new Map<string, AgentPermissionResponse>();
private readonly revertTokens = new Map<string, ProviderTimelineItem["revertToken"]>();
private readonly timelineSnapshots = new Map<string, TimelineTextSnapshot>();
private readonly timelineSnapshots = new Map<string, ProviderTimelineItem>();
private readonly subagentIdsBySession = new Map<string, string | null>();
private readonly childUnsubscribes = new Map<string, () => void>();
private readonly childSnapshots = new Map<string, Map<string, TimelineTextSnapshot>>();
private readonly childSnapshots = new Map<string, Map<string, ProviderTimelineItem>>();
private unsubscribe: (() => void) | null = null;
private currentTurnId: string | null = null;
private closed = false;
@@ -1256,7 +1236,7 @@ class PluginAgentSession implements AgentSession {
}
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
for (const event of this.history.read(this.provider)) yield event;
for (const event of this.history) yield event;
}
get canStopRuntime(): (() => Promise<boolean>) | undefined {
@@ -1324,12 +1304,6 @@ class PluginAgentSession implements AgentSession {
for (const unsubscribe of this.childUnsubscribes.values()) unsubscribe();
this.childUnsubscribes.clear();
this.subagentIdsBySession.clear();
this.history.clear();
this.timelineSnapshots.clear();
this.childSnapshots.clear();
this.revertTokens.clear();
this.pendingPermissions.clear();
this.permissionResponses.clear();
this.listeners.clear();
this.onClose();
await this.bridge.close();
@@ -1370,20 +1344,16 @@ class PluginAgentSession implements AgentSession {
}
timelineHistory(): ImportedProviderSession["timeline"] {
return this.history
.read(this.provider)
.flatMap((event) =>
event.type === "timeline" ? [{ item: event.item, timestamp: event.timestamp }] : [],
);
return this.history.flatMap((event) =>
event.type === "timeline" ? [{ item: event.item, timestamp: event.timestamp }] : [],
);
}
subagentHistory(): NonNullable<ImportedProviderSession["providerSubagentEvents"]> {
return this.history
.read(this.provider)
.filter(
(event): event is Extract<AgentStreamEvent, { type: "provider_subagent" }> =>
event.type === "provider_subagent",
);
return this.history.filter(
(event): event is Extract<AgentStreamEvent, { type: "provider_subagent" }> =>
event.type === "provider_subagent",
);
}
attachChild(
@@ -1412,7 +1382,7 @@ class PluginAgentSession implements AgentSession {
cwd: opened.cwd,
},
});
const snapshots = new Map<string, TimelineTextSnapshot>();
const snapshots = new Map<string, ProviderTimelineItem>();
this.childSnapshots.set(childId, snapshots);
for (const event of child.history) this.acceptChildEvent(childId, event, snapshots);
this.childUnsubscribes.set(
@@ -1598,7 +1568,7 @@ class PluginAgentSession implements AgentSession {
private acceptChildEvent(
childId: string,
event: ProviderEvent,
snapshots: Map<string, TimelineTextSnapshot>,
snapshots: Map<string, ProviderTimelineItem>,
): void {
if (event.type === "timeline.item") {
const item = mapTimelineItem(event.item, snapshots);
@@ -1751,24 +1721,24 @@ function mapPromptContent(prompt: AgentPromptInput): ProviderContent[] {
function mapTimelineItem(
item: ProviderTimelineItem,
snapshots: Map<string, TimelineTextSnapshot>,
snapshots: Map<string, ProviderTimelineItem>,
revertTokens?: Map<string, ProviderTimelineItem["revertToken"]>,
): AgentTimelineItem | null {
const previous = snapshots.get(item.id);
if (isTextSnapshotItem(item)) {
snapshots.set(item.id, { type: item.type, text: item.text });
snapshots.set(item.id, item);
const { id, revertToken, ...withoutIdentity } = item;
if (revertToken !== undefined && revertTokens) {
revertTokens.set(id, revertToken);
if (item.type === "user_message" || item.type === "assistant_message") {
revertTokens.set(item.messageId ?? id, revertToken);
}
}
const { id, revertToken: _revertToken, ...withoutIdentity } = item;
rememberRevertToken(item, revertTokens);
if (item.type === "assistant_message" || item.type === "reasoning") {
const previousText = previous?.type === item.type ? previous.text : "";
const suffix = item.text.startsWith(previousText)
const text = item.text.startsWith(previousText)
? item.text.slice(previousText.length)
: item.text;
// Match OpenCode's detachedSuffix: a sliced delta otherwise keeps its entire
// cumulative IPC snapshot alive in the projected timeline. Preserve split surrogates.
const text = Buffer.from(suffix, "utf16le").toString("utf16le");
if (text.length === 0) return null;
return item.type === "assistant_message"
? { type: "assistant_message", text, messageId: item.messageId ?? id }
@@ -1796,17 +1766,6 @@ function mapTimelineItem(
return withoutIdentity as AgentTimelineItem;
}
function rememberRevertToken(
item: ProviderTimelineItem,
tokens?: Map<string, ProviderTimelineItem["revertToken"]>,
): void {
if (item.revertToken === undefined || !tokens) return;
tokens.set(item.id, item.revertToken);
if (item.type === "user_message" || item.type === "assistant_message") {
tokens.set(item.messageId ?? item.id, item.revertToken);
}
}
function persistenceHandle(
provider: string,
persistence: ProviderPersistence,
@@ -1,66 +0,0 @@
import { TimelineProjection } from "./timeline-projection.js";
import type { AgentStreamEvent } from "./agent-sdk-types.js";
// Replay canonical items, rather than retaining every streamed delta or tool state.
// Sequence numbers here only preserve adapter replay order, not wire cursors.
export class PluginSessionHistory {
private readonly timelines = new Map<
string,
{ projection: TimelineProjection; seq: number; positions: Map<number, number> }
>();
private readonly events: Array<{ seq: number; event: AgentStreamEvent }> = [];
private seq = 0;
push(event: AgentStreamEvent): void {
const seq = ++this.seq;
const child =
event.type === "provider_subagent" && event.event.type === "timeline" ? event.event : null;
if (event.type !== "timeline" && !child) {
this.events.push({ seq, event });
return;
}
const key = child?.id ?? "";
let projection = this.timelines.get(key);
if (!projection) {
projection = { projection: new TimelineProjection(), seq: 0, positions: new Map() };
this.timelines.set(key, projection);
}
const timeline = event.type === "timeline" ? event : child!;
const row = projection.projection.append({
seq: ++projection.seq,
item: timeline.item,
timestamp: timeline.timestamp ?? "",
...(event.type === "timeline" && event.turnId ? { turnId: event.turnId } : {}),
});
if (!projection.positions.has(row.seqStart)) projection.positions.set(row.seqStart, seq);
}
read(provider: string): AgentStreamEvent[] {
const events = [...this.events];
for (const [id, projection] of this.timelines) {
for (const row of projection.projection.getRows()) {
const timestamp = row.timestamp ? { timestamp: row.timestamp } : {};
const event: AgentStreamEvent = id
? {
type: "provider_subagent",
provider,
event: { type: "timeline", id, item: row.item, ...timestamp },
}
: {
type: "timeline",
provider,
item: row.item,
...timestamp,
...(row.turnId ? { turnId: row.turnId } : {}),
};
events.push({ seq: projection.positions.get(row.seqStart)!, event });
}
}
return events.sort((a, b) => a.seq - b.seq).map(({ event }) => event);
}
clear(): void {
this.timelines.clear();
this.events.length = 0;
}
}
@@ -422,237 +422,6 @@ describe("ClaudeAgentSession persisted subagent replay", () => {
});
});
describe("a background subagent", () => {
// Shapes recorded from Claude Code 2.1.x: the launch returns at once with an async result,
// and a child stopped by an interrupt ends on the interrupt placeholder, not an end_turn.
function backgroundLaunch(): string[] {
return [
parentEntry([
{
type: "tool_use",
id: TOOL_USE_ID,
name: "Agent",
input: {
subagent_type: "general-purpose",
description: "probe sleeper",
run_in_background: true,
},
},
]),
JSON.stringify({
type: "user",
sessionId: "replay-session",
timestamp: "2026-07-26T06:27:48.000Z",
message: {
role: "user",
content: [
{
type: "tool_result",
tool_use_id: TOOL_USE_ID,
content: [
{
type: "text",
text: `Async agent launched successfully.\nagentId: ${AGENT_ID}`,
},
],
},
],
},
toolUseResult: { isAsync: true, status: "async_launched", agentId: AGENT_ID },
}),
];
}
function readStatus(event: { status?: string }): string | undefined {
return event.status;
}
function interruptedChild(): string[] {
return [
JSON.stringify({
type: "assistant",
isSidechain: true,
agentId: AGENT_ID,
timestamp: "2026-07-26T06:27:50.000Z",
message: {
role: "assistant",
content: [{ type: "tool_use", id: "toolu_child_bash", name: "Bash", input: {} }],
stop_reason: "tool_use",
},
}),
JSON.stringify({
type: "user",
isSidechain: true,
agentId: AGENT_ID,
timestamp: "2026-07-26T06:28:05.000Z",
message: {
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "toolu_child_bash",
content: "The user doesn't want to proceed with this tool use.",
is_error: true,
},
],
},
}),
JSON.stringify({
type: "user",
isSidechain: true,
agentId: AGENT_ID,
timestamp: "2026-07-26T06:28:05.001Z",
message: {
role: "user",
content: [{ type: "text", text: "[Request interrupted by user for tool use]" }],
},
}),
];
}
test.each([
["the meta sidecar", JSON.stringify({ toolUseId: TOOL_USE_ID })],
["the legacy scrape", null],
])("is canceled when an interrupt stopped it, linked through %s", async (_link, meta) => {
writeSession({ parentLines: backgroundLaunch(), meta, sidechainLines: interruptedChild() });
expect(upserts(await replayDescriptors()).at(-1)).toMatchObject({
id: TOOL_USE_ID,
status: "canceled",
});
});
test("stays running when the child was given a new prompt after the interrupt", async () => {
writeSession({
parentLines: backgroundLaunch(),
meta: JSON.stringify({ toolUseId: TOOL_USE_ID }),
sidechainLines: [
...interruptedChild(),
JSON.stringify({
type: "user",
isSidechain: true,
agentId: AGENT_ID,
timestamp: "2026-07-26T06:29:00.000Z",
message: { role: "user", content: [{ type: "text", text: "Carry on with the task." }] },
}),
],
});
const statuses = upserts(await replayDescriptors()).map(readStatus);
expect(statuses).not.toContain("canceled");
expect(statuses).toContain("running");
});
test.each(["interrupted", "running"])(
"ignores an API request copy of a %s child's launch result",
async (state) => {
const launch = backgroundLaunch();
const result = JSON.parse(launch[1]!);
writeSession({
parentLines: [
...launch,
JSON.stringify({
type: "api-request-blob",
sessionId: "replay-session",
hash: "request-copy",
message: result.message,
}),
],
meta: JSON.stringify({ toolUseId: TOOL_USE_ID }),
sidechainLines:
state === "interrupted"
? interruptedChild()
: [sidechainEntry({ stopReason: "tool_use" })],
});
expect(upserts(await replayDescriptors()).at(-1)).toMatchObject({
id: TOOL_USE_ID,
status: state === "interrupted" ? "canceled" : "running",
});
},
);
test("stays running while the child has not finished", async () => {
writeSession({
parentLines: backgroundLaunch(),
meta: JSON.stringify({ toolUseId: TOOL_USE_ID }),
sidechainLines: [sidechainEntry({ stopReason: "tool_use" })],
});
const statuses = upserts(await replayDescriptors()).map(readStatus);
expect(statuses).not.toContain("completed");
expect(statuses).toContain("running");
});
function taskNotification(status: string, form = "user"): string {
const text = `<task-notification>\n<task-id>${AGENT_ID}</task-id>\n<tool-use-id>${TOOL_USE_ID}</tool-use-id>\n<status>${status}</status>\n<summary>Background task ended</summary>\n</task-notification>`;
const records = {
user: { message: { role: "user", content: [{ type: "text", text }] } },
system: {
subtype: "task_notification",
task_id: AGENT_ID,
tool_use_id: TOOL_USE_ID,
status,
},
"queue-operation": { operation: "enqueue", content: text },
};
return JSON.stringify({
type: form,
timestamp: "2026-07-26T06:29:00.000Z",
...records[form as keyof typeof records],
});
}
for (const form of ["user", "system", "queue-operation"]) {
test.each([
["completed", "completed"],
["failed", "failed"],
["killed", "canceled"],
["stopped", "canceled"],
])(
`restores a %s task notification from ${form} when the child stop_reason is null`,
async (status, expected) => {
writeSession({
parentLines: [...backgroundLaunch(), taskNotification(status, form)],
meta: JSON.stringify({ toolUseId: TOOL_USE_ID }),
sidechainLines: [sidechainEntry({ stopReason: null })],
});
expect(upserts(await replayDescriptors()).at(-1)).toMatchObject({
id: TOOL_USE_ID,
status: expected,
});
},
);
}
test("prefers a stopped notification over the child's end_turn", async () => {
writeSession({
parentLines: [...backgroundLaunch(), taskNotification("stopped")],
meta: JSON.stringify({ toolUseId: TOOL_USE_ID }),
sidechainLines: [sidechainEntry({ stopReason: "end_turn" })],
});
expect(upserts(await replayDescriptors()).at(-1)).toMatchObject({ status: "canceled" });
});
test("does not invent an outcome for an unknown notification status", async () => {
writeSession({
parentLines: [...backgroundLaunch(), taskNotification("unknown")],
meta: JSON.stringify({ toolUseId: TOOL_USE_ID }),
sidechainLines: [sidechainEntry({ stopReason: null })],
});
expect(upserts(await replayDescriptors()).at(-1)).toMatchObject({ status: "running" });
});
test("completes from the child's own end_turn", async () => {
writeSession({
parentLines: backgroundLaunch(),
meta: JSON.stringify({ toolUseId: TOOL_USE_ID }),
sidechainLines: [sidechainEntry({ stopReason: "end_turn" })],
});
expect(upserts(await replayDescriptors()).at(-1)).toMatchObject({ status: "completed" });
});
});
test("does not replay a skill-spawned subagent the parent never named", async () => {
// The reported bug. A /code-review subagent has no Task tool_use, so its sidecar carries only
// agentType and its id appears nowhere in the parent — it used to replay as running forever,
@@ -887,68 +887,6 @@ describe("ClaudeAgentSession features", () => {
await session.close();
});
test("keeps an extraArgs settings file when Paseo sets Claude flag settings", async () => {
const settingsDir = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-claude-settings-"));
const settingsPath = path.join(settingsDir, "settings.json");
await fs.writeFile(
settingsPath,
JSON.stringify({ env: { ENABLE_TOOL_SEARCH: "false" }, permissions: { deny: ["Monitor"] } }),
);
const { queryFactory } = createQueryMock();
const client = new ClaudeAgentClient({
logger,
queryFactory,
resolveBinary: async () => "/test/claude/bin",
});
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),
model: "claude-opus-5-5",
providerOptions: {
extraArgs: { chrome: null, settings: settingsPath },
},
});
await session.startTurn("hello");
const options = queryFactory.mock.calls[0]?.[0].options;
expect(options?.extraArgs).toEqual({ chrome: null });
expect(options?.settings).toEqual({
env: { ENABLE_TOOL_SEARCH: "false" },
permissions: { deny: ["Monitor"] },
fastMode: false,
});
await session.close();
await fs.rm(settingsDir, { recursive: true, force: true });
});
test("keeps inline extraArgs settings JSON for internal agents", async () => {
const { queryFactory } = createQueryMock();
const client = new ClaudeAgentClient({
logger,
queryFactory,
resolveBinary: async () => "/test/claude/bin",
});
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),
internal: true,
providerOptions: {
extraArgs: { settings: '{"env":{"ENABLE_TOOL_SEARCH":"false"}}' },
},
});
await session.startTurn("hello");
const options = queryFactory.mock.calls[0]?.[0].options;
expect(options?.extraArgs).toEqual({});
expect(options?.settings).toEqual({
env: { ENABLE_TOOL_SEARCH: "false" },
disableAllHooks: true,
});
await session.close();
});
test("lists fast mode only for supported Opus models", async () => {
const client = new ClaudeAgentClient({ logger, resolveBinary: async () => "/test/claude/bin" });
@@ -33,9 +33,7 @@ import {
mapTaskNotificationSystemRecordToToolCall,
mapTaskNotificationUserContentToToolCall,
readTaskNotificationToolUseIdFromHistoryRecord,
readTaskNotificationFromHistoryRecord,
} from "./task-notification-tool-call.js";
import { isClaudeInterruptPlaceholderText } from "./interrupt-placeholder.js";
import {
getClaudeContextWindowMaxTokens,
getClaudeModelsWithSettings,
@@ -48,7 +46,6 @@ import {
parseClaudeCodeVersion,
resolveClaudeDisabledThinkingForModel,
} from "./model-manifest.js";
import { expandTilde } from "../../../../utils/path.js";
import { parsePartialJsonObject } from "./partial-json.js";
import { withClaudeReplayHistory, type ClaudeReplayHistory } from "./transcript-history.js";
import { ClaudeSidechainTracker } from "./sidechain-tracker.js";
@@ -59,8 +56,6 @@ import {
type ClaudeHookObservationInput,
} from "./subagents/live-source.js";
import { observeReplaySubagents, type ClaudeReplayParentFacts } from "./subagents/replay-source.js";
import { mapTaskStatus } from "./subagents/task-status.js";
import type { ProviderSubagentStatus } from "../../provider-subagents/store.js";
import { foldSubagentObservations, type SubagentObservation } from "./subagents/observation.js";
import { observeReplayWorkflows } from "./subagents/workflow-replay-source.js";
import { limitAgentTimelineItemContent } from "../../agent-timeline-content.js";
@@ -430,6 +425,7 @@ const CLAUDE_ROOT_ONLY_COMMANDS = new Set([
"usage",
]);
const INTERRUPT_TOOL_USE_PLACEHOLDER = "[Request interrupted by user for tool use]";
const INTERRUPT_PLACEHOLDER_PATTERN = /^\[Request interrupted by user(?:[^\]]*)\]$/;
const NO_RESPONSE_REQUESTED_PLACEHOLDER = "No response requested.";
const STEER_SUPERSEDED_PERMISSION_MESSAGE =
"The user answered with a message instead of approving. Their message follows.";
@@ -649,40 +645,6 @@ function readClaudeFastModeSetting(settings: ClaudeOptions["settings"]): boolean
return typeof settings.fastMode === "boolean" ? settings.fastMode : null;
}
type ClaudeSettingsObject = NonNullable<Exclude<ClaudeOptions["settings"], string>>;
// Mirrors `claude --settings`: the value is inline JSON or a path to a JSON file,
// resolved against the session cwd like the Claude process would.
async function readClaudeCliSettings(value: string, cwd: string): Promise<ClaudeSettingsObject> {
const trimmed = value.trim();
const isInlineJson = trimmed.startsWith("{");
const source = isInlineJson ? "--settings JSON" : path.resolve(cwd, expandTilde(trimmed));
let raw = trimmed;
if (!isInlineJson) {
try {
raw = await fsPromises.readFile(source, "utf8");
} catch (error) {
throw new Error(
`Failed to read Claude settings file ${source}: ${error instanceof Error ? error.message : String(error)}`,
{ cause: error },
);
}
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch (error) {
throw new Error(
`Claude ${source} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`,
{ cause: error },
);
}
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error(`Claude ${source} must contain a JSON object`);
}
return parsed as ClaudeSettingsObject;
}
function mergeClaudeSettings(
settings: ClaudeOptions["settings"],
updates: NonNullable<Exclude<ClaudeOptions["settings"], string>>,
@@ -827,6 +789,11 @@ function normalizeClaudeTranscriptText(value: unknown): string | null {
return normalized.length > 0 ? normalized : null;
}
function isClaudeInterruptPlaceholderText(value: unknown): boolean {
const normalized = normalizeClaudeTranscriptText(value);
return normalized !== null && INTERRUPT_PLACEHOLDER_PATTERN.test(normalized);
}
function isClaudeNoResponsePlaceholderText(value: unknown): boolean {
return normalizeClaudeTranscriptText(value) === NO_RESPONSE_REQUESTED_PLACEHOLDER;
}
@@ -3645,7 +3612,7 @@ class ClaudeAgentSession implements AgentSession {
this.config.providerOptions,
this.config.toolPolicy,
);
const settingsOptions = await this.buildSettingsOptions(providerOptions, { ultracode });
const settingsOptions = this.buildSettingsOptions(providerOptions, { ultracode });
const sdkEnv = this.harnessEnvironment;
assertClaudeModeCanRun(permissionMode, sdkEnv);
@@ -3730,10 +3697,10 @@ class ClaudeAgentSession implements AgentSession {
return base;
}
private async buildSettingsOptions(
private buildSettingsOptions(
providerOptions: ClaudeProviderOptions,
input: { ultracode: boolean },
): Promise<Pick<ClaudeOptions, "settings" | "extraArgs"> | Record<string, never>> {
): Pick<ClaudeOptions, "settings"> | Record<string, never> {
const fastMode = this.resolveFastModeSetting();
// Internal agents do daemon work such as naming a branch, so the user's and
// project's hooks must not run for them.
@@ -3741,21 +3708,12 @@ class ClaudeAgentSession implements AgentSession {
if (fastMode === null && !input.ultracode && !disableAllHooks) {
return {};
}
const updates = {
...(fastMode === null ? {} : { fastMode }),
...(input.ultracode ? { ultracode: true } : {}),
...(disableAllHooks ? { disableAllHooks: true } : {}),
};
// The SDK replaces extraArgs.settings with Options.settings, so a user's
// `--settings` would be dropped whenever Paseo sets flag settings. Fold it in.
const { settings: extraArgsSettings, ...extraArgs } = providerOptions.extraArgs ?? {};
if (typeof extraArgsSettings !== "string") {
return { settings: mergeClaudeSettings(providerOptions.settings, updates) };
}
const cliSettings = await readClaudeCliSettings(extraArgsSettings, this.config.cwd);
return {
extraArgs,
settings: { ...cliSettings, ...providerOptions.settings, ...updates },
settings: mergeClaudeSettings(providerOptions.settings, {
...(fastMode === null ? {} : { fastMode }),
...(input.ultracode ? { ultracode: true } : {}),
...(disableAllHooks ? { disableAllHooks: true } : {}),
}),
};
}
@@ -6304,34 +6262,33 @@ async function readClaudeReplayParentFacts(
entries: Iterable<ClaudeHistoryEntry> | AsyncIterable<ClaudeHistoryEntry>,
): Promise<ClaudeReplayParentFacts> {
const toolCalls = new Map<string, { title?: string; description?: string }>();
const linksByAgentId = new Map<string, { toolCallId: string }>();
const outcomes: Map<string, { status: ProviderSubagentStatus }> = new Map();
const linksByAgentId = new Map<string, { toolCallId: string; failed: boolean }>();
const outcomes = new Map<string, { failed: boolean }>();
for await (const entry of entries) {
const notification = readTaskNotificationFromHistoryRecord(entry);
const status = mapTaskStatus(notification?.status ?? undefined);
if (notification?.toolUseId && status) {
outcomes.set(notification.toolUseId, { status });
}
const content = toObjectRecord(entry.message)?.content;
if (!Array.isArray(content)) continue;
for (const value of content) {
const block = toObjectRecord(value);
const declaration = readClaudeReplayTaskDeclaration(entry.type, block);
if (declaration) toolCalls.set(declaration.id, declaration.identity);
if (
entry.type !== "user" ||
block?.type !== "tool_result" ||
typeof block.tool_use_id !== "string"
)
continue;
if (toObjectRecord(entry.toolUseResult)?.status !== "async_launched")
outcomes.set(block.tool_use_id, {
status: block.is_error === true ? "failed" : "completed",
block?.type === "tool_use" &&
(block.name === "Task" || block.name === "Agent") &&
typeof block.id === "string"
) {
const input = toObjectRecord(block.input);
const title = readNonEmptyString(input?.name) ?? readNonEmptyString(input?.subagent_type);
const description = readNonEmptyString(input?.description);
toolCalls.set(block.id, {
...(title ? { title } : {}),
...(description ? { description } : {}),
});
}
if (block?.type !== "tool_result" || typeof block.tool_use_id !== "string") continue;
outcomes.set(block.tool_use_id, { failed: block.is_error === true });
const agentId = readLegacyClaudeSubagentId(block.content);
if (agentId)
linksByAgentId.set(agentId, {
toolCallId: block.tool_use_id,
failed: block.is_error === true,
});
}
}
@@ -6342,29 +6299,6 @@ async function readClaudeReplayParentFacts(
};
}
function readClaudeReplayTaskDeclaration(
entryType: unknown,
block: Record<string, unknown> | undefined,
): {
id: string;
identity: { title?: string; description?: string };
} | null {
if (
entryType !== "assistant" ||
block?.type !== "tool_use" ||
(block.name !== "Task" && block.name !== "Agent") ||
typeof block.id !== "string"
)
return null;
const input = toObjectRecord(block.input);
const title = readNonEmptyString(input?.name) ?? readNonEmptyString(input?.subagent_type);
const description = readNonEmptyString(input?.description);
return {
id: block.id,
identity: { ...(title ? { title } : {}), ...(description ? { description } : {}) },
};
}
function readLegacyClaudeSubagentId(content: unknown): string | undefined {
return /agentId:\s*([\w-]+)/.exec(JSON.stringify(content))?.[1];
}
@@ -1,6 +0,0 @@
const INTERRUPT_PLACEHOLDER_PATTERN = /^\[Request interrupted by user(?:[^\]]*)\]$/;
/** The text Claude Code records in place of a turn that an interrupt stopped. */
export function isClaudeInterruptPlaceholderText(value: unknown): boolean {
return typeof value === "string" && INTERRUPT_PLACEHOLDER_PATTERN.test(value.trim());
}
@@ -1,46 +0,0 @@
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import pino from "pino";
import { expect, test } from "vitest";
import { ClaudeAgentClient } from "./agent.js";
test("the real SDK passes the user's settings file contents alongside Paseo flags", async () => {
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-claude-settings-argv-"));
const capturePath = path.join(cwd, "argv.json");
const binaryPath = path.join(cwd, "capture.mjs");
const settingsPath = path.join(cwd, "settings.json");
const userSettings = {
env: { ENABLE_TOOL_SEARCH: "false" },
permissions: { deny: ["Monitor"] },
};
try {
// A CLI fixture records the real SDK's arguments without making a model request.
await fs.writeFile(
binaryPath,
`import fs from "node:fs"; fs.writeFileSync(${JSON.stringify(capturePath)}, JSON.stringify(process.argv.slice(2)));`,
);
await fs.writeFile(settingsPath, JSON.stringify(userSettings));
const client = new ClaudeAgentClient({
logger: pino({ level: "silent" }),
resolveBinary: async () => binaryPath,
});
const session = await client.createSession({
provider: "claude",
cwd,
model: "claude-opus-5-5",
providerOptions: { extraArgs: { settings: settingsPath } },
});
try {
await session.startTurn("hello");
} finally {
await session.close();
}
const argv: string[] = JSON.parse(await fs.readFile(capturePath, "utf8"));
const settingsIndex = argv.indexOf("--settings");
expect(settingsIndex).toBeGreaterThanOrEqual(0);
expect(JSON.parse(argv[settingsIndex + 1]!)).toEqual({ ...userSettings, fastMode: false });
} finally {
await fs.rm(cwd, { recursive: true, force: true });
}
});
@@ -1,8 +1,7 @@
import type { ProviderSubagentStatus } from "../../../provider-subagents/store.js";
import { mapTaskStatus } from "./task-status.js";
import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";
import type { AgentMetadata } from "../../../agent-sdk-types.js";
import type { ProviderSubagentStatus } from "../../../provider-subagents/store.js";
import { resolveObservedClaudeModelId } from "../models.js";
import type { SubagentObservation } from "./observation.js";
import {
@@ -121,6 +120,28 @@ function readString(value: unknown): string | undefined {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
}
/**
* The descriptor has no paused or pending state, and neither is terminal, so both read as
* running. `killed` is a cancellation, not a failure — the child was stopped, it did not error.
*/
function mapTaskStatus(status: string | undefined): ProviderSubagentStatus | undefined {
switch (status) {
case "pending":
case "running":
case "paused":
return "running";
case "completed":
return "completed";
case "failed":
return "failed";
case "killed":
case "stopped":
return "canceled";
default:
return undefined;
}
}
export interface ClaudeTaskProtocolSourceInput {
/**
* The parent's Task tool input, by tool_use id.
@@ -36,7 +36,7 @@ function parentWithTaskCall(failed = false): ClaudeReplayParentFacts {
],
]),
linksByAgentId: new Map(),
outcomesByToolCallId: new Map([[TOOL_USE_ID, { status: failed ? "failed" : "completed" }]]),
outcomesByToolCallId: new Map([[TOOL_USE_ID, { failed }]]),
};
}
@@ -117,7 +117,7 @@ describe("observeReplaySubagents", () => {
[nestedToolUseId, { title: "Explore", description: "Nested audit" }],
]),
linksByAgentId: new Map(),
outcomesByToolCallId: new Map([[nestedToolUseId, { status: "completed" }]]),
outcomesByToolCallId: new Map([[nestedToolUseId, { failed: false }]]),
},
},
{
@@ -173,8 +173,8 @@ describe("observeReplaySubagents", () => {
it("falls back to the scraped link when there is no meta file", async () => {
const parent: ClaudeReplayParentFacts = {
toolCalls: new Map([[TOOL_USE_ID, { title: "Explore" }]]),
linksByAgentId: new Map([[AGENT_ID, { toolCallId: TOOL_USE_ID }]]),
outcomesByToolCallId: new Map([[TOOL_USE_ID, { status: "completed" }]]),
linksByAgentId: new Map([[AGENT_ID, { toolCallId: TOOL_USE_ID, failed: false }]]),
outcomesByToolCallId: new Map(),
};
const [declared, ...rest] = (
@@ -263,8 +263,8 @@ describe("observeReplaySubagents", () => {
subagents: [{ agentId: AGENT_ID, meta: null, entries: [] }],
parent: {
toolCalls: new Map([[TOOL_USE_ID, { title: "Explore" }]]),
linksByAgentId: new Map([[AGENT_ID, { toolCallId: TOOL_USE_ID }]]),
outcomesByToolCallId: new Map([[TOOL_USE_ID, { status: "failed" }]]),
linksByAgentId: new Map([[AGENT_ID, { toolCallId: TOOL_USE_ID, failed: true }]]),
outcomesByToolCallId: new Map(),
},
convertEntry: () => [],
})
@@ -3,7 +3,6 @@ import { z } from "zod";
import type { AgentTimelineItem } from "../../../agent-sdk-types.js";
import { normalizeProviderReplayTimestamp } from "../../../provider-history-timestamps.js";
import type { ProviderSubagentStatus } from "../../../provider-subagents/store.js";
import { isClaudeInterruptPlaceholderText } from "../interrupt-placeholder.js";
import { resolveObservedClaudeModelId } from "../models.js";
import type { SubagentObservation } from "./observation.js";
import { buildClaudeSubagentSubtitle, type ClaudeSubagentUsage } from "./presentation.js";
@@ -140,12 +139,9 @@ export interface ClaudeReplayParentFacts {
/** Task `tool_use` id -> identity declared in the parent's tool input. */
toolCalls: ReadonlyMap<string, { title?: string; description?: string }>;
/** agentId -> tool call, recovered by scraping tool-result text. Legacy fallback only. */
linksByAgentId: ReadonlyMap<string, { toolCallId: string }>;
/**
* Task `tool_use` id -> outcome, whichever way the child was linked. A background launch has
* none: its immediate result only says the child started.
*/
outcomesByToolCallId: ReadonlyMap<string, { status: ProviderSubagentStatus }>;
linksByAgentId: ReadonlyMap<string, { toolCallId: string; failed: boolean }>;
/** Task `tool_use` id -> outcome. Usable once meta.json supplies the link directly. */
outcomesByToolCallId: ReadonlyMap<string, { failed: boolean }>;
}
interface ParentLink {
@@ -181,14 +177,27 @@ function resolveParentLink(
parent: ClaudeReplayParentFacts,
): ParentLink | null {
const metaToolUseId = subagent.meta?.toolUseId?.trim();
const toolCallId =
metaToolUseId && parent.toolCalls.has(metaToolUseId)
? metaToolUseId
: parent.linksByAgentId.get(subagent.agentId)?.toolCallId;
if (!toolCallId || !parent.toolCalls.has(toolCallId)) return null;
if (metaToolUseId && parent.toolCalls.has(metaToolUseId)) {
const outcome = parent.outcomesByToolCallId.get(metaToolUseId);
let status: ProviderSubagentStatus | null = null;
if (outcome) status = outcome.failed ? "failed" : "completed";
return {
id: metaToolUseId,
toolCallId: metaToolUseId,
status,
};
}
const status = parent.outcomesByToolCallId.get(toolCallId)?.status ?? null;
return { id: toolCallId, toolCallId, status };
const scraped = parent.linksByAgentId.get(subagent.agentId);
if (scraped && parent.toolCalls.has(scraped.toolCallId)) {
return {
id: scraped.toolCallId,
toolCallId: scraped.toolCallId,
status: scraped.failed ? "failed" : "completed",
};
}
return null;
}
/**
@@ -267,7 +276,9 @@ async function observeSubagent(
lastTimestamp = timestamp;
Object.assign(runtime, readRuntime(entry));
usage = readUsage(entry) ?? usage;
childTerminalStatus = readChildTerminalStatus(entry, childTerminalStatus);
if (entry.type === "assistant") {
childTerminalStatus = entry.message?.stop_reason === "end_turn" ? "completed" : null;
}
recordReplayToolOwners(toolOwners, entry, link.id);
for (const item of await convertEntry(entry)) {
timeline.push({ kind: "timeline", id: link.id, item, ...(timestamp ? { timestamp } : {}) });
@@ -297,30 +308,6 @@ async function observeSubagent(
return observations;
}
/** Advance the child's fallback outcome without retaining transcript entries. */
function readChildTerminalStatus(
entry: ClaudeReplayEntry,
previous: ProviderSubagentStatus | null,
): ProviderSubagentStatus | null {
if (entry.type === "assistant") {
return entry.message?.stop_reason === "end_turn" ? "completed" : null;
}
if (entry.type !== "user") return previous;
const texts = readUserTexts(entry);
if (texts.length === 0) return previous;
return texts.some(isClaudeInterruptPlaceholderText) ? "canceled" : null;
}
/** Tool results carry no user text and do not change the child's terminal status. */
function readUserTexts(entry: ClaudeReplayEntry): string[] {
const content = entry.message?.content;
if (typeof content === "string") return [content];
if (!Array.isArray(content)) return [];
return content.flatMap((block: { type?: unknown; text?: unknown } | null) =>
block?.type === "text" && typeof block.text === "string" ? [block.text] : [],
);
}
function recordReplayToolOwners(
owners: Map<string, string>,
entry: ClaudeReplayEntry,
@@ -1,23 +0,0 @@
import type { ProviderSubagentStatus } from "../../../provider-subagents/store.js";
/**
* The descriptor has no paused or pending state, and neither is terminal, so both read as
* running. `killed` is a cancellation, not a failure — the child was stopped, it did not error.
*/
export function mapTaskStatus(status: string | undefined): ProviderSubagentStatus | undefined {
switch (status) {
case "pending":
case "running":
case "paused":
return "running";
case "completed":
return "completed";
case "failed":
return "failed";
case "killed":
case "stopped":
return "canceled";
default:
return undefined;
}
}
@@ -407,21 +407,19 @@ export function mapTaskNotificationSystemRecordToToolCall(
}
export function readTaskNotificationToolUseIdFromHistoryRecord(record: unknown): string | null {
return readTaskNotificationFromHistoryRecord(record)?.toolUseId ?? null;
}
export function readTaskNotificationFromHistoryRecord(
record: unknown,
): TaskNotificationEnvelope | null {
const parsedRecord = TaskNotificationHistoryRecordSchema.safeParse(record);
if (!parsedRecord.success) return null;
if (parsedRecord.data.type === "user" && parsedRecord.data.message) {
return parseTaskNotificationFromUserContent({
content: parsedRecord.data.message.content,
messageId: parsedRecord.data.uuid ?? parsedRecord.data.message_id,
});
if (!parsedRecord.success) {
return null;
}
return parseTaskNotificationFromSystemRecord(record);
if (parsedRecord.data.type === "user" && parsedRecord.data.message) {
return (
parseTaskNotificationFromUserContent({
content: parsedRecord.data.message.content,
messageId: parsedRecord.data.uuid ?? parsedRecord.data.message_id,
})?.toolUseId ?? null
);
}
return parseTaskNotificationFromSystemRecord(record)?.toolUseId ?? null;
}
export function coerceTaskNotificationHistoryRecordToSystemMessage(

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