diff --git a/.oxlintrc.json b/.oxlintrc.json index f5078649c9..7a6192215e 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -85,6 +85,151 @@ "max-nested-callbacks": ["error", { "max": 3 }] }, "overrides": [ + // Exceptions name individual APIs in individual modules; new APIs still fail in these files. + // TODO entries are runtime debt, not startup exemptions. Do not add directory-wide exceptions. + { + "files": ["packages/server/src/**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}"], + "rules": { + "paseo/no-sync-io": [ + "error", + { + "allow": { + // TODO: literal executableExists is a synchronous public capability; move its callers and Windows existence predicates to async together. + "executable-resolution/executable-resolution.ts": ["existsSync"], + // TODO: winget enumeration shares synchronous existence predicates with literal lookup; convert the executable-resolution boundary together. + "executable-resolution/windows.ts": ["readdirSync"], + // TODO: live file-change classification is a synchronous event mapper; move the probe to async event ingestion. + "server/agent/providers/claude/agent.ts": ["existsSync"], + // TODO: new-session construction still resolves its project directory synchronously; move preparation before construction. + "server/agent/providers/claude/project-dir.ts": ["realpathSync"], + // TODO: live subagent output is read by a synchronous event mapper; add bounded async loading at ingestion. + "server/agent/providers/claude/subagents/workflow-output.ts": [ + "closeSync", + "openSync", + "readSync" + ], + // TODO: diagnostics probes provider-owned auth/session storage; convert alongside provider discovery and readiness. + "server/agent/providers/omp/agent.ts": ["existsSync"], + // TODO: synchronous provider configuration discovery; move discovery into async launch preparation. + "server/agent/providers/omp/provider-config.ts": ["existsSync"], + // TODO: synchronous session-root selection is shared by descriptor builders; migrate callers with explicit readiness. + "server/agent/providers/omp/session-descriptor.ts": ["existsSync"], + // TODO: launch directory creation shares the server generation/start state machine; audit concurrent rotation before adding an await. + "server/agent/providers/opencode/server-manager.ts": ["mkdirSync"], + // TODO: launch preparation owns MCP config merge, extension generation and synchronous cleanup; migrate lifecycle and diagnostics together. + "server/agent/providers/pi/agent.ts": [ + "existsSync", + "mkdtempSync", + "readFileSync", + "rmSync", + "writeFileSync" + ], + // Provider child process: Pi loads this extension outside the daemon; it reads child-agent status there. + "server/agent/providers/pi/extensions/pi-subagents/index.ts": ["readFileSync"], + // Startup/shutdown only: remove stale Unix listening sockets before bind and during disposal. + "server/bootstrap.ts": ["existsSync", "unlinkSync"], + // Startup configuration resolution: detect the bundled app distribution before the daemon listens. + "server/config.ts": ["existsSync"], + // Startup identity: load or create the daemon keypair before accepting connections. + "server/daemon-keypair.ts": ["existsSync", "readFileSync"], + // Fatal exit diagnostics: synchronously flush the exit reason when the worker is about to terminate. + "server/daemon-worker.ts": ["appendFileSync", "mkdirSync"], + // Startup host identity: memoized scutil lookup is warmed before listening; later callers use the cache. + "server/host-name.ts": ["execFileSync"], + // TODO: relationship persistence is atomic with enrollment/revocation state transitions; serialize those transitions before async writes. + "server/hub/relationship-controller.ts": [ + "existsSync", + "readFileSync", + "renameSync", + "rmSync" + ], + // Local client bootstrap: reads the local credential and pid lock to select/authenticate a daemon connection. + "server/local-credential.ts": ["readFileSync"], + // Startup logger construction: create the log directory before opening the logging destination. + "server/logger.ts": ["mkdirSync"], + // TODO: runtime skill commands resolve their bundled source through a synchronous targets capability; migrate the capability and controller together. + "server/orchestration-skills/internal/paths.ts": ["existsSync"], + // Module initialization: the package version is resolved once and cached at import. + "server/package-version.ts": ["existsSync", "readFileSync"], + // Startup config and exported synchronous configuration API; runtime DaemonConfigStore uses the async load/save capabilities. + "server/persisted-config.ts": ["existsSync", "readFileSync"], + // Startup pid identity: read and memoize the Linux boot id for the daemon lock. + "server/pid-lock.ts": ["readFileSync"], + // Startup builtin loader construction: select the bundled plugin root before loading plugins. + "server/plugins/builtin/index.ts": ["existsSync"], + // Build child process: plugin graph traversal and esbuild preparation run in the isolated compiler process. + "server/plugins/compiler-build.ts": ["existsSync", "readFileSync", "realpathSync"], + // Build child process: select the esbuild binary for the isolated plugin compiler. + "server/plugins/compiler-esbuild.ts": ["existsSync"], + // Build child process: TypeScript resolution and source reads run in the isolated plugin compiler. + "server/plugins/compiler-imports.ts": ["readFileSync", "realpathSync"], + // TODO: plugin install/update/remove commits sources.json synchronously; preserve record/write ordering with an async mutation queue. + "server/plugins/managed-source.ts": ["existsSync", "readFileSync"], + // TODO: shared synchronous persistence supports startup identity plus Hub/plugin/push runtime writers; migrate those owners before removing these exports. + "server/private-files.ts": [ + "chmodSync", + "mkdirSync", + "realpathSync", + "renameSync", + "rmSync", + "writeFileSync" + ], + // TODO: constructor load plus runtime renew/revoke/prune use synchronous persistence; introduce ordered async mutations and readiness. + "server/push/token-store.ts": ["existsSync", "readFileSync"], + // Startup identity: load or create the server id before accepting connections. + "server/server-id.ts": ["existsSync", "readFileSync"], + // Listener lifecycle only: unlink the Unix socket before binding or after stopping the proxy. + "server/service-proxy.ts": ["existsSync", "unlinkSync"], + // TODO: self-update origin validation exposes synchronous package-root and realpath checks; migrate validation and its runtime interface together. + "server/session/daemon/install-origin.ts": ["existsSync", "readFileSync"], + // TODO: npm self-update inspection probes the reported install path; move the probe into bounded async installation inspection. + "server/session/daemon/npm-global-cli.ts": ["lstatSync"], + // Speech child process: model-file validation runs in the worker-backed local speech provider. + "server/speech/providers/local/sherpa/sherpa-offline-recognizer.ts": ["existsSync"], + // Speech child process: native addon discovery and patchelf run outside the daemon. + "server/speech/providers/local/sherpa/sherpa-onnx-node-loader.ts": [ + "existsSync", + "spawnSync" + ], + // Speech child process: model-file validation runs in the worker-backed local speech provider. + "server/speech/providers/local/sherpa/sherpa-tts.ts": ["existsSync"], + // TODO: static request selection and index injection need bounded async serving with response/error ownership; keep this as a separate request-path conversion. + "server/web-ui.ts": ["readFileSync", "statSync"], + // Startup registry hydration: check persisted workspace directories before the runtime reconciliation service starts. + "server/workspace-registry-bootstrap.ts": ["statSync"], + // Terminal child process: hook config installation/removal is owned by terminal creation and teardown in the worker. + "terminal/agent-hooks/agent-hook-installer.ts": [ + "existsSync", + "readFileSync", + "rmSync" + ], + // Node loader thread: TypeScript terminal-worker resolution runs in the loader, outside the daemon event loop. + "terminal/terminal-ts-loader.mjs": ["existsSync"], + // Terminal child process: executable preparation and shell integration run in the worker-backed terminal manager. + "terminal/terminal.ts": [ + "accessSync", + "chmodSync", + "existsSync", + "mkdirSync", + "readFileSync", + "statSync" + ], + // TODO: legacy realpath helpers still serve install-origin validation and other synchronous consumers; migrate remaining callers before removing them. + "utils/path.ts": ["realpathSync"] + } + } + ] + } + }, + { + "files": [ + "packages/server/src/**/*.{test,spec,fixture}.{ts,tsx,mts,cts,js,jsx,mjs,cjs}", + "packages/server/src/**/test-utils/**", + "packages/server/src/server/daemon-e2e/**", + "packages/server/src/server/agent/providers/pi/extensions/*-test.ts" + ], + "rules": { "paseo/no-sync-io": "off" } + }, { "files": [ "plugins/*-provider/server/**/*.ts", diff --git a/CHANGELOG.md b/CHANGELOG.md index 98d1c76900..18454f14e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,65 @@ # Changelog +## 0.14.0-beta.1 - 2026-10-27 + +Plugins can now add tools to individual agents and configure their MCP servers when sessions open. + +Completed conversation turns can be collapsed into summaries and expanded to read the full exchange. + +Voice settings now let you configure speech providers and download local models with one tap, including SenseVoice and Whisper for Chinese dictation. Voice mode is now opt-in. + +### Added + +- Let plugins give tools to specific agents ([#7938](https://github.com/getpaseo/paseo/pull/7938)) +- 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)) +- Added sent-message recall with Up and Down in an empty web or desktop Composer ([#7710](https://github.com/getpaseo/paseo/pull/7710)) +- Added long-press Send to queue a message or send it immediately ([#7897](https://github.com/getpaseo/paseo/pull/7897) by [@bjspi](https://github.com/bjspi)) +- Added project-specific editor choices, including Xcode on macOS ([#7898](https://github.com/getpaseo/paseo/pull/7898) by [@kinoroy](https://github.com/kinoroy)) + +### Improved + +- 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)) +- 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)) +- Stopped hidden chats from rendering incoming text updates ([#7901](https://github.com/getpaseo/paseo/pull/7901)) +- Reduced stalls when highlighting and wrapping large diffs ([#8006](https://github.com/getpaseo/paseo/pull/8006), [#7945](https://github.com/getpaseo/paseo/pull/7945)) +- Reduced daemon stalls when opening and paging large Claude and OMP histories ([#7789](https://github.com/getpaseo/paseo/pull/7789), [#7940](https://github.com/getpaseo/paseo/pull/7940), [#7948](https://github.com/getpaseo/paseo/pull/7948), [#7958](https://github.com/getpaseo/paseo/pull/7958) by [@ragokan](https://github.com/ragokan)) +- Reduced daemon stalls during plugin builds, image processing and config reloads ([#7967](https://github.com/getpaseo/paseo/pull/7967), [#7954](https://github.com/getpaseo/paseo/pull/7954), [#7959](https://github.com/getpaseo/paseo/pull/7959)) +- Reduced blocking filesystem work during workspace and project refreshes ([#7941](https://github.com/getpaseo/paseo/pull/7941), [#7943](https://github.com/getpaseo/paseo/pull/7943), [#7944](https://github.com/getpaseo/paseo/pull/7944)) +- Added a workaround for Claude Code sessions consuming subscription limits faster in Paseo ([#7976](https://github.com/getpaseo/paseo/pull/7976) by [@david828019-oss](https://github.com/david828019-oss)) + +### Fixed + +- Fixed terminal glyph corruption when shared WebGL texture atlases change ([#8013](https://github.com/getpaseo/paseo/pull/8013) by [@donghyeonan](https://github.com/donghyeonan)) +- Fixed restored workspaces with no open chat staying on Loading workspace at launch on phones ([#7918](https://github.com/getpaseo/paseo/pull/7918)) +- Fixed restored agent tabs appearing in unrelated workspaces ([#7956](https://github.com/getpaseo/paseo/pull/7956) by [@odoo-mvds](https://github.com/odoo-mvds)) +- Fixed background Git fetches opening credential prompts ([#8019](https://github.com/getpaseo/paseo/pull/8019)) +- Fixed failed SSH tunnels crashing callers with an unhandled EPIPE ([#7876](https://github.com/getpaseo/paseo/pull/7876)) +- Fixed brief Windows file locks preventing atomic saves ([#7955](https://github.com/getpaseo/paseo/pull/7955)) +- Fixed large ACP terminal output blocking daemon requests ([#7953](https://github.com/getpaseo/paseo/pull/7953) by [@bbtfr](https://github.com/bbtfr)) +- Fixed messages sent during Pi manual compaction failing instead of waiting ([#7848](https://github.com/getpaseo/paseo/pull/7848) by [@itssimon](https://github.com/itssimon)) +- Fixed late tool updates creating duplicate rows during overlapping turns ([#8010](https://github.com/getpaseo/paseo/pull/8010) by [@luoph](https://github.com/luoph)) +- Fixed plugin ACP reasoning appearing inside assistant replies when message IDs match ([#7272](https://github.com/getpaseo/paseo/pull/7272) by [@cncolder](https://github.com/cncolder)) +- 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)) +- 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 selectors staying open over Settings ([#7783](https://github.com/getpaseo/paseo/pull/7783) by [@ragokan](https://github.com/ragokan)) + +### Notes + +- Voice mode now defaults to off; enable it in Voice settings +- If environment variables select OpenAI speech, move keys and endpoints stored only in legacy `providers.openai` into Voice settings +- Before downgrading to 0.13.2, restore a backed-up `config.json` or convert speech credentials and idle-cleanup settings saved by the beta + ## 0.13.2 - 2026-10-26 ### Fixed diff --git a/CHANGELOG.zh-CN.md b/CHANGELOG.zh-CN.md index 421da176dd..51f974221f 100644 --- a/CHANGELOG.zh-CN.md +++ b/CHANGELOG.zh-CN.md @@ -1,5 +1,65 @@ # 更新日志 +## 0.14.0-beta.1 - 2026-10-27 + +插件现在可以为单个 Agent 添加工具,并在会话打开时配置其 MCP 服务器。 + +已完成的对话轮次可以折叠为摘要,也可以展开查看完整对话。 + +语音设置现在支持配置语音服务商并一键下载本地模型,包括可用于中文听写的 SenseVoice 和 Whisper。语音模式现在需要主动开启。 + +### 新增 + +- 插件现在可以为特定 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) 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)) +- 新增在网页和桌面端的空 Composer 中按上下键回顾已发送消息 ([#7710](https://github.com/getpaseo/paseo/pull/7710)) +- 新增长按发送按钮排队发送或立即发送消息 ([#7897](https://github.com/getpaseo/paseo/pull/7897) by [@bjspi](https://github.com/bjspi)) +- 新增按项目记忆编辑器的功能,macOS 支持 Xcode ([#7898](https://github.com/getpaseo/paseo/pull/7898) by [@kinoroy](https://github.com/kinoroy)) + +### 改进 + +- 符合条件的 Agent 运行时空闲一小时后释放,下次发送提示词时恢复已保存的会话 ([#7782](https://github.com/getpaseo/paseo/pull/7782)) +- 在子 Agent 窗格中新增返回父 Agent 的链接 ([#7784](https://github.com/getpaseo/paseo/pull/7784)) +- 问题卡片新增可点击链接和可选中的描述文本 ([#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)) +- 隐藏的聊天不再渲染收到的文本更新 ([#7901](https://github.com/getpaseo/paseo/pull/7901)) +- 减少大型 diff 高亮和自动换行时的卡顿 ([#8006](https://github.com/getpaseo/paseo/pull/8006), [#7945](https://github.com/getpaseo/paseo/pull/7945)) +- 减少打开和分页加载大型 Claude 和 OMP 历史时的 daemon 卡顿 ([#7789](https://github.com/getpaseo/paseo/pull/7789), [#7940](https://github.com/getpaseo/paseo/pull/7940), [#7948](https://github.com/getpaseo/paseo/pull/7948), [#7958](https://github.com/getpaseo/paseo/pull/7958) by [@ragokan](https://github.com/ragokan)) +- 减少插件构建、图片处理和重新加载配置时的 daemon 卡顿 ([#7967](https://github.com/getpaseo/paseo/pull/7967), [#7954](https://github.com/getpaseo/paseo/pull/7954), [#7959](https://github.com/getpaseo/paseo/pull/7959)) +- 减少刷新工作区和项目时由文件系统操作造成的阻塞 ([#7941](https://github.com/getpaseo/paseo/pull/7941), [#7943](https://github.com/getpaseo/paseo/pull/7943), [#7944](https://github.com/getpaseo/paseo/pull/7944)) +- 为 Claude Code 会话在 Paseo 中更快消耗订阅额度的问题添加了变通方案 ([#7976](https://github.com/getpaseo/paseo/pull/7976) by [@david828019-oss](https://github.com/david828019-oss)) + +### 修复 + +- 修复共享 WebGL 纹理图集变化时终端字符显示错误的问题 ([#8013](https://github.com/getpaseo/paseo/pull/8013) by [@donghyeonan](https://github.com/donghyeonan)) +- 修复手机启动时没有打开聊天的已恢复工作区停留在加载状态的问题 ([#7918](https://github.com/getpaseo/paseo/pull/7918)) +- 修复已恢复的 Agent 标签出现在无关工作区中的问题 ([#7956](https://github.com/getpaseo/paseo/pull/7956) by [@odoo-mvds](https://github.com/odoo-mvds)) +- 修复后台 Git 拉取弹出凭据提示的问题 ([#8019](https://github.com/getpaseo/paseo/pull/8019)) +- 修复 SSH 隧道失败时未处理的 EPIPE 导致调用方崩溃的问题 ([#7876](https://github.com/getpaseo/paseo/pull/7876)) +- 修复 Windows 短暂文件锁导致原子保存失败的问题 ([#7955](https://github.com/getpaseo/paseo/pull/7955)) +- 修复 ACP 终端产生大量输出时阻塞 daemon 的问题 ([#7953](https://github.com/getpaseo/paseo/pull/7953) by [@bbtfr](https://github.com/bbtfr)) +- 修复 Pi 手动压缩上下文期间发送消息直接失败而不等待的问题 ([#7848](https://github.com/getpaseo/paseo/pull/7848) by [@itssimon](https://github.com/itssimon)) +- 修复轮次重叠时较晚的工具更新产生重复行的问题 ([#8010](https://github.com/getpaseo/paseo/pull/8010) by [@luoph](https://github.com/luoph)) +- 修复插件 ACP 推理和回复的消息 ID 相同时,推理内容混入回复的问题 ([#7272](https://github.com/getpaseo/paseo/pull/7272) by [@cncolder](https://github.com/cncolder)) +- 修复工作区外的文件夹链接使 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)) +- 修复切换设置页面时丢失已选主机或侧栏滚动位置的问题 ([#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)) +- 修复模型选择器在打开设置后仍然显示的问题 ([#7783](https://github.com/getpaseo/paseo/pull/7783) by [@ragokan](https://github.com/ragokan)) + +### 说明 + +- 语音模式默认关闭,可在语音设置中启用 +- 如果通过环境变量选择 OpenAI 语音,请将仅保存在旧版 `providers.openai` 中的密钥和服务地址移至语音设置 +- 降级至 0.13.2 前,请恢复备份的 `config.json` 或转换 beta 保存的语音凭据和空闲清理设置 + ## 0.13.2 - 2026-10-26 ### 修复 diff --git a/docs/development.md b/docs/development.md index 7eb92375ab..873f9cabb9 100644 --- a/docs/development.md +++ b/docs/development.md @@ -273,6 +273,26 @@ Playwright dispatch lateness. Set `PASEO_PROFILE_TYPING_KEYS`, `PASEO_PROFILE_TY `PASEO_PROFILE_CPU_PATH` and `PASEO_PROFILE_TRACE_PATH` captures run separately after the latency measurements so profiling overhead does not contaminate them. +For diff layout, two benchmarks share one corpus +(`packages/app/src/git/diff-document/benchmark-corpus.ts`: a real source file, a changelog with +600-character lines, generated code with ligature punctuation, and JSON). The node bench runs the +shared document model with the web and native measurers replaced by counters, so it reports how +many strings each backend would have to shape, which is the cost that survives the move to canvas +or Skia: + +```bash +cd packages/app && npx vitest bench --project unit src/git/diff-document/model.bench.ts +``` + +The browser benchmark opens the same corpus as a working diff, toggles line wrap from the toolbar, +and reports pointer-down to painted latency, the longest main-thread task, and the `DiffDocument` +React commit time for cold and warm toggles, then scroll frame times in both modes: + +```bash +cd packages/app && PASEO_DIFF_PERF_E2E=1 npx playwright test --project=browser \ + e2e/browser/diff-performance.spec.ts -g "toggling line wrap" +``` + Set `PASEO_PROFILE_TYPING_SCENARIO=height-growth` to alternate `Shift+Enter` and printable input. That report includes input and composer height changes plus React work grouped into composer, stream, and ancestor/root scopes. Ancestor/root timings include descendant work because the Profiler @@ -542,6 +562,39 @@ patch every build the consumers use: Metro resolves the `react-native` field of (`src/*.ts` for `react-native-svg`), while Node and Vitest resolve `main`/`module` (`lib/commonjs`, `lib/module`). Patching only `lib/` leaves the app bundle unfixed. +## Dependency overrides and workspace tooling + +Root `package.json` `overrides` pin one Expo SDK across the tree (`expo`, `expo-router`, +`react-native`, …). npm does not apply those overrides to packages it places under a +workspace's own `node_modules`. When `expo-module-scripts` was declared in +`packages/expo-two-way-audio`, npm nested it there and its `jest-expo` peer `expo@*` resolved to +a second, newer Expo SDK and React Native: 200+ duplicate packages nothing imported (#7730). +Declare tooling that peer-depends on `expo` or `react-native` in the root `devDependencies`, +where the overrides apply; workspace scripts still find the binary through the root +`node_modules/.bin`. + +## Dependency signature audit + +The `quality` CI job runs `npm audit signatures`. It verifies every lockfile package's registry +signature and, for packages published with provenance, fetches +`/-/npm/v1/attestations/@` from the registry. Cloudflare caches that document +for a year. An edge that cached it in the seconds before the registry attached the publish +attestation keeps serving a document with only the provenance bundle, and npm fails with: + +``` +npm error code EMISSINGSIGNATUREKEY +npm error @ has attestations but no corresponding public key(s) can be found +``` + +The same package's registry signature verified a moment earlier with the same keys, so the +key list is intact. The failure names one package, repeats only for that package, and depends +on the Azure region the runner landed in, because each region maps to one Cloudflare colo. A +rerun passes only by landing elsewhere. + +To confirm, fetch the URL from several runners and compare `content-length`, the `cf-ray` colo +suffix, and the number of `attestations` entries (#7730 has the probe). To fix, remove or +replace that version in the lockfile and report the URL to npm so they purge it. + ## ACP provider catalog versions The in-app ACP provider catalog pins package-runner entries (`npx`, `npm exec`, diff --git a/docs/plugins.md b/docs/plugins.md index 9cab47214e..f07f831803 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -382,6 +382,15 @@ grace. During daemon startup, plugin sessions may connect while application WebS paused; the daemon accepts clients only after configured plugins have settled and the initial catalog is complete. +The app retains client bundles per host in its file cache (IndexedDB on web), keyed by plugin ID +and the daemon's SHA-256. A cold client verifies stored contents before advertising their hashes; +missing or corrupt files request full bundles. Only the latest catalog's bundles remain. Hashing +uses Expo's native async digest on mobile, avoiding a JavaScript hash loop on Hermes; reading and +passing the bundle string still costs memory and time once per client lifetime. +Persistence runs after catalog reconstruction without delaying installation. One write runs at a +time; while storage is busy, only the newest pending catalog is kept. A failed write leaves the +in-memory bundles usable, and a later catalog retries persistence. + When the same plugin contribution exists on multiple hosts, Paseo shows it once in the sidebar and adds a host picker to the screen header. The selected host supplies the bundle, RPC transport, and query cache. Explicit SDK targets follow the [host API contract](../public-docs/plugins/reference.md#discover-hosts-and-target-another-host). @@ -430,7 +439,11 @@ The [public reference](../public-docs/plugins/reference.md#icons) owns the autho Server entries register lifecycle observers with `server.on()` and request transforms with `server.before()`. The [public reference](../public-docs/plugins/reference.md#lifecycle-hooks) -owns callback shapes, ordering, and failure behavior. `plugin-examples/lifecycle-logger` registers all +owns callback shapes, ordering, and failure behavior. `agent.create` edits the agent's saved +configuration; `agent.session_open` contributes launch-only `env`, `tools`, and `mcpServers`, +re-derived on each open and following the plugin's lifecycle. See +[Inject MCP servers](../public-docs/plugins/reference.md#inject-mcp-servers) for choosing the hook. +`plugin-examples/lifecycle-logger` registers all eleven hooks; `plugin-examples/lifecycle-actions` demonstrates common automation callbacks. Session-open hooks also [attach tools to individual agents](../public-docs/plugins/reference.md#attach-tools-to-agents), as shown in `plugin-examples/agent-tools`. The agent manager stamps their metadata per session; diff --git a/docs/protocol-compatibility.md b/docs/protocol-compatibility.md index e5ba1db2a3..f7244e67ba 100644 --- a/docs/protocol-compatibility.md +++ b/docs/protocol-compatibility.md @@ -2,7 +2,7 @@ The app and the daemon are separate products that ship separately. A user updates the app from an app store or a desktop auto-update; they update the daemon when they feel like it. Every combination happens in the wild: new app against an old daemon, old app against a new daemon, and both sides months apart. -In development both sides are always the same version, which is why this is the constraint contributors miss most often. +Development can have the same version drift: an app reload picks up new code while a long-running daemon keeps its loaded version. Check the connected daemon version when diagnosing behavior that crosses this boundary. Two contracts follow from it. @@ -38,7 +38,7 @@ The app checks for the capability and either runs the feature or tells the user - **No defensive branches spread through the feature.** Detection happens in one place, and everything downstream reads a clean shape. - Capability flags live in `features` on the `server_info` message (`packages/protocol/src/messages.ts`, the `server_info` schema). -Existing functionality keeps working across versions because of the protocol contract. Gating a new feature never substitutes for that. +Existing functionality keeps working across versions. Never gate an established request on a new enhancement: negotiate the optional enhancement at the client boundary and preserve the existing request for older hosts. Whole-turn pagination can require a capability; reading conversation history cannot. ## Client capability ownership diff --git a/docs/timeline-sync.md b/docs/timeline-sync.md index 118ad628ce..812718be58 100644 --- a/docs/timeline-sync.md +++ b/docs/timeline-sync.md @@ -13,7 +13,7 @@ and rebuilds the projection when an agent resumes. The invariants are: > A continuously subscribed client applies every committed row in order. Opening or resuming an -> agent establishes the daemon's current tail in one bounded request, with older history reachable +> agent establishes the daemon's current tail in one complete-turn request, with older history reachable > through backward pagination. Tool output is bounded before it enters either delivery path. Canonical shell tool output is sliced @@ -44,7 +44,21 @@ recovery is complete only when `hasNewer: false`. Initialization timeouts guard lack of catch-up progress, not the full multi-page sync. A successful page that queues the next `after` page refreshes the watchdog. -Opening or resuming an agent fetches one bounded latest tail page. Older history remains +Opening or resuming an agent fetches the latest available turns with a soft target of 40 +projected items. Backward pages and prompt-jump windows also include whole turns, including +all currently available rows of an active run. There is no per-turn cap: a large run can +exceed the target. Local history mounting uses the same boundaries so revealing older rows +does not change the collapse state of a turn already displayed. + +`wholeTurns` is a preference negotiated once at the client fetch boundary. Hosts advertising +`features.wholeTurnTimeline` receive it; other hosts receive the existing item-based request. +Reading history remains available on older hosts, whose pages can still split turns. Forward +catch-up stays incremental, but its reset snapshot honors a supported whole-turn request. +Source coverage still includes interleaved tool updates. + +Canonical IDs define runs. With missing IDs, human prompts, heartbeats and typed received +agent messages each start a fallback turn. This deterministic fallback is shared by paging +and presentation; it cannot recover historical batching or steering. Older history remains user-driven by scrolling upward. A failed catch-up or subscription reconcile retries on its own, doubling from 1s to a 30s ceiling. @@ -149,12 +163,13 @@ range describes those exact items: `startSeq` drives older pagination and `endSe catch-up. The owner requests `after endSeq`, and requests `before startSeq` when the user loads older history. Code outside the owner does not distinguish cached and network timelines. -The first resume request is bounded. If it reports more newer history, fetch one latest bounded tail +The first resume request is bounded. If it reports more newer history, fetch one latest complete-turn tail instead of replaying every missed page. Live gap recovery still pages forward until current. -If the canonical window exceeds the cache item limit, contains a discontiguous retained range, has a +If the canonical window exceeds the cache item target, contains a discontiguous retained range, has a live head, or includes presentation data the cache cannot encode losslessly, persistence drops the -range and keeps a display-only tail. Never slice items while retaining the pre-slice range; that +range and keeps a display-only tail of complete available turns. The item target is soft; +the byte budget still evicts entire cache rows. Never slice items while retaining the pre-slice range; that falsely certifies discarded source rows. A display-only row paints without granting synchronization authority, so the owner uses the ordinary bounded `tail` bootstrap. @@ -238,11 +253,11 @@ foreground control ownership remains a separate daemon concern. Cancellation req with that record rather than in a React component, so an old request cannot clear a newer one. Submissions remain a separate pre-turn registry and retire on canonical acknowledgement. -Canonical turns and visible responses are different boundaries. Agent-originated prompts and lifecycle -notifications project into tool rows, so they do not create a human-message boundary. Legacy system -prompts, including schedule injections, remain hidden. One visible response can span several canonical -turns; layout and copy group that response together while lifecycle, timing, tool sequences, and exact -fork positions retain the canonical `turnId` boundaries. +Every canonical run owns a visible turn, including heartbeats and agent notifications. +Notifications delivered during a run stay inside it; batched notifications share the run +that starts or accepts them. Received messages retain sender attribution, while lifecycle +notifications remain visible events. Collapse, timing and locally mounted history share +these boundaries. Agent-message XML belongs to the daemon's `agent/agent-messages` boundary. Prompt delivery encodes provenance; acceptance, provider echoes, import, and replay share its projection. The delivery ID is diff --git a/nix/npm-deps.hash b/nix/npm-deps.hash index 3e19f893c2..f57d216905 100644 --- a/nix/npm-deps.hash +++ b/nix/npm-deps.hash @@ -1 +1 @@ -sha256-eNiKmiZ3XXZMRZRSoGxUbMn2RtY6AX079vUatBYM+qE= +sha256-5/wat91m8GWxkGVk211lu9KFLhDQNEF/dlMhHMGf8Q0= diff --git a/package-lock.json b/package-lock.json index 847bd24eb7..5017de5705 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "paseo", - "version": "0.13.2", + "version": "0.14.0-beta.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "paseo", - "version": "0.13.2", + "version": "0.14.0-beta.1", "hasInstallScript": true, "license": "Apache-2.0", "workspaces": [ @@ -30,6 +30,7 @@ "concurrently": "^9.2.1", "cross-env": "^10.1.0", "electron": "44.2.0", + "expo-module-scripts": "^5.0.8", "get-port-cli": "^3.0.0", "js-yaml": "^4.1.1", "knip": "^5.82.1", @@ -980,7 +981,7 @@ "version": "7.8.4", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" @@ -993,7 +994,7 @@ "version": "7.8.3", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" @@ -1006,7 +1007,7 @@ "version": "7.12.13", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.12.13" @@ -1019,7 +1020,7 @@ "version": "7.14.5", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.14.5" @@ -1108,7 +1109,7 @@ "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.28.6.tgz", "integrity": "sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" @@ -1124,7 +1125,7 @@ "version": "7.10.4", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.10.4" @@ -1137,7 +1138,7 @@ "version": "7.8.3", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" @@ -1165,7 +1166,7 @@ "version": "7.10.4", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.10.4" @@ -1190,7 +1191,7 @@ "version": "7.10.4", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.10.4" @@ -1203,7 +1204,7 @@ "version": "7.8.3", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" @@ -1216,7 +1217,7 @@ "version": "7.8.3", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" @@ -1241,7 +1242,7 @@ "version": "7.14.5", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.14.5" @@ -1257,7 +1258,7 @@ "version": "7.14.5", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.14.5" @@ -2510,6 +2511,14 @@ "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "devOptional": true, + "license": "MIT", + "peer": true + }, "node_modules/@blazediff/core": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/@blazediff/core/-/core-1.9.1.tgz", @@ -2884,6 +2893,17 @@ "react": ">=16.8.0" } }, + "node_modules/@dual-bundle/import-meta-resolve": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@dual-bundle/import-meta-resolve/-/import-meta-resolve-4.2.1.tgz", + "integrity": "sha512-id+7YRUgoUX6CgV0DtuhirQWodeeA7Lf4i2x71JS/vtA5pRb/hIGWlw+G6MeXvsM+MXrz0VAydTGElX1rAfgPg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/JounQin" + } + }, "node_modules/@egjs/hammerjs": { "version": "2.0.17", "resolved": "https://registry.npmjs.org/@egjs/hammerjs/-/hammerjs-2.0.17.tgz", @@ -5537,14 +5557,6 @@ "react-native": "*" } }, - "node_modules/@expo/log-box-utils": { - "version": "58.0.2", - "resolved": "https://registry.npmjs.org/@expo/log-box-utils/-/log-box-utils-58.0.2.tgz", - "integrity": "sha512-My8Lwe9FU/yDwwzqwi5LN2rzzDcJqIAZKrD9f0XHZEKz/FQnWJYdt1rsV7+RwqMGeGq2XsxTCVT1ggTCOvumxw==", - "dev": true, - "license": "MIT", - "peer": true - }, "node_modules/@expo/logger": { "version": "1.0.221", "resolved": "https://registry.npmjs.org/@expo/logger/-/logger-1.0.221.tgz", @@ -5899,36 +5911,6 @@ "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" } }, - "node_modules/@expo/modules-macros-win32-arm64": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/@expo/modules-macros-win32-arm64/-/modules-macros-win32-arm64-0.20.0.tgz", - "integrity": "sha512-7B57gc7+TeygPcK8fLYlEhJbbvaPh+JBzex11YIYZrSzbFhz7Q+Z37fSdAfp4EsfCswJNcX0S6wpkgSlVNefVw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true - }, - "node_modules/@expo/modules-macros-win32-x64": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/@expo/modules-macros-win32-x64/-/modules-macros-win32-x64-0.20.0.tgz", - "integrity": "sha512-BM5a2U2tCyHzmtiSEY70KPsts82Mfy1bHSZ7AUbg6z9LcnxuThxJlcH85/SV5eFbhrPfgDBAG/WAncyZCvKvyA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true - }, "node_modules/@expo/multipart-body-parser": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@expo/multipart-body-parser/-/multipart-body-parser-2.0.0.tgz", @@ -6216,14 +6198,6 @@ "node-forge": "^1.2.1" } }, - "node_modules/@expo/platforms": { - "version": "58.0.1", - "resolved": "https://registry.npmjs.org/@expo/platforms/-/platforms-58.0.1.tgz", - "integrity": "sha512-KtLrtAvpShq16l9lTxd2fZSjULxcGxXrJTHA31iUVqrc+Af7aTAJW4ZB3yQBgi/w+kSb6BB/CG9RXI0ohbMm6g==", - "dev": true, - "license": "MIT", - "peer": true - }, "node_modules/@expo/plist": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/@expo/plist/-/plist-0.2.0.tgz", @@ -7569,7 +7543,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "camelcase": "^5.3.1", @@ -7586,7 +7560,7 @@ "version": "5.3.1", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6" @@ -7596,7 +7570,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "locate-path": "^5.0.0", @@ -7610,7 +7584,7 @@ "version": "3.14.2", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "argparse": "^1.0.7", @@ -7624,7 +7598,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "p-locate": "^4.1.0" @@ -7637,7 +7611,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "p-try": "^2.0.0" @@ -7653,7 +7627,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "p-limit": "^2.2.0" @@ -7666,7 +7640,7 @@ "version": "0.1.3", "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -7676,7 +7650,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/@jest/console/-/console-29.7.0.tgz", "integrity": "sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jest/types": "^29.6.3", @@ -7694,12 +7668,72 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=8" } }, + "node_modules/@jest/core": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-29.7.0.tgz", + "integrity": "sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/reporters": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-changed-files": "^29.7.0", + "jest-config": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-resolve-dependencies": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "jest-watcher": "^29.7.0", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/core/node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "devOptional": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, "node_modules/@jest/create-cache-key-function": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/@jest/create-cache-key-function/-/create-cache-key-function-29.7.0.tgz", @@ -7714,10 +7748,10 @@ } }, "node_modules/@jest/diff-sequences": { - "version": "30.4.0", - "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.4.0.tgz", - "integrity": "sha512-zOpzlfUs45l6u7jm39qr87JCHUDsaeCtvL+kQe/Vn9jSnRB4/5IPXISm0h9I1vZW/o00Kn4UTJ2MOlhnUGwv3g==", - "dev": true, + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.5.0.tgz", + "integrity": "sha512-OsqBjHXCn8cadasoAZBP6nWYvMsRhpMzGXTpxJ5aO04NlbdhIz+FVe3q49l0AwVhsz/cEmIpBes6gAFl1/dWQg==", + "devOptional": true, "license": "MIT", "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -7727,7 +7761,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jest/fake-timers": "^29.7.0", @@ -7743,7 +7777,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz", "integrity": "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "expect": "^29.7.0", @@ -7757,7 +7791,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz", "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "jest-get-type": "^29.6.3" @@ -7770,7 +7804,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz", "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jest/types": "^29.6.3", @@ -7785,10 +7819,10 @@ } }, "node_modules/@jest/get-type": { - "version": "30.1.0", - "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.1.0.tgz", - "integrity": "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==", - "dev": true, + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.5.0.tgz", + "integrity": "sha512-9/2VUPitAjmBzbvDvqrxmvB7BzWsBW0WmkkojX1ODuxX1NLGxx9gfaZpHB0z8DtJ9uhGNmZG/VXBhf8uO0OV8Q==", + "devOptional": true, "license": "MIT", "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -7798,7 +7832,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz", "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jest/environment": "^29.7.0", @@ -7810,6 +7844,171 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/@jest/react-is-18": { + "name": "react-is", + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@jest/react-is-19": { + "name": "react-is", + "version": "19.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.3.1.tgz", + "integrity": "sha512-RRieF6IyxQBkcK6pP8xOHprLTPW6QjRkbGdImtqNme+/hYVwCZkLQLdlZGOhUqXwkS9IEynaGvPJtv6XB/cz0Q==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@jest/reporters": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", + "integrity": "sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "@types/node": "*", + "chalk": "^4.0.0", + "collect-v8-coverage": "^1.0.0", + "exit": "^0.1.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^6.0.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^4.0.0", + "istanbul-reports": "^3.1.3", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "slash": "^3.0.0", + "string-length": "^4.0.1", + "strip-ansi": "^6.0.0", + "v8-to-istanbul": "^9.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/reporters/node_modules/brace-expansion": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@jest/reporters/node_modules/char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "devOptional": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/@jest/reporters/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "devOptional": true, + "license": "ISC", + "peer": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@jest/reporters/node_modules/istanbul-lib-instrument": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", + "devOptional": true, + "license": "BSD-3-Clause", + "peer": true, + "dependencies": { + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@jest/reporters/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "devOptional": true, + "license": "ISC", + "peer": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@jest/reporters/node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "devOptional": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/reporters/node_modules/string-length": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", + "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "char-regex": "^1.0.2", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@jest/schemas": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", @@ -7822,11 +8021,27 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/@jest/source-map": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.6.3.tgz", + "integrity": "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.18", + "callsites": "^3.0.0", + "graceful-fs": "^4.2.9" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/@jest/test-result": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.7.0.tgz", "integrity": "sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jest/console": "^29.7.0", @@ -7838,11 +8053,39 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/@jest/test-sequencer": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz", + "integrity": "sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jest/test-result": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/test-sequencer/node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "devOptional": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, "node_modules/@jest/transform": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz", "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/core": "^7.11.6", @@ -7869,14 +8112,14 @@ "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true, + "devOptional": true, "license": "ISC" }, "node_modules/@jest/transform/node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -7886,7 +8129,7 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "imurmurhash": "^0.1.4", @@ -8892,150 +9135,6 @@ "node": ">=12.4.0" } }, - "node_modules/@noxcturnal/noxcturnal-darwin-arm64": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/@noxcturnal/noxcturnal-darwin-arm64/-/noxcturnal-darwin-arm64-0.1.5.tgz", - "integrity": "sha512-hQgDaWxKYln83Uk1Hh4Liju9AvGoYh4aLd4A1ou3lGn5hxzhW2uO+z3mERtWk6BMGVazvi/yUXtp9x3IlaWyhA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@noxcturnal/noxcturnal-darwin-x64": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/@noxcturnal/noxcturnal-darwin-x64/-/noxcturnal-darwin-x64-0.1.5.tgz", - "integrity": "sha512-I57zR8QF7Xam+4nGD5UoIODsw0VjkVyn51iDBL3CMaDR5Lh1OxXwKZMYKtv3CtQl+RSDsMfmXBEqGYLE8lIPHg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@noxcturnal/noxcturnal-linux-arm64-gnu": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/@noxcturnal/noxcturnal-linux-arm64-gnu/-/noxcturnal-linux-arm64-gnu-0.1.5.tgz", - "integrity": "sha512-gVH4XJPh8RdrdjjlU0dKeielQt/FXfvqhr8IEWEf3VeNtQs8l2rPzFPdqS0uwLWQYHLBGgB0LkuOil1KKYx1gQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@noxcturnal/noxcturnal-linux-arm64-musl": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/@noxcturnal/noxcturnal-linux-arm64-musl/-/noxcturnal-linux-arm64-musl-0.1.5.tgz", - "integrity": "sha512-rzQ075fcHZ+ebsELussRjnW8WBMy1RuLdS8wSqBGhdVlPR7/NaSyoWkNxnVDT8sEO26xjqGoW/o/08ghBzr/zA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@noxcturnal/noxcturnal-linux-x64-gnu": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/@noxcturnal/noxcturnal-linux-x64-gnu/-/noxcturnal-linux-x64-gnu-0.1.5.tgz", - "integrity": "sha512-X5O0lXMrv8HGOGoXDB1PG0Hjt7Rl+MJA+seR2NecQvUAAw2gzDvF/unCV5sqTAVeHfXhBHEFGkLenHZSD44efg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@noxcturnal/noxcturnal-linux-x64-musl": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/@noxcturnal/noxcturnal-linux-x64-musl/-/noxcturnal-linux-x64-musl-0.1.5.tgz", - "integrity": "sha512-fkQIQHRxqldNXVmjxfeCvGOiLhLU9SLBw1As02pMA2xjyiZfiXcUn4SUrZ92ciGnyCS8/XMbx+nZSxgEc3qDPw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@noxcturnal/noxcturnal-win32-arm64-msvc": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/@noxcturnal/noxcturnal-win32-arm64-msvc/-/noxcturnal-win32-arm64-msvc-0.1.5.tgz", - "integrity": "sha512-baeH5m/vM1nf0J2UkW8cfgiVogpM0gRO9b4PZWknXQPbv5+k+0N5sGOYl/UAO2q72qYb3Xbiu7eD8c4A2kR2SA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@noxcturnal/noxcturnal-win32-x64-msvc": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/@noxcturnal/noxcturnal-win32-x64-msvc/-/noxcturnal-win32-x64-msvc-0.1.5.tgz", - "integrity": "sha512-2faDMzom8pCG/s/uR4Czh1atz4KohI85AaSmA2vtAuVnCq+nLL4Viys3A5vUlNz9MNBmTNFxzXyF2Z5HtfxXLg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, "node_modules/@npmcli/agent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-3.0.0.tgz", @@ -9387,17 +9486,6 @@ "node": ">=8.0.0" } }, - "node_modules/@oxc-project/types": { - "version": "0.154.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.154.0.tgz", - "integrity": "sha512-KYOUGQFlLWR+s4QMV2Q9ko38TZm7EdO87Qm7rvZLqk/webse/cDjtyxKhg/47l0byCbs/k46LT8qc4PKjAikzA==", - "dev": true, - "license": "MIT", - "peer": true, - "funding": { - "url": "https://github.com/sponsors/oxc-project" - } - }, "node_modules/@oxc-resolver/binding-android-arm-eabi": { "version": "11.19.1", "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm-eabi/-/binding-android-arm-eabi-11.19.1.tgz", @@ -10429,13 +10517,13 @@ } }, "node_modules/@pkgr/core": { - "version": "0.2.9", - "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", - "integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==", + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.3.6.tgz", + "integrity": "sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==", "dev": true, "license": "MIT", "engines": { - "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + "node": "^14.18.0 || >=16.0.0" }, "funding": { "url": "https://opencollective.com/pkgr" @@ -11020,17 +11108,6 @@ "react-native": ">=0.57" } }, - "node_modules/@react-native/asset-utils": { - "version": "0.88.0", - "resolved": "https://registry.npmjs.org/@react-native/asset-utils/-/asset-utils-0.88.0.tgz", - "integrity": "sha512-Bj1DxQt5f4OrjdlgSVkZT37Z3hx5hUIN5AUDSqL/e99vgtTcqj5vnm067epZL1EF/QiqsniZO8Q1GSkItOakDg==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": "^22.13.0 || ^24.3.0 || >= 26.0.0" - } - }, "node_modules/@react-native/assets-registry": { "version": "0.86.3", "resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.86.3.tgz", @@ -12073,7 +12150,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", - "dev": true, + "devOptional": true, "license": "BSD-3-Clause", "dependencies": { "type-detect": "4.0.8" @@ -12083,7 +12160,7 @@ "version": "10.3.0", "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", - "dev": true, + "devOptional": true, "license": "BSD-3-Clause", "dependencies": { "@sinonjs/commons": "^3.0.0" @@ -12556,6 +12633,151 @@ } } }, + "node_modules/@testing-library/react-native": { + "version": "13.3.3", + "resolved": "https://registry.npmjs.org/@testing-library/react-native/-/react-native-13.3.3.tgz", + "integrity": "sha512-k6Mjsd9dbZgvY4Bl7P1NIpePQNi+dfYtlJ5voi9KQlynxSyQkfOgJmYGCYmw/aSgH/rUcFvG8u5gd4npzgRDyg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "jest-matcher-utils": "^30.0.5", + "picocolors": "^1.1.1", + "pretty-format": "^30.0.5", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "jest": ">=29.0.0", + "react": ">=18.2.0", + "react-native": ">=0.71", + "react-test-renderer": ">=18.2.0" + }, + "peerDependenciesMeta": { + "jest": { + "optional": true + } + } + }, + "node_modules/@testing-library/react-native/node_modules/@jest/schemas": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.5.0.tgz", + "integrity": "sha512-/hunigyNpc4RCjC0VaW3f5RCUZVM2+WQ65qP7z083Gmvac7or2LI50XVNOtE4YPgBpV0yxYiAgorAPGniCoJmg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@testing-library/react-native/node_modules/@sinclair/typebox": { + "version": "0.34.52", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.52.tgz", + "integrity": "sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@testing-library/react-native/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@testing-library/react-native/node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@testing-library/react-native/node_modules/jest-diff": { + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.5.2.tgz", + "integrity": "sha512-rBtHnI6BWTiWj3lM7fOLVfChx7R9B0u9VV3PRHawKs79x6ZjW1QoSrKenaigNGWlvhtoi2BqvDbABDie+Os3xQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@jest/diff-sequences": "30.5.0", + "@jest/get-type": "30.5.0", + "chalk": "^4.1.2", + "pretty-format": "30.5.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@testing-library/react-native/node_modules/jest-matcher-utils": { + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.5.2.tgz", + "integrity": "sha512-lFkB365PTIHOhPzwrb9T6gvlDnPpOZ7/c3ewOKKB7bzztqjVrtlyRL2MkyfJXpyY2u2SKw001JKrTx14fBUFcQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.5.0", + "chalk": "^4.1.2", + "jest-diff": "30.5.2", + "pretty-format": "30.5.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@testing-library/react-native/node_modules/pretty-format": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.5.1.tgz", + "integrity": "sha512-byhRAPguVKMQIj4kjJwJ5lAskVhfuiSdiYl/aLTWpgkGEmic2jYhJh1yE9ih8Ox44Xg9ccCT11/S6QrzSJuNrg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@jest/react-is-18": "npm:react-is@^18.3.1", + "@jest/react-is-19": "npm:react-is@^19.2.5", + "@jest/schemas": "30.5.0", + "ansi-styles": "^5.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@testing-library/react-native/node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@testing-library/react-native/node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/@tootallnate/once": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", @@ -12623,7 +12845,7 @@ "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/parser": "^7.20.7", @@ -12637,7 +12859,7 @@ "version": "7.27.0", "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/types": "^7.0.0" @@ -12647,7 +12869,7 @@ "version": "7.4.4", "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/parser": "^7.1.0", @@ -12658,7 +12880,7 @@ "version": "7.28.0", "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/types": "^7.28.2" @@ -13065,7 +13287,7 @@ "version": "4.1.9", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", "integrity": "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -13373,7 +13595,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@types/tough-cookie": { @@ -13443,6 +13665,288 @@ "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", "license": "MIT" }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.72.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.72.1.tgz", + "integrity": "sha512-5CokTWXpmbylfFabsbvHC0CAw3XLaGnoa+nFJX5FR592h/LoZsENUE6p/rg5xvkKrFEXz6pYmgI29AgXc2N3BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.72.1", + "@typescript-eslint/type-utils": "8.72.1", + "@typescript-eslint/utils": "8.72.1", + "@typescript-eslint/visitor-keys": "8.72.1", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.72.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.14", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.14.tgz", + "integrity": "sha512-wCBiZ77u1tuWde6G/nfOtLaCTYcFDU5yCzARzK10y+rLhzYoL7a7yEemcopcc2Mzow7gZOIjnV2ym1NYmdiJOQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.72.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.72.1.tgz", + "integrity": "sha512-ZGwZP4OCfyA0AQTpEAMWz+rtX01DRhQWW5hB0ucKDEIX5U7jdmPSaMx94rQfaPAQzIYz2BimOgKOJtjfpRFQ9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.72.1", + "@typescript-eslint/types": "8.72.1", + "@typescript-eslint/typescript-estree": "8.72.1", + "@typescript-eslint/visitor-keys": "8.72.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.72.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.72.1.tgz", + "integrity": "sha512-fAuVMkwdO/huI8nyo/WKnNKf6Gag93S56Rh3Ts5mGcavwSdaN8tRg1gCeIGX8PF+nzp1BjGfO4gHhGzW1J6jTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.72.1", + "@typescript-eslint/types": "^8.72.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.72.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.72.1.tgz", + "integrity": "sha512-lC/t6eMCZZQAAKZEYGL6RDVSuFehpdmMT6h+nJIjIa6oylN/PwDgDVtEIbB7O9xFfZYtZAYLoLlDNWKJhtF+uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.72.1", + "@typescript-eslint/visitor-keys": "8.72.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.72.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.72.1.tgz", + "integrity": "sha512-e4D5R5vpeAEPBxT2BAzsFQL3sbwyeeeo5+aczgNSRK1V734jE7pTqaGFU+fXapWEQwnZbpFaTn32+Z8DwS6OTg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.72.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.72.1.tgz", + "integrity": "sha512-iY2wlmyrXJmx4yxUGP3aYCFF3cA/6ShddOKmLU2dx7LnEykbAM8WThlxZEfd1NddqceF6eJmZUayxx9oKIYNlg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.72.1", + "@typescript-eslint/typescript-estree": "8.72.1", + "@typescript-eslint/utils": "8.72.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.72.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.72.1.tgz", + "integrity": "sha512-LmE5IhQet1BWOWEXqBrVtBPsMAOQ6Nzk35d+WLh3mk4EUF33sqlfxjio1MdYeSvKSG5skactKKuixBIKXhL4Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.72.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.72.1.tgz", + "integrity": "sha512-DAlUoUV93hIvrtIQLwCbmZQYVZwOMhiHJrOVUUIWicFZaIYjTRqlY3I/NZevfkLmSqkNHRu2qYDcq9lrn16oOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.72.1", + "@typescript-eslint/tsconfig-utils": "8.72.1", + "@typescript-eslint/types": "8.72.1", + "@typescript-eslint/visitor-keys": "8.72.1", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.72.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.72.1.tgz", + "integrity": "sha512-5v5liypurJBFHCBNqh/RjsymAD+VWcE0djuGY/BUqp5fC3Qn+luWDEksjuTtWjPn6i5zQlUtkZCRG/CYG0yCkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.72.1", + "@typescript-eslint/types": "8.72.1", + "@typescript-eslint/typescript-estree": "8.72.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.72.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.72.1.tgz", + "integrity": "sha512-SB8xqipSzZHUePCCLNeYxqnxq83X61/qzNhjQGbTfjxGZIeOEP7tBgWYX05Tcd6CgE02WdTJnh5b8kMZJ/+bnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.72.1", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/@typescript/native-preview": { "version": "7.0.0-dev.20260423.1", "resolved": "https://registry.npmjs.org/@typescript/native-preview/-/native-preview-7.0.0-dev.20260423.1.tgz", @@ -14520,7 +15024,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "normalize-path": "^3.0.0", @@ -14534,7 +15038,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=8.6" @@ -15208,7 +15712,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jest/transform": "^29.7.0", @@ -15230,7 +15734,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -15250,7 +15754,7 @@ "version": "6.1.1", "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", - "dev": true, + "devOptional": true, "license": "BSD-3-Clause", "dependencies": { "@babel/helper-plugin-utils": "^7.0.0", @@ -15267,7 +15771,7 @@ "version": "29.6.3", "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz", "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/template": "^7.3.3", @@ -15365,7 +15869,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", "integrity": "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/plugin-syntax-async-generators": "^7.8.4", @@ -15436,7 +15940,7 @@ "version": "29.6.3", "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz", "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "babel-plugin-jest-hoist": "^29.6.3", @@ -15469,7 +15973,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/barcode-detector": { @@ -16103,7 +16607,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6" @@ -16378,6 +16882,14 @@ "node": ">=8" } }, + "node_modules/cjs-module-lexer": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", + "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", + "devOptional": true, + "license": "MIT", + "peer": true + }, "node_modules/clean-stack": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-3.0.1.tgz", @@ -16508,11 +17020,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "devOptional": true, + "license": "MIT", + "peer": true, + "engines": { + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" + } + }, "node_modules/collect-v8-coverage": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz", "integrity": "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/color": { @@ -16677,7 +17201,7 @@ "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/concurrently": { @@ -16876,6 +17400,29 @@ "ieee754": "^1.1.13" } }, + "node_modules/create-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", + "integrity": "sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "prompts": "^2.0.1" + }, + "bin": { + "create-jest": "bin/create-jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/create-require": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", @@ -17797,6 +18344,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/dedent": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", + "integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==", + "devOptional": true, + "license": "MIT", + "peer": true, + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -17964,6 +18527,17 @@ "node": ">=8" } }, + "node_modules/detect-newline": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", + "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", + "devOptional": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, "node_modules/detect-node": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", @@ -18004,7 +18578,7 @@ "version": "29.6.3", "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" @@ -18769,7 +19343,7 @@ "version": "0.13.1", "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=12" @@ -18869,7 +19443,7 @@ "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "is-arrayish": "^0.2.1" @@ -19189,7 +19763,6 @@ "dev": true, "license": "BSD-3-Clause", "optional": true, - "peer": true, "engines": { "node": ">=0.10.0" } @@ -19270,6 +19843,60 @@ "eslint": ">=6.0.0" } }, + "node_modules/eslint-config-prettier": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.2.tgz", + "integrity": "sha512-iI1f+D2ViGn+uvv5HuHVUamg8ll4tN+JRHGc6IJi4TP9Kl976C57fzPXgseXNs8v0iA8aSJpHsTWjDb9QJamGQ==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-config-universe": { + "version": "15.2.0", + "resolved": "https://registry.npmjs.org/eslint-config-universe/-/eslint-config-universe-15.2.0.tgz", + "integrity": "sha512-n2662q/mM+2pTFVz7ELosqhN+/nbR75Ut/4vLme40kKSHHe0oPbPMxgPqyYrASlANuSDP4aAJ71rRviDMCZTxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "^8.59.0", + "@typescript-eslint/parser": "^8.59.0", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-import": "^2.31.0", + "eslint-plugin-n": "^17.17.0", + "eslint-plugin-node": "^11.1.0", + "eslint-plugin-prettier": "^5.2.6", + "eslint-plugin-react": "^7.37.5", + "eslint-plugin-react-hooks": "^7.0.0", + "globals": "^16.0.0" + }, + "peerDependencies": { + "eslint": ">=8.10", + "prettier": ">=3" + }, + "peerDependenciesMeta": { + "prettier": { + "optional": true + } + } + }, + "node_modules/eslint-config-universe/node_modules/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/eslint-import-resolver-node": { "version": "0.3.9", "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", @@ -19547,9 +20174,9 @@ } }, "node_modules/eslint-plugin-node/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { @@ -19581,14 +20208,14 @@ } }, "node_modules/eslint-plugin-prettier": { - "version": "5.5.5", - "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.5.tgz", - "integrity": "sha512-hscXkbqUZ2sPithAuLm5MXL+Wph+U7wHngPBv9OMWwlP8iaflyxpjTYZkmdgB4/vPIhemRlBEoLrH7UC1n7aUw==", + "version": "5.5.7", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.7.tgz", + "integrity": "sha512-WjHyn3GrFd+ZR0rzsLiM4zJZ35PZMOE0SUIxzGmnWNG0126yNfJKJzObmmkYDwHJfGlkz3PSlzyG9ez/LJntwg==", "dev": true, "license": "MIT", "dependencies": { "prettier-linter-helpers": "^1.0.1", - "synckit": "^0.11.12" + "synckit": "^0.12.3" }, "engines": { "node": "^14.18.0 || >=16.0.0" @@ -19644,6 +20271,43 @@ "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" } }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-plugin-react-hooks/node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint-plugin-react-hooks/node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, "node_modules/eslint-plugin-react/node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -19841,7 +20505,7 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true, + "devOptional": true, "license": "BSD-2-Clause", "bin": { "esparse": "bin/esparse.js", @@ -19990,11 +20654,54 @@ "dev": true, "license": "MIT" }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/execa/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "devOptional": true, + "license": "ISC", + "peer": true + }, + "node_modules/exit": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", + "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==", + "devOptional": true, + "peer": true, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/expect": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jest/expect-utils": "^29.7.0", @@ -20246,6 +20953,131 @@ "react-native": "*" } }, + "node_modules/expo-module-scripts": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/expo-module-scripts/-/expo-module-scripts-5.0.8.tgz", + "integrity": "sha512-3YkAic3E6QGQuNEIchq6/0GGSBwR2C70AhDUF7VI7/N3THwUpeqZrX20FxHKN95/wclHs1ljrGvRX+2YTgXxiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/cli": "^7.23.4", + "@babel/plugin-transform-export-namespace-from": "^7.23.4", + "@babel/preset-env": "^7.23.8", + "@babel/preset-typescript": "^7.23.3", + "@expo/npm-proofread": "^1.0.1", + "@testing-library/react-native": "^13.2.0", + "@tsconfig/node18": "^18.2.2", + "@types/jest": "^29.2.1", + "babel-plugin-dynamic-import-node": "^2.3.3", + "babel-preset-expo": "~54.0.7", + "commander": "^12.1.0", + "eslint-config-universe": "^15.0.3", + "glob": "^13.0.0", + "jest-expo": "~54.0.13", + "jest-snapshot-prettier": "npm:prettier@^2", + "jest-watch-typeahead": "2.2.1", + "resolve-workspace-root": "^2.0.0", + "ts-jest": "~29.0.4", + "typescript": "^5.9.2" + }, + "bin": { + "expo-module": "bin/expo-module.js" + } + }, + "node_modules/expo-module-scripts/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/expo-module-scripts/node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/expo-module-scripts/node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/expo-module-scripts/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/expo-module-scripts/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/expo-module-scripts/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/expo-module-scripts/node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/expo-modules-autolinking": { "version": "57.0.16", "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-57.0.16.tgz", @@ -20303,18 +21135,6 @@ "react-native": "*" } }, - "node_modules/expo-modules-macros": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/expo-modules-macros/-/expo-modules-macros-0.20.0.tgz", - "integrity": "sha512-Ekgb5c9LQPM9CPnmDZdeWcOa1eBbCDUGrgdN5lIFNH2GWDUf9doeAOK7IqQOOVviamy37eIp5FW+N1zyrIwzoA==", - "dev": true, - "license": "MIT", - "peer": true, - "optionalDependencies": { - "@expo/modules-macros-win32-arm64": "0.20.0", - "@expo/modules-macros-win32-x64": "0.20.0" - } - }, "node_modules/expo-router": { "version": "57.0.28", "resolved": "https://registry.npmjs.org/expo-router/-/expo-router-57.0.28.tgz", @@ -20947,7 +21767,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/fast-levenshtein": { @@ -21293,31 +22113,6 @@ "integrity": "sha512-3PYnM29RFXwvAN6Pc/scUfkI7RwhQ/xqyLUyPNlXUp9S40zI8nup9tUSrTLSVnWGBN38FNiGWbwZOB6uR4OGdw==", "license": "MIT" }, - "node_modules/flow-estree": { - "version": "0.331.0", - "resolved": "https://registry.npmjs.org/flow-estree/-/flow-estree-0.331.0.tgz", - "integrity": "sha512-FVLYkSL/ITb/QXBEQvNWPjPooRGswuUtGOwrH+puSlMDneNkxy640+FZsk/TfKL+b7Wbw/Fg9FTUv2bMQDpj2w==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/flow-parser": { - "version": "0.331.0", - "resolved": "https://registry.npmjs.org/flow-parser/-/flow-parser-0.331.0.tgz", - "integrity": "sha512-vEZcHIlnKeN8JSVtYFczj8sWJfHIJOkz+YG00O/oul/Bete+t1ZhTi+O6JRRLFYvGW+yJkZ0YgL16iStnbkUqQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "flow-estree": "0.331.0" - }, - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/follow-redirects": { "version": "1.15.11", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", @@ -21507,7 +22302,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true, + "devOptional": true, "license": "ISC" }, "node_modules/fsevents": { @@ -21629,7 +22424,7 @@ "version": "0.1.0", "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=8.0.0" @@ -21681,6 +22476,20 @@ "node": ">= 0.4" } }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "devOptional": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-symbol-description": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", @@ -22394,6 +23203,14 @@ "node": ">=12" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "devOptional": true, + "license": "MIT", + "peer": true + }, "node_modules/html-parse-stringify": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", @@ -22525,6 +23342,17 @@ "node": ">= 6" } }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "devOptional": true, + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">=10.17.0" + } + }, "node_modules/hyperlinker": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/hyperlinker/-/hyperlinker-1.0.0.tgz", @@ -22671,6 +23499,27 @@ "node": ">=4" } }, + "node_modules/import-local": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", + "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/import-meta-resolve": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", @@ -22685,7 +23534,7 @@ "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=0.8.19" @@ -22709,7 +23558,7 @@ "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "once": "^1.3.0", @@ -22847,7 +23696,7 @@ "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/is-async-function": { @@ -23057,6 +23906,17 @@ "node": ">=8" } }, + "node_modules/is-generator-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", + "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", + "devOptional": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6" + } + }, "node_modules/is-generator-function": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", @@ -23247,7 +24107,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -23417,7 +24277,7 @@ "version": "3.2.2", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", - "dev": true, + "devOptional": true, "license": "BSD-3-Clause", "engines": { "node": ">=8" @@ -23427,7 +24287,7 @@ "version": "5.2.1", "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", - "dev": true, + "devOptional": true, "license": "BSD-3-Clause", "dependencies": { "@babel/core": "^7.12.3", @@ -23444,12 +24304,101 @@ "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, + "devOptional": true, "license": "ISC", "bin": { "semver": "bin/semver.js" } }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "devOptional": true, + "license": "BSD-3-Clause", + "peer": true, + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report/node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/istanbul-lib-report/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", + "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", + "devOptional": true, + "license": "BSD-3-Clause", + "peer": true, + "dependencies": { + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "devOptional": true, + "license": "BSD-3-Clause", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "devOptional": true, + "license": "BSD-3-Clause", + "peer": true, + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/iterator.prototype": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", @@ -23502,11 +24451,273 @@ "node": ">=10" } }, + "node_modules/jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", + "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/types": "^29.6.3", + "import-local": "^3.0.2", + "jest-cli": "^29.7.0" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-changed-files": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-29.7.0.tgz", + "integrity": "sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "execa": "^5.0.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-circus": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-29.7.0.tgz", + "integrity": "sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "co": "^4.6.0", + "dedent": "^1.0.0", + "is-generator-fn": "^2.0.0", + "jest-each": "^29.7.0", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0", + "pretty-format": "^29.7.0", + "pure-rand": "^6.0.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-circus/node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "devOptional": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "peer": true + }, + "node_modules/jest-circus/node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "devOptional": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-cli": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.7.0.tgz", + "integrity": "sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "create-jest": "^29.7.0", + "exit": "^0.1.2", + "import-local": "^3.0.2", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "yargs": "^17.3.1" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-config": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-29.7.0.tgz", + "integrity": "sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/test-sequencer": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-jest": "^29.7.0", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "deepmerge": "^4.2.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-circus": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "micromatch": "^4.0.4", + "parse-json": "^5.2.0", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@types/node": "*", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/jest-config/node_modules/brace-expansion": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/jest-config/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "devOptional": true, + "license": "ISC", + "peer": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jest-config/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "devOptional": true, + "license": "ISC", + "peer": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/jest-config/node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "devOptional": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-config/node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "devOptional": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/jest-diff": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "chalk": "^4.0.0", @@ -23518,6 +24729,38 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/jest-docblock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.7.0.tgz", + "integrity": "sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "detect-newline": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-each": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.7.0.tgz", + "integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "jest-util": "^29.7.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/jest-environment-jsdom": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-29.7.0.tgz", @@ -23546,6 +24789,358 @@ } } }, + "node_modules/jest-environment-node": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", + "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-expo": { + "version": "54.0.18", + "resolved": "https://registry.npmjs.org/jest-expo/-/jest-expo-54.0.18.tgz", + "integrity": "sha512-couPPatkifK3v0WZW7zu1HZW5HyxY+1cY69LKHPawwbZTuIvf5wk4SYHtoUrGa/nAM2jW/YiXDLWfociEHJPvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@expo/config": "~12.0.14", + "@expo/json-file": "^10.0.16", + "@jest/create-cache-key-function": "^29.2.1", + "@jest/globals": "^29.2.1", + "babel-jest": "^29.2.1", + "jest-environment-jsdom": "^29.2.1", + "jest-snapshot": "^29.2.1", + "jest-watch-select-projects": "^2.0.0", + "jest-watch-typeahead": "2.2.1", + "json5": "^2.2.3", + "lodash": "^4.17.19", + "react-test-renderer": "19.1.0", + "server-only": "^0.0.1", + "stacktrace-js": "^2.0.2" + }, + "bin": { + "jest": "bin/jest.js" + }, + "peerDependencies": { + "expo": "*", + "react-native": "*", + "react-server-dom-webpack": "~19.0.4 || ~19.1.5 || ~19.2.4" + }, + "peerDependenciesMeta": { + "react-server-dom-webpack": { + "optional": true + } + } + }, + "node_modules/jest-expo/node_modules/@expo/config": { + "version": "12.0.14", + "resolved": "https://registry.npmjs.org/@expo/config/-/config-12.0.14.tgz", + "integrity": "sha512-3dfbBd9LnPDgyylhCgkOsaG8Adg52uOVOTQYH5lf23a/t8M5eQpXKCvzUarrf62B78057n2NnkiofK9TfPgvzw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "~7.10.4", + "@expo/config-plugins": "~54.0.5", + "@expo/config-types": "^54.0.10", + "@expo/json-file": "^10.0.16", + "deepmerge": "^4.3.1", + "getenv": "^2.0.0", + "glob": "^13.0.0", + "require-from-string": "^2.0.2", + "resolve-from": "^5.0.0", + "resolve-workspace-root": "^2.0.0", + "semver": "^7.6.0", + "slugify": "^1.3.4", + "sucrase": "~3.35.1" + } + }, + "node_modules/jest-expo/node_modules/@expo/config-plugins": { + "version": "54.0.6", + "resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-54.0.6.tgz", + "integrity": "sha512-RKKhe3HSCTEmU2PBl4lu0BtV68TOz7qjPRXQBkBOUeYe83vIno1N63viDOiHIf7wxZ1ALxvsyEu3QWlCICnfWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@expo/config-types": "^54.0.10", + "@expo/json-file": "~10.0.16", + "@expo/plist": "^0.4.9", + "@expo/sdk-runtime-versions": "^1.0.0", + "chalk": "^4.1.2", + "debug": "^4.3.5", + "getenv": "^2.0.0", + "glob": "^13.0.0", + "resolve-from": "^5.0.0", + "semver": "^7.5.4", + "slash": "^3.0.0", + "slugify": "^1.6.6", + "xcode": "^4.0.0", + "xml2js": "0.6.0" + } + }, + "node_modules/jest-expo/node_modules/@expo/config-plugins/node_modules/@babel/code-frame": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.4.tgz", + "integrity": "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/highlight": "^7.10.4" + } + }, + "node_modules/jest-expo/node_modules/@expo/config-plugins/node_modules/@expo/json-file": { + "version": "10.0.16", + "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.0.16.tgz", + "integrity": "sha512-fcVkWEj+hLuP2yt5W0aw6LmDRqSPWDLUSxOMcmFeV+algmIF59sQVKCwB9btjQLd4V6x9N0pISkQEkBubUHrCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "~7.10.4", + "json5": "^2.2.3" + } + }, + "node_modules/jest-expo/node_modules/@expo/config-types": { + "version": "54.0.10", + "resolved": "https://registry.npmjs.org/@expo/config-types/-/config-types-54.0.10.tgz", + "integrity": "sha512-/J16SC2an1LdtCZ67xhSkGXpALYUVUNyZws7v+PVsFZxClYehDSoKLqyRaGkpHlYrCc08bS0RF5E0JV6g50psA==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-expo/node_modules/@expo/config/node_modules/@babel/code-frame": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.4.tgz", + "integrity": "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/highlight": "^7.10.4" + } + }, + "node_modules/jest-expo/node_modules/@expo/json-file": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.2.0.tgz", + "integrity": "sha512-S6XzKe3R9GQeHiUPXc3xJjOv2VJhOEwFYf7xdC2z2cUqt3kZJ9mSO877sNQloVdnW/SUCtPY3bexlM7nwq+CAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.20.0", + "json5": "^2.2.3" + } + }, + "node_modules/jest-expo/node_modules/@expo/plist": { + "version": "0.4.9", + "resolved": "https://registry.npmjs.org/@expo/plist/-/plist-0.4.9.tgz", + "integrity": "sha512-MPVpmKGfnQEnrCzgxuXcmPP/y/t6AVm+DcSb2Myp21LKWv1N3l8uFxMggesfF4ixAxkRlGmMMx9GyDC9M+XklQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.8.8", + "base64-js": "^1.2.3", + "xmlbuilder": "^15.1.1" + } + }, + "node_modules/jest-expo/node_modules/@xmldom/xmldom": { + "version": "0.8.15", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.15.tgz", + "integrity": "sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/jest-expo/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/jest-expo/node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/jest-expo/node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/jest-expo/node_modules/getenv": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/getenv/-/getenv-2.0.0.tgz", + "integrity": "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/jest-expo/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jest-expo/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/jest-expo/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jest-expo/node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jest-expo/node_modules/react-test-renderer": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-19.1.0.tgz", + "integrity": "sha512-jXkSl3CpvPYEF+p/eGDLB4sPoDX8pKkYvRl9+rR8HxLY0X04vW7hCm1/0zHoUSjPZ3bDa+wXWNTDVIw/R8aDVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "react-is": "^19.1.0", + "scheduler": "^0.26.0" + }, + "peerDependencies": { + "react": "^19.1.0" + } + }, + "node_modules/jest-expo/node_modules/scheduler": { + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", + "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-expo/node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-expo/node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/jest-expo/node_modules/xcode": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/xcode/-/xcode-4.0.0.tgz", + "integrity": "sha512-dDaoWzc9EP2fp9FS9PVZUkyvFxY4y1TOV9pcKaKLk++Ei/qb97XDFEVP7GnW7Osii/TzVABJ0jq2F+9/lq0TVA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "simple-plist": "^1.3.1" + }, + "engines": { + "node": ">=14.17.0" + } + }, + "node_modules/jest-expo/node_modules/xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0" + } + }, "node_modules/jest-get-type": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", @@ -23559,7 +25154,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.7.0.tgz", "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jest/types": "^29.6.3", @@ -23581,11 +25176,26 @@ "fsevents": "^2.3.2" } }, + "node_modules/jest-leak-detector": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz", + "integrity": "sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/jest-matcher-utils": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "chalk": "^4.0.0", @@ -23601,7 +25211,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.12.13", @@ -23622,7 +25232,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -23632,7 +25242,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jest/types": "^29.6.3", @@ -23643,21 +25253,251 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/jest-pnp-resolver": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", + "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", + "devOptional": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6" + }, + "peerDependencies": { + "jest-resolve": "*" + }, + "peerDependenciesMeta": { + "jest-resolve": { + "optional": true + } + } + }, "node_modules/jest-regex-util": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz", "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/jest-resolve": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-29.7.0.tgz", + "integrity": "sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-pnp-resolver": "^1.2.2", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "resolve": "^1.20.0", + "resolve.exports": "^2.0.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-resolve-dependencies": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-29.7.0.tgz", + "integrity": "sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "jest-regex-util": "^29.6.3", + "jest-snapshot": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-resolve/node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "devOptional": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-runner": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-29.7.0.tgz", + "integrity": "sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/environment": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "graceful-fs": "^4.2.9", + "jest-docblock": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-leak-detector": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-resolve": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-util": "^29.7.0", + "jest-watcher": "^29.7.0", + "jest-worker": "^29.7.0", + "p-limit": "^3.1.0", + "source-map-support": "0.5.13" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runner/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "devOptional": true, + "license": "BSD-3-Clause", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/jest-runner/node_modules/source-map-support": { + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", + "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/jest-runtime": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.7.0.tgz", + "integrity": "sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/globals": "^29.7.0", + "@jest/source-map": "^29.6.3", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "cjs-module-lexer": "^1.0.0", + "collect-v8-coverage": "^1.0.0", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0", + "strip-bom": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runtime/node_modules/brace-expansion": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/jest-runtime/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "devOptional": true, + "license": "ISC", + "peer": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jest-runtime/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "devOptional": true, + "license": "ISC", + "peer": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/jest-runtime/node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "devOptional": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-runtime/node_modules/strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "devOptional": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, "node_modules/jest-snapshot": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz", "integrity": "sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/core": "^7.11.6", @@ -23868,7 +25708,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-29.7.0.tgz", "integrity": "sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jest/test-result": "^29.7.0", @@ -23888,7 +25728,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=10" @@ -23898,7 +25738,7 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "char-regex": "^1.0.2", @@ -24172,7 +26012,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/json-schema": { @@ -24967,7 +26807,7 @@ "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/linkify-it": { @@ -27300,7 +29140,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6" @@ -27332,7 +29172,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=4" @@ -27823,7 +29663,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/natural-orderby": { @@ -28154,7 +29994,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -28173,31 +30013,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/noxcturnal": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/noxcturnal/-/noxcturnal-0.1.5.tgz", - "integrity": "sha512-X/HQXJP/xn7LVQ4z1E4La0NHSl5UHdtJPG6ky4r2/Xgb7k79nGDJgBXk7I5f1V2vInWjlSCM14oSB0cUxwZS/Q==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/code-frame": "^7.20.0", - "@oxc-project/types": "0.154.0" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@noxcturnal/noxcturnal-darwin-arm64": "0.1.5", - "@noxcturnal/noxcturnal-darwin-x64": "0.1.5", - "@noxcturnal/noxcturnal-linux-arm64-gnu": "0.1.5", - "@noxcturnal/noxcturnal-linux-arm64-musl": "0.1.5", - "@noxcturnal/noxcturnal-linux-x64-gnu": "0.1.5", - "@noxcturnal/noxcturnal-linux-x64-musl": "0.1.5", - "@noxcturnal/noxcturnal-win32-arm64-msvc": "0.1.5", - "@noxcturnal/noxcturnal-win32-x64-msvc": "0.1.5" - } - }, "node_modules/npm-package-arg": { "version": "11.0.3", "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-11.0.3.tgz", @@ -28231,6 +30046,20 @@ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "license": "ISC" }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/nth-check": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", @@ -28453,7 +30282,7 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "mimic-fn": "^2.1.0" @@ -28728,7 +30557,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "yocto-queue": "^0.1.0" @@ -28880,7 +30709,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.0.0", @@ -29015,7 +30844,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -29190,7 +31019,7 @@ "version": "4.0.7", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">= 6" @@ -29209,7 +31038,7 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "find-up": "^4.0.0" @@ -29222,7 +31051,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "locate-path": "^5.0.0", @@ -29236,7 +31065,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "p-locate": "^4.1.0" @@ -29249,7 +31078,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "p-try": "^2.0.0" @@ -29265,7 +31094,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "p-limit": "^2.2.0" @@ -30085,25 +31914,9 @@ } }, "node_modules/react-is": { - "version": "19.2.4", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz", - "integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==", - "license": "MIT" - }, - "node_modules/react-is-18": { - "name": "react-is", - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true, - "license": "MIT" - }, - "node_modules/react-is-19": { - "name": "react-is", - "version": "19.2.7", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.7.tgz", - "integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==", - "dev": true, + "version": "19.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.3.1.tgz", + "integrity": "sha512-RRieF6IyxQBkcK6pP8xOHprLTPW6QjRkbGdImtqNme+/hYVwCZkLQLdlZGOhUqXwkS9IEynaGvPJtv6XB/cz0Q==", "license": "MIT" }, "node_modules/react-markdown": { @@ -30651,6 +32464,21 @@ } } }, + "node_modules/react-test-renderer": { + "version": "19.3.1", + "resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-19.3.1.tgz", + "integrity": "sha512-YcNewwmnZMd0wTb2nkb4hQ58NmX/u1hW2OJsNpen8CnV+jNkmEjYd1R6wJrOMSgb8bIF5HAJv4iL+S3PwNptOA==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "react-is": "^19.3.1", + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.1" + } + }, "node_modules/read-binary-file-arch": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz", @@ -31181,6 +33009,20 @@ "dev": true, "license": "MIT" }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/resolve-from": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", @@ -31206,6 +33048,17 @@ "integrity": "sha512-nR23LHAvaI6aHtMg6RWoaHpdR4D881Nydkzi2CixINyg9T00KgaJdJI6Vwty+Ps8WLxZHuxsS0BseWjxSA4C+w==", "license": "MIT" }, + "node_modules/resolve.exports": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.4.tgz", + "integrity": "sha512-zepuGL0ulf+nt+XaFrlKeaiTJeyPFPLLY62+ncq1/VTHBdt3HsfXOH60priZ6tnXQozdGcDeIF5JVTanvIcYWQ==", + "devOptional": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + } + }, "node_modules/responselike": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz", @@ -31583,11 +33436,12 @@ } }, "node_modules/scheduler": { - "version": "0.26.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", - "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", - "dev": true, - "license": "MIT" + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "devOptional": true, + "license": "MIT", + "peer": true }, "node_modules/secure-json-parse": { "version": "4.1.0", @@ -32450,7 +34304,7 @@ "version": "2.0.6", "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "escape-string-regexp": "^2.0.0" @@ -32463,7 +34317,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -32863,6 +34717,17 @@ "node": ">=4" } }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "devOptional": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6" + } + }, "node_modules/strip-indent": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-4.1.1.tgz", @@ -33047,13 +34912,14 @@ "license": "MIT" }, "node_modules/synckit": { - "version": "0.11.12", - "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.12.tgz", - "integrity": "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==", + "version": "0.12.3", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.12.3.tgz", + "integrity": "sha512-U+/AXT6LWsxnj2vz9HeUuPWBNgdWxjyIEjEPdbJARC9txmgPzo2VEdqnFo4hHaNyMPtycyVJrSaQYeQv/PoyyA==", "dev": true, "license": "MIT", "dependencies": { - "@pkgr/core": "^0.2.9" + "@dual-bundle/import-meta-resolve": "^4.2.1", + "@pkgr/core": "^0.3.6" }, "engines": { "node": "^14.18.0 || >=16.0.0" @@ -33228,7 +35094,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "@istanbuljs/schema": "^0.1.2", @@ -33243,7 +35109,7 @@ "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -33255,7 +35121,7 @@ "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "fs.realpath": "^1.0.0", @@ -33276,7 +35142,7 @@ "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -33579,6 +35445,19 @@ "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", "license": "MIT" }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, "node_modules/ts-declaration-location": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/ts-declaration-location/-/ts-declaration-location-1.0.7.tgz", @@ -33868,7 +35747,7 @@ "version": "4.0.8", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=4" @@ -34517,6 +36396,22 @@ "dev": true, "license": "MIT" }, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "devOptional": true, + "license": "ISC", + "peer": true, + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, "node_modules/validate-npm-package-license": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", @@ -35517,7 +37412,7 @@ "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=10" @@ -35695,7 +37590,7 @@ }, "packages/app": { "name": "@getpaseo/app", - "version": "0.13.2", + "version": "0.14.0-beta.1", "dependencies": { "@codemirror/commands": "6.10.4", "@codemirror/language": "6.12.4", @@ -35730,8 +37625,8 @@ "@xterm/addon-search": "^0.17.0-beta.213", "@xterm/addon-unicode11": "^0.10.0-beta.213", "@xterm/addon-web-links": "^0.13.0-beta.213", - "@xterm/addon-webgl": "^0.20.0-beta.212", - "@xterm/xterm": "^6.1.0-beta.213", + "@xterm/addon-webgl": "0.20.0-beta.291", + "@xterm/xterm": "6.1.0-beta.292", "buffer": "^6.0.3", "expo": "~57.0.30", "expo-asset": "~57.0.20", @@ -36640,12 +38535,12 @@ } }, "packages/app/node_modules/@xterm/addon-webgl": { - "version": "0.20.0-beta.212", - "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.212.tgz", - "integrity": "sha512-RzaTYeukTXTJth+Sl7FzCmb8AqiyDrNWNnqG+/300qJbfHYfCMmBlBVZ0yCTSZIWMdgFbgkhmnmBN7Yzs6xoBA==", + "version": "0.20.0-beta.291", + "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.291.tgz", + "integrity": "sha512-gH7r5g8f2NWMhBtaeApuwLQTJmX4uq7zT0okjeNWkFAD0nxzbUPB+kt6sogZZ5K90XYPt2OLu/bhuvF6DmKqOQ==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.213" + "@xterm/xterm": "^6.1.0-beta.292" } }, "packages/app/node_modules/@xterm/headless": { @@ -36659,9 +38554,9 @@ ] }, "packages/app/node_modules/@xterm/xterm": { - "version": "6.1.0-beta.213", - "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.213.tgz", - "integrity": "sha512-bq5nJkgoZDSg6Ma4zgiQ7jlooGNo7U+HDa6vnhx43yXsVEK2laIF7R08bMYAuU0B+C9xX0gwSrcsW18GxjUoJQ==", + "version": "6.1.0-beta.292", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.292.tgz", + "integrity": "sha512-17zqK5tM/l6qeD7McF42OrEJ6w3XqJ2vFVKdWqu0cYLzdFqMWAHp2oFNc8Fj5DmqDSl1E1FZEg6IFflDllTvLA==", "license": "MIT", "workspaces": [ "addons/*" @@ -37103,43 +38998,6 @@ "typescript": ">=4.8.4 <6.1.0" } }, - "packages/app/node_modules/eslint-config-expo/node_modules/eslint-plugin-react-hooks": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", - "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.24.4", - "@babel/parser": "^7.24.4", - "hermes-parser": "^0.25.1", - "zod": "^3.25.0 || ^4.0.0", - "zod-validation-error": "^3.5.0 || ^4.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" - } - }, - "packages/app/node_modules/eslint-config-expo/node_modules/hermes-estree": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", - "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", - "dev": true, - "license": "MIT" - }, - "packages/app/node_modules/eslint-config-expo/node_modules/hermes-parser": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", - "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", - "dev": true, - "license": "MIT", - "dependencies": { - "hermes-estree": "0.25.1" - } - }, "packages/app/node_modules/eslint-config-expo/node_modules/ignore": { "version": "7.0.12", "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.12.tgz", @@ -37150,19 +39008,6 @@ "node": ">= 4" } }, - "packages/app/node_modules/eslint-config-expo/node_modules/ts-api-utils": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", - "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.12" - }, - "peerDependencies": { - "typescript": ">=4.8.4" - } - }, "packages/app/node_modules/eslint-plugin-expo": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/eslint-plugin-expo/-/eslint-plugin-expo-1.2.1.tgz", @@ -37272,19 +39117,6 @@ "typescript": ">=4.8.4 <6.1.0" } }, - "packages/app/node_modules/eslint-plugin-expo/node_modules/ts-api-utils": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", - "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.12" - }, - "peerDependencies": { - "typescript": ">=4.8.4" - } - }, "packages/app/node_modules/eslint-visitor-keys": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", @@ -37961,12 +39793,12 @@ }, "packages/cli": { "name": "@getpaseo/cli", - "version": "0.13.2", + "version": "0.14.0-beta.1", "dependencies": { "@clack/prompts": "^1.0.0", - "@getpaseo/client": "0.13.2", - "@getpaseo/protocol": "0.13.2", - "@getpaseo/server": "0.13.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", @@ -38213,10 +40045,10 @@ }, "packages/client": { "name": "@getpaseo/client", - "version": "0.13.2", + "version": "0.14.0-beta.1", "dependencies": { - "@getpaseo/protocol": "0.13.2", - "@getpaseo/relay": "0.13.2", + "@getpaseo/protocol": "0.14.0-beta.1", + "@getpaseo/relay": "0.14.0-beta.1", "ws": "^8.20.0", "zod": "^4.4.3" }, @@ -38228,7 +40060,7 @@ }, "packages/desktop": { "name": "@getpaseo/desktop", - "version": "0.13.2", + "version": "0.14.0-beta.1", "license": "Apache-2.0", "dependencies": { "@getpaseo/cli": "*", @@ -38472,12 +40304,11 @@ }, "packages/expo-two-way-audio": { "name": "@getpaseo/expo-two-way-audio", - "version": "0.13.2", + "version": "0.14.0-beta.1", "license": "MIT", "devDependencies": { "@types/jest": "^29.5.14", "@types/react": "^18.0.25", - "expo-module-scripts": "^5.0.8", "expo-modules-core": "^2.0.4" }, "peerDependencies": { @@ -38486,1234 +40317,6 @@ "react-native": "*" } }, - "packages/expo-two-way-audio/node_modules/@expo/code-signing-certificates": { - "version": "0.0.7", - "resolved": "https://registry.npmjs.org/@expo/code-signing-certificates/-/code-signing-certificates-0.0.7.tgz", - "integrity": "sha512-zP0tnfqrFFRL8INtOaQ6XwEYfdIpIH9vrEo7y0bHQGQa50z4/QWXE5CI4nRUHxqFW4MVWOwXFg1hom1aAZHNkQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "node-forge": "^1.4.0" - }, - "engines": { - "node": ">=22.13.0" - } - }, - "packages/expo-two-way-audio/node_modules/@expo/config": { - "version": "12.0.13", - "resolved": "https://registry.npmjs.org/@expo/config/-/config-12.0.13.tgz", - "integrity": "sha512-Cu52arBa4vSaupIWsF0h7F/Cg//N374nYb7HAxV0I4KceKA7x2UXpYaHOL7EEYYvp7tZdThBjvGpVmr8ScIvaQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "~7.10.4", - "@expo/config-plugins": "~54.0.4", - "@expo/config-types": "^54.0.10", - "@expo/json-file": "^10.0.8", - "deepmerge": "^4.3.1", - "getenv": "^2.0.0", - "glob": "^13.0.0", - "require-from-string": "^2.0.2", - "resolve-from": "^5.0.0", - "resolve-workspace-root": "^2.0.0", - "semver": "^7.6.0", - "slugify": "^1.3.4", - "sucrase": "~3.35.1" - } - }, - "packages/expo-two-way-audio/node_modules/@expo/config-plugins": { - "version": "54.0.4", - "resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-54.0.4.tgz", - "integrity": "sha512-g2yXGICdoOw5i3LkQSDxl2Q5AlQCrG7oniu0pCPPO+UxGb7He4AFqSvPSy8HpRUj55io17hT62FTjYRD+d6j3Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@expo/config-types": "^54.0.10", - "@expo/json-file": "~10.0.8", - "@expo/plist": "^0.4.8", - "@expo/sdk-runtime-versions": "^1.0.0", - "chalk": "^4.1.2", - "debug": "^4.3.5", - "getenv": "^2.0.0", - "glob": "^13.0.0", - "resolve-from": "^5.0.0", - "semver": "^7.5.4", - "slash": "^3.0.0", - "slugify": "^1.6.6", - "xcode": "^3.0.1", - "xml2js": "0.6.0" - } - }, - "packages/expo-two-way-audio/node_modules/@expo/config-plugins/node_modules/@babel/code-frame": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.4.tgz", - "integrity": "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/highlight": "^7.10.4" - } - }, - "packages/expo-two-way-audio/node_modules/@expo/config-plugins/node_modules/@expo/json-file": { - "version": "10.0.16", - "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.0.16.tgz", - "integrity": "sha512-fcVkWEj+hLuP2yt5W0aw6LmDRqSPWDLUSxOMcmFeV+algmIF59sQVKCwB9btjQLd4V6x9N0pISkQEkBubUHrCw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "~7.10.4", - "json5": "^2.2.3" - } - }, - "packages/expo-two-way-audio/node_modules/@expo/config-types": { - "version": "54.0.10", - "resolved": "https://registry.npmjs.org/@expo/config-types/-/config-types-54.0.10.tgz", - "integrity": "sha512-/J16SC2an1LdtCZ67xhSkGXpALYUVUNyZws7v+PVsFZxClYehDSoKLqyRaGkpHlYrCc08bS0RF5E0JV6g50psA==", - "dev": true, - "license": "MIT" - }, - "packages/expo-two-way-audio/node_modules/@expo/config/node_modules/@babel/code-frame": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.4.tgz", - "integrity": "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/highlight": "^7.10.4" - } - }, - "packages/expo-two-way-audio/node_modules/@expo/devtools": { - "version": "58.0.2", - "resolved": "https://registry.npmjs.org/@expo/devtools/-/devtools-58.0.2.tgz", - "integrity": "sha512-WBj/K+vv8JPSmY70ennL04YYOHkpEsGGkfuVY9lfv4XO7Qhbt5qz4q6W7maME194I5oGcimRMigUFfWNkBe90w==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "chalk": "^4.1.2" - }, - "peerDependencies": { - "expo": "*", - "react": "*", - "react-native": "*" - }, - "peerDependenciesMeta": { - "expo": { - "optional": true - }, - "react": { - "optional": true - }, - "react-native": { - "optional": true - } - } - }, - "packages/expo-two-way-audio/node_modules/@expo/dom-webview": { - "version": "58.0.2", - "resolved": "https://registry.npmjs.org/@expo/dom-webview/-/dom-webview-58.0.2.tgz", - "integrity": "sha512-bLkREoP7qL3rEuzxP60xG+TjdPoGjbnZfxkvYo5imJxGWfKMOf7x962W06Pu+WWAr4gcDRk16iA+Clg8D0zjCg==", - "dev": true, - "license": "MIT", - "peer": true, - "peerDependencies": { - "expo": "*", - "react": "*", - "react-native": "*" - } - }, - "packages/expo-two-way-audio/node_modules/@expo/env": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/@expo/env/-/env-2.5.2.tgz", - "integrity": "sha512-z8kCvBVJpd2S20a+Ab1otQgz8sJLeR7CXDTsuAMcNbYFsK10Fdhfb7rx9Ys1vvlbijnY55eZhQvJACajc1IjBQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "chalk": "^4.0.0", - "debug": "^4.3.4", - "getenv": "^2.0.0" - }, - "engines": { - "node": ">=20.12.0" - } - }, - "packages/expo-two-way-audio/node_modules/@expo/fingerprint": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@expo/fingerprint/-/fingerprint-0.21.5.tgz", - "integrity": "sha512-4ngc+aPgMK56sYc47tfabmoaNdP7OvPBOH7QPgN3riSYM0Ev+KwsGRDius5jBBwjCI9ADp7IpjJEK9xlRYjjvA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@expo/env": "^2.5.2", - "@expo/spawn-async": "^1.8.0", - "arg": "^5.0.2", - "chalk": "^4.1.2", - "debug": "^4.3.4", - "getenv": "^2.0.0", - "glob": "^13.0.0", - "ignore": "^5.3.1", - "minimatch": "^10.2.2", - "resolve-from": "^5.0.0", - "semver": "^7.7.4" - }, - "bin": { - "fingerprint": "bin/cli.js" - } - }, - "packages/expo-two-way-audio/node_modules/@expo/fingerprint/node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 4" - } - }, - "packages/expo-two-way-audio/node_modules/@expo/image-utils": { - "version": "0.12.5", - "resolved": "https://registry.npmjs.org/@expo/image-utils/-/image-utils-0.12.5.tgz", - "integrity": "sha512-nsFYd7GfDdwZa+S+p+EkVCkh7UKKNf0kfUXDQ53HbyMmNFD3psb5JYwU/2N+uZG5wOEfKGfQ5u7pk/wn2gMlBA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@expo/require-utils": "^58.0.4", - "@expo/spawn-async": "^1.8.0", - "chalk": "^4.0.0", - "getenv": "^2.0.0", - "jimp-compact": "0.16.1", - "parse-png": "^2.1.0", - "semver": "^7.7.4" - } - }, - "packages/expo-two-way-audio/node_modules/@expo/image-utils/node_modules/@expo/require-utils": { - "version": "58.0.4", - "resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-58.0.4.tgz", - "integrity": "sha512-DvjS5NCitXYcad2Woe1EMHJKcbhTbE77HX0BgOQGL2LFHE3q2H0n8kCWDPBOCVJ63Drap3c35nwoJwF5TQL7Tw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/code-frame": "^7.20.0", - "@babel/core": "^7.25.2", - "@babel/plugin-transform-modules-commonjs": "^7.24.8" - }, - "peerDependencies": { - "typescript": "^5.0.0 || ^5.0.0-0 || ^6.0.0 || ^7.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "packages/expo-two-way-audio/node_modules/@expo/inline-modules": { - "version": "0.2.6", - "resolved": "https://registry.npmjs.org/@expo/inline-modules/-/inline-modules-0.2.6.tgz", - "integrity": "sha512-S8PK3wx6ogwunoQ7pqdPbklQGtXloeWTAOzWJ9yIL7xHFxvnI/Ryj0phA3KEGCMlaKZjaCP1h/3s7n/ueErRgQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@expo/config-plugins": "~58.0.9" - } - }, - "packages/expo-two-way-audio/node_modules/@expo/inline-modules/node_modules/@expo/config-plugins": { - "version": "58.0.9", - "resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-58.0.9.tgz", - "integrity": "sha512-HyP7V1QgDxeHcOEh4qQH7cfK7cI7wNp0y9lszAu98SRYCshhDbwB9gWpFfnpX1GQUtgiqG4Hvgh1z3ntcGwAjw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@expo/config-types": "^58.0.3", - "@expo/json-file": "~11.2.1", - "@expo/plist": "^0.10.1", - "@expo/require-utils": "^58.0.4", - "@expo/sdk-runtime-versions": "^1.0.1", - "chalk": "^4.1.2", - "debug": "^4.3.5", - "getenv": "^2.0.0", - "glob": "^13.0.0", - "semver": "^7.7.4", - "slugify": "^1.6.6", - "xcode": "^3.0.1", - "xml2js": "0.6.0" - } - }, - "packages/expo-two-way-audio/node_modules/@expo/inline-modules/node_modules/@expo/config-types": { - "version": "58.0.3", - "resolved": "https://registry.npmjs.org/@expo/config-types/-/config-types-58.0.3.tgz", - "integrity": "sha512-wyfrwkTT/hQHzwyqkXkngKxoOa8L6ovsTP4h/UukS1mTmssee5et6HOPoN5RtawdJPnm5JO28f//C/wXlpka8Q==", - "dev": true, - "license": "MIT", - "peer": true - }, - "packages/expo-two-way-audio/node_modules/@expo/inline-modules/node_modules/@expo/json-file": { - "version": "11.2.1", - "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-11.2.1.tgz", - "integrity": "sha512-UPhKoFCj8BcwgLB1rKQ1CuR/REITnZLiqxHEHOeH1tPsdXcHGtUDh0P5bLbr7oU0nKVdtJ5rzyKx8jniMQdESQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/code-frame": "^7.20.0", - "json5": "^2.2.3" - } - }, - "packages/expo-two-way-audio/node_modules/@expo/inline-modules/node_modules/@expo/plist": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@expo/plist/-/plist-0.10.1.tgz", - "integrity": "sha512-nuNOqj+r/hGJbZ1Ax3jUpZaOB8UAMHUrISALJEUKaCieTflYj7224EqFWmMpriCGk9wXS1gH+9GGVnSj0tWzuQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@xmldom/xmldom": "^0.8.8", - "base64-js": "^1.5.1", - "xmlbuilder": "^15.1.1" - } - }, - "packages/expo-two-way-audio/node_modules/@expo/inline-modules/node_modules/@expo/require-utils": { - "version": "58.0.4", - "resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-58.0.4.tgz", - "integrity": "sha512-DvjS5NCitXYcad2Woe1EMHJKcbhTbE77HX0BgOQGL2LFHE3q2H0n8kCWDPBOCVJ63Drap3c35nwoJwF5TQL7Tw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/code-frame": "^7.20.0", - "@babel/core": "^7.25.2", - "@babel/plugin-transform-modules-commonjs": "^7.24.8" - }, - "peerDependencies": { - "typescript": "^5.0.0 || ^5.0.0-0 || ^6.0.0 || ^7.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "packages/expo-two-way-audio/node_modules/@expo/json-file": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.2.0.tgz", - "integrity": "sha512-S6XzKe3R9GQeHiUPXc3xJjOv2VJhOEwFYf7xdC2z2cUqt3kZJ9mSO877sNQloVdnW/SUCtPY3bexlM7nwq+CAQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.20.0", - "json5": "^2.2.3" - } - }, - "packages/expo-two-way-audio/node_modules/@expo/local-build-cache-provider": { - "version": "58.0.6", - "resolved": "https://registry.npmjs.org/@expo/local-build-cache-provider/-/local-build-cache-provider-58.0.6.tgz", - "integrity": "sha512-AJk3pslFNlwRMbUbfvxhu6CPA9U7A+JPb88VS/uWkG/S/QMJL90Iw4sPVfG4BOKfRUNkB8ipl432Dr4txjSMKw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@expo/config": "~58.0.6", - "chalk": "^4.1.2" - } - }, - "packages/expo-two-way-audio/node_modules/@expo/local-build-cache-provider/node_modules/@expo/config": { - "version": "58.0.6", - "resolved": "https://registry.npmjs.org/@expo/config/-/config-58.0.6.tgz", - "integrity": "sha512-PT1qv4s/R5A/NxXb4efGlFsYF5Consi7F8xSsJBAN090EGDr6U1EDQGOijFqzDc3JA2epD8zjKLJLOVB9YOewg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@expo/config-plugins": "~58.0.9", - "@expo/config-types": "^58.0.3", - "@expo/json-file": "^11.2.1", - "@expo/platforms": "^58.0.1", - "@expo/require-utils": "^58.0.4", - "deepmerge": "^4.3.1", - "getenv": "^2.0.0", - "glob": "^13.0.0", - "resolve-workspace-root": "^2.0.0", - "semver": "^7.7.4", - "slugify": "^1.3.4" - } - }, - "packages/expo-two-way-audio/node_modules/@expo/local-build-cache-provider/node_modules/@expo/config-plugins": { - "version": "58.0.9", - "resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-58.0.9.tgz", - "integrity": "sha512-HyP7V1QgDxeHcOEh4qQH7cfK7cI7wNp0y9lszAu98SRYCshhDbwB9gWpFfnpX1GQUtgiqG4Hvgh1z3ntcGwAjw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@expo/config-types": "^58.0.3", - "@expo/json-file": "~11.2.1", - "@expo/plist": "^0.10.1", - "@expo/require-utils": "^58.0.4", - "@expo/sdk-runtime-versions": "^1.0.1", - "chalk": "^4.1.2", - "debug": "^4.3.5", - "getenv": "^2.0.0", - "glob": "^13.0.0", - "semver": "^7.7.4", - "slugify": "^1.6.6", - "xcode": "^3.0.1", - "xml2js": "0.6.0" - } - }, - "packages/expo-two-way-audio/node_modules/@expo/local-build-cache-provider/node_modules/@expo/config-types": { - "version": "58.0.3", - "resolved": "https://registry.npmjs.org/@expo/config-types/-/config-types-58.0.3.tgz", - "integrity": "sha512-wyfrwkTT/hQHzwyqkXkngKxoOa8L6ovsTP4h/UukS1mTmssee5et6HOPoN5RtawdJPnm5JO28f//C/wXlpka8Q==", - "dev": true, - "license": "MIT", - "peer": true - }, - "packages/expo-two-way-audio/node_modules/@expo/local-build-cache-provider/node_modules/@expo/json-file": { - "version": "11.2.1", - "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-11.2.1.tgz", - "integrity": "sha512-UPhKoFCj8BcwgLB1rKQ1CuR/REITnZLiqxHEHOeH1tPsdXcHGtUDh0P5bLbr7oU0nKVdtJ5rzyKx8jniMQdESQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/code-frame": "^7.20.0", - "json5": "^2.2.3" - } - }, - "packages/expo-two-way-audio/node_modules/@expo/local-build-cache-provider/node_modules/@expo/plist": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@expo/plist/-/plist-0.10.1.tgz", - "integrity": "sha512-nuNOqj+r/hGJbZ1Ax3jUpZaOB8UAMHUrISALJEUKaCieTflYj7224EqFWmMpriCGk9wXS1gH+9GGVnSj0tWzuQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@xmldom/xmldom": "^0.8.8", - "base64-js": "^1.5.1", - "xmlbuilder": "^15.1.1" - } - }, - "packages/expo-two-way-audio/node_modules/@expo/local-build-cache-provider/node_modules/@expo/require-utils": { - "version": "58.0.4", - "resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-58.0.4.tgz", - "integrity": "sha512-DvjS5NCitXYcad2Woe1EMHJKcbhTbE77HX0BgOQGL2LFHE3q2H0n8kCWDPBOCVJ63Drap3c35nwoJwF5TQL7Tw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/code-frame": "^7.20.0", - "@babel/core": "^7.25.2", - "@babel/plugin-transform-modules-commonjs": "^7.24.8" - }, - "peerDependencies": { - "typescript": "^5.0.0 || ^5.0.0-0 || ^6.0.0 || ^7.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "packages/expo-two-way-audio/node_modules/@expo/log-box": { - "version": "58.0.12", - "resolved": "https://registry.npmjs.org/@expo/log-box/-/log-box-58.0.12.tgz", - "integrity": "sha512-IzDYoFY2iUsmSDvl1V7YUM7UkCPDyMVS4e4a38c2LnFc1A3WGMUiQh+vsHLYTPKjIelu4r7/u4V7Gxtfa0oJgA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@expo/dom-webview": "^58.0.2", - "@expo/log-box-utils": "^58.0.2", - "anser": "^1.4.9", - "stacktrace-parser": "^0.1.10" - }, - "peerDependencies": { - "@expo/dom-webview": "^58.0.2", - "expo": "*", - "react": "*", - "react-native": "*" - }, - "peerDependenciesMeta": { - "expo": { - "optional": true - } - } - }, - "packages/expo-two-way-audio/node_modules/@expo/metro": { - "version": "58.0.0", - "resolved": "https://registry.npmjs.org/@expo/metro/-/metro-58.0.0.tgz", - "integrity": "sha512-PMD640ihB0C3Oqm0AnDVJ/8qF2cKbRUEqGuSOr4tM/OTb9ktxsQqsiSSSeaOEgdCZBq20AxRS0d6WHNhhH2P+g==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "metro": "0.87.1", - "metro-babel-transformer": "0.87.1", - "metro-cache": "0.87.1", - "metro-cache-key": "0.87.1", - "metro-config": "0.87.1", - "metro-core": "0.87.1", - "metro-file-map": "0.87.1", - "metro-minify-terser": "0.87.1", - "metro-resolver": "0.87.1", - "metro-runtime": "0.87.1", - "metro-source-map": "0.87.1", - "metro-symbolicate": "0.87.1", - "metro-transform-plugins": "0.87.1", - "metro-transform-worker": "0.87.1" - } - }, - "packages/expo-two-way-audio/node_modules/@expo/metro-config": { - "version": "58.0.14", - "resolved": "https://registry.npmjs.org/@expo/metro-config/-/metro-config-58.0.14.tgz", - "integrity": "sha512-UDUMAJwYGEzaB26ejMLG/IOVzwKDbHzoODBf3FZeRS94kk9jDf1dWE1VMX6bLBLoiflxXwqrMWiH0Fvz3gZ9YQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/code-frame": "^7.20.0", - "@babel/core": "^7.20.0", - "@babel/generator": "^7.20.5", - "@expo/config": "~58.0.6", - "@expo/env": "~2.5.2", - "@expo/json-file": "~11.2.1", - "@expo/metro": "~58.0.0", - "@expo/platforms": "^58.0.1", - "@expo/require-utils": "^58.0.4", - "@expo/spawn-async": "^1.8.0", - "@jridgewell/gen-mapping": "^0.3.13", - "@jridgewell/remapping": "^2.3.5", - "@jridgewell/sourcemap-codec": "^1.5.5", - "@jridgewell/trace-mapping": "^0.3.31", - "@react-native/js-polyfills": "0.88.0", - "2g": "^1.0.1", - "browserslist": "^4.25.0", - "chalk": "^4.1.0", - "debug": "^4.3.2", - "getenv": "^2.0.0", - "glob": "^13.0.0", - "hermes-parser": "^0.36.0", - "jsc-safe-url": "^0.2.4", - "lightningcss": "^1.30.1", - "noxcturnal": "^0.1.0", - "picomatch": "^4.0.4", - "postcss": "^8.5.23", - "resolve-from": "^5.0.0" - }, - "peerDependencies": { - "expo": "*" - }, - "peerDependenciesMeta": { - "expo": { - "optional": true - } - } - }, - "packages/expo-two-way-audio/node_modules/@expo/metro-config/node_modules/@expo/config": { - "version": "58.0.6", - "resolved": "https://registry.npmjs.org/@expo/config/-/config-58.0.6.tgz", - "integrity": "sha512-PT1qv4s/R5A/NxXb4efGlFsYF5Consi7F8xSsJBAN090EGDr6U1EDQGOijFqzDc3JA2epD8zjKLJLOVB9YOewg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@expo/config-plugins": "~58.0.9", - "@expo/config-types": "^58.0.3", - "@expo/json-file": "^11.2.1", - "@expo/platforms": "^58.0.1", - "@expo/require-utils": "^58.0.4", - "deepmerge": "^4.3.1", - "getenv": "^2.0.0", - "glob": "^13.0.0", - "resolve-workspace-root": "^2.0.0", - "semver": "^7.7.4", - "slugify": "^1.3.4" - } - }, - "packages/expo-two-way-audio/node_modules/@expo/metro-config/node_modules/@expo/config-plugins": { - "version": "58.0.9", - "resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-58.0.9.tgz", - "integrity": "sha512-HyP7V1QgDxeHcOEh4qQH7cfK7cI7wNp0y9lszAu98SRYCshhDbwB9gWpFfnpX1GQUtgiqG4Hvgh1z3ntcGwAjw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@expo/config-types": "^58.0.3", - "@expo/json-file": "~11.2.1", - "@expo/plist": "^0.10.1", - "@expo/require-utils": "^58.0.4", - "@expo/sdk-runtime-versions": "^1.0.1", - "chalk": "^4.1.2", - "debug": "^4.3.5", - "getenv": "^2.0.0", - "glob": "^13.0.0", - "semver": "^7.7.4", - "slugify": "^1.6.6", - "xcode": "^3.0.1", - "xml2js": "0.6.0" - } - }, - "packages/expo-two-way-audio/node_modules/@expo/metro-config/node_modules/@expo/config-types": { - "version": "58.0.3", - "resolved": "https://registry.npmjs.org/@expo/config-types/-/config-types-58.0.3.tgz", - "integrity": "sha512-wyfrwkTT/hQHzwyqkXkngKxoOa8L6ovsTP4h/UukS1mTmssee5et6HOPoN5RtawdJPnm5JO28f//C/wXlpka8Q==", - "dev": true, - "license": "MIT", - "peer": true - }, - "packages/expo-two-way-audio/node_modules/@expo/metro-config/node_modules/@expo/json-file": { - "version": "11.2.1", - "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-11.2.1.tgz", - "integrity": "sha512-UPhKoFCj8BcwgLB1rKQ1CuR/REITnZLiqxHEHOeH1tPsdXcHGtUDh0P5bLbr7oU0nKVdtJ5rzyKx8jniMQdESQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/code-frame": "^7.20.0", - "json5": "^2.2.3" - } - }, - "packages/expo-two-way-audio/node_modules/@expo/metro-config/node_modules/@expo/plist": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@expo/plist/-/plist-0.10.1.tgz", - "integrity": "sha512-nuNOqj+r/hGJbZ1Ax3jUpZaOB8UAMHUrISALJEUKaCieTflYj7224EqFWmMpriCGk9wXS1gH+9GGVnSj0tWzuQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@xmldom/xmldom": "^0.8.8", - "base64-js": "^1.5.1", - "xmlbuilder": "^15.1.1" - } - }, - "packages/expo-two-way-audio/node_modules/@expo/metro-config/node_modules/@expo/require-utils": { - "version": "58.0.4", - "resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-58.0.4.tgz", - "integrity": "sha512-DvjS5NCitXYcad2Woe1EMHJKcbhTbE77HX0BgOQGL2LFHE3q2H0n8kCWDPBOCVJ63Drap3c35nwoJwF5TQL7Tw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/code-frame": "^7.20.0", - "@babel/core": "^7.25.2", - "@babel/plugin-transform-modules-commonjs": "^7.24.8" - }, - "peerDependencies": { - "typescript": "^5.0.0 || ^5.0.0-0 || ^6.0.0 || ^7.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "packages/expo-two-way-audio/node_modules/@expo/metro-config/node_modules/2g": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/2g/-/2g-1.0.1.tgz", - "integrity": "sha512-+uViadKOS/wwR4pFu5g8TPn3QHpKqoX19JHCnp8k/3cLpImdcXozwvZA6Q/5PeW3AcU8neL8faaMidBcv7o8CA==", - "dev": true, - "license": "MIT", - "peer": true, - "bin": { - "2g": "bin/cli.js" - }, - "peerDependencies": { - "typescript": ">=5.8.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "packages/expo-two-way-audio/node_modules/@expo/metro-config/node_modules/hermes-estree": { - "version": "0.36.1", - "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.36.1.tgz", - "integrity": "sha512-guv1nQ6IJ7S83NRFPWc3SA7IBZrdNC9kapwOq6uXvF4wP+sDCgjzQbKPCoyYmoyZRzztF/n/c36l/rccCZSiCw==", - "dev": true, - "license": "MIT", - "peer": true - }, - "packages/expo-two-way-audio/node_modules/@expo/metro-config/node_modules/hermes-parser": { - "version": "0.36.1", - "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.36.1.tgz", - "integrity": "sha512-GApNk4zLHi2UWoWZZkx7LNCOSzLSc5lB55pZ/PhK7ycFeg7u5LcF88p/WbpIi1XUDtE0MpHE3uRR3u3KB7TjSQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "hermes-estree": "0.36.1" - } - }, - "packages/expo-two-way-audio/node_modules/@expo/metro-file-map": { - "version": "58.0.5", - "resolved": "https://registry.npmjs.org/@expo/metro-file-map/-/metro-file-map-58.0.5.tgz", - "integrity": "sha512-Y2w8qKklLffZie28In4WF6f6e8nIJOhU0mZ5UaQdCiITVQm2a5teNdIDudEPkBsh8ItlvyHZt9n42PN2onRbkQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "2g": "^1.0.1", - "debug": "^4.3.4", - "fb-watchman": "^2.0.2", - "invariant": "^2.2.4", - "jest-worker": "^29.7.0", - "walker": "^1.0.8" - } - }, - "packages/expo-two-way-audio/node_modules/@expo/metro-file-map/node_modules/2g": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/2g/-/2g-1.0.1.tgz", - "integrity": "sha512-+uViadKOS/wwR4pFu5g8TPn3QHpKqoX19JHCnp8k/3cLpImdcXozwvZA6Q/5PeW3AcU8neL8faaMidBcv7o8CA==", - "dev": true, - "license": "MIT", - "peer": true, - "bin": { - "2g": "bin/cli.js" - }, - "peerDependencies": { - "typescript": ">=5.8.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "packages/expo-two-way-audio/node_modules/@expo/osascript": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/@expo/osascript/-/osascript-2.8.1.tgz", - "integrity": "sha512-SCi+6q6uOkGhiAn6AIP/OPFgaIq/iKD2oYpnAOg2rShHStJ8ey7wyT127RbOaZXmlcY/62rHOX1WiiMnD8u/BA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@expo/spawn-async": "^1.8.0" - }, - "engines": { - "node": ">=12" - } - }, - "packages/expo-two-way-audio/node_modules/@expo/package-manager": { - "version": "1.14.2", - "resolved": "https://registry.npmjs.org/@expo/package-manager/-/package-manager-1.14.2.tgz", - "integrity": "sha512-sftl0eRVVCarI6grMdjAB3jX2pX1Mt3cdSnajlXS4QyeDkaKRM/ntBy6dcagj1VL0+cEfEqctlyvinmlZdNubg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@expo/json-file": "^11.2.1", - "@expo/spawn-async": "^1.8.0", - "chalk": "^4.0.0", - "npm-package-arg": "^11.0.0", - "ora": "^3.4.0", - "resolve-workspace-root": "^2.0.0" - } - }, - "packages/expo-two-way-audio/node_modules/@expo/package-manager/node_modules/@expo/json-file": { - "version": "11.2.1", - "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-11.2.1.tgz", - "integrity": "sha512-UPhKoFCj8BcwgLB1rKQ1CuR/REITnZLiqxHEHOeH1tPsdXcHGtUDh0P5bLbr7oU0nKVdtJ5rzyKx8jniMQdESQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/code-frame": "^7.20.0", - "json5": "^2.2.3" - } - }, - "packages/expo-two-way-audio/node_modules/@expo/plist": { - "version": "0.4.9", - "resolved": "https://registry.npmjs.org/@expo/plist/-/plist-0.4.9.tgz", - "integrity": "sha512-MPVpmKGfnQEnrCzgxuXcmPP/y/t6AVm+DcSb2Myp21LKWv1N3l8uFxMggesfF4ixAxkRlGmMMx9GyDC9M+XklQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@xmldom/xmldom": "^0.8.8", - "base64-js": "^1.2.3", - "xmlbuilder": "^15.1.1" - } - }, - "packages/expo-two-way-audio/node_modules/@expo/prebuild-config": { - "version": "58.0.14", - "resolved": "https://registry.npmjs.org/@expo/prebuild-config/-/prebuild-config-58.0.14.tgz", - "integrity": "sha512-2wVzVDfXjRB3LzUbW5shOM/A+6eUUCfR+2bpZiWRBBZBtZP2HA5Zq1YgCio1tDqMGAHf2NQQDhvy/Rz1U6aC3A==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@expo/config": "~58.0.6", - "@expo/config-plugins": "~58.0.9", - "@expo/config-types": "^58.0.3", - "@expo/image-utils": "^0.12.5", - "@expo/json-file": "^11.2.1", - "@react-native/normalize-colors": "0.88.0", - "debug": "^4.3.1", - "expo-modules-autolinking": "~58.0.13", - "resolve-from": "^5.0.0", - "semver": "^7.7.4" - } - }, - "packages/expo-two-way-audio/node_modules/@expo/prebuild-config/node_modules/@expo/config": { - "version": "58.0.6", - "resolved": "https://registry.npmjs.org/@expo/config/-/config-58.0.6.tgz", - "integrity": "sha512-PT1qv4s/R5A/NxXb4efGlFsYF5Consi7F8xSsJBAN090EGDr6U1EDQGOijFqzDc3JA2epD8zjKLJLOVB9YOewg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@expo/config-plugins": "~58.0.9", - "@expo/config-types": "^58.0.3", - "@expo/json-file": "^11.2.1", - "@expo/platforms": "^58.0.1", - "@expo/require-utils": "^58.0.4", - "deepmerge": "^4.3.1", - "getenv": "^2.0.0", - "glob": "^13.0.0", - "resolve-workspace-root": "^2.0.0", - "semver": "^7.7.4", - "slugify": "^1.3.4" - } - }, - "packages/expo-two-way-audio/node_modules/@expo/prebuild-config/node_modules/@expo/config-plugins": { - "version": "58.0.9", - "resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-58.0.9.tgz", - "integrity": "sha512-HyP7V1QgDxeHcOEh4qQH7cfK7cI7wNp0y9lszAu98SRYCshhDbwB9gWpFfnpX1GQUtgiqG4Hvgh1z3ntcGwAjw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@expo/config-types": "^58.0.3", - "@expo/json-file": "~11.2.1", - "@expo/plist": "^0.10.1", - "@expo/require-utils": "^58.0.4", - "@expo/sdk-runtime-versions": "^1.0.1", - "chalk": "^4.1.2", - "debug": "^4.3.5", - "getenv": "^2.0.0", - "glob": "^13.0.0", - "semver": "^7.7.4", - "slugify": "^1.6.6", - "xcode": "^3.0.1", - "xml2js": "0.6.0" - } - }, - "packages/expo-two-way-audio/node_modules/@expo/prebuild-config/node_modules/@expo/config-types": { - "version": "58.0.3", - "resolved": "https://registry.npmjs.org/@expo/config-types/-/config-types-58.0.3.tgz", - "integrity": "sha512-wyfrwkTT/hQHzwyqkXkngKxoOa8L6ovsTP4h/UukS1mTmssee5et6HOPoN5RtawdJPnm5JO28f//C/wXlpka8Q==", - "dev": true, - "license": "MIT", - "peer": true - }, - "packages/expo-two-way-audio/node_modules/@expo/prebuild-config/node_modules/@expo/json-file": { - "version": "11.2.1", - "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-11.2.1.tgz", - "integrity": "sha512-UPhKoFCj8BcwgLB1rKQ1CuR/REITnZLiqxHEHOeH1tPsdXcHGtUDh0P5bLbr7oU0nKVdtJ5rzyKx8jniMQdESQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/code-frame": "^7.20.0", - "json5": "^2.2.3" - } - }, - "packages/expo-two-way-audio/node_modules/@expo/prebuild-config/node_modules/@expo/plist": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@expo/plist/-/plist-0.10.1.tgz", - "integrity": "sha512-nuNOqj+r/hGJbZ1Ax3jUpZaOB8UAMHUrISALJEUKaCieTflYj7224EqFWmMpriCGk9wXS1gH+9GGVnSj0tWzuQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@xmldom/xmldom": "^0.8.8", - "base64-js": "^1.5.1", - "xmlbuilder": "^15.1.1" - } - }, - "packages/expo-two-way-audio/node_modules/@expo/prebuild-config/node_modules/@expo/require-utils": { - "version": "58.0.4", - "resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-58.0.4.tgz", - "integrity": "sha512-DvjS5NCitXYcad2Woe1EMHJKcbhTbE77HX0BgOQGL2LFHE3q2H0n8kCWDPBOCVJ63Drap3c35nwoJwF5TQL7Tw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/code-frame": "^7.20.0", - "@babel/core": "^7.25.2", - "@babel/plugin-transform-modules-commonjs": "^7.24.8" - }, - "peerDependencies": { - "typescript": "^5.0.0 || ^5.0.0-0 || ^6.0.0 || ^7.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "packages/expo-two-way-audio/node_modules/@expo/schema-utils": { - "version": "58.0.2", - "resolved": "https://registry.npmjs.org/@expo/schema-utils/-/schema-utils-58.0.2.tgz", - "integrity": "sha512-w28y8SG+5ZTdDdmH2ZnCQk9wHVwGzI9NqX5V4urJUFEnxbm7I129fJRkb8RIAJmMA1WKU5h3ONmranV473uaXQ==", - "dev": true, - "license": "MIT", - "peer": true - }, - "packages/expo-two-way-audio/node_modules/@expo/sdk-runtime-versions": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@expo/sdk-runtime-versions/-/sdk-runtime-versions-1.0.1.tgz", - "integrity": "sha512-kvisefkhxu8Fm/4M8G6DEqUaigrXfrSOm2lFw7hsk/nq0zchcp6B4wSk8rlheHY8FRi5iHxKtoVMGY/3M6h04A==", - "dev": true, - "license": "MIT" - }, - "packages/expo-two-way-audio/node_modules/@expo/spawn-async": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@expo/spawn-async/-/spawn-async-1.8.0.tgz", - "integrity": "sha512-eb9xxd/LbuEGSdua4NumCu/McVB9EM+F/JxB9pWgnERw4HQ9XyTNH1KapG6oqLWR8TuRK2LQfzJlmNi94CVobw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "cross-spawn": "^7.0.6" - }, - "engines": { - "node": ">=12" - } - }, - "packages/expo-two-way-audio/node_modules/@jest/schemas": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.4.1.tgz", - "integrity": "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@sinclair/typebox": "^0.34.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "packages/expo-two-way-audio/node_modules/@react-native/babel-plugin-codegen": { - "version": "0.88.0", - "resolved": "https://registry.npmjs.org/@react-native/babel-plugin-codegen/-/babel-plugin-codegen-0.88.0.tgz", - "integrity": "sha512-leGEnCcS7Q0hTxvzvhNtr8zGQpI1gfWqXjzC4UedsFmNMv6kIo4s70o0XbtM30ZXbpfWe4BcSC8NKWuak5sI4A==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/traverse": "^7.29.0", - "@react-native/codegen": "0.88.0" - }, - "engines": { - "node": "^22.13.0 || ^24.3.0 || >= 26.0.0" - } - }, - "packages/expo-two-way-audio/node_modules/@react-native/babel-preset": { - "version": "0.88.0", - "resolved": "https://registry.npmjs.org/@react-native/babel-preset/-/babel-preset-0.88.0.tgz", - "integrity": "sha512-iMfAHhFGNegnReSY6CYaWHncvyDwHq8olRs0zp9nRRApMAoCiLyfo1dwL/f39qt/MreD6xIqUs9SrTPYWobY6g==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@babel/core": "^7.25.2", - "@babel/plugin-proposal-export-default-from": "^7.24.7", - "@babel/plugin-syntax-dynamic-import": "^7.8.3", - "@babel/plugin-syntax-export-default-from": "^7.24.7", - "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", - "@babel/plugin-syntax-optional-chaining": "^7.8.3", - "@babel/plugin-transform-async-generator-functions": "^7.25.4", - "@babel/plugin-transform-async-to-generator": "^7.24.7", - "@babel/plugin-transform-block-scoping": "^7.25.0", - "@babel/plugin-transform-class-properties": "^7.25.4", - "@babel/plugin-transform-classes": "^7.25.4", - "@babel/plugin-transform-destructuring": "^7.24.8", - "@babel/plugin-transform-flow-strip-types": "^7.25.2", - "@babel/plugin-transform-for-of": "^7.24.7", - "@babel/plugin-transform-modules-commonjs": "^7.24.8", - "@babel/plugin-transform-named-capturing-groups-regex": "^7.24.7", - "@babel/plugin-transform-nullish-coalescing-operator": "^7.24.7", - "@babel/plugin-transform-optional-catch-binding": "^7.24.7", - "@babel/plugin-transform-optional-chaining": "^7.24.8", - "@babel/plugin-transform-private-methods": "^7.24.7", - "@babel/plugin-transform-private-property-in-object": "^7.24.7", - "@babel/plugin-transform-react-display-name": "^7.24.7", - "@babel/plugin-transform-react-jsx": "^7.25.2", - "@babel/plugin-transform-react-jsx-self": "^7.24.7", - "@babel/plugin-transform-react-jsx-source": "^7.24.7", - "@babel/plugin-transform-regenerator": "^7.24.7", - "@babel/plugin-transform-runtime": "^7.24.7", - "@babel/plugin-transform-typescript": "^7.25.2", - "@babel/plugin-transform-unicode-regex": "^7.24.7", - "@react-native/babel-plugin-codegen": "0.88.0", - "babel-plugin-transform-flow-enums": "^0.0.2", - "flow-parser": "0.327.0", - "react-refresh": "^0.14.0" - }, - "engines": { - "node": "^22.13.0 || ^24.3.0 || >= 26.0.0" - }, - "peerDependencies": { - "@babel/core": "*" - } - }, - "packages/expo-two-way-audio/node_modules/@react-native/babel-preset/node_modules/flow-parser": { - "version": "0.327.0", - "resolved": "https://registry.npmjs.org/flow-parser/-/flow-parser-0.327.0.tgz", - "integrity": "sha512-/MYZ5hXWf5ozpbdfUDmazMgQXviLxWEiRLH3MppEGDdW0itRVqmB5UaU7QX+SBCDNiq+gEesSVn8nlxmPDyDkw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "flow-estree": "0.327.0" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "packages/expo-two-way-audio/node_modules/@react-native/codegen": { - "version": "0.88.0", - "resolved": "https://registry.npmjs.org/@react-native/codegen/-/codegen-0.88.0.tgz", - "integrity": "sha512-SAGg+XfzsrPiYTscbKa9bo2/H2ei2TjyxW8RTMq2Ps661kRK3AnmpMGVfrEFshEMr3FGEbuQIvj86X98kaXlRA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/core": "^7.25.2", - "@babel/parser": "^7.29.0", - "flow-parser": "0.327.0", - "invariant": "^2.2.4", - "nullthrows": "^1.1.1", - "tinyglobby": "^0.2.15", - "yargs": "^17.6.2" - }, - "engines": { - "node": "^22.13.0 || ^24.3.0 || >= 26.0.0" - }, - "peerDependencies": { - "@babel/core": "*" - } - }, - "packages/expo-two-way-audio/node_modules/@react-native/codegen/node_modules/flow-parser": { - "version": "0.327.0", - "resolved": "https://registry.npmjs.org/flow-parser/-/flow-parser-0.327.0.tgz", - "integrity": "sha512-/MYZ5hXWf5ozpbdfUDmazMgQXviLxWEiRLH3MppEGDdW0itRVqmB5UaU7QX+SBCDNiq+gEesSVn8nlxmPDyDkw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "flow-estree": "0.327.0" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "packages/expo-two-way-audio/node_modules/@react-native/community-cli-plugin": { - "version": "0.88.0", - "resolved": "https://registry.npmjs.org/@react-native/community-cli-plugin/-/community-cli-plugin-0.88.0.tgz", - "integrity": "sha512-WrQdXbjVt1gwAay8ZzYtpOi9ly99ksJ0VMYFdVcMe6nq3vtiRgqKRpHgCdN6ySIbtr5o+aJ+Vr7OTDY98ec/Ow==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@react-native/asset-utils": "0.88.0", - "@react-native/dev-middleware": "0.88.0", - "commander": "^12.0.0", - "debug": "^4.4.0", - "invariant": "^2.2.4", - "metro": "^0.87.1", - "semver": "^7.1.3" - }, - "engines": { - "node": "^22.13.0 || ^24.3.0 || >= 26.0.0" - }, - "peerDependencies": { - "@react-native-community/cli": "*", - "@react-native/metro-config": "0.88.0" - }, - "peerDependenciesMeta": { - "@react-native-community/cli": { - "optional": true - }, - "@react-native/metro-config": { - "optional": true - } - } - }, - "packages/expo-two-way-audio/node_modules/@react-native/debugger-frontend": { - "version": "0.88.0", - "resolved": "https://registry.npmjs.org/@react-native/debugger-frontend/-/debugger-frontend-0.88.0.tgz", - "integrity": "sha512-v5cnBaaWhDRPcQv8EC20zj53jOAKH7u2wnzzXS4JTNu+diPC+cA2+kC7rq3eKI5vGhlAcG2kDPoViRuC0AY0oA==", - "dev": true, - "license": "BSD-3-Clause", - "peer": true, - "engines": { - "node": "^22.13.0 || ^24.3.0 || >= 26.0.0" - } - }, - "packages/expo-two-way-audio/node_modules/@react-native/debugger-shell": { - "version": "0.88.0", - "resolved": "https://registry.npmjs.org/@react-native/debugger-shell/-/debugger-shell-0.88.0.tgz", - "integrity": "sha512-p82zT8ZKNx7nIm/8zf8pi55sxm5KQBm8td47kzoJysTdqHLyPsRMUvDXtvCJIWadHSv7RJ6t/yiCc+IoImLBtw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "cross-spawn": "^7.0.6", - "debug": "^4.4.0", - "fb-dotslash": "0.5.8" - }, - "engines": { - "node": "^22.13.0 || ^24.3.0 || >= 26.0.0" - } - }, - "packages/expo-two-way-audio/node_modules/@react-native/dev-middleware": { - "version": "0.88.0", - "resolved": "https://registry.npmjs.org/@react-native/dev-middleware/-/dev-middleware-0.88.0.tgz", - "integrity": "sha512-sHLxVHOwfkF672ZzMepNh4d3IKbrZy3qXDKWSwN5LC5VJkzzZ5FdmSPP4N/CCn2pcKJ4oSqvojXwN4De3Xk92A==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@isaacs/ttlcache": "^1.4.1", - "@react-native/debugger-frontend": "0.88.0", - "@react-native/debugger-shell": "0.88.0", - "connect": "^3.6.5", - "debug": "^4.4.0", - "invariant": "^2.2.4", - "nullthrows": "^1.1.1", - "open": "^8.4.2", - "serve-static": "^1.16.2", - "ws": "^7.5.10" - }, - "engines": { - "node": "^22.13.0 || ^24.3.0 || >= 26.0.0" - } - }, - "packages/expo-two-way-audio/node_modules/@react-native/dev-middleware/node_modules/ws": { - "version": "7.5.13", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.13.tgz", - "integrity": "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=8.3.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": "^5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "packages/expo-two-way-audio/node_modules/@react-native/gradle-plugin": { - "version": "0.88.0", - "resolved": "https://registry.npmjs.org/@react-native/gradle-plugin/-/gradle-plugin-0.88.0.tgz", - "integrity": "sha512-4jPSKslOfJj8NKY/jv9qmWkxZYmqPyamoS3Vs0JOFxw9t4PM6L/ea0fUsjNV0LxC9zJlvWilPJOCTOf7h8wlyw==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": "^22.13.0 || ^24.3.0 || >= 26.0.0" - } - }, - "packages/expo-two-way-audio/node_modules/@react-native/js-polyfills": { - "version": "0.88.0", - "resolved": "https://registry.npmjs.org/@react-native/js-polyfills/-/js-polyfills-0.88.0.tgz", - "integrity": "sha512-Iq6v4+EWdOLv0M1+BpADQJBVfInl6FkABXCIBSi0FbvHahEYT+Y4UJ7Cm96p18gbEN1VKzqz3nbBbwLA6bx0rQ==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": "^22.13.0 || ^24.3.0 || >= 26.0.0" - } - }, - "packages/expo-two-way-audio/node_modules/@react-native/metro-babel-transformer": { - "version": "0.88.0", - "resolved": "https://registry.npmjs.org/@react-native/metro-babel-transformer/-/metro-babel-transformer-0.88.0.tgz", - "integrity": "sha512-xsUfzXrqKXeD7sBja7hkyBG32AGIwWJ3IEZ+UcNMRTR/sucnXXZ6Do40qXoeY52YC8b9//J+p51JIp2CC0DZKA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@babel/core": "^7.25.2", - "@react-native/babel-preset": "0.88.0", - "flow-parser": "0.327.0", - "nullthrows": "^1.1.1" - }, - "engines": { - "node": "^22.13.0 || ^24.3.0 || >= 26.0.0" - }, - "peerDependencies": { - "@babel/core": "*" - } - }, - "packages/expo-two-way-audio/node_modules/@react-native/metro-babel-transformer/node_modules/flow-parser": { - "version": "0.327.0", - "resolved": "https://registry.npmjs.org/flow-parser/-/flow-parser-0.327.0.tgz", - "integrity": "sha512-/MYZ5hXWf5ozpbdfUDmazMgQXviLxWEiRLH3MppEGDdW0itRVqmB5UaU7QX+SBCDNiq+gEesSVn8nlxmPDyDkw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "flow-estree": "0.327.0" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "packages/expo-two-way-audio/node_modules/@react-native/metro-config": { - "version": "0.88.0", - "resolved": "https://registry.npmjs.org/@react-native/metro-config/-/metro-config-0.88.0.tgz", - "integrity": "sha512-iPV2HNXlPs7xQDBJJGIjkH0x2psZwJeRIEpfSqxgqLaO5eEfE2Ccicfz2OBHBcIC5jKGGa4IoI/e26zMNDuTMQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@react-native/js-polyfills": "0.88.0", - "@react-native/metro-babel-transformer": "0.88.0", - "metro-config": "^0.87.1", - "metro-runtime": "^0.87.1" - }, - "engines": { - "node": "^22.13.0 || ^24.3.0 || >= 26.0.0" - } - }, - "packages/expo-two-way-audio/node_modules/@react-native/normalize-colors": { - "version": "0.88.0", - "resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.88.0.tgz", - "integrity": "sha512-eZCu3tGAQCfvMCPQJVY4dmoMX8ZZYBvqIGHX7lwOYyzNK+Dl8e5ky7ZoXub1Rq75oSrQXNNmSP2CDjNNXXZpFQ==", - "dev": true, - "license": "MIT", - "peer": true - }, - "packages/expo-two-way-audio/node_modules/@sinclair/typebox": { - "version": "0.34.49", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz", - "integrity": "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==", - "dev": true, - "license": "MIT" - }, "packages/expo-two-way-audio/node_modules/@types/react": { "version": "18.3.28", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", @@ -39725,1390 +40328,6 @@ "csstype": "^3.2.2" } }, - "packages/expo-two-way-audio/node_modules/@typescript-eslint/scope-manager": { - "version": "8.61.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.61.1.tgz", - "integrity": "sha512-L2bdIeoQS8FlKAvONAr20w6OcLXeB+qiDKbAooS9A0Ben+iSIkBef0FxqwKWYqt5sa0i4KJtxVyVmhMylKzF5w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.61.1", - "@typescript-eslint/visitor-keys": "8.61.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "packages/expo-two-way-audio/node_modules/@typescript-eslint/types": { - "version": "8.61.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.61.1.tgz", - "integrity": "sha512-G+CRlPqLv7Bz1IZVs03x5K59F1veqL0EJUROAdGhKsEq8qOiRiZbI+HUojPq5l0fEGOKModD9br6lObhB8zkoA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "packages/expo-two-way-audio/node_modules/@typescript-eslint/visitor-keys": { - "version": "8.61.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.61.1.tgz", - "integrity": "sha512-6fJ9MHWtK14C1DSkiMlHUSOmrVebL7150xZJBlJiL62jjhIA4JmOq6flwBgDxIdBKKdoiZRel+dfPD5MLfny3w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.61.1", - "eslint-visitor-keys": "^5.0.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "packages/expo-two-way-audio/node_modules/@xmldom/xmldom": { - "version": "0.8.13", - "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", - "integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.0.0" - } - }, - "packages/expo-two-way-audio/node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 14" - } - }, - "packages/expo-two-way-audio/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "packages/expo-two-way-audio/node_modules/babel-plugin-react-native-web": { - "version": "0.21.4", - "resolved": "https://registry.npmjs.org/babel-plugin-react-native-web/-/babel-plugin-react-native-web-0.21.4.tgz", - "integrity": "sha512-FSclsDTGi5EcvpbW3TM7s8GLWwQqo5wI9zQqJr2m0IoJRenSkp1+bVvdeZXTNznI5Ms9Bry7DLBR+C5Shb9gtw==", - "dev": true, - "license": "MIT", - "peer": true - }, - "packages/expo-two-way-audio/node_modules/babel-plugin-syntax-hermes-parser": { - "version": "0.36.1", - "resolved": "https://registry.npmjs.org/babel-plugin-syntax-hermes-parser/-/babel-plugin-syntax-hermes-parser-0.36.1.tgz", - "integrity": "sha512-ycduwJbvdvIMmVvlAZqGggS+pm5Eu4Bk9pcV9Sm2Z4PJNRVsKkv0g7vHj+LeuC1gHTeF67sJXFOq61IlqCa2hA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "hermes-parser": "0.36.1" - } - }, - "packages/expo-two-way-audio/node_modules/babel-plugin-syntax-hermes-parser/node_modules/hermes-estree": { - "version": "0.36.1", - "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.36.1.tgz", - "integrity": "sha512-guv1nQ6IJ7S83NRFPWc3SA7IBZrdNC9kapwOq6uXvF4wP+sDCgjzQbKPCoyYmoyZRzztF/n/c36l/rccCZSiCw==", - "dev": true, - "license": "MIT", - "peer": true - }, - "packages/expo-two-way-audio/node_modules/babel-plugin-syntax-hermes-parser/node_modules/hermes-parser": { - "version": "0.36.1", - "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.36.1.tgz", - "integrity": "sha512-GApNk4zLHi2UWoWZZkx7LNCOSzLSc5lB55pZ/PhK7ycFeg7u5LcF88p/WbpIi1XUDtE0MpHE3uRR3u3KB7TjSQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "hermes-estree": "0.36.1" - } - }, - "packages/expo-two-way-audio/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "packages/expo-two-way-audio/node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "packages/expo-two-way-audio/node_modules/cli-cursor": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-2.1.0.tgz", - "integrity": "sha512-8lgKz8LmCRYZZQDpRyT2m5rKJ08TnU4tR9FFFW2rxpxR1FzWi4PQ/NfyODchAatHaUgnSPVcx/R5w6NuTBzFiw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "restore-cursor": "^2.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "packages/expo-two-way-audio/node_modules/color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "color-name": "1.1.3" - } - }, - "packages/expo-two-way-audio/node_modules/color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "dev": true, - "license": "MIT", - "peer": true - }, - "packages/expo-two-way-audio/node_modules/commander": { - "version": "12.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", - "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "packages/expo-two-way-audio/node_modules/content-type": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", - "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "packages/expo-two-way-audio/node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=0.8.0" - } - }, - "packages/expo-two-way-audio/node_modules/eslint-config-prettier": { - "version": "9.1.2", - "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.2.tgz", - "integrity": "sha512-iI1f+D2ViGn+uvv5HuHVUamg8ll4tN+JRHGc6IJi4TP9Kl976C57fzPXgseXNs8v0iA8aSJpHsTWjDb9QJamGQ==", - "dev": true, - "license": "MIT", - "bin": { - "eslint-config-prettier": "bin/cli.js" - }, - "peerDependencies": { - "eslint": ">=7.0.0" - } - }, - "packages/expo-two-way-audio/node_modules/eslint-config-universe": { - "version": "15.2.0", - "resolved": "https://registry.npmjs.org/eslint-config-universe/-/eslint-config-universe-15.2.0.tgz", - "integrity": "sha512-n2662q/mM+2pTFVz7ELosqhN+/nbR75Ut/4vLme40kKSHHe0oPbPMxgPqyYrASlANuSDP4aAJ71rRviDMCZTxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/eslint-plugin": "^8.59.0", - "@typescript-eslint/parser": "^8.59.0", - "eslint-config-prettier": "^9.1.0", - "eslint-plugin-import": "^2.31.0", - "eslint-plugin-n": "^17.17.0", - "eslint-plugin-node": "^11.1.0", - "eslint-plugin-prettier": "^5.2.6", - "eslint-plugin-react": "^7.37.5", - "eslint-plugin-react-hooks": "^7.0.0", - "globals": "^16.0.0" - }, - "peerDependencies": { - "eslint": ">=8.10", - "prettier": ">=3" - }, - "peerDependenciesMeta": { - "prettier": { - "optional": true - } - } - }, - "packages/expo-two-way-audio/node_modules/eslint-config-universe/node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.61.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.1.tgz", - "integrity": "sha512-ZPlVl3PB3et/59Ne0fv/sci6ZXz4T4Hp4nTJ56i/Y0gR89ARb+KphojTq6j+56E5PIezmOIOOWyY+aWQFd+IkQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.61.1", - "@typescript-eslint/type-utils": "8.61.1", - "@typescript-eslint/utils": "8.61.1", - "@typescript-eslint/visitor-keys": "8.61.1", - "ignore": "^7.0.5", - "natural-compare": "^1.4.0", - "ts-api-utils": "^2.5.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "@typescript-eslint/parser": "^8.61.1", - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "packages/expo-two-way-audio/node_modules/eslint-config-universe/node_modules/@typescript-eslint/parser": { - "version": "8.61.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.61.1.tgz", - "integrity": "sha512-PJ5vePq5/ognBbrIcoC5+SHO5dfpeLPzP9FpLkzWrguoYQEeeSjlJpVwOpo1JRSTEi7dRcwNy4h4dzV70PqHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/scope-manager": "8.61.1", - "@typescript-eslint/types": "8.61.1", - "@typescript-eslint/typescript-estree": "8.61.1", - "@typescript-eslint/visitor-keys": "8.61.1", - "debug": "^4.4.3" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "packages/expo-two-way-audio/node_modules/eslint-config-universe/node_modules/@typescript-eslint/project-service": { - "version": "8.61.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.61.1.tgz", - "integrity": "sha512-PrC4JYGmR241lYnfhmKGTXkFqv8+ymbTFgSAY0fVXpY82/QkMw5TZPl+vGzuDDU2QYJk9fIDOBTntF+yDv9LEA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.61.1", - "@typescript-eslint/types": "^8.61.1", - "debug": "^4.4.3" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" - } - }, - "packages/expo-two-way-audio/node_modules/eslint-config-universe/node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.61.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.61.1.tgz", - "integrity": "sha512-UN/H4di+OO7EWx2ovME+8t31YO+KVnK0RRKEHR3kOt21/Ay8BOq3M1OMvWs5vNiqcFCYGYoxK3MXPZzmMUE+yg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" - } - }, - "packages/expo-two-way-audio/node_modules/eslint-config-universe/node_modules/@typescript-eslint/type-utils": { - "version": "8.61.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.61.1.tgz", - "integrity": "sha512-GYRicKmVK0C4fsKgaACaknOUAq9Oa2kwsjnpFhFcS/5p4Ht5IP9OVLbgIgcK4SRk92nVHFluurg1lumD9dBcLw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.61.1", - "@typescript-eslint/typescript-estree": "8.61.1", - "@typescript-eslint/utils": "8.61.1", - "debug": "^4.4.3", - "ts-api-utils": "^2.5.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "packages/expo-two-way-audio/node_modules/eslint-config-universe/node_modules/@typescript-eslint/typescript-estree": { - "version": "8.61.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.61.1.tgz", - "integrity": "sha512-u+oQD3BqYWPc8YV9Zab4vaJElJuwOLPRc10Jm1o/qS+6Qwen14HCWwx0Seo4LnSn2wxea2Ik8DxPt2/FHmuhrg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/project-service": "8.61.1", - "@typescript-eslint/tsconfig-utils": "8.61.1", - "@typescript-eslint/types": "8.61.1", - "@typescript-eslint/visitor-keys": "8.61.1", - "debug": "^4.4.3", - "minimatch": "^10.2.2", - "semver": "^7.7.3", - "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.5.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" - } - }, - "packages/expo-two-way-audio/node_modules/eslint-config-universe/node_modules/@typescript-eslint/utils": { - "version": "8.61.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.61.1.tgz", - "integrity": "sha512-1+P/3Dj6jvtybE1q0HQ6yBt/gq+oKJyLdEv4HdnqasaEXRSYCAsD59mXEVQnM/ULNdQxbX77tdG4jPRjIS6knA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.61.1", - "@typescript-eslint/types": "8.61.1", - "@typescript-eslint/typescript-estree": "8.61.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "packages/expo-two-way-audio/node_modules/eslint-config-universe/node_modules/ts-api-utils": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", - "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.12" - }, - "peerDependencies": { - "typescript": ">=4.8.4" - } - }, - "packages/expo-two-way-audio/node_modules/eslint-plugin-react-hooks": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", - "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.24.4", - "@babel/parser": "^7.24.4", - "hermes-parser": "^0.25.1", - "zod": "^3.25.0 || ^4.0.0", - "zod-validation-error": "^3.5.0 || ^4.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" - } - }, - "packages/expo-two-way-audio/node_modules/eslint-visitor-keys": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", - "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "packages/expo-two-way-audio/node_modules/expo-asset": { - "version": "58.0.16", - "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-58.0.16.tgz", - "integrity": "sha512-beR3uusLA+rFo+6Ug0L7FPR4b6y/37VwtYW2Jig/i+3Co+oyYCTnvVuFPWhb05W9TOZRxdHUEI569fVKIgtYAA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@expo/image-utils": "^0.12.5", - "expo-constants": "~58.0.12" - }, - "peerDependencies": { - "expo": "*", - "react": "*", - "react-native": "*" - } - }, - "packages/expo-two-way-audio/node_modules/expo-constants": { - "version": "58.0.12", - "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-58.0.12.tgz", - "integrity": "sha512-d3/dsps32nU4m6YJQbXQ1dIfn58GxfmJjTnZ0WtnhdSHTdG/oVsOakLqYRlX/FaLUKkQOSHXMf7hyCx+2a/HSQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@expo/env": "~2.5.2", - "@expo/require-utils": "^58.0.4" - }, - "peerDependencies": { - "expo": "*", - "react-native": "*" - } - }, - "packages/expo-two-way-audio/node_modules/expo-constants/node_modules/@expo/require-utils": { - "version": "58.0.4", - "resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-58.0.4.tgz", - "integrity": "sha512-DvjS5NCitXYcad2Woe1EMHJKcbhTbE77HX0BgOQGL2LFHE3q2H0n8kCWDPBOCVJ63Drap3c35nwoJwF5TQL7Tw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/code-frame": "^7.20.0", - "@babel/core": "^7.25.2", - "@babel/plugin-transform-modules-commonjs": "^7.24.8" - }, - "peerDependencies": { - "typescript": "^5.0.0 || ^5.0.0-0 || ^6.0.0 || ^7.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "packages/expo-two-way-audio/node_modules/expo-file-system": { - "version": "58.0.9", - "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-58.0.9.tgz", - "integrity": "sha512-JT0km3Wa+wDz/7Rli8ifreoMnmk68ulf/uaWzRA26xm/4K+jGTY+cDhnboRmpmXUCBkED6uqxWmNoXjaVU9VUw==", - "dev": true, - "license": "MIT", - "peer": true, - "peerDependencies": { - "expo": "*", - "react-native": "*" - } - }, - "packages/expo-two-way-audio/node_modules/expo-font": { - "version": "58.0.8", - "resolved": "https://registry.npmjs.org/expo-font/-/expo-font-58.0.8.tgz", - "integrity": "sha512-i9HKj/ljdmmjLamYQEkI4wXJB9+ap7AG5CpCqK/bgwwy45y5T8+1lpNbYgRXE7aQgmDjPpzd09wX6dOZkHfrxg==", - "dev": true, - "license": "MIT", - "peer": true, - "peerDependencies": { - "expo": "*", - "react": "*", - "react-native": "*" - } - }, - "packages/expo-two-way-audio/node_modules/expo-keep-awake": { - "version": "58.0.3", - "resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-58.0.3.tgz", - "integrity": "sha512-wLRF+XeVsD/Rt/ZyX+A0mk1brdf8vefWizNAPzqV5eOCgkj23LCRZdN8gUi5X8UwyR8UJ7Y7Dzdz4YDGB/4c9Q==", - "dev": true, - "license": "MIT", - "peer": true, - "peerDependencies": { - "expo": "*", - "react": "*" - } - }, - "packages/expo-two-way-audio/node_modules/expo-module-scripts": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/expo-module-scripts/-/expo-module-scripts-5.0.8.tgz", - "integrity": "sha512-3YkAic3E6QGQuNEIchq6/0GGSBwR2C70AhDUF7VI7/N3THwUpeqZrX20FxHKN95/wclHs1ljrGvRX+2YTgXxiw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/cli": "^7.23.4", - "@babel/plugin-transform-export-namespace-from": "^7.23.4", - "@babel/preset-env": "^7.23.8", - "@babel/preset-typescript": "^7.23.3", - "@expo/npm-proofread": "^1.0.1", - "@testing-library/react-native": "^13.2.0", - "@tsconfig/node18": "^18.2.2", - "@types/jest": "^29.2.1", - "babel-plugin-dynamic-import-node": "^2.3.3", - "babel-preset-expo": "~54.0.7", - "commander": "^12.1.0", - "eslint-config-universe": "^15.0.3", - "glob": "^13.0.0", - "jest-expo": "~54.0.13", - "jest-snapshot-prettier": "npm:prettier@^2", - "jest-watch-typeahead": "2.2.1", - "resolve-workspace-root": "^2.0.0", - "ts-jest": "~29.0.4", - "typescript": "^5.9.2" - }, - "bin": { - "expo-module": "bin/expo-module.js" - } - }, - "packages/expo-two-way-audio/node_modules/expo-module-scripts/node_modules/@expo/config-plugins": { - "version": "58.0.9", - "resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-58.0.9.tgz", - "integrity": "sha512-HyP7V1QgDxeHcOEh4qQH7cfK7cI7wNp0y9lszAu98SRYCshhDbwB9gWpFfnpX1GQUtgiqG4Hvgh1z3ntcGwAjw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@expo/config-types": "^58.0.3", - "@expo/json-file": "~11.2.1", - "@expo/plist": "^0.10.1", - "@expo/require-utils": "^58.0.4", - "@expo/sdk-runtime-versions": "^1.0.1", - "chalk": "^4.1.2", - "debug": "^4.3.5", - "getenv": "^2.0.0", - "glob": "^13.0.0", - "semver": "^7.7.4", - "slugify": "^1.6.6", - "xcode": "^3.0.1", - "xml2js": "0.6.0" - } - }, - "packages/expo-two-way-audio/node_modules/expo-module-scripts/node_modules/@expo/config-plugins/node_modules/@expo/json-file": { - "version": "11.2.1", - "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-11.2.1.tgz", - "integrity": "sha512-UPhKoFCj8BcwgLB1rKQ1CuR/REITnZLiqxHEHOeH1tPsdXcHGtUDh0P5bLbr7oU0nKVdtJ5rzyKx8jniMQdESQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/code-frame": "^7.20.0", - "json5": "^2.2.3" - } - }, - "packages/expo-two-way-audio/node_modules/expo-module-scripts/node_modules/@expo/config-plugins/node_modules/@expo/require-utils": { - "version": "58.0.4", - "resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-58.0.4.tgz", - "integrity": "sha512-DvjS5NCitXYcad2Woe1EMHJKcbhTbE77HX0BgOQGL2LFHE3q2H0n8kCWDPBOCVJ63Drap3c35nwoJwF5TQL7Tw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/code-frame": "^7.20.0", - "@babel/core": "^7.25.2", - "@babel/plugin-transform-modules-commonjs": "^7.24.8" - }, - "peerDependencies": { - "typescript": "^5.0.0 || ^5.0.0-0 || ^6.0.0 || ^7.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "packages/expo-two-way-audio/node_modules/expo-module-scripts/node_modules/@expo/config-types": { - "version": "58.0.3", - "resolved": "https://registry.npmjs.org/@expo/config-types/-/config-types-58.0.3.tgz", - "integrity": "sha512-wyfrwkTT/hQHzwyqkXkngKxoOa8L6ovsTP4h/UukS1mTmssee5et6HOPoN5RtawdJPnm5JO28f//C/wXlpka8Q==", - "dev": true, - "license": "MIT", - "peer": true - }, - "packages/expo-two-way-audio/node_modules/expo-module-scripts/node_modules/@expo/metro-runtime": { - "version": "58.0.14", - "resolved": "https://registry.npmjs.org/@expo/metro-runtime/-/metro-runtime-58.0.14.tgz", - "integrity": "sha512-IIvTo+RpN5jZVuYspQdECsxQ03VGWjZnGS6wtr9U7DERBpykUe+ZSHDZ1hsrvQ7HAucsVoKr9np0xAscGEuXtg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@expo/log-box": "^58.0.12", - "anser": "^1.4.9", - "pretty-format": "^29.7.0", - "stacktrace-parser": "^0.1.10", - "whatwg-fetch": "^3.0.0" - }, - "peerDependencies": { - "@expo/log-box": "^58.0.12", - "expo": "*", - "react": "*", - "react-dom": "*", - "react-native": "*" - }, - "peerDependenciesMeta": { - "react-dom": { - "optional": true - } - } - }, - "packages/expo-two-way-audio/node_modules/expo-module-scripts/node_modules/@expo/metro-runtime/node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "packages/expo-two-way-audio/node_modules/expo-module-scripts/node_modules/@expo/plist": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@expo/plist/-/plist-0.10.1.tgz", - "integrity": "sha512-nuNOqj+r/hGJbZ1Ax3jUpZaOB8UAMHUrISALJEUKaCieTflYj7224EqFWmMpriCGk9wXS1gH+9GGVnSj0tWzuQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@xmldom/xmldom": "^0.8.8", - "base64-js": "^1.5.1", - "xmlbuilder": "^15.1.1" - } - }, - "packages/expo-two-way-audio/node_modules/expo-module-scripts/node_modules/@jest/schemas": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", - "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@sinclair/typebox": "^0.27.8" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "packages/expo-two-way-audio/node_modules/expo-module-scripts/node_modules/@react-native/virtualized-lists": { - "version": "0.88.0", - "resolved": "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.88.0.tgz", - "integrity": "sha512-zpZIOAbobdS93edwHkNUdGDzvElNeQJqukRERRPns3/ucPvuMd2G4fYpmVJSVjhxtGCcLLB1PFndLYYfwaYoBQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "invariant": "^2.2.4", - "nullthrows": "^1.1.1" - }, - "engines": { - "node": "^22.13.0 || ^24.3.0 || >= 26.0.0" - }, - "peerDependencies": { - "@types/react": "^19.2.0", - "react": "*", - "react-native": "0.88.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "packages/expo-two-way-audio/node_modules/expo-module-scripts/node_modules/@sinclair/typebox": { - "version": "0.27.12", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.12.tgz", - "integrity": "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==", - "dev": true, - "license": "MIT", - "peer": true - }, - "packages/expo-two-way-audio/node_modules/expo-module-scripts/node_modules/@testing-library/react-native": { - "version": "13.3.3", - "resolved": "https://registry.npmjs.org/@testing-library/react-native/-/react-native-13.3.3.tgz", - "integrity": "sha512-k6Mjsd9dbZgvY4Bl7P1NIpePQNi+dfYtlJ5voi9KQlynxSyQkfOgJmYGCYmw/aSgH/rUcFvG8u5gd4npzgRDyg==", - "dev": true, - "license": "MIT", - "dependencies": { - "jest-matcher-utils": "^30.0.5", - "picocolors": "^1.1.1", - "pretty-format": "^30.0.5", - "redent": "^3.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "jest": ">=29.0.0", - "react": ">=18.2.0", - "react-native": ">=0.71", - "react-test-renderer": ">=18.2.0" - }, - "peerDependenciesMeta": { - "jest": { - "optional": true - } - } - }, - "packages/expo-two-way-audio/node_modules/expo-module-scripts/node_modules/@types/react": { - "version": "19.3.0", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", - "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "csstype": "^3.2.2" - } - }, - "packages/expo-two-way-audio/node_modules/expo-module-scripts/node_modules/expo": { - "version": "58.0.14", - "resolved": "https://registry.npmjs.org/expo/-/expo-58.0.14.tgz", - "integrity": "sha512-M05O++KRgG4NA6kQ9AP60kbN8xqHJ6S8uTXdtkeiwcGVIYzdu2kaUXJyraVturvePO/Mf4PWa0C9+JggrHITwA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/runtime": "^7.20.0", - "@expo/cli": "^58.1.11", - "@expo/config": "~58.0.6", - "@expo/config-plugins": "~58.0.9", - "@expo/devtools": "~58.0.2", - "@expo/dom-webview": "~58.0.2", - "@expo/fingerprint": "^0.21.5", - "@expo/local-build-cache-provider": "^58.0.6", - "@expo/log-box": "^58.0.12", - "@expo/log-box-utils": "^58.0.2", - "@expo/metro": "~58.0.0", - "@expo/metro-config": "~58.0.14", - "@ungap/structured-clone": "^1.3.0", - "babel-preset-expo": "~58.0.17", - "expo-asset": "~58.0.16", - "expo-constants": "~58.0.12", - "expo-file-system": "~58.0.9", - "expo-font": "~58.0.8", - "expo-keep-awake": "~58.0.3", - "expo-modules-autolinking": "~58.0.13", - "expo-modules-core": "~58.0.22", - "pretty-format": "^29.7.0", - "react-refresh": "^0.14.2", - "whatwg-url-minimum": "^0.2.0" - }, - "bin": { - "expo": "bin/cli", - "expo-modules-autolinking": "bin/autolinking", - "fingerprint": "bin/fingerprint" - }, - "engines": { - "node": "^22.13.0 || ^24.3.0 || ^26.0.0 || >=27.0.0" - }, - "peerDependencies": { - "@expo/dom-webview": "*", - "@expo/metro-runtime": "*", - "react": "*", - "react-dom": "*", - "react-native": "*", - "react-native-web": "*", - "react-native-webview": "*" - }, - "peerDependenciesMeta": { - "@expo/dom-webview": { - "optional": true - }, - "@expo/metro-runtime": { - "optional": true - }, - "react-dom": { - "optional": true - }, - "react-native-web": { - "optional": true - }, - "react-native-webview": { - "optional": true - } - } - }, - "packages/expo-two-way-audio/node_modules/expo-module-scripts/node_modules/expo-modules-core": { - "version": "58.0.22", - "resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-58.0.22.tgz", - "integrity": "sha512-T3YJYziFkF4JlGDpX4zCl91oIxz1Skq5sp1LFVO+GL/BARSIXk013QWGdRZrvt+wMxoVaql5dpOdPdP5q2+0jQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "expo-modules-jsi": "~58.0.15", - "expo-modules-macros": "0.20.0", - "invariant": "^2.2.4" - }, - "peerDependencies": { - "react": "*", - "react-native": "*", - "react-native-worklets": ">=0.13.0" - }, - "peerDependenciesMeta": { - "react-native-worklets": { - "optional": true - } - } - }, - "packages/expo-two-way-audio/node_modules/expo-module-scripts/node_modules/expo/node_modules/@expo/cli": { - "version": "58.1.11", - "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-58.1.11.tgz", - "integrity": "sha512-wkFTSLXXYqLkRT5oy4DnLuTOtbFIu3qTiXHSHkFSqUBTHiE92y3bOIwM3UN7Y1ys18qVJge8lXjezaEfsHmijQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@expo/code-signing-certificates": "^0.0.7", - "@expo/config": "~58.0.6", - "@expo/config-plugins": "~58.0.9", - "@expo/devcert": "^1.2.2", - "@expo/env": "~2.5.2", - "@expo/image-utils": "^0.12.5", - "@expo/inline-modules": "^0.2.6", - "@expo/json-file": "^11.2.1", - "@expo/log-box": "^58.0.12", - "@expo/log-box-utils": "^58.0.2", - "@expo/metro": "~58.0.0", - "@expo/metro-config": "58.0.14", - "@expo/metro-file-map": "^58.0.5", - "@expo/osascript": "^2.8.1", - "@expo/package-manager": "^1.14.2", - "@expo/platforms": "^58.0.1", - "@expo/plist": "^0.10.1", - "@expo/prebuild-config": "^58.0.14", - "@expo/require-utils": "^58.0.4", - "@expo/router-server": "^58.0.14", - "@expo/schema-utils": "^58.0.2", - "@expo/spawn-async": "^1.8.0", - "@expo/ws-tunnel": "^2.0.1", - "@expo/xcpretty": "^4.4.6", - "@react-native/asset-utils": "0.88.0", - "@react-native/dev-middleware": "0.88.0", - "@react-native/js-polyfills": "0.88.0", - "2g": "^1.0.1", - "accepts": "^1.3.8", - "agent-cli-detector": "0.1.7", - "arg": "^5.0.2", - "bplist-creator": "0.1.0", - "bplist-parser": "^0.3.1", - "chalk": "^4.0.0", - "ci-info": "^3.3.0", - "compression": "^1.7.4", - "connect": "^3.7.0", - "dnssd-advertise": "^1.1.6", - "expo-server": "^58.0.5", - "fetch-nodeshim": "^0.4.10", - "getenv": "^2.0.0", - "glob": "^13.0.0", - "lan-network": "^0.2.1", - "multitars": "^1.0.2", - "node-forge": "^1.4.0", - "npm-package-arg": "^11.0.0", - "ora": "^3.4.0", - "picomatch": "^4.0.4", - "pretty-format": "^29.7.0", - "progress": "^2.0.3", - "prompts": "^2.3.2", - "resolve-from": "^5.0.0", - "sandbox-cli-detector": "^0.2.0", - "semver": "^7.7.4", - "send": "^0.19.0", - "slugify": "^1.3.4", - "stacktrace-parser": "^0.1.10", - "structured-headers": "^0.4.1", - "terminal-link": "^2.1.1", - "toqr": "^0.1.1", - "wrap-ansi": "^7.0.0", - "ws": "^8.12.1", - "zod": "^3.25.76" - }, - "bin": { - "expo-internal": "bin/cli.js" - }, - "peerDependencies": { - "expo": "*", - "expo-router": "*", - "react-native": "*" - }, - "peerDependenciesMeta": { - "expo-router": { - "optional": true - }, - "react-native": { - "optional": true - } - } - }, - "packages/expo-two-way-audio/node_modules/expo-module-scripts/node_modules/expo/node_modules/@expo/cli/node_modules/@expo/router-server": { - "version": "58.0.14", - "resolved": "https://registry.npmjs.org/@expo/router-server/-/router-server-58.0.14.tgz", - "integrity": "sha512-RSzQYMA+Tv2eTemhjhzpx8+hL+pMC30JTVqAWrk5AZwht+OgaKZar/gHmj4iJf7n3rq+GG6KujrkKF9xAXLz2w==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "debug": "^4.3.4" - }, - "peerDependencies": { - "@expo/metro-runtime": "^58.0.14", - "expo": "*", - "expo-constants": "^58.0.12", - "expo-font": "^58.0.8", - "expo-router": "*", - "expo-server": "^58.0.5", - "react": "*", - "react-dom": "*", - "react-server-dom-webpack": "~19.0.1 || ~19.1.2 || ~19.2.1 || ~19.3.0" - }, - "peerDependenciesMeta": { - "@expo/metro-runtime": { - "optional": true - }, - "expo-router": { - "optional": true - }, - "react-dom": { - "optional": true - }, - "react-server-dom-webpack": { - "optional": true - } - } - }, - "packages/expo-two-way-audio/node_modules/expo-module-scripts/node_modules/expo/node_modules/@expo/config": { - "version": "58.0.6", - "resolved": "https://registry.npmjs.org/@expo/config/-/config-58.0.6.tgz", - "integrity": "sha512-PT1qv4s/R5A/NxXb4efGlFsYF5Consi7F8xSsJBAN090EGDr6U1EDQGOijFqzDc3JA2epD8zjKLJLOVB9YOewg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@expo/config-plugins": "~58.0.9", - "@expo/config-types": "^58.0.3", - "@expo/json-file": "^11.2.1", - "@expo/platforms": "^58.0.1", - "@expo/require-utils": "^58.0.4", - "deepmerge": "^4.3.1", - "getenv": "^2.0.0", - "glob": "^13.0.0", - "resolve-workspace-root": "^2.0.0", - "semver": "^7.7.4", - "slugify": "^1.3.4" - } - }, - "packages/expo-two-way-audio/node_modules/expo-module-scripts/node_modules/expo/node_modules/@expo/json-file": { - "version": "11.2.1", - "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-11.2.1.tgz", - "integrity": "sha512-UPhKoFCj8BcwgLB1rKQ1CuR/REITnZLiqxHEHOeH1tPsdXcHGtUDh0P5bLbr7oU0nKVdtJ5rzyKx8jniMQdESQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/code-frame": "^7.20.0", - "json5": "^2.2.3" - } - }, - "packages/expo-two-way-audio/node_modules/expo-module-scripts/node_modules/expo/node_modules/@expo/require-utils": { - "version": "58.0.4", - "resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-58.0.4.tgz", - "integrity": "sha512-DvjS5NCitXYcad2Woe1EMHJKcbhTbE77HX0BgOQGL2LFHE3q2H0n8kCWDPBOCVJ63Drap3c35nwoJwF5TQL7Tw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/code-frame": "^7.20.0", - "@babel/core": "^7.25.2", - "@babel/plugin-transform-modules-commonjs": "^7.24.8" - }, - "peerDependencies": { - "typescript": "^5.0.0 || ^5.0.0-0 || ^6.0.0 || ^7.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "packages/expo-two-way-audio/node_modules/expo-module-scripts/node_modules/expo/node_modules/2g": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/2g/-/2g-1.0.1.tgz", - "integrity": "sha512-+uViadKOS/wwR4pFu5g8TPn3QHpKqoX19JHCnp8k/3cLpImdcXozwvZA6Q/5PeW3AcU8neL8faaMidBcv7o8CA==", - "dev": true, - "license": "MIT", - "peer": true, - "bin": { - "2g": "bin/cli.js" - }, - "peerDependencies": { - "typescript": ">=5.8.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "packages/expo-two-way-audio/node_modules/expo-module-scripts/node_modules/expo/node_modules/babel-preset-expo": { - "version": "58.0.17", - "resolved": "https://registry.npmjs.org/babel-preset-expo/-/babel-preset-expo-58.0.17.tgz", - "integrity": "sha512-Rrqc/Z5Idc4bG2/PrEh725t3kvuAc7jUKCJ+wZVU9KXk2bKSdqAniXxQa8aph9aHdv4h5sGKLpMXMaMNz/4+Og==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/generator": "^7.20.5", - "@babel/helper-module-imports": "^7.25.9", - "@babel/plugin-proposal-decorators": "^7.12.9", - "@babel/plugin-proposal-export-default-from": "^7.24.7", - "@babel/plugin-syntax-dynamic-import": "^7.8.3", - "@babel/plugin-syntax-export-default-from": "^7.24.7", - "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", - "@babel/plugin-syntax-optional-chaining": "^7.8.3", - "@babel/plugin-transform-async-generator-functions": "^7.25.4", - "@babel/plugin-transform-async-to-generator": "^7.24.7", - "@babel/plugin-transform-block-scoping": "^7.25.0", - "@babel/plugin-transform-class-properties": "^7.25.4", - "@babel/plugin-transform-class-static-block": "^7.27.1", - "@babel/plugin-transform-classes": "^7.25.4", - "@babel/plugin-transform-destructuring": "^7.24.8", - "@babel/plugin-transform-export-namespace-from": "^7.25.9", - "@babel/plugin-transform-flow-strip-types": "^7.25.2", - "@babel/plugin-transform-for-of": "^7.24.7", - "@babel/plugin-transform-logical-assignment-operators": "^7.24.7", - "@babel/plugin-transform-modules-commonjs": "^7.24.8", - "@babel/plugin-transform-named-capturing-groups-regex": "^7.24.7", - "@babel/plugin-transform-nullish-coalescing-operator": "^7.24.7", - "@babel/plugin-transform-object-rest-spread": "^7.24.7", - "@babel/plugin-transform-optional-catch-binding": "^7.24.7", - "@babel/plugin-transform-optional-chaining": "^7.24.8", - "@babel/plugin-transform-parameters": "^7.24.7", - "@babel/plugin-transform-private-methods": "^7.24.7", - "@babel/plugin-transform-private-property-in-object": "^7.24.7", - "@babel/plugin-transform-react-display-name": "^7.24.7", - "@babel/plugin-transform-react-jsx": "^7.28.6", - "@babel/plugin-transform-react-jsx-development": "^7.27.1", - "@babel/plugin-transform-react-pure-annotations": "^7.27.1", - "@babel/plugin-transform-runtime": "^7.24.7", - "@babel/plugin-transform-typescript": "^7.25.2", - "@babel/plugin-transform-unicode-regex": "^7.24.7", - "@babel/preset-typescript": "^7.23.0", - "@react-native/babel-plugin-codegen": "0.88.0", - "babel-plugin-react-compiler": "^1.0.0", - "babel-plugin-react-native-web": "~0.21.4", - "babel-plugin-syntax-hermes-parser": "^0.36.0", - "babel-plugin-transform-flow-enums": "^0.0.2", - "debug": "^4.3.4" - }, - "peerDependencies": { - "@babel/runtime": "^7.20.0", - "expo": "*", - "expo-widgets": "^58.0.20", - "react-refresh": ">=0.14.0 <1.0.0" - }, - "peerDependenciesMeta": { - "@babel/runtime": { - "optional": true - }, - "expo": { - "optional": true - }, - "expo-widgets": { - "optional": true - } - } - }, - "packages/expo-two-way-audio/node_modules/expo-module-scripts/node_modules/expo/node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "packages/expo-two-way-audio/node_modules/expo-module-scripts/node_modules/flow-parser": { - "version": "0.327.0", - "resolved": "https://registry.npmjs.org/flow-parser/-/flow-parser-0.327.0.tgz", - "integrity": "sha512-/MYZ5hXWf5ozpbdfUDmazMgQXviLxWEiRLH3MppEGDdW0itRVqmB5UaU7QX+SBCDNiq+gEesSVn8nlxmPDyDkw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "flow-estree": "0.327.0" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "packages/expo-two-way-audio/node_modules/expo-module-scripts/node_modules/jest-expo": { - "version": "54.0.17", - "resolved": "https://registry.npmjs.org/jest-expo/-/jest-expo-54.0.17.tgz", - "integrity": "sha512-LyIhrsP4xvHEEcR1R024u/LBj3uPpAgB+UljgV+YXWkEHjprnr0KpE4tROsMNYCVTM1pPlAnPuoBmn5gnAN9KA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@expo/config": "~12.0.13", - "@expo/json-file": "^10.0.8", - "@jest/create-cache-key-function": "^29.2.1", - "@jest/globals": "^29.2.1", - "babel-jest": "^29.2.1", - "jest-environment-jsdom": "^29.2.1", - "jest-snapshot": "^29.2.1", - "jest-watch-select-projects": "^2.0.0", - "jest-watch-typeahead": "2.2.1", - "json5": "^2.2.3", - "lodash": "^4.17.19", - "react-test-renderer": "19.1.0", - "server-only": "^0.0.1", - "stacktrace-js": "^2.0.2" - }, - "bin": { - "jest": "bin/jest.js" - }, - "peerDependencies": { - "expo": "*", - "react-native": "*", - "react-server-dom-webpack": "~19.0.4 || ~19.1.5 || ~19.2.4" - }, - "peerDependenciesMeta": { - "react-server-dom-webpack": { - "optional": true - } - } - }, - "packages/expo-two-way-audio/node_modules/expo-module-scripts/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true, - "license": "MIT", - "peer": true - }, - "packages/expo-two-way-audio/node_modules/expo-module-scripts/node_modules/react-native": { - "version": "0.88.0", - "resolved": "https://registry.npmjs.org/react-native/-/react-native-0.88.0.tgz", - "integrity": "sha512-v/wee0nYWcammMYBnSat/e3WY/5iaf9YQOMcBrfKT/wPApPPlHp94hgZovBtwhMuthR01yc/8cGn0FpFTE/C4A==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@react-native/asset-utils": "0.88.0", - "@react-native/codegen": "0.88.0", - "@react-native/community-cli-plugin": "0.88.0", - "@react-native/gradle-plugin": "0.88.0", - "@react-native/normalize-colors": "0.88.0", - "@react-native/virtualized-lists": "0.88.0", - "anser": "^1.4.9", - "ansi-regex": "^5.0.0", - "base64-js": "^1.5.1", - "flow-enums-runtime": "^0.0.6", - "flow-parser": "0.327.0", - "hermes-compiler": "260318099.0.4", - "invariant": "^2.2.4", - "memoize-one": "^5.0.0", - "metro-runtime": "^0.87.1", - "metro-source-map": "^0.87.1", - "nullthrows": "^1.1.1", - "pretty-format": "^29.7.0", - "promise": "^8.3.0", - "react-devtools-core": "^6.1.5", - "react-refresh": "^0.14.0", - "regenerator-runtime": "^0.13.2", - "scheduler": "0.28.0", - "semver": "^7.1.3", - "stacktrace-parser": "^0.1.10", - "tinyglobby": "^0.2.15", - "whatwg-fetch": "^3.0.0", - "ws": "^7.5.10", - "yargs": "^17.6.2" - }, - "bin": { - "react-native": "cli.js" - }, - "engines": { - "node": "^22.13.0 || ^24.3.0 || >= 26.0.0" - }, - "peerDependencies": { - "@types/react": "^19.1.1", - "react": "^19.3.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "packages/expo-two-way-audio/node_modules/expo-module-scripts/node_modules/react-native/node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "packages/expo-two-way-audio/node_modules/expo-module-scripts/node_modules/react-native/node_modules/ws": { - "version": "7.5.13", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.13.tgz", - "integrity": "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=8.3.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": "^5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "packages/expo-two-way-audio/node_modules/expo-module-scripts/node_modules/scheduler": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", - "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", - "dev": true, - "license": "MIT", - "peer": true - }, - "packages/expo-two-way-audio/node_modules/expo-modules-autolinking": { - "version": "58.0.13", - "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-58.0.13.tgz", - "integrity": "sha512-uzLFoTnAKabUdITvzRqWTvFkZsOdRPEXj7AEMAuBEhA2kGhqaBDa2EOWqPXVay0QJVoZuIQaEEgPGAXN1dPuYg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@expo/platforms": "^58.0.1", - "@expo/require-utils": "^58.0.4", - "@expo/spawn-async": "^1.8.0", - "chalk": "^4.1.0", - "commander": "^7.2.0" - }, - "bin": { - "expo-modules-autolinking": "bin/expo-modules-autolinking.js" - } - }, - "packages/expo-two-way-audio/node_modules/expo-modules-autolinking/node_modules/@expo/require-utils": { - "version": "58.0.4", - "resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-58.0.4.tgz", - "integrity": "sha512-DvjS5NCitXYcad2Woe1EMHJKcbhTbE77HX0BgOQGL2LFHE3q2H0n8kCWDPBOCVJ63Drap3c35nwoJwF5TQL7Tw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/code-frame": "^7.20.0", - "@babel/core": "^7.25.2", - "@babel/plugin-transform-modules-commonjs": "^7.24.8" - }, - "peerDependencies": { - "typescript": "^5.0.0 || ^5.0.0-0 || ^6.0.0 || ^7.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "packages/expo-two-way-audio/node_modules/expo-modules-autolinking/node_modules/commander": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", - "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 10" - } - }, "packages/expo-two-way-audio/node_modules/expo-modules-core": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-2.5.0.tgz", @@ -41119,770 +40338,6 @@ "invariant": "^2.2.4" } }, - "packages/expo-two-way-audio/node_modules/expo-modules-jsi": { - "version": "58.0.15", - "resolved": "https://registry.npmjs.org/expo-modules-jsi/-/expo-modules-jsi-58.0.15.tgz", - "integrity": "sha512-PsjYgL4KWtvBqWH242r0wbP99VjgHkHCpkPCekO7nzb+ulkDNyrNnhysxa0TcNpfRTNLetmCUaLs9t4CcoquZQ==", - "dev": true, - "license": "MIT", - "peer": true, - "peerDependencies": { - "react-native": "*" - } - }, - "packages/expo-two-way-audio/node_modules/expo-server": { - "version": "58.0.5", - "resolved": "https://registry.npmjs.org/expo-server/-/expo-server-58.0.5.tgz", - "integrity": "sha512-FHYecxfGOuAhacHcanR6qCN8Cln9S+FFulcYetLu3ddDg55ohuCsMZ9U4Egzwdm+A2o6N+MdSoJKquBC5F9NIg==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": "^22.13.0 || ^24.3.0 || ^26.0.0 || >=27.0.0" - } - }, - "packages/expo-two-way-audio/node_modules/flow-estree": { - "version": "0.327.0", - "resolved": "https://registry.npmjs.org/flow-estree/-/flow-estree-0.327.0.tgz", - "integrity": "sha512-f9vK751YUzD0qaHSpKc5/qQM6gqOlgGS+4ZRqTNcZOpVqn3mC55yEQ6dUG2K1eaVm5FbKkz9CFWe6XNvrkQFQg==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=18" - } - }, - "packages/expo-two-way-audio/node_modules/getenv": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/getenv/-/getenv-2.0.0.tgz", - "integrity": "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "packages/expo-two-way-audio/node_modules/glob": { - "version": "13.0.6", - "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", - "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "minimatch": "^10.2.2", - "minipass": "^7.1.3", - "path-scurry": "^2.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "packages/expo-two-way-audio/node_modules/globals": { - "version": "16.5.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", - "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "packages/expo-two-way-audio/node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=4" - } - }, - "packages/expo-two-way-audio/node_modules/hermes-compiler": { - "version": "260318099.0.4", - "resolved": "https://registry.npmjs.org/hermes-compiler/-/hermes-compiler-260318099.0.4.tgz", - "integrity": "sha512-8odcPFkPC82HlqCl0C5QI9mBN4rEAT+IAo55sJhzwKw//K3qZloiv3ex6J2O8CuBbhdmkJozfidNsset2KH25w==", - "dev": true, - "license": "MIT", - "peer": true - }, - "packages/expo-two-way-audio/node_modules/hermes-estree": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", - "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", - "dev": true, - "license": "MIT" - }, - "packages/expo-two-way-audio/node_modules/hermes-parser": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", - "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", - "dev": true, - "license": "MIT", - "dependencies": { - "hermes-estree": "0.25.1" - } - }, - "packages/expo-two-way-audio/node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, - "packages/expo-two-way-audio/node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "packages/expo-two-way-audio/node_modules/indent-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", - "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "packages/expo-two-way-audio/node_modules/jest-diff": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.4.1.tgz", - "integrity": "sha512-CRpFK0RtLriVDGcPPAnR6HMVI8bSR2jnUIgralhauzYQZIb4RH9AtEInTuQr65LmmGggGcRT6HIASxwqsVsmlA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/diff-sequences": "30.4.0", - "@jest/get-type": "30.1.0", - "chalk": "^4.1.2", - "pretty-format": "30.4.1" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "packages/expo-two-way-audio/node_modules/jest-matcher-utils": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.4.1.tgz", - "integrity": "sha512-zvYfX5CaeEkFrrLS9suWe9rvJrm9J1Iv3ua8kIBv9GEPzcnsfBf0bob37la7s67fs0nlBC3EuvkOLnXQKxtx4A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/get-type": "30.1.0", - "chalk": "^4.1.2", - "jest-diff": "30.4.1", - "pretty-format": "30.4.1" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "packages/expo-two-way-audio/node_modules/log-symbols": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-2.2.0.tgz", - "integrity": "sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "chalk": "^2.0.1" - }, - "engines": { - "node": ">=4" - } - }, - "packages/expo-two-way-audio/node_modules/log-symbols/node_modules/ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "color-convert": "^1.9.0" - }, - "engines": { - "node": ">=4" - } - }, - "packages/expo-two-way-audio/node_modules/log-symbols/node_modules/chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - }, - "engines": { - "node": ">=4" - } - }, - "packages/expo-two-way-audio/node_modules/lru-cache": { - "version": "11.5.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", - "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, - "packages/expo-two-way-audio/node_modules/metro": { - "version": "0.87.1", - "resolved": "https://registry.npmjs.org/metro/-/metro-0.87.1.tgz", - "integrity": "sha512-1oyLU9elM7hPAsKIqKooEekwxiuWr27+MZBhUROPhzcbXhMDxK64GPF/hCXwBZVp1F89ODavCrBmJtLx9WtBoQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/core": "^7.25.2", - "@babel/generator": "^7.29.1", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.29.0", - "@babel/types": "^7.29.0", - "accepts": "^2.0.0", - "connect": "^3.6.5", - "debug": "^4.4.0", - "error-stack-parser": "^2.0.6", - "flow-enums-runtime": "^0.0.6", - "flow-parser": "0.331.0", - "graceful-fs": "^4.2.4", - "invariant": "^2.2.4", - "jest-worker": "^29.7.0", - "jsc-safe-url": "^0.2.2", - "lodash.throttle": "^4.1.1", - "metro-babel-transformer": "0.87.1", - "metro-cache": "0.87.1", - "metro-cache-key": "0.87.1", - "metro-config": "0.87.1", - "metro-core": "0.87.1", - "metro-file-map": "0.87.1", - "metro-resolver": "0.87.1", - "metro-runtime": "0.87.1", - "metro-source-map": "0.87.1", - "metro-symbolicate": "0.87.1", - "metro-transform-plugins": "0.87.1", - "metro-transform-worker": "0.87.1", - "mime-types": "^3.0.1", - "nullthrows": "^1.1.1", - "serialize-error": "^2.1.0", - "source-map": "^0.5.6", - "throat": "^5.0.0", - "ws": "^7.5.10", - "yargs": "^17.6.2" - }, - "bin": { - "metro": "src/cli.js" - }, - "engines": { - "node": "^22.13.0 || ^24.3.0 || >= 26.0.0" - } - }, - "packages/expo-two-way-audio/node_modules/metro-babel-transformer": { - "version": "0.87.1", - "resolved": "https://registry.npmjs.org/metro-babel-transformer/-/metro-babel-transformer-0.87.1.tgz", - "integrity": "sha512-orvRIGpb0yK1yMbk5YP3dhVKjmoxUkRB9fdltGJxNppEqus/1tFpNuf9KnGFR3Qjg0izoUrJ4z6WgoBvdkXPrA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/core": "^7.25.2", - "flow-enums-runtime": "^0.0.6", - "flow-parser": "0.331.0", - "metro-cache-key": "0.87.1", - "nullthrows": "^1.1.1" - }, - "engines": { - "node": "^22.13.0 || ^24.3.0 || >= 26.0.0" - } - }, - "packages/expo-two-way-audio/node_modules/metro-cache": { - "version": "0.87.1", - "resolved": "https://registry.npmjs.org/metro-cache/-/metro-cache-0.87.1.tgz", - "integrity": "sha512-juNPaj0Xi5tkLp8imXqvbSnEIijwKG36m7OiFFVJMqEkiURwAjkMFVek3/xpix1+LVQqO7kjVp3PvuvIM61mKQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "exponential-backoff": "^3.1.1", - "flow-enums-runtime": "^0.0.6", - "https-proxy-agent": "^7.0.5", - "metro-core": "0.87.1" - }, - "engines": { - "node": "^22.13.0 || ^24.3.0 || >= 26.0.0" - } - }, - "packages/expo-two-way-audio/node_modules/metro-cache-key": { - "version": "0.87.1", - "resolved": "https://registry.npmjs.org/metro-cache-key/-/metro-cache-key-0.87.1.tgz", - "integrity": "sha512-scqVVPMA2c+RVO12I3wyMDp5xCkRPLsyrcG4Qc3xfU5JR0EW/0sz2tjty+5UjMUtG09uiM1KYQ8HjWg7OQXHtw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "flow-enums-runtime": "^0.0.6" - }, - "engines": { - "node": "^22.13.0 || ^24.3.0 || >= 26.0.0" - } - }, - "packages/expo-two-way-audio/node_modules/metro-config": { - "version": "0.87.1", - "resolved": "https://registry.npmjs.org/metro-config/-/metro-config-0.87.1.tgz", - "integrity": "sha512-NmkDlc/qZAdo5gTkVTuO2AKE/khemRKRH5IA+SKfqUBJigI5GqZ5TOXhmEokhlkAqzjt8dyzKhzI6FHvXoXaTQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "connect": "^3.6.5", - "flow-enums-runtime": "^0.0.6", - "jest-validate": "^29.7.0", - "metro": "0.87.1", - "metro-cache": "0.87.1", - "metro-core": "0.87.1", - "metro-runtime": "0.87.1" - }, - "engines": { - "node": "^22.13.0 || ^24.3.0 || >= 26.0.0" - } - }, - "packages/expo-two-way-audio/node_modules/metro-core": { - "version": "0.87.1", - "resolved": "https://registry.npmjs.org/metro-core/-/metro-core-0.87.1.tgz", - "integrity": "sha512-xizzky/4+c/Mkznege8OW05j7bSdM39VPoEps61Se4+hf32UQujmbvLvIE9+UnP6kNEPVomk0Fw8NsBK9iRQmw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "flow-enums-runtime": "^0.0.6", - "lodash.throttle": "^4.1.1", - "metro-resolver": "0.87.1" - }, - "engines": { - "node": "^22.13.0 || ^24.3.0 || >= 26.0.0" - } - }, - "packages/expo-two-way-audio/node_modules/metro-file-map": { - "version": "0.87.1", - "resolved": "https://registry.npmjs.org/metro-file-map/-/metro-file-map-0.87.1.tgz", - "integrity": "sha512-9sPuNCojC3pS4vj+ZPY7FXmHGpdJeckXuKXQrDAfJvZc60AF8owRFTkI8XdwlwrmPIB2w++KraK9FMEXvC4wJw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "debug": "^4.4.0", - "fb-watchman": "^2.0.0", - "flow-enums-runtime": "^0.0.6", - "graceful-fs": "^4.2.4", - "invariant": "^2.2.4", - "jest-worker": "^29.7.0", - "micromatch": "^4.0.4", - "nullthrows": "^1.1.1" - }, - "engines": { - "node": "^22.13.0 || ^24.3.0 || >= 26.0.0" - } - }, - "packages/expo-two-way-audio/node_modules/metro-minify-terser": { - "version": "0.87.1", - "resolved": "https://registry.npmjs.org/metro-minify-terser/-/metro-minify-terser-0.87.1.tgz", - "integrity": "sha512-YK4k1oO1wV48hfE+Nbw2rJCnAgeabzrgT/xH5ha5n9gbK4p3HYFC898cERUiyTl9vRh58CNTiXRc88f7y6PfKg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "flow-enums-runtime": "^0.0.6", - "terser": "^5.15.0" - }, - "engines": { - "node": "^22.13.0 || ^24.3.0 || >= 26.0.0" - } - }, - "packages/expo-two-way-audio/node_modules/metro-resolver": { - "version": "0.87.1", - "resolved": "https://registry.npmjs.org/metro-resolver/-/metro-resolver-0.87.1.tgz", - "integrity": "sha512-FXH/brY69wT6sPxaSW8BhMmDGD/6irmBr/cL4rUi+VWwqR3pbvdcfUOfyjcHucgQ7pe5IW3xJY21eHKLVKNumQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "flow-enums-runtime": "^0.0.6" - }, - "engines": { - "node": "^22.13.0 || ^24.3.0 || >= 26.0.0" - } - }, - "packages/expo-two-way-audio/node_modules/metro-runtime": { - "version": "0.87.1", - "resolved": "https://registry.npmjs.org/metro-runtime/-/metro-runtime-0.87.1.tgz", - "integrity": "sha512-kjeuSvInsM6OzBjkCQsWxnkrKZFMKtnkCl38oXWRI9lDGs2L9hGs9Tak9sQYJ9h3gtDujneB7wZio+irgqJKDw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/runtime": "^7.25.0", - "flow-enums-runtime": "^0.0.6" - }, - "engines": { - "node": "^22.13.0 || ^24.3.0 || >= 26.0.0" - } - }, - "packages/expo-two-way-audio/node_modules/metro-source-map": { - "version": "0.87.1", - "resolved": "https://registry.npmjs.org/metro-source-map/-/metro-source-map-0.87.1.tgz", - "integrity": "sha512-Bqpm0PBGdy53pigIJb4HRF7QxD2pUOdgSmVh/4AWbP635kSCv+Y6ltVxUZgSnzS/QbSmzyCGijmRPlXMj2/qHw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/traverse": "^7.29.0", - "@babel/types": "^7.29.0", - "flow-enums-runtime": "^0.0.6", - "invariant": "^2.2.4", - "metro-symbolicate": "0.87.1", - "nullthrows": "^1.1.1", - "ob1": "0.87.1", - "source-map": "^0.5.6", - "vlq": "^1.0.0" - }, - "engines": { - "node": "^22.13.0 || ^24.3.0 || >= 26.0.0" - } - }, - "packages/expo-two-way-audio/node_modules/metro-symbolicate": { - "version": "0.87.1", - "resolved": "https://registry.npmjs.org/metro-symbolicate/-/metro-symbolicate-0.87.1.tgz", - "integrity": "sha512-rcGvwabrg0lbXGRGRKl14PY3My3+dkno2jZYhdJEuH2DmjTtTkV5bYwgto4JtV/Lbe+sXE6GO1UGg654430uow==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "flow-enums-runtime": "^0.0.6", - "invariant": "^2.2.4", - "metro-source-map": "0.87.1", - "nullthrows": "^1.1.1", - "source-map": "^0.5.6", - "vlq": "^1.0.0" - }, - "bin": { - "metro-symbolicate": "src/index.js" - }, - "engines": { - "node": "^22.13.0 || ^24.3.0 || >= 26.0.0" - } - }, - "packages/expo-two-way-audio/node_modules/metro-transform-plugins": { - "version": "0.87.1", - "resolved": "https://registry.npmjs.org/metro-transform-plugins/-/metro-transform-plugins-0.87.1.tgz", - "integrity": "sha512-qMiJ/x+VfqumFJJSefKS6nj8uojzC29b2/aK8vlMuFABIqGjDucICEOXd2Yt6k/XMFAgAcaJvShUOT0HLyYaPw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/core": "^7.25.2", - "@babel/generator": "^7.29.1", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.29.0", - "flow-enums-runtime": "^0.0.6", - "nullthrows": "^1.1.1" - }, - "engines": { - "node": "^22.13.0 || ^24.3.0 || >= 26.0.0" - } - }, - "packages/expo-two-way-audio/node_modules/metro-transform-worker": { - "version": "0.87.1", - "resolved": "https://registry.npmjs.org/metro-transform-worker/-/metro-transform-worker-0.87.1.tgz", - "integrity": "sha512-VOzs3OV405FuOLYdAhqA2wsn1F9lvueDVxMDLvCKaQ44U+yknIqphJ9eMjjsGwLDZiJq9sW3DMLhjD27JxQgqQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/core": "^7.25.2", - "@babel/generator": "^7.29.1", - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", - "flow-enums-runtime": "^0.0.6", - "metro": "0.87.1", - "metro-babel-transformer": "0.87.1", - "metro-cache": "0.87.1", - "metro-cache-key": "0.87.1", - "metro-minify-terser": "0.87.1", - "metro-source-map": "0.87.1", - "metro-transform-plugins": "0.87.1", - "nullthrows": "^1.1.1" - }, - "engines": { - "node": "^22.13.0 || ^24.3.0 || >= 26.0.0" - } - }, - "packages/expo-two-way-audio/node_modules/metro/node_modules/accepts": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", - "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "mime-types": "^3.0.0", - "negotiator": "^1.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "packages/expo-two-way-audio/node_modules/metro/node_modules/ws": { - "version": "7.5.13", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.13.tgz", - "integrity": "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=8.3.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": "^5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "packages/expo-two-way-audio/node_modules/mime-types": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", - "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "packages/expo-two-way-audio/node_modules/mimic-fn": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-1.2.0.tgz", - "integrity": "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=4" - } - }, - "packages/expo-two-way-audio/node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.5" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "packages/expo-two-way-audio/node_modules/negotiator": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", - "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "content-type": "^2.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "packages/expo-two-way-audio/node_modules/node-forge": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz", - "integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==", - "dev": true, - "license": "(BSD-3-Clause OR GPL-2.0)", - "peer": true, - "engines": { - "node": ">= 6.13.0" - } - }, - "packages/expo-two-way-audio/node_modules/ob1": { - "version": "0.87.1", - "resolved": "https://registry.npmjs.org/ob1/-/ob1-0.87.1.tgz", - "integrity": "sha512-i8iA8uij0g1YQzS8uOJPSRCgwDjO9warIHUAu1Fqj877Wc3wlfxDYBioYWgKTBF2+URVJttyDWSEpmd99nlvtQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "flow-enums-runtime": "^0.0.6" - }, - "engines": { - "node": "^22.13.0 || ^24.3.0 || >= 26.0.0" - } - }, - "packages/expo-two-way-audio/node_modules/onetime": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-2.0.1.tgz", - "integrity": "sha512-oyyPpiMaKARvvcgip+JV+7zci5L8D1W9RZIz2l1o08AM3pfspitVWnPt3mzHcBPp12oYMTy0pqrFs/C+m3EwsQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "mimic-fn": "^1.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "packages/expo-two-way-audio/node_modules/open": { - "version": "8.4.2", - "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", - "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "define-lazy-prop": "^2.0.0", - "is-docker": "^2.1.1", - "is-wsl": "^2.2.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "packages/expo-two-way-audio/node_modules/ora": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/ora/-/ora-3.4.0.tgz", - "integrity": "sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "chalk": "^2.4.2", - "cli-cursor": "^2.1.0", - "cli-spinners": "^2.0.0", - "log-symbols": "^2.2.0", - "strip-ansi": "^5.2.0", - "wcwidth": "^1.0.1" - }, - "engines": { - "node": ">=6" - } - }, - "packages/expo-two-way-audio/node_modules/ora/node_modules/ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "color-convert": "^1.9.0" - }, - "engines": { - "node": ">=4" - } - }, - "packages/expo-two-way-audio/node_modules/ora/node_modules/chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - }, - "engines": { - "node": ">=4" - } - }, - "packages/expo-two-way-audio/node_modules/path-scurry": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", - "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^11.0.0", - "minipass": "^7.1.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "packages/expo-two-way-audio/node_modules/pretty-format": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", - "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/schemas": "30.4.1", - "ansi-styles": "^5.2.0", - "react-is-18": "npm:react-is@^18.3.1", - "react-is-19": "npm:react-is@^19.2.5" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, "packages/expo-two-way-audio/node_modules/react": { "version": "19.3.1", "resolved": "https://registry.npmjs.org/react/-/react-19.3.1.tgz", @@ -41893,205 +40348,9 @@ "node": ">=0.10.0" } }, - "packages/expo-two-way-audio/node_modules/react-refresh": { - "version": "0.14.2", - "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", - "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, - "packages/expo-two-way-audio/node_modules/react-test-renderer": { - "version": "19.1.0", - "resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-19.1.0.tgz", - "integrity": "sha512-jXkSl3CpvPYEF+p/eGDLB4sPoDX8pKkYvRl9+rR8HxLY0X04vW7hCm1/0zHoUSjPZ3bDa+wXWNTDVIw/R8aDVw==", - "dev": true, - "license": "MIT", - "dependencies": { - "react-is": "^19.1.0", - "scheduler": "^0.26.0" - }, - "peerDependencies": { - "react": "^19.1.0" - } - }, - "packages/expo-two-way-audio/node_modules/redent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", - "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", - "dev": true, - "license": "MIT", - "dependencies": { - "indent-string": "^4.0.0", - "strip-indent": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "packages/expo-two-way-audio/node_modules/restore-cursor": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-2.0.0.tgz", - "integrity": "sha512-6IzJLuGi4+R14vwagDHX+JrXmPVtPpn4mffDJ1UdR7/Edm87fl6yi8mMBIVvFtJaNTUvjughmW4hwLhRG7gC1Q==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "onetime": "^2.0.0", - "signal-exit": "^3.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "packages/expo-two-way-audio/node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true, - "license": "ISC", - "peer": true - }, - "packages/expo-two-way-audio/node_modules/slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "packages/expo-two-way-audio/node_modules/source-map": { - "version": "0.5.7", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", - "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", - "dev": true, - "license": "BSD-3-Clause", - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, - "packages/expo-two-way-audio/node_modules/strip-ansi": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", - "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "ansi-regex": "^4.1.0" - }, - "engines": { - "node": ">=6" - } - }, - "packages/expo-two-way-audio/node_modules/strip-ansi/node_modules/ansi-regex": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.1.tgz", - "integrity": "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=6" - } - }, - "packages/expo-two-way-audio/node_modules/strip-indent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", - "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "min-indent": "^1.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "packages/expo-two-way-audio/node_modules/sucrase": { - "version": "3.35.1", - "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", - "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.2", - "commander": "^4.0.0", - "lines-and-columns": "^1.1.6", - "mz": "^2.7.0", - "pirates": "^4.0.1", - "tinyglobby": "^0.2.11", - "ts-interface-checker": "^0.1.9" - }, - "bin": { - "sucrase": "bin/sucrase", - "sucrase-node": "bin/sucrase-node" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "packages/expo-two-way-audio/node_modules/sucrase/node_modules/commander": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", - "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "packages/expo-two-way-audio/node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "has-flag": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "packages/expo-two-way-audio/node_modules/whatwg-url-minimum": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/whatwg-url-minimum/-/whatwg-url-minimum-0.2.0.tgz", - "integrity": "sha512-BtjXU1caNcCFfETP2DhqSeVPfRDl2MTUitI107RE34rJXjR/izfUtR5IoTFOK1WqjNLXbbeqaCH/mQAOLjpc8g==", - "dev": true, - "license": "MIT", - "peer": true - }, - "packages/expo-two-way-audio/node_modules/xmlbuilder": { - "version": "15.1.1", - "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", - "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.0" - } - }, - "packages/expo-two-way-audio/node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, "packages/highlight": { "name": "@getpaseo/highlight", - "version": "0.13.2", + "version": "0.14.0-beta.1", "dependencies": { "@codemirror/language": "6.12.4", "@codemirror/legacy-modes": "^6.5.3", @@ -42120,15 +40379,15 @@ }, "packages/plugin": { "name": "@getpaseo/plugin", - "version": "0.13.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.13.2", - "@getpaseo/protocol": "0.13.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", @@ -42138,8 +40397,8 @@ "zod": "^4.4.3" }, "peerDependencies": { - "@getpaseo/client": "0.13.2", - "@getpaseo/protocol": "0.13.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" @@ -42161,7 +40420,7 @@ }, "packages/protocol": { "name": "@getpaseo/protocol", - "version": "0.13.2", + "version": "0.14.0-beta.1", "dependencies": { "semver": "^7.8.5", "zod": "^4.4.3" @@ -42188,7 +40447,7 @@ }, "packages/relay": { "name": "@getpaseo/relay", - "version": "0.13.2", + "version": "0.14.0-beta.1", "dependencies": { "@noble/ciphers": "^2.4.0", "@noble/curves": "^2.4.0", @@ -42409,16 +40668,16 @@ }, "packages/server": { "name": "@getpaseo/server", - "version": "0.13.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.13.2", - "@getpaseo/highlight": "0.13.2", - "@getpaseo/plugin": "0.13.2", - "@getpaseo/protocol": "0.13.2", - "@getpaseo/relay": "0.13.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", @@ -43339,7 +41598,7 @@ }, "packages/website": { "name": "@getpaseo/website", - "version": "0.13.2", + "version": "0.14.0-beta.1", "dependencies": { "@cloudflare/vite-plugin": "^1.29.1", "@cloudflare/workers-types": "^4.20260317.1", @@ -44949,7 +43208,7 @@ }, "plugins": { "name": "@getpaseo/builtin-plugins", - "version": "0.13.2", + "version": "0.14.0-beta.1", "dependencies": { "@getpaseo/plugin": "*", "zod": "^4.4.3" diff --git a/package.json b/package.json index beeff7c613..1406cab3aa 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "paseo", - "version": "0.13.2", + "version": "0.14.0-beta.1", "private": true, "description": "Paseo: voice-controlled development environment for local AI coding agents", "keywords": [ @@ -131,6 +131,7 @@ "concurrently": "^9.2.1", "cross-env": "^10.1.0", "electron": "44.2.0", + "expo-module-scripts": "^5.0.8", "get-port-cli": "^3.0.0", "js-yaml": "^4.1.1", "knip": "^5.82.1", diff --git a/packages/app/e2e/browser/agent-message-submission.spec.ts b/packages/app/e2e/browser/agent-message-submission.spec.ts index 0009188b34..dcf872f8be 100644 --- a/packages/app/e2e/browser/agent-message-submission.spec.ts +++ b/packages/app/e2e/browser/agent-message-submission.spec.ts @@ -53,6 +53,7 @@ import { import { expectInFlightForkAvailable } from "../support/helpers/assistant-fork"; import { scrollTimelineToNewestLoadedEdge, + userScrollsTimelineToHistoryStart, scrollTimelineUntilOlderHistoryIsReachable, } from "../support/helpers/timeline-pagination"; import { @@ -986,7 +987,7 @@ test.describe("Agent message submission", () => { } }); - test("keeps one prompt when its provider echo crosses the bounded tail", async ({ + test("keeps one prompt when a delayed provider echo belongs to a large whole turn", async ({ page, }, testInfo) => { const workspace = await seedWorkspace({ @@ -1003,8 +1004,10 @@ test.describe("Agent message submission", () => { await expectVisibleAgentSurfacesIdle(page); await page.reload(); - await expect(submittedPrompt).toHaveCount(0); - await scrollTimelineUntilOlderHistoryIsReachable(page, prompt); + // The complete turn arrives on reload; scrolling mounts its oldest rows + // without requiring another page or an increase in total content height. + await userScrollsTimelineToHistoryStart(page); + await expect(submittedPrompt).toBeVisible(); await expect(submittedPrompt).toHaveCount(1); await expect(submittedPrompt).toHaveAttribute("aria-busy", "false"); } finally { diff --git a/packages/app/e2e/browser/agent-timeline-pagination.spec.ts b/packages/app/e2e/browser/agent-timeline-pagination.spec.ts index f2772b0b1b..57ee050fa4 100644 --- a/packages/app/e2e/browser/agent-timeline-pagination.spec.ts +++ b/packages/app/e2e/browser/agent-timeline-pagination.spec.ts @@ -27,6 +27,10 @@ import { seedRealisticOlderTimeline, sendLiveTurnBeforeHydration, expectTimelineViewportAnchoredAfterPrepend, + expectToolCallGroupPositionPreserved, + rememberToolCallGroupPosition, + seedItemPagedToolGroupTimeline, + useChatDisplay, userNavigatesTimelineToHistoryStartWithKeyboard, userScrollsTimelineToHistoryStart, } from "../support/helpers/timeline-pagination"; @@ -100,6 +104,32 @@ test.describe("Agent timeline pagination", () => { } }); + // Hosts paged by items can end a page inside a tool run. The older page extends the group + // the reader is on, which changes the row hosting it; the reader must stay with that group + // rather than land at history start and drain every older page. + test("keeps the reading position when an older item page extends the tool-call group being read", async ({ + page, + }) => { + test.setTimeout(120_000); + await useChatDisplay(page, "collapseToolCalls"); + const agent = await seedItemPagedToolGroupTimeline(); + try { + const history = await holdOlderHistoryPages(page, agent, { itemPagedHost: true }); + await openAgentTimeline(page, agent); + await expectTimelinePromptVisible(page, agent.newestPrompt); + + await userScrollsTimelineToHistoryStart(page); + await history.expectRequestedPages(1); + const reading = await rememberToolCallGroupPosition(page, agent.loadedCommands); + + history.releasePage(1); + await expectToolCallGroupPositionPreserved(page, agent.totalCommands, reading); + await history.expectSettledWithRequestedPages(1); + } finally { + await agent.cleanup(); + } + }); + test("loads older history when the user navigates to history start with the keyboard", async ({ page, }) => { diff --git a/packages/app/e2e/browser/diff-performance.spec.ts b/packages/app/e2e/browser/diff-performance.spec.ts index c11c82f279..0358b6359e 100644 --- a/packages/app/e2e/browser/diff-performance.spec.ts +++ b/packages/app/e2e/browser/diff-performance.spec.ts @@ -1,6 +1,7 @@ import { writeFile } from "node:fs/promises"; import path from "node:path"; import type { CDPSession, Locator, Page, TestInfo } from "@playwright/test"; +import { pullRequestCorpus } from "../../src/git/diff-document/benchmark-corpus"; import { buildHostWorkspaceRoute } from "../../src/utils/host-routes"; import { test, expect } from "../support/fixtures"; import { getServerId } from "../support/helpers/server-id"; @@ -296,8 +297,187 @@ diffPerfDescribe("Diff canvas performance", () => { await repo.cleanup().catch(() => undefined); } }); + test("toggling line wrap on a pull-request-sized diff", async ({ page }, testInfo) => { + test.setTimeout(300_000); + const corpus = await createPullRequestCorpusRepository(); + const client = await connectSeedClient(); + + try { + const created = await client.createWorkspace({ + source: { kind: "directory", path: corpus.repo.path }, + }); + if (!created.workspace) { + throw new Error(created.error ?? "Failed to create wrap performance workspace"); + } + + await configureUnwrappedUnifiedDiff(page); + await instrumentDiffLatency(page); + await page.setViewportSize({ width: 1400, height: 900 }); + await page.goto(buildHostWorkspaceRoute(getServerId(), created.workspace.id)); + await waitForWorkspaceTabsVisible(page); + const openStartedAt = performance.now(); + await openChangesPanel(page, 90_000); + await expect(page.getByTestId("git-diff-canvas")).toBeVisible({ timeout: 30_000 }); + const open = { + readyMs: Math.round(performance.now() - openStartedAt), + ...(await readDiffDocumentRenders(page)), + }; + + const toggles: WrapToggleMeasurement[] = []; + for (const name of [ + "wrap on (cold)", + "wrap off (cold)", + "wrap on (warm)", + "wrap off (warm)", + ]) { + toggles.push(await measureWrapToggle(page, name)); + } + const unwrappedScroll = await continuouslyScrollCanvas(page); + toggles.push(await measureWrapToggle(page, "wrap on (warm, scrolled)")); + const wrappedScroll = await continuouslyScrollCanvas(page); + + const report = { + scenario: "wrap-toggle", + sourceFiles: corpus.fileCount, + sourceLines: corpus.lineCount, + open, + toggles, + scroll: { + unwrapped: summarizeScroll(unwrappedScroll), + wrapped: summarizeScroll(wrappedScroll), + }, + }; + await attachPerformanceReport(testInfo, report); + console.log(`[perf] Wrap toggle: ${JSON.stringify(report)}`); + + const [wrapOn, wrapOff] = toggles; + expect(wrapOn!.scrollHeightAfter).toBeGreaterThan(wrapOn!.scrollHeightBefore); + expect(wrapOff!.scrollHeightAfter).toBe(wrapOn!.scrollHeightBefore); + } finally { + await client.close().catch(() => undefined); + await corpus.repo.cleanup().catch(() => undefined); + } + }); }); +interface WrapToggleMeasurement { + name: string; + /** Pointer down on the toolbar button to two painted frames after the document resized. */ + latencyMs: number; + scrollHeightBefore: number; + scrollHeightAfter: number; + longestTaskMs: number; + longTaskTotalMs: number; + diffDocumentCommits: number; + diffDocumentRenderMs: number; +} + +interface LongTask { + startTime: number; + duration: number; +} + +interface DiffLatencyScope { + __PASEO_RENDER_PROFILE_ENABLED__?: boolean; + __PASEO_RENDER_PROFILE__?: Array<{ id: string; actualDuration: number }>; + __PASEO_RESET_RENDER_PROFILE__?: () => void; + __PASEO_LONG_TASKS__?: LongTask[]; + __PASEO_WRAP_TOGGLE_AT__?: number; +} + +async function createPullRequestCorpusRepository() { + const files = pullRequestCorpus(); + const repo = await createTempGitRepo("diff-wrap-perf-", { + files: files.map((file) => ({ path: file.path, content: "baseline\n" })), + }); + for (const file of files) { + await writeFile(path.join(repo.path, file.path), `${file.lines.join("\n")}\n`); + } + return { + repo, + fileCount: files.length, + lineCount: files.reduce((count, file) => count + file.lines.length, 0), + }; +} + +async function instrumentDiffLatency(page: Page): Promise { + await page.addInitScript(() => { + const scope = globalThis as typeof globalThis & DiffLatencyScope; + scope.__PASEO_RENDER_PROFILE_ENABLED__ = true; + scope.__PASEO_LONG_TASKS__ = []; + new PerformanceObserver((list) => { + for (const entry of list.getEntries()) { + scope.__PASEO_LONG_TASKS__?.push({ startTime: entry.startTime, duration: entry.duration }); + } + }).observe({ type: "longtask", buffered: true }); + }); +} + +async function readDiffDocumentRenders( + page: Page, +): Promise<{ diffDocumentCommits: number; diffDocumentRenderMs: number }> { + return page.evaluate(() => { + const scope = globalThis as typeof globalThis & DiffLatencyScope; + const samples = (scope.__PASEO_RENDER_PROFILE__ ?? []).filter( + (sample) => sample.id === "DiffDocument", + ); + return { + diffDocumentCommits: samples.length, + diffDocumentRenderMs: Math.round( + samples.reduce((total, sample) => total + sample.actualDuration, 0), + ), + }; + }); +} + +async function measureWrapToggle(page: Page, name: string): Promise { + const scroll = page.getByTestId("git-diff-scroll"); + const scrollHeightBefore = await scroll.evaluate((element) => element.scrollHeight); + await page.evaluate(() => { + const scope = globalThis as typeof globalThis & DiffLatencyScope; + scope.__PASEO_RESET_RENDER_PROFILE__?.(); + scope.__PASEO_LONG_TASKS__ = []; + scope.__PASEO_WRAP_TOGGLE_AT__ = undefined; + document.addEventListener( + "pointerdown", + () => { + scope.__PASEO_WRAP_TOGGLE_AT__ = performance.now(); + }, + { capture: true, once: true }, + ); + }); + await page.getByTestId("changes-toggle-wrap-lines").click(); + const measured = await scroll.evaluate(async (element, before) => { + const scope = globalThis as typeof globalThis & DiffLatencyScope; + const deadline = performance.now() + 240_000; + while (element.scrollHeight === before && performance.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 5)); + } + await new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())), + ); + const doneAt = performance.now(); + // Long task entries are delivered after the task ends; give the observer a turn. + await new Promise((resolve) => setTimeout(resolve, 50)); + const startedAt = scope.__PASEO_WRAP_TOGGLE_AT__ ?? doneAt; + const longTasks = (scope.__PASEO_LONG_TASKS__ ?? []).filter( + (task) => task.startTime + task.duration >= startedAt && task.startTime <= doneAt, + ); + return { + latencyMs: Math.round(doneAt - startedAt), + scrollHeightAfter: element.scrollHeight, + longestTaskMs: Math.round(Math.max(0, ...longTasks.map((task) => task.duration))), + longTaskTotalMs: Math.round(longTasks.reduce((total, task) => total + task.duration, 0)), + }; + }, scrollHeightBefore); + return { name, scrollHeightBefore, ...measured, ...(await readDiffDocumentRenders(page)) }; +} + +function summarizeScroll(report: Awaited>) { + const { frameSignatures: _frameSignatures, ...summary } = report; + return summary; +} + async function measureAdditionalPerformanceScenario( page: Page, cdp: CDPSession, diff --git a/packages/app/e2e/browser/owned-subscriptions-old-daemon.spec.ts b/packages/app/e2e/browser/owned-subscriptions-old-daemon.spec.ts index 17f4db2e85..5f1f36e409 100644 --- a/packages/app/e2e/browser/owned-subscriptions-old-daemon.spec.ts +++ b/packages/app/e2e/browser/owned-subscriptions-old-daemon.spec.ts @@ -1,29 +1,90 @@ import { randomUUID } from "node:crypto"; -import { submitMessage, expectComposerEditable } from "../support/helpers/composer"; import { focusTerminalSurface, typeInTerminal, getTerminalBufferText, } from "../support/helpers/terminal-perf"; import { buildHostWorkspaceRoute } from "../../src/utils/host-routes"; -import { metroTest as test, expect } from "../support/fixtures"; +import { metroTest as test, expect, type Page } from "../support/fixtures"; import { buildCreateAgentPreferences, buildSeededHost } from "../support/helpers/daemon-registry"; import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon"; import type { MockAgentWorkspace } from "../support/helpers/mock-agent"; import { seedWorkspace } from "../support/helpers/seed-client"; import { - expectTimelinePromptVisible, + expectFoldedTurnAtReadingPosition, + expectOldestLoadedTurnPartial, holdOlderHistoryPages, openAgentTimeline, - scrollThroughOlderHistoryPages, + expectTimelinePromptVisible, + userScrollsTimelineToHistoryStart, } from "../support/helpers/timeline-pagination"; -test("does not repeat an assistant block when the current app paginates a published 0.2.5 daemon", async ({ +interface PublishedDaemonAgent { + serverId: string; + daemon: Awaited>; + agent: MockAgentWorkspace; + /** Register the host in the browser and apply app settings before the first load. */ + seedBrowser(page: Page, appSettings?: Record): Promise; + close(): Promise; +} + +async function startPublishedDaemonAgent(options: { + publishedVersion: string; + serverIdPrefix: string; + repoPrefix: string; + title: string; +}): Promise { + const serverId = `${options.serverIdPrefix}${randomUUID().replaceAll("-", "").slice(0, 12)}`; + const daemon = await startIsolatedHostDaemon(serverId, { + publishedVersion: options.publishedVersion, + }); + const workspace = await seedWorkspace({ repoPrefix: options.repoPrefix, port: daemon.port }); + const createdAgent = await workspace.client.createAgent({ + provider: "mock", + cwd: workspace.repoPath, + workspaceId: workspace.workspaceId, + title: options.title, + modeId: "load-test", + model: "ten-second-stream", + }); + const agent: MockAgentWorkspace = { + agentId: createdAgent.id, + workspaceId: workspace.workspaceId, + cwd: workspace.repoPath, + client: workspace.client, + cleanup: workspace.cleanup, + }; + return { + serverId, + daemon, + agent, + async seedBrowser(page, appSettings) { + const host = buildSeededHost({ + serverId, + endpoint: `127.0.0.1:${daemon.port}`, + nowIso: new Date().toISOString(), + }); + await page.addInitScript( + ({ seededHost, preferences, settings }) => { + localStorage.setItem("@paseo:e2e", "1"); + localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededHost])); + localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences)); + if (settings) localStorage.setItem("@paseo:app-settings", JSON.stringify(settings)); + }, + { seededHost: host, preferences: buildCreateAgentPreferences(), settings: appSettings }, + ); + }, + async close() { + await agent.cleanup(); + await daemon.close(); + }, + }; +} + +test("loads history across browser reconnection while retaining old-daemon connections and terminals", async ({ page, }) => { test.setTimeout(120_000); - const serverId = `srv_old_pagination_${randomUUID().replaceAll("-", "").slice(0, 12)}`; - const daemon = await startIsolatedHostDaemon(serverId, { publishedVersion: "0.2.5" }); const observationSockets = new Set(); page.on("websocket", (socket) => { socket.on("framesent", ({ payload }) => { @@ -37,104 +98,84 @@ test("does not repeat an assistant block when the current app paginates a publis observationSockets.add(socket); }); }); - const workspace = await seedWorkspace({ + const published = await startPublishedDaemonAgent({ + publishedVersion: "0.2.5", + serverIdPrefix: "srv_old_pagination_", repoPrefix: "timeline-old-daemon-pagination-", - port: daemon.port, - }); - const createdAgent = await workspace.client.createAgent({ - provider: "mock", - cwd: workspace.repoPath, - workspaceId: workspace.workspaceId, title: "Published daemon pagination regression", - modeId: "load-test", - model: "ten-second-stream", }); - const agent: MockAgentWorkspace = { - agentId: createdAgent.id, - workspaceId: workspace.workspaceId, - cwd: workspace.repoPath, - client: workspace.client, - cleanup: workspace.cleanup, - }; + const { agent, serverId } = published; try { - for (let index = 0; index < 40; index += 1) { - await agent.client.sendAgentMessage( - agent.agentId, - `timeline-pagination-older-turn-${index}: emit 1 coalesced agent stream updates`, - ); - await agent.client.waitForFinish(agent.agentId, 15_000); - } - await agent.client.sendAgentMessage(agent.agentId, "build a realistic long mock timeline"); - await agent.client.waitForFinish(agent.agentId, 20_000); - for (let index = 0; index < 20; index += 1) { - await agent.client.sendAgentMessage( - agent.agentId, - `timeline-pagination-turn-${index}: emit 1 coalesced agent stream updates`, - ); - await agent.client.waitForFinish(agent.agentId, 15_000); - } + await agent.client.sendAgentMessage(agent.agentId, "emit 1 coalesced agent stream updates"); + await agent.client.waitForFinish(agent.agentId, 15_000); + await published.seedBrowser(page); - const host = buildSeededHost({ - serverId, - endpoint: `127.0.0.1:${daemon.port}`, - nowIso: new Date().toISOString(), - }); - await page.addInitScript( - ({ seededHost, preferences }) => { - localStorage.setItem("@paseo:e2e", "1"); - localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededHost])); - localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences)); - }, - { seededHost: host, preferences: buildCreateAgentPreferences() }, - ); - - const history = await holdOlderHistoryPages(page, agent, daemon.port); await openAgentTimeline(page, agent, serverId); - await expectTimelinePromptVisible( - page, - "timeline-pagination-turn-19: emit 1 coalesced agent stream updates", - ); - await scrollThroughOlderHistoryPages(page, 3, history); - - history.expectRepeatedEntries(); - await history.expectOwnedTextRendered("Now I have a clearer picture."); + await expectTimelinePromptVisible(page, "emit 1 coalesced agent stream updates"); expect(observationSockets.size).toBe(1); - await test.step("send a live turn after reconnecting the old daemon", async () => { - await daemon.restart(); - await expect.poll(() => observationSockets.size, { timeout: 30_000 }).toBe(2); - await expectComposerEditable(page); - await submitMessage( - page, - "compatibility-after-reconnect: emit 1 coalesced agent stream updates", - ); - await expectTimelinePromptVisible( - page, - "compatibility-after-reconnect: emit 1 coalesced agent stream updates", - ); - // The browser and the seed client reconnect independently after the restart. - await agent.client.connect(); - await agent.client.waitForFinish(agent.agentId, 20_000); - await page.screenshot({ path: test.info().outputPath("old-daemon-live-reconnect.png") }); + await test.step("reconnect the browser and reload the old daemon history", async () => { + observationSockets.clear(); + await page.reload(); + await expectTimelinePromptVisible(page, "emit 1 coalesced agent stream updates"); + await expect.poll(() => observationSockets.size).toBe(1); }); await test.step("open a terminal and receive its output", async () => { const terminal = await agent.client.createTerminal(agent.cwd, "Compatibility terminal"); expect(terminal.error).toBeNull(); const terminalId = terminal.terminal!.id; - const route = buildHostWorkspaceRoute(serverId, workspace.workspaceId); + const route = buildHostWorkspaceRoute(serverId, agent.workspaceId); await page.goto(`${route}?open=${encodeURIComponent(`terminal:${terminalId}`)}`); await focusTerminalSurface(page); await typeInTerminal(page, "printf 'compatibility-%s\\n' terminal-output\n"); await expect .poll(() => getTerminalBufferText(page), { timeout: 15_000 }) .toContain("compatibility-terminal-output"); - await page.screenshot({ path: test.info().outputPath("old-daemon-terminal.png") }); await agent.client.killTerminal(terminalId); }); } finally { - await agent.cleanup(); - await daemon.close(); + await published.close(); + } +}); + +// Hosts without whole-turn pages split turns across item pages. The oldest loaded turn +// starts mid-turn and only folds once the next page completes it; the reader must stay +// with that turn instead of landing at history start and draining every older page. +test("keeps the reader on the turn that folds when an older item page completes it", async ({ + page, +}) => { + test.setTimeout(240_000); + const published = await startPublishedDaemonAgent({ + publishedVersion: "0.13.0-beta.2", + serverIdPrefix: "srv_old_fold_", + repoPrefix: "timeline-old-daemon-fold-", + title: "Published daemon folded turn pagination", + }); + const { agent, serverId } = published; + const prompts = Array.from({ length: 5 }, (_, index) => `Realistic turn ${index}: inspect it`); + + try { + for (const prompt of prompts) { + await agent.client.sendAgentMessage(agent.agentId, prompt); + await agent.client.waitForFinish(agent.agentId, 30_000); + } + await published.seedBrowser(page, { chatDisplay: "collapseTurns" }); + const history = await holdOlderHistoryPages(page, agent, { + daemonPort: published.daemon.port, + }); + await openAgentTimeline(page, agent, serverId); + await expectTimelinePromptVisible(page, prompts.at(-1)!); + + await userScrollsTimelineToHistoryStart(page); + await history.expectRequestedPages(1); + const partialTurnPrompt = await expectOldestLoadedTurnPartial(page, prompts); + + history.releasePage(1); + await expectFoldedTurnAtReadingPosition(page, partialTurnPrompt); + await history.expectSettledWithRequestedPages(1); + } finally { + await published.close(); } }); diff --git a/packages/app/e2e/browser/plugin-timeline.spec.ts b/packages/app/e2e/browser/plugin-timeline.spec.ts index 166e6b9881..d75653298e 100644 --- a/packages/app/e2e/browser/plugin-timeline.spec.ts +++ b/packages/app/e2e/browser/plugin-timeline.spec.ts @@ -1,4 +1,5 @@ import { test } from "../support/fixtures"; +import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate"; import { withTimelinePlugin, viewInlineThinkingExample, @@ -6,6 +7,8 @@ import { interactWithStreamingCard, expectWholeCompletedCard, expectBothConsecutiveTools, + trackPluginChatRecovery, + reconnectPluginChat, } from "../support/helpers/plugin-timeline"; for (const width of [1100, 390]) { @@ -13,13 +16,24 @@ for (const width of [1100, 390]) { page, }, info) => { await page.setViewportSize({ width, height: 900 }); + const gate = await installDaemonWebSocketGate(page); await withTimelinePlugin(page, info, "assistant", async (agent) => { await requestPluginTimeline(agent); await interactWithStreamingCard(page); await agent.client.waitForFinish(agent.agentId, 30_000); await expectWholeCompletedCard(page); // Local state is checked during growth; the viewport remounts on the history handoff. + const recovery = trackPluginChatRecovery(page); + gate.holdTimelineResponses(agent.agentId); await page.reload({ waitUntil: "domcontentloaded" }); + await gate.waitForHeldTimelineResponse(); + await recovery.expectCatalogWithoutRetransmission(); + gate.releaseHeldTimelineResponses(); + await expectWholeCompletedCard(page); + await recovery.expectRecoveredWithoutRetransmission(); + recovery.reset(); + await reconnectPluginChat(page, gate); + await recovery.expectRecoveredWithoutRetransmission(); await expectWholeCompletedCard(page); }); }); diff --git a/packages/app/e2e/browser/provider-settings-refresh.spec.ts b/packages/app/e2e/browser/provider-settings-refresh.spec.ts index 961b518435..6c9739e894 100644 --- a/packages/app/e2e/browser/provider-settings-refresh.spec.ts +++ b/packages/app/e2e/browser/provider-settings-refresh.spec.ts @@ -1,5 +1,7 @@ +import path from "node:path"; import type { Locator } from "@playwright/test"; import { expect, test, type Page } from "../support/fixtures"; +import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher"; import { expectComposerVisible } from "../support/helpers/composer"; import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; @@ -214,3 +216,66 @@ test.describe("provider settings overlay stack", () => { } }); }); + +async function openTimedOutProvider(page: Page, workspaceId: string, width: number) { + await gotoWorkspace(page, workspaceId); + await clickNewChat(page); + await expectComposerVisible(page); + await page.setViewportSize({ width, height: 900 }); + await page.getByRole("button", { name: /Select model/ }).click(); + if (width < 600) { + await page.getByTestId("agent-controls-model").click(); + } else { + await page.getByRole("button", { name: "Back", exact: true }).click(); + } + await page.getByTestId("model-provider-picker-timeout").click(); + await expect(page.getByText(/Timed out refreshing Pi timeout/)).toBeVisible(); +} + +async function expectErrorHasSideGutters(page: Page) { + const error = page.getByText(/Timed out refreshing Pi timeout/); + const bounds = await error.evaluate((element) => { + const text = element.getBoundingClientRect(); + const state = element.parentElement!.getBoundingClientRect(); + return { left: text.left - state.left, right: state.right - text.right }; + }); + expect(bounds.left, "error text stays inside the empty state's left edge").toBeGreaterThan(0); + expect(bounds.right, "error text stays inside the empty state's right edge").toBeGreaterThan(0); + await expect(page.getByRole("button", { name: "Retry", exact: true })).toBeVisible(); +} + +test.use({ + e2eDaemonConfig: { + version: 1, + agents: { + catalogRefreshTimeoutMs: 2000, + providers: { + "picker-timeout": { + extends: "pi", + label: "Pi timeout", + enabled: true, + command: [ + process.execPath, + path.resolve("e2e/fixtures/fake-pi-rpc.mjs"), + "--stall-model-discovery", + ], + }, + }, + }, + }, +}); + +test.describe("model discovery timeout", () => { + for (const width of [1200, 390]) { + test(`keeps a wrapped discovery error away from both picker edges at ${width}px`, async ({ + page, + withWorkspace, + }, testInfo) => { + await page.setViewportSize({ width: 1200, height: 900 }); + const workspace = await withWorkspace({ prefix: "model-error-spacing-" }); + await openTimedOutProvider(page, workspace.workspaceId, width); + await page.screenshot({ path: testInfo.outputPath("model-error.png") }); + await expectErrorHasSideGutters(page); + }); + } +}); diff --git a/packages/app/e2e/browser/turn-collapse.spec.ts b/packages/app/e2e/browser/turn-collapse.spec.ts index 1deb050fa8..ddbff1e2f3 100644 --- a/packages/app/e2e/browser/turn-collapse.spec.ts +++ b/packages/app/e2e/browser/turn-collapse.spec.ts @@ -1,8 +1,29 @@ -import type { Page } from "@playwright/test"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import path from "node:path"; +import { + holdOlderHistoryPages, + userScrollsTimelineToHistoryStart, +} from "../support/helpers/timeline-pagination"; +import { getActiveTabTestId } from "../support/helpers/launcher"; +import type { Page, Locator } from "@playwright/test"; import { test, expect } from "../support/fixtures"; import { expectComposerVisible } from "../support/helpers/composer"; import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; +test.use({ + e2eDaemonConfig: { + version: 1, + agents: { + providers: { + codex: { + enabled: true, + command: [process.execPath, path.resolve("e2e/fixtures/catalog-codex.mjs")], + }, + }, + }, + }, +}); + type ChatDisplay = "full" | "collapseToolCalls" | "collapseTurns"; async function useChatDisplay(page: Page, chatDisplay: ChatDisplay): Promise { @@ -19,7 +40,10 @@ async function seedAnsweredAgent(title: string) { initialPrompt: "Why does the chat stop following new output?", }); await agent.client.waitForFinish(agent.agentId, 30_000); - await agent.client.sendAgentMessage(agent.agentId, "Make the fix and run the burst script."); + await agent.client.sendAgentMessage( + agent.agentId, + "Make the fix and run the burst script.\n" + "Keep the previous response visible.\n".repeat(40), + ); await agent.client.waitForFinish(agent.agentId, 30_000); return agent; } @@ -36,17 +60,84 @@ async function rowsBetweenLatestSummaryAndReply(page: Page): Promise { }); } +async function groupedToolsInsideLatestTurn(page: Page): Promise { + return page.evaluate(() => { + const rows = [...document.querySelectorAll("[data-message-id]")]; + const start = rows.findLastIndex((row) => row.querySelector("[data-testid=turn-summary]")); + const end = rows.findLastIndex((row) => row.querySelector("[data-testid=assistant-message]")); + return rows + .slice(start + 1, end) + .flatMap((row) => + [...row.querySelectorAll("[data-testid=tool-call-group]")].map( + (group) => group.textContent ?? "", + ), + ); + }); +} + +async function expectCompactMessageToggle(prompt: Locator, label: string) { + const button = prompt.getByRole("button", { name: label, exact: true }); + const text = prompt.getByText(label, { exact: true }); + await expect + .poll( + async () => { + const control = (await button.boundingBox())!; + const glyphs = (await text.boundingBox())!; + return control.height - glyphs.height; + }, + { message: "The inline toggle adds no vertical space beyond its text" }, + ) + .toBe(0); +} + +async function expandAndCollapseLongPrompt(page: Page) { + const prompt = page.getByTestId("user-message").last(); + const before = await prompt.boundingBox(); + const text = prompt.locator('[data-message-text="true"]'); + const messageLeft = (await text.boundingBox())!.x; + await expect + .poll(async () => (await prompt.getByText("Show more", { exact: true }).boundingBox())!.x) + .toBe(messageLeft); + await expectCompactMessageToggle(prompt, "Show more"); + await prompt.getByRole("button", { name: "Show more", exact: true }).click(); + await expect(prompt.getByRole("button", { name: "Show less", exact: true })).toBeVisible(); + await expect + .poll(async () => (await prompt.getByText("Show less", { exact: true }).boundingBox())!.x) + .toBe(messageLeft); + await expect + .poll(async () => (await prompt.boundingBox())?.height ?? 0) + .toBeGreaterThan(before!.height); + await expectCompactMessageToggle(prompt, "Show less"); + await prompt.getByRole("button", { name: "Show less", exact: true }).click(); + await expect.poll(async () => (await prompt.boundingBox())?.height ?? 0).toBe(before!.height); + await prompt.scrollIntoViewIfNeeded(); + const bounds = (await prompt.boundingBox())!; + const body = (await text.boundingBox())!; + await page.screenshot({ + path: "/tmp/message-toggle-aligned.png", + clip: { + x: body.x - 16, + y: bounds.y, + width: bounds.x + bounds.width - body.x + 16, + height: bounds.height, + }, + }); +} + test.describe("Turn collapse", () => { test("folds each answered turn behind a summary line that opens it", async ({ page }) => { test.setTimeout(120_000); + await page.setViewportSize({ width: 1280, height: 1000 }); await useChatDisplay(page, "collapseTurns"); const agent = await seedAnsweredAgent("Turn collapse"); try { await openAgentRoute(page, agent); await expectComposerVisible(page); + await expandAndCollapseLongPrompt(page); + const summary = page.getByTestId("turn-summary").last().getByRole("button"); - await expect(summary).toHaveText(/^Wrote \d+ messages?, edited 1 file/); + await expect(summary).toHaveText(/^Edited 1 file/); // Text chunks that stream back to back merge into one message, so the reply holds // at least the stream's last chunk. await expect(page.getByTestId("assistant-message").last()).toHaveText( @@ -57,6 +148,9 @@ test.describe("Turn collapse", () => { await summary.click(); await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBeGreaterThan(0); await expect(summary).toBeInViewport(); + await summary.evaluate((element) => element.scrollIntoView({ block: "start" })); + await page.screenshot({ path: "/tmp/turn-rail-aligned.png" }); + await expect.poll(() => groupedToolsInsideLatestTurn(page)).toEqual([]); await summary.click(); await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0); @@ -65,3 +159,400 @@ test.describe("Turn collapse", () => { } }); }); + +test("received messages show their sender and open that agent", async ({ page }) => { + await page.addInitScript(() => + localStorage.setItem("@paseo:app-settings", JSON.stringify({ theme: "dark" })), + ); + const recipient = await seedMockAgentWorkspace({ + repoPrefix: "sender-attribution-", + title: "Implementation", + }); + try { + const sender = await recipient.client.createAgent({ + provider: "codex", + cwd: recipient.cwd, + workspaceId: recipient.workspaceId, + title: "Reviewer", + model: "gpt-6.1-sol", + modeId: "full-access", + }); + await recipient.client.sendAgentMessage( + recipient.agentId, + "I reviewed the turn-grouping changes. Heartbeats now keep their own timing footer, and the previous response stays visible.", + { sourceAgentId: sender.id }, + ); + await openAgentRoute(page, recipient); + await expectComposerVisible(page); + const attribution = page.getByRole("button", { name: "Sent by Reviewer", exact: true }); + await expect(attribution).toBeVisible(); + await expect(page.getByTestId("user-message").filter({ has: attribution })).toContainText( + "I reviewed the turn-grouping changes.", + ); + const labelColor = await attribution + .getByText("Sent by Reviewer", { exact: true }) + .evaluate((element) => getComputedStyle(element).color); + await expect(attribution.locator("svg")).toHaveCSS("fill", labelColor); + await expectSenderAlignedWithBubble(attribution); + await attribution.scrollIntoViewIfNeeded(); + await page.screenshot({ path: "/tmp/sender-attribution.png" }); + await attribution.click(); + await expect.poll(() => getActiveTabTestId(page)).toContain(sender.id); + } finally { + await recipient.cleanup(); + } +}); + +interface MessagePreviewWatch { + /** Frames after the watch started on which the fade or "Show more" control was missing. */ + framesWithoutClip: number[]; + /** Frames on which the fade was a different DOM node than when the watch started. */ + framesRemounted: number[]; +} + +declare global { + interface Window { + __paseoMessagePreviewWatch?: { stop(): MessagePreviewWatch }; + } +} + +async function seedReceivedReviewWithToolTurn() { + const recipient = await seedMockAgentWorkspace({ + repoPrefix: "preview-clip-", + title: "Implementation", + }); + const sender = await recipient.client.createAgent({ + provider: "mock", + cwd: recipient.cwd, + workspaceId: recipient.workspaceId, + title: "Reviewer", + modeId: "load-test", + model: "e2e-fast-stream", + }); + // Long enough to clip behind the preview fade; the instruction makes the recipient run tools. + await recipient.client.sendAgentMessage( + recipient.agentId, + "Review complete: the regression is red on the baseline and green with the fix.\n" + + "Keep the previous response visible while the reader scrolls.\n".repeat(30) + + "Now emit 7 tool agent stream updates", + { sourceAgentId: sender.id }, + ); + return recipient; +} + +/** Samples the received message's bubble on every animation frame until stopped. */ +async function watchReceivedMessagePreview(page: Page, senderLabel: string): Promise { + await page.evaluate((label) => { + const findBubble = () => + [...document.querySelectorAll("[data-testid=user-message]")].find((row) => + row.textContent?.includes(label), + ) ?? null; + const initialFade = findBubble()?.querySelector("svg") ?? null; + if (!initialFade) throw new Error(`No faded bubble for ${label}`); + const framesWithoutClip: number[] = []; + const framesRemounted: number[] = []; + let frame = 0; + let running = true; + const sample = () => { + if (!running) return; + const bubble = findBubble(); + const fade = bubble?.querySelector("svg") ?? null; + if (!fade || !bubble?.textContent?.includes("Show more")) framesWithoutClip.push(frame); + else if (fade !== initialFade) framesRemounted.push(frame); + frame += 1; + requestAnimationFrame(sample); + }; + requestAnimationFrame(sample); + window.__paseoMessagePreviewWatch = { + stop() { + running = false; + return { framesWithoutClip, framesRemounted }; + }, + }; + }, senderLabel); +} + +async function expectReceivedMessagePreviewUndisturbed(page: Page): Promise { + // Give the opened turn's layout a few frames to settle before judging. + await page.evaluate( + () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))), + ); + const watch = await page.evaluate(() => window.__paseoMessagePreviewWatch!.stop()); + expect( + watch.framesWithoutClip, + "the preview fade and Show more control stay on every frame", + ).toEqual([]); + expect(watch.framesRemounted, "the preview keeps its DOM node (no remount)").toEqual([]); +} + +// Opening a turn must not rebuild its host row: the preview above the summary loses its +// measured height when remounted, which drops the fade and "Show more" for a frame. +test("opening a turn keeps the received message above it clipped on every frame", async ({ + page, +}) => { + await useChatDisplay(page, "collapseTurns"); + const recipient = await seedReceivedReviewWithToolTurn(); + try { + await openAgentRoute(page, recipient); + await expectComposerVisible(page); + await expect(page.getByText("Completed 7 commands", { exact: true })).toBeVisible({ + timeout: 30_000, + }); + const bubble = page + .getByTestId("user-message") + .filter({ has: page.getByRole("button", { name: "Sent by Reviewer", exact: true }) }); + await expect(bubble.getByRole("button", { name: "Show more", exact: true })).toBeVisible(); + await bubble.scrollIntoViewIfNeeded(); + + await watchReceivedMessagePreview(page, "Sent by Reviewer"); + await page + .getByTestId("turn-summary") + .getByRole("button", { name: "Ran 7 commands", exact: true }) + .click(); + await expect(page.getByText("echo stress-6", { exact: true })).toBeVisible(); + await expectReceivedMessagePreviewUndisturbed(page); + } finally { + await recipient.cleanup(); + } +}); + +async function expectSenderAlignedWithBubble(attribution: Locator) { + const edges = await attribution.evaluate((button) => { + const bubble = button.nextElementSibling; + const label = button.querySelector('[dir="auto"]'); + if (!bubble || !label) throw new Error("Sender label or message bubble missing"); + return { + labelRight: label.getBoundingClientRect().right, + bubbleRight: bubble.getBoundingClientRect().right, + }; + }); + expect( + Math.abs(edges.labelRight - edges.bubbleRight), + "sender and bubble share the right edge", + ).toBeLessThanOrEqual(1); +} + +async function seedWholeTurnHistory() { + const agent = await seedMockAgentWorkspace({ + repoPrefix: "whole-turn-history-", + title: "Whole turns", + model: "e2e-fast-stream", + }); + for (const prompt of [ + ...Array.from( + { length: 25 }, + (_, index) => `Short ${index}: emit 1 coalesced agent stream updates`, + ), + "Older long turn: emit 60 tool agent stream updates", + "Latest long turn: emit 120 tool agent stream updates", + ]) { + await agent.client.sendAgentMessage(agent.agentId, prompt); + await agent.client.waitForFinish(agent.agentId, 15_000); + } + return agent; +} + +test("loads complete long turns and preserves expansion while revealing older history", async ({ + page, +}) => { + test.setTimeout(120_000); + await useChatDisplay(page, "collapseTurns"); + const agent = await seedWholeTurnHistory(); + try { + const history = await holdOlderHistoryPages(page, agent); + await openAgentRoute(page, agent); + await expectComposerVisible(page); + const latest = page + .getByTestId("turn-summary") + .getByRole("button", { name: "Ran 120 commands", exact: true }); + await expect(latest).toBeVisible(); + await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0); + await latest.click(); + await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(120); + await userScrollsTimelineToHistoryStart(page); + await history.expectRequestedPages(1); + history.releasePage(1); + await expect( + page + .getByTestId("turn-summary") + .getByRole("button", { name: "Ran 60 commands", exact: true }), + ).toBeVisible(); + await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(120); + history.expectNoRepeatedEntries(); + await latest.scrollIntoViewIfNeeded(); + await latest.click(); + await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0); + } finally { + await agent.cleanup(); + } +}); + +async function useThinkingDisplay( + page: Page, + autoExpandReasoning: boolean, + chatDisplay: ChatDisplay, +) { + await page.addInitScript( + (settings) => { + localStorage.setItem( + "@paseo:app-settings", + JSON.stringify({ + ...settings, + chatDisplay: sessionStorage.getItem("e2e-chat-display") ?? settings.chatDisplay, + }), + ); + }, + { autoExpandReasoning, chatDisplay }, + ); +} + +for (const autoExpandReasoning of [false, true]) { + test(`reasoning follows Expand thinking (${autoExpandReasoning}) when opening its turn`, async ({ + page, + }) => { + await page.setViewportSize({ width: 1280, height: 1000 }); + await useThinkingDisplay(page, autoExpandReasoning, "collapseToolCalls"); + const agent = await seedMockAgentWorkspace({ + repoPrefix: "thinking-group-", + title: "Thinking with tools", + model: "ten-second-stream", + initialPrompt: "Inspect the scroll container", + }); + try { + await agent.client.waitForFinish(agent.agentId, 30_000); + await openAgentRoute(page, agent); + await expectComposerVisible(page); + const reasoning = page + .getByText(/Need to find the scroll container, the layout effect/) + .filter({ visible: true }) + .first(); + await expectThinkingPresentation(page, autoExpandReasoning); + await switchToCollapsedTurns(page); + await expectComposerVisible(page); + const summary = page.getByTestId("turn-summary").last().getByRole("button"); + await expect(summary).toBeVisible(); + await expect(summary).not.toContainText(/thought/i); + await expect(reasoning).toHaveCount(0); + await summary.click(); + await expect(reasoning).toHaveCount(autoExpandReasoning ? 1 : 0); + await expect.poll(() => groupedToolsInsideLatestTurn(page)).toEqual([]); + await openOneToolOutput(page); + await summary.click(); + await expect(reasoning).toHaveCount(0); + } finally { + await agent.cleanup(); + } + }); +} + +async function openOneToolOutput(page: Page) { + const output = page.getByText(/\[burst\] tick 1 userIsAtBottom=true/).filter({ visible: true }); + await expect(output).toHaveCount(0); + const command = page + .getByTestId("tool-call-badge") + .filter({ hasText: "node scripts/simulate-stream-burst.mjs" }) + .first(); + await command.getByRole("button").first().click(); + await expect(output.first()).toBeVisible(); + await command.getByRole("button").first().click(); + await expect(output).toHaveCount(0); +} + +async function expectThinkingPresentation(page: Page, autoExpandReasoning: boolean) { + const reasoning = page + .getByText(/Need to find the scroll container, the layout effect/) + .filter({ visible: true }) + .first(); + if (autoExpandReasoning) { + await expect(reasoning).toBeVisible(); + await expect(page.getByTestId("tool-call-group").filter({ hasText: /thought/ })).toHaveCount(0); + } else { + await expect(reasoning).toHaveCount(0); + const group = page + .getByTestId("tool-call-group") + .filter({ hasText: /read 1 file/i, visible: true }) + .last(); + await expect(group).toContainText(/read 1 file/i); + await expect(group.getByRole("button").first()).not.toContainText(/thought/i); + await group.getByRole("button").first().click(); + // Expand thinking is off: the group opens its rows, but the thought row stays closed + // until the reader opens it. + const thought = group + .getByTestId("tool-call-badge") + .filter({ hasText: /^Thinking/ }) + .first(); + await expect(thought).toBeVisible(); + await expect(reasoning).toHaveCount(0); + // A press right after the group opens occasionally does not register; retry until the + // thought row opens. + await expect(async () => { + await thought.getByRole("button").first().click(); + await expect(reasoning).toBeVisible({ timeout: 2_000 }); + }).toPass(); + } +} + +async function switchToCollapsedTurns(page: Page) { + await page.evaluate(() => { + sessionStorage.setItem("e2e-chat-display", "collapseTurns"); + }); + await page.reload(); +} + +test("a scheduled heartbeat opens a separate turn without reopening the previous response", async ({ + page, +}) => { + test.setTimeout(90_000); + await useChatDisplay(page, "collapseTurns"); + const agent = await seedMockAgentWorkspace({ + repoPrefix: "heartbeat-turn-", + title: "Heartbeat turns", + model: "ten-second-stream", + initialPrompt: "Inspect the scroll container", + }); + const schedules = agent.client as unknown as Pick< + DaemonClient, + "scheduleCreate" | "scheduleRunOnce" | "scheduleDelete" + >; + let scheduleId: string | undefined; + try { + await agent.client.waitForFinish(agent.agentId, 30_000); + await openAgentRoute(page, agent); + await expectComposerVisible(page); + const summaries = page.getByTestId("turn-summary"); + await expect(summaries).toHaveCount(1); + const previousSummary = await summaries.first().innerText(); + const previousReplyId = await page + .getByTestId("assistant-message") + .last() + .evaluate((element) => element.closest("[data-message-id]")!.getAttribute("data-message-id")); + const created = await schedules.scheduleCreate({ + name: "Check the completed work", + prompt: "Inspect the scroll container again", + cadence: { type: "cron", expression: "0 0 1 1 *" }, + target: { type: "self", agentId: agent.agentId }, + runOnCreate: false, + }); + expect(created.error).toBeNull(); + scheduleId = created.schedule!.id; + const fired = await schedules.scheduleRunOnce({ id: scheduleId }); + expect(fired.error).toBeNull(); + await expect( + page.getByTestId("tool-call-badge").filter({ hasText: /^Heartbeat/ }), + ).toBeVisible(); + await expect(summaries.first()).toHaveText(previousSummary); + const previousReply = page + .locator(`[data-message-id="${previousReplyId}"]`) + .getByTestId("assistant-message"); + await expect(previousReply).toBeVisible(); + await expect(previousReply).toContainText("(end of synthetic stream)"); + await agent.client.waitForFinish(agent.agentId, 30_000); + await expect(summaries).toHaveCount(2); + await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0); + await expect(page.getByRole("button", { name: /^Worked for/ })).toHaveCount(2); + await page.screenshot({ path: "/tmp/heartbeat-turn-footer.png" }); + } finally { + if (scheduleId) await schedules.scheduleDelete({ id: scheduleId }); + await agent.cleanup(); + } +}); diff --git a/packages/app/e2e/fixtures/fake-pi-rpc.mjs b/packages/app/e2e/fixtures/fake-pi-rpc.mjs index b6d156ddfc..372e29c44b 100644 --- a/packages/app/e2e/fixtures/fake-pi-rpc.mjs +++ b/packages/app/e2e/fixtures/fake-pi-rpc.mjs @@ -6,6 +6,7 @@ for await (const line of lines) { const command = JSON.parse(line); let data = {}; if (command.type === "get_available_models") { + if (process.argv.includes("--stall-model-discovery")) continue; data = { models: [ { diff --git a/packages/app/e2e/support/helpers/agent-timeline-gate.ts b/packages/app/e2e/support/helpers/agent-timeline-gate.ts index 408bd8af24..89a1d8c364 100644 --- a/packages/app/e2e/support/helpers/agent-timeline-gate.ts +++ b/packages/app/e2e/support/helpers/agent-timeline-gate.ts @@ -338,11 +338,32 @@ export async function delayAgentOlderTimelineResponse( return delayAgentTimelineResponse(page, agentId, "before"); } +export interface OlderTimelinePagesOptions { + daemonPort?: number; + /** + * Hide whole-turn paging from the app so it reads this host with item pages, as it reads + * daemons released before whole-turn pages. An item page can end inside a turn or a tool run. + */ + itemPagedHost?: boolean; +} + +function withoutWholeTurnPages(message: WebSocketMessage): WebSocketMessage { + const sessionMessage = getSessionMessage(message); + const payload = sessionMessage ? getPayload(sessionMessage) : null; + if (sessionMessage?.type !== "status" || payload?.status !== "server_info") return message; + const envelope = parseWebSocketJson(message) as { + message: { payload: { features?: Record } }; + }; + delete envelope.message.payload.features?.wholeTurnTimeline; + return JSON.stringify(envelope); +} + export async function holdAgentOlderTimelinePages( page: Page, agentId: string, - daemonPort?: number, + options: OlderTimelinePagesOptions = {}, ): Promise { + const { daemonPort } = options; let requestCount = 0; let responseCount = 0; let repeatedEntryCount = 0; @@ -376,7 +397,8 @@ export async function holdAgentOlderTimelinePages( } server.send(message); }); - server.onMessage((message) => { + server.onMessage((received) => { + const message = options.itemPagedHost ? withoutWholeTurnPages(received) : received; const sessionMessage = getSessionMessage(message); const payload = sessionMessage ? getPayload(sessionMessage) : null; if ( diff --git a/packages/app/e2e/support/helpers/custom-models.ts b/packages/app/e2e/support/helpers/custom-models.ts index 11933a0dfa..aff79965ea 100644 --- a/packages/app/e2e/support/helpers/custom-models.ts +++ b/packages/app/e2e/support/helpers/custom-models.ts @@ -19,6 +19,9 @@ export async function addCustomModel(page: Page, id: string): Promise { .getByRole("button", { name: "Add", exact: true }) .click(); await expect(page.getByRole("button", { name: `Remove ${id}`, exact: true })).toHaveCount(1); + // Config updates before provider refresh finishes and dismisses the add sheet. + // Resizing or interacting with the model list must wait for that save to finish. + await expect(page.getByTestId("add-custom-model-sheet")).toBeHidden(); } export async function removeCustomModel(page: Page, id: string): Promise { diff --git a/packages/app/e2e/support/helpers/plugin-timeline.ts b/packages/app/e2e/support/helpers/plugin-timeline.ts index 533b546022..98ab7d303b 100644 --- a/packages/app/e2e/support/helpers/plugin-timeline.ts +++ b/packages/app/e2e/support/helpers/plugin-timeline.ts @@ -5,8 +5,64 @@ import { expect, type Page, type TestInfo } from "@playwright/test"; import { copyPluginExample, pluginRequirements } from "./plugin-fixture"; import { connectNewWorkspaceDaemonClient } from "./new-workspace"; import { openAgentRoute, seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent"; +import { installDaemonWebSocketGate } from "./daemon-websocket-gate"; +import { expectReconnectingToastVisible } from "./workspace-ui"; const PLUGIN_ID = "timeline-transform-e2e"; + +/** Observe the app's real socket, including fresh sockets after a reload. */ +export function trackPluginChatRecovery(page: Page) { + const messages: { + type: string; + payload?: { plugins?: { id: string; clientBundle?: string }[] }; + }[] = []; + page.on("websocket", (socket) => { + const collect = ({ payload }: { payload: string | Buffer }) => { + if (typeof payload !== "string") return; + const frame = JSON.parse(payload); + if (frame.type === "session") messages.push(frame.message); + }; + socket.on("framesent", collect); + socket.on("framereceived", collect); + }); + return { + reset() { + messages.length = 0; + }, + async expectCatalogWithoutRetransmission() { + await expect + .poll(() => + messages.some( + (message) => + message.type === "plugin.catalog.get.response" && + message.payload?.plugins?.some((plugin) => plugin.id === PLUGIN_ID), + ), + ) + .toBe(true); + const entry = messages + .find((message) => message.type === "plugin.catalog.get.response") + ?.payload?.plugins?.find((plugin) => plugin.id === PLUGIN_ID); + expect(entry).toBeDefined(); + expect(entry).not.toHaveProperty("clientBundle"); + }, + async expectRecoveredWithoutRetransmission() { + await this.expectCatalogWithoutRetransmission(); + await expect + .poll(() => messages.some((message) => message.type === "fetch_agent_timeline_response")) + .toBe(true); + }, + }; +} + +export async function reconnectPluginChat( + page: Page, + gate: Awaited>, +) { + await gate.drop(); + await gate.waitForBlockedConnection(); + await expectReconnectingToastVisible(page); + gate.restore(); +} export const ASSISTANT_TEXT = "Skill header\n\n# Instructions\n\nRead every paragraph and preserve the full message while this response is still streaming."; diff --git a/packages/app/e2e/support/helpers/seed-client.ts b/packages/app/e2e/support/helpers/seed-client.ts index 4419e5193f..f57beb5783 100644 --- a/packages/app/e2e/support/helpers/seed-client.ts +++ b/packages/app/e2e/support/helpers/seed-client.ts @@ -149,7 +149,11 @@ export interface SeedDaemonClient { timeout?: number, ): Promise<{ status: string }>; cancelAgent(agentId: string): Promise; - sendAgentMessage(agentId: string, text: string): Promise; + sendAgentMessage( + agentId: string, + text: string, + options?: { sourceAgentId?: string }, + ): Promise; waitForFinish( agentId: string, timeout?: number, diff --git a/packages/app/e2e/support/helpers/timeline-pagination.ts b/packages/app/e2e/support/helpers/timeline-pagination.ts index 52027df956..e575f4f0d9 100644 --- a/packages/app/e2e/support/helpers/timeline-pagination.ts +++ b/packages/app/e2e/support/helpers/timeline-pagination.ts @@ -1,4 +1,4 @@ -import { expect, type TestInfo, type Page } from "@playwright/test"; +import { expect, type Locator, type TestInfo, type Page } from "@playwright/test"; import { buildAgentRoute, seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent"; import { readReplicaCache } from "./replica-cache-storage"; import { @@ -9,6 +9,7 @@ import { type AgentTimelineResponseGate, type BootstrapTimelineGate, type OlderTimelinePagesGate, + type OlderTimelinePagesOptions, } from "./agent-timeline-gate"; export { holdDaemonHydration }; @@ -35,6 +36,19 @@ interface RealisticOlderTimelineAgent extends MockAgentWorkspace { newestPrompt: string; } +export interface ItemPagedToolGroupAgent extends MockAgentWorkspace { + newestPrompt: string; + /** Commands in one run, more than an item page holds. */ + totalCommands: number; + /** Commands of that run the initial tail loads, so the size of the group as first shown. */ + loadedCommands: number; +} + +interface ToolCallGroupPositionSnapshot { + top: number; +} + +const ITEM_PAGE_SIZE = 40; const PROMPT_PREFIX = "timeline-pagination-turn"; const LIVE_BEFORE_HYDRATION_PROMPT = "timeline live before authoritative hydration"; const HISTORY_START_THRESHOLD_PX = 96; @@ -126,6 +140,93 @@ export async function seedRealisticOlderTimeline(): Promise { + const agent = await seedMockAgentWorkspace({ + repoPrefix: "timeline-item-page-tool-group-", + title: "Item page tool group regression", + }); + const olderTurns = 10; + const newerTurns = 12; + const totalCommands = 50; + // A short turn is its prompt and one reply; the run's turn ends with one reply. + const loadedCommands = ITEM_PAGE_SIZE - newerTurns * 2 - 1; + const shortTurnPrompt = (label: string, index: number) => + `item-page-${label}-turn-${index}: emit 1 coalesced agent stream updates`; + const sendTurn = async (prompt: string) => { + await agent.client.sendAgentMessage(agent.agentId, prompt); + await agent.client.waitForFinish(agent.agentId, 15_000); + }; + + for (let index = 0; index < olderTurns; index += 1) { + await sendTurn(shortTurnPrompt("older", index)); + } + await sendTurn(`emit ${totalCommands} tool agent stream updates`); + for (let index = 0; index < newerTurns; index += 1) { + await sendTurn(shortTurnPrompt("newer", index)); + } + + return { + ...agent, + newestPrompt: shortTurnPrompt("newer", newerTurns - 1), + totalCommands, + loadedCommands, + }; +} + +export async function useChatDisplay( + page: Page, + chatDisplay: "full" | "collapseToolCalls" | "collapseTurns", +): Promise { + await page.addInitScript((value) => { + localStorage.setItem("@paseo:app-settings", JSON.stringify({ chatDisplay: value })); + }, chatDisplay); +} + +function toolCallGroup(page: Page, commandCount: number): Locator { + const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first(); + return timeline + .getByTestId("tool-call-group") + .getByRole("button", { name: `Ran ${commandCount} commands`, exact: true }); +} + +export async function rememberToolCallGroupPosition( + page: Page, + commandCount: number, +): Promise { + const group = toolCallGroup(page, commandCount); + await expect(group).toBeVisible(); + const box = await group.boundingBox(); + if (!box) { + throw new Error(`Expected a rendered tool-call group of ${commandCount} commands`); + } + return { top: box.y }; +} + +/** The group the reader was on keeps its place on screen, now counting the older page's calls. */ +export async function expectToolCallGroupPositionPreserved( + page: Page, + commandCount: number, + before: ToolCallGroupPositionSnapshot, +): Promise { + const group = toolCallGroup(page, commandCount); + await expect(group).toBeVisible(); + await expect + .poll( + async () => { + const box = await group.boundingBox(); + return box ? Math.abs(box.y - before.top) : Number.POSITIVE_INFINITY; + }, + { message: `the group of ${commandCount} commands keeps its viewport position` }, + ) + .toBeLessThanOrEqual(2); +} + export async function scrollThroughOlderHistoryPages( page: Page, pageCount: number, @@ -316,13 +417,14 @@ export async function holdNextOlderTimelinePage( export async function holdOlderHistoryPages( page: Page, agent: MockAgentWorkspace, - daemonPort?: number, + options?: OlderTimelinePagesOptions, ): Promise { const gate: OlderTimelinePagesGate = await holdAgentOlderTimelinePages( page, agent.agentId, - daemonPort, + options, ); + const loader = page.getByTestId("load-older-history-spinner"); return { async expectRequestedPages(count) { await gate.waitForRequestCount(count); @@ -330,7 +432,17 @@ export async function holdOlderHistoryPages( }, async expectSettledWithRequestedPages(count) { await waitForTimelineGeometryToSettle(page); - expect(gate.getRequestCount()).toBe(count); + // The loader stays until the prepend settles and the viewport decides whether to + // keep loading. Once it hides, no further page loads without new user intent. + await expect + .poll( + async () => ({ + requestedPages: gate.getRequestCount(), + loading: await loader.isVisible(), + }), + { message: `older history settles after ${count} requested page(s)` }, + ) + .toEqual({ requestedPages: count, loading: false }); }, expectNoRepeatedEntries() { expect(gate.getRepeatedEntryCount()).toBe(0); @@ -423,6 +535,53 @@ export async function expectTimelineViewportAnchoredAfterPrepend( expect(Math.abs(contentGrowth - scrollAdjustment)).toBeLessThanOrEqual(2); } +/** + * Item-based pages from older hosts split turns, so the oldest loaded turn starts + * mid-turn: its prompt is above loaded history. Returns that prompt. + */ +export async function expectOldestLoadedTurnPartial( + page: Page, + prompts: readonly string[], +): Promise { + const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first(); + const oldestLoadedPrompt = await timeline + .getByTestId("user-message") + .first() + .evaluate((element) => element.textContent ?? ""); + const loadedIndex = prompts.findIndex((prompt) => oldestLoadedPrompt.includes(prompt)); + expect(loadedIndex, "oldest loaded prompt is a seeded turn").toBeGreaterThan(0); + const oldestRow = timeline.locator("[data-history-row-id]").first(); + await expect(oldestRow.getByTestId("user-message"), "rows above the oldest prompt").toHaveCount( + 0, + ); + const partialPrompt = prompts[loadedIndex - 1]!; + await expectTimelinePromptNotMounted(page, partialPrompt); + return partialPrompt; +} + +/** The reader stays with the turn they were reading once it folds behind its summary. */ +export async function expectFoldedTurnAtReadingPosition(page: Page, prompt: string): Promise { + const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first(); + const promptRow = timeline.locator("[data-history-row-id]").filter({ hasText: prompt }); + await expect(promptRow).toBeVisible({ timeout: 15_000 }); + // The host row carries the folded turn's summary line. + await expect(promptRow.getByTestId("turn-summary")).toBeVisible(); + await expect + .poll( + async () => { + const [timelineBox, promptBox] = await Promise.all([ + timeline.boundingBox(), + promptRow.boundingBox(), + ]); + if (!timelineBox || !promptBox) return false; + const offset = promptBox.y - timelineBox.y; + return offset >= 0 && offset <= timelineBox.height / 2; + }, + { message: "folded turn sits in the upper half of the viewport" }, + ) + .toBe(true); +} + export async function expectTimelineAtHistoryStart(page: Page): Promise { await expect .poll(async () => (await readTimelineViewport(page)).scrollTop) diff --git a/packages/app/package.json b/packages/app/package.json index 3eddd9b53f..f53b0ec42c 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/app", - "version": "0.13.2", + "version": "0.14.0-beta.1", "private": true, "main": "index.ts", "scripts": { @@ -76,8 +76,8 @@ "@xterm/addon-search": "^0.17.0-beta.213", "@xterm/addon-unicode11": "^0.10.0-beta.213", "@xterm/addon-web-links": "^0.13.0-beta.213", - "@xterm/addon-webgl": "^0.20.0-beta.212", - "@xterm/xterm": "^6.1.0-beta.213", + "@xterm/addon-webgl": "0.20.0-beta.291", + "@xterm/xterm": "6.1.0-beta.292", "buffer": "^6.0.3", "expo": "~57.0.30", "expo-asset": "~57.0.20", diff --git a/packages/app/src/agent-stream/footer-spacing.test.ts b/packages/app/src/agent-stream/footer-spacing.test.ts index 5fa3535bce..341ce75583 100644 --- a/packages/app/src/agent-stream/footer-spacing.test.ts +++ b/packages/app/src/agent-stream/footer-spacing.test.ts @@ -71,7 +71,7 @@ function layoutFor(input: { platform: "web" | "native"; isTurnActive: boolean }) isTurnActive: input.isTurnActive, history: strategy.orderTail([userMessage("user"), assistant]), liveHead: [], - timingByAssistantId: timingFor(assistant.id), + timingByItemId: timingFor(assistant.id), }), }; } diff --git a/packages/app/src/agent-stream/history-window.ts b/packages/app/src/agent-stream/history-window.ts index 347f780023..43cca2bbbf 100644 --- a/packages/app/src/agent-stream/history-window.ts +++ b/packages/app/src/agent-stream/history-window.ts @@ -1,4 +1,5 @@ import type { StreamItem } from "@/types/stream"; +import { continuesTurn } from "./turn-membership"; export const DEFAULT_MOUNTED_RECENT_STREAM_ITEMS = 20; @@ -32,7 +33,7 @@ export function findMountedWindowStart(input: { } let startIndex = Math.max(items.length - minMountedCount, 0); - while (startIndex > 0 && items[startIndex]?.kind !== "user_message") { + while (startIndex > 0 && continuesTurn(items[startIndex - 1], items[startIndex])) { startIndex -= 1; } return startIndex; diff --git a/packages/app/src/agent-stream/layout.test.ts b/packages/app/src/agent-stream/layout.test.ts index f10fde2cb1..a86817b6a7 100644 --- a/packages/app/src/agent-stream/layout.test.ts +++ b/packages/app/src/agent-stream/layout.test.ts @@ -104,7 +104,7 @@ function layoutFor(input: { strategy, streamHead: input.head ?? [], }), - timingByAssistantId: timingFor(...(input.timingIds ?? [])), + timingByItemId: timingFor(...(input.timingIds ?? [])), }); } @@ -148,7 +148,7 @@ function findLayoutItem(layout: StreamLayout, id: string): StreamLayoutItem { } describe("layoutStream", () => { - it("places one response footer after an adjacent tagged tool-only turn", () => { + it("keeps separate footers for an assistant turn and an adjacent tool-only turn", () => { const priorAssistant = assistantMessage("prior", 1, undefined, "turn-1"); const nextTool = toolCall("next-tool", 2, "turn-2"); const layout = layoutFor({ @@ -157,8 +157,8 @@ describe("layoutStream", () => { timingIds: [priorAssistant.id], }); - expect(layout.auxiliaryTurnFooter?.itemId).toBe(priorAssistant.id); - expect(footerOwners(layout)).toEqual([priorAssistant.id]); + expect(layout.auxiliaryTurnFooter?.itemId).toBe(nextTool.id); + expect(footerOwners(layout)).toEqual([priorAssistant.id, nextTool.id]); }); it("recomputes cached history layout when only the live boundary turn changes", () => { @@ -171,7 +171,7 @@ describe("layoutStream", () => { isTurnActive: true, history, liveHead, - timingByAssistantId: timingFor(priorAssistant.id), + timingByItemId: timingFor(priorAssistant.id), }); liveHead[0] = { ...liveHead[0]!, turnId: "turn-2" }; @@ -180,7 +180,7 @@ describe("layoutStream", () => { isTurnActive: true, history, liveHead, - timingByAssistantId: timingFor(priorAssistant.id), + timingByItemId: timingFor(priorAssistant.id), }); expect(footerOwners(first)).toEqual([]); diff --git a/packages/app/src/agent-stream/layout.ts b/packages/app/src/agent-stream/layout.ts index 82ef98db54..924d9d75ec 100644 --- a/packages/app/src/agent-stream/layout.ts +++ b/packages/app/src/agent-stream/layout.ts @@ -39,13 +39,13 @@ export interface StreamLayoutInput { isTurnActive: boolean; history: StreamItem[]; liveHead: StreamItem[]; - timingByAssistantId: Map; + timingByItemId: Map; } interface LayoutSegmentInput { strategy: StreamStrategy; items: StreamItem[]; - timingByAssistantId: Map; + timingByItemId: Map; auxiliaryTurnFooter: TurnFooterHost | null; hasAuxiliaryFooter: boolean; frameOrder: StreamFrameChildOrder; @@ -57,8 +57,8 @@ interface LayoutSegmentInput { phase: "streaming" | "complete"; } -interface AssistantFooterSource { - item: Extract; +interface TurnFooterSource { + item: StreamItem; items: StreamItem[]; index: number; } @@ -67,23 +67,26 @@ function createTurnFooterHost(input: { item: StreamItem; items: StreamItem[]; index: number; - timingByAssistantId: Map; + timingByItemId: Map; }): TurnFooterHost { return { itemId: input.item.id, items: input.items, - timing: input.timingByAssistantId.get(input.item.id), + timing: input.timingByItemId.get(input.item.id), startIndex: input.index, }; } -function findLatestAssistantInResponse(input: { +function findTurnFooterSource(input: { strategy: StreamStrategy; items: StreamItem[]; startIndex: number; boundaryAboveItems?: StreamItem[] | null; boundaryAboveIndex?: number | null; -}): AssistantFooterSource | null { +}): TurnFooterSource | null { + const last = input.items[input.startIndex]; + const toolFallback: TurnFooterSource | null = + last?.kind === "tool_call" ? { item: last, items: input.items, index: input.startIndex } : null; let items = input.items; let index = input.startIndex; let canCrossBoundary = true; @@ -97,7 +100,7 @@ function findLatestAssistantInResponse(input: { ) { const item = items[index]; if (!item || (laterItem && !continuesResponse(item, laterItem))) { - return null; + return toolFallback; } if (item.kind === "assistant_message") { return { item, items, index }; @@ -111,7 +114,7 @@ function findLatestAssistantInResponse(input: { input.boundaryAboveIndex === null || input.boundaryAboveIndex === undefined ) { - return null; + return toolFallback; } items = input.boundaryAboveItems; @@ -131,20 +134,20 @@ function resolveAuxiliaryTurnFooter(input: StreamLayoutInput): TurnFooterHost | return null; } - const assistant = findLatestAssistantInResponse({ + const source = findTurnFooterSource({ strategy: input.strategy, items: footerItems, startIndex: latestIndex, }); - if (!assistant) { + if (!source) { return null; } return createTurnFooterHost({ - item: assistant.item, - items: assistant.items, - index: assistant.index, - timingByAssistantId: input.timingByAssistantId, + item: source.item, + items: source.items, + index: source.index, + timingByItemId: input.timingByItemId, }); } @@ -154,7 +157,7 @@ function resolveCompletedFooter(input: { index: number; item: StreamItem; belowItem: StreamItem | null; - timingByAssistantId: Map; + timingByItemId: Map; auxiliaryTurnFooter: TurnFooterHost | null; boundaryAboveItems: StreamItem[] | null; boundaryAboveIndex: number | null; @@ -163,21 +166,21 @@ function resolveCompletedFooter(input: { return null; } - const assistant = findLatestAssistantInResponse({ + const source = findTurnFooterSource({ strategy: input.strategy, items: input.items, startIndex: input.index, boundaryAboveItems: input.boundaryAboveItems, boundaryAboveIndex: input.boundaryAboveIndex, }); - if (!assistant || input.auxiliaryTurnFooter?.itemId === assistant.item.id) { + if (!source || input.auxiliaryTurnFooter?.itemId === source.item.id) { return null; } return createTurnFooterHost({ - item: assistant.item, - items: assistant.items, - index: assistant.index, - timingByAssistantId: input.timingByAssistantId, + item: source.item, + items: source.items, + index: source.index, + timingByItemId: input.timingByItemId, }); } @@ -306,7 +309,7 @@ function layoutSegmentItem( index, item, belowItem, - timingByAssistantId: input.timingByAssistantId, + timingByItemId: input.timingByItemId, auxiliaryTurnFooter: input.auxiliaryTurnFooter, boundaryAboveItems: input.boundaryAboveItems, boundaryAboveIndex: input.boundaryAboveIndex, @@ -375,7 +378,7 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout { history = layoutSegment({ strategy: input.strategy, items: input.history, - timingByAssistantId: input.timingByAssistantId, + timingByItemId: input.timingByItemId, auxiliaryTurnFooter, hasAuxiliaryFooter, frameOrder, @@ -395,7 +398,7 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout { const liveHead = layoutSegment({ strategy: input.strategy, items: input.liveHead, - timingByAssistantId: input.timingByAssistantId, + timingByItemId: input.timingByItemId, auxiliaryTurnFooter, hasAuxiliaryFooter, frameOrder, diff --git a/packages/app/src/agent-stream/model.test.ts b/packages/app/src/agent-stream/model.test.ts index dec4c489b9..9c6ef3384d 100644 --- a/packages/app/src/agent-stream/model.test.ts +++ b/packages/app/src/agent-stream/model.test.ts @@ -69,8 +69,8 @@ describe("buildAgentStreamRenderModel", () => { historyStart: 2, }); - expect(model.turnTiming.byAssistantId.has("hidden-a")).toBe(false); - expect(model.turnTiming.byAssistantId.get("visible-a")).toEqual({ + expect(model.turnTiming.byItemId.has("hidden-a")).toBe(false); + expect(model.turnTiming.byItemId.get("visible-a")).toEqual({ completedAt: tail[3]?.timestamp, durationMs: 1000, }); @@ -184,7 +184,7 @@ describe("buildAgentStreamRenderModel", () => { }); expect(model.turnTiming.runningStartedAt).toBe(tail[0]?.timestamp); - expect(model.turnTiming.byAssistantId.has("live-a")).toBe(false); + expect(model.turnTiming.byItemId.has("live-a")).toBe(false); }); it("maps completed turn timing to assistant ids across committed history and live head", () => { @@ -201,7 +201,7 @@ describe("buildAgentStreamRenderModel", () => { }); expect(model.turnTiming.runningStartedAt).toBe(null); - expect(model.turnTiming.byAssistantId.get("live-a")).toEqual({ + expect(model.turnTiming.byItemId.get("live-a")).toEqual({ completedAt: head[0]?.timestamp, durationMs: 3000, }); @@ -220,7 +220,7 @@ describe("buildAgentStreamRenderModel", () => { }); expect(model.segments.historyMounted.map((item) => item.id)).toEqual(["a1", "u1"]); - expect(model.turnTiming.byAssistantId.get("a1")).toEqual({ + expect(model.turnTiming.byItemId.get("a1")).toEqual({ completedAt: tail[1]?.timestamp, durationMs: 3000, }); @@ -238,7 +238,7 @@ describe("buildAgentStreamRenderModel", () => { isMobileBreakpoint: false, }); - expect(model.turnTiming.byAssistantId.size).toBe(0); + expect(model.turnTiming.byItemId.size).toBe(0); }); it.each(["web", "native"] as const)( @@ -274,10 +274,10 @@ describe("buildAgentStreamRenderModel", () => { expect(second.history).toBe(first.history); expect(second.segments.historyMounted).toBe(first.segments.historyMounted); - expect(second.turnTiming.byAssistantId).toBe(first.turnTiming.byAssistantId); + expect(second.turnTiming.byItemId).toBe(first.turnTiming.byItemId); expect(first.history).toHaveLength(20); - expect(first.turnTiming.byAssistantId.has("a29")).toBe(false); - expect(first.turnTiming.byAssistantId.get("a38")).toEqual({ + expect(first.turnTiming.byItemId.has("a29")).toBe(false); + expect(first.turnTiming.byItemId.get("a38")).toEqual({ completedAt: tail[77]?.timestamp, durationMs: 1000, }); diff --git a/packages/app/src/agent-stream/presentation.test.ts b/packages/app/src/agent-stream/presentation.test.ts index 357b6c079b..ce3c0352ff 100644 --- a/packages/app/src/agent-stream/presentation.test.ts +++ b/packages/app/src/agent-stream/presentation.test.ts @@ -15,6 +15,7 @@ import { QueryClient } from "@tanstack/react-query"; import { describe, expect, it } from "vitest"; import { buildAgentStreamRenderModel } from "./model"; import { createStreamPresentation } from "./presentation"; +import { createReadingAnchor } from "./reading-anchor"; const runtime = { paseo: {}, @@ -588,3 +589,197 @@ describe("timeline presentation", () => { expect(projectTimelineItems([source], () => [])).toEqual([]); }); }); + +it("opening a turn reveals individual calls rather than another tool group", () => { + const present = createStreamPresentation(); + const timestamp = new Date(); + const tail: StreamItem[] = [ + { kind: "user_message", id: "prompt", text: "Work", timestamp }, + toolCall("one", "Bash"), + toolCall("two", "Bash"), + { kind: "assistant_message", id: "reply", text: "Done", timestamp }, + ]; + const result = present({ + ...presentationOptions, + transform: undefined, + tail, + head: [], + display: "collapseTurns", + isTurnActive: false, + expandedTurnHostIds: new Set(["prompt"]), + }); + expect(result.tail.map((item) => item.id)).toEqual(tail.map((item) => item.id)); + expect(result.groupsByHostId.size).toBe(0); + expect(result.readingRowReplacements.get("tool-two")).toBeUndefined(); +}); + +it.each(["collapseToolCalls", "collapseTurns"] as const)( + "preserves the reader when a prepend extends a group in %s", + (display) => { + const present = createStreamPresentation(); + const anchor = createReadingAnchor(); + const calls = [toolCall("one", "Bash"), toolCall("two", "Bash")]; + const input = { + ...presentationOptions, + transform: undefined, + display, + head: [], + isTurnActive: false, + }; + const partial = present({ ...input, tail: calls.slice(1) }); + anchor.reconcile(0, [{ id: partial.tail[0]!.id, top: 48, height: 200 }]); + const completed = present({ ...input, tail: calls }); + const groupId = completed.tail[0]!.id; + expect(anchor.getRowId(completed.readingRowReplacements)).toBe(groupId); + expect( + anchor.reconcile( + 0, + [{ id: groupId, top: 448, height: 200 }], + false, + completed.readingRowReplacements, + ), + ).toBe(400); + expect(anchor.getRowId()).toBe(groupId); + const timestamp = new Date(); + const folded = present({ + ...input, + display: "collapseTurns", + tail: [ + { kind: "user_message", id: "prompt", text: "Work", timestamp }, + ...calls, + { kind: "assistant_message", id: "reply", text: "Done", timestamp }, + ], + }); + // Both the original partial group and its replacement resolve directly to the turn. + expect(folded.readingRowReplacements.get(calls[1]!.id)).toBe("prompt"); + expect(anchor.getRowId(folded.readingRowReplacements)).toBe("prompt"); + const opened = present({ ...input, tail: calls, display: "full" }); + expect(opened.readingRowReplacements.get(calls[1]!.id)).toBeUndefined(); + }, +); + +it("presents received agent messages as attributed bubbles with the original turn identity", () => { + const call = toolCall("received", "agent_message"); + if (call.payload.source !== "agent") throw new Error("Expected agent tool"); + call.turnId = "turn"; + call.payload.data.agentMessage = { + event: "message", + sender: { id: "sender", title: "Reviewer" }, + text: "Ready", + }; + const result = createStreamPresentation()({ + ...presentationOptions, + transform: undefined, + tail: [call], + head: [], + }); + expect(result.tail[0]).toMatchObject({ + kind: "user_message", + id: call.id, + turnId: "turn", + text: "Ready", + sender: { id: "sender", title: "Reviewer" }, + }); +}); + +it("does not reopen the previous turn while a new run waits for its first row", () => { + const timestamp = new Date(); + const tail: StreamItem[] = [ + { kind: "user_message", id: "prompt", text: "Work", timestamp }, + toolCall("one", "Bash"), + { kind: "assistant_message", id: "reply", text: "Done", timestamp }, + ]; + for (const item of tail) item.turnId = "completed"; + const result = createStreamPresentation()({ + ...presentationOptions, + transform: undefined, + tail, + head: [], + display: "collapseTurns", + isTurnActive: true, + activeTurnId: "new", + }); + expect(result.tail.map((item) => item.id)).toEqual(["prompt", "reply"]); +}); + +// A sent prompt counts as activity before the daemon opens its turn, so the only known turn is +// the completed one; it must stay folded until the new turn's rows arrive. +it("keeps the previous turn folded while a sent prompt waits for its turn to open", () => { + const timestamp = new Date(); + const tail: StreamItem[] = [ + { kind: "user_message", id: "prompt", text: "Work", timestamp }, + toolCall("one", "Bash"), + { kind: "assistant_message", id: "reply", text: "Done", timestamp }, + ]; + for (const item of tail) item.turnId = "completed"; + const result = createStreamPresentation()({ + ...presentationOptions, + transform: undefined, + tail, + head: [], + display: "collapseTurns", + isTurnActive: true, + activeTurnId: null, + }); + expect(result.tail.map((item) => item.id)).toEqual(["prompt", "reply"]); +}); + +it("groups reasoning with tools unless Expand thinking is enabled, including setting changes", () => { + const present = createStreamPresentation(); + const timestamp = new Date(); + const thought: StreamItem = { + kind: "thought", + id: "thought", + text: "Consider this", + status: "ready", + timestamp, + }; + const tail: StreamItem[] = [toolCall("one", "Bash"), thought, toolCall("two", "Bash")]; + for (const item of tail) item.turnId = "run"; + const input = { + ...presentationOptions, + transform: undefined, + tail, + head: [], + display: "collapseToolCalls" as const, + isTurnActive: false, + }; + const grouped = present({ ...input, autoExpandReasoning: false }); + expect(grouped.tail).toHaveLength(1); + expect(grouped.groupsByHostId.get("tool-one")?.run.calls).toEqual(tail); + const separate = present({ ...input, autoExpandReasoning: true }); + expect(separate.tail.map((item) => item.id)).toEqual(["tool-one", "thought", "tool-two"]); + expect(separate.groupsByHostId.has("thought")).toBe(false); + const next = { ...thought, id: "next", turnId: "next-run" }; + const bounded = present({ ...input, tail: [...tail, next], autoExpandReasoning: false }); + expect(bounded.tail.map((item) => item.id)).toEqual(["tool-one", "next"]); +}); + +it.each([false, true])( + "reveals raw reasoning when opening a turn (Expand thinking %s)", + (autoExpandReasoning) => { + const timestamp = new Date(); + const tail: StreamItem[] = [ + { kind: "user_message", id: "prompt", text: "Work", timestamp }, + { kind: "thought", id: "thought", text: "Consider this", status: "ready", timestamp }, + toolCall("one", "Bash"), + { kind: "assistant_message", id: "reply", text: "Done", timestamp }, + ]; + const input = { + ...presentationOptions, + transform: undefined, + tail, + head: [], + display: "collapseTurns" as const, + isTurnActive: false, + autoExpandReasoning, + }; + const present = createStreamPresentation(); + const closed = present(input); + expect(closed.tail.some((item) => item.id === "thought")).toBe(false); + const opened = present({ ...input, expandedTurnHostIds: new Set(["prompt"]) }); + expect(opened.tail).toEqual(tail); + expect(opened.turns.nestedItemIds.has("thought")).toBe(true); + expect(opened.groupsByHostId.size).toBe(0); + }, +); diff --git a/packages/app/src/agent-stream/presentation.ts b/packages/app/src/agent-stream/presentation.ts index 9e8e5b430e..94140ca395 100644 --- a/packages/app/src/agent-stream/presentation.ts +++ b/packages/app/src/agent-stream/presentation.ts @@ -1,3 +1,4 @@ +import { isTurnBoundary } from "./turn-membership"; import type { ChatDisplay } from "@/hooks/use-settings"; import type { TimelineItemTransform } from "@/plugins/timeline/model"; import { projectPluginTimelineItems } from "@/plugins/timeline/projection"; @@ -10,19 +11,38 @@ import { } from "@/tool-calls/detail-level/projection"; import type { StreamItem, UserMessageItem } from "@/types/stream"; import { collapseTurns, type TurnCollapse } from "./turn-collapse/model"; +import type { ReadingRowReplacements } from "./reading-anchor"; interface PresentationInput { tail: StreamItem[]; head: StreamItem[]; transform: TimelineItemTransform | undefined; display: ChatDisplay; + autoExpandReasoning?: boolean; /** User messages whose collapsed turn the reader opened. */ expandedTurnHostIds: ReadonlySet; isTurnActive: boolean; + activeTurnId?: string | null; } export interface StreamPresentation extends ToolCallDetailProjection { turns: Omit; + readingRowReplacements: ReadingRowReplacements; +} + +function groupedRowHosts( + rows: readonly StreamItem[], + groups: ToolCallDetailProjection["groupsByHostId"], +): ReadonlyMap { + const hosts = new Map(); + for (const row of rows) { + const group = groups.get(row.id); + if (!group) continue; + for (const call of group.run.calls) { + if (call.id !== row.id) hosts.set(call.id, row.id); + } + } + return hosts; } const NO_COLLAPSED_TURNS: StreamPresentation["turns"] = collapseTurns({ @@ -55,27 +75,54 @@ export function createStreamPresentation() { let historyTransform: TimelineItemTransform | undefined; let historyRows: StreamItem[] = []; let preparedTail: StreamItem[] | undefined; + let preparedAutoExpandReasoning: boolean | undefined; let preparedLevel: ToolCallDetailLevel | undefined; let preparedHistory: PreparedToolCallHistory | null = null; + let historyGroupHosts: ReadonlyMap = new Map(); let collapsedSource: StreamItem[] | undefined; let collapsedIsLatestTurnOpen = false; let collapsedExpandedHostIds: ReadonlySet | undefined; let collapsed: TurnCollapse | undefined; function presentRows(item: StreamItem): StreamItem[] { + if (item.kind === "tool_call" && item.payload.source === "agent") { + const message = item.payload.data.agentMessage; + if (message?.event === "message") { + return [ + { + kind: "user_message", + id: item.id, + turnId: item.turnId, + timelineCursor: item.timelineCursor, + timestamp: item.timestamp, + text: message.text, + sender: message.sender, + }, + ]; + } + } return [item.kind === "user_message" ? presentUserMessage(item) : item]; } function collapse( projection: ToolCallDetailProjection, input: PresentationInput, - ): StreamPresentation { + ): Omit { if (input.display !== "collapseTurns") { return { ...projection, turns: NO_COLLAPSED_TURNS }; } // Live text updates reach the head only, so the folded history is reused until the // retained rows, the latest turn's liveness, or the reader's expanded turns change. - const isLatestTurnOpen = input.isTurnActive || projection.head.length > 0; + const last = projection.tail.at(-1) ?? null; + const first = projection.head[0] ?? null; + const activeTurnIsAnother = Boolean( + input.activeTurnId && last?.turnId && input.activeTurnId !== last.turnId, + ); + // Without head rows, the latest retained turn is live only while the active run is that + // turn. A run that has not opened yet carries no id and belongs to the next turn. + const isLatestTurnOpen = first + ? !isTurnBoundary(last, first) && !activeTurnIsAnother + : input.isTurnActive && (last?.turnId === undefined || input.activeTurnId === last.turnId); if ( !collapsed || collapsedSource !== projection.tail || @@ -93,7 +140,20 @@ export function createStreamPresentation() { collapsedExpandedHostIds = input.expandedTurnHostIds; } const { tail, ...turns } = collapsed; - return { ...projection, tail, turns }; + if (turns.nestedItemIds.size === 0) return { ...projection, tail, turns }; + // Opening a turn reveals its raw calls; those rows must not become group hosts again. + const withoutExpandedRows = (groups: StreamPresentation["groupsByHostId"]) => ({ + size: groups.size - [...turns.nestedItemIds].filter((id) => groups.has(id)).length, + get: (id: string) => (turns.nestedItemIds.has(id) ? undefined : groups.get(id)), + has: (id: string) => !turns.nestedItemIds.has(id) && groups.has(id), + }); + return { + ...projection, + tail, + turns, + groupsByHostId: withoutExpandedRows(projection.groupsByHostId), + historyGroupUpdatesByHostId: withoutExpandedRows(projection.historyGroupUpdatesByHostId), + }; } return (input: PresentationInput): StreamPresentation => { @@ -111,20 +171,48 @@ export function createStreamPresentation() { const blocks = presentRows(item); head.push(...blocks); } - if (preparedTail !== historyRows || preparedLevel !== level) { - preparedHistory = prepareToolCallHistory(level, historyRows); + if ( + preparedTail !== historyRows || + preparedLevel !== level || + preparedAutoExpandReasoning !== input.autoExpandReasoning + ) { + preparedHistory = prepareToolCallHistory(level, historyRows, input.autoExpandReasoning); + historyGroupHosts = preparedHistory + ? groupedRowHosts(preparedHistory.grouped.tail, preparedHistory.grouped.groupsByHostId) + : new Map(); preparedTail = historyRows; preparedLevel = level; + preparedAutoExpandReasoning = input.autoExpandReasoning; } - return collapse( - projectToolCallDetailLevel({ - level, - tail: historyRows, - head, - preparedHistory, - isTurnActive: input.isTurnActive, - }), - input, + const projection = projectToolCallDetailLevel({ + level, + tail: historyRows, + head, + preparedHistory, + isTurnActive: input.isTurnActive, + }); + const result = collapse(projection, input); + // Only the trailing retained group can absorb head rows. Reuse the retained + // index across streaming updates rather than walking the full history again. + const last = projection.tail.at(-1); + const liveGroupHosts = groupedRowHosts( + last ? [last, ...projection.head] : projection.head, + projection.groupsByHostId, ); + const retainedGroupHosts = historyGroupHosts; + return { + ...result, + readingRowReplacements: { + get(id) { + if (result.turns.nestedItemIds.has(id)) return undefined; + const groupHost = liveGroupHosts.get(id) ?? retainedGroupHosts.get(id) ?? id; + return ( + result.turns.hostIdByHiddenRowId.get(id) ?? + result.turns.hostIdByHiddenRowId.get(groupHost) ?? + (groupHost === id ? undefined : groupHost) + ); + }, + }, + }; }; } diff --git a/packages/app/src/agent-stream/reading-anchor.test.ts b/packages/app/src/agent-stream/reading-anchor.test.ts index 74b2bd5bcd..d7d1b2beb4 100644 --- a/packages/app/src/agent-stream/reading-anchor.test.ts +++ b/packages/app/src/agent-stream/reading-anchor.test.ts @@ -2,6 +2,24 @@ import { describe, expect, it } from "vitest"; import { createReadingAnchor } from "./reading-anchor"; describe("reading anchor", () => { + it("pins the folded turn host when a prepend replaces the row being read", () => { + const anchor = createReadingAnchor(); + anchor.reconcile(0, [{ id: "tool", top: 48, height: 200 }]); + const replacements = new Map([["tool", "prompt"]]); + expect(anchor.getRowId(replacements)).toBe("prompt"); + expect(anchor.project(0, { id: "prompt", top: 1048 }, replacements)).toBe(1000); + // Projection does not consume the correction; an unmounted host keeps the pin. + expect(anchor.reconcile(0, [], false, replacements)).toBe(0); + expect(anchor.getRowId()).toBe("tool"); + expect( + anchor.reconcile(0, [{ id: "prompt", top: 1048, height: 100 }], false, replacements), + ).toBe(1000); + expect(anchor.getRowId()).toBe("prompt"); + expect( + anchor.reconcile(1000, [{ id: "prompt", top: 1048, height: 100 }], false, replacements), + ).toBe(1000); + }); + const rows = [ { id: "above", top: 0, height: 500 }, { id: "reading", top: 500, height: 500 }, diff --git a/packages/app/src/agent-stream/reading-anchor.ts b/packages/app/src/agent-stream/reading-anchor.ts index 4299f1d9c8..aa915c6ae7 100644 --- a/packages/app/src/agent-stream/reading-anchor.ts +++ b/packages/app/src/agent-stream/reading-anchor.ts @@ -6,6 +6,10 @@ interface RowGeometry { // A row must clear the reading line before the next row takes ownership. const READING_POSITION_OFFSET_PX = 8; +export interface ReadingRowReplacements { + get(id: string): string | undefined; +} +const NO_REPLACEMENTS: ReadingRowReplacements = new Map(); // Content coordinates survive user scrolling. Layout commits replace the geometry; // scroll events reuse it, without another DOM measurement pass. @@ -14,10 +18,15 @@ export function createReadingAnchor() { let geometry: readonly RowGeometry[] = []; const readingRow = (scrollTop: number) => geometry.find((row) => row.top + row.height > scrollTop + READING_POSITION_OFFSET_PX); - const project = (scrollTop: number, row: Pick | undefined) => - scrollTop + (anchor && row?.id === anchor.id ? row.top - anchor.top : 0); + const getRowId = (replacements = NO_REPLACEMENTS) => + anchor ? (replacements.get(anchor.id) ?? anchor.id) : null; + const project = ( + scrollTop: number, + row: Pick | undefined, + replacements = NO_REPLACEMENTS, + ) => scrollTop + (anchor && row && row.id === getRowId(replacements) ? row.top - anchor.top : 0); return { - getRowId: () => anchor?.id ?? null, + getRowId, getReadingRowId: (scrollTop: number) => readingRow(scrollTop)?.id ?? geometry.at(-1)?.id ?? null, project, @@ -28,13 +37,18 @@ export function createReadingAnchor() { const next = readingRow(scrollTop); anchor = next ? { id: next.id, top: next.top } : null; }, - reconcile(scrollTop: number, rows: readonly RowGeometry[], userScrolled = false): number { + reconcile( + scrollTop: number, + rows: readonly RowGeometry[], + userScrolled = false, + replacements = NO_REPLACEMENTS, + ): number { geometry = rows; - const previous = anchor && rows.find((row) => row.id === anchor?.id); + const previous = anchor && rows.find((row) => row.id === getRowId(replacements)); // The first virtualized commit can precede its mounted range. Keep the // pinned reader until it mounts, rather than adopting an unrelated row. if (anchor && !previous && !userScrolled) return scrollTop; - const correctedTop = project(scrollTop, previous ?? undefined); + const correctedTop = project(scrollTop, previous ?? undefined, replacements); // A prepend can expose the bottom of an estimated row above the reader. // Do not transfer ownership to it until the user moves the reading position. const next = previous && !userScrolled ? previous : readingRow(correctedTop); diff --git a/packages/app/src/agent-stream/render-strategy.test.ts b/packages/app/src/agent-stream/render-strategy.test.ts index 22d7c5f2f1..59a2f7aed3 100644 --- a/packages/app/src/agent-stream/render-strategy.test.ts +++ b/packages/app/src/agent-stream/render-strategy.test.ts @@ -214,7 +214,7 @@ describe("neighbor and traversal semantics", () => { ).toBe("assistant-1\n\nassistant-2"); }); - it("collects copy content across adjacent turns without a visible prompt", () => { + it("keeps copied content within the canonical turn without a visible prompt", () => { const chronological: StreamItem[] = [ { ...assistantMessage("a1", "first turn", 1), turnId: "turn-1" }, { ...toolCall("tool", 2), turnId: "turn-2" }, @@ -228,7 +228,7 @@ describe("neighbor and traversal semantics", () => { items: chronological, startIndex: 2, }), - ).toBe("first turn\n\nsecond turn"); + ).toBe("second turn"); }); it("returns undefined neighbor when index would be out of bounds", () => { diff --git a/packages/app/src/agent-stream/strategy-web.test.tsx b/packages/app/src/agent-stream/strategy-web.test.tsx index 1f61d0a14c..5ad0be03c2 100644 --- a/packages/app/src/agent-stream/strategy-web.test.tsx +++ b/packages/app/src/agent-stream/strategy-web.test.tsx @@ -7,7 +7,7 @@ import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { RetainedPanelActivity } from "@/components/retained-panel"; -import type { StreamItem } from "@/types/stream"; +import { reduceStreamUpdate, type StreamItem } from "@/types/stream"; import type { StreamRenderInput, StreamSegmentRenderers, StreamViewportHandle } from "./strategy"; import { createWebStreamStrategy } from "./strategy-web"; @@ -131,6 +131,79 @@ describe("createWebStreamStrategy", () => { vi.restoreAllMocks(); }); + it.each(["liveHead", "historyMounted"] as const)( + "keeps one rendered tool row after late prior-turn updates in %s", + (lane) => { + const strategy = createWebStreamStrategy({ isMobileBreakpoint: true }); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + const input: StreamRenderInput = { + agentId: "agent", + segments: { historyVirtualized: [], historyMounted: [], liveHead: [] }, + boundary: { + hasVirtualizedHistory: false, + hasMountedHistory: lane === "historyMounted", + hasLiveHead: lane === "liveHead", + }, + renderers: createRenderers(() => {}), + listEmptyComponent: null, + viewportRef: React.createRef(), + routeBottomAnchorRequest: null, + isAuthoritativeHistoryReady: true, + onNearBottomChange: () => {}, + onNearHistoryStart: () => true, + isLoadingOlderHistory: false, + hasOlderHistory: false, + olderHistoryProgressKey: null, + scrollEnabled: true, + listStyle: null, + baseListContentContainerStyle: null, + forwardListContentContainerStyle: null, + contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH, + }; + let items: StreamItem[] = []; + const updates = [ + { callId: "background", turnId: "old", status: "running" }, + { callId: "exec", turnId: "current", status: "running" }, + { callId: "background", turnId: "old", status: "completed" }, + { callId: "exec", turnId: "current", status: "completed" }, + ] as const; + for (const [index, update] of updates.entries()) { + items = reduceStreamUpdate( + items, + { + type: "timeline", + provider: "codex", + turnId: update.turnId, + item: { + type: "tool_call", + callId: update.callId, + name: "exec_command", + status: update.status, + error: null, + detail: { type: "shell", command: update.callId }, + }, + }, + new Date(index), + ); + act(() => + root?.render( + strategy.render({ + ...input, + segments: { ...input.segments, [lane]: items }, + }), + ), + ); + } + const rows = [...container.querySelectorAll("[data-history-row-id]")]; + expect(rows.map((row) => row.getAttribute("data-history-row-id"))).toEqual([ + "agent_tool_turn:old/background", + "agent_tool_turn:current/exec", + ]); + }, + ); + it("mounts virtualized history without recursive row measurement updates", () => { const rowRenderCount = vi.fn(); const strategy = createWebStreamStrategy({ isMobileBreakpoint: true }); diff --git a/packages/app/src/agent-stream/strategy-web.tsx b/packages/app/src/agent-stream/strategy-web.tsx index 8e8bdf7680..9dae42bcc0 100644 --- a/packages/app/src/agent-stream/strategy-web.tsx +++ b/packages/app/src/agent-stream/strategy-web.tsx @@ -394,7 +394,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool const rangeExtractor = useCallback( (range: VirtualRange): number[] => { const anchorIndex = segments.historyVirtualized.findIndex( - (row) => row.id === readingAnchor.getRowId(), + (row) => row.id === readingAnchor.getRowId(props.readingRowReplacements), ); const container = scrollContainerRef.current; // TanStack has computed the new measurements before calling the extractor. @@ -404,10 +404,14 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool const measurement = virtualizer.measurementsCache[anchorIndex]; const correctedOffset = container && measurement - ? readingAnchor.project(container.scrollTop, { - id: String(measurement.key), - top: measurement.start, - }) + ? readingAnchor.project( + container.scrollTop, + { + id: String(measurement.key), + top: measurement.start, + }, + props.readingRowReplacements, + ) : container?.scrollTop; let visibleRange = range; if ( @@ -428,7 +432,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool ]), ].sort((left, right) => left - right); }, - [chatFindRowIndexes, readingAnchor, segments.historyVirtualized], + [chatFindRowIndexes, readingAnchor, segments.historyVirtualized, props.readingRowReplacements], ); const virtualOffsetListener = useRef<((offset: number, scrolling: boolean) => void) | null>(null); const observeVirtualOffset = useCallback((instance, onOffset) => { @@ -742,7 +746,12 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool ); const shouldAnchor = !followOutputRef.current && !isJumpSettling(); if (!shouldAnchor) readingAnchor.reset(); - const correctedTop = readingAnchor.reconcile(container.scrollTop, rows); + const correctedTop = readingAnchor.reconcile( + container.scrollTop, + rows, + false, + props.readingRowReplacements, + ); if (!shouldAnchor) { readingAnchor.reset(); return; diff --git a/packages/app/src/agent-stream/strategy.ts b/packages/app/src/agent-stream/strategy.ts index 3d9df4d201..364eba1043 100644 --- a/packages/app/src/agent-stream/strategy.ts +++ b/packages/app/src/agent-stream/strategy.ts @@ -1,6 +1,7 @@ import type { ComponentType, ReactElement, ReactNode, RefObject } from "react"; import type { StyleProp, ViewStyle } from "react-native"; import type { StreamItem } from "@/types/stream"; +import type { ReadingRowReplacements } from "./reading-anchor"; import { continuesResponse } from "./turn-membership"; import type { StreamHistoryBoundary, StreamRenderSegments } from "./model"; import type { @@ -78,6 +79,8 @@ export interface StreamRenderInput { segments: StreamRenderSegments; historyRowRevision?: StreamHistoryRowRevision; liveHeadRowRevision?: unknown; + /** Presentation rows replaced by another row after history is folded. */ + readingRowReplacements?: ReadingRowReplacements; boundary: StreamHistoryBoundary; renderers: StreamSegmentRenderers; listEmptyComponent: ReactNode; diff --git a/packages/app/src/agent-stream/stream-tick.bench.ts b/packages/app/src/agent-stream/stream-tick.bench.ts index dc1fecb881..b765c441e8 100644 --- a/packages/app/src/agent-stream/stream-tick.bench.ts +++ b/packages/app/src/agent-stream/stream-tick.bench.ts @@ -110,7 +110,7 @@ function runTicks(input: { tail: StreamItem[]; historyStart: number; heads: Stre isTurnActive: true, history: model.history, liveHead: model.segments.liveHead, - timingByAssistantId: model.turnTiming.byAssistantId, + timingByItemId: model.turnTiming.byItemId, }); } } diff --git a/packages/app/src/agent-stream/turn-collapse/model.test.ts b/packages/app/src/agent-stream/turn-collapse/model.test.ts index 4535b14d9e..eca6d8757c 100644 --- a/packages/app/src/agent-stream/turn-collapse/model.test.ts +++ b/packages/app/src/agent-stream/turn-collapse/model.test.ts @@ -48,6 +48,49 @@ function collapse(tail: StreamItem[], options: Partial = {}) const ids = (items: StreamItem[]) => items.map((item) => item.id); describe("collapseTurns", () => { + it("maps hidden rows only while their turn is closed", () => { + const result = collapse([user("u1"), shell("c1"), reply("final")]); + expect(result.hostIdByHiddenRowId.get("c1")).toBe("u1"); + const expanded = collapse([user("u1"), shell("c1"), reply("final")], { + expandedHostIds: new Set(["u1"]), + }); + expect(expanded.hostIdByHiddenRowId.size).toBe(0); + }); + it("keeps lifecycle events and compaction recognizable beside a semantic action summary", () => { + const create = shell("create"); + if (create.payload.source !== "agent") throw new Error("Expected agent tool"); + create.payload.data.name = "mcp__paseo__create_agent"; + create.payload.data.detail = { type: "unknown", input: { title: "Side fix" }, output: null }; + const finished = shell("finished"); + if (finished.payload.source !== "agent") throw new Error("Expected agent tool"); + finished.payload.data.name = "agent_notification"; + finished.payload.data.agentMessage = { + event: "finished", + sender: { id: "worker", title: "PR helper" }, + text: "Finished", + }; + const items: StreamItem[] = [ + user("u1"), + create, + finished, + { + kind: "compaction", + id: "compaction", + timestamp: at, + status: "completed", + } satisfies StreamItem, + shell("bash"), + reply("done"), + ].map((item) => Object.assign(item, { turnId: "same-run" })); + const result = collapse(items); + expect(ids(result.tail)).toEqual(["u1", "finished", "compaction", "done"]); + expect(result.turnsByHostId.get("u1")?.summary.tools).toMatchObject({ + commandCount: 1, + otherToolCount: 0, + paseoActions: { "Created agent": 1 }, + }); + }); + it("folds a finished turn's work into its user message and keeps the reply", () => { const result = collapse([ user("u1"), @@ -63,7 +106,6 @@ describe("collapseTurns", () => { hostId: "u1", expanded: false, summary: { - messageCount: 1, tools: expect.objectContaining({ commandCount: 2 }), }, }); @@ -121,6 +163,34 @@ describe("collapseTurns", () => { expect(result.turnsByHostId.size).toBe(0); }); + it("keeps a completed response folded when a promptless canonical turn starts", () => { + const first = [user("u1"), shell("c1"), reply("r1")].map((item) => + Object.assign(item, { turnId: "first" }), + ); + const next = { ...shell("heartbeat"), turnId: "second" }; + const result = collapse([...first, next], { isLatestTurnOpen: true }); + expect(ids(result.tail)).toEqual(["u1", "r1", "heartbeat"]); + expect(result.turnsByHostId.has("u1")).toBe(true); + }); + + it("keeps mid-turn messages visible when work is folded", () => { + const tail = [user("prompt"), shell("work"), user("steer"), shell("more"), reply("done")]; + for (const item of tail) item.turnId = "same-turn"; + const result = collapse(tail); + expect(ids(result.tail)).toEqual(["prompt", "steer", "done"]); + expect(result.hostIdByFoldedMessageId.has("steer")).toBe(false); + }); + + it("reveals every raw call inside an expanded turn", () => { + const calls = [shell("one"), shell("two")]; + const result = collapse([user("prompt"), calls[0]!, reply("done")], { + expandedHostIds: new Set(["prompt"]), + getGroupedCalls: (id) => (id === "one" ? calls : undefined), + }); + expect(ids(result.tail)).toEqual(["prompt", "one", "two", "done"]); + expect([...result.nestedItemIds]).toEqual(["one", "two"]); + }); + it("counts every call a tool-call group row stands for", () => { const group = [shell("c1"), shell("c2"), shell("c3")]; const result = collapse([user("u1"), group[0]!, reply("final")], { diff --git a/packages/app/src/agent-stream/turn-collapse/model.ts b/packages/app/src/agent-stream/turn-collapse/model.ts index d9f532ad73..a5985e600f 100644 --- a/packages/app/src/agent-stream/turn-collapse/model.ts +++ b/packages/app/src/agent-stream/turn-collapse/model.ts @@ -1,13 +1,18 @@ -import { getStreamItemMessageId, type StreamItem, type ToolCallItem } from "@/types/stream"; +import { isTurnBoundary, isTurnTrigger } from "../turn-membership"; +import { + getStreamItemMessageId, + type StreamItem, + type ThoughtItem, + type ToolCallItem, +} from "@/types/stream"; import { summarizeToolCalls, type OverviewSummary } from "@/tool-calls/detail-level/overview/model"; export interface TurnSummary { - messageCount: number; tools: OverviewSummary; } export interface CollapsedTurn { - /** The user message the turn answers. Its row carries the turn's summary line. */ + /** The input or first loaded row that carries the turn's summary line. */ hostId: string; expanded: boolean; summary: TurnSummary; @@ -20,6 +25,8 @@ export interface TurnCollapse { nestedItemIds: ReadonlySet; /** The turn a folded message belongs to, for surfaces that must reveal it. */ hostIdByFoldedMessageId: ReadonlyMap; + /** Mounted rows replaced by a closed turn's host, for preserving the reading position. */ + hostIdByHiddenRowId: ReadonlyMap; } export interface CollapseTurnsInput { @@ -28,7 +35,7 @@ export interface CollapseTurnsInput { isLatestTurnOpen: boolean; expandedHostIds: ReadonlySet; /** The calls a tool-call group row stands for; undefined for a single call. */ - getGroupedCalls: (itemId: string) => readonly ToolCallItem[] | undefined; + getGroupedCalls: (itemId: string) => readonly (ToolCallItem | ThoughtItem)[] | undefined; } const EMPTY_TURNS: ReadonlyMap = new Map(); @@ -39,17 +46,21 @@ function summarizeTurn( items: readonly StreamItem[], getGroupedCalls: CollapseTurnsInput["getGroupedCalls"], ): TurnSummary { - const calls: ToolCallItem[] = []; - let messageCount = 0; + const calls: (ToolCallItem | ThoughtItem)[] = []; for (const item of items) { - if (item.kind === "assistant_message") messageCount += 1; - else if (item.kind === "tool_call") calls.push(...(getGroupedCalls(item.id) ?? [item])); + if ((item.kind === "tool_call" || item.kind === "thought") && !isTurnTrigger(item)) + calls.push(...(getGroupedCalls(item.id) ?? [item])); } - return { messageCount, tools: summarizeToolCalls(calls) }; + return { tools: summarizeToolCalls(calls) }; } function hasWork(summary: TurnSummary): boolean { - return summary.messageCount > 0 || Object.values(summary.tools).some((count) => count > 0); + const { paseoActions, thoughtCount: _thoughtCount, ...counts } = summary.tools; + return Object.keys(paseoActions).length > 0 || Object.values(counts).some((count) => count > 0); +} + +function staysVisibleWhenCollapsed(item: StreamItem): boolean { + return isTurnTrigger(item) || item.kind === "compaction"; } function findLastAssistantIndex(tail: StreamItem[], start: number, end: number): number { @@ -63,7 +74,7 @@ function findLastAssistantIndex(tail: StreamItem[], start: number, end: number): * Folds each answered turn into its user message: everything the agent did before its last * message collapses into one summary line, and that message stays in the chat. A turn folds * only once it is complete, its user message is loaded, and it sent messages or called tools - * before its reply. Thoughts fold with the turn but are not counted. + * before its reply. Reasoning is revealed only when the turn opens. */ export function collapseTurns(input: CollapseTurnsInput): TurnCollapse { const { tail } = input; @@ -71,34 +82,55 @@ export function collapseTurns(input: CollapseTurnsInput): TurnCollapse { const turnsByHostId = new Map(); const nestedItemIds = new Set(); const hostIdByFoldedMessageId = new Map(); + const hostIdByHiddenRowId = new Map(); let runStart = 0; const flushRun = (runEnd: number) => { const host = tail[runStart - 1]; const isClosed = runEnd < tail.length || !input.isLatestTurnOpen; const replyIndex = findLastAssistantIndex(tail, runStart, runEnd); - if (host?.kind !== "user_message" || !isClosed || replyIndex <= runStart) { + if ( + !host || + (host.kind !== "user_message" && host.kind !== "tool_call") || + !isClosed || + replyIndex <= runStart + ) { output.push(...tail.slice(runStart, runEnd)); return; } const folded = tail.slice(runStart, replyIndex); const summary = summarizeTurn(folded, input.getGroupedCalls); - if (!hasWork(summary)) { + if (!hasWork(summary) && !folded.some((item) => item.kind === "assistant_message")) { output.push(...tail.slice(runStart, runEnd)); return; } const expanded = input.expandedHostIds.has(host.id); turnsByHostId.set(host.id, { hostId: host.id, expanded, summary }); - for (const item of folded) { + const revealed = expanded + ? folded.flatMap((item) => + item.kind === "tool_call" || item.kind === "thought" + ? [...(input.getGroupedCalls(item.id) ?? [item])] + : [item], + ) + : folded; + for (const item of revealed) { + if (staysVisibleWhenCollapsed(item)) continue; hostIdByFoldedMessageId.set(getStreamItemMessageId(item), host.id); if (expanded) nestedItemIds.add(item.id); + else hostIdByHiddenRowId.set(item.id, host.id); } - if (expanded) output.push(...folded); + output.push( + ...(expanded ? revealed : folded.filter((item) => staysVisibleWhenCollapsed(item))), + ); output.push(...tail.slice(replyIndex, runEnd)); }; for (let index = 0; index < tail.length; index += 1) { - if (tail[index]?.kind !== "user_message") continue; + if ( + !(index === 0 && isTurnTrigger(tail[index]!)) && + !isTurnBoundary(tail[index - 1] ?? null, tail[index] ?? null) + ) + continue; flushRun(index); output.push(tail[index]!); runStart = index + 1; @@ -111,12 +143,17 @@ export function collapseTurns(input: CollapseTurnsInput): TurnCollapse { turnsByHostId: EMPTY_TURNS, nestedItemIds: EMPTY_IDS, hostIdByFoldedMessageId: EMPTY_HOSTS, + hostIdByHiddenRowId: EMPTY_HOSTS, }; } return { - tail: output.length === tail.length ? tail : output, + tail: + output.length === tail.length && output.every((item, index) => item === tail[index]) + ? tail + : output, turnsByHostId, nestedItemIds, hostIdByFoldedMessageId, + hostIdByHiddenRowId, }; } diff --git a/packages/app/src/agent-stream/turn-collapse/summary-line.tsx b/packages/app/src/agent-stream/turn-collapse/summary-line.tsx index 9f73f1ac22..191ac706d5 100644 --- a/packages/app/src/agent-stream/turn-collapse/summary-line.tsx +++ b/packages/app/src/agent-stream/turn-collapse/summary-line.tsx @@ -1,4 +1,4 @@ -import { memo, useCallback, useMemo } from "react"; +import { memo, useCallback } from "react"; import { useTranslation } from "react-i18next"; import { Summary } from "lucide-react-native"; import { ExpandableBadge } from "@/components/message"; @@ -17,19 +17,7 @@ export const TurnSummaryLine = memo(function TurnSummaryLine({ onExpandedChange: (hostId: string, expanded: boolean) => void; }) { const { t } = useTranslation(); - const { messageCount, tools } = turn.summary; - const messageParts = useMemo( - () => - messageCount > 0 - ? [ - t(`turnSummary.messages.${messageCount === 1 ? "one" : "other"}`, { - count: messageCount, - }), - ] - : [], - [messageCount, t], - ); - const label = useOverviewSummaryLabel(tools, messageParts); + const label = useOverviewSummaryLabel(turn.summary.tools) || t("turnSummary.details"); const toggle = useCallback( () => onExpandedChange(turn.hostId, !turn.expanded), [onExpandedChange, turn.expanded, turn.hostId], diff --git a/packages/app/src/agent-stream/turn-footer.tsx b/packages/app/src/agent-stream/turn-footer.tsx index acbd920b81..5d04f9eca9 100644 --- a/packages/app/src/agent-stream/turn-footer.tsx +++ b/packages/app/src/agent-stream/turn-footer.tsx @@ -197,7 +197,7 @@ function CompletedTurnFooter({ return ( ["layout"]): str } describe("canonical turn membership", () => { + it("uses trigger fallback for missing IDs without pretending to recover batching", () => { + const previous = assistant("reply", 1, "first"); + const trigger = runningTool("heartbeat", 2, "first"); + if (trigger.kind !== "tool_call" || trigger.payload.source !== "agent") + throw new Error("Expected agent tool"); + trigger.payload.data.name = "heartbeat"; + expect(continuesTurn(previous, trigger)).toBe(true); + delete trigger.turnId; + expect(continuesTurn(previous, trigger)).toBe(false); + trigger.payload.data.name = "agent_message"; + trigger.payload.data.agentMessage = { + event: "finished", + sender: { id: "worker" }, + text: "Done", + }; + expect(continuesTurn(previous, trigger)).toBe(false); + }); + it("stops tagged traversal at every mismatching turn ID", () => { expect(continuesTurn(assistant("first", 1, "turn-1"), runningTool("next", 2, "turn-2"))).toBe( false, @@ -76,7 +94,7 @@ describe("canonical turn membership", () => { ); }); - it("renders adjacent turns without visible prompts as one response", () => { + it("renders a separate footer for each canonical turn without visible prompts", () => { const items = [ user("prompt", 1, "turn-1"), assistant("first reply", 2, "turn-1"), @@ -88,7 +106,11 @@ describe("canonical turn membership", () => { const completed = layoutFor(items, false); - expect(completedFooterIds(completed.layout)).toEqual(["heartbeat-2-reply"]); + expect(completedFooterIds(completed.layout)).toEqual([ + "first reply", + "heartbeat-1-reply", + "heartbeat-2-reply", + ]); expect(completed.layout.auxiliaryTurnFooter?.timing).toEqual({ completedAt: at(6), durationMs: null, @@ -123,7 +145,7 @@ describe("canonical turn membership", () => { const completedItems = [...build(turnId), assistant("done", 9, turnId)]; const completed = layoutFor(completedItems, false); expect(completedFooterIds(completed.layout)).toEqual(["done"]); - expect(completed.model.turnTiming.byAssistantId.get("done")).toMatchObject({ + expect(completed.model.turnTiming.byItemId.get("done")).toMatchObject({ completedAt: at(9), durationMs: 8000, }); diff --git a/packages/app/src/agent-stream/turn-membership.ts b/packages/app/src/agent-stream/turn-membership.ts index 414d583305..9650c12ff1 100644 --- a/packages/app/src/agent-stream/turn-membership.ts +++ b/packages/app/src/agent-stream/turn-membership.ts @@ -1,24 +1,21 @@ +import { continuesTimelineTurn, isTimelineTurnTrigger } from "@getpaseo/protocol/timeline-turn"; import type { StreamItem } from "@/types/stream"; /** * Canonical turn IDs take precedence. Timelines without them retain the legacy - * user-message boundary rule so old daemons and persisted rows need no rewrite. + * trigger boundary rule; missing IDs cannot reconstruct historical batching. */ export function continuesTurn(previous: StreamItem | null, next: StreamItem | null): boolean { if (!previous || !next) return false; - if (previous.turnId !== undefined && next.turnId !== undefined) { - return previous.turnId === next.turnId; - } - return next.kind !== "user_message"; + return continuesTimelineTurn(previous.turnId, next.turnId, isTurnTrigger(next)); } /** - * A visible response can span multiple canonical turns when their prompts are - * system-injected and therefore absent from the Paseo timeline. + * Every canonical turn owns its response, including system-triggered runs. */ export function continuesResponse(previous: StreamItem | null, next: StreamItem | null): boolean { if (!previous || !next) return false; - return continuesTurn(previous, next) || next.kind !== "user_message"; + return continuesTurn(previous, next); } export function isTurnBoundary(previous: StreamItem | null, next: StreamItem | null): boolean { @@ -37,3 +34,12 @@ export function startsNewTurn(item: StreamItem, previous: StreamItem | null): bo export function belongsToTurn(item: StreamItem, turnId: string | null): boolean { return turnId !== null && item.turnId === turnId; } + +/** Inputs that start a turn when older history has no canonical turn IDs. */ +export function isTurnTrigger(item: StreamItem): boolean { + return isTimelineTurnTrigger( + item.kind === "tool_call" && item.payload.source === "agent" + ? { type: item.kind, ...item.payload.data } + : { type: item.kind }, + ); +} diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx index 67e08a6f82..90c88fb0d1 100644 --- a/packages/app/src/agent-stream/view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -90,6 +90,7 @@ import { import { createAssistantImageOccurrenceKey } from "@/assistant-image/acquisition-cache"; import { AssistantSelectionCopySurface } from "@/assistant-selection-copy/surface"; import { AssistantFileLinkResolverProvider, createInlinePathOpener } from "@/assistant-file-links"; +import { useOpenOutsideWorkspaceFolder } from "@/workspace/open-in-file-manager/outside-folder"; import { createWorkspaceFileTabTarget, type WorkspaceFileLocation, @@ -459,6 +460,7 @@ const AgentStreamViewComponent = forwardRef createInlinePathOpener({ @@ -466,8 +468,9 @@ const AgentStreamViewComponent = forwardRef { if (!isActive) inlinePathOpener.cancel(); @@ -524,8 +527,10 @@ const AgentStreamViewComponent = forwardRef) => { + ( + layoutItem: StreamLayoutItem, + item: Extract, + ) => { const group = getToolCallGroup(item.id); if (!group) { - return renderSingleToolCallItem(item, layoutItem.isLastInToolSequence); + return item.kind === "thought" + ? renderThoughtItem(layoutItem, item) + : renderSingleToolCallItem(item, layoutItem.isLastInToolSequence); } const expanded = expandedToolCallGroupIds.has(group.run.id); return ( @@ -884,10 +897,23 @@ const AgentStreamViewComponent = forwardRef ( - {renderSingleToolCallItem( - call, - index === group.run.calls.length - 1, - GROUPED_TOOL_CALL_DETAIL_MAX_HEIGHT, + {call.kind === "thought" ? ( + // Reasoning is grouped only while Expand thinking is off, so an opened + // group shows each thought as a closed row like any other call. + + ) : ( + renderSingleToolCallItem( + call, + index === group.run.calls.length - 1, + GROUPED_TOOL_CALL_DETAIL_MAX_HEIGHT, + ) )} )) @@ -897,6 +923,8 @@ const AgentStreamViewComponent = forwardRef + {renderToolCallItem(layoutItem, item)} + {getCollapsedTurn(item.id) ? ( + + ) : null} + + ); case "notification": return ; @@ -947,8 +985,9 @@ const AgentStreamViewComponent = forwardRef {streamRenderStrategy.render({ + readingRowReplacements: presentation.readingRowReplacements, agentId, segments: renderModel.segments, historyRowRevision, @@ -1861,12 +1901,11 @@ function StreamItemWrapper({ gapBelow, turnIndent, children }: StreamItemWrapper const gapStyle = useMemo(() => ({ paddingBottom: gapBelow }), [gapBelow]); switch (turnIndent) { case "none": - return {children}; case "openedHost": return ( {children} - + {turnIndent === "openedHost" ? : null} ); case "nested": diff --git a/packages/app/src/agent-stream/web-virtualization.test.ts b/packages/app/src/agent-stream/web-virtualization.test.ts index 31107789e2..2d1f1b5b75 100644 --- a/packages/app/src/agent-stream/web-virtualization.test.ts +++ b/packages/app/src/agent-stream/web-virtualization.test.ts @@ -70,6 +70,17 @@ function indexEntries(items: StreamItem[]): IndexedStreamItem[] { } describe("findMountedWindowStart", () => { + it("mounts complete autonomous runs and keeps same-run steers together", () => { + const items = [ + { ...userMessage("human", 1), turnId: "first" }, + { ...assistantMessage("reply", 2), turnId: "first" }, + { ...toolCall("wake", 3), turnId: "autonomous" }, + { ...userMessage("steer", 4), turnId: "autonomous" }, + { ...assistantMessage("done", 5), turnId: "autonomous" }, + ]; + expect(findMountedWindowStart({ items, minMountedCount: 1 })).toBe(2); + }); + it("keeps all items mounted when the chat is below the threshold", () => { const items = [userMessage("u1", 1), assistantMessage("a1", 2)]; diff --git a/packages/app/src/assistant-file-links/path-open.test.ts b/packages/app/src/assistant-file-links/path-open.test.ts index a046a598f6..a85d4c8ec0 100644 --- a/packages/app/src/assistant-file-links/path-open.test.ts +++ b/packages/app/src/assistant-file-links/path-open.test.ts @@ -71,6 +71,9 @@ it("ignores an older probe after a newer file link opens", async () => { openDirectory: (directory) => { opened.push(directory); }, + openOutsideDirectory: (directory) => { + opened.push(directory); + }, }); const first = opener.open({ raw: "./first.md", path: "/repo/first.md" }, "preferred"); await opener.open({ raw: "./second.md:2", path: "/repo/second.md", lineStart: 2 }, "preferred"); @@ -95,6 +98,9 @@ it("ignores a pending probe when its view is left", async () => { openDirectory: (directory) => { opened.push(directory); }, + openOutsideDirectory: (directory) => { + opened.push(directory); + }, }); const pending = opener.open({ raw: "./docs", path: "/repo/docs" }, "preferred"); opener.cancel(); @@ -102,3 +108,25 @@ it("ignores a pending probe when its view is left", async () => { await pending; expect(opened).toEqual([]); }); + +it("sends folders outside the workspace away from the workspace Files panel", async () => { + const { createInlinePathOpener } = await import("./path-open"); + const workspaceFolders: string[] = []; + const outsideFolders: string[] = []; + const opener = createInlinePathOpener({ + workspaceRoot: "/Users/me/repo", + client: null, + openFile: () => {}, + openDirectory: (directory) => { + workspaceFolders.push(directory); + }, + openOutsideDirectory: (directory) => { + outsideFolders.push(directory); + }, + }); + for (const raw of ["~/Downloads/contracts/", "/tmp/reports/", "/Users/me/repo/docs/"]) { + await opener.open({ raw, path: raw }, "preferred"); + } + expect(outsideFolders).toEqual(["~/Downloads/contracts", "/tmp/reports"]); + expect(workspaceFolders).toEqual(["docs"]); +}); diff --git a/packages/app/src/assistant-file-links/path-open.ts b/packages/app/src/assistant-file-links/path-open.ts index b3d2178165..7c9cf228f4 100644 --- a/packages/app/src/assistant-file-links/path-open.ts +++ b/packages/app/src/assistant-file-links/path-open.ts @@ -14,12 +14,23 @@ interface PathOpenInput { client: { listDirectory(root: string, path: string): Promise } | null; } -type PathOpenTarget = { kind: "file" } | { kind: "directory"; directoryPath: string }; +type PathOpenTarget = + | { kind: "file" } + | { kind: "directory"; directoryPath: string } + | { kind: "outsideDirectory"; directoryPath: string }; + +// Workspace paths arrive relative to the root; anything still absolute or +// home-relative lives outside it, where the Files panel cannot list it. +function isOutsideWorkspacePath(pathValue: string): boolean { + return isAbsolutePath(pathValue) || pathValue === "~" || pathValue.startsWith("~/"); +} /** Resolve workspace folders from filesystem facts, including names that contain dots. */ export async function resolveInlinePathOpen(input: PathOpenInput): Promise { if (!input.file) { - return { kind: "directory", directoryPath: input.directory }; + return isOutsideWorkspacePath(input.directory) + ? { kind: "outsideDirectory", directoryPath: input.directory } + : { kind: "directory", directoryPath: input.directory }; } if ( input.lineStart !== undefined || @@ -42,6 +53,7 @@ export function createInlinePathOpener(input: { client: PathOpenInput["client"]; openFile: (location: WorkspaceFileLocation, disposition: OpenFileDisposition) => void; openDirectory: (path: string) => void; + openOutsideDirectory: (path: string) => void; }) { let generation = 0; return { @@ -58,6 +70,8 @@ export function createInlinePathOpener(input: { if (ownGeneration !== generation) return; if (destination.kind === "directory") { input.openDirectory(destination.directoryPath); + } else if (destination.kind === "outsideDirectory") { + input.openOutsideDirectory(destination.directoryPath); } else if (normalized.file) { const location = normalizeWorkspaceFileLocation({ path: normalized.file, diff --git a/packages/app/src/components/indent-rail.tsx b/packages/app/src/components/indent-rail.tsx index b54ac1ca9b..36fd84a0a9 100644 --- a/packages/app/src/components/indent-rail.tsx +++ b/packages/app/src/components/indent-rail.tsx @@ -3,11 +3,10 @@ import { View, type StyleProp, type ViewStyle } from "react-native"; import { StyleSheet } from "react-native-unistyles"; /** - * Collapsed chat rows badge their summary with an icon whose center sits 7px into the content - * column (`ExpandableBadge` bleeds 13px left, then 1px border, 8px padding, 22px icon). The rail - * hangs from that center so opened rows read as children of the line that opened them. + * Align the rail with the expanded caret's left edge. Keep the content offset fixed so + * expanded tool cards stay in place, with a gap between their border and the rail. */ -const RAIL_LEFT = 7; +const RAIL_LEFT = 1; const RAIL_WIDTH = 1; export const INDENT_RAIL_CONTENT_OFFSET = 21; diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index 9430aa3a12..a075b97c92 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -1,3 +1,4 @@ +import { MessagePreview, MessageSender } from "@/components/user-message"; import { useStableEvent } from "@/hooks/use-stable-event"; import { getAgentAttachmentPillContent } from "@/attachments/attachment-pill-content"; import { AssistantForkMenu, type AssistantForkTarget } from "@/components/assistant-fork-menu"; @@ -89,6 +90,7 @@ export type { InlinePathTarget } from "@/assistant-file-links"; export type { AssistantForkTarget }; interface UserMessageProps { + sender?: AgentMessage["sender"]; serverId?: string; agentId?: string; messageId?: string; @@ -387,6 +389,7 @@ function UserMessageImagePill({ image, onOpen, accessibilityLabel }: UserMessage const MESSAGE_TEXT_DATASET = { messageText: "true" }; export const UserMessage = memo(function UserMessage({ + sender, serverId, agentId, messageId, @@ -473,42 +476,45 @@ export const UserMessage = memo(function UserMessage({ onPointerEnter={handlePointerEnter} onPointerLeave={handlePointerLeave} > + {sender ? : null} - {hasImages ? ( - - {images.map((image) => ( - - ))} - - ) : null} - {hasAttachments ? ( - - {attachments.map((attachment, index) => { - const content = getAgentAttachmentPillContent(attachment, t); - return ( - - - - ); - })} - - ) : null} - {hasText ? ( - - {message} - - ) : null} + + {hasImages ? ( + + {images.map((image) => ( + + ))} + + ) : null} + {hasAttachments ? ( + + {attachments.map((attachment, index) => { + const content = getAgentAttachmentPillContent(attachment, t); + return ( + + + + ); + })} + + ) : null} + {hasText ? ( + + {message} + + ) : null} + {hasText ? ( string; + getContent?: () => string; completedAt?: Date; durationMs?: number | null; onFork?: (target: AssistantForkTarget) => Promise | void; @@ -641,10 +647,12 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({ return ( - + {getContent ? ( + + ) : null} {canFork ? : null} {primaryLabel ? ( ({ opacity: 0, }, emptyState: { - paddingVertical: theme.spacing[4], + padding: theme.spacing[4], alignItems: "center", gap: theme.spacing[2], }, diff --git a/packages/app/src/components/rewind/use-rewind-agent-mutation.ts b/packages/app/src/components/rewind/use-rewind-agent-mutation.ts index d482bb2d42..f6d3d0325c 100644 --- a/packages/app/src/components/rewind/use-rewind-agent-mutation.ts +++ b/packages/app/src/components/rewind/use-rewind-agent-mutation.ts @@ -1,3 +1,4 @@ +import { planTimelineTailFetch } from "@/timeline/timeline-sync-plan"; import { useCallback } from "react"; import { useTranslation } from "react-i18next"; import { useMutation } from "@tanstack/react-query"; @@ -42,8 +43,7 @@ export function useRewindAgentMutation(input: UseRewindAgentMutationInput): { : undefined; if (!input.serverId) throw new Error(t("common.errors.daemonClientUnavailable")); await getHostRuntimeStore().fetchAgentTimeline(input.serverId, input.agentId, { - direction: "tail", - projection: "projected", + ...planTimelineTailFetch(), ...(cursor ? { cursor: { epoch: cursor.epoch, seq: cursor.endSeq } } : {}), }); } diff --git a/packages/app/src/components/ui/edge-fade.tsx b/packages/app/src/components/ui/edge-fade.tsx index de404aeed8..f5a36ce30b 100644 --- a/packages/app/src/components/ui/edge-fade.tsx +++ b/packages/app/src/components/ui/edge-fade.tsx @@ -4,9 +4,10 @@ import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop } from "reac import { withUnistyles } from "react-native-unistyles"; import type { Theme } from "@/styles/theme"; -type EdgeFadeBackdrop = "surface0" | "surfaceWorkspace"; +type EdgeFadeBackdrop = "surface0" | "surfaceWorkspace" | "surface3"; const backdropColorMappings: Record { color: string }> = { + surface3: (theme) => ({ color: theme.colors.surface3 }), surface0: (theme) => ({ color: theme.colors.surface0 }), surfaceWorkspace: (theme) => ({ color: theme.colors.surfaceWorkspace }), }; diff --git a/packages/app/src/components/user-message/index.tsx b/packages/app/src/components/user-message/index.tsx new file mode 100644 index 0000000000..ba383eb0d3 --- /dev/null +++ b/packages/app/src/components/user-message/index.tsx @@ -0,0 +1,92 @@ +import { useState, useCallback, useMemo, type ReactNode } from "react"; +import { View, type LayoutChangeEvent } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { useTranslation } from "react-i18next"; +import type { AgentMessage } from "@getpaseo/protocol/agent-message"; +import { Button } from "@/components/ui/button"; +import { EdgeFade } from "@/components/ui/edge-fade"; +import { useProviderIcon } from "@/components/provider-icons"; +import { useSessionStore } from "@/stores/session-store"; +import { navigateToAgent } from "@/utils/navigate-to-agent"; + +const PREVIEW_HEIGHT = 240; + +export function MessagePreview({ children }: { children: ReactNode }) { + const { t } = useTranslation(); + const [expanded, setExpanded] = useState(false); + const [height, setHeight] = useState(0); + const overflowing = height > PREVIEW_HEIGHT; + const measure = useCallback( + (event: LayoutChangeEvent) => setHeight(event.nativeEvent.layout.height), + [], + ); + const toggle = useCallback(() => setExpanded((value) => !value), []); + const accessibilityState = useMemo(() => ({ expanded }), [expanded]); + return ( + <> + + + {children} + + {overflowing && !expanded ? ( + + ) : null} + + {overflowing ? ( + + ) : null} + + ); +} + +export function MessageSender({ + sender, + serverId, +}: { + sender: AgentMessage["sender"]; + serverId?: string; +}) { + const { t } = useTranslation(); + const host = sender.serverId ?? serverId; + const agent = useSessionStore((state) => { + const session = host ? state.sessions[host] : undefined; + return session?.agents.get(sender.id) ?? session?.agentDetails.get(sender.id); + }); + const ProviderIcon = useProviderIcon(agent?.provider ?? "", host); + const renderProviderIcon = useCallback( + (color: string) => , + [ProviderIcon], + ); + const openSender = useCallback(() => { + if (host) navigateToAgent({ serverId: host, agentId: sender.id }); + }, [host, sender.id]); + return ( + + ); +} + +const styles = StyleSheet.create((theme) => ({ + preview: { maxHeight: PREVIEW_HEIGHT, overflow: "hidden" }, + body: { flexShrink: 0 }, + sender: { paddingRight: 0, borderRightWidth: 0 }, + toggle: { alignSelf: "flex-start", paddingHorizontal: 0, minHeight: 0, borderWidth: 0 }, + expandedToggle: { marginTop: theme.spacing[2] }, + fade: { position: "absolute", bottom: 0, left: 0, right: 0, height: theme.spacing[12] }, +})); diff --git a/packages/app/src/git/diff-document/benchmark-corpus.ts b/packages/app/src/git/diff-document/benchmark-corpus.ts new file mode 100644 index 0000000000..469d437e8d --- /dev/null +++ b/packages/app/src/git/diff-document/benchmark-corpus.ts @@ -0,0 +1,65 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; + +/** + * The diff shapes the layout benchmarks measure, shared by the node bench + * (`model.bench.ts`) and the browser benchmark (`e2e/browser/diff-performance.spec.ts`) + * so both report against the same text. + */ +export interface BenchmarkCorpusFile { + path: string; + lines: string[]; +} + +function repeatToLength(unit: string, length: number): string { + return unit.repeat(Math.ceil(length / unit.length)).slice(0, length); +} + +/** A real source file: short lines, a few with ligature punctuation. */ +export function realCodeCorpusFile(): BenchmarkCorpusFile { + const source = readFileSync(path.resolve(__dirname, "../diff-pane.tsx"), "utf8"); + return { path: "packages/app/src/git/diff-pane.tsx", lines: source.split("\n") }; +} + +/** The CHANGELOG shape that once froze the renderer: 42 lines, longest ~600 characters. */ +export function changelogCorpusFile(): BenchmarkCorpusFile { + const sentence = + "- Fixed an issue where the diff viewer re-measured every line -> on toggle, which made wrapping painfully slow. "; + return { + path: "CHANGELOG.md", + lines: Array.from({ length: 42 }, (_, index) => + repeatToLength(sentence, 200 + ((index * 97) % 398)), + ), + }; +} + +/** Generated TypeScript: ligature punctuation on every line, every tenth line long. */ +export function generatedCodeCorpusFile(lineCount: number): BenchmarkCorpusFile { + return { + path: "src/generated.ts", + lines: Array.from({ length: lineCount }, (_, index) => { + const base = `const handler${index} = async (event) => items.filter((item) => item.id !== ${index} && item.kind === "selected");`; + return index % 10 === 9 ? repeatToLength(`${base} // `, 320) : base; + }), + }; +} + +/** Serialized data: no ligatures, every line wraps several times. */ +export function jsonCorpusFile(lineCount: number): BenchmarkCorpusFile { + return { + path: "fixtures/data.json", + lines: Array.from({ length: lineCount }, (_, index) => + repeatToLength(`{"id": ${index}, "label": "row ${index}", "tags": ["alpha", "beta"]}, `, 300), + ), + }; +} + +/** A pull-request-sized working diff: one of each shape above. */ +export function pullRequestCorpus(): BenchmarkCorpusFile[] { + return [ + realCodeCorpusFile(), + changelogCorpusFile(), + generatedCodeCorpusFile(1200), + jsonCorpusFile(300), + ]; +} diff --git a/packages/app/src/git/diff-document/model.bench.ts b/packages/app/src/git/diff-document/model.bench.ts new file mode 100644 index 0000000000..2fb25d502a --- /dev/null +++ b/packages/app/src/git/diff-document/model.bench.ts @@ -0,0 +1,317 @@ +import type { ParsedDiffFile } from "@getpaseo/protocol/messages"; +import { bench, describe } from "vitest"; +import { + changelogCorpusFile, + generatedCodeCorpusFile, + jsonCorpusFile, + pullRequestCorpus, + realCodeCorpusFile, + type BenchmarkCorpusFile, +} from "./benchmark-corpus"; +import { buildDiffDocumentModel } from "./model"; +import { + createAdditiveAdvances, + createChunkedAdvanceMeasurer, + createChunkedWidthMeasurer, + createMeasuredAdvances, + requiresShaping, +} from "./text-measurement"; +import type { BuildDiffDocumentModelInput, TextMeasurer } from "./types"; + +// The pure layout work the diff surface does when a diff opens or line wrap is +// toggled, without a browser or Skia. Counts are the backend-independent cost +// owners (how many strings a shaper has to see), timings are the JS overhead +// around them: +// npx vitest bench --project unit src/git/diff-document/model.bench.ts + +const DESKTOP_VIEWPORT = { width: 1400, height: 900 }; +const PHONE_VIEWPORT = { width: 390, height: 800 }; +const CELL_WIDTH = 7.8; + +interface MeasurementStats { + /** Whole strings handed to the backend. */ + calls: number; + /** Characters inside those strings; canvas and Skia cost scales with this. */ + characters: number; +} + +interface Backend { + measurer: TextMeasurer; + stats: MeasurementStats; +} + +function monospaceWidth(text: string): number { + let width = 0; + for (const character of text) width += character.charCodeAt(0) > 0x2e7f ? 2 : 1; + return width * CELL_WIDTH; +} + +function countingMeasure(stats: MeasurementStats): (text: string) => number { + return (text) => { + stats.calls += 1; + stats.characters += text.length; + return monospaceWidth(text); + }; +} + +/** Previous web backend, retained to isolate the cost of width-only probes. */ +function webBackendWithoutWidthProbe(): Backend { + const stats: MeasurementStats = { calls: 0, characters: 0 }; + const measure = countingMeasure(stats); + return { stats, measurer: { measure, measureAdvances: createMeasuredAdvances(measure) } }; +} + +/** + * `createWebTextMeasurer` in surface.web.tsx, with canvas replaced by a counter. + * Width-only probes shape one string per chunk, like the native measurer; the + * widths are the same sums, in the same order, as the last cumulative advance. + */ +function webBackend(): Backend { + const stats: MeasurementStats = { calls: 0, characters: 0 }; + const measure = countingMeasure(stats); + const additive = createAdditiveAdvances(measure); + return { + stats, + measurer: { + measure, + measureAdvances: createMeasuredAdvances(measure), + measureWidth: createChunkedWidthMeasurer((graphemes) => { + const text = graphemes.join(""); + return requiresShaping(text) ? measure(text) : (additive(graphemes).at(-1) ?? 0); + }), + }, + }; +} + +/** `createNativeTextMeasurer` in text.native.ts, with Skia replaced by a counter. */ +function nativeBackend(): Backend { + const stats: MeasurementStats = { calls: 0, characters: 0 }; + const widths = new Map(); + const additive = (graphemes: readonly string[]) => { + let advance = 0; + return graphemes.map((grapheme) => { + let width = widths.get(grapheme); + if (width === undefined) { + stats.calls += 1; + stats.characters += grapheme.length; + width = monospaceWidth(grapheme); + widths.set(grapheme, width); + } + return (advance += width); + }); + }; + const shape = countingMeasure(stats); + return { + stats, + measurer: { + measure(text) { + return requiresShaping(text) ? shape(text) : (additive([...text]).at(-1) ?? 0); + }, + measureWidth: createChunkedWidthMeasurer((graphemes) => { + const text = graphemes.join(""); + return requiresShaping(text) ? shape(text) : (additive(graphemes).at(-1) ?? 0); + }), + measureAdvances: createChunkedAdvanceMeasurer({ + requiresShaping, + measureAdditive: additive, + measureShaped(graphemes) { + shape(graphemes.join("")); + let advance = 0; + return graphemes.map((grapheme) => (advance += monospaceWidth(grapheme))); + }, + }), + }, + }; +} + +function addedFile(file: BenchmarkCorpusFile): ParsedDiffFile { + return { + path: file.path, + isNew: true, + isDeleted: false, + additions: file.lines.length, + deletions: 0, + hunks: [ + { + oldStart: 0, + oldCount: 0, + newStart: 1, + newCount: file.lines.length, + lines: file.lines.map((content) => ({ + type: "add" as const, + content, + tokens: [{ text: content, style: null }], + })), + }, + ], + }; +} + +interface Scenario { + name: string; + files: ParsedDiffFile[]; +} + +const SCENARIOS: Scenario[] = [ + { name: "real code (diff-pane.tsx)", files: [addedFile(realCodeCorpusFile())] }, + { name: "changelog (42 long prose lines)", files: [addedFile(changelogCorpusFile())] }, + { name: "generated code (1200 lines)", files: [addedFile(generatedCodeCorpusFile(1200))] }, + { name: "json (300 lines x 300 chars)", files: [addedFile(jsonCorpusFile(300))] }, + { name: "pull request (all of the above)", files: pullRequestCorpus().map(addedFile) }, +]; + +function input( + scenario: Scenario, + options: { + wrapLines: boolean; + viewport: { width: number; height: number }; + measureText: TextMeasurer; + }, +): BuildDiffDocumentModelInput { + return { + files: scenario.files, + collapsedFilePaths: new Set(), + layout: "unified", + wrapLines: options.wrapLines, + viewportWidth: options.viewport.width, + typography: { family: "monospace", size: 13, lineHeight: 20 }, + measureText: options.measureText, + palette: { + surface: "#000", + headerSurface: "#111", + border: "#222", + foreground: "#fff", + foregroundMuted: "#aaa", + addition: "green", + deletion: "red", + additionBackground: "#010", + deletionBackground: "#100", + emptyBackground: "#111", + selection: "blue", + headerActiveSurface: "#222", + headerBorder: "#333", + statusSuccess: "green", + statusDanger: "red", + statusWarning: "orange", + syntax: {}, + }, + labels: { binary: "Binary", tooLarge: "Too large" }, + // What the surfaces pass at scrollTop 0: five viewports tall. + materializationWindow: { top: 0, height: options.viewport.height * 5 }, + }; +} + +interface Variant { + name: string; + wrapLines: boolean; + viewport: { width: number; height: number }; + backend: () => Backend; +} + +const VARIANTS: Variant[] = [ + { + name: "unwrapped desktop | web", + wrapLines: false, + viewport: DESKTOP_VIEWPORT, + backend: webBackend, + }, + { + name: "wrapped desktop | web", + wrapLines: true, + viewport: DESKTOP_VIEWPORT, + backend: webBackend, + }, + { + name: "wrapped desktop | web without width probe", + wrapLines: true, + viewport: DESKTOP_VIEWPORT, + backend: webBackendWithoutWidthProbe, + }, + { + name: "wrapped phone | web", + wrapLines: true, + viewport: PHONE_VIEWPORT, + backend: webBackend, + }, + { + name: "unwrapped desktop | native", + wrapLines: false, + viewport: DESKTOP_VIEWPORT, + backend: nativeBackend, + }, + { + name: "wrapped desktop | native", + wrapLines: true, + viewport: DESKTOP_VIEWPORT, + backend: nativeBackend, + }, + { + name: "wrapped phone | native", + wrapLines: true, + viewport: PHONE_VIEWPORT, + backend: nativeBackend, + }, +]; + +function countModel(scenario: Scenario, variant: Variant) { + const { measurer, stats } = variant.backend(); + const started = performance.now(); + const model = buildDiffDocumentModel( + input(scenario, { + wrapLines: variant.wrapLines, + viewport: variant.viewport, + measureText: measurer, + }), + ); + const elapsedMs = performance.now() - started; + let materializedCells = 0; + let fragments = 0; + let graphemes = 0; + for (const row of model.rows) { + if (row.kind !== "line") continue; + for (const cell of row.cells) { + if (!cell || cell.fragments.length === 0) continue; + materializedCells += 1; + fragments += cell.fragments.length; + for (const fragment of cell.fragments) graphemes += fragment.graphemes.length; + } + } + const sourceLines = scenario.files.reduce( + (count, file) => count + file.hunks.reduce((total, hunk) => total + hunk.lines.length, 0), + 0, + ); + return { + sourceLines, + materializedCells, + fragments, + graphemeObjects: graphemes, + measureCalls: stats.calls, + measuredCharacters: stats.characters, + coldMs: Number(elapsedMs.toFixed(1)), + }; +} + +const report: Record> = {}; +for (const scenario of SCENARIOS) { + for (const variant of VARIANTS) { + report[`${scenario.name} | ${variant.name}`] = countModel(scenario, variant); + } +} +console.table(report); + +for (const scenario of SCENARIOS) { + describe(scenario.name, () => { + for (const variant of VARIANTS) { + if (variant.backend === nativeBackend) continue; + bench(variant.name, () => { + buildDiffDocumentModel( + input(scenario, { + wrapLines: variant.wrapLines, + viewport: variant.viewport, + measureText: variant.backend().measurer, + }), + ); + }); + } + }); +} diff --git a/packages/app/src/git/diff-document/model.test.ts b/packages/app/src/git/diff-document/model.test.ts index ace965c1ce..9b55a242db 100644 --- a/packages/app/src/git/diff-document/model.test.ts +++ b/packages/app/src/git/diff-document/model.test.ts @@ -10,7 +10,7 @@ import { resolveRelayoutScrollTop, shouldApplyRelayoutScroll, } from "./model"; -import type { BuildDiffDocumentModelInput, TextMeasurer } from "./types"; +import type { BuildDiffDocumentModelInput, DiffCell, TextMeasurer } from "./types"; const measurer: TextMeasurer = { measure: (text) => Array.from(text).length * 10 }; @@ -639,17 +639,119 @@ describe("diff document model", () => { expect(firstLineCells(last, "b.ts")).toBe(firstLineCells(first, "b.ts")); }); - it("keeps wrapped geometry fully measured", () => { + it("replaces estimated wrapped heights through reuse and preserves the scroll anchor", () => { + const changed = fileWithChangedLine("x".repeat(100)); + const files = [changed, file("later.ts")]; + // Deliberately inaccurate M width makes the estimate differ from exact layout. + const measureText = { + measure: (text: string) => (text === "M" ? 5 : Array.from(text).length * 10), + }; + const options = input({ files, measureText }); + const first = buildDiffDocumentModel({ + ...options, + materializationWindow: { top: 30, height: 1 }, + }); + const eager = buildDiffDocumentModel(options); + const exact = buildDiffDocumentModel({ ...options, reuseFrom: [first] }); + expect(first.height).not.toBe(eager.height); + expect(exact.rows).toEqual(eager.rows); + expect(exact.height).toBe(eager.height); + expect(firstLineCells(exact, changed.path)).toBe(firstLineCells(first, changed.path)); + const scrollTop = first.files[1]!.bodyTop + 3; + const nextScrollTop = resolveRelayoutScrollTop(first, exact, scrollTop); + expect(nextScrollTop).toBe(exact.files[1]!.bodyTop + 3); + expect(captureScrollAnchor(exact, nextScrollTop)).toEqual( + captureScrollAnchor(first, scrollTop), + ); + }); + + it("preserves the wrapped source offset when resize leaves the anchor outside the window", () => { + const source = file(); + source.hunks[0]!.lines = Array.from({ length: 200 }, () => ({ + type: "add" as const, + content: "x".repeat(200), + })); + const options = input({ files: [source], viewportWidth: 500 }); + const before = buildDiffDocumentModel(options); + const scrollTop = before.rows[100]!.top + 36; + const anchor = captureScrollAnchor(before, scrollTop)!; + expect(anchor).toMatchObject({ kind: "text", cellOffset: 90 }); + const resizedOptions = { ...options, viewportWidth: 180 }; + const estimated = buildDiffDocumentModel({ + ...resizedOptions, + materializationWindow: { + top: Math.floor(scrollTop / 800) * 800 - 800, + height: 2000, + }, + }); + const estimatedRow = estimated.rows[100]!; + expect(estimatedRow.kind === "line" && estimatedRow.cells[0]?.fragments).toEqual([]); + const restored = resolveRelayoutScrollTop(before, estimated, scrollTop); + const recaptured = captureScrollAnchor(estimated, restored)!; + expect(recaptured).toMatchObject({ kind: "text", cellOffset: 78 }); + + const materialized = buildDiffDocumentModel({ + ...resizedOptions, + materializationWindow: { top: restored - 800, height: 2000 }, + reuseFrom: [estimated], + }); + const eager = buildDiffDocumentModel(resizedOptions); + const expected = resolveRelayoutScrollTop(before, eager, scrollTop); + expect(expected).toBe(28_938); + expect(Math.abs(restored - expected)).toBeLessThan(before.lineHeight); + expect(resolveRelayoutScrollTop(estimated, materialized, restored)).toBe(expected); + }); + + it("materializes the relayout anchor even when off-screen height estimates are inaccurate", () => { + const source = file(); + source.hunks[0]!.lines = Array.from({ length: 200 }, () => ({ + type: "add" as const, + content: "x".repeat(200), + })); + const options = input({ files: [source], viewportWidth: 500 }); + const before = buildDiffDocumentModel(options); + const scrollTop = before.rows[100]!.top + 38; + const anchor = captureScrollAnchor(before, scrollTop)!; + const resizedOptions = { + ...options, + viewportWidth: 180, + measureText: { measure: (text: string) => (text === "M" ? 5 : text.length * 10) }, + }; + const resized = buildDiffDocumentModel({ + ...resizedOptions, + materializationWindow: { top: scrollTop - 800, height: 2000 }, + materializationAnchor: anchor, + }); + const row = resized.rows[100]!; + expect(row.kind === "line" && row.cells[0]!.fragments.length).toBeGreaterThan(0); + const restored = resolveRelayoutScrollTop(before, resized, scrollTop); + const materialized = buildDiffDocumentModel({ + ...resizedOptions, + materializationWindow: { top: restored - 800, height: 2000 }, + materializationAnchor: captureScrollAnchor(resized, restored), + reuseFrom: [resized], + }); + const nextScrollTop = resolveRelayoutScrollTop(resized, materialized, restored); + const nextAnchor = captureScrollAnchor(materialized, nextScrollTop); + // Offset 90 belongs to the fragment beginning at 78 after narrowing. + expect(nextAnchor).toMatchObject({ kind: "text", cellOffset: 78, viewportDelta: -2 }); + expect(nextScrollTop).toBe(resolveScrollAnchor(materialized, anchor)); + }); + + it("materializes wrapped text only inside the window", () => { const model = buildDiffDocumentModel( input({ files: [file("a.ts"), file("b.ts")], wrapLines: true, - materializationWindow: { top: 0, height: FILE_HEADER_HEIGHT }, + materializationWindow: { top: FILE_HEADER_HEIGHT, height: 1 }, }), ); expect(measuredFragments(model, "a.ts")).toBeGreaterThan(0); - expect(measuredFragments(model, "b.ts")).toBeGreaterThan(0); + expect(measuredFragments(model, "b.ts")).toBe(0); + expect( + model.rows.filter((row) => row.kind === "line" && row.cells.some(hasFragments)), + ).toHaveLength(1); }); }); @@ -798,3 +900,7 @@ function reviewActionsWithEditor( onDeleteComment() {}, }; } + +function hasFragments(cell: DiffCell | null): boolean { + return cell !== null && cell.fragments.length > 0; +} diff --git a/packages/app/src/git/diff-document/model.ts b/packages/app/src/git/diff-document/model.ts index fb2b9a04f0..67efe06c2b 100644 --- a/packages/app/src/git/diff-document/model.ts +++ b/packages/app/src/git/diff-document/model.ts @@ -154,27 +154,38 @@ function appendReusableFileRows(candidate: { }): FileRowsResult { const reusedRows = candidate.model.rows.slice(candidate.file.rowStart, candidate.file.rowEnd); const retainsRowGeometry = - !candidate.input.wrapLines && candidate.model.reviewGeometryKey === candidate.reviewGeometryKey && candidate.file.top === candidate.fileTop && candidate.file.fileIndex === candidate.fileIndex && candidate.file.rowStart === candidate.rows.length; let top = candidate.bodyTop; for (const reusedRow of reusedRows) { - let nextRow = retainsRowGeometry ? reusedRow : repositionReusedRow(reusedRow, candidate, top); + let nextRow = + retainsRowGeometry && reusedRow.top === top + ? reusedRow + : repositionReusedRow(reusedRow, candidate, top); if ( nextRow.kind === "line" && - intersectsMaterializationWindow(candidate.input, top, top + nextRow.height) && + (intersectsMaterializationWindow(candidate.input, top, top + nextRow.height) || + containsMaterializationAnchor(candidate.input, candidate.file.path, nextRow.cells)) && nextRow.cells.some((cell) => cell && cell.fragments.length === 0) ) { + const cells = materializeCells( + nextRow.cells, + candidate.file.file, + candidate.file.gutterWidth, + candidate.input, + ); nextRow = { ...nextRow, - cells: materializeCells( - nextRow.cells, - candidate.file.file, - candidate.file.gutterWidth, - candidate.input, - ), + cells, + height: + Math.max( + candidate.input.typography.lineHeight, + ...cells.map( + (cell) => (cell?.fragments.length ?? 1) * candidate.input.typography.lineHeight, + ), + ) + nextRow.reviewHeight, }; } candidate.rows.push(nextRow); @@ -240,6 +251,7 @@ function appendNewFileRows(candidate: { const lines = geometryLines( lineSources(candidate.file, candidate.input.layout, false), candidate.input, + candidate.gutterWidth, ); const fileBottom = candidate.bodyTop + @@ -252,8 +264,8 @@ function appendNewFileRows(candidate: { let maximumHorizontalOverflow = 0; for (const line of lines) { const shouldMaterialize = - candidate.input.wrapLines || - intersectsMaterializationWindow(candidate.input, top, top + line.height); + intersectsMaterializationWindow(candidate.input, top, top + line.height) || + containsMaterializationAnchor(candidate.input, candidate.file.path, line.cells); const cells = shouldMaterialize ? materializeCells(line.cells, candidate.file, candidate.gutterWidth, candidate.input) : line.cells; @@ -275,7 +287,7 @@ function appendNewFileRows(candidate: { candidate.input.typography.lineHeight, ...cells.map((cell) => (cell?.fragments.length ?? 1) * candidate.input.typography.lineHeight), ); - const height = textHeight + line.reviewHeight; + const height = shouldMaterialize ? textHeight + line.reviewHeight : line.height; candidate.rows.push({ kind: "line", index: candidate.rows.length, @@ -412,13 +424,14 @@ function cellSource(line: SplitDiffDisplayLine | null, includeTokens: boolean): }; } -function geometryCell(source: CellSource): DiffCell { +function geometryCell(source: CellSource, estimatedWrapColumns: number): DiffCell { return { type: source.type, content: source.content, lineNumber: source.lineNumber, tokens: [], fragments: [], + estimatedWrapColumns, reviewTarget: source.reviewTarget, sourceIdentity: source.sourceIdentity, }; @@ -427,20 +440,87 @@ function geometryCell(source: CellSource): DiffCell { function geometryLines( sources: Array<[CellSource] | [CellSource | null, CellSource | null]>, input: BuildDiffDocumentModelInput, + gutterWidth: number, ): GeometryLine[] { + const characterWidth = input.wrapLines ? input.measureText.measure("M") : 0; return sources.map((sourceCells) => { + const columns = input.wrapLines + ? Math.max( + 1, + Math.floor( + (input.viewportWidth / sourceCells.length - gutterWidth - CODE_HORIZONTAL_PADDING) / + characterWidth, + ), + ) + : Infinity; const cells = sourceCells.map((source) => - source ? geometryCell(source) : null, + source ? geometryCell(source, columns) : null, ) as DiffLineRow["cells"]; const reviewHeight = reviewHeightForCells(cells, input); return { cells, reviewHeight, - height: input.typography.lineHeight + reviewHeight, + // Unseen wrapped rows use a monospace estimate; materialization replaces + // it with the exact fragment count without changing source identity. + height: + input.typography.lineHeight * + Math.max( + 1, + ...cells.map((cell) => { + return cell && input.wrapLines + ? Math.ceil(estimatedDisplayOffset(cell.content) / columns) + : 1; + }), + ) + + reviewHeight, }; }); } +// Use the same tab expansion and column count for estimated heights and anchors. +function estimatedDisplayOffset(text: string): number { + return text.length + (text.match(/\t/g)?.length ?? 0) * 3; +} + +function estimatedVisualLine(cell: DiffCell, sourceOffset: number): number { + return Math.floor( + estimatedDisplayOffset(cell.content.slice(0, sourceOffset)) / + (cell.estimatedWrapColumns ?? Infinity), + ); +} + +function estimatedFragment(cell: DiffCell, visualIndex: number, lineHeight: number) { + const columns = cell.estimatedWrapColumns ?? Infinity; + const lastIndex = Math.max(0, Math.ceil(estimatedDisplayOffset(cell.content) / columns) - 1); + const index = Math.min(visualIndex, lastIndex); + const displayOffset = index === 0 ? 0 : index * columns; + let start = Math.min(displayOffset, cell.content.length); + if (cell.content.includes("\t")) { + let offset = 0; + start = 0; + while (start < cell.content.length && offset < displayOffset) { + offset += cell.content[start] === "\t" ? 4 : 1; + start += 1; + } + } + return { start, top: index * lineHeight }; +} + +// A resize can move the anchor outside the old window. Measure that row in the +// same pass: estimated columns cannot identify exact shaped-text fragments. +function containsMaterializationAnchor( + input: BuildDiffDocumentModelInput, + path: string, + cells: DiffLineRow["cells"], +): boolean { + const anchor = input.materializationAnchor; + return ( + anchor?.kind === "text" && + anchor.path === path && + cells.some((cell) => cell && sameSourceIdentity(cell.sourceIdentity, anchor.sourceIdentity)) + ); +} + function intersectsMaterializationWindow( input: BuildDiffDocumentModelInput, fileTop: number, @@ -795,17 +875,27 @@ export function captureScrollAnchor( } const textY = Math.max(0, Math.min(scrollTop - row.top, row.height - row.reviewHeight)); const visualIndex = Math.max(0, Math.floor(textY / model.lineHeight)); - let cellIndex = row.cells.findIndex((cell) => Boolean(cell?.fragments[visualIndex])); + let cellIndex = row.cells.findIndex( + (cell) => + cell && + (cell.fragments.length > 0 + ? Boolean(cell.fragments[visualIndex]) + : estimatedFragment(cell, visualIndex, model.lineHeight).top === + visualIndex * model.lineHeight), + ); if (cellIndex < 0) cellIndex = row.cells.findIndex((cell) => cell !== null); const cell = row.cells[cellIndex]; if (!cell) return { kind: "header", path: file.path, viewportDelta: row.top - scrollTop }; - const fragment = cell.fragments[Math.min(cell.fragments.length - 1, visualIndex)]; - const anchorY = row.top + (fragment?.top ?? 0); + const fragment = + cell.fragments.length > 0 + ? cell.fragments[Math.min(cell.fragments.length - 1, visualIndex)]! + : estimatedFragment(cell, visualIndex, model.lineHeight); + const anchorY = row.top + fragment.top; return { kind: "text", path: file.path, sourceIdentity: cell.sourceIdentity, - cellOffset: fragment?.start ?? 0, + cellOffset: fragment.start, viewportDelta: anchorY - scrollTop, }; } @@ -831,8 +921,10 @@ export function resolveScrollAnchor(model: DiffDocumentModel, anchor: DiffScroll const fragment = cell.fragments.find( (candidate) => candidate.start <= anchor.cellOffset && anchor.cellOffset < candidate.end, - ) ?? cell.fragments.at(-1); - return Math.max(0, row.top + (fragment?.top ?? 0) - anchor.viewportDelta); + ) ?? + cell.fragments.at(-1) ?? + estimatedFragment(cell, estimatedVisualLine(cell, anchor.cellOffset), model.lineHeight); + return Math.max(0, row.top + fragment.top - anchor.viewportDelta); } export function resolveRelayoutScrollTop( diff --git a/packages/app/src/git/diff-document/surface.native.tsx b/packages/app/src/git/diff-document/surface.native.tsx index 2063198712..00a47437cd 100644 --- a/packages/app/src/git/diff-document/surface.native.tsx +++ b/packages/app/src/git/diff-document/surface.native.tsx @@ -38,6 +38,7 @@ import { } from "./horizontal-offsets"; import { buildDiffDocumentModel, + captureScrollAnchor, resolveRelayoutScrollTop, retainReusableModels, shouldApplyRelayoutScroll, @@ -138,6 +139,9 @@ export function DiffSurface(props: DiffSurfaceProps) { tooLarge: t("workspace.git.diff.tooLarge"), }, materializationWindow: diffMaterializationWindow(fileWindowTop, viewport.height), + materializationAnchor: previousModelRef.current + ? captureScrollAnchor(previousModelRef.current, scrollTop.get()) + : null, reuseFrom, }); reusableModelRef.current = { @@ -148,6 +152,7 @@ export function DiffSurface(props: DiffSurfaceProps) { }, [ measurement, fileWindowTop, + scrollTop, props.collapsedFilePaths, props.displayPreferences.layout, props.displayPreferences.wrapLines, diff --git a/packages/app/src/git/diff-document/surface.web.tsx b/packages/app/src/git/diff-document/surface.web.tsx index 97801b708f..393f6dfcd7 100644 --- a/packages/app/src/git/diff-document/surface.web.tsx +++ b/packages/app/src/git/diff-document/surface.web.tsx @@ -28,10 +28,20 @@ import { } from "./hit-testing"; import { retainHorizontalOffsetMapForPaths } from "./horizontal-offsets"; import { HorizontalScroll } from "./horizontal-scroll.web"; -import { buildDiffDocumentModel, FILE_HEADER_HEIGHT, resolveRelayoutScrollTop } from "./model"; +import { + buildDiffDocumentModel, + captureScrollAnchor, + FILE_HEADER_HEIGHT, + resolveRelayoutScrollTop, +} from "./model"; import { paintWebFileHeader, paintWebViewport } from "./paint.web"; import { hasPointerDragStarted } from "./pointer-gesture"; -import { createMeasuredAdvances } from "./text-measurement"; +import { + createMeasuredAdvances, + createAdditiveAdvances, + createChunkedWidthMeasurer, + requiresShaping, +} from "./text-measurement"; import { retainDiffViewport } from "./viewport"; import type { DiffHit, @@ -183,6 +193,9 @@ export function DiffSurface(props: DiffSurfaceProps) { tooLarge: t("workspace.git.diff.tooLarge"), }, materializationWindow: diffMaterializationWindow(fileWindowTop, viewport.height), + materializationAnchor: previousModelRef.current + ? captureScrollAnchor(previousModelRef.current, scrollTopRef.current) + : null, }); return next; }, [ @@ -1166,5 +1179,13 @@ function createWebTextMeasurer(typography: DiffTypography): TextMeasurer { }; // Canvas exposes no glyph coverage, so `requiresShaping` alone decides which // runs a shaper has to see -- there is no `glyphIds` to fall back on. - return { measure, measureAdvances: createMeasuredAdvances((text) => measure(text)) }; + const additive = createAdditiveAdvances(measure); + return { + measure, + measureAdvances: createMeasuredAdvances(measure), + measureWidth: createChunkedWidthMeasurer((graphemes) => { + const text = graphemes.join(""); + return requiresShaping(text) ? measure(text) : (additive(graphemes).at(-1) ?? 0); + }), + }; } diff --git a/packages/app/src/git/diff-document/types.ts b/packages/app/src/git/diff-document/types.ts index 4ac8125445..d98880eb8c 100644 --- a/packages/app/src/git/diff-document/types.ts +++ b/packages/app/src/git/diff-document/types.ts @@ -112,6 +112,8 @@ export interface DiffCell { lineNumber: number | null; tokens: DiffTokenRun[]; fragments: DiffFragment[]; + /** Monospace columns per visual line until this cell is measured. */ + estimatedWrapColumns?: number; reviewTarget: ReviewableDiffTarget | null; sourceIdentity: DiffSourceIdentity; } @@ -185,6 +187,8 @@ export interface BuildDiffDocumentModelInput { reviewActions?: InlineReviewActions; labels: { binary: string; tooLarge: string }; materializationWindow?: { top: number; height: number }; + /** Also measure the anchored row so relayout can restore its exact fragment. */ + materializationAnchor?: DiffScrollAnchor | null; /** A geometry-compatible model whose unchanged file measurements may be reused. */ reuseFrom?: readonly DiffDocumentModel[]; } diff --git a/packages/app/src/git/diff-document/workspace-cache.test.ts b/packages/app/src/git/diff-document/workspace-cache.test.ts index ddf9972a46..a264d417ca 100644 --- a/packages/app/src/git/diff-document/workspace-cache.test.ts +++ b/packages/app/src/git/diff-document/workspace-cache.test.ts @@ -95,6 +95,37 @@ describe("diff document workspace cache", () => { expect(stats.calls).toBe(0); }); + it("materializes a new anchor when the viewport window is unchanged", () => { + const cache = createDiffDocumentWorkspaceCache(); + const source = diffFile(); + source.hunks[0]!.lines = Array.from({ length: 100 }, () => ({ + type: "add" as const, + content: "x".repeat(200), + })); + const { measureText } = countingMeasurer(); + const input = modelInput([source], measureText, { + wrapLines: true, + materializationWindow: { top: 30, height: 54 }, + }); + const first = cache.buildModel(input); + const anchored = cache.buildModel({ + ...input, + materializationAnchor: { + kind: "text", + path: source.path, + sourceIdentity: { hunkIndex: 0, lineIndex: 50, side: "new" }, + cellOffset: 90, + viewportDelta: -2, + }, + }); + const firstRow = first.rows[50]!; + const anchoredRow = anchored.rows[50]!; + expect(firstRow.kind === "line" && firstRow.cells[0]!.fragments.length).toBe(0); + expect(anchoredRow.kind === "line" && anchoredRow.cells[0]!.fragments.length).toBeGreaterThan( + 0, + ); + }); + it("retains text cells for unchanged files when another file changes", () => { const cache = createDiffDocumentWorkspaceCache(); const { measureText } = countingMeasurer(); diff --git a/packages/app/src/git/diff-document/workspace-cache.tsx b/packages/app/src/git/diff-document/workspace-cache.tsx index 5b9921e31d..dac4d96c2e 100644 --- a/packages/app/src/git/diff-document/workspace-cache.tsx +++ b/packages/app/src/git/diff-document/workspace-cache.tsx @@ -128,7 +128,9 @@ function exactModelKey(input: Omit): s const materializationWindow = input.materializationWindow ? [input.materializationWindow.top, input.materializationWindow.height] : null; - return JSON.stringify([collapsedFilePaths, reviewGeometry, materializationWindow]); + const anchor = input.materializationAnchor; + const anchoredRow = anchor?.kind === "text" ? [anchor.path, anchor.sourceIdentity] : null; + return JSON.stringify([collapsedFilePaths, reviewGeometry, materializationWindow, anchoredRow]); } function typographyKey(typography: DiffTypography): string { diff --git a/packages/app/src/hooks/use-agent-initialization.test.ts b/packages/app/src/hooks/use-agent-initialization.test.ts index f2726fba02..e517021b6b 100644 --- a/packages/app/src/hooks/use-agent-initialization.test.ts +++ b/packages/app/src/hooks/use-agent-initialization.test.ts @@ -78,6 +78,7 @@ describe("ensureAgentIsInitialized", () => { cursor: { epoch: "epoch-1", seq: 42 }, limit: TIMELINE_FETCH_PAGE_SIZE, projection: "projected", + wholeTurns: true, }, }, ]); @@ -105,6 +106,7 @@ describe("ensureAgentIsInitialized", () => { direction: "tail", limit: TIMELINE_FETCH_PAGE_SIZE, projection: "projected", + wholeTurns: true, }, }, ]); @@ -148,6 +150,7 @@ describe("ensureAgentIsInitialized", () => { direction: "tail", limit: TIMELINE_FETCH_PAGE_SIZE, projection: "projected", + wholeTurns: true, }, }, ]); @@ -192,6 +195,7 @@ describe("ensureAgentIsInitialized", () => { cursor: { epoch: "epoch-1", seq: 12 }, limit: TIMELINE_FETCH_PAGE_SIZE, projection: "projected", + wholeTurns: true, }, }, ]); @@ -283,6 +287,7 @@ describe("refreshAgent", () => { direction: "tail", limit: TIMELINE_FETCH_PAGE_SIZE, projection: "projected", + wholeTurns: true, }, }, ]); diff --git a/packages/app/src/hooks/use-load-older-agent-history.test.ts b/packages/app/src/hooks/use-load-older-agent-history.test.ts index 1da68b4edf..3259631992 100644 --- a/packages/app/src/hooks/use-load-older-agent-history.test.ts +++ b/packages/app/src/hooks/use-load-older-agent-history.test.ts @@ -152,6 +152,7 @@ describe("loadOlderAgentHistory", () => { cursor: { epoch: "epoch-1", seq: 10 }, limit: TIMELINE_FETCH_PAGE_SIZE, projection: "projected", + wholeTurns: true, }, }, ]); diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 6bfc30095c..3928da7e68 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -67,6 +67,7 @@ export const ar: TranslationResources = { daemonClientUnavailable: "عميل Daemon غير متوفر", daemonClientDisconnected: "تم قطع اتصال عميل Daemon", noFileFound: "لم يتم العثور على ملف لـ{{token}}", + folderOutsideWorkspace: "{{path}} خارج مساحة العمل هذه", unexpectedDictationError: "حدث خطأ غير متوقع أثناء معالجة الإملاء.", }, connectionStatus: { @@ -2018,12 +2019,13 @@ export const ar: TranslationResources = { output: "الإخراج", }, turnSummary: { - messages: { - one: "كتب {{count}} رسالة", - other: "كتب {{count}} رسائل", - }, + details: "عرض تفاصيل الجولة", + sentBy: "أرسلها {{name}}", + showMore: "عرض المزيد", + showLess: "عرض أقل", }, toolCallGroup: { + thought: "فكّر", editedFiles: { one: "حرّر {{count}} ملفًا", other: "حرّر {{count}} ملفات", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index ff1e80d88b..e7ed75e509 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -63,6 +63,7 @@ export const en = { daemonClientUnavailable: "Daemon client unavailable", daemonClientDisconnected: "Daemon client is disconnected", noFileFound: "No file found for {{token}}", + folderOutsideWorkspace: "{{path}} is outside this workspace", unexpectedDictationError: "An unexpected error occurred while handling dictation.", }, connectionStatus: { @@ -2043,12 +2044,13 @@ export const en = { output: "Output", }, turnSummary: { - messages: { - one: "wrote {{count}} message", - other: "wrote {{count}} messages", - }, + details: "View turn details", + sentBy: "Sent by {{name}}", + showMore: "Show more", + showLess: "Show less", }, toolCallGroup: { + thought: "Thought", editedFiles: { one: "edited {{count}} file", other: "edited {{count}} files", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 8784adefb5..7cf68c69ba 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -68,6 +68,7 @@ export const es: TranslationResources = { daemonClientUnavailable: "ClienteDaemonno disponible", daemonClientDisconnected: "El clienteDaemonestá desconectado", noFileFound: "No se encontró ningún archivo para{{token}}", + folderOutsideWorkspace: "{{path}} está fuera de este espacio de trabajo", unexpectedDictationError: "Se produjo un error inesperado al manejar el dictado.", }, connectionStatus: { @@ -2066,12 +2067,13 @@ export const es: TranslationResources = { output: "Producción", }, turnSummary: { - messages: { - one: "escribió {{count}} mensaje", - other: "escribió {{count}} mensajes", - }, + details: "Ver detalles del turno", + sentBy: "Enviado por {{name}}", + showMore: "Mostrar más", + showLess: "Mostrar menos", }, toolCallGroup: { + thought: "Razonó", editedFiles: { one: "editó {{count}} archivo", other: "editó {{count}} archivos", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index bbafc03dda..da58636a70 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -68,6 +68,7 @@ export const fr: TranslationResources = { daemonClientUnavailable: "Client du daemon indisponible", daemonClientDisconnected: "Le client du daemon est déconnecté", noFileFound: "Aucun fichier trouvé pour {{token}}", + folderOutsideWorkspace: "{{path}} se trouve en dehors de cet espace de travail", unexpectedDictationError: "Une erreur inattendue s’est produite pendant la dictée.", }, connectionStatus: { @@ -2065,12 +2066,13 @@ export const fr: TranslationResources = { output: "Sortie", }, turnSummary: { - messages: { - one: "{{count}} message écrit", - other: "{{count}} messages écrits", - }, + details: "Voir les détails du tour", + sentBy: "Envoyé par {{name}}", + showMore: "Afficher plus", + showLess: "Afficher moins", }, toolCallGroup: { + thought: "Réfléchi", editedFiles: { one: "{{count}} fichier modifié", other: "{{count}} fichiers modifiés", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index e7dcc5b5e7..cd327a6bb5 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -68,6 +68,7 @@ export const ja: TranslationResources = { daemonClientUnavailable: "デーモンクライアントが利用できません", daemonClientDisconnected: "デーモンクライアントが切断されています", noFileFound: "{{token}}のファイルが見つかりません", + folderOutsideWorkspace: "{{path}} はこのワークスペースの外にあります", unexpectedDictationError: "音声入力処理中に予期しないエラーが発生しました。", }, connectionStatus: { @@ -2038,12 +2039,13 @@ export const ja: TranslationResources = { output: "出力", }, turnSummary: { - messages: { - one: "{{count}}件のメッセージを送信", - other: "{{count}}件のメッセージを送信", - }, + details: "ターンの詳細を表示", + sentBy: "{{name}} から送信", + showMore: "もっと見る", + showLess: "折りたたむ", }, toolCallGroup: { + thought: "思考", editedFiles: { one: "{{count}}個のファイルを編集", other: "{{count}}個のファイルを編集", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index d8d1fbc787..aad8d5e0cb 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -67,6 +67,7 @@ export const ko: TranslationResources = { daemonClientUnavailable: "데몬 클라이언트를 사용할 수 없습니다", daemonClientDisconnected: "데몬 클라이언트 연결이 끊어졌습니다", noFileFound: "{{token}}에 해당하는 파일을 찾을 수 없습니다", + folderOutsideWorkspace: "{{path}}은(는) 이 워크스페이스 밖에 있습니다", unexpectedDictationError: "받아쓰기를 처리하는 중 예기치 않은 오류가 발생했습니다.", }, connectionStatus: { @@ -2028,12 +2029,13 @@ export const ko: TranslationResources = { output: "출력", }, turnSummary: { - messages: { - one: "{{count}} 메시지를 작성했습니다.", - other: "{{count}} 메시지를 작성했습니다.", - }, + details: "턴 세부 정보 보기", + sentBy: "{{name}}이(가) 보냄", + showMore: "더 보기", + showLess: "접기", }, toolCallGroup: { + thought: "생각함", editedFiles: { one: "{{count}} 파일을 편집했습니다.", other: "{{count}} 파일을 편집했습니다.", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 1c024dc64a..696cb86b17 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -68,6 +68,7 @@ export const ptBR: TranslationResources = { daemonClientUnavailable: "Cliente do daemon indisponível", daemonClientDisconnected: "O cliente do daemon está desconectado", noFileFound: "Nenhum arquivo encontrado para {{token}}", + folderOutsideWorkspace: "{{path}} está fora deste workspace", unexpectedDictationError: "Ocorreu um erro inesperado ao processar o ditado.", }, connectionStatus: { @@ -2051,12 +2052,13 @@ export const ptBR: TranslationResources = { output: "Saída", }, turnSummary: { - messages: { - one: "escreveu {{count}} mensagem", - other: "escreveu {{count}} mensagens", - }, + details: "Ver detalhes do turno", + sentBy: "Enviado por {{name}}", + showMore: "Mostrar mais", + showLess: "Mostrar menos", }, toolCallGroup: { + thought: "Pensou", editedFiles: { one: "editou {{count}} arquivo", other: "editou {{count}} arquivos", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 093ac68c36..ab1405852a 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -68,6 +68,7 @@ export const ru: TranslationResources = { daemonClientUnavailable: "Daemon клиента недоступен", daemonClientDisconnected: "Daemon клиента отключен", noFileFound: "Файл для {{token}} не найден", + folderOutsideWorkspace: "{{path}} находится вне этого рабочего пространства", unexpectedDictationError: "При обработке диктовки произошла непредвиденная ошибка.", }, connectionStatus: { @@ -2051,12 +2052,13 @@ export const ru: TranslationResources = { output: "Выходные данные", }, turnSummary: { - messages: { - one: "написано {{count}} сообщение", - other: "написаны сообщения ({{count}})", - }, + details: "Показать подробности хода", + sentBy: "Отправлено агентом {{name}}", + showMore: "Показать больше", + showLess: "Свернуть", }, toolCallGroup: { + thought: "Обдумал", editedFiles: { one: "изменён {{count}} файл", other: "изменены файлы ({{count}})", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 8ca178f206..da97288cf4 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -67,6 +67,7 @@ export const zhCN: TranslationResources = { daemonClientUnavailable: "Daemon client 不可用", daemonClientDisconnected: "Daemon client 已断开连接", noFileFound: "未找到 {{token}} 对应的文件", + folderOutsideWorkspace: "{{path}} 不在此工作区内", unexpectedDictationError: "处理听写时发生意外错误。", }, connectionStatus: { @@ -1996,12 +1997,13 @@ export const zhCN: TranslationResources = { output: "输出", }, turnSummary: { - messages: { - one: "写了 {{count}} 条消息", - other: "写了 {{count}} 条消息", - }, + details: "查看轮次详情", + sentBy: "由 {{name}} 发送", + showMore: "展开", + showLess: "收起", }, toolCallGroup: { + thought: "已思考", editedFiles: { one: "编辑了 {{count}} 个文件", other: "编辑了 {{count}} 个文件", diff --git a/packages/app/src/plugins/client-bundles/index.ts b/packages/app/src/plugins/client-bundles/index.ts new file mode 100644 index 0000000000..06580dd222 --- /dev/null +++ b/packages/app/src/plugins/client-bundles/index.ts @@ -0,0 +1,32 @@ +import * as FileSystem from "expo-file-system/legacy"; +import { CryptoDigestAlgorithm, digestStringAsync, randomUUID } from "expo-crypto"; +import { createPluginClientBundleStore } from "./store"; + +export function createHostPluginClientBundleStore(serverId: string) { + const directory = `${FileSystem.cacheDirectory}plugin-client-bundles/${encodeURIComponent(serverId)}/`; + const ensureDirectory = async () => { + if (!FileSystem.cacheDirectory) throw new Error("Plugin bundle cache directory unavailable"); + await FileSystem.makeDirectoryAsync(directory, { intermediates: true }); + }; + return createPluginClientBundleStore( + { + async list() { + await ensureDirectory(); + return FileSystem.readDirectoryAsync(directory); + }, + read: (name) => FileSystem.readAsStringAsync(`${directory}${name}`), + async writeAtomic(name, contents) { + await ensureDirectory(); + const temporary = `${directory}${randomUUID()}.tmp`; + try { + await FileSystem.writeAsStringAsync(temporary, contents); + await FileSystem.moveAsync({ from: temporary, to: `${directory}${name}` }); + } finally { + await FileSystem.deleteAsync(temporary, { idempotent: true }); + } + }, + remove: (name) => FileSystem.deleteAsync(`${directory}${name}`, { idempotent: true }), + }, + (contents) => digestStringAsync(CryptoDigestAlgorithm.SHA256, contents), + ); +} diff --git a/packages/app/src/plugins/client-bundles/index.web.ts b/packages/app/src/plugins/client-bundles/index.web.ts new file mode 100644 index 0000000000..6e67778089 --- /dev/null +++ b/packages/app/src/plugins/client-bundles/index.web.ts @@ -0,0 +1,62 @@ +import { createPluginClientBundleStore } from "./store"; + +const DATABASE_NAME = "paseo-plugin-client-bundles"; +const STORE_NAME = "bundles"; +let opening: Promise | undefined; + +function openDatabase(): Promise { + opening ??= new Promise((resolve, reject) => { + const request = indexedDB.open(DATABASE_NAME, 1); + request.addEventListener("upgradeneeded", () => request.result.createObjectStore(STORE_NAME)); + request.addEventListener("success", () => resolve(request.result)); + request.addEventListener("error", () => reject(request.error)); + }).catch((error) => { + opening = undefined; + throw error; + }); + return opening; +} + +export function createHostPluginClientBundleStore(serverId: string) { + const prefix = `${encodeURIComponent(serverId)}/`; + const range = () => IDBKeyRange.bound(prefix, `${prefix}\uffff`); + async function run( + mode: IDBTransactionMode, + operation: (store: IDBObjectStore) => IDBRequest, + ): Promise { + const database = await openDatabase(); + return new Promise((resolve, reject) => { + const transaction = database.transaction(STORE_NAME, mode); + const request = operation(transaction.objectStore(STORE_NAME)); + transaction.addEventListener("complete", () => resolve(request.result)); + transaction.addEventListener("abort", () => reject(transaction.error)); + transaction.addEventListener("error", () => reject(transaction.error)); + }); + } + return createPluginClientBundleStore( + { + async list() { + return (await run("readonly", (store) => store.getAllKeys(range()))) + .filter((key): key is string => typeof key === "string") + .map((key) => key.slice(prefix.length)); + }, + async read(name) { + const result: unknown = await run("readonly", (store) => store.get(`${prefix}${name}`)); + if (typeof result !== "string") throw new Error("Missing plugin client bundle"); + return result; + }, + async writeAtomic(name, contents) { + await run("readwrite", (store) => store.put(contents, `${prefix}${name}`)); + }, + async remove(name) { + await run("readwrite", (store) => store.delete(`${prefix}${name}`)); + }, + }, + async (contents) => { + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(contents)); + return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join( + "", + ); + }, + ); +} diff --git a/packages/app/src/plugins/client-bundles/storage.browser.test.ts b/packages/app/src/plugins/client-bundles/storage.browser.test.ts new file mode 100644 index 0000000000..9acd7e0c52 --- /dev/null +++ b/packages/app/src/plugins/client-bundles/storage.browser.test.ts @@ -0,0 +1,36 @@ +import { expect, test } from "vitest"; +import { createHostPluginClientBundleStore } from "./index.web"; + +async function sha256(contents: string) { + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(contents)); + return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +test("browser bundles survive a fresh store and remain isolated by host", async () => { + const first = createHostPluginClientBundleStore("bundle-test-host-a"); + const second = createHostPluginClientBundleStore("bundle-test-host-b"); + const clientBundle = "bundle".repeat(250_000); + const entry = { + id: "same-plugin", + clientBundle, + clientBundleHash: await sha256(clientBundle), + }; + const otherCode = "other host code"; + const other = { + id: entry.id, + clientBundle: otherCode, + clientBundleHash: await sha256(otherCode), + }; + try { + await first.replace([entry]); + await second.replace([other]); + expect(await createHostPluginClientBundleStore("bundle-test-host-a").load()).toEqual([entry]); + expect(await createHostPluginClientBundleStore("bundle-test-host-b").load()).toEqual([other]); + await first.replace([]); + expect(await createHostPluginClientBundleStore("bundle-test-host-a").load()).toEqual([]); + expect(await second.load()).toEqual([other]); + } finally { + await first.replace([]); + await second.replace([]); + } +}); diff --git a/packages/app/src/plugins/client-bundles/store.test.ts b/packages/app/src/plugins/client-bundles/store.test.ts new file mode 100644 index 0000000000..c3332c6b0e --- /dev/null +++ b/packages/app/src/plugins/client-bundles/store.test.ts @@ -0,0 +1,179 @@ +import { createHash } from "node:crypto"; +import { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { expect, test } from "vitest"; +import { createPluginClientBundleStore, type PluginBundleFiles } from "./store"; + +const sha256 = async (contents: string) => createHash("sha256").update(contents).digest("hex"); + +class BundleFiles implements PluginBundleFiles { + readonly contents = new Map(); + readonly writes: string[] = []; + async list() { + return [...this.contents.keys()]; + } + async read(name: string) { + const contents = this.contents.get(name); + if (contents === undefined) throw new Error("File disappeared"); + return contents; + } + async writeAtomic(name: string, contents: string) { + this.writes.push(name); + this.contents.set(name, contents); + } + async remove(name: string) { + this.contents.delete(name); + } +} + +async function bundle(id: string, clientBundle: string) { + return { id, clientBundle, clientBundleHash: await sha256(clientBundle) }; +} + +test("a cold store loads verified bundles and prunes versions missing from the latest catalog", async () => { + const files = new BundleFiles(); + const old = await bundle("changed", "old"); + const kept = await bundle("kept", "Unicode: 折叠屏 🐚"); + await createPluginClientBundleStore(files, sha256).replace([old, kept]); + const cold = createPluginClientBundleStore(files, sha256); + expect(await cold.load()).toEqual([old, kept]); + const changed = await bundle("changed", "new"); + await cold.replace([kept, changed]); + expect(files.contents.size).toBe(2); + expect(await createPluginClientBundleStore(files, sha256).load()).toEqual([kept, changed]); + expect(files.writes).toHaveLength(3); + await cold.replace([]); + expect(files.contents.size).toBe(0); +}); + +test("same-length corruption, truncated files and unreadable files are not advertised", async () => { + const files = new BundleFiles(); + const entries = await Promise.all([ + bundle("same-length", "good"), + bundle("truncated", "long"), + bundle("missing", "lost"), + ]); + await createPluginClientBundleStore(files, sha256).replace(entries); + const names = [...files.contents.keys()]; + files.contents.set(names[0]!, "evil"); + files.contents.set(names[1]!, "lo"); + // The directory entry still exists while reading the file fails. + const unreadable: PluginBundleFiles = { + list: () => files.list(), + read: (name) => + name === names[2] ? Promise.reject(new Error("Unreadable")) : files.read(name), + writeAtomic: (name, contents) => files.writeAtomic(name, contents), + remove: (name) => files.remove(name), + }; + const cold = createPluginClientBundleStore(unreadable, sha256); + expect(await cold.load()).toEqual([]); + await cold.replace(entries); + expect(await createPluginClientBundleStore(files, sha256).load()).toEqual(entries); +}); + +test("an interrupted atomic write leaves the prior catalog available for a cold reader", async () => { + const files = new BundleFiles(); + const previous = await bundle("plugin", "previous"); + await createPluginClientBundleStore(files, sha256).replace([previous]); + const failing: PluginBundleFiles = { + list: () => files.list(), + read: (name) => files.read(name), + remove: (name) => files.remove(name), + writeAtomic: async () => { + throw new Error("Disk full"); + }, + }; + await expect( + createPluginClientBundleStore(failing, sha256).replace([await bundle("plugin", "next")]), + ).rejects.toThrow("Disk full"); + expect(await createPluginClientBundleStore(files, sha256).load()).toEqual([previous]); +}); + +// Exercise the app store through a fresh platform-agnostic client, using its transport port. +test.each(["valid", "corrupt", "missing"])( + "cold catalog uses a %s persisted bundle correctly", + async (state) => { + const files = new BundleFiles(); + const entry = await bundle("plugin", "good code"); + await createPluginClientBundleStore(files, sha256).replace([entry]); + if (state === "corrupt") files.contents.set([...files.contents.keys()][0]!, "evil code"); + if (state === "missing") files.contents.clear(); + let open = () => {}; + let receive: (data: unknown, binary: boolean) => void = () => {}; + const requests: Record[] = []; + const client = new DaemonClient({ + url: "ws://test", + clientId: "plugin-cache-test", + reconnect: { enabled: false }, + createPluginClientBundleStore: () => createPluginClientBundleStore(files, sha256), + transportFactory: () => ({ + send(data) { + if (typeof data !== "string") return; + const frame = JSON.parse(data); + if (frame.type !== "session") return; + const request = frame.message; + if (request.type !== "plugin.catalog.get.request") return; + requests.push(request); + const { clientBundle: _code, ...reference } = entry; + receive( + JSON.stringify({ + type: "session", + message: { + type: "plugin.catalog.get.response", + payload: { + requestId: request.requestId, + plugins: [request.cachedClientBundles ? reference : entry], + }, + }, + }), + false, + ); + }, + close() {}, + onOpen(handler) { + open = handler; + return () => {}; + }, + onMessage(handler) { + receive = handler; + return () => {}; + }, + onClose() { + return () => {}; + }, + onError() { + return () => {}; + }, + }), + }); + try { + const connecting = client.connect(); + open(); + receive( + JSON.stringify({ + type: "session", + message: { + type: "status", + payload: { + status: "server_info", + serverId: "cache-host", + hostname: null, + version: null, + features: { plugins: true }, + }, + }, + }), + false, + ); + await connecting; + expect(await client.getPluginCatalog()).toEqual([entry]); + if (state === "valid") + expect(requests[0]?.cachedClientBundles).toEqual([ + { id: entry.id, clientBundleHash: entry.clientBundleHash }, + ]); + else expect(requests[0]).not.toHaveProperty("cachedClientBundles"); + await expect.poll(() => createPluginClientBundleStore(files, sha256).load()).toEqual([entry]); + } finally { + await client.close(); + } + }, +); diff --git a/packages/app/src/plugins/client-bundles/store.ts b/packages/app/src/plugins/client-bundles/store.ts new file mode 100644 index 0000000000..4a120c2c2a --- /dev/null +++ b/packages/app/src/plugins/client-bundles/store.ts @@ -0,0 +1,56 @@ +import type { + PluginClientBundle, + PluginClientBundleStore, +} from "@getpaseo/client/internal/daemon-client"; + +/** Each adapter is scoped to one host. Writes publish a complete file/record atomically. */ +export interface PluginBundleFiles { + list(): Promise; + read(name: string): Promise; + writeAtomic(name: string, contents: string): Promise; + remove(name: string): Promise; +} + +export function createPluginClientBundleStore( + files: PluginBundleFiles, + sha256: (contents: string) => Promise, +): PluginClientBundleStore { + const verified = new Set(); + const filename = (bundle: PluginClientBundle) => + `${encodeURIComponent(bundle.id)}.${bundle.clientBundleHash}.js`; + return { + async load() { + const bundles: PluginClientBundle[] = []; + for (const name of await files.list()) { + try { + const match = /^(.*)\.([a-f0-9]{64})\.js$/.exec(name); + if (!match) continue; + const id = decodeURIComponent(match[1]!); + const clientBundleHash = match[2]!; + const clientBundle = await files.read(name); + if ((await sha256(clientBundle)) !== clientBundleHash) continue; + verified.add(name); + bundles.push({ id, clientBundleHash, clientBundle }); + } catch { + // Missing, unreadable or corrupt files are cache misses, never executable code. + } + } + return bundles; + }, + async replace(bundles) { + const retained = new Set(); + for (const bundle of bundles) { + const name = filename(bundle); + retained.add(name); + if (verified.has(name)) continue; + await files.writeAtomic(name, bundle.clientBundle); + verified.add(name); + } + for (const name of await files.list()) { + if (retained.has(name)) continue; + await files.remove(name); + verified.delete(name); + } + }, + }; +} diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index bb953220cb..0c7c9f4660 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -1,3 +1,4 @@ +import { createHostPluginClientBundleStore } from "@/plugins/client-bundles"; import { watchdog } from "@/watchdog"; import { createHostChannelTransport } from "@getpaseo/client/internal/hosts"; import type { OwnedSubscription } from "@getpaseo/client"; @@ -589,6 +590,7 @@ function createDefaultDeps( capabilities: appCapabilities, trace: createPerformanceTrace({ host: host.serverId, connection: connection.id }), providerSnapshots: "wire", + createPluginClientBundleStore: createHostPluginClientBundleStore, ...(host.password ? { password: host.password } : {}), localCredential: () => readDesktopManagedLocalCredential(connection), } satisfies Omit; @@ -668,6 +670,7 @@ function createDefaultDeps( ), reconnect: { enabled: false }, appVersion: resolveAppVersion() ?? undefined, + createPluginClientBundleStore: createHostPluginClientBundleStore, }); try { await client.connect(); @@ -684,6 +687,7 @@ function createDefaultDeps( ...(timeoutMs !== undefined ? { timeoutMs } : {}), capabilities: appCapabilities, trace: createPerformanceTrace({ host: host.serverId, connection: connection.id }), + createPluginClientBundleStore: createHostPluginClientBundleStore, }); }, getClientId: () => getOrCreateClientId(), diff --git a/packages/app/src/runtime/replica-cache/index.test.ts b/packages/app/src/runtime/replica-cache/index.test.ts index eaad715a52..d91d8e7ba4 100644 --- a/packages/app/src/runtime/replica-cache/index.test.ts +++ b/packages/app/src/runtime/replica-cache/index.test.ts @@ -306,6 +306,21 @@ async function expectLegacyWorkspaceRefresh({ } describe("ReplicaCache", () => { + it("persists the complete newest turn beyond the cache item target", async () => { + const storage = new MemoryStorage(); + const writer = createCache(storage); + const items = Array.from({ length: 60 }, (_, index) => ({ + ...timelineItem(`item-${index}`), + id: `item-${index}`, + turnId: "long-run", + })); + writer.commitTimeline(SERVER_ID, "agent-1", { ...timeline(), items }); + await writer.flush(); + const restored = await createCache(storage).readTimeline(SERVER_ID, "agent-1"); + expect(restored?.items.map((item) => item.id)).toEqual(items.map((item) => item.id)); + expect(restored?.range).toBeNull(); + }); + it("preserves workspace sorting timestamps across cache reopen", async () => { const storage = new MemoryStorage(); const writer = createCache(storage); diff --git a/packages/app/src/runtime/replica-cache/index.ts b/packages/app/src/runtime/replica-cache/index.ts index 59578e65d7..9ad604c8eb 100644 --- a/packages/app/src/runtime/replica-cache/index.ts +++ b/packages/app/src/runtime/replica-cache/index.ts @@ -1,3 +1,4 @@ +import { findMountedWindowStart } from "@/agent-stream/history-window"; import { z } from "zod"; import { AgentStatusSchema, @@ -766,7 +767,14 @@ function serializeTimeline(timeline: CachedTimeline): StoredTimeline | null { const canonicalItems = timeline.items.filter( (item) => item.kind !== "user_message" || !isUnreconciledLocalUserMessage(item), ); - const items = canonicalItems.map(serializeTimelineItem).filter((item) => item !== null); + const start = findMountedWindowStart({ + items: canonicalItems, + minMountedCount: MAX_TIMELINE_ITEMS, + }); + const items = canonicalItems + .slice(start) + .map(serializeTimelineItem) + .filter((item) => item !== null); const range = timeline.range; const canPersistCoverage = range !== null && @@ -783,7 +791,7 @@ function serializeTimeline(timeline: CachedTimeline): StoredTimeline | null { canonicalItems.some((item) => item.timelineCursor?.seq === range.endSeq); return { agentId: timeline.agentId, - items: items.slice(-MAX_TIMELINE_ITEMS), + items, range: canPersistCoverage ? { epoch: range.epoch, startSeq: range.startSeq, endSeq: range.endSeq } : null, diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 3617c074e2..d1b3fd3e9a 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -1,3 +1,4 @@ +import { planTimelineTailFetch } from "@/timeline/timeline-sync-plan"; import { useHostFeature } from "@/runtime/host-features"; import { AssistantFileLinkResolverProvider, @@ -63,6 +64,7 @@ import { WorkspaceScriptsButton } from "@/screens/workspace/scripts"; import { ImportSessionSheet } from "@/components/import-session-sheet"; import { useNavigateToImportedAgent } from "@/hooks/use-import-session"; import { useToast } from "@/contexts/toast-context"; +import { useOpenOutsideWorkspaceFolder } from "@/workspace/open-in-file-manager/outside-folder"; import { getOrCreateClientId } from "@/utils/client-id"; import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store"; import { toggleDesktopSidebarsWithCheckoutIntent } from "@/utils/desktop-sidebar-toggle"; @@ -2179,8 +2181,7 @@ function WorkspaceScreenContent({ const sessionState = useSessionStore.getState().sessions[normalizedServerId]; const currentCursor = sessionState?.agentTimelineCursor.get(agentId); await getHostRuntimeStore().fetchAgentTimeline(normalizedServerId, agentId, { - direction: "tail", - projection: "projected", + ...planTimelineTailFetch(), ...(currentCursor ? { cursor: { epoch: currentCursor.epoch, seq: currentCursor.endSeq } } : {}), @@ -3682,6 +3683,7 @@ function WorkspaceScreenContent({ view: "files", }); }); + const openMarkdownOutsideDirectory = useOpenOutsideWorkspaceFolder(normalizedServerId); const markdownPathOpener = useMemo( () => createInlinePathOpener({ @@ -3689,8 +3691,15 @@ function WorkspaceScreenContent({ client, openFile: openMarkdownFile, openDirectory: openMarkdownDirectory, + openOutsideDirectory: openMarkdownOutsideDirectory, }), - [workspaceDirectory, client, openMarkdownFile, openMarkdownDirectory], + [ + workspaceDirectory, + client, + openMarkdownFile, + openMarkdownDirectory, + openMarkdownOutsideDirectory, + ], ); useEffect(() => { if (!isRouteFocused) markdownPathOpener.cancel(); diff --git a/packages/app/src/terminal/runtime/terminal-glyph-rendering.browser.test.ts b/packages/app/src/terminal/runtime/terminal-glyph-rendering.browser.test.ts new file mode 100644 index 0000000000..35629f01f8 --- /dev/null +++ b/packages/app/src/terminal/runtime/terminal-glyph-rendering.browser.test.ts @@ -0,0 +1,203 @@ +import "@xterm/xterm/css/xterm.css"; +import { page } from "vitest/browser"; +import { afterEach, expect, it } from "vitest"; +import type { Terminal } from "@xterm/xterm"; +import { encodeTerminalOutput, TerminalEmulatorRuntime } from "./terminal-emulator-runtime"; + +interface TerminalView { + root: HTMLDivElement; + host: HTMLDivElement; + runtime: TerminalEmulatorRuntime; + terminal: Terminal; +} + +interface RowImage { + width: number; + height: number; + pixels: Uint8ClampedArray; +} + +const views: TerminalView[] = []; +const header = "HEADER_0123456789 한글 日本語"; + +function mountTerminal(left: number): TerminalView { + const root = document.createElement("div"); + Object.assign(root.style, { + position: "fixed", + left: `${left}px`, + top: "0", + width: "440px", + height: "360px", + overflow: "hidden", + }); + const host = document.createElement("div"); + Object.assign(host.style, { width: "100%", height: "100%" }); + root.appendChild(host); + document.body.appendChild(root); + const runtime = new TerminalEmulatorRuntime(); + runtime.mount({ + root, + host, + initialSnapshot: null, + scrollback: 1000, + fontFamily: "monospace", + fontSize: 16, + theme: { background: "#101010", foreground: "#eeeeee", cursor: "#eeeeee" }, + }); + const terminal = window.__paseoTerminal; + if (!terminal) throw new Error("Terminal did not mount"); + const view = { root, host, runtime, terminal }; + views.push(view); + return view; +} + +function write(view: TerminalView, text: string): Promise { + return new Promise((resolve) => { + view.runtime.write({ data: encodeTerminalOutput(text), onCommitted: resolve }); + }); +} + +function webglCanvas(view: TerminalView): HTMLCanvasElement { + for (const canvas of view.host.querySelectorAll(".xterm-screen canvas")) { + if (canvas instanceof HTMLCanvasElement && canvas.getContext("webgl2")) return canvas; + } + throw new Error("Real WebGL is required; DOM fallback cannot test glyph corruption"); +} + +async function mountPair() { + await page.viewport(1000, 600); + const active = mountTerminal(0); + const sibling = mountTerminal(460); + await document.fonts.ready; + await expect + .poll(() => active.host.querySelectorAll(".xterm-screen canvas").length) + .toBeGreaterThan(0); + await expect + .poll(() => sibling.host.querySelectorAll(".xterm-screen canvas").length) + .toBeGreaterThan(0); + webglCanvas(active); + webglCanvas(sibling); + await write(active, "\x1b[?25l"); + await write(sibling, "\x1b[?25l"); + return { active, sibling }; +} + +function captureHeader(input: { view: TerminalView; output?: string }): Promise { + const { view, output } = input; + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + rendered.dispose(); + reject(new Error("Terminal did not render within 2 seconds")); + }, 2000); + const rendered = view.terminal.onRender(() => { + rendered.dispose(); + clearTimeout(timeout); + try { + // Copy during onRender, before WebGL discards its default drawing buffer. + const source = webglCanvas(view); + const canvas = document.createElement("canvas"); + canvas.width = source.width; + canvas.height = Math.round(source.height / view.terminal.rows); + const context = canvas.getContext("2d"); + if (!context) throw new Error("Canvas 2D pixel capture is unavailable"); + context.drawImage(source, 0, 0); + const pixels = context.getImageData(0, 0, canvas.width, canvas.height).data; + resolve({ width: canvas.width, height: canvas.height, pixels }); + } catch (error) { + reject(error); + } + }); + if (output === undefined) view.terminal.refresh(0, view.terminal.rows - 1); + else view.runtime.write({ data: encodeTerminalOutput(output) }); + }); +} + +function expectSamePixels(input: { expected: RowImage; actual: RowImage; label: string }): void { + const { expected, actual, label } = input; + expect(actual.width, label).toBe(expected.width); + expect(actual.height, label).toBe(expected.height); + let changed = 0; + for (let index = 0; index < expected.pixels.length; index += 1) { + if (expected.pixels[index] !== actual.pixels[index]) changed += 1; + } + expect(changed, label).toBe(0); +} + +function coloredOutput(input: { cells: number; offset: number }): string { + const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + let output = ""; + for (let index = 0; index < input.cells; index += 1) { + const value = index + input.offset; + const foreground = `${value % 256};${(value >> 8) % 256};${(value * 13) % 256}`; + const background = (value * 7) % 256; + output += `\x1b[38;2;${foreground}m\x1b[48;5;${background}m${alphabet[value % alphabet.length]}`; + } + return `${output}\x1b[0m`; +} + +afterEach(() => { + for (const view of views.splice(0)) { + view.runtime.unmount(); + view.root.remove(); + } +}); + +it("preserves another terminal's glyph pixels when a shared atlas is cleared", async () => { + const { active, sibling } = await mountPair(); + await write(sibling, header); + const before = await captureHeader({ view: sibling }); + const visibleChannels = before.pixels.filter( + (channel, index) => index % 4 !== 3 && channel > 100, + ); + expect(visibleChannels.length).toBeGreaterThan(100); + + active.terminal.clearTextureAtlas(); + await captureHeader({ view: active, output: coloredOutput({ cells: 2000, offset: 0 }) }); + const after = await captureHeader({ + view: sibling, + output: `\x1b[${sibling.terminal.rows};1Hx`, + }); + expect(sibling.terminal.buffer.active.getLine(0)?.translateToString(true)).toBe(header); + expectSamePixels({ expected: before, actual: after, label: "sibling after atlas clear" }); +}); + +it("preserves both headers while ordinary output grows the shared glyph atlas", async () => { + const { active, sibling } = await mountPair(); + const cells = active.terminal.cols * (active.terminal.rows - 2); + // Startup glyphs can stay on an unmerged page and hide this bug. Allocate the + // reference after some output, as a real long-running TUI does. + for (let batch = 0; batch < 16; batch += 1) { + await captureHeader({ + view: active, + output: `\x1b[H${coloredOutput({ cells, offset: batch * cells })}`, + }); + } + const lateHeader = `\x1b[H\x1b[0m\x1b[2K\x1b[38;2;253;251;247m${header}\x1b[0m`; + await write(active, lateHeader); + await write(sibling, lateHeader); + const activeBefore = await captureHeader({ view: active }); + const siblingBefore = await captureHeader({ view: sibling }); + + for (let batch = 0; batch < 256; batch += 1) { + const activeFrame = await captureHeader({ + view: active, + output: `\x1b[2;1H${coloredOutput({ cells, offset: (batch + 16) * cells })}`, + }); + const siblingFrame = await captureHeader({ + view: sibling, + output: `\x1b[${sibling.terminal.rows};1H${batch % 10}`, + }); + expect(active.terminal.buffer.active.getLine(0)?.translateToString(true)).toBe(header); + expect(sibling.terminal.buffer.active.getLine(0)?.translateToString(true)).toBe(header); + expectSamePixels({ + expected: activeBefore, + actual: activeFrame, + label: `active frame ${batch}`, + }); + expectSamePixels({ + expected: siblingBefore, + actual: siblingFrame, + label: `sibling frame ${batch}`, + }); + } +}, 30000); diff --git a/packages/app/src/timeline/replica.test.ts b/packages/app/src/timeline/replica.test.ts index 47bd36596a..0d825673e9 100644 --- a/packages/app/src/timeline/replica.test.ts +++ b/packages/app/src/timeline/replica.test.ts @@ -371,6 +371,22 @@ describe("viewed timeline persistence", () => { ]); }); + it("preparing an already painted cache again keeps history out of the live head", async () => { + useSessionStore.getState().initializeSession(SERVER_ID, null); + const replica = createTimelineReplica({ + serverId: SERVER_ID, + storage: { readTimeline: async () => cachedTimeline(), commitTimeline: () => undefined }, + prepareAgent: async () => undefined, + }); + await replica.prepare(AGENT_ID); + const painted = useSessionStore.getState().sessions[SERVER_ID]!.agentStreamTail.get(AGENT_ID); + expect(painted).toEqual(cachedTimeline().items); + await replica.prepare(AGENT_ID); + const session = useSessionStore.getState().sessions[SERVER_ID]!; + expect(session.agentStreamHead.get(AGENT_ID) ?? []).toEqual([]); + expect(session.agentStreamTail.get(AGENT_ID)).toBe(painted); + }); + it("reconciles a live head that overlaps the cached canonical timeline", async () => { useSessionStore.getState().initializeSession(SERVER_ID, null); let release!: (value: CachedTimeline) => void; diff --git a/packages/app/src/timeline/session-stream-reducers.test.ts b/packages/app/src/timeline/session-stream-reducers.test.ts index 9e57142846..0e401ad7a0 100644 --- a/packages/app/src/timeline/session-stream-reducers.test.ts +++ b/packages/app/src/timeline/session-stream-reducers.test.ts @@ -19,6 +19,66 @@ import { type TimelineCursor, } from "./session-stream-reducers"; +it("keeps streamed reasoning together while the initial history is loading", () => { + let head: StreamItem[] = []; + const chunks = ["Checking a long oper", "ation before", " the re", "sult arrives."]; + for (const [index, text] of chunks.entries()) { + const result = processAgentStreamEvent({ + event: { + type: "timeline", + provider: "codex", + turnId: "turn-18", + item: { type: "reasoning", text }, + }, + seq: index + 1, + epoch: "epoch-1", + currentTail: [], + currentHead: head, + currentCursor: undefined, + timestamp: new Date(1000 + index), + hasAuthoritativeBaseline: false, + }); + head = result.head; + expect(head).toHaveLength(1); + expect(head[0]).toMatchObject({ + kind: "thought", + text: chunks.slice(0, index + 1).join(""), + turnId: "turn-18", + }); + } +}); + +it.each([false, true])( + "keeps adjacent reasoning from different turns separate (history loaded: %s)", + (hasAuthoritativeBaseline) => { + let head: StreamItem[] = []; + let tail: StreamItem[] = []; + for (const [index, turnId] of ["turn-1", "turn-2"].entries()) { + const result = processAgentStreamEvent({ + event: { + type: "timeline", + provider: "codex", + turnId, + item: { type: "reasoning", text: turnId }, + }, + seq: index + 1, + epoch: "epoch-1", + currentTail: tail, + currentHead: head, + currentCursor: undefined, + timestamp: new Date(1000), + hasAuthoritativeBaseline, + }); + head = result.head; + tail = result.tail; + } + expect([...tail, ...head]).toMatchObject([ + { kind: "thought", text: "turn-1", turnId: "turn-1" }, + { kind: "thought", text: "turn-2", turnId: "turn-2" }, + ]); + }, +); + // --------------------------------------------------------------------------- // Test helpers // --------------------------------------------------------------------------- diff --git a/packages/app/src/timeline/session-stream-reducers.ts b/packages/app/src/timeline/session-stream-reducers.ts index 34a461d893..a46ec8188f 100644 --- a/packages/app/src/timeline/session-stream-reducers.ts +++ b/packages/app/src/timeline/session-stream-reducers.ts @@ -1615,19 +1615,17 @@ export function processAgentStreamEvent( unmatchedUserMessageInsert: "head", }); } else { - const overlay = applyStreamEvent({ - tail: currentHead, - head: [], - event, - timestamp, + // Before history arrives, the overlay is one ordered stream. Reducing it + // directly keeps consecutive reasoning chunks in the same row. + const overlay = reduceStreamUpdate(currentHead, event, timestamp, { source: "live", timelineCursor, }); streamResult = { tail: currentTail, - head: [...overlay.tail, ...overlay.head], + head: overlay, changedTail: false, - changedHead: overlay.changedTail || overlay.changedHead, + changedHead: overlay !== currentHead, }; } } else { diff --git a/packages/app/src/timeline/timeline-replacement.test.ts b/packages/app/src/timeline/timeline-replacement.test.ts index f457ea7daa..cea2dc909d 100644 --- a/packages/app/src/timeline/timeline-replacement.test.ts +++ b/packages/app/src/timeline/timeline-replacement.test.ts @@ -15,7 +15,7 @@ describe("requestTimelineReplacement", () => { await expect(requestTimelineReplacement(client, "agent-1")).rejects.toThrow("fetch complete"); expect(requests).toEqual([ - ["agent-1", { direction: "tail", projection: "projected", limit: 40 }], + ["agent-1", { direction: "tail", projection: "projected", wholeTurns: true, limit: 40 }], ]); }); }); diff --git a/packages/app/src/timeline/timeline-sync-plan.test.ts b/packages/app/src/timeline/timeline-sync-plan.test.ts index 9135b5fb85..093941fb77 100644 --- a/packages/app/src/timeline/timeline-sync-plan.test.ts +++ b/packages/app/src/timeline/timeline-sync-plan.test.ts @@ -17,6 +17,7 @@ describe("timeline sync planning", () => { direction: "tail", limit: TIMELINE_FETCH_PAGE_SIZE, projection: "projected", + wholeTurns: true, }); }); @@ -26,6 +27,7 @@ describe("timeline sync planning", () => { cursor: { epoch: "epoch-1", seq: 49 }, limit: TIMELINE_FETCH_PAGE_SIZE, projection: "projected", + wholeTurns: true, }); }); @@ -37,6 +39,7 @@ describe("timeline sync planning", () => { cursor: { epoch: "epoch-1", seq: 25 }, limit: TIMELINE_FETCH_PAGE_SIZE, projection: "projected", + wholeTurns: true, }); }); @@ -49,6 +52,7 @@ describe("timeline sync planning", () => { }, limit: TIMELINE_FETCH_PAGE_SIZE, projection: "projected", + wholeTurns: true, mergeWindow: true, }); }); diff --git a/packages/app/src/timeline/timeline-sync-plan.ts b/packages/app/src/timeline/timeline-sync-plan.ts index e5e8b2fb95..b66de16941 100644 --- a/packages/app/src/timeline/timeline-sync-plan.ts +++ b/packages/app/src/timeline/timeline-sync-plan.ts @@ -9,6 +9,7 @@ export interface ProjectedTimelineTailFetchPlan { direction: "tail"; limit: number; projection: "projected"; + wholeTurns: true; } export interface ProjectedTimelineAfterFetchPlan { @@ -16,6 +17,7 @@ export interface ProjectedTimelineAfterFetchPlan { cursor: TimelineSyncCursor; limit: number; projection: "projected"; + wholeTurns: true; } export interface ProjectedTimelineBeforeFetchPlan { @@ -23,6 +25,7 @@ export interface ProjectedTimelineBeforeFetchPlan { cursor: TimelineSyncCursor; limit: number; projection: "projected"; + wholeTurns: true; } export type ProjectedTimelineFetchPlan = @@ -40,6 +43,7 @@ export function planTimelineCatchUpAfter(cursor: TimelineSyncCursor) { cursor, limit: TIMELINE_FETCH_PAGE_SIZE, projection: "projected", + wholeTurns: true, } as const; } @@ -48,6 +52,7 @@ export function planTimelineTailFetch() { direction: "tail", limit: TIMELINE_FETCH_PAGE_SIZE, projection: "projected", + wholeTurns: true, } as const; } @@ -65,6 +70,7 @@ export function planTimelineOlderFetch(cursor: TimelineSyncCursor) { cursor, limit: TIMELINE_FETCH_PAGE_SIZE, projection: "projected", + wholeTurns: true, } as const; } @@ -75,6 +81,7 @@ export function planTimelinePromptJump(target: TimelineSyncCursor) { cursor: { epoch: target.epoch, seq: target.seq + newerRows + 1 }, limit: TIMELINE_FETCH_PAGE_SIZE, projection: "projected", + wholeTurns: true, mergeWindow: true, } as const; } diff --git a/packages/app/src/timeline/turn-time.test.ts b/packages/app/src/timeline/turn-time.test.ts index 3ce6942301..f3e97b2e49 100644 --- a/packages/app/src/timeline/turn-time.test.ts +++ b/packages/app/src/timeline/turn-time.test.ts @@ -51,7 +51,7 @@ describe("deriveStreamTurnTiming", () => { }); assert.equal(timing.runningStartedAt, secondUserAt); - assert.equal(timing.byAssistantId.has("a2"), false); + assert.equal(timing.byItemId.has("a2"), false); }); it("derives completed turn timing from user and assistant item timestamps", () => { @@ -69,7 +69,7 @@ describe("deriveStreamTurnTiming", () => { head: [], }); - assert.deepEqual(timing.byAssistantId.get("a1"), { + assert.deepEqual(timing.byItemId.get("a1"), { completedAt: assistantAt, durationMs: 7000, }); @@ -95,8 +95,8 @@ describe("deriveStreamTurnTiming", () => { completedAt: lastAssistantAt, durationMs: 7000, }; - assert.deepEqual(timing.byAssistantId.get("a1"), expected); - assert.deepEqual(timing.byAssistantId.get("a2"), expected); + assert.deepEqual(timing.byItemId.get("a1"), expected); + assert.deepEqual(timing.byItemId.get("a2"), expected); }); it("preserves the completion timestamp when a canonical turn has no visible prompt", () => { @@ -120,7 +120,7 @@ describe("deriveStreamTurnTiming", () => { head: [], }); - assert.deepEqual(timing.byAssistantId.get("hidden-prompt-a2"), { + assert.deepEqual(timing.byItemId.get("hidden-prompt-a2"), { completedAt: hiddenPromptTurnAt, durationMs: null, }); @@ -143,12 +143,12 @@ describe("deriveStreamTurnTiming", () => { const first = derive("one", "2026-05-15T00:01:04.000Z"); const second = derive("one two", "2026-05-15T00:01:05.000Z"); - assert.equal(second.byAssistantId, first.byAssistantId); - assert.deepEqual(first.byAssistantId.get("a1"), { + assert.equal(second.byItemId, first.byItemId); + assert.deepEqual(first.byItemId.get("a1"), { completedAt: tail[1]?.timestamp, durationMs: 7000, }); - assert.equal(first.byAssistantId.has("a2"), false); + assert.equal(first.byItemId.has("a2"), false); }); it("keeps the timing map identity when the head closed the tail's open turn", () => { @@ -168,8 +168,8 @@ describe("deriveStreamTurnTiming", () => { const first = derive("one", "2026-05-15T00:01:04.000Z"); const second = derive("one two", "2026-05-15T00:01:05.000Z"); - assert.equal(second.byAssistantId, first.byAssistantId); - assert.deepEqual(first.byAssistantId.get("a1"), { + assert.equal(second.byItemId, first.byItemId); + assert.deepEqual(first.byItemId.get("a1"), { completedAt: lastTailAt, durationMs: 7000, }); @@ -193,9 +193,9 @@ describe("deriveStreamTurnTiming", () => { head, }); - assert.notEqual(completed.byAssistantId, active.byAssistantId); - assert.equal(active.byAssistantId.has("a1"), false); - assert.deepEqual(completed.byAssistantId.get("a1"), { completedAt, durationMs: 9000 }); + assert.notEqual(completed.byItemId, active.byItemId); + assert.equal(active.byItemId.has("a1"), false); + assert.deepEqual(completed.byItemId.get("a1"), { completedAt, durationMs: 9000 }); }); it("derives fresh timing when the tail itself changes", () => { @@ -211,11 +211,39 @@ describe("deriveStreamTurnTiming", () => { const first = derive(firstTail); const second = derive(secondTail); - assert.notEqual(second.byAssistantId, first.byAssistantId); - assert.equal(first.byAssistantId.has("a2"), false); - assert.deepEqual(second.byAssistantId.get("a2"), { + assert.notEqual(second.byItemId, first.byItemId); + assert.equal(first.byItemId.has("a2"), false); + assert.deepEqual(second.byItemId.get("a2"), { completedAt: secondTail[3]?.timestamp, durationMs: 3000, }); }); }); + +it("times a heartbeat that ends with tools and no assistant text", () => { + const trigger: StreamItem = { + kind: "tool_call", + id: "trigger", + timestamp: new Date("2026-05-15T00:00:00Z"), + turnId: "heartbeat", + payload: { + source: "agent", + data: { + provider: "claude", + callId: "trigger", + name: "heartbeat", + status: "completed", + error: null, + detail: { type: "plain_text", text: "Check progress" }, + }, + }, + }; + const tool: StreamItem = { ...trigger, id: "last", timestamp: new Date("2026-05-15T00:00:05Z") }; + const timing = deriveStreamTurnTiming({ + tail: [trigger, tool], + head: [], + isTurnActive: false, + activeTurnStartedAt: null, + }); + assert.equal(timing.byItemId.get("last")?.durationMs, 5000); +}); diff --git a/packages/app/src/timeline/turn-time.ts b/packages/app/src/timeline/turn-time.ts index c47a6983cd..8d1aa00648 100644 --- a/packages/app/src/timeline/turn-time.ts +++ b/packages/app/src/timeline/turn-time.ts @@ -1,5 +1,5 @@ import type { StreamItem } from "@/types/stream"; -import { startsNewTurn } from "@/agent-stream/turn-membership"; +import { startsNewTurn, isTurnTrigger } from "@/agent-stream/turn-membership"; export interface TurnTiming { completedAt: Date; @@ -7,7 +7,7 @@ export interface TurnTiming { } export interface StreamTurnTiming { - byAssistantId: Map; + byItemId: Map; runningStartedAt: Date | null; } @@ -20,7 +20,7 @@ interface OpenTurn { interface HeadTurnTiming { additions: Map; - byAssistantId: Map; + byItemId: Map; } interface TailTurnTiming { @@ -37,14 +37,18 @@ const EMPTY_OPEN_TURN: OpenTurn = { }; function closeTurn(turn: OpenTurn, into: Map): void { - if (!turn.lastItemAt || turn.assistantIds.length === 0) { + if (!turn.lastItemAt) { return; } const timing: TurnTiming = { completedAt: turn.lastItemAt, durationMs: turn.userAt ? Math.max(0, turn.lastItemAt.getTime() - turn.userAt.getTime()) : null, }; - for (const id of turn.assistantIds) { + let footerIds = turn.assistantIds; + if (footerIds.length === 0 && turn.previousItem?.kind === "tool_call") { + footerIds = [turn.previousItem.id]; + } + for (const id of footerIds) { into.set(id, timing); } } @@ -61,7 +65,7 @@ function walkTurns( for (const item of items) { if (startsNewTurn(item, previousItem)) { closeTurn({ userAt, lastItemAt, assistantIds, previousItem }, into); - userAt = item.kind === "user_message" ? item.timestamp : null; + userAt = isTurnTrigger(item) ? item.timestamp : null; lastItemAt = null; assistantIds = []; } @@ -108,7 +112,7 @@ function getTailTurnTiming(tail: StreamItem[]): TailTurnTiming { return timing; } -function resolveByAssistantId( +function resolveByItemId( tail: TailTurnTiming, additions: Map, ): Map { @@ -116,14 +120,14 @@ function resolveByAssistantId( return tail.completed; } if (tail.lastHead && areTimingsEqual(tail.lastHead.additions, additions)) { - return tail.lastHead.byAssistantId; + return tail.lastHead.byItemId; } - const byAssistantId = new Map(tail.completed); + const byItemId = new Map(tail.completed); for (const [id, timing] of additions) { - byAssistantId.set(id, timing); + byItemId.set(id, timing); } - tail.lastHead = { additions, byAssistantId }; - return byAssistantId; + tail.lastHead = { additions, byItemId }; + return byItemId; } export function deriveStreamTurnTiming(params: { @@ -139,7 +143,7 @@ export function deriveStreamTurnTiming(params: { closeTurn(openTurn, additions); } return { - byAssistantId: resolveByAssistantId(tail, additions), + byItemId: resolveByItemId(tail, additions), runningStartedAt: params.isTurnActive ? params.activeTurnStartedAt : null, }; } diff --git a/packages/app/src/timeline/viewed-timeline-sync.test.ts b/packages/app/src/timeline/viewed-timeline-sync.test.ts index 9bf6897bd9..a6e7cbeb33 100644 --- a/packages/app/src/timeline/viewed-timeline-sync.test.ts +++ b/packages/app/src/timeline/viewed-timeline-sync.test.ts @@ -189,6 +189,7 @@ class TimelineWorld { direction: "tail", limit: 40, projection: "projected", + wholeTurns: true, }); } finally { this.forcedTimelineTailReplacements.delete(agentId); @@ -341,7 +342,12 @@ test("uses a tail fetch when an agent becomes visible", async () => { membership.succeed(); const fetch = await world.nextFetch("agent-a"); - expect(fetch.request).toEqual({ direction: "tail", limit: 40, projection: "projected" }); + expect(fetch.request).toEqual({ + direction: "tail", + limit: 40, + projection: "projected", + wholeTurns: true, + }); fetch.respond({ hasNewer: false }); }); @@ -366,6 +372,7 @@ test("loads the cache before choosing the authoritative network request", async cursor: { epoch: "cached-epoch", seq: 17 }, limit: 40, projection: "projected", + wholeTurns: true, }); fetch.respond({ hasNewer: false }); }); @@ -384,6 +391,7 @@ test("catches up after the restored cursor when an agent becomes visible", async cursor: { epoch: "epoch-agent-a", seq: 42 }, limit: 40, projection: "projected", + wholeTurns: true, }); fetch.respond({ hasNewer: false }); }); @@ -401,7 +409,12 @@ test("falls back to the latest tail when a restored cursor has more than one cat probe.respond({ hasNewer: true, seq: 82 }); const fallback = await world.nextFetch("agent-a"); - expect(fallback.request).toEqual({ direction: "tail", limit: 40, projection: "projected" }); + expect(fallback.request).toEqual({ + direction: "tail", + limit: 40, + projection: "projected", + wholeTurns: true, + }); expect( world.applyTimelineResponse({ requestId: "fallback-tail", @@ -444,6 +457,7 @@ test("a gap absorbed by a running tail is recovered after the tail completes", a cursor: { epoch: "epoch-agent-a", seq: 9 }, limit: 40, projection: "projected", + wholeTurns: true, }); recovery.respond({ hasNewer: false }); await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready")); @@ -527,6 +541,7 @@ test("unchanged visible-set publication does not cancel paged catch-up", async ( cursor: { epoch: "epoch-agent-a", seq: 5 }, limit: 40, projection: "projected", + wholeTurns: true, }); world.expectNoPendingMembership(); }); @@ -897,12 +912,14 @@ test("gap recovery supersedes completed catch-up and pages through the current t cursor: { epoch: "epoch-agent-a", seq: 10 }, limit: 40, projection: "projected", + wholeTurns: true, }, { direction: "after", cursor: { epoch: "epoch-agent-a", seq: 15 }, limit: 40, projection: "projected", + wholeTurns: true, }, ]); }); diff --git a/packages/app/src/timeline/viewed-timeline-sync.ts b/packages/app/src/timeline/viewed-timeline-sync.ts index 5939feabce..2e14a42093 100644 --- a/packages/app/src/timeline/viewed-timeline-sync.ts +++ b/packages/app/src/timeline/viewed-timeline-sync.ts @@ -109,6 +109,15 @@ class TimelineReplicaOwner implements TimelineReplica { async prepare(agentId: string): Promise { const existing = this.preparations.get(agentId); if (existing) return existing; + const timeline = selectAgentTimelineState( + useSessionStore.getState().sessions[this.serverId], + agentId, + ); + // A route and the visible-chat owner can both prepare before catch-up finishes. + // Repainting that same cache would treat its history as a live head. + if (timeline.status !== "cold" && this.cachedRanges.has(agentId)) { + return; + } const preparation = this.load(agentId).finally(() => { if (this.preparations.get(agentId) === preparation) { this.preparations.delete(agentId); diff --git a/packages/app/src/tool-calls/detail-level/grouping.ts b/packages/app/src/tool-calls/detail-level/grouping.ts index 079158b2d9..5713a06de3 100644 --- a/packages/app/src/tool-calls/detail-level/grouping.ts +++ b/packages/app/src/tool-calls/detail-level/grouping.ts @@ -1,5 +1,6 @@ +import { isTurnTrigger, isTurnBoundary } from "@/agent-stream/turn-membership"; import type { ToolCallDetail } from "@getpaseo/protocol/agent-types"; -import type { StreamItem, ToolCallItem } from "@/types/stream"; +import type { StreamItem, ToolCallItem, ThoughtItem } from "@/types/stream"; export interface ToolCallDescriptor { detail: ToolCallDetail; @@ -9,17 +10,19 @@ export interface ToolCallDescriptor { metadata?: Record; } +export type GroupableItem = ToolCallItem | ThoughtItem; + export interface ToolCallRun { id: string; - calls: readonly ToolCallItem[]; - latest: ToolCallItem; + calls: readonly GroupableItem[]; + latest: GroupableItem; isSealed: boolean; } export interface GroupedHistory { tail: StreamItem[]; groupsByHostId: Map; - pendingCalls: readonly ToolCallItem[]; + pendingCalls: readonly GroupableItem[]; } export interface GroupedToolCalls { @@ -62,15 +65,19 @@ export function describeToolCall(item: ToolCallItem): ToolCallDescriptor { }; } -export function isGroupableToolCall(item: StreamItem): item is ToolCallItem { - if (item.kind !== "tool_call") { +export function isGroupableToolCall( + item: StreamItem, + autoExpandReasoning = false, +): item is GroupableItem { + if (item.kind === "thought") return !autoExpandReasoning; + if (item.kind !== "tool_call" || isTurnTrigger(item)) { return false; } const descriptor = describeToolCall(item); return descriptor.detail.type !== "plan" && descriptor.name.trim().toLowerCase() !== "speak"; } -function createRun(calls: readonly ToolCallItem[], isSealed: boolean): ToolCallRun { +function createRun(calls: readonly GroupableItem[], isSealed: boolean): ToolCallRun { const first = calls[0]; const latest = calls.at(-1); if (!first || !latest) { @@ -79,20 +86,21 @@ function createRun(calls: readonly ToolCallItem[], isSealed: boolean): ToolCallR return { id: first.id, calls, latest, isSealed }; } -function createHost(run: ToolCallRun): ToolCallItem { +function createHost(run: ToolCallRun): GroupableItem { if (run.calls.length === 1) { return run.latest; } return { ...run.latest, id: run.id }; } -function isRunning(call: ToolCallItem): boolean { +function isRunning(call: GroupableItem): boolean { + if (call.kind === "thought") return call.status !== "ready"; const status = describeToolCall(call).status; return status === "running" || status === "executing"; } function appendRun(input: { - calls: readonly ToolCallItem[]; + calls: readonly GroupableItem[]; isSealed: boolean; output: StreamItem[]; groups: Map; @@ -110,13 +118,17 @@ function appendRun(input: { export function prepareGroupedHistory(input: { tail: StreamItem[]; buildGroup: (run: ToolCallRun) => TGroup; + autoExpandReasoning?: boolean; }): GroupedHistory { const output: StreamItem[] = []; const groups = new Map(); - let pending: ToolCallItem[] = []; + let pending: GroupableItem[] = []; for (const item of input.tail) { - if (isGroupableToolCall(item)) { + if ( + isGroupableToolCall(item, input.autoExpandReasoning) && + !isTurnBoundary(pending.at(-1) ?? null, item) + ) { pending.push(item); continue; } @@ -128,7 +140,8 @@ export function prepareGroupedHistory(input: { buildGroup: input.buildGroup, }); pending = []; - output.push(item); + if (isGroupableToolCall(item, input.autoExpandReasoning)) pending.push(item); + else output.push(item); } appendRun({ @@ -151,6 +164,7 @@ export function groupLiveToolCalls(input: { head: StreamItem[]; isTurnActive: boolean; buildGroup: (run: ToolCallRun) => TGroup; + autoExpandReasoning?: boolean; }): GroupedToolCalls { const head: StreamItem[] = []; const liveGroups = new Map(); @@ -175,7 +189,8 @@ export function groupLiveToolCalls(input: { }; for (const item of input.head) { - if (isGroupableToolCall(item)) { + if (isTurnBoundary(pending.at(-1) ?? null, item)) flush(true); + if (isGroupableToolCall(item, input.autoExpandReasoning)) { if (pending.length === 0) { hostPlacement = "head"; } diff --git a/packages/app/src/tool-calls/detail-level/overview/label.ts b/packages/app/src/tool-calls/detail-level/overview/label.ts index 762e530172..605e9ce211 100644 --- a/packages/app/src/tool-calls/detail-level/overview/label.ts +++ b/packages/app/src/tool-calls/detail-level/overview/label.ts @@ -33,13 +33,20 @@ export function useOverviewSummaryLabel( [summary.readFileCount, "toolCallGroup.readFiles"], [summary.searchCount, "toolCallGroup.searches"], [summary.otherToolCount, "toolCallGroup.otherTools"], - [summary.paseoCallCount, "toolCallGroup.paseoCalls"], ] as const; for (const [count, key] of entries) { if (count > 0) { parts.push(t(`${key}.${count === 1 ? "one" : "other"}`, { count })); } } + for (const [action, count] of Object.entries(summary.paseoActions)) { + parts.push( + count === 1 ? action.toLocaleLowerCase() : `${action.toLocaleLowerCase()} ×${count}`, + ); + } + // Reasoning stays in the details; it is not an action in a work summary. + // Keep a label for a group containing only reasoning. + if (parts.length === 0 && summary.thoughtCount > 0) return t("toolCallGroup.thought"); return joinSummaryParts(parts, t("toolCallGroup.and")); }, [leadingParts, summary, t]); } diff --git a/packages/app/src/tool-calls/detail-level/overview/model.ts b/packages/app/src/tool-calls/detail-level/overview/model.ts index 0fb638c012..e4717c43bf 100644 --- a/packages/app/src/tool-calls/detail-level/overview/model.ts +++ b/packages/app/src/tool-calls/detail-level/overview/model.ts @@ -1,17 +1,18 @@ -import { isPaseoToolName } from "@getpaseo/protocol/tool-name-normalization"; -import type { ToolCallItem } from "@/types/stream"; -import { describeToolCall, type ToolCallRun } from "../grouping"; +import { getPaseoToolActionLabel } from "@getpaseo/protocol/paseo-tool-presentation/index"; +import { isTurnTrigger } from "@/agent-stream/turn-membership"; + +import { describeToolCall, type GroupableItem, type ToolCallRun } from "../grouping"; -const DIRECT_PASEO_TOOL_PREFIX = "paseo_"; const DIRECT_SEARCH_TOOL_SUFFIX_PATTERN = /(?:^|[_.:/])(?:web_search|llm_context)$/; export interface OverviewSummary { + thoughtCount: number; editedFileCount: number; commandCount: number; readFileCount: number; searchCount: number; otherToolCount: number; - paseoCallCount: number; + paseoActions: Record; } export interface OverviewToolCallGroup { @@ -21,28 +22,31 @@ export interface OverviewToolCallGroup { isLoading: boolean; } -function isPaseoCall(name: string, normalizedName: string): boolean { - return isPaseoToolName(name) || normalizedName.startsWith(DIRECT_PASEO_TOOL_PREFIX); -} - function isSearchCall(name: string): boolean { return DIRECT_SEARCH_TOOL_SUFFIX_PATTERN.test(name); } /** Counts tool calls by what they did, for any summary of a stretch of agent work. */ -export function summarizeToolCalls(calls: readonly ToolCallItem[]): OverviewSummary { +export function summarizeToolCalls(calls: readonly GroupableItem[]): OverviewSummary { const editedFiles = new Set(); const readFiles = new Set(); + let thoughtCount = 0; let commandCount = 0; let searchCount = 0; let otherToolCount = 0; - let paseoCallCount = 0; + const paseoActions: Record = {}; for (const call of calls) { + if (call.kind === "thought") { + thoughtCount += 1; + continue; + } + if (isTurnTrigger(call)) continue; const descriptor = describeToolCall(call); const normalizedName = descriptor.name.trim().toLowerCase(); - if (isPaseoCall(descriptor.name, normalizedName)) { - paseoCallCount += 1; + const action = getPaseoToolActionLabel(descriptor.name); + if (action) { + paseoActions[action] = (paseoActions[action] ?? 0) + 1; } else if (descriptor.detail.type === "edit" || descriptor.detail.type === "write") { editedFiles.add(descriptor.detail.filePath); } else if (descriptor.detail.type === "shell") { @@ -57,17 +61,19 @@ export function summarizeToolCalls(calls: readonly ToolCallItem[]): OverviewSumm } return { + thoughtCount, editedFileCount: editedFiles.size, commandCount, readFileCount: readFiles.size, searchCount, otherToolCount, - paseoCallCount, + paseoActions, }; } export function buildOverviewGroup(run: ToolCallRun): OverviewToolCallGroup { const isLoading = run.calls.some((call) => { + if (call.kind === "thought") return call.status !== "ready"; const status = describeToolCall(call).status; return status === "running" || status === "executing"; }); diff --git a/packages/app/src/tool-calls/detail-level/projection.test.ts b/packages/app/src/tool-calls/detail-level/projection.test.ts index dabf87b73d..d6233854a2 100644 --- a/packages/app/src/tool-calls/detail-level/projection.test.ts +++ b/packages/app/src/tool-calls/detail-level/projection.test.ts @@ -255,12 +255,13 @@ describe("tool call detail-level projection", () => { run: expect.any(Object), isLoading: false, summary: { + thoughtCount: 0, editedFileCount: 1, commandCount: 1, readFileCount: 2, searchCount: 0, otherToolCount: 0, - paseoCallCount: 0, + paseoActions: {}, }, }); }); @@ -328,7 +329,7 @@ describe("tool call detail-level projection", () => { const result = project({ level: "overview", head: calls }); expect(result.groupsByHostId.get("1")).toMatchObject({ - summary: { otherToolCount: 2, paseoCallCount: 2 }, + summary: { otherToolCount: 2, paseoActions: { "Listed agents": 1, "List worktrees": 1 } }, }); }); @@ -346,7 +347,11 @@ describe("tool call detail-level projection", () => { const result = project({ level: "overview", head: calls }); expect(result.groupsByHostId.get("1")).toMatchObject({ - summary: { searchCount: 3, otherToolCount: 0, paseoCallCount: 3 }, + summary: { + searchCount: 3, + otherToolCount: 0, + paseoActions: { "Listed providers": 1, "List worktrees": 2 }, + }, }); }); @@ -480,3 +485,56 @@ describe("tool call detail-level projection", () => { expect(result.groupsByHostId.size).toBe(1); }); }); + +it("keeps a heartbeat trigger visible outside grouped work", () => { + const trigger = toolCall( + "1", + { type: "plain_text", text: "Check progress" }, + { name: "heartbeat" }, + ); + const work = toolCall("2", { type: "shell", command: "pwd" }); + const result = project({ level: "overview", tail: [trigger, work] }); + expect(result.tail.map((item) => item.id)).toEqual(["1", "2"]); + expect(result.groupsByHostId.has("1")).toBe(false); +}); + +it("does not merge tools from different canonical turns", () => { + const first = toolCall("1", { type: "shell", command: "pwd" }); + first.turnId = "first"; + const second = toolCall("2", { type: "shell", command: "ls" }); + second.turnId = "second"; + const result = project({ level: "overview", tail: [first, second] }); + expect(result.tail.map((item) => item.id)).toEqual(["1", "2"]); +}); + +it.each([false, true])( + "handles streaming reasoning across retained history (Expand thinking %s)", + (autoExpandReasoning) => { + const call = { ...toolCall("1", { type: "shell", command: "echo ok" }), turnId: "run" }; + const thought: StreamItem = { + kind: "thought", + id: "reasoning", + text: "Checking", + status: "loading", + timestamp: new Date(), + turnId: "run", + }; + const tail = [call]; + const result = project({ + level: "overview", + tail, + head: [thought], + isTurnActive: true, + preparedHistory: prepareToolCallHistory("overview", tail, autoExpandReasoning), + }); + expect(result.head).toEqual(autoExpandReasoning ? [thought] : []); + expect(result.groupsByHostId.get(call.id)?.run.calls).toEqual( + autoExpandReasoning ? [call] : [call, thought], + ); + expect(result.groupsByHostId.get(call.id)?.summary).toMatchObject({ + commandCount: 1, + otherToolCount: 0, + thoughtCount: autoExpandReasoning ? 0 : 1, + }); + }, +); diff --git a/packages/app/src/tool-calls/detail-level/projection.ts b/packages/app/src/tool-calls/detail-level/projection.ts index 9d57391b2e..3f10855ce5 100644 --- a/packages/app/src/tool-calls/detail-level/projection.ts +++ b/packages/app/src/tool-calls/detail-level/projection.ts @@ -13,6 +13,7 @@ export type ToolCallDetailGroup = OverviewToolCallGroup; export interface PreparedToolCallHistory { mode: "overview"; + autoExpandReasoning: boolean; grouped: GroupedHistory; } @@ -41,15 +42,18 @@ function visibleToolCallItems(items: StreamItem[]): StreamItem[] { export function prepareToolCallHistory( level: ToolCallDetailLevel, tail: StreamItem[], + autoExpandReasoning = false, ): PreparedToolCallHistory | null { if (level === "detailed") { return null; } return { mode: "overview", + autoExpandReasoning, grouped: prepareGroupedHistory({ tail: visibleToolCallItems(tail), buildGroup: buildOverviewGroup, + autoExpandReasoning, }), }; } @@ -74,6 +78,7 @@ export function projectToolCallDetailLevel(input: { } return groupLiveToolCalls({ history: input.preparedHistory.grouped, + autoExpandReasoning: input.preparedHistory.autoExpandReasoning, head: visibleToolCallItems(input.head), isTurnActive: input.isTurnActive, buildGroup: buildOverviewGroup, diff --git a/packages/app/src/types/stream.test.ts b/packages/app/src/types/stream.test.ts index 62e26698db..376d04ea67 100644 --- a/packages/app/src/types/stream.test.ts +++ b/packages/app/src/types/stream.test.ts @@ -661,6 +661,82 @@ describe("stream reducer canonical tool calls", () => { expect(new Set(tools.map((tool) => tool.id)).size).toBe(2); }); + it.each(["live", "canonical"] as const)( + "keeps current tool identity during %s updates when an older turn reports late", + (source) => { + let state: StreamItem[] = []; + const events = [ + { callId: "background", turnId: "codex-turn-17", status: "running" as const }, + { callId: "exec-current", turnId: "codex-turn-18", status: "running" as const }, + { callId: "background", turnId: "codex-turn-17", status: "completed" as const }, + { callId: "exec-current", turnId: "codex-turn-18", status: "completed" as const }, + ]; + for (const [index, item] of events.entries()) { + state = reduceStreamUpdate( + state, + canonicalToolTimeline({ + provider: "codex", + name: "exec_command", + ...item, + }), + new Date(index * 1000), + { source }, + ); + } + expect( + state.filter(isAgentToolCallItem).map((item) => ({ + id: item.id, + turnId: item.turnId, + status: item.payload.data.status, + })), + ).toEqual([ + { + id: "agent_tool_turn:codex-turn-17/background", + turnId: "codex-turn-17", + status: "completed", + }, + { + id: "agent_tool_turn:codex-turn-18/exec-current", + turnId: "codex-turn-18", + status: "completed", + }, + ]); + }, + ); + + it("does not move reasoning into an older turn when a late tool update finalizes it", () => { + const background = canonicalToolTimeline({ + provider: "codex", + name: "exec_command", + callId: "background", + turnId: "old", + status: "running", + }); + let state = reduceStreamUpdate([], background, new Date(0)); + state = reduceStreamUpdate( + state, + { + type: "timeline", + provider: "codex", + turnId: "current", + item: { type: "reasoning", text: "Working on the current request" }, + }, + new Date(1), + ); + state = reduceStreamUpdate( + state, + canonicalToolTimeline({ + provider: "codex", + name: "exec_command", + callId: "background", + turnId: "old", + status: "completed", + }), + new Date(2), + ); + expect(state.at(-1)).toMatchObject({ kind: "thought", turnId: "current", status: "ready" }); + }); + it("merges lifecycle updates for the same call occurrence", () => { const callId = "tool-updated-within-turn"; const turnId = "autonomous-turn-1"; diff --git a/packages/app/src/types/stream.ts b/packages/app/src/types/stream.ts index 657d218b35..0226993a8c 100644 --- a/packages/app/src/types/stream.ts +++ b/packages/app/src/types/stream.ts @@ -91,6 +91,8 @@ export type StreamItem = export type UserMessageImageAttachment = AttachmentMetadata; export interface UserMessageItem { + /** Attribution added by presentation for received agent messages. */ + sender?: AgentMessage["sender"]; kind: "user_message"; id: string; clientMessageId?: string; @@ -986,6 +988,7 @@ function appendThought( text: string, timestamp: Date, timelineCursor?: TimelinePosition, + turnId?: string, ): StreamItem[] { const { chunk, hasContent } = normalizeChunk(text); if (!chunk) { @@ -993,7 +996,7 @@ function appendThought( } const last = state[state.length - 1]; - if (last && last.kind === "thought") { + if (last && last.kind === "thought" && last.turnId === turnId) { const updated: ThoughtItem = { ...last, ...(timelineCursor ? { timelineCursor } : {}), @@ -1511,36 +1514,30 @@ function reduceTimelineEvent( const item = event.item; switch (item.type) { case "user_message": - return finalizeActiveThoughts( - appendUserMessage( - state, - item.text, - timestamp, - source, - item.messageId, - item.clientMessageId, - timelineCursor, - event.turnId, - ), + return appendUserMessage( + state, + item.text, + timestamp, + source, + item.messageId, + item.clientMessageId, + timelineCursor, + event.turnId, ); case "assistant_message": - return finalizeActiveThoughts( - appendAssistantMessage( - state, - item.text, - timestamp, - source, - item.messageId, - reservedItemIds, - timelineCursor, - ), + return appendAssistantMessage( + state, + item.text, + timestamp, + source, + item.messageId, + reservedItemIds, + timelineCursor, ); case "reasoning": - return appendThought(state, item.text, timestamp, timelineCursor); + return appendThought(state, item.text, timestamp, timelineCursor, event.turnId); case "tool_call": - return finalizeActiveThoughts( - reduceTimelineToolCall(state, event, item, timestamp, timelineCursor), - ); + return reduceTimelineToolCall(state, event, item, timestamp, timelineCursor); case "todo": { const items: TodoEntry[] = (item.items ?? []).map((todo) => ({ text: todo.text, @@ -1549,9 +1546,7 @@ function reduceTimelineEvent( status: todo.status, activeForm: todo.activeForm, })); - return finalizeActiveThoughts( - appendTodoList(state, event.provider, items, timestamp, timelineCursor), - ); + return appendTodoList(state, event.provider, items, timestamp, timelineCursor); } case "error": { const notification: NotificationItem = { @@ -1563,7 +1558,7 @@ function reduceTimelineEvent( level: "error", message: item.message ?? "Unknown error", }; - return finalizeActiveThoughts(appendNotification(state, notification)); + return appendNotification(state, notification); } case "notification": { const notification: NotificationItem = { @@ -1575,16 +1570,12 @@ function reduceTimelineEvent( level: item.level, message: item.message, }; - return finalizeActiveThoughts(appendNotification(state, notification)); + return appendNotification(state, notification); } case "compaction": - return finalizeActiveThoughts( - reduceTimelineCompaction(state, item, timestamp, timelineCursor), - ); + return reduceTimelineCompaction(state, item, timestamp, timelineCursor); case "plugin": - return finalizeActiveThoughts( - appendPluginTimelineItem(state, item, timestamp, timelineCursor), - ); + return appendPluginTimelineItem(state, item, timestamp, timelineCursor); default: return state; } @@ -1601,8 +1592,9 @@ export function reduceStreamUpdate( ): StreamItem[] { const source = options?.source ?? "live"; switch (event.type) { - case "timeline": - return applyTimelineTurnId( + case "timeline": { + const updated = applyTimelineTurnId( + state, reduceTimelineEvent( state, event, @@ -1613,6 +1605,8 @@ export function reduceStreamUpdate( ), event, ); + return event.item.type === "reasoning" ? updated : finalizeActiveThoughts(updated); + } case "thread_started": case "turn_started": case "turn_completed": @@ -1628,6 +1622,7 @@ export function reduceStreamUpdate( } function applyTimelineTurnId( + previous: StreamItem[], items: StreamItem[], event: Extract, ): StreamItem[] { @@ -1637,15 +1632,14 @@ function applyTimelineTurnId( return reconcileCanonicalUserTurnMembership(items, clientMessageId, event.turnId); } - if (!event.turnId || items.length === 0) return items; - const index = items.length - 1; - const last = items[index]; - if (!last || last.turnId === event.turnId) return items; - return [ - ...items.slice(0, index), - { ...last, turnId: event.turnId } as StreamItem, - ...items.slice(index + 1), - ]; + if (!event.turnId || items === previous) return items; + // Assign membership only to rows written by this event, before finalizing + // unrelated reasoning rows. Late lifecycle updates can replace an earlier row. + return items.map((item, index) => + item === previous[index] || item.turnId === event.turnId + ? item + : ({ ...item, turnId: event.turnId } as StreamItem), + ); } function reconcileCanonicalUserTurnMembership( diff --git a/packages/app/src/utils/test-daemon-connection.test.ts b/packages/app/src/utils/test-daemon-connection.test.ts index 4dc82dda29..f528065437 100644 --- a/packages/app/src/utils/test-daemon-connection.test.ts +++ b/packages/app/src/utils/test-daemon-connection.test.ts @@ -129,6 +129,23 @@ describe("test-daemon-connection connectToDaemon", () => { expect(probe.createdConfigs()[0]?.transportFactory).toBeUndefined(); }); + it("keeps the bundle cache factory on a probe client that the host runtime can adopt", async () => { + const { connectToDaemon } = await import("./test-daemon-connection"); + const createPluginClientBundleStore = () => ({ + async load() { + return []; + }, + async replace() {}, + }); + const { client } = await connectToDaemon( + { id: "direct:lan:6767", type: "directTcp", endpoint: "lan:6767" }, + { createPluginClientBundleStore }, + probe.deps, + ); + expect(client.config.createPluginClientBundleStore).toBe(createPluginClientBundleStore); + await client.close(); + }); + it("encodes the local socket target into the client config", async () => { const { connectToDaemon } = await import("./test-daemon-connection"); const result = await connectToDaemon( diff --git a/packages/app/src/utils/test-daemon-connection.ts b/packages/app/src/utils/test-daemon-connection.ts index 18ffa62dc4..d292c67001 100644 --- a/packages/app/src/utils/test-daemon-connection.ts +++ b/packages/app/src/utils/test-daemon-connection.ts @@ -132,12 +132,13 @@ function resolveConnectionCredentials( export async function buildClientConfig( connection: HostConnection, serverId?: string, - options?: { + options: { capabilities?: DaemonClientConfig["capabilities"]; trace?: DaemonClientConfig["trace"]; + createPluginClientBundleStore?: DaemonClientConfig["createPluginClientBundleStore"]; password?: string; localCredential?: DaemonClientConfig["localCredential"]; - }, + } = {}, deps: Pick< DaemonConnectionDependencies, | "getClientId" @@ -157,8 +158,9 @@ export async function buildClientConfig( suppressSendErrors: true, reconnect: { enabled: false }, ...resolveConnectionCredentials(connection, options), - ...(options?.capabilities ? { capabilities: options.capabilities } : {}), - ...(options?.trace ? { trace: options.trace } : {}), + ...(options.capabilities ? { capabilities: options.capabilities } : {}), + ...(options.trace ? { trace: options.trace } : {}), + createPluginClientBundleStore: options.createPluginClientBundleStore, ...((connection.type === "directSocket" || connection.type === "directPipe") && desktopTransportFactory ? { transportFactory: desktopTransportFactory } @@ -288,6 +290,7 @@ interface ProbeOptions { localCredential?: DaemonClientConfig["localCredential"]; capabilities?: DaemonClientConfig["capabilities"]; trace?: DaemonClientConfig["trace"]; + createPluginClientBundleStore?: DaemonClientConfig["createPluginClientBundleStore"]; } function resolveTimeout(connection: HostConnection, options?: ProbeOptions): number { diff --git a/packages/app/src/workspace/open-in-file-manager/outside-folder.ts b/packages/app/src/workspace/open-in-file-manager/outside-folder.ts new file mode 100644 index 0000000000..e7d41d72d9 --- /dev/null +++ b/packages/app/src/workspace/open-in-file-manager/outside-folder.ts @@ -0,0 +1,36 @@ +import { useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import { useToast } from "@/contexts/toast-context"; +import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon"; +import { openDesktopTarget, useDesktopOpenTargets } from "@/workspace/desktop-open-targets"; + +/** + * Opens a folder outside the workspace in the system file manager. The Files + * panel only lists the workspace, so hosts without a local file manager get a + * toast instead. + */ +export function useOpenOutsideWorkspaceFolder(serverId: string): (folderPath: string) => void { + const { t } = useTranslation(); + const toast = useToast(); + const isLocalExecution = useIsLocalDaemon(serverId); + const { targets } = useDesktopOpenTargets({ isLocalExecution }); + const fileManagerTarget = targets.find((target) => target.kind === "file-manager"); + + return useCallback( + (folderPath: string) => { + const showOutsideWorkspace = () => + toast.show(t("common.errors.folderOutsideWorkspace", { path: folderPath })); + if (!fileManagerTarget) { + showOutsideWorkspace(); + return; + } + void openDesktopTarget({ editorId: fileManagerTarget.id, workspacePath: folderPath }).catch( + (error: unknown) => { + console.warn("[open-outside-workspace-folder] open failed", error); + showOutsideWorkspace(); + }, + ); + }, + [fileManagerTarget, t, toast], + ); +} diff --git a/packages/app/vitest.config.ts b/packages/app/vitest.config.ts index 3254fc5e0c..ae7f935823 100644 --- a/packages/app/vitest.config.ts +++ b/packages/app/vitest.config.ts @@ -96,7 +96,10 @@ export default defineConfig({ include: ["src/**/*.browser.{test,spec}.{ts,tsx}"], browser: { enabled: true, - provider: playwright(), + provider: playwright({ + // Exercise real WebGL glyph rendering on GPU-less CI runners. + launchOptions: { args: ["--enable-unsafe-swiftshader"] }, + }), headless: true, connectTimeout: 180_000, instances: [{ browser: "chromium" }], diff --git a/packages/cli/package.json b/packages/cli/package.json index be8188fff3..e5e7503c39 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/cli", - "version": "0.13.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.13.2", - "@getpaseo/protocol": "0.13.2", - "@getpaseo/server": "0.13.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", diff --git a/packages/cli/tests/42-test-daemon-ephemeral-port.test.ts b/packages/cli/tests/42-test-daemon-ephemeral-port.test.ts new file mode 100644 index 0000000000..f559c275bc --- /dev/null +++ b/packages/cli/tests/42-test-daemon-ephemeral-port.test.ts @@ -0,0 +1,16 @@ +#!/usr/bin/env npx tsx + +import assert from "node:assert/strict"; +import { startTestDaemon, runPaseoCli } from "./helpers/test-daemon.ts"; + +// Port zero leaves allocation to the listening daemon, with no reserve/release gap. +const daemon = await startTestDaemon({ port: 0 }); +try { + assert(daemon.port > 0, "the helper returns the port the daemon actually bound"); + assert.equal(daemon.wsUrl, `ws://127.0.0.1:${daemon.port}`); + const result = await runPaseoCli(daemon, ["agent", "ls", "--host", daemon.wsUrl, "--json"]); + assert.equal(result.exitCode, 0, result.stderr); + assert.deepEqual(JSON.parse(result.stdout), []); +} finally { + await daemon.stop(); +} diff --git a/packages/cli/tests/helpers/test-daemon.ts b/packages/cli/tests/helpers/test-daemon.ts index 5b5513d1a7..cfc9a87fb1 100644 --- a/packages/cli/tests/helpers/test-daemon.ts +++ b/packages/cli/tests/helpers/test-daemon.ts @@ -18,7 +18,7 @@ import { tmpdir } from "os"; import { join } from "path"; import { fileURLToPath } from "url"; import { ChildProcess, spawn } from "child_process"; -import { getAvailablePort } from "./network.ts"; +import { readDaemonInstance } from "@getpaseo/server/daemon-control"; export interface TestDaemonContext { /** Available local port for test daemon (never 6767) */ @@ -195,17 +195,20 @@ async function probeDaemonReady( } async function waitForDaemonReady( - port: number, paseoHome: string, timeout = 30000, env?: NodeJS.ProcessEnv, -): Promise { +): Promise { const deadline = Date.now() + timeout; - async function poll(): Promise { - if (await probeDaemonReady(port, paseoHome, env)) return; + async function poll(): Promise { + const instance = await readDaemonInstance(paseoHome); + if (instance?.listen) { + const port = Number(new URL(`http://${instance.listen}`).port); + if (await probeDaemonReady(port, paseoHome, env)) return port; + } if (Date.now() >= deadline) { - throw new Error(`Daemon failed to become ready on port ${port} within ${timeout}ms`); + throw new Error(`Daemon failed to publish a ready endpoint within ${timeout}ms`); } await sleep(100); return poll(); @@ -231,15 +234,14 @@ export async function startTestDaemon(options?: { timeout?: number; env?: NodeJS.ProcessEnv; }): Promise { - const port = options?.port ?? (await getAvailablePort()); + // Let the listening process allocate its port, without a reserve/release race. + const requestedPort = options?.port ?? 0; const { paseoHome, workDir } = options?.paseoHome && options?.workDir ? { paseoHome: options.paseoHome, workDir: options.workDir } : await createTempDirs(); const timeout = options?.timeout ?? 30000; - const wsUrl = `ws://${TEST_DAEMON_HOST}:${port}`; - // Find the CLI entry point - use the source file directly with tsx const cliDir = join(import.meta.dirname, "..", ".."); const cliSrcPath = join(cliDir, "src", "index.ts"); @@ -252,7 +254,7 @@ export async function startTestDaemon(options?: { ), ...TEST_DAEMON_ENV_DEFAULTS, PASEO_HOME: paseoHome, - PASEO_LISTEN: `${TEST_DAEMON_HOST}:${port}`, + PASEO_LISTEN: `${TEST_DAEMON_HOST}:${requestedPort}`, // Force no TTY to prevent QR code output CI: "true", ...options?.env, @@ -312,20 +314,18 @@ export async function startTestDaemon(options?: { } }); - const ctx: TestDaemonContext = { - port, - wsUrl, - paseoHome, - workDir, - process: daemonProcess, - isReady: false, - stop: cleanup, - }; - // Wait for daemon to be ready try { - await waitForDaemonReady(port, paseoHome, timeout, options?.env); - ctx.isReady = true; + const port = await waitForDaemonReady(paseoHome, timeout, options?.env); + return { + port, + wsUrl: `ws://${TEST_DAEMON_HOST}:${port}`, + paseoHome, + workDir, + process: daemonProcess, + isReady: true, + stop: cleanup, + }; } catch (err) { // Daemon failed to start - clean up and rethrow await cleanup(); @@ -335,8 +335,6 @@ export async function startTestDaemon(options?: { { cause: err }, ); } - - return ctx; } /** diff --git a/packages/client/package.json b/packages/client/package.json index df80ba532c..08cd272057 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/client", - "version": "0.13.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.13.2", - "@getpaseo/relay": "0.13.2", + "@getpaseo/protocol": "0.14.0-beta.1", + "@getpaseo/relay": "0.14.0-beta.1", "ws": "^8.20.0", "zod": "^4.4.3" }, diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index f663aeb8be..112113caf6 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -1,5 +1,5 @@ import { readFileSync } from "node:fs"; -import { afterEach, expect, expectTypeOf, test, vi } from "vitest"; +import { afterEach, describe, expect, expectTypeOf, test, vi } from "vitest"; import { z } from "zod"; import { DaemonClient, @@ -9,6 +9,8 @@ import { type CreateAgentRequestOptions, type DaemonTransport, type Logger, + type PluginClientBundle, + type PluginClientBundleStore, } from "./daemon-client"; import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities"; import { BROWSER_AUTOMATION_COMMAND_NAMES } from "@getpaseo/protocol/browser-automation/rpc-schemas"; @@ -1640,6 +1642,77 @@ test("preserves legacy fetchAgent id overload", async () => { await expect(responsePromise).rejects.toThrow("legacy fetch sentinel"); }); +test.each([undefined, false, true])( + "loads history with whole-turn support advertised as %s", + async (wholeTurnTimeline) => { + const transport = createMockTransport(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "history-compatibility", + reconnect: { enabled: false }, + transportFactory: () => transport.transport, + }); + clients.push(client); + const connected = client.connect(); + transport.triggerOpen({ + features: wholeTurnTimeline === undefined ? {} : { wholeTurnTimeline }, + }); + await connected; + + const requests = [ + { direction: "tail" as const }, + { direction: "before" as const, cursor: { epoch: "epoch-1", seq: 50 } }, + { direction: "after" as const, cursor: { epoch: "epoch-1", seq: 50 } }, + { direction: "before" as const, cursor: { epoch: "epoch-1", seq: 80 }, mergeWindow: true }, + ]; + for (const [index, request] of requests.entries()) { + const requestId = `history-${index}`; + const result = client.fetchAgentTimeline("agent-1", { + ...request, + requestId, + wholeTurns: true, + limit: 40, + projection: "projected", + }); + // Attach immediately so the old rejection is captured by the regression. + const completed = expect(result).resolves.toMatchObject({ requestId, entries: [] }); + expect(parseSentFrame(transport.sent.at(-1))).toEqual({ + type: "fetch_agent_timeline_request", + agentId: "agent-1", + requestId, + ...request, + limit: 40, + projection: "projected", + ...(wholeTurnTimeline === true ? { wholeTurns: true } : {}), + }); + transport.triggerMessage( + wrapSessionMessage({ + type: "fetch_agent_timeline_response", + payload: { + requestId, + agentId: "agent-1", + agent: null, + direction: request.direction, + projection: "projected", + epoch: "epoch-1", + reset: false, + staleCursor: false, + gap: false, + window: { minSeq: 0, maxSeq: 0, nextSeq: 1 }, + startCursor: null, + endCursor: null, + hasOlder: false, + hasNewer: false, + entries: [], + error: null, + }, + }), + ); + await completed; + } + }, +); + test("honors explicit fetchAgentTimeline timeout below the session RPC default", async () => { useHeartbeatClock(); const logger = createMockLogger(); @@ -2028,6 +2101,188 @@ test("gets a structured plugin log snapshot", async () => { ]); }); +describe("plugin catalog", () => { + async function connectedClient(createStore?: (serverId: string) => PluginClientBundleStore) { + const mock = createMockTransport(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + logger: createMockLogger(), + createPluginClientBundleStore: createStore, + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + const connectPromise = client.connect(); + mock.triggerOpen({ features: { plugins: true } }); + await connectPromise; + return { client, mock }; + } + + async function fetchCatalog( + harness: Awaited>, + respond: (request: Record) => Array>, + ) { + const before = harness.mock.sent.length; + const response = harness.client.getPluginCatalog(); + await vi.waitFor(() => expect(harness.mock.sent.length).toBeGreaterThan(before)); + const request = parseSentFrame(harness.mock.sent.at(-1)); + harness.mock.triggerMessage( + wrapSessionMessage({ + type: "plugin.catalog.get.response", + payload: { requestId: request.requestId, plugins: respond(request) }, + }), + ); + return { request, plugins: await response }; + } + + test("reuses retained client bundles the daemon leaves out", async () => { + const harness = await connectedClient(); + const first = await fetchCatalog(harness, () => [ + { id: "kept", clientBundle: "kept bundle", clientBundleHash: "hash-kept" }, + { id: "changed", clientBundle: "old bundle", clientBundleHash: "hash-old" }, + ]); + expect(first.request).not.toHaveProperty("cachedClientBundles"); + + const second = await fetchCatalog(harness, () => [ + { id: "kept", clientBundleHash: "hash-kept" }, + { id: "changed", clientBundle: "new bundle", clientBundleHash: "hash-new" }, + ]); + + expect(second.request.cachedClientBundles).toEqual([ + { id: "kept", clientBundleHash: "hash-kept" }, + { id: "changed", clientBundleHash: "hash-old" }, + ]); + expect(second.plugins).toEqual([ + { id: "kept", clientBundle: "kept bundle", clientBundleHash: "hash-kept" }, + { id: "changed", clientBundle: "new bundle", clientBundleHash: "hash-new" }, + ]); + const third = await fetchCatalog(harness, () => []); + expect(third.request.cachedClientBundles).toEqual([ + { id: "kept", clientBundleHash: "hash-kept" }, + { id: "changed", clientBundleHash: "hash-new" }, + ]); + const fourth = await fetchCatalog(harness, () => []); + expect(fourth.request).not.toHaveProperty("cachedClientBundles"); + }); + + test("keeps fetching every bundle from a daemon that sends no bundle hashes", async () => { + const harness = await connectedClient(); + const legacyCatalog = () => [{ id: "legacy", clientBundle: "legacy bundle" }]; + await fetchCatalog(harness, legacyCatalog); + + const second = await fetchCatalog(harness, legacyCatalog); + + expect(second.request).not.toHaveProperty("cachedClientBundles"); + expect(second.plugins).toEqual([{ id: "legacy", clientBundle: "legacy bundle" }]); + }); + + test("a fresh client advertises persisted bundles on its first fetch and expands omitted code", async () => { + let persisted: PluginClientBundle[] = []; + let loads = 0; + const store: PluginClientBundleStore = { + async load() { + loads++; + return persisted; + }, + async replace(bundles) { + persisted = bundles; + }, + }; + const createdFor: string[] = []; + const createStore = (serverId: string) => { + createdFor.push(serverId); + return store; + }; + const bundle = { id: "kept", clientBundleHash: "hash-kept", clientBundle: "stored code" }; + const warm = await connectedClient(createStore); + await fetchCatalog(warm, () => [bundle]); + const cold = await connectedClient(createStore); + const first = await fetchCatalog(cold, () => [{ id: "kept", clientBundleHash: "hash-kept" }]); + expect(first.request.cachedClientBundles).toEqual([ + { id: "kept", clientBundleHash: "hash-kept" }, + ]); + expect(first.plugins).toEqual([bundle]); + expect(loads).toBe(2); + await fetchCatalog(cold, () => []); + expect(loads).toBe(2); + expect(createdFor).toEqual([ + warm.client.getLastServerInfoMessage()!.serverId, + cold.client.getLastServerInfoMessage()!.serverId, + ]); + expect(persisted).toEqual([]); + }); + + test.each(["missing", "unreadable"])( + "a %s persistent cache requests full bundles", + async (state) => { + const store: PluginClientBundleStore = { + async load() { + if (state === "unreadable") throw new Error("Storage unavailable"); + return []; + }, + async replace() {}, + }; + const bundle = { id: "full", clientBundleHash: "hash-full", clientBundle: "full code" }; + const result = await fetchCatalog(await connectedClient(() => store), () => [bundle]); + expect(result.request).not.toHaveProperty("cachedClientBundles"); + expect(result.plugins).toEqual([bundle]); + }, + ); + + test("failed cache writes never fail plugin loading", async () => { + const store: PluginClientBundleStore = { + async load() { + return []; + }, + async replace() { + throw new Error("Disk full"); + }, + }; + const bundle = { id: "full", clientBundleHash: "hash-full", clientBundle: "full code" }; + expect( + (await fetchCatalog(await connectedClient(() => store), () => [bundle])).plugins, + ).toEqual([bundle]); + }); + + test.each([false, true])( + "usable catalogs do not wait for storage; latest write survives a failed first write: %s", + async (failFirstWrite) => { + const writes: PluginClientBundle[][] = []; + let finishWrite = () => {}; + const blockedWrite = new Promise((resolve) => { + finishWrite = resolve; + }); + const store: PluginClientBundleStore = { + async load() { + return []; + }, + async replace(bundles) { + writes.push(bundles); + if (writes.length === 1) { + await blockedWrite; + if (failFirstWrite) throw new Error("Disk full"); + } + }, + }; + const harness = await connectedClient(() => store); + const first = { id: "plugin", clientBundle: "old", clientBundleHash: "hash-old" }; + const changed = { id: "plugin", clientBundle: "new", clientBundleHash: "hash-new" }; + try { + expect((await fetchCatalog(harness, () => [first])).plugins).toEqual([first]); + await vi.waitFor(() => expect(writes).toEqual([[first]])); + expect((await fetchCatalog(harness, () => [changed])).plugins).toEqual([changed]); + expect((await fetchCatalog(harness, () => [])).plugins).toEqual([]); + expect(writes).toEqual([[first]]); + finishWrite(); + await vi.waitFor(() => expect(writes).toEqual([[first], []])); + } finally { + finishWrite(); + } + }, + ); +}); + test("keeps a plugin reload pending past the session RPC deadline", async () => { useHeartbeatClock(); try { diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 59d928283f..bca3d00301 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -396,6 +396,17 @@ export type DaemonEventHandler = (event: DaemonEvent) => void; export type BrowserAutomationExecuteRequestMessage = BrowserAutomationExecuteRequest; export type BrowserAutomationExecuteResponseMessage = BrowserAutomationExecuteResponse; +export interface PluginClientBundle { + id: string; + clientBundleHash: string; + clientBundle: string; +} + +/** Host-scoped storage. load must return only bundles whose contents have been verified. */ +export interface PluginClientBundleStore { + load(): Promise; + replace(bundles: PluginClientBundle[]): Promise; +} /** Endpoint security negotiated on a relay connection. See docs/relay-endpoint-security.md. */ export type RelaySecurityMode = "legacy" | "protected"; @@ -406,6 +417,8 @@ export interface DaemonClientConfig { sessionPermissions?: import("@getpaseo/protocol/messages").DaemonPermission[]; url: string; clientId: string; + /** Create one cache for the confirmed daemon identity, retained for this client lifetime. */ + createPluginClientBundleStore?: (serverId: string) => PluginClientBundleStore; clientType?: "mobile" | "browser" | "cli" | "mcp" | "hub"; appVersion?: string; runtimeGeneration?: number | null; @@ -686,6 +699,8 @@ export interface FetchAgentOptions { } type LegacyFetchAgentOptions = Omit; export interface FetchAgentTimelineOptions { + /** Prefer whole-turn pages when supported; older hosts retain item-based paging. */ + wholeTurns?: boolean; direction?: FetchAgentTimelineDirection; cursor?: FetchAgentTimelineCursor; limit?: number; @@ -1014,6 +1029,12 @@ type CorrelatedResponsePayloads = { type CorrelatedResponsePayload = CorrelatedResponsePayloads[TType]; +type PluginCatalogResponseEntry = + CorrelatedResponsePayload<"plugin.catalog.get.response">["plugins"][number]; +export type PluginCatalogEntry = Omit & { + clientBundle: string; +}; + export class DaemonConnectionError extends Error { readonly serverId?: string; readonly outcome?: "unknown"; @@ -1341,6 +1362,15 @@ export class DaemonClient { private pendingBinaryFileReads = new Map(); private activeBinaryFileTransfers = new Map(); private completedBinaryFileReads = new Map(); + /** The last catalog's client bundles by plugin id, so later catalogs can leave them out. */ + private pluginClientBundles = new Map< + string, + { clientBundleHash: string; clientBundle: string } + >(); + private pluginClientBundleStore: PluginClientBundleStore | undefined; + private pluginClientBundlesLoaded: Promise | undefined; + private pluginClientBundlesWrite: PluginClientBundle[] | undefined; + private pluginClientBundlesWriting = false; private logger: Logger; private pendingSendQueue: PendingSend[] = []; /** @@ -3548,9 +3578,15 @@ export class DaemonClient { agentId: string, options: FetchAgentTimelineOptions = {}, ): Promise { + // COMPAT(wholeTurnTimeline): added after v0.13.2 (#8010); remove after 2027-04-27 once the daemon floor supports whole-turn pages. + // Reading history predates this capability and must keep working on older hosts. + const supportsWholeTurns = this.lastServerInfoMessage?.features?.wholeTurnTimeline === true; const resolvedRequestId = this.createRequestId(options.requestId); const message = SessionInboundMessageSchema.parse({ type: "fetch_agent_timeline_request", + ...(supportsWholeTurns && options.wholeTurns !== undefined + ? { wholeTurns: options.wholeTurns } + : {}), agentId, requestId: resolvedRequestId, ...(options.direction ? { direction: options.direction } : {}), @@ -5785,14 +5821,82 @@ export class DaemonClient { }); } - async getPluginCatalog() { + async getPluginCatalog(): Promise { + if (this.config.createPluginClientBundleStore && !this.pluginClientBundleStore) { + const serverInfo = this.getLastServerInfoMessage(); + if (!serverInfo) throw new Error("Plugin catalog requires a connected host"); + this.pluginClientBundleStore = this.config.createPluginClientBundleStore(serverInfo.serverId); + } + const store = this.pluginClientBundleStore; + if (store) { + this.pluginClientBundlesLoaded ??= store.load().then( + (bundles) => { + for (const { id, ...bundle } of bundles) this.pluginClientBundles.set(id, bundle); + return undefined; + }, + (error) => this.logger.warn({ err: error }, "Failed to load plugin client bundles"), + ); + await this.pluginClientBundlesLoaded; + } const requestId = this.createRequestId(); + const cached = this.pluginClientBundles; + const cachedClientBundles = [...cached].map(([id, { clientBundleHash }]) => ({ + id, + clientBundleHash, + })); const payload = await this.sendCorrelatedSessionRequest({ requestId, - message: { type: "plugin.catalog.get.request", requestId }, + message: { + type: "plugin.catalog.get.request", + requestId, + ...(cachedClientBundles.length > 0 ? { cachedClientBundles } : {}), + }, responseType: "plugin.catalog.get.response", }); - return payload.plugins; + const retained = new Map(); + const plugins = payload.plugins.map(({ clientBundle: sent, ...plugin }): PluginCatalogEntry => { + const previous = cached.get(plugin.id); + const clientBundle = + sent ?? + (previous?.clientBundleHash === plugin.clientBundleHash + ? previous?.clientBundle + : undefined); + if (clientBundle === undefined) { + throw new Error(`Plugin catalog left out the client bundle for ${plugin.id}`); + } + // COMPAT(pluginCatalogBundleHash): introduced by PR #6732 (unreleased); remove after + // 2027-04-27 once the daemon floor includes bundle hashes. Older bundles are not retained. + if (plugin.clientBundleHash !== undefined) { + retained.set(plugin.id, { clientBundleHash: plugin.clientBundleHash, clientBundle }); + } + return Object.assign(plugin, { clientBundle }); + }); + this.pluginClientBundles = retained; + if (store) { + this.pluginClientBundlesWrite = [...retained].map(([id, bundle]) => + Object.assign({ id }, bundle), + ); + void this.persistPluginClientBundles(store); + } + return plugins; + } + + private async persistPluginClientBundles(store: PluginClientBundleStore): Promise { + if (this.pluginClientBundlesWriting) return; + this.pluginClientBundlesWriting = true; + try { + while (this.pluginClientBundlesWrite) { + const bundles = this.pluginClientBundlesWrite; + this.pluginClientBundlesWrite = undefined; + try { + await store.replace(bundles); + } catch (error) { + this.logger.warn({ err: error }, "Failed to persist plugin client bundles"); + } + } + } finally { + this.pluginClientBundlesWriting = false; + } } async listPlugins(): Promise { diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 4735fb5192..ff5f33401a 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/desktop", - "version": "0.13.2", + "version": "0.14.0-beta.1", "private": true, "description": "Paseo desktop app (Electron wrapper)", "homepage": "https://paseo.sh", diff --git a/packages/desktop/src/features/editor-targets/registry.test.ts b/packages/desktop/src/features/editor-targets/registry.test.ts index 4d8c4d80c7..0a6ae12328 100644 --- a/packages/desktop/src/features/editor-targets/registry.test.ts +++ b/packages/desktop/src/features/editor-targets/registry.test.ts @@ -86,6 +86,10 @@ class FakeEditorTargets implements EditorTargetRuntime { return this.paths.has(targetPath); } + expandHomePath(targetPath: string): string { + return targetPath.startsWith("~/") ? `/home/me/${targetPath.slice(2)}` : targetPath; + } + isAbsolutePath(targetPath: string): boolean { return targetPath.startsWith("/") || /^[A-Z]:\//u.test(targetPath); } @@ -513,6 +517,19 @@ describe("editor target registry", () => { expect(runtime.revealedPaths).toEqual(["C:/repo/src/app.ts"]); }); + it("opens a home-relative folder in the file manager", async () => { + const runtime = new FakeEditorTargets(); + runtime.addPath("/home/me/Downloads/contracts"); + + await openEditorTarget( + { editorId: "file-manager", workspacePath: "~/Downloads/contracts" }, + runtime, + [fileManagerTarget], + ); + + expect(runtime.openedPaths).toEqual(["/home/me/Downloads/contracts"]); + }); + it("keeps the platform file-manager ids used by stored preferences", async () => { const macTargets = await listAvailableEditorTargets(new FakeEditorTargets("darwin"), [ finderTarget, diff --git a/packages/desktop/src/features/editor-targets/registry.ts b/packages/desktop/src/features/editor-targets/registry.ts index 1e589f7a64..99a8e8d461 100644 --- a/packages/desktop/src/features/editor-targets/registry.ts +++ b/packages/desktop/src/features/editor-targets/registry.ts @@ -97,10 +97,16 @@ export function getEditorTarget( } export async function openEditorTarget( - input: EditorTargetLaunchInput & { editorId: string }, + request: EditorTargetLaunchInput & { editorId: string }, runtime: EditorTargetRuntime, targets: readonly EditorTarget[] = EDITOR_TARGETS, ): Promise { + // Assistant replies name folders like `~/Downloads/x`; expand them before the absolute-path guard. + const input = { + ...request, + workspacePath: runtime.expandHomePath(request.workspacePath), + filePath: request.filePath === undefined ? undefined : runtime.expandHomePath(request.filePath), + }; if (!runtime.isAbsolutePath(input.workspacePath)) { throw new Error("Editor target workspace path must be an absolute local path"); } diff --git a/packages/desktop/src/features/editor-targets/runtime.ts b/packages/desktop/src/features/editor-targets/runtime.ts index 9562b27977..36bfac0e65 100644 --- a/packages/desktop/src/features/editor-targets/runtime.ts +++ b/packages/desktop/src/features/editor-targets/runtime.ts @@ -61,6 +61,14 @@ function isAbsolutePath(value: string, platform: NodeJS.Platform): boolean { return platform === "win32" ? win32.isAbsolute(value) : posix.isAbsolute(value); } +function expandHomePath(value: string, homeDirectory: string): string { + if (value === "~") return homeDirectory; + if (value.startsWith("~/") || value.startsWith("~\\")) { + return path.join(homeDirectory, value.slice(2)); + } + return value; +} + function resolveExecutable( commands: readonly string[], input: { @@ -166,6 +174,7 @@ export function createEditorTargetRuntime( pathExists, isAbsolutePath: (targetPath) => isAbsolutePath(targetPath, platform), listDirectory, + expandHomePath: (targetPath) => expandHomePath(targetPath, homeDirectory), resolveCommand: (commands) => resolveExecutable(commands, { env, pathExists, platform }), async spawnDetached({ command, args }) { const commandScript = isWindowsCommandScript(command, platform); diff --git a/packages/desktop/src/features/editor-targets/target.ts b/packages/desktop/src/features/editor-targets/target.ts index f7111662c5..b6a9f93668 100644 --- a/packages/desktop/src/features/editor-targets/target.ts +++ b/packages/desktop/src/features/editor-targets/target.ts @@ -30,6 +30,8 @@ export interface EditorTargetRuntime { pathExists(path: string): boolean; isAbsolutePath(path: string): boolean; listDirectory(path: string): EditorTargetDirectoryEntry[]; + /** Resolves a leading `~` to the user's home directory. */ + expandHomePath(path: string): string; resolveCommand(commands: readonly string[]): string | null; spawnDetached(input: { command: string; args: readonly string[] }): Promise; openPath(path: string): Promise; diff --git a/packages/expo-two-way-audio/package.json b/packages/expo-two-way-audio/package.json index 1a38528c25..98e790361e 100644 --- a/packages/expo-two-way-audio/package.json +++ b/packages/expo-two-way-audio/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/expo-two-way-audio", - "version": "0.13.2", + "version": "0.14.0-beta.1", "description": "Native module for two way audio streaming", "keywords": [ "ExpoTwoWayAudio", @@ -33,7 +33,6 @@ "devDependencies": { "@types/jest": "^29.5.14", "@types/react": "^18.0.25", - "expo-module-scripts": "^5.0.8", "expo-modules-core": "^2.0.4" }, "peerDependencies": { diff --git a/packages/expo-two-way-audio/tsconfig.json b/packages/expo-two-way-audio/tsconfig.json index 102525ffa9..24e443895c 100644 --- a/packages/expo-two-way-audio/tsconfig.json +++ b/packages/expo-two-way-audio/tsconfig.json @@ -6,7 +6,7 @@ "module": "esnext", "moduleResolution": "bundler", "typeRoots": [ - "./node_modules/expo-module-scripts/ts-declarations", + "../../node_modules/expo-module-scripts/ts-declarations", "./node_modules/@types", "../../node_modules/@types" ] diff --git a/packages/highlight/package.json b/packages/highlight/package.json index d7708e5925..cf4c09305d 100644 --- a/packages/highlight/package.json +++ b/packages/highlight/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/highlight", - "version": "0.13.2", + "version": "0.14.0-beta.1", "files": [ "dist", "!dist/**/*.map", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 18421b41e1..a55ca023ac 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/plugin", - "version": "0.13.2", + "version": "0.14.0-beta.1", "description": "Paseo plugin SDK package", "files": [ "dist", @@ -72,8 +72,8 @@ "use-sync-external-store": "^1.6.0" }, "devDependencies": { - "@getpaseo/client": "0.13.2", - "@getpaseo/protocol": "0.13.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", @@ -83,8 +83,8 @@ "zod": "^4.4.3" }, "peerDependencies": { - "@getpaseo/client": "0.13.2", - "@getpaseo/protocol": "0.13.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" diff --git a/packages/plugin/src/boundaries.test.ts b/packages/plugin/src/boundaries.test.ts index 017b69bda5..56f0d0d78c 100644 --- a/packages/plugin/src/boundaries.test.ts +++ b/packages/plugin/src/boundaries.test.ts @@ -238,7 +238,11 @@ describe("plugin agent tools SDK boundary", () => { // @ts-expect-error labels are read-only request.labels.x = "changed"; if (request.labels.x !== "y") return; - return { ...request, tools: [...request.tools, tool] }; + // @ts-expect-error MCP servers use the same typed transport config as saved servers + request.mcpServers.invalid = { type: "http", url: 42 }; + return { ...request, tools: [...request.tools, tool], mcpServers: { + ...request.mcpServers, company: { type: "http", url: "https://tools.example.com/mcp" }, + } }; }); return () => {}; } diff --git a/packages/plugin/src/server/acp-internal/connection.ts b/packages/plugin/src/server/acp-internal/connection.ts index b2fbee8e2c..86e22b2afe 100644 --- a/packages/plugin/src/server/acp-internal/connection.ts +++ b/packages/plugin/src/server/acp-internal/connection.ts @@ -997,7 +997,9 @@ class AcpRuntime { ): string { if (messageId) { this.fallbackChunkIds.delete(kind); - return messageId; + // Native IDs identify a message within its chunk kind. Timeline IDs must + // also distinguish reasoning from replies and from generated fallback IDs. + return JSON.stringify([kind, messageId]); } let id = this.fallbackChunkIds.get(kind); if (!id) { diff --git a/packages/plugin/src/server/acp.test.ts b/packages/plugin/src/server/acp.test.ts index f4b429578f..1d99c73a75 100644 --- a/packages/plugin/src/server/acp.test.ts +++ b/packages/plugin/src/server/acp.test.ts @@ -1307,7 +1307,15 @@ describe("runAcpProvider streamed chunks without messageId (#4699)", () => { const items = events.flatMap((event) => event.type === "timeline.item" && (event.item.type === "assistant_message" || event.item.type === "reasoning") - ? [{ id: event.item.id, text: event.item.text }] + ? [ + { + id: event.item.id, + text: event.item.text, + ...(event.item.type === "assistant_message" && event.item.messageId + ? { messageId: event.item.messageId } + : {}), + }, + ] : [], ); expect(items).toEqual([ @@ -1315,7 +1323,11 @@ describe("runAcpProvider streamed chunks without messageId (#4699)", () => { { id: "agent_message_chunk:1", text: "- **Current temperature**: 25°C" }, { id: "agent_message_chunk:2", text: "next turn" }, { id: "agent_thought_chunk:3", text: "think A" }, - { id: "m-explicit", text: "say" }, + { + id: JSON.stringify(["agent_message_chunk", "m-explicit"]), + messageId: "m-explicit", + text: "say", + }, { id: "agent_thought_chunk:4", text: "think B" }, ]); await connection.close(); @@ -1402,6 +1414,21 @@ async function streamChunkTurns( } describe("ACP streamed message boundaries", () => { + it("keeps reasoning separate from a reply sharing its native message ID", async () => { + expect( + await streamChunkTurns([ + textChunk("private ", "agent_thought_chunk", "shared-id"), + textChunk("reasoning", "agent_thought_chunk", "shared-id"), + textChunk("public ", "agent_message_chunk", "shared-id"), + textChunk("reply", "agent_message_chunk", "shared-id"), + ]), + ).toEqual([ + { type: "reasoning", text: "private reasoning" }, + { type: "assistant_message", text: "public reply" }, + { type: "assistant_message", text: "Next turn" }, + ]); + }); + it.each([ { name: "tool call", @@ -1584,8 +1611,12 @@ describe("ACP session history replay", () => { ); try { expect(timelineProjection(events)).toEqual([ - { type: "user_message", id: "user-1", text: "What is the " }, - { type: "user_message", id: "user-1", text: "What is the temperature?" }, + { type: "user_message", id: '["user_message_chunk","user-1"]', text: "What is the " }, + { + type: "user_message", + id: '["user_message_chunk","user-1"]', + text: "What is the temperature?", + }, { type: "reasoning", id: "agent_thought_chunk:1", text: "Checking the sensor" }, { type: "tool_call", id: "tool-1", status: "running" }, { type: "tool_call", id: "tool-1", status: "completed" }, @@ -1599,6 +1630,48 @@ describe("ACP session history replay", () => { } }); + it("keeps replayed user, reasoning and reply chunks separate when their native IDs match", async () => { + const { connection, events } = await openReplaySession( + [ + userChunk("What is the ", "shared-id"), + userChunk("temperature?", "shared-id"), + textChunk("Checking ", "agent_thought_chunk", "shared-id"), + textChunk("the sensor", "agent_thought_chunk", "shared-id"), + textChunk("It is ", "agent_message_chunk", "shared-id"), + textChunk("25°C", "agent_message_chunk", "shared-id"), + ], + "replay", + ); + try { + const items = new Map( + events.flatMap((event) => + event.type === "timeline.item" ? [[event.item.id, event.item] as const] : [], + ), + ); + expect([...items.values()]).toEqual([ + { + type: "user_message", + id: '["user_message_chunk","shared-id"]', + messageId: "shared-id", + text: "What is the temperature?", + }, + { + type: "reasoning", + id: '["agent_thought_chunk","shared-id"]', + text: "Checking the sensor", + }, + { + type: "assistant_message", + id: '["agent_message_chunk","shared-id"]', + messageId: "shared-id", + text: "It is 25°C", + }, + ]); + } finally { + await connection.close(); + } + }); + it("continues anonymous user chunks into one message and starts a new one after an agent reply", async () => { const { connection, events } = await openReplaySession( [ diff --git a/packages/plugin/src/server/lifecycle.ts b/packages/plugin/src/server/lifecycle.ts index f3f2643a04..9486383072 100644 --- a/packages/plugin/src/server/lifecycle.ts +++ b/packages/plugin/src/server/lifecycle.ts @@ -41,6 +41,7 @@ export interface PluginSessionOpenRequest { readonly labels: Readonly>; tools: AgentToolDefinition[]; env: Record; + mcpServers: NonNullable; } export type PluginTurnOutcome = diff --git a/packages/protocol/package.json b/packages/protocol/package.json index e463e681e7..e1c8b14763 100644 --- a/packages/protocol/package.json +++ b/packages/protocol/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/protocol", - "version": "0.13.2", + "version": "0.14.0-beta.1", "description": "Paseo shared protocol schemas and wire types", "files": [ "dist", diff --git a/packages/protocol/src/messages.plugins.test.ts b/packages/protocol/src/messages.plugins.test.ts index f266b65988..47e91493fe 100644 --- a/packages/protocol/src/messages.plugins.test.ts +++ b/packages/protocol/src/messages.plugins.test.ts @@ -23,6 +23,29 @@ describe("plugin protocol compatibility", () => { expect(SessionOutboundMessageSchema.parse(message)).toEqual(message); }, ); + it.each([ + { type: "plugin.catalog.get.request", requestId: "catalog" }, + { + type: "plugin.catalog.get.request", + requestId: "catalog", + cachedClientBundles: [{ id: "example", clientBundleHash: "a".repeat(64) }], + }, + ])("parses plugin catalog requests %j", (request) => { + expect(SessionInboundMessageSchema.parse(request)).toEqual(request); + }); + it("parses plugin catalogs that leave out a client bundle the client already holds", () => { + const message = { + type: "plugin.catalog.get.response", + payload: { + requestId: "catalog", + plugins: [ + { id: "cached", clientBundleHash: "a".repeat(64) }, + { id: "changed", clientBundle: "bundle", clientBundleHash: "b".repeat(64) }, + ], + }, + }; + expect(SessionOutboundMessageSchema.parse(message)).toEqual(message); + }); it("preserves manifest metadata in plugin list responses", () => { const message = { type: "plugin.list.response", diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 3034598f1e..92d6ec71e1 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -1470,6 +1470,10 @@ export const DiagnosticsRequestSchema = z.object({ export const PluginCatalogGetRequestSchema = z.object({ type: z.literal("plugin.catalog.get.request"), requestId: z.string(), + /** Client bundles the client already holds; the daemon leaves out each one whose hash matches. */ + cachedClientBundles: z + .array(z.object({ id: PluginIdSchema, clientBundleHash: z.string() })) + .optional(), }); export const PluginListRequestSchema = z.object({ @@ -1910,6 +1914,8 @@ export const AgentTimelineCursorSchema = z.object({ }); export const FetchAgentTimelineRequestMessageSchema = z.object({ + // Keep available turns whole for tail/before pages; after catch-up stays incremental. + wholeTurns: z.boolean().optional(), type: z.literal("fetch_agent_timeline_request"), agentId: z.string(), requestId: z.string(), @@ -3765,6 +3771,7 @@ export const ServerInfoStatusPayloadSchema = z providerSubagents: z.boolean().optional(), // COMPAT(projectedSubagentTimeline): added after v0.8.0, remove gates after 2027-03-14; retain wire field. projectedSubagentTimeline: z.boolean().optional(), + wholeTurnTimeline: z.boolean().optional(), // COMPAT(providerSubagentNesting): added in v0.7, remove gate after 2027-03-04. providerSubagentNesting: z.boolean().optional(), // COMPAT(workspacePinning): added in v0.1.107, remove gate after 2027-01-12. @@ -6754,7 +6761,10 @@ export const PluginCatalogGetResponseSchema = z.object({ plugins: z.array( z.object({ id: PluginIdSchema, - clientBundle: z.string(), + /** Left out only when the request listed this plugin with the same `clientBundleHash`. */ + clientBundle: z.string().optional(), + /** SHA-256 of `clientBundle`, hex encoded. */ + clientBundleHash: z.string().optional(), requirements: PluginRequirementsSchema.optional(), }), ), diff --git a/packages/protocol/src/paseo-tool-presentation/catalog.ts b/packages/protocol/src/paseo-tool-presentation/catalog.ts index 0c35b128b7..4844b7ceb7 100644 --- a/packages/protocol/src/paseo-tool-presentation/catalog.ts +++ b/packages/protocol/src/paseo-tool-presentation/catalog.ts @@ -19,6 +19,7 @@ export interface PaseoToolDetailSpec { export interface PaseoToolEntry { label: string; + completedLabel: string; context?: PaseoToolContext; detail?: PaseoToolDetailSpec; } @@ -123,14 +124,24 @@ function baseName(value: unknown): string | undefined { return value.split(/[\\/]/).findLast((segment) => segment.length > 0); } -function browser(label: string, context?: PaseoToolContext): PaseoToolEntry { - return { label, ...(context ? { context } : {}), detail: { inputOrder: BROWSER_FIELDS } }; +function browser( + label: string, + completedLabel: string, + context?: PaseoToolContext, +): PaseoToolEntry { + return { + label, + completedLabel, + ...(context ? { context } : {}), + detail: { inputOrder: BROWSER_FIELDS }, + }; } export const PASEO_TOOL_CATALOG = { // Agents create_agent: { label: "Create agent", + completedLabel: "Created agent", context: field("title", "initialPrompt"), detail: { promptField: "initialPrompt", @@ -140,6 +151,7 @@ export const PASEO_TOOL_CATALOG = { }, send_agent_prompt: { label: "Send message", + completedLabel: "Sent message", context: field("prompt"), detail: { promptField: "prompt", @@ -149,42 +161,55 @@ export const PASEO_TOOL_CATALOG = { }, get_agent_status: { label: "Check agent", + completedLabel: "Checked agent", context: targetAgent(), detail: { inputOrder: ["agentId"] }, }, get_agent_activity: { label: "Read agent activity", + completedLabel: "Read agent activity", context: targetAgent(), detail: { inputOrder: ["agentId", "limit"] }, }, list_agents: { label: "List agents", + completedLabel: "Listed agents", detail: { inputOrder: ["cwd", "statuses", "sinceHours", "limit", "includeArchived"] }, }, update_agent: { label: "Update agent", + completedLabel: "Updated agent", context: fieldOrTargetAgent("name"), detail: { inputOrder: AGENT_FIELDS }, }, set_agent_mode: { label: "Set agent mode", + completedLabel: "Set agent mode", context: field("modeId"), detail: { inputOrder: ["agentId", "modeId"] }, }, cancel_agent: { label: "Cancel agent", + completedLabel: "Canceled agent", context: targetAgent(), detail: { inputOrder: ["agentId"] }, }, archive_agent: { label: "Archive agent", + completedLabel: "Archived agent", context: targetAgent(), detail: { inputOrder: ["agentId"] }, }, - kill_agent: { label: "Kill agent", context: targetAgent(), detail: { inputOrder: ["agentId"] } }, - list_pending_permissions: { label: "List permissions" }, + kill_agent: { + label: "Kill agent", + completedLabel: "Killed agent", + context: targetAgent(), + detail: { inputOrder: ["agentId"] }, + }, + list_pending_permissions: { label: "List permissions", completedLabel: "Listed permissions" }, respond_to_permission: { label: "Respond to permission", + completedLabel: "Responded to permission", context: targetAgent(), detail: { inputOrder: ["agentId", "requestId", "response"] }, }, @@ -192,32 +217,41 @@ export const PASEO_TOOL_CATALOG = { // Workspaces and scripts create_workspace: { label: "Create workspace", + completedLabel: "Created workspace", context: workspaceContext(), detail: { inputOrder: WORKSPACE_FIELDS, outputFields: ["workspaceId", "projectId"] }, }, - list_hosts: { label: "List hosts" }, - list_workspaces: { label: "List workspaces" }, + list_hosts: { label: "List hosts", completedLabel: "Listed hosts" }, + list_workspaces: { label: "List workspaces", completedLabel: "Listed workspaces" }, rename_workspace: { label: "Rename workspace", + completedLabel: "Renamed workspace", context: field("title"), detail: { inputOrder: ["title", "workspaceId"] }, }, archive_workspace: { label: "Archive workspace", + completedLabel: "Archived workspace", context: field("workspaceId"), detail: { inputOrder: ["workspaceId"], outputFields: ["workspaceId", "archivedAgentIds", "removedDirectory"], }, }, - list_workspace_scripts: { label: "List scripts", detail: { inputOrder: ["workspaceId"] } }, + list_workspace_scripts: { + label: "List scripts", + completedLabel: "Listed scripts", + detail: { inputOrder: ["workspaceId"] }, + }, start_workspace_script: { label: "Start script", + completedLabel: "Started script", context: field("scriptName"), detail: { inputOrder: ["workspaceId", "scriptName"] }, }, stop_workspace_script: { label: "Stop script", + completedLabel: "Stopped script", context: field("scriptName"), detail: { inputOrder: ["workspaceId", "scriptName"] }, }, @@ -225,22 +259,30 @@ export const PASEO_TOOL_CATALOG = { // Terminals create_terminal: { label: "Create terminal", + completedLabel: "Created terminal", context: field("name", "command"), detail: { inputOrder: ["cwd", "workspaceId", "name", "command"] }, }, - list_terminals: { label: "List terminals", detail: { inputOrder: ["cwd", "all"] } }, + list_terminals: { + label: "List terminals", + completedLabel: "Listed terminals", + detail: { inputOrder: ["cwd", "all"] }, + }, capture_terminal: { label: "Read terminal", + completedLabel: "Read terminal", context: field("terminalId"), detail: { inputOrder: ["terminalId", "lines"] }, }, send_terminal_keys: { label: "Send keys", + completedLabel: "Sent keys", context: field("keys"), detail: { inputOrder: ["terminalId", "keys", "literal"] }, }, kill_terminal: { label: "Kill terminal", + completedLabel: "Killed terminal", context: field("terminalId"), detail: { inputOrder: ["terminalId"] }, }, @@ -248,6 +290,7 @@ export const PASEO_TOOL_CATALOG = { // Schedules and heartbeats create_schedule: { label: "Create schedule", + completedLabel: "Created schedule", context: field("name", "cron"), detail: { promptField: "prompt", @@ -257,6 +300,7 @@ export const PASEO_TOOL_CATALOG = { }, create_heartbeat: { label: "Create heartbeat", + completedLabel: "Created heartbeat", context: field("name", "cron"), detail: { promptField: "prompt", @@ -266,79 +310,93 @@ export const PASEO_TOOL_CATALOG = { }, update_schedule: { label: "Update schedule", + completedLabel: "Updated schedule", context: field("name", "id"), detail: { promptField: "prompt", inputOrder: AUTOMATION_FIELDS }, }, - list_schedules: { label: "List schedules" }, + list_schedules: { label: "List schedules", completedLabel: "Listed schedules" }, inspect_schedule: { label: "Inspect schedule", + completedLabel: "Inspected schedule", context: field("id"), detail: { inputOrder: ["id"] }, }, schedule_logs: { label: "Read schedule logs", + completedLabel: "Read schedule logs", context: field("id"), detail: { inputOrder: ["id"] }, }, run_schedule_once: { label: "Run schedule", + completedLabel: "Ran schedule", + context: field("id"), + detail: { inputOrder: ["id"] }, + }, + pause_schedule: { + label: "Pause schedule", + completedLabel: "Paused schedule", context: field("id"), detail: { inputOrder: ["id"] }, }, - pause_schedule: { label: "Pause schedule", context: field("id"), detail: { inputOrder: ["id"] } }, resume_schedule: { label: "Resume schedule", + completedLabel: "Resumed schedule", context: field("id"), detail: { inputOrder: ["id"] }, }, delete_schedule: { label: "Delete schedule", + completedLabel: "Deleted schedule", context: field("id"), detail: { inputOrder: ["id"] }, }, delete_heartbeat: { label: "Delete heartbeat", + completedLabel: "Deleted heartbeat", context: field("id"), detail: { inputOrder: ["id"] }, }, // Providers - list_providers: { label: "List providers" }, + list_providers: { label: "List providers", completedLabel: "Listed providers" }, list_models: { label: "List models", + completedLabel: "Listed models", context: field("provider"), detail: { inputOrder: ["provider"] }, }, - list_profiles: { label: "List profiles" }, + list_profiles: { label: "List profiles", completedLabel: "Listed profiles" }, inspect_provider: { label: "Inspect provider", + completedLabel: "Inspected provider", context: field("provider"), detail: { inputOrder: ["provider", "cwd", "settings"] }, }, // Browser - browser_list_tabs: browser("List browser tabs"), - browser_new_tab: browser("Open browser tab", field("url")), - browser_close_tab: browser("Close browser tab", field("browserId")), - browser_navigate: browser("Navigate", field("url")), - browser_back: browser("Go back"), - browser_forward: browser("Go forward"), - browser_reload: browser("Reload page"), - browser_snapshot: browser("Snapshot page"), - browser_screenshot: browser("Take screenshot"), - browser_click: browser("Click element", field("ref")), - browser_hover: browser("Hover element", field("ref")), - browser_drag: browser("Drag element", field("sourceRef")), - browser_fill: browser("Fill field", field("ref")), - browser_select: browser("Select option", field("value", "ref")), - browser_type: browser("Type text", field("text")), - browser_keypress: browser("Press key", field("key")), - browser_upload: browser("Upload files", field("filePaths")), - browser_wait: browser("Wait for page", field("text", "url")), - browser_scroll: browser("Scroll page"), - browser_resize: browser("Resize browser", viewportContext()), - browser_logs: browser("Read browser logs"), - browser_evaluate: browser("Run JavaScript", field("function")), + browser_list_tabs: browser("List browser tabs", "Listed browser tabs"), + browser_new_tab: browser("Open browser tab", "Opened browser tab", field("url")), + browser_close_tab: browser("Close browser tab", "Closed browser tab", field("browserId")), + browser_navigate: browser("Navigate", "Navigated", field("url")), + browser_back: browser("Go back", "Went back"), + browser_forward: browser("Go forward", "Went forward"), + browser_reload: browser("Reload page", "Reloaded page"), + browser_snapshot: browser("Snapshot page", "Snapshotted page"), + browser_screenshot: browser("Take screenshot", "Took screenshot"), + browser_click: browser("Click element", "Clicked element", field("ref")), + browser_hover: browser("Hover element", "Hovered element", field("ref")), + browser_drag: browser("Drag element", "Dragged element", field("sourceRef")), + browser_fill: browser("Fill field", "Filled field", field("ref")), + browser_select: browser("Select option", "Selected option", field("value", "ref")), + browser_type: browser("Type text", "Typed text", field("text")), + browser_keypress: browser("Press key", "Pressed key", field("key")), + browser_upload: browser("Upload files", "Uploaded files", field("filePaths")), + browser_wait: browser("Wait for page", "Waited for page", field("text", "url")), + browser_scroll: browser("Scroll page", "Scrolled page"), + browser_resize: browser("Resize browser", "Resized browser", viewportContext()), + browser_logs: browser("Read browser logs", "Read browser logs"), + browser_evaluate: browser("Run JavaScript", "Ran JavaScript", field("function")), } satisfies Record; export type PaseoToolName = keyof typeof PASEO_TOOL_CATALOG; diff --git a/packages/protocol/src/paseo-tool-presentation/index.ts b/packages/protocol/src/paseo-tool-presentation/index.ts index c04606a231..e5395a9e62 100644 --- a/packages/protocol/src/paseo-tool-presentation/index.ts +++ b/packages/protocol/src/paseo-tool-presentation/index.ts @@ -28,6 +28,8 @@ export const PASEO_TOOL_NAMES = Object.keys(PASEO_TOOL_CATALOG) as readonly Pase /** Returns null for tools outside the Paseo namespace. */ export function buildPaseoToolDisplay(input: PaseoToolDisplayInput): PaseoToolDisplay | null { + // Canonical details own internal lifecycle rows such as worktree setup. + if (input.detail.type !== "unknown") return null; const leafName = paseoLeafName(input.toolName); if (!leafName) return null; const entry = getPaseoToolEntry(leafName); @@ -39,6 +41,13 @@ export function buildPaseoToolDisplay(input: PaseoToolDisplayInput): PaseoToolDi return { displayName: entry.label, ...(summary ? { summary } : {}) }; } +/** A semantic action label for summaries, sharing the tool presentation catalog. */ +export function getPaseoToolActionLabel(toolName: string): string | null { + const leafName = paseoLeafName(toolName); + if (!leafName) return null; + return getPaseoToolEntry(leafName)?.completedLabel ?? humanizeLeafName(leafName); +} + /** The agent a Paseo call acts on, so callers can resolve its title. */ export function getPaseoToolTargetAgentId( toolName: string, @@ -49,7 +58,11 @@ export function getPaseoToolTargetAgentId( } function paseoLeafName(toolName: string): string | null { - return isPaseoToolName(toolName) ? getPaseoToolLeafName(toolName) : null; + if (isPaseoToolName(toolName)) return getPaseoToolLeafName(toolName); + const normalized = toolName.trim().toLowerCase(); + return normalized.startsWith("paseo_") && !normalized.startsWith("paseo__") + ? normalized.slice("paseo_".length) + : null; } function readToolInput(detail: ToolCallDetail): Readonly> { diff --git a/packages/protocol/src/timeline-turn.ts b/packages/protocol/src/timeline-turn.ts new file mode 100644 index 0000000000..f509787317 --- /dev/null +++ b/packages/protocol/src/timeline-turn.ts @@ -0,0 +1,24 @@ +/** Canonical run identity wins over trigger-shaped rows steered into that run. */ +export function continuesTimelineTurn( + previousTurnId: string | undefined, + nextTurnId: string | undefined, + nextIsTrigger: boolean, +): boolean { + if (previousTurnId !== undefined && nextTurnId !== undefined) { + return previousTurnId === nextTurnId; + } + // Missing IDs cannot recover historical batching or steering. Each visible + // trigger deterministically starts a turn in both paging and presentation. + return !nextIsTrigger; +} + +export function isTimelineTurnTrigger(item: { + type: string; + name?: string; + agentMessage?: unknown; +}): boolean { + return ( + item.type === "user_message" || + (item.type === "tool_call" && (item.agentMessage !== undefined || item.name === "heartbeat")) + ); +} diff --git a/packages/relay/package.json b/packages/relay/package.json index b2e0c5187f..0d96207e98 100644 --- a/packages/relay/package.json +++ b/packages/relay/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/relay", - "version": "0.13.2", + "version": "0.14.0-beta.1", "description": "Paseo relay for bridging daemon and client connections", "files": [ "dist", diff --git a/packages/server/package.json b/packages/server/package.json index f39d284343..c651f134bf 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/server", - "version": "0.13.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.13.2", - "@getpaseo/highlight": "0.13.2", - "@getpaseo/plugin": "0.13.2", - "@getpaseo/protocol": "0.13.2", - "@getpaseo/relay": "0.13.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", diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index ae8989c003..2d21bdc63c 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -880,6 +880,66 @@ test("retries provider history hydration after a stream failure", async () => { } }); +test("notification batches share the run they start or join", async () => { + const session = new SteeringTestSession({ provider: "codex", cwd: process.cwd() }); + const workdir = mkdtempSync(join(tmpdir(), "agent-notification-turns-")); + const manager = new AgentManager({ + clients: { + codex: new (class extends TestAgentClient { + override async createSession(): Promise { + return session; + } + })(), + }, + logger, + }); + const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + const batch = (prefix: string) => + ["one", "two"] + .map((id) => + formatAgentMessage({ + id: `${prefix}-${id}`, + source: { kind: "agent-notification", agentId: "worker", event: "finished" }, + text: "Worker finished", + }), + ) + .join("\n\n"); + const consume = async (prompt: string, clientMessageId: string) => { + for await (const _event of manager.streamAgent(agent.id, prompt, { clientMessageId })) { + } + }; + try { + const first = consume("human", "human"); + await manager.waitForAgentRunStart(agent.id); + await expect( + manager.steerAgentRun(agent.id, batch("joined"), { clientMessageId: "joined-one" }), + ).resolves.toEqual({ status: "accepted" }); + session.pushEvent({ type: "turn_completed", provider: "codex", turnId: "active-turn-1" }); + await first; + const second = consume(batch("idle"), "idle-one"); + await manager.waitForAgentRunStart(agent.id); + session.pushEvent({ type: "turn_completed", provider: "codex", turnId: "active-turn-2" }); + await second; + expect( + manager.fetchTimeline(agent.id, { limit: 0 }).rows.map((row) => ({ + turnId: row.turnId, + id: row.item.type === "tool_call" ? row.item.callId : row.item.type, + })), + ).toEqual([ + { turnId: "active-turn-1", id: "user_message" }, + { turnId: "active-turn-1", id: "paseo-agent-message:joined-one" }, + { turnId: "active-turn-1", id: "paseo-agent-message:joined-two" }, + { turnId: "active-turn-2", id: "paseo-agent-message:idle-one" }, + { turnId: "active-turn-2", id: "paseo-agent-message:idle-two" }, + ]); + } finally { + await manager.closeAgent(agent.id); + rmSync(workdir, { recursive: true, force: true }); + } +}); + test("unavailable steer interrupts once and starts one replacement turn", async () => { const session = new SteeringTestSession({ provider: "codex", cwd: process.cwd() }); session.steerResult = "unavailable"; @@ -11341,7 +11401,7 @@ test("listImportableSessions searches every provider result before global rankin ]); }); -test("user_message events wrapping a paseo-system envelope are not added to the timeline", async () => { +test("system-injected prompts appear as trigger tools in the live timeline", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-envelope-live-")); const storagePath = join(workdir, "agents"); const storage = new AgentStorage(storagePath, logger); @@ -11370,13 +11430,16 @@ test("user_message events wrapping a paseo-system envelope are not added to the await manager.runAgent(snapshot.id, { text: "do something" }); const timeline = manager.getTimeline(snapshot.id); + expect(timeline).toContainEqual( + expect.objectContaining({ type: "tool_call", name: "heartbeat", status: "completed" }), + ); const userMessages = timeline.filter((item) => item.type === "user_message"); expect(userMessages).toHaveLength(1); expect(userMessages[0].text).toBe("plain user message"); }); -test("user_message events wrapping a paseo-system envelope are not restored during history replay", async () => { +test("system-injected prompts appear as trigger tools during history replay", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-envelope-history-")); const storagePath = join(workdir, "agents"); const storage = new AgentStorage(storagePath, logger); @@ -11411,6 +11474,9 @@ test("user_message events wrapping a paseo-system envelope are not restored duri await manager.hydrateTimelineFromProvider(snapshot.id, { force: true }); const timeline = manager.getTimeline(snapshot.id); + expect(timeline).toContainEqual( + expect.objectContaining({ type: "tool_call", name: "heartbeat", status: "completed" }), + ); const userMessages = timeline.filter((item) => item.type === "user_message"); expect(userMessages).toHaveLength(1); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 1342576f7f..8cbdfc068e 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -85,7 +85,11 @@ import { import { invokeRewindCapability, type RewindMode } from "./rewind/rewind.js"; import { parseAgentMessage, projectAgentMessages } from "./agent-messages/index.js"; import { isStaleProviderSessionError } from "./stale-provider-session-error.js"; -import { stripInternalPaseoMcpServer, withRuntimePaseoMcpServer } from "./runtime-mcp-config.js"; +import { + PASEO_MCP_SERVER_NAME, + stripInternalPaseoMcpServer, + withRuntimePaseoMcpServer, +} from "./runtime-mcp-config.js"; import { resolveCreateAgentTitles } from "./create-agent-title.js"; import type { PaseoToolCatalogFactory } from "./tools/types.js"; import { isPaseoToolPolicyEnabled } from "./paseo-tool-policy.js"; @@ -559,18 +563,20 @@ interface AgentMetadataPatch { const SYSTEM_ERROR_PREFIX = "[System Error]"; -function attachPersistenceCwd( +function normalizePersistenceForConfig( handle: AgentPersistenceHandle | null, - cwd: string, + config: AgentSessionConfig, ): AgentPersistenceHandle | null { if (!handle) { return null; } + const { mcpServers: _launchMcpServers, ...metadata } = handle.metadata ?? {}; return { ...handle, metadata: { - ...handle.metadata, - cwd, + ...metadata, + cwd: config.cwd, + ...(config.mcpServers ? { mcpServers: config.mcpServers } : {}), }, }; } @@ -1387,7 +1393,7 @@ export class AgentManager { const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext); const createOptions = this.buildCreateSessionOptions(options); const session = await client.createSession(providerLaunchConfig, launchContext, createOptions); - await this.requireExternalMcpSupport(session, storedConfig); + await this.requireExternalMcpSupport(session, providerLaunchConfig, launchContext); const agent = await this.registerSession(session, storedConfig, resolvedAgentId, { labels: options.labels, initialTitle: options.initialTitle, @@ -1515,7 +1521,7 @@ export class AgentManager { launchContext, currentResumeOptions, ); - await this.requireExternalMcpSupport(session, storedConfig); + await this.requireExternalMcpSupport(session, providerLaunchConfig, launchContext); return this.registerSession(session, storedConfig, resolvedAgentId, { ...options, persistence: handle, @@ -1578,6 +1584,7 @@ export class AgentManager { }, { config: providerLaunchConfig, storedConfig, launchContext }, ); + await this.requireExternalMcpSupport(imported.session, providerLaunchConfig, launchContext); let handedToRegistration = false; try { const importedConfig = await this.normalizeConfig( @@ -1673,10 +1680,10 @@ export class AgentManager { ); const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext); if ( - Object.keys(storedConfig.mcpServers ?? {}).length > 0 && + Object.keys(stripInternalPaseoMcpServer(providerLaunchConfig).mcpServers ?? {}).length > 0 && existing.session.capabilities.supportsMcpServers !== true ) { - throw new Error(`Provider '${provider}' does not support MCP servers`); + throw this.unsupportedMcpError(providerLaunchConfig, launchContext); } let session: AgentSession | undefined; @@ -1695,7 +1702,7 @@ export class AgentManager { session = handle ? await client.resumeSession(handle, providerLaunchConfig, launchContext) : await client.createSession(providerLaunchConfig, launchContext); - await this.requireExternalMcpSupport(session, storedConfig); + await this.requireExternalMcpSupport(session, providerLaunchConfig, launchContext); this.assertAcceptingAgentRegistrations(); if (rehydrateFromDisk) { @@ -2921,7 +2928,10 @@ export class AgentManager { ? { provider: mutableAgent.provider, sessionId: mutableAgent.runtimeInfo.sessionId } : null); if (persistenceHandle) { - mutableAgent.persistence = attachPersistenceCwd(persistenceHandle, mutableAgent.cwd); + mutableAgent.persistence = normalizePersistenceForConfig( + persistenceHandle, + mutableAgent.config, + ); } this.logger.trace( { @@ -3865,16 +3875,23 @@ export class AgentManager { private async requireExternalMcpSupport( session: AgentSession, - storedConfig: AgentSessionConfig, + launchConfig: AgentSessionConfig, + launchContext: AgentLaunchContext, ): Promise { if ( - Object.keys(storedConfig.mcpServers ?? {}).length === 0 || + Object.keys(stripInternalPaseoMcpServer(launchConfig).mcpServers ?? {}).length === 0 || session.capabilities.supportsMcpServers === true ) { return; } await this.closeUnregisteredSession(session); - throw new Error(`Provider '${storedConfig.provider}' does not support MCP servers`); + throw this.unsupportedMcpError(launchConfig, launchContext); + } + + private unsupportedMcpError(config: AgentSessionConfig, context: AgentLaunchContext): Error { + const owners = [...new Set(Object.values(context.mcpServerOwners ?? {}))]; + const source = owners.length ? ` (plugin ${owners.join(", ")})` : ""; + return new Error(`Provider '${config.provider}' does not support MCP servers${source}`); } private async initializeAgentTimelineForRegister(params: { @@ -3960,9 +3977,9 @@ export class AgentManager { foregroundTurnWaiters: new Set(), finalizedForegroundTurnIds: new Set(), unsubscribeSession: null, - persistence: attachPersistenceCwd( + persistence: normalizePersistenceForConfig( options?.persistence ?? session.describePersistence(), - config.cwd, + config, ), historyPrimed: options?.historyPrimed ?? durableTimelineHasRows, lastUserMessageAt: options?.lastUserMessageAt ?? null, @@ -4266,9 +4283,9 @@ export class AgentManager { newInfo.modeId !== agent.runtimeInfo?.modeId; agent.runtimeInfo = newInfo; if (!agent.persistence && newInfo.sessionId) { - agent.persistence = attachPersistenceCwd( + agent.persistence = normalizePersistenceForConfig( { provider: agent.provider, sessionId: newInfo.sessionId }, - agent.cwd, + agent.config, ); } // Emit state if runtimeInfo changed so clients get the updated model @@ -4626,9 +4643,9 @@ export class AgentManager { case "model_changed": agent.runtimeInfo = event.runtimeInfo; if (!agent.persistence && event.runtimeInfo.sessionId) { - agent.persistence = attachPersistenceCwd( + agent.persistence = normalizePersistenceForConfig( { provider: agent.provider, sessionId: event.runtimeInfo.sessionId }, - agent.cwd, + agent.config, ); } this.applyObservedMode(agent, event.runtimeInfo.modeId ?? agent.currentModeId); @@ -4702,7 +4719,7 @@ export class AgentManager { private refreshSessionPersistence(agent: ActiveManagedAgent): void { const handle = agent.session.describePersistence(); if (handle) { - agent.persistence = attachPersistenceCwd(handle, agent.cwd); + agent.persistence = normalizePersistenceForConfig(handle, agent.config); } } @@ -5519,6 +5536,8 @@ export class AgentManager { // A plugin changing during this open must still make the resulting session stale. const generation = this.sessionOpenGeneration; this.detachPluginTools(agentId); + let mcpServers: AgentSessionConfig["mcpServers"]; + let mcpServerOwners: Record | undefined; if (this.pluginLifecycle) { const request = { agentId, @@ -5531,14 +5550,20 @@ export class AgentManager { title: opening?.title ?? null, labels: opening?.labels ?? {}, tools: [], + mcpServers: {}, }; const transformed = await this.pluginLifecycle.before("agent.session_open", request); env = transformed.env; - if (request.purpose === "interactive") + if (request.purpose === "interactive") { this.attachedPluginTools.set(agentId, transformed.tools as AttachedAgentTool[]); + mcpServers = transformed.mcpServers; + mcpServerOwners = transformed.mcpServerOwners; + } } const context: AgentLaunchContext = { agentId, + mcpServers, + mcpServerOwners, env: { ...env, PASEO_AGENT_ID: agentId, @@ -5572,7 +5597,26 @@ export class AgentManager { launchConfig: AgentSessionConfig, launchContext: AgentLaunchContext, ): AgentSessionConfig { - return launchContext.paseoTools ? stripInternalPaseoMcpServer(launchConfig) : launchConfig; + const savedServerNames = new Set( + Object.keys(stripInternalPaseoMcpServer(launchConfig).mcpServers ?? {}), + ); + for (const name of Object.keys(launchContext.mcpServers ?? {})) { + const owner = launchContext.mcpServerOwners?.[name]; + if (name === PASEO_MCP_SERVER_NAME || savedServerNames.has(name)) { + const existingOwner = savedServerNames.has(name) + ? "agent saved configuration" + : "Paseo internal MCP server"; + throw new Error(`MCP server '${name}' from plugin ${owner} collides with ${existingOwner}`); + } + } + const merged = + Object.keys(launchContext.mcpServers ?? {}).length > 0 + ? { + ...launchConfig, + mcpServers: { ...launchConfig.mcpServers, ...launchContext.mcpServers }, + } + : launchConfig; + return launchContext.paseoTools ? stripInternalPaseoMcpServer(merged) : merged; } private async requireAvailableClient(options: { provider: AgentProvider }): Promise { diff --git a/packages/server/src/server/agent/agent-messages/envelope.ts b/packages/server/src/server/agent/agent-messages/envelope.ts index 2144d6ae02..a0aa880fce 100644 --- a/packages/server/src/server/agent/agent-messages/envelope.ts +++ b/packages/server/src/server/agent/agent-messages/envelope.ts @@ -1,5 +1,5 @@ import { renderPromptAttachmentAsText } from "../prompt-attachments.js"; -import { randomUUID } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; import { z } from "zod"; import type { AgentPromptContentBlock, @@ -175,9 +175,18 @@ function projectAgentMessage(item: AgentTimelineItem): AgentTimelineItem | null if (item.type !== "user_message") return item; const message = parseAgentMessage(item.text); if (!message) { - // COMPAT(legacySystemEnvelope): added in v0.11.1; remove after 2027-04-08 once - // legacy notification history is retired. Schedules still use this envelope. - return isSystemInjectedEnvelope(item.text) ? null : item; + // Schedule bodies include their run ID, keeping identity stable across acceptance + // and provider replay even when the provider assigns its own message ID. + if (!isSystemInjectedEnvelope(item.text)) return item; + const text = item.text.slice("\n".length, -"\n".length); + return { + type: "tool_call", + callId: `paseo-system:${createHash("sha256").update(item.text).digest("hex")}`, + name: "heartbeat", + status: "completed", + error: null, + detail: { type: "plain_text", text, icon: "sparkles" }, + }; } const { source } = message; if (!source) { diff --git a/packages/server/src/server/agent/agent-prompt.test.ts b/packages/server/src/server/agent/agent-prompt.test.ts index 00e70756de..2c150bd2e1 100644 --- a/packages/server/src/server/agent/agent-prompt.test.ts +++ b/packages/server/src/server/agent/agent-prompt.test.ts @@ -357,3 +357,21 @@ test("a human pasting an agent envelope stays a human message through provider r clientMessageId: "human-submission", }); }); + +test("schedule prompts remain visible as completed tool calls on replay", () => { + const text = formatSystemNotificationPrompt("Heartbeat: check progress"); + expect(projectAgentMessages({ type: "user_message", messageId: "fire-1", text })).toEqual([ + expect.objectContaining({ + type: "tool_call", + status: "completed", + detail: { type: "plain_text", text: "Heartbeat: check progress", icon: "sparkles" }, + }), + ]); +}); + +test("schedule trigger identity survives the provider assigning a message ID", () => { + const text = formatSystemNotificationPrompt("Schedule fired (id=heartbeat, run=run-1)."); + expect(projectAgentMessages({ type: "user_message", text })).toEqual( + projectAgentMessages({ type: "user_message", text, messageId: "provider-message" }), + ); +}); diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index 6818e65445..507cfbceb8 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -616,6 +616,9 @@ export interface AgentSessionConfig { } export interface AgentLaunchContext { + /** Launch-only plugin MCP servers and their owners; never persisted. */ + mcpServers?: Record; + mcpServerOwners?: Record; agentId?: string; env?: Record; /** diff --git a/packages/server/src/server/agent/agent-timeline-store-types.ts b/packages/server/src/server/agent/agent-timeline-store-types.ts index 639b24a904..9ca1ab37b9 100644 --- a/packages/server/src/server/agent/agent-timeline-store-types.ts +++ b/packages/server/src/server/agent/agent-timeline-store-types.ts @@ -17,6 +17,7 @@ export interface AgentTimelineCursor { export type AgentTimelineFetchDirection = "tail" | "before" | "after"; export interface AgentTimelineFetchOptions { + wholeTurns?: boolean; direction?: AgentTimelineFetchDirection; cursor?: AgentTimelineCursor; /** diff --git a/packages/server/src/server/agent/agent-timeline-store.test.ts b/packages/server/src/server/agent/agent-timeline-store.test.ts index eb46916fe3..4baaf89edb 100644 --- a/packages/server/src/server/agent/agent-timeline-store.test.ts +++ b/packages/server/src/server/agent/agent-timeline-store.test.ts @@ -1,9 +1,43 @@ +import { measureMainThreadWork } from "../../test-utils/main-thread-work.js"; import { describe, expect, it } from "vitest"; import { selectReferenceProjectedTimelinePage } from "./test-utils/timeline-page-reference.js"; import type { AgentTimelineItem } from "./agent-sdk-types.js"; import { InMemoryAgentTimelineStore } from "./agent-timeline-store.js"; describe("InMemoryAgentTimelineStore", () => { + it("resets stale forward cursors to complete available turns", () => { + const store = new InMemoryAgentTimelineStore(); + store.initialize("agent", { epoch: "current" }); + for (let index = 0; index < 60; index++) + store.append( + "agent", + { + type: "tool_call", + callId: `call-${index}`, + name: "shell", + status: "completed", + error: null, + detail: { type: "unknown", input: null, output: null }, + }, + { turnId: "run" }, + ); + const page = store.fetch("agent", { + direction: "after", + cursor: { epoch: "old", seq: 10 }, + limit: 40, + wholeTurns: true, + }); + expect(page.rows).toHaveLength(60); + expect(page).toMatchObject({ + reset: true, + staleCursor: true, + startSeq: 1, + endSeq: 60, + hasOlder: false, + hasNewer: false, + }); + }); + it("clamps an overshooting before cursor into the bounded tail window", () => { const store = new InMemoryAgentTimelineStore(); store.initialize("agent-1", { @@ -217,31 +251,29 @@ it("includes transitive tool updates in a contiguous projected tail", () => { expect(tail).toMatchObject({ startSeq: 1, endSeq: 6, hasOlder: false }); }); -it("serves tail 200 from 100,000 retained rows without stalling the loop", async () => { - const store = new InMemoryAgentTimelineStore(); - store.initialize("large"); - for (let index = 0; index < 100_000; index++) { - store.append("large", { type: "user_message", text: String(index) }); - } - // Generation and its GC are outside the measured request window. - await new Promise((resolve) => setImmediate(resolve)); - const started = performance.now(); - const probe = new Promise((resolve) => - setImmediate(() => resolve(performance.now() - started)), - ); - let page = store.fetch("large", { limit: 200 }); - for (let repeat = 1; repeat < 8; repeat++) page = store.fetch("large", { limit: 200 }); - const duration = performance.now() - started; - const loopGap = await probe; - console.info({ duration, loopGap }); - expect(page.rows).toHaveLength(200); - expect(page.startSeq).toBe(99_801); - expect(page.endSeq).toBe(100_000); - // CI runners can pause for scheduling/GC; the local per-request 2 ms gate - // is measured separately. Eight requests amplify the old full-history work. - expect(duration).toBeLessThan(50); - expect(loopGap).toBeLessThan(50); -}); +it.each([false, true])( + "serves tail 200 from 100,000 retained rows without stalling the loop (wholeTurns=%s)", + async (wholeTurns) => { + const store = new InMemoryAgentTimelineStore(); + store.initialize("large"); + for (let index = 0; index < 100_000; index++) { + store.append("large", { type: "user_message", text: String(index) }); + } + const { result: tail, blockingMs } = await measureMainThreadWork( + `timeline tail main-thread work (wholeTurns=${wholeTurns})`, + () => { + let page = store.fetch("large", { limit: 200, wholeTurns }); + for (let repeat = 1; repeat < 8; repeat++) + page = store.fetch("large", { limit: 200, wholeTurns }); + return page; + }, + ); + expect(tail.rows).toHaveLength(200); + expect(tail.startSeq).toBe(99_801); + expect(tail.endSeq).toBe(100_000); + expect(blockingMs).toBeLessThan(50); + }, +); it("matches the previous reprojecting selector on seeded randomized timelines", () => { let seed = 0x76aabbcc; diff --git a/packages/server/src/server/agent/agent-timeline-store.ts b/packages/server/src/server/agent/agent-timeline-store.ts index b9727e33e9..49b5013fd4 100644 --- a/packages/server/src/server/agent/agent-timeline-store.ts +++ b/packages/server/src/server/agent/agent-timeline-store.ts @@ -107,6 +107,7 @@ export class InMemoryAgentTimelineStore { direction: reset ? "tail" : direction, cursorSeq: cursor?.seq, limit: options?.limit ?? DEFAULT_TIMELINE_FETCH_LIMIT, + wholeTurns: options?.wholeTurns, }); return { epoch: state.epoch, diff --git a/packages/server/src/server/agent/plugin-provider.test.ts b/packages/server/src/server/agent/plugin-provider.test.ts index 72bc0f475b..8ae271c08a 100644 --- a/packages/server/src/server/agent/plugin-provider.test.ts +++ b/packages/server/src/server/agent/plugin-provider.test.ts @@ -1321,3 +1321,28 @@ test("plugin without a runtime safety capability uses baseline collection withou await rm(home, { recursive: true, force: true }); } }); + +test("plugin provider import launches with runtime MCP servers and returns only saved configuration", async () => { + const harness = createProviderHarness(); + const registry = new PluginAgentClientRegistry(createTestLogger()); + registry.replace([harness.registration]); + const client = registry.clients()[harness.registration.id]!; + const storedConfig = { provider: harness.registration.id, cwd: "/project" }; + const company = { type: "http", url: "https://tools.example.com/mcp" } as const; + try { + const imported = await client.importSession!( + { providerHandleId: 'plugin:{"version":1,"data":{"token":"root"}}', cwd: "/project" }, + { config: { ...storedConfig, mcpServers: { company } }, storedConfig }, + ); + expect(harness.inputs).toContainEqual( + expect.objectContaining({ + type: "session.open", + config: expect.objectContaining({ mcpServers: { company } }), + }), + ); + expect(imported.config).toEqual(storedConfig); + await imported.session.close(); + } finally { + await registry.shutdown(); + } +}); diff --git a/packages/server/src/server/agent/plugin-provider.ts b/packages/server/src/server/agent/plugin-provider.ts index 68e9778e43..570e339203 100644 --- a/packages/server/src/server/agent/plugin-provider.ts +++ b/packages/server/src/server/agent/plugin-provider.ts @@ -1052,7 +1052,7 @@ class PluginAgentClient implements AgentClient { if (!persistence) throw new Error(`Plugin provider '${this.provider}' did not persist import`); return { session, - config: { ...context.config, provider: this.provider, cwd: input.cwd }, + config: { ...context.storedConfig, provider: this.provider, cwd: input.cwd }, persistence, timeline: session.timelineHistory(), providerSubagentEvents: session.subagentHistory(), diff --git a/packages/server/src/server/agent/providers/claude/agent.history-load.test.ts b/packages/server/src/server/agent/providers/claude/agent.history-load.test.ts index 921b4018be..393fda60de 100644 --- a/packages/server/src/server/agent/providers/claude/agent.history-load.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.history-load.test.ts @@ -1,3 +1,4 @@ +import { measureMainThreadWork } from "../../../../test-utils/main-thread-work.js"; import fs, { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { open } from "node:fs/promises"; import { performance } from "node:perf_hooks"; @@ -117,13 +118,6 @@ describe("ClaudeAgentSession persisted history load", () => { await file.close(); } const daemon = await createTestPaseoDaemon(); - let lastTick = performance.now(); - let longestGap = 0; - const probe = setInterval(() => { - const now = performance.now(); - longestGap = Math.max(longestGap, now - lastTick); - lastTick = now; - }, 2); let loading = true; let answeredDuringLoad = false; const health = fetch(`http://127.0.0.1:${daemon.port}/api/health`).then(async (response) => { @@ -134,33 +128,39 @@ describe("ClaudeAgentSession persisted history load", () => { }); let session: AgentSession | undefined; try { - session = await resume(replyFactory("live after large resume")); - let index = 0; - for await (const event of session.streamHistory()) { - expect(event).toEqual({ - type: "timeline", - provider: "claude", - timestamp: undefined, - item: { type: "assistant_message", text: `${index}:${text}` }, - }); - index++; - } - loading = false; - await health; - await new Promise((resolve) => setTimeout(resolve, 5)); + const { + result: { resumed: loadedSession, historyCount }, + blockingMs, + } = await measureMainThreadWork("Claude history main-thread work", async () => { + const resumed = await resume(replyFactory("live after large resume")); + session = resumed; + let index = 0; + for await (const event of resumed.streamHistory()) { + expect(event).toEqual({ + type: "timeline", + provider: "claude", + timestamp: undefined, + item: { type: "assistant_message", text: `${index}:${text}` }, + }); + index++; + } + loading = false; + await health; + return { resumed, historyCount: index }; + }); console.info( JSON.stringify({ bytes, count, - longestGap, + blockingMs, answeredDuringLoad, elapsedMs: performance.now() - startedAt, }), ); - expect(index).toBe(count); - expect(longestGap).toBeLessThan(50); + expect(historyCount).toBe(count); + expect(blockingMs).toBeLessThan(50); expect(answeredDuringLoad).toBe(true); - const live = await collectSessionTurnEvents(session, "follow up"); + const live = await collectSessionTurnEvents(loadedSession, "follow up"); expect( live .filter((event) => event.type === "timeline" && event.item.type === "assistant_message") @@ -169,7 +169,6 @@ describe("ClaudeAgentSession persisted history load", () => { { type: "assistant_message", messageId: "live-reply", text: "live after large resume" }, ]); } finally { - clearInterval(probe); await session?.close(); await daemon.close(); } diff --git a/packages/server/src/server/agent/providers/claude/agent.image-rendering.test.ts b/packages/server/src/server/agent/providers/claude/agent.image-rendering.test.ts index 5a06fea7fd..a772e544d1 100644 --- a/packages/server/src/server/agent/providers/claude/agent.image-rendering.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.image-rendering.test.ts @@ -13,8 +13,8 @@ const MATERIALIZED_PNG_PATH_PATTERN = /paseo-attachments(?:-[^\\/]+)?[\\/](?:[^\\/]+[\\/])?[0-9a-f]{64}\.png$/; interface ClaudeImageTestSession { - translateMessageToEvents(message: SDKMessage): AgentStreamEvent[]; - convertHistoryEntry(entry: unknown): AgentTimelineItem[]; + translateMessageToEvents(message: SDKMessage): Promise; + convertHistoryEntry(entry: unknown): Promise; } async function createSession(): Promise { @@ -157,7 +157,7 @@ describe("Claude tool_result image rendering", () => { test("emits the image as assistant markdown and keeps base64 out of the live tool output", async () => { const session = await createSession(); - const events = session.translateMessageToEvents(imageToolResultUserMessage()); + const events = await session.translateMessageToEvents(imageToolResultUserMessage()); const timelineItems = events .filter((event) => event.type === "timeline") @@ -181,7 +181,7 @@ describe("Claude tool_result image rendering", () => { test("replays the image as assistant markdown through history conversion", async () => { const session = await createSession(); - const items = session.convertHistoryEntry(imageToolResultHistoryEntry()); + const items = await session.convertHistoryEntry(imageToolResultHistoryEntry()); const [imageMessage, ...extraImages] = imageMessages(items); expect(extraImages).toEqual([]); @@ -202,7 +202,7 @@ describe("Claude tool_result image rendering", () => { test("keeps base64 out of an errored tool_result that carries an image", async () => { const session = await createSession(); - const events = session.translateMessageToEvents(erroredImageToolResultUserMessage()); + const events = await session.translateMessageToEvents(erroredImageToolResultUserMessage()); const timelineItems = events .filter((event) => event.type === "timeline") @@ -226,7 +226,7 @@ describe("Claude tool_result image rendering", () => { test("emits one image message per image block in a multi-image tool_result", async () => { const session = await createSession(); - const events = session.translateMessageToEvents(multiImageToolResultUserMessage()); + const events = await session.translateMessageToEvents(multiImageToolResultUserMessage()); const timelineItems = events .filter((event) => event.type === "timeline") diff --git a/packages/server/src/server/agent/providers/claude/agent.test.ts b/packages/server/src/server/agent/providers/claude/agent.test.ts index 2b2c725269..52b70c25bc 100644 --- a/packages/server/src/server/agent/providers/claude/agent.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.test.ts @@ -32,7 +32,7 @@ import type { AgentAttachment } from "@getpaseo/protocol/messages"; import { buildAgentPrompt, renderPromptAttachmentAsText } from "../../prompt-attachments.js"; interface TestClaudeSession { - translateMessageToEvents(message: SDKMessage): AgentStreamEvent[]; + translateMessageToEvents(message: SDKMessage): Promise; close(): Promise; } @@ -53,7 +53,7 @@ afterEach(() => { }); describe("convertClaudeHistoryEntry", () => { - test("maps user tool results to timeline items", () => { + test("maps user tool results to timeline items", async () => { const toolUseId = "toolu_test"; const entry = { type: "user", @@ -79,14 +79,14 @@ describe("convertClaudeHistoryEntry", () => { ]; const mapBlocks = vi.fn().mockReturnValue(stubTimeline); - const result = convertClaudeHistoryEntry(entry, mapBlocks); + const result = await convertClaudeHistoryEntry(entry, mapBlocks); expect(result).toEqual(stubTimeline); expect(mapBlocks).toHaveBeenCalledTimes(1); expect(Array.isArray(mapBlocks.mock.calls[0][0])).toBe(true); }); - test("replays persisted Claude tool results as completed tool calls", () => { + test("replays persisted Claude tool results as completed tool calls", async () => { const entry = { type: "user", message: { @@ -124,12 +124,12 @@ describe("convertClaudeHistoryEntry", () => { const mapPersistedToolResultBlocks = (): AgentTimelineItem[] => completedToolCall; - expect(convertClaudeHistoryEntry(entry, mapPersistedToolResultBlocks)).toEqual( + expect(await convertClaudeHistoryEntry(entry, mapPersistedToolResultBlocks)).toEqual( completedToolCall, ); }); - test("returns user messages when no tool blocks exist", () => { + test("returns user messages when no tool blocks exist", async () => { const entry = { type: "user", message: { @@ -138,7 +138,7 @@ describe("convertClaudeHistoryEntry", () => { }, }; - expect(convertClaudeHistoryEntry(entry, () => [])).toEqual([ + expect(await convertClaudeHistoryEntry(entry, () => [])).toEqual([ { type: "user_message", text: "Run npm test", @@ -146,7 +146,7 @@ describe("convertClaudeHistoryEntry", () => { ]); }); - test("converts compact boundary metadata variants", () => { + test("converts compact boundary metadata variants", async () => { const fixtures = [ { entry: { @@ -175,7 +175,7 @@ describe("convertClaudeHistoryEntry", () => { ] as const; for (const fixture of fixtures) { - expect(convertClaudeHistoryEntry(fixture.entry, () => [])).toEqual([ + expect(await convertClaudeHistoryEntry(fixture.entry, () => [])).toEqual([ { type: "compaction", status: "completed", @@ -186,7 +186,7 @@ describe("convertClaudeHistoryEntry", () => { } }); - test("skips synthetic user entries", () => { + test("skips synthetic user entries", async () => { const entry = { type: "user", isSynthetic: true, @@ -197,13 +197,13 @@ describe("convertClaudeHistoryEntry", () => { }; const mapBlocks = vi.fn().mockReturnValue([]); - const result = convertClaudeHistoryEntry(entry, mapBlocks); + const result = await convertClaudeHistoryEntry(entry, mapBlocks); expect(result).toEqual([]); expect(mapBlocks).not.toHaveBeenCalled(); }); - test("skips meta user entries from Claude skill loading", () => { + test("skips meta user entries from Claude skill loading", async () => { const entry = { type: "user", isMeta: true, @@ -219,13 +219,13 @@ describe("convertClaudeHistoryEntry", () => { }; const mapBlocks = vi.fn().mockReturnValue([]); - const result = convertClaudeHistoryEntry(entry, mapBlocks); + const result = await convertClaudeHistoryEntry(entry, mapBlocks); expect(result).toEqual([]); expect(mapBlocks).not.toHaveBeenCalled(); }); - test("skips interrupt placeholder transcript noise", () => { + test("skips interrupt placeholder transcript noise", async () => { const interruptEntry = { type: "user", message: { @@ -246,11 +246,11 @@ describe("convertClaudeHistoryEntry", () => { .fn() .mockReturnValue([{ type: "assistant_message", text: "No response requested." }]); - expect(convertClaudeHistoryEntry(interruptEntry, mapBlocks)).toEqual([]); - expect(convertClaudeHistoryEntry(assistantNoiseEntry, mapBlocks)).toEqual([]); + expect(await convertClaudeHistoryEntry(interruptEntry, mapBlocks)).toEqual([]); + expect(await convertClaudeHistoryEntry(assistantNoiseEntry, mapBlocks)).toEqual([]); }); - test("skips messages (model switch, /context, etc.)", () => { + test("skips messages (model switch, /context, etc.)", async () => { // Real entries from Claude Code JSONL history files const modelSwitch = { type: "user", @@ -305,24 +305,24 @@ describe("convertClaudeHistoryEntry", () => { const mapBlocks = vi.fn().mockReturnValue([]); - expect(convertClaudeHistoryEntry(modelSwitch, mapBlocks)).toEqual([]); - expect(convertClaudeHistoryEntry(modelSwitchWithAnsi, mapBlocks)).toEqual([]); - expect(convertClaudeHistoryEntry(contextDump, mapBlocks)).toEqual([]); - expect(convertClaudeHistoryEntry(planMode, mapBlocks)).toEqual([]); - expect(convertClaudeHistoryEntry(goodbye, mapBlocks)).toEqual([]); - expect(convertClaudeHistoryEntry(empty, mapBlocks)).toEqual([]); + expect(await convertClaudeHistoryEntry(modelSwitch, mapBlocks)).toEqual([]); + expect(await convertClaudeHistoryEntry(modelSwitchWithAnsi, mapBlocks)).toEqual([]); + expect(await convertClaudeHistoryEntry(contextDump, mapBlocks)).toEqual([]); + expect(await convertClaudeHistoryEntry(planMode, mapBlocks)).toEqual([]); + expect(await convertClaudeHistoryEntry(goodbye, mapBlocks)).toEqual([]); + expect(await convertClaudeHistoryEntry(empty, mapBlocks)).toEqual([]); // Real user messages must NOT be filtered const realMessage = { type: "user", message: { role: "user", content: "fix the bug in auth.ts" }, }; - expect(convertClaudeHistoryEntry(realMessage, mapBlocks)).toEqual([ + expect(await convertClaudeHistoryEntry(realMessage, mapBlocks)).toEqual([ { type: "user_message", text: "fix the bug in auth.ts" }, ]); }); - test("maps task notifications to synthetic tool calls", () => { + test("maps task notifications to synthetic tool calls", async () => { const entry = { type: "system", subtype: "task_notification", @@ -333,7 +333,7 @@ describe("convertClaudeHistoryEntry", () => { output_file: "/tmp/bg-fail-1.txt", }; - expect(convertClaudeHistoryEntry(entry, () => [])).toEqual([ + expect(await convertClaudeHistoryEntry(entry, () => [])).toEqual([ { type: "tool_call", callId: "task_notification_task-note-system-1", @@ -357,7 +357,7 @@ describe("convertClaudeHistoryEntry", () => { ]); }); - test("maps queue-operation task notifications to synthetic tool calls", () => { + test("maps queue-operation task notifications to synthetic tool calls", async () => { const entry = { type: "queue-operation", operation: "enqueue", @@ -372,7 +372,7 @@ describe("convertClaudeHistoryEntry", () => { ].join("\n"), }; - expect(convertClaudeHistoryEntry(entry, () => [])).toEqual([ + expect(await convertClaudeHistoryEntry(entry, () => [])).toEqual([ { type: "tool_call", callId: "task_notification_task-note-queue-1", @@ -396,7 +396,7 @@ describe("convertClaudeHistoryEntry", () => { ]); }); - test("passes assistant content blocks through to the mapper", () => { + test("passes assistant content blocks through to the mapper", async () => { const entry = { type: "assistant", message: { @@ -414,7 +414,7 @@ describe("convertClaudeHistoryEntry", () => { ]; const mapBlocks = vi.fn().mockReturnValue(mappedTimeline); - expect(convertClaudeHistoryEntry(entry, mapBlocks)).toEqual(mappedTimeline); + expect(await convertClaudeHistoryEntry(entry, mapBlocks)).toEqual(mappedTimeline); expect(mapBlocks).toHaveBeenCalledWith(entry.message.content); }); }); @@ -1208,7 +1208,7 @@ describe("ClaudeAgentSession features", () => { input: Record, options: Record, ): Promise; - translateMessageToEvents(message: SDKMessage): AgentStreamEvent[]; + translateMessageToEvents(message: SDKMessage): Promise; }; try { @@ -1248,7 +1248,7 @@ describe("ClaudeAgentSession features", () => { ), ).resolves.toMatchObject({ behavior: "deny", interrupt: undefined }); - internal.translateMessageToEvents({ + await internal.translateMessageToEvents({ type: "command_lifecycle", command_uuid: steerUuid, state: "started", @@ -2395,7 +2395,7 @@ describe("ClaudeAgentSession context window usage", () => { test("emits canonical task snapshots from Claude TaskCreate results", async () => { const session = await createSessionForTest(); try { - session.translateMessageToEvents({ + await session.translateMessageToEvents({ type: "assistant", message: { content: [ @@ -2409,7 +2409,7 @@ describe("ClaudeAgentSession context window usage", () => { }, } as unknown as SDKMessage); - const events = session.translateMessageToEvents({ + const events = await session.translateMessageToEvents({ type: "user", message: { content: [{ type: "tool_result", tool_use_id: "task-create-1", content: "created" }], @@ -3611,9 +3611,9 @@ describe("ClaudeAgentSession context window usage", () => { ]); try { - const compactEvents = (session as unknown as TestClaudeSession).translateMessageToEvents( - createCompactBoundary(), - ); + const compactEvents = await ( + session as unknown as TestClaudeSession + ).translateMessageToEvents(createCompactBoundary()); expect(compactEvents).toContainEqual( expect.objectContaining({ type: "usage_updated", @@ -3799,7 +3799,7 @@ describe("ClaudeAgentSession context window usage", () => { test("result.result is surfaced as an assistant message when no model output was produced", async () => { const session = await createSessionForTest(); - const events = session.translateMessageToEvents({ + const events = await session.translateMessageToEvents({ type: "result", subtype: "success", result: "Unknown command: /foo-doesnt-exist", @@ -3834,7 +3834,7 @@ describe("ClaudeAgentSession context window usage", () => { test("result.result is not duplicated when the model produced output during the turn", async () => { const session = await createSessionForTest(); - const events = session.translateMessageToEvents({ + const events = await session.translateMessageToEvents({ type: "result", subtype: "success", result: "Here is the answer.", diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index 734272dd02..6516ff81b7 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -2546,7 +2546,11 @@ class ClaudeAgentSession implements AgentSession { this.input.push(sdkMessage); setTimeout(() => { if (this.activeForegroundTurnId === turnId) { - this.emitSubmittedUserMessage(sdkMessage, turnId, options?.clientMessageId); + void this.emitSubmittedUserMessage(sdkMessage, turnId, options?.clientMessageId).catch( + (error: unknown) => { + this.logger.warn({ err: error }, "Failed to emit submitted Claude message"); + }, + ); } }, 0); } catch (error) { @@ -4134,7 +4138,7 @@ class ClaudeAgentSession implements AgentSession { consecutiveInterruptAbortRecoveries = 0; if (await this.handleMissingResumedConversation(message, activeQuery)) return true; if (!this.ownsQuery(activeQuery)) return true; - this.routeSdkMessageFromPump(message); + await this.routeSdkMessageFromPump(message); return false; } finally { this.processingSdkMessages--; @@ -4258,7 +4262,7 @@ class ClaudeAgentSession implements AgentSession { return !this.closed && this.query === query; } - private routeSdkMessageFromPump(message: SDKMessage): void { + private async routeSdkMessageFromPump(message: SDKMessage): Promise { if (this.consumeSessionStateChange(message)) { return; } @@ -4292,7 +4296,7 @@ class ClaudeAgentSession implements AgentSession { "provider.claude.parsed_event", ); - const events = this.buildPumpedMessageEvents(message, identifiers.messageId, turnId); + const events = await this.buildPumpedMessageEvents(message, identifiers.messageId, turnId); if (events.length === 0) { return; @@ -4327,15 +4331,22 @@ class ClaudeAgentSession implements AgentSession { this.dispatchEvents(events); } - private buildPumpedMessageEvents( + private async buildPumpedMessageEvents( message: SDKMessage, messageIdHint: string | null, turnId: string | null, - ): AgentStreamEvent[] { - const messageEvents = this.translateMessageToEvents(message, { + ): Promise { + const query = this.query; + const messageEvents = await this.translateMessageToEvents(message, { suppressAssistantText: true, suppressReasoning: true, }); + if ( + this.closed || + this.query !== query || + (this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? null) !== turnId + ) + return []; const assistantTimelineEvents = readClaudeParentToolUseId(message) ? [] : this.timelineAssembler @@ -4480,13 +4491,13 @@ class ClaudeAgentSession implements AgentSession { return withdrewAll; } - private translateMessageToEvents( + private async translateMessageToEvents( message: SDKMessage, options?: { suppressAssistantText?: boolean; suppressReasoning?: boolean; }, - ): AgentStreamEvent[] { + ): Promise { const parentToolUseId = readClaudeParentToolUseId(message); if (parentToolUseId) { return this.translateSidechainFrameToEvents(message, parentToolUseId); @@ -4534,11 +4545,11 @@ class ClaudeAgentSession implements AgentSession { this.appendSystemMessageEvents(message, events); break; case "user": - this.appendUserMessageEvents(message, events); + await this.appendUserMessageEvents(message, events); this.appendSidechainResultEvents(message, events); break; case "assistant": { - const timelineItems = this.mapBlocksToTimeline(message.message.content, { + const timelineItems = await this.mapBlocksToTimeline(message.message.content, { suppressAssistantText: options?.suppressAssistantText ?? false, suppressReasoning: options?.suppressReasoning ?? false, }); @@ -4549,7 +4560,7 @@ class ClaudeAgentSession implements AgentSession { break; } case "stream_event": - this.appendStreamEventEvents(message, events, options); + await this.appendStreamEventEvents(message, events, options); break; case "result": this.appendResultEvents(message, events); @@ -4651,13 +4662,14 @@ class ClaudeAgentSession implements AgentSession { } } - private emitSubmittedUserMessage( + private async emitSubmittedUserMessage( message: Extract, turnId: string, clientMessageId?: string, - ): void { + ): Promise { const events: AgentStreamEvent[] = []; - this.appendUserMessageEvents(message, events); + await this.appendUserMessageEvents(message, events); + if (this.activeForegroundTurnId !== turnId) return; if (events.length === 0) { return; } @@ -4787,10 +4799,10 @@ class ClaudeAgentSession implements AgentSession { } } - private appendUserMessageEvents( + private async appendUserMessageEvents( message: Extract, events: AgentStreamEvent[], - ): void { + ): Promise { if (isSyntheticUserEntry(message)) { return; } @@ -4829,7 +4841,7 @@ class ClaudeAgentSession implements AgentSession { return; } if (Array.isArray(content)) { - this.appendUserContentArrayEvents(content, messageId, events); + await this.appendUserContentArrayEvents(content, messageId, events); } } @@ -4855,12 +4867,12 @@ class ClaudeAgentSession implements AgentSession { events.push({ type: "timeline", item, provider: "claude" }); } - private appendUserContentArrayEvents( + private async appendUserContentArrayEvents( content: ReadonlyArray, messageId: string | undefined, events: AgentStreamEvent[], - ): void { - const timelineItems = this.mapBlocksToTimeline(content, { + ): Promise { + const timelineItems = await this.mapBlocksToTimeline(content, { textMessageType: "user_message", }); for (const item of timelineItems) { @@ -4876,16 +4888,16 @@ class ClaudeAgentSession implements AgentSession { } } - private appendStreamEventEvents( + private async appendStreamEventEvents( message: Extract, events: AgentStreamEvent[], options: { suppressAssistantText?: boolean; suppressReasoning?: boolean } | undefined, - ): void { + ): Promise { const usageUpdatedEvent = this.contextUsage.buildStreamUsageEvent(message.event); if (usageUpdatedEvent) { events.push(usageUpdatedEvent); } - const timelineItems = this.mapPartialEvent(message.event, { + const timelineItems = await this.mapPartialEvent(message.event, { suppressAssistantText: options?.suppressAssistantText ?? false, suppressReasoning: options?.suppressReasoning ?? false, }); @@ -5351,7 +5363,7 @@ class ClaudeAgentSession implements AgentSession { ): Promise { const timeline: PersistedTimelineEntry[] = []; for await (const entry of entries) { - this.ingestPersistedHistoryEntry(entry, timeline, replay); + await this.ingestPersistedHistoryEntry(entry, timeline, replay); } if (timeline.length > 0) { @@ -5411,11 +5423,11 @@ class ClaudeAgentSession implements AgentSession { return replay; } - private ingestPersistedHistoryEntry( + private async ingestPersistedHistoryEntry( entry: ClaudeHistoryEntry, timeline: PersistedTimelineEntry[], replay: ClaudeReplayOwnership, - ): void { + ): Promise { if (entry.isSidechain) { return; } @@ -5429,7 +5441,7 @@ class ClaudeAgentSession implements AgentSession { ? replay.toolOwners.get(notificationToolUseId) : undefined; if (notificationOwner) { - for (const item of this.convertHistoryEntry(entry)) { + for (const item of await this.convertHistoryEntry(entry)) { this.persistedProviderSubagentEvents.push({ type: "provider_subagent", provider: "claude", @@ -5446,7 +5458,7 @@ class ClaudeAgentSession implements AgentSession { const taskSnapshot = this.taskState.observe(entry); const items = [ ...(taskSnapshot ? [taskSnapshot] : []), - ...this.convertHistoryEntry(entry).map((item) => + ...(await this.convertHistoryEntry(entry)).map((item) => labelReplayedSubagentCard(item, replay.subagentToolCalls), ), ]; @@ -5483,10 +5495,10 @@ class ClaudeAgentSession implements AgentSession { }); } - private convertHistoryEntry(entry: ClaudeHistoryEntry): AgentTimelineItem[] { - return convertClaudeHistoryEntry(entry, (content) => this.mapBlocksToTimeline(content)).map( - limitAgentTimelineItemContent, - ); + private async convertHistoryEntry(entry: ClaudeHistoryEntry): Promise { + return ( + await convertClaudeHistoryEntry(entry, (content) => this.mapBlocksToTimeline(content)) + ).map(limitAgentTimelineItemContent); } // Maps Claude content blocks into AgentTimelineItems. @@ -5501,14 +5513,14 @@ class ClaudeAgentSession implements AgentSession { // // NOTE: convertClaudeHistoryEntry uses extractUserMessageText directly instead of this function // for user entries. Both paths must produce equivalent user_message items. - private mapBlocksToTimeline( + private async mapBlocksToTimeline( content: string | ReadonlyArray, options?: { textMessageType?: "assistant_message" | "user_message"; suppressAssistantText?: boolean; suppressReasoning?: boolean; }, - ): AgentTimelineItem[] { + ): Promise { const textMessageType = options?.textMessageType ?? "assistant_message"; const suppressText = textMessageType === "assistant_message" && (options?.suppressAssistantText ?? false); @@ -5535,7 +5547,7 @@ class ClaudeAgentSession implements AgentSession { if (!isClaudeContentChunk(block) || this.mapSubagentHandbackBlock(block, items)) { continue; } - this.mapBlockToTimeline(block, { + await this.mapBlockToTimeline(block, { items, userTextParts, textMessageType, @@ -5580,7 +5592,7 @@ class ClaudeAgentSession implements AgentSession { } } - private mapBlockToTimeline( + private async mapBlockToTimeline( block: ClaudeContentChunk, context: { items: AgentTimelineItem[]; @@ -5589,7 +5601,7 @@ class ClaudeAgentSession implements AgentSession { suppressText: boolean; suppressReasoning: boolean; }, - ): void { + ): Promise { switch (block.type) { case "text": case "text_delta": @@ -5613,7 +5625,7 @@ class ClaudeAgentSession implements AgentSession { case "code_execution_tool_result": case "bash_code_execution_tool_result": case "text_editor_code_execution_tool_result": - this.handleToolResult(block, context.items); + await this.handleToolResult(block, context.items); break; default: break; @@ -5659,7 +5671,10 @@ class ClaudeAgentSession implements AgentSession { ); } - private handleToolResult(block: ClaudeContentChunk, items: AgentTimelineItem[]): void { + private async handleToolResult( + block: ClaudeContentChunk, + items: AgentTimelineItem[], + ): Promise { const entry = typeof block.tool_use_id === "string" ? this.toolUseCache.get(block.tool_use_id) : undefined; const blockToolName = typeof block.tool_name === "string" ? block.tool_name : undefined; @@ -5692,7 +5707,7 @@ class ClaudeAgentSession implements AgentSession { this.rememberSettledSubagentToolCall(settledToolCall, entry); for (const image of images) { - const imageItem = renderProviderImageOutputAsAssistantMarkdown(image, { + const imageItem = await renderProviderImageOutputAsAssistantMarkdown(image, { materialize: materializeProviderImage, }); if (imageItem) { @@ -5916,13 +5931,13 @@ class ClaudeAgentSession implements AgentSession { return false; } - private mapPartialEvent( + private async mapPartialEvent( event: SDKPartialAssistantMessage["event"], options?: { suppressAssistantText?: boolean; suppressReasoning?: boolean; }, - ): AgentTimelineItem[] { + ): Promise { if (this.updatePartialEventToolState(event)) { return []; } @@ -5930,14 +5945,14 @@ class ClaudeAgentSession implements AgentSession { switch (event.type) { case "content_block_start": return isClaudeContentChunk(event.content_block) - ? this.mapBlocksToTimeline([event.content_block], { + ? await this.mapBlocksToTimeline([event.content_block], { suppressAssistantText: options?.suppressAssistantText, suppressReasoning: options?.suppressReasoning, }) : []; case "content_block_delta": return isClaudeContentChunk(event.delta) - ? this.mapBlocksToTimeline([event.delta], { + ? await this.mapBlocksToTimeline([event.delta], { suppressAssistantText: options?.suppressAssistantText, suppressReasoning: options?.suppressReasoning, }) @@ -6300,12 +6315,14 @@ interface ClaudeHistoryEntry { [key: string]: unknown; } -function mapAssistantHistoryBlocksWithMessageId( +async function mapAssistantHistoryBlocksWithMessageId( entry: ClaudeHistoryEntry, content: string | ClaudeContentChunk[], - mapBlocks: (content: string | ClaudeContentChunk[]) => AgentTimelineItem[], -): AgentTimelineItem[] { - const items = mapBlocks(content); + mapBlocks: ( + content: string | ClaudeContentChunk[], + ) => AgentTimelineItem[] | Promise, +): Promise { + const items = await mapBlocks(content); const assistantMessageId = typeof entry.uuid === "string" && entry.uuid.length > 0 ? entry.uuid : null; if (!assistantMessageId) { @@ -6373,10 +6390,12 @@ function isProviderImageMessage(item: AgentTimelineItem): boolean { return item.type === "assistant_message" && isProviderImageMarkdown(item.text); } -export function convertClaudeHistoryEntry( +export async function convertClaudeHistoryEntry( entry: ClaudeHistoryEntry, - mapBlocks: (content: string | ClaudeContentChunk[]) => AgentTimelineItem[], -): AgentTimelineItem[] { + mapBlocks: ( + content: string | ClaudeContentChunk[], + ) => AgentTimelineItem[] | Promise, +): Promise { const preamble = convertClaudeHistoryEntryPreamble(entry); if ("shortCircuit" in preamble) { return preamble.shortCircuit; @@ -6415,7 +6434,7 @@ export function convertClaudeHistoryEntry( } if (hasToolBlock && normalizedBlocks) { - const mapped = mapBlocks(normalizedBlocks); + const mapped = await mapBlocks(normalizedBlocks); if (entry.type === "user") { // tool_result handling (handleToolResult) emits image markdown as an assistant_message // alongside the tool_call. User-entry text blocks also map to assistant_message in this path @@ -6429,7 +6448,7 @@ export function convertClaudeHistoryEntry( } if (entry.type === "assistant" && contentValue) { - return mapAssistantHistoryBlocksWithMessageId(entry, contentValue, mapBlocks); + return await mapAssistantHistoryBlocksWithMessageId(entry, contentValue, mapBlocks); } return timeline; diff --git a/packages/server/src/server/agent/providers/claude/subagents/replay-source.test.ts b/packages/server/src/server/agent/providers/claude/subagents/replay-source.test.ts index 413f39d427..f5bbf52422 100644 --- a/packages/server/src/server/agent/providers/claude/subagents/replay-source.test.ts +++ b/packages/server/src/server/agent/providers/claude/subagents/replay-source.test.ts @@ -1,3 +1,9 @@ +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { + materializeProviderImage, + renderProviderImageOutputAsAssistantMarkdown, +} from "../../provider-image-output.js"; import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk"; import { describe, expect, it } from "vitest"; @@ -660,3 +666,34 @@ describe("live and replay agree", () => { }); }); }); + +it("awaits image materialization before publishing replay observations", async () => { + const bytes = Buffer.from("async replay image fixture"); + const convertEntry = async () => { + const item = await renderProviderImageOutputAsAssistantMarkdown( + { data: bytes.toString("base64"), mimeType: "image/png" }, + { materialize: materializeProviderImage }, + ); + return item ? [item] : []; + }; + const { observations } = await observeReplaySubagents({ + parent: parentWithTaskCall(), + subagents: [ + { + agentId: AGENT_ID, + meta: { toolUseId: TOOL_USE_ID }, + entries: [{ type: "assistant", message: { content: [] } }], + }, + ], + convertEntry, + }); + const image = observations.find( + (observation) => + observation.kind === "timeline" && observation.item.type === "assistant_message", + ); + if (!image || image.kind !== "timeline" || image.item.type !== "assistant_message") + throw new Error("Expected replay image"); + const source = image.item.text.match(/!\[Image\]\(([^)]+)\)/)?.[1]; + expect(source).toBeTruthy(); + expect(await readFile(fileURLToPath(source!))).toEqual(bytes); +}); diff --git a/packages/server/src/server/agent/providers/claude/subagents/replay-source.ts b/packages/server/src/server/agent/providers/claude/subagents/replay-source.ts index 567a98dfe5..2a3ca74846 100644 --- a/packages/server/src/server/agent/providers/claude/subagents/replay-source.ts +++ b/packages/server/src/server/agent/providers/claude/subagents/replay-source.ts @@ -253,7 +253,7 @@ function observeSubtitle( async function observeSubagent( subagent: ClaudeReplaySubagentInput, parent: ClaudeReplayParentFacts, - convertEntry: (entry: ClaudeReplayEntry) => AgentTimelineItem[], + convertEntry: (entry: ClaudeReplayEntry) => AgentTimelineItem[] | Promise, toolOwners: Map, parentSubagentId?: string, ): Promise { @@ -280,7 +280,7 @@ async function observeSubagent( childTerminalStatus = entry.message?.stop_reason === "end_turn" ? "completed" : null; } recordReplayToolOwners(toolOwners, entry, link.id); - for (const item of convertEntry(entry)) { + for (const item of await convertEntry(entry)) { timeline.push({ kind: "timeline", id: link.id, item, ...(timestamp ? { timestamp } : {}) }); } } @@ -323,7 +323,7 @@ function recordReplayToolOwners( export async function observeReplaySubagents(input: { subagents: readonly ClaudeReplaySubagentInput[]; parent: ClaudeReplayParentFacts; - convertEntry: (entry: ClaudeReplayEntry) => AgentTimelineItem[]; + convertEntry: (entry: ClaudeReplayEntry) => AgentTimelineItem[] | Promise; }): Promise<{ observations: SubagentObservation[]; toolOwners: ReadonlyMap }> { const observations: SubagentObservation[] = []; const toolOwners = new Map(); diff --git a/packages/server/src/server/agent/providers/claude/subagents/workflow-replay-source.test.ts b/packages/server/src/server/agent/providers/claude/subagents/workflow-replay-source.test.ts index af03a515b7..edb673c664 100644 --- a/packages/server/src/server/agent/providers/claude/subagents/workflow-replay-source.test.ts +++ b/packages/server/src/server/agent/providers/claude/subagents/workflow-replay-source.test.ts @@ -1,3 +1,9 @@ +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { + materializeProviderImage, + renderProviderImageOutputAsAssistantMarkdown, +} from "../../provider-image-output.js"; import { describe, expect, it } from "vitest"; import { ProviderSubagentStore } from "../../../provider-subagents/store.js"; @@ -195,3 +201,29 @@ describe("Claude workflow replay", () => { }, ); }); + +it("awaits image materialization before publishing replay observations", async () => { + const bytes = Buffer.from("async replay image fixture"); + const convertEntry = async () => { + const item = await renderProviderImageOutputAsAssistantMarkdown( + { data: bytes.toString("base64"), mimeType: "image/png" }, + { materialize: materializeProviderImage }, + ); + return item ? [item] : []; + }; + const observations = await observeReplayWorkflows({ + workflows: [{ runId: RUN_ID, status: "completed" }], + parentEntries: parentEntries(), + entriesByRunId: new Map([[RUN_ID, [{ type: "assistant", message: { content: [] } }]]]), + convertEntry, + }); + const image = observations.find( + (observation) => + observation.kind === "timeline" && observation.item.type === "assistant_message", + ); + if (!image || image.kind !== "timeline" || image.item.type !== "assistant_message") + throw new Error("Expected replay image"); + const source = image.item.text.match(/!\[Image\]\(([^)]+)\)/)?.[1]; + expect(source).toBeTruthy(); + expect(await readFile(fileURLToPath(source!))).toEqual(bytes); +}); diff --git a/packages/server/src/server/agent/providers/claude/subagents/workflow-replay-source.ts b/packages/server/src/server/agent/providers/claude/subagents/workflow-replay-source.ts index 7496ce7c8d..17a278d00f 100644 --- a/packages/server/src/server/agent/providers/claude/subagents/workflow-replay-source.ts +++ b/packages/server/src/server/agent/providers/claude/subagents/workflow-replay-source.ts @@ -53,7 +53,7 @@ export async function observeReplayWorkflows(input: { string, Iterable | AsyncIterable >; - convertEntry?: (entry: ClaudeReplayEntry) => AgentTimelineItem[]; + convertEntry?: (entry: ClaudeReplayEntry) => AgentTimelineItem[] | Promise; }): Promise { const toolCallIdByRunId = await readWorkflowLinks(input.parentEntries); const observations: SubagentObservation[] = []; @@ -122,7 +122,7 @@ async function observeWorkflowTimeline(input: { id: string; result: unknown; entries: Iterable | AsyncIterable; - convertEntry?: (entry: ClaudeReplayEntry) => AgentTimelineItem[]; + convertEntry?: (entry: ClaudeReplayEntry) => AgentTimelineItem[] | Promise; finishedAt?: string; }): Promise { const observations: SubagentObservation[] = []; @@ -130,7 +130,7 @@ async function observeWorkflowTimeline(input: { for await (const entry of input.entries) { const timestamp = normalizeProviderReplayTimestamp(entry.timestamp); - const items = input.convertEntry?.(entry) ?? []; + const items = (await input.convertEntry?.(entry)) ?? []; for (const item of items) { if (item.type === "assistant_message") replayedAssistantText.add(item.text.trim()); observations.push({ diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts index 8c61635d6f..1d113fb27a 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts @@ -113,7 +113,7 @@ interface CodexSessionTestAccess { }; ensureThreadLoaded(): Promise; handleToolApprovalRequest(params: unknown): Promise; - handleNotification(method: string, params: unknown): void; + handleNotification(method: string, params: unknown): Promise; loadPersistedHistory(client: CodexClientLike | null): Promise; refreshResolvedCollaborationMode(): void; serviceTier: "fast" | null; @@ -212,6 +212,7 @@ async function startPublicSteeringSession( const started = await session.startTurn("first"); await appServer.waitForTurnStart(); appServer.startsTurn({ threadId: "thread-1", turnId: "native-A" }); + await new Promise((resolve) => setImmediate(resolve)); await new Promise((resolve) => setTimeout(resolve, 0)); return { session, paseoTurnId: started.turnId }; } @@ -354,9 +355,11 @@ describe("Codex active-turn steering admission", () => { }); await resolverEntered.promise; appServer.completeTurn({ threadId: "thread-1" }); + await new Promise((resolve) => setImmediate(resolve)); await new Promise((resolve) => setTimeout(resolve, 0)); const startedB = await session.startTurn("second"); appServer.startsTurn({ threadId: "thread-1", turnId: "native-B" }); + await new Promise((resolve) => setImmediate(resolve)); await new Promise((resolve) => setTimeout(resolve, 0)); commandResolution.resolve(null); @@ -478,6 +481,7 @@ async function startCompactionTurnTest(): Promise<{ await session.startTurn("exercise compaction lifecycle"); appServer.startsTurn({ threadId: "thread-1", turnId: "codex-turn-1" }); + await new Promise((resolve) => setImmediate(resolve)); return { appServer, session, events, terminalEvent }; } @@ -1460,7 +1464,9 @@ describe("Codex app-server provider", () => { command: "gh pr merge 2030 --squash", cwd: "/workspace/project", }); + await new Promise((resolve) => setImmediate(resolve)); appServer.says({ threadId: "thread-1", text: "Merged." }); + await new Promise((resolve) => setImmediate(resolve)); await expect(nextTimelineItem).resolves.toEqual({ type: "timeline", @@ -1504,6 +1510,7 @@ describe("Codex app-server provider", () => { command: "gh pr merge 2030 --squash", cwd: "/workspace/project", }); + await new Promise((resolve) => setImmediate(resolve)); await expect(nextTimelineItem).resolves.toEqual({ type: "timeline", @@ -1548,6 +1555,7 @@ describe("Codex app-server provider", () => { processId: "4242", text: "gh pr merge 2030 --squash\n", }); + await new Promise((resolve) => setImmediate(resolve)); await expect(nextTimelineItem).resolves.toEqual({ type: "timeline", @@ -1576,6 +1584,7 @@ describe("Codex app-server provider", () => { command: "sleep 30", output: "Process running with session id 4242", }); + await new Promise((resolve) => setImmediate(resolve)); await expect(relabeledTerminal).resolves.toEqual({ type: "timeline", @@ -1623,6 +1632,7 @@ describe("Codex app-server provider", () => { processId: "73", command, }); + await new Promise((resolve) => setImmediate(resolve)); const terminalWrite = waitForTimelineToolCall(session, "terminal-session-73-1"); appServer.typesIntoTerminal({ @@ -1632,6 +1642,7 @@ describe("Codex app-server provider", () => { processId: "73", text: "print(1)\n", }); + await new Promise((resolve) => setImmediate(resolve)); await expect(terminalWrite).resolves.toMatchObject({ item: { @@ -1668,7 +1679,9 @@ describe("Codex app-server provider", () => { processId: "4242", text: "git status\n", }); + await new Promise((resolve) => setImmediate(resolve)); + await firstTimelineItem; const secondTimelineItem = waitForNextTimelineItem(session); appServer.typesIntoTerminal({ threadId: "thread-1", @@ -1677,6 +1690,7 @@ describe("Codex app-server provider", () => { processId: "4242", text: "git push\n", }); + await new Promise((resolve) => setImmediate(resolve)); const [first, second] = await Promise.all([firstTimelineItem, secondTimelineItem]); expect(first.item).toMatchObject({ @@ -2075,9 +2089,13 @@ describe("Codex app-server provider", () => { await session.startTurn("remember first"); emitCodexUserMessage(appServer, { id: "codex-first", text: "remember first" }); appServer.completeTurn(); + await new Promise((resolve) => setImmediate(resolve)); + await new Promise((resolve) => setImmediate(resolve)); await session.startTurn("remember second"); emitCodexUserMessage(appServer, { id: "codex-second", text: "remember second" }); appServer.completeTurn(); + await new Promise((resolve) => setImmediate(resolve)); + await new Promise((resolve) => setImmediate(resolve)); await session.revertConversation({ messageId: "codex-first" }); @@ -2116,6 +2134,8 @@ describe("Codex app-server provider", () => { turnId: "turn-first", }); appServer.completeTurn(); + await new Promise((resolve) => setImmediate(resolve)); + await new Promise((resolve) => setImmediate(resolve)); await session.startTurn("remember second"); emitCodexUserMessage(appServer, { id: "codex-second", @@ -2123,6 +2143,8 @@ describe("Codex app-server provider", () => { turnId: "turn-second", }); appServer.completeTurn(); + await new Promise((resolve) => setImmediate(resolve)); + await new Promise((resolve) => setImmediate(resolve)); await session.revertConversation({ messageId: "codex-second" }); @@ -2172,6 +2194,8 @@ describe("Codex app-server provider", () => { turnId: "turn-first", }); appServer.completeTurn(); + await new Promise((resolve) => setImmediate(resolve)); + await new Promise((resolve) => setImmediate(resolve)); await session.startTurn("remember second"); emitCodexUserMessage(appServer, { id: "codex-second", @@ -2179,6 +2203,8 @@ describe("Codex app-server provider", () => { turnId: "turn-second", }); appServer.completeTurn(); + await new Promise((resolve) => setImmediate(resolve)); + await new Promise((resolve) => setImmediate(resolve)); await session.revertConversation({ messageId: "codex-first" }); @@ -2249,6 +2275,8 @@ describe("Codex app-server provider", () => { turnId: "turn-first", }); appServer.completeTurn(); + await new Promise((resolve) => setImmediate(resolve)); + await new Promise((resolve) => setImmediate(resolve)); await session.startTurn("remember second"); emitCodexUserMessage(appServer, { id: "codex-second", @@ -2256,6 +2284,8 @@ describe("Codex app-server provider", () => { turnId: "turn-second", }); appServer.completeTurn(); + await new Promise((resolve) => setImmediate(resolve)); + await new Promise((resolve) => setImmediate(resolve)); await session.revertConversation({ messageId: "codex-first" }); @@ -2290,7 +2320,7 @@ describe("Codex app-server provider", () => { const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); await session.startTurn("remember this", { clientMessageId: "client-message" }); - asInternals(session).handleNotification("turn/started", { + await asInternals(session).handleNotification("turn/started", { threadId: "thread-1", turn: { id: "turn-1" }, }); @@ -2306,6 +2336,8 @@ describe("Codex app-server provider", () => { }); expect(events.slice(0, 2).map((event) => event.type)).toEqual(["turn_started", "timeline"]); appServer.completeTurn(); + await new Promise((resolve) => setImmediate(resolve)); + await new Promise((resolve) => setImmediate(resolve)); await session.close(); }); @@ -3181,12 +3213,12 @@ describe("Codex app-server provider", () => { ]); }); - test("converts Codex collab agent notifications through the normal timeline path", () => { + test("converts Codex collab agent notifications through the normal timeline path", async () => { const session = createSession(); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); - asInternals(session).handleNotification("item/started", { + await asInternals(session).handleNotification("item/started", { threadId: "test-thread", item: { type: "collabAgentToolCall", @@ -3222,12 +3254,12 @@ describe("Codex app-server provider", () => { ]); }); - test("folds child-thread Codex activity into the parent sub-agent tool call", () => { + test("folds child-thread Codex activity into the parent sub-agent tool call", async () => { const session = createSession(); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { threadId: "test-thread", item: { type: "collabAgentToolCall", @@ -3241,12 +3273,12 @@ describe("Codex app-server provider", () => { }, }, }); - asInternals(session).handleNotification("item/agentMessage/delta", { + await asInternals(session).handleNotification("item/agentMessage/delta", { threadId: "child-thread-1", itemId: "child-message-1", delta: "Found", }); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { threadId: "child-thread-1", item: { type: "agentMessage", @@ -3254,7 +3286,7 @@ describe("Codex app-server provider", () => { text: "Found the path.", }, }); - asInternals(session).handleNotification("turn/completed", { + await asInternals(session).handleNotification("turn/completed", { threadId: "child-thread-1", turn: { status: "completed" }, }); @@ -3332,12 +3364,15 @@ describe("Codex app-server provider", () => { threadId: "settled-child-thread", agentPath: "/root/settled-child", }); + await new Promise((resolve) => setImmediate(resolve)); appServer.completeTurn({ threadId: "settled-child-thread" }); + await new Promise((resolve) => setImmediate(resolve)); appServer.says({ threadId: "settled-child-thread", itemId: "late-child-message", text: "Late trailing output.", }); + await new Promise((resolve) => setImmediate(resolve)); const providerUpserts = events.flatMap((event) => event.type === "provider_subagent" && event.event.type === "upsert" ? [event.event] : [], @@ -3361,6 +3396,7 @@ describe("Codex app-server provider", () => { agentPath: "/root/settled-child", kind: "interacted", }); + await new Promise((resolve) => setImmediate(resolve)); expect( events.findLast( (event) => event.type === "provider_subagent" && event.event.type === "upsert", @@ -3371,6 +3407,7 @@ describe("Codex app-server provider", () => { threadId: "settled-child-thread", turnId: "next-child-turn", }); + await new Promise((resolve) => setImmediate(resolve)); expect( events.findLast( (event) => event.type === "provider_subagent" && event.event.type === "upsert", @@ -3378,7 +3415,9 @@ describe("Codex app-server provider", () => { ).toMatchObject({ event: { id: "settled-child-thread", status: "running" } }); appServer.completeTurn({ threadId: "settled-child-thread" }); + await new Promise((resolve) => setImmediate(resolve)); appServer.completeTurn(); + await new Promise((resolve) => setImmediate(resolve)); await resultPromise; appServer.assertNoErrors(); } finally { @@ -3386,12 +3425,12 @@ describe("Codex app-server provider", () => { } }); - test("updates a registered child with its later native activity name", () => { + test("updates a registered child with its later native activity name", async () => { const session = createSession(); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { threadId: "test-thread", item: { type: "collabAgentToolCall", @@ -3405,7 +3444,7 @@ describe("Codex app-server provider", () => { }, }, }); - asInternals(session).handleNotification("item/started", { + await asInternals(session).handleNotification("item/started", { threadId: "test-thread", item: { type: "subAgentActivity", @@ -3440,11 +3479,11 @@ describe("Codex app-server provider", () => { }); }); - test("renders child MCP image results in the provider subagent timeline", () => { + test("renders child MCP image results in the provider subagent timeline", async () => { const session = createSession(); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { threadId: "test-thread", item: { type: "subAgentActivity", @@ -3455,7 +3494,7 @@ describe("Codex app-server provider", () => { }, }); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { threadId: "image-child-thread", item: { id: "child-mcp-image", @@ -3488,11 +3527,11 @@ describe("Codex app-server provider", () => { rmSync(source, { force: true }); }); - test("renders a child user message once across lifecycle notifications", () => { + test("renders a child user message once across lifecycle notifications", async () => { const session = createSession(); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { threadId: "test-thread", item: { type: "subAgentActivity", @@ -3508,11 +3547,11 @@ describe("Codex app-server provider", () => { content: [{ type: "text", text: "Inspect this path." }], }; - asInternals(session).handleNotification("item/started", { + await asInternals(session).handleNotification("item/started", { threadId: "user-child-thread", item: childUserMessage, }); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { threadId: "user-child-thread", item: childUserMessage, }); @@ -3546,19 +3585,24 @@ describe("Codex app-server provider", () => { threadId: "child-thread-1", agentPath: "/root/child", }); + await new Promise((resolve) => setImmediate(resolve)); appServer.says({ threadId: "child-thread-1", itemId: "child-message-1", text: "Child findings.", }); + await new Promise((resolve) => setImmediate(resolve)); appServer.completeTurn({ threadId: "child-thread-1" }); + await new Promise((resolve) => setImmediate(resolve)); appServer.says({ threadId: "thread-1", itemId: "parent-message-1", text: "Parent report.", chunks: ["Parent ", "report."], }); + await new Promise((resolve) => setImmediate(resolve)); appServer.completeTurn(); + await new Promise((resolve) => setImmediate(resolve)); const result = await resultPromise; expect(result.finalText).toBe("Parent report."); @@ -3592,13 +3636,16 @@ describe("Codex app-server provider", () => { itemId: "first-parent-message", text: "First report.", }); + await new Promise((resolve) => setImmediate(resolve)); appServer.says({ threadId: "thread-1", itemId: "second-parent-message", text: "Second report.", chunks: ["", "Second report."], }); + await new Promise((resolve) => setImmediate(resolve)); appServer.completeTurn(); + await new Promise((resolve) => setImmediate(resolve)); const result = await resultPromise; expect(result.finalText).toBe("Second report."); @@ -3623,8 +3670,11 @@ describe("Codex app-server provider", () => { await appServer.waitForTurnStart(); appServer.says({ threadId: "thread-1", text: "First report." }); + await new Promise((resolve) => setImmediate(resolve)); appServer.says({ threadId: "thread-1", text: "Second report." }); + await new Promise((resolve) => setImmediate(resolve)); appServer.completeTurn(); + await new Promise((resolve) => setImmediate(resolve)); const result = await resultPromise; expect(result.finalText).toBe("Second report."); @@ -3648,23 +3698,29 @@ describe("Codex app-server provider", () => { await appServer.waitForTurnStart(); appServer.startsTurn({ threadId: "child-thread-early" }); + await new Promise((resolve) => setImmediate(resolve)); appServer.says({ threadId: "child-thread-early", itemId: "child-message-early", text: "Early child findings.", }); + await new Promise((resolve) => setImmediate(resolve)); appServer.completeTurn({ threadId: "child-thread-early" }); + await new Promise((resolve) => setImmediate(resolve)); appServer.startsSubAgent({ callId: "spawn-child-early", threadId: "child-thread-early", agentPath: "/root/early-child", }); + await new Promise((resolve) => setImmediate(resolve)); appServer.says({ threadId: "thread-1", itemId: "parent-message-after-early-child", text: "Parent report after replay.", }); + await new Promise((resolve) => setImmediate(resolve)); appServer.completeTurn(); + await new Promise((resolve) => setImmediate(resolve)); const result = await resultPromise; expect(result.finalText).toBe("Parent report after replay."); @@ -3708,36 +3764,43 @@ describe("Codex app-server provider", () => { threadId: "child-thread-stable", agentPath: "/root/stable-child", }); + await new Promise((resolve) => setImmediate(resolve)); appServer.beginsSubAgentActivity({ callId: "message-child-stable", threadId: "child-thread-stable", agentPath: "/root/stable-child", kind: "interacted", }); + await new Promise((resolve) => setImmediate(resolve)); appServer.completesSubAgentActivity({ callId: "message-child-stable", threadId: "child-thread-stable", agentPath: "/root/stable-child", kind: "interacted", }); + await new Promise((resolve) => setImmediate(resolve)); appServer.says({ threadId: "child-thread-stable", itemId: "stable-child-message", text: "Still on the same card.", }); + await new Promise((resolve) => setImmediate(resolve)); appServer.beginsSubAgentActivity({ callId: "interrupt-child-stable", threadId: "child-thread-stable", agentPath: "/root/stable-child", kind: "interrupted", }); + await new Promise((resolve) => setImmediate(resolve)); appServer.completesSubAgentActivity({ callId: "interrupt-child-stable", threadId: "child-thread-stable", agentPath: "/root/stable-child", kind: "interrupted", }); + await new Promise((resolve) => setImmediate(resolve)); appServer.completeTurn(); + await new Promise((resolve) => setImmediate(resolve)); const result = await resultPromise; const toolCalls = result.timeline.filter((item) => item.type === "tool_call"); @@ -3772,6 +3835,7 @@ describe("Codex app-server provider", () => { await appServer.waitForTurnStart(); appServer.completeTurn({ threadId: "child-thread-fast" }); + await new Promise((resolve) => setImmediate(resolve)); const activity = { callId: "spawn-child-fast", threadId: "child-thread-fast", @@ -3779,8 +3843,11 @@ describe("Codex app-server provider", () => { kind: "started" as const, }; appServer.beginsSubAgentActivity(activity); + await new Promise((resolve) => setImmediate(resolve)); appServer.completesSubAgentActivity(activity); + await new Promise((resolve) => setImmediate(resolve)); appServer.completeTurn(); + await new Promise((resolve) => setImmediate(resolve)); const result = await resultPromise; const toolCalls = result.timeline.filter((item) => item.type === "tool_call"); @@ -3809,16 +3876,20 @@ describe("Codex app-server provider", () => { await appServer.waitForTurnStart(); appServer.completeTurn({ threadId: "child-late-compaction" }); + await new Promise((resolve) => setImmediate(resolve)); appServer.completesCompaction({ threadId: "child-late-compaction", itemId: "late-child-compaction", }); + await new Promise((resolve) => setImmediate(resolve)); appServer.startsSubAgent({ callId: "spawn-child-late-compaction", threadId: "child-late-compaction", agentPath: "/root/late-compaction", }); + await new Promise((resolve) => setImmediate(resolve)); appServer.completeTurn(); + await new Promise((resolve) => setImmediate(resolve)); const result = await resultPromise; const toolCalls = result.timeline.filter((item) => item.type === "tool_call"); @@ -3852,6 +3923,7 @@ describe("Codex app-server provider", () => { threadId: "legacy-tool-child", agentPath: "/root/legacy-tool-child", }); + await new Promise((resolve) => setImmediate(resolve)); const command = { threadId: "legacy-tool-child", callId: "legacy-child-command", @@ -3859,15 +3931,20 @@ describe("Codex app-server provider", () => { output: "child output", }; appServer.runsLegacyCommand(command); + await new Promise((resolve) => setImmediate(resolve)); appServer.completesCommand(command); + await new Promise((resolve) => setImmediate(resolve)); appServer.appliesLegacyPatch({ threadId: "legacy-tool-child", callId: "legacy-child-patch", path: "/workspace/project/src/child.ts", diff: "@@\n-old\n+new\n", }); + await new Promise((resolve) => setImmediate(resolve)); appServer.completeTurn({ threadId: "legacy-tool-child" }); + await new Promise((resolve) => setImmediate(resolve)); appServer.completeTurn(); + await new Promise((resolve) => setImmediate(resolve)); const result = await resultPromise; const toolCalls = result.timeline.filter((item) => item.type === "tool_call"); @@ -3890,12 +3967,12 @@ describe("Codex app-server provider", () => { } }); - test("keeps nested MultiAgentV2 output inside the root sub-agent card", () => { + test("keeps nested MultiAgentV2 output inside the root sub-agent card", async () => { const session = createSession(); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { threadId: "test-thread", item: { type: "subAgentActivity", @@ -3905,7 +3982,7 @@ describe("Codex app-server provider", () => { agentPath: "/root/child", }, }); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { threadId: "child-thread-root", item: { type: "subAgentActivity", @@ -3915,12 +3992,12 @@ describe("Codex app-server provider", () => { agentPath: "/root/child/grandchild", }, }); - asInternals(session).handleNotification("item/agentMessage/delta", { + await asInternals(session).handleNotification("item/agentMessage/delta", { threadId: "grandchild-thread", itemId: "grandchild-message", delta: "Grandchild findings.", }); - asInternals(session).handleNotification("turn/completed", { + await asInternals(session).handleNotification("turn/completed", { threadId: "grandchild-thread", turn: { status: "completed" }, }); @@ -3953,7 +4030,7 @@ describe("Codex app-server provider", () => { detail: { type: "sub_agent", log: expect.stringContaining("Grandchild findings.") }, }); - asInternals(session).handleNotification("turn/completed", { + await asInternals(session).handleNotification("turn/completed", { threadId: "child-thread-root", turn: { status: "completed" }, }); @@ -3963,12 +4040,12 @@ describe("Codex app-server provider", () => { }); }); - test("keeps a nested child under its spawning parent after a root interaction", () => { + test("keeps a nested child under its spawning parent after a root interaction", async () => { const session = createSession(); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { threadId: "test-thread", item: { type: "subAgentActivity", @@ -3978,7 +4055,7 @@ describe("Codex app-server provider", () => { agentPath: "/root/child", }, }); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { threadId: "child-thread-root", item: { type: "subAgentActivity", @@ -3988,7 +4065,7 @@ describe("Codex app-server provider", () => { agentPath: "/root/child/grandchild", }, }); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { threadId: "test-thread", item: { type: "collabAgentToolCall", @@ -4001,7 +4078,7 @@ describe("Codex app-server provider", () => { }, }, }); - asInternals(session).handleNotification("item/agentMessage/delta", { + await asInternals(session).handleNotification("item/agentMessage/delta", { threadId: "grandchild-thread", itemId: "grandchild-after-wait", delta: "Still nested.", @@ -4027,28 +4104,28 @@ describe("Codex app-server provider", () => { }); }); - test("never treats an unmapped foreign terminal as the root terminal", () => { + test("never treats an unmapped foreign terminal as the root terminal", async () => { const session = createSession(); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); - asInternals(session).handleNotification("turn/completed", { + await asInternals(session).handleNotification("turn/completed", { threadId: "unmapped-child-thread", turn: { status: "completed" }, }); expect(events).toEqual([]); - asInternals(session).handleNotification("turn/completed", { + await asInternals(session).handleNotification("turn/completed", { threadId: "test-thread", turn: { status: "completed" }, }); expect(events.filter((event) => event.type === "turn_completed")).toHaveLength(1); - asInternals(session).handleNotification("turn/started", { + await asInternals(session).handleNotification("turn/started", { threadId: "test-thread", turn: { id: "next-root-turn" }, }); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { threadId: "test-thread", item: { type: "subAgentActivity", @@ -4064,12 +4141,12 @@ describe("Codex app-server provider", () => { }); }); - test("routes msg-scoped legacy Codex events to their child thread", () => { + test("routes msg-scoped legacy Codex events to their child thread", async () => { const session = createSession(); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { threadId: "test-thread", item: { type: "subAgentActivity", @@ -4079,7 +4156,7 @@ describe("Codex app-server provider", () => { agentPath: "/root/legacy-envelope-child", }, }); - asInternals(session).handleNotification("codex/event/exec_command_begin", { + await asInternals(session).handleNotification("codex/event/exec_command_begin", { msg: { type: "exec_command_begin", threadId: "legacy-envelope-child", @@ -4087,7 +4164,7 @@ describe("Codex app-server provider", () => { command: "pwd", }, }); - asInternals(session).handleNotification("codex/event/task_complete", { + await asInternals(session).handleNotification("codex/event/task_complete", { msg: { type: "task_complete", thread_id: "legacy-envelope-child", @@ -4125,7 +4202,7 @@ describe("Codex app-server provider", () => { }, }); - asInternals(session).handleNotification("codex/event/task_complete", { + await asInternals(session).handleNotification("codex/event/task_complete", { msg: { type: "task_complete" }, }); expect(events.filter((event) => event.type === "turn_completed")).toHaveLength(1); @@ -4147,11 +4224,13 @@ describe("Codex app-server provider", () => { const spawn = waitForTimelineToolCall(session, "spawn-legacy-only-child"); appServer.startsTurn({ threadId: "thread-1", turnId: "turn-with-legacy-only-child" }); + await new Promise((resolve) => setImmediate(resolve)); appServer.startsLegacyOnlySubAgent({ callId: "spawn-legacy-only-child", threadId: "legacy-only-child-thread", agentPath: "/root/legacy-only-child", }); + await new Promise((resolve) => setImmediate(resolve)); await expect(child).resolves.toMatchObject({ type: "provider_subagent", @@ -4179,6 +4258,7 @@ describe("Codex app-server provider", () => { }); appServer.completeTurn(); + await new Promise((resolve) => setImmediate(resolve)); await resultPromise; appServer.assertNoErrors(); } finally { @@ -4203,10 +4283,12 @@ describe("Codex app-server provider", () => { const resultPromise = session.run("Wait for the child."); await appServer.waitForTurnStart(); appServer.startsTurn({ threadId: "thread-1", turnId: "turn-waiting-for-child" }); + await new Promise((resolve) => setImmediate(resolve)); await expect(session.interrupt()).rejects.toThrow("A foreground turn is already active"); appServer.completeTurn(); + await new Promise((resolve) => setImmediate(resolve)); await resultPromise; appServer.assertNoErrors(); } finally { @@ -4231,10 +4313,12 @@ describe("Codex app-server provider", () => { const resultPromise = session.run("Wait for the child."); await appServer.waitForTurnStart(); appServer.startsTurn({ threadId: "thread-1", turnId: "turn-already-idle" }); + await new Promise((resolve) => setImmediate(resolve)); await expect(session.interrupt()).resolves.toBeUndefined(); appServer.completeTurn(); + await new Promise((resolve) => setImmediate(resolve)); await resultPromise; appServer.assertNoErrors(); } finally { @@ -4263,10 +4347,12 @@ describe("Codex app-server provider", () => { const interruptPromise = session.interrupt(); appServer.startsTurn({ threadId: "thread-1", turnId: "turn-identified-late" }); + await new Promise((resolve) => setImmediate(resolve)); await interruptPromise; expect(interruptedTurns).toEqual([{ threadId: "thread-1", turnId: "turn-identified-late" }]); appServer.completeTurn(); + await new Promise((resolve) => setImmediate(resolve)); await resultPromise; appServer.assertNoErrors(); } finally { @@ -4294,6 +4380,7 @@ describe("Codex app-server provider", () => { await appServer.waitForTurnStart(); const interruptPromise = session.interrupt(); appServer.completeTurn(); + await new Promise((resolve) => setImmediate(resolve)); await expect(interruptPromise).resolves.toBeUndefined(); await resultPromise; @@ -4353,7 +4440,9 @@ describe("Codex app-server provider", () => { const nativeStart = await appServer.waitForTurnStart().catch(() => null); if (nativeStart) { appServer.startsTurn({ threadId: "thread-1", turnId: "cleanup-turn" }); + await new Promise((resolve) => setImmediate(resolve)); appServer.completeTurn(); + await new Promise((resolve) => setImmediate(resolve)); } await resultPromise.catch(() => undefined); await session.close(); @@ -4371,7 +4460,7 @@ describe("Codex app-server provider", () => { }, }; - asInternals(session).handleNotification("turn/started", { + await asInternals(session).handleNotification("turn/started", { threadId: "test-thread", turn: { id: "autonomous-turn" }, }); @@ -4387,13 +4476,13 @@ describe("Codex app-server provider", () => { }); }); - test("never replaces the root identity with an early child thread start", () => { + test("never replaces the root identity with an early child thread start", async () => { const session = createSession(); - asInternals(session).handleNotification("thread/started", { + await asInternals(session).handleNotification("thread/started", { thread: { id: "child-thread-started-early" }, }); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { threadId: "test-thread", item: { type: "subAgentActivity", @@ -4407,12 +4496,12 @@ describe("Codex app-server provider", () => { expect(session.currentThreadId).toBe("test-thread"); }); - test("does not leak aggregate child telemetry into the root timeline", () => { + test("does not leak aggregate child telemetry into the root timeline", async () => { const session = createSession(); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { threadId: "test-thread", item: { type: "subAgentActivity", @@ -4424,7 +4513,7 @@ describe("Codex app-server provider", () => { }); const eventCountAfterSpawn = events.length; - asInternals(session).handleNotification("turn/plan/updated", { + await asInternals(session).handleNotification("turn/plan/updated", { threadId: "child-thread-telemetry", plan: [{ step: "Child-only plan", status: "inProgress" }], }); @@ -4432,12 +4521,12 @@ describe("Codex app-server provider", () => { expect(events).toHaveLength(eventCountAfterSpawn); }); - test("keeps child context compaction inside the child card", () => { + test("keeps child context compaction inside the child card", async () => { const session = createSession(); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { threadId: "test-thread", item: { type: "subAgentActivity", @@ -4447,11 +4536,11 @@ describe("Codex app-server provider", () => { agentPath: "/root/compacting-child", }, }); - asInternals(session).handleNotification("item/started", { + await asInternals(session).handleNotification("item/started", { threadId: "child-thread-compaction", item: { type: "contextCompaction", id: "child-compaction" }, }); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { threadId: "child-thread-compaction", item: { type: "contextCompaction", id: "child-compaction" }, }); @@ -4471,12 +4560,12 @@ describe("Codex app-server provider", () => { }); }); - test("keeps the parent sub-agent running when a child command fails during the child turn", () => { + test("keeps the parent sub-agent running when a child command fails during the child turn", async () => { const session = createSession(); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { threadId: "test-thread", item: { type: "collabAgentToolCall", @@ -4490,7 +4579,7 @@ describe("Codex app-server provider", () => { }, }, }); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { threadId: "child-thread-1", item: { type: "commandExecution", @@ -4517,12 +4606,12 @@ describe("Codex app-server provider", () => { }); }); - test("does not synthesize a parent sub-agent failure from child error state alone", () => { + test("does not synthesize a parent sub-agent failure from child error state alone", async () => { const session = createSession(); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { threadId: "test-thread", item: { type: "collabAgentToolCall", @@ -4773,11 +4862,11 @@ describe("Codex app-server provider", () => { const liveEvents: AgentStreamEvent[] = []; session.subscribe((event) => liveEvents.push(event)); - asInternals(session).handleNotification("turn/started", { + await asInternals(session).handleNotification("turn/started", { threadId: "v2-child-thread", turn: { id: "v2-child-turn-after-resume" }, }); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { threadId: "test-thread", item: { type: "subAgentActivity", @@ -4787,7 +4876,7 @@ describe("Codex app-server provider", () => { agentPath: "/root/v2-child", }, }); - asInternals(session).handleNotification("item/agentMessage/delta", { + await asInternals(session).handleNotification("item/agentMessage/delta", { threadId: "v2-child-thread", itemId: "v2-child-message-after-resume", delta: "More findings after resume.", @@ -4805,11 +4894,11 @@ describe("Codex app-server provider", () => { }); liveEvents.length = 0; - asInternals(session).handleNotification("turn/started", { + await asInternals(session).handleNotification("turn/started", { threadId: "legacy-child-thread", turn: { id: "legacy-child-turn-after-resume" }, }); - asInternals(session).handleNotification("item/agentMessage/delta", { + await asInternals(session).handleNotification("item/agentMessage/delta", { threadId: "legacy-child-thread", itemId: "legacy-child-message-after-resume", delta: "Legacy findings after resume.", @@ -5058,6 +5147,7 @@ describe("Codex app-server provider", () => { threadId: "child-thread", agentPath: "/root/child", }); + await new Promise((resolve) => setImmediate(resolve)); await child; appServer.beginsSubAgentActivity({ callId: "parent-interacted", @@ -5066,7 +5156,9 @@ describe("Codex app-server provider", () => { kind: "interacted", agentPath: "/root", }); + await new Promise((resolve) => setImmediate(resolve)); appServer.completeTurn(); + await new Promise((resolve) => setImmediate(resolve)); await resultPromise; expect(providerSubagentIds).toEqual(new Set(["child-thread"])); @@ -5198,7 +5290,7 @@ describe("Codex app-server provider", () => { ]); }); - test("captures live Codex user message ids from item events", () => { + test("captures live Codex user message ids from item events", async () => { const session = createSession(); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); @@ -5209,11 +5301,11 @@ describe("Codex app-server provider", () => { content: [{ type: "text", text: "Use the native Codex id." }], }; - asInternals(session).handleNotification("item/started", { + await asInternals(session).handleNotification("item/started", { threadId: "test-thread", item: userMessageItem, }); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { threadId: "test-thread", item: userMessageItem, }); @@ -5232,19 +5324,19 @@ describe("Codex app-server provider", () => { ]); }); - test("emits Codex context compaction markers from live thread items", () => { + test("emits Codex context compaction markers from live thread items", async () => { const session = createSession(); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); - asInternals(session).handleNotification("item/started", { + await asInternals(session).handleNotification("item/started", { threadId: "test-thread", item: { type: "contextCompaction", id: "compact-live", }, }); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { threadId: "test-thread", item: { type: "contextCompaction", @@ -5282,7 +5374,9 @@ describe("Codex app-server provider", () => { threadId: "thread-1", itemId: "compact-without-completion", }); + await new Promise((resolve) => setImmediate(resolve)); appServer.completeTurn(); + await new Promise((resolve) => setImmediate(resolve)); await terminalEvent; expect( @@ -5304,11 +5398,14 @@ describe("Codex app-server provider", () => { threadId: "thread-1", itemId: "compact-completed-normally", }); + await new Promise((resolve) => setImmediate(resolve)); appServer.completesCompaction({ threadId: "thread-1", itemId: "compact-completed-normally", }); + await new Promise((resolve) => setImmediate(resolve)); appServer.completeTurn(); + await new Promise((resolve) => setImmediate(resolve)); await terminalEvent; expect( @@ -5327,8 +5424,11 @@ describe("Codex app-server provider", () => { try { appServer.startsCompaction({ threadId: "thread-1", itemId: "current-compaction" }); + await new Promise((resolve) => setImmediate(resolve)); appServer.completesCompaction({ threadId: "thread-1", itemId: "older-compaction" }); + await new Promise((resolve) => setImmediate(resolve)); appServer.completeTurn(); + await new Promise((resolve) => setImmediate(resolve)); await terminalEvent; expect( @@ -5350,10 +5450,12 @@ describe("Codex app-server provider", () => { try { appServer.startsCompaction({ threadId: "thread-1", itemId: `compact-${status}` }); + await new Promise((resolve) => setImmediate(resolve)); appServer.completeTurn({ status, error: status === "failed" ? { message: "Compaction failed" } : null, }); + await new Promise((resolve) => setImmediate(resolve)); await terminalEvent; expect( @@ -5367,17 +5469,17 @@ describe("Codex app-server provider", () => { } }); - test("emits and dedupes Codex thread/compacted notifications", () => { + test("emits and dedupes Codex thread/compacted notifications", async () => { const session = createSession(); session.activeForegroundTurnId = null; const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); - asInternals(session).handleNotification("thread/compacted", { + await asInternals(session).handleNotification("thread/compacted", { threadId: "test-thread", turnId: "legacy-compact-turn", }); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { threadId: "test-thread", item: { type: "contextCompaction", @@ -5398,17 +5500,17 @@ describe("Codex app-server provider", () => { ]); }); - test("emits consecutive Codex thread/compacted notifications", () => { + test("emits consecutive Codex thread/compacted notifications", async () => { const session = createSession(); session.activeForegroundTurnId = null; const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); - asInternals(session).handleNotification("thread/compacted", { + await asInternals(session).handleNotification("thread/compacted", { threadId: "test-thread", turnId: "legacy-compact-turn-1", }); - asInternals(session).handleNotification("thread/compacted", { + await asInternals(session).handleNotification("thread/compacted", { threadId: "test-thread", turnId: "legacy-compact-turn-2", }); @@ -5534,14 +5636,14 @@ describe("Codex app-server provider", () => { const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); await handler?.run({ emit: (event) => events.push(event) }); - asInternals(session).handleNotification("item/started", { + await asInternals(session).handleNotification("item/started", { threadId: "test-thread", item: { type: "contextCompaction", id: "manual-compact", }, }); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { threadId: "test-thread", item: { type: "contextCompaction", @@ -5656,23 +5758,23 @@ describe("Codex app-server provider", () => { }); }); - test("emits a synthetic plan approval permission after a successful Codex plan turn", () => { + test("emits a synthetic plan approval permission after a successful Codex plan turn", async () => { const session = createSession({ featureValues: { plan_mode: true, fast_mode: true }, }); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); - asInternals(session).handleNotification("turn/started", { + await asInternals(session).handleNotification("turn/started", { turn: { id: "turn-plan-1" }, }); - asInternals(session).handleNotification("turn/plan/updated", { + await asInternals(session).handleNotification("turn/plan/updated", { plan: [ { step: "Inspect the existing auth flow", status: "completed" }, { step: "Implement the button behavior", status: "pending" }, ], }); - asInternals(session).handleNotification("turn/completed", { + await asInternals(session).handleNotification("turn/completed", { turn: { status: "completed", error: null }, }); @@ -5726,24 +5828,24 @@ describe("Codex app-server provider", () => { }); }); - test("does not complete Codex plan timeline cards while plan approval is pending", () => { + test("does not complete Codex plan timeline cards while plan approval is pending", async () => { const session = createSession({ featureValues: { plan_mode: true, fast_mode: true }, }); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); - asInternals(session).handleNotification("turn/started", { + await asInternals(session).handleNotification("turn/started", { turn: { id: "turn-plan-thread-item" }, }); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { item: { id: "plan-item-1", type: "plan", text: "- Inspect README\n- Add a short note", }, }); - asInternals(session).handleNotification("turn/completed", { + await asInternals(session).handleNotification("turn/completed", { turn: { status: "completed", error: null }, }); @@ -5772,28 +5874,28 @@ describe("Codex app-server provider", () => { }); }); - test("replaces a pending synthetic plan approval when a later plan completes", () => { + test("replaces a pending synthetic plan approval when a later plan completes", async () => { const session = createSession({ featureValues: { plan_mode: true }, }); - asInternals(session).handleNotification("turn/started", { + await asInternals(session).handleNotification("turn/started", { turn: { id: "turn-plan-first" }, }); - asInternals(session).handleNotification("turn/plan/updated", { + await asInternals(session).handleNotification("turn/plan/updated", { plan: [{ step: "Implement the first plan", status: "pending" }], }); - asInternals(session).handleNotification("turn/completed", { + await asInternals(session).handleNotification("turn/completed", { turn: { status: "completed", error: null }, }); - asInternals(session).handleNotification("turn/started", { + await asInternals(session).handleNotification("turn/started", { turn: { id: "turn-plan-second" }, }); - asInternals(session).handleNotification("turn/plan/updated", { + await asInternals(session).handleNotification("turn/plan/updated", { plan: [{ step: "Implement the revised plan", status: "pending" }], }); - asInternals(session).handleNotification("turn/completed", { + await asInternals(session).handleNotification("turn/completed", { turn: { status: "completed", error: null }, }); @@ -5812,13 +5914,13 @@ describe("Codex app-server provider", () => { const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); - asInternals(session).handleNotification("turn/started", { + await asInternals(session).handleNotification("turn/started", { turn: { id: "turn-plan-pending" }, }); - asInternals(session).handleNotification("turn/plan/updated", { + await asInternals(session).handleNotification("turn/plan/updated", { plan: [{ step: "Implement the original plan", status: "pending" }], }); - asInternals(session).handleNotification("turn/completed", { + await asInternals(session).handleNotification("turn/completed", { turn: { status: "completed", error: null }, }); const pendingPlan = session.getPendingPermissions()[0]; @@ -5852,13 +5954,13 @@ describe("Codex app-server provider", () => { featureValues: { plan_mode: true }, }); - asInternals(session).handleNotification("turn/started", { + await asInternals(session).handleNotification("turn/started", { turn: { id: "turn-plan-pending" }, }); - asInternals(session).handleNotification("turn/plan/updated", { + await asInternals(session).handleNotification("turn/plan/updated", { plan: [{ step: "Implement the original plan", status: "pending" }], }); - asInternals(session).handleNotification("turn/completed", { + await asInternals(session).handleNotification("turn/completed", { turn: { status: "completed", error: null }, }); const pendingPlan = session.getPendingPermissions()[0]; @@ -5906,13 +6008,13 @@ describe("Codex app-server provider", () => { featureValues: { plan_mode: true }, }); - asInternals(session).handleNotification("turn/started", { + await asInternals(session).handleNotification("turn/started", { turn: { id: "turn-plan-pending" }, }); - asInternals(session).handleNotification("turn/plan/updated", { + await asInternals(session).handleNotification("turn/plan/updated", { plan: [{ step: "Implement the original plan", status: "pending" }], }); - asInternals(session).handleNotification("turn/completed", { + await asInternals(session).handleNotification("turn/completed", { turn: { status: "completed", error: null }, }); @@ -5937,10 +6039,10 @@ describe("Codex app-server provider", () => { const startTurn = session.startTurn("Revise the plan"); await promptRequested; - asInternals(session).handleNotification("turn/plan/updated", { + await asInternals(session).handleNotification("turn/plan/updated", { plan: [{ step: "Implement the newer plan", status: "pending" }], }); - asInternals(session).handleNotification("turn/completed", { + await asInternals(session).handleNotification("turn/completed", { turn: { status: "completed", error: null }, }); acceptPrompt?.(); @@ -5956,13 +6058,13 @@ describe("Codex app-server provider", () => { featureValues: { plan_mode: true }, }); - asInternals(session).handleNotification("turn/started", { + await asInternals(session).handleNotification("turn/started", { turn: { id: "turn-plan-pending" }, }); - asInternals(session).handleNotification("turn/plan/updated", { + await asInternals(session).handleNotification("turn/plan/updated", { plan: [{ step: "Implement the original plan", status: "pending" }], }); - asInternals(session).handleNotification("turn/completed", { + await asInternals(session).handleNotification("turn/completed", { turn: { status: "completed", error: null }, }); const pendingPlan = session.getPendingPermissions()[0]; @@ -5982,12 +6084,12 @@ describe("Codex app-server provider", () => { expect(session.getPendingPermissions()).toEqual([]); }); - test("emits imageView paths with spaces as valid assistant markdown images", () => { + test("emits imageView paths with spaces as valid assistant markdown images", async () => { const session = createSession(); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { item: { id: "image-view-1", type: "imageView", @@ -6013,12 +6115,12 @@ describe("Codex app-server provider", () => { ["saved_path", { saved_path: "/tmp/generated-snake.png" }, "file:///tmp/generated-snake.png"], ])( "emits imageGeneration thread items with %s as assistant markdown images", - (_fieldName, imageFields, expectedPath) => { + async (_fieldName, imageFields, expectedPath) => { const session = createSession(); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { item: { id: `image-generation-${_fieldName}`, type: "imageGeneration", @@ -6041,12 +6143,12 @@ describe("Codex app-server provider", () => { }, ); - test("materializes imageGeneration base64 results before rendering markdown", () => { + test("materializes imageGeneration base64 results before rendering markdown", async () => { const session = createSession(); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { item: { id: "image-generation-base64", type: "imageGeneration", @@ -6194,21 +6296,22 @@ describe("Codex app-server provider", () => { } }); - test("ignores incomplete imageGeneration thread items without failing the turn", () => { + test("ignores incomplete imageGeneration thread items without failing the turn", async () => { const session = createSession(); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); - expect(() => - asInternals(session).handleNotification("item/completed", { - item: { - id: "image-generation-incomplete", - type: "imageGeneration", - status: "in_progress", - }, - }), + expect( + async () => + await asInternals(session).handleNotification("item/completed", { + item: { + id: "image-generation-incomplete", + type: "imageGeneration", + status: "in_progress", + }, + }), ).not.toThrow(); - asInternals(session).handleNotification("turn/completed", { + await asInternals(session).handleNotification("turn/completed", { turn: { status: "completed", error: null }, }); @@ -6231,12 +6334,12 @@ describe("Codex app-server provider", () => { ["no failure details", null, { message: "Image generation failed" }], ])( "emits failed imageGeneration items with %s as failed tool calls", - (_label, failure, expectedError) => { + async (_label, failure, expectedError) => { const session = createSession(); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { item: { id: "image-generation-failed", type: "imageGeneration", @@ -6323,12 +6426,12 @@ describe("Codex app-server provider", () => { ]); }); - test("emits usage_updated on token usage updates and keeps usage on turn completion", () => { + test("emits usage_updated on token usage updates and keeps usage on turn completion", async () => { const session = createSession(); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); - asInternals(session).handleNotification("thread/tokenUsage/updated", { + await asInternals(session).handleNotification("thread/tokenUsage/updated", { tokenUsage: { model_context_window: 200000, last: { @@ -6339,7 +6442,7 @@ describe("Codex app-server provider", () => { }, }, }); - asInternals(session).handleNotification("turn/completed", { + await asInternals(session).handleNotification("turn/completed", { turn: { status: "completed", error: null }, }); @@ -6369,20 +6472,20 @@ describe("Codex app-server provider", () => { }); }); - test("streams Codex assistant message deltas and does not replay completed text", () => { + test("streams Codex assistant message deltas and does not replay completed text", async () => { const session = createSession(); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); - asInternals(session).handleNotification("item/agentMessage/delta", { + await asInternals(session).handleNotification("item/agentMessage/delta", { itemId: "assistant-item-1", delta: "Hel", }); - asInternals(session).handleNotification("item/agentMessage/delta", { + await asInternals(session).handleNotification("item/agentMessage/delta", { itemId: "assistant-item-1", delta: "lo", }); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { item: { id: "assistant-item-1", type: "agentMessage", @@ -6406,20 +6509,20 @@ describe("Codex app-server provider", () => { ]); }); - test("emits only the missing assistant suffix when completed text extends streamed deltas", () => { + test("emits only the missing assistant suffix when completed text extends streamed deltas", async () => { const session = createSession(); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); - asInternals(session).handleNotification("item/agentMessage/delta", { + await asInternals(session).handleNotification("item/agentMessage/delta", { itemId: "assistant-item-2", delta: "Hel", }); - asInternals(session).handleNotification("item/agentMessage/delta", { + await asInternals(session).handleNotification("item/agentMessage/delta", { itemId: "assistant-item-2", delta: "lo", }); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { item: { id: "assistant-item-2", type: "agentMessage", @@ -6449,24 +6552,24 @@ describe("Codex app-server provider", () => { ]); }); - test("emits a markdown divider when a new Codex assistant item starts after the previous one completed", () => { + test("emits a markdown divider when a new Codex assistant item starts after the previous one completed", async () => { const session = createSession(); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); - asInternals(session).handleNotification("item/agentMessage/delta", { + await asInternals(session).handleNotification("item/agentMessage/delta", { itemId: "assistant-item-3", delta: "I’m in the waiting phase now. The next read is intentionally delayed so we get meaningful CI state instead of churn.", }); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { item: { id: "assistant-item-3", type: "agentMessage", text: "I’m in the waiting phase now. The next read is intentionally delayed so we get meaningful CI state instead of churn.", }, }); - asInternals(session).handleNotification("item/agentMessage/delta", { + await asInternals(session).handleNotification("item/agentMessage/delta", { itemId: "assistant-item-4", delta: "CI is still cooking. I’m staying on the current run rather than jumping around, because the first red job will tell us exactly whether anything else needs work.", @@ -6496,20 +6599,20 @@ describe("Codex app-server provider", () => { ]); }); - test("streams Codex reasoning deltas and does not replay completed reasoning", () => { + test("streams Codex reasoning deltas and does not replay completed reasoning", async () => { const session = createSession(); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); - asInternals(session).handleNotification("item/reasoning/summaryTextDelta", { + await asInternals(session).handleNotification("item/reasoning/summaryTextDelta", { itemId: "reasoning-item-1", delta: "Think", }); - asInternals(session).handleNotification("item/reasoning/summaryTextDelta", { + await asInternals(session).handleNotification("item/reasoning/summaryTextDelta", { itemId: "reasoning-item-1", delta: "ing", }); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { item: { id: "reasoning-item-1", type: "reasoning", @@ -6533,20 +6636,20 @@ describe("Codex app-server provider", () => { ]); }); - test("emits only the missing reasoning suffix when completed reasoning extends streamed deltas", () => { + test("emits only the missing reasoning suffix when completed reasoning extends streamed deltas", async () => { const session = createSession(); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); - asInternals(session).handleNotification("item/reasoning/summaryTextDelta", { + await asInternals(session).handleNotification("item/reasoning/summaryTextDelta", { itemId: "reasoning-item-2", delta: "Think", }); - asInternals(session).handleNotification("item/reasoning/summaryTextDelta", { + await asInternals(session).handleNotification("item/reasoning/summaryTextDelta", { itemId: "reasoning-item-2", delta: "ing", }); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { item: { id: "reasoning-item-2", type: "reasoning", @@ -6583,13 +6686,13 @@ describe("Codex app-server provider", () => { const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); - asInternals(session).handleNotification("turn/started", { + await asInternals(session).handleNotification("turn/started", { turn: { id: "turn-plan-2" }, }); - asInternals(session).handleNotification("turn/plan/updated", { + await asInternals(session).handleNotification("turn/plan/updated", { plan: [{ step: "Implement the new flow", status: "pending" }], }); - asInternals(session).handleNotification("turn/completed", { + await asInternals(session).handleNotification("turn/completed", { turn: { status: "completed", error: null }, }); @@ -6637,13 +6740,13 @@ describe("Codex app-server provider", () => { const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); - asInternals(session).handleNotification("turn/started", { + await asInternals(session).handleNotification("turn/started", { turn: { id: "turn-plan-3" }, }); - asInternals(session).handleNotification("turn/plan/updated", { + await asInternals(session).handleNotification("turn/plan/updated", { plan: [{ step: "Implement the safe flow", status: "pending" }], }); - asInternals(session).handleNotification("turn/completed", { + await asInternals(session).handleNotification("turn/completed", { turn: { status: "completed", error: null }, }); @@ -6705,13 +6808,13 @@ describe("Codex app-server provider", () => { const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); - asInternals(session).handleNotification("turn/started", { + await asInternals(session).handleNotification("turn/started", { turn: { id: "turn-plan-4" }, }); - asInternals(session).handleNotification("turn/plan/updated", { + await asInternals(session).handleNotification("turn/plan/updated", { plan: [{ step: "Implement the fast flow", status: "pending" }], }); - asInternals(session).handleNotification("turn/completed", { + await asInternals(session).handleNotification("turn/completed", { turn: { status: "completed", error: null }, }); @@ -7182,6 +7285,7 @@ test("Codex runtime safety queries live terminals and retains unsupported app se await appServer.waitForTurnStart(); expect(await session.canStopRuntime()).toBe(false); appServer.completeTurn({ threadId: "thread-1" }); + await new Promise((resolve) => setImmediate(resolve)); await expect.poll(() => session.canStopRuntime()).toBe(true); response = { data: [{ processId: "shell-1" }], nextCursor: null }; expect(await session.canStopRuntime()).toBe(false); @@ -7199,3 +7303,60 @@ test("Codex runtime safety queries live terminals and retains unsupported app se await session.close(); } }); + +test("publishes a materialized image before dispatching the following turn completion", async () => { + const appServer = createFakeCodexAppServer(); + const session = new CodexAppServerAgentSession( + createConfig(), + null, + createTestLogger(), + async () => appServer.child, + ); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + try { + await session.startTurn("generate an image"); + const data = Buffer.alloc(1024 * 1024, 47).toString("base64"); + const imageFrame = + JSON.stringify({ + method: "item/completed", + params: { + item: { + id: "ordered-image", + type: "imageGeneration", + status: "completed", + result: { data, mimeType: "image/png" }, + }, + }, + }) + "\n"; + const completed = new Promise((resolve) => + session.subscribe((event) => { + if (event.type === "turn_completed") resolve(); + }), + ); + appServer.child.stdout.write(imageFrame); + appServer.child.stdout.write( + JSON.stringify({ + method: "turn/completed", + params: { turn: { status: "completed", error: null } }, + }) + "\n", + ); + await completed; + await new Promise((resolve) => setTimeout(resolve, 5)); + const imageIndex = events.findIndex( + (event) => + event.type === "timeline" && + event.item.type === "assistant_message" && + event.item.text.includes("![Image]"), + ); + const completedIndex = events.findIndex((event) => event.type === "turn_completed"); + expect(imageIndex).toBeGreaterThanOrEqual(0); + expect(imageIndex).toBeLessThan(completedIndex); + const imageEvent = events[imageIndex]; + if (imageEvent.type !== "timeline" || imageEvent.item.type !== "assistant_message") + throw new Error("Expected image"); + expect(existsSync(markdownImageSource(imageEvent.item.text))).toBe(true); + } finally { + await session.close(); + } +}); diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index f438c98ec4..291841c730 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -1939,12 +1939,12 @@ function codexImageOutputFromResult(result: unknown): ProviderImageOutput | null }; } -function mapCodexThreadImageItem( +async function mapCodexThreadImageItem( normalizedType: string, normalizedItem: Record, -): AgentTimelineItem | null { +): Promise { if (normalizedType === "imageView") { - return renderProviderImageOutputAsAssistantMarkdown({ + return await renderProviderImageOutputAsAssistantMarkdown({ path: firstStringField(normalizedItem, ["path"]), }); } @@ -1960,7 +1960,7 @@ function mapCodexThreadImageItem( const savedPath = firstStringField(normalizedItem, ["savedPath", "saved_path"]); const result = codexImageOutputFromResult(normalizedItem.result); - return renderProviderImageOutputAsAssistantMarkdown( + return await renderProviderImageOutputAsAssistantMarkdown( { path: savedPath ?? result?.path ?? null, url: result?.url ?? null, @@ -1982,10 +1982,10 @@ function mapCodexAgentMessage(item: Record): AgentTimelineItem }; } -export function threadItemToTimeline( +export async function threadItemToTimeline( item: unknown, options?: { includeUserMessage?: boolean; cwd?: string | null }, -): AgentTimelineItem | null { +): Promise { const itemRecord = toObjectRecord(item); if (!itemRecord) return null; const includeUserMessage = options?.includeUserMessage ?? true; @@ -1999,7 +1999,7 @@ export function threadItemToTimeline( : itemRecord; if (normalizedType === "imageView" || normalizedType === "imageGeneration") { - return mapCodexThreadImageItem(normalizedType, normalizedItem); + return await mapCodexThreadImageItem(normalizedType, normalizedItem); } if (normalizedType && CODEX_TOOL_THREAD_ITEM_TYPES.has(normalizedType)) { return mapCodexToolCallFromThreadItem(normalizedItem, { cwd }); @@ -2024,7 +2024,7 @@ export function threadItemToTimeline( } } -function mcpToolResultImagesToTimeline(item: unknown): AgentTimelineItem[] { +async function mcpToolResultImagesToTimeline(item: unknown): Promise { const itemRecord = toObjectRecord(item); if (!itemRecord) { return []; @@ -2037,24 +2037,25 @@ function mcpToolResultImagesToTimeline(item: unknown): AgentTimelineItem[] { } const { images } = splitCodexMcpToolResultImages(itemRecord.result); - return images - .map((image) => - renderProviderImageOutputAsAssistantMarkdown(image, { - materialize: materializeProviderImage, - }), - ) - .filter((timelineItem): timelineItem is AgentTimelineItem => timelineItem !== null); + const items: AgentTimelineItem[] = []; + for (const image of images) { + const imageItem = await renderProviderImageOutputAsAssistantMarkdown(image, { + materialize: materializeProviderImage, + }); + if (imageItem) items.push(imageItem); + } + return items; } -function threadItemToTimelineEntries( +async function threadItemToTimelineEntries( item: unknown, options?: { includeUserMessage?: boolean; cwd?: string | null }, -): AgentTimelineItem[] { - const timelineItem = threadItemToTimeline(item, options); +): Promise { + const timelineItem = await threadItemToTimeline(item, options); if (!timelineItem) { return []; } - return [timelineItem, ...mcpToolResultImagesToTimeline(item)]; + return [timelineItem, ...(await mcpToolResultImagesToTimeline(item))]; } const CodexThreadReadResponseSchema = z @@ -2103,7 +2104,7 @@ async function loadCodexThreadHistoryTimeline(params: { historicalSubAgentActivity.agentThreadId, ); if (existingIndex !== undefined) { - const activityTimelineItem = threadItemToTimeline(item, { cwd: params.cwd }); + const activityTimelineItem = await threadItemToTimeline(item, { cwd: params.cwd }); updateHistoricalSubAgentActivity( timeline, existingIndex, @@ -2116,7 +2117,7 @@ async function loadCodexThreadHistoryTimeline(params: { continue; } } - for (const timelineItem of threadItemToTimelineEntries(item, { cwd: params.cwd })) { + for (const timelineItem of await threadItemToTimelineEntries(item, { cwd: params.cwd })) { const timestamp = readCodexHistoryTimestamp(item) ?? readCodexTurnHistoryTimestamp(turn, timelineItem); const settledTimelineItem = @@ -3290,10 +3291,12 @@ export async function codexAppServerTurnInputFromPrompt( } if (block.type === "image") { try { - const filePath = materializeProviderImage({ - data: block.data, - mimeType: block.mimeType, - }).path; + const filePath = ( + await materializeProviderImage({ + data: block.data, + mimeType: block.mimeType, + }) + ).path; output.push({ type: "localImage", path: filePath }); } catch (error) { const message = error instanceof Error ? error.message : String(error); @@ -4370,6 +4373,7 @@ export class CodexAppServerAgentSession implements AgentSession { this.dismissPendingPlanApprovals("Dismissed by a new prompt"); try { + await this.pendingNotifications; await this.connect(); if (!this.client) { throw new Error("Codex client not initialized"); @@ -5437,7 +5441,21 @@ export class CodexAppServerAgentSession implements AgentSession { return `codex-turn-${this.nextTurnOrdinal++}`; } - private handleNotification(method: string, params: unknown): void { + private pendingNotifications: Promise = Promise.resolve(); + + private handleNotification(method: string, params: unknown): Promise { + // An image must exist before its event, and completion must follow that event. + // Transport callbacks cannot await: the session owns their ordered drain. + const next = this.pendingNotifications.then(() => { + return this.closed ? undefined : this.processNotification(method, params); + }); + this.pendingNotifications = next.catch((error: unknown) => { + this.handleUnexpectedTermination(error instanceof Error ? error : new Error(String(error))); + }); + return this.pendingNotifications; + } + + private async processNotification(method: string, params: unknown): Promise { const notificationParams = toObjectRecord(params); if (method === "serverRequest/resolved" && typeof notificationParams?.requestId === "number") { const requestId = this.mcpElicitationPermissionIds.get(notificationParams.requestId); @@ -5467,13 +5485,16 @@ export class CodexAppServerAgentSession implements AgentSession { return; } if (route.kind === "sub_agent") { - this.dispatchSubAgentNotification(parsed, route.callId); + await this.dispatchSubAgentNotification(parsed, route.callId); return; } - this.dispatchParsedNotification(parsed); + await this.dispatchParsedNotification(parsed); } - private dispatchSubAgentNotification(parsed: ParsedCodexNotification, callId: string): void { + private async dispatchSubAgentNotification( + parsed: ParsedCodexNotification, + callId: string, + ): Promise { switch (parsed.kind) { case "thread_started": this.emitSubAgentActivityUpdate(callId, "running", { reopen: true }); @@ -5484,7 +5505,7 @@ export class CodexAppServerAgentSession implements AgentSession { case "reasoning_delta": case "item_started": case "item_completed": - this.dispatchParsedNotification(parsed); + await this.dispatchParsedNotification(parsed); return; case "exec_command_output_delta": case "file_change_output_delta": @@ -5510,7 +5531,7 @@ export class CodexAppServerAgentSession implements AgentSession { } } - private dispatchParsedNotification(parsed: ParsedCodexNotification): void { + private async dispatchParsedNotification(parsed: ParsedCodexNotification): Promise { if (isCodexDeltaNotification(parsed)) { this.handleCodexDeltaNotification(parsed); return; @@ -5555,10 +5576,10 @@ export class CodexAppServerAgentSession implements AgentSession { this.handlePatchApplyCompletedNotification(parsed); return; case "item_completed": - this.handleItemCompletedNotification(parsed); + await this.handleItemCompletedNotification(parsed); return; case "item_started": - this.handleItemStartedNotification(parsed); + await this.handleItemStartedNotification(parsed); return; case "invalid_payload": this.warnInvalidNotificationPayload(parsed.method, parsed.params); @@ -5600,7 +5621,7 @@ export class CodexAppServerAgentSession implements AgentSession { pending.push(parsed); } - private replayPendingSubAgentNotifications(threadIds: readonly string[]): void { + private async replayPendingSubAgentNotifications(threadIds: readonly string[]): Promise { for (const threadId of threadIds) { const pending = this.pendingSubAgentNotificationsByThreadId.get(threadId); if (!pending) { @@ -5612,7 +5633,7 @@ export class CodexAppServerAgentSession implements AgentSession { continue; } for (const parsed of pending) { - this.dispatchSubAgentNotification(parsed, callId); + await this.dispatchSubAgentNotification(parsed, callId); } } } @@ -6578,9 +6599,13 @@ export class CodexAppServerAgentSession implements AgentSession { } } - private handleItemCompletedNotification( + private ownsNotificationTurn(turnId: string | null): boolean { + return !this.closed && this.activeForegroundTurnId === turnId; + } + + private async handleItemCompletedNotification( parsed: Extract, - ): void { + ): Promise { // Codex emits mirrored lifecycle notifications via both `codex/event/item_*` // and canonical `item/*`. Render ordinary items only from the canonical // channel, but accept a legacy-only child announcement so it can establish @@ -6590,20 +6615,23 @@ export class CodexAppServerAgentSession implements AgentSession { } this.receiveAsyncQuestion(parsed.threadId, parsed.item); if (this.isUserMessageItem(parsed.item)) { - this.handleUserMessageItem(parsed); + await this.handleUserMessageItem(parsed); return; } const childSubAgentCallId = this.getSubAgentCallIdForThread(parsed.threadId); if (this.handleCompletedSpecialItem(parsed, childSubAgentCallId)) { return; } - const timelineItem = threadItemToTimeline(parsed.item, { + const foregroundTurnId = this.activeForegroundTurnId; + const timelineItem = await threadItemToTimeline(parsed.item, { includeUserMessage: false, cwd: this.config.cwd ?? null, }); if (!timelineItem) { return; } + const imageItems = await mcpToolResultImagesToTimeline(parsed.item); + if (!this.ownsNotificationTurn(foregroundTurnId)) return; const registeredChildThreadIds = timelineItem.type === "tool_call" ? this.registerSubAgentToolCall({ @@ -6613,12 +6641,11 @@ export class CodexAppServerAgentSession implements AgentSession { parentSubagentId: resolveCodexParentSubagentId(childSubAgentCallId, parsed.threadId), }) : []; - const imageItems = mcpToolResultImagesToTimeline(parsed.item); if (childSubAgentCallId) { this.emitCompletedProviderSubagentItem(parsed, timelineItem); this.handleSubAgentChildItemCompleted(childSubAgentCallId, parsed.item.id, timelineItem); this.emitProviderSubagentTimelineItems(parsed.threadId, imageItems); - this.replayPendingSubAgentNotifications(registeredChildThreadIds); + await this.replayPendingSubAgentNotifications(registeredChildThreadIds); return; } const normalizedItemType = normalizeCodexThreadItemType( @@ -6626,7 +6653,7 @@ export class CodexAppServerAgentSession implements AgentSession { ); const itemId = parsed.item.id; if (this.shouldSkipCompletedThreadItem(timelineItem, normalizedItemType, itemId)) { - this.replayPendingSubAgentNotifications(registeredChildThreadIds); + await this.replayPendingSubAgentNotifications(registeredChildThreadIds); return; } if (this.consumeStreamedTextCompletion(timelineItem, itemId)) { @@ -6637,7 +6664,7 @@ export class CodexAppServerAgentSession implements AgentSession { this.emittedItemCompletedIds.add(itemId); this.emittedItemStartedIds.delete(itemId); } - this.replayPendingSubAgentNotifications(registeredChildThreadIds); + await this.replayPendingSubAgentNotifications(registeredChildThreadIds); return; } this.applyBufferedDeltaTextToTimelineItem(timelineItem, itemId); @@ -6666,7 +6693,7 @@ export class CodexAppServerAgentSession implements AgentSession { this.pendingCommandOutputDeltas.delete(itemId); this.pendingFileChangeOutputDeltas.delete(itemId); } - this.replayPendingSubAgentNotifications(registeredChildThreadIds); + await this.replayPendingSubAgentNotifications(registeredChildThreadIds); } private consumeStreamedTextCompletion( @@ -6738,14 +6765,14 @@ export class CodexAppServerAgentSession implements AgentSession { } } - private handleItemStartedNotification( + private async handleItemStartedNotification( parsed: Extract, - ): void { + ): Promise { if (shouldIgnoreMirroredLifecycleItem(parsed.source, parsed.item)) { return; } if (this.isUserMessageItem(parsed.item)) { - this.handleUserMessageItem(parsed); + await this.handleUserMessageItem(parsed); return; } const childSubAgentCallId = this.getSubAgentCallIdForThread(parsed.threadId); @@ -6767,7 +6794,7 @@ export class CodexAppServerAgentSession implements AgentSession { if (this.handleRegisteredSubAgentActivity(parsed.item)) { return; } - const timelineItem = threadItemToTimeline(parsed.item, { + const timelineItem = await threadItemToTimeline(parsed.item, { includeUserMessage: false, cwd: this.config.cwd ?? null, }); @@ -6786,7 +6813,7 @@ export class CodexAppServerAgentSession implements AgentSession { this.upsertSubAgentChildItem(childSubAgentCallId, parsed.item.id, timelineItem); } this.emitSubAgentActivityUpdate(childSubAgentCallId, "running"); - this.replayPendingSubAgentNotifications(registeredChildThreadIds); + await this.replayPendingSubAgentNotifications(registeredChildThreadIds); return; } const normalizedItemType = normalizeCodexThreadItemType( @@ -6810,14 +6837,14 @@ export class CodexAppServerAgentSession implements AgentSession { this.pendingCommandOutputDeltas.delete(itemId); this.pendingFileChangeOutputDeltas.delete(itemId); } - this.replayPendingSubAgentNotifications(registeredChildThreadIds); + await this.replayPendingSubAgentNotifications(registeredChildThreadIds); } - private handleUserMessageItem( + private async handleUserMessageItem( parsed: Extract, - ): void { + ): Promise { const itemId = parsed.item.id; - const timelineItem = threadItemToTimeline(parsed.item, { + const timelineItem = await threadItemToTimeline(parsed.item, { includeUserMessage: true, cwd: this.config.cwd ?? null, }); diff --git a/packages/server/src/server/agent/providers/jsonl-frame-decoder.test.ts b/packages/server/src/server/agent/providers/jsonl-frame-decoder.test.ts index c1c81bdae8..4844415324 100644 --- a/packages/server/src/server/agent/providers/jsonl-frame-decoder.test.ts +++ b/packages/server/src/server/agent/providers/jsonl-frame-decoder.test.ts @@ -1,5 +1,4 @@ -import { performance } from "node:perf_hooks"; -import { setTimeout as delay } from "node:timers/promises"; +import { measureMainThreadWork } from "../../../test-utils/main-thread-work.js"; import { describe, expect, it } from "vitest"; import { JsonlFrameDecoder, type JsonlFrameProblem } from "./jsonl-frame-decoder.js"; @@ -72,26 +71,15 @@ describe("provider line admission", () => { it("keeps the loop responsive while accumulating a fragmented provider response", async () => { const { decoder, frames, problems } = decoderHarness(); const part = "x".repeat(512); - let last = performance.now(); - let longestGap = 0; - const probe = setInterval(() => { - const now = performance.now(); - longestGap = Math.max(longestGap, now - last); - last = now; - }, 1); - try { - await delay(5); + const { blockingMs } = await measureMainThreadWork("fragmented JSONL main-thread work", () => { decoder.write('{"type":"notice","text":"'); - // 1 MiB is enough to expose the former rescan of every accumulated prefix. + // 1 MiB exposes the former rescan of every accumulated prefix. for (let index = 0; index < 2048; index++) decoder.write(part); decoder.write('"}\n'); - await delay(5); - expect(frames).toEqual([{ type: "notice", text: part.repeat(2048) }]); - expect(problems).toEqual([]); - expect(longestGap).toBeLessThan(50); - } finally { - clearInterval(probe); - } + }); + expect(frames).toEqual([{ type: "notice", text: part.repeat(2048) }]); + expect(problems).toEqual([]); + expect(blockingMs).toBeLessThan(50); }); it("rejects an unterminated over-cap UTF-8 line and stops accepting frames", () => { diff --git a/packages/server/src/server/agent/providers/mock-load-test-agent.ts b/packages/server/src/server/agent/providers/mock-load-test-agent.ts index 7055030dc0..14322c6ccf 100644 --- a/packages/server/src/server/agent/providers/mock-load-test-agent.ts +++ b/packages/server/src/server/agent/providers/mock-load-test-agent.ts @@ -230,6 +230,7 @@ interface LargeAgentStreamPayloadRequest { } interface AgentStreamStressRequest { + tools: boolean; count: number; coalesced: boolean; } @@ -461,7 +462,7 @@ function parseLargeAgentStreamPayloadPrompt( function parseAgentStreamStressPrompt(prompt: AgentPromptInput): AgentStreamStressRequest | null { const text = promptToText(prompt); - const match = /emit\s+(\d+)\s+(coalesced\s+)?agent stream updates/i.exec(text); + const match = /emit\s+(\d+)\s+(coalesced\s+|tool\s+)?agent stream updates/i.exec(text); if (!match) { return null; } @@ -471,7 +472,8 @@ function parseAgentStreamStressPrompt(prompt: AgentPromptInput): AgentStreamStre } return { count: Math.min(count, 5_000), - coalesced: Boolean(match[2]), + coalesced: match[2]?.trim() === "coalesced", + tools: match[2]?.trim() === "tool", }; } @@ -1452,21 +1454,37 @@ export class MockLoadTestAgentSession implements AgentSession { this.emitTurnStarted(turn); for (let index = 0; index < stress.count; index += 1) { - this.emitTimeline( - turn.turnId, - stress.coalesced - ? { - type: "assistant_message", - text: `stress-update-${index}`, - messageId: turn.assistantMessageId, - } - : { - type: "todo", - items: [{ text: `stress-update-${index}`, completed: index % 2 === 0 }], - }, - ); + let item: AgentTimelineItem; + if (stress.tools) { + item = { + type: "tool_call", + callId: `${turn.turnId}:stress:${index}`, + name: "Bash", + status: "completed", + error: null, + detail: { type: "shell", command: `echo stress-${index}`, output: `stress-${index}` }, + }; + } else if (stress.coalesced) { + item = { + type: "assistant_message", + text: `stress-update-${index}`, + messageId: turn.assistantMessageId, + }; + } else { + item = { + type: "todo", + items: [{ text: `stress-update-${index}`, completed: index % 2 === 0 }], + }; + } + this.emitTimeline(turn.turnId, item); } + if (stress.tools) + this.emitTimeline(turn.turnId, { + type: "assistant_message", + text: `Completed ${stress.count} commands`, + messageId: turn.assistantMessageId, + }); this.activeTurn = null; const usage = { inputTokens: 1, diff --git a/packages/server/src/server/agent/providers/omp/agent.test.ts b/packages/server/src/server/agent/providers/omp/agent.test.ts index 7b56c9bdec..15392fc07c 100644 --- a/packages/server/src/server/agent/providers/omp/agent.test.ts +++ b/packages/server/src/server/agent/providers/omp/agent.test.ts @@ -1343,13 +1343,14 @@ describe("OMP agent client and session", () => { expect(omp.runtimeLaunches()).toHaveLength(1); }); - test("closes a replacement process if the session closes during relaunch", async () => { + test("rejects a pending recovery start and closes its processes when the session closes", async () => { const omp = new OmpHarness(); await omp.start(); omp.processExit("OMP RPC process exited with code 1 and signal null"); const turn = omp.startTurnDetached("continue"); + const rejected = expect(turn).rejects.toThrow("closed"); await omp.close(); - await turn; + await rejected; await waitForImmediate(); expect(omp.runtimeSessions().every((session) => session.closed)).toBe(true); }); @@ -1638,3 +1639,93 @@ test.each([ expect(await session.canStopRuntime!()).toBe(expected); }, ); + +test.each([false, true])( + "image prompt keeps OMP's vision capability contract (vision: %s)", + async (vision) => { + const runtime = new FakeOmp(); + runtime.queueSessionSetup((session) => { + session.state = { + ...session.state, + model: { + provider: "test", + id: "image-model", + input: vision ? ["text", "image"] : ["text"], + }, + }; + }); + const client = new OmpAgentClient({ logger: createTestLogger(), runtime }); + const session = await client.createSession({ provider: "omp", cwd: process.cwd() }); + onTestFinished(() => session.close()); + const bytes = Buffer.from("OMP attachment fixture"); + const first = session.startTurn([ + { type: "text", text: "Describe this image." }, + { type: "image", data: bytes.toString("base64"), mimeType: "image/png" }, + ]); + await expect(session.startTurn("overlapping prompt")).rejects.toThrow("already active"); + await first; + const [prompt] = runtime.latestSession().prompts; + expect(prompt.imageCount).toBe(vision ? 1 : 0); + if (vision) expect(prompt.message).toBe("Describe this image."); + else { + const imagePath = prompt.message.match(/\[Image available at: (.+)\]/)?.[1]; + expect(imagePath).toBeTruthy(); + const { readFile, rm } = await import("node:fs/promises"); + expect(await readFile(imagePath!)).toEqual(bytes); + await rm(imagePath!); + } + }, +); + +test("interrupting OMP during image conversion cancels the admitted turn and permits a follow-up", async () => { + const runtime = new FakeOmp(); + const client = new OmpAgentClient({ logger: createTestLogger(), runtime }); + const session = await client.createSession({ provider: "omp", cwd: process.cwd() }); + onTestFinished(() => session.close()); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + let admitted = false; + const first = session + .startTurn([ + { + type: "image", + data: Buffer.alloc(16 * 1024 * 1024, 53).toString("base64"), + mimeType: "image/png", + }, + ]) + .then((result) => { + admitted = true; + return result; + }); + await waitForImmediate(); + expect(admitted).toBe(false); + expect(runtime.latestSession().prompts).toHaveLength(0); + await session.interrupt(); + const followup = await session.startTurn("follow-up after image interrupt"); + const original = await first; + expect(events).toContainEqual( + expect.objectContaining({ type: "turn_canceled", turnId: original.turnId }), + ); + expect(followup.turnId).not.toBe(original.turnId); + expect(runtime.latestSession().prompts).toEqual([ + { message: "follow-up after image interrupt", imageCount: 0 }, + ]); +}); + +test("closing OMP during image conversion rejects its pending start", async () => { + const runtime = new FakeOmp(); + const client = new OmpAgentClient({ logger: createTestLogger(), runtime }); + const session = await client.createSession({ provider: "omp", cwd: process.cwd() }); + const first = session.startTurn([ + { + type: "image", + data: Buffer.alloc(16 * 1024 * 1024, 54).toString("base64"), + mimeType: "image/png", + }, + ]); + const rejected = expect(first).rejects.toThrow("closed"); + await waitForImmediate(); + expect(runtime.latestSession().prompts).toHaveLength(0); + await session.close(); + await rejected; +}); diff --git a/packages/server/src/server/agent/providers/omp/agent.ts b/packages/server/src/server/agent/providers/omp/agent.ts index 434a49d89e..f2f405540b 100644 --- a/packages/server/src/server/agent/providers/omp/agent.ts +++ b/packages/server/src/server/agent/providers/omp/agent.ts @@ -317,9 +317,9 @@ function ompModelSupportsImageInput(model: OmpModel | null | undefined): boolean return model?.input?.includes("image") === true; } -function renderTextOnlyImageHint(image: { data: string; mimeType: string }): string { +async function renderTextOnlyImageHint(image: { data: string; mimeType: string }): Promise { try { - const materialized = materializeProviderImage({ + const materialized = await materializeProviderImage({ data: image.data, mimeType: image.mimeType, }); @@ -329,10 +329,10 @@ function renderTextOnlyImageHint(image: { data: string; mimeType: string }): str } } -function convertPromptInput( +async function convertPromptInput( prompt: AgentPromptInput, options: { model: OmpModel | null | undefined }, -): OmpPromptPayload { +): Promise { if (typeof prompt === "string") { return { text: prompt }; } @@ -355,7 +355,7 @@ function convertPromptInput( mimeType: block.mimeType, }); } else { - textParts.push(renderTextOnlyImageHint(block)); + textParts.push(await renderTextOnlyImageHint(block)); } continue; } @@ -868,11 +868,9 @@ export class OmpAgentSession implements AgentSession { throw new Error("An OMP turn is already active"); } - const payload = convertPromptInput(prompt, { model: this.state.model }); const turnId = randomUUID(); this.live = true; this.activeTurnId = turnId; - this.rememberClientMessage(options?.clientMessageId ?? null, payload.text); this.activeAssistantMessageId = null; this.activeTurnTerminalAssistantMessage = null; this.activeTurnStarted = false; @@ -880,6 +878,10 @@ export class OmpAgentSession implements AgentSession { this.activeTurnHasUserMessage = false; this.activePromptRequestId = null; this.clearNoTurnBuffers(); + const payload = await convertPromptInput(prompt, { model: this.state.model }); + if (this.closed) throw new Error("OMP session is closed"); + if (this.activeTurnId !== turnId) return { turnId }; + this.rememberClientMessage(options?.clientMessageId ?? null, payload.text); this.activeNoTurnPromptText = payload.text; this.usagePoller.startTurn(); if (announceTurn) { @@ -946,7 +948,9 @@ export class OmpAgentSession implements AgentSession { ): Promise { if (this.closed || this.activeTurnId !== options.expectedTurnId) return { status: "unavailable" }; - const payload = convertPromptInput(prompt, { model: this.state.model }); + const payload = await convertPromptInput(prompt, { model: this.state.model }); + if (this.closed || this.activeTurnId !== options.expectedTurnId) + return { status: "unavailable" }; if (this.parseSlashCommandInput(payload.text)) return { status: "unavailable" }; const submission = this.rememberClientMessage(options.clientMessageId ?? null, payload.text); try { diff --git a/packages/server/src/server/agent/providers/omp/history.e2e.test.ts b/packages/server/src/server/agent/providers/omp/history.e2e.test.ts index 77521a9687..f0a00d5a0d 100644 --- a/packages/server/src/server/agent/providers/omp/history.e2e.test.ts +++ b/packages/server/src/server/agent/providers/omp/history.e2e.test.ts @@ -1,3 +1,4 @@ +import { measureMainThreadWork } from "../../../../test-utils/main-thread-work.js"; import { BUILTIN_PROVIDER_IDS } from "@getpaseo/protocol/provider-manifest"; import { mkdtemp, open, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -50,51 +51,45 @@ test("replays selected OMP history without blocking the event loop", async () => } finally { await file.close(); } - let last = performance.now(); - let longestGap = 0; - const probe = setInterval(() => { - const now = performance.now(); - longestGap = Math.max(longestGap, now - last); - last = now; - }, 5); - try { - const replayState = { finished: false }; - let repliesDuringReplay = 0; - const replay = readActiveOmpEntryChain(sessionFile, "row-0").finally(() => { - replayState.finished = true; - }); - // A real WebSocket RPC served by the isolated daemon on the same event loop. - const requests = (async () => { - while (!replayState.finished) { - await client.getDaemonStatus(); - if (!replayState.finished) repliesDuringReplay++; - await delay(10); - } - })(); - const [entries] = await Promise.all([replay, requests]); - await delay(10); - console.log( - JSON.stringify({ - bytes, - count, - longestGap, - repliesDuringReplay, - generationAndLoadMs: performance.now() - started, - }), - ); - expect(entries).toEqual([ - { - type: "message", - id: "row-0", - parentId: null, - message: { role: "user", content: text }, - }, - ]); - expect(longestGap).toBeLessThan(50); - expect(repliesDuringReplay).toBeGreaterThan(0); - } finally { - clearInterval(probe); - } + let repliesDuringReplay = 0; + const { result: replayedEntries, blockingMs } = await measureMainThreadWork( + "OMP history main-thread work", + async () => { + const replayState = { finished: false }; + const replay = readActiveOmpEntryChain(sessionFile, "row-0").finally(() => { + replayState.finished = true; + }); + // A real WebSocket RPC served by the isolated daemon on the same event loop. + const requests = (async () => { + while (!replayState.finished) { + await client.getDaemonStatus(); + if (!replayState.finished) repliesDuringReplay++; + await delay(10); + } + })(); + const [entries] = await Promise.all([replay, requests]); + return entries; + }, + ); + console.log( + JSON.stringify({ + bytes, + count, + blockingMs, + repliesDuringReplay, + generationAndLoadMs: performance.now() - started, + }), + ); + expect(replayedEntries).toEqual([ + { + type: "message", + id: "row-0", + parentId: null, + message: { role: "user", content: text }, + }, + ]); + expect(blockingMs).toBeLessThan(50); + expect(repliesDuringReplay).toBeGreaterThan(0); } finally { await client.close().finally(() => daemon.close()); } diff --git a/packages/server/src/server/agent/providers/opencode/event-translator.test.ts b/packages/server/src/server/agent/providers/opencode/event-translator.test.ts index 9ed61fef67..6dc5bd5a2e 100644 --- a/packages/server/src/server/agent/providers/opencode/event-translator.test.ts +++ b/packages/server/src/server/agent/providers/opencode/event-translator.test.ts @@ -1,3 +1,4 @@ +import { collectTimelineSnapshot } from "./test-utils/v2-timeline.js"; import type { SessionMessageAssistant, SessionMessageUser } from "@opencode/client"; import { V2Timeline } from "./v2/timeline.js"; import { describe, expect, it } from "vitest"; @@ -1560,7 +1561,7 @@ describe("translateOpenCodeEvent", () => { }); describe("OpenCode v2 history reconciliation", () => { - it("keeps client message identity on live echoes but omits it from history", () => { + it("keeps client message identity on live echoes but omits it from history", async () => { const user: SessionMessageUser = { id: "native-message", type: "user", @@ -1568,13 +1569,13 @@ describe("OpenCode v2 history reconciliation", () => { time: { created: 1 }, metadata: { paseoClientMessageId: "client-message" }, }; - expect(new V2Timeline().messages([user])).toMatchObject([ + expect(await collectTimelineSnapshot(new V2Timeline(), [user])).toMatchObject([ { item: { clientMessageId: "client-message", messageId: "native-message" } }, ]); - expect(new V2Timeline(false).messages([user])).toMatchObject([ + expect(await collectTimelineSnapshot(new V2Timeline(false), [user])).toMatchObject([ { item: { type: "user_message", messageId: "native-message" } }, ]); - const [event] = new V2Timeline(false).messages([user]); + const [event] = await collectTimelineSnapshot(new V2Timeline(false), [user]); expect(event && "item" in event && event.item).not.toHaveProperty("clientMessageId"); }); function assistant(text: string): SessionMessageAssistant { @@ -1587,31 +1588,35 @@ describe("OpenCode v2 history reconciliation", () => { content: [{ type: "text", text }], }; } - it("emits only the missing suffix after reconnect, even if an intermediate snapshot lags", () => { + it("emits only the missing suffix after reconnect, even if an intermediate snapshot lags", async () => { const timeline = new V2Timeline(); - const text = (messages: ReturnType) => + const text = (messages: Awaited>) => messages.flatMap((event) => event.type === "timeline" && event.item.type === "assistant_message" ? [event.item.text] : [], ); - expect(text(timeline.messages([assistant("hello")]))).toEqual(["hello"]); - expect(timeline.messages([assistant("hel")])).toEqual([]); - expect(text(timeline.messages([assistant("hello world")]))).toEqual([" world"]); - expect(timeline.messages([assistant("hello world")])).toEqual([]); + expect(text(await collectTimelineSnapshot(timeline, [assistant("hello")]))).toEqual(["hello"]); + expect(await collectTimelineSnapshot(timeline, [assistant("hel")])).toEqual([]); + expect(text(await collectTimelineSnapshot(timeline, [assistant("hello world")]))).toEqual([ + " world", + ]); + expect(await collectTimelineSnapshot(timeline, [assistant("hello world")])).toEqual([]); }); - it("keeps reasoning separate from assistant output", () => { + it("keeps reasoning separate from assistant output", async () => { const timeline = new V2Timeline(); const message = assistant("answer"); message.content.unshift({ type: "reasoning", text: "thinking" }); expect( - timeline.messages([message]).map((event) => (event.type === "timeline" ? event.item : null)), + (await collectTimelineSnapshot(timeline, [message])).map((event) => + event.type === "timeline" ? event.item : null, + ), ).toEqual([ { type: "reasoning", text: "thinking" }, { type: "assistant_message", text: "answer", messageId: "message" }, ]); }); - it("emits tool state transitions once and keeps their call identity", () => { + it("emits tool state transitions once and keeps their call identity", async () => { const timeline = new V2Timeline(); const message = assistant(""); message.content = [ @@ -1623,7 +1628,7 @@ describe("OpenCode v2 history reconciliation", () => { state: { status: "running", input: { command: "pwd" }, metadata: {} }, }, ]; - expect(timeline.messages([message])).toMatchObject([ + expect(await collectTimelineSnapshot(timeline, [message])).toMatchObject([ { item: { type: "tool_call", callId: "call", status: "running" } }, ]); message.content = [ @@ -1639,9 +1644,9 @@ describe("OpenCode v2 history reconciliation", () => { }, }, ]; - expect(timeline.messages([message])).toMatchObject([ + expect(await collectTimelineSnapshot(timeline, [message])).toMatchObject([ { item: { type: "tool_call", callId: "call", status: "completed" } }, ]); - expect(timeline.messages([message])).toEqual([]); + expect(await collectTimelineSnapshot(timeline, [message])).toEqual([]); }); }); diff --git a/packages/server/src/server/agent/providers/opencode/test-utils/v2-streamed-text-memory.ts b/packages/server/src/server/agent/providers/opencode/test-utils/v2-streamed-text-memory.ts index d9d254d9de..98cd79526a 100644 --- a/packages/server/src/server/agent/providers/opencode/test-utils/v2-streamed-text-memory.ts +++ b/packages/server/src/server/agent/providers/opencode/test-utils/v2-streamed-text-memory.ts @@ -1,3 +1,4 @@ +import { collectTimelineSnapshot } from "./v2-timeline.js"; import { TimelineProjection } from "../../../timeline-projection.js"; import { V2Timeline } from "../v2/timeline.js"; @@ -21,16 +22,18 @@ for (let index = 0; index < chunks; index++) { const event = mode === "delta" ? timeline.delta({ ...part, delta }) - : timeline.messages([ - { - id: "answer", - type: "assistant", - agent: "build", - model: { providerID: "test", id: "model" }, - time: { created: 2 }, - content: [{ type: "reasoning", text }], - }, - ])[0]; + : ( + await collectTimelineSnapshot(timeline, [ + { + id: "answer", + type: "assistant", + agent: "build", + model: { providerID: "test", id: "model" }, + time: { created: 2 }, + content: [{ type: "reasoning", text }], + }, + ]) + )[0]; if (event?.type !== "timeline") throw new Error("Expected a timeline event"); projection.append({ seq: index + 1, diff --git a/packages/server/src/server/agent/providers/opencode/test-utils/v2-timeline.ts b/packages/server/src/server/agent/providers/opencode/test-utils/v2-timeline.ts new file mode 100644 index 0000000000..e7aa552479 --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/test-utils/v2-timeline.ts @@ -0,0 +1,15 @@ +import type { SessionMessageInfo } from "@opencode/client"; +import type { AgentStreamEvent } from "../../../agent-sdk-types.js"; +import type { V2Timeline } from "../v2/timeline.js"; + +// Snapshot assertions without a live stream can collect the publisher's events. +export async function collectTimelineSnapshot( + timeline: V2Timeline, + messages: SessionMessageInfo[], +): Promise { + const events: AgentStreamEvent[] = []; + await timeline.messages(messages, (event) => { + events.push(event); + }); + return events; +} diff --git a/packages/server/src/server/agent/providers/opencode/v2/children.ts b/packages/server/src/server/agent/providers/opencode/v2/children.ts index 8c2e0ec312..08af88dcb0 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/children.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/children.ts @@ -92,13 +92,13 @@ export class SessionChildren { this.childStates.set(info.id, signature); this.options.emit({ type: "provider_subagent", provider: "opencode", event: presentation }); } - for (const event of timeline.messages(await messages(this.options.client(), info.id))) { + await timeline.messages(await messages(this.options.client(), info.id), (event) => { if (event.type === "timeline") this.options.emit({ type: "provider_subagent", provider: "opencode", event: { type: "timeline", id: info.id, item: event.item, timestamp: event.timestamp }, }); - } + }); } } diff --git a/packages/server/src/server/agent/providers/opencode/v2/session.ts b/packages/server/src/server/agent/providers/opencode/v2/session.ts index 9358a14040..f173ccb301 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/session.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/session.ts @@ -131,7 +131,7 @@ export class OpenCodeV2Session implements AgentSession { await this.configureConnection(); const location = { directory: this.config.cwd }; this.modes = modesFromV2((await this.client.agent.list({ location })).data); - this.timeline.messages(await messages(this.client, this.id)); + await this.timeline.messages(await messages(this.client, this.id), () => {}); await this.startStream(); await this.children.reconcile(this.id); } @@ -261,7 +261,11 @@ export class OpenCodeV2Session implements AgentSession { async *streamHistory() { await this.reconnectIfExited(); const history = new V2Timeline(false); - yield* history.messages(await messages(this.client, this.id)); + const events: AgentStreamEvent[] = []; + await history.messages(await messages(this.client, this.id), (event) => { + events.push(event); + }); + yield* events; } async getRuntimeInfo() { await this.reconnectIfExited(); @@ -417,7 +421,7 @@ export class OpenCodeV2Session implements AgentSession { ]); this.info = info; this.history = history; - for (const event of this.timeline.messages(history)) this.emitTimeline(event); + await this.timeline.messages(history, (event) => this.emitTimeline(event)); await this.permissions.reconcile(this.id); } private async reconcile() { diff --git a/packages/server/src/server/agent/providers/opencode/v2/streaming.test.ts b/packages/server/src/server/agent/providers/opencode/v2/streaming.test.ts index aa74faa6cf..b8c118f037 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/streaming.test.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/streaming.test.ts @@ -1,3 +1,4 @@ +import { collectTimelineSnapshot } from "../test-utils/v2-timeline.js"; import { V2Timeline } from "./timeline.js"; import type { SessionMessageAssistant } from "@opencode/client"; import { describe, expect, test } from "vitest"; @@ -80,7 +81,7 @@ describe("OpenCode v2 token streaming", () => { } }); - test("deduplicates snapshots before and after deltas using per-type ordinals", () => { + test("deduplicates snapshots before and after deltas using per-type ordinals", async () => { const timeline = new V2Timeline(); const text = { assistantMessageID: "answer", type: "text", ordinal: 0 } as const; const reasoning = { ...text, type: "reasoning" } as const; @@ -99,24 +100,28 @@ describe("OpenCode v2 token streaming", () => { }, { type: "text", text: "Hello" }, ]); - expect(timeline.messages([snapshot])).toMatchObject([{ item: { text: "lo" } }]); + expect(await collectTimelineSnapshot(timeline, [snapshot])).toMatchObject([ + { item: { text: "lo" } }, + ]); expect(timeline.delta({ ...text, delta: "lo" })).toBeNull(); - expect(timeline.messages([snapshot])).toEqual([]); + expect(await collectTimelineSnapshot(timeline, [snapshot])).toEqual([]); expect(timeline.delta({ ...text, delta: "!" })).toMatchObject({ item: { text: "!" } }); - expect(timeline.messages([snapshot])).toEqual([]); + expect(await collectTimelineSnapshot(timeline, [snapshot])).toEqual([]); }); - test("recovers missed fragments from snapshots after reconnect", () => { + test("recovers missed fragments from snapshots after reconnect", async () => { const timeline = new V2Timeline(); const part = { assistantMessageID: "answer", type: "text", ordinal: 0 } as const; timeline.startPart(part); expect(timeline.delta({ ...part, delta: "Hel" })).toMatchObject({ item: { text: "Hel" } }); timeline.resetStreams(); expect(timeline.delta({ ...part, delta: "!" })).toBeNull(); - expect(timeline.messages([assistant([{ type: "text", text: "Hello!" }])])).toMatchObject([ - { item: { text: "lo!" } }, - ]); - expect(timeline.messages([assistant([{ type: "text", text: "Hello!" }])])).toEqual([]); + expect( + await collectTimelineSnapshot(timeline, [assistant([{ type: "text", text: "Hello!" }])]), + ).toMatchObject([{ item: { text: "lo!" } }]); + expect( + await collectTimelineSnapshot(timeline, [assistant([{ type: "text", text: "Hello!" }])]), + ).toEqual([]); }); test("withholds streamed prose during a structured-output turn", async () => { @@ -203,3 +208,62 @@ describe("OpenCode v2 token streaming", () => { } }); }); + +test.each(["text", "reasoning"] as const)( + "publishes snapshot %s before a live suffix during image materialization", + async (type) => { + const timeline = new V2Timeline(); + const part = { assistantMessageID: "answer", type, ordinal: 0 }; + timeline.startPart(part); + const emitted: AgentStreamEvent[] = []; + let settled = false; + const snapshot = timeline + .messages( + [ + assistant([ + { type, text: "Hello" }, + { + type: "tool", + id: "screenshot", + name: "paseo_browser_screenshot", + executed: false, + state: { + status: "completed", + input: {}, + content: [ + { type: "file", uri: "data:image/png;base64,aGVsbG8=", mime: "image/png" }, + ], + metadata: {}, + }, + time: { created: 2, ran: 2, completed: 2 }, + }, + ]), + ], + (event) => { + emitted.push(event); + }, + ) + .then(() => { + settled = true; + return undefined; + }); + await new Promise((resolve) => setImmediate(resolve)); + expect(settled).toBe(false); + const delta = timeline.delta({ ...part, delta: "Hello world" }); + if (delta) emitted.push(delta); + await snapshot; + const text = emitted.flatMap((event) => { + if (event.type !== "timeline") return []; + if ( + type === "text" && + event.item.type === "assistant_message" && + !event.item.text.includes("![Image]") + ) + return [event.item.text]; + if (type === "reasoning" && event.item.type === "reasoning") return [event.item.text]; + return []; + }); + expect(text).toEqual(["Hello", " world"]); + expect(text.join("")).toBe("Hello world"); + }, +); diff --git a/packages/server/src/server/agent/providers/opencode/v2/timeline.test.ts b/packages/server/src/server/agent/providers/opencode/v2/timeline.test.ts index d86b9a7dcb..6e9b886c09 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/timeline.test.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/timeline.test.ts @@ -1,3 +1,4 @@ +import { collectTimelineSnapshot } from "../test-utils/v2-timeline.js"; import type { SessionMessageAssistant } from "@opencode/client"; import { execFileSync } from "node:child_process"; import { fileURLToPath } from "node:url"; @@ -15,7 +16,7 @@ function assistant(content: SessionMessageAssistant["content"]): SessionMessageA }; } -test("a completed edit carries the replaced and replacement text", () => { +test("a completed edit carries the replaced and replacement text", async () => { // Captured from OpenCode 2.0.18, whose edit tool takes `path` instead of `filePath`. const edit = assistant([ { @@ -45,7 +46,7 @@ test("a completed edit carries the replaced and replacement text", () => { }, ]); - expect(new V2Timeline().messages([edit])).toMatchObject([ + expect(await collectTimelineSnapshot(new V2Timeline(), [edit])).toMatchObject([ { item: { type: "tool_call", @@ -57,7 +58,7 @@ test("a completed edit carries the replaced and replacement text", () => { ]); }); -test("a tool's image output is shown as an image, not as base64 in the tool output", () => { +test("a tool's image output is shown as an image, not as base64 in the tool output", async () => { const png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=="; const screenshot = assistant([ @@ -80,7 +81,7 @@ test("a tool's image output is shown as an image, not as base64 in the tool outp ]); const timeline = new V2Timeline(); - const events = timeline.messages([screenshot]); + const events = await collectTimelineSnapshot(timeline, [screenshot]); expect(JSON.stringify(events)).not.toContain(png); expect(events.map((event) => event.type === "timeline" && event.item)).toMatchObject([ @@ -95,7 +96,7 @@ test("a tool's image output is shown as an image, not as base64 in the tool outp text: expect.stringMatching(/^\n\n!\[Image\]\(file:\/\/.*paseo-attachments.*\.png\)\n\n$/), }, ]); - expect(timeline.messages([screenshot])).toEqual([]); + expect(await collectTimelineSnapshot(timeline, [screenshot])).toEqual([]); }); test("a surrogate pair split across deltas streams back intact", () => { diff --git a/packages/server/src/server/agent/providers/opencode/v2/timeline.ts b/packages/server/src/server/agent/providers/opencode/v2/timeline.ts index 0fdd6f9f40..5596443bbb 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/timeline.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/timeline.ts @@ -55,18 +55,32 @@ export class V2Timeline { return { type: "timeline", provider: "opencode", item, timestamp: new Date().toISOString() }; } - messages(messages: SessionMessageInfo[]): AgentStreamEvent[] { - const events: AgentStreamEvent[] = []; + private pendingMessages: Promise = Promise.resolve(); + + // Publish each cursor advance in the same synchronous step. Buffering snapshot + // events until image IO finishes lets live deltas overtake already committed text. + messages( + messages: SessionMessageInfo[], + publish: (event: AgentStreamEvent) => void, + ): Promise { + const result = this.pendingMessages.then(() => this.publishMessages(messages, publish)); + this.pendingMessages = result.catch(() => {}); + return result; + } + + private async publishMessages( + messages: SessionMessageInfo[], + publish: (event: AgentStreamEvent) => void, + ): Promise { const state: TimelineState = { structured: false, accepted: false }; for (const message of messages) { const timestamp = new Date(message.time.created).toISOString(); const push = (item: AgentTimelineItem) => - events.push({ type: "timeline", provider: "opencode", item, timestamp }); + publish({ type: "timeline", provider: "opencode", item, timestamp }); if (message.type === "user") this.userMessage(message, state, push); - else if (message.type === "assistant") this.assistantMessage(message, state, push); + else if (message.type === "assistant") await this.assistantMessage(message, state, push); else if (message.type === "compaction") this.compactionMessage(message, push); } - return events; } private userMessage( @@ -89,7 +103,7 @@ export class V2Timeline { }); } - private assistantMessage( + private async assistantMessage( message: Extract, state: TimelineState, push: (item: AgentTimelineItem) => void, @@ -99,16 +113,16 @@ export class V2Timeline { // paths address the same cursor entry. let textOrdinal = 0; let reasoningOrdinal = 0; - message.content.forEach((part, partIndex) => { + for (const [partIndex, part] of message.content.entries()) { if (part.type === "tool" && part.name === STRUCTURED_OUTPUT_TOOL) { this.structuredOutputPart(message.id, partIndex, part, state, push); } else if (part.type === "text" || part.type === "reasoning") { const ordinal = part.type === "text" ? textOrdinal++ : reasoningOrdinal++; this.textPart(message.id, part, ordinal, state, push); } else { - this.toolPart(message.id, partIndex, part, push); + await this.toolPart(message.id, partIndex, part, push); } - }); + } } private structuredOutputPart( @@ -156,7 +170,7 @@ export class V2Timeline { else push({ type: "reasoning", text: suffix }); } - private toolPart( + private async toolPart( messageID: string, partIndex: number, part: Extract< @@ -174,16 +188,16 @@ export class V2Timeline { if (item) push(item); // Images follow their tool call as markdown, as Claude and Codex emit them, // so base64 never reaches the tool output. - images.forEach((image, index) => { + for (const [index, image] of images.entries()) { const imageKey = `${key}:image:${index}`; - if (this.content.has(imageKey)) return; + if (this.content.has(imageKey)) continue; this.content.set(imageKey, ""); - const imageItem = renderProviderImageOutputAsAssistantMarkdown( + const imageItem = await renderProviderImageOutputAsAssistantMarkdown( { url: image.uri, mimeType: image.mime }, { materialize: materializeProviderImage }, ); if (imageItem) push(imageItem); - }); + } } private compactionMessage( diff --git a/packages/server/src/server/agent/providers/pi/agent.test.ts b/packages/server/src/server/agent/providers/pi/agent.test.ts index b9f31b61b2..82887bf199 100644 --- a/packages/server/src/server/agent/providers/pi/agent.test.ts +++ b/packages/server/src/server/agent/providers/pi/agent.test.ts @@ -3583,3 +3583,63 @@ test("pi runtime safety protects queued replies and runtime-owned activity", asy native.getStateError = new Error("Unable to prove idle"); await expect(session.canStopRuntime!()).rejects.toThrow("Unable to prove idle"); }); + +test("reserves the Pi turn while an image prompt is materialized", async () => { + const { pi, session } = await createSession(); + onTestFinished(() => session.close()); + const first = session.startTurn([ + { type: "image", data: ONE_BY_ONE_PNG_BASE64, mimeType: "image/png" }, + ]); + await expect(session.startTurn("overlapping prompt")).rejects.toThrow("already active"); + await first; + expect(pi.latestSession().prompts).toHaveLength(1); +}); + +test("interrupting Pi during image conversion cancels the admitted turn and permits a follow-up", async () => { + const { pi, session } = await createSession(); + onTestFinished(() => session.close()); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + let admitted = false; + const first = session + .startTurn([ + { + type: "image", + data: Buffer.alloc(16 * 1024 * 1024, 51).toString("base64"), + mimeType: "image/png", + }, + ]) + .then((result) => { + admitted = true; + return result; + }); + await waitForImmediate(); + expect(admitted).toBe(false); + expect(pi.latestSession().prompts).toHaveLength(0); + await session.interrupt(); + const followup = await session.startTurn("follow-up after image interrupt"); + const original = await first; + expect(events).toContainEqual( + expect.objectContaining({ type: "turn_canceled", turnId: original.turnId }), + ); + expect(followup.turnId).not.toBe(original.turnId); + expect(pi.latestSession().prompts).toEqual([ + { message: "follow-up after image interrupt", imageCount: 0 }, + ]); +}); + +test("closing Pi during image conversion rejects its pending start", async () => { + const { pi, session } = await createSession(); + const first = session.startTurn([ + { + type: "image", + data: Buffer.alloc(16 * 1024 * 1024, 52).toString("base64"), + mimeType: "image/png", + }, + ]); + const rejected = expect(first).rejects.toThrow("closed"); + await waitForImmediate(); + expect(pi.latestSession().prompts).toHaveLength(0); + await session.close(); + await rejected; +}); diff --git a/packages/server/src/server/agent/providers/pi/agent.ts b/packages/server/src/server/agent/providers/pi/agent.ts index 881f8cbb29..5fbffde84b 100644 --- a/packages/server/src/server/agent/providers/pi/agent.ts +++ b/packages/server/src/server/agent/providers/pi/agent.ts @@ -367,9 +367,9 @@ function piModelSupportsImageInput(model: PiModel | null | undefined): boolean { return model?.input?.includes("image") === true; } -function renderTextOnlyImageHint(image: { data: string; mimeType: string }): string { +async function renderTextOnlyImageHint(image: { data: string; mimeType: string }): Promise { try { - const materialized = materializeProviderImage({ + const materialized = await materializeProviderImage({ data: image.data, mimeType: image.mimeType, }); @@ -379,10 +379,10 @@ function renderTextOnlyImageHint(image: { data: string; mimeType: string }): str } } -function convertPromptInput( +async function convertPromptInput( prompt: AgentPromptInput, options: { model: PiModel | null | undefined }, -): PiPromptPayload { +): Promise { if (typeof prompt === "string") { return { text: prompt }; } @@ -405,7 +405,7 @@ function convertPromptInput( mimeType: block.mimeType, }); } else { - textParts.push(renderTextOnlyImageHint(block)); + textParts.push(await renderTextOnlyImageHint(block)); } continue; } @@ -1280,7 +1280,6 @@ export class PiRpcAgentSession implements AgentSession { throw new Error("A Pi turn is already active"); } - const payload = convertPromptInput(prompt, { model: this.state.model }); const turnId = randomUUID(); this.activeTurnId = turnId; this.usagePoller.startTurn(); @@ -1292,6 +1291,9 @@ export class PiRpcAgentSession implements AgentSession { this.activePromptRequestId = null; this.pendingSteerSubmissions.length = 0; this.clearNoTurnBuffers(); + const payload = await convertPromptInput(prompt, { model: this.state.model }); + if (this.closed) throw new Error("Pi session is closed"); + if (this.activeTurnId !== turnId) return { turnId }; this.activeNoTurnPromptText = payload.text; const shouldProbeForNoTurnPrompt = this.parseSlashCommandInput(payload.text) !== null; @@ -1363,7 +1365,9 @@ export class PiRpcAgentSession implements AgentSession { if (this.closed || this.activeTurnId !== options.expectedTurnId) { return { status: "unavailable" }; } - const payload = convertPromptInput(prompt, { model: this.state.model }); + const payload = await convertPromptInput(prompt, { model: this.state.model }); + if (this.closed || this.activeTurnId !== options.expectedTurnId) + return { status: "unavailable" }; // Pi rejects steer RPCs that are extension commands, so slash inputs keep the // interrupt-and-replace fallback where they can run directly. if (this.parseSlashCommandInput(payload.text)) { diff --git a/packages/server/src/server/agent/providers/provider-image-output.posix.test.ts b/packages/server/src/server/agent/providers/provider-image-output.posix.test.ts index 1f4da6e04a..5e71bad0bd 100644 --- a/packages/server/src/server/agent/providers/provider-image-output.posix.test.ts +++ b/packages/server/src/server/agent/providers/provider-image-output.posix.test.ts @@ -5,8 +5,8 @@ import { describe, expect, test } from "vitest"; import { materializeProviderImage } from "./provider-image-output.js"; describe.skipIf(process.platform === "win32")("materializeProviderImage", () => { - test("writes image attachments under a private temp directory", () => { - const materialized = materializeProviderImage({ + test("writes image attachments under a private temp directory", async () => { + const materialized = await materializeProviderImage({ data: "YWJjMTIz", mimeType: "image/png", }); diff --git a/packages/server/src/server/agent/providers/provider-image-output.test.ts b/packages/server/src/server/agent/providers/provider-image-output.test.ts index b4f4784a66..0606c59de3 100644 --- a/packages/server/src/server/agent/providers/provider-image-output.test.ts +++ b/packages/server/src/server/agent/providers/provider-image-output.test.ts @@ -1,10 +1,11 @@ -import { existsSync, rmSync } from "node:fs"; +import { existsSync, rmSync, statSync, readFileSync } from "node:fs"; import path from "node:path"; import MarkdownIt from "markdown-it"; import { projectTimelineRows } from "../timeline-projection.js"; import { describe, expect, test } from "vitest"; import { + MAX_PROVIDER_IMAGE_BYTES, isProviderImageMarkdown, materializeProviderImage, renderProviderImageOutputAsAssistantMarkdown, @@ -12,8 +13,8 @@ import { const HASH = "a".repeat(64); -function renderImageMarkdown(imagePath: string): string { - const item = renderProviderImageOutputAsAssistantMarkdown({ path: imagePath }); +async function renderImageMarkdown(imagePath: string): Promise { + const item = await renderProviderImageOutputAsAssistantMarkdown({ path: imagePath }); if (!item || item.type !== "assistant_message") { throw new Error("Expected provider image output to render as assistant markdown."); } @@ -40,8 +41,8 @@ describe("isProviderImageMarkdown", () => { ).toBe(true); }); - test("emits Windows file paths as file URIs", () => { - const markdown = renderImageMarkdown( + test("emits Windows file paths as file URIs", async () => { + const markdown = await renderImageMarkdown( `C:\\Users\\me\\AppData\\Local\\Temp\\paseo-attachments\\${HASH}.png`, ); @@ -51,22 +52,24 @@ describe("isProviderImageMarkdown", () => { expect(isProviderImageMarkdown(markdown)).toBe(true); }); - test("emits POSIX file paths with spaces as valid file URI markdown", () => { - const markdown = renderImageMarkdown("/home/user/Projects/Project With Spaces/screenshot.png"); + test("emits POSIX file paths with spaces as valid file URI markdown", async () => { + const markdown = await renderImageMarkdown( + "/home/user/Projects/Project With Spaces/screenshot.png", + ); expect(markdown).toBe( "\n\n![Image](file:///home/user/Projects/Project%20With%20Spaces/screenshot.png)\n\n", ); }); - test("encodes URI-significant characters in POSIX file paths", () => { - const markdown = renderImageMarkdown("/tmp/screenshot#1?draft.png"); + test("encodes URI-significant characters in POSIX file paths", async () => { + const markdown = await renderImageMarkdown("/tmp/screenshot#1?draft.png"); expect(markdown).toBe("\n\n![Image](file:///tmp/screenshot%231%3Fdraft.png)\n\n"); }); - test("preserves double-leading slashes in POSIX file paths", () => { - const markdown = renderImageMarkdown("//tmp/screenshot#1.png"); + test("preserves double-leading slashes in POSIX file paths", async () => { + const markdown = await renderImageMarkdown("//tmp/screenshot#1.png"); expect(markdown).toBe("\n\n![Image](file:////tmp/screenshot%231.png)\n\n"); }); @@ -78,8 +81,8 @@ describe("isProviderImageMarkdown", () => { "\\\\?\\UNC\\server\\share\\shot?draft.png", "file://server/share/shot%3Fdraft.png", ], - ])("encodes %s image paths as file URIs", (_label, imagePath, expectedSource) => { - expect(renderImageMarkdown(imagePath)).toBe(`\n\n![Image](${expectedSource})\n\n`); + ])("encodes %s image paths as file URIs", async (_label, imagePath, expectedSource) => { + expect(await renderImageMarkdown(imagePath)).toBe(`\n\n![Image](${expectedSource})\n\n`); }); test("rejects user-authored markdown that is not a materialized attachment", () => { @@ -92,8 +95,8 @@ describe("isProviderImageMarkdown", () => { }); describe("materializeProviderImage", () => { - test("recreates the private temp directory if the cached directory is removed", () => { - const first = materializeProviderImage({ + test("recreates the private temp directory if the cached directory is removed", async () => { + const first = await materializeProviderImage({ data: "YWJjMTIz", mimeType: "image/png", }); @@ -102,7 +105,7 @@ describe("materializeProviderImage", () => { rmSync(firstDir, { recursive: true, force: true }); - const second = materializeProviderImage({ + const second = await materializeProviderImage({ data: "ZGVmNDU2", mimeType: "image/png", }); @@ -117,8 +120,8 @@ describe("materializeProviderImage", () => { }); describe("provider image paragraph boundaries", () => { - test.each(["", "\n"])("keeps image outputs separate from prose ending in %j", (ending) => { - const image = renderProviderImageOutputAsAssistantMarkdown({ + test.each(["", "\n"])("keeps image outputs separate from prose ending in %j", async (ending) => { + const image = await renderProviderImageOutputAsAssistantMarkdown({ url: "https://example.com/chart.png", }); if (!image) throw new Error("Expected image output"); @@ -152,10 +155,10 @@ describe("provider image paragraph boundaries", () => { }); }); -test("materialized image output retains paragraph boundaries and attachment recognition", () => { - const item = renderProviderImageOutputAsAssistantMarkdown( +test("materialized image output retains paragraph boundaries and attachment recognition", async () => { + const item = await renderProviderImageOutputAsAssistantMarkdown( { data: "YWJjMTIz", mimeType: "image/png" }, - { materialize: () => ({ path: `/tmp/paseo-attachments/${HASH}.png` }) }, + { materialize: async () => ({ path: `/tmp/paseo-attachments/${HASH}.png` }) }, ); expect(item).toEqual({ type: "assistant_message", @@ -164,3 +167,76 @@ test("materialized image output retains paragraph boundaries and attachment reco if (!item || item.type !== "assistant_message") throw new Error("Expected image output"); expect(isProviderImageMarkdown(item.text)).toBe(true); }); + +test("repeated content is reused without rewriting the attachment", async () => { + const image = { + data: Buffer.from("dedupe regression").toString("base64"), + mimeType: "image/png", + }; + const first = await materializeProviderImage(image); + const before = statSync(first.path, { bigint: true }); + await new Promise((resolve) => setTimeout(resolve, 20)); + const second = await materializeProviderImage(image); + expect(second.path).toBe(first.path); + expect(statSync(second.path, { bigint: true }).mtimeNs).toBe(before.mtimeNs); +}); + +test("64 MiB materialization leaves the event loop responsive and preserves bytes", async () => { + const bytes = Buffer.alloc(64 * 1024 * 1024, 37); + const data = bytes.toString("base64"); + let previous = performance.now(); + let longest = 0; + const probe = setInterval(() => { + const now = performance.now(); + longest = Math.max(longest, now - previous); + previous = now; + }, 1); + try { + const result = await materializeProviderImage({ data, mimeType: "image/png" }); + await new Promise((resolve) => setTimeout(resolve, 5)); + console.log(`64 MiB attachment longest loop gap: ${longest.toFixed(2)} ms`); + expect(longest).toBeLessThan(50); + expect(readFileSync(result.path).equals(bytes)).toBe(true); + rmSync(result.path, { force: true }); + } finally { + clearInterval(probe); + } +}); + +test("preserves base64 decoding across whitespace and chunk boundaries", async () => { + const bytes = Buffer.alloc(512 * 1024, 123); + const base64 = bytes.toString("base64"); + const data = base64.match(/.{1,77}/g)!.join("\n"); + const result = await materializeProviderImage({ data, mimeType: "image/png" }); + expect(readFileSync(result.path).equals(bytes)).toBe(true); +}); + +test("rejects oversized materialization before work and leaves the queue usable", async () => { + const data = "A".repeat(Math.ceil(MAX_PROVIDER_IMAGE_BYTES / 3) * 4 + 260); + await expect(materializeProviderImage({ data, mimeType: "image/png" })).rejects.toThrow( + "exceeds", + ); + const omitted = await renderProviderImageOutputAsAssistantMarkdown( + { data, mimeType: "image/png" }, + { materialize: materializeProviderImage }, + ); + expect(omitted).toEqual({ + type: "assistant_message", + text: "Image output was omitted because it was not available as a file path or URL.", + }); + const image = await materializeProviderImage({ data: "YWJjMTIz", mimeType: "image/png" }); + expect(readFileSync(image.path).toString()).toBe("abc123"); +}); + +test("simultaneous requests for the same content share the published file", async () => { + const image = { + data: Buffer.from("concurrent dedupe fixture").toString("base64"), + mimeType: "image/png", + }; + const results = await Promise.all([ + materializeProviderImage(image), + materializeProviderImage(image), + ]); + expect(results[0]).toEqual(results[1]); + expect(readFileSync(results[0].path).toString()).toBe("concurrent dedupe fixture"); +}); diff --git a/packages/server/src/server/agent/providers/provider-image-output.ts b/packages/server/src/server/agent/providers/provider-image-output.ts index ba8843918b..0cd0787c1e 100644 --- a/packages/server/src/server/agent/providers/provider-image-output.ts +++ b/packages/server/src/server/agent/providers/provider-image-output.ts @@ -1,5 +1,6 @@ import { createHash } from "node:crypto"; -import * as fsSync from "node:fs"; +import * as fs from "node:fs/promises"; +import { setImmediate } from "node:timers/promises"; import os from "node:os"; import path from "node:path"; @@ -23,32 +24,26 @@ const PRIVATE_ATTACHMENT_DIR_MODE = 0o700; const MATERIALIZED_IMAGE_FILE_MODE = 0o600; let materializedImageAttachmentDir: string | null = null; +// Serialize materializations: one decoder and one file operation at a time, including +// concurrent history replay and live turns. A rejected operation must not poison the queue. +let pendingMaterialization: Promise = Promise.resolve(); -function canReuseMaterializedImageAttachmentDir(dir: string): boolean { - try { - const stats = fsSync.lstatSync(dir); - if (!stats.isDirectory()) { - return false; +async function getMaterializedImageAttachmentDir(): Promise { + if (materializedImageAttachmentDir) { + try { + const stats = await fs.lstat(materializedImageAttachmentDir); + if (stats.isDirectory()) { + await fs.chmod(materializedImageAttachmentDir, PRIVATE_ATTACHMENT_DIR_MODE); + return materializedImageAttachmentDir; + } + } catch { + /* Temp storage may have been removed between turns. */ } - fsSync.chmodSync(dir, PRIVATE_ATTACHMENT_DIR_MODE); - return true; - } catch { - return false; } -} - -function getMaterializedImageAttachmentDir(): string { - if ( - materializedImageAttachmentDir && - canReuseMaterializedImageAttachmentDir(materializedImageAttachmentDir) - ) { - return materializedImageAttachmentDir; - } - - materializedImageAttachmentDir = fsSync.mkdtempSync( + materializedImageAttachmentDir = await fs.mkdtemp( path.join(os.tmpdir(), PROVIDER_IMAGE_ATTACHMENT_DIR_PREFIX), ); - fsSync.chmodSync(materializedImageAttachmentDir, PRIVATE_ATTACHMENT_DIR_MODE); + await fs.chmod(materializedImageAttachmentDir, PRIVATE_ATTACHMENT_DIR_MODE); return materializedImageAttachmentDir; } @@ -71,32 +66,92 @@ function getImageExtension(mimeType: string): string { } } -function normalizeImageData(mimeType: string, data: string): { mimeType: string; data: string } { - if (data.startsWith("data:")) { - const match = data.match(/^data:([^;]+);base64,(.*)$/); - if (match) { - return { mimeType: match[1], data: match[2] }; - } - } - return { mimeType, data }; -} +// 128 MiB admits the measured 64 MiB fixture with headroom while bounding disk +// and decoding per image. This is materialization admission, not a wire schema limit. +export const MAX_PROVIDER_IMAGE_BYTES = 128 * 1024 * 1024; +const BASE64_CHUNK_CHARACTERS = 256 * 1024; // Multiple of four; decode units stay aligned. -// Filenames are a content hash of the bytes so re-materializing the same image -// within a process reuses the existing temp file instead of leaking a fresh one -// for repeated image blocks or history replay. export function materializeProviderImage(image: { data: string; mimeType: string | null; -}): MaterializedProviderImage { - const attachmentsDir = getMaterializedImageAttachmentDir(); - const normalized = normalizeImageData(image.mimeType ?? "image/png", image.data); - const bytes = Buffer.from(normalized.data, "base64"); - const extension = getImageExtension(normalized.mimeType); - const hash = createHash("sha256").update(bytes).digest("hex"); - const filePath = path.join(attachmentsDir, `${hash}.${extension}`); - fsSync.writeFileSync(filePath, bytes, { mode: MATERIALIZED_IMAGE_FILE_MODE }); - fsSync.chmodSync(filePath, MATERIALIZED_IMAGE_FILE_MODE); - return { path: filePath }; +}): Promise { + const result = pendingMaterialization.then(() => materializeImage(image)); + pendingMaterialization = result.catch(() => {}); + return result; +} + +async function materializeImage(image: { + data: string; + mimeType: string | null; +}): Promise { + const maxEncodedCharacters = Math.ceil(MAX_PROVIDER_IMAGE_BYTES / 3) * 4; + // Check length before inspecting/copying the payload, including a bounded data URI header. + if (image.data.length > maxEncodedCharacters + 256) { + throw new Error(`Image attachment exceeds ${MAX_PROVIDER_IMAGE_BYTES} bytes`); + } + let data = image.data; + let mimeType = image.mimeType ?? "image/png"; + if (data.startsWith("data:")) { + const separator = data.indexOf(";base64,"); + if (separator >= 0 && separator < 256) { + mimeType = data.slice(5, separator); + data = data.slice(separator + 8); + } + } + if (data.length > maxEncodedCharacters) { + throw new Error(`Image attachment exceeds ${MAX_PROVIDER_IMAGE_BYTES} bytes`); + } + const hash = createHash("sha256"); + for await (const bytes of decodeImage(data)) hash.update(bytes); + const dir = await getMaterializedImageAttachmentDir(); + const filePath = path.join(dir, `${hash.digest("hex")}.${getImageExtension(mimeType)}`); + try { + await fs.stat(filePath); + return { path: filePath }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + + const staging = await fs.mkdtemp(path.join(dir, ".pending-")); + const stagingPath = path.join(staging, "image"); + try { + await fs.chmod(staging, PRIVATE_ATTACHMENT_DIR_MODE); + const file = await fs.open(stagingPath, "wx", MATERIALIZED_IMAGE_FILE_MODE); + try { + await file.chmod(MATERIALIZED_IMAGE_FILE_MODE); + // Decode again only for a new hash. This bounds retained memory to one chunk + // and performs no writes at all for repeated content, including history replay. + for await (const bytes of decodeImage(data)) await file.writeFile(bytes); + } finally { + await file.close(); + } + await fs.rename(stagingPath, filePath); + return { path: filePath }; + } finally { + await fs.rm(staging, { recursive: true, force: true }); + } +} + +async function* decodeImage(data: string): AsyncGenerator { + let carry = ""; + let decodedBytes = 0; + for (let offset = 0; offset < data.length; offset += BASE64_CHUNK_CHARACTERS) { + // Buffer.from ignores non-base64 characters. Carry incomplete quartets so + // line-wrapped payloads decode identically across chunk boundaries. + const chunk = + carry + + data.slice(offset, offset + BASE64_CHUNK_CHARACTERS).replace(/[^A-Za-z0-9+/_=-]/g, ""); + const finalChunk = offset + BASE64_CHUNK_CHARACTERS >= data.length || chunk.includes("="); + const end = finalChunk ? chunk.length : chunk.length - (chunk.length % 4); + const bytes = Buffer.from(chunk.slice(0, end), "base64"); + carry = chunk.slice(end); + decodedBytes += bytes.length; + if (decodedBytes > MAX_PROVIDER_IMAGE_BYTES) + throw new Error("Image attachment exceeds size limit"); + yield bytes; + await setImmediate(); + if (finalChunk) break; + } } // Recognizes markdown rendered for a materialized provider image: its source is a content-hashed @@ -112,7 +167,10 @@ export function isProviderImageMarkdown(text: string): boolean { } interface RenderProviderImageOutputOptions { - materialize?: (image: { data: string; mimeType: string | null }) => MaterializedProviderImage; + materialize?: (image: { + data: string; + mimeType: string | null; + }) => Promise; } function nonEmptyString(value: string | null | undefined): string | null { @@ -169,10 +227,10 @@ function escapeMarkdownImageSource(value: string): string { return markdownImageSource(value).replace(/\\/g, "\\\\").replace(/\)/g, "\\)"); } -export function renderProviderImageOutputAsAssistantMarkdown( +export async function renderProviderImageOutputAsAssistantMarkdown( image: ProviderImageOutput, options: RenderProviderImageOutputOptions = {}, -): AgentTimelineItem | null { +): Promise { const source = nonEmptyString(image.path) ?? nonEmptyString(image.url); if (source && !isDataImageSource(source)) { const altText = escapeMarkdownImageAlt(nonEmptyString(image.altText) ?? "Image"); @@ -191,7 +249,7 @@ export function renderProviderImageOutputAsAssistantMarkdown( let materialized: MaterializedProviderImage | null = null; try { materialized = options.materialize - ? options.materialize({ + ? await options.materialize({ data, mimeType: nonEmptyString(image.mimeType), }) diff --git a/packages/server/src/server/agent/runtime-mcp-config.ts b/packages/server/src/server/agent/runtime-mcp-config.ts index af04e8021c..711638ce2f 100644 --- a/packages/server/src/server/agent/runtime-mcp-config.ts +++ b/packages/server/src/server/agent/runtime-mcp-config.ts @@ -1,6 +1,6 @@ import type { AgentSessionConfig, McpServerConfig } from "./agent-sdk-types.js"; -const PASEO_MCP_SERVER_NAME = "paseo"; +export const PASEO_MCP_SERVER_NAME = "paseo"; const PASEO_MCP_PATHNAME = "/mcp/agents"; export function stripInternalPaseoMcpServer(config: AgentSessionConfig): AgentSessionConfig { diff --git a/packages/server/src/server/agent/test-utils/timeline-page-reference.ts b/packages/server/src/server/agent/test-utils/timeline-page-reference.ts index 8e9ab228a6..b3a51f22e5 100644 --- a/packages/server/src/server/agent/test-utils/timeline-page-reference.ts +++ b/packages/server/src/server/agent/test-utils/timeline-page-reference.ts @@ -234,7 +234,7 @@ export function selectReferenceProjectedTimelinePage(input: { // Existing selection fixtures exercise the retained owner through its public API. export function selectProjectedTimelinePage( - input: Parameters[0], + input: Parameters[0] & { wholeTurns?: boolean }, ) { const projection = new TimelineProjection(input.rows); const bounds = input.bounds ?? getTimelineBounds(input.rows) ?? { minSeq: 0, maxSeq: 0 }; diff --git a/packages/server/src/server/agent/timeline-projection.test.ts b/packages/server/src/server/agent/timeline-projection.test.ts index f2f832ffcf..d5e4c5b637 100644 --- a/packages/server/src/server/agent/timeline-projection.test.ts +++ b/packages/server/src/server/agent/timeline-projection.test.ts @@ -534,6 +534,154 @@ describe("selectProjectedTimelinePage", () => { }; } + test("whole-turn pages extend past the soft target and keep prompt jumps whole", () => { + const rows: AgentTimelineRow[] = Array.from({ length: 125 }, (_, index) => ({ + seq: index + 1, + timestamp: new Date(1000 + index).toISOString(), + turnId: ["short", "long", "latest"][Number(index >= 3) + Number(index >= 123)], + item: { + type: "tool_call", + callId: `tool-${index}`, + name: "shell", + status: "completed", + error: null, + detail: { type: "unknown", input: null, output: null }, + }, + })); + const tail = selectProjectedTimelinePage({ + rows, + direction: "tail", + limit: 40, + wholeTurns: true, + }); + expect(tail.entries.map((entry) => entry.seqStart)).toEqual( + rows.slice(3).map((row) => row.seq), + ); + expect(tail).toMatchObject({ startSeq: 4, endSeq: 125, hasOlder: true, hasNewer: false }); + const before = selectProjectedTimelinePage({ + rows, + direction: "before", + cursorSeq: 124, + limit: 40, + wholeTurns: true, + }); + expect(before.entries.map((entry) => entry.seqStart)).toEqual( + rows.slice(3, 123).map((row) => row.seq), + ); + expect(before.endSeq).toBe(123); + const older = selectProjectedTimelinePage({ + rows, + direction: "before", + cursorSeq: 4, + limit: 40, + wholeTurns: true, + }); + expect(older.entries.map((entry) => entry.seqStart)).toEqual([1, 2, 3]); + expect(older.hasOlder).toBe(false); + const jump = selectProjectedTimelinePage({ + rows, + direction: "before", + cursorSeq: 70, + limit: 40, + wholeTurns: true, + }); + expect(jump.entries.map((entry) => entry.seqStart)).toEqual( + rows.slice(3, 123).map((row) => row.seq), + ); + expect(jump).toMatchObject({ startSeq: 4, endSeq: 123, hasOlder: true, hasNewer: true }); + const nearPreviousPrompt = selectProjectedTimelinePage({ + rows, + direction: "before", + cursorSeq: 10, + limit: 40, + wholeTurns: true, + }); + expect(nearPreviousPrompt).toMatchObject({ startSeq: 1, endSeq: 123 }); + expect( + selectProjectedTimelinePage({ rows, direction: "tail", limit: 40 }).entries, + ).toHaveLength(40); + }); + + test("whole-turn pages share short runs and use the same deterministic legacy triggers", () => { + const rows: AgentTimelineRow[] = Array.from({ length: 24 }, (_, index) => ({ + seq: index + 1, + timestamp: new Date(1000 + index).toISOString(), + item: + index % 2 === 0 + ? { type: "user_message", text: `prompt-${index}` } + : { type: "assistant_message", text: `reply-${index}` }, + })); + const page = selectProjectedTimelinePage({ + rows, + direction: "tail", + limit: 7, + wholeTurns: true, + }); + expect(page.entries.map((entry) => entry.seqStart)).toEqual([17, 18, 19, 20, 21, 22, 23, 24]); + expect( + selectProjectedTimelinePage({ rows, direction: "tail", limit: 0, wholeTurns: true }).entries, + ).toHaveLength(24); + rows.push({ + ...toolRow(25, "completed"), + item: { ...toolRow(25, "completed").item, name: "heartbeat" } as AgentTimelineRow["item"], + }); + rows.push({ + seq: 26, + timestamp: new Date().toISOString(), + item: { type: "assistant_message", text: "awake" }, + }); + expect( + selectProjectedTimelinePage({ + rows, + direction: "tail", + limit: 1, + wholeTurns: true, + }).entries.map((entry) => entry.seqStart), + ).toEqual([25, 26]); + }); + + test("whole-turn tails close interleaved source coverage without changing forward catch-up", () => { + const rows: AgentTimelineRow[] = [ + { + seq: 1, + timestamp: new Date(1).toISOString(), + turnId: "first", + item: { type: "user_message", text: "go" }, + }, + { ...toolRow(2, "running"), turnId: "first" }, + { + seq: 3, + timestamp: new Date(3).toISOString(), + turnId: "second", + item: { type: "user_message", text: "next" }, + }, + { + seq: 4, + timestamp: new Date(4).toISOString(), + turnId: "second", + item: { type: "assistant_message", text: "done" }, + }, + { ...toolRow(5, "completed"), turnId: "first" }, + ]; + expect( + selectProjectedTimelinePage({ rows, direction: "tail", limit: 1, wholeTurns: true }), + ).toMatchObject({ + startSeq: 1, + endSeq: 5, + hasOlder: false, + hasNewer: false, + }); + expect( + selectProjectedTimelinePage({ + rows, + direction: "after", + cursorSeq: 2, + limit: 1, + wholeTurns: true, + }), + ).toEqual(selectProjectedTimelinePage({ rows, direction: "after", cursorSeq: 2, limit: 1 })); + }); + test("tail page returns full projected items instead of tool lifecycle deltas", () => { const rows: AgentTimelineRow[] = [ { seq: 1, timestamp: "2026-02-13T00:00:00.000Z", item: { type: "user_message", text: "go" } }, diff --git a/packages/server/src/server/agent/timeline-projection.ts b/packages/server/src/server/agent/timeline-projection.ts index 3a65f28531..8b05432545 100644 --- a/packages/server/src/server/agent/timeline-projection.ts +++ b/packages/server/src/server/agent/timeline-projection.ts @@ -1,3 +1,4 @@ +import { continuesTimelineTurn, isTimelineTurnTrigger } from "@getpaseo/protocol/timeline-turn"; import type { AgentTimelineItem, ToolCallDetail } from "./agent-sdk-types.js"; import { timelineItemIdentity } from "@getpaseo/protocol/timeline-identity"; @@ -296,6 +297,7 @@ function mergeAssistantChunks(entries: readonly WorkingEntry[]): WorkingEntry[] export type ProjectedTimelineRow = AgentTimelineRow & TimelineProjectionEntry; interface ProjectedTimelinePageOptions { + wholeTurns?: boolean; bounds: { minSeq: number; maxSeq: number }; direction: TimelineLimitDirection; cursorSeq?: number; @@ -423,7 +425,8 @@ export class TimelineProjection { private selectTail(input: ProjectedTimelinePageOptions): ProjectedTimelinePageSelection { const limit = Math.max(0, Math.floor(input.limit ?? 0)); const requestedStart = limit === 0 ? 0 : Math.max(0, this.rows.length - limit); - const start = this.spans[this.spanIndex(requestedStart)].start; + const coveredStart = this.spans[this.spanIndex(requestedStart)].start; + const start = input.wholeTurns ? this.wholeTurnWindowStart(coveredStart) : coveredStart; return { entries: this.rows.slice(start), startSeq: this.rows[start].seqStart, @@ -433,6 +436,17 @@ export class TimelineProjection { }; } + private wholeTurnWindowStart(requestedStart: number): number { + let start = requestedStart; + for (;;) { + while (start > 0 && continuesProjectedTurn(this.rows[start - 1], this.rows[start])) start--; + // Source coverage can pull in an earlier tool and therefore its entire run. + const coveredStart = this.spans[this.spanIndex(start)].start; + if (coveredStart === start) return start; + start = coveredStart; + } + } + private selectAfter(input: ProjectedTimelinePageOptions): ProjectedTimelinePageSelection { const startSeq = Math.max( input.bounds.minSeq, @@ -480,13 +494,23 @@ export class TimelineProjection { else high = middle; } const limit = Math.max(0, Math.floor(input.limit ?? 0)); - const start = limit === 0 ? 0 : Math.max(0, low - limit); + const requestedStart = limit === 0 ? 0 : Math.max(0, low - limit); + const start = input.wholeTurns ? this.wholeTurnWindowStart(requestedStart) : requestedStart; + // Choose the window before extending the right edge, so a prompt jump + // cannot lose its target when the next turn is arbitrarily large. + if (input.wholeTurns) { + while (low < this.rows.length && continuesProjectedTurn(this.rows[low - 1], this.rows[low])) + low++; + } + let selectedEnd = endSeq; + if (input.wholeTurns) + selectedEnd = low === this.rows.length ? input.bounds.maxSeq : this.rows[low].seqStart - 1; return { entries: this.rows.slice(start, low), startSeq: this.rows[start]?.seqStart ?? null, - endSeq, + endSeq: selectedEnd, hasOlder: start > 0, - hasNewer: endSeq < input.bounds.maxSeq, + hasNewer: selectedEnd < input.bounds.maxSeq, }; } return { entries: [], startSeq: null, endSeq: null, hasOlder: false, hasNewer: true }; @@ -675,6 +699,13 @@ function selectProjectedEntriesAfter(input: { }; } +function continuesProjectedTurn( + previous: TimelineProjectionEntry, + next: TimelineProjectionEntry, +): boolean { + return continuesTimelineTurn(previous.turnId, next.turnId, isTimelineTurnTrigger(next.item)); +} + /** * Apply a projected-count limit to a flat AgentTimelineItem[] without seq metadata. * Used by callers that only have items in hand (e.g. MCP tools reading diff --git a/packages/server/src/server/config.test.ts b/packages/server/src/server/config.test.ts index 72d1c30f7d..53a77338b6 100644 --- a/packages/server/src/server/config.test.ts +++ b/packages/server/src/server/config.test.ts @@ -1,3 +1,4 @@ +import { measureMainThreadWork } from "../test-utils/main-thread-work.js"; import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -25,22 +26,18 @@ describe("server config", () => { const env = { PASEO_PASSWORD: "synthetic-audit-password" }; const persisted = loadPersistedConfig(paseoHome); const startup = resolveConfigFromPersisted(paseoHome, persisted, { env }); - const started = performance.now(); - const timer = new Promise((resolve) => - setTimeout(() => resolve(performance.now() - started), 0), + const { result: reloaded, blockingMs } = await measureMainThreadWork( + "config resolve main-thread work", + () => + resolveReloadableConfig( + { ...persisted, daemon: { ...persisted.daemon, browserTools: { enabled: true } } }, + { env }, + ), ); - const reloaded = resolveReloadableConfig( - { - ...persisted, - daemon: { ...persisted.daemon, browserTools: { enabled: true } }, - }, - { env }, - ); - const gap = await timer; expect(reloaded).not.toHaveProperty("auth"); expect(reloaded.browserToolsEnabled).toBe(true); expect(startup.auth?.password).toBeTypeOf("string"); - expect(gap).toBeLessThan(50); + expect(blockingMs).toBeLessThan(50); }); test("records when the daemon is managed by Paseo Desktop", async () => { diff --git a/packages/server/src/server/plugins/agent-configuration.e2e.test.ts b/packages/server/src/server/plugins/agent-configuration.e2e.test.ts index 3228306297..1cfad77e05 100644 --- a/packages/server/src/server/plugins/agent-configuration.e2e.test.ts +++ b/packages/server/src/server/plugins/agent-configuration.e2e.test.ts @@ -4,17 +4,29 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { expect, test } from "vitest"; import { DaemonClient } from "../test-utils/daemon-client.js"; +import type { AgentSessionConfig } from "../agent/agent-sdk-types.js"; import { createTestAgentClient } from "../test-utils/fake-agent-client.js"; import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; -test("the configuration example adds MCP servers and overrides Codex options while preserving other configuration", async () => { +test("the configuration example saves Codex options and injects launch-only MCP servers while preserving other configuration", async () => { const directory = await mkdtemp(path.join(tmpdir(), "paseo-config-example-")); + const launches: AgentSessionConfig[] = []; const daemon = await createTestPaseoDaemon({ - daemonVersion: "0.8.0", - agentClients: { codex: createTestAgentClient("codex", { supportsMcpServers: true }) }, + daemonVersion: "0.13.2", + agentClients: { + codex: createTestAgentClient("codex", { + supportsMcpServers: true, + beforeCreateSession: async (config) => { + launches.push(config); + }, + }), + }, mcpEnabled: false, }); - const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" }); + const client = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + appVersion: "0.13.2", + }); try { await client.connect(); await client.fetchAgents({ subscribe: {} }); @@ -44,9 +56,15 @@ test("the configuration example adds MCP servers and overrides Codex options whi }, mcpServers: { existing: { type: "http", url: "https://existing.example.com/mcp" }, - company: { type: "http", url: "https://tools.example.com/mcp" }, }, }); + expect(daemon.daemon.agentManager.getAgent(agent.id)!.config.mcpServers).toEqual({ + existing: { type: "http", url: "https://existing.example.com/mcp" }, + }); + expect(launches[0].mcpServers).toEqual({ + existing: { type: "http", url: "https://existing.example.com/mcp" }, + company: { type: "http", url: "https://tools.example.com/mcp" }, + }); await client.archiveAgent(agent.id); } finally { await client.close(); diff --git a/packages/server/src/server/plugins/agent-tools.e2e.test.ts b/packages/server/src/server/plugins/agent-tools.e2e.test.ts index 14629aa15e..5b03bae09b 100644 --- a/packages/server/src/server/plugins/agent-tools.e2e.test.ts +++ b/packages/server/src/server/plugins/agent-tools.e2e.test.ts @@ -1,5 +1,5 @@ import { fileURLToPath } from "node:url"; -import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { expect, test } from "vitest"; @@ -11,6 +11,7 @@ import { type TestAgentClientOptions, } from "../test-utils/fake-agent-client.js"; import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; +import { importSessionFromPersistence } from "../agent/provider-session-import.js"; import { BuiltinPluginLoader } from "./builtin/index.js"; import { serializePaseoToolInputParameters } from "../agent/tools/paseo-tool-serialization.js"; import type { @@ -27,7 +28,7 @@ function recordingProvider(native: boolean, callbacks: TestAgentClientOptions = const events = { interrupts: 0 }; const inner = createTestAgentClient("codex", { ...callbacks, - supportsMcpServers: true, + supportsMcpServers: callbacks.supportsMcpServers ?? true, onStartTurn: (prompt, config) => { turns.push({ prompt, launchCount: launches.length }); callbacks.onStartTurn?.(prompt, config); @@ -52,6 +53,13 @@ function recordingProvider(native: boolean, callbacks: TestAgentClientOptions = launches.push({ config: config as AgentSessionConfig, context }); return inner.resumeSession(handle, config, context, options); }, + importSession: (input, context) => + importSessionFromPersistence({ + provider: "codex", + request: input, + context, + resumeSession: provider.resumeSession.bind(provider), + }), isAvailable: () => inner.isAvailable(), fetchCatalog: (options) => inner.fetchCatalog(options), }; @@ -750,3 +758,285 @@ test("the agent-tools example appends notes only for labeled agents", async () = await h.close(); } }, 60_000); + +const companyServer = { type: "http", url: "https://tools.example.com/mcp" } as const; +const mcpSource = ` +export default function contribute(server) { + server.before("agent.session_open", ({ request }) => { + if (request.provider !== "codex") return; + return { ...request, mcpServers: { ...request.mcpServers, company: ${JSON.stringify(companyServer)} }, + env: { ...request.env, OPEN_REASON: request.reason } }; + }); + return () => {}; +} +`; + +test.each([ + { native: false, serverNames: ["paseo", "company"] }, + { native: true, serverNames: ["company"] }, +])( + "session-open MCP servers reach launch but never saved configuration (native=$native)", + async ({ native, serverNames }) => { + const h = await setup(native, mcpSource); + try { + const a = await h.create(); + expect(h.launches.at(-1)!.config.mcpServers?.company).toEqual(companyServer); + expect(a.config.mcpServers?.company).toBeUndefined(); + const snapshot = (await h.client.fetchAgent(a.id))!.agent; + expect(snapshot.id).toBe(a.id); + expect(JSON.stringify(snapshot)).not.toContain("tools.example.com"); + expect(h.launches.at(-1)!.context!.env!.OPEN_REASON).toBe("create"); + expect(Object.keys(h.launches.at(-1)!.config.mcpServers!)).toEqual(serverNames); + await h.daemon.daemon.agentManager.closeAgent(a.id); + const agentFiles = await readdir(path.join(h.daemon.paseoHome, "agents"), { + recursive: true, + }); + const recordFile = agentFiles.find((file) => file.endsWith(a.id + ".json"))!; + const record = await readFile(path.join(h.daemon.paseoHome, "agents", recordFile), "utf8"); + expect(record).not.toContain("tools.example.com"); + } finally { + await h.close(); + } + }, + 60_000, +); + +test("session-open MCP servers are rederived on resume, SDK reload, and provider import", async () => { + const source = reloadRpcSource + .replace(' if (request.labels.x !== "y") return;', "") + .replace( + "return { ...request, env:", + `return { ...request, mcpServers: { ...request.mcpServers, company: ${JSON.stringify(companyServer)} }, env:`, + ); + const h = await setup(true, source); + try { + const a = await h.create({ x: "y" }); + const manager = h.daemon.daemon.agentManager; + await manager.closeAgent(a.id); + await manager.resumeAgentFromPersistence(a.persistence!, a.config, a.id); + expect(h.launches.at(-1)!.config.mcpServers?.company).toEqual(companyServer); + expect(JSON.parse(h.launches.at(-1)!.context!.env!.OPEN_INFO).reason).toBe("resume"); + await h.client.invokePluginRpc("tools-test", "reload_agent", { id: a.id }); + expect(h.launches.at(-1)!.config.mcpServers?.company).toEqual(companyServer); + expect(JSON.parse(h.launches.at(-1)!.context!.env!.OPEN_INFO).reason).toBe("refresh"); + const imported = await manager.importProviderSession({ + provider: "codex", + providerHandleId: "imported-session", + cwd: h.directory, + workspaceId: "import-workspace", + labels: { x: "y" }, + }); + expect(h.launches.at(-1)!.config.mcpServers?.company).toEqual(companyServer); + expect(JSON.parse(h.launches.at(-1)!.context!.env!.OPEN_INFO).reason).toBe("import"); + expect(imported.config.mcpServers?.company).toBeUndefined(); + expect(JSON.stringify(imported.persistence)).not.toContain("tools.example.com"); + expect(manager.getAgent(a.id)!.config.mcpServers?.company).toBeUndefined(); + expect(JSON.stringify(manager.getAgent(a.id)!.persistence)).not.toContain("tools.example.com"); + } finally { + await h.close(); + } +}, 60_000); + +test("installing and disabling an MCP plugin reopens idle sessions before their next prompt", async () => { + const h = await setup(true, "export default function contribute() { return () => {}; }"); + try { + const a = await h.create(); + expect(h.launches.at(-1)!.config.mcpServers?.company).toBeUndefined(); + await h.install("company-mcp", mcpSource); + await sendAndFinish(h, a.id, "after install"); + expect(h.launches).toHaveLength(2); + expect(h.launches.at(-1)!.config.mcpServers?.company).toEqual(companyServer); + await h.client.disablePlugin("company-mcp"); + await sendAndFinish(h, a.id, "after disable"); + expect(h.launches).toHaveLength(3); + expect(h.launches.at(-1)!.config.mcpServers?.company).toBeUndefined(); + await sendAndFinish(h, a.id, "stable"); + expect(h.launches).toHaveLength(3); + expect(h.events.interrupts).toBe(0); + } finally { + await h.close(); + } +}, 60_000); + +test("installing and disabling an MCP plugin never interrupts a running turn", async () => { + let release!: () => void; + const blocked = new Promise((resolve) => { + release = resolve; + }); + const h = await setup( + true, + "export default function contribute() { return () => {}; }", + undefined, + { beforeTurnComplete: async () => blocked }, + ); + try { + const a = await h.create(); + await h.client.sendMessage(a.id, "busy", { activeTurnBehavior: "steer" }); + const manager = h.daemon.daemon.agentManager; + await expect.poll(() => manager.getAgent(a.id)!.activeTurnId).not.toBeNull(); + const turn = manager.getAgent(a.id)!.activeTurnId; + await h.install("company-mcp", mcpSource); + expect(h.launches).toHaveLength(1); + expect(manager.getAgent(a.id)!.activeTurnId).toBe(turn); + await h.client.disablePlugin("company-mcp"); + expect(h.launches).toHaveLength(1); + expect(manager.getAgent(a.id)!.activeTurnId).toBe(turn); + expect(h.events.interrupts).toBe(0); + release(); + await expect.poll(() => manager.hasInFlightRun(a.id)).toBe(false); + await sendAndFinish(h, a.id, "idle"); + expect(h.launches).toHaveLength(2); + expect(h.launches.at(-1)!.config.mcpServers?.company).toBeUndefined(); + } finally { + release(); + await h.close(); + } +}, 60_000); + +test.each([ + { + target: "saved configuration", + saved: true, + name: "company", + other: false, + error: /company.*plugin tools-test.*agent saved configuration/, + }, + { + target: "saved Paseo name", + saved: true, + name: "paseo", + other: false, + error: /paseo.*plugin tools-test.*agent saved configuration/, + }, + { + target: "Paseo internal server", + saved: false, + name: "paseo", + other: false, + error: /paseo.*plugin tools-test.*Paseo internal MCP server/, + }, + { + target: "another plugin with identical config", + saved: false, + name: "company", + other: true, + error: /Plugin tools-test.*company.*plugin other-mcp/, + }, +])( + "session-open MCP collisions with $target name both owners", + async ({ saved, name, other, error }) => { + const h = await setup(true, mcpSource.replace("company:", name + ":")); + try { + if (other) await h.install("other-mcp", mcpSource); + await expect( + h.daemon.daemon.agentManager.createAgent( + { + provider: "codex", + cwd: h.directory, + ...(saved + ? { mcpServers: { [name]: { type: "http", url: "https://saved.example.com" } } } + : {}), + }, + undefined, + {}, + ), + ).rejects.toThrow(error); + expect(h.launches).toHaveLength(0); + } finally { + await h.close(); + } + }, + 60_000, +); + +test("session-open MCP servers fail on unsupported providers and leave saved config untouched", async () => { + const h = await setup(false, mcpSource, undefined, { supportsMcpServers: false }); + try { + await h.client.disablePlugin("tools-test"); + const a = await h.create(); + await h.client.enablePlugin("tools-test"); + await expect(h.daemon.daemon.agentManager.reloadAgentSession(a.id)).rejects.toThrow( + /does not support MCP servers.*plugin tools-test/, + ); + expect(a.config.mcpServers?.company).toBeUndefined(); + await expect(h.create()).rejects.toThrow(/does not support MCP servers.*plugin tools-test/); + const imported = h.daemon.daemon.agentManager.importProviderSession({ + provider: "codex", + providerHandleId: "unsupported-import", + cwd: h.directory, + workspaceId: "import-workspace", + }); + await expect(imported).rejects.toThrow(/does not support MCP servers.*plugin tools-test/); + } finally { + await h.close(); + } +}, 60_000); + +test("history opens ignore session-open MCP servers", async () => { + const h = await setup(true, mcpSource); + try { + const a = await h.create(); + await h.client.archiveAgent(a.id); + await h.install("other-mcp", mcpSource); + await h.daemon.daemon.agentManager.resumeAgentFromPersistence(a.persistence!, a.config, a.id); + expect(h.launches.at(-1)!.config.mcpServers?.company).toBeUndefined(); + expect(h.launches.at(-1)!.context!.mcpServers).toBeUndefined(); + } finally { + await h.close(); + } +}, 60_000); + +test("session-open MCP maps are plugin-local and accumulate only across their own callbacks", async () => { + const h = await setup(true, mcpSource); + try { + await h.install( + "z-extend", + ` +export default function contribute(server) { + server.before("agent.session_open", ({ request }) => { + if ("mcpServerOwners" in request) throw new Error("Daemon provenance leaked"); + if (Object.keys(request.mcpServers).length !== 0) throw new Error("Saw another plugin servers"); + return { ...request, mcpServers: { ...request.mcpServers, extra: { type: "stdio", command: "company-tools", args: ["--mcp"], env: { MODE: "agent" } } } }; + }); + server.before("agent.session_open", ({ request }) => { + if (request.mcpServers.extra.command !== "company-tools") throw new Error("Lost own earlier server"); + return { ...request, mcpServers: { ...request.mcpServers, additional: { type: "http", url: "https://additional.example.com/mcp" } } }; + }); + server.before("agent.session_open", () => undefined); + return () => {}; +}`, + ); + const a = await h.create(); + expect(h.launches.at(-1)!.config.mcpServers).toEqual({ + company: companyServer, + extra: { type: "stdio", command: "company-tools", args: ["--mcp"], env: { MODE: "agent" } }, + additional: { type: "http", url: "https://additional.example.com/mcp" }, + }); + const contributedServers = h.launches.at(-1)!.config.mcpServers; + await h.install( + "zz-remove", + `export default function contribute(server) { + server.before("agent.session_open", ({ request }) => ({ ...request, mcpServers: {} })); + return () => {}; +}`, + ); + await sendAndFinish(h, a.id, "after removal"); + expect(h.launches.at(-1)!.config.mcpServers).toEqual(contributedServers); + expect(a.config.mcpServers).toBeUndefined(); + } finally { + await h.close(); + } +}, 60_000); + +test("session-open MCP configs are validated before a provider launches", async () => { + const h = await setup( + true, + mcpSource.replace(JSON.stringify(companyServer), '{ type: "http", url: 42 }'), + ); + try { + await expect(h.create()).rejects.toThrow(/Plugin tools-test.*before agent.session_open/s); + expect(h.launches).toHaveLength(0); + } finally { + await h.close(); + } +}, 60_000); diff --git a/packages/server/src/server/plugins/builtin/index.test.ts b/packages/server/src/server/plugins/builtin/index.test.ts index 6160c8f855..5f4728ecfa 100644 --- a/packages/server/src/server/plugins/builtin/index.test.ts +++ b/packages/server/src/server/plugins/builtin/index.test.ts @@ -78,6 +78,45 @@ test("listed client bundle is published while plugins are disabled; unlisted dir } }, 60_000); +test("a repeated catalog fetch leaves out the client bundle the client already holds", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "paseo-builtin-catalog-cache-")); + roots.push(root); + const directory = await fixture(root, "large", true); + // About the size of a real plugin client bundle. + await writeFile( + path.join(directory, "index.client.tsx"), + `const payload = ${JSON.stringify("x".repeat(1_000_000))}; +export default function contribute() { void payload; return () => {}; }`, + ); + const daemon = await createTestPaseoDaemon({ + daemonVersion: "0.9.2", + pluginsEnabled: false, + builtinPlugins: new BuiltinPluginLoader(root, ["large"]), + }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.9.2" }); + const responseBytes: number[] = []; + const unsubscribe = client.subscribeRawMessages((message) => { + if (message.type === "plugin.catalog.get.response") { + responseBytes.push(JSON.stringify(message).length); + } + }); + try { + await client.connect(); + const first = await client.getPluginCatalog(); + const second = await client.getPluginCatalog(); + + expect(second).toEqual(first); + expect(second[0]?.clientBundle).toContain("x".repeat(1_000_000)); + expect(responseBytes).toHaveLength(2); + expect(responseBytes[0]).toBeGreaterThan(1_000_000); + expect(responseBytes[1]).toBeLessThan(500); + } finally { + unsubscribe(); + await client.close(); + await daemon.close(); + } +}, 60_000); + test("directory, Git, and npm installs reject a built-in ID", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "paseo-builtin-install-")); roots.push(root); diff --git a/packages/server/src/server/plugins/compiler-build.ts b/packages/server/src/server/plugins/compiler-build.ts index bfc18d1c64..51eb2d2549 100644 --- a/packages/server/src/server/plugins/compiler-build.ts +++ b/packages/server/src/server/plugins/compiler-build.ts @@ -268,7 +268,7 @@ async function minifyClientBundle(code: string): Promise { target: "es2020", supported: { "async-await": false }, charset: "ascii", - legalComments: "none", + legalComments: "inline", }); return result.code; } diff --git a/packages/server/src/server/plugins/compiler.test.ts b/packages/server/src/server/plugins/compiler.test.ts index 720d17681c..69510cdfb0 100644 --- a/packages/server/src/server/plugins/compiler.test.ts +++ b/packages/server/src/server/plugins/compiler.test.ts @@ -3,6 +3,7 @@ import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; +import * as zod from "zod"; import { ZodError } from "zod"; import { compilePlugin, @@ -569,40 +570,77 @@ export default function contribute(client) { expect(clientBundle).toContain("__async"); }); - it("minifies the client bundle after Hermes interop getters are eager", async () => { + it("reduces client bytes while preserving registration and leaving server output readable", async () => { const entries = await createSplitPlugin(); - await writeFile( - entries.client, - `import { useState } from "react"; -const label = "\\u2013"; + const source = `import { z } from "zod"; +const commandSchema = z.object({ id: z.string(), title: z.string() }); export default function contribute(client) { - void useState; - void label; - client.addCommandCenterItem({ id: "open", title: "Open", icon: "Scan", context: "global", onSelect() {} }); + const command = commandSchema.parse({ id: "open", title: "Open – plugin" }); + client.addCommandCenterItem({ + ...command, + icon: "Scan", + context: "global", + onSelect() { return Promise.resolve(command.title); }, + }); return () => undefined; -}`, - ); - await writeFile( - entries.server, - `import { z } from "zod"; -export default function contribute() { - void z.object({}); - return () => undefined; -}`, - ); - +}`; + await Promise.all([writeFile(entries.client, source), writeFile(entries.server, source)]); const { clientBundle, serverBundle } = await compilePlugin(entries); if (clientBundle === null || serverBundle === null) { throw new Error("compilePlugin returned no bundle"); } - expect(clientBundle).not.toContain("get: () => from[key]"); - expect(clientBundle).not.toContain("get:()=>from[key]"); - expect(clientBundle).toContain("value:from[key]"); + for (const bundle of [clientBundle, serverBundle]) { + const registered: { id: string; title: string; onSelect: () => Promise }[] = []; + const exports = new Function(`return ${bundle}`)()((specifier: string) => { + expect(specifier).toBe("zod"); + return zod; + }); + const cleanup = exports.default({ + addCommandCenterItem: (command: (typeof registered)[number]) => registered.push(command), + }); + expect(registered).toMatchObject([{ id: "open", title: "Open – plugin" }]); + await expect(registered[0]!.onSelect()).resolves.toBe("Open – plugin"); + expect(cleanup()).toBeUndefined(); + } + expect(Buffer.byteLength(clientBundle)).toBeLessThan(Buffer.byteLength(serverBundle) * 0.9); expect(clientBundle.split("").every((char) => char.charCodeAt(0) <= 0x7f)).toBe(true); - expect(serverBundle).toContain("value: from[key]"); - expect(serverBundle).not.toContain("value:from[key]"); - expect(clientBundle.split("\n").length).toBeLessThan(serverBundle.split("\n").length); + expect(serverBundle).toContain('title: "Open'); + }); + + it("retains dependency license notices while minifying executable client code", async () => { + const entries = await createSplitPlugin(); + const dependency = path.join(entries.directory, "node_modules/licensed-dependency"); + const notice = "Copyright Example Authors. SPDX-License-Identifier: MIT"; + await mkdir(dependency, { recursive: true }); + await writeFile( + path.join(dependency, "package.json"), + JSON.stringify({ name: "licensed-dependency", main: "index.js" }), + ); + await writeFile( + path.join(dependency, "index.js"), + `/*! ${notice} */ +exports.title = function title(input) { + const trimmed = input.trim(); + return "Licensed " + trimmed; +};`, + ); + const source = `import { title } from "licensed-dependency"; +export default function contribute() { return title(" plugin "); }`; + await Promise.all([writeFile(entries.client, source), writeFile(entries.server, source)]); + const { clientBundle, serverBundle } = await compilePlugin(entries); + if (clientBundle === null || serverBundle === null) { + throw new Error("compilePlugin returned no bundle"); + } + + for (const bundle of [clientBundle, serverBundle]) { + expect(bundle).toContain(notice); + const exports = new Function(`return ${bundle}`)()(() => { + throw new Error("The dependency must be bundled"); + }); + expect(exports.default()).toBe("Licensed plugin"); + } + expect(Buffer.byteLength(clientBundle)).toBeLessThan(Buffer.byteLength(serverBundle) * 0.9); }); it("keeps distinct host exports eager when minifying in the worker", async () => { diff --git a/packages/server/src/server/plugins/lifecycle/handlers.test.ts b/packages/server/src/server/plugins/lifecycle/handlers.test.ts index bafd1f9b94..fe86f6a754 100644 --- a/packages/server/src/server/plugins/lifecycle/handlers.test.ts +++ b/packages/server/src/server/plugins/lifecycle/handlers.test.ts @@ -96,6 +96,7 @@ test("session-open hooks reject changes to session identity instead of silently title: null, labels: {}, tools: [], + mcpServers: {}, }, paseo, ), @@ -120,12 +121,13 @@ test.each([{ title: "Changed" }, { labels: { changed: "true" } }])( title: "Original", labels: {}, tools: [], + mcpServers: {}, env: {}, reason: "create", purpose: "interactive", }, paseo, ), - ).rejects.toThrow("agent.session_open hooks can only change env and tools"); + ).rejects.toThrow("agent.session_open hooks can only change env, tools, and mcpServers"); }, ); diff --git a/packages/server/src/server/plugins/lifecycle/index.ts b/packages/server/src/server/plugins/lifecycle/index.ts index 2b379eb7f8..c43e113e38 100644 --- a/packages/server/src/server/plugins/lifecycle/index.ts +++ b/packages/server/src/server/plugins/lifecycle/index.ts @@ -27,6 +27,11 @@ export type PluginWireBeforeRequests = Omit }; +}; + export const lifecycleEventNames = [ "agent.created", "agent.closed", @@ -54,6 +59,7 @@ const beforeSchemas = { labels: z.record(z.string(), z.string()), tools: z.array(agentToolMetadataSchema), env: z.record(z.string(), z.string()), + mcpServers: CreateAgentRequestMessageSchema.shape.config.shape.mcpServers.unwrap(), }) .strict(), "workspace.create": WorkspaceCreateRequestSchema.omit({ type: true, requestId: true }).strict(), @@ -80,7 +86,7 @@ export interface PluginLifecycle { before( name: Name, request: PluginWireBeforeRequests[Name], - ): Promise; + ): Promise; } export function validateBeforeRequest( @@ -176,7 +182,7 @@ export function validateBeforeResult { - const now = performance.now(); - longestGap = Math.max(longestGap, now - previous); - previous = now; - }, 1); - try { - const reloaded = await client.reloadDaemonConfig(); - longestGap = Math.max(longestGap, performance.now() - previous); - expect(reloaded.appliedPaths).toContain("daemon.browserTools.enabled"); - expect(longestGap).toBeLessThan(50); - } finally { - clearInterval(probe); - } + const { result: reloaded, blockingMs } = await measureMainThreadWork( + "daemon config reload main-thread work", + () => client.reloadDaemonConfig(), + ); + expect(reloaded.appliedPaths).toContain("daemon.browserTools.enabled"); + expect(blockingMs).toBeLessThan(50); expect((await client.getDaemonConfig()).config.browserTools.enabled).toBe(true); // A new physical session uses ready daemon state; corrupt config is reported only by an explicit read. await writeFile(configPath, "invalid JSON"); diff --git a/packages/server/src/server/plugins/requirements.posix.test.ts b/packages/server/src/server/plugins/requirements.posix.test.ts index 65462336a3..597d907e18 100644 --- a/packages/server/src/server/plugins/requirements.posix.test.ts +++ b/packages/server/src/server/plugins/requirements.posix.test.ts @@ -1,4 +1,5 @@ import { mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import { createHash } from "node:crypto"; import { tmpdir } from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; @@ -68,9 +69,18 @@ it("rejects incompatible installs without persisting them, then accepts the corr await expect(service.installDirectory({ path: root })).resolves.toMatchObject({ status: "running", }); - expect(service.catalog()).toEqual([ - { id: "example", requirements: { paseo: "^0.8.0" }, clientBundle: expect.any(String) }, + const catalog = service.catalog(); + expect(catalog).toEqual([ + { + id: "example", + requirements: { paseo: "^0.8.0" }, + clientBundle: expect.any(String), + clientBundleHash: expect.any(String), + }, ]); + expect(catalog[0]!.clientBundleHash).toBe( + createHash("sha256").update(catalog[0]!.clientBundle).digest("hex"), + ); }); it("marks pre-0.8 plugins failed on startup and recovers after migration and reload", async () => { diff --git a/packages/server/src/server/plugins/runtime.posix.test.ts b/packages/server/src/server/plugins/runtime.posix.test.ts index 30ada955a6..8004fd52bf 100644 --- a/packages/server/src/server/plugins/runtime.posix.test.ts +++ b/packages/server/src/server/plugins/runtime.posix.test.ts @@ -1,5 +1,6 @@ import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises"; import { fork } from "node:child_process"; +import { createHash } from "node:crypto"; import { tmpdir } from "node:os"; import { PassThrough } from "node:stream"; import path from "node:path"; @@ -106,6 +107,12 @@ function createReloadChild( }; } +function sha256(text: string | undefined): string { + return createHash("sha256") + .update(text ?? "") + .digest("hex"); +} + function createTestRuntime( dependencies: NonNullable[2]> = {}, logger = pino({ level: "silent" }), @@ -651,7 +658,8 @@ lines.on("line", (line) => { type: "timeline.item", item: expect.objectContaining({ type: "assistant_message", - id: "assistant-1", + id: JSON.stringify(["agent_message_chunk", "assistant-1"]), + messageId: "assistant-1", text: "ACP says hi", }), }), @@ -1535,6 +1543,7 @@ export default function contribute(server: { registerProvider(provider: Provider expect(spawnChild).not.toHaveBeenCalled(); expect(runtime.catalog()[0]?.clientBundle).toContain("addTheme"); + expect(runtime.catalog()[0]?.clientBundleHash).toBe(sha256(runtime.catalog()[0]?.clientBundle)); await runtime.stopAll(); }); @@ -1697,6 +1706,7 @@ export function inspectHost(_input: z.input) { const plugin = runtime.catalog()[0]; expect(plugin?.clientBundle).toContain("Client-only surface"); expect(plugin?.clientBundle).not.toContain("node:os"); + expect(plugin?.clientBundleHash).toBe(sha256(plugin?.clientBundle)); await expect(runtime.invoke("split-runtime", "inspect", {})).resolves.toMatchObject({ platform: expect.any(String), }); diff --git a/packages/server/src/server/plugins/runtime.ts b/packages/server/src/server/plugins/runtime.ts index bc06d5d991..5a15020cef 100644 --- a/packages/server/src/server/plugins/runtime.ts +++ b/packages/server/src/server/plugins/runtime.ts @@ -1,4 +1,4 @@ -import type { PluginWireBeforeRequests } from "./lifecycle/index.js"; +import type { PluginWireBeforeRequests, PluginBeforeResults } from "./lifecycle/index.js"; import type { SessionOpenWireRequest, AttachedAgentTool } from "./agent-tools.js"; import type { UsageScope } from "@getpaseo/plugin/server/usage"; import { @@ -15,7 +15,7 @@ import { validateBeforeRequest, validateBeforeResult } from "./lifecycle/index.j import { fork } from "node:child_process"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { randomUUID } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; import type pino from "pino"; import { PROVIDER_CAPABILITIES, @@ -74,6 +74,8 @@ interface LoadedPlugin { instanceId: string; requirements: PluginRequirements | undefined; clientBundle: string; + /** Lets a client that already holds this bundle skip downloading it again. */ + clientBundleHash: string; methods: ReadonlySet; hooks: { events: string[]; before: string[] }; providers: readonly PluginProviderMetadata[]; @@ -246,6 +248,10 @@ function terminatePluginChild(child: PluginChild): void { if (!child.killed) child.kill(); } +function hashClientBundle(clientBundle: string): string { + return createHash("sha256").update(clientBundle).digest("hex"); +} + function describeError(error: unknown): string { return error instanceof Error ? error.message : String(error); } @@ -369,9 +375,19 @@ export class PluginRuntime { return true; } - catalog(): Array<{ id: string; clientBundle: string; requirements?: PluginRequirements }> { + catalog(): Array<{ + id: string; + clientBundle: string; + clientBundleHash: string; + requirements?: PluginRequirements; + }> { return [...this.plugins.values()] - .map(({ id, clientBundle, requirements }) => ({ id, clientBundle, requirements })) + .map(({ id, clientBundle, clientBundleHash, requirements }) => ({ + id, + clientBundle, + clientBundleHash, + requirements, + })) .sort((left, right) => left.id.localeCompare(right.id)); } @@ -535,12 +551,14 @@ export class PluginRuntime { async before( name: Name, input: PluginWireBeforeRequests[Name], - ): Promise { + ): Promise { let request = validateBeforeRequest(name, input); const plugins = [...this.plugins.values()].sort((left, right) => { return left.id.localeCompare(right.id); }); const attached: AttachedAgentTool[] = []; + const mcpServers: SessionOpenWireRequest["mcpServers"] = {}; + const mcpServerOwners: Record = {}; for (const loaded of plugins) { if (!loaded.hooks.before.includes(name)) { continue; @@ -551,11 +569,14 @@ export class PluginRuntime { requestId: randomUUID(), kind: "before", name, - input: name === "agent.session_open" ? { ...request, tools: [] } : request, + input: + name === "agent.session_open" ? { ...request, tools: [], mcpServers: {} } : request, }); request = validateBeforeResult(name, request, output); if (name === "agent.session_open") { - this.collectAgentTools(attached, loaded, (request as SessionOpenWireRequest).tools); + const opened = request as SessionOpenWireRequest; + this.collectMcpServers(mcpServers, mcpServerOwners, loaded, opened.mcpServers); + this.collectAgentTools(attached, loaded, opened.tools); } } catch (error) { if (name === "agent.session_open") @@ -565,7 +586,14 @@ export class PluginRuntime { }); } } - if (name === "agent.session_open") (request as SessionOpenWireRequest).tools = attached; + if (name === "agent.session_open") { + return { + ...request, + tools: attached, + mcpServers, + mcpServerOwners, + } as PluginBeforeResults[Name]; + } return request; } @@ -587,6 +615,23 @@ export class PluginRuntime { return output; } + private collectMcpServers( + servers: SessionOpenWireRequest["mcpServers"], + owners: Record, + loaded: LoadedPlugin, + contributions: SessionOpenWireRequest["mcpServers"], + ): void { + for (const [name, server] of Object.entries(contributions)) { + if (Object.hasOwn(servers, name)) { + throw new Error( + `MCP server ${name} from plugin ${loaded.id} collides with plugin ${owners[name]}`, + ); + } + servers[name] = server; + owners[name] = loaded.id; + } + } + private collectAgentTools( attached: AttachedAgentTool[], loaded: LoadedPlugin, @@ -710,10 +755,12 @@ export class PluginRuntime { if (!canPublish()) throw new Error(`Plugin start cancelled: ${pluginId}`); const serverBundle = bundles.serverBundle; if (!serverBundle) { + const clientBundle = bundles.clientBundle ?? ""; return { id: pluginId, instanceId: randomUUID(), - clientBundle: bundles.clientBundle ?? "", + clientBundle, + clientBundleHash: hashClientBundle(clientBundle), requirements: manifest.requirements, methods: new Set(), hooks: { events: [], before: [] }, @@ -843,6 +890,7 @@ export class PluginRuntime { id: pluginId, instanceId: randomUUID(), clientBundle, + clientBundleHash: hashClientBundle(clientBundle), requirements, methods: new Set(ready.methods), hooks: ready.hooks ?? { events: [], before: [] }, diff --git a/packages/server/src/server/plugins/test-fixtures/compile-responsiveness.e2e.ts b/packages/server/src/server/plugins/test-fixtures/compile-responsiveness.e2e.ts index 229e388b34..95aaec0d6a 100644 --- a/packages/server/src/server/plugins/test-fixtures/compile-responsiveness.e2e.ts +++ b/packages/server/src/server/plugins/test-fixtures/compile-responsiveness.e2e.ts @@ -1,9 +1,7 @@ +import { measureMainThreadWork } from "../../../test-utils/main-thread-work.js"; import assert from "node:assert/strict"; -import { createHook, executionAsyncResource } from "node:async_hooks"; import { open as openFile, writeFile } from "node:fs/promises"; -import { performance, PerformanceObserver } from "node:perf_hooks"; import path from "node:path"; -import { MessagePort } from "node:worker_threads"; import pino from "pino"; import { DaemonConfigStore } from "../../daemon-config-store.js"; import { PluginService } from "../index.js"; @@ -53,125 +51,9 @@ try { () => service.enablePlugin("responsive-build"), () => service.reloadPlugin("responsive-build"), ]) { - // Node 22.19+ exposes per-thread CPU accounting; the repository's older - // Node declarations do not include it yet. .tool-versions pins Node 22.20. - const threadCpuUsage: (previous?: NodeJS.CpuUsage) => NodeJS.CpuUsage = Reflect.get( - process, - "threadCpuUsage", - ); - const startedAt = performance.now(); - let previous = startedAt; - let cpu = threadCpuUsage(); - let longestWall = 0; - let longestBlocking = 0; - let longestWallCpu = 0; - let workerStarts = 0; - let portCallbacks = 0; - let longestWallEvents = { workerStarts: 0, portCallbacks: 0 }; - let longestBlockingWindow = { wallMs: 0, cpuMs: 0, resource: "" }; - let probe: ReturnType; - const callbacks = new Map(); - const recordBlocking = ( - blockingMs: number, - window: { wallMs: number; cpuMs: number; resource: string }, - ) => { - if (blockingMs > longestBlocking) { - longestBlocking = blockingMs; - longestBlockingWindow = window; - } - }; - let longestGc = 0; - const observeGc = (entries: readonly import("node:perf_hooks").PerformanceEntry[]) => { - for (const entry of entries) { - if (entry.startTime >= startedAt) longestGc = Math.max(longestGc, entry.duration); - } - }; - const gc = new PerformanceObserver((list) => observeGc(list.getEntries())); - gc.observe({ entryTypes: ["gc"] }); - // Measure work within callbacks, not CPU-counter increments attributed to - // the idle interval between them. Exclude this probe's own timer callback. - const activity = createHook({ - init(_id, type) { - if (type === "WORKER") workerStarts++; - }, - before(id) { - const resource = executionAsyncResource(); - if (resource instanceof MessagePort) portCallbacks++; - if (resource === probe) return; - callbacks.set(id, { - wall: performance.now(), - cpu: threadCpuUsage(), - resource: Object.prototype.toString.call(resource), - }); - }, - after(id) { - const start = callbacks.get(id); - if (!start) return; - callbacks.delete(id); - const wallMs = performance.now() - start.wall; - const { user, system } = threadCpuUsage(start.cpu); - const cpuMs = (user + system) / 1_000; - // A callback prevents the loop from serving IO for its entire span. - // Use the high-resolution clock, not Windows' coarse CPU increments. - recordBlocking(wallMs, { wallMs, cpuMs, resource: start.resource }); - }, - }).enable(); - const sample = () => { - const now = performance.now(); - const currentCpu = threadCpuUsage(); - const wallMs = now - previous; - const cpuMs = (currentCpu.user - cpu.user + currentCpu.system - cpu.system) / 1_000; - // Native worker construction and message deserialization can precede - // async_hooks.before. Include their interval when those events occurred. - if (workerStarts || portCallbacks) { - recordBlocking(Math.min(wallMs, cpuMs), { - wallMs, - cpuMs, - resource: "worker/port interval", - }); - } - if (wallMs > longestWall) { - longestWall = wallMs; - longestWallCpu = cpuMs; - longestWallEvents = { workerStarts, portCallbacks }; - } - workerStarts = 0; - portCallbacks = 0; - previous = now; - cpu = currentCpu; - }; - // Sleep between probes: a continuously runnable setImmediate loop consumes - // a core while the worker compiles. Raw gaps remain diagnostics; the - // assertion measures elapsed daemon-thread callback spans and GC pauses. - probe = setInterval(sample, 1); - try { - // The caller's current callback began before the hook was enabled. - // Include synchronous work before the action's first await explicitly. - const actionStartedAt = performance.now(); - const actionCpu = threadCpuUsage(); - const pending = action(); - const actionWallMs = performance.now() - actionStartedAt; - const actionCost = threadCpuUsage(actionCpu); - recordBlocking(actionWallMs, { - wallMs: actionWallMs, - cpuMs: (actionCost.user + actionCost.system) / 1_000, - resource: "action()", - }); - await pending; - await new Promise((resolve) => setImmediate(resolve)); - sample(); - observeGc(gc.takeRecords()); - } finally { - clearInterval(probe); - activity.disable(); - gc.disconnect(); - } - longestBlocking = Math.max(longestBlocking, longestGc); - process.stderr.write( - `plugin compile main-thread blocking: ${longestBlocking.toFixed(2)} ms ${JSON.stringify({ longestWall, longestWallCpu, ...longestWallEvents, longestBlockingWindow, longestGc })}\n`, - ); + const { blockingMs } = await measureMainThreadWork("plugin compile main-thread work", action); assert.ok(service.catalog()[0]?.clientBundle.includes("compiled fixture")); - results.push({ blockingMs: longestBlocking }); + results.push({ blockingMs }); } // Evaluate after both probes so parsing the returned code is not charged to compilation. const factory = globalThis.eval(service.catalog()[0]!.clientBundle) as ( diff --git a/packages/server/src/server/session.test.ts b/packages/server/src/server/session.test.ts index 7321312826..3cdef95a83 100644 --- a/packages/server/src/server/session.test.ts +++ b/packages/server/src/server/session.test.ts @@ -588,6 +588,78 @@ test("routes plugin requests and releases its owned catalog subscription on clea expect(releasePluginSubscription).toHaveBeenCalledOnce(); }); +test("leaves out the client bundles a catalog request already holds", async () => { + const messages: SessionOutboundMessage[] = []; + const pluginRuntime: NonNullable = { + before: async (_name, request) => request, + emit: () => {}, + listPlugins: async () => [], + getLogs: () => [], + installDirectory: async () => { + throw new Error("Unexpected install"); + }, + inspectDirectory: async () => ({ id: "unused" }), + reloadPlugin: async () => { + throw new Error("Unexpected reload"); + }, + enablePlugin: async () => { + throw new Error("Unexpected enable"); + }, + disablePlugin: async () => { + throw new Error("Unexpected disable"); + }, + removePlugin: async () => undefined, + subscribe: () => () => {}, + catalog: () => [ + { id: "cached", clientBundle: "cached bundle", clientBundleHash: "hash-cached" }, + { id: "changed", clientBundle: "new bundle", clientBundleHash: "hash-new" }, + { id: "added", clientBundle: "added bundle", clientBundleHash: "hash-added" }, + ], + invokePluginRpc: async () => null, + listUsageReports: async () => [], + listLegacyUsage: async () => ({ fetchedAt: "2026-01-01T00:00:00.000Z", providers: [] }), + }; + const session = createSessionForTest({ messages, pluginRuntime }); + + await session.handleMessage({ + type: "plugin.catalog.get.request", + requestId: "reconnect", + cachedClientBundles: [ + { id: "cached", clientBundleHash: "hash-cached" }, + { id: "changed", clientBundleHash: "hash-old" }, + { id: "removed", clientBundleHash: "hash-removed" }, + ], + }); + // Clients from before cachedClientBundles always receive every bundle. + await session.handleMessage({ type: "plugin.catalog.get.request", requestId: "legacy" }); + + expect(messages).toEqual([ + { + type: "plugin.catalog.get.response", + payload: { + requestId: "reconnect", + plugins: [ + { id: "cached", clientBundleHash: "hash-cached" }, + { id: "changed", clientBundle: "new bundle", clientBundleHash: "hash-new" }, + { id: "added", clientBundle: "added bundle", clientBundleHash: "hash-added" }, + ], + }, + }, + { + type: "plugin.catalog.get.response", + payload: { + requestId: "legacy", + plugins: [ + { id: "cached", clientBundle: "cached bundle", clientBundleHash: "hash-cached" }, + { id: "changed", clientBundle: "new bundle", clientBundleHash: "hash-new" }, + { id: "added", clientBundle: "added bundle", clientBundleHash: "hash-added" }, + ], + }, + }, + ]); + await session.cleanup(); +}); + describe("workspace label subscriptions", () => { type LabelSubscription = Awaited>; type LabelChange = Parameters[0]["onChange"]>[0]; diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 9d25328079..b52c9dbfe4 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -518,7 +518,7 @@ export interface SessionOptions { removePlugin(pluginId: string): Promise; subscribe(listener: (pluginId: string) => void): () => void; subscribeSettings?(listener: (pluginId: string, settingsId: string) => void): () => void; - catalog(): Array<{ id: string; clientBundle: string }>; + catalog(): Array<{ id: string; clientBundle: string; clientBundleHash?: string }>; invokePluginRpc(pluginId: string, method: string, input: unknown): Promise; listUsageReports(options?: { forceRefresh?: boolean; @@ -2584,12 +2584,17 @@ export class Session { }); } if (msg.type === "plugin.catalog.get.request") { + const cached = new Map( + msg.cachedClientBundles?.map((entry) => [entry.id, entry.clientBundleHash]), + ); + const plugins = (this.pluginRuntime?.catalog() ?? []).map(({ clientBundle, ...plugin }) => + plugin.clientBundleHash !== undefined && cached.get(plugin.id) === plugin.clientBundleHash + ? plugin + : Object.assign(plugin, { clientBundle }), + ); this.emit({ type: "plugin.catalog.get.response", - payload: { - requestId: msg.requestId, - plugins: this.pluginRuntime?.catalog() ?? [], - }, + payload: { requestId: msg.requestId, plugins }, }); return undefined; } @@ -7805,6 +7810,7 @@ export class Session { direction, cursor, limit: msg.limit, + wholeTurns: msg.wholeTurns, }); const selectedTimeline = { timeline: fetchedControlTimeline, diff --git a/packages/server/src/server/utils/diff-highlighter.test.ts b/packages/server/src/server/utils/diff-highlighter.test.ts index 3ecb0372ad..794cd00856 100644 --- a/packages/server/src/server/utils/diff-highlighter.test.ts +++ b/packages/server/src/server/utils/diff-highlighter.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { setImmediate } from "node:timers/promises"; -import { performance } from "node:perf_hooks"; +import { measureMainThreadWork } from "../../test-utils/main-thread-work.js"; import { SessionDelivery } from "../session/owned-subscriptions/index.js"; import { highlightCode, highlightCodeWithBudget } from "@getpaseo/highlight"; import { @@ -840,26 +840,19 @@ describe("diff highlighting budget", () => { @@ -1 +1 @@ -const value = Math.max(42, 7); +const value = Math.max(43, 7);`)[0]; - let lastTick = performance.now(); - let longestGap = 0; - const timer = setInterval(() => { - const now = performance.now(); - longestGap = Math.max(longestGap, now - lastTick); - lastTick = now; - }, 1); try { - await setImmediate(); - const highlighted = await highlightDiffWithFileContent( - file, - cwd, - source === "supplied" ? { oldFileContent } : {}, + const { result: highlighted, blockingMs } = await measureMainThreadWork( + `diff highlight 5MiB ${source} main-thread work`, + () => + highlightDiffWithFileContent( + file, + cwd, + source === "supplied" ? { oldFileContent } : {}, + ), ); - await new Promise((resolve) => setTimeout(resolve, 5)); - console.info({ sourceBytes: Buffer.byteLength(oldFileContent), longestGap }); - expect(longestGap).toBeLessThan(50); + expect(blockingMs).toBeLessThan(50); expect(highlighted).toEqual(highlightDiffFromHunks(file)); } finally { - clearInterval(timer); rmSync(cwd, { recursive: true, force: true }); } }, @@ -952,23 +945,17 @@ it("highlights a one-line hunk from an over-budget JSON source without blocking @@ -2 +2 @@ - "value": 42, + "value": 43,`); - let last = performance.now(), - longestGap = 0; - const timer = setInterval(() => { - const now = performance.now(); - longestGap = Math.max(longestGap, now - last); - last = now; - }, 1); try { - const result = await highlightDiffWithFileContent(file, cwd, { oldFileContent }); - await new Promise((resolve) => setTimeout(resolve, 5)); - expect(longestGap).toBeLessThan(50); + const { result, blockingMs } = await measureMainThreadWork( + "diff highlight large JSON main-thread work", + () => highlightDiffWithFileContent(file, cwd, { oldFileContent }), + ); + expect(blockingMs).toBeLessThan(50); expect(result.hunks[0].lines.find((line) => line.type === "add")?.tokens).toContainEqual({ text: "43", style: "number", }); } finally { - clearInterval(timer); rmSync(cwd, { recursive: true, force: true }); } }); diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 4473a4004d..dfa324315c 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -2062,6 +2062,7 @@ export class VoiceAssistantWebSocketServer { providerSubagents: true, // COMPAT(projectedSubagentTimeline): added after v0.8.0, remove gates after 2027-03-14; retain advertisement. projectedSubagentTimeline: true, + wholeTurnTimeline: true, // COMPAT(providerSubagentNesting): added in v0.7, remove gate after 2027-03-04. providerSubagentNesting: true, // COMPAT(workspacePinning): added in v0.1.107, remove gate after 2027-01-12. diff --git a/packages/server/src/server/workspace-git-fetch.test.ts b/packages/server/src/server/workspace-git-fetch.test.ts index 29a95b64d8..de9e69959e 100644 --- a/packages/server/src/server/workspace-git-fetch.test.ts +++ b/packages/server/src/server/workspace-git-fetch.test.ts @@ -1,4 +1,8 @@ import { describe, expect, test } from "vitest"; +import { createServer } from "node:http"; +import { mkdtemp, readFile, writeFile, rm, chmod } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { diffWorkspaceGitRefs, @@ -6,14 +10,21 @@ import { fetchWorkspaceGitRemote, parseWorkspaceGitRefs, } from "./workspace-git-fetch.js"; -import type { RunGitCommand } from "../utils/run-git-command.js"; +import { runGitCommand, type RunGitCommand } from "../utils/run-git-command.js"; describe("workspace Git remote refs", () => { test("runs the complete fetch through the supplied Git runner", async () => { const commands: string[][] = []; let refRead = 0; - const runGitCommand: RunGitCommand = async (args) => { + const run: RunGitCommand = async (args, options) => { commands.push(args); + if (args[0] === "fetch") { + expect(options.envOverlay).toEqual({ + GIT_TERMINAL_PROMPT: "0", + GIT_ASKPASS: "", + GCM_INTERACTIVE: "0", + }); + } const stdout = args[0] === "for-each-ref" ? `refs/remotes/origin/main\0${refRead++ === 0 ? "aaaaaaaa" : "bbbbbbbb"}` @@ -22,7 +33,7 @@ describe("workspace Git remote refs", () => { }; await expect( - fetchWorkspaceGitRemote("/repo", { onRefSnapshot() {} }, runGitCommand), + fetchWorkspaceGitRemote("/repo", { onRefSnapshot() {} }, run), ).resolves.toMatchObject({ changes: [ { @@ -109,3 +120,130 @@ describe("workspace Git remote refs", () => { ]); }); }); + +// Serve a real bare repository over Git's dumb HTTP transport, behind Basic authentication. +// Both the authentication challenge and successful fetch exercise Git itself. +async function withAuthenticatedRemote( + verify: (fixture: { + cwd: string; + run: RunGitCommand; + root: string; + origin: string; + expectedOid: string; + }) => Promise, +): Promise { + const root = await mkdtemp(join(tmpdir(), "paseo-fetch-auth-")); + const cwd = join(root, "checkout"); + const remote = join(root, "remote.git"); + const emptyConfig = join(root, "gitconfig"); + const askpass = join(root, "askpass.sh"); + const marker = join(root, "prompts"); + await writeFile(emptyConfig, ""); + await writeFile( + askpass, + `#!/bin/sh\nprintf 'prompt\\n' >> '${marker.replaceAll("'", "'\\''")}'\nexit 1\n`, + ); + await chmod(askpass, 0o755); + const askpassCommand = askpass; + const run: RunGitCommand = (args, options) => + runGitCommand(args, { + ...options, + env: { + ...options.env, + GIT_CONFIG_GLOBAL: emptyConfig, + GIT_CONFIG_NOSYSTEM: "1", + GIT_ASKPASS: askpassCommand, + SSH_ASKPASS: askpassCommand, + }, + }); + const server = createServer(async (request, response) => { + if ( + request.headers.authorization !== `Basic ${Buffer.from("fixture:secret").toString("base64")}` + ) { + response.writeHead(401, { "WWW-Authenticate": 'Basic realm="git-test"' }); + response.end(); + return; + } + const pathname = new URL(request.url!, "http://localhost").pathname; + try { + const contents = await readFile(join(root, pathname)); + response.writeHead(200, { "Content-Type": "application/octet-stream" }); + response.end(contents); + } catch { + response.writeHead(404); + response.end(); + } + }); + try { + await run(["init", "--initial-branch=main", cwd], { cwd: root }); + await run( + [ + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.invalid", + "commit", + "--allow-empty", + "-m", + "initial", + ], + { cwd }, + ); + await run(["clone", "--bare", cwd, remote], { cwd: root }); + await run(["update-server-info"], { cwd: remote }); + await run(["config", "credential.helper", ""], { cwd }); + await run(["config", "core.askPass", askpassCommand], { cwd }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Expected TCP server"); + const origin = `http://127.0.0.1:${address.port}/remote.git`; + await run(["remote", "add", "origin", origin], { cwd }); + const expectedOid = (await run(["rev-parse", "HEAD"], { cwd })).stdout.trim(); + await verify({ cwd, run, root, origin, expectedOid }); + } finally { + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + await rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + } +} + +test.skipIf(process.platform === "win32")( + "background fetches never invoke askpass when credentials are missing", + async () => { + await withAuthenticatedRemote(async ({ cwd, run, root }) => { + for (let attempt = 0; attempt < 2; attempt++) { + const result = await fetchWorkspaceGitRemote(cwd, { onRefSnapshot() {} }, run); + expect(String(result.error)).toContain("terminal prompts disabled"); + } + await expect(readFile(join(root, "prompts"), "utf8")).rejects.toMatchObject({ + code: "ENOENT", + }); + }); + }, +); + +test.skipIf(process.platform === "win32")( + "background fetches still use stored credentials", + async () => { + await withAuthenticatedRemote(async ({ cwd, run, root, origin, expectedOid }) => { + const credentials = join(root, "credentials"); + await writeFile(credentials, origin.replace("http://", "http://fixture:secret@") + "\n"); + await run(["config", "credential.helper", `store --file="${credentials}"`], { cwd }); + + const result = await fetchWorkspaceGitRemote(cwd, { onRefSnapshot() {} }, run); + + expect(result.error).toBeNull(); + expect(result.changes).toContainEqual({ + kind: "added", + ref: "origin/main", + afterOid: expectedOid, + }); + expect((await run(["rev-parse", "refs/remotes/origin/main"], { cwd })).stdout.trim()).toBe( + expectedOid, + ); + await expect(readFile(join(root, "prompts"), "utf8")).rejects.toMatchObject({ + code: "ENOENT", + }); + }); + }, +); diff --git a/packages/server/src/server/workspace-git-fetch.ts b/packages/server/src/server/workspace-git-fetch.ts index 72f5d407da..5673600d23 100644 --- a/packages/server/src/server/workspace-git-fetch.ts +++ b/packages/server/src/server/workspace-git-fetch.ts @@ -33,7 +33,9 @@ export async function fetchWorkspaceGitRemote( try { await run(["fetch", "origin", "--prune"], { cwd, - envOverlay: { GIT_TERMINAL_PROMPT: "0" }, + // Terminal prompting and graphical credential prompting have separate controls. + // Keep cached credentials available, but never start a login from a passive fetch. + envOverlay: { GIT_TERMINAL_PROMPT: "0", GIT_ASKPASS: "", GCM_INTERACTIVE: "0" }, timeout: 120_000, }); } catch (caught) { diff --git a/packages/server/src/server/workspace-git-service.observation.integration.test.ts b/packages/server/src/server/workspace-git-service.observation.integration.test.ts index 55265416dc..9f53dec33a 100644 --- a/packages/server/src/server/workspace-git-service.observation.integration.test.ts +++ b/packages/server/src/server/workspace-git-service.observation.integration.test.ts @@ -534,6 +534,7 @@ test("Git refresh, command tracing and 10,000 ignored alias events do no synchro const repositoryRoot = fileURLToPath(new URL("../../../../", import.meta.url)); const source = ` import assert from 'node:assert/strict'; + import { measureMainThreadWork } from './packages/server/src/test-utils/main-thread-work.ts'; import { write } from 'node:fs'; import { mkdtemp, mkdir, writeFile, symlink, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; @@ -575,29 +576,23 @@ test("Git refresh, command tracing and 10,000 ignored alias events do no synchro await symlink(worktree, alias, process.platform === 'win32' ? 'junction' : 'dir'); const subscription = await service.requestWorkingTreeWatch(alias, () => { notifications++; }); const events = Array.from({ length: process.env.GIT_IO_TRACE ? 2 : 10000 }, (_, i) => ({ type: 'update', path: join(i % 2 ? alias : worktree, 'ignored', 'noise') })); - // A shared CI runner can deschedule the child during a wall-clock sample. - // Keep the 20ms gate, but take three independent batches and require one - // responsive callback/timer pair. All batches must remain filesystem-free. - const samples = []; - for (let trial = 0; trial < (process.env.GIT_IO_TRACE ? 1 : 3); trial++) { - await tick(); - let fsCalls = 0; - const hook = createHook({ init(_id, type) { if (type.startsWith('FSREQ')) fsCalls++; } }); + let fsCalls = 0; + const hook = createHook({ init(_id, type) { if (type.startsWith('FSREQ')) fsCalls++; } }); + await tick(); + const { blockingMs } = await measureMainThreadWork('Git filter main-thread work', async () => { await marker('FILTER_START'); hook.enable(); - const started = performance.now(); - const pulse = new Promise((resolve) => setTimeout(() => resolve(performance.now() - started), 0)); - callback(null, events); - const filterMs = performance.now() - started; - hook.disable(); - const loopGapMs = await pulse; - await marker('FILTER_END'); - assert.equal(fsCalls, 0, "filesystem calls"); - assert.equal(notifications, 0, "ignored event notification"); - samples.push({ filterMs, loopGapMs, fsCalls }); - } - console.log(JSON.stringify({ ...samples[0], samples })); - assert.ok(process.env.GIT_IO_TRACE || samples.some(({ filterMs, loopGapMs }) => filterMs < 20 && loopGapMs < 20), 'no responsive filter sample: ' + JSON.stringify(samples)); + try { + callback(null, events); + } finally { + hook.disable(); + await marker('FILTER_END'); + } + }); + assert.equal(fsCalls, 0, "filesystem calls"); + assert.equal(notifications, 0, "ignored event notification"); + assert.ok(blockingMs < 20, 'blocking filter work: ' + blockingMs); + console.log(JSON.stringify({ blockingMs, fsCalls })); // Alias identity remains useful after deletion: no per-event realpath can recover this file. callback(null, [{ type: 'delete', path: join(worktree, 'tracked.txt') }]); assert.equal(notifications, 1); diff --git a/packages/server/src/server/workspace-reconciliation-service.test.ts b/packages/server/src/server/workspace-reconciliation-service.test.ts index e1a8860e3b..0e9f5a2c2d 100644 --- a/packages/server/src/server/workspace-reconciliation-service.test.ts +++ b/packages/server/src/server/workspace-reconciliation-service.test.ts @@ -1,3 +1,4 @@ +import { measureMainThreadWork } from "../test-utils/main-thread-work.js"; import { execFileSync } from "node:child_process"; import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -658,24 +659,20 @@ describe("WorkspaceReconciliationService", () => { workspaceRegistry, logger: createTestLogger(), }); - let last = performance.now(); - let longestGap = 0; - const probe = setInterval(() => { - const now = performance.now(); - longestGap = Math.max(longestGap, now - last); - last = now; - }, 1); - let result; try { - result = await service.runOnce(); - await new Promise((resolve) => setTimeout(resolve, 1)); + const { result, blockingMs, filesystemRequests } = await measureMainThreadWork( + "workspace reconciliation main-thread work", + () => service.runOnce(), + ); + expect(blockingMs).toBeLessThan(20); + expect(filesystemRequests).toBe(20001); + expect(result.changesApplied).toHaveLength(20000); + expect([...workspaces.values()].every((workspace) => workspace.archivedAt !== null)).toBe( + true, + ); } finally { - clearInterval(probe); service.dispose(); } - expect(longestGap).toBeLessThan(20); - expect(result.changesApplied).toHaveLength(20000); - expect([...workspaces.values()].every((workspace) => workspace.archivedAt !== null)).toBe(true); }, 30000); test("preserves workspace archival that lands during boot reconciliation", async () => { diff --git a/packages/server/src/test-utils/main-thread-work.ts b/packages/server/src/test-utils/main-thread-work.ts new file mode 100644 index 0000000000..66bb55e491 --- /dev/null +++ b/packages/server/src/test-utils/main-thread-work.ts @@ -0,0 +1,108 @@ +import { createHook, executionAsyncResource } from "node:async_hooks"; +import { performance, PerformanceObserver, type PerformanceEntry } from "node:perf_hooks"; +import { MessagePort } from "node:worker_threads"; + +/** Measure main-thread work without charging idle IO or runner descheduling. */ +export async function measureMainThreadWork(label: string, action: () => T | Promise) { + // Node 22.20 supplies this API; the repository's older declarations omit it. + const threadCpuUsage: (previous?: NodeJS.CpuUsage) => NodeJS.CpuUsage = Reflect.get( + process, + "threadCpuUsage", + ); + let blockingMs = 0; + let filesystemRequests = 0; + let yields = 0; + let longestCallbackMs = 0; + let longestCallbackCpuMs = 0; + let longestGapMs = 0; + let longestGcMs = 0; + let gcCount = 0; + let workerEvents = 0; + let draining = false; + const startedAt = performance.now(); + let previous = startedAt; + let previousCpu = threadCpuUsage(); + let probe: ReturnType; + let drain: ReturnType; + const callbacks = new Map(); + const record = (wallMs: number, cpu: NodeJS.CpuUsage) => { + const cpuMs = (cpu.user + cpu.system) / 1_000; + // Windows CPU accounting is coarse. Cap charged CPU by the real span so a + // counter increment cannot inflate a short callback. Zero-CPU waits drop out. + blockingMs = Math.max(blockingMs, Math.min(wallMs, cpuMs)); + if (wallMs > longestCallbackMs) { + longestCallbackMs = wallMs; + longestCallbackCpuMs = cpuMs; + } + }; + const observeGc = (entries: readonly PerformanceEntry[]) => { + for (const entry of entries) { + if (entry.startTime < startedAt) continue; + gcCount++; + longestGcMs = Math.max(longestGcMs, entry.duration); + } + }; + const gc = new PerformanceObserver((list) => observeGc(list.getEntries())); + gc.observe({ entryTypes: ["gc"] }); + const activity = createHook({ + init(_id, type) { + if (type.startsWith("FSREQ")) filesystemRequests++; + if (!draining && type === "Immediate") yields++; + if (type === "WORKER") workerEvents++; + }, + before(id) { + const resource = executionAsyncResource(); + if (resource === probe || resource === drain) return; + if (resource instanceof MessagePort) workerEvents++; + callbacks.set(id, { wall: performance.now(), cpu: threadCpuUsage() }); + }, + after(id) { + const start = callbacks.get(id); + if (!start) return; + callbacks.delete(id); + record(performance.now() - start.wall, threadCpuUsage(start.cpu)); + }, + }).enable(); + const sample = () => { + const now = performance.now(); + const cpu = threadCpuUsage(); + const wallMs = now - previous; + longestGapMs = Math.max(longestGapMs, wallMs); + // Worker construction/message cloning can precede async_hooks.before. + if (workerEvents) { + blockingMs = Math.max( + blockingMs, + Math.min(wallMs, (cpu.user - previousCpu.user + cpu.system - previousCpu.system) / 1_000), + ); + } + workerEvents = 0; + previous = now; + previousCpu = cpu; + }; + probe = setInterval(sample, 1); + let result: T; + try { + // The caller's current callback began before the hook was installed. + const wall = performance.now(); + const cpu = threadCpuUsage(); + const pending = action(); + record(performance.now() - wall, threadCpuUsage(cpu)); + result = await pending; + draining = true; + await new Promise((resolve) => { + drain = setImmediate(resolve); + }); + sample(); + observeGc(gc.takeRecords()); + } finally { + clearInterval(probe); + activity.disable(); + gc.disconnect(); + } + // GC CPU inside callbacks is charged above. Elapsed GC spans, like elapsed + // callback spans, include preemption and remain diagnostics rather than gates. + process.stderr.write( + `${label}: ${JSON.stringify({ blockingMs, filesystemRequests, yields, longestCallbackMs, longestCallbackCpuMs, longestGcMs, gcCount, longestGapMs })}\n`, + ); + return { result, blockingMs, filesystemRequests, yields }; +} diff --git a/packages/server/src/utils/paseo-config-file.test.ts b/packages/server/src/utils/paseo-config-file.test.ts index 075ddd9720..1eed6ec054 100644 --- a/packages/server/src/utils/paseo-config-file.test.ts +++ b/packages/server/src/utils/paseo-config-file.test.ts @@ -3,7 +3,7 @@ import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { fileURLToPath } from "node:url"; import { writeFile, truncate, rm } from "node:fs/promises"; -import { setImmediate as nextTurn } from "node:timers/promises"; +import { measureMainThreadWork } from "../test-utils/main-thread-work.js"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; @@ -67,7 +67,7 @@ describe("paseo config file substrate", () => { ); }); - it("keeps the longest loop stall below 20ms while admitting large configs", async () => { + it("keeps main-thread work below 20ms while admitting large configs", async () => { const config = { scripts: Object.fromEntries( Array.from({ length: 7_000 }, (_, i) => [`script${i}`, { command: "echo hi" }]), @@ -75,48 +75,27 @@ describe("paseo config file substrate", () => { worktree: { setup: "npm ci" }, }; await writeFile(join(tempDir, "paseo.json"), JSON.stringify(config)); - let previous = performance.now(); - let longestGap = 0; - const probe = setInterval(() => { - const now = performance.now(); - longestGap = Math.max(longestGap, now - previous); - previous = now; - }, 1); - let result: Awaited>; - try { - result = await readPaseoConfigForEdit(tempDir); - await nextTurn(); - longestGap = Math.max(longestGap, performance.now() - previous); - } finally { - clearInterval(probe); - } + const { result, blockingMs, filesystemRequests, yields } = await measureMainThreadWork( + "project config 7000 scripts main-thread work", + () => readPaseoConfigForEdit(tempDir), + ); expect(result).toEqual({ ok: true, config, revision: await statPaseoConfigPath(tempDir) }); - process.stdout.write(`project config 7,000 scripts max loop gap: ${longestGap.toFixed(2)}ms\n`); - expect(longestGap).toBeLessThan(20); + expect(blockingMs).toBeLessThan(20); + expect(filesystemRequests).toBeGreaterThan(0); + expect(yields).toBeGreaterThan(0); }); - it("rejects a 32MiB config with a loop stall below 20ms", async () => { + it("rejects a 32MiB config with main-thread work below 20ms", async () => { await writeFile( join(tempDir, "paseo.json"), JSON.stringify({ extra: "x".repeat(32 * 1024 * 1024) }), ); - let previous = performance.now(); - let longestGap = 0; - const probe = setInterval(() => { - const now = performance.now(); - longestGap = Math.max(longestGap, now - previous); - previous = now; - }, 1); - let result: Awaited>; - try { - result = await readPaseoConfigForEdit(tempDir); - await nextTurn(); - longestGap = Math.max(longestGap, performance.now() - previous); - } finally { - clearInterval(probe); - } - process.stdout.write(`project config 32MiB max loop gap: ${longestGap.toFixed(2)}ms\n`); - expect(longestGap).toBeLessThan(20); + const { result, blockingMs, filesystemRequests } = await measureMainThreadWork( + "project config 32MiB main-thread work", + () => readPaseoConfigForEdit(tempDir), + ); + expect(blockingMs).toBeLessThan(20); + expect(filesystemRequests).toBeGreaterThan(0); expect(result).toEqual({ ok: false, error: { code: "invalid_project_config" } }); }); diff --git a/packages/website/package.json b/packages/website/package.json index 2166f1a22e..889be528a9 100644 --- a/packages/website/package.json +++ b/packages/website/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/website", - "version": "0.13.2", + "version": "0.14.0-beta.1", "private": true, "type": "module", "scripts": { diff --git a/patches/react-native-enriched-markdown+1.2.0-nightly-20261014-766fc0e67.patch b/patches/react-native-enriched-markdown+1.2.0-nightly-20261014-766fc0e67.patch index 7ec1ec33b5..f6794d8c62 100644 --- a/patches/react-native-enriched-markdown+1.2.0-nightly-20261014-766fc0e67.patch +++ b/patches/react-native-enriched-markdown+1.2.0-nightly-20261014-766fc0e67.patch @@ -3770,3 +3770,60 @@ index eaa3279..05a02eb 100644 private: int forceHeightRecalculationCounter_{0}; std::shared_ptr measurementsManager_; +diff --git a/node_modules/react-native-enriched-markdown/ios/generated/ReactCodegen/EnrichedMarkdownTextSpec/Props.h b/node_modules/react-native-enriched-markdown/ios/generated/ReactCodegen/EnrichedMarkdownTextSpec/Props.h +--- a/node_modules/react-native-enriched-markdown/ios/generated/ReactCodegen/EnrichedMarkdownTextSpec/Props.h ++++ b/node_modules/react-native-enriched-markdown/ios/generated/ReactCodegen/EnrichedMarkdownTextSpec/Props.h +@@ -1639,6 +1639,7 @@ + struct EnrichedMarkdownMarkdownStyleCodeStruct { + std::string fontFamily{}; + Float fontSize{0.0}; ++ Float fontScale{0.0}; + SharedColor color{}; + SharedColor backgroundColor{}; + SharedColor borderColor{}; +@@ -1651,6 +1652,7 @@ + folly::dynamic result = folly::dynamic::object(); + result["fontFamily"] = fontFamily; + result["fontSize"] = fontSize; ++ result["fontScale"] = fontScale; + result["color"] = ::facebook::react::toDynamic(color); + result["backgroundColor"] = ::facebook::react::toDynamic(backgroundColor); + result["borderColor"] = ::facebook::react::toDynamic(borderColor); +@@ -1670,6 +1672,10 @@ + if (tmp_fontSize != map.end()) { + fromRawValue(context, tmp_fontSize->second, result.fontSize); + } ++ auto tmp_fontScale = map.find("fontScale"); ++ if (tmp_fontScale != map.end()) { ++ fromRawValue(context, tmp_fontScale->second, result.fontScale); ++ } + auto tmp_color = map.find("color"); + if (tmp_color != map.end()) { + fromRawValue(context, tmp_color->second, result.color); +@@ -6185,6 +6191,7 @@ + struct EnrichedMarkdownTextMarkdownStyleCodeStruct { + std::string fontFamily{}; + Float fontSize{0.0}; ++ Float fontScale{0.0}; + SharedColor color{}; + SharedColor backgroundColor{}; + SharedColor borderColor{}; +@@ -6197,6 +6204,7 @@ + folly::dynamic result = folly::dynamic::object(); + result["fontFamily"] = fontFamily; + result["fontSize"] = fontSize; ++ result["fontScale"] = fontScale; + result["color"] = ::facebook::react::toDynamic(color); + result["backgroundColor"] = ::facebook::react::toDynamic(backgroundColor); + result["borderColor"] = ::facebook::react::toDynamic(borderColor); +@@ -6216,6 +6224,10 @@ + if (tmp_fontSize != map.end()) { + fromRawValue(context, tmp_fontSize->second, result.fontSize); + } ++ auto tmp_fontScale = map.find("fontScale"); ++ if (tmp_fontScale != map.end()) { ++ fromRawValue(context, tmp_fontScale->second, result.fontScale); ++ } + auto tmp_color = map.find("color"); + if (tmp_color != map.end()) { + fromRawValue(context, tmp_color->second, result.color); diff --git a/plugin-examples/agent-configuration/README.md b/plugin-examples/agent-configuration/README.md index 189afe6cfd..2a34c1222d 100644 --- a/plugin-examples/agent-configuration/README.md +++ b/plugin-examples/agent-configuration/README.md @@ -1,14 +1,19 @@ # Agent configuration -Changes new Codex agents through `server.before("agent.create", ...)`: +Sets saved Codex options through `server.before("agent.create", ...)`: - Sets `config.providerOptions.sandbox_mode` to `workspace-write`. - Sets `config.providerOptions.approval_policy` to `on-request`. -- Adds the `company` server to `config.mcpServers`. -- Preserves other configuration, provider options, MCP servers, and environment overrides. +- Preserves other configuration and provider options. + +Adds the `company` MCP server to Codex launches through +`server.before("agent.session_open", ...)`. The server is never saved in the agent's configuration; +existing saved MCP servers are preserved. Existing agents receive the server through the +[session reopen behavior](../../public-docs/plugins/reference.md#attach-tools-to-agents). Replace `https://tools.example.com/mcp` in `index.server.ts` with your MCP server URL before using -this example. The URL is a placeholder. The `company` entry replaces an existing server with that name. +this example. A `company` name collision with a saved server or another +plugin fails the session open and names both sources. Install on a daemon with plugins enabled: @@ -16,11 +21,13 @@ Install on a daemon with plugins enabled: paseo plugin install /absolute/path/to/plugin-examples/agent-configuration ``` -The callback receives the request after earlier plugins have transformed it. The nested spreads -preserve their settings; the explicit values replace only the named fields. Paseo validates and saves -the resulting configuration before starting the agent. Existing agents are unchanged. +Each session-open callback receives only this plugin's MCP servers. The nested spreads preserve +entries added by this plugin's earlier callbacks. The create callback receives configuration after +earlier plugins have transformed it; its explicit Codex options replace the named fields in the +saved configuration of new agents and remain after this plugin is disabled. -See the [lifecycle reference](../../public-docs/plugins/reference.md#lifecycle-hooks) for ordering -and callback shapes. [Lifecycle actions](../lifecycle-actions) demonstrates provider switching and -environment injection. Its provider-switch callback clears provider options when changing Codex to -Claude, so use these examples separately when trying the Codex-specific settings. +See [Inject MCP servers](../../public-docs/plugins/reference.md#inject-mcp-servers) for choosing +between saved configuration and plugin-owned servers. [Lifecycle actions](../lifecycle-actions) +demonstrates provider switching and environment injection. Its provider-switch callback clears +provider options when changing Codex to Claude, so use these examples separately when trying the +Codex-specific settings. diff --git a/plugin-examples/agent-configuration/index.server.ts b/plugin-examples/agent-configuration/index.server.ts index 1f86e63a67..bc5abc9e3d 100644 --- a/plugin-examples/agent-configuration/index.server.ts +++ b/plugin-examples/agent-configuration/index.server.ts @@ -15,12 +15,22 @@ export default function contribute(server: PluginServerContext) { sandbox_mode: "workspace-write", approval_policy: "on-request", }, - mcpServers: { - ...request.config.mcpServers, - company: { - type: "http", - url: "https://tools.example.com/mcp", - }, + }, + }; + }); + + server.before("agent.session_open", ({ request }) => { + if (request.provider !== "codex") { + return; + } + + return { + ...request, + mcpServers: { + ...request.mcpServers, + company: { + type: "http", + url: "https://tools.example.com/mcp", }, }, }; diff --git a/plugin-examples/agent-configuration/paseo-plugin.json b/plugin-examples/agent-configuration/paseo-plugin.json index 1a049eda62..019394b8f7 100644 --- a/plugin-examples/agent-configuration/paseo-plugin.json +++ b/plugin-examples/agent-configuration/paseo-plugin.json @@ -1,6 +1,6 @@ { "id": "agent-configuration", "requirements": { - "paseo": ">=0.8.0" + "paseo": ">=0.13.2" } } diff --git a/plugins/package.json b/plugins/package.json index 30d9929eef..7ddbaab4fb 100644 --- a/plugins/package.json +++ b/plugins/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/builtin-plugins", - "version": "0.13.2", + "version": "0.14.0-beta.1", "private": true, "type": "module", "scripts": { diff --git a/public-docs/plugins/reference.md b/public-docs/plugins/reference.md index 69608a83a7..439892edb8 100644 --- a/public-docs/plugins/reference.md +++ b/public-docs/plugins/reference.md @@ -653,9 +653,14 @@ export default function contribute(server: PluginServerContext) { Hooks run on the daemon while the plugin is enabled, even with no app connected. -### Change configuration and inject an MCP server +`agent.create` edits the agent's saved configuration. Changes to `config` are persisted and remain +after the plugin is disabled or removed. `agent.session_open` contributes launch-only `env`, `tools`, +and `mcpServers`, re-derived on every create, resume, refresh, and import, following the plugin's +lifecycle without being saved in the agent record. -Add this callback inside `contribute(server)`. Replace the placeholder URL with your MCP endpoint. +### Change saved configuration + +Add this callback inside `contribute(server)` to set the saved options for new Codex agents: ```ts server.before("agent.create", ({ request }) => { @@ -672,27 +677,50 @@ server.before("agent.create", ({ request }) => { sandbox_mode: "workspace-write", approval_policy: "on-request", }, - mcpServers: { - ...request.config.mcpServers, - company: { - type: "http", - url: "https://tools.example.com/mcp", - }, - }, }, }; }); ``` -| Input | Result | -| ------------------------------------------- | ----------------------------- | -| `providerOptions.sandbox_mode: "read-only"` | `"workspace-write"` | -| `providerOptions.web_search: "disabled"` | Preserved by the spread | -| Existing `mcpServers.search` | Preserved by the spread | -| Existing `mcpServers.company` | Replaced with the entry above | +| Input | Result | +| ------------------------------------------- | ----------------------- | +| `providerOptions.sandbox_mode: "read-only"` | `"workspace-write"` | +| `providerOptions.web_search: "disabled"` | Preserved by the spread | -The selected provider validates `providerOptions` and must support the configured MCP servers. -Explicit Codex sandbox and approval options override its mode presets. +The selected provider validates `providerOptions`. Explicit Codex sandbox and approval options +override its mode presets. + +### Inject MCP servers + +Use `agent.create.config.mcpServers` when the server should become part of the agent's saved +configuration. For a plugin-owned server, add this callback inside `contribute(server)`, replacing +the placeholder URL with your MCP endpoint: + +```ts +server.before("agent.session_open", ({ request }) => { + if (request.provider !== "codex") return; + + return { + ...request, + mcpServers: { + ...request.mcpServers, + company: { type: "http", url: "https://tools.example.com/mcp" }, + }, + }; +}); +``` + +`request.mcpServers` uses the same server configurations as `AgentSessionConfig.mcpServers`. +The map starts empty for each plugin and accumulates across that plugin's own callbacks. It never +contains the agent's saved servers or another plugin's servers. Replace the map to add, replace, +or remove this plugin's entries; returning `undefined` preserves it. Paseo merges the plugins' +final maps in plugin-ID order. + +A name colliding with a saved server, Paseo's internal `paseo` server, or another plugin's server +fails the session open and names the server and both sources. A provider without MCP support fails +with `does not support MCP servers`, naming the plugin. Opens with `purpose: "history"` ignore +these servers. Install, disable, and reload changes reach existing agents through the +[session reopen behavior](#attach-tools-to-agents). ### Inject environment variables on every session opening @@ -708,8 +736,8 @@ server.before("agent.session_open", ({ request }) => { }); ``` -This runs on create, resume, refresh, and import. To inject only on creation, set `env` in an -`agent.create` callback instead. +Session-open overrides are re-derived on every open. `agent.create.env` applies only to the +initial launch and is not saved in the agent's configuration. ### Attach tools to agents @@ -992,11 +1020,11 @@ type PluginTurnOutcome = ### Before hooks -| Name | Request fields | Editable | -| -------------------- | ---------------------------------------------------------------------------------------------------------------- | --------------------------------------- | -| `agent.create` | `config`, optional `env` | Public agent config except `cwd`; `env` | -| `agent.session_open` | `agentId`, `workspaceId`, `provider`, `cwd`, read-only `title` and `labels`, `reason`, `purpose`, `env`, `tools` | `env` and `tools` | -| `workspace.create` | `source`, optional `title`, `firstAgentContext` | Entire explicit creation request | +| Name | Request fields | Editable | +| -------------------- | ------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------- | +| `agent.create` | `config`, optional `env` | Public agent config except `cwd`; `env` | +| `agent.session_open` | `agentId`, `workspaceId`, `provider`, `cwd`, read-only `title` and `labels`, `reason`, `purpose`, `env`, `tools`, `mcpServers` | Launch-only `env`, `tools`, `mcpServers` | +| `workspace.create` | `source`, optional `title`, `firstAgentContext` | Entire explicit creation request | **`agent.create.config`** uses `AgentSessionConfig`: @@ -1020,6 +1048,7 @@ type PluginTurnOutcome = "title": "Workspace notes", "labels": { "notes/enabled": "true" }, "tools": [], + "mcpServers": {}, "reason": "resume", "purpose": "interactive", "env": { "COMPANY_ENV": "development" } @@ -1032,6 +1061,7 @@ type PluginTurnOutcome = | `title` | Read-only string or `null`; creation title on create, stored title on resume. | | `labels` | Read-only string map; creation labels on create, stored labels on resume. | | `tools` | Editable `AgentToolDefinition[]`, local to this plugin. See [Attach tools to agents](#attach-tools-to-agents). | +| `mcpServers` | Editable launch-only server map, local to this plugin. See [Inject MCP servers](#inject-mcp-servers). | | `reason` | `create`, `resume`, `refresh`, `import` | | `purpose` | `interactive`, `history` | | `env` | Launch override map; excludes the daemon's inherited environment. Replace the map to add, replace, or remove overrides. | @@ -1043,7 +1073,7 @@ Creation request → agent.create hooks (plugin-ID order; registration order within each plugin) → resolve defaults and validate provider configuration → derive launch configuration with Paseo runtime tools and daemon prompt - → agent.session_open hooks (same ordering; env and plugin-local tools) + → agent.session_open hooks (same ordering; env and plugin-local tools and MCP servers) → set PASEO_AGENT_ID and PASEO_AGENT_CWD → open provider session and save agent configuration ``` @@ -1055,8 +1085,9 @@ Creation request | `undefined` | Unchanged request | | Throws or returns invalid data | Operation fails; later callbacks do not run | -No automatic deep merge. Later callbacks can overwrite earlier values. Agent configuration is -saved; environment overrides are not persisted with it. +There is no automatic deep merge. `env` accumulates across plugins; `tools` and `mcpServers` +accumulate only across callbacks within the same plugin. Returning a replacement map or array +replaces that scope's prior value. ### Context and cleanup diff --git a/public-docs/plugins/reference.zh-CN.md b/public-docs/plugins/reference.zh-CN.md index 3c84f8cc0c..4fc7380ee3 100644 --- a/public-docs/plugins/reference.zh-CN.md +++ b/public-docs/plugins/reference.zh-CN.md @@ -529,9 +529,12 @@ export default function contribute(server: PluginServerContext) { 只要插件处于启用状态,钩子就会在 daemon 上运行,即使没有应用连接。 -### 修改配置并注入 MCP 服务器 {#change-configuration-and-inject-an-mcp-server} +`agent.create` 修改 Agent 的已保存配置。对 `config` 的修改会被持久化,在插件停用或移除后仍然保留。 +`agent.session_open` 提供仅用于启动的 `env`、`tools` 和 `mcpServers`;每次创建、恢复、刷新和导入时都会重新计算,随插件的生命周期变化,不会写入 Agent 记录。 -在 `contribute(server)` 中添加这个回调。把占位 URL 替换为你的 MCP 端点。 +### 修改已保存的配置 {#change-saved-configuration} + +在 `contribute(server)` 中添加这个回调,为新的 Codex Agent 设置已保存的选项: ```ts server.before("agent.create", ({ request }) => { @@ -548,13 +551,6 @@ server.before("agent.create", ({ request }) => { sandbox_mode: "workspace-write", approval_policy: "on-request", }, - mcpServers: { - ...request.config.mcpServers, - company: { - type: "http", - url: "https://tools.example.com/mcp", - }, - }, }, }; }); @@ -564,10 +560,37 @@ server.before("agent.create", ({ request }) => { | ------------------------------------------- | ------------------- | | `providerOptions.sandbox_mode: "read-only"` | `"workspace-write"` | | `providerOptions.web_search: "disabled"` | 由展开运算保留 | -| 已有的 `mcpServers.search` | 由展开运算保留 | -| 已有的 `mcpServers.company` | 被替换为上面的条目 | -所选的 Provider 会校验 `providerOptions`,并且必须支持配置的 MCP 服务器。显式的 Codex 沙盒和批准选项会覆盖它的模式预设。 +所选的 Provider 会校验 `providerOptions`。显式的 Codex 沙盒和批准选项会覆盖它的模式预设。 + +### 注入 MCP 服务器 {#inject-mcp-servers} + +需要将服务器保存为 Agent 配置的一部分时,使用 `agent.create.config.mcpServers`。 +插件拥有的服务器使用下面的回调,在 `contribute(server)` 中添加,并把占位 URL 替换为你的 MCP 端点: + +```ts +server.before("agent.session_open", ({ request }) => { + if (request.provider !== "codex") return; + + return { + ...request, + mcpServers: { + ...request.mcpServers, + company: { type: "http", url: "https://tools.example.com/mcp" }, + }, + }; +}); +``` + +`request.mcpServers` 使用与 `AgentSessionConfig.mcpServers` 相同的服务器配置。 +每个插件的映射从空开始,只在该插件自己的回调之间累积,不包含 Agent 已保存的服务器或其他插件的服务器。 +替换映射即可添加、替换或移除本插件的条目;返回 `undefined` 会保持不变。 +Paseo 按插件 ID 顺序合并各插件的最终映射。 + +名称与已保存的服务器、Paseo 内部的 `paseo` 服务器或其他插件的服务器冲突时,会话打开失败,错误会指出服务器名称及双方来源。 +不支持 MCP 的 Provider 会报 `does not support MCP servers` 错误,并指出插件名称。 +`purpose: "history"` 的会话打开会忽略这些服务器。安装、停用和重新加载插件后,现有 Agent +通过[会话重新打开行为](#attach-tools-to-agents)获取变化。 ### 在每次打开会话时注入环境变量 {#inject-environment-variables-on-every-session-opening} @@ -583,7 +606,7 @@ server.before("agent.session_open", ({ request }) => { }); ``` -它会在创建、恢复、刷新和导入时运行。如果只想在创建时注入,请改为在 `agent.create` 回调中设置 `env`。 +会话打开时的环境变量覆盖项会在每次打开时重新计算。`agent.create.env` 只作用于首次启动,不会写入 Agent 的已保存配置。 ### 为 Agent 附加工具 {#attach-tools-to-agents} @@ -852,11 +875,11 @@ type PluginTurnOutcome = ### before 钩子 {#before-hooks} -| 名称 | 请求字段 | 可编辑 | -| -------------------- | ------------------------------------------------------------------------------------------------------------ | ----------------------------------- | -| `agent.create` | `config`,可选的 `env` | 除 `cwd` 外的公开 Agent 配置;`env` | -| `agent.session_open` | `agentId`, `workspaceId`, `provider`, `cwd`,只读的 `title` 和 `labels`,`reason`, `purpose`, `env`, `tools` | `env` 和 `tools` | -| `workspace.create` | `source`,可选的 `title`,`firstAgentContext` | 整个显式创建请求 | +| 名称 | 请求字段 | 可编辑 | +| -------------------- | -------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------- | +| `agent.create` | `config`,可选的 `env` | 除 `cwd` 外的公开 Agent 配置;`env` | +| `agent.session_open` | `agentId`, `workspaceId`, `provider`, `cwd`,只读的 `title` 和 `labels`,`reason`, `purpose`, `env`, `tools`, `mcpServers` | 仅用于启动的 `env`、`tools`、`mcpServers` | +| `workspace.create` | `source`,可选的 `title`,`firstAgentContext` | 整个显式创建请求 | **`agent.create.config`** 使用 `AgentSessionConfig`: @@ -880,6 +903,7 @@ type PluginTurnOutcome = "title": "Workspace notes", "labels": { "notes/enabled": "true" }, "tools": [], + "mcpServers": {}, "reason": "resume", "purpose": "interactive", "env": { "COMPANY_ENV": "development" } @@ -892,6 +916,7 @@ type PluginTurnOutcome = | `title` | 只读字符串或 `null`;创建时使用创建请求的标题,恢复时使用已保存的标题。 | | `labels` | 只读的字符串映射;创建时使用创建请求的标签,恢复时使用已保存的标签。 | | `tools` | 可编辑的 `AgentToolDefinition[]`,仅包含此插件的工具。参阅[为 Agent 附加工具](#attach-tools-to-agents)。 | +| `mcpServers` | 可编辑的服务器映射,仅用于启动且仅包含本插件的条目。参阅[注入 MCP 服务器](#inject-mcp-servers)。 | | `reason` | `create`、`resume`、`refresh`、`import` | | `purpose` | `interactive`、`history` | | `env` | 启动覆盖映射;不包括 daemon 继承的环境变量。替换整个映射即可添加、替换或移除覆盖项。 | @@ -903,7 +928,7 @@ Creation request → agent.create hooks (plugin-ID order; registration order within each plugin) → resolve defaults and validate provider configuration → derive launch configuration with Paseo runtime tools and daemon prompt - → agent.session_open hooks (same ordering; env and plugin-local tools) + → agent.session_open hooks (same ordering; env and plugin-local tools and MCP servers) → set PASEO_AGENT_ID and PASEO_AGENT_CWD → open provider session and save agent configuration ``` @@ -915,7 +940,8 @@ Creation request | `undefined` | 未改变的请求 | | 抛出错误或返回无效数据 | 操作失败;后面的回调不会运行 | -没有自动的深度合并。后面的回调可以覆盖前面的值。Agent 配置会被保存;环境变量覆盖项不会随之持久化。 +没有自动的深度合并。`env` 在插件之间累积;`tools` 和 `mcpServers` 只在同一插件的回调之间累积。 +返回替换的映射或数组会替换该范围内之前的值。 ### context 与清理 {#context-and-cleanup} @@ -1187,7 +1213,7 @@ export function IssueActions({ theme }: PluginSurfaceProps) { ```tsx import { Markdown } from "@getpaseo/plugin/client/ui"; -{"## 审查\n\n检查**通过**。"}; +{"## Review\n\nThe checks **passed**."}; ``` `children` 是唯一的属性,必须为字符串。更新它会立即渲染新内容;空字符串不渲染任何内容。 diff --git a/scripts/oxlint/paseo-plugin.mjs b/scripts/oxlint/paseo-plugin.mjs index d6f8d0365e..af58021991 100644 --- a/scripts/oxlint/paseo-plugin.mjs +++ b/scripts/oxlint/paseo-plugin.mjs @@ -34,9 +34,130 @@ const noRawPressable = { }, }; +const SYNC_IO_MODULES = new Set(["fs", "node:fs", "child_process", "node:child_process"]); + +function propertyName(node) { + return node.type === "Identifier" ? node.name : node.value; +} + +function findDefinition(node, sourceCode) { + let scope = sourceCode.getScope(node); + while (scope && !scope.set.has(node.name)) scope = scope.upper; + return scope?.set.get(node.name)?.defs[0]; +} + +function isIoNamespaceImport(definition) { + if (definition?.node.type === "TSImportEqualsDeclaration") { + return SYNC_IO_MODULES.has(definition.node.moduleReference.expression?.value); + } + if (definition?.type !== "ImportBinding") return false; + return ( + (definition.node.type !== "ImportSpecifier" || + propertyName(definition.node.imported) === "default") && + SYNC_IO_MODULES.has(definition.parent.source.value) + ); +} + +// Follow only immutable namespace aliases. Resolve bindings rather than spelling so a parameter +// named `fs` is not mistaken for the imported namespace. +function isIoNamespace(node, sourceCode, seen = new Set()) { + if (!node || seen.has(node)) return false; + seen.add(node); + if (node.type === "AwaitExpression") return isIoNamespace(node.argument, sourceCode, seen); + if (node.type === "ImportExpression") return SYNC_IO_MODULES.has(node.source.value); + if (node.type === "CallExpression") { + return ( + node.callee.type === "Identifier" && + node.callee.name === "require" && + SYNC_IO_MODULES.has(node.arguments[0]?.value) + ); + } + if (node.type !== "Identifier") return false; + const definition = findDefinition(node, sourceCode); + if (isIoNamespaceImport(definition)) return true; + return ( + definition?.type === "Variable" && + definition.parent.kind === "const" && + definition.node.id.type === "Identifier" && + isIoNamespace(definition.node.init, sourceCode, seen) + ); +} + +const noSyncIo = { + meta: { + schema: [ + { + type: "object", + properties: { + allow: { + type: "object", + additionalProperties: { type: "array", items: { type: "string" } }, + }, + }, + additionalProperties: false, + }, + ], + }, + create(context) { + const filename = context.filename.replaceAll("\\", "/"); + const relativeFile = filename.split("/packages/server/src/")[1]; + const allowed = new Set(context.options[0]?.allow?.[relativeFile] ?? []); + const unused = new Set(allowed); + function check(node, name) { + if (typeof name !== "string" || !name.endsWith("Sync")) return; + if (allowed.has(name)) { + unused.delete(name); + return; + } + context.report({ + node, + message: `Synchronous Node IO (${name}) blocks the daemon event loop. Use an asynchronous capability at the owning module boundary.`, + }); + } + return { + "Program:exit"(node) { + for (const name of unused) { + context.report({ node, message: `Remove the unused sync IO exception for ${name}.` }); + } + }, + ImportDeclaration(node) { + if (node.importKind === "type" || !SYNC_IO_MODULES.has(node.source.value)) return; + for (const specifier of node.specifiers) { + if (specifier.type === "ImportSpecifier" && specifier.importKind !== "type") { + check(specifier, propertyName(specifier.imported)); + } + } + }, + ExportNamedDeclaration(node) { + if (node.exportKind === "type" || !SYNC_IO_MODULES.has(node.source?.value)) return; + for (const specifier of node.specifiers) { + if (specifier.exportKind !== "type") check(specifier, propertyName(specifier.local)); + } + }, + MemberExpression(node) { + if (node.computed && node.property.type !== "Literal") return; + const name = propertyName(node.property); + if (typeof name !== "string" || !name.endsWith("Sync")) return; + if (isIoNamespace(node.object, context.sourceCode)) { + check(node.property, name); + } + }, + VariableDeclarator(node) { + if (node.id.type !== "ObjectPattern" || !isIoNamespace(node.init, context.sourceCode)) { + return; + } + for (const property of node.id.properties) { + if (property.type === "Property") check(property, propertyName(property.key)); + } + }, + }; + }, +}; + export default { meta: { name: "paseo" }, rules: { "no-raw-pressable": noRawPressable, + "no-sync-io": noSyncIo, }, }; diff --git a/scripts/oxlint/paseo-plugin.test.mjs b/scripts/oxlint/paseo-plugin.test.mjs index c30a429026..4312f5e337 100644 --- a/scripts/oxlint/paseo-plugin.test.mjs +++ b/scripts/oxlint/paseo-plugin.test.mjs @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { test } from "node:test"; @@ -10,15 +10,21 @@ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../ const oxlintBin = path.join(repoRoot, "node_modules/.bin/oxlint"); const pluginPath = path.join(repoRoot, "scripts/oxlint/paseo-plugin.mjs"); -function lint(source) { +function lint(source, rule = "paseo/no-raw-pressable", options, filename = "probe.tsx") { + const config = JSON.stringify({ + jsPlugins: [pluginPath], + rules: { [rule]: options ? ["error", options] : "error" }, + }); + return lintWithConfig(source, config, filename); +} + +function lintWithConfig(source, config, filename) { const dir = mkdtempSync(path.join(tmpdir(), "paseo-oxlint-")); try { - writeFileSync( - path.join(dir, ".oxlintrc.json"), - JSON.stringify({ jsPlugins: [pluginPath], rules: { "paseo/no-raw-pressable": "error" } }), - ); - writeFileSync(path.join(dir, "probe.tsx"), source); - const result = spawnSync(oxlintBin, ["-c", ".oxlintrc.json", "probe.tsx"], { + writeFileSync(path.join(dir, ".oxlintrc.json"), config); + mkdirSync(path.dirname(path.join(dir, filename)), { recursive: true }); + writeFileSync(path.join(dir, filename), source); + const result = spawnSync(oxlintBin, ["-c", ".oxlintrc.json", filename], { cwd: dir, encoding: "utf8", }); @@ -52,3 +58,95 @@ test("press primitives and pressable types are allowed", () => { ); assert.equal(result.failed, false, result.output); }); + +for (const source of [ + 'import { readFileSync as read } from "node:fs"; export { read };', + 'import { execSync } from "child_process"; export { execSync };', + 'import fs from "node:fs"; export const read = fs.readFileSync;', + 'import { default as fs } from "node:fs"; export const read = fs.readFileSync;', + 'import fs = require("node:fs"); export const read = fs.readFileSync;', + 'import * as fs from "fs"; export const read = fs["readFileSync"];', + 'import cp from "node:child_process"; export const run = cp.spawnSync;', + 'const { readFileSync: read } = require("node:fs"); export { read };', + 'const fs = require("fs"); export const read = fs.readFileSync;', + 'export const run = require("node:child_process").execFileSync;', + 'import fs from "node:fs"; const other = fs; export const read = other.statSync;', + 'export { readFileSync as read } from "node:fs";', + 'export const read = (await import("node:fs")).readFileSync;', +]) { + test(`sync IO is reported: ${source}`, () => { + const result = lint(source, "paseo/no-sync-io"); + assert.equal(result.failed, true, result.output); + assert.match(result.output, /Synchronous Node IO/); + }); +} + +test("async IO, types, unrelated methods and shadowed namespaces are allowed", () => { + const result = lint( + 'import fs from "node:fs"; import { readFile } from "node:fs/promises";\n' + + 'import type { readFileSync } from "node:fs";\n' + + "export const read = readFile; export const asyncRead = fs.promises.readFile;\n" + + "export function unrelated(fs: { readFileSync(): void }) { fs.readFileSync(); }\n" + + "export type Reader = typeof readFileSync;\n", + "paseo/no-sync-io", + ); + assert.equal(result.failed, false, result.output); +}); + +test("sync IO exceptions allow only named APIs in the named server module", () => { + const options = { allow: { "server/startup.ts": ["readFileSync"] } }; + const source = 'import { readFileSync } from "node:fs"; export { readFileSync };'; + const allowed = lint( + source, + "paseo/no-sync-io", + options, + "packages/server/src/server/startup.ts", + ); + assert.equal(allowed.failed, false, allowed.output); + const otherFile = lint( + source, + "paseo/no-sync-io", + options, + "packages/server/src/server/session.ts", + ); + assert.equal(otherFile.failed, true, otherFile.output); + assert.match(otherFile.output, /Synchronous Node IO/); + const otherApi = lint( + 'import fs from "node:fs"; export const read = fs.statSync;', + "paseo/no-sync-io", + options, + "packages/server/src/server/startup.ts", + ); + assert.equal(otherApi.failed, true, otherApi.output); + assert.match(otherApi.output, /Synchronous Node IO/); +}); + +test("repository config guards request paths while allowing startup APIs and test fixtures", () => { + const config = readFileSync(path.join(repoRoot, ".oxlintrc.json"), "utf8").replace( + '"./scripts/oxlint/paseo-plugin.mjs"', + JSON.stringify(pluginPath), + ); + const source = 'import { existsSync } from "node:fs"; export const probe = existsSync;'; + const request = lintWithConfig(source, config, "packages/server/src/server/probe.ts"); + assert.equal(request.failed, true, request.output); + assert.match(request.output, /Synchronous Node IO/); + for (const filename of [ + "packages/server/src/server/config.ts", + "packages/server/src/server/probe.test.ts", + "packages/app/src/probe.ts", + ]) { + const result = lintWithConfig(source, config, filename); + assert.equal(result.failed, false, result.output); + } +}); + +test("sync IO exceptions must be removed when a module stops using the API", () => { + const result = lint( + 'import { readFile } from "node:fs/promises"; export const read = readFile;', + "paseo/no-sync-io", + { allow: { "server/startup.ts": ["readFileSync"] } }, + "packages/server/src/server/startup.ts", + ); + assert.equal(result.failed, true, result.output); + assert.match(result.output, /Remove the unused sync IO exception for readFileSync/); +}); diff --git a/scripts/postinstall-patches.test.mjs b/scripts/postinstall-patches.test.mjs index 9e4d4c0722..6933ca5f9d 100644 --- a/scripts/postinstall-patches.test.mjs +++ b/scripts/postinstall-patches.test.mjs @@ -10,6 +10,38 @@ const bin = path.resolve("node_modules", ".bin"); const packageName = "react-native-draggable-flatlist"; const patchFile = `${packageName}+4.0.3.patch`; +test("patched Markdown ships iOS props generated from its native specs", () => { + const packageDir = path.resolve("packages/app/node_modules/react-native-enriched-markdown"); + const output = mkdtempSync(path.join(tmpdir(), "markdown-ios-codegen-")); + try { + // includesGeneratedCode makes CocoaPods use the shipped headers instead of + // regenerating them after patch-package changes the TypeScript specs. + const result = spawnSync( + process.execPath, + [ + path.resolve("node_modules/react-native/scripts/generate-codegen-artifacts.js"), + "-p", + packageDir, + "-t", + "ios", + "-o", + output, + "-s", + "library", + ], + { encoding: "utf8" }, + ); + assert.equal(result.status, 0, result.stdout + result.stderr); + const header = "ReactCodegen/EnrichedMarkdownTextSpec/Props.h"; + assert.equal( + readFileSync(path.join(packageDir, "ios/generated", header), "utf8"), + readFileSync(path.join(output, header), "utf8"), + ); + } finally { + rmSync(output, { recursive: true, force: true }); + } +}); + function createCheckout() { const root = mkdtempSync(path.join(tmpdir(), "postinstall-patches-")); writeFileSync(path.join(root, "package.json"), JSON.stringify({ name: "checkout" })); diff --git a/skills/paseo-plugin/SKILL.md b/skills/paseo-plugin/SKILL.md index 028d869085..0a36b5b3eb 100644 --- a/skills/paseo-plugin/SKILL.md +++ b/skills/paseo-plugin/SKILL.md @@ -27,28 +27,29 @@ public docs; this skill indexes the references and examples. Pick the contribution that matches the request. Each row names the registration, when it fits, and where the full contract lives. Most plugins combine several: a slash command that calls an RPC, which appends a timeline row, which a renderer draws. -| Contribution | Registration | Use it when | Reference | -| ------------------------- | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | -| Screen and sidebar item | `addScreen` + `addSidebarHeaderItem`/`addSidebarFooterItem` | A full screen of plugin UI, or sidebar header or footer rows that open a screen or popover | reference.md → Screens and sidebar items | -| Workspace panel | `addWorkspacePanel` | UI that lives as a tab beside agents, terminals, files, and diffs; `locations: ["explorer"]` for the Explorer | reference.md → Workspace panels | -| File panel | `addFilePanel` | View or edit a matching file inside the file tab through its file handle | reference.md → File panels; `plugin-examples/pdf-panel`, `plugin-examples/markdown-panel` | -| Command Center item | `addCommandCenterItem` | A global, workspace, or agent action reachable from ⌘K | reference.md → Command Center items | -| Client slash command | `addSlashCommand` | A `/command args` in the composer that runs plugin code instead of prompting the agent | reference.md → Slash commands | -| Composer pill | `addComposerPill` | A per-agent button in the composer track bar next to Tasks and Subagents | reference.md → Composer pills | -| Timeline transformer | `addTimelineTransformer` + `addTimelineRenderer` | Replace, explode, or hide a built-in timeline item, including while it streams | reference.md → Timeline items; `plugin-examples/timeline-items`, `plugin-examples/inline-thinking` | -| Timeline row | `paseo.agents.ref(id).timeline.append(...)` | Push a plugin-owned row into an agent timeline from a server handler and update it later | reference.md → Append a timeline row from the daemon | -| Attachment source | `client.addAttachmentSource` + `server.handle` | Let the user attach a searchable external resource, such as an issue, to a prompt | reference.md → Add a composer attachment source; `plugin-examples/linear` | -| Theme | `addTheme` | A light or dark palette under Settings → Appearance | reference.md → Contribute a theme; `plugin-examples/catppuccin` | -| Plugin RPC | `defineRpc` + `server.handle` + `useRpc` | Daemon-side work that is not a normal Paseo operation: vendor APIs, credentials, local files | reference.md → Add plugin-specific backend behavior | -| Lifecycle events | `server.on` | Observe agent/workspace lifecycle, inspect ended turns, and answer permission requests | [Lifecycle hooks](https://paseo.sh/docs/plugins/reference.md#lifecycle-hooks) | -| Creation and launch hooks | `server.before` | Change agent config, provider options, MCP servers, environment, or workspace isolation before the operation | [Before hooks](https://paseo.sh/docs/plugins/reference.md#before-hooks) | -| Paseo SDK | `usePaseo()` / handler `{ paseo }` | Normal Paseo operations: workspaces, agents, providers, config | reference.md → Use the Paseo SDK | +| Contribution | Registration | Use it when | Reference | +| ------------------------- | ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | +| Screen and sidebar item | `addScreen` + `addSidebarHeaderItem`/`addSidebarFooterItem` | A full screen of plugin UI, or sidebar header or footer rows that open a screen or popover | reference.md → Screens and sidebar items | +| Workspace panel | `addWorkspacePanel` | UI that lives as a tab beside agents, terminals, files, and diffs; `locations: ["explorer"]` for the Explorer | reference.md → Workspace panels | +| File panel | `addFilePanel` | View or edit a matching file inside the file tab through its file handle | reference.md → File panels; `plugin-examples/pdf-panel`, `plugin-examples/markdown-panel` | +| Command Center item | `addCommandCenterItem` | A global, workspace, or agent action reachable from ⌘K | reference.md → Command Center items | +| Client slash command | `addSlashCommand` | A `/command args` in the composer that runs plugin code instead of prompting the agent | reference.md → Slash commands | +| Composer pill | `addComposerPill` | A per-agent button in the composer track bar next to Tasks and Subagents | reference.md → Composer pills | +| Timeline transformer | `addTimelineTransformer` + `addTimelineRenderer` | Replace, explode, or hide a built-in timeline item, including while it streams | reference.md → Timeline items; `plugin-examples/timeline-items`, `plugin-examples/inline-thinking` | +| Timeline row | `paseo.agents.ref(id).timeline.append(...)` | Push a plugin-owned row into an agent timeline from a server handler and update it later | reference.md → Append a timeline row from the daemon | +| Attachment source | `client.addAttachmentSource` + `server.handle` | Let the user attach a searchable external resource, such as an issue, to a prompt | reference.md → Add a composer attachment source; `plugin-examples/linear` | +| Theme | `addTheme` | A light or dark palette under Settings → Appearance | reference.md → Contribute a theme; `plugin-examples/catppuccin` | +| Plugin RPC | `defineRpc` + `server.handle` + `useRpc` | Daemon-side work that is not a normal Paseo operation: vendor APIs, credentials, local files | reference.md → Add plugin-specific backend behavior | +| Lifecycle events | `server.on` | Observe agent/workspace lifecycle, inspect ended turns, and answer permission requests | [Lifecycle hooks](https://paseo.sh/docs/plugins/reference.md#lifecycle-hooks) | +| Creation and launch hooks | `server.before` | `agent.create` edits saved config; `agent.session_open` contributes launch-only `env`, `tools`, `mcpServers` on every open; `workspace.create` chooses isolation | [Before hooks](https://paseo.sh/docs/plugins/reference.md#before-hooks) | +| Paseo SDK | `usePaseo()` / handler `{ paseo }` | Normal Paseo operations: workspaces, agents, providers, config | reference.md → Use the Paseo SDK | -| Lifecycle task | Example | -| ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | -| Log all eleven hooks | [lifecycle-logger](https://github.com/getpaseo/paseo/tree/main/plugin-examples/lifecycle-logger) | -| Follow-ups, permissions, environment, provider switching, worktrees | [lifecycle-actions](https://github.com/getpaseo/paseo/tree/main/plugin-examples/lifecycle-actions) | -| Inject MCP servers and change Codex sandbox/approval options | [agent-configuration](https://github.com/getpaseo/paseo/tree/main/plugin-examples/agent-configuration) | +| Lifecycle task | Example | +| ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | +| Log all eleven hooks | [lifecycle-logger](https://github.com/getpaseo/paseo/tree/main/plugin-examples/lifecycle-logger) | +| Follow-ups, permissions, environment, provider switching, worktrees | [lifecycle-actions](https://github.com/getpaseo/paseo/tree/main/plugin-examples/lifecycle-actions) | +| Inject launch-only MCP servers and save Codex sandbox/approval options | [agent-configuration](https://github.com/getpaseo/paseo/tree/main/plugin-examples/agent-configuration) | +| Attach a tool to selected agents | [agent-tools](https://github.com/getpaseo/paseo/tree/main/plugin-examples/agent-tools) | ## Create the project @@ -377,6 +378,35 @@ Inputs and outputs are validated on both sides. Backend handlers receive the sam Use TanStack Query for async request state, caching, and mutations. +### Contribute tools and MCP servers at session open + +Use `before("agent.session_open")` for per-agent tools and plugin-owned MCP servers. Add this +callback inside `contribute(server)`: + +```ts +server.before("agent.session_open", ({ request }) => { + if (request.provider !== "codex") return; + return { + ...request, + mcpServers: { + ...request.mcpServers, + company: { type: "http", url: "https://tools.example.com/mcp" }, + }, + }; +}); +``` + +`tools` and `mcpServers` start empty for each plugin and accumulate across its own callbacks. +They are launch-only. Use `agent.create` to edit configuration that should be saved with the agent. +See [Attach tools to agents](https://paseo.sh/docs/plugins/reference.md#attach-tools-to-agents) +for `defineAgentTool` and targeting, and +[Inject MCP servers](https://paseo.sh/docs/plugins/reference.md#inject-mcp-servers) for server configuration and collisions. + +Starting, stopping, or reloading a plugin with a session-open hook marks open agent sessions stale. +Each reopens before its next idle prompt without interrupting a running turn. +`await paseo.agents.ref(id).reload()` applies changes immediately and interrupts a running turn; +`refresh()` only reads the current snapshot. + ### Debug daemon-side behavior Backend contributions can use normal Node logging. `console.log()` writes to the plugin's stdout;