Compare commits

...

133 Commits

Author SHA1 Message Date
LukeParkerDev
2b3d027e3b fix: resolve compiled binary crashes from circular dep and Bun CJS splitting bug 2026-04-20 16:19:44 +10:00
Brendan Allan
ce209e22a2 Merge branch 'dev' into opencode-remote-voice 2026-04-19 22:03:59 +08:00
opencode-agent[bot]
33b2795cc8 chore: generate 2026-04-19 09:47:36 +00:00
Luke Parker
10bd044c55 feat: add terminal font settings and built-in Nerd Font (#23391) 2026-04-19 19:46:34 +10:00
opencode
c09bcfe531 sync release versions for v1.14.18 2026-04-19 09:36:56 +00:00
Brendan Allan
83227be0ca fix(version): remove --target flag from beta release creation (#23403) 2026-04-19 17:05:03 +08:00
opencode-agent[bot]
8ee47a0533 chore: update nix node_modules hashes 2026-04-19 08:29:51 +00:00
Brendan Allan
a546e88f37 fix(desktop-electron): run JSON migration before spawning sidecar (#23396) 2026-04-19 15:53:47 +08:00
opencode-agent[bot]
e998c9e9cb chore: update nix node_modules hashes 2026-04-19 07:35:27 +00:00
Shoubhit Dash
889087c966 fix(ripgrep): restore native rg backend (#22773)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-04-19 06:58:15 +00:00
opencode-agent[bot]
7f3b64c7c4 chore: update nix node_modules hashes 2026-04-19 06:38:10 +00:00
Dax
e60a6e3a82 fix: change Free download button text to Download (#23388) 2026-04-19 02:19:40 -04:00
opencode-agent[bot]
135c8f0e99 chore: generate 2026-04-19 05:59:31 +00:00
opencode-agent[bot]
f02504bb80 chore: generate 2026-04-19 05:58:31 +00:00
Dax Raad
40834fdf2f core: allow users with credits but no payment method to access zen mode 2026-04-19 01:57:16 -04:00
Aiden Cline
fc0588954b fix (#23385) 2026-04-19 00:45:44 -05:00
opencode-agent[bot]
75960e3bf3 chore: generate 2026-04-19 04:25:23 +00:00
Ariane Emory
f14ac472a3 docs: document --dangerously-skip-permissions CLI flag (#23371) 2026-04-18 23:24:23 -05:00
opencode-agent[bot]
9ed93715ef chore: update nix node_modules hashes 2026-04-19 03:40:53 +00:00
Luke Parker
b34ca44abe fix incorrect config directory by lazily loading electron-store (#23373) 2026-04-19 13:06:07 +10:00
opencode
40ba8f3570 sync release versions for v1.14.17 2026-04-19 03:02:14 +00:00
Luke Parker
e543acf923 chore: bump electron and fix taskbar icon (#23368) 2026-04-19 02:35:02 +00:00
Dax Raad
d183568644 core: ensure executable permissions are set before Docker builds
Fixes an issue where GitHub artifact downloads could strip executable bits
from binaries, causing Docker builds to fail when using unpacked dist files
directly rather than published tarballs. The chmod now runs before the
publish check to guarantee binaries are executable.
2026-04-18 22:32:53 -04:00
Dax Raad
f27eb8f09e fix plugins reinstalling too often 2026-04-18 20:02:24 -04:00
Dax Raad
ad0545335a ci 2026-04-18 19:29:21 -04:00
Dax Raad
cfbbae7323 ci 2026-04-18 18:59:44 -04:00
Dax Raad
940f971ca0 ci: fix 2026-04-18 18:56:14 -04:00
Aiden Cline
78ca49a1bc test: fix bedrock test (#23351) 2026-04-18 17:46:15 -05:00
Ryan Vogel
1d54b0e540 Stefan/enterprise forms waitlist (#23158)
Co-authored-by: Ryan Vogel <me@ryan.ceo>
2026-04-18 18:30:28 -04:00
opencode-agent[bot]
7e971d8302 chore: generate 2026-04-18 21:37:45 +00:00
Frank
54b3b3fe05 zen: redeem go 2026-04-18 17:33:28 -04:00
Frank
9d012b0621 zen: redeem credit 2026-04-18 17:33:28 -04:00
opencode-agent[bot]
fbb0a93e12 chore: update nix node_modules hashes 2026-04-18 21:32:47 +00:00
Aiden Cline
e2e7a8d722 fix: ensure display: summarized is sent by default for bedrock (#23343) 2026-04-18 16:04:00 -05:00
Aiden Cline
ce7923adaf chore: bump @ai-sdk/amazon-bedrock (#23341) 2026-04-18 16:00:46 -05:00
Dax
a26d53151b tui: allow full-session forks from the session dialog (#23339) 2026-04-18 20:20:23 +00:00
Dax Raad
5eaef6b758 release: avoid package.json drift during publish 2026-04-18 12:32:23 -04:00
opencode-agent[bot]
c5c38cad9c chore: generate 2026-04-18 16:00:01 +00:00
Kit Langton
9918f389e7 fix: detect attachment mime from file contents (#23291) 2026-04-18 11:59:08 -04:00
opencode-agent[bot]
dd8c424806 chore: generate 2026-04-18 15:21:48 +00:00
Dax Raad
078d8a07cf core: support OTEL_RESOURCE_ATTRIBUTES environment variable for custom telemetry attributes
Users can now pass custom OpenTelemetry resource attributes via the OTEL_RESOURCE_ATTRIBUTES environment variable (comma-separated key=value format). These attributes are automatically included in all telemetry data sent from both the main process and workspace environments, enabling better observability integration with existing monitoring systems that rely on custom resource tags.
2026-04-18 11:20:29 -04:00
Dax Raad
1ee712e549 core: fix early return when node_modules is missing during package install 2026-04-18 10:42:33 -04:00
Dax Raad
55315bdffa tui: fix sync loading indicator to properly show loading state on startup 2026-04-18 10:39:10 -04:00
Dax Raad
882b8e1e75 core: track retry attempts with detailed error context on assistant entries
users can now see when transient failures occur during assistant responses,
such as rate limits or provider overloads, giving visibility into what
issues were encountered and automatically resolved before the final response
2026-04-18 10:38:35 -04:00
opencode-agent[bot]
95edbc0ae6 chore: generate 2026-04-18 05:49:37 +00:00
Dax Raad
11cd4fb639 core: extract session entry stepping logic into dedicated module
Move the step function from session-entry.ts to session-entry-stepper.ts and remove immer dependency. Add static fromEvent factory methods to Synthetic, Assistant, and Compaction classes for cleaner event-to-entry conversion.
2026-04-18 01:48:21 -04:00
Aiden Cline
9c16bd1e30 fix: make skills logic more token efficient (#23253) 2026-04-17 23:51:16 -05:00
opencode-agent[bot]
5e9d5c734e chore: generate 2026-04-18 03:52:28 +00:00
Kit Langton
b382d1a467 docs(effect): track schema migration progress with concrete file checklists (#23242) 2026-04-18 03:51:30 +00:00
Kit Langton
23f31475e7 refactor(config): migrate config.ts root Info to Effect Schema (#23241) 2026-04-18 03:44:35 +00:00
OpeOginni
c0eab9e442 fix(desktop): adjust ui tool diff sticky header offset (#23149) 2026-04-18 11:31:38 +08:00
opencode-agent[bot]
8a1e85d0c8 chore: generate 2026-04-18 03:17:28 +00:00
Kit Langton
2793502db2 refactor(config): migrate agent.ts Info to Effect Schema (#23237) 2026-04-18 03:16:24 +00:00
opencode-agent[bot]
9f7bd0246c chore: generate 2026-04-18 03:05:59 +00:00
Kit Langton
a6a4350d10 refactor(config): migrate permission.ts Info to Effect Schema (#23231) 2026-04-18 03:05:06 +00:00
Kit Langton
471b9f4dc4 refactor: use InstanceState context in worktree cleanup paths (#23019) 2026-04-17 23:04:16 -04:00
Kit Langton
24fb9b1296 fix: stop rewriting dev during release publish (#22982) 2026-04-18 02:53:19 +00:00
Kit Langton
3573019916 fix(generate): make openapi output deterministic by formatting in-place (#23228) 2026-04-17 22:31:21 -04:00
Kit Langton
fc5b353144 refactor(config): migrate keybinds.ts to Effect Schema (#23227) 2026-04-18 02:28:45 +00:00
Kit Langton
1dd257b76a refactor: use instance state in small services (#23022) 2026-04-18 02:16:15 +00:00
Ryan Vogel
56fa267e09 Merge branch 'dev' into opencode-remote-voice 2026-04-17 19:09:34 -04:00
Kit Langton
2b73a08916 feat(tui): show session ID in sidebar on non-prod channels (#23185) 2026-04-17 22:47:48 +00:00
Ryan Vogel
b0190116a7 fix: restore status idle set in processor error handler to fix unit tests 2026-04-17 22:44:51 +00:00
Kit Langton
11c0ad24aa feat(server): auto-tag route spans with route params (session.id, message.id, …) (#23189) 2026-04-17 22:43:10 +00:00
Ryan Vogel
4cfe8a8bf8 Merge remote-tracking branch 'origin/dev' into opencode-remote-voice
# Conflicts:
#	packages/opencode/src/server/routes/instance/experimental.ts
#	packages/opencode/src/session/processor.ts
#	packages/opencode/src/session/run-state.ts
#	packages/opencode/src/session/status.ts
2026-04-17 22:38:23 +00:00
Ryan Vogel
754951bbbd feat: persist APN relay secret across restarts, add dev logging and server identification to pair UI
- Electron desktop: auto-start PushRelay with secret persisted in electron-store
- CLI serve (Tauri): persist relay secret to Global.Path.state/relay-secret (mode 0600)
- Pair endpoint now returns relayURL, serverID, relaySecretHash for debugging
- Desktop settings-pair component shows server name, relay URL, and secret hash above QR
- Add console.debug logging for pairing fetch lifecycle
- Export PushRelay from node.ts entry point for Electron consumption
2026-04-17 22:31:02 +00:00
Ryan Vogel
38d4d03ba8 update to new app icon and new app.json 2026-04-17 18:44:40 +00:00
Ryan Vogel
162da4bd97 fix: update imports after dev namespace reorganization
- Log: @/util/log -> @/util
- Project: ../../project/project -> ../../project
- Database.use -> use (direct export from @/storage/db)
2026-04-16 13:39:25 +00:00
Ryan Vogel
18563363da fix: resolve merge conflicts with dev in serve.ts and status.ts
serve.ts: keep push relay additions (imports, types, helpers)
status.ts: use consolidated @/effect import path from dev, keep Log import
2026-04-16 13:35:57 +00:00
Ryan Vogel
d18891523d fix(push-relay): update Session.get to use direct DB access after Effect migration 2026-04-16 01:05:37 +00:00
Ryan Vogel
bda7a488d4 Merge remote-tracking branch 'origin/dev' into opencode-remote-voice 2026-04-16 01:03:03 +00:00
Ryan Vogel
bde80e24e5 chore(mobile-voice): update app icon to control-icon 2026-04-16 00:31:30 +00:00
Ryan Vogel
0114a3f317 Merge remote-tracking branch 'origin/dev' into opencode-remote-voice 2026-04-12 21:34:21 +00:00
Ryan Vogel
a4d4a3f545 fix(pairing): shrink mobile QR payload 2026-04-12 21:17:36 +00:00
Ryan Vogel
e8b4eb8972 fix(mobile-voice): restore camera and discovery in dev builds 2026-04-08 21:16:53 +00:00
Ryan Vogel
7c18b95826 feat(opencode): add mobile pairing dialogs 2026-04-08 21:01:32 +00:00
Ryan Vogel
381afd6e10 fix(opencode): remove redundant push pair QR endpoint 2026-04-08 20:01:23 +00:00
Ryan Vogel
a8d5b14d1a update for beta 2026-04-08 19:51:31 +00:00
Ryan Vogel
39b4f861f3 ui: polish mobile voice app for 1.0.2
Tighten the dictation UI and Whisper model settings, update the mobile package metadata, and remove the stale npm lockfile so Bun stays the source of truth for builds.
2026-04-08 19:12:57 +00:00
Ryan Vogel
13f89d5aa5 fix(opencode): delay transient APN permission notifications 2026-04-08 18:23:25 +00:00
Ryan Vogel
f29cb81c7d feat(opencode): add observability logging to push relay and session status
Add structured logging to the push notification pipeline so events
can be traced end-to-end when --print-logs is enabled:
- push-relay map(): log every classification decision and skip reason
- push-relay dedupe(): log suppressed duplicates with elapsed time
- push-relay start()/stop(): log init failures and teardown
- session/status: log every idle/busy/retry transition
2026-04-08 15:30:58 +00:00
Ryan Vogel
d034a61635 fix: add mobile app version hint to QR code scan prompt 2026-04-08 14:19:50 +00:00
Ryan Vogel
2eca96c80f Merge remote-tracking branch 'origin/dev' into opencode-remote-voice 2026-04-08 14:17:23 +00:00
Ryan Vogel
057208e022 fix: prevent spurious session-complete notifications from APN relay
Remove message.updated -> complete classification in the mobile foreground
SSE monitor. The processor cleanup stamps time.completed on every assistant
message after each LLM step, not just at session end, causing premature
complete events. The sole reliable signal is session.status with type idle.

Remove the redundant status.set(idle) from the processor halt() path. The
Runner onIdle callback already transitions to idle when the loop exits,
so the explicit set in halt was firing a duplicate complete notification
alongside the error notification.
2026-04-04 19:43:50 +00:00
Ryan Vogel
6db0f9855c fix(mobile-voice): monitoring robustness, neutral prompt history colors, swipe hint fade, filter subagents
- SSE recovery: retry syncSessionState on stream failure or natural close
- Periodic 20s polling fallback while monitorJob is active in foreground
- syncSessionState retries after 5s when runtime status fetch fails
- isSending 5s safety timeout to prevent permanent send block
- Prompt history and swipe hint colors changed to neutral grays
- Swipe hint fades out/in inversely with waveform visibility
- Session list excludes subagent sessions via roots=true API param
2026-04-04 19:43:24 +00:00
Ryan Vogel
e0894d7b63 feat(mobile-voice): add swipeable prompt history pager in transcription panel
Swipe right-to-left on the transcription area to browse previous prompts
sent in the current session. Includes haptic feedback on page snap,
auto-snap to live page on record start, and layout-measured page width
for correct alignment.
2026-04-04 18:59:02 +00:00
Ryan Vogel
cd3a58a4c2 Merge remote-tracking branch 'origin/dev' into opencode-remote-voice
# Conflicts:
#	bun.lock
2026-04-03 13:26:01 +00:00
Ryan Vogel
a5614f988f fix: unblock mobile EAS installs with Bun
Refresh the workspace lockfile and pin EAS to Bun 1.3.11 so frozen installs match local resolution and development builds stop failing in CI.
2026-04-03 13:18:13 +00:00
Ryan Vogel
0f58efe030 fix: advertise MagicDNS hosts for Tailscale pairing
Prefer .ts.net names and allow Tailscale CGNAT HTTP on iOS so mobile pairing avoids ATS-blocked raw tailnet IPs.
2026-04-03 13:02:58 +00:00
Ryan Vogel
4abb464345 feat: refine mobile voice reader interactions
Make Reader open and close feel like one continuous control instead of a view swap. Render assistant markdown consistently in both the compact card and Reader so formatted responses stay readable in either mode.
2026-04-02 20:38:49 +00:00
Ryan Vogel
d1d3d420bf fix: suppress subagent APN completion and error events 2026-04-02 20:11:12 +00:00
Ryan Vogel
1d68cd288c fix: resolve dev merge conflicts in opencode deps 2026-04-02 19:41:33 +00:00
Ryan Vogel
0b8f8bc196 fix: reduce noisy push relay notifications
Only send completion pushes from session.status idle and suppress aborted/overflow errors. Avoid emitting redundant idle state on no-op cancel so users don't get duplicate notifications.
2026-04-02 15:48:02 +00:00
Ryan Vogel
4d30ad1e7c feat: enhance mobile voice build and UI layout
- Updated AGENTS.md with new build commands for development and production.
- Added a new script in package.json for starting Expo with a specific hostname.
- Improved layout handling in DictationScreen by adding dynamic height calculations for dropdown menus and footers.
- Introduced new state variables to manage menu list heights and footer heights for better UI responsiveness.
- Enhanced QR code generation for mobile pairing in the serve command, allowing for a connect QR option without starting the server.
2026-04-02 13:01:17 +00:00
Ryan Vogel
c90640e0e1 feat: expose push pairing QR endpoints
Return both JSON metadata and a PNG QR so clients can consume mobile pairing without rebuilding the payload themselves.
2026-04-02 13:00:08 +00:00
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
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
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
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
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
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
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
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
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
243 changed files with 20902 additions and 2411 deletions

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

@@ -1,12 +1,8 @@
- 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
@@ -56,48 +52,48 @@ else foo = 2
### 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.

1610
bun.lock

File diff suppressed because it is too large Load Diff

0
eas.json Normal file
View File

View File

@@ -236,7 +236,6 @@ new sst.cloudflare.x.SolidStart("Console", {
SALESFORCE_INSTANCE_URL,
ZEN_BLACK_PRICE,
ZEN_LITE_PRICE,
new sst.Secret("ZEN_LITE_COUPON_FIRST_MONTH_100_INVITEES"),
new sst.Secret("ZEN_LIMITS"),
new sst.Secret("ZEN_SESSION_SECRET"),
...ZEN_MODELS,

View File

@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-GjpBQhvGLTM6NWX29b/mS+KjrQPl0w9VjQHH5jaK9SM=",
"aarch64-linux": "sha256-F5h9p+iZ8CASdUYaYR7O22NwBRa/iT+ZinUxO8lbPTc=",
"aarch64-darwin": "sha256-jWo5yvCtjVKRf9i5XUcTTaLtj2+G6+T1Td2llO/cT5I=",
"x86_64-darwin": "sha256-LzV+5/8P2mkiFHmt+a8zDeJjRbU8z9nssSA4tzv1HxA="
"x86_64-linux": "sha256-i9TxYwWkJAR+kW6pbvhgQbRW9UYPtdrPQAGic4zPoa4=",
"aarch64-linux": "sha256-RYc/OYlETXUwkWBRDas+/P4cBW6zde4FqxxnMARu5vs=",
"aarch64-darwin": "sha256-jIhUOIRIQEa2WT62TVIedmRIhl/edhK8sbiAFvU3yCM=",
"x86_64-darwin": "sha256-xLGzaX7OofFlZzVgpORJR5QXD2u+54hp+t3cCfUtO84="
}
}

View File

@@ -7,6 +7,7 @@
sysctl,
makeBinaryWrapper,
models-dev,
ripgrep,
installShellFiles,
versionCheckHook,
writableTmpDirAsHomeHook,
@@ -51,25 +52,25 @@ stdenvNoCC.mkDerivation (finalAttrs: {
runHook postBuild
'';
installPhase =
''
runHook preInstall
installPhase = ''
runHook preInstall
install -Dm755 dist/opencode-*/bin/opencode $out/bin/opencode
install -Dm644 schema.json $out/share/opencode/schema.json
''
# bun runs sysctl to detect if dunning on rosetta2
+ lib.optionalString stdenvNoCC.hostPlatform.isDarwin ''
wrapProgram $out/bin/opencode \
--prefix PATH : ${
lib.makeBinPath [
sysctl
install -Dm755 dist/opencode-*/bin/opencode $out/bin/opencode
install -Dm644 schema.json $out/share/opencode/schema.json
wrapProgram $out/bin/opencode \
--prefix PATH : ${
lib.makeBinPath (
[
ripgrep
]
}
''
+ ''
runHook postInstall
'';
# bun runs sysctl to detect if dunning on rosetta2
++ lib.optional stdenvNoCC.hostPlatform.isDarwin sysctl
)
}
runHook postInstall
'';
postInstall = lib.optionalString (stdenvNoCC.buildPlatform.canExecute stdenvNoCC.hostPlatform) ''
# trick yargs into also generating zsh completions

View File

@@ -7,7 +7,7 @@
"packageManager": "bun@1.3.11",
"scripts": {
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
"dev:desktop": "bun --cwd packages/desktop tauri dev",
"dev:desktop": "bun --cwd packages/desktop-electron dev",
"dev:web": "bun --cwd packages/app dev",
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
"dev:storybook": "bun --cwd packages/storybook storybook",

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

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

View File

@@ -8,14 +8,20 @@ import { SettingsGeneral } from "./settings-general"
import { SettingsKeybinds } from "./settings-keybinds"
import { SettingsProviders } from "./settings-providers"
import { SettingsModels } from "./settings-models"
import { SettingsPair } from "./settings-pair"
export const DialogSettings: Component = () => {
export const DialogSettings: Component<{ defaultTab?: string }> = (props) => {
const language = useLanguage()
const platform = usePlatform()
return (
<Dialog size="x-large" transition>
<Tabs orientation="vertical" variant="settings" defaultValue="general" class="h-full settings-dialog">
<Tabs
orientation="vertical"
variant="settings"
defaultValue={props.defaultTab ?? "general"}
class="h-full settings-dialog"
>
<Tabs.List>
<div class="flex flex-col justify-between h-full w-full">
<div class="flex flex-col gap-3 w-full pt-3">
@@ -45,6 +51,10 @@ export const DialogSettings: Component = () => {
<Icon name="models" />
{language.t("settings.models.title")}
</Tabs.Trigger>
<Tabs.Trigger value="pair">
<Icon name="link" />
{language.t("settings.pair.title")}
</Tabs.Trigger>
</div>
</div>
</div>
@@ -67,6 +77,9 @@ export const DialogSettings: Component = () => {
<Tabs.Content value="models" class="no-scrollbar">
<SettingsModels />
</Tabs.Content>
<Tabs.Content value="pair" class="no-scrollbar">
<SettingsPair />
</Tabs.Content>
</Tabs>
</Dialog>
)

View File

@@ -19,6 +19,9 @@ import {
sansDefault,
sansFontFamily,
sansInput,
terminalDefault,
terminalFontFamily,
terminalInput,
useSettings,
} from "@/context/settings"
import { decode64 } from "@/utils/base64"
@@ -181,6 +184,7 @@ export const SettingsGeneral: Component = () => {
const soundOptions = [noneSound, ...SOUND_OPTIONS]
const mono = () => monoInput(settings.appearance.font())
const sans = () => sansInput(settings.appearance.uiFont())
const terminal = () => terminalInput(settings.appearance.terminalFont())
const soundSelectProps = (
enabled: () => boolean,
@@ -451,6 +455,29 @@ export const SettingsGeneral: Component = () => {
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.terminalFont.title")}
description={language.t("settings.general.row.terminalFont.description")}
>
<div class="w-full sm:w-[220px]">
<TextField
data-action="settings-terminal-font"
label={language.t("settings.general.row.terminalFont.title")}
hideLabel
type="text"
value={terminal()}
onChange={(value) => settings.appearance.setTerminalFont(value)}
placeholder={terminalDefault}
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
class="text-12-regular"
style={{ "font-family": terminalFontFamily(settings.appearance.terminalFont()) }}
/>
</div>
</SettingsRow>
</SettingsList>
</div>
)

View File

@@ -0,0 +1,166 @@
import { type Component, createResource, Show } from "solid-js"
import { Icon } from "@opencode-ai/ui/icon"
import { useLanguage } from "@/context/language"
import { useGlobalSDK } from "@/context/global-sdk"
import { useServer } from "@/context/server"
import { usePlatform } from "@/context/platform"
import { SettingsList } from "./settings-list"
type PairResult =
| { enabled: false }
| {
enabled: true
hosts: string[]
relayURL?: string
serverID?: string
relaySecretHash?: string
link: string
qr: string
}
export const SettingsPair: Component = () => {
const language = useLanguage()
const globalSDK = useGlobalSDK()
const server = useServer()
const platform = usePlatform()
const [data] = createResource(async () => {
const url = `${globalSDK.url}/experimental/push/pair`
console.debug("[settings-pair] fetching pair data", {
serverUrl: globalSDK.url,
serverName: server.name,
serverKey: server.key,
})
const f = platform.fetch ?? fetch
const res = await f(url)
if (!res.ok) {
console.debug("[settings-pair] pair endpoint returned non-ok", {
status: res.status,
serverUrl: globalSDK.url,
})
return { enabled: false as const }
}
const result = (await res.json()) as PairResult
console.debug("[settings-pair] pair data received", {
enabled: result.enabled,
serverUrl: globalSDK.url,
serverName: server.name,
...(result.enabled
? {
relayURL: result.relayURL,
serverID: result.serverID,
relaySecretHash: result.relaySecretHash,
hostCount: result.hosts.length,
hosts: result.hosts,
}
: {}),
})
return result
})
return (
<div class="flex flex-col gap-6 py-4 px-5">
<div class="flex flex-col gap-1">
<h2 class="text-16-semibold text-text-strong">{language.t("settings.pair.title")}</h2>
<p class="text-13-regular text-text-weak">{language.t("settings.pair.description")}</p>
</div>
<Show when={data.loading}>
<SettingsList>
<div class="flex items-center justify-center py-12">
<span class="text-14-regular text-text-weak">{language.t("settings.pair.loading")}</span>
</div>
</SettingsList>
</Show>
<Show when={data.error}>
<SettingsList>
<div class="flex flex-col items-center justify-center py-12 gap-3 text-center">
<Icon name="warning" size="large" />
<div class="flex flex-col gap-1">
<span class="text-14-medium text-text-strong">{language.t("settings.pair.error.title")}</span>
<span class="text-13-regular text-text-weak max-w-md">
{language.t("settings.pair.error.description")}
</span>
</div>
</div>
</SettingsList>
</Show>
<Show when={!data.loading && !data.error && data()}>
{(result) => (
<Show
when={result().enabled && result()}
fallback={
<SettingsList>
<div class="flex flex-col items-center justify-center py-12 gap-3 text-center">
<Icon name="link" size="large" />
<div class="flex flex-col gap-1">
<span class="text-14-medium text-text-strong">{language.t("settings.pair.disabled.title")}</span>
<span class="text-13-regular text-text-weak max-w-md">
{language.t("settings.pair.disabled.description")}
</span>
</div>
<code class="text-12-regular text-text-weak bg-surface-inset px-3 py-1.5 rounded mt-1">
opencode serve --relay-url &lt;url&gt; --relay-secret &lt;secret&gt;
</code>
</div>
</SettingsList>
}
>
{(pair) => {
const p = pair() as PairResult & { enabled: true }
return (
<SettingsList>
<div class="flex flex-col items-center py-8 gap-4">
<Show when={server.list.length > 1 || p.relayURL}>
<div class="flex flex-col gap-1.5 w-full max-w-sm text-left">
<div class="flex items-center gap-2">
<span class="text-12-medium text-text-weak shrink-0">
{language.t("settings.pair.server.label")}
</span>
<code class="text-12-regular text-text-default bg-surface-inset px-2 py-0.5 rounded truncate">
{server.name}
</code>
</div>
<Show when={p.relayURL}>
<div class="flex items-center gap-2">
<span class="text-12-medium text-text-weak shrink-0">
{language.t("settings.pair.relay.label")}
</span>
<code class="text-12-regular text-text-default bg-surface-inset px-2 py-0.5 rounded truncate">
{p.relayURL}
</code>
</div>
</Show>
<Show when={p.relaySecretHash}>
<div class="flex items-center gap-2">
<span class="text-12-medium text-text-weak shrink-0">
{language.t("settings.pair.secret.label")}
</span>
<code class="text-12-regular text-text-default bg-surface-inset px-2 py-0.5 rounded truncate">
{p.relaySecretHash}
</code>
</div>
</Show>
</div>
</Show>
<img src={p.qr} alt="Pairing QR code" class="w-64 h-64" />
<div class="flex flex-col gap-1 text-center max-w-sm">
<span class="text-14-medium text-text-strong">
{language.t("settings.pair.instructions.title")}
</span>
<span class="text-13-regular text-text-weak">
{language.t("settings.pair.instructions.description")}
</span>
</div>
</div>
</SettingsList>
)
}}
</Show>
)}
</Show>
</div>
)
}

View File

@@ -11,7 +11,7 @@ import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { useSDK } from "@/context/sdk"
import { useServer } from "@/context/server"
import { monoFontFamily, useSettings } from "@/context/settings"
import { terminalFontFamily, useSettings } from "@/context/settings"
import type { LocalPTY } from "@/context/terminal"
import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters"
import { terminalWriter } from "@/utils/terminal-writer"
@@ -300,7 +300,7 @@ export const Terminal = (props: TerminalProps) => {
})
createEffect(() => {
const font = monoFontFamily(settings.appearance.font())
const font = terminalFontFamily(settings.appearance.terminalFont())
if (!term) return
setOptionIfSupported(term, "fontFamily", font)
scheduleFit()
@@ -360,7 +360,7 @@ export const Terminal = (props: TerminalProps) => {
cols: restoreSize?.cols,
rows: restoreSize?.rows,
fontSize: 14,
fontFamily: monoFontFamily(settings.appearance.font()),
fontFamily: terminalFontFamily(settings.appearance.terminalFont()),
allowTransparency: false,
convertEol: false,
theme: terminalColors(),

View File

@@ -39,6 +39,7 @@ export interface Settings {
fontSize: number
mono: string
sans: string
terminal: string
}
keybinds: Record<string, string>
permissions: {
@@ -50,13 +51,17 @@ export interface Settings {
export const monoDefault = "System Mono"
export const sansDefault = "System Sans"
export const terminalDefault = "JetBrainsMono Nerd Font Mono"
const monoFallback =
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
const sansFallback = 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
const terminalFallback =
'"JetBrainsMono Nerd Font Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
const monoBase = monoFallback
const sansBase = sansFallback
const terminalBase = terminalFallback
function input(font: string | undefined) {
return font ?? ""
@@ -89,6 +94,14 @@ export function sansFontFamily(font: string | undefined) {
return stack(font, sansBase)
}
export function terminalInput(font: string | undefined) {
return input(font)
}
export function terminalFontFamily(font: string | undefined) {
return stack(font, terminalBase)
}
const defaultSettings: Settings = {
general: {
autoSave: true,
@@ -110,6 +123,7 @@ const defaultSettings: Settings = {
fontSize: 14,
mono: "",
sans: "",
terminal: "",
},
keybinds: {},
permissions: {
@@ -233,6 +247,10 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setUIFont(value: string) {
setStore("appearance", "sans", value.trim() ? value : "")
},
terminalFont: withFallback(() => store.appearance?.terminal, defaultSettings.appearance.terminal),
setTerminalFont(value: string) {
setStore("appearance", "terminal", value.trim() ? value : "")
},
},
keybinds: {
get: (action: string) => store.keybinds?.[action],

View File

@@ -565,7 +565,9 @@ export const dict = {
"settings.general.row.theme.title": "السمة",
"settings.general.row.theme.description": "تخصيص سمة OpenCode.",
"settings.general.row.font.title": "خط الكود",
"settings.general.row.font.description": "خصّص الخط المستخدم في كتل التعليمات البرمجية والطرفيات",
"settings.general.row.font.description": "خصّص الخط المستخدم في كتل التعليمات البرمجية",
"settings.general.row.terminalFont.title": "Terminal Font",
"settings.general.row.terminalFont.description": "Customise the font used in the terminal",
"settings.general.row.uiFont.title": "خط الواجهة",
"settings.general.row.uiFont.description": "خصّص الخط المستخدم في الواجهة بأكملها",
"settings.general.row.followup.title": "سلوك المتابعة",

View File

@@ -572,7 +572,9 @@ export const dict = {
"settings.general.row.theme.title": "Tema",
"settings.general.row.theme.description": "Personalize como o OpenCode é tematizado.",
"settings.general.row.font.title": "Fonte de código",
"settings.general.row.font.description": "Personalize a fonte usada em blocos de código e terminais",
"settings.general.row.font.description": "Personalize a fonte usada em blocos de código",
"settings.general.row.terminalFont.title": "Terminal Font",
"settings.general.row.terminalFont.description": "Customise the font used in the terminal",
"settings.general.row.uiFont.title": "Fonte da interface",
"settings.general.row.uiFont.description": "Personalize a fonte usada em toda a interface",
"settings.general.row.followup.title": "Comportamento de acompanhamento",

View File

@@ -637,7 +637,9 @@ export const dict = {
"settings.general.row.theme.title": "Tema",
"settings.general.row.theme.description": "Prilagodi temu OpenCode-a.",
"settings.general.row.font.title": "Font za kod",
"settings.general.row.font.description": "Prilagodi font koji se koristi u blokovima koda i terminalima",
"settings.general.row.font.description": "Prilagodi font koji se koristi u blokovima koda",
"settings.general.row.terminalFont.title": "Terminal Font",
"settings.general.row.terminalFont.description": "Customise the font used in the terminal",
"settings.general.row.uiFont.title": "UI font",
"settings.general.row.uiFont.description": "Prilagodi font koji se koristi u cijelom interfejsu",
"settings.general.row.followup.title": "Ponašanje nadovezivanja",

View File

@@ -632,7 +632,9 @@ export const dict = {
"settings.general.row.theme.title": "Tema",
"settings.general.row.theme.description": "Tilpas hvordan OpenCode er temabestemt.",
"settings.general.row.font.title": "Kode-skrifttype",
"settings.general.row.font.description": "Tilpas skrifttypen, der bruges i kodeblokke og terminaler",
"settings.general.row.font.description": "Tilpas skrifttypen, der bruges i kodeblokke",
"settings.general.row.terminalFont.title": "Terminal Font",
"settings.general.row.terminalFont.description": "Customise the font used in the terminal",
"settings.general.row.uiFont.title": "UI-skrifttype",
"settings.general.row.uiFont.description": "Tilpas skrifttypen, der bruges i hele brugerfladen",
"settings.general.row.followup.title": "Opfølgningsadfærd",

View File

@@ -582,7 +582,9 @@ export const dict = {
"settings.general.row.theme.title": "Thema",
"settings.general.row.theme.description": "Das Thema von OpenCode anpassen.",
"settings.general.row.font.title": "Code-Schriftart",
"settings.general.row.font.description": "Die in Codeblöcken und Terminals verwendete Schriftart anpassen",
"settings.general.row.font.description": "Die in Codeblöcken verwendete Schriftart anpassen",
"settings.general.row.terminalFont.title": "Terminal Font",
"settings.general.row.terminalFont.description": "Customise the font used in the terminal",
"settings.general.row.uiFont.title": "UI-Schriftart",
"settings.general.row.uiFont.description": "Die im gesamten Interface verwendete Schriftart anpassen",
"settings.general.row.followup.title": "Verhalten bei Folgefragen",

View File

@@ -28,6 +28,7 @@ export const dict = {
"command.provider.connect": "Connect provider",
"command.server.switch": "Switch server",
"command.settings.open": "Open settings",
"command.pair.show": "Pair mobile device",
"command.session.previous": "Previous session",
"command.session.next": "Next session",
"command.session.previous.unseen": "Previous unread session",
@@ -735,7 +736,9 @@ export const dict = {
"settings.general.row.theme.title": "Theme",
"settings.general.row.theme.description": "Customise how OpenCode is themed.",
"settings.general.row.font.title": "Code Font",
"settings.general.row.font.description": "Customise the font used in code blocks and terminals",
"settings.general.row.font.description": "Customise the font used in code blocks",
"settings.general.row.terminalFont.title": "Terminal Font",
"settings.general.row.terminalFont.description": "Customise the font used in the terminal",
"settings.general.row.uiFont.title": "UI Font",
"settings.general.row.uiFont.description": "Customise the font used throughout the interface",
"settings.general.row.followup.title": "Follow-up behavior",
@@ -868,6 +871,20 @@ export const dict = {
"settings.providers.tag.config": "Config",
"settings.providers.tag.custom": "Custom",
"settings.providers.tag.other": "Other",
"settings.pair.title": "Pair",
"settings.pair.description": "Pair a mobile device for push notifications.",
"settings.pair.loading": "Loading pairing info...",
"settings.pair.error.title": "Could not load pairing info",
"settings.pair.error.description": "Check that the server is reachable and try again.",
"settings.pair.disabled.title": "Push relay is not enabled",
"settings.pair.disabled.description": "Start the server with push relay options to enable mobile pairing.",
"settings.pair.server.label": "Server",
"settings.pair.relay.label": "Relay",
"settings.pair.secret.label": "Secret",
"settings.pair.instructions.title": "Scan with the OpenCode Control app",
"settings.pair.instructions.description":
"Open the OpenCode Control app and scan this QR code to pair your device for push notifications.",
"settings.models.title": "Models",
"settings.models.description": "Model settings will be configurable here.",
"settings.agents.title": "Agents",

View File

@@ -640,7 +640,9 @@ export const dict = {
"settings.general.row.theme.title": "Tema",
"settings.general.row.theme.description": "Personaliza el tema de OpenCode.",
"settings.general.row.font.title": "Fuente de código",
"settings.general.row.font.description": "Personaliza la fuente usada en bloques de código y terminales",
"settings.general.row.font.description": "Personaliza la fuente usada en bloques de código",
"settings.general.row.terminalFont.title": "Terminal Font",
"settings.general.row.terminalFont.description": "Customise the font used in the terminal",
"settings.general.row.uiFont.title": "Fuente de la interfaz",
"settings.general.row.uiFont.description": "Personaliza la fuente usada en toda la interfaz",
"settings.general.row.followup.title": "Comportamiento de seguimiento",

View File

@@ -579,7 +579,9 @@ export const dict = {
"settings.general.row.theme.title": "Thème",
"settings.general.row.theme.description": "Personnaliser le thème d'OpenCode.",
"settings.general.row.font.title": "Police de code",
"settings.general.row.font.description": "Personnaliser la police utilisée dans les blocs de code et les terminaux",
"settings.general.row.font.description": "Personnaliser la police utilisée dans les blocs de code",
"settings.general.row.terminalFont.title": "Terminal Font",
"settings.general.row.terminalFont.description": "Customise the font used in the terminal",
"settings.general.row.uiFont.title": "Police de l'interface",
"settings.general.row.uiFont.description": "Personnaliser la police utilisée dans toute l'interface",
"settings.general.row.followup.title": "Comportement de suivi",

View File

@@ -569,7 +569,9 @@ export const dict = {
"settings.general.row.theme.title": "テーマ",
"settings.general.row.theme.description": "OpenCodeのテーマをカスタマイズします。",
"settings.general.row.font.title": "コードフォント",
"settings.general.row.font.description": "コードブロックとターミナルで使用するフォントをカスタマイズします",
"settings.general.row.font.description": "コードブロックで使用するフォントをカスタマイズします",
"settings.general.row.terminalFont.title": "Terminal Font",
"settings.general.row.terminalFont.description": "Customise the font used in the terminal",
"settings.general.row.uiFont.title": "UIフォント",
"settings.general.row.uiFont.description": "インターフェース全体で使用するフォントをカスタマイズします",
"settings.general.row.followup.title": "フォローアップの動作",

View File

@@ -566,7 +566,9 @@ export const dict = {
"settings.general.row.theme.title": "테마",
"settings.general.row.theme.description": "OpenCode 테마 사용자 지정",
"settings.general.row.font.title": "코드 글꼴",
"settings.general.row.font.description": "코드 블록과 터미널에 사용되는 글꼴을 사용자 지정",
"settings.general.row.font.description": "코드 블록에 사용되는 글꼴을 사용자 지정",
"settings.general.row.terminalFont.title": "Terminal Font",
"settings.general.row.terminalFont.description": "Customise the font used in the terminal",
"settings.general.row.uiFont.title": "UI 글꼴",
"settings.general.row.uiFont.description": "인터페이스 전반에 사용되는 글꼴을 사용자 지정",
"settings.general.row.followup.title": "후속 조치 동작",

View File

@@ -640,7 +640,9 @@ export const dict = {
"settings.general.row.theme.title": "Tema",
"settings.general.row.theme.description": "Tilpass hvordan OpenCode er tematisert.",
"settings.general.row.font.title": "Kodefont",
"settings.general.row.font.description": "Tilpass skrifttypen som brukes i kodeblokker og terminaler",
"settings.general.row.font.description": "Tilpass skrifttypen som brukes i kodeblokker",
"settings.general.row.terminalFont.title": "Terminal Font",
"settings.general.row.terminalFont.description": "Customise the font used in the terminal",
"settings.general.row.uiFont.title": "UI-skrift",
"settings.general.row.uiFont.description": "Tilpass skrifttypen som brukes i hele grensesnittet",
"settings.general.row.followup.title": "Oppfølgingsadferd",

View File

@@ -571,7 +571,9 @@ export const dict = {
"settings.general.row.theme.title": "Motyw",
"settings.general.row.theme.description": "Dostosuj motyw OpenCode.",
"settings.general.row.font.title": "Czcionka kodu",
"settings.general.row.font.description": "Dostosuj czcionkę używaną w blokach kodu i terminalach",
"settings.general.row.font.description": "Dostosuj czcionkę używaną w blokach kodu",
"settings.general.row.terminalFont.title": "Terminal Font",
"settings.general.row.terminalFont.description": "Customise the font used in the terminal",
"settings.general.row.uiFont.title": "Czcionka interfejsu",
"settings.general.row.uiFont.description": "Dostosuj czcionkę używaną w całym interfejsie",
"settings.general.row.followup.title": "Zachowanie kontynuacji",

View File

@@ -637,7 +637,9 @@ export const dict = {
"settings.general.row.theme.title": "Тема",
"settings.general.row.theme.description": "Настройте оформление OpenCode.",
"settings.general.row.font.title": "Шрифт кода",
"settings.general.row.font.description": "Настройте шрифт, используемый в блоках кода и терминалах",
"settings.general.row.font.description": "Настройте шрифт, используемый в блоках кода",
"settings.general.row.terminalFont.title": "Terminal Font",
"settings.general.row.terminalFont.description": "Customise the font used in the terminal",
"settings.general.row.uiFont.title": "Шрифт интерфейса",
"settings.general.row.uiFont.description": "Настройте шрифт, используемый во всем интерфейсе",
"settings.general.row.followup.title": "Поведение уточняющих вопросов",

View File

@@ -631,7 +631,9 @@ export const dict = {
"settings.general.row.theme.title": "ธีม",
"settings.general.row.theme.description": "ปรับแต่งวิธีการที่ OpenCode มีธีม",
"settings.general.row.font.title": "ฟอนต์โค้ด",
"settings.general.row.font.description": "ปรับแต่งฟอนต์ที่ใช้ในบล็อกโค้ดและเทอร์มินัล",
"settings.general.row.font.description": "ปรับแต่งฟอนต์ที่ใช้ในบล็อกโค้ด",
"settings.general.row.terminalFont.title": "Terminal Font",
"settings.general.row.terminalFont.description": "Customise the font used in the terminal",
"settings.general.row.uiFont.title": "ฟอนต์ UI",
"settings.general.row.uiFont.description": "ปรับแต่งฟอนต์ที่ใช้ทั่วทั้งอินเทอร์เฟซ",
"settings.general.row.followup.title": "พฤติกรรมการติดตามผล",

View File

@@ -644,7 +644,9 @@ export const dict = {
"settings.general.row.theme.title": "Tema",
"settings.general.row.theme.description": "OpenCode'un temasını özelleştirin.",
"settings.general.row.font.title": "Kod Yazı Tipi",
"settings.general.row.font.description": "Kod bloklarında ve terminallerde kullanılan yazı tipini özelleştirin",
"settings.general.row.font.description": "Kod bloklarında kullanılan yazı tipini özelleştirin",
"settings.general.row.terminalFont.title": "Terminal Font",
"settings.general.row.terminalFont.description": "Customise the font used in the terminal",
"settings.general.row.uiFont.title": "Arayüz Yazı Tipi",
"settings.general.row.uiFont.description": "Arayüz genelinde kullanılan yazı tipini özelleştirin",
"settings.general.row.followup.title": "Takip davranışı",

View File

@@ -631,7 +631,9 @@ export const dict = {
"settings.general.row.theme.title": "主题",
"settings.general.row.theme.description": "自定义 OpenCode 的主题。",
"settings.general.row.font.title": "代码字体",
"settings.general.row.font.description": "自定义代码块和终端使用的字体",
"settings.general.row.font.description": "自定义代码块使用的字体",
"settings.general.row.terminalFont.title": "Terminal Font",
"settings.general.row.terminalFont.description": "Customise the font used in the terminal",
"settings.general.row.uiFont.title": "界面字体",
"settings.general.row.uiFont.description": "自定义整个界面使用的字体",
"settings.general.row.followup.title": "跟进消息行为",

View File

@@ -626,7 +626,9 @@ export const dict = {
"settings.general.row.theme.title": "主題",
"settings.general.row.theme.description": "自訂 OpenCode 的主題。",
"settings.general.row.font.title": "程式碼字型",
"settings.general.row.font.description": "自訂程式碼區塊和終端機使用的字型",
"settings.general.row.font.description": "自訂程式碼區塊使用的字型",
"settings.general.row.terminalFont.title": "Terminal Font",
"settings.general.row.terminalFont.description": "Customise the font used in the terminal",
"settings.general.row.uiFont.title": "介面字型",
"settings.general.row.uiFont.description": "自訂整個介面使用的字型",
"settings.general.row.followup.title": "後續追問行為",

View File

@@ -1,5 +1,12 @@
@import "@opencode-ai/ui/styles/tailwind";
@font-face {
font-family: "JetBrainsMono Nerd Font Mono";
src: url("/assets/JetBrainsMonoNerdFontMono-Regular.woff2") format("woff2");
font-weight: normal;
font-style: normal;
}
@layer components {
@keyframes session-progress-whip {
0% {

View File

@@ -1061,6 +1061,13 @@ export default function Layout(props: ParentProps) {
keybind: "mod+comma",
onSelect: () => openSettings(),
},
{
id: "pair.show",
title: language.t("command.pair.show"),
category: language.t("command.category.settings"),
slash: "pair",
onSelect: () => openSettings("pair"),
},
{
id: "session.previous",
title: language.t("command.session.previous"),
@@ -1213,11 +1220,11 @@ export default function Layout(props: ParentProps) {
})
}
function openSettings() {
function openSettings(defaultTab?: string) {
const run = ++dialogRun
void import("@/components/dialog-settings").then((x) => {
if (dialogDead || dialogRun !== run) return
dialog.show(() => <x.DialogSettings />)
dialog.show(() => <x.DialogSettings defaultTab={defaultTab} />)
})
}

View File

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

View File

@@ -11,7 +11,7 @@ export const dict = {
"nav.enterprise": "المؤسسات",
"nav.zen": "Zen",
"nav.login": "تسجيل الدخول",
"nav.free": "مجانا",
"nav.free": "تحميل",
"nav.home": "الرئيسية",
"nav.openMenu": "فتح القائمة",
"nav.getStartedFree": "ابدأ مجانا",
@@ -558,6 +558,13 @@ export const dict = {
"workspace.monthlyLimit.currentUsage.beforeMonth": "الاستخدام الحالي لـ",
"workspace.monthlyLimit.currentUsage.beforeAmount": "هو $",
"workspace.redeem.title": "استرداد قسيمة",
"workspace.redeem.subtitle": "استرد رمز القسيمة للحصول على رصيد أو مزايا.",
"workspace.redeem.placeholder": "أدخل رمز القسيمة",
"workspace.redeem.redeem": "استرداد",
"workspace.redeem.redeeming": "جارٍ الاسترداد...",
"workspace.redeem.success": "تم استرداد القسيمة بنجاح.",
"workspace.reload.title": "إعادة الشحن التلقائي",
"workspace.reload.disabled.before": "إعادة الشحن التلقائي",
"workspace.reload.disabled.state": "معطّل",

View File

@@ -11,7 +11,7 @@ export const dict = {
"nav.enterprise": "Enterprise",
"nav.zen": "Zen",
"nav.login": "Entrar",
"nav.free": "Grátis",
"nav.free": "Download",
"nav.home": "Início",
"nav.openMenu": "Abrir menu",
"nav.getStartedFree": "Começar grátis",
@@ -567,6 +567,13 @@ export const dict = {
"workspace.monthlyLimit.currentUsage.beforeMonth": "Uso atual para",
"workspace.monthlyLimit.currentUsage.beforeAmount": "é $",
"workspace.redeem.title": "Resgatar Cupom",
"workspace.redeem.subtitle": "Resgate um código de cupom para receber créditos ou vantagens.",
"workspace.redeem.placeholder": "Digite o código do cupom",
"workspace.redeem.redeem": "Resgatar",
"workspace.redeem.redeeming": "Resgatando...",
"workspace.redeem.success": "Cupom resgatado com sucesso.",
"workspace.reload.title": "Recarga Automática",
"workspace.reload.disabled.before": "A recarga automática está",
"workspace.reload.disabled.state": "desativada",

View File

@@ -11,7 +11,7 @@ export const dict = {
"nav.enterprise": "Enterprise",
"nav.zen": "Zen",
"nav.login": "Log ind",
"nav.free": "Gratis",
"nav.free": "Download",
"nav.home": "Hjem",
"nav.openMenu": "Åbn menu",
"nav.getStartedFree": "Kom i gang gratis",
@@ -563,6 +563,13 @@ export const dict = {
"workspace.monthlyLimit.currentUsage.beforeMonth": "Nuværende brug for",
"workspace.monthlyLimit.currentUsage.beforeAmount": "er $",
"workspace.redeem.title": "Indløs kupon",
"workspace.redeem.subtitle": "Indløs en kuponkode for at få kreditter eller fordele.",
"workspace.redeem.placeholder": "Indtast kuponkode",
"workspace.redeem.redeem": "Indløs",
"workspace.redeem.redeeming": "Indløser...",
"workspace.redeem.success": "Kuponen blev indløst.",
"workspace.reload.title": "Automatisk genopfyldning",
"workspace.reload.disabled.before": "Automatisk genopfyldning er",
"workspace.reload.disabled.state": "deaktiveret",

View File

@@ -11,7 +11,7 @@ export const dict = {
"nav.enterprise": "Enterprise",
"nav.zen": "Zen",
"nav.login": "Anmelden",
"nav.free": "Kostenlos",
"nav.free": "Download",
"nav.home": "Startseite",
"nav.openMenu": "Menü öffnen",
"nav.getStartedFree": "Kostenlos starten",
@@ -566,6 +566,13 @@ export const dict = {
"workspace.monthlyLimit.currentUsage.beforeMonth": "Aktuelle Nutzung für",
"workspace.monthlyLimit.currentUsage.beforeAmount": "ist $",
"workspace.redeem.title": "Gutschein einlösen",
"workspace.redeem.subtitle": "Löse einen Gutscheincode ein, um Guthaben oder Vorteile zu erhalten.",
"workspace.redeem.placeholder": "Gutscheincode eingeben",
"workspace.redeem.redeem": "Einlösen",
"workspace.redeem.redeeming": "Wird eingelöst...",
"workspace.redeem.success": "Gutschein erfolgreich eingelöst.",
"workspace.reload.title": "Auto-Reload",
"workspace.reload.disabled.before": "Auto-Reload ist",
"workspace.reload.disabled.state": "deaktiviert",

View File

@@ -8,7 +8,7 @@ export const dict = {
"nav.zen": "Zen",
"nav.go": "Go",
"nav.login": "Login",
"nav.free": "Free",
"nav.free": "Download",
"nav.home": "Home",
"nav.openMenu": "Open menu",
"nav.getStartedFree": "Get started for free",
@@ -559,6 +559,13 @@ export const dict = {
"workspace.monthlyLimit.currentUsage.beforeMonth": "Current usage for",
"workspace.monthlyLimit.currentUsage.beforeAmount": "is $",
"workspace.redeem.title": "Redeem Coupon",
"workspace.redeem.subtitle": "Redeem a coupon code to claim credits or perks.",
"workspace.redeem.placeholder": "Enter coupon code",
"workspace.redeem.redeem": "Redeem",
"workspace.redeem.redeeming": "Redeeming...",
"workspace.redeem.success": "Coupon redeemed successfully.",
"workspace.reload.title": "Auto Reload",
"workspace.reload.disabled.before": "Auto reload is",
"workspace.reload.disabled.state": "disabled",

View File

@@ -11,7 +11,7 @@ export const dict = {
"nav.enterprise": "Enterprise",
"nav.zen": "Zen",
"nav.login": "Iniciar sesión",
"nav.free": "Gratis",
"nav.free": "Descargar",
"nav.home": "Inicio",
"nav.openMenu": "Abrir menú",
"nav.getStartedFree": "Empezar gratis",
@@ -567,6 +567,13 @@ export const dict = {
"workspace.monthlyLimit.currentUsage.beforeMonth": "Uso actual para",
"workspace.monthlyLimit.currentUsage.beforeAmount": "es $",
"workspace.redeem.title": "Canjear cupón",
"workspace.redeem.subtitle": "Canjea un código de cupón para obtener crédito o beneficios.",
"workspace.redeem.placeholder": "Introduce el código del cupón",
"workspace.redeem.redeem": "Canjear",
"workspace.redeem.redeeming": "Canjeando...",
"workspace.redeem.success": "Cupón canjeado correctamente.",
"workspace.reload.title": "Auto Recarga",
"workspace.reload.disabled.before": "La auto recarga está",
"workspace.reload.disabled.state": "deshabilitada",

View File

@@ -12,7 +12,7 @@ export const dict = {
"nav.enterprise": "Entreprise",
"nav.zen": "Zen",
"nav.login": "Se connecter",
"nav.free": "Gratuit",
"nav.free": "Télécharger",
"nav.home": "Accueil",
"nav.openMenu": "Ouvrir le menu",
"nav.getStartedFree": "Commencer gratuitement",
@@ -569,6 +569,13 @@ export const dict = {
"workspace.monthlyLimit.currentUsage.beforeMonth": "L'utilisation actuelle pour",
"workspace.monthlyLimit.currentUsage.beforeAmount": "est de",
"workspace.redeem.title": "Utiliser un coupon",
"workspace.redeem.subtitle": "Utilisez un code promo pour obtenir du crédit ou des avantages.",
"workspace.redeem.placeholder": "Saisissez le code promo",
"workspace.redeem.redeem": "Utiliser",
"workspace.redeem.redeeming": "Utilisation...",
"workspace.redeem.success": "Coupon utilisé avec succès.",
"workspace.reload.title": "Rechargement automatique",
"workspace.reload.disabled.before": "Le rechargement automatique est",
"workspace.reload.disabled.state": "désactivé",

View File

@@ -11,7 +11,7 @@ export const dict = {
"nav.enterprise": "Enterprise",
"nav.zen": "Zen",
"nav.login": "Accedi",
"nav.free": "Gratis",
"nav.free": "Scarica",
"nav.home": "Home",
"nav.openMenu": "Apri menu",
"nav.getStartedFree": "Inizia gratis",
@@ -565,6 +565,13 @@ export const dict = {
"workspace.monthlyLimit.currentUsage.beforeMonth": "Utilizzo attuale per",
"workspace.monthlyLimit.currentUsage.beforeAmount": "è $",
"workspace.redeem.title": "Riscatta Coupon",
"workspace.redeem.subtitle": "Riscatta un codice coupon per ottenere credito o vantaggi.",
"workspace.redeem.placeholder": "Inserisci il codice coupon",
"workspace.redeem.redeem": "Riscatta",
"workspace.redeem.redeeming": "Riscatto in corso...",
"workspace.redeem.success": "Coupon riscattato con successo.",
"workspace.reload.title": "Ricarica Auto",
"workspace.reload.disabled.before": "La ricarica auto è",
"workspace.reload.disabled.state": "disabilitata",

View File

@@ -11,7 +11,7 @@ export const dict = {
"nav.enterprise": "エンタープライズ",
"nav.zen": "Zen",
"nav.login": "ログイン",
"nav.free": "無料",
"nav.free": "ダウンロード",
"nav.home": "ホーム",
"nav.openMenu": "メニューを開く",
"nav.getStartedFree": "無料ではじめる",
@@ -564,6 +564,13 @@ export const dict = {
"workspace.monthlyLimit.currentUsage.beforeMonth": "現在の使用状況(",
"workspace.monthlyLimit.currentUsage.beforeAmount": ")は $",
"workspace.redeem.title": "クーポンを利用",
"workspace.redeem.subtitle": "クーポンコードを利用して、クレジットや特典を受け取ります。",
"workspace.redeem.placeholder": "クーポンコードを入力",
"workspace.redeem.redeem": "利用する",
"workspace.redeem.redeeming": "利用中...",
"workspace.redeem.success": "クーポンを利用しました。",
"workspace.reload.title": "自動チャージ",
"workspace.reload.disabled.before": "自動チャージは",
"workspace.reload.disabled.state": "無効",

View File

@@ -11,7 +11,7 @@ export const dict = {
"nav.enterprise": "엔터프라이즈",
"nav.zen": "Zen",
"nav.login": "로그인",
"nav.free": "무료",
"nav.free": "다운로드",
"nav.home": "홈",
"nav.openMenu": "메뉴 열기",
"nav.getStartedFree": "무료로 시작하기",
@@ -558,6 +558,13 @@ export const dict = {
"workspace.monthlyLimit.currentUsage.beforeMonth": "현재",
"workspace.monthlyLimit.currentUsage.beforeAmount": "사용량: $",
"workspace.redeem.title": "쿠폰 사용",
"workspace.redeem.subtitle": "쿠폰 코드를 사용해 크레딧이나 혜택을 받으세요.",
"workspace.redeem.placeholder": "쿠폰 코드를 입력하세요",
"workspace.redeem.redeem": "사용",
"workspace.redeem.redeeming": "사용 중...",
"workspace.redeem.success": "쿠폰을 성공적으로 사용했습니다.",
"workspace.reload.title": "자동 충전",
"workspace.reload.disabled.before": "자동 충전이",
"workspace.reload.disabled.state": "비활성화",

View File

@@ -11,7 +11,7 @@ export const dict = {
"nav.enterprise": "Enterprise",
"nav.zen": "Zen",
"nav.login": "Logg inn",
"nav.free": "Gratis",
"nav.free": "Last ned",
"nav.home": "Hjem",
"nav.openMenu": "Åpne meny",
"nav.getStartedFree": "Kom i gang gratis",
@@ -564,6 +564,13 @@ export const dict = {
"workspace.monthlyLimit.currentUsage.beforeMonth": "Gjeldende forbruk for",
"workspace.monthlyLimit.currentUsage.beforeAmount": "er $",
"workspace.redeem.title": "Løs inn kupong",
"workspace.redeem.subtitle": "Løs inn en kupongkode for å få kreditt eller fordeler.",
"workspace.redeem.placeholder": "Skriv inn kupongkode",
"workspace.redeem.redeem": "Løs inn",
"workspace.redeem.redeeming": "Løser inn...",
"workspace.redeem.success": "Kupongen ble løst inn.",
"workspace.reload.title": "Auto-påfyll",
"workspace.reload.disabled.before": "Auto-påfyll er",
"workspace.reload.disabled.state": "deaktivert",

View File

@@ -10,7 +10,7 @@ export const dict = {
"nav.enterprise": "Enterprise",
"nav.zen": "Zen",
"nav.login": "Zaloguj się",
"nav.free": "Darmowe",
"nav.free": "Pobierz",
"nav.home": "Strona główna",
"nav.openMenu": "Otwórz menu",
"nav.getStartedFree": "Zacznij za darmo",
@@ -565,6 +565,13 @@ export const dict = {
"workspace.monthlyLimit.currentUsage.beforeMonth": "Aktualne użycie za",
"workspace.monthlyLimit.currentUsage.beforeAmount": "wynosi $",
"workspace.redeem.title": "Zrealizuj kupon",
"workspace.redeem.subtitle": "Zrealizuj kod kuponu, aby otrzymać środki lub korzyści.",
"workspace.redeem.placeholder": "Wpisz kod kuponu",
"workspace.redeem.redeem": "Zrealizuj",
"workspace.redeem.redeeming": "Realizowanie...",
"workspace.redeem.success": "Kupon został zrealizowany.",
"workspace.reload.title": "Automatyczne doładowanie",
"workspace.reload.disabled.before": "Automatyczne doładowanie jest",
"workspace.reload.disabled.state": "wyłączone",

View File

@@ -11,7 +11,7 @@ export const dict = {
"nav.enterprise": "Enterprise",
"nav.zen": "Zen",
"nav.login": "Войти",
"nav.free": "Бесплатно",
"nav.free": "Скачать",
"nav.home": "Главная",
"nav.openMenu": "Открыть меню",
"nav.getStartedFree": "Начать бесплатно",
@@ -571,6 +571,13 @@ export const dict = {
"workspace.monthlyLimit.currentUsage.beforeMonth": "Текущее использование за",
"workspace.monthlyLimit.currentUsage.beforeAmount": "составляет $",
"workspace.redeem.title": "Активировать купон",
"workspace.redeem.subtitle": "Активируйте код купона, чтобы получить кредит или бонусы.",
"workspace.redeem.placeholder": "Введите код купона",
"workspace.redeem.redeem": "Активировать",
"workspace.redeem.redeeming": "Активация...",
"workspace.redeem.success": "Купон успешно активирован.",
"workspace.reload.title": "Автопополнение",
"workspace.reload.disabled.before": "Автопополнение",
"workspace.reload.disabled.state": "отключено",

View File

@@ -11,7 +11,7 @@ export const dict = {
"nav.enterprise": "องค์กร",
"nav.zen": "Zen",
"nav.login": "เข้าสู่ระบบ",
"nav.free": "ฟรี",
"nav.free": "ดาวน์โหลด",
"nav.home": "หน้าหลัก",
"nav.openMenu": "เปิดเมนู",
"nav.getStartedFree": "เริ่มต้นฟรี",
@@ -560,6 +560,13 @@ export const dict = {
"workspace.monthlyLimit.currentUsage.beforeMonth": "การใช้งานปัจจุบันสำหรับ",
"workspace.monthlyLimit.currentUsage.beforeAmount": "คือ $",
"workspace.redeem.title": "แลกคูปอง",
"workspace.redeem.subtitle": "แลกรหัสคูปองเพื่อรับเครดิตหรือสิทธิพิเศษ",
"workspace.redeem.placeholder": "กรอกรหัสคูปอง",
"workspace.redeem.redeem": "แลก",
"workspace.redeem.redeeming": "กำลังแลก...",
"workspace.redeem.success": "แลกคูปองสำเร็จ",
"workspace.reload.title": "โหลดซ้ำอัตโนมัติ",
"workspace.reload.disabled.before": "การโหลดซ้ำอัตโนมัติ",
"workspace.reload.disabled.state": "ปิดใช้งานอยู่",

View File

@@ -11,7 +11,7 @@ export const dict = {
"nav.enterprise": "Kurumsal",
"nav.zen": "Zen",
"nav.login": "Giriş",
"nav.free": "Ücretsiz",
"nav.free": "İndir",
"nav.home": "Ana sayfa",
"nav.openMenu": "Menüyü aç",
"nav.getStartedFree": "Ücretsiz başla",
@@ -567,6 +567,13 @@ export const dict = {
"workspace.monthlyLimit.currentUsage.beforeMonth": "Şu anki kullanım",
"workspace.monthlyLimit.currentUsage.beforeAmount": "$",
"workspace.redeem.title": "Kupon Kullan",
"workspace.redeem.subtitle": "Kredi veya avantajlardan yararlanmak için bir kupon kodu kullanın.",
"workspace.redeem.placeholder": "Kupon kodunu girin",
"workspace.redeem.redeem": "Kullan",
"workspace.redeem.redeeming": "Kullanılıyor...",
"workspace.redeem.success": "Kupon başarıyla kullanıldı.",
"workspace.reload.title": "Otomatik Yeniden Yükleme",
"workspace.reload.disabled.before": "Otomatik yeniden yükleme:",
"workspace.reload.disabled.state": "devre dışı",

View File

@@ -11,7 +11,7 @@ export const dict = {
"nav.enterprise": "企业版",
"nav.zen": "Zen",
"nav.login": "登录",
"nav.free": "免费",
"nav.free": "下载",
"nav.home": "首页",
"nav.openMenu": "打开菜单",
"nav.getStartedFree": "免费开始",
@@ -542,6 +542,13 @@ export const dict = {
"workspace.monthlyLimit.currentUsage.beforeMonth": "当前",
"workspace.monthlyLimit.currentUsage.beforeAmount": "的使用量为 $",
"workspace.redeem.title": "兑换优惠券",
"workspace.redeem.subtitle": "兑换优惠码以领取充值额度或权益。",
"workspace.redeem.placeholder": "输入优惠码",
"workspace.redeem.redeem": "兑换",
"workspace.redeem.redeeming": "兑换中...",
"workspace.redeem.success": "优惠券兑换成功。",
"workspace.reload.title": "自动充值",
"workspace.reload.disabled.before": "自动充值已",
"workspace.reload.disabled.state": "禁用",

View File

@@ -11,7 +11,7 @@ export const dict = {
"nav.enterprise": "企業",
"nav.zen": "Zen",
"nav.login": "登入",
"nav.free": "免費",
"nav.free": "下載",
"nav.home": "首頁",
"nav.openMenu": "開啟選單",
"nav.getStartedFree": "免費開始使用",
@@ -542,6 +542,13 @@ export const dict = {
"workspace.monthlyLimit.currentUsage.beforeMonth": "目前",
"workspace.monthlyLimit.currentUsage.beforeAmount": "的使用量為 $",
"workspace.redeem.title": "兌換優惠券",
"workspace.redeem.subtitle": "兌換優惠碼以領取儲值額度或權益。",
"workspace.redeem.placeholder": "輸入優惠碼",
"workspace.redeem.redeem": "兌換",
"workspace.redeem.redeeming": "兌換中...",
"workspace.redeem.success": "優惠券兌換成功。",
"workspace.reload.title": "自動儲值",
"workspace.reload.disabled.before": "自動儲值已",
"workspace.reload.disabled.state": "停用",

View File

@@ -1,5 +1,6 @@
import type { APIEvent } from "@solidjs/start/server"
import { AWS } from "@opencode-ai/console-core/aws.js"
import { Resource } from "@opencode-ai/console-resource"
import { i18n } from "~/i18n"
import { localeFromRequest } from "~/lib/language"
import { createLead } from "~/lib/salesforce"
@@ -14,6 +15,64 @@ interface EnterpriseFormData {
message: string
}
const EMAIL_OCTOPUS_LIST_ID = "1b381e5e-39bd-11f1-ba4a-cdd4791f0c43"
function splitFullName(fullName: string) {
const parts = fullName
.trim()
.split(/\s+/)
.filter((p) => p.length > 0)
if (parts.length === 0) return { firstName: "", lastName: "" }
if (parts.length === 1) return { firstName: parts[0], lastName: "" }
return { firstName: parts[0], lastName: parts.slice(1).join(" ") }
}
function getEmailOctopusApiKey() {
if (process.env.EMAILOCTOPUS_API_KEY) return process.env.EMAILOCTOPUS_API_KEY
try {
return Resource.EMAILOCTOPUS_API_KEY.value
} catch {
return
}
}
function subscribe(email: string, fullName: string) {
const apiKey = getEmailOctopusApiKey()
if (!apiKey) {
console.warn("Skipping EmailOctopus subscribe: missing API key")
return Promise.resolve(false)
}
const name = splitFullName(fullName)
const fields: Record<string, string> = {}
if (name.firstName) fields.FirstName = name.firstName
if (name.lastName) fields.LastName = name.lastName
const payload: { email_address: string; fields?: Record<string, string> } = { email_address: email }
if (Object.keys(fields).length) payload.fields = fields
return fetch(`https://api.emailoctopus.com/lists/${EMAIL_OCTOPUS_LIST_ID}/contacts`, {
method: "PUT",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
}).then(
(res) => {
if (!res.ok) {
console.error("EmailOctopus subscribe failed:", res.status, res.statusText)
return false
}
return true
},
(err) => {
console.error("Failed to subscribe enterprise email:", err)
return false
},
)
}
export async function POST(event: APIEvent) {
const dict = i18n(localeFromRequest(event.request))
try {
@@ -41,7 +100,7 @@ ${body.role}<br>
${body.company ? `${body.company}<br>` : ""}${body.email}<br>
${body.phone ? `${body.phone}<br>` : ""}`.trim()
const [lead, mail] = await Promise.all([
const [lead, mail, octopus] = await Promise.all([
createLead({
name: body.name,
role: body.role,
@@ -49,6 +108,9 @@ ${body.phone ? `${body.phone}<br>` : ""}`.trim()
email: body.email,
phone: body.phone,
message: body.message,
}).catch((err) => {
console.error("Failed to create Salesforce lead:", err)
return false
}),
AWS.sendEmail({
to: "contact@anoma.ly",
@@ -62,9 +124,14 @@ ${body.phone ? `${body.phone}<br>` : ""}`.trim()
return false
},
),
subscribe(body.email, body.name),
])
if (!lead && !mail) {
if (!lead && !mail && !octopus) {
if (import.meta.env.DEV) {
console.warn("Enterprise inquiry accepted in dev mode without integrations", { email: body.email })
return Response.json({ success: true, message: dict["enterprise.form.success.submitted"] }, { status: 200 })
}
console.error("Enterprise inquiry delivery failed", { email: body.email })
return Response.json({ error: dict["enterprise.form.error.internalServer"] }, { status: 500 })
}

View File

@@ -9,6 +9,7 @@ import { Actor } from "@opencode-ai/console-core/actor.js"
import { Resource } from "@opencode-ai/console-resource"
import { LiteData } from "@opencode-ai/console-core/lite.js"
import { BlackData } from "@opencode-ai/console-core/black.js"
import { User } from "@opencode-ai/console-core/user.js"
export async function POST(input: APIEvent) {
const body = await Billing.stripe().webhooks.constructEventAsync(
@@ -109,6 +110,8 @@ export async function POST(input: APIEvent) {
if (type === "lite") {
const workspaceID = body.data.object.metadata?.workspaceID
const userID = body.data.object.metadata?.userID
const userEmail = body.data.object.metadata?.userEmail
const coupon = body.data.object.metadata?.coupon
const customerID = body.data.object.customer as string
const invoiceID = body.data.object.latest_invoice as string
const subscriptionID = body.data.object.id as string
@@ -156,6 +159,10 @@ export async function POST(input: APIEvent) {
id: Identifier.create("lite"),
userID: userID,
})
if (userEmail && coupon === LiteData.firstMonth100Coupon) {
await Billing.redeemCoupon(userEmail, "GOFREEMONTH")
}
})
})
}

View File

@@ -3,6 +3,7 @@ import { BillingSection } from "./billing-section"
import { ReloadSection } from "./reload-section"
import { PaymentSection } from "./payment-section"
import { BlackSection } from "./black-section"
import { RedeemSection } from "./redeem-section"
import { createMemo, Show } from "solid-js"
import { createAsync, useParams } from "@solidjs/router"
import { queryBillingInfo, querySessionInfo } from "../../common"
@@ -21,6 +22,7 @@ export default function () {
<BlackSection />
</Show>
<BillingSection />
<RedeemSection />
<Show when={billingInfo()?.customerID}>
<ReloadSection />
<MonthlyLimitSection />

View File

@@ -0,0 +1,61 @@
.root {
[data-slot="redeem-container"] {
display: flex;
flex-direction: column;
gap: var(--space-3);
min-width: 20rem;
width: fit-content;
@media (max-width: 30rem) {
width: 100%;
}
}
[data-slot="redeem-form"] {
display: flex;
flex-direction: column;
gap: var(--space-2);
[data-slot="input-row"] {
display: flex;
gap: var(--space-2);
align-items: stretch;
@media (max-width: 30rem) {
flex-direction: column;
}
}
input {
flex: 1;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
background-color: var(--color-bg);
color: var(--color-text);
font-size: var(--font-size-sm);
font-family: var(--font-mono);
&:focus {
outline: none;
border-color: var(--color-accent);
}
&::placeholder {
color: var(--color-text-disabled);
}
}
[data-slot="form-error"] {
color: var(--color-danger);
font-size: var(--font-size-sm);
line-height: 1.4;
}
[data-slot="form-success"] {
color: var(--color-success, var(--color-accent));
font-size: var(--font-size-sm);
line-height: 1.4;
}
}
}

View File

@@ -0,0 +1,71 @@
import { json, action, useParams, useSubmission } from "@solidjs/router"
import { Show } from "solid-js"
import { withActor } from "~/context/auth.withActor"
import { Billing } from "@opencode-ai/console-core/billing.js"
import { User } from "@opencode-ai/console-core/user.js"
import { Actor } from "@opencode-ai/console-core/actor.js"
import { CouponType } from "@opencode-ai/console-core/schema/billing.sql.js"
import styles from "./redeem-section.module.css"
import { queryBillingInfo } from "../../common"
import { useI18n } from "~/context/i18n"
import { formError, localizeError } from "~/lib/form-error"
const redeem = action(async (form: FormData) => {
"use server"
const workspaceID = form.get("workspaceID") as string | null
if (!workspaceID) return { error: formError.workspaceRequired }
const code = (form.get("code") as string | null)?.trim().toUpperCase()
if (!code) return { error: "Coupon code is required." }
if (!(CouponType as readonly string[]).includes(code)) return { error: "Invalid coupon code." }
return json(
await withActor(async () => {
const actor = Actor.assert("user")
const email = await User.getAuthEmail(actor.properties.userID)
if (!email) return { error: "No email on account." }
return Billing.redeemCoupon(email, code as (typeof CouponType)[number])
.then(() => ({ error: undefined, data: true }))
.catch((e) => ({ error: e.message as string }))
}, workspaceID),
{ revalidate: queryBillingInfo.key },
)
}, "billing.redeemCoupon")
export function RedeemSection() {
const params = useParams()
const i18n = useI18n()
const submission = useSubmission(redeem)
return (
<section class={styles.root}>
<div data-slot="section-title">
<h2>{i18n.t("workspace.redeem.title")}</h2>
<p>{i18n.t("workspace.redeem.subtitle")}</p>
</div>
<div data-slot="redeem-container">
<form action={redeem} method="post" data-slot="redeem-form">
<div data-slot="input-row">
<input
required
data-component="input"
name="code"
type="text"
autocomplete="off"
placeholder={i18n.t("workspace.redeem.placeholder")}
/>
<button type="submit" data-color="primary" disabled={submission.pending}>
{submission.pending ? i18n.t("workspace.redeem.redeeming") : i18n.t("workspace.redeem.redeem")}
</button>
</div>
<Show when={submission.result && (submission.result as any).error}>
{(err: any) => <div data-slot="form-error">{localizeError(i18n.t, err())}</div>}
</Show>
<Show when={submission.result && !(submission.result as any).error && (submission.result as any).data}>
<div data-slot="form-success">{i18n.t("workspace.redeem.success")}</div>
</Show>
<input type="hidden" name="workspaceID" value={params.id} />
</form>
</div>
</section>
)
}

View File

@@ -762,7 +762,8 @@ export async function handler(
const billing = authInfo.billing
const billingUrl = `https://opencode.ai/workspace/${authInfo.workspaceID}/billing`
const membersUrl = `https://opencode.ai/workspace/${authInfo.workspaceID}/members`
if (!billing.paymentMethodID) throw new CreditsError(t("zen.api.error.noPaymentMethod", { billingUrl }))
if (!billing.paymentMethodID && billing.balance <= 0)
throw new CreditsError(t("zen.api.error.noPaymentMethod", { billingUrl }))
if (billing.balance <= 0) throw new CreditsError(t("zen.api.error.insufficientBalance", { billingUrl }))
const now = new Date()

View File

@@ -0,0 +1,6 @@
CREATE TABLE `coupon` (
`email` varchar(255),
`type` enum('BUILDATHON','GOFREEMONTH') NOT NULL,
`time_redeemed` timestamp(3),
CONSTRAINT PRIMARY KEY(`email`,`type`)
);

View File

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

View File

@@ -0,0 +1,24 @@
import { Database } from "../src/drizzle/index.js"
import { CouponTable, CouponType } from "../src/schema/billing.sql.js"
const email = process.argv[2]
const type = process.argv[3] as (typeof CouponType)[number]
if (!email || !type) {
console.error(`Usage: bun create-coupon.ts <email> <${CouponType.join("|")}>`)
process.exit(1)
}
if (!(CouponType as readonly string[]).includes(type)) {
console.error(`Error: type must be one of ${CouponType.join(", ")}`)
process.exit(1)
}
await Database.use((tx) =>
tx.insert(CouponTable).values({
email,
type,
}),
)
console.log(`Created ${type} coupon for ${email}`)

View File

@@ -1,6 +1,14 @@
import { Stripe } from "stripe"
import { Database, eq, sql } from "./drizzle"
import { BillingTable, LiteTable, PaymentTable, SubscriptionTable, UsageTable } from "./schema/billing.sql"
import { and, Database, eq, isNull, sql } from "./drizzle"
import {
BillingTable,
CouponTable,
CouponType,
LiteTable,
PaymentTable,
SubscriptionTable,
UsageTable,
} from "./schema/billing.sql"
import { Actor } from "./actor"
import { fn } from "./util/fn"
import { z } from "zod"
@@ -147,6 +155,37 @@ export namespace Billing {
return amountInMicroCents
}
export const redeemCoupon = async (email: string, type: (typeof CouponType)[number]) => {
const coupon = await Database.use((tx) =>
tx
.select()
.from(CouponTable)
.where(and(eq(CouponTable.email, email), eq(CouponTable.type, type)))
.then((rows) => rows[0]),
)
if (!coupon) throw new Error("Invalid coupon code")
if (coupon.timeRedeemed) throw new Error("Coupon already redeemed")
if (type === "BUILDATHON") await grantCredit(Actor.workspace(), 500)
await Database.use((tx) =>
tx
.update(CouponTable)
.set({ timeRedeemed: sql`now()` })
.where(and(eq(CouponTable.email, email), eq(CouponTable.type, type))),
)
}
export const hasCoupon = async (email: string, type: (typeof CouponType)[number]) => {
return await Database.use((tx) =>
tx
.select()
.from(CouponTable)
.where(and(eq(CouponTable.email, email), eq(CouponTable.type, type), isNull(CouponTable.timeRedeemed)))
.then((rows) => rows.length > 0),
)
}
export const setMonthlyLimit = fn(z.number(), async (input) => {
return await Database.use((tx) =>
tx
@@ -245,16 +284,19 @@ export namespace Billing {
const user = Actor.assert("user")
const { successUrl, cancelUrl, method } = input
const email = await User.getAuthEmail(user.properties.userID)
const email = (await User.getAuthEmail(user.properties.userID))!
const billing = await Billing.get()
if (billing.subscriptionID) throw new Error("Already subscribed to Black")
if (billing.liteSubscriptionID) throw new Error("Already subscribed to Lite")
const coupon = (await Billing.hasCoupon(email, "GOFREEMONTH"))
? LiteData.firstMonth100Coupon
: LiteData.firstMonth50Coupon
const createSession = () =>
Billing.stripe().checkout.sessions.create({
mode: "subscription",
discounts: [{ coupon: LiteData.firstMonthCoupon(email!) }],
discounts: [{ coupon }],
...(billing.customerID
? {
customer: billing.customerID,
@@ -264,7 +306,7 @@ export namespace Billing {
},
}
: {
customer_email: email!,
customer_email: email,
}),
...(() => {
if (method === "alipay") {
@@ -312,6 +354,8 @@ export namespace Billing {
metadata: {
workspaceID: Actor.workspace(),
userID: user.properties.userID,
userEmail: email,
coupon,
type: "lite",
},
},

View File

@@ -11,11 +11,7 @@ export namespace LiteData {
export const productID = fn(z.void(), () => Resource.ZEN_LITE_PRICE.product)
export const priceID = fn(z.void(), () => Resource.ZEN_LITE_PRICE.price)
export const priceInr = fn(z.void(), () => Resource.ZEN_LITE_PRICE.priceInr)
export const firstMonthCoupon = fn(z.string(), (email) => {
const invitees = Resource.ZEN_LITE_COUPON_FIRST_MONTH_100_INVITEES.value.split(",")
return invitees.includes(email)
? Resource.ZEN_LITE_PRICE.firstMonth100Coupon
: Resource.ZEN_LITE_PRICE.firstMonth50Coupon
})
export const firstMonth100Coupon = Resource.ZEN_LITE_PRICE.firstMonth100Coupon
export const firstMonth50Coupon = Resource.ZEN_LITE_PRICE.firstMonth50Coupon
export const planName = fn(z.void(), () => "lite")
}

View File

@@ -1,4 +1,15 @@
import { bigint, boolean, index, int, json, mysqlEnum, mysqlTable, uniqueIndex, varchar } from "drizzle-orm/mysql-core"
import {
bigint,
boolean,
index,
int,
json,
mysqlEnum,
mysqlTable,
primaryKey,
uniqueIndex,
varchar,
} from "drizzle-orm/mysql-core"
import { timestamps, ulid, utc, workspaceColumns } from "../drizzle/types"
import { workspaceIndexes } from "./workspace.sql"
@@ -121,3 +132,14 @@ export const UsageTable = mysqlTable(
},
(table) => [...workspaceIndexes(table), index("usage_time_created").on(table.workspaceID, table.timeCreated)],
)
export const CouponType = ["BUILDATHON", "GOFREEMONTH"] as const
export const CouponTable = mysqlTable(
"coupon",
{
email: varchar("email", { length: 255 }),
type: mysqlEnum("type", CouponType).notNull(),
timeRedeemed: utc("time_redeemed"),
},
(table) => [primaryKey({ columns: [table.email, table.type] })],
)

View File

@@ -142,10 +142,6 @@ declare module "sst" {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_LITE_COUPON_FIRST_MONTH_100_INVITEES": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_LITE_PRICE": {
"firstMonth100Coupon": string
"firstMonth50Coupon": string

View File

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

View File

@@ -142,10 +142,6 @@ declare module "sst" {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_LITE_COUPON_FIRST_MONTH_100_INVITEES": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_LITE_PRICE": {
"firstMonth100Coupon": string
"firstMonth50Coupon": string

View File

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

View File

@@ -142,10 +142,6 @@ declare module "sst" {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_LITE_COUPON_FIRST_MONTH_100_INVITEES": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_LITE_PRICE": {
"firstMonth100Coupon": string
"firstMonth50Coupon": string

View File

@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop-electron",
"private": true,
"version": "1.4.11",
"version": "1.14.18",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
@@ -30,6 +30,7 @@
"electron-store": "^10",
"electron-updater": "^6",
"electron-window-state": "^5.0.3",
"drizzle-orm": "catalog:",
"marked": "^15"
},
"devDependencies": {
@@ -45,7 +46,7 @@
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"@valibot/to-json-schema": "1.6.0",
"electron": "40.4.1",
"electron": "41.2.1",
"electron-builder": "^26",
"electron-vite": "^5",
"solid-js": "catalog:",

View File

@@ -10,6 +10,10 @@ declare module "virtual:opencode-server" {
export const listen: typeof import("../../../opencode/dist/types/src/node").Server.listen
export type Listener = import("../../../opencode/dist/types/src/node").Server.Listener
}
export namespace PushRelay {
export const start: typeof import("../../../opencode/dist/types/src/node").PushRelay.start
export const stop: typeof import("../../../opencode/dist/types/src/node").PushRelay.stop
}
export namespace Config {
export const get: typeof import("../../../opencode/dist/types/src/node").Config.get
export type Info = import("../../../opencode/dist/types/src/node").Config.Info

View File

@@ -28,8 +28,10 @@ const APP_IDS: Record<string, string> = {
beta: "ai.opencode.desktop.beta",
prod: "ai.opencode.desktop",
}
const appId = app.isPackaged ? APP_IDS[CHANNEL] : "ai.opencode.desktop.dev"
app.setName(app.isPackaged ? APP_NAMES[CHANNEL] : "OpenCode Dev")
app.setPath("userData", join(app.getPath("appData"), app.isPackaged ? APP_IDS[CHANNEL] : "ai.opencode.desktop.dev"))
app.setAppUserModelId(appId)
app.setPath("userData", join(app.getPath("appData"), appId))
const { autoUpdater } = pkg
import type { InitStep, ServerReadyData, SqliteMigrationProgress, WslConfig } from "../preload/types"
@@ -41,6 +43,7 @@ import { parseMarkdown } from "./markdown"
import { createMenu } from "./menu"
import { getDefaultServerUrl, getWslConfig, setDefaultServerUrl, setWslConfig, spawnLocalServer } from "./server"
import { createLoadingWindow, createMainWindow, setBackgroundColor, setDockIcon } from "./windows"
import { drizzle } from "drizzle-orm/node-sqlite/driver"
import type { Server } from "virtual:opencode-server"
const initEmitter = new EventEmitter()
@@ -137,15 +140,6 @@ async function initialize() {
const url = `http://${hostname}:${port}`
const password = randomUUID()
logger.log("spawning sidecar", { url })
const { listener, health } = await spawnLocalServer(hostname, port, password)
server = listener
serverReady.resolve({
url,
username: "opencode",
password,
})
const loadingTask = (async () => {
logger.log("sidecar connection started", { url })
@@ -156,10 +150,32 @@ async function initialize() {
if (progress.type === "Done") sqliteDone?.resolve()
})
if (needsMigration) {
const { Database, JsonMigration } = await import("virtual:opencode-server")
await JsonMigration.run(drizzle({ client: Database.Client().$client }), {
progress: (event: { current: number; total: number }) => {
const percent = Math.round(event.current / event.total) * 100
initEmitter.emit("sqlite", { type: "InProgress", value: percent })
},
})
initEmitter.emit("sqlite", { type: "Done" })
sqliteDone?.resolve()
}
if (needsMigration) {
await sqliteDone?.promise
}
logger.log("spawning sidecar", { url })
const { listener, health } = await spawnLocalServer(hostname, port, password)
server = listener
serverReady.resolve({
url,
username: "opencode",
password,
})
await Promise.race([
health.wait,
delay(30_000).then(() => {

View File

@@ -4,7 +4,7 @@ import { existsSync, readdirSync, readFileSync } from "node:fs"
import { homedir } from "node:os"
import { join } from "node:path"
import { CHANNEL } from "./constants"
import { getStore, store } from "./store"
import { getStore } from "./store"
const TAURI_MIGRATED_KEY = "tauriMigrated"
@@ -67,7 +67,7 @@ function migrateFile(datPath: string, filename: string) {
}
export function migrate() {
if (store.get(TAURI_MIGRATED_KEY)) {
if (getStore().get(TAURI_MIGRATED_KEY)) {
log.log("tauri migration: already done, skipping")
return
}
@@ -77,7 +77,7 @@ export function migrate() {
if (!existsSync(dir)) {
log.log("tauri migration: no tauri data directory found, nothing to migrate")
store.set(TAURI_MIGRATED_KEY, true)
getStore().set(TAURI_MIGRATED_KEY, true)
return
}
@@ -87,5 +87,5 @@ export function migrate() {
}
log.log("tauri migration: complete")
store.set(TAURI_MIGRATED_KEY, true)
getStore().set(TAURI_MIGRATED_KEY, true)
}

View File

@@ -1,38 +1,50 @@
import { randomBytes } from "node:crypto"
import { app } from "electron"
import { DEFAULT_SERVER_URL_KEY, WSL_ENABLED_KEY } from "./constants"
import { getUserShell, loadShellEnv } from "./shell-env"
import { store } from "./store"
import { getStore } from "./store"
const DEFAULT_RELAY_URL = "https://apn.dev.opencode.ai"
const RELAY_SECRET_KEY = "relaySecret"
function getOrCreateRelaySecret(): string {
const existing = getStore().get(RELAY_SECRET_KEY)
if (typeof existing === "string" && existing.length > 0) return existing
const secret = randomBytes(18).toString("base64url")
getStore().set(RELAY_SECRET_KEY, secret)
return secret
}
export type WslConfig = { enabled: boolean }
export type HealthCheck = { wait: Promise<void> }
export function getDefaultServerUrl(): string | null {
const value = store.get(DEFAULT_SERVER_URL_KEY)
const value = getStore().get(DEFAULT_SERVER_URL_KEY)
return typeof value === "string" ? value : null
}
export function setDefaultServerUrl(url: string | null) {
if (url) {
store.set(DEFAULT_SERVER_URL_KEY, url)
getStore().set(DEFAULT_SERVER_URL_KEY, url)
return
}
store.delete(DEFAULT_SERVER_URL_KEY)
getStore().delete(DEFAULT_SERVER_URL_KEY)
}
export function getWslConfig(): WslConfig {
const value = store.get(WSL_ENABLED_KEY)
const value = getStore().get(WSL_ENABLED_KEY)
return { enabled: typeof value === "boolean" ? value : false }
}
export function setWslConfig(config: WslConfig) {
store.set(WSL_ENABLED_KEY, config.enabled)
getStore().set(WSL_ENABLED_KEY, config.enabled)
}
export async function spawnLocalServer(hostname: string, port: number, password: string) {
prepareServerEnv(password)
const { Log, Server } = await import("virtual:opencode-server")
const { Log, Server, PushRelay } = await import("virtual:opencode-server")
await Log.init({ level: "WARN" })
const listener = await Server.listen({
port,
@@ -41,6 +53,18 @@ export async function spawnLocalServer(hostname: string, port: number, password:
password,
})
const relayURL = (process.env.OPENCODE_EXPERIMENTAL_PUSH_RELAY_URL ?? DEFAULT_RELAY_URL).trim()
const relaySecretInput = (process.env.OPENCODE_EXPERIMENTAL_PUSH_RELAY_SECRET ?? "").trim()
const relaySecret = relaySecretInput || getOrCreateRelaySecret()
if (relayURL && relaySecret) {
PushRelay.start({
relayURL,
relaySecret,
hostname,
port: listener.port,
})
}
const wait = (async () => {
const url = `http://${hostname}:${port}`

View File

@@ -4,6 +4,10 @@ import { SETTINGS_STORE } from "./constants"
const cache = new Map<string, Store>()
// We cannot instantiate the electron-store at module load time because
// module import hoisting causes this to run before app.setPath("userData", ...)
// in index.ts has executed, which would result in files being written to the default directory
// (e.g. bad: %APPDATA%\@opencode-ai\desktop-electron\opencode.settings vs good: %APPDATA%\ai.opencode.desktop.dev\opencode.settings).
export function getStore(name = SETTINGS_STORE) {
const cached = cache.get(name)
if (cached) return cached
@@ -11,5 +15,3 @@ export function getStore(name = SETTINGS_STORE) {
cache.set(name, next)
return next
}
export const store = getStore(SETTINGS_STORE)

View File

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

View File

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

View File

@@ -142,10 +142,6 @@ declare module "sst" {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_LITE_COUPON_FIRST_MONTH_100_INVITEES": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_LITE_PRICE": {
"firstMonth100Coupon": string
"firstMonth50Coupon": string

View File

@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.4.11"
version = "1.14.18"
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.4.11/opencode-darwin-arm64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.18/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.11/opencode-darwin-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.18/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.11/opencode-linux-arm64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.18/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.4.11/opencode-linux-x64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.18/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.4.11/opencode-windows-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.18/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]

View File

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

View File

@@ -142,10 +142,6 @@ declare module "sst" {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_LITE_COUPON_FIRST_MONTH_100_INVITEES": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_LITE_PRICE": {
"firstMonth100Coupon": string
"firstMonth50Coupon": string

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,183 @@
# 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.
- Dev build (internal/dev client):
- bunx eas build --profile development --platform ios
- Production build + auto-submit:
- bunx eas build --profile production --platform ios --auto-submit

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