Compare commits

...

96 Commits

Author SHA1 Message Date
Ryan Vogel
36b51cad33 Merge branch 'dev' into opencode-remote-voice 2026-03-31 13:59:13 -04:00
Ryan Vogel
776e61d1ec update to build proc 2026-03-31 13:58:57 -04:00
Aiden Cline
434d82bbe2 test: update model test fixture (#20182) 2026-03-31 16:20:01 +00:00
Aiden Cline
2929774acb chore: rm harcoded model definition from codex plugin (#20294) 2026-03-31 11:13:11 -05:00
Adam
6e61a46a84 chore: skip 2 tests 2026-03-31 10:56:06 -05:00
Yuxin Dong
2daf4b805a feat: add a dedicated system prompt for Kimi models (#20259)
Co-authored-by: dongyuxin <dongyuxin@dev.dongyuxin.msh-dev.svc.cluster.local>
2026-03-31 17:44:17 +02:00
opencode-agent[bot]
7342e650c0 chore: update nix node_modules hashes 2026-03-31 15:33:12 +00:00
Adam
8c2e2ecc95 chore: e2e model 2026-03-31 10:14:26 -05:00
Sebastian
25a2b739e6 warn only and ignore plugins without entrypoints, default config via exports (#20284) 2026-03-31 17:14:03 +02:00
Adam
85c16926c4 chore: use paid zen model in e2e 2026-03-31 10:06:44 -05:00
Sebastian
2e78fdec43 ensure pinned plugin versions and do not run package scripts on install (#20248) 2026-03-31 16:59:43 +02:00
Sebastian
1fcb920eb4 upgrade opentui to 0.1.93 (#19950) 2026-03-31 16:50:23 +02:00
Ryan Vogel
28aebb2772 update mobile voice iOS tracking
Stop tracking generated iOS native project files so EAS builds use app config prebuild output and avoid mixed native/CNG state.
2026-03-31 10:21:02 -04:00
opencode
b1e89c344b release: v1.3.10 2026-03-31 13:31:37 +00:00
Dax
befbedacdc fix(session): subagents not being clickable (#20263) 2026-03-31 08:58:46 -04:00
Frank
2cc738fb17 wip: zen 2026-03-31 00:07:56 -04:00
opencode-agent[bot]
71b20698bb chore: generate 2026-03-31 01:57:41 +00:00
Kit Langton
3df18dcde1 refactor(provider): effectify Provider service (#20160) 2026-03-30 21:56:43 -04:00
Kit Langton
a898c2ea3a refactor(storage): effectify Storage service (#20132) 2026-03-31 01:16:02 +00:00
Kit Langton
bf777298c8 fix(theme): darken muted text in catppuccin tui themes (#20161) 2026-03-30 21:06:05 -04:00
Luke Parker
93fad99f7f smarter changelog (#20138) 2026-03-31 00:05:46 +00:00
opencode
057848deb8 release: v1.3.9 2026-03-30 23:43:58 +00:00
Luke Parker
1de06452d3 fix(plugin): properly resolve entrypoints without leading dot (#20140) 2026-03-31 09:21:17 +10:00
Frank
58f60629a1 wip: zen 2026-03-30 19:04:42 -04:00
Frank
39a47c9b8c wip: zen 2026-03-30 18:50:09 -04:00
opencode-agent[bot]
ea88044f2e chore: generate 2026-03-30 21:49:45 +00:00
Kit Langton
e6f6f7aff1 refactor: replace Filesystem util with AppFileSystem service (#20127) 2026-03-30 21:48:28 +00:00
opencode
48e97b47af release: v1.3.8 2026-03-30 21:35:10 +00:00
Ryan Vogel
6494f48136 update 2026-03-30 17:05:49 -04:00
Ryan Vogel
15fae6cb60 update mobile pairing flow and audio session handling
Improve pairing reliability and UX by letting users choose among scanned hosts with health checks and cleaner row styling while shrinking QR payloads. Handle iOS call-time audio session conflicts more gracefully with user-friendly messaging and lower-noise logs.
2026-03-30 16:53:35 -04:00
opencode-agent[bot]
fe120e3cbf chore: update nix node_modules hashes 2026-03-30 20:46:10 +00:00
Frank
f2dd774660 zen: qwen3.6 plus 2026-03-30 16:43:55 -04:00
Frank
e7ff0f17c8 zen: qwen3.6 plus 2026-03-30 16:32:22 -04:00
Kit Langton
2ed756c72c fix(session): restore busy route handling and add regression coverage (#20125) 2026-03-30 20:30:34 +00:00
Frank
054f4be185 zen: add safety identifier 2026-03-30 16:28:40 -04:00
Jaaneek
e3e1e9af50 fix(Opencode): Bump ai-sdk/xai to 3.0.75 to fix tool calls (#20123)
Co-authored-by: Jaaneek <jankiewiczmilosz@gmail.com>
2026-03-30 15:11:21 -05:00
opencode-agent[bot]
c8389cf96d chore: generate 2026-03-30 20:08:09 +00:00
Kit Langton
c5442d418d refactor(session): effectify SessionPrompt service (#19483) 2026-03-30 20:06:51 +00:00
Sebastian
fa95a61c4e Refactor into plugin loader and do not enforce (#20112) 2026-03-30 20:36:21 +02:00
Ryan Vogel
aacf1d20d3 update app hanlding 2026-03-30 13:07:30 -04:00
Ryan Vogel
bcf7817127 update mobile dictation controls
Add mobile permission approval flow, simplify dictation settings into toggles, and remove oversized Whisper models while syncing the iOS project with the current runtime configuration.
2026-03-30 13:01:14 -04:00
Aiden Cline
9f3c2bd861 fix: agent value passed to chat.params and chat.headers hooks was not a string (#19996) 2026-03-30 16:32:27 +00:00
Adam
c2f78224ae chore(app): cleanup (#20062) 2026-03-30 08:50:42 -05:00
Ryan Vogel
abf79ae24c refactor mobile screen orchestration
Extract server/session and monitoring workflows into focused hooks so DictationScreen no longer owns every network and notification path. Add a dedicated mobile typecheck config so TypeScript checks pass without breaking Expo export resolution.
2026-03-30 08:57:35 -04:00
Sebastian
14f9e21d5c pluggable home footer (#20057) 2026-03-30 14:33:01 +02:00
Ryan Vogel
922633ea9d refactor mobile derived ui state
Rewrite a focused cluster of nested ternaries in the mobile screen into straight-line derived logic so the render state is easier to read without changing behavior.
2026-03-30 08:31:46 -04:00
Ryan Vogel
49b40e3c90 refactor mobile fire-and-forget calls
Mark intentional async work in the mobile screen with the void operator so lint can distinguish real promise bugs from deliberate fire-and-forget behavior.
2026-03-30 08:30:28 -04:00
Ryan Vogel
df3276fc87 refactor mobile web color hydration
Replace the hydration state effect with useSyncExternalStore so the web color-scheme hook keeps its static-render fallback without triggering the set-state-in-effect lint warning.
2026-03-30 08:28:25 -04:00
Ryan Vogel
f8f986536b refactor mobile session payload parsing
Move server session response parsing into a typed helper so the mobile screen no longer relies on inline any-based mapping in the refresh path.
2026-03-30 08:27:16 -04:00
Ryan Vogel
785635caef refactor mobile onboarding config
Replace the onboarding step ternary chain with a typed step config so the screen is easier to read and lint can highlight the remaining hotspots more clearly.
2026-03-30 08:18:51 -04:00
Ryan Vogel
ec27518eca update mobile voice quality guardrails
Document package-specific React Native best practices and add lint warnings so state, effect, and complexity issues surface earlier during mobile-voice work.
2026-03-30 08:15:29 -04:00
Sebastian
8e4bab5181 update plugin themes when plugin was updated (#20052) 2026-03-30 13:51:07 +02:00
Ryan Vogel
8ee4ada38e update for onboarding 2026-03-30 07:45:21 -04:00
Ryan Vogel
ab7b1d78bf Update settings 2026-03-30 07:33:30 -04:00
Jack
3c32013eb1 fix: preserve image attachments when selecting slash commands (#19771) 2026-03-30 17:11:34 +08:00
opencode
47d2ab120a release: v1.3.7 2026-03-30 06:06:12 +00:00
Luke Parker
186af2723d make variant modal less annoying (#19998) 2026-03-30 15:42:38 +10:00
Luke Parker
6926fe1c74 fix: stabilize release changelog generation (#19987) 2026-03-30 04:37:02 +00:00
Chris Yang
ee018d5c82 docs: rename patch tool to apply_patch and clarify apply_patch behavior (#19979) 2026-03-29 23:01:57 -05:00
opencode-agent[bot]
0465579d6b chore: update nix node_modules hashes 2026-03-30 03:53:11 +00:00
Knut Zuidema
196a03caff fix: discourage _noop tool call during LiteLLM compaction (#18539) 2026-03-29 22:48:17 -05:00
Luke Parker
b234370080 feat(windows): add first-class pwsh/powershell support (#16069) 2026-03-30 13:10:01 +10:00
Ryan Vogel
2f44d1900e feat: support deep-link QR pairing in mobile
Generate mobilevoice deep links in serve QR output and let mobile parse both raw payloads and pair query links, while keeping advertised-host ordering and removing QR name overrides.
2026-03-29 19:38:19 -04:00
Ryan Vogel
cb535eef9d feat: support advertised QR hosts for mobile pairing
Allow serve to publish preferred host/domain entries in QR payloads and make mobile choose the first reachable host by QR order so preferred addresses like .ts.net are selected consistently.
2026-03-29 18:32:21 -04:00
Ryan Vogel
d3ec6f75f4 feat: route push notifications by server and session
Include serverID in relay event payloads and prefer server+session matching in mobile notification handling so taps reliably open the correct context and stale state is refreshed.
2026-03-29 17:52:07 -04:00
Ryan Vogel
9a8b2ae0b1 update apn server 2026-03-29 16:26:16 -04:00
Ryan Vogel
eadb0e25da update to the apn and server management 2026-03-29 16:17:57 -04:00
Sebastian
5d2dc8888c theme colors for dialog textarea placeholders (#19939) 2026-03-29 21:37:46 +02:00
Sebastian
0b1018f6dd plugins installs should preserve jsonc comments (#19938) 2026-03-29 21:15:03 +02:00
Aiden Cline
afb6abff73 fix: ensure OPENCODE_DISABLE_CLAUDE_CODE_PROMPT is respected for project lvl CLAUDE.md (#19924) 2026-03-29 18:02:29 +00:00
opencode
e7f94f9b9a release: v1.3.6 2026-03-29 18:02:20 +00:00
ualtinok
72c77d0e7b fix(session): fix token usage double-counting w/ anthropic & bedrock due to AI SDK v6 upgrade (#19758)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-03-29 12:40:10 -05:00
Ariane Emory
5c15755a10 docs: add question tool to available permissions list (#19922) 2026-03-29 17:35:52 +00:00
Frank
3a4bfeb5b5 wip: zen 2026-03-29 13:06:00 -04:00
Frank
1037c72d99 wip: zen 2026-03-29 12:47:53 -04:00
Kit Langton
ba00e9a993 Fix variant dialog filtering (#19917) 2026-03-29 16:43:13 +00:00
Frank
963dad75ef ci: fix 2026-03-29 12:41:52 -04:00
Frank
7e9b721e97 wip: zen 2026-03-29 12:04:20 -04:00
Luke Parker
a5b1dc081d test: add regression coverage for sync plugin hooks (#19589) 2026-03-29 03:43:00 +00:00
Ryan Vogel
ddd30ef304 update 2026-03-28 21:38:21 -04:00
Ryan Vogel
2abf1100ee update for whisper 2026-03-28 21:12:24 -04:00
Ryan Vogel
bd2e34f3bd update 2026-03-28 19:03:13 -04:00
Ryan Vogel
a45c3a0049 feat: harden mobile server flow and enrich push alerts
Persist scanned servers across reloads, smooth server/session UI states, and make recording feel immediate. Add session-aware push notification title/body metadata from the OpenCode server.
2026-03-28 18:10:35 -04:00
Ryan Vogel
52d1ee70a0 feat: use new mobile app icon and QR-only server add flow
Replace Expo icon/adaptive icon assets with the provided image and simplify the server dropdown so adding a server is done by scanning the setup QR code only.
2026-03-28 17:30:13 -04:00
Ryan Vogel
0a9fcab56f chore: update dependencies and enhance mobile-voice functionality
- Updated package dependencies in bun.lock and package.json for mobile-voice and opencode.
- Added expo-camera and improved camera permission handling in mobile-voice.
- Introduced QR code generation for relay setup in opencode serve command.
- Enhanced server management and logging in DictationScreen component.
2026-03-28 17:05:35 -04:00
Ryan Vogel
62fae6d182 fix: auto-recover APNs env mismatch in relay
Retry sends on BadEnvironmentKeyInToken with the opposite APNs environment, persist the corrected env, and add request/send logs for register/unregister/event delivery debugging.
2026-03-28 16:58:36 -04:00
Ryan Vogel
3a5be7ad33 update index.ts 2026-03-28 14:31:16 -04:00
Ryan Vogel
f1e88d35ba update for the db.ts 2026-03-28 14:28:44 -04:00
Ryan Vogel
b737e87d9a update env again 2026-03-28 14:16:57 -04:00
Ryan Vogel
bd6e81f30b update for env checks 2026-03-28 14:11:02 -04:00
Ryan Vogel
f080147363 update for app and bun 2026-03-28 14:03:57 -04:00
Ryan Vogel
0051b605ae feat: improve mobile model download UX and relay defaults
Add in-button model download progress plus a model reset control in mobile-voice, and switch APN relay defaults to apn.dev.opencode.ai in serve and docs.
2026-03-28 14:03:57 -04:00
Ryan Vogel
56e0e5ce65 Update packages json for the porter stuff 2026-03-28 14:03:57 -04:00
porter-deployment-app[bot]
d065d5a8ec Add Porter workflow files for APN relay project (#19547)
Co-authored-by: porter-deployment-app[bot] <87230664+porter-deployment-app[bot]@users.noreply.github.com>
2026-03-28 13:59:37 -04:00
Ryan Vogel
cf79208055 mobile-voice commit 2026-03-28 13:30:21 -04:00
Ryan Vogel
f276a8db42 feat: add APN relay MVP and experimental push bridge 2026-03-28 13:28:24 -04:00
276 changed files with 97591 additions and 43095 deletions

View File

@@ -9,7 +9,8 @@ on:
jobs:
sync-locales:
if: github.actor != 'opencode-agent[bot]'
if: false
#if: github.actor != 'opencode-agent[bot]'
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
contents: write
@@ -34,7 +35,7 @@ jobs:
- name: Compute changed English docs
id: changes
run: |
FILES=$(git diff --name-only "${{ github.event.before }}" "${{ github.sha }}" -- 'packages/web/src/content/docs/*.mdx' || true)
FILES=$(git diff --name-only "${{ github.event.before }}" "${{ github.sha }}" -- ':(glob)packages/web/src/content/docs/*.mdx' || true)
if [ -z "$FILES" ]; then
echo "has_changes=false" >> "$GITHUB_OUTPUT"
echo "No English docs changed in push range"

View File

@@ -0,0 +1,27 @@
"on":
push:
branches:
- opencode-remote-voice
name: Deploy to apn-relay
jobs:
porter-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set Github tag
id: vars
run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
- name: Setup porter
uses: porter-dev/setup-porter@v0.1.0
- name: Deploy stack
timeout-minutes: 30
run: porter apply
env:
PORTER_APP_NAME: apn-relay
PORTER_CLUSTER: "5534"
PORTER_DEPLOYMENT_TARGET_ID: d60e67f5-b0a6-4275-8ed6-3cebaf092147
PORTER_HOST: https://dashboard.porter.run
PORTER_PROJECT: "18525"
PORTER_TAG: ${{ steps.vars.outputs.sha_short }}
PORTER_TOKEN: ${{ secrets.PORTER_APP_18525_975734319 }}

View File

@@ -100,6 +100,9 @@ jobs:
run: bun --cwd packages/app test:e2e:local
env:
CI: true
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
OPENCODE_E2E_MODEL: opencode/claude-haiku-4-5
OPENCODE_E2E_REQUIRE_PAID: "true"
timeout-minutes: 30
- name: Upload Playwright artifacts

1
.gitignore vendored
View File

@@ -25,6 +25,7 @@ target
# Local dev files
opencode-dev
UPCOMING_CHANGELOG.md
logs/
*.bun-build
tsconfig.tsbuildinfo

View File

@@ -1,23 +1,46 @@
---
model: opencode/kimi-k2.5
model: opencode/gpt-5.4
---
create UPCOMING_CHANGELOG.md
Create `UPCOMING_CHANGELOG.md` from the structured changelog input below.
If `UPCOMING_CHANGELOG.md` already exists, ignore its current contents completely.
Do not preserve, merge, or reuse text from the existing file.
it should have sections
The input already contains the exact commit range since the last non-draft release.
The commits are already filtered to the release-relevant packages and grouped into
the release sections. Do not fetch GitHub releases, PRs, or build your own commit list.
The input may also include a `## Community Contributors Input` section.
```
## TUI
Before writing any entry you keep, inspect the real diff with
`git show --stat --format='' <hash>` or `git show --format='' <hash>` so you can
understand the actual code changes and not just the commit message (they may be misleading).
Do not use `git log` or author metadata when deciding attribution.
## Desktop
Rules:
## Core
- Write the final file with sections in this order:
`## Core`, `## TUI`, `## Desktop`, `## SDK`, `## Extensions`
- Only include sections that have at least one notable entry
- Keep one bullet per commit you keep
- Skip commits that are entirely internal, CI, tests, refactors, or otherwise not user-facing
- Start each bullet with a capital letter
- Prefer what changed for users over what code changed internally
- Do not copy raw commit prefixes like `fix:` or `feat:` or trailing PR numbers like `(#123)`
- Community attribution is deterministic: only preserve an existing `(@username)` suffix from the changelog input
- If an input bullet has no `(@username)` suffix, do not add one
- Never add a new `(@username)` suffix from `git show`, commit authors, names, or email addresses
- If no notable entries remain and there is no contributor block, write exactly `No notable changes.`
- If no notable entries remain but there is a contributor block, omit all release sections and return only the contributor block
- If the input contains `## Community Contributors Input`, append the block below that heading to the end of the final file verbatim
- Do not add, remove, rewrite, or reorder contributor names or commit titles in that block
- Do not derive the thank-you section from the main summary bullets
- Do not include the heading `## Community Contributors Input` in the final file
- Focus on writing the least words to get your point across - users will skim read the changelog, so we should be precise
## Misc
```
**Importantly, the changelog is for users (who are at least slightly technical), they may use the TUI, Desktop, SDK, Plugins and so forth. Be thorough in understanding flow on effects may not be immediately apparent. e.g. a package upgrade looks internal but may patch a bug. Or a refactor may also stabilise some race condition that fixes bugs for users. The PR title/body + commit message will give you the authors context, usually containing the outcome not just technical detail**
fetch the latest github release for this repository to determine the last release version.
<changelog_input>
find each PR that was merged since the last release
!`bun script/raw-changelog.ts $ARGUMENTS`
for each PR spawn a subagent to summarize what the PR was about. focus on user facing changes. if it was entirely internal or code related you can ignore it. also skip docs updates. each subagent should append its summary to UPCOMING_CHANGELOG.md into the appropriate section.
</changelog_input>

252
AGENTS.md
View File

@@ -1,128 +1,162 @@
- To regenerate the JavaScript SDK, run `./packages/sdk/js/script/build.ts`.
- ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE.
- The default branch in this repo is `dev`.
- Local `main` ref may not exist; use `dev` or `origin/dev` for diffs.
- Prefer automation: execute requested actions without confirmation unless blocked by missing info or safety/irreversibility.
# OpenCode Monorepo Agent Guide
## Style Guide
This file is for coding agents working in `/Users/ryanvogel/dev/opencode`.
### General Principles
## Scope And Precedence
- Keep things in one function unless composable or reusable
- Avoid `try`/`catch` where possible
- Avoid using the `any` type
- Prefer single word variable names where possible
- Use Bun APIs when possible, like `Bun.file()`
- Rely on type inference when possible; avoid explicit type annotations or interfaces unless necessary for exports or clarity
- Prefer functional array methods (flatMap, filter, map) over for loops; use type guards on filter to maintain type inference downstream
- Start with this file for repo-wide defaults.
- Then check package-local `AGENTS.md` files for stricter rules.
- Existing local guides include `packages/opencode/AGENTS.md` and `packages/app/AGENTS.md`.
- Package-specific guides override this file when they conflict.
## Repo Facts
- Package manager: `bun` (`bun@1.3.11`).
- Monorepo tool: `turbo`.
- Default branch: `dev`.
- Root test script intentionally fails; do not run tests from root.
## Cursor / Copilot Rules
- No `.cursor/rules/` directory found.
- No `.cursorrules` file found.
- No `.github/copilot-instructions.md` file found.
- If these files are added later, treat them as mandatory project policy.
## High-Value Commands
Run commands from the correct package directory unless noted.
### Root
- Install deps: `bun install`
- Run all typechecks via turbo: `bun run typecheck`
- OpenCode dev CLI entry: `bun run dev`
- OpenCode serve (common): `bun run dev serve --hostname 0.0.0.0 --port 4096`
### `packages/opencode`
- Dev CLI: `bun run dev`
- Typecheck: `bun run typecheck`
- Tests (all): `bun test --timeout 30000`
- Tests (single file): `bun test test/path/to/file.test.ts --timeout 30000`
- Tests (single test name): `bun test test/path/to/file.test.ts -t "name fragment" --timeout 30000`
- Build: `bun run build`
- Drizzle helper: `bun run db`
### `packages/app`
- Dev server: `bun dev`
- Build: `bun run build`
- Typecheck: `bun run typecheck`
- Unit tests (all): `bun run test:unit`
- Unit tests (single file): `bun test --preload ./happydom.ts ./src/path/to/file.test.ts`
- Unit tests (single test name): `bun test --preload ./happydom.ts ./src/path/to/file.test.ts -t "name fragment"`
- E2E tests: `bun run test:e2e`
### `packages/mobile-voice`
- Start Expo: `bun run start`
- Start Expo dev client: `bunx expo start --dev-client --clear --host lan`
- iOS native run: `bun run ios`
- Android native run: `bun run android`
- Lint: `bun run lint`
- Expo doctor: `bunx expo-doctor`
- Dependency compatibility check: `bunx expo install --check`
### `packages/apn-relay`
- Start relay: `bun run dev`
- Typecheck: `bun run typecheck`
- DB connectivity check: `bun run db:check`
## Build / Lint / Test Expectations
- Always run the narrowest checks that prove your change.
- For backend changes: run package typecheck + relevant tests.
- For mobile changes: run `expo lint` and at least one `expo` compile-style command if possible.
- Never claim tests passed unless you ran them in this workspace.
## Single-Test Guidance
- Prefer running one file first, then broaden scope.
- For Bun tests, pass the file path directly.
- For name filtering, use `-t "..."`.
- Keep original timeouts when scripts define them.
## Code Style Guidelines
These conventions are already used heavily in this repo and should be preserved.
### Formatting
- Use Prettier defaults configured in root: `semi: false`, `printWidth: 120`.
- Keep imports grouped and stable; avoid noisy reorder-only edits.
- Avoid unrelated formatting churn in touched files.
### Imports
- Prefer explicit imports over dynamic imports unless runtime gating is required.
- Prefer existing alias patterns (for example `@/...`) where already configured.
- Do not introduce new dependency layers when a local util already exists.
### Types
- Avoid `any`.
- Prefer inference for local variables.
- Add explicit annotations for exported APIs and complex boundaries.
- Prefer `zod` schemas for request/response validation and parsing.
### Naming
Prefer single word names for variables and functions. Only use multiple words if necessary.
### Naming Enforcement (Read This)
THIS RULE IS MANDATORY FOR AGENT WRITTEN CODE.
- Use single word names by default for new locals, params, and helper functions.
- Multi-word names are allowed only when a single word would be unclear or ambiguous.
- Do not introduce new camelCase compounds when a short single-word alternative is clear.
- Before finishing edits, review touched lines and shorten newly introduced identifiers where possible.
- Good short names to prefer: `pid`, `cfg`, `err`, `opts`, `dir`, `root`, `child`, `state`, `timeout`.
- Examples to avoid unless truly required: `inputPID`, `existingClient`, `connectTimeout`, `workerPath`.
```ts
// Good
const foo = 1
function journal(dir: string) {}
// Bad
const fooBar = 1
function prepareJournal(dir: string) {}
```
Reduce total variable count by inlining when a value is only used once.
```ts
// Good
const journal = await Bun.file(path.join(dir, "journal.json")).json()
// Bad
const journalPath = path.join(dir, "journal.json")
const journal = await Bun.file(journalPath).json()
```
### Destructuring
Avoid unnecessary destructuring. Use dot notation to preserve context.
```ts
// Good
obj.a
obj.b
// Bad
const { a, b } = obj
```
### Variables
Prefer `const` over `let`. Use ternaries or early returns instead of reassignment.
```ts
// Good
const foo = condition ? 1 : 2
// Bad
let foo
if (condition) foo = 1
else foo = 2
```
- Follow existing repo preference for short, clear names.
- Use single-word names when readable; use multi-word only for clarity.
- Keep naming consistent with nearby code.
### Control Flow
Avoid `else` statements. Prefer early returns.
- Prefer early returns over nested `else` blocks.
- Keep functions focused; split only when it improves reuse or readability.
```ts
// Good
function foo() {
if (condition) return 1
return 2
}
### Error Handling
// Bad
function foo() {
if (condition) return 1
else return 2
}
```
- Fail with actionable messages.
- Avoid swallowing errors silently.
- Log enough context to debug production issues (IDs, env, status), but never secrets.
- In UI code, degrade gracefully for missing capabilities.
### Schema Definitions (Drizzle)
### Data / DB
Use snake_case for field names so column names don't need to be redefined as strings.
- For Drizzle schema, use snake_case fields and columns.
- Keep migration and schema changes minimal and explicit.
- Follow package-specific DB guidance in `packages/opencode/AGENTS.md`.
```ts
// Good
const table = sqliteTable("session", {
id: text().primaryKey(),
project_id: text().notNull(),
created_at: integer().notNull(),
})
### Testing Philosophy
// Bad
const table = sqliteTable("session", {
id: text("id").primaryKey(),
projectID: text("project_id").notNull(),
createdAt: integer("created_at").notNull(),
})
```
- Prefer testing real behavior over mocks.
- Add regression tests for bug fixes where practical.
- Keep fixtures small and focused.
## Testing
## Agent Workflow Tips
- Avoid mocks as much as possible
- Test actual implementation, do not duplicate logic into tests
- Tests cannot run from repo root (guard: `do-not-run-tests-from-root`); run from package dirs like `packages/opencode`.
- Read existing code paths before introducing new abstractions.
- Match local patterns first; do not impose a new style per file.
- If a package has its own `AGENTS.md`, review it before editing.
- For OpenCode Effect services, follow `packages/opencode/AGENTS.md` strictly.
## Type Checking
## Known Operational Notes
- Always run `bun typecheck` from package directories (e.g., `packages/opencode`), never `tsc` directly.
- `packages/app/AGENTS.md` says: never restart app/server processes during that package's debugging workflow.
- `packages/app/AGENTS.md` also documents local backend+web split for UI work.
- `packages/opencode/AGENTS.md` contains mandatory Effect and database conventions.
## Regeneration / Special Scripts
- Regenerate JS SDK with: `./packages/sdk/js/script/build.ts`
## Quick Checklist Before Finishing
- Ran relevant package checks.
- Updated docs/config when behavior changed.
- Avoided committing unrelated files.
- Kept edits minimal and aligned with local conventions.

1674
bun.lock

File diff suppressed because it is too large Load Diff

0
eas.json Normal file
View File

View File

@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-ppK5TVMsmy/7uP1kc6hw3gHMxokD/hBZYt5IGHR3/ok=",
"aarch64-linux": "sha256-lrZjanBS8iHJa5TJJHQ9Gaz+lUqNaTgAUuDd6QHu8No=",
"aarch64-darwin": "sha256-EojkRZQF5NqKPF3Bd/8UIiNngpkBk7uAM8m875bfOUo=",
"x86_64-darwin": "sha256-fEO9Hx8yikkvdGj8nC06fy4u/hTGWO6kjENsU/B2OyY="
"x86_64-linux": "sha256-UuVbB5lTRB4bIcaKMc8CLSbQW7m9EjXgxYvxp/uO7Co=",
"aarch64-linux": "sha256-8D7ReLRVb7NDd5PQTVxFhRLmlLbfjK007XgIhhpNKoE=",
"aarch64-darwin": "sha256-M+z7C/eXfVqwDiGiiwKo/LT/m4dvCjL1Pblsr1kxoyI=",
"x86_64-darwin": "sha256-RzZS6GMwYVDPK0W+K/mlebixNMs2+JRkMG9n8OFhd0c="
}
}

View File

@@ -104,6 +104,7 @@
"protobufjs",
"tree-sitter",
"tree-sitter-bash",
"tree-sitter-powershell",
"web-tree-sitter",
"electron"
],

View File

@@ -0,0 +1,11 @@
PORT=8787
DATABASE_HOST=
DATABASE_USERNAME=
DATABASE_PASSWORD=
DATABASE_NAME=main
APNS_TEAM_ID=
APNS_KEY_ID=
APNS_PRIVATE_KEY=
APNS_DEFAULT_BUNDLE_ID=com.anomalyco.mobilevoice

View File

@@ -0,0 +1,106 @@
# apn-relay Agent Guide
This file defines package-specific guidance for agents working in `packages/apn-relay`.
## Scope And Precedence
- Follow root `AGENTS.md` first.
- This file provides stricter package-level conventions for relay service work.
- If future local guides are added, closest guide wins.
## Project Overview
- Minimal APNs relay service (Hono + Bun + PlanetScale via Drizzle).
- Core routes:
- `GET /health`
- `GET /`
- `POST /v1/device/register`
- `POST /v1/device/unregister`
- `POST /v1/event`
## Commands
Run all commands from `packages/apn-relay`.
- Install deps: `bun install`
- Start relay locally: `bun run dev`
- Typecheck: `bun run typecheck`
- DB connectivity check: `bun run db:check`
## Build / Test Expectations
- There is no dedicated package test script currently.
- Required validation for behavior changes:
- `bun run typecheck`
- `bun run db:check` when DB/env changes are involved
- manual endpoint verification against `/health`, `/v1/device/register`, `/v1/event`
## Single-Test Guidance
- No single-test command exists for this package today.
- For focused checks, run endpoint-level manual tests against a local dev server.
## Code Style Guidelines
### Formatting / Structure
- Keep handlers compact and explicit.
- Prefer small local helpers for repeated route logic.
- Avoid broad refactors when a targeted fix is enough.
### Types / Validation
- Validate request bodies with `zod` at route boundaries.
- Keep payload and DB row shapes explicit and close to usage.
- Avoid `any`; narrow unknown input immediately after parsing.
### Naming
- Follow existing concise naming in this package (`reg`, `unreg`, `evt`, `row`, `key`).
- For DB columns, keep snake_case alignment with schema.
### Error Handling
- Return clear JSON errors for invalid input.
- Keep handler failures observable via `app.onError` and structured logs.
- Do not leak secrets in responses or logs.
### Logging
- Log delivery lifecycle at key checkpoints:
- registration/unregistration attempts
- event fanout start/end
- APNs send failures and retries
- Mask sensitive values; prefer token suffixes and metadata.
### APNs Environment Rules
- Keep APNs env explicit per registration (`sandbox` / `production`).
- For `BadEnvironmentKeyInToken`, retry once with flipped env and persist correction.
- Avoid infinite retry loops; one retry max per delivery attempt.
## Database Conventions
- Schema is in `src/schema.sql.ts`.
- Keep table/column names snake_case.
- Maintain index naming consistency with existing schema.
- For upserts, update only fields required by current behavior.
## API Behavior Expectations
- `register`/`unregister` must be idempotent.
- `event` should return success envelope even when no devices are registered.
- Delivery logs should capture per-attempt result and error payload.
## Operational Notes
- Ensure `APNS_PRIVATE_KEY` supports escaped newline format (`\n`) and raw multiline.
- Validate that `APNS_DEFAULT_BUNDLE_ID` matches mobile app bundle identifier.
- Avoid coupling route behavior to deployment platform specifics.
## Before Finishing
- Run `bun run typecheck`.
- If DB/env behavior changed, run `bun run db:check`.
- Manually exercise affected endpoints.
- Confirm logs are useful and secret-safe.

View File

@@ -0,0 +1,14 @@
FROM oven/bun:1.3.11-alpine
WORKDIR /app
COPY package.json ./
COPY tsconfig.json ./
COPY drizzle.config.ts ./
RUN bun install --production
COPY src ./src
EXPOSE 8787
CMD ["bun", "run", "src/index.ts"]

View File

@@ -0,0 +1,46 @@
# APN Relay
Minimal APNs relay for OpenCode mobile background notifications.
## What it does
- Registers iOS device tokens for a shared secret.
- Receives OpenCode event posts (`complete`, `permission`, `error`).
- Sends APNs notifications to mapped devices.
- Stores delivery rows in PlanetScale.
## Routes
- `GET /health`
- `GET /` (simple dashboard)
- `POST /v1/device/register`
- `POST /v1/device/unregister`
- `POST /v1/event`
## Environment
Use `.env.example` as a starting point.
- `DATABASE_HOST`
- `DATABASE_USERNAME`
- `DATABASE_PASSWORD`
- `APNS_TEAM_ID`
- `APNS_KEY_ID`
- `APNS_PRIVATE_KEY`
- `APNS_DEFAULT_BUNDLE_ID`
## Run locally
```bash
bun install
bun run src/index.ts
```
## Docker
Build from this directory:
```bash
docker build -t apn-relay .
docker run --rm -p 8787:8787 --env-file .env apn-relay
```

View File

@@ -0,0 +1,17 @@
import { defineConfig } from "drizzle-kit"
export default defineConfig({
out: "./migration",
strict: true,
schema: ["./src/**/*.sql.ts"],
dialect: "mysql",
dbCredentials: {
host: process.env.DATABASE_HOST ?? "",
user: process.env.DATABASE_USERNAME ?? "",
password: process.env.DATABASE_PASSWORD ?? "",
database: process.env.DATABASE_NAME ?? "main",
ssl: {
rejectUnauthorized: false,
},
},
})

View File

@@ -0,0 +1,27 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/apn-relay",
"version": "0.0.0",
"private": true,
"type": "module",
"license": "MIT",
"scripts": {
"dev": "bun run src/index.ts",
"db:check": "bun run --env-file .env src/check.ts",
"typecheck": "tsgo --noEmit"
},
"dependencies": {
"@planetscale/database": "1.19.0",
"drizzle-orm": "1.0.0-beta.19-d95b7a4",
"hono": "4.10.7",
"jose": "6.0.11",
"zod": "4.1.8"
},
"devDependencies": {
"@tsconfig/bun": "1.0.9",
"@types/bun": "1.3.11",
"@typescript/native-preview": "7.0.0-dev.20251207.1",
"drizzle-kit": "1.0.0-beta.19-d95b7a4",
"typescript": "5.8.2"
}
}

View File

@@ -0,0 +1,185 @@
import { connect } from "node:http2"
import { SignJWT, importPKCS8 } from "jose"
import { env } from "./env"
export type PushEnv = "sandbox" | "production"
type PushInput = {
token: string
bundle: string
env: PushEnv
title: string
body: string
data: Record<string, unknown>
}
type PushResult = {
ok: boolean
code: number
error?: string
}
function tokenSuffix(input: string) {
return input.length > 8 ? input.slice(-8) : input
}
let jwt = ""
let exp = 0
let pk: Awaited<ReturnType<typeof importPKCS8>> | undefined
function host(input: PushEnv) {
if (input === "sandbox") return "api.sandbox.push.apple.com"
return "api.push.apple.com"
}
function key() {
if (env.APNS_PRIVATE_KEY.includes("\\n")) return env.APNS_PRIVATE_KEY.replace(/\\n/g, "\n")
return env.APNS_PRIVATE_KEY
}
async function sign() {
if (!pk) pk = await importPKCS8(key(), "ES256")
const now = Math.floor(Date.now() / 1000)
if (jwt && now < exp) return jwt
jwt = await new SignJWT({})
.setProtectedHeader({ alg: "ES256", kid: env.APNS_KEY_ID })
.setIssuer(env.APNS_TEAM_ID)
.setIssuedAt(now)
.sign(pk)
exp = now + 50 * 60
return jwt
}
function post(input: {
host: string
token: string
auth: string
bundle: string
payload: string
}): Promise<{ code: number; body: string }> {
return new Promise((resolve, reject) => {
const cli = connect(`https://${input.host}`)
let done = false
let code = 0
let body = ""
const stop = (fn: () => void) => {
if (done) return
done = true
fn()
}
cli.on("error", (err) => {
stop(() => reject(err))
cli.close()
})
const req = cli.request({
":method": "POST",
":path": `/3/device/${input.token}`,
authorization: `bearer ${input.auth}`,
"apns-topic": input.bundle,
"apns-push-type": "alert",
"apns-priority": "10",
"content-type": "application/json",
})
req.setEncoding("utf8")
req.on("response", (headers) => {
code = Number(headers[":status"] ?? 0)
})
req.on("data", (chunk) => {
body += chunk
})
req.on("end", () => {
stop(() => resolve({ code, body }))
cli.close()
})
req.on("error", (err) => {
stop(() => reject(err))
cli.close()
})
req.end(input.payload)
})
}
export async function send(input: PushInput): Promise<PushResult> {
const apnsHost = host(input.env)
const suffix = tokenSuffix(input.token)
console.log("[ APN RELAY ] push:start", {
env: input.env,
host: apnsHost,
bundle: input.bundle,
tokenSuffix: suffix,
})
const auth = await sign().catch((err) => {
return `error:${String(err)}`
})
if (auth.startsWith("error:")) {
console.log("[ APN RELAY ] push:auth-failed", {
env: input.env,
host: apnsHost,
bundle: input.bundle,
tokenSuffix: suffix,
error: auth,
})
return {
ok: false,
code: 0,
error: auth,
}
}
const payload = JSON.stringify({
aps: {
alert: {
title: input.title,
body: input.body,
},
sound: "alert.wav",
},
...input.data,
})
const out = await post({
host: apnsHost,
token: input.token,
auth,
bundle: input.bundle,
payload,
}).catch((err) => ({
code: 0,
body: String(err),
}))
if (out.code === 200) {
console.log("[ APN RELAY ] push:sent", {
env: input.env,
host: apnsHost,
bundle: input.bundle,
tokenSuffix: suffix,
code: out.code,
})
return {
ok: true,
code: 200,
}
}
console.log("[ APN RELAY ] push:failed", {
env: input.env,
host: apnsHost,
bundle: input.bundle,
tokenSuffix: suffix,
code: out.code,
error: out.body,
})
return {
ok: false,
code: out.code,
error: out.body,
}
}

View File

@@ -0,0 +1,28 @@
import { sql } from "drizzle-orm"
import { db } from "./db"
import { env } from "./env"
import { delivery_log, device_registration } from "./schema.sql"
import { setup } from "./setup"
async function run() {
console.log(`[apn-relay] DB host: ${env.DATABASE_HOST}`)
await db.execute(sql`SELECT 1`)
console.log("[apn-relay] DB connection OK")
await setup()
console.log("[apn-relay] Setup migration OK")
const [a] = await db.select({ value: sql<number>`count(*)` }).from(device_registration)
const [b] = await db.select({ value: sql<number>`count(*)` }).from(delivery_log)
console.log(`[apn-relay] device_registration rows: ${Number(a?.value ?? 0)}`)
console.log(`[apn-relay] delivery_log rows: ${Number(b?.value ?? 0)}`)
console.log("[apn-relay] DB check passed")
}
run().catch((err) => {
console.error("[apn-relay] DB check failed")
console.error(err)
process.exit(1)
})

View File

@@ -0,0 +1,11 @@
import { Client } from "@planetscale/database"
import { drizzle } from "drizzle-orm/planetscale-serverless"
import { env } from "./env"
const client = new Client({
host: env.DATABASE_HOST,
username: env.DATABASE_USERNAME,
password: env.DATABASE_PASSWORD,
})
export const db = drizzle({ client })

View File

@@ -0,0 +1,47 @@
import { z } from "zod"
const bad = new Set(["undefined", "null"])
const txt = z
.string()
.transform((input) => input.trim())
.refine((input) => input.length > 0 && !bad.has(input.toLowerCase()))
const schema = z.object({
PORT: z.coerce.number().int().positive().default(8787),
DATABASE_HOST: txt,
DATABASE_USERNAME: txt,
DATABASE_PASSWORD: txt,
APNS_TEAM_ID: txt,
APNS_KEY_ID: txt,
APNS_PRIVATE_KEY: txt,
APNS_DEFAULT_BUNDLE_ID: txt,
})
const req = [
"DATABASE_HOST",
"DATABASE_USERNAME",
"DATABASE_PASSWORD",
"APNS_TEAM_ID",
"APNS_KEY_ID",
"APNS_PRIVATE_KEY",
"APNS_DEFAULT_BUNDLE_ID",
] as const
const out = schema.safeParse(process.env)
if (!out.success) {
const miss = req.filter((key) => !process.env[key]?.trim())
const bad = out.error.issues
.map((item) => item.path[0])
.filter((key): key is string => typeof key === "string")
.filter((key) => !miss.includes(key as (typeof req)[number]))
console.error("[apn-relay] Invalid startup configuration")
if (miss.length) console.error(`[apn-relay] Missing required env vars: ${miss.join(", ")}`)
if (bad.length) console.error(`[apn-relay] Invalid env vars: ${Array.from(new Set(bad)).join(", ")}`)
console.error("[apn-relay] Check .env.example and restart")
throw new Error("Startup configuration invalid")
}
export const env = out.data

View File

@@ -0,0 +1,5 @@
import { createHash } from "node:crypto"
export function hash(input: string) {
return createHash("sha256").update(input).digest("hex")
}

View File

@@ -0,0 +1,448 @@
import { randomUUID } from "node:crypto"
import { and, desc, eq, sql } from "drizzle-orm"
import { Hono } from "hono"
import { z } from "zod"
import { send } from "./apns"
import { db } from "./db"
import { env } from "./env"
import { hash } from "./hash"
import { delivery_log, device_registration } from "./schema.sql"
import { setup } from "./setup"
function bad(input?: string) {
if (!input) return false
return input.includes("BadEnvironmentKeyInToken")
}
function flip(input: "sandbox" | "production") {
if (input === "sandbox") return "production"
return "sandbox"
}
function tail(input: string) {
return input.slice(-8)
}
function esc(input: unknown) {
return String(input ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;")
}
function fmt(input: number) {
return new Date(input).toISOString()
}
const reg = z.object({
secret: z.string().min(1),
deviceToken: z.string().min(1),
bundleId: z.string().min(1).optional(),
apnsEnv: z.enum(["sandbox", "production"]).default("production"),
})
const unreg = z.object({
secret: z.string().min(1),
deviceToken: z.string().min(1),
})
const evt = z.object({
secret: z.string().min(1),
serverID: z.string().min(1).optional(),
eventType: z.enum(["complete", "permission", "error"]),
sessionID: z.string().min(1),
title: z.string().min(1).optional(),
body: z.string().min(1).optional(),
})
function title(input: z.infer<typeof evt>["eventType"]) {
if (input === "complete") return "Session complete"
if (input === "permission") return "Action needed"
return "Session error"
}
function body(input: z.infer<typeof evt>["eventType"]) {
if (input === "complete") return "OpenCode finished your session."
if (input === "permission") return "OpenCode needs your permission decision."
return "OpenCode reported an error for your session."
}
const app = new Hono()
app.onError((err, c) => {
return c.json(
{
ok: false,
error: err.message,
},
500,
)
})
app.notFound((c) => {
return c.json(
{
ok: false,
error: "Not found",
},
404,
)
})
app.get("/health", async (c) => {
const [a] = await db.select({ value: sql<number>`count(*)` }).from(device_registration)
const [b] = await db.select({ value: sql<number>`count(*)` }).from(delivery_log)
return c.json({
ok: true,
devices: Number(a?.value ?? 0),
deliveries: Number(b?.value ?? 0),
})
})
app.get("/", async (c) => {
const [a] = await db.select({ value: sql<number>`count(*)` }).from(device_registration)
const [b] = await db.select({ value: sql<number>`count(*)` }).from(delivery_log)
const devices = await db.select().from(device_registration).orderBy(desc(device_registration.updated_at)).limit(100)
const byBundle = await db
.select({
bundle: device_registration.bundle_id,
env: device_registration.apns_env,
value: sql<number>`count(*)`,
})
.from(device_registration)
.groupBy(device_registration.bundle_id, device_registration.apns_env)
.orderBy(desc(sql<number>`count(*)`))
const rows = await db.select().from(delivery_log).orderBy(desc(delivery_log.created_at)).limit(20)
const html = `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>APN Relay</title>
<style>
body { font-family: ui-sans-serif, system-ui, sans-serif; margin: 24px; color: #111827; }
h1 { margin: 0 0 12px 0; }
h2 { margin: 22px 0 10px 0; font-size: 16px; }
.stats { display: flex; gap: 16px; margin: 0 0 18px 0; }
.card { border: 1px solid #e5e7eb; border-radius: 8px; padding: 10px 12px; min-width: 160px; }
.muted { color: #6b7280; font-size: 12px; }
.small { font-size: 11px; color: #6b7280; }
table { border-collapse: collapse; width: 100%; }
th, td { border: 1px solid #e5e7eb; text-align: left; padding: 8px; font-size: 12px; }
th { background: #f9fafb; }
</style>
</head>
<body>
<h1>APN Relay</h1>
<p class="muted">MVP dashboard</p>
<div class="stats">
<div class="card">
<div class="muted">Registered devices</div>
<div>${Number(a?.value ?? 0)}</div>
</div>
<div class="card">
<div class="muted">Delivery log rows</div>
<div>${Number(b?.value ?? 0)}</div>
</div>
</div>
<h2>Registered devices</h2>
<p class="small">Most recent 100 registrations. Token values are masked to suffix only.</p>
<table>
<thead>
<tr>
<th>updated</th>
<th>created</th>
<th>token suffix</th>
<th>env</th>
<th>bundle</th>
<th>secret hash</th>
</tr>
</thead>
<tbody>
${
devices.length
? devices
.map(
(row) => `<tr>
<td>${esc(fmt(row.updated_at))}</td>
<td>${esc(fmt(row.created_at))}</td>
<td>${esc(tail(row.device_token))}</td>
<td>${esc(row.apns_env)}</td>
<td>${esc(row.bundle_id)}</td>
<td>${esc(`${row.secret_hash.slice(0, 12)}`)}</td>
</tr>`,
)
.join("")
: `<tr><td colspan="6" class="muted">No devices registered.</td></tr>`
}
</tbody>
</table>
<h2>Bundle breakdown</h2>
<table>
<thead>
<tr>
<th>bundle</th>
<th>env</th>
<th>count</th>
</tr>
</thead>
<tbody>
${
byBundle.length
? byBundle
.map(
(row) => `<tr>
<td>${esc(row.bundle)}</td>
<td>${esc(row.env)}</td>
<td>${esc(Number(row.value ?? 0))}</td>
</tr>`,
)
.join("")
: `<tr><td colspan="3" class="muted">No device data.</td></tr>`
}
</tbody>
</table>
<h2>Recent deliveries</h2>
<table>
<thead>
<tr>
<th>time</th>
<th>event</th>
<th>session</th>
<th>status</th>
<th>error</th>
</tr>
</thead>
<tbody>
${rows
.map(
(row) => `<tr>
<td>${esc(fmt(row.created_at))}</td>
<td>${esc(row.event_type)}</td>
<td>${esc(row.session_id)}</td>
<td>${esc(row.status)}</td>
<td>${esc(row.error ?? "")}</td>
</tr>`,
)
.join("")}
</tbody>
</table>
</body>
</html>`
return c.html(html)
})
app.post("/v1/device/register", async (c) => {
const raw = await c.req.json().catch(() => undefined)
const check = reg.safeParse(raw)
if (!check.success) {
return c.json(
{
ok: false,
error: "Invalid request body",
},
400,
)
}
const now = Date.now()
const key = hash(check.data.secret)
const row = {
id: randomUUID(),
secret_hash: key,
device_token: check.data.deviceToken,
bundle_id: check.data.bundleId ?? env.APNS_DEFAULT_BUNDLE_ID,
apns_env: check.data.apnsEnv,
created_at: now,
updated_at: now,
}
console.log("[relay] register", {
token: tail(row.device_token),
env: row.apns_env,
bundle: row.bundle_id,
secretHash: `${key.slice(0, 12)}...`,
})
await db
.insert(device_registration)
.values(row)
.onDuplicateKeyUpdate({
set: {
bundle_id: row.bundle_id,
apns_env: row.apns_env,
updated_at: now,
},
})
return c.json({ ok: true })
})
app.post("/v1/device/unregister", async (c) => {
const raw = await c.req.json().catch(() => undefined)
const check = unreg.safeParse(raw)
if (!check.success) {
return c.json(
{
ok: false,
error: "Invalid request body",
},
400,
)
}
const key = hash(check.data.secret)
console.log("[relay] unregister", {
token: tail(check.data.deviceToken),
secretHash: `${key.slice(0, 12)}...`,
})
await db
.delete(device_registration)
.where(and(eq(device_registration.secret_hash, key), eq(device_registration.device_token, check.data.deviceToken)))
return c.json({ ok: true })
})
app.post("/v1/event", async (c) => {
const raw = await c.req.json().catch(() => undefined)
const check = evt.safeParse(raw)
if (!check.success) {
return c.json(
{
ok: false,
error: "Invalid request body",
},
400,
)
}
const key = hash(check.data.secret)
const list = await db.select().from(device_registration).where(eq(device_registration.secret_hash, key))
console.log("[relay] event", {
type: check.data.eventType,
serverID: check.data.serverID,
session: check.data.sessionID,
secretHash: `${key.slice(0, 12)}...`,
devices: list.length,
})
if (!list.length) {
const [total] = await db.select({ value: sql<number>`count(*)` }).from(device_registration)
console.log("[relay] event:no-matching-devices", {
type: check.data.eventType,
serverID: check.data.serverID,
session: check.data.sessionID,
secretHash: `${key.slice(0, 12)}...`,
totalDevices: Number(total?.value ?? 0),
})
return c.json({
ok: true,
sent: 0,
failed: 0,
})
}
const out = await Promise.all(
list.map(async (row) => {
const env = row.apns_env === "sandbox" ? "sandbox" : "production"
const payload = {
token: row.device_token,
bundle: row.bundle_id,
title: check.data.title ?? title(check.data.eventType),
body: check.data.body ?? body(check.data.eventType),
data: {
serverID: check.data.serverID,
eventType: check.data.eventType,
sessionID: check.data.sessionID,
},
}
const first = await send({ ...payload, env })
if (first.ok || !bad(first.error)) {
if (!first.ok) {
console.log("[relay] send:error", {
token: tail(row.device_token),
env,
error: first.error,
})
}
return first
}
const alt = flip(env)
console.log("[relay] send:retry-env", {
token: tail(row.device_token),
from: env,
to: alt,
})
const second = await send({ ...payload, env: alt })
if (!second.ok) {
console.log("[relay] send:error", {
token: tail(row.device_token),
env: alt,
error: second.error,
})
return second
}
await db
.update(device_registration)
.set({ apns_env: alt, updated_at: Date.now() })
.where(
and(
eq(device_registration.secret_hash, row.secret_hash),
eq(device_registration.device_token, row.device_token),
),
)
console.log("[relay] send:env-updated", {
token: tail(row.device_token),
env: alt,
})
return second
}),
)
const now = Date.now()
await db.insert(delivery_log).values(
out.map((item) => ({
id: randomUUID(),
secret_hash: key,
event_type: check.data.eventType,
session_id: check.data.sessionID,
status: item.ok ? "sent" : "failed",
error: item.error,
created_at: now,
})),
)
const sent = out.filter((item) => item.ok).length
console.log("[relay] event:done", {
type: check.data.eventType,
session: check.data.sessionID,
sent,
failed: out.length - sent,
})
return c.json({
ok: true,
sent,
failed: out.length - sent,
})
})
await setup()
if (import.meta.main) {
Bun.serve({
port: env.PORT,
fetch: app.fetch,
})
console.log(`apn-relay listening on http://0.0.0.0:${env.PORT}`)
}
export { app }

View File

@@ -0,0 +1,35 @@
import { bigint, index, mysqlTable, uniqueIndex, varchar } from "drizzle-orm/mysql-core"
export const device_registration = mysqlTable(
"device_registration",
{
id: varchar("id", { length: 36 }).primaryKey(),
secret_hash: varchar("secret_hash", { length: 64 }).notNull(),
device_token: varchar("device_token", { length: 255 }).notNull(),
bundle_id: varchar("bundle_id", { length: 255 }).notNull(),
apns_env: varchar("apns_env", { length: 16 }).notNull().default("production"),
created_at: bigint("created_at", { mode: "number" }).notNull(),
updated_at: bigint("updated_at", { mode: "number" }).notNull(),
},
(table) => [
uniqueIndex("device_registration_secret_token_idx").on(table.secret_hash, table.device_token),
index("device_registration_secret_hash_idx").on(table.secret_hash),
],
)
export const delivery_log = mysqlTable(
"delivery_log",
{
id: varchar("id", { length: 36 }).primaryKey(),
secret_hash: varchar("secret_hash", { length: 64 }).notNull(),
event_type: varchar("event_type", { length: 32 }).notNull(),
session_id: varchar("session_id", { length: 255 }).notNull(),
status: varchar("status", { length: 16 }).notNull(),
error: varchar("error", { length: 1024 }),
created_at: bigint("created_at", { mode: "number" }).notNull(),
},
(table) => [
index("delivery_log_secret_hash_idx").on(table.secret_hash),
index("delivery_log_created_at_idx").on(table.created_at),
],
)

View File

@@ -0,0 +1,34 @@
import { sql } from "drizzle-orm"
import { db } from "./db"
export async function setup() {
await db.execute(sql`
CREATE TABLE IF NOT EXISTS device_registration (
id varchar(36) NOT NULL,
secret_hash varchar(64) NOT NULL,
device_token varchar(255) NOT NULL,
bundle_id varchar(255) NOT NULL,
apns_env varchar(16) NOT NULL DEFAULT 'production',
created_at bigint NOT NULL,
updated_at bigint NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY device_registration_secret_token_idx (secret_hash, device_token),
KEY device_registration_secret_hash_idx (secret_hash)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`)
await db.execute(sql`
CREATE TABLE IF NOT EXISTS delivery_log (
id varchar(36) NOT NULL,
secret_hash varchar(64) NOT NULL,
event_type varchar(32) NOT NULL,
session_id varchar(255) NOT NULL,
status varchar(16) NOT NULL,
error varchar(1024) NULL,
created_at bigint NOT NULL,
PRIMARY KEY (id),
KEY delivery_log_secret_hash_idx (secret_hash),
KEY delivery_log_created_at_idx (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`)
}

View File

@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@tsconfig/bun/tsconfig.json",
"compilerOptions": {
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"noUncheckedIndexedAccess": false
}
}

View File

@@ -15,6 +15,16 @@ import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils"
export const settingsKey = "settings.v3"
const seedModel = (() => {
const [providerID = "opencode", modelID = "big-pickle"] = (
process.env.OPENCODE_E2E_MODEL ?? "opencode/big-pickle"
).split("/")
return {
providerID: providerID || "opencode",
modelID: modelID || "big-pickle",
}
})()
type TestFixtures = {
sdk: ReturnType<typeof createSdk>
gotoSession: (sessionID?: string) => Promise<void>
@@ -125,7 +135,7 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
async function seedStorage(page: Page, input: { directory: string; extra?: string[] }) {
await seedProjects(page, input)
await page.addInitScript(() => {
await page.addInitScript((model: { providerID: string; modelID: string }) => {
const win = window as E2EWindow
win.__opencode_e2e = {
...win.__opencode_e2e,
@@ -143,12 +153,12 @@ async function seedStorage(page: Page, input: { directory: string; extra?: strin
localStorage.setItem(
"opencode.global.dat:model",
JSON.stringify({
recent: [{ providerID: "opencode", modelID: "big-pickle" }],
recent: [model],
user: [],
variant: {},
}),
)
})
}, seedModel)
}
export { expect }

View File

@@ -234,6 +234,7 @@ async function fileOverflow(page: Parameters<typeof test>[0]["page"]) {
}
test("review applies inline comment clicks without horizontal overflow", async ({ page, withProject }) => {
test.skip(true, "Flaky in CI for now.")
test.setTimeout(180_000)
const tag = `review-comment-${Date.now()}`
@@ -283,6 +284,7 @@ test("review applies inline comment clicks without horizontal overflow", async (
})
test("review file comments submit on click without clipping actions", async ({ page, withProject }) => {
test.skip(true, "Flaky in CI for now.")
test.setTimeout(180_000)
const tag = `review-file-comment-${Date.now()}`

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.3.5",
"version": "1.3.10",
"description": "",
"type": "module",
"exports": {

View File

@@ -71,7 +71,7 @@ const serverEnv = {
OPENCODE_E2E_PROJECT_DIR: repoDir,
OPENCODE_E2E_SESSION_TITLE: "E2E Session",
OPENCODE_E2E_MESSAGE: "Seeded for UI e2e",
OPENCODE_E2E_MODEL: "opencode/gpt-5-nano",
OPENCODE_E2E_MODEL: process.env.OPENCODE_E2E_MODEL ?? "opencode/gpt-5-nano",
OPENCODE_CLIENT: "app",
OPENCODE_STRICT_CONFIG_DEPS: "true",
} satisfies Record<string, string>

View File

@@ -624,17 +624,18 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (!cmd) return
promptProbe.select(cmd.id)
closePopover()
const images = imageAttachments()
if (cmd.type === "custom") {
const text = `/${cmd.trigger} `
setEditorText(text)
prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length)
prompt.set([{ type: "text", content: text, start: 0, end: text.length }, ...images], text.length)
focusEditorEnd()
return
}
clearEditor()
prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0)
prompt.set([...DEFAULT_PROMPT, ...images], 0)
command.trigger(cmd.id, "slash")
}

View File

@@ -11,6 +11,47 @@ import { useSDK } from "@/context/sdk"
const cache = new Map<string, { tab: number; answers: QuestionAnswer[]; custom: string[]; customOn: boolean[] }>()
function Mark(props: { multi: boolean; picked: boolean; onClick?: (event: MouseEvent) => void }) {
return (
<span data-slot="question-option-check" aria-hidden="true" onClick={props.onClick}>
<span data-slot="question-option-box" data-type={props.multi ? "checkbox" : "radio"} data-picked={props.picked}>
<Show when={props.multi} fallback={<span data-slot="question-option-radio-dot" />}>
<Icon name="check-small" size="small" />
</Show>
</span>
</span>
)
}
function Option(props: {
multi: boolean
picked: boolean
label: string
description?: string
disabled: boolean
onClick: VoidFunction
}) {
return (
<button
type="button"
data-slot="question-option"
data-picked={props.picked}
role={props.multi ? "checkbox" : "radio"}
aria-checked={props.picked}
disabled={props.disabled}
onClick={props.onClick}
>
<Mark multi={props.multi} picked={props.picked} />
<span data-slot="question-option-main">
<span data-slot="option-label">{props.label}</span>
<Show when={props.description}>
<span data-slot="option-description">{props.description}</span>
</Show>
</span>
</button>
)
}
export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit: () => void }> = (props) => {
const sdk = useSDK()
const language = useLanguage()
@@ -41,6 +82,9 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
return language.t("session.question.progress", { current: n, total: total() })
})
const customLabel = () => language.t("ui.messagePart.option.typeOwnAnswer")
const customPlaceholder = () => language.t("ui.question.custom.placeholder")
const last = createMemo(() => store.tab >= total() - 1)
const customUpdate = (value: string, selected: boolean = on()) => {
@@ -164,6 +208,13 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
const submit = () => void reply(questions().map((_, i) => store.answers[i] ?? []))
const answered = (i: number) => {
if ((store.answers[i]?.length ?? 0) > 0) return true
return store.customOn[i] === true && (store.custom[i] ?? "").trim().length > 0
}
const picked = (answer: string) => store.answers[store.tab]?.includes(answer) ?? false
const pick = (answer: string, custom: boolean = false) => {
setStore("answers", store.tab, [answer])
if (custom) setStore("custom", store.tab, answer)
@@ -230,6 +281,24 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
customUpdate(input())
}
const resizeInput = (el: HTMLTextAreaElement) => {
el.style.height = "0px"
el.style.height = `${el.scrollHeight}px`
}
const focusCustom = (el: HTMLTextAreaElement) => {
setTimeout(() => {
el.focus()
resizeInput(el)
}, 0)
}
const toggleCustomMark = (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
customToggle()
}
const next = () => {
if (sending()) return
if (store.editing) commitCustom()
@@ -270,10 +339,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
type="button"
data-slot="question-progress-segment"
data-active={i() === store.tab}
data-answered={
(store.answers[i()]?.length ?? 0) > 0 ||
(store.customOn[i()] === true && (store.custom[i()] ?? "").trim().length > 0)
}
data-answered={answered(i())}
disabled={sending()}
onClick={() => jump(i())}
aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`}
@@ -307,43 +373,23 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
</Show>
<div data-slot="question-options">
<For each={options()}>
{(opt, i) => {
const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false
return (
<button
data-slot="question-option"
data-picked={picked()}
role={multi() ? "checkbox" : "radio"}
aria-checked={picked()}
disabled={sending()}
onClick={() => selectOption(i())}
>
<span data-slot="question-option-check" aria-hidden="true">
<span
data-slot="question-option-box"
data-type={multi() ? "checkbox" : "radio"}
data-picked={picked()}
>
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
<Icon name="check-small" size="small" />
</Show>
</span>
</span>
<span data-slot="question-option-main">
<span data-slot="option-label">{opt.label}</span>
<Show when={opt.description}>
<span data-slot="option-description">{opt.description}</span>
</Show>
</span>
</button>
)
}}
{(opt, i) => (
<Option
multi={multi()}
picked={picked(opt.label)}
label={opt.label}
description={opt.description}
disabled={sending()}
onClick={() => selectOption(i())}
/>
)}
</For>
<Show
when={store.editing}
fallback={
<button
type="button"
data-slot="question-option"
data-custom="true"
data-picked={on()}
@@ -352,24 +398,10 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
disabled={sending()}
onClick={customOpen}
>
<span
data-slot="question-option-check"
aria-hidden="true"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
customToggle()
}}
>
<span data-slot="question-option-box" data-type={multi() ? "checkbox" : "radio"} data-picked={on()}>
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
<Icon name="check-small" size="small" />
</Show>
</span>
</span>
<Mark multi={multi()} picked={on()} onClick={toggleCustomMark} />
<span data-slot="question-option-main">
<span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
<span data-slot="option-description">{input() || language.t("ui.question.custom.placeholder")}</span>
<span data-slot="option-label">{customLabel()}</span>
<span data-slot="option-description">{input() || customPlaceholder()}</span>
</span>
</button>
}
@@ -394,33 +426,13 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
commitCustom()
}}
>
<span
data-slot="question-option-check"
aria-hidden="true"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
customToggle()
}}
>
<span data-slot="question-option-box" data-type={multi() ? "checkbox" : "radio"} data-picked={on()}>
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
<Icon name="check-small" size="small" />
</Show>
</span>
</span>
<Mark multi={multi()} picked={on()} onClick={toggleCustomMark} />
<span data-slot="question-option-main">
<span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
<span data-slot="option-label">{customLabel()}</span>
<textarea
ref={(el) =>
setTimeout(() => {
el.focus()
el.style.height = "0px"
el.style.height = `${el.scrollHeight}px`
}, 0)
}
ref={focusCustom}
data-slot="question-custom-input"
placeholder={language.t("ui.question.custom.placeholder")}
placeholder={customPlaceholder()}
value={input()}
rows={1}
disabled={sending()}
@@ -436,8 +448,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
}}
onInput={(e) => {
customUpdate(e.currentTarget.value)
e.currentTarget.style.height = "0px"
e.currentTarget.style.height = `${e.currentTarget.scrollHeight}px`
resizeInput(e.currentTarget)
}}
/>
</span>

View File

@@ -52,6 +52,132 @@ function FileCommentMenu(props: {
)
}
type ScrollPos = { x: number; y: number }
function createScrollSync(input: { tab: () => string; view: ReturnType<typeof useSessionLayout>["view"] }) {
let scroll: HTMLDivElement | undefined
let scrollFrame: number | undefined
let restoreFrame: number | undefined
let pending: ScrollPos | undefined
let code: HTMLElement[] = []
const getCode = () => {
const el = scroll
if (!el) return []
const host = el.querySelector("diffs-container")
if (!(host instanceof HTMLElement)) return []
const root = host.shadowRoot
if (!root) return []
return Array.from(root.querySelectorAll("[data-code]")).filter(
(node): node is HTMLElement => node instanceof HTMLElement && node.clientWidth > 0,
)
}
const save = (next: ScrollPos) => {
pending = next
if (scrollFrame !== undefined) return
scrollFrame = requestAnimationFrame(() => {
scrollFrame = undefined
const out = pending
pending = undefined
if (!out) return
input.view().setScroll(input.tab(), out)
})
}
const onCodeScroll = (event: Event) => {
const el = scroll
if (!el) return
const target = event.currentTarget
if (!(target instanceof HTMLElement)) return
save({
x: target.scrollLeft,
y: el.scrollTop,
})
}
const sync = () => {
const next = getCode()
if (next.length === code.length && next.every((el, i) => el === code[i])) return
for (const item of code) {
item.removeEventListener("scroll", onCodeScroll)
}
code = next
for (const item of code) {
item.addEventListener("scroll", onCodeScroll)
}
}
const restore = () => {
const el = scroll
if (!el) return
const pos = input.view().scroll(input.tab())
if (!pos) return
sync()
if (code.length > 0) {
for (const item of code) {
if (item.scrollLeft !== pos.x) item.scrollLeft = pos.x
}
}
if (el.scrollTop !== pos.y) el.scrollTop = pos.y
if (code.length > 0) return
if (el.scrollLeft !== pos.x) el.scrollLeft = pos.x
}
const queueRestore = () => {
if (restoreFrame !== undefined) return
restoreFrame = requestAnimationFrame(() => {
restoreFrame = undefined
restore()
})
}
const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
if (code.length === 0) sync()
save({
x: code[0]?.scrollLeft ?? event.currentTarget.scrollLeft,
y: event.currentTarget.scrollTop,
})
}
const setViewport = (el: HTMLDivElement) => {
scroll = el
restore()
}
onCleanup(() => {
for (const item of code) {
item.removeEventListener("scroll", onCodeScroll)
}
if (scrollFrame !== undefined) cancelAnimationFrame(scrollFrame)
if (restoreFrame !== undefined) cancelAnimationFrame(restoreFrame)
})
return {
handleScroll,
queueRestore,
setViewport,
}
}
export function FileTabContent(props: { tab: string }) {
const file = useFile()
const comments = useComments()
@@ -65,11 +191,6 @@ export function FileTabContent(props: { tab: string }) {
normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
}).activeFileTab
let scroll: HTMLDivElement | undefined
let scrollFrame: number | undefined
let restoreFrame: number | undefined
let pending: { x: number; y: number } | undefined
let codeScroll: HTMLElement[] = []
let find: FileSearchHandle | null = null
const search = {
@@ -92,6 +213,10 @@ export function FileTabContent(props: { tab: string }) {
if (file.ready()) return (file.selectedLines(p) as SelectedLineRange | undefined) ?? null
return (getSessionHandoff(sessionKey())?.files[p] as SelectedLineRange | undefined) ?? null
})
const scrollSync = createScrollSync({
tab: () => props.tab,
view,
})
const selectionPreview = (source: string, selection: FileSelection) => {
return previewSelectedLines(source, {
@@ -100,6 +225,12 @@ export function FileTabContent(props: { tab: string }) {
})
}
const buildPreview = (filePath: string, selection: FileSelection) => {
const source = filePath === path() ? contents() : file.get(filePath)?.content?.content
if (!source) return undefined
return selectionPreview(source, selection)
}
const addCommentToContext = (input: {
file: string
selection: SelectedLineRange
@@ -108,14 +239,7 @@ export function FileTabContent(props: { tab: string }) {
origin?: "review" | "file"
}) => {
const selection = selectionFromLines(input.selection)
const preview =
input.preview ??
(() => {
if (input.file === path()) return selectionPreview(contents(), selection)
const source = file.get(input.file)?.content?.content
if (!source) return undefined
return selectionPreview(source, selection)
})()
const preview = input.preview ?? buildPreview(input.file, selection)
const saved = comments.add({
file: input.file,
@@ -140,8 +264,7 @@ export function FileTabContent(props: { tab: string }) {
comment: string
}) => {
comments.update(input.file, input.id, input.comment)
const preview =
input.file === path() ? selectionPreview(contents(), selectionFromLines(input.selection)) : undefined
const preview = input.file === path() ? buildPreview(input.file, selectionFromLines(input.selection)) : undefined
prompt.context.updateComment(input.file, input.id, {
comment: input.comment,
...(preview ? { preview } : {}),
@@ -260,102 +383,6 @@ export function FileTabContent(props: { tab: string }) {
requestAnimationFrame(() => comments.clearFocus())
})
const getCodeScroll = () => {
const el = scroll
if (!el) return []
const host = el.querySelector("diffs-container")
if (!(host instanceof HTMLElement)) return []
const root = host.shadowRoot
if (!root) return []
return Array.from(root.querySelectorAll("[data-code]")).filter(
(node): node is HTMLElement => node instanceof HTMLElement && node.clientWidth > 0,
)
}
const queueScrollUpdate = (next: { x: number; y: number }) => {
pending = next
if (scrollFrame !== undefined) return
scrollFrame = requestAnimationFrame(() => {
scrollFrame = undefined
const out = pending
pending = undefined
if (!out) return
view().setScroll(props.tab, out)
})
}
const handleCodeScroll = (event: Event) => {
const el = scroll
if (!el) return
const target = event.currentTarget
if (!(target instanceof HTMLElement)) return
queueScrollUpdate({
x: target.scrollLeft,
y: el.scrollTop,
})
}
const syncCodeScroll = () => {
const next = getCodeScroll()
if (next.length === codeScroll.length && next.every((el, i) => el === codeScroll[i])) return
for (const item of codeScroll) {
item.removeEventListener("scroll", handleCodeScroll)
}
codeScroll = next
for (const item of codeScroll) {
item.addEventListener("scroll", handleCodeScroll)
}
}
const restoreScroll = () => {
const el = scroll
if (!el) return
const s = view().scroll(props.tab)
if (!s) return
syncCodeScroll()
if (codeScroll.length > 0) {
for (const item of codeScroll) {
if (item.scrollLeft !== s.x) item.scrollLeft = s.x
}
}
if (el.scrollTop !== s.y) el.scrollTop = s.y
if (codeScroll.length > 0) return
if (el.scrollLeft !== s.x) el.scrollLeft = s.x
}
const queueRestore = () => {
if (restoreFrame !== undefined) return
restoreFrame = requestAnimationFrame(() => {
restoreFrame = undefined
restoreScroll()
})
}
const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
if (codeScroll.length === 0) syncCodeScroll()
queueScrollUpdate({
x: codeScroll[0]?.scrollLeft ?? event.currentTarget.scrollLeft,
y: event.currentTarget.scrollTop,
})
}
const cancelCommenting = () => {
const p = path()
if (p) file.setSelectedLines(p, null)
@@ -375,16 +402,7 @@ export function FileTabContent(props: { tab: string }) {
const restore = (loaded && !prev.loaded) || (ready && !prev.ready) || (active && loaded && !prev.active)
prev = { loaded, ready, active }
if (!restore) return
queueRestore()
})
onCleanup(() => {
for (const item of codeScroll) {
item.removeEventListener("scroll", handleCodeScroll)
}
if (scrollFrame !== undefined) cancelAnimationFrame(scrollFrame)
if (restoreFrame !== undefined) cancelAnimationFrame(restoreFrame)
scrollSync.queueRestore()
})
const renderFile = (source: string) => (
@@ -402,7 +420,7 @@ export function FileTabContent(props: { tab: string }) {
selectedLines={activeSelection()}
commentedLines={commentedLines()}
onRendered={() => {
queueRestore()
scrollSync.queueRestore()
}}
annotations={commentsUi.annotations()}
renderAnnotation={commentsUi.renderAnnotation}
@@ -420,7 +438,7 @@ export function FileTabContent(props: { tab: string }) {
mode: "auto",
path: path(),
current: state()?.content,
onLoad: queueRestore,
onLoad: scrollSync.queueRestore,
onError: (args: { kind: "image" | "audio" | "svg" }) => {
if (args.kind !== "svg") return
showToast({
@@ -435,14 +453,7 @@ export function FileTabContent(props: { tab: string }) {
return (
<Tabs.Content value={props.tab} class="mt-3 relative h-full">
<ScrollView
class="h-full"
viewportRef={(el: HTMLDivElement) => {
scroll = el
restoreScroll()
}}
onScroll={handleScroll as any}
>
<ScrollView class="h-full" viewportRef={scrollSync.setViewport} onScroll={scrollSync.handleScroll as any}>
<Switch>
<Match when={state()?.loaded}>{renderFile(contents())}</Match>
<Match when={state()?.loading}>

View File

@@ -128,380 +128,452 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
if (sessionID) return permission.isAutoAccepting(sessionID, sdk.directory)
return permission.isAutoAcceptingDirectory(sdk.directory)
}
command.register("session", () => {
const share =
sync.data.config.share === "disabled"
? []
: [
sessionCommand({
id: "session.share",
title: info()?.share?.url
? language.t("session.share.copy.copyLink")
: language.t("command.session.share"),
description: info()?.share?.url
? language.t("toast.session.share.success.description")
: language.t("command.session.share.description"),
slash: "share",
disabled: !params.id,
onSelect: async () => {
if (!params.id) return
const write = async (value: string) => {
const body = typeof document === "undefined" ? undefined : document.body
if (body) {
const textarea = document.createElement("textarea")
textarea.value = value
textarea.setAttribute("readonly", "")
textarea.style.position = "fixed"
textarea.style.opacity = "0"
textarea.style.pointerEvents = "none"
body.appendChild(textarea)
textarea.select()
const copied = document.execCommand("copy")
body.removeChild(textarea)
if (copied) return true
}
const write = (value: string) => {
const body = typeof document === "undefined" ? undefined : document.body
if (body) {
const textarea = document.createElement("textarea")
textarea.value = value
textarea.setAttribute("readonly", "")
textarea.style.position = "fixed"
textarea.style.opacity = "0"
textarea.style.pointerEvents = "none"
body.appendChild(textarea)
textarea.select()
const copied = document.execCommand("copy")
body.removeChild(textarea)
if (copied) return Promise.resolve(true)
}
const clipboard = typeof navigator === "undefined" ? undefined : navigator.clipboard
if (!clipboard?.writeText) return false
return clipboard.writeText(value).then(
() => true,
() => false,
)
}
const clipboard = typeof navigator === "undefined" ? undefined : navigator.clipboard
if (!clipboard?.writeText) return Promise.resolve(false)
return clipboard.writeText(value).then(
() => true,
() => false,
)
}
const copyShare = async (url: string, existing: boolean) => {
if (!(await write(url))) {
showToast({
title: language.t("toast.session.share.copyFailed.title"),
variant: "error",
})
return
}
const copy = async (url: string, existing: boolean) => {
const ok = await write(url)
if (!ok) {
showToast({
title: language.t("toast.session.share.copyFailed.title"),
variant: "error",
})
return
}
showToast({
title: existing ? language.t("session.share.copy.copied") : language.t("toast.session.share.success.title"),
description: language.t("toast.session.share.success.description"),
variant: "success",
})
}
showToast({
title: existing
? language.t("session.share.copy.copied")
: language.t("toast.session.share.success.title"),
description: language.t("toast.session.share.success.description"),
variant: "success",
})
}
const share = async () => {
const sessionID = params.id
if (!sessionID) return
const existing = info()?.share?.url
if (existing) {
await copy(existing, true)
return
}
const existing = info()?.share?.url
if (existing) {
await copyShare(existing, true)
return
}
const url = await sdk.client.session
.share({ sessionID: params.id })
.then((res) => res.data?.share?.url)
.catch(() => undefined)
if (!url) {
showToast({
title: language.t("toast.session.share.failed.title"),
description: language.t("toast.session.share.failed.description"),
variant: "error",
})
return
}
const url = await sdk.client.session
.share({ sessionID })
.then((res) => res.data?.share?.url)
.catch(() => undefined)
if (!url) {
showToast({
title: language.t("toast.session.share.failed.title"),
description: language.t("toast.session.share.failed.description"),
variant: "error",
})
return
}
await copy(url, false)
},
}),
sessionCommand({
id: "session.unshare",
title: language.t("command.session.unshare"),
description: language.t("command.session.unshare.description"),
slash: "unshare",
disabled: !params.id || !info()?.share?.url,
onSelect: async () => {
if (!params.id) return
await sdk.client.session
.unshare({ sessionID: params.id })
.then(() =>
showToast({
title: language.t("toast.session.unshare.success.title"),
description: language.t("toast.session.unshare.success.description"),
variant: "success",
}),
)
.catch(() =>
showToast({
title: language.t("toast.session.unshare.failed.title"),
description: language.t("toast.session.unshare.failed.description"),
variant: "error",
}),
)
},
}),
]
await copyShare(url, false)
}
const unshare = async () => {
const sessionID = params.id
if (!sessionID) return
await sdk.client.session
.unshare({ sessionID })
.then(() =>
showToast({
title: language.t("toast.session.unshare.success.title"),
description: language.t("toast.session.unshare.success.description"),
variant: "success",
}),
)
.catch(() =>
showToast({
title: language.t("toast.session.unshare.failed.title"),
description: language.t("toast.session.unshare.failed.description"),
variant: "error",
}),
)
}
const openFile = () => {
void import("@/components/dialog-select-file").then((x) => {
dialog.show(() => <x.DialogSelectFile onOpenFile={showAllFiles} />)
})
}
const closeTab = () => {
const tab = closableTab()
if (!tab) return
tabs().close(tab)
}
const addSelection = () => {
const tab = activeFileTab()
if (!tab) return
const path = file.pathFromTab(tab)
if (!path) return
const range = file.selectedLines(path) as SelectedLineRange | null | undefined
if (!range) {
showToast({
title: language.t("toast.context.noLineSelection.title"),
description: language.t("toast.context.noLineSelection.description"),
})
return
}
addSelectionToContext(path, selectionFromLines(range))
}
const openTerminal = () => {
if (terminal.all().length > 0) terminal.new()
view().terminal.open()
}
const chooseModel = () => {
void import("@/components/dialog-select-model").then((x) => {
dialog.show(() => <x.DialogSelectModel model={local.model} />)
})
}
const chooseMcp = () => {
void import("@/components/dialog-select-mcp").then((x) => {
dialog.show(() => <x.DialogSelectMcp />)
})
}
const toggleAutoAccept = () => {
const sessionID = params.id
if (sessionID) permission.toggleAutoAccept(sessionID, sdk.directory)
else permission.toggleAutoAcceptDirectory(sdk.directory)
const active = sessionID
? permission.isAutoAccepting(sessionID, sdk.directory)
: permission.isAutoAcceptingDirectory(sdk.directory)
showToast({
title: active
? language.t("toast.permissions.autoaccept.on.title")
: language.t("toast.permissions.autoaccept.off.title"),
description: active
? language.t("toast.permissions.autoaccept.on.description")
: language.t("toast.permissions.autoaccept.off.description"),
})
}
const undo = async () => {
const sessionID = params.id
if (!sessionID) return
if (status().type !== "idle") {
await sdk.client.session.abort({ sessionID }).catch(() => {})
}
const revert = info()?.revert?.messageID
const message = findLast(userMessages(), (x) => !revert || x.id < revert)
if (!message) return
await sdk.client.session.revert({ sessionID, messageID: message.id })
const parts = sync.data.part[message.id]
if (parts) {
const restored = extractPromptFromParts(parts, { directory: sdk.directory })
prompt.set(restored)
}
const prev = findLast(userMessages(), (x) => x.id < message.id)
setActiveMessage(prev)
}
const redo = async () => {
const sessionID = params.id
if (!sessionID) return
const revertMessageID = info()?.revert?.messageID
if (!revertMessageID) return
const next = userMessages().find((x) => x.id > revertMessageID)
if (!next) {
await sdk.client.session.unrevert({ sessionID })
prompt.reset()
const last = findLast(userMessages(), (x) => x.id >= revertMessageID)
setActiveMessage(last)
return
}
await sdk.client.session.revert({ sessionID, messageID: next.id })
const prev = findLast(userMessages(), (x) => x.id < next.id)
setActiveMessage(prev)
}
const compact = async () => {
const sessionID = params.id
if (!sessionID) return
const model = local.model.current()
if (!model) {
showToast({
title: language.t("toast.model.none.title"),
description: language.t("toast.model.none.description"),
})
return
}
await sdk.client.session.summarize({
sessionID,
modelID: model.id,
providerID: model.provider.id,
})
}
const fork = () => {
void import("@/components/dialog-fork").then((x) => {
dialog.show(() => <x.DialogFork />)
})
}
const shareCmds = () => {
if (sync.data.config.share === "disabled") return []
return [
sessionCommand({
id: "session.new",
title: language.t("command.session.new"),
keybind: "mod+shift+s",
slash: "new",
onSelect: () => navigate(`/${params.dir}/session`),
}),
fileCommand({
id: "file.open",
title: language.t("command.file.open"),
description: language.t("palette.search.placeholder"),
keybind: "mod+k,mod+p",
slash: "open",
onSelect: () => {
void import("@/components/dialog-select-file").then((x) => {
dialog.show(() => <x.DialogSelectFile onOpenFile={showAllFiles} />)
})
},
}),
fileCommand({
id: "tab.close",
title: language.t("command.tab.close"),
keybind: "mod+w",
disabled: !closableTab(),
onSelect: () => {
const tab = closableTab()
if (!tab) return
tabs().close(tab)
},
}),
contextCommand({
id: "context.addSelection",
title: language.t("command.context.addSelection"),
description: language.t("command.context.addSelection.description"),
keybind: "mod+shift+l",
disabled: !canAddSelectionContext(),
onSelect: () => {
const tab = activeFileTab()
if (!tab) return
const path = file.pathFromTab(tab)
if (!path) return
const range = file.selectedLines(path) as SelectedLineRange | null | undefined
if (!range) {
showToast({
title: language.t("toast.context.noLineSelection.title"),
description: language.t("toast.context.noLineSelection.description"),
})
return
}
addSelectionToContext(path, selectionFromLines(range))
},
}),
viewCommand({
id: "terminal.toggle",
title: language.t("command.terminal.toggle"),
keybind: "ctrl+`",
slash: "terminal",
onSelect: () => view().terminal.toggle(),
}),
viewCommand({
id: "review.toggle",
title: language.t("command.review.toggle"),
keybind: "mod+shift+r",
onSelect: () => view().reviewPanel.toggle(),
}),
viewCommand({
id: "fileTree.toggle",
title: language.t("command.fileTree.toggle"),
keybind: "mod+\\",
onSelect: () => layout.fileTree.toggle(),
}),
viewCommand({
id: "input.focus",
title: language.t("command.input.focus"),
keybind: "ctrl+l",
onSelect: focusInput,
}),
terminalCommand({
id: "terminal.new",
title: language.t("command.terminal.new"),
description: language.t("command.terminal.new.description"),
keybind: "ctrl+alt+t",
onSelect: () => {
if (terminal.all().length > 0) terminal.new()
view().terminal.open()
},
}),
sessionCommand({
id: "message.previous",
title: language.t("command.message.previous"),
description: language.t("command.message.previous.description"),
keybind: "mod+alt+[",
id: "session.share",
title: info()?.share?.url ? language.t("session.share.copy.copyLink") : language.t("command.session.share"),
description: info()?.share?.url
? language.t("toast.session.share.success.description")
: language.t("command.session.share.description"),
slash: "share",
disabled: !params.id,
onSelect: () => navigateMessageByOffset(-1),
onSelect: share,
}),
sessionCommand({
id: "message.next",
title: language.t("command.message.next"),
description: language.t("command.message.next.description"),
keybind: "mod+alt+]",
disabled: !params.id,
onSelect: () => navigateMessageByOffset(1),
id: "session.unshare",
title: language.t("command.session.unshare"),
description: language.t("command.session.unshare.description"),
slash: "unshare",
disabled: !params.id || !info()?.share?.url,
onSelect: unshare,
}),
modelCommand({
id: "model.choose",
title: language.t("command.model.choose"),
description: language.t("command.model.choose.description"),
keybind: "mod+'",
slash: "model",
onSelect: () => {
void import("@/components/dialog-select-model").then((x) => {
dialog.show(() => <x.DialogSelectModel model={local.model} />)
})
},
}),
mcpCommand({
id: "mcp.toggle",
title: language.t("command.mcp.toggle"),
description: language.t("command.mcp.toggle.description"),
keybind: "mod+;",
slash: "mcp",
onSelect: () => {
void import("@/components/dialog-select-mcp").then((x) => {
dialog.show(() => <x.DialogSelectMcp />)
})
},
}),
agentCommand({
id: "agent.cycle",
title: language.t("command.agent.cycle"),
description: language.t("command.agent.cycle.description"),
keybind: "mod+.",
slash: "agent",
onSelect: () => local.agent.move(1),
}),
agentCommand({
id: "agent.cycle.reverse",
title: language.t("command.agent.cycle.reverse"),
description: language.t("command.agent.cycle.reverse.description"),
keybind: "shift+mod+.",
onSelect: () => local.agent.move(-1),
}),
modelCommand({
id: "model.variant.cycle",
title: language.t("command.model.variant.cycle"),
description: language.t("command.model.variant.cycle.description"),
keybind: "shift+mod+d",
onSelect: () => local.model.variant.cycle(),
}),
permissionsCommand({
id: "permissions.autoaccept",
title: isAutoAcceptActive()
? language.t("command.permissions.autoaccept.disable")
: language.t("command.permissions.autoaccept.enable"),
keybind: "mod+shift+a",
disabled: false,
onSelect: () => {
const sessionID = params.id
if (sessionID) permission.toggleAutoAccept(sessionID, sdk.directory)
else permission.toggleAutoAcceptDirectory(sdk.directory)
const active = sessionID
? permission.isAutoAccepting(sessionID, sdk.directory)
: permission.isAutoAcceptingDirectory(sdk.directory)
showToast({
title: active
? language.t("toast.permissions.autoaccept.on.title")
: language.t("toast.permissions.autoaccept.off.title"),
description: active
? language.t("toast.permissions.autoaccept.on.description")
: language.t("toast.permissions.autoaccept.off.description"),
})
},
}),
sessionCommand({
id: "session.undo",
title: language.t("command.session.undo"),
description: language.t("command.session.undo.description"),
slash: "undo",
disabled: !params.id || visibleUserMessages().length === 0,
onSelect: async () => {
const sessionID = params.id
if (!sessionID) return
if (status().type !== "idle") {
await sdk.client.session.abort({ sessionID }).catch(() => {})
}
const revert = info()?.revert?.messageID
const message = findLast(userMessages(), (x) => !revert || x.id < revert)
if (!message) return
await sdk.client.session.revert({ sessionID, messageID: message.id })
const parts = sync.data.part[message.id]
if (parts) {
const restored = extractPromptFromParts(parts, { directory: sdk.directory })
prompt.set(restored)
}
const priorMessage = findLast(userMessages(), (x) => x.id < message.id)
setActiveMessage(priorMessage)
},
}),
sessionCommand({
id: "session.redo",
title: language.t("command.session.redo"),
description: language.t("command.session.redo.description"),
slash: "redo",
disabled: !params.id || !info()?.revert?.messageID,
onSelect: async () => {
const sessionID = params.id
if (!sessionID) return
const revertMessageID = info()?.revert?.messageID
if (!revertMessageID) return
const nextMessage = userMessages().find((x) => x.id > revertMessageID)
if (!nextMessage) {
await sdk.client.session.unrevert({ sessionID })
prompt.reset()
const lastMsg = findLast(userMessages(), (x) => x.id >= revertMessageID)
setActiveMessage(lastMsg)
return
}
await sdk.client.session.revert({ sessionID, messageID: nextMessage.id })
const priorMsg = findLast(userMessages(), (x) => x.id < nextMessage.id)
setActiveMessage(priorMsg)
},
}),
sessionCommand({
id: "session.compact",
title: language.t("command.session.compact"),
description: language.t("command.session.compact.description"),
slash: "compact",
disabled: !params.id || visibleUserMessages().length === 0,
onSelect: async () => {
const sessionID = params.id
if (!sessionID) return
const model = local.model.current()
if (!model) {
showToast({
title: language.t("toast.model.none.title"),
description: language.t("toast.model.none.description"),
})
return
}
await sdk.client.session.summarize({
sessionID,
modelID: model.id,
providerID: model.provider.id,
})
},
}),
sessionCommand({
id: "session.fork",
title: language.t("command.session.fork"),
description: language.t("command.session.fork.description"),
slash: "fork",
disabled: !params.id || visibleUserMessages().length === 0,
onSelect: () => {
void import("@/components/dialog-fork").then((x) => {
dialog.show(() => <x.DialogFork />)
})
},
}),
...share,
]
})
}
const sessionCmds = () => [
sessionCommand({
id: "session.new",
title: language.t("command.session.new"),
keybind: "mod+shift+s",
slash: "new",
onSelect: () => navigate(`/${params.dir}/session`),
}),
sessionCommand({
id: "session.undo",
title: language.t("command.session.undo"),
description: language.t("command.session.undo.description"),
slash: "undo",
disabled: !params.id || visibleUserMessages().length === 0,
onSelect: undo,
}),
sessionCommand({
id: "session.redo",
title: language.t("command.session.redo"),
description: language.t("command.session.redo.description"),
slash: "redo",
disabled: !params.id || !info()?.revert?.messageID,
onSelect: redo,
}),
sessionCommand({
id: "session.compact",
title: language.t("command.session.compact"),
description: language.t("command.session.compact.description"),
slash: "compact",
disabled: !params.id || visibleUserMessages().length === 0,
onSelect: compact,
}),
sessionCommand({
id: "session.fork",
title: language.t("command.session.fork"),
description: language.t("command.session.fork.description"),
slash: "fork",
disabled: !params.id || visibleUserMessages().length === 0,
onSelect: fork,
}),
]
const fileCmds = () => [
fileCommand({
id: "file.open",
title: language.t("command.file.open"),
description: language.t("palette.search.placeholder"),
keybind: "mod+k,mod+p",
slash: "open",
onSelect: openFile,
}),
fileCommand({
id: "tab.close",
title: language.t("command.tab.close"),
keybind: "mod+w",
disabled: !closableTab(),
onSelect: closeTab,
}),
]
const contextCmds = () => [
contextCommand({
id: "context.addSelection",
title: language.t("command.context.addSelection"),
description: language.t("command.context.addSelection.description"),
keybind: "mod+shift+l",
disabled: !canAddSelectionContext(),
onSelect: addSelection,
}),
]
const viewCmds = () => [
viewCommand({
id: "terminal.toggle",
title: language.t("command.terminal.toggle"),
keybind: "ctrl+`",
slash: "terminal",
onSelect: () => view().terminal.toggle(),
}),
viewCommand({
id: "review.toggle",
title: language.t("command.review.toggle"),
keybind: "mod+shift+r",
onSelect: () => view().reviewPanel.toggle(),
}),
viewCommand({
id: "fileTree.toggle",
title: language.t("command.fileTree.toggle"),
keybind: "mod+\\",
onSelect: () => layout.fileTree.toggle(),
}),
viewCommand({
id: "input.focus",
title: language.t("command.input.focus"),
keybind: "ctrl+l",
onSelect: focusInput,
}),
]
const terminalCmds = () => [
terminalCommand({
id: "terminal.new",
title: language.t("command.terminal.new"),
description: language.t("command.terminal.new.description"),
keybind: "ctrl+alt+t",
onSelect: openTerminal,
}),
]
const messageCmds = () => [
sessionCommand({
id: "message.previous",
title: language.t("command.message.previous"),
description: language.t("command.message.previous.description"),
keybind: "mod+alt+[",
disabled: !params.id,
onSelect: () => navigateMessageByOffset(-1),
}),
sessionCommand({
id: "message.next",
title: language.t("command.message.next"),
description: language.t("command.message.next.description"),
keybind: "mod+alt+]",
disabled: !params.id,
onSelect: () => navigateMessageByOffset(1),
}),
]
const modelCmds = () => [
modelCommand({
id: "model.choose",
title: language.t("command.model.choose"),
description: language.t("command.model.choose.description"),
keybind: "mod+'",
slash: "model",
onSelect: chooseModel,
}),
modelCommand({
id: "model.variant.cycle",
title: language.t("command.model.variant.cycle"),
description: language.t("command.model.variant.cycle.description"),
keybind: "shift+mod+d",
onSelect: () => local.model.variant.cycle(),
}),
]
const mcpCmds = () => [
mcpCommand({
id: "mcp.toggle",
title: language.t("command.mcp.toggle"),
description: language.t("command.mcp.toggle.description"),
keybind: "mod+;",
slash: "mcp",
onSelect: chooseMcp,
}),
]
const agentCmds = () => [
agentCommand({
id: "agent.cycle",
title: language.t("command.agent.cycle"),
description: language.t("command.agent.cycle.description"),
keybind: "mod+.",
slash: "agent",
onSelect: () => local.agent.move(1),
}),
agentCommand({
id: "agent.cycle.reverse",
title: language.t("command.agent.cycle.reverse"),
description: language.t("command.agent.cycle.reverse.description"),
keybind: "shift+mod+.",
onSelect: () => local.agent.move(-1),
}),
]
const permissionsCmds = () => [
permissionsCommand({
id: "permissions.autoaccept",
title: isAutoAcceptActive()
? language.t("command.permissions.autoaccept.disable")
: language.t("command.permissions.autoaccept.enable"),
keybind: "mod+shift+a",
disabled: false,
onSelect: toggleAutoAccept,
}),
]
command.register("session", () => [
...sessionCmds(),
...shareCmds(),
...fileCmds(),
...contextCmds(),
...viewCmds(),
...terminalCmds(),
...messageCmds(),
...modelCmds(),
...mcpCmds(),
...agentCmds(),
...permissionsCmds(),
])
}

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.3.5",
"version": "1.3.10",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -432,9 +432,7 @@ export default function Home() {
</Faq>
</li>
<li>
<Faq question={i18n.t("go.faq.q5")}>
{i18n.t("go.faq.a5.body")} <a href="mailto:contact@anoma.ly">{i18n.t("common.contactUs")}</a>
</Faq>
<Faq question={i18n.t("go.faq.q5")}>{i18n.t("go.faq.a5.body")}</Faq>
</li>
<li>
<Faq question={i18n.t("go.faq.q6")}>{i18n.t("go.faq.a6")}</Faq>

View File

@@ -139,19 +139,16 @@ export async function handler(
const startTimestamp = Date.now()
const reqUrl = providerInfo.modifyUrl(providerInfo.api, isStream)
const reqBody = JSON.stringify(
providerInfo.modifyBody(
{
...createBodyConverter(opts.format, providerInfo.format)(body),
model: providerInfo.model,
...(providerInfo.payloadModifier ?? {}),
...Object.fromEntries(
Object.entries(providerInfo.payloadMappings ?? {})
.map(([k, v]) => [k, input.request.headers.get(v)])
.filter(([_k, v]) => !!v),
),
},
authInfo?.workspaceID,
),
providerInfo.modifyBody({
...createBodyConverter(opts.format, providerInfo.format)(body),
model: providerInfo.model,
...(providerInfo.payloadModifier ?? {}),
...Object.fromEntries(
Object.entries(providerInfo.payloadMappings ?? {})
.map(([k, v]) => [k, input.request.headers.get(v)])
.filter(([_k, v]) => !!v),
),
}),
)
logger.debug("REQUEST URL: " + reqUrl)
logger.debug("REQUEST: " + reqBody.substring(0, 300) + "...")
@@ -470,15 +467,17 @@ export async function handler(
...(() => {
const providerProps = zenData.providers[modelProvider.id]
const format = providerProps.format
const providerModel = modelProvider.model
if (format === "anthropic") return anthropicHelper({ reqModel, providerModel })
if (format === "google") return googleHelper({ reqModel, providerModel })
if (format === "openai") return openaiHelper({ reqModel, providerModel })
return oaCompatHelper({
const opts = {
reqModel,
providerModel,
providerModel: modelProvider.model,
adjustCacheUsage: providerProps.adjustCacheUsage,
})
safetyIdentifier: modelProvider.safetyIdentifier ? ip : undefined,
workspaceID: authInfo?.workspaceID,
}
if (format === "anthropic") return anthropicHelper(opts)
if (format === "google") return googleHelper(opts)
if (format === "openai") return openaiHelper(opts)
return oaCompatHelper(opts)
})(),
}
}

View File

@@ -21,17 +21,18 @@ type Usage = {
}
}
export const oaCompatHelper: ProviderHelper = ({ adjustCacheUsage }) => ({
export const oaCompatHelper: ProviderHelper = ({ adjustCacheUsage, safetyIdentifier }) => ({
format: "oa-compat",
modifyUrl: (providerApi: string) => providerApi + "/chat/completions",
modifyHeaders: (headers: Headers, body: Record<string, any>, apiKey: string) => {
headers.set("authorization", `Bearer ${apiKey}`)
headers.set("x-session-affinity", headers.get("x-opencode-session") ?? "")
},
modifyBody: (body: Record<string, any>) => {
modifyBody: (body: Record<string, any>, workspaceID?: string) => {
return {
...body,
...(body.stream ? { stream_options: { include_usage: true } } : {}),
...(safetyIdentifier ? { safety_identifier: safetyIdentifier } : {}),
}
},
createBinaryStreamDecoder: () => undefined,

View File

@@ -12,13 +12,13 @@ type Usage = {
total_tokens?: number
}
export const openaiHelper: ProviderHelper = () => ({
export const openaiHelper: ProviderHelper = ({ workspaceID }) => ({
format: "openai",
modifyUrl: (providerApi: string) => providerApi + "/responses",
modifyHeaders: (headers: Headers, body: Record<string, any>, apiKey: string) => {
headers.set("authorization", `Bearer ${apiKey}`)
},
modifyBody: (body: Record<string, any>, workspaceID?: string) => ({
modifyBody: (body: Record<string, any>) => ({
...body,
...(workspaceID ? { safety_identifier: workspaceID } : {}),
}),

View File

@@ -33,11 +33,17 @@ export type UsageInfo = {
cacheWrite1hTokens?: number
}
export type ProviderHelper = (input: { reqModel: string; providerModel: string; adjustCacheUsage?: boolean }) => {
export type ProviderHelper = (input: {
reqModel: string
providerModel: string
adjustCacheUsage?: boolean
safetyIdentifier?: string
workspaceID?: string
}) => {
format: ZenData.Format
modifyUrl: (providerApi: string, isStream?: boolean) => string
modifyHeaders: (headers: Headers, body: Record<string, any>, apiKey: string) => void
modifyBody: (body: Record<string, any>, workspaceID?: string) => Record<string, any>
modifyBody: (body: Record<string, any>) => Record<string, any>
createBinaryStreamDecoder: () => ((chunk: Uint8Array) => Uint8Array | undefined) | undefined
streamSeparator: string
createUsageParser: () => {

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.3.5",
"version": "1.3.10",
"private": true,
"type": "module",
"license": "MIT",

View File

@@ -10,7 +10,7 @@ if (!stage) throw new Error("Stage is required")
const root = path.resolve(process.cwd(), "..", "..", "..")
// read the secret
const ret = await $`bun sst secret list`.cwd(root).text()
const ret = await $`bun sst secret list --stage frank`.cwd(root).text()
const lines = ret.split("\n")
const value = lines.find((line) => line.startsWith("ZEN_LIMITS"))?.split("=")[1]
if (!value) throw new Error("ZEN_LIMITS not found")

View File

@@ -12,7 +12,7 @@ const root = path.resolve(process.cwd(), "..", "..", "..")
const PARTS = 30
// read the secret
const ret = await $`bun sst secret list`.cwd(root).text()
const ret = await $`bun sst secret list --stage frank`.cwd(root).text()
const lines = ret.split("\n")
const values = Array.from({ length: PARTS }, (_, i) => {
const value = lines

View File

@@ -6,7 +6,7 @@ import os from "os"
import { Subscription } from "../src/subscription"
const root = path.resolve(process.cwd(), "..", "..", "..")
const secrets = await $`bun sst secret list`.cwd(root).text()
const secrets = await $`bun sst secret list --stage frank`.cwd(root).text()
// read value
const lines = secrets.split("\n")
@@ -25,4 +25,4 @@ const newValue = JSON.stringify(JSON.parse(await tempFile.text()))
Subscription.validate(JSON.parse(newValue))
// update the secret
await $`bun sst secret set ZEN_LIMITS ${newValue}`
await $`bun sst secret set ZEN_LIMITS ${newValue} --stage frank`.cwd(root)

View File

@@ -6,7 +6,7 @@ import os from "os"
import { ZenData } from "../src/model"
const root = path.resolve(process.cwd(), "..", "..", "..")
const models = await $`bun sst secret list`.cwd(root).text()
const models = await $`bun sst secret list --stage frank`.cwd(root).text()
const PARTS = 30
// read the line starting with "ZEN_MODELS"
@@ -40,4 +40,4 @@ const newValues = Array.from({ length: PARTS }, (_, i) =>
const envFile = Bun.file(path.join(os.tmpdir(), `models-${Date.now()}.env`))
await envFile.write(newValues.map((v, i) => `ZEN_MODELS${i + 1}="${v.replace(/"/g, '\\"')}"`).join("\n"))
await $`bun sst secret load ${envFile.name}`.cwd(root)
await $`bun sst secret load ${envFile.name} --stage frank`.cwd(root)

View File

@@ -37,6 +37,7 @@ export namespace ZenData {
disabled: z.boolean().optional(),
storeModel: z.string().optional(),
payloadModifier: z.record(z.string(), z.any()).optional(),
safetyIdentifier: z.boolean().optional(),
}),
),
})

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.3.5",
"version": "1.3.10",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.3.5",
"version": "1.3.10",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",

View File

@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop-electron",
"private": true,
"version": "1.3.5",
"version": "1.3.10",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",

View File

@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.3.5",
"version": "1.3.10",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.3.5",
"version": "1.3.10",
"private": true,
"type": "module",
"license": "MIT",

View File

@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.3.5"
version = "1.3.10"
schema_version = 1
authors = ["Anomaly"]
repository = "https://github.com/anomalyco/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.5/opencode-darwin-arm64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.10/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.5/opencode-darwin-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.10/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.5/opencode-linux-arm64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.10/opencode-linux-arm64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.5/opencode-linux-x64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.10/opencode-linux-x64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.5/opencode-windows-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.10/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.3.5",
"version": "1.3.10",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",

View File

@@ -0,0 +1,7 @@
{
"plugins": {
"figma": {
"enabled": true
}
}
}

43
packages/mobile-voice/.gitignore vendored Normal file
View File

@@ -0,0 +1,43 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
# dependencies
node_modules/
# Expo
.expo/
dist/
web-build/
expo-env.d.ts
# Native
.kotlin/
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
# Metro
.metro-health-check*
# debug
npm-debug.*
yarn-debug.*
yarn-error.*
# macOS
.DS_Store
*.pem
# local env files
.env*.local
# typescript
*.tsbuildinfo
app-example
# generated native folders
/ios
/android

View File

@@ -0,0 +1,177 @@
# mobile-voice Agent Guide
This file defines package-specific guidance for agents working in `packages/mobile-voice`.
## Scope And Precedence
- Follow root `AGENTS.md` first.
- This file overrides root guidance for this package when rules conflict.
- If additional local guides are added later, treat the closest guide as highest priority.
## Project Overview
- Expo + React Native app for voice dictation and OpenCode session monitoring.
- Uses native/device-heavy modules such as `whisper.rn`, `react-native-audio-api`, `expo-notifications`, and `expo-camera`.
- Development builds are required for native module changes.
## Commands
Run all commands from `packages/mobile-voice`.
- Install deps: `bun install`
- Start Metro: `bun run start`
- Start dev client server (recommended): `bunx expo start --dev-client --clear --host lan`
- iOS run: `bun run ios`
- Android run: `bun run android`
- Lint: `bun run lint`
- Typecheck: `bun run typecheck`
- Expo doctor: `bunx expo-doctor`
- Dependency compatibility check: `bunx expo install --check`
- Export bundle smoke test: `bunx expo export --platform ios --clear`
## Build / Verification Expectations
- For JS-only changes: run `bun run lint` and verify app behavior via dev client.
- For TS-heavy refactors: run `bun run typecheck` in addition to lint.
- For native dependency/config/plugin changes: rebuild dev client via EAS before validation.
- If notifications, camera, microphone, or audio-session behavior changes, verify on a physical iOS device.
- Do not claim a fix unless you validated in Metro logs and app runtime behavior.
## Single-Test Guidance
- This package currently has no dedicated unit test script.
- Use targeted validation commands instead:
- `bun run lint`
- `bun run typecheck`
- `bunx expo export --platform ios --clear`
- manual runtime test in dev client
## Architecture Priorities
- Keep screens focused on composition and orchestration. Once a screen owns multiple workflows, extract hooks/components before adding more local state.
- Prefer extracting pure helpers and config objects before introducing new stores or abstractions.
- Treat `src/app/index.tsx` as a composition root, not as the permanent home for onboarding, dictation, monitoring, pairing, persistence, and all UI details.
- Avoid mirrored `state + ref` pairs unless they are needed for imperative native APIs, race cancellation, or subscription callbacks.
## Code Style And Patterns
### Formatting / Structure
- Preserve existing style (`semi: false`, concise JSX, stable import grouping).
- Keep UI changes localized and behavior-preserving; avoid unrelated formatting churn.
- Prefer feature-adjacent hooks/components over growing a single screen file.
### React State / Effects
- Effects are for subscriptions, timers, persistence, network I/O, and native bridge setup/cleanup.
- Do not add `useEffect` just to derive render data from props or state. Derive during render instead.
- Prefer one source of truth. If a value can be computed from existing state, do not store it separately.
- Use `useMemo` only when computation is expensive or stable identity actually matters.
- Use `useCallback` only when stable function identity matters for dependencies, cleanup, or memoized children.
- When UI branches are driven by a small finite state, prefer config tables/objects over long nested ternaries.
### Types
- Avoid `any`; prefer local type aliases for component state and network payloads.
- Keep exported/shared boundaries typed explicitly.
- Parse persisted and network payloads as `unknown` first, then validate before use.
- Use discriminated unions for UI modes/status where practical.
### Naming
- Prefer short, readable names consistent with nearby code.
- Keep naming aligned with existing app state keys (`monitorStatus`, `activeSessionId`, etc.).
### Error Handling / Logging
- Fail gracefully in UI (alerts, disabled actions, fallback text).
- Avoid bare `catch {}` or `.catch(() => {})` for meaningful work. If failure is intentionally best-effort, leave a short comment or use a helper that makes that explicit.
- Log actionable diagnostics for runtime workflows such as server health checks, relay registration, and notification token lifecycle.
- Never log secrets or full APNs tokens.
- Keep hot-path logging behind `__DEV__` when possible.
### Network / Relay Integration
- Normalize and validate URLs before storing server configs.
- Use `AbortController` or request IDs for overlapping requests, streams, and polling.
- Keep relay registration idempotent.
- Guard duplicate scan/add flows to avoid repeated server entries.
### Notifications / APNs
- This package currently assumes APNs relay registration uses the `production` environment only. Do not add environment switching unless explicitly requested.
- On registration changes, ensure old token unregister flow remains intact.
- Treat permission failures as non-fatal and degrade to foreground monitoring when needed.
### Performance / RN
- Validate performance-sensitive changes in a dev client or release build, not only Metro dev mode.
- During recording and monitoring flows, keep JS-thread work light.
- Prefer Reanimated/native-thread-friendly animations for motion.
- For small menus a `ScrollView` is fine; if a list grows beyond a small bounded menu, move to `FlatList` or `FlashList`.
## Lint / Quality Bar
- Keep hooks lint warnings clean before finishing.
- Treat `any`, `no-console`, complexity, and max-lines warnings as refactor prompts, not noise to suppress.
- Do not disable React Hooks lint rules inline unless there is a documented native-interop reason.
- When introducing new persistence or network payloads, add or reuse a parser instead of scattering casts.
## Native-Module Safety
- If adding a native module, ensure it is in `package.json` with an SDK-compatible version.
- Rebuild the dev client after native module additions or changes.
- For optional native capability usage, prefer runtime fallback paths instead of hard crashes.
## Expo Native Config (EAS)
- Treat `packages/mobile-voice/app.json` as the source of truth for iOS native metadata in EAS cloud builds.
- Do not rely on manual edits in `ios/mobilevoice/Info.plist`, entitlements files, or `PrivacyInfo.xcprivacy`; for this package they are generated outputs.
- Keep generated native folders untracked in git (`/ios`, `/android`) to avoid mixed CNG/bare behavior during EAS builds.
- Put App Store compliance and permission metadata in app config using these fields:
- `expo.ios.infoPlist` for Info.plist keys (usage strings, ATS, Bonjour, and related keys).
- `expo.ios.config.usesNonExemptEncryption` for export-compliance encryption declaration.
- `expo.ios.entitlements` for iOS entitlements.
- `expo.ios.privacyManifests` for Apple privacy manifest declarations.
- Keep `app.json` entries explicit and review-friendly:
- Permission descriptions should be complete, product-specific sentences.
- Compliance keys should be set intentionally rather than relying on implicit defaults.
- Preserve existing JSON style in this package (concise arrays and stable key grouping).
- After native config changes, verify resolved config with `bunx expo config --type prebuild --json` and check the resulting `ios` fields.
Example shape:
```json
{
"expo": {
"ios": {
"infoPlist": {
"NSCameraUsageDescription": "...",
"NSMicrophoneUsageDescription": "..."
},
"config": {
"usesNonExemptEncryption": false
},
"entitlements": {
"com.apple.developer.kernel.extended-virtual-addressing": true
},
"privacyManifests": {
"NSPrivacyAccessedAPITypes": []
}
}
}
}
```
## Common Pitfalls
- Black screen + "No script URL provided" often means a stale dev client binary.
- `expo-doctor` duplicate module warnings may appear in Bun workspaces; prioritize runtime verification.
- `expo lint` may auto-generate `eslint.config.js`; do not commit accidental generated config unless requested.
## Before Finishing
- Run `bun run lint`.
- If behavior could break startup, run `bunx expo export --platform ios --clear`.
- Confirm no accidental config side effects were introduced.
- Summarize what was verified on-device vs only in tooling.

View File

@@ -0,0 +1,39 @@
# Mobile Voice
Expo app for voice dictation and OpenCode session monitoring.
## Current monitoring behavior
- Foreground: app reads OpenCode SSE (`GET /event`) and updates monitor status live.
- Background/terminated: app relies on APNs notifications sent by `apn-relay`.
- The app registers its native APNs device token with relay route `POST /v1/device/register`.
## App requirements
- Use a development build or production build (not Expo Go).
- `expo-notifications` plugin is enabled with `enableBackgroundRemoteNotifications: true`.
- Notification permission must be granted.
## Server entry fields in app
When adding a server, provide:
- OpenCode URL
- APN relay URL
- Relay shared secret
Default APN relay URL: `https://apn.dev.opencode.ai`
The app uses these values to:
- send prompts to OpenCode
- register/unregister APNs token with relay
- receive background push updates for monitored sessions
## Local dev
```bash
npx expo start
```
Use your machine LAN IP / reachable host values for OpenCode and relay when testing on a physical device.

View File

@@ -0,0 +1,98 @@
{
"expo": {
"name": "Control",
"slug": "control",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "mobilevoice",
"userInterfaceStyle": "automatic",
"ios": {
"icon": "./assets/images/icon.png",
"bundleIdentifier": "com.anomalyco.mobilevoice",
"config": {
"usesNonExemptEncryption": false
},
"entitlements": {
"com.apple.developer.kernel.extended-virtual-addressing": true
},
"infoPlist": {
"NSMicrophoneUsageDescription": "Control uses the microphone while you hold Record to turn your speech into text for an OpenCode session.",
"NSCameraUsageDescription": "Control uses the camera to scan the OpenCode pairing QR code shown on your computer.",
"NSLocalNetworkUsageDescription": "Control uses your local network to discover and connect to OpenCode servers running on your computer.",
"NSBonjourServices": ["_http._tcp."],
"NSAppTransportSecurity": {
"NSAllowsLocalNetworking": true,
"NSExceptionDomains": {
"ts.net": {
"NSIncludesSubdomains": true,
"NSExceptionAllowsInsecureHTTPLoads": true
}
}
}
}
},
"android": {
"adaptiveIcon": {
"backgroundColor": "#E6F4FE",
"foregroundImage": "./assets/images/android-icon-foreground.png",
"backgroundImage": "./assets/images/android-icon-background.png",
"monochromeImage": "./assets/images/android-icon-monochrome.png"
},
"permissions": [
"RECORD_AUDIO",
"POST_NOTIFICATIONS",
"android.permission.FOREGROUND_SERVICE",
"android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK",
"android.permission.RECORD_AUDIO",
"android.permission.MODIFY_AUDIO_SETTINGS",
"android.permission.ACCESS_NETWORK_STATE",
"android.permission.ACCESS_WIFI_STATE",
"android.permission.CHANGE_WIFI_MULTICAST_STATE"
],
"predictiveBackGestureEnabled": false
},
"web": {
"output": "static",
"favicon": "./assets/images/favicon.png"
},
"plugins": [
"expo-router",
[
"expo-splash-screen",
{
"backgroundColor": "#121212",
"android": {
"image": "./assets/images/splash-icon.png",
"imageWidth": 76
}
}
],
"react-native-audio-api",
"expo-asset",
"expo-audio",
[
"expo-notifications",
{
"enableBackgroundRemoteNotifications": true,
"sounds": ["./assets/sounds/alert.wav"]
}
]
],
"experiments": {
"typedRoutes": true,
"reactCompiler": true
},
"extra": {
"router": {},
"eas": {
"projectId": "50b3dac3-8b5e-4142-b749-65ecf7b2904d"
}
},
"owner": "anomaly-co",
"runtimeVersion": "1.0.0",
"updates": {
"url": "https://u.expo.dev/50b3dac3-8b5e-4142-b749-65ecf7b2904d"
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

View File

@@ -0,0 +1,3 @@
<svg width="652" height="606" viewBox="0 0 652 606" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M353.554 0H298.446C273.006 0 249.684 14.6347 237.962 37.9539L4.37994 502.646C-1.04325 513.435 -1.45067 526.178 3.2716 537.313L22.6123 582.918C34.6475 611.297 72.5404 614.156 88.4414 587.885L309.863 222.063C313.34 216.317 319.439 212.826 326 212.826C332.561 212.826 338.659 216.317 342.137 222.063L563.559 587.885C579.46 614.156 617.352 611.297 629.388 582.918L648.728 537.313C653.451 526.178 653.043 513.435 647.62 502.646L414.038 37.9539C402.316 14.6347 378.994 0 353.554 0Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 608 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

View File

@@ -0,0 +1,40 @@
{
"fill" : {
"automatic-gradient" : "extended-srgb:0.00000,0.47843,1.00000,1.00000"
},
"groups" : [
{
"layers" : [
{
"image-name" : "expo-symbol 2.svg",
"name" : "expo-symbol 2",
"position" : {
"scale" : 1,
"translation-in-points" : [
1.1008400065293245e-05,
-16.046875
]
}
},
{
"image-name" : "grid.png",
"name" : "grid"
}
],
"shadow" : {
"kind" : "neutral",
"opacity" : 0.5
},
"translucency" : {
"enabled" : true,
"value" : 0.5
}
}
],
"supported-platforms" : {
"circles" : [
"watchOS"
],
"squares" : "shared"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 324 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 347 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 468 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 253 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 343 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 479 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -0,0 +1,24 @@
{
"cli": {
"version": ">= 18.4.0",
"appVersionSource": "remote"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal",
"channel": "development"
},
"preview": {
"distribution": "internal",
"channel": "preview"
},
"production": {
"autoIncrement": true,
"channel": "production"
}
},
"submit": {
"production": {}
}
}

View File

@@ -0,0 +1,52 @@
// https://docs.expo.dev/guides/using-eslint/
const { defineConfig } = require("eslint/config")
const tsGuard = require("@typescript-eslint/eslint-plugin")
const expoConfig = require("eslint-config-expo/flat")
const reactHooksNext = require("eslint-plugin-react-hooks")
module.exports = defineConfig([
expoConfig,
{
ignores: ["dist/*"],
},
{
files: ["**/*.{ts,tsx}"],
languageOptions: {
parserOptions: {
projectService: true,
tsconfigRootDir: __dirname,
},
},
plugins: {
"react-hooks-next": reactHooksNext,
"ts-guard": tsGuard,
},
rules: {
"ts-guard/no-explicit-any": "warn",
"ts-guard/no-floating-promises": "warn",
complexity: ["warn", 20],
"max-lines": [
"warn",
{
max: 1200,
skipBlankLines: true,
skipComments: true,
},
],
"max-lines-per-function": [
"warn",
{
max: 250,
skipBlankLines: true,
skipComments: true,
},
],
"no-console": ["warn", { allow: ["warn", "error"] }],
"no-nested-ternary": "warn",
"react-hooks/exhaustive-deps": "error",
"react-hooks-next/refs": "warn",
"react-hooks-next/set-state-in-effect": "warn",
"react-hooks-next/static-components": "warn",
},
},
])

View File

@@ -0,0 +1,8 @@
const { getDefaultConfig } = require('expo/metro-config');
const config = getDefaultConfig(__dirname);
// Required for react-native-executorch model files
config.resolver.assetExts.push('pte', 'bin');
module.exports = config;

View File

@@ -0,0 +1,5 @@
- While the model is loading for the first time, there should be some fun little like onboarding sequence that you can go through that makes sure the model is automated properly.
- When a permission/session complete notification is sent, if you click on it, the session/server should auto be selected.
- We need some sort of permissions UI in the top half of the generation.
- Need to figure out a good way to start new sessions.
- When an agent returns a generation, we should be able to expand it into a reader mode view.

8913
packages/mobile-voice/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,65 @@
{
"name": "mobile-voice",
"main": "expo-router/entry",
"version": "1.0.0",
"scripts": {
"start": "expo start",
"relay": "echo 'Use packages/apn-relay for APNs relay server'",
"relay:legacy": "node ./relay/opencode-relay.mjs",
"reset-project": "node ./scripts/reset-project.js",
"android": "expo run:android",
"ios": "expo run:ios",
"web": "expo start --web",
"lint": "expo lint",
"typecheck": "tsc -p tsconfig.typecheck.json --noEmit"
},
"dependencies": {
"@fugood/react-native-audio-pcm-stream": "1.1.4",
"@react-navigation/bottom-tabs": "^7.15.5",
"@react-navigation/elements": "^2.9.10",
"@react-navigation/native": "^7.1.33",
"expo": "~55.0.9",
"expo-asset": "~55.0.10",
"expo-audio": "~55.0.9",
"expo-camera": "~55.0.11",
"expo-constants": "~55.0.9",
"expo-dev-client": "~55.0.19",
"expo-device": "~55.0.10",
"expo-file-system": "~55.0.12",
"expo-font": "~55.0.4",
"expo-glass-effect": "~55.0.8",
"expo-haptics": "~55.0.9",
"expo-image": "~55.0.6",
"expo-linking": "~55.0.9",
"expo-notifications": "~55.0.14",
"expo-router": "~55.0.8",
"expo-splash-screen": "~55.0.13",
"expo-status-bar": "~55.0.4",
"expo-symbols": "~55.0.5",
"expo-system-ui": "~55.0.11",
"expo-task-manager": "~55.0.10",
"expo-updates": "~55.0.16",
"expo-web-browser": "~55.0.10",
"react": "19.2.0",
"react-dom": "19.2.0",
"react-native": "0.83.4",
"react-native-audio-api": "^0.11.7",
"react-native-gesture-handler": "~2.30.0",
"react-native-reanimated": "4.2.1",
"react-native-safe-area-context": "~5.6.2",
"react-native-screens": "~4.23.0",
"react-native-web": "~0.21.0",
"react-native-worklets": "0.7.2",
"react-native-zeroconf": "0.14.0",
"whisper.rn": "0.5.5"
},
"devDependencies": {
"@typescript-eslint/eslint-plugin": "^8.57.2",
"@typescript-eslint/parser": "^8.57.2",
"@types/react": "~19.2.2",
"babel-preset-expo": "~55.0.8",
"eslint-plugin-react-hooks": "^7.0.1",
"typescript": "~5.9.2"
},
"private": true
}

View File

@@ -0,0 +1,88 @@
# Mobile Voice Refactor Plan
## Goals
- Reduce the surface area of `src/app/index.tsx` without changing product behavior.
- Make device, network, and monitoring flows easier to reason about.
- Move toward React Native / Expo best practices for state, effects, and file structure.
- Use the new lint warnings as refactor prompts, not as permanent background noise.
## Current Pain Points
- `DictationScreen` currently owns onboarding, permissions, Whisper/model lifecycle, dictation, pairing, server/session sync, relay registration, notification handling, and most UI rendering.
- The screen mixes render-time derived state, imperative refs, polling, persistence, and native cleanup in one place.
- There are many nested conditionals and long derived blocks that are hard to scan.
- Best-effort async cleanup and silent catches make failures harder to understand.
## Target Shape
- `src/app/index.tsx`
- compose hooks and presentational sections
- keep only screen-level orchestration
- `src/features/onboarding/`
- onboarding step config
- onboarding UI component
- `src/features/dictation/`
- `use-whisper-dictation`
- transcript helpers
- `src/features/servers/`
- server/session refresh and pairing helpers
- persisted server state helpers
- `src/features/monitoring/`
- foreground SSE monitoring
- notification payload handling
- relay registration helpers
- `src/lib/`
- parser/validation helpers
- logger helper for dev-only diagnostics
## Refactor Order
### Phase 1: Extract pure helpers first
- Move onboarding step text/style selection into a config object or array.
- Move server/session payload parsing into dedicated helpers.
- Keep existing behavior and props the same.
### Phase 2: Extract onboarding UI
- Create an `OnboardingFlow` component that receives explicit state and handlers.
- Keep onboarding persistence in the screen until the UI extraction is stable.
### Phase 3: Extract dictation logic
- Move Whisper loading, recording, bulk/realtime transcription, and waveform state into a `useWhisperDictation` hook.
- Expose a small interface: recording state, transcript, actions, and model status.
### Phase 4: Extract server/session management
- Move server restore/save, pairing, health refresh, and active server/session selection into a dedicated hook.
- Centralize server parsing and dedupe logic.
### Phase 5: Extract monitoring and notifications
- Move SSE monitoring, push payload handling, and relay registration into a `useMonitoring` hook.
- Keep side effects close to the feature that owns them.
### Phase 6: Lint burn-down
- Replace `any` with explicit parsed shapes.
- Reduce nested ternaries in favor of config tables.
- Replace ad hoc `console.log` calls with a logger helper or `__DEV__`-gated diagnostics.
- Audit bare `.catch(() => {})` and convert non-trivial cases to explicit best-effort helpers or real error handling.
## Guardrails During Refactor
- Keep one behavior-preserving slice per PR.
- Do not introduce more derived state in `useEffect`.
- Prefer explicit hook inputs/outputs over hidden cross-hook coupling.
- Only use refs for imperative APIs, subscriptions, and race control.
- Re-run lint after each slice.
- Validate app behavior in the dev client for microphone, notifications, pairing, and monitoring flows.
## Exit Criteria
- `src/app/index.tsx` is mostly screen composition and stays under roughly 800-1200 lines.
- Feature logic lives in focused hooks/components with clearer ownership.
- New payload parsing does not rely on `any`.
- Lint warnings trend down instead of growing.

View File

@@ -0,0 +1,328 @@
import { createServer } from 'node:http';
const PORT = Number(process.env.PORT || 8787);
const HOST = process.env.HOST || '0.0.0.0';
const EXPO_PUSH_URL = 'https://exp.host/--/api/v2/push/send';
/** @type {Map<string, {jobID: string, sessionID: string, opencodeBaseURL: string, relayBaseURL: string, expoPushToken: string, createdAt: number, done: boolean}>} */
const jobs = new Map();
/** @type {Map<string, {key: string, opencodeBaseURL: string, abortController: AbortController, sessions: Set<string>, running: boolean}>} */
const streams = new Map();
/** @type {Set<string>} */
const dedupe = new Set();
function json(res, status, body) {
const value = JSON.stringify(body);
res.writeHead(status, {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(value),
});
res.end(value);
}
async function readJSON(req) {
let raw = '';
for await (const chunk of req) {
raw += chunk;
if (raw.length > 1_000_000) {
throw new Error('Payload too large');
}
}
if (!raw.trim()) return {};
return JSON.parse(raw);
}
function extractSessionID(event) {
const properties = event?.properties ?? {};
if (typeof properties.sessionID === 'string') return properties.sessionID;
if (properties.info && typeof properties.info === 'object' && typeof properties.info.sessionID === 'string') {
return properties.info.sessionID;
}
if (properties.part && typeof properties.part === 'object' && typeof properties.part.sessionID === 'string') {
return properties.part.sessionID;
}
return null;
}
function classifyEvent(event) {
const type = String(event?.type || '');
const lower = type.toLowerCase();
if (lower.includes('permission')) return 'permission';
if (lower.includes('error')) return 'error';
if (type === 'session.status') {
const statusType = event?.properties?.status?.type;
if (statusType === 'idle') return 'complete';
}
if (type === 'message.updated') {
const info = event?.properties?.info;
if (info && typeof info === 'object') {
if (info.error) return 'error';
if (info.role === 'assistant' && info.time && typeof info.time === 'object' && info.time.completed) {
return 'complete';
}
}
}
return null;
}
function notificationBody(eventType) {
if (eventType === 'complete') {
return {
title: 'Session complete',
body: 'OpenCode finished your monitored prompt.',
};
}
if (eventType === 'permission') {
return {
title: 'Action needed',
body: 'OpenCode needs a permission decision.',
};
}
return {
title: 'Session error',
body: 'OpenCode reported an error for your monitored session.',
};
}
async function sendPush({ expoPushToken, eventType, sessionID, jobID }) {
const dedupeKey = `${jobID}:${eventType}`;
if (dedupe.has(dedupeKey)) return;
dedupe.add(dedupeKey);
const text = notificationBody(eventType);
const payload = {
to: expoPushToken,
priority: 'high',
_contentAvailable: true,
data: {
eventType,
sessionID,
jobID,
title: text.title,
body: text.body,
dedupeKey,
at: Date.now(),
},
};
const response = await fetch(EXPO_PUSH_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify(payload),
});
if (!response.ok) {
const body = await response.text();
throw new Error(`Push send failed (${response.status}): ${body || response.statusText}`);
}
}
async function* parseSSE(readable) {
const reader = readable.getReader();
const decoder = new TextDecoder();
let pending = '';
try {
while (true) {
const next = await reader.read();
if (next.done) break;
pending += decoder.decode(next.value, { stream: true });
const blocks = pending.split(/\r?\n\r?\n/);
pending = blocks.pop() || '';
for (const block of blocks) {
const lines = block.split(/\r?\n/);
const dataLines = [];
for (const line of lines) {
if (!line || line.startsWith(':')) continue;
if (line.startsWith('data:')) {
dataLines.push(line.slice(5).trimStart());
}
}
if (dataLines.length > 0) {
yield dataLines.join('\n');
}
}
}
} finally {
reader.releaseLock();
}
}
function cleanupStreamIfUnused(baseURL) {
const key = baseURL.replace(/\/+$/, '');
const entry = streams.get(key);
if (!entry) return;
const stillUsed = Array.from(jobs.values()).some((job) => !job.done && job.opencodeBaseURL === key);
if (stillUsed) return;
entry.abortController.abort();
streams.delete(key);
}
async function runStream(baseURL) {
const key = baseURL.replace(/\/+$/, '');
if (streams.has(key)) return;
const abortController = new AbortController();
streams.set(key, {
key,
opencodeBaseURL: key,
abortController,
sessions: new Set(),
running: true,
});
while (!abortController.signal.aborted) {
try {
const response = await fetch(`${key}/event`, {
signal: abortController.signal,
headers: {
Accept: 'text/event-stream',
'Cache-Control': 'no-cache',
},
});
if (!response.ok || !response.body) {
throw new Error(`SSE connect failed (${response.status})`);
}
for await (const data of parseSSE(response.body)) {
if (abortController.signal.aborted) break;
let event;
try {
event = JSON.parse(data);
} catch {
continue;
}
const sessionID = extractSessionID(event);
if (!sessionID) continue;
const eventType = classifyEvent(event);
if (!eventType) continue;
const related = Array.from(jobs.values()).filter(
(job) => !job.done && job.opencodeBaseURL === key && job.sessionID === sessionID,
);
if (related.length === 0) continue;
await Promise.allSettled(
related.map(async (job) => {
await sendPush({
expoPushToken: job.expoPushToken,
eventType,
sessionID,
jobID: job.jobID,
});
if (eventType === 'complete' || eventType === 'error') {
const current = jobs.get(job.jobID);
if (current) current.done = true;
}
}),
);
}
} catch (error) {
if (abortController.signal.aborted) break;
console.warn('[relay] SSE loop error:', error instanceof Error ? error.message : String(error));
await new Promise((resolve) => setTimeout(resolve, 1200));
}
}
}
const server = createServer(async (req, res) => {
if (!req.url || !req.method) {
json(res, 400, { ok: false, error: 'Invalid request' });
return;
}
if (req.url === '/health' && req.method === 'GET') {
json(res, 200, {
ok: true,
activeJobs: Array.from(jobs.values()).filter((job) => !job.done).length,
streams: streams.size,
});
return;
}
if (req.url === '/v1/monitor/start' && req.method === 'POST') {
try {
const body = await readJSON(req);
const jobID = String(body.jobID || '').trim();
const sessionID = String(body.sessionID || '').trim();
const opencodeBaseURL = String(body.opencodeBaseURL || '').trim().replace(/\/+$/, '');
const relayBaseURL = String(body.relayBaseURL || '').trim().replace(/\/+$/, '');
const expoPushToken = String(body.expoPushToken || '').trim();
if (!jobID || !sessionID || !opencodeBaseURL || !expoPushToken) {
json(res, 400, { ok: false, error: 'Missing required fields' });
return;
}
jobs.set(jobID, {
jobID,
sessionID,
opencodeBaseURL,
relayBaseURL,
expoPushToken,
createdAt: Date.now(),
done: false,
});
runStream(opencodeBaseURL).catch((error) => {
console.warn('[relay] runStream failed:', error instanceof Error ? error.message : String(error));
});
json(res, 200, { ok: true });
return;
} catch (error) {
json(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) });
return;
}
}
if (req.url === '/v1/monitor/stop' && req.method === 'POST') {
try {
const body = await readJSON(req);
const jobID = String(body.jobID || '').trim();
const token = String(body.expoPushToken || '').trim();
if (!jobID || !token) {
json(res, 400, { ok: false, error: 'Missing required fields' });
return;
}
const job = jobs.get(jobID);
if (job && job.expoPushToken === token) {
job.done = true;
cleanupStreamIfUnused(job.opencodeBaseURL);
}
json(res, 200, { ok: true });
return;
} catch (error) {
json(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) });
return;
}
}
json(res, 404, { ok: false, error: 'Not found' });
});
server.listen(PORT, HOST, () => {
console.log(`[relay] listening on http://${HOST}:${PORT}`);
});

View File

@@ -0,0 +1,114 @@
#!/usr/bin/env node
/**
* This script is used to reset the project to a blank state.
* It deletes or moves the /src and /scripts directories to /example based on user input and creates a new /src/app directory with an index.tsx and _layout.tsx file.
* You can remove the `reset-project` script from package.json and safely delete this file after running it.
*/
const fs = require("fs");
const path = require("path");
const readline = require("readline");
const root = process.cwd();
const oldDirs = ["src", "scripts"];
const exampleDir = "example";
const newAppDir = "src/app";
const exampleDirPath = path.join(root, exampleDir);
const indexContent = `import { Text, View, StyleSheet } from "react-native";
export default function Index() {
return (
<View style={styles.container}>
<Text>Edit src/app/index.tsx to edit this screen.</Text>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: "center",
justifyContent: "center",
},
});
`;
const layoutContent = `import { Stack } from "expo-router";
export default function RootLayout() {
return <Stack />;
}
`;
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const moveDirectories = async (userInput) => {
try {
if (userInput === "y") {
// Create the app-example directory
await fs.promises.mkdir(exampleDirPath, { recursive: true });
console.log(`📁 /${exampleDir} directory created.`);
}
// Move old directories to new app-example directory or delete them
for (const dir of oldDirs) {
const oldDirPath = path.join(root, dir);
if (fs.existsSync(oldDirPath)) {
if (userInput === "y") {
const newDirPath = path.join(root, exampleDir, dir);
await fs.promises.rename(oldDirPath, newDirPath);
console.log(`➡️ /${dir} moved to /${exampleDir}/${dir}.`);
} else {
await fs.promises.rm(oldDirPath, { recursive: true, force: true });
console.log(`❌ /${dir} deleted.`);
}
} else {
console.log(`➡️ /${dir} does not exist, skipping.`);
}
}
// Create new /src/app directory
const newAppDirPath = path.join(root, newAppDir);
await fs.promises.mkdir(newAppDirPath, { recursive: true });
console.log("\n📁 New /src/app directory created.");
// Create index.tsx
const indexPath = path.join(newAppDirPath, "index.tsx");
await fs.promises.writeFile(indexPath, indexContent);
console.log("📄 src/app/index.tsx created.");
// Create _layout.tsx
const layoutPath = path.join(newAppDirPath, "_layout.tsx");
await fs.promises.writeFile(layoutPath, layoutContent);
console.log("📄 src/app/_layout.tsx created.");
console.log("\n✅ Project reset complete. Next steps:");
console.log(
`1. Run \`npx expo start\` to start a development server.\n2. Edit src/app/index.tsx to edit the main screen.\n3. Put all your application code in /src, only screens and layout files should be in /src/app.${
userInput === "y"
? `\n4. Delete the /${exampleDir} directory when you're done referencing it.`
: ""
}`
);
} catch (error) {
console.error(`❌ Error during script execution: ${error.message}`);
}
};
rl.question(
"Do you want to move existing files to /example instead of deleting them? (Y/n): ",
(answer) => {
const userInput = answer.trim().toLowerCase() || "y";
if (userInput === "y" || userInput === "n") {
moveDirectories(userInput).finally(() => rl.close());
} else {
console.log("❌ Invalid input. Please enter 'Y' or 'N'.");
rl.close();
}
}
);

View File

@@ -0,0 +1,22 @@
import React from "react"
import { Slot } from "expo-router"
import { LogBox } from "react-native"
import {
configureNotificationBehavior,
registerBackgroundNotificationTask,
} from "@/notifications/monitoring-notifications"
// Suppress known non-actionable warnings from third-party libs.
LogBox.ignoreLogs([
"RecordingNotificationManager is not implemented on iOS",
"`transcribeRealtime` is deprecated, use `RealtimeTranscriber` instead",
"Parsed error meta:",
"Session activation failed",
])
configureNotificationBehavior()
registerBackgroundNotificationTask().catch(() => {})
export default function RootLayout() {
return <Slot />
}

File diff suppressed because it is too large Load Diff

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